mirror of
https://github.com/block/buzz.git
synced 2026-10-28 04:51:40 +00:00
**Category:** improvement **User Impact:** Users can scan what each workflow does and trigger, edit, duplicate, enable, disable, or delete it directly from the library. **Problem:** The workflow list buried common actions and did not expose each automation's trigger-to-action shape at a glance. **Solution:** Add a responsive workflow library with a persistent create tile, compact trigger/action diagrams, prominent workflow titles with supporting descriptions, and shared card actions while preserving existing detail, editor, and run-history entry points. Card toggles refresh both list and open-detail caches so status and definition stay consistent. <details> <summary>File changes</summary> **desktop/src/features/workflows/ui/WorkflowActionsMenu.tsx** Adds a shared card menu for trigger, edit, duplicate, enable/disable, and delete actions. **desktop/src/features/workflows/ui/WorkflowCard.tsx** Reworks cards around the prototype's visual hierarchy: color-coded trigger, action flow, sentence-case eyebrow, prominent title, supporting description, status, channel, and update date without a footer clock icon. **desktop/src/features/workflows/ui/WorkflowsView.tsx** Adds the responsive grid, create tile, mutation wiring, and list/detail cache invalidation. Container breakpoints keep cards two-across at medium widths and three-across in the 1280px desktop layout. **desktop/src/features/workflows/ui/workflowDefinition.ts** Adds immutable enabled-state updates plus narrow trigger and first-action readers used only to select card icons. **desktop/src/features/workflows/ui/workflowDefinition.test.mjs** Covers neutral icon selection, enabled-state immutability, and status presentation. **desktop/tests/e2e/workflows.spec.ts** Covers the create tile, title/description hierarchy, selected-card enable/disable consistency, and deterministic narrow/medium/wide captures while retaining existing action coverage. </details> ## Reproduction steps 1. Open **Workflows** and confirm the create tile stays first as cards flow from one to three columns with available width. 2. Confirm each card shows a sentence-case trigger eyebrow, prominent workflow title, supporting description when present, status, channel, and update date without a clock icon. 3. Open a card's overflow menu and trigger, edit, duplicate, enable/disable, or delete the workflow. 4. Leave the detail panel open while toggling and confirm its badge and JSON definition update with the card. ## Screenshots Real built E2E UI with representative workflow data at three viewport sizes. ### Narrow — 800 × 720  ### Medium — 1024 × 720  ### Wide — 1280 × 720  ### Card actions  --------- Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
257 lines
9.7 KiB
Rust
257 lines
9.7 KiB
Rust
use sha2::{Digest, Sha256};
|
|
|
|
use crate::client::{
|
|
extract_d_tag, extract_relay_response_field, normalize_write_response, print_create_response,
|
|
BuzzClient,
|
|
};
|
|
use crate::error::CliError;
|
|
use crate::validate::{parse_uuid, read_or_stdin, sdk_err, validate_uuid};
|
|
|
|
// TODO(phase-4): Replace raw nostr::EventBuilder usage with buzz-sdk builder functions
|
|
|
|
/// List workflows in a channel — query kind:30620 workflow definition events.
|
|
pub async fn cmd_list_workflows(client: &BuzzClient, channel_id: &str) -> Result<(), CliError> {
|
|
validate_uuid(channel_id)?;
|
|
let filter = serde_json::json!({
|
|
"kinds": [30620],
|
|
"#h": [channel_id]
|
|
});
|
|
let resp = client.query(&filter).await?;
|
|
let events: Vec<serde_json::Value> = serde_json::from_str(&resp).unwrap_or_default();
|
|
let workflows: Vec<serde_json::Value> = events
|
|
.iter()
|
|
.map(|e| {
|
|
serde_json::json!({
|
|
"workflow_id": extract_d_tag(e),
|
|
"content": e.get("content").and_then(|v| v.as_str()).unwrap_or(""),
|
|
"created_at": e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0),
|
|
"pubkey": e.get("pubkey").and_then(|v| v.as_str()).unwrap_or(""),
|
|
})
|
|
})
|
|
.collect();
|
|
let output = serde_json::to_string(&workflows).unwrap_or_default();
|
|
println!("{output}");
|
|
Ok(())
|
|
}
|
|
|
|
/// Get a single workflow definition.
|
|
pub async fn cmd_get_workflow(client: &BuzzClient, workflow_id: &str) -> Result<(), CliError> {
|
|
validate_uuid(workflow_id)?;
|
|
let filter = serde_json::json!({
|
|
"kinds": [30620],
|
|
"#d": [workflow_id]
|
|
});
|
|
let resp = client.query(&filter).await?;
|
|
let events: Vec<serde_json::Value> = serde_json::from_str(&resp).unwrap_or_default();
|
|
if let Some(e) = events.first() {
|
|
let normalized = serde_json::json!({
|
|
"workflow_id": extract_d_tag(e),
|
|
"content": e.get("content").and_then(|v| v.as_str()).unwrap_or(""),
|
|
"created_at": e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0),
|
|
"pubkey": e.get("pubkey").and_then(|v| v.as_str()).unwrap_or(""),
|
|
});
|
|
println!("{normalized}");
|
|
} else {
|
|
println!("null");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Get workflow run history — query kinds [46001, 46002, 46003].
|
|
///
|
|
/// NOTE: The relay does not currently emit workflow execution events (46001-46003).
|
|
/// Run history is stored in the workflow_runs DB table, not as Nostr events.
|
|
/// This command will return an empty array until the relay adds event emission
|
|
/// or a dedicated REST endpoint for run history.
|
|
pub async fn cmd_get_workflow_runs(
|
|
client: &BuzzClient,
|
|
workflow_id: &str,
|
|
limit: Option<u32>,
|
|
) -> Result<(), CliError> {
|
|
validate_uuid(workflow_id)?;
|
|
let limit = limit.unwrap_or(20).min(100);
|
|
let filter = serde_json::json!({
|
|
"kinds": [46001, 46002, 46003],
|
|
"#d": [workflow_id],
|
|
"limit": limit
|
|
});
|
|
let resp = client.query(&filter).await?;
|
|
let events: Vec<serde_json::Value> = serde_json::from_str(&resp).unwrap_or_default();
|
|
let normalized: Vec<serde_json::Value> = events
|
|
.iter()
|
|
.map(|e| {
|
|
serde_json::json!({
|
|
"event_id": e.get("id").and_then(|v| v.as_str()).unwrap_or(""),
|
|
"kind": e.get("kind").and_then(|v| v.as_u64()).unwrap_or(0),
|
|
"content": e.get("content").and_then(|v| v.as_str()).unwrap_or(""),
|
|
"created_at": e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0),
|
|
"tags": e.get("tags").cloned().unwrap_or(serde_json::json!([])),
|
|
})
|
|
})
|
|
.collect();
|
|
let output = serde_json::to_string(&normalized).unwrap_or_default();
|
|
println!("{output}");
|
|
Ok(())
|
|
}
|
|
|
|
/// Create a workflow — sign and submit a kind:30620 event.
|
|
pub async fn cmd_create_workflow(
|
|
client: &BuzzClient,
|
|
channel_id: &str,
|
|
yaml: &str,
|
|
) -> Result<(), CliError> {
|
|
let channel_uuid = parse_uuid(channel_id)?;
|
|
let yaml_definition = read_or_stdin(yaml)?;
|
|
|
|
let workflow_id = uuid::Uuid::new_v4();
|
|
let builder = buzz_sdk::build_workflow_def(channel_uuid, workflow_id, &yaml_definition)
|
|
.map_err(sdk_err)?;
|
|
let event = client.sign_event(builder)?;
|
|
|
|
let resp = client.submit_event(event).await?;
|
|
let final_workflow_id = extract_relay_response_field(&resp, "workflow_id")
|
|
.unwrap_or_else(|| workflow_id.to_string());
|
|
print_create_response(&resp, "workflow_id", &final_workflow_id);
|
|
Ok(())
|
|
}
|
|
|
|
/// Update a workflow — sign and submit an updated kind:30620 event with same d-tag.
|
|
pub async fn cmd_update_workflow(
|
|
client: &BuzzClient,
|
|
channel_id: &str,
|
|
workflow_id: &str,
|
|
yaml: &str,
|
|
) -> Result<(), CliError> {
|
|
let channel_uuid = parse_uuid(channel_id)?;
|
|
let wf_uuid = parse_uuid(workflow_id)?;
|
|
let yaml_definition = read_or_stdin(yaml)?;
|
|
|
|
let filter = serde_json::json!({
|
|
"kinds": [30620],
|
|
"#d": [workflow_id]
|
|
});
|
|
let resp = client.query(&filter).await?;
|
|
let events: Vec<serde_json::Value> = serde_json::from_str(&resp).unwrap_or_default();
|
|
let expected_revision = events
|
|
.first()
|
|
.and_then(|event| event.get("id"))
|
|
.and_then(|id| id.as_str())
|
|
.ok_or_else(|| CliError::NotFound(format!("workflow {workflow_id} not found")))?;
|
|
|
|
let builder =
|
|
buzz_sdk::build_workflow_update(channel_uuid, wf_uuid, &yaml_definition, expected_revision)
|
|
.map_err(sdk_err)?;
|
|
let event = client.sign_event(builder)?;
|
|
|
|
let resp = client.submit_event(event).await?;
|
|
println!("{}", normalize_write_response(&resp));
|
|
Ok(())
|
|
}
|
|
|
|
/// Delete a workflow — sign and submit a kind:5 deletion event.
|
|
pub async fn cmd_delete_workflow(client: &BuzzClient, workflow_id: &str) -> Result<(), CliError> {
|
|
let wf_uuid = parse_uuid(workflow_id)?;
|
|
let keys = client.keys();
|
|
|
|
let builder =
|
|
buzz_sdk::build_workflow_delete(&keys.public_key().to_hex(), wf_uuid).map_err(sdk_err)?;
|
|
let event = client.sign_event(builder)?;
|
|
|
|
let resp = client.submit_event(event).await?;
|
|
println!("{}", normalize_write_response(&resp));
|
|
Ok(())
|
|
}
|
|
|
|
/// Trigger a workflow — sign and submit a kind:46020 event.
|
|
///
|
|
/// When `inputs` is provided, it is parsed as a JSON object and used as the
|
|
/// event content (MCP parity). When omitted, the event content is `{}`.
|
|
pub async fn cmd_trigger_workflow(
|
|
client: &BuzzClient,
|
|
workflow_id: &str,
|
|
inputs: Option<&str>,
|
|
) -> Result<(), CliError> {
|
|
let wf_uuid = parse_uuid(workflow_id)?;
|
|
|
|
if let Some(raw) = inputs {
|
|
// Parse and validate it is a JSON object, then build the event manually
|
|
// so we can embed the inputs as the event content.
|
|
let parsed: serde_json::Value = serde_json::from_str(raw)
|
|
.map_err(|e| CliError::Usage(format!("--inputs is not valid JSON: {e}")))?;
|
|
if !parsed.is_object() {
|
|
return Err(CliError::Usage("--inputs must be a JSON object".into()));
|
|
}
|
|
let content = serde_json::to_string(&parsed).unwrap_or_default();
|
|
use nostr::{EventBuilder, Kind, Tag};
|
|
let tags = vec![Tag::parse(["d", &wf_uuid.to_string()])
|
|
.map_err(|e| CliError::Other(format!("tag error: {e}")))?];
|
|
let builder = EventBuilder::new(
|
|
Kind::Custom(buzz_sdk::kind::KIND_WORKFLOW_TRIGGER as u16),
|
|
&content,
|
|
)
|
|
.tags(tags);
|
|
let event = client.sign_event(builder)?;
|
|
let resp = client.submit_event(event).await?;
|
|
println!("{}", normalize_write_response(&resp));
|
|
} else {
|
|
let builder = buzz_sdk::build_workflow_trigger(wf_uuid).map_err(sdk_err)?;
|
|
let event = client.sign_event(builder)?;
|
|
let resp = client.submit_event(event).await?;
|
|
println!("{}", normalize_write_response(&resp));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Approve or deny a workflow step — sign and submit a kind:46030 (grant) or 46031 (deny) event.
|
|
pub async fn cmd_approve_step(
|
|
client: &BuzzClient,
|
|
approval_token: &str,
|
|
approved: bool,
|
|
note: Option<&str>,
|
|
) -> Result<(), CliError> {
|
|
validate_uuid(approval_token)?;
|
|
|
|
let content = note.unwrap_or("");
|
|
|
|
// The relay expects d-tag = hex(SHA256(token)), not the raw token UUID.
|
|
let token_hash = hex::encode(Sha256::digest(approval_token.as_bytes()));
|
|
let builder =
|
|
buzz_sdk::build_workflow_approval(&token_hash, approved, content).map_err(sdk_err)?;
|
|
let event = client.sign_event(builder)?;
|
|
|
|
let resp = client.submit_event(event).await?;
|
|
println!("{}", normalize_write_response(&resp));
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn dispatch(cmd: crate::WorkflowsCmd, client: &BuzzClient) -> Result<(), CliError> {
|
|
use crate::WorkflowsCmd;
|
|
match cmd {
|
|
WorkflowsCmd::List { channel } => cmd_list_workflows(client, &channel).await,
|
|
WorkflowsCmd::Get { workflow } => cmd_get_workflow(client, &workflow).await,
|
|
WorkflowsCmd::Create { channel, yaml } => {
|
|
cmd_create_workflow(client, &channel, &yaml).await
|
|
}
|
|
WorkflowsCmd::Update {
|
|
channel,
|
|
workflow,
|
|
yaml,
|
|
} => cmd_update_workflow(client, &channel, &workflow, &yaml).await,
|
|
WorkflowsCmd::Delete { workflow } => cmd_delete_workflow(client, &workflow).await,
|
|
WorkflowsCmd::Trigger { workflow, inputs } => {
|
|
cmd_trigger_workflow(client, &workflow, inputs.as_deref()).await
|
|
}
|
|
WorkflowsCmd::Runs { workflow, limit } => {
|
|
cmd_get_workflow_runs(client, &workflow, limit).await
|
|
}
|
|
WorkflowsCmd::Approve {
|
|
token,
|
|
approved,
|
|
note,
|
|
} => {
|
|
// approved is already a bool — no parse_bool_flag needed
|
|
cmd_approve_step(client, &token, approved, note.as_deref()).await
|
|
}
|
|
}
|
|
}
|