From 5d8c3e66945d7d9a35e58b99ea40daf194197f5a Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Fri, 31 Jul 2026 11:56:04 +0100 Subject: [PATCH 1/2] feat(workflows): expose durable run history [issue:#2980] Signed-off-by: Tom Ballard --- crates/buzz-cli/TESTING.md | 3 +- crates/buzz-cli/src/commands/workflows.rs | 36 ++---- crates/buzz-relay/src/api/bridge.rs | 130 ++++++++++++++++++++ crates/buzz-relay/src/router.rs | 1 + desktop/src-tauri/src/commands/workflows.rs | 30 ++--- desktop/src-tauri/src/relay.rs | 27 ++++ desktop/src/shared/api/tauriWorkflows.ts | 2 + desktop/src/shared/api/workflowTypes.ts | 1 + desktop/src/testing/e2eBridge.ts | 2 + 9 files changed, 183 insertions(+), 49 deletions(-) diff --git a/crates/buzz-cli/TESTING.md b/crates/buzz-cli/TESTING.md index 77234b7faab..af06888e5b3 100644 --- a/crates/buzz-cli/TESTING.md +++ b/crates/buzz-cli/TESTING.md @@ -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. diff --git a/crates/buzz-cli/src/commands/workflows.rs b/crates/buzz-cli/src/commands/workflows.rs index 2786d2c5088..d932cfa62e9 100644 --- a/crates/buzz-cli/src/commands/workflows.rs +++ b/crates/buzz-cli/src/commands/workflows.rs @@ -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, ) -> 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::from_str(&resp).unwrap_or_default(); - let normalized: Vec = 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::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(()) } diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index a118ff453fd..faaf6efdb39 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -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, +} + +/// 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>, + Path(id_str): Path, + Query(query): Query, + headers: HeaderMap, +) -> Result, (StatusCode, Json)> { + 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. @@ -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!( diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 400ed1dfe34..00464e23d18 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -72,6 +72,7 @@ pub fn build_router(state: Arc) -> 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), diff --git a/desktop/src-tauri/src/commands/workflows.rs b/desktop/src-tauri/src/commands/workflows.rs index 1d5f309fb5c..15d75f11a41 100644 --- a/desktop/src-tauri/src/commands/workflows.rs +++ b/desktop/src-tauri/src/commands/workflows.rs @@ -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) ────────────────── @@ -121,26 +121,16 @@ pub async fn get_workflow( pub async fn get_workflow_runs( workflow_id: String, limit: Option, - _state: State<'_, AppState>, + state: State<'_, AppState>, ) -> Result, 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 ─────────────────────────────────────────────────────────────────── diff --git a/desktop/src-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs index 71aa21c4133..18b791fbcc2 100644 --- a/desktop/src-tauri/src/relay.rs +++ b/desktop/src-tauri/src/relay.rs @@ -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( + state: &AppState, + path: &str, +) -> Result { + 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). diff --git a/desktop/src/shared/api/tauriWorkflows.ts b/desktop/src/shared/api/tauriWorkflows.ts index 2fbf4be0443..fbcd7729ec0 100644 --- a/desktop/src/shared/api/tauriWorkflows.ts +++ b/desktop/src/shared/api/tauriWorkflows.ts @@ -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[]; @@ -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), diff --git a/desktop/src/shared/api/workflowTypes.ts b/desktop/src/shared/api/workflowTypes.ts index 7f4a312acc7..273bc2eab55 100644 --- a/desktop/src/shared/api/workflowTypes.ts +++ b/desktop/src/shared/api/workflowTypes.ts @@ -36,6 +36,7 @@ export type TraceEntry = { export type WorkflowRun = { id: string; workflowId: string; + triggerEventId: string | null; status: WorkflowRunStatus; currentStep: number | null; executionTrace: TraceEntry[]; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 7566c82370a..46ea83eac9e 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -2994,6 +2994,7 @@ type RawWorkflowTraceEntry = { type RawWorkflowRun = { id: string; workflow_id: string; + trigger_event_id?: string | null; status: | "pending" | "running" @@ -3159,6 +3160,7 @@ function buildMockWorkflowRun(workflow: MockWorkflow): RawWorkflowRun { return { id: `mock-run-${Date.now()}`, workflow_id: workflow.id, + trigger_event_id: null, status: "completed", current_step: null, execution_trace: executionTrace, From 6fcf393573438141e240666d2c4244be5293c7ef Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Fri, 31 Jul 2026 14:06:08 +0100 Subject: [PATCH 2/2] fix(workflows): surface terminal run status badges [issue:#2980] Keep failed and cancelled statuses visible in collapsed run-history rows and cover both states with focused Desktop E2E evidence. Signed-off-by: Tom Ballard --- desktop/playwright.config.ts | 1 + .../workflows/ui/WorkflowDetailPanel.tsx | 14 +++-- desktop/src/testing/e2eBridge.ts | 19 +++++-- .../e2e/workflow-run-status-badges.spec.ts | 51 +++++++++++++++++++ desktop/tests/helpers/bridge.ts | 8 ++- 5 files changed, 83 insertions(+), 10 deletions(-) create mode 100644 desktop/tests/e2e/workflow-run-status-badges.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index bba9218a1a4..8438dfc0482 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -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", diff --git a/desktop/src/features/workflows/ui/WorkflowDetailPanel.tsx b/desktop/src/features/workflows/ui/WorkflowDetailPanel.tsx index 152bf99203a..b33fc820f4b 100644 --- a/desktop/src/features/workflows/ui/WorkflowDetailPanel.tsx +++ b/desktop/src/features/workflows/ui/WorkflowDetailPanel.tsx @@ -184,7 +184,10 @@ export function WorkflowDetailPanel({ {run.id.slice(0, 8)} - +
@@ -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) { const variants: Record = { active: "success", disabled: "secondary", @@ -289,12 +295,12 @@ function RunStatusBadge({ status }: { status: string }) { failed: "destructive", running: "info", pending: "secondary", - cancelled: "secondary", + cancelled: "warning", waiting_approval: "warning", }; return ( - + {formatStatusLabel(status)} ); diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 46ea83eac9e..9decf05527f 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -13,7 +13,11 @@ import { import { relayClient } from "@/shared/api/relayClient"; import { activateRateLimit } from "@/shared/api/relayRateLimitGate"; import type { ConnectionState } from "@/shared/api/relayClientShared"; -import type { ChannelTemplate, RelayEvent } from "@/shared/api/types"; +import type { + ChannelTemplate, + RelayEvent, + WorkflowRunStatus, +} from "@/shared/api/types"; import { getMarkdownParseCount } from "@/shared/ui/markdown/nodeCache"; import { syncAgentTurnsFromEvents } from "@/features/agents/activeAgentTurnsStore"; import { recordTimeoutFromRejection } from "@/features/moderation/lib/timeoutStore"; @@ -259,6 +263,8 @@ type E2eConfig = { channelsReadDelayMs?: number; /** Number of seeded rows in the deep-history fixture. Defaults to 600. */ deepHistoryMessageCount?: number; + /** Statuses returned by successive mocked workflow triggers. */ + workflowRunStatuses?: WorkflowRunStatus[]; feedReadError?: string; canvasReadError?: string; /** Delay (ms) for `apply_workspace` so e2e tests can observe the @@ -3013,11 +3019,13 @@ type RawWorkflowRun = { const mockWorkflows: MockWorkflow[] = []; let mockWorkflowRuns: RawWorkflowRun[] = []; let mockWorkflowIdCounter = 0; +let mockWorkflowRunCounter = 0; function resetMockWorkflows() { mockWorkflows.length = 0; mockWorkflowRuns = []; mockWorkflowIdCounter = 0; + mockWorkflowRunCounter = 0; } function parseWorkflowDefinition( @@ -3112,6 +3120,7 @@ function handleDeleteWorkflow(args: { workflowId: string }) { function buildMockWorkflowRun(workflow: MockWorkflow): RawWorkflowRun { const createdAt = Math.floor(Date.now() / 1000); + const status = getConfig()?.mock?.workflowRunStatuses?.shift() ?? "completed"; const rawSteps = Array.isArray(workflow.definition.steps) ? workflow.definition.steps : []; @@ -3139,7 +3148,7 @@ function buildMockWorkflowRun(workflow: MockWorkflow): RawWorkflowRun { typeof step.id === "string" && step.id.trim().length > 0 ? step.id : `step_${index + 1}`, - status: "completed", + status: status === "failed" ? "failed" : "completed", output, started_at: startedAt, completed_at: completedAt, @@ -3158,15 +3167,15 @@ function buildMockWorkflowRun(workflow: MockWorkflow): RawWorkflowRun { : createdAt; return { - id: `mock-run-${Date.now()}`, + id: `mock-run-${++mockWorkflowRunCounter}`, workflow_id: workflow.id, trigger_event_id: null, - status: "completed", + status, current_step: null, execution_trace: executionTrace, started_at: startedAt, completed_at: completedAt, - error_message: null, + error_message: status === "failed" ? "Mock workflow run failed." : null, created_at: createdAt, }; } diff --git a/desktop/tests/e2e/workflow-run-status-badges.spec.ts b/desktop/tests/e2e/workflow-run-status-badges.spec.ts new file mode 100644 index 00000000000..a1180ebd09e --- /dev/null +++ b/desktop/tests/e2e/workflow-run-status-badges.spec.ts @@ -0,0 +1,51 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; +import { waitForAnimations } from "../helpers/animations"; + +test.beforeEach(async ({ page }) => { + await installMockBridge(page, { + workflowRunStatuses: ["failed", "cancelled"], + }); +}); + +test("shows failed and cancelled run badges while history rows are collapsed", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("open-workflows-view").click(); + await expect(page.getByTestId("workflows-view")).toBeVisible(); + + await page.getByRole("button", { name: "Create Workflow" }).click(); + const dialog = page.getByRole("dialog"); + await dialog.getByLabel("Workflow name").fill("status_badges"); + await dialog.getByRole("button", { name: "Add step" }).click(); + await dialog.getByRole("button", { name: "Create" }).click(); + + await page.getByRole("button", { name: "View status_badges" }).click(); + const panel = page.getByTestId("workflow-detail-panel"); + await expect(panel).toBeVisible(); + + for (const status of ["failed", "cancelled"] as const) { + await panel.getByRole("button", { name: "Trigger" }).click(); + await expect( + panel.getByTestId(`workflow-run-status-${status}`), + ).toBeVisible(); + await panel.getByTestId("workflow-selected-run").click(); + } + + await expect(panel.getByTestId("workflow-run-status-failed")).toHaveText( + "failed", + ); + await expect(panel.getByTestId("workflow-run-status-cancelled")).toHaveText( + "cancelled", + ); + await expect(panel.getByTestId("workflow-run-trace")).not.toBeVisible(); + + if (process.env.BUZZ_WORKFLOW_STATUS_SCREENSHOTS === "1") { + await waitForAnimations(page); + await panel.screenshot({ + path: "test-results/workflow-run-status-badges.png", + }); + } +}); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index c3473ae4f1b..657fc92b302 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -1,5 +1,9 @@ import type { Page } from "@playwright/test"; -import type { ChannelTemplate, RelayEvent } from "../../src/shared/api/types"; +import type { + ChannelTemplate, + RelayEvent, + WorkflowRunStatus, +} from "../../src/shared/api/types"; import { FEATURE_OVERRIDES_STORAGE_KEY, PREVIEW_FEATURE_IDS } from "./features"; export const TEST_IDENTITIES = { @@ -246,6 +250,8 @@ type MockBridgeOptions = { joinChannelErrors?: string[]; /** Number of seeded rows in the deep-history fixture. Defaults to 600. */ deepHistoryMessageCount?: number; + /** Statuses returned by successive mocked workflow triggers. */ + workflowRunStatuses?: WorkflowRunStatus[]; feedReadError?: string; canvasReadError?: string; /** Delay (ms) for `apply_workspace`; see e2eBridge mock config. */