mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
feat: add Workflows UI surface (#217)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
143
desktop/src-tauri/src/commands/workflows.rs
Normal file
143
desktop/src-tauri/src/commands/workflows.rs
Normal 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
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" />;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -125,7 +125,7 @@ export function MessageActionBar({
|
||||
) : null}
|
||||
<Picker
|
||||
data={data}
|
||||
onEmojiSelect={(emoji: any) => {
|
||||
onEmojiSelect={(emoji: { native: string }) => {
|
||||
if (!onReactionSelect) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
169
desktop/src/features/workflows/hooks.ts
Normal file
169
desktop/src/features/workflows/hooks.ts
Normal 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",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
180
desktop/src/features/workflows/ui/CreateWorkflowDialog.tsx
Normal file
180
desktop/src/features/workflows/ui/CreateWorkflowDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
79
desktop/src/features/workflows/ui/WorkflowApprovalCard.tsx
Normal file
79
desktop/src/features/workflows/ui/WorkflowApprovalCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
102
desktop/src/features/workflows/ui/WorkflowCard.tsx
Normal file
102
desktop/src/features/workflows/ui/WorkflowCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
170
desktop/src/features/workflows/ui/WorkflowDetailPanel.tsx
Normal file
170
desktop/src/features/workflows/ui/WorkflowDetailPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
90
desktop/src/features/workflows/ui/WorkflowRunTrace.tsx
Normal file
90
desktop/src/features/workflows/ui/WorkflowRunTrace.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
181
desktop/src/features/workflows/ui/WorkflowsView.tsx
Normal file
181
desktop/src/features/workflows/ui/WorkflowsView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
243
desktop/src/shared/api/tauriWorkflows.ts
Normal file
243
desktop/src/shared/api/tauriWorkflows.ts
Normal 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);
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
74
desktop/src/shared/api/workflowTypes.ts
Normal file
74
desktop/src/shared/api/workflowTypes.ts
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user