use pointlock_ir::{
ActionStepIR, AssertStepIR, CallStepIR, EffectClassAction, FlowIR, ForeachStepIR,
HandlerBinding, HumanStepIR, IfStepIR, LetStepIR, PathFrame, StepIR, render_run_path,
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use super::ProjectionVersion;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct FlowGraphView {
pub projection_version: ProjectionVersion,
pub flow_id: String,
pub ir_hash: String,
pub nodes: Vec<GraphNode>,
pub edges: Vec<GraphEdge>,
pub flow_hooks: Vec<HookBadge>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub enum NodeRegion {
Then,
Else,
Body,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[schemars(deny_unknown_fields)]
pub struct GraphNode {
pub id: String,
pub run_path: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub region: Option<NodeRegion>,
#[serde(flatten)]
pub body: GraphNodeBody,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum GraphNodeBody {
#[serde(rename_all = "camelCase")]
Action {
#[serde(skip_serializing_if = "Option::is_none")]
verb: Option<String>,
action_name: String,
mutating: bool,
act_chain: Vec<String>,
assertion_count: u32,
},
#[serde(rename_all = "camelCase")]
Assert {
observe: String,
assertions: Vec<AssertionSummary>,
},
#[serde(rename_all = "camelCase")]
Call {
callee_flow_id: String,
callee_ir_hash: String,
input_keys: Vec<String>,
},
#[serde(rename_all = "camelCase")]
Human {
mode: String,
prompt_head: String,
timeout_ms: u64,
},
#[serde(rename_all = "camelCase")]
If {
cond: String,
has_else: bool,
},
#[serde(rename_all = "camelCase")]
Foreach {
items: String,
r#as: String,
},
#[serde(rename_all = "camelCase")]
Let {
binding_keys: Vec<String>,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AssertionSummary {
pub assert_id: String,
pub predicate: String,
pub verify_via: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub enum GraphEdgeKind {
Seq,
Branch,
Hook,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct GraphEdge {
pub kind: GraphEdgeKind,
pub from: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub to: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hook: Option<HookBadge>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct HookBadge {
pub hook: String,
pub disposition: String,
pub max_triggers: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub error_classes: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub repair_target: Option<String>,
}
pub fn flow_graph_view(flow: &FlowIR) -> FlowGraphView {
let mut nodes = Vec::new();
let mut edges = Vec::new();
let root = vec![PathFrame::Flow {
flow_id: flow.flow_id.clone(),
ir_hash: flow.ir_hash.clone(),
}];
project_body(&flow.body, &root, None, None, &mut nodes, &mut edges);
FlowGraphView {
projection_version: ProjectionVersion,
flow_id: flow.flow_id.to_string(),
ir_hash: flow.ir_hash.to_string(),
nodes,
edges,
flow_hooks: flow
.handlers
.as_deref()
.unwrap_or_default()
.iter()
.map(hook_badge)
.collect(),
}
}
fn project_body(
body: &[StepIR],
prefix: &[PathFrame],
parent_id: Option<&str>,
region: Option<NodeRegion>,
nodes: &mut Vec<GraphNode>,
edges: &mut Vec<GraphEdge>,
) {
let mut previous: Option<String> = None;
for step in body {
let step_id = step.step_id().to_string();
let mut anchor = prefix.to_vec();
match step {
StepIR::Call(CallStepIR { flow_ref, .. }) => anchor.push(PathFrame::Call {
step_id: Some(step.step_id().clone()),
callee_flow_id: flow_ref.flow_id.clone(),
callee_ir_hash: flow_ref.ir_hash.clone(),
}),
_ => anchor.push(PathFrame::Step {
step_id: step.step_id().clone(),
}),
}
if let Some(prev) = previous.take() {
edges.push(GraphEdge {
kind: GraphEdgeKind::Seq,
from: prev,
to: Some(step_id.clone()),
label: None,
hook: None,
});
}
previous = Some(step_id.clone());
nodes.push(GraphNode {
id: step_id.clone(),
run_path: render_run_path(&anchor),
parent_id: parent_id.map(str::to_owned),
region,
body: node_body(step),
});
if let Some(handlers) = step.base().handlers.as_deref() {
for binding in handlers {
edges.push(GraphEdge {
kind: GraphEdgeKind::Hook,
from: step_id.clone(),
to: None,
label: None,
hook: Some(hook_badge(binding)),
});
}
}
match step {
StepIR::If(IfStepIR { then, r#else, .. }) => {
if let Some(first) = then.first() {
edges.push(branch_edge(&step_id, first.step_id().as_ref(), "then"));
}
project_body(
then,
&anchor,
Some(&step_id),
Some(NodeRegion::Then),
nodes,
edges,
);
if let Some(else_body) = r#else.as_deref() {
if let Some(first) = else_body.first() {
edges.push(branch_edge(&step_id, first.step_id().as_ref(), "else"));
}
project_body(
else_body,
&anchor,
Some(&step_id),
Some(NodeRegion::Else),
nodes,
edges,
);
}
}
StepIR::Foreach(ForeachStepIR { body, .. }) => {
project_body(
body,
&anchor,
Some(&step_id),
Some(NodeRegion::Body),
nodes,
edges,
);
}
_ => {}
}
}
}
fn branch_edge(from: &str, to: &str, label: &str) -> GraphEdge {
GraphEdge {
kind: GraphEdgeKind::Branch,
from: from.to_owned(),
to: Some(to.to_owned()),
label: Some(label.to_owned()),
hook: None,
}
}
fn wire<T: Serialize>(value: &T) -> String {
serde_json::to_value(value)
.ok()
.and_then(|v| v.as_str().map(str::to_owned))
.unwrap_or_default()
}
fn expr_summary(expr: &pointlock_ir::Expr) -> String {
serde_json::to_string(expr).unwrap_or_default()
}
fn hook_badge(binding: &HandlerBinding) -> HookBadge {
let (disposition, repair_target) = match &binding.action {
pointlock_ir::HandlerAction::Retry { .. } => ("retry", None),
pointlock_ir::HandlerAction::Continue => ("continue", None),
pointlock_ir::HandlerAction::Escalate { .. } => ("escalate", None),
pointlock_ir::HandlerAction::Abort => ("abort", None),
pointlock_ir::HandlerAction::Repair { flow_ref } => (
"repair",
Some(format!("{}@{}", flow_ref.flow_id, flow_ref.ir_hash)),
),
};
HookBadge {
hook: wire(&binding.hook),
disposition: disposition.to_owned(),
max_triggers: binding.max_triggers,
error_classes: binding
.error_classes
.as_ref()
.map(|classes| classes.iter().map(wire).collect()),
repair_target,
}
}
fn node_body(step: &StepIR) -> GraphNodeBody {
match step {
StepIR::Action(ActionStepIR {
verb,
effect,
binding,
assertions,
..
}) => GraphNodeBody::Action {
verb: verb.as_ref().map(wire),
action_name: binding
.attempts
.first()
.map(|attempt| attempt.action_name.to_string())
.unwrap_or_default(),
mutating: *effect == EffectClassAction::Mutating,
act_chain: binding
.attempts
.iter()
.map(|attempt| wire(&attempt.channel))
.collect(),
assertion_count: assertions.len() as u32,
},
StepIR::Assert(AssertStepIR {
observe,
assertions,
..
}) => GraphNodeBody::Assert {
observe: match serde_json::to_value(observe) {
Ok(serde_json::Value::String(fresh)) => fresh,
Ok(other) => other
.get("fromStep")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_owned(),
Err(_) => String::new(),
},
assertions: assertions.iter().map(assertion_summary).collect(),
},
StepIR::Call(CallStepIR {
flow_ref, inputs, ..
}) => GraphNodeBody::Call {
callee_flow_id: flow_ref.flow_id.to_string(),
callee_ir_hash: flow_ref.ir_hash.to_string(),
input_keys: inputs.keys().map(ToString::to_string).collect(),
},
StepIR::Human(HumanStepIR {
mode,
prompt,
timeout_ms,
..
}) => GraphNodeBody::Human {
mode: wire(mode),
prompt_head: prompt.lines().next().unwrap_or_default().to_owned(),
timeout_ms: *timeout_ms,
},
StepIR::If(IfStepIR { cond, r#else, .. }) => GraphNodeBody::If {
cond: expr_summary(cond),
has_else: r#else.is_some(),
},
StepIR::Foreach(ForeachStepIR { items, r#as, .. }) => GraphNodeBody::Foreach {
items: expr_summary(items),
r#as: r#as.to_string(),
},
StepIR::Let(LetStepIR { bindings, .. }) => GraphNodeBody::Let {
binding_keys: bindings.keys().map(ToString::to_string).collect(),
},
}
}
fn assertion_summary(assertion: &pointlock_ir::AssertionIR) -> AssertionSummary {
let predicate = serde_json::to_value(&assertion.predicate)
.ok()
.and_then(|v| v.get("type").and_then(|t| t.as_str()).map(str::to_owned))
.unwrap_or_default();
AssertionSummary {
assert_id: assertion.assert_id.to_string(),
predicate,
verify_via: assertion.verify_via.iter().map(wire).collect(),
}
}