1use serde_json::json;
3
4use lean_ctx::core::work_graph::{
5 BoundedWorkGraph, ChildExecutionReceipt, ChildOutcome, ResultClaim, StopReason, UsageSource,
6 WorkNodeBudget,
7};
8use lean_ctx::core::work_graph_store::{ClaimNodeExecution, WorkGraphStore, validate_id};
9
10#[derive(Clone, Copy)]
11pub struct Request<'a> {
12 pub action: &'a str,
13 pub project_root: &'a str,
14 pub agent_id: &'a str,
15 pub graph_id: &'a str,
16 pub if_revision: Option<&'a str>,
17 pub node_id: Option<&'a str>,
18 pub parent_node_id: Option<&'a str>,
19 pub to_agent: Option<&'a str>,
20 pub capsule_ref: Option<&'a str>,
21 pub outcome_ref: Option<&'a str>,
22 pub tokens: Option<u64>,
23 pub cost_micros: Option<u64>,
24 pub max_concurrency: Option<usize>,
25 pub receipt_id: Option<&'a str>,
26 pub execution_fence: Option<&'a str>,
27 pub outcome: Option<&'a str>,
28 pub claims: Option<&'a serde_json::Value>,
29 pub accepted_nodes: Option<&'a serde_json::Value>,
30 pub stop_reason: Option<&'a str>,
31 pub timeout_ms: Option<u64>,
32 pub path_claims: Option<&'a serde_json::Value>,
33 pub task_ref: Option<&'a str>,
34 pub policy_ref: Option<&'a str>,
35 pub expected_outcome_ref: Option<&'a str>,
36}
37
38pub fn handle(request: Request<'_>) -> Result<String, String> {
39 if request.action == "execute" {
40 return Err(EXECUTE_REMOVED.to_string());
41 }
42 if request.agent_id.is_empty() {
43 return Err("agent must be registered first via ctx_agent".to_string());
44 }
45 ensure_active_agent(request.project_root, request.agent_id)?;
46 if request.action != "list" {
47 validate_id(request.graph_id, "graph_id")?;
48 }
49 check_required_fields(&request)?;
50 if request.if_revision.is_some()
51 && matches!(
52 request.action,
53 "create" | "fuse" | "attribution" | "value_report" | "get" | "list"
54 )
55 {
56 return Err(format!(
57 "if_revision applies to observe and to mutating actions, not to {}",
58 request.action
59 ));
60 }
61 match request.action {
62 "create" => create(&request),
63 "delegate" => delegate(&request),
64 "claim" => claim(&request),
65 "start" => start(&request),
66 "consume" => consume(&request),
67 "complete" => complete(&request),
68 "receipt" => receipt(&request),
69 "accept" => accept(&request),
70 "fuse" => fuse(&request),
71 "attribution" => attribution(&request),
72 "value_report" => value_report(&request),
73 "cancel" => cancel(&request),
74 "get" => get(&request),
75 "observe" => observe(&request),
76 "list" => list(request.project_root, request.agent_id),
77 other => Err(format!("unknown work graph action: {other}")),
78 }
79}
80
81const EXECUTE_REMOVED: &str = "execute was removed: LeanCTX no longer launches agents. Launch the \
83 child agent yourself and bind it to the node with action=start (returns the launch \
84 environment); finish with action=receipt or stop it with action=cancel";
85
86pub fn start(request: &Request<'_>) -> Result<String, String> {
92 use lean_ctx::core::work_graph_delegation as delegation;
93
94 if let Some(policy) =
95 lean_ctx::core::policy::runtime::for_project(std::path::Path::new(request.project_root))?
96 && !policy.tool_allowed("ctx_work_graph")
97 {
98 return Err("project policy denies Work Graph execution".into());
99 }
100 let node_id = required(request.node_id, "node_id")?;
101 let window_ms = delegation::start_window_ms(
102 request
103 .timeout_ms
104 .unwrap_or(delegation::DEFAULT_START_WINDOW_MS),
105 )?;
106 let issuer = delegation::DeliveryIssuerConfig::from_environment()?;
108 if let Some(issuer) = &issuer
112 && issuer.host_agent() != request.agent_id
113 {
114 return Err(format!(
115 "{} belongs to host agent {}; it must not reach agent {}",
116 delegation::ISSUER_ENV,
117 issuer.host_agent(),
118 request.agent_id
119 ));
120 }
121 let now_epoch_ms = epoch_ms()?;
122 let lease_expires_epoch_ms = now_epoch_ms
123 .checked_add(window_ms)
124 .ok_or_else(|| "execution lease expiry overflow".to_string())?;
125 let claimed = mutate_store(request, |store| {
126 let graph = store.graph_mut(request.graph_id)?;
127 authorize_node_or_parent(graph, node_id, request.agent_id)?;
128 let claim = store.claim_node_execution(
129 request.graph_id,
130 node_id,
131 now_epoch_ms,
132 lease_expires_epoch_ms,
133 )?;
134 let ClaimNodeExecution::Claimed(node) = claim else {
135 return Ok(None);
136 };
137 let graph = store.graph(request.graph_id).expect("graph checked above");
138 let root_agent = graph
139 .root_agent_for(node_id)
140 .map_err(|error| error.to_string())?
141 .to_string();
142 let parent_capsule_ref = node
143 .parent_node_id
144 .as_deref()
145 .and_then(|parent| graph.get_node(parent))
146 .map(|parent| parent.capsule_ref.clone());
147 Ok(Some((*node, root_agent, parent_capsule_ref)))
148 })?;
149 let Some((node, root_agent, parent_capsule_ref)) = claimed else {
150 return Err("expired execution was stopped and cannot be reclaimed".into());
151 };
152 let fence = node
153 .execution_fence
154 .clone()
155 .ok_or_else(|| "claim did not produce execution fence".to_string())?;
156 let acquired = incarnation_leases(request.project_root, request.graph_id, &node, &fence)
161 .and_then(|leases| {
162 let remaining_ms = lease_expires_epoch_ms.saturating_sub(epoch_ms()?);
163 if remaining_ms == 0 {
164 return Err("execution window elapsed before its leases were acquired".into());
165 }
166 acquire_incarnation_leases(&node.agent_id, &leases, remaining_ms)
167 });
168 if let Err(error) = acquired {
169 abort_start(
170 request,
171 node_id,
172 &node.agent_id,
173 &fence,
174 StopReason::LeaseLost,
175 )?;
176 return Err(error);
177 }
178 let plan = delegation::StartPlan {
179 graph_id: request.graph_id.to_string(),
180 node,
181 root_agent_id: root_agent,
182 parent_capsule_ref,
183 project_root: request.project_root.into(),
184 window_ms,
185 };
186 let transport = lean_ctx::core::a2a::health::local_transport();
187 let mut reserved = None;
188 let launch = (|| {
189 plan.validate()?;
190 let (signed_capsule, delivery) = transport
191 .reserve(&plan.node.agent_id, &fence)
192 .map_err(|error| error.to_string())?
193 .ok_or_else(|| {
194 format!(
195 "no unreserved signed context capsule is queued for agent {}",
196 plan.node.agent_id
197 )
198 })?;
199 reserved = Some(delivery.clone());
200 if delivery.capsule_ref != plan.node.capsule_ref {
201 return Err("queued delivery does not match claimed node capsule".into());
202 }
203 let root_key = lean_ctx::core::agent_identity::get_stored_public_key(&plan.root_agent_id)
204 .map_err(|error| format!("root-agent trust key unavailable: {error}"))?;
205 plan.verify_capsule(&signed_capsule, &root_key)?;
206 WorkGraphStore::mutate(request.project_root, |store| {
208 store
209 .graph_mut(request.graph_id)?
210 .begin_delivery_attempt(node_id, &fence, 1)
211 .map_err(|error| error.to_string())
212 })?;
213 let task_id = delegation::attempt_task_id(
214 &delegation::execution_key(
215 std::path::Path::new(request.project_root),
216 request.graph_id,
217 node_id,
218 &fence,
219 )?,
220 1,
221 );
222 let profile = match &issuer {
223 Some(issuer) => {
224 let issued =
225 issuer.issue(&plan, 1, &task_id, signed_capsule.capsule.sensitivity)?;
226 Some(
227 issued
228 .launch_value(&task_id, std::path::Path::new(request.project_root))
229 .ok_or("issued delivery profile failed launch validation")?,
230 )
231 }
232 None => None,
233 };
234 if !transport.acknowledge(&plan.node.agent_id, &delivery.relay_id, &fence) {
235 let _ = transport.quarantine_reserved(&plan.node.agent_id, &delivery.relay_id, &fence);
236 reserved = None;
237 lean_ctx::core::a2a::health::record_delivery(false);
238 return Err("capsule acknowledgement lost its reservation".into());
239 }
240 reserved = None;
241 lean_ctx::core::a2a::health::record_delivery(true);
242 Ok((task_id, profile))
243 })();
244 let (task_id, profile) = match launch {
245 Ok(launch) => launch,
246 Err(error) => {
247 if let Some(delivery) = reserved {
248 let _ =
249 transport.negative_acknowledge(&plan.node.agent_id, &delivery.relay_id, &fence);
250 lean_ctx::core::a2a::health::record_delivery(false);
251 }
252 abort_start(
253 request,
254 node_id,
255 &plan.node.agent_id,
256 &fence,
257 StopReason::ExecutionFailed,
258 )?;
259 return Err(error);
260 }
261 };
262 let graph: serde_json::Value =
263 serde_json::from_str(&get(request)?).map_err(|error| error.to_string())?;
264 serde_json::to_string(&json!({
265 "graph": graph,
266 "launch": {
267 "graph_id": request.graph_id,
268 "node_id": node_id,
269 "agent_id": plan.node.agent_id,
270 "execution_fence": fence,
271 "task_id": task_id,
272 "lease_expires_epoch_ms": lease_expires_epoch_ms,
273 "delegated_delivery": profile.is_some(),
274 "env": {
275 delegation::CHILD_DELIVERY_ENV:
276 profile.as_deref().unwrap_or(delegation::NO_CHILD_DELIVERY),
277 },
278 "unset_env": [delegation::ISSUER_ENV],
279 "finish": "action=receipt with this execution_fence, or action=cancel; \
280 the lease ends at lease_expires_epoch_ms",
281 },
282 }))
283 .map_err(|error| error.to_string())
284}
285
286fn epoch_ms() -> Result<u64, String> {
287 std::time::SystemTime::now()
288 .duration_since(std::time::UNIX_EPOCH)
289 .map_err(|error| format!("system clock before Unix epoch: {error}"))?
290 .as_millis()
291 .try_into()
292 .map_err(|_| "system clock exceeds u64 milliseconds".to_string())
293}
294
295type IncarnationLease = (
296 lean_ctx::core::agent_lease::AgentLeaseResourceKindV1,
297 String,
298 String,
299);
300
301fn incarnation_leases(
305 project_root: &str,
306 graph_id: &str,
307 node: &lean_ctx::core::work_graph::WorkNode,
308 fence: &str,
309) -> Result<Vec<IncarnationLease>, String> {
310 use lean_ctx::core::agent_lease::AgentLeaseResourceKindV1;
311 let mut leases = vec![(
312 AgentLeaseResourceKindV1::WorkGraphNode,
313 lean_ctx::core::work_graph_delegation::node_lease_resource(
314 project_root,
315 graph_id,
316 &node.node_id,
317 )?,
318 fence.to_string(),
319 )];
320 for (index, claim) in node.path_claims.iter().enumerate() {
321 leases.push((
322 AgentLeaseResourceKindV1::Path,
323 lean_ctx::core::work_graph_delegation::path_lease_resource(project_root, claim)?,
324 format!("{fence}:{index}"),
325 ));
326 }
327 Ok(leases)
328}
329
330fn acquire_incarnation_leases(
331 owner: &str,
332 leases: &[IncarnationLease],
333 duration_ms: u64,
334) -> Result<(), String> {
335 use lean_ctx::core::agent_lease::{
336 AGENT_LEASE_SCHEMA_VERSION, AgentLeaseAcquireV1, AgentLeaseRequestV1, acquire_shared,
337 };
338 for (kind, resource, lease_request_ref) in leases {
339 let acquired = acquire_shared(AgentLeaseRequestV1 {
340 schema_version: AGENT_LEASE_SCHEMA_VERSION,
341 lease_request_ref: lease_request_ref.clone(),
342 resource_kind: *kind,
343 resource_ref: resource.clone(),
344 owner_agent_id: owner.to_string(),
345 duration_ms,
346 })
347 .map_err(|error| error.to_string())?;
348 if let AgentLeaseAcquireV1::HeldBy { .. } = acquired {
349 return Err(format!("lease is held by another execution: {resource}"));
350 }
351 }
352 Ok(())
353}
354
355fn release_incarnation_leases(owner: &str, fence: &str) {
359 if let Err(error) = lean_ctx::core::agent_lease::release_fence_shared(owner, fence) {
362 tracing::warn!(event = "work_graph_lease_release_failed", %error);
363 }
364}
365
366fn abort_start(
369 request: &Request<'_>,
370 node_id: &str,
371 owner: &str,
372 fence: &str,
373 reason: StopReason,
374) -> Result<(), String> {
375 WorkGraphStore::mutate(request.project_root, |store| {
376 store
377 .graph_mut(request.graph_id)?
378 .stop_unlaunched(node_id, reason)
379 .map(|_| ())
380 .map_err(|error| error.to_string())
381 })?;
382 release_incarnation_leases(owner, fence);
383 Ok(())
384}
385
386fn mutate_store<T>(
391 request: &Request<'_>,
392 mutate: impl FnOnce(&mut WorkGraphStore) -> Result<T, String>,
393) -> Result<T, String> {
394 match request.if_revision {
395 Some(expected) => WorkGraphStore::mutate_if_revision(
396 request.project_root,
397 request.graph_id,
398 expected,
399 mutate,
400 )
401 .map_err(|error| {
402 if error == "work graph revision conflict" {
403 "work graph revision conflict: the graph changed since this write_revision; \
404 observe again for the current write_revision"
405 .to_string()
406 } else {
407 error
408 }
409 }),
410 None => WorkGraphStore::mutate(request.project_root, mutate),
411 }
412}
413
414fn create(request: &Request<'_>) -> Result<String, String> {
415 let node_id = required(request.node_id, "node_id")?;
416 validate_id(node_id, "node_id")?;
417 let tokens = positive(request.tokens, "tokens")?;
418 let cost = positive(request.cost_micros, "cost_micros")?;
419 let capsule = required(request.capsule_ref, "capsule_ref")?;
420 let task_ref = required(request.task_ref, "task_ref")?;
421 let policy_ref = required(request.policy_ref, "policy_ref")?;
422 let expected_outcome_ref = required(request.expected_outcome_ref, "expected_outcome_ref")?;
423 bounded_ref(capsule, "capsule_ref")?;
424 let mut graph = BoundedWorkGraph::default()
425 .with_local_concurrency(request.max_concurrency.unwrap_or(4))
426 .with_graph_id(request.graph_id)
427 .map_err(|error| error.to_string())?;
428 graph
429 .add_root(
430 node_id.to_string(),
431 request.agent_id.to_string(),
432 capsule.to_string(),
433 budget(tokens, cost),
434 )
435 .map_err(|error| error.to_string())?;
436 graph
437 .configure_root_context(node_id, task_ref, policy_ref, expected_outcome_ref)
438 .map_err(|error| error.to_string())?;
439 WorkGraphStore::mutate(request.project_root, |store| {
440 store.create(request.graph_id, graph)
441 })?;
442 get(request)
443}
444
445fn delegate(request: &Request<'_>) -> Result<String, String> {
446 let parent = required(request.parent_node_id, "parent_node_id")?;
447 let node = required(request.node_id, "node_id")?;
448 validate_id(parent, "parent_node_id")?;
449 validate_id(node, "node_id")?;
450 let target = required(request.to_agent, "to_agent")?;
451 validate_id(target, "to_agent")?;
452 ensure_active_agent(request.project_root, target)?;
453 let capsule = required(request.capsule_ref, "capsule_ref")?;
454 let task_ref = required(request.task_ref, "task_ref")?;
455 let expected_outcome_ref = required(request.expected_outcome_ref, "expected_outcome_ref")?;
456 bounded_ref(capsule, "capsule_ref")?;
457 let tokens = positive(request.tokens, "tokens")?;
458 let cost = positive(request.cost_micros, "cost_micros")?;
459 let path_claims: Vec<String> = request
460 .path_claims
461 .map_or_else(
462 || Ok(Vec::new()),
463 |value| serde_json::from_value::<Vec<String>>(value.clone()),
464 )
465 .map_err(|error| format!("invalid path_claims: {error}"))?
466 .into_iter()
467 .map(|claim| canonical_path_claim(request.project_root, &claim))
468 .collect::<Result<_, _>>()?;
469 mutate_store(request, |store| {
470 let graph = store.graph_mut(request.graph_id)?;
471 authorize_node(graph, parent, request.agent_id)?;
472 graph
473 .queue_child(
474 parent,
475 node.to_string(),
476 target.to_string(),
477 capsule.to_string(),
478 budget(tokens, cost),
479 )
480 .map_err(|error| error.to_string())?;
481 graph
482 .set_path_claims(node, path_claims)
483 .map_err(|error| error.to_string())?;
484 graph
485 .configure_child_context(node, task_ref, expected_outcome_ref)
486 .map_err(|error| error.to_string())?;
487 Ok(())
488 })?;
489 get(request)
490}
491
492fn claim(request: &Request<'_>) -> Result<String, String> {
493 let node = required(request.node_id, "node_id")?;
494 validate_id(node, "node_id")?;
495 let lease_window_ms = request.timeout_ms.unwrap_or(300_000).clamp(1, 3_600_000);
496 let now_epoch_ms: u64 = std::time::SystemTime::now()
497 .duration_since(std::time::UNIX_EPOCH)
498 .map_err(|error| format!("system clock before Unix epoch: {error}"))?
499 .as_millis()
500 .try_into()
501 .map_err(|_| "system clock exceeds u64 milliseconds".to_string())?;
502 let lease_expires_epoch_ms = now_epoch_ms
503 .checked_add(lease_window_ms)
504 .ok_or_else(|| "execution lease expiry overflow".to_string())?;
505 mutate_store(request, |store| {
506 let graph = store.graph_mut(request.graph_id)?;
507 authorize_node(graph, node, request.agent_id)?;
508 graph
509 .claim_and_activate_until(node, Some(lease_expires_epoch_ms))
510 .map_err(|error| error.to_string())?;
511 Ok(())
512 })?;
513 get(request)
514}
515
516fn consume(request: &Request<'_>) -> Result<String, String> {
517 let node = required(request.node_id, "node_id")?;
518 validate_id(node, "node_id")?;
519 let tokens = request.tokens.unwrap_or(0);
520 let cost = request.cost_micros.unwrap_or(0);
521 if tokens == 0 && cost == 0 {
522 return Err("tokens or cost_micros must be positive".to_string());
523 }
524 mutate_store(request, |store| {
525 let graph = store.graph_mut(request.graph_id)?;
526 authorize_node(graph, node, request.agent_id)?;
527 graph
528 .consume_budget(node, tokens, cost)
529 .map_err(|error| error.to_string())?;
530 Ok(())
531 })?;
532 get(request)
533}
534
535fn complete(request: &Request<'_>) -> Result<String, String> {
536 let node = required(request.node_id, "node_id")?;
537 validate_id(node, "node_id")?;
538 let outcome = required(request.outcome_ref, "outcome_ref")?;
539 bounded_ref(outcome, "outcome_ref")?;
540 mutate_store(request, |store| {
541 let graph = store.graph_mut(request.graph_id)?;
542 authorize_node(graph, node, request.agent_id)?;
543 if graph
544 .get_node(node)
545 .is_some_and(|work_node| work_node.parent_node_id.is_some())
546 {
547 return Err("child nodes must finish with a child receipt".to_string());
548 }
549 if has_unfinished_descendants(graph, node) {
550 return Err("root cannot complete while descendants are unfinished".to_string());
551 }
552 graph
553 .complete(node, outcome.to_string())
554 .map_err(|error| error.to_string())
555 })?;
556 get(request)
557}
558
559pub fn receipt(request: &Request<'_>) -> Result<String, String> {
563 let node = required(request.node_id, "node_id")?;
564 validate_id(node, "node_id")?;
565 let receipt_id = required(request.receipt_id, "receipt_id")?;
566 validate_id(receipt_id, "receipt_id")?;
567 let outcome_ref = required(request.outcome_ref, "outcome_ref")?;
568 bounded_ref(outcome_ref, "outcome_ref")?;
569 let outcome = match required(request.outcome, "outcome")? {
570 "accepted" => ChildOutcome::Accepted,
571 "rejected" => ChildOutcome::Rejected,
572 "partial" => ChildOutcome::Partial,
573 _ => return Err("outcome must be accepted|rejected|partial".to_string()),
574 };
575 let claims: Vec<ResultClaim> = request
576 .claims
577 .map_or_else(
578 || Ok(Vec::new()),
579 |value| serde_json::from_value(value.clone()),
580 )
581 .map_err(|error| format!("invalid claims: {error}"))?;
582 let finished = mutate_store(request, |store| {
583 let graph = store.graph_mut(request.graph_id)?;
584 authorize_node_or_parent(graph, node, request.agent_id)?;
585 let work_node = graph
586 .get_node(node)
587 .ok_or_else(|| format!("node not found: {node}"))?
588 .clone();
589 if work_node.execution_fence.is_some() && request.execution_fence.is_none() {
590 return Err(
591 "receipt requires the execution_fence returned by action=start or action=claim"
592 .into(),
593 );
594 }
595 graph
596 .record_child_receipt(ChildExecutionReceipt {
597 receipt_id: receipt_id.to_string(),
598 node_id: node.to_string(),
599 outcome_ref: outcome_ref.to_string(),
600 outcome,
601 tokens_consumed: request.tokens.unwrap_or(0),
602 cost_micros_consumed: request.cost_micros.unwrap_or(0),
603 execution_fence: request.execution_fence.map(str::to_string),
604 claims,
605 usage_source: UsageSource::Reported,
606 })
607 .map_err(|error| error.to_string())?;
608 Ok(work_node)
609 })?;
610 if let (true, Some(fence)) = (
612 finished.execution_started,
613 finished.execution_fence.as_deref(),
614 ) {
615 release_incarnation_leases(&finished.agent_id, fence);
616 }
617 get(request)
618}
619
620fn accept(request: &Request<'_>) -> Result<String, String> {
621 let authorizer = required(request.node_id, "node_id")?;
622 let accepted: Vec<String> = request
623 .accepted_nodes
624 .ok_or_else(|| "accepted_nodes is required".to_string())
625 .and_then(|value| {
626 serde_json::from_value(value.clone())
627 .map_err(|error| format!("invalid accepted_nodes: {error}"))
628 })?;
629 if accepted.is_empty() {
630 return Err("accepted_nodes must not be empty".to_string());
631 }
632 mutate_store(request, |store| {
633 let graph = store.graph_mut(request.graph_id)?;
634 let root = graph
635 .get_node(authorizer)
636 .ok_or_else(|| format!("node not found: {authorizer}"))?;
637 if root.parent_node_id.is_some() || root.agent_id != request.agent_id {
638 return Err("accepted path may only be selected by the root owner".to_string());
639 }
640 for node in &accepted {
641 validate_id(node, "accepted_node")?;
642 }
643 graph
644 .mark_accepted_path(&accepted)
645 .map_err(|error| error.to_string())
646 })?;
647 get(request)
648}
649
650fn fuse(request: &Request<'_>) -> Result<String, String> {
651 let store = WorkGraphStore::load(request.project_root)?;
652 let graph = store
653 .graph(request.graph_id)
654 .ok_or_else(|| format!("graph not found: {}", request.graph_id))?;
655 authorize_member(graph, request.agent_id)?;
656 let result = graph
657 .fuse_accepted_results()
658 .map_err(|error| error.to_string())?;
659 serde_json::to_string(&result).map_err(|error| error.to_string())
660}
661
662fn attribution(request: &Request<'_>) -> Result<String, String> {
663 let store = WorkGraphStore::load(request.project_root)?;
664 let graph = store
665 .graph(request.graph_id)
666 .ok_or_else(|| format!("graph not found: {}", request.graph_id))?;
667 authorize_member(graph, request.agent_id)?;
668 let result = graph.attribution().map_err(|error| error.to_string())?;
669 serde_json::to_string(&result).map_err(|error| error.to_string())
670}
671
672fn value_report(request: &Request<'_>) -> Result<String, String> {
673 let store = WorkGraphStore::load(request.project_root)?;
674 let graph = store
675 .graph(request.graph_id)
676 .ok_or_else(|| format!("graph not found: {}", request.graph_id))?;
677 authorize_member(graph, request.agent_id)?;
678 serde_json::to_string(
679 &graph
680 .team_value_report()
681 .map_err(|error| error.to_string())?,
682 )
683 .map_err(|error| error.to_string())
684}
685
686fn cancel(request: &Request<'_>) -> Result<String, String> {
689 let node = required(request.node_id, "node_id")?;
690 validate_id(node, "node_id")?;
691 mutate_store(request, |store| {
692 let graph = store.graph_mut(request.graph_id)?;
693 authorize_node_or_parent(graph, node, request.agent_id)?;
694 let reason = match request.stop_reason.unwrap_or("manual_stop") {
695 "manual_stop" => StopReason::ManualStop,
696 "stale" => StopReason::Stale,
697 "redundant" => StopReason::Redundant,
698 "policy_denied" => StopReason::PolicyDenied,
699 "lease_lost" => StopReason::LeaseLost,
700 "duplicate" => StopReason::Duplicate,
701 "low_value" => StopReason::LowValue,
702 "execution_failed" => StopReason::ExecutionFailed,
703 other => return Err(format!("unsupported stop_reason: {other}")),
704 };
705 graph
706 .stop(node, reason)
707 .map(|_| ())
708 .map_err(|error| error.to_string())
709 })?;
710 get(request)
713}
714
715fn get(request: &Request<'_>) -> Result<String, String> {
716 let store = WorkGraphStore::load(request.project_root)?;
717 let graph = store
718 .graph(request.graph_id)
719 .ok_or_else(|| format!("graph not found: {}", request.graph_id))?;
720 authorize_member(graph, request.agent_id)?;
721 serde_json::to_string(graph).map_err(|error| error.to_string())
722}
723
724fn observe(request: &Request<'_>) -> Result<String, String> {
725 let store = WorkGraphStore::load(request.project_root)?;
726 let graph = store
727 .graph(request.graph_id)
728 .ok_or_else(|| format!("graph not found: {}", request.graph_id))?;
729 authorize_member(graph, request.agent_id)?;
730 let mut observation = graph.observe_if_changed(request.if_revision);
731 observation["write_revision"] = store.graph_revision(request.graph_id)?.into();
733 serde_json::to_string(&observation).map_err(|error| error.to_string())
734}
735
736fn list(project_root: &str, agent_id: &str) -> Result<String, String> {
737 let store = WorkGraphStore::load(project_root)?;
738 serde_json::to_string(
739 &json!({"graph_ids": store.graph_ids_for_agent(agent_id).collect::<Vec<_>>() }),
740 )
741 .map_err(|error| error.to_string())
742}
743
744fn budget(tokens: u64, cost: u64) -> WorkNodeBudget {
745 WorkNodeBudget {
746 tokens_allocated: tokens,
747 tokens_consumed: 0,
748 cost_micros_allocated: cost,
749 cost_micros_consumed: 0,
750 }
751}
752
753fn check_required_fields(request: &Request<'_>) -> Result<(), String> {
756 let fields: &[&str] = match request.action {
757 "create" => &[
758 "node_id",
759 "capsule_ref",
760 "task_ref",
761 "policy_ref",
762 "expected_outcome_ref",
763 "tokens",
764 "cost_micros",
765 ],
766 "delegate" => &[
767 "parent_node_id",
768 "node_id",
769 "to_agent",
770 "capsule_ref",
771 "task_ref",
772 "expected_outcome_ref",
773 "tokens",
774 "cost_micros",
775 ],
776 "claim" | "start" | "consume" | "cancel" => &["node_id"],
777 "complete" => &["node_id", "outcome_ref"],
778 "receipt" => &["node_id", "receipt_id", "outcome_ref", "outcome"],
779 "accept" => &["node_id", "accepted_nodes"],
780 _ => &[],
781 };
782 let present = |field: &str| match field {
783 "node_id" => request.node_id.is_some_and(|v| !v.is_empty()),
784 "parent_node_id" => request.parent_node_id.is_some_and(|v| !v.is_empty()),
785 "to_agent" => request.to_agent.is_some_and(|v| !v.is_empty()),
786 "capsule_ref" => request.capsule_ref.is_some_and(|v| !v.is_empty()),
787 "task_ref" => request.task_ref.is_some_and(|v| !v.is_empty()),
788 "policy_ref" => request.policy_ref.is_some_and(|v| !v.is_empty()),
789 "expected_outcome_ref" => request.expected_outcome_ref.is_some_and(|v| !v.is_empty()),
790 "outcome_ref" => request.outcome_ref.is_some_and(|v| !v.is_empty()),
791 "receipt_id" => request.receipt_id.is_some_and(|v| !v.is_empty()),
792 "outcome" => request.outcome.is_some_and(|v| !v.is_empty()),
793 "tokens" => request.tokens.is_some(),
794 "cost_micros" => request.cost_micros.is_some(),
795 "accepted_nodes" => request.accepted_nodes.is_some(),
796 _ => true,
797 };
798 let missing: Vec<&str> = fields.iter().copied().filter(|f| !present(f)).collect();
799 if missing.is_empty() {
800 return Ok(());
801 }
802 Err(format!(
803 "{} requires {}; missing: {}",
804 request.action,
805 fields.join(", "),
806 missing.join(", ")
807 ))
808}
809
810fn required<'a>(value: Option<&'a str>, field: &str) -> Result<&'a str, String> {
811 value
812 .filter(|value| !value.is_empty())
813 .ok_or_else(|| format!("{field} is required"))
814}
815
816fn positive(value: Option<u64>, field: &str) -> Result<u64, String> {
817 value
818 .filter(|value| *value > 0)
819 .ok_or_else(|| format!("{field} must be positive"))
820}
821
822fn bounded_ref(value: &str, field: &str) -> Result<(), String> {
823 if value.len() > 4096 {
824 return Err(format!("{field} exceeds 4096 bytes"));
825 }
826 Ok(())
827}
828
829fn canonical_path_claim(project_root: &str, claim: &str) -> Result<String, String> {
830 let relative = claim
831 .strip_prefix("path:")
832 .ok_or_else(|| "path claim must use path: scheme".to_string())?;
833 if relative.is_empty()
834 || relative.starts_with('/')
835 || relative.starts_with('\\')
836 || relative
837 .split(['/', '\\'])
838 .any(|part| part.is_empty() || part == "." || part == "..")
839 || relative.chars().any(char::is_control)
840 {
841 return Err("path claim must be a normalized project-relative path".into());
842 }
843 let root = std::path::Path::new(project_root)
844 .canonicalize()
845 .map_err(|error| format!("invalid project root: {error}"))?;
846 let candidate = root.join(relative);
847 let mut cursor = root.clone();
848 for component in std::path::Path::new(relative).components() {
849 cursor.push(component);
850 if let Ok(metadata) = std::fs::symlink_metadata(&cursor)
851 && metadata.file_type().is_symlink()
852 {
853 return Err(format!("path claim traverses symlink: {relative}"));
854 }
855 }
856 if candidate.exists() {
857 let canonical = candidate
858 .canonicalize()
859 .map_err(|error| format!("invalid path claim: {error}"))?;
860 if !canonical.starts_with(&root) {
861 return Err("path claim escapes project root".into());
862 }
863 }
864 let project_hash = lean_ctx::core::project_hash::hash_project_root(project_root);
865 Ok(format!("path:{project_hash}/{relative}"))
866}
867
868fn authorize_node(graph: &BoundedWorkGraph, node_id: &str, agent_id: &str) -> Result<(), String> {
869 let node = graph
870 .get_node(node_id)
871 .ok_or_else(|| format!("node not found: {node_id}"))?;
872 if node.agent_id != agent_id {
873 return Err(format!("agent {agent_id} does not own node {node_id}"));
874 }
875 Ok(())
876}
877
878fn authorize_node_or_parent(
880 graph: &BoundedWorkGraph,
881 node_id: &str,
882 agent_id: &str,
883) -> Result<(), String> {
884 let node = graph
885 .get_node(node_id)
886 .ok_or_else(|| format!("node not found: {node_id}"))?;
887 let delegator = node
888 .parent_node_id
889 .as_deref()
890 .and_then(|parent| graph.get_node(parent))
891 .map(|parent| parent.agent_id.as_str());
892 if node.agent_id == agent_id || delegator == Some(agent_id) {
893 return Ok(());
894 }
895 Err(format!(
896 "agent {agent_id} neither owns node {node_id} nor delegated it"
897 ))
898}
899
900fn authorize_member(graph: &BoundedWorkGraph, agent_id: &str) -> Result<(), String> {
901 if graph.contains_agent(agent_id) {
902 return Ok(());
903 }
904 Err(format!("agent {agent_id} is not a member of this graph"))
905}
906
907fn has_unfinished_descendants(graph: &BoundedWorkGraph, node_id: &str) -> bool {
908 graph.children_of(node_id).iter().any(|child_id| {
909 graph.get_node(child_id).is_some_and(|node| {
910 matches!(
911 node.status,
912 lean_ctx::core::work_graph::NodeStatus::Pending
913 | lean_ctx::core::work_graph::NodeStatus::Active
914 )
915 }) || has_unfinished_descendants(graph, child_id)
916 })
917}
918
919fn ensure_active_agent(project_root: &str, agent_id: &str) -> Result<(), String> {
920 let registry = lean_ctx::core::agents::AgentRegistry::load_or_create();
921 if registry
922 .list_active(Some(project_root))
923 .iter()
924 .any(|agent| agent.agent_id == agent_id)
925 {
926 return Ok(());
927 }
928 Err(format!(
929 "agent is not active in this project: {agent_id} — register it first with \
930 ctx_agent action=register (agent ids are per project)"
931 ))
932}
933
934#[cfg(test)]
935#[path = "ctx_work_graph_lifecycle_tests.rs"]
936mod lifecycle_tests;
937
938#[cfg(any(test, feature = "test-support"))]
942pub mod test_api {
943 use super::{Request, WorkGraphStore};
944
945 #[cfg(feature = "http-server")]
946 pub fn start(request: &Request<'_>) -> Result<String, String> {
947 super::start(request)
948 }
949
950 #[cfg(feature = "http-server")]
951 pub fn receipt(request: &Request<'_>) -> Result<String, String> {
952 super::receipt(request)
953 }
954
955 pub fn request<'a>(action: &'a str, project: &'a str, agent: &'a str) -> Request<'a> {
956 Request {
957 action,
958 project_root: project,
959 agent_id: agent,
960 graph_id: "graph",
961 node_id: Some("child"),
962 if_revision: None,
963 parent_node_id: None,
964 to_agent: None,
965 capsule_ref: None,
966 outcome_ref: None,
967 tokens: None,
968 cost_micros: None,
969 max_concurrency: None,
970 receipt_id: None,
971 execution_fence: None,
972 outcome: None,
973 claims: None,
974 accepted_nodes: None,
975 stop_reason: None,
976 timeout_ms: None,
977 path_claims: None,
978 task_ref: None,
979 policy_ref: None,
980 expected_outcome_ref: None,
981 }
982 }
983
984 pub fn node(project: &str) -> lean_ctx::core::work_graph::WorkNode {
985 WorkGraphStore::load(project)
986 .expect("work graph test fixture")
987 .graph("graph")
988 .expect("work graph test fixture")
989 .get_node("child")
990 .expect("work graph test fixture")
991 .clone()
992 }
993}