mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +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
|
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.
|
/// Update an approval's status.
|
||||||
pub async fn update_approval(
|
pub async fn update_approval(
|
||||||
&self,
|
&self,
|
||||||
@@ -1137,6 +1154,24 @@ impl Db {
|
|||||||
workflow::update_approval(&self.pool, token, status, approver_pubkey, note).await
|
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 ──────────────────────────────────────────────────────────
|
// ── Partitions ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Ensures monthly partitions exist for the next N months.
|
/// 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.
|
/// A pending or resolved approval gate for a workflow step.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ApprovalRecord {
|
pub struct ApprovalRecord {
|
||||||
/// Unique approval token (hashed before storage).
|
/// Token hash as stored in the DB (BYTEA).
|
||||||
pub token: String,
|
pub token: Vec<u8>,
|
||||||
/// The workflow this approval belongs to.
|
/// The workflow this approval belongs to.
|
||||||
pub workflow_id: Uuid,
|
pub workflow_id: Uuid,
|
||||||
/// The run waiting on this approval.
|
/// The run waiting on this approval.
|
||||||
@@ -644,7 +644,17 @@ pub async fn create_approval(pool: &PgPool, params: CreateApprovalParams<'_>) ->
|
|||||||
/// sent to the database layer.
|
/// sent to the database layer.
|
||||||
pub async fn get_approval(pool: &PgPool, token: &str) -> Result<ApprovalRecord> {
|
pub async fn get_approval(pool: &PgPool, token: &str) -> Result<ApprovalRecord> {
|
||||||
let token_hash = hash_approval_token(token);
|
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(
|
let row = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
SELECT token, workflow_id, run_id, step_id, step_index, approver_spec,
|
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)
|
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.
|
/// Update an approval's status, approver pubkey, and optional note.
|
||||||
/// Also stamps `granted_at` or `denied_at` based on the new status.
|
/// Also stamps `granted_at` or `denied_at` based on the new status.
|
||||||
///
|
///
|
||||||
@@ -680,6 +713,22 @@ pub async fn update_approval(
|
|||||||
note: Option<&str>,
|
note: Option<&str>,
|
||||||
) -> Result<bool> {
|
) -> Result<bool> {
|
||||||
let token_hash = hash_approval_token(token);
|
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 status_str = status.to_string();
|
||||||
let affected = sqlx::query(
|
let affected = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
@@ -1134,7 +1183,7 @@ mod tests {
|
|||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
|
|
||||||
let record = ApprovalRecord {
|
let record = ApprovalRecord {
|
||||||
token: "abc123def456abc123def456abc123de".to_owned(),
|
token: b"abc123def456abc123def456abc123de".to_vec(),
|
||||||
workflow_id,
|
workflow_id,
|
||||||
run_id,
|
run_id,
|
||||||
step_id: "request_approval".to_owned(),
|
step_id: "request_approval".to_owned(),
|
||||||
@@ -1147,7 +1196,7 @@ mod tests {
|
|||||||
created_at: now,
|
created_at: now,
|
||||||
};
|
};
|
||||||
|
|
||||||
assert_eq!(record.token, "abc123def456abc123def456abc123de");
|
assert_eq!(record.token, b"abc123def456abc123def456abc123de");
|
||||||
assert_eq!(record.workflow_id, workflow_id);
|
assert_eq!(record.workflow_id, workflow_id);
|
||||||
assert_eq!(record.run_id, run_id);
|
assert_eq!(record.run_id, run_id);
|
||||||
assert_eq!(record.step_id, "request_approval");
|
assert_eq!(record.step_id, "request_approval");
|
||||||
@@ -1164,7 +1213,7 @@ mod tests {
|
|||||||
let approver_pubkey = vec![0xca; 32];
|
let approver_pubkey = vec![0xca; 32];
|
||||||
|
|
||||||
let record = ApprovalRecord {
|
let record = ApprovalRecord {
|
||||||
token: "token-granted".to_owned(),
|
token: b"token-granted".to_vec(),
|
||||||
workflow_id: Uuid::new_v4(),
|
workflow_id: Uuid::new_v4(),
|
||||||
run_id: Uuid::new_v4(),
|
run_id: Uuid::new_v4(),
|
||||||
step_id: "gate".to_owned(),
|
step_id: "gate".to_owned(),
|
||||||
@@ -1187,7 +1236,7 @@ mod tests {
|
|||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
|
|
||||||
let record = ApprovalRecord {
|
let record = ApprovalRecord {
|
||||||
token: "token-denied".to_owned(),
|
token: b"token-denied".to_vec(),
|
||||||
workflow_id: Uuid::new_v4(),
|
workflow_id: Uuid::new_v4(),
|
||||||
run_id: Uuid::new_v4(),
|
run_id: Uuid::new_v4(),
|
||||||
step_id: "gate".to_owned(),
|
step_id: "gate".to_owned(),
|
||||||
@@ -1208,7 +1257,7 @@ mod tests {
|
|||||||
fn approval_record_clone_is_independent() {
|
fn approval_record_clone_is_independent() {
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
let record = ApprovalRecord {
|
let record = ApprovalRecord {
|
||||||
token: "original-token".to_owned(),
|
token: b"original-token".to_vec(),
|
||||||
workflow_id: Uuid::new_v4(),
|
workflow_id: Uuid::new_v4(),
|
||||||
run_id: Uuid::new_v4(),
|
run_id: Uuid::new_v4(),
|
||||||
step_id: "gate".to_owned(),
|
step_id: "gate".to_owned(),
|
||||||
|
|||||||
@@ -167,49 +167,41 @@ async fn resume_workflow_after_approval(
|
|||||||
engine.finalize_run(run_id, result, existing_trace).await;
|
engine.finalize_run(run_id, result, existing_trace).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── POST /api/approvals/:token/grant ─────────────────────────────────────────
|
// ── Shared approval validation ───────────────────────────────────────────────
|
||||||
|
|
||||||
/// 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"))?;
|
|
||||||
|
|
||||||
|
/// 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 {
|
if approval.status != sprout_db::workflow::ApprovalStatus::Pending {
|
||||||
return Err(api_error(
|
return Err(api_error(
|
||||||
StatusCode::CONFLICT,
|
StatusCode::CONFLICT,
|
||||||
&format!("approval already {}", approval.status),
|
&format!("approval already {}", approval.status),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
if Utc::now() > approval.expires_at {
|
if Utc::now() > approval.expires_at {
|
||||||
return Err(api_error(StatusCode::GONE, "approval token has expired"));
|
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())?;
|
/// Execute the grant: update DB, spawn workflow resume, return response.
|
||||||
|
async fn execute_grant(
|
||||||
let note = body.as_ref().and_then(|b| b.note.as_deref());
|
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
|
let updated = state
|
||||||
.db
|
.db
|
||||||
.update_approval(
|
.update_approval_by_stored_hash(
|
||||||
&token,
|
token_hash,
|
||||||
sprout_db::workflow::ApprovalStatus::Granted,
|
sprout_db::workflow::ApprovalStatus::Granted,
|
||||||
Some(&pubkey_bytes),
|
Some(pubkey_bytes),
|
||||||
note,
|
note,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -219,11 +211,9 @@ pub async fn grant_approval(
|
|||||||
return Err(api_error(StatusCode::CONFLICT, "approval already acted on"));
|
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 run_id = approval.run_id;
|
||||||
let workflow_id = approval.workflow_id;
|
let workflow_id = approval.workflow_id;
|
||||||
let resume_index = approval.step_index as usize + 1;
|
let resume_index = approval.step_index as usize + 1;
|
||||||
|
|
||||||
let engine = Arc::clone(&state.workflow_engine);
|
let engine = Arc::clone(&state.workflow_engine);
|
||||||
let db = state.db.clone();
|
let db = state.db.clone();
|
||||||
|
|
||||||
@@ -234,7 +224,6 @@ pub async fn grant_approval(
|
|||||||
Ok((
|
Ok((
|
||||||
StatusCode::ACCEPTED,
|
StatusCode::ACCEPTED,
|
||||||
Json(serde_json::json!({
|
Json(serde_json::json!({
|
||||||
"token": token,
|
|
||||||
"status": "granted",
|
"status": "granted",
|
||||||
"run_id": approval.run_id.to_string(),
|
"run_id": approval.run_id.to_string(),
|
||||||
"workflow_id": approval.workflow_id.to_string(),
|
"workflow_id": approval.workflow_id.to_string(),
|
||||||
@@ -242,49 +231,21 @@ pub async fn grant_approval(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── POST /api/approvals/:token/deny ──────────────────────────────────────────
|
/// Execute the deny: update DB, spawn workflow cancellation, return response.
|
||||||
|
async fn execute_deny(
|
||||||
/// Deny a pending approval and cancel the suspended workflow run.
|
state: &Arc<AppState>,
|
||||||
///
|
approval: &sprout_db::workflow::ApprovalRecord,
|
||||||
/// Uses `AND status = 'pending'` in the DB update to prevent TOCTOU races.
|
token_hash: &[u8],
|
||||||
pub async fn deny_approval(
|
pubkey_bytes: &[u8],
|
||||||
State(state): State<Arc<AppState>>,
|
pubkey_hex: String,
|
||||||
headers: HeaderMap,
|
note: Option<&str>,
|
||||||
Path(token): Path<String>,
|
|
||||||
body: Option<Json<ApprovalBody>>,
|
|
||||||
) -> Result<(StatusCode, Json<serde_json::Value>), (StatusCode, Json<serde_json::Value>)> {
|
) -> 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
|
let updated = state
|
||||||
.db
|
.db
|
||||||
.update_approval(
|
.update_approval_by_stored_hash(
|
||||||
&token,
|
token_hash,
|
||||||
sprout_db::workflow::ApprovalStatus::Denied,
|
sprout_db::workflow::ApprovalStatus::Denied,
|
||||||
Some(&pubkey_bytes),
|
Some(pubkey_bytes),
|
||||||
note,
|
note,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -294,11 +255,7 @@ pub async fn deny_approval(
|
|||||||
return Err(api_error(StatusCode::CONFLICT, "approval already acted on"));
|
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 run_id = approval.run_id;
|
||||||
let pubkey_for_msg = ctx.pubkey.to_hex();
|
|
||||||
let db = state.db.clone();
|
let db = state.db.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let run = match db.get_workflow_run(run_id).await {
|
let run = match db.get_workflow_run(run_id).await {
|
||||||
@@ -317,7 +274,7 @@ pub async fn deny_approval(
|
|||||||
return;
|
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
|
if let Err(e) = db
|
||||||
.update_workflow_run(
|
.update_workflow_run(
|
||||||
run_id,
|
run_id,
|
||||||
@@ -335,7 +292,6 @@ pub async fn deny_approval(
|
|||||||
Ok((
|
Ok((
|
||||||
StatusCode::ACCEPTED,
|
StatusCode::ACCEPTED,
|
||||||
Json(serde_json::json!({
|
Json(serde_json::json!({
|
||||||
"token": token,
|
|
||||||
"status": "denied",
|
"status": "denied",
|
||||||
"run_id": approval.run_id.to_string(),
|
"run_id": approval.run_id.to_string(),
|
||||||
"workflow_id": approval.workflow_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 ─────────────────────────────────────────────────────────────────────
|
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ pub mod workflows;
|
|||||||
|
|
||||||
// Re-export all public handlers so router.rs can use `api::*_handler` unchanged.
|
// Re-export all public handlers so router.rs can use `api::*_handler` unchanged.
|
||||||
pub use agents::agents_handler;
|
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 canvas::get_canvas;
|
||||||
pub use channels::channels_handler;
|
pub use channels::channels_handler;
|
||||||
pub use channels_metadata::get_channel_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,
|
get_profile, get_user_profile, get_users_batch, put_channel_add_policy, search_users,
|
||||||
};
|
};
|
||||||
pub use workflows::{
|
pub use workflows::{
|
||||||
create_workflow, delete_workflow, get_workflow, list_channel_workflows, list_workflow_runs,
|
create_workflow, delete_workflow, get_workflow, list_channel_workflows, list_run_approvals,
|
||||||
trigger_workflow, update_workflow, workflow_webhook,
|
list_workflow_runs, trigger_workflow, update_workflow, workflow_webhook,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Shared helpers ────────────────────────────────────────────────────────────
|
// ── 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 ───────────────────────────────────────────────────────────
|
// ── SSRF prevention ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Validate all CallWebhook URLs in a workflow definition.
|
/// Validate all CallWebhook URLs in a workflow definition.
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ use serde::Deserialize;
|
|||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
use super::workflow_helpers::{
|
use super::workflow_helpers::{
|
||||||
definition_hash, ensure_webhook_secret, run_record_to_json, spawn_workflow_execution,
|
approval_record_to_json, definition_hash, ensure_webhook_secret, run_record_to_json,
|
||||||
validate_webhook_urls, workflow_record_to_json,
|
spawn_workflow_execution, validate_webhook_urls, workflow_record_to_json,
|
||||||
};
|
};
|
||||||
use super::{
|
use super::{
|
||||||
api_error, check_channel_access, check_token_channel_access, extract_auth_context, forbidden,
|
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)))
|
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 ──────────────────────────────────────────
|
// ── POST /api/workflows/:id/trigger ──────────────────────────────────────────
|
||||||
|
|
||||||
/// Manually trigger a workflow. Returns 202 Accepted; execution is async.
|
/// 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),
|
.delete(api::delete_workflow),
|
||||||
)
|
)
|
||||||
.route("/api/workflows/{id}/runs", get(api::list_workflow_runs))
|
.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}/trigger", post(api::trigger_workflow))
|
||||||
.route("/api/workflows/{id}/webhook", post(api::workflow_webhook))
|
.route("/api/workflows/{id}/webhook", post(api::workflow_webhook))
|
||||||
.route("/api/approvals/{token}/grant", post(api::grant_approval))
|
.route("/api/approvals/{token}/grant", post(api::grant_approval))
|
||||||
.route("/api/approvals/{token}/deny", post(api::deny_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
|
// Membership routes
|
||||||
.route("/api/channels/{channel_id}/members", get(api::list_members))
|
.route("/api/channels/{channel_id}/members", get(api::list_members))
|
||||||
// Channel detail + metadata routes
|
// Channel detail + metadata routes
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const rules = [
|
|||||||
const overrides = new Map([
|
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/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-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/hooks.ts", 550], // canvas query + mutation hooks + DM hide mutation
|
||||||
["src/features/channels/ui/ChannelManagementSheet.tsx", 800],
|
["src/features/channels/ui/ChannelManagementSheet.tsx", 800],
|
||||||
["src/features/messages/hooks.ts", 500], // message query/mutation hooks + optimistic updates
|
["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/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/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/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) {
|
async function walkFiles(directory) {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ mod personas;
|
|||||||
mod profile;
|
mod profile;
|
||||||
mod teams;
|
mod teams;
|
||||||
mod tokens;
|
mod tokens;
|
||||||
|
mod workflows;
|
||||||
|
|
||||||
pub use agent_discovery::*;
|
pub use agent_discovery::*;
|
||||||
pub use agent_models::*;
|
pub use agent_models::*;
|
||||||
@@ -28,3 +29,4 @@ pub use personas::*;
|
|||||||
pub use profile::*;
|
pub use profile::*;
|
||||||
pub use teams::*;
|
pub use teams::*;
|
||||||
pub use tokens::*;
|
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_team_file,
|
||||||
parse_persona_files,
|
parse_persona_files,
|
||||||
export_persona_to_json,
|
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!())
|
.build(tauri::generate_context!())
|
||||||
.expect("error while building tauri application");
|
.expect("error while building tauri application");
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useActiveChannelHeader } from "@/app/useActiveChannelHeader";
|
|||||||
import { useChannelPaneHandlers } from "@/app/useChannelPaneHandlers";
|
import { useChannelPaneHandlers } from "@/app/useChannelPaneHandlers";
|
||||||
import { AgentsView } from "@/features/agents/ui/AgentsView";
|
import { AgentsView } from "@/features/agents/ui/AgentsView";
|
||||||
import { ForumView } from "@/features/forum/ui/ForumView";
|
import { ForumView } from "@/features/forum/ui/ForumView";
|
||||||
|
import { WorkflowsView } from "@/features/workflows/ui/WorkflowsView";
|
||||||
import { ChatHeader } from "@/features/chat/ui/ChatHeader";
|
import { ChatHeader } from "@/features/chat/ui/ChatHeader";
|
||||||
import {
|
import {
|
||||||
channelsQueryKey,
|
channelsQueryKey,
|
||||||
@@ -64,7 +65,7 @@ import {
|
|||||||
} from "@/shared/ui/sidebar";
|
} from "@/shared/ui/sidebar";
|
||||||
import { useWebviewZoomShortcuts } from "@/app/useWebviewZoomShortcuts";
|
import { useWebviewZoomShortcuts } from "@/app/useWebviewZoomShortcuts";
|
||||||
|
|
||||||
type AppView = "home" | "channel" | "settings" | "agents";
|
type AppView = "home" | "channel" | "settings" | "agents" | "workflows";
|
||||||
type MainView = Exclude<AppView, "settings">;
|
type MainView = Exclude<AppView, "settings">;
|
||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
useWebviewZoomShortcuts();
|
useWebviewZoomShortcuts();
|
||||||
@@ -93,6 +94,11 @@ export function AppShell() {
|
|||||||
const [editTargetId, setEditTargetId] = React.useState<string | null>(null);
|
const [editTargetId, setEditTargetId] = React.useState<string | null>(null);
|
||||||
const lastNonSettingsViewRef = React.useRef<MainView>("home");
|
const lastNonSettingsViewRef = React.useRef<MainView>("home");
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const selectView = React.useCallback((view: AppView | MainView) => {
|
||||||
|
React.startTransition(() => {
|
||||||
|
setSelectedView(view);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
const identityQuery = useIdentityQuery();
|
const identityQuery = useIdentityQuery();
|
||||||
const profileQuery = useProfileQuery();
|
const profileQuery = useProfileQuery();
|
||||||
const presenceSession = usePresenceSession(identityQuery.data?.pubkey);
|
const presenceSession = usePresenceSession(identityQuery.data?.pubkey);
|
||||||
@@ -343,12 +349,10 @@ export function AppShell() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (selectedChannel?.id === channelId) {
|
if (selectedChannel?.id === channelId) {
|
||||||
React.startTransition(() => {
|
selectView("home");
|
||||||
setSelectedView("home");
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[hideDmMutation, selectedChannel?.id],
|
[hideDmMutation, selectView, selectedChannel?.id],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleOpenSettings = React.useCallback(
|
const handleOpenSettings = React.useCallback(
|
||||||
@@ -370,10 +374,8 @@ export function AppShell() {
|
|||||||
? "home"
|
? "home"
|
||||||
: lastNonSettingsViewRef.current;
|
: lastNonSettingsViewRef.current;
|
||||||
|
|
||||||
React.startTransition(() => {
|
selectView(nextView);
|
||||||
setSelectedView(nextView);
|
}, [selectView, selectedChannel]);
|
||||||
});
|
|
||||||
}, [selectedChannel]);
|
|
||||||
|
|
||||||
const handleOpenSearchResult = React.useCallback(
|
const handleOpenSearchResult = React.useCallback(
|
||||||
(hit: SearchHit) => {
|
(hit: SearchHit) => {
|
||||||
@@ -631,16 +633,10 @@ export function AppShell() {
|
|||||||
});
|
});
|
||||||
openChannelView(directMessage.id);
|
openChannelView(directMessage.id);
|
||||||
}}
|
}}
|
||||||
onSelectAgents={() => {
|
onSelectAgents={() => selectView("agents")}
|
||||||
React.startTransition(() => {
|
onSelectWorkflows={() => selectView("workflows")}
|
||||||
setSelectedView("agents");
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
onSelectHome={() => {
|
onSelectHome={() => {
|
||||||
React.startTransition(() => {
|
selectView("home");
|
||||||
setSelectedView("home");
|
|
||||||
});
|
|
||||||
|
|
||||||
void homeFeedQuery.refetch();
|
void homeFeedQuery.refetch();
|
||||||
}}
|
}}
|
||||||
onSelectChannel={handleOpenChannel}
|
onSelectChannel={handleOpenChannel}
|
||||||
@@ -664,6 +660,12 @@ export function AppShell() {
|
|||||||
mode="agents"
|
mode="agents"
|
||||||
title="Agents"
|
title="Agents"
|
||||||
/>
|
/>
|
||||||
|
) : selectedView === "workflows" ? (
|
||||||
|
<ChatHeader
|
||||||
|
description="Create, manage, and monitor automated workflows across your channels."
|
||||||
|
mode="workflows"
|
||||||
|
title="Workflows"
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ChatHeader
|
<ChatHeader
|
||||||
actions={
|
actions={
|
||||||
@@ -728,7 +730,18 @@ export function AppShell() {
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={
|
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"
|
? "flex min-h-0 flex-1 flex-col overflow-hidden"
|
||||||
: "hidden"
|
: "hidden"
|
||||||
}
|
}
|
||||||
@@ -805,10 +818,8 @@ export function AppShell() {
|
|||||||
channel={activeChannel}
|
channel={activeChannel}
|
||||||
currentPubkey={identityQuery.data?.pubkey}
|
currentPubkey={identityQuery.data?.pubkey}
|
||||||
onDeleted={() => {
|
onDeleted={() => {
|
||||||
React.startTransition(() => {
|
setIsChannelManagementOpen(false);
|
||||||
setIsChannelManagementOpen(false);
|
selectView("home");
|
||||||
setSelectedView("home");
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
onOpenChange={setIsChannelManagementOpen}
|
onOpenChange={setIsChannelManagementOpen}
|
||||||
open={isChannelManagementOpen && activeChannel !== null}
|
open={isChannelManagementOpen && activeChannel !== null}
|
||||||
|
|||||||
@@ -437,7 +437,7 @@ export function useManagedAgentLogQuery(
|
|||||||
) {
|
) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["managed-agent-log", pubkey, lineCount],
|
queryKey: ["managed-agent-log", pubkey, lineCount],
|
||||||
queryFn: () => getManagedAgentLog(pubkey!, lineCount),
|
queryFn: () => getManagedAgentLog(pubkey as string, lineCount),
|
||||||
enabled: pubkey !== null,
|
enabled: pubkey !== null,
|
||||||
retry: false,
|
retry: false,
|
||||||
staleTime: 3_000,
|
staleTime: 3_000,
|
||||||
|
|||||||
@@ -174,8 +174,9 @@ export function CreateAgentDialog({
|
|||||||
// Initialize config from schema defaults so unchanged defaults
|
// Initialize config from schema defaults so unchanged defaults
|
||||||
// are included in the submit payload (not silently dropped).
|
// are included in the submit payload (not silently dropped).
|
||||||
if (result.config_schema) {
|
if (result.config_schema) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
const props =
|
||||||
const props = (result.config_schema as any)?.properties ?? {};
|
(result.config_schema as Record<string, unknown>)?.properties ??
|
||||||
|
{};
|
||||||
const defaults: Record<string, string> = {};
|
const defaults: Record<string, string> = {};
|
||||||
for (const [key, prop] of Object.entries(props) as [
|
for (const [key, prop] of Object.entries(props) as [
|
||||||
string,
|
string,
|
||||||
@@ -291,9 +292,8 @@ export function CreateAgentDialog({
|
|||||||
// Check provider config required fields are filled.
|
// Check provider config required fields are filled.
|
||||||
const providerConfigComplete = React.useMemo(() => {
|
const providerConfigComplete = React.useMemo(() => {
|
||||||
if (!isProviderMode || !probedProvider?.config_schema) return true;
|
if (!isProviderMode || !probedProvider?.config_schema) return true;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
const schema = probedProvider.config_schema as Record<string, unknown>;
|
||||||
const schema = probedProvider.config_schema as any;
|
const required: string[] = (schema?.required as string[] | undefined) ?? [];
|
||||||
const required: string[] = schema?.required ?? [];
|
|
||||||
return required.every(
|
return required.every(
|
||||||
(key) => (providerConfig[key] ?? "").trim().length > 0,
|
(key) => (providerConfig[key] ?? "").trim().length > 0,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ export function coerceConfigValues(
|
|||||||
schema: Record<string, unknown> | undefined,
|
schema: Record<string, unknown> | undefined,
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
if (!schema) return { ...config };
|
if (!schema) return { ...config };
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
const properties = ((schema as Record<string, unknown>)?.properties ??
|
||||||
const properties = (schema as any)?.properties ?? {};
|
{}) as Record<string, Record<string, unknown>>;
|
||||||
const result: Record<string, unknown> = {};
|
const result: Record<string, unknown> = {};
|
||||||
for (const [key, value] of Object.entries(config)) {
|
for (const [key, value] of Object.entries(config)) {
|
||||||
const prop = properties[key] as Record<string, unknown> | undefined;
|
const prop = properties[key] as Record<string, unknown> | undefined;
|
||||||
@@ -37,10 +37,10 @@ export function ProviderConfigFields({
|
|||||||
config: Record<string, string>;
|
config: Record<string, string>;
|
||||||
onChange: (config: Record<string, string>) => void;
|
onChange: (config: Record<string, string>) => void;
|
||||||
}) {
|
}) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
const properties = (schema as Record<string, unknown>)?.properties ?? {};
|
||||||
const properties = (schema as any)?.properties ?? {};
|
const required = new Set<string>(
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
((schema as Record<string, unknown>)?.required as string[]) ?? [],
|
||||||
const required = new Set<string>((schema as any)?.required ?? []);
|
);
|
||||||
|
|
||||||
const entries = Object.entries(properties) as [
|
const entries = Object.entries(properties) as [
|
||||||
string,
|
string,
|
||||||
|
|||||||
@@ -168,9 +168,8 @@ export function AddChannelBotDialog({
|
|||||||
);
|
);
|
||||||
const providerConfigComplete = React.useMemo(() => {
|
const providerConfigComplete = React.useMemo(() => {
|
||||||
if (!isProviderMode || !probedProvider?.config_schema) return true;
|
if (!isProviderMode || !probedProvider?.config_schema) return true;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
const schema = probedProvider.config_schema as Record<string, unknown>;
|
||||||
const schema = probedProvider.config_schema as any;
|
const required: string[] = (schema?.required as string[] | undefined) ?? [];
|
||||||
const required: string[] = schema?.required ?? [];
|
|
||||||
return required.every(
|
return required.every(
|
||||||
(key) => (providerConfig[key] ?? "").trim().length > 0,
|
(key) => (providerConfig[key] ?? "").trim().length > 0,
|
||||||
);
|
);
|
||||||
@@ -216,8 +215,10 @@ export function AddChannelBotDialog({
|
|||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setProbedProvider(result);
|
setProbedProvider(result);
|
||||||
if (result.config_schema) {
|
if (result.config_schema) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
const props =
|
||||||
const props = (result.config_schema as any)?.properties ?? {};
|
(result.config_schema as Record<string, unknown>)?.properties ??
|
||||||
|
{};
|
||||||
|
|
||||||
const defaults: Record<string, string> = {};
|
const defaults: Record<string, string> = {};
|
||||||
for (const [key, prop] of Object.entries(props) as [
|
for (const [key, prop] of Object.entries(props) as [
|
||||||
string,
|
string,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Home,
|
Home,
|
||||||
Lock,
|
Lock,
|
||||||
Settings2,
|
Settings2,
|
||||||
|
Zap,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type * as React from "react";
|
import type * as React from "react";
|
||||||
|
|
||||||
@@ -17,7 +18,7 @@ type ChatHeaderProps = {
|
|||||||
description: string;
|
description: string;
|
||||||
channelType?: ChannelType;
|
channelType?: ChannelType;
|
||||||
visibility?: ChannelVisibility;
|
visibility?: ChannelVisibility;
|
||||||
mode?: "home" | "channel" | "settings" | "agents";
|
mode?: "home" | "channel" | "settings" | "agents" | "workflows";
|
||||||
statusBadge?: React.ReactNode;
|
statusBadge?: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -28,7 +29,7 @@ function ChannelIcon({
|
|||||||
}: {
|
}: {
|
||||||
channelType?: ChannelType;
|
channelType?: ChannelType;
|
||||||
visibility?: ChannelVisibility;
|
visibility?: ChannelVisibility;
|
||||||
mode?: "home" | "channel" | "settings" | "agents";
|
mode?: "home" | "channel" | "settings" | "agents" | "workflows";
|
||||||
}) {
|
}) {
|
||||||
if (mode === "home") {
|
if (mode === "home") {
|
||||||
return <Home className="h-5 w-5 text-primary" />;
|
return <Home className="h-5 w-5 text-primary" />;
|
||||||
@@ -38,6 +39,10 @@ function ChannelIcon({
|
|||||||
return <Bot className="h-5 w-5 text-primary" />;
|
return <Bot className="h-5 w-5 text-primary" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (mode === "workflows") {
|
||||||
|
return <Zap className="h-5 w-5 text-primary" />;
|
||||||
|
}
|
||||||
|
|
||||||
if (mode === "settings") {
|
if (mode === "settings") {
|
||||||
return <Settings2 className="h-5 w-5 text-primary" />;
|
return <Settings2 className="h-5 w-5 text-primary" />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,9 @@ export const ComposerEmojiPicker = React.memo(function ComposerEmojiPicker({
|
|||||||
>
|
>
|
||||||
<Picker
|
<Picker
|
||||||
data={data}
|
data={data}
|
||||||
onEmojiSelect={(emoji: any) => onEmojiSelect(emoji.native)}
|
onEmojiSelect={(emoji: { native: string }) =>
|
||||||
|
onEmojiSelect(emoji.native)
|
||||||
|
}
|
||||||
theme="auto"
|
theme="auto"
|
||||||
previewPosition="none"
|
previewPosition="none"
|
||||||
skinTonePosition="search"
|
skinTonePosition="search"
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ export function MessageActionBar({
|
|||||||
) : null}
|
) : null}
|
||||||
<Picker
|
<Picker
|
||||||
data={data}
|
data={data}
|
||||||
onEmojiSelect={(emoji: any) => {
|
onEmojiSelect={(emoji: { native: string }) => {
|
||||||
if (!onReactionSelect) {
|
if (!onReactionSelect) {
|
||||||
return;
|
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 * as React from "react";
|
||||||
|
|
||||||
import { useManagedAgentsQuery } from "@/features/agents/hooks";
|
import { useManagedAgentsQuery } from "@/features/agents/hooks";
|
||||||
@@ -61,7 +61,7 @@ type AppSidebarProps = {
|
|||||||
selfPresenceStatus: PresenceStatus;
|
selfPresenceStatus: PresenceStatus;
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
selectedChannelId: string | null;
|
selectedChannelId: string | null;
|
||||||
selectedView: "home" | "channel" | "settings" | "agents";
|
selectedView: "home" | "channel" | "settings" | "agents" | "workflows";
|
||||||
unreadChannelIds: Set<string>;
|
unreadChannelIds: Set<string>;
|
||||||
onCreateChannel: (input: {
|
onCreateChannel: (input: {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -79,6 +79,7 @@ type AppSidebarProps = {
|
|||||||
onHideDm: (channelId: string) => void;
|
onHideDm: (channelId: string) => void;
|
||||||
onOpenDm: (input: { pubkeys: string[] }) => Promise<void>;
|
onOpenDm: (input: { pubkeys: string[] }) => Promise<void>;
|
||||||
onSelectAgents: () => void;
|
onSelectAgents: () => void;
|
||||||
|
onSelectWorkflows: () => void;
|
||||||
onSelectHome: () => void;
|
onSelectHome: () => void;
|
||||||
onSelectChannel: (channelId: string) => void;
|
onSelectChannel: (channelId: string) => void;
|
||||||
onSelectSettings: () => void;
|
onSelectSettings: () => void;
|
||||||
@@ -443,6 +444,7 @@ export function AppSidebar({
|
|||||||
onHideDm,
|
onHideDm,
|
||||||
onOpenDm,
|
onOpenDm,
|
||||||
onSelectAgents,
|
onSelectAgents,
|
||||||
|
onSelectWorkflows,
|
||||||
onSelectHome,
|
onSelectHome,
|
||||||
onSelectChannel,
|
onSelectChannel,
|
||||||
onSelectSettings,
|
onSelectSettings,
|
||||||
@@ -539,6 +541,18 @@ export function AppSidebar({
|
|||||||
</SidebarMenuBadge>
|
</SidebarMenuBadge>
|
||||||
) : null}
|
) : null}
|
||||||
</SidebarMenuItem>
|
</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>
|
</SidebarMenu>
|
||||||
</SidebarHeader>
|
</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[];
|
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 ───────────────────────────────────────────────────────────────
|
// ── Forum types ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export type ThreadSummary = {
|
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