feat: add Workflows UI surface (#217)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wes
2026-04-03 11:54:29 -07:00
committed by GitHub
parent a4d12a4209
commit ddd54a6e84
30 changed files with 1873 additions and 137 deletions

View File

@@ -1126,6 +1126,23 @@ impl Db {
workflow::get_approval(&self.pool, token).await
}
/// Fetch an approval by its already-hashed token (no re-hashing).
pub async fn get_approval_by_stored_hash(
&self,
token_hash: &[u8],
) -> Result<workflow::ApprovalRecord> {
workflow::get_approval_by_stored_hash(&self.pool, token_hash).await
}
/// Fetch all approvals for a workflow run.
pub async fn get_run_approvals(
&self,
workflow_id: uuid::Uuid,
run_id: uuid::Uuid,
) -> Result<Vec<workflow::ApprovalRecord>> {
workflow::get_run_approvals(&self.pool, workflow_id, run_id).await
}
/// Update an approval's status.
pub async fn update_approval(
&self,
@@ -1137,6 +1154,24 @@ impl Db {
workflow::update_approval(&self.pool, token, status, approver_pubkey, note).await
}
/// Update an approval by its already-hashed token (no re-hashing).
pub async fn update_approval_by_stored_hash(
&self,
token_hash: &[u8],
status: workflow::ApprovalStatus,
approver_pubkey: Option<&[u8]>,
note: Option<&str>,
) -> Result<bool> {
workflow::update_approval_by_stored_hash(
&self.pool,
token_hash,
status,
approver_pubkey,
note,
)
.await
}
// ── Partitions ──────────────────────────────────────────────────────────
/// Ensures monthly partitions exist for the next N months.

View File

@@ -214,8 +214,8 @@ pub struct WorkflowRunRecord {
/// A pending or resolved approval gate for a workflow step.
#[derive(Debug, Clone)]
pub struct ApprovalRecord {
/// Unique approval token (hashed before storage).
pub token: String,
/// Token hash as stored in the DB (BYTEA).
pub token: Vec<u8>,
/// The workflow this approval belongs to.
pub workflow_id: Uuid,
/// The run waiting on this approval.
@@ -644,7 +644,17 @@ pub async fn create_approval(pool: &PgPool, params: CreateApprovalParams<'_>) ->
/// sent to the database layer.
pub async fn get_approval(pool: &PgPool, token: &str) -> Result<ApprovalRecord> {
let token_hash = hash_approval_token(token);
get_approval_by_stored_hash(pool, &token_hash).await
}
/// Fetch an approval record by its already-hashed token value.
///
/// Use this when you already have the hash stored in the DB (e.g., from
/// `get_run_approvals`). The `token_hash` is used directly without re-hashing.
pub async fn get_approval_by_stored_hash(
pool: &PgPool,
token_hash: &[u8],
) -> Result<ApprovalRecord> {
let row = sqlx::query(
r#"
SELECT token, workflow_id, run_id, step_id, step_index, approver_spec,
@@ -661,6 +671,29 @@ pub async fn get_approval(pool: &PgPool, token: &str) -> Result<ApprovalRecord>
row_to_approval_record(row)
}
/// Fetch all approval records for a given workflow run.
pub async fn get_run_approvals(
pool: &PgPool,
workflow_id: Uuid,
run_id: Uuid,
) -> Result<Vec<ApprovalRecord>> {
let rows = sqlx::query(
r#"
SELECT token, workflow_id, run_id, step_id, step_index, approver_spec,
status::text AS status, approver_pubkey, note, expires_at, created_at
FROM workflow_approvals
WHERE run_id = $1 AND workflow_id = $2
ORDER BY step_index, created_at
"#,
)
.bind(run_id)
.bind(workflow_id)
.fetch_all(pool)
.await?;
rows.into_iter().map(row_to_approval_record).collect()
}
/// Update an approval's status, approver pubkey, and optional note.
/// Also stamps `granted_at` or `denied_at` based on the new status.
///
@@ -680,6 +713,22 @@ pub async fn update_approval(
note: Option<&str>,
) -> Result<bool> {
let token_hash = hash_approval_token(token);
update_approval_by_stored_hash(pool, &token_hash, status, approver_pubkey, note).await
}
/// Update an approval by its already-hashed token value.
///
/// Use this when you already have the hash stored in the DB (e.g., from
/// `get_run_approvals`). The `token_hash` is used directly without re-hashing.
///
/// See [`update_approval`] for TOCTOU safety notes.
pub async fn update_approval_by_stored_hash(
pool: &PgPool,
token_hash: &[u8],
status: ApprovalStatus,
approver_pubkey: Option<&[u8]>,
note: Option<&str>,
) -> Result<bool> {
let status_str = status.to_string();
let affected = sqlx::query(
r#"
@@ -1134,7 +1183,7 @@ mod tests {
let now = Utc::now();
let record = ApprovalRecord {
token: "abc123def456abc123def456abc123de".to_owned(),
token: b"abc123def456abc123def456abc123de".to_vec(),
workflow_id,
run_id,
step_id: "request_approval".to_owned(),
@@ -1147,7 +1196,7 @@ mod tests {
created_at: now,
};
assert_eq!(record.token, "abc123def456abc123def456abc123de");
assert_eq!(record.token, b"abc123def456abc123def456abc123de");
assert_eq!(record.workflow_id, workflow_id);
assert_eq!(record.run_id, run_id);
assert_eq!(record.step_id, "request_approval");
@@ -1164,7 +1213,7 @@ mod tests {
let approver_pubkey = vec![0xca; 32];
let record = ApprovalRecord {
token: "token-granted".to_owned(),
token: b"token-granted".to_vec(),
workflow_id: Uuid::new_v4(),
run_id: Uuid::new_v4(),
step_id: "gate".to_owned(),
@@ -1187,7 +1236,7 @@ mod tests {
let now = Utc::now();
let record = ApprovalRecord {
token: "token-denied".to_owned(),
token: b"token-denied".to_vec(),
workflow_id: Uuid::new_v4(),
run_id: Uuid::new_v4(),
step_id: "gate".to_owned(),
@@ -1208,7 +1257,7 @@ mod tests {
fn approval_record_clone_is_independent() {
let now = Utc::now();
let record = ApprovalRecord {
token: "original-token".to_owned(),
token: b"original-token".to_vec(),
workflow_id: Uuid::new_v4(),
run_id: Uuid::new_v4(),
step_id: "gate".to_owned(),

View File

@@ -167,49 +167,41 @@ async fn resume_workflow_after_approval(
engine.finalize_run(run_id, result, existing_trace).await;
}
// ── POST /api/approvals/:token/grant ─────────────────────────────────────────
/// Grant a pending approval and resume the suspended workflow run.
///
/// Uses `AND status = 'pending'` in the DB update to prevent TOCTOU races.
pub async fn grant_approval(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(token): Path<String>,
body: Option<Json<ApprovalBody>>,
) -> Result<(StatusCode, Json<serde_json::Value>), (StatusCode, Json<serde_json::Value>)> {
let ctx = extract_auth_context(&headers, &state).await?;
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsWrite)
.map_err(scope_error)?;
let pubkey_bytes = ctx.pubkey_bytes.clone();
let approval = state
.db
.get_approval(&token)
.await
.map_err(|_| not_found("approval not found"))?;
// ── Shared approval validation ───────────────────────────────────────────────
/// Validate that an approval is pending and not expired, and that the requester
/// is allowed by the approver spec.
fn validate_approval(
approval: &sprout_db::workflow::ApprovalRecord,
requester_hex: &str,
) -> Result<(), (StatusCode, Json<serde_json::Value>)> {
if approval.status != sprout_db::workflow::ApprovalStatus::Pending {
return Err(api_error(
StatusCode::CONFLICT,
&format!("approval already {}", approval.status),
));
}
if Utc::now() > approval.expires_at {
return Err(api_error(StatusCode::GONE, "approval token has expired"));
}
check_approver_spec(&approval.approver_spec, requester_hex)?;
Ok(())
}
check_approver_spec(&approval.approver_spec, &ctx.pubkey.to_hex())?;
let note = body.as_ref().and_then(|b| b.note.as_deref());
/// Execute the grant: update DB, spawn workflow resume, return response.
async fn execute_grant(
state: &Arc<AppState>,
approval: &sprout_db::workflow::ApprovalRecord,
token_hash: &[u8],
pubkey_bytes: &[u8],
note: Option<&str>,
) -> Result<(StatusCode, Json<serde_json::Value>), (StatusCode, Json<serde_json::Value>)> {
let updated = state
.db
.update_approval(
&token,
.update_approval_by_stored_hash(
token_hash,
sprout_db::workflow::ApprovalStatus::Granted,
Some(&pubkey_bytes),
Some(pubkey_bytes),
note,
)
.await
@@ -219,11 +211,9 @@ pub async fn grant_approval(
return Err(api_error(StatusCode::CONFLICT, "approval already acted on"));
}
// Resume workflow execution from the step after the approval gate.
let run_id = approval.run_id;
let workflow_id = approval.workflow_id;
let resume_index = approval.step_index as usize + 1;
let engine = Arc::clone(&state.workflow_engine);
let db = state.db.clone();
@@ -234,7 +224,6 @@ pub async fn grant_approval(
Ok((
StatusCode::ACCEPTED,
Json(serde_json::json!({
"token": token,
"status": "granted",
"run_id": approval.run_id.to_string(),
"workflow_id": approval.workflow_id.to_string(),
@@ -242,49 +231,21 @@ pub async fn grant_approval(
))
}
// ── POST /api/approvals/:token/deny ──────────────────────────────────────────
/// Deny a pending approval and cancel the suspended workflow run.
///
/// Uses `AND status = 'pending'` in the DB update to prevent TOCTOU races.
pub async fn deny_approval(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(token): Path<String>,
body: Option<Json<ApprovalBody>>,
/// Execute the deny: update DB, spawn workflow cancellation, return response.
async fn execute_deny(
state: &Arc<AppState>,
approval: &sprout_db::workflow::ApprovalRecord,
token_hash: &[u8],
pubkey_bytes: &[u8],
pubkey_hex: String,
note: Option<&str>,
) -> Result<(StatusCode, Json<serde_json::Value>), (StatusCode, Json<serde_json::Value>)> {
let ctx = extract_auth_context(&headers, &state).await?;
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsWrite)
.map_err(scope_error)?;
let pubkey_bytes = ctx.pubkey_bytes.clone();
let approval = state
.db
.get_approval(&token)
.await
.map_err(|_| not_found("approval not found"))?;
if approval.status != sprout_db::workflow::ApprovalStatus::Pending {
return Err(api_error(
StatusCode::CONFLICT,
&format!("approval already {}", approval.status),
));
}
if Utc::now() > approval.expires_at {
return Err(api_error(StatusCode::GONE, "approval token has expired"));
}
check_approver_spec(&approval.approver_spec, &ctx.pubkey.to_hex())?;
let note = body.as_ref().and_then(|b| b.note.as_deref());
let updated = state
.db
.update_approval(
&token,
.update_approval_by_stored_hash(
token_hash,
sprout_db::workflow::ApprovalStatus::Denied,
Some(&pubkey_bytes),
Some(pubkey_bytes),
note,
)
.await
@@ -294,11 +255,7 @@ pub async fn deny_approval(
return Err(api_error(StatusCode::CONFLICT, "approval already acted on"));
}
// Mark the workflow run as Cancelled — only if it's still WaitingApproval.
// A run that has already transitioned to Failed/Completed/Cancelled through
// another path (e.g., timeout, manual cancel) must not be overwritten.
let run_id = approval.run_id;
let pubkey_for_msg = ctx.pubkey.to_hex();
let db = state.db.clone();
tokio::spawn(async move {
let run = match db.get_workflow_run(run_id).await {
@@ -317,7 +274,7 @@ pub async fn deny_approval(
return;
}
let cancel_msg = format!("workflow cancelled: approval denied by {pubkey_for_msg}");
let cancel_msg = format!("workflow cancelled: approval denied by {pubkey_hex}");
if let Err(e) = db
.update_workflow_run(
run_id,
@@ -335,7 +292,6 @@ pub async fn deny_approval(
Ok((
StatusCode::ACCEPTED,
Json(serde_json::json!({
"token": token,
"status": "denied",
"run_id": approval.run_id.to_string(),
"workflow_id": approval.workflow_id.to_string(),
@@ -343,6 +299,141 @@ pub async fn deny_approval(
))
}
// ── POST /api/approvals/:token/grant ─────────────────────────────────────────
/// Grant a pending approval using the raw (plaintext) token.
///
/// Uses `AND status = 'pending'` in the DB update to prevent TOCTOU races.
pub async fn grant_approval(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(token): Path<String>,
body: Option<Json<ApprovalBody>>,
) -> Result<(StatusCode, Json<serde_json::Value>), (StatusCode, Json<serde_json::Value>)> {
let ctx = extract_auth_context(&headers, &state).await?;
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsWrite)
.map_err(scope_error)?;
let approval = state
.db
.get_approval(&token)
.await
.map_err(|_| not_found("approval not found"))?;
validate_approval(&approval, &ctx.pubkey.to_hex())?;
let note = body.as_ref().and_then(|b| b.note.as_deref());
execute_grant(
&state,
&approval,
&approval.token.clone(),
&ctx.pubkey_bytes,
note,
)
.await
}
// ── POST /api/approvals/:token/deny ──────────────────────────────────────────
/// Deny a pending approval using the raw (plaintext) token.
///
/// Uses `AND status = 'pending'` in the DB update to prevent TOCTOU races.
pub async fn deny_approval(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(token): Path<String>,
body: Option<Json<ApprovalBody>>,
) -> Result<(StatusCode, Json<serde_json::Value>), (StatusCode, Json<serde_json::Value>)> {
let ctx = extract_auth_context(&headers, &state).await?;
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsWrite)
.map_err(scope_error)?;
let approval = state
.db
.get_approval(&token)
.await
.map_err(|_| not_found("approval not found"))?;
validate_approval(&approval, &ctx.pubkey.to_hex())?;
let note = body.as_ref().and_then(|b| b.note.as_deref());
execute_deny(
&state,
&approval,
&approval.token.clone(),
&ctx.pubkey_bytes,
ctx.pubkey.to_hex(),
note,
)
.await
}
// ── POST /api/approvals/by-hash/:hash/grant ─────────────────────────────────
/// Grant a pending approval using the DB-stored token hash.
///
/// This endpoint is used by the UI, which receives the hash from the
/// run-approvals listing. The hash is used directly without re-hashing.
pub async fn grant_approval_by_hash(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(hash_hex): Path<String>,
body: Option<Json<ApprovalBody>>,
) -> Result<(StatusCode, Json<serde_json::Value>), (StatusCode, Json<serde_json::Value>)> {
let ctx = extract_auth_context(&headers, &state).await?;
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsWrite)
.map_err(scope_error)?;
let token_hash = hex::decode(&hash_hex)
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid hex token hash"))?;
let approval = state
.db
.get_approval_by_stored_hash(&token_hash)
.await
.map_err(|_| not_found("approval not found"))?;
validate_approval(&approval, &ctx.pubkey.to_hex())?;
let note = body.as_ref().and_then(|b| b.note.as_deref());
execute_grant(&state, &approval, &token_hash, &ctx.pubkey_bytes, note).await
}
// ── POST /api/approvals/by-hash/:hash/deny ──────────────────────────────────
/// Deny a pending approval using the DB-stored token hash.
///
/// This endpoint is used by the UI, which receives the hash from the
/// run-approvals listing. The hash is used directly without re-hashing.
pub async fn deny_approval_by_hash(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(hash_hex): Path<String>,
body: Option<Json<ApprovalBody>>,
) -> Result<(StatusCode, Json<serde_json::Value>), (StatusCode, Json<serde_json::Value>)> {
let ctx = extract_auth_context(&headers, &state).await?;
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsWrite)
.map_err(scope_error)?;
let token_hash = hex::decode(&hash_hex)
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid hex token hash"))?;
let approval = state
.db
.get_approval_by_stored_hash(&token_hash)
.await
.map_err(|_| not_found("approval not found"))?;
validate_approval(&approval, &ctx.pubkey.to_hex())?;
let note = body.as_ref().and_then(|b| b.note.as_deref());
execute_deny(
&state,
&approval,
&token_hash,
&ctx.pubkey_bytes,
ctx.pubkey.to_hex(),
note,
)
.await
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]

View File

@@ -51,7 +51,7 @@ pub mod workflows;
// Re-export all public handlers so router.rs can use `api::*_handler` unchanged.
pub use agents::agents_handler;
pub use approvals::{deny_approval, grant_approval};
pub use approvals::{deny_approval, deny_approval_by_hash, grant_approval, grant_approval_by_hash};
pub use canvas::get_canvas;
pub use channels::channels_handler;
pub use channels_metadata::get_channel_handler;
@@ -67,8 +67,8 @@ pub use users::{
get_profile, get_user_profile, get_users_batch, put_channel_add_policy, search_users,
};
pub use workflows::{
create_workflow, delete_workflow, get_workflow, list_channel_workflows, list_workflow_runs,
trigger_workflow, update_workflow, workflow_webhook,
create_workflow, delete_workflow, get_workflow, list_channel_workflows, list_run_approvals,
list_workflow_runs, trigger_workflow, update_workflow, workflow_webhook,
};
// ── Shared helpers ────────────────────────────────────────────────────────────

View File

@@ -46,6 +46,25 @@ pub(crate) fn run_record_to_json(r: &sprout_db::workflow::WorkflowRunRecord) ->
})
}
/// Serialize an [`ApprovalRecord`] to a JSON value.
pub(crate) fn approval_record_to_json(
a: &sprout_db::workflow::ApprovalRecord,
) -> serde_json::Value {
serde_json::json!({
"token": hex::encode(&a.token),
"workflow_id": a.workflow_id.to_string(),
"run_id": a.run_id.to_string(),
"step_id": a.step_id,
"step_index": a.step_index,
"approver_spec": a.approver_spec,
"status": a.status.to_string(),
"approver_pubkey": a.approver_pubkey.as_ref().map(nostr_hex::encode),
"note": a.note,
"expires_at": a.expires_at.to_rfc3339(),
"created_at": a.created_at.timestamp(),
})
}
// ── SSRF prevention ───────────────────────────────────────────────────────────
/// Validate all CallWebhook URLs in a workflow definition.

View File

@@ -22,8 +22,8 @@ use serde::Deserialize;
use crate::state::AppState;
use super::workflow_helpers::{
definition_hash, ensure_webhook_secret, run_record_to_json, spawn_workflow_execution,
validate_webhook_urls, workflow_record_to_json,
approval_record_to_json, definition_hash, ensure_webhook_secret, run_record_to_json,
spawn_workflow_execution, validate_webhook_urls, workflow_record_to_json,
};
use super::{
api_error, check_channel_access, check_token_channel_access, extract_auth_context, forbidden,
@@ -358,6 +358,47 @@ pub async fn list_workflow_runs(
Ok(Json(serde_json::json!(result)))
}
// ── GET /api/workflows/:id/runs/:run_id/approvals ────────────────────────────
/// List all approval records for a workflow run.
pub async fn list_run_approvals(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path((id_str, run_id_str)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let ctx = extract_auth_context(&headers, &state).await?;
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsRead)
.map_err(scope_error)?;
let pubkey_bytes = ctx.pubkey_bytes.clone();
let id = uuid::Uuid::parse_str(&id_str)
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid workflow UUID"))?;
let run_id = uuid::Uuid::parse_str(&run_id_str)
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid run UUID"))?;
let workflow = state
.db
.get_workflow(id)
.await
.map_err(|_| not_found("workflow not found"))?;
if let Some(channel_id) = workflow.channel_id {
check_token_channel_access(&ctx, &channel_id)?;
check_channel_access(&state, channel_id, &pubkey_bytes).await?;
} else if workflow.owner_pubkey != pubkey_bytes {
return Err(forbidden("not authorized to access this workflow"));
}
let approvals = state
.db
.get_run_approvals(id, run_id)
.await
.map_err(|e| internal_error(&format!("db error: {e}")))?;
let result: Vec<serde_json::Value> = approvals.iter().map(approval_record_to_json).collect();
Ok(Json(serde_json::json!(result)))
}
// ── POST /api/workflows/:id/trigger ──────────────────────────────────────────
/// Manually trigger a workflow. Returns 202 Accepted; execution is async.

View File

@@ -83,10 +83,22 @@ pub fn build_router(state: Arc<AppState>) -> Router {
.delete(api::delete_workflow),
)
.route("/api/workflows/{id}/runs", get(api::list_workflow_runs))
.route(
"/api/workflows/{id}/runs/{run_id}/approvals",
get(api::list_run_approvals),
)
.route("/api/workflows/{id}/trigger", post(api::trigger_workflow))
.route("/api/workflows/{id}/webhook", post(api::workflow_webhook))
.route("/api/approvals/{token}/grant", post(api::grant_approval))
.route("/api/approvals/{token}/deny", post(api::deny_approval))
.route(
"/api/approvals/by-hash/{hash}/grant",
post(api::grant_approval_by_hash),
)
.route(
"/api/approvals/by-hash/{hash}/deny",
post(api::deny_approval_by_hash),
)
// Membership routes
.route("/api/channels/{channel_id}/members", get(api::list_members))
// Channel detail + metadata routes

View File

@@ -32,7 +32,7 @@ const rules = [
const overrides = new Map([
["src-tauri/src/managed_agents/personas.rs", 600], // built-in persona system prompts (Solo, Ralph, Strategist) are long string literals
["src-tauri/src/managed_agents/persona_card.rs", 772], // PNG/ZIP persona card codec + provider/model fields + 27 unit tests (~350 lines of tests); rustfmt adds line breaks around long literals/builders
["src/app/AppShell.tsx", 820], // message edit state + handlers + ChannelPane edit prop threading + scrollback pagination
["src/app/AppShell.tsx", 840], // message edit state + handlers + ChannelPane edit prop threading + scrollback pagination + workflows view
["src/features/channels/hooks.ts", 550], // canvas query + mutation hooks + DM hide mutation
["src/features/channels/ui/ChannelManagementSheet.tsx", 800],
["src/features/messages/hooks.ts", 500], // message query/mutation hooks + optimistic updates
@@ -47,7 +47,7 @@ const overrides = new Map([
["src/features/agents/ui/AgentsView.tsx", 790], // remote agent stop/delete + channel UUID resolution + presence-aware delete guard + persona/team import + provider/model fields
["src/features/agents/ui/CreateAgentDialog.tsx", 685], // provider selector + config form + schema-typed config coercion + required field validation + locked scopes
["src/features/channels/ui/AddChannelBotDialog.tsx", 640], // provider mode: Run on selector, trust warning, probe effect, single-agent enforcement, provider warnings display
["src/shared/api/types.ts", 515], // persona provider/model fields + forum types
["src/shared/api/types.ts", 525], // persona provider/model fields + forum types + workflow type re-exports
]);
async function walkFiles(directory) {

View File

@@ -13,6 +13,7 @@ mod personas;
mod profile;
mod teams;
mod tokens;
mod workflows;
pub use agent_discovery::*;
pub use agent_models::*;
@@ -28,3 +29,4 @@ pub use personas::*;
pub use profile::*;
pub use teams::*;
pub use tokens::*;
pub use workflows::*;

View File

@@ -0,0 +1,143 @@
use reqwest::Method;
use serde::Serialize;
use tauri::State;
use crate::{
app_state::AppState,
relay::{build_authed_request, send_empty_request, send_json_request},
};
// ── Reads ───────────────────────────────────────────────────────────────────
#[tauri::command]
pub async fn get_channel_workflows(
channel_id: String,
state: State<'_, AppState>,
) -> Result<serde_json::Value, String> {
let path = format!("/api/channels/{channel_id}/workflows");
let request = build_authed_request(&state.http_client, Method::GET, &path, &state)?;
send_json_request(request).await
}
#[tauri::command]
pub async fn get_workflow(
workflow_id: String,
state: State<'_, AppState>,
) -> Result<serde_json::Value, String> {
let path = format!("/api/workflows/{workflow_id}");
let request = build_authed_request(&state.http_client, Method::GET, &path, &state)?;
send_json_request(request).await
}
#[tauri::command]
pub async fn get_workflow_runs(
workflow_id: String,
limit: Option<u32>,
state: State<'_, AppState>,
) -> Result<serde_json::Value, String> {
let mut path = format!("/api/workflows/{workflow_id}/runs");
if let Some(limit) = limit {
path.push_str(&format!("?limit={limit}"));
}
let request = build_authed_request(&state.http_client, Method::GET, &path, &state)?;
send_json_request(request).await
}
// ── Writes ──────────────────────────────────────────────────────────────────
#[derive(Serialize)]
struct CreateWorkflowBody {
yaml_definition: String,
}
#[tauri::command]
pub async fn create_workflow(
channel_id: String,
yaml_definition: String,
state: State<'_, AppState>,
) -> Result<serde_json::Value, String> {
let path = format!("/api/channels/{channel_id}/workflows");
let request = build_authed_request(&state.http_client, Method::POST, &path, &state)?
.json(&CreateWorkflowBody { yaml_definition });
send_json_request(request).await
}
#[derive(Serialize)]
struct UpdateWorkflowBody {
yaml_definition: String,
}
#[tauri::command]
pub async fn update_workflow(
workflow_id: String,
yaml_definition: String,
state: State<'_, AppState>,
) -> Result<serde_json::Value, String> {
let path = format!("/api/workflows/{workflow_id}");
let request = build_authed_request(&state.http_client, Method::PUT, &path, &state)?
.json(&UpdateWorkflowBody { yaml_definition });
send_json_request(request).await
}
#[tauri::command]
pub async fn delete_workflow(
workflow_id: String,
state: State<'_, AppState>,
) -> Result<(), String> {
let path = format!("/api/workflows/{workflow_id}");
let request = build_authed_request(&state.http_client, Method::DELETE, &path, &state)?;
send_empty_request(request).await
}
#[tauri::command]
pub async fn trigger_workflow(
workflow_id: String,
state: State<'_, AppState>,
) -> Result<serde_json::Value, String> {
let path = format!("/api/workflows/{workflow_id}/trigger");
let request = build_authed_request(&state.http_client, Method::POST, &path, &state)?;
send_json_request(request).await
}
// ── Approvals ───────────────────────────────────────────────────────────────
#[tauri::command]
pub async fn get_run_approvals(
workflow_id: String,
run_id: String,
state: State<'_, AppState>,
) -> Result<serde_json::Value, String> {
let path = format!("/api/workflows/{workflow_id}/runs/{run_id}/approvals");
let request = build_authed_request(&state.http_client, Method::GET, &path, &state)?;
send_json_request(request).await
}
#[derive(Serialize)]
struct ApprovalBody {
#[serde(skip_serializing_if = "Option::is_none")]
note: Option<String>,
}
#[tauri::command]
pub async fn grant_approval(
token: String,
note: Option<String>,
state: State<'_, AppState>,
) -> Result<serde_json::Value, String> {
let path = format!("/api/approvals/by-hash/{token}/grant");
let request = build_authed_request(&state.http_client, Method::POST, &path, &state)?
.json(&ApprovalBody { note });
send_json_request(request).await
}
#[tauri::command]
pub async fn deny_approval(
token: String,
note: Option<String>,
state: State<'_, AppState>,
) -> Result<serde_json::Value, String> {
let path = format!("/api/approvals/by-hash/{token}/deny");
let request = build_authed_request(&state.http_client, Method::POST, &path, &state)?
.json(&ApprovalBody { note });
send_json_request(request).await
}

View File

@@ -283,6 +283,16 @@ pub fn run() {
parse_team_file,
parse_persona_files,
export_persona_to_json,
get_channel_workflows,
get_workflow,
create_workflow,
update_workflow,
delete_workflow,
get_workflow_runs,
get_run_approvals,
trigger_workflow,
grant_approval,
deny_approval,
])
.build(tauri::generate_context!())
.expect("error while building tauri application");

View File

@@ -6,6 +6,7 @@ import { useActiveChannelHeader } from "@/app/useActiveChannelHeader";
import { useChannelPaneHandlers } from "@/app/useChannelPaneHandlers";
import { AgentsView } from "@/features/agents/ui/AgentsView";
import { ForumView } from "@/features/forum/ui/ForumView";
import { WorkflowsView } from "@/features/workflows/ui/WorkflowsView";
import { ChatHeader } from "@/features/chat/ui/ChatHeader";
import {
channelsQueryKey,
@@ -64,7 +65,7 @@ import {
} from "@/shared/ui/sidebar";
import { useWebviewZoomShortcuts } from "@/app/useWebviewZoomShortcuts";
type AppView = "home" | "channel" | "settings" | "agents";
type AppView = "home" | "channel" | "settings" | "agents" | "workflows";
type MainView = Exclude<AppView, "settings">;
export function AppShell() {
useWebviewZoomShortcuts();
@@ -93,6 +94,11 @@ export function AppShell() {
const [editTargetId, setEditTargetId] = React.useState<string | null>(null);
const lastNonSettingsViewRef = React.useRef<MainView>("home");
const queryClient = useQueryClient();
const selectView = React.useCallback((view: AppView | MainView) => {
React.startTransition(() => {
setSelectedView(view);
});
}, []);
const identityQuery = useIdentityQuery();
const profileQuery = useProfileQuery();
const presenceSession = usePresenceSession(identityQuery.data?.pubkey);
@@ -343,12 +349,10 @@ export function AppShell() {
return;
}
if (selectedChannel?.id === channelId) {
React.startTransition(() => {
setSelectedView("home");
});
selectView("home");
}
},
[hideDmMutation, selectedChannel?.id],
[hideDmMutation, selectView, selectedChannel?.id],
);
const handleOpenSettings = React.useCallback(
@@ -370,10 +374,8 @@ export function AppShell() {
? "home"
: lastNonSettingsViewRef.current;
React.startTransition(() => {
setSelectedView(nextView);
});
}, [selectedChannel]);
selectView(nextView);
}, [selectView, selectedChannel]);
const handleOpenSearchResult = React.useCallback(
(hit: SearchHit) => {
@@ -631,16 +633,10 @@ export function AppShell() {
});
openChannelView(directMessage.id);
}}
onSelectAgents={() => {
React.startTransition(() => {
setSelectedView("agents");
});
}}
onSelectAgents={() => selectView("agents")}
onSelectWorkflows={() => selectView("workflows")}
onSelectHome={() => {
React.startTransition(() => {
setSelectedView("home");
});
selectView("home");
void homeFeedQuery.refetch();
}}
onSelectChannel={handleOpenChannel}
@@ -664,6 +660,12 @@ export function AppShell() {
mode="agents"
title="Agents"
/>
) : selectedView === "workflows" ? (
<ChatHeader
description="Create, manage, and monitor automated workflows across your channels."
mode="workflows"
title="Workflows"
/>
) : (
<ChatHeader
actions={
@@ -728,7 +730,18 @@ export function AppShell() {
</div>
<div
className={
selectedView !== "home" && selectedView !== "agents"
selectedView === "workflows"
? "flex min-h-0 flex-1 flex-col"
: "hidden"
}
>
<WorkflowsView channels={memberChannels} />
</div>
<div
className={
selectedView !== "home" &&
selectedView !== "agents" &&
selectedView !== "workflows"
? "flex min-h-0 flex-1 flex-col overflow-hidden"
: "hidden"
}
@@ -805,10 +818,8 @@ export function AppShell() {
channel={activeChannel}
currentPubkey={identityQuery.data?.pubkey}
onDeleted={() => {
React.startTransition(() => {
setIsChannelManagementOpen(false);
setSelectedView("home");
});
setIsChannelManagementOpen(false);
selectView("home");
}}
onOpenChange={setIsChannelManagementOpen}
open={isChannelManagementOpen && activeChannel !== null}

View File

@@ -437,7 +437,7 @@ export function useManagedAgentLogQuery(
) {
return useQuery({
queryKey: ["managed-agent-log", pubkey, lineCount],
queryFn: () => getManagedAgentLog(pubkey!, lineCount),
queryFn: () => getManagedAgentLog(pubkey as string, lineCount),
enabled: pubkey !== null,
retry: false,
staleTime: 3_000,

View File

@@ -174,8 +174,9 @@ export function CreateAgentDialog({
// Initialize config from schema defaults so unchanged defaults
// are included in the submit payload (not silently dropped).
if (result.config_schema) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const props = (result.config_schema as any)?.properties ?? {};
const props =
(result.config_schema as Record<string, unknown>)?.properties ??
{};
const defaults: Record<string, string> = {};
for (const [key, prop] of Object.entries(props) as [
string,
@@ -291,9 +292,8 @@ export function CreateAgentDialog({
// Check provider config required fields are filled.
const providerConfigComplete = React.useMemo(() => {
if (!isProviderMode || !probedProvider?.config_schema) return true;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const schema = probedProvider.config_schema as any;
const required: string[] = schema?.required ?? [];
const schema = probedProvider.config_schema as Record<string, unknown>;
const required: string[] = (schema?.required as string[] | undefined) ?? [];
return required.every(
(key) => (providerConfig[key] ?? "").trim().length > 0,
);

View File

@@ -8,8 +8,8 @@ export function coerceConfigValues(
schema: Record<string, unknown> | undefined,
): Record<string, unknown> {
if (!schema) return { ...config };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const properties = (schema as any)?.properties ?? {};
const properties = ((schema as Record<string, unknown>)?.properties ??
{}) as Record<string, Record<string, unknown>>;
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(config)) {
const prop = properties[key] as Record<string, unknown> | undefined;
@@ -37,10 +37,10 @@ export function ProviderConfigFields({
config: Record<string, string>;
onChange: (config: Record<string, string>) => void;
}) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const properties = (schema as any)?.properties ?? {};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const required = new Set<string>((schema as any)?.required ?? []);
const properties = (schema as Record<string, unknown>)?.properties ?? {};
const required = new Set<string>(
((schema as Record<string, unknown>)?.required as string[]) ?? [],
);
const entries = Object.entries(properties) as [
string,

View File

@@ -168,9 +168,8 @@ export function AddChannelBotDialog({
);
const providerConfigComplete = React.useMemo(() => {
if (!isProviderMode || !probedProvider?.config_schema) return true;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const schema = probedProvider.config_schema as any;
const required: string[] = schema?.required ?? [];
const schema = probedProvider.config_schema as Record<string, unknown>;
const required: string[] = (schema?.required as string[] | undefined) ?? [];
return required.every(
(key) => (providerConfig[key] ?? "").trim().length > 0,
);
@@ -216,8 +215,10 @@ export function AddChannelBotDialog({
if (!cancelled) {
setProbedProvider(result);
if (result.config_schema) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const props = (result.config_schema as any)?.properties ?? {};
const props =
(result.config_schema as Record<string, unknown>)?.properties ??
{};
const defaults: Record<string, string> = {};
for (const [key, prop] of Object.entries(props) as [
string,

View File

@@ -6,6 +6,7 @@ import {
Home,
Lock,
Settings2,
Zap,
} from "lucide-react";
import type * as React from "react";
@@ -17,7 +18,7 @@ type ChatHeaderProps = {
description: string;
channelType?: ChannelType;
visibility?: ChannelVisibility;
mode?: "home" | "channel" | "settings" | "agents";
mode?: "home" | "channel" | "settings" | "agents" | "workflows";
statusBadge?: React.ReactNode;
};
@@ -28,7 +29,7 @@ function ChannelIcon({
}: {
channelType?: ChannelType;
visibility?: ChannelVisibility;
mode?: "home" | "channel" | "settings" | "agents";
mode?: "home" | "channel" | "settings" | "agents" | "workflows";
}) {
if (mode === "home") {
return <Home className="h-5 w-5 text-primary" />;
@@ -38,6 +39,10 @@ function ChannelIcon({
return <Bot className="h-5 w-5 text-primary" />;
}
if (mode === "workflows") {
return <Zap className="h-5 w-5 text-primary" />;
}
if (mode === "settings") {
return <Settings2 className="h-5 w-5 text-primary" />;
}

View File

@@ -45,7 +45,9 @@ export const ComposerEmojiPicker = React.memo(function ComposerEmojiPicker({
>
<Picker
data={data}
onEmojiSelect={(emoji: any) => onEmojiSelect(emoji.native)}
onEmojiSelect={(emoji: { native: string }) =>
onEmojiSelect(emoji.native)
}
theme="auto"
previewPosition="none"
skinTonePosition="search"

View File

@@ -125,7 +125,7 @@ export function MessageActionBar({
) : null}
<Picker
data={data}
onEmojiSelect={(emoji: any) => {
onEmojiSelect={(emoji: { native: string }) => {
if (!onReactionSelect) {
return;
}

View File

@@ -1,4 +1,4 @@
import { Bot, Home, Lock, PenSquare, Plus, Search } from "lucide-react";
import { Bot, Home, Lock, PenSquare, Plus, Search, Zap } from "lucide-react";
import * as React from "react";
import { useManagedAgentsQuery } from "@/features/agents/hooks";
@@ -61,7 +61,7 @@ type AppSidebarProps = {
selfPresenceStatus: PresenceStatus;
errorMessage?: string;
selectedChannelId: string | null;
selectedView: "home" | "channel" | "settings" | "agents";
selectedView: "home" | "channel" | "settings" | "agents" | "workflows";
unreadChannelIds: Set<string>;
onCreateChannel: (input: {
name: string;
@@ -79,6 +79,7 @@ type AppSidebarProps = {
onHideDm: (channelId: string) => void;
onOpenDm: (input: { pubkeys: string[] }) => Promise<void>;
onSelectAgents: () => void;
onSelectWorkflows: () => void;
onSelectHome: () => void;
onSelectChannel: (channelId: string) => void;
onSelectSettings: () => void;
@@ -443,6 +444,7 @@ export function AppSidebar({
onHideDm,
onOpenDm,
onSelectAgents,
onSelectWorkflows,
onSelectHome,
onSelectChannel,
onSelectSettings,
@@ -539,6 +541,18 @@ export function AppSidebar({
</SidebarMenuBadge>
) : null}
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
data-testid="open-workflows-view"
isActive={selectedView === "workflows"}
onClick={onSelectWorkflows}
tooltip="Workflows"
type="button"
>
<Zap className="h-4 w-4" />
<span>Workflows</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>

View File

@@ -0,0 +1,169 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { WorkflowRun, WorkflowRunStatus } from "@/shared/api/types";
import {
createWorkflow,
deleteWorkflow,
denyApproval,
getChannelWorkflows,
getRunApprovals,
getWorkflow,
getWorkflowRuns,
grantApproval,
triggerWorkflow,
updateWorkflow,
} from "@/shared/api/tauriWorkflows";
export const allWorkflowsQueryKey = (channelIdKey: string) =>
["workflows-all", channelIdKey] as const;
export const workflowsQueryKey = (channelId: string) =>
["workflows", channelId] as const;
export const workflowQueryKey = (workflowId: string) =>
["workflow", workflowId] as const;
export const workflowRunsQueryKey = (workflowId: string) =>
["workflow-runs", workflowId] as const;
export const runApprovalsQueryKey = (workflowId: string, runId: string) =>
["run-approvals", workflowId, runId] as const;
function invalidateWorkflowListQueries(
queryClient: ReturnType<typeof useQueryClient>,
) {
void queryClient.invalidateQueries({
predicate: (query) =>
query.queryKey[0] === "workflows" ||
query.queryKey[0] === "workflows-all",
});
}
function isActiveWorkflowRunStatus(status: WorkflowRunStatus) {
return (
status === "pending" ||
status === "running" ||
status === "waiting_approval"
);
}
export function useChannelWorkflowsQuery(channelId: string | null) {
return useQuery({
queryKey: workflowsQueryKey(channelId ?? ""),
queryFn: ({ queryKey: [, resolvedChannelId] }) =>
getChannelWorkflows(resolvedChannelId),
enabled: channelId !== null,
staleTime: 30_000,
refetchOnWindowFocus: true,
});
}
export function useWorkflowQuery(workflowId: string | null) {
return useQuery({
queryKey: workflowQueryKey(workflowId ?? ""),
queryFn: ({ queryKey: [, resolvedWorkflowId] }) =>
getWorkflow(resolvedWorkflowId),
enabled: workflowId !== null,
staleTime: 30_000,
});
}
export function useWorkflowRunsQuery(workflowId: string | null) {
return useQuery({
queryKey: workflowRunsQueryKey(workflowId ?? ""),
queryFn: ({ queryKey: [, resolvedWorkflowId] }) =>
getWorkflowRuns(resolvedWorkflowId),
enabled: workflowId !== null,
staleTime: 10_000,
refetchInterval: (query) => {
const runs = query.state.data as WorkflowRun[] | undefined;
return runs?.some((run) => isActiveWorkflowRunStatus(run.status))
? 1_000
: false;
},
});
}
export function useRunApprovalsQuery(
workflowId: string | null,
runId: string | null,
) {
return useQuery({
queryKey: runApprovalsQueryKey(workflowId ?? "", runId ?? ""),
queryFn: ({ queryKey: [, resolvedWorkflowId, resolvedRunId] }) =>
getRunApprovals(resolvedWorkflowId, resolvedRunId),
enabled: workflowId !== null && runId !== null,
staleTime: 10_000,
refetchInterval: 10_000,
});
}
export function useCreateWorkflowMutation(channelId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (yamlDefinition: string) =>
createWorkflow(channelId, yamlDefinition),
onSuccess: () => {
invalidateWorkflowListQueries(queryClient);
},
});
}
export function useUpdateWorkflowMutation(workflowId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (yamlDefinition: string) =>
updateWorkflow(workflowId, yamlDefinition),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: workflowQueryKey(workflowId),
});
invalidateWorkflowListQueries(queryClient);
},
});
}
export function useDeleteWorkflowMutation(workflowId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => deleteWorkflow(workflowId),
onSuccess: () => {
invalidateWorkflowListQueries(queryClient);
},
});
}
export function useTriggerWorkflowMutation(workflowId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => triggerWorkflow(workflowId),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: workflowRunsQueryKey(workflowId),
});
},
});
}
export function useApprovalMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: {
token: string;
action: "grant" | "deny";
note?: string;
}) =>
input.action === "grant"
? grantApproval(input.token, input.note)
: denyApproval(input.token, input.note),
onSuccess: (_data, _variables) => {
void queryClient.invalidateQueries({
predicate: (query) =>
query.queryKey[0] === "workflow-runs" ||
query.queryKey[0] === "workflow" ||
query.queryKey[0] === "run-approvals",
});
},
});
}

View File

@@ -0,0 +1,180 @@
import { ChevronDown } from "lucide-react";
import * as React from "react";
import { useCreateWorkflowMutation } from "@/features/workflows/hooks";
import type { Channel } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import { Textarea } from "@/shared/ui/textarea";
type CreateWorkflowDialogProps = {
channels: Channel[];
onOpenChange: (open: boolean) => void;
open: boolean;
};
const WORKFLOW_PLACEHOLDER = `name: ui_smoke_delay
trigger:
on: message_posted
steps:
- id: wait_two_seconds
action: delay
duration: 2s`;
export function CreateWorkflowDialog({
channels,
onOpenChange,
open,
}: CreateWorkflowDialogProps) {
const [selectedChannelId, setSelectedChannelId] = React.useState(
channels[0]?.id ?? "",
);
const [yamlDefinition, setYamlDefinition] = React.useState("");
const createMutation = useCreateWorkflowMutation(selectedChannelId);
const selectedChannel =
channels.find((channel) => channel.id === selectedChannelId) ?? null;
const reset = React.useCallback(() => {
setSelectedChannelId(channels[0]?.id ?? "");
setYamlDefinition("");
createMutation.reset();
}, [channels, createMutation]);
const handleOpenChange = React.useCallback(
(nextOpen: boolean) => {
if (!nextOpen) {
reset();
}
onOpenChange(nextOpen);
},
[onOpenChange, reset],
);
React.useEffect(() => {
if (open && channels.length > 0 && !selectedChannelId) {
setSelectedChannelId(channels[0].id);
}
}, [open, channels, selectedChannelId]);
async function handleCreate() {
if (!selectedChannelId || !yamlDefinition.trim()) return;
try {
await createMutation.mutateAsync(yamlDefinition);
handleOpenChange(false);
} catch {
// React Query stores the error; keep the dialog open and render it inline.
}
}
return (
<Dialog onOpenChange={handleOpenChange} open={open}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Create Workflow</DialogTitle>
<DialogDescription>
Pick a channel, paste YAML, and create a workflow scoped to that
channel.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<label
className="block text-sm font-medium"
htmlFor="wf-channel-select"
>
Channel
</label>
<div className="relative">
<select
className="flex h-11 w-full appearance-none rounded-xl border border-border/70 bg-muted/20 px-3 pr-10 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
disabled={channels.length === 0 || createMutation.isPending}
id="wf-channel-select"
onChange={(event) => {
createMutation.reset();
setSelectedChannelId(event.target.value);
}}
value={selectedChannelId}
>
{channels.length === 0 ? (
<option value="">No channels available</option>
) : null}
{channels.map((channel) => (
<option key={channel.id} value={channel.id}>
{channel.name} · {channel.channelType} ·{" "}
{channel.visibility}
</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
</div>
<p className="text-xs text-muted-foreground">
{selectedChannel
? `New workflows will belong to ${selectedChannel.name}.`
: "Join or create a channel before adding a workflow."}
</p>
</div>
<div className="space-y-1.5">
<label
className="block text-sm font-medium"
htmlFor="wf-yaml-editor"
>
YAML Definition
</label>
<Textarea
className="min-h-[200px] resize-y font-mono text-xs"
id="wf-yaml-editor"
onChange={(event) => {
createMutation.reset();
setYamlDefinition(event.target.value);
}}
placeholder={WORKFLOW_PLACEHOLDER}
value={yamlDefinition}
/>
<p className="text-xs text-muted-foreground">
Use <code>trigger.on</code> with values like{" "}
<code>message_posted</code>, <code>reaction_added</code>,{" "}
<code>webhook</code>, or <code>schedule</code>.
</p>
</div>
</div>
{createMutation.error instanceof Error ? (
<p className="rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{createMutation.error.message}
</p>
) : null}
<div className="flex justify-end gap-2 pt-4">
<Button
onClick={() => handleOpenChange(false)}
type="button"
variant="outline"
>
Cancel
</Button>
<Button
disabled={
!selectedChannelId ||
!yamlDefinition.trim() ||
createMutation.isPending
}
onClick={handleCreate}
type="button"
>
{createMutation.isPending ? "Creating..." : "Create"}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,79 @@
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) {
return null;
}
return (
<div
className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-3"
data-testid="workflow-approval-card"
>
<p className="mb-2 text-sm font-medium">Approval Required</p>
<p className="mb-2 text-xs text-muted-foreground">
Approver: {approval.approverSpec}
</p>
<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-3 w-3" />
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-3 w-3" />
Deny
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,102 @@
import { Clock, MoreHorizontal, Play, Trash2, Zap } from "lucide-react";
import type { Workflow } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
type WorkflowCardProps = {
workflow: Workflow;
channelName?: string;
onSelect: (workflowId: string) => void;
onTrigger: (workflowId: string) => void;
onDelete: (workflowId: string) => void;
};
function StatusBadge({ status }: { status: Workflow["status"] }) {
const colors = {
active: "bg-green-500/15 text-green-500",
disabled: "bg-muted text-muted-foreground",
archived: "bg-amber-500/15 text-amber-500",
};
return (
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${colors[status]}`}
>
{status}
</span>
);
}
export function WorkflowCard({
workflow,
channelName,
onSelect,
onTrigger,
onDelete,
}: WorkflowCardProps) {
return (
<div
className="relative w-full rounded-lg border bg-card p-3 text-left transition-colors hover:bg-muted/50"
data-testid={`workflow-card-${workflow.id}`}
>
<button
className="absolute inset-0 rounded-lg"
onClick={() => onSelect(workflow.id)}
type="button"
>
<span className="sr-only">View {workflow.name}</span>
</button>
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Zap className="h-4 w-4 shrink-0 text-amber-500" />
<span className="truncate text-sm font-medium">
{workflow.name}
</span>
<StatusBadge status={workflow.status} />
</div>
<div className="mt-1.5 flex items-center gap-3 pl-6 text-[11px] text-muted-foreground">
{channelName ? <span>{channelName}</span> : null}
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{new Date(workflow.updatedAt * 1000).toLocaleDateString()}
</span>
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label="Workflow actions"
className="relative z-10 h-7 w-7 shrink-0"
size="icon"
variant="ghost"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onTrigger(workflow.id)}>
<Play className="mr-2 h-4 w-4" />
Trigger
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive"
onClick={() => onDelete(workflow.id)}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
}

View File

@@ -0,0 +1,170 @@
import { Play, X } from "lucide-react";
import * as React from "react";
import {
useRunApprovalsQuery,
useTriggerWorkflowMutation,
useWorkflowQuery,
useWorkflowRunsQuery,
} from "@/features/workflows/hooks";
import { WorkflowRunTrace } from "@/features/workflows/ui/WorkflowRunTrace";
import { Button } from "@/shared/ui/button";
type WorkflowDetailPanelProps = {
workflowId: string;
onClose: () => void;
};
export function WorkflowDetailPanel({
workflowId,
onClose,
}: WorkflowDetailPanelProps) {
const workflowQuery = useWorkflowQuery(workflowId);
const runsQuery = useWorkflowRunsQuery(workflowId);
const triggerMutation = useTriggerWorkflowMutation(workflowId);
const [selectedRunId, setSelectedRunId] = React.useState<string | null>(null);
const workflow = workflowQuery.data;
const runs = runsQuery.data ?? [];
const selectedRun = selectedRunId
? (runs.find((r) => r.id === selectedRunId) ?? null)
: null;
const approvalsQuery = useRunApprovalsQuery(workflowId, selectedRunId);
async function handleTrigger() {
try {
const response = await triggerMutation.mutateAsync();
setSelectedRunId(response.runId);
} catch {
// React Query stores the error; keep the current selection unchanged.
}
}
return (
<div
className="flex h-full flex-col border-l bg-background"
data-testid="workflow-detail-panel"
>
<div className="flex items-center justify-between border-b px-4 py-3">
<h3 className="truncate text-sm font-semibold">
{workflow?.name ?? "Loading..."}
</h3>
<div className="flex items-center gap-1">
<Button
disabled={triggerMutation.isPending}
onClick={() => void handleTrigger()}
size="sm"
variant="outline"
>
<Play className="mr-1 h-3 w-3" />
{triggerMutation.isPending ? "Triggering..." : "Trigger"}
</Button>
<Button
aria-label="Close detail panel"
onClick={onClose}
size="icon"
variant="ghost"
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
{triggerMutation.isError ? (
<div className="border-b px-4 py-2 text-xs text-red-400">
Failed to trigger workflow
</div>
) : null}
<div className="flex-1 overflow-y-auto">
{workflow ? (
<div className="space-y-4 p-4">
<div>
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Definition
</h4>
<pre className="max-h-64 overflow-auto rounded-md bg-muted/50 p-3 font-mono text-xs leading-relaxed">
{JSON.stringify(workflow.definition, null, 2)}
</pre>
</div>
<div>
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Run History
</h4>
{runs.length === 0 ? (
<p className="text-sm text-muted-foreground">No runs yet.</p>
) : (
<div className="space-y-2">
{runs.map((run) => (
<button
className={`w-full rounded-md border px-3 py-2 text-left text-xs transition-colors hover:bg-muted/50 ${
selectedRunId === run.id
? "border-primary bg-primary/5"
: ""
}`}
key={run.id}
onClick={() =>
setSelectedRunId(
selectedRunId === run.id ? null : run.id,
)
}
type="button"
>
<div className="flex items-center justify-between">
<span className="font-mono">{run.id.slice(0, 8)}</span>
<RunStatusBadge status={run.status} />
</div>
<div className="mt-1 text-muted-foreground">
{new Date(run.createdAt * 1000).toLocaleString()}
</div>
</button>
))}
</div>
)}
</div>
{selectedRun ? (
<div>
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Execution Trace
</h4>
<WorkflowRunTrace
approvals={approvalsQuery.data}
run={selectedRun}
/>
</div>
) : null}
</div>
) : workflowQuery.isError ? (
<div className="flex h-32 flex-col items-center justify-center gap-2">
<p className="text-sm text-red-400">Failed to load workflow</p>
</div>
) : (
<div className="flex h-32 items-center justify-center">
<p className="text-sm text-muted-foreground">Loading...</p>
</div>
)}
</div>
</div>
);
}
function RunStatusBadge({ status }: { status: string }) {
const colors: Record<string, string> = {
completed: "bg-green-500/15 text-green-500",
failed: "bg-red-500/15 text-red-500",
running: "bg-blue-500/15 text-blue-500",
pending: "bg-muted text-muted-foreground",
cancelled: "bg-muted text-muted-foreground",
waiting_approval: "bg-amber-500/15 text-amber-500",
};
return (
<span
className={`inline-flex rounded-full px-2 py-0.5 text-[10px] font-medium ${colors[status] ?? colors.pending}`}
>
{status}
</span>
);
}

View File

@@ -0,0 +1,90 @@
import { Check, Clock, SkipForward, X } from "lucide-react";
import type { WorkflowApproval, WorkflowRun } from "@/shared/api/types";
import { WorkflowApprovalCard } from "@/features/workflows/ui/WorkflowApprovalCard";
type WorkflowRunTraceProps = {
run: WorkflowRun;
approvals?: WorkflowApproval[];
};
function StepStatusIcon({ status }: { status: string }) {
switch (status) {
case "completed":
return <Check className="h-4 w-4 text-green-500" />;
case "failed":
case "error":
return <X className="h-4 w-4 text-red-500" />;
case "skipped":
return <SkipForward className="h-4 w-4 text-muted-foreground" />;
case "waiting_approval":
return <Clock className="h-4 w-4 text-amber-500" />;
default:
return <Clock className="h-4 w-4 text-blue-500" />;
}
}
function formatDuration(startedAt: number | null, completedAt: number | null) {
if (startedAt === null || completedAt === null) return null;
const seconds = completedAt - startedAt;
if (seconds < 1) return `${Math.round(seconds * 1000)}ms`;
return `${seconds.toFixed(1)}s`;
}
export function WorkflowRunTrace({
run,
approvals = [],
}: WorkflowRunTraceProps) {
if (run.executionTrace.length === 0) {
return (
<p className="py-4 text-center text-sm text-muted-foreground">
No steps recorded yet.
</p>
);
}
return (
<div className="space-y-1" data-testid="workflow-run-trace">
{run.executionTrace.map((step) => {
const duration = formatDuration(step.startedAt, step.completedAt);
const pendingApproval = approvals.find(
(a) => a.stepId === step.stepId && a.status === "pending",
);
return (
<div key={step.stepId}>
<div className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-muted/50">
<StepStatusIcon status={step.status} />
<span className="flex-1 truncate font-mono text-xs">
{step.stepId}
</span>
<span className="text-xs text-muted-foreground">
{step.status}
</span>
{duration ? (
<span className="text-xs text-muted-foreground">
{duration}
</span>
) : null}
</div>
{Object.keys(step.output).length > 0 ? (
<pre className="ml-8 max-h-24 overflow-auto rounded bg-muted/30 px-2 py-1 font-mono text-xs text-muted-foreground">
{JSON.stringify(step.output, null, 2)}
</pre>
) : null}
{step.error ? (
<pre className="ml-8 max-h-24 overflow-auto rounded bg-red-500/10 px-2 py-1 font-mono text-xs text-red-400">
{step.error}
</pre>
) : null}
{pendingApproval ? (
<div className="ml-8 mt-1">
<WorkflowApprovalCard approval={pendingApproval} />
</div>
) : null}
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,181 @@
import { Plus, RefreshCw, Zap } from "lucide-react";
import * as React from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { allWorkflowsQueryKey } from "@/features/workflows/hooks";
import { CreateWorkflowDialog } from "@/features/workflows/ui/CreateWorkflowDialog";
import { WorkflowCard } from "@/features/workflows/ui/WorkflowCard";
import { WorkflowDetailPanel } from "@/features/workflows/ui/WorkflowDetailPanel";
import type { Channel, Workflow } from "@/shared/api/types";
import {
deleteWorkflow,
getChannelWorkflows,
triggerWorkflow,
} from "@/shared/api/tauriWorkflows";
import { Button } from "@/shared/ui/button";
type WorkflowsViewProps = {
channels: Channel[];
};
type WorkflowWithChannel = {
workflow: Workflow;
channelName: string;
};
export function WorkflowsView({ channels }: WorkflowsViewProps) {
const [isCreateOpen, setIsCreateOpen] = React.useState(false);
const [selectedWorkflowId, setSelectedWorkflowId] = React.useState<
string | null
>(null);
const queryClient = useQueryClient();
const memberChannels = channels.filter((c) => c.isMember);
const channelIds = memberChannels.map((c) => c.id).sort();
const channelIdKey = channelIds.join(",");
const allWorkflowsQuery = useQuery({
queryKey: allWorkflowsQueryKey(channelIdKey),
queryFn: async () => {
const results: WorkflowWithChannel[] = [];
await Promise.all(
memberChannels.map(async (channel) => {
const workflows = await getChannelWorkflows(channel.id);
for (const workflow of workflows) {
results.push({ workflow, channelName: channel.name });
}
}),
);
return results;
},
enabled: memberChannels.length > 0,
staleTime: 30_000,
refetchOnWindowFocus: true,
});
const allWorkflows = allWorkflowsQuery.data ?? [];
const triggerMutation = useMutation({
mutationFn: (workflowId: string) => triggerWorkflow(workflowId),
onSuccess: () => {
void queryClient.invalidateQueries({
predicate: (query) => query.queryKey[0] === "workflow-runs",
});
},
});
const deleteMutation = useMutation({
mutationFn: (workflowId: string) => deleteWorkflow(workflowId),
onSuccess: (_data, workflowId) => {
setSelectedWorkflowId((current) =>
current === workflowId ? null : current,
);
void queryClient.invalidateQueries({
predicate: (query) =>
query.queryKey[0] === "workflows" ||
query.queryKey[0] === "workflows-all",
});
},
});
const handleTrigger = React.useCallback(
(workflowId: string) => triggerMutation.mutate(workflowId),
[triggerMutation],
);
const handleDelete = React.useCallback(
(workflowId: string) => deleteMutation.mutate(workflowId),
[deleteMutation],
);
return (
<div
className="flex min-h-0 flex-1 overflow-hidden"
data-testid="workflows-view"
>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto p-4">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold">Workflows</h2>
<Button
aria-label="Refresh workflows"
disabled={allWorkflowsQuery.isFetching}
onClick={() => void allWorkflowsQuery.refetch()}
size="icon"
variant="ghost"
>
<RefreshCw
className={`h-4 w-4 ${allWorkflowsQuery.isFetching ? "animate-spin" : ""}`}
/>
</Button>
</div>
<Button onClick={() => setIsCreateOpen(true)} size="sm">
<Plus className="mr-1 h-4 w-4" />
Create Workflow
</Button>
</div>
{allWorkflowsQuery.isLoading ? (
<div className="flex flex-1 items-center justify-center">
<p className="text-sm text-muted-foreground">
Loading workflows...
</p>
</div>
) : allWorkflowsQuery.isError ? (
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-muted-foreground">
<p className="text-sm text-red-400">Failed to load workflows</p>
<Button
onClick={() => void allWorkflowsQuery.refetch()}
size="sm"
variant="outline"
>
Retry
</Button>
</div>
) : allWorkflows.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center gap-3 text-muted-foreground">
<Zap className="h-10 w-10 opacity-30" />
<p className="text-sm">No workflows yet</p>
<Button
onClick={() => setIsCreateOpen(true)}
size="sm"
variant="outline"
>
<Plus className="mr-1 h-4 w-4" />
Create your first workflow
</Button>
</div>
) : (
<div className="space-y-2">
{allWorkflows.map(({ workflow, channelName }) => (
<WorkflowCard
channelName={channelName}
key={workflow.id}
onDelete={handleDelete}
onSelect={setSelectedWorkflowId}
onTrigger={handleTrigger}
workflow={workflow}
/>
))}
</div>
)}
</div>
{selectedWorkflowId ? (
<div className="w-[400px] shrink-0">
<WorkflowDetailPanel
key={selectedWorkflowId}
onClose={() => setSelectedWorkflowId(null)}
workflowId={selectedWorkflowId}
/>
</div>
) : null}
<CreateWorkflowDialog
channels={memberChannels}
onOpenChange={setIsCreateOpen}
open={isCreateOpen}
/>
</div>
);
}

View File

@@ -0,0 +1,243 @@
import { invokeTauri } from "@/shared/api/tauri";
import type {
ApprovalActionResponse,
TriggerWorkflowResponse,
Workflow,
WorkflowApproval,
WorkflowRun,
TraceEntry,
} from "@/shared/api/types";
// ── Raw types (snake_case from backend) ───────────────────────────────────
type RawWorkflow = {
id: string;
name: string;
owner_pubkey: string;
channel_id: string | null;
definition: Record<string, unknown>;
status: Workflow["status"];
created_at: number;
updated_at: number;
};
type RawTraceEntry = {
step_id: string;
status: string;
output?: Record<string, unknown>;
started_at?: number | null;
completed_at?: number | null;
error?: string | null;
};
type RawWorkflowRun = {
id: string;
workflow_id: string;
status: WorkflowRun["status"];
current_step: number | null;
execution_trace: RawTraceEntry[];
started_at: number | null;
completed_at: number | null;
error_message: string | null;
created_at: number;
};
type RawWorkflowApproval = {
token: string;
workflow_id: string;
run_id: string;
step_id: string;
step_index: number;
approver_spec: string;
status: WorkflowApproval["status"];
approver_pubkey: string | null;
note: string | null;
expires_at: string;
created_at: number;
};
type RawTriggerWorkflowResponse = {
run_id: string;
workflow_id: string;
status: string;
};
type RawApprovalActionResponse = {
token: string;
status: string;
run_id: string;
workflow_id: string;
};
// ── Conversion functions ──────────────────────────────────────────────────
function fromRawWorkflow(raw: RawWorkflow): Workflow {
return {
id: raw.id,
name: raw.name,
ownerPubkey: raw.owner_pubkey,
channelId: raw.channel_id,
definition: raw.definition,
status: raw.status,
createdAt: raw.created_at,
updatedAt: raw.updated_at,
};
}
function fromRawTraceEntry(raw: RawTraceEntry): TraceEntry {
return {
stepId: raw.step_id,
status: raw.status,
output: raw.output ?? {},
startedAt: raw.started_at ?? null,
completedAt: raw.completed_at ?? null,
error: raw.error ?? null,
};
}
function fromRawWorkflowRun(raw: RawWorkflowRun): WorkflowRun {
return {
id: raw.id,
workflowId: raw.workflow_id,
status: raw.status,
currentStep: raw.current_step,
executionTrace: raw.execution_trace.map(fromRawTraceEntry),
startedAt: raw.started_at,
completedAt: raw.completed_at,
errorMessage: raw.error_message,
createdAt: raw.created_at,
};
}
export function fromRawApproval(raw: RawWorkflowApproval): WorkflowApproval {
return {
token: raw.token,
workflowId: raw.workflow_id,
runId: raw.run_id,
stepId: raw.step_id,
stepIndex: raw.step_index,
approverSpec: raw.approver_spec,
status: raw.status,
approverPubkey: raw.approver_pubkey,
note: raw.note,
expiresAt: raw.expires_at,
createdAt: raw.created_at,
};
}
function fromRawTriggerResponse(
raw: RawTriggerWorkflowResponse,
): TriggerWorkflowResponse {
return {
runId: raw.run_id,
workflowId: raw.workflow_id,
status: raw.status,
};
}
function fromRawApprovalResponse(
raw: RawApprovalActionResponse,
): ApprovalActionResponse {
return {
token: raw.token,
status: raw.status,
runId: raw.run_id,
workflowId: raw.workflow_id,
};
}
// ── Tauri invoke wrappers ─────────────────────────────────────────────────
export async function getChannelWorkflows(
channelId: string,
): Promise<Workflow[]> {
const raw = await invokeTauri<RawWorkflow[]>("get_channel_workflows", {
channelId,
});
return raw.map(fromRawWorkflow);
}
export async function getWorkflow(workflowId: string): Promise<Workflow> {
const raw = await invokeTauri<RawWorkflow>("get_workflow", { workflowId });
return fromRawWorkflow(raw);
}
export async function createWorkflow(
channelId: string,
yamlDefinition: string,
): Promise<Workflow> {
const raw = await invokeTauri<RawWorkflow>("create_workflow", {
channelId,
yamlDefinition,
});
return fromRawWorkflow(raw);
}
export async function updateWorkflow(
workflowId: string,
yamlDefinition: string,
): Promise<Workflow> {
const raw = await invokeTauri<RawWorkflow>("update_workflow", {
workflowId,
yamlDefinition,
});
return fromRawWorkflow(raw);
}
export async function deleteWorkflow(workflowId: string): Promise<void> {
await invokeTauri("delete_workflow", { workflowId });
}
export async function getWorkflowRuns(
workflowId: string,
limit?: number,
): Promise<WorkflowRun[]> {
const raw = await invokeTauri<RawWorkflowRun[]>("get_workflow_runs", {
workflowId,
limit: limit ?? null,
});
return raw.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);
}
export async function triggerWorkflow(
workflowId: string,
): Promise<TriggerWorkflowResponse> {
const raw = await invokeTauri<RawTriggerWorkflowResponse>(
"trigger_workflow",
{ workflowId },
);
return fromRawTriggerResponse(raw);
}
export async function grantApproval(
token: string,
note?: string,
): Promise<ApprovalActionResponse> {
const raw = await invokeTauri<RawApprovalActionResponse>("grant_approval", {
token,
note: note ?? null,
});
return fromRawApprovalResponse(raw);
}
export async function denyApproval(
token: string,
note?: string,
): Promise<ApprovalActionResponse> {
const raw = await invokeTauri<RawApprovalActionResponse>("deny_approval", {
token,
note: note ?? null,
});
return fromRawApprovalResponse(raw);
}

View File

@@ -464,6 +464,19 @@ export type UpdateTeamInput = {
personaIds: string[];
};
// ── Workflow types (re-exported from workflowTypes.ts) ────────────────────
export type {
ApprovalActionResponse,
Workflow,
WorkflowApproval,
WorkflowApprovalStatus,
WorkflowRun,
WorkflowRunStatus,
WorkflowStatus,
TraceEntry,
TriggerWorkflowResponse,
} from "@/shared/api/workflowTypes";
// ── Forum types ───────────────────────────────────────────────────────────────
export type ThreadSummary = {

View File

@@ -0,0 +1,74 @@
export type WorkflowStatus = "active" | "disabled" | "archived";
export type Workflow = {
id: string;
name: string;
ownerPubkey: string;
channelId: string | null;
definition: Record<string, unknown>;
status: WorkflowStatus;
createdAt: number;
updatedAt: number;
};
export type WorkflowRunStatus =
| "pending"
| "running"
| "completed"
| "failed"
| "cancelled"
| "waiting_approval";
export type TraceEntry = {
stepId: string;
status: string;
output: Record<string, unknown>;
startedAt: number | null;
completedAt: number | null;
error: string | null;
};
export type WorkflowRun = {
id: string;
workflowId: string;
status: WorkflowRunStatus;
currentStep: number | null;
executionTrace: TraceEntry[];
startedAt: number | null;
completedAt: number | null;
errorMessage: string | null;
createdAt: number;
};
export type WorkflowApprovalStatus =
| "pending"
| "granted"
| "denied"
| "expired";
export type WorkflowApproval = {
token: string;
workflowId: string;
runId: string;
stepId: string;
stepIndex: number;
approverSpec: string;
status: WorkflowApprovalStatus;
approverPubkey: string | null;
note: string | null;
expiresAt: string;
createdAt: number;
};
export type TriggerWorkflowResponse = {
runId: string;
workflowId: string;
status: string;
};
export type ApprovalActionResponse = {
token: string;
status: string;
runId: string;
workflowId: string;
};