Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions crates/sprout-db/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1126,6 +1126,15 @@ impl Db {
workflow::get_approval(&self.pool, token).await
}

/// Fetch all approvals for a workflow run.
pub async fn get_run_approvals(
&self,
workflow_id: uuid::Uuid,
run_id: uuid::Uuid,
) -> Result<Vec<workflow::ApprovalRecord>> {
workflow::get_run_approvals(&self.pool, workflow_id, run_id).await
}

/// Update an approval's status.
pub async fn update_approval(
&self,
Expand Down
23 changes: 23 additions & 0 deletions crates/sprout-db/src/workflow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -661,6 +661,29 @@ pub async fn get_approval(pool: &PgPool, token: &str) -> Result<ApprovalRecord>
row_to_approval_record(row)
}

/// Fetch all approval records for a given workflow run.
pub async fn get_run_approvals(
pool: &PgPool,
workflow_id: Uuid,
run_id: Uuid,
) -> Result<Vec<ApprovalRecord>> {
let rows = sqlx::query(
r#"
SELECT token, workflow_id, run_id, step_id, step_index, approver_spec,
status::text AS status, approver_pubkey, note, expires_at, created_at
FROM workflow_approvals
WHERE run_id = $1 AND workflow_id = $2
ORDER BY step_index, created_at
"#,
)
.bind(run_id)
.bind(workflow_id)
.fetch_all(pool)
.await?;

rows.into_iter().map(row_to_approval_record).collect()
}

/// Update an approval's status, approver pubkey, and optional note.
/// Also stamps `granted_at` or `denied_at` based on the new status.
///
Expand Down
4 changes: 2 additions & 2 deletions crates/sprout-relay/src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,8 @@ pub use users::{
get_profile, get_user_profile, get_users_batch, put_channel_add_policy, search_users,
};
pub use workflows::{
create_workflow, delete_workflow, get_workflow, list_channel_workflows, list_workflow_runs,
trigger_workflow, update_workflow, workflow_webhook,
create_workflow, delete_workflow, get_workflow, list_channel_workflows, list_run_approvals,
list_workflow_runs, trigger_workflow, update_workflow, workflow_webhook,
};

// ── Shared helpers ────────────────────────────────────────────────────────────
Expand Down
19 changes: 19 additions & 0 deletions crates/sprout-relay/src/api/workflow_helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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": a.token,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Return a usable approval token in run approvals API

The new run-approvals response is serializing ApprovalRecord.token directly, but that field is the DB-stored hashed token (the raw token is hashed before insert in sprout-db::workflow::create_approval). The UI then passes this value to POST /api/approvals/{token}/grant|deny, which hashes the path token again for lookup, so approvals fetched from this endpoint cannot be granted/denied and will consistently fail as not found/conflict. This blocks the primary approval action flow added by this change.

Useful? React with 👍 / 👎.

"workflow_id": a.workflow_id.to_string(),
"run_id": a.run_id.to_string(),
"step_id": a.step_id,
"step_index": a.step_index,
"approver_spec": a.approver_spec,
"status": a.status.to_string(),
"approver_pubkey": a.approver_pubkey.as_ref().map(nostr_hex::encode),
"note": a.note,
"expires_at": a.expires_at.to_rfc3339(),
"created_at": a.created_at.timestamp(),
})
}

// ── SSRF prevention ───────────────────────────────────────────────────────────

/// Validate all CallWebhook URLs in a workflow definition.
Expand Down
43 changes: 41 additions & 2 deletions crates/sprout-relay/src/api/workflows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ use serde::Deserialize;
use crate::state::AppState;

use super::workflow_helpers::{
definition_hash, ensure_webhook_secret, run_record_to_json, spawn_workflow_execution,
validate_webhook_urls, workflow_record_to_json,
approval_record_to_json, definition_hash, ensure_webhook_secret, run_record_to_json,
spawn_workflow_execution, validate_webhook_urls, workflow_record_to_json,
};
use super::{
api_error, check_channel_access, check_token_channel_access, extract_auth_context, forbidden,
Expand Down Expand Up @@ -358,6 +358,45 @@ pub async fn list_workflow_runs(
Ok(Json(serde_json::json!(result)))
}

// ── GET /api/workflows/:id/runs/:run_id/approvals ────────────────────────────

/// List all approval records for a workflow run.
pub async fn list_run_approvals(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path((id_str, run_id_str)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let ctx = extract_auth_context(&headers, &state).await?;
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsRead)
.map_err(scope_error)?;
let pubkey_bytes = ctx.pubkey_bytes.clone();

let id = uuid::Uuid::parse_str(&id_str)
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid workflow UUID"))?;
let run_id = uuid::Uuid::parse_str(&run_id_str)
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid run UUID"))?;

let workflow = state
.db
.get_workflow(id)
.await
.map_err(|_| not_found("workflow not found"))?;

if let Some(channel_id) = workflow.channel_id {
check_token_channel_access(&ctx, &channel_id)?;
check_channel_access(&state, channel_id, &pubkey_bytes).await?;
}
Comment on lines +385 to +390

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Enforce ownership for workflows without channel scope

This endpoint checks membership only when workflow.channel_id is present, but it has no fallback authorization check for workflows where channel_id is NULL. Unlike get_workflow, that means any caller with channels:read can query approvals for channel-less workflows if they know the IDs. Please mirror the owner check used in get_workflow so non-channel workflows remain private to their owner.

Useful? React with 👍 / 👎.


let approvals = state
.db
.get_run_approvals(id, run_id)
.await
.map_err(|e| internal_error(&format!("db error: {e}")))?;

let result: Vec<serde_json::Value> = approvals.iter().map(approval_record_to_json).collect();
Ok(Json(serde_json::json!(result)))
}

// ── POST /api/workflows/:id/trigger ──────────────────────────────────────────

/// Manually trigger a workflow. Returns 202 Accepted; execution is async.
Expand Down
4 changes: 4 additions & 0 deletions crates/sprout-relay/src/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,10 @@ pub fn build_router(state: Arc<AppState>) -> Router {
.delete(api::delete_workflow),
)
.route("/api/workflows/{id}/runs", get(api::list_workflow_runs))
.route(
"/api/workflows/{id}/runs/{run_id}/approvals",
get(api::list_run_approvals),
)
.route("/api/workflows/{id}/trigger", post(api::trigger_workflow))
.route("/api/workflows/{id}/webhook", post(api::workflow_webhook))
.route("/api/approvals/{token}/grant", post(api::grant_approval))
Expand Down
4 changes: 2 additions & 2 deletions desktop/scripts/check-file-sizes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ const rules = [
const overrides = new Map([
["src-tauri/src/managed_agents/personas.rs", 600], // built-in persona system prompts (Solo, Ralph, Strategist) are long string literals
["src-tauri/src/managed_agents/persona_card.rs", 772], // PNG/ZIP persona card codec + provider/model fields + 27 unit tests (~350 lines of tests); rustfmt adds line breaks around long literals/builders
["src/app/AppShell.tsx", 820], // message edit state + handlers + ChannelPane edit prop threading + scrollback pagination
["src/app/AppShell.tsx", 840], // message edit state + handlers + ChannelPane edit prop threading + scrollback pagination + workflows view
["src/features/channels/hooks.ts", 550], // canvas query + mutation hooks + DM hide mutation
["src/features/channels/ui/ChannelManagementSheet.tsx", 800],
["src/features/messages/hooks.ts", 500], // message query/mutation hooks + optimistic updates
Expand All @@ -47,7 +47,7 @@ const overrides = new Map([
["src/features/agents/ui/AgentsView.tsx", 790], // remote agent stop/delete + channel UUID resolution + presence-aware delete guard + persona/team import + provider/model fields
["src/features/agents/ui/CreateAgentDialog.tsx", 685], // provider selector + config form + schema-typed config coercion + required field validation + locked scopes
["src/features/channels/ui/AddChannelBotDialog.tsx", 640], // provider mode: Run on selector, trust warning, probe effect, single-agent enforcement, provider warnings display
["src/shared/api/types.ts", 515], // persona provider/model fields + forum types
["src/shared/api/types.ts", 525], // persona provider/model fields + forum types + workflow type re-exports
]);

async function walkFiles(directory) {
Expand Down
2 changes: 2 additions & 0 deletions desktop/src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ mod personas;
mod profile;
mod teams;
mod tokens;
mod workflows;

pub use agent_discovery::*;
pub use agent_models::*;
Expand All @@ -28,3 +29,4 @@ pub use personas::*;
pub use profile::*;
pub use teams::*;
pub use tokens::*;
pub use workflows::*;
143 changes: 143 additions & 0 deletions desktop/src-tauri/src/commands/workflows.rs
Original file line number Diff line number Diff line change
@@ -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/{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/{token}/deny");
let request = build_authed_request(&state.http_client, Method::POST, &path, &state)?
.json(&ApprovalBody { note });
send_json_request(request).await
}
10 changes: 10 additions & 0 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,16 @@ pub fn run() {
parse_team_file,
parse_persona_files,
export_persona_to_json,
get_channel_workflows,
get_workflow,
create_workflow,
update_workflow,
delete_workflow,
get_workflow_runs,
get_run_approvals,
trigger_workflow,
grant_approval,
deny_approval,
])
.build(tauri::generate_context!())
.expect("error while building tauri application");
Expand Down
Loading
Loading