feat(v1.1.1-dead-letters): service + Rhai SDK + admin endpoints
`PostgresDeadLetterService` lands as the real `DeadLetterService`
impl, replacing `NoopDeadLetterService` in the picloud binary's
`Services` bundle. Both methods are gated by
`Capability::AppDeadLetterManage(AppId)` — public-HTTP scripts with
`principal: None` fail the check, per design notes §4.
- `dead_letters::replay(id)` (Rhai SDK + admin endpoint): re-inserts
the original event payload into the outbox with attempt_count=0,
reply_to=None. The DL row is marked `resolution='replayed'`.
- `dead_letters::resolve(id, reason)` (Rhai SDK + admin endpoint):
closes the row with `resolved_at = NOW()` and the given reason.
CHECK constraint on the column enforces the 4-value vocabulary.
- `dead_letters::list(filter)` is intentionally NOT shipped —
design notes §4 defers it to v1.2 to align with the eventual
`docs::find()` query DSL.
Admin endpoints under `/api/v1/admin/apps/{id}/dead_letters/*`:
- `GET /` (with `?unresolved=true`) → list view
- `GET /count` → unresolved-count badge
- `GET /{dl_id}` → row detail (full payload + error)
- `POST /{dl_id}/replay` → re-enqueue
- `POST /{dl_id}/resolve` body `{reason}` → close out
All cross-app-aware: the row's `app_id` is compared against the path
param so a caller with rights on app A cannot manipulate app B's
dead letters by id alone.
The Rhai bridge for `dead_letters::*` follows the same sync↔async
pattern as the `kv::` bridge (`Handle::current().block_on(...)`
inside the spawn_blocking-wrapped Rhai engine).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,14 +11,15 @@ use axum::{routing::get, Json, Router};
|
||||
use picloud_executor_core::{Engine, Limits};
|
||||
use picloud_manager_core::{
|
||||
admin_router, admins_router, api_keys_router, app_members_router, apps_api, apps_router,
|
||||
attach_principal_if_present, auth_router, compile_routes, migrations, require_authenticated,
|
||||
route_admin_router, triggers_router, AbandonedRepo, AdminPrincipalResolver,
|
||||
AdminSessionRepository, AdminState, AdminUserRepository, AdminsState, ApiKeyRepository,
|
||||
ApiKeysState, AppDomainRepository, AppMembersRepository, AppMembersState, AppRepository,
|
||||
AppsState, AuthState, AuthzRepo, DeadLetterRepo, Dispatcher, KvServiceImpl, OutboxEventEmitter,
|
||||
OutboxRepo, PostgresAbandonedRepo, PostgresAdminSessionRepository, PostgresAdminUserRepository,
|
||||
PostgresApiKeyRepository, PostgresAppDomainRepository, PostgresAppMembersRepository,
|
||||
PostgresAppRepository, PostgresDeadLetterRepo, PostgresExecutionLogRepository,
|
||||
attach_principal_if_present, auth_router, compile_routes, dead_letters_router, migrations,
|
||||
require_authenticated, route_admin_router, triggers_router, AbandonedRepo,
|
||||
AdminPrincipalResolver, AdminSessionRepository, AdminState, AdminUserRepository, AdminsState,
|
||||
ApiKeyRepository, ApiKeysState, AppDomainRepository, AppMembersRepository, AppMembersState,
|
||||
AppRepository, AppsState, AuthState, AuthzRepo, DeadLetterRepo, DeadLettersState, Dispatcher,
|
||||
KvServiceImpl, OutboxEventEmitter, OutboxRepo, PostgresAbandonedRepo,
|
||||
PostgresAdminSessionRepository, PostgresAdminUserRepository, PostgresApiKeyRepository,
|
||||
PostgresAppDomainRepository, PostgresAppMembersRepository, PostgresAppRepository,
|
||||
PostgresDeadLetterRepo, PostgresDeadLetterService, PostgresExecutionLogRepository,
|
||||
PostgresExecutionLogSink, PostgresKvRepo, PostgresOutboxRepo, PostgresRouteRepository,
|
||||
PostgresScriptRepository, PostgresTriggerRepo, PrincipalResolver, RepoResolver,
|
||||
RouteAdminState, RouteRepository, SandboxCeiling, ScriptRepository, TriggerConfig, TriggerRepo,
|
||||
@@ -30,9 +31,8 @@ use picloud_orchestrator_core::{
|
||||
LocalExecutorClient,
|
||||
};
|
||||
use picloud_shared::{
|
||||
ExecutionLogSink, InboxResolver, KvService, NoopDeadLetterService, OutboxWriter,
|
||||
ScriptValidator, ServiceEventEmitter, Services, API_VERSION, PRODUCT_VERSION, SDK_VERSION,
|
||||
WIRE_VERSION,
|
||||
DeadLetterService, ExecutionLogSink, InboxResolver, KvService, OutboxWriter, ScriptValidator,
|
||||
ServiceEventEmitter, Services, API_VERSION, PRODUCT_VERSION, SDK_VERSION, WIRE_VERSION,
|
||||
};
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use sqlx::PgPool;
|
||||
@@ -119,10 +119,9 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
|
||||
let abandoned_repo: Arc<dyn AbandonedRepo> = Arc::new(PostgresAbandonedRepo::new(pool.clone()));
|
||||
let trigger_config = TriggerConfig::from_env();
|
||||
|
||||
// SDK services bundle. v1.1.1 ships the KV store and the
|
||||
// outbox-backed event emitter; `NoopDeadLetterService` is a v1.1.1
|
||||
// stub that errors loudly until the real `PostgresDeadLetterService`
|
||||
// ships (commit 8).
|
||||
// SDK services bundle. v1.1.1 ships the KV store + the
|
||||
// outbox-backed event emitter + the dead-letter service (replay /
|
||||
// resolve).
|
||||
let kv_repo = Arc::new(PostgresKvRepo::new(pool));
|
||||
let events: Arc<dyn ServiceEventEmitter> = Arc::new(OutboxEventEmitter::new(
|
||||
trigger_repo.clone(),
|
||||
@@ -130,7 +129,12 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
|
||||
));
|
||||
let kv: Arc<dyn KvService> =
|
||||
Arc::new(KvServiceImpl::new(kv_repo, authz.clone(), events.clone()));
|
||||
let services = Services::new(kv, Arc::new(NoopDeadLetterService), events);
|
||||
let dl_service: Arc<dyn DeadLetterService> = Arc::new(PostgresDeadLetterService::new(
|
||||
dl_repo.clone(),
|
||||
outbox_repo.clone(),
|
||||
authz.clone(),
|
||||
));
|
||||
let services = Services::new(kv, dl_service.clone(), events);
|
||||
let engine = Arc::new(Engine::new(Limits::default(), services));
|
||||
|
||||
// Compile the routes table once at startup; admin writes refresh it.
|
||||
@@ -216,16 +220,20 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
|
||||
inbox: inbox_registry,
|
||||
outbox: outbox_writer,
|
||||
};
|
||||
// Silence unused-import warnings for repos handed to the
|
||||
// dispatcher in this commit; commit 8 wires them into the
|
||||
// dead-letters admin endpoints and commit 10 into the GC sweeper.
|
||||
let _ = (&dl_repo, &abandoned_repo);
|
||||
// Commit 10 wires abandoned_repo into the GC sweeper.
|
||||
let _ = &abandoned_repo;
|
||||
let triggers_state = TriggersState {
|
||||
triggers: trigger_repo,
|
||||
apps: apps_repo.clone(),
|
||||
authz: authz.clone(),
|
||||
config: trigger_config,
|
||||
};
|
||||
let dead_letters_state = DeadLettersState {
|
||||
repo: dl_repo,
|
||||
service: dl_service,
|
||||
apps: apps_repo.clone(),
|
||||
authz: authz.clone(),
|
||||
};
|
||||
let apps_state = AppsState {
|
||||
apps: apps_repo,
|
||||
domains: domains_repo,
|
||||
@@ -268,6 +276,7 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
|
||||
.merge(app_members_router(app_members_state))
|
||||
.merge(api_keys_router(api_keys_state))
|
||||
.merge(triggers_router(triggers_state))
|
||||
.merge(dead_letters_router(dead_letters_state))
|
||||
.layer(from_fn_with_state(
|
||||
auth_state.clone(),
|
||||
require_authenticated,
|
||||
|
||||
Reference in New Issue
Block a user