diff --git a/crates/buzz-cli/README.md b/crates/buzz-cli/README.md index a8c668cf06..ffac15fb37 100644 --- a/crates/buzz-cli/README.md +++ b/crates/buzz-cli/README.md @@ -42,6 +42,10 @@ buzz messages delete --event # Diffs buzz messages send-diff --channel --diff - --repo https://github.com/org/repo --commit abc123 < diff.patch +# Agent job handoffs +buzz jobs handoff --channel --job --manifest result.json +generate-result-manifest | buzz jobs handoff --channel --job --manifest - + # Channels buzz channels list buzz channels create --name "my-channel" --type stream --visibility open @@ -107,6 +111,7 @@ stored rules in `validation_error` so an owner can remove and repair them. | | `thread` | Get a message thread | | | `search` | Full-text search, filterable by author | | | `vote` | Vote on a forum post | +| `jobs` | `handoff` | Publish a validated, structured job result | | `channels` | `list` | List channels | | | `get` | Get channel details | | | `create` | Create a channel | diff --git a/crates/buzz-cli/src/commands/jobs.rs b/crates/buzz-cli/src/commands/jobs.rs new file mode 100644 index 0000000000..aaa530ada4 --- /dev/null +++ b/crates/buzz-cli/src/commands/jobs.rs @@ -0,0 +1,117 @@ +use buzz_core::job::JobResultPayload; + +use crate::client::{normalize_write_response, BuzzClient}; +use crate::error::CliError; +use crate::validate::{parse_event_id, parse_uuid, read_file_or_stdin, sdk_err}; + +fn parse_manifest(content: &str) -> Result { + let payload: JobResultPayload = serde_json::from_str(content) + .map_err(|error| CliError::Usage(format!("invalid job handoff manifest: {error}")))?; + payload + .validate() + .map_err(|error| CliError::Usage(error.to_string()))?; + Ok(payload) +} + +fn ensure_job_matches(payload: &JobResultPayload, job_event_id: &str) -> Result<(), CliError> { + if payload.job_request.eq_ignore_ascii_case(job_event_id) { + Ok(()) + } else { + Err(CliError::Usage( + "manifest jobRequest must match --job".into(), + )) + } +} + +async fn cmd_handoff( + client: &BuzzClient, + channel: &str, + job: &str, + manifest: &str, +) -> Result<(), CliError> { + let channel_id = parse_uuid(channel)?; + let job_event_id = parse_event_id(job)?; + let manifest_content = read_file_or_stdin(manifest)?; + let payload = parse_manifest(&manifest_content)?; + ensure_job_matches(&payload, &job_event_id.to_hex())?; + + let builder = + buzz_sdk::build_job_result(channel_id, job_event_id, &payload).map_err(sdk_err)?; + let event = client.sign_event(builder)?; + let response = client.submit_event(event).await?; + println!("{}", normalize_write_response(&response)); + Ok(()) +} + +pub async fn dispatch(command: crate::JobsCmd, client: &BuzzClient) -> Result<(), CliError> { + match command { + crate::JobsCmd::Handoff { + channel, + job, + manifest, + } => cmd_handoff(client, &channel, &job, &manifest).await, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const JOB_EVENT: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + fn valid_manifest() -> String { + serde_json::json!({ + "schemaVersion": 1, + "jobRequest": JOB_EVENT, + "requestedOutcome": "Make the result inspectable", + "outcome": "The handoff is ready.", + "lastProgress": "Verification completed.", + "disposition": "completed", + "artifacts": [{ + "kind": "pull_request", + "label": "Pull request", + "reference": "https://github.com/block/buzz/pull/1", + "sourceState": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }], + "verification": [{ + "label": "just ci", + "status": "passed", + "evidence": "exit 0" + }] + }) + .to_string() + } + + #[test] + fn parses_and_validates_manifest() { + let payload = parse_manifest(&valid_manifest()).expect("valid manifest"); + assert_eq!(payload.job_request, JOB_EVENT); + assert_eq!(payload.artifacts.len(), 1); + } + + #[test] + fn rejects_invalid_json() { + assert!(matches!( + parse_manifest("{not-json"), + Err(CliError::Usage(_)) + )); + } + + #[test] + fn rejects_invalid_contract() { + let manifest = valid_manifest().replace("\"schemaVersion\":1", "\"schemaVersion\":2"); + assert!(matches!(parse_manifest(&manifest), Err(CliError::Usage(_)))); + } + + #[test] + fn rejects_manifest_for_different_job() { + let payload = parse_manifest(&valid_manifest()).expect("valid manifest"); + assert!(matches!( + ensure_job_matches( + &payload, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ), + Err(CliError::Usage(_)) + )); + } +} diff --git a/crates/buzz-cli/src/commands/mod.rs b/crates/buzz-cli/src/commands/mod.rs index 8691590636..1102ba3870 100644 --- a/crates/buzz-cli/src/commands/mod.rs +++ b/crates/buzz-cli/src/commands/mod.rs @@ -5,6 +5,7 @@ pub mod dms; pub mod emoji; pub mod feed; pub mod issues; +pub mod jobs; pub mod mem; pub mod messages; pub mod moderation; diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 6ab81a082d..2715341d17 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -179,6 +179,9 @@ enum Cmd { /// Send, read, search, and manage messages #[command(subcommand)] Messages(MessagesCmd), + /// Publish inspectable agent job handoffs + #[command(subcommand)] + Jobs(JobsCmd), /// Create, configure, and manage channels #[command(subcommand)] Channels(ChannelsCmd), @@ -498,6 +501,25 @@ pub enum MessagesCmd { }, } +#[derive(Subcommand)] +pub enum JobsCmd { + /// Publish a structured result for an agent job (kind 43004) + #[command(after_help = "Examples:\n \ +buzz jobs handoff --channel --job --manifest result.json\n \ +cat result.json | buzz jobs handoff --channel --job --manifest -")] + Handoff { + /// Channel UUID containing the originating job + #[arg(long)] + channel: String, + /// Event ID of the originating kind 43001 job request + #[arg(long)] + job: String, + /// Job result JSON file, or '-' to read from stdin + #[arg(long)] + manifest: String, + }, +} + #[derive(Subcommand)] pub enum ChannelsCmd { /// List channels visible to the current identity @@ -1770,6 +1792,7 @@ async fn run(cli: Cli) -> Result<(), CliError> { match cli.command { Cmd::Agents(sub) => commands::agents::dispatch(sub, &client).await, Cmd::Messages(sub) => commands::messages::dispatch(sub, &client, &cli.format).await, + Cmd::Jobs(sub) => commands::jobs::dispatch(sub, &client).await, Cmd::Channels(sub) => commands::channels::dispatch(sub, &client, &cli.format).await, Cmd::Canvas(sub) => commands::channels::dispatch_canvas(sub, &client).await, Cmd::Reactions(sub) => commands::reactions::dispatch(sub, &client).await, @@ -1795,7 +1818,7 @@ async fn run(cli: Cli) -> Result<(), CliError> { #[cfg(test)] mod tests { use super::*; - use clap::CommandFactory; + use clap::{CommandFactory, Parser}; /// Smoke test: CLI definition is valid and parseable. #[test] @@ -1803,6 +1826,35 @@ mod tests { Cli::command().debug_assert(); } + #[test] + fn jobs_handoff_parses_file_and_stdin_manifests() { + for manifest in ["result.json", "-"] { + let cli = Cli::try_parse_from([ + "buzz", + "jobs", + "handoff", + "--channel", + "550e8400-e29b-41d4-a716-446655440000", + "--job", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "--manifest", + manifest, + ]) + .expect("jobs handoff should parse"); + + assert!(matches!( + cli.command, + Cmd::Jobs(JobsCmd::Handoff { + channel, + job, + manifest: parsed_manifest, + }) if channel == "550e8400-e29b-41d4-a716-446655440000" + && job == "a".repeat(64) + && parsed_manifest == manifest + )); + } + } + #[test] fn command_inventory_is_stable() { let expected_groups: Vec<&str> = vec![ @@ -1813,6 +1865,7 @@ mod tests { "emoji", "feed", "issues", + "jobs", "media", "mem", "messages", @@ -1891,6 +1944,7 @@ mod tests { "vote" ] ); + assert_eq!(names(&cmd, "jobs"), vec!["handoff"]); assert_eq!( names(&cmd, "channels"), vec![ @@ -2002,6 +2056,7 @@ mod tests { ("emoji", 5), ("feed", 1), ("issues", 4), + ("jobs", 1), ("media", 1), ("messages", 8), ("pack", 2), diff --git a/crates/buzz-core/src/job.rs b/crates/buzz-core/src/job.rs new file mode 100644 index 0000000000..1df25a7719 --- /dev/null +++ b/crates/buzz-core/src/job.rs @@ -0,0 +1,732 @@ +//! Structured payloads for the signed agent job lifecycle. + +use serde::{Deserialize, Serialize}; +use url::Url; + +/// Current schema version for [`JobResultPayload`]. +pub const JOB_RESULT_SCHEMA_VERSION: u8 = 1; + +/// Maximum serialized size of a job result payload. +pub const MAX_JOB_RESULT_BYTES: usize = 64 * 1024; + +const MAX_OUTCOME_BYTES: usize = 8 * 1024; +const MAX_DETAIL_BYTES: usize = 4 * 1024; +const MAX_LABEL_BYTES: usize = 512; +const MAX_REFERENCE_BYTES: usize = 2 * 1024; +const MAX_SOURCE_STATE_BYTES: usize = 512; +const MAX_ITEMS: usize = 50; + +/// A complete, inspectable handoff for a finished agent job (`kind:43004`). +/// +/// The payload deliberately carries references and provenance, not embedded +/// local file contents. Producers should upload files separately or point at a +/// repository, canvas, workflow run, build, or deployment that readers can +/// inspect. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct JobResultPayload { + /// Payload contract version. Currently [`JOB_RESULT_SCHEMA_VERSION`]. + pub schema_version: u8, + /// Hex event id of the originating `kind:43001` job request. + pub job_request: String, + /// The outcome the requester asked for. + pub requested_outcome: String, + /// Concise final outcome. + pub outcome: String, + /// Last meaningful progress or phase reached before the result. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_progress: Option, + /// Final state of the work. + pub disposition: JobDisposition, + /// Inspectable artifacts or proof produced by the job. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub artifacts: Vec, + /// Verification performed against the result. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub verification: Vec, + /// Concrete blocker when the result is blocked or partial. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub blocker: Option, +} + +impl JobResultPayload { + /// Validate the payload contract, including cross-field invariants. + pub fn validate(&self) -> Result<(), JobResultError> { + if self.schema_version != JOB_RESULT_SCHEMA_VERSION { + return Err(JobResultError::UnsupportedSchemaVersion( + self.schema_version, + )); + } + validate_event_id(&self.job_request)?; + validate_required_text( + "requestedOutcome", + &self.requested_outcome, + MAX_OUTCOME_BYTES, + )?; + validate_required_text("outcome", &self.outcome, MAX_OUTCOME_BYTES)?; + validate_optional_text( + "lastProgress", + self.last_progress.as_deref(), + MAX_DETAIL_BYTES, + )?; + validate_optional_text("blocker", self.blocker.as_deref(), MAX_DETAIL_BYTES)?; + + if self.artifacts.len() > MAX_ITEMS { + return Err(JobResultError::TooManyItems { + field: "artifacts", + max: MAX_ITEMS, + got: self.artifacts.len(), + }); + } + if self.verification.len() > MAX_ITEMS { + return Err(JobResultError::TooManyItems { + field: "verification", + max: MAX_ITEMS, + got: self.verification.len(), + }); + } + for artifact in &self.artifacts { + artifact.validate()?; + } + for verification in &self.verification { + verification.validate()?; + } + + match self.disposition { + JobDisposition::Completed if self.artifacts.is_empty() => { + return Err(JobResultError::InvalidCombination( + "completed results require an artifact; use no_artifact for analytical work" + .into(), + )); + } + JobDisposition::NoArtifact if !self.artifacts.is_empty() => { + return Err(JobResultError::InvalidCombination( + "no_artifact results cannot include artifacts".into(), + )); + } + JobDisposition::Blocked + if self + .blocker + .as_deref() + .map(str::trim) + .unwrap_or_default() + .is_empty() => + { + return Err(JobResultError::InvalidCombination( + "blocked results require a blocker".into(), + )); + } + _ => {} + } + + let size = serde_json::to_vec(self) + .map_err(|error| JobResultError::Serialization(error.to_string()))? + .len(); + if size > MAX_JOB_RESULT_BYTES { + return Err(JobResultError::PayloadTooLarge { + max: MAX_JOB_RESULT_BYTES, + got: size, + }); + } + Ok(()) + } + + /// Validate and serialize the payload to its canonical JSON content. + pub fn to_json(&self) -> Result { + self.validate()?; + serde_json::to_string(self) + .map_err(|error| JobResultError::Serialization(error.to_string())) + } +} + +/// Final disposition of an agent job. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum JobDisposition { + /// Requested work and its verification completed. + Completed, + /// Useful work exists, but the requested outcome is not fully complete. + Partial, + /// Work cannot continue until the named blocker is resolved. + Blocked, + /// The requested work failed. + Failed, + /// The work completed without a durable artifact, such as an analysis-only result. + NoArtifact, +} + +impl JobDisposition { + /// Stable wire value used in event tags and UI labels. + pub const fn as_str(self) -> &'static str { + match self { + Self::Completed => "completed", + Self::Partial => "partial", + Self::Blocked => "blocked", + Self::Failed => "failed", + Self::NoArtifact => "no_artifact", + } + } +} + +/// A durable artifact or proof reference produced by a job. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct JobArtifact { + /// Artifact category. + pub kind: JobArtifactKind, + /// Reader-facing label. + pub label: String, + /// URL, Buzz reference, repository-relative path, ref, or object id. + pub reference: String, + /// Optional source commit, branch, workflow run, build id, or equivalent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_state: Option, +} + +impl JobArtifact { + fn validate(&self) -> Result<(), JobResultError> { + validate_required_single_line_text("artifact.label", &self.label, MAX_LABEL_BYTES)?; + validate_required_single_line_text( + "artifact.reference", + &self.reference, + MAX_REFERENCE_BYTES, + )?; + validate_optional_single_line_text( + "artifact.sourceState", + self.source_state.as_deref(), + MAX_SOURCE_STATE_BYTES, + )?; + + match self.kind { + JobArtifactKind::PullRequest + | JobArtifactKind::Build + | JobArtifactKind::Deployment + | JobArtifactKind::Link + | JobArtifactKind::Media => validate_reference_url(&self.reference)?, + JobArtifactKind::File => validate_file_reference(&self.reference)?, + JobArtifactKind::Commit => validate_commit_reference(&self.reference)?, + JobArtifactKind::Branch + | JobArtifactKind::Canvas + | JobArtifactKind::WorkflowOutput + | JobArtifactKind::Other => {} + } + Ok(()) + } +} + +/// Supported artifact reference categories. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum JobArtifactKind { + /// Uploaded file or repository-relative file. + File, + /// Uploaded media. + Media, + /// Repository branch. + Branch, + /// Full commit object id or commit URL. + Commit, + /// Pull request URL. + PullRequest, + /// Buzz channel canvas reference. + Canvas, + /// Workflow output or run. + WorkflowOutput, + /// Build result. + Build, + /// Deployment proof. + Deployment, + /// Provenance-bearing link. + Link, + /// Explicitly labeled artifact outside the predefined categories. + Other, +} + +impl JobArtifactKind { + /// Stable wire value used by clients. + pub const fn as_str(self) -> &'static str { + match self { + Self::File => "file", + Self::Media => "media", + Self::Branch => "branch", + Self::Commit => "commit", + Self::PullRequest => "pull_request", + Self::Canvas => "canvas", + Self::WorkflowOutput => "workflow_output", + Self::Build => "build", + Self::Deployment => "deployment", + Self::Link => "link", + Self::Other => "other", + } + } +} + +/// One verification result attached to a job handoff. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct JobVerification { + /// Check or review performed. + pub label: String, + /// Verification status. + pub status: JobVerificationStatus, + /// Optional command, result URL, or concise evidence. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub evidence: Option, +} + +impl JobVerification { + fn validate(&self) -> Result<(), JobResultError> { + validate_required_single_line_text("verification.label", &self.label, MAX_LABEL_BYTES)?; + validate_optional_text( + "verification.evidence", + self.evidence.as_deref(), + MAX_REFERENCE_BYTES, + ) + } +} + +/// Status of one verification check. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum JobVerificationStatus { + /// Verification ran and passed. + Passed, + /// Verification ran and failed. + Failed, + /// Verification was intentionally or unavoidably not run. + NotRun, +} + +impl JobVerificationStatus { + /// Stable wire value used by clients. + pub const fn as_str(self) -> &'static str { + match self { + Self::Passed => "passed", + Self::Failed => "failed", + Self::NotRun => "not_run", + } + } +} + +/// Validation and serialization errors for a structured job result. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum JobResultError { + /// Only the current schema version is accepted by this producer. + #[error("unsupported job result schema version {0}")] + UnsupportedSchemaVersion(u8), + /// The job request reference is not a Nostr event id. + #[error("jobRequest must be a 64-character hex event id")] + InvalidJobRequest, + /// A required field was empty. + #[error("{0} must not be empty")] + EmptyField(&'static str), + /// A text field exceeded its limit. + #[error("{field} exceeds {max} bytes (got {got})")] + FieldTooLarge { + /// Field name. + field: &'static str, + /// Maximum allowed bytes. + max: usize, + /// Actual byte count. + got: usize, + }, + /// A text field contains control characters. + #[error("{0} must not contain control characters")] + ControlCharacters(&'static str), + /// An array exceeded its item limit. + #[error("{field} exceeds {max} items (got {got})")] + TooManyItems { + /// Field name. + field: &'static str, + /// Maximum allowed items. + max: usize, + /// Actual item count. + got: usize, + }, + /// A URL-like reference was invalid or used an unsupported scheme. + #[error("invalid artifact reference: {0}")] + InvalidReference(String), + /// Two otherwise valid fields conflict. + #[error("invalid job result combination: {0}")] + InvalidCombination(String), + /// The serialized payload exceeded the event content limit. + #[error("job result payload exceeds {max} bytes (got {got})")] + PayloadTooLarge { + /// Maximum allowed bytes. + max: usize, + /// Actual byte count. + got: usize, + }, + /// JSON serialization failed. + #[error("failed to serialize job result: {0}")] + Serialization(String), +} + +fn validate_event_id(value: &str) -> Result<(), JobResultError> { + if value.len() != 64 || !value.chars().all(|character| character.is_ascii_hexdigit()) { + return Err(JobResultError::InvalidJobRequest); + } + Ok(()) +} + +fn validate_required_text( + field: &'static str, + value: &str, + max: usize, +) -> Result<(), JobResultError> { + if value.trim().is_empty() { + return Err(JobResultError::EmptyField(field)); + } + validate_text(field, value, max) +} + +fn validate_optional_text( + field: &'static str, + value: Option<&str>, + max: usize, +) -> Result<(), JobResultError> { + if let Some(value) = value { + validate_required_text(field, value, max)?; + } + Ok(()) +} + +fn validate_required_single_line_text( + field: &'static str, + value: &str, + max: usize, +) -> Result<(), JobResultError> { + validate_required_text(field, value, max)?; + if value.chars().any(char::is_control) { + return Err(JobResultError::ControlCharacters(field)); + } + Ok(()) +} + +fn validate_optional_single_line_text( + field: &'static str, + value: Option<&str>, + max: usize, +) -> Result<(), JobResultError> { + if let Some(value) = value { + validate_required_single_line_text(field, value, max)?; + } + Ok(()) +} + +fn validate_text(field: &'static str, value: &str, max: usize) -> Result<(), JobResultError> { + if value.len() > max { + return Err(JobResultError::FieldTooLarge { + field, + max, + got: value.len(), + }); + } + if value + .chars() + .any(|character| character.is_control() && character != '\n' && character != '\t') + { + return Err(JobResultError::ControlCharacters(field)); + } + Ok(()) +} + +fn validate_reference_url(value: &str) -> Result<(), JobResultError> { + let url = + Url::parse(value).map_err(|error| JobResultError::InvalidReference(error.to_string()))?; + if !matches!(url.scheme(), "http" | "https" | "buzz" | "nostr") { + return Err(JobResultError::InvalidReference(format!( + "unsupported URL scheme {:?}", + url.scheme() + ))); + } + if matches!(url.scheme(), "http" | "https") && url.host_str().is_none() { + return Err(JobResultError::InvalidReference( + "HTTP(S) references require a host".into(), + )); + } + if !url.username().is_empty() || url.password().is_some() { + return Err(JobResultError::InvalidReference( + "artifact references must not contain URL credentials".into(), + )); + } + Ok(()) +} + +fn validate_file_reference(value: &str) -> Result<(), JobResultError> { + if Url::parse(value).is_ok() { + return validate_reference_url(value); + } + if value.starts_with('/') + || value.starts_with("~/") + || value.starts_with("~\\") + || value.as_bytes().get(1) == Some(&b':') + { + return Err(JobResultError::InvalidReference( + "file references must be uploaded URLs or repository-relative paths".into(), + )); + } + if value.split(['/', '\\']).any(|component| component == "..") { + return Err(JobResultError::InvalidReference( + "file references must not traverse outside the repository".into(), + )); + } + Ok(()) +} + +fn validate_commit_reference(value: &str) -> Result<(), JobResultError> { + if Url::parse(value).is_ok() { + return validate_reference_url(value); + } + if matches!(value.len(), 40 | 64) + && value.chars().all(|character| character.is_ascii_hexdigit()) + { + return Ok(()); + } + Err(JobResultError::InvalidReference( + "commit references must be a full 40/64-character object id or URL".into(), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn artifact(kind: JobArtifactKind, reference: &str) -> JobArtifact { + JobArtifact { + kind, + label: "Primary artifact".into(), + reference: reference.into(), + source_state: Some("abc1234".into()), + } + } + + fn payload(disposition: JobDisposition) -> JobResultPayload { + JobResultPayload { + schema_version: JOB_RESULT_SCHEMA_VERSION, + job_request: "a".repeat(64), + requested_outcome: "Ship an inspectable result".into(), + outcome: "The result is ready for review.".into(), + last_progress: Some("Verification completed.".into()), + disposition, + artifacts: vec![artifact( + JobArtifactKind::PullRequest, + "https://github.com/block/buzz/pull/1", + )], + verification: vec![JobVerification { + label: "just ci".into(), + status: JobVerificationStatus::Passed, + evidence: Some("exit 0".into()), + }], + blocker: None, + } + } + + #[test] + fn result_payload_round_trips() { + let original = payload(JobDisposition::Completed); + let json = original.to_json().expect("serialize"); + let decoded: JobResultPayload = serde_json::from_str(&json).expect("parse"); + assert_eq!(decoded, original); + assert!(json.contains(r#""schemaVersion":1"#)); + assert!(json.contains(r#""pull_request""#)); + } + + #[test] + fn additive_fields_are_ignored_within_the_current_schema() { + let mut value = serde_json::to_value(payload(JobDisposition::Completed)).unwrap(); + value + .as_object_mut() + .unwrap() + .insert("futureField".into(), serde_json::json!("ignored")); + value["artifacts"][0] + .as_object_mut() + .unwrap() + .insert("futureArtifactField".into(), serde_json::json!(true)); + + let decoded: JobResultPayload = serde_json::from_value(value).expect("forward compatible"); + decoded.validate().expect("known fields remain valid"); + } + + #[test] + fn all_artifact_kinds_validate() { + let cases = [ + (JobArtifactKind::File, "src/lib.rs"), + (JobArtifactKind::Media, "https://example.com/result.png"), + (JobArtifactKind::Branch, "agent/job-result-handoff"), + ( + JobArtifactKind::Commit, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + ), + ( + JobArtifactKind::PullRequest, + "https://github.com/block/buzz/pull/1", + ), + ( + JobArtifactKind::Canvas, + "buzz://canvas?channel=550e8400-e29b-41d4-a716-446655440000", + ), + (JobArtifactKind::WorkflowOutput, "run-123"), + (JobArtifactKind::Build, "https://example.com/build/123"), + ( + JobArtifactKind::Deployment, + "https://example.com/deploy/123", + ), + (JobArtifactKind::Link, "https://example.com/report"), + (JobArtifactKind::Other, "signed-note-123"), + ]; + + for (kind, reference) in cases { + let mut candidate = payload(JobDisposition::Completed); + candidate.artifacts = vec![artifact(kind, reference)]; + candidate.validate().unwrap_or_else(|error| { + panic!("{kind:?} reference {reference:?} should validate: {error}") + }); + } + } + + #[test] + fn analytical_result_requires_explicit_no_artifact_disposition() { + let mut completed = payload(JobDisposition::Completed); + completed.artifacts.clear(); + assert!(matches!( + completed.validate(), + Err(JobResultError::InvalidCombination(_)) + )); + + completed.disposition = JobDisposition::NoArtifact; + completed.validate().expect("explicit no-artifact result"); + } + + #[test] + fn no_artifact_rejects_artifact_list() { + let candidate = payload(JobDisposition::NoArtifact); + assert!(matches!( + candidate.validate(), + Err(JobResultError::InvalidCombination(_)) + )); + } + + #[test] + fn blocked_result_requires_blocker() { + let mut candidate = payload(JobDisposition::Blocked); + candidate.artifacts.clear(); + assert!(matches!( + candidate.validate(), + Err(JobResultError::InvalidCombination(_)) + )); + candidate.blocker = Some("Maintainer decision required.".into()); + candidate.validate().expect("blocked with blocker"); + } + + #[test] + fn absolute_local_file_paths_are_rejected() { + let mut candidate = payload(JobDisposition::Completed); + candidate.artifacts = vec![artifact(JobArtifactKind::File, "/Users/alice/private.txt")]; + assert!(matches!( + candidate.validate(), + Err(JobResultError::InvalidReference(_)) + )); + } + + #[test] + fn unsupported_link_schemes_are_rejected() { + let mut candidate = payload(JobDisposition::Completed); + candidate.artifacts = vec![artifact(JobArtifactKind::Link, "file:///tmp/result")]; + assert!(matches!( + candidate.validate(), + Err(JobResultError::InvalidReference(_)) + )); + } + + #[test] + fn link_references_with_credentials_or_missing_hosts_are_rejected() { + for reference in ["https://user:secret@example.com/report", "https://"] { + let mut candidate = payload(JobDisposition::Completed); + candidate.artifacts = vec![artifact(JobArtifactKind::Link, reference)]; + assert!( + matches!( + candidate.validate(), + Err(JobResultError::InvalidReference(_)) + ), + "{reference:?} should be rejected" + ); + } + } + + #[test] + fn repository_file_references_cannot_traverse_upward() { + for reference in [ + "../private.txt", + "docs/../../private.txt", + r"..\private.txt", + ] { + let mut candidate = payload(JobDisposition::Completed); + candidate.artifacts = vec![artifact(JobArtifactKind::File, reference)]; + assert!( + matches!( + candidate.validate(), + Err(JobResultError::InvalidReference(_)) + ), + "{reference:?} should be rejected" + ); + } + } + + #[test] + fn multiline_artifact_references_are_rejected() { + let mut candidate = payload(JobDisposition::Completed); + candidate.artifacts = vec![artifact(JobArtifactKind::Other, "run-123\nprivate-note")]; + assert!(matches!( + candidate.validate(), + Err(JobResultError::ControlCharacters("artifact.reference")) + )); + } + + #[test] + fn unsupported_schema_version_is_rejected() { + let mut candidate = payload(JobDisposition::Completed); + candidate.schema_version = 2; + assert_eq!( + candidate.validate(), + Err(JobResultError::UnsupportedSchemaVersion(2)) + ); + } + + #[test] + fn invalid_job_request_is_rejected() { + let mut candidate = payload(JobDisposition::Completed); + candidate.job_request = "not-an-event".into(); + assert_eq!(candidate.validate(), Err(JobResultError::InvalidJobRequest)); + } + + #[test] + fn too_many_artifacts_are_rejected() { + let mut candidate = payload(JobDisposition::Completed); + candidate.artifacts = vec![artifact(JobArtifactKind::File, "src/lib.rs"); MAX_ITEMS + 1]; + assert!(matches!( + candidate.validate(), + Err(JobResultError::TooManyItems { + field: "artifacts", + .. + }) + )); + } + + #[test] + fn payload_size_limit_is_enforced() { + let mut candidate = payload(JobDisposition::Completed); + candidate.artifacts = (0..MAX_ITEMS) + .map(|index| JobArtifact { + kind: JobArtifactKind::Other, + label: format!("artifact-{index}"), + reference: "x".repeat(MAX_REFERENCE_BYTES), + source_state: None, + }) + .collect(); + assert!(matches!( + candidate.validate(), + Err(JobResultError::PayloadTooLarge { .. }) + )); + } +} diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index afec52305a..5fef914891 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -460,7 +460,7 @@ pub const KIND_JOB_REQUEST: u32 = 43001; pub const KIND_JOB_ACCEPTED: u32 = 43002; /// Progress update for an in-flight agent job. pub const KIND_JOB_PROGRESS: u32 = 43003; -/// Final result of a completed agent job. +/// Final result of a completed agent job. See `docs/nips/NIP-AJ.md`. pub const KIND_JOB_RESULT: u32 = 43004; /// A job cancellation was requested. pub const KIND_JOB_CANCEL: u32 = 43005; diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs index dd57b46937..0474488c33 100644 --- a/crates/buzz-core/src/lib.rs +++ b/crates/buzz-core/src/lib.rs @@ -20,6 +20,8 @@ pub mod event; pub mod filter; /// Git permission types — ref patterns, protection rules, policy evaluation. pub mod git_perms; +/// Structured payloads for the signed agent job lifecycle. +pub mod job; /// Buzz kind number registry — custom event type constants. pub mod kind; /// Network utilities — SSRF-safe IP classification. diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index f9e54de9c5..25ff7cc132 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -1,15 +1,16 @@ -//! Typed event builder functions (38 builders). +//! Typed event builder functions (39 builders). //! //! All functions return `Result`. //! The caller signs: `builder.sign_with_keys(&keys)?`. use buzz_core::{ + job::JobResultPayload, kind::{ KIND_AGENT_OBSERVER_FRAME, KIND_APPROVAL_DENY, KIND_APPROVAL_GRANT, KIND_DELETION, KIND_DM_ADD_MEMBER, KIND_DM_OPEN, KIND_EMOJI_SET, KIND_GIT_ISSUE, KIND_GIT_PATCH, KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST, KIND_GIT_REPO_ANNOUNCEMENT, KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED, - KIND_GIT_STATUS_OPEN, KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST, + KIND_GIT_STATUS_OPEN, KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST, KIND_JOB_RESULT, KIND_MODERATION_BAN, KIND_MODERATION_RESOLVE_REPORT, KIND_MODERATION_TIMEOUT, KIND_MODERATION_UNBAN, KIND_MODERATION_UNTIMEOUT, KIND_PRESENCE_UPDATE, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, @@ -237,6 +238,37 @@ pub fn build_message( Ok(EventBuilder::new(Kind::Custom(9), content).tags(tags)) } +/// Build an inspectable agent job handoff (`kind:43004`). +/// +/// The result is channel-scoped and references the originating job request as +/// a direct NIP-10 reply. Payload validation happens before the event is built, +/// including schema, artifact, disposition, and serialized-size checks. +pub fn build_job_result( + channel_id: Uuid, + job_request: nostr::EventId, + payload: &JobResultPayload, +) -> Result { + let expected_job = job_request.to_hex(); + if !payload.job_request.eq_ignore_ascii_case(&expected_job) { + return Err(SdkError::InvalidInput( + "payload jobRequest must match the referenced job event".into(), + )); + } + let content = payload + .to_json() + .map_err(|error| SdkError::InvalidInput(error.to_string()))?; + check_content(&content, 64 * 1024)?; + let channel_id = channel_id.to_string(); + let schema_version = payload.schema_version.to_string(); + let tags = vec![ + tag(&["h", &channel_id])?, + tag(&["e", &expected_job, "", "reply"])?, + tag(&["schema", "buzz.job-result", &schema_version])?, + tag(&["disposition", payload.disposition.as_str()])?, + ]; + Ok(EventBuilder::new(Kind::Custom(KIND_JOB_RESULT as u16), content).tags(tags)) +} + /// Build an encrypted agent observer frame (kind 24200). /// /// `recipient_pubkey` is the cleartext `p` tag used by the relay for owner-only @@ -1825,6 +1857,10 @@ pub fn build_unarchive_identity_request( #[cfg(test)] mod tests { use super::*; + use buzz_core::job::{ + JobArtifact, JobArtifactKind, JobDisposition, JobVerification, JobVerificationStatus, + JOB_RESULT_SCHEMA_VERSION, + }; use nostr::{EventId, Keys}; fn keys() -> Keys { @@ -1879,6 +1915,61 @@ mod tests { assert!(has_tag(&ev, "h", &cid.to_string())); } + fn job_result(job_request: &EventId) -> JobResultPayload { + JobResultPayload { + schema_version: JOB_RESULT_SCHEMA_VERSION, + job_request: job_request.to_hex(), + requested_outcome: "Make the result inspectable".into(), + outcome: "The handoff is ready.".into(), + last_progress: Some("Full verification passed.".into()), + disposition: JobDisposition::Completed, + artifacts: vec![JobArtifact { + kind: JobArtifactKind::PullRequest, + label: "Pull request".into(), + reference: "https://github.com/block/buzz/pull/1".into(), + source_state: Some("a".repeat(40)), + }], + verification: vec![JobVerification { + label: "just ci".into(), + status: JobVerificationStatus::Passed, + evidence: Some("exit 0".into()), + }], + blocker: None, + } + } + + #[test] + fn job_result_has_channel_job_schema_and_disposition_tags() { + let channel_id = uuid(); + let job_request = event_id(); + let payload = job_result(&job_request); + let event = sign(build_job_result(channel_id, job_request, &payload).unwrap()); + + assert_eq!(event.kind.as_u16(), KIND_JOB_RESULT as u16); + assert!(has_tag(&event, "h", &channel_id.to_string())); + assert!(has_tag(&event, "e", &payload.job_request)); + assert!(has_tag(&event, "schema", "buzz.job-result")); + assert!(has_tag(&event, "disposition", "completed")); + let e_tag = event + .tags + .iter() + .find(|tag| tag.as_slice().first().map(String::as_str) == Some("e")) + .expect("job reference tag"); + assert_eq!(e_tag.as_slice().get(3).map(String::as_str), Some("reply")); + let decoded: JobResultPayload = + serde_json::from_str(&event.content).expect("structured content"); + assert_eq!(decoded, payload); + } + + #[test] + fn job_result_rejects_payload_for_different_job() { + let referenced_job = event_id(); + let different_job = event_id(); + let payload = job_result(&different_job); + let error = build_job_result(uuid(), referenced_job, &payload).unwrap_err(); + assert!(matches!(error, SdkError::InvalidInput(_))); + } + #[test] fn agent_observer_frame_happy_path() { let sender = keys(); diff --git a/crates/buzz-sdk/src/lib.rs b/crates/buzz-sdk/src/lib.rs index 4ee0cd4c88..6fe662bbac 100644 --- a/crates/buzz-sdk/src/lib.rs +++ b/crates/buzz-sdk/src/lib.rs @@ -18,6 +18,8 @@ pub mod nip_oa; pub use builders::*; +/// Re-export the structured agent job result contract. +pub use buzz_core::job; /// Re-export kind constants so consumers don't need buzz-core directly. pub use buzz_core::kind; diff --git a/desktop/src/features/home/ui/FeedSection.tsx b/desktop/src/features/home/ui/FeedSection.tsx index 22fc177190..37f60cbb9f 100644 --- a/desktop/src/features/home/ui/FeedSection.tsx +++ b/desktop/src/features/home/ui/FeedSection.tsx @@ -4,6 +4,10 @@ import { resolveUserLabel, type UserProfileLookup, } from "@/features/profile/lib/identity"; +import { + getJobResultFeedPresentation, + getJobResultRequestId, +} from "@/features/messages/lib/jobResult"; import type { FeedItem } from "@/shared/api/types"; import { KIND_APPROVAL_REQUEST, @@ -109,6 +113,23 @@ function feedContent(item: FeedItem) { return "No additional details were attached to this event."; } +function feedPresentation(item: FeedItem) { + if (item.kind === KIND_JOB_RESULT) { + const jobResultPresentation = getJobResultFeedPresentation( + item.content, + getJobResultRequestId(item.tags), + ); + if (jobResultPresentation) { + return jobResultPresentation; + } + } + + return { + content: feedContent(item), + headline: feedHeadline(item), + }; +} + type FeedSectionProps = { title: string; emptyTitle: string; @@ -170,6 +191,7 @@ export function FeedSection({ item.tags, profiles, ); + const presentation = feedPresentation(item); return (
- {feedHeadline(item)} + {presentation.headline} diff --git a/desktop/src/features/messages/lib/jobResult.test.mjs b/desktop/src/features/messages/lib/jobResult.test.mjs new file mode 100644 index 0000000000..59b8102b59 --- /dev/null +++ b/desktop/src/features/messages/lib/jobResult.test.mjs @@ -0,0 +1,281 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + getJobArtifactKindLabel, + getJobResultFeedHeadline, + getJobResultFeedPresentation, + getJobResultRequestId, + parseJobResultContent, +} from "./jobResult.ts"; + +const JOB_REQUEST = + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + +function parseContent(content, expectedJobRequest = JOB_REQUEST) { + return parseJobResultContent(content, expectedJobRequest); +} + +function manifest(overrides = {}) { + return JSON.stringify({ + schemaVersion: 1, + jobRequest: JOB_REQUEST, + requestedOutcome: "Make the result inspectable", + outcome: "The handoff is ready.", + lastProgress: "Full verification passed.", + disposition: "completed", + artifacts: [ + { + kind: "pull_request", + label: "Pull request", + reference: "https://github.com/block/buzz/pull/1", + sourceState: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + }, + ], + verification: [ + { + label: "just ci", + status: "passed", + evidence: "exit 0", + }, + ], + ...overrides, + }); +} + +test("parses a structured job result and ignores additive fields", () => { + const result = parseContent( + manifest({ + futureField: "ignored", + artifacts: [ + { + kind: "pull_request", + label: "Pull request", + reference: "https://github.com/block/buzz/pull/1", + futureArtifactField: true, + }, + ], + }), + ); + + assert.equal(result?.disposition, "completed"); + assert.equal(result?.outcome, "The handoff is ready."); + assert.equal(result?.artifacts[0]?.kind, "pull_request"); + assert.equal(result?.verification[0]?.status, "passed"); +}); + +test("supports every artifact type and reader-facing label", () => { + const kinds = [ + "file", + "media", + "branch", + "commit", + "pull_request", + "canvas", + "workflow_output", + "build", + "deployment", + "link", + "other", + ]; + const references = { + file: "docs/report.md", + media: "https://example.com/report.png", + branch: "agent/job-handoff", + commit: "b".repeat(40), + pull_request: "https://github.com/block/buzz/pull/1", + canvas: "buzz://canvas?channel=aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + workflow_output: "run-123", + build: "https://example.com/build/123", + deployment: "https://example.com/deploy/123", + link: "https://example.com/report", + other: "signed-note-123", + }; + const artifacts = kinds.map((kind) => ({ + kind, + label: `${kind} artifact`, + reference: references[kind], + })); + + const result = parseContent(manifest({ artifacts })); + + assert.deepEqual( + result?.artifacts.map((artifact) => artifact.kind), + kinds, + ); + assert.deepEqual( + result?.artifacts.map((artifact) => getJobArtifactKindLabel(artifact.kind)), + [ + "File", + "Media", + "Branch", + "Commit", + "Pull request", + "Canvas", + "Workflow output", + "Build", + "Deployment", + "Link", + "Artifact", + ], + ); +}); + +test("parses an explicit no-artifact result", () => { + const result = parseContent( + manifest({ + disposition: "no_artifact", + artifacts: [], + verification: [], + lastProgress: undefined, + }), + ); + + assert.equal(result?.disposition, "no_artifact"); + assert.deepEqual(result?.artifacts, []); + assert.equal(getJobResultFeedHeadline(result.disposition), "Job completed"); +}); + +test("projects a human-readable Home feed headline and outcome", () => { + assert.deepEqual(getJobResultFeedPresentation(manifest(), JOB_REQUEST), { + headline: "Job completed", + content: "The handoff is ready.", + }); + assert.equal( + getJobResultFeedPresentation("legacy plaintext result", JOB_REQUEST), + null, + ); +}); + +test("rejects legacy text, malformed JSON, and unsupported schemas", () => { + assert.equal(parseContent("Done."), null); + assert.equal(parseContent("{not-json"), null); + assert.equal(parseContent(manifest({ schemaVersion: 2 })), null); +}); + +test("rejects invalid enums and malformed item arrays", () => { + assert.equal(parseContent(manifest({ disposition: "mostly_done" })), null); + assert.equal( + parseContent( + manifest({ + artifacts: [ + { + kind: "database_row", + label: "Unexpected", + reference: "row-1", + }, + ], + }), + ), + null, + ); + assert.equal( + parseContent( + manifest({ + verification: [{ label: "check", status: "maybe" }], + }), + ), + null, + ); +}); + +test("rejects invalid disposition combinations", () => { + assert.equal( + parseContent(manifest({ disposition: "completed", artifacts: [] })), + null, + ); + assert.equal( + parseContent(manifest({ disposition: "no_artifact", artifacts: [{}] })), + null, + ); + assert.equal( + parseContent( + manifest({ disposition: "blocked", artifacts: [], blocker: undefined }), + ), + null, + ); + + const blocked = parseContent( + manifest({ + disposition: "blocked", + artifacts: [], + blocker: "Maintainer decision required.", + }), + ); + assert.equal(blocked?.blocker, "Maintainer decision required."); + assert.equal(getJobResultFeedHeadline(blocked.disposition), "Job blocked"); +}); + +test("rejects control characters in single-line artifact fields", () => { + assert.equal( + parseContent( + manifest({ + artifacts: [ + { + kind: "other", + label: "Artifact", + reference: "run-1\nprivate-note", + }, + ], + }), + ), + null, + ); +}); + +test("rejects unsafe URL credentials and upward file traversal", () => { + for (const artifact of [ + { + kind: "link", + label: "Credential-bearing URL", + reference: "https://user:secret@example.com/report", + }, + { + kind: "link", + label: "Hostless URL", + reference: "https://", + }, + { + kind: "file", + label: "Private file", + reference: "../private.txt", + }, + ]) { + assert.equal(parseContent(manifest({ artifacts: [artifact] })), null); + } +}); + +test("requires one reply tag matching the payload job request", () => { + const tags = [["e", JOB_REQUEST, "", "reply"]]; + assert.equal(getJobResultRequestId(tags), JOB_REQUEST); + assert.equal( + parseContent(manifest(), getJobResultRequestId(tags))?.jobRequest, + JOB_REQUEST, + ); + + const differentRequest = "b".repeat(64); + assert.equal(parseContent(manifest(), differentRequest), null); + assert.equal(getJobResultRequestId([]), null); + assert.equal( + getJobResultRequestId([ + ["e", JOB_REQUEST, "", "reply"], + ["e", differentRequest, "", "reply"], + ]), + null, + ); +}); + +test("rejects content and fields that exceed byte limits before trimming", () => { + assert.equal( + parseContent( + manifest({ + futureField: "x".repeat(64 * 1024), + }), + ), + null, + ); + assert.equal( + parseContent(manifest({ outcome: `ready${" ".repeat(8 * 1024)}` })), + null, + ); +}); diff --git a/desktop/src/features/messages/lib/jobResult.ts b/desktop/src/features/messages/lib/jobResult.ts new file mode 100644 index 0000000000..f4a4f0c498 --- /dev/null +++ b/desktop/src/features/messages/lib/jobResult.ts @@ -0,0 +1,405 @@ +export const JOB_RESULT_SCHEMA_VERSION = 1; + +export const JOB_RESULT_DISPOSITIONS = [ + "completed", + "partial", + "blocked", + "failed", + "no_artifact", +] as const; + +export const JOB_ARTIFACT_KINDS = [ + "file", + "media", + "branch", + "commit", + "pull_request", + "canvas", + "workflow_output", + "build", + "deployment", + "link", + "other", +] as const; + +export const JOB_VERIFICATION_STATUSES = [ + "passed", + "failed", + "not_run", +] as const; + +export type JobResultDisposition = (typeof JOB_RESULT_DISPOSITIONS)[number]; +export type JobArtifactKind = (typeof JOB_ARTIFACT_KINDS)[number]; +export type JobVerificationStatus = (typeof JOB_VERIFICATION_STATUSES)[number]; + +export type JobArtifact = { + kind: JobArtifactKind; + label: string; + reference: string; + sourceState?: string; +}; + +export type JobVerification = { + label: string; + status: JobVerificationStatus; + evidence?: string; +}; + +export type JobResult = { + schemaVersion: typeof JOB_RESULT_SCHEMA_VERSION; + jobRequest: string; + requestedOutcome: string; + outcome: string; + lastProgress?: string; + disposition: JobResultDisposition; + artifacts: JobArtifact[]; + verification: JobVerification[]; + blocker?: string; +}; + +const EVENT_ID_PATTERN = /^[0-9a-f]{64}$/i; +const MAX_OUTCOME_BYTES = 8 * 1024; +const MAX_DETAIL_BYTES = 4 * 1024; +const MAX_LABEL_BYTES = 512; +const MAX_REFERENCE_BYTES = 2 * 1024; +const MAX_SOURCE_STATE_BYTES = 512; +const MAX_ITEMS = 50; +const MAX_JOB_RESULT_BYTES = 64 * 1024; + +const dispositionSet = new Set(JOB_RESULT_DISPOSITIONS); +const artifactKindSet = new Set(JOB_ARTIFACT_KINDS); +const verificationStatusSet = new Set(JOB_VERIFICATION_STATUSES); +const textEncoder = new TextEncoder(); +const supportedReferenceSchemes = new Set([ + "http:", + "https:", + "buzz:", + "nostr:", +]); + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readText( + value: unknown, + maxBytes: number, + options: { singleLine?: boolean } = {}, +): string | null { + if (typeof value !== "string") { + return null; + } + + const trimmed = value.trim(); + if (trimmed.length === 0 || textEncoder.encode(value).length > maxBytes) { + return null; + } + + if ( + [...trimmed].some((character) => { + const codePoint = character.codePointAt(0) ?? 0; + const isControl = + codePoint < 32 || (codePoint >= 127 && codePoint <= 159); + return ( + isControl && + (options.singleLine || (character !== "\n" && character !== "\t")) + ); + }) + ) { + return null; + } + + return trimmed; +} + +function readOptionalText( + value: unknown, + maxBytes: number, + options: { singleLine?: boolean } = {}, +): string | null | undefined { + if (value === undefined || value === null) { + return undefined; + } + + return readText(value, maxBytes, options); +} + +function validateReferenceUrl(value: string): boolean | null { + let url: URL; + try { + url = new URL(value); + } catch { + return null; + } + + return ( + supportedReferenceSchemes.has(url.protocol) && + (!["http:", "https:"].includes(url.protocol) || Boolean(url.hostname)) && + url.username.length === 0 && + url.password.length === 0 + ); +} + +function isValidArtifactReference( + kind: JobArtifactKind, + reference: string, +): boolean { + const urlValidity = validateReferenceUrl(reference); + + switch (kind) { + case "pull_request": + case "build": + case "deployment": + case "link": + case "media": + return urlValidity === true; + case "file": + if (urlValidity !== null) { + return urlValidity; + } + return ( + !reference.startsWith("/") && + !reference.startsWith("~/") && + !reference.startsWith("~\\") && + !/^[A-Za-z]:/.test(reference) && + !reference.split(/[\\/]/).includes("..") + ); + case "commit": + if (urlValidity !== null) { + return urlValidity; + } + return ( + [40, 64].includes(reference.length) && /^[0-9a-f]+$/i.test(reference) + ); + case "branch": + case "canvas": + case "workflow_output": + case "other": + return true; + } +} + +function parseArtifact(value: unknown): JobArtifact | null { + if (!isRecord(value) || !artifactKindSet.has(String(value.kind))) { + return null; + } + + const label = readText(value.label, MAX_LABEL_BYTES, { singleLine: true }); + const reference = readText(value.reference, MAX_REFERENCE_BYTES, { + singleLine: true, + }); + const sourceState = readOptionalText( + value.sourceState, + MAX_SOURCE_STATE_BYTES, + { singleLine: true }, + ); + if ( + !label || + !reference || + sourceState === null || + !isValidArtifactReference(value.kind as JobArtifactKind, reference) + ) { + return null; + } + + return { + kind: value.kind as JobArtifactKind, + label, + reference, + ...(sourceState ? { sourceState } : {}), + }; +} + +function parseVerification(value: unknown): JobVerification | null { + if (!isRecord(value) || !verificationStatusSet.has(String(value.status))) { + return null; + } + + const label = readText(value.label, MAX_LABEL_BYTES, { singleLine: true }); + const evidence = readOptionalText(value.evidence, MAX_REFERENCE_BYTES); + if (!label || evidence === null) { + return null; + } + + return { + label, + status: value.status as JobVerificationStatus, + ...(evidence ? { evidence } : {}), + }; +} + +function parseArray( + value: unknown, + parseItem: (item: unknown) => T | null, +): T[] | null { + if (value === undefined) { + return []; + } + if (!Array.isArray(value) || value.length > MAX_ITEMS) { + return null; + } + + const parsed = value.map(parseItem); + return parsed.every((item): item is T => item !== null) ? parsed : null; +} + +export function getJobResultRequestId(tags: string[][]): string | null { + const replyTags = tags.filter((tag) => tag[0] === "e" && tag[3] === "reply"); + if (replyTags.length !== 1) { + return null; + } + + const eventId = replyTags[0]?.[1]; + return eventId && EVENT_ID_PATTERN.test(eventId) ? eventId : null; +} + +/** + * Parse the versioned `kind:43004` payload. + * + * Invalid, legacy, and unsupported content returns null so callers can fall + * back to the existing Markdown renderer without trusting partial fields. + */ +export function parseJobResultContent( + content: string, + expectedJobRequest: string | null, +): JobResult | null { + if ( + textEncoder.encode(content).length > MAX_JOB_RESULT_BYTES || + !expectedJobRequest || + !EVENT_ID_PATTERN.test(expectedJobRequest) + ) { + return null; + } + + let value: unknown; + try { + value = JSON.parse(content); + } catch { + return null; + } + + if ( + !isRecord(value) || + value.schemaVersion !== JOB_RESULT_SCHEMA_VERSION || + !EVENT_ID_PATTERN.test(String(value.jobRequest)) || + String(value.jobRequest).toLowerCase() !== + expectedJobRequest.toLowerCase() || + !dispositionSet.has(String(value.disposition)) + ) { + return null; + } + + const requestedOutcome = readText(value.requestedOutcome, MAX_OUTCOME_BYTES); + const outcome = readText(value.outcome, MAX_OUTCOME_BYTES); + const lastProgress = readOptionalText(value.lastProgress, MAX_DETAIL_BYTES); + const blocker = readOptionalText(value.blocker, MAX_DETAIL_BYTES); + const artifacts = parseArray(value.artifacts, parseArtifact); + const verification = parseArray(value.verification, parseVerification); + + if ( + !requestedOutcome || + !outcome || + lastProgress === null || + blocker === null || + !artifacts || + !verification + ) { + return null; + } + + const disposition = value.disposition as JobResultDisposition; + if ( + (disposition === "completed" && artifacts.length === 0) || + (disposition === "no_artifact" && artifacts.length > 0) || + (disposition === "blocked" && !blocker) + ) { + return null; + } + + return { + schemaVersion: JOB_RESULT_SCHEMA_VERSION, + jobRequest: String(value.jobRequest).toLowerCase(), + requestedOutcome, + outcome, + lastProgress, + disposition, + artifacts, + verification, + blocker, + }; +} + +export function getJobResultDispositionLabel( + disposition: JobResultDisposition, +): string { + switch (disposition) { + case "completed": + return "Completed"; + case "partial": + return "Partially completed"; + case "blocked": + return "Blocked"; + case "failed": + return "Failed"; + case "no_artifact": + return "Completed without an artifact"; + } +} + +export function getJobResultFeedHeadline( + disposition: JobResultDisposition, +): string { + switch (disposition) { + case "completed": + case "no_artifact": + return "Job completed"; + case "partial": + return "Job partially completed"; + case "blocked": + return "Job blocked"; + case "failed": + return "Job failed"; + } +} + +export function getJobResultFeedPresentation( + content: string, + expectedJobRequest: string | null, +): { headline: string; content: string } | null { + const result = parseJobResultContent(content, expectedJobRequest); + if (!result) { + return null; + } + + return { + headline: getJobResultFeedHeadline(result.disposition), + content: result.outcome, + }; +} + +export function getJobArtifactKindLabel(kind: JobArtifactKind): string { + switch (kind) { + case "file": + return "File"; + case "media": + return "Media"; + case "branch": + return "Branch"; + case "commit": + return "Commit"; + case "pull_request": + return "Pull request"; + case "canvas": + return "Canvas"; + case "workflow_output": + return "Workflow output"; + case "build": + return "Build"; + case "deployment": + return "Deployment"; + case "link": + return "Link"; + case "other": + return "Artifact"; + } +} diff --git a/desktop/src/features/messages/lib/rowHeightEstimate.test.mjs b/desktop/src/features/messages/lib/rowHeightEstimate.test.mjs index dc33da4f8f..518bf9a708 100644 --- a/desktop/src/features/messages/lib/rowHeightEstimate.test.mjs +++ b/desktop/src/features/messages/lib/rowHeightEstimate.test.mjs @@ -90,6 +90,61 @@ test("estimateRowHeight: bare URL line adds a preview card", () => { assert.ok(withUrl > withoutUrl + 50, `url ${withUrl} vs ${withoutUrl}`); }); +test("estimateRowHeight: structured job results reserve the handoff card", () => { + const jobRequest = "a".repeat(64); + const tags = [["e", jobRequest, "", "reply"]]; + const oneArtifact = estimateRowHeight( + msg({ + kind: 43004, + tags, + body: JSON.stringify({ + schemaVersion: 1, + jobRequest, + requestedOutcome: "Make the result inspectable", + outcome: "The handoff is ready.", + disposition: "completed", + artifacts: [ + { + kind: "pull_request", + label: "Pull request", + reference: "https://github.com/block/buzz/pull/1", + }, + ], + verification: [], + }), + }), + ); + const twoArtifacts = estimateRowHeight( + msg({ + kind: 43004, + tags, + body: JSON.stringify({ + schemaVersion: 1, + jobRequest, + requestedOutcome: "Make the result inspectable", + outcome: "The handoff is ready.", + disposition: "completed", + artifacts: [ + { + kind: "pull_request", + label: "Pull request", + reference: "https://github.com/block/buzz/pull/1", + }, + { + kind: "file", + label: "Protocol documentation", + reference: "docs/nips/NIP-AJ.md", + }, + ], + verification: [], + }), + }), + ); + + assert.ok(oneArtifact > 300); + assert.ok(twoArtifacts > oneArtifact); +}); + test("timelineRowReserveStyle: message item yields containIntrinsicSize", () => { const style = timelineRowReserveStyle({ kind: "message", diff --git a/desktop/src/features/messages/lib/rowHeightEstimate.ts b/desktop/src/features/messages/lib/rowHeightEstimate.ts index 56dd018275..0c8a76031c 100644 --- a/desktop/src/features/messages/lib/rowHeightEstimate.ts +++ b/desktop/src/features/messages/lib/rowHeightEstimate.ts @@ -3,7 +3,9 @@ import type * as React from "react"; import { dimensionsFromDim } from "@/shared/ui/markdown/utils"; import type { TimelineItem } from "./timelineItems"; import type { TimelineMessage } from "../types"; +import { getJobResultRequestId, parseJobResultContent } from "./jobResult"; import { parseImetaTags } from "./parseImeta"; +import { KIND_JOB_RESULT } from "@/shared/constants/kinds"; /** * Estimate a timeline row's rendered height so its `content-visibility` @@ -30,6 +32,10 @@ const CONTINUATION_ROW_CHROME = 8; // dense row padding only; header/avatar are const MEDIA_BLOCK_MARGIN_TOP = 4; // image/video blocks use mt-1 in markdown const REACTION_ROW = 24; const PREVIEW_CARD = 70; +const JOB_RESULT_CARD_BASE = 164; +const JOB_RESULT_ARTIFACT_ROW = 70; +const JOB_RESULT_VERIFICATION_ROW = 56; +const JOB_RESULT_EMPTY_SECTION = 38; const MESSAGE_ITEM_BOTTOM_PADDING = 10; // TimelineMessageList pb-2.5 const MIN_ESTIMATE = 60; // never reserve less than the old flat floor const CONTINUATION_MIN_ESTIMATE = 34; @@ -112,6 +118,41 @@ export function estimateRowHeight( message: TimelineMessage, { isContinuation = false }: { isContinuation?: boolean } = {}, ): number { + if (message.kind === KIND_JOB_RESULT) { + const result = parseJobResultContent( + message.body, + getJobResultRequestId(message.tags ?? []), + ); + if (result) { + const chrome = isContinuation ? CONTINUATION_ROW_CHROME : ROW_CHROME; + const narrative = [ + result.requestedOutcome, + result.outcome, + result.lastProgress, + result.blocker, + ] + .filter((value): value is string => Boolean(value)) + .join("\n"); + const artifactHeight = + result.artifacts.length > 0 + ? result.artifacts.length * JOB_RESULT_ARTIFACT_ROW + : JOB_RESULT_EMPTY_SECTION; + const verificationHeight = + result.verification.length > 0 + ? result.verification.length * JOB_RESULT_VERIFICATION_ROW + : JOB_RESULT_EMPTY_SECTION; + + return Math.max( + isContinuation ? CONTINUATION_MIN_ESTIMATE : MIN_ESTIMATE, + chrome + + JOB_RESULT_CARD_BASE + + wrappedLineCount(narrative) * TEXT_LINE_HEIGHT + + artifactHeight + + verificationHeight, + ); + } + } + const body = message.body ?? ""; const { prose, codeLines } = splitFencedCode(body); const proseForLineCount = stripMediaOnlyLines(prose); diff --git a/desktop/src/features/messages/ui/JobResultCard.render.test.mjs b/desktop/src/features/messages/ui/JobResultCard.render.test.mjs new file mode 100644 index 0000000000..cc889b20ea --- /dev/null +++ b/desktop/src/features/messages/ui/JobResultCard.render.test.mjs @@ -0,0 +1,120 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { parseJobResultContent } from "../lib/jobResult.ts"; +import { JobResultCard } from "./JobResultCard.tsx"; + +const JOB_REQUEST = + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + +function parse(overrides = {}) { + const result = parseJobResultContent( + JSON.stringify({ + schemaVersion: 1, + jobRequest: JOB_REQUEST, + requestedOutcome: "Make the result inspectable", + outcome: "The handoff is ready.", + lastProgress: "Full verification passed.", + disposition: "completed", + artifacts: [ + { + kind: "pull_request", + label: "Pull request", + reference: "https://github.com/block/buzz/pull/1", + sourceState: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + }, + ], + verification: [ + { + label: "just ci", + status: "passed", + evidence: "exit 0", + }, + ], + ...overrides, + }), + JOB_REQUEST, + ); + assert.ok(result); + return result; +} + +test("renders the disposition, outcome, artifact, and verification evidence", () => { + const html = renderToStaticMarkup( + React.createElement(JobResultCard, { result: parse() }), + ); + + assert.match(html, /data-disposition="completed"/); + assert.match(html, />CompletedPassed { + const html = renderToStaticMarkup( + React.createElement(JobResultCard, { + result: parse({ + disposition: "blocked", + artifacts: [], + blocker: "Maintainer decision required.", + verification: [ + { + label: "Desktop smoke", + status: "failed", + evidence: "Result card did not load.", + }, + ], + }), + }), + ); + + assert.match(html, /data-disposition="blocked"/); + assert.match(html, /Maintainer decision required\./); + assert.match(html, /Desktop smoke/); + assert.match(html, />Failed { + const html = renderToStaticMarkup( + React.createElement(JobResultCard, { + result: parse({ + disposition: "no_artifact", + artifacts: [], + verification: [], + lastProgress: undefined, + }), + }), + ); + + assert.match(html, /data-disposition="no_artifact"/); + assert.match(html, /Completed without an artifact/); + assert.match(html, /No durable artifact was expected/); + assert.match(html, /No verification was reported/); +}); + +test("does not turn non-http artifact references into links", () => { + const html = renderToStaticMarkup( + React.createElement(JobResultCard, { + result: parse({ + artifacts: [ + { + kind: "file", + label: "Report", + reference: "docs/report.md", + }, + ], + }), + }), + ); + + assert.match(html, /docs\/report\.md/); + assert.doesNotMatch(html, /href="docs\/report\.md"/); +}); diff --git a/desktop/src/features/messages/ui/JobResultCard.tsx b/desktop/src/features/messages/ui/JobResultCard.tsx new file mode 100644 index 0000000000..8920f1ee82 --- /dev/null +++ b/desktop/src/features/messages/ui/JobResultCard.tsx @@ -0,0 +1,329 @@ +import type { LucideIcon } from "lucide-react"; +import { + Ban, + Boxes, + CheckCircle2, + CircleAlert, + CircleDotDashed, + CircleX, + ExternalLink, + FileText, + GitBranch, + GitCommitHorizontal, + GitPullRequest, + Hammer, + Image, + Link2, + ListChecks, + PackageCheck, + Rocket, + ScrollText, + Workflow, + XCircle, +} from "lucide-react"; + +import { + getJobArtifactKindLabel, + getJobResultDispositionLabel, + type JobArtifactKind, + type JobResult, + type JobResultDisposition, + type JobVerificationStatus, +} from "@/features/messages/lib/jobResult"; +import { cn } from "@/shared/lib/cn"; +import { isSafeUrl } from "@/shared/lib/url"; + +const dispositionPresentation: Record< + JobResultDisposition, + { + icon: LucideIcon; + cardClass: string; + iconClass: string; + badgeClass: string; + } +> = { + completed: { + icon: CheckCircle2, + cardClass: "border-emerald-500/35 bg-emerald-500/5", + iconClass: "text-emerald-500", + badgeClass: + "border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400", + }, + partial: { + icon: CircleAlert, + cardClass: "border-amber-500/35 bg-amber-500/5", + iconClass: "text-amber-500", + badgeClass: + "border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400", + }, + blocked: { + icon: Ban, + cardClass: "border-orange-500/35 bg-orange-500/5", + iconClass: "text-orange-500", + badgeClass: + "border-orange-500/30 bg-orange-500/10 text-orange-600 dark:text-orange-400", + }, + failed: { + icon: XCircle, + cardClass: "border-destructive/35 bg-destructive/5", + iconClass: "text-destructive", + badgeClass: "border-destructive/30 bg-destructive/10 text-destructive", + }, + no_artifact: { + icon: PackageCheck, + cardClass: "border-sky-500/35 bg-sky-500/5", + iconClass: "text-sky-500", + badgeClass: + "border-sky-500/30 bg-sky-500/10 text-sky-600 dark:text-sky-400", + }, +}; + +const artifactIcons: Record = { + file: FileText, + media: Image, + branch: GitBranch, + commit: GitCommitHorizontal, + pull_request: GitPullRequest, + canvas: ScrollText, + workflow_output: Workflow, + build: Hammer, + deployment: Rocket, + link: Link2, + other: Boxes, +}; + +const verificationPresentation: Record< + JobVerificationStatus, + { icon: LucideIcon; label: string; className: string } +> = { + passed: { + icon: CheckCircle2, + label: "Passed", + className: "text-emerald-500", + }, + failed: { + icon: CircleX, + label: "Failed", + className: "text-destructive", + }, + not_run: { + icon: CircleDotDashed, + label: "Not run", + className: "text-muted-foreground", + }, +}; + +export function JobResultCard({ result }: { result: JobResult }) { + const disposition = dispositionPresentation[result.disposition]; + const DispositionIcon = disposition.icon; + + return ( +
+
+ + + +
+
+

+ Agent handoff +

+ + {getJobResultDispositionLabel(result.disposition)} + +
+

+ {result.outcome} +

+
+
+ +
+
+

+ Requested outcome +

+

+ {result.requestedOutcome} +

+
+ + {result.lastProgress ? ( +
+

+ Last progress +

+

+ {result.lastProgress} +

+
+ ) : null} + + {result.blocker ? ( +
+

+ + Blocker +

+

+ {result.blocker} +

+
+ ) : null} + +
+
+ +

+ Artifacts +

+ + {result.artifacts.length} + +
+ + {result.artifacts.length > 0 ? ( +
    + {result.artifacts.map((artifact) => { + const ArtifactIcon = artifactIcons[artifact.kind]; + const linkedReference = isSafeUrl(artifact.reference); + + return ( +
  • + +
    +
    +

    + {artifact.label} +

    + + {getJobArtifactKindLabel(artifact.kind)} + +
    + {linkedReference ? ( + + {artifact.reference} + + + ) : ( +

    + {artifact.reference} +

    + )} + {artifact.sourceState ? ( +

    + Source state:{" "} + + {artifact.sourceState} + +

    + ) : null} +
    +
  • + ); + })} +
+ ) : ( +

+ No durable artifact was expected for this result. +

+ )} +
+ +
+
+ +

+ Verification +

+ + {result.verification.length} + +
+ + {result.verification.length > 0 ? ( +
    + {result.verification.map((verification) => { + const presentation = + verificationPresentation[verification.status]; + const VerificationIcon = presentation.icon; + + return ( +
  • + +
    +
    +

    + {verification.label} +

    + + {presentation.label} + +
    + {verification.evidence ? ( +

    + {verification.evidence} +

    + ) : null} +
    +
  • + ); + })} +
+ ) : ( +

+ No verification was reported. +

+ )} +
+
+
+ ); +} diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index a57bf93e40..dfd9a5e98b 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -25,9 +25,14 @@ import { } from "@/features/messages/lib/threadTreeLayout"; import { KIND_HUDDLE_STARTED, + KIND_JOB_RESULT, KIND_STREAM_MESSAGE_DIFF, } from "@/shared/constants/kinds"; import { getConfigNudgeAuthorPubkey } from "@/features/messages/ui/configNudgeAuthPubkey"; +import { + getJobResultRequestId, + parseJobResultContent, +} from "@/features/messages/lib/jobResult"; import { cn } from "@/shared/lib/cn"; import { normalizePubkey } from "@/shared/lib/pubkey"; import { UserAvatar } from "@/shared/ui/UserAvatar"; @@ -43,6 +48,7 @@ import { MessageActionBar } from "./MessageActionBar"; import { MessageAgentOwner } from "./MessageAgentOwner"; import { MessageAuthorText, MessageHeaderRow } from "./MessageHeader"; import { MessageTimestamp } from "./MessageTimestamp"; +import { JobResultCard } from "./JobResultCard"; import { WaveMessageAttachment } from "./WaveMessageAttachment"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; @@ -302,8 +308,64 @@ export const MessageRow = React.memo( const getTag = (name: string) => message.tags?.find((tag) => tag[0] === name)?.[1]; + const structuredJobResult = + message.kind === KIND_JOB_RESULT + ? parseJobResultContent( + message.body, + getJobResultRequestId(message.tags ?? []), + ) + : null; + + const renderStandardBody = () => { + const waveMessage = parseWaveMessageContent(message.body); + if (waveMessage) { + return ( + + ); + } + + return ( + + ); + }; + const renderBody = () => { switch (message.kind) { + case KIND_JOB_RESULT: + return structuredJobResult ? ( + + ) : ( + renderStandardBody() + ); case KIND_STREAM_MESSAGE_DIFF: return ( ); default: - { - const waveMessage = parseWaveMessageContent(message.body); - if (waveMessage) { - return ( - - ); - } - } - - return ( - - ); + return renderStandardBody(); } }; diff --git a/docs/nips/NIP-AJ.md b/docs/nips/NIP-AJ.md new file mode 100644 index 0000000000..f568cd12a5 --- /dev/null +++ b/docs/nips/NIP-AJ.md @@ -0,0 +1,165 @@ +NIP-AJ +====== + +Agent Job Result Handoffs +------------------------- + +`draft` `optional` + +This NIP defines a versioned, inspectable payload for Buzz agent job result events. It keeps the existing signed, channel-scoped agent job lifecycle (`kind:43001` through `kind:43006`) and gives `kind:43004` a stable result contract that clients can render without reconstructing the full source thread. + +## Motivation + +A plaintext final message such as "done" does not tell a requester which outcome was attempted, what was produced, what verification ran, or whether work is complete, partial, blocked, failed, or intentionally has no durable artifact. These distinctions matter when work is delegated between agents or reviewed on a device that does not have the entire source thread in view. + +The result event is the handoff. It carries explicit references and evidence; producers do not scan or upload local workspaces implicitly. + +## Event + +`kind:43004` is a regular, signed event. It is stored, append-only, and scoped to the same channel as the originating job request. + +```json +{ + "kind": 43004, + "pubkey": "", + "created_at": 1785048000, + "content": "", + "tags": [ + ["h", ""], + ["e", "", "", "reply"], + ["schema", "buzz.job-result", "1"], + ["disposition", "completed"] + ], + "sig": "..." +} +``` + +The event MUST contain exactly one channel `h` tag and MUST reference the originating job request with an `e` reply tag. The event content's `jobRequest` value MUST match that `e` tag. The `schema` and `disposition` tags are query and summary hints; clients MUST treat the signed content as authoritative and MUST reject a structured rendering when required content validation fails. + +The event uses the existing relay event path. No additional HTTP endpoint, scheduler, or worker loop is required. + +## Version 1 Payload + +The UTF-8 event content is a JSON object: + +```json +{ + "schemaVersion": 1, + "jobRequest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "requestedOutcome": "Make the completed work inspectable.", + "outcome": "The structured handoff is ready for review.", + "lastProgress": "The full repository gate passed.", + "disposition": "completed", + "artifacts": [ + { + "kind": "pull_request", + "label": "Implementation pull request", + "reference": "https://github.com/block/buzz/pull/1234", + "sourceState": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + } + ], + "verification": [ + { + "label": "just ci", + "status": "passed", + "evidence": "Exit status 0 at commit bbbbbbb." + } + ] +} +``` + +Required fields: + +- `schemaVersion`: integer `1`. +- `jobRequest`: the 64-character hexadecimal event id of the originating `kind:43001`. +- `requestedOutcome`: the outcome the requester asked for. +- `outcome`: a concise final result. +- `disposition`: one of `completed`, `partial`, `blocked`, `failed`, or `no_artifact`. + +Optional fields: + +- `lastProgress`: the last meaningful phase or progress reached. +- `artifacts`: zero or more artifact references. +- `verification`: zero or more verification results. +- `blocker`: the concrete condition preventing completion. + +Consumers MUST ignore unknown object fields when `schemaVersion` is supported. Producers MUST reject unsupported schema versions rather than publishing a payload they cannot validate. + +### Disposition invariants + +- `completed` requires at least one artifact reference. +- `no_artifact` requires an empty artifact list and represents completed analytical or advisory work that intentionally produced no durable artifact. +- `blocked` requires a non-empty `blocker`. +- `partial` and `failed` MAY include artifacts, verification, and a blocker when those fields help a reviewer understand the usable result and remaining work. + +## Artifact References + +Each artifact contains: + +- `kind`: one of `file`, `media`, `branch`, `commit`, `pull_request`, `canvas`, `workflow_output`, `build`, `deployment`, `link`, or `other`. +- `label`: a reader-facing name. +- `reference`: an uploaded URL, Buzz reference, repository-relative path, branch/ref, object id, workflow/build id, or equivalent stable reference. +- `sourceState`: optional commit, branch, workflow run, build id, deployment revision, or equivalent provenance. + +URL-bearing references use `http`, `https`, `buzz`, or `nostr`. HTTP(S) references require a host, and URLs containing embedded credentials are invalid. File references MUST be uploaded URLs or repository-relative paths; absolute local paths, upward traversal, and `file:` URLs are invalid. Commit references MUST be a full 40- or 64-character hexadecimal object id or a supported URL. + +Artifacts are references, not implicit uploads. A producer MUST NOT read, attach, or publish local file contents merely because a path appears in a handoff manifest. + +## Verification + +Each verification item contains: + +- `label`: the check or review performed. +- `status`: `passed`, `failed`, or `not_run`. +- `evidence`: optional concise output, source revision, or result URL. + +`not_run` is distinct from `passed`. Producers MUST NOT infer verification from the existence of an artifact, successful event publication, a merged pull request, or an unrelated CI run. + +## Limits + +- Serialized content MUST NOT exceed 65,536 bytes. +- `artifacts` and `verification` are each limited to 50 items. +- `requestedOutcome` and `outcome` are each limited to 8 KiB. +- `lastProgress` and `blocker` are each limited to 4 KiB. +- Labels are limited to 512 bytes. +- Artifact references and verification evidence are limited to 2 KiB. +- Artifact source state is limited to 512 bytes. + +Required text MUST contain a non-whitespace character. Labels, artifact references, and source state MUST be single-line and free of control characters. + +## CLI + +Agents can publish a validated handoff from a file: + +```bash +buzz jobs handoff \ + --channel \ + --job \ + --manifest result.json +``` + +Or through standard input: + +```bash +generate-result-manifest | + buzz jobs handoff \ + --channel \ + --job \ + --manifest - +``` + +The CLI validates JSON, schema, cross-field invariants, references, item limits, size, and the match between `jobRequest` and `--job` before it signs or submits an event. A successful write uses the CLI's standard `{event_id, accepted, message}` response. + +## Client Behavior and Compatibility + +Clients that recognize a valid version 1 payload SHOULD render the disposition, requested outcome, outcome, progress, artifacts, verification, and blocker as distinct fields. Home or notification summaries SHOULD use the outcome and disposition rather than showing raw JSON. + +Legacy or malformed `kind:43004` content remains valid plaintext job-result content. A client that cannot validate the structured payload MUST fall back to its existing plaintext or Markdown rendering. It MUST NOT render partially parsed fields as trusted handoff metadata. + +## Security and Privacy + +Job result content is visible to channel members under the channel's existing access policy. Producers MUST NOT include credentials, environment variables, private local paths, system prompts, tool inputs/results, or copied local file contents unless the requester explicitly chose to publish that material and the channel is appropriate. + +Artifact labels and references are untrusted event content. Clients MUST escape text and restrict clickable links to schemes they can open safely. A signed event proves authorship; it does not prove that an external URL is safe, that an artifact still exists, or that a reported verification result is honest. + +The schema improves accountability by making claims explicit. It is not a remote-attestation or reputation protocol.