use std::collections::{BTreeMap, BTreeSet};
use crate::ipc::IpcValue;
use crate::receipt::{CausalReceipt, ReceiptOutcome};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum DedupePolicy {
None,
SameIdempotencyKey,
SameCommandId,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct CommandPolicy {
pub dedupe: DedupePolicy,
pub supersede: bool,
pub cancel_on_preempt: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct CommandSubmit {
pub command_id: String,
pub causation_id: String,
pub source: String,
pub target: String,
pub namespace: String,
pub name: String,
pub authority_generation: u64,
pub idempotency_key: String,
pub deadline_ms: u64,
pub policy: CommandPolicy,
pub payload_type: String,
pub payload_hash: String,
pub payload: IpcValue,
pub required_features: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct CommandCancel {
pub command_id: String,
pub causation_id: String,
pub source: String,
pub authority_generation: u64,
pub reason: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum CommandEventKind {
Observed,
Accepted,
Started,
Progress,
Cancelled,
Superseded,
TimedOut,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct CommandEvent {
pub event_id: String,
pub command_id: String,
pub kind: CommandEventKind,
pub generation: u64,
pub detail: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct CommandEvents {
pub events: Vec<CommandEvent>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum CommandStatus {
Submitted,
Accepted,
Running,
Applied,
Rejected,
Cancelled,
Superseded,
TimedOut,
}
impl CommandStatus {
#[must_use]
pub const fn is_terminal(self) -> bool {
matches!(
self,
Self::Applied | Self::Rejected | Self::Cancelled | Self::Superseded | Self::TimedOut
)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct CommandProjectionEntry {
pub command_id: String,
pub status: CommandStatus,
pub terminal: bool,
pub generation: u64,
pub reason: Option<String>,
pub terminal_receipt_id: Option<String>,
pub last_event_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct CommandProjectionImage {
pub generation: u64,
pub commands: Vec<CommandProjectionEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum CommandMessage {
CommandSubmit(Box<CommandSubmit>),
CommandCancel(CommandCancel),
CommandEvents(CommandEvents),
CommandProjection(CommandProjectionImage),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CommandApplyStatus {
Recorded,
Duplicate,
Unknown,
StaleGeneration {
expected: u64,
actual: u64,
},
TerminalConflict {
command_id: String,
existing: CommandStatus,
incoming: CommandStatus,
},
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CommandProjection {
generation: u64,
entries: BTreeMap<String, CommandProjectionEntry>,
seen_event_ids: BTreeSet<String>,
seen_receipt_ids: BTreeSet<String>,
seen_cancel_ids: BTreeSet<String>,
conflicts: BTreeSet<String>,
}
fn terminal_status_of(outcome: ReceiptOutcome, reason: Option<&str>) -> CommandStatus {
match outcome {
ReceiptOutcome::Applied => CommandStatus::Applied,
ReceiptOutcome::Rejected => match reason {
Some("cancelled") => CommandStatus::Cancelled,
Some("superseded") => CommandStatus::Superseded,
Some("timed_out") => CommandStatus::TimedOut,
_ => CommandStatus::Rejected,
},
ReceiptOutcome::Observed | ReceiptOutcome::Accepted => CommandStatus::Accepted,
}
}
fn progress_status_of(kind: CommandEventKind) -> Option<CommandStatus> {
match kind {
CommandEventKind::Observed | CommandEventKind::Accepted => Some(CommandStatus::Accepted),
CommandEventKind::Started | CommandEventKind::Progress => Some(CommandStatus::Running),
CommandEventKind::Cancelled | CommandEventKind::Superseded | CommandEventKind::TimedOut => {
None
}
}
}
fn phase_rank(status: CommandStatus) -> u8 {
match status {
CommandStatus::Submitted => 0,
CommandStatus::Accepted => 1,
CommandStatus::Running => 2,
_ => 3, }
}
impl CommandProjection {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn generation(&self) -> u64 {
self.generation
}
pub fn apply_message(&mut self, message: &CommandMessage) -> CommandApplyStatus {
match message {
CommandMessage::CommandSubmit(s) => self.submit(s),
CommandMessage::CommandCancel(c) => self.cancel(c),
CommandMessage::CommandEvents(b) => {
let mut last = CommandApplyStatus::Unknown;
for event in &b.events {
last = self.event(event);
}
last
}
CommandMessage::CommandProjection(image) => self.apply_projection(image),
}
}
pub fn submit(&mut self, submit: &CommandSubmit) -> CommandApplyStatus {
if self.entries.contains_key(&submit.command_id) {
return CommandApplyStatus::Duplicate;
}
self.generation = self.generation.max(submit.authority_generation);
self.entries.insert(
submit.command_id.clone(),
CommandProjectionEntry {
command_id: submit.command_id.clone(),
status: CommandStatus::Submitted,
terminal: false,
generation: submit.authority_generation,
reason: None,
terminal_receipt_id: None,
last_event_id: None,
},
);
CommandApplyStatus::Recorded
}
pub fn event(&mut self, event: &CommandEvent) -> CommandApplyStatus {
if self.seen_event_ids.contains(&event.event_id) {
return CommandApplyStatus::Duplicate;
}
let Some(entry) = self.entries.get_mut(&event.command_id) else {
return CommandApplyStatus::Unknown;
};
if event.generation != entry.generation {
return CommandApplyStatus::StaleGeneration {
expected: entry.generation,
actual: event.generation,
};
}
self.seen_event_ids.insert(event.event_id.clone());
entry.last_event_id = Some(event.event_id.clone());
if !entry.terminal
&& let Some(next) = progress_status_of(event.kind)
&& phase_rank(next) >= phase_rank(entry.status)
{
entry.status = next;
}
CommandApplyStatus::Recorded
}
pub fn cancel(&mut self, cancel: &CommandCancel) -> CommandApplyStatus {
if self.seen_cancel_ids.contains(&cancel.causation_id) {
return CommandApplyStatus::Duplicate;
}
let Some(entry) = self.entries.get(&cancel.command_id) else {
return CommandApplyStatus::Unknown;
};
if cancel.authority_generation != entry.generation {
return CommandApplyStatus::StaleGeneration {
expected: entry.generation,
actual: cancel.authority_generation,
};
}
self.seen_cancel_ids.insert(cancel.causation_id.clone());
CommandApplyStatus::Recorded
}
pub fn observe_receipt(&mut self, receipt: &CausalReceipt) -> CommandApplyStatus {
if self.seen_receipt_ids.contains(&receipt.receipt_id) {
return CommandApplyStatus::Duplicate;
}
let Some(entry) = self.entries.get(&receipt.causation_id) else {
return CommandApplyStatus::Unknown;
};
if receipt.generation != entry.generation {
return CommandApplyStatus::StaleGeneration {
expected: entry.generation,
actual: receipt.generation,
};
}
if !receipt.outcome.is_terminal() {
self.seen_receipt_ids.insert(receipt.receipt_id.clone());
let entry = self
.entries
.get_mut(&receipt.causation_id)
.expect("present");
if !entry.terminal && phase_rank(CommandStatus::Accepted) >= phase_rank(entry.status) {
entry.status = CommandStatus::Accepted;
}
return CommandApplyStatus::Recorded;
}
let incoming = terminal_status_of(receipt.outcome, receipt.reason.as_deref());
if entry.terminal {
if entry.status == incoming {
self.seen_receipt_ids.insert(receipt.receipt_id.clone());
return CommandApplyStatus::Recorded;
}
let existing = entry.status;
self.conflicts.insert(receipt.causation_id.clone());
return CommandApplyStatus::TerminalConflict {
command_id: receipt.causation_id.clone(),
existing,
incoming,
};
}
self.seen_receipt_ids.insert(receipt.receipt_id.clone());
let entry = self
.entries
.get_mut(&receipt.causation_id)
.expect("present");
entry.terminal = true;
entry.status = incoming;
entry.reason = receipt.reason.clone();
entry.terminal_receipt_id = Some(receipt.receipt_id.clone());
CommandApplyStatus::Recorded
}
pub fn apply_projection(&mut self, image: &CommandProjectionImage) -> CommandApplyStatus {
self.generation = self.generation.max(image.generation);
for entry in &image.commands {
self.entries.insert(entry.command_id.clone(), entry.clone());
if let Some(ev) = &entry.last_event_id {
self.seen_event_ids.insert(ev.clone());
}
if let Some(rc) = &entry.terminal_receipt_id {
self.seen_receipt_ids.insert(rc.clone());
}
}
CommandApplyStatus::Recorded
}
#[must_use]
pub fn entry(&self, command_id: &str) -> Option<&CommandProjectionEntry> {
self.entries.get(command_id)
}
#[must_use]
pub fn terminal_for(&self, command_id: &str) -> Option<&CommandProjectionEntry> {
self.entries.get(command_id).filter(|e| e.terminal)
}
#[must_use]
pub fn has_conflict(&self, command_id: &str) -> bool {
self.conflicts.contains(command_id)
}
#[must_use]
pub fn to_image(&self) -> CommandProjectionImage {
CommandProjectionImage {
generation: self.generation,
commands: self.entries.values().cloned().collect(),
}
}
}
#[must_use]
pub fn applied_receipt(
receipt_id: impl Into<String>,
command_id: impl Into<String>,
observer: impl Into<String>,
generation: u64,
) -> CausalReceipt {
CausalReceipt::applied(receipt_id, command_id, observer, generation)
}
#[must_use]
pub fn rejected_receipt(
receipt_id: impl Into<String>,
command_id: impl Into<String>,
observer: impl Into<String>,
generation: u64,
reason: impl Into<String>,
) -> CausalReceipt {
CausalReceipt::rejected(receipt_id, command_id, observer, generation).with_reason(reason)
}
pub trait CommandTransport {
type Error;
fn send(&mut self, message: &CommandMessage) -> Result<(), Self::Error>;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CallState {
Pending,
Resolved(CommandProjectionEntry),
Conflict,
}
#[derive(Debug)]
pub struct CommandRpcClient<T: CommandTransport> {
transport: T,
projection: CommandProjection,
}
impl<T: CommandTransport> CommandRpcClient<T> {
pub fn new(transport: T) -> Self {
Self {
transport,
projection: CommandProjection::new(),
}
}
#[must_use]
pub fn projection(&self) -> &CommandProjection {
&self.projection
}
pub fn submit(&mut self, submit: CommandSubmit) -> Result<String, T::Error> {
let command_id = submit.command_id.clone();
let message = CommandMessage::CommandSubmit(Box::new(submit));
self.transport.send(&message)?;
self.projection.apply_message(&message);
Ok(command_id)
}
pub fn cancel(&mut self, cancel: CommandCancel) -> Result<(), T::Error> {
let message = CommandMessage::CommandCancel(cancel);
self.transport.send(&message)?;
self.projection.apply_message(&message);
Ok(())
}
pub fn ingest_command(&mut self, message: &CommandMessage) -> CommandApplyStatus {
self.projection.apply_message(message)
}
pub fn ingest_receipt(&mut self, receipt: &CausalReceipt) -> CommandApplyStatus {
self.projection.observe_receipt(receipt)
}
#[must_use]
pub fn poll_call(&self, command_id: &str) -> CallState {
if self.projection.has_conflict(command_id) {
return CallState::Conflict;
}
match self.projection.terminal_for(command_id) {
Some(entry) => CallState::Resolved(entry.clone()),
None => CallState::Pending,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn submit_fixture(command_id: &str, generation: u64) -> CommandSubmit {
CommandSubmit {
command_id: command_id.to_string(),
causation_id: command_id.to_string(),
source: "vscode-plugin".to_string(),
target: "project-controller".to_string(),
namespace: "agent-doc".to_string(),
name: "editor_route".to_string(),
authority_generation: generation,
idempotency_key: "project-root:plan.md:run".to_string(),
deadline_ms: 120_000,
policy: CommandPolicy {
dedupe: DedupePolicy::SameIdempotencyKey,
supersede: false,
cancel_on_preempt: true,
},
payload_type: "agent-doc.editor_route.v1".to_string(),
payload_hash: "sha256:deadbeef".to_string(),
payload: IpcValue::Inline(vec![1, 2, 3]),
required_features: vec!["causal-receipts".to_string()],
}
}
#[test]
fn command_status_terminality_is_explicit() {
assert!(!CommandStatus::Submitted.is_terminal());
assert!(!CommandStatus::Accepted.is_terminal());
assert!(!CommandStatus::Running.is_terminal());
assert!(CommandStatus::Applied.is_terminal());
assert!(CommandStatus::Cancelled.is_terminal());
assert!(CommandStatus::TimedOut.is_terminal());
}
#[test]
fn accepted_progress_is_not_terminal() {
let mut p = CommandProjection::new();
p.submit(&submit_fixture("cmd-1", 42));
p.event(&CommandEvent {
event_id: "ev-1".into(),
command_id: "cmd-1".into(),
kind: CommandEventKind::Accepted,
generation: 42,
detail: Some("queued".into()),
});
let entry = p.entry("cmd-1").unwrap();
assert!(!entry.terminal);
assert_eq!(entry.status, CommandStatus::Accepted);
assert!(p.terminal_for("cmd-1").is_none());
}
#[test]
fn applied_receipt_makes_command_terminal() {
let mut p = CommandProjection::new();
p.submit(&submit_fixture("cmd-1", 42));
assert_eq!(
p.observe_receipt(&applied_receipt(
"rcpt-1",
"cmd-1",
"project-controller",
42
)),
CommandApplyStatus::Recorded
);
let entry = p.terminal_for("cmd-1").unwrap();
assert_eq!(entry.status, CommandStatus::Applied);
assert_eq!(entry.terminal_receipt_id.as_deref(), Some("rcpt-1"));
}
#[test]
fn stale_generation_receipt_is_ignored() {
let mut p = CommandProjection::new();
p.submit(&submit_fixture("cmd-1", 42));
assert_eq!(
p.observe_receipt(&applied_receipt(
"rcpt-old",
"cmd-1",
"project-controller",
41
)),
CommandApplyStatus::StaleGeneration {
expected: 42,
actual: 41
}
);
assert!(p.terminal_for("cmd-1").is_none());
}
#[test]
fn duplicate_submit_is_idempotent() {
let mut p = CommandProjection::new();
assert_eq!(
p.submit(&submit_fixture("cmd-1", 42)),
CommandApplyStatus::Recorded
);
assert_eq!(
p.submit(&submit_fixture("cmd-1", 99)),
CommandApplyStatus::Duplicate
);
assert_eq!(p.entry("cmd-1").unwrap().generation, 42);
}
#[test]
fn cancel_before_terminal_then_rejected_receipt_is_cancelled() {
let mut p = CommandProjection::new();
p.submit(&submit_fixture("cmd-1", 42));
p.event(&CommandEvent {
event_id: "ev-1".into(),
command_id: "cmd-1".into(),
kind: CommandEventKind::Accepted,
generation: 42,
detail: None,
});
p.cancel(&CommandCancel {
command_id: "cmd-1".into(),
causation_id: "cancel-1".into(),
source: "vscode-plugin".into(),
authority_generation: 42,
reason: Some("operator cleared run".into()),
});
p.observe_receipt(&rejected_receipt(
"rcpt-cancel",
"cmd-1",
"project-controller",
42,
"cancelled",
));
let entry = p.terminal_for("cmd-1").unwrap();
assert_eq!(entry.status, CommandStatus::Cancelled);
assert_eq!(entry.reason.as_deref(), Some("cancelled"));
}
#[test]
fn cancel_after_applied_does_not_override() {
let mut p = CommandProjection::new();
p.submit(&submit_fixture("cmd-1", 42));
p.observe_receipt(&applied_receipt(
"rcpt-applied",
"cmd-1",
"project-controller",
42,
));
p.cancel(&CommandCancel {
command_id: "cmd-1".into(),
causation_id: "cancel-late".into(),
source: "vscode-plugin".into(),
authority_generation: 42,
reason: Some("too late".into()),
});
assert_eq!(
p.terminal_for("cmd-1").unwrap().status,
CommandStatus::Applied
);
}
#[test]
fn conflicting_terminal_receipts_fail_closed() {
let mut p = CommandProjection::new();
p.submit(&submit_fixture("cmd-1", 42));
p.observe_receipt(&applied_receipt(
"rcpt-applied",
"cmd-1",
"project-controller",
42,
));
let status = p.observe_receipt(&rejected_receipt(
"rcpt-rejected",
"cmd-1",
"project-controller",
42,
"conflicting terminal",
));
assert!(matches!(
status,
CommandApplyStatus::TerminalConflict { .. }
));
assert!(p.has_conflict("cmd-1"));
assert_eq!(p.entry("cmd-1").unwrap().status, CommandStatus::Applied);
}
#[test]
fn reconnect_projection_is_fold_equivalent() {
let mut source = CommandProjection::new();
source.submit(&submit_fixture("cmd-1", 43));
source.observe_receipt(&applied_receipt(
"rcpt-1",
"cmd-1",
"project-controller",
43,
));
let image = source.to_image();
let mut reconnected = CommandProjection::new();
reconnected.apply_projection(&image);
assert_eq!(reconnected.to_image(), image);
assert_eq!(
reconnected.terminal_for("cmd-1").unwrap().status,
CommandStatus::Applied
);
}
struct VecTransport {
sent: Vec<CommandMessage>,
}
impl CommandTransport for VecTransport {
type Error = ();
fn send(&mut self, message: &CommandMessage) -> Result<(), ()> {
self.sent.push(message.clone());
Ok(())
}
}
#[test]
fn rpc_call_resolves_only_on_terminal_receipt() {
let mut client = CommandRpcClient::new(VecTransport { sent: Vec::new() });
let id = client.submit(submit_fixture("cmd-1", 42)).unwrap();
client.ingest_command(&CommandMessage::CommandEvents(CommandEvents {
events: vec![
CommandEvent {
event_id: "ev-1".into(),
command_id: id.clone(),
kind: CommandEventKind::Accepted,
generation: 42,
detail: Some("queued".into()),
},
CommandEvent {
event_id: "ev-2".into(),
command_id: id.clone(),
kind: CommandEventKind::Started,
generation: 42,
detail: None,
},
],
}));
assert_eq!(client.poll_call(&id), CallState::Pending);
client.ingest_receipt(&applied_receipt("rcpt-1", &id, "project-controller", 42));
match client.poll_call(&id) {
CallState::Resolved(entry) => assert_eq!(entry.status, CommandStatus::Applied),
other => panic!("expected Resolved, got {other:?}"),
}
}
}