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
3 changes: 2 additions & 1 deletion crates/buzz-cli/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -407,7 +407,8 @@ buzz workflows trigger --workflow "$WF_ID" | jq .

# workflows runs
buzz workflows runs --workflow "$WF_ID" | jq .
# Expected: [] — relay stores runs in DB, not as Nostr events; empty is normal
# Expected: a newest-first array of durable run records (or [] when no runs
# exist). Each record includes status, per-step execution_trace, and errors.

# workflows approve — requires a workflow run waiting for approval
# This is hard to test ad-hoc without a workflow that has an approval gate.
Expand Down
36 changes: 8 additions & 28 deletions crates/buzz-cli/src/commands/workflows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,40 +57,20 @@ pub async fn cmd_get_workflow(client: &BuzzClient, workflow_id: &str) -> Result<
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.
/// Get workflow run history from the relay's durable workflow_runs read path.
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}");
let limit = limit.unwrap_or(20).clamp(1, 100);
let resp = client
.get_authed(&format!("/api/workflows/{workflow_id}/runs?limit={limit}"))
.await?;
let runs: Vec<serde_json::Value> = serde_json::from_str(&resp)
.map_err(|e| CliError::Other(format!("failed to parse workflow run response: {e}")))?;
println!("{}", serde_json::to_string(&runs).unwrap_or_default());
Ok(())
}

Expand Down
130 changes: 130 additions & 0 deletions crates/buzz-relay/src/api/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1321,6 +1321,102 @@ async fn query_events_authed(
Ok(Json(Value::Array(events)))
}

/// Query parameters for the authenticated workflow run-history endpoint.
#[derive(Debug, serde::Deserialize)]
pub struct WorkflowRunsQuery {
/// Maximum number of runs to return. The relay applies a hard cap.
pub limit: Option<u32>,
}

/// Convert the durable DB run record to the wire shape consumed by Desktop and
/// the agent CLI. Timestamps are Unix seconds to match the existing workflow
/// event and frontend contracts.
fn workflow_run_to_json(run: &buzz_db::workflow::WorkflowRunRecord) -> Value {
serde_json::json!({
"id": run.id.to_string(),
"workflow_id": run.workflow_id.to_string(),
"trigger_event_id": run.trigger_event_id.as_ref().map(hex::encode),
"status": run.status.to_string(),
"current_step": run.current_step,
"execution_trace": run.execution_trace,
"started_at": run.started_at.map(|value| value.timestamp()),
"completed_at": run.completed_at.map(|value| value.timestamp()),
"error_message": run.error_message,
"created_at": run.created_at.timestamp(),
})
}

/// Read workflow run history from the relay's durable workflow_runs table.
///
/// This is an authenticated, tenant-bound read. A caller may only see runs for
/// workflows whose channel is in the caller's existing channel-access scope;
/// inaccessible and unknown workflows deliberately share the same 404 response.
pub async fn workflow_runs(
State(state): State<Arc<AppState>>,
Path(id_str): Path<String>,
Query(query): Query<WorkflowRunsQuery>,
headers: HeaderMap,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let workflow_id =
uuid::Uuid::parse_str(&id_str).map_err(|_| not_found("workflow not found"))?;
let path = match query.limit {
Some(limit) => format!("/api/workflows/{workflow_id}/runs?limit={limit}"),
None => format!("/api/workflows/{workflow_id}/runs"),
};
let limit = query.limit.unwrap_or(20).clamp(1, 100);

let raw_host = headers
.get(axum::http::header::HOST)
.and_then(|value| value.to_str().ok())
.unwrap_or("");
let tenant = crate::tenant::bind_community(&state.db, raw_host)
.await
.map_err(|_| not_found("workflow not found"))?;
let url = nip98_expected_url(&state.config.relay_url, &tenant, &path);
let (pubkey, event_id_bytes) =
verify_bridge_auth(&headers, "GET", &url, None, state.config.require_auth_token)?;

enforce_http_admission(&state, &tenant, &pubkey).await?;
check_nip98_replay(&state, &tenant, event_id_bytes).await?;

let pubkey_bytes = pubkey.to_bytes().to_vec();
let auth_tag = headers
.get("x-auth-tag")
.and_then(|value| value.to_str().ok());
super::relay_members::enforce_relay_membership(
&state,
tenant.community(),
&pubkey_bytes,
auth_tag,
)
.await?;

let workflow = state
.db
.get_workflow(tenant.community(), workflow_id)
.await
.map_err(|_| not_found("workflow not found"))?;
let Some(channel_id) = workflow.channel_id else {
return Err(not_found("workflow not found"));
};
let accessible_channels = state
.get_accessible_channel_ids_cached(tenant.community(), &pubkey_bytes)
.await
.map_err(|error| internal_error(&format!("channel access lookup: {error}")))?;
if !accessible_channels.contains(&channel_id) {
return Err(not_found("workflow not found"));
}

let runs = state
.db
.list_workflow_runs(tenant.community(), workflow_id, i64::from(limit))
.await
.map_err(|error| internal_error(&format!("workflow run lookup: {error}")))?;
Ok(Json(Value::Array(
runs.iter().map(workflow_run_to_json).collect(),
)))
}

/// Count events via HTTP bridge (NIP-98 auth). Returns `{"count": N}`.
///
/// Enforces channel access: only counts events in channels the user can access.
Expand Down Expand Up @@ -2297,6 +2393,40 @@ mod tests {
assert!(!has_mixed_search_filters(&filters));
}

#[test]
fn workflow_run_wire_preserves_failure_diagnostics() {
let created_at = chrono::DateTime::from_timestamp(1_700_000_000, 0).unwrap();
let started_at = chrono::DateTime::from_timestamp(1_700_000_001, 0).unwrap();
let completed_at = chrono::DateTime::from_timestamp(1_700_000_003, 0).unwrap();
let run = buzz_db::workflow::WorkflowRunRecord {
id: uuid::Uuid::from_u128(1),
community_id: buzz_core::CommunityId::from_uuid(uuid::Uuid::from_u128(2)),
workflow_id: uuid::Uuid::from_u128(3),
status: buzz_db::workflow::RunStatus::Failed,
trigger_event_id: Some(vec![0xde, 0xad, 0xbe, 0xef]),
current_step: 0,
execution_trace: serde_json::json!([
{ "step_id": "notify", "status": "failed", "error": "destination missing" }
]),
trigger_context: None,
started_at: Some(started_at),
completed_at: Some(completed_at),
error_message: Some("destination missing".to_string()),
created_at,
};

let wire = workflow_run_to_json(&run);
assert_eq!(wire["id"], "00000000-0000-0000-0000-000000000001");
assert_eq!(wire["workflow_id"], "00000000-0000-0000-0000-000000000003");
assert_eq!(wire["trigger_event_id"], "deadbeef");
assert_eq!(wire["status"], "failed");
assert_eq!(wire["created_at"], 1_700_000_000);
assert_eq!(wire["started_at"], 1_700_000_001);
assert_eq!(wire["completed_at"], 1_700_000_003);
assert_eq!(wire["error_message"], "destination missing");
assert_eq!(wire["execution_trace"][0]["step_id"], "notify");
}

#[test]
fn bridge_search_mode_extension_defaults_to_full_text() {
assert_eq!(
Expand Down
1 change: 1 addition & 0 deletions crates/buzz-relay/src/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ pub fn build_router(state: Arc<AppState>) -> Router {
.route("/events", post(api::bridge::submit_event))
.route("/query", post(api::bridge::query_events))
.route("/count", post(api::bridge::count_events))
.route("/api/workflows/{id}/runs", get(api::bridge::workflow_runs))
.route(
"/operator/communities",
get(api::operator::list_owned_communities).post(api::operator::provision_community),
Expand Down
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export default defineConfig({
"**/relay-reconnect.spec.ts",
"**/relay-reconnect-affordance.spec.ts",
"**/workflows.spec.ts",
"**/workflow-run-status-badges.spec.ts",
"**/identity-archive.spec.ts",
"**/identity-archive-hide.spec.ts",
"**/relay-connectivity.spec.ts",
Expand Down
30 changes: 10 additions & 20 deletions desktop/src-tauri/src/commands/workflows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use tauri::State;
use crate::{
app_state::AppState,
events,
relay::{parse_command_response, query_relay, submit_event},
relay::{get_relay_json, parse_command_response, query_relay, submit_event},
};

// ── Wire shapes (snake_case, consumed by tauriWorkflows.ts) ──────────────────
Expand Down Expand Up @@ -121,26 +121,16 @@ pub async fn get_workflow(
pub async fn get_workflow_runs(
workflow_id: String,
limit: Option<u32>,
_state: State<'_, AppState>,
state: State<'_, AppState>,
) -> Result<Vec<Value>, String> {
// TODO(workflow-runs): Run reconstruction is a clearly-scoped follow-up.
// The authoritative run record the frontend's `WorkflowRun` shape needs
// (status / current_step / execution_trace / error_message) lives in the
// relay DB and is not exposed to the desktop client as a single queryable
// record. If the relay starts emitting lifecycle events (46001–46007, …),
// folding that stream into `WorkflowRun` would be another viable design.
// The important bit for this command is that raw lifecycle events are not
// the `RawWorkflowRun` contract.
//
// Until then we return a bare empty array — NOT a raw-event wrapper. The
// frontend wrapper (`getWorkflowRuns`) does `raw.map(fromRawWorkflowRun)`,
// so it must receive an array; the wrapped `{ runs: [...] }` shape would
// make `.map()` throw and crash the detail panel (the same TypeError class
// as the original page bug). Raw lifecycle events also don't carry the
// `id`/`workflow_id`/`status`/… fields `RawWorkflowRun` expects, so an
// empty list is the honest, safe placeholder.
let _ = (workflow_id, limit);
Ok(Vec::new())
let workflow_id =
uuid::Uuid::parse_str(&workflow_id).map_err(|_| "invalid workflow UUID".to_string())?;
let limit = limit.unwrap_or(20).clamp(1, 100);
get_relay_json(
&state,
&format!("/api/workflows/{workflow_id}/runs?limit={limit}"),
)
.await
}

// ── Writes ───────────────────────────────────────────────────────────────────
Expand Down
27 changes: 27 additions & 0 deletions desktop/src-tauri/src/relay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,33 @@ pub async fn query_relay(
query_relay_at(state, &relay_api_base_url_with_override(state), filters).await
}

/// Fetch a JSON response from an authenticated relay GET endpoint.
///
/// `path` is root-relative and may include a query string. The complete URL is
/// signed in the NIP-98 `u` tag so tenant and query parameters cannot be
/// rewritten between signing and verification.
pub async fn get_relay_json<T: DeserializeOwned>(
state: &AppState,
path: &str,
) -> Result<T, String> {
crate::relay_admission::wait_for_rate_limit().await;
let url = format!("{}{path}", relay_api_base_url_with_override(state));
let auth = build_nip98_auth_header(&Method::GET, &url, &[], state)?;
let response = state
.http_client
.get(&url)
.header("Authorization", auth)
.send()
.await
.map_err(|error| classify_request_error(&error))?;

if !response.status().is_success() {
return Err(relay_error_message(response).await);
}

parse_json_response(response).await
}

/// Like [`query_relay`] but targets an explicit HTTP API base URL instead of
/// the workspace override. Used when a query must hit a specific relay (e.g.
/// reconciling an agent's profile on the relay where it was published).
Expand Down
14 changes: 10 additions & 4 deletions desktop/src/features/workflows/ui/WorkflowDetailPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,10 @@ export function WorkflowDetailPanel({
<span className="truncate font-mono text-xs font-medium">
{run.id.slice(0, 8)}
</span>
<RunStatusBadge status={run.status} />
<RunStatusBadge
data-testid={`workflow-run-status-${run.status}`}
status={run.status}
/>
</div>
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 pl-6 text-2xs text-muted-foreground">
<span>
Expand Down Expand Up @@ -280,7 +283,10 @@ function formatStatusLabel(status: string) {
return status.replace(/_/g, " ");
}

function RunStatusBadge({ status }: { status: string }) {
function RunStatusBadge({
status,
...props
}: { status: string } & React.ComponentProps<typeof Badge>) {
const variants: Record<string, BadgeProps["variant"]> = {
active: "success",
disabled: "secondary",
Expand All @@ -289,12 +295,12 @@ function RunStatusBadge({ status }: { status: string }) {
failed: "destructive",
running: "info",
pending: "secondary",
cancelled: "secondary",
cancelled: "warning",
waiting_approval: "warning",
};

return (
<Badge variant={variants[status] ?? "secondary"}>
<Badge {...props} variant={variants[status] ?? "secondary"}>
{formatStatusLabel(status)}
</Badge>
);
Expand Down
2 changes: 2 additions & 0 deletions desktop/src/shared/api/tauriWorkflows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ type RawTraceEntry = {
type RawWorkflowRun = {
id: string;
workflow_id: string;
trigger_event_id?: string | null;
status: WorkflowRun["status"];
current_step: number | null;
execution_trace: RawTraceEntry[];
Expand Down Expand Up @@ -111,6 +112,7 @@ function fromRawWorkflowRun(raw: RawWorkflowRun): WorkflowRun {
return {
id: raw.id,
workflowId: raw.workflow_id,
triggerEventId: raw.trigger_event_id ?? null,
status: raw.status,
currentStep: raw.current_step,
executionTrace: raw.execution_trace.map(fromRawTraceEntry),
Expand Down
1 change: 1 addition & 0 deletions desktop/src/shared/api/workflowTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export type TraceEntry = {
export type WorkflowRun = {
id: string;
workflowId: string;
triggerEventId: string | null;
status: WorkflowRunStatus;
currentStep: number | null;
executionTrace: TraceEntry[];
Expand Down
Loading