1use pointlock_ir::{
10 ActionStepIR, AssertStepIR, CallStepIR, EffectClassAction, FlowIR, ForeachStepIR,
11 HandlerBinding, HumanStepIR, IfStepIR, LetStepIR, PathFrame, StepIR, render_run_path,
12};
13use schemars::JsonSchema;
14use serde::{Deserialize, Serialize};
15
16use super::ProjectionVersion;
17
18#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
20#[serde(rename_all = "camelCase", deny_unknown_fields)]
21pub struct FlowGraphView {
22 pub projection_version: ProjectionVersion,
24 pub flow_id: String,
26 pub ir_hash: String,
28 pub nodes: Vec<GraphNode>,
30 pub edges: Vec<GraphEdge>,
32 pub flow_hooks: Vec<HookBadge>,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
39#[serde(rename_all = "camelCase")]
40pub enum NodeRegion {
41 Then,
43 Else,
45 Body,
47}
48
49#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
55#[serde(rename_all = "camelCase")]
56#[schemars(deny_unknown_fields)]
57pub struct GraphNode {
58 pub id: String,
60 pub run_path: String,
65 #[serde(skip_serializing_if = "Option::is_none")]
67 pub parent_id: Option<String>,
68 #[serde(skip_serializing_if = "Option::is_none")]
70 pub region: Option<NodeRegion>,
71 #[serde(flatten)]
73 pub body: GraphNodeBody,
74}
75
76#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
78#[serde(tag = "kind", rename_all = "camelCase")]
79pub enum GraphNodeBody {
80 #[serde(rename_all = "camelCase")]
82 Action {
83 #[serde(skip_serializing_if = "Option::is_none")]
85 verb: Option<String>,
86 action_name: String,
88 mutating: bool,
90 act_chain: Vec<String>,
93 assertion_count: u32,
95 },
96 #[serde(rename_all = "camelCase")]
98 Assert {
99 observe: String,
101 assertions: Vec<AssertionSummary>,
103 },
104 #[serde(rename_all = "camelCase")]
107 Call {
108 callee_flow_id: String,
110 callee_ir_hash: String,
113 input_keys: Vec<String>,
115 },
116 #[serde(rename_all = "camelCase")]
118 Human {
119 mode: String,
121 prompt_head: String,
123 timeout_ms: u64,
125 },
126 #[serde(rename_all = "camelCase")]
129 If {
130 cond: String,
132 has_else: bool,
134 },
135 #[serde(rename_all = "camelCase")]
138 Foreach {
139 items: String,
141 r#as: String,
143 },
144 #[serde(rename_all = "camelCase")]
146 Let {
147 binding_keys: Vec<String>,
149 },
150}
151
152#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
154#[serde(rename_all = "camelCase", deny_unknown_fields)]
155pub struct AssertionSummary {
156 pub assert_id: String,
158 pub predicate: String,
160 pub verify_via: Vec<String>,
163}
164
165#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
167#[serde(rename_all = "camelCase")]
168pub enum GraphEdgeKind {
169 Seq,
171 Branch,
173 Hook,
176}
177
178#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
180#[serde(rename_all = "camelCase", deny_unknown_fields)]
181pub struct GraphEdge {
182 pub kind: GraphEdgeKind,
184 pub from: String,
186 #[serde(skip_serializing_if = "Option::is_none")]
188 pub to: Option<String>,
189 #[serde(skip_serializing_if = "Option::is_none")]
191 pub label: Option<String>,
192 #[serde(skip_serializing_if = "Option::is_none")]
194 pub hook: Option<HookBadge>,
195}
196
197#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
199#[serde(rename_all = "camelCase", deny_unknown_fields)]
200pub struct HookBadge {
201 pub hook: String,
203 pub disposition: String,
205 pub max_triggers: u32,
207 #[serde(skip_serializing_if = "Option::is_none")]
209 pub error_classes: Option<Vec<String>>,
210 #[serde(skip_serializing_if = "Option::is_none")]
212 pub repair_target: Option<String>,
213}
214
215pub fn flow_graph_view(flow: &FlowIR) -> FlowGraphView {
219 let mut nodes = Vec::new();
220 let mut edges = Vec::new();
221 let root = vec![PathFrame::Flow {
222 flow_id: flow.flow_id.clone(),
223 ir_hash: flow.ir_hash.clone(),
224 }];
225 project_body(&flow.body, &root, None, None, &mut nodes, &mut edges);
226 FlowGraphView {
227 projection_version: ProjectionVersion,
228 flow_id: flow.flow_id.to_string(),
229 ir_hash: flow.ir_hash.to_string(),
230 nodes,
231 edges,
232 flow_hooks: flow
233 .handlers
234 .as_deref()
235 .unwrap_or_default()
236 .iter()
237 .map(hook_badge)
238 .collect(),
239 }
240}
241
242fn project_body(
245 body: &[StepIR],
246 prefix: &[PathFrame],
247 parent_id: Option<&str>,
248 region: Option<NodeRegion>,
249 nodes: &mut Vec<GraphNode>,
250 edges: &mut Vec<GraphEdge>,
251) {
252 let mut previous: Option<String> = None;
253 for step in body {
254 let step_id = step.step_id().to_string();
255 let mut anchor = prefix.to_vec();
256 match step {
261 StepIR::Call(CallStepIR { flow_ref, .. }) => anchor.push(PathFrame::Call {
262 step_id: Some(step.step_id().clone()),
263 callee_flow_id: flow_ref.flow_id.clone(),
264 callee_ir_hash: flow_ref.ir_hash.clone(),
265 }),
266 _ => anchor.push(PathFrame::Step {
267 step_id: step.step_id().clone(),
268 }),
269 }
270
271 if let Some(prev) = previous.take() {
272 edges.push(GraphEdge {
273 kind: GraphEdgeKind::Seq,
274 from: prev,
275 to: Some(step_id.clone()),
276 label: None,
277 hook: None,
278 });
279 }
280 previous = Some(step_id.clone());
281
282 nodes.push(GraphNode {
283 id: step_id.clone(),
284 run_path: render_run_path(&anchor),
285 parent_id: parent_id.map(str::to_owned),
286 region,
287 body: node_body(step),
288 });
289
290 if let Some(handlers) = step.base().handlers.as_deref() {
292 for binding in handlers {
293 edges.push(GraphEdge {
294 kind: GraphEdgeKind::Hook,
295 from: step_id.clone(),
296 to: None,
297 label: None,
298 hook: Some(hook_badge(binding)),
299 });
300 }
301 }
302
303 match step {
305 StepIR::If(IfStepIR { then, r#else, .. }) => {
306 if let Some(first) = then.first() {
307 edges.push(branch_edge(&step_id, first.step_id().as_ref(), "then"));
308 }
309 project_body(
310 then,
311 &anchor,
312 Some(&step_id),
313 Some(NodeRegion::Then),
314 nodes,
315 edges,
316 );
317 if let Some(else_body) = r#else.as_deref() {
318 if let Some(first) = else_body.first() {
319 edges.push(branch_edge(&step_id, first.step_id().as_ref(), "else"));
320 }
321 project_body(
322 else_body,
323 &anchor,
324 Some(&step_id),
325 Some(NodeRegion::Else),
326 nodes,
327 edges,
328 );
329 }
330 }
331 StepIR::Foreach(ForeachStepIR { body, .. }) => {
332 project_body(
333 body,
334 &anchor,
335 Some(&step_id),
336 Some(NodeRegion::Body),
337 nodes,
338 edges,
339 );
340 }
341 _ => {}
342 }
343 }
344}
345
346fn branch_edge(from: &str, to: &str, label: &str) -> GraphEdge {
347 GraphEdge {
348 kind: GraphEdgeKind::Branch,
349 from: from.to_owned(),
350 to: Some(to.to_owned()),
351 label: Some(label.to_owned()),
352 hook: None,
353 }
354}
355
356fn wire<T: Serialize>(value: &T) -> String {
358 serde_json::to_value(value)
359 .ok()
360 .and_then(|v| v.as_str().map(str::to_owned))
361 .unwrap_or_default()
362}
363
364fn expr_summary(expr: &pointlock_ir::Expr) -> String {
366 serde_json::to_string(expr).unwrap_or_default()
367}
368
369fn hook_badge(binding: &HandlerBinding) -> HookBadge {
370 let (disposition, repair_target) = match &binding.action {
371 pointlock_ir::HandlerAction::Retry { .. } => ("retry", None),
372 pointlock_ir::HandlerAction::Continue => ("continue", None),
373 pointlock_ir::HandlerAction::Escalate { .. } => ("escalate", None),
374 pointlock_ir::HandlerAction::Abort => ("abort", None),
375 pointlock_ir::HandlerAction::Repair { flow_ref } => (
376 "repair",
377 Some(format!("{}@{}", flow_ref.flow_id, flow_ref.ir_hash)),
378 ),
379 };
380 HookBadge {
381 hook: wire(&binding.hook),
382 disposition: disposition.to_owned(),
383 max_triggers: binding.max_triggers,
384 error_classes: binding
385 .error_classes
386 .as_ref()
387 .map(|classes| classes.iter().map(wire).collect()),
388 repair_target,
389 }
390}
391
392fn node_body(step: &StepIR) -> GraphNodeBody {
393 match step {
394 StepIR::Action(ActionStepIR {
395 verb,
396 effect,
397 binding,
398 assertions,
399 ..
400 }) => GraphNodeBody::Action {
401 verb: verb.as_ref().map(wire),
402 action_name: binding
403 .attempts
404 .first()
405 .map(|attempt| attempt.action_name.to_string())
406 .unwrap_or_default(),
407 mutating: *effect == EffectClassAction::Mutating,
408 act_chain: binding
409 .attempts
410 .iter()
411 .map(|attempt| wire(&attempt.channel))
412 .collect(),
413 assertion_count: assertions.len() as u32,
414 },
415 StepIR::Assert(AssertStepIR {
416 observe,
417 assertions,
418 ..
419 }) => GraphNodeBody::Assert {
420 observe: match serde_json::to_value(observe) {
421 Ok(serde_json::Value::String(fresh)) => fresh,
422 Ok(other) => other
423 .get("fromStep")
424 .and_then(|v| v.as_str())
425 .unwrap_or_default()
426 .to_owned(),
427 Err(_) => String::new(),
428 },
429 assertions: assertions.iter().map(assertion_summary).collect(),
430 },
431 StepIR::Call(CallStepIR {
432 flow_ref, inputs, ..
433 }) => GraphNodeBody::Call {
434 callee_flow_id: flow_ref.flow_id.to_string(),
435 callee_ir_hash: flow_ref.ir_hash.to_string(),
436 input_keys: inputs.keys().map(ToString::to_string).collect(),
437 },
438 StepIR::Human(HumanStepIR {
439 mode,
440 prompt,
441 timeout_ms,
442 ..
443 }) => GraphNodeBody::Human {
444 mode: wire(mode),
445 prompt_head: prompt.lines().next().unwrap_or_default().to_owned(),
446 timeout_ms: *timeout_ms,
447 },
448 StepIR::If(IfStepIR { cond, r#else, .. }) => GraphNodeBody::If {
449 cond: expr_summary(cond),
450 has_else: r#else.is_some(),
451 },
452 StepIR::Foreach(ForeachStepIR { items, r#as, .. }) => GraphNodeBody::Foreach {
453 items: expr_summary(items),
454 r#as: r#as.to_string(),
455 },
456 StepIR::Let(LetStepIR { bindings, .. }) => GraphNodeBody::Let {
457 binding_keys: bindings.keys().map(ToString::to_string).collect(),
458 },
459 }
460}
461
462fn assertion_summary(assertion: &pointlock_ir::AssertionIR) -> AssertionSummary {
463 let predicate = serde_json::to_value(&assertion.predicate)
464 .ok()
465 .and_then(|v| v.get("type").and_then(|t| t.as_str()).map(str::to_owned))
466 .unwrap_or_default();
467 AssertionSummary {
468 assert_id: assertion.assert_id.to_string(),
469 predicate,
470 verify_via: assertion.verify_via.iter().map(wire).collect(),
471 }
472}