mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Merge origin/main into carl-message-performance-guards
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz> Signed-off-by: Wes <wesbillman@users.noreply.github.com>
This commit is contained in:
@@ -4095,6 +4095,27 @@ impl Db {
|
||||
workflow::list_workflow_runs(&self.pool, community_id, workflow_id, limit).await
|
||||
}
|
||||
|
||||
/// List one keyset-paginated page of workflow runs.
|
||||
#[datastore_span(name = "list_workflow_runs_page", system = "postgresql")]
|
||||
pub async fn list_workflow_runs_page(
|
||||
&self,
|
||||
community_id: CommunityId,
|
||||
workflow_id: Uuid,
|
||||
before: Option<chrono::DateTime<chrono::Utc>>,
|
||||
before_id: Option<Uuid>,
|
||||
limit: i64,
|
||||
) -> Result<Vec<workflow::WorkflowRunRecord>> {
|
||||
workflow::list_workflow_runs_page(
|
||||
&self.pool,
|
||||
community_id,
|
||||
workflow_id,
|
||||
before,
|
||||
before_id,
|
||||
limit,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Update a workflow run's status.
|
||||
#[datastore_span(name = "update_workflow_run", system = "postgresql")]
|
||||
pub async fn update_workflow_run(
|
||||
@@ -4104,7 +4125,7 @@ impl Db {
|
||||
status: workflow::RunStatus,
|
||||
current_step: i32,
|
||||
trace: &serde_json::Value,
|
||||
error: Option<&str>,
|
||||
failure: Option<workflow::WorkflowRunFailure<'_>>,
|
||||
) -> Result<()> {
|
||||
workflow::update_workflow_run(
|
||||
&self.pool,
|
||||
@@ -4113,7 +4134,7 @@ impl Db {
|
||||
status,
|
||||
current_step,
|
||||
trace,
|
||||
error,
|
||||
failure,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -625,7 +625,7 @@ mod tests {
|
||||
let mut migrations: Vec<_> = MIGRATOR.iter().collect();
|
||||
migrations.sort_by_key(|migration| migration.version);
|
||||
|
||||
assert_eq!(migrations.len(), 30);
|
||||
assert_eq!(migrations.len(), 31);
|
||||
assert_eq!(migrations[0].version, 1);
|
||||
assert_eq!(&*migrations[0].description, "initial schema");
|
||||
assert!(migrations[0]
|
||||
@@ -1038,6 +1038,27 @@ mod tests {
|
||||
assert!(deletion_recovery.contains("SET LOCAL lock_timeout = '5s'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_run_error_codes_are_additive_and_backfilled_without_parsing_diagnostics() {
|
||||
let mut migrations: Vec<_> = MIGRATOR.iter().collect();
|
||||
migrations.sort_by_key(|migration| migration.version);
|
||||
|
||||
assert_eq!(migrations[30].version, 31);
|
||||
let sql = migrations[30].sql.as_str();
|
||||
assert!(sql.contains("ALTER TABLE workflow_runs ADD COLUMN error_code TEXT"));
|
||||
assert!(sql.contains("SET error_code = 'legacy_unclassified'"));
|
||||
assert!(sql.contains("status IN ('failed', 'cancelled')"));
|
||||
assert!(!sql.contains("error_message LIKE"));
|
||||
assert!(!MIGRATOR
|
||||
.iter()
|
||||
.find(|migration| migration.version == 1)
|
||||
.expect("initial migration")
|
||||
.sql
|
||||
.as_str()
|
||||
.contains("error_code"));
|
||||
assert!(include_str!("../../../schema/schema.sql").contains("error_code TEXT"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_lint_detects_tables_missing_community_id_by_default() {
|
||||
let sql = r#"
|
||||
|
||||
@@ -216,8 +216,11 @@ pub struct WorkflowRunRecord {
|
||||
pub started_at: Option<DateTime<Utc>>,
|
||||
/// When execution finished (success or failure).
|
||||
pub completed_at: Option<DateTime<Utc>>,
|
||||
/// Error message if the run failed.
|
||||
/// Redacted human-readable diagnostic for failed or cancelled runs.
|
||||
pub error_message: Option<String>,
|
||||
/// Stable machine-readable failure or cancellation classification.
|
||||
/// Kept separate from `error_message` so callers never parse diagnostics.
|
||||
pub error_code: Option<String>,
|
||||
/// When the run record was created.
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -831,7 +834,7 @@ pub async fn get_workflow_run(
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT community_id, id, workflow_id, status::text AS status, trigger_event_id, current_step,
|
||||
execution_trace, trigger_context, started_at, completed_at, error_message, created_at
|
||||
execution_trace, trigger_context, started_at, completed_at, error_message, error_code, created_at
|
||||
FROM workflow_runs
|
||||
WHERE community_id = $1 AND id = $2
|
||||
"#,
|
||||
@@ -845,26 +848,40 @@ pub async fn get_workflow_run(
|
||||
row_to_run_record(row)
|
||||
}
|
||||
|
||||
/// List runs for a workflow, newest first, up to `limit` rows.
|
||||
pub async fn list_workflow_runs(
|
||||
/// List runs for a workflow using a stable newest-first keyset.
|
||||
///
|
||||
/// Rows are ordered by `(created_at DESC, id DESC)`. A cursor is valid only
|
||||
/// when both `before` and `before_id` are supplied; callers should pass the
|
||||
/// final row from the previous page. `limit` is clamped to the shared list
|
||||
/// bounds.
|
||||
pub async fn list_workflow_runs_page(
|
||||
pool: &PgPool,
|
||||
community_id: CommunityId,
|
||||
workflow_id: Uuid,
|
||||
before: Option<DateTime<Utc>>,
|
||||
before_id: Option<Uuid>,
|
||||
limit: i64,
|
||||
) -> Result<Vec<WorkflowRunRecord>> {
|
||||
let limit = limit.min(1000);
|
||||
let limit = limit.clamp(1, LIST_MAX_LIMIT);
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT community_id, id, workflow_id, status::text AS status, trigger_event_id, current_step,
|
||||
execution_trace, trigger_context, started_at, completed_at, error_message, created_at
|
||||
execution_trace, trigger_context, started_at, completed_at, error_message, error_code, created_at
|
||||
FROM workflow_runs
|
||||
WHERE community_id = $1 AND workflow_id = $2
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $3
|
||||
AND (
|
||||
$3::timestamptz IS NULL
|
||||
OR $4::uuid IS NULL
|
||||
OR (created_at, id) < ($3, $4)
|
||||
)
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT $5
|
||||
"#,
|
||||
)
|
||||
.bind(community_id.as_uuid())
|
||||
.bind(workflow_id)
|
||||
.bind(before)
|
||||
.bind(before_id)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
@@ -872,7 +889,26 @@ pub async fn list_workflow_runs(
|
||||
rows.into_iter().map(row_to_run_record).collect()
|
||||
}
|
||||
|
||||
/// Update run status, current step, execution trace, and optional error message.
|
||||
/// List runs for a workflow, newest first, up to `limit` rows.
|
||||
pub async fn list_workflow_runs(
|
||||
pool: &PgPool,
|
||||
community_id: CommunityId,
|
||||
workflow_id: Uuid,
|
||||
limit: i64,
|
||||
) -> Result<Vec<WorkflowRunRecord>> {
|
||||
list_workflow_runs_page(pool, community_id, workflow_id, None, None, limit).await
|
||||
}
|
||||
|
||||
/// Structured failure persisted for a workflow run.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct WorkflowRunFailure<'a> {
|
||||
/// Stable machine-readable failure code.
|
||||
pub code: &'a str,
|
||||
/// Human-readable failure detail.
|
||||
pub message: &'a str,
|
||||
}
|
||||
|
||||
/// Update run status, current step, execution trace, and optional failure.
|
||||
///
|
||||
/// Fix C3: `started_at` is set when the NEW status is 'running' and `started_at`
|
||||
/// has not yet been stamped (IS NULL). The original code read `status` from the
|
||||
@@ -885,26 +921,31 @@ pub async fn update_workflow_run(
|
||||
status: RunStatus,
|
||||
current_step: i32,
|
||||
trace: &serde_json::Value,
|
||||
error: Option<&str>,
|
||||
failure: Option<WorkflowRunFailure<'_>>,
|
||||
) -> Result<()> {
|
||||
let status_str = status.to_string();
|
||||
let (error_code, error) = failure
|
||||
.map(|failure| (Some(failure.code), Some(failure.message)))
|
||||
.unwrap_or((None, None));
|
||||
let affected = sqlx::query(
|
||||
r#"
|
||||
UPDATE workflow_runs
|
||||
SET status = $1::run_status,
|
||||
current_step = $2,
|
||||
execution_trace = $3,
|
||||
error_message = $4,
|
||||
started_at = CASE WHEN $5 = 'running' AND started_at IS NULL
|
||||
error_code = $4,
|
||||
error_message = $5,
|
||||
started_at = CASE WHEN $6 = 'running' AND started_at IS NULL
|
||||
THEN NOW() ELSE started_at END,
|
||||
completed_at = CASE WHEN $6 IN ('completed','failed','cancelled')
|
||||
completed_at = CASE WHEN $7 IN ('completed','failed','cancelled')
|
||||
THEN NOW() ELSE completed_at END
|
||||
WHERE community_id = $7 AND id = $8
|
||||
WHERE community_id = $8 AND id = $9
|
||||
"#,
|
||||
)
|
||||
.bind(&status_str)
|
||||
.bind(current_step)
|
||||
.bind(trace)
|
||||
.bind(error_code)
|
||||
.bind(error)
|
||||
.bind(&status_str) // for started_at CASE
|
||||
.bind(&status_str) // for completed_at CASE
|
||||
@@ -1169,6 +1210,7 @@ fn row_to_run_record(row: sqlx::postgres::PgRow) -> Result<WorkflowRunRecord> {
|
||||
started_at: row.try_get("started_at")?,
|
||||
completed_at: row.try_get("completed_at")?,
|
||||
error_message: row.try_get("error_message")?,
|
||||
error_code: row.try_get("error_code")?,
|
||||
created_at: row.try_get("created_at")?,
|
||||
})
|
||||
}
|
||||
@@ -1473,6 +1515,7 @@ mod tests {
|
||||
started_at: Some(now),
|
||||
completed_at: None,
|
||||
error_message: None,
|
||||
error_code: None,
|
||||
created_at: now,
|
||||
};
|
||||
|
||||
@@ -1501,6 +1544,7 @@ mod tests {
|
||||
started_at: None,
|
||||
completed_at: None,
|
||||
error_message: None,
|
||||
error_code: None,
|
||||
created_at: now,
|
||||
};
|
||||
|
||||
@@ -1524,6 +1568,7 @@ mod tests {
|
||||
started_at: Some(now),
|
||||
completed_at: Some(now),
|
||||
error_message: Some("step timeout exceeded".to_owned()),
|
||||
error_code: Some("step_timeout".to_owned()),
|
||||
created_at: now,
|
||||
};
|
||||
|
||||
@@ -1555,6 +1600,7 @@ mod tests {
|
||||
started_at: Some(now),
|
||||
completed_at: Some(now),
|
||||
error_message: None,
|
||||
error_code: None,
|
||||
created_at: now,
|
||||
};
|
||||
|
||||
@@ -1577,6 +1623,7 @@ mod tests {
|
||||
started_at: None,
|
||||
completed_at: None,
|
||||
error_message: None,
|
||||
error_code: None,
|
||||
created_at: now,
|
||||
};
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ use crate::state::AppState;
|
||||
|
||||
use super::{api_error, internal_error, not_found};
|
||||
|
||||
async fn enforce_http_admission(
|
||||
pub(crate) async fn enforce_http_admission(
|
||||
state: &AppState,
|
||||
tenant: &TenantContext,
|
||||
pubkey: &nostr::PublicKey,
|
||||
@@ -1997,7 +1997,10 @@ pub async fn workflow_webhook(
|
||||
buzz_db::workflow::RunStatus::Failed,
|
||||
0,
|
||||
&serde_json::json!([]),
|
||||
Some(&format!("definition parse error: {e}")),
|
||||
Some(buzz_db::workflow::WorkflowRunFailure {
|
||||
code: "invalid_definition",
|
||||
message: &format!("definition parse error: {e}"),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -9,6 +9,7 @@ pub mod media;
|
||||
pub mod mesh_demo;
|
||||
pub mod nip05;
|
||||
pub mod operator;
|
||||
pub mod workflows;
|
||||
|
||||
// Re-export imeta helpers used by ingest pipeline.
|
||||
pub use crate::handlers::imeta::{validate_imeta_tags, verify_imeta_blobs};
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
//! Authorized structured reads for workflow execution state.
|
||||
//!
|
||||
//! Runs and approvals are relay-owned database rows, not Nostr events. These
|
||||
//! endpoints expose those read models without inventing synthetic events.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
extract::{Path, Query, RawQuery, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::Json,
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use uuid::Uuid;
|
||||
|
||||
use buzz_core::TenantContext;
|
||||
|
||||
use crate::{
|
||||
api::{api_error, bridge, internal_error},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
const DEFAULT_RUN_LIMIT: i64 = 20;
|
||||
const MAX_RUN_LIMIT: i64 = 100;
|
||||
|
||||
/// Pagination query for workflow run history.
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
pub struct RunsQuery {
|
||||
before: Option<DateTime<Utc>>,
|
||||
before_id: Option<Uuid>,
|
||||
limit: Option<i64>,
|
||||
}
|
||||
|
||||
fn request_path(path: &str, raw_query: Option<&str>) -> String {
|
||||
match raw_query {
|
||||
Some(query) if !query.is_empty() => format!("{path}?{query}"),
|
||||
_ => path.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn authorize_workflow_read(
|
||||
state: &Arc<AppState>,
|
||||
headers: &HeaderMap,
|
||||
path: &str,
|
||||
raw_query: Option<&str>,
|
||||
workflow_id: Uuid,
|
||||
) -> Result<TenantContext, (StatusCode, Json<Value>)> {
|
||||
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(|_| {
|
||||
api_error(
|
||||
StatusCode::NOT_FOUND,
|
||||
"relay: no community is configured for this host",
|
||||
)
|
||||
})?;
|
||||
|
||||
let path_with_query = request_path(path, raw_query);
|
||||
let url = bridge::nip98_expected_url(&state.config.relay_url, &tenant, &path_with_query);
|
||||
let (pubkey, event_id_bytes) =
|
||||
bridge::verify_bridge_auth(headers, "GET", &url, None, state.config.require_auth_token)?;
|
||||
bridge::enforce_http_admission(state, &tenant, &pubkey).await?;
|
||||
bridge::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(|error| match error {
|
||||
buzz_db::error::DbError::NotFound(_) => {
|
||||
api_error(StatusCode::NOT_FOUND, "workflow not found")
|
||||
}
|
||||
other => internal_error(&format!("get workflow for run read: {other}")),
|
||||
})?;
|
||||
let channel_id = workflow
|
||||
.channel_id
|
||||
.ok_or_else(|| api_error(StatusCode::FORBIDDEN, "workflow is not channel-scoped"))?;
|
||||
let accessible = state
|
||||
.get_accessible_channel_ids_cached(tenant.community(), &pubkey_bytes)
|
||||
.await
|
||||
.map_err(|error| internal_error(&format!("workflow channel access lookup: {error}")))?;
|
||||
if !accessible.contains(&channel_id) {
|
||||
return Err(api_error(
|
||||
StatusCode::FORBIDDEN,
|
||||
"workflow is not accessible",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(tenant)
|
||||
}
|
||||
|
||||
/// `GET /workflows/{workflow_id}/runs` — one authorized, keyset-paginated page.
|
||||
pub async fn workflow_runs(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(workflow_id): Path<Uuid>,
|
||||
headers: HeaderMap,
|
||||
RawQuery(raw_query): RawQuery,
|
||||
Query(query): Query<RunsQuery>,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
if query.before.is_some() != query.before_id.is_some() {
|
||||
return Err(api_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"before and before_id must be supplied together",
|
||||
));
|
||||
}
|
||||
let limit = query.limit.unwrap_or(DEFAULT_RUN_LIMIT);
|
||||
if !(1..=MAX_RUN_LIMIT).contains(&limit) {
|
||||
return Err(api_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"limit must be between 1 and 100",
|
||||
));
|
||||
}
|
||||
|
||||
let path = format!("/workflows/{workflow_id}/runs");
|
||||
let tenant =
|
||||
authorize_workflow_read(&state, &headers, &path, raw_query.as_deref(), workflow_id).await?;
|
||||
let mut rows = state
|
||||
.db
|
||||
.list_workflow_runs_page(
|
||||
tenant.community(),
|
||||
workflow_id,
|
||||
query.before,
|
||||
query.before_id,
|
||||
limit + 1,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| internal_error(&format!("list workflow runs: {error}")))?;
|
||||
|
||||
let has_more = rows.len() > limit as usize;
|
||||
rows.truncate(limit as usize);
|
||||
let next = if has_more {
|
||||
rows.last().map(|last| {
|
||||
serde_json::json!({
|
||||
"before": last.created_at,
|
||||
"before_id": last.id,
|
||||
})
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"runs": rows.iter().map(run_json).collect::<Vec<_>>(),
|
||||
"next": next,
|
||||
})))
|
||||
}
|
||||
|
||||
/// `GET /workflows/{workflow_id}/runs/{run_id}/approvals` — approvals for a run.
|
||||
pub async fn run_approvals(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path((workflow_id, run_id)): Path<(Uuid, Uuid)>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
let path = format!("/workflows/{workflow_id}/runs/{run_id}/approvals");
|
||||
let tenant = authorize_workflow_read(&state, &headers, &path, None, workflow_id).await?;
|
||||
|
||||
let run = state
|
||||
.db
|
||||
.get_workflow_run(tenant.community(), run_id)
|
||||
.await
|
||||
.map_err(|error| match error {
|
||||
buzz_db::error::DbError::NotFound(_) => {
|
||||
api_error(StatusCode::NOT_FOUND, "workflow run not found")
|
||||
}
|
||||
other => internal_error(&format!("get workflow run for approval read: {other}")),
|
||||
})?;
|
||||
if run.workflow_id != workflow_id {
|
||||
return Err(api_error(StatusCode::NOT_FOUND, "workflow run not found"));
|
||||
}
|
||||
|
||||
let approvals = state
|
||||
.db
|
||||
.get_run_approvals(tenant.community(), workflow_id, run_id)
|
||||
.await
|
||||
.map_err(|error| internal_error(&format!("list run approvals: {error}")))?;
|
||||
Ok(Json(serde_json::json!({
|
||||
"approvals": approvals.iter().map(approval_json).collect::<Vec<_>>(),
|
||||
})))
|
||||
}
|
||||
|
||||
fn run_json(run: &buzz_db::workflow::WorkflowRunRecord) -> Value {
|
||||
serde_json::json!({
|
||||
"id": run.id,
|
||||
"workflow_id": run.workflow_id,
|
||||
"status": run.status,
|
||||
"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_code": run.error_code,
|
||||
"error_message": run.error_message,
|
||||
"created_at": run.created_at.timestamp(),
|
||||
})
|
||||
}
|
||||
|
||||
fn approval_json(approval: &buzz_db::workflow::ApprovalRecord) -> Value {
|
||||
serde_json::json!({
|
||||
"approval_ref": hex::encode(&approval.token),
|
||||
"workflow_id": approval.workflow_id,
|
||||
"run_id": approval.run_id,
|
||||
"step_id": approval.step_id,
|
||||
"step_index": approval.step_index,
|
||||
"approver_spec": approval.approver_spec,
|
||||
"status": approval.status,
|
||||
"approver_pubkey": approval.approver_pubkey.as_ref().map(hex::encode),
|
||||
"note": approval.note,
|
||||
"expires_at": approval.expires_at,
|
||||
"created_at": approval.created_at.timestamp(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn request_path_preserves_signed_query_verbatim() {
|
||||
assert_eq!(
|
||||
request_path("/workflows/id/runs", Some("limit=20&before_id=abc")),
|
||||
"/workflows/id/runs?limit=20&before_id=abc"
|
||||
);
|
||||
assert_eq!(
|
||||
request_path("/workflows/id/runs", None),
|
||||
"/workflows/id/runs"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_wire_does_not_expose_hash_as_token() {
|
||||
let approval = buzz_db::workflow::ApprovalRecord {
|
||||
token: vec![0xab; 32],
|
||||
workflow_id: Uuid::new_v4(),
|
||||
run_id: Uuid::new_v4(),
|
||||
step_id: "review".to_string(),
|
||||
step_index: 1,
|
||||
approver_spec: "any".to_string(),
|
||||
status: buzz_db::workflow::ApprovalStatus::Pending,
|
||||
approver_pubkey: None,
|
||||
note: None,
|
||||
expires_at: Utc::now(),
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
let wire = approval_json(&approval);
|
||||
assert!(wire.get("token").is_none());
|
||||
assert_eq!(wire["approval_ref"], hex::encode([0xab; 32]));
|
||||
}
|
||||
}
|
||||
@@ -964,7 +964,10 @@ async fn handle_workflow_trigger(
|
||||
RunStatus::Failed,
|
||||
0,
|
||||
&serde_json::json!([]),
|
||||
Some(&format!("definition parse error: {e}")),
|
||||
Some(buzz_db::workflow::WorkflowRunFailure {
|
||||
code: "invalid_definition",
|
||||
message: &format!("definition parse error: {e}"),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -1261,7 +1264,10 @@ async fn handle_approval_deny(
|
||||
RunStatus::Cancelled,
|
||||
run.current_step,
|
||||
&run.execution_trace,
|
||||
Some(&cancel_msg),
|
||||
Some(buzz_db::workflow::WorkflowRunFailure {
|
||||
code: "approval_denied",
|
||||
message: &cancel_msg,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -1329,7 +1335,10 @@ async fn resume_workflow_after_approval(
|
||||
RunStatus::Failed,
|
||||
run.current_step,
|
||||
&run.execution_trace,
|
||||
Some(&format!("definition parse error: {e}")),
|
||||
Some(buzz_db::workflow::WorkflowRunFailure {
|
||||
code: "invalid_definition",
|
||||
message: &format!("definition parse error: {e}"),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -72,6 +72,14 @@ 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(
|
||||
"/workflows/{workflow_id}/runs",
|
||||
get(api::workflows::workflow_runs),
|
||||
)
|
||||
.route(
|
||||
"/workflows/{workflow_id}/runs/{run_id}/approvals",
|
||||
get(api::workflows::run_approvals),
|
||||
)
|
||||
.route(
|
||||
"/operator/communities",
|
||||
get(api::operator::list_owned_communities).post(api::operator::provision_community),
|
||||
|
||||
@@ -65,8 +65,50 @@ pub enum WorkflowError {
|
||||
NotImplemented(String),
|
||||
}
|
||||
|
||||
impl WorkflowError {
|
||||
/// Stable run-level classification. Diagnostics remain in `Display` output.
|
||||
pub const fn code(&self) -> &'static str {
|
||||
match self {
|
||||
Self::InvalidYaml(_) => "invalid_yaml",
|
||||
Self::InvalidDefinition(_) => "invalid_definition",
|
||||
Self::ConditionError(_) => "condition_evaluation_failed",
|
||||
Self::TemplateError(_) => "template_resolution_failed",
|
||||
Self::StepTimeout { .. } => "step_timeout",
|
||||
Self::WebhookError(_) => "webhook_failed",
|
||||
Self::CapacityExceeded => "capacity_exceeded",
|
||||
Self::Database(_) => "database_error",
|
||||
Self::Unauthorized(_) => "owner_unauthorized",
|
||||
Self::NotImplemented(_) => "action_not_implemented",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<buzz_db::error::DbError> for WorkflowError {
|
||||
fn from(e: buzz_db::error::DbError) -> Self {
|
||||
WorkflowError::Database(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::WorkflowError;
|
||||
|
||||
#[test]
|
||||
fn workflow_error_codes_are_stable_and_separate_from_diagnostics() {
|
||||
let timeout = WorkflowError::StepTimeout {
|
||||
step_id: "notify".to_owned(),
|
||||
timeout_secs: 30,
|
||||
};
|
||||
assert_eq!(timeout.code(), "step_timeout");
|
||||
assert!(timeout.to_string().contains("notify"));
|
||||
|
||||
let webhook = WorkflowError::WebhookError("secret-bearing detail".to_owned());
|
||||
assert_eq!(webhook.code(), "webhook_failed");
|
||||
assert!(!webhook.code().contains("secret-bearing detail"));
|
||||
|
||||
assert_eq!(
|
||||
WorkflowError::NotImplemented("SendDm".to_owned()).code(),
|
||||
"action_not_implemented"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,7 +242,10 @@ impl WorkflowEngine {
|
||||
RunStatus::Failed,
|
||||
step_count,
|
||||
&trace_json,
|
||||
Some("approval gates not yet implemented — see WF-08"),
|
||||
Some(buzz_db::workflow::WorkflowRunFailure {
|
||||
code: "approval_not_supported",
|
||||
message: "approval gates not yet implemented — see WF-08",
|
||||
}),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -285,7 +288,10 @@ impl WorkflowEngine {
|
||||
RunStatus::Failed,
|
||||
progress.step_index as i32,
|
||||
&trace_json,
|
||||
Some(&e.to_string()),
|
||||
Some(buzz_db::workflow::WorkflowRunFailure {
|
||||
code: e.code(),
|
||||
message: &e.to_string(),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -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) ──────────────────
|
||||
@@ -47,6 +47,41 @@ pub struct WorkflowSaveWire {
|
||||
pub webhook_secret: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Deserialize, Serialize, PartialEq)]
|
||||
pub struct WorkflowRunCursorWire {
|
||||
pub before: String,
|
||||
pub before_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Deserialize, Serialize, PartialEq)]
|
||||
pub struct WorkflowRunsWire {
|
||||
pub runs: Vec<Value>,
|
||||
pub next: Option<WorkflowRunCursorWire>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Deserialize, Serialize, PartialEq)]
|
||||
pub struct WorkflowApprovalsWire {
|
||||
pub approvals: Vec<Value>,
|
||||
}
|
||||
|
||||
/// Canonical trigger acknowledgement consumed by the Desktop client.
|
||||
///
|
||||
/// The relay currently returns only `run_id`; the workflow id is the command
|
||||
/// input and a newly-created run always begins pending. Keeping that adaptation
|
||||
/// here prevents the frontend from guessing fields or confusing the trigger
|
||||
/// event id with the persisted run id.
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
pub struct WorkflowTriggerWire {
|
||||
pub run_id: String,
|
||||
pub workflow_id: String,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct WorkflowTriggerAck {
|
||||
run_id: String,
|
||||
}
|
||||
|
||||
// ── Reads ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
@@ -121,26 +156,16 @@ pub async fn get_workflow(
|
||||
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())
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<WorkflowRunsWire, String> {
|
||||
let workflow_id =
|
||||
uuid::Uuid::parse_str(&workflow_id).map_err(|_| "invalid workflow id".to_string())?;
|
||||
let limit = limit.unwrap_or(20).clamp(1, 100);
|
||||
get_relay_json(
|
||||
&state,
|
||||
&format!("/workflows/{workflow_id}/runs?limit={limit}"),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// ── Writes ───────────────────────────────────────────────────────────────────
|
||||
@@ -242,10 +267,10 @@ pub async fn delete_workflow(
|
||||
pub async fn trigger_workflow(
|
||||
workflow_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Value, String> {
|
||||
) -> Result<WorkflowTriggerWire, 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 }))
|
||||
trigger_wire_from_message(workflow_id, &result.message)
|
||||
}
|
||||
|
||||
// ── Approvals ────────────────────────────────────────────────────────────────
|
||||
@@ -254,15 +279,17 @@ pub async fn trigger_workflow(
|
||||
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())
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<WorkflowApprovalsWire, String> {
|
||||
let workflow_id =
|
||||
uuid::Uuid::parse_str(&workflow_id).map_err(|_| "invalid workflow id".to_string())?;
|
||||
let run_id =
|
||||
uuid::Uuid::parse_str(&run_id).map_err(|_| "invalid workflow run id".to_string())?;
|
||||
get_relay_json(
|
||||
&state,
|
||||
&format!("/workflows/{workflow_id}/runs/{run_id}/approvals"),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -289,6 +316,21 @@ pub async fn deny_approval(
|
||||
|
||||
// ── Helpers (pure, unit-tested in workflows_tests.rs) ─────────────────────────
|
||||
|
||||
fn trigger_wire_from_message(
|
||||
workflow_id: String,
|
||||
message: &str,
|
||||
) -> Result<WorkflowTriggerWire, String> {
|
||||
let ack: WorkflowTriggerAck = parse_command_response(message)?;
|
||||
if ack.run_id.trim().is_empty() {
|
||||
return Err("workflow trigger response contained an empty run_id".to_string());
|
||||
}
|
||||
Ok(WorkflowTriggerWire {
|
||||
run_id: ack.run_id,
|
||||
workflow_id,
|
||||
status: "pending".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
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())
|
||||
|
||||
@@ -189,21 +189,41 @@ fn workflow_wire_serializes_with_snake_case_keys() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runs_and_approvals_serialize_to_bare_empty_array() {
|
||||
// Regression guard for the crash class this fix closed. The frontend
|
||||
// wrappers `getWorkflowRuns` / `getRunApprovals` do `raw.map(...)`, so the
|
||||
// Rust side MUST return a bare JSON array. A wrapped `{ runs: [...] }` /
|
||||
// `{ approvals: [...] }` shape would make `.map()` throw and crash the
|
||||
// detail panel — the same TypeError class as the original page bug.
|
||||
//
|
||||
// The commands take `State<AppState>`, so we can't invoke them directly in
|
||||
// a unit test; instead we pin the exact value they return (`Vec::new()` of
|
||||
// their `Vec<Value>` element type) and assert its serialized shape.
|
||||
let runs: Vec<Value> = Vec::new();
|
||||
let approvals: Vec<Value> = Vec::new();
|
||||
assert_eq!(serde_json::to_string(&runs).expect("serialize runs"), "[]");
|
||||
fn trigger_response_uses_persisted_run_id_contract() {
|
||||
let wire = trigger_wire_from_message(
|
||||
WF.to_string(),
|
||||
"response:{\"run_id\":\"33333333-3333-3333-3333-333333333333\"}",
|
||||
)
|
||||
.expect("parse trigger response");
|
||||
|
||||
assert_eq!(wire.run_id, "33333333-3333-3333-3333-333333333333");
|
||||
assert_eq!(wire.workflow_id, WF);
|
||||
assert_eq!(wire.status, "pending");
|
||||
let value = serde_json::to_value(wire).expect("serialize trigger response");
|
||||
assert!(value.get("event_id").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trigger_response_rejects_missing_or_empty_run_id() {
|
||||
assert!(trigger_wire_from_message(WF.to_string(), "response:{}").is_err());
|
||||
assert!(trigger_wire_from_message(WF.to_string(), "response:{\"run_id\":\" \"}",).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_reads_serialize_to_backend_envelopes() {
|
||||
let runs = WorkflowRunsWire {
|
||||
runs: Vec::new(),
|
||||
next: None,
|
||||
};
|
||||
let approvals = WorkflowApprovalsWire {
|
||||
approvals: Vec::new(),
|
||||
};
|
||||
assert_eq!(
|
||||
serde_json::to_string(&approvals).expect("serialize approvals"),
|
||||
"[]"
|
||||
serde_json::to_value(runs).expect("serialize runs"),
|
||||
serde_json::json!({ "runs": [], "next": null })
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(approvals).expect("serialize approvals"),
|
||||
serde_json::json!({ "approvals": [] })
|
||||
);
|
||||
}
|
||||
|
||||
@@ -532,6 +532,9 @@ pub struct AgentProfileInfo {
|
||||
|
||||
// ── Signed-event submission ─────────────────────────────────────────────────
|
||||
|
||||
mod get;
|
||||
pub use get::get_relay_json;
|
||||
|
||||
mod submit;
|
||||
pub use submit::{
|
||||
submit_event, submit_event_at_with_keys, submit_signed_event_at_with_keys, SubmitEventResponse,
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
use reqwest::Method;
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
use crate::app_state::AppState;
|
||||
|
||||
use super::{
|
||||
build_nip98_auth_header, classify_request_error, parse_json_response,
|
||||
relay_api_base_url_with_override, relay_error_message,
|
||||
};
|
||||
|
||||
/// Execute an authenticated GET against the active relay and decode its JSON body.
|
||||
pub async fn get_relay_json<T: DeserializeOwned>(
|
||||
state: &AppState,
|
||||
path_with_query: &str,
|
||||
) -> Result<T, String> {
|
||||
if !path_with_query.starts_with('/') {
|
||||
return Err("relay GET path must begin with '/'".to_string());
|
||||
}
|
||||
crate::relay_admission::wait_for_rate_limit().await;
|
||||
let url = format!(
|
||||
"{}{}",
|
||||
relay_api_base_url_with_override(state),
|
||||
path_with_query
|
||||
);
|
||||
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
|
||||
}
|
||||
@@ -1,19 +1,10 @@
|
||||
import { Check, X } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { useApprovalMutation } from "@/features/workflows/hooks";
|
||||
import type { WorkflowApproval } from "@/shared/api/types";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
|
||||
type WorkflowApprovalCardProps = {
|
||||
approval: WorkflowApproval;
|
||||
};
|
||||
|
||||
export function WorkflowApprovalCard({ approval }: WorkflowApprovalCardProps) {
|
||||
const [note, setNote] = React.useState("");
|
||||
const approvalMutation = useApprovalMutation();
|
||||
|
||||
const isExpired = new Date(approval.expiresAt) < new Date();
|
||||
|
||||
if (approval.status !== "pending" || isExpired) {
|
||||
@@ -32,48 +23,9 @@ export function WorkflowApprovalCard({ approval }: WorkflowApprovalCardProps) {
|
||||
<p className="mb-2 text-xs text-muted-foreground">
|
||||
Expires: {new Date(approval.expiresAt).toLocaleString()}
|
||||
</p>
|
||||
|
||||
<Textarea
|
||||
aria-label="Approval note"
|
||||
className="mb-2 h-16 resize-none text-xs"
|
||||
onChange={(event) => setNote(event.target.value)}
|
||||
placeholder="Optional note..."
|
||||
value={note}
|
||||
/>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
className="flex-1 bg-green-600 text-white hover:bg-green-700"
|
||||
disabled={approvalMutation.isPending}
|
||||
onClick={() =>
|
||||
approvalMutation.mutate({
|
||||
token: approval.token,
|
||||
action: "grant",
|
||||
note: note || undefined,
|
||||
})
|
||||
}
|
||||
size="sm"
|
||||
>
|
||||
<Check className="mr-1 h-4 w-4" />
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
disabled={approvalMutation.isPending}
|
||||
onClick={() =>
|
||||
approvalMutation.mutate({
|
||||
token: approval.token,
|
||||
action: "deny",
|
||||
note: note || undefined,
|
||||
})
|
||||
}
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
>
|
||||
<X className="mr-1 h-4 w-4" />
|
||||
Deny
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground" role="status">
|
||||
Approval actions are not yet available in Desktop.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,6 +44,16 @@ export function WorkflowDetailPanel({
|
||||
? getWorkflowTriggerSummary(workflow.definition)
|
||||
: null;
|
||||
const workflowStatus = workflow ? getWorkflowDisplayStatus(workflow) : null;
|
||||
const triggerError = errorMessage(
|
||||
triggerMutation.error,
|
||||
"The relay did not create a workflow run.",
|
||||
);
|
||||
const runsError = errorMessage(
|
||||
runsQuery.error,
|
||||
"Run history could not be loaded.",
|
||||
);
|
||||
const selectedRunIsPendingHistory =
|
||||
selectedRunId !== null && !runs.some((run) => run.id === selectedRunId);
|
||||
|
||||
async function handleTrigger() {
|
||||
try {
|
||||
@@ -118,8 +128,14 @@ export function WorkflowDetailPanel({
|
||||
</div>
|
||||
|
||||
{triggerMutation.isError ? (
|
||||
<div className="border-b px-4 py-2 text-xs text-red-400">
|
||||
Failed to trigger workflow
|
||||
<div
|
||||
className="border-b px-4 py-2 text-xs text-destructive"
|
||||
role="alert"
|
||||
>
|
||||
<p className="font-medium">Failed to trigger workflow</p>
|
||||
<p className="mt-1 break-words text-muted-foreground">
|
||||
{triggerError}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -142,7 +158,37 @@ export function WorkflowDetailPanel({
|
||||
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Run History
|
||||
</h4>
|
||||
{runs.length === 0 ? (
|
||||
{runsQuery.isError ? (
|
||||
<div
|
||||
className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive"
|
||||
role="alert"
|
||||
>
|
||||
<p className="font-medium">Failed to load run history</p>
|
||||
<p className="mt-1 break-words">{runsError}</p>
|
||||
</div>
|
||||
) : runsQuery.isLoading ? (
|
||||
<div
|
||||
className="space-y-2"
|
||||
aria-label="Loading run history"
|
||||
role="status"
|
||||
>
|
||||
<Skeleton className="h-16 w-full rounded-xl" />
|
||||
</div>
|
||||
) : selectedRunIsPendingHistory ? (
|
||||
<div
|
||||
className="rounded-lg border border-primary/30 bg-primary/10 px-3 py-2 text-xs"
|
||||
data-testid="workflow-run-created"
|
||||
role="status"
|
||||
>
|
||||
<p className="font-medium">Run created</p>
|
||||
<p className="mt-1 break-all font-mono text-muted-foreground">
|
||||
{selectedRunId}
|
||||
</p>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
Waiting for its persisted trace…
|
||||
</p>
|
||||
</div>
|
||||
) : runs.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No runs yet.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
@@ -152,6 +198,10 @@ export function WorkflowDetailPanel({
|
||||
run.startedAt,
|
||||
run.completedAt,
|
||||
);
|
||||
const failureReason = workflowRunFailureReason(
|
||||
run.errorCode,
|
||||
run.errorMessage,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -205,9 +255,9 @@ export function WorkflowDetailPanel({
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{run.errorMessage ? (
|
||||
{failureReason ? (
|
||||
<p className="mt-2 break-words pl-6 text-xs text-destructive">
|
||||
{run.errorMessage}
|
||||
{failureReason}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -266,6 +316,30 @@ export function WorkflowDetailPanel({
|
||||
);
|
||||
}
|
||||
|
||||
function workflowRunFailureReason(
|
||||
errorCode: string | null,
|
||||
diagnostic: string | null,
|
||||
) {
|
||||
if (diagnostic?.trim()) return diagnostic;
|
||||
if (!errorCode) return null;
|
||||
const knownReasons: Record<string, string> = {
|
||||
approval_denied: "Approval was denied.",
|
||||
approval_expired: "Approval expired before the workflow could continue.",
|
||||
external_outcome_unknown:
|
||||
"The external action may have completed, but its outcome could not be confirmed.",
|
||||
run_interrupted: "The run was interrupted before it could finish.",
|
||||
};
|
||||
return (
|
||||
knownReasons[errorCode] ?? `Run failed (${errorCode.replace(/_/g, " ")}).`
|
||||
);
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown, fallback: string) {
|
||||
return error instanceof Error && error.message.trim().length > 0
|
||||
? error.message
|
||||
: fallback;
|
||||
}
|
||||
|
||||
function formatRunDuration(
|
||||
startedAt: number | null,
|
||||
completedAt: number | null,
|
||||
|
||||
@@ -43,12 +43,23 @@ type RawWorkflowRun = {
|
||||
execution_trace: RawTraceEntry[];
|
||||
started_at: number | null;
|
||||
completed_at: number | null;
|
||||
error_code?: string | null;
|
||||
error_message: string | null;
|
||||
created_at: number;
|
||||
};
|
||||
|
||||
type RawWorkflowRunCursor = {
|
||||
before: string;
|
||||
before_id: string;
|
||||
};
|
||||
|
||||
type RawWorkflowRunsResponse = {
|
||||
runs: RawWorkflowRun[];
|
||||
next: RawWorkflowRunCursor | null;
|
||||
};
|
||||
|
||||
type RawWorkflowApproval = {
|
||||
token: string;
|
||||
approval_ref: string;
|
||||
workflow_id: string;
|
||||
run_id: string;
|
||||
step_id: string;
|
||||
@@ -61,6 +72,10 @@ type RawWorkflowApproval = {
|
||||
created_at: number;
|
||||
};
|
||||
|
||||
type RawWorkflowApprovalsResponse = {
|
||||
approvals: RawWorkflowApproval[];
|
||||
};
|
||||
|
||||
type RawTriggerWorkflowResponse = {
|
||||
run_id: string;
|
||||
workflow_id: string;
|
||||
@@ -116,6 +131,7 @@ function fromRawWorkflowRun(raw: RawWorkflowRun): WorkflowRun {
|
||||
executionTrace: raw.execution_trace.map(fromRawTraceEntry),
|
||||
startedAt: raw.started_at,
|
||||
completedAt: raw.completed_at,
|
||||
errorCode: raw.error_code ?? null,
|
||||
errorMessage: raw.error_message,
|
||||
createdAt: raw.created_at,
|
||||
};
|
||||
@@ -123,7 +139,7 @@ function fromRawWorkflowRun(raw: RawWorkflowRun): WorkflowRun {
|
||||
|
||||
export function fromRawApproval(raw: RawWorkflowApproval): WorkflowApproval {
|
||||
return {
|
||||
token: raw.token,
|
||||
approvalRef: raw.approval_ref,
|
||||
workflowId: raw.workflow_id,
|
||||
runId: raw.run_id,
|
||||
stepId: raw.step_id,
|
||||
@@ -220,22 +236,25 @@ export async function getWorkflowRuns(
|
||||
workflowId: string,
|
||||
limit?: number,
|
||||
): Promise<WorkflowRun[]> {
|
||||
const raw = await invokeTauri<RawWorkflowRun[]>("get_workflow_runs", {
|
||||
const raw = await invokeTauri<RawWorkflowRunsResponse>("get_workflow_runs", {
|
||||
workflowId,
|
||||
limit: limit ?? null,
|
||||
});
|
||||
return raw.map(fromRawWorkflowRun);
|
||||
return raw.runs.map(fromRawWorkflowRun);
|
||||
}
|
||||
|
||||
export async function getRunApprovals(
|
||||
workflowId: string,
|
||||
runId: string,
|
||||
): Promise<WorkflowApproval[]> {
|
||||
const raw = await invokeTauri<RawWorkflowApproval[]>("get_run_approvals", {
|
||||
workflowId,
|
||||
runId,
|
||||
});
|
||||
return raw.map(fromRawApproval);
|
||||
const raw = await invokeTauri<RawWorkflowApprovalsResponse>(
|
||||
"get_run_approvals",
|
||||
{
|
||||
workflowId,
|
||||
runId,
|
||||
},
|
||||
);
|
||||
return raw.approvals.map(fromRawApproval);
|
||||
}
|
||||
|
||||
export async function triggerWorkflow(
|
||||
|
||||
@@ -41,6 +41,7 @@ export type WorkflowRun = {
|
||||
executionTrace: TraceEntry[];
|
||||
startedAt: number | null;
|
||||
completedAt: number | null;
|
||||
errorCode: string | null;
|
||||
errorMessage: string | null;
|
||||
createdAt: number;
|
||||
};
|
||||
@@ -52,7 +53,8 @@ export type WorkflowApprovalStatus =
|
||||
| "expired";
|
||||
|
||||
export type WorkflowApproval = {
|
||||
token: string;
|
||||
/** Opaque, non-actionable identifier for display/correlation only. */
|
||||
approvalRef: string;
|
||||
workflowId: string;
|
||||
runId: string;
|
||||
stepId: string;
|
||||
|
||||
@@ -3376,6 +3376,7 @@ type RawWorkflowRun = {
|
||||
execution_trace: RawWorkflowTraceEntry[];
|
||||
started_at: number | null;
|
||||
completed_at: number | null;
|
||||
error_code: string | null;
|
||||
error_message: string | null;
|
||||
created_at: number;
|
||||
};
|
||||
@@ -3535,6 +3536,7 @@ function buildMockWorkflowRun(workflow: MockWorkflow): RawWorkflowRun {
|
||||
execution_trace: executionTrace,
|
||||
started_at: startedAt,
|
||||
completed_at: completedAt,
|
||||
error_code: null,
|
||||
error_message: null,
|
||||
created_at: createdAt,
|
||||
};
|
||||
@@ -3559,11 +3561,14 @@ function handleGetWorkflowRuns(args: {
|
||||
const runs = mockWorkflowRuns.filter(
|
||||
(run) => run.workflow_id === args.workflowId,
|
||||
);
|
||||
return args.limit ? runs.slice(0, args.limit) : runs;
|
||||
return {
|
||||
runs: args.limit ? runs.slice(0, args.limit) : runs,
|
||||
next: null,
|
||||
};
|
||||
}
|
||||
|
||||
function handleGetRunApprovals(_args: { workflowId: string; runId: string }) {
|
||||
return [];
|
||||
return { approvals: [] };
|
||||
}
|
||||
|
||||
const mockProfiles = new Map<string, RawProfile>([
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Stable workflow failure classification, kept separate from redacted human diagnostics.
|
||||
-- TEXT is intentional: new codes remain additive across rolling upgrades.
|
||||
SET LOCAL lock_timeout = '5s';
|
||||
|
||||
ALTER TABLE workflow_runs ADD COLUMN error_code TEXT;
|
||||
|
||||
UPDATE workflow_runs
|
||||
SET error_code = 'legacy_unclassified'
|
||||
WHERE status IN ('failed', 'cancelled')
|
||||
AND error_code IS NULL;
|
||||
@@ -396,6 +396,7 @@ CREATE TABLE workflow_runs (
|
||||
started_at TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ,
|
||||
error_message TEXT,
|
||||
error_code TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (community_id, id),
|
||||
FOREIGN KEY (community_id, workflow_id)
|
||||
|
||||
Reference in New Issue
Block a user