use serde::{Deserialize, Serialize};
use super::kind::EventType;
use super::payload::EventPayload;
use super::task::SourceSpeaker;
use crate::model::{
EngagementId, ObjectId, ReviewEndpoint, RevisionId, RevisionSource, WorkObjectId,
};
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GitProvenance {
pub source: RevisionSource,
pub base: ReviewEndpoint,
pub target: ReviewEndpoint,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Revision {
pub id: RevisionId,
pub object_id: ObjectId,
#[serde(skip_serializing_if = "Option::is_none")]
pub git_provenance: Option<GitProvenance>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(
rename_all = "snake_case",
rename_all_fields = "camelCase",
tag = "kind"
)]
pub enum WorkObjectProposal {
Revision {
revision: Revision,
object_artifact_content_hash: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
supersedes: Vec<RevisionId>,
},
TaskAttempt {
task_attempt_id: WorkObjectId,
project_path: String,
claude_session_uuid: String,
initial_prompt_hash: String,
#[serde(skip_serializing_if = "Option::is_none")]
predecessor: Option<WorkObjectId>,
#[serde(skip_serializing_if = "Option::is_none")]
base_state_fingerprint: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
source_speaker: Option<SourceSpeaker>,
},
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WorkObjectProposedPayload {
pub engagement_id: EngagementId,
pub work_object: WorkObjectProposal,
}
impl EventPayload for WorkObjectProposedPayload {
fn event_type(&self) -> EventType {
EventType::WorkObjectProposed
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{CommitRangeCaptureMode, JournalId, WorktreeCaptureMode};
fn git_provenance() -> GitProvenance {
GitProvenance {
source: RevisionSource::GitWorktree {
mode: WorktreeCaptureMode::CombinedHeadToWorkingTree,
include_untracked: true,
pathspecs: Vec::new(),
},
base: ReviewEndpoint::GitCommit {
commit_oid: "abc".to_owned(),
tree_oid: "def".to_owned(),
},
target: ReviewEndpoint::GitWorkingTree {
worktree_root: "/repo".to_owned(),
},
}
}
fn revision_payload() -> WorkObjectProposedPayload {
WorkObjectProposedPayload {
engagement_id: EngagementId::new("engagement:sha256:e"),
work_object: WorkObjectProposal::Revision {
revision: Revision {
id: RevisionId::new("rev:sha256:r"),
object_id: ObjectId::new("obj:sha256:o"),
git_provenance: Some(git_provenance()),
},
object_artifact_content_hash: "sha256:artifact".to_owned(),
supersedes: vec![],
},
}
}
#[test]
fn revision_arm_round_trips_and_tags_with_kind() {
let payload = revision_payload();
let json = serde_json::to_value(&payload).unwrap();
assert_eq!(json["engagementId"], "engagement:sha256:e");
assert_eq!(json["workObject"]["kind"], "revision");
assert_eq!(json["workObject"]["revision"]["id"], "rev:sha256:r");
assert_eq!(json["workObject"]["revision"]["objectId"], "obj:sha256:o");
assert_eq!(
json["workObject"]["objectArtifactContentHash"],
"sha256:artifact"
);
assert_eq!(
json["workObject"]["revision"]["gitProvenance"]["source"]["kind"],
"git_worktree"
);
let parsed: WorkObjectProposedPayload = serde_json::from_value(json).unwrap();
assert_eq!(parsed, payload);
}
#[test]
fn revision_arm_omits_git_provenance_when_absent() {
let payload = WorkObjectProposedPayload {
engagement_id: EngagementId::new("engagement:sha256:e"),
work_object: WorkObjectProposal::Revision {
revision: Revision {
id: RevisionId::new("rev:sha256:r"),
object_id: ObjectId::new("obj:sha256:o"),
git_provenance: None,
},
object_artifact_content_hash: "sha256:artifact".to_owned(),
supersedes: vec![],
},
};
let json = serde_json::to_value(&payload).unwrap();
assert!(
json["workObject"]["revision"]
.get("gitProvenance")
.is_none()
);
let parsed: WorkObjectProposedPayload = serde_json::from_value(json).unwrap();
assert_eq!(parsed, payload);
}
#[test]
fn revision_arm_serializes_object_artifact_content_hash() {
let payload = revision_payload();
let json = serde_json::to_value(&payload).unwrap();
assert_eq!(
json["workObject"]["objectArtifactContentHash"],
"sha256:artifact"
);
assert!(
json["workObject"]
.get("snapshotArtifactContentHash")
.is_none()
);
}
#[test]
fn task_attempt_arm_serializes_base_state_fingerprint() {
let payload = WorkObjectProposedPayload {
engagement_id: EngagementId::new("engagement:sha256:t"),
work_object: WorkObjectProposal::TaskAttempt {
task_attempt_id: WorkObjectId::new("task-attempt:sha256:a"),
project_path: "/repo".to_owned(),
claude_session_uuid: "uuid-1".to_owned(),
initial_prompt_hash: "sha256:prompt".to_owned(),
predecessor: None,
base_state_fingerprint: Some("sha256:base".to_owned()),
source_speaker: None,
},
};
let json = serde_json::to_value(&payload).unwrap();
assert_eq!(json["workObject"]["baseStateFingerprint"], "sha256:base");
assert!(json["workObject"].get("baseSnapshotFingerprint").is_none());
}
#[test]
fn task_attempt_arm_round_trips_and_tags_with_kind() {
let payload = WorkObjectProposedPayload {
engagement_id: EngagementId::new("engagement:sha256:t"),
work_object: WorkObjectProposal::TaskAttempt {
task_attempt_id: WorkObjectId::new("task-attempt:sha256:a"),
project_path: "/repo".to_owned(),
claude_session_uuid: "uuid-1".to_owned(),
initial_prompt_hash: "sha256:prompt".to_owned(),
predecessor: None,
base_state_fingerprint: None,
source_speaker: Some(SourceSpeaker::Agent),
},
};
let json = serde_json::to_value(&payload).unwrap();
assert_eq!(json["workObject"]["kind"], "task_attempt");
assert_eq!(json["workObject"]["taskAttemptId"], "task-attempt:sha256:a");
assert_eq!(json["workObject"]["sourceSpeaker"], "agent");
assert!(json["workObject"].get("predecessor").is_none());
let parsed: WorkObjectProposedPayload = serde_json::from_value(json).unwrap();
assert_eq!(parsed, payload);
}
#[test]
fn scoped_provenance_serializes_pathspecs_inside_the_source() {
let mut provenance = git_provenance();
provenance.source = RevisionSource::GitWorktree {
mode: WorktreeCaptureMode::CombinedHeadToWorkingTree,
include_untracked: true,
pathspecs: vec!["packages/foo".to_owned()],
};
let payload = WorkObjectProposedPayload {
engagement_id: EngagementId::new("engagement:sha256:e"),
work_object: WorkObjectProposal::Revision {
revision: Revision {
id: RevisionId::new("rev:sha256:r"),
object_id: ObjectId::new("obj:sha256:o"),
git_provenance: Some(provenance),
},
object_artifact_content_hash: "sha256:artifact".to_owned(),
supersedes: vec![],
},
};
let json = serde_json::to_value(&payload).unwrap();
assert_eq!(
json["workObject"]["revision"]["gitProvenance"]["source"]["pathspecs"][0],
"packages/foo"
);
let parsed: WorkObjectProposedPayload = serde_json::from_value(json).unwrap();
assert_eq!(parsed, payload);
}
#[test]
fn unscoped_provenance_payload_omits_pathspecs() {
let json = serde_json::to_value(revision_payload()).unwrap();
assert!(
json["workObject"]["revision"]["gitProvenance"]["source"]
.get("pathspecs")
.is_none()
);
}
#[test]
fn unused_range_mode_is_constructible() {
let _ = CommitRangeCaptureMode::BaseTreeToTargetTree;
}
#[test]
fn payload_reports_work_object_proposed_event_type() {
assert_eq!(
revision_payload().event_type(),
EventType::WorkObjectProposed
);
}
#[test]
fn empty_supersedes_is_omitted_from_serialization() {
let payload = revision_payload();
let json = serde_json::to_value(&payload).unwrap();
assert!(json["workObject"].get("supersedes").is_none());
}
#[test]
fn non_empty_supersedes_round_trips_as_an_array() {
let payload = WorkObjectProposedPayload {
engagement_id: EngagementId::new("engagement:sha256:e"),
work_object: WorkObjectProposal::Revision {
revision: Revision {
id: RevisionId::new("rev:sha256:r"),
object_id: ObjectId::new("obj:sha256:o"),
git_provenance: None,
},
object_artifact_content_hash: "sha256:artifact".to_owned(),
supersedes: vec![
RevisionId::new("rev:sha256:a"),
RevisionId::new("rev:sha256:b"),
],
},
};
let json = serde_json::to_value(&payload).unwrap();
assert_eq!(json["workObject"]["supersedes"][0], "rev:sha256:a");
assert_eq!(json["workObject"]["supersedes"][1], "rev:sha256:b");
let parsed: WorkObjectProposedPayload = serde_json::from_value(json).unwrap();
assert_eq!(parsed, payload);
}
#[test]
fn set_equal_supersedes_converge_to_one_payload_hash() {
use crate::session::event::{EventTarget, ShoreEvent, Writer};
use crate::session::workflow::util::sorted_unique;
fn event_with_supersedes(supersedes: Vec<RevisionId>) -> ShoreEvent {
let revision_id = RevisionId::new("rev:sha256:x");
ShoreEvent::new(
EventType::WorkObjectProposed,
format!("work_object_proposed:{}", revision_id.as_str()),
EventTarget::for_revision(
JournalId::new("journal:default"),
revision_id.clone(),
None,
)
.unwrap(),
Writer::shore_local("test"),
WorkObjectProposedPayload {
engagement_id: EngagementId::new("engagement:sha256:e"),
work_object: WorkObjectProposal::Revision {
revision: Revision {
id: revision_id,
object_id: ObjectId::new("obj:sha256:o"),
git_provenance: None,
},
object_artifact_content_hash: "sha256:artifact".to_owned(),
supersedes,
},
},
"2026-06-04T00:00:00Z",
)
.unwrap()
}
let a = event_with_supersedes(sorted_unique(vec![
RevisionId::new("rev:sha256:b"),
RevisionId::new("rev:sha256:a"),
RevisionId::new("rev:sha256:b"),
]));
let b = event_with_supersedes(sorted_unique(vec![
RevisionId::new("rev:sha256:a"),
RevisionId::new("rev:sha256:b"),
]));
assert_eq!(a.payload_hash, b.payload_hash);
}
}