Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions crates/buzz-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ buzz messages delete --event <event-id>
# Diffs
buzz messages send-diff --channel <uuid> --diff - --repo https://github.com/org/repo --commit abc123 < diff.patch

# Agent job handoffs
buzz jobs handoff --channel <uuid> --job <event-id> --manifest result.json
generate-result-manifest | buzz jobs handoff --channel <uuid> --job <event-id> --manifest -

# Channels
buzz channels list
buzz channels create --name "my-channel" --type stream --visibility open
Expand Down Expand Up @@ -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 |
Expand Down
117 changes: 117 additions & 0 deletions crates/buzz-cli/src/commands/jobs.rs
Original file line number Diff line number Diff line change
@@ -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<JobResultPayload, CliError> {
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(_))
));
}
}
1 change: 1 addition & 0 deletions crates/buzz-cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
57 changes: 56 additions & 1 deletion crates/buzz-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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 <UUID> --job <EVENT_ID> --manifest result.json\n \
cat result.json | buzz jobs handoff --channel <UUID> --job <EVENT_ID> --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
Expand Down Expand Up @@ -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,
Expand All @@ -1795,14 +1818,43 @@ 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]
fn cli_definition_is_valid() {
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![
Expand All @@ -1813,6 +1865,7 @@ mod tests {
"emoji",
"feed",
"issues",
"jobs",
"media",
"mem",
"messages",
Expand Down Expand Up @@ -1891,6 +1944,7 @@ mod tests {
"vote"
]
);
assert_eq!(names(&cmd, "jobs"), vec!["handoff"]);
assert_eq!(
names(&cmd, "channels"),
vec![
Expand Down Expand Up @@ -2002,6 +2056,7 @@ mod tests {
("emoji", 5),
("feed", 1),
("issues", 4),
("jobs", 1),
("media", 1),
("messages", 8),
("pack", 2),
Expand Down
Loading