mirror of
https://github.com/block/buzz.git
synced 2026-11-10 11:21:05 +00:00
Signed-off-by: Tyler Longwell <tlongwell@block.xyz> Co-authored-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6 <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1jmc9dt2lyvzu3h0kxlwxt5zg4fxp9476awyxw6gwxn72g6cw7exqs64whm <96f056ad5f2305c8ddf637dc65d048aa4c12d7daeb8867690e34fca46b0ef64c@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
365 lines
12 KiB
Rust
365 lines
12 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())
|
||
}
|
||
|
||
/// Fetch workflows across many channels in a single relay round-trip.
|
||
///
|
||
/// The Workflows overview screen previously issued one `get_channel_workflows`
|
||
/// query per member channel (`Promise.all` fanout in `WorkflowsView`), i.e. N
|
||
/// relay POSTs. A nostr `#h` filter matches ANY of its listed values, so one
|
||
/// query with all channel ids returns the same set. Each `WorkflowWire` carries
|
||
/// its own `channel_id` (from the event's `h` tag), so the frontend can still
|
||
/// group results by channel. Neither this nor the per-channel command sets a
|
||
/// `limit`, so batching does not change result completeness.
|
||
#[tauri::command]
|
||
pub async fn get_channels_workflows(
|
||
channel_ids: Vec<String>,
|
||
state: State<'_, AppState>,
|
||
) -> Result<Vec<WorkflowWire>, String> {
|
||
if channel_ids.is_empty() {
|
||
return Ok(Vec::new());
|
||
}
|
||
|
||
let events = query_relay(
|
||
&state,
|
||
&[serde_json::json!({
|
||
"kinds": [30620],
|
||
"#h": channel_ids,
|
||
})],
|
||
)
|
||
.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;
|