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>
This commit is contained in:
MechaCat02
2026-06-11 17:20:34 +02:00
parent 05ed9b00bb
commit b0f7b72dd6
10 changed files with 628 additions and 77 deletions

368
Cargo.lock generated
View File

@@ -8,7 +8,7 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [ dependencies = [
"crypto-common", "crypto-common 0.1.7",
"generic-array", "generic-array",
] ]
@@ -20,7 +20,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cipher", "cipher",
"cpufeatures", "cpufeatures 0.2.17",
] ]
[[package]] [[package]]
@@ -139,7 +139,7 @@ checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
dependencies = [ dependencies = [
"base64ct", "base64ct",
"blake2", "blake2",
"cpufeatures", "cpufeatures 0.2.17",
"password-hash", "password-hash",
] ]
@@ -324,7 +324,7 @@ version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [ dependencies = [
"digest", "digest 0.10.7",
] ]
[[package]] [[package]]
@@ -336,6 +336,15 @@ dependencies = [
"generic-array", "generic-array",
] ]
[[package]]
name = "block-buffer"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be"
dependencies = [
"hybrid-array",
]
[[package]] [[package]]
name = "bstr" name = "bstr"
version = "1.12.1" version = "1.12.1"
@@ -399,6 +408,17 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chacha20"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"rand_core 0.10.1",
]
[[package]] [[package]]
name = "chrono" name = "chrono"
version = "0.4.44" version = "0.4.44"
@@ -421,7 +441,7 @@ checksum = "93698b29de5e97ad0ae26447b344c482a7284c737d9ddc5f9e52b74a336671bb"
dependencies = [ dependencies = [
"chrono", "chrono",
"chrono-tz-build", "chrono-tz-build",
"phf", "phf 0.11.3",
] ]
[[package]] [[package]]
@@ -431,7 +451,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c088aee841df9c3041febbb73934cfc39708749bf96dc827e3359cd39ef11b1" checksum = "0c088aee841df9c3041febbb73934cfc39708749bf96dc827e3359cd39ef11b1"
dependencies = [ dependencies = [
"parse-zoneinfo", "parse-zoneinfo",
"phf", "phf 0.11.3",
"phf_codegen", "phf_codegen",
] ]
@@ -441,7 +461,7 @@ version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [ dependencies = [
"crypto-common", "crypto-common 0.1.7",
"inout", "inout",
] ]
@@ -485,6 +505,12 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "cmov"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
[[package]] [[package]]
name = "colorchoice" name = "colorchoice"
version = "1.0.5" version = "1.0.5"
@@ -506,6 +532,12 @@ version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "const-oid"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]] [[package]]
name = "const-random" name = "const-random"
version = "0.1.18" version = "0.1.18"
@@ -551,6 +583,15 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "cpufeatures"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "crc" name = "crc"
version = "3.4.0" version = "3.4.0"
@@ -609,6 +650,15 @@ dependencies = [
"typenum", "typenum",
] ]
[[package]]
name = "crypto-common"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [
"hybrid-array",
]
[[package]] [[package]]
name = "ctr" name = "ctr"
version = "0.9.2" version = "0.9.2"
@@ -618,6 +668,15 @@ dependencies = [
"cipher", "cipher",
] ]
[[package]]
name = "ctutils"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
dependencies = [
"cmov",
]
[[package]] [[package]]
name = "data-encoding" name = "data-encoding"
version = "2.11.0" version = "2.11.0"
@@ -630,7 +689,7 @@ version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [ dependencies = [
"const-oid", "const-oid 0.9.6",
"pem-rfc7468", "pem-rfc7468",
"zeroize", "zeroize",
] ]
@@ -662,12 +721,24 @@ version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [ dependencies = [
"block-buffer", "block-buffer 0.10.4",
"const-oid", "const-oid 0.9.6",
"crypto-common", "crypto-common 0.1.7",
"subtle", "subtle",
] ]
[[package]]
name = "digest"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer 0.12.0",
"const-oid 0.10.2",
"crypto-common 0.2.2",
"ctutils",
]
[[package]] [[package]]
name = "directories" name = "directories"
version = "5.0.1" version = "5.0.1"
@@ -769,6 +840,12 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "fallible-iterator"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7"
[[package]] [[package]]
name = "fastrand" name = "fastrand"
version = "2.4.1" version = "2.4.1"
@@ -948,7 +1025,7 @@ dependencies = [
"cfg-if", "cfg-if",
"js-sys", "js-sys",
"libc", "libc",
"wasi", "wasi 0.11.1+wasi-snapshot-preview1",
"wasm-bindgen", "wasm-bindgen",
] ]
@@ -975,6 +1052,7 @@ dependencies = [
"cfg-if", "cfg-if",
"libc", "libc",
"r-efi 6.0.0", "r-efi 6.0.0",
"rand_core 0.10.1",
"wasip2", "wasip2",
"wasip3", "wasip3",
] ]
@@ -1033,7 +1111,7 @@ version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
dependencies = [ dependencies = [
"hmac", "hmac 0.12.1",
] ]
[[package]] [[package]]
@@ -1042,7 +1120,16 @@ version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [ dependencies = [
"digest", "digest 0.10.7",
]
[[package]]
name = "hmac"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
dependencies = [
"digest 0.11.3",
] ]
[[package]] [[package]]
@@ -1110,6 +1197,15 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hybrid-array"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da"
dependencies = [
"typenum",
]
[[package]] [[package]]
name = "hyper" name = "hyper"
version = "1.9.0" version = "1.9.0"
@@ -1501,7 +1597,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"digest", "digest 0.10.7",
]
[[package]]
name = "md-5"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98"
dependencies = [
"cfg-if",
"digest 0.11.3",
] ]
[[package]] [[package]]
@@ -1529,7 +1635,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
dependencies = [ dependencies = [
"libc", "libc",
"wasi", "wasi 0.11.1+wasi-snapshot-preview1",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
@@ -1628,6 +1734,24 @@ dependencies = [
"libm", "libm",
] ]
[[package]]
name = "objc2-core-foundation"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags",
]
[[package]]
name = "objc2-system-configuration"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396"
dependencies = [
"objc2-core-foundation",
]
[[package]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.21.4" version = "1.21.4"
@@ -1748,7 +1872,17 @@ version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
dependencies = [ dependencies = [
"phf_shared", "phf_shared 0.11.3",
]
[[package]]
name = "phf"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
dependencies = [
"phf_shared 0.13.1",
"serde",
] ]
[[package]] [[package]]
@@ -1758,7 +1892,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
dependencies = [ dependencies = [
"phf_generator", "phf_generator",
"phf_shared", "phf_shared 0.11.3",
] ]
[[package]] [[package]]
@@ -1767,7 +1901,7 @@ version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [ dependencies = [
"phf_shared", "phf_shared 0.11.3",
"rand 0.8.6", "rand 0.8.6",
] ]
@@ -1780,6 +1914,15 @@ dependencies = [
"siphasher", "siphasher",
] ]
[[package]]
name = "phf_shared"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
dependencies = [
"siphasher",
]
[[package]] [[package]]
name = "picloud" name = "picloud"
version = "1.1.9" version = "1.1.9"
@@ -1791,14 +1934,14 @@ dependencies = [
"chrono", "chrono",
"figment", "figment",
"hex", "hex",
"hmac", "hmac 0.12.1",
"picloud-executor-core", "picloud-executor-core",
"picloud-manager-core", "picloud-manager-core",
"picloud-orchestrator-core", "picloud-orchestrator-core",
"picloud-shared", "picloud-shared",
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2 0.10.9",
"sqlx", "sqlx",
"thiserror 1.0.69", "thiserror 1.0.69",
"tokio", "tokio",
@@ -1820,6 +1963,7 @@ dependencies = [
"directories", "directories",
"libc", "libc",
"picloud-shared", "picloud-shared",
"postgres",
"predicates", "predicates",
"reqwest", "reqwest",
"rpassword", "rpassword",
@@ -1828,6 +1972,7 @@ dependencies = [
"tempfile", "tempfile",
"tokio", "tokio",
"toml", "toml",
"uuid",
] ]
[[package]] [[package]]
@@ -1892,7 +2037,7 @@ dependencies = [
"data-encoding", "data-encoding",
"futures", "futures",
"hex", "hex",
"hmac", "hmac 0.12.1",
"lettre", "lettre",
"picloud-executor-core", "picloud-executor-core",
"picloud-orchestrator-core", "picloud-orchestrator-core",
@@ -1901,7 +2046,7 @@ dependencies = [
"reqwest", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2 0.10.9",
"sqlx", "sqlx",
"thiserror 1.0.69", "thiserror 1.0.69",
"tokio", "tokio",
@@ -1953,11 +2098,11 @@ dependencies = [
"async-trait", "async-trait",
"base64", "base64",
"chrono", "chrono",
"hmac", "hmac 0.12.1",
"rand 0.8.6", "rand 0.8.6",
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2 0.10.9",
"thiserror 1.0.69", "thiserror 1.0.69",
"tokio", "tokio",
"tracing", "tracing",
@@ -2010,7 +2155,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures", "cpufeatures 0.2.17",
"opaque-debug", "opaque-debug",
"universal-hash", "universal-hash",
] ]
@@ -2021,6 +2166,52 @@ version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
[[package]]
name = "postgres"
version = "0.19.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aacf632d0554ff75f58183694f41dc8999c8a3a43a386994d0ec2d034f1dfbe1"
dependencies = [
"bytes",
"fallible-iterator",
"futures-util",
"log",
"tokio",
"tokio-postgres",
]
[[package]]
name = "postgres-protocol"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56201207dac53e2f38e848e31b4b91616a6bb6e0c7205b77718994a7f49e70fc"
dependencies = [
"base64",
"byteorder",
"bytes",
"fallible-iterator",
"hmac 0.13.0",
"md-5 0.11.0",
"memchr",
"rand 0.10.1",
"sha2 0.11.0",
"stringprep",
]
[[package]]
name = "postgres-types"
version = "0.2.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8dc729a129e682e8d24170cd30ae1aa01b336b096cbb56df6d534ffec133d186"
dependencies = [
"bytes",
"fallible-iterator",
"postgres-protocol",
"serde_core",
"serde_json",
"uuid",
]
[[package]] [[package]]
name = "potential_utf" name = "potential_utf"
version = "0.1.5" version = "0.1.5"
@@ -2220,6 +2411,17 @@ dependencies = [
"rand_core 0.9.5", "rand_core 0.9.5",
] ]
[[package]]
name = "rand"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207"
dependencies = [
"chacha20",
"getrandom 0.4.2",
"rand_core 0.10.1",
]
[[package]] [[package]]
name = "rand_chacha" name = "rand_chacha"
version = "0.3.1" version = "0.3.1"
@@ -2258,6 +2460,12 @@ dependencies = [
"getrandom 0.3.4", "getrandom 0.3.4",
] ]
[[package]]
name = "rand_core"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.5.18" version = "0.5.18"
@@ -2426,8 +2634,8 @@ version = "0.9.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
dependencies = [ dependencies = [
"const-oid", "const-oid 0.9.6",
"digest", "digest 0.10.7",
"num-bigint-dig", "num-bigint-dig",
"num-integer", "num-integer",
"num-traits", "num-traits",
@@ -2626,8 +2834,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures", "cpufeatures 0.2.17",
"digest", "digest 0.10.7",
] ]
[[package]] [[package]]
@@ -2637,8 +2845,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures", "cpufeatures 0.2.17",
"digest", "digest 0.10.7",
]
[[package]]
name = "sha2"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"digest 0.11.3",
] ]
[[package]] [[package]]
@@ -2672,7 +2891,7 @@ version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [ dependencies = [
"digest", "digest 0.10.7",
"rand_core 0.6.4", "rand_core 0.6.4",
] ]
@@ -2784,7 +3003,7 @@ dependencies = [
"rustls", "rustls",
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2 0.10.9",
"smallvec", "smallvec",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
@@ -2823,7 +3042,7 @@ dependencies = [
"quote", "quote",
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2 0.10.9",
"sqlx-core", "sqlx-core",
"sqlx-mysql", "sqlx-mysql",
"sqlx-postgres", "sqlx-postgres",
@@ -2846,7 +3065,7 @@ dependencies = [
"bytes", "bytes",
"chrono", "chrono",
"crc", "crc",
"digest", "digest 0.10.7",
"dotenvy", "dotenvy",
"either", "either",
"futures-channel", "futures-channel",
@@ -2856,10 +3075,10 @@ dependencies = [
"generic-array", "generic-array",
"hex", "hex",
"hkdf", "hkdf",
"hmac", "hmac 0.12.1",
"itoa", "itoa",
"log", "log",
"md-5", "md-5 0.10.6",
"memchr", "memchr",
"once_cell", "once_cell",
"percent-encoding", "percent-encoding",
@@ -2867,14 +3086,14 @@ dependencies = [
"rsa", "rsa",
"serde", "serde",
"sha1", "sha1",
"sha2", "sha2 0.10.9",
"smallvec", "smallvec",
"sqlx-core", "sqlx-core",
"stringprep", "stringprep",
"thiserror 2.0.18", "thiserror 2.0.18",
"tracing", "tracing",
"uuid", "uuid",
"whoami", "whoami 1.6.1",
] ]
[[package]] [[package]]
@@ -2896,24 +3115,24 @@ dependencies = [
"futures-util", "futures-util",
"hex", "hex",
"hkdf", "hkdf",
"hmac", "hmac 0.12.1",
"home", "home",
"itoa", "itoa",
"log", "log",
"md-5", "md-5 0.10.6",
"memchr", "memchr",
"once_cell", "once_cell",
"rand 0.8.6", "rand 0.8.6",
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2 0.10.9",
"smallvec", "smallvec",
"sqlx-core", "sqlx-core",
"stringprep", "stringprep",
"thiserror 2.0.18", "thiserror 2.0.18",
"tracing", "tracing",
"uuid", "uuid",
"whoami", "whoami 1.6.1",
] ]
[[package]] [[package]]
@@ -3178,6 +3397,32 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "tokio-postgres"
version = "0.7.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4dd8df5ef180f6364759a6f00f7aadda4fbbac86cdee37480826a6ff9f3574ce"
dependencies = [
"async-trait",
"byteorder",
"bytes",
"fallible-iterator",
"futures-channel",
"futures-util",
"log",
"parking_lot",
"percent-encoding",
"phf 0.13.1",
"pin-project-lite",
"postgres-protocol",
"postgres-types",
"rand 0.10.1",
"socket2",
"tokio",
"tokio-util",
"whoami 2.1.2",
]
[[package]] [[package]]
name = "tokio-rustls" name = "tokio-rustls"
version = "0.26.4" version = "0.26.4"
@@ -3436,7 +3681,7 @@ version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [ dependencies = [
"crypto-common", "crypto-common 0.1.7",
"subtle", "subtle",
] ]
@@ -3530,6 +3775,15 @@ version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasi"
version = "0.14.7+wasi-0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c"
dependencies = [
"wasip2",
]
[[package]] [[package]]
name = "wasip2" name = "wasip2"
version = "1.0.3+wasi-0.2.9" version = "1.0.3+wasi-0.2.9"
@@ -3554,6 +3808,15 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
[[package]]
name = "wasite"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42"
dependencies = [
"wasi 0.14.7+wasi-0.2.4",
]
[[package]] [[package]]
name = "wasm-bindgen" name = "wasm-bindgen"
version = "0.2.122" version = "0.2.122"
@@ -3688,7 +3951,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d"
dependencies = [ dependencies = [
"libredox", "libredox",
"wasite", "wasite 0.1.0",
]
[[package]]
name = "whoami"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d"
dependencies = [
"libc",
"libredox",
"objc2-system-configuration",
"wasite 1.0.2",
"web-sys",
] ]
[[package]] [[package]]

View File

@@ -29,6 +29,14 @@ export function subscribeTopic<T = unknown>(
let lastEventId: string | undefined; let lastEventId: string | undefined;
let controller: AbortController | null = null; let controller: AbortController | null = null;
let backoffTimer: ReturnType<typeof setTimeout> | null = null; let backoffTimer: ReturnType<typeof setTimeout> | null = null;
// F-T-003: bound the 401-refresh loop. If onTokenExpired keeps
// returning a token that the server rejects (mis-issued refresh,
// server-side authz drift, …) we'd otherwise reconnect immediately
// forever with attempt=0 each iteration — the previous behavior.
// Three consecutive 401s within the loop give up and surface the
// error so the caller knows the credential is bad.
let consecutive401 = 0;
const MAX_CONSECUTIVE_401 = 3;
const stop = () => { const stop = () => {
stopped = true; stopped = true;
@@ -61,6 +69,17 @@ export function subscribeTopic<T = unknown>(
} }
if (res.status === 401) { if (res.status === 401) {
consecutive401 += 1;
if (consecutive401 >= MAX_CONSECUTIVE_401) {
opts.onError?.(
new Error(
`realtime subscribe stuck in 401-refresh loop (${consecutive401} consecutive); ` +
'check that onTokenExpired returns a credential the server accepts'
)
);
stop();
return;
}
// Token expired / rejected — try to refresh, else give up. // Token expired / rejected — try to refresh, else give up.
const fresh = opts.onTokenExpired ? await opts.onTokenExpired() : null; const fresh = opts.onTokenExpired ? await opts.onTokenExpired() : null;
if (fresh) { if (fresh) {
@@ -79,8 +98,10 @@ export function subscribeTopic<T = unknown>(
return; return;
} }
// Connected — reset backoff and stream frames until the body ends. // Connected — reset backoff and the 401 counter; a successful
// open proves the current credential works.
attempt = 0; attempt = 0;
consecutive401 = 0;
try { try {
await readStream(res.body, (frame) => { await readStream(res.body, (frame) => {
if (frame.id !== undefined) lastEventId = frame.id; if (frame.id !== undefined) lastEventId = frame.id;

View File

@@ -96,4 +96,30 @@ describe('subscribe', () => {
await vi.waitFor(() => expect(onError).toHaveBeenCalled()); await vi.waitFor(() => expect(onError).toHaveBeenCalled());
unsubscribe(); unsubscribe();
}); });
it('caps the 401-refresh loop after consecutive failures', async () => {
// onTokenExpired keeps returning a fresh-looking-but-still-rejected
// token. Without the cap the loop would reconnect forever.
const fetchMock = queuedFetch([
async () => emptyResponse(401),
async () => emptyResponse(401),
async () => emptyResponse(401),
async () => emptyResponse(401),
async () => emptyResponse(401)
]);
const client = new PicloudClient({ baseURL: 'https://api.test', fetch: fetchMock });
const onError = vi.fn();
const onTokenExpired = vi.fn(() => 'never-good-enough');
const unsubscribe = client.subscribe('chat', () => {}, {
onTokenExpired,
onError
});
await vi.waitFor(() => expect(onError).toHaveBeenCalled(), { timeout: 1000 });
unsubscribe();
// The cap is 3 consecutive 401s — at most 3 fetches should have
// been issued before the loop bailed.
expect(fetchMock.mock.calls.length).toBeLessThanOrEqual(3);
const errMsg = String(onError.mock.calls[0]?.[0]);
expect(errMsg).toContain('401-refresh loop');
});
}); });

View File

@@ -34,12 +34,51 @@ use crate::trigger_repo::{
TriggerRepo, TriggerRepoError, TriggerRepo, TriggerRepoError,
}; };
/// Minimum allowed queue visibility timeout. Anything shorter races the /// Hard floor on queue visibility timeout. Anything shorter is a typo
/// dispatcher's per-message executor budget; reclaim would re-deliver /// in practice (the dispatcher itself ticks every 100ms; reclaim ticks
/// the message before the handler returned. Picked to outlast a slow /// every 30s; an executor needs more wall-clock than this to do useful
/// handler by a comfortable margin. /// work). Reject below this with a 422 + actionable error.
const MIN_QUEUE_VISIBILITY_TIMEOUT_SECS: u32 = 30; const MIN_QUEUE_VISIBILITY_TIMEOUT_SECS: u32 = 30;
/// Soft warning ceiling: when the configured visibility is shorter than
/// the dispatcher's per-message executor budget (default 300s, see
/// `DEFAULT_ASYNC_EXEC_TIMEOUT` in dispatcher.rs), a handler that runs
/// for the full budget will be reclaimed mid-execution and the message
/// double-delivered. Don't reject — short handlers are a valid choice
/// — but log loudly so the operator can see the mismatch.
const SAFE_VISIBILITY_VS_EXEC_BUDGET_SECS: u32 = 300;
/// Validate the script-supplied visibility timeout. Hard-rejects below
/// [`MIN_QUEUE_VISIBILITY_TIMEOUT_SECS`]; warn-logs (does not reject)
/// when in `[MIN, SAFE)` because a handler running past `visibility_secs`
/// will get double-delivered when reclaim sweeps the stale claim.
///
/// Extracted from the handler so it's unit-testable without spinning up
/// the full Axum harness.
fn validate_queue_visibility_timeout(secs: Option<u32>) -> Result<(), TriggersApiError> {
let Some(s) = secs else {
return Ok(()); // None → fall back to TriggerConfig's default.
};
if s < 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 tick + reclaim cadence; \
a handler can't return before reclaim fires)"
)));
}
if s < SAFE_VISIBILITY_VS_EXEC_BUDGET_SECS {
tracing::warn!(
visibility_timeout_secs = s,
executor_budget_secs = SAFE_VISIBILITY_VS_EXEC_BUDGET_SECS,
"queue trigger visibility_timeout_secs is below the dispatcher's per-message \
executor budget; a handler that runs longer than {s}s will be reclaimed \
mid-execution and the message double-delivered. Either raise visibility or \
accept the at-least-once semantics for this queue."
);
}
Ok(())
}
#[derive(Clone)] #[derive(Clone)]
pub struct TriggersState { pub struct TriggersState {
pub triggers: Arc<dyn TriggerRepo>, pub triggers: Arc<dyn TriggerRepo>,
@@ -573,21 +612,7 @@ async fn create_queue_trigger(
"queue_name must not be empty".into(), "queue_name must not be empty".into(),
)); ));
} }
// Reject obviously-too-short visibility timeouts. The dispatcher validate_queue_visibility_timeout(input.visibility_timeout_secs)?;
// 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?; validate_trigger_target(&*s.scripts, app_id, input.script_id).await?;
let req = crate::trigger_repo::CreateQueueTrigger { let req = crate::trigger_repo::CreateQueueTrigger {
@@ -736,6 +761,44 @@ mod tests {
use super::*; use super::*;
use crate::app_repo::{AppLookup, AppRepository}; use crate::app_repo::{AppLookup, AppRepository};
#[test]
fn visibility_timeout_none_passes() {
assert!(validate_queue_visibility_timeout(None).is_ok());
}
#[test]
fn visibility_timeout_above_safe_passes() {
// ≥ 300s: safely above the executor budget, no warn.
assert!(validate_queue_visibility_timeout(Some(600)).is_ok());
assert!(validate_queue_visibility_timeout(Some(300)).is_ok());
}
#[test]
fn visibility_timeout_between_min_and_safe_warns_but_passes() {
// [30, 300): valid but the operator should know. We assert the
// call succeeds; the warn-log emission is a side effect tracing
// would surface in real ops — covered visually, not asserted.
assert!(validate_queue_visibility_timeout(Some(60)).is_ok());
assert!(validate_queue_visibility_timeout(Some(MIN_QUEUE_VISIBILITY_TIMEOUT_SECS)).is_ok());
}
#[test]
fn visibility_timeout_below_min_rejected() {
for too_short in [0u32, 1, 5, 10, MIN_QUEUE_VISIBILITY_TIMEOUT_SECS - 1] {
let err = validate_queue_visibility_timeout(Some(too_short)).unwrap_err();
match err {
TriggersApiError::Invalid(msg) => {
assert!(
msg.contains(&MIN_QUEUE_VISIBILITY_TIMEOUT_SECS.to_string()),
"error should cite the minimum: {msg}"
);
}
other => panic!("expected Invalid for {too_short}s, got {other:?}"),
}
}
}
use crate::trigger_repo::{ use crate::trigger_repo::{
CreateCronTrigger, CreateEmailTrigger, CreateFilesTrigger, CreatePubsubTrigger, CreateCronTrigger, CreateEmailTrigger, CreateFilesTrigger, CreatePubsubTrigger,
DeadLetterTriggerMatch, DocsTriggerMatch, EmailInboundTarget, FilesTriggerMatch, DeadLetterTriggerMatch, DocsTriggerMatch, EmailInboundTarget, FilesTriggerMatch,

View File

@@ -40,3 +40,9 @@ predicates = "3"
tempfile = "3" tempfile = "3"
reqwest = { workspace = true, features = ["json", "blocking"] } reqwest = { workspace = true, features = ["json", "blocking"] }
libc = "0.2" libc = "0.2"
# Synchronous Postgres driver used by the dead-letters integration test
# to inject a row the replay path can act on. Smaller compile cost than
# pulling sqlx into the CLI tree, and the existing tests are
# blocking-style anyway.
postgres = { version = "0.19", features = ["with-uuid-1", "with-serde_json-1"] }
uuid = { version = "1", features = ["v4"] }

View File

@@ -399,6 +399,18 @@ impl Client {
decode(resp).await decode(resp).await
} }
/// `GET /api/v1/admin/apps/{id}/dead_letters/count`
pub async fn dead_letters_count(&self, app: &str) -> Result<DeadLetterCountDto> {
let resp = self
.request(
Method::GET,
&format!("/api/v1/admin/apps/{app}/dead_letters/count"),
)
.send()
.await?;
decode(resp).await
}
/// `GET /api/v1/admin/apps/{id}/dead_letters?unresolved={bool}&limit={n}` /// `GET /api/v1/admin/apps/{id}/dead_letters?unresolved={bool}&limit={n}`
pub async fn dead_letters_list( pub async fn dead_letters_list(
&self, &self,
@@ -647,6 +659,11 @@ pub struct TriggerDto {
pub details: Value, pub details: Value,
} }
#[derive(Debug, Deserialize)]
pub struct DeadLetterCountDto {
pub unresolved: i64,
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct DeadLetterListDto { pub struct DeadLetterListDto {
pub dead_letters: Vec<DeadLetterDto>, pub dead_letters: Vec<DeadLetterDto>,

View File

@@ -6,6 +6,18 @@ use crate::client::Client;
use crate::config; use crate::config;
use crate::output::{KvBlock, OutputMode, Table}; 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<()> { pub async fn ls(app: &str, unresolved: bool, limit: u32, mode: OutputMode) -> Result<()> {
let creds = config::resolve()?; let creds = config::resolve()?;
let client = Client::from_creds(&creds)?; let client = Client::from_creds(&creds)?;

View File

@@ -466,6 +466,13 @@ enum TriggersCmd {
#[derive(Subcommand)] #[derive(Subcommand)]
enum DeadLettersCmd { enum DeadLettersCmd {
/// Print the unresolved DL count. Cheap probe for alerting /
/// dashboards — no row payloads, just the bare integer.
Count {
#[arg(long)]
app: String,
},
/// List dead-letter rows for an app. /// List dead-letter rows for an app.
Ls { Ls {
#[arg(long)] #[arg(long)]
@@ -822,6 +829,9 @@ async fn main() -> ExitCode {
Cmd::Triggers { Cmd::Triggers {
cmd: TriggersCmd::CreateFromJson { app, kind, body }, cmd: TriggersCmd::CreateFromJson { app, kind, body },
} => cmds::triggers::create_from_json(&app, &kind, &body, mode).await, } => cmds::triggers::create_from_json(&app, &kind, &body, mode).await,
Cmd::DeadLetters {
cmd: DeadLettersCmd::Count { app },
} => cmds::dead_letters::count(&app, mode).await,
Cmd::DeadLetters { Cmd::DeadLetters {
cmd: cmd:
DeadLettersCmd::Ls { DeadLettersCmd::Ls {

View File

@@ -46,6 +46,136 @@ fn ls_empty_app_succeeds() {
assert_eq!(stdout.lines().count(), 1, "fresh app has no DL rows"); assert_eq!(stdout.lines().count(), 1, "fresh app has no DL rows");
} }
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn count_returns_unresolved_total() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let slug = common::unique_slug("dl-count");
common::pic_as(&env)
.args(["apps", "create", &slug])
.assert()
.success();
let _guard = AppGuard::new(&env.url, &env.token, &slug);
let out = common::pic_as(&env)
.args(["dead-letters", "count", "--app", &slug])
.output()
.expect("dl count");
assert!(out.status.success(), "count failed: {out:?}");
let stdout = String::from_utf8(out.stdout).unwrap();
let header = stdout.lines().next().expect("header");
assert_eq!(common::cells(header), vec!["unresolved"]);
let row = stdout.lines().nth(1).expect("data row");
assert_eq!(common::cells(row), vec!["0"]);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn replay_against_real_dl_row_succeeds() {
use postgres::{Client as PgClient, NoTls};
use uuid::Uuid;
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let (script_id_str, guard) = common::deploy_fixture(&env, "dl-replay", "hello.rhai");
let app_slug = guard.slug().to_string();
// Need the app's UUID to inject the DL row. Resolve via the admin
// GET — picloud accepts either slug or id, returns both.
let app_id: String = reqwest::blocking::Client::new()
.get(format!("{}/api/v1/admin/apps/{}", env.url, app_slug))
.bearer_auth(&env.token)
.send()
.expect("apps_get")
.json::<serde_json::Value>()
.expect("apps_get json")["id"]
.as_str()
.expect("app id")
.to_string();
// Direct INSERT — no admin endpoint creates DL rows; the real ones
// come from the dispatcher's retry-exhaust path, which would take
// minutes to drive. The replay path doesn't care how the row got
// there, only that it exists, belongs to this app, and is
// unresolved.
let db_url =
std::env::var("DATABASE_URL").expect("DATABASE_URL required for replay happy-path test");
let mut pg = PgClient::connect(&db_url, NoTls).expect("postgres connect");
let dl_id = Uuid::new_v4();
let app_uuid = Uuid::parse_str(&app_id).expect("app uuid");
let script_uuid = Uuid::parse_str(&script_id_str).expect("script uuid");
// Payload shape must round-trip through the dispatcher: source="kv"
// matches a KV TriggerEvent, op="set" is a KvEventOp, and the
// service_kind=`kv` outbox row gets routed by resolve_trigger.
let payload = serde_json::json!({
"source": "kv",
"op": "set",
"kv": {
"collection": "x",
"key": "y",
"value": { "v": 1 }
}
});
pg.execute(
"INSERT INTO dead_letters (
id, app_id, original_event_id, source, op, trigger_id, script_id,
payload, attempt_count, first_attempt_at, last_attempt_at, last_error
) VALUES ($1, $2, $3, 'kv', 'set', NULL, $4, $5, 1, NOW(), NOW(), 'forced')",
&[&dl_id, &app_uuid, &Uuid::new_v4(), &script_uuid, &payload],
)
.expect("insert synthetic DL row");
// Verify CLI count picks the row up.
let out = common::pic_as(&env)
.args(["dead-letters", "count", "--app", &app_slug])
.output()
.expect("dl count");
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(
stdout.lines().nth(1) == Some("1"),
"count should be 1: {stdout}"
);
// Replay — pic exits 0, the server resolves the row with reason=replayed.
common::pic_as(&env)
.args([
"dead-letters",
"replay",
"--app",
&app_slug,
&dl_id.to_string(),
])
.assert()
.success()
.stdout(predicate::str::contains("Replayed dead-letter"));
// Verify the row is now resolved with reason=replayed.
let row = pg
.query_one(
"SELECT resolution FROM dead_letters WHERE id = $1",
&[&dl_id],
)
.expect("dl row exists post-replay");
let resolution: Option<String> = row.get(0);
assert_eq!(resolution.as_deref(), Some("replayed"));
// And the unresolved count drops back to 0.
let out = common::pic_as(&env)
.args(["dead-letters", "count", "--app", &app_slug])
.output()
.expect("dl count");
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(
stdout.lines().nth(1) == Some("0"),
"count should be 0 after replay: {stdout}"
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test] #[test]
fn resolve_marks_row_resolved() { fn resolve_marks_row_resolved() {

View File

@@ -1,11 +1,10 @@
<script lang="ts"> <script lang="ts">
import { base } from '$app/paths'; import { base } from '$app/paths';
import { page } from '$app/state'; import { page } from '$app/state';
import { api, ApiError, type App, type Invitation } from '$lib/api'; import { api, ApiError, type Invitation } from '$lib/api';
import ConfirmModal from '$lib/ConfirmModal.svelte'; import ConfirmModal from '$lib/ConfirmModal.svelte';
let slug = $derived(page.params.slug ?? ''); let slug = $derived(page.params.slug ?? '');
let app = $state<App | null>(null);
let invitations = $state<Invitation[]>([]); let invitations = $state<Invitation[]>([]);
let loading = $state(false); let loading = $state(false);
let error = $state<string | null>(null); let error = $state<string | null>(null);
@@ -26,14 +25,6 @@
let toRevoke = $state<Invitation | null>(null); let toRevoke = $state<Invitation | null>(null);
let revoking = $state(false); let revoking = $state(false);
async function loadApp() {
try {
app = await api.apps.get(slug);
} catch (e) {
error = e instanceof ApiError ? e.message : String(e);
}
}
async function loadInvitations() { async function loadInvitations() {
loading = true; loading = true;
error = null; error = null;
@@ -49,7 +40,6 @@
$effect(() => { $effect(() => {
void slug; void slug;
void loadApp();
void loadInvitations(); void loadInvitations();
}); });