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:
MechaCat02
2026-06-01 22:17:25 +02:00
parent 77b2cb58bb
commit 20f1b5e64d
6 changed files with 553 additions and 22 deletions

View File

@@ -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,