Skip to main content

lean_ctx_server/tools/
ctx_work_graph.rs

1// SPDX-License-Identifier: Apache-2.0
2use 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
81/// Replies to the removed launcher action with the host-start path.
82const 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
86/// Bind a host-launched child agent to one fenced claim of its node. The host
87/// starts the process; LeanCTX verifies the handed-over context capsule, holds
88/// the node and path leases, and issues the child's delivery profile when the
89/// host is configured as delivery issuer. A receipt or cancel ends it; an
90/// expired lease is stopped on the next claim. Every stop revokes delivery.
91pub 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    // A malformed issuer configuration must fail before any node is claimed.
107    let issuer = delegation::DeliveryIssuerConfig::from_environment()?;
108    // The issuer signs as the root host. A process that holds the host's
109    // issuer configuration under another agent identity (a child whose host
110    // ignored launch.unset_env) must not sign for its own delegations.
111    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    // Every failure from here on stops the claim again, so a refused start
157    // never strands a claimed node. The shared leases must never outlive the
158    // node lease: a stopped child's paths are released by the graph at that
159    // instant, and a later claimant must then find the shared leases free.
160    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        // Publish the attempt before any delivery authority exists for it.
207        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
301/// Node and path leases of one execution incarnation, keyed by its fence.
302/// Path leases are project-bound and shared with `ctx_agent` leases, so an
303/// agent holding a path there excludes a node claiming it here, and back.
304fn 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
355/// Releases every lease of one incarnation by its owner and fence, never by
356/// recomputed resource names: a project root spelled differently at release
357/// time must not leave the leases behind.
358fn release_incarnation_leases(owner: &str, fence: &str) {
359    // Leases expire with the execution window; a failed early release only
360    // delays reuse and never extends authority.
361    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
366/// Stop a claim whose launch was refused; the stop revokes delivery and frees
367/// the slot before any lease of this incarnation is released.
368fn 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
386/// The caller's mutation of its graph. With `if_revision` it runs only while
387/// the graph still has that `write_revision` (from observe), checked under
388/// the same persistence lock; otherwise it fails as a revision conflict and
389/// changes nothing. The revision proves freshness, never authorization.
390fn 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
559/// Terminal child receipt from the node owner or the host that delegated it.
560/// A claimed node is bound by its fence; the figures are reported by the
561/// caller and stay bounded by the node and chain budgets.
562pub 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    // The receipt reports the child finished, so its leases end with it.
611    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
686/// Durable stop by the node owner or its delegating host. The stop revokes the
687/// child's delegated delivery at once; the host ends its own child process.
688fn 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    // The stop revokes delivery at once. The stopped children's leases stay
711    // until their windows end: the host may still be ending those processes.
712    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    // The precondition for a guarded write (if_revision on a mutating action).
732    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
753/// Names every missing field of an action at once. Reported one at a time,
754/// callers retried field by field (telemetry 10/2026: 11 of 45 failures).
755fn 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
878/// The node's owner, or the owner of its direct parent that delegated it.
879fn 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/// The host-start actions with their test request and node views, for the
939/// lifecycle tests here and the end-to-end test that drives a started child
940/// against the HTTP delivery endpoint.
941#[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}