1use std::collections::{BTreeMap, BTreeSet};
12
13use crate::budget_cascade::{BudgetAllocation, CascadeError, cascade_budget, validate_cascade};
14use serde::{Deserialize, Serialize};
15
16pub const WORK_GRAPH_SCHEMA_VERSION: u16 = 1;
17pub const MAX_DELIVERY_ATTEMPTS: u8 = 2;
20const MAX_GRAPH_NODES: usize = 256;
21const MAX_FAN_OUT: usize = 16;
22const MAX_DEPTH: u16 = 8;
23const MAX_LOCAL_CONCURRENCY: usize = 32;
24const MAX_IDENTIFIER_BYTES: usize = 128;
25const MAX_RESULT_CLAIMS: usize = 64;
26const MAX_PATH_CLAIMS: usize = 64;
27
28fn default_local_concurrency() -> usize {
29 MAX_FAN_OUT
30}
31
32#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34pub enum ChildOutcome {
35 Accepted,
36 Rejected,
37 Partial,
38}
39
40#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
41pub struct ResultClaim {
42 pub key: String,
43 pub value: String,
44}
45
46#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
47pub struct ChildExecutionReceipt {
48 pub receipt_id: String,
49 pub node_id: String,
50 pub outcome_ref: String,
51 pub outcome: ChildOutcome,
52 pub tokens_consumed: u64,
53 pub cost_micros_consumed: u64,
54 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub execution_fence: Option<String>,
57 #[serde(default)]
58 pub claims: Vec<ResultClaim>,
59 #[serde(default, skip_serializing_if = "UsageSource::is_measured")]
61 pub usage_source: UsageSource,
62}
63
64#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
68#[serde(rename_all = "snake_case")]
69pub enum UsageSource {
70 #[default]
71 Measured,
72 Reported,
73}
74
75impl UsageSource {
76 fn is_measured(&self) -> bool {
77 *self == Self::Measured
78 }
79}
80
81#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
86pub struct MeasuredExecutionSpend {
87 pub spend_id: String,
88 pub node_id: String,
89 pub outcome_refs: Vec<String>,
90 pub tokens_consumed: u64,
91 pub cost_micros_consumed: u64,
92 pub execution_fence: String,
93}
94
95impl MeasuredExecutionSpend {
96 pub fn new(
97 node_id: String,
98 outcome_refs: Vec<String>,
99 tokens_consumed: u64,
100 cost_micros_consumed: u64,
101 execution_fence: String,
102 ) -> Self {
103 let spend_id = measured_spend_id(&outcome_refs);
104 Self {
105 spend_id,
106 node_id,
107 outcome_refs,
108 tokens_consumed,
109 cost_micros_consumed,
110 execution_fence,
111 }
112 }
113}
114
115#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
116pub struct ResultConflict {
117 pub key: String,
118 pub values: Vec<String>,
119 pub node_ids: Vec<String>,
120}
121
122#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
123pub struct FusedResults {
124 pub accepted: BTreeMap<String, String>,
125 pub conflicts: Vec<ResultConflict>,
126}
127
128#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
129pub struct WorkAttribution {
130 pub accepted_tokens: u64,
131 pub accepted_cost_micros: u64,
132 pub waste_tokens: u64,
133 pub waste_cost_micros: u64,
134}
135
136#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
137pub struct TeamValueReport {
138 pub accepted_tokens: u64,
139 pub total_tokens: u64,
140 pub accepted_cost_micros: u64,
141 pub total_cost_micros: u64,
142 pub waste_tokens: u64,
143 pub waste_cost_micros: u64,
144 pub accepted_cost_basis_points: u16,
145}
146
147#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
148#[serde(rename_all = "snake_case")]
149pub enum NodeStatus {
150 Pending,
151 Active,
152 Completed,
153 Stopped,
154 Failed,
155}
156
157#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
158#[serde(rename_all = "snake_case")]
159pub enum StopReason {
160 BudgetExhausted,
161 Stale,
162 Redundant,
163 ParentStopped,
164 ManualStop,
165 DepthExceeded,
166 FanOutExceeded,
167 PolicyDenied,
168 LeaseLost,
169 Duplicate,
170 LowValue,
171 ExecutionFailed,
172}
173
174#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
175pub struct WorkNodeBudget {
176 pub tokens_allocated: u64,
177 pub tokens_consumed: u64,
178 pub cost_micros_allocated: u64,
179 pub cost_micros_consumed: u64,
180}
181
182impl WorkNodeBudget {
183 pub fn tokens_remaining(&self) -> u64 {
184 self.tokens_allocated.saturating_sub(self.tokens_consumed)
185 }
186
187 pub fn cost_remaining(&self) -> u64 {
188 self.cost_micros_allocated
189 .saturating_sub(self.cost_micros_consumed)
190 }
191
192 pub fn is_exhausted(&self) -> bool {
193 self.tokens_remaining() == 0 || self.cost_remaining() == 0
194 }
195}
196
197#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
199pub struct ChainBudget {
200 pub chain_id: String,
201 pub root_budget_tokens: u64,
202 pub total_consumed_tokens: u64,
203 pub total_allocated_tokens: u64,
204 #[serde(default)]
205 pub root_budget_cost_micros: u64,
206 #[serde(default)]
207 pub total_consumed_cost_micros: u64,
208 #[serde(default)]
209 pub total_allocated_cost_micros: u64,
210 #[serde(default)]
212 pub unauthorized_tokens: u64,
213 #[serde(default)]
214 pub unauthorized_cost_micros: u64,
215 pub depth: u16,
216}
217
218impl ChainBudget {
219 pub fn remaining(&self) -> u64 {
220 self.root_budget_tokens
221 .saturating_sub(self.total_consumed_tokens)
222 .saturating_sub(self.unauthorized_tokens)
223 }
224
225 pub fn utilization_pct(&self) -> f64 {
226 if self.root_budget_tokens == 0 {
227 return 0.0;
228 }
229
230 (self
231 .total_consumed_tokens
232 .saturating_add(self.unauthorized_tokens) as f64
233 / self.root_budget_tokens as f64)
234 * 100.0
235 }
236}
237
238#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
239pub struct WorkNode {
240 pub node_id: String,
241 pub agent_id: String,
242 pub parent_node_id: Option<String>,
243 pub capsule_ref: String,
244 pub status: NodeStatus,
245 pub budget: WorkNodeBudget,
246 pub depth: u16,
247 pub stop_reason: Option<StopReason>,
248 pub outcome_ref: Option<String>,
249 #[serde(default)]
250 pub claim_attempt: u32,
251 #[serde(default, skip_serializing_if = "Option::is_none")]
253 pub delivery_attempt: Option<u8>,
254 #[serde(default, skip_serializing_if = "Option::is_none")]
255 pub execution_fence: Option<String>,
256 #[serde(default, skip_serializing_if = "Option::is_none")]
257 pub lease_expires_epoch_ms: Option<u64>,
258 #[serde(default)]
259 pub path_claims: Vec<String>,
260 #[serde(default)]
261 pub task_ref: String,
262 #[serde(default, skip_serializing_if = "Option::is_none")]
263 pub parent_task_ref: Option<String>,
264 #[serde(default)]
265 pub policy_ref: String,
266 #[serde(default)]
267 pub expected_outcome_ref: String,
268 #[serde(default)]
269 pub execution_started: bool,
270}
271
272#[derive(Clone, Debug, Serialize, Deserialize)]
274pub struct BoundedWorkGraph {
275 #[serde(default)]
276 graph_id: String,
277 nodes: BTreeMap<String, WorkNode>,
278 children: BTreeMap<String, Vec<String>>,
279 #[serde(default)]
280 chain_budgets: BTreeMap<String, ChainBudget>,
281 #[serde(default)]
282 pending_child_budgets: BTreeMap<String, WorkNodeBudget>,
283 #[serde(default)]
284 pending_child_parents: BTreeMap<String, String>,
285 #[serde(default)]
286 receipts: BTreeMap<String, ChildExecutionReceipt>,
287 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
288 failed_spend: BTreeMap<String, MeasuredExecutionSpend>,
289 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
290 reaped_executions: BTreeMap<String, String>,
291 #[serde(default)]
292 accepted_path: BTreeSet<String>,
293 #[serde(default = "default_local_concurrency")]
294 max_local_concurrency: usize,
295 max_fan_out: usize,
296 max_depth: u16,
297}
298
299impl Default for BoundedWorkGraph {
300 fn default() -> Self {
301 Self::new(MAX_FAN_OUT, MAX_DEPTH)
302 }
303}
304
305mod control_ops;
306mod graph_ops;
307
308fn execution_fence(graph_id: &str, node: &WorkNode) -> String {
309 let material = format!(
310 "{}\0{}\0{}\0{}\0{}",
311 graph_id, node.node_id, node.agent_id, node.capsule_ref, node.claim_attempt
312 );
313 format!("fence:{}", blake3::hash(material.as_bytes()).to_hex())
314}
315
316pub fn path_claims_overlap(left: &str, right: &str) -> bool {
317 left == right
318 || left
319 .strip_prefix(right)
320 .is_some_and(|suffix| suffix.starts_with('/'))
321 || right
322 .strip_prefix(left)
323 .is_some_and(|suffix| suffix.starts_with('/'))
324}
325
326fn validate_identifier(value: &str, field: &'static str) -> Result<(), WorkGraphError> {
327 if value.is_empty() || value.len() > MAX_IDENTIFIER_BYTES || value.chars().any(char::is_control)
328 {
329 return Err(WorkGraphError::InvalidIdentifier(field));
330 }
331 Ok(())
332}
333
334fn validate_budget(budget: &WorkNodeBudget) -> Result<(), WorkGraphError> {
335 if budget.tokens_consumed > budget.tokens_allocated
336 || budget.cost_micros_consumed > budget.cost_micros_allocated
337 {
338 return Err(WorkGraphError::InvalidBudget);
339 }
340 Ok(())
341}
342
343fn validate_receipt(receipt: &ChildExecutionReceipt) -> Result<(), WorkGraphError> {
344 validate_identifier(&receipt.receipt_id, "receipt_id")?;
345 validate_identifier(&receipt.node_id, "node_id")?;
346 validate_identifier(&receipt.outcome_ref, "outcome_ref")?;
347 if let Some(fence) = receipt.execution_fence.as_deref()
348 && (fence.len() != 70
349 || !fence.starts_with("fence:")
350 || !fence[6..]
351 .bytes()
352 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)))
353 {
354 return Err(WorkGraphError::InvalidIdentifier("execution_fence"));
355 }
356 if receipt.claims.len() > MAX_RESULT_CLAIMS {
357 return Err(WorkGraphError::TooManyClaims);
358 }
359 let mut keys = BTreeSet::new();
360 for claim in &receipt.claims {
361 validate_identifier(&claim.key, "claim_key")?;
362 validate_identifier(&claim.value, "claim_value")?;
363 if !keys.insert(&claim.key) {
364 return Err(WorkGraphError::DuplicateClaim(claim.key.clone()));
365 }
366 }
367 Ok(())
368}
369
370fn validate_measured_spend(spend: &MeasuredExecutionSpend) -> Result<(), WorkGraphError> {
371 validate_identifier(&spend.spend_id, "spend_id")?;
372 validate_identifier(&spend.node_id, "node_id")?;
373 if spend.outcome_refs.is_empty() || spend.outcome_refs.len() > 2 {
374 return Err(WorkGraphError::InvalidGraph(
375 "failed spend must reference one or two receipts".into(),
376 ));
377 }
378 let mut outcome_refs = BTreeSet::new();
379 for outcome_ref in &spend.outcome_refs {
380 validate_identifier(outcome_ref, "outcome_ref")?;
381 let digest = outcome_ref
382 .strip_prefix("id:sha256:")
383 .filter(|digest| {
384 digest.len() == 64
385 && digest
386 .bytes()
387 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
388 })
389 .ok_or(WorkGraphError::InvalidIdentifier("outcome_ref"))?;
390 if !outcome_refs.insert(digest) {
391 return Err(WorkGraphError::InvalidGraph(
392 "duplicate failed spend receipt".into(),
393 ));
394 }
395 }
396 if spend.spend_id != measured_spend_id(&spend.outcome_refs) {
397 return Err(WorkGraphError::InvalidIdentifier("spend_id"));
398 }
399 if spend.execution_fence.len() != 70
400 || !spend.execution_fence.starts_with("fence:")
401 || !spend.execution_fence[6..]
402 .bytes()
403 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
404 {
405 return Err(WorkGraphError::InvalidIdentifier("execution_fence"));
406 }
407 Ok(())
408}
409
410fn measured_spend_id(outcome_refs: &[String]) -> String {
411 format!(
412 "spend:{}",
413 blake3::hash(outcome_refs.join("\0").as_bytes()).to_hex()
414 )
415}
416
417fn validate_path_claim(value: &str) -> Result<(), WorkGraphError> {
418 let relative = value
419 .strip_prefix("path:")
420 .ok_or(WorkGraphError::InvalidPathClaim)?;
421 if relative.is_empty()
422 || relative.len() > 1_024
423 || relative.starts_with('/')
424 || relative.starts_with('\\')
425 || relative
426 .split(['/', '\\'])
427 .any(|part| part.is_empty() || part == "." || part == "..")
428 || relative.chars().any(char::is_control)
429 {
430 return Err(WorkGraphError::InvalidPathClaim);
431 }
432 Ok(())
433}
434
435#[derive(Debug, thiserror::Error)]
438pub enum WorkGraphError {
439 #[error("graph at capacity ({MAX_GRAPH_NODES} nodes)")]
440 CapacityExceeded,
441 #[error("duplicate node: {0}")]
442 DuplicateNode(String),
443 #[error("node not found: {0}")]
444 NodeNotFound(String),
445 #[error("parent not active: {0}")]
446 ParentNotActive(String),
447 #[error("depth exceeds max {0}")]
448 DepthExceeded(u16),
449 #[error("fan-out exceeds max {0}")]
450 FanOutExceeded(usize),
451 #[error("child budget ({child_requested}) exceeds parent remaining ({parent_remaining})")]
452 BudgetExceedsParent {
453 child_requested: u64,
454 parent_remaining: u64,
455 },
456 #[error(
457 "invalid status transition for node: {0} (only a pending child of an active parent can \
458 be claimed; completed, failed and cancelled nodes are final — inspect with action=get)"
459 )]
460 InvalidTransition(String),
461 #[error(
462 "local concurrency exceeds max {0} (finish an active node with complete or receipt, \
463 or cancel one, first)"
464 )]
465 LocalConcurrencyExceeded(usize),
466 #[error("invalid or oversized {0}")]
467 InvalidIdentifier(&'static str),
468 #[error("consumed budget exceeds allocation")]
469 InvalidBudget,
470 #[error("duplicate execution receipt: {0}")]
471 DuplicateReceipt(String),
472 #[error("execution receipt exceeds node or chain budget: {0}")]
473 ReceiptExceedsBudget(String),
474 #[error("missing execution receipt for accepted node: {0}")]
475 MissingReceipt(String),
476 #[error("unaccepted node cannot establish an accepted path: {0}")]
477 RejectedAcceptedPath(String),
478 #[error("execution receipt has too many result claims")]
479 TooManyClaims,
480 #[error("node has too many path claims")]
481 TooManyPathClaims,
482 #[error("invalid project-relative path claim")]
483 InvalidPathClaim,
484 #[error("duplicate result claim key: {0}")]
485 DuplicateClaim(String),
486 #[error("work graph accounting overflow")]
487 Overflow,
488 #[error("stale or missing execution fence for node: {0}")]
489 StaleExecutionFence(String),
490 #[error("invalid work graph: {0}")]
491 InvalidGraph(String),
492 #[error("budget cascade error: {0}")]
493 Cascade(#[from] CascadeError),
494}
495
496#[cfg(test)]
499pub mod tests;