1use crate::runtime::binding::{
2 validate_envelope_freshness, validate_lease, validate_receipt, validate_reservation,
3};
4use crate::{
5 ActionClass, ActionPreview, ComputerUseAuthContext, ComputerUseRuntime, ControlLease,
6 ExecutionMode, ExecutionReceipt, ScopeAuthorizer, TargetReservation, VerificationOutcome,
7};
8use adk_graph::{
9 Channel, Checkpointer, CompiledGraph, DeferredNodeConfig, END, GraphError, MergeStrategy,
10 NodeOutput, START, StateGraph, StateSchema,
11};
12use serde_json::{Value, json};
13use std::sync::Arc;
14
15fn node_error(node: &str, message: impl Into<String>) -> GraphError {
16 GraphError::NodeExecutionFailed { node: node.to_string(), message: message.into() }
17}
18
19fn preview_route(preview: &ActionPreview) -> &'static str {
20 if preview.executable {
21 "allowed"
22 } else if preview.blocker.as_deref() == Some("approval_required") {
23 "approval"
24 } else {
25 "blocked"
26 }
27}
28
29fn reservation_from_state(
30 value: Option<&Value>,
31 node: &str,
32) -> Result<Option<TargetReservation>, GraphError> {
33 match value {
34 None | Some(Value::Null) => Ok(None),
35 Some(value) => serde_json::from_value(value.clone())
36 .map(Some)
37 .map_err(|error| node_error(node, format!("invalid target reservation: {error}"))),
38 }
39}
40
41fn validate_preview_history(
42 preview: &ActionPreview,
43 history: Option<&Value>,
44 node: &str,
45) -> Result<(), GraphError> {
46 let history = history
47 .and_then(Value::as_array)
48 .ok_or_else(|| node_error(node, "missing append-only preview history"))?;
49 if history.len() != 1 {
50 return Err(node_error(
51 node,
52 format!(
53 "append-only preview history contains {} entries instead of exactly one; \
54 resumed state may have attempted to replace the approved preview",
55 history.len()
56 ),
57 ));
58 }
59 let original: ActionPreview = serde_json::from_value(history[0].clone())
60 .map_err(|error| node_error(node, format!("invalid preview history: {error}")))?;
61 if original != *preview {
62 return Err(node_error(node, "preview changed after policy evaluation; refusing mutation"));
63 }
64 Ok(())
65}
66
67fn validate_execution_approval(
68 preview: &ActionPreview,
69 route: Option<&str>,
70 approval: Option<&Value>,
71 approval_grant_id: Option<&str>,
72 approved_action_digest: Option<&str>,
73 approved_policy_digest: Option<&str>,
74) -> Result<(), GraphError> {
75 let expected_route = preview_route(preview);
76 if route != Some(expected_route) {
77 return Err(node_error(
78 "execute",
79 "preview route changed after policy evaluation; refusing mutation",
80 ));
81 }
82
83 match expected_route {
84 "allowed" => {
85 if approval_grant_id.is_some()
86 || approved_action_digest.is_some()
87 || approved_policy_digest.is_some()
88 {
89 return Err(node_error(
90 "execute",
91 "an allowed preview carried stale approval authority",
92 ));
93 }
94 }
95 "approval" => {
96 let approval = approval.and_then(Value::as_object).ok_or_else(|| {
97 node_error("execute", "approval state is missing before mutation")
98 })?;
99 let action_digest = approval.get("actionDigest").and_then(Value::as_str);
100 let policy_digest = approval.get("policyDigest").and_then(Value::as_str);
101 if action_digest != Some(preview.envelope.args_digest.as_str())
102 || policy_digest != Some(preview.policy.policy_digest.as_str())
103 || approved_action_digest != action_digest
104 || approved_policy_digest != policy_digest
105 {
106 return Err(node_error(
107 "execute",
108 "approval no longer matches the exact preview action and policy digests",
109 ));
110 }
111
112 let supplied_grant = approval.get("grantId").and_then(Value::as_str);
113 let runtime_approved =
114 approval.get("runtimeApproved").and_then(Value::as_bool).unwrap_or(false);
115 let exact_grant = supplied_grant.is_some()
116 && supplied_grant == approval_grant_id
117 && !runtime_approved;
118 let runtime_holds_grant =
119 supplied_grant.is_none() && approval_grant_id.is_none() && runtime_approved;
120 if !exact_grant && !runtime_holds_grant {
121 return Err(node_error(
122 "execute",
123 "approval authority changed after the digest-bound approval step",
124 ));
125 }
126 }
127 _ => {
128 return Err(node_error("execute", "a blocked preview reached the mutation node"));
129 }
130 }
131
132 Ok(())
133}
134
135async fn release_on_error<T>(
136 runtime: &Arc<dyn ComputerUseRuntime>,
137 reservation: Option<&TargetReservation>,
138 node: &str,
139 result: Result<T, GraphError>,
140) -> Result<T, GraphError> {
141 let Err(primary) = result else {
142 return result;
143 };
144 let Some(reservation) = reservation else {
145 return Err(primary);
146 };
147 match runtime.release_target(reservation).await {
148 Ok(()) => Err(primary),
149 Err(cleanup) => Err(node_error(
150 node,
151 format!(
152 "primary failure: {primary}; target reservation cleanup also failed for {}: \
153 {cleanup}",
154 reservation.reservation_id
155 ),
156 )),
157 }
158}
159
160async fn release_after_terminal<T>(
161 runtime: &Arc<dyn ComputerUseRuntime>,
162 reservation: Option<&TargetReservation>,
163 node: &str,
164 result: Result<T, GraphError>,
165) -> Result<T, GraphError> {
166 let Some(reservation) = reservation else {
167 return result;
168 };
169 match (result, runtime.release_target(reservation).await) {
170 (Ok(value), Ok(())) => Ok(value),
171 (Ok(_), Err(cleanup)) => Err(node_error(
172 node,
173 format!(
174 "target reservation cleanup failed for {}: {cleanup}",
175 reservation.reservation_id
176 ),
177 )),
178 (Err(primary), Ok(())) => Err(primary),
179 (Err(primary), Err(cleanup)) => Err(node_error(
180 node,
181 format!(
182 "primary failure: {primary}; target reservation cleanup also failed for {}: \
183 {cleanup}",
184 reservation.reservation_id
185 ),
186 )),
187 }
188}
189
190pub fn build_reference_graph(
196 runtime: Arc<dyn ComputerUseRuntime>,
197 authorizer: Arc<ScopeAuthorizer>,
198) -> Result<CompiledGraph, GraphError> {
199 build_reference_graph_with_checkpointer(runtime, authorizer, None)
200}
201
202pub fn build_reference_graph_with_checkpointer(
204 runtime: Arc<dyn ComputerUseRuntime>,
205 authorizer: Arc<ScopeAuthorizer>,
206 checkpointer: Option<Arc<dyn Checkpointer>>,
207) -> Result<CompiledGraph, GraphError> {
208 let capability_runtime = runtime.clone();
209 let visual_runtime = runtime.clone();
210 let semantic_runtime = runtime.clone();
211 let preview_runtime = runtime.clone();
212 let reservation_runtime = runtime.clone();
213 let lease_runtime = runtime.clone();
214 let execute_runtime = runtime.clone();
215 let verify_runtime = runtime;
216
217 StateGraph::new({
218 let mut schema = StateSchema::simple(&[
219 "proposed_action",
220 "capabilities",
221 "visual_evidence",
222 "semantic_evidence",
223 "observations_joined",
224 "preview",
225 "route",
226 "approval",
227 "approval_grant_id",
228 "approved_action_digest",
229 "approved_policy_digest",
230 "reservation",
231 "lease",
232 "receipt",
233 "verified",
234 "committed",
235 "result",
236 ]);
237 schema.channels.insert("preview_history".into(), Channel::list("preview_history"));
241 schema
242 })
243 .add_node_fn("discover", move |_| {
244 let runtime = capability_runtime.clone();
245 async move {
246 let value = runtime
247 .discover_capabilities()
248 .await
249 .map_err(|error| node_error("discover", error.to_string()))?;
250 Ok(NodeOutput::new().with_update("capabilities", value))
251 }
252 })
253 .add_node_fn("observe_visual", move |_| {
254 let runtime = visual_runtime.clone();
255 async move {
256 let value = runtime
257 .observe_visual()
258 .await
259 .map_err(|error| node_error("observe_visual", error.to_string()))?;
260 Ok(NodeOutput::new().with_update("visual_evidence", value))
261 }
262 })
263 .add_node_fn("observe_semantic", move |_| {
264 let runtime = semantic_runtime.clone();
265 async move {
266 let value = runtime
267 .observe_semantic()
268 .await
269 .map_err(|error| node_error("observe_semantic", error.to_string()))?;
270 Ok(NodeOutput::new().with_update("semantic_evidence", value))
271 }
272 })
273 .add_deferred_node_fn(
274 "join_observations",
275 |ctx| async move {
276 let complete = ctx.get("capabilities").is_some()
277 && ctx.get("visual_evidence").is_some()
278 && ctx.get("semantic_evidence").is_some();
279 if !complete {
280 return Err(node_error(
281 "join_observations",
282 "fan-in completed without all evidence",
283 ));
284 }
285 Ok(NodeOutput::new().with_update("observations_joined", json!(true)))
286 },
287 DeferredNodeConfig {
288 merge_strategy: MergeStrategy::Collect,
289 fan_in_timeout: None,
290 ..Default::default()
291 },
292 )
293 .add_node_fn("plan", |ctx| async move {
294 let proposed = ctx
295 .get("proposed_action")
296 .cloned()
297 .ok_or_else(|| node_error("plan", "missing proposed_action"))?;
298 Ok(NodeOutput::new().with_update("proposed_action", proposed))
299 })
300 .add_node_fn("preview", move |ctx| {
301 let runtime = preview_runtime.clone();
302 async move {
303 let proposed = ctx
304 .get("proposed_action")
305 .cloned()
306 .ok_or_else(|| node_error("preview", "missing proposed_action"))?;
307 let preview = runtime
308 .preview_action(proposed)
309 .await
310 .map_err(|error| node_error("preview", error.to_string()))?;
311 validate_envelope_freshness(&preview.envelope)
312 .map_err(|error| node_error("preview", error.to_string()))?;
313 let route = preview_route(&preview);
314 let preview = serde_json::to_value(preview)?;
315 Ok(NodeOutput::new()
316 .with_update("preview", preview.clone())
317 .with_update("preview_history", preview)
318 .with_update("route", json!(route)))
319 }
320 })
321 .add_node_fn("request_approval", |ctx| async move {
322 let preview: ActionPreview = serde_json::from_value(
323 ctx.get("preview")
324 .cloned()
325 .ok_or_else(|| node_error("request_approval", "missing preview"))?,
326 )?;
327 validate_preview_history(&preview, ctx.get("preview_history"), "request_approval")?;
328 let approval = ctx.get("approval").and_then(Value::as_object);
329 let approved_digest =
330 approval.and_then(|value| value.get("actionDigest")).and_then(Value::as_str);
331 let grant_id = approval
332 .and_then(|value| value.get("grantId"))
333 .and_then(Value::as_str)
334 .filter(|value| !value.is_empty());
335 let approved_policy =
336 approval.and_then(|value| value.get("policyDigest")).and_then(Value::as_str);
337 let runtime_approved = approval
338 .and_then(|value| value.get("runtimeApproved"))
339 .and_then(Value::as_bool)
340 .unwrap_or(false);
341 match (approved_digest, approved_policy, grant_id, runtime_approved) {
342 (None, None, None, false) => Ok(NodeOutput::interrupt_with_data(
343 "computer-use action requires scoped approval",
344 serde_json::to_value(&preview)?,
345 )),
346 (Some(digest), Some(policy), Some(grant_id), false)
347 if digest == preview.envelope.args_digest
348 && policy == preview.policy.policy_digest =>
349 {
350 Ok(NodeOutput::new()
351 .with_update("approval_grant_id", json!(grant_id))
352 .with_update("approved_action_digest", json!(digest))
353 .with_update("approved_policy_digest", json!(policy)))
354 }
355 (Some(digest), Some(policy), None, true)
356 if digest == preview.envelope.args_digest
357 && policy == preview.policy.policy_digest =>
358 {
359 Ok(NodeOutput::new()
360 .with_update("approved_action_digest", json!(digest))
361 .with_update("approved_policy_digest", json!(policy)))
362 }
363 _ => Err(node_error(
364 "request_approval",
365 "approval does not match the interrupted action and policy digests",
366 )),
367 }
368 })
369 .add_node_fn("blocked", |ctx| async move {
370 let blocker = ctx
371 .get("preview")
372 .and_then(|value| value.get("blocker"))
373 .cloned()
374 .unwrap_or_else(|| json!("policy_denied"));
375 Ok(NodeOutput::new()
376 .with_update("result", json!({ "status": "blocked", "reason": blocker })))
377 })
378 .add_node_fn("reserve_target", move |ctx| {
379 let runtime = reservation_runtime.clone();
380 async move {
381 let preview: ActionPreview = serde_json::from_value(
382 ctx.get("preview")
383 .cloned()
384 .ok_or_else(|| node_error("reserve_target", "missing preview"))?,
385 )?;
386 validate_preview_history(&preview, ctx.get("preview_history"), "reserve_target")?;
387 let reservation = runtime
388 .reserve_target(&preview.envelope)
389 .await
390 .map_err(|error| node_error("reserve_target", error.to_string()))?;
391 if let Some(reservation) = reservation.as_ref() {
392 let validation = validate_reservation(reservation, &preview.envelope)
393 .map_err(|error| node_error("reserve_target", error.to_string()));
394 release_on_error(&runtime, Some(reservation), "reserve_target", validation).await?;
395 }
396 let serialized = serde_json::to_value(&reservation)
397 .map_err(|error| node_error("reserve_target", error.to_string()));
398 let serialized =
399 release_on_error(&runtime, reservation.as_ref(), "reserve_target", serialized)
400 .await?;
401 Ok(NodeOutput::new().with_update("reservation", serialized))
402 }
403 })
404 .add_node_fn("acquire_lease", move |ctx| {
405 let runtime = lease_runtime.clone();
406 async move {
407 let reservation = reservation_from_state(ctx.get("reservation"), "acquire_lease")?;
408 let result = async {
409 let preview: ActionPreview = serde_json::from_value(
410 ctx.get("preview")
411 .cloned()
412 .ok_or_else(|| node_error("acquire_lease", "missing preview"))?,
413 )
414 .map_err(|error| node_error("acquire_lease", error.to_string()))?;
415 let lease = runtime
416 .acquire_lease(&preview.envelope)
417 .await
418 .map_err(|error| node_error("acquire_lease", error.to_string()))?;
419 validate_lease(&lease, &preview.envelope)
420 .map_err(|error| node_error("acquire_lease", error.to_string()))?;
421 let lease = serde_json::to_value(lease)
422 .map_err(|error| node_error("acquire_lease", error.to_string()))?;
423 Ok(NodeOutput::new().with_update("lease", lease))
424 }
425 .await;
426 release_on_error(&runtime, reservation.as_ref(), "acquire_lease", result).await
427 }
428 })
429 .add_node_fn("execute", move |ctx| {
430 let runtime = execute_runtime.clone();
431 let authorizer = authorizer.clone();
432 async move {
433 let reservation = reservation_from_state(ctx.get("reservation"), "execute")?;
434 let result = async {
435 let preview: ActionPreview = serde_json::from_value(
436 ctx.get("preview")
437 .cloned()
438 .ok_or_else(|| node_error("execute", "missing preview"))?,
439 )
440 .map_err(|error| node_error("execute", error.to_string()))?;
441 let lease: ControlLease = serde_json::from_value(
442 ctx.get("lease")
443 .cloned()
444 .ok_or_else(|| node_error("execute", "missing lease"))?,
445 )
446 .map_err(|error| node_error("execute", error.to_string()))?;
447 let envelope = &preview.envelope;
448 validate_preview_history(&preview, ctx.get("preview_history"), "execute")?;
449 validate_envelope_freshness(envelope)
450 .map_err(|error| node_error("execute", error.to_string()))?;
451 validate_lease(&lease, envelope)
452 .map_err(|error| node_error("execute", error.to_string()))?;
453 if let Some(reservation) = reservation.as_ref() {
454 validate_reservation(reservation, envelope)
455 .map_err(|error| node_error("execute", error.to_string()))?;
456 }
457 validate_execution_approval(
458 &preview,
459 ctx.get("route").and_then(Value::as_str),
460 ctx.get("approval"),
461 ctx.get("approval_grant_id").and_then(Value::as_str),
462 ctx.get("approved_action_digest").and_then(Value::as_str),
463 ctx.get("approved_policy_digest").and_then(Value::as_str),
464 )?;
465 let auth = ComputerUseAuthContext {
466 principal_id: envelope.principal_id.clone(),
467 tenant_id: authorizer.verified_tenant_id().map(str::to_owned),
468 session_id: envelope.session_id.clone(),
469 execution_group_id: envelope.execution_group_id.clone().unwrap_or_default(),
470 requested_mode: envelope.requested_mode,
471 action_class: envelope.action_class,
472 target_app: envelope.target.as_ref().map(|target| target.app_id.clone()),
473 target_window: envelope
474 .target
475 .as_ref()
476 .and_then(|target| target.window_id.as_ref())
477 .map(|value| match value {
478 Value::String(value) => value.clone(),
479 value => value.to_string(),
480 }),
481 policy_digest: preview.policy.policy_digest.clone(),
482 };
483 authorizer
484 .authorize(&auth)
485 .map_err(|error| node_error("execute", error.to_string()))?;
486 let receipt = runtime
487 .execute_action(
488 envelope,
489 &lease,
490 ctx.get("approval_grant_id").and_then(Value::as_str),
491 )
492 .await
493 .map_err(|error| node_error("execute", error.to_string()))?;
494 validate_receipt(&receipt, envelope, &envelope.args_digest)
495 .map_err(|error| node_error("execute", error.to_string()))?;
496 let receipt = serde_json::to_value(receipt)
497 .map_err(|error| node_error("execute", error.to_string()))?;
498 Ok(NodeOutput::new().with_update("receipt", receipt))
499 }
500 .await;
501 release_on_error(&runtime, reservation.as_ref(), "execute", result).await
502 }
503 })
504 .add_node_fn("verify", move |ctx| {
505 let runtime = verify_runtime.clone();
506 async move {
507 let reservation = reservation_from_state(ctx.get("reservation"), "verify")?;
508 let result = async {
509 let receipt: ExecutionReceipt = serde_json::from_value(
510 ctx.get("receipt")
511 .cloned()
512 .ok_or_else(|| node_error("verify", "missing receipt"))?,
513 )
514 .map_err(|error| node_error("verify", error.to_string()))?;
515 let preview: ActionPreview = serde_json::from_value(
516 ctx.get("preview")
517 .cloned()
518 .ok_or_else(|| node_error("verify", "missing preview"))?,
519 )
520 .map_err(|error| node_error("verify", error.to_string()))?;
521 let outcome = runtime
522 .verify(&receipt, preview.envelope.postcondition.as_ref())
523 .await
524 .map_err(|error| node_error("verify", error.to_string()))?;
525 let detail = match &outcome {
526 VerificationOutcome::Verified => None,
527 VerificationOutcome::CommittedUnverified { reason }
528 | VerificationOutcome::Failed { reason } => Some(reason.clone()),
529 };
530
531 Ok(NodeOutput::new()
532 .with_update("verified", json!(outcome.is_verified()))
533 .with_update("committed", json!(outcome.is_committed()))
534 .with_update(
535 "result",
536 json!({
537 "status": outcome.status(),
538 "receiptId": receipt.receipt_id,
539 "verificationDetail": detail,
540 }),
541 ))
542 }
543 .await;
544 release_after_terminal(&runtime, reservation.as_ref(), "verify", result).await
545 }
546 })
547 .add_edge(START, "discover")
548 .add_edge(START, "observe_visual")
549 .add_edge(START, "observe_semantic")
550 .add_edge("discover", "join_observations")
551 .add_edge("observe_visual", "join_observations")
552 .add_edge("observe_semantic", "join_observations")
553 .add_edge("join_observations", "plan")
554 .add_edge("plan", "preview")
555 .add_conditional_edges(
556 "preview",
557 |state| state.get("route").and_then(Value::as_str).unwrap_or("blocked").to_string(),
558 [("allowed", "reserve_target"), ("approval", "request_approval"), ("blocked", "blocked")],
559 )
560 .add_edge("request_approval", "reserve_target")
561 .add_edge("blocked", END)
562 .add_edge("reserve_target", "acquire_lease")
563 .add_edge("acquire_lease", "execute")
564 .add_edge("execute", "verify")
565 .add_edge("verify", END)
566 .compile()
567 .map(CompiledGraph::with_strict_channels)
571 .map(|graph| match checkpointer {
572 Some(checkpointer) => graph.with_checkpointer_arc(checkpointer),
573 None => graph,
574 })
575}
576
577const _: (ExecutionMode, ActionClass) = (ExecutionMode::Shadow, ActionClass::Observe);