From 83b16f11cf77a3744e3cab725719e26d0abaa474 Mon Sep 17 00:00:00 2001 From: Postil Maintainer Date: Mon, 24 Aug 2026 23:45:41 +0000 Subject: [PATCH 1/7] Keep CLI logins renewable --- Cargo.lock | 33 ++ Cargo.toml | 1 + README.md | 2 +- docs/configuration.md | 6 +- src/api_key.rs | 61 ++-- src/attribution.rs | 2 +- src/config.rs | 6 +- src/credentials.rs | 193 ++++++++++- src/doctor.rs | 180 +++++++++- src/llm.rs | 35 +- src/login.rs | 785 ++++++++++++++++++++++++++++++++++++++++-- src/main.rs | 2 + src/respond.rs | 2 +- src/review.rs | 23 +- 14 files changed, 1228 insertions(+), 103 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c0a3a08..e508114 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -460,6 +460,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -1112,6 +1122,7 @@ dependencies = [ "clap", "csv", "dirs", + "fs2", "futures", "globset", "httpdate", @@ -2159,6 +2170,22 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -2168,6 +2195,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 1330d48..60e93b3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ aho-corasick = "1.1.4" anyhow = "1.0.102" clap = { version = "4.6.1", features = ["derive"] } dirs = "6.0.0" +fs2 = "0.4.3" futures = "0.3.32" globset = "0.4.18" httpdate = "1.0.3" diff --git a/README.md b/README.md index 1bdf14d..16c82e2 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Release binaries cover Linux x86_64 and ARM64 with glibc or musl, plus macOS on Authenticate once for hosted inference against your organization's entitlement, or bring your own model key: ```sh -postil login # zero-config: stores a credential for hosted inference +postil login # zero-config: stores a renewable login for hosted inference # or: export MODEL_API_KEY=... # OpenRouter is the default endpoint # export REVIEW_MODEL=provider/qualified-model postil doctor # validate the endpoint and repository diff --git a/docs/configuration.md b/docs/configuration.md index 2fa8594..f2f54e7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -47,7 +47,7 @@ Ignore patterns remove matching paths before grounding, batching, and large-revi | Variable | Purpose | | --- | --- | | `POSTIL_API_KEY`, `OPENROUTER_API_KEY`, `MODEL_API_KEY`, `LLM_API_KEY` | Model provider credential, checked in that order | -| `POSTIL_LOGIN_SERVER` | Postil web app used by `postil login`/`postil logout`; defaults to `https://postil.dev` | +| `POSTIL_LOGIN_SERVER` | Postil web app used by `postil login`, refresh, and `postil logout`; defaults to `https://postil.dev` | | `POSTIL_API_BASE` | Model endpoint selected by the operator | | `POSTIL_API_FORMAT` | `openai-compatible` or `anthropic` | | `POSTIL_ENDPOINT_AUTH_HEADER`, `POSTIL_ENDPOINT_AUTH_VALUE` | Additional private-gateway authentication | @@ -74,7 +74,7 @@ postil login postil logout ``` -`postil login` authenticates against postil.dev over a device-authorization flow (open the printed URL, enter the code) and stores a credential at `${XDG_CONFIG_HOME:-~/.config}/postil/credentials.json`, mode `0600` in a `0700` directory. That credential is a fallback: it is used only when none of `POSTIL_API_KEY`, `OPENROUTER_API_KEY`, `MODEL_API_KEY`, or `LLM_API_KEY` is set. When it is used, its `apiBase` and model select the request unless `POSTIL_API_BASE`/`REVIEW_MODEL` are set, which still win. `postil logout` revokes the credential server-side and removes the local file even if that call fails. An expired credential produces one instruction to run `postil login` again rather than a provider authentication error. +`postil login` authenticates against postil.dev over a device-authorization flow (open the printed URL, enter the code) and stores a renewable credential at `${XDG_CONFIG_HOME:-~/.config}/postil/credentials.json`, mode `0600` in a `0700` directory. That credential is a fallback: it is used only when none of `POSTIL_API_KEY`, `OPENROUTER_API_KEY`, `MODEL_API_KEY`, or `LLM_API_KEY` is set. When it is used, Postil rotates the access credential before it expires and persists the replacement; explicit API keys never trigger a refresh. Its `apiBase` and model select the request unless `POSTIL_API_BASE`/`REVIEW_MODEL` are set, which still win. `postil logout` revokes the stored refresh credential server-side and removes the local file even if that call fails. A legacy access-only login, an expired refresh inactivity window, or a rejected refresh produces one instruction to run `postil login` again rather than a provider authentication error. Temporary refresh failures retain the stored credential and ask the user to try again. ## Inspect and initialize @@ -84,4 +84,4 @@ postil config postil doctor ``` -`postil config` prints the resolved non-secret configuration and its sources. `postil doctor` validates endpoint reachability, credential acceptance, and repository setup without printing credential values, and reports whether a login credential is present and when it expires. +`postil config` prints the resolved non-secret configuration and its sources. `postil doctor` validates endpoint reachability, credential acceptance, and repository setup without printing credential values. Both commands identify renewable logins, access expiry, refresh inactivity expiry, and legacy access-only logins. diff --git a/src/api_key.rs b/src/api_key.rs index 060f2f8..84dbf06 100644 --- a/src/api_key.rs +++ b/src/api_key.rs @@ -1,16 +1,15 @@ //! Inference API-key resolution shared by CLI runtime checks. //! //! [`resolve_from_process_env`] resolves only the four explicit env vars, in -//! priority order. [`resolve_effective`] adds one more source below all -//! four: a stored `postil login` credential, used only when none of the env -//! vars is set. See `config.rs`'s module doc for the full precedence -//! statement. +//! priority order. Stored-login resolution is invoked only when none of the +//! four is set. See `config.rs`'s module doc for the full precedence statement. -use std::path::Path; +use std::future::Future; use anyhow::Result; -use crate::credentials::{self, Credentials}; +#[cfg(test)] +use crate::credentials::Credentials; pub(crate) const API_KEY_ENV_VARS: [&str; 4] = [ "POSTIL_API_KEY", @@ -33,20 +32,7 @@ pub(crate) fn resolve_with(mut lookup: impl FnMut(&str) -> Option) -> Op .find_map(|name| lookup(name).filter(|value| !value.trim().is_empty())) } -/// Bearer key resolved for inference, falling back to a stored `postil -/// login` credential when none of the four explicit env vars is set. -/// `Ok(None)` means neither source has a key; callers turn that into their -/// own "set a key or run `postil login`" message. An expired stored -/// credential is reported here as an error, so the caller surfaces one -/// actionable instruction instead of a confusing upstream auth failure once -/// the request reaches the provider. -pub(crate) fn resolve_effective(credentials_path: &Path) -> Result> { - resolve_effective_with( - |name| std::env::var(name).ok(), - || credentials::read(credentials_path), - ) -} - +#[cfg(test)] pub(crate) fn resolve_effective_with( env_lookup: impl FnMut(&str) -> Option, credential_lookup: impl FnOnce() -> Result>, @@ -64,10 +50,29 @@ pub(crate) fn resolve_effective_with( Ok(Some(credentials.token)) } +/// Resolves an explicit key without polling the stored-login future. This +/// keeps BYOK invocations independent of both the credential file and the +/// Postil authentication service. +pub(crate) async fn resolve_explicit_or_stored( + explicit: Option, + stored: F, +) -> Result> +where + F: Future>>, +{ + match explicit { + Some(key) => Ok(Some(key)), + None => stored.await, + } +} + #[cfg(test)] mod tests { use super::*; + use crate::credentials; use std::collections::HashMap; + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, Ordering}; fn resolve(pairs: &[(&str, &str)]) -> Option { let values: HashMap<&str, &str> = pairs.iter().copied().collect(); @@ -112,6 +117,8 @@ mod tests { version: credentials::CREDENTIALS_VERSION, token: "pcli_stored-token-not-a-real-secret".to_string(), expires_at: expires_at.to_string(), + refresh_token: None, + refresh_expires_at: None, api_base: "https://postil.dev/api/inference/v1".to_string(), org: "runatlas-is".to_string(), model: "z-ai/glm-5.2".to_string(), @@ -168,4 +175,18 @@ mod tests { assert!(message.contains("postil login")); assert!(!message.contains("pcli_stored-token-not-a-real-secret")); } + + #[tokio::test] + async fn explicit_key_bypasses_stored_login_refresh() { + let refreshed = Arc::new(AtomicBool::new(false)); + let invoked = refreshed.clone(); + let resolved = resolve_explicit_or_stored(Some("explicit-key".to_string()), async move { + invoked.store(true, Ordering::SeqCst); + Ok(Some("stored-key".to_string())) + }) + .await + .unwrap(); + assert_eq!(resolved.as_deref(), Some("explicit-key")); + assert!(!refreshed.load(Ordering::SeqCst)); + } } diff --git a/src/attribution.rs b/src/attribution.rs index efc11ec..28f9692 100644 --- a/src/attribution.rs +++ b/src/attribution.rs @@ -243,7 +243,7 @@ async fn run_inner(input_path: &Path, config_path: Option<&Path>) -> Result cfg.scorer == input.model, "atomic attribution model must equal the configured primary scorer" ); - let client = LlmClient::from_env(&cfg)?; + let client = LlmClient::from_env(&cfg).await?; let system = system_prompt(); let user = user_prompt(&input)?; let review = client diff --git a/src/config.rs b/src/config.rs index e6604ba..7cbe312 100644 --- a/src/config.rs +++ b/src/config.rs @@ -12,13 +12,13 @@ //! `MODEL_API_KEY`, and `LLM_API_KEY` (first set wins) all take priority over //! a stored `postil login` credential. When none of those four is set and a //! valid, unexpired credential exists at -//! `${XDG_CONFIG_HOME:-~/.config}/postil/credentials.json`, its token becomes -//! the bearer key (see `api_key::resolve_effective`) and its `apiBase`/ +//! `${XDG_CONFIG_HOME:-~/.config}/postil/credentials.json`, +//! `login::resolve_stored_token` supplies its bearer key and its `apiBase`/ //! `model` replace the values resolved above -- unless `POSTIL_API_BASE` / //! `REVIEW_MODEL` are set, which still win, applied first in this same //! function. An expired or unreadable stored credential is left alone here; //! `resolve_api_key` reports it as one actionable "run `postil login` again" -//! error instead of blocking commands, like `postil config`, that never need +//! error, while `postil config` reports the stored-login state without needing //! a live key. use std::path::{Path, PathBuf}; diff --git a/src/credentials.rs b/src/credentials.rs index ac26515..bf65f1e 100644 --- a/src/credentials.rs +++ b/src/credentials.rs @@ -9,16 +9,23 @@ //! `chmod`'d afterward, so there is no window where the token is briefly //! world-readable. -use std::fs::{self, OpenOptions}; +use std::fs::{self, File, OpenOptions}; use std::io::Write; #[cfg(unix)] use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt}; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; +use fs2::FileExt; use serde::{Deserialize, Serialize}; -pub const CREDENTIALS_VERSION: u32 = 1; +pub const CREDENTIALS_VERSION: u32 = 2; +pub const LEGACY_CREDENTIALS_VERSION: u32 = 1; +// The refresh exchange has separately bounded send and body-read phases. A +// second local process waits long enough for both before asking the caller to +// retry, so concurrent agent runs converge on one token rotation. +const CREDENTIAL_LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(45); +const CREDENTIAL_LOCK_RETRY: std::time::Duration = std::time::Duration::from_millis(25); #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Credentials { @@ -26,6 +33,18 @@ pub struct Credentials { pub token: String, #[serde(rename = "expiresAt")] pub expires_at: String, + #[serde( + default, + rename = "refreshToken", + skip_serializing_if = "Option::is_none" + )] + pub refresh_token: Option, + #[serde( + default, + rename = "refreshExpiresAt", + skip_serializing_if = "Option::is_none" + )] + pub refresh_expires_at: Option, #[serde(rename = "apiBase")] pub api_base: String, pub org: String, @@ -37,14 +56,36 @@ impl Credentials { /// already expired rather than trusted, so a corrupted file never grants /// silent access. pub fn is_expired(&self) -> bool { - match time::OffsetDateTime::parse( - &self.expires_at, - &time::format_description::well_known::Rfc3339, - ) { - Ok(expires_at) => expires_at <= time::OffsetDateTime::now_utc(), + self.expires_within(std::time::Duration::ZERO) + } + + pub fn expires_within(&self, margin: std::time::Duration) -> bool { + let Ok(margin) = time::Duration::try_from(margin) else { + return true; + }; + match parse_timestamp(&self.expires_at) { + Ok(expires_at) => expires_at <= time::OffsetDateTime::now_utc() + margin, Err(_) => true, } } + + pub fn refresh_is_expired(&self) -> bool { + match self.refresh_expires_at.as_deref().map(parse_timestamp) { + Some(Ok(expires_at)) => expires_at <= time::OffsetDateTime::now_utc(), + Some(Err(_)) | None => true, + } + } + + pub fn can_refresh(&self) -> bool { + self.refresh_token + .as_deref() + .is_some_and(|token| !token.trim().is_empty()) + && !self.refresh_is_expired() + } +} + +fn parse_timestamp(value: &str) -> Result { + time::OffsetDateTime::parse(value, &time::format_description::well_known::Rfc3339) } /// The real, XDG-resolved path used by `postil login`/`postil logout` and by @@ -74,6 +115,14 @@ pub fn read(path: &Path) -> Result> { }; let credentials: Credentials = serde_json::from_str(&raw).with_context(|| format!("parsing {}", path.display()))?; + anyhow::ensure!( + matches!( + credentials.version, + LEGACY_CREDENTIALS_VERSION | CREDENTIALS_VERSION + ), + "unsupported credentials version {}; run `postil login` again", + credentials.version + ); Ok(Some(credentials)) } @@ -95,6 +144,14 @@ pub fn write(path: &Path, credentials: &Credentials) -> Result<()> { let mut file = options .open(&temp_path) .context("creating private credentials file")?; + anyhow::ensure!( + matches!( + credentials.version, + LEGACY_CREDENTIALS_VERSION | CREDENTIALS_VERSION + ), + "unsupported credentials version {}", + credentials.version + ); serde_json::to_writer_pretty(&mut file, credentials).context("serializing credentials")?; file.write_all(b"\n").context("writing credentials")?; file.sync_all().context("syncing credentials")?; @@ -107,6 +164,61 @@ pub fn write(path: &Path, credentials: &Credentials) -> Result<()> { result } +/// A kernel-backed, cross-process lock shared by every stored-login mutation. +/// Keeping the lock file in place avoids stale-file recovery: the operating +/// system releases the exclusive lock when the handle closes or its process +/// exits. +pub struct CredentialLock { + _file: File, +} + +impl CredentialLock { + pub async fn acquire(credentials_path: &Path) -> Result { + let parent = credentials_path + .parent() + .context("credentials path must have a parent directory")?; + create_private_dir(parent)?; + let file_name = credentials_path + .file_name() + .and_then(|name| name.to_str()) + .context("credentials path must have a file name")?; + let path = parent.join(format!(".{file_name}.refresh.lock")); + let file = open_private_lock_file(&path)?; + let deadline = tokio::time::Instant::now() + CREDENTIAL_LOCK_WAIT; + + loop { + match file.try_lock_exclusive() { + Ok(()) => return Ok(Self { _file: file }), + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + if tokio::time::Instant::now() >= deadline { + anyhow::bail!( + "another postil process is updating the stored login; try again" + ); + } + tokio::time::sleep(CREDENTIAL_LOCK_RETRY).await; + } + Err(error) => return Err(error).context("locking stored login"), + } + } + } +} + +fn open_private_lock_file(path: &Path) -> Result { + let mut options = OpenOptions::new(); + options.read(true).write(true).create(true); + #[cfg(unix)] + options.mode(0o600); + let file = options.open(path).context("creating stored login lock")?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let permissions = fs::Permissions::from_mode(0o600); + file.set_permissions(permissions) + .context("securing stored login lock")?; + } + Ok(file) +} + /// Idempotent: removing an already-absent file is success, matching the /// server-side logout endpoint's own idempotence. pub fn remove(path: &Path) -> Result<()> { @@ -140,6 +252,8 @@ mod tests { version: CREDENTIALS_VERSION, token: "pcli_test-token-not-a-real-secret".to_string(), expires_at: expires_at.to_string(), + refresh_token: Some("pcli_test-refresh-not-a-real-secret".to_string()), + refresh_expires_at: Some("2999-01-01T00:00:00.000Z".to_string()), api_base: "https://postil.dev/api/inference/v1".to_string(), org: "runatlas-is".to_string(), model: "z-ai/glm-5.2".to_string(), @@ -223,4 +337,69 @@ mod tests { fn unparsable_expiry_fails_closed_as_expired() { assert!(sample("not-a-timestamp").is_expired()); } + + #[test] + fn reads_a_v1_access_only_credential() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("credentials.json"); + fs::write( + &path, + r#"{"version":1,"token":"pcli_test-token-not-a-real-secret","expiresAt":"2999-01-01T00:00:00.000Z","apiBase":"https://postil.dev/api/inference/v1","org":"runatlas-is","model":"z-ai/glm-5.2"}"#, + ) + .unwrap(); + let credentials = read(&path).unwrap().unwrap(); + assert_eq!(credentials.version, 1); + assert!(credentials.refresh_token.is_none()); + assert!(credentials.refresh_expires_at.is_none()); + } + + #[test] + fn preserves_v1_for_an_access_only_credential() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("postil").join("credentials.json"); + let mut credentials = sample("2999-01-01T00:00:00.000Z"); + credentials.version = 1; + credentials.refresh_token = None; + credentials.refresh_expires_at = None; + write(&path, &credentials).unwrap(); + let raw = fs::read_to_string(path).unwrap(); + assert!(raw.contains("\"version\": 1")); + assert!(!raw.contains("refreshToken")); + } + + #[test] + fn rejects_unknown_future_credential_versions() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("credentials.json"); + let mut credentials = sample("2999-01-01T00:00:00.000Z"); + credentials.version = CREDENTIALS_VERSION + 1; + fs::write(&path, serde_json::to_string(&credentials).unwrap()).unwrap(); + let error = read(&path).expect_err("future credential versions must fail closed"); + assert!( + error + .to_string() + .contains("unsupported credentials version") + ); + } + + #[tokio::test] + #[cfg(unix)] + async fn stored_login_lock_file_has_owner_only_permission_bits() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let credentials_path = dir.path().join("postil").join("credentials.json"); + write(&credentials_path, &sample("2999-01-01T00:00:00.000Z")).unwrap(); + let lock = CredentialLock::acquire(&credentials_path).await.unwrap(); + drop(lock); + let lock_path = credentials_path + .parent() + .unwrap() + .join(".credentials.json.refresh.lock"); + let mode = fs::metadata(lock_path).unwrap().permissions().mode() & 0o777; + assert_eq!( + mode, 0o600, + "stored login lock file must be mode 0600, got {mode:o}" + ); + } } diff --git a/src/doctor.rs b/src/doctor.rs index cdd1fc0..798f57e 100644 --- a/src/doctor.rs +++ b/src/doctor.rs @@ -9,6 +9,7 @@ use crate::api_key; use crate::config::Config; use crate::credentials; use crate::llm::LlmClient; +use crate::login; use anyhow::Result; pub struct Check { @@ -49,28 +50,22 @@ pub async fn run(cfg: &Config) -> Result> { }); let credentials_path = credentials::default_path(); - let stored_login = credentials_path - .as_ref() - .ok() - .and_then(|path| credentials::read(path).ok().flatten()); - checks.push(Check { - name: "login", - ok: stored_login.as_ref().is_none_or(|c| !c.is_expired()), - detail: match &stored_login { - None => "not logged in; `postil login` gives zero-config hosted inference".to_string(), - Some(c) if c.is_expired() => format!( - "credential for org {} expired at {}; run `postil login` again", - c.org, c.expires_at - ), - Some(c) => format!("logged in to org {} (expires {})", c.org, c.expires_at), - }, + checks.push(match credentials_path.as_ref() { + Ok(path) => login_status_at(path), + Err(error) => { + login_status_error(format!("cannot inspect the stored login path: {error:#}")) + } }); let key_names = api_key::names_text(); - let key_lookup = credentials_path - .as_ref() - .map_err(|e| anyhow::anyhow!("{e:#}")) - .and_then(|path| api_key::resolve_effective(path)); + let key_lookup = if let Some(key) = api_key::resolve_from_process_env() { + Ok(Some(key)) + } else { + match credentials_path.as_ref() { + Ok(path) => login::resolve_stored_token(path).await, + Err(error) => Err(anyhow::anyhow!("{error:#}")), + } + }; let (key_ok, key_detail, key) = match key_lookup { Ok(Some(k)) => ( true, @@ -135,6 +130,82 @@ pub async fn run(cfg: &Config) -> Result> { Ok(checks) } +/// The non-secret login state shown by both `postil doctor` and `postil +/// config`. It deliberately reads without refreshing so configuration +/// inspection never rotates a credential. +pub fn login_status() -> Check { + match credentials::default_path() { + Ok(path) => login_status_at(&path), + Err(error) => { + login_status_error(format!("cannot inspect the stored login path: {error:#}")) + } + } +} + +fn login_status_error(detail: String) -> Check { + Check { + name: "login", + ok: false, + detail, + } +} + +fn login_status_at(credentials_path: &std::path::Path) -> Check { + let stored_login = match credentials::read(credentials_path) { + Ok(stored_login) => stored_login, + Err(error) => { + return login_status_error(format!( + "stored login is unreadable; run `postil login` again ({error:#})" + )); + } + }; + match stored_login { + None => Check { + name: "login", + ok: true, + detail: "not logged in; `postil login` gives zero-config hosted inference".to_string(), + }, + Some(c) if c.refresh_token.is_none() && c.refresh_expires_at.is_none() => Check { + name: "login", + ok: !c.is_expired(), + detail: if c.is_expired() { + format!( + "legacy access-only login for org {} expired at {}; run `postil login` again", + c.org, c.expires_at + ) + } else { + format!( + "legacy access-only login for org {} (access expires {})", + c.org, c.expires_at + ) + }, + }, + Some(c) if !c.can_refresh() => Check { + name: "login", + ok: false, + detail: format!( + "renewable login for org {} has refresh inactivity expiry {}; run `postil login` again", + c.org, + c.refresh_expires_at + .as_deref() + .unwrap_or("that is missing or invalid") + ), + }, + Some(c) => Check { + name: "login", + ok: true, + detail: format!( + "renewable login for org {} (access expires {}; refresh inactivity expires {})", + c.org, + c.expires_at, + c.refresh_expires_at + .as_deref() + .expect("renewable login has an expiry") + ), + }, + } +} + pub fn print_report(checks: &[Check]) -> bool { let mut all_ok = true; for c in checks { @@ -149,3 +220,74 @@ pub fn print_report(checks: &[Check]) -> bool { } all_ok } + +#[cfg(test)] +mod tests { + use super::*; + use crate::credentials::{self, Credentials}; + + fn stored_login() -> Credentials { + Credentials { + version: credentials::CREDENTIALS_VERSION, + token: "pcli_test-access-not-a-real-secret".to_string(), + expires_at: "2999-01-01T00:00:00.000Z".to_string(), + refresh_token: Some("pcli_test-refresh-not-a-real-secret".to_string()), + refresh_expires_at: Some("2999-12-01T00:00:00.000Z".to_string()), + api_base: "https://postil.dev/api/inference/v1".to_string(), + org: "runatlas-is".to_string(), + model: "z-ai/glm-5.2".to_string(), + } + } + + #[test] + fn status_distinguishes_a_renewable_login_and_its_expiries() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("credentials.json"); + credentials::write(&path, &stored_login()).unwrap(); + let check = login_status_at(&path); + assert!(check.ok); + assert!(check.detail.contains("renewable login")); + assert!(check.detail.contains("access expires")); + assert!(check.detail.contains("refresh inactivity expires")); + } + + #[test] + fn status_distinguishes_a_legacy_access_only_login() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("credentials.json"); + let mut legacy = stored_login(); + legacy.version = 1; + legacy.refresh_token = None; + legacy.refresh_expires_at = None; + credentials::write(&path, &legacy).unwrap(); + let check = login_status_at(&path); + assert!(check.ok); + assert!(check.detail.contains("legacy access-only")); + } + + #[test] + fn status_reports_refresh_inactivity_expiry() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("credentials.json"); + let mut expired = stored_login(); + expired.refresh_expires_at = Some("2020-01-01T00:00:00.000Z".to_string()); + credentials::write(&path, &expired).unwrap(); + let check = login_status_at(&path); + assert!(!check.ok); + assert!(check.detail.contains("refresh inactivity expiry")); + } + + #[test] + fn unreadable_login_is_unhealthy_without_exposing_file_contents() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("credentials.json"); + std::fs::write(&path, "private credential contents that are not JSON").unwrap(); + + let check = login_status_at(&path); + + assert!(!check.ok); + assert!(check.detail.contains("stored login is unreadable")); + assert!(check.detail.contains("postil login")); + assert!(!check.detail.contains("private credential contents")); + } +} diff --git a/src/llm.rs b/src/llm.rs index 19ff91e..c3ce009 100644 --- a/src/llm.rs +++ b/src/llm.rs @@ -2602,8 +2602,8 @@ impl LlmClient { /// Local and interactive clients have no built-in total deadline. They only /// get one when POSTIL_LLM_TOTAL_TIMEOUT_SECS is explicitly set. - pub fn from_env(cfg: &Config) -> Result { - let api_key = resolve_api_key()?; + pub async fn from_env(cfg: &Config) -> Result { + let api_key = resolve_api_key().await?; let timeouts = LlmTimeouts::from_env(DEFAULT_REQUEST_TIMEOUT_SECS, None)?; let total_deadline = timeouts.total.map(|duration| Instant::now() + duration); Self::build( @@ -2615,14 +2615,14 @@ impl LlmClient { ) } - pub(crate) fn from_env_for_remote_review( + pub(crate) async fn from_env_for_remote_review( cfg: &Config, total_budget_started_at: Instant, default_request_timeout: Duration, default_review_timeout: Duration, default_total_timeout: Duration, ) -> Result { - let api_key = resolve_api_key()?; + let api_key = resolve_api_key().await?; let timeouts = LlmTimeouts::from_env( default_request_timeout.as_secs(), Some(default_total_timeout.as_secs()), @@ -5650,16 +5650,17 @@ impl LlmTimeouts { } } -fn resolve_api_key() -> Result { +async fn resolve_api_key() -> Result { // Checked first, and independently of the credentials-path lookup below, // so an explicit key still works in a minimal environment with no HOME // or XDG_CONFIG_HOME -- exactly what worked before login existed. - if let Some(key) = api_key::resolve_from_process_env() { - return Ok(key); - } - let credentials_path = crate::credentials::default_path() - .context("resolving the postil login credentials path")?; - match api_key::resolve_effective(&credentials_path)? { + let explicit_key = api_key::resolve_from_process_env(); + let stored_login = async { + let credentials_path = crate::credentials::default_path() + .context("resolving the postil login credentials path")?; + crate::login::resolve_stored_token(&credentials_path).await + }; + match api_key::resolve_explicit_or_stored(explicit_key, stored_login).await? { Some(key) => Ok(key), None => { let key_names = api_key::names_text(); @@ -6772,13 +6773,13 @@ mod tests { EnvRestore::remove(TOTAL_TIMEOUT_ENV); EnvRestore::set("POSTIL_API_KEY", "test-key"); - let client = LlmClient::from_env_for_remote_review( + let client = futures::executor::block_on(LlmClient::from_env_for_remote_review( &Config::default(), Instant::now(), Duration::from_secs(crate::review::HOSTED_LLM_REQUEST_TIMEOUT_SECS), Duration::from_secs(crate::review::HOSTED_LLM_REVIEW_TIMEOUT_SECS), Duration::from_secs(crate::review::HOSTED_LLM_TOTAL_TIMEOUT_SECS), - ) + )) .unwrap(); assert_eq!( client.review_model_timeout, @@ -6816,13 +6817,13 @@ mod tests { let wave_slot = Duration::from_secs(crate::review::LARGE_DIFF_LLM_REQUEST_TIMEOUT_SECS); let build = || { - LlmClient::from_env_for_remote_review( + futures::executor::block_on(LlmClient::from_env_for_remote_review( &config, Instant::now(), Duration::from_secs(crate::review::HOSTED_LLM_REQUEST_TIMEOUT_SECS), Duration::from_secs(review_budget), Duration::from_secs(crate::review::HOSTED_LLM_TOTAL_TIMEOUT_SECS), - ) + )) .unwrap() }; @@ -8743,13 +8744,13 @@ mod tests { let elapsed = Duration::from_secs(10); let started_at = Instant::now() - elapsed; - let client = LlmClient::from_env_for_remote_review( + let client = futures::executor::block_on(LlmClient::from_env_for_remote_review( &Config::default(), started_at, Duration::from_secs(crate::review::HOSTED_LLM_REQUEST_TIMEOUT_SECS), Duration::from_secs(crate::review::HOSTED_LLM_REVIEW_TIMEOUT_SECS), Duration::from_secs(crate::review::HOSTED_LLM_TOTAL_TIMEOUT_SECS), - ) + )) .unwrap(); let remaining = client.remaining_budget(LlmPhase::Total).unwrap().unwrap(); diff --git a/src/login.rs b/src/login.rs index e9af441..5ce961c 100644 --- a/src/login.rs +++ b/src/login.rs @@ -2,16 +2,16 @@ //! Postil web app, modelled on RFC 8628. Chosen because the CLI runs over //! SSH and in containers where no localhost browser callback is reachable. //! -//! A successful login writes a credential the API-key resolver -//! (`api_key::resolve_effective`) falls back to when no explicit key env var -//! is set, so a first-time user gets working hosted inference with zero -//! configuration. +//! A successful login writes a credential that `resolve_stored_token` uses +//! when no explicit key environment variable is set, so a first-time user gets +//! working hosted inference with zero configuration. use std::path::Path; use std::time::Duration; use anyhow::{Context, Result, anyhow}; use serde::Deserialize; +use serde::de::DeserializeOwned; use crate::credentials::{self, Credentials}; use crate::llm::secure_http_client; @@ -26,6 +26,9 @@ const DEFAULT_LOGIN_SERVER: &str = "https://postil.dev"; /// this is only a client-side backstop against a stuck loop if that cap is /// ever missed (a server bug, or a hostile `interval`). const MAX_POLL_ATTEMPTS: u32 = 220; +const ACCESS_REFRESH_MARGIN: Duration = Duration::from_secs(5 * 60); +const REFRESH_RESPONSE_MAX_BYTES: usize = 16 * 1024; +const REFRESH_REQUEST_TIMEOUT: Duration = Duration::from_secs(20); fn login_server() -> String { std::env::var(LOGIN_SERVER_ENV) @@ -67,12 +70,27 @@ struct DeviceTokenApproved { token: String, #[serde(rename = "expiresAt")] expires_at: String, + #[serde(rename = "refreshToken")] + refresh_token: Option, + #[serde(rename = "refreshExpiresAt")] + refresh_expires_at: Option, #[serde(rename = "apiBase")] api_base: String, org: DeviceTokenOrg, model: String, } +#[derive(Debug, Deserialize)] +struct RefreshTokenResponse { + token: String, + #[serde(rename = "expiresAt")] + expires_at: String, + #[serde(rename = "refreshToken")] + refresh_token: String, + #[serde(rename = "refreshExpiresAt")] + refresh_expires_at: String, +} + #[derive(Debug, Deserialize)] struct DeviceTokenOrg { slug: String, @@ -136,19 +154,64 @@ async fn login_with( .json() .await .context("parsing postil login approval response")?; - let creds = Credentials { - version: credentials::CREDENTIALS_VERSION, - token: approved.token, - expires_at: approved.expires_at, - api_base: approved.api_base, - org: approved.org.slug, - model: approved.model, + let DeviceTokenApproved { + token, + expires_at, + refresh_token, + refresh_expires_at, + api_base, + org, + model, + } = approved; + let renewable = match (refresh_token, refresh_expires_at) { + (Some(refresh_token), Some(refresh_expires_at)) => { + let credentials = Credentials { + version: credentials::CREDENTIALS_VERSION, + token, + expires_at, + refresh_token: Some(refresh_token), + refresh_expires_at: Some(refresh_expires_at), + api_base, + org: org.slug, + model, + }; + validate_refresh_response(&credentials) + .context("validating postil login approval response")?; + credentials + } + (None, None) => Credentials { + version: credentials::LEGACY_CREDENTIALS_VERSION, + token, + expires_at, + refresh_token: None, + refresh_expires_at: None, + api_base, + org: org.slug, + model, + }, + _ => anyhow::bail!( + "the postil login approval response has incomplete renewable credentials" + ), }; - credentials::write(credentials_path, &creds)?; - eprintln!( - "postil: logged in to {} ({}) -- credential expires {}", - approved.org.name, creds.org, creds.expires_at - ); + let renewable_login = renewable.refresh_token.is_some(); + let _lock = credentials::CredentialLock::acquire(credentials_path).await?; + credentials::write(credentials_path, &renewable)?; + if renewable_login { + eprintln!( + "postil: logged in to {} ({}): renewable until inactive after {}", + org.name, + renewable.org, + renewable + .refresh_expires_at + .as_deref() + .expect("renewable credentials have an expiry") + ); + } else { + eprintln!( + "postil: logged in to {} ({}) with an access-only credential", + org.name, renewable.org + ); + } return Ok(0); } 428 => continue, @@ -181,15 +244,21 @@ async fn logout_with( server: &str, credentials_path: &Path, ) -> Result { + let _lock = credentials::CredentialLock::acquire(credentials_path).await?; match credentials::read(credentials_path) { Ok(Some(creds)) => { let logout_url = format!("{}/api/cli/logout", server.trim_end_matches('/')); - match client - .post(&logout_url) - .bearer_auth(&creds.token) - .send() - .await + let request = client.post(&logout_url).bearer_auth(&creds.token); + let request = if let Some(refresh_token) = creds + .refresh_token + .as_deref() + .filter(|token| !token.trim().is_empty()) { + request.json(&serde_json::json!({ "refreshToken": refresh_token })) + } else { + request + }; + match request.send().await { Ok(response) if response.status().is_success() => {} Ok(response) => eprintln!( "postil: logout request returned {}; removing the local credential anyway", @@ -210,6 +279,137 @@ async fn logout_with( Ok(0) } +/// Resolves a stored login for a hosted request. Explicit API-key environment +/// variables are handled by the caller before this function is reached, so a +/// BYOK invocation never reads, locks, or refreshes a local Postil login. +pub(crate) async fn resolve_stored_token(credentials_path: &Path) -> Result> { + let server = login_server(); + let client = secure_http_client(&server).context("building the postil refresh HTTP client")?; + resolve_stored_token_with(&client, &server, credentials_path).await +} + +async fn resolve_stored_token_with( + client: &reqwest::Client, + server: &str, + credentials_path: &Path, +) -> Result> { + let Some(credentials) = credentials::read(credentials_path)? else { + return Ok(None); + }; + if credentials.refresh_token.is_some() && !credentials.can_refresh() { + anyhow::bail!("the stored postil login can no longer be renewed; run `postil login` again"); + } + if !credentials.expires_within(ACCESS_REFRESH_MARGIN) { + return Ok(Some(credentials.token)); + } + + let _lock = credentials::CredentialLock::acquire(credentials_path).await?; + let Some(credentials) = credentials::read(credentials_path)? else { + return Ok(None); + }; + if credentials.refresh_token.is_some() && !credentials.can_refresh() { + anyhow::bail!("the stored postil login can no longer be renewed; run `postil login` again"); + } + if !credentials.expires_within(ACCESS_REFRESH_MARGIN) { + return Ok(Some(credentials.token)); + } + let Some(refresh_token) = credentials.refresh_token.as_deref() else { + anyhow::bail!("the stored postil login credential expired; run `postil login` again"); + }; + + let refreshed = refresh_token_with(client, server, refresh_token).await?; + let replacement = Credentials { + version: credentials::CREDENTIALS_VERSION, + token: refreshed.token, + expires_at: refreshed.expires_at, + refresh_token: Some(refreshed.refresh_token), + refresh_expires_at: Some(refreshed.refresh_expires_at), + api_base: credentials.api_base, + org: credentials.org, + model: credentials.model, + }; + validate_refresh_response(&replacement)?; + credentials::write(credentials_path, &replacement)?; + Ok(Some(replacement.token)) +} + +async fn refresh_token_with( + client: &reqwest::Client, + server: &str, + refresh_token: &str, +) -> Result { + let refresh_url = format!("{}/api/cli/token/refresh", server.trim_end_matches('/')); + let response = tokio::time::timeout( + REFRESH_REQUEST_TIMEOUT, + client + .post(&refresh_url) + .json(&serde_json::json!({ "refreshToken": refresh_token })) + .send(), + ) + .await + .map_err(|_| anyhow!("could not refresh the stored postil login; try again"))? + .map_err(|_| anyhow!("could not refresh the stored postil login; try again"))?; + let status = response.status(); + if status.is_server_error() || matches!(status.as_u16(), 408 | 429) { + anyhow::bail!("could not refresh the stored postil login; try again"); + } + if !status.is_success() { + anyhow::bail!("the stored postil login can no longer be renewed; run `postil login` again"); + } + match tokio::time::timeout(REFRESH_REQUEST_TIMEOUT, bounded_json(response)).await { + Ok(Ok(response)) => Ok(response), + Ok(Err(RefreshResponseError::Invalid)) => { + anyhow::bail!("the postil refresh response was invalid; run `postil login` again") + } + Ok(Err(RefreshResponseError::Transport)) | Err(_) => { + anyhow::bail!("could not refresh the stored postil login; try again") + } + } +} + +enum RefreshResponseError { + Transport, + Invalid, +} + +async fn bounded_json( + mut response: reqwest::Response, +) -> std::result::Result { + let expected_length = response.content_length(); + if expected_length.is_some_and(|length| length > REFRESH_RESPONSE_MAX_BYTES as u64) { + return Err(RefreshResponseError::Invalid); + } + let mut bytes = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .map_err(|_| RefreshResponseError::Transport)? + { + if bytes.len().saturating_add(chunk.len()) > REFRESH_RESPONSE_MAX_BYTES { + return Err(RefreshResponseError::Invalid); + } + bytes.extend_from_slice(&chunk); + } + if expected_length.is_some_and(|length| length != bytes.len() as u64) { + return Err(RefreshResponseError::Transport); + } + serde_json::from_slice(&bytes).map_err(|_| RefreshResponseError::Invalid) +} + +fn validate_refresh_response(credentials: &Credentials) -> Result<()> { + anyhow::ensure!( + !credentials.token.trim().is_empty() + && credentials + .refresh_token + .as_deref() + .is_some_and(|token| !token.trim().is_empty()) + && !credentials.is_expired() + && credentials.can_refresh(), + "refresh response did not contain usable credentials" + ); + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -248,12 +448,24 @@ mod tests { "status": "approved", "token": "pcli_test-token-not-a-real-secret", "expiresAt": "2999-01-01T00:00:00.000Z", + "refreshToken": "pcli_test-refresh-not-a-real-secret", + "refreshExpiresAt": "2999-12-01T00:00:00.000Z", "apiBase": "https://postil.dev/api/inference/v1", "org": {"slug": "runatlas-is", "name": "RunAtlas"}, "model": "z-ai/glm-5.2", }) } + fn legacy_approved_body() -> serde_json::Value { + let mut body = approved_body(); + let object = body + .as_object_mut() + .expect("approved response fixture is an object"); + object.remove("refreshToken"); + object.remove("refreshExpiresAt"); + body + } + fn start_body() -> serde_json::Value { serde_json::json!({ "deviceCode": "test-device-code", @@ -294,6 +506,83 @@ mod tests { assert_eq!(stored.token, "pcli_test-token-not-a-real-secret"); assert_eq!(stored.org, "runatlas-is"); assert_eq!(stored.model, "z-ai/glm-5.2"); + assert_eq!( + stored.refresh_expires_at.as_deref(), + Some("2999-12-01T00:00:00.000Z") + ); + } + + #[tokio::test] + async fn login_waits_for_an_in_progress_stored_login_mutation() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/cli/device/start")) + .respond_with(ResponseTemplate::new(200).set_body_json(start_body())) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/api/cli/device/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(approved_body())) + .mount(&server) + .await; + + let dir = tempfile::tempdir().unwrap(); + let credentials_path = dir.path().join("postil").join("credentials.json"); + let held_lock = credentials::CredentialLock::acquire(&credentials_path) + .await + .unwrap(); + let task_path = credentials_path.clone(); + let server_uri = server.uri(); + let login = tokio::spawn(async move { + login_with(&reqwest::Client::new(), &server_uri, None, &task_path).await + }); + + tokio::time::sleep(Duration::from_millis(100)).await; + assert!(!login.is_finished()); + assert!(!credentials_path.exists()); + drop(held_lock); + + let exit = tokio::time::timeout(Duration::from_secs(2), login) + .await + .expect("login should resume after the stored login lock is released") + .unwrap() + .unwrap(); + assert_eq!(exit, 0); + assert!(credentials::read(&credentials_path).unwrap().is_some()); + } + + #[tokio::test] + async fn login_accepts_a_legacy_access_only_approval_response() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/cli/device/start")) + .respond_with(ResponseTemplate::new(200).set_body_json(start_body())) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/api/cli/device/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(legacy_approved_body())) + .mount(&server) + .await; + + let dir = tempfile::tempdir().unwrap(); + let credentials_path = dir.path().join("postil").join("credentials.json"); + assert_eq!( + login_with( + &reqwest::Client::new(), + &server.uri(), + None, + &credentials_path, + ) + .await + .unwrap(), + 0 + ); + + let stored = credentials::read(&credentials_path).unwrap().unwrap(); + assert_eq!(stored.version, 1); + assert!(stored.refresh_token.is_none()); + assert!(stored.refresh_expires_at.is_none()); } #[tokio::test] @@ -407,6 +696,8 @@ mod tests { version: credentials::CREDENTIALS_VERSION, token: "pcli_test-token-not-a-real-secret".to_string(), expires_at: "2999-01-01T00:00:00.000Z".to_string(), + refresh_token: Some("pcli_test-refresh-not-a-real-secret".to_string()), + refresh_expires_at: Some("2999-12-01T00:00:00.000Z".to_string()), api_base: "https://postil.dev/api/inference/v1".to_string(), org: "runatlas-is".to_string(), model: "z-ai/glm-5.2".to_string(), @@ -433,4 +724,456 @@ mod tests { assert_eq!(exit, 0); assert!(!credentials_path.exists()); } + + #[tokio::test] + async fn logout_waits_for_an_in_progress_stored_login_mutation() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/cli/logout")) + .respond_with(ResponseTemplate::new(200)) + .mount(&server) + .await; + let dir = tempfile::tempdir().unwrap(); + let credentials_path = dir.path().join("postil").join("credentials.json"); + credentials::write( + &credentials_path, + &stored_credentials("2999-01-01T00:00:00.000Z"), + ) + .unwrap(); + let held_lock = credentials::CredentialLock::acquire(&credentials_path) + .await + .unwrap(); + let task_path = credentials_path.clone(); + let server_uri = server.uri(); + let logout = tokio::spawn(async move { + logout_with(&reqwest::Client::new(), &server_uri, &task_path).await + }); + + tokio::time::sleep(Duration::from_millis(100)).await; + assert!(!logout.is_finished()); + assert!(credentials_path.exists()); + drop(held_lock); + + let exit = tokio::time::timeout(Duration::from_secs(2), logout) + .await + .expect("logout should resume after the stored login lock is released") + .unwrap() + .unwrap(); + assert_eq!(exit, 0); + assert!(!credentials_path.exists()); + } + + fn stored_credentials(expires_at: &str) -> Credentials { + Credentials { + version: credentials::CREDENTIALS_VERSION, + token: "pcli_test-old-access-not-a-real-secret".to_string(), + expires_at: expires_at.to_string(), + refresh_token: Some("pcli_test-old-refresh-not-a-real-secret".to_string()), + refresh_expires_at: Some("2999-12-01T00:00:00.000Z".to_string()), + api_base: "https://postil.dev/api/inference/v1".to_string(), + org: "runatlas-is".to_string(), + model: "z-ai/glm-5.2".to_string(), + } + } + + fn write_legacy_credentials(path: &Path, credentials: &Credentials) { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, serde_json::to_string(credentials).unwrap()).unwrap(); + } + + fn refresh_body() -> serde_json::Value { + serde_json::json!({ + "token": "pcli_test-new-access-not-a-real-secret", + "expiresAt": "2999-01-01T00:00:00.000Z", + "refreshToken": "pcli_test-new-refresh-not-a-real-secret", + "refreshExpiresAt": "2999-12-01T00:00:00.000Z" + }) + } + + #[tokio::test] + async fn refreshes_a_near_expiry_access_token_and_persists_rotation() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/cli/token/refresh")) + .respond_with(ResponseTemplate::new(200).set_body_json(refresh_body())) + .mount(&server) + .await; + let dir = tempfile::tempdir().unwrap(); + let credentials_path = dir.path().join("postil").join("credentials.json"); + credentials::write( + &credentials_path, + &stored_credentials("2020-01-01T00:00:00.000Z"), + ) + .unwrap(); + + let resolved = + resolve_stored_token_with(&reqwest::Client::new(), &server.uri(), &credentials_path) + .await + .unwrap(); + assert_eq!( + resolved.as_deref(), + Some("pcli_test-new-access-not-a-real-secret") + ); + let stored = credentials::read(&credentials_path).unwrap().unwrap(); + assert_eq!(stored.token, "pcli_test-new-access-not-a-real-secret"); + assert_eq!( + stored.refresh_token.as_deref(), + Some("pcli_test-new-refresh-not-a-real-secret") + ); + } + + #[tokio::test] + async fn refreshes_inside_the_proactive_access_margin() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/cli/token/refresh")) + .respond_with(ResponseTemplate::new(200).set_body_json(refresh_body())) + .expect(1) + .mount(&server) + .await; + let expires_at = (time::OffsetDateTime::now_utc() + time::Duration::minutes(2)) + .format(&time::format_description::well_known::Rfc3339) + .unwrap(); + let dir = tempfile::tempdir().unwrap(); + let credentials_path = dir.path().join("postil").join("credentials.json"); + credentials::write(&credentials_path, &stored_credentials(&expires_at)).unwrap(); + + resolve_stored_token_with(&reqwest::Client::new(), &server.uri(), &credentials_path) + .await + .unwrap(); + } + + #[tokio::test] + async fn concurrent_resolvers_share_one_refresh_rotation() { + #[derive(Clone)] + struct DelayedRefreshResponder { + calls: Arc, + } + + impl Respond for DelayedRefreshResponder { + fn respond(&self, _request: &Request) -> ResponseTemplate { + self.calls.fetch_add(1, Ordering::SeqCst); + ResponseTemplate::new(200) + .set_body_json(refresh_body()) + .set_delay(Duration::from_millis(100)) + } + } + + let server = MockServer::start().await; + let responder = DelayedRefreshResponder { + calls: Arc::new(AtomicUsize::new(0)), + }; + Mock::given(method("POST")) + .and(path("/api/cli/token/refresh")) + .respond_with(responder.clone()) + .mount(&server) + .await; + let dir = tempfile::tempdir().unwrap(); + let credentials_path = dir.path().join("postil").join("credentials.json"); + credentials::write( + &credentials_path, + &stored_credentials("2020-01-01T00:00:00.000Z"), + ) + .unwrap(); + let client = reqwest::Client::new(); + let server_uri = server.uri(); + + let (first, second) = tokio::join!( + resolve_stored_token_with(&client, &server_uri, &credentials_path), + resolve_stored_token_with(&client, &server_uri, &credentials_path) + ); + assert_eq!( + first.unwrap().as_deref(), + Some("pcli_test-new-access-not-a-real-secret") + ); + assert_eq!( + second.unwrap().as_deref(), + Some("pcli_test-new-access-not-a-real-secret") + ); + assert_eq!(responder.calls.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn failed_refresh_keeps_the_previous_credential() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/cli/token/refresh")) + .respond_with(ResponseTemplate::new(503).set_body_string("not shown")) + .mount(&server) + .await; + let dir = tempfile::tempdir().unwrap(); + let credentials_path = dir.path().join("postil").join("credentials.json"); + let original = stored_credentials("2020-01-01T00:00:00.000Z"); + credentials::write(&credentials_path, &original).unwrap(); + + let error = + resolve_stored_token_with(&reqwest::Client::new(), &server.uri(), &credentials_path) + .await + .expect_err("a failed refresh must fail closed"); + assert!(error.to_string().contains("try again")); + assert_eq!( + credentials::read(&credentials_path).unwrap().unwrap(), + original + ); + } + + #[tokio::test] + async fn malformed_refresh_response_requires_relogin_without_leaking_it() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/cli/token/refresh")) + .respond_with( + ResponseTemplate::new(200).set_body_string("server body must stay private"), + ) + .mount(&server) + .await; + let dir = tempfile::tempdir().unwrap(); + let credentials_path = dir.path().join("postil").join("credentials.json"); + let original = stored_credentials("2020-01-01T00:00:00.000Z"); + credentials::write(&credentials_path, &original).unwrap(); + + let error = + resolve_stored_token_with(&reqwest::Client::new(), &server.uri(), &credentials_path) + .await + .expect_err("a malformed response must not be accepted"); + assert!(error.to_string().contains("postil login")); + assert!(!error.to_string().contains("server body must stay private")); + assert_eq!( + credentials::read(&credentials_path).unwrap().unwrap(), + original + ); + } + + #[tokio::test] + async fn rejected_refresh_requires_relogin_without_leaking_the_server_body() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/cli/token/refresh")) + .respond_with(ResponseTemplate::new(401).set_body_string("refresh replay details")) + .mount(&server) + .await; + let dir = tempfile::tempdir().unwrap(); + let credentials_path = dir.path().join("postil").join("credentials.json"); + let original = stored_credentials("2020-01-01T00:00:00.000Z"); + credentials::write(&credentials_path, &original).unwrap(); + + let error = + resolve_stored_token_with(&reqwest::Client::new(), &server.uri(), &credentials_path) + .await + .expect_err("a replayed refresh token must require login"); + assert!(error.to_string().contains("postil login")); + assert!(!error.to_string().contains("refresh replay details")); + assert_eq!( + credentials::read(&credentials_path).unwrap().unwrap(), + original + ); + } + + #[tokio::test] + async fn expired_v1_login_requires_relogin() { + let dir = tempfile::tempdir().unwrap(); + let credentials_path = dir.path().join("postil").join("credentials.json"); + let mut legacy = stored_credentials("2020-01-01T00:00:00.000Z"); + legacy.version = 1; + legacy.refresh_token = None; + legacy.refresh_expires_at = None; + write_legacy_credentials(&credentials_path, &legacy); + + let error = resolve_stored_token_with( + &reqwest::Client::new(), + "http://127.0.0.1:9", + &credentials_path, + ) + .await + .expect_err("expired legacy credentials must require login"); + assert!(error.to_string().contains("postil login")); + } + + #[tokio::test] + async fn unexpired_v1_login_remains_usable_without_a_refresh() { + let dir = tempfile::tempdir().unwrap(); + let credentials_path = dir.path().join("postil").join("credentials.json"); + let mut legacy = stored_credentials("2999-01-01T00:00:00.000Z"); + legacy.version = 1; + legacy.refresh_token = None; + legacy.refresh_expires_at = None; + write_legacy_credentials(&credentials_path, &legacy); + + let resolved = resolve_stored_token_with( + &reqwest::Client::new(), + "http://127.0.0.1:9", + &credentials_path, + ) + .await + .unwrap(); + assert_eq!( + resolved.as_deref(), + Some("pcli_test-old-access-not-a-real-secret") + ); + } + + #[tokio::test] + async fn expired_refresh_inactivity_requires_relogin() { + let dir = tempfile::tempdir().unwrap(); + let credentials_path = dir.path().join("postil").join("credentials.json"); + let mut expired = stored_credentials("2999-01-01T00:00:00.000Z"); + expired.refresh_expires_at = Some("2020-01-01T00:00:00.000Z".to_string()); + credentials::write(&credentials_path, &expired).unwrap(); + + let error = resolve_stored_token_with( + &reqwest::Client::new(), + "http://127.0.0.1:9", + &credentials_path, + ) + .await + .expect_err("expired refresh inactivity must require login"); + assert!(error.to_string().contains("postil login")); + } + + #[tokio::test] + async fn logout_sends_refresh_token_even_after_access_expiry() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/cli/logout")) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&server) + .await; + let dir = tempfile::tempdir().unwrap(); + let credentials_path = dir.path().join("postil").join("credentials.json"); + credentials::write( + &credentials_path, + &stored_credentials("2020-01-01T00:00:00.000Z"), + ) + .unwrap(); + + logout_with(&reqwest::Client::new(), &server.uri(), &credentials_path) + .await + .unwrap(); + assert!(!credentials_path.exists()); + let requests = server.received_requests().await.unwrap(); + let request = requests.first().unwrap(); + assert!(request.headers.contains_key("authorization")); + assert!( + std::str::from_utf8(&request.body) + .unwrap() + .contains("refreshToken") + ); + } + + #[tokio::test] + async fn legacy_logout_uses_its_access_bearer_without_a_null_refresh_body() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/cli/logout")) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&server) + .await; + let dir = tempfile::tempdir().unwrap(); + let credentials_path = dir.path().join("postil").join("credentials.json"); + let mut legacy = stored_credentials("2999-01-01T00:00:00.000Z"); + legacy.version = 1; + legacy.refresh_token = None; + legacy.refresh_expires_at = None; + credentials::write(&credentials_path, &legacy).unwrap(); + + assert_eq!( + logout_with(&reqwest::Client::new(), &server.uri(), &credentials_path) + .await + .unwrap(), + 0 + ); + assert!(!credentials_path.exists()); + let requests = server.received_requests().await.unwrap(); + assert!(requests.first().unwrap().body.is_empty()); + } + + #[tokio::test] + async fn rate_limited_refresh_keeps_the_previous_credential() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/cli/token/refresh")) + .respond_with(ResponseTemplate::new(429).set_body_string("retry later")) + .mount(&server) + .await; + let dir = tempfile::tempdir().unwrap(); + let credentials_path = dir.path().join("postil").join("credentials.json"); + let original = stored_credentials("2020-01-01T00:00:00.000Z"); + credentials::write(&credentials_path, &original).unwrap(); + + let error = + resolve_stored_token_with(&reqwest::Client::new(), &server.uri(), &credentials_path) + .await + .expect_err("rate-limited refreshes must fail closed without relogin"); + assert!(error.to_string().contains("try again")); + assert!(!error.to_string().contains("run `postil login` again")); + assert_eq!( + credentials::read(&credentials_path).unwrap().unwrap(), + original + ); + } + + #[tokio::test] + async fn request_timeout_refresh_keeps_the_previous_credential() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/cli/token/refresh")) + .respond_with(ResponseTemplate::new(408).set_body_string("retry later")) + .mount(&server) + .await; + let dir = tempfile::tempdir().unwrap(); + let credentials_path = dir.path().join("postil").join("credentials.json"); + let original = stored_credentials("2020-01-01T00:00:00.000Z"); + credentials::write(&credentials_path, &original).unwrap(); + + let error = + resolve_stored_token_with(&reqwest::Client::new(), &server.uri(), &credentials_path) + .await + .expect_err("request-timeout refreshes must retain the credential"); + assert!(error.to_string().contains("try again")); + assert!(!error.to_string().contains("run `postil login` again")); + assert_eq!( + credentials::read(&credentials_path).unwrap().unwrap(), + original + ); + } + + #[tokio::test] + async fn truncated_successful_refresh_response_is_retryable_and_preserves_the_credential() { + use std::io::{Read, Write}; + + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = [0_u8; 4_096]; + let _ = stream.read(&mut request); + stream + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 4096\r\nConnection: close\r\n\r\n{\"token\":\"committed-but-truncated\"", + ) + .unwrap(); + }); + let dir = tempfile::tempdir().unwrap(); + let credentials_path = dir.path().join("postil").join("credentials.json"); + let original = stored_credentials("2020-01-01T00:00:00.000Z"); + credentials::write(&credentials_path, &original).unwrap(); + + let error = resolve_stored_token_with( + &reqwest::Client::new(), + &format!("http://{address}"), + &credentials_path, + ) + .await + .expect_err("a truncated committed response must be retried with the old credential"); + + server.join().unwrap(); + assert!(error.to_string().contains("try again")); + assert!(!error.to_string().contains("run `postil login` again")); + assert_eq!( + credentials::read(&credentials_path).unwrap().unwrap(), + original + ); + } } diff --git a/src/main.rs b/src/main.rs index 9354f36..fbc3c29 100644 --- a/src/main.rs +++ b/src/main.rs @@ -201,6 +201,8 @@ async fn dispatch(cli: Cli) -> anyhow::Result { println!("model.apiBase: {}", cfg.api_base); println!("model.apiFormat: {}", cfg.api_format.as_str()); println!("model.consensus: {}", cfg.consensus); + let login = doctor::login_status(); + println!("login.status: {}", login.detail); Ok(0) } Command::Init { force } => { diff --git a/src/respond.rs b/src/respond.rs index 3f65d96..0206fa0 100644 --- a/src/respond.rs +++ b/src/respond.rs @@ -297,7 +297,7 @@ async fn respond_with( let system = prompt::respond_system_prompt(cfg, current_utc_date); let user = respond_user_prompt(&context, comment); - let client = LlmClient::from_env(cfg)?; + let client = LlmClient::from_env(cfg).await?; client.preflight_respond_plan(cfg, &system, &user)?; let answer = client .answer(cfg, &system, &user, validate_respond_output) diff --git a/src/review.rs b/src/review.rs index b58352b..af42138 100644 --- a/src/review.rs +++ b/src/review.rs @@ -1755,16 +1755,19 @@ async fn review_diff_at( None }; let client = match llm_budget_started_at { - Some(started_at) => LlmClient::from_env_for_remote_review( - cfg, - started_at, - Duration::from_secs(hosted_request_timeout_secs( - deterministic_large_review, - )), - Duration::from_secs(hosted_review_timeout_secs(cfg)), - Duration::from_secs(HOSTED_LLM_TOTAL_TIMEOUT_SECS), - )?, - None => LlmClient::from_env(cfg)?, + Some(started_at) => { + LlmClient::from_env_for_remote_review( + cfg, + started_at, + Duration::from_secs(hosted_request_timeout_secs( + deterministic_large_review, + )), + Duration::from_secs(hosted_review_timeout_secs(cfg)), + Duration::from_secs(HOSTED_LLM_TOTAL_TIMEOUT_SECS), + ) + .await? + } + None => LlmClient::from_env(cfg).await?, }; let planned_batch_count = large_diff_receipt.as_ref().map_or_else( || { From a6ef44c65f41e7817db304e33f4d30a923937069 Mon Sep 17 00:00:00 2001 From: Postil Maintainer Date: Mon, 24 Aug 2026 23:57:43 +0000 Subject: [PATCH 2/7] Clarify refresh credential fixtures --- src/credentials.rs | 2 +- src/doctor.rs | 2 +- src/login.rs | 10 +++++----- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/credentials.rs b/src/credentials.rs index bf65f1e..a8d7414 100644 --- a/src/credentials.rs +++ b/src/credentials.rs @@ -252,7 +252,7 @@ mod tests { version: CREDENTIALS_VERSION, token: "pcli_test-token-not-a-real-secret".to_string(), expires_at: expires_at.to_string(), - refresh_token: Some("pcli_test-refresh-not-a-real-secret".to_string()), + refresh_token: Some("fixture-refresh-not-a-credential".to_string()), refresh_expires_at: Some("2999-01-01T00:00:00.000Z".to_string()), api_base: "https://postil.dev/api/inference/v1".to_string(), org: "runatlas-is".to_string(), diff --git a/src/doctor.rs b/src/doctor.rs index 798f57e..915a74b 100644 --- a/src/doctor.rs +++ b/src/doctor.rs @@ -231,7 +231,7 @@ mod tests { version: credentials::CREDENTIALS_VERSION, token: "pcli_test-access-not-a-real-secret".to_string(), expires_at: "2999-01-01T00:00:00.000Z".to_string(), - refresh_token: Some("pcli_test-refresh-not-a-real-secret".to_string()), + refresh_token: Some("fixture-refresh-not-a-credential".to_string()), refresh_expires_at: Some("2999-12-01T00:00:00.000Z".to_string()), api_base: "https://postil.dev/api/inference/v1".to_string(), org: "runatlas-is".to_string(), diff --git a/src/login.rs b/src/login.rs index 5ce961c..38fb95b 100644 --- a/src/login.rs +++ b/src/login.rs @@ -448,7 +448,7 @@ mod tests { "status": "approved", "token": "pcli_test-token-not-a-real-secret", "expiresAt": "2999-01-01T00:00:00.000Z", - "refreshToken": "pcli_test-refresh-not-a-real-secret", + "refreshToken": "fixture-refresh-not-a-credential", "refreshExpiresAt": "2999-12-01T00:00:00.000Z", "apiBase": "https://postil.dev/api/inference/v1", "org": {"slug": "runatlas-is", "name": "RunAtlas"}, @@ -696,7 +696,7 @@ mod tests { version: credentials::CREDENTIALS_VERSION, token: "pcli_test-token-not-a-real-secret".to_string(), expires_at: "2999-01-01T00:00:00.000Z".to_string(), - refresh_token: Some("pcli_test-refresh-not-a-real-secret".to_string()), + refresh_token: Some("fixture-refresh-not-a-credential".to_string()), refresh_expires_at: Some("2999-12-01T00:00:00.000Z".to_string()), api_base: "https://postil.dev/api/inference/v1".to_string(), org: "runatlas-is".to_string(), @@ -768,7 +768,7 @@ mod tests { version: credentials::CREDENTIALS_VERSION, token: "pcli_test-old-access-not-a-real-secret".to_string(), expires_at: expires_at.to_string(), - refresh_token: Some("pcli_test-old-refresh-not-a-real-secret".to_string()), + refresh_token: Some("fixture-old-refresh-not-a-credential".to_string()), refresh_expires_at: Some("2999-12-01T00:00:00.000Z".to_string()), api_base: "https://postil.dev/api/inference/v1".to_string(), org: "runatlas-is".to_string(), @@ -785,7 +785,7 @@ mod tests { serde_json::json!({ "token": "pcli_test-new-access-not-a-real-secret", "expiresAt": "2999-01-01T00:00:00.000Z", - "refreshToken": "pcli_test-new-refresh-not-a-real-secret", + "refreshToken": "fixture-new-refresh-not-a-credential", "refreshExpiresAt": "2999-12-01T00:00:00.000Z" }) } @@ -818,7 +818,7 @@ mod tests { assert_eq!(stored.token, "pcli_test-new-access-not-a-real-secret"); assert_eq!( stored.refresh_token.as_deref(), - Some("pcli_test-new-refresh-not-a-real-secret") + Some("fixture-new-refresh-not-a-credential") ); } From 19ba88eeac1754555bff294453ab89b7b4416e23 Mon Sep 17 00:00:00 2001 From: Postil Maintainer Date: Tue, 25 Aug 2026 01:33:01 +0000 Subject: [PATCH 3/7] Make local reviews batteries included --- ARCHITECTURE.md | 6 +- Cargo.toml | 2 +- README.md | 18 +- ROADMAP.md | 7 - bench/review-contract-sources.json | 2 +- bench/src/harness.ts | 4 +- bench/src/livemodels-score.ts | 2 +- bench/src/livemodels.test.ts | 7 +- bench/src/livemodels.ts | 2 +- config.toml | 2 + docs/automation.md | 13 - docs/configuration.md | 16 +- docs/model-providers.md | 8 +- qualified-models.json | 2 +- src/api_key.rs | 8 + src/cli.rs | 92 +- src/config.rs | 564 ++++++++-- src/diff.rs | 81 -- src/envelope.rs | 2 - src/forge/azure.rs | 45 +- src/forge/bitbucket.rs | 40 +- src/forge/github.rs | 232 +--- src/forge/gitlab.rs | 50 +- src/forge/mod.rs | 20 - src/lib.rs | 2 +- src/llm.rs | 408 +++---- src/local.rs | 682 +++++++++++- src/main.rs | 86 +- src/output.rs | 2 +- src/progress.rs | 143 +++ src/prompt.rs | 56 - src/respond.rs | 1080 ------------------- src/review.rs | 127 ++- tests/e2e.rs | 1581 ++++++++-------------------- 34 files changed, 2182 insertions(+), 3210 deletions(-) create mode 100644 src/progress.rs delete mode 100644 src/respond.rs diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e52a949..c4e3f42 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -137,13 +137,11 @@ acquire diff --> parse supported lockfiles --> parse + index --> bounded evidenc object mode, path, blob object ID, and gitlink path and object ID. Symlink blobs are searched as link text and are never followed. A snapshot containing a gitlink remains incomplete because content inside the referenced repository is outside the snapshot. -- `respond.rs`: interactive bot (`postil respond`): answers an @postil mention on a PR - or issue, grounded in the diff/issue, and posts one reply. Review-and-answer only; it - never opens PRs or pushes commits. - `sarif.rs`: envelope → SARIF 2.1.0 for code-scanning ingestion (`--sarif`). -- `config.rs`: precedence (flags > env > .postil.* > .coderabbit.yaml > defaults), +- `config.rs`: model precedence (flags > env > .postil.* > stored login > defaults), `deny_unknown_fields` so typos fail loudly. Also resolves `guardrails` and `content_policy`, the two prompt-injected repo policy sources (see below). + Translated `.coderabbit.yaml` settings supply review policy but do not select a model. Exception to precedence: `model.apiBase` from a config file is ignored by default (a repo could redirect the base URL that receives the inference credential); honored only with `POSTIL_ALLOW_CONFIG_API_BASE=1`. The diff --git a/Cargo.toml b/Cargo.toml index 1330d48..8b05732 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,7 +39,7 @@ sha2 = "0.11.0" similar = "3.1.1" tempfile = "3.27.0" time = { version = "0.3.54", features = ["parsing", "formatting"] } -tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "process", "time"] } +tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "process", "signal", "time"] } toml = "1.1.2" yaml_serde = "0.10.4" diff --git a/README.md b/README.md index 1bdf14d..a30f794 100644 --- a/README.md +++ b/README.md @@ -25,15 +25,17 @@ Authenticate once for hosted inference against your organization's entitlement, ```sh postil login # zero-config: stores a credential for hosted inference # or: export MODEL_API_KEY=... # OpenRouter is the default endpoint -# export REVIEW_MODEL=provider/qualified-model postil doctor # validate the endpoint and repository -postil review --staged # review the staged change +postil review # review staged, branch, or tracked working-tree changes +postil review --staged # explicitly review the staged change postil review --base origin/main # review the branch postil review --bounded --base origin/main # cap large reviews at five source batches postil hook install # add a pre-push review ``` -`postil review` exits `0` when the gate passes, `1` when it fails, and `2` when it cannot produce a review envelope. +Bare local review selects staged changes first, then committed changes since the current branch's locally known default branch, then tracked working-tree changes, and finally an empty clean diff. It sends the selected diff to the configured inference endpoint, but does not modify the working tree, index, or refs and does not write comments or checks to a forge unless `--publish` is supplied. + +`postil models` lists the embedded tested preset, reasoning defaults, accepted effort values, and exact override syntax. The reviewer defaults to `low` reasoning effort and the scorer defaults to `none`. `postil review` exits `0` when the gate passes, `1` when it fails, and `2` when it cannot produce a review envelope. ## Review a pull request @@ -48,7 +50,7 @@ For GitHub Actions, use [`postil-action`](https://github.com/postil-dev/postil-a ## Configuration -`postil init` writes `.postil.yaml`. Flags override environment variables, which override repository configuration and defaults. +`postil init` writes `.postil.yaml`. Flags override environment variables, which override trusted repository configuration, stored login routing, and embedded defaults. `postil config` prints both the resolved model and its winning source. ```yaml ignore: @@ -62,10 +64,12 @@ reviewer: gate: failOn: error onError: block +model: + reasoningEffort: low + scorerReasoningEffort: none ``` -Unknown keys are rejected. Repository configuration cannot redirect a deployment credential to another API host unless the operator explicitly permits that behavior. -The CLI has no implicit model roster. Review and scorer models must be selected explicitly from models qualified by the benchmark. +Unknown keys are rejected. Repository configuration cannot redirect a deployment credential to another API host unless the operator explicitly permits that behavior. The embedded local preset uses `openai/gpt-5.6-luna`; local scoring is disabled until explicitly configured. Hosted profile selection remains service-controlled. ## Documentation @@ -74,7 +78,7 @@ The CLI has no implicit model roster. Review and scorer models must be selected | [Configuration](docs/configuration.md) | Policy, precedence, model selection, and environment variables | | [Model providers](docs/model-providers.md) | OpenAI-compatible, Anthropic, and local endpoints | | [Code forges](docs/forges.md) | GitHub, GitLab, Bitbucket, and Azure DevOps | -| [Automation](docs/automation.md) | SARIF, incremental review, envelopes, planning, and interactive replies | +| [Automation](docs/automation.md) | SARIF, incremental review, envelopes, planning, and usage receipts | | [Architecture](ARCHITECTURE.md) | Trust boundaries and review pipeline | | [Benchmarks](bench/README.md) | Model evaluation harness | diff --git a/ROADMAP.md b/ROADMAP.md index fd75921..ade0d13 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -7,9 +7,6 @@ - Two-check semantics (postil/review advisory, postil/gate blocking); `gate.onError: block|advisory` for fail-open on provider outages. - Incremental re-review with resolved/carried reconciliation. -- Interactive bot (`postil respond`): replies to @postil mentions across every forge. - GitHub and GitLab cover PRs/MRs and issues; Bitbucket and Azure DevOps cover pull - requests. Review-and-answer only — never opens PRs or pushes. - SARIF 2.1.0 output (`--sarif`) for code-scanning ingestion. - Repo guardrails file (`.postil/guardrails.md`) injected into the prompt; violations surface as `kind: guardrail` findings that quote the rule. @@ -48,10 +45,6 @@ large PRs. - Learning from dismissals: feed comment-resolution outcomes from the hosted platform back into per-repo suppression hints. -- `postil respond` on the Bitbucket issue tracker and Azure DevOps work items (today - scoped to PRs there; those comment endpoints use a different base/version we have not - confirmed against a live host), and a visible error reply when the hosted bot exhausts - its retries (today a dead respond job is only logged). - An `/evidence` benchmark: Postil's own silence rate and confirmed-finding rate on public OSS PRs, with raw envelopes. diff --git a/bench/review-contract-sources.json b/bench/review-contract-sources.json index d7d6353..2f83d8e 100644 --- a/bench/review-contract-sources.json +++ b/bench/review-contract-sources.json @@ -28,10 +28,10 @@ "src/main.rs", "src/output.rs", "src/plan.rs", + "src/progress.rs", "src/prompt.rs", "src/repository_search.rs", "src/resolve.rs", - "src/respond.rs", "src/review.rs", "src/sarif.rs" ] diff --git a/bench/src/harness.ts b/bench/src/harness.ts index 155491d..ddaa211 100644 --- a/bench/src/harness.ts +++ b/bench/src/harness.ts @@ -443,7 +443,7 @@ export const envelopeV1 = z.object({ .array( z.object({ model: z.string().min(1), - role: z.enum(["reviewPlanner", "reviewGenerator", "findingScorer", "mentionResponder"]).optional(), + role: z.enum(["reviewPlanner", "reviewGenerator", "findingScorer"]).optional(), phase: z.enum(["initial", "schemaRepair", "semanticRetry"]).optional(), callOrdinal: z.number().int().positive().optional(), attempt: z.number().int().positive().optional(), @@ -459,7 +459,7 @@ export const envelopeV1 = z.object({ modelIncidents: z .array( z.object({ - phase: z.enum(["planner", "review", "scorer", "respond"]), + phase: z.enum(["planner", "review", "scorer"]), category: z.enum(["providerError", "invalidOutput", "timeout", "deadline"]), recovered: z.boolean(), recovery: z.enum(["repair", "fallback"]).optional(), diff --git a/bench/src/livemodels-score.ts b/bench/src/livemodels-score.ts index 23ccd3d..6fd41f5 100644 --- a/bench/src/livemodels-score.ts +++ b/bench/src/livemodels-score.ts @@ -94,7 +94,7 @@ export interface AttributionEvidenceReference { export interface UsageCostEvidence { model: string; - role: "reviewPlanner" | "reviewGenerator" | "findingScorer" | "mentionResponder" | null; + role: "reviewPlanner" | "reviewGenerator" | "findingScorer" | null; phase: "initial" | "schemaRepair" | "semanticRetry" | null; callOrdinal: number | null; attempt: number | null; diff --git a/bench/src/livemodels.test.ts b/bench/src/livemodels.test.ts index f39949f..f8c9acd 100644 --- a/bench/src/livemodels.test.ts +++ b/bench/src/livemodels.test.ts @@ -330,7 +330,12 @@ describe("pair qualification configuration", () => { "structured_outputs", "temperature", ]], - ["provider/generator-fallback", ["max_tokens", "temperature"]], + ["provider/generator-fallback", [ + "max_tokens", + "reasoning", + "reasoning_effort", + "temperature", + ]], ["provider/scorer-fallback", [ "max_tokens", "reasoning", diff --git a/bench/src/livemodels.ts b/bench/src/livemodels.ts index 64de418..5d399ae 100644 --- a/bench/src/livemodels.ts +++ b/bench/src/livemodels.ts @@ -2405,7 +2405,7 @@ export function qualificationRequiredParameters( }; for (const pair of pairs) { for (const model of qualificationGeneratorModels(pair)) { - add(model, ["max_tokens", "temperature"]); + add(model, ["max_tokens", "reasoning", "reasoning_effort", "temperature"]); } for (const model of qualificationScorerModels(pair)) { add(model, [ diff --git a/config.toml b/config.toml index 6b8566b..1d70b02 100644 --- a/config.toml +++ b/config.toml @@ -1,5 +1,6 @@ version = 3 default_model = "openai/gpt-5.6-luna" +reasoning_effort = "low" cascade = [] consensus = 1 api_base = "https://openrouter.ai/api/v1" @@ -8,5 +9,6 @@ api_format = "openai-compatible" [scorer] enabled = true default_model = "openai/gpt-5.6-luna" +reasoning_effort = "none" fallback = "" qualification_candidates = ["openai/gpt-5.6-luna"] diff --git a/docs/automation.md b/docs/automation.md index 64518b0..d04fc67 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -32,19 +32,6 @@ postil plan --envelopes .cache/envelopes --config .postil.candidate.yaml The report shows which findings become visible or suppressed and which gate results change. -## Interactive replies - -`postil respond` answers a mention on a pull request. GitHub and GitLab also support issue mentions. It does not open pull requests or push commits. -Posting the reply requires `--publish`; without it, the reply is printed locally. -CI detection and environment variables do not authorize a forge write. - -```sh -POSTIL_COMMENT='@postil is this safe?' \ -postil respond --repo owner/repository --pr 123 --publish -``` - -Pass comment text through `POSTIL_COMMENT` in automation because command-line arguments are visible to other local processes. - ## Usage receipts Hosted workers can set `POSTIL_USAGE_RECEIPT_PATH` to a worker-owned path. A successful response writes a mode-`0600` version 2 JSON receipt before forge delivery. Every provider attempt includes its role, phase, operation-wide call ordinal, transport attempt, token counts, accounting-completeness flag, cost source, canonical provider-reported decimal cost when present, and rounded micro-dollar display value. The caller owns receipt deletion. Receipt consumers must accept the additional `costProviderDecimal` field before deploying this CLI. diff --git a/docs/configuration.md b/docs/configuration.md index 2fa8594..69ae24f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,13 +1,15 @@ # Configuration -Postil resolves settings in this order: +Postil resolves model settings in this order: 1. Command-line flags 2. Environment variables 3. `.postil.yaml`, `.postil.yml`, or `.postil.json` -4. Translated `.coderabbit.yaml` settings +4. Stored `postil login` routing 5. Built-in defaults +Translated `.coderabbit.yaml` settings can supply review policy but do not select a model. + Unknown keys fail validation so a misspelling cannot silently weaken a review. ## Repository policy @@ -33,8 +35,10 @@ contentPolicy: enabled: true model: name: provider/qualified-model + reasoningEffort: low # max, xhigh, high, medium, low, minimal, or none cascade: [] # qualified fallbacks only # scorer: provider/qualified-scorer + scorerReasoningEffort: none consensus: 1 ``` @@ -55,9 +59,11 @@ Ignore patterns remove matching paths before grounding, batching, and large-revi | `POSTIL_ALLOW_CONFIG_API_BASE` | Honor a repository-controlled `model.apiBase` | | `POSTIL_IGNORE_REPOSITORY_MODEL_CONFIG` | Keep trusted local/hosted model selection independent of repository model fields | | `REVIEW_MODEL` | Primary model override | +| `REVIEW_REASONING_EFFORT` | Reviewer reasoning effort: `max`, `xhigh`, `high`, `medium`, `low`, `minimal`, or `none` | | `REVIEW_MODEL_CASCADE` | Comma-separated fallback models | | `REVIEW_MODEL_CONSENSUS` | Number of models from the generator chain to run concurrently; agreeing findings are retained | | `REVIEW_SCORER_MODEL` | Scorer model override | +| `REVIEW_SCORER_REASONING_EFFORT` | Scorer and adjudication reasoning effort: `max`, `xhigh`, `high`, `medium`, `low`, `minimal`, or `none` | | `REVIEW_SCORER_MODEL_CASCADE` | One scorer fallback model | | `POSTIL_UNCERTAINTY_RESOLUTION` | Override uncertainty resolution with `true`/`false` or `1`/`0` | | `POSTIL_CONCISE_FINDINGS` | Override concise findings with `true`/`false` or `1`/`0` | @@ -74,7 +80,7 @@ postil login postil logout ``` -`postil login` authenticates against postil.dev over a device-authorization flow (open the printed URL, enter the code) and stores a credential at `${XDG_CONFIG_HOME:-~/.config}/postil/credentials.json`, mode `0600` in a `0700` directory. That credential is a fallback: it is used only when none of `POSTIL_API_KEY`, `OPENROUTER_API_KEY`, `MODEL_API_KEY`, or `LLM_API_KEY` is set. When it is used, its `apiBase` and model select the request unless `POSTIL_API_BASE`/`REVIEW_MODEL` are set, which still win. `postil logout` revokes the credential server-side and removes the local file even if that call fails. An expired credential produces one instruction to run `postil login` again rather than a provider authentication error. +`postil login` authenticates against postil.dev over a device-authorization flow (open the printed URL, enter the code) and stores a credential at `${XDG_CONFIG_HOME:-~/.config}/postil/credentials.json`, mode `0600` in a `0700` directory. That credential is a fallback: it is used only when none of `POSTIL_API_KEY`, `OPENROUTER_API_KEY`, `MODEL_API_KEY`, or `LLM_API_KEY` is set. Its `apiBase` and model provide a baseline below trusted project configuration and environment overrides. `postil logout` revokes the credential server-side and removes the local file even if that call fails. An expired credential produces one instruction to run `postil login` again rather than a provider authentication error. ## Inspect and initialize @@ -84,4 +90,6 @@ postil config postil doctor ``` -`postil config` prints the resolved non-secret configuration and its sources. `postil doctor` validates endpoint reachability, credential acceptance, and repository setup without printing credential values, and reports whether a login credential is present and when it expires. +`postil config` prints the resolved non-secret configuration and separate provenance for the model, reviewer reasoning effort, and scorer reasoning effort. `postil doctor` validates endpoint reachability, credential acceptance, and repository setup without printing credential values, and reports whether a login credential is present and when it expires. + +Use `--reasoning-effort` and `--scorer-reasoning-effort` for one review. These flags override the matching environment variables, which override `model.reasoningEffort` and `model.scorerReasoningEffort`. The built-in reviewer and scorer defaults are `low` and `none`, respectively. Every request carries the resolved value, including retries and repair calls. diff --git a/docs/model-providers.md b/docs/model-providers.md index 985c560..0d89622 100644 --- a/docs/model-providers.md +++ b/docs/model-providers.md @@ -4,9 +4,9 @@ Postil speaks either an OpenAI-compatible chat-completions interface or the nati ## Model admission -The CLI has no implicit model or fallback chain. Set `REVIEW_MODEL` to a model that passes the repository benchmark for the intended review profile. Set `REVIEW_MODEL_CASCADE` only to other qualified models. Scoring is disabled unless `REVIEW_SCORER_MODEL` names a qualified scorer. `REVIEW_SCORER_MODEL_CASCADE` accepts one qualified scorer fallback. +Local review defaults to the embedded, tested `openai/gpt-5.6-luna` preset with no fallback chain or scorer. Reviewer requests use `low` reasoning effort and scorer-role requests use `none`. `postil models` prints the tested preset, accepted effort values, and exact override syntax. Set `REVIEW_MODEL` to override the generator and `REVIEW_MODEL_CASCADE` to add explicit fallbacks. Scoring is disabled unless `REVIEW_SCORER_MODEL` names a scorer. Hosted deployments continue to admit only complete qualified profiles. `REVIEW_SCORER_MODEL_CASCADE` accepts one qualified scorer fallback. -Hosted deployments admit only complete profiles listed in the embedded `qualified-models.json` qualification manifest. A profile binds the canonical API base, provider API format, ordered generator chain, consensus width, ordered scorer chain, exact Bun evaluator runtime, qualification source commit, a 30-day authority window, at least three complete runs, and SHA-256 digests for the review contract, evaluator contract, fixture set, and benchmark report. The manifest also binds the exact `config.toml` digest. Hosted configuration must match one unexpired profile exactly; the expiry second is excluded. An empty, degraded, stale, or mismatched profile rejects inference. +Hosted deployments admit only complete profiles listed in the embedded `qualified-models.json` qualification manifest. A profile binds the canonical API base, provider API format, ordered generator chain, consensus width, ordered scorer chain, exact Bun evaluator runtime, qualification source commit, a 30-day authority window, at least three complete runs, and SHA-256 digests for the review contract, evaluator contract, fixture set, and benchmark report. The manifest also binds the exact `config.toml` digest, including the reviewer and scorer reasoning defaults. Hosted configuration must match one unexpired profile exactly, so an effort override that differs from those qualified defaults is rejected. The expiry second is excluded. An empty, degraded, stale, or mismatched profile rejects inference. Every nonempty manifest has a committed `qualified-models.attestation.json` bundle. CI and release validation use [`gh attestation verify`](https://cli.github.com/manual/gh_attestation_verify) to require SLSA provenance from the exact `postil-dev/postil-cli` admission workflow, `refs/heads/main` source ref, source and signer commit, issued by GitHub OIDC through public Sigstore on a GitHub-hosted runner. The source commit must be an ancestor of the candidate commit, and only the manifest and bundle may differ. A verified Sigstore timestamp must match the signed issue time and remain within the authority window. The bundle authenticates the exact manifest bytes. Checksums inside the report and manifest provide integrity evidence, not producer authentication. The binary metadata exposes the manifest issue time, exclusive expiry time, and maximum age as top-level values. An empty manifest exposes null authority metadata, needs no bundle, and admits no model. @@ -33,6 +33,8 @@ postil doctor These requests authenticate with `Authorization: Bearer`. +OpenAI-compatible requests carry the [OpenRouter reasoning configuration](https://openrouter.ai/docs/guides/best-practices/reasoning-tokens) as `reasoning.effort`. The accepted values are `max`, `xhigh`, `high`, `medium`, `low`, `minimal`, and `none`. This field is sent to OpenRouter and custom OpenAI-compatible endpoints on every request. Postil does not drop or downgrade it, so custom endpoints must implement the extension. + ## Anthropic Messages API ```sh @@ -43,7 +45,7 @@ export REVIEW_MODEL=provider-qualified-anthropic-model postil doctor ``` -Native Anthropic requests use `x-api-key` and `anthropic-version`. Set `REVIEW_SCORER_MODEL` to a model available through the same endpoint when scoring is required. +Native Anthropic requests use `x-api-key` and `anthropic-version`. Reviewer and scorer values from `max` through `low` map to `output_config.effort`; `none` maps to disabled thinking. The Anthropic request format has no `minimal` mapping, so Postil rejects that value locally before provider access. Individual Anthropic models can support a subset of the remaining effort values. Set `REVIEW_SCORER_MODEL` to a model available through the same endpoint when scoring is required. ## Local endpoints diff --git a/qualified-models.json b/qualified-models.json index f0b8b50..5602481 100644 --- a/qualified-models.json +++ b/qualified-models.json @@ -4,6 +4,6 @@ "qualificationIssuedAtUnixSeconds": null, "qualificationExpiresAtUnixSeconds": null, "qualificationMaxAgeDays": null, - "modelDefaultsSha256": "2a2fc3a8732b833ae25e60e76abe6bdafeb3df9c9f9e283c19f2a94dac5c2df5", + "modelDefaultsSha256": "b581d37af77c48552412c383cebcab55129c1d8c73d3f6dfa00bf3c7ff04ab3f", "profiles": [] } diff --git a/src/api_key.rs b/src/api_key.rs index 060f2f8..c093469 100644 --- a/src/api_key.rs +++ b/src/api_key.rs @@ -23,6 +23,14 @@ pub(crate) fn names_text() -> String { API_KEY_ENV_VARS.join(", ") } +pub(crate) fn credential_help() -> String { + format!( + "default model is {}; run `postil login` for hosted inference or set one of {}; see `postil models` for tested presets and override syntax", + crate::config::default_model(), + names_text() + ) +} + pub(crate) fn resolve_from_process_env() -> Option { resolve_with(|name| std::env::var(name).ok()) } diff --git a/src/cli.rs b/src/cli.rs index 3014326..54b9c37 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -77,6 +77,9 @@ pub enum Command { config: Option, }, /// Review a diff: a PR/MR on a forge, or local changes. + #[command( + after_help = "Examples:\n postil review\n postil review --staged\n postil review --base origin/main\n\nBare local review selects, in order: staged changes; committed changes since the current branch remote's default branch; tracked working-tree changes; then an empty clean diff. Explicit --staged, --base, and --diff-file keep their exact behavior.\n\nUse `postil models` to see tested presets and override syntax." + )] Review { /// Code host for remote review. Inferred as github when --repo is set. #[arg(long, value_enum)] @@ -135,6 +138,15 @@ pub enum Command { /// Model override (else REVIEW_MODEL, else config, else default). #[arg(long)] model: Option, + /// Reviewer reasoning effort: max|xhigh|high|medium|low|minimal|none (else REVIEW_REASONING_EFFORT, else config, else low). + #[arg(long, value_name = "EFFORT")] + reasoning_effort: Option, + /// Scorer reasoning effort: max|xhigh|high|medium|low|minimal|none (else REVIEW_SCORER_REASONING_EFFORT, else config, else none). + #[arg(long, value_name = "EFFORT")] + scorer_reasoning_effort: Option, + /// Keep detailed provider, retry, and batch telemetry in interactive terminals. + #[arg(long)] + verbose: bool, /// Use deterministic semantic synthesis and model-assisted risk selection to cap large reviews at five source batches; report bounded coverage. #[arg(long)] bounded: bool, @@ -186,37 +198,8 @@ pub enum Command { )] publication_input_identity: Option, }, - /// Reply to an @postil mention on a pull request or issue (interactive bot). - Respond { - /// Code host. GitHub and GitLab support PRs/MRs and issues; Bitbucket - /// and Azure DevOps support pull requests only. - #[arg(long, value_enum, default_value = "github")] - forge: ForgeArg, - /// Repository as owner/name. - #[arg(long)] - repo: Option, - /// Pull request number the mention is on. - #[arg(long)] - pr: Option, - /// Issue number the mention is on. - #[arg(long)] - issue: Option, - /// The maintainer's message text (the mention body). Falls back to the - /// POSTIL_COMMENT environment variable — prefer that for automation: - /// argv is visible in `ps` and clap would reject text starting with `-`. - #[arg(long, allow_hyphen_values = true)] - comment: Option, - #[arg(long)] - config: Option, - #[arg(long)] - model: Option, - /// Post the reply to the selected forge. Replies are printed locally by default. - #[arg(long, conflicts_with = "no_post")] - publish: bool, - /// Deprecated compatibility flag. Replies are local-only by default. - #[arg(long, hide = true, conflicts_with = "publish")] - no_post: bool, - }, + /// List tested embedded presets and exact local override syntax. + Models, /// Replay stored envelopes under a candidate config: what would change? Plan { /// Directory of envelope JSON files from previous reviews. @@ -327,6 +310,53 @@ mod tests { )); } + #[test] + fn review_help_makes_bare_selection_and_model_discovery_explicit() { + let help = Cli::command() + .find_subcommand_mut("review") + .expect("review subcommand") + .render_long_help() + .to_string(); + assert!(help.contains("postil review\n")); + assert!(help.contains("staged changes; committed changes")); + assert!(help.contains("postil models")); + let root = Cli::command().render_long_help().to_string(); + assert!(root.contains("models")); + assert!(!root.contains("respond")); + } + + #[test] + fn review_accepts_role_specific_reasoning_effort_flags() { + let parsed = Cli::try_parse_from([ + "postil", + "review", + "--reasoning-effort", + "xhigh", + "--scorer-reasoning-effort", + "none", + ]) + .unwrap(); + let Command::Review { + reasoning_effort, + scorer_reasoning_effort, + .. + } = parsed.command + else { + panic!("expected review command"); + }; + assert_eq!(reasoning_effort.as_deref(), Some("xhigh")); + assert_eq!(scorer_reasoning_effort.as_deref(), Some("none")); + + let help = Cli::command() + .find_subcommand_mut("review") + .expect("review subcommand") + .render_long_help() + .to_string(); + assert!(help.contains("--reasoning-effort ")); + assert!(help.contains("--scorer-reasoning-effort ")); + assert!(help.contains("max|xhigh|high|medium|low|minimal|none")); + } + #[test] fn review_publication_requires_an_explicit_flag() { let parsed = Cli::try_parse_from([ diff --git a/src/config.rs b/src/config.rs index e6604ba..0cb4308 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,7 +1,8 @@ //! Resolved review configuration. //! -//! Precedence: CLI flags > environment > `.postil.{yaml,yml,json}` > -//! `.coderabbit.yaml` (translated) > defaults. +//! Model precedence: CLI flags > environment > `.postil.{yaml,yml,json}` > +//! stored login routing > embedded defaults. Translated `.coderabbit.yaml` +//! settings do not select a model. //! //! Exception: `model.apiBase` from a config file is repo-controlled, and the //! resolved base URL receives the deployment's bearer key. It is ignored by @@ -13,10 +14,9 @@ //! a stored `postil login` credential. When none of those four is set and a //! valid, unexpired credential exists at //! `${XDG_CONFIG_HOME:-~/.config}/postil/credentials.json`, its token becomes -//! the bearer key (see `api_key::resolve_effective`) and its `apiBase`/ -//! `model` replace the values resolved above -- unless `POSTIL_API_BASE` / -//! `REVIEW_MODEL` are set, which still win, applied first in this same -//! function. An expired or unreadable stored credential is left alone here; +//! the bearer key (see `api_key::resolve_effective`) and its `apiBase` and +//! `model` provide the baseline below trusted project configuration and +//! environment overrides. An expired or unreadable stored credential is left alone here; //! `resolve_api_key` reports it as one actionable "run `postil login` again" //! error instead of blocking commands, like `postil config`, that never need //! a live key. @@ -117,17 +117,61 @@ impl ApiFormat { } } +pub const REASONING_EFFORT_VALUES: &str = "max|xhigh|high|medium|low|minimal|none"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReasoningEffort { + Max, + Xhigh, + High, + Medium, + Low, + Minimal, + None, +} + +impl ReasoningEffort { + pub fn parse(field: &str, value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "max" => Ok(Self::Max), + "xhigh" => Ok(Self::Xhigh), + "high" => Ok(Self::High), + "medium" => Ok(Self::Medium), + "low" => Ok(Self::Low), + "minimal" => Ok(Self::Minimal), + "none" => Ok(Self::None), + _ => anyhow::bail!( + "invalid {field} {value:?}; accepted values: {REASONING_EFFORT_VALUES}" + ), + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::Max => "max", + Self::Xhigh => "xhigh", + Self::High => "high", + Self::Medium => "medium", + Self::Low => "low", + Self::Minimal => "minimal", + Self::None => "none", + } + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct ModelDefaults { pub version: u64, pub source_sha256: String, pub default_model: String, + pub reasoning_effort: ReasoningEffort, pub cascade: Vec, pub consensus: usize, pub api_base: String, pub api_format: ApiFormat, pub scorer_enabled: bool, pub scorer_model: String, + pub scorer_reasoning_effort: ReasoningEffort, pub scorer_fallback: String, pub scorer_qualification_candidates: Vec, } @@ -239,6 +283,7 @@ const MAX_SAFE_JSON_INTEGER: u64 = 9_007_199_254_740_991; const QUALIFICATION_CANDIDATE_PROFILE_ENV: &str = "POSTIL_QUALIFICATION_CANDIDATE_PROFILE"; const BENCH_SCREEN_PROFILE_ENV: &str = "POSTIL_BENCH_SCREEN_PROFILE"; const BENCH_FORCE_BOUNDED_SELECTION_ENV: &str = "POSTIL_BENCH_FORCE_BOUNDED_SELECTION"; +const IGNORE_REPOSITORY_MODEL_CONFIG_ENV: &str = "POSTIL_IGNORE_REPOSITORY_MODEL_CONFIG"; const PROVISIONAL_HOSTED_ROSTER_ENV: &str = "POSTIL_PROVISIONAL_HOSTED_ROSTER"; #[derive(Debug, Deserialize)] @@ -246,6 +291,7 @@ const PROVISIONAL_HOSTED_ROSTER_ENV: &str = "POSTIL_PROVISIONAL_HOSTED_ROSTER"; struct ModelDefaultsFile { version: u64, default_model: String, + reasoning_effort: String, cascade: Vec, consensus: usize, api_base: String, @@ -258,6 +304,7 @@ struct ModelDefaultsFile { struct ScorerDefaultsFile { enabled: bool, default_model: String, + reasoning_effort: String, fallback: String, qualification_candidates: Vec, } @@ -277,10 +324,18 @@ pub fn default_cascade() -> &'static [String] { model_defaults().cascade.as_slice() } +pub fn default_reasoning_effort() -> ReasoningEffort { + model_defaults().reasoning_effort +} + pub fn default_scorer_model() -> &'static str { model_defaults().scorer_model.as_str() } +pub fn default_scorer_reasoning_effort() -> ReasoningEffort { + model_defaults().scorer_reasoning_effort +} + pub fn default_scorer_fallback() -> &'static str { model_defaults().scorer_fallback.as_str() } @@ -406,6 +461,9 @@ fn admitted_profile_for( } pub fn admitted_profile_for_config(config: &Config) -> Option<&'static QualificationProfile> { + if !reasoning_efforts_match_embedded_defaults(config) { + return None; + } let generator_chain = config.model_chain(); let scorer_chain = config.scorer_chain(); let api_base = normalize_api_base(&config.api_base).ok()?; @@ -418,6 +476,12 @@ pub fn admitted_profile_for_config(config: &Config) -> Option<&'static Qualifica }) } +fn reasoning_efforts_match_embedded_defaults(config: &Config) -> bool { + let defaults = model_defaults(); + config.reasoning_effort == defaults.reasoning_effort + && config.scorer_reasoning_effort == defaults.scorer_reasoning_effort +} + fn evaluator_runtime_identity() -> String { #[derive(Deserialize)] #[serde(rename_all = "camelCase")] @@ -720,6 +784,9 @@ fn validate_qualification_authority(manifest: &QualificationManifest, now: u64) fn parse_model_defaults(raw: &str) -> Result { let file: ModelDefaultsFile = toml::from_str(raw)?; + let reasoning_effort = ReasoningEffort::parse("reasoning_effort", &file.reasoning_effort)?; + let scorer_reasoning_effort = + ReasoningEffort::parse("scorer.reasoning_effort", &file.scorer.reasoning_effort)?; anyhow::ensure!( file.version > 0, "model defaults version must be greater than zero" @@ -785,12 +852,14 @@ fn parse_model_defaults(raw: &str) -> Result { version: file.version, source_sha256: sha256_hex(raw), default_model: file.default_model, + reasoning_effort, cascade: file.cascade, consensus: file.consensus, api_base: file.api_base, api_format: file.api_format, scorer_enabled: file.scorer.enabled, scorer_model: file.scorer.default_model, + scorer_reasoning_effort, scorer_fallback: file.scorer.fallback, scorer_qualification_candidates: file.scorer.qualification_candidates, }) @@ -979,8 +1048,14 @@ pub struct Config { /// also meet the calibrated 0.30 gate confidence floor. Default: [HumanEscalation]. pub block_on_kinds: Vec, pub model: String, + pub reasoning_effort: ReasoningEffort, + /// Winning source for reviewer reasoning effort, shown by `postil config`. + pub reasoning_effort_source: String, pub cascade: Vec, pub scorer: String, + pub scorer_reasoning_effort: ReasoningEffort, + /// Winning source for scorer reasoning effort, shown by `postil config`. + pub scorer_reasoning_effort_source: String, pub scorer_fallback: String, /// Embedded scoring remains disabled until a candidate passes the repeated /// qualification gate. BYOK can select a scorer and one fallback. @@ -1002,6 +1077,8 @@ pub struct Config { pub content_policy_disabled: bool, /// Where the config came from, for `postil config` provenance. pub source: String, + /// Winning source for the generator chain, shown by `postil config`. + pub model_source: String, } impl Default for Config { @@ -1022,9 +1099,13 @@ impl Default for Config { gate_fail_on: GateLevel::Severity(Severity::Error), gate_on_error: OnError::Block, block_on_kinds: vec![Kind::HumanEscalation], - model: String::new(), - cascade: Vec::new(), + model: defaults.default_model.clone(), + reasoning_effort: defaults.reasoning_effort, + reasoning_effort_source: "embedded default".to_string(), + cascade: defaults.cascade.clone(), scorer: String::new(), + scorer_reasoning_effort: defaults.scorer_reasoning_effort, + scorer_reasoning_effort_source: "embedded default".to_string(), scorer_fallback: String::new(), scorer_enabled: false, api_base: defaults.api_base.clone(), @@ -1034,6 +1115,7 @@ impl Default for Config { content_policy: Some(BUILTIN_CONTENT_POLICY.to_string()), content_policy_disabled: false, source: "defaults".to_string(), + model_source: "embedded default".to_string(), } } } @@ -1083,8 +1165,10 @@ pub struct GateSection { #[serde(deny_unknown_fields, rename_all = "camelCase")] pub struct ModelSection { pub name: Option, + pub reasoning_effort: Option, pub cascade: Option>, pub scorer: Option, + pub scorer_reasoning_effort: Option, pub api_base: Option, pub api_format: Option, pub consensus: Option, @@ -1108,26 +1192,28 @@ impl Config { pub fn load(root: &Path, explicit: Option<&Path>) -> Result { #[cfg(test)] let _environment_guard = tests::env_lock().lock().unwrap(); - let mut cfg = if let Some(path) = explicit { - let mut c = Self::from_postil_file(path) + let mut cfg = Config::default(); + // Login supplies a local baseline, below trusted project configuration + // and environment overrides but above the embedded provider defaults. + cfg.apply_stored_login_credential(); + if let Some(path) = explicit { + let file = Self::read_postil_file(path) .with_context(|| format!("reading config {}", path.display()))?; - c.source = path.display().to_string(); - c + cfg.apply_file(file)?; + cfg.source = path.display().to_string(); } else if let Some(path) = find_first(root, &[".postil.yaml", ".postil.yml", ".postil.json"]) { - let mut c = Self::from_postil_file(&path) + let file = Self::read_postil_file(&path) .with_context(|| format!("reading config {}", path.display()))?; - c.source = rel_name(&path); - c + cfg.apply_file(file)?; + cfg.source = rel_name(&path); } else if let Some(path) = find_first(root, &[".coderabbit.yaml", ".coderabbit.yml"]) { - let mut c = Self::from_coderabbit(&path) + let translated = Self::from_coderabbit(&path) .with_context(|| format!("translating {}", path.display()))?; - c.source = format!("{} (translated)", rel_name(&path)); - c - } else { - Config::default() - }; + cfg.apply_translated_config(translated); + cfg.source = format!("{} (translated)", rel_name(&path)); + } cfg.apply_env()?; validate_benchmark_bounded_selection(&cfg)?; // Repo guardrails are a separate file so they can be long-form prose. @@ -1158,7 +1244,7 @@ impl Config { Ok(cfg) } - fn from_postil_file(path: &Path) -> Result { + fn read_postil_file(path: &Path) -> Result { let raw = std::fs::read_to_string(path)?; let file: FileConfig = if path.extension().is_some_and(|e| e == "json") { serde_json::from_str(&raw)? @@ -1166,9 +1252,7 @@ impl Config { parse_yaml_value(&raw)?; yaml_serde::from_str(&raw)? }; - let mut cfg = Config::default(); - cfg.apply_file(file)?; - Ok(cfg) + Ok(file) } pub fn apply_file(&mut self, f: FileConfig) -> Result<()> { @@ -1255,6 +1339,12 @@ impl Config { } else { if let Some(n) = m.name { self.model = n; + self.model_source = "trusted project config".to_string(); + } + if let Some(effort) = m.reasoning_effort { + self.reasoning_effort = + ReasoningEffort::parse("model.reasoningEffort", &effort)?; + self.reasoning_effort_source = "trusted project config".to_string(); } if let Some(c) = m.cascade { self.cascade = c; @@ -1264,6 +1354,11 @@ impl Config { self.scorer_fallback.clear(); self.scorer_enabled = true; } + if let Some(effort) = m.scorer_reasoning_effort { + self.scorer_reasoning_effort = + ReasoningEffort::parse("model.scorerReasoningEffort", &effort)?; + self.scorer_reasoning_effort_source = "trusted project config".to_string(); + } if let Some(b) = m.api_base { // `model.apiBase` from `.postil.yaml` is repo-controlled, and the // resolved base URL receives the deployment's bearer key. Honoring @@ -1346,6 +1441,12 @@ impl Config { Ok(cfg) } + fn apply_translated_config(&mut self, translated: Config) { + self.enabled = translated.enabled; + self.ignore = translated.ignore; + self.min_confidence = translated.min_confidence; + } + fn apply_env(&mut self) -> Result<()> { if let Ok(value) = std::env::var("POSTIL_CONCISE_FINDINGS") && !value.trim().is_empty() @@ -1369,6 +1470,19 @@ impl Config { ), }; } + if let Ok(value) = std::env::var("REVIEW_REASONING_EFFORT") + && !value.trim().is_empty() + { + self.reasoning_effort = ReasoningEffort::parse("REVIEW_REASONING_EFFORT", &value)?; + self.reasoning_effort_source = "environment".to_string(); + } + if let Ok(value) = std::env::var("REVIEW_SCORER_REASONING_EFFORT") + && !value.trim().is_empty() + { + self.scorer_reasoning_effort = + ReasoningEffort::parse("REVIEW_SCORER_REASONING_EFFORT", &value)?; + self.scorer_reasoning_effort_source = "environment".to_string(); + } if hosted_mode() { if let Some(profile) = admitted_profile_for(model_defaults(), qualification_manifest()) { @@ -1380,6 +1494,7 @@ impl Config { self.scorer_enabled = !profile.scorer_chain.is_empty(); self.api_base = profile.api_base.clone(); self.api_format = profile.api_format; + self.model_source = "hosted qualification profile".to_string(); } else if provisional_hosted_roster_enabled() { let profile = provisional_hosted_profile(); self.model = profile.generator_chain[0].clone(); @@ -1390,6 +1505,7 @@ impl Config { self.scorer_enabled = !profile.scorer_chain.is_empty(); self.api_base = profile.api_base.clone(); self.api_format = profile.api_format; + self.model_source = "hosted provisional profile".to_string(); } return Ok(()); } @@ -1404,12 +1520,14 @@ impl Config { self.scorer_enabled = true; self.api_base = profile.api_base; self.api_format = profile.api_format; + self.model_source = "qualification candidate profile".to_string(); return Ok(()); } if let Ok(m) = std::env::var("REVIEW_MODEL") - && !m.is_empty() + && !m.trim().is_empty() { self.model = m; + self.model_source = "environment".to_string(); } if let Ok(c) = std::env::var("REVIEW_MODEL_CASCADE") && !c.is_empty() @@ -1465,16 +1583,12 @@ impl Config { { self.api_format = ApiFormat::parse(&format)?; } - self.apply_stored_login_credential(); Ok(()) } - /// A first-time `postil login` user gets working defaults with zero - /// `.postil.yaml`: when no explicit key env var is set, a stored - /// credential supplies `apiBase` and `model`, unless `POSTIL_API_BASE`/ - /// `REVIEW_MODEL` (just applied above) already overrode them. The - /// cascade is cleared along with `model`, since a leftover BYOK cascade - /// names models the hosted gateway does not operate. + /// A stored `postil login` credential supplies a local fallback API base + /// and model. Project configuration and environment variables are applied + /// after this method and therefore retain their documented precedence. /// /// This applies even when the credential is expired: routing /// (`apiBase`/`model`) is not a secret, and populating it here is what @@ -1483,23 +1597,21 @@ impl Config { /// resolution in `resolve_api_key` is the sole place that enforces and /// reports expiry -- it still refuses the token regardless of what /// happens here. - fn apply_stored_login_credential(&mut self) { + fn apply_stored_login_credential(&mut self) -> bool { if api_key::resolve_from_process_env().is_some() { - return; + return false; } let Ok(path) = credentials::default_path() else { - return; + return false; }; let Ok(Some(creds)) = credentials::read(&path) else { - return; + return false; }; - if std::env::var("POSTIL_API_BASE").is_err() { - self.api_base = creds.api_base; - } - if std::env::var("REVIEW_MODEL").is_err() { - self.model = creds.model; - self.cascade.clear(); - } + self.api_base = creds.api_base; + self.model = creds.model; + self.cascade.clear(); + self.model_source = "stored login".to_string(); + true } /// All models to try, in order, deduplicated. @@ -1540,9 +1652,20 @@ impl Config { return Ok(()); } let generator_chain = self.model_chain(); + if self.api_format == ApiFormat::Anthropic { + anyhow::ensure!( + self.reasoning_effort != ReasoningEffort::Minimal, + "model.reasoningEffort minimal is unsupported by the Anthropic request format; accepted Anthropic values: max|xhigh|high|medium|low|none" + ); + anyhow::ensure!( + self.scorer_reasoning_effort != ReasoningEffort::Minimal, + "model.scorerReasoningEffort minimal is unsupported by the Anthropic request format; accepted Anthropic values: max|xhigh|high|medium|low|none" + ); + } anyhow::ensure!( !generator_chain.is_empty(), - "no review model is configured; pass --model, set REVIEW_MODEL, or set model.name in a trusted local config" + "no review model is configured; remove the empty override to use default {}, or run `postil models` for tested presets and override syntax", + default_model() ); if hosted { let manifest = qualification_manifest(); @@ -1553,6 +1676,7 @@ impl Config { && profile.consensus == self.consensus && profile.scorer_chain == scorer_chain && profile.api_format == self.api_format + && reasoning_efforts_match_embedded_defaults(self) && normalize_api_base(&self.api_base).ok().as_deref() == Some(profile.api_base.as_str()) }) || (provisional && provisional_hosted_profile_for_config(self).is_some()), @@ -1759,7 +1883,8 @@ pub(crate) fn benchmark_screening_profile_for_config( && profile.consensus == config.consensus && profile.scorer_chain == config.scorer_chain() && profile.api_base == api_base - && profile.api_format == config.api_format, + && profile.api_format == config.api_format + && reasoning_efforts_match_embedded_defaults(config), "benchmark screening profile does not exactly match the resolved review configuration" ); Ok(Some(profile)) @@ -1858,7 +1983,8 @@ pub(crate) fn qualification_candidate_profile_for_config( && profile.consensus == config.consensus && profile.scorer_chain == config.scorer_chain() && profile.api_base == api_base - && profile.api_format == config.api_format, + && profile.api_format == config.api_format + && reasoning_efforts_match_embedded_defaults(config), "qualification candidate profile does not exactly match the resolved review configuration" ); Ok(Some(profile)) @@ -1903,6 +2029,7 @@ fn provisional_hosted_profile_for_config(config: &Config) -> Option bool { hosted_runtime_mode() || benchmark_screening_mode() || benchmark_force_bounded_selection_requested() - || std::env::var("POSTIL_IGNORE_REPOSITORY_MODEL_CONFIG") + || std::env::var(IGNORE_REPOSITORY_MODEL_CONFIG_ENV) .map(|value| value == "1" || value.eq_ignore_ascii_case("true")) .unwrap_or(false) } @@ -2037,8 +2164,10 @@ gate: model: name: __DEFAULT_MODEL__ + reasoningEffort: __DEFAULT_REASONING_EFFORT__ # max | xhigh | high | medium | low | minimal | none cascade: __DEFAULT_CASCADE__ # scorer: provider/model # explicit BYOK opt-in; embedded scoring is disabled + scorerReasoningEffort: __DEFAULT_SCORER_REASONING_EFFORT__ # apiBase: https://openrouter.ai/api/v1 # OpenAI-compatible or Anthropic endpoint base URL. # apiFormat: openai-compatible # openai-compatible (default) or anthropic. # # Ignored from config by default (a repo could redirect @@ -2064,6 +2193,14 @@ pub fn starter_config() -> &'static str { .collect::(); STARTER_CONFIG_TEMPLATE .replace("__DEFAULT_MODEL__", &yaml_scalar(&defaults.model)) + .replace( + "__DEFAULT_REASONING_EFFORT__", + defaults.reasoning_effort.as_str(), + ) + .replace( + "__DEFAULT_SCORER_REASONING_EFFORT__", + defaults.scorer_reasoning_effort.as_str(), + ) .replace("__DEFAULT_CASCADE__", &cascade) }) } @@ -2100,6 +2237,12 @@ mod tests { std::env::set_var(name, value); } } + + fn remove(name: &str) { + unsafe { + std::env::remove_var(name); + } + } } impl Drop for EnvRestore { @@ -2120,6 +2263,46 @@ mod tests { LOCK.get_or_init(|| Mutex::new(())) } + /// Pin tests that exercise local repository model configuration to the + /// local runtime contract. The production wrapper intentionally enters + /// hosted mode when deployment environment says so, therefore these tests + /// must not inherit that process-wide state from another test or harness. + struct LocalModelConfigEnv { + _hosted: EnvRestore, + _candidate: EnvRestore, + _screening: EnvRestore, + _bounded: EnvRestore, + _ignore_repository_models: EnvRestore, + _review_reasoning_effort: EnvRestore, + _scorer_reasoning_effort: EnvRestore, + } + + impl LocalModelConfigEnv { + fn clear() -> Self { + let environment = Self { + _hosted: EnvRestore::capture("POSTIL_HOSTED_MODE"), + _candidate: EnvRestore::capture(QUALIFICATION_CANDIDATE_PROFILE_ENV), + _screening: EnvRestore::capture(BENCH_SCREEN_PROFILE_ENV), + _bounded: EnvRestore::capture(BENCH_FORCE_BOUNDED_SELECTION_ENV), + _ignore_repository_models: EnvRestore::capture(IGNORE_REPOSITORY_MODEL_CONFIG_ENV), + _review_reasoning_effort: EnvRestore::capture("REVIEW_REASONING_EFFORT"), + _scorer_reasoning_effort: EnvRestore::capture("REVIEW_SCORER_REASONING_EFFORT"), + }; + for name in [ + "POSTIL_HOSTED_MODE", + QUALIFICATION_CANDIDATE_PROFILE_ENV, + BENCH_SCREEN_PROFILE_ENV, + BENCH_FORCE_BOUNDED_SELECTION_ENV, + IGNORE_REPOSITORY_MODEL_CONFIG_ENV, + "REVIEW_REASONING_EFFORT", + "REVIEW_SCORER_REASONING_EFFORT", + ] { + EnvRestore::remove(name); + } + environment + } + } + fn write(dir: &Path, name: &str, content: &str) { let mut f = std::fs::File::create(dir.join(name)).unwrap(); f.write_all(content.as_bytes()).unwrap(); @@ -2180,6 +2363,10 @@ mod tests { assert_eq!(parsed.source_sha256, sha256_hex(MODEL_DEFAULTS_TOML)); assert_eq!(parsed.source_sha256.len(), 64); assert_eq!(parsed.default_model, raw["default_model"].as_str().unwrap()); + assert_eq!( + parsed.reasoning_effort.as_str(), + raw["reasoning_effort"].as_str().unwrap() + ); assert_eq!( parsed.cascade, raw["cascade"] @@ -2193,6 +2380,10 @@ mod tests { parsed.scorer_model, raw["scorer"]["default_model"].as_str().unwrap() ); + assert_eq!( + parsed.scorer_reasoning_effort.as_str(), + raw["scorer"]["reasoning_effort"].as_str().unwrap() + ); assert_eq!( parsed.scorer_fallback, raw["scorer"]["fallback"].as_str().unwrap() @@ -2218,19 +2409,37 @@ mod tests { let defaults = model_defaults(); assert_eq!(default_model(), defaults.default_model); assert_eq!(super::default_cascade(), defaults.cascade); + assert_eq!(default_reasoning_effort(), defaults.reasoning_effort); assert_eq!(default_scorer_model(), defaults.scorer_model); assert_eq!(default_scorer_fallback(), defaults.scorer_fallback); + assert_eq!( + default_scorer_reasoning_effort(), + defaults.scorer_reasoning_effort + ); assert_eq!( scorer_qualification_candidates(), defaults.scorer_qualification_candidates ); } + #[test] + fn reasoning_effort_parser_matches_the_openrouter_contract() { + for value in ["max", "xhigh", "high", "medium", "low", "minimal", "none"] { + let parsed = ReasoningEffort::parse("effort", value).unwrap(); + assert_eq!(parsed.as_str(), value); + } + assert_eq!( + ReasoningEffort::parse("effort", " XHIGH ").unwrap(), + ReasoningEffort::Xhigh + ); + } + #[test] fn malformed_model_defaults_fail_loudly() { let err = parse_model_defaults( r#"version = 1 default_model = "example/model" +reasoning_effort = "low" cascade = ["example/fallback"] consensus = 1 api_base = "https://openrouter.ai/api/v1" @@ -2240,6 +2449,7 @@ unexpected_key = "typo" [scorer] enabled = false default_model = "example/scorer" +reasoning_effort = "none" fallback = "example/scorer-fallback" qualification_candidates = ["example/scorer"] "#, @@ -2256,41 +2466,45 @@ qualification_candidates = ["example/scorer"] ( "version = 0\n\ default_model = \"example/model\"\n\ + reasoning_effort = \"low\"\n\ cascade = [\"example/fallback\"]\n\ consensus = 1\n\ api_base = \"https://openrouter.ai/api/v1\"\n\ api_format = \"openai-compatible\"\n\ - scorer = { enabled = false, default_model = \"example/scorer\", fallback = \"example/scorer-fallback\", qualification_candidates = [\"example/scorer\"] }\n", + scorer = { enabled = false, default_model = \"example/scorer\", reasoning_effort = \"none\", fallback = \"example/scorer-fallback\", qualification_candidates = [\"example/scorer\"] }\n", "version must be greater than zero", ), ( "version = 1\n\ default_model = \"\"\n\ + reasoning_effort = \"low\"\n\ cascade = [\"example/fallback\"]\n\ consensus = 1\n\ api_base = \"https://openrouter.ai/api/v1\"\n\ api_format = \"openai-compatible\"\n\ - scorer = { enabled = false, default_model = \"example/scorer\", fallback = \"example/scorer-fallback\", qualification_candidates = [\"example/scorer\"] }\n", + scorer = { enabled = false, default_model = \"example/scorer\", reasoning_effort = \"none\", fallback = \"example/scorer-fallback\", qualification_candidates = [\"example/scorer\"] }\n", "cascade must be empty when defaultModel is empty", ), ( "version = 1\n\ default_model = \"example/model\"\n\ + reasoning_effort = \"low\"\n\ cascade = [\"\"]\n\ consensus = 1\n\ api_base = \"https://openrouter.ai/api/v1\"\n\ api_format = \"openai-compatible\"\n\ - scorer = { enabled = false, default_model = \"example/scorer\", fallback = \"example/scorer-fallback\", qualification_candidates = [\"example/scorer\"] }\n", + scorer = { enabled = false, default_model = \"example/scorer\", reasoning_effort = \"none\", fallback = \"example/scorer-fallback\", qualification_candidates = [\"example/scorer\"] }\n", "cascade entries must not be empty", ), ( "version = 1\n\ default_model = \"example/model\"\n\ + reasoning_effort = \"low\"\n\ cascade = [\"example/fallback\"]\n\ consensus = 1\n\ api_base = \"https://openrouter.ai/api/v1\"\n\ api_format = \"openai-compatible\"\n\ - scorer = { enabled = false, default_model = \"\", fallback = \"example/scorer-fallback\", qualification_candidates = [\"example/scorer\"] }\n", + scorer = { enabled = false, default_model = \"\", reasoning_effort = \"none\", fallback = \"example/scorer-fallback\", qualification_candidates = [\"example/scorer\"] }\n", "scorer configuration must be empty when scorer.defaultModel is empty", ), ]; @@ -2307,11 +2521,12 @@ qualification_candidates = ["example/scorer"] fn model_defaults_reject_duplicate_generator_and_scorer_entries() { let duplicate_generator = r#"version = 1 default_model = "provider/model" +reasoning_effort = "low" cascade = ["provider/model"] consensus = 2 api_base = "https://openrouter.ai/api/v1" api_format = "openai-compatible" -scorer = { enabled = true, default_model = "provider/scorer", fallback = "", qualification_candidates = [] } +scorer = { enabled = true, default_model = "provider/scorer", reasoning_effort = "none", fallback = "", qualification_candidates = [] } "#; assert!( parse_model_defaults(duplicate_generator) @@ -2322,11 +2537,12 @@ scorer = { enabled = true, default_model = "provider/scorer", fallback = "", qua let duplicate_scorer = r#"version = 1 default_model = "provider/model" +reasoning_effort = "low" cascade = [] consensus = 1 api_base = "https://openrouter.ai/api/v1" api_format = "openai-compatible" -scorer = { enabled = true, default_model = "provider/scorer", fallback = "provider/scorer", qualification_candidates = [] } +scorer = { enabled = true, default_model = "provider/scorer", reasoning_effort = "none", fallback = "provider/scorer", qualification_candidates = [] } "#; assert!( parse_model_defaults(duplicate_scorer) @@ -2346,11 +2562,16 @@ scorer = { enabled = true, default_model = "provider/scorer", fallback = "provid } #[test] - fn provider_guide_requires_qualified_explicit_models() { + fn provider_guide_documents_local_default_and_hosted_admission() { let readme = include_str!("../README.md"); assert!(readme.contains("docs/model-providers.md")); let provider_guide = include_str!("../docs/model-providers.md"); - assert!(provider_guide.contains("no implicit model or fallback chain")); + assert!(provider_guide.contains("openai/gpt-5.6-luna")); + assert!(provider_guide.contains("postil models")); + assert!(provider_guide.contains("reasoning.effort")); + assert!(provider_guide.contains("`minimal`")); + assert!(provider_guide.contains("`none`")); + assert!(provider_guide.contains("output_config.effort")); assert!(provider_guide.contains("qualification manifest")); } @@ -2537,19 +2758,207 @@ scorer = { enabled = true, default_model = "provider/scorer", fallback = "provid } #[test] - fn defaults_require_an_explicit_model_roster() { + fn defaults_use_the_embedded_generator_roster_without_a_scorer() { let c = Config::default(); let defaults = model_defaults(); - assert!(c.model.is_empty()); - assert!(c.cascade.is_empty()); - assert!(c.scorer.is_empty()); + assert_eq!(c.model, defaults.default_model); + assert_eq!(c.cascade, defaults.cascade); + assert_eq!(c.model_source, "embedded default"); + assert_eq!(c.reasoning_effort, ReasoningEffort::Low); + assert_eq!(c.reasoning_effort_source, "embedded default"); + assert_eq!(c.scorer_reasoning_effort, ReasoningEffort::None); + assert_eq!(c.scorer_reasoning_effort_source, "embedded default"); assert!(c.concise_findings); assert!(!c.scorer_enabled); assert!(c.scorer_chain().is_empty()); - assert!(c.model_chain().is_empty()); + assert_eq!(c.model_chain(), vec![defaults.default_model.clone()]); assert_eq!(c.api_base, defaults.api_base); } + #[test] + fn empty_model_environment_does_not_claim_provenance() { + let _lock = env_lock().lock().unwrap(); + let _local_environment = LocalModelConfigEnv::clear(); + let review_model = EnvRestore::capture("REVIEW_MODEL"); + let review_cascade = EnvRestore::capture("REVIEW_MODEL_CASCADE"); + EnvRestore::set("REVIEW_MODEL", " "); + EnvRestore::set("REVIEW_MODEL_CASCADE", ""); + let mut config = Config::default(); + + config.apply_env().unwrap(); + + assert_eq!(config.model, model_defaults().default_model); + assert_eq!(config.model_source, "embedded default"); + EnvRestore::set("REVIEW_MODEL", "environment/model"); + config.apply_env().unwrap(); + assert_eq!(config.model, "environment/model"); + assert_eq!(config.model_source, "environment"); + drop(review_cascade); + drop(review_model); + } + + #[test] + fn reasoning_effort_precedence_and_provenance_are_role_specific() { + let _lock = env_lock().lock().unwrap(); + let _local_environment = LocalModelConfigEnv::clear(); + let file: FileConfig = yaml_serde::from_str( + "model:\n reasoningEffort: medium\n scorerReasoningEffort: high\n", + ) + .unwrap(); + let mut config = Config::default(); + config.apply_file(file).unwrap(); + assert_eq!(config.reasoning_effort, ReasoningEffort::Medium); + assert_eq!(config.reasoning_effort_source, "trusted project config"); + assert_eq!(config.scorer_reasoning_effort, ReasoningEffort::High); + assert_eq!( + config.scorer_reasoning_effort_source, + "trusted project config" + ); + + EnvRestore::set("REVIEW_REASONING_EFFORT", "xhigh"); + EnvRestore::set("REVIEW_SCORER_REASONING_EFFORT", "minimal"); + config.apply_env().unwrap(); + assert_eq!(config.reasoning_effort, ReasoningEffort::Xhigh); + assert_eq!(config.reasoning_effort_source, "environment"); + assert_eq!(config.scorer_reasoning_effort, ReasoningEffort::Minimal); + assert_eq!(config.scorer_reasoning_effort_source, "environment"); + } + + #[test] + fn invalid_reasoning_effort_lists_every_accepted_value() { + let _lock = env_lock().lock().unwrap(); + let _local_environment = LocalModelConfigEnv::clear(); + let file: FileConfig = yaml_serde::from_str( + "model:\n reasoningEffort: turbo\n scorerReasoningEffort: none\n", + ) + .unwrap(); + let error = Config::default().apply_file(file).unwrap_err(); + assert!(error.to_string().contains("model.reasoningEffort")); + assert!(error.to_string().contains(REASONING_EFFORT_VALUES)); + + EnvRestore::set("REVIEW_SCORER_REASONING_EFFORT", "turbo"); + let error = Config::default().apply_env().unwrap_err(); + assert!(error.to_string().contains("REVIEW_SCORER_REASONING_EFFORT")); + assert!(error.to_string().contains(REASONING_EFFORT_VALUES)); + } + + #[test] + fn native_anthropic_rejects_minimal_effort_before_provider_access() { + let reviewer = Config { + api_format: ApiFormat::Anthropic, + reasoning_effort: ReasoningEffort::Minimal, + ..Config::default() + }; + let error = reviewer.require_model_for(false, false).unwrap_err(); + assert!(error.to_string().contains("reasoningEffort minimal")); + assert!(error.to_string().contains("max|xhigh|high|medium|low|none")); + + let scorer = Config { + api_format: ApiFormat::Anthropic, + scorer_reasoning_effort: ReasoningEffort::Minimal, + ..Config::default() + }; + let error = scorer.require_model_for(false, false).unwrap_err(); + assert!(error.to_string().contains("scorerReasoningEffort minimal")); + assert!(error.to_string().contains("max|xhigh|high|medium|low|none")); + } + + #[test] + fn hosted_profile_provenance_replaces_ignored_project_provenance() { + let _lock = env_lock().lock().unwrap(); + let hosted = EnvRestore::capture("POSTIL_HOSTED_MODE"); + let provisional = EnvRestore::capture(PROVISIONAL_HOSTED_ROSTER_ENV); + EnvRestore::set("POSTIL_HOSTED_MODE", "1"); + EnvRestore::set(PROVISIONAL_HOSTED_ROSTER_ENV, "1"); + let mut config = Config::default(); + let file: FileConfig = + yaml_serde::from_str("model:\n name: ignored/project-model\n").unwrap(); + + config.apply_file(file).unwrap(); + assert_eq!(config.model_source, "embedded default"); + config.apply_env().unwrap(); + + assert_eq!( + config.model, + provisional_hosted_profile().generator_chain[0] + ); + assert_eq!(config.model_source, "hosted provisional profile"); + drop(provisional); + drop(hosted); + } + + #[test] + fn explicit_provider_keys_do_not_inherit_stored_login_routing() { + let _lock = env_lock().lock().unwrap(); + let directory = tempfile::tempdir().unwrap(); + let xdg = EnvRestore::capture("XDG_CONFIG_HOME"); + let saved_keys = crate::api_key::API_KEY_ENV_VARS.map(EnvRestore::capture); + EnvRestore::set("XDG_CONFIG_HOME", directory.path().to_str().unwrap()); + let path = credentials::default_path().unwrap(); + credentials::write( + &path, + &credentials::Credentials { + version: credentials::CREDENTIALS_VERSION, + token: "pcli_fixture-not-a-real-secret".into(), + expires_at: "2999-01-01T00:00:00.000Z".into(), + api_base: "https://postil.dev/api/inference/v1".into(), + org: "fixture".into(), + model: "hosted/model".into(), + }, + ) + .unwrap(); + for name in crate::api_key::API_KEY_ENV_VARS { + for other in crate::api_key::API_KEY_ENV_VARS { + unsafe { std::env::remove_var(other) }; + } + EnvRestore::set(name, "provider-fixture-key"); + let mut cfg = Config::default(); + assert!( + !cfg.apply_stored_login_credential(), + "{name} must suppress login routing" + ); + assert_eq!(cfg.api_base, model_defaults().api_base, "{name}"); + assert_eq!(cfg.model, model_defaults().default_model, "{name}"); + } + drop(saved_keys); + drop(xdg); + } + + #[test] + fn project_model_provenance_replaces_stored_login_provenance() { + let _lock = env_lock().lock().unwrap(); + let directory = tempfile::tempdir().unwrap(); + let xdg = EnvRestore::capture("XDG_CONFIG_HOME"); + let saved_keys = crate::api_key::API_KEY_ENV_VARS.map(EnvRestore::capture); + EnvRestore::set("XDG_CONFIG_HOME", directory.path().to_str().unwrap()); + for name in crate::api_key::API_KEY_ENV_VARS { + unsafe { std::env::remove_var(name) }; + } + credentials::write( + &credentials::default_path().unwrap(), + &credentials::Credentials { + version: credentials::CREDENTIALS_VERSION, + token: "pcli_fixture-not-a-real-secret".into(), + expires_at: "2999-01-01T00:00:00.000Z".into(), + api_base: "https://postil.dev/api/inference/v1".into(), + org: "fixture".into(), + model: "hosted/model".into(), + }, + ) + .unwrap(); + let mut config = Config::default(); + + assert!(config.apply_stored_login_credential()); + assert_eq!(config.model_source, "stored login"); + let file: FileConfig = yaml_serde::from_str("model:\n name: project/model\n").unwrap(); + config.apply_file(file).unwrap(); + assert_eq!(config.model, "project/model"); + assert_eq!(config.model_source, "trusted project config"); + + drop(saved_keys); + drop(xdg); + } + #[test] fn postil_yaml_wins_over_coderabbit() { let dir = tempfile::tempdir().unwrap(); @@ -2638,13 +3047,17 @@ scorer = { enabled = true, default_model = "provider/scorer", fallback = "provid let mut c = Config::default(); c.apply_file(f).unwrap(); assert_eq!(c.max_findings, 20); - assert!(c.model.is_empty()); - assert!(c.cascade.is_empty()); + assert_eq!(c.model, model_defaults().default_model); + assert_eq!(c.cascade, model_defaults().cascade); + assert_eq!(c.reasoning_effort, ReasoningEffort::Low); + assert_eq!(c.scorer_reasoning_effort, ReasoningEffort::None); assert!(c.scorer.is_empty()); } #[test] fn model_scorer_parses_from_postil_config() { + let _lock = env_lock().lock().unwrap(); + let _local_environment = LocalModelConfigEnv::clear(); let f: FileConfig = yaml_serde::from_str("model:\n scorer: custom/scorer\n").unwrap(); let mut c = Config::default(); c.apply_file(f).unwrap(); @@ -2656,7 +3069,7 @@ scorer = { enabled = true, default_model = "provider/scorer", fallback = "provid #[test] fn trusted_runtime_can_ignore_the_complete_repository_model_section() { let f: FileConfig = yaml_serde::from_str( - "model:\n name: anthropic/claude-opus-4.1\n cascade:\n - attacker/fallback\n scorer: anthropic/claude-haiku-4.5\n apiBase: https://attacker.invalid/v1\n apiFormat: anthropic\n consensus: 3\n", + "model:\n name: anthropic/claude-opus-4.1\n reasoningEffort: max\n cascade:\n - attacker/fallback\n scorer: anthropic/claude-haiku-4.5\n scorerReasoningEffort: high\n apiBase: https://attacker.invalid/v1\n apiFormat: anthropic\n consensus: 3\n", ) .unwrap(); let mut config = Config::default(); @@ -2665,7 +3078,20 @@ scorer = { enabled = true, default_model = "provider/scorer", fallback = "provid config.apply_file_inner(f, true, true).unwrap(); assert_eq!(config.model_chain(), expected.model_chain()); + assert_eq!(config.reasoning_effort, expected.reasoning_effort); + assert_eq!( + config.reasoning_effort_source, + expected.reasoning_effort_source + ); assert_eq!(config.scorer, expected.scorer); + assert_eq!( + config.scorer_reasoning_effort, + expected.scorer_reasoning_effort + ); + assert_eq!( + config.scorer_reasoning_effort_source, + expected.scorer_reasoning_effort_source + ); assert_eq!(config.scorer_chain(), expected.scorer_chain()); assert_eq!(config.api_base, DEFAULT_API_BASE); assert_eq!(config.api_format, ApiFormat::OpenaiCompatible); @@ -3119,12 +3545,14 @@ scorer = { enabled = true, default_model = "provider/scorer", fallback = "provid version: 1, source_sha256: "a".repeat(64), default_model: "provider/generator".into(), + reasoning_effort: ReasoningEffort::Low, cascade: vec!["provider/fallback".into()], consensus: 1, api_base: "https://models.example/v1".into(), api_format: ApiFormat::OpenaiCompatible, scorer_enabled: true, scorer_model: "provider/scorer".into(), + scorer_reasoning_effort: ReasoningEffort::None, scorer_fallback: "provider/scorer-fallback".into(), scorer_qualification_candidates: Vec::new(), }; @@ -3230,6 +3658,8 @@ scorer = { enabled = true, default_model = "provider/scorer", fallback = "provid #[test] fn native_anthropic_has_no_implicit_scorer_and_honors_explicit_byok_models() { + let _lock = env_lock().lock().unwrap(); + let _local_environment = LocalModelConfigEnv::clear(); let mut config = Config { api_format: ApiFormat::Anthropic, ..Config::default() @@ -3286,6 +3716,12 @@ scorer = { enabled = true, default_model = "provider/scorer", fallback = "provid assert_eq!(profile.upstream_provider_identity, "Azure"); assert_eq!(profile.generator_chain, vec!["openai/gpt-5.6-luna"]); assert_eq!(profile.scorer_chain, vec!["openai/gpt-5.6-luna"]); + let drifted = Config { + reasoning_effort: ReasoningEffort::High, + ..config.clone() + }; + assert!(provisional_hosted_profile_for_config(&drifted).is_none()); + assert!(drifted.require_model_for(true, true).is_err()); assert_eq!( profile.model_price_bounds, vec![ModelPriceBound { diff --git a/src/diff.rs b/src/diff.rs index e63c097..2f28dfe 100644 --- a/src/diff.rs +++ b/src/diff.rs @@ -1987,57 +1987,6 @@ fn hosted_risk_tokens(line: &str) -> Vec { tokens } -/// Build one bounded, line-grounded context for an interactive answer. Every -/// source window contributes semantic evidence; large inputs are reduced with -/// the same bounded fan-in used by review synthesis rather than byte truncation. -pub fn bounded_respond_context( - snapshot: &DiffSnapshot, - max_bytes: usize, - max_manifest_bytes: usize, -) -> Result<(String, bool)> { - let mut prepared = prepare_review(snapshot)?; - let reserved_anchor = prepared.reserved_anchor; - let mut batches = spool_model_batches(&mut prepared, max_bytes, max_manifest_bytes, false)?; - if batches.count == 0 { - return Ok((String::new(), reserved_anchor)); - } - let mut first: Option = None; - let mut digests = Vec::with_capacity(batches.count.min(1_024)); - while let Some(batch) = batches.next_batch()? { - if let Some(previous) = first.take() { - if digests.is_empty() { - digests.push(semantic_digest(&previous)); - } - digests.push(semantic_digest(&batch)); - } else if digests.is_empty() { - first = Some(batch); - } else { - digests.push(semantic_digest(&batch)); - } - } - if digests.is_empty() { - return Ok((first.expect("one batch was counted"), reserved_anchor)); - } - let mut level = 1usize; - loop { - let chunks = pack_semantic_digests(&digests, level, max_bytes)?; - if chunks.len() == 1 { - return Ok(( - chunks.into_iter().next().expect("checked above"), - reserved_anchor, - )); - } - anyhow::ensure!( - chunks.len() < digests.len(), - "interactive context synthesis did not reduce its bounded fan-in" - ); - digests = chunks; - level = level - .checked_add(1) - .context("interactive synthesis level overflowed")?; - } -} - pub fn spool_model_batches( prepared: &mut PreparedReview, max_batch_bytes: usize, @@ -6964,36 +6913,6 @@ diff --git a/src/multi.rs b/src/multi.rs assert!(first < second && second < third); } - #[test] - fn interactive_context_streams_distant_evidence_without_byte_truncation() { - use std::fmt::Write as _; - - let mut source = String::from( - "diff --git a/src/flow.rs b/src/flow.rs\n--- a/src/flow.rs\n+++ b/src/flow.rs\n@@ -0,0 +1,400 @@\n", - ); - for line in 1..=400 { - if line == 1 { - source.push_str("+let checked = validate_pair(input);\n"); - } else if line == 400 { - source.push_str("+dangerous_sink(original);\n"); - } else { - writeln!( - source, - "+let padding_{line} = ordinary; // {}", - "x".repeat(1_000) - ) - .unwrap(); - } - } - let snapshot = DiffSnapshot::from_bytes(source.as_bytes()).unwrap(); - let (context, reserved) = bounded_respond_context(&snapshot, 120_000, 24_000).unwrap(); - assert!(!reserved); - assert!(context.len() <= 120_000); - assert!(context.contains("validate_pair(input)")); - assert!(context.contains("dangerous_sink(original)")); - assert!(!context.contains("diff truncated")); - } - #[test] fn malformed_and_unsupported_lockfiles_use_bounded_source_review() { let malformed = "diff --git a/Cargo.lock b/Cargo.lock\n--- a/Cargo.lock\n+++ b/Cargo.lock\n@@ -1,5 +1,5 @@\n-checksum = \"old-one\"\n-source = \"old-two\"\n-checksum = \"old-interior\"\n-source = \"old-four\"\n-checksum = \"old-five\"\n+checksum = \"new-one\"\n+source = \"new-two\"\n+checksum = \"new-interior\"\n+source = \"new-four\"\n+checksum = \"new-five\"\n"; diff --git a/src/envelope.rs b/src/envelope.rs index 9f6fd40..9421265 100644 --- a/src/envelope.rs +++ b/src/envelope.rs @@ -1412,7 +1412,6 @@ pub enum ModelUsageRole { ReviewPlanner, ReviewGenerator, FindingScorer, - MentionResponder, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -1436,7 +1435,6 @@ pub enum ModelIncidentPhase { Planner, Review, Scorer, - Respond, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] diff --git a/src/forge/azure.rs b/src/forge/azure.rs index eb973eb..16b4bc5 100644 --- a/src/forge/azure.rs +++ b/src/forge/azure.rs @@ -17,8 +17,8 @@ use serde_json::json; use std::io::Write; use super::{ - CheckRunIds, CheckState, Forge, PrMeta, ReviewPublicationReceipt, ThreadKind, check_summary, - check_title, untracked_review_publication_receipt, + CheckRunIds, CheckState, Forge, PrMeta, ReviewPublicationReceipt, check_summary, check_title, + untracked_review_publication_receipt, }; use crate::diff::{DiffSnapshot, DiffSpool, WorkspaceBudget}; use crate::envelope::{Envelope, Finding}; @@ -585,47 +585,6 @@ impl Forge for Azure { .await? == expected.base_sha) } - - /// Title and description of a PR. Work-item comments live under a different - /// `_apis/wit` base and api-version than the git endpoints this client uses, - /// so respond is scoped to pull requests. - async fn fetch_thread(&self, _number: u64, kind: ThreadKind) -> Result<(String, String)> { - // TODO(respond): Azure work-item comments - // (`_apis/wit/workItems/{id}/comments?api-version=7.0-preview.3`, body - // field `text`) use a different base/version; scope respond to PRs. - if kind == ThreadKind::Issue { - return Err(anyhow!( - "postil respond on Azure DevOps supports --pr only (work items not supported)" - )); - } - let pr = self.pr().await?; - Ok((pr.title, pr.description)) - } - - /// Post a top-level PR comment as a non-anchored thread (the bot's reply to - /// a mention), using the same threads endpoint the reviewer uses for its summary. - async fn post_comment(&self, number: u64, kind: ThreadKind, body: &str) -> Result<()> { - // TODO(respond): Azure work-item comments are unverified here; scope to PRs. - if kind == ThreadKind::Issue { - return Err(anyhow!( - "postil respond on Azure DevOps supports --pr only (work items not supported)" - )); - } - let resp = self - .request( - reqwest::Method::POST, - self.url(&format!("/pullRequests/{number}/threads"), ""), - ) - .json(&json!({ - "comments": [{ "parentCommentId": 0, "commentType": 1, "content": body }], - "status": 1, - })) - .send() - .await - .context("posting comment")?; - Self::check_ok(resp, "comment post").await?; - Ok(()) - } } /// Both merge commits, or an actionable error: Azure omits them on PRs with diff --git a/src/forge/bitbucket.rs b/src/forge/bitbucket.rs index 37c13b5..ff616c6 100644 --- a/src/forge/bitbucket.rs +++ b/src/forge/bitbucket.rs @@ -21,8 +21,8 @@ use sha2::Digest; use std::io::Write; use super::{ - CheckRunIds, CheckState, Forge, PrMeta, ReviewPublicationReceipt, ThreadKind, check_summary, - check_title, untracked_review_publication_receipt, + CheckRunIds, CheckState, Forge, PrMeta, ReviewPublicationReceipt, check_summary, check_title, + untracked_review_publication_receipt, }; use crate::diff::{DiffSnapshot, DiffSpool, WorkspaceBudget}; use crate::envelope::{Envelope, Finding}; @@ -634,42 +634,6 @@ impl Forge for Bitbucket { .await?; Ok(merge_base == expected.base_sha) } - - /// Title and description of a PR. Bitbucket Cloud's issue tracker is a - /// separate, often-disabled product with a different object shape we cannot - /// verify against a live instance, so respond is scoped to pull requests. - async fn fetch_thread(&self, _number: u64, kind: ThreadKind) -> Result<(String, String)> { - // TODO(respond): Bitbucket issue-tracker comments - // (`/issues/{id}` + `/issues/{id}/comments`) are unverified; scope to PRs. - if kind == ThreadKind::Issue { - return Err(anyhow!( - "postil respond on Bitbucket supports --pr only (issue tracker not supported)" - )); - } - let pr = self.pr_meta().await?; - Ok((pr.title, pr.summary.map(|s| s.raw).unwrap_or_default())) - } - - /// Post a top-level comment on a PR (the bot's reply to a mention). - async fn post_comment(&self, number: u64, kind: ThreadKind, body: &str) -> Result<()> { - // TODO(respond): Bitbucket issue-tracker comments are unverified; scope to PRs. - if kind == ThreadKind::Issue { - return Err(anyhow!( - "postil respond on Bitbucket supports --pr only (issue tracker not supported)" - )); - } - let resp = self - .request( - reqwest::Method::POST, - self.url(&format!("/pullrequests/{number}/comments")), - ) - .json(&json!({ "content": { "raw": body } })) - .send() - .await - .context("posting comment")?; - Self::check_ok(resp, "comment post").await?; - Ok(()) - } } #[cfg(test)] diff --git a/src/forge/github.rs b/src/forge/github.rs index 409399e..b611487 100644 --- a/src/forge/github.rs +++ b/src/forge/github.rs @@ -26,8 +26,7 @@ use super::{ PublicationPlanReviewCreateOutcome, PublicationPlanReviewCreatePayload, PublicationPlanReviewSummaryCase, PublicationPlanSnapshot, PublicationPlanTerminalOperation, PublicationPlanTerminalOutcome, ReviewPublicationReceipt, ReviewPublicationSummary, - SummaryContext, ThreadKind, check_summary, check_title, only_operational_findings, - valid_details_url, + SummaryContext, check_summary, check_title, only_operational_findings, valid_details_url, }; use crate::diff::{Diff, DiffIndex, DiffSnapshot, DiffSpool, WorkspaceBudget}; use crate::envelope::{Envelope, Finding, Severity}; @@ -37,6 +36,18 @@ pub const EXPECTED_REPOSITORY_ID_ENV: &str = "POSTIL_EXPECTED_GITHUB_REPO_ID"; const GITHUB_MAX_ANNOTATIONS_PER_REQUEST: usize = 50; const FILE_LEVEL_COMMENT_MARKER: &str = ""; +macro_rules! github_telemetry { + ($($argument:tt)*) => { + crate::progress::telemetry(format_args!($($argument)*)) + }; +} + +macro_rules! github_notice { + ($($argument:tt)*) => { + crate::progress::notice(format_args!($($argument)*)) + }; +} + // Filtering caps visible findings before forge publication, so one completed // check update always fits GitHub's annotation request limit. Raising the // product cap requires implementing multi-request annotation delivery first. @@ -195,7 +206,7 @@ impl GitHub { .await .is_err() { - eprintln!( + github_notice!( "postil: github operation=review-summary-update status=incomplete recovery=truthful-initial-summary" ); } @@ -406,7 +417,7 @@ impl GitHub { let response = match response { Ok(response) => response, Err(error) => { - eprintln!( + github_telemetry!( "postil: github operation={} attempt={}/{} category=transport-error elapsed_ms={} budget_remaining_ms={}", what.replace(' ', "-"), attempt, @@ -432,7 +443,7 @@ impl GitHub { let status = response.status(); let request_id = github_request_id(response.headers()).unwrap_or_else(|| "none".to_string()); - eprintln!( + github_telemetry!( "postil: github operation={} attempt={}/{} status={} elapsed_ms={} request_id={} rate_remaining={} retry_after_secs={}", what.replace(' ', "-"), attempt, @@ -454,7 +465,7 @@ impl GitHub { if delay.is_zero() { return Ok(response); } - eprintln!( + github_telemetry!( "postil: github operation={} retrying_in_secs={} retry={}/{}", what.replace(' ', "-"), delay.as_secs(), @@ -726,48 +737,11 @@ impl GitHub { Ok(receipt) } - async fn comment_exists(&self, number: u64, marker: &str) -> Result { - const PAGE_SIZE: usize = 100; - const MAX_PAGES: usize = 20; - for page in 1..=MAX_PAGES { - let response = self - .send_retryable( - self.request( - reqwest::Method::GET, - self.url(&format!( - "/issues/{number}/comments?per_page={PAGE_SIZE}&page={page}&sort=created&direction=desc" - )), - ), - "comment reconciliation", - ) - .await?; - let comments: Vec = super::bounded_response_json( - Self::check_ok(response, "comment reconciliation").await?, - "GitHub comment reconciliation", - ) - .await?; - let page_len = comments.len(); - if comments - .into_iter() - .any(|comment| comment.body.contains(marker)) - { - return Ok(true); - } - if page_len < PAGE_SIZE { - return Ok(false); - } - } - Err(anyhow!( - "GitHub comment reconciliation exceeded {MAX_PAGES} pages; refusing an unsafe retry" - )) - } - /// Finding markers already carried by inline comments on this pull request. /// /// A finding's id is a hash over the head SHA, kind, path, line, and title, /// so an identical marker is the same finding at the same place on the same - /// commit. Two reviews of one head — a push review followed by an `@postil` - /// mention, say — legitimately re-detect the same issue, and without this + /// commit. Two reviews of one head legitimately re-detect the same issue, and without this /// the second run posts a second copy of every comment the first already /// left. /// @@ -920,47 +894,6 @@ impl GitHub { unreachable!("bounded GitHub file-level comment loop always returns") } - async fn post_comment_reconciled(&self, number: u64, body: &str, marker: &str) -> Result<()> { - const RETRIES: u32 = 2; - for retry in 0..=RETRIES { - self.verify_repository_identity_before_write().await?; - let response = self - .request( - reqwest::Method::POST, - self.url(&format!("/issues/{number}/comments")), - ) - .json(&json!({ "body": body })) - .send() - .await; - match response { - Ok(response) if response.status().is_success() => return Ok(()), - Ok(response) - if github_retryable_response(response.status(), response.headers()) => - { - if self.comment_exists(number, marker).await? { - return Ok(()); - } - if retry == RETRIES { - Self::check_ok(response, "comment post").await?; - } - } - Ok(response) => { - Self::check_ok(response, "comment post").await?; - } - Err(error) => { - if self.comment_exists(number, marker).await? { - return Ok(()); - } - if retry == RETRIES { - return Err(error).context("posting comment after reconciliation"); - } - } - } - tokio::time::sleep(github_transport_retry_delay(retry)).await; - } - unreachable!("bounded GitHub comment loop always returns") - } - async fn pull_files(&self, expected: usize) -> Result> { const PAGE_SIZE: usize = 100; const MAX_FILES: usize = 3_000; @@ -1793,11 +1726,6 @@ struct PublishedReviewComment { commit_id: Option, } -#[derive(Deserialize)] -struct PublishedComment { - body: String, -} - enum ReviewDelivery { Response(reqwest::Response), Reconciled(PublishedReview), @@ -3084,12 +3012,12 @@ impl Forge for GitHub { )); } - eprintln!( + github_telemetry!( "postil: github operation=review-post status=422 category=unresolved-line request_id={} recovery=placement-ladder", request_id, ); } else { - eprintln!( + github_telemetry!( "postil: github operation=review-reconciliation status=partial recovery=placement-ladder" ); } @@ -3214,7 +3142,7 @@ impl Forge for GitHub { && !line_findings.is_empty() && github_review_rejected_line(&body) { - eprintln!( + github_telemetry!( "postil: github operation=placement-fallback status=422 category=unresolved-line request_id={} recovery=file-comments", request_id, ); @@ -3474,32 +3402,6 @@ impl Forge for GitHub { } Ok(()) } - - /// Title and body of an issue or PR (the issues API covers both, so `kind` - /// is not needed here). - async fn fetch_thread(&self, number: u64, _kind: ThreadKind) -> Result<(String, String)> { - let resp = self - .send_retryable( - self.request(reqwest::Method::GET, self.url(&format!("/issues/{number}"))), - "issue fetch", - ) - .await?; - let v: serde_json::Value = super::bounded_response_json( - Self::check_ok(resp, "issue fetch").await?, - "GitHub issue", - ) - .await?; - let title = v["title"].as_str().unwrap_or_default().to_string(); - let body = v["body"].as_str().unwrap_or_default().to_string(); - Ok((title, body)) - } - - /// Post a top-level comment on an issue or PR (the bot's reply to a mention). - async fn post_comment(&self, number: u64, _kind: ThreadKind, body: &str) -> Result<()> { - let marker = comment_marker(number, body); - self.post_comment_reconciled(number, &append_marker(body, &marker), &marker) - .await - } } fn valid_object_id(value: &str) -> bool { @@ -4305,17 +4207,6 @@ fn required_review_body(body: &str, marker: &str) -> Result { Ok(marked) } -fn comment_marker(number: u64, body: &str) -> String { - let mut digest = Sha256::new(); - digest.update(number.to_be_bytes()); - digest.update(body.as_bytes()); - let hash = digest.finalize(); - format!( - "", - hash[0], hash[1], hash[2], hash[3], hash[4], hash[5] - ) -} - #[cfg(test)] mod tests { use super::{ @@ -4656,37 +4547,6 @@ mod tests { assert!(matches!(response, super::ReviewDelivery::Reconciled(_))); } - #[tokio::test] - async fn github_comment_reconciles_before_retrying_uncertain_post() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/repos/owner/repo/issues/9/comments")) - .respond_with(ResponseTemplate::new(500)) - .expect(1) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path("/repos/owner/repo/issues/9/comments")) - .respond_with( - ResponseTemplate::new(200).set_body_json(serde_json::json!([{ - "body": "reply\n\n" - }])), - ) - .expect(1) - .mount(&server) - .await; - let github = test_github(&server); - - github - .post_comment_reconciled( - 9, - "reply\n\n", - "", - ) - .await - .unwrap(); - } - fn test_github(server: &MockServer) -> GitHub { GitHub { http: reqwest::Client::new(), @@ -7420,56 +7280,6 @@ mod tests { } } - #[tokio::test] - async fn github_repository_identity_fence_rejects_a_renamed_repository() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/repos/owner/repo")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "id": 42, - "full_name": "owner/renamed" - }))) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(path("/repos/owner/repo/issues/9/comments")) - .respond_with(ResponseTemplate::new(201)) - .expect(0) - .mount(&server) - .await; - - let error = fenced_test_github(&server, 42) - .post_comment_reconciled(9, "reply", "marker") - .await - .unwrap_err(); - assert!(crate::forge::is_repository_identity_failure(&error)); - } - - #[tokio::test] - async fn github_repository_identity_fence_rejects_a_fork_id_for_base_publication() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/repos/owner/repo")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "id": 42, - "full_name": "owner/repo" - }))) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(path("/repos/owner/repo/issues/9/comments")) - .respond_with(ResponseTemplate::new(201)) - .expect(0) - .mount(&server) - .await; - - let error = fenced_test_github(&server, 99) - .post_comment_reconciled(9, "reply", "marker") - .await - .unwrap_err(); - assert!(crate::forge::is_repository_identity_failure(&error)); - } - #[tokio::test] async fn github_repository_identity_fence_publishes_only_for_the_same_pr_snapshot() { let server = MockServer::start().await; diff --git a/src/forge/gitlab.rs b/src/forge/gitlab.rs index add8e71..f49ac81 100644 --- a/src/forge/gitlab.rs +++ b/src/forge/gitlab.rs @@ -10,8 +10,8 @@ use serde_json::json; use std::io::Write; use super::{ - CheckRunIds, CheckState, Forge, PrMeta, ReviewPublicationReceipt, ThreadKind, check_summary, - check_title, untracked_review_publication_receipt, + CheckRunIds, CheckState, Forge, PrMeta, ReviewPublicationReceipt, check_summary, check_title, + untracked_review_publication_receipt, }; use crate::diff::{DiffSnapshot, DiffSpool, WorkspaceBudget}; use crate::envelope::{Envelope, Finding}; @@ -596,52 +596,6 @@ impl Forge for GitLab { let current = self.mr().await?; Ok(mr_matches_snapshot(¤t, expected)) } - - /// Title and description of an issue or MR. GitLab's issue and merge-request - /// objects both expose `title` and `description`, so only the resource path - /// differs by `kind`. - async fn fetch_thread(&self, number: u64, kind: ThreadKind) -> Result<(String, String)> { - let resource = match kind { - ThreadKind::Pull => "merge_requests", - ThreadKind::Issue => "issues", - }; - let resp = self - .request( - reqwest::Method::GET, - self.url(&format!("/{resource}/{number}")), - ) - .send() - .await - .context("fetching thread")?; - let v: serde_json::Value = super::bounded_response_json( - Self::check_ok(resp, "thread fetch").await?, - "GitLab thread", - ) - .await?; - let title = v["title"].as_str().unwrap_or_default().to_string(); - let body = v["description"].as_str().unwrap_or_default().to_string(); - Ok((title, body)) - } - - /// Post a top-level note on an issue or MR (the bot's reply to a mention). - /// Both resources expose `/{resource}/{number}/notes` with a `body` field. - async fn post_comment(&self, number: u64, kind: ThreadKind, body: &str) -> Result<()> { - let resource = match kind { - ThreadKind::Pull => "merge_requests", - ThreadKind::Issue => "issues", - }; - let resp = self - .request( - reqwest::Method::POST, - self.url(&format!("/{resource}/{number}/notes")), - ) - .json(&json!({ "body": body })) - .send() - .await - .context("posting note")?; - Self::check_ok(resp, "note post").await?; - Ok(()) - } } #[cfg(test)] diff --git a/src/forge/mod.rs b/src/forge/mod.rs index 6b1222d..afc25fb 100644 --- a/src/forge/mod.rs +++ b/src/forge/mod.rs @@ -409,17 +409,6 @@ pub struct CheckRunIds<'a> { pub gate: &'a str, } -/// What a `respond` thread number points at. GitHub's issues API covers both, -/// so it ignores this; GitLab/Bitbucket/Azure key issues and PRs on different -/// endpoints, so they branch on it. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ThreadKind { - /// A pull request / merge request. - Pull, - /// An issue / work item on the forge's issue tracker. - Issue, -} - /// Versioned result of one review publication attempt. Finding outcomes are /// keyed by the envelope's stable finding ID so the hosted service can join /// the immutable delivery result to later thread lifecycle observations. @@ -1509,15 +1498,6 @@ pub trait Forge { && current.base_sha == expected.base_sha && current.target_sha == expected.target_sha) } - - /// Title and body of the issue/PR/MR a maintainer mentioned Postil on, used - /// to ground the answer (`postil respond`). `kind` disambiguates the number - /// for forges whose issues and pulls live on different endpoints. - async fn fetch_thread(&self, number: u64, kind: ThreadKind) -> Result<(String, String)>; - - /// Post a top-level comment (Postil's reply to a mention). `kind` selects the - /// issue- vs pull-level endpoint where the forge separates them. - async fn post_comment(&self, number: u64, kind: ThreadKind, body: &str) -> Result<()>; } /// GitHub rejects a check-run `output.summary` over 65535 chars and a `title` diff --git a/src/lib.rs b/src/lib.rs index 77192d4..ed90030 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,9 +20,9 @@ pub mod local; pub mod login; pub mod output; pub mod plan; +pub(crate) mod progress; pub mod prompt; pub(crate) mod repository_search; pub(crate) mod resolve; -pub mod respond; pub mod review; pub mod sarif; diff --git a/src/llm.rs b/src/llm.rs index 19ff91e..b514cd5 100644 --- a/src/llm.rs +++ b/src/llm.rs @@ -19,9 +19,13 @@ use time::Date; use crate::adjudication::{AdjudicationResult, MAX_ADJUDICATION_OUTPUT_TOKENS}; use crate::api_key; + +macro_rules! eprintln { + ($($argument:tt)*) => { crate::progress::telemetry(format_args!($($argument)*)) }; +} use crate::config::{ ApiFormat, Config, HOSTED_ADMISSION_PROJECTION_CAP_MICROS, HOSTED_OPERATION_COST_CAP_MICROS, - ModelPriceBound, + ModelPriceBound, ReasoningEffort, }; use crate::envelope::{ Finding, Kind, ModelIncident, ModelIncidentCategory, ModelIncidentPhase, ModelIncidentRecovery, @@ -130,15 +134,6 @@ struct RawAtomicAttribution { reason: String, } -#[derive(Debug, Clone)] -pub struct Answer { - pub content: String, - pub model_used: String, - pub usage: Usage, - pub models: Vec, - pub usage_accounting_complete: bool, -} - #[derive(Debug)] pub struct ModelError { error: anyhow::Error, @@ -402,10 +397,6 @@ pub(crate) fn add_usage(total: &mut Usage, usage: Usage) { }; } -fn has_billable_usage(usage: Usage) -> bool { - usage.prompt_tokens > 0 || usage.completion_tokens > 0 || usage.provider_cost.is_some() -} - fn add_response_usage( usage: &mut Usage, prompt_tokens: u64, @@ -455,20 +446,6 @@ fn safe_model_error_category(error: &ModelError) -> &'static str { } } -fn safe_anyhow_category(error: &anyhow::Error) -> &'static str { - if error.downcast_ref::().is_some() { - "deadline" - } else if reqwest_error(error).is_some_and(reqwest::Error::is_timeout) - || error.downcast_ref::().is_some() - { - "timeout" - } else if error.downcast_ref::().is_some() { - "provider" - } else { - "invalid-output" - } -} - /// Raw shape we ask the model for. Findings are validated leniently here and /// strictly (grounding, ranges) by the caller. #[derive(Debug, Deserialize)] @@ -630,6 +607,8 @@ pub struct LlmClient { struct RequestDecorations { api_base: String, api_format: ApiFormat, + reasoning_effort: ReasoningEffort, + scorer_reasoning_effort: ReasoningEffort, require_openrouter_privacy: bool, hosted_price_bounds: Option>>, pinned_upstream_provider: Option, @@ -729,10 +708,6 @@ const SCORER_MAX_TOKENS_PER_FINDING: u32 = 144; const SCORER_REPAIR_BYTES_PER_OUTPUT_TOKEN: usize = 4; pub(crate) const SCORER_MAX_FINDINGS: usize = 20; const REPAIR_ERROR_MAX_BYTES: usize = 1_024; -// The publication contract targets 1,200 characters and hard-stops at 2,400. -// Keep generation bounded too, so an invalid model cannot spend an article's -// worth of output tokens before the validator rejects it. -const RESPOND_MAX_TOKENS: u32 = 1024; const PLANNER_MAX_TOKENS: u32 = 1024; fn planned_review_models(cfg: &Config) -> Vec { if cfg.consensus > 1 { @@ -1296,6 +1271,16 @@ fn base_request_body( impl RequestDecorations { fn from_config(cfg: &Config) -> Result { + if cfg.api_format == ApiFormat::Anthropic { + ensure!( + cfg.reasoning_effort != ReasoningEffort::Minimal, + "model.reasoningEffort minimal is unsupported by the Anthropic request format; accepted Anthropic values: max|xhigh|high|medium|low|none" + ); + ensure!( + cfg.scorer_reasoning_effort != ReasoningEffort::Minimal, + "model.scorerReasoningEffort minimal is unsupported by the Anthropic request format; accepted Anthropic values: max|xhigh|high|medium|low|none" + ); + } let screening_profile = crate::config::benchmark_screening_profile_for_config(cfg)?; let hosted_price_bounds = if crate::config::hosted_runtime_mode() { ensure_hosted_provider_contract(cfg.api_format, &cfg.api_base)?; @@ -1345,6 +1330,8 @@ impl RequestDecorations { Ok(Self { api_base: cfg.api_base.trim_end_matches('/').to_string(), api_format: cfg.api_format, + reasoning_effort: cfg.reasoning_effort, + scorer_reasoning_effort: cfg.scorer_reasoning_effort, require_openrouter_privacy: is_canonical_openrouter_base(&cfg.api_base) && (crate::config::hosted_runtime_mode() || crate::config::benchmark_screening_mode() @@ -1375,6 +1362,10 @@ impl RequestDecorations { max_tokens, temperature, ); + apply_openrouter_reasoning( + &mut body, + phase.reasoning_effort(self.reasoning_effort, self.scorer_reasoning_effort), + ); apply_openrouter_privacy(&mut body, self.require_openrouter_privacy); let canonical_openrouter = is_canonical_openrouter_base(&self.api_base); if let Some(provider) = self.pinned_upstream_provider.as_deref() { @@ -1397,14 +1388,21 @@ impl RequestDecorations { } body } - ApiFormat::Anthropic => base_request_body( - self.api_format, - model, - system, - user, - max_tokens, - temperature, - ), + ApiFormat::Anthropic => { + let mut body = base_request_body( + self.api_format, + model, + system, + user, + max_tokens, + temperature, + ); + apply_anthropic_reasoning( + &mut body, + phase.reasoning_effort(self.reasoning_effort, self.scorer_reasoning_effort), + ); + body + } } } } @@ -1551,7 +1549,6 @@ enum LlmPhase { Adjudication, #[cfg_attr(not(feature = "qualification-candidate"), allow(dead_code))] Attribution, - Respond, #[cfg_attr(not(test), allow(dead_code))] Total, } @@ -1579,7 +1576,6 @@ impl LlmPhase { Self::Scorer { .. } => "scorer", Self::Adjudication => "finding-adjudication", Self::Attribution => "attribution", - Self::Respond => "respond", Self::Total => "total", } } @@ -1593,7 +1589,19 @@ impl LlmPhase { Self::Scorer { .. } => ModelUsageRole::FindingScorer, Self::Adjudication => ModelUsageRole::FindingScorer, Self::Attribution => ModelUsageRole::FindingScorer, - Self::Respond => ModelUsageRole::MentionResponder, + } + } + + fn reasoning_effort( + self, + reviewer: ReasoningEffort, + scorer: ReasoningEffort, + ) -> ReasoningEffort { + match self { + Self::Planner | Self::Review | Self::Resolution | Self::Brevity | Self::Total => { + reviewer + } + Self::Scorer { .. } | Self::Adjudication | Self::Attribution => scorer, } } @@ -1735,7 +1743,6 @@ impl std::fmt::Display for DeadlineExceeded { | LlmPhase::Scorer { .. } | LlmPhase::Adjudication | LlmPhase::Attribution - | LlmPhase::Respond | LlmPhase::Total => f.write_str("LLM total deadline exceeded"), } } @@ -2189,35 +2196,6 @@ impl LlmClient { ReviewAdmission::try_from(exposure) } - pub(crate) fn preflight_respond_plan( - &self, - cfg: &Config, - system: &str, - user: &str, - ) -> Result { - let Some(bounds) = &self.request_decorations.hosted_price_bounds else { - return Ok(ReviewAdmission::default()); - }; - let models = cfg.model_chain(); - ensure!(!models.is_empty(), "hosted respond has no admitted model"); - let mut exposure = PlannedExposure::default(); - for model in models { - let price = bounds.get(&model).ok_or_else(|| { - anyhow!("hosted respond model {model:?} has no admitted price bound") - })?; - let path = self.planned_request_path( - &model, - system, - user, - RESPOND_MAX_TOKENS, - 0.1, - LlmPhase::Respond, - )?; - exposure.add_path(&path, price)?; - } - self.validate_hosted_exposure("respond", &exposure) - } - pub async fn plan_review_batches( &self, cfg: &Config, @@ -3010,102 +2988,6 @@ impl LlmClient { } } - /// Interactive answer validated by the caller's publication contract. - /// Invalid model content consumes and preserves its usage before the next - /// configured model is tried. - pub async fn answer( - &self, - cfg: &Config, - system: &str, - user: &str, - validate: F, - ) -> Result - where - F: Fn(&str) -> Result, - { - let mut usage = Usage::default(); - let mut models = Vec::new(); - let mut last_err = None; - let mut usage_accounting_complete = true; - let chain = cfg.model_chain(); - let chain_len = chain.len(); - for (index, model) in chain.into_iter().enumerate() { - let mut model_usage = Usage::default(); - let mut call_usage = Vec::new(); - let mut model_accounting_complete = true; - match self - .chat( - &model, - system, - user, - &mut model_usage, - &mut call_usage, - &mut model_accounting_complete, - RESPOND_MAX_TOKENS, - LlmPhase::Respond, - LlmCallPhase::Initial, - ) - .await - { - Ok(content) => { - usage_accounting_complete &= model_accounting_complete; - add_usage(&mut usage, model_usage); - models.append(&mut call_usage); - match validate(&content) { - Ok(content) => { - return Ok(Answer { - content, - model_used: model, - usage, - models, - usage_accounting_complete, - }); - } - Err(error) => { - let disposition = if index + 1 < chain_len { - "trying the next model" - } else { - "no fallback models remain" - }; - eprintln!( - "postil: model {} produced an invalid reply; {disposition} category={}", - log_text(&model), - safe_anyhow_category(&error), - ); - last_err = Some(error.context("model reply failed publication checks")); - } - } - } - Err(e) => { - // Usage parsed from a provider response is complete even - // when the response has no usable answer. A transport - // failure with no response usage is ambiguous. - if !model_accounting_complete || !has_billable_usage(model_usage) { - usage_accounting_complete = false; - } - eprintln!( - "postil: model {} failed category={}", - log_text(&model), - safe_anyhow_category(&e) - ); - // Provider failures that report no tokens have no billable - // usage to attribute. Omit them rather than emitting a - // misleading accounting entry; token-bearing failures are - // retained and priced by the hosted control plane. - if has_billable_usage(model_usage) { - add_usage(&mut usage, model_usage); - } - models.append(&mut call_usage); - if e.downcast_ref::().is_some() { - return Err(e); - } - last_err = Some(e); - } - } - } - Err(last_err.unwrap_or_else(|| anyhow!("empty model chain"))) - } - pub async fn score_findings( &self, cfg: &Config, @@ -5135,7 +5017,7 @@ impl LlmClient { | LlmPhase::Scorer { .. } | LlmPhase::Adjudication | LlmPhase::Attribution => self.scorer_deadline.or(self.total_deadline), - LlmPhase::Respond | LlmPhase::Total => self.total_deadline, + LlmPhase::Total => self.total_deadline, }; let deadline = if matches!(phase, LlmPhase::Planner | LlmPhase::Review) { match (phase_deadline, self.review_model_deadline) { @@ -5258,6 +5140,24 @@ fn apply_openrouter_price_ceiling(body: &mut serde_json::Value, bound: &ModelPri ); } +fn apply_openrouter_reasoning(body: &mut serde_json::Value, effort: ReasoningEffort) { + body["reasoning"] = json!({ "effort": effort.as_str() }); +} + +fn apply_anthropic_reasoning(body: &mut serde_json::Value, effort: ReasoningEffort) { + match effort { + ReasoningEffort::None => { + body["thinking"] = json!({ "type": "disabled" }); + } + ReasoningEffort::Minimal => { + unreachable!("Anthropic minimal effort is rejected while resolving request settings") + } + _ => { + body["output_config"] = json!({ "effort": effort.as_str() }); + } + } +} + fn apply_openrouter_scorer_contract(body: &mut serde_json::Value, expected_len: usize) { debug_assert!(expected_len <= SCORER_MAX_FINDINGS); let provider = body @@ -5269,10 +5169,7 @@ fn apply_openrouter_scorer_contract(body: &mut serde_json::Value, expected_len: .as_object_mut() .expect("provider routing configuration is an object") .insert("require_parameters".to_string(), json!(true)); - body["reasoning"] = json!({ - "effort": "none", - "exclude": true, - }); + body["reasoning"]["exclude"] = json!(true); body["response_format"] = json!({ "type": "json_schema", "json_schema": { @@ -5332,7 +5229,7 @@ fn apply_openrouter_atomic_attribution_contract( .as_object_mut() .expect("provider routing configuration is an object"); provider.insert("require_parameters".to_string(), json!(true)); - body["reasoning"] = json!({ "effort": "none", "exclude": true }); + body["reasoning"]["exclude"] = json!(true); body["response_format"] = json!({ "type": "json_schema", "json_schema": { @@ -5661,12 +5558,10 @@ fn resolve_api_key() -> Result { .context("resolving the postil login credentials path")?; match api_key::resolve_effective(&credentials_path)? { Some(key) => Ok(key), - None => { - let key_names = api_key::names_text(); - Err(anyhow!( - "no API key: set {key_names}, or run `postil login`. Postil never proxies your inference without one of these." - )) - } + None => Err(anyhow!( + "no API key: {}. Postil never proxies your inference without one of these.", + api_key::credential_help() + )), } } @@ -7856,50 +7751,6 @@ mod tests { })); } - #[test] - fn hosted_respond_preflight_accounts_for_every_fallback_before_calls() { - let config = Config { - model: "provider/primary".into(), - cascade: vec!["provider/fallback".into()], - ..Config::default() - }; - let mut client = LlmClient::build( - &config, - "test-key".into(), - Duration::from_secs(1), - None, - None, - ) - .unwrap(); - client.request_decorations.hosted_price_bounds = Some(Arc::new(HashMap::from([ - ( - "provider/primary".into(), - ModelPriceBound { - model: "provider/primary".into(), - input_micros_per_million_tokens: 1, - output_micros_per_million_tokens: 1, - }, - ), - ( - "provider/fallback".into(), - ModelPriceBound { - model: "provider/fallback".into(), - input_micros_per_million_tokens: 1, - output_micros_per_million_tokens: 1, - }, - ), - ]))); - let admission = client - .preflight_respond_plan(&config, "system", "bounded user context") - .unwrap(); - assert_eq!(admission.provider_attempts, 6); - assert_eq!( - admission.output_tokens, - u64::from(RESPOND_MAX_TOKENS + 2 * RESPOND_MAX_TOKENS * 2) * 2 - ); - assert_eq!(client.admission.lock().unwrap().attempts, 0); - } - #[test] fn exhausted_output_retry_ceiling_is_phase_aware() { assert_eq!( @@ -7910,10 +7761,6 @@ mod tests { LlmPhase::Planner.exhausted_output_retry_max_tokens(PLANNER_MAX_TOKENS), PLANNER_MAX_TOKENS * 2 ); - assert_eq!( - LlmPhase::Respond.exhausted_output_retry_max_tokens(RESPOND_MAX_TOKENS), - RESPOND_MAX_TOKENS * 2 - ); let scorer_tokens = scorer_max_tokens(SCORER_MAX_FINDINGS).unwrap(); assert_eq!( LlmPhase::Scorer { @@ -9465,7 +9312,7 @@ mod tests { } #[test] - fn canonical_openrouter_scorer_uses_no_reasoning_effort_and_requires_strict_schema() { + fn canonical_openrouter_uses_role_defaults_and_scorer_strict_schema() { let client = LlmClient::build( &Config::default(), "test-key".into(), @@ -9537,7 +9384,7 @@ mod tests { 0.1, LlmPhase::Review, ); - assert!(generator.get("reasoning").is_none()); + assert_eq!(generator["reasoning"], json!({"effort": "low"})); assert!(generator.get("response_format").is_none()); assert!(generator["provider"].get("require_parameters").is_none()); } @@ -9560,7 +9407,7 @@ mod tests { 0.0, LlmPhase::Resolution, ); - assert!(resolution.get("reasoning").is_none()); + assert_eq!(resolution["reasoning"], json!({"effort": "low"})); assert!(resolution.get("response_format").is_none()); assert!(resolution["provider"].get("require_parameters").is_none()); } @@ -9957,7 +9804,7 @@ mod tests { } #[test] - fn byok_openai_and_direct_anthropic_request_bodies_have_no_hosted_routing_fields() { + fn byok_openai_and_direct_anthropic_requests_send_role_reasoning_without_hosted_routing() { let _lock = env_lock().lock().unwrap(); let _env = EnvRestore::capture(&["POSTIL_HOSTED_MODE"]); EnvRestore::remove("POSTIL_HOSTED_MODE"); @@ -9983,7 +9830,7 @@ mod tests { LlmPhase::Scorer { expected_len: 1 }, ); assert!(openai_body.get("provider").is_none()); - assert!(openai_body.get("reasoning").is_none()); + assert_eq!(openai_body["reasoning"], json!({"effort": "none"})); assert!(openai_body.get("response_format").is_none()); let anthropic = LlmClient::build( @@ -10008,11 +9855,102 @@ mod tests { LlmPhase::Scorer { expected_len: 1 }, ); assert!(anthropic_body.get("provider").is_none()); - assert!(anthropic_body.get("reasoning").is_none()); + assert_eq!(anthropic_body["thinking"], json!({"type": "disabled"})); + assert!(anthropic_body.get("output_config").is_none()); assert!(anthropic_body.get("response_format").is_none()); assert_eq!(anthropic_body["system"], "system"); } + #[test] + fn every_openrouter_phase_uses_its_role_specific_effort() { + let client = LlmClient::build( + &Config { + reasoning_effort: ReasoningEffort::Xhigh, + scorer_reasoning_effort: ReasoningEffort::Medium, + ..Config::default() + }, + "test-key".into(), + Duration::from_secs(1), + None, + None, + ) + .unwrap(); + + for phase in [ + LlmPhase::Planner, + LlmPhase::Review, + LlmPhase::Resolution, + LlmPhase::Brevity, + LlmPhase::Total, + ] { + let body = client.request_body("provider/model", "system", "user", 100, 0.0, phase); + assert_eq!(body["reasoning"], json!({"effort": "xhigh"})); + } + for phase in [ + LlmPhase::Scorer { expected_len: 1 }, + LlmPhase::Adjudication, + LlmPhase::Attribution, + ] { + let body = client.request_body("provider/model", "system", "user", 100, 0.0, phase); + assert_eq!(body["reasoning"]["effort"], "medium"); + } + } + + #[test] + fn native_anthropic_maps_supported_efforts_and_rejects_minimal() { + let client = LlmClient::build( + &Config { + api_format: ApiFormat::Anthropic, + api_base: "https://api.anthropic.com/v1".into(), + reasoning_effort: ReasoningEffort::High, + scorer_reasoning_effort: ReasoningEffort::None, + ..Config::default() + }, + "test-key".into(), + Duration::from_secs(1), + None, + None, + ) + .unwrap(); + let reviewer = client.request_body( + "provider/model", + "system", + "user", + 100, + 0.0, + LlmPhase::Review, + ); + assert_eq!(reviewer["output_config"], json!({"effort": "high"})); + assert!(reviewer.get("thinking").is_none()); + let scorer = client.request_body( + "provider/model", + "system", + "user", + 100, + 0.0, + LlmPhase::Scorer { expected_len: 1 }, + ); + assert_eq!(scorer["thinking"], json!({"type": "disabled"})); + assert!(scorer.get("output_config").is_none()); + + let error = LlmClient::build( + &Config { + api_format: ApiFormat::Anthropic, + api_base: "https://api.anthropic.com/v1".into(), + reasoning_effort: ReasoningEffort::Minimal, + ..Config::default() + }, + "test-key".into(), + Duration::from_secs(1), + None, + None, + ) + .err() + .expect("unsupported effort must fail before a request is built"); + assert!(error.to_string().contains("reasoningEffort minimal")); + assert!(error.to_string().contains("max|xhigh|high|medium|low|none")); + } + #[test] fn hosted_projected_and_reported_costs_cannot_cross_the_service_reservation() { let config = Config { diff --git a/src/local.rs b/src/local.rs index f1ef7b6..8ecfc7d 100644 --- a/src/local.rs +++ b/src/local.rs @@ -15,6 +15,71 @@ pub enum LocalSource { Base(String), /// A unified diff already on disk. DiffFile(std::path::PathBuf), + /// Read-only staged selection for bare `postil review`. + AutoStaged, + /// A clean branch range has an immutable HEAD revision. A dirty branch + /// range includes the working tree and deliberately has no revision. + AutoBranch { + base: String, + include_worktree: bool, + }, + /// Tracked working-tree changes only, never untracked files. + WorkingTree, + /// A clean repository has a valid empty review without a model request. + Clean, +} + +pub struct AutoSelection { + pub source: LocalSource, + pub warning: Option<&'static str>, +} + +/// Select the least surprising local diff without fetching, writing Git +/// objects, or changing the index. Explicit local modes bypass this selector. +pub async fn auto_select( + repository_root: &std::path::Path, + head_sha: Option<&str>, +) -> Result { + let untracked = !git_succeeds( + repository_root, + &["ls-files", "--others", "--exclude-standard"], + ) + .await? + .trim() + .is_empty(); + let warning = untracked.then_some( + "untracked files were not reviewed; add the files you want reviewed, then rerun `postil review`", + ); + if git_has_changes(repository_root, &["diff", "--cached", "--quiet", "--"]).await? { + return Ok(AutoSelection { + source: LocalSource::AutoStaged, + warning, + }); + } + if let (Some(base), Some(head_sha)) = (default_branch(repository_root).await?, head_sha) { + let range = format!("{base}...{head_sha}"); + if git_has_changes(repository_root, &["diff", "--quiet", &range, "--"]).await? { + let include_worktree = + git_has_changes(repository_root, &["diff", "--quiet", "--"]).await?; + return Ok(AutoSelection { + source: LocalSource::AutoBranch { + base, + include_worktree, + }, + warning, + }); + } + } + if git_has_changes(repository_root, &["diff", "--quiet", "--"]).await? { + return Ok(AutoSelection { + source: LocalSource::WorkingTree, + warning, + }); + } + Ok(AutoSelection { + source: LocalSource::Clean, + warning, + }) } pub struct LocalReviewSnapshot { @@ -35,23 +100,14 @@ pub async fn acquire( diff: DiffSnapshot::from_path(path)?, repository_revision: None, }), - LocalSource::Staged => { - let base_revision = match head_sha { - Some(head_sha) => head_sha.to_string(), - None => git_output(repository_root, &["mktree"]).await?, - }; - let index_tree = git_output(repository_root, &["write-tree"]).await?; - if !crate::repository_search::valid_full_object_id(&index_tree) { - return Err(anyhow!("git write-tree returned an invalid object id")); - } - Ok(LocalReviewSnapshot { - diff: git_diff( - repository_root, - &["diff", "--no-color", &base_revision, &index_tree, "--"], - ) - .await?, - repository_revision: Some(index_tree), - }) + LocalSource::Staged => acquire_index_snapshot(head_sha, repository_root).await, + LocalSource::AutoStaged => { + acquire_automatic( + repository_root, + head_sha, + acquire_index_snapshot(head_sha, repository_root), + ) + .await } LocalSource::Base(base) => { let range = format!("{base}...{}", head_sha.unwrap_or("HEAD")); @@ -60,7 +116,227 @@ pub async fn acquire( repository_revision: head_sha.map(str::to_string), }) } + LocalSource::AutoBranch { + base, + include_worktree, + } => { + let head_sha = head_sha.context("automatic branch review requires a captured HEAD")?; + anyhow::ensure!( + crate::repository_search::valid_full_object_id(head_sha), + "automatic branch review captured an invalid HEAD object id" + ); + acquire_automatic(repository_root, Some(head_sha), async { + let merge_base = + git_output(repository_root, &["merge-base", base, head_sha]).await?; + let range = format!("{base}...{head_sha}"); + let diff = if *include_worktree { + git_diff( + repository_root, + &["diff", "--no-color", merge_base.as_str(), "--"], + ) + .await? + } else { + git_diff( + repository_root, + &["diff", "--no-color", range.as_str(), "--"], + ) + .await? + }; + Ok(LocalReviewSnapshot { + diff, + repository_revision: (!include_worktree).then(|| head_sha.to_string()), + }) + }) + .await + } + LocalSource::WorkingTree => { + acquire_automatic(repository_root, head_sha, async { + Ok(LocalReviewSnapshot { + diff: git_diff(repository_root, &["diff", "--no-color", "--"]).await?, + repository_revision: None, + }) + }) + .await + } + LocalSource::Clean => { + acquire_automatic(repository_root, head_sha, async { + Ok(LocalReviewSnapshot { + diff: DiffSnapshot::from_bytes(b"")?, + repository_revision: head_sha.map(str::to_string), + }) + }) + .await + } + } +} + +async fn acquire_automatic( + repository_root: &std::path::Path, + captured_head: Option<&str>, + acquisition: impl std::future::Future>, +) -> Result { + ensure_captured_head( + repository_root, + captured_head, + "after automatic review selection", + ) + .await?; + let value = acquisition.await?; + ensure_captured_head( + repository_root, + captured_head, + "while acquiring the automatic review", + ) + .await?; + Ok(value) +} + +async fn acquire_index_snapshot( + head_sha: Option<&str>, + repository_root: &std::path::Path, +) -> Result { + let base_revision = match head_sha { + Some(head_sha) => head_sha.to_string(), + None => git_output(repository_root, &["mktree"]).await?, + }; + let index_tree = git_output(repository_root, &["write-tree"]).await?; + if !crate::repository_search::valid_full_object_id(&index_tree) { + return Err(anyhow!("git write-tree returned an invalid object id")); + } + Ok(LocalReviewSnapshot { + diff: git_diff( + repository_root, + &["diff", "--no-color", &base_revision, &index_tree, "--"], + ) + .await?, + repository_revision: Some(index_tree), + }) +} + +async fn ensure_captured_head( + repository_root: &std::path::Path, + captured_head: Option<&str>, + timing: &str, +) -> Result<()> { + let current_head = head_sha(repository_root).await?; + anyhow::ensure!( + current_head.as_deref() == captured_head, + "HEAD changed {timing}; rerun `postil review`" + ); + Ok(()) +} + +async fn git_succeeds(repository_root: &std::path::Path, args: &[&str]) -> Result { + let output = Command::new("git") + .arg("-C") + .arg(repository_root) + .args(args) + .output() + .await + .context("running git")?; + if !output.status.success() { + return Err(anyhow!( + "git {} failed: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr).trim() + )); + } + String::from_utf8(output.stdout).context("git output is not UTF-8") +} + +async fn git_has_changes(repository_root: &std::path::Path, args: &[&str]) -> Result { + let output = Command::new("git") + .arg("-C") + .arg(repository_root) + .args(args) + .output() + .await + .context("running git")?; + match output.status.code() { + Some(0) => Ok(false), + Some(1) => Ok(true), + _ => Err(anyhow!( + "git {} failed: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr).trim() + )), + } +} + +async fn default_branch(repository_root: &std::path::Path) -> Result> { + let current = match git_output(repository_root, &["branch", "--show-current"]).await { + Ok(branch) if !branch.is_empty() => branch, + _ => return Ok(None), + }; + let configured_remote = git_output( + repository_root, + &["config", "--get", &format!("branch.{current}.remote")], + ) + .await + .ok(); + let known_remotes = git_output(repository_root, &["remote"]) + .await + .unwrap_or_default() + .lines() + .map(str::trim) + .filter(|remote| !remote.is_empty()) + .map(str::to_string) + .collect::>(); + let mut remotes = Vec::new(); + if let Some(remote) = configured_remote.filter(|remote| !remote.is_empty() && remote != ".") { + remotes.push(remote); + } + if known_remotes.iter().any(|remote| remote == "origin") + && !remotes.iter().any(|remote| remote == "origin") + { + remotes.push("origin".to_string()); + } + for remote in known_remotes { + if !remotes.contains(&remote) { + remotes.push(remote); + } } + let mut candidates = Vec::new(); + for remote in &remotes { + if let Ok(symbolic) = git_output( + repository_root, + &[ + "symbolic-ref", + "--quiet", + "--short", + &format!("refs/remotes/{remote}/HEAD"), + ], + ) + .await + { + candidates.push(symbolic); + } + } + for remote in remotes { + candidates.extend([ + format!("refs/remotes/{remote}/main"), + format!("refs/remotes/{remote}/master"), + ]); + } + candidates.extend(["main".to_string(), "master".to_string()]); + for candidate in candidates { + if candidate != current + && git_output( + repository_root, + &[ + "rev-parse", + "--verify", + "--quiet", + &format!("{candidate}^{{commit}}"), + ], + ) + .await + .is_ok() + { + return Ok(Some(candidate)); + } + } + Ok(None) } async fn git_output(repository_root: &std::path::Path, args: &[&str]) -> Result { @@ -132,15 +408,32 @@ async fn git_diff(repository_root: &std::path::Path, args: &[&str]) -> Result Option { - let out = Command::new("git") - .args(["rev-parse", "HEAD"]) +pub async fn head_sha(repository_root: &std::path::Path) -> Result> { + let output = Command::new("git") + .arg("-C") + .arg(repository_root) + .args(["rev-parse", "--verify", "--quiet", "HEAD"]) .output() .await - .ok()?; - out.status - .success() - .then(|| String::from_utf8_lossy(&out.stdout).trim().to_string()) + .context("running git rev-parse")?; + match output.status.code() { + Some(0) => { + let head = String::from_utf8(output.stdout) + .context("git rev-parse output is not UTF-8")? + .trim() + .to_string(); + anyhow::ensure!( + crate::repository_search::valid_full_object_id(&head), + "git rev-parse returned an invalid HEAD object id" + ); + Ok(Some(head)) + } + Some(1) => Ok(None), + _ => Err(anyhow!( + "git rev-parse HEAD failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + )), + } } #[cfg(test)] @@ -183,8 +476,334 @@ mod tests { commit } + fn commit_from_head(root: &Path) -> String { + run_git(root, &["add", "-A"]); + let tree = run_git(root, &["write-tree"]); + let parent = run_git(root, &["rev-parse", "HEAD"]); + let output = std::process::Command::new("git") + .arg("-C") + .arg(root) + .args(["commit-tree", &tree, "-p", &parent, "-m", "fixture"]) + .env("GIT_AUTHOR_NAME", "Fixture") + .env("GIT_AUTHOR_EMAIL", "fixture@example.invalid") + .env("GIT_COMMITTER_NAME", "Fixture") + .env("GIT_COMMITTER_EMAIL", "fixture@example.invalid") + .output() + .unwrap(); + assert!(output.status.success()); + let commit = String::from_utf8(output.stdout).unwrap().trim().to_string(); + run_git(root, &["update-ref", "HEAD", &commit]); + commit + } + + fn repository_with_main_and_feature(root: &Path) -> String { + run_git(root, &["init", "--quiet"]); + std::fs::write(root.join("tracked.txt"), "base\n").unwrap(); + let base = create_commit(root); + if run_git(root, &["branch", "--show-current"]) != "main" { + run_git(root, &["branch", "main", &base]); + } + run_git(root, &["checkout", "--quiet", "-b", "feature"]); + base + } + + #[tokio::test] + async fn bare_selection_prefers_one_immutable_staged_snapshot() { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path(); + repository_with_main_and_feature(root); + std::fs::write(root.join("tracked.txt"), "staged\n").unwrap(); + run_git(root, &["add", "tracked.txt"]); + std::fs::write(root.join("tracked.txt"), "working\n").unwrap(); + + let head = run_git(root, &["rev-parse", "HEAD"]); + let selection = auto_select(root, Some(&head)).await.unwrap(); + assert!(matches!(selection.source, LocalSource::AutoStaged)); + let snapshot = acquire(&selection.source, Some(&head), root).await.unwrap(); + assert!(snapshot.diff.as_str().contains("+staged")); + assert!(!snapshot.diff.as_str().contains("+working")); + let revision = snapshot.repository_revision.as_deref().unwrap(); + assert_eq!( + run_git(root, &["show", &format!("{revision}:tracked.txt")]), + "staged" + ); + } + + #[tokio::test] + async fn bare_selection_uses_current_remote_symbolic_default_before_main_fallback() { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path(); + repository_with_main_and_feature(root); + let base = run_git(root, &["rev-parse", "main"]); + run_git(root, &["update-ref", "refs/remotes/origin/trunk", &base]); + run_git( + root, + &[ + "symbolic-ref", + "refs/remotes/origin/HEAD", + "refs/remotes/origin/trunk", + ], + ); + run_git(root, &["config", "branch.feature.remote", "origin"]); + std::fs::write(root.join("branch.txt"), "committed\n").unwrap(); + let head = commit_from_head(root); + + let selection = auto_select(root, Some(&head)).await.unwrap(); + assert!( + matches!(selection.source, LocalSource::AutoBranch { ref base, include_worktree: false } if base == "origin/trunk") + ); + let snapshot = acquire(&selection.source, Some(&head), root).await.unwrap(); + assert!(snapshot.diff.as_str().contains("+committed")); + assert_eq!(snapshot.repository_revision.as_deref(), Some(head.as_str())); + } + + #[tokio::test] + async fn bare_selection_uses_remote_default_without_a_branch_upstream() { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path(); + repository_with_main_and_feature(root); + let base = run_git(root, &["rev-parse", "main"]); + run_git(root, &["update-ref", "refs/remotes/origin/trunk", &base]); + run_git( + root, + &[ + "symbolic-ref", + "refs/remotes/origin/HEAD", + "refs/remotes/origin/trunk", + ], + ); + run_git( + root, + &[ + "remote", + "add", + "origin", + "https://example.invalid/repository.git", + ], + ); + assert!( + std::process::Command::new("git") + .args([ + "-C", + root.to_str().unwrap(), + "config", + "--get", + "branch.feature.remote" + ]) + .output() + .unwrap() + .stdout + .is_empty() + ); + std::fs::write(root.join("branch.txt"), "committed\n").unwrap(); + let head = commit_from_head(root); + + let selection = auto_select(root, Some(&head)).await.unwrap(); + assert!( + matches!(selection.source, LocalSource::AutoBranch { ref base, include_worktree: false } if base == "origin/trunk") + ); + let snapshot = acquire(&selection.source, Some(&head), root).await.unwrap(); + assert!(snapshot.diff.as_str().contains("+committed")); + assert_eq!(snapshot.repository_revision.as_deref(), Some(head.as_str())); + } + + #[tokio::test] + async fn clean_automatic_branch_review_rejects_a_changed_head() { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path(); + repository_with_main_and_feature(root); + std::fs::write(root.join("captured.txt"), "captured\n").unwrap(); + let captured_head = commit_from_head(root); + + let selection = auto_select(root, Some(&captured_head)).await.unwrap(); + assert!(matches!( + selection.source, + LocalSource::AutoBranch { + include_worktree: false, + .. + } + )); + + std::fs::write(root.join("later.txt"), "later\n").unwrap(); + let later_head = commit_from_head(root); + assert_ne!(captured_head, later_head); + + let error = match acquire(&selection.source, Some(&captured_head), root).await { + Ok(_) => panic!("a clean automatic branch review must reject a moving HEAD"), + Err(error) => error, + }; + assert!(error.to_string().contains("HEAD changed")); + } + + #[tokio::test] + async fn dirty_automatic_branch_review_rejects_a_changed_head() { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path(); + repository_with_main_and_feature(root); + std::fs::write(root.join("captured.txt"), "captured\n").unwrap(); + let captured_head = commit_from_head(root); + std::fs::write(root.join("tracked.txt"), "dirty\n").unwrap(); + + let selection = auto_select(root, Some(&captured_head)).await.unwrap(); + assert!(matches!( + selection.source, + LocalSource::AutoBranch { + include_worktree: true, + .. + } + )); + + commit_from_head(root); + let error = match acquire(&selection.source, Some(&captured_head), root).await { + Ok(_) => panic!("a dirty automatic review must not follow a moving HEAD"), + Err(error) => error, + }; + assert!(error.to_string().contains("HEAD changed")); + } + + #[tokio::test] + async fn automatic_staged_review_rejects_a_changed_head() { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path(); + repository_with_main_and_feature(root); + std::fs::write(root.join("tracked.txt"), "staged\n").unwrap(); + run_git(root, &["add", "tracked.txt"]); + let captured_head = run_git(root, &["rev-parse", "HEAD"]); + let selection = auto_select(root, Some(&captured_head)).await.unwrap(); + assert!(matches!(selection.source, LocalSource::AutoStaged)); + + commit_from_head(root); + let error = match acquire(&selection.source, Some(&captured_head), root).await { + Ok(_) => panic!("an automatic staged review must reject a moving HEAD"), + Err(error) => error, + }; + assert!(error.to_string().contains("HEAD changed")); + } + + #[tokio::test] + async fn automatic_worktree_review_rejects_a_changed_head() { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path(); + repository_with_main_and_feature(root); + std::fs::write(root.join("tracked.txt"), "working\n").unwrap(); + let captured_head = run_git(root, &["rev-parse", "HEAD"]); + let selection = auto_select(root, Some(&captured_head)).await.unwrap(); + assert!(matches!(selection.source, LocalSource::WorkingTree)); + + commit_from_head(root); + let error = match acquire(&selection.source, Some(&captured_head), root).await { + Ok(_) => panic!("an automatic worktree review must reject a moving HEAD"), + Err(error) => error, + }; + assert!(error.to_string().contains("HEAD changed")); + } + + #[tokio::test] + async fn automatic_clean_review_rejects_a_changed_head() { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path(); + repository_with_main_and_feature(root); + let captured_head = run_git(root, &["rev-parse", "HEAD"]); + let selection = auto_select(root, Some(&captured_head)).await.unwrap(); + assert!(matches!(selection.source, LocalSource::Clean)); + + std::fs::write(root.join("later.txt"), "later\n").unwrap(); + commit_from_head(root); + let error = match acquire(&selection.source, Some(&captured_head), root).await { + Ok(_) => panic!("an automatic clean review must reject a moving HEAD"), + Err(error) => error, + }; + assert!(error.to_string().contains("HEAD changed")); + } + + #[tokio::test] + async fn automatic_acquisition_rejects_a_head_change_after_reading_the_source() { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path(); + repository_with_main_and_feature(root); + let captured_head = run_git(root, &["rev-parse", "HEAD"]); + + let error = acquire_automatic(root, Some(&captured_head), async { + let acquired_head = run_git(root, &["rev-parse", "HEAD"]); + std::fs::write(root.join("later.txt"), "later\n").unwrap(); + commit_from_head(root); + Ok(acquired_head) + }) + .await + .unwrap_err(); + assert!( + error + .to_string() + .contains("HEAD changed while acquiring the automatic review") + ); + } + #[tokio::test] - async fn staged_review_binds_diff_and_repository_to_one_index_tree() { + async fn bare_selection_combines_committed_branch_and_tracked_worktree_changes() { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path(); + repository_with_main_and_feature(root); + std::fs::write(root.join("branch.txt"), "committed\n").unwrap(); + commit_from_head(root); + std::fs::write(root.join("tracked.txt"), "working\n").unwrap(); + + let head = run_git(root, &["rev-parse", "HEAD"]); + let selection = auto_select(root, Some(&head)).await.unwrap(); + assert!(matches!( + selection.source, + LocalSource::AutoBranch { + include_worktree: true, + .. + } + )); + let snapshot = acquire(&selection.source, Some(&head), root).await.unwrap(); + assert!(snapshot.diff.as_str().contains("+committed")); + assert!(snapshot.diff.as_str().contains("+working")); + assert!(snapshot.repository_revision.is_none()); + } + + #[tokio::test] + async fn bare_selection_falls_back_to_tracked_worktree_then_clean_diff() { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path(); + repository_with_main_and_feature(root); + std::fs::write(root.join("tracked.txt"), "working\n").unwrap(); + let head = run_git(root, &["rev-parse", "HEAD"]); + let working = auto_select(root, Some(&head)).await.unwrap(); + assert!(matches!(working.source, LocalSource::WorkingTree)); + + std::fs::write(root.join("tracked.txt"), "base\n").unwrap(); + let clean = auto_select(root, Some(&head)).await.unwrap(); + assert!(matches!(clean.source, LocalSource::Clean)); + assert!( + acquire(&clean.source, Some(&head), root) + .await + .unwrap() + .diff + .as_str() + .is_empty() + ); + } + + #[tokio::test] + async fn bare_selection_warns_about_untracked_files_without_claiming_them_reviewed() { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path(); + repository_with_main_and_feature(root); + std::fs::write(root.join("new.txt"), "untracked\n").unwrap(); + let head = run_git(root, &["rev-parse", "HEAD"]); + let selection = auto_select(root, Some(&head)).await.unwrap(); + assert!(matches!(selection.source, LocalSource::Clean)); + assert!( + selection + .warning + .unwrap() + .contains("untracked files were not reviewed") + ); + } + + #[tokio::test] + async fn automatic_staged_review_matches_explicit_diff_and_searches_the_same_index_tree() { let directory = tempfile::tempdir().unwrap(); let root = directory.path(); run_git(root, &["init", "--quiet"]); @@ -193,19 +812,24 @@ mod tests { std::fs::write(root.join("config.yaml"), "version: staged-one\n").unwrap(); run_git(root, &["add", "config.yaml"]); - let snapshot = acquire(&LocalSource::Staged, Some(&head), root) + let automatic = acquire(&LocalSource::AutoStaged, Some(&head), root) .await .unwrap(); + let explicit = acquire(&LocalSource::Staged, Some(&head), root) + .await + .unwrap(); + assert_eq!(automatic.diff.as_str(), explicit.diff.as_str()); + assert_eq!(automatic.repository_revision, explicit.repository_revision); std::fs::write(root.join("config.yaml"), "version: staged-two\n").unwrap(); run_git(root, &["add", "config.yaml"]); - let revision = snapshot.repository_revision.as_deref().unwrap(); + let revision = automatic.repository_revision.as_deref().unwrap(); assert_eq!( run_git(root, &["show", &format!("{revision}:config.yaml")]), "version: staged-one" ); - assert!(snapshot.diff.as_str().contains("+version: staged-one")); - assert!(!snapshot.diff.as_str().contains("staged-two")); + assert!(automatic.diff.as_str().contains("+version: staged-one")); + assert!(!automatic.diff.as_str().contains("staged-two")); let finding = crate::envelope::Finding { path: "config.yaml".into(), diff --git a/src/main.rs b/src/main.rs index 9354f36..2b4a5af 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,9 +5,12 @@ use postil_cli::attribution; use postil_cli::cli::{ Cli, Command, ForgeArg, HookAction, publication_enabled, publication_plan_contract_capability, }; -use postil_cli::config::{Config, qualification_metadata, starter_config}; +use postil_cli::config::{ + Config, REASONING_EFFORT_VALUES, default_reasoning_effort, default_scorer_reasoning_effort, + qualification_metadata, starter_config, +}; use postil_cli::review::{ForgeKind, ReviewArgs}; -use postil_cli::{doctor, hook, login, plan, respond, review}; +use postil_cli::{doctor, hook, login, plan, review}; #[tokio::main] async fn main() { @@ -61,6 +64,9 @@ async fn dispatch(cli: Cli) -> anyhow::Result { fail_on, config, model, + reasoning_effort, + scorer_reasoning_effort, + verbose, bounded, publish, no_post, @@ -100,6 +106,9 @@ async fn dispatch(cli: Cli) -> anyhow::Result { fail_on, config, model, + reasoning_effort, + scorer_reasoning_effort, + verbose, bounded, no_post: !publication_enabled(publish, no_post)?, defer_gate_check, @@ -109,35 +118,38 @@ async fn dispatch(cli: Cli) -> anyhow::Result { }) .await } - Command::Respond { - forge, - repo, - pr, - issue, - comment, - config, - model, - publish, - no_post, - } => { - let kind = match forge { - ForgeArg::Github => ForgeKind::GitHub, - ForgeArg::Gitlab => ForgeKind::GitLab, - ForgeArg::Bitbucket => ForgeKind::Bitbucket, - ForgeArg::Azure => ForgeKind::Azure, - ForgeArg::Local => ForgeKind::Local, + Command::Models => { + let cascade = postil_cli::config::default_cascade(); + let cascade_text = if cascade.is_empty() { + "none".to_string() + } else { + cascade.join(" -> ") }; - respond::run(respond::RespondArgs { - forge: kind, - repo, - pr, - issue, - comment, - config, - model, - no_post: !publication_enabled(publish, no_post)?, - }) - .await + println!("Postil model presets"); + println!( + " Luna (tested default): {}", + postil_cli::config::default_model() + ); + println!(" Fallback cascade: {cascade_text}"); + println!( + " Reviewer reasoning effort: {}", + default_reasoning_effort().as_str() + ); + println!( + " Scorer reasoning effort: {}", + default_scorer_reasoning_effort().as_str() + ); + println!(" Accepted reasoning efforts: {REASONING_EFFORT_VALUES}"); + println!("\nOverride once: postil review --model provider/model"); + println!( + "Override reasoning once: postil review --reasoning-effort high --scorer-reasoning-effort none" + ); + println!("Override persistently: REVIEW_MODEL=provider/model postil review"); + println!( + "Persist reasoning: REVIEW_REASONING_EFFORT=high REVIEW_SCORER_REASONING_EFFORT=none postil review" + ); + println!("Config keys: model.reasoningEffort and model.scorerReasoningEffort"); + Ok(0) } Command::Plan { envelopes, config } => { let cwd = std::env::current_dir()?; @@ -196,8 +208,22 @@ async fn dispatch(cli: Cli) -> anyhow::Result { } ); println!("model.name: {}", cfg.model); + println!("model.source: {}", cfg.model_source); + println!("model.reasoningEffort: {}", cfg.reasoning_effort.as_str()); + println!( + "model.reasoningEffort.source: {}", + cfg.reasoning_effort_source + ); println!("model.cascade: {:?}", cfg.cascade); println!("model.scorer: {}", cfg.scorer); + println!( + "model.scorerReasoningEffort: {}", + cfg.scorer_reasoning_effort.as_str() + ); + println!( + "model.scorerReasoningEffort.source: {}", + cfg.scorer_reasoning_effort_source + ); println!("model.apiBase: {}", cfg.api_base); println!("model.apiFormat: {}", cfg.api_format.as_str()); println!("model.consensus: {}", cfg.consensus); diff --git a/src/output.rs b/src/output.rs index 075cf52..a42f762 100644 --- a/src/output.rs +++ b/src/output.rs @@ -153,7 +153,7 @@ fn csv_field(field: String) -> String { } pub fn print_pretty(envelope: &Envelope) { - let color = std::io::stderr().is_terminal(); + let color = std::io::stderr().is_terminal() && std::env::var_os("NO_COLOR").is_none(); let mut out = String::new(); if envelope.silent { diff --git a/src/progress.rs b/src/progress.rs new file mode 100644 index 0000000..ed967fa --- /dev/null +++ b/src/progress.rs @@ -0,0 +1,143 @@ +//! Human-terminal review progress. Machine and hosted paths retain telemetry. + +use std::fmt::Arguments; +use std::io::{IsTerminal, Write}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + +static SUPPRESS_TELEMETRY: AtomicBool = AtomicBool::new(false); +static REDRAW: AtomicBool = AtomicBool::new(false); +static DEGRADED: AtomicBool = AtomicBool::new(false); +static TICK: AtomicUsize = AtomicUsize::new(0); + +pub struct ProgressGuard { + active: bool, +} + +pub struct OutputSuspension { + redraw: bool, +} + +pub fn start_review(machine_output: bool, verbose: bool, hosted: bool) -> ProgressGuard { + let ci = std::env::var_os("CI").is_some(); + let debug = + std::env::var_os("RUST_LOG").is_some() || std::env::var_os("POSTIL_DEBUG").is_some(); + let tty = std::io::stdout().is_terminal() && std::io::stderr().is_terminal(); + let dumb = std::env::var("TERM").ok().as_deref() == Some("dumb"); + let no_color = std::env::var_os("NO_COLOR").is_some(); + let suppress = tty && !machine_output && !verbose && !hosted && !ci && !debug; + SUPPRESS_TELEMETRY.store(suppress, Ordering::Relaxed); + REDRAW.store(suppress && !dumb && !no_color, Ordering::Relaxed); + DEGRADED.store(false, Ordering::Relaxed); + if suppress { + if REDRAW.load(Ordering::Relaxed) { + draw(); + } else { + eprintln!("postil: reviewing changes..."); + } + } + ProgressGuard { active: suppress } +} + +pub fn telemetry(message: Arguments<'_>) { + if !SUPPRESS_TELEMETRY.load(Ordering::Relaxed) { + eprintln!("{message}"); + } else if REDRAW.load(Ordering::Relaxed) { + draw(); + } +} + +/// Keep safety-relevant notices visible even while detailed telemetry is +/// collapsed into an interactive progress line. +pub fn notice(message: Arguments<'_>) { + DEGRADED.store(true, Ordering::Relaxed); + if SUPPRESS_TELEMETRY.load(Ordering::Relaxed) { + clear(); + eprintln!("{message}"); + if REDRAW.load(Ordering::Relaxed) { + draw(); + } + } else { + eprintln!("{message}"); + } +} + +/// Remove the transient progress line while durable review output is written. +/// The line resumes afterward if the review still has work to complete. +pub fn suspend_for_output() -> OutputSuspension { + let redraw = SUPPRESS_TELEMETRY.load(Ordering::Relaxed) && REDRAW.load(Ordering::Relaxed); + if redraw { + clear(); + REDRAW.store(false, Ordering::Relaxed); + } + OutputSuspension { redraw } +} + +impl Drop for OutputSuspension { + fn drop(&mut self) { + if self.redraw && SUPPRESS_TELEMETRY.load(Ordering::Relaxed) { + REDRAW.store(true, Ordering::Relaxed); + draw(); + } + } +} + +impl ProgressGuard { + pub fn finish(mut self, exit_code: i32) { + if self.active { + clear(); + eprintln!( + "postil: {}", + completion_message(exit_code, DEGRADED.load(Ordering::Relaxed)) + ); + self.active = false; + } + } +} + +impl Drop for ProgressGuard { + fn drop(&mut self) { + if self.active { + clear(); + } + SUPPRESS_TELEMETRY.store(false, Ordering::Relaxed); + REDRAW.store(false, Ordering::Relaxed); + DEGRADED.store(false, Ordering::Relaxed); + } +} + +fn completion_message(exit_code: i32, degraded: bool) -> &'static str { + match (exit_code, degraded) { + (0, false) => "review complete", + (0, true) => "review complete with warnings", + (_, false) => "review found blocking findings", + (_, true) => "review found blocking findings; warnings were also reported", + } +} + +fn draw() { + let frames = ["â ‹", "â ™", "â ¹", "â ¸"]; + let frame = frames[TICK.fetch_add(1, Ordering::Relaxed) % frames.len()]; + let _ = write!(std::io::stderr(), "\r\x1b[2K{frame} Reviewing changes..."); + let _ = std::io::stderr().flush(); +} + +fn clear() { + if REDRAW.load(Ordering::Relaxed) { + let _ = write!(std::io::stderr(), "\r\x1b[2K"); + let _ = std::io::stderr().flush(); + } +} + +#[cfg(test)] +mod tests { + use super::completion_message; + + #[test] + fn degraded_reviews_never_use_the_plain_completion_message() { + assert_eq!(completion_message(0, true), "review complete with warnings"); + assert_eq!( + completion_message(1, true), + "review found blocking findings; warnings were also reported" + ); + } +} diff --git a/src/prompt.rs b/src/prompt.rs index 2a980e2..874b980 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -323,44 +323,6 @@ pub(crate) fn sanitize_scorer_input(value: &str) -> String { .collect() } -/// System prompt for the interactive bot answering a maintainer's mention. -/// The small JSON envelope keeps generated prose behind a deterministic -/// publication check before it can reach a forge. -pub fn respond_system_prompt(cfg: &Config, current_utc_date: Date) -> String { - let mut p = String::from( - "You are Postil, replying to a maintainer who mentioned you on a pull request or \ - issue. Answer the actual question directly. Ground every claim in the diff or thread \ - you are given, and cite file:line when you reference code. If something cannot be \ - determined from the supplied context, say so plainly rather than guessing. No filler, \ - praise, preamble, or restatement of the question. You do not open pull requests or push \ - commits; if asked to, explain that you review and answer only.\n\ - \n\ - Keep an ordinary reply at or below 1,200 characters. A re-review reply is a compact \ - review, not an article: report only actionable merge risks, give each risk in one \ - concise item with its file:line evidence and next action, and say briefly when no such \ - risk is present. Do not add an overview, implementation tour, correctness section, \ - generic risk inventory, or verdict. Do not use Markdown headings. Use no more than three \ - list items.\n\ - Do not emit active @mentions, raw HTML or HTML comments, details blocks, Markdown \ - tables, or images.\n\ - \n\ - Return ONLY one JSON object with exactly this shape and no markdown fence or surrounding \ - prose:\n\ - {\"answer\":\"concise GitHub-flavored Markdown\",\"diagram\":null}\n\ - The answer must be non-empty and diagram must always be null. Generated diagrams and \ - Mermaid are not accepted. The publication validator rejects output over 2,400 characters \ - or 24 nonblank lines, extra fields, Markdown headings, more than three list items, and \ - unsafe Markdown.", - ); - p.push_str(&trusted_current_date_context(current_utc_date)); - if let Some(rules) = &cfg.guardrails { - p.push_str("\n\nRepository guardrails you may reference:\n"); - let rules = bounded_untrusted_prompt_text(rules, MAX_GUARDRAIL_PROMPT_BYTES / 2); - p.push_str(&rules); - } - p -} - const MAX_PR_BODY_PROMPT_CHARS: usize = 2_000; fn bounded_pr_body(body: Option<&str>) -> Option { @@ -598,24 +560,6 @@ mod tests { assert_eq!(sanitize_scorer_input("a\0b\u{001f}c\n\t"), "a b c\n\t"); } - #[test] - fn respond_prompt_requires_a_compact_structured_reply() { - let p = respond_system_prompt(&Config::default(), trusted_date()); - assert_eq!(p.matches("UTC date 2026-08-10; later=future.").count(), 1); - assert!(!p.contains("UTC date 2026-08-11; later=future.")); - assert!(p.contains("at or below 1,200 characters")); - assert!(p.contains("not an article")); - assert!(p.contains("{\"answer\":\"concise GitHub-flavored Markdown\",\"diagram\":null}")); - assert!(p.contains("diagram must always be null")); - assert!(p.contains("Mermaid are not accepted")); - assert!(!p.contains("When justified")); - assert!(!p.contains("materially clarifies")); - assert!(p.contains("Do not add an overview")); - assert!(p.contains("Do not use Markdown headings")); - assert!(p.contains("no more than three list items")); - assert!(p.contains("Do not emit active @mentions")); - } - #[test] fn system_prompt_omits_content_policy_when_inactive() { let mut cfg = Config::default(); diff --git a/src/respond.rs b/src/respond.rs deleted file mode 100644 index 3f65d96..0000000 --- a/src/respond.rs +++ /dev/null @@ -1,1080 +0,0 @@ -//! Interactive bot: reply to an @postil mention on a PR or issue. -//! -//! Scope is review and answer only. Postil never opens PRs or pushes commits. -//! Works across every forge the reviewer supports. PR/MR mentions are grounded -//! on the diff; issue mentions on the issue body. GitHub and GitLab cover both -//! issues and pulls; Bitbucket and Azure DevOps are scoped to PRs (their issue -//! trackers / work items use endpoints we cannot verify against a live host). - -use std::fs::{File, OpenOptions}; -use std::io::Write; -use std::os::unix::fs::OpenOptionsExt; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicU64, Ordering}; - -use anyhow::{Context, Result, anyhow, ensure}; -use serde::{Deserialize, Serialize}; -use time::OffsetDateTime; - -use crate::config::Config; -use crate::diff; -use crate::envelope::{ModelUsageCostSource, ModelUsagePhase, ModelUsageRole}; -use crate::forge::{ - Forge, ThreadKind, azure::Azure, bitbucket::Bitbucket, github::GitHub, gitlab::GitLab, -}; -use crate::llm::{Answer, LlmClient}; -use crate::prompt; -use crate::review::ForgeKind; - -pub struct RespondArgs { - pub forge: ForgeKind, - pub repo: Option, - /// The PR number, when the mention is on a pull request. - pub pr: Option, - /// The issue number, when the mention is on an issue. - pub issue: Option, - /// The maintainer's comment text (the mention). When None, read from the - /// POSTIL_COMMENT environment variable, the safe path for automation. - pub comment: Option, - pub config: Option, - pub model: Option, - /// Print the answer instead of posting it. - pub no_post: bool, -} - -// Leave enough transport headroom for JSON escaping, the system prompt, PR -// metadata, and the maintainer message under either supported API shape. -const MAX_RESPOND_DIFF_CONTEXT_BYTES: usize = crate::llm::MAX_PROVIDER_REQUEST_BYTES / 12; -const MAX_RESPOND_MANIFEST_BYTES: usize = 24_000; -const MAX_RESPOND_REPO_BYTES: usize = 512; -const MAX_RESPOND_TITLE_BYTES: usize = 2 * 1024; -const MAX_RESPOND_DESCRIPTION_BYTES: usize = 4 * 1024; -const MAX_RESPOND_COMMENT_BYTES: usize = 8 * 1024; -const MAX_RESPOND_ISSUE_BODY_BYTES: usize = 8 * 1024; -const USAGE_RECEIPT_PATH_ENV: &str = "POSTIL_USAGE_RECEIPT_PATH"; -const RESPOND_MAX_CHARS: usize = 2_400; -const RESPOND_MAX_NONBLANK_LINES: usize = 24; -const RESPOND_MAX_LIST_ITEMS: usize = 3; - -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct RespondOutput { - answer: String, - diagram: serde_json::Value, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct RespondUsageReceipt<'a> { - version: u32, - operation: &'static str, - prompt_tokens: u64, - completion_tokens: u64, - models: Vec>, - usage_accounting_complete: bool, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct RespondModelUsage<'a> { - model: &'a str, - #[serde(skip_serializing_if = "Option::is_none")] - role: Option, - #[serde(skip_serializing_if = "Option::is_none")] - phase: Option, - #[serde(skip_serializing_if = "Option::is_none")] - call_ordinal: Option, - #[serde(skip_serializing_if = "Option::is_none")] - attempt: Option, - prompt_tokens: u64, - completion_tokens: u64, - #[serde(skip_serializing_if = "Option::is_none")] - cost_micros: Option, - #[serde(skip_serializing_if = "Option::is_none")] - cost_provider_decimal: Option<&'a str>, - #[serde(skip_serializing_if = "Option::is_none")] - cost_source: Option, - accounting_complete: bool, -} - -// PID separates concurrent processes; this sequence separates writers within -// one process. create_new below also fails closed if a path already exists. -static RECEIPT_TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0); - -struct UsageReceiptWriter { - file: Option, - temp_path: PathBuf, - final_path: PathBuf, -} - -impl UsageReceiptWriter { - fn from_env() -> Result> { - let Some(path) = std::env::var_os(USAGE_RECEIPT_PATH_ENV) else { - return Ok(None); - }; - anyhow::ensure!( - !path.is_empty(), - "{USAGE_RECEIPT_PATH_ENV} must not be empty" - ); - let final_path = PathBuf::from(path); - let parent = final_path.parent().unwrap_or_else(|| Path::new(".")); - let file_name = final_path - .file_name() - .ok_or_else(|| anyhow!("{USAGE_RECEIPT_PATH_ENV} must name a file"))? - .to_string_lossy(); - let sequence = RECEIPT_TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed); - let temp_path = parent.join(format!( - ".{file_name}.{}.{}.tmp", - std::process::id(), - sequence - )); - let file = OpenOptions::new() - .write(true) - .create_new(true) - // Usage receipts contain private provider-accounting metadata. - .mode(0o600) - .open(&temp_path) - .context("creating private usage receipt temporary file")?; - Ok(Some(Self { - file: Some(file), - temp_path, - final_path, - })) - } - - fn commit(mut self, answer: &Answer) -> Result<()> { - let receipt = RespondUsageReceipt { - version: 2, - operation: "respond", - prompt_tokens: answer.usage.prompt_tokens, - completion_tokens: answer.usage.completion_tokens, - models: answer - .models - .iter() - .map(|model| RespondModelUsage { - model: &model.model, - role: model.role, - phase: model.phase, - call_ordinal: model.call_ordinal, - attempt: model.attempt, - prompt_tokens: model.prompt_tokens, - completion_tokens: model.completion_tokens, - cost_micros: model.cost_micros, - cost_provider_decimal: model.cost_provider_decimal.as_deref(), - cost_source: model.cost_source, - accounting_complete: model.accounting_complete, - }) - .collect(), - usage_accounting_complete: answer.usage_accounting_complete, - }; - let file = self.file.as_mut().expect("usage receipt file is present"); - serde_json::to_writer(&mut *file, &receipt).context("serializing usage receipt")?; - file.write_all(b"\n").context("writing usage receipt")?; - // Publish only after file contents are durable. The directory sync - // below makes the rename durable before stdout or forge delivery. - file.sync_all().context("syncing usage receipt")?; - drop(self.file.take()); - std::fs::rename(&self.temp_path, &self.final_path) - .context("atomically publishing usage receipt")?; - if let Some(parent) = self.final_path.parent() { - File::open(parent) - .and_then(|directory| directory.sync_all()) - .context("syncing usage receipt directory")?; - } - Ok(()) - } -} - -impl Drop for UsageReceiptWriter { - fn drop(&mut self) { - // Drop cannot return cleanup errors. Best-effort removal is safe: an - // unpublished temp file is never treated as a committed receipt, and - // create_new prevents a later writer from clobbering it. - let _ = std::fs::remove_file(&self.temp_path); - } -} - -pub async fn run(args: RespondArgs) -> Result { - let cwd = std::env::current_dir()?; - let mut cfg = Config::load(&cwd, args.config.as_deref())?; - if let Some(m) = &args.model { - cfg.model = m.clone(); - } - cfg.require_model()?; - let repo = args - .repo - .clone() - .or_else(|| std::env::var("GITHUB_REPOSITORY").ok()) - .ok_or_else(|| anyhow!("--repo is required"))?; - let comment = args - .comment - .clone() - .or_else(|| std::env::var("POSTIL_COMMENT").ok()) - .filter(|c| !c.trim().is_empty()) - .ok_or_else(|| anyhow!("the mention text is required: --comment or POSTIL_COMMENT"))?; - let usage_receipt = UsageReceiptWriter::from_env()?; - - // The number the mention is on, and whether it is a PR/MR or an issue. - let (number, kind) = match (args.pr, args.issue) { - (Some(pr), _) => (pr, ThreadKind::Pull), - (None, Some(issue)) => (issue, ThreadKind::Issue), - (None, None) => return Err(anyhow!("one of --pr or --issue is required")), - }; - - // Same flow for every forge; the trait carries the per-host endpoints. The - // forge is monomorphized (the trait uses `async fn` and is not dyn-safe), so - // dispatch by kind here, exactly as the reviewer does. - match args.forge { - ForgeKind::GitHub => { - respond_with( - GitHub::new(&repo, number)?, - &cfg, - &repo, - number, - kind, - &comment, - args.no_post, - usage_receipt, - ) - .await - } - ForgeKind::GitLab => { - respond_with( - GitLab::new(&repo, number)?, - &cfg, - &repo, - number, - kind, - &comment, - args.no_post, - usage_receipt, - ) - .await - } - ForgeKind::Bitbucket => { - respond_with( - Bitbucket::new(&repo, number)?, - &cfg, - &repo, - number, - kind, - &comment, - args.no_post, - usage_receipt, - ) - .await - } - ForgeKind::Azure => { - respond_with( - Azure::new(&repo, number)?, - &cfg, - &repo, - number, - kind, - &comment, - args.no_post, - usage_receipt, - ) - .await - } - ForgeKind::Local => Err(anyhow!("postil respond needs a remote forge, not --local")), - } -} - -#[allow(clippy::too_many_arguments)] -async fn respond_with( - forge: F, - cfg: &Config, - repo: &str, - number: u64, - kind: ThreadKind, - comment: &str, - no_post: bool, - usage_receipt: Option, -) -> Result { - let current_utc_date = OffsetDateTime::now_utc().date(); - let context = build_context(&forge, repo, number, kind).await?; - - let system = prompt::respond_system_prompt(cfg, current_utc_date); - let user = respond_user_prompt(&context, comment); - let client = LlmClient::from_env(cfg)?; - client.preflight_respond_plan(cfg, &system, &user)?; - let answer = client - .answer(cfg, &system, &user, validate_respond_output) - .await?; - let reply = answer.content.clone(); - - // Hosted execution requires the durable usage receipt before any external - // delivery. Commit it before stdout or forge posting so the control plane - // can reconcile spend and own idempotent delivery. - if let Some(writer) = usage_receipt { - writer.commit(&answer)?; - } - if no_post { - println!("{reply}"); - } else { - forge - .post_comment(number, kind, &reply) - .await - .context("posting reply")?; - eprintln!("postil: replied on {repo}#{number}"); - } - Ok(0) -} - -fn respond_user_prompt(context: &str, comment: &str) -> String { - let comment = prompt::bounded_untrusted_prompt_text(comment.trim(), MAX_RESPOND_COMMENT_BYTES); - format!( - "{context}\n--- Maintainer's message to you ---\n{comment}\n\nReply to the message above." - ) -} - -fn validate_respond_output(raw: &str) -> Result { - if raw.chars().count() > RESPOND_MAX_CHARS { - return Err(anyhow!( - "reply exceeds the 2,400-character publication limit" - )); - } - if nonblank_line_count(raw) > RESPOND_MAX_NONBLANK_LINES { - return Err(anyhow!( - "reply exceeds the {RESPOND_MAX_NONBLANK_LINES}-line publication limit" - )); - } - - let output: RespondOutput = serde_json::from_str(raw.trim()) - .context("reply must be the exact {answer, diagram} JSON object")?; - if !output.diagram.is_null() { - return Err(anyhow!("reply diagram must be null")); - } - let normalized_answer = normalize_newlines(&output.answer); - let answer = trim_outer_blank_lines(&normalized_answer); - if answer.is_empty() { - return Err(anyhow!("reply answer is empty")); - } - let publication_text = mask_markdown_code(&answer); - validate_answer_publication(&publication_text)?; - if contains_mermaid_fence(&answer) - || answer - .lines() - .any(|line| is_mermaid_declaration(line.trim())) - { - return Err(anyhow!("Mermaid must use the diagram field")); - } - if markdown_heading_count(&publication_text) > 0 { - return Err(anyhow!( - "reply contains a Markdown heading; conversational replies do not allow headings" - )); - } - let list_items = markdown_list_item_count(&publication_text); - if list_items > RESPOND_MAX_LIST_ITEMS { - return Err(anyhow!( - "reply contains {list_items} list items; at most {RESPOND_MAX_LIST_ITEMS} are allowed" - )); - } - - let rendered = answer; - - if rendered.chars().count() > RESPOND_MAX_CHARS { - return Err(anyhow!( - "rendered reply exceeds the 2,400-character publication limit" - )); - } - if nonblank_line_count(&rendered) > RESPOND_MAX_NONBLANK_LINES { - return Err(anyhow!( - "rendered reply exceeds the {RESPOND_MAX_NONBLANK_LINES}-line publication limit" - )); - } - Ok(rendered) -} - -fn normalize_newlines(text: &str) -> String { - text.replace("\r\n", "\n").replace('\r', "\n") -} - -fn trim_outer_blank_lines(text: &str) -> String { - let lines: Vec<&str> = text.split('\n').collect(); - let Some(first) = lines.iter().position(|line| !line.trim().is_empty()) else { - return String::new(); - }; - let last = lines - .iter() - .rposition(|line| !line.trim().is_empty()) - .expect("a first nonblank line has a matching last line"); - lines[first..=last].join("\n") -} - -fn validate_answer_publication(publication_text: &str) -> Result<()> { - if contains_active_mention(publication_text) { - return Err(anyhow!("reply contains an active mention")); - } - if contains_raw_html(publication_text) { - return Err(anyhow!("reply contains raw HTML")); - } - if contains_markdown_image(publication_text) { - return Err(anyhow!("reply contains a Markdown image")); - } - if contains_markdown_table(publication_text) { - return Err(anyhow!("reply contains a Markdown table")); - } - Ok(()) -} - -fn mask_markdown_code(text: &str) -> String { - let mut masked = String::with_capacity(text.len()); - let mut fence: Option<(char, usize)> = None; - let mut in_indented_block = false; - let mut previous_line_was_blank = true; - for line_with_newline in text.split_inclusive('\n') { - let (line, newline) = line_with_newline - .strip_suffix('\n') - .map_or((line_with_newline, ""), |line| (line, "\n")); - if let Some((marker, width)) = fence { - let closes = markdown_fence_marker(line).is_some_and(|(candidate, candidate_width)| { - candidate == marker - && candidate_width >= width - && fence_remainder(line, candidate, candidate_width) - .trim() - .is_empty() - }); - masked.extend(std::iter::repeat_n(' ', line.chars().count())); - masked.push_str(newline); - if closes { - fence = None; - previous_line_was_blank = true; - } - continue; - } - if let Some(opening) = markdown_fence_opening(line) { - fence = Some(opening); - in_indented_block = false; - masked.extend(std::iter::repeat_n(' ', line.chars().count())); - masked.push_str(newline); - continue; - } - if is_indented_code_line(line) && (in_indented_block || previous_line_was_blank) { - in_indented_block = true; - previous_line_was_blank = false; - masked.extend(std::iter::repeat_n(' ', line.chars().count())); - masked.push_str(newline); - continue; - } - if line.trim().is_empty() { - previous_line_was_blank = true; - masked.push_str(line); - masked.push_str(newline); - continue; - } - in_indented_block = false; - previous_line_was_blank = false; - masked.push_str(&mask_inline_code(line)); - masked.push_str(newline); - } - masked -} - -fn markdown_fence_marker(line: &str) -> Option<(char, usize)> { - let indentation = line.chars().take_while(|ch| *ch == ' ').count(); - if indentation > 3 { - return None; - } - let content = &line[indentation..]; - let marker = content.chars().next()?; - if !matches!(marker, '`' | '~') { - return None; - } - let width = content.chars().take_while(|ch| *ch == marker).count(); - (width >= 3).then_some((marker, width)) -} - -fn markdown_fence_opening(line: &str) -> Option<(char, usize)> { - let (marker, width) = markdown_fence_marker(line)?; - if marker == '`' && fence_remainder(line, marker, width).contains('`') { - return None; - } - Some((marker, width)) -} - -fn is_indented_code_line(line: &str) -> bool { - line.starts_with(" ") || line.starts_with('\t') -} - -fn fence_remainder(line: &str, marker: char, width: usize) -> &str { - let indentation = line.chars().take_while(|ch| *ch == ' ').count(); - let offset = indentation + marker.len_utf8() * width; - &line[offset..] -} - -fn contains_mermaid_fence(text: &str) -> bool { - text.lines().any(|line| { - markdown_fence_opening(line).is_some_and(|(marker, width)| { - fence_remainder(line, marker, width) - .split_whitespace() - .next() - .is_some_and(|language| language.eq_ignore_ascii_case("mermaid")) - }) - }) -} - -fn mask_inline_code(line: &str) -> String { - let chars: Vec = line.chars().collect(); - let mut masked = chars.clone(); - let mut cursor = 0; - while cursor < chars.len() { - if chars[cursor] != '`' { - cursor += 1; - continue; - } - let width = chars[cursor..].iter().take_while(|ch| **ch == '`').count(); - let mut closing = cursor + width; - let mut found = None; - while closing < chars.len() { - if chars[closing] != '`' { - closing += 1; - continue; - } - let closing_width = chars[closing..].iter().take_while(|ch| **ch == '`').count(); - if closing_width == width { - found = Some(closing + width); - break; - } - closing += closing_width; - } - let Some(end) = found else { - cursor += width; - continue; - }; - masked[cursor..end].fill(' '); - cursor = end; - } - masked.into_iter().collect() -} - -fn contains_active_mention(text: &str) -> bool { - let chars: Vec = text.chars().collect(); - for (index, ch) in chars.iter().enumerate() { - if *ch != '@' - || index - .checked_sub(1) - .and_then(|previous| chars.get(previous)) - .is_some_and(|previous| { - previous.is_ascii_alphanumeric() || matches!(previous, '_' | '-') - }) - { - continue; - } - let mut cursor = index + 1; - let mut name_len = 0; - while chars - .get(cursor) - .is_some_and(|next| next.is_ascii_alphanumeric() || *next == '-') - && name_len < 39 - { - cursor += 1; - name_len += 1; - } - if name_len > 0 - && chars.get(cursor).is_none_or(|boundary| { - !boundary.is_ascii_alphanumeric() && !matches!(boundary, '_' | '-') - }) - { - return true; - } - } - false -} - -fn contains_raw_html(text: &str) -> bool { - if text.contains("Visible text.", - "
MoreHidden text.
", - "A | B\n--- | ---\n1 | 2", - "![diagram](https://example.test/diagram.png)", - "## What this PR does\nA tour.", - "# Correctness\nNarration.", - "### Issues and risks\nInventory.", - "## Verdict\nLooks good.", - ] { - assert!( - validate_respond_output(&structured(answer, None)).is_err(), - "unsafe reply was accepted: {answer}" - ); - } - let email = structured("Email dev@example.test with the result.", None); - assert_eq!( - validate_respond_output(&email).unwrap(), - "Email dev@example.test with the result." - ); - } - - #[test] - fn rejects_all_generated_diagrams_and_mermaid_answers() { - let diagram = structured( - "The request crosses the queue before a worker handles it.", - Some("flowchart LR\n API --> Queue"), - ); - assert!( - validate_respond_output(&diagram) - .unwrap_err() - .to_string() - .contains("must be null") - ); - for answer in [ - "```mermaid\nflowchart TD\n A --> B\n```", - "``` mermaid\nA --> B\n```", - "~~~mermaid\nsequenceDiagram\n A->>B: hello\n~~~", - " ~~~ MERMAID\nA --> B\n ~~~", - "flowchart LR\n A --> B", - "graph TD\n A --> B", - "sequenceDiagram", - "stateDiagram", - "stateDiagram-v2", - "classDiagram\n class A", - "erDiagram", - "journey", - "gantt", - "pie showData", - "mindmap", - "timeline", - "gitGraph {\"showBranches\": true}", - "quadrantChart", - "xychart-beta", - "block-beta columns 3", - "packet-beta", - "architecture-beta", - "kanban", - "sankey-beta", - ] { - assert!(validate_respond_output(&structured(answer, None)).is_err()); - } - } - - #[test] - fn mermaid_detection_does_not_reject_ordinary_prose() { - for answer in [ - "Graph construction is linear in the number of edges.", - "A timeline helps explain the retry sequence.", - "Pie is not relevant to this handler.", - "The journey continues through the queue.", - "Kanban boards are outside this change.", - ] { - assert_eq!( - validate_respond_output(&structured(answer, None)).unwrap(), - answer - ); - } - } - - #[test] - fn rejects_report_shaped_headings() { - for answer in [ - "# Analysis\nLong-form report prose.", - "# Recommendations\nLong-form report prose.", - "# Overview\nLong-form report prose.", - "# Summary\nLong-form report prose.", - "# What this pull request does\nLong-form report prose.", - "# Issue and risk\nLong-form report prose.", - "# Risks\nLong-form report prose.", - "# Assessment\nLong-form report prose.", - "# Review metadata\nLong-form report prose.", - "# Summary)\nLong-form report prose.", - "## Verdict: ###\nLong-form report prose.", - "Correctness!\n------------\nLong-form report prose.", - ] { - let reply = structured(answer, None); - assert!( - validate_respond_output(&reply).is_err(), - "report heading was accepted: {answer}", - ); - } - } - - #[test] - fn contract_vectors_mask_code_and_reject_image_forms() { - for answer in [ - "Use `@maintainer` as the literal test fixture.", - "Unicode before code: é `@maintainer`.", - "`
not HTML here
` is sample text.", - "```markdown\n@maintainer\n
sample
\n![image][ref]\n## Verdict\nA | B\n--- | ---\n```", - " @maintainer", - "\t@maintainer", - ] { - assert_eq!( - validate_respond_output(&structured(answer, None)).unwrap(), - answer - ); - } - - for image in [ - "![inline](image.png)", - "![reference][image-ref]", - "![collapsed][]", - "![shortcut]", - "![multi\nline](image.png)", - ] { - assert!(validate_respond_output(&structured(image, None)).is_err()); - } - - for invalid in [ - r#"{"answer":"ok"}"#, - r#"{"answer":"ok","diagram":null,"extra":true}"#, - r#"{"answer":"ok","diagram":{}}"#, - r#"{"answer":"ok","diagram":""}"#, - ] { - assert!(validate_respond_output(invalid).is_err()); - } - } - - #[test] - fn commonmark_masking_does_not_hide_active_mentions_in_live_prose() { - for answer in [ - "Ask @maintainer to approve this.", - "`unterminated @maintainer", - "Unicode before unmatched code: é `unterminated @maintainer", - "```rust`invalid info string\n@maintainer\n```", - "This is a paragraph.\n @maintainer remains live prose.", - ] { - let error = validate_respond_output(&structured(answer, None)).unwrap_err(); - assert!( - error.to_string().contains("active mention"), - "active mention was hidden by invalid code markup: {answer}" - ); - } - } - - #[test] - fn outer_blank_line_trimming_preserves_indented_code() { - let answer = " @maintainer"; - assert_eq!( - validate_respond_output(&structured("\n \n @maintainer\n\t\n", None)).unwrap(), - answer - ); - } - - #[test] - fn shape_limits_ignore_markers_inside_code() { - for answer in [ - " 1) One\n 2) Two\n 3) Three\n 4) Four\n 5) Five\n 6) Six", - " # One\n ## Two\n ### Three", - "`# One`\n`## Two`\n`### Three`\n`1) One`\n`2) Two`\n`3) Three`\n`4) Four`\n`5) Five`\n`6) Six`", - ] { - assert_eq!( - validate_respond_output(&structured(answer, None)).unwrap(), - answer - ); - } - } -} diff --git a/src/review.rs b/src/review.rs index b58352b..c1d041f 100644 --- a/src/review.rs +++ b/src/review.rs @@ -6,7 +6,7 @@ use std::time::{Duration, Instant}; use anyhow::{Context, Result, anyhow}; use futures::StreamExt; -use crate::config::{Config, FindingPresentation, GateLevel, OnError}; +use crate::config::{Config, FindingPresentation, GateLevel, OnError, ReasoningEffort}; use crate::diff; use crate::durable_plan::{DurablePlanRegistrar, DurableReviewPlan}; use crate::envelope::{ @@ -27,6 +27,14 @@ use sha2::{Digest, Sha256}; use std::collections::HashMap; use time::{Date, OffsetDateTime}; +macro_rules! eprintln { + ($($argument:tt)*) => { crate::progress::telemetry(format_args!($($argument)*)) }; +} + +macro_rules! notice { + ($($argument:tt)*) => { crate::progress::notice(format_args!($($argument)*)) }; +} + /// Each model request stays bounded. Large reviews continue through sequential /// source windows; actual provider-attempt, deadline, and spend guards remain /// enforced by `LlmClient` while raw diff size never decides reviewability. @@ -403,6 +411,9 @@ pub struct ReviewArgs { pub fail_on: Option, pub config: Option, pub model: Option, + pub reasoning_effort: Option, + pub scorer_reasoning_effort: Option, + pub verbose: bool, pub bounded: bool, pub no_post: bool, pub defer_gate_check: bool, @@ -549,12 +560,26 @@ pub async fn run(args: ReviewArgs) -> Result { let mut cfg = Config::load(&cwd, args.config.as_deref())?; if let Some(m) = &args.model { cfg.model = m.clone(); + cfg.model_source = "command line".to_string(); + } + if let Some(effort) = &args.reasoning_effort { + cfg.reasoning_effort = ReasoningEffort::parse("--reasoning-effort", effort)?; + cfg.reasoning_effort_source = "command line".to_string(); + } + if let Some(effort) = &args.scorer_reasoning_effort { + cfg.scorer_reasoning_effort = ReasoningEffort::parse("--scorer-reasoning-effort", effort)?; + cfg.scorer_reasoning_effort_source = "command line".to_string(); } cfg.require_model()?; if let Some(fo) = &args.fail_on { cfg.gate_fail_on = GateLevel::parse(fo).ok_or_else(|| anyhow!("invalid --fail-on {fo:?}"))?; } + let progress = crate::progress::start_review( + args.resolved_output_format().is_some() || args.sarif.is_some(), + args.verbose, + crate::config::hosted_runtime_mode(), + ); if !args.no_post && cfg.finding_presentation == FindingPresentation::CheckAnnotations && args.forge != ForgeKind::GitHub @@ -564,28 +589,41 @@ pub async fn run(args: ReviewArgs) -> Result { )); } - match args.forge { - ForgeKind::Local => run_local(&args, &cfg, &cwd).await, - ForgeKind::GitHub => { - let repo = require_repo(&args)?; - let forge = GitHub::new(&repo, require_pr(&args)?)?; - run_remote(&args, &cfg, &forge, &repo, RepositorySource::GitHub(&forge)).await - } - ForgeKind::GitLab => { - let repo = require_repo(&args)?; - let forge = GitLab::new(&repo, require_pr(&args)?)?; - run_remote(&args, &cfg, &forge, &repo, RepositorySource::Unavailable).await - } - ForgeKind::Bitbucket => { - let repo = require_repo(&args)?; - let forge = Bitbucket::new(&repo, require_pr(&args)?)?; - run_remote(&args, &cfg, &forge, &repo, RepositorySource::Unavailable).await - } - ForgeKind::Azure => { - let repo = require_repo(&args)?; - let forge = Azure::new(&repo, require_pr(&args)?)?; - run_remote(&args, &cfg, &forge, &repo, RepositorySource::Unavailable).await + let review = async { + match args.forge { + ForgeKind::Local => run_local(&args, &cfg, &cwd).await, + ForgeKind::GitHub => { + let repo = require_repo(&args)?; + let forge = GitHub::new(&repo, require_pr(&args)?)?; + run_remote(&args, &cfg, &forge, &repo, RepositorySource::GitHub(&forge)).await + } + ForgeKind::GitLab => { + let repo = require_repo(&args)?; + let forge = GitLab::new(&repo, require_pr(&args)?)?; + run_remote(&args, &cfg, &forge, &repo, RepositorySource::Unavailable).await + } + ForgeKind::Bitbucket => { + let repo = require_repo(&args)?; + let forge = Bitbucket::new(&repo, require_pr(&args)?)?; + run_remote(&args, &cfg, &forge, &repo, RepositorySource::Unavailable).await + } + ForgeKind::Azure => { + let repo = require_repo(&args)?; + let forge = Azure::new(&repo, require_pr(&args)?)?; + run_remote(&args, &cfg, &forge, &repo, RepositorySource::Unavailable).await + } } + }; + let result = tokio::select! { + result = review => result, + _ = tokio::signal::ctrl_c() => Err(anyhow!("review interrupted")), + }; + if let Ok(code) = result { + progress.finish(code); + Ok(code) + } else { + drop(progress); + result } } @@ -602,19 +640,33 @@ fn require_pr(args: &ReviewArgs) -> Result { } async fn run_local(args: &ReviewArgs, cfg: &Config, repo_root: &Path) -> Result { - let source = if let Some(path) = &args.diff_file { - LocalSource::DiffFile(path.clone()) + let head_sha = if args.diff_file.is_some() { + None + } else { + local::head_sha(repo_root).await? + }; + let selection = if let Some(path) = &args.diff_file { + local::AutoSelection { + source: LocalSource::DiffFile(path.clone()), + warning: None, + } } else if args.staged { - LocalSource::Staged + local::AutoSelection { + source: LocalSource::Staged, + warning: None, + } } else if let Some(base) = &args.base { - LocalSource::Base(base.clone()) + local::AutoSelection { + source: LocalSource::Base(base.clone()), + warning: None, + } } else { - return Err(anyhow!( - "local review needs one of --staged, --base , or --diff-file " - )); + local::auto_select(repo_root, head_sha.as_deref()).await? }; - let head_sha = local::head_sha().await; - let local_snapshot = local::acquire(&source, head_sha.as_deref(), repo_root).await?; + if let Some(warning) = selection.warning { + crate::progress::notice(format_args!("postil: {warning}")); + } + let local_snapshot = local::acquire(&selection.source, head_sha.as_deref(), repo_root).await?; let baseline = load_baseline(args)?; // This is a fail-closed placeholder, not the completed review's trust. // review_diff replaces it with Failed, Bounded, or Exhaustive after the @@ -731,7 +783,7 @@ async fn run_remote( ); } // CI tokens without checks:write still get review + exit code. - eprintln!("postil: cannot create check runs ({e:#}); continuing without"); + notice!("postil: cannot create check runs ({e:#}); continuing without"); None } } @@ -859,7 +911,7 @@ async fn run_remote( if strict_publication { Err(post_err) } else { - eprintln!("postil: could not post the error review ({post_err:#})"); + notice!("postil: could not post the error review ({post_err:#})"); Ok(code) } } @@ -950,7 +1002,7 @@ fn retain_publication_failure( Err(error) if required => Ok(Some(error)), Err(error) if crate::forge::is_repository_identity_failure(&error) => Err(error), Err(error) => { - eprintln!("postil: {warning} ({error:#})"); + notice!("postil: {warning} ({error:#})"); Ok(None) } } @@ -2879,6 +2931,7 @@ async fn finish( if !publication_plan_to_stdout && (args.resolved_output_format().is_none() || args.output_file.is_some()) { + let _progress_suspension = crate::progress::suspend_for_output(); output::print_pretty(&envelope); } @@ -2981,7 +3034,7 @@ async fn finish( )); } Ok(false) => { - eprintln!( + notice!( "postil: publication skipped because the pull request snapshot changed after review" ); return Ok(if envelope.gate.failing { 1 } else { 0 }); @@ -2992,7 +3045,7 @@ async fn finish( ); } Err(error) => { - eprintln!( + notice!( "postil: publication skipped because snapshot freshness could not be verified ({error:#})" ); return Ok(if envelope.gate.failing { 1 } else { 0 }); @@ -3016,7 +3069,7 @@ async fn finish( if strict_publication { return Err(e).context("required hosted review publication failed"); } - eprintln!("postil: could not post review comment ({e:#})"); + notice!("postil: could not post review comment ({e:#})"); } } } diff --git a/tests/e2e.rs b/tests/e2e.rs index c3b9d9c..ddd2433 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -464,26 +464,6 @@ fn llm_text(text: &str) -> Value { }) } -fn respond_payload(answer: &str, diagram: Option<&str>) -> String { - json!({"answer": answer, "diagram": diagram}).to_string() -} - -fn respond_text(answer: &str) -> Value { - llm_text(&respond_payload(answer, None)) -} - -fn respond_article_slop() -> String { - let prefix = "I reviewed the full diff. Here's my assessment.\n\n\ -## What this PR does\n\nA broad implementation tour.\n\n\ -## Correctness\n\nSeveral paragraphs of non-actionable narration.\n\n\ -## Issues and risks\n\n\ -1. First item.\n2. Second item.\n3. Third item.\n\ -4. Fourth item.\n5. Fifth item.\n6. Sixth item.\n\n\ -## Verdict\n\nA long conclusion.\n\n"; - let padding = 7_186 - prefix.chars().count(); - format!("{prefix}{}", "x".repeat(padding)) -} - fn anthropic_content(findings: Value, input_tokens: u64, output_tokens: u64) -> Value { let findings = explicit_repository_context(findings); let summary = if findings.as_array().is_none_or(|items| items.is_empty()) { @@ -1131,8 +1111,10 @@ fn postil() -> Command { // Isolate from developer environment and repo config discovery. cmd.env_remove("REVIEW_MODEL") .env_remove("REVIEW_MODEL_CASCADE") + .env_remove("REVIEW_REASONING_EFFORT") .env_remove("REVIEW_SCORER_MODEL") .env_remove("REVIEW_SCORER_MODEL_CASCADE") + .env_remove("REVIEW_SCORER_REASONING_EFFORT") .env_remove("POSTIL_DISABLE_SCORER") .env_remove("POSTIL_UNCERTAINTY_RESOLUTION") .env_remove("POSTIL_CONCISE_FINDINGS") @@ -1659,6 +1641,8 @@ async fn native_anthropic_review_uses_messages_shape_auth_and_usage() { assert_eq!(body["messages"].as_array().unwrap().len(), 1); assert_eq!(body["messages"][0]["role"], "user"); assert_eq!(body["max_tokens"], 8_000); + assert_eq!(body["output_config"], json!({"effort": "low"})); + assert!(body.get("thinking").is_none()); assert!(body.get("choices").is_none()); } @@ -1887,6 +1871,21 @@ async fn native_anthropic_findings_use_explicit_native_scorer() { assert_eq!(envelope["findings"][0]["scorerConfidence"], 0.82); assert_eq!(envelope["usage"]["promptTokens"], 52); assert_eq!(envelope["usage"]["completionTokens"], 22); + let requests = server.received_requests().await.unwrap(); + let generator: Value = requests + .iter() + .find(|request| String::from_utf8_lossy(&request.body).contains("claude-sonnet-4-6")) + .unwrap() + .body_json() + .unwrap(); + let scorer: Value = requests + .iter() + .find(|request| String::from_utf8_lossy(&request.body).contains("claude-haiku-4-5")) + .unwrap() + .body_json() + .unwrap(); + assert_eq!(generator["output_config"], json!({"effort": "low"})); + assert_eq!(scorer["thinking"], json!({"type": "disabled"})); } #[tokio::test] @@ -1956,7 +1955,14 @@ async fn openai_successful_scorer_with_zero_usage_marks_accounting_incomplete() .env("POSTIL_API_BASE", server.uri()) .env("REVIEW_MODEL", "generator-model") .env("REVIEW_SCORER_MODEL", "scorer-model") - .args(["review", "--diff-file"]) + .args([ + "review", + "--reasoning-effort", + "medium", + "--scorer-reasoning-effort", + "high", + "--diff-file", + ]) .arg(&diff) .arg("--output-json") .assert() @@ -1969,6 +1975,21 @@ async fn openai_successful_scorer_with_zero_usage_marks_accounting_incomplete() assert!(stderr.contains( "llm usage accounting incomplete phase=scorer model=scorer-model attempt=1 reason=nonpositive" )); + let requests = server.received_requests().await.unwrap(); + let generator: Value = requests + .iter() + .find(|request| String::from_utf8_lossy(&request.body).contains("generator-model")) + .unwrap() + .body_json() + .unwrap(); + let scorer: Value = requests + .iter() + .find(|request| String::from_utf8_lossy(&request.body).contains("scorer-model")) + .unwrap() + .body_json() + .unwrap(); + assert_eq!(generator["reasoning"], json!({"effort": "medium"})); + assert_eq!(scorer["reasoning"], json!({"effort": "high"})); } #[tokio::test] @@ -5731,7 +5752,7 @@ fn hosted_config_ignores_repository_model_provider_and_scorer() { let dir = tempfile::tempdir().unwrap(); std::fs::write( dir.path().join(".postil.yaml"), - "model:\n name: anthropic/claude-opus-4.1\n cascade: [attacker/fallback]\n scorer: anthropic/claude-haiku-4.5\n apiBase: https://attacker.invalid/v1\n apiFormat: anthropic\n consensus: 3\n", + "model:\n name: anthropic/claude-opus-4.1\n reasoningEffort: max\n cascade: [attacker/fallback]\n scorer: anthropic/claude-haiku-4.5\n scorerReasoningEffort: high\n apiBase: https://attacker.invalid/v1\n apiFormat: anthropic\n consensus: 3\n", ) .unwrap(); @@ -5750,6 +5771,8 @@ fn hosted_config_ignores_repository_model_provider_and_scorer() { assert!(stdout.contains("model.cascade: []")); assert!(stdout.contains("model.scorer: ")); assert!(!stdout.contains("model.scorer: openai/gpt-5.6-luna")); + assert!(stdout.contains("model.reasoningEffort: low")); + assert!(stdout.contains("model.scorerReasoningEffort: none")); assert!(stdout.contains("model.apiBase: https://openrouter.ai/api/v1")); assert!(stdout.contains("model.apiFormat: openai-compatible")); assert!(stdout.contains("model.consensus: 1")); @@ -5763,7 +5786,7 @@ fn provisional_hosted_config_uses_only_the_baked_roster() { let dir = tempfile::tempdir().unwrap(); std::fs::write( dir.path().join(".postil.yaml"), - "model:\n name: attacker/model\n cascade: [attacker/fallback]\n scorer: attacker/scorer\n apiBase: https://attacker.invalid/v1\n apiFormat: anthropic\n consensus: 3\n", + "model:\n name: attacker/model\n reasoningEffort: max\n cascade: [attacker/fallback]\n scorer: attacker/scorer\n scorerReasoningEffort: high\n apiBase: https://attacker.invalid/v1\n apiFormat: anthropic\n consensus: 3\n", ) .unwrap(); @@ -5782,6 +5805,8 @@ fn provisional_hosted_config_uses_only_the_baked_roster() { assert!(stdout.contains("model.name: openai/gpt-5.6-luna")); assert!(stdout.contains("model.cascade: []")); assert!(stdout.contains("model.scorer: openai/gpt-5.6-luna")); + assert!(stdout.contains("model.reasoningEffort: low")); + assert!(stdout.contains("model.scorerReasoningEffort: none")); assert!(stdout.contains("model.apiBase: https://openrouter.ai:443/api/v1")); assert!(stdout.contains("model.apiFormat: openai-compatible")); assert!(stdout.contains("model.consensus: 1")); @@ -5812,8 +5837,7 @@ async fn local_review_reports_grounded_finding_and_gates() { assert_eq!(env["gate"]["failing"], true); assert_eq!(env["counts"]["error"], 1); // Embedded scoring is disabled unless the user explicitly configures it. - assert_eq!(env["usage"]["promptTokens"], 130); - assert_eq!(env["usage"]["completionTokens"], 60); + // Complete finding adjudication still uses the generator under the scorer role. assert_eq!(env["usageAccountingComplete"], true); let model_usage = env["modelUsage"].as_array().unwrap(); assert_eq!(model_usage.len(), 2); @@ -5823,7 +5847,10 @@ async fn local_review_reports_grounded_finding_and_gates() { assert_eq!(model_usage[0]["attempt"], 1); assert_eq!(model_usage[0]["accountingComplete"], true); assert_eq!(model_usage[1]["role"], "findingScorer"); + assert_eq!(model_usage[1]["phase"], "initial"); assert_eq!(model_usage[1]["callOrdinal"], 2); + assert_eq!(model_usage[1]["attempt"], 1); + assert_eq!(model_usage[1]["accountingComplete"], true); assert_eq!( model_usage .iter() @@ -5834,7 +5861,10 @@ async fn local_review_reports_grounded_finding_and_gates() { let requests = server.received_requests().await.unwrap(); let request: Value = requests[0].body_json().unwrap(); + let adjudication: Value = requests[1].body_json().unwrap(); assert_eq!(request["max_tokens"], 8_000); + assert_eq!(request["reasoning"], json!({"effort": "low"})); + assert_eq!(adjudication["reasoning"], json!({"effort": "none"})); assert_eq!(request["messages"].as_array().unwrap().len(), 2); let prompt_bytes = request["messages"] .as_array() @@ -5856,13 +5886,358 @@ fn review_without_an_explicit_model_exits_before_provider_access() { .current_dir(dir.path()) .env_remove("REVIEW_MODEL") .env_remove("REVIEW_MODEL_CASCADE") + .env_remove("MODEL_API_KEY") + .env_remove("LLM_API_KEY") + .env_remove("OPENROUTER_API_KEY") + .env_remove("POSTIL_API_KEY") .args(["review", "--diff-file"]) .arg(&diff) .assert() .code(2); let stderr = String::from_utf8_lossy(&out.get_output().stderr); - assert!(stderr.contains("no review model is configured")); + assert!(stderr.contains("no API key")); + assert!(stderr.contains("openai/gpt-5.6-luna")); + assert!(stderr.contains("postil models")); +} + +#[test] +fn bare_review_keeps_redirected_output_free_of_terminal_control_sequences() { + let dir = tempfile::tempdir().unwrap(); + assert!( + std::process::Command::new("git") + .args(["init", "--quiet"]) + .current_dir(dir.path()) + .status() + .unwrap() + .success() + ); + let output = Command::cargo_bin("postil") + .unwrap() + .current_dir(dir.path()) + .env_remove("CI") + .env_remove("POSTIL_HOSTED_MODE") + .env_remove("RUST_LOG") + .env_remove("NO_COLOR") + .arg("review") + .output() + .unwrap(); + assert!(output.status.success()); + let combined = [output.stdout, output.stderr].concat(); + assert!(!String::from_utf8_lossy(&combined).contains("\x1b[")); + assert!(!String::from_utf8_lossy(&combined).contains("Reviewing changes")); +} + +#[cfg(unix)] +#[test] +fn bare_review_clears_progress_before_pretty_output_in_a_pty() { + let dir = tempfile::tempdir().unwrap(); + assert!( + std::process::Command::new("git") + .args(["init", "--quiet"]) + .current_dir(dir.path()) + .status() + .unwrap() + .success() + ); + let binary = Command::cargo_bin("postil") + .unwrap() + .get_program() + .to_string_lossy() + .into_owned(); + let output = std::process::Command::new("script") + .args(["-qefc", &format!("'{binary}' review"), "/dev/null"]) + .current_dir(dir.path()) + .env("TERM", "xterm") + .env_remove("CI") + .env_remove("POSTIL_HOSTED_MODE") + .env_remove("RUST_LOG") + .env_remove("NO_COLOR") + .output() + .unwrap(); + assert!(output.status.success()); + let rendered = String::from_utf8_lossy(&output.stdout); + let summary = rendered + .find("✓ postil: no merge-relevant findings") + .expect("pretty output was not rendered"); + let before_summary = &rendered[..summary]; + let spinner = before_summary + .rfind("Reviewing changes...") + .expect("progress was not rendered"); + let clear = before_summary + .rfind("\r\x1b[2K") + .expect("progress was not cleared"); + assert!( + clear > spinner, + "pretty output began before the progress line was cleared: {rendered:?}" + ); +} + +#[cfg(unix)] +#[tokio::test] +async fn github_telemetry_is_hidden_only_for_interactive_reviews() { + let server = MockServer::start().await; + mount_github_complete_diff(&server, 7).await; + mount_static_github_pr(&server).await; + let dir = tempfile::tempdir().unwrap(); + disable_review_for_hosted_publication(dir.path(), false); + let binary = Command::cargo_bin("postil") + .unwrap() + .get_program() + .to_string_lossy() + .into_owned(); + + let interactive = std::process::Command::new("script") + .args([ + "-qefc", + &format!("'{binary}' review --repo acme/api --pr 7"), + "/dev/null", + ]) + .current_dir(dir.path()) + .env("TERM", "xterm") + .env("GITHUB_API_URL", server.uri()) + .env("GITHUB_TOKEN", "gh-test-token") + .env_remove("CI") + .env_remove("POSTIL_HOSTED_MODE") + .env_remove("RUST_LOG") + .env_remove("POSTIL_DEBUG") + .env_remove("NO_COLOR") + .output() + .unwrap(); + assert!(interactive.status.success()); + let rendered = String::from_utf8_lossy(&interactive.stdout); + assert!(rendered.contains("Reviewing changes")); + assert!( + !rendered.contains("postil: github operation="), + "interactive review exposed routine GitHub telemetry: {rendered:?}" + ); + + let machine = postil() + .current_dir(dir.path()) + .env("GITHUB_API_URL", server.uri()) + .env("GITHUB_TOKEN", "gh-test-token") + .args([ + "review", "--repo", "acme/api", "--pr", "7", "--output", "json", + ]) + .output() + .unwrap(); + assert!(machine.status.success()); + let telemetry = String::from_utf8_lossy(&machine.stderr); + assert!( + telemetry.contains("postil: github operation="), + "machine review omitted GitHub telemetry: {telemetry:?}" + ); +} + +#[cfg(unix)] +#[test] +fn bare_review_keeps_warnings_visible_and_marks_degraded_completion_in_a_pty() { + let dir = tempfile::tempdir().unwrap(); + assert!( + std::process::Command::new("git") + .args(["init", "--quiet"]) + .current_dir(dir.path()) + .status() + .unwrap() + .success() + ); + std::fs::write(dir.path().join("untracked.txt"), "review me\n").unwrap(); + let binary = Command::cargo_bin("postil") + .unwrap() + .get_program() + .to_string_lossy() + .into_owned(); + let output = std::process::Command::new("script") + .args(["-qefc", &format!("'{binary}' review"), "/dev/null"]) + .current_dir(dir.path()) + .env("TERM", "xterm") + .env_remove("CI") + .env_remove("POSTIL_HOSTED_MODE") + .env_remove("RUST_LOG") + .env_remove("NO_COLOR") + .output() + .unwrap(); + assert!(output.status.success()); + let rendered = String::from_utf8_lossy(&output.stdout); + assert!(rendered.contains("Reviewing changes")); + assert!(rendered.contains("\x1b[2K")); + assert!(rendered.contains("untracked files were not reviewed")); + assert!(rendered.contains("review complete with warnings")); +} + +#[cfg(unix)] +#[test] +fn bare_review_respects_no_color_in_a_pty() { + let dir = tempfile::tempdir().unwrap(); + assert!( + std::process::Command::new("git") + .args(["init", "--quiet"]) + .current_dir(dir.path()) + .status() + .unwrap() + .success() + ); + let binary = Command::cargo_bin("postil") + .unwrap() + .get_program() + .to_string_lossy() + .into_owned(); + let output = std::process::Command::new("script") + .args(["-qefc", &format!("'{binary}' review"), "/dev/null"]) + .current_dir(dir.path()) + .env("TERM", "xterm") + .env("NO_COLOR", "1") + .env_remove("CI") + .env_remove("POSTIL_HOSTED_MODE") + .env_remove("RUST_LOG") + .output() + .unwrap(); + assert!(output.status.success()); + let rendered = String::from_utf8_lossy(&output.stdout); + assert!(rendered.contains("reviewing changes")); + assert!( + !rendered.contains("\x1b["), + "NO_COLOR output contained terminal controls: {rendered:?}" + ); + assert!(rendered.contains("review complete")); +} + +#[test] +fn models_and_config_explain_the_embedded_default() { + let dir = tempfile::tempdir().unwrap(); + let models = Command::cargo_bin("postil") + .unwrap() + .env_remove("REVIEW_REASONING_EFFORT") + .env_remove("REVIEW_SCORER_REASONING_EFFORT") + .args(["models"]) + .assert() + .success() + .get_output() + .stdout + .clone(); + let models = String::from_utf8(models).unwrap(); + assert!(models.contains("Luna (tested default): openai/gpt-5.6-luna")); + assert!(models.contains("Reviewer reasoning effort: low")); + assert!(models.contains("Scorer reasoning effort: none")); + assert!(models.contains("max|xhigh|high|medium|low|minimal|none")); + assert!(models.contains("postil review --model provider/model")); + assert!(models.contains("model.reasoningEffort and model.scorerReasoningEffort")); + let config = Command::cargo_bin("postil") + .unwrap() + .current_dir(dir.path()) + .env_remove("REVIEW_REASONING_EFFORT") + .env_remove("REVIEW_SCORER_REASONING_EFFORT") + .args(["config"]) + .assert() + .success() + .get_output() + .stdout + .clone(); + let config = String::from_utf8(config).unwrap(); + assert!(config.contains("model.source: embedded default")); + assert!(config.contains("model.name: openai/gpt-5.6-luna")); + assert!(config.contains("model.reasoningEffort: low")); + assert!(config.contains("model.reasoningEffort.source: embedded default")); + assert!(config.contains("model.scorerReasoningEffort: none")); + assert!(config.contains("model.scorerReasoningEffort.source: embedded default")); +} + +#[test] +fn config_reports_separate_environment_reasoning_provenance() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join(".postil.yaml"), + "model:\n reasoningEffort: medium\n scorerReasoningEffort: high\n", + ) + .unwrap(); + let output = postil() + .current_dir(dir.path()) + .env("REVIEW_REASONING_EFFORT", "xhigh") + .env("REVIEW_SCORER_REASONING_EFFORT", "minimal") + .args(["config"]) + .assert() + .success() + .get_output() + .stdout + .clone(); + let output = String::from_utf8(output).unwrap(); + assert!(output.contains("model.reasoningEffort: xhigh")); + assert!(output.contains("model.reasoningEffort.source: environment")); + assert!(output.contains("model.scorerReasoningEffort: minimal")); + assert!(output.contains("model.scorerReasoningEffort.source: environment")); +} + +#[tokio::test] +async fn cli_reasoning_effort_overrides_environment_and_config_in_provider_request() { + let server = MockServer::start().await; + mock_review(&server, json!([])).await; + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join(".postil.yaml"), + "model:\n reasoningEffort: medium\n", + ) + .unwrap(); + let diff = write_diff(dir.path()); + + postil() + .current_dir(dir.path()) + .env("POSTIL_API_BASE", server.uri()) + .env("REVIEW_REASONING_EFFORT", "high") + .args(["review", "--reasoning-effort", "xhigh", "--diff-file"]) + .arg(&diff) + .assert() + .success(); + + let requests = server.received_requests().await.unwrap(); + let request: Value = requests[0].body_json().unwrap(); + assert_eq!(request["reasoning"], json!({"effort": "xhigh"})); +} + +#[tokio::test] +async fn invalid_cli_reasoning_effort_fails_before_provider_access() { + let server = MockServer::start().await; + let dir = tempfile::tempdir().unwrap(); + let diff = write_diff(dir.path()); + + let output = postil() + .current_dir(dir.path()) + .env("POSTIL_API_BASE", server.uri()) + .args(["review", "--reasoning-effort", "turbo", "--diff-file"]) + .arg(&diff) + .assert() + .code(2) + .get_output() + .stderr + .clone(); + + let stderr = String::from_utf8(output).unwrap(); + assert!(stderr.contains("invalid --reasoning-effort \"turbo\"")); + assert!(stderr.contains("max|xhigh|high|medium|low|minimal|none")); + assert!(server.received_requests().await.unwrap().is_empty()); +} + +#[tokio::test] +async fn native_anthropic_minimal_effort_fails_before_provider_access() { + let server = MockServer::start().await; + let dir = tempfile::tempdir().unwrap(); + let diff = write_diff(dir.path()); + + let output = postil() + .current_dir(dir.path()) + .env("POSTIL_API_BASE", server.uri()) + .env("POSTIL_API_FORMAT", "anthropic") + .args(["review", "--reasoning-effort", "minimal", "--diff-file"]) + .arg(&diff) + .assert() + .code(2) + .get_output() + .stderr + .clone(); + + let stderr = String::from_utf8(output).unwrap(); + assert!(stderr.contains("minimal is unsupported by the Anthropic request format")); + assert!(stderr.contains("max|xhigh|high|medium|low|none")); + assert!(server.received_requests().await.unwrap().is_empty()); } #[tokio::test] @@ -13118,914 +13493,28 @@ async fn azure_flow_reconstructs_diff_and_posts_thread() { } #[tokio::test] -async fn respond_to_pr_mention_posts_grounded_reply() { +async fn gitlab_diff_pagination_follows_authoritative_next_page_to_exhaustion() { let server = MockServer::start().await; - mount_github_complete_diff(&server, 5).await; + mount_gitlab_source_files(&server).await; Mock::given(method("POST")) .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(respond_text( - "Line 41 interpolates `user_input` straight into the query; that is the \ - injection risk. Parameterize it.", - ))) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path("/repos/acme/api/pulls/5")) - .and(header("Accept", "application/vnd.github.v3.diff")) - .respond_with(ResponseTemplate::new(200).set_body_string(DIFF)) + .respond_with(ResponseTemplate::new(200).set_body_json(llm_content(json!([])))) .mount(&server) .await; Mock::given(method("GET")) - .and(path("/repos/acme/api/pulls/5")) + .and(path_regex(r"^/projects/.+/merge_requests/6$")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "title": "Add login", "body": "PR body", - "state": "open", "merged": false, - "head": {"sha": "aaaaaaaa"}, "base": {"sha": "bbbbbbbb"}, "changed_files": 1 + "title": "Paginated change", + "description": "", + "state": "opened", "diff_refs": {"base_sha": "b", "start_sha": "s", "head_sha": "h"} }))) .mount(&server) .await; - Mock::given(method("POST")) - .and(path("/repos/acme/api/issues/5/comments")) - .respond_with(ResponseTemplate::new(201).set_body_json(json!({}))) - .mount(&server) - .await; - - let dir = tempfile::tempdir().unwrap(); - postil() - .current_dir(dir.path()) - .env("POSTIL_API_BASE", server.uri()) - .env("GITHUB_API_URL", server.uri()) - .env("GITHUB_TOKEN", "gh-test-token") - .args([ - "respond", - "--publish", - "--repo", - "acme/api", - "--pr", - "5", - "--comment", - "@postil is this safe?", - ]) - .assert() - .success(); - - let reqs = server.received_requests().await.unwrap(); - let comment = reqs - .iter() - .find(|r| r.url.path() == "/repos/acme/api/issues/5/comments") - .expect("reply posted"); - let body: Value = comment.body_json().unwrap(); - let text = body["body"].as_str().unwrap(); - assert!(text.contains("injection risk")); - assert!(!text.contains("Postil ·")); -} - -#[tokio::test] -async fn respond_without_publish_prints_locally_and_never_comments() { - let server = MockServer::start().await; - mount_github_complete_diff(&server, 5).await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(respond_text( - "Line 41 passes untrusted input to the query. Parameterize it.", - ))) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path("/repos/acme/api/pulls/5")) - .and(header("Accept", "application/vnd.github.v3.diff")) - .respond_with(ResponseTemplate::new(200).set_body_string(DIFF)) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path("/repos/acme/api/pulls/5")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "title": "Add login", "body": "PR body", - "state": "open", "merged": false, - "head": {"sha": "aaaaaaaa"}, "base": {"sha": "bbbbbbbb"}, "changed_files": 1 - }))) - .mount(&server) - .await; - - let dir = tempfile::tempdir().unwrap(); - let output = postil() - .current_dir(dir.path()) - .env("CI", "true") - .env("GITHUB_ACTIONS", "true") - .env("POSTIL_API_BASE", server.uri()) - .env("GITHUB_API_URL", server.uri()) - .env("GITHUB_TOKEN", "gh-test-token") - .args([ - "respond", - "--repo", - "acme/api", - "--pr", - "5", - "--comment", - "@postil is this safe?", - ]) - .assert() - .success(); - - assert!(String::from_utf8_lossy(&output.get_output().stdout).contains("Parameterize it.")); - let requests = server.received_requests().await.unwrap(); - assert!(!requests.iter().any(|request| { - request.method == wiremock::http::Method::POST - && request.url.path() == "/repos/acme/api/issues/5/comments" - })); -} - -#[tokio::test] -async fn respond_truncation_retries_without_publishing_partial_text() { - let server = MockServer::start().await; - let partial_answer = "Partial answer must never publish."; - mount_github_complete_diff(&server, 5).await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(SequentialReviewResponder { - calls: Arc::new(AtomicUsize::new(0)), - responses: Arc::new(vec![ - json!({ - "choices": [{"finish_reason": "length", "message": {"content": - respond_payload(partial_answer, None) - }}], - "usage": {"prompt_tokens": 40, "completion_tokens": 1024} - }), - respond_text("Parameterize `user_input` before executing the query."), - ]), - }) - .expect(2) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path("/repos/acme/api/pulls/5")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "title": "Add login", "body": "PR body", - "state": "open", "merged": false, - "head": {"sha": "aaaaaaaa"}, "base": {"sha": "bbbbbbbb"}, "changed_files": 1 - }))) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(path("/repos/acme/api/issues/5/comments")) - .respond_with(ResponseTemplate::new(201).set_body_json(json!({}))) - .expect(1) - .mount(&server) - .await; - - let dir = tempfile::tempdir().unwrap(); - postil() - .current_dir(dir.path()) - .env("POSTIL_API_BASE", server.uri()) - .env("GITHUB_API_URL", server.uri()) - .env("GITHUB_TOKEN", "gh-test-token") - .env("REVIEW_MODEL", "primary-model") - .args([ - "respond", - "--publish", - "--repo", - "acme/api", - "--pr", - "5", - "--comment", - "@postil is this safe?", - ]) - .assert() - .success(); - - let requests = server.received_requests().await.unwrap(); - let model_requests = requests - .iter() - .filter(|request| request.url.path() == "/chat/completions") - .collect::>(); - assert_eq!(model_requests.len(), 2); - let first: Value = model_requests[0].body_json().unwrap(); - let second: Value = model_requests[1].body_json().unwrap(); - assert_eq!(first["max_tokens"], 1024); - assert_eq!(second["max_tokens"], 2048); - assert_eq!(first["messages"], second["messages"]); - assert!( - !serde_json::to_string(&second) - .unwrap() - .contains(partial_answer) - ); - let comment = requests - .iter() - .find(|request| request.url.path() == "/repos/acme/api/issues/5/comments") - .expect("reply posted"); - let body: Value = comment.body_json().unwrap(); - assert!(body["body"].as_str().unwrap().contains("Parameterize")); - assert!(!body["body"].as_str().unwrap().contains(partial_answer)); -} - -#[tokio::test] -async fn repeated_respond_truncation_fails_without_publishing() { - let server = MockServer::start().await; - let partial_answer = "Partial answer must never publish."; - mount_github_complete_diff(&server, 5).await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "choices": [{"finish_reason": "length", "message": {"content": - respond_payload(partial_answer, None) - }}], - "usage": {"prompt_tokens": 40, "completion_tokens": 1024} - }))) - .expect(2) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path("/repos/acme/api/pulls/5")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "title": "Add login", "body": "PR body", - "state": "open", "merged": false, - "head": {"sha": "aaaaaaaa"}, "base": {"sha": "bbbbbbbb"}, "changed_files": 1 - }))) - .mount(&server) - .await; - - let dir = tempfile::tempdir().unwrap(); - let out = postil() - .current_dir(dir.path()) - .env("POSTIL_API_BASE", server.uri()) - .env("GITHUB_API_URL", server.uri()) - .env("GITHUB_TOKEN", "gh-test-token") - .env("REVIEW_MODEL", "primary-model") - .args([ - "respond", - "--publish", - "--repo", - "acme/api", - "--pr", - "5", - "--comment", - "@postil is this safe?", - ]) - .assert() - .failure(); - - assert!(!String::from_utf8_lossy(&out.get_output().stdout).contains(partial_answer)); - let requests = server.received_requests().await.unwrap(); - assert_eq!( - requests - .iter() - .filter(|request| request.url.path() == "/chat/completions") - .count(), - 2 - ); - assert!( - requests - .iter() - .all(|request| request.url.path() != "/repos/acme/api/issues/5/comments") - ); -} - -#[tokio::test] -async fn respond_rejects_article_shape_and_preserves_usage_across_fallback() { - use std::os::unix::fs::PermissionsExt; - - let server = MockServer::start().await; - let slop = respond_article_slop(); - assert_eq!(slop.chars().count(), 7_186); - Mock::given(method("POST")) - .and(path("/chat/completions")) - .and(body_string_contains("article-model")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "choices": [{"finish_reason": "stop", "message": {"content": slop}}], - "usage": {"prompt_tokens": 30, "completion_tokens": 900} - }))) - .expect(1) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .and(body_string_contains("compact-model")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "choices": [{"finish_reason": "stop", "message": {"content": respond_payload( - "`src/queue.rs:18` retries forever. Add a terminal state before merge.", - None, - )}}], - "usage": {"prompt_tokens": 20, "completion_tokens": 15} - }))) - .expect(1) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path("/repos/acme/api/issues/9")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "title": "Queue retry", "body": "Review the retry behavior." - }))) - .mount(&server) - .await; - - let dir = tempfile::tempdir().unwrap(); - let receipt_path = dir.path().join("respond-usage.json"); - let out = postil() - .current_dir(dir.path()) - .env("POSTIL_API_BASE", server.uri()) - .env("GITHUB_API_URL", server.uri()) - .env("GITHUB_TOKEN", "gh-test-token") - .env("REVIEW_MODEL", "article-model") - .env("REVIEW_MODEL_CASCADE", "compact-model") - .env("POSTIL_USAGE_RECEIPT_PATH", &receipt_path) - .args([ - "respond", - "--repo", - "acme/api", - "--issue", - "9", - "--comment", - "@postil review this", - "--no-post", - ]) - .assert() - .success(); - - let stdout = String::from_utf8_lossy(&out.get_output().stdout); - assert!(stdout.contains("Add a terminal state")); - assert!(!stdout.contains("What this PR does")); - assert!(!stdout.contains("Postil ·")); - let receipt: Value = serde_json::from_slice(&std::fs::read(&receipt_path).unwrap()).unwrap(); - assert_eq!(receipt["promptTokens"], 50); - assert_eq!(receipt["completionTokens"], 915); - assert_eq!(receipt["models"][0]["model"], "article-model"); - assert_eq!(receipt["models"][1]["model"], "compact-model"); - assert_eq!( - std::fs::metadata(&receipt_path) - .unwrap() - .permissions() - .mode() - & 0o777, - 0o600, - ); -} - -#[tokio::test] -async fn respond_rejects_generated_mermaid_even_when_requested() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .and(body_string_contains("diagram-model")) - .respond_with( - ResponseTemplate::new(200).set_body_json(llm_text(&respond_payload( - "The request is queued before a worker handles it.", - Some("flowchart LR\n API --> Queue\n Queue --> Worker"), - ))), - ) - .expect(1) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .and(body_string_contains("compact-model")) - .respond_with(ResponseTemplate::new(200).set_body_json(respond_text( - "The API writes the job to the queue before a worker claims it.", - ))) - .expect(1) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path("/repos/acme/api/issues/10")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "title": "Queue flow", "body": "How does work reach a worker?" - }))) - .mount(&server) - .await; - - let dir = tempfile::tempdir().unwrap(); - let out = postil() - .current_dir(dir.path()) - .env("POSTIL_API_BASE", server.uri()) - .env("GITHUB_API_URL", server.uri()) - .env("GITHUB_TOKEN", "gh-test-token") - .env("REVIEW_MODEL", "diagram-model") - .env("REVIEW_MODEL_CASCADE", "compact-model") - .args([ - "respond", - "--repo", - "acme/api", - "--issue", - "10", - "--comment", - "@postil please include a Mermaid diagram of this flow", - "--no-post", - ]) - .assert() - .success(); - - let stdout = String::from_utf8_lossy(&out.get_output().stdout); - assert!(stdout.contains("worker claims it")); - assert!(!stdout.contains("```mermaid")); - assert!(!stdout.contains("API --> Queue")); -} - -#[tokio::test] -async fn respond_rejects_unrequested_mermaid_and_uses_compact_fallback() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .and(body_string_contains("diagram-model")) - .respond_with( - ResponseTemplate::new(200).set_body_json(llm_text(&respond_payload( - "The request enters a queue.", - Some("flowchart LR\n API --> Queue"), - ))), - ) - .expect(1) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .and(body_string_contains("compact-model")) - .respond_with(ResponseTemplate::new(200).set_body_json(respond_text( - "The API writes the job to the queue before a worker claims it.", - ))) - .expect(1) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path("/repos/acme/api/issues/11")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "title": "Queue flow", "body": "Explain the worker handoff." - }))) - .mount(&server) - .await; - - let dir = tempfile::tempdir().unwrap(); - let out = postil() - .current_dir(dir.path()) - .env("POSTIL_API_BASE", server.uri()) - .env("GITHUB_API_URL", server.uri()) - .env("GITHUB_TOKEN", "gh-test-token") - .env("REVIEW_MODEL", "diagram-model") - .env("REVIEW_MODEL_CASCADE", "compact-model") - .args([ - "respond", - "--repo", - "acme/api", - "--issue", - "11", - "--comment", - "@postil explain the worker handoff", - "--no-post", - ]) - .assert() - .success(); - - let stdout = String::from_utf8_lossy(&out.get_output().stdout); - assert!(stdout.contains("worker claims it")); - assert!(!stdout.contains("```mermaid")); -} - -#[tokio::test] -async fn respond_rejects_unsafe_reply_before_direct_forge_posting() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .and(body_string_contains("unsafe-model")) - .respond_with(ResponseTemplate::new(200).set_body_json(respond_text( - "Ask @maintainer to approve this.\n\n## Verdict\nLooks good.", - ))) - .expect(1) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .and(body_string_contains("compact-model")) - .respond_with(ResponseTemplate::new(200).set_body_json(respond_text( - "`src/queue.rs:18` can retry forever. Add a terminal state before merge.", - ))) - .expect(1) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path("/repos/acme/api/issues/12")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "title": "Queue retry", "body": "Review the retry behavior." - }))) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(path("/repos/acme/api/issues/12/comments")) - .respond_with(ResponseTemplate::new(201).set_body_json(json!({}))) - .expect(1) - .mount(&server) - .await; - - let dir = tempfile::tempdir().unwrap(); - postil() - .current_dir(dir.path()) - .env("POSTIL_API_BASE", server.uri()) - .env("GITHUB_API_URL", server.uri()) - .env("GITHUB_TOKEN", "gh-test-token") - .env("REVIEW_MODEL", "unsafe-model") - .env("REVIEW_MODEL_CASCADE", "compact-model") - .args([ - "respond", - "--publish", - "--repo", - "acme/api", - "--issue", - "12", - "--comment", - "@postil review the retry behavior", - ]) - .assert() - .success(); - - let requests = server.received_requests().await.unwrap(); - let post = requests - .iter() - .find(|request| request.url.path() == "/repos/acme/api/issues/12/comments") - .expect("validated fallback posted"); - let body: Value = post.body_json().unwrap(); - let reply = body["body"].as_str().unwrap(); - assert!(reply.contains("Add a terminal state")); - assert!(!reply.contains("@maintainer")); - assert!(!reply.contains("Verdict")); -} - -#[tokio::test] -async fn respond_writes_private_usage_receipt_across_model_fallback() { - use std::os::unix::fs::PermissionsExt; - - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .and(body_string_contains("primary-model")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "choices": [], - "usage": {"prompt_tokens": 10, "completion_tokens": 2} - }))) - .expect(2) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .and(body_string_contains("backup-model")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "choices": [{"finish_reason": "stop", "message": {"content": respond_payload("Use a bounded worker pool.", None)}}], - "usage": {"prompt_tokens": 20, "completion_tokens": 3, "cost": 0.00000049} - }))) - .expect(1) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path("/repos/acme/api/issues/9")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "title": "Timeouts", "body": "Requests hang under load." - }))) - .mount(&server) - .await; - - let dir = tempfile::tempdir().unwrap(); - let receipt_path = dir.path().join("respond-usage.json"); - std::fs::write( - &receipt_path, - b"stale receipt from an interrupted attempt\n", - ) - .unwrap(); - let out = postil() - .current_dir(dir.path()) - .env("POSTIL_API_BASE", server.uri()) - .env("GITHUB_API_URL", server.uri()) - .env("GITHUB_TOKEN", "gh-test-token") - .env("REVIEW_MODEL", "primary-model") - .env("REVIEW_MODEL_CASCADE", "backup-model") - .env("POSTIL_USAGE_RECEIPT_PATH", &receipt_path) - .args([ - "respond", - "--repo", - "acme/api", - "--issue", - "9", - "--comment", - "@postil how should this be bounded?", - "--no-post", - ]) - .assert() - .success(); - - let stdout = String::from_utf8_lossy(&out.get_output().stdout); - assert!(stdout.contains("Use a bounded worker pool.")); - assert!(!stdout.contains("promptTokens")); - let receipt: Value = serde_json::from_slice(&std::fs::read(&receipt_path).unwrap()).unwrap(); - assert_eq!(receipt["version"], 2); - assert_eq!(receipt["models"][0]["role"], "mentionResponder"); - assert_eq!(receipt["models"][0]["phase"], "initial"); - assert!(receipt["models"][0]["callOrdinal"].is_number()); - assert!(receipt["models"][0]["attempt"].is_number()); - assert!(receipt["models"][0]["accountingComplete"].is_boolean()); - assert_eq!(receipt["operation"], "respond"); - assert_eq!(receipt["usageAccountingComplete"], true); - assert_eq!(receipt["promptTokens"], 40); - assert_eq!(receipt["completionTokens"], 7); - assert_eq!(receipt["models"][0]["model"], "primary-model"); - assert_eq!(receipt["models"][0]["promptTokens"], 10); - assert_eq!(receipt["models"][0]["completionTokens"], 2); - assert_eq!(receipt["models"][1]["model"], "primary-model"); - assert_eq!(receipt["models"][1]["promptTokens"], 10); - assert_eq!(receipt["models"][1]["completionTokens"], 2); - assert_eq!(receipt["models"][2]["model"], "backup-model"); - assert_eq!(receipt["models"][2]["promptTokens"], 20); - assert_eq!(receipt["models"][2]["completionTokens"], 3); - assert_eq!(receipt["models"][2]["costProviderDecimal"], "0.00000049"); - assert_eq!(receipt["models"][2]["costMicros"], 0); - assert_eq!(receipt["models"][2]["costSource"], "providerReported"); - assert_eq!( - std::fs::metadata(&receipt_path) - .unwrap() - .permissions() - .mode() - & 0o777, - 0o600, - ); -} - -#[tokio::test] -async fn respond_marks_receipt_incomplete_after_ambiguous_fallback() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .and(body_string_contains("primary-model")) - .respond_with(ResponseTemplate::new(503)) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .and(body_string_contains("backup-model")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "choices": [{"finish_reason": "stop", "message": {"content": respond_payload("Retry with a bounded backoff.", None)}}], - "usage": {"prompt_tokens": 20, "completion_tokens": 3} - }))) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path("/repos/acme/api/issues/10")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "title": "Retries", "body": "Requests fail intermittently." - }))) - .mount(&server) - .await; - - let dir = tempfile::tempdir().unwrap(); - let receipt_path = dir.path().join("respond-usage.json"); - postil() - .current_dir(dir.path()) - .env("POSTIL_API_BASE", server.uri()) - .env("GITHUB_API_URL", server.uri()) - .env("GITHUB_TOKEN", "gh-test-token") - .env("REVIEW_MODEL", "primary-model") - .env("REVIEW_MODEL_CASCADE", "backup-model") - .env("POSTIL_USAGE_RECEIPT_PATH", &receipt_path) - .args([ - "respond", - "--repo", - "acme/api", - "--issue", - "10", - "--comment", - "@postil how should this retry?", - "--no-post", - ]) - .assert() - .success(); - - let receipt: Value = serde_json::from_slice(&std::fs::read(receipt_path).unwrap()).unwrap(); - assert_eq!(receipt["usageAccountingComplete"], false); - assert_eq!(receipt["models"].as_array().unwrap().len(), 4); - assert_eq!(receipt["models"][3]["model"], "backup-model"); -} - -#[tokio::test] -async fn respond_marks_receipt_incomplete_after_internal_retry_succeeds() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .and(body_string_contains("primary-model")) - .respond_with(ResponseTemplate::new(408).set_body_string("request timed out")) - .up_to_n_times(1) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .and(body_string_contains("primary-model")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "choices": [{"finish_reason": "stop", "message": {"content": respond_payload("Use a bounded retry.", None)}}], - "usage": {"prompt_tokens": 20, "completion_tokens": 3} - }))) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path("/repos/acme/api/issues/11")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "title": "Retries", "body": "Requests fail intermittently." - }))) - .mount(&server) - .await; - - let dir = tempfile::tempdir().unwrap(); - let receipt_path = dir.path().join("respond-usage.json"); - postil() - .current_dir(dir.path()) - .env("POSTIL_API_BASE", server.uri()) - .env("GITHUB_API_URL", server.uri()) - .env("GITHUB_TOKEN", "gh-test-token") - .env("REVIEW_MODEL", "primary-model") - .env("REVIEW_MODEL_CASCADE", "") - .env("POSTIL_USAGE_RECEIPT_PATH", &receipt_path) - .args([ - "respond", - "--repo", - "acme/api", - "--issue", - "11", - "--comment", - "@postil how should this retry?", - "--no-post", - ]) - .assert() - .success(); - - let receipt: Value = serde_json::from_slice(&std::fs::read(receipt_path).unwrap()).unwrap(); - assert_eq!(receipt["usageAccountingComplete"], false); - assert_eq!(receipt["models"].as_array().unwrap().len(), 2); - assert_eq!(receipt["models"][0]["model"], "primary-model"); - assert_eq!( - server - .received_requests() - .await - .unwrap() - .iter() - .filter(|request| { request.url.path() == "/chat/completions" }) - .count(), - 2 - ); -} - -#[tokio::test] -async fn respond_to_issue_mention_uses_issue_body() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(respond_text( - "This looks like a connection-pool exhaustion under load, not a logic bug.", - ))) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path("/repos/acme/api/issues/9")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "title": "Timeouts under load", - "body": "Requests hang after ~200 concurrent users." - }))) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(path("/repos/acme/api/issues/9/comments")) - .respond_with(ResponseTemplate::new(201).set_body_json(json!({}))) - .mount(&server) - .await; - - let dir = tempfile::tempdir().unwrap(); - postil() - .current_dir(dir.path()) - .env("POSTIL_API_BASE", server.uri()) - .env("GITHUB_API_URL", server.uri()) - .env("GITHUB_TOKEN", "gh-test-token") - .args([ - "respond", - "--publish", - "--repo", - "acme/api", - "--issue", - "9", - "--comment", - "@postil what do you think is happening?", - ]) - .assert() - .success(); - - let reqs = server.received_requests().await.unwrap(); - // The model was given the issue body as grounding. - let llm = reqs - .iter() - .find(|r| r.url.path() == "/chat/completions") - .unwrap(); - let sent: Value = llm.body_json().unwrap(); - let user_msg = sent["messages"][1]["content"].as_str().unwrap(); - assert!(user_msg.contains("200 concurrent users")); - assert!(reqs.iter().any(|r| { - r.method == wiremock::http::Method::POST - && r.url.path() == "/repos/acme/api/issues/9/comments" - })); -} - -#[tokio::test] -async fn respond_gitlab_mr_mention_posts_note() { - let server = MockServer::start().await; - mount_gitlab_source_files(&server).await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(respond_text( - "Line 41 interpolates `user_input` into the query; parameterize it.", - ))) - .mount(&server) - .await; - // MR metadata (title/description + diff refs) for grounding. - Mock::given(method("GET")) - .and(path_regex(r"^/projects/.+/merge_requests/5$")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "title": "Add login", - "description": "MR body", - "state": "opened", "diff_refs": {"base_sha": "b", "start_sha": "s", "head_sha": "h"} - }))) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path_regex(r"^/projects/.+/merge_requests/5/versions$")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!([{ - "state": "collected", "real_size": "1" - }]))) - .mount(&server) - .await; - // MR file diffs (paginated; one short page ends iteration). - Mock::given(method("GET")) - .and(path_regex(r"^/projects/.+/merge_requests/5/diffs$")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!([{ - "old_path": "src/auth.rs", "new_path": "src/auth.rs", - "diff": "@@ -40,6 +40,8 @@ fn login() {\n context line\n+let token = format!(\"{}\", user_input);\n+exec_query(&token);\n trailing context\n", - "new_file": false, "deleted_file": false - }]))) - .mount(&server) - .await; - // The reply note endpoint. - Mock::given(method("POST")) - .and(path_regex(r"^/projects/.+/merge_requests/5/notes$")) - .respond_with(ResponseTemplate::new(201).set_body_json(json!({}))) - .mount(&server) - .await; - - let dir = tempfile::tempdir().unwrap(); - postil() - .current_dir(dir.path()) - .env("POSTIL_API_BASE", server.uri()) - .env("GITLAB_API_URL", server.uri()) - .env("GITLAB_TOKEN", fixture_credential("gitlab")) - .args([ - "respond", - "--publish", - "--forge", - "gitlab", - "--repo", - "acme/api", - "--pr", - "5", - "--comment", - "@postil is this safe?", - ]) - .assert() - .success(); - - let reqs = server.received_requests().await.unwrap(); - let note = reqs - .iter() - .find(|r| { - r.method == wiremock::http::Method::POST - && r.url.path().ends_with("/merge_requests/5/notes") - }) - .expect("reply note posted to the MR"); - let body: Value = note.body_json().unwrap(); - let text = body["body"].as_str().unwrap(); - assert!(text.contains("parameterize")); - assert!(!text.contains("Postil ·")); -} - -#[tokio::test] -async fn gitlab_diff_pagination_follows_authoritative_next_page_to_exhaustion() { - let server = MockServer::start().await; - mount_gitlab_source_files(&server).await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(respond_text( - "The late-page change is included in the review context.", - ))) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path_regex(r"^/projects/.+/merge_requests/6$")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "title": "Paginated change", - "description": "", - "state": "opened", "diff_refs": {"base_sha": "b", "start_sha": "s", "head_sha": "h"} - }))) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path_regex(r"^/projects/.+/merge_requests/6/versions$")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!([{ - "state": "collected", "real_size": "101" - }]))) + Mock::given(method("GET")) + .and(path_regex(r"^/projects/.+/merge_requests/6/versions$")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!([{ + "state": "collected", "real_size": "101" + }]))) .mount(&server) .await; let first_page: Vec = (0..100) @@ -14082,248 +13571,26 @@ async fn gitlab_diff_pagination_follows_authoritative_next_page_to_exhaustion() .env("GITLAB_API_URL", server.uri()) .env("GITLAB_TOKEN", fixture_credential("gitlab")) .args([ - "respond", - "--forge", - "gitlab", - "--repo", - "acme/api", - "--pr", - "6", - "--comment", - "@postil review this", + "review", "--forge", "gitlab", "--repo", "acme/api", "--pr", "6", ]) .assert() .success(); let requests = server.received_requests().await.unwrap(); - let model = requests + let reviewed_last_page = requests .iter() - .find(|request| request.url.path() == "/chat/completions") - .unwrap(); - let body: Value = model.body_json().unwrap(); - let model_context = body["messages"][1]["content"].as_str().unwrap(); + .filter(|request| request.url.path() == "/chat/completions") + .any(|request| { + let body: Value = request.body_json().unwrap(); + body["messages"][1]["content"] + .as_str() + .is_some_and(|context| context.contains("AUTHORITATIVE_LAST_PAGE")) + }); assert!( - model_context.contains("AUTHORITATIVE_LAST_PAGE"), - "final paginated evidence missing from bounded context: {model_context}" + reviewed_last_page, + "final paginated evidence was missing from every review batch" ); } -#[tokio::test] -async fn respond_gitlab_issue_mention_uses_issue_body() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(respond_text( - "This looks like connection-pool exhaustion under load, not a logic bug.", - ))) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path_regex(r"^/projects/.+/issues/9$")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "title": "Timeouts under load", - "description": "Requests hang after ~200 concurrent users." - }))) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(path_regex(r"^/projects/.+/issues/9/notes$")) - .respond_with(ResponseTemplate::new(201).set_body_json(json!({}))) - .mount(&server) - .await; - - let dir = tempfile::tempdir().unwrap(); - postil() - .current_dir(dir.path()) - .env("POSTIL_API_BASE", server.uri()) - .env("GITLAB_API_URL", server.uri()) - .env("GITLAB_TOKEN", fixture_credential("gitlab")) - .args([ - "respond", - "--publish", - "--forge", - "gitlab", - "--repo", - "acme/api", - "--issue", - "9", - "--comment", - "@postil what is happening?", - ]) - .assert() - .success(); - - let reqs = server.received_requests().await.unwrap(); - // The model was grounded on the issue body. - let llm = reqs - .iter() - .find(|r| r.url.path() == "/chat/completions") - .unwrap(); - let sent: Value = llm.body_json().unwrap(); - let user_msg = sent["messages"][1]["content"].as_str().unwrap(); - assert!(user_msg.contains("200 concurrent users")); - // The reply landed on the issue notes endpoint, not the MR one. - assert!(reqs.iter().any(|r| { - r.method == wiremock::http::Method::POST && r.url.path().ends_with("/issues/9/notes") - })); -} - -#[tokio::test] -async fn respond_bitbucket_pr_mention_posts_comment() { - let server = MockServer::start().await; - mount_bitbucket_complete_diff(&server).await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(respond_text( - "Line 41 interpolates `user_input`; that is the injection risk.", - ))) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path("/repositories/acme/api/pullrequests/7")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "title": "Add login", - "summary": {"raw": "PR body"}, - "state": "OPEN", "source": {"commit": {"hash": "aaaaaaaa"}}, - "destination": {"commit": {"hash": "bbbbbbbb"}} - }))) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path("/repositories/acme/api/pullrequests/7/diff")) - .respond_with(ResponseTemplate::new(200).set_body_string(DIFF)) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(path("/repositories/acme/api/pullrequests/7/comments")) - .respond_with(ResponseTemplate::new(201).set_body_json(json!({}))) - .mount(&server) - .await; - - let dir = tempfile::tempdir().unwrap(); - postil() - .current_dir(dir.path()) - .env("POSTIL_API_BASE", server.uri()) - .env("BITBUCKET_API_URL", server.uri()) - .env("BITBUCKET_TOKEN", "bb-test-token") - .env_remove("BITBUCKET_USER") - .args([ - "respond", - "--publish", - "--forge", - "bitbucket", - "--repo", - "acme/api", - "--pr", - "7", - "--comment", - "@postil is this safe?", - ]) - .assert() - .success(); - - let reqs = server.received_requests().await.unwrap(); - let comment = reqs - .iter() - .find(|r| { - r.method == wiremock::http::Method::POST - && r.url.path() == "/repositories/acme/api/pullrequests/7/comments" - }) - .expect("reply comment posted to the PR"); - let body: Value = comment.body_json().unwrap(); - let text = body["content"]["raw"].as_str().unwrap(); - assert!(text.contains("injection risk")); - assert!(!text.contains("Postil ·")); -} - -#[tokio::test] -async fn respond_azure_pr_mention_posts_thread() { - let server = MockServer::start().await; - mount_azure_merge_base(&server).await; - let old_content = "fn login() {\n let token = sanitize(user_input);\n}\n"; - let new_content = "fn login() {\n let token = user_input;\n}\n"; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(respond_text( - "Line 2 drops the sanitize() call; that is the risk.", - ))) - .mount(&server) - .await; - let pr_path = "/myorg/myproj/_apis/git/repositories/myrepo/pullRequests/7"; - Mock::given(method("GET")) - .and(path(pr_path)) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "title": "Add login", "description": "PR body", - "status": "active", "lastMergeSourceCommit": {"commitId": "HEAD"}, - "lastMergeTargetCommit": {"commitId": "BASE"} - }))) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path( - "/myorg/myproj/_apis/git/repositories/myrepo/diffs/commits", - )) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "changes": [{"item": {"path": "/src/auth.rs", "isFolder": false}, "changeType": "edit"}], - "allChangesIncluded": true - }))) - .mount(&server) - .await; - let items_path = "/myorg/myproj/_apis/git/repositories/myrepo/items"; - Mock::given(method("GET")) - .and(path(items_path)) - .and(query_param("version", "BASE")) - .respond_with(ResponseTemplate::new(200).set_body_string(old_content)) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path(items_path)) - .and(query_param("version", "HEAD")) - .respond_with(ResponseTemplate::new(200).set_body_string(new_content)) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(path( - "/myorg/myproj/_apis/git/repositories/myrepo/pullRequests/7/threads", - )) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({}))) - .mount(&server) - .await; - - let dir = tempfile::tempdir().unwrap(); - postil() - .current_dir(dir.path()) - .env("POSTIL_API_BASE", server.uri()) - .env("AZURE_DEVOPS_API_URL", server.uri()) - .env("AZURE_DEVOPS_TOKEN", "az-test-pat") - .args([ - "respond", - "--publish", - "--forge", - "azure", - "--repo", - "myorg/myproj/myrepo", - "--pr", - "7", - "--comment", - "@postil is this safe?", - ]) - .assert() - .success(); - - let reqs = server.received_requests().await.unwrap(); - let thread = reqs - .iter() - .find(|r| { - r.method == wiremock::http::Method::POST - && r.url.path().ends_with("/pullRequests/7/threads") - }) - .expect("reply thread posted to the PR"); - let body: Value = thread.body_json().unwrap(); - let text = body["comments"][0]["content"].as_str().unwrap(); - assert!(text.contains("risk")); - assert!(!text.contains("Postil ·")); -} - #[test] fn init_writes_starter_and_config_shows_provenance() { let dir = tempfile::tempdir().unwrap(); From 7b9a6130e309318f488e43e938623d9177ec54af Mon Sep 17 00:00:00 2001 From: Postil Maintainer Date: Tue, 25 Aug 2026 03:31:46 +0000 Subject: [PATCH 4/7] Make local review batteries included --- README.md | 4 +- bench/baseline.json | 2 +- bench/src/livemodels.test.ts | 78 ++-- bench/src/livemodels.ts | 16 +- bench/src/verify-admission.test.ts | 21 ++ bench/src/verify-admission.ts | 11 + docs/automation.md | 2 + docs/configuration.md | 1 + docs/model-providers.md | 4 +- src/api_key.rs | 2 +- src/brevity.rs | 4 +- src/cli.rs | 48 ++- src/config.rs | 23 +- src/forge/azure.rs | 8 +- src/forge/bitbucket.rs | 8 +- src/forge/gitlab.rs | 8 +- src/lib.rs | 6 + src/llm.rs | 32 +- src/local.rs | 564 +++++++++++++++++++++++----- src/main.rs | 57 ++- src/progress.rs | 94 ++++- src/resolve.rs | 12 +- src/review.rs | 313 +++++++++++----- tests/e2e.rs | 577 ++++++++++++++++++++++++----- 24 files changed, 1496 insertions(+), 399 deletions(-) diff --git a/README.md b/README.md index 0e6e0e4..ceac62b 100644 --- a/README.md +++ b/README.md @@ -33,9 +33,9 @@ postil review --bounded --base origin/main # cap large reviews at five source ba postil hook install # add a pre-push review ``` -Bare local review selects staged changes first, then committed changes since the current branch's locally known default branch, then tracked working-tree changes, and finally an empty clean diff. It sends the selected diff to the configured inference endpoint, but does not modify the working tree, index, or refs and does not write comments or checks to a forge unless `--publish` is supplied. +Bare local review selects staged changes first, then committed changes since the current branch's locally known default branch, then tracked working-tree changes, and finally an empty clean diff. Symbolic remote HEAD plus conventional `main`, `master`, and `trunk` refs are recognized without fetching. If no default branch can be resolved for a repository with commits, Postil fails closed and asks for `--base`, `--staged`, or `--diff-file` instead of reporting a partial review as clean. It sends the selected diff to the configured inference endpoint, but does not modify the working tree, index, or refs and does not write comments or checks to a forge unless `--publish` is supplied. -`postil models` lists the embedded tested preset, reasoning defaults, accepted effort values, and exact override syntax. The reviewer defaults to `low` reasoning effort and the scorer defaults to `none`. `postil review` exits `0` when the gate passes, `1` when it fails, and `2` when it cannot produce a review envelope. +`postil models` answers model-selection questions offline without reading credentials or contacting a provider. No model setting is required: it identifies the embedded default, explains the model-ID contracts for OpenAI-compatible and native Anthropic endpoints, separates local protocol compatibility from hosted qualification, and gives exact `postil doctor` and override commands. The Luna reviewer uses `low` reasoning effort. Local scoring stays disabled until `REVIEW_SCORER_MODEL` names a scorer; the hosted scorer candidate uses `none`. `postil review` exits `0` when the gate passes, `1` when it fails, and `2` when it cannot produce a review envelope. Interactive terminals show concise progress; `--no-progress` or `POSTIL_NO_PROGRESS=1` replaces animation with concise static milestones, while `--verbose` enables complete provider, retry, and batch telemetry. ## Review a pull request diff --git a/bench/baseline.json b/bench/baseline.json index 63f5b0c..ae61525 100644 --- a/bench/baseline.json +++ b/bench/baseline.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "corpus": { "fixtureCorpusSha256": "8e4c2cb9ad5a7efdfe6a875566d20133e905155b6f693a873595adf6c069e065", - "evaluatorSha256": "ebc72a8d7d60c08bf79759a0dfc5dbf821a9e6f79f4503c08ea9660dddece4d7" + "evaluatorSha256": "afadb5b9097433000a0c0ee9a81ccb23ecd01cfeb92829421f3e1acb68015c0d" }, "profiles": { "z-ai/glm-5.2": { diff --git a/bench/src/livemodels.test.ts b/bench/src/livemodels.test.ts index f8c9acd..c7db75f 100644 --- a/bench/src/livemodels.test.ts +++ b/bench/src/livemodels.test.ts @@ -610,57 +610,66 @@ describe("pair qualification configuration", () => { }); - test("rejects runtime-shaped exact pair exposure above the hard cap", async () => { + test("isolates runtime-shaped preflight and routes any provider escape to loopback", async () => { const root = await mkdtemp(resolve(tmpdir(), "postil-runtime-preflight-")); - const cases = fixtureInputs.map((input) => benchmarkCase.parse(input)); - const inheritedModelKey = process.env.MODEL_API_KEY; - const runtimePair: QualificationPair = { - generatorModel: "z-ai/glm-5.2", - scorerModel: "openai/gpt-5-mini", - }; - // Priced an order of magnitude above any admissible profile so the plan is - // refused on projected exposure. Realistic bounds project inside the - // admission ceiling and would exercise the happy path instead. + const binary = resolve(root, "fixture-postil"); + const cases = fixtureInputs.slice(0, 2).map((input) => benchmarkCase.parse(input)); + const runtimePair = normalizeQualificationPairs([pair])[0]!; const pricing = new Map([ [runtimePair.generatorModel, { providerIdentity: "PinnedProvider", - promptUsdPerToken: 0.0028434, - completionUsdPerToken: 0.0089364, - inputMicrosPerMillionTokens: 2_843_400, - outputMicrosPerMillionTokens: 8_936_400, + promptUsdPerToken: 0.000001, + completionUsdPerToken: 0.000002, + inputMicrosPerMillionTokens: 1_000_000, + outputMicrosPerMillionTokens: 2_000_000, }], [runtimePair.scorerModel, { providerIdentity: "PinnedProvider", - promptUsdPerToken: 0.0028434, - completionUsdPerToken: 0.0089364, - inputMicrosPerMillionTokens: 2_843_400, - outputMicrosPerMillionTokens: 8_936_400, + promptUsdPerToken: 0.000001, + completionUsdPerToken: 0.000002, + inputMicrosPerMillionTokens: 1_000_000, + outputMicrosPerMillionTokens: 2_000_000, }], ]); + await writeFile(binary, `#!/usr/bin/env node +const fail = (message) => { console.error(message); process.exit(97); }; +if (process.env.POSTIL_QUALIFICATION_PLAN_ONLY !== "1") fail("plan-only mode was not set"); +if (process.env.POSTIL_QUALIFICATION_CAPTURE_API_BASE !== "http://127.0.0.1:9") { + fail("plan-only preflight escaped loopback capture"); +} +if (process.env.POSTIL_API_KEY !== "postil-plan-only-fixture") { + fail("the explicit fixture credential was not forwarded"); +} +for (const name of ["MODEL_API_KEY", "LLM_API_KEY", "OPENROUTER_API_KEY", "POSTIL_ENDPOINT_AUTH_VALUE"]) { + if (process.env[name] !== undefined) fail("ambient credential reached plan-only preflight: " + name); +} +console.log(JSON.stringify({ + version: 1, summary: "", silent: true, findings: [], resolved: [], + counts: { info: 0, warn: 0, error: 0, suppressed: 0, ungrounded: 0 }, + confidenceBuckets: [0, 0, 0, 0, 0], + gate: { failOn: "error", failing: false, blockOnKinds: [] }, + modelUsed: "none (qualification plan)", usage: { promptTokens: 0, completionTokens: 0 }, + reviewCoverage: { mode: "exhaustive", selectedBatches: 1, totalBatches: 1 }, + reviewAdmission: { providerAttempts: 6, serializedInputBytes: 5000, outputTokens: 180, projectedCostMicros: 1000000 }, + durationMs: 0, baseSha: null, headSha: null, sinceSha: null +})); +`); + await chmod(binary, 0o700); try { - process.env.MODEL_API_KEY = "postil-plan-only-fixture"; - const cargoTarget = process.env.CARGO_TARGET_DIR; - const binary = process.env.POSTIL_BIN === undefined - ? cargoTarget === undefined - ? resolve(import.meta.dir, "..", "..", "target", "release", "postil") - : resolve(cargoTarget, "release", "postil") - : resolve(process.env.POSTIL_BIN); - const normalizedPair = normalizeQualificationPairs([runtimePair])[0]!; await expect(assertRuntimeShapedQualificationPreflight({ binary, rootDir: root, cases, - pairs: [normalizedPair], - repeats: 9, + pairs: [runtimePair], + repeats: 3, pricing, apiBase: normalizeApiBase("https://openrouter.ai/api/v1"), apiFormat: "openai-compatible", - costCapUsdDecimal: "70", + costCapUsdDecimal: "1", upstreamProvider: "PinnedProvider", - })).rejects.toThrow("micro-dollar admission projection cap"); + credentialEnvironment: { POSTIL_API_KEY: "postil-plan-only-fixture" }, + })).rejects.toThrow("runtime-shaped qualification spend"); } finally { - if (inheritedModelKey === undefined) delete process.env.MODEL_API_KEY; - else process.env.MODEL_API_KEY = inheritedModelKey; await rm(root, { recursive: true, force: true }); } }, 60_000); @@ -669,7 +678,6 @@ describe("pair qualification configuration", () => { const root = await mkdtemp(resolve(tmpdir(), "postil-runtime-preflight-failure-")); const binary = resolve(root, "fixture-postil"); const startsPath = resolve(root, "starts.log"); - const inheritedModelKey = process.env.MODEL_API_KEY; await writeFile(binary, `#!/usr/bin/env node import { appendFileSync, closeSync, openSync } from "node:fs"; import { resolve } from "node:path"; @@ -697,7 +705,6 @@ process.exit(0); `); await chmod(binary, 0o700); try { - process.env.MODEL_API_KEY = "postil-plan-only-fixture"; const cases = fixtureInputs.slice(0, 8).map((input) => benchmarkCase.parse(input)); const pricing = new Map([ [pair.generatorModel, { @@ -726,6 +733,7 @@ process.exit(0); apiFormat: "openai-compatible", costCapUsdDecimal: "55", upstreamProvider: "PinnedProvider", + credentialEnvironment: { POSTIL_API_KEY: "postil-plan-only-fixture" }, })).rejects.toThrow("deliberate preflight failure"); const starts = (await readFile(startsPath, "utf8")).trim().split("\n"); const finishes = (await readFile(resolve(root, "finishes.log"), "utf8")) @@ -738,8 +746,6 @@ process.exit(0); expect(finishes.every((identity) => starts.includes(identity))).toBe(true); await expect(lstat(resolve(root, "preflight"))).rejects.toThrow(); } finally { - if (inheritedModelKey === undefined) delete process.env.MODEL_API_KEY; - else process.env.MODEL_API_KEY = inheritedModelKey; await rm(root, { recursive: true, force: true }); } }, 20_000); diff --git a/bench/src/livemodels.ts b/bench/src/livemodels.ts index 5d399ae..bf34591 100644 --- a/bench/src/livemodels.ts +++ b/bench/src/livemodels.ts @@ -97,6 +97,7 @@ export const QUALIFICATION_MAX_AGE_DAYS = 30; export const QUALIFICATION_MAX_AGE_SECONDS = QUALIFICATION_MAX_AGE_DAYS * 24 * 60 * 60; const MAX_QUALIFICATION_SOURCE_BYTES = 16 * 1024 * 1024; const MANAGED_OPENROUTER_API_BASE = "https://openrouter.ai:443/api/v1"; +const PLAN_ONLY_CAPTURE_API_BASE = "http://127.0.0.1:9"; export const MANAGED_OPENROUTER_PROVIDER_IDENTITY = "openrouter:managed-routing"; export const LIVE_MODELS_REPORT_SCHEMA_VERSION = 3; export const LIVE_MODELS_PRIVATE_EVIDENCE_SCHEMA_VERSION = 1; @@ -1507,6 +1508,7 @@ export async function assertRuntimeShapedQualificationPreflight(args: { apiFormat: "openai-compatible" | "anthropic"; costCapUsdDecimal: string; upstreamProvider: string; + credentialEnvironment?: NodeJS.ProcessEnv; }): Promise { let projectedMicros = 0n; const planRoot = join(args.rootDir, "preflight"); @@ -1551,6 +1553,8 @@ export async function assertRuntimeShapedQualificationPreflight(args: { args.apiBase, args.apiFormat, profilePath, + PLAN_ONLY_CAPTURE_API_BASE, + args.credentialEnvironment, ); env.POSTIL_QUALIFICATION_PLAN_ONLY = "1"; const { stdout } = await execFile( @@ -1774,6 +1778,7 @@ export function liveEnv( apiFormat: "openai-compatible" | "anthropic" = "openai-compatible", candidateProfilePath?: string, qualificationCaptureApiBase?: string, + sourceEnvironment: NodeJS.ProcessEnv = process.env, ): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = { PATH: process.env.PATH, @@ -1809,12 +1814,12 @@ export function liveEnv( } env.POSTIL_QUALIFICATION_CAPTURE_API_BASE = qualificationCaptureApiBase; } - const endpointAuth = endpointAuthFromEnvironment(apiFormat); + const endpointAuth = endpointAuthFromEnvironment(apiFormat, sourceEnvironment); if (endpointAuth) { env.POSTIL_ENDPOINT_AUTH_HEADER = endpointAuth.header; env.POSTIL_ENDPOINT_AUTH_VALUE = endpointAuth.value; } - const allowPrivate = process.env.POSTIL_ALLOW_PRIVATE_API_BASE; + const allowPrivate = sourceEnvironment.POSTIL_ALLOW_PRIVATE_API_BASE; if (allowPrivate !== undefined) { if (allowPrivate !== "1" && allowPrivate.toLowerCase() !== "true") { throw new Error("POSTIL_ALLOW_PRIVATE_API_BASE must be 1 or true when set"); @@ -1824,7 +1829,7 @@ export function liveEnv( // Forward the selected inference-key variable without logging or placing the // value on argv. Neutral aliases are also mirrored into POSTIL_API_KEY so // older binaries can run from the same benchmark harness. - forwardApiKey(env); + forwardApiKey(env, sourceEnvironment); return env; } @@ -2423,9 +2428,10 @@ export function qualificationRequiredParameters( export function endpointAuthFromEnvironment( apiFormat: "openai-compatible" | "anthropic", + sourceEnvironment: NodeJS.ProcessEnv = process.env, ): { header: string; value: string } | null { - const rawHeader = process.env.POSTIL_ENDPOINT_AUTH_HEADER; - const rawValue = process.env.POSTIL_ENDPOINT_AUTH_VALUE; + const rawHeader = sourceEnvironment.POSTIL_ENDPOINT_AUTH_HEADER; + const rawValue = sourceEnvironment.POSTIL_ENDPOINT_AUTH_VALUE; const header = rawHeader?.trim() || undefined; const value = rawValue === "" ? undefined : rawValue; if (header === undefined && value === undefined) return null; diff --git a/bench/src/verify-admission.test.ts b/bench/src/verify-admission.test.ts index 61cfdef..5b41b23 100644 --- a/bench/src/verify-admission.test.ts +++ b/bench/src/verify-admission.test.ts @@ -61,6 +61,27 @@ describe("admission attestation verification", () => { )).toBe("provisional"); }); + test("binds reviewer and scorer reasoning efforts to the model-defaults digest", async () => { + const repositoryRoot = join(import.meta.dir, "..", ".."); + const original = await readFile(join(repositoryRoot, "config.toml"), "utf8"); + for (const altered of [ + original.replace('reasoning_effort = "low"', 'reasoning_effort = "high"'), + original.replace('reasoning_effort = "none"', 'reasoning_effort = "low"'), + ]) { + const directory = await temporaryDirectory(); + const manifest = join(directory, "qualified-models.json"); + const config = join(directory, "config.toml"); + await writeFile(manifest, await readFile(join(repositoryRoot, "qualified-models.json"))); + await writeFile(config, altered); + + await expect(verifyProvisionalRelease( + manifest, + config, + join(repositoryRoot, "provisional-models.json"), + )).rejects.toThrow("empty qualification manifest does not match embedded model defaults"); + } + }); + test("rejects duplicate models within either provisional chain", async () => { const repositoryRoot = join(import.meta.dir, "..", ".."); const directory = await temporaryDirectory(); diff --git a/bench/src/verify-admission.ts b/bench/src/verify-admission.ts index 3f17034..bb9fbc2 100644 --- a/bench/src/verify-admission.ts +++ b/bench/src/verify-admission.ts @@ -36,10 +36,20 @@ const boundedIdentifierSchema = z.string().trim().min(1).refine( "identifier must not contain line breaks", ); const optionalIdentifierSchema = z.union([z.literal(""), boundedIdentifierSchema]); +const reasoningEffortSchema = z.enum([ + "max", + "xhigh", + "high", + "medium", + "low", + "minimal", + "none", +]); const modelDefaultsSchema = z.object({ version: z.number().int().positive(), default_model: optionalIdentifierSchema, + reasoning_effort: reasoningEffortSchema, cascade: z.array(boundedIdentifierSchema), consensus: z.number().int().positive(), api_base: z.literal("https://openrouter.ai/api/v1"), @@ -47,6 +57,7 @@ const modelDefaultsSchema = z.object({ scorer: z.object({ enabled: z.boolean(), default_model: optionalIdentifierSchema, + reasoning_effort: reasoningEffortSchema, fallback: optionalIdentifierSchema, qualification_candidates: z.array(boundedIdentifierSchema), }).strict(), diff --git a/docs/automation.md b/docs/automation.md index 31c876a..230cd8e 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -8,6 +8,8 @@ The JSON and YAML envelope contains the summary, findings, resolved findings, po `--sarif ` writes SARIF 2.1.0 for code-scanning ingestion. +Machine data written to stdout never includes progress controls. Interactive terminals collapse routine telemetry into concise progress only when stdout is human-readable. `--output-file` and `--sarif` keep that human progress because their machine artifacts go to files. `--no-progress` or `POSTIL_NO_PROGRESS=1` replaces animation with concise static milestones. `--verbose` enables complete provider, retry, and batch telemetry. Non-TTY and machine-output contexts retain complete telemetry automatically. + ## Incremental review ```sh diff --git a/docs/configuration.md b/docs/configuration.md index 0cfbc0b..5619b5e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -67,6 +67,7 @@ Ignore patterns remove matching paths before grounding, batching, and large-revi | `REVIEW_SCORER_MODEL_CASCADE` | One scorer fallback model | | `POSTIL_UNCERTAINTY_RESOLUTION` | Override uncertainty resolution with `true`/`false` or `1`/`0` | | `POSTIL_CONCISE_FINDINGS` | Override concise findings with `true`/`false` or `1`/`0` | +| `POSTIL_NO_PROGRESS` | Disable animated progress and retain concise human milestones when set | | `POSTIL_LLM_REQUEST_TIMEOUT_SECS` | Per-attempt model request timeout; defaults to 480 seconds | | `POSTIL_LLM_TOTAL_TIMEOUT_SECS` | Optional total local-review model deadline | | `POSTIL_DETAILS_URL` | HTTP(S) details link for forge check runs | diff --git a/docs/model-providers.md b/docs/model-providers.md index 0d89622..e023bcd 100644 --- a/docs/model-providers.md +++ b/docs/model-providers.md @@ -4,7 +4,7 @@ Postil speaks either an OpenAI-compatible chat-completions interface or the nati ## Model admission -Local review defaults to the embedded, tested `openai/gpt-5.6-luna` preset with no fallback chain or scorer. Reviewer requests use `low` reasoning effort and scorer-role requests use `none`. `postil models` prints the tested preset, accepted effort values, and exact override syntax. Set `REVIEW_MODEL` to override the generator and `REVIEW_MODEL_CASCADE` to add explicit fallbacks. Scoring is disabled unless `REVIEW_SCORER_MODEL` names a scorer. Hosted deployments continue to admit only complete qualified profiles. `REVIEW_SCORER_MODEL_CASCADE` accepts one qualified scorer fallback. +Local review defaults to the embedded `openai/gpt-5.6-luna` reviewer with no fallback chain or scorer, so no model setting is required. Reviewer requests use `low` reasoning effort. Local scoring is disabled unless `REVIEW_SCORER_MODEL` names a scorer. The embedded hosted scorer candidate is `openai/gpt-5.6-luna` at `none` effort. `postil models` reports these roles, the accepted endpoint model-ID contracts, embedded qualification status, accepted effort values, and exact `postil doctor` and override commands without reading credentials or contacting a provider. Local use has no fixed model-ID allowlist: Postil passes a non-empty model ID unchanged to the configured OpenAI-compatible or native Anthropic endpoint. Endpoint protocol compatibility does not imply hosted qualification. Hosted deployments admit only complete qualified profiles. `REVIEW_SCORER_MODEL_CASCADE` accepts one qualified scorer fallback. Hosted deployments admit only complete profiles listed in the embedded `qualified-models.json` qualification manifest. A profile binds the canonical API base, provider API format, ordered generator chain, consensus width, ordered scorer chain, exact Bun evaluator runtime, qualification source commit, a 30-day authority window, at least three complete runs, and SHA-256 digests for the review contract, evaluator contract, fixture set, and benchmark report. The manifest also binds the exact `config.toml` digest, including the reviewer and scorer reasoning defaults. Hosted configuration must match one unexpired profile exactly, so an effort override that differs from those qualified defaults is rejected. The expiry second is excluded. An empty, degraded, stale, or mismatched profile rejects inference. @@ -45,7 +45,7 @@ export REVIEW_MODEL=provider-qualified-anthropic-model postil doctor ``` -Native Anthropic requests use `x-api-key` and `anthropic-version`. Reviewer and scorer values from `max` through `low` map to `output_config.effort`; `none` maps to disabled thinking. The Anthropic request format has no `minimal` mapping, so Postil rejects that value locally before provider access. Individual Anthropic models can support a subset of the remaining effort values. Set `REVIEW_SCORER_MODEL` to a model available through the same endpoint when scoring is required. +Native Anthropic requests use `x-api-key` and `anthropic-version`. Reviewer and scorer values from `max` through `low` map to `output_config.effort`; incompatible non-default temperature values are omitted from those requests. `none` maps to disabled thinking and retains the request temperature. The Anthropic request format has no `minimal` mapping, so Postil rejects that value locally before provider access. Individual Anthropic models can support a subset of the remaining effort values. Set `REVIEW_SCORER_MODEL` to a model available through the same endpoint when scoring is required. ## Local endpoints diff --git a/src/api_key.rs b/src/api_key.rs index b0625ae..dcf99d5 100644 --- a/src/api_key.rs +++ b/src/api_key.rs @@ -24,7 +24,7 @@ pub(crate) fn names_text() -> String { pub(crate) fn credential_help() -> String { format!( - "default model is {}; run `postil login` for hosted inference or set one of {}; see `postil models` for tested presets and override syntax", + "default model is {}; run `postil login` for hosted inference or set one of {}; see `postil models` for supported model IDs, embedded defaults, qualification status, and override syntax", crate::config::default_model(), names_text() ) diff --git a/src/brevity.rs b/src/brevity.rs index 5eb8941..66b1d0a 100644 --- a/src/brevity.rs +++ b/src/brevity.rs @@ -60,7 +60,9 @@ pub(crate) async fn compress_findings( pass.model_incidents .extend_from_slice(error.model_incidents()); pass.usage_accounting_complete &= error.usage_accounting_complete(); - eprintln!("postil: finding compression failed open and kept the original body"); + crate::progress::notice(format_args!( + "postil: finding compression failed open and kept the original body" + )); None } }; diff --git a/src/cli.rs b/src/cli.rs index 54b9c37..2a39d25 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -78,7 +78,7 @@ pub enum Command { }, /// Review a diff: a PR/MR on a forge, or local changes. #[command( - after_help = "Examples:\n postil review\n postil review --staged\n postil review --base origin/main\n\nBare local review selects, in order: staged changes; committed changes since the current branch remote's default branch; tracked working-tree changes; then an empty clean diff. Explicit --staged, --base, and --diff-file keep their exact behavior.\n\nUse `postil models` to see tested presets and override syntax." + after_help = "Examples:\n postil review\n postil review --staged\n postil review --base origin/main\n\nBare local review selects, in order: staged changes; committed changes since the current branch remote's default branch; tracked working-tree changes; then an empty clean diff. Locally known symbolic remote HEAD, main, master, and trunk refs are recognized without fetching. If no default branch can be resolved, Postil fails closed and asks for --base, --staged, or --diff-file. Explicit source flags are mutually exclusive and keep their exact behavior.\n\nUse `postil models` to see supported model-ID contracts, embedded defaults, qualification, and override syntax." )] Review { /// Code host for remote review. Inferred as github when --repo is set. @@ -97,13 +97,13 @@ pub enum Command { #[arg(long)] base_sha: Option, /// Review staged changes (git diff --cached). - #[arg(long)] + #[arg(long, conflicts_with_all = ["base", "diff_file"])] staged: bool, /// Review changes since a base ref (git diff base...HEAD). - #[arg(long)] + #[arg(long, conflicts_with_all = ["staged", "diff_file"])] base: Option, /// Review a unified diff from a file. - #[arg(long)] + #[arg(long, conflicts_with_all = ["staged", "base"])] diff_file: Option, /// Existing advisory check-run id to complete (hosted callers). #[arg(long)] @@ -147,6 +147,9 @@ pub enum Command { /// Keep detailed provider, retry, and batch telemetry in interactive terminals. #[arg(long)] verbose: bool, + /// Disable animation while keeping concise human progress milestones. + #[arg(long)] + no_progress: bool, /// Use deterministic semantic synthesis and model-assisted risk selection to cap large reviews at five source batches; report bounded coverage. #[arg(long)] bounded: bool, @@ -198,7 +201,7 @@ pub enum Command { )] publication_input_identity: Option, }, - /// List tested embedded presets and exact local override syntax. + /// Explain supported model IDs, embedded defaults, qualification, and overrides. Models, /// Replay stored envelopes under a candidate config: what would change? Plan { @@ -357,6 +360,41 @@ mod tests { assert!(help.contains("max|xhigh|high|medium|low|minimal|none")); } + #[test] + fn local_review_sources_are_mutually_exclusive() { + for arguments in [ + vec!["postil", "review", "--staged", "--base", "main"], + vec!["postil", "review", "--staged", "--diff-file", "change.diff"], + vec![ + "postil", + "review", + "--base", + "main", + "--diff-file", + "change.diff", + ], + ] { + assert!(Cli::try_parse_from(arguments).is_err()); + } + } + + #[test] + fn no_progress_keeps_concise_human_milestones() { + let parsed = Cli::try_parse_from(["postil", "review", "--no-progress"]).unwrap(); + let Command::Review { no_progress, .. } = parsed.command else { + panic!("expected review command"); + }; + assert!(no_progress); + + let help = Cli::command() + .find_subcommand_mut("review") + .expect("review subcommand") + .render_long_help() + .to_string(); + assert!(help.contains("--no-progress")); + assert!(help.contains("concise human progress milestones")); + } + #[test] fn review_publication_requires_an_explicit_flag() { let parsed = Cli::try_parse_from([ diff --git a/src/config.rs b/src/config.rs index 565c52e..6c980cd 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1192,7 +1192,7 @@ impl Config { /// Resolve config for a repo root. `explicit` (from --config) bypasses discovery. pub fn load(root: &Path, explicit: Option<&Path>) -> Result { #[cfg(test)] - let _environment_guard = tests::env_lock().lock().unwrap(); + let _environment_guard = crate::test_env_lock().lock().unwrap(); let mut cfg = Config::default(); // Login supplies a local baseline, below trusted project configuration // and environment overrides but above the embedded provider defaults. @@ -1334,9 +1334,9 @@ impl Config { } if let Some(m) = f.model { if hosted_mode { - eprintln!( + crate::progress::notice(format_args!( "postil: ignoring repository model configuration in hosted mode; hosted inference selects the provider and model roster" - ); + )); } else { if let Some(n) = m.name { self.model = n; @@ -1371,11 +1371,11 @@ impl Config { if allow_api_base { self.api_base = b; } else { - eprintln!( + crate::progress::notice(format_args!( "postil: ignoring model.apiBase from config ({b:?}); set \ POSTIL_ALLOW_CONFIG_API_BASE=1 to honor it, or use the \ POSTIL_API_BASE environment variable" - ); + )); } } if let Some(format) = m.api_format { @@ -1665,7 +1665,7 @@ impl Config { } anyhow::ensure!( !generator_chain.is_empty(), - "no review model is configured; remove the empty override to use default {}, or run `postil models` for tested presets and override syntax", + "no review model is configured; remove the empty override to use default {}, or run `postil models` for supported model IDs, qualification status, and override syntax", default_model() ); if hosted { @@ -2217,8 +2217,8 @@ pub const BUILTIN_CONTENT_POLICY: &str = include_str!("builtin-content-policy.md #[cfg(test)] mod tests { use super::*; + use crate::test_env_lock as env_lock; use std::io::Write; - use std::sync::Mutex; struct EnvRestore { name: &'static str, @@ -2259,11 +2259,6 @@ mod tests { } } - pub(super) fn env_lock() -> &'static Mutex<()> { - static LOCK: OnceLock> = OnceLock::new(); - LOCK.get_or_init(|| Mutex::new(())) - } - /// Pin tests that exercise local repository model configuration to the /// local runtime contract. The production wrapper intentionally enters /// hosted mode when deployment environment says so, therefore these tests @@ -2902,6 +2897,8 @@ scorer = { enabled = true, default_model = "provider/scorer", reasoning_effort = version: credentials::CREDENTIALS_VERSION, token: "pcli_fixture-not-a-real-secret".into(), expires_at: "2999-01-01T00:00:00.000Z".into(), + refresh_token: None, + refresh_expires_at: None, api_base: "https://postil.dev/api/inference/v1".into(), org: "fixture".into(), model: "hosted/model".into(), @@ -2941,6 +2938,8 @@ scorer = { enabled = true, default_model = "provider/scorer", reasoning_effort = version: credentials::CREDENTIALS_VERSION, token: "pcli_fixture-not-a-real-secret".into(), expires_at: "2999-01-01T00:00:00.000Z".into(), + refresh_token: None, + refresh_expires_at: None, api_base: "https://postil.dev/api/inference/v1".into(), org: "fixture".into(), model: "hosted/model".into(), diff --git a/src/forge/azure.rs b/src/forge/azure.rs index 16b4bc5..8aa2cee 100644 --- a/src/forge/azure.rs +++ b/src/forge/azure.rs @@ -473,7 +473,9 @@ impl Forge for Azure { return Ok(receipt); } if !self.snapshot_is_current(snapshot).await? { - eprintln!("postil: azure review delivery skipped because the pull request changed"); + crate::progress::notice(format_args!( + "postil: azure review delivery skipped because the pull request changed" + )); return Ok(receipt); } let summary = self.review_summary(envelope); @@ -536,7 +538,9 @@ impl Forge for Azure { _annotate_findings: bool, ) -> Result<()> { if !self.snapshot_is_current(snapshot).await? { - eprintln!("postil: azure status delivery skipped because the pull request changed"); + crate::progress::notice(format_args!( + "postil: azure status delivery skipped because the pull request changed" + )); return Ok(()); } let head = envelope.head_sha.clone().unwrap_or_default(); diff --git a/src/forge/bitbucket.rs b/src/forge/bitbucket.rs index ff616c6..2b66c0e 100644 --- a/src/forge/bitbucket.rs +++ b/src/forge/bitbucket.rs @@ -519,7 +519,9 @@ impl Forge for Bitbucket { return Ok(receipt); } if !self.snapshot_is_current(snapshot).await? { - eprintln!("postil: bitbucket review delivery skipped because the pull request changed"); + crate::progress::notice(format_args!( + "postil: bitbucket review delivery skipped because the pull request changed" + )); return Ok(receipt); } let summary = self.review_summary(envelope); @@ -584,7 +586,9 @@ impl Forge for Bitbucket { _annotate_findings: bool, ) -> Result<()> { if !self.snapshot_is_current(snapshot).await? { - eprintln!("postil: bitbucket status delivery skipped because the pull request changed"); + crate::progress::notice(format_args!( + "postil: bitbucket status delivery skipped because the pull request changed" + )); return Ok(()); } let head = envelope diff --git a/src/forge/gitlab.rs b/src/forge/gitlab.rs index f49ac81..bf907a0 100644 --- a/src/forge/gitlab.rs +++ b/src/forge/gitlab.rs @@ -494,7 +494,9 @@ impl Forge for GitLab { } let mr = self.mr().await?; if !mr_matches_snapshot(&mr, snapshot) { - eprintln!("postil: gitlab review delivery skipped because the merge request changed"); + crate::progress::notice(format_args!( + "postil: gitlab review delivery skipped because the merge request changed" + )); return Ok(receipt); } let summary = self.review_summary(envelope); @@ -557,7 +559,9 @@ impl Forge for GitLab { ) -> Result<()> { let current = self.mr().await?; if !mr_matches_snapshot(¤t, snapshot) { - eprintln!("postil: gitlab status delivery skipped because the merge request changed"); + crate::progress::notice(format_args!( + "postil: gitlab status delivery skipped because the merge request changed" + )); return Ok(()); } let head = envelope diff --git a/src/lib.rs b/src/lib.rs index 9d9e81f..0ddb27a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -27,3 +27,9 @@ pub(crate) mod repository_search; pub(crate) mod resolve; pub mod review; pub mod sarif; + +#[cfg(test)] +pub(crate) fn test_env_lock() -> &'static std::sync::Mutex<()> { + static LOCK: std::sync::OnceLock> = std::sync::OnceLock::new(); + LOCK.get_or_init(|| std::sync::Mutex::new(())) +} diff --git a/src/llm.rs b/src/llm.rs index 6f67edf..54ce8a2 100644 --- a/src/llm.rs +++ b/src/llm.rs @@ -5155,6 +5155,15 @@ fn apply_anthropic_reasoning(body: &mut serde_json::Value, effort: ReasoningEffo unreachable!("Anthropic minimal effort is rejected while resolving request settings") } _ => { + if body + .get("temperature") + .and_then(serde_json::Value::as_f64) + .is_some_and(|temperature| temperature != 1.0) + { + body.as_object_mut() + .expect("Anthropic request body is an object") + .remove("temperature"); + } body["output_config"] = json!({ "effort": effort.as_str() }); } } @@ -6468,7 +6477,7 @@ mod tests { } use crate::config::Config; use crate::envelope::{Kind, Severity}; - use std::sync::{Mutex, OnceLock}; + use crate::test_env_lock as env_lock; use wiremock::matchers::{body_string_contains, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -6638,11 +6647,6 @@ mod tests { } } - fn env_lock() -> &'static Mutex<()> { - static LOCK: OnceLock> = OnceLock::new(); - LOCK.get_or_init(|| Mutex::new(())) - } - #[test] fn provider_error_downcast_survives_additional_context() { // review.rs's fail-open/fail-closed classifier does @@ -9873,6 +9877,7 @@ mod tests { assert!(anthropic_body.get("provider").is_none()); assert_eq!(anthropic_body["thinking"], json!({"type": "disabled"})); assert!(anthropic_body.get("output_config").is_none()); + assert_eq!(anthropic_body["temperature"], 0.0); assert!(anthropic_body.get("response_format").is_none()); assert_eq!(anthropic_body["system"], "system"); } @@ -9938,6 +9943,20 @@ mod tests { ); assert_eq!(reviewer["output_config"], json!({"effort": "high"})); assert!(reviewer.get("thinking").is_none()); + assert!(reviewer.get("temperature").is_none()); + let reviewer_with_default_temperature = client.request_body( + "provider/model", + "system", + "user", + 100, + 1.0, + LlmPhase::Review, + ); + assert_eq!(reviewer_with_default_temperature["temperature"], 1.0); + assert_eq!( + reviewer_with_default_temperature["output_config"], + json!({"effort": "high"}) + ); let scorer = client.request_body( "provider/model", "system", @@ -9948,6 +9967,7 @@ mod tests { ); assert_eq!(scorer["thinking"], json!({"type": "disabled"})); assert!(scorer.get("output_config").is_none()); + assert_eq!(scorer["temperature"], 0.0); let error = LlmClient::build( &Config { diff --git a/src/local.rs b/src/local.rs index a11e2bb..e5e4001 100644 --- a/src/local.rs +++ b/src/local.rs @@ -4,6 +4,7 @@ use std::io::{Read, Write}; use std::process::Stdio; use anyhow::{Context, Result, anyhow}; +use sha2::{Digest, Sha256}; use tokio::process::Command; use crate::diff::{DiffSnapshot, DiffSpool}; @@ -16,17 +17,33 @@ pub enum LocalSource { /// A unified diff already on disk. DiffFile(std::path::PathBuf), /// Read-only staged selection for bare `postil review`. - AutoStaged, + AutoStaged { fence: LocalStateFence }, /// A clean branch range has an immutable HEAD revision. A dirty branch /// range includes the working tree and deliberately has no revision. AutoBranch { - base: String, + base_ref: String, + base_oid: String, include_worktree: bool, + fence: LocalStateFence, }, /// Tracked working-tree changes only, never untracked files. - WorkingTree, + WorkingTree { fence: LocalStateFence }, /// A clean repository has a valid empty review without a model request. - Clean, + Clean { fence: LocalStateFence }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LocalStateFence { + index_tree: String, + tracked_worktree_sha256: String, + tracked_worktree_dirty: bool, + untracked_paths: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct DefaultBranch { + reference: String, + oid: String, } pub struct AutoSelection { @@ -34,50 +51,69 @@ pub struct AutoSelection { pub warning: Option<&'static str>, } -/// Select the least surprising local diff without fetching, writing Git -/// objects, or changing the index. Explicit local modes bypass this selector. +/// Select the least surprising local diff without fetching or changing Git +/// refs or the index. Capturing the index may materialize its immutable tree. pub async fn auto_select( repository_root: &std::path::Path, head_sha: Option<&str>, ) -> Result { - let untracked = !git_succeeds( + let fence = capture_local_state(repository_root).await?; + let warning = (!fence.untracked_paths.trim().is_empty()).then_some( + "untracked files were not reviewed; add the files you want reviewed, then rerun `postil review`", + ); + let index_base = match head_sha { + Some(head_sha) => head_sha.to_string(), + None => empty_tree(repository_root).await?, + }; + if git_has_changes( repository_root, - &["ls-files", "--others", "--exclude-standard"], + &[ + "diff", + "--quiet", + index_base.as_str(), + fence.index_tree.as_str(), + "--", + ], ) .await? - .trim() - .is_empty(); - let warning = untracked.then_some( - "untracked files were not reviewed; add the files you want reviewed, then rerun `postil review`", - ); - if git_has_changes(repository_root, &["diff", "--cached", "--quiet", "--"]).await? { + { return Ok(AutoSelection { - source: LocalSource::AutoStaged, + source: LocalSource::AutoStaged { fence }, warning, }); } - if let (Some(base), Some(head_sha)) = (default_branch(repository_root).await?, head_sha) { - let range = format!("{base}...{head_sha}"); + let default_branch = if head_sha.is_some() { + Some(default_branch(repository_root).await?.ok_or_else(|| { + anyhow!( + "could not determine the repository default branch without fetching; rerun with `--base `, or use `--staged` or `--diff-file ` for an explicit review source" + ) + })?) + } else { + None + }; + if let (Some(base), Some(head_sha)) = (default_branch, head_sha) { + let range = format!("{}...{head_sha}", base.oid); if git_has_changes(repository_root, &["diff", "--quiet", &range, "--"]).await? { - let include_worktree = - git_has_changes(repository_root, &["diff", "--quiet", "--"]).await?; + let include_worktree = fence.tracked_worktree_dirty; return Ok(AutoSelection { source: LocalSource::AutoBranch { - base, + base_ref: base.reference, + base_oid: base.oid, include_worktree, + fence, }, warning, }); } } - if git_has_changes(repository_root, &["diff", "--quiet", "--"]).await? { + if fence.tracked_worktree_dirty { return Ok(AutoSelection { - source: LocalSource::WorkingTree, + source: LocalSource::WorkingTree { fence }, warning, }); } Ok(AutoSelection { - source: LocalSource::Clean, + source: LocalSource::Clean { fence }, warning, }) } @@ -101,56 +137,70 @@ pub async fn acquire( repository_revision: None, }), LocalSource::Staged => acquire_index_snapshot(head_sha, repository_root).await, - LocalSource::AutoStaged => { + LocalSource::AutoStaged { fence } => { acquire_automatic( repository_root, head_sha, - acquire_index_snapshot(head_sha, repository_root), + fence, + None, + acquire_index_tree_snapshot(head_sha, &fence.index_tree, repository_root), ) .await } LocalSource::Base(base) => { - let range = format!("{base}...{}", head_sha.unwrap_or("HEAD")); + let base_oid = resolve_commit_oid(repository_root, base).await?; + let range = format!("{base_oid}...{}", head_sha.unwrap_or("HEAD")); Ok(LocalReviewSnapshot { diff: git_diff(repository_root, &["diff", "--no-color", &range]).await?, repository_revision: head_sha.map(str::to_string), }) } LocalSource::AutoBranch { - base, + base_ref, + base_oid, include_worktree, + fence, } => { let head_sha = head_sha.context("automatic branch review requires a captured HEAD")?; anyhow::ensure!( crate::repository_search::valid_full_object_id(head_sha), "automatic branch review captured an invalid HEAD object id" ); - acquire_automatic(repository_root, Some(head_sha), async { - let merge_base = - git_output(repository_root, &["merge-base", base, head_sha]).await?; - let range = format!("{base}...{head_sha}"); - let diff = if *include_worktree { - git_diff( - repository_root, - &["diff", "--no-color", merge_base.as_str(), "--"], - ) - .await? - } else { - git_diff( + acquire_automatic( + repository_root, + Some(head_sha), + fence, + Some((base_ref.as_str(), base_oid.as_str())), + async { + let merge_base = git_output( repository_root, - &["diff", "--no-color", range.as_str(), "--"], + &["merge-base", base_oid.as_str(), head_sha], ) - .await? - }; - Ok(LocalReviewSnapshot { - diff, - repository_revision: (!include_worktree).then(|| head_sha.to_string()), - }) - }) + .await?; + let range = format!("{base_oid}...{head_sha}"); + let diff = if *include_worktree { + git_diff( + repository_root, + &["diff", "--no-color", merge_base.as_str(), "--"], + ) + .await? + } else { + git_diff( + repository_root, + &["diff", "--no-color", range.as_str(), "--"], + ) + .await? + }; + Ok(LocalReviewSnapshot { + diff, + repository_revision: (!include_worktree).then(|| head_sha.to_string()), + }) + }, + ) .await } - LocalSource::WorkingTree => { - acquire_automatic(repository_root, head_sha, async { + LocalSource::WorkingTree { fence } => { + acquire_automatic(repository_root, head_sha, fence, None, async { Ok(LocalReviewSnapshot { diff: git_diff(repository_root, &["diff", "--no-color", "--"]).await?, repository_revision: None, @@ -158,8 +208,8 @@ pub async fn acquire( }) .await } - LocalSource::Clean => { - acquire_automatic(repository_root, head_sha, async { + LocalSource::Clean { fence } => { + acquire_automatic(repository_root, head_sha, fence, None, async { Ok(LocalReviewSnapshot { diff: DiffSnapshot::from_bytes(b"")?, repository_revision: head_sha.map(str::to_string), @@ -173,18 +223,24 @@ pub async fn acquire( async fn acquire_automatic( repository_root: &std::path::Path, captured_head: Option<&str>, + fence: &LocalStateFence, + base: Option<(&str, &str)>, acquisition: impl std::future::Future>, ) -> Result { - ensure_captured_head( + ensure_automatic_state( repository_root, captured_head, + fence, + base, "after automatic review selection", ) .await?; let value = acquisition.await?; - ensure_captured_head( + ensure_automatic_state( repository_root, captured_head, + fence, + base, "while acquiring the automatic review", ) .await?; @@ -194,25 +250,110 @@ async fn acquire_automatic( async fn acquire_index_snapshot( head_sha: Option<&str>, repository_root: &std::path::Path, +) -> Result { + let index_tree = capture_index_tree(repository_root).await?; + acquire_index_tree_snapshot(head_sha, &index_tree, repository_root).await +} + +async fn acquire_index_tree_snapshot( + head_sha: Option<&str>, + index_tree: &str, + repository_root: &std::path::Path, ) -> Result { let base_revision = match head_sha { Some(head_sha) => head_sha.to_string(), - None => git_output(repository_root, &["mktree"]).await?, + None => empty_tree(repository_root).await?, }; - let index_tree = git_output(repository_root, &["write-tree"]).await?; - if !crate::repository_search::valid_full_object_id(&index_tree) { + if !crate::repository_search::valid_full_object_id(index_tree) { return Err(anyhow!("git write-tree returned an invalid object id")); } Ok(LocalReviewSnapshot { diff: git_diff( repository_root, - &["diff", "--no-color", &base_revision, &index_tree, "--"], + &["diff", "--no-color", &base_revision, index_tree, "--"], ) .await?, - repository_revision: Some(index_tree), + repository_revision: Some(index_tree.to_string()), + }) +} + +async fn capture_local_state(repository_root: &std::path::Path) -> Result { + let index_tree = capture_index_tree(repository_root).await?; + let tracked = git_diff( + repository_root, + &[ + "diff", + "--no-color", + "--binary", + "--full-index", + "--no-ext-diff", + "--no-textconv", + "--", + ], + ) + .await?; + let tracked_worktree_dirty = !tracked.as_bytes().is_empty(); + let tracked_worktree_sha256 = Sha256::digest(tracked.as_bytes()) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + let untracked_paths = git_succeeds( + repository_root, + &["ls-files", "--others", "--exclude-standard"], + ) + .await?; + Ok(LocalStateFence { + index_tree, + tracked_worktree_sha256, + tracked_worktree_dirty, + untracked_paths, }) } +async fn capture_index_tree(repository_root: &std::path::Path) -> Result { + let index_tree = git_output(repository_root, &["write-tree"]).await?; + anyhow::ensure!( + crate::repository_search::valid_full_object_id(&index_tree), + "git write-tree returned an invalid object id" + ); + Ok(index_tree) +} + +async fn empty_tree(repository_root: &std::path::Path) -> Result { + git_output(repository_root, &["mktree"]).await +} + +async fn ensure_automatic_state( + repository_root: &std::path::Path, + captured_head: Option<&str>, + fence: &LocalStateFence, + base: Option<(&str, &str)>, + timing: &str, +) -> Result<()> { + ensure_captured_head(repository_root, captured_head, timing).await?; + if let Some((base_ref, base_oid)) = base { + let current_base = resolve_commit_oid(repository_root, base_ref).await.ok(); + anyhow::ensure!( + current_base.as_deref() == Some(base_oid), + "default branch {base_ref} changed {timing}; rerun `postil review`" + ); + } + let current = capture_local_state(repository_root).await?; + anyhow::ensure!( + current.index_tree == fence.index_tree, + "index changed {timing}; rerun `postil review`" + ); + anyhow::ensure!( + current.tracked_worktree_sha256 == fence.tracked_worktree_sha256, + "tracked working tree changed {timing}; rerun `postil review`" + ); + anyhow::ensure!( + current.untracked_paths == fence.untracked_paths, + "untracked file set changed {timing}; rerun `postil review`" + ); + Ok(()) +} + async fn ensure_captured_head( repository_root: &std::path::Path, captured_head: Option<&str>, @@ -263,17 +404,21 @@ async fn git_has_changes(repository_root: &std::path::Path, args: &[&str]) -> Re } } -async fn default_branch(repository_root: &std::path::Path) -> Result> { - let current = match git_output(repository_root, &["branch", "--show-current"]).await { - Ok(branch) if !branch.is_empty() => branch, - _ => return Ok(None), +async fn default_branch(repository_root: &std::path::Path) -> Result> { + let current = git_output(repository_root, &["branch", "--show-current"]) + .await + .ok() + .filter(|branch| !branch.is_empty()); + let configured_remote = if let Some(current) = current.as_deref() { + git_output( + repository_root, + &["config", "--get", &format!("branch.{current}.remote")], + ) + .await + .ok() + } else { + None }; - let configured_remote = git_output( - repository_root, - &["config", "--get", &format!("branch.{current}.remote")], - ) - .await - .ok(); let known_remotes = git_output(repository_root, &["remote"]) .await .unwrap_or_default() @@ -296,8 +441,8 @@ async fn default_branch(repository_root: &std::path::Path) -> Result Result, +) -> Result> { for candidate in candidates { - if candidate != current - && git_output( - repository_root, - &[ - "rev-parse", - "--verify", - "--quiet", - &format!("{candidate}^{{commit}}"), - ], - ) - .await - .is_ok() - { - return Ok(Some(candidate)); + if let Ok(oid) = resolve_commit_oid(repository_root, &candidate).await { + return Ok(Some(DefaultBranch { + reference: candidate, + oid, + })); } } Ok(None) } +async fn resolve_commit_oid(repository_root: &std::path::Path, reference: &str) -> Result { + let oid = git_output( + repository_root, + &[ + "rev-parse", + "--verify", + "--quiet", + &format!("{reference}^{{commit}}"), + ], + ) + .await?; + anyhow::ensure!( + crate::repository_search::valid_full_object_id(&oid), + "git rev-parse returned an invalid commit object id" + ); + Ok(oid) +} + async fn git_output(repository_root: &std::path::Path, args: &[&str]) -> Result { let output = Command::new("git") .arg("-C") @@ -518,7 +687,7 @@ mod tests { let head = run_git(root, &["rev-parse", "HEAD"]); let selection = auto_select(root, Some(&head)).await.unwrap(); - assert!(matches!(selection.source, LocalSource::AutoStaged)); + assert!(matches!(selection.source, LocalSource::AutoStaged { .. })); let snapshot = acquire(&selection.source, Some(&head), root).await.unwrap(); assert!(snapshot.diff.as_str().contains("+staged")); assert!(!snapshot.diff.as_str().contains("+working")); @@ -550,7 +719,7 @@ mod tests { let selection = auto_select(root, Some(&head)).await.unwrap(); assert!( - matches!(selection.source, LocalSource::AutoBranch { ref base, include_worktree: false } if base == "origin/trunk") + matches!(selection.source, LocalSource::AutoBranch { ref base_ref, include_worktree: false, .. } if base_ref == "origin/trunk") ); let snapshot = acquire(&selection.source, Some(&head), root).await.unwrap(); assert!(snapshot.diff.as_str().contains("+committed")); @@ -600,13 +769,138 @@ mod tests { let selection = auto_select(root, Some(&head)).await.unwrap(); assert!( - matches!(selection.source, LocalSource::AutoBranch { ref base, include_worktree: false } if base == "origin/trunk") + matches!(selection.source, LocalSource::AutoBranch { ref base_ref, include_worktree: false, .. } if base_ref == "origin/trunk") + ); + let snapshot = acquire(&selection.source, Some(&head), root).await.unwrap(); + assert!(snapshot.diff.as_str().contains("+committed")); + assert_eq!(snapshot.repository_revision.as_deref(), Some(head.as_str())); + } + + #[tokio::test] + async fn bare_selection_uses_remote_trunk_without_a_symbolic_remote_head() { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path(); + repository_with_main_and_feature(root); + let base = run_git(root, &["rev-parse", "main"]); + run_git( + root, + &[ + "remote", + "add", + "origin", + "https://example.invalid/repository.git", + ], + ); + run_git(root, &["update-ref", "refs/remotes/origin/trunk", &base]); + run_git(root, &["branch", "-D", "main"]); + std::fs::write(root.join("branch.txt"), "committed\n").unwrap(); + let head = commit_from_head(root); + + let selection = auto_select(root, Some(&head)).await.unwrap(); + assert!( + matches!(selection.source, LocalSource::AutoBranch { ref base_ref, include_worktree: false, .. } if base_ref == "refs/remotes/origin/trunk") ); let snapshot = acquire(&selection.source, Some(&head), root).await.unwrap(); assert!(snapshot.diff.as_str().contains("+committed")); assert_eq!(snapshot.repository_revision.as_deref(), Some(head.as_str())); } + #[tokio::test] + async fn bare_selection_fails_closed_when_the_default_branch_is_unresolved() { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path(); + run_git(root, &["init", "--quiet"]); + run_git(root, &["symbolic-ref", "HEAD", "refs/heads/feature"]); + std::fs::write(root.join("tracked.txt"), "committed\n").unwrap(); + let head = create_commit(root); + + let error = match auto_select(root, Some(&head)).await { + Ok(_) => panic!("an unresolved default branch must not produce a partial review"), + Err(error) => error, + }; + let message = error.to_string(); + assert!(message.contains("could not determine the repository default branch")); + assert!(message.contains("--base ")); + assert!(message.contains("--staged")); + assert!(message.contains("--diff-file ")); + } + + #[tokio::test] + async fn bare_selection_rejects_the_current_local_default_branch_without_a_remote() { + for branch in ["main", "trunk"] { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path(); + run_git(root, &["init", "--quiet"]); + run_git( + root, + &["symbolic-ref", "HEAD", &format!("refs/heads/{branch}")], + ); + std::fs::write(root.join("tracked.txt"), "committed\n").unwrap(); + let head = create_commit(root); + + let error = match auto_select(root, Some(&head)).await { + Ok(_) => panic!( + "the checked-out local {branch} branch must not be its own automatic base" + ), + Err(error) => error, + }; + let message = error.to_string(); + assert!(message.contains("could not determine the repository default branch")); + assert!(message.contains("--base ")); + assert!(message.contains("--staged")); + assert!(message.contains("--diff-file ")); + } + } + + #[tokio::test] + async fn configured_remote_main_precedes_another_remote_symbolic_default() { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path(); + repository_with_main_and_feature(root); + let base = run_git(root, &["rev-parse", "main"]); + run_git( + root, + &[ + "remote", + "add", + "upstream", + "https://example.invalid/upstream.git", + ], + ); + run_git( + root, + &[ + "remote", + "add", + "origin", + "https://example.invalid/origin.git", + ], + ); + run_git(root, &["update-ref", "refs/remotes/upstream/main", &base]); + run_git(root, &["update-ref", "refs/remotes/origin/trunk", &base]); + run_git( + root, + &[ + "symbolic-ref", + "refs/remotes/origin/HEAD", + "refs/remotes/origin/trunk", + ], + ); + run_git(root, &["config", "branch.feature.remote", "upstream"]); + std::fs::write(root.join("branch.txt"), "committed\n").unwrap(); + let head = commit_from_head(root); + + let selection = auto_select(root, Some(&head)).await.unwrap(); + assert!(matches!( + selection.source, + LocalSource::AutoBranch { + ref base_ref, + include_worktree: false, + .. + } if base_ref == "refs/remotes/upstream/main" + )); + } + #[tokio::test] async fn clean_automatic_branch_review_rejects_a_changed_head() { let directory = tempfile::tempdir().unwrap(); @@ -670,7 +964,7 @@ mod tests { run_git(root, &["add", "tracked.txt"]); let captured_head = run_git(root, &["rev-parse", "HEAD"]); let selection = auto_select(root, Some(&captured_head)).await.unwrap(); - assert!(matches!(selection.source, LocalSource::AutoStaged)); + assert!(matches!(selection.source, LocalSource::AutoStaged { .. })); commit_from_head(root); let error = match acquire(&selection.source, Some(&captured_head), root).await { @@ -688,7 +982,7 @@ mod tests { std::fs::write(root.join("tracked.txt"), "working\n").unwrap(); let captured_head = run_git(root, &["rev-parse", "HEAD"]); let selection = auto_select(root, Some(&captured_head)).await.unwrap(); - assert!(matches!(selection.source, LocalSource::WorkingTree)); + assert!(matches!(selection.source, LocalSource::WorkingTree { .. })); commit_from_head(root); let error = match acquire(&selection.source, Some(&captured_head), root).await { @@ -705,7 +999,7 @@ mod tests { repository_with_main_and_feature(root); let captured_head = run_git(root, &["rev-parse", "HEAD"]); let selection = auto_select(root, Some(&captured_head)).await.unwrap(); - assert!(matches!(selection.source, LocalSource::Clean)); + assert!(matches!(selection.source, LocalSource::Clean { .. })); std::fs::write(root.join("later.txt"), "later\n").unwrap(); commit_from_head(root); @@ -722,8 +1016,9 @@ mod tests { let root = directory.path(); repository_with_main_and_feature(root); let captured_head = run_git(root, &["rev-parse", "HEAD"]); + let fence = capture_local_state(root).await.unwrap(); - let error = acquire_automatic(root, Some(&captured_head), async { + let error = acquire_automatic(root, Some(&captured_head), &fence, None, async { let acquired_head = run_git(root, &["rev-parse", "HEAD"]); std::fs::write(root.join("later.txt"), "later\n").unwrap(); commit_from_head(root); @@ -738,6 +1033,76 @@ mod tests { ); } + #[tokio::test] + async fn automatic_branch_review_rejects_a_moved_base_ref() { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path(); + repository_with_main_and_feature(root); + std::fs::write(root.join("branch.txt"), "committed\n").unwrap(); + let head = commit_from_head(root); + let selection = auto_select(root, Some(&head)).await.unwrap(); + assert!(matches!(selection.source, LocalSource::AutoBranch { .. })); + + run_git(root, &["update-ref", "refs/heads/main", &head]); + let error = match acquire(&selection.source, Some(&head), root).await { + Ok(_) => panic!("an automatic branch review must reject a moved base ref"), + Err(error) => error, + }; + assert!(error.to_string().contains("default branch main changed")); + } + + #[tokio::test] + async fn automatic_staged_review_rejects_an_index_change() { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path(); + repository_with_main_and_feature(root); + std::fs::write(root.join("tracked.txt"), "staged-one\n").unwrap(); + run_git(root, &["add", "tracked.txt"]); + let head = run_git(root, &["rev-parse", "HEAD"]); + let selection = auto_select(root, Some(&head)).await.unwrap(); + + std::fs::write(root.join("tracked.txt"), "staged-two\n").unwrap(); + run_git(root, &["add", "tracked.txt"]); + let error = match acquire(&selection.source, Some(&head), root).await { + Ok(_) => panic!("an automatic staged review must reject an index change"), + Err(error) => error, + }; + assert!(error.to_string().contains("index changed")); + } + + #[tokio::test] + async fn automatic_worktree_review_rejects_a_tracked_change() { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path(); + repository_with_main_and_feature(root); + std::fs::write(root.join("tracked.txt"), "working-one\n").unwrap(); + let head = run_git(root, &["rev-parse", "HEAD"]); + let selection = auto_select(root, Some(&head)).await.unwrap(); + + std::fs::write(root.join("tracked.txt"), "working-two\n").unwrap(); + let error = match acquire(&selection.source, Some(&head), root).await { + Ok(_) => panic!("an automatic worktree review must reject a tracked change"), + Err(error) => error, + }; + assert!(error.to_string().contains("tracked working tree changed")); + } + + #[tokio::test] + async fn automatic_clean_review_rejects_a_new_tracked_change() { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path(); + repository_with_main_and_feature(root); + let head = run_git(root, &["rev-parse", "HEAD"]); + let selection = auto_select(root, Some(&head)).await.unwrap(); + + std::fs::write(root.join("tracked.txt"), "later\n").unwrap(); + let error = match acquire(&selection.source, Some(&head), root).await { + Ok(_) => panic!("an automatic clean review must reject a tracked change"), + Err(error) => error, + }; + assert!(error.to_string().contains("tracked working tree changed")); + } + #[tokio::test] async fn bare_selection_combines_committed_branch_and_tracked_worktree_changes() { let directory = tempfile::tempdir().unwrap(); @@ -770,11 +1135,11 @@ mod tests { std::fs::write(root.join("tracked.txt"), "working\n").unwrap(); let head = run_git(root, &["rev-parse", "HEAD"]); let working = auto_select(root, Some(&head)).await.unwrap(); - assert!(matches!(working.source, LocalSource::WorkingTree)); + assert!(matches!(working.source, LocalSource::WorkingTree { .. })); std::fs::write(root.join("tracked.txt"), "base\n").unwrap(); let clean = auto_select(root, Some(&head)).await.unwrap(); - assert!(matches!(clean.source, LocalSource::Clean)); + assert!(matches!(clean.source, LocalSource::Clean { .. })); assert!( acquire(&clean.source, Some(&head), root) .await @@ -793,7 +1158,7 @@ mod tests { std::fs::write(root.join("new.txt"), "untracked\n").unwrap(); let head = run_git(root, &["rev-parse", "HEAD"]); let selection = auto_select(root, Some(&head)).await.unwrap(); - assert!(matches!(selection.source, LocalSource::Clean)); + assert!(matches!(selection.source, LocalSource::Clean { .. })); assert!( selection .warning @@ -812,9 +1177,8 @@ mod tests { std::fs::write(root.join("config.yaml"), "version: staged-one\n").unwrap(); run_git(root, &["add", "config.yaml"]); - let automatic = acquire(&LocalSource::AutoStaged, Some(&head), root) - .await - .unwrap(); + let selection = auto_select(root, Some(&head)).await.unwrap(); + let automatic = acquire(&selection.source, Some(&head), root).await.unwrap(); let explicit = acquire(&LocalSource::Staged, Some(&head), root) .await .unwrap(); diff --git a/src/main.rs b/src/main.rs index 6ac3f4d..c25e224 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,8 +6,8 @@ use postil_cli::cli::{ Cli, Command, ForgeArg, HookAction, publication_enabled, publication_plan_contract_capability, }; use postil_cli::config::{ - Config, REASONING_EFFORT_VALUES, default_reasoning_effort, default_scorer_reasoning_effort, - qualification_metadata, starter_config, + Config, REASONING_EFFORT_VALUES, default_reasoning_effort, default_scorer_model, + default_scorer_reasoning_effort, qualification_metadata, starter_config, }; use postil_cli::review::{ForgeKind, ReviewArgs}; use postil_cli::{doctor, hook, login, plan, review}; @@ -67,6 +67,7 @@ async fn dispatch(cli: Cli) -> anyhow::Result { reasoning_effort, scorer_reasoning_effort, verbose, + no_progress, bounded, publish, no_post, @@ -109,6 +110,7 @@ async fn dispatch(cli: Cli) -> anyhow::Result { reasoning_effort, scorer_reasoning_effort, verbose, + no_progress, bounded, no_post: !publication_enabled(publish, no_post)?, defer_gate_check, @@ -119,28 +121,63 @@ async fn dispatch(cli: Cli) -> anyhow::Result { .await } Command::Models => { + let qualification = qualification_metadata(); let cascade = postil_cli::config::default_cascade(); let cascade_text = if cascade.is_empty() { "none".to_string() } else { cascade.join(" -> ") }; - println!("Postil model presets"); + println!("Postil model support (offline)"); + println!(" No model setting is required."); println!( - " Luna (tested default): {}", + " Embedded local reviewer (Luna): {}", postil_cli::config::default_model() ); + println!(" Reviewer source: embedded default"); println!(" Fallback cascade: {cascade_text}"); println!( " Reviewer reasoning effort: {}", default_reasoning_effort().as_str() ); + println!(" Local scorer: disabled (set REVIEW_SCORER_MODEL to enable)"); + println!(" Hosted scorer candidate: {}", default_scorer_model()); println!( - " Scorer reasoning effort: {}", + " Hosted scorer reasoning effort: {}", default_scorer_reasoning_effort().as_str() ); println!(" Accepted reasoning efforts: {REASONING_EFFORT_VALUES}"); - println!("\nOverride once: postil review --model provider/model"); + println!("\nAccepted local model IDs"); + println!(" Postil does not maintain a fixed local model-ID allowlist."); + println!( + " OpenAI-compatible endpoints accept any non-empty endpoint model ID and pass it unchanged; OpenRouter commonly uses provider/model." + ); + println!( + " Native Anthropic endpoints accept any non-empty Anthropic endpoint model ID and pass it unchanged, such as claude-* IDs." + ); + println!( + " Local compatibility means Postil can construct the endpoint protocol; it does not mean the model is hosted-qualified." + ); + println!("\nHosted qualification"); + if let Some(profile) = qualification.admitted_profile.as_ref() { + println!(" Embedded qualified profile: {}", profile.id); + println!( + " Qualified reviewer IDs: {}", + profile.generator_chain.join(" -> ") + ); + println!( + " Qualified scorer IDs: {}", + if profile.scorer_chain.is_empty() { + "none".to_string() + } else { + profile.scorer_chain.join(" -> ") + } + ); + } else { + println!(" Hosted qualified model IDs: none (no embedded qualified profile)"); + } + println!("\nCheck the configured endpoint and model: postil doctor"); + println!("Override once: postil review --model provider/model"); println!( "Override reasoning once: postil review --reasoning-effort high --scorer-reasoning-effort none" ); @@ -148,7 +185,12 @@ async fn dispatch(cli: Cli) -> anyhow::Result { println!( "Persist reasoning: REVIEW_REASONING_EFFORT=high REVIEW_SCORER_REASONING_EFFORT=none postil review" ); - println!("Config keys: model.reasoningEffort and model.scorerReasoningEffort"); + println!( + "Native Anthropic config: set model.apiBase, model.apiFormat: anthropic, and model.name, then run postil doctor" + ); + println!( + "Config keys: model.name, model.reasoningEffort, and model.scorerReasoningEffort" + ); Ok(0) } Command::Plan { envelopes, config } => { @@ -216,6 +258,7 @@ async fn dispatch(cli: Cli) -> anyhow::Result { ); println!("model.cascade: {:?}", cfg.cascade); println!("model.scorer: {}", cfg.scorer); + println!("model.scorer.enabled: {}", cfg.scorer_enabled()); println!( "model.scorerReasoningEffort: {}", cfg.scorer_reasoning_effort.as_str() diff --git a/src/progress.rs b/src/progress.rs index ed967fa..daad824 100644 --- a/src/progress.rs +++ b/src/progress.rs @@ -17,16 +17,36 @@ pub struct OutputSuspension { redraw: bool, } -pub fn start_review(machine_output: bool, verbose: bool, hosted: bool) -> ProgressGuard { +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReviewCompletion { + Clean, + AdvisoryFindings, + BlockingFindings, + ProviderIncomplete, + ModelOutputIncomplete, + OperationalIncomplete, +} + +pub fn start_review( + machine_output: bool, + verbose: bool, + hosted: bool, + no_progress: bool, +) -> ProgressGuard { let ci = std::env::var_os("CI").is_some(); let debug = std::env::var_os("RUST_LOG").is_some() || std::env::var_os("POSTIL_DEBUG").is_some(); let tty = std::io::stdout().is_terminal() && std::io::stderr().is_terminal(); let dumb = std::env::var("TERM").ok().as_deref() == Some("dumb"); let no_color = std::env::var_os("NO_COLOR").is_some(); + let static_progress = no_progress + || std::env::var_os("POSTIL_NO_PROGRESS").is_some_and(|value| !value.is_empty()); let suppress = tty && !machine_output && !verbose && !hosted && !ci && !debug; SUPPRESS_TELEMETRY.store(suppress, Ordering::Relaxed); - REDRAW.store(suppress && !dumb && !no_color, Ordering::Relaxed); + REDRAW.store( + suppress && !static_progress && !dumb && !no_color, + Ordering::Relaxed, + ); DEGRADED.store(false, Ordering::Relaxed); if suppress { if REDRAW.load(Ordering::Relaxed) { @@ -82,12 +102,12 @@ impl Drop for OutputSuspension { } impl ProgressGuard { - pub fn finish(mut self, exit_code: i32) { + pub fn finish(mut self, completion: ReviewCompletion) { if self.active { clear(); eprintln!( "postil: {}", - completion_message(exit_code, DEGRADED.load(Ordering::Relaxed)) + completion_message(completion, DEGRADED.load(Ordering::Relaxed)) ); self.active = false; } @@ -105,12 +125,23 @@ impl Drop for ProgressGuard { } } -fn completion_message(exit_code: i32, degraded: bool) -> &'static str { - match (exit_code, degraded) { - (0, false) => "review complete", - (0, true) => "review complete with warnings", - (_, false) => "review found blocking findings", - (_, true) => "review found blocking findings; warnings were also reported", +fn completion_message(completion: ReviewCompletion, degraded: bool) -> String { + let message = match completion { + ReviewCompletion::Clean => "review complete; no findings", + ReviewCompletion::AdvisoryFindings => "review complete; advisory findings reported", + ReviewCompletion::BlockingFindings => "review found blocking findings", + ReviewCompletion::ProviderIncomplete => "review incomplete; model provider unavailable", + ReviewCompletion::ModelOutputIncomplete => { + "review incomplete; model output could not be validated" + } + ReviewCompletion::OperationalIncomplete => { + "review incomplete; an operational error prevented completion" + } + }; + if degraded { + format!("{message}; warnings were also reported") + } else { + message.to_string() } } @@ -130,14 +161,49 @@ fn clear() { #[cfg(test)] mod tests { - use super::completion_message; + use super::{ReviewCompletion, completion_message}; #[test] - fn degraded_reviews_never_use_the_plain_completion_message() { - assert_eq!(completion_message(0, true), "review complete with warnings"); + fn degraded_reviews_preserve_the_specific_completion_state() { + assert_eq!( + completion_message(ReviewCompletion::Clean, true), + "review complete; no findings; warnings were also reported" + ); assert_eq!( - completion_message(1, true), + completion_message(ReviewCompletion::BlockingFindings, true), "review found blocking findings; warnings were also reported" ); + assert_eq!( + completion_message(ReviewCompletion::ProviderIncomplete, true), + "review incomplete; model provider unavailable; warnings were also reported" + ); + } + + #[test] + fn completion_states_distinguish_verdicts_from_incomplete_reviews() { + assert_eq!( + completion_message(ReviewCompletion::Clean, false), + "review complete; no findings" + ); + assert_eq!( + completion_message(ReviewCompletion::AdvisoryFindings, false), + "review complete; advisory findings reported" + ); + assert_eq!( + completion_message(ReviewCompletion::BlockingFindings, false), + "review found blocking findings" + ); + assert_eq!( + completion_message(ReviewCompletion::ProviderIncomplete, false), + "review incomplete; model provider unavailable" + ); + assert_eq!( + completion_message(ReviewCompletion::ModelOutputIncomplete, false), + "review incomplete; model output could not be validated" + ); + assert_eq!( + completion_message(ReviewCompletion::OperationalIncomplete, false), + "review incomplete; an operational error prevented completion" + ); } } diff --git a/src/resolve.rs b/src/resolve.rs index fe14783..56e60c9 100644 --- a/src/resolve.rs +++ b/src/resolve.rs @@ -106,9 +106,9 @@ pub(crate) async fn resolve_uncertainties( { Ok(Ok(files)) => files, Ok(Err(error)) => { - eprintln!( + crate::progress::notice(format_args!( "postil: uncertainty resolution is continuing with diff evidence after repository file acquisition failed: {error:#}" - ); + )); Vec::new() } Err(_) => { @@ -158,9 +158,9 @@ pub(crate) async fn resolve_uncertainties( pass.model_incidents .extend_from_slice(error.model_incidents()); pass.usage_accounting_complete &= error.usage_accounting_complete(); - eprintln!( + crate::progress::notice(format_args!( "postil: uncertainty resolution failed open and kept the original finding" - ); + )); None } }; @@ -187,12 +187,12 @@ pub(crate) async fn resolve_uncertainties( } pass.suppressed_findings.reverse(); if confirmed > 0 || unresolved > 0 || !pass.suppressed_findings.is_empty() { - eprintln!( + crate::progress::telemetry(format_args!( "postil: uncertainty resolution confirmed={} refuted={} unresolved={}", confirmed, pass.suppressed_findings.len(), unresolved - ); + )); } pass } diff --git a/src/review.rs b/src/review.rs index 3615b98..3434dc0 100644 --- a/src/review.rs +++ b/src/review.rs @@ -425,6 +425,7 @@ pub struct ReviewArgs { pub reasoning_effort: Option, pub scorer_reasoning_effort: Option, pub verbose: bool, + pub no_progress: bool, pub bounded: bool, pub no_post: bool, pub defer_gate_check: bool, @@ -441,6 +442,11 @@ impl ReviewArgs { self.output } } + + fn writes_machine_stdout(&self) -> bool { + (self.resolved_output_format().is_some() && self.output_file.is_none()) + || self.publication_plan_output.as_deref() == Some(Path::new("-")) + } } struct ReviewInput<'a> { @@ -469,6 +475,51 @@ struct RemoteReviewResult { publication_diff: Option, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct ReviewOutcome { + exit_code: i32, + completion: crate::progress::ReviewCompletion, +} + +impl ReviewOutcome { + fn from_envelope(envelope: &Envelope) -> Self { + use crate::progress::ReviewCompletion; + + let completion = if envelope + .findings + .iter() + .any(|finding| finding.path == crate::envelope::PROVIDER_PATH) + { + ReviewCompletion::ProviderIncomplete + } else if envelope.findings.iter().any(|finding| { + finding.path == crate::envelope::OPERATIONAL_PATH + && matches!( + finding.title.as_str(), + "Model output could not be validated" + | "Model narrated risk without structured findings" + ) + }) { + ReviewCompletion::ModelOutputIncomplete + } else if envelope + .findings + .iter() + .any(|finding| finding.path == crate::envelope::OPERATIONAL_PATH) + { + ReviewCompletion::OperationalIncomplete + } else if envelope.gate.failing { + ReviewCompletion::BlockingFindings + } else if envelope.findings.is_empty() { + ReviewCompletion::Clean + } else { + ReviewCompletion::AdvisoryFindings + }; + Self { + exit_code: i32::from(envelope.gate.failing), + completion, + } + } +} + #[derive(Clone, Copy)] struct PublicationContext<'a> { snapshot: &'a PrMeta, @@ -534,86 +585,90 @@ impl std::error::Error for ReviewFailure {} pub async fn run(args: ReviewArgs) -> Result { let cwd = std::env::current_dir()?; - if args.output_json { - eprintln!("warning: --output-json is deprecated; use --output json instead"); - } - if args.output_file.is_some() && args.resolved_output_format().is_none() { - return Err(anyhow!("--output-file requires --output or --output-json")); - } - if args.publication_plan_output.is_some() { - anyhow::ensure!( - args.forge == ForgeKind::GitHub - && args.repo.is_some() - && args.pr.is_some() - && args.sha.is_some() - && args.base_sha.is_some() - && !args.staged - && args.base.is_none() - && args.diff_file.is_none(), - "--publication-plan-output requires a remote GitHub pull request with explicit --repo, --pr, --sha, and --base-sha" - ); - anyhow::ensure!( - args.no_post - && args.check_run_id.is_none() - && args.gate_check_run_id.is_none() - && !args.defer_gate_check, - "--publication-plan-output cannot be combined with forge mutation options" - ); - let generation = args - .publication_generation - .as_deref() - .context("--publication-plan-output requires --publication-generation")?; - crate::forge::ensure_publication_decimal_identifier("controller generation", generation) - .context("invalid --publication-generation")?; - let input_identity = args - .publication_input_identity - .as_deref() - .context("--publication-plan-output requires --publication-input-identity")?; - crate::forge::ensure_publication_sha256_identity("input identity", input_identity) - .context("invalid --publication-input-identity")?; - } else { - anyhow::ensure!( - args.publication_generation.is_none(), - "--publication-generation requires --publication-plan-output" - ); - anyhow::ensure!( - args.publication_input_identity.is_none(), - "--publication-input-identity requires --publication-plan-output" - ); - } - let mut cfg = Config::load(&cwd, args.config.as_deref())?; - if let Some(m) = &args.model { - cfg.model = m.clone(); - cfg.model_source = "command line".to_string(); - } - if let Some(effort) = &args.reasoning_effort { - cfg.reasoning_effort = ReasoningEffort::parse("--reasoning-effort", effort)?; - cfg.reasoning_effort_source = "command line".to_string(); - } - if let Some(effort) = &args.scorer_reasoning_effort { - cfg.scorer_reasoning_effort = ReasoningEffort::parse("--scorer-reasoning-effort", effort)?; - cfg.scorer_reasoning_effort_source = "command line".to_string(); - } - cfg.require_model()?; - if let Some(fo) = &args.fail_on { - cfg.gate_fail_on = - GateLevel::parse(fo).ok_or_else(|| anyhow!("invalid --fail-on {fo:?}"))?; - } let progress = crate::progress::start_review( - args.resolved_output_format().is_some() || args.sarif.is_some(), + args.writes_machine_stdout(), args.verbose, crate::config::hosted_runtime_mode(), + args.no_progress, ); - if !args.no_post - && cfg.finding_presentation == FindingPresentation::CheckAnnotations - && args.forge != ForgeKind::GitHub - { - return Err(anyhow!( - "review.findingPresentation checkAnnotations requires GitHub publication" - )); - } - let review = async { + if args.output_json { + notice!("warning: --output-json is deprecated; use --output json instead"); + } + if args.output_file.is_some() && args.resolved_output_format().is_none() { + return Err(anyhow!("--output-file requires --output or --output-json")); + } + if args.publication_plan_output.is_some() { + anyhow::ensure!( + args.forge == ForgeKind::GitHub + && args.repo.is_some() + && args.pr.is_some() + && args.sha.is_some() + && args.base_sha.is_some() + && !args.staged + && args.base.is_none() + && args.diff_file.is_none(), + "--publication-plan-output requires a remote GitHub pull request with explicit --repo, --pr, --sha, and --base-sha" + ); + anyhow::ensure!( + args.no_post + && args.check_run_id.is_none() + && args.gate_check_run_id.is_none() + && !args.defer_gate_check, + "--publication-plan-output cannot be combined with forge mutation options" + ); + let generation = args + .publication_generation + .as_deref() + .context("--publication-plan-output requires --publication-generation")?; + crate::forge::ensure_publication_decimal_identifier( + "controller generation", + generation, + ) + .context("invalid --publication-generation")?; + let input_identity = args + .publication_input_identity + .as_deref() + .context("--publication-plan-output requires --publication-input-identity")?; + crate::forge::ensure_publication_sha256_identity("input identity", input_identity) + .context("invalid --publication-input-identity")?; + } else { + anyhow::ensure!( + args.publication_generation.is_none(), + "--publication-generation requires --publication-plan-output" + ); + anyhow::ensure!( + args.publication_input_identity.is_none(), + "--publication-input-identity requires --publication-plan-output" + ); + } + let mut cfg = Config::load(&cwd, args.config.as_deref())?; + if let Some(m) = &args.model { + cfg.model = m.clone(); + cfg.model_source = "command line".to_string(); + } + if let Some(effort) = &args.reasoning_effort { + cfg.reasoning_effort = ReasoningEffort::parse("--reasoning-effort", effort)?; + cfg.reasoning_effort_source = "command line".to_string(); + } + if let Some(effort) = &args.scorer_reasoning_effort { + cfg.scorer_reasoning_effort = + ReasoningEffort::parse("--scorer-reasoning-effort", effort)?; + cfg.scorer_reasoning_effort_source = "command line".to_string(); + } + cfg.require_model()?; + if let Some(fo) = &args.fail_on { + cfg.gate_fail_on = + GateLevel::parse(fo).ok_or_else(|| anyhow!("invalid --fail-on {fo:?}"))?; + } + if !args.no_post + && cfg.finding_presentation == FindingPresentation::CheckAnnotations + && args.forge != ForgeKind::GitHub + { + return Err(anyhow!( + "review.findingPresentation checkAnnotations requires GitHub publication" + )); + } match args.forge { ForgeKind::Local => run_local(&args, &cfg, &cwd).await, ForgeKind::GitHub => { @@ -642,12 +697,15 @@ pub async fn run(args: ReviewArgs) -> Result { result = review => result, _ = tokio::signal::ctrl_c() => Err(anyhow!("review interrupted")), }; - if let Ok(code) = result { - progress.finish(code); - Ok(code) - } else { - drop(progress); - result + match result { + Ok(outcome) => { + progress.finish(outcome.completion); + Ok(outcome.exit_code) + } + Err(error) => { + progress.finish(crate::progress::ReviewCompletion::OperationalIncomplete); + Err(error) + } } } @@ -663,7 +721,7 @@ fn require_pr(args: &ReviewArgs) -> Result { .ok_or_else(|| anyhow!("--pr is required for remote review")) } -async fn run_local(args: &ReviewArgs, cfg: &Config, repo_root: &Path) -> Result { +async fn run_local(args: &ReviewArgs, cfg: &Config, repo_root: &Path) -> Result { let head_sha = if args.diff_file.is_some() { None } else { @@ -695,7 +753,7 @@ async fn run_local(args: &ReviewArgs, cfg: &Config, repo_root: &Path) -> Result< let touched_carried_error = cfg.enabled && args.since_sha.is_some() && carried_error_outside_anchor_file_is_touched(&baseline, &local_snapshot.diff); - let supports_full_fallback = matches!(&source, LocalSource::Base(_)); + let supports_full_fallback = matches!(&selection.source, LocalSource::Base(_)); let fail_closed_baseline = (touched_carried_error && !supports_full_fallback).then(|| baseline.clone()); // This is a fail-closed placeholder, not the completed review's trust. @@ -788,7 +846,7 @@ async fn run_remote( forge: &F, repo: &str, repository_source: RepositorySource<'_>, -) -> Result { +) -> Result { let review_started = std::time::Instant::now(); let strict_publication = strict_hosted_github_publication(args); let meta = run_with_hosted_budget( @@ -947,7 +1005,7 @@ async fn run_remote( // Emit the envelope and SARIF before delivery. Hosted GitHub runs // require every applicable publication step; other invocations // retain their gate-derived result when the forge is unavailable. - let code = if envelope.gate.failing { 1 } else { 0 }; + let outcome = ReviewOutcome::from_envelope(&envelope); let finish_result = finish( args, cfg, @@ -971,7 +1029,7 @@ async fn run_remote( Err(post_err) } else { notice!("postil: could not post the error review ({post_err:#})"); - Ok(code) + Ok(outcome) } } }; @@ -1069,8 +1127,8 @@ fn retain_publication_failure( fn combine_required_publication( check_failure: Option, - finish_result: Result, -) -> Result { + finish_result: Result, +) -> Result { match (check_failure, finish_result) { (None, result) => result, (Some(check_error), Ok(_)) => { @@ -2977,7 +3035,8 @@ async fn finish( hosted_budget_started_at: Option, publication: Option>, strict_publication: bool, -) -> Result { +) -> Result { + let outcome = ReviewOutcome::from_envelope(&envelope); let publication_plan_to_stdout = args.publication_plan_output.as_deref() == Some(Path::new("-")); // Persist artifacts before any forge I/O: a posting hiccup must not @@ -3043,7 +3102,7 @@ async fn finish( } else { crate::forge::write_github_publication_plan(path, &plan)?; } - return Ok(if envelope.gate.failing { 1 } else { 0 }); + return Ok(outcome); } if let Some(forge) = forge @@ -3065,7 +3124,7 @@ async fn finish( } } crate::forge::write_review_publication_receipt_from_env(&receipt)?; - return Ok(if envelope.gate.failing { 1 } else { 0 }); + return Ok(outcome); } if !should_comment { let mut receipt = forge.plan_review_publication(&envelope, expected_snapshot); @@ -3084,7 +3143,7 @@ async fn finish( } } crate::forge::write_review_publication_receipt_from_env(&receipt)?; - return Ok(if envelope.gate.failing { 1 } else { 0 }); + return Ok(outcome); } let freshness = if let Some(started_at) = hosted_budget_started_at { snapshot_is_current(forge, expected_snapshot, started_at).await @@ -3102,7 +3161,7 @@ async fn finish( notice!( "postil: publication skipped because the pull request snapshot changed after review" ); - return Ok(if envelope.gate.failing { 1 } else { 0 }); + return Ok(outcome); } Err(error) if strict_publication => { return Err(error).context( @@ -3113,7 +3172,7 @@ async fn finish( notice!( "postil: publication skipped because snapshot freshness could not be verified ({error:#})" ); - return Ok(if envelope.gate.failing { 1 } else { 0 }); + return Ok(outcome); } } let posted = run_with_hosted_budget( @@ -3138,7 +3197,7 @@ async fn finish( } } } - Ok(if envelope.gate.failing { 1 } else { 0 }) + Ok(outcome) } async fn run_with_hosted_budget( @@ -3604,9 +3663,9 @@ mod tests { .unwrap() }; - let local_edge = format!("Benchmark pull request{}", "x".repeat(42)); - let ci_edge = format!("Benchmark pull request{}", "x".repeat(58)); - let below_floor = format!("Benchmark pull request{}", "x".repeat(442)); + let local_edge = format!("Benchmark pull request{}", "x".repeat(13)); + let ci_edge = format!("Benchmark pull request{}", "x".repeat(29)); + let below_floor = format!("Benchmark pull request{}", "x".repeat(413)); let local_budgets = batch_budgets_for_title(&local_edge); assert_eq!(local_budgets.synthesis, 4_116); @@ -3709,6 +3768,10 @@ mod tests { finding.body ); assert!(envelope.gate.failing); + assert_eq!( + ReviewOutcome::from_envelope(&envelope).completion, + crate::progress::ReviewCompletion::OperationalIncomplete + ); } #[test] @@ -3729,6 +3792,10 @@ mod tests { "the recorded scorer error must be preserved: {}", finding.body ); + assert_eq!( + ReviewOutcome::from_envelope(&envelope).completion, + crate::progress::ReviewCompletion::ModelOutputIncomplete + ); } #[test] @@ -3862,6 +3929,12 @@ mod tests { let envelope = error_envelope(&cfg, &error, "head", Some(&pr_meta()), 99); assert_eq!(envelope.findings[0].path, crate::envelope::PROVIDER_PATH); assert!(!envelope.gate.failing); + let outcome = ReviewOutcome::from_envelope(&envelope); + assert_eq!(outcome.exit_code, 0); + assert_eq!( + outcome.completion, + crate::progress::ReviewCompletion::ProviderIncomplete + ); assert_eq!(envelope.model_usage.len(), 2); assert_eq!(envelope.review_admission.unwrap().provider_attempts, 12); } @@ -3879,6 +3952,41 @@ mod tests { assert!(envelope.summary.contains("failing closed")); } + #[test] + fn ordinary_envelopes_distinguish_clean_advisory_and_blocking_completion() { + let mut envelope = error_envelope( + &Config::default(), + &anyhow::anyhow!("fixture operational error"), + "head", + Some(&pr_meta()), + 1, + ); + envelope.findings[0].path = "src/lib.rs".to_string(); + envelope.findings[0].title = "Ordinary review finding".to_string(); + envelope.gate.failing = false; + let advisory = ReviewOutcome::from_envelope(&envelope); + assert_eq!(advisory.exit_code, 0); + assert_eq!( + advisory.completion, + crate::progress::ReviewCompletion::AdvisoryFindings + ); + + envelope.gate.failing = true; + let blocking = ReviewOutcome::from_envelope(&envelope); + assert_eq!(blocking.exit_code, 1); + assert_eq!( + blocking.completion, + crate::progress::ReviewCompletion::BlockingFindings + ); + + envelope.findings.clear(); + envelope.gate.failing = false; + assert_eq!( + ReviewOutcome::from_envelope(&envelope).completion, + crate::progress::ReviewCompletion::Clean + ); + } + #[test] fn invalid_output_anywhere_in_exhausted_scorer_chain_dominates_provider_failure() { let incidents = vec![ @@ -4147,7 +4255,14 @@ mod tests { let retained = retain_publication_failure(true, completion, "fixture warning") .unwrap() .expect("strict hosted publication retains the timeout"); - let error = combine_required_publication(Some(retained), Ok(0)).unwrap_err(); + let error = combine_required_publication( + Some(retained), + Ok(ReviewOutcome { + exit_code: 0, + completion: crate::progress::ReviewCompletion::Clean, + }), + ) + .unwrap_err(); assert!(format!("{error:#}").contains("completing check runs timed out")); assert!( diff --git a/tests/e2e.rs b/tests/e2e.rs index a3389fc..642ca77 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -1,8 +1,9 @@ //! End-to-end tests: the real binary against mocked LLM and forge endpoints. use std::collections::BTreeMap; +use std::path::Path; use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, OnceLock}; use assert_cmd::Command; #[cfg(feature = "qualification-candidate")] @@ -1106,45 +1107,168 @@ async fn mount_azure_merge_base(server: &MockServer) { .await; } -fn postil() -> Command { +fn isolated_config_home() -> &'static Path { + static HOME: OnceLock = OnceLock::new(); + HOME.get_or_init(|| tempfile::tempdir().unwrap()).path() +} + +const ISOLATED_POSTIL_ENV: &[&str] = &[ + "CI", + "GITHUB_ACTIONS", + "GITHUB_REPOSITORY", + "REVIEW_MODEL", + "REVIEW_MODEL_CASCADE", + "REVIEW_MODEL_CONSENSUS", + "REVIEW_REASONING_EFFORT", + "REVIEW_SCORER_MODEL", + "REVIEW_SCORER_MODEL_CASCADE", + "REVIEW_SCORER_REASONING_EFFORT", + "POSTIL_DISABLE_SCORER", + "POSTIL_ALLOW_CONFIG_API_BASE", + "POSTIL_UNCERTAINTY_RESOLUTION", + "POSTIL_CONCISE_FINDINGS", + "POSTIL_NO_PROGRESS", + "POSTIL_DEBUG", + "RUST_LOG", + "NO_COLOR", + "POSTIL_HOSTED_MODE", + "POSTIL_PROVISIONAL_HOSTED_ROSTER", + "POSTIL_EXPECTED_GITHUB_REPO_ID", + "POSTIL_QUALIFICATION_CANDIDATE_PROFILE", + "POSTIL_BENCH_SCREEN_PROFILE", + "POSTIL_QUALIFICATION_PLAN_ONLY", + "POSTIL_QUALIFICATION_CAPTURE_API_BASE", + "POSTIL_IGNORE_REPOSITORY_MODEL_CONFIG", + "POSTIL_BENCH_FORCE_BOUNDED_SELECTION", + "POSTIL_BENCH_REQUIRE_HOSTED_PROVIDER_PRIVACY", + "POSTIL_LLM_REQUEST_TIMEOUT_SECS", + "POSTIL_LLM_TOTAL_TIMEOUT_SECS", + "MODEL_API_KEY", + "LLM_API_KEY", + "OPENROUTER_API_KEY", + "POSTIL_API_KEY", + "POSTIL_API_BASE", + "POSTIL_API_FORMAT", + "POSTIL_ENDPOINT_AUTH_HEADER", + "POSTIL_ENDPOINT_AUTH_VALUE", + "POSTIL_LARGE_REVIEW_PLAN_ENDPOINT", + "POSTIL_LARGE_REVIEW_PLAN_TOKEN", + "POSTIL_ALLOW_PRIVATE_API_BASE", + "POSTIL_DETAILS_URL", + "POSTIL_PREVENTION_HINT", + "POSTIL_PREVENTION_COMMANDS_JSON", + "POSTIL_PUBLICATION_RECEIPT_PATH", + "POSTIL_LOGIN_SERVER", + "POSTIL_PUBLISH", + "POSTIL_NO_POST", + "AZURE_DEVOPS_API_URL", + "AZURE_DEVOPS_TOKEN", + "BITBUCKET_API_URL", + "BITBUCKET_TOKEN", + "BITBUCKET_USER", + "GITHUB_API_URL", + "GITHUB_TOKEN", + "GITHUB_SERVER_URL", + "GITLAB_API_URL", + "GITLAB_TOKEN", + "POSTIL_ENABLE_BITBUCKET_INCREMENTAL", +]; + +fn isolated_postil() -> Command { let mut cmd = Command::cargo_bin("postil").unwrap(); // Isolate from developer environment and repo config discovery. - cmd.env_remove("REVIEW_MODEL") - .env_remove("REVIEW_MODEL_CASCADE") - .env_remove("REVIEW_REASONING_EFFORT") - .env_remove("REVIEW_SCORER_MODEL") - .env_remove("REVIEW_SCORER_MODEL_CASCADE") - .env_remove("REVIEW_SCORER_REASONING_EFFORT") - .env_remove("POSTIL_DISABLE_SCORER") - .env_remove("POSTIL_UNCERTAINTY_RESOLUTION") - .env_remove("POSTIL_CONCISE_FINDINGS") - .env_remove("POSTIL_HOSTED_MODE") - .env_remove("POSTIL_EXPECTED_GITHUB_REPO_ID") - .env_remove("POSTIL_QUALIFICATION_CANDIDATE_PROFILE") - .env_remove("POSTIL_QUALIFICATION_PLAN_ONLY") - .env_remove("POSTIL_BENCH_FORCE_BOUNDED_SELECTION") - .env_remove("POSTIL_BENCH_REQUIRE_HOSTED_PROVIDER_PRIVACY") - .env_remove("POSTIL_LLM_REQUEST_TIMEOUT_SECS") - .env_remove("POSTIL_LLM_TOTAL_TIMEOUT_SECS") - .env_remove("MODEL_API_KEY") - .env_remove("LLM_API_KEY") - .env_remove("OPENROUTER_API_KEY") - .env_remove("POSTIL_API_KEY") - .env_remove("POSTIL_API_BASE") - .env_remove("POSTIL_API_FORMAT") - .env_remove("POSTIL_ENDPOINT_AUTH_HEADER") - .env_remove("POSTIL_ENDPOINT_AUTH_VALUE") - .env_remove("POSTIL_LARGE_REVIEW_PLAN_ENDPOINT") - .env_remove("POSTIL_LARGE_REVIEW_PLAN_TOKEN") - .env_remove("POSTIL_ALLOW_PRIVATE_API_BASE") - .env_remove("POSTIL_DETAILS_URL") - .env_remove("POSTIL_PREVENTION_HINT") - .env_remove("POSTIL_PREVENTION_COMMANDS_JSON") - .env_remove("POSTIL_PUBLISH") - .env_remove("POSTIL_NO_POST") - .env_remove("GITHUB_SERVER_URL") - .env_remove("POSTIL_ENABLE_BITBUCKET_INCREMENTAL") - .env("REVIEW_MODEL", "openai/gpt-5-mini") + for name in ISOLATED_POSTIL_ENV { + cmd.env_remove(name); + } + cmd.env("XDG_CONFIG_HOME", isolated_config_home()); + cmd +} + +#[cfg(unix)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ScriptFlavor { + UtilLinux, + Bsd, +} + +#[cfg(unix)] +fn native_script_flavor() -> ScriptFlavor { + if cfg!(target_os = "linux") { + ScriptFlavor::UtilLinux + } else { + ScriptFlavor::Bsd + } +} + +#[cfg(unix)] +fn shell_quote(argument: &str) -> String { + format!("'{}'", argument.replace('\'', "'\"'\"'")) +} + +#[cfg(unix)] +fn script_arguments(flavor: ScriptFlavor, program: &str, arguments: &[String]) -> Vec { + match flavor { + ScriptFlavor::UtilLinux => vec![ + "-qefc".to_string(), + std::iter::once(program) + .chain(arguments.iter().map(String::as_str)) + .map(shell_quote) + .collect::>() + .join(" "), + "/dev/null".to_string(), + ], + ScriptFlavor::Bsd => std::iter::once("-q".to_string()) + .chain(std::iter::once("/dev/null".to_string())) + .chain(std::iter::once(program.to_string())) + .chain(arguments.iter().cloned()) + .collect(), + } +} + +#[cfg(unix)] +fn isolated_script(program: &str, arguments: &[String]) -> std::process::Command { + let mut command = std::process::Command::new("script"); + for name in ISOLATED_POSTIL_ENV { + command.env_remove(name); + } + command + .env("XDG_CONFIG_HOME", isolated_config_home()) + .args(script_arguments(native_script_flavor(), program, arguments)); + command +} + +#[cfg(unix)] +#[test] +fn pty_script_arguments_cover_util_linux_and_bsd_syntax() { + let arguments = vec![ + "review".to_string(), + "--base".to_string(), + "topic's base".to_string(), + ]; + assert_eq!( + script_arguments(ScriptFlavor::UtilLinux, "/tmp/postil binary", &arguments), + vec![ + "-qefc", + "'/tmp/postil binary' 'review' '--base' 'topic'\"'\"'s base'", + "/dev/null", + ] + ); + assert_eq!( + script_arguments(ScriptFlavor::Bsd, "/tmp/postil binary", &arguments), + vec![ + "-q", + "/dev/null", + "/tmp/postil binary", + "review", + "--base", + "topic's base", + ] + ); +} + +fn postil() -> Command { + let mut cmd = isolated_postil(); + cmd.env("REVIEW_MODEL", "openai/gpt-5-mini") .env("MODEL_API_KEY", fixture_credential("provider")) // Mock providers bind loopback. Production and normal CLI invocations // reject private API endpoints unless this explicit local-only escape @@ -5912,8 +6036,7 @@ fn bare_review_keeps_redirected_output_free_of_terminal_control_sequences() { .unwrap() .success() ); - let output = Command::cargo_bin("postil") - .unwrap() + let output = isolated_postil() .current_dir(dir.path()) .env_remove("CI") .env_remove("POSTIL_HOSTED_MODE") @@ -5945,8 +6068,7 @@ fn bare_review_clears_progress_before_pretty_output_in_a_pty() { .get_program() .to_string_lossy() .into_owned(); - let output = std::process::Command::new("script") - .args(["-qefc", &format!("'{binary}' review"), "/dev/null"]) + let output = isolated_script(&binary, &["review".to_string()]) .current_dir(dir.path()) .env("TERM", "xterm") .env_remove("CI") @@ -5973,9 +6095,68 @@ fn bare_review_clears_progress_before_pretty_output_in_a_pty() { ); } +#[cfg(unix)] +#[test] +fn file_artifacts_keep_human_progress_and_pretty_output_in_a_pty() { + let dir = tempfile::tempdir().unwrap(); + assert!( + std::process::Command::new("git") + .args(["init", "--quiet"]) + .current_dir(dir.path()) + .status() + .unwrap() + .success() + ); + let binary = Command::cargo_bin("postil") + .unwrap() + .get_program() + .to_string_lossy() + .into_owned(); + let envelope = dir.path().join("review.json"); + let sarif = dir.path().join("review.sarif.json"); + + for arguments in [ + vec![ + "review".to_string(), + "--output".to_string(), + "json".to_string(), + "--output-file".to_string(), + envelope.display().to_string(), + ], + vec![ + "review".to_string(), + "--sarif".to_string(), + sarif.display().to_string(), + ], + ] { + let output = isolated_script(&binary, &arguments) + .current_dir(dir.path()) + .env("TERM", "xterm") + .env_remove("CI") + .env_remove("POSTIL_HOSTED_MODE") + .env_remove("RUST_LOG") + .env_remove("POSTIL_DEBUG") + .env_remove("NO_COLOR") + .env_remove("POSTIL_NO_PROGRESS") + .output() + .unwrap(); + assert!(output.status.success()); + let rendered = String::from_utf8_lossy(&output.stdout); + assert!(rendered.contains("Reviewing changes"), "{rendered:?}"); + assert!( + rendered.contains("✓ postil: no merge-relevant findings"), + "{rendered:?}" + ); + } + + serde_json::from_slice::(&std::fs::read(envelope).unwrap()).unwrap(); + let sarif: Value = serde_json::from_slice(&std::fs::read(sarif).unwrap()).unwrap(); + assert_eq!(sarif["version"], "2.1.0"); +} + #[cfg(unix)] #[tokio::test] -async fn github_telemetry_is_hidden_only_for_interactive_reviews() { +async fn interactive_progress_controls_animation_separately_from_telemetry() { let server = MockServer::start().await; mount_github_complete_diff(&server, 7).await; mount_static_github_pr(&server).await; @@ -5987,23 +6168,27 @@ async fn github_telemetry_is_hidden_only_for_interactive_reviews() { .to_string_lossy() .into_owned(); - let interactive = std::process::Command::new("script") - .args([ - "-qefc", - &format!("'{binary}' review --repo acme/api --pr 7"), - "/dev/null", - ]) - .current_dir(dir.path()) - .env("TERM", "xterm") - .env("GITHUB_API_URL", server.uri()) - .env("GITHUB_TOKEN", "gh-test-token") - .env_remove("CI") - .env_remove("POSTIL_HOSTED_MODE") - .env_remove("RUST_LOG") - .env_remove("POSTIL_DEBUG") - .env_remove("NO_COLOR") - .output() - .unwrap(); + let interactive = isolated_script( + &binary, + &[ + "review".to_string(), + "--repo".to_string(), + "acme/api".to_string(), + "--pr".to_string(), + "7".to_string(), + ], + ) + .current_dir(dir.path()) + .env("TERM", "xterm") + .env("GITHUB_API_URL", server.uri()) + .env("GITHUB_TOKEN", "gh-test-token") + .env_remove("CI") + .env_remove("POSTIL_HOSTED_MODE") + .env_remove("RUST_LOG") + .env_remove("POSTIL_DEBUG") + .env_remove("NO_COLOR") + .output() + .unwrap(); assert!(interactive.status.success()); let rendered = String::from_utf8_lossy(&interactive.stdout); assert!(rendered.contains("Reviewing changes")); @@ -6027,6 +6212,154 @@ async fn github_telemetry_is_hidden_only_for_interactive_reviews() { telemetry.contains("postil: github operation="), "machine review omitted GitHub telemetry: {telemetry:?}" ); + + for (arguments, no_progress_environment) in [ + ( + vec![ + "review".to_string(), + "--repo".to_string(), + "acme/api".to_string(), + "--pr".to_string(), + "7".to_string(), + "--no-progress".to_string(), + ], + None, + ), + ( + vec![ + "review".to_string(), + "--repo".to_string(), + "acme/api".to_string(), + "--pr".to_string(), + "7".to_string(), + ], + Some("1"), + ), + ] { + let mut automation = isolated_script(&binary, &arguments); + automation + .current_dir(dir.path()) + .env("TERM", "xterm") + .env("GITHUB_API_URL", server.uri()) + .env("GITHUB_TOKEN", "gh-test-token") + .env_remove("CI") + .env_remove("POSTIL_HOSTED_MODE") + .env_remove("RUST_LOG") + .env_remove("POSTIL_DEBUG") + .env_remove("NO_COLOR") + .env_remove("POSTIL_NO_PROGRESS"); + if let Some(value) = no_progress_environment { + automation.env("POSTIL_NO_PROGRESS", value); + } + let static_progress = automation.output().unwrap(); + assert!(static_progress.status.success()); + let static_progress = String::from_utf8_lossy(&static_progress.stdout); + assert!( + static_progress.contains("postil: reviewing changes..."), + "{static_progress:?}" + ); + assert!( + static_progress.contains("postil: review complete; no findings"), + "{static_progress:?}" + ); + assert!(!static_progress.contains("\x1b[2K"), "{static_progress:?}"); + assert!( + !static_progress.contains("postil: github operation="), + "static human progress exposed routine GitHub telemetry: {static_progress:?}" + ); + } + + let verbose = isolated_script( + &binary, + &[ + "review".to_string(), + "--repo".to_string(), + "acme/api".to_string(), + "--pr".to_string(), + "7".to_string(), + "--verbose".to_string(), + ], + ) + .current_dir(dir.path()) + .env("TERM", "xterm") + .env("GITHUB_API_URL", server.uri()) + .env("GITHUB_TOKEN", "gh-test-token") + .env_remove("CI") + .env_remove("POSTIL_HOSTED_MODE") + .env_remove("RUST_LOG") + .env_remove("POSTIL_DEBUG") + .env_remove("NO_COLOR") + .env_remove("POSTIL_NO_PROGRESS") + .output() + .unwrap(); + assert!(verbose.status.success()); + let verbose = String::from_utf8_lossy(&verbose.stdout); + assert!( + verbose.contains("postil: github operation="), + "verbose interactive review omitted GitHub telemetry: {verbose:?}" + ); + assert!(!verbose.contains("Reviewing changes"), "{verbose:?}"); +} + +#[cfg(unix)] +#[tokio::test] +async fn no_progress_keeps_provider_telemetry_collapsed_until_verbose() { + let server = MockServer::start().await; + mock_review(&server, json!([])).await; + let dir = tempfile::tempdir().unwrap(); + let diff = write_diff(dir.path()); + let binary = Command::cargo_bin("postil") + .unwrap() + .get_program() + .to_string_lossy() + .into_owned(); + + let run = |mode: &str| { + let mut command = isolated_script( + &binary, + &[ + "review".to_string(), + "--diff-file".to_string(), + diff.display().to_string(), + mode.to_string(), + ], + ); + command + .current_dir(dir.path()) + .env("TERM", "xterm") + .env("POSTIL_API_BASE", server.uri()) + .env("POSTIL_ALLOW_PRIVATE_API_BASE", "1") + .env("REVIEW_MODEL", "generator-model") + .env("MODEL_API_KEY", fixture_credential("provider")) + .env("POSTIL_DISABLE_SCORER", "1") + .env_remove("CI") + .env_remove("POSTIL_HOSTED_MODE") + .env_remove("RUST_LOG") + .env_remove("POSTIL_DEBUG") + .env_remove("NO_COLOR") + .env_remove("POSTIL_NO_PROGRESS") + .output() + .unwrap() + }; + + let static_progress = run("--no-progress"); + assert!(static_progress.status.success()); + let static_progress = String::from_utf8_lossy(&static_progress.stdout); + assert!(static_progress.contains("postil: reviewing changes...")); + assert!(static_progress.contains("postil: review complete; no findings")); + assert!(!static_progress.contains("postil: llm attempt")); + assert!(!static_progress.contains("postil: queued source request")); + assert!(!static_progress.contains("\x1b[2K")); + + let verbose = run("--verbose"); + assert!(verbose.status.success()); + let verbose = String::from_utf8_lossy(&verbose.stdout); + assert!(verbose.contains("postil: llm attempt"), "{verbose:?}"); + assert!( + verbose.contains("postil: queued source request"), + "{verbose:?}" + ); + assert!(!verbose.contains("Reviewing changes"), "{verbose:?}"); } #[cfg(unix)] @@ -6047,8 +6380,7 @@ fn bare_review_keeps_warnings_visible_and_marks_degraded_completion_in_a_pty() { .get_program() .to_string_lossy() .into_owned(); - let output = std::process::Command::new("script") - .args(["-qefc", &format!("'{binary}' review"), "/dev/null"]) + let output = isolated_script(&binary, &["review".to_string()]) .current_dir(dir.path()) .env("TERM", "xterm") .env_remove("CI") @@ -6062,7 +6394,7 @@ fn bare_review_keeps_warnings_visible_and_marks_degraded_completion_in_a_pty() { assert!(rendered.contains("Reviewing changes")); assert!(rendered.contains("\x1b[2K")); assert!(rendered.contains("untracked files were not reviewed")); - assert!(rendered.contains("review complete with warnings")); + assert!(rendered.contains("review complete; no findings; warnings were also reported")); } #[cfg(unix)] @@ -6082,8 +6414,7 @@ fn bare_review_respects_no_color_in_a_pty() { .get_program() .to_string_lossy() .into_owned(); - let output = std::process::Command::new("script") - .args(["-qefc", &format!("'{binary}' review"), "/dev/null"]) + let output = isolated_script(&binary, &["review".to_string()]) .current_dir(dir.path()) .env("TERM", "xterm") .env("NO_COLOR", "1") @@ -6102,13 +6433,52 @@ fn bare_review_respects_no_color_in_a_pty() { assert!(rendered.contains("review complete")); } +#[cfg(unix)] #[test] -fn models_and_config_explain_the_embedded_default() { +fn interactive_early_errors_replace_progress_with_an_incomplete_result() { let dir = tempfile::tempdir().unwrap(); - let models = Command::cargo_bin("postil") + let binary = Command::cargo_bin("postil") .unwrap() - .env_remove("REVIEW_REASONING_EFFORT") - .env_remove("REVIEW_SCORER_REASONING_EFFORT") + .get_program() + .to_string_lossy() + .into_owned(); + let output = isolated_script( + &binary, + &[ + "review".to_string(), + "--output-file".to_string(), + dir.path().join("review.json").display().to_string(), + ], + ) + .current_dir(dir.path()) + .env("TERM", "xterm") + .env_remove("CI") + .env_remove("POSTIL_HOSTED_MODE") + .env_remove("RUST_LOG") + .env_remove("NO_COLOR") + .output() + .unwrap(); + let rendered = String::from_utf8_lossy(&output.stdout); + assert!(rendered.contains("Reviewing changes"), "{rendered:?}"); + assert!( + rendered.contains("review incomplete; an operational error prevented completion"), + "{rendered:?}" + ); + assert!( + rendered.contains("--output-file requires --output or --output-json"), + "{rendered:?}" + ); +} + +#[test] +fn models_and_config_explain_the_embedded_default() { + let dir = tempfile::tempdir().unwrap(); + let models = isolated_postil() + .env("MODEL_API_KEY", "models-secret-sentinel") + .env( + "POSTIL_API_BASE", + "https://models-secret-sentinel.invalid/v1", + ) .args(["models"]) .assert() .success() @@ -6116,17 +6486,32 @@ fn models_and_config_explain_the_embedded_default() { .stdout .clone(); let models = String::from_utf8(models).unwrap(); - assert!(models.contains("Luna (tested default): openai/gpt-5.6-luna")); + assert!(models.contains("Postil model support (offline)")); + assert!(models.contains("No model setting is required")); + assert!(models.contains("Embedded local reviewer (Luna): openai/gpt-5.6-luna")); + assert!(models.contains("Reviewer source: embedded default")); assert!(models.contains("Reviewer reasoning effort: low")); - assert!(models.contains("Scorer reasoning effort: none")); + assert!(models.contains("Local scorer: disabled")); + assert!(models.contains("Hosted scorer candidate: openai/gpt-5.6-luna")); + assert!(models.contains("Hosted scorer reasoning effort: none")); + assert!(models.contains("does not maintain a fixed local model-ID allowlist")); + assert!(models.contains("OpenAI-compatible endpoints accept any non-empty endpoint model ID")); + assert!(models.contains("OpenRouter commonly uses provider/model")); + assert!( + models.contains( + "Native Anthropic endpoints accept any non-empty Anthropic endpoint model ID" + ) + ); + assert!(models.contains("does not mean the model is hosted-qualified")); + assert!(models.contains("Hosted qualified model IDs: none (no embedded qualified profile)")); assert!(models.contains("max|xhigh|high|medium|low|minimal|none")); + assert!(models.contains("postil doctor")); assert!(models.contains("postil review --model provider/model")); - assert!(models.contains("model.reasoningEffort and model.scorerReasoningEffort")); - let config = Command::cargo_bin("postil") - .unwrap() + assert!(models.contains("model.apiFormat: anthropic")); + assert!(models.contains("model.name, model.reasoningEffort, and model.scorerReasoningEffort")); + assert!(!models.contains("models-secret-sentinel")); + let config = isolated_postil() .current_dir(dir.path()) - .env_remove("REVIEW_REASONING_EFFORT") - .env_remove("REVIEW_SCORER_REASONING_EFFORT") .args(["config"]) .assert() .success() @@ -6138,10 +6523,22 @@ fn models_and_config_explain_the_embedded_default() { assert!(config.contains("model.name: openai/gpt-5.6-luna")); assert!(config.contains("model.reasoningEffort: low")); assert!(config.contains("model.reasoningEffort.source: embedded default")); + assert!(config.contains("model.scorer.enabled: false")); assert!(config.contains("model.scorerReasoningEffort: none")); assert!(config.contains("model.scorerReasoningEffort.source: embedded default")); } +#[test] +fn conflicting_local_sources_are_usage_errors() { + for arguments in [ + vec!["review", "--staged", "--base", "main"], + vec!["review", "--staged", "--diff-file", "change.diff"], + vec!["review", "--base", "main", "--diff-file", "change.diff"], + ] { + isolated_postil().args(arguments).assert().code(2); + } +} + #[test] fn config_reports_separate_environment_reasoning_provenance() { let dir = tempfile::tempdir().unwrap(); @@ -8259,8 +8656,7 @@ async fn deprecated_output_json_alias_prints_json_with_warning() { #[test] fn review_help_documents_machine_output_flags() { - let out = Command::cargo_bin("postil") - .unwrap() + let out = isolated_postil() .args(["review", "--help"]) .assert() .success(); @@ -8274,8 +8670,7 @@ fn review_help_documents_machine_output_flags() { #[test] fn review_rejects_unknown_output_format() { - Command::cargo_bin("postil") - .unwrap() + isolated_postil() .args(["review", "--output", "xml"]) .assert() .failure() @@ -13815,26 +14210,21 @@ async fn gitlab_diff_pagination_follows_authoritative_next_page_to_exhaustion() #[test] fn init_writes_starter_and_config_shows_provenance() { let dir = tempfile::tempdir().unwrap(); - Command::cargo_bin("postil") - .unwrap() + isolated_postil() .current_dir(dir.path()) .args(["init"]) .assert() .success(); assert!(dir.path().join(".postil.yaml").is_file()); // Second init refuses without --force. - Command::cargo_bin("postil") - .unwrap() + isolated_postil() .current_dir(dir.path()) .args(["init"]) .assert() .code(2); - let out = Command::cargo_bin("postil") - .unwrap() + let out = isolated_postil() .current_dir(dir.path()) - .env_remove("REVIEW_MODEL") - .env_remove("POSTIL_API_BASE") .args(["config"]) .assert() .success(); @@ -13846,8 +14236,7 @@ fn init_writes_starter_and_config_shows_provenance() { #[test] fn qualification_metadata_cli_emits_service_authority_fields() { - let output = Command::cargo_bin("postil") - .unwrap() + let output = isolated_postil() .args(["qualification-metadata"]) .assert() .success() @@ -13871,11 +14260,8 @@ fn coderabbit_config_is_translated() { "reviews:\n profile: chill\n path_filters:\n - \"!**/generated/**\"\n", ) .unwrap(); - let out = Command::cargo_bin("postil") - .unwrap() + let out = isolated_postil() .current_dir(dir.path()) - .env_remove("REVIEW_MODEL") - .env_remove("POSTIL_API_BASE") .args(["config"]) .assert() .success(); @@ -13912,8 +14298,7 @@ fn plan_replays_envelopes_deterministically() { ) .unwrap(); - let out = Command::cargo_bin("postil") - .unwrap() + let out = isolated_postil() .current_dir(dir.path()) .args(["plan", "--envelopes"]) .arg(&envelopes) From 2f1e5c06ed2c93e9cf2e1553feae6b19466c7f99 Mon Sep 17 00:00:00 2001 From: Postil Maintainer Date: Tue, 25 Aug 2026 03:55:20 +0000 Subject: [PATCH 5/7] Keep CLI sessions renewable --- ARCHITECTURE.md | 11 +- docs/configuration.md | 10 +- src/api_key.rs | 2 + src/cli.rs | 4 +- src/config.rs | 18 +- src/credentials.rs | 219 +++++++++- src/doctor.rs | 4 +- src/llm.rs | 8 +- src/login.rs | 994 ++++++++++++++++++++++++++++++++++++++---- tests/e2e.rs | 141 ++++++ 10 files changed, 1284 insertions(+), 127 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e52a949..8b9df98 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -56,6 +56,14 @@ acquire diff --> parse supported lockfiles --> parse + index --> bounded evidenc OpenRouter's response `usage.cost` is preserved as canonical decimal dollars without floating-point conversion; rounded micro-dollars remain a display/index field. Endpoints that omit cost retain token-only accounting with unavailable provenance. +- `credentials.rs` and `login.rs`: local device-login storage and session lifecycle. + Renewable credentials bind refresh and logout to a canonical issuing server, and the + inference bearer is accepted only for its stored API base. Legacy credentials infer the + public issuer only when their stored API base is the canonical Postil endpoint. Atomic + owner-only writes stage newly issued and overwritten session families in a + pending-revocation queue before replacing the active credential. Background retries + contact only each family's persisted issuer. Logout retains the active revocation handle + until the issuing server accepts revocation. - `review.rs`: orchestration; enforces acquisition, model-aware context, request, provider-attempt, output-token, and worst-case token-exposure budgets before calls; one UTF-8 byte counts as one projected token rather than using an optimistic ratio. @@ -147,7 +155,8 @@ acquire diff --> parse supported lockfiles --> parse + index --> bounded evidenc Exception to precedence: `model.apiBase` from a config file is ignored by default (a repo could redirect the base URL that receives the inference credential); honored only with `POSTIL_ALLOW_CONFIG_API_BASE=1`. The - `POSTIL_API_BASE` environment variable is always applied. `model.apiFormat` and + `POSTIL_API_BASE` environment variable is applied to BYOK requests. A different + endpoint cannot receive a stored login bearer. `model.apiFormat` and `POSTIL_API_FORMAT` select `openai-compatible` (default) or `anthropic`. Hosted admission matches the complete ordered generator/scorer configuration and consensus width to one immutable qualification profile. Each profile binds benchmark diff --git a/docs/configuration.md b/docs/configuration.md index f2f54e7..e6777d6 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -47,8 +47,8 @@ Ignore patterns remove matching paths before grounding, batching, and large-revi | Variable | Purpose | | --- | --- | | `POSTIL_API_KEY`, `OPENROUTER_API_KEY`, `MODEL_API_KEY`, `LLM_API_KEY` | Model provider credential, checked in that order | -| `POSTIL_LOGIN_SERVER` | Postil web app used by `postil login`, refresh, and `postil logout`; defaults to `https://postil.dev` | -| `POSTIL_API_BASE` | Model endpoint selected by the operator | +| `POSTIL_LOGIN_SERVER` | Postil web app used to issue a new login; defaults to `https://postil.dev`. Refresh and logout use the stored issuing server and reject a conflicting explicit value. | +| `POSTIL_API_BASE` | Model endpoint selected by the operator. A value that differs from a stored login's endpoint requires an explicit API key. | | `POSTIL_API_FORMAT` | `openai-compatible` or `anthropic` | | `POSTIL_ENDPOINT_AUTH_HEADER`, `POSTIL_ENDPOINT_AUTH_VALUE` | Additional private-gateway authentication | | `POSTIL_ALLOW_PRIVATE_API_BASE` | Explicitly permit a local or private-network endpoint | @@ -74,7 +74,11 @@ postil login postil logout ``` -`postil login` authenticates against postil.dev over a device-authorization flow (open the printed URL, enter the code) and stores a renewable credential at `${XDG_CONFIG_HOME:-~/.config}/postil/credentials.json`, mode `0600` in a `0700` directory. That credential is a fallback: it is used only when none of `POSTIL_API_KEY`, `OPENROUTER_API_KEY`, `MODEL_API_KEY`, or `LLM_API_KEY` is set. When it is used, Postil rotates the access credential before it expires and persists the replacement; explicit API keys never trigger a refresh. Its `apiBase` and model select the request unless `POSTIL_API_BASE`/`REVIEW_MODEL` are set, which still win. `postil logout` revokes the stored refresh credential server-side and removes the local file even if that call fails. A legacy access-only login, an expired refresh inactivity window, or a rejected refresh produces one instruction to run `postil login` again rather than a provider authentication error. Temporary refresh failures retain the stored credential and ask the user to try again. +`postil login` authenticates against the configured login server over a device-authorization flow (open the printed URL, enter the code) and stores a renewable credential at `${XDG_CONFIG_HOME:-~/.config}/postil/credentials.json`, mode `0600` in a `0700` directory. An ambiguous polling failure retries the same device code so the service can recover the already-issued pair during its 60-second recovery window. The credential records its canonical issuing server. Refresh and logout use that stored issuer; a conflicting explicit `POSTIL_LOGIN_SERVER` fails before any login credential is sent. Issuer-free credentials from older CLI versions infer `https://postil.dev` only when their normalized `apiBase` is the canonical Postil inference endpoint. An issuer-free credential for any custom endpoint requires `postil login` again before refresh or logout. + +The stored credential is a fallback used only when none of `POSTIL_API_KEY`, `OPENROUTER_API_KEY`, `MODEL_API_KEY`, or `LLM_API_KEY` is set. Postil rotates it before access expiry and persists the replacement; explicit API keys never trigger a refresh. Its `apiBase` is the only endpoint that may receive its bearer. A different `POSTIL_API_BASE` fails before network access unless an explicit API key is set. `REVIEW_MODEL` may still select a model at the stored endpoint. A legacy access-only login, an expired refresh inactivity window, or a rejected refresh instructs the user to run `postil login` again. A valid `Retry-After` value on a temporary refresh rate limit is reported in seconds. Missing or malformed values use a generic retry message, and all temporary refresh failures retain the credential. + +`postil logout` removes the active local credential only after remote revocation succeeds. A failed revocation retains its sole retry handle and asks the user to run `postil logout` again. A new login stages the newly issued family and any overwritten family in a private pending-revocation queue before replacing the active credential. A local replacement failure therefore retains the new remote family's revocation handle. Pending retries never block normal review work and never follow `POSTIL_LOGIN_SERVER` overrides. ## Inspect and initialize diff --git a/src/api_key.rs b/src/api_key.rs index 84dbf06..4835c90 100644 --- a/src/api_key.rs +++ b/src/api_key.rs @@ -115,6 +115,7 @@ mod tests { fn stored_credential(expires_at: &str) -> Credentials { Credentials { version: credentials::CREDENTIALS_VERSION, + issuer: Some("https://postil.dev".to_string()), token: "pcli_stored-token-not-a-real-secret".to_string(), expires_at: expires_at.to_string(), refresh_token: None, @@ -122,6 +123,7 @@ mod tests { api_base: "https://postil.dev/api/inference/v1".to_string(), org: "runatlas-is".to_string(), model: "z-ai/glm-5.2".to_string(), + pending_revocations: Vec::new(), } } diff --git a/src/cli.rs b/src/cli.rs index 3014326..cfcf66d 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -247,14 +247,14 @@ pub enum Command { #[command(subcommand)] action: HookAction, }, - /// Authenticate against postil.dev for zero-config hosted inference. + /// Authenticate through a Postil login server. Login { /// Organization to select during approval. The browser approval page /// is authoritative for membership; this only pre-fills a hint. #[arg(long)] org: Option, }, - /// Remove the stored login credential and revoke it server-side. + /// Revoke the stored login server-side, then remove it locally. Logout, } diff --git a/src/config.rs b/src/config.rs index 7cbe312..4fd0e11 100644 --- a/src/config.rs +++ b/src/config.rs @@ -14,9 +14,10 @@ //! valid, unexpired credential exists at //! `${XDG_CONFIG_HOME:-~/.config}/postil/credentials.json`, //! `login::resolve_stored_token` supplies its bearer key and its `apiBase`/ -//! `model` replace the values resolved above -- unless `POSTIL_API_BASE` / -//! `REVIEW_MODEL` are set, which still win, applied first in this same -//! function. An expired or unreadable stored credential is left alone here; +//! `model` replace the values resolved above. `REVIEW_MODEL` may still replace +//! the model. `POSTIL_API_BASE` may replace the endpoint only when an explicit +//! API key is also set; a stored login remains bound to its issuing endpoint. +//! An expired or unreadable stored credential is left alone here; //! `resolve_api_key` reports it as one actionable "run `postil login` again" //! error, while `postil config` reports the stored-login state without needing //! a live key. @@ -1471,10 +1472,11 @@ impl Config { /// A first-time `postil login` user gets working defaults with zero /// `.postil.yaml`: when no explicit key env var is set, a stored - /// credential supplies `apiBase` and `model`, unless `POSTIL_API_BASE`/ - /// `REVIEW_MODEL` (just applied above) already overrode them. The - /// cascade is cleared along with `model`, since a leftover BYOK cascade - /// names models the hosted gateway does not operate. + /// credential supplies `apiBase` and `model`, unless `POSTIL_API_BASE` or + /// `REVIEW_MODEL` (just applied above) already overrode them. Runtime + /// credential resolution rejects an endpoint override when no explicit + /// API key is set. The cascade is cleared along with `model`, since a + /// leftover BYOK cascade names models the hosted gateway does not operate. /// /// This applies even when the credential is expired: routing /// (`apiBase`/`model`) is not a secret, and populating it here is what @@ -1959,7 +1961,7 @@ fn repository_model_config_locked() -> bool { .unwrap_or(false) } -fn normalize_api_base(value: &str) -> Result { +pub(crate) fn normalize_api_base(value: &str) -> Result { let url = reqwest::Url::parse(value).context("model API base must be an absolute URL")?; anyhow::ensure!( matches!(url.scheme(), "http" | "https"), diff --git a/src/credentials.rs b/src/credentials.rs index a8d7414..0e6eae1 100644 --- a/src/credentials.rs +++ b/src/credentials.rs @@ -5,7 +5,7 @@ //! developer secret bound to one machine, not project configuration. The //! bearer token inside spends an organization's hosted-inference //! entitlement, so both the containing directory (0700) and the file itself -//! (0600) get their final permission bits set at creation time -- never +//! (0600) get their final permission bits set at creation time, never //! `chmod`'d afterward, so there is no window where the token is briefly //! world-readable. @@ -19,17 +19,33 @@ use anyhow::{Context, Result}; use fs2::FileExt; use serde::{Deserialize, Serialize}; -pub const CREDENTIALS_VERSION: u32 = 2; +pub const CREDENTIALS_VERSION: u32 = 3; pub const LEGACY_CREDENTIALS_VERSION: u32 = 1; +pub const LEGACY_REFRESH_CREDENTIALS_VERSION: u32 = 2; +const PENDING_REVOCATIONS_VERSION: u32 = 1; // The refresh exchange has separately bounded send and body-read phases. A // second local process waits long enough for both before asking the caller to // retry, so concurrent agent runs converge on one token rotation. const CREDENTIAL_LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(45); const CREDENTIAL_LOCK_RETRY: std::time::Duration = std::time::Duration::from_millis(25); +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PendingRevocation { + pub issuer: String, + pub token: String, + #[serde( + default, + rename = "refreshToken", + skip_serializing_if = "Option::is_none" + )] + pub refresh_token: Option, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Credentials { pub version: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub issuer: Option, pub token: String, #[serde(rename = "expiresAt")] pub expires_at: String, @@ -49,6 +65,18 @@ pub struct Credentials { pub api_base: String, pub org: String, pub model: String, + #[serde( + default, + rename = "pendingRevocations", + skip_serializing_if = "Vec::is_empty" + )] + pub pending_revocations: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +struct PendingRevocations { + version: u32, + revocations: Vec, } impl Credentials { @@ -118,11 +146,20 @@ pub fn read(path: &Path) -> Result> { anyhow::ensure!( matches!( credentials.version, - LEGACY_CREDENTIALS_VERSION | CREDENTIALS_VERSION + LEGACY_CREDENTIALS_VERSION | LEGACY_REFRESH_CREDENTIALS_VERSION | CREDENTIALS_VERSION ), "unsupported credentials version {}; run `postil login` again", credentials.version ); + anyhow::ensure!( + credentials.version != CREDENTIALS_VERSION + || credentials + .issuer + .as_deref() + .is_some_and(|issuer| !issuer.trim().is_empty()), + "credentials version {} is missing its issuer; run `postil login` again", + credentials.version + ); Ok(Some(credentials)) } @@ -130,11 +167,77 @@ pub fn read(path: &Path) -> Result> { /// bits already set, filled, `fsync`'d, then renamed into place, so a reader /// never observes a partially written or briefly world-readable file. pub fn write(path: &Path, credentials: &Credentials) -> Result<()> { - let parent = path + anyhow::ensure!( + matches!( + credentials.version, + LEGACY_CREDENTIALS_VERSION | LEGACY_REFRESH_CREDENTIALS_VERSION | CREDENTIALS_VERSION + ), + "unsupported credentials version {}", + credentials.version + ); + anyhow::ensure!( + credentials.version != CREDENTIALS_VERSION + || credentials + .issuer + .as_deref() + .is_some_and(|issuer| !issuer.trim().is_empty()), + "credentials version {} is missing its issuer", + credentials.version + ); + write_private_json(path, credentials, "credentials") +} + +pub fn read_pending(credentials_path: &Path) -> Result> { + let path = pending_path(credentials_path)?; + let raw = match fs::read_to_string(&path) { + Ok(raw) => raw, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => { + return Err(error).with_context(|| format!("reading {}", path.display())); + } + }; + let pending: PendingRevocations = + serde_json::from_str(&raw).with_context(|| format!("parsing {}", path.display()))?; + anyhow::ensure!( + pending.version == PENDING_REVOCATIONS_VERSION, + "unsupported pending revocations version {}", + pending.version + ); + Ok(pending.revocations) +} + +pub fn write_pending(credentials_path: &Path, revocations: &[PendingRevocation]) -> Result<()> { + let path = pending_path(credentials_path)?; + if revocations.is_empty() { + return remove(&path); + } + write_private_json( + &path, + &PendingRevocations { + version: PENDING_REVOCATIONS_VERSION, + revocations: revocations.to_vec(), + }, + "pending revocations", + ) +} + +fn pending_path(credentials_path: &Path) -> Result { + let parent = credentials_path .parent() .context("credentials path must have a parent directory")?; + Ok(parent.join("pending-revocations.json")) +} + +fn write_private_json(path: &Path, value: &T, description: &str) -> Result<()> { + let parent = path + .parent() + .with_context(|| format!("{description} path must have a parent directory"))?; create_private_dir(parent)?; - let temp_path = parent.join(format!(".credentials.{}.tmp", std::process::id())); + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .with_context(|| format!("{description} path must have a file name"))?; + let temp_path = parent.join(format!(".{file_name}.{}.tmp", std::process::id())); let _ = fs::remove_file(&temp_path); let mut options = OpenOptions::new(); options.write(true).create_new(true); @@ -143,19 +246,15 @@ pub fn write(path: &Path, credentials: &Credentials) -> Result<()> { let result = (|| -> Result<()> { let mut file = options .open(&temp_path) - .context("creating private credentials file")?; - anyhow::ensure!( - matches!( - credentials.version, - LEGACY_CREDENTIALS_VERSION | CREDENTIALS_VERSION - ), - "unsupported credentials version {}", - credentials.version - ); - serde_json::to_writer_pretty(&mut file, credentials).context("serializing credentials")?; - file.write_all(b"\n").context("writing credentials")?; - file.sync_all().context("syncing credentials")?; - fs::rename(&temp_path, path).context("installing credentials file")?; + .with_context(|| format!("creating private {description} file"))?; + serde_json::to_writer_pretty(&mut file, value) + .with_context(|| format!("serializing {description}"))?; + file.write_all(b"\n") + .with_context(|| format!("writing {description}"))?; + file.sync_all() + .with_context(|| format!("syncing {description}"))?; + fs::rename(&temp_path, path).with_context(|| format!("installing {description} file"))?; + sync_parent_dir(parent).with_context(|| format!("syncing {description} directory"))?; Ok(()) })(); if result.is_err() { @@ -164,6 +263,17 @@ pub fn write(path: &Path, credentials: &Credentials) -> Result<()> { result } +#[cfg(unix)] +fn sync_parent_dir(parent: &Path) -> Result<()> { + File::open(parent)?.sync_all()?; + Ok(()) +} + +#[cfg(not(unix))] +fn sync_parent_dir(_parent: &Path) -> Result<()> { + Ok(()) +} + /// A kernel-backed, cross-process lock shared by every stored-login mutation. /// Keeping the lock file in place avoids stale-file recovery: the operating /// system releases the exclusive lock when the handle closes or its process @@ -250,6 +360,7 @@ mod tests { fn sample(expires_at: &str) -> Credentials { Credentials { version: CREDENTIALS_VERSION, + issuer: Some("https://postil.dev".to_string()), token: "pcli_test-token-not-a-real-secret".to_string(), expires_at: expires_at.to_string(), refresh_token: Some("fixture-refresh-not-a-credential".to_string()), @@ -257,6 +368,7 @@ mod tests { api_base: "https://postil.dev/api/inference/v1".to_string(), org: "runatlas-is".to_string(), model: "z-ai/glm-5.2".to_string(), + pending_revocations: Vec::new(), } } @@ -349,8 +461,42 @@ mod tests { .unwrap(); let credentials = read(&path).unwrap().unwrap(); assert_eq!(credentials.version, 1); + assert!(credentials.issuer.is_none()); assert!(credentials.refresh_token.is_none()); assert!(credentials.refresh_expires_at.is_none()); + assert!(credentials.pending_revocations.is_empty()); + } + + #[test] + fn reads_a_v2_refresh_credential_without_an_issuer() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("credentials.json"); + fs::write( + &path, + r#"{"version":2,"token":"pcli_test-token-not-a-real-secret","expiresAt":"2999-01-01T00:00:00.000Z","refreshToken":"fixture-refresh-not-a-credential","refreshExpiresAt":"2999-12-01T00:00:00.000Z","apiBase":"https://postil.dev/api/inference/v1","org":"runatlas-is","model":"z-ai/glm-5.2"}"#, + ) + .unwrap(); + let credentials = read(&path).unwrap().unwrap(); + assert_eq!(credentials.version, LEGACY_REFRESH_CREDENTIALS_VERSION); + assert!(credentials.issuer.is_none()); + assert!(credentials.can_refresh()); + } + + #[test] + fn rejects_a_current_refresh_credential_without_an_issuer() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("credentials.json"); + fs::write( + &path, + format!( + r#"{{"version":{CREDENTIALS_VERSION},"token":"pcli_test-token-not-a-real-secret","expiresAt":"2999-01-01T00:00:00.000Z","refreshToken":"fixture-refresh-not-a-credential","refreshExpiresAt":"2999-12-01T00:00:00.000Z","apiBase":"https://postil.dev/api/inference/v1","org":"runatlas-is","model":"z-ai/glm-5.2"}}"# + ), + ) + .unwrap(); + + let error = read(&path).expect_err("current credentials must carry their issuer"); + assert!(error.to_string().contains("missing its issuer")); + assert!(error.to_string().contains("postil login")); } #[test] @@ -359,12 +505,14 @@ mod tests { let path = dir.path().join("postil").join("credentials.json"); let mut credentials = sample("2999-01-01T00:00:00.000Z"); credentials.version = 1; + credentials.issuer = None; credentials.refresh_token = None; credentials.refresh_expires_at = None; write(&path, &credentials).unwrap(); let raw = fs::read_to_string(path).unwrap(); assert!(raw.contains("\"version\": 1")); assert!(!raw.contains("refreshToken")); + assert!(!raw.contains("issuer")); } #[test] @@ -402,4 +550,39 @@ mod tests { "stored login lock file must be mode 0600, got {mode:o}" ); } + + #[test] + #[cfg(unix)] + fn pending_revocations_are_private_and_atomically_replaced() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let credentials_path = dir.path().join("postil").join("credentials.json"); + let first = PendingRevocation { + issuer: "https://postil.dev".to_string(), + token: "pcli_first-access-not-a-real-secret".to_string(), + refresh_token: Some("fixture-first-refresh-not-a-credential".to_string()), + }; + let replacement = PendingRevocation { + issuer: "https://login.example.test".to_string(), + token: "pcli_second-access-not-a-real-secret".to_string(), + refresh_token: None, + }; + + write_pending(&credentials_path, std::slice::from_ref(&first)).unwrap(); + write_pending(&credentials_path, std::slice::from_ref(&replacement)).unwrap(); + + assert_eq!(read_pending(&credentials_path).unwrap(), vec![replacement]); + let pending_path = credentials_path + .parent() + .unwrap() + .join("pending-revocations.json"); + let mode = fs::metadata(&pending_path).unwrap().permissions().mode() & 0o777; + assert_eq!( + mode, 0o600, + "pending revocations file must be mode 0600, got {mode:o}" + ); + let temp_name = format!(".pending-revocations.json.{}.tmp", std::process::id()); + assert!(!pending_path.parent().unwrap().join(temp_name).exists()); + } } diff --git a/src/doctor.rs b/src/doctor.rs index 915a74b..e1435bc 100644 --- a/src/doctor.rs +++ b/src/doctor.rs @@ -62,7 +62,7 @@ pub async fn run(cfg: &Config) -> Result> { Ok(Some(key)) } else { match credentials_path.as_ref() { - Ok(path) => login::resolve_stored_token(path).await, + Ok(path) => login::resolve_stored_token(path, &cfg.api_base).await, Err(error) => Err(anyhow::anyhow!("{error:#}")), } }; @@ -229,6 +229,7 @@ mod tests { fn stored_login() -> Credentials { Credentials { version: credentials::CREDENTIALS_VERSION, + issuer: Some("https://postil.dev".to_string()), token: "pcli_test-access-not-a-real-secret".to_string(), expires_at: "2999-01-01T00:00:00.000Z".to_string(), refresh_token: Some("fixture-refresh-not-a-credential".to_string()), @@ -236,6 +237,7 @@ mod tests { api_base: "https://postil.dev/api/inference/v1".to_string(), org: "runatlas-is".to_string(), model: "z-ai/glm-5.2".to_string(), + pending_revocations: Vec::new(), } } diff --git a/src/llm.rs b/src/llm.rs index c3ce009..b7171c2 100644 --- a/src/llm.rs +++ b/src/llm.rs @@ -2603,7 +2603,7 @@ impl LlmClient { /// Local and interactive clients have no built-in total deadline. They only /// get one when POSTIL_LLM_TOTAL_TIMEOUT_SECS is explicitly set. pub async fn from_env(cfg: &Config) -> Result { - let api_key = resolve_api_key().await?; + let api_key = resolve_api_key(&cfg.api_base).await?; let timeouts = LlmTimeouts::from_env(DEFAULT_REQUEST_TIMEOUT_SECS, None)?; let total_deadline = timeouts.total.map(|duration| Instant::now() + duration); Self::build( @@ -2622,7 +2622,7 @@ impl LlmClient { default_review_timeout: Duration, default_total_timeout: Duration, ) -> Result { - let api_key = resolve_api_key().await?; + let api_key = resolve_api_key(&cfg.api_base).await?; let timeouts = LlmTimeouts::from_env( default_request_timeout.as_secs(), Some(default_total_timeout.as_secs()), @@ -5650,7 +5650,7 @@ impl LlmTimeouts { } } -async fn resolve_api_key() -> Result { +async fn resolve_api_key(api_base: &str) -> Result { // Checked first, and independently of the credentials-path lookup below, // so an explicit key still works in a minimal environment with no HOME // or XDG_CONFIG_HOME -- exactly what worked before login existed. @@ -5658,7 +5658,7 @@ async fn resolve_api_key() -> Result { let stored_login = async { let credentials_path = crate::credentials::default_path() .context("resolving the postil login credentials path")?; - crate::login::resolve_stored_token(&credentials_path).await + crate::login::resolve_stored_token(&credentials_path, api_base).await }; match api_key::resolve_explicit_or_stored(explicit_key, stored_login).await? { Some(key) => Ok(key), diff --git a/src/login.rs b/src/login.rs index 38fb95b..f864d7b 100644 --- a/src/login.rs +++ b/src/login.rs @@ -13,7 +13,8 @@ use anyhow::{Context, Result, anyhow}; use serde::Deserialize; use serde::de::DeserializeOwned; -use crate::credentials::{self, Credentials}; +use crate::config::normalize_api_base; +use crate::credentials::{self, Credentials, PendingRevocation}; use crate::llm::secure_http_client; /// Overrides the Postil web app the device-flow calls target. Distinct from @@ -21,6 +22,7 @@ use crate::llm::secure_http_client; /// inference gateway itself, not the auth endpoints. const LOGIN_SERVER_ENV: &str = "POSTIL_LOGIN_SERVER"; const DEFAULT_LOGIN_SERVER: &str = "https://postil.dev"; +const DEFAULT_LOGIN_API_BASE: &str = "https://postil.dev/api/inference/v1"; /// The server caps polling at 200 attempts and then returns 410 regardless; /// this is only a client-side backstop against a stuck loop if that cap is @@ -29,27 +31,138 @@ const MAX_POLL_ATTEMPTS: u32 = 220; const ACCESS_REFRESH_MARGIN: Duration = Duration::from_secs(5 * 60); const REFRESH_RESPONSE_MAX_BYTES: usize = 16 * 1024; const REFRESH_REQUEST_TIMEOUT: Duration = Duration::from_secs(20); +const REVOCATION_REQUEST_TIMEOUT: Duration = Duration::from_secs(20); -fn login_server() -> String { - std::env::var(LOGIN_SERVER_ENV) - .ok() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) - .unwrap_or_else(|| DEFAULT_LOGIN_SERVER.to_string()) +fn canonicalize_issuer(value: &str) -> Result { + anyhow::ensure!( + !value.trim().is_empty(), + "login server URL must not be empty" + ); + let mut url = + reqwest::Url::parse(value.trim()).context("login server must be an absolute URL")?; + anyhow::ensure!( + matches!(url.scheme(), "http" | "https"), + "login server must use HTTP or HTTPS" + ); + anyhow::ensure!( + url.username().is_empty() && url.password().is_none(), + "login server must not contain credentials" + ); + anyhow::ensure!( + url.host_str().is_some_and(|hostname| !hostname.is_empty()), + "login server must include a hostname" + ); + anyhow::ensure!( + url.query().is_none() && url.fragment().is_none(), + "login server must not contain a query or fragment" + ); + let path = url.path().trim_end_matches('/').to_string(); + url.set_path(if path.is_empty() { "/" } else { &path }); + Ok(url.as_str().trim_end_matches('/').to_string()) +} + +fn login_server_override() -> Result> { + let Some(_) = std::env::var_os(LOGIN_SERVER_ENV) else { + return Ok(None); + }; + let value = std::env::var(LOGIN_SERVER_ENV) + .with_context(|| format!("{LOGIN_SERVER_ENV} must contain a valid URL"))?; + canonicalize_issuer(&value) + .with_context(|| format!("invalid {LOGIN_SERVER_ENV}")) + .map(Some) +} + +fn login_server() -> Result { + login_server_override()?.map_or_else(|| canonicalize_issuer(DEFAULT_LOGIN_SERVER), Ok) +} + +fn stored_issuer(credentials: &Credentials) -> Result { + if let Some(issuer) = credentials.issuer.as_deref() { + return canonicalize_issuer(issuer) + .context("the stored login issuer is invalid; run `postil login` again"); + } + + let stored_api_base = normalize_api_base(&credentials.api_base) + .context("the stored login API base is invalid; run `postil login` again")?; + let canonical_api_base = normalize_api_base(DEFAULT_LOGIN_API_BASE) + .expect("the embedded Postil API base must be valid"); + anyhow::ensure!( + stored_api_base == canonical_api_base, + "the stored legacy login has no issuing server and its API base is not the canonical Postil endpoint; run `postil login` again" + ); + canonicalize_issuer(DEFAULT_LOGIN_SERVER) +} + +fn require_matching_issuer( + credentials: &Credentials, + configured_issuer: Option<&str>, +) -> Result { + let issuer = stored_issuer(credentials)?; + if let Some(configured_issuer) = configured_issuer { + let configured_issuer = canonicalize_issuer(configured_issuer) + .with_context(|| format!("invalid {LOGIN_SERVER_ENV}"))?; + anyhow::ensure!( + configured_issuer == issuer, + "{LOGIN_SERVER_ENV} does not match the stored login issuer; unset it to use this login, or run `postil login` to replace the login for that server" + ); + } + Ok(issuer) +} + +fn require_matching_api_base(credentials: &Credentials, resolved_api_base: &str) -> Result<()> { + let stored_api_base = normalize_api_base(&credentials.api_base) + .context("the stored login API base is invalid; run `postil login` again")?; + let resolved_api_base = + normalize_api_base(resolved_api_base).context("the resolved model API base is invalid")?; + if stored_api_base != resolved_api_base { + let key_names = crate::api_key::names_text(); + anyhow::bail!( + "the stored postil login is bound to {}; the resolved model API base is {}. Set an explicit key with {}, or remove the API base override to use this login", + credentials.api_base, + resolved_api_base, + key_names + ); + } + Ok(()) } pub async fn run_login(org: Option) -> Result { - let server = login_server(); + let server = login_server()?; let client = secure_http_client(&server).context("building the postil login HTTP client")?; let path = credentials::default_path()?; login_with(&client, &server, org.as_deref(), &path).await } pub async fn run_logout() -> Result { - let server = login_server(); - let client = secure_http_client(&server).context("building the postil logout HTTP client")?; + let configured_issuer = login_server_override()?; let path = credentials::default_path()?; - logout_with(&client, &server, &path).await + logout_with_mode(ClientMode::Secure, configured_issuer.as_deref(), &path).await +} + +#[derive(Clone, Copy)] +enum ClientMode<'a> { + Secure, + #[cfg(test)] + Provided(&'a reqwest::Client), + Reuse { + client: &'a reqwest::Client, + issuer: &'a str, + }, +} + +impl ClientMode<'_> { + fn for_issuer(self, issuer: &str) -> Result { + match self { + #[cfg(test)] + Self::Provided(client) => Ok(client.clone()), + Self::Reuse { + client, + issuer: client_issuer, + } if client_issuer == issuer => Ok(client.clone()), + Self::Secure | Self::Reuse { .. } => secure_http_client(issuer) + .context("building an HTTP client for stored login issuer"), + } + } } #[derive(Debug, Deserialize)] @@ -103,6 +216,7 @@ async fn login_with( org: Option<&str>, credentials_path: &Path, ) -> Result { + let server = canonicalize_issuer(server)?; let mut start_body = serde_json::json!({ "clientVersion": env!("CARGO_PKG_VERSION") }); // The device/start contract takes no org field today; sending it as an // extra, ignorable JSON key is a forward-compatible hint only. Org @@ -111,7 +225,7 @@ async fn login_with( if let Some(org) = org { start_body["org"] = serde_json::Value::String(org.to_string()); } - let start_url = format!("{}/api/cli/device/start", server.trim_end_matches('/')); + let start_url = format!("{server}/api/cli/device/start"); let start_response = client .post(&start_url) .json(&start_body) @@ -139,21 +253,24 @@ async fn login_with( eprintln!("postil: waiting for approval..."); let interval = Duration::from_secs(interval_secs(start.interval)); - let token_url = format!("{}/api/cli/device/token", server.trim_end_matches('/')); + let token_url = format!("{server}/api/cli/device/token"); for _ in 0..MAX_POLL_ATTEMPTS { tokio::time::sleep(interval).await; - let response = client + let response = match client .post(&token_url) .json(&serde_json::json!({ "deviceCode": start.device_code })) .send() .await - .context("polling postil login status")?; + { + Ok(response) => response, + Err(_) => continue, + }; match response.status().as_u16() { 200 => { - let approved: DeviceTokenApproved = response - .json() - .await - .context("parsing postil login approval response")?; + let approved: DeviceTokenApproved = match response.json().await { + Ok(approved) => approved, + Err(_) => continue, + }; let DeviceTokenApproved { token, expires_at, @@ -163,10 +280,11 @@ async fn login_with( org, model, } = approved; - let renewable = match (refresh_token, refresh_expires_at) { + let mut renewable = match (refresh_token, refresh_expires_at) { (Some(refresh_token), Some(refresh_expires_at)) => { let credentials = Credentials { version: credentials::CREDENTIALS_VERSION, + issuer: Some(server.clone()), token, expires_at, refresh_token: Some(refresh_token), @@ -174,13 +292,15 @@ async fn login_with( api_base, org: org.slug, model, + pending_revocations: Vec::new(), }; validate_refresh_response(&credentials) .context("validating postil login approval response")?; credentials } (None, None) => Credentials { - version: credentials::LEGACY_CREDENTIALS_VERSION, + version: credentials::CREDENTIALS_VERSION, + issuer: Some(server.clone()), token, expires_at, refresh_token: None, @@ -188,14 +308,23 @@ async fn login_with( api_base, org: org.slug, model, + pending_revocations: Vec::new(), }, _ => anyhow::bail!( "the postil login approval response has incomplete renewable credentials" ), }; let renewable_login = renewable.refresh_token.is_some(); - let _lock = credentials::CredentialLock::acquire(credentials_path).await?; - credentials::write(credentials_path, &renewable)?; + let pending_count = persist_approved_login_with( + ClientMode::Reuse { + client, + issuer: &server, + }, + credentials_path, + &mut renewable, + credentials::write, + ) + .await?; if renewable_login { eprintln!( "postil: logged in to {} ({}): renewable until inactive after {}", @@ -212,9 +341,13 @@ async fn login_with( org.name, renewable.org ); } + if pending_count > 0 { + eprintln!("postil: a previous login is pending revocation and will be retried"); + } return Ok(0); } 428 => continue, + 408 | 429 | 500..=599 => continue, 403 => { eprintln!("postil: login request was denied"); return Ok(1); @@ -239,64 +372,134 @@ fn interval_secs(server_interval: u64) -> u64 { server_interval.clamp(1, 30) } +#[cfg(test)] async fn logout_with( client: &reqwest::Client, - server: &str, + configured_issuer: &str, + credentials_path: &Path, +) -> Result { + logout_with_mode( + ClientMode::Provided(client), + Some(configured_issuer), + credentials_path, + ) + .await +} + +async fn logout_with_mode( + client_mode: ClientMode<'_>, + configured_issuer: Option<&str>, credentials_path: &Path, ) -> Result { let _lock = credentials::CredentialLock::acquire(credentials_path).await?; - match credentials::read(credentials_path) { - Ok(Some(creds)) => { - let logout_url = format!("{}/api/cli/logout", server.trim_end_matches('/')); - let request = client.post(&logout_url).bearer_auth(&creds.token); - let request = if let Some(refresh_token) = creds - .refresh_token - .as_deref() - .filter(|token| !token.trim().is_empty()) - { - request.json(&serde_json::json!({ "refreshToken": refresh_token })) - } else { - request - }; - match request.send().await { - Ok(response) if response.status().is_success() => {} - Ok(response) => eprintln!( - "postil: logout request returned {}; removing the local credential anyway", - response.status() - ), - Err(_) => eprintln!( - "postil: could not reach postil to revoke the credential; removing the local credential anyway" - ), - } + let Some(mut stored) = credentials::read(credentials_path)? else { + let mut pending = credentials::read_pending(credentials_path)?; + if pending.is_empty() { + eprintln!("postil: not logged in"); + return Ok(0); } - Ok(None) => eprintln!("postil: not logged in"), - Err(_) => eprintln!("postil: stored credentials were unreadable; removing them anyway"), + let remaining = + drain_pending_without_active_locked(client_mode, credentials_path, &mut pending) + .await?; + if remaining == 0 { + eprintln!("postil: pending session revocations completed; no login is stored"); + return Ok(0); + } + eprintln!("postil: a previous session could not be revoked; run `postil logout` again"); + return Ok(1); + }; + + let issuer = require_matching_issuer(&stored, configured_issuer)?; + let mut detached = credentials::read_pending(credentials_path)?; + if !detached.is_empty() { + stored.pending_revocations.append(&mut detached); + deduplicate_revocations(&mut stored.pending_revocations); + credentials::write(credentials_path, &stored)?; + } + + let current = revocation_for(&stored)?; + if !revoke(client_mode, ¤t).await { + eprintln!( + "postil: could not revoke the stored login at its issuing server; credentials were kept. Run `postil logout` again" + ); + return Ok(1); } - // Removal happens regardless of what happened above: a developer running - // `postil logout` wants the local secret gone even if the network is down. + + debug_assert_eq!(issuer, current.issuer); + let remaining = + drain_pending_for_active_locked(client_mode, credentials_path, &mut stored).await?; + if remaining == 0 { + credentials::remove(credentials_path)?; + credentials::write_pending(credentials_path, &[])?; + eprintln!("postil: logged out"); + return Ok(0); + } + + credentials::write_pending(credentials_path, &stored.pending_revocations)?; credentials::remove(credentials_path)?; - eprintln!("postil: logged out"); - Ok(0) + eprintln!( + "postil: the current login was revoked, but a previous session is still pending revocation; run `postil logout` again" + ); + Ok(1) } /// Resolves a stored login for a hosted request. Explicit API-key environment /// variables are handled by the caller before this function is reached, so a /// BYOK invocation never reads, locks, or refreshes a local Postil login. -pub(crate) async fn resolve_stored_token(credentials_path: &Path) -> Result> { - let server = login_server(); - let client = secure_http_client(&server).context("building the postil refresh HTTP client")?; - resolve_stored_token_with(&client, &server, credentials_path).await +pub(crate) async fn resolve_stored_token( + credentials_path: &Path, + resolved_api_base: &str, +) -> Result> { + let configured_issuer = login_server_override()?; + let result = resolve_stored_token_with_mode( + ClientMode::Secure, + configured_issuer.as_deref(), + credentials_path, + resolved_api_base, + ) + .await; + if result.is_ok() { + spawn_pending_revocation_drain(credentials_path); + } + result } +#[cfg(test)] async fn resolve_stored_token_with( client: &reqwest::Client, - server: &str, + configured_issuer: &str, credentials_path: &Path, +) -> Result> { + let resolved_api_base = credentials::read(credentials_path)? + .map(|credentials| credentials.api_base) + .unwrap_or_default(); + resolve_stored_token_with_mode( + ClientMode::Provided(client), + Some(configured_issuer), + credentials_path, + &resolved_api_base, + ) + .await +} + +async fn resolve_stored_token_with_mode( + client_mode: ClientMode<'_>, + configured_issuer: Option<&str>, + credentials_path: &Path, + resolved_api_base: &str, ) -> Result> { let Some(credentials) = credentials::read(credentials_path)? else { return Ok(None); }; - if credentials.refresh_token.is_some() && !credentials.can_refresh() { + require_matching_api_base(&credentials, resolved_api_base)?; + if credentials.refresh_token.is_none() { + if credentials.is_expired() { + anyhow::bail!("the stored postil login credential expired; run `postil login` again"); + } + return Ok(Some(credentials.token)); + } + require_matching_issuer(&credentials, configured_issuer)?; + if !credentials.can_refresh() { anyhow::bail!("the stored postil login can no longer be renewed; run `postil login` again"); } if !credentials.expires_within(ACCESS_REFRESH_MARGIN) { @@ -307,7 +510,9 @@ async fn resolve_stored_token_with( let Some(credentials) = credentials::read(credentials_path)? else { return Ok(None); }; - if credentials.refresh_token.is_some() && !credentials.can_refresh() { + require_matching_api_base(&credentials, resolved_api_base)?; + let issuer = require_matching_issuer(&credentials, configured_issuer)?; + if !credentials.can_refresh() { anyhow::bail!("the stored postil login can no longer be renewed; run `postil login` again"); } if !credentials.expires_within(ACCESS_REFRESH_MARGIN) { @@ -317,9 +522,11 @@ async fn resolve_stored_token_with( anyhow::bail!("the stored postil login credential expired; run `postil login` again"); }; - let refreshed = refresh_token_with(client, server, refresh_token).await?; + let client = client_mode.for_issuer(&issuer)?; + let refreshed = refresh_token_with(&client, &issuer, refresh_token).await?; let replacement = Credentials { version: credentials::CREDENTIALS_VERSION, + issuer: Some(issuer), token: refreshed.token, expires_at: refreshed.expires_at, refresh_token: Some(refreshed.refresh_token), @@ -327,12 +534,191 @@ async fn resolve_stored_token_with( api_base: credentials.api_base, org: credentials.org, model: credentials.model, + pending_revocations: credentials.pending_revocations, }; validate_refresh_response(&replacement)?; credentials::write(credentials_path, &replacement)?; Ok(Some(replacement.token)) } +fn revocation_for(credentials: &Credentials) -> Result { + anyhow::ensure!( + !credentials.token.trim().is_empty(), + "the stored login cannot be revoked because its access credential is invalid; run `postil login` again" + ); + Ok(PendingRevocation { + issuer: stored_issuer(credentials)?, + token: credentials.token.clone(), + refresh_token: credentials + .refresh_token + .as_deref() + .filter(|token| !token.trim().is_empty()) + .map(str::to_string), + }) +} + +fn deduplicate_revocations(revocations: &mut Vec) { + let mut unique = Vec::with_capacity(revocations.len()); + for revocation in revocations.drain(..) { + if !unique.contains(&revocation) { + unique.push(revocation); + } + } + *revocations = unique; +} + +async fn persist_approved_login_with( + client_mode: ClientMode<'_>, + credentials_path: &Path, + approved: &mut Credentials, + write_active: F, +) -> Result +where + F: FnOnce(&Path, &Credentials) -> Result<()>, +{ + let _lock = credentials::CredentialLock::acquire(credentials_path).await?; + let new_revocation = revocation_for(approved)?; + let mut pending = credentials::read_pending(credentials_path)?; + pending.push(new_revocation.clone()); + deduplicate_revocations(&mut pending); + if let Err(write_error) = credentials::write_pending(credentials_path, &pending) { + if revoke(client_mode, &new_revocation).await { + return Err(write_error) + .context("could not retain the new login locally; its remote session was revoked"); + } + return Err(write_error).context( + "could not retain the new login or its revocation handle locally; the remote session may remain active", + ); + } + if let Some(existing) = credentials::read(credentials_path)? { + pending.extend(existing.pending_revocations.iter().cloned()); + pending.push(revocation_for(&existing)?); + } + deduplicate_revocations(&mut pending); + credentials::write_pending(credentials_path, &pending)?; + approved.pending_revocations = pending; + write_active(credentials_path, approved)?; + let pending_count = + match drain_pending_for_active_locked(client_mode, credentials_path, approved).await { + Ok(count) => count, + Err(_) => approved.pending_revocations.len(), + }; + Ok(pending_count) +} + +fn revocation_matches_active(revocation: &PendingRevocation, active: &Credentials) -> Result { + if canonicalize_issuer(&revocation.issuer)? != stored_issuer(active)? { + return Ok(false); + } + if revocation.token == active.token { + return Ok(true); + } + Ok(revocation + .refresh_token + .as_deref() + .is_some_and(|pending| active.refresh_token.as_deref() == Some(pending))) +} + +async fn revoke(client_mode: ClientMode<'_>, revocation: &PendingRevocation) -> bool { + if revocation.token.trim().is_empty() { + return false; + } + let Ok(issuer) = canonicalize_issuer(&revocation.issuer) else { + return false; + }; + let Ok(client) = client_mode.for_issuer(&issuer) else { + return false; + }; + let logout_url = format!("{issuer}/api/cli/logout"); + let request = client.post(&logout_url).bearer_auth(&revocation.token); + let request = if let Some(refresh_token) = revocation + .refresh_token + .as_deref() + .filter(|token| !token.trim().is_empty()) + { + request.json(&serde_json::json!({ "refreshToken": refresh_token })) + } else { + request + }; + matches!( + tokio::time::timeout(REVOCATION_REQUEST_TIMEOUT, request.send()).await, + Ok(Ok(response)) if response.status().is_success() + ) +} + +async fn drain_pending_for_active_locked( + client_mode: ClientMode<'_>, + credentials_path: &Path, + active: &mut Credentials, +) -> Result { + let mut detached = credentials::read_pending(credentials_path)?; + if !detached.is_empty() { + active.pending_revocations.append(&mut detached); + deduplicate_revocations(&mut active.pending_revocations); + credentials::write(credentials_path, active)?; + let _ = credentials::write_pending(credentials_path, &[]); + } + + for revocation in active.pending_revocations.clone() { + match revocation_matches_active(&revocation, active) { + Ok(true) => { + active + .pending_revocations + .retain(|item| item != &revocation); + credentials::write(credentials_path, active)?; + continue; + } + Ok(false) => {} + Err(_) => continue, + } + if revoke(client_mode, &revocation).await { + active + .pending_revocations + .retain(|item| item != &revocation); + credentials::write(credentials_path, active)?; + } + } + Ok(active.pending_revocations.len()) +} + +async fn drain_pending_without_active_locked( + client_mode: ClientMode<'_>, + credentials_path: &Path, + pending: &mut Vec, +) -> Result { + deduplicate_revocations(pending); + for revocation in pending.clone() { + if revoke(client_mode, &revocation).await { + pending.retain(|item| item != &revocation); + credentials::write_pending(credentials_path, pending)?; + } + } + Ok(pending.len()) +} + +fn spawn_pending_revocation_drain(credentials_path: &Path) { + let credentials_path = credentials_path.to_path_buf(); + tokio::spawn(async move { + let _ = drain_pending_revocations(&credentials_path).await; + }); +} + +async fn drain_pending_revocations(credentials_path: &Path) -> Result<()> { + let _lock = credentials::CredentialLock::acquire(credentials_path).await?; + match credentials::read(credentials_path)? { + Some(mut active) => { + drain_pending_for_active_locked(ClientMode::Secure, credentials_path, &mut active) + .await?; + } + None => { + let mut pending = credentials::read_pending(credentials_path)?; + drain_pending_without_active_locked(ClientMode::Secure, credentials_path, &mut pending) + .await?; + } + } + Ok(()) +} + async fn refresh_token_with( client: &reqwest::Client, server: &str, @@ -350,7 +736,24 @@ async fn refresh_token_with( .map_err(|_| anyhow!("could not refresh the stored postil login; try again"))? .map_err(|_| anyhow!("could not refresh the stored postil login; try again"))?; let status = response.status(); - if status.is_server_error() || matches!(status.as_u16(), 408 | 429) { + if status.as_u16() == 429 { + if let Some(retry_after) = response + .headers() + .get(reqwest::header::RETRY_AFTER) + .and_then(|value| value.to_str().ok()) + .filter(|value| { + !value.is_empty() + && value.bytes().all(|byte| byte.is_ascii_digit()) + && value.parse::().is_ok() + }) + { + anyhow::bail!( + "could not refresh the stored postil login; retry after {retry_after} seconds" + ); + } + anyhow::bail!("could not refresh the stored postil login; try again"); + } + if status.is_server_error() || status.as_u16() == 408 { anyhow::bail!("could not refresh the stored postil login; try again"); } if !status.is_success() { @@ -504,6 +907,7 @@ mod tests { assert_eq!(exit, 0); let stored = credentials::read(&credentials_path).unwrap().unwrap(); assert_eq!(stored.token, "pcli_test-token-not-a-real-secret"); + assert_eq!(stored.issuer.as_deref(), Some(server.uri().as_str())); assert_eq!(stored.org, "runatlas-is"); assert_eq!(stored.model, "z-ai/glm-5.2"); assert_eq!( @@ -580,7 +984,7 @@ mod tests { ); let stored = credentials::read(&credentials_path).unwrap().unwrap(); - assert_eq!(stored.version, 1); + assert_eq!(stored.version, credentials::CREDENTIALS_VERSION); assert!(stored.refresh_token.is_none()); assert!(stored.refresh_expires_at.is_none()); } @@ -622,6 +1026,163 @@ mod tests { assert!(credentials::read(&credentials_path).unwrap().is_some()); } + #[tokio::test] + async fn login_retries_an_ambiguous_server_failure_with_the_same_device_code() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/cli/device/start")) + .respond_with(ResponseTemplate::new(200).set_body_json(start_body())) + .mount(&server) + .await; + let responder = SequentialTokenResponder { + calls: Arc::new(AtomicUsize::new(0)), + responses: Arc::new(vec![ + (500, serde_json::json!({"status": "uncertain"})), + (200, approved_body()), + ]), + }; + Mock::given(method("POST")) + .and(path("/api/cli/device/token")) + .respond_with(responder.clone()) + .mount(&server) + .await; + + let dir = tempfile::tempdir().unwrap(); + let credentials_path = dir.path().join("postil").join("credentials.json"); + assert_eq!( + login_with( + &reqwest::Client::new(), + &server.uri(), + None, + &credentials_path, + ) + .await + .unwrap(), + 0 + ); + assert_eq!(responder.calls.load(Ordering::SeqCst), 2); + let requests = server.received_requests().await.unwrap(); + let device_codes = requests + .iter() + .filter(|request| request.url.path() == "/api/cli/device/token") + .map(|request| request.body_json::().unwrap()["deviceCode"].clone()) + .collect::>(); + assert_eq!( + device_codes, + vec![ + serde_json::json!("test-device-code"), + serde_json::json!("test-device-code") + ] + ); + } + + #[tokio::test] + async fn login_write_failure_retains_the_new_family_revocation_handle() { + let server = MockServer::start().await; + let dir = tempfile::tempdir().unwrap(); + let credentials_path = dir.path().join("postil").join("credentials.json"); + let mut approved = stored_credentials_at(&server.uri(), "2999-01-01T00:00:00.000Z"); + approved.token = "pcli_test-token-not-a-real-secret".to_string(); + approved.refresh_token = Some("fixture-refresh-not-a-credential".to_string()); + let client = reqwest::Client::new(); + let error = persist_approved_login_with( + ClientMode::Provided(&client), + &credentials_path, + &mut approved, + |_path, _credentials| anyhow::bail!("simulated active credential write failure"), + ) + .await + .expect_err("installing the active credential should fail"); + + assert!( + error + .to_string() + .contains("simulated active credential write failure") + ); + assert!(credentials::read(&credentials_path).unwrap().is_none()); + let pending = credentials::read_pending(&credentials_path).unwrap(); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].issuer, server.uri()); + assert_eq!(pending[0].token, "pcli_test-token-not-a-real-secret"); + assert_eq!( + pending[0].refresh_token.as_deref(), + Some("fixture-refresh-not-a-credential") + ); + } + + #[tokio::test] + async fn relogin_retains_a_failed_old_family_revocation_until_retry_succeeds() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/cli/device/start")) + .respond_with(ResponseTemplate::new(200).set_body_json(start_body())) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/api/cli/device/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(approved_body())) + .mount(&server) + .await; + let revocations = SequentialTokenResponder { + calls: Arc::new(AtomicUsize::new(0)), + responses: Arc::new(vec![ + (503, serde_json::json!({"status": "retry"})), + (200, serde_json::json!({"status": "revoked"})), + ]), + }; + Mock::given(method("POST")) + .and(path("/api/cli/logout")) + .respond_with(revocations.clone()) + .mount(&server) + .await; + + let dir = tempfile::tempdir().unwrap(); + let credentials_path = dir.path().join("postil").join("credentials.json"); + credentials::write( + &credentials_path, + &stored_credentials_at(&server.uri(), "2999-01-01T00:00:00.000Z"), + ) + .unwrap(); + let client = reqwest::Client::new(); + + assert_eq!( + login_with(&client, &server.uri(), None, &credentials_path) + .await + .unwrap(), + 0 + ); + let mut stored = credentials::read(&credentials_path).unwrap().unwrap(); + assert_eq!(stored.token, "pcli_test-token-not-a-real-secret"); + assert_eq!(stored.pending_revocations.len(), 1); + assert_eq!( + stored.pending_revocations[0].refresh_token.as_deref(), + Some("fixture-old-refresh-not-a-credential") + ); + assert_eq!(stored.pending_revocations[0].issuer, server.uri()); + + let _lock = credentials::CredentialLock::acquire(&credentials_path) + .await + .unwrap(); + assert_eq!( + drain_pending_for_active_locked( + ClientMode::Provided(&client), + &credentials_path, + &mut stored, + ) + .await + .unwrap(), + 0 + ); + assert!( + credentials::read(&credentials_path) + .unwrap() + .unwrap() + .pending_revocations + .is_empty() + ); + assert_eq!(revocations.calls.load(Ordering::SeqCst), 2); + } + #[tokio::test] async fn login_reports_denial_and_writes_nothing() { let server = MockServer::start().await; @@ -682,11 +1243,27 @@ mod tests { assert!(credentials::read(&credentials_path).unwrap().is_none()); } + #[test] + fn issuer_normalization_removes_default_ports_and_trailing_slashes() { + assert_eq!( + canonicalize_issuer(" HTTPS://Example.COM:443/auth/// ").unwrap(), + "https://example.com/auth" + ); + assert_eq!( + canonicalize_issuer("http://Example.COM:80/").unwrap(), + "http://example.com" + ); + } + #[tokio::test] - async fn logout_removes_credentials_even_when_the_server_call_fails() { - // No mock mounted for /api/cli/logout at all: every request to this - // server 404s, standing in for "the network call failed." + async fn transient_logout_failure_preserves_the_revocation_handle() { let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/cli/logout")) + .respond_with(ResponseTemplate::new(503)) + .expect(1) + .mount(&server) + .await; let dir = tempfile::tempdir().unwrap(); let credentials_path = dir.path().join("postil").join("credentials.json"); @@ -694,6 +1271,7 @@ mod tests { &credentials_path, &Credentials { version: credentials::CREDENTIALS_VERSION, + issuer: Some(server.uri()), token: "pcli_test-token-not-a-real-secret".to_string(), expires_at: "2999-01-01T00:00:00.000Z".to_string(), refresh_token: Some("fixture-refresh-not-a-credential".to_string()), @@ -701,6 +1279,7 @@ mod tests { api_base: "https://postil.dev/api/inference/v1".to_string(), org: "runatlas-is".to_string(), model: "z-ai/glm-5.2".to_string(), + pending_revocations: Vec::new(), }, ) .unwrap(); @@ -708,8 +1287,86 @@ mod tests { let exit = logout_with(&reqwest::Client::new(), &server.uri(), &credentials_path) .await .unwrap(); - assert_eq!(exit, 0); - assert!(!credentials_path.exists()); + assert_eq!(exit, 1); + assert!(credentials_path.exists()); + let stored = credentials::read(&credentials_path).unwrap().unwrap(); + assert_eq!( + stored.refresh_token.as_deref(), + Some("fixture-refresh-not-a-credential") + ); + } + + #[tokio::test] + async fn explicit_issuer_conflict_sends_no_refresh_or_logout_credential() { + let issuing_server = MockServer::start().await; + let conflicting_server = MockServer::start().await; + let dir = tempfile::tempdir().unwrap(); + let credentials_path = dir.path().join("postil").join("credentials.json"); + let original = stored_credentials_at(&issuing_server.uri(), "2020-01-01T00:00:00.000Z"); + credentials::write(&credentials_path, &original).unwrap(); + let client = reqwest::Client::new(); + let conflicting_issuer = format!("{}/", conflicting_server.uri()); + + let refresh_error = resolve_stored_token_with_mode( + ClientMode::Provided(&client), + Some(&conflicting_issuer), + &credentials_path, + &original.api_base, + ) + .await + .expect_err("a conflicting issuer must stop refresh"); + let logout_error = logout_with_mode( + ClientMode::Provided(&client), + Some(&conflicting_issuer), + &credentials_path, + ) + .await + .expect_err("a conflicting issuer must stop logout"); + + assert!(refresh_error.to_string().contains(LOGIN_SERVER_ENV)); + assert!(refresh_error.to_string().contains("postil login")); + assert!(logout_error.to_string().contains(LOGIN_SERVER_ENV)); + assert!(issuing_server.received_requests().await.unwrap().is_empty()); + assert!( + conflicting_server + .received_requests() + .await + .unwrap() + .is_empty() + ); + assert_eq!( + credentials::read(&credentials_path).unwrap().unwrap(), + original + ); + } + + #[tokio::test] + async fn api_base_conflict_sends_no_stored_credential() { + let issuing_server = MockServer::start().await; + let other_api = MockServer::start().await; + let dir = tempfile::tempdir().unwrap(); + let credentials_path = dir.path().join("postil").join("credentials.json"); + let original = stored_credentials_at(&issuing_server.uri(), "2020-01-01T00:00:00.000Z"); + credentials::write(&credentials_path, &original).unwrap(); + + let error = resolve_stored_token_with_mode( + ClientMode::Provided(&reqwest::Client::new()), + None, + &credentials_path, + &other_api.uri(), + ) + .await + .expect_err("a stored bearer must remain bound to its API base"); + + let message = error.to_string(); + assert!(message.contains("bound to")); + assert!(message.contains("explicit key")); + assert!(issuing_server.received_requests().await.unwrap().is_empty()); + assert!(other_api.received_requests().await.unwrap().is_empty()); + assert_eq!( + credentials::read(&credentials_path).unwrap().unwrap(), + original + ); } #[tokio::test] @@ -737,7 +1394,7 @@ mod tests { let credentials_path = dir.path().join("postil").join("credentials.json"); credentials::write( &credentials_path, - &stored_credentials("2999-01-01T00:00:00.000Z"), + &stored_credentials_at(&server.uri(), "2999-01-01T00:00:00.000Z"), ) .unwrap(); let held_lock = credentials::CredentialLock::acquire(&credentials_path) @@ -766,6 +1423,7 @@ mod tests { fn stored_credentials(expires_at: &str) -> Credentials { Credentials { version: credentials::CREDENTIALS_VERSION, + issuer: Some(DEFAULT_LOGIN_SERVER.to_string()), token: "pcli_test-old-access-not-a-real-secret".to_string(), expires_at: expires_at.to_string(), refresh_token: Some("fixture-old-refresh-not-a-credential".to_string()), @@ -773,9 +1431,16 @@ mod tests { api_base: "https://postil.dev/api/inference/v1".to_string(), org: "runatlas-is".to_string(), model: "z-ai/glm-5.2".to_string(), + pending_revocations: Vec::new(), } } + fn stored_credentials_at(issuer: &str, expires_at: &str) -> Credentials { + let mut credentials = stored_credentials(expires_at); + credentials.issuer = Some(canonicalize_issuer(issuer).unwrap()); + credentials + } + fn write_legacy_credentials(path: &Path, credentials: &Credentials) { std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write(path, serde_json::to_string(credentials).unwrap()).unwrap(); @@ -802,19 +1467,25 @@ mod tests { let credentials_path = dir.path().join("postil").join("credentials.json"); credentials::write( &credentials_path, - &stored_credentials("2020-01-01T00:00:00.000Z"), + &stored_credentials_at(&server.uri(), "2020-01-01T00:00:00.000Z"), ) .unwrap(); - let resolved = - resolve_stored_token_with(&reqwest::Client::new(), &server.uri(), &credentials_path) - .await - .unwrap(); + let client = reqwest::Client::new(); + let resolved = resolve_stored_token_with_mode( + ClientMode::Provided(&client), + None, + &credentials_path, + "https://postil.dev/api/inference/v1", + ) + .await + .unwrap(); assert_eq!( resolved.as_deref(), Some("pcli_test-new-access-not-a-real-secret") ); let stored = credentials::read(&credentials_path).unwrap().unwrap(); + assert_eq!(stored.issuer.as_deref(), Some(server.uri().as_str())); assert_eq!(stored.token, "pcli_test-new-access-not-a-real-secret"); assert_eq!( stored.refresh_token.as_deref(), @@ -836,7 +1507,11 @@ mod tests { .unwrap(); let dir = tempfile::tempdir().unwrap(); let credentials_path = dir.path().join("postil").join("credentials.json"); - credentials::write(&credentials_path, &stored_credentials(&expires_at)).unwrap(); + credentials::write( + &credentials_path, + &stored_credentials_at(&server.uri(), &expires_at), + ) + .unwrap(); resolve_stored_token_with(&reqwest::Client::new(), &server.uri(), &credentials_path) .await @@ -872,7 +1547,7 @@ mod tests { let credentials_path = dir.path().join("postil").join("credentials.json"); credentials::write( &credentials_path, - &stored_credentials("2020-01-01T00:00:00.000Z"), + &stored_credentials_at(&server.uri(), "2020-01-01T00:00:00.000Z"), ) .unwrap(); let client = reqwest::Client::new(); @@ -903,7 +1578,7 @@ mod tests { .await; let dir = tempfile::tempdir().unwrap(); let credentials_path = dir.path().join("postil").join("credentials.json"); - let original = stored_credentials("2020-01-01T00:00:00.000Z"); + let original = stored_credentials_at(&server.uri(), "2020-01-01T00:00:00.000Z"); credentials::write(&credentials_path, &original).unwrap(); let error = @@ -929,7 +1604,7 @@ mod tests { .await; let dir = tempfile::tempdir().unwrap(); let credentials_path = dir.path().join("postil").join("credentials.json"); - let original = stored_credentials("2020-01-01T00:00:00.000Z"); + let original = stored_credentials_at(&server.uri(), "2020-01-01T00:00:00.000Z"); credentials::write(&credentials_path, &original).unwrap(); let error = @@ -954,7 +1629,7 @@ mod tests { .await; let dir = tempfile::tempdir().unwrap(); let credentials_path = dir.path().join("postil").join("credentials.json"); - let original = stored_credentials("2020-01-01T00:00:00.000Z"); + let original = stored_credentials_at(&server.uri(), "2020-01-01T00:00:00.000Z"); credentials::write(&credentials_path, &original).unwrap(); let error = @@ -969,19 +1644,116 @@ mod tests { ); } + #[tokio::test] + async fn refresh_record_without_issuer_is_bound_to_the_public_default() { + let override_server = MockServer::start().await; + let dir = tempfile::tempdir().unwrap(); + let credentials_path = dir.path().join("postil").join("credentials.json"); + let mut legacy_refresh = stored_credentials("2020-01-01T00:00:00.000Z"); + legacy_refresh.version = credentials::LEGACY_REFRESH_CREDENTIALS_VERSION; + legacy_refresh.issuer = None; + credentials::write(&credentials_path, &legacy_refresh).unwrap(); + + assert_eq!( + stored_issuer(&legacy_refresh).unwrap(), + DEFAULT_LOGIN_SERVER + ); + let error = resolve_stored_token_with( + &reqwest::Client::new(), + &override_server.uri(), + &credentials_path, + ) + .await + .expect_err("an old refresh credential must not follow an issuer override"); + + assert!(error.to_string().contains(LOGIN_SERVER_ENV)); + assert!(error.to_string().contains("postil login")); + assert!( + override_server + .received_requests() + .await + .unwrap() + .is_empty() + ); + assert_eq!( + credentials::read(&credentials_path).unwrap().unwrap(), + legacy_refresh + ); + } + + #[tokio::test] + async fn custom_refresh_record_without_issuer_fails_before_network() { + let custom_server = MockServer::start().await; + let dir = tempfile::tempdir().unwrap(); + let credentials_path = dir.path().join("postil").join("credentials.json"); + let mut legacy_refresh = stored_credentials("2020-01-01T00:00:00.000Z"); + legacy_refresh.version = credentials::LEGACY_REFRESH_CREDENTIALS_VERSION; + legacy_refresh.issuer = None; + legacy_refresh.api_base = format!("{}/api/inference/v1", custom_server.uri()); + credentials::write(&credentials_path, &legacy_refresh).unwrap(); + + let error = resolve_stored_token_with_mode( + ClientMode::Provided(&reqwest::Client::new()), + None, + &credentials_path, + &legacy_refresh.api_base, + ) + .await + .expect_err("an issuer-free custom refresh credential must require login"); + + assert!(error.to_string().contains("no issuing server")); + assert!(error.to_string().contains("postil login")); + assert!(custom_server.received_requests().await.unwrap().is_empty()); + assert_eq!( + credentials::read(&credentials_path).unwrap().unwrap(), + legacy_refresh + ); + } + + #[tokio::test] + async fn custom_access_record_without_issuer_refuses_logout_before_network() { + let custom_server = MockServer::start().await; + let dir = tempfile::tempdir().unwrap(); + let credentials_path = dir.path().join("postil").join("credentials.json"); + let mut legacy_access = stored_credentials("2999-01-01T00:00:00.000Z"); + legacy_access.version = 1; + legacy_access.issuer = None; + legacy_access.refresh_token = None; + legacy_access.refresh_expires_at = None; + legacy_access.api_base = format!("{}/api/inference/v1", custom_server.uri()); + write_legacy_credentials(&credentials_path, &legacy_access); + + let error = logout_with_mode( + ClientMode::Provided(&reqwest::Client::new()), + None, + &credentials_path, + ) + .await + .expect_err("an issuer-free custom access credential must require login"); + + assert!(error.to_string().contains("no issuing server")); + assert!(error.to_string().contains("postil login")); + assert!(custom_server.received_requests().await.unwrap().is_empty()); + assert_eq!( + credentials::read(&credentials_path).unwrap().unwrap(), + legacy_access + ); + } + #[tokio::test] async fn expired_v1_login_requires_relogin() { let dir = tempfile::tempdir().unwrap(); let credentials_path = dir.path().join("postil").join("credentials.json"); let mut legacy = stored_credentials("2020-01-01T00:00:00.000Z"); legacy.version = 1; + legacy.issuer = None; legacy.refresh_token = None; legacy.refresh_expires_at = None; write_legacy_credentials(&credentials_path, &legacy); let error = resolve_stored_token_with( &reqwest::Client::new(), - "http://127.0.0.1:9", + DEFAULT_LOGIN_SERVER, &credentials_path, ) .await @@ -995,13 +1767,14 @@ mod tests { let credentials_path = dir.path().join("postil").join("credentials.json"); let mut legacy = stored_credentials("2999-01-01T00:00:00.000Z"); legacy.version = 1; + legacy.issuer = None; legacy.refresh_token = None; legacy.refresh_expires_at = None; write_legacy_credentials(&credentials_path, &legacy); let resolved = resolve_stored_token_with( &reqwest::Client::new(), - "http://127.0.0.1:9", + "https://self-hosted.example.test", &credentials_path, ) .await @@ -1022,7 +1795,7 @@ mod tests { let error = resolve_stored_token_with( &reqwest::Client::new(), - "http://127.0.0.1:9", + DEFAULT_LOGIN_SERVER, &credentials_path, ) .await @@ -1043,11 +1816,12 @@ mod tests { let credentials_path = dir.path().join("postil").join("credentials.json"); credentials::write( &credentials_path, - &stored_credentials("2020-01-01T00:00:00.000Z"), + &stored_credentials_at(&server.uri(), "2020-01-01T00:00:00.000Z"), ) .unwrap(); - logout_with(&reqwest::Client::new(), &server.uri(), &credentials_path) + let client = reqwest::Client::new(); + logout_with_mode(ClientMode::Provided(&client), None, &credentials_path) .await .unwrap(); assert!(!credentials_path.exists()); @@ -1072,7 +1846,7 @@ mod tests { .await; let dir = tempfile::tempdir().unwrap(); let credentials_path = dir.path().join("postil").join("credentials.json"); - let mut legacy = stored_credentials("2999-01-01T00:00:00.000Z"); + let mut legacy = stored_credentials_at(&server.uri(), "2999-01-01T00:00:00.000Z"); legacy.version = 1; legacy.refresh_token = None; legacy.refresh_expires_at = None; @@ -1094,19 +1868,23 @@ mod tests { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/api/cli/token/refresh")) - .respond_with(ResponseTemplate::new(429).set_body_string("retry later")) + .respond_with( + ResponseTemplate::new(429) + .insert_header("Retry-After", "3527") + .set_body_string("retry later"), + ) .mount(&server) .await; let dir = tempfile::tempdir().unwrap(); let credentials_path = dir.path().join("postil").join("credentials.json"); - let original = stored_credentials("2020-01-01T00:00:00.000Z"); + let original = stored_credentials_at(&server.uri(), "2020-01-01T00:00:00.000Z"); credentials::write(&credentials_path, &original).unwrap(); let error = resolve_stored_token_with(&reqwest::Client::new(), &server.uri(), &credentials_path) .await .expect_err("rate-limited refreshes must fail closed without relogin"); - assert!(error.to_string().contains("try again")); + assert!(error.to_string().contains("retry after 3527 seconds")); assert!(!error.to_string().contains("run `postil login` again")); assert_eq!( credentials::read(&credentials_path).unwrap().unwrap(), @@ -1114,6 +1892,41 @@ mod tests { ); } + #[tokio::test] + async fn malformed_or_missing_retry_after_uses_a_safe_generic_error() { + for retry_after in [None, Some("tomorrow"), Some("18446744073709551616")] { + let server = MockServer::start().await; + let response = ResponseTemplate::new(429).set_body_string("retry later"); + let response = match retry_after { + Some(value) => response.insert_header("Retry-After", value), + None => response, + }; + Mock::given(method("POST")) + .and(path("/api/cli/token/refresh")) + .respond_with(response) + .mount(&server) + .await; + let dir = tempfile::tempdir().unwrap(); + let credentials_path = dir.path().join("postil").join("credentials.json"); + let original = stored_credentials_at(&server.uri(), "2020-01-01T00:00:00.000Z"); + credentials::write(&credentials_path, &original).unwrap(); + + let error = resolve_stored_token_with( + &reqwest::Client::new(), + &server.uri(), + &credentials_path, + ) + .await + .expect_err("an unusable Retry-After must fail safely"); + assert!(error.to_string().contains("try again")); + assert!(!error.to_string().contains("retry after")); + assert_eq!( + credentials::read(&credentials_path).unwrap().unwrap(), + original + ); + } + } + #[tokio::test] async fn request_timeout_refresh_keeps_the_previous_credential() { let server = MockServer::start().await; @@ -1124,7 +1937,7 @@ mod tests { .await; let dir = tempfile::tempdir().unwrap(); let credentials_path = dir.path().join("postil").join("credentials.json"); - let original = stored_credentials("2020-01-01T00:00:00.000Z"); + let original = stored_credentials_at(&server.uri(), "2020-01-01T00:00:00.000Z"); credentials::write(&credentials_path, &original).unwrap(); let error = @@ -1157,7 +1970,8 @@ mod tests { }); let dir = tempfile::tempdir().unwrap(); let credentials_path = dir.path().join("postil").join("credentials.json"); - let original = stored_credentials("2020-01-01T00:00:00.000Z"); + let original = + stored_credentials_at(&format!("http://{address}"), "2020-01-01T00:00:00.000Z"); credentials::write(&credentials_path, &original).unwrap(); let error = resolve_stored_token_with( diff --git a/tests/e2e.rs b/tests/e2e.rs index c3b9d9c..89fb497 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -1171,6 +1171,147 @@ fn postil() -> Command { cmd } +#[tokio::test] +async fn transient_logout_failure_keeps_the_local_revocation_handle() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/cli/logout")) + .respond_with(ResponseTemplate::new(503)) + .expect(1) + .mount(&server) + .await; + let dir = tempfile::tempdir().unwrap(); + let config_home = dir.path().join("config"); + let credentials_dir = config_home.join("postil"); + std::fs::create_dir_all(&credentials_dir).unwrap(); + let credentials_path = credentials_dir.join("credentials.json"); + std::fs::write( + &credentials_path, + serde_json::to_vec(&json!({ + "version": 3, + "issuer": server.uri(), + "token": "pcli_e2e-access-not-a-real-secret", + "expiresAt": "2999-01-01T00:00:00.000Z", + "refreshToken": "fixture-e2e-refresh-not-a-credential", + "refreshExpiresAt": "2999-12-01T00:00:00.000Z", + "apiBase": "https://postil.dev/api/inference/v1", + "org": "example", + "model": "example/model" + })) + .unwrap(), + ) + .unwrap(); + + let assertion = postil() + .env("XDG_CONFIG_HOME", &config_home) + .env("POSTIL_LOGIN_SERVER", format!("{}/", server.uri())) + .arg("logout") + .assert() + .code(1); + let stderr = String::from_utf8(assertion.get_output().stderr.clone()).unwrap(); + + assert!(stderr.contains("credentials were kept")); + assert!(stderr.contains("postil logout")); + assert!(!stderr.contains("logged out")); + let stored: Value = serde_json::from_slice(&std::fs::read(credentials_path).unwrap()).unwrap(); + assert!(stored["refreshToken"].is_string()); +} + +#[tokio::test] +async fn stored_login_refuses_an_api_base_override_before_network() { + let server = MockServer::start().await; + let dir = tempfile::tempdir().unwrap(); + let config_home = dir.path().join("config"); + let credentials_dir = config_home.join("postil"); + std::fs::create_dir_all(&credentials_dir).unwrap(); + std::fs::write( + credentials_dir.join("credentials.json"), + serde_json::to_vec(&json!({ + "version": 3, + "issuer": "https://postil.dev", + "token": "pcli_e2e-access-not-a-real-secret", + "expiresAt": "2999-01-01T00:00:00.000Z", + "refreshToken": "fixture-e2e-refresh-not-a-credential", + "refreshExpiresAt": "2999-12-01T00:00:00.000Z", + "apiBase": "https://postil.dev/api/inference/v1", + "org": "example", + "model": "openai/gpt-5-mini" + })) + .unwrap(), + ) + .unwrap(); + let diff = write_diff(dir.path()); + + let output = postil() + .current_dir(dir.path()) + .env("XDG_CONFIG_HOME", &config_home) + .env("POSTIL_API_BASE", server.uri()) + .env_remove("POSTIL_API_KEY") + .env_remove("OPENROUTER_API_KEY") + .env_remove("MODEL_API_KEY") + .env_remove("LLM_API_KEY") + .args(["review", "--diff-file"]) + .arg(&diff) + .assert() + .failure(); + let stderr = String::from_utf8_lossy(&output.get_output().stderr); + assert!(stderr.contains("stored postil login is bound to")); + assert!(stderr.contains("explicit key")); + assert!(server.received_requests().await.unwrap().is_empty()); +} + +#[tokio::test] +async fn explicit_byok_key_remains_valid_with_an_api_base_override() { + let server = MockServer::start().await; + let provider_key = fixture_credential("provider"); + Mock::given(method("POST")) + .and(path("/chat/completions")) + .and(header("authorization", format!("Bearer {provider_key}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "choices": [{ + "finish_reason": "stop", + "message": {"content": "{\"summary\":\"\",\"findings\":[]}"} + }], + "usage": {"prompt_tokens": 10, "completion_tokens": 5} + }))) + .expect(1) + .mount(&server) + .await; + let dir = tempfile::tempdir().unwrap(); + let config_home = dir.path().join("config"); + let credentials_dir = config_home.join("postil"); + std::fs::create_dir_all(&credentials_dir).unwrap(); + std::fs::write( + credentials_dir.join("credentials.json"), + serde_json::to_vec(&json!({ + "version": 3, + "issuer": "https://postil.dev", + "token": "pcli_e2e-access-not-a-real-secret", + "expiresAt": "2999-01-01T00:00:00.000Z", + "refreshToken": "fixture-e2e-refresh-not-a-credential", + "refreshExpiresAt": "2999-12-01T00:00:00.000Z", + "apiBase": "https://postil.dev/api/inference/v1", + "org": "example", + "model": "openai/gpt-5-mini" + })) + .unwrap(), + ) + .unwrap(); + let diff = write_diff(dir.path()); + + postil() + .current_dir(dir.path()) + .env("XDG_CONFIG_HOME", &config_home) + .env("POSTIL_API_BASE", server.uri()) + .env("MODEL_API_KEY", provider_key) + .env("POSTIL_DISABLE_SCORER", "1") + .args(["review", "--diff-file"]) + .arg(&diff) + .args(["--output", "json"]) + .assert() + .success(); +} + #[tokio::test] async fn local_review_stays_local_without_a_forge_token_in_ci() { let server = MockServer::start().await; From afc1fa077758e3315609b02501ba329762d4e343 Mon Sep 17 00:00:00 2001 From: Postil Maintainer Date: Tue, 25 Aug 2026 04:06:13 +0000 Subject: [PATCH 6/7] Use review budgets for provider retries --- src/llm.rs | 553 ++++++++++++++++++++++++++++++++++++++++----------- tests/e2e.rs | 13 +- 2 files changed, 442 insertions(+), 124 deletions(-) diff --git a/src/llm.rs b/src/llm.rs index 075ac5a..04d0ca9 100644 --- a/src/llm.rs +++ b/src/llm.rs @@ -641,11 +641,14 @@ struct LlmTimeouts { /// Retries per model on transient provider errors before the cascade moves on. pub(crate) const TRANSIENT_RETRIES: u32 = 2; -/// A fresh request can recover when the caller's request timeout, rather than -/// the provider's response, ended an otherwise viable routed completion. The -/// shared total deadline remains authoritative, so this cannot extend a hosted -/// review beyond its worker budget. -const TIMEOUT_RETRIES: u32 = 1; +/// Maximum total transient retries available to a budgeted review. +/// The fixed allowance uses the first two, leaving ten budget-funded retries +/// and at most thirteen attempts for one logical review call. +pub(crate) const MAX_BUDGETED_TRANSIENT_RETRIES: u32 = 12; +const MIN_BUDGETED_RETRY_WAIT: Duration = Duration::from_secs(1); +const MAX_SYNTHESIZED_RETRY_BACKOFF: Duration = Duration::from_secs(20); +/// The minimum time reserved for the request funded by a budgeted wait. +const RETRY_ATTEMPT_RESERVE: Duration = Duration::from_secs(15); const EMPTY_RESPONSE_RETRIES: u32 = 1; const EXHAUSTED_OUTPUT_RETRIES: u32 = 1; const TIMEOUT_RETRY_CAP_SECS: u64 = 90; @@ -654,7 +657,6 @@ pub(crate) const REVIEW_MAX_OUTPUT_TOKENS: u32 = 16_000; const EXHAUSTED_OUTPUT_RETRY_MAX_TOKENS: u32 = REVIEW_MAX_OUTPUT_TOKENS; const MAX_REVIEW_RETRY_PREVIOUS_BYTES: usize = 16_384; const MAX_REVIEW_RETRY_REASON_BYTES: usize = 16_384; -const PROVIDER_RETRY_DELAY_CAP_SECS: u64 = 30; /// Unqualified models receive a bounded review budget. A larger bound belongs /// in explicit admitted-model metadata after that model proves it needs one. @@ -687,11 +689,13 @@ pub(crate) struct ReviewPlanSchedule<'a> { pub planner: Option<(&'a str, usize)>, pub batch_concurrency: usize, } -pub(crate) const MAX_PROVIDER_ATTEMPTS: usize = 216; pub(crate) const MAX_REPORTED_TOKEN_SPEND: usize = 20_000_000; const MAX_PROVIDER_INPUT_BYTES: usize = 64 * 1024 * 1024; pub(crate) const MAX_PROVIDER_REQUEST_BYTES: usize = 256 * 1024; -pub(crate) const MAX_PROVIDER_OUTPUT_TOKEN_EXPOSURE: usize = 2_000_000; +// Review admission includes the full thirteen-attempt transport path for each +// logical review call. Keep the output ceiling above the five-batch hosted +// plan while the aggregate token and cost caps remain authoritative. +pub(crate) const MAX_PROVIDER_OUTPUT_TOKEN_EXPOSURE: usize = 10_000_000; // Hosted planning reserves one initial and one correction call for every // selected model request plus the maximum enabled resolution and compression // passes. The selected-batch limit is derived after all model fan-out is known. @@ -701,8 +705,12 @@ const MAX_LOGICAL_CALLS_PER_SCORER_MODEL: usize = 2; const MAX_LOGICAL_CALLS_PER_RESOLUTION_MODEL: usize = 2; const MAX_LOGICAL_CALLS_PER_BREVITY_MODEL: usize = 1; const MAX_TRANSPORT_ATTEMPTS_PER_CALL: usize = TRANSIENT_RETRIES as usize + 1; +const MAX_REVIEW_TRANSPORT_ATTEMPTS_PER_CALL: usize = MAX_BUDGETED_TRANSIENT_RETRIES as usize + 1; +pub(crate) const MAX_PROVIDER_ATTEMPTS: usize = + MAX_HOSTED_PLANNED_CALLS_BY_WATCHDOG * MAX_REVIEW_TRANSPORT_ATTEMPTS_PER_CALL; const _: () = assert!( - MAX_HOSTED_PLANNED_CALLS_BY_WATCHDOG * MAX_TRANSPORT_ATTEMPTS_PER_CALL <= MAX_PROVIDER_ATTEMPTS + MAX_HOSTED_PLANNED_CALLS_BY_WATCHDOG * MAX_REVIEW_TRANSPORT_ATTEMPTS_PER_CALL + <= MAX_PROVIDER_ATTEMPTS ); const MAX_MODEL_RESPONSE_BYTES: usize = 512 * 1024; const SCORER_BASE_MAX_TOKENS: u32 = 256; @@ -1523,12 +1531,65 @@ fn provider_retry_delay(retry: u32) -> Duration { } fn provider_retry_delay_with_sample(retry: u32, sample: u64) -> Duration { - let ceiling_ms = 2_000_u64.saturating_mul(u64::from(retry.max(1))); + let ceiling_ms = if retry <= TRANSIENT_RETRIES { + 2_000_u64.saturating_mul(u64::from(retry.max(1))) + } else { + let doublings = retry - TRANSIENT_RETRIES; + 2_000_u64 + .saturating_mul(u64::from(TRANSIENT_RETRIES)) + .saturating_mul(1_u64 << doublings.min(6)) + } + .min(MAX_SYNTHESIZED_RETRY_BACKOFF.as_millis() as u64); let floor_ms = ceiling_ms / 2; let jitter_ms = sample % (ceiling_ms - floor_ms + 1); Duration::from_millis(floor_ms + jitter_ms) } +fn budgeted_review_retry(phase: LlmPhase) -> bool { + matches!(phase, LlmPhase::Review) +} + +fn max_transport_attempts(phase: LlmPhase) -> u32 { + if budgeted_review_retry(phase) { + MAX_REVIEW_TRANSPORT_ATTEMPTS_PER_CALL as u32 + } else { + MAX_TRANSPORT_ATTEMPTS_PER_CALL as u32 + } +} + +fn budgeted_retry_wait(retry_after: Option, retry: u32) -> Duration { + retry_after + .unwrap_or_else(|| provider_retry_delay(retry)) + .max(MIN_BUDGETED_RETRY_WAIT) +} + +fn transient_retry_wait(retry_after: Option, phase: LlmPhase, retries: u32) -> Duration { + if budgeted_review_retry(phase) && retries >= TRANSIENT_RETRIES { + budgeted_retry_wait(retry_after, retries + 1) + } else { + retry_after.unwrap_or_else(|| provider_retry_delay(retries + 1)) + } +} + +/// Whether the next transient retry can be funded without consuming the time +/// needed to issue the request it is intended to recover. +fn transient_retry_affordable( + phase: LlmPhase, + retries: u32, + wait: Duration, + remaining: Option, +) -> bool { + if retries < TRANSIENT_RETRIES { + return true; + } + budgeted_review_retry(phase) + && retries < MAX_BUDGETED_TRANSIENT_RETRIES + && remaining.is_some_and(|left| { + wait.checked_add(RETRY_ATTEMPT_RESERVE) + .is_some_and(|required| required <= left) + }) +} + fn timeout_status(status: u16) -> bool { matches!(status, 408 | 504) } @@ -1836,6 +1897,7 @@ impl LlmClient { temperature: f64, phase: LlmPhase, ) -> Result { + let max_attempts = max_transport_attempts(phase) as usize; let initial_body = self.request_body(model, system, user, initial_max_tokens, temperature, phase); let initial_bytes = serialized_provider_request_bytes( @@ -1849,10 +1911,7 @@ impl LlmClient { let expanded_tokens = phase.exhausted_output_retry_max_tokens(initial_max_tokens); if expanded_tokens == initial_max_tokens { return Ok(PlannedRequestPath { - attempts: vec![ - (initial_bytes, initial_max_tokens as usize); - MAX_TRANSPORT_ATTEMPTS_PER_CALL - ], + attempts: vec![(initial_bytes, initial_max_tokens as usize); max_attempts], }); } let expanded_body = @@ -1868,11 +1927,11 @@ impl LlmClient { expanded_bytes >= initial_bytes, "expanded output request serialized smaller than its initial request" ); - let mut attempts = Vec::with_capacity(MAX_TRANSPORT_ATTEMPTS_PER_CALL); + let mut attempts = Vec::with_capacity(max_attempts); attempts.push((initial_bytes, initial_max_tokens as usize)); attempts.extend(std::iter::repeat_n( (expanded_bytes, expanded_tokens as usize), - MAX_TRANSPORT_ATTEMPTS_PER_CALL.saturating_sub(1), + max_attempts.saturating_sub(1), )); Ok(PlannedRequestPath { attempts }) } @@ -2422,9 +2481,10 @@ impl LlmClient { logical_calls <= MAX_HOSTED_PLANNED_CALLS_BY_WATCHDOG, "complete hosted review needs {logical_calls} logical model calls, exceeding the {MAX_HOSTED_PLANNED_CALLS_BY_WATCHDOG}-call watchdog plan" ); - // Admission includes every executable logical call and the exact - // three-attempt transport path. Output expansion replaces a retry; it - // does not add a fourth request. + // Admission includes every executable logical call and its exact + // phase-specific transport path. Review calls can use thirteen + // attempts; non-Review paths use three. Output expansion replaces a + // retry; it does not add another request. let mut exposure = PlannedExposure::default(); for model in &review_models { let price = bounds @@ -4243,10 +4303,16 @@ impl LlmClient { Self::ensure_atomic_attribution_request_size(&body)?; } let mut retries = 0u32; - let mut timeout_retries = 0u32; let mut empty_response_retries = 0u32; let mut exhausted_output_retries = 0u32; let mut attempt_timeout = self.request_timeout; + let attempt_limit = if budgeted_review_retry(phase) + && (self.review_deadline.is_some() || self.review_model_deadline.is_some()) + { + MAX_REVIEW_TRANSPORT_ATTEMPTS_PER_CALL as u32 + } else { + MAX_TRANSPORT_ATTEMPTS_PER_CALL as u32 + }; loop { if matches!(phase, LlmPhase::Review | LlmPhase::Adjudication) { self.ensure_review_request_fits_context(model, &body, request_max_tokens)?; @@ -4261,7 +4327,7 @@ impl LlmClient { phase.as_str(), log_text(model), retries + 1, - TRANSIENT_RETRIES + 1, + attempt_limit, elapsed_text(timeout), remaining .map(elapsed_text) @@ -4284,18 +4350,22 @@ impl LlmClient { *usage_accounting_complete = false; call_usage .push(self.model_usage_event(model, phase, call_phase, attempt, None)); - if timeout_retries < TIMEOUT_RETRIES && retries < TRANSIENT_RETRIES { + let wait = transient_retry_wait(None, phase, retries); + let remaining = self.remaining_budget(phase)?; + if transient_retry_affordable(phase, retries, wait, remaining) { retries += 1; - timeout_retries += 1; - let wait = provider_retry_delay(retries); eprintln!( "postil: model {} hit a request timeout after {}, retrying in {} \ - (timeout retry {timeout_retries}/{TIMEOUT_RETRIES}; retry {retries}/{TRANSIENT_RETRIES})", + (retry {retries}/{attempt_limit}; budget_remaining={})", log_text(model), elapsed_text(attempt_started_at.elapsed()), - elapsed_text(wait) + elapsed_text(wait), + remaining + .map(elapsed_text) + .unwrap_or_else(|| "unbounded".to_string()) ); - self.sleep_with_budget(phase, wait).await?; + self.sleep_with_captured_budget(phase, wait, remaining) + .await?; attempt_timeout = self.timeout_retry_timeout; continue; } @@ -4516,25 +4586,26 @@ impl LlmClient { *usage_accounting_complete = false; } let status = response.status; - if timeout_status(status.as_u16()) - && timeout_retries < TIMEOUT_RETRIES - && retries < TRANSIENT_RETRIES - { - retries += 1; - timeout_retries += 1; - let wait = response - .retry_after - .unwrap_or_else(|| provider_retry_delay(retries)); - eprintln!( - "postil: model {} returned timeout HTTP {status} after {}, retrying in {} \ - (timeout retry {timeout_retries}/{TIMEOUT_RETRIES}; retry {retries}/{TRANSIENT_RETRIES})", - log_text(model), - elapsed_text(attempt_started_at.elapsed()), - elapsed_text(wait) - ); - self.sleep_with_budget(phase, wait).await?; - attempt_timeout = self.timeout_retry_timeout; - continue; + if timeout_status(status.as_u16()) { + let wait = transient_retry_wait(response.retry_after, phase, retries); + let remaining = self.remaining_budget(phase)?; + if transient_retry_affordable(phase, retries, wait, remaining) { + retries += 1; + eprintln!( + "postil: model {} returned timeout HTTP {status} after {}, retrying in {} \ + (retry {retries}/{attempt_limit}; budget_remaining={})", + log_text(model), + elapsed_text(attempt_started_at.elapsed()), + elapsed_text(wait), + remaining + .map(elapsed_text) + .unwrap_or_else(|| "unbounded".to_string()) + ); + self.sleep_with_captured_budget(phase, wait, remaining) + .await?; + attempt_timeout = self.timeout_retry_timeout; + continue; + } } if timeout_status(status.as_u16()) { let detail = provider_http_status_detail( @@ -4544,21 +4615,26 @@ impl LlmClient { ); return Err(anyhow::Error::new(ProviderHttpFailure(status)).context(detail)); } - if retryable_status(status.as_u16()) && retries < TRANSIENT_RETRIES { - retries += 1; - let wait = response - .retry_after - .unwrap_or_else(|| provider_retry_delay(retries)); - eprintln!( - "postil: model {} returned retryable HTTP {status} after {}, retrying in {} \ - (retry {retries}/{TRANSIENT_RETRIES})", - log_text(model), - elapsed_text(attempt_started_at.elapsed()), - elapsed_text(wait) - ); - self.sleep_with_budget(phase, wait).await?; - attempt_timeout = self.request_timeout; - continue; + if retryable_status(status.as_u16()) { + let wait = transient_retry_wait(response.retry_after, phase, retries); + let remaining = self.remaining_budget(phase)?; + if transient_retry_affordable(phase, retries, wait, remaining) { + retries += 1; + eprintln!( + "postil: model {} returned retryable HTTP {status} after {}, retrying in {} \ + (retry {retries}/{attempt_limit}; budget_remaining={})", + log_text(model), + elapsed_text(attempt_started_at.elapsed()), + elapsed_text(wait), + remaining + .map(elapsed_text) + .unwrap_or_else(|| "unbounded".to_string()) + ); + self.sleep_with_captured_budget(phase, wait, remaining) + .await?; + attempt_timeout = self.request_timeout; + continue; + } } return Err(anyhow::Error::new(ProviderHttpFailure(status)).context( provider_http_status_detail( @@ -4568,45 +4644,55 @@ impl LlmClient { ), )); } - Err(error) - if reqwest_error(&error).is_some_and(reqwest::Error::is_timeout) - && timeout_retries < TIMEOUT_RETRIES - && retries < TRANSIENT_RETRIES => - { + Err(error) if reqwest_error(&error).is_some_and(reqwest::Error::is_timeout) => { *usage_accounting_complete = false; call_usage .push(self.model_usage_event(model, phase, call_phase, attempt, None)); - retries += 1; - timeout_retries += 1; - let wait = provider_retry_delay(retries); - eprintln!( - "postil: model {} hit a request timeout after {}, retrying in {} \ - (timeout retry {timeout_retries}/{TIMEOUT_RETRIES}; retry {retries}/{TRANSIENT_RETRIES})", - log_text(model), - elapsed_text(attempt_started_at.elapsed()), - elapsed_text(wait) - ); - self.sleep_with_budget(phase, wait).await?; - attempt_timeout = self.timeout_retry_timeout; + let wait = transient_retry_wait(None, phase, retries); + let remaining = self.remaining_budget(phase)?; + if transient_retry_affordable(phase, retries, wait, remaining) { + retries += 1; + eprintln!( + "postil: model {} hit a request timeout after {}, retrying in {} \ + (retry {retries}/{attempt_limit}; budget_remaining={})", + log_text(model), + elapsed_text(attempt_started_at.elapsed()), + elapsed_text(wait), + remaining + .map(elapsed_text) + .unwrap_or_else(|| "unbounded".to_string()) + ); + self.sleep_with_captured_budget(phase, wait, remaining) + .await?; + attempt_timeout = self.timeout_retry_timeout; + continue; + } + return Err(RequestTimedOut.into()); } - Err(error) - if reqwest_error(&error).is_some_and(reqwest::Error::is_connect) - && retries < TRANSIENT_RETRIES => - { + Err(error) if reqwest_error(&error).is_some_and(reqwest::Error::is_connect) => { *usage_accounting_complete = false; call_usage .push(self.model_usage_event(model, phase, call_phase, attempt, None)); - retries += 1; - let wait = provider_retry_delay(retries); - eprintln!( - "postil: model {} hit a retryable connection error after {}, retrying in {} \ - (retry {retries}/{TRANSIENT_RETRIES})", - log_text(model), - elapsed_text(attempt_started_at.elapsed()), - elapsed_text(wait) - ); - self.sleep_with_budget(phase, wait).await?; - attempt_timeout = self.request_timeout; + let wait = transient_retry_wait(None, phase, retries); + let remaining = self.remaining_budget(phase)?; + if transient_retry_affordable(phase, retries, wait, remaining) { + retries += 1; + eprintln!( + "postil: model {} hit a retryable connection error after {}, retrying in {} \ + (retry {retries}/{attempt_limit}; budget_remaining={})", + log_text(model), + elapsed_text(attempt_started_at.elapsed()), + elapsed_text(wait), + remaining + .map(elapsed_text) + .unwrap_or_else(|| "unbounded".to_string()) + ); + self.sleep_with_captured_budget(phase, wait, remaining) + .await?; + attempt_timeout = self.request_timeout; + continue; + } + return Err(error.context("request to model endpoint failed")); } Err(error) => { *usage_accounting_complete = false; @@ -5052,7 +5138,18 @@ impl LlmClient { } async fn sleep_with_budget(&self, phase: LlmPhase, duration: Duration) -> Result<()> { - let Some(remaining) = self.remaining_budget(phase)? else { + let remaining = self.remaining_budget(phase)?; + self.sleep_with_captured_budget(phase, duration, remaining) + .await + } + + async fn sleep_with_captured_budget( + &self, + phase: LlmPhase, + duration: Duration, + remaining: Option, + ) -> Result<()> { + let Some(remaining) = remaining else { tokio::time::sleep(duration).await; return Ok(()); }; @@ -5327,7 +5424,7 @@ fn retry_after_duration_at(headers: &HeaderMap, now: std::time::SystemTime) -> O .unwrap_or_default() }; - Some(duration.min(Duration::from_secs(PROVIDER_RETRY_DELAY_CAP_SECS))) + Some(duration) } fn safe_header_value(value: Option<&HeaderValue>) -> Option { @@ -6475,6 +6572,110 @@ mod tests { Duration::from_secs(4) ); } + + #[test] + fn retry_attempt_limits_are_phase_specific() { + let fixed_phases = [ + LlmPhase::Planner, + LlmPhase::Resolution, + LlmPhase::Brevity, + LlmPhase::Scorer { expected_len: 1 }, + LlmPhase::Adjudication, + LlmPhase::Attribution, + LlmPhase::Respond, + ]; + for phase in fixed_phases { + assert_eq!(max_transport_attempts(phase), 3, "phase={phase:?}"); + } + assert_eq!(max_transport_attempts(LlmPhase::Review), 13); + } + + #[test] + fn budgeted_waits_have_a_floor_and_provider_hints_remain_authoritative() { + assert_eq!( + budgeted_retry_wait(Some(Duration::ZERO), TRANSIENT_RETRIES + 1), + MIN_BUDGETED_RETRY_WAIT + ); + assert_eq!( + budgeted_retry_wait(Some(Duration::from_secs(37)), TRANSIENT_RETRIES + 1), + Duration::from_secs(37) + ); + assert_eq!( + provider_retry_delay_with_sample(MAX_BUDGETED_TRANSIENT_RETRIES, 10_000), + MAX_SYNTHESIZED_RETRY_BACKOFF + ); + } + + #[test] + fn budgeted_retry_reserves_the_next_attempt_and_unbudgeted_review_stops_at_three() { + let phase = LlmPhase::Review; + assert!(transient_retry_affordable( + phase, + TRANSIENT_RETRIES, + Duration::from_secs(1), + Some(RETRY_ATTEMPT_RESERVE + Duration::from_secs(1)), + )); + assert!(!transient_retry_affordable( + phase, + TRANSIENT_RETRIES, + Duration::from_secs(2), + Some(RETRY_ATTEMPT_RESERVE + Duration::from_secs(1)), + )); + assert!(!transient_retry_affordable( + phase, + TRANSIENT_RETRIES, + Duration::from_secs(1), + None, + )); + assert!(!transient_retry_affordable( + phase, + MAX_BUDGETED_TRANSIENT_RETRIES, + Duration::from_secs(1), + Some(Duration::from_secs(86_400)), + )); + } + + #[test] + fn hosted_exposure_prices_review_and_non_review_phases() { + let config = Config { + model: "provider/model".into(), + scorer_enabled: false, + uncertainty_resolution: false, + concise_findings: false, + ..Config::default() + }; + let client = LlmClient::build( + &config, + "test-key".into(), + Duration::from_secs(1), + None, + None, + ) + .unwrap(); + let price = ModelPriceBound { + model: config.model.clone(), + input_micros_per_million_tokens: 1, + output_micros_per_million_tokens: 1, + }; + let review = client + .planned_review_request_paths(&config.model, "system", "user", REVIEW_MAX_TOKENS) + .unwrap(); + let resolution = client + .planned_request_path( + &config.model, + "system", + "user", + 1_024, + 0.0, + LlmPhase::Resolution, + ) + .unwrap(); + assert_eq!( + request_paths_exposure(&review, &price).unwrap().attempts, + 26 + ); + assert_eq!(resolution.attempts.len(), 3); + } use crate::config::Config; use crate::envelope::{Kind, Severity}; use crate::test_env_lock as env_lock; @@ -7073,12 +7274,14 @@ mod tests { .unwrap(); assert_eq!( admission.output_tokens, - u64::from(REVIEW_MAX_TOKENS + 2 * REVIEW_MAX_OUTPUT_TOKENS) + (u64::from(REVIEW_MAX_TOKENS) + + (MAX_REVIEW_TRANSPORT_ATTEMPTS_PER_CALL as u64 - 1) + * u64::from(REVIEW_MAX_OUTPUT_TOKENS)) * MAX_LOGICAL_CALLS_PER_REVIEW_MODEL as u64 + u64::from(MAX_ADJUDICATION_OUTPUT_TOKENS) * MAX_TRANSPORT_ATTEMPTS_PER_CALL as u64 ); - assert_eq!(admission.provider_attempts, 9); + assert_eq!(admission.provider_attempts, 29); let plan = client .plan_review_batches( &config, @@ -7862,7 +8065,9 @@ mod tests { }, ) .unwrap(); - let review_per_model = u64::from(REVIEW_MAX_TOKENS + 2 * REVIEW_MAX_OUTPUT_TOKENS) + let review_per_model = (u64::from(REVIEW_MAX_TOKENS) + + (MAX_REVIEW_TRANSPORT_ATTEMPTS_PER_CALL as u64 - 1) + * u64::from(REVIEW_MAX_OUTPUT_TOKENS)) * MAX_LOGICAL_CALLS_PER_REVIEW_MODEL as u64; let scorer_per_model = u64::from(scorer_max_tokens(SCORER_MAX_FINDINGS).unwrap()) * MAX_TRANSPORT_ATTEMPTS_PER_CALL as u64 @@ -7884,8 +8089,8 @@ mod tests { + u64::from(MAX_ADJUDICATION_OUTPUT_TOKENS) * MAX_TRANSPORT_ATTEMPTS_PER_CALL as u64 ); - assert_eq!(admission.provider_attempts, 117); - assert_eq!(admission.projected_cost_micros, 234); + assert_eq!(admission.provider_attempts, 157); + assert_eq!(admission.projected_cost_micros, 314); } #[test] @@ -7938,12 +8143,12 @@ mod tests { assert_eq!( admission.output_tokens, u64::from(REVIEW_MAX_OUTPUT_TOKENS) - * MAX_TRANSPORT_ATTEMPTS_PER_CALL as u64 + * MAX_REVIEW_TRANSPORT_ATTEMPTS_PER_CALL as u64 * MAX_LOGICAL_CALLS_PER_REVIEW_MODEL as u64 + u64::from(MAX_ADJUDICATION_OUTPUT_TOKENS) * MAX_TRANSPORT_ATTEMPTS_PER_CALL as u64 ); - assert_eq!(admission.provider_attempts, 9); + assert_eq!(admission.provider_attempts, 29); } #[test] @@ -8039,7 +8244,9 @@ mod tests { .unwrap(); assert_eq!( admission.output_tokens, - u64::from(REVIEW_MAX_TOKENS + 2 * REVIEW_MAX_OUTPUT_TOKENS) + (u64::from(REVIEW_MAX_TOKENS) + + (MAX_REVIEW_TRANSPORT_ATTEMPTS_PER_CALL as u64 - 1) + * u64::from(REVIEW_MAX_OUTPUT_TOKENS)) * MAX_LOGICAL_CALLS_PER_REVIEW_MODEL as u64 * crate::review::MAX_HOSTED_SELECTED_BATCHES as u64 + u64::from(PLANNER_MAX_TOKENS + 2 * PLANNER_MAX_TOKENS * 2) @@ -8055,7 +8262,7 @@ mod tests { + u64::from(MAX_ADJUDICATION_OUTPUT_TOKENS) * MAX_TRANSPORT_ATTEMPTS_PER_CALL as u64 ); - assert_eq!(admission.provider_attempts, 84); + assert_eq!(admission.provider_attempts, 184); assert_eq!(client.admission.lock().unwrap().attempts, 0); } @@ -8147,8 +8354,8 @@ mod tests { assert!(logical_calls <= MAX_HOSTED_PLANNED_CALLS_BY_WATCHDOG); assert!(logical_calls * MAX_TRANSPORT_ATTEMPTS_PER_CALL <= MAX_PROVIDER_ATTEMPTS); assert_eq!( - MAX_HOSTED_PLANNED_CALLS_BY_WATCHDOG * MAX_TRANSPORT_ATTEMPTS_PER_CALL, - 192 + MAX_HOSTED_PLANNED_CALLS_BY_WATCHDOG * MAX_REVIEW_TRANSPORT_ATTEMPTS_PER_CALL, + MAX_PROVIDER_ATTEMPTS ); } @@ -8304,13 +8511,13 @@ mod tests { output_micros_per_million_tokens: 1, }, )]))); - let prompts = vec!["candidate".to_string(); 25]; + let prompts = vec!["candidate".to_string(); 7]; let output_tokens = vec![REVIEW_MAX_TOKENS; prompts.len()]; client .preflight_review_plan_with_output_limits( &config, - 24, + 6, "system", ReviewPreflightPrompts { first_users: &prompts, @@ -8321,14 +8528,14 @@ mod tests { }, ReviewPlanSchedule { planner: None, - batch_concurrency: 4, + batch_concurrency: 1, }, ) .unwrap(); let error = client .preflight_review_plan_with_output_limits( &config, - 25, + 7, "system", ReviewPreflightPrompts { first_users: &prompts, @@ -8339,7 +8546,7 @@ mod tests { }, ReviewPlanSchedule { planner: None, - batch_concurrency: 4, + batch_concurrency: 1, }, ) .unwrap_err(); @@ -8865,7 +9072,7 @@ mod tests { } #[test] - fn provider_retry_after_parses_delta_seconds_and_caps_local_waits() { + fn provider_retry_after_preserves_provider_delta_seconds() { let mut headers = HeaderMap::new(); headers.insert(reqwest::header::RETRY_AFTER, HeaderValue::from_static("0")); assert_eq!(retry_after_duration(&headers), Some(Duration::ZERO)); @@ -8882,7 +9089,7 @@ mod tests { ); assert_eq!( retry_after_duration(&headers), - Some(Duration::from_secs(PROVIDER_RETRY_DELAY_CAP_SECS)) + Some(Duration::from_secs(999)) ); } @@ -8913,14 +9120,12 @@ mod tests { headers.insert( reqwest::header::RETRY_AFTER, - HeaderValue::from_str(&httpdate::fmt_http_date( - now + Duration::from_secs(PROVIDER_RETRY_DELAY_CAP_SECS), - )) - .unwrap(), + HeaderValue::from_str(&httpdate::fmt_http_date(now + Duration::from_secs(999))) + .unwrap(), ); assert_eq!( retry_after_duration_at(&headers, now), - Some(Duration::from_secs(PROVIDER_RETRY_DELAY_CAP_SECS)) + Some(Duration::from_secs(999)) ); headers.insert( @@ -9599,6 +9804,118 @@ mod tests { assert_eq!(client.admission.lock().unwrap().reported_token_spend, 27); } + #[tokio::test] + async fn budgeted_review_retries_source_and_synthesis_past_the_fixed_allowance() { + for route in [ReviewRequestRoute::Source, ReviewRequestRoute::Synthesis] { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/chat/completions")) + .respond_with(ResponseTemplate::new(429).insert_header("Retry-After", "0")) + .with_priority(1) + .up_to_n_times(3) + .expect(3) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/chat/completions")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "choices": [{ + "finish_reason": "stop", + "message": {"content": "{\"summary\":\"\",\"findings\":[]}"} + }], + "usage": {"prompt_tokens": 10, "completion_tokens": 2} + }))) + .with_priority(2) + .expect(1) + .mount(&server) + .await; + let config = Config { + api_base: server.uri(), + api_format: ApiFormat::OpenaiCompatible, + model: "provider/model".into(), + scorer_enabled: false, + uncertainty_resolution: false, + concise_findings: false, + ..Config::default() + }; + let deadline = Instant::now() + Duration::from_secs(20); + let client = LlmClient::build( + &config, + "test-key".into(), + Duration::from_secs(1), + Some(deadline), + Some(deadline), + ) + .unwrap(); + *client.http.lock().unwrap() = Some(reqwest::Client::new()); + + let result = client + .review_validated_with_safe_output_limit( + &config, + "system", + "user", + REVIEW_MAX_TOKENS, + route, + |_| Ok(()), + ) + .await + .unwrap(); + assert!(result.findings.is_empty()); + assert_eq!(server.received_requests().await.unwrap().len(), 4); + assert_eq!( + result + .model_usage + .iter() + .map(|event| event.attempt) + .collect::>(), + vec![Some(1), Some(2), Some(3), Some(4)] + ); + } + } + + #[tokio::test] + async fn unbudgeted_source_review_keeps_three_transient_attempts() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/chat/completions")) + .respond_with(ResponseTemplate::new(429).insert_header("Retry-After", "0")) + .expect(3) + .mount(&server) + .await; + let config = Config { + api_base: server.uri(), + api_format: ApiFormat::OpenaiCompatible, + model: "provider/model".into(), + scorer_enabled: false, + uncertainty_resolution: false, + concise_findings: false, + ..Config::default() + }; + let client = LlmClient::build( + &config, + "test-key".into(), + Duration::from_secs(1), + None, + None, + ) + .unwrap(); + *client.http.lock().unwrap() = Some(reqwest::Client::new()); + + let error = client + .review_validated_with_safe_output_limit( + &config, + "system", + "user", + REVIEW_MAX_TOKENS, + ReviewRequestRoute::Source, + |_| Ok(()), + ) + .await + .unwrap_err(); + assert!(!error.is_timeout()); + assert_eq!(server.received_requests().await.unwrap().len(), 3); + } + #[tokio::test] async fn a_persistently_missing_identity_echo_stays_terminal_after_the_transient_budget() { let server = MockServer::start().await; diff --git a/tests/e2e.rs b/tests/e2e.rs index 094aa07..a144e38 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -10311,7 +10311,7 @@ async fn slow_model_request_retries_same_model_then_succeeds() { assert_eq!(env["usageAccountingComplete"], false); let stderr = String::from_utf8(out.get_output().stderr.clone()).unwrap(); assert!(stderr.contains("postil: model primary-model hit a request timeout after")); - assert!(stderr.contains("timeout retry 1/1")); + assert!(stderr.contains("retry 1/13")); assert!(stderr.contains("postil: model primary-model responded in")); assert!(!stderr.contains("postil: attempting model: backup-model")); @@ -10940,10 +10940,10 @@ fn connection_failure_after_exhausted_output_uses_the_remaining_third_request() let stderr = String::from_utf8_lossy(&out.get_output().stderr); assert!( - stderr.contains("model=primary-model attempt=2/3"), + stderr.contains("model=primary-model attempt=2/13"), "unexpected log: {stderr}" ); - assert!(stderr.contains("model=primary-model attempt=3/3")); + assert!(stderr.contains("model=primary-model attempt=3/13")); assert!(!stderr.contains("127.0.0.1")); } @@ -10984,7 +10984,7 @@ async fn timeout_http_status_retries_same_model_then_succeeds() { assert_eq!(envelope["usageAccountingComplete"], false); let stderr = String::from_utf8(out.get_output().stderr.clone()).unwrap(); assert!(stderr.contains("returned timeout HTTP 408 Request Timeout")); - assert!(stderr.contains("timeout retry 1/1")); + assert!(stderr.contains("retry 1/13")); let requests = server.received_requests().await.unwrap(); assert_eq!(requests.len(), 2); @@ -11034,7 +11034,7 @@ async fn exhausted_timeout_retry_falls_back_to_next_model() { assert_eq!(envelope["modelUsed"], "backup-model"); let stderr = String::from_utf8(out.get_output().stderr.clone()).unwrap(); assert!(stderr.contains("model primary-model hit a request timeout after")); - assert!(stderr.contains("timeout retry 1/1")); + assert!(stderr.contains("retry 1/13")); let attempts = envelope["modelUsage"] .as_array() .unwrap() @@ -11051,6 +11051,7 @@ async fn exhausted_timeout_retry_falls_back_to_next_model() { vec![ ("primary-model", 1), ("primary-model", 2), + ("primary-model", 3), ("backup-model", 1) ] ); @@ -11066,7 +11067,7 @@ async fn exhausted_timeout_retry_falls_back_to_next_model() { }) .collect::>(); assert_eq!(models.last().map(String::as_str), Some("backup-model")); - assert!(models.len() <= 3); + assert!(models.len() <= 4); assert_eq!( models .iter() From cfdfa0fb55aa9e2335e48be54a4746e0900f4912 Mon Sep 17 00:00:00 2001 From: Postil Maintainer Date: Tue, 25 Aug 2026 04:08:55 +0000 Subject: [PATCH 7/7] Keep retry phases aligned with CLI commands --- src/llm.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/llm.rs b/src/llm.rs index 04d0ca9..f53a038 100644 --- a/src/llm.rs +++ b/src/llm.rs @@ -6582,7 +6582,6 @@ mod tests { LlmPhase::Scorer { expected_len: 1 }, LlmPhase::Adjudication, LlmPhase::Attribution, - LlmPhase::Respond, ]; for phase in fixed_phases { assert_eq!(max_transport_attempts(phase), 3, "phase={phase:?}");