fix(stage-6): dashboard hardening + audit Lows cherry-pick

Closes 4 dashboard hardening findings and 5 of the Lows from the audit.

Dashboard hardening:
- Subtabs no longer re-fetch the app via api.apps.get on every page
  load. users/files/dead-letters drop the fetch outright (the variable
  was set but never read); queues + queues/[name] now consume the
  layout's AppContext via getContext for the page title. The layout's
  reloadApp() owns the historical-slug redirect — subtab-local redirect
  blocks are removed so there's no race.
- The global :global(details > summary::before) chevron is now scoped
  to details.chevron. The script editor's "Advanced sandbox" details
  and the inbound-email-shape help-text both opt in; the script
  exec-list logs no longer inherit a spurious chevron.
- deriveTab now matches the path segment by anchored ===, so a future
  /apps/<slug>/queues-archived route wouldn't activate the queues tab.

Lows cherry-pick:
- ExecError gains Serialize/Deserialize derives + a snake_case tag so
  RemoteExecutorClient (cluster mode v1.3+) can round-trip the variant.
- triggers_api rejects queue triggers whose visibility_timeout_secs is
  below the dispatcher's per-message executor budget; with no minimum
  the reclaim task races the handler and the queue silently
  double-delivers. Existing test using 5s updated to 30s.
- New migration 0040: execution_logs.script_id cascade switched from
  ON DELETE CASCADE to ON DELETE SET NULL so deleting a script no
  longer wipes the forensic history that motivated the delete.
- New migration 0041: dead_letters composite index on
  (app_id, created_at DESC) so the "list all" dashboard view stops
  falling back to seqscan + sort when unresolved=false.
- Schema snapshot re-blessed.

Deferred to v1.2: the ExecRequest principal serde(skip) marker
(documented in-place; the cluster-mode PR will introduce the wire-safe
snapshot at that point) and the `pic --help` mention of
`picloud admin reset-password` (one-line follow-up).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-10 21:50:58 +02:00
parent 24490d5ddb
commit 05ed9b00bb
15 changed files with 119 additions and 97 deletions

View File

@@ -34,6 +34,12 @@ use crate::trigger_repo::{
TriggerRepo, TriggerRepoError,
};
/// Minimum allowed queue visibility timeout. Anything shorter races the
/// dispatcher's per-message executor budget; reclaim would re-deliver
/// the message before the handler returned. Picked to outlast a slow
/// handler by a comfortable margin.
const MIN_QUEUE_VISIBILITY_TIMEOUT_SECS: u32 = 30;
#[derive(Clone)]
pub struct TriggersState {
pub triggers: Arc<dyn TriggerRepo>,
@@ -567,6 +573,21 @@ async fn create_queue_trigger(
"queue_name must not be empty".into(),
));
}
// Reject obviously-too-short visibility timeouts. The dispatcher
// budgets up to ~5 minutes of executor wall-clock per message
// (DEFAULT_ASYNC_EXEC_TIMEOUT in dispatcher.rs); a 10s visibility
// timeout would race the executor and cause the reclaim task to
// re-deliver the message before the first handler returned, so the
// queue would silently double-deliver. The audit's Low finding.
if let Some(secs) = input.visibility_timeout_secs {
if secs < MIN_QUEUE_VISIBILITY_TIMEOUT_SECS {
return Err(TriggersApiError::Invalid(format!(
"visibility_timeout_secs must be >= {MIN_QUEUE_VISIBILITY_TIMEOUT_SECS} \
(shorter than the dispatcher's per-message executor budget; \
reclaim would race the handler)"
)));
}
}
validate_trigger_target(&*s.scripts, app_id, input.script_id).await?;
let req = crate::trigger_repo::CreateQueueTrigger {