mirror of
https://github.com/block/buzz.git
synced 2026-11-09 19:02:00 +00:00
Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
335 lines
11 KiB
Rust
335 lines
11 KiB
Rust
use serde::Serialize;
|
||
use serde_json::Value;
|
||
use tauri::State;
|
||
|
||
use crate::{
|
||
app_state::AppState,
|
||
events,
|
||
relay::{parse_command_response, query_relay, submit_event},
|
||
};
|
||
|
||
// ── Wire shapes (snake_case, consumed by tauriWorkflows.ts) ──────────────────
|
||
|
||
/// A workflow definition as the desktop frontend expects it. Mirrors the
|
||
/// `RawWorkflow` type in `desktop/src/shared/api/tauriWorkflows.ts`.
|
||
///
|
||
/// The relay stores a workflow as a single kind:30620 event whose content is
|
||
/// the raw YAML. Everything the UI needs is derived from that event:
|
||
/// - `id` / `channel_id` from the `d` / `h` tags,
|
||
/// - `definition` from parsing the YAML body into a free-form object,
|
||
/// - `name` from `definition.name`,
|
||
/// - `owner_pubkey` / timestamps from the event itself.
|
||
///
|
||
/// `status` is always `"active"` here: the relay's disable/archive lifecycle is
|
||
/// not reflected back into the kind:30620 event, and the UI derives a
|
||
/// "disabled" display state from `definition.enabled` on its own
|
||
/// (`getWorkflowDisplayStatus`).
|
||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||
pub struct WorkflowWire {
|
||
pub id: String,
|
||
pub name: String,
|
||
pub owner_pubkey: String,
|
||
pub channel_id: Option<String>,
|
||
pub definition: Value,
|
||
pub status: String,
|
||
pub created_at: i64,
|
||
pub updated_at: i64,
|
||
}
|
||
|
||
/// Response shape for create/update. Mirrors `RawWorkflowSaveResponse` in the
|
||
/// frontend: a full workflow record plus an optional webhook secret (only
|
||
/// present for webhook-triggered workflows on creation).
|
||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||
pub struct WorkflowSaveWire {
|
||
#[serde(flatten)]
|
||
pub workflow: WorkflowWire,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub webhook_secret: Option<String>,
|
||
}
|
||
|
||
// ── Reads ────────────────────────────────────────────────────────────────────
|
||
|
||
#[tauri::command]
|
||
pub async fn get_channel_workflows(
|
||
channel_id: String,
|
||
state: State<'_, AppState>,
|
||
) -> Result<Vec<WorkflowWire>, String> {
|
||
let events = query_relay(
|
||
&state,
|
||
&[serde_json::json!({
|
||
"kinds": [30620],
|
||
"#h": [channel_id],
|
||
})],
|
||
)
|
||
.await?;
|
||
|
||
Ok(events.iter().map(workflow_from_event).collect())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn get_workflow(
|
||
workflow_id: String,
|
||
state: State<'_, AppState>,
|
||
) -> Result<WorkflowWire, String> {
|
||
let events = query_relay(
|
||
&state,
|
||
&[serde_json::json!({
|
||
"kinds": [30620],
|
||
"#d": [workflow_id],
|
||
"limit": 1
|
||
})],
|
||
)
|
||
.await?;
|
||
|
||
events
|
||
.first()
|
||
.map(workflow_from_event)
|
||
.ok_or_else(|| "workflow not found".to_string())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn get_workflow_runs(
|
||
workflow_id: String,
|
||
limit: Option<u32>,
|
||
_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())
|
||
}
|
||
|
||
// ── Writes ───────────────────────────────────────────────────────────────────
|
||
|
||
#[tauri::command]
|
||
pub async fn create_workflow(
|
||
channel_id: String,
|
||
yaml_definition: String,
|
||
state: State<'_, AppState>,
|
||
) -> Result<WorkflowSaveWire, String> {
|
||
let workflow_id = uuid::Uuid::new_v4().to_string();
|
||
let builder = events::build_workflow_definition(&workflow_id, &channel_id, &yaml_definition)?;
|
||
let result = submit_event(builder, &state).await?;
|
||
|
||
// The relay returns `webhook_secret` in the OK response message for
|
||
// webhook-triggered workflows. Everything else in the save record is built
|
||
// locally from the inputs we already hold — the relay's create response
|
||
// only carries `{ workflow_id, webhook_secret? }`.
|
||
let webhook_secret = parse_command_response::<Value>(&result.message)
|
||
.ok()
|
||
.and_then(|v| {
|
||
v.get("webhook_secret")
|
||
.and_then(Value::as_str)
|
||
.map(str::to_string)
|
||
});
|
||
|
||
let now = now_secs();
|
||
let workflow = workflow_record(
|
||
workflow_id,
|
||
Some(channel_id),
|
||
current_pubkey_hex(&state)?,
|
||
&yaml_definition,
|
||
now,
|
||
now,
|
||
);
|
||
|
||
Ok(WorkflowSaveWire {
|
||
workflow,
|
||
webhook_secret,
|
||
})
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn update_workflow(
|
||
workflow_id: String,
|
||
yaml_definition: String,
|
||
state: State<'_, AppState>,
|
||
) -> Result<WorkflowSaveWire, String> {
|
||
// Find the channel id (and creation time) from the existing workflow event
|
||
// so the new event carries the same `h` tag — kind:30620 is replaceable by
|
||
// (pubkey, d-tag).
|
||
let prior = query_relay(
|
||
&state,
|
||
&[serde_json::json!({
|
||
"kinds": [30620],
|
||
"#d": [workflow_id.clone()],
|
||
"limit": 1
|
||
})],
|
||
)
|
||
.await?;
|
||
|
||
let prior_event = prior
|
||
.first()
|
||
.ok_or_else(|| "workflow not found".to_string())?;
|
||
let channel_id = tag_value(prior_event, "h").ok_or_else(|| "workflow not found".to_string())?;
|
||
let created_at = prior_event.created_at.as_secs() as i64;
|
||
|
||
let builder = events::build_workflow_definition(&workflow_id, &channel_id, &yaml_definition)?;
|
||
submit_event(builder, &state).await?;
|
||
|
||
let updated_at = now_secs();
|
||
let workflow = workflow_record(
|
||
workflow_id,
|
||
Some(channel_id),
|
||
current_pubkey_hex(&state)?,
|
||
&yaml_definition,
|
||
created_at,
|
||
updated_at,
|
||
);
|
||
|
||
Ok(WorkflowSaveWire {
|
||
workflow,
|
||
// Updates never rotate the webhook secret.
|
||
webhook_secret: None,
|
||
})
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn delete_workflow(
|
||
workflow_id: String,
|
||
state: State<'_, AppState>,
|
||
) -> Result<(), String> {
|
||
let builder = events::build_workflow_delete(&workflow_id, ¤t_pubkey_hex(&state)?)?;
|
||
submit_event(builder, &state).await?;
|
||
Ok(())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn trigger_workflow(
|
||
workflow_id: String,
|
||
state: State<'_, AppState>,
|
||
) -> Result<Value, String> {
|
||
let builder = events::build_workflow_trigger(&workflow_id)?;
|
||
let result = submit_event(builder, &state).await?;
|
||
Ok(serde_json::json!({ "event_id": result.event_id }))
|
||
}
|
||
|
||
// ── Approvals ────────────────────────────────────────────────────────────────
|
||
|
||
#[tauri::command]
|
||
pub async fn get_run_approvals(
|
||
workflow_id: String,
|
||
run_id: String,
|
||
_state: State<'_, AppState>,
|
||
) -> Result<Vec<Value>, String> {
|
||
// TODO(workflow-runs): Like runs (see `get_workflow_runs`), reconstructing
|
||
// approvals into the frontend's `WorkflowApproval` shape from lifecycle
|
||
// events (46010/46011/46012) is a clearly-scoped follow-up tracked under
|
||
// TODO(workflow-runs). Return a bare empty array so the frontend's
|
||
// `getRunApprovals` (`raw.map(fromRawApproval)`) is safe.
|
||
let _ = (workflow_id, run_id);
|
||
Ok(Vec::new())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn grant_approval(
|
||
token: String,
|
||
note: Option<String>,
|
||
state: State<'_, AppState>,
|
||
) -> Result<Value, String> {
|
||
let builder = events::build_approval_grant(&token, note.as_deref())?;
|
||
let result = submit_event(builder, &state).await?;
|
||
Ok(serde_json::json!({ "event_id": result.event_id }))
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn deny_approval(
|
||
token: String,
|
||
note: Option<String>,
|
||
state: State<'_, AppState>,
|
||
) -> Result<Value, String> {
|
||
let builder = events::build_approval_deny(&token, note.as_deref())?;
|
||
let result = submit_event(builder, &state).await?;
|
||
Ok(serde_json::json!({ "event_id": result.event_id }))
|
||
}
|
||
|
||
// ── Helpers (pure, unit-tested in workflows_tests.rs) ─────────────────────────
|
||
|
||
fn current_pubkey_hex(state: &AppState) -> Result<String, String> {
|
||
let keys = state.keys.lock().map_err(|e| e.to_string())?;
|
||
Ok(keys.public_key().to_hex())
|
||
}
|
||
|
||
fn now_secs() -> i64 {
|
||
std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.map(|d| d.as_secs() as i64)
|
||
.unwrap_or_default()
|
||
}
|
||
|
||
/// First value of the tag whose name matches `name` (e.g. `d`, `h`).
|
||
fn tag_value(ev: &nostr::Event, name: &str) -> Option<String> {
|
||
ev.tags.iter().find_map(|t| {
|
||
let s = t.as_slice();
|
||
(s.len() >= 2 && s[0] == name).then(|| s[1].clone())
|
||
})
|
||
}
|
||
|
||
/// Parse a workflow's YAML body into a free-form JSON object. The frontend
|
||
/// consumes `definition` as `Record<string, unknown>`, so we preserve the full
|
||
/// document. On parse failure (or a non-object document) we fall back to an
|
||
/// empty object rather than failing the whole list query — a single malformed
|
||
/// workflow must not break the page.
|
||
fn parse_definition(yaml: &str) -> Value {
|
||
match serde_yaml::from_str::<Value>(yaml) {
|
||
Ok(v @ Value::Object(_)) => v,
|
||
_ => Value::Object(serde_json::Map::new()),
|
||
}
|
||
}
|
||
|
||
/// Build a [`WorkflowWire`] record from its parts. Shared by the read path
|
||
/// (from a relay event) and the write path (from local inputs).
|
||
fn workflow_record(
|
||
id: String,
|
||
channel_id: Option<String>,
|
||
owner_pubkey: String,
|
||
yaml_definition: &str,
|
||
created_at: i64,
|
||
updated_at: i64,
|
||
) -> WorkflowWire {
|
||
let definition = parse_definition(yaml_definition);
|
||
let name = definition
|
||
.get("name")
|
||
.and_then(Value::as_str)
|
||
.filter(|s| !s.trim().is_empty())
|
||
.map(str::to_string)
|
||
.unwrap_or_else(|| id.clone());
|
||
|
||
WorkflowWire {
|
||
id,
|
||
name,
|
||
owner_pubkey,
|
||
channel_id,
|
||
definition,
|
||
status: "active".to_string(),
|
||
created_at,
|
||
updated_at,
|
||
}
|
||
}
|
||
|
||
/// Convert a kind:30620 workflow definition event into a [`WorkflowWire`].
|
||
fn workflow_from_event(ev: &nostr::Event) -> WorkflowWire {
|
||
let id = tag_value(ev, "d").unwrap_or_default();
|
||
let channel_id = tag_value(ev, "h");
|
||
let ts = ev.created_at.as_secs() as i64;
|
||
workflow_record(id, channel_id, ev.pubkey.to_hex(), &ev.content, ts, ts)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
#[path = "workflows_tests.rs"]
|
||
mod tests;
|