use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
pub const ENVELOPE_VERSION: u32 = 2;
pub const ENVELOPE_VERSIONS_READ: &[u32] = &[ENVELOPE_VERSION, 1];
pub const MAX_PAYLOAD_TEXT_BYTES: usize = 4096;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Envelope {
pub v: u32,
pub ts: String,
pub stream: String,
pub seq: u64,
pub source: Source,
pub kind: EventKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub phase: Option<Phase>,
#[serde(default)]
pub labels: Labels,
#[serde(default)]
pub payload: Map<String, Value>,
#[serde(default)]
pub artifacts: Vec<ArtifactRef>,
}
impl Envelope {
#[must_use]
pub fn written_at_a_known_version(&self) -> bool {
ENVELOPE_VERSIONS_READ.contains(&self.v)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Phase {
Development,
Integrate,
Review,
Release,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Source {
Agentgraph,
Vcs,
Pipeline,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct EventKind(pub String);
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Labels {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub run_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub round: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub node: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub step: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub persona: Option<String>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[non_exhaustive]
pub enum PipelineKind {
RunStarted,
ConcurrentAcknowledged,
NodeReady,
NodeDispatched,
NodeSettled,
EditCommitted,
CommandAccepted,
EditRejected,
PlannerSurfaceQueued,
PlannerSurfaced,
PlannerReplied,
HumanAttested,
DriverAdopted,
RunStopped,
QuietWorker,
NodeHeld,
NodeUnheld,
DecisionPending,
DecisionCleared,
CrossDagSatisfied,
UpstreamModified,
CompletionRequested,
ReleaseWait,
ReleaseArrived,
ReleaseAdopted,
CriterionChecked,
BodyNotDrafted,
}
impl PipelineKind {
pub const fn as_str(self) -> &'static str {
match self {
Self::RunStarted => "run-started",
Self::ConcurrentAcknowledged => "concurrent-acknowledged",
Self::NodeReady => "node-ready",
Self::NodeDispatched => "node-dispatched",
Self::NodeSettled => "node-settled",
Self::EditCommitted => "edit-committed",
Self::CommandAccepted => "command-accepted",
Self::EditRejected => "edit-rejected",
Self::PlannerSurfaceQueued => "planner-surface-queued",
Self::PlannerSurfaced => "planner-surfaced",
Self::PlannerReplied => "planner-replied",
Self::HumanAttested => "human-attested",
Self::DriverAdopted => "driver-adopted",
Self::RunStopped => "run-stopped",
Self::QuietWorker => "quiet-worker",
Self::NodeHeld => "node-held",
Self::NodeUnheld => "node-unheld",
Self::DecisionPending => "decision-pending",
Self::DecisionCleared => "decision-cleared",
Self::CrossDagSatisfied => "cross-dag-satisfied",
Self::UpstreamModified => "upstream-modified",
Self::CompletionRequested => "completion-requested",
Self::ReleaseWait => "release-wait",
Self::ReleaseArrived => "release-arrived",
Self::ReleaseAdopted => "release-adopted",
Self::CriterionChecked => "criterion-checked",
Self::BodyNotDrafted => "body-not-drafted",
}
}
pub fn from_wire(kind: &EventKind) -> Option<Self> {
PIPELINE_KINDS
.iter()
.copied()
.find(|candidate| candidate.as_str() == kind.0)
}
}
impl std::fmt::Display for PipelineKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl From<PipelineKind> for EventKind {
fn from(kind: PipelineKind) -> Self {
Self(kind.as_str().to_string())
}
}
pub const PIPELINE_KINDS: &[PipelineKind] = &[
PipelineKind::RunStarted,
PipelineKind::ConcurrentAcknowledged,
PipelineKind::NodeReady,
PipelineKind::NodeDispatched,
PipelineKind::NodeSettled,
PipelineKind::EditCommitted,
PipelineKind::CommandAccepted,
PipelineKind::EditRejected,
PipelineKind::PlannerSurfaceQueued,
PipelineKind::PlannerSurfaced,
PipelineKind::PlannerReplied,
PipelineKind::HumanAttested,
PipelineKind::DriverAdopted,
PipelineKind::RunStopped,
PipelineKind::QuietWorker,
PipelineKind::NodeHeld,
PipelineKind::NodeUnheld,
PipelineKind::DecisionPending,
PipelineKind::DecisionCleared,
PipelineKind::CrossDagSatisfied,
PipelineKind::UpstreamModified,
PipelineKind::CompletionRequested,
PipelineKind::ReleaseWait,
PipelineKind::ReleaseArrived,
PipelineKind::ReleaseAdopted,
PipelineKind::CriterionChecked,
PipelineKind::BodyNotDrafted,
];
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ArtifactRef {
pub id: ArtifactId,
pub kind: String,
pub bytes: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ArtifactId(pub String);
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
const GOLDEN: &str = include_str!("../tests/golden/envelope-v2.json");
const GOLDEN_BEFORE: &str = include_str!("../tests/golden/envelope-v1.json");
#[test]
fn the_envelope_this_build_writes_is_the_committed_golden() {
let golden: serde_json::Value = serde_json::from_str(GOLDEN).expect("the golden is JSON");
assert_eq!(
golden["v"],
json!(ENVELOPE_VERSION),
"the golden is not at the version this build writes. Bump ENVELOPE_VERSION and \
add tests/golden/envelope-v<n>.json together, keeping the older file as the \
read-compatibility reference"
);
let envelope: Envelope = serde_json::from_value(golden.clone()).expect("it parses");
assert_eq!(envelope.v, ENVELOPE_VERSION);
assert_eq!(envelope.source, Source::Pipeline);
assert_eq!(envelope.kind, EventKind("edit-committed".into()));
assert!(envelope.written_at_a_known_version());
assert_eq!(
serde_json::to_value(&envelope).expect("it serializes"),
golden,
"the envelope changed shape. Bump ENVELOPE_VERSION, add the golden for the new \
one, and keep this file as what the older version looked like"
);
}
#[test]
fn the_envelope_version_before_this_one_is_still_read_and_never_restamped() {
let before: serde_json::Value =
serde_json::from_str(GOLDEN_BEFORE).expect("the golden is JSON");
assert_eq!(before["v"], json!(1));
assert!(
ENVELOPE_VERSIONS_READ.contains(&1),
"this build no longer reads the version its committed fixture is written at"
);
let envelope: Envelope = serde_json::from_value(before.clone()).expect("it parses");
assert!(envelope.written_at_a_known_version());
assert_eq!(envelope.v, 1, "a version this build read was rewritten");
assert_eq!(
serde_json::to_value(&envelope).expect("it serializes"),
before
);
let ahead: Envelope = serde_json::from_value(json!({
"v": ENVELOPE_VERSION + 1,
"ts": "2026-09-09T04:00:00.000Z",
"stream": "onepipeline-7f3a",
"seq": 43,
"source": "pipeline",
"kind": "edit-committed",
"labels": {},
"payload": {},
"artifacts": []
}))
.expect("a newer build's record still parses structurally");
assert!(!ahead.written_at_a_known_version());
}
#[test]
fn an_envelopes_optional_fields_round_trip_and_are_omitted_when_empty() {
let bare = json!({
"v": ENVELOPE_VERSION,
"ts": "2026-09-09T04:00:00.000Z",
"stream": "onepipeline-7f3a",
"seq": 1,
"source": "pipeline",
"kind": "node-ready",
"labels": {},
"payload": {},
"artifacts": []
});
let envelope: Envelope = serde_json::from_value(bare.clone()).expect("it parses");
assert_eq!(envelope.phase, None);
assert_eq!(serde_json::to_value(&envelope).expect("serializes"), bare);
let mut with = bare.clone();
with["phase"] = json!("release");
let envelope: Envelope = serde_json::from_value(with.clone()).expect("it parses");
assert_eq!(envelope.phase, Some(Phase::Release));
assert_eq!(serde_json::to_value(&envelope).expect("serializes"), with);
let minimal = json!({
"v": ENVELOPE_VERSION,
"ts": "2026-09-09T04:00:00.000Z",
"stream": "onepipeline-7f3a",
"seq": 2,
"source": "pipeline",
"kind": "node-ready"
});
let envelope: Envelope = serde_json::from_value(minimal).expect("it parses");
assert!(envelope.payload.is_empty() && envelope.artifacts.is_empty());
assert_eq!(envelope.labels, Labels::default());
}
}