feat(v1.1.9): admin HTTP endpoints — queue triggers + read-only queues

triggers_api.rs adds:
  POST /api/v1/admin/apps/{id}/triggers/queue
    body: { script_id, queue_name, visibility_timeout_secs?,
            dispatch_mode?, retry_max_attempts?, retry_backoff?,
            retry_base_ms? }
    cap: AppManageTriggers
    409-equiv via the repo's "queue 'X' already has a consumer" error
    (TriggerRepoError::Invalid → 422 Invalid in the existing mapping)

queues_api.rs (new) adds read-only inspection endpoints:
  GET /api/v1/admin/apps/{id}/queues
    -> [{queue_name, total, pending, claimed}]

  GET /api/v1/admin/apps/{id}/queues/{queue_name}
    -> {queue_name, total, pending, claimed,
        consumer: Some(QueueConsumer) | None}

  QueueConsumer = { trigger_id, script_id, script_name,
                    visibility_timeout_secs, last_fired_at }

Capability: AppLogRead (same trust tier as execution-log read — these
are read-only operational views, not config changes).

No mutating queue endpoints — enqueue lives on the SDK side; purge /
requeue / delete-message stays at v1.2.

picloud/lib.rs wires the queues router into the guarded /admin chain.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-06 22:36:50 +02:00
parent e142d471e1
commit 714f6dae18
4 changed files with 278 additions and 1 deletions

View File

@@ -0,0 +1,205 @@
//! v1.1.9. Read-only admin endpoints for queues.
//!
//! - `GET /apps/{app_id}/queues` — list all queue names in the app
//! with `(total, pending, claimed)` counts.
//! - `GET /apps/{app_id}/queues/{queue_name}` — per-queue drill-down:
//! stats + the registered consumer trigger (if any).
//!
//! No mutating endpoints — purge / requeue is v1.2.
//! Capability: `AppLogRead` (read-only inspection of operational state,
//! same tier as execution logs).
use std::sync::Arc;
use axum::{
extract::{Path, State},
routing::get,
Extension, Json, Router,
};
use picloud_shared::{AppId, Principal};
use serde::Serialize;
use crate::app_repo::AppRepository;
use crate::authz::{self, AuthzRepo, Capability};
use crate::queue_repo::{QueueRepo, QueueStats};
use crate::repo::ScriptRepository;
use crate::trigger_repo::TriggerRepo;
#[derive(Clone)]
pub struct QueuesState {
pub queues: Arc<dyn QueueRepo>,
pub triggers: Arc<dyn TriggerRepo>,
pub apps: Arc<dyn AppRepository>,
pub authz: Arc<dyn AuthzRepo>,
pub scripts: Arc<dyn ScriptRepository>,
}
#[derive(Debug, thiserror::Error)]
pub enum QueuesApiError {
#[error("forbidden")]
Forbidden,
#[error("app not found")]
AppNotFound,
#[error("repo: {0}")]
Repo(String),
}
impl axum::response::IntoResponse for QueuesApiError {
fn into_response(self) -> axum::response::Response {
let status = match self {
Self::Forbidden => axum::http::StatusCode::FORBIDDEN,
Self::AppNotFound => axum::http::StatusCode::NOT_FOUND,
Self::Repo(_) => axum::http::StatusCode::INTERNAL_SERVER_ERROR,
};
(status, self.to_string()).into_response()
}
}
pub fn queues_router(state: QueuesState) -> Router {
Router::new()
.route("/apps/{app_id}/queues", get(list_queues))
.route("/apps/{app_id}/queues/{queue_name}", get(get_queue))
.with_state(state)
}
#[derive(Debug, Serialize)]
pub struct QueueSummary {
pub queue_name: String,
pub total: u64,
pub pending: u64,
pub claimed: u64,
}
#[derive(Debug, Serialize)]
pub struct QueueDetail {
pub queue_name: String,
pub total: u64,
pub pending: u64,
pub claimed: u64,
/// `Some` if a queue:receive trigger is registered for this queue.
pub consumer: Option<QueueConsumer>,
}
#[derive(Debug, Serialize)]
pub struct QueueConsumer {
pub trigger_id: picloud_shared::TriggerId,
pub script_id: picloud_shared::ScriptId,
pub script_name: String,
pub visibility_timeout_secs: u32,
pub last_fired_at: Option<chrono::DateTime<chrono::Utc>>,
}
async fn list_queues(
State(s): State<QueuesState>,
Extension(principal): Extension<Principal>,
Path(app_id): Path<AppId>,
) -> Result<Json<Vec<QueueSummary>>, QueuesApiError> {
ensure_app(&*s.apps, app_id).await?;
require_log_read(&*s.authz, &principal, app_id).await?;
let rows = s
.queues
.list_for_app(app_id)
.await
.map_err(|e| QueuesApiError::Repo(e.to_string()))?;
Ok(Json(
rows.into_iter()
.map(|(name, stats)| QueueSummary {
queue_name: name,
total: stats.total,
pending: stats.pending,
claimed: stats.claimed,
})
.collect(),
))
}
async fn get_queue(
State(s): State<QueuesState>,
Extension(principal): Extension<Principal>,
Path((app_id, queue_name)): Path<(AppId, String)>,
) -> Result<Json<QueueDetail>, QueuesApiError> {
ensure_app(&*s.apps, app_id).await?;
require_log_read(&*s.authz, &principal, app_id).await?;
let total = s
.queues
.depth(app_id, &queue_name)
.await
.map_err(|e| QueuesApiError::Repo(e.to_string()))?;
let pending = s
.queues
.depth_pending(app_id, &queue_name)
.await
.map_err(|e| QueuesApiError::Repo(e.to_string()))?;
// Claimed = total - pending - (delayed-not-yet-due). The delayed
// portion can't be teased apart without an extra query; we report
// (total - pending) as a useful approximation. (For v1.2 we can
// surface a third count via list_for_app's QueueStats which has it.)
let claimed = total.saturating_sub(pending);
// Find a registered consumer for this queue.
let consumers = s
.triggers
.list_active_queue_consumers()
.await
.map_err(|e| QueuesApiError::Repo(e.to_string()))?;
let consumer_record = consumers
.into_iter()
.find(|c| c.app_id == app_id && c.queue_name == queue_name);
let consumer = if let Some(c) = consumer_record {
let script = s
.scripts
.get(c.script_id)
.await
.map_err(|e| QueuesApiError::Repo(e.to_string()))?;
let script_name = script.map_or_else(|| "<missing>".to_string(), |s| s.name);
// Pull last_fired_at from the trigger row (it's on the detail
// table — exposed via `Trigger.details` / TriggerDetails::Queue).
let trigger = s
.triggers
.get(c.trigger_id)
.await
.map_err(|e| QueuesApiError::Repo(e.to_string()))?;
let last_fired_at = match trigger.map(|t| t.details) {
Some(crate::trigger_repo::TriggerDetails::Queue { last_fired_at, .. }) => last_fired_at,
_ => None,
};
Some(QueueConsumer {
trigger_id: c.trigger_id,
script_id: c.script_id,
script_name,
visibility_timeout_secs: c.visibility_timeout_secs,
last_fired_at,
})
} else {
None
};
let _ = QueueStats::default();
Ok(Json(QueueDetail {
queue_name,
total,
pending,
claimed,
consumer,
}))
}
async fn ensure_app(apps: &dyn AppRepository, app_id: AppId) -> Result<(), QueuesApiError> {
apps.get_by_id(app_id)
.await
.map_err(|e| QueuesApiError::Repo(e.to_string()))?
.ok_or(QueuesApiError::AppNotFound)?;
Ok(())
}
async fn require_log_read(
authz: &dyn AuthzRepo,
principal: &Principal,
app_id: AppId,
) -> Result<(), QueuesApiError> {
authz::require(authz, principal, Capability::AppLogRead(app_id))
.await
.map_err(|_| QueuesApiError::Forbidden)?;
Ok(())
}