feat: 合并上游 Rust 实现,扩展 API/运行时/工具链能力

将 claw-code/rust/crates 的完整实现合并到主 workspace,涵盖
  9 个 crate 的更新与 2 个新 crate 的引入。

  API 层:
  - 用原生 Anthropic 客户端(anthropic.rs)替换 claw_provider,
    新增 prompt cache 减少重复请求开销
  - 新增 HTTP 客户端构建器统一代理配置,OpenAI 兼容端增加
    DashScope/Qwen 支持与抖动重试
  - MessageRequest 扩展 temperature/top_p 等模型调参字段
  - SSE 解析器增加 provider 上下文感知的错误信息

  运行时(~11,000 行新增):
  - 新增 bash 命令安全校验、分支锁碰撞检测、配置文件校验
  - 新增会话存储与控制面、MCP 生命周期状态机与服务端实现
  - 新增权限执行引擎、策略引擎、插件生命周期管理
  - 新增 worker 启动编排、任务/定时任务注册表、信任解析器
  - 保留 Windows cmd /C fallback

  命令/插件/工具:
  - commands 大幅重写,扩展 sandbox、doctor、plan 等 slash 命令
  - plugins 新增 PostToolUseFailure hook 与宽容加载机制
  - tools 新增 PDF 提取与 lane 补全工具

  新增 crate:mock-anthropic-service(测试)、telemetry(遥测)

  适配 claw-cli/server:ClawApiClient→AnthropicClient 重命名,
  SlashCommand::parse 返回 Result,移除 session 级 Thinking 变体,
  TokenUsage/ConversationMessage 补充序列化支持
This commit is contained in:
fengmengqi
2026-04-13 14:39:17 +08:00
parent 4a04faf926
commit d8d77824f4
94 changed files with 49049 additions and 4429 deletions
+3 -2
View File
@@ -7,13 +7,14 @@ publish.workspace = true
[dependencies]
sha2 = "0.10"
which = "7"
glob = "0.3"
lsp = { path = "../lsp" }
plugins = { path = "../plugins" }
regex = "1"
serde = { version = "1", features = ["derive"] }
serde_json.workspace = true
tokio = { version = "1", features = ["io-util", "macros", "process", "rt", "rt-multi-thread", "time"] }
telemetry = { path = "../telemetry" }
tokio = { version = "1", features = ["io-std", "io-util", "macros", "process", "rt", "rt-multi-thread", "time"] }
walkdir = "2"
[lints]
+77 -30
View File
@@ -14,6 +14,7 @@ use crate::sandbox::{
};
use crate::ConfigLoader;
/// Input schema for the built-in bash execution tool.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct BashCommandInput {
pub command: String,
@@ -33,6 +34,7 @@ pub struct BashCommandInput {
pub allowed_mounts: Option<Vec<String>>,
}
/// Output returned from a bash tool invocation.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct BashCommandOutput {
pub stdout: String,
@@ -64,6 +66,7 @@ pub struct BashCommandOutput {
pub sandbox_status: Option<SandboxStatus>,
}
/// Executes a shell command with the requested sandbox settings.
pub fn execute_bash(input: BashCommandInput) -> io::Result<BashCommandOutput> {
let cwd = env::current_dir()?;
let sandbox_status = sandbox_status_for_input(&input, &cwd);
@@ -134,8 +137,8 @@ async fn execute_bash_async(
};
let (output, interrupted) = output_result;
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
let stdout = truncate_output(&String::from_utf8_lossy(&output.stdout));
let stderr = truncate_output(&String::from_utf8_lossy(&output.stderr));
let no_output_expected = Some(stdout.trim().is_empty() && stderr.trim().is_empty());
let return_code_interpretation = output.status.code().and_then(|code| {
if code == 0 {
@@ -197,36 +200,31 @@ fn prepare_command(
return prepared;
}
let mut prepared = if cfg!(target_os = "windows") && !sh_exists() {
let prepared = if cfg!(target_os = "windows") && !sh_exists() {
let mut p = Command::new("cmd");
p.arg("/C").arg(command);
p.arg("/C").arg(command).current_dir(cwd);
p
} else {
let mut p = Command::new("sh");
p.arg("-lc").arg(command);
p.arg("-lc").arg(command).current_dir(cwd);
if sandbox_status.filesystem_active {
p.env("HOME", cwd.join(".sandbox-home"));
p.env("TMPDIR", cwd.join(".sandbox-tmp"));
}
p
};
prepared.current_dir(cwd);
if sandbox_status.filesystem_active {
prepared.env("HOME", cwd.join(".sandbox-home"));
prepared.env("TMPDIR", cwd.join(".sandbox-tmp"));
}
prepared
}
fn sh_exists() -> bool {
env::var_os("PATH").is_some_and(|paths| {
env::split_paths(&paths).any(|path| {
#[cfg(windows)]
{
path.join("sh.exe").exists() || path.join("sh.bat").exists() || path.join("sh").exists()
}
#[cfg(not(windows))]
{
path.join("sh").exists()
}
})
})
#[cfg(windows)]
{
which::which("sh").is_ok()
}
#[cfg(not(windows))]
{
true
}
}
fn prepare_tokio_command(
@@ -247,20 +245,19 @@ fn prepare_tokio_command(
return prepared;
}
let mut prepared = if cfg!(target_os = "windows") && !sh_exists() {
let prepared = if cfg!(target_os = "windows") && !sh_exists() {
let mut p = TokioCommand::new("cmd");
p.arg("/C").arg(command);
p.arg("/C").arg(command).current_dir(cwd);
p
} else {
let mut p = TokioCommand::new("sh");
p.arg("-lc").arg(command);
p.arg("-lc").arg(command).current_dir(cwd);
if sandbox_status.filesystem_active {
p.env("HOME", cwd.join(".sandbox-home"));
p.env("TMPDIR", cwd.join(".sandbox-tmp"));
}
p
};
prepared.current_dir(cwd);
if sandbox_status.filesystem_active {
prepared.env("HOME", cwd.join(".sandbox-home"));
prepared.env("TMPDIR", cwd.join(".sandbox-tmp"));
}
prepared
}
@@ -312,3 +309,53 @@ mod tests {
assert!(!output.sandbox_status.expect("sandbox status").enabled);
}
}
/// Maximum output bytes before truncation (16 KiB, matching upstream).
const MAX_OUTPUT_BYTES: usize = 16_384;
/// Truncate output to `MAX_OUTPUT_BYTES`, appending a marker when trimmed.
fn truncate_output(s: &str) -> String {
if s.len() <= MAX_OUTPUT_BYTES {
return s.to_string();
}
// Find the last valid UTF-8 boundary at or before MAX_OUTPUT_BYTES
let mut end = MAX_OUTPUT_BYTES;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
let mut truncated = s[..end].to_string();
truncated.push_str("\n\n[output truncated — exceeded 16384 bytes]");
truncated
}
#[cfg(test)]
mod truncation_tests {
use super::*;
#[test]
fn short_output_unchanged() {
let s = "hello world";
assert_eq!(truncate_output(s), s);
}
#[test]
fn long_output_truncated() {
let s = "x".repeat(20_000);
let result = truncate_output(&s);
assert!(result.len() < 20_000);
assert!(result.ends_with("[output truncated — exceeded 16384 bytes]"));
}
#[test]
fn exact_boundary_unchanged() {
let s = "a".repeat(MAX_OUTPUT_BYTES);
assert_eq!(truncate_output(&s), s);
}
#[test]
fn one_over_boundary_truncated() {
let s = "a".repeat(MAX_OUTPUT_BYTES + 1);
let result = truncate_output(&s);
assert!(result.contains("[output truncated"));
}
}
File diff suppressed because it is too large Load Diff
+56 -1
View File
@@ -21,7 +21,7 @@ pub struct BootstrapPlan {
impl BootstrapPlan {
#[must_use]
pub fn claw_default() -> Self {
pub fn claude_code_default() -> Self {
Self::from_phases(vec![
BootstrapPhase::CliEntry,
BootstrapPhase::FastPathVersion,
@@ -54,3 +54,58 @@ impl BootstrapPlan {
&self.phases
}
}
#[cfg(test)]
mod tests {
use super::{BootstrapPhase, BootstrapPlan};
#[test]
fn from_phases_deduplicates_while_preserving_order() {
// given
let phases = vec![
BootstrapPhase::CliEntry,
BootstrapPhase::FastPathVersion,
BootstrapPhase::CliEntry,
BootstrapPhase::MainRuntime,
BootstrapPhase::FastPathVersion,
];
// when
let plan = BootstrapPlan::from_phases(phases);
// then
assert_eq!(
plan.phases(),
&[
BootstrapPhase::CliEntry,
BootstrapPhase::FastPathVersion,
BootstrapPhase::MainRuntime,
]
);
}
#[test]
fn claude_code_default_covers_each_phase_once() {
// given
let expected = [
BootstrapPhase::CliEntry,
BootstrapPhase::FastPathVersion,
BootstrapPhase::StartupProfiler,
BootstrapPhase::SystemPromptFastPath,
BootstrapPhase::ChromeMcpFastPath,
BootstrapPhase::DaemonWorkerFastPath,
BootstrapPhase::BridgeFastPath,
BootstrapPhase::DaemonFastPath,
BootstrapPhase::BackgroundSessionFastPath,
BootstrapPhase::TemplateFastPath,
BootstrapPhase::EnvironmentRunnerFastPath,
BootstrapPhase::MainRuntime,
];
// when
let plan = BootstrapPlan::claude_code_default();
// then
assert_eq!(plan.phases(), &expected);
}
}
+144
View File
@@ -0,0 +1,144 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BranchLockIntent {
#[serde(rename = "laneId")]
pub lane_id: String,
pub branch: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub worktree: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub modules: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BranchLockCollision {
pub branch: String,
pub module: String,
#[serde(rename = "laneIds")]
pub lane_ids: Vec<String>,
}
#[must_use]
pub fn detect_branch_lock_collisions(intents: &[BranchLockIntent]) -> Vec<BranchLockCollision> {
let mut collisions = Vec::new();
for (index, left) in intents.iter().enumerate() {
for right in &intents[index + 1..] {
if left.branch != right.branch {
continue;
}
for module in overlapping_modules(&left.modules, &right.modules) {
collisions.push(BranchLockCollision {
branch: left.branch.clone(),
module,
lane_ids: vec![left.lane_id.clone(), right.lane_id.clone()],
});
}
}
}
collisions.sort_by(|a, b| {
a.branch
.cmp(&b.branch)
.then(a.module.cmp(&b.module))
.then(a.lane_ids.cmp(&b.lane_ids))
});
collisions.dedup();
collisions
}
fn overlapping_modules(left: &[String], right: &[String]) -> Vec<String> {
let mut overlaps = Vec::new();
for left_module in left {
for right_module in right {
if modules_overlap(left_module, right_module) {
overlaps.push(shared_scope(left_module, right_module));
}
}
}
overlaps.sort();
overlaps.dedup();
overlaps
}
fn modules_overlap(left: &str, right: &str) -> bool {
left == right
|| left.starts_with(&format!("{right}/"))
|| right.starts_with(&format!("{left}/"))
}
fn shared_scope(left: &str, right: &str) -> String {
if left.starts_with(&format!("{right}/")) || left == right {
right.to_string()
} else {
left.to_string()
}
}
#[cfg(test)]
mod tests {
use super::{detect_branch_lock_collisions, BranchLockIntent};
#[test]
fn detects_same_branch_same_module_collisions() {
let collisions = detect_branch_lock_collisions(&[
BranchLockIntent {
lane_id: "lane-a".to_string(),
branch: "feature/lock".to_string(),
worktree: Some("wt-a".to_string()),
modules: vec!["runtime/mcp".to_string()],
},
BranchLockIntent {
lane_id: "lane-b".to_string(),
branch: "feature/lock".to_string(),
worktree: Some("wt-b".to_string()),
modules: vec!["runtime/mcp".to_string()],
},
]);
assert_eq!(collisions.len(), 1);
assert_eq!(collisions[0].branch, "feature/lock");
assert_eq!(collisions[0].module, "runtime/mcp");
}
#[test]
fn detects_nested_module_scope_collisions() {
let collisions = detect_branch_lock_collisions(&[
BranchLockIntent {
lane_id: "lane-a".to_string(),
branch: "feature/lock".to_string(),
worktree: None,
modules: vec!["runtime".to_string()],
},
BranchLockIntent {
lane_id: "lane-b".to_string(),
branch: "feature/lock".to_string(),
worktree: None,
modules: vec!["runtime/mcp".to_string()],
},
]);
assert_eq!(collisions[0].module, "runtime");
}
#[test]
fn ignores_different_branches() {
let collisions = detect_branch_lock_collisions(&[
BranchLockIntent {
lane_id: "lane-a".to_string(),
branch: "feature/a".to_string(),
worktree: None,
modules: vec!["runtime/mcp".to_string()],
},
BranchLockIntent {
lane_id: "lane-b".to_string(),
branch: "feature/b".to_string(),
worktree: None,
modules: vec!["runtime/mcp".to_string()],
},
]);
assert!(collisions.is_empty());
}
}
+196 -81
View File
@@ -5,6 +5,7 @@ const COMPACT_CONTINUATION_PREAMBLE: &str =
const COMPACT_RECENT_MESSAGES_NOTE: &str = "Recent messages are preserved verbatim.";
const COMPACT_DIRECT_RESUME_INSTRUCTION: &str = "Continue the conversation from where it left off without asking the user any further questions. Resume directly — do not acknowledge the summary, do not recap what was happening, and do not preface with continuation text.";
/// Thresholds controlling when and how a session is compacted.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CompactionConfig {
pub preserve_recent_messages: usize,
@@ -20,6 +21,7 @@ impl Default for CompactionConfig {
}
}
/// Result of compacting a session into a summary plus preserved tail messages.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CompactionResult {
pub summary: String,
@@ -28,11 +30,13 @@ pub struct CompactionResult {
pub removed_message_count: usize,
}
/// Roughly estimates the token footprint of the current session transcript.
#[must_use]
pub fn estimate_session_tokens(session: &Session) -> usize {
session.messages.iter().map(estimate_message_tokens).sum()
}
/// Returns `true` when the session exceeds the configured compaction budget.
#[must_use]
pub fn should_compact(session: &Session, config: CompactionConfig) -> bool {
let start = compacted_summary_prefix_len(session);
@@ -46,6 +50,7 @@ pub fn should_compact(session: &Session, config: CompactionConfig) -> bool {
>= config.max_estimated_tokens
}
/// Normalizes a compaction summary into user-facing continuation text.
#[must_use]
pub fn format_compact_summary(summary: &str) -> String {
let without_analysis = strip_tag_block(summary, "analysis");
@@ -61,6 +66,7 @@ pub fn format_compact_summary(summary: &str) -> String {
collapse_blank_lines(&formatted).trim().to_string()
}
/// Builds the synthetic system message used after session compaction.
#[must_use]
pub fn get_compact_continuation_message(
summary: &str,
@@ -85,6 +91,7 @@ pub fn get_compact_continuation_message(
base
}
/// Compacts a session by summarizing older messages and preserving the recent tail.
#[must_use]
pub fn compact_session(session: &Session, config: CompactionConfig) -> CompactionResult {
if !should_compact(session, config) {
@@ -101,10 +108,55 @@ pub fn compact_session(session: &Session, config: CompactionConfig) -> Compactio
.first()
.and_then(extract_existing_compacted_summary);
let compacted_prefix_len = usize::from(existing_summary.is_some());
let keep_from = session
let raw_keep_from = session
.messages
.len()
.saturating_sub(config.preserve_recent_messages);
// Ensure we do not split a tool-use / tool-result pair at the compaction
// boundary. If the first preserved message is a user message whose first
// block is a ToolResult, the assistant message with the matching ToolUse
// was slated for removal — that produces an orphaned tool role message on
// the OpenAI-compat path (400: tool message must follow assistant with
// tool_calls). Walk the boundary back until we start at a safe point.
let keep_from = {
let mut k = raw_keep_from;
// If the first preserved message is a tool-result turn, ensure its
// paired assistant tool-use turn is preserved too. Without this fix,
// the OpenAI-compat adapter sends an orphaned 'tool' role message
// with no preceding assistant 'tool_calls', which providers reject
// with a 400. We walk back only if the immediately preceding message
// is NOT an assistant message that contains a ToolUse block (i.e. the
// pair is actually broken at the boundary).
loop {
if k == 0 || k <= compacted_prefix_len {
break;
}
let first_preserved = &session.messages[k];
let starts_with_tool_result = first_preserved
.blocks
.first()
.map(|b| matches!(b, ContentBlock::ToolResult { .. }))
.unwrap_or(false);
if !starts_with_tool_result {
break;
}
// Check the message just before the current boundary.
let preceding = &session.messages[k - 1];
let preceding_has_tool_use = preceding
.blocks
.iter()
.any(|b| matches!(b, ContentBlock::ToolUse { .. }));
if preceding_has_tool_use {
// Pair is intact — walk back one more to include the assistant turn.
k = k.saturating_sub(1);
break;
}
// Preceding message has no ToolUse but we have a ToolResult —
// this is already an orphaned pair; walk back to try to fix it.
k = k.saturating_sub(1);
}
k
};
let removed = &session.messages[compacted_prefix_len..keep_from];
let preserved = session.messages[keep_from..].to_vec();
let summary =
@@ -119,13 +171,14 @@ pub fn compact_session(session: &Session, config: CompactionConfig) -> Compactio
}];
compacted_messages.extend(preserved);
let mut compacted_session = session.clone();
compacted_session.messages = compacted_messages;
compacted_session.record_compaction(summary.clone(), removed.len());
CompactionResult {
summary,
formatted_summary,
compacted_session: Session {
version: session.version,
messages: compacted_messages,
},
compacted_session,
removed_message_count: removed.len(),
}
}
@@ -160,9 +213,7 @@ fn summarize_messages(messages: &[ConversationMessage]) -> String {
.filter_map(|block| match block {
ContentBlock::ToolUse { name, .. } => Some(name.as_str()),
ContentBlock::ToolResult { tool_name, .. } => Some(tool_name.as_str()),
ContentBlock::Text { .. }
| ContentBlock::Thinking { .. }
| ContentBlock::RedactedThinking { .. } => None,
ContentBlock::Text { .. } => None,
})
.collect::<Vec<_>>();
tool_names.sort_unstable();
@@ -277,8 +328,6 @@ fn summarize_block(block: &ContentBlock) -> String {
"tool_result {tool_name}: {}{output}",
if *is_error { "error " } else { "" }
),
ContentBlock::Thinking { thinking, .. } => format!("thinking: {thinking}"),
ContentBlock::RedactedThinking { .. } => "thinking: <redacted>".to_string(),
};
truncate_summary(&raw, 160)
}
@@ -328,8 +377,6 @@ fn collect_key_files(messages: &[ConversationMessage]) -> Vec<String> {
.flat_map(|message| message.blocks.iter())
.map(|block| match block {
ContentBlock::Text { text } => text.as_str(),
ContentBlock::Thinking { thinking, .. } => thinking.as_str(),
ContentBlock::RedactedThinking { .. } => "",
ContentBlock::ToolUse { input, .. } => input.as_str(),
ContentBlock::ToolResult { output, .. } => output.as_str(),
})
@@ -354,9 +401,7 @@ fn first_text_block(message: &ConversationMessage) -> Option<&str> {
ContentBlock::Text { text } if !text.trim().is_empty() => Some(text.as_str()),
ContentBlock::ToolUse { .. }
| ContentBlock::ToolResult { .. }
| ContentBlock::Text { .. }
| ContentBlock::Thinking { .. }
| ContentBlock::RedactedThinking { .. } => None,
| ContentBlock::Text { .. } => None,
})
}
@@ -402,8 +447,6 @@ fn estimate_message_tokens(message: &ConversationMessage) -> usize {
.iter()
.map(|block| match block {
ContentBlock::Text { text } => text.len() / 4 + 1,
ContentBlock::Thinking { thinking, .. } => thinking.len() / 4 + 1,
ContentBlock::RedactedThinking { .. } => 1,
ContentBlock::ToolUse { name, input, .. } => (name.len() + input.len()) / 4 + 1,
ContentBlock::ToolResult {
tool_name, output, ..
@@ -512,7 +555,7 @@ fn extract_summary_timeline(summary: &str) -> Vec<String> {
#[cfg(test)]
mod tests {
use super::{
collect_key_files, compact_session, estimate_session_tokens, format_compact_summary,
collect_key_files, compact_session, format_compact_summary,
get_compact_continuation_message, infer_pending_work, should_compact, CompactionConfig,
};
use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session};
@@ -525,10 +568,8 @@ mod tests {
#[test]
fn leaves_small_sessions_unchanged() {
let session = Session {
version: 1,
messages: vec![ConversationMessage::user_text("hello")],
};
let mut session = Session::new();
session.messages = vec![ConversationMessage::user_text("hello")];
let result = compact_session(&session, CompactionConfig::default());
assert_eq!(result.removed_message_count, 0);
@@ -539,23 +580,21 @@ mod tests {
#[test]
fn compacts_older_messages_into_a_system_summary() {
let session = Session {
version: 1,
messages: vec![
ConversationMessage::user_text("one ".repeat(200)),
ConversationMessage::assistant(vec![ContentBlock::Text {
text: "two ".repeat(200),
}]),
ConversationMessage::tool_result("1", "bash", "ok ".repeat(200), false),
ConversationMessage {
role: MessageRole::Assistant,
blocks: vec![ContentBlock::Text {
text: "recent".to_string(),
}],
usage: None,
},
],
};
let mut session = Session::new();
session.messages = vec![
ConversationMessage::user_text("one ".repeat(200)),
ConversationMessage::assistant(vec![ContentBlock::Text {
text: "two ".repeat(200),
}]),
ConversationMessage::tool_result("1", "bash", "ok ".repeat(200), false),
ConversationMessage {
role: MessageRole::Assistant,
blocks: vec![ContentBlock::Text {
text: "recent".to_string(),
}],
usage: None,
},
];
let result = compact_session(
&session,
@@ -565,7 +604,14 @@ mod tests {
},
);
assert_eq!(result.removed_message_count, 2);
// With the tool-use/tool-result boundary fix, the compaction preserves
// one extra message to avoid an orphaned tool result at the boundary.
// messages[1] (assistant) must be kept along with messages[2] (tool result).
assert!(
result.removed_message_count <= 2,
"expected at most 2 removed, got {}",
result.removed_message_count
);
assert_eq!(
result.compacted_session.messages[0].role,
MessageRole::System
@@ -583,28 +629,29 @@ mod tests {
max_estimated_tokens: 1,
}
));
// Note: with the tool-use/tool-result boundary guard the compacted session
// may preserve one extra message at the boundary, so token reduction is
// not guaranteed for small sessions. The invariant that matters is that
// the removed_message_count is non-zero (something was compacted).
assert!(
estimate_session_tokens(&result.compacted_session) < estimate_session_tokens(&session)
result.removed_message_count > 0,
"compaction must remove at least one message"
);
}
#[test]
fn keeps_previous_compacted_context_when_compacting_again() {
let initial_session = Session {
version: 1,
messages: vec![
ConversationMessage::user_text("Investigate rust/crates/runtime/src/compact.rs"),
ConversationMessage::assistant(vec![ContentBlock::Text {
text: "I will inspect the compact flow.".to_string(),
}]),
ConversationMessage::user_text(
"Also update rust/crates/runtime/src/conversation.rs",
),
ConversationMessage::assistant(vec![ContentBlock::Text {
text: "Next: preserve prior summary context during auto compact.".to_string(),
}]),
],
};
let mut initial_session = Session::new();
initial_session.messages = vec![
ConversationMessage::user_text("Investigate rust/crates/runtime/src/compact.rs"),
ConversationMessage::assistant(vec![ContentBlock::Text {
text: "I will inspect the compact flow.".to_string(),
}]),
ConversationMessage::user_text("Also update rust/crates/runtime/src/conversation.rs"),
ConversationMessage::assistant(vec![ContentBlock::Text {
text: "Next: preserve prior summary context during auto compact.".to_string(),
}]),
];
let config = CompactionConfig {
preserve_recent_messages: 2,
max_estimated_tokens: 1,
@@ -619,13 +666,9 @@ mod tests {
}]),
]);
let second = compact_session(
&Session {
version: 1,
messages: follow_up_messages,
},
config,
);
let mut second_session = Session::new();
second_session.messages = follow_up_messages;
let second = compact_session(&second_session, config);
assert!(second
.formatted_summary
@@ -654,22 +697,20 @@ mod tests {
#[test]
fn ignores_existing_compacted_summary_when_deciding_to_recompact() {
let summary = "<summary>Conversation summary:\n- Scope: earlier work preserved.\n- Key timeline:\n - user: large preserved context\n</summary>";
let session = Session {
version: 1,
messages: vec![
ConversationMessage {
role: MessageRole::System,
blocks: vec![ContentBlock::Text {
text: get_compact_continuation_message(summary, true, true),
}],
usage: None,
},
ConversationMessage::user_text("tiny"),
ConversationMessage::assistant(vec![ContentBlock::Text {
text: "recent".to_string(),
}]),
],
};
let mut session = Session::new();
session.messages = vec![
ConversationMessage {
role: MessageRole::System,
blocks: vec![ContentBlock::Text {
text: get_compact_continuation_message(summary, true, true),
}],
usage: None,
},
ConversationMessage::user_text("tiny"),
ConversationMessage::assistant(vec![ContentBlock::Text {
text: "recent".to_string(),
}]),
];
assert!(!should_compact(
&session,
@@ -692,10 +733,84 @@ mod tests {
#[test]
fn extracts_key_files_from_message_content() {
let files = collect_key_files(&[ConversationMessage::user_text(
"Update rust/crates/runtime/src/compact.rs and rust/crates/tools/src/lib.rs next.",
"Update rust/crates/runtime/src/compact.rs and rust/crates/rusty-claude-cli/src/main.rs next.",
)]);
assert!(files.contains(&"rust/crates/runtime/src/compact.rs".to_string()));
assert!(files.contains(&"rust/crates/tools/src/lib.rs".to_string()));
assert!(files.contains(&"rust/crates/rusty-claude-cli/src/main.rs".to_string()));
}
/// Regression: compaction must not split an assistant(ToolUse) /
/// user(ToolResult) pair at the boundary. An orphaned tool-result message
/// without the preceding assistant tool_calls causes a 400 on the
/// OpenAI-compat path (gaebal-gajae repro 2026-04-09).
#[test]
fn compaction_does_not_split_tool_use_tool_result_pair() {
use crate::session::{ContentBlock, Session};
let tool_id = "call_abc";
let mut session = Session::default();
// Turn 1: user prompt
session
.push_message(ConversationMessage::user_text("Search for files"))
.unwrap();
// Turn 2: assistant calls a tool
session
.push_message(ConversationMessage::assistant(vec![
ContentBlock::ToolUse {
id: tool_id.to_string(),
name: "search".to_string(),
input: "{\"q\":\"*.rs\"}".to_string(),
},
]))
.unwrap();
// Turn 3: tool result
session
.push_message(ConversationMessage::tool_result(
tool_id,
"search",
"found 5 files",
false,
))
.unwrap();
// Turn 4: assistant final response
session
.push_message(ConversationMessage::assistant(vec![ContentBlock::Text {
text: "Done.".to_string(),
}]))
.unwrap();
// Compact preserving only 1 recent message — without the fix this
// would cut the boundary so that the tool result (turn 3) is first,
// without its preceding assistant tool_calls (turn 2).
let config = CompactionConfig {
preserve_recent_messages: 1,
..CompactionConfig::default()
};
let result = compact_session(&session, config);
// After compaction, no two consecutive messages should have the pattern
// tool_result immediately following a non-assistant message (i.e. an
// orphaned tool result without a preceding assistant ToolUse).
let messages = &result.compacted_session.messages;
for i in 1..messages.len() {
let curr_is_tool_result = messages[i]
.blocks
.first()
.map(|b| matches!(b, ContentBlock::ToolResult { .. }))
.unwrap_or(false);
if curr_is_tool_result {
let prev_has_tool_use = messages[i - 1]
.blocks
.iter()
.any(|b| matches!(b, ContentBlock::ToolUse { .. }));
assert!(
prev_has_tool_use,
"message[{}] is a ToolResult but message[{}] has no ToolUse: {:?}",
i,
i - 1,
&messages[i - 1].blocks
);
}
}
}
#[test]
File diff suppressed because it is too large Load Diff
+901
View File
@@ -0,0 +1,901 @@
use std::collections::BTreeMap;
use std::path::Path;
use crate::config::ConfigError;
use crate::json::JsonValue;
/// Diagnostic emitted when a config file contains a suspect field.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigDiagnostic {
pub path: String,
pub field: String,
pub line: Option<usize>,
pub kind: DiagnosticKind,
}
/// Classification of the diagnostic.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DiagnosticKind {
UnknownKey {
suggestion: Option<String>,
},
WrongType {
expected: &'static str,
got: &'static str,
},
Deprecated {
replacement: &'static str,
},
}
impl std::fmt::Display for ConfigDiagnostic {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let location = self
.line
.map_or_else(String::new, |line| format!(" (line {line})"));
match &self.kind {
DiagnosticKind::UnknownKey { suggestion: None } => {
write!(f, "{}: unknown key \"{}\"{location}", self.path, self.field)
}
DiagnosticKind::UnknownKey {
suggestion: Some(hint),
} => {
write!(
f,
"{}: unknown key \"{}\"{location}. Did you mean \"{}\"?",
self.path, self.field, hint
)
}
DiagnosticKind::WrongType { expected, got } => {
write!(
f,
"{}: field \"{}\" must be {expected}, got {got}{location}",
self.path, self.field
)
}
DiagnosticKind::Deprecated { replacement } => {
write!(
f,
"{}: field \"{}\" is deprecated{location}. Use \"{replacement}\" instead",
self.path, self.field
)
}
}
}
}
/// Result of validating a single config file.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ValidationResult {
pub errors: Vec<ConfigDiagnostic>,
pub warnings: Vec<ConfigDiagnostic>,
}
impl ValidationResult {
#[must_use]
pub fn is_ok(&self) -> bool {
self.errors.is_empty()
}
fn merge(&mut self, other: Self) {
self.errors.extend(other.errors);
self.warnings.extend(other.warnings);
}
}
// ---- known-key schema ----
/// Expected type for a config field.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FieldType {
String,
Bool,
Object,
StringArray,
Number,
}
impl FieldType {
fn label(self) -> &'static str {
match self {
Self::String => "a string",
Self::Bool => "a boolean",
Self::Object => "an object",
Self::StringArray => "an array of strings",
Self::Number => "a number",
}
}
fn matches(self, value: &JsonValue) -> bool {
match self {
Self::String => value.as_str().is_some(),
Self::Bool => value.as_bool().is_some(),
Self::Object => value.as_object().is_some(),
Self::StringArray => value
.as_array()
.is_some_and(|arr| arr.iter().all(|v| v.as_str().is_some())),
Self::Number => value.as_i64().is_some(),
}
}
}
fn json_type_label(value: &JsonValue) -> &'static str {
match value {
JsonValue::Null => "null",
JsonValue::Bool(_) => "a boolean",
JsonValue::Number(_) => "a number",
JsonValue::String(_) => "a string",
JsonValue::Array(_) => "an array",
JsonValue::Object(_) => "an object",
}
}
struct FieldSpec {
name: &'static str,
expected: FieldType,
}
struct DeprecatedField {
name: &'static str,
replacement: &'static str,
}
const TOP_LEVEL_FIELDS: &[FieldSpec] = &[
FieldSpec {
name: "$schema",
expected: FieldType::String,
},
FieldSpec {
name: "model",
expected: FieldType::String,
},
FieldSpec {
name: "hooks",
expected: FieldType::Object,
},
FieldSpec {
name: "permissions",
expected: FieldType::Object,
},
FieldSpec {
name: "permissionMode",
expected: FieldType::String,
},
FieldSpec {
name: "mcpServers",
expected: FieldType::Object,
},
FieldSpec {
name: "oauth",
expected: FieldType::Object,
},
FieldSpec {
name: "enabledPlugins",
expected: FieldType::Object,
},
FieldSpec {
name: "plugins",
expected: FieldType::Object,
},
FieldSpec {
name: "sandbox",
expected: FieldType::Object,
},
FieldSpec {
name: "env",
expected: FieldType::Object,
},
FieldSpec {
name: "aliases",
expected: FieldType::Object,
},
FieldSpec {
name: "providerFallbacks",
expected: FieldType::Object,
},
FieldSpec {
name: "trustedRoots",
expected: FieldType::StringArray,
},
];
const HOOKS_FIELDS: &[FieldSpec] = &[
FieldSpec {
name: "PreToolUse",
expected: FieldType::StringArray,
},
FieldSpec {
name: "PostToolUse",
expected: FieldType::StringArray,
},
FieldSpec {
name: "PostToolUseFailure",
expected: FieldType::StringArray,
},
];
const PERMISSIONS_FIELDS: &[FieldSpec] = &[
FieldSpec {
name: "defaultMode",
expected: FieldType::String,
},
FieldSpec {
name: "allow",
expected: FieldType::StringArray,
},
FieldSpec {
name: "deny",
expected: FieldType::StringArray,
},
FieldSpec {
name: "ask",
expected: FieldType::StringArray,
},
];
const PLUGINS_FIELDS: &[FieldSpec] = &[
FieldSpec {
name: "enabled",
expected: FieldType::Object,
},
FieldSpec {
name: "externalDirectories",
expected: FieldType::StringArray,
},
FieldSpec {
name: "installRoot",
expected: FieldType::String,
},
FieldSpec {
name: "registryPath",
expected: FieldType::String,
},
FieldSpec {
name: "bundledRoot",
expected: FieldType::String,
},
FieldSpec {
name: "maxOutputTokens",
expected: FieldType::Number,
},
];
const SANDBOX_FIELDS: &[FieldSpec] = &[
FieldSpec {
name: "enabled",
expected: FieldType::Bool,
},
FieldSpec {
name: "namespaceRestrictions",
expected: FieldType::Bool,
},
FieldSpec {
name: "networkIsolation",
expected: FieldType::Bool,
},
FieldSpec {
name: "filesystemMode",
expected: FieldType::String,
},
FieldSpec {
name: "allowedMounts",
expected: FieldType::StringArray,
},
];
const OAUTH_FIELDS: &[FieldSpec] = &[
FieldSpec {
name: "clientId",
expected: FieldType::String,
},
FieldSpec {
name: "authorizeUrl",
expected: FieldType::String,
},
FieldSpec {
name: "tokenUrl",
expected: FieldType::String,
},
FieldSpec {
name: "callbackPort",
expected: FieldType::Number,
},
FieldSpec {
name: "manualRedirectUrl",
expected: FieldType::String,
},
FieldSpec {
name: "scopes",
expected: FieldType::StringArray,
},
];
const DEPRECATED_FIELDS: &[DeprecatedField] = &[
DeprecatedField {
name: "permissionMode",
replacement: "permissions.defaultMode",
},
DeprecatedField {
name: "enabledPlugins",
replacement: "plugins.enabled",
},
];
// ---- line-number resolution ----
/// Find the 1-based line number where a JSON key first appears in the raw source.
fn find_key_line(source: &str, key: &str) -> Option<usize> {
// Search for `"key"` followed by optional whitespace and a colon.
let needle = format!("\"{key}\"");
let mut search_start = 0;
while let Some(offset) = source[search_start..].find(&needle) {
let absolute = search_start + offset;
let after = absolute + needle.len();
// Verify the next non-whitespace char is `:` to confirm this is a key, not a value.
if source[after..].chars().find(|ch| !ch.is_ascii_whitespace()) == Some(':') {
return Some(source[..absolute].chars().filter(|&ch| ch == '\n').count() + 1);
}
search_start = after;
}
None
}
// ---- core validation ----
fn validate_object_keys(
object: &BTreeMap<String, JsonValue>,
known_fields: &[FieldSpec],
prefix: &str,
source: &str,
path_display: &str,
) -> ValidationResult {
let mut result = ValidationResult {
errors: Vec::new(),
warnings: Vec::new(),
};
let known_names: Vec<&str> = known_fields.iter().map(|f| f.name).collect();
for (key, value) in object {
let field_path = if prefix.is_empty() {
key.clone()
} else {
format!("{prefix}.{key}")
};
if let Some(spec) = known_fields.iter().find(|f| f.name == key) {
// Type check.
if !spec.expected.matches(value) {
result.errors.push(ConfigDiagnostic {
path: path_display.to_string(),
field: field_path,
line: find_key_line(source, key),
kind: DiagnosticKind::WrongType {
expected: spec.expected.label(),
got: json_type_label(value),
},
});
}
} else if DEPRECATED_FIELDS.iter().any(|d| d.name == key) {
// Deprecated key — handled separately, not an unknown-key error.
} else {
// Unknown key.
let suggestion = suggest_field(key, &known_names);
result.errors.push(ConfigDiagnostic {
path: path_display.to_string(),
field: field_path,
line: find_key_line(source, key),
kind: DiagnosticKind::UnknownKey { suggestion },
});
}
}
result
}
fn suggest_field(input: &str, candidates: &[&str]) -> Option<String> {
let input_lower = input.to_ascii_lowercase();
candidates
.iter()
.filter_map(|candidate| {
let distance = simple_edit_distance(&input_lower, &candidate.to_ascii_lowercase());
(distance <= 3).then_some((distance, *candidate))
})
.min_by_key(|(distance, _)| *distance)
.map(|(_, name)| name.to_string())
}
fn simple_edit_distance(left: &str, right: &str) -> usize {
if left.is_empty() {
return right.len();
}
if right.is_empty() {
return left.len();
}
let right_chars: Vec<char> = right.chars().collect();
let mut previous: Vec<usize> = (0..=right_chars.len()).collect();
let mut current = vec![0; right_chars.len() + 1];
for (left_index, left_char) in left.chars().enumerate() {
current[0] = left_index + 1;
for (right_index, right_char) in right_chars.iter().enumerate() {
let cost = usize::from(left_char != *right_char);
current[right_index + 1] = (previous[right_index + 1] + 1)
.min(current[right_index] + 1)
.min(previous[right_index] + cost);
}
previous.clone_from(&current);
}
previous[right_chars.len()]
}
/// Validate a parsed config file's keys and types against the known schema.
///
/// Returns diagnostics (errors and deprecation warnings) without blocking the load.
pub fn validate_config_file(
object: &BTreeMap<String, JsonValue>,
source: &str,
file_path: &Path,
) -> ValidationResult {
let path_display = file_path.display().to_string();
let mut result = validate_object_keys(object, TOP_LEVEL_FIELDS, "", source, &path_display);
// Check deprecated fields.
for deprecated in DEPRECATED_FIELDS {
if object.contains_key(deprecated.name) {
result.warnings.push(ConfigDiagnostic {
path: path_display.clone(),
field: deprecated.name.to_string(),
line: find_key_line(source, deprecated.name),
kind: DiagnosticKind::Deprecated {
replacement: deprecated.replacement,
},
});
}
}
// Validate known nested objects.
if let Some(hooks) = object.get("hooks").and_then(JsonValue::as_object) {
result.merge(validate_object_keys(
hooks,
HOOKS_FIELDS,
"hooks",
source,
&path_display,
));
}
if let Some(permissions) = object.get("permissions").and_then(JsonValue::as_object) {
result.merge(validate_object_keys(
permissions,
PERMISSIONS_FIELDS,
"permissions",
source,
&path_display,
));
}
if let Some(plugins) = object.get("plugins").and_then(JsonValue::as_object) {
result.merge(validate_object_keys(
plugins,
PLUGINS_FIELDS,
"plugins",
source,
&path_display,
));
}
if let Some(sandbox) = object.get("sandbox").and_then(JsonValue::as_object) {
result.merge(validate_object_keys(
sandbox,
SANDBOX_FIELDS,
"sandbox",
source,
&path_display,
));
}
if let Some(oauth) = object.get("oauth").and_then(JsonValue::as_object) {
result.merge(validate_object_keys(
oauth,
OAUTH_FIELDS,
"oauth",
source,
&path_display,
));
}
result
}
/// Check whether a file path uses an unsupported config format (e.g. TOML).
pub fn check_unsupported_format(file_path: &Path) -> Result<(), ConfigError> {
if let Some(ext) = file_path.extension().and_then(|e| e.to_str()) {
if ext.eq_ignore_ascii_case("toml") {
return Err(ConfigError::Parse(format!(
"{}: TOML config files are not supported. Use JSON (settings.json) instead",
file_path.display()
)));
}
}
Ok(())
}
/// Format all diagnostics into a human-readable report.
#[must_use]
pub fn format_diagnostics(result: &ValidationResult) -> String {
let mut lines = Vec::new();
for warning in &result.warnings {
lines.push(format!("warning: {warning}"));
}
for error in &result.errors {
lines.push(format!("error: {error}"));
}
lines.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn test_path() -> PathBuf {
PathBuf::from("/test/settings.json")
}
#[test]
fn detects_unknown_top_level_key() {
// given
let source = r#"{"model": "opus", "unknownField": true}"#;
let parsed = JsonValue::parse(source).expect("valid json");
let object = parsed.as_object().expect("object");
// when
let result = validate_config_file(object, source, &test_path());
// then
assert_eq!(result.errors.len(), 1);
assert_eq!(result.errors[0].field, "unknownField");
assert!(matches!(
result.errors[0].kind,
DiagnosticKind::UnknownKey { .. }
));
}
#[test]
fn detects_wrong_type_for_model() {
// given
let source = r#"{"model": 123}"#;
let parsed = JsonValue::parse(source).expect("valid json");
let object = parsed.as_object().expect("object");
// when
let result = validate_config_file(object, source, &test_path());
// then
assert_eq!(result.errors.len(), 1);
assert_eq!(result.errors[0].field, "model");
assert!(matches!(
result.errors[0].kind,
DiagnosticKind::WrongType {
expected: "a string",
got: "a number"
}
));
}
#[test]
fn detects_deprecated_permission_mode() {
// given
let source = r#"{"permissionMode": "plan"}"#;
let parsed = JsonValue::parse(source).expect("valid json");
let object = parsed.as_object().expect("object");
// when
let result = validate_config_file(object, source, &test_path());
// then
assert_eq!(result.warnings.len(), 1);
assert_eq!(result.warnings[0].field, "permissionMode");
assert!(matches!(
result.warnings[0].kind,
DiagnosticKind::Deprecated {
replacement: "permissions.defaultMode"
}
));
}
#[test]
fn detects_deprecated_enabled_plugins() {
// given
let source = r#"{"enabledPlugins": {"tool-guard@builtin": true}}"#;
let parsed = JsonValue::parse(source).expect("valid json");
let object = parsed.as_object().expect("object");
// when
let result = validate_config_file(object, source, &test_path());
// then
assert_eq!(result.warnings.len(), 1);
assert_eq!(result.warnings[0].field, "enabledPlugins");
assert!(matches!(
result.warnings[0].kind,
DiagnosticKind::Deprecated {
replacement: "plugins.enabled"
}
));
}
#[test]
fn reports_line_number_for_unknown_key() {
// given
let source = "{\n \"model\": \"opus\",\n \"badKey\": true\n}";
let parsed = JsonValue::parse(source).expect("valid json");
let object = parsed.as_object().expect("object");
// when
let result = validate_config_file(object, source, &test_path());
// then
assert_eq!(result.errors.len(), 1);
assert_eq!(result.errors[0].line, Some(3));
assert_eq!(result.errors[0].field, "badKey");
}
#[test]
fn reports_line_number_for_wrong_type() {
// given
let source = "{\n \"model\": 42\n}";
let parsed = JsonValue::parse(source).expect("valid json");
let object = parsed.as_object().expect("object");
// when
let result = validate_config_file(object, source, &test_path());
// then
assert_eq!(result.errors.len(), 1);
assert_eq!(result.errors[0].line, Some(2));
}
#[test]
fn validates_nested_hooks_keys() {
// given
let source = r#"{"hooks": {"PreToolUse": ["cmd"], "BadHook": ["x"]}}"#;
let parsed = JsonValue::parse(source).expect("valid json");
let object = parsed.as_object().expect("object");
// when
let result = validate_config_file(object, source, &test_path());
// then
assert_eq!(result.errors.len(), 1);
assert_eq!(result.errors[0].field, "hooks.BadHook");
}
#[test]
fn validates_nested_permissions_keys() {
// given
let source = r#"{"permissions": {"allow": ["Read"], "denyAll": true}}"#;
let parsed = JsonValue::parse(source).expect("valid json");
let object = parsed.as_object().expect("object");
// when
let result = validate_config_file(object, source, &test_path());
// then
assert_eq!(result.errors.len(), 1);
assert_eq!(result.errors[0].field, "permissions.denyAll");
}
#[test]
fn validates_nested_sandbox_keys() {
// given
let source = r#"{"sandbox": {"enabled": true, "containerMode": "strict"}}"#;
let parsed = JsonValue::parse(source).expect("valid json");
let object = parsed.as_object().expect("object");
// when
let result = validate_config_file(object, source, &test_path());
// then
assert_eq!(result.errors.len(), 1);
assert_eq!(result.errors[0].field, "sandbox.containerMode");
}
#[test]
fn validates_nested_plugins_keys() {
// given
let source = r#"{"plugins": {"installRoot": "/tmp", "autoUpdate": true}}"#;
let parsed = JsonValue::parse(source).expect("valid json");
let object = parsed.as_object().expect("object");
// when
let result = validate_config_file(object, source, &test_path());
// then
assert_eq!(result.errors.len(), 1);
assert_eq!(result.errors[0].field, "plugins.autoUpdate");
}
#[test]
fn validates_nested_oauth_keys() {
// given
let source = r#"{"oauth": {"clientId": "abc", "secret": "hidden"}}"#;
let parsed = JsonValue::parse(source).expect("valid json");
let object = parsed.as_object().expect("object");
// when
let result = validate_config_file(object, source, &test_path());
// then
assert_eq!(result.errors.len(), 1);
assert_eq!(result.errors[0].field, "oauth.secret");
}
#[test]
fn valid_config_produces_no_diagnostics() {
// given
let source = r#"{
"model": "opus",
"hooks": {"PreToolUse": ["guard"]},
"permissions": {"defaultMode": "plan", "allow": ["Read"]},
"mcpServers": {},
"sandbox": {"enabled": false}
}"#;
let parsed = JsonValue::parse(source).expect("valid json");
let object = parsed.as_object().expect("object");
// when
let result = validate_config_file(object, source, &test_path());
// then
assert!(result.is_ok());
assert!(result.warnings.is_empty());
}
#[test]
fn suggests_close_field_name() {
// given
let source = r#"{"modle": "opus"}"#;
let parsed = JsonValue::parse(source).expect("valid json");
let object = parsed.as_object().expect("object");
// when
let result = validate_config_file(object, source, &test_path());
// then
assert_eq!(result.errors.len(), 1);
match &result.errors[0].kind {
DiagnosticKind::UnknownKey {
suggestion: Some(s),
} => assert_eq!(s, "model"),
other => panic!("expected suggestion, got {other:?}"),
}
}
#[test]
fn format_diagnostics_includes_all_entries() {
// given
let source = r#"{"permissionMode": "plan", "badKey": 1}"#;
let parsed = JsonValue::parse(source).expect("valid json");
let object = parsed.as_object().expect("object");
let result = validate_config_file(object, source, &test_path());
// when
let output = format_diagnostics(&result);
// then
assert!(output.contains("warning:"));
assert!(output.contains("error:"));
assert!(output.contains("badKey"));
assert!(output.contains("permissionMode"));
}
#[test]
fn check_unsupported_format_rejects_toml() {
// given
let path = PathBuf::from("/home/.claw/settings.toml");
// when
let result = check_unsupported_format(&path);
// then
assert!(result.is_err());
let message = result.unwrap_err().to_string();
assert!(message.contains("TOML"));
assert!(message.contains("settings.toml"));
}
#[test]
fn check_unsupported_format_allows_json() {
// given
let path = PathBuf::from("/home/.claw/settings.json");
// when / then
assert!(check_unsupported_format(&path).is_ok());
}
#[test]
fn wrong_type_in_nested_sandbox_field() {
// given
let source = r#"{"sandbox": {"enabled": "yes"}}"#;
let parsed = JsonValue::parse(source).expect("valid json");
let object = parsed.as_object().expect("object");
// when
let result = validate_config_file(object, source, &test_path());
// then
assert_eq!(result.errors.len(), 1);
assert_eq!(result.errors[0].field, "sandbox.enabled");
assert!(matches!(
result.errors[0].kind,
DiagnosticKind::WrongType {
expected: "a boolean",
got: "a string"
}
));
}
#[test]
fn display_format_unknown_key_with_line() {
// given
let diag = ConfigDiagnostic {
path: "/test/settings.json".to_string(),
field: "badKey".to_string(),
line: Some(5),
kind: DiagnosticKind::UnknownKey { suggestion: None },
};
// when
let output = diag.to_string();
// then
assert_eq!(
output,
r#"/test/settings.json: unknown key "badKey" (line 5)"#
);
}
#[test]
fn display_format_wrong_type_with_line() {
// given
let diag = ConfigDiagnostic {
path: "/test/settings.json".to_string(),
field: "model".to_string(),
line: Some(2),
kind: DiagnosticKind::WrongType {
expected: "a string",
got: "a number",
},
};
// when
let output = diag.to_string();
// then
assert_eq!(
output,
r#"/test/settings.json: field "model" must be a string, got a number (line 2)"#
);
}
#[test]
fn display_format_deprecated_with_line() {
// given
let diag = ConfigDiagnostic {
path: "/test/settings.json".to_string(),
field: "permissionMode".to_string(),
line: Some(3),
kind: DiagnosticKind::Deprecated {
replacement: "permissions.defaultMode",
},
};
// when
let output = diag.to_string();
// then
assert_eq!(
output,
r#"/test/settings.json: field "permissionMode" is deprecated (line 3). Use "permissions.defaultMode" instead"#
);
}
}
File diff suppressed because it is too large Load Diff
+296 -7
View File
@@ -9,6 +9,41 @@ use regex::RegexBuilder;
use serde::{Deserialize, Serialize};
use walkdir::WalkDir;
/// Maximum file size that can be read (10 MB).
const MAX_READ_SIZE: u64 = 10 * 1024 * 1024;
/// Maximum file size that can be written (10 MB).
const MAX_WRITE_SIZE: usize = 10 * 1024 * 1024;
/// Check whether a file appears to contain binary content by examining
/// the first chunk for NUL bytes.
fn is_binary_file(path: &Path) -> io::Result<bool> {
use std::io::Read;
let mut file = fs::File::open(path)?;
let mut buffer = [0u8; 8192];
let bytes_read = file.read(&mut buffer)?;
Ok(buffer[..bytes_read].contains(&0))
}
/// Validate that a resolved path stays within the given workspace root.
/// Returns the canonical path on success, or an error if the path escapes
/// the workspace boundary (e.g. via `../` traversal or symlink).
#[allow(dead_code)]
fn validate_workspace_boundary(resolved: &Path, workspace_root: &Path) -> io::Result<()> {
if !resolved.starts_with(workspace_root) {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
format!(
"path {} escapes workspace boundary {}",
resolved.display(),
workspace_root.display()
),
));
}
Ok(())
}
/// Text payload returned by file-reading operations.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TextFilePayload {
#[serde(rename = "filePath")]
@@ -22,6 +57,7 @@ pub struct TextFilePayload {
pub total_lines: usize,
}
/// Output envelope for the `read_file` tool.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ReadFileOutput {
#[serde(rename = "type")]
@@ -29,6 +65,7 @@ pub struct ReadFileOutput {
pub file: TextFilePayload,
}
/// Structured patch hunk emitted by write and edit operations.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct StructuredPatchHunk {
#[serde(rename = "oldStart")]
@@ -42,6 +79,7 @@ pub struct StructuredPatchHunk {
pub lines: Vec<String>,
}
/// Output envelope for full-file write operations.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WriteFileOutput {
#[serde(rename = "type")]
@@ -57,6 +95,7 @@ pub struct WriteFileOutput {
pub git_diff: Option<serde_json::Value>,
}
/// Output envelope for targeted string-replacement edits.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct EditFileOutput {
#[serde(rename = "filePath")]
@@ -77,6 +116,7 @@ pub struct EditFileOutput {
pub git_diff: Option<serde_json::Value>,
}
/// Result of a glob-based filename search.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct GlobSearchOutput {
#[serde(rename = "durationMs")]
@@ -87,6 +127,7 @@ pub struct GlobSearchOutput {
pub truncated: bool,
}
/// Parameters accepted by the grep-style search tool.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct GrepSearchInput {
pub pattern: String,
@@ -112,6 +153,7 @@ pub struct GrepSearchInput {
pub multiline: Option<bool>,
}
/// Result payload returned by the grep-style search tool.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct GrepSearchOutput {
pub mode: Option<String>,
@@ -129,12 +171,35 @@ pub struct GrepSearchOutput {
pub applied_offset: Option<usize>,
}
/// Reads a text file and returns a line-windowed payload.
pub fn read_file(
path: &str,
offset: Option<usize>,
limit: Option<usize>,
) -> io::Result<ReadFileOutput> {
let absolute_path = normalize_path(path)?;
// Check file size before reading
let metadata = fs::metadata(&absolute_path)?;
if metadata.len() > MAX_READ_SIZE {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"file is too large ({} bytes, max {} bytes)",
metadata.len(),
MAX_READ_SIZE
),
));
}
// Detect binary files
if is_binary_file(&absolute_path)? {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"file appears to be binary",
));
}
let content = fs::read_to_string(&absolute_path)?;
let lines: Vec<&str> = content.lines().collect();
let start_index = offset.unwrap_or(0).min(lines.len());
@@ -155,7 +220,19 @@ pub fn read_file(
})
}
/// Replaces a file's contents and returns patch metadata.
pub fn write_file(path: &str, content: &str) -> io::Result<WriteFileOutput> {
if content.len() > MAX_WRITE_SIZE {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"content is too large ({} bytes, max {} bytes)",
content.len(),
MAX_WRITE_SIZE
),
));
}
let absolute_path = normalize_path_allow_missing(path)?;
let original_file = fs::read_to_string(&absolute_path).ok();
if let Some(parent) = absolute_path.parent() {
@@ -177,6 +254,7 @@ pub fn write_file(path: &str, content: &str) -> io::Result<WriteFileOutput> {
})
}
/// Performs an in-file string replacement and returns patch metadata.
pub fn edit_file(
path: &str,
old_string: &str,
@@ -217,6 +295,7 @@ pub fn edit_file(
})
}
/// Expands a glob pattern and returns matching filenames.
pub fn glob_search(pattern: &str, path: Option<&str>) -> io::Result<GlobSearchOutput> {
let started = Instant::now();
let base_dir = path
@@ -229,12 +308,20 @@ pub fn glob_search(pattern: &str, path: Option<&str>) -> io::Result<GlobSearchOu
base_dir.join(pattern).to_string_lossy().into_owned()
};
// The `glob` crate does not support brace expansion ({a,b,c}).
// Expand braces into multiple patterns so patterns like
// `Assets/**/*.{cs,uxml,uss}` work correctly.
let expanded = expand_braces(&search_pattern);
let mut seen = std::collections::HashSet::new();
let mut matches = Vec::new();
let entries = glob::glob(&search_pattern)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error.to_string()))?;
for entry in entries.flatten() {
if entry.is_file() {
matches.push(entry);
for pat in &expanded {
let entries = glob::glob(pat)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error.to_string()))?;
for entry in entries.flatten() {
if entry.is_file() && seen.insert(entry.clone()) {
matches.push(entry);
}
}
}
@@ -260,6 +347,7 @@ pub fn glob_search(pattern: &str, path: Option<&str>) -> io::Result<GlobSearchOu
})
}
/// Runs a regex search over workspace files with optional context lines.
pub fn grep_search(input: &GrepSearchInput) -> io::Result<GrepSearchOutput> {
let base_path = input
.path
@@ -477,18 +565,105 @@ fn normalize_path_allow_missing(path: &str) -> io::Result<PathBuf> {
Ok(candidate)
}
/// Read a file with workspace boundary enforcement.
#[allow(dead_code)]
pub fn read_file_in_workspace(
path: &str,
offset: Option<usize>,
limit: Option<usize>,
workspace_root: &Path,
) -> io::Result<ReadFileOutput> {
let absolute_path = normalize_path(path)?;
let canonical_root = workspace_root
.canonicalize()
.unwrap_or_else(|_| workspace_root.to_path_buf());
validate_workspace_boundary(&absolute_path, &canonical_root)?;
read_file(path, offset, limit)
}
/// Write a file with workspace boundary enforcement.
#[allow(dead_code)]
pub fn write_file_in_workspace(
path: &str,
content: &str,
workspace_root: &Path,
) -> io::Result<WriteFileOutput> {
let absolute_path = normalize_path_allow_missing(path)?;
let canonical_root = workspace_root
.canonicalize()
.unwrap_or_else(|_| workspace_root.to_path_buf());
validate_workspace_boundary(&absolute_path, &canonical_root)?;
write_file(path, content)
}
/// Edit a file with workspace boundary enforcement.
#[allow(dead_code)]
pub fn edit_file_in_workspace(
path: &str,
old_string: &str,
new_string: &str,
replace_all: bool,
workspace_root: &Path,
) -> io::Result<EditFileOutput> {
let absolute_path = normalize_path(path)?;
let canonical_root = workspace_root
.canonicalize()
.unwrap_or_else(|_| workspace_root.to_path_buf());
validate_workspace_boundary(&absolute_path, &canonical_root)?;
edit_file(path, old_string, new_string, replace_all)
}
/// Check whether a path is a symlink that resolves outside the workspace.
#[allow(dead_code)]
pub fn is_symlink_escape(path: &Path, workspace_root: &Path) -> io::Result<bool> {
let metadata = fs::symlink_metadata(path)?;
if !metadata.is_symlink() {
return Ok(false);
}
let resolved = path.canonicalize()?;
let canonical_root = workspace_root
.canonicalize()
.unwrap_or_else(|_| workspace_root.to_path_buf());
Ok(!resolved.starts_with(&canonical_root))
}
/// Expand shell-style brace groups in a glob pattern.
///
/// Handles one level of braces: `foo.{a,b,c}` → `["foo.a", "foo.b", "foo.c"]`.
/// Nested braces are not expanded (uncommon in practice).
/// Patterns without braces pass through unchanged.
fn expand_braces(pattern: &str) -> Vec<String> {
let Some(open) = pattern.find('{') else {
return vec![pattern.to_owned()];
};
let Some(close) = pattern[open..].find('}').map(|i| open + i) else {
// Unmatched brace — treat as literal.
return vec![pattern.to_owned()];
};
let prefix = &pattern[..open];
let suffix = &pattern[close + 1..];
let alternatives = &pattern[open + 1..close];
alternatives
.split(',')
.flat_map(|alt| expand_braces(&format!("{prefix}{alt}{suffix}")))
.collect()
}
#[cfg(test)]
mod tests {
use std::time::{SystemTime, UNIX_EPOCH};
use super::{edit_file, glob_search, grep_search, read_file, write_file, GrepSearchInput};
use super::{
edit_file, expand_braces, glob_search, grep_search, is_symlink_escape, read_file,
read_file_in_workspace, write_file, GrepSearchInput, MAX_WRITE_SIZE,
};
fn temp_path(name: &str) -> std::path::PathBuf {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time should move forward")
.as_nanos();
std::env::temp_dir().join(format!("claw-native-{name}-{unique}"))
std::env::temp_dir().join(format!("clawd-native-{name}-{unique}"))
}
#[test]
@@ -513,6 +688,73 @@ mod tests {
assert!(output.replace_all);
}
#[test]
fn rejects_binary_files() {
let path = temp_path("binary-test.bin");
std::fs::write(&path, b"\x00\x01\x02\x03binary content").expect("write should succeed");
let result = read_file(path.to_string_lossy().as_ref(), None, None);
assert!(result.is_err());
let error = result.unwrap_err();
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
assert!(error.to_string().contains("binary"));
}
#[test]
fn rejects_oversized_writes() {
let path = temp_path("oversize-write.txt");
let huge = "x".repeat(MAX_WRITE_SIZE + 1);
let result = write_file(path.to_string_lossy().as_ref(), &huge);
assert!(result.is_err());
let error = result.unwrap_err();
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
assert!(error.to_string().contains("too large"));
}
#[test]
fn enforces_workspace_boundary() {
let workspace = temp_path("workspace-boundary");
std::fs::create_dir_all(&workspace).expect("workspace dir should be created");
let inside = workspace.join("inside.txt");
write_file(inside.to_string_lossy().as_ref(), "safe content")
.expect("write inside workspace should succeed");
// Reading inside workspace should succeed
let result =
read_file_in_workspace(inside.to_string_lossy().as_ref(), None, None, &workspace);
assert!(result.is_ok());
// Reading outside workspace should fail
let outside = temp_path("outside-boundary.txt");
write_file(outside.to_string_lossy().as_ref(), "unsafe content")
.expect("write outside should succeed");
let result =
read_file_in_workspace(outside.to_string_lossy().as_ref(), None, None, &workspace);
assert!(result.is_err());
let error = result.unwrap_err();
assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied);
assert!(error.to_string().contains("escapes workspace"));
}
#[test]
fn detects_symlink_escape() {
let workspace = temp_path("symlink-workspace");
std::fs::create_dir_all(&workspace).expect("workspace dir should be created");
let outside = temp_path("symlink-target.txt");
std::fs::write(&outside, "target content").expect("target should write");
let _link_path = workspace.join("escape-link.txt");
#[cfg(unix)]
{
std::os::unix::fs::symlink(&outside, &link_path).expect("symlink should create");
assert!(is_symlink_escape(&link_path, &workspace).expect("check should succeed"));
}
// Non-symlink file should not be an escape
let normal = workspace.join("normal.txt");
std::fs::write(&normal, "normal content").expect("normal file should write");
assert!(!is_symlink_escape(&normal, &workspace).expect("check should succeed"));
}
#[test]
fn globs_and_greps_directory() {
let dir = temp_path("search-dir");
@@ -547,4 +789,51 @@ mod tests {
.expect("grep should succeed");
assert!(grep_output.content.unwrap_or_default().contains("hello"));
}
#[test]
fn expand_braces_no_braces() {
assert_eq!(expand_braces("*.rs"), vec!["*.rs"]);
}
#[test]
fn expand_braces_single_group() {
let mut result = expand_braces("Assets/**/*.{cs,uxml,uss}");
result.sort();
assert_eq!(
result,
vec!["Assets/**/*.cs", "Assets/**/*.uss", "Assets/**/*.uxml",]
);
}
#[test]
fn expand_braces_nested() {
let mut result = expand_braces("src/{a,b}.{rs,toml}");
result.sort();
assert_eq!(
result,
vec!["src/a.rs", "src/a.toml", "src/b.rs", "src/b.toml"]
);
}
#[test]
fn expand_braces_unmatched() {
assert_eq!(expand_braces("foo.{bar"), vec!["foo.{bar"]);
}
#[test]
fn glob_search_with_braces_finds_files() {
let dir = temp_path("glob-braces");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("a.rs"), "fn main() {}").unwrap();
std::fs::write(dir.join("b.toml"), "[package]").unwrap();
std::fs::write(dir.join("c.txt"), "hello").unwrap();
let result =
glob_search("*.{rs,toml}", Some(dir.to_str().unwrap())).expect("glob should succeed");
assert_eq!(
result.num_files, 2,
"should match .rs and .toml but not .txt"
);
let _ = std::fs::remove_dir_all(&dir);
}
}
+324
View File
@@ -0,0 +1,324 @@
use std::path::Path;
use std::process::Command;
/// A single git commit entry from the log.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitCommitEntry {
pub hash: String,
pub subject: String,
}
/// Git-aware context gathered at startup for injection into the system prompt.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitContext {
pub branch: Option<String>,
pub recent_commits: Vec<GitCommitEntry>,
pub staged_files: Vec<String>,
}
const MAX_RECENT_COMMITS: usize = 5;
impl GitContext {
/// Detect the git context from the given working directory.
///
/// Returns `None` when the directory is not inside a git repository.
#[must_use]
pub fn detect(cwd: &Path) -> Option<Self> {
// Quick gate: is this a git repo at all?
let rev_parse = Command::new("git")
.args(["rev-parse", "--is-inside-work-tree"])
.current_dir(cwd)
.output()
.ok()?;
if !rev_parse.status.success() {
return None;
}
Some(Self {
branch: read_branch(cwd),
recent_commits: read_recent_commits(cwd),
staged_files: read_staged_files(cwd),
})
}
/// Render a human-readable summary suitable for system-prompt injection.
#[must_use]
pub fn render(&self) -> String {
let mut lines = Vec::new();
if let Some(branch) = &self.branch {
lines.push(format!("Git branch: {branch}"));
}
if !self.recent_commits.is_empty() {
lines.push(String::new());
lines.push("Recent commits:".to_string());
for entry in &self.recent_commits {
lines.push(format!(" {} {}", entry.hash, entry.subject));
}
}
if !self.staged_files.is_empty() {
lines.push(String::new());
lines.push("Staged files:".to_string());
for file in &self.staged_files {
lines.push(format!(" {file}"));
}
}
lines.join("\n")
}
}
fn read_branch(cwd: &Path) -> Option<String> {
let output = Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.current_dir(cwd)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let branch = String::from_utf8(output.stdout).ok()?;
let trimmed = branch.trim();
if trimmed.is_empty() || trimmed == "HEAD" {
None
} else {
Some(trimmed.to_string())
}
}
fn read_recent_commits(cwd: &Path) -> Vec<GitCommitEntry> {
let output = Command::new("git")
.args([
"--no-optional-locks",
"log",
"--oneline",
"-n",
&MAX_RECENT_COMMITS.to_string(),
"--no-decorate",
])
.current_dir(cwd)
.output()
.ok();
let Some(output) = output else {
return Vec::new();
};
if !output.status.success() {
return Vec::new();
}
let stdout = String::from_utf8(output.stdout).unwrap_or_default();
stdout
.lines()
.filter_map(|line| {
let line = line.trim();
if line.is_empty() {
return None;
}
let (hash, subject) = line.split_once(' ')?;
Some(GitCommitEntry {
hash: hash.to_string(),
subject: subject.to_string(),
})
})
.collect()
}
fn read_staged_files(cwd: &Path) -> Vec<String> {
let output = Command::new("git")
.args(["--no-optional-locks", "diff", "--cached", "--name-only"])
.current_dir(cwd)
.output()
.ok();
let Some(output) = output else {
return Vec::new();
};
if !output.status.success() {
return Vec::new();
}
let stdout = String::from_utf8(output.stdout).unwrap_or_default();
stdout
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| line.trim().to_string())
.collect()
}
#[cfg(test)]
mod tests {
use super::{GitCommitEntry, GitContext};
use std::fs;
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_dir(label: &str) -> std::path::PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time should be after epoch")
.as_nanos();
std::env::temp_dir().join(format!("runtime-git-context-{label}-{nanos}"))
}
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
crate::test_env_lock()
}
fn ensure_valid_cwd() {
if std::env::current_dir().is_err() {
std::env::set_current_dir(env!("CARGO_MANIFEST_DIR"))
.expect("test cwd should be recoverable");
}
}
#[test]
fn returns_none_for_non_git_directory() {
// given
let _guard = env_lock();
ensure_valid_cwd();
let root = temp_dir("non-git");
fs::create_dir_all(&root).expect("create dir");
// when
let context = GitContext::detect(&root);
// then
assert!(context.is_none());
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn detects_branch_name_and_commits() {
// given
let _guard = env_lock();
ensure_valid_cwd();
let root = temp_dir("branch-commits");
fs::create_dir_all(&root).expect("create dir");
git(&root, &["init", "--quiet", "--initial-branch=main"]);
git(&root, &["config", "user.email", "tests@example.com"]);
git(&root, &["config", "user.name", "Git Context Tests"]);
fs::write(root.join("a.txt"), "a\n").expect("write a");
git(&root, &["add", "a.txt"]);
git(&root, &["commit", "-m", "first commit", "--quiet"]);
fs::write(root.join("b.txt"), "b\n").expect("write b");
git(&root, &["add", "b.txt"]);
git(&root, &["commit", "-m", "second commit", "--quiet"]);
// when
let context = GitContext::detect(&root).expect("should detect git repo");
// then
assert_eq!(context.branch.as_deref(), Some("main"));
assert_eq!(context.recent_commits.len(), 2);
assert_eq!(context.recent_commits[0].subject, "second commit");
assert_eq!(context.recent_commits[1].subject, "first commit");
assert!(context.staged_files.is_empty());
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn detects_staged_files() {
// given
let _guard = env_lock();
ensure_valid_cwd();
let root = temp_dir("staged");
fs::create_dir_all(&root).expect("create dir");
git(&root, &["init", "--quiet", "--initial-branch=main"]);
git(&root, &["config", "user.email", "tests@example.com"]);
git(&root, &["config", "user.name", "Git Context Tests"]);
fs::write(root.join("init.txt"), "init\n").expect("write init");
git(&root, &["add", "init.txt"]);
git(&root, &["commit", "-m", "initial", "--quiet"]);
fs::write(root.join("staged.txt"), "staged\n").expect("write staged");
git(&root, &["add", "staged.txt"]);
// when
let context = GitContext::detect(&root).expect("should detect git repo");
// then
assert_eq!(context.staged_files, vec!["staged.txt"]);
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn render_formats_all_sections() {
// given
let context = GitContext {
branch: Some("feat/test".to_string()),
recent_commits: vec![
GitCommitEntry {
hash: "abc1234".to_string(),
subject: "add feature".to_string(),
},
GitCommitEntry {
hash: "def5678".to_string(),
subject: "fix bug".to_string(),
},
],
staged_files: vec!["src/main.rs".to_string()],
};
// when
let rendered = context.render();
// then
assert!(rendered.contains("Git branch: feat/test"));
assert!(rendered.contains("abc1234 add feature"));
assert!(rendered.contains("def5678 fix bug"));
assert!(rendered.contains("src/main.rs"));
}
#[test]
fn render_omits_empty_sections() {
// given
let context = GitContext {
branch: Some("main".to_string()),
recent_commits: Vec::new(),
staged_files: Vec::new(),
};
// when
let rendered = context.render();
// then
assert!(rendered.contains("Git branch: main"));
assert!(!rendered.contains("Recent commits:"));
assert!(!rendered.contains("Staged files:"));
}
#[test]
fn limits_to_five_recent_commits() {
// given
let _guard = env_lock();
ensure_valid_cwd();
let root = temp_dir("five-commits");
fs::create_dir_all(&root).expect("create dir");
git(&root, &["init", "--quiet", "--initial-branch=main"]);
git(&root, &["config", "user.email", "tests@example.com"]);
git(&root, &["config", "user.name", "Git Context Tests"]);
for i in 1..=8 {
let name = format!("file{i}.txt");
fs::write(root.join(&name), format!("{i}\n")).expect("write file");
git(&root, &["add", &name]);
git(&root, &["commit", "-m", &format!("commit {i}"), "--quiet"]);
}
// when
let context = GitContext::detect(&root).expect("should detect git repo");
// then
assert_eq!(context.recent_commits.len(), 5);
assert_eq!(context.recent_commits[0].subject, "commit 8");
assert_eq!(context.recent_commits[4].subject, "commit 4");
fs::remove_dir_all(root).expect("cleanup");
}
fn git(cwd: &std::path::Path, args: &[&str]) {
let status = Command::new("git")
.args(args)
.current_dir(cwd)
.output()
.unwrap_or_else(|_| panic!("git {args:?} should run"))
.status;
assert!(status.success(), "git {args:?} failed");
}
}
+152
View File
@@ -0,0 +1,152 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GreenLevel {
TargetedTests,
Package,
Workspace,
MergeReady,
}
impl GreenLevel {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::TargetedTests => "targeted_tests",
Self::Package => "package",
Self::Workspace => "workspace",
Self::MergeReady => "merge_ready",
}
}
}
impl std::fmt::Display for GreenLevel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct GreenContract {
pub required_level: GreenLevel,
}
impl GreenContract {
#[must_use]
pub fn new(required_level: GreenLevel) -> Self {
Self { required_level }
}
#[must_use]
pub fn evaluate(self, observed_level: Option<GreenLevel>) -> GreenContractOutcome {
match observed_level {
Some(level) if level >= self.required_level => GreenContractOutcome::Satisfied {
required_level: self.required_level,
observed_level: level,
},
_ => GreenContractOutcome::Unsatisfied {
required_level: self.required_level,
observed_level,
},
}
}
#[must_use]
pub fn is_satisfied_by(self, observed_level: GreenLevel) -> bool {
observed_level >= self.required_level
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "outcome", rename_all = "snake_case")]
pub enum GreenContractOutcome {
Satisfied {
required_level: GreenLevel,
observed_level: GreenLevel,
},
Unsatisfied {
required_level: GreenLevel,
observed_level: Option<GreenLevel>,
},
}
impl GreenContractOutcome {
#[must_use]
pub fn is_satisfied(&self) -> bool {
matches!(self, Self::Satisfied { .. })
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn given_matching_level_when_evaluating_contract_then_it_is_satisfied() {
// given
let contract = GreenContract::new(GreenLevel::Package);
// when
let outcome = contract.evaluate(Some(GreenLevel::Package));
// then
assert_eq!(
outcome,
GreenContractOutcome::Satisfied {
required_level: GreenLevel::Package,
observed_level: GreenLevel::Package,
}
);
assert!(outcome.is_satisfied());
}
#[test]
fn given_higher_level_when_checking_requirement_then_it_still_satisfies_contract() {
// given
let contract = GreenContract::new(GreenLevel::TargetedTests);
// when
let is_satisfied = contract.is_satisfied_by(GreenLevel::Workspace);
// then
assert!(is_satisfied);
}
#[test]
fn given_lower_level_when_evaluating_contract_then_it_is_unsatisfied() {
// given
let contract = GreenContract::new(GreenLevel::Workspace);
// when
let outcome = contract.evaluate(Some(GreenLevel::Package));
// then
assert_eq!(
outcome,
GreenContractOutcome::Unsatisfied {
required_level: GreenLevel::Workspace,
observed_level: Some(GreenLevel::Package),
}
);
assert!(!outcome.is_satisfied());
}
#[test]
fn given_no_green_level_when_evaluating_contract_then_contract_is_unsatisfied() {
// given
let contract = GreenContract::new(GreenLevel::MergeReady);
// when
let outcome = contract.evaluate(None);
// then
assert_eq!(
outcome,
GreenContractOutcome::Unsatisfied {
required_level: GreenLevel::MergeReady,
observed_level: None,
}
);
}
}
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -1,8 +1,7 @@
use std::collections::BTreeMap;
use std::fmt::{Display, Formatter};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum JsonValue {
Null,
Bool(bool),
+383
View File
@@ -0,0 +1,383 @@
#![allow(clippy::similar_names)]
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum LaneEventName {
#[serde(rename = "lane.started")]
Started,
#[serde(rename = "lane.ready")]
Ready,
#[serde(rename = "lane.prompt_misdelivery")]
PromptMisdelivery,
#[serde(rename = "lane.blocked")]
Blocked,
#[serde(rename = "lane.red")]
Red,
#[serde(rename = "lane.green")]
Green,
#[serde(rename = "lane.commit.created")]
CommitCreated,
#[serde(rename = "lane.pr.opened")]
PrOpened,
#[serde(rename = "lane.merge.ready")]
MergeReady,
#[serde(rename = "lane.finished")]
Finished,
#[serde(rename = "lane.failed")]
Failed,
#[serde(rename = "lane.reconciled")]
Reconciled,
#[serde(rename = "lane.merged")]
Merged,
#[serde(rename = "lane.superseded")]
Superseded,
#[serde(rename = "lane.closed")]
Closed,
#[serde(rename = "branch.stale_against_main")]
BranchStaleAgainstMain,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LaneEventStatus {
Running,
Ready,
Blocked,
Red,
Green,
Completed,
Failed,
Reconciled,
Merged,
Superseded,
Closed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LaneFailureClass {
PromptDelivery,
TrustGate,
BranchDivergence,
Compile,
Test,
PluginStartup,
McpStartup,
McpHandshake,
GatewayRouting,
ToolRuntime,
Infra,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LaneEventBlocker {
#[serde(rename = "failureClass")]
pub failure_class: LaneFailureClass,
pub detail: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LaneCommitProvenance {
pub commit: String,
pub branch: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub worktree: Option<String>,
#[serde(rename = "canonicalCommit", skip_serializing_if = "Option::is_none")]
pub canonical_commit: Option<String>,
#[serde(rename = "supersededBy", skip_serializing_if = "Option::is_none")]
pub superseded_by: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub lineage: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LaneEvent {
pub event: LaneEventName,
pub status: LaneEventStatus,
#[serde(rename = "emittedAt")]
pub emitted_at: String,
#[serde(rename = "failureClass", skip_serializing_if = "Option::is_none")]
pub failure_class: Option<LaneFailureClass>,
#[serde(skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<Value>,
}
impl LaneEvent {
#[must_use]
pub fn new(
event: LaneEventName,
status: LaneEventStatus,
emitted_at: impl Into<String>,
) -> Self {
Self {
event,
status,
emitted_at: emitted_at.into(),
failure_class: None,
detail: None,
data: None,
}
}
#[must_use]
pub fn started(emitted_at: impl Into<String>) -> Self {
Self::new(LaneEventName::Started, LaneEventStatus::Running, emitted_at)
}
#[must_use]
pub fn finished(emitted_at: impl Into<String>, detail: Option<String>) -> Self {
Self::new(
LaneEventName::Finished,
LaneEventStatus::Completed,
emitted_at,
)
.with_optional_detail(detail)
}
#[must_use]
pub fn commit_created(
emitted_at: impl Into<String>,
detail: Option<String>,
provenance: LaneCommitProvenance,
) -> Self {
Self::new(
LaneEventName::CommitCreated,
LaneEventStatus::Completed,
emitted_at,
)
.with_optional_detail(detail)
.with_data(serde_json::to_value(provenance).expect("commit provenance should serialize"))
}
#[must_use]
pub fn superseded(
emitted_at: impl Into<String>,
detail: Option<String>,
provenance: LaneCommitProvenance,
) -> Self {
Self::new(
LaneEventName::Superseded,
LaneEventStatus::Superseded,
emitted_at,
)
.with_optional_detail(detail)
.with_data(serde_json::to_value(provenance).expect("commit provenance should serialize"))
}
#[must_use]
pub fn blocked(emitted_at: impl Into<String>, blocker: &LaneEventBlocker) -> Self {
Self::new(LaneEventName::Blocked, LaneEventStatus::Blocked, emitted_at)
.with_failure_class(blocker.failure_class)
.with_detail(blocker.detail.clone())
}
#[must_use]
pub fn failed(emitted_at: impl Into<String>, blocker: &LaneEventBlocker) -> Self {
Self::new(LaneEventName::Failed, LaneEventStatus::Failed, emitted_at)
.with_failure_class(blocker.failure_class)
.with_detail(blocker.detail.clone())
}
#[must_use]
pub fn with_failure_class(mut self, failure_class: LaneFailureClass) -> Self {
self.failure_class = Some(failure_class);
self
}
#[must_use]
pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
self.detail = Some(detail.into());
self
}
#[must_use]
pub fn with_optional_detail(mut self, detail: Option<String>) -> Self {
self.detail = detail;
self
}
#[must_use]
pub fn with_data(mut self, data: Value) -> Self {
self.data = Some(data);
self
}
}
#[must_use]
pub fn dedupe_superseded_commit_events(events: &[LaneEvent]) -> Vec<LaneEvent> {
let mut keep = vec![true; events.len()];
let mut latest_by_key = std::collections::BTreeMap::<String, usize>::new();
for (index, event) in events.iter().enumerate() {
if event.event != LaneEventName::CommitCreated {
continue;
}
let Some(data) = event.data.as_ref() else {
continue;
};
let key = data
.get("canonicalCommit")
.or_else(|| data.get("commit"))
.and_then(serde_json::Value::as_str)
.map(str::to_string);
let superseded = data
.get("supersededBy")
.and_then(serde_json::Value::as_str)
.is_some();
if superseded {
keep[index] = false;
continue;
}
if let Some(key) = key {
if let Some(previous) = latest_by_key.insert(key, index) {
keep[previous] = false;
}
}
}
events
.iter()
.cloned()
.zip(keep)
.filter_map(|(event, retain)| retain.then_some(event))
.collect()
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::{
dedupe_superseded_commit_events, LaneCommitProvenance, LaneEvent, LaneEventBlocker,
LaneEventName, LaneEventStatus, LaneFailureClass,
};
#[test]
fn canonical_lane_event_names_serialize_to_expected_wire_values() {
let cases = [
(LaneEventName::Started, "lane.started"),
(LaneEventName::Ready, "lane.ready"),
(LaneEventName::PromptMisdelivery, "lane.prompt_misdelivery"),
(LaneEventName::Blocked, "lane.blocked"),
(LaneEventName::Red, "lane.red"),
(LaneEventName::Green, "lane.green"),
(LaneEventName::CommitCreated, "lane.commit.created"),
(LaneEventName::PrOpened, "lane.pr.opened"),
(LaneEventName::MergeReady, "lane.merge.ready"),
(LaneEventName::Finished, "lane.finished"),
(LaneEventName::Failed, "lane.failed"),
(LaneEventName::Reconciled, "lane.reconciled"),
(LaneEventName::Merged, "lane.merged"),
(LaneEventName::Superseded, "lane.superseded"),
(LaneEventName::Closed, "lane.closed"),
(
LaneEventName::BranchStaleAgainstMain,
"branch.stale_against_main",
),
];
for (event, expected) in cases {
assert_eq!(
serde_json::to_value(event).expect("serialize event"),
json!(expected)
);
}
}
#[test]
fn failure_classes_cover_canonical_taxonomy_wire_values() {
let cases = [
(LaneFailureClass::PromptDelivery, "prompt_delivery"),
(LaneFailureClass::TrustGate, "trust_gate"),
(LaneFailureClass::BranchDivergence, "branch_divergence"),
(LaneFailureClass::Compile, "compile"),
(LaneFailureClass::Test, "test"),
(LaneFailureClass::PluginStartup, "plugin_startup"),
(LaneFailureClass::McpStartup, "mcp_startup"),
(LaneFailureClass::McpHandshake, "mcp_handshake"),
(LaneFailureClass::GatewayRouting, "gateway_routing"),
(LaneFailureClass::ToolRuntime, "tool_runtime"),
(LaneFailureClass::Infra, "infra"),
];
for (failure_class, expected) in cases {
assert_eq!(
serde_json::to_value(failure_class).expect("serialize failure class"),
json!(expected)
);
}
}
#[test]
fn blocked_and_failed_events_reuse_blocker_details() {
let blocker = LaneEventBlocker {
failure_class: LaneFailureClass::McpStartup,
detail: "broken server".to_string(),
};
let blocked = LaneEvent::blocked("2026-04-04T00:00:00Z", &blocker);
let failed = LaneEvent::failed("2026-04-04T00:00:01Z", &blocker);
assert_eq!(blocked.event, LaneEventName::Blocked);
assert_eq!(blocked.status, LaneEventStatus::Blocked);
assert_eq!(blocked.failure_class, Some(LaneFailureClass::McpStartup));
assert_eq!(failed.event, LaneEventName::Failed);
assert_eq!(failed.status, LaneEventStatus::Failed);
assert_eq!(failed.detail.as_deref(), Some("broken server"));
}
#[test]
fn commit_events_can_carry_worktree_and_supersession_metadata() {
let event = LaneEvent::commit_created(
"2026-04-04T00:00:00Z",
Some("commit created".to_string()),
LaneCommitProvenance {
commit: "abc123".to_string(),
branch: "feature/provenance".to_string(),
worktree: Some("wt-a".to_string()),
canonical_commit: Some("abc123".to_string()),
superseded_by: None,
lineage: vec!["abc123".to_string()],
},
);
let event_json = serde_json::to_value(&event).expect("lane event should serialize");
assert_eq!(event_json["event"], "lane.commit.created");
assert_eq!(event_json["data"]["branch"], "feature/provenance");
assert_eq!(event_json["data"]["worktree"], "wt-a");
}
#[test]
fn dedupes_superseded_commit_events_by_canonical_commit() {
let retained = dedupe_superseded_commit_events(&[
LaneEvent::commit_created(
"2026-04-04T00:00:00Z",
Some("old".to_string()),
LaneCommitProvenance {
commit: "old123".to_string(),
branch: "feature/provenance".to_string(),
worktree: Some("wt-a".to_string()),
canonical_commit: Some("canon123".to_string()),
superseded_by: Some("new123".to_string()),
lineage: vec!["old123".to_string(), "new123".to_string()],
},
),
LaneEvent::commit_created(
"2026-04-04T00:00:01Z",
Some("new".to_string()),
LaneCommitProvenance {
commit: "new123".to_string(),
branch: "feature/provenance".to_string(),
worktree: Some("wt-b".to_string()),
canonical_commit: Some("canon123".to_string()),
superseded_by: None,
lineage: vec!["old123".to_string(), "new123".to_string()],
},
),
]);
assert_eq!(retained.len(), 1);
assert_eq!(retained[0].detail.as_deref(), Some("new"));
}
}
+105 -20
View File
@@ -1,64 +1,112 @@
//! Core runtime primitives for the `claw` CLI and supporting crates.
//!
//! This crate owns session persistence, permission evaluation, prompt assembly,
//! MCP plumbing, tool-facing file operations, and the core conversation loop
//! that drives interactive and one-shot turns.
mod bash;
pub mod bash_validation;
mod bootstrap;
pub mod branch_lock;
mod compact;
mod config;
pub mod config_validate;
mod conversation;
mod file_ops;
mod git_context;
pub mod green_contract;
mod hooks;
mod json;
mod lane_events;
pub mod lsp_client;
mod mcp;
mod mcp_client;
pub mod mcp_lifecycle_hardened;
pub mod mcp_server;
mod mcp_stdio;
pub mod mcp_tool_bridge;
mod oauth;
pub mod permission_enforcer;
mod permissions;
pub mod plugin_lifecycle;
mod policy_engine;
mod prompt;
pub mod recovery_recipes;
mod remote;
pub mod sandbox;
mod session;
pub mod session_control;
pub use session_control::SessionStore;
mod sse;
pub mod stale_base;
pub mod stale_branch;
pub mod summary_compression;
pub mod task_packet;
pub mod task_registry;
pub mod team_cron_registry;
#[cfg(test)]
mod trust_resolver;
mod usage;
pub mod worker_boot;
pub use lsp::{
FileDiagnostics, LspContextEnrichment, LspError, LspManager, LspServerConfig,
SymbolLocation, WorkspaceDiagnostics,
};
pub use bash::{execute_bash, BashCommandInput, BashCommandOutput};
pub use bootstrap::{BootstrapPhase, BootstrapPlan};
pub use branch_lock::{detect_branch_lock_collisions, BranchLockCollision, BranchLockIntent};
pub use compact::{
compact_session, estimate_session_tokens, format_compact_summary,
get_compact_continuation_message, should_compact, CompactionConfig, CompactionResult,
};
pub use config::{
ConfigEntry, ConfigError, ConfigLoader, ConfigSource, McpManagedProxyServerConfig,
McpConfigCollection, McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig,
ConfigEntry, ConfigError, ConfigLoader, ConfigSource, McpConfigCollection,
McpManagedProxyServerConfig, McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig,
McpServerConfig, McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig,
ResolvedPermissionMode, RuntimeConfig, RuntimeFeatureConfig, RuntimeHookConfig,
RuntimePluginConfig, ScopedMcpServerConfig, CLAW_SETTINGS_SCHEMA_NAME,
ProviderFallbackConfig, ResolvedPermissionMode, RuntimeConfig, RuntimeFeatureConfig,
RuntimeHookConfig, RuntimePermissionRuleConfig, RuntimePluginConfig, ScopedMcpServerConfig,
CLAW_SETTINGS_SCHEMA_NAME,
};
pub use config_validate::{
check_unsupported_format, format_diagnostics, validate_config_file, ConfigDiagnostic,
DiagnosticKind, ValidationResult,
};
pub use conversation::{
ApiClient, ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError, StaticToolExecutor,
ToolError, ToolExecutor, TurnSummary,
auto_compaction_threshold_from_env, ApiClient, ApiRequest, AssistantEvent, AutoCompactionEvent,
ConversationRuntime, PromptCacheEvent, RuntimeError, StaticToolExecutor, ToolError,
ToolExecutor, TurnSummary,
};
pub use file_ops::{
edit_file, glob_search, grep_search, read_file, write_file, EditFileOutput, GlobSearchOutput,
GrepSearchInput, GrepSearchOutput, ReadFileOutput, StructuredPatchHunk, TextFilePayload,
WriteFileOutput,
};
pub use hooks::{HookEvent, HookRunResult, HookRunner};
pub use git_context::{GitCommitEntry, GitContext};
pub use hooks::{
HookAbortSignal, HookEvent, HookProgressEvent, HookProgressReporter, HookRunResult, HookRunner,
};
pub use lane_events::{
dedupe_superseded_commit_events, LaneCommitProvenance, LaneEvent, LaneEventBlocker,
LaneEventName, LaneEventStatus, LaneFailureClass,
};
pub use mcp::{
mcp_server_signature, mcp_tool_name, mcp_tool_prefix, normalize_name_for_mcp,
scoped_mcp_config_hash, unwrap_ccr_proxy_url,
};
pub use mcp_client::{
McpManagedProxyTransport, McpClientAuth, McpClientBootstrap, McpClientTransport,
McpClientAuth, McpClientBootstrap, McpClientTransport, McpManagedProxyTransport,
McpRemoteTransport, McpSdkTransport, McpStdioTransport,
};
pub use mcp_lifecycle_hardened::{
McpDegradedReport, McpErrorSurface, McpFailedServer, McpLifecyclePhase, McpLifecycleState,
McpLifecycleValidator, McpPhaseResult,
};
pub use mcp_server::{McpServer, McpServerSpec, ToolCallHandler, MCP_SERVER_PROTOCOL_VERSION};
pub use mcp_stdio::{
spawn_mcp_stdio_process, JsonRpcError, JsonRpcId, JsonRpcRequest, JsonRpcResponse,
ManagedMcpTool, McpInitializeClientInfo, McpInitializeParams, McpInitializeResult,
McpInitializeServerInfo, McpListResourcesParams, McpListResourcesResult, McpListToolsParams,
McpListToolsResult, McpReadResourceParams, McpReadResourceResult, McpResource,
McpResourceContents, McpServerManager, McpServerManagerError, McpStdioProcess, McpTool,
McpToolCallContent, McpToolCallParams, McpToolCallResult, UnsupportedMcpServer,
ManagedMcpTool, McpDiscoveryFailure, McpInitializeClientInfo, McpInitializeParams,
McpInitializeResult, McpInitializeServerInfo, McpListResourcesParams, McpListResourcesResult,
McpListToolsParams, McpListToolsResult, McpReadResourceParams, McpReadResourceResult,
McpResource, McpResourceContents, McpServerManager, McpServerManagerError, McpStdioProcess,
McpTool, McpToolCallContent, McpToolCallParams, McpToolCallResult, McpToolDiscoveryReport,
UnsupportedMcpServer,
};
pub use oauth::{
clear_oauth_credentials, code_challenge_s256, credentials_path, generate_pkce_pair,
@@ -68,22 +116,59 @@ pub use oauth::{
PkceChallengeMethod, PkceCodePair,
};
pub use permissions::{
PermissionMode, PermissionOutcome, PermissionPolicy, PermissionPromptDecision,
PermissionPrompter, PermissionRequest,
PermissionContext, PermissionMode, PermissionOutcome, PermissionOverride, PermissionPolicy,
PermissionPromptDecision, PermissionPrompter, PermissionRequest,
};
pub use plugin_lifecycle::{
DegradedMode, DiscoveryResult, PluginHealthcheck, PluginLifecycle, PluginLifecycleEvent,
PluginState, ResourceInfo, ServerHealth, ServerStatus, ToolInfo,
};
pub use policy_engine::{
evaluate, DiffScope, GreenLevel, LaneBlocker, LaneContext, PolicyAction, PolicyCondition,
PolicyEngine, PolicyRule, ReconcileReason, ReviewStatus,
};
pub use prompt::{
load_system_prompt, prepend_bullets, ContextFile, ProjectContext, PromptBuildError,
SystemPromptBuilder, FRONTIER_MODEL_NAME, SYSTEM_PROMPT_DYNAMIC_BOUNDARY,
};
pub use recovery_recipes::{
attempt_recovery, recipe_for, EscalationPolicy, FailureScenario, RecoveryContext,
RecoveryEvent, RecoveryRecipe, RecoveryResult, RecoveryStep,
};
pub use remote::{
inherited_upstream_proxy_env, no_proxy_list, read_token, upstream_proxy_ws_url,
RemoteSessionContext, UpstreamProxyBootstrap, UpstreamProxyState, DEFAULT_REMOTE_BASE_URL,
DEFAULT_SESSION_TOKEN_PATH, DEFAULT_SYSTEM_CA_BUNDLE, NO_PROXY_HOSTS, UPSTREAM_PROXY_ENV_KEYS,
};
pub use session::{ContentBlock, ConversationMessage, MessageRole, Session, SessionError};
pub use sandbox::{
build_linux_sandbox_command, detect_container_environment, detect_container_environment_from,
resolve_sandbox_status, resolve_sandbox_status_for_request, ContainerEnvironment,
FilesystemIsolationMode, LinuxSandboxCommand, SandboxConfig, SandboxDetectionInputs,
SandboxRequest, SandboxStatus,
};
pub use session::{
ContentBlock, ConversationMessage, MessageRole, Session, SessionCompaction, SessionError,
SessionFork, SessionPromptEntry,
};
pub use sse::{IncrementalSseParser, SseEvent};
pub use stale_base::{
check_base_commit, format_stale_base_warning, read_claw_base_file, resolve_expected_base,
BaseCommitSource, BaseCommitState,
};
pub use stale_branch::{
apply_policy, check_freshness, BranchFreshness, StaleBranchAction, StaleBranchEvent,
StaleBranchPolicy,
};
pub use task_packet::{validate_packet, TaskPacket, TaskPacketValidationError, ValidatedPacket};
#[cfg(test)]
pub use trust_resolver::{TrustConfig, TrustDecision, TrustEvent, TrustPolicy, TrustResolver};
pub use usage::{
format_usd, pricing_for_model, ModelPricing, TokenUsage, UsageCostEstimate, UsageTracker,
};
pub use worker_boot::{
Worker, WorkerEvent, WorkerEventKind, WorkerEventPayload, WorkerFailure, WorkerFailureKind,
WorkerPromptTarget, WorkerReadySnapshot, WorkerRegistry, WorkerStatus, WorkerTrustResolution,
};
#[cfg(test)]
pub(crate) fn test_env_lock() -> std::sync::MutexGuard<'static, ()> {
+747
View File
@@ -0,0 +1,747 @@
#![allow(clippy::should_implement_trait, clippy::must_use_candidate)]
//! LSP (Language Server Protocol) client registry for tool dispatch.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use serde::{Deserialize, Serialize};
/// Supported LSP actions.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LspAction {
Diagnostics,
Hover,
Definition,
References,
Completion,
Symbols,
Format,
}
impl LspAction {
pub fn from_str(s: &str) -> Option<Self> {
match s {
"diagnostics" => Some(Self::Diagnostics),
"hover" => Some(Self::Hover),
"definition" | "goto_definition" => Some(Self::Definition),
"references" | "find_references" => Some(Self::References),
"completion" | "completions" => Some(Self::Completion),
"symbols" | "document_symbols" => Some(Self::Symbols),
"format" | "formatting" => Some(Self::Format),
_ => None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LspDiagnostic {
pub path: String,
pub line: u32,
pub character: u32,
pub severity: String,
pub message: String,
pub source: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LspLocation {
pub path: String,
pub line: u32,
pub character: u32,
pub end_line: Option<u32>,
pub end_character: Option<u32>,
pub preview: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LspHoverResult {
pub content: String,
pub language: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LspCompletionItem {
pub label: String,
pub kind: Option<String>,
pub detail: Option<String>,
pub insert_text: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LspSymbol {
pub name: String,
pub kind: String,
pub path: String,
pub line: u32,
pub character: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LspServerStatus {
Connected,
Disconnected,
Starting,
Error,
}
impl std::fmt::Display for LspServerStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Connected => write!(f, "connected"),
Self::Disconnected => write!(f, "disconnected"),
Self::Starting => write!(f, "starting"),
Self::Error => write!(f, "error"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LspServerState {
pub language: String,
pub status: LspServerStatus,
pub root_path: Option<String>,
pub capabilities: Vec<String>,
pub diagnostics: Vec<LspDiagnostic>,
}
#[derive(Debug, Clone, Default)]
pub struct LspRegistry {
inner: Arc<Mutex<RegistryInner>>,
}
#[derive(Debug, Default)]
struct RegistryInner {
servers: HashMap<String, LspServerState>,
}
impl LspRegistry {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn register(
&self,
language: &str,
status: LspServerStatus,
root_path: Option<&str>,
capabilities: Vec<String>,
) {
let mut inner = self.inner.lock().expect("lsp registry lock poisoned");
inner.servers.insert(
language.to_owned(),
LspServerState {
language: language.to_owned(),
status,
root_path: root_path.map(str::to_owned),
capabilities,
diagnostics: Vec::new(),
},
);
}
pub fn get(&self, language: &str) -> Option<LspServerState> {
let inner = self.inner.lock().expect("lsp registry lock poisoned");
inner.servers.get(language).cloned()
}
/// Find the appropriate server for a file path based on extension.
pub fn find_server_for_path(&self, path: &str) -> Option<LspServerState> {
let ext = std::path::Path::new(path)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
let language = match ext {
"rs" => "rust",
"ts" | "tsx" => "typescript",
"js" | "jsx" => "javascript",
"py" => "python",
"go" => "go",
"java" => "java",
"c" | "h" => "c",
"cpp" | "hpp" | "cc" => "cpp",
"rb" => "ruby",
"lua" => "lua",
_ => return None,
};
self.get(language)
}
/// List all registered servers.
pub fn list_servers(&self) -> Vec<LspServerState> {
let inner = self.inner.lock().expect("lsp registry lock poisoned");
inner.servers.values().cloned().collect()
}
/// Add diagnostics to a server.
pub fn add_diagnostics(
&self,
language: &str,
diagnostics: Vec<LspDiagnostic>,
) -> Result<(), String> {
let mut inner = self.inner.lock().expect("lsp registry lock poisoned");
let server = inner
.servers
.get_mut(language)
.ok_or_else(|| format!("LSP server not found for language: {language}"))?;
server.diagnostics.extend(diagnostics);
Ok(())
}
/// Get diagnostics for a specific file path.
pub fn get_diagnostics(&self, path: &str) -> Vec<LspDiagnostic> {
let inner = self.inner.lock().expect("lsp registry lock poisoned");
inner
.servers
.values()
.flat_map(|s| &s.diagnostics)
.filter(|d| d.path == path)
.cloned()
.collect()
}
/// Clear diagnostics for a language server.
pub fn clear_diagnostics(&self, language: &str) -> Result<(), String> {
let mut inner = self.inner.lock().expect("lsp registry lock poisoned");
let server = inner
.servers
.get_mut(language)
.ok_or_else(|| format!("LSP server not found for language: {language}"))?;
server.diagnostics.clear();
Ok(())
}
/// Disconnect a server.
pub fn disconnect(&self, language: &str) -> Option<LspServerState> {
let mut inner = self.inner.lock().expect("lsp registry lock poisoned");
inner.servers.remove(language)
}
#[must_use]
pub fn len(&self) -> usize {
let inner = self.inner.lock().expect("lsp registry lock poisoned");
inner.servers.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
/// Dispatch an LSP action and return a structured result.
pub fn dispatch(
&self,
action: &str,
path: Option<&str>,
line: Option<u32>,
character: Option<u32>,
_query: Option<&str>,
) -> Result<serde_json::Value, String> {
let lsp_action =
LspAction::from_str(action).ok_or_else(|| format!("unknown LSP action: {action}"))?;
// For diagnostics, we can check existing cached diagnostics
if lsp_action == LspAction::Diagnostics {
if let Some(path) = path {
let diags = self.get_diagnostics(path);
return Ok(serde_json::json!({
"action": "diagnostics",
"path": path,
"diagnostics": diags,
"count": diags.len()
}));
}
// All diagnostics across all servers
let inner = self.inner.lock().expect("lsp registry lock poisoned");
let all_diags: Vec<_> = inner
.servers
.values()
.flat_map(|s| &s.diagnostics)
.collect();
return Ok(serde_json::json!({
"action": "diagnostics",
"diagnostics": all_diags,
"count": all_diags.len()
}));
}
// For other actions, we need a connected server for the given file
let path = path.ok_or("path is required for this LSP action")?;
let server = self
.find_server_for_path(path)
.ok_or_else(|| format!("no LSP server available for path: {path}"))?;
if server.status != LspServerStatus::Connected {
return Err(format!(
"LSP server for '{}' is not connected (status: {})",
server.language, server.status
));
}
// Return structured placeholder — actual LSP JSON-RPC calls would
// go through the real LSP process here.
Ok(serde_json::json!({
"action": action,
"path": path,
"line": line,
"character": character,
"language": server.language,
"status": "dispatched",
"message": format!("LSP {} dispatched to {} server", action, server.language)
}))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn registers_and_retrieves_server() {
let registry = LspRegistry::new();
registry.register(
"rust",
LspServerStatus::Connected,
Some("/workspace"),
vec!["hover".into(), "completion".into()],
);
let server = registry.get("rust").expect("should exist");
assert_eq!(server.language, "rust");
assert_eq!(server.status, LspServerStatus::Connected);
assert_eq!(server.capabilities.len(), 2);
}
#[test]
fn finds_server_by_file_extension() {
let registry = LspRegistry::new();
registry.register("rust", LspServerStatus::Connected, None, vec![]);
registry.register("typescript", LspServerStatus::Connected, None, vec![]);
let rs_server = registry.find_server_for_path("src/main.rs").unwrap();
assert_eq!(rs_server.language, "rust");
let ts_server = registry.find_server_for_path("src/index.ts").unwrap();
assert_eq!(ts_server.language, "typescript");
assert!(registry.find_server_for_path("data.csv").is_none());
}
#[test]
fn manages_diagnostics() {
let registry = LspRegistry::new();
registry.register("rust", LspServerStatus::Connected, None, vec![]);
registry
.add_diagnostics(
"rust",
vec![LspDiagnostic {
path: "src/main.rs".into(),
line: 10,
character: 5,
severity: "error".into(),
message: "mismatched types".into(),
source: Some("rust-analyzer".into()),
}],
)
.unwrap();
let diags = registry.get_diagnostics("src/main.rs");
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].message, "mismatched types");
registry.clear_diagnostics("rust").unwrap();
assert!(registry.get_diagnostics("src/main.rs").is_empty());
}
#[test]
fn dispatches_diagnostics_action() {
let registry = LspRegistry::new();
registry.register("rust", LspServerStatus::Connected, None, vec![]);
registry
.add_diagnostics(
"rust",
vec![LspDiagnostic {
path: "src/lib.rs".into(),
line: 1,
character: 0,
severity: "warning".into(),
message: "unused import".into(),
source: None,
}],
)
.unwrap();
let result = registry
.dispatch("diagnostics", Some("src/lib.rs"), None, None, None)
.unwrap();
assert_eq!(result["count"], 1);
}
#[test]
fn dispatches_hover_action() {
let registry = LspRegistry::new();
registry.register("rust", LspServerStatus::Connected, None, vec![]);
let result = registry
.dispatch("hover", Some("src/main.rs"), Some(10), Some(5), None)
.unwrap();
assert_eq!(result["action"], "hover");
assert_eq!(result["language"], "rust");
}
#[test]
fn rejects_action_on_disconnected_server() {
let registry = LspRegistry::new();
registry.register("rust", LspServerStatus::Disconnected, None, vec![]);
assert!(registry
.dispatch("hover", Some("src/main.rs"), Some(1), Some(0), None)
.is_err());
}
#[test]
fn rejects_unknown_action() {
let registry = LspRegistry::new();
assert!(registry
.dispatch("unknown_action", Some("file.rs"), None, None, None)
.is_err());
}
#[test]
fn disconnects_server() {
let registry = LspRegistry::new();
registry.register("rust", LspServerStatus::Connected, None, vec![]);
assert_eq!(registry.len(), 1);
let removed = registry.disconnect("rust");
assert!(removed.is_some());
assert!(registry.is_empty());
}
#[test]
fn lsp_action_from_str_all_aliases() {
// given
let cases = [
("diagnostics", Some(LspAction::Diagnostics)),
("hover", Some(LspAction::Hover)),
("definition", Some(LspAction::Definition)),
("goto_definition", Some(LspAction::Definition)),
("references", Some(LspAction::References)),
("find_references", Some(LspAction::References)),
("completion", Some(LspAction::Completion)),
("completions", Some(LspAction::Completion)),
("symbols", Some(LspAction::Symbols)),
("document_symbols", Some(LspAction::Symbols)),
("format", Some(LspAction::Format)),
("formatting", Some(LspAction::Format)),
("unknown", None),
];
// when
let resolved: Vec<_> = cases
.into_iter()
.map(|(input, expected)| (input, LspAction::from_str(input), expected))
.collect();
// then
for (input, actual, expected) in resolved {
assert_eq!(actual, expected, "unexpected action resolution for {input}");
}
}
#[test]
fn lsp_server_status_display_all_variants() {
// given
let cases = [
(LspServerStatus::Connected, "connected"),
(LspServerStatus::Disconnected, "disconnected"),
(LspServerStatus::Starting, "starting"),
(LspServerStatus::Error, "error"),
];
// when
let rendered: Vec<_> = cases
.into_iter()
.map(|(status, expected)| (status.to_string(), expected))
.collect();
// then
assert_eq!(
rendered,
vec![
("connected".to_string(), "connected"),
("disconnected".to_string(), "disconnected"),
("starting".to_string(), "starting"),
("error".to_string(), "error"),
]
);
}
#[test]
fn dispatch_diagnostics_without_path_aggregates() {
// given
let registry = LspRegistry::new();
registry.register("rust", LspServerStatus::Connected, None, vec![]);
registry.register("python", LspServerStatus::Connected, None, vec![]);
registry
.add_diagnostics(
"rust",
vec![LspDiagnostic {
path: "src/lib.rs".into(),
line: 1,
character: 0,
severity: "warning".into(),
message: "unused import".into(),
source: Some("rust-analyzer".into()),
}],
)
.expect("rust diagnostics should add");
registry
.add_diagnostics(
"python",
vec![LspDiagnostic {
path: "script.py".into(),
line: 2,
character: 4,
severity: "error".into(),
message: "undefined name".into(),
source: Some("pyright".into()),
}],
)
.expect("python diagnostics should add");
// when
let result = registry
.dispatch("diagnostics", None, None, None, None)
.expect("aggregate diagnostics should work");
// then
assert_eq!(result["action"], "diagnostics");
assert_eq!(result["count"], 2);
assert_eq!(result["diagnostics"].as_array().map(Vec::len), Some(2));
}
#[test]
fn dispatch_non_diagnostics_requires_path() {
// given
let registry = LspRegistry::new();
// when
let result = registry.dispatch("hover", None, Some(1), Some(0), None);
// then
assert_eq!(
result.expect_err("path should be required"),
"path is required for this LSP action"
);
}
#[test]
fn dispatch_no_server_for_path_errors() {
// given
let registry = LspRegistry::new();
// when
let result = registry.dispatch("hover", Some("notes.md"), Some(1), Some(0), None);
// then
let error = result.expect_err("missing server should fail");
assert!(error.contains("no LSP server available for path: notes.md"));
}
#[test]
fn dispatch_disconnected_server_error_payload() {
// given
let registry = LspRegistry::new();
registry.register("typescript", LspServerStatus::Disconnected, None, vec![]);
// when
let result = registry.dispatch("hover", Some("src/index.ts"), Some(3), Some(2), None);
// then
let error = result.expect_err("disconnected server should fail");
assert!(error.contains("typescript"));
assert!(error.contains("disconnected"));
}
#[test]
fn find_server_for_all_extensions() {
// given
let registry = LspRegistry::new();
for language in [
"rust",
"typescript",
"javascript",
"python",
"go",
"java",
"c",
"cpp",
"ruby",
"lua",
] {
registry.register(language, LspServerStatus::Connected, None, vec![]);
}
let cases = [
("src/main.rs", "rust"),
("src/index.ts", "typescript"),
("src/view.tsx", "typescript"),
("src/app.js", "javascript"),
("src/app.jsx", "javascript"),
("script.py", "python"),
("main.go", "go"),
("Main.java", "java"),
("native.c", "c"),
("native.h", "c"),
("native.cpp", "cpp"),
("native.hpp", "cpp"),
("native.cc", "cpp"),
("script.rb", "ruby"),
("script.lua", "lua"),
];
// when
let resolved: Vec<_> = cases
.into_iter()
.map(|(path, expected)| {
(
path,
registry
.find_server_for_path(path)
.map(|server| server.language),
expected,
)
})
.collect();
// then
for (path, actual, expected) in resolved {
assert_eq!(
actual.as_deref(),
Some(expected),
"unexpected mapping for {path}"
);
}
}
#[test]
fn find_server_for_path_no_extension() {
// given
let registry = LspRegistry::new();
registry.register("rust", LspServerStatus::Connected, None, vec![]);
// when
let result = registry.find_server_for_path("Makefile");
// then
assert!(result.is_none());
}
#[test]
fn list_servers_with_multiple() {
// given
let registry = LspRegistry::new();
registry.register("rust", LspServerStatus::Connected, None, vec![]);
registry.register("typescript", LspServerStatus::Starting, None, vec![]);
registry.register("python", LspServerStatus::Error, None, vec![]);
// when
let servers = registry.list_servers();
// then
assert_eq!(servers.len(), 3);
assert!(servers.iter().any(|server| server.language == "rust"));
assert!(servers.iter().any(|server| server.language == "typescript"));
assert!(servers.iter().any(|server| server.language == "python"));
}
#[test]
fn get_missing_server_returns_none() {
// given
let registry = LspRegistry::new();
// when
let server = registry.get("missing");
// then
assert!(server.is_none());
}
#[test]
fn add_diagnostics_missing_language_errors() {
// given
let registry = LspRegistry::new();
// when
let result = registry.add_diagnostics("missing", vec![]);
// then
let error = result.expect_err("missing language should fail");
assert!(error.contains("LSP server not found for language: missing"));
}
#[test]
fn get_diagnostics_across_servers() {
// given
let registry = LspRegistry::new();
let shared_path = "shared/file.txt";
registry.register("rust", LspServerStatus::Connected, None, vec![]);
registry.register("python", LspServerStatus::Connected, None, vec![]);
registry
.add_diagnostics(
"rust",
vec![LspDiagnostic {
path: shared_path.into(),
line: 4,
character: 1,
severity: "warning".into(),
message: "warn".into(),
source: None,
}],
)
.expect("rust diagnostics should add");
registry
.add_diagnostics(
"python",
vec![LspDiagnostic {
path: shared_path.into(),
line: 8,
character: 3,
severity: "error".into(),
message: "err".into(),
source: None,
}],
)
.expect("python diagnostics should add");
// when
let diagnostics = registry.get_diagnostics(shared_path);
// then
assert_eq!(diagnostics.len(), 2);
assert!(diagnostics
.iter()
.any(|diagnostic| diagnostic.message == "warn"));
assert!(diagnostics
.iter()
.any(|diagnostic| diagnostic.message == "err"));
}
#[test]
fn clear_diagnostics_missing_language_errors() {
// given
let registry = LspRegistry::new();
// when
let result = registry.clear_diagnostics("missing");
// then
let error = result.expect_err("missing language should fail");
assert!(error.contains("LSP server not found for language: missing"));
}
}
+6 -2
View File
@@ -84,10 +84,13 @@ pub fn mcp_server_signature(config: &McpServerConfig) -> Option<String> {
pub fn scoped_mcp_config_hash(config: &ScopedMcpServerConfig) -> String {
let rendered = match &config.config {
McpServerConfig::Stdio(stdio) => format!(
"stdio|{}|{}|{}",
"stdio|{}|{}|{}|{}",
stdio.command,
render_command_signature(&stdio.args),
render_env_signature(&stdio.env)
render_env_signature(&stdio.env),
stdio
.tool_call_timeout_ms
.map_or_else(String::new, |timeout_ms| timeout_ms.to_string())
),
McpServerConfig::Sse(remote) => format!(
"sse|{}|{}|{}|{}",
@@ -245,6 +248,7 @@ mod tests {
command: "uvx".to_string(),
args: vec!["mcp-server".to_string()],
env: BTreeMap::from([("TOKEN".to_string(), "secret".to_string())]),
tool_call_timeout_ms: None,
});
assert_eq!(
mcp_server_signature(&stdio),
+14
View File
@@ -3,6 +3,8 @@ use std::collections::BTreeMap;
use crate::config::{McpOAuthConfig, McpServerConfig, ScopedMcpServerConfig};
use crate::mcp::{mcp_server_signature, mcp_tool_prefix, normalize_name_for_mcp};
pub const DEFAULT_MCP_TOOL_CALL_TIMEOUT_MS: u64 = 60_000;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum McpClientTransport {
Stdio(McpStdioTransport),
@@ -18,6 +20,7 @@ pub struct McpStdioTransport {
pub command: String,
pub args: Vec<String>,
pub env: BTreeMap<String, String>,
pub tool_call_timeout_ms: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -75,6 +78,7 @@ impl McpClientTransport {
command: config.command.clone(),
args: config.args.clone(),
env: config.env.clone(),
tool_call_timeout_ms: config.tool_call_timeout_ms,
}),
McpServerConfig::Sse(config) => Self::Sse(McpRemoteTransport {
url: config.url.clone(),
@@ -105,6 +109,14 @@ impl McpClientTransport {
}
}
impl McpStdioTransport {
#[must_use]
pub fn resolved_tool_call_timeout_ms(&self) -> u64 {
self.tool_call_timeout_ms
.unwrap_or(DEFAULT_MCP_TOOL_CALL_TIMEOUT_MS)
}
}
impl McpClientAuth {
#[must_use]
pub fn from_oauth(oauth: Option<McpOAuthConfig>) -> Self {
@@ -136,6 +148,7 @@ mod tests {
command: "uvx".to_string(),
args: vec!["mcp-server".to_string()],
env: BTreeMap::from([("TOKEN".to_string(), "secret".to_string())]),
tool_call_timeout_ms: Some(15_000),
}),
};
@@ -154,6 +167,7 @@ mod tests {
transport.env.get("TOKEN").map(String::as_str),
Some("secret")
);
assert_eq!(transport.tool_call_timeout_ms, Some(15_000));
}
other => panic!("expected stdio transport, got {other:?}"),
}
@@ -0,0 +1,843 @@
#![allow(clippy::unnested_or_patterns, clippy::map_unwrap_or)]
use std::collections::{BTreeMap, BTreeSet};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum McpLifecyclePhase {
ConfigLoad,
ServerRegistration,
SpawnConnect,
InitializeHandshake,
ToolDiscovery,
ResourceDiscovery,
Ready,
Invocation,
ErrorSurfacing,
Shutdown,
Cleanup,
}
impl McpLifecyclePhase {
#[must_use]
pub fn all() -> [Self; 11] {
[
Self::ConfigLoad,
Self::ServerRegistration,
Self::SpawnConnect,
Self::InitializeHandshake,
Self::ToolDiscovery,
Self::ResourceDiscovery,
Self::Ready,
Self::Invocation,
Self::ErrorSurfacing,
Self::Shutdown,
Self::Cleanup,
]
}
}
impl std::fmt::Display for McpLifecyclePhase {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::ConfigLoad => write!(f, "config_load"),
Self::ServerRegistration => write!(f, "server_registration"),
Self::SpawnConnect => write!(f, "spawn_connect"),
Self::InitializeHandshake => write!(f, "initialize_handshake"),
Self::ToolDiscovery => write!(f, "tool_discovery"),
Self::ResourceDiscovery => write!(f, "resource_discovery"),
Self::Ready => write!(f, "ready"),
Self::Invocation => write!(f, "invocation"),
Self::ErrorSurfacing => write!(f, "error_surfacing"),
Self::Shutdown => write!(f, "shutdown"),
Self::Cleanup => write!(f, "cleanup"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct McpErrorSurface {
pub phase: McpLifecyclePhase,
pub server_name: Option<String>,
pub message: String,
pub context: BTreeMap<String, String>,
pub recoverable: bool,
pub timestamp: u64,
}
impl McpErrorSurface {
#[must_use]
pub fn new(
phase: McpLifecyclePhase,
server_name: Option<String>,
message: impl Into<String>,
context: BTreeMap<String, String>,
recoverable: bool,
) -> Self {
Self {
phase,
server_name,
message: message.into(),
context,
recoverable,
timestamp: now_secs(),
}
}
}
impl std::fmt::Display for McpErrorSurface {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"MCP lifecycle error during {}: {}",
self.phase, self.message
)?;
if let Some(server_name) = &self.server_name {
write!(f, " (server: {server_name})")?;
}
if !self.context.is_empty() {
write!(f, " with context {:?}", self.context)?;
}
if self.recoverable {
write!(f, " [recoverable]")?;
}
Ok(())
}
}
impl std::error::Error for McpErrorSurface {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum McpPhaseResult {
Success {
phase: McpLifecyclePhase,
duration: Duration,
},
Failure {
phase: McpLifecyclePhase,
error: McpErrorSurface,
},
Timeout {
phase: McpLifecyclePhase,
waited: Duration,
error: McpErrorSurface,
},
}
impl McpPhaseResult {
#[must_use]
pub fn phase(&self) -> McpLifecyclePhase {
match self {
Self::Success { phase, .. }
| Self::Failure { phase, .. }
| Self::Timeout { phase, .. } => *phase,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct McpLifecycleState {
current_phase: Option<McpLifecyclePhase>,
phase_errors: BTreeMap<McpLifecyclePhase, Vec<McpErrorSurface>>,
phase_timestamps: BTreeMap<McpLifecyclePhase, u64>,
phase_results: Vec<McpPhaseResult>,
}
impl McpLifecycleState {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn current_phase(&self) -> Option<McpLifecyclePhase> {
self.current_phase
}
#[must_use]
pub fn errors_for_phase(&self, phase: McpLifecyclePhase) -> &[McpErrorSurface] {
self.phase_errors
.get(&phase)
.map(Vec::as_slice)
.unwrap_or(&[])
}
#[must_use]
pub fn results(&self) -> &[McpPhaseResult] {
&self.phase_results
}
#[must_use]
pub fn phase_timestamps(&self) -> &BTreeMap<McpLifecyclePhase, u64> {
&self.phase_timestamps
}
#[must_use]
pub fn phase_timestamp(&self, phase: McpLifecyclePhase) -> Option<u64> {
self.phase_timestamps.get(&phase).copied()
}
fn record_phase(&mut self, phase: McpLifecyclePhase) {
self.current_phase = Some(phase);
self.phase_timestamps.insert(phase, now_secs());
}
fn record_error(&mut self, error: McpErrorSurface) {
self.phase_errors
.entry(error.phase)
.or_default()
.push(error);
}
fn record_result(&mut self, result: McpPhaseResult) {
self.phase_results.push(result);
}
fn can_resume_after_error(&self) -> bool {
match self.phase_results.last() {
Some(McpPhaseResult::Failure { error, .. } | McpPhaseResult::Timeout { error, .. }) => {
error.recoverable
}
_ => false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct McpFailedServer {
pub server_name: String,
pub phase: McpLifecyclePhase,
pub error: McpErrorSurface,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct McpDegradedReport {
pub working_servers: Vec<String>,
pub failed_servers: Vec<McpFailedServer>,
pub available_tools: Vec<String>,
pub missing_tools: Vec<String>,
}
impl McpDegradedReport {
#[must_use]
pub fn new(
working_servers: Vec<String>,
failed_servers: Vec<McpFailedServer>,
available_tools: Vec<String>,
expected_tools: Vec<String>,
) -> Self {
let working_servers = dedupe_sorted(working_servers);
let available_tools = dedupe_sorted(available_tools);
let available_tool_set: BTreeSet<_> = available_tools.iter().cloned().collect();
let expected_tools = dedupe_sorted(expected_tools);
let missing_tools = expected_tools
.into_iter()
.filter(|tool| !available_tool_set.contains(tool))
.collect();
Self {
working_servers,
failed_servers,
available_tools,
missing_tools,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct McpLifecycleValidator {
state: McpLifecycleState,
}
impl McpLifecycleValidator {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn state(&self) -> &McpLifecycleState {
&self.state
}
#[must_use]
pub fn validate_phase_transition(from: McpLifecyclePhase, to: McpLifecyclePhase) -> bool {
match (from, to) {
(McpLifecyclePhase::ConfigLoad, McpLifecyclePhase::ServerRegistration)
| (McpLifecyclePhase::ServerRegistration, McpLifecyclePhase::SpawnConnect)
| (McpLifecyclePhase::SpawnConnect, McpLifecyclePhase::InitializeHandshake)
| (McpLifecyclePhase::InitializeHandshake, McpLifecyclePhase::ToolDiscovery)
| (McpLifecyclePhase::ToolDiscovery, McpLifecyclePhase::ResourceDiscovery)
| (McpLifecyclePhase::ToolDiscovery, McpLifecyclePhase::Ready)
| (McpLifecyclePhase::ResourceDiscovery, McpLifecyclePhase::Ready)
| (McpLifecyclePhase::Ready, McpLifecyclePhase::Invocation)
| (McpLifecyclePhase::Invocation, McpLifecyclePhase::Ready)
| (McpLifecyclePhase::ErrorSurfacing, McpLifecyclePhase::Ready)
| (McpLifecyclePhase::ErrorSurfacing, McpLifecyclePhase::Shutdown)
| (McpLifecyclePhase::Shutdown, McpLifecyclePhase::Cleanup) => true,
(_, McpLifecyclePhase::Shutdown) => from != McpLifecyclePhase::Cleanup,
(_, McpLifecyclePhase::ErrorSurfacing) => {
from != McpLifecyclePhase::Cleanup && from != McpLifecyclePhase::Shutdown
}
_ => false,
}
}
pub fn run_phase(&mut self, phase: McpLifecyclePhase) -> McpPhaseResult {
let started = Instant::now();
if let Some(current_phase) = self.state.current_phase() {
if current_phase == McpLifecyclePhase::ErrorSurfacing
&& phase == McpLifecyclePhase::Ready
&& !self.state.can_resume_after_error()
{
return self.record_failure(McpErrorSurface::new(
phase,
None,
"cannot return to ready after a non-recoverable MCP lifecycle failure",
BTreeMap::from([
("from".to_string(), current_phase.to_string()),
("to".to_string(), phase.to_string()),
]),
false,
));
}
if !Self::validate_phase_transition(current_phase, phase) {
return self.record_failure(McpErrorSurface::new(
phase,
None,
format!("invalid MCP lifecycle transition from {current_phase} to {phase}"),
BTreeMap::from([
("from".to_string(), current_phase.to_string()),
("to".to_string(), phase.to_string()),
]),
false,
));
}
} else if phase != McpLifecyclePhase::ConfigLoad {
return self.record_failure(McpErrorSurface::new(
phase,
None,
format!("invalid initial MCP lifecycle phase {phase}"),
BTreeMap::from([("phase".to_string(), phase.to_string())]),
false,
));
}
self.state.record_phase(phase);
let result = McpPhaseResult::Success {
phase,
duration: started.elapsed(),
};
self.state.record_result(result.clone());
result
}
pub fn record_failure(&mut self, error: McpErrorSurface) -> McpPhaseResult {
let phase = error.phase;
self.state.record_error(error.clone());
self.state.record_phase(McpLifecyclePhase::ErrorSurfacing);
let result = McpPhaseResult::Failure { phase, error };
self.state.record_result(result.clone());
result
}
pub fn record_timeout(
&mut self,
phase: McpLifecyclePhase,
waited: Duration,
server_name: Option<String>,
mut context: BTreeMap<String, String>,
) -> McpPhaseResult {
context.insert("waited_ms".to_string(), waited.as_millis().to_string());
let error = McpErrorSurface::new(
phase,
server_name,
format!(
"MCP lifecycle phase {phase} timed out after {} ms",
waited.as_millis()
),
context,
true,
);
self.state.record_error(error.clone());
self.state.record_phase(McpLifecyclePhase::ErrorSurfacing);
let result = McpPhaseResult::Timeout {
phase,
waited,
error,
};
self.state.record_result(result.clone());
result
}
}
fn dedupe_sorted(mut values: Vec<String>) -> Vec<String> {
values.sort();
values.dedup();
values
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn phase_display_matches_serde_name() {
// given
let phases = McpLifecyclePhase::all();
// when
let serialized = phases
.into_iter()
.map(|phase| {
(
phase.to_string(),
serde_json::to_value(phase).expect("serialize phase"),
)
})
.collect::<Vec<_>>();
// then
for (display, json_value) in serialized {
assert_eq!(json_value, json!(display));
}
}
#[test]
fn given_startup_path_when_running_to_cleanup_then_each_control_transition_succeeds() {
// given
let mut validator = McpLifecycleValidator::new();
let phases = [
McpLifecyclePhase::ConfigLoad,
McpLifecyclePhase::ServerRegistration,
McpLifecyclePhase::SpawnConnect,
McpLifecyclePhase::InitializeHandshake,
McpLifecyclePhase::ToolDiscovery,
McpLifecyclePhase::ResourceDiscovery,
McpLifecyclePhase::Ready,
McpLifecyclePhase::Invocation,
McpLifecyclePhase::Ready,
McpLifecyclePhase::Shutdown,
McpLifecyclePhase::Cleanup,
];
// when
let results = phases
.into_iter()
.map(|phase| validator.run_phase(phase))
.collect::<Vec<_>>();
// then
assert!(results
.iter()
.all(|result| matches!(result, McpPhaseResult::Success { .. })));
assert_eq!(
validator.state().current_phase(),
Some(McpLifecyclePhase::Cleanup)
);
for phase in [
McpLifecyclePhase::ConfigLoad,
McpLifecyclePhase::ServerRegistration,
McpLifecyclePhase::SpawnConnect,
McpLifecyclePhase::InitializeHandshake,
McpLifecyclePhase::ToolDiscovery,
McpLifecyclePhase::ResourceDiscovery,
McpLifecyclePhase::Ready,
McpLifecyclePhase::Invocation,
McpLifecyclePhase::Shutdown,
McpLifecyclePhase::Cleanup,
] {
assert!(validator.state().phase_timestamp(phase).is_some());
}
}
#[test]
fn given_tool_discovery_when_resource_discovery_is_skipped_then_ready_is_still_allowed() {
// given
let mut validator = McpLifecycleValidator::new();
for phase in [
McpLifecyclePhase::ConfigLoad,
McpLifecyclePhase::ServerRegistration,
McpLifecyclePhase::SpawnConnect,
McpLifecyclePhase::InitializeHandshake,
McpLifecyclePhase::ToolDiscovery,
] {
let result = validator.run_phase(phase);
assert!(matches!(result, McpPhaseResult::Success { .. }));
}
// when
let result = validator.run_phase(McpLifecyclePhase::Ready);
// then
assert!(matches!(result, McpPhaseResult::Success { .. }));
assert_eq!(
validator.state().current_phase(),
Some(McpLifecyclePhase::Ready)
);
}
#[test]
fn validates_expected_phase_transitions() {
// given
let valid_transitions = [
(
McpLifecyclePhase::ConfigLoad,
McpLifecyclePhase::ServerRegistration,
),
(
McpLifecyclePhase::ServerRegistration,
McpLifecyclePhase::SpawnConnect,
),
(
McpLifecyclePhase::SpawnConnect,
McpLifecyclePhase::InitializeHandshake,
),
(
McpLifecyclePhase::InitializeHandshake,
McpLifecyclePhase::ToolDiscovery,
),
(
McpLifecyclePhase::ToolDiscovery,
McpLifecyclePhase::ResourceDiscovery,
),
(McpLifecyclePhase::ToolDiscovery, McpLifecyclePhase::Ready),
(
McpLifecyclePhase::ResourceDiscovery,
McpLifecyclePhase::Ready,
),
(McpLifecyclePhase::Ready, McpLifecyclePhase::Invocation),
(McpLifecyclePhase::Invocation, McpLifecyclePhase::Ready),
(McpLifecyclePhase::Ready, McpLifecyclePhase::Shutdown),
(
McpLifecyclePhase::Invocation,
McpLifecyclePhase::ErrorSurfacing,
),
(
McpLifecyclePhase::ErrorSurfacing,
McpLifecyclePhase::Shutdown,
),
(McpLifecyclePhase::Shutdown, McpLifecyclePhase::Cleanup),
];
// when / then
for (from, to) in valid_transitions {
assert!(McpLifecycleValidator::validate_phase_transition(from, to));
}
assert!(!McpLifecycleValidator::validate_phase_transition(
McpLifecyclePhase::Ready,
McpLifecyclePhase::ConfigLoad,
));
assert!(!McpLifecycleValidator::validate_phase_transition(
McpLifecyclePhase::Cleanup,
McpLifecyclePhase::Ready,
));
}
#[test]
fn given_invalid_transition_when_running_phase_then_structured_failure_is_recorded() {
// given
let mut validator = McpLifecycleValidator::new();
let _ = validator.run_phase(McpLifecyclePhase::ConfigLoad);
let _ = validator.run_phase(McpLifecyclePhase::ServerRegistration);
// when
let result = validator.run_phase(McpLifecyclePhase::Ready);
// then
match result {
McpPhaseResult::Failure { phase, error } => {
assert_eq!(phase, McpLifecyclePhase::Ready);
assert!(!error.recoverable);
assert_eq!(error.phase, McpLifecyclePhase::Ready);
assert_eq!(
error.context.get("from").map(String::as_str),
Some("server_registration")
);
assert_eq!(error.context.get("to").map(String::as_str), Some("ready"));
}
other => panic!("expected failure result, got {other:?}"),
}
assert_eq!(
validator.state().current_phase(),
Some(McpLifecyclePhase::ErrorSurfacing)
);
assert_eq!(
validator
.state()
.errors_for_phase(McpLifecyclePhase::Ready)
.len(),
1
);
}
#[test]
fn given_each_phase_when_failure_is_recorded_then_error_is_tracked_per_phase() {
// given
let mut validator = McpLifecycleValidator::new();
// when / then
for phase in McpLifecyclePhase::all() {
let result = validator.record_failure(McpErrorSurface::new(
phase,
Some("alpha".to_string()),
format!("failure at {phase}"),
BTreeMap::from([("server".to_string(), "alpha".to_string())]),
phase == McpLifecyclePhase::ResourceDiscovery,
));
match result {
McpPhaseResult::Failure {
phase: failed_phase,
error,
} => {
assert_eq!(failed_phase, phase);
assert_eq!(error.phase, phase);
assert_eq!(
error.recoverable,
phase == McpLifecyclePhase::ResourceDiscovery
);
}
other => panic!("expected failure result, got {other:?}"),
}
assert_eq!(validator.state().errors_for_phase(phase).len(), 1);
}
}
#[test]
fn given_spawn_connect_timeout_when_recorded_then_waited_duration_is_preserved() {
// given
let mut validator = McpLifecycleValidator::new();
let waited = Duration::from_millis(250);
// when
let result = validator.record_timeout(
McpLifecyclePhase::SpawnConnect,
waited,
Some("alpha".to_string()),
BTreeMap::from([("attempt".to_string(), "1".to_string())]),
);
// then
match result {
McpPhaseResult::Timeout {
phase,
waited: actual,
error,
} => {
assert_eq!(phase, McpLifecyclePhase::SpawnConnect);
assert_eq!(actual, waited);
assert!(error.recoverable);
assert_eq!(error.server_name.as_deref(), Some("alpha"));
}
other => panic!("expected timeout result, got {other:?}"),
}
let errors = validator
.state()
.errors_for_phase(McpLifecyclePhase::SpawnConnect);
assert_eq!(errors.len(), 1);
assert_eq!(
errors[0].context.get("waited_ms").map(String::as_str),
Some("250")
);
assert_eq!(
validator.state().current_phase(),
Some(McpLifecyclePhase::ErrorSurfacing)
);
}
#[test]
fn given_partial_server_health_when_building_degraded_report_then_missing_tools_are_reported() {
// given
let failed = vec![McpFailedServer {
server_name: "broken".to_string(),
phase: McpLifecyclePhase::InitializeHandshake,
error: McpErrorSurface::new(
McpLifecyclePhase::InitializeHandshake,
Some("broken".to_string()),
"initialize failed",
BTreeMap::from([("reason".to_string(), "broken pipe".to_string())]),
false,
),
}];
// when
let report = McpDegradedReport::new(
vec!["alpha".to_string(), "beta".to_string(), "alpha".to_string()],
failed,
vec![
"alpha.echo".to_string(),
"beta.search".to_string(),
"alpha.echo".to_string(),
],
vec![
"alpha.echo".to_string(),
"beta.search".to_string(),
"broken.fetch".to_string(),
],
);
// then
assert_eq!(
report.working_servers,
vec!["alpha".to_string(), "beta".to_string()]
);
assert_eq!(report.failed_servers.len(), 1);
assert_eq!(report.failed_servers[0].server_name, "broken");
assert_eq!(
report.available_tools,
vec!["alpha.echo".to_string(), "beta.search".to_string()]
);
assert_eq!(report.missing_tools, vec!["broken.fetch".to_string()]);
}
#[test]
fn given_failure_during_resource_discovery_when_shutting_down_then_cleanup_still_succeeds() {
// given
let mut validator = McpLifecycleValidator::new();
for phase in [
McpLifecyclePhase::ConfigLoad,
McpLifecyclePhase::ServerRegistration,
McpLifecyclePhase::SpawnConnect,
McpLifecyclePhase::InitializeHandshake,
McpLifecyclePhase::ToolDiscovery,
] {
let result = validator.run_phase(phase);
assert!(matches!(result, McpPhaseResult::Success { .. }));
}
let _ = validator.record_failure(McpErrorSurface::new(
McpLifecyclePhase::ResourceDiscovery,
Some("alpha".to_string()),
"resource listing failed",
BTreeMap::from([("reason".to_string(), "timeout".to_string())]),
true,
));
// when
let shutdown = validator.run_phase(McpLifecyclePhase::Shutdown);
let cleanup = validator.run_phase(McpLifecyclePhase::Cleanup);
// then
assert!(matches!(shutdown, McpPhaseResult::Success { .. }));
assert!(matches!(cleanup, McpPhaseResult::Success { .. }));
assert_eq!(
validator.state().current_phase(),
Some(McpLifecyclePhase::Cleanup)
);
assert!(validator
.state()
.phase_timestamp(McpLifecyclePhase::ErrorSurfacing)
.is_some());
}
#[test]
fn error_surface_display_includes_phase_server_and_recoverable_flag() {
// given
let error = McpErrorSurface::new(
McpLifecyclePhase::SpawnConnect,
Some("alpha".to_string()),
"process exited early",
BTreeMap::from([("exit_code".to_string(), "1".to_string())]),
true,
);
// when
let rendered = error.to_string();
// then
assert!(rendered.contains("spawn_connect"));
assert!(rendered.contains("process exited early"));
assert!(rendered.contains("server: alpha"));
assert!(rendered.contains("recoverable"));
let trait_object: &dyn std::error::Error = &error;
assert_eq!(trait_object.to_string(), rendered);
}
#[test]
fn given_nonrecoverable_failure_when_returning_to_ready_then_validator_rejects_resume() {
// given
let mut validator = McpLifecycleValidator::new();
for phase in [
McpLifecyclePhase::ConfigLoad,
McpLifecyclePhase::ServerRegistration,
McpLifecyclePhase::SpawnConnect,
McpLifecyclePhase::InitializeHandshake,
McpLifecyclePhase::ToolDiscovery,
McpLifecyclePhase::Ready,
] {
let result = validator.run_phase(phase);
assert!(matches!(result, McpPhaseResult::Success { .. }));
}
let _ = validator.record_failure(McpErrorSurface::new(
McpLifecyclePhase::Invocation,
Some("alpha".to_string()),
"tool call corrupted the session",
BTreeMap::from([("reason".to_string(), "invalid frame".to_string())]),
false,
));
// when
let result = validator.run_phase(McpLifecyclePhase::Ready);
// then
match result {
McpPhaseResult::Failure { phase, error } => {
assert_eq!(phase, McpLifecyclePhase::Ready);
assert!(!error.recoverable);
assert!(error.message.contains("non-recoverable"));
}
other => panic!("expected failure result, got {other:?}"),
}
assert_eq!(
validator.state().current_phase(),
Some(McpLifecyclePhase::ErrorSurfacing)
);
}
#[test]
fn given_recoverable_failure_when_returning_to_ready_then_validator_allows_resume() {
// given
let mut validator = McpLifecycleValidator::new();
for phase in [
McpLifecyclePhase::ConfigLoad,
McpLifecyclePhase::ServerRegistration,
McpLifecyclePhase::SpawnConnect,
McpLifecyclePhase::InitializeHandshake,
McpLifecyclePhase::ToolDiscovery,
McpLifecyclePhase::Ready,
] {
let result = validator.run_phase(phase);
assert!(matches!(result, McpPhaseResult::Success { .. }));
}
let _ = validator.record_failure(McpErrorSurface::new(
McpLifecyclePhase::Invocation,
Some("alpha".to_string()),
"tool call failed but can be retried",
BTreeMap::from([("reason".to_string(), "upstream timeout".to_string())]),
true,
));
// when
let result = validator.run_phase(McpLifecyclePhase::Ready);
// then
assert!(matches!(result, McpPhaseResult::Success { .. }));
assert_eq!(
validator.state().current_phase(),
Some(McpLifecyclePhase::Ready)
);
}
}
+440
View File
@@ -0,0 +1,440 @@
//! Minimal Model Context Protocol (MCP) server.
//!
//! Implements a newline-safe, LSP-framed JSON-RPC server over stdio that
//! answers `initialize`, `tools/list`, and `tools/call` requests. The framing
//! matches the client transport implemented in [`crate::mcp_stdio`] so this
//! server can be driven by either an external MCP client (e.g. Claude
//! Desktop) or `claw`'s own [`McpServerManager`](crate::McpServerManager).
//!
//! The server is intentionally small: it exposes a list of pre-built
//! [`McpTool`] descriptors and delegates `tools/call` to a caller-supplied
//! handler. Tool execution itself lives in the `tools` crate; this module is
//! purely the transport + dispatch loop.
//!
//! [`McpTool`]: crate::mcp_stdio::McpTool
use std::io;
use serde_json::{json, Value as JsonValue};
use tokio::io::{
stdin, stdout, AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader, Stdin, Stdout,
};
use crate::mcp_stdio::{
JsonRpcError, JsonRpcId, JsonRpcRequest, JsonRpcResponse, McpInitializeResult,
McpInitializeServerInfo, McpListToolsResult, McpTool, McpToolCallContent, McpToolCallParams,
McpToolCallResult,
};
/// Protocol version the server advertises during `initialize`.
///
/// Matches the version used by the built-in client in
/// [`crate::mcp_stdio`], so the two stay in lockstep.
pub const MCP_SERVER_PROTOCOL_VERSION: &str = "2025-03-26";
/// Synchronous handler invoked for every `tools/call` request.
///
/// Returning `Ok(text)` yields a single `text` content block and
/// `isError: false`. Returning `Err(message)` yields a `text` block with the
/// error and `isError: true`, mirroring the error-surfacing convention used
/// elsewhere in claw.
pub type ToolCallHandler =
Box<dyn Fn(&str, &JsonValue) -> Result<String, String> + Send + Sync + 'static>;
/// Configuration for an [`McpServer`] instance.
///
/// Named `McpServerSpec` rather than `McpServerConfig` to avoid colliding
/// with the existing client-side [`crate::config::McpServerConfig`] that
/// describes *remote* MCP servers the runtime connects to.
pub struct McpServerSpec {
/// Name advertised in the `serverInfo` field of the `initialize` response.
pub server_name: String,
/// Version advertised in the `serverInfo` field of the `initialize`
/// response.
pub server_version: String,
/// Tool descriptors returned for `tools/list`.
pub tools: Vec<McpTool>,
/// Handler invoked for `tools/call`.
pub tool_handler: ToolCallHandler,
}
/// Minimal MCP stdio server.
///
/// The server runs a blocking read/dispatch/write loop over the current
/// process's stdin/stdout, terminating cleanly when the peer closes the
/// stream.
pub struct McpServer {
spec: McpServerSpec,
stdin: BufReader<Stdin>,
stdout: Stdout,
}
impl McpServer {
#[must_use]
pub fn new(spec: McpServerSpec) -> Self {
Self {
spec,
stdin: BufReader::new(stdin()),
stdout: stdout(),
}
}
/// Runs the server until the client closes stdin.
///
/// Returns `Ok(())` on clean EOF; any other I/O error is propagated so
/// callers can log and exit non-zero.
pub async fn run(&mut self) -> io::Result<()> {
loop {
let Some(payload) = read_frame(&mut self.stdin).await? else {
return Ok(());
};
// Requests and notifications share a wire format; the absence of
// `id` distinguishes notifications, which must never receive a
// response.
let message: JsonValue = match serde_json::from_slice(&payload) {
Ok(value) => value,
Err(error) => {
// Parse error with null id per JSON-RPC 2.0 §4.2.
let response = JsonRpcResponse::<JsonValue> {
jsonrpc: "2.0".to_string(),
id: JsonRpcId::Null,
result: None,
error: Some(JsonRpcError {
code: -32700,
message: format!("parse error: {error}"),
data: None,
}),
};
write_response(&mut self.stdout, &response).await?;
continue;
}
};
if message.get("id").is_none() {
// Notification: dispatch for side effects only (e.g. log),
// but send no reply.
continue;
}
let request: JsonRpcRequest<JsonValue> = match serde_json::from_value(message) {
Ok(request) => request,
Err(error) => {
let response = JsonRpcResponse::<JsonValue> {
jsonrpc: "2.0".to_string(),
id: JsonRpcId::Null,
result: None,
error: Some(JsonRpcError {
code: -32600,
message: format!("invalid request: {error}"),
data: None,
}),
};
write_response(&mut self.stdout, &response).await?;
continue;
}
};
let response = self.dispatch(request);
write_response(&mut self.stdout, &response).await?;
}
}
fn dispatch(&self, request: JsonRpcRequest<JsonValue>) -> JsonRpcResponse<JsonValue> {
let id = request.id.clone();
match request.method.as_str() {
"initialize" => self.handle_initialize(id),
"tools/list" => self.handle_tools_list(id),
"tools/call" => self.handle_tools_call(id, request.params),
other => JsonRpcResponse {
jsonrpc: "2.0".to_string(),
id,
result: None,
error: Some(JsonRpcError {
code: -32601,
message: format!("method not found: {other}"),
data: None,
}),
},
}
}
fn handle_initialize(&self, id: JsonRpcId) -> JsonRpcResponse<JsonValue> {
let result = McpInitializeResult {
protocol_version: MCP_SERVER_PROTOCOL_VERSION.to_string(),
capabilities: json!({ "tools": {} }),
server_info: McpInitializeServerInfo {
name: self.spec.server_name.clone(),
version: self.spec.server_version.clone(),
},
};
JsonRpcResponse {
jsonrpc: "2.0".to_string(),
id,
result: serde_json::to_value(result).ok(),
error: None,
}
}
fn handle_tools_list(&self, id: JsonRpcId) -> JsonRpcResponse<JsonValue> {
let result = McpListToolsResult {
tools: self.spec.tools.clone(),
next_cursor: None,
};
JsonRpcResponse {
jsonrpc: "2.0".to_string(),
id,
result: serde_json::to_value(result).ok(),
error: None,
}
}
fn handle_tools_call(
&self,
id: JsonRpcId,
params: Option<JsonValue>,
) -> JsonRpcResponse<JsonValue> {
let Some(params) = params else {
return invalid_params_response(id, "missing params for tools/call");
};
let call: McpToolCallParams = match serde_json::from_value(params) {
Ok(value) => value,
Err(error) => {
return invalid_params_response(id, &format!("invalid tools/call params: {error}"));
}
};
let arguments = call.arguments.unwrap_or_else(|| json!({}));
let tool_result = (self.spec.tool_handler)(&call.name, &arguments);
let (text, is_error) = match tool_result {
Ok(text) => (text, false),
Err(message) => (message, true),
};
let mut data = std::collections::BTreeMap::new();
data.insert("text".to_string(), JsonValue::String(text));
let call_result = McpToolCallResult {
content: vec![McpToolCallContent {
kind: "text".to_string(),
data,
}],
structured_content: None,
is_error: Some(is_error),
meta: None,
};
JsonRpcResponse {
jsonrpc: "2.0".to_string(),
id,
result: serde_json::to_value(call_result).ok(),
error: None,
}
}
}
fn invalid_params_response(id: JsonRpcId, message: &str) -> JsonRpcResponse<JsonValue> {
JsonRpcResponse {
jsonrpc: "2.0".to_string(),
id,
result: None,
error: Some(JsonRpcError {
code: -32602,
message: message.to_string(),
data: None,
}),
}
}
/// Reads a single LSP-framed JSON-RPC payload from `reader`.
///
/// Returns `Ok(None)` on clean EOF before any header bytes have been read,
/// matching how [`crate::mcp_stdio::McpStdioProcess`] treats stream closure.
async fn read_frame(reader: &mut BufReader<Stdin>) -> io::Result<Option<Vec<u8>>> {
let mut content_length: Option<usize> = None;
let mut first_header = true;
loop {
let mut line = String::new();
let bytes_read = reader.read_line(&mut line).await?;
if bytes_read == 0 {
if first_header {
return Ok(None);
}
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"MCP stdio stream closed while reading headers",
));
}
first_header = false;
if line == "\r\n" || line == "\n" {
break;
}
let header = line.trim_end_matches(['\r', '\n']);
if let Some((name, value)) = header.split_once(':') {
if name.trim().eq_ignore_ascii_case("Content-Length") {
let parsed = value
.trim()
.parse::<usize>()
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
content_length = Some(parsed);
}
}
}
let content_length = content_length.ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidData, "missing Content-Length header")
})?;
let mut payload = vec![0_u8; content_length];
reader.read_exact(&mut payload).await?;
Ok(Some(payload))
}
async fn write_response(
stdout: &mut Stdout,
response: &JsonRpcResponse<JsonValue>,
) -> io::Result<()> {
let body = serde_json::to_vec(response)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
let header = format!("Content-Length: {}\r\n\r\n", body.len());
stdout.write_all(header.as_bytes()).await?;
stdout.write_all(&body).await?;
stdout.flush().await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dispatch_initialize_returns_server_info() {
let server = McpServer {
spec: McpServerSpec {
server_name: "test".to_string(),
server_version: "9.9.9".to_string(),
tools: Vec::new(),
tool_handler: Box::new(|_, _| Ok(String::new())),
},
stdin: BufReader::new(stdin()),
stdout: stdout(),
};
let request = JsonRpcRequest::<JsonValue> {
jsonrpc: "2.0".to_string(),
id: JsonRpcId::Number(1),
method: "initialize".to_string(),
params: None,
};
let response = server.dispatch(request);
assert_eq!(response.id, JsonRpcId::Number(1));
assert!(response.error.is_none());
let result = response.result.expect("initialize result");
assert_eq!(result["protocolVersion"], MCP_SERVER_PROTOCOL_VERSION);
assert_eq!(result["serverInfo"]["name"], "test");
assert_eq!(result["serverInfo"]["version"], "9.9.9");
}
#[test]
fn dispatch_tools_list_returns_registered_tools() {
let tool = McpTool {
name: "echo".to_string(),
description: Some("Echo".to_string()),
input_schema: Some(json!({"type": "object"})),
annotations: None,
meta: None,
};
let server = McpServer {
spec: McpServerSpec {
server_name: "test".to_string(),
server_version: "0.0.0".to_string(),
tools: vec![tool.clone()],
tool_handler: Box::new(|_, _| Ok(String::new())),
},
stdin: BufReader::new(stdin()),
stdout: stdout(),
};
let request = JsonRpcRequest::<JsonValue> {
jsonrpc: "2.0".to_string(),
id: JsonRpcId::Number(2),
method: "tools/list".to_string(),
params: None,
};
let response = server.dispatch(request);
assert!(response.error.is_none());
let result = response.result.expect("tools/list result");
assert_eq!(result["tools"][0]["name"], "echo");
}
#[test]
fn dispatch_tools_call_wraps_handler_output() {
let server = McpServer {
spec: McpServerSpec {
server_name: "test".to_string(),
server_version: "0.0.0".to_string(),
tools: Vec::new(),
tool_handler: Box::new(|name, args| Ok(format!("called {name} with {args}"))),
},
stdin: BufReader::new(stdin()),
stdout: stdout(),
};
let request = JsonRpcRequest::<JsonValue> {
jsonrpc: "2.0".to_string(),
id: JsonRpcId::Number(3),
method: "tools/call".to_string(),
params: Some(json!({
"name": "echo",
"arguments": {"text": "hi"}
})),
};
let response = server.dispatch(request);
assert!(response.error.is_none());
let result = response.result.expect("tools/call result");
assert_eq!(result["isError"], false);
assert_eq!(result["content"][0]["type"], "text");
assert!(result["content"][0]["text"]
.as_str()
.unwrap()
.starts_with("called echo"));
}
#[test]
fn dispatch_tools_call_surfaces_handler_error() {
let server = McpServer {
spec: McpServerSpec {
server_name: "test".to_string(),
server_version: "0.0.0".to_string(),
tools: Vec::new(),
tool_handler: Box::new(|_, _| Err("boom".to_string())),
},
stdin: BufReader::new(stdin()),
stdout: stdout(),
};
let request = JsonRpcRequest::<JsonValue> {
jsonrpc: "2.0".to_string(),
id: JsonRpcId::Number(4),
method: "tools/call".to_string(),
params: Some(json!({"name": "broken"})),
};
let response = server.dispatch(request);
let result = response.result.expect("tools/call result");
assert_eq!(result["isError"], true);
assert_eq!(result["content"][0]["text"], "boom");
}
#[test]
fn dispatch_unknown_method_returns_method_not_found() {
let server = McpServer {
spec: McpServerSpec {
server_name: "test".to_string(),
server_version: "0.0.0".to_string(),
tools: Vec::new(),
tool_handler: Box::new(|_, _| Ok(String::new())),
},
stdin: BufReader::new(stdin()),
stdout: stdout(),
};
let request = JsonRpcRequest::<JsonValue> {
jsonrpc: "2.0".to_string(),
id: JsonRpcId::Number(5),
method: "nonsense".to_string(),
params: None,
};
let response = server.dispatch(request);
let error = response.error.expect("error payload");
assert_eq!(error.code, -32601);
}
}
File diff suppressed because it is too large Load Diff
+920
View File
@@ -0,0 +1,920 @@
#![allow(
clippy::await_holding_lock,
clippy::doc_markdown,
clippy::match_same_arms,
clippy::must_use_candidate,
clippy::uninlined_format_args,
clippy::unnested_or_patterns
)]
//! Bridge between MCP tool surface (ListMcpResources, ReadMcpResource, McpAuth, MCP)
//! and the existing McpServerManager runtime.
//!
//! Provides a stateful client registry that tool handlers can use to
//! connect to MCP servers and invoke their capabilities.
use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
use crate::mcp::mcp_tool_name;
use crate::mcp_stdio::McpServerManager;
use serde::{Deserialize, Serialize};
/// Status of a managed MCP server connection.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum McpConnectionStatus {
Disconnected,
Connecting,
Connected,
AuthRequired,
Error,
}
impl std::fmt::Display for McpConnectionStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Disconnected => write!(f, "disconnected"),
Self::Connecting => write!(f, "connecting"),
Self::Connected => write!(f, "connected"),
Self::AuthRequired => write!(f, "auth_required"),
Self::Error => write!(f, "error"),
}
}
}
/// Metadata about an MCP resource.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpResourceInfo {
pub uri: String,
pub name: String,
pub description: Option<String>,
pub mime_type: Option<String>,
}
/// Metadata about an MCP tool exposed by a server.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpToolInfo {
pub name: String,
pub description: Option<String>,
pub input_schema: Option<serde_json::Value>,
}
/// Tracked state of an MCP server connection.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpServerState {
pub server_name: String,
pub status: McpConnectionStatus,
pub tools: Vec<McpToolInfo>,
pub resources: Vec<McpResourceInfo>,
pub server_info: Option<String>,
pub error_message: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct McpToolRegistry {
inner: Arc<Mutex<HashMap<String, McpServerState>>>,
manager: Arc<OnceLock<Arc<Mutex<McpServerManager>>>>,
}
impl McpToolRegistry {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn set_manager(
&self,
manager: Arc<Mutex<McpServerManager>>,
) -> Result<(), Arc<Mutex<McpServerManager>>> {
self.manager.set(manager)
}
pub fn register_server(
&self,
server_name: &str,
status: McpConnectionStatus,
tools: Vec<McpToolInfo>,
resources: Vec<McpResourceInfo>,
server_info: Option<String>,
) {
let mut inner = self.inner.lock().expect("mcp registry lock poisoned");
inner.insert(
server_name.to_owned(),
McpServerState {
server_name: server_name.to_owned(),
status,
tools,
resources,
server_info,
error_message: None,
},
);
}
pub fn get_server(&self, server_name: &str) -> Option<McpServerState> {
let inner = self.inner.lock().expect("mcp registry lock poisoned");
inner.get(server_name).cloned()
}
pub fn list_servers(&self) -> Vec<McpServerState> {
let inner = self.inner.lock().expect("mcp registry lock poisoned");
inner.values().cloned().collect()
}
pub fn list_resources(&self, server_name: &str) -> Result<Vec<McpResourceInfo>, String> {
let inner = self.inner.lock().expect("mcp registry lock poisoned");
match inner.get(server_name) {
Some(state) => {
if state.status != McpConnectionStatus::Connected {
return Err(format!(
"server '{}' is not connected (status: {})",
server_name, state.status
));
}
Ok(state.resources.clone())
}
None => Err(format!("server '{}' not found", server_name)),
}
}
pub fn read_resource(&self, server_name: &str, uri: &str) -> Result<McpResourceInfo, String> {
let inner = self.inner.lock().expect("mcp registry lock poisoned");
let state = inner
.get(server_name)
.ok_or_else(|| format!("server '{}' not found", server_name))?;
if state.status != McpConnectionStatus::Connected {
return Err(format!(
"server '{}' is not connected (status: {})",
server_name, state.status
));
}
state
.resources
.iter()
.find(|r| r.uri == uri)
.cloned()
.ok_or_else(|| format!("resource '{}' not found on server '{}'", uri, server_name))
}
pub fn list_tools(&self, server_name: &str) -> Result<Vec<McpToolInfo>, String> {
let inner = self.inner.lock().expect("mcp registry lock poisoned");
match inner.get(server_name) {
Some(state) => {
if state.status != McpConnectionStatus::Connected {
return Err(format!(
"server '{}' is not connected (status: {})",
server_name, state.status
));
}
Ok(state.tools.clone())
}
None => Err(format!("server '{}' not found", server_name)),
}
}
fn spawn_tool_call(
manager: Arc<Mutex<McpServerManager>>,
qualified_tool_name: String,
arguments: Option<serde_json::Value>,
) -> Result<serde_json::Value, String> {
let join_handle = std::thread::Builder::new()
.name(format!("mcp-tool-call-{qualified_tool_name}"))
.spawn(move || {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|error| format!("failed to create MCP tool runtime: {error}"))?;
runtime.block_on(async move {
let response = {
let mut manager = manager
.lock()
.map_err(|_| "mcp server manager lock poisoned".to_string())?;
manager
.discover_tools()
.await
.map_err(|error| error.to_string())?;
let response = manager
.call_tool(&qualified_tool_name, arguments)
.await
.map_err(|error| error.to_string());
let shutdown = manager.shutdown().await.map_err(|error| error.to_string());
match (response, shutdown) {
(Ok(response), Ok(())) => Ok(response),
(Err(error), Ok(())) | (Err(error), Err(_)) => Err(error),
(Ok(_), Err(error)) => Err(error),
}
}?;
if let Some(error) = response.error {
return Err(format!(
"MCP server returned JSON-RPC error for tools/call: {} ({})",
error.message, error.code
));
}
let result = response.result.ok_or_else(|| {
"MCP server returned no result for tools/call".to_string()
})?;
serde_json::to_value(result)
.map_err(|error| format!("failed to serialize MCP tool result: {error}"))
})
})
.map_err(|error| format!("failed to spawn MCP tool call thread: {error}"))?;
join_handle.join().map_err(|panic_payload| {
if let Some(message) = panic_payload.downcast_ref::<&str>() {
format!("MCP tool call thread panicked: {message}")
} else if let Some(message) = panic_payload.downcast_ref::<String>() {
format!("MCP tool call thread panicked: {message}")
} else {
"MCP tool call thread panicked".to_string()
}
})?
}
pub fn call_tool(
&self,
server_name: &str,
tool_name: &str,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, String> {
let inner = self.inner.lock().expect("mcp registry lock poisoned");
let state = inner
.get(server_name)
.ok_or_else(|| format!("server '{}' not found", server_name))?;
if state.status != McpConnectionStatus::Connected {
return Err(format!(
"server '{}' is not connected (status: {})",
server_name, state.status
));
}
if !state.tools.iter().any(|t| t.name == tool_name) {
return Err(format!(
"tool '{}' not found on server '{}'",
tool_name, server_name
));
}
drop(inner);
let manager = self
.manager
.get()
.cloned()
.ok_or_else(|| "MCP server manager is not configured".to_string())?;
Self::spawn_tool_call(
manager,
mcp_tool_name(server_name, tool_name),
(!arguments.is_null()).then(|| arguments.clone()),
)
}
/// Set auth status for a server.
pub fn set_auth_status(
&self,
server_name: &str,
status: McpConnectionStatus,
) -> Result<(), String> {
let mut inner = self.inner.lock().expect("mcp registry lock poisoned");
let state = inner
.get_mut(server_name)
.ok_or_else(|| format!("server '{}' not found", server_name))?;
state.status = status;
Ok(())
}
/// Disconnect / remove a server.
pub fn disconnect(&self, server_name: &str) -> Option<McpServerState> {
let mut inner = self.inner.lock().expect("mcp registry lock poisoned");
inner.remove(server_name)
}
/// Number of registered servers.
#[must_use]
pub fn len(&self) -> usize {
let inner = self.inner.lock().expect("mcp registry lock poisoned");
inner.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
#[cfg(all(test, unix))]
mod tests {
use std::collections::BTreeMap;
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use super::*;
use crate::config::{
ConfigSource, McpServerConfig, McpStdioServerConfig, ScopedMcpServerConfig,
};
fn temp_dir() -> PathBuf {
static NEXT_TEMP_DIR_ID: AtomicU64 = AtomicU64::new(0);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time should be after epoch")
.as_nanos();
let unique_id = NEXT_TEMP_DIR_ID.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!("runtime-mcp-tool-bridge-{nanos}-{unique_id}"))
}
fn cleanup_script(script_path: &Path) {
if let Some(root) = script_path.parent() {
let _ = fs::remove_dir_all(root);
}
}
fn write_bridge_mcp_server_script() -> PathBuf {
let root = temp_dir();
fs::create_dir_all(&root).expect("temp dir");
let script_path = root.join("bridge-mcp-server.py");
let script = [
"#!/usr/bin/env python3",
"import json, os, sys",
"LABEL = os.environ.get('MCP_SERVER_LABEL', 'server')",
"LOG_PATH = os.environ.get('MCP_LOG_PATH')",
"",
"def log(method):",
" if LOG_PATH:",
" with open(LOG_PATH, 'a', encoding='utf-8') as handle:",
" handle.write(f'{method}\\n')",
"",
"def read_message():",
" header = b''",
r" while not header.endswith(b'\r\n\r\n'):",
" chunk = sys.stdin.buffer.read(1)",
" if not chunk:",
" return None",
" header += chunk",
" length = 0",
r" for line in header.decode().split('\r\n'):",
r" if line.lower().startswith('content-length:'):",
r" length = int(line.split(':', 1)[1].strip())",
" payload = sys.stdin.buffer.read(length)",
" return json.loads(payload.decode())",
"",
"def send_message(message):",
" payload = json.dumps(message).encode()",
r" sys.stdout.buffer.write(f'Content-Length: {len(payload)}\r\n\r\n'.encode() + payload)",
" sys.stdout.buffer.flush()",
"",
"while True:",
" request = read_message()",
" if request is None:",
" break",
" method = request['method']",
" log(method)",
" if method == 'initialize':",
" send_message({",
" 'jsonrpc': '2.0',",
" 'id': request['id'],",
" 'result': {",
" 'protocolVersion': request['params']['protocolVersion'],",
" 'capabilities': {'tools': {}},",
" 'serverInfo': {'name': LABEL, 'version': '1.0.0'}",
" }",
" })",
" elif method == 'tools/list':",
" send_message({",
" 'jsonrpc': '2.0',",
" 'id': request['id'],",
" 'result': {",
" 'tools': [",
" {",
" 'name': 'echo',",
" 'description': f'Echo tool for {LABEL}',",
" 'inputSchema': {",
" 'type': 'object',",
" 'properties': {'text': {'type': 'string'}},",
" 'required': ['text']",
" }",
" }",
" ]",
" }",
" })",
" elif method == 'tools/call':",
" args = request['params'].get('arguments') or {}",
" text = args.get('text', '')",
" send_message({",
" 'jsonrpc': '2.0',",
" 'id': request['id'],",
" 'result': {",
" 'content': [{'type': 'text', 'text': f'{LABEL}:{text}'}],",
" 'structuredContent': {'server': LABEL, 'echoed': text},",
" 'isError': False",
" }",
" })",
" else:",
" send_message({",
" 'jsonrpc': '2.0',",
" 'id': request['id'],",
" 'error': {'code': -32601, 'message': f'unknown method: {method}'},",
" })",
"",
]
.join("\n");
fs::write(&script_path, script).expect("write script");
let mut permissions = fs::metadata(&script_path).expect("metadata").permissions();
permissions.set_mode(0o755);
fs::set_permissions(&script_path, permissions).expect("chmod");
script_path
}
fn manager_server_config(
script_path: &Path,
server_name: &str,
log_path: &Path,
) -> ScopedMcpServerConfig {
ScopedMcpServerConfig {
scope: ConfigSource::Local,
config: McpServerConfig::Stdio(McpStdioServerConfig {
command: "python3".to_string(),
args: vec![script_path.to_string_lossy().into_owned()],
env: BTreeMap::from([
("MCP_SERVER_LABEL".to_string(), server_name.to_string()),
(
"MCP_LOG_PATH".to_string(),
log_path.to_string_lossy().into_owned(),
),
]),
tool_call_timeout_ms: Some(1_000),
}),
}
}
#[test]
fn registers_and_retrieves_server() {
let registry = McpToolRegistry::new();
registry.register_server(
"test-server",
McpConnectionStatus::Connected,
vec![McpToolInfo {
name: "greet".into(),
description: Some("Greet someone".into()),
input_schema: None,
}],
vec![McpResourceInfo {
uri: "res://data".into(),
name: "Data".into(),
description: None,
mime_type: Some("application/json".into()),
}],
Some("TestServer v1.0".into()),
);
let server = registry.get_server("test-server").expect("should exist");
assert_eq!(server.status, McpConnectionStatus::Connected);
assert_eq!(server.tools.len(), 1);
assert_eq!(server.resources.len(), 1);
}
#[test]
fn lists_resources_from_connected_server() {
let registry = McpToolRegistry::new();
registry.register_server(
"srv",
McpConnectionStatus::Connected,
vec![],
vec![McpResourceInfo {
uri: "res://alpha".into(),
name: "Alpha".into(),
description: None,
mime_type: None,
}],
None,
);
let resources = registry.list_resources("srv").expect("should succeed");
assert_eq!(resources.len(), 1);
assert_eq!(resources[0].uri, "res://alpha");
}
#[test]
fn rejects_resource_listing_for_disconnected_server() {
let registry = McpToolRegistry::new();
registry.register_server(
"srv",
McpConnectionStatus::Disconnected,
vec![],
vec![],
None,
);
assert!(registry.list_resources("srv").is_err());
}
#[test]
fn reads_specific_resource() {
let registry = McpToolRegistry::new();
registry.register_server(
"srv",
McpConnectionStatus::Connected,
vec![],
vec![McpResourceInfo {
uri: "res://data".into(),
name: "Data".into(),
description: Some("Test data".into()),
mime_type: Some("text/plain".into()),
}],
None,
);
let resource = registry
.read_resource("srv", "res://data")
.expect("should find");
assert_eq!(resource.name, "Data");
assert!(registry.read_resource("srv", "res://missing").is_err());
}
#[test]
fn given_connected_server_without_manager_when_calling_tool_then_it_errors() {
let registry = McpToolRegistry::new();
registry.register_server(
"srv",
McpConnectionStatus::Connected,
vec![McpToolInfo {
name: "greet".into(),
description: None,
input_schema: None,
}],
vec![],
None,
);
let error = registry
.call_tool("srv", "greet", &serde_json::json!({"name": "world"}))
.expect_err("should require a configured manager");
assert!(error.contains("MCP server manager is not configured"));
// Unknown tool should fail
assert!(registry
.call_tool("srv", "missing", &serde_json::json!({}))
.is_err());
}
#[test]
fn given_connected_server_with_manager_when_calling_tool_then_it_returns_live_result() {
let script_path = write_bridge_mcp_server_script();
let root = script_path.parent().expect("script parent");
let log_path = root.join("bridge.log");
let servers = BTreeMap::from([(
"alpha".to_string(),
manager_server_config(&script_path, "alpha", &log_path),
)]);
let manager = Arc::new(Mutex::new(McpServerManager::from_servers(&servers)));
let registry = McpToolRegistry::new();
registry.register_server(
"alpha",
McpConnectionStatus::Connected,
vec![McpToolInfo {
name: "echo".into(),
description: Some("Echo tool for alpha".into()),
input_schema: Some(serde_json::json!({
"type": "object",
"properties": {"text": {"type": "string"}},
"required": ["text"]
})),
}],
vec![],
Some("bridge test server".into()),
);
registry
.set_manager(Arc::clone(&manager))
.expect("manager should only be set once");
let result = registry
.call_tool("alpha", "echo", &serde_json::json!({"text": "hello"}))
.expect("should return live MCP result");
assert_eq!(
result["structuredContent"]["server"],
serde_json::json!("alpha")
);
assert_eq!(
result["structuredContent"]["echoed"],
serde_json::json!("hello")
);
assert_eq!(
result["content"][0]["text"],
serde_json::json!("alpha:hello")
);
let log = fs::read_to_string(&log_path).expect("read log");
assert_eq!(
log.lines().collect::<Vec<_>>(),
vec!["initialize", "tools/list", "tools/call"]
);
cleanup_script(&script_path);
}
#[test]
fn rejects_tool_call_on_disconnected_server() {
let registry = McpToolRegistry::new();
registry.register_server(
"srv",
McpConnectionStatus::AuthRequired,
vec![McpToolInfo {
name: "greet".into(),
description: None,
input_schema: None,
}],
vec![],
None,
);
assert!(registry
.call_tool("srv", "greet", &serde_json::json!({}))
.is_err());
}
#[test]
fn sets_auth_and_disconnects() {
let registry = McpToolRegistry::new();
registry.register_server(
"srv",
McpConnectionStatus::AuthRequired,
vec![],
vec![],
None,
);
registry
.set_auth_status("srv", McpConnectionStatus::Connected)
.expect("should succeed");
let state = registry.get_server("srv").unwrap();
assert_eq!(state.status, McpConnectionStatus::Connected);
let removed = registry.disconnect("srv");
assert!(removed.is_some());
assert!(registry.is_empty());
}
#[test]
fn rejects_operations_on_missing_server() {
let registry = McpToolRegistry::new();
assert!(registry.list_resources("missing").is_err());
assert!(registry.read_resource("missing", "uri").is_err());
assert!(registry.list_tools("missing").is_err());
assert!(registry
.call_tool("missing", "tool", &serde_json::json!({}))
.is_err());
assert!(registry
.set_auth_status("missing", McpConnectionStatus::Connected)
.is_err());
}
#[test]
fn mcp_connection_status_display_all_variants() {
// given
let cases = [
(McpConnectionStatus::Disconnected, "disconnected"),
(McpConnectionStatus::Connecting, "connecting"),
(McpConnectionStatus::Connected, "connected"),
(McpConnectionStatus::AuthRequired, "auth_required"),
(McpConnectionStatus::Error, "error"),
];
// when
let rendered: Vec<_> = cases
.into_iter()
.map(|(status, expected)| (status.to_string(), expected))
.collect();
// then
assert_eq!(
rendered,
vec![
("disconnected".to_string(), "disconnected"),
("connecting".to_string(), "connecting"),
("connected".to_string(), "connected"),
("auth_required".to_string(), "auth_required"),
("error".to_string(), "error"),
]
);
}
#[test]
fn list_servers_returns_all_registered() {
// given
let registry = McpToolRegistry::new();
registry.register_server(
"alpha",
McpConnectionStatus::Connected,
vec![],
vec![],
None,
);
registry.register_server(
"beta",
McpConnectionStatus::Connecting,
vec![],
vec![],
None,
);
// when
let servers = registry.list_servers();
// then
assert_eq!(servers.len(), 2);
assert!(servers.iter().any(|server| server.server_name == "alpha"));
assert!(servers.iter().any(|server| server.server_name == "beta"));
}
#[test]
fn list_tools_from_connected_server() {
// given
let registry = McpToolRegistry::new();
registry.register_server(
"srv",
McpConnectionStatus::Connected,
vec![McpToolInfo {
name: "inspect".into(),
description: Some("Inspect data".into()),
input_schema: Some(serde_json::json!({"type": "object"})),
}],
vec![],
None,
);
// when
let tools = registry.list_tools("srv").expect("tools should list");
// then
assert_eq!(tools.len(), 1);
assert_eq!(tools[0].name, "inspect");
}
#[test]
fn list_tools_rejects_disconnected_server() {
// given
let registry = McpToolRegistry::new();
registry.register_server(
"srv",
McpConnectionStatus::AuthRequired,
vec![],
vec![],
None,
);
// when
let result = registry.list_tools("srv");
// then
let error = result.expect_err("non-connected server should fail");
assert!(error.contains("not connected"));
assert!(error.contains("auth_required"));
}
#[test]
fn list_tools_rejects_missing_server() {
// given
let registry = McpToolRegistry::new();
// when
let result = registry.list_tools("missing");
// then
assert_eq!(
result.expect_err("missing server should fail"),
"server 'missing' not found"
);
}
#[test]
fn get_server_returns_none_for_missing() {
// given
let registry = McpToolRegistry::new();
// when
let server = registry.get_server("missing");
// then
assert!(server.is_none());
}
#[test]
fn call_tool_payload_structure() {
let script_path = write_bridge_mcp_server_script();
let root = script_path.parent().expect("script parent");
let log_path = root.join("payload.log");
let servers = BTreeMap::from([(
"srv".to_string(),
manager_server_config(&script_path, "srv", &log_path),
)]);
let registry = McpToolRegistry::new();
let arguments = serde_json::json!({"text": "world"});
registry.register_server(
"srv",
McpConnectionStatus::Connected,
vec![McpToolInfo {
name: "echo".into(),
description: Some("Echo tool for srv".into()),
input_schema: Some(serde_json::json!({
"type": "object",
"properties": {"text": {"type": "string"}},
"required": ["text"]
})),
}],
vec![],
None,
);
registry
.set_manager(Arc::new(Mutex::new(McpServerManager::from_servers(
&servers,
))))
.expect("manager should only be set once");
let result = registry
.call_tool("srv", "echo", &arguments)
.expect("tool should return live payload");
assert_eq!(result["structuredContent"]["server"], "srv");
assert_eq!(result["structuredContent"]["echoed"], "world");
assert_eq!(result["content"][0]["text"], "srv:world");
cleanup_script(&script_path);
}
#[test]
fn upsert_overwrites_existing_server() {
// given
let registry = McpToolRegistry::new();
registry.register_server("srv", McpConnectionStatus::Connecting, vec![], vec![], None);
// when
registry.register_server(
"srv",
McpConnectionStatus::Connected,
vec![McpToolInfo {
name: "inspect".into(),
description: None,
input_schema: None,
}],
vec![],
Some("Inspector".into()),
);
let state = registry.get_server("srv").expect("server should exist");
// then
assert_eq!(state.status, McpConnectionStatus::Connected);
assert_eq!(state.tools.len(), 1);
assert_eq!(state.server_info.as_deref(), Some("Inspector"));
}
#[test]
fn disconnect_missing_returns_none() {
// given
let registry = McpToolRegistry::new();
// when
let removed = registry.disconnect("missing");
// then
assert!(removed.is_none());
}
#[test]
fn len_and_is_empty_transitions() {
// given
let registry = McpToolRegistry::new();
// when
registry.register_server(
"alpha",
McpConnectionStatus::Connected,
vec![],
vec![],
None,
);
registry.register_server("beta", McpConnectionStatus::Connected, vec![], vec![], None);
let after_create = registry.len();
registry.disconnect("alpha");
let after_first_remove = registry.len();
registry.disconnect("beta");
// then
assert_eq!(after_create, 2);
assert_eq!(after_first_remove, 1);
assert_eq!(registry.len(), 0);
assert!(registry.is_empty());
}
}
+18 -10
View File
@@ -9,6 +9,7 @@ use sha2::{Digest, Sha256};
use crate::config::OAuthConfig;
/// Persisted OAuth access token bundle used by the CLI.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OAuthTokenSet {
pub access_token: String,
@@ -17,6 +18,7 @@ pub struct OAuthTokenSet {
pub scopes: Vec<String>,
}
/// PKCE verifier/challenge pair generated for an OAuth authorization flow.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PkceCodePair {
pub verifier: String,
@@ -24,6 +26,7 @@ pub struct PkceCodePair {
pub challenge_method: PkceChallengeMethod,
}
/// Challenge algorithms supported by the local PKCE helpers.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PkceChallengeMethod {
S256,
@@ -38,6 +41,7 @@ impl PkceChallengeMethod {
}
}
/// Parameters needed to build an authorization URL for browser-based login.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OAuthAuthorizationRequest {
pub authorize_url: String,
@@ -50,6 +54,7 @@ pub struct OAuthAuthorizationRequest {
pub extra_params: BTreeMap<String, String>,
}
/// Request body for exchanging an OAuth authorization code for tokens.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OAuthTokenExchangeRequest {
pub grant_type: &'static str,
@@ -60,6 +65,7 @@ pub struct OAuthTokenExchangeRequest {
pub state: String,
}
/// Request body for refreshing an existing OAuth token set.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OAuthRefreshRequest {
pub grant_type: &'static str,
@@ -68,6 +74,7 @@ pub struct OAuthRefreshRequest {
pub scopes: Vec<String>,
}
/// Parsed query parameters returned to the local OAuth callback endpoint.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OAuthCallbackParams {
pub code: Option<String>,
@@ -327,15 +334,16 @@ fn credentials_home_dir() -> io::Result<PathBuf> {
if let Some(path) = std::env::var_os("CLAW_CONFIG_HOME") {
return Ok(PathBuf::from(path));
}
if let Some(path) = std::env::var_os("HOME") {
return Ok(PathBuf::from(path).join(".claw"));
}
if cfg!(target_os = "windows") {
if let Some(path) = std::env::var_os("USERPROFILE") {
return Ok(PathBuf::from(path).join(".claw"));
}
}
Err(io::Error::new(io::ErrorKind::NotFound, "HOME or USERPROFILE is not set"))
let home = std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
"HOME is not set (on Windows, set USERPROFILE or HOME, \
or use CLAW_CONFIG_HOME to point directly at the config directory)",
)
})?;
Ok(PathBuf::from(home).join(".claw"))
}
fn read_credentials_root(path: &PathBuf) -> io::Result<Map<String, Value>> {
@@ -448,7 +456,7 @@ fn decode_hex(byte: u8) -> Result<u8, String> {
b'0'..=b'9' => Ok(byte - b'0'),
b'a'..=b'f' => Ok(byte - b'a' + 10),
b'A'..=b'F' => Ok(byte - b'A' + 10),
_ => Err(format!("invalid percent-encoding byte: {byte}")),
_ => Err(format!("invalid percent byte: {byte}")),
}
}
+551
View File
@@ -0,0 +1,551 @@
#![allow(
clippy::match_wildcard_for_single_variants,
clippy::must_use_candidate,
clippy::uninlined_format_args
)]
//! Permission enforcement layer that gates tool execution based on the
//! active `PermissionPolicy`.
use crate::permissions::{PermissionMode, PermissionOutcome, PermissionPolicy};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "outcome")]
pub enum EnforcementResult {
/// Tool execution is allowed.
Allowed,
/// Tool execution was denied due to insufficient permissions.
Denied {
tool: String,
active_mode: String,
required_mode: String,
reason: String,
},
}
#[derive(Debug, Clone, PartialEq)]
pub struct PermissionEnforcer {
policy: PermissionPolicy,
}
impl PermissionEnforcer {
#[must_use]
pub fn new(policy: PermissionPolicy) -> Self {
Self { policy }
}
/// Check whether a tool can be executed under the current permission policy.
/// Auto-denies when prompting is required but no prompter is provided.
pub fn check(&self, tool_name: &str, input: &str) -> EnforcementResult {
// When the active mode is Prompt, defer to the caller's interactive
// prompt flow rather than hard-denying (the enforcer has no prompter).
if self.policy.active_mode() == PermissionMode::Prompt {
return EnforcementResult::Allowed;
}
let outcome = self.policy.authorize(tool_name, input, None);
match outcome {
PermissionOutcome::Allow => EnforcementResult::Allowed,
PermissionOutcome::Deny { reason } => {
let active_mode = self.policy.active_mode();
let required_mode = self.policy.required_mode_for(tool_name);
EnforcementResult::Denied {
tool: tool_name.to_owned(),
active_mode: active_mode.as_str().to_owned(),
required_mode: required_mode.as_str().to_owned(),
reason,
}
}
}
}
#[must_use]
pub fn is_allowed(&self, tool_name: &str, input: &str) -> bool {
matches!(self.check(tool_name, input), EnforcementResult::Allowed)
}
#[must_use]
pub fn active_mode(&self) -> PermissionMode {
self.policy.active_mode()
}
/// Classify a file operation against workspace boundaries.
pub fn check_file_write(&self, path: &str, workspace_root: &str) -> EnforcementResult {
let mode = self.policy.active_mode();
match mode {
PermissionMode::ReadOnly => EnforcementResult::Denied {
tool: "write_file".to_owned(),
active_mode: mode.as_str().to_owned(),
required_mode: PermissionMode::WorkspaceWrite.as_str().to_owned(),
reason: format!("file writes are not allowed in '{}' mode", mode.as_str()),
},
PermissionMode::WorkspaceWrite => {
if is_within_workspace(path, workspace_root) {
EnforcementResult::Allowed
} else {
EnforcementResult::Denied {
tool: "write_file".to_owned(),
active_mode: mode.as_str().to_owned(),
required_mode: PermissionMode::DangerFullAccess.as_str().to_owned(),
reason: format!(
"path '{}' is outside workspace root '{}'",
path, workspace_root
),
}
}
}
// Allow and DangerFullAccess permit all writes
PermissionMode::Allow | PermissionMode::DangerFullAccess => EnforcementResult::Allowed,
PermissionMode::Prompt => EnforcementResult::Denied {
tool: "write_file".to_owned(),
active_mode: mode.as_str().to_owned(),
required_mode: PermissionMode::WorkspaceWrite.as_str().to_owned(),
reason: "file write requires confirmation in prompt mode".to_owned(),
},
}
}
/// Check if a bash command should be allowed based on current mode.
pub fn check_bash(&self, command: &str) -> EnforcementResult {
let mode = self.policy.active_mode();
match mode {
PermissionMode::ReadOnly => {
if is_read_only_command(command) {
EnforcementResult::Allowed
} else {
EnforcementResult::Denied {
tool: "bash".to_owned(),
active_mode: mode.as_str().to_owned(),
required_mode: PermissionMode::WorkspaceWrite.as_str().to_owned(),
reason: format!(
"command may modify state; not allowed in '{}' mode",
mode.as_str()
),
}
}
}
PermissionMode::Prompt => EnforcementResult::Denied {
tool: "bash".to_owned(),
active_mode: mode.as_str().to_owned(),
required_mode: PermissionMode::DangerFullAccess.as_str().to_owned(),
reason: "bash requires confirmation in prompt mode".to_owned(),
},
// WorkspaceWrite, Allow, DangerFullAccess: permit bash
_ => EnforcementResult::Allowed,
}
}
}
/// Simple workspace boundary check via string prefix.
fn is_within_workspace(path: &str, workspace_root: &str) -> bool {
let normalized = if path.starts_with('/') {
path.to_owned()
} else {
format!("{workspace_root}/{path}")
};
let root = if workspace_root.ends_with('/') {
workspace_root.to_owned()
} else {
format!("{workspace_root}/")
};
normalized.starts_with(&root) || normalized == workspace_root.trim_end_matches('/')
}
/// Conservative heuristic: is this bash command read-only?
fn is_read_only_command(command: &str) -> bool {
let first_token = command
.split_whitespace()
.next()
.unwrap_or("")
.rsplit('/')
.next()
.unwrap_or("");
matches!(
first_token,
"cat"
| "head"
| "tail"
| "less"
| "more"
| "wc"
| "ls"
| "find"
| "grep"
| "rg"
| "awk"
| "sed"
| "echo"
| "printf"
| "which"
| "where"
| "whoami"
| "pwd"
| "env"
| "printenv"
| "date"
| "cal"
| "df"
| "du"
| "free"
| "uptime"
| "uname"
| "file"
| "stat"
| "diff"
| "sort"
| "uniq"
| "tr"
| "cut"
| "paste"
| "tee"
| "xargs"
| "test"
| "true"
| "false"
| "type"
| "readlink"
| "realpath"
| "basename"
| "dirname"
| "sha256sum"
| "md5sum"
| "b3sum"
| "xxd"
| "hexdump"
| "od"
| "strings"
| "tree"
| "jq"
| "yq"
| "python3"
| "python"
| "node"
| "ruby"
| "cargo"
| "rustc"
| "git"
| "gh"
) && !command.contains("-i ")
&& !command.contains("--in-place")
&& !command.contains(" > ")
&& !command.contains(" >> ")
}
#[cfg(test)]
mod tests {
use super::*;
fn make_enforcer(mode: PermissionMode) -> PermissionEnforcer {
let policy = PermissionPolicy::new(mode);
PermissionEnforcer::new(policy)
}
#[test]
fn allow_mode_permits_everything() {
let enforcer = make_enforcer(PermissionMode::Allow);
assert!(enforcer.is_allowed("bash", ""));
assert!(enforcer.is_allowed("write_file", ""));
assert!(enforcer.is_allowed("edit_file", ""));
assert_eq!(
enforcer.check_file_write("/outside/path", "/workspace"),
EnforcementResult::Allowed
);
assert_eq!(enforcer.check_bash("rm -rf /"), EnforcementResult::Allowed);
}
#[test]
fn read_only_denies_writes() {
let policy = PermissionPolicy::new(PermissionMode::ReadOnly)
.with_tool_requirement("read_file", PermissionMode::ReadOnly)
.with_tool_requirement("grep_search", PermissionMode::ReadOnly)
.with_tool_requirement("write_file", PermissionMode::WorkspaceWrite);
let enforcer = PermissionEnforcer::new(policy);
assert!(enforcer.is_allowed("read_file", ""));
assert!(enforcer.is_allowed("grep_search", ""));
// write_file requires WorkspaceWrite but we're in ReadOnly
let result = enforcer.check("write_file", "");
assert!(matches!(result, EnforcementResult::Denied { .. }));
let result = enforcer.check_file_write("/workspace/file.rs", "/workspace");
assert!(matches!(result, EnforcementResult::Denied { .. }));
}
#[test]
fn read_only_allows_read_commands() {
let enforcer = make_enforcer(PermissionMode::ReadOnly);
assert_eq!(
enforcer.check_bash("cat src/main.rs"),
EnforcementResult::Allowed
);
assert_eq!(
enforcer.check_bash("grep -r 'pattern' ."),
EnforcementResult::Allowed
);
assert_eq!(enforcer.check_bash("ls -la"), EnforcementResult::Allowed);
}
#[test]
fn read_only_denies_write_commands() {
let enforcer = make_enforcer(PermissionMode::ReadOnly);
let result = enforcer.check_bash("rm file.txt");
assert!(matches!(result, EnforcementResult::Denied { .. }));
}
#[test]
fn workspace_write_allows_within_workspace() {
let enforcer = make_enforcer(PermissionMode::WorkspaceWrite);
let result = enforcer.check_file_write("/workspace/src/main.rs", "/workspace");
assert_eq!(result, EnforcementResult::Allowed);
}
#[test]
fn workspace_write_denies_outside_workspace() {
let enforcer = make_enforcer(PermissionMode::WorkspaceWrite);
let result = enforcer.check_file_write("/etc/passwd", "/workspace");
assert!(matches!(result, EnforcementResult::Denied { .. }));
}
#[test]
fn prompt_mode_denies_without_prompter() {
let enforcer = make_enforcer(PermissionMode::Prompt);
let result = enforcer.check_bash("echo test");
assert!(matches!(result, EnforcementResult::Denied { .. }));
let result = enforcer.check_file_write("/workspace/file.rs", "/workspace");
assert!(matches!(result, EnforcementResult::Denied { .. }));
}
#[test]
fn workspace_boundary_check() {
assert!(is_within_workspace("/workspace/src/main.rs", "/workspace"));
assert!(is_within_workspace("/workspace", "/workspace"));
assert!(!is_within_workspace("/etc/passwd", "/workspace"));
assert!(!is_within_workspace("/workspacex/hack", "/workspace"));
}
#[test]
fn read_only_command_heuristic() {
assert!(is_read_only_command("cat file.txt"));
assert!(is_read_only_command("grep pattern file"));
assert!(is_read_only_command("git log --oneline"));
assert!(!is_read_only_command("rm file.txt"));
assert!(!is_read_only_command("echo test > file.txt"));
assert!(!is_read_only_command("sed -i 's/a/b/' file"));
}
#[test]
fn active_mode_returns_policy_mode() {
// given
let modes = [
PermissionMode::ReadOnly,
PermissionMode::WorkspaceWrite,
PermissionMode::DangerFullAccess,
PermissionMode::Prompt,
PermissionMode::Allow,
];
// when
let active_modes: Vec<_> = modes
.into_iter()
.map(|mode| make_enforcer(mode).active_mode())
.collect();
// then
assert_eq!(active_modes, modes);
}
#[test]
fn danger_full_access_permits_file_writes_and_bash() {
// given
let enforcer = make_enforcer(PermissionMode::DangerFullAccess);
// when
let file_result = enforcer.check_file_write("/outside/workspace/file.txt", "/workspace");
let bash_result = enforcer.check_bash("rm -rf /tmp/scratch");
// then
assert_eq!(file_result, EnforcementResult::Allowed);
assert_eq!(bash_result, EnforcementResult::Allowed);
}
#[test]
fn check_denied_payload_contains_tool_and_modes() {
// given
let policy = PermissionPolicy::new(PermissionMode::ReadOnly)
.with_tool_requirement("write_file", PermissionMode::WorkspaceWrite);
let enforcer = PermissionEnforcer::new(policy);
// when
let result = enforcer.check("write_file", "{}");
// then
match result {
EnforcementResult::Denied {
tool,
active_mode,
required_mode,
reason,
} => {
assert_eq!(tool, "write_file");
assert_eq!(active_mode, "read-only");
assert_eq!(required_mode, "workspace-write");
assert!(reason.contains("requires workspace-write permission"));
}
other => panic!("expected denied result, got {other:?}"),
}
}
#[test]
fn workspace_write_relative_path_resolved() {
// given
let enforcer = make_enforcer(PermissionMode::WorkspaceWrite);
// when
let result = enforcer.check_file_write("src/main.rs", "/workspace");
// then
assert_eq!(result, EnforcementResult::Allowed);
}
#[test]
fn workspace_root_with_trailing_slash() {
// given
let enforcer = make_enforcer(PermissionMode::WorkspaceWrite);
// when
let result = enforcer.check_file_write("/workspace/src/main.rs", "/workspace/");
// then
assert_eq!(result, EnforcementResult::Allowed);
}
#[test]
fn workspace_root_equality() {
// given
let root = "/workspace/";
// when
let equal_to_root = is_within_workspace("/workspace", root);
// then
assert!(equal_to_root);
}
#[test]
fn bash_heuristic_full_path_prefix() {
// given
let full_path_command = "/usr/bin/cat Cargo.toml";
let git_path_command = "/usr/local/bin/git status";
// when
let cat_result = is_read_only_command(full_path_command);
let git_result = is_read_only_command(git_path_command);
// then
assert!(cat_result);
assert!(git_result);
}
#[test]
fn bash_heuristic_redirects_block_read_only_commands() {
// given
let overwrite = "cat Cargo.toml > out.txt";
let append = "echo test >> out.txt";
// when
let overwrite_result = is_read_only_command(overwrite);
let append_result = is_read_only_command(append);
// then
assert!(!overwrite_result);
assert!(!append_result);
}
#[test]
fn bash_heuristic_in_place_flag_blocks() {
// given
let interactive_python = "python -i script.py";
let in_place_sed = "sed --in-place 's/a/b/' file.txt";
// when
let interactive_result = is_read_only_command(interactive_python);
let in_place_result = is_read_only_command(in_place_sed);
// then
assert!(!interactive_result);
assert!(!in_place_result);
}
#[test]
fn bash_heuristic_empty_command() {
// given
let empty = "";
let whitespace = " ";
// when
let empty_result = is_read_only_command(empty);
let whitespace_result = is_read_only_command(whitespace);
// then
assert!(!empty_result);
assert!(!whitespace_result);
}
#[test]
fn prompt_mode_check_bash_denied_payload_fields() {
// given
let enforcer = make_enforcer(PermissionMode::Prompt);
// when
let result = enforcer.check_bash("git status");
// then
match result {
EnforcementResult::Denied {
tool,
active_mode,
required_mode,
reason,
} => {
assert_eq!(tool, "bash");
assert_eq!(active_mode, "prompt");
assert_eq!(required_mode, "danger-full-access");
assert_eq!(reason, "bash requires confirmation in prompt mode");
}
other => panic!("expected denied result, got {other:?}"),
}
}
#[test]
fn read_only_check_file_write_denied_payload() {
// given
let enforcer = make_enforcer(PermissionMode::ReadOnly);
// when
let result = enforcer.check_file_write("/workspace/file.txt", "/workspace");
// then
match result {
EnforcementResult::Denied {
tool,
active_mode,
required_mode,
reason,
} => {
assert_eq!(tool, "write_file");
assert_eq!(active_mode, "read-only");
assert_eq!(required_mode, "workspace-write");
assert!(reason.contains("file writes are not allowed"));
}
other => panic!("expected denied result, got {other:?}"),
}
}
}
+477 -26
View File
@@ -1,5 +1,10 @@
use std::collections::BTreeMap;
use serde_json::Value;
use crate::config::RuntimePermissionRuleConfig;
/// Permission level assigned to a tool invocation or runtime session.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum PermissionMode {
ReadOnly,
@@ -22,34 +27,81 @@ impl PermissionMode {
}
}
/// Hook-provided override applied before standard permission evaluation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PermissionOverride {
Allow,
Deny,
Ask,
}
/// Additional permission context supplied by hooks or higher-level orchestration.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct PermissionContext {
override_decision: Option<PermissionOverride>,
override_reason: Option<String>,
}
impl PermissionContext {
#[must_use]
pub fn new(
override_decision: Option<PermissionOverride>,
override_reason: Option<String>,
) -> Self {
Self {
override_decision,
override_reason,
}
}
#[must_use]
pub fn override_decision(&self) -> Option<PermissionOverride> {
self.override_decision
}
#[must_use]
pub fn override_reason(&self) -> Option<&str> {
self.override_reason.as_deref()
}
}
/// Full authorization request presented to a permission prompt.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PermissionRequest {
pub tool_name: String,
pub input: String,
pub current_mode: PermissionMode,
pub required_mode: PermissionMode,
pub reason: Option<String>,
}
/// User-facing decision returned by a [`PermissionPrompter`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PermissionPromptDecision {
Allow,
Deny { reason: String },
}
/// Prompting interface used when policy requires interactive approval.
pub trait PermissionPrompter {
fn decide(&mut self, request: &PermissionRequest) -> PermissionPromptDecision;
}
/// Final authorization result after evaluating static rules and prompts.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PermissionOutcome {
Allow,
Deny { reason: String },
}
/// Evaluates permission mode requirements plus allow/deny/ask rules.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PermissionPolicy {
active_mode: PermissionMode,
tool_requirements: BTreeMap<String, PermissionMode>,
allow_rules: Vec<PermissionRule>,
deny_rules: Vec<PermissionRule>,
ask_rules: Vec<PermissionRule>,
}
impl PermissionPolicy {
@@ -58,6 +110,9 @@ impl PermissionPolicy {
Self {
active_mode,
tool_requirements: BTreeMap::new(),
allow_rules: Vec::new(),
deny_rules: Vec::new(),
ask_rules: Vec::new(),
}
}
@@ -72,6 +127,26 @@ impl PermissionPolicy {
self
}
#[must_use]
pub fn with_permission_rules(mut self, config: &RuntimePermissionRuleConfig) -> Self {
self.allow_rules = config
.allow()
.iter()
.map(|rule| PermissionRule::parse(rule))
.collect();
self.deny_rules = config
.deny()
.iter()
.map(|rule| PermissionRule::parse(rule))
.collect();
self.ask_rules = config
.ask()
.iter()
.map(|rule| PermissionRule::parse(rule))
.collect();
self
}
#[must_use]
pub fn active_mode(&self) -> PermissionMode {
self.active_mode
@@ -90,38 +165,121 @@ impl PermissionPolicy {
&self,
tool_name: &str,
input: &str,
mut prompter: Option<&mut dyn PermissionPrompter>,
prompter: Option<&mut dyn PermissionPrompter>,
) -> PermissionOutcome {
let current_mode = self.active_mode();
let required_mode = self.required_mode_for(tool_name);
if current_mode == PermissionMode::Allow || current_mode >= required_mode {
return PermissionOutcome::Allow;
self.authorize_with_context(tool_name, input, &PermissionContext::default(), prompter)
}
#[must_use]
#[allow(clippy::too_many_lines)]
pub fn authorize_with_context(
&self,
tool_name: &str,
input: &str,
context: &PermissionContext,
prompter: Option<&mut dyn PermissionPrompter>,
) -> PermissionOutcome {
if let Some(rule) = Self::find_matching_rule(&self.deny_rules, tool_name, input) {
return PermissionOutcome::Deny {
reason: format!(
"Permission to use {tool_name} has been denied by rule '{}'",
rule.raw
),
};
}
let request = PermissionRequest {
tool_name: tool_name.to_string(),
input: input.to_string(),
current_mode,
required_mode,
};
let current_mode = self.active_mode();
let required_mode = self.required_mode_for(tool_name);
let ask_rule = Self::find_matching_rule(&self.ask_rules, tool_name, input);
let allow_rule = Self::find_matching_rule(&self.allow_rules, tool_name, input);
match context.override_decision() {
Some(PermissionOverride::Deny) => {
return PermissionOutcome::Deny {
reason: context.override_reason().map_or_else(
|| format!("tool '{tool_name}' denied by hook"),
ToOwned::to_owned,
),
};
}
Some(PermissionOverride::Ask) => {
let reason = context.override_reason().map_or_else(
|| format!("tool '{tool_name}' requires approval due to hook guidance"),
ToOwned::to_owned,
);
return Self::prompt_or_deny(
tool_name,
input,
current_mode,
required_mode,
Some(reason),
prompter,
);
}
Some(PermissionOverride::Allow) => {
if let Some(rule) = ask_rule {
let reason = format!(
"tool '{tool_name}' requires approval due to ask rule '{}'",
rule.raw
);
return Self::prompt_or_deny(
tool_name,
input,
current_mode,
required_mode,
Some(reason),
prompter,
);
}
if allow_rule.is_some()
|| current_mode == PermissionMode::Allow
|| current_mode >= required_mode
{
return PermissionOutcome::Allow;
}
}
None => {}
}
if let Some(rule) = ask_rule {
let reason = format!(
"tool '{tool_name}' requires approval due to ask rule '{}'",
rule.raw
);
return Self::prompt_or_deny(
tool_name,
input,
current_mode,
required_mode,
Some(reason),
prompter,
);
}
if allow_rule.is_some()
|| current_mode == PermissionMode::Allow
|| current_mode >= required_mode
{
return PermissionOutcome::Allow;
}
if current_mode == PermissionMode::Prompt
|| (current_mode == PermissionMode::WorkspaceWrite
&& required_mode == PermissionMode::DangerFullAccess)
{
return match prompter.as_mut() {
Some(prompter) => match prompter.decide(&request) {
PermissionPromptDecision::Allow => PermissionOutcome::Allow,
PermissionPromptDecision::Deny { reason } => PermissionOutcome::Deny { reason },
},
None => PermissionOutcome::Deny {
reason: format!(
"tool '{tool_name}' requires approval to escalate from {} to {}",
current_mode.as_str(),
required_mode.as_str()
),
},
};
let reason = Some(format!(
"tool '{tool_name}' requires approval to escalate from {} to {}",
current_mode.as_str(),
required_mode.as_str()
));
return Self::prompt_or_deny(
tool_name,
input,
current_mode,
required_mode,
reason,
prompter,
);
}
PermissionOutcome::Deny {
@@ -132,14 +290,191 @@ impl PermissionPolicy {
),
}
}
fn prompt_or_deny(
tool_name: &str,
input: &str,
current_mode: PermissionMode,
required_mode: PermissionMode,
reason: Option<String>,
mut prompter: Option<&mut dyn PermissionPrompter>,
) -> PermissionOutcome {
let request = PermissionRequest {
tool_name: tool_name.to_string(),
input: input.to_string(),
current_mode,
required_mode,
reason: reason.clone(),
};
match prompter.as_mut() {
Some(prompter) => match prompter.decide(&request) {
PermissionPromptDecision::Allow => PermissionOutcome::Allow,
PermissionPromptDecision::Deny { reason } => PermissionOutcome::Deny { reason },
},
None => PermissionOutcome::Deny {
reason: reason.unwrap_or_else(|| {
format!(
"tool '{tool_name}' requires approval to run while mode is {}",
current_mode.as_str()
)
}),
},
}
}
fn find_matching_rule<'a>(
rules: &'a [PermissionRule],
tool_name: &str,
input: &str,
) -> Option<&'a PermissionRule> {
rules.iter().find(|rule| rule.matches(tool_name, input))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct PermissionRule {
raw: String,
tool_name: String,
matcher: PermissionRuleMatcher,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum PermissionRuleMatcher {
Any,
Exact(String),
Prefix(String),
}
impl PermissionRule {
fn parse(raw: &str) -> Self {
let trimmed = raw.trim();
let open = find_first_unescaped(trimmed, '(');
let close = find_last_unescaped(trimmed, ')');
if let (Some(open), Some(close)) = (open, close) {
if close == trimmed.len() - 1 && open < close {
let tool_name = trimmed[..open].trim();
let content = &trimmed[open + 1..close];
if !tool_name.is_empty() {
let matcher = parse_rule_matcher(content);
return Self {
raw: trimmed.to_string(),
tool_name: tool_name.to_string(),
matcher,
};
}
}
}
Self {
raw: trimmed.to_string(),
tool_name: trimmed.to_string(),
matcher: PermissionRuleMatcher::Any,
}
}
fn matches(&self, tool_name: &str, input: &str) -> bool {
if self.tool_name != tool_name {
return false;
}
match &self.matcher {
PermissionRuleMatcher::Any => true,
PermissionRuleMatcher::Exact(expected) => {
extract_permission_subject(input).is_some_and(|candidate| candidate == *expected)
}
PermissionRuleMatcher::Prefix(prefix) => extract_permission_subject(input)
.is_some_and(|candidate| candidate.starts_with(prefix)),
}
}
}
fn parse_rule_matcher(content: &str) -> PermissionRuleMatcher {
let unescaped = unescape_rule_content(content.trim());
if unescaped.is_empty() || unescaped == "*" {
PermissionRuleMatcher::Any
} else if let Some(prefix) = unescaped.strip_suffix(":*") {
PermissionRuleMatcher::Prefix(prefix.to_string())
} else {
PermissionRuleMatcher::Exact(unescaped)
}
}
fn unescape_rule_content(content: &str) -> String {
content
.replace(r"\(", "(")
.replace(r"\)", ")")
.replace(r"\\", r"\")
}
fn find_first_unescaped(value: &str, needle: char) -> Option<usize> {
let mut escaped = false;
for (idx, ch) in value.char_indices() {
if ch == '\\' {
escaped = !escaped;
continue;
}
if ch == needle && !escaped {
return Some(idx);
}
escaped = false;
}
None
}
fn find_last_unescaped(value: &str, needle: char) -> Option<usize> {
let chars = value.char_indices().collect::<Vec<_>>();
for (pos, (idx, ch)) in chars.iter().enumerate().rev() {
if *ch != needle {
continue;
}
let mut backslashes = 0;
for (_, prev) in chars[..pos].iter().rev() {
if *prev == '\\' {
backslashes += 1;
} else {
break;
}
}
if backslashes % 2 == 0 {
return Some(*idx);
}
}
None
}
fn extract_permission_subject(input: &str) -> Option<String> {
let parsed = serde_json::from_str::<Value>(input).ok();
if let Some(Value::Object(object)) = parsed {
for key in [
"command",
"path",
"file_path",
"filePath",
"notebook_path",
"notebookPath",
"url",
"pattern",
"code",
"message",
] {
if let Some(value) = object.get(key).and_then(Value::as_str) {
return Some(value.to_string());
}
}
}
(!input.trim().is_empty()).then(|| input.to_string())
}
#[cfg(test)]
mod tests {
use super::{
PermissionMode, PermissionOutcome, PermissionPolicy, PermissionPromptDecision,
PermissionPrompter, PermissionRequest,
PermissionContext, PermissionMode, PermissionOutcome, PermissionOverride, PermissionPolicy,
PermissionPromptDecision, PermissionPrompter, PermissionRequest,
};
use crate::config::RuntimePermissionRuleConfig;
struct RecordingPrompter {
seen: Vec<PermissionRequest>,
@@ -229,4 +564,120 @@ mod tests {
PermissionOutcome::Deny { reason } if reason == "not now"
));
}
#[test]
fn applies_rule_based_denials_and_allows() {
let rules = RuntimePermissionRuleConfig::new(
vec!["bash(git:*)".to_string()],
vec!["bash(rm -rf:*)".to_string()],
Vec::new(),
);
let policy = PermissionPolicy::new(PermissionMode::ReadOnly)
.with_tool_requirement("bash", PermissionMode::DangerFullAccess)
.with_permission_rules(&rules);
assert_eq!(
policy.authorize("bash", r#"{"command":"git status"}"#, None),
PermissionOutcome::Allow
);
assert!(matches!(
policy.authorize("bash", r#"{"command":"rm -rf /tmp/x"}"#, None),
PermissionOutcome::Deny { reason } if reason.contains("denied by rule")
));
}
#[test]
fn ask_rules_force_prompt_even_when_mode_allows() {
let rules = RuntimePermissionRuleConfig::new(
Vec::new(),
Vec::new(),
vec!["bash(git:*)".to_string()],
);
let policy = PermissionPolicy::new(PermissionMode::DangerFullAccess)
.with_tool_requirement("bash", PermissionMode::DangerFullAccess)
.with_permission_rules(&rules);
let mut prompter = RecordingPrompter {
seen: Vec::new(),
allow: true,
};
let outcome = policy.authorize("bash", r#"{"command":"git status"}"#, Some(&mut prompter));
assert_eq!(outcome, PermissionOutcome::Allow);
assert_eq!(prompter.seen.len(), 1);
assert!(prompter.seen[0]
.reason
.as_deref()
.is_some_and(|reason| reason.contains("ask rule")));
}
#[test]
fn hook_allow_still_respects_ask_rules() {
let rules = RuntimePermissionRuleConfig::new(
Vec::new(),
Vec::new(),
vec!["bash(git:*)".to_string()],
);
let policy = PermissionPolicy::new(PermissionMode::ReadOnly)
.with_tool_requirement("bash", PermissionMode::DangerFullAccess)
.with_permission_rules(&rules);
let context = PermissionContext::new(
Some(PermissionOverride::Allow),
Some("hook approved".to_string()),
);
let mut prompter = RecordingPrompter {
seen: Vec::new(),
allow: true,
};
let outcome = policy.authorize_with_context(
"bash",
r#"{"command":"git status"}"#,
&context,
Some(&mut prompter),
);
assert_eq!(outcome, PermissionOutcome::Allow);
assert_eq!(prompter.seen.len(), 1);
}
#[test]
fn hook_deny_short_circuits_permission_flow() {
let policy = PermissionPolicy::new(PermissionMode::DangerFullAccess)
.with_tool_requirement("bash", PermissionMode::DangerFullAccess);
let context = PermissionContext::new(
Some(PermissionOverride::Deny),
Some("blocked by hook".to_string()),
);
assert_eq!(
policy.authorize_with_context("bash", "{}", &context, None),
PermissionOutcome::Deny {
reason: "blocked by hook".to_string(),
}
);
}
#[test]
fn hook_ask_forces_prompt() {
let policy = PermissionPolicy::new(PermissionMode::DangerFullAccess)
.with_tool_requirement("bash", PermissionMode::DangerFullAccess);
let context = PermissionContext::new(
Some(PermissionOverride::Ask),
Some("hook requested confirmation".to_string()),
);
let mut prompter = RecordingPrompter {
seen: Vec::new(),
allow: true,
};
let outcome = policy.authorize_with_context("bash", "{}", &context, Some(&mut prompter));
assert_eq!(outcome, PermissionOutcome::Allow);
assert_eq!(prompter.seen.len(), 1);
assert_eq!(
prompter.seen[0].reason.as_deref(),
Some("hook requested confirmation")
);
}
}
+533
View File
@@ -0,0 +1,533 @@
#![allow(clippy::redundant_closure_for_method_calls)]
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use crate::config::RuntimePluginConfig;
use crate::mcp_tool_bridge::{McpResourceInfo, McpToolInfo};
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
pub type ToolInfo = McpToolInfo;
pub type ResourceInfo = McpResourceInfo;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ServerStatus {
Healthy,
Degraded,
Failed,
}
impl std::fmt::Display for ServerStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Healthy => write!(f, "healthy"),
Self::Degraded => write!(f, "degraded"),
Self::Failed => write!(f, "failed"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ServerHealth {
pub server_name: String,
pub status: ServerStatus,
pub capabilities: Vec<String>,
pub last_error: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "state")]
pub enum PluginState {
Unconfigured,
Validated,
Starting,
Healthy,
Degraded {
healthy_servers: Vec<String>,
failed_servers: Vec<ServerHealth>,
},
Failed {
reason: String,
},
ShuttingDown,
Stopped,
}
impl PluginState {
#[must_use]
pub fn from_servers(servers: &[ServerHealth]) -> Self {
if servers.is_empty() {
return Self::Failed {
reason: "no servers available".to_string(),
};
}
let healthy_servers = servers
.iter()
.filter(|server| server.status != ServerStatus::Failed)
.map(|server| server.server_name.clone())
.collect::<Vec<_>>();
let failed_servers = servers
.iter()
.filter(|server| server.status == ServerStatus::Failed)
.cloned()
.collect::<Vec<_>>();
let has_degraded_server = servers
.iter()
.any(|server| server.status == ServerStatus::Degraded);
if failed_servers.is_empty() && !has_degraded_server {
Self::Healthy
} else if healthy_servers.is_empty() {
Self::Failed {
reason: format!("all {} servers failed", failed_servers.len()),
}
} else {
Self::Degraded {
healthy_servers,
failed_servers,
}
}
}
}
impl std::fmt::Display for PluginState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Unconfigured => write!(f, "unconfigured"),
Self::Validated => write!(f, "validated"),
Self::Starting => write!(f, "starting"),
Self::Healthy => write!(f, "healthy"),
Self::Degraded { .. } => write!(f, "degraded"),
Self::Failed { .. } => write!(f, "failed"),
Self::ShuttingDown => write!(f, "shutting_down"),
Self::Stopped => write!(f, "stopped"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PluginHealthcheck {
pub plugin_name: String,
pub state: PluginState,
pub servers: Vec<ServerHealth>,
pub last_check: u64,
}
impl PluginHealthcheck {
#[must_use]
pub fn new(plugin_name: impl Into<String>, servers: Vec<ServerHealth>) -> Self {
let state = PluginState::from_servers(&servers);
Self {
plugin_name: plugin_name.into(),
state,
servers,
last_check: now_secs(),
}
}
#[must_use]
pub fn degraded_mode(&self, discovery: &DiscoveryResult) -> Option<DegradedMode> {
match &self.state {
PluginState::Degraded {
healthy_servers,
failed_servers,
} => Some(DegradedMode {
available_tools: discovery
.tools
.iter()
.map(|tool| tool.name.clone())
.collect(),
unavailable_tools: failed_servers
.iter()
.flat_map(|server| server.capabilities.iter().cloned())
.collect(),
reason: format!(
"{} servers healthy, {} servers failed",
healthy_servers.len(),
failed_servers.len()
),
}),
_ => None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiscoveryResult {
pub tools: Vec<ToolInfo>,
pub resources: Vec<ResourceInfo>,
pub partial: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DegradedMode {
pub available_tools: Vec<String>,
pub unavailable_tools: Vec<String>,
pub reason: String,
}
impl DegradedMode {
#[must_use]
pub fn new(
available_tools: Vec<String>,
unavailable_tools: Vec<String>,
reason: impl Into<String>,
) -> Self {
Self {
available_tools,
unavailable_tools,
reason: reason.into(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PluginLifecycleEvent {
ConfigValidated,
StartupHealthy,
StartupDegraded,
StartupFailed,
Shutdown,
}
impl std::fmt::Display for PluginLifecycleEvent {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::ConfigValidated => write!(f, "config_validated"),
Self::StartupHealthy => write!(f, "startup_healthy"),
Self::StartupDegraded => write!(f, "startup_degraded"),
Self::StartupFailed => write!(f, "startup_failed"),
Self::Shutdown => write!(f, "shutdown"),
}
}
}
pub trait PluginLifecycle {
fn validate_config(&self, config: &RuntimePluginConfig) -> Result<(), String>;
fn healthcheck(&self) -> PluginHealthcheck;
fn discover(&self) -> DiscoveryResult;
fn shutdown(&mut self) -> Result<(), String>;
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Debug, Clone)]
struct MockPluginLifecycle {
plugin_name: String,
valid_config: bool,
healthcheck: PluginHealthcheck,
discovery: DiscoveryResult,
shutdown_error: Option<String>,
shutdown_called: bool,
}
impl MockPluginLifecycle {
fn new(
plugin_name: &str,
valid_config: bool,
servers: Vec<ServerHealth>,
discovery: DiscoveryResult,
shutdown_error: Option<String>,
) -> Self {
Self {
plugin_name: plugin_name.to_string(),
valid_config,
healthcheck: PluginHealthcheck::new(plugin_name, servers),
discovery,
shutdown_error,
shutdown_called: false,
}
}
}
impl PluginLifecycle for MockPluginLifecycle {
fn validate_config(&self, _config: &RuntimePluginConfig) -> Result<(), String> {
if self.valid_config {
Ok(())
} else {
Err(format!(
"plugin `{}` failed configuration validation",
self.plugin_name
))
}
}
fn healthcheck(&self) -> PluginHealthcheck {
if self.shutdown_called {
PluginHealthcheck {
plugin_name: self.plugin_name.clone(),
state: PluginState::Stopped,
servers: self.healthcheck.servers.clone(),
last_check: now_secs(),
}
} else {
self.healthcheck.clone()
}
}
fn discover(&self) -> DiscoveryResult {
self.discovery.clone()
}
fn shutdown(&mut self) -> Result<(), String> {
if let Some(error) = &self.shutdown_error {
return Err(error.clone());
}
self.shutdown_called = true;
Ok(())
}
}
fn healthy_server(name: &str, capabilities: &[&str]) -> ServerHealth {
ServerHealth {
server_name: name.to_string(),
status: ServerStatus::Healthy,
capabilities: capabilities
.iter()
.map(|capability| capability.to_string())
.collect(),
last_error: None,
}
}
fn failed_server(name: &str, capabilities: &[&str], error: &str) -> ServerHealth {
ServerHealth {
server_name: name.to_string(),
status: ServerStatus::Failed,
capabilities: capabilities
.iter()
.map(|capability| capability.to_string())
.collect(),
last_error: Some(error.to_string()),
}
}
fn degraded_server(name: &str, capabilities: &[&str], error: &str) -> ServerHealth {
ServerHealth {
server_name: name.to_string(),
status: ServerStatus::Degraded,
capabilities: capabilities
.iter()
.map(|capability| capability.to_string())
.collect(),
last_error: Some(error.to_string()),
}
}
fn tool(name: &str) -> ToolInfo {
ToolInfo {
name: name.to_string(),
description: Some(format!("{name} tool")),
input_schema: None,
}
}
fn resource(name: &str, uri: &str) -> ResourceInfo {
ResourceInfo {
uri: uri.to_string(),
name: name.to_string(),
description: Some(format!("{name} resource")),
mime_type: Some("application/json".to_string()),
}
}
#[test]
fn full_lifecycle_happy_path() {
// given
let mut lifecycle = MockPluginLifecycle::new(
"healthy-plugin",
true,
vec![
healthy_server("alpha", &["search", "read"]),
healthy_server("beta", &["write"]),
],
DiscoveryResult {
tools: vec![tool("search"), tool("read"), tool("write")],
resources: vec![resource("docs", "file:///docs")],
partial: false,
},
None,
);
let config = RuntimePluginConfig::default();
// when
let validation = lifecycle.validate_config(&config);
let healthcheck = lifecycle.healthcheck();
let discovery = lifecycle.discover();
let shutdown = lifecycle.shutdown();
let post_shutdown = lifecycle.healthcheck();
// then
assert_eq!(validation, Ok(()));
assert_eq!(healthcheck.state, PluginState::Healthy);
assert_eq!(healthcheck.plugin_name, "healthy-plugin");
assert_eq!(discovery.tools.len(), 3);
assert_eq!(discovery.resources.len(), 1);
assert!(!discovery.partial);
assert_eq!(shutdown, Ok(()));
assert_eq!(post_shutdown.state, PluginState::Stopped);
}
#[test]
fn degraded_startup_when_one_of_three_servers_fails() {
// given
let lifecycle = MockPluginLifecycle::new(
"degraded-plugin",
true,
vec![
healthy_server("alpha", &["search"]),
failed_server("beta", &["write"], "connection refused"),
healthy_server("gamma", &["read"]),
],
DiscoveryResult {
tools: vec![tool("search"), tool("read")],
resources: vec![resource("alpha-docs", "file:///alpha")],
partial: true,
},
None,
);
// when
let healthcheck = lifecycle.healthcheck();
let discovery = lifecycle.discover();
let degraded_mode = healthcheck
.degraded_mode(&discovery)
.expect("degraded startup should expose degraded mode");
// then
match healthcheck.state {
PluginState::Degraded {
healthy_servers,
failed_servers,
} => {
assert_eq!(
healthy_servers,
vec!["alpha".to_string(), "gamma".to_string()]
);
assert_eq!(failed_servers.len(), 1);
assert_eq!(failed_servers[0].server_name, "beta");
assert_eq!(
failed_servers[0].last_error.as_deref(),
Some("connection refused")
);
}
other => panic!("expected degraded state, got {other:?}"),
}
assert!(discovery.partial);
assert_eq!(
degraded_mode.available_tools,
vec!["search".to_string(), "read".to_string()]
);
assert_eq!(degraded_mode.unavailable_tools, vec!["write".to_string()]);
assert_eq!(degraded_mode.reason, "2 servers healthy, 1 servers failed");
}
#[test]
fn degraded_server_status_keeps_server_usable() {
// given
let lifecycle = MockPluginLifecycle::new(
"soft-degraded-plugin",
true,
vec![
healthy_server("alpha", &["search"]),
degraded_server("beta", &["write"], "high latency"),
],
DiscoveryResult {
tools: vec![tool("search"), tool("write")],
resources: Vec::new(),
partial: true,
},
None,
);
// when
let healthcheck = lifecycle.healthcheck();
// then
match healthcheck.state {
PluginState::Degraded {
healthy_servers,
failed_servers,
} => {
assert_eq!(
healthy_servers,
vec!["alpha".to_string(), "beta".to_string()]
);
assert!(failed_servers.is_empty());
}
other => panic!("expected degraded state, got {other:?}"),
}
}
#[test]
fn complete_failure_when_all_servers_fail() {
// given
let lifecycle = MockPluginLifecycle::new(
"failed-plugin",
true,
vec![
failed_server("alpha", &["search"], "timeout"),
failed_server("beta", &["read"], "handshake failed"),
],
DiscoveryResult {
tools: Vec::new(),
resources: Vec::new(),
partial: false,
},
None,
);
// when
let healthcheck = lifecycle.healthcheck();
let discovery = lifecycle.discover();
// then
match &healthcheck.state {
PluginState::Failed { reason } => {
assert_eq!(reason, "all 2 servers failed");
}
other => panic!("expected failed state, got {other:?}"),
}
assert!(!discovery.partial);
assert!(discovery.tools.is_empty());
assert!(discovery.resources.is_empty());
assert!(healthcheck.degraded_mode(&discovery).is_none());
}
#[test]
fn graceful_shutdown() {
// given
let mut lifecycle = MockPluginLifecycle::new(
"shutdown-plugin",
true,
vec![healthy_server("alpha", &["search"])],
DiscoveryResult {
tools: vec![tool("search")],
resources: Vec::new(),
partial: false,
},
None,
);
// when
let shutdown = lifecycle.shutdown();
let post_shutdown = lifecycle.healthcheck();
// then
assert_eq!(shutdown, Ok(()));
assert_eq!(PluginLifecycleEvent::Shutdown.to_string(), "shutdown");
assert_eq!(post_shutdown.state, PluginState::Stopped);
}
}
+581
View File
@@ -0,0 +1,581 @@
use std::time::Duration;
pub type GreenLevel = u8;
const STALE_BRANCH_THRESHOLD: Duration = Duration::from_secs(60 * 60);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PolicyRule {
pub name: String,
pub condition: PolicyCondition,
pub action: PolicyAction,
pub priority: u32,
}
impl PolicyRule {
#[must_use]
pub fn new(
name: impl Into<String>,
condition: PolicyCondition,
action: PolicyAction,
priority: u32,
) -> Self {
Self {
name: name.into(),
condition,
action,
priority,
}
}
#[must_use]
pub fn matches(&self, context: &LaneContext) -> bool {
self.condition.matches(context)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PolicyCondition {
And(Vec<PolicyCondition>),
Or(Vec<PolicyCondition>),
GreenAt { level: GreenLevel },
StaleBranch,
StartupBlocked,
LaneCompleted,
LaneReconciled,
ReviewPassed,
ScopedDiff,
TimedOut { duration: Duration },
}
impl PolicyCondition {
#[must_use]
pub fn matches(&self, context: &LaneContext) -> bool {
match self {
Self::And(conditions) => conditions
.iter()
.all(|condition| condition.matches(context)),
Self::Or(conditions) => conditions
.iter()
.any(|condition| condition.matches(context)),
Self::GreenAt { level } => context.green_level >= *level,
Self::StaleBranch => context.branch_freshness >= STALE_BRANCH_THRESHOLD,
Self::StartupBlocked => context.blocker == LaneBlocker::Startup,
Self::LaneCompleted => context.completed,
Self::LaneReconciled => context.reconciled,
Self::ReviewPassed => context.review_status == ReviewStatus::Approved,
Self::ScopedDiff => context.diff_scope == DiffScope::Scoped,
Self::TimedOut { duration } => context.branch_freshness >= *duration,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PolicyAction {
MergeToDev,
MergeForward,
RecoverOnce,
Escalate { reason: String },
CloseoutLane,
CleanupSession,
Reconcile { reason: ReconcileReason },
Notify { channel: String },
Block { reason: String },
Chain(Vec<PolicyAction>),
}
/// Why a lane was reconciled without further action.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReconcileReason {
/// Branch already merged into main — no PR needed.
AlreadyMerged,
/// Work superseded by another lane or direct commit.
Superseded,
/// PR would be empty — all changes already landed.
EmptyDiff,
/// Lane manually closed by operator.
ManualClose,
}
impl PolicyAction {
fn flatten_into(&self, actions: &mut Vec<PolicyAction>) {
match self {
Self::Chain(chained) => {
for action in chained {
action.flatten_into(actions);
}
}
_ => actions.push(self.clone()),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LaneBlocker {
None,
Startup,
External,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReviewStatus {
Pending,
Approved,
Rejected,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiffScope {
Full,
Scoped,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LaneContext {
pub lane_id: String,
pub green_level: GreenLevel,
pub branch_freshness: Duration,
pub blocker: LaneBlocker,
pub review_status: ReviewStatus,
pub diff_scope: DiffScope,
pub completed: bool,
pub reconciled: bool,
}
impl LaneContext {
#[must_use]
pub fn new(
lane_id: impl Into<String>,
green_level: GreenLevel,
branch_freshness: Duration,
blocker: LaneBlocker,
review_status: ReviewStatus,
diff_scope: DiffScope,
completed: bool,
) -> Self {
Self {
lane_id: lane_id.into(),
green_level,
branch_freshness,
blocker,
review_status,
diff_scope,
completed,
reconciled: false,
}
}
/// Create a lane context that is already reconciled (no further action needed).
#[must_use]
pub fn reconciled(lane_id: impl Into<String>) -> Self {
Self {
lane_id: lane_id.into(),
green_level: 0,
branch_freshness: Duration::from_secs(0),
blocker: LaneBlocker::None,
review_status: ReviewStatus::Pending,
diff_scope: DiffScope::Full,
completed: true,
reconciled: true,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PolicyEngine {
rules: Vec<PolicyRule>,
}
impl PolicyEngine {
#[must_use]
pub fn new(mut rules: Vec<PolicyRule>) -> Self {
rules.sort_by_key(|rule| rule.priority);
Self { rules }
}
#[must_use]
pub fn rules(&self) -> &[PolicyRule] {
&self.rules
}
#[must_use]
pub fn evaluate(&self, context: &LaneContext) -> Vec<PolicyAction> {
evaluate(self, context)
}
}
#[must_use]
pub fn evaluate(engine: &PolicyEngine, context: &LaneContext) -> Vec<PolicyAction> {
let mut actions = Vec::new();
for rule in &engine.rules {
if rule.matches(context) {
rule.action.flatten_into(&mut actions);
}
}
actions
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::{
evaluate, DiffScope, LaneBlocker, LaneContext, PolicyAction, PolicyCondition, PolicyEngine,
PolicyRule, ReconcileReason, ReviewStatus, STALE_BRANCH_THRESHOLD,
};
fn default_context() -> LaneContext {
LaneContext::new(
"lane-7",
0,
Duration::from_secs(0),
LaneBlocker::None,
ReviewStatus::Pending,
DiffScope::Full,
false,
)
}
#[test]
fn merge_to_dev_rule_fires_for_green_scoped_reviewed_lane() {
// given
let engine = PolicyEngine::new(vec![PolicyRule::new(
"merge-to-dev",
PolicyCondition::And(vec![
PolicyCondition::GreenAt { level: 2 },
PolicyCondition::ScopedDiff,
PolicyCondition::ReviewPassed,
]),
PolicyAction::MergeToDev,
20,
)]);
let context = LaneContext::new(
"lane-7",
3,
Duration::from_secs(5),
LaneBlocker::None,
ReviewStatus::Approved,
DiffScope::Scoped,
false,
);
// when
let actions = engine.evaluate(&context);
// then
assert_eq!(actions, vec![PolicyAction::MergeToDev]);
}
#[test]
fn stale_branch_rule_fires_at_threshold() {
// given
let engine = PolicyEngine::new(vec![PolicyRule::new(
"merge-forward",
PolicyCondition::StaleBranch,
PolicyAction::MergeForward,
10,
)]);
let context = LaneContext::new(
"lane-7",
1,
STALE_BRANCH_THRESHOLD,
LaneBlocker::None,
ReviewStatus::Pending,
DiffScope::Full,
false,
);
// when
let actions = engine.evaluate(&context);
// then
assert_eq!(actions, vec![PolicyAction::MergeForward]);
}
#[test]
fn startup_blocked_rule_recovers_then_escalates() {
// given
let engine = PolicyEngine::new(vec![PolicyRule::new(
"startup-recovery",
PolicyCondition::StartupBlocked,
PolicyAction::Chain(vec![
PolicyAction::RecoverOnce,
PolicyAction::Escalate {
reason: "startup remained blocked".to_string(),
},
]),
15,
)]);
let context = LaneContext::new(
"lane-7",
0,
Duration::from_secs(0),
LaneBlocker::Startup,
ReviewStatus::Pending,
DiffScope::Full,
false,
);
// when
let actions = engine.evaluate(&context);
// then
assert_eq!(
actions,
vec![
PolicyAction::RecoverOnce,
PolicyAction::Escalate {
reason: "startup remained blocked".to_string(),
},
]
);
}
#[test]
fn completed_lane_rule_closes_out_and_cleans_up() {
// given
let engine = PolicyEngine::new(vec![PolicyRule::new(
"lane-closeout",
PolicyCondition::LaneCompleted,
PolicyAction::Chain(vec![
PolicyAction::CloseoutLane,
PolicyAction::CleanupSession,
]),
30,
)]);
let context = LaneContext::new(
"lane-7",
0,
Duration::from_secs(0),
LaneBlocker::None,
ReviewStatus::Pending,
DiffScope::Full,
true,
);
// when
let actions = engine.evaluate(&context);
// then
assert_eq!(
actions,
vec![PolicyAction::CloseoutLane, PolicyAction::CleanupSession]
);
}
#[test]
fn matching_rules_are_returned_in_priority_order_with_stable_ties() {
// given
let engine = PolicyEngine::new(vec![
PolicyRule::new(
"late-cleanup",
PolicyCondition::And(vec![]),
PolicyAction::CleanupSession,
30,
),
PolicyRule::new(
"first-notify",
PolicyCondition::And(vec![]),
PolicyAction::Notify {
channel: "ops".to_string(),
},
10,
),
PolicyRule::new(
"second-notify",
PolicyCondition::And(vec![]),
PolicyAction::Notify {
channel: "review".to_string(),
},
10,
),
PolicyRule::new(
"merge",
PolicyCondition::And(vec![]),
PolicyAction::MergeToDev,
20,
),
]);
let context = default_context();
// when
let actions = evaluate(&engine, &context);
// then
assert_eq!(
actions,
vec![
PolicyAction::Notify {
channel: "ops".to_string(),
},
PolicyAction::Notify {
channel: "review".to_string(),
},
PolicyAction::MergeToDev,
PolicyAction::CleanupSession,
]
);
}
#[test]
fn combinators_handle_empty_cases_and_nested_chains() {
// given
let engine = PolicyEngine::new(vec![
PolicyRule::new(
"empty-and",
PolicyCondition::And(vec![]),
PolicyAction::Notify {
channel: "orchestrator".to_string(),
},
5,
),
PolicyRule::new(
"empty-or",
PolicyCondition::Or(vec![]),
PolicyAction::Block {
reason: "should not fire".to_string(),
},
10,
),
PolicyRule::new(
"nested",
PolicyCondition::Or(vec![
PolicyCondition::StartupBlocked,
PolicyCondition::And(vec![
PolicyCondition::GreenAt { level: 2 },
PolicyCondition::TimedOut {
duration: Duration::from_secs(5),
},
]),
]),
PolicyAction::Chain(vec![
PolicyAction::Notify {
channel: "alerts".to_string(),
},
PolicyAction::Chain(vec![
PolicyAction::MergeForward,
PolicyAction::CleanupSession,
]),
]),
15,
),
]);
let context = LaneContext::new(
"lane-7",
2,
Duration::from_secs(10),
LaneBlocker::External,
ReviewStatus::Pending,
DiffScope::Full,
false,
);
// when
let actions = engine.evaluate(&context);
// then
assert_eq!(
actions,
vec![
PolicyAction::Notify {
channel: "orchestrator".to_string(),
},
PolicyAction::Notify {
channel: "alerts".to_string(),
},
PolicyAction::MergeForward,
PolicyAction::CleanupSession,
]
);
}
#[test]
fn reconciled_lane_emits_reconcile_and_cleanup() {
// given — a lane where branch is already merged, no PR needed, session stale
let engine = PolicyEngine::new(vec![
PolicyRule::new(
"reconcile-closeout",
PolicyCondition::LaneReconciled,
PolicyAction::Chain(vec![
PolicyAction::Reconcile {
reason: ReconcileReason::AlreadyMerged,
},
PolicyAction::CloseoutLane,
PolicyAction::CleanupSession,
]),
5,
),
// This rule should NOT fire — reconciled lanes are completed but we want
// the more specific reconcile rule to handle them
PolicyRule::new(
"generic-closeout",
PolicyCondition::And(vec![
PolicyCondition::LaneCompleted,
// Only fire if NOT reconciled
PolicyCondition::And(vec![]),
]),
PolicyAction::CloseoutLane,
30,
),
]);
let context = LaneContext::reconciled("lane-9411");
// when
let actions = engine.evaluate(&context);
// then — reconcile rule fires first (priority 5), then generic closeout also fires
// because reconciled context has completed=true
assert_eq!(
actions,
vec![
PolicyAction::Reconcile {
reason: ReconcileReason::AlreadyMerged,
},
PolicyAction::CloseoutLane,
PolicyAction::CleanupSession,
PolicyAction::CloseoutLane,
]
);
}
#[test]
fn reconciled_context_has_correct_defaults() {
let ctx = LaneContext::reconciled("test-lane");
assert_eq!(ctx.lane_id, "test-lane");
assert!(ctx.completed);
assert!(ctx.reconciled);
assert_eq!(ctx.blocker, LaneBlocker::None);
assert_eq!(ctx.green_level, 0);
}
#[test]
fn non_reconciled_lane_does_not_trigger_reconcile_rule() {
let engine = PolicyEngine::new(vec![PolicyRule::new(
"reconcile-closeout",
PolicyCondition::LaneReconciled,
PolicyAction::Reconcile {
reason: ReconcileReason::EmptyDiff,
},
5,
)]);
// Normal completed lane — not reconciled
let context = LaneContext::new(
"lane-7",
0,
Duration::from_secs(0),
LaneBlocker::None,
ReviewStatus::Pending,
DiffScope::Full,
true,
);
let actions = engine.evaluate(&context);
assert!(actions.is_empty());
}
#[test]
fn reconcile_reason_variants_are_distinct() {
assert_ne!(ReconcileReason::AlreadyMerged, ReconcileReason::Superseded);
assert_ne!(ReconcileReason::EmptyDiff, ReconcileReason::ManualClose);
}
}
+147 -37
View File
@@ -4,8 +4,9 @@ use std::path::{Path, PathBuf};
use std::process::Command;
use crate::config::{ConfigError, ConfigLoader, RuntimeConfig};
use lsp::LspContextEnrichment;
use crate::git_context::GitContext;
/// Errors raised while assembling the final system prompt.
#[derive(Debug)]
pub enum PromptBuildError {
Io(std::io::Error),
@@ -35,23 +36,28 @@ impl From<ConfigError> for PromptBuildError {
}
}
/// Marker separating static prompt scaffolding from dynamic runtime context.
pub const SYSTEM_PROMPT_DYNAMIC_BOUNDARY: &str = "__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__";
pub const FRONTIER_MODEL_NAME: &str = "Opus 4.6";
/// Human-readable default frontier model name embedded into generated prompts.
pub const FRONTIER_MODEL_NAME: &str = "Claude Opus 4.6";
const MAX_INSTRUCTION_FILE_CHARS: usize = 4_000;
const MAX_TOTAL_INSTRUCTION_CHARS: usize = 12_000;
/// Contents of an instruction file included in prompt construction.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ContextFile {
pub path: PathBuf,
pub content: String,
}
/// Project-local context injected into the rendered system prompt.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ProjectContext {
pub cwd: PathBuf,
pub current_date: String,
pub git_status: Option<String>,
pub git_diff: Option<String>,
pub git_context: Option<GitContext>,
pub instruction_files: Vec<ContextFile>,
}
@@ -67,6 +73,7 @@ impl ProjectContext {
current_date: current_date.into(),
git_status: None,
git_diff: None,
git_context: None,
instruction_files,
})
}
@@ -78,10 +85,12 @@ impl ProjectContext {
let mut context = Self::discover(cwd, current_date)?;
context.git_status = read_git_status(&context.cwd);
context.git_diff = read_git_diff(&context.cwd);
context.git_context = GitContext::detect(&context.cwd);
Ok(context)
}
}
/// Builder for the runtime system prompt and dynamic environment sections.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SystemPromptBuilder {
output_style_name: Option<String>,
@@ -131,15 +140,6 @@ impl SystemPromptBuilder {
self
}
#[must_use]
pub fn with_lsp_context(mut self, enrichment: &LspContextEnrichment) -> Self {
if !enrichment.is_empty() {
self.append_sections
.push(enrichment.render_prompt_section());
}
self
}
#[must_use]
pub fn build(&self) -> Vec<String> {
let mut sections = Vec::new();
@@ -194,6 +194,7 @@ impl SystemPromptBuilder {
}
}
/// Formats each item as an indented bullet for prompt sections.
#[must_use]
pub fn prepend_bullets(items: Vec<String>) -> Vec<String> {
items.into_iter().map(|item| format!(" - {item}")).collect()
@@ -211,9 +212,9 @@ fn discover_instruction_files(cwd: &Path) -> std::io::Result<Vec<ContextFile>> {
let mut files = Vec::new();
for dir in directories {
for candidate in [
dir.join("CLAW.md"),
dir.join("CLAW.local.md"),
dir.join(".claw").join("CLAW.md"),
dir.join("CLAUDE.md"),
dir.join("CLAUDE.local.md"),
dir.join(".claw").join("CLAUDE.md"),
dir.join(".claw").join("instructions.md"),
] {
push_context_file(&mut files, candidate)?;
@@ -292,7 +293,7 @@ fn render_project_context(project_context: &ProjectContext) -> String {
];
if !project_context.instruction_files.is_empty() {
bullets.push(format!(
"Claw instruction files discovered: {}.",
"Claude instruction files discovered: {}.",
project_context.instruction_files.len()
));
}
@@ -302,16 +303,32 @@ fn render_project_context(project_context: &ProjectContext) -> String {
lines.push("Git status snapshot:".to_string());
lines.push(status.clone());
}
if let Some(ref gc) = project_context.git_context {
if !gc.recent_commits.is_empty() {
lines.push(String::new());
lines.push("Recent commits (last 5):".to_string());
for c in &gc.recent_commits {
lines.push(format!(" {} {}", c.hash, c.subject));
}
}
}
if let Some(diff) = &project_context.git_diff {
lines.push(String::new());
lines.push("Git diff snapshot:".to_string());
lines.push(diff.clone());
}
if let Some(git_context) = &project_context.git_context {
let rendered = git_context.render();
if !rendered.is_empty() {
lines.push(String::new());
lines.push(rendered);
}
}
lines.join("\n")
}
fn render_instruction_files(files: &[ContextFile]) -> String {
let mut sections = vec!["# Claw instructions".to_string()];
let mut sections = vec!["# Claude instructions".to_string()];
let mut remaining_chars = MAX_TOTAL_INSTRUCTION_CHARS;
for file in files {
if remaining_chars == 0 {
@@ -411,6 +428,7 @@ fn collapse_blank_lines(content: &str) -> String {
result
}
/// Loads config and project context, then renders the system prompt text.
pub fn load_system_prompt(
cwd: impl Into<PathBuf>,
current_date: impl Into<String>,
@@ -523,24 +541,31 @@ mod tests {
crate::test_env_lock()
}
fn ensure_valid_cwd() {
if std::env::current_dir().is_err() {
std::env::set_current_dir(env!("CARGO_MANIFEST_DIR"))
.expect("test cwd should be recoverable");
}
}
#[test]
fn discovers_instruction_files_from_ancestor_chain() {
let root = temp_dir();
let nested = root.join("apps").join("api");
fs::create_dir_all(nested.join(".claw")).expect("nested claw dir");
fs::write(root.join("CLAW.md"), "root instructions").expect("write root instructions");
fs::write(root.join("CLAW.local.md"), "local instructions")
fs::write(root.join("CLAUDE.md"), "root instructions").expect("write root instructions");
fs::write(root.join("CLAUDE.local.md"), "local instructions")
.expect("write local instructions");
fs::create_dir_all(root.join("apps")).expect("apps dir");
fs::create_dir_all(root.join("apps").join(".claw")).expect("apps claw dir");
fs::write(root.join("apps").join("CLAW.md"), "apps instructions")
fs::write(root.join("apps").join("CLAUDE.md"), "apps instructions")
.expect("write apps instructions");
fs::write(
root.join("apps").join(".claw").join("instructions.md"),
"apps dot claw instructions",
"apps dot claude instructions",
)
.expect("write apps dot claw instructions");
fs::write(nested.join(".claw").join("CLAW.md"), "nested rules")
.expect("write apps dot claude instructions");
fs::write(nested.join(".claw").join("CLAUDE.md"), "nested rules")
.expect("write nested rules");
fs::write(
nested.join(".claw").join("instructions.md"),
@@ -561,7 +586,7 @@ mod tests {
"root instructions",
"local instructions",
"apps instructions",
"apps dot claw instructions",
"apps dot claude instructions",
"nested rules",
"nested instructions"
]
@@ -574,8 +599,8 @@ mod tests {
let root = temp_dir();
let nested = root.join("apps").join("api");
fs::create_dir_all(&nested).expect("nested dir");
fs::write(root.join("CLAW.md"), "same rules\n\n").expect("write root");
fs::write(nested.join("CLAW.md"), "same rules\n").expect("write nested");
fs::write(root.join("CLAUDE.md"), "same rules\n\n").expect("write root");
fs::write(nested.join("CLAUDE.md"), "same rules\n").expect("write nested");
let context = ProjectContext::discover(&nested, "2026-03-31").expect("context should load");
assert_eq!(context.instruction_files.len(), 1);
@@ -603,14 +628,15 @@ mod tests {
#[test]
fn displays_context_paths_compactly() {
assert_eq!(
display_context_path(Path::new("/tmp/project/.claw/CLAW.md")),
"CLAW.md"
display_context_path(Path::new("/tmp/project/.claw/CLAUDE.md")),
"CLAUDE.md"
);
}
#[test]
fn discover_with_git_includes_status_snapshot() {
let _guard = env_lock();
ensure_valid_cwd();
let root = temp_dir();
fs::create_dir_all(&root).expect("root dir");
std::process::Command::new("git")
@@ -618,7 +644,7 @@ mod tests {
.current_dir(&root)
.status()
.expect("git init should run");
fs::write(root.join("CLAW.md"), "rules").expect("write instructions");
fs::write(root.join("CLAUDE.md"), "rules").expect("write instructions");
fs::write(root.join("tracked.txt"), "hello").expect("write tracked file");
let context =
@@ -626,16 +652,99 @@ mod tests {
let status = context.git_status.expect("git status should be present");
assert!(status.contains("## No commits yet on") || status.contains("## "));
assert!(status.contains("?? CLAW.md"));
assert!(status.contains("?? CLAUDE.md"));
assert!(status.contains("?? tracked.txt"));
assert!(context.git_diff.is_none());
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn discover_with_git_includes_recent_commits_and_renders_them() {
// given: a git repo with three commits and a current branch
let _guard = env_lock();
ensure_valid_cwd();
let root = temp_dir();
fs::create_dir_all(&root).expect("root dir");
std::process::Command::new("git")
.args(["init", "--quiet", "-b", "main"])
.current_dir(&root)
.status()
.expect("git init should run");
std::process::Command::new("git")
.args(["config", "user.email", "tests@example.com"])
.current_dir(&root)
.status()
.expect("git config email should run");
std::process::Command::new("git")
.args(["config", "user.name", "Runtime Prompt Tests"])
.current_dir(&root)
.status()
.expect("git config name should run");
for (file, message) in [
("a.txt", "first commit"),
("b.txt", "second commit"),
("c.txt", "third commit"),
] {
fs::write(root.join(file), "x\n").expect("write commit file");
std::process::Command::new("git")
.args(["add", file])
.current_dir(&root)
.status()
.expect("git add should run");
std::process::Command::new("git")
.args(["commit", "-m", message, "--quiet"])
.current_dir(&root)
.status()
.expect("git commit should run");
}
fs::write(root.join("d.txt"), "staged\n").expect("write staged file");
std::process::Command::new("git")
.args(["add", "d.txt"])
.current_dir(&root)
.status()
.expect("git add staged should run");
// when: discovering project context with git auto-include
let context =
ProjectContext::discover_with_git(&root, "2026-03-31").expect("context should load");
let rendered = SystemPromptBuilder::new()
.with_os("linux", "6.8")
.with_project_context(context.clone())
.render();
// then: branch, recent commits and staged files are present in context
let gc = context
.git_context
.as_ref()
.expect("git context should be present");
let commits: String = gc
.recent_commits
.iter()
.map(|c| c.subject.clone())
.collect::<Vec<_>>()
.join("\n");
assert!(commits.contains("first commit"));
assert!(commits.contains("second commit"));
assert!(commits.contains("third commit"));
assert_eq!(gc.recent_commits.len(), 3);
let status = context.git_status.as_deref().expect("status snapshot");
assert!(status.contains("## main"));
assert!(status.contains("A d.txt"));
assert!(rendered.contains("Recent commits (last 5):"));
assert!(rendered.contains("first commit"));
assert!(rendered.contains("Git status snapshot:"));
assert!(rendered.contains("## main"));
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn discover_with_git_includes_diff_snapshot_for_tracked_changes() {
let _guard = env_lock();
ensure_valid_cwd();
let root = temp_dir();
fs::create_dir_all(&root).expect("root dir");
std::process::Command::new("git")
@@ -677,10 +786,10 @@ mod tests {
}
#[test]
fn load_system_prompt_reads_claw_files_and_config() {
fn load_system_prompt_reads_claude_files_and_config() {
let root = temp_dir();
fs::create_dir_all(root.join(".claw")).expect("claw dir");
fs::write(root.join("CLAW.md"), "Project rules").expect("write instructions");
fs::write(root.join("CLAUDE.md"), "Project rules").expect("write instructions");
fs::write(
root.join(".claw").join("settings.json"),
r#"{"permissionMode":"acceptEdits"}"#,
@@ -688,6 +797,7 @@ mod tests {
.expect("write settings");
let _guard = env_lock();
ensure_valid_cwd();
let previous = std::env::current_dir().expect("cwd");
let original_home = std::env::var("HOME").ok();
let original_claw_home = std::env::var("CLAW_CONFIG_HOME").ok();
@@ -719,10 +829,10 @@ mod tests {
}
#[test]
fn renders_claw_code_style_sections_with_project_context() {
fn renders_claude_code_style_sections_with_project_context() {
let root = temp_dir();
fs::create_dir_all(root.join(".claw")).expect("claw dir");
fs::write(root.join("CLAW.md"), "Project rules").expect("write CLAW.md");
fs::write(root.join("CLAUDE.md"), "Project rules").expect("write CLAUDE.md");
fs::write(
root.join(".claw").join("settings.json"),
r#"{"permissionMode":"acceptEdits"}"#,
@@ -743,7 +853,7 @@ mod tests {
assert!(prompt.contains("# System"));
assert!(prompt.contains("# Project context"));
assert!(prompt.contains("# Claw instructions"));
assert!(prompt.contains("# Claude instructions"));
assert!(prompt.contains("Project rules"));
assert!(prompt.contains("permissionMode"));
assert!(prompt.contains(SYSTEM_PROMPT_DYNAMIC_BOUNDARY));
@@ -760,7 +870,7 @@ mod tests {
}
#[test]
fn discovers_dot_claw_instructions_markdown() {
fn discovers_dot_claude_instructions_markdown() {
let root = temp_dir();
let nested = root.join("apps").join("api");
fs::create_dir_all(nested.join(".claw")).expect("nested claw dir");
@@ -785,10 +895,10 @@ mod tests {
#[test]
fn renders_instruction_file_metadata() {
let rendered = render_instruction_files(&[ContextFile {
path: PathBuf::from("/tmp/project/CLAW.md"),
path: PathBuf::from("/tmp/project/CLAUDE.md"),
content: "Project rules".to_string(),
}]);
assert!(rendered.contains("# Claw instructions"));
assert!(rendered.contains("# Claude instructions"));
assert!(rendered.contains("scope: /tmp/project"));
assert!(rendered.contains("Project rules"));
}
+631
View File
@@ -0,0 +1,631 @@
#![allow(clippy::cast_possible_truncation, clippy::uninlined_format_args)]
//! Recovery recipes for common failure scenarios.
//!
//! Encodes known automatic recoveries for the six failure scenarios
//! listed in ROADMAP item 8, and enforces one automatic recovery
//! attempt before escalation. Each attempt is emitted as a structured
//! recovery event.
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use crate::worker_boot::WorkerFailureKind;
/// The six failure scenarios that have known recovery recipes.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FailureScenario {
TrustPromptUnresolved,
PromptMisdelivery,
StaleBranch,
CompileRedCrossCrate,
McpHandshakeFailure,
PartialPluginStartup,
ProviderFailure,
}
impl FailureScenario {
/// Returns all known failure scenarios.
#[must_use]
pub fn all() -> &'static [FailureScenario] {
&[
Self::TrustPromptUnresolved,
Self::PromptMisdelivery,
Self::StaleBranch,
Self::CompileRedCrossCrate,
Self::McpHandshakeFailure,
Self::PartialPluginStartup,
Self::ProviderFailure,
]
}
/// Map a `WorkerFailureKind` to the corresponding `FailureScenario`.
/// This is the bridge that lets recovery policy consume worker boot events.
#[must_use]
pub fn from_worker_failure_kind(kind: WorkerFailureKind) -> Self {
match kind {
WorkerFailureKind::TrustGate => Self::TrustPromptUnresolved,
WorkerFailureKind::PromptDelivery => Self::PromptMisdelivery,
WorkerFailureKind::Protocol => Self::McpHandshakeFailure,
WorkerFailureKind::Provider => Self::ProviderFailure,
}
}
}
impl std::fmt::Display for FailureScenario {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::TrustPromptUnresolved => write!(f, "trust_prompt_unresolved"),
Self::PromptMisdelivery => write!(f, "prompt_misdelivery"),
Self::StaleBranch => write!(f, "stale_branch"),
Self::CompileRedCrossCrate => write!(f, "compile_red_cross_crate"),
Self::McpHandshakeFailure => write!(f, "mcp_handshake_failure"),
Self::PartialPluginStartup => write!(f, "partial_plugin_startup"),
Self::ProviderFailure => write!(f, "provider_failure"),
}
}
}
/// Individual step that can be executed as part of a recovery recipe.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RecoveryStep {
AcceptTrustPrompt,
RedirectPromptToAgent,
RebaseBranch,
CleanBuild,
RetryMcpHandshake { timeout: u64 },
RestartPlugin { name: String },
RestartWorker,
EscalateToHuman { reason: String },
}
/// Policy governing what happens when automatic recovery is exhausted.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EscalationPolicy {
AlertHuman,
LogAndContinue,
Abort,
}
/// A recovery recipe encodes the sequence of steps to attempt for a
/// given failure scenario, along with the maximum number of automatic
/// attempts and the escalation policy.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RecoveryRecipe {
pub scenario: FailureScenario,
pub steps: Vec<RecoveryStep>,
pub max_attempts: u32,
pub escalation_policy: EscalationPolicy,
}
/// Outcome of a recovery attempt.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RecoveryResult {
Recovered {
steps_taken: u32,
},
PartialRecovery {
recovered: Vec<RecoveryStep>,
remaining: Vec<RecoveryStep>,
},
EscalationRequired {
reason: String,
},
}
/// Structured event emitted during recovery.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RecoveryEvent {
RecoveryAttempted {
scenario: FailureScenario,
recipe: RecoveryRecipe,
result: RecoveryResult,
},
RecoverySucceeded,
RecoveryFailed,
Escalated,
}
/// Minimal context for tracking recovery state and emitting events.
///
/// Holds per-scenario attempt counts, a structured event log, and an
/// optional simulation knob for controlling step outcomes during tests.
#[derive(Debug, Clone, Default)]
pub struct RecoveryContext {
attempts: HashMap<FailureScenario, u32>,
events: Vec<RecoveryEvent>,
/// Optional step index at which simulated execution fails.
/// `None` means all steps succeed.
fail_at_step: Option<usize>,
}
impl RecoveryContext {
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Configure a step index at which simulated execution will fail.
#[must_use]
pub fn with_fail_at_step(mut self, index: usize) -> Self {
self.fail_at_step = Some(index);
self
}
/// Returns the structured event log populated during recovery.
#[must_use]
pub fn events(&self) -> &[RecoveryEvent] {
&self.events
}
/// Returns the number of recovery attempts made for a scenario.
#[must_use]
pub fn attempt_count(&self, scenario: &FailureScenario) -> u32 {
self.attempts.get(scenario).copied().unwrap_or(0)
}
}
/// Returns the known recovery recipe for the given failure scenario.
#[must_use]
pub fn recipe_for(scenario: &FailureScenario) -> RecoveryRecipe {
match scenario {
FailureScenario::TrustPromptUnresolved => RecoveryRecipe {
scenario: *scenario,
steps: vec![RecoveryStep::AcceptTrustPrompt],
max_attempts: 1,
escalation_policy: EscalationPolicy::AlertHuman,
},
FailureScenario::PromptMisdelivery => RecoveryRecipe {
scenario: *scenario,
steps: vec![RecoveryStep::RedirectPromptToAgent],
max_attempts: 1,
escalation_policy: EscalationPolicy::AlertHuman,
},
FailureScenario::StaleBranch => RecoveryRecipe {
scenario: *scenario,
steps: vec![RecoveryStep::RebaseBranch, RecoveryStep::CleanBuild],
max_attempts: 1,
escalation_policy: EscalationPolicy::AlertHuman,
},
FailureScenario::CompileRedCrossCrate => RecoveryRecipe {
scenario: *scenario,
steps: vec![RecoveryStep::CleanBuild],
max_attempts: 1,
escalation_policy: EscalationPolicy::AlertHuman,
},
FailureScenario::McpHandshakeFailure => RecoveryRecipe {
scenario: *scenario,
steps: vec![RecoveryStep::RetryMcpHandshake { timeout: 5000 }],
max_attempts: 1,
escalation_policy: EscalationPolicy::Abort,
},
FailureScenario::PartialPluginStartup => RecoveryRecipe {
scenario: *scenario,
steps: vec![
RecoveryStep::RestartPlugin {
name: "stalled".to_string(),
},
RecoveryStep::RetryMcpHandshake { timeout: 3000 },
],
max_attempts: 1,
escalation_policy: EscalationPolicy::LogAndContinue,
},
FailureScenario::ProviderFailure => RecoveryRecipe {
scenario: *scenario,
steps: vec![RecoveryStep::RestartWorker],
max_attempts: 1,
escalation_policy: EscalationPolicy::AlertHuman,
},
}
}
/// Attempts automatic recovery for the given failure scenario.
///
/// Looks up the recipe, enforces the one-attempt-before-escalation
/// policy, simulates step execution (controlled by the context), and
/// emits structured [`RecoveryEvent`]s for every attempt.
pub fn attempt_recovery(scenario: &FailureScenario, ctx: &mut RecoveryContext) -> RecoveryResult {
let recipe = recipe_for(scenario);
let attempt_count = ctx.attempts.entry(*scenario).or_insert(0);
// Enforce one automatic recovery attempt before escalation.
if *attempt_count >= recipe.max_attempts {
let result = RecoveryResult::EscalationRequired {
reason: format!(
"max recovery attempts ({}) exceeded for {}",
recipe.max_attempts, scenario
),
};
ctx.events.push(RecoveryEvent::RecoveryAttempted {
scenario: *scenario,
recipe,
result: result.clone(),
});
ctx.events.push(RecoveryEvent::Escalated);
return result;
}
*attempt_count += 1;
// Execute steps, honoring the optional fail_at_step simulation.
let fail_index = ctx.fail_at_step;
let mut executed = Vec::new();
let mut failed = false;
for (i, step) in recipe.steps.iter().enumerate() {
if fail_index == Some(i) {
failed = true;
break;
}
executed.push(step.clone());
}
let result = if failed {
let remaining: Vec<RecoveryStep> = recipe.steps[executed.len()..].to_vec();
if executed.is_empty() {
RecoveryResult::EscalationRequired {
reason: format!("recovery failed at first step for {}", scenario),
}
} else {
RecoveryResult::PartialRecovery {
recovered: executed,
remaining,
}
}
} else {
RecoveryResult::Recovered {
steps_taken: recipe.steps.len() as u32,
}
};
// Emit the attempt as structured event data.
ctx.events.push(RecoveryEvent::RecoveryAttempted {
scenario: *scenario,
recipe,
result: result.clone(),
});
match &result {
RecoveryResult::Recovered { .. } => {
ctx.events.push(RecoveryEvent::RecoverySucceeded);
}
RecoveryResult::PartialRecovery { .. } => {
ctx.events.push(RecoveryEvent::RecoveryFailed);
}
RecoveryResult::EscalationRequired { .. } => {
ctx.events.push(RecoveryEvent::Escalated);
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn each_scenario_has_a_matching_recipe() {
// given
let scenarios = FailureScenario::all();
// when / then
for scenario in scenarios {
let recipe = recipe_for(scenario);
assert_eq!(
recipe.scenario, *scenario,
"recipe scenario should match requested scenario"
);
assert!(
!recipe.steps.is_empty(),
"recipe for {} should have at least one step",
scenario
);
assert!(
recipe.max_attempts >= 1,
"recipe for {} should allow at least one attempt",
scenario
);
}
}
#[test]
fn successful_recovery_returns_recovered_and_emits_events() {
// given
let mut ctx = RecoveryContext::new();
let scenario = FailureScenario::TrustPromptUnresolved;
// when
let result = attempt_recovery(&scenario, &mut ctx);
// then
assert_eq!(result, RecoveryResult::Recovered { steps_taken: 1 });
assert_eq!(ctx.events().len(), 2);
assert!(matches!(
&ctx.events()[0],
RecoveryEvent::RecoveryAttempted {
scenario: s,
result: r,
..
} if *s == FailureScenario::TrustPromptUnresolved
&& matches!(r, RecoveryResult::Recovered { steps_taken: 1 })
));
assert_eq!(ctx.events()[1], RecoveryEvent::RecoverySucceeded);
}
#[test]
fn escalation_after_max_attempts_exceeded() {
// given
let mut ctx = RecoveryContext::new();
let scenario = FailureScenario::PromptMisdelivery;
// when — first attempt succeeds
let first = attempt_recovery(&scenario, &mut ctx);
assert!(matches!(first, RecoveryResult::Recovered { .. }));
// when — second attempt should escalate
let second = attempt_recovery(&scenario, &mut ctx);
// then
assert!(
matches!(
&second,
RecoveryResult::EscalationRequired { reason }
if reason.contains("max recovery attempts")
),
"second attempt should require escalation, got: {second:?}"
);
assert_eq!(ctx.attempt_count(&scenario), 1);
assert!(ctx
.events()
.iter()
.any(|e| matches!(e, RecoveryEvent::Escalated)));
}
#[test]
fn partial_recovery_when_step_fails_midway() {
// given — PartialPluginStartup has two steps; fail at step index 1
let mut ctx = RecoveryContext::new().with_fail_at_step(1);
let scenario = FailureScenario::PartialPluginStartup;
// when
let result = attempt_recovery(&scenario, &mut ctx);
// then
match &result {
RecoveryResult::PartialRecovery {
recovered,
remaining,
} => {
assert_eq!(recovered.len(), 1, "one step should have succeeded");
assert_eq!(remaining.len(), 1, "one step should remain");
assert!(matches!(recovered[0], RecoveryStep::RestartPlugin { .. }));
assert!(matches!(
remaining[0],
RecoveryStep::RetryMcpHandshake { .. }
));
}
other => panic!("expected PartialRecovery, got {other:?}"),
}
assert!(ctx
.events()
.iter()
.any(|e| matches!(e, RecoveryEvent::RecoveryFailed)));
}
#[test]
fn first_step_failure_escalates_immediately() {
// given — fail at step index 0
let mut ctx = RecoveryContext::new().with_fail_at_step(0);
let scenario = FailureScenario::CompileRedCrossCrate;
// when
let result = attempt_recovery(&scenario, &mut ctx);
// then
assert!(
matches!(
&result,
RecoveryResult::EscalationRequired { reason }
if reason.contains("failed at first step")
),
"zero-step failure should escalate, got: {result:?}"
);
assert!(ctx
.events()
.iter()
.any(|e| matches!(e, RecoveryEvent::Escalated)));
}
#[test]
fn emitted_events_include_structured_attempt_data() {
// given
let mut ctx = RecoveryContext::new();
let scenario = FailureScenario::McpHandshakeFailure;
// when
let _ = attempt_recovery(&scenario, &mut ctx);
// then — verify the RecoveryAttempted event carries full context
let attempted = ctx
.events()
.iter()
.find(|e| matches!(e, RecoveryEvent::RecoveryAttempted { .. }))
.expect("should have emitted RecoveryAttempted event");
match attempted {
RecoveryEvent::RecoveryAttempted {
scenario: s,
recipe,
result,
} => {
assert_eq!(*s, scenario);
assert_eq!(recipe.scenario, scenario);
assert!(!recipe.steps.is_empty());
assert!(matches!(result, RecoveryResult::Recovered { .. }));
}
_ => unreachable!(),
}
// Verify the event is serializable as structured JSON
let json = serde_json::to_string(&ctx.events()[0])
.expect("recovery event should be serializable to JSON");
assert!(
json.contains("mcp_handshake_failure"),
"serialized event should contain scenario name"
);
}
#[test]
fn recovery_context_tracks_attempts_per_scenario() {
// given
let mut ctx = RecoveryContext::new();
// when
assert_eq!(ctx.attempt_count(&FailureScenario::StaleBranch), 0);
attempt_recovery(&FailureScenario::StaleBranch, &mut ctx);
// then
assert_eq!(ctx.attempt_count(&FailureScenario::StaleBranch), 1);
assert_eq!(ctx.attempt_count(&FailureScenario::PromptMisdelivery), 0);
}
#[test]
fn stale_branch_recipe_has_rebase_then_clean_build() {
// given
let recipe = recipe_for(&FailureScenario::StaleBranch);
// then
assert_eq!(recipe.steps.len(), 2);
assert_eq!(recipe.steps[0], RecoveryStep::RebaseBranch);
assert_eq!(recipe.steps[1], RecoveryStep::CleanBuild);
}
#[test]
fn partial_plugin_startup_recipe_has_restart_then_handshake() {
// given
let recipe = recipe_for(&FailureScenario::PartialPluginStartup);
// then
assert_eq!(recipe.steps.len(), 2);
assert!(matches!(
recipe.steps[0],
RecoveryStep::RestartPlugin { .. }
));
assert!(matches!(
recipe.steps[1],
RecoveryStep::RetryMcpHandshake { timeout: 3000 }
));
assert_eq!(recipe.escalation_policy, EscalationPolicy::LogAndContinue);
}
#[test]
fn failure_scenario_display_all_variants() {
// given
let cases = [
(
FailureScenario::TrustPromptUnresolved,
"trust_prompt_unresolved",
),
(FailureScenario::PromptMisdelivery, "prompt_misdelivery"),
(FailureScenario::StaleBranch, "stale_branch"),
(
FailureScenario::CompileRedCrossCrate,
"compile_red_cross_crate",
),
(
FailureScenario::McpHandshakeFailure,
"mcp_handshake_failure",
),
(
FailureScenario::PartialPluginStartup,
"partial_plugin_startup",
),
];
// when / then
for (scenario, expected) in &cases {
assert_eq!(scenario.to_string(), *expected);
}
}
#[test]
fn multi_step_success_reports_correct_steps_taken() {
// given — StaleBranch has 2 steps, no simulated failure
let mut ctx = RecoveryContext::new();
let scenario = FailureScenario::StaleBranch;
// when
let result = attempt_recovery(&scenario, &mut ctx);
// then
assert_eq!(result, RecoveryResult::Recovered { steps_taken: 2 });
}
#[test]
fn mcp_handshake_recipe_uses_abort_escalation_policy() {
// given
let recipe = recipe_for(&FailureScenario::McpHandshakeFailure);
// then
assert_eq!(recipe.escalation_policy, EscalationPolicy::Abort);
assert_eq!(recipe.max_attempts, 1);
}
#[test]
fn worker_failure_kind_maps_to_failure_scenario() {
// given / when / then — verify the bridge is correct
assert_eq!(
FailureScenario::from_worker_failure_kind(WorkerFailureKind::TrustGate),
FailureScenario::TrustPromptUnresolved,
);
assert_eq!(
FailureScenario::from_worker_failure_kind(WorkerFailureKind::PromptDelivery),
FailureScenario::PromptMisdelivery,
);
assert_eq!(
FailureScenario::from_worker_failure_kind(WorkerFailureKind::Protocol),
FailureScenario::McpHandshakeFailure,
);
assert_eq!(
FailureScenario::from_worker_failure_kind(WorkerFailureKind::Provider),
FailureScenario::ProviderFailure,
);
}
#[test]
fn provider_failure_recipe_uses_restart_worker_step() {
// given
let recipe = recipe_for(&FailureScenario::ProviderFailure);
// then
assert_eq!(recipe.scenario, FailureScenario::ProviderFailure);
assert!(recipe.steps.contains(&RecoveryStep::RestartWorker));
assert_eq!(recipe.escalation_policy, EscalationPolicy::AlertHuman);
assert_eq!(recipe.max_attempts, 1);
}
#[test]
fn provider_failure_recovery_attempt_succeeds_then_escalates() {
// given
let mut ctx = RecoveryContext::new();
let scenario = FailureScenario::ProviderFailure;
// when — first attempt
let first = attempt_recovery(&scenario, &mut ctx);
assert!(matches!(first, RecoveryResult::Recovered { .. }));
// when — second attempt should escalate (max_attempts=1)
let second = attempt_recovery(&scenario, &mut ctx);
assert!(matches!(second, RecoveryResult::EscalationRequired { .. }));
assert!(ctx
.events()
.iter()
.any(|e| matches!(e, RecoveryEvent::Escalated)));
}
}
+7 -7
View File
@@ -72,9 +72,9 @@ impl RemoteSessionContext {
#[must_use]
pub fn from_env_map(env_map: &BTreeMap<String, String>) -> Self {
Self {
enabled: env_truthy(env_map.get("CLAW_CODE_REMOTE")),
enabled: env_truthy(env_map.get("CLAUDE_CODE_REMOTE")),
session_id: env_map
.get("CLAW_CODE_REMOTE_SESSION_ID")
.get("CLAUDE_CODE_REMOTE_SESSION_ID")
.filter(|value| !value.is_empty())
.cloned(),
base_url: env_map
@@ -272,9 +272,9 @@ mod tests {
#[test]
fn remote_context_reads_env_state() {
let env = BTreeMap::from([
("CLAW_CODE_REMOTE".to_string(), "true".to_string()),
("CLAUDE_CODE_REMOTE".to_string(), "true".to_string()),
(
"CLAW_CODE_REMOTE_SESSION_ID".to_string(),
"CLAUDE_CODE_REMOTE_SESSION_ID".to_string(),
"session-123".to_string(),
),
(
@@ -291,7 +291,7 @@ mod tests {
#[test]
fn bootstrap_fails_open_when_token_or_session_is_missing() {
let env = BTreeMap::from([
("CLAW_CODE_REMOTE".to_string(), "1".to_string()),
("CLAUDE_CODE_REMOTE".to_string(), "1".to_string()),
("CCR_UPSTREAM_PROXY_ENABLED".to_string(), "true".to_string()),
]);
let bootstrap = UpstreamProxyBootstrap::from_env_map(&env);
@@ -307,10 +307,10 @@ mod tests {
fs::write(&token_path, "secret-token\n").expect("write token");
let env = BTreeMap::from([
("CLAW_CODE_REMOTE".to_string(), "1".to_string()),
("CLAUDE_CODE_REMOTE".to_string(), "1".to_string()),
("CCR_UPSTREAM_PROXY_ENABLED".to_string(), "true".to_string()),
(
"CLAW_CODE_REMOTE_SESSION_ID".to_string(),
"CLAUDE_CODE_REMOTE_SESSION_ID".to_string(),
"session-123".to_string(),
),
(
+25 -16
View File
@@ -107,23 +107,11 @@ impl SandboxConfig {
#[must_use]
pub fn detect_container_environment() -> ContainerEnvironment {
let proc_1_cgroup = if cfg!(target_os = "linux") {
fs::read_to_string("/proc/1/cgroup").ok()
} else {
None
};
let proc_1_cgroup = fs::read_to_string("/proc/1/cgroup").ok();
detect_container_environment_from(SandboxDetectionInputs {
env_pairs: env::vars().collect(),
dockerenv_exists: if cfg!(target_os = "linux") {
Path::new("/.dockerenv").exists()
} else {
false
},
containerenv_exists: if cfg!(target_os = "linux") {
Path::new("/run/.containerenv").exists()
} else {
false
},
dockerenv_exists: Path::new("/.dockerenv").exists(),
containerenv_exists: Path::new("/run/.containerenv").exists(),
proc_1_cgroup: proc_1_cgroup.as_deref(),
})
}
@@ -173,7 +161,7 @@ pub fn resolve_sandbox_status(config: &SandboxConfig, cwd: &Path) -> SandboxStat
#[must_use]
pub fn resolve_sandbox_status_for_request(request: &SandboxRequest, cwd: &Path) -> SandboxStatus {
let container = detect_container_environment();
let namespace_supported = cfg!(target_os = "linux") && command_exists("unshare");
let namespace_supported = cfg!(target_os = "linux") && unshare_user_namespace_works();
let network_supported = namespace_supported;
let filesystem_active =
request.enabled && request.filesystem_mode != FilesystemIsolationMode::Off;
@@ -294,6 +282,27 @@ fn command_exists(command: &str) -> bool {
.is_some_and(|paths| env::split_paths(&paths).any(|path| path.join(command).exists()))
}
/// Check whether `unshare --user` actually works on this system.
/// On some CI environments (e.g. GitHub Actions), the binary exists but
/// user namespaces are restricted, causing silent failures.
fn unshare_user_namespace_works() -> bool {
use std::sync::OnceLock;
static RESULT: OnceLock<bool> = OnceLock::new();
*RESULT.get_or_init(|| {
if !command_exists("unshare") {
return false;
}
std::process::Command::new("unshare")
.args(["--user", "--map-root-user", "true"])
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
})
}
#[cfg(test)]
mod tests {
use super::{
File diff suppressed because it is too large Load Diff
+873
View File
@@ -0,0 +1,873 @@
#![allow(dead_code)]
use std::env;
use std::fmt::{Display, Formatter};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::UNIX_EPOCH;
use crate::session::{Session, SessionError};
/// Per-worktree session store that namespaces on-disk session files by
/// workspace fingerprint so that parallel `opencode serve` instances never
/// collide.
///
/// Create via [`SessionStore::from_cwd`] (derives the store path from the
/// server's working directory) or [`SessionStore::from_data_dir`] (honours an
/// explicit `--data-dir` flag). Both constructors produce a directory layout
/// of `<data_dir>/sessions/<workspace_hash>/` where `<workspace_hash>` is a
/// stable hex digest of the canonical workspace root.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionStore {
/// Resolved root of the session namespace, e.g.
/// `/home/user/project/.claw/sessions/a1b2c3d4e5f60718/`.
sessions_root: PathBuf,
/// The canonical workspace path that was fingerprinted.
workspace_root: PathBuf,
}
impl SessionStore {
/// Build a store from the server's current working directory.
///
/// The on-disk layout becomes `<cwd>/.claw/sessions/<workspace_hash>/`.
pub fn from_cwd(cwd: impl AsRef<Path>) -> Result<Self, SessionControlError> {
let cwd = cwd.as_ref();
let sessions_root = cwd
.join(".claw")
.join("sessions")
.join(workspace_fingerprint(cwd));
fs::create_dir_all(&sessions_root)?;
Ok(Self {
sessions_root,
workspace_root: cwd.to_path_buf(),
})
}
/// Build a store from an explicit `--data-dir` flag.
///
/// The on-disk layout becomes `<data_dir>/sessions/<workspace_hash>/`
/// where `<workspace_hash>` is derived from `workspace_root`.
pub fn from_data_dir(
data_dir: impl AsRef<Path>,
workspace_root: impl AsRef<Path>,
) -> Result<Self, SessionControlError> {
let workspace_root = workspace_root.as_ref();
let sessions_root = data_dir
.as_ref()
.join("sessions")
.join(workspace_fingerprint(workspace_root));
fs::create_dir_all(&sessions_root)?;
Ok(Self {
sessions_root,
workspace_root: workspace_root.to_path_buf(),
})
}
/// The fully resolved sessions directory for this namespace.
#[must_use]
pub fn sessions_dir(&self) -> &Path {
&self.sessions_root
}
/// The workspace root this store is bound to.
#[must_use]
pub fn workspace_root(&self) -> &Path {
&self.workspace_root
}
pub fn create_handle(&self, session_id: &str) -> SessionHandle {
let id = session_id.to_string();
let path = self
.sessions_root
.join(format!("{id}.{PRIMARY_SESSION_EXTENSION}"));
SessionHandle { id, path }
}
pub fn resolve_reference(&self, reference: &str) -> Result<SessionHandle, SessionControlError> {
if is_session_reference_alias(reference) {
let latest = self.latest_session()?;
return Ok(SessionHandle {
id: latest.id,
path: latest.path,
});
}
let direct = PathBuf::from(reference);
let candidate = if direct.is_absolute() {
direct.clone()
} else {
self.workspace_root.join(&direct)
};
let looks_like_path = direct.extension().is_some() || direct.components().count() > 1;
let path = if candidate.exists() {
candidate
} else if looks_like_path {
return Err(SessionControlError::Format(
format_missing_session_reference(reference),
));
} else {
self.resolve_managed_path(reference)?
};
Ok(SessionHandle {
id: session_id_from_path(&path).unwrap_or_else(|| reference.to_string()),
path,
})
}
pub fn resolve_managed_path(&self, session_id: &str) -> Result<PathBuf, SessionControlError> {
for extension in [PRIMARY_SESSION_EXTENSION, LEGACY_SESSION_EXTENSION] {
let path = self.sessions_root.join(format!("{session_id}.{extension}"));
if path.exists() {
return Ok(path);
}
}
Err(SessionControlError::Format(
format_missing_session_reference(session_id),
))
}
pub fn list_sessions(&self) -> Result<Vec<ManagedSessionSummary>, SessionControlError> {
let mut sessions = Vec::new();
let read_result = fs::read_dir(&self.sessions_root);
let entries = match read_result {
Ok(entries) => entries,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(sessions),
Err(err) => return Err(err.into()),
};
for entry in entries {
let entry = entry?;
let path = entry.path();
if !is_managed_session_file(&path) {
continue;
}
let metadata = entry.metadata()?;
let modified_epoch_millis = metadata
.modified()
.ok()
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
.map(|duration| duration.as_millis())
.unwrap_or_default();
let (id, message_count, parent_session_id, branch_name) =
match Session::load_from_path(&path) {
Ok(session) => {
let parent_session_id = session
.fork
.as_ref()
.map(|fork| fork.parent_session_id.clone());
let branch_name = session
.fork
.as_ref()
.and_then(|fork| fork.branch_name.clone());
(
session.session_id,
session.messages.len(),
parent_session_id,
branch_name,
)
}
Err(_) => (
path.file_stem()
.and_then(|value| value.to_str())
.unwrap_or("unknown")
.to_string(),
0,
None,
None,
),
};
sessions.push(ManagedSessionSummary {
id,
path,
modified_epoch_millis,
message_count,
parent_session_id,
branch_name,
});
}
sessions.sort_by(|left, right| {
right
.modified_epoch_millis
.cmp(&left.modified_epoch_millis)
.then_with(|| right.id.cmp(&left.id))
});
Ok(sessions)
}
pub fn latest_session(&self) -> Result<ManagedSessionSummary, SessionControlError> {
self.list_sessions()?
.into_iter()
.next()
.ok_or_else(|| SessionControlError::Format(format_no_managed_sessions()))
}
pub fn load_session(
&self,
reference: &str,
) -> Result<LoadedManagedSession, SessionControlError> {
let handle = self.resolve_reference(reference)?;
let session = Session::load_from_path(&handle.path)?;
Ok(LoadedManagedSession {
handle: SessionHandle {
id: session.session_id.clone(),
path: handle.path,
},
session,
})
}
pub fn fork_session(
&self,
session: &Session,
branch_name: Option<String>,
) -> Result<ForkedManagedSession, SessionControlError> {
let parent_session_id = session.session_id.clone();
let forked = session.fork(branch_name);
let handle = self.create_handle(&forked.session_id);
let branch_name = forked
.fork
.as_ref()
.and_then(|fork| fork.branch_name.clone());
let forked = forked.with_persistence_path(handle.path.clone());
forked.save_to_path(&handle.path)?;
Ok(ForkedManagedSession {
parent_session_id,
handle,
session: forked,
branch_name,
})
}
}
/// Stable hex fingerprint of a workspace path.
///
/// Uses FNV-1a (64-bit) to produce a 16-char hex string that partitions the
/// on-disk session directory per workspace root.
#[must_use]
pub fn workspace_fingerprint(workspace_root: &Path) -> String {
let input = workspace_root.to_string_lossy();
let mut hash = 0xcbf2_9ce4_8422_2325_u64;
for byte in input.as_bytes() {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x0100_0000_01b3);
}
format!("{hash:016x}")
}
pub const PRIMARY_SESSION_EXTENSION: &str = "jsonl";
pub const LEGACY_SESSION_EXTENSION: &str = "json";
pub const LATEST_SESSION_REFERENCE: &str = "latest";
const SESSION_REFERENCE_ALIASES: &[&str] = &[LATEST_SESSION_REFERENCE, "last", "recent"];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionHandle {
pub id: String,
pub path: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ManagedSessionSummary {
pub id: String,
pub path: PathBuf,
pub modified_epoch_millis: u128,
pub message_count: usize,
pub parent_session_id: Option<String>,
pub branch_name: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LoadedManagedSession {
pub handle: SessionHandle,
pub session: Session,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ForkedManagedSession {
pub parent_session_id: String,
pub handle: SessionHandle,
pub session: Session,
pub branch_name: Option<String>,
}
#[derive(Debug)]
pub enum SessionControlError {
Io(std::io::Error),
Session(SessionError),
Format(String),
}
impl Display for SessionControlError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(error) => write!(f, "{error}"),
Self::Session(error) => write!(f, "{error}"),
Self::Format(error) => write!(f, "{error}"),
}
}
}
impl std::error::Error for SessionControlError {}
impl From<std::io::Error> for SessionControlError {
fn from(value: std::io::Error) -> Self {
Self::Io(value)
}
}
impl From<SessionError> for SessionControlError {
fn from(value: SessionError) -> Self {
Self::Session(value)
}
}
pub fn sessions_dir() -> Result<PathBuf, SessionControlError> {
managed_sessions_dir_for(env::current_dir()?)
}
pub fn managed_sessions_dir_for(
base_dir: impl AsRef<Path>,
) -> Result<PathBuf, SessionControlError> {
let path = base_dir.as_ref().join(".claw").join("sessions");
fs::create_dir_all(&path)?;
Ok(path)
}
pub fn create_managed_session_handle(
session_id: &str,
) -> Result<SessionHandle, SessionControlError> {
create_managed_session_handle_for(env::current_dir()?, session_id)
}
pub fn create_managed_session_handle_for(
base_dir: impl AsRef<Path>,
session_id: &str,
) -> Result<SessionHandle, SessionControlError> {
let id = session_id.to_string();
let path =
managed_sessions_dir_for(base_dir)?.join(format!("{id}.{PRIMARY_SESSION_EXTENSION}"));
Ok(SessionHandle { id, path })
}
pub fn resolve_session_reference(reference: &str) -> Result<SessionHandle, SessionControlError> {
resolve_session_reference_for(env::current_dir()?, reference)
}
pub fn resolve_session_reference_for(
base_dir: impl AsRef<Path>,
reference: &str,
) -> Result<SessionHandle, SessionControlError> {
let base_dir = base_dir.as_ref();
if is_session_reference_alias(reference) {
let latest = latest_managed_session_for(base_dir)?;
return Ok(SessionHandle {
id: latest.id,
path: latest.path,
});
}
let direct = PathBuf::from(reference);
let candidate = if direct.is_absolute() {
direct.clone()
} else {
base_dir.join(&direct)
};
let looks_like_path = direct.extension().is_some() || direct.components().count() > 1;
let path = if candidate.exists() {
candidate
} else if looks_like_path {
return Err(SessionControlError::Format(
format_missing_session_reference(reference),
));
} else {
resolve_managed_session_path_for(base_dir, reference)?
};
Ok(SessionHandle {
id: session_id_from_path(&path).unwrap_or_else(|| reference.to_string()),
path,
})
}
pub fn resolve_managed_session_path(session_id: &str) -> Result<PathBuf, SessionControlError> {
resolve_managed_session_path_for(env::current_dir()?, session_id)
}
pub fn resolve_managed_session_path_for(
base_dir: impl AsRef<Path>,
session_id: &str,
) -> Result<PathBuf, SessionControlError> {
let directory = managed_sessions_dir_for(base_dir)?;
for extension in [PRIMARY_SESSION_EXTENSION, LEGACY_SESSION_EXTENSION] {
let path = directory.join(format!("{session_id}.{extension}"));
if path.exists() {
return Ok(path);
}
}
Err(SessionControlError::Format(
format_missing_session_reference(session_id),
))
}
#[must_use]
pub fn is_managed_session_file(path: &Path) -> bool {
path.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|extension| {
extension == PRIMARY_SESSION_EXTENSION || extension == LEGACY_SESSION_EXTENSION
})
}
pub fn list_managed_sessions() -> Result<Vec<ManagedSessionSummary>, SessionControlError> {
list_managed_sessions_for(env::current_dir()?)
}
pub fn list_managed_sessions_for(
base_dir: impl AsRef<Path>,
) -> Result<Vec<ManagedSessionSummary>, SessionControlError> {
let mut sessions = Vec::new();
for entry in fs::read_dir(managed_sessions_dir_for(base_dir)?)? {
let entry = entry?;
let path = entry.path();
if !is_managed_session_file(&path) {
continue;
}
let metadata = entry.metadata()?;
let modified_epoch_millis = metadata
.modified()
.ok()
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
.map(|duration| duration.as_millis())
.unwrap_or_default();
let (id, message_count, parent_session_id, branch_name) =
match Session::load_from_path(&path) {
Ok(session) => {
let parent_session_id = session
.fork
.as_ref()
.map(|fork| fork.parent_session_id.clone());
let branch_name = session
.fork
.as_ref()
.and_then(|fork| fork.branch_name.clone());
(
session.session_id,
session.messages.len(),
parent_session_id,
branch_name,
)
}
Err(_) => (
path.file_stem()
.and_then(|value| value.to_str())
.unwrap_or("unknown")
.to_string(),
0,
None,
None,
),
};
sessions.push(ManagedSessionSummary {
id,
path,
modified_epoch_millis,
message_count,
parent_session_id,
branch_name,
});
}
sessions.sort_by(|left, right| {
right
.modified_epoch_millis
.cmp(&left.modified_epoch_millis)
.then_with(|| right.id.cmp(&left.id))
});
Ok(sessions)
}
pub fn latest_managed_session() -> Result<ManagedSessionSummary, SessionControlError> {
latest_managed_session_for(env::current_dir()?)
}
pub fn latest_managed_session_for(
base_dir: impl AsRef<Path>,
) -> Result<ManagedSessionSummary, SessionControlError> {
list_managed_sessions_for(base_dir)?
.into_iter()
.next()
.ok_or_else(|| SessionControlError::Format(format_no_managed_sessions()))
}
pub fn load_managed_session(reference: &str) -> Result<LoadedManagedSession, SessionControlError> {
load_managed_session_for(env::current_dir()?, reference)
}
pub fn load_managed_session_for(
base_dir: impl AsRef<Path>,
reference: &str,
) -> Result<LoadedManagedSession, SessionControlError> {
let handle = resolve_session_reference_for(base_dir, reference)?;
let session = Session::load_from_path(&handle.path)?;
Ok(LoadedManagedSession {
handle: SessionHandle {
id: session.session_id.clone(),
path: handle.path,
},
session,
})
}
pub fn fork_managed_session(
session: &Session,
branch_name: Option<String>,
) -> Result<ForkedManagedSession, SessionControlError> {
fork_managed_session_for(env::current_dir()?, session, branch_name)
}
pub fn fork_managed_session_for(
base_dir: impl AsRef<Path>,
session: &Session,
branch_name: Option<String>,
) -> Result<ForkedManagedSession, SessionControlError> {
let parent_session_id = session.session_id.clone();
let forked = session.fork(branch_name);
let handle = create_managed_session_handle_for(base_dir, &forked.session_id)?;
let branch_name = forked
.fork
.as_ref()
.and_then(|fork| fork.branch_name.clone());
let forked = forked.with_persistence_path(handle.path.clone());
forked.save_to_path(&handle.path)?;
Ok(ForkedManagedSession {
parent_session_id,
handle,
session: forked,
branch_name,
})
}
#[must_use]
pub fn is_session_reference_alias(reference: &str) -> bool {
SESSION_REFERENCE_ALIASES
.iter()
.any(|alias| reference.eq_ignore_ascii_case(alias))
}
fn session_id_from_path(path: &Path) -> Option<String> {
path.file_name()
.and_then(|value| value.to_str())
.and_then(|name| {
name.strip_suffix(&format!(".{PRIMARY_SESSION_EXTENSION}"))
.or_else(|| name.strip_suffix(&format!(".{LEGACY_SESSION_EXTENSION}")))
})
.map(ToOwned::to_owned)
}
fn format_missing_session_reference(reference: &str) -> String {
format!(
"session not found: {reference}\nHint: managed sessions live in .claw/sessions/. Try `{LATEST_SESSION_REFERENCE}` for the most recent session or `/session list` in the REPL."
)
}
fn format_no_managed_sessions() -> String {
format!(
"no managed sessions found in .claw/sessions/\nStart `claw` to create a session, then rerun with `--resume {LATEST_SESSION_REFERENCE}`."
)
}
#[cfg(test)]
mod tests {
use super::{
create_managed_session_handle_for, fork_managed_session_for, is_session_reference_alias,
list_managed_sessions_for, load_managed_session_for, resolve_session_reference_for,
workspace_fingerprint, ManagedSessionSummary, SessionStore, LATEST_SESSION_REFERENCE,
};
use crate::session::Session;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_dir() -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time should be after epoch")
.as_nanos();
std::env::temp_dir().join(format!("runtime-session-control-{nanos}"))
}
fn persist_session(root: &Path, text: &str) -> Session {
let mut session = Session::new();
session
.push_user_text(text)
.expect("session message should save");
let handle = create_managed_session_handle_for(root, &session.session_id)
.expect("managed session handle should build");
let session = session.with_persistence_path(handle.path.clone());
session
.save_to_path(&handle.path)
.expect("session should persist");
session
}
fn wait_for_next_millisecond() {
let start = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time should be after epoch")
.as_millis();
while SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time should be after epoch")
.as_millis()
<= start
{}
}
fn summary_by_id<'a>(
summaries: &'a [ManagedSessionSummary],
id: &str,
) -> &'a ManagedSessionSummary {
summaries
.iter()
.find(|summary| summary.id == id)
.expect("session summary should exist")
}
#[test]
fn creates_and_lists_managed_sessions() {
// given
let root = temp_dir();
fs::create_dir_all(&root).expect("root dir should exist");
let older = persist_session(&root, "older session");
wait_for_next_millisecond();
let newer = persist_session(&root, "newer session");
// when
let sessions = list_managed_sessions_for(&root).expect("managed sessions should list");
// then
assert_eq!(sessions.len(), 2);
assert_eq!(sessions[0].id, newer.session_id);
assert_eq!(summary_by_id(&sessions, &older.session_id).message_count, 1);
assert_eq!(summary_by_id(&sessions, &newer.session_id).message_count, 1);
fs::remove_dir_all(root).expect("temp dir should clean up");
}
#[test]
fn resolves_latest_alias_and_loads_session_from_workspace_root() {
// given
let root = temp_dir();
fs::create_dir_all(&root).expect("root dir should exist");
let older = persist_session(&root, "older session");
wait_for_next_millisecond();
let newer = persist_session(&root, "newer session");
// when
let handle = resolve_session_reference_for(&root, LATEST_SESSION_REFERENCE)
.expect("latest alias should resolve");
let loaded = load_managed_session_for(&root, "recent")
.expect("recent alias should load the latest session");
// then
assert_eq!(handle.id, newer.session_id);
assert_eq!(loaded.handle.id, newer.session_id);
assert_eq!(loaded.session.messages.len(), 1);
assert_ne!(loaded.handle.id, older.session_id);
assert!(is_session_reference_alias("last"));
fs::remove_dir_all(root).expect("temp dir should clean up");
}
#[test]
fn forks_session_into_managed_storage_with_lineage() {
// given
let root = temp_dir();
fs::create_dir_all(&root).expect("root dir should exist");
let source = persist_session(&root, "parent session");
// when
let forked = fork_managed_session_for(&root, &source, Some("incident-review".to_string()))
.expect("session should fork");
let sessions = list_managed_sessions_for(&root).expect("managed sessions should list");
let summary = summary_by_id(&sessions, &forked.handle.id);
// then
assert_eq!(forked.parent_session_id, source.session_id);
assert_eq!(forked.branch_name.as_deref(), Some("incident-review"));
assert_eq!(
summary.parent_session_id.as_deref(),
Some(source.session_id.as_str())
);
assert_eq!(summary.branch_name.as_deref(), Some("incident-review"));
assert_eq!(
forked.session.persistence_path(),
Some(forked.handle.path.as_path())
);
fs::remove_dir_all(root).expect("temp dir should clean up");
}
// ------------------------------------------------------------------
// Per-worktree session isolation (SessionStore) tests
// ------------------------------------------------------------------
fn persist_session_via_store(store: &SessionStore, text: &str) -> Session {
let mut session = Session::new();
session
.push_user_text(text)
.expect("session message should save");
let handle = store.create_handle(&session.session_id);
let session = session.with_persistence_path(handle.path.clone());
session
.save_to_path(&handle.path)
.expect("session should persist");
session
}
#[test]
fn workspace_fingerprint_is_deterministic_and_differs_per_path() {
// given
let path_a = Path::new("/tmp/worktree-alpha");
let path_b = Path::new("/tmp/worktree-beta");
// when
let fp_a1 = workspace_fingerprint(path_a);
let fp_a2 = workspace_fingerprint(path_a);
let fp_b = workspace_fingerprint(path_b);
// then
assert_eq!(fp_a1, fp_a2, "same path must produce the same fingerprint");
assert_ne!(
fp_a1, fp_b,
"different paths must produce different fingerprints"
);
assert_eq!(fp_a1.len(), 16, "fingerprint must be a 16-char hex string");
}
#[test]
fn session_store_from_cwd_isolates_sessions_by_workspace() {
// given
let base = temp_dir();
let workspace_a = base.join("repo-alpha");
let workspace_b = base.join("repo-beta");
fs::create_dir_all(&workspace_a).expect("workspace a should exist");
fs::create_dir_all(&workspace_b).expect("workspace b should exist");
let store_a = SessionStore::from_cwd(&workspace_a).expect("store a should build");
let store_b = SessionStore::from_cwd(&workspace_b).expect("store b should build");
// when
let session_a = persist_session_via_store(&store_a, "alpha work");
let _session_b = persist_session_via_store(&store_b, "beta work");
// then — each store only sees its own sessions
let list_a = store_a.list_sessions().expect("list a");
let list_b = store_b.list_sessions().expect("list b");
assert_eq!(list_a.len(), 1, "store a should see exactly one session");
assert_eq!(list_b.len(), 1, "store b should see exactly one session");
assert_eq!(list_a[0].id, session_a.session_id);
assert_ne!(
store_a.sessions_dir(),
store_b.sessions_dir(),
"session directories must differ across workspaces"
);
fs::remove_dir_all(base).expect("temp dir should clean up");
}
#[test]
fn session_store_from_data_dir_namespaces_by_workspace() {
// given
let base = temp_dir();
let data_dir = base.join("global-data");
let workspace_a = PathBuf::from("/tmp/project-one");
let workspace_b = PathBuf::from("/tmp/project-two");
fs::create_dir_all(&data_dir).expect("data dir should exist");
let store_a =
SessionStore::from_data_dir(&data_dir, &workspace_a).expect("store a should build");
let store_b =
SessionStore::from_data_dir(&data_dir, &workspace_b).expect("store b should build");
// when
persist_session_via_store(&store_a, "work in project-one");
persist_session_via_store(&store_b, "work in project-two");
// then
assert_ne!(
store_a.sessions_dir(),
store_b.sessions_dir(),
"data-dir stores must namespace by workspace"
);
assert_eq!(store_a.list_sessions().expect("list a").len(), 1);
assert_eq!(store_b.list_sessions().expect("list b").len(), 1);
assert_eq!(store_a.workspace_root(), workspace_a.as_path());
assert_eq!(store_b.workspace_root(), workspace_b.as_path());
fs::remove_dir_all(base).expect("temp dir should clean up");
}
#[test]
fn session_store_create_and_load_round_trip() {
// given
let base = temp_dir();
fs::create_dir_all(&base).expect("base dir should exist");
let store = SessionStore::from_cwd(&base).expect("store should build");
let session = persist_session_via_store(&store, "round-trip message");
// when
let loaded = store
.load_session(&session.session_id)
.expect("session should load via store");
// then
assert_eq!(loaded.handle.id, session.session_id);
assert_eq!(loaded.session.messages.len(), 1);
fs::remove_dir_all(base).expect("temp dir should clean up");
}
#[test]
fn session_store_latest_and_resolve_reference() {
// given
let base = temp_dir();
fs::create_dir_all(&base).expect("base dir should exist");
let store = SessionStore::from_cwd(&base).expect("store should build");
let _older = persist_session_via_store(&store, "older");
wait_for_next_millisecond();
let newer = persist_session_via_store(&store, "newer");
// when
let latest = store.latest_session().expect("latest should resolve");
let handle = store
.resolve_reference("latest")
.expect("latest alias should resolve");
// then
assert_eq!(latest.id, newer.session_id);
assert_eq!(handle.id, newer.session_id);
fs::remove_dir_all(base).expect("temp dir should clean up");
}
#[test]
fn session_store_fork_stays_in_same_namespace() {
// given
let base = temp_dir();
fs::create_dir_all(&base).expect("base dir should exist");
let store = SessionStore::from_cwd(&base).expect("store should build");
let source = persist_session_via_store(&store, "parent work");
// when
let forked = store
.fork_session(&source, Some("bugfix".to_string()))
.expect("fork should succeed");
let sessions = store.list_sessions().expect("list sessions");
// then
assert_eq!(
sessions.len(),
2,
"forked session must land in the same namespace"
);
assert_eq!(forked.parent_session_id, source.session_id);
assert_eq!(forked.branch_name.as_deref(), Some("bugfix"));
assert!(
forked.handle.path.starts_with(store.sessions_dir()),
"forked session path must be inside the store namespace"
);
fs::remove_dir_all(base).expect("temp dir should clean up");
}
}
+31 -1
View File
@@ -80,7 +80,11 @@ impl IncrementalSseParser {
}
fn take_event(&mut self) -> Option<SseEvent> {
if self.data_lines.is_empty() && self.event_name.is_none() && self.id.is_none() && self.retry.is_none() {
if self.data_lines.is_empty()
&& self.event_name.is_none()
&& self.id.is_none()
&& self.retry.is_none()
{
return None;
}
@@ -102,8 +106,13 @@ mod tests {
#[test]
fn parses_streaming_events() {
// given
let mut parser = IncrementalSseParser::new();
// when
let first = parser.push_chunk("event: message\ndata: hel");
// then
assert!(first.is_empty());
let second = parser.push_chunk("lo\n\nid: 1\ndata: world\n\n");
@@ -125,4 +134,25 @@ mod tests {
]
);
}
#[test]
fn finish_flushes_a_trailing_event_without_separator() {
// given
let mut parser = IncrementalSseParser::new();
parser.push_chunk("event: message\ndata: trailing");
// when
let events = parser.finish();
// then
assert_eq!(
events,
vec![SseEvent {
event: Some("message".to_string()),
data: "trailing".to_string(),
id: None,
retry: None,
}]
);
}
}
+429
View File
@@ -0,0 +1,429 @@
#![allow(clippy::must_use_candidate)]
use std::path::Path;
use std::process::Command;
/// Outcome of comparing the worktree HEAD against the expected base commit.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BaseCommitState {
/// HEAD matches the expected base commit.
Matches,
/// HEAD has diverged from the expected base.
Diverged { expected: String, actual: String },
/// No expected base was supplied (neither flag nor file).
NoExpectedBase,
/// The working directory is not inside a git repository.
NotAGitRepo,
}
/// Where the expected base commit originated from.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BaseCommitSource {
Flag(String),
File(String),
}
/// Read the `.claw-base` file from the given directory and return the trimmed
/// commit hash, or `None` when the file is absent or empty.
pub fn read_claw_base_file(cwd: &Path) -> Option<String> {
let path = cwd.join(".claw-base");
let content = std::fs::read_to_string(path).ok()?;
let trimmed = content.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
/// Resolve the expected base commit: prefer the `--base-commit` flag value,
/// fall back to reading `.claw-base` from `cwd`.
pub fn resolve_expected_base(flag_value: Option<&str>, cwd: &Path) -> Option<BaseCommitSource> {
if let Some(value) = flag_value {
let trimmed = value.trim();
if !trimmed.is_empty() {
return Some(BaseCommitSource::Flag(trimmed.to_string()));
}
}
read_claw_base_file(cwd).map(BaseCommitSource::File)
}
/// Verify that the worktree HEAD matches `expected_base`.
///
/// Returns [`BaseCommitState::NoExpectedBase`] when no expected commit is
/// provided (the check is effectively a no-op in that case).
pub fn check_base_commit(cwd: &Path, expected_base: Option<&BaseCommitSource>) -> BaseCommitState {
let Some(source) = expected_base else {
return BaseCommitState::NoExpectedBase;
};
let expected_raw = match source {
BaseCommitSource::Flag(value) | BaseCommitSource::File(value) => value.as_str(),
};
let Some(head_sha) = resolve_head_sha(cwd) else {
return BaseCommitState::NotAGitRepo;
};
let Some(expected_sha) = resolve_rev(cwd, expected_raw) else {
// If the expected ref cannot be resolved, compare raw strings as a
// best-effort fallback (e.g. partial SHA provided by the caller).
return if head_sha.starts_with(expected_raw) || expected_raw.starts_with(&head_sha) {
BaseCommitState::Matches
} else {
BaseCommitState::Diverged {
expected: expected_raw.to_string(),
actual: head_sha,
}
};
};
if head_sha == expected_sha {
BaseCommitState::Matches
} else {
BaseCommitState::Diverged {
expected: expected_sha,
actual: head_sha,
}
}
}
/// Format a human-readable warning when the base commit has diverged.
///
/// Returns `None` for non-warning states (`Matches`, `NoExpectedBase`).
pub fn format_stale_base_warning(state: &BaseCommitState) -> Option<String> {
match state {
BaseCommitState::Diverged { expected, actual } => Some(format!(
"warning: worktree HEAD ({actual}) does not match expected base commit ({expected}). \
Session may run against a stale codebase."
)),
BaseCommitState::NotAGitRepo => {
Some("warning: stale-base check skipped — not inside a git repository.".to_string())
}
BaseCommitState::Matches | BaseCommitState::NoExpectedBase => None,
}
}
fn resolve_head_sha(cwd: &Path) -> Option<String> {
resolve_rev(cwd, "HEAD")
}
fn resolve_rev(cwd: &Path, rev: &str) -> Option<String> {
let output = Command::new("git")
.args(["rev-parse", rev])
.current_dir(cwd)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let sha = String::from_utf8(output.stdout).ok()?;
let trimmed = sha.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_dir() -> std::path::PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time should be after epoch")
.as_nanos();
std::env::temp_dir().join(format!("runtime-stale-base-{nanos}"))
}
fn init_repo(path: &std::path::Path) {
fs::create_dir_all(path).expect("create repo dir");
run(path, &["init", "--quiet", "-b", "main"]);
run(path, &["config", "user.email", "tests@example.com"]);
run(path, &["config", "user.name", "Stale Base Tests"]);
fs::write(path.join("init.txt"), "initial\n").expect("write init file");
run(path, &["add", "."]);
run(path, &["commit", "-m", "initial commit", "--quiet"]);
}
fn run(cwd: &std::path::Path, args: &[&str]) {
let status = Command::new("git")
.args(args)
.current_dir(cwd)
.status()
.unwrap_or_else(|e| panic!("git {} failed to execute: {e}", args.join(" ")));
assert!(
status.success(),
"git {} exited with {status}",
args.join(" ")
);
}
fn commit_file(repo: &std::path::Path, name: &str, msg: &str) {
fs::write(repo.join(name), format!("{msg}\n")).expect("write file");
run(repo, &["add", name]);
run(repo, &["commit", "-m", msg, "--quiet"]);
}
fn head_sha(repo: &std::path::Path) -> String {
let output = Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(repo)
.output()
.expect("git rev-parse HEAD");
String::from_utf8(output.stdout)
.expect("valid utf8")
.trim()
.to_string()
}
#[test]
fn matches_when_head_equals_expected_base() {
// given
let root = temp_dir();
init_repo(&root);
let sha = head_sha(&root);
let source = BaseCommitSource::Flag(sha);
// when
let state = check_base_commit(&root, Some(&source));
// then
assert_eq!(state, BaseCommitState::Matches);
fs::remove_dir_all(&root).expect("cleanup");
}
#[test]
fn diverged_when_head_moved_past_expected_base() {
// given
let root = temp_dir();
init_repo(&root);
let old_sha = head_sha(&root);
commit_file(&root, "extra.txt", "move head forward");
let new_sha = head_sha(&root);
let source = BaseCommitSource::Flag(old_sha.clone());
// when
let state = check_base_commit(&root, Some(&source));
// then
assert_eq!(
state,
BaseCommitState::Diverged {
expected: old_sha,
actual: new_sha,
}
);
fs::remove_dir_all(&root).expect("cleanup");
}
#[test]
fn no_expected_base_when_source_is_none() {
// given
let root = temp_dir();
init_repo(&root);
// when
let state = check_base_commit(&root, None);
// then
assert_eq!(state, BaseCommitState::NoExpectedBase);
fs::remove_dir_all(&root).expect("cleanup");
}
#[test]
fn not_a_git_repo_when_outside_repo() {
// given
let root = temp_dir();
fs::create_dir_all(&root).expect("create dir");
let source = BaseCommitSource::Flag("abc1234".to_string());
// when
let state = check_base_commit(&root, Some(&source));
// then
assert_eq!(state, BaseCommitState::NotAGitRepo);
fs::remove_dir_all(&root).expect("cleanup");
}
#[test]
fn reads_claw_base_file() {
// given
let root = temp_dir();
fs::create_dir_all(&root).expect("create dir");
fs::write(root.join(".claw-base"), "abc1234def5678\n").expect("write .claw-base");
// when
let value = read_claw_base_file(&root);
// then
assert_eq!(value, Some("abc1234def5678".to_string()));
fs::remove_dir_all(&root).expect("cleanup");
}
#[test]
fn returns_none_for_missing_claw_base_file() {
// given
let root = temp_dir();
fs::create_dir_all(&root).expect("create dir");
// when
let value = read_claw_base_file(&root);
// then
assert!(value.is_none());
fs::remove_dir_all(&root).expect("cleanup");
}
#[test]
fn returns_none_for_empty_claw_base_file() {
// given
let root = temp_dir();
fs::create_dir_all(&root).expect("create dir");
fs::write(root.join(".claw-base"), " \n").expect("write empty .claw-base");
// when
let value = read_claw_base_file(&root);
// then
assert!(value.is_none());
fs::remove_dir_all(&root).expect("cleanup");
}
#[test]
fn resolve_expected_base_prefers_flag_over_file() {
// given
let root = temp_dir();
fs::create_dir_all(&root).expect("create dir");
fs::write(root.join(".claw-base"), "from_file\n").expect("write .claw-base");
// when
let source = resolve_expected_base(Some("from_flag"), &root);
// then
assert_eq!(
source,
Some(BaseCommitSource::Flag("from_flag".to_string()))
);
fs::remove_dir_all(&root).expect("cleanup");
}
#[test]
fn resolve_expected_base_falls_back_to_file() {
// given
let root = temp_dir();
fs::create_dir_all(&root).expect("create dir");
fs::write(root.join(".claw-base"), "from_file\n").expect("write .claw-base");
// when
let source = resolve_expected_base(None, &root);
// then
assert_eq!(
source,
Some(BaseCommitSource::File("from_file".to_string()))
);
fs::remove_dir_all(&root).expect("cleanup");
}
#[test]
fn resolve_expected_base_returns_none_when_nothing_available() {
// given
let root = temp_dir();
fs::create_dir_all(&root).expect("create dir");
// when
let source = resolve_expected_base(None, &root);
// then
assert!(source.is_none());
fs::remove_dir_all(&root).expect("cleanup");
}
#[test]
fn format_warning_returns_message_for_diverged() {
// given
let state = BaseCommitState::Diverged {
expected: "abc1234".to_string(),
actual: "def5678".to_string(),
};
// when
let warning = format_stale_base_warning(&state);
// then
let message = warning.expect("should produce warning");
assert!(message.contains("abc1234"));
assert!(message.contains("def5678"));
assert!(message.contains("stale codebase"));
}
#[test]
fn format_warning_returns_none_for_matches() {
// given
let state = BaseCommitState::Matches;
// when
let warning = format_stale_base_warning(&state);
// then
assert!(warning.is_none());
}
#[test]
fn format_warning_returns_none_for_no_expected_base() {
// given
let state = BaseCommitState::NoExpectedBase;
// when
let warning = format_stale_base_warning(&state);
// then
assert!(warning.is_none());
}
#[test]
fn matches_with_claw_base_file_in_real_repo() {
// given
let root = temp_dir();
init_repo(&root);
let sha = head_sha(&root);
fs::write(root.join(".claw-base"), format!("{sha}\n")).expect("write .claw-base");
let source = resolve_expected_base(None, &root);
// when
let state = check_base_commit(&root, source.as_ref());
// then
assert_eq!(state, BaseCommitState::Matches);
fs::remove_dir_all(&root).expect("cleanup");
}
#[test]
fn diverged_with_claw_base_file_after_new_commit() {
// given
let root = temp_dir();
init_repo(&root);
let old_sha = head_sha(&root);
fs::write(root.join(".claw-base"), format!("{old_sha}\n")).expect("write .claw-base");
commit_file(&root, "new.txt", "advance head");
let new_sha = head_sha(&root);
let source = resolve_expected_base(None, &root);
// when
let state = check_base_commit(&root, source.as_ref());
// then
assert_eq!(
state,
BaseCommitState::Diverged {
expected: old_sha,
actual: new_sha,
}
);
fs::remove_dir_all(&root).expect("cleanup");
}
}
+417
View File
@@ -0,0 +1,417 @@
#![allow(clippy::must_use_candidate)]
use std::path::Path;
use std::process::Command;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BranchFreshness {
Fresh,
Stale {
commits_behind: usize,
missing_fixes: Vec<String>,
},
Diverged {
ahead: usize,
behind: usize,
missing_fixes: Vec<String>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StaleBranchPolicy {
AutoRebase,
AutoMergeForward,
WarnOnly,
Block,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StaleBranchEvent {
BranchStaleAgainstMain {
branch: String,
commits_behind: usize,
missing_fixes: Vec<String>,
},
RebaseAttempted {
branch: String,
result: String,
},
MergeForwardAttempted {
branch: String,
result: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StaleBranchAction {
Noop,
Warn { message: String },
Block { message: String },
Rebase,
MergeForward,
}
pub fn check_freshness(branch: &str, main_ref: &str) -> BranchFreshness {
check_freshness_in(branch, main_ref, Path::new("."))
}
pub fn apply_policy(freshness: &BranchFreshness, policy: StaleBranchPolicy) -> StaleBranchAction {
match freshness {
BranchFreshness::Fresh => StaleBranchAction::Noop,
BranchFreshness::Stale {
commits_behind,
missing_fixes,
} => match policy {
StaleBranchPolicy::WarnOnly => StaleBranchAction::Warn {
message: format!(
"Branch is {commits_behind} commit(s) behind main. Missing fixes: {}",
if missing_fixes.is_empty() {
"(none)".to_string()
} else {
missing_fixes.join("; ")
}
),
},
StaleBranchPolicy::Block => StaleBranchAction::Block {
message: format!(
"Branch is {commits_behind} commit(s) behind main and must be updated before proceeding."
),
},
StaleBranchPolicy::AutoRebase => StaleBranchAction::Rebase,
StaleBranchPolicy::AutoMergeForward => StaleBranchAction::MergeForward,
},
BranchFreshness::Diverged {
ahead,
behind,
missing_fixes,
} => match policy {
StaleBranchPolicy::WarnOnly => StaleBranchAction::Warn {
message: format!(
"Branch has diverged: {ahead} commit(s) ahead, {behind} commit(s) behind main. Missing fixes: {}",
format_missing_fixes(missing_fixes)
),
},
StaleBranchPolicy::Block => StaleBranchAction::Block {
message: format!(
"Branch has diverged ({ahead} ahead, {behind} behind) and must be reconciled before proceeding. Missing fixes: {}",
format_missing_fixes(missing_fixes)
),
},
StaleBranchPolicy::AutoRebase => StaleBranchAction::Rebase,
StaleBranchPolicy::AutoMergeForward => StaleBranchAction::MergeForward,
},
}
}
pub(crate) fn check_freshness_in(
branch: &str,
main_ref: &str,
repo_path: &Path,
) -> BranchFreshness {
let behind = rev_list_count(main_ref, branch, repo_path);
let ahead = rev_list_count(branch, main_ref, repo_path);
if behind == 0 {
return BranchFreshness::Fresh;
}
if ahead > 0 {
return BranchFreshness::Diverged {
ahead,
behind,
missing_fixes: missing_fix_subjects(main_ref, branch, repo_path),
};
}
let missing_fixes = missing_fix_subjects(main_ref, branch, repo_path);
BranchFreshness::Stale {
commits_behind: behind,
missing_fixes,
}
}
fn format_missing_fixes(missing_fixes: &[String]) -> String {
if missing_fixes.is_empty() {
"(none)".to_string()
} else {
missing_fixes.join("; ")
}
}
fn rev_list_count(a: &str, b: &str, repo_path: &Path) -> usize {
let output = Command::new("git")
.args(["rev-list", "--count", &format!("{b}..{a}")])
.current_dir(repo_path)
.output();
match output {
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
.trim()
.parse::<usize>()
.unwrap_or(0),
_ => 0,
}
}
fn missing_fix_subjects(a: &str, b: &str, repo_path: &Path) -> Vec<String> {
let output = Command::new("git")
.args(["log", "--format=%s", &format!("{b}..{a}")])
.current_dir(repo_path)
.output();
match output {
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
.lines()
.filter(|l| !l.is_empty())
.map(String::from)
.collect(),
_ => Vec::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_dir() -> std::path::PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time should be after epoch")
.as_nanos();
std::env::temp_dir().join(format!("runtime-stale-branch-{nanos}"))
}
fn init_repo(path: &Path) {
fs::create_dir_all(path).expect("create repo dir");
run(path, &["init", "--quiet", "-b", "main"]);
run(path, &["config", "user.email", "tests@example.com"]);
run(path, &["config", "user.name", "Stale Branch Tests"]);
fs::write(path.join("init.txt"), "initial\n").expect("write init file");
run(path, &["add", "."]);
run(path, &["commit", "-m", "initial commit", "--quiet"]);
}
fn run(cwd: &Path, args: &[&str]) {
let status = Command::new("git")
.args(args)
.current_dir(cwd)
.status()
.unwrap_or_else(|e| panic!("git {} failed to execute: {e}", args.join(" ")));
assert!(
status.success(),
"git {} exited with {status}",
args.join(" ")
);
}
fn commit_file(repo: &Path, name: &str, msg: &str) {
fs::write(repo.join(name), format!("{msg}\n")).expect("write file");
run(repo, &["add", name]);
run(repo, &["commit", "-m", msg, "--quiet"]);
}
#[test]
fn fresh_branch_passes() {
let root = temp_dir();
init_repo(&root);
// given
run(&root, &["checkout", "-b", "topic"]);
// when
let freshness = check_freshness_in("topic", "main", &root);
// then
assert_eq!(freshness, BranchFreshness::Fresh);
fs::remove_dir_all(&root).expect("cleanup");
}
#[test]
fn fresh_branch_ahead_of_main_still_fresh() {
let root = temp_dir();
init_repo(&root);
// given
run(&root, &["checkout", "-b", "topic"]);
commit_file(&root, "feature.txt", "add feature");
// when
let freshness = check_freshness_in("topic", "main", &root);
// then
assert_eq!(freshness, BranchFreshness::Fresh);
fs::remove_dir_all(&root).expect("cleanup");
}
#[test]
fn stale_branch_detected_with_correct_behind_count_and_missing_fixes() {
let root = temp_dir();
init_repo(&root);
// given
run(&root, &["checkout", "-b", "topic"]);
run(&root, &["checkout", "main"]);
commit_file(&root, "fix1.txt", "fix: resolve timeout");
commit_file(&root, "fix2.txt", "fix: handle null pointer");
// when
let freshness = check_freshness_in("topic", "main", &root);
// then
match freshness {
BranchFreshness::Stale {
commits_behind,
missing_fixes,
} => {
assert_eq!(commits_behind, 2);
assert_eq!(missing_fixes.len(), 2);
assert_eq!(missing_fixes[0], "fix: handle null pointer");
assert_eq!(missing_fixes[1], "fix: resolve timeout");
}
other => panic!("expected Stale, got {other:?}"),
}
fs::remove_dir_all(&root).expect("cleanup");
}
#[test]
fn diverged_branch_detection() {
let root = temp_dir();
init_repo(&root);
// given
run(&root, &["checkout", "-b", "topic"]);
commit_file(&root, "topic_work.txt", "topic work");
run(&root, &["checkout", "main"]);
commit_file(&root, "main_fix.txt", "main fix");
// when
let freshness = check_freshness_in("topic", "main", &root);
// then
match freshness {
BranchFreshness::Diverged {
ahead,
behind,
missing_fixes,
} => {
assert_eq!(ahead, 1);
assert_eq!(behind, 1);
assert_eq!(missing_fixes, vec!["main fix".to_string()]);
}
other => panic!("expected Diverged, got {other:?}"),
}
fs::remove_dir_all(&root).expect("cleanup");
}
#[test]
fn policy_noop_for_fresh_branch() {
// given
let freshness = BranchFreshness::Fresh;
// when
let action = apply_policy(&freshness, StaleBranchPolicy::WarnOnly);
// then
assert_eq!(action, StaleBranchAction::Noop);
}
#[test]
fn policy_warn_for_stale_branch() {
// given
let freshness = BranchFreshness::Stale {
commits_behind: 3,
missing_fixes: vec!["fix: timeout".into(), "fix: null ptr".into()],
};
// when
let action = apply_policy(&freshness, StaleBranchPolicy::WarnOnly);
// then
match action {
StaleBranchAction::Warn { message } => {
assert!(message.contains("3 commit(s) behind"));
assert!(message.contains("fix: timeout"));
assert!(message.contains("fix: null ptr"));
}
other => panic!("expected Warn, got {other:?}"),
}
}
#[test]
fn policy_block_for_stale_branch() {
// given
let freshness = BranchFreshness::Stale {
commits_behind: 1,
missing_fixes: vec!["hotfix".into()],
};
// when
let action = apply_policy(&freshness, StaleBranchPolicy::Block);
// then
match action {
StaleBranchAction::Block { message } => {
assert!(message.contains("1 commit(s) behind"));
}
other => panic!("expected Block, got {other:?}"),
}
}
#[test]
fn policy_auto_rebase_for_stale_branch() {
// given
let freshness = BranchFreshness::Stale {
commits_behind: 2,
missing_fixes: vec![],
};
// when
let action = apply_policy(&freshness, StaleBranchPolicy::AutoRebase);
// then
assert_eq!(action, StaleBranchAction::Rebase);
}
#[test]
fn policy_auto_merge_forward_for_diverged_branch() {
// given
let freshness = BranchFreshness::Diverged {
ahead: 5,
behind: 2,
missing_fixes: vec!["fix: merge main".into()],
};
// when
let action = apply_policy(&freshness, StaleBranchPolicy::AutoMergeForward);
// then
assert_eq!(action, StaleBranchAction::MergeForward);
}
#[test]
fn policy_warn_for_diverged_branch() {
// given
let freshness = BranchFreshness::Diverged {
ahead: 3,
behind: 1,
missing_fixes: vec!["main hotfix".into()],
};
// when
let action = apply_policy(&freshness, StaleBranchPolicy::WarnOnly);
// then
match action {
StaleBranchAction::Warn { message } => {
assert!(message.contains("diverged"));
assert!(message.contains("3 commit(s) ahead"));
assert!(message.contains("1 commit(s) behind"));
assert!(message.contains("main hotfix"));
}
other => panic!("expected Warn, got {other:?}"),
}
}
}
+300
View File
@@ -0,0 +1,300 @@
use std::collections::BTreeSet;
const DEFAULT_MAX_CHARS: usize = 1_200;
const DEFAULT_MAX_LINES: usize = 24;
const DEFAULT_MAX_LINE_CHARS: usize = 160;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SummaryCompressionBudget {
pub max_chars: usize,
pub max_lines: usize,
pub max_line_chars: usize,
}
impl Default for SummaryCompressionBudget {
fn default() -> Self {
Self {
max_chars: DEFAULT_MAX_CHARS,
max_lines: DEFAULT_MAX_LINES,
max_line_chars: DEFAULT_MAX_LINE_CHARS,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SummaryCompressionResult {
pub summary: String,
pub original_chars: usize,
pub compressed_chars: usize,
pub original_lines: usize,
pub compressed_lines: usize,
pub removed_duplicate_lines: usize,
pub omitted_lines: usize,
pub truncated: bool,
}
#[must_use]
pub fn compress_summary(
summary: &str,
budget: SummaryCompressionBudget,
) -> SummaryCompressionResult {
let original_chars = summary.chars().count();
let original_lines = summary.lines().count();
let normalized = normalize_lines(summary, budget.max_line_chars);
if normalized.lines.is_empty() || budget.max_chars == 0 || budget.max_lines == 0 {
return SummaryCompressionResult {
summary: String::new(),
original_chars,
compressed_chars: 0,
original_lines,
compressed_lines: 0,
removed_duplicate_lines: normalized.removed_duplicate_lines,
omitted_lines: normalized.lines.len(),
truncated: original_chars > 0,
};
}
let selected = select_line_indexes(&normalized.lines, budget);
let mut compressed_lines = selected
.iter()
.map(|index| normalized.lines[*index].clone())
.collect::<Vec<_>>();
if compressed_lines.is_empty() {
compressed_lines.push(truncate_line(&normalized.lines[0], budget.max_chars));
}
let omitted_lines = normalized
.lines
.len()
.saturating_sub(compressed_lines.len());
if omitted_lines > 0 {
let omission_notice = omission_notice(omitted_lines);
push_line_with_budget(&mut compressed_lines, omission_notice, budget);
}
let compressed_summary = compressed_lines.join("\n");
SummaryCompressionResult {
compressed_chars: compressed_summary.chars().count(),
compressed_lines: compressed_lines.len(),
removed_duplicate_lines: normalized.removed_duplicate_lines,
omitted_lines,
truncated: compressed_summary != summary.trim(),
summary: compressed_summary,
original_chars,
original_lines,
}
}
#[must_use]
pub fn compress_summary_text(summary: &str) -> String {
compress_summary(summary, SummaryCompressionBudget::default()).summary
}
#[derive(Debug, Default)]
struct NormalizedSummary {
lines: Vec<String>,
removed_duplicate_lines: usize,
}
fn normalize_lines(summary: &str, max_line_chars: usize) -> NormalizedSummary {
let mut seen = BTreeSet::new();
let mut lines = Vec::new();
let mut removed_duplicate_lines = 0;
for raw_line in summary.lines() {
let normalized = collapse_inline_whitespace(raw_line);
if normalized.is_empty() {
continue;
}
let truncated = truncate_line(&normalized, max_line_chars);
let dedupe_key = dedupe_key(&truncated);
if !seen.insert(dedupe_key) {
removed_duplicate_lines += 1;
continue;
}
lines.push(truncated);
}
NormalizedSummary {
lines,
removed_duplicate_lines,
}
}
fn select_line_indexes(lines: &[String], budget: SummaryCompressionBudget) -> Vec<usize> {
let mut selected = BTreeSet::<usize>::new();
for priority in 0..=3 {
for (index, line) in lines.iter().enumerate() {
if selected.contains(&index) || line_priority(line) != priority {
continue;
}
let candidate = selected
.iter()
.map(|selected_index| lines[*selected_index].as_str())
.chain(std::iter::once(line.as_str()))
.collect::<Vec<_>>();
if candidate.len() > budget.max_lines {
continue;
}
if joined_char_count(&candidate) > budget.max_chars {
continue;
}
selected.insert(index);
}
}
selected.into_iter().collect()
}
fn push_line_with_budget(lines: &mut Vec<String>, line: String, budget: SummaryCompressionBudget) {
let candidate = lines
.iter()
.map(String::as_str)
.chain(std::iter::once(line.as_str()))
.collect::<Vec<_>>();
if candidate.len() <= budget.max_lines && joined_char_count(&candidate) <= budget.max_chars {
lines.push(line);
}
}
fn joined_char_count(lines: &[&str]) -> usize {
lines.iter().map(|line| line.chars().count()).sum::<usize>() + lines.len().saturating_sub(1)
}
fn line_priority(line: &str) -> usize {
if line == "Summary:" || line == "Conversation summary:" || is_core_detail(line) {
0
} else if is_section_header(line) {
1
} else if line.starts_with("- ") || line.starts_with(" - ") {
2
} else {
3
}
}
fn is_core_detail(line: &str) -> bool {
[
"- Scope:",
"- Current work:",
"- Pending work:",
"- Key files referenced:",
"- Tools mentioned:",
"- Recent user requests:",
"- Previously compacted context:",
"- Newly compacted context:",
]
.iter()
.any(|prefix| line.starts_with(prefix))
}
fn is_section_header(line: &str) -> bool {
line.ends_with(':')
}
fn omission_notice(omitted_lines: usize) -> String {
format!("- … {omitted_lines} additional line(s) omitted.")
}
fn collapse_inline_whitespace(line: &str) -> String {
line.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn truncate_line(line: &str, max_chars: usize) -> String {
if max_chars == 0 || line.chars().count() <= max_chars {
return line.to_string();
}
if max_chars == 1 {
return "".to_string();
}
let mut truncated = line
.chars()
.take(max_chars.saturating_sub(1))
.collect::<String>();
truncated.push('…');
truncated
}
fn dedupe_key(line: &str) -> String {
line.to_ascii_lowercase()
}
#[cfg(test)]
mod tests {
use super::{compress_summary, compress_summary_text, SummaryCompressionBudget};
#[test]
fn collapses_whitespace_and_duplicate_lines() {
// given
let summary = "Conversation summary:\n\n- Scope: compact earlier messages.\n- Scope: compact earlier messages.\n- Current work: update runtime module.\n";
// when
let result = compress_summary(summary, SummaryCompressionBudget::default());
// then
assert_eq!(result.removed_duplicate_lines, 1);
assert!(result
.summary
.contains("- Scope: compact earlier messages."));
assert!(!result.summary.contains(" compact earlier"));
}
#[test]
fn keeps_core_lines_when_budget_is_tight() {
// given
let summary = [
"Conversation summary:",
"- Scope: 18 earlier messages compacted.",
"- Current work: finish summary compression.",
"- Key timeline:",
" - user: asked for a working implementation.",
" - assistant: inspected runtime compaction flow.",
" - tool: cargo check succeeded.",
]
.join("\n");
// when
let result = compress_summary(
&summary,
SummaryCompressionBudget {
max_chars: 120,
max_lines: 3,
max_line_chars: 80,
},
);
// then
assert!(result.summary.contains("Conversation summary:"));
assert!(result
.summary
.contains("- Scope: 18 earlier messages compacted."));
assert!(result
.summary
.contains("- Current work: finish summary compression."));
assert!(result.omitted_lines > 0);
}
#[test]
fn provides_a_default_text_only_helper() {
// given
let summary = "Summary:\n\nA short line.";
// when
let compressed = compress_summary_text(summary);
// then
assert_eq!(compressed, "Summary:\nA short line.");
}
}
+158
View File
@@ -0,0 +1,158 @@
use serde::{Deserialize, Serialize};
use std::fmt::{Display, Formatter};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TaskPacket {
pub objective: String,
pub scope: String,
pub repo: String,
pub branch_policy: String,
pub acceptance_tests: Vec<String>,
pub commit_policy: String,
pub reporting_contract: String,
pub escalation_policy: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TaskPacketValidationError {
errors: Vec<String>,
}
impl TaskPacketValidationError {
#[must_use]
pub fn new(errors: Vec<String>) -> Self {
Self { errors }
}
#[must_use]
pub fn errors(&self) -> &[String] {
&self.errors
}
}
impl Display for TaskPacketValidationError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.errors.join("; "))
}
}
impl std::error::Error for TaskPacketValidationError {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ValidatedPacket(TaskPacket);
impl ValidatedPacket {
#[must_use]
pub fn packet(&self) -> &TaskPacket {
&self.0
}
#[must_use]
pub fn into_inner(self) -> TaskPacket {
self.0
}
}
pub fn validate_packet(packet: TaskPacket) -> Result<ValidatedPacket, TaskPacketValidationError> {
let mut errors = Vec::new();
validate_required("objective", &packet.objective, &mut errors);
validate_required("scope", &packet.scope, &mut errors);
validate_required("repo", &packet.repo, &mut errors);
validate_required("branch_policy", &packet.branch_policy, &mut errors);
validate_required("commit_policy", &packet.commit_policy, &mut errors);
validate_required(
"reporting_contract",
&packet.reporting_contract,
&mut errors,
);
validate_required("escalation_policy", &packet.escalation_policy, &mut errors);
for (index, test) in packet.acceptance_tests.iter().enumerate() {
if test.trim().is_empty() {
errors.push(format!(
"acceptance_tests contains an empty value at index {index}"
));
}
}
if errors.is_empty() {
Ok(ValidatedPacket(packet))
} else {
Err(TaskPacketValidationError::new(errors))
}
}
fn validate_required(field: &str, value: &str, errors: &mut Vec<String>) {
if value.trim().is_empty() {
errors.push(format!("{field} must not be empty"));
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_packet() -> TaskPacket {
TaskPacket {
objective: "Implement typed task packet format".to_string(),
scope: "runtime/task system".to_string(),
repo: "claw-code-parity".to_string(),
branch_policy: "origin/main only".to_string(),
acceptance_tests: vec![
"cargo build --workspace".to_string(),
"cargo test --workspace".to_string(),
],
commit_policy: "single verified commit".to_string(),
reporting_contract: "print build result, test result, commit sha".to_string(),
escalation_policy: "stop only on destructive ambiguity".to_string(),
}
}
#[test]
fn valid_packet_passes_validation() {
let packet = sample_packet();
let validated = validate_packet(packet.clone()).expect("packet should validate");
assert_eq!(validated.packet(), &packet);
assert_eq!(validated.into_inner(), packet);
}
#[test]
fn invalid_packet_accumulates_errors() {
let packet = TaskPacket {
objective: " ".to_string(),
scope: String::new(),
repo: String::new(),
branch_policy: "\t".to_string(),
acceptance_tests: vec!["ok".to_string(), " ".to_string()],
commit_policy: String::new(),
reporting_contract: String::new(),
escalation_policy: String::new(),
};
let error = validate_packet(packet).expect_err("packet should be rejected");
assert!(error.errors().len() >= 7);
assert!(error
.errors()
.contains(&"objective must not be empty".to_string()));
assert!(error
.errors()
.contains(&"scope must not be empty".to_string()));
assert!(error
.errors()
.contains(&"repo must not be empty".to_string()));
assert!(error
.errors()
.contains(&"acceptance_tests contains an empty value at index 1".to_string()));
}
#[test]
fn serialization_roundtrip_preserves_packet() {
let packet = sample_packet();
let serialized = serde_json::to_string(&packet).expect("packet should serialize");
let deserialized: TaskPacket =
serde_json::from_str(&serialized).expect("packet should deserialize");
assert_eq!(deserialized, packet);
}
}
+503
View File
@@ -0,0 +1,503 @@
#![allow(clippy::must_use_candidate, clippy::unnecessary_map_or)]
//! In-memory task registry for sub-agent task lifecycle management.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use crate::{validate_packet, TaskPacket, TaskPacketValidationError};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TaskStatus {
Created,
Running,
Completed,
Failed,
Stopped,
}
impl std::fmt::Display for TaskStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Created => write!(f, "created"),
Self::Running => write!(f, "running"),
Self::Completed => write!(f, "completed"),
Self::Failed => write!(f, "failed"),
Self::Stopped => write!(f, "stopped"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Task {
pub task_id: String,
pub prompt: String,
pub description: Option<String>,
pub task_packet: Option<TaskPacket>,
pub status: TaskStatus,
pub created_at: u64,
pub updated_at: u64,
pub messages: Vec<TaskMessage>,
pub output: String,
pub team_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskMessage {
pub role: String,
pub content: String,
pub timestamp: u64,
}
#[derive(Debug, Clone, Default)]
pub struct TaskRegistry {
inner: Arc<Mutex<RegistryInner>>,
}
#[derive(Debug, Default)]
struct RegistryInner {
tasks: HashMap<String, Task>,
counter: u64,
}
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
impl TaskRegistry {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn create(&self, prompt: &str, description: Option<&str>) -> Task {
self.create_task(prompt.to_owned(), description.map(str::to_owned), None)
}
pub fn create_from_packet(
&self,
packet: TaskPacket,
) -> Result<Task, TaskPacketValidationError> {
let packet = validate_packet(packet)?.into_inner();
Ok(self.create_task(
packet.objective.clone(),
Some(packet.scope.clone()),
Some(packet),
))
}
fn create_task(
&self,
prompt: String,
description: Option<String>,
task_packet: Option<TaskPacket>,
) -> Task {
let mut inner = self.inner.lock().expect("registry lock poisoned");
inner.counter += 1;
let ts = now_secs();
let task_id = format!("task_{:08x}_{}", ts, inner.counter);
let task = Task {
task_id: task_id.clone(),
prompt,
description,
task_packet,
status: TaskStatus::Created,
created_at: ts,
updated_at: ts,
messages: Vec::new(),
output: String::new(),
team_id: None,
};
inner.tasks.insert(task_id, task.clone());
task
}
pub fn get(&self, task_id: &str) -> Option<Task> {
let inner = self.inner.lock().expect("registry lock poisoned");
inner.tasks.get(task_id).cloned()
}
pub fn list(&self, status_filter: Option<TaskStatus>) -> Vec<Task> {
let inner = self.inner.lock().expect("registry lock poisoned");
inner
.tasks
.values()
.filter(|t| status_filter.map_or(true, |s| t.status == s))
.cloned()
.collect()
}
pub fn stop(&self, task_id: &str) -> Result<Task, String> {
let mut inner = self.inner.lock().expect("registry lock poisoned");
let task = inner
.tasks
.get_mut(task_id)
.ok_or_else(|| format!("task not found: {task_id}"))?;
match task.status {
TaskStatus::Completed | TaskStatus::Failed | TaskStatus::Stopped => {
return Err(format!(
"task {task_id} is already in terminal state: {}",
task.status
));
}
_ => {}
}
task.status = TaskStatus::Stopped;
task.updated_at = now_secs();
Ok(task.clone())
}
pub fn update(&self, task_id: &str, message: &str) -> Result<Task, String> {
let mut inner = self.inner.lock().expect("registry lock poisoned");
let task = inner
.tasks
.get_mut(task_id)
.ok_or_else(|| format!("task not found: {task_id}"))?;
task.messages.push(TaskMessage {
role: String::from("user"),
content: message.to_owned(),
timestamp: now_secs(),
});
task.updated_at = now_secs();
Ok(task.clone())
}
pub fn output(&self, task_id: &str) -> Result<String, String> {
let inner = self.inner.lock().expect("registry lock poisoned");
let task = inner
.tasks
.get(task_id)
.ok_or_else(|| format!("task not found: {task_id}"))?;
Ok(task.output.clone())
}
pub fn append_output(&self, task_id: &str, output: &str) -> Result<(), String> {
let mut inner = self.inner.lock().expect("registry lock poisoned");
let task = inner
.tasks
.get_mut(task_id)
.ok_or_else(|| format!("task not found: {task_id}"))?;
task.output.push_str(output);
task.updated_at = now_secs();
Ok(())
}
pub fn set_status(&self, task_id: &str, status: TaskStatus) -> Result<(), String> {
let mut inner = self.inner.lock().expect("registry lock poisoned");
let task = inner
.tasks
.get_mut(task_id)
.ok_or_else(|| format!("task not found: {task_id}"))?;
task.status = status;
task.updated_at = now_secs();
Ok(())
}
pub fn assign_team(&self, task_id: &str, team_id: &str) -> Result<(), String> {
let mut inner = self.inner.lock().expect("registry lock poisoned");
let task = inner
.tasks
.get_mut(task_id)
.ok_or_else(|| format!("task not found: {task_id}"))?;
task.team_id = Some(team_id.to_owned());
task.updated_at = now_secs();
Ok(())
}
pub fn remove(&self, task_id: &str) -> Option<Task> {
let mut inner = self.inner.lock().expect("registry lock poisoned");
inner.tasks.remove(task_id)
}
#[must_use]
pub fn len(&self) -> usize {
let inner = self.inner.lock().expect("registry lock poisoned");
inner.tasks.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn creates_and_retrieves_tasks() {
let registry = TaskRegistry::new();
let task = registry.create("Do something", Some("A test task"));
assert_eq!(task.status, TaskStatus::Created);
assert_eq!(task.prompt, "Do something");
assert_eq!(task.description.as_deref(), Some("A test task"));
assert_eq!(task.task_packet, None);
let fetched = registry.get(&task.task_id).expect("task should exist");
assert_eq!(fetched.task_id, task.task_id);
}
#[test]
fn creates_task_from_packet() {
let registry = TaskRegistry::new();
let packet = TaskPacket {
objective: "Ship task packet support".to_string(),
scope: "runtime/task system".to_string(),
repo: "claw-code-parity".to_string(),
branch_policy: "origin/main only".to_string(),
acceptance_tests: vec!["cargo test --workspace".to_string()],
commit_policy: "single commit".to_string(),
reporting_contract: "print commit sha".to_string(),
escalation_policy: "manual escalation".to_string(),
};
let task = registry
.create_from_packet(packet.clone())
.expect("packet-backed task should be created");
assert_eq!(task.prompt, packet.objective);
assert_eq!(task.description.as_deref(), Some("runtime/task system"));
assert_eq!(task.task_packet, Some(packet.clone()));
let fetched = registry.get(&task.task_id).expect("task should exist");
assert_eq!(fetched.task_packet, Some(packet));
}
#[test]
fn lists_tasks_with_optional_filter() {
let registry = TaskRegistry::new();
registry.create("Task A", None);
let task_b = registry.create("Task B", None);
registry
.set_status(&task_b.task_id, TaskStatus::Running)
.expect("set status should succeed");
let all = registry.list(None);
assert_eq!(all.len(), 2);
let running = registry.list(Some(TaskStatus::Running));
assert_eq!(running.len(), 1);
assert_eq!(running[0].task_id, task_b.task_id);
let created = registry.list(Some(TaskStatus::Created));
assert_eq!(created.len(), 1);
}
#[test]
fn stops_running_task() {
let registry = TaskRegistry::new();
let task = registry.create("Stoppable", None);
registry
.set_status(&task.task_id, TaskStatus::Running)
.unwrap();
let stopped = registry.stop(&task.task_id).expect("stop should succeed");
assert_eq!(stopped.status, TaskStatus::Stopped);
// Stopping again should fail
let result = registry.stop(&task.task_id);
assert!(result.is_err());
}
#[test]
fn updates_task_with_messages() {
let registry = TaskRegistry::new();
let task = registry.create("Messageable", None);
let updated = registry
.update(&task.task_id, "Here's more context")
.expect("update should succeed");
assert_eq!(updated.messages.len(), 1);
assert_eq!(updated.messages[0].content, "Here's more context");
assert_eq!(updated.messages[0].role, "user");
}
#[test]
fn appends_and_retrieves_output() {
let registry = TaskRegistry::new();
let task = registry.create("Output task", None);
registry
.append_output(&task.task_id, "line 1\n")
.expect("append should succeed");
registry
.append_output(&task.task_id, "line 2\n")
.expect("append should succeed");
let output = registry.output(&task.task_id).expect("output should exist");
assert_eq!(output, "line 1\nline 2\n");
}
#[test]
fn assigns_team_and_removes_task() {
let registry = TaskRegistry::new();
let task = registry.create("Team task", None);
registry
.assign_team(&task.task_id, "team_abc")
.expect("assign should succeed");
let fetched = registry.get(&task.task_id).unwrap();
assert_eq!(fetched.team_id.as_deref(), Some("team_abc"));
let removed = registry.remove(&task.task_id);
assert!(removed.is_some());
assert!(registry.get(&task.task_id).is_none());
assert!(registry.is_empty());
}
#[test]
fn rejects_operations_on_missing_task() {
let registry = TaskRegistry::new();
assert!(registry.stop("nonexistent").is_err());
assert!(registry.update("nonexistent", "msg").is_err());
assert!(registry.output("nonexistent").is_err());
assert!(registry.append_output("nonexistent", "data").is_err());
assert!(registry
.set_status("nonexistent", TaskStatus::Running)
.is_err());
}
#[test]
fn task_status_display_all_variants() {
// given
let cases = [
(TaskStatus::Created, "created"),
(TaskStatus::Running, "running"),
(TaskStatus::Completed, "completed"),
(TaskStatus::Failed, "failed"),
(TaskStatus::Stopped, "stopped"),
];
// when
let rendered: Vec<_> = cases
.into_iter()
.map(|(status, expected)| (status.to_string(), expected))
.collect();
// then
assert_eq!(
rendered,
vec![
("created".to_string(), "created"),
("running".to_string(), "running"),
("completed".to_string(), "completed"),
("failed".to_string(), "failed"),
("stopped".to_string(), "stopped"),
]
);
}
#[test]
fn stop_rejects_completed_task() {
// given
let registry = TaskRegistry::new();
let task = registry.create("done", None);
registry
.set_status(&task.task_id, TaskStatus::Completed)
.expect("set status should succeed");
// when
let result = registry.stop(&task.task_id);
// then
let error = result.expect_err("completed task should be rejected");
assert!(error.contains("already in terminal state"));
assert!(error.contains("completed"));
}
#[test]
fn stop_rejects_failed_task() {
// given
let registry = TaskRegistry::new();
let task = registry.create("failed", None);
registry
.set_status(&task.task_id, TaskStatus::Failed)
.expect("set status should succeed");
// when
let result = registry.stop(&task.task_id);
// then
let error = result.expect_err("failed task should be rejected");
assert!(error.contains("already in terminal state"));
assert!(error.contains("failed"));
}
#[test]
fn stop_succeeds_from_created_state() {
// given
let registry = TaskRegistry::new();
let task = registry.create("created task", None);
// when
let stopped = registry.stop(&task.task_id).expect("stop should succeed");
// then
assert_eq!(stopped.status, TaskStatus::Stopped);
assert!(stopped.updated_at >= task.updated_at);
}
#[test]
fn new_registry_is_empty() {
// given
let registry = TaskRegistry::new();
// when
let all_tasks = registry.list(None);
// then
assert!(registry.is_empty());
assert_eq!(registry.len(), 0);
assert!(all_tasks.is_empty());
}
#[test]
fn create_without_description() {
// given
let registry = TaskRegistry::new();
// when
let task = registry.create("Do the thing", None);
// then
assert!(task.task_id.starts_with("task_"));
assert_eq!(task.description, None);
assert_eq!(task.task_packet, None);
assert!(task.messages.is_empty());
assert!(task.output.is_empty());
assert_eq!(task.team_id, None);
}
#[test]
fn remove_nonexistent_returns_none() {
// given
let registry = TaskRegistry::new();
// when
let removed = registry.remove("missing");
// then
assert!(removed.is_none());
}
#[test]
fn assign_team_rejects_missing_task() {
// given
let registry = TaskRegistry::new();
// when
let result = registry.assign_team("missing", "team_123");
// then
let error = result.expect_err("missing task should be rejected");
assert_eq!(error, "task not found: missing");
}
}
+509
View File
@@ -0,0 +1,509 @@
#![allow(clippy::must_use_candidate)]
//! In-memory registries for Team and Cron lifecycle management.
//!
//! Provides TeamCreate/Delete and CronCreate/Delete/List runtime backing
//! to replace the stub implementations in the tools crate.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Team {
pub team_id: String,
pub name: String,
pub task_ids: Vec<String>,
pub status: TeamStatus,
pub created_at: u64,
pub updated_at: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TeamStatus {
Created,
Running,
Completed,
Deleted,
}
impl std::fmt::Display for TeamStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Created => write!(f, "created"),
Self::Running => write!(f, "running"),
Self::Completed => write!(f, "completed"),
Self::Deleted => write!(f, "deleted"),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct TeamRegistry {
inner: Arc<Mutex<TeamInner>>,
}
#[derive(Debug, Default)]
struct TeamInner {
teams: HashMap<String, Team>,
counter: u64,
}
impl TeamRegistry {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn create(&self, name: &str, task_ids: Vec<String>) -> Team {
let mut inner = self.inner.lock().expect("team registry lock poisoned");
inner.counter += 1;
let ts = now_secs();
let team_id = format!("team_{:08x}_{}", ts, inner.counter);
let team = Team {
team_id: team_id.clone(),
name: name.to_owned(),
task_ids,
status: TeamStatus::Created,
created_at: ts,
updated_at: ts,
};
inner.teams.insert(team_id, team.clone());
team
}
pub fn get(&self, team_id: &str) -> Option<Team> {
let inner = self.inner.lock().expect("team registry lock poisoned");
inner.teams.get(team_id).cloned()
}
pub fn list(&self) -> Vec<Team> {
let inner = self.inner.lock().expect("team registry lock poisoned");
inner.teams.values().cloned().collect()
}
pub fn delete(&self, team_id: &str) -> Result<Team, String> {
let mut inner = self.inner.lock().expect("team registry lock poisoned");
let team = inner
.teams
.get_mut(team_id)
.ok_or_else(|| format!("team not found: {team_id}"))?;
team.status = TeamStatus::Deleted;
team.updated_at = now_secs();
Ok(team.clone())
}
pub fn remove(&self, team_id: &str) -> Option<Team> {
let mut inner = self.inner.lock().expect("team registry lock poisoned");
inner.teams.remove(team_id)
}
#[must_use]
pub fn len(&self) -> usize {
let inner = self.inner.lock().expect("team registry lock poisoned");
inner.teams.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CronEntry {
pub cron_id: String,
pub schedule: String,
pub prompt: String,
pub description: Option<String>,
pub enabled: bool,
pub created_at: u64,
pub updated_at: u64,
pub last_run_at: Option<u64>,
pub run_count: u64,
}
#[derive(Debug, Clone, Default)]
pub struct CronRegistry {
inner: Arc<Mutex<CronInner>>,
}
#[derive(Debug, Default)]
struct CronInner {
entries: HashMap<String, CronEntry>,
counter: u64,
}
impl CronRegistry {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn create(&self, schedule: &str, prompt: &str, description: Option<&str>) -> CronEntry {
let mut inner = self.inner.lock().expect("cron registry lock poisoned");
inner.counter += 1;
let ts = now_secs();
let cron_id = format!("cron_{:08x}_{}", ts, inner.counter);
let entry = CronEntry {
cron_id: cron_id.clone(),
schedule: schedule.to_owned(),
prompt: prompt.to_owned(),
description: description.map(str::to_owned),
enabled: true,
created_at: ts,
updated_at: ts,
last_run_at: None,
run_count: 0,
};
inner.entries.insert(cron_id, entry.clone());
entry
}
pub fn get(&self, cron_id: &str) -> Option<CronEntry> {
let inner = self.inner.lock().expect("cron registry lock poisoned");
inner.entries.get(cron_id).cloned()
}
pub fn list(&self, enabled_only: bool) -> Vec<CronEntry> {
let inner = self.inner.lock().expect("cron registry lock poisoned");
inner
.entries
.values()
.filter(|e| !enabled_only || e.enabled)
.cloned()
.collect()
}
pub fn delete(&self, cron_id: &str) -> Result<CronEntry, String> {
let mut inner = self.inner.lock().expect("cron registry lock poisoned");
inner
.entries
.remove(cron_id)
.ok_or_else(|| format!("cron not found: {cron_id}"))
}
/// Disable a cron entry without removing it.
pub fn disable(&self, cron_id: &str) -> Result<(), String> {
let mut inner = self.inner.lock().expect("cron registry lock poisoned");
let entry = inner
.entries
.get_mut(cron_id)
.ok_or_else(|| format!("cron not found: {cron_id}"))?;
entry.enabled = false;
entry.updated_at = now_secs();
Ok(())
}
/// Record a cron run.
pub fn record_run(&self, cron_id: &str) -> Result<(), String> {
let mut inner = self.inner.lock().expect("cron registry lock poisoned");
let entry = inner
.entries
.get_mut(cron_id)
.ok_or_else(|| format!("cron not found: {cron_id}"))?;
entry.last_run_at = Some(now_secs());
entry.run_count += 1;
entry.updated_at = now_secs();
Ok(())
}
#[must_use]
pub fn len(&self) -> usize {
let inner = self.inner.lock().expect("cron registry lock poisoned");
inner.entries.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
#[cfg(test)]
mod tests {
use super::*;
// ── Team tests ──────────────────────────────────────
#[test]
fn creates_and_retrieves_team() {
let registry = TeamRegistry::new();
let team = registry.create("Alpha Squad", vec!["task_001".into(), "task_002".into()]);
assert_eq!(team.name, "Alpha Squad");
assert_eq!(team.task_ids.len(), 2);
assert_eq!(team.status, TeamStatus::Created);
let fetched = registry.get(&team.team_id).expect("team should exist");
assert_eq!(fetched.team_id, team.team_id);
}
#[test]
fn lists_and_deletes_teams() {
let registry = TeamRegistry::new();
let t1 = registry.create("Team A", vec![]);
let t2 = registry.create("Team B", vec![]);
let all = registry.list();
assert_eq!(all.len(), 2);
let deleted = registry.delete(&t1.team_id).expect("delete should succeed");
assert_eq!(deleted.status, TeamStatus::Deleted);
// Team is still listable (soft delete)
let still_there = registry.get(&t1.team_id).unwrap();
assert_eq!(still_there.status, TeamStatus::Deleted);
// Hard remove
registry.remove(&t2.team_id);
assert_eq!(registry.len(), 1);
}
#[test]
fn rejects_missing_team_operations() {
let registry = TeamRegistry::new();
assert!(registry.delete("nonexistent").is_err());
assert!(registry.get("nonexistent").is_none());
}
// ── Cron tests ──────────────────────────────────────
#[test]
fn creates_and_retrieves_cron() {
let registry = CronRegistry::new();
let entry = registry.create("0 * * * *", "Check status", Some("hourly check"));
assert_eq!(entry.schedule, "0 * * * *");
assert_eq!(entry.prompt, "Check status");
assert!(entry.enabled);
assert_eq!(entry.run_count, 0);
assert!(entry.last_run_at.is_none());
let fetched = registry.get(&entry.cron_id).expect("cron should exist");
assert_eq!(fetched.cron_id, entry.cron_id);
}
#[test]
fn lists_with_enabled_filter() {
let registry = CronRegistry::new();
let c1 = registry.create("* * * * *", "Task 1", None);
let c2 = registry.create("0 * * * *", "Task 2", None);
registry
.disable(&c1.cron_id)
.expect("disable should succeed");
let all = registry.list(false);
assert_eq!(all.len(), 2);
let enabled_only = registry.list(true);
assert_eq!(enabled_only.len(), 1);
assert_eq!(enabled_only[0].cron_id, c2.cron_id);
}
#[test]
fn deletes_cron_entry() {
let registry = CronRegistry::new();
let entry = registry.create("* * * * *", "To delete", None);
let deleted = registry
.delete(&entry.cron_id)
.expect("delete should succeed");
assert_eq!(deleted.cron_id, entry.cron_id);
assert!(registry.get(&entry.cron_id).is_none());
assert!(registry.is_empty());
}
#[test]
fn records_cron_runs() {
let registry = CronRegistry::new();
let entry = registry.create("*/5 * * * *", "Recurring", None);
registry.record_run(&entry.cron_id).unwrap();
registry.record_run(&entry.cron_id).unwrap();
let fetched = registry.get(&entry.cron_id).unwrap();
assert_eq!(fetched.run_count, 2);
assert!(fetched.last_run_at.is_some());
}
#[test]
fn rejects_missing_cron_operations() {
let registry = CronRegistry::new();
assert!(registry.delete("nonexistent").is_err());
assert!(registry.disable("nonexistent").is_err());
assert!(registry.record_run("nonexistent").is_err());
assert!(registry.get("nonexistent").is_none());
}
#[test]
fn team_status_display_all_variants() {
// given
let cases = [
(TeamStatus::Created, "created"),
(TeamStatus::Running, "running"),
(TeamStatus::Completed, "completed"),
(TeamStatus::Deleted, "deleted"),
];
// when
let rendered: Vec<_> = cases
.into_iter()
.map(|(status, expected)| (status.to_string(), expected))
.collect();
// then
assert_eq!(
rendered,
vec![
("created".to_string(), "created"),
("running".to_string(), "running"),
("completed".to_string(), "completed"),
("deleted".to_string(), "deleted"),
]
);
}
#[test]
fn new_team_registry_is_empty() {
// given
let registry = TeamRegistry::new();
// when
let teams = registry.list();
// then
assert!(registry.is_empty());
assert_eq!(registry.len(), 0);
assert!(teams.is_empty());
}
#[test]
fn team_remove_nonexistent_returns_none() {
// given
let registry = TeamRegistry::new();
// when
let removed = registry.remove("missing");
// then
assert!(removed.is_none());
}
#[test]
fn team_len_transitions() {
// given
let registry = TeamRegistry::new();
// when
let alpha = registry.create("Alpha", vec![]);
let beta = registry.create("Beta", vec![]);
let after_create = registry.len();
registry.remove(&alpha.team_id);
let after_first_remove = registry.len();
registry.remove(&beta.team_id);
// then
assert_eq!(after_create, 2);
assert_eq!(after_first_remove, 1);
assert_eq!(registry.len(), 0);
assert!(registry.is_empty());
}
#[test]
fn cron_list_all_disabled_returns_empty_for_enabled_only() {
// given
let registry = CronRegistry::new();
let first = registry.create("* * * * *", "Task 1", None);
let second = registry.create("0 * * * *", "Task 2", None);
registry
.disable(&first.cron_id)
.expect("disable should succeed");
registry
.disable(&second.cron_id)
.expect("disable should succeed");
// when
let enabled_only = registry.list(true);
let all_entries = registry.list(false);
// then
assert!(enabled_only.is_empty());
assert_eq!(all_entries.len(), 2);
}
#[test]
fn cron_create_without_description() {
// given
let registry = CronRegistry::new();
// when
let entry = registry.create("*/15 * * * *", "Check health", None);
// then
assert!(entry.cron_id.starts_with("cron_"));
assert_eq!(entry.description, None);
assert!(entry.enabled);
assert_eq!(entry.run_count, 0);
assert_eq!(entry.last_run_at, None);
}
#[test]
fn new_cron_registry_is_empty() {
// given
let registry = CronRegistry::new();
// when
let enabled_only = registry.list(true);
let all_entries = registry.list(false);
// then
assert!(registry.is_empty());
assert_eq!(registry.len(), 0);
assert!(enabled_only.is_empty());
assert!(all_entries.is_empty());
}
#[test]
fn cron_record_run_updates_timestamp_and_counter() {
// given
let registry = CronRegistry::new();
let entry = registry.create("*/5 * * * *", "Recurring", None);
// when
registry
.record_run(&entry.cron_id)
.expect("first run should succeed");
registry
.record_run(&entry.cron_id)
.expect("second run should succeed");
let fetched = registry.get(&entry.cron_id).expect("entry should exist");
// then
assert_eq!(fetched.run_count, 2);
assert!(fetched.last_run_at.is_some());
assert!(fetched.updated_at >= entry.updated_at);
}
#[test]
fn cron_disable_updates_timestamp() {
// given
let registry = CronRegistry::new();
let entry = registry.create("0 0 * * *", "Nightly", None);
// when
registry
.disable(&entry.cron_id)
.expect("disable should succeed");
let fetched = registry.get(&entry.cron_id).expect("entry should exist");
// then
assert!(!fetched.enabled);
assert!(fetched.updated_at >= entry.updated_at);
}
}
+299
View File
@@ -0,0 +1,299 @@
use std::path::{Path, PathBuf};
const TRUST_PROMPT_CUES: &[&str] = &[
"do you trust the files in this folder",
"trust the files in this folder",
"trust this folder",
"allow and continue",
"yes, proceed",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TrustPolicy {
AutoTrust,
RequireApproval,
Deny,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TrustEvent {
TrustRequired { cwd: String },
TrustResolved { cwd: String, policy: TrustPolicy },
TrustDenied { cwd: String, reason: String },
}
#[derive(Debug, Clone, Default)]
pub struct TrustConfig {
allowlisted: Vec<PathBuf>,
denied: Vec<PathBuf>,
}
impl TrustConfig {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_allowlisted(mut self, path: impl Into<PathBuf>) -> Self {
self.allowlisted.push(path.into());
self
}
#[must_use]
pub fn with_denied(mut self, path: impl Into<PathBuf>) -> Self {
self.denied.push(path.into());
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TrustDecision {
NotRequired,
Required {
policy: TrustPolicy,
events: Vec<TrustEvent>,
},
}
impl TrustDecision {
#[must_use]
pub fn policy(&self) -> Option<TrustPolicy> {
match self {
Self::NotRequired => None,
Self::Required { policy, .. } => Some(*policy),
}
}
#[must_use]
pub fn events(&self) -> &[TrustEvent] {
match self {
Self::NotRequired => &[],
Self::Required { events, .. } => events,
}
}
}
#[derive(Debug, Clone)]
pub struct TrustResolver {
config: TrustConfig,
}
impl TrustResolver {
#[must_use]
pub fn new(config: TrustConfig) -> Self {
Self { config }
}
#[must_use]
pub fn resolve(&self, cwd: &str, screen_text: &str) -> TrustDecision {
if !detect_trust_prompt(screen_text) {
return TrustDecision::NotRequired;
}
let mut events = vec![TrustEvent::TrustRequired {
cwd: cwd.to_owned(),
}];
if let Some(matched_root) = self
.config
.denied
.iter()
.find(|root| path_matches(cwd, root))
{
let reason = format!("cwd matches denied trust root: {}", matched_root.display());
events.push(TrustEvent::TrustDenied {
cwd: cwd.to_owned(),
reason,
});
return TrustDecision::Required {
policy: TrustPolicy::Deny,
events,
};
}
if self
.config
.allowlisted
.iter()
.any(|root| path_matches(cwd, root))
{
events.push(TrustEvent::TrustResolved {
cwd: cwd.to_owned(),
policy: TrustPolicy::AutoTrust,
});
return TrustDecision::Required {
policy: TrustPolicy::AutoTrust,
events,
};
}
TrustDecision::Required {
policy: TrustPolicy::RequireApproval,
events,
}
}
#[must_use]
pub fn trusts(&self, cwd: &str) -> bool {
!self
.config
.denied
.iter()
.any(|root| path_matches(cwd, root))
&& self
.config
.allowlisted
.iter()
.any(|root| path_matches(cwd, root))
}
}
#[must_use]
pub fn detect_trust_prompt(screen_text: &str) -> bool {
let lowered = screen_text.to_ascii_lowercase();
TRUST_PROMPT_CUES
.iter()
.any(|needle| lowered.contains(needle))
}
#[must_use]
pub fn path_matches_trusted_root(cwd: &str, trusted_root: &str) -> bool {
path_matches(cwd, &normalize_path(Path::new(trusted_root)))
}
fn path_matches(candidate: &str, root: &Path) -> bool {
let candidate = normalize_path(Path::new(candidate));
let root = normalize_path(root);
candidate == root || candidate.starts_with(&root)
}
fn normalize_path(path: &Path) -> PathBuf {
std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}
#[cfg(test)]
mod tests {
use super::{
detect_trust_prompt, path_matches_trusted_root, TrustConfig, TrustDecision, TrustEvent,
TrustPolicy, TrustResolver,
};
#[test]
fn detects_known_trust_prompt_copy() {
// given
let screen_text = "Do you trust the files in this folder?\n1. Yes, proceed\n2. No";
// when
let detected = detect_trust_prompt(screen_text);
// then
assert!(detected);
}
#[test]
fn does_not_emit_events_when_prompt_is_absent() {
// given
let resolver = TrustResolver::new(TrustConfig::new().with_allowlisted("/tmp/worktrees"));
// when
let decision = resolver.resolve("/tmp/worktrees/repo-a", "Ready for your input\n>");
// then
assert_eq!(decision, TrustDecision::NotRequired);
assert_eq!(decision.events(), &[]);
assert_eq!(decision.policy(), None);
}
#[test]
fn auto_trusts_allowlisted_cwd_after_prompt_detection() {
// given
let resolver = TrustResolver::new(TrustConfig::new().with_allowlisted("/tmp/worktrees"));
// when
let decision = resolver.resolve(
"/tmp/worktrees/repo-a",
"Do you trust the files in this folder?\n1. Yes, proceed\n2. No",
);
// then
assert_eq!(decision.policy(), Some(TrustPolicy::AutoTrust));
assert_eq!(
decision.events(),
&[
TrustEvent::TrustRequired {
cwd: "/tmp/worktrees/repo-a".to_string(),
},
TrustEvent::TrustResolved {
cwd: "/tmp/worktrees/repo-a".to_string(),
policy: TrustPolicy::AutoTrust,
},
]
);
}
#[test]
fn requires_approval_for_unknown_cwd_after_prompt_detection() {
// given
let resolver = TrustResolver::new(TrustConfig::new().with_allowlisted("/tmp/worktrees"));
// when
let decision = resolver.resolve(
"/tmp/other/repo-b",
"Do you trust the files in this folder?\n1. Yes, proceed\n2. No",
);
// then
assert_eq!(decision.policy(), Some(TrustPolicy::RequireApproval));
assert_eq!(
decision.events(),
&[TrustEvent::TrustRequired {
cwd: "/tmp/other/repo-b".to_string(),
}]
);
}
#[test]
fn denied_root_takes_precedence_over_allowlist() {
// given
let resolver = TrustResolver::new(
TrustConfig::new()
.with_allowlisted("/tmp/worktrees")
.with_denied("/tmp/worktrees/repo-c"),
);
// when
let decision = resolver.resolve(
"/tmp/worktrees/repo-c",
"Do you trust the files in this folder?\n1. Yes, proceed\n2. No",
);
// then
assert_eq!(decision.policy(), Some(TrustPolicy::Deny));
assert_eq!(
decision.events(),
&[
TrustEvent::TrustRequired {
cwd: "/tmp/worktrees/repo-c".to_string(),
},
TrustEvent::TrustDenied {
cwd: "/tmp/worktrees/repo-c".to_string(),
reason: "cwd matches denied trust root: /tmp/worktrees/repo-c".to_string(),
},
]
);
}
#[test]
fn sibling_prefix_does_not_match_trusted_root() {
// given
let trusted_root = "/tmp/worktrees";
let sibling_path = "/tmp/worktrees-other/repo-d";
// when
let matched = path_matches_trusted_root(sibling_path, trusted_root);
// then
assert!(!matched);
}
}
+22 -19
View File
@@ -1,11 +1,11 @@
use crate::session::Session;
use serde::{Deserialize, Serialize};
const DEFAULT_INPUT_COST_PER_MILLION: f64 = 15.0;
const DEFAULT_OUTPUT_COST_PER_MILLION: f64 = 75.0;
const DEFAULT_CACHE_CREATION_COST_PER_MILLION: f64 = 18.75;
const DEFAULT_CACHE_READ_COST_PER_MILLION: f64 = 1.5;
/// Per-million-token pricing used for cost estimation.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ModelPricing {
pub input_cost_per_million: f64,
@@ -26,7 +26,8 @@ impl ModelPricing {
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
/// Token counters accumulated for a conversation turn or session.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct TokenUsage {
pub input_tokens: u32,
pub output_tokens: u32,
@@ -34,6 +35,7 @@ pub struct TokenUsage {
pub cache_read_input_tokens: u32,
}
/// Estimated dollar cost derived from a [`TokenUsage`] sample.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct UsageCostEstimate {
pub input_cost_usd: f64,
@@ -52,6 +54,7 @@ impl UsageCostEstimate {
}
}
/// Returns pricing metadata for a known model alias or family.
#[must_use]
pub fn pricing_for_model(model: &str) -> Option<ModelPricing> {
let normalized = model.to_ascii_lowercase();
@@ -156,10 +159,12 @@ fn cost_for_tokens(tokens: u32, usd_per_million_tokens: f64) -> f64 {
}
#[must_use]
/// Formats a dollar-denominated value for CLI display.
pub fn format_usd(amount: f64) -> String {
format!("${amount:.4}")
}
/// Aggregates token usage across a running session.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct UsageTracker {
latest_turn: TokenUsage,
@@ -250,9 +255,9 @@ mod tests {
let cost = usage.estimate_cost_usd();
assert_eq!(format_usd(cost.input_cost_usd), "$15.0000");
assert_eq!(format_usd(cost.output_cost_usd), "$37.5000");
let lines = usage.summary_lines_for_model("usage", Some("claude-sonnet-4-6"));
let lines = usage.summary_lines_for_model("usage", Some("claude-sonnet-4-20250514"));
assert!(lines[0].contains("estimated_cost=$54.6750"));
assert!(lines[0].contains("model=claude-sonnet-4-6"));
assert!(lines[0].contains("model=claude-sonnet-4-20250514"));
assert!(lines[1].contains("cache_read=$0.3000"));
}
@@ -265,7 +270,7 @@ mod tests {
cache_read_input_tokens: 0,
};
let haiku = pricing_for_model("claude-haiku-4-5-20251213").expect("haiku pricing");
let haiku = pricing_for_model("claude-haiku-4-5-20251001").expect("haiku pricing");
let opus = pricing_for_model("claude-opus-4-6").expect("opus pricing");
let haiku_cost = usage.estimate_cost_usd_with_pricing(haiku);
let opus_cost = usage.estimate_cost_usd_with_pricing(opus);
@@ -287,21 +292,19 @@ mod tests {
#[test]
fn reconstructs_usage_from_session_messages() {
let session = Session {
version: 1,
messages: vec![ConversationMessage {
role: MessageRole::Assistant,
blocks: vec![ContentBlock::Text {
text: "done".to_string(),
}],
usage: Some(TokenUsage {
input_tokens: 5,
output_tokens: 2,
cache_creation_input_tokens: 1,
cache_read_input_tokens: 0,
}),
let mut session = Session::new();
session.messages = vec![ConversationMessage {
role: MessageRole::Assistant,
blocks: vec![ContentBlock::Text {
text: "done".to_string(),
}],
};
usage: Some(TokenUsage {
input_tokens: 5,
output_tokens: 2,
cache_creation_input_tokens: 1,
cache_read_input_tokens: 0,
}),
}];
let tracker = UsageTracker::from_session(&session);
assert_eq!(tracker.turns(), 1);
File diff suppressed because it is too large Load Diff
+386
View File
@@ -0,0 +1,386 @@
#![allow(clippy::doc_markdown, clippy::uninlined_format_args, unused_imports)]
//! Integration tests for cross-module wiring.
//!
//! These tests verify that adjacent modules in the runtime crate actually
//! connect correctly — catching wiring gaps that unit tests miss.
use std::time::Duration;
use runtime::green_contract::{GreenContract, GreenContractOutcome, GreenLevel};
use runtime::{
apply_policy, BranchFreshness, DiffScope, LaneBlocker, LaneContext, PolicyAction,
PolicyCondition, PolicyEngine, PolicyRule, ReconcileReason, ReviewStatus, StaleBranchAction,
StaleBranchPolicy,
};
/// stale_branch + policy_engine integration:
/// When a branch is detected stale, does it correctly flow through
/// PolicyCondition::StaleBranch to generate the expected action?
#[test]
fn stale_branch_detection_flows_into_policy_engine() {
// given — a stale branch context (2 hours behind main, threshold is 1 hour)
let stale_context = LaneContext::new(
"stale-lane",
0,
Duration::from_secs(2 * 60 * 60), // 2 hours stale
LaneBlocker::None,
ReviewStatus::Pending,
DiffScope::Full,
false,
);
let engine = PolicyEngine::new(vec![PolicyRule::new(
"stale-merge-forward",
PolicyCondition::StaleBranch,
PolicyAction::MergeForward,
10,
)]);
// when
let actions = engine.evaluate(&stale_context);
// then
assert_eq!(actions, vec![PolicyAction::MergeForward]);
}
/// stale_branch + policy_engine: Fresh branch does NOT trigger stale rules
#[test]
fn fresh_branch_does_not_trigger_stale_policy() {
let fresh_context = LaneContext::new(
"fresh-lane",
0,
Duration::from_secs(30 * 60), // 30 min stale — under 1 hour threshold
LaneBlocker::None,
ReviewStatus::Pending,
DiffScope::Full,
false,
);
let engine = PolicyEngine::new(vec![PolicyRule::new(
"stale-merge-forward",
PolicyCondition::StaleBranch,
PolicyAction::MergeForward,
10,
)]);
let actions = engine.evaluate(&fresh_context);
assert!(actions.is_empty());
}
/// green_contract + policy_engine integration:
/// A lane that meets its green contract should be mergeable
#[test]
fn green_contract_satisfied_allows_merge() {
let contract = GreenContract::new(GreenLevel::Workspace);
let satisfied = contract.is_satisfied_by(GreenLevel::Workspace);
assert!(satisfied);
let exceeded = contract.is_satisfied_by(GreenLevel::MergeReady);
assert!(exceeded);
let insufficient = contract.is_satisfied_by(GreenLevel::Package);
assert!(!insufficient);
}
/// green_contract + policy_engine:
/// Lane with green level below contract requirement gets blocked
#[test]
fn green_contract_unsatisfied_blocks_merge() {
let context = LaneContext::new(
"partial-green-lane",
1, // GreenLevel::Package as u8
Duration::from_secs(0),
LaneBlocker::None,
ReviewStatus::Pending,
DiffScope::Full,
false,
);
// This is a conceptual test — we need a way to express "requires workspace green"
// Currently LaneContext has raw green_level: u8, not a contract
// For now we just verify the policy condition works
let engine = PolicyEngine::new(vec![PolicyRule::new(
"workspace-green-required",
PolicyCondition::GreenAt { level: 3 }, // GreenLevel::Workspace
PolicyAction::MergeToDev,
10,
)]);
let actions = engine.evaluate(&context);
assert!(actions.is_empty()); // level 1 < 3, so no merge
}
/// reconciliation + policy_engine integration:
/// A reconciled lane should be handled by reconcile rules, not generic closeout
#[test]
fn reconciled_lane_matches_reconcile_condition() {
let context = LaneContext::reconciled("reconciled-lane");
let engine = PolicyEngine::new(vec![
PolicyRule::new(
"reconcile-first",
PolicyCondition::LaneReconciled,
PolicyAction::Reconcile {
reason: ReconcileReason::AlreadyMerged,
},
5,
),
PolicyRule::new(
"generic-closeout",
PolicyCondition::LaneCompleted,
PolicyAction::CloseoutLane,
30,
),
]);
let actions = engine.evaluate(&context);
// Both rules fire — reconcile (priority 5) first, then closeout (priority 30)
assert_eq!(
actions,
vec![
PolicyAction::Reconcile {
reason: ReconcileReason::AlreadyMerged,
},
PolicyAction::CloseoutLane,
]
);
}
/// stale_branch module: apply_policy generates correct actions
#[test]
fn stale_branch_apply_policy_produces_rebase_action() {
let stale = BranchFreshness::Stale {
commits_behind: 5,
missing_fixes: vec!["fix-123".to_string()],
};
let action = apply_policy(&stale, StaleBranchPolicy::AutoRebase);
assert_eq!(action, StaleBranchAction::Rebase);
}
#[test]
fn stale_branch_apply_policy_produces_merge_forward_action() {
let stale = BranchFreshness::Stale {
commits_behind: 3,
missing_fixes: vec![],
};
let action = apply_policy(&stale, StaleBranchPolicy::AutoMergeForward);
assert_eq!(action, StaleBranchAction::MergeForward);
}
#[test]
fn stale_branch_apply_policy_warn_only() {
let stale = BranchFreshness::Stale {
commits_behind: 2,
missing_fixes: vec!["fix-456".to_string()],
};
let action = apply_policy(&stale, StaleBranchPolicy::WarnOnly);
match action {
StaleBranchAction::Warn { message } => {
assert!(message.contains("2 commit(s) behind main"));
assert!(message.contains("fix-456"));
}
_ => panic!("expected Warn action, got {:?}", action),
}
}
#[test]
fn stale_branch_fresh_produces_noop() {
let fresh = BranchFreshness::Fresh;
let action = apply_policy(&fresh, StaleBranchPolicy::AutoRebase);
assert_eq!(action, StaleBranchAction::Noop);
}
/// Combined flow: stale detection + policy + action
#[test]
fn end_to_end_stale_lane_gets_merge_forward_action() {
// Simulating what a harness would do:
// 1. Detect branch freshness
// 2. Build lane context from freshness + other signals
// 3. Run policy engine
// 4. Return actions
// given: detected stale state
let _freshness = BranchFreshness::Stale {
commits_behind: 5,
missing_fixes: vec!["fix-123".to_string()],
};
// when: build context and evaluate policy
let context = LaneContext::new(
"lane-9411",
3, // Workspace green
Duration::from_secs(5 * 60 * 60), // 5 hours stale, definitely over threshold
LaneBlocker::None,
ReviewStatus::Approved,
DiffScope::Scoped,
false,
);
let engine = PolicyEngine::new(vec![
// Priority 5: Check if stale first
PolicyRule::new(
"auto-merge-forward-if-stale-and-approved",
PolicyCondition::And(vec![
PolicyCondition::StaleBranch,
PolicyCondition::ReviewPassed,
]),
PolicyAction::MergeForward,
5,
),
// Priority 10: Normal stale handling
PolicyRule::new(
"stale-warning",
PolicyCondition::StaleBranch,
PolicyAction::Notify {
channel: "#build-status".to_string(),
},
10,
),
]);
let actions = engine.evaluate(&context);
// then: both rules should fire (stale + approved matches both)
assert_eq!(
actions,
vec![
PolicyAction::MergeForward,
PolicyAction::Notify {
channel: "#build-status".to_string(),
},
]
);
}
/// Fresh branch with approved review should merge (not stale-blocked)
#[test]
fn fresh_approved_lane_gets_merge_action() {
let context = LaneContext::new(
"fresh-approved-lane",
3, // Workspace green
Duration::from_secs(30 * 60), // 30 min — under 1 hour threshold = fresh
LaneBlocker::None,
ReviewStatus::Approved,
DiffScope::Scoped,
false,
);
let engine = PolicyEngine::new(vec![PolicyRule::new(
"merge-if-green-approved-not-stale",
PolicyCondition::And(vec![
PolicyCondition::GreenAt { level: 3 },
PolicyCondition::ReviewPassed,
// NOT PolicyCondition::StaleBranch — fresh lanes bypass this
]),
PolicyAction::MergeToDev,
5,
)]);
let actions = engine.evaluate(&context);
assert_eq!(actions, vec![PolicyAction::MergeToDev]);
}
/// worker_boot + recovery_recipes + policy_engine integration:
/// When a session completes with a provider failure, does the worker
/// status transition trigger the correct recovery recipe, and does
/// the resulting recovery state feed into policy decisions?
#[test]
fn worker_provider_failure_flows_through_recovery_to_policy() {
use runtime::recovery_recipes::{
attempt_recovery, FailureScenario, RecoveryContext, RecoveryResult, RecoveryStep,
};
use runtime::worker_boot::{WorkerFailureKind, WorkerRegistry, WorkerStatus};
// given — a worker that encounters a provider failure during session completion
let registry = WorkerRegistry::new();
let worker = registry.create("/tmp/repo-recovery-test", &[], true);
// Worker reaches ready state
registry
.observe(&worker.worker_id, "Ready for your input\n>")
.expect("ready observe should succeed");
registry
.send_prompt(&worker.worker_id, Some("Run analysis"))
.expect("prompt send should succeed");
// Session completes with provider failure (finish="unknown", tokens=0)
let failed_worker = registry
.observe_completion(&worker.worker_id, "unknown", 0)
.expect("completion observe should succeed");
assert_eq!(failed_worker.status, WorkerStatus::Failed);
let failure = failed_worker
.last_error
.expect("worker should have recorded error");
assert_eq!(failure.kind, WorkerFailureKind::Provider);
// Bridge: WorkerFailureKind -> FailureScenario
let scenario = FailureScenario::from_worker_failure_kind(failure.kind);
assert_eq!(scenario, FailureScenario::ProviderFailure);
// Recovery recipe lookup and execution
let mut ctx = RecoveryContext::new();
let result = attempt_recovery(&scenario, &mut ctx);
// then — recovery should recommend RestartWorker step
assert!(
matches!(result, RecoveryResult::Recovered { steps_taken: 1 }),
"provider failure should recover via single RestartWorker step, got: {result:?}"
);
assert!(
ctx.events().iter().any(|e| {
matches!(
e,
runtime::recovery_recipes::RecoveryEvent::RecoveryAttempted {
result: RecoveryResult::Recovered { steps_taken: 1 },
..
}
)
}),
"recovery should emit structured attempt event"
);
// Policy integration: recovery success + green status = merge-ready
// (Simulating the policy check that would happen after successful recovery)
let recovery_success = matches!(result, RecoveryResult::Recovered { .. });
let green_level = 3; // Workspace green
let not_stale = Duration::from_secs(30 * 60); // 30 min — fresh
let post_recovery_context = LaneContext::new(
"recovered-lane",
green_level,
not_stale,
LaneBlocker::None,
ReviewStatus::Approved,
DiffScope::Scoped,
false,
);
let policy_engine = PolicyEngine::new(vec![
// Rule: if recovered from failure + green + approved -> merge
PolicyRule::new(
"merge-after-successful-recovery",
PolicyCondition::And(vec![
PolicyCondition::GreenAt { level: 3 },
PolicyCondition::ReviewPassed,
]),
PolicyAction::MergeToDev,
10,
),
]);
// Recovery success is a pre-condition; policy evaluates post-recovery context
assert!(
recovery_success,
"recovery must succeed for lane to proceed"
);
let actions = policy_engine.evaluate(&post_recovery_context);
assert_eq!(
actions,
vec![PolicyAction::MergeToDev],
"post-recovery green+approved lane should be merge-ready"
);
}