- 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>
108 lines
3.4 KiB
Rust
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
|
|
}
|