Files
PiCloud/crates/picloud-cli/src/cmds/dead_letters.rs
MechaCat02 b0f7b72dd6 fix(review): close 5 follow-up gaps from Stage 6 audit re-review
- F-T-003 actually closed: clients/typescript/src/subscribe.ts now
  bounds the 401-refresh loop at 3 consecutive refusals, surfaces an
  onError describing the loop, and resets on any successful stream
  open. New test covers the cap. The Stage 2 dashboard fix only
  addressed adminRequest in dashboard/src/lib/api.ts; the originally-
  cited TS client file was untouched.

- users/invitations subtab dropped the redundant api.apps.get fetch
  the other 5 subtabs already shed in Stage 6.

- Queue visibility-timeout validator pulled out as
  validate_queue_visibility_timeout, with two thresholds: hard-reject
  below MIN_QUEUE_VISIBILITY_TIMEOUT_SECS (30s — catches typos), warn-
  log between MIN and SAFE_VISIBILITY_VS_EXEC_BUDGET_SECS (300s) so
  operators see when their visibility is below the dispatcher's
  per-message executor budget. Stage 6 only had the hard floor; the
  reviewer caught that a 60s handler still races a 30s visibility
  even after the floor. Four new unit tests cover none/above-safe/
  between/below-min.

- pic dead-letters count subcommand: cheap headless probe for
  unresolved DL totals, parallels the dashboard's badge query.

- pic dead-letters replay now has a happy-path integration test
  (replay_against_real_dl_row_succeeds): inserts a synthetic DL row
  directly via the rust-postgres sync driver (added as dev-dep),
  drives `pic dead-letters replay`, asserts the row is resolved with
  reason=replayed and count drops back to 0. Plus a count smoke test.

All 75 CLI integration tests + 16 TS client tests + 4 new
visibility-timeout unit tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-11 17:20:34 +02:00

108 lines
3.4 KiB
Rust

//! `pic dead-letters` subcommands: `ls`, `show`, `replay`, `resolve`.
use anyhow::Result;
use crate::client::Client;
use crate::config;
use crate::output::{KvBlock, OutputMode, Table};
/// `pic dead-letters count --app <slug>` — print the unresolved DL
/// count for an app. Useful for headless probes (alerting / dashboards).
pub async fn count(app: &str, mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let resp = client.dead_letters_count(app).await?;
let mut table = Table::new(["unresolved"]);
table.row([resp.unresolved.to_string()]);
table.print(mode);
Ok(())
}
pub async fn ls(app: &str, unresolved: bool, limit: u32, mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let resp = client.dead_letters_list(app, unresolved, limit).await?;
let mut table = Table::new([
"id",
"source",
"op",
"attempts",
"resolved",
"last_error",
"created_at",
]);
for d in resp.dead_letters {
let last_err = truncate(&d.last_error, 60);
let resolved = d.resolution.unwrap_or_else(|| "-".into());
table.row([
d.id,
d.source,
d.op,
d.attempt_count.to_string(),
resolved,
last_err,
d.created_at.to_rfc3339(),
]);
}
table.print(mode);
Ok(())
}
pub async fn show(app: &str, dl_id: &str, mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let d = client.dead_letters_get(app, dl_id).await?;
let mut block = KvBlock::new();
block
.field("id", d.id)
.field("source", d.source)
.field("op", d.op)
.field("trigger_id", d.trigger_id.unwrap_or_else(|| "-".into()))
.field(
"script_id",
d.script_id
.map(|s| s.to_string())
.unwrap_or_else(|| "-".into()),
)
.field("attempt_count", d.attempt_count.to_string())
.field("first_attempt_at", d.first_attempt_at.to_rfc3339())
.field("last_attempt_at", d.last_attempt_at.to_rfc3339())
.field("last_error", d.last_error)
.field("created_at", d.created_at.to_rfc3339())
.field(
"resolved_at",
d.resolved_at
.map(|t| t.to_rfc3339())
.unwrap_or_else(|| "-".into()),
)
.field("resolution", d.resolution.unwrap_or_else(|| "-".into()))
.field("payload", d.payload.to_string());
block.print(mode);
Ok(())
}
pub async fn replay(app: &str, dl_id: &str) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
client.dead_letters_replay(app, dl_id).await?;
println!("Replayed dead-letter {dl_id}");
Ok(())
}
pub async fn resolve(app: &str, dl_id: &str, reason: &str) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
client.dead_letters_resolve(app, dl_id, reason).await?;
println!("Resolved dead-letter {dl_id}: {reason}");
Ok(())
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_string();
}
let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
out.push('…');
out
}