feat: add main event types

This commit is contained in:
fkukuck 2026-06-21 16:00:50 +02:00
parent b2db168ca3
commit b815f07211
3 changed files with 485 additions and 0 deletions

View file

@ -15,6 +15,7 @@ pub mod graph;
mod id;
pub mod interview;
pub mod llm_backend;
pub mod main_event;
pub mod manifest_path;
pub mod outcome;
pub mod pair;
@ -74,6 +75,11 @@ pub use graph::{
};
pub use interview::{InterviewQuestionRecord, QuestionType};
pub use llm_backend::AgentBackend;
pub use main_event::{
MainEvent, MainEventBody, MainEventCancelledProps, MainEventCompletedProps,
MainEventCreatedProps, MainEventEnvelope, MainEventFailedProps, MainEventLifecycleProps,
MainEventSource, MainEventStartedProps,
};
pub use manifest_path::{ManifestPath, ManifestPathParseError};
pub use outcome::{
FailureCategory, FailureDetail, NodeResult, Outcome, OutcomeMeta, StageOutcome, StageState,

View file

@ -0,0 +1,307 @@
use chrono::{DateTime, Utc};
use serde::de::Error as DeError;
use serde::ser::Error as SerError;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use serde_json::{Map, Value, json};
use crate::{
AutomationRef, BilledTokenCounts, FailureCategory, FailureReason, GitContext, Principal, RunId,
SuccessReason,
};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MainEventEnvelope {
pub seq: u32,
pub event: MainEvent,
}
#[derive(Debug, Clone, PartialEq)]
pub struct MainEvent {
pub id: String,
pub ts: DateTime<Utc>,
pub body: MainEventBody,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
#[serde(tag = "event", content = "properties")]
pub enum MainEventBody {
#[serde(rename = "fabro.run.created")]
RunCreated(MainEventCreatedProps),
#[serde(rename = "fabro.run.started")]
RunStarted(MainEventStartedProps),
#[serde(rename = "fabro.run.running")]
RunRunning(MainEventLifecycleProps),
#[serde(rename = "fabro.run.completed")]
RunCompleted(MainEventCompletedProps),
#[serde(rename = "fabro.run.failed")]
RunFailed(MainEventFailedProps),
#[serde(rename = "fabro.run.cancelled")]
RunCancelled(MainEventCancelledProps),
#[serde(rename = "fabro.run.paused")]
RunPaused(MainEventLifecycleProps),
#[serde(rename = "fabro.run.unpaused")]
RunUnpaused(MainEventLifecycleProps),
Unknown {
name: String,
properties: Value,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MainEventSource {
pub run_id: RunId,
pub source_event_id: String,
pub source_event_ts: DateTime<Utc>,
pub source_event_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub actor: Option<Principal>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MainEventCreatedProps {
#[serde(flatten)]
pub source: MainEventSource,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workflow_slug: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub automation: Option<AutomationRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub git: Option<GitContext>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_id: Option<RunId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provenance_subject: Option<Principal>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MainEventLifecycleProps {
#[serde(flatten)]
pub source: MainEventSource,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MainEventStartedProps {
#[serde(flatten)]
pub source: MainEventSource,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_branch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_sha: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub run_branch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worktree_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub goal: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MainEventCompletedProps {
#[serde(flatten)]
pub source: MainEventSource,
pub status: String,
pub reason: SuccessReason,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub total_usd_micros: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub final_git_commit_sha: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub automation: Option<AutomationRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub billing: Option<BilledTokenCounts>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MainEventFailedProps {
#[serde(flatten)]
pub source: MainEventSource,
pub reason: FailureReason,
pub category: FailureCategory,
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub final_git_commit_sha: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub automation: Option<AutomationRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub billing: Option<BilledTokenCounts>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MainEventCancelledProps {
#[serde(flatten)]
pub source: MainEventSource,
pub reason: FailureReason,
pub category: FailureCategory,
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub final_git_commit_sha: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub automation: Option<AutomationRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub billing: Option<BilledTokenCounts>,
}
#[derive(Debug, Clone, Deserialize)]
struct MainEventRaw {
id: String,
ts: DateTime<Utc>,
event: String,
#[serde(default = "default_properties")]
properties: Value,
}
struct MainEventParts<'a> {
id: String,
ts: DateTime<Utc>,
event: &'a str,
properties: &'a Value,
}
impl MainEventBody {
pub fn event_name(&self) -> &str {
match self {
Self::RunCreated(_) => "fabro.run.created",
Self::RunStarted(_) => "fabro.run.started",
Self::RunRunning(_) => "fabro.run.running",
Self::RunCompleted(_) => "fabro.run.completed",
Self::RunFailed(_) => "fabro.run.failed",
Self::RunCancelled(_) => "fabro.run.cancelled",
Self::RunPaused(_) => "fabro.run.paused",
Self::RunUnpaused(_) => "fabro.run.unpaused",
Self::Unknown { name, .. } => name.as_str(),
}
}
fn properties_value(&self) -> serde_json::Result<Value> {
if let Self::Unknown { properties, .. } = self {
return Ok(properties.clone());
}
match serde_json::to_value(self)? {
Value::Object(mut map) => {
Ok(map.remove("properties").unwrap_or_else(default_properties))
}
_ => Ok(default_properties()),
}
}
}
fn is_known_main_event_name(event: &str) -> bool {
matches!(
event,
"fabro.run.created"
| "fabro.run.started"
| "fabro.run.running"
| "fabro.run.completed"
| "fabro.run.failed"
| "fabro.run.cancelled"
| "fabro.run.paused"
| "fabro.run.unpaused"
)
}
impl MainEvent {
pub fn from_value(value: Value) -> serde_json::Result<Self> {
let raw: MainEventRaw = serde_json::from_value(value)?;
Self::from_parts(MainEventParts {
id: raw.id,
ts: raw.ts,
event: &raw.event,
properties: &raw.properties,
})
}
pub fn from_ref(value: &Value) -> serde_json::Result<Self> {
let obj = value.as_object().ok_or_else(|| {
<serde_json::Error as DeError>::custom("main event must be a JSON object")
})?;
let id = obj.get("id").and_then(Value::as_str).ok_or_else(|| {
<serde_json::Error as DeError>::custom("missing or non-string field: id")
})?;
let ts = obj
.get("ts")
.ok_or_else(|| <serde_json::Error as DeError>::custom("missing field: ts"))
.and_then(DateTime::<Utc>::deserialize)?;
let event = obj.get("event").and_then(Value::as_str).ok_or_else(|| {
<serde_json::Error as DeError>::custom("missing or non-string field: event")
})?;
let properties = obj
.get("properties")
.cloned()
.unwrap_or_else(default_properties);
Self::from_parts(MainEventParts {
id: id.to_string(),
ts,
event,
properties: &properties,
})
}
fn from_parts(parts: MainEventParts<'_>) -> serde_json::Result<Self> {
let body_payload = json!({
"event": parts.event,
"properties": parts.properties,
});
let body: MainEventBody = match serde_json::from_value(body_payload) {
Ok(body) => body,
Err(err) if is_known_main_event_name(parts.event) => return Err(err),
Err(_) => MainEventBody::Unknown {
name: parts.event.to_string(),
properties: parts.properties.clone(),
},
};
Ok(Self {
id: parts.id,
ts: parts.ts,
body,
})
}
pub fn to_value(&self) -> serde_json::Result<Value> {
let mut map = Map::new();
map.insert("id".to_string(), Value::String(self.id.clone()));
map.insert("ts".to_string(), serde_json::to_value(self.ts)?);
map.insert(
"event".to_string(),
Value::String(self.body.event_name().to_string()),
);
map.insert("properties".to_string(), self.body.properties_value()?);
Ok(Value::Object(map))
}
pub fn event_name(&self) -> &str {
self.body.event_name()
}
pub fn properties(&self) -> serde_json::Result<Value> {
self.body.properties_value()
}
}
impl Serialize for MainEvent {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
self.to_value()
.map_err(S::Error::custom)?
.serialize(serializer)
}
}
impl<'de> Deserialize<'de> for MainEvent {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = Value::deserialize(deserializer)?;
Self::from_value(value).map_err(D::Error::custom)
}
}
fn default_properties() -> Value {
Value::Object(Map::new())
}

View file

@ -0,0 +1,172 @@
use chrono::{TimeZone, Utc};
use fabro_types::{
AuthMethod, FailureCategory, FailureReason, IdpIdentity, MainEvent, MainEventBody,
MainEventCancelledProps, MainEventCompletedProps, MainEventCreatedProps,
MainEventFailedProps, MainEventLifecycleProps, MainEventSource, MainEventStartedProps,
Principal, RunId, SuccessReason,
};
use serde_json::json;
fn run_id() -> RunId {
"01JT56VE4Z5NZ814GZN2JZD65A".parse().unwrap()
}
fn actor() -> Principal {
Principal::user(
IdpIdentity::new("https://github.com", "123").unwrap(),
"fabian".to_string(),
AuthMethod::Github,
)
}
fn source(source_event_name: &str) -> MainEventSource {
MainEventSource {
run_id: run_id(),
source_event_id: format!("evt-{source_event_name}"),
source_event_ts: Utc.with_ymd_and_hms(2026, 6, 21, 10, 0, 0).unwrap(),
source_event_name: source_event_name.to_string(),
actor: Some(actor()),
}
}
fn main_event(body: MainEventBody) -> MainEvent {
MainEvent {
id: "019796d3-d2d0-7c81-a65a-6f3ddc67d4a4".to_string(),
ts: Utc.with_ymd_and_hms(2026, 6, 21, 10, 1, 0).unwrap(),
body,
}
}
#[test]
fn v1_event_names_are_stable() {
let cases = [
(
MainEventBody::RunCreated(MainEventCreatedProps {
source: source("run.created"),
title: Some("Triage bug".to_string()),
workflow_slug: Some("triage".to_string()),
automation: None,
git: None,
parent_id: None,
provenance_subject: Some(actor()),
}),
"fabro.run.created",
),
(
MainEventBody::RunStarted(MainEventStartedProps {
source: source("run.started"),
name: "triage".to_string(),
base_branch: Some("main".to_string()),
base_sha: Some("abc123".to_string()),
run_branch: Some("fabro/run-1".to_string()),
worktree_dir: None,
goal: Some("Triage bug".to_string()),
}),
"fabro.run.started",
),
(
MainEventBody::RunRunning(MainEventLifecycleProps {
source: source("run.running"),
}),
"fabro.run.running",
),
(
MainEventBody::RunCompleted(MainEventCompletedProps {
source: source("run.completed"),
status: "succeeded".to_string(),
reason: SuccessReason::Completed,
total_usd_micros: Some(12),
final_git_commit_sha: Some("abc123".to_string()),
automation: None,
billing: None,
}),
"fabro.run.completed",
),
(
MainEventBody::RunFailed(MainEventFailedProps {
source: source("run.failed"),
reason: FailureReason::WorkflowError,
category: FailureCategory::Deterministic,
message: "boom".to_string(),
final_git_commit_sha: None,
automation: None,
billing: None,
}),
"fabro.run.failed",
),
(
MainEventBody::RunCancelled(MainEventCancelledProps {
source: source("run.failed"),
reason: FailureReason::Cancelled,
category: FailureCategory::Canceled,
message: "cancelled".to_string(),
final_git_commit_sha: None,
automation: None,
billing: None,
}),
"fabro.run.cancelled",
),
(
MainEventBody::RunPaused(MainEventLifecycleProps {
source: source("run.paused"),
}),
"fabro.run.paused",
),
(
MainEventBody::RunUnpaused(MainEventLifecycleProps {
source: source("run.unpaused"),
}),
"fabro.run.unpaused",
),
];
for (body, expected) in cases {
assert_eq!(body.event_name(), expected);
assert_eq!(main_event(body).event_name(), expected);
}
}
#[test]
fn main_event_round_trips_flat_event_properties() {
let event = main_event(MainEventBody::RunCompleted(MainEventCompletedProps {
source: source("run.completed"),
status: "succeeded".to_string(),
reason: SuccessReason::Completed,
total_usd_micros: Some(42),
final_git_commit_sha: Some("deadbeef".to_string()),
automation: None,
billing: None,
}));
let value = serde_json::to_value(&event).unwrap();
assert_eq!(value["event"], "fabro.run.completed");
assert_eq!(value["properties"]["run_id"], run_id().to_string());
assert_eq!(value["properties"]["source_event_name"], "run.completed");
assert_eq!(value["properties"]["status"], "succeeded");
let parsed: MainEvent = serde_json::from_value(value.clone()).unwrap();
assert_eq!(serde_json::to_value(parsed).unwrap(), value);
}
#[test]
fn unknown_main_event_preserves_name_and_properties() {
let value = json!({
"id": "019796d3-d2d0-7c81-a65a-6f3ddc67d4a4",
"ts": "2026-06-21T10:01:00Z",
"event": "github.issue_comment.created",
"properties": {
"delivery_id": "delivery-1",
"action": "created"
}
});
let parsed: MainEvent = serde_json::from_value(value.clone()).unwrap();
match &parsed.body {
MainEventBody::Unknown { name, properties } => {
assert_eq!(name, "github.issue_comment.created");
assert_eq!(properties["delivery_id"], "delivery-1");
}
other => panic!("expected unknown event, got {other:?}"),
}
assert_eq!(serde_json::to_value(parsed).unwrap(), value);
}