use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use super::{MessageType, RelayMessage, epoch_ms, gen_msg_id};
const MAX_FILES_PAYLOAD: usize = 50 * 1024;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DelegationContext {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub git_remote: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub git_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub git_commit: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub relevant_files: HashMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub brain_context: Option<BrainContextSummary>,
#[serde(default)]
pub dependency_graph: DependencyGraph,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BrainContextSummary {
pub project_preferences: String,
#[serde(default)]
pub recent_insights: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DependencyGraph {
#[serde(default)]
pub blocks: Vec<String>,
#[serde(default)]
pub blocked_by: Vec<String>,
}
impl DelegationContext {
pub fn files_payload_size(&self) -> usize {
self.relevant_files.values().map(|v| v.len()).sum()
}
pub fn validate(&self) -> Result<(), String> {
let size = self.files_payload_size();
if size > MAX_FILES_PAYLOAD {
return Err(format!(
"relevant_files payload too large: {size} bytes (max {MAX_FILES_PAYLOAD})"
));
}
Ok(())
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TaskStats {
#[serde(default)]
pub tokens_used: u64,
#[serde(default)]
pub cost_usd: f64,
#[serde(default)]
pub context_pct: u8,
#[serde(default)]
pub files_modified: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub elapsed_secs: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_activity: Option<String>,
}
pub fn build_delegate_message(
task_id: &str,
prompt: &str,
cwd: Option<&str>,
context: &DelegationContext,
identity: &str,
) -> Result<RelayMessage, String> {
context.validate()?;
Ok(RelayMessage {
id: gen_msg_id(),
msg_type: MessageType::DelegateTask,
from_peer: identity.to_string(),
timestamp: epoch_ms(),
payload: serde_json::json!({
"task_id": task_id,
"prompt": prompt,
"cwd": cwd,
"context": context,
}),
})
}
pub fn parse_delegate_message(
msg: &RelayMessage,
) -> Result<(String, String, Option<String>, DelegationContext), String> {
let task_id = msg
.payload
.get("task_id")
.and_then(|v| v.as_str())
.ok_or("missing task_id")?
.to_string();
let prompt = msg
.payload
.get("prompt")
.and_then(|v| v.as_str())
.ok_or("missing prompt")?
.to_string();
let cwd = msg
.payload
.get("cwd")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let context: DelegationContext = msg
.payload
.get("context")
.map(|v| serde_json::from_value(v.clone()).unwrap_or_default())
.unwrap_or_default();
Ok((task_id, prompt, cwd, context))
}
pub fn build_status_message(
task_id: &str,
state: &str,
stats: &TaskStats,
identity: &str,
) -> RelayMessage {
RelayMessage {
id: gen_msg_id(),
msg_type: MessageType::TaskStatus,
from_peer: identity.to_string(),
timestamp: epoch_ms(),
payload: serde_json::json!({
"task_id": task_id,
"state": state,
"stats": stats,
}),
}
}
pub fn parse_status_message(msg: &RelayMessage) -> Result<(String, String, TaskStats), String> {
let task_id = msg
.payload
.get("task_id")
.and_then(|v| v.as_str())
.ok_or("missing task_id")?
.to_string();
let state = msg
.payload
.get("state")
.and_then(|v| v.as_str())
.ok_or("missing state")?
.to_string();
let stats: TaskStats = msg
.payload
.get("stats")
.map(|v| serde_json::from_value(v.clone()).unwrap_or_default())
.unwrap_or_default();
Ok((task_id, state, stats))
}
pub fn build_handoff_message(
task_id: &str,
summary: &str,
artifacts: &[String],
git_ref: Option<&str>,
total_cost_usd: f64,
total_tokens: u64,
identity: &str,
) -> RelayMessage {
RelayMessage {
id: gen_msg_id(),
msg_type: MessageType::TaskHandoff,
from_peer: identity.to_string(),
timestamp: epoch_ms(),
payload: serde_json::json!({
"task_id": task_id,
"state": "completed",
"summary": summary,
"artifacts": artifacts,
"git_ref": git_ref,
"total_cost_usd": total_cost_usd,
"total_tokens": total_tokens,
}),
}
}
pub fn build_failure_message(
task_id: &str,
reason: &str,
total_cost_usd: f64,
total_tokens: u64,
identity: &str,
) -> RelayMessage {
RelayMessage {
id: gen_msg_id(),
msg_type: MessageType::TaskHandoff,
from_peer: identity.to_string(),
timestamp: epoch_ms(),
payload: serde_json::json!({
"task_id": task_id,
"state": "failed",
"summary": reason,
"artifacts": [],
"total_cost_usd": total_cost_usd,
"total_tokens": total_tokens,
}),
}
}
pub fn build_interrupt_message(
task_id: &str,
interrupt_type: &str,
reason: &str,
identity: &str,
) -> RelayMessage {
RelayMessage {
id: gen_msg_id(),
msg_type: MessageType::TaskInterrupt,
from_peer: identity.to_string(),
timestamp: epoch_ms(),
payload: serde_json::json!({
"task_id": task_id,
"interrupt_type": interrupt_type,
"reason": reason,
}),
}
}
pub fn parse_interrupt_message(msg: &RelayMessage) -> Result<(String, String, String), String> {
let task_id = msg
.payload
.get("task_id")
.and_then(|v| v.as_str())
.ok_or("missing task_id")?
.to_string();
let interrupt_type = msg
.payload
.get("interrupt_type")
.and_then(|v| v.as_str())
.ok_or("missing interrupt_type")?
.to_string();
let reason = msg
.payload
.get("reason")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
Ok((task_id, interrupt_type, reason))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn delegate_message_roundtrip() {
let ctx = DelegationContext {
git_remote: Some("git@github.com:team/project.git".into()),
git_ref: Some("feat/auth".into()),
..Default::default()
};
let msg = build_delegate_message("t_1", "Fix the tests", Some("/project"), &ctx, "peer-a")
.unwrap();
assert_eq!(msg.msg_type, MessageType::DelegateTask);
let (task_id, prompt, cwd, parsed_ctx) = parse_delegate_message(&msg).unwrap();
assert_eq!(task_id, "t_1");
assert_eq!(prompt, "Fix the tests");
assert_eq!(cwd.as_deref(), Some("/project"));
assert_eq!(
parsed_ctx.git_remote.as_deref(),
Some("git@github.com:team/project.git")
);
assert_eq!(parsed_ctx.git_ref.as_deref(), Some("feat/auth"));
}
#[test]
fn delegate_rejects_oversized_files() {
let mut files = HashMap::new();
files.insert("big.txt".into(), "x".repeat(60_000));
let ctx = DelegationContext {
relevant_files: files,
..Default::default()
};
assert!(ctx.validate().is_err());
}
#[test]
fn status_message_roundtrip() {
let stats = TaskStats {
tokens_used: 8000,
cost_usd: 0.42,
context_pct: 35,
files_modified: vec!["src/auth.rs".into()],
..Default::default()
};
let msg = build_status_message("t_1", "running", &stats, "peer-b");
let (task_id, state, parsed_stats) = parse_status_message(&msg).unwrap();
assert_eq!(task_id, "t_1");
assert_eq!(state, "running");
assert_eq!(parsed_stats.tokens_used, 8000);
assert_eq!(parsed_stats.context_pct, 35);
}
#[test]
fn handoff_message_fields() {
let msg = build_handoff_message(
"t_1",
"Tests pass",
&["src/auth.rs".into()],
Some("feat/done"),
1.23,
50000,
"peer-b",
);
assert_eq!(msg.msg_type, MessageType::TaskHandoff);
assert_eq!(
msg.payload.get("summary").and_then(|v| v.as_str()),
Some("Tests pass")
);
assert_eq!(
msg.payload.get("git_ref").and_then(|v| v.as_str()),
Some("feat/done")
);
}
#[test]
fn interrupt_message_roundtrip() {
let msg = build_interrupt_message("t_1", "nudge", "dependency resolved", "peer-a");
let (task_id, itype, reason) = parse_interrupt_message(&msg).unwrap();
assert_eq!(task_id, "t_1");
assert_eq!(itype, "nudge");
assert_eq!(reason, "dependency resolved");
}
#[test]
fn failure_message_fields() {
let msg = build_failure_message("t_2", "exit code 1", 0.15, 3000, "peer-b");
assert_eq!(
msg.payload.get("state").and_then(|v| v.as_str()),
Some("failed")
);
assert_eq!(
msg.payload.get("summary").and_then(|v| v.as_str()),
Some("exit code 1")
);
}
#[test]
fn default_context_validates() {
let ctx = DelegationContext::default();
assert!(ctx.validate().is_ok());
}
}