mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 17:10:50 -10:00
refactor(config): cut tui config's edges into other tui modules (P3, first batch)
Phase P3 moves the TUI's config modules out of codewhale-tui. This batch cuts config's production edges into other tui modules; the module move itself comes later. The destination is codewhale-runtime, not codewhale-config: cloud-facts, models, palette, runtime and agent-contract all depend on codewhale-config, so moving tui config into it would create crate cycles. The edge list comes from `scripts/split/partition_report.py --scc-edges 700`. Edges cut, each a one-way move with every caller migrated and no re-export left behind: - snapshot: DEFAULT_SNAPSHOT_MAX_AGE and DEFAULT_MAX_WORKSPACE_BYTES_FOR_SNAPSHOT move to codewhale-config, which also drops its hard-coded copy of the 7-day value. - network_policy, lsp: tui's duplicate NetworkPolicyToml, LspConfigToml and CustomLspDef are deleted in favour of the codewhale-config types (a superset). into_runtime() becomes From impls on the runtime types, and network_policy_from_document moves into network_policy.rs. The integration harness's stub audit module is gone, because network_policy now calls codewhale_runtime::audit. - mcp: McpBackend moves to codewhale-config. McpBackend::from_config(c) becomes Config::mcp_backend(). - route_receipt: CredentialGeneration moves to codewhale_config::credentials. - client: resolve_stream_open_timeout, stream_open_timeout_from_env, the open-timeout constants and force_http1_from_env move to codewhale-config, next to the stream chunk timeout. canonical_json moves to codewhale_runtime::hashing. - settings -> pricing: CostCurrency moves to codewhale_config::pricing. - audit: audit.rs moves to codewhale_runtime::audit. The owner-only log helpers (open_append, private_log_options, restrict_to_owner, flush_and_sync) move from tui utils to codewhale_runtime::private_log. The test-home guard is now any(test, feature = "test-support"), so tui tests still never append to the real audit log. runtime gains a codewhale-paths dev-dependency for the audit test. - core::engine::turn_budget moves to codewhale_runtime::turn_budget, and the five streaming constants that only fed its defaults are folded into its DEFAULT_* constants. Tests and comments use the DEFAULT_* names. TurnWallClock::rewind_for_test is gated by test-support. - core::authority::auto_review: the config-to-policy conversion is inverted into AutoReviewPolicy::from_config. Config keeps the schema, the validation and a name-only alias table (auto_review_action_kind_name). The policy maps names back through ToolActionKind::as_str, so there is still one table. Dead code deleted (no callers): Config::fleet_members (the config -> fleet edge), Config::approval_policy_is_managed, and Settings::set_pinned_model_label, which had been allow(dead_code) since #4873. Its round-trip test now sets the label field directly. Ratchets: dead-code budget 213 -> 212. Blocking-call budget: +2 std::fs sites in runtime/private_log.rs. These are the moved sync log helpers; the old utils.rs entry never counted them. `python3 scripts/split/module_graph.py --check`: PASS. Verified: - scripts/dev-cargo.sh check --workspace --all-targets --locked: finished, 0 warnings, 0 errors (9m 24s). The check ran before cargo fmt and the ratchet --update; fmt was the only source change after it. - cargo fmt --all -- --check: clean. - nextest: - codewhale-runtime: 252 passed. - codewhale-config: 806 passed, 1 skipped. - codewhale-tui lib, filtered to network_policy, lsp, auto_review, stream_entry, pinned_models, config::tests, snapshot, large_output_router, route_receipt, prepared, thread_history, mcp_backend and pricing: 893 passed. - tui integration, filtered to network_policy: 29 passed. - Not run: clippy, cargo doc, a Windows compile, hosted CI (by rule, the final release step). Refs CW-6129 CW-6382 CW-7525 Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
498dd5a876
commit
8b5baa7a5f
87 files changed
+786
-881
No files matched your search
Generated
+1
@@ -1200,6 +1200,7 @@ dependencies = [
|
||||
"codewhale-memory",
|
||||
"codewhale-models",
|
||||
"codewhale-palette",
|
||||
"codewhale-paths",
|
||||
"colored",
|
||||
"fd-lock",
|
||||
"libc",
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! roll both stores back if either leg fails.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use sha2::Digest as _;
|
||||
|
||||
use crate::provider_kind::ProviderKind;
|
||||
use crate::{ConfigStore, Secrets};
|
||||
@@ -238,6 +239,56 @@ pub fn clear_provider_api_key(
|
||||
})
|
||||
}
|
||||
|
||||
/// One-way digest proving a credential (and the endpoint it is bound to) is
|
||||
/// still the same one.
|
||||
///
|
||||
/// SHA-256 over a domain-separated, length-prefixed preimage, truncated to 128
|
||||
/// bits. Length prefixing keeps `(base_url, key)` unambiguous, so no pair of
|
||||
/// distinct routes can be made to share a generation by moving bytes across the
|
||||
/// boundary. The value is never rendered: it is credential-derived, and a
|
||||
/// stable public digest of a secret is a secret's shadow.
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct CredentialGeneration(String);
|
||||
|
||||
impl CredentialGeneration {
|
||||
#[must_use]
|
||||
pub fn derive(base_url: &str, credential: &str) -> Self {
|
||||
let mut hasher = sha2::Sha256::new();
|
||||
hasher.update(b"codewhale/turn-route/credential-generation/v1\0");
|
||||
hasher.update(
|
||||
u64::try_from(base_url.len())
|
||||
.unwrap_or(u64::MAX)
|
||||
.to_le_bytes(),
|
||||
);
|
||||
hasher.update(base_url.as_bytes());
|
||||
hasher.update(
|
||||
u64::try_from(credential.len())
|
||||
.unwrap_or(u64::MAX)
|
||||
.to_le_bytes(),
|
||||
);
|
||||
hasher.update(credential.as_bytes());
|
||||
let digest = hasher.finalize();
|
||||
let mut hex = String::with_capacity(32);
|
||||
for byte in &digest[..16] {
|
||||
use std::fmt::Write as _;
|
||||
let _ = write!(hex, "{byte:02x}");
|
||||
}
|
||||
Self(hex)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Redacted: see the type docs. There is no accessor for the digest string.
|
||||
impl std::fmt::Debug for CredentialGeneration {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("<redacted>")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -1724,6 +1724,32 @@ pub struct ToolsToml {
|
||||
pub extras: BTreeMap<String, toml::Value>,
|
||||
}
|
||||
|
||||
/// Protocol owner selected only by user configuration. Rust is the default;
|
||||
/// Host explicitly selects the SDK and never falls back to Rust on refusal.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum McpBackend {
|
||||
#[default]
|
||||
Rust,
|
||||
Host,
|
||||
}
|
||||
|
||||
/// Default snapshot retention window: 7 days.
|
||||
pub const DEFAULT_SNAPSHOT_MAX_AGE: std::time::Duration =
|
||||
std::time::Duration::from_secs(7 * 24 * 60 * 60);
|
||||
|
||||
/// Default workspace-size ceiling above which snapshots self-disable
|
||||
/// on first use (2 GB of non-excluded content). Reports from users with
|
||||
/// multi-hundred-GB project directories — datasets, model weights,
|
||||
/// docker image dumps that fall outside the built-in excludes —
|
||||
/// surfaced that `git add -A` on first init would hang the TUI for
|
||||
/// minutes-to-hours while indexing the workspace. Snapshots are a
|
||||
/// rollback safety net, not a backup tool; bailing out on workspaces
|
||||
/// that big is the right tradeoff. Users with legitimate large
|
||||
/// monorepos can raise `[snapshots] max_workspace_gb` (or set it to
|
||||
/// `0` to disable the cap entirely).
|
||||
pub const DEFAULT_MAX_WORKSPACE_BYTES_FOR_SNAPSHOT: u64 = 2 * 1024 * 1024 * 1024;
|
||||
|
||||
/// On-disk schema for the `[snapshots]` table (#137). See
|
||||
/// `config.example.toml` for documentation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -1743,7 +1769,7 @@ fn default_snapshots_enabled() -> bool {
|
||||
}
|
||||
|
||||
fn default_snapshot_max_age_days() -> u64 {
|
||||
7
|
||||
DEFAULT_SNAPSHOT_MAX_AGE.as_secs() / (24 * 60 * 60)
|
||||
}
|
||||
|
||||
impl Default for SnapshotsToml {
|
||||
@@ -1785,6 +1811,57 @@ pub const DEFAULT_STREAM_CHUNK_TIMEOUT_SECS: u64 = 900;
|
||||
pub const MIN_STREAM_CHUNK_TIMEOUT_SECS: u64 = 1;
|
||||
pub const MAX_STREAM_CHUNK_TIMEOUT_SECS: u64 = 3600;
|
||||
|
||||
/// Default bounded wait for SSE response headers. Intentionally shorter than
|
||||
/// the per-chunk idle timeout: it covers connection setup and upstream header
|
||||
/// return only, never model thinking time after streaming has started.
|
||||
pub const DEFAULT_STREAM_OPEN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(45);
|
||||
/// Accepted response-header wait range, in seconds.
|
||||
pub const MIN_STREAM_OPEN_TIMEOUT_SECS: u64 = 5;
|
||||
pub const MAX_STREAM_OPEN_TIMEOUT_SECS: u64 = 300;
|
||||
|
||||
/// Resolve the response-header wait shared by every streaming adapter.
|
||||
///
|
||||
/// A positive `[stream].open_timeout_secs` (legacy `[tui]` fallback) wins; omitted or `0`
|
||||
/// falls back to the env override (`CODEWHALE_STREAM_OPEN_TIMEOUT_SECS`,
|
||||
/// legacy `DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS`), then the 45s default. Every
|
||||
/// source clamps to `5..=300`.
|
||||
#[must_use]
|
||||
pub fn resolve_stream_open_timeout(configured_secs: Option<u64>) -> std::time::Duration {
|
||||
match configured_secs {
|
||||
Some(secs) if secs > 0 => std::time::Duration::from_secs(
|
||||
secs.clamp(MIN_STREAM_OPEN_TIMEOUT_SECS, MAX_STREAM_OPEN_TIMEOUT_SECS),
|
||||
),
|
||||
_ => stream_open_timeout_from_env(
|
||||
std::env::var("CODEWHALE_STREAM_OPEN_TIMEOUT_SECS")
|
||||
.or_else(|_| std::env::var("DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS"))
|
||||
.ok()
|
||||
.as_deref(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stream_open_timeout_from_env(value: Option<&str>) -> std::time::Duration {
|
||||
let secs = value
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
.unwrap_or(DEFAULT_STREAM_OPEN_TIMEOUT.as_secs())
|
||||
.clamp(MIN_STREAM_OPEN_TIMEOUT_SECS, MAX_STREAM_OPEN_TIMEOUT_SECS);
|
||||
std::time::Duration::from_secs(secs)
|
||||
}
|
||||
|
||||
/// Returns true when CODEWHALE_FORCE_HTTP1 (legacy alias: DEEPSEEK_FORCE_HTTP1)
|
||||
/// is set to a truthy value (`1`, `true`, `yes`, `on`, case-insensitive). Read
|
||||
/// only by `Config::force_http1`, which ORs it with the selected stream config flag; every
|
||||
/// client builder and stream open takes that resolved value (#103, #6700). Anything else (unset, `0`,
|
||||
/// `false`, ...) leaves HTTP/2 on.
|
||||
#[must_use]
|
||||
pub fn force_http1_from_env() -> bool {
|
||||
std::env::var("CODEWHALE_FORCE_HTTP1")
|
||||
.or_else(|_| std::env::var("DEEPSEEK_FORCE_HTTP1"))
|
||||
.ok()
|
||||
.map(|v| v.trim().to_ascii_lowercase())
|
||||
.is_some_and(|v| matches!(v.as_str(), "1" | "true" | "yes" | "on"))
|
||||
}
|
||||
|
||||
/// Largest representable absolute depth; admission rejects arithmetic overflow.
|
||||
pub const MAX_SPAWN_DEPTH_CEILING: u32 = u32::MAX;
|
||||
|
||||
|
||||
@@ -915,5 +915,31 @@ fn endpoint_fingerprint_from_source(source: &CatalogSource) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Cost display currency.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CostCurrency {
|
||||
Usd,
|
||||
Cny,
|
||||
}
|
||||
|
||||
impl CostCurrency {
|
||||
#[must_use]
|
||||
pub fn from_setting(value: &str) -> Option<Self> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"usd" | "dollar" | "dollars" | "$" => Some(Self::Usd),
|
||||
"cny" | "rmb" | "yuan" | "¥" => Some(Self::Cny),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn symbol(self) -> &'static str {
|
||||
match self {
|
||||
Self::Usd => "$",
|
||||
Self::Cny => "¥",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -62,6 +62,7 @@ uuid.workspace = true
|
||||
wait-timeout = "0.2"
|
||||
|
||||
[dev-dependencies]
|
||||
codewhale-paths = { path = "../paths", version = "0.10.2" }
|
||||
codewhale-models = { path = "../models", version = "0.10.2", features = [
|
||||
"test-support",
|
||||
] }
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::path::{Path, PathBuf};
|
||||
use chrono::Utc;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::utils::{flush_and_sync, open_append};
|
||||
use crate::private_log::{flush_and_sync, open_append};
|
||||
|
||||
/// Append an audit event to `$CODEWHALE_HOME/audit.log` (or the default
|
||||
/// `~/.codewhale/audit.log` when no explicit CodeWhale home is configured).
|
||||
@@ -16,7 +16,7 @@ use crate::utils::{flush_and_sync, open_append};
|
||||
pub fn log_sensitive_event(event: &str, details: Value) {
|
||||
let result = default_audit_path().and_then(|path| append_event(&path, event, details));
|
||||
if let Err(err) = result {
|
||||
codewhale_runtime::logging::warn(format!("audit log write failed: {err}"));
|
||||
crate::logging::warn(format!("audit log write failed: {err}"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ pub fn log_sensitive_event(event: &str, details: Value) {
|
||||
/// than in whatever home the process environment names (#6534).
|
||||
pub fn log_sensitive_event_in(codewhale_home: &Path, event: &str, details: Value) {
|
||||
if let Err(err) = append_event(&codewhale_home.join("audit.log"), event, details) {
|
||||
codewhale_runtime::logging::warn(format!("audit log write failed: {err}"));
|
||||
crate::logging::warn(format!("audit log write failed: {err}"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ fn default_audit_path() -> anyhow::Result<PathBuf> {
|
||||
// developer's real ~/.codewhale/audit.log (#6534); it gets a per-process
|
||||
// scratch log instead. An *ambient* CODEWHALE_HOME is not a seal: it names
|
||||
// somebody's real profile just as `~/.codewhale` does.
|
||||
#[cfg(test)]
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
if !crate::test_support::home_is_sealed() {
|
||||
return Ok(std::env::temp_dir()
|
||||
.join(format!("codewhale-test-audit-{}", std::process::id()))
|
||||
@@ -13,3 +13,46 @@ pub fn hex_bytes(bytes: impl AsRef<[u8]>) -> String {
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Canonical JSON: object keys sorted, no insignificant whitespace.
|
||||
///
|
||||
/// Deterministic for a given `Value` regardless of how it was built.
|
||||
pub fn canonical_json(value: &serde_json::Value) -> String {
|
||||
let mut out = String::new();
|
||||
write_canonical(value, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn write_canonical(value: &serde_json::Value, out: &mut String) {
|
||||
match value {
|
||||
serde_json::Value::Object(map) => {
|
||||
let mut keys: Vec<&String> = map.keys().collect();
|
||||
keys.sort_unstable();
|
||||
out.push('{');
|
||||
for (index, key) in keys.iter().enumerate() {
|
||||
if index > 0 {
|
||||
out.push(',');
|
||||
}
|
||||
push_json_string(key, out);
|
||||
out.push(':');
|
||||
write_canonical(&map[*key], out);
|
||||
}
|
||||
out.push('}');
|
||||
}
|
||||
serde_json::Value::Array(items) => {
|
||||
out.push('[');
|
||||
for (index, item) in items.iter().enumerate() {
|
||||
if index > 0 {
|
||||
out.push(',');
|
||||
}
|
||||
write_canonical(item, out);
|
||||
}
|
||||
out.push(']');
|
||||
}
|
||||
other => out.push_str(&other.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn push_json_string(value: &str, out: &mut String) {
|
||||
out.push_str(&serde_json::Value::String(value.to_string()).to_string());
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
pub use codewhale_command_contract::elapsed;
|
||||
|
||||
pub mod audit;
|
||||
pub mod child_env;
|
||||
pub mod context_budget;
|
||||
pub mod continual_harness;
|
||||
@@ -28,6 +29,7 @@ pub mod logging;
|
||||
pub mod media_originals;
|
||||
pub mod model_context;
|
||||
pub mod native_memory;
|
||||
pub mod private_log;
|
||||
pub mod process_tree;
|
||||
pub mod prompt_zones;
|
||||
pub mod regex_cache;
|
||||
@@ -42,6 +44,7 @@ pub mod startup_trace;
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub mod test_support;
|
||||
pub mod tool_history_repair;
|
||||
pub mod turn_budget;
|
||||
pub mod workspace_discovery;
|
||||
|
||||
pub const CODEWHALE_MAIN_STACK_BYTES: usize = 32 * 1024 * 1024;
|
||||
@@ -0,0 +1,49 @@
|
||||
use std::io::Write as _;
|
||||
use std::path::Path;
|
||||
|
||||
/// Open or create a file for appending at `path`, optionally syncing after
|
||||
/// every write. Use this for append-only logs like `audit.log`.
|
||||
///
|
||||
/// The returned `BufWriter<fs::File>` wraps the append handle. Call
|
||||
/// `.flush()` followed by `.get_ref().sync_all()` after each batch.
|
||||
pub fn open_append(path: &Path) -> std::io::Result<std::io::BufWriter<std::fs::File>> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let file = private_log_options().append(true).open(path)?;
|
||||
restrict_to_owner(&file)?;
|
||||
Ok(std::io::BufWriter::new(file))
|
||||
}
|
||||
|
||||
/// Open options for an owner-only log or lock file: created 0600 and never
|
||||
/// opened through a link at the final component (Unix). Callers add the
|
||||
/// access mode they need.
|
||||
pub fn private_log_options() -> std::fs::OpenOptions {
|
||||
let mut options = std::fs::OpenOptions::new();
|
||||
options.create(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.mode(0o600).custom_flags(libc::O_NOFOLLOW);
|
||||
}
|
||||
options
|
||||
}
|
||||
|
||||
/// Tighten a log created by an earlier version at the default mode. No-op
|
||||
/// outside Unix.
|
||||
pub fn restrict_to_owner(file: &std::fs::File) -> std::io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = file;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Flush a `BufWriter` wrapping a `File`, then `fsync` the underlying file.
|
||||
pub fn flush_and_sync(writer: &mut std::io::BufWriter<std::fs::File>) -> std::io::Result<()> {
|
||||
writer.flush()?;
|
||||
writer.get_ref().sync_all()
|
||||
}
|
||||
@@ -35,9 +35,10 @@ pub const DEFAULT_MAX_MODEL_STEPS: u32 = u32::MAX;
|
||||
/// [`TurnWallClock::begin_human_wait`]).
|
||||
pub const DEFAULT_TURN_WALL_CLOCK: Duration = Duration::MAX;
|
||||
|
||||
/// Default per-step cap on accumulated streamed content, in bytes.
|
||||
/// Default per-step cap on accumulated streamed content, in bytes: the total
|
||||
/// text, reasoning and tool-argument content before aborting the stream.
|
||||
/// Preserves the pre-R1 hard-coded value; R1 only makes it overridable.
|
||||
pub const DEFAULT_STREAM_MAX_CONTENT_BYTES: usize = super::streaming::STREAM_MAX_CONTENT_BYTES;
|
||||
pub const DEFAULT_STREAM_MAX_CONTENT_BYTES: usize = 10 * 1024 * 1024;
|
||||
/// Smallest accepted per-step stream content cap (64 KiB).
|
||||
pub const MIN_STREAM_MAX_CONTENT_BYTES: usize = 64 * 1024;
|
||||
/// Largest accepted per-step stream content cap (512 MiB).
|
||||
@@ -45,27 +46,46 @@ pub const MAX_STREAM_MAX_CONTENT_BYTES: usize = 512 * 1024 * 1024;
|
||||
|
||||
/// Default per-step cap on a single stream's wall-clock duration, in
|
||||
/// seconds. Preserves the pre-R1 hard-coded value.
|
||||
pub const DEFAULT_STREAM_MAX_DURATION_SECS: u64 = super::streaming::STREAM_MAX_DURATION_SECS;
|
||||
///
|
||||
/// Sanity backstop for total stream wall-clock duration. **Not** a routine
|
||||
/// kill switch — the stream chunk idle timeout is the primary stall
|
||||
/// detector. The wall-clock cap is here only to bound pathological cases
|
||||
/// (e.g. a server that keeps sending heartbeats forever without progress).
|
||||
///
|
||||
/// History: this used to be 300s (5 min) which was too aggressive — V4
|
||||
/// thinking turns on hard prompts legitimately exceed 5 minutes wall-clock
|
||||
/// while still emitting reasoning_content chunks the whole way. Bumped to
|
||||
/// 30 min in v0.6.6 after long-reasoning turns hit the old cap. Codex defaults to a
|
||||
/// per-chunk idle of 300s with no wall-clock cap; we keep both layers but
|
||||
/// give the wall-clock a generous window so it never fires in practice.
|
||||
pub const DEFAULT_STREAM_MAX_DURATION_SECS: u64 = 1800;
|
||||
/// Smallest accepted per-step stream duration cap.
|
||||
pub const MIN_STREAM_MAX_DURATION_SECS: u64 = 10;
|
||||
/// Largest accepted per-step stream duration cap (24 hours).
|
||||
pub const MAX_STREAM_MAX_DURATION_SECS: u64 = 86_400;
|
||||
|
||||
/// Default whole-request resume budget after a failed stream (open failure,
|
||||
/// dead stream, sleep, or mid-stream network drop). Preserves the
|
||||
/// pre-#6700 hard-coded `MAX_STREAM_RETRIES`.
|
||||
pub const DEFAULT_STREAM_MAX_RESUMES: u32 = super::streaming::MAX_STREAM_RETRIES;
|
||||
/// dead stream, sleep, or mid-stream network drop). Shared by the
|
||||
/// nothing-streamed outer retry (#103 Phase 3), the sleep-resume retry
|
||||
/// (#2990), the network-drop resumes, and stream-open failures (#6699).
|
||||
/// Overridable via `[tui].stream_max_resumes` (#6700).
|
||||
pub const DEFAULT_STREAM_MAX_RESUMES: u32 = 3;
|
||||
/// Largest accepted resume budget. `0` is accepted and disables resumes.
|
||||
pub const MAX_STREAM_MAX_RESUMES: u32 = 10;
|
||||
/// Default in-stream transparent retry budget (nothing streamed yet).
|
||||
/// Preserves the pre-#6700 hard-coded `MAX_TRANSPARENT_STREAM_RETRIES`.
|
||||
pub const DEFAULT_STREAM_MAX_TRANSPARENT_RETRIES: u32 =
|
||||
super::streaming::MAX_TRANSPARENT_STREAM_RETRIES;
|
||||
/// Default in-stream transparent retry budget. These retries only happen
|
||||
/// when the wire dies before any content was streamed. The user has seen
|
||||
/// nothing, but provider usage or billing may already exist. Two attempts
|
||||
/// can ride out a flaky edge node without amplifying real outages (#103).
|
||||
/// `[tui].stream_max_transparent_retries` overrides it (#6700).
|
||||
pub const DEFAULT_STREAM_MAX_TRANSPARENT_RETRIES: u32 = 2;
|
||||
/// Largest accepted transparent retry budget. `0` disables them.
|
||||
pub const MAX_STREAM_MAX_TRANSPARENT_RETRIES: u32 = 10;
|
||||
/// Default streak of recoverable stream errors tolerated in one stream.
|
||||
/// Preserves the pre-#6700 hard-coded `MAX_STREAM_ERRORS_BEFORE_FAIL`.
|
||||
pub const DEFAULT_STREAM_MAX_ERRORS: u32 = super::streaming::MAX_STREAM_ERRORS_BEFORE_FAIL;
|
||||
/// Default streak of recoverable stream errors tolerated in one stream before
|
||||
/// the turn fails. Bumped 3 → 5 in v0.6.7 along with the HTTP/2 keepalive
|
||||
/// defaults (#103) — keepalive should make spurious decode errors rarer, so
|
||||
/// we can tolerate a longer streak before giving up on the turn.
|
||||
/// `[tui].stream_max_errors` overrides it (#6700).
|
||||
pub const DEFAULT_STREAM_MAX_ERRORS: u32 = 5;
|
||||
/// Smallest accepted error streak: the first error ends the stream. `0` is
|
||||
/// not a value here; like the other finite stream budgets it selects
|
||||
/// [`DEFAULT_STREAM_MAX_ERRORS`].
|
||||
@@ -179,7 +199,7 @@ pub fn resolve_stream_max_duration_secs(raw: Option<u64>) -> u64 {
|
||||
/// the turn — and discard the work the user just approved — the moment they
|
||||
/// came back.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct TurnWallClock {
|
||||
pub struct TurnWallClock {
|
||||
budget: Duration,
|
||||
started_at: Instant,
|
||||
/// Total time already excluded because the turn was blocked on a human.
|
||||
@@ -192,7 +212,7 @@ impl TurnWallClock {
|
||||
/// Start a fresh budget. A zero budget is legal here (and only here):
|
||||
/// it is how tests assert the stop path without sleeping. Configuration
|
||||
/// never produces one — [`resolve_turn_wall_clock`] reads `0` as no limit.
|
||||
pub(crate) fn start(budget: Duration) -> Self {
|
||||
pub fn start(budget: Duration) -> Self {
|
||||
Self {
|
||||
budget,
|
||||
started_at: Instant::now(),
|
||||
@@ -202,13 +222,13 @@ impl TurnWallClock {
|
||||
}
|
||||
|
||||
/// The budget this clock was started with.
|
||||
pub(crate) fn budget(&self) -> Duration {
|
||||
pub fn budget(&self) -> Duration {
|
||||
self.budget
|
||||
}
|
||||
|
||||
/// Wall-clock time this turn has spent on its own work, excluding time
|
||||
/// blocked on a human decision.
|
||||
pub(crate) fn spent(&self) -> Duration {
|
||||
pub fn spent(&self) -> Duration {
|
||||
let blocked_now = self
|
||||
.blocked_since
|
||||
.map_or(Duration::ZERO, |since| since.elapsed());
|
||||
@@ -219,21 +239,21 @@ impl TurnWallClock {
|
||||
}
|
||||
|
||||
/// Whether the cumulative budget is spent.
|
||||
pub(crate) fn exhausted(&self) -> bool {
|
||||
pub fn exhausted(&self) -> bool {
|
||||
self.spent() >= self.budget
|
||||
}
|
||||
|
||||
/// Stop counting: the turn is now waiting on a human decision.
|
||||
/// Idempotent — a second call while already blocked does nothing, so a
|
||||
/// nested or re-entered approval cannot double-exclude.
|
||||
pub(crate) fn begin_human_wait(&mut self) {
|
||||
pub fn begin_human_wait(&mut self) {
|
||||
if self.blocked_since.is_none() {
|
||||
self.blocked_since = Some(Instant::now());
|
||||
}
|
||||
}
|
||||
|
||||
/// Resume counting after a human decision, banking the blocked time.
|
||||
pub(crate) fn end_human_wait(&mut self) {
|
||||
pub fn end_human_wait(&mut self) {
|
||||
if let Some(since) = self.blocked_since.take() {
|
||||
self.excluded = self.excluded.saturating_add(since.elapsed());
|
||||
}
|
||||
@@ -243,8 +263,8 @@ impl TurnWallClock {
|
||||
/// exhaustion path can be exercised without sleeping. An in-progress
|
||||
/// human wait is rewound too — otherwise the simulated time would count
|
||||
/// as agent-owned work that never actually happened.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn rewind_for_test(&mut self, elapsed: Duration) {
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn rewind_for_test(&mut self, elapsed: Duration) {
|
||||
self.started_at = self
|
||||
.started_at
|
||||
.checked_sub(elapsed)
|
||||
@@ -583,7 +583,7 @@ impl ApprovalReceiptStore {
|
||||
) -> io::Result<()> {
|
||||
let log = self.log_file(session_id, true)?;
|
||||
let lock_file = log.sibling(APPROVAL_LOCK_FILE)?.open_update(true, false)?;
|
||||
crate::utils::restrict_to_owner(&lock_file)?;
|
||||
codewhale_runtime::private_log::restrict_to_owner(&lock_file)?;
|
||||
let mut lock = fd_lock::RwLock::new(lock_file);
|
||||
let _guard = lock.write()?;
|
||||
let mut loaded = Self::load_unlocked(&log)?.unwrap_or_default();
|
||||
@@ -600,7 +600,7 @@ impl ApprovalReceiptStore {
|
||||
line.push(b'\n');
|
||||
// Approval receipts are owner-only and never written through a link.
|
||||
let mut file = log.open_update(true, true)?;
|
||||
crate::utils::restrict_to_owner(&file)?;
|
||||
codewhale_runtime::private_log::restrict_to_owner(&file)?;
|
||||
if let Some(tail) = &loaded.torn_tail {
|
||||
// Validate the candidate and preserve evidence before touching the
|
||||
// live log. Any preservation error leaves the original intact.
|
||||
|
||||
@@ -1476,19 +1476,6 @@ fn build_speech_synthesis_body(
|
||||
|
||||
// === CodewhaleClient ===
|
||||
|
||||
/// Returns true when CODEWHALE_FORCE_HTTP1 (legacy alias: DEEPSEEK_FORCE_HTTP1)
|
||||
/// is set to a truthy value (`1`, `true`, `yes`, `on`, case-insensitive). Read
|
||||
/// only by `Config::force_http1`, which ORs it with the selected stream config flag; every
|
||||
/// client builder and stream open takes that resolved value (#103, #6700). Anything else (unset, `0`,
|
||||
/// `false`, ...) leaves HTTP/2 on.
|
||||
pub(crate) fn force_http1_from_env() -> bool {
|
||||
std::env::var("CODEWHALE_FORCE_HTTP1")
|
||||
.or_else(|_| std::env::var("DEEPSEEK_FORCE_HTTP1"))
|
||||
.ok()
|
||||
.map(|v| v.trim().to_ascii_lowercase())
|
||||
.is_some_and(|v| matches!(v.as_str(), "1" | "true" | "yes" | "on"))
|
||||
}
|
||||
|
||||
/// Read `SSL_CERT_FILE` and add its contents as extra root
|
||||
/// certificates on the reqwest builder (#418). Tries the PEM-bundle
|
||||
/// parser first (covers single-cert files too), then falls back to
|
||||
@@ -6626,7 +6613,7 @@ pub(crate) fn stream_first_response_bound(open: Duration, idle: Duration) -> Dur
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) use stream_entry::first_byte_timeout as stream_first_byte_timeout;
|
||||
pub(crate) use stream_entry::{is_stream_open_transport_failure, resolve_stream_open_timeout};
|
||||
pub(crate) use stream_entry::is_stream_open_transport_failure;
|
||||
mod wire;
|
||||
|
||||
// Retain the crate-visible accounting helpers at the existing client seam.
|
||||
@@ -6659,7 +6646,7 @@ pub(crate) use chat::{
|
||||
};
|
||||
pub(crate) use prepared::{
|
||||
CallerStreamMode, EndpointIdentity, PreparedOutboundRequest, RouteShape, WireBodyView,
|
||||
WireDialect, canonical_json,
|
||||
WireDialect,
|
||||
};
|
||||
pub(crate) use provider_native_search::{ProviderNativeSearchClient, ProviderNativeSearchRequest};
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use codewhale_config::provider::WireFormat;
|
||||
use codewhale_runtime::hashing::canonical_json;
|
||||
|
||||
use crate::config::ProviderKind;
|
||||
|
||||
@@ -419,49 +420,6 @@ impl PreparedOutboundRequest {
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical JSON: object keys sorted, no insignificant whitespace.
|
||||
///
|
||||
/// Deterministic for a given `Value` regardless of how it was built.
|
||||
pub(crate) fn canonical_json(value: &Value) -> String {
|
||||
let mut out = String::new();
|
||||
write_canonical(value, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn write_canonical(value: &Value, out: &mut String) {
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
let mut keys: Vec<&String> = map.keys().collect();
|
||||
keys.sort_unstable();
|
||||
out.push('{');
|
||||
for (index, key) in keys.iter().enumerate() {
|
||||
if index > 0 {
|
||||
out.push(',');
|
||||
}
|
||||
push_json_string(key, out);
|
||||
out.push(':');
|
||||
write_canonical(&map[*key], out);
|
||||
}
|
||||
out.push('}');
|
||||
}
|
||||
Value::Array(items) => {
|
||||
out.push('[');
|
||||
for (index, item) in items.iter().enumerate() {
|
||||
if index > 0 {
|
||||
out.push(',');
|
||||
}
|
||||
write_canonical(item, out);
|
||||
}
|
||||
out.push(']');
|
||||
}
|
||||
other => out.push_str(&other.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn push_json_string(value: &str, out: &mut String) {
|
||||
out.push_str(&Value::String(value.to_string()).to_string());
|
||||
}
|
||||
|
||||
/// Where a given dialect keeps its system text, turn items, and tool schemas.
|
||||
///
|
||||
/// Extraction is by shape, never by guessing: a Responses body has
|
||||
|
||||
@@ -16,43 +16,6 @@ use reqwest::Client;
|
||||
|
||||
use crate::llm_client::LlmError;
|
||||
|
||||
/// Default bounded wait for SSE response headers. Intentionally shorter than
|
||||
/// the per-chunk idle timeout: it covers connection setup and upstream header
|
||||
/// return only, never model thinking time after streaming has started.
|
||||
pub(crate) const DEFAULT_STREAM_OPEN_TIMEOUT: Duration = Duration::from_secs(45);
|
||||
/// Accepted response-header wait range, in seconds.
|
||||
pub(crate) const MIN_STREAM_OPEN_TIMEOUT_SECS: u64 = 5;
|
||||
pub(crate) const MAX_STREAM_OPEN_TIMEOUT_SECS: u64 = 300;
|
||||
|
||||
/// Resolve the response-header wait shared by every streaming adapter.
|
||||
///
|
||||
/// A positive `[stream].open_timeout_secs` (legacy `[tui]` fallback) wins; omitted or `0`
|
||||
/// falls back to the env override (`CODEWHALE_STREAM_OPEN_TIMEOUT_SECS`,
|
||||
/// legacy `DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS`), then the 45s default. Every
|
||||
/// source clamps to `5..=300`.
|
||||
#[must_use]
|
||||
pub(crate) fn resolve_stream_open_timeout(configured_secs: Option<u64>) -> Duration {
|
||||
match configured_secs {
|
||||
Some(secs) if secs > 0 => Duration::from_secs(
|
||||
secs.clamp(MIN_STREAM_OPEN_TIMEOUT_SECS, MAX_STREAM_OPEN_TIMEOUT_SECS),
|
||||
),
|
||||
_ => stream_open_timeout_from_env(
|
||||
std::env::var("CODEWHALE_STREAM_OPEN_TIMEOUT_SECS")
|
||||
.or_else(|_| std::env::var("DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS"))
|
||||
.ok()
|
||||
.as_deref(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn stream_open_timeout_from_env(value: Option<&str>) -> Duration {
|
||||
let secs = value
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
.unwrap_or(DEFAULT_STREAM_OPEN_TIMEOUT.as_secs())
|
||||
.clamp(MIN_STREAM_OPEN_TIMEOUT_SECS, MAX_STREAM_OPEN_TIMEOUT_SECS);
|
||||
Duration::from_secs(secs)
|
||||
}
|
||||
|
||||
/// Default wait for the first body byte after the response headers (#6184).
|
||||
/// Well under the 900s default inter-chunk idle budget: a provider that has
|
||||
/// answered the headers and then sends nothing at all — not even an SSE
|
||||
@@ -480,6 +443,10 @@ mod tests {
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
use super::*;
|
||||
use codewhale_config::{
|
||||
MAX_STREAM_OPEN_TIMEOUT_SECS, MIN_STREAM_OPEN_TIMEOUT_SECS, resolve_stream_open_timeout,
|
||||
stream_open_timeout_from_env,
|
||||
};
|
||||
|
||||
fn open_req(policy: StreamHttpPolicy, open_timeout: Duration) -> StreamOpenRequest {
|
||||
StreamOpenRequest {
|
||||
|
||||
@@ -780,7 +780,7 @@
|
||||
let _lock = crate::test_support::lock_test_env();
|
||||
let _guard = ForceHttp1EnvGuard::capture();
|
||||
unsafe { std::env::remove_var("DEEPSEEK_FORCE_HTTP1") };
|
||||
assert!(!force_http1_from_env());
|
||||
assert!(!codewhale_config::force_http1_from_env());
|
||||
}
|
||||
// from force_http1_truthy_values
|
||||
{
|
||||
@@ -790,7 +790,7 @@
|
||||
// Safety: serialized by lock_test_env; reverted by guard.
|
||||
unsafe { std::env::set_var("DEEPSEEK_FORCE_HTTP1", value) };
|
||||
assert!(
|
||||
force_http1_from_env(),
|
||||
codewhale_config::force_http1_from_env(),
|
||||
"{value:?} should be parsed as truthy",
|
||||
);
|
||||
}
|
||||
@@ -802,7 +802,7 @@
|
||||
for value in ["0", "false", "no", "off", "", "garbage", "2"] {
|
||||
unsafe { std::env::set_var("DEEPSEEK_FORCE_HTTP1", value) };
|
||||
assert!(
|
||||
!force_http1_from_env(),
|
||||
!codewhale_config::force_http1_from_env(),
|
||||
"{value:?} should NOT be parsed as truthy"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -424,8 +424,8 @@ pub(crate) fn show_single_setting(app: &App, key: &str) -> ConfigReply {
|
||||
),
|
||||
"cost_currency" | "currency" => Some(
|
||||
match app.cost_currency {
|
||||
crate::pricing::CostCurrency::Usd => "usd",
|
||||
crate::pricing::CostCurrency::Cny => "cny",
|
||||
codewhale_config::pricing::CostCurrency::Usd => "usd",
|
||||
codewhale_config::pricing::CostCurrency::Cny => "cny",
|
||||
}
|
||||
.to_string(),
|
||||
),
|
||||
@@ -2509,8 +2509,9 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) ->
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
"cost_currency" | "currency" => {
|
||||
app.cost_currency = crate::pricing::CostCurrency::from_setting(&settings.cost_currency)
|
||||
.unwrap_or(crate::pricing::CostCurrency::Usd);
|
||||
app.cost_currency =
|
||||
codewhale_config::pricing::CostCurrency::from_setting(&settings.cost_currency)
|
||||
.unwrap_or(codewhale_config::pricing::CostCurrency::Usd);
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
key @ ("mini_window.keep_header"
|
||||
@@ -4328,7 +4329,10 @@ mod tests {
|
||||
let msg = result.message.unwrap();
|
||||
|
||||
assert_eq!(msg, "cost_currency = cny (saved)");
|
||||
assert_eq!(app.cost_currency, crate::pricing::CostCurrency::Cny);
|
||||
assert_eq!(
|
||||
app.cost_currency,
|
||||
codewhale_config::pricing::CostCurrency::Cny
|
||||
);
|
||||
|
||||
let settings_path = Settings::path().unwrap();
|
||||
let saved = fs::read_to_string(settings_path).unwrap();
|
||||
|
||||
@@ -93,9 +93,9 @@ use crate::commands::groups::plugins::plugin_network_policy;
|
||||
|
||||
use crate::dependencies::ExternalTool as _;
|
||||
use crate::network_policy::NetworkPolicy;
|
||||
use crate::pricing::CostCurrency;
|
||||
use crate::tui::app::App;
|
||||
use crate::tui::history::HistoryCell;
|
||||
use codewhale_config::pricing::CostCurrency;
|
||||
use codewhale_localization::{MessageId, tr};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -2805,7 +2805,7 @@ fn installer_settings() -> (NetworkPolicy, u64, String) {
|
||||
let network = cfg
|
||||
.network
|
||||
.clone()
|
||||
.map(|policy| policy.into_runtime())
|
||||
.map(crate::network_policy::NetworkPolicy::from)
|
||||
.unwrap_or_default();
|
||||
let skills_cfg = cfg.skills.as_ref();
|
||||
let max_size = skills_cfg
|
||||
@@ -5344,7 +5344,7 @@ mod tests {
|
||||
recorded_at: std::time::Instant::now(),
|
||||
});
|
||||
let expected_total =
|
||||
app.displayed_session_cost_for_currency(crate::pricing::CostCurrency::Usd);
|
||||
app.displayed_session_cost_for_currency(codewhale_config::pricing::CostCurrency::Usd);
|
||||
let mut bundle = app.command_contexts();
|
||||
let mut parts = bundle
|
||||
.contexts(CommandCapabilities::DEBUG_DIAGNOSTICS)
|
||||
|
||||
@@ -54,7 +54,7 @@ impl CommandPermissionsContext for PermissionsAdapter<'_> {
|
||||
}
|
||||
},
|
||||
approval_mode: to_command_approval(app.approval_mode),
|
||||
audit_path: crate::audit::audit_log_path(),
|
||||
audit_path: codewhale_runtime::audit::audit_log_path(),
|
||||
})
|
||||
}
|
||||
fn remove_rule(
|
||||
|
||||
@@ -45,7 +45,7 @@ fn normalize(message: &str, temp: &TempDir) -> String {
|
||||
text = text.replace(workspace, "<WORKSPACE>");
|
||||
}
|
||||
}
|
||||
if let Some(audit) = crate::audit::audit_log_path() {
|
||||
if let Some(audit) = codewhale_runtime::audit::audit_log_path() {
|
||||
text = text.replace(
|
||||
&codewhale_config::quote_os_path(&audit),
|
||||
"\"<TEST_AUDIT_LOG>\"",
|
||||
|
||||
@@ -17,9 +17,10 @@ use crate::client::{
|
||||
};
|
||||
use crate::config::provider_has_balance_api;
|
||||
use crate::context_report::project_source_map as source_map;
|
||||
use crate::pricing::{CostCurrency, token_usage_for_pricing};
|
||||
use crate::pricing::token_usage_for_pricing;
|
||||
use crate::tool_inspection::project_snapshot as tool_snapshot;
|
||||
use crate::tui::app::App;
|
||||
use codewhale_config::pricing::CostCurrency;
|
||||
|
||||
pub(super) struct DebugDiagnosticsAdapter<'a> {
|
||||
pub(super) host: SharedCommandHost<'a>,
|
||||
|
||||
@@ -255,12 +255,12 @@ fn cost_coverage_is_currency_specific_for_mixed_deepseek_openai() {
|
||||
app.accrue_session_cost_estimate(deepseek.estimate.expect("DeepSeek priced"));
|
||||
app.accrue_session_cost_estimate(openai.estimate.expect("OpenAI priced"));
|
||||
|
||||
app.cost_currency = crate::pricing::CostCurrency::Usd;
|
||||
app.cost_currency = codewhale_config::pricing::CostCurrency::Usd;
|
||||
let usd = cost(&mut app).message.expect("USD report");
|
||||
assert!(usd.contains("Covered: 2 of 2"), "{usd}");
|
||||
assert!(usd.contains("Estimated total:"), "{usd}");
|
||||
|
||||
app.cost_currency = crate::pricing::CostCurrency::Cny;
|
||||
app.cost_currency = codewhale_config::pricing::CostCurrency::Cny;
|
||||
let cny = cost(&mut app).message.expect("CNY report");
|
||||
assert!(cny.contains("Covered: 1 of 2"), "{cny}");
|
||||
assert!(cny.contains("Priced subtotal:"), "{cny}");
|
||||
@@ -1480,9 +1480,10 @@ mod cost_breakdown_tests {
|
||||
use super::*;
|
||||
use crate::commands::contract::DebugCostComponents as CostComponents;
|
||||
use crate::config::Config;
|
||||
use crate::pricing::{CostCurrency, CostEstimate, TurnCostAudit};
|
||||
use crate::pricing::{CostEstimate, TurnCostAudit};
|
||||
use crate::tui::app::App;
|
||||
use crate::tui::app::{TuiOptions, TurnCacheRecord};
|
||||
use codewhale_config::pricing::CostCurrency;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Instant;
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ pub(in crate::commands) fn create_test_app() -> App {
|
||||
};
|
||||
let mut app = App::new(options, &Config::default());
|
||||
app.ui_locale = codewhale_localization::Locale::En;
|
||||
app.cost_currency = crate::pricing::CostCurrency::Usd;
|
||||
app.cost_currency = codewhale_config::pricing::CostCurrency::Usd;
|
||||
app.api_provider = crate::config::ProviderKind::Deepseek;
|
||||
app
|
||||
}
|
||||
|
||||
@@ -917,7 +917,7 @@ pub(crate) fn plugin_network_policy() -> crate::network_policy::NetworkPolicy {
|
||||
crate::config::Config::load(None, None)
|
||||
.unwrap_or_default()
|
||||
.network
|
||||
.map(|policy| policy.into_runtime())
|
||||
.map(crate::network_policy::NetworkPolicy::from)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
|
||||
+43
-293
@@ -15,9 +15,9 @@ use serde_json::json;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
use crate::audit::log_sensitive_event;
|
||||
use crate::credentials::CredentialStore;
|
||||
use crate::hooks::HooksConfig;
|
||||
use codewhale_runtime::audit::log_sensitive_event;
|
||||
use codewhale_runtime::features::{Feature, Features, FeaturesToml, is_known_feature_key};
|
||||
|
||||
// Sub-agent concurrency/timeout limit constants and their clamp resolvers live
|
||||
@@ -92,7 +92,8 @@ pub struct ProviderIdentity {
|
||||
/// serialized.
|
||||
pub(crate) migrated_legacy_ollama_cloud_route: bool,
|
||||
/// Parse-scoped, redacted proof for the id-less migrated root route.
|
||||
pub(crate) legacy_root_custom_generation: Option<crate::route_receipt::CredentialGeneration>,
|
||||
pub(crate) legacy_root_custom_generation:
|
||||
Option<codewhale_config::credentials::CredentialGeneration>,
|
||||
}
|
||||
|
||||
impl std::hash::Hash for ProviderIdentity {
|
||||
@@ -922,7 +923,7 @@ pub struct ExtensionHostConfig {
|
||||
/// Explicit MCP protocol backend. Rust remains default; Host supports stdio,
|
||||
/// HTTP and SSE independently of optional Native extension activation.
|
||||
#[serde(default)]
|
||||
pub mcp_backend: crate::mcp::McpBackend,
|
||||
pub mcp_backend: codewhale_config::McpBackend,
|
||||
/// Which runtime runs the host: `node` (the default), `bun`, or `auto`
|
||||
/// (Bun when a supported one is found and starts, else Node). `bun` and
|
||||
/// `auto` are opt-ins: Bun is not the default until it is qualified on
|
||||
@@ -1251,11 +1252,11 @@ fn default_snapshots_enabled() -> bool {
|
||||
}
|
||||
|
||||
fn default_snapshot_max_age_days() -> u64 {
|
||||
crate::snapshot::DEFAULT_MAX_AGE.as_secs() / (24 * 60 * 60)
|
||||
codewhale_config::DEFAULT_SNAPSHOT_MAX_AGE.as_secs() / (24 * 60 * 60)
|
||||
}
|
||||
|
||||
fn default_snapshot_max_workspace_gb() -> u64 {
|
||||
crate::snapshot::DEFAULT_MAX_WORKSPACE_BYTES_FOR_SNAPSHOT / (1024 * 1024 * 1024)
|
||||
codewhale_config::DEFAULT_MAX_WORKSPACE_BYTES_FOR_SNAPSHOT / (1024 * 1024 * 1024)
|
||||
}
|
||||
|
||||
/// Workspace side-git snapshot configuration (#137).
|
||||
@@ -2479,7 +2480,7 @@ pub struct Config {
|
||||
/// Per-domain network policy (#135). When absent, network tools fall back
|
||||
/// to a permissive default that mirrors pre-v0.7.0 behavior.
|
||||
#[serde(default)]
|
||||
pub network: Option<NetworkPolicyToml>,
|
||||
pub network: Option<codewhale_config::NetworkPolicyToml>,
|
||||
|
||||
/// Verifier-preview behavior (#2093). When absent, automatic verifier
|
||||
/// preview stays off and verifier verdicts use the hunt policy.
|
||||
@@ -2552,9 +2553,9 @@ pub struct Config {
|
||||
pub cloud_facts: Option<CloudFactsConfig>,
|
||||
|
||||
/// Post-edit LSP diagnostics injection (#136). When absent, the engine
|
||||
/// applies the defaults documented in [`LspConfigToml`].
|
||||
/// applies the defaults documented in [`codewhale_config::LspConfigToml`].
|
||||
#[serde(default)]
|
||||
pub lsp: Option<LspConfigToml>,
|
||||
pub lsp: Option<codewhale_config::LspConfigToml>,
|
||||
|
||||
/// Context configuration (project context pack; legacy seam keys are
|
||||
/// parsed but ignored since the 2026-07-23 removal).
|
||||
@@ -2658,12 +2659,13 @@ pub struct Config {
|
||||
/// Bound at parse time, never reconstructed from later diagnostic notes.
|
||||
#[cfg(not(test))]
|
||||
#[serde(skip)]
|
||||
legacy_root_custom_generation: Option<crate::route_receipt::CredentialGeneration>,
|
||||
legacy_root_custom_generation: Option<codewhale_config::credentials::CredentialGeneration>,
|
||||
// Test fixtures construct Config through public-field struct updates. The
|
||||
// production receipt remains private; both builds run identical admission.
|
||||
#[cfg(test)]
|
||||
#[serde(skip)]
|
||||
pub(crate) legacy_root_custom_generation: Option<crate::route_receipt::CredentialGeneration>,
|
||||
pub(crate) legacy_root_custom_generation:
|
||||
Option<codewhale_config::credentials::CredentialGeneration>,
|
||||
|
||||
/// Mini-window (pinned, always-on-top) mode layout preferences
|
||||
/// (`[mini_window]` in config.toml). When the host terminal window is
|
||||
@@ -2774,33 +2776,6 @@ pub struct AutoReviewRuleConfig {
|
||||
}
|
||||
|
||||
impl AutoReviewConfig {
|
||||
fn to_runtime_policy(&self) -> crate::core::authority::auto_review::AutoReviewPolicy {
|
||||
crate::core::authority::auto_review::AutoReviewPolicy {
|
||||
allow_rules: self
|
||||
.allow
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, rule)| {
|
||||
rule.to_runtime_rule(
|
||||
index,
|
||||
crate::core::authority::auto_review::AutoReviewAction::Allow,
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
block_rules: self
|
||||
.block
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, rule)| {
|
||||
rule.to_runtime_rule(
|
||||
index,
|
||||
crate::core::authority::auto_review::AutoReviewAction::Block,
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate(&self) -> Result<()> {
|
||||
validate_auto_review_rules("allow", &self.allow)?;
|
||||
validate_auto_review_rules("block", &self.block)?;
|
||||
@@ -2809,62 +2784,6 @@ impl AutoReviewConfig {
|
||||
}
|
||||
|
||||
impl AutoReviewRuleConfig {
|
||||
fn to_runtime_rule(
|
||||
&self,
|
||||
index: usize,
|
||||
action: crate::core::authority::auto_review::AutoReviewAction,
|
||||
) -> crate::core::authority::auto_review::AutoReviewRule {
|
||||
let id_prefix = match action {
|
||||
crate::core::authority::auto_review::AutoReviewAction::Allow => "allow",
|
||||
crate::core::authority::auto_review::AutoReviewAction::Block => "block",
|
||||
crate::core::authority::auto_review::AutoReviewAction::AskUser => "ask",
|
||||
};
|
||||
let id = self
|
||||
.id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| format!("config-{id_prefix}-{index}"));
|
||||
let reason = self
|
||||
.reason
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| format!("configured auto-review {id_prefix} rule"));
|
||||
let mut rule = match action {
|
||||
crate::core::authority::auto_review::AutoReviewAction::Allow => {
|
||||
crate::core::authority::auto_review::AutoReviewRule::allow(id, reason)
|
||||
}
|
||||
crate::core::authority::auto_review::AutoReviewAction::Block => {
|
||||
crate::core::authority::auto_review::AutoReviewRule::block(id, reason)
|
||||
}
|
||||
crate::core::authority::auto_review::AutoReviewAction::AskUser => {
|
||||
crate::core::authority::auto_review::AutoReviewRule::block(id, reason)
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(tool) = self
|
||||
.tool
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
rule = rule.tool_name(tool.to_string());
|
||||
}
|
||||
if let Some(action_kind) = self
|
||||
.action_kind
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.and_then(parse_auto_review_action_kind)
|
||||
{
|
||||
rule = rule.action_kind(action_kind);
|
||||
}
|
||||
rule
|
||||
}
|
||||
|
||||
fn has_matcher(&self) -> bool {
|
||||
self.tool
|
||||
.as_deref()
|
||||
@@ -2894,7 +2813,7 @@ fn validate_auto_review_rules(kind: &str, rules: &[AutoReviewRuleConfig]) -> Res
|
||||
}
|
||||
if let Some(action_kind) = rule.action_kind.as_deref() {
|
||||
let normalized = action_kind.trim().to_ascii_lowercase().replace('-', "_");
|
||||
if parse_auto_review_action_kind(&normalized).is_none() {
|
||||
if auto_review_action_kind_name(&normalized).is_none() {
|
||||
anyhow::bail!(
|
||||
"Invalid auto_review.{kind}[{index}].action_kind '{action_kind}': expected read, write, shell, external, publish, or destructive."
|
||||
);
|
||||
@@ -2914,20 +2833,17 @@ fn validate_auto_review_rules(kind: &str, rules: &[AutoReviewRuleConfig]) -> Res
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_auto_review_action_kind(
|
||||
raw: &str,
|
||||
) -> Option<crate::core::authority::auto_review::ToolActionKind> {
|
||||
/// Canonical action-kind name for an `auto_review` `action_kind` spelling,
|
||||
/// including the retired narrow kinds that fold into a current class.
|
||||
#[must_use]
|
||||
pub fn auto_review_action_kind_name(raw: &str) -> Option<&'static str> {
|
||||
match raw.trim().to_ascii_lowercase().replace('-', "_").as_str() {
|
||||
"read" | "mcp_read" => Some(crate::core::authority::auto_review::ToolActionKind::Read),
|
||||
"write" => Some(crate::core::authority::auto_review::ToolActionKind::Write),
|
||||
"shell" => Some(crate::core::authority::auto_review::ToolActionKind::Shell),
|
||||
"external" | "network" | "git" | "mcp_action" | "browser" | "unknown" => {
|
||||
Some(crate::core::authority::auto_review::ToolActionKind::External)
|
||||
}
|
||||
"publish" => Some(crate::core::authority::auto_review::ToolActionKind::Publish),
|
||||
"destructive" | "secret" => {
|
||||
Some(crate::core::authority::auto_review::ToolActionKind::Destructive)
|
||||
}
|
||||
"read" | "mcp_read" => Some("read"),
|
||||
"write" => Some("write"),
|
||||
"shell" => Some("shell"),
|
||||
"external" | "network" | "git" | "mcp_action" | "browser" | "unknown" => Some("external"),
|
||||
"publish" => Some("publish"),
|
||||
"destructive" | "secret" => Some("destructive"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -3024,149 +2940,6 @@ impl SkillsConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// `[network]` table — mirrors `codewhale_config::NetworkPolicyToml` so the live
|
||||
/// TUI runtime can construct a [`crate::network_policy::NetworkPolicy`]
|
||||
/// without reaching into the workspace config crate. See `config.example.toml`
|
||||
/// for documentation.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct NetworkPolicyToml {
|
||||
/// Decision for hosts that are not in `allow` or `deny`. One of
|
||||
/// `"allow" | "deny" | "prompt"`. Defaults to `"prompt"`.
|
||||
#[serde(default = "default_network_decision")]
|
||||
pub default: String,
|
||||
/// Hosts that are always allowed. Subdomain rules: a leading dot
|
||||
/// (`.example.com`) matches subdomains but not the apex.
|
||||
#[serde(default)]
|
||||
pub allow: Vec<String>,
|
||||
/// Hosts that are always denied. Deny entries win over allow entries.
|
||||
#[serde(default)]
|
||||
pub deny: Vec<String>,
|
||||
/// Hostnames whose DNS may resolve to fake-IP/private proxy ranges in an
|
||||
/// explicitly trusted proxy setup. Literal IP URLs remain blocked.
|
||||
#[serde(default)]
|
||||
pub proxy: Vec<String>,
|
||||
/// Explicit fake-IP placeholder CIDRs for those proxy hosts. Only subnets
|
||||
/// within `198.18.0.0/15` are accepted by the runtime SSRF guard.
|
||||
#[serde(default)]
|
||||
pub proxy_fake_ip_cidrs: Vec<String>,
|
||||
/// Whether to record one audit-log line per outbound network call.
|
||||
#[serde(default = "default_network_audit")]
|
||||
pub audit: bool,
|
||||
}
|
||||
|
||||
fn default_network_decision() -> String {
|
||||
"prompt".to_string()
|
||||
}
|
||||
|
||||
fn default_network_audit() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl Default for NetworkPolicyToml {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
default: default_network_decision(),
|
||||
allow: Vec::new(),
|
||||
deny: Vec::new(),
|
||||
proxy: Vec::new(),
|
||||
proxy_fake_ip_cidrs: Vec::new(),
|
||||
audit: default_network_audit(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NetworkPolicyToml {
|
||||
/// Build a runtime [`crate::network_policy::NetworkPolicy`] from the
|
||||
/// on-disk schema.
|
||||
#[must_use]
|
||||
pub fn into_runtime(self) -> crate::network_policy::NetworkPolicy {
|
||||
crate::network_policy::NetworkPolicy {
|
||||
default: crate::network_policy::Decision::parse(&self.default).into(),
|
||||
allow: self.allow,
|
||||
deny: self.deny,
|
||||
proxy: self.proxy,
|
||||
proxy_fake_ip_cidrs: self.proxy_fake_ip_cidrs,
|
||||
audit: self.audit,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-read the `[network]` table from a configuration document.
|
||||
///
|
||||
/// Deliberately narrower than [`Config::load`]: this runs on the tool-context
|
||||
/// build path, where re-applying the environment, managed, and credential
|
||||
/// layers would be both wasteful and wrong. Only the document's own table comes
|
||||
/// back — the same table `/network allow <host>` edits.
|
||||
///
|
||||
/// `None` means there is nothing usable to adopt: the document is missing,
|
||||
/// unreadable, unparseable, or carries no `[network]` table. Callers keep the
|
||||
/// policy they already hold, which is the conservative direction for an
|
||||
/// allow/deny gate.
|
||||
///
|
||||
/// Known limitation: `[profiles.<name>.network]` is not consulted. `/network`
|
||||
/// does not write it either, so a live session and the command agree on this
|
||||
/// base table.
|
||||
#[must_use]
|
||||
pub fn network_policy_from_document(path: &Path) -> Option<crate::network_policy::NetworkPolicy> {
|
||||
#[derive(Deserialize)]
|
||||
struct NetworkTable {
|
||||
network: Option<NetworkPolicyToml>,
|
||||
}
|
||||
|
||||
let contents = fs::read_to_string(path).ok()?;
|
||||
let parsed: NetworkTable = toml::from_str(&contents).ok()?;
|
||||
parsed.network.map(NetworkPolicyToml::into_runtime)
|
||||
}
|
||||
|
||||
/// `[lsp]` table — mirrors [`crate::lsp::LspConfig`]. Documented in
|
||||
/// `config.example.toml`. When omitted, defaults from `LspConfig::default()`
|
||||
/// apply (enabled, 5 s poll, 20 diagnostics/file, errors only, no overrides).
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
pub struct LspConfigToml {
|
||||
/// Master switch. Defaults to `true`.
|
||||
#[serde(default)]
|
||||
pub enabled: Option<bool>,
|
||||
/// How long to wait for the LSP server to publish diagnostics after a
|
||||
/// `didOpen`/`didChange`. Defaults to 5000 ms.
|
||||
#[serde(default)]
|
||||
pub poll_after_edit_ms: Option<u64>,
|
||||
/// Cap on diagnostics surfaced per file. Defaults to 20.
|
||||
#[serde(default)]
|
||||
pub max_diagnostics_per_file: Option<usize>,
|
||||
/// Whether to surface warnings in addition to errors. Defaults to `false`.
|
||||
#[serde(default)]
|
||||
pub include_warnings: Option<bool>,
|
||||
/// Optional override for the `Language -> [cmd, ...args]` table. Keys
|
||||
/// are language slugs (`"rust"`, `"go"`, etc.).
|
||||
#[serde(default)]
|
||||
pub servers: Option<HashMap<String, Vec<String>>>,
|
||||
/// User-defined LSP servers for file extensions not in the built-in
|
||||
/// registry. Keyed by extension (e.g. `"php"`, `"rb"`).
|
||||
#[serde(default)]
|
||||
pub custom: Option<HashMap<String, crate::lsp::CustomLspDef>>,
|
||||
}
|
||||
|
||||
impl LspConfigToml {
|
||||
/// Build a runtime [`crate::lsp::LspConfig`] from the on-disk schema,
|
||||
/// falling back to defaults for any unset fields.
|
||||
#[must_use]
|
||||
pub fn into_runtime(self) -> crate::lsp::LspConfig {
|
||||
let defaults = crate::lsp::LspConfig::default();
|
||||
crate::lsp::LspConfig {
|
||||
enabled: self.enabled.unwrap_or(defaults.enabled),
|
||||
poll_after_edit_ms: self
|
||||
.poll_after_edit_ms
|
||||
.unwrap_or(defaults.poll_after_edit_ms),
|
||||
max_diagnostics_per_file: self
|
||||
.max_diagnostics_per_file
|
||||
.unwrap_or(defaults.max_diagnostics_per_file),
|
||||
include_warnings: self.include_warnings.unwrap_or(defaults.include_warnings),
|
||||
servers: self.servers.unwrap_or_default(),
|
||||
custom: self.custom.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
pub struct ProviderConfig {
|
||||
/// OpenRouter upstream slug; disables upstream fallbacks when set.
|
||||
@@ -4138,16 +3911,6 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an explicit config or requirements file owns approval posture.
|
||||
/// TUI preferences may supply a default only when this is false.
|
||||
#[must_use]
|
||||
pub fn approval_policy_is_managed(&self) -> bool {
|
||||
if self.approval_policy.is_some() {
|
||||
return true;
|
||||
}
|
||||
self.approval_policy_is_requirements_managed()
|
||||
}
|
||||
|
||||
/// Whether organization requirements, rather than a user-editable config
|
||||
/// key, own approval posture. User config still outranks TUI settings, but
|
||||
/// `/config approval_mode ... --save` may edit that user-owned key.
|
||||
@@ -4348,14 +4111,6 @@ impl Config {
|
||||
Some(std::time::Duration::from_secs(seconds.min(MAX_SECONDS)))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn auto_review_policy(&self) -> crate::core::authority::auto_review::AutoReviewPolicy {
|
||||
self.auto_review
|
||||
.as_ref()
|
||||
.map(AutoReviewConfig::to_runtime_policy)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Load configuration from disk and merge with environment overrides.
|
||||
///
|
||||
/// # Examples
|
||||
@@ -5289,12 +5044,12 @@ impl Config {
|
||||
|
||||
fn legacy_root_custom_table_generation(
|
||||
&self,
|
||||
) -> Option<crate::route_receipt::CredentialGeneration> {
|
||||
) -> Option<codewhale_config::credentials::CredentialGeneration> {
|
||||
let entry = self.providers.as_ref()?.custom.get("custom")?;
|
||||
let value = serde_json::to_value(entry).ok()?;
|
||||
Some(crate::route_receipt::CredentialGeneration::derive(
|
||||
Some(codewhale_config::credentials::CredentialGeneration::derive(
|
||||
"codewhale/legacy-root-custom-table/v1",
|
||||
&crate::client::canonical_json(&value),
|
||||
&codewhale_runtime::hashing::canonical_json(&value),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -5328,7 +5083,7 @@ impl Config {
|
||||
|
||||
fn current_legacy_root_custom_generation(
|
||||
&self,
|
||||
) -> Option<crate::route_receipt::CredentialGeneration> {
|
||||
) -> Option<codewhale_config::credentials::CredentialGeneration> {
|
||||
let captured = self.legacy_root_custom_generation.as_ref()?;
|
||||
let current = self.legacy_root_custom_table_generation()?;
|
||||
(captured == ¤t).then_some(current)
|
||||
@@ -6500,7 +6255,7 @@ impl Config {
|
||||
pub(crate) fn readonly_health_credential_generation(
|
||||
&self,
|
||||
identity: &ProviderIdentity,
|
||||
) -> Option<crate::route_receipt::CredentialGeneration> {
|
||||
) -> Option<codewhale_config::credentials::CredentialGeneration> {
|
||||
if self.required_account_model_owner.is_some() {
|
||||
return None;
|
||||
}
|
||||
@@ -6510,7 +6265,7 @@ impl Config {
|
||||
let endpoint = scoped.base_url_for_route(identity);
|
||||
let mode = scoped.auth_mode_for_provider(identity);
|
||||
let generation = |value: &str| {
|
||||
crate::route_receipt::CredentialGeneration::derive(
|
||||
codewhale_config::credentials::CredentialGeneration::derive(
|
||||
&endpoint,
|
||||
&codewhale_secrets::normalize_api_key(value),
|
||||
)
|
||||
@@ -7301,7 +7056,7 @@ impl Config {
|
||||
#[must_use]
|
||||
pub fn goal_max_steps(&self) -> u32 {
|
||||
let configured = self.goal.as_ref().and_then(|goal| goal.max_steps);
|
||||
let resolved = crate::core::engine::turn_budget::resolve_max_model_steps(configured);
|
||||
let resolved = codewhale_runtime::turn_budget::resolve_max_model_steps(configured);
|
||||
if let Some(steps) = configured
|
||||
&& steps > 0
|
||||
&& resolved != steps
|
||||
@@ -7744,7 +7499,7 @@ impl Config {
|
||||
.ok()
|
||||
.and_then(|value| value.trim().parse::<u32>().ok())
|
||||
});
|
||||
crate::core::engine::turn_budget::resolve_max_model_steps(raw)
|
||||
codewhale_runtime::turn_budget::resolve_max_model_steps(raw)
|
||||
}
|
||||
|
||||
/// R1: resolved cumulative per-turn wall-clock budget.
|
||||
@@ -7763,13 +7518,13 @@ impl Config {
|
||||
.ok()
|
||||
.and_then(|value| value.trim().parse::<u64>().ok())
|
||||
});
|
||||
crate::core::engine::turn_budget::resolve_turn_wall_clock(raw)
|
||||
codewhale_runtime::turn_budget::resolve_turn_wall_clock(raw)
|
||||
}
|
||||
|
||||
/// R1: resolved per-step cap on accumulated streamed content, in bytes.
|
||||
#[must_use]
|
||||
pub fn stream_max_content_bytes(&self) -> usize {
|
||||
crate::core::engine::turn_budget::resolve_stream_max_content_bytes(
|
||||
codewhale_runtime::turn_budget::resolve_stream_max_content_bytes(
|
||||
self.stream
|
||||
.as_ref()
|
||||
.and_then(|cfg| cfg.max_content_mb)
|
||||
@@ -7781,7 +7536,7 @@ impl Config {
|
||||
#[must_use]
|
||||
pub fn stream_max_duration(&self) -> std::time::Duration {
|
||||
std::time::Duration::from_secs(
|
||||
crate::core::engine::turn_budget::resolve_stream_max_duration_secs(
|
||||
codewhale_runtime::turn_budget::resolve_stream_max_duration_secs(
|
||||
self.stream
|
||||
.as_ref()
|
||||
.and_then(|cfg| cfg.max_duration_secs)
|
||||
@@ -7796,10 +7551,10 @@ impl Config {
|
||||
|
||||
/// Resolved `[stream]` retry budgets, falling back to legacy `[tui]` keys.
|
||||
#[must_use]
|
||||
pub fn stream_retry_limits(&self) -> crate::core::engine::turn_budget::StreamRetryLimits {
|
||||
pub fn stream_retry_limits(&self) -> codewhale_runtime::turn_budget::StreamRetryLimits {
|
||||
let tui = self.tui.as_ref();
|
||||
let stream = self.stream.as_ref();
|
||||
crate::core::engine::turn_budget::resolve_stream_retry_limits(
|
||||
codewhale_runtime::turn_budget::resolve_stream_retry_limits(
|
||||
stream
|
||||
.and_then(|cfg| cfg.max_resumes)
|
||||
.or_else(|| tui.and_then(|cfg| cfg.stream_max_resumes)),
|
||||
@@ -7815,7 +7570,7 @@ impl Config {
|
||||
/// #6700: resolved wait for SSE response headers.
|
||||
#[must_use]
|
||||
pub fn stream_open_timeout(&self) -> std::time::Duration {
|
||||
crate::client::resolve_stream_open_timeout(
|
||||
codewhale_config::resolve_stream_open_timeout(
|
||||
self.stream
|
||||
.as_ref()
|
||||
.and_then(|cfg| cfg.open_timeout_secs)
|
||||
@@ -7836,7 +7591,7 @@ impl Config {
|
||||
.and_then(|cfg| cfg.force_http1)
|
||||
.or_else(|| self.tui.as_ref().and_then(|cfg| cfg.force_http1))
|
||||
.unwrap_or(false)
|
||||
|| crate::client::force_http1_from_env()
|
||||
|| codewhale_config::force_http1_from_env()
|
||||
}
|
||||
|
||||
/// #6700: resolved TCP/TLS connect timeout for the model HTTP client.
|
||||
@@ -8010,16 +7765,11 @@ impl Config {
|
||||
self.fleet.clone().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The person's fleet as models (design MODEL-ROUTING-CATALOG §10 F1):
|
||||
/// every exact provider + model route in the selected fleet for
|
||||
/// `workspace`, with the roles each fills. `Ok(empty)` = the session
|
||||
/// model only; `Err` = a selected fleet that cannot be read. This is the
|
||||
/// seam the operator-awareness slice (F2) reads.
|
||||
pub fn fleet_members(
|
||||
&self,
|
||||
workspace: &Path,
|
||||
) -> Result<Vec<crate::fleet::members::FleetModel>, crate::fleet::store::FleetStoreError> {
|
||||
crate::fleet::members::fleet_models(workspace)
|
||||
#[must_use]
|
||||
pub fn mcp_backend(&self) -> codewhale_config::McpBackend {
|
||||
self.extension_host
|
||||
.as_ref()
|
||||
.map_or(codewhale_config::McpBackend::Rust, |host| host.mcp_backend)
|
||||
}
|
||||
|
||||
/// Parsed `[workflow]` table, or product defaults when the table is absent
|
||||
|
||||
@@ -1096,7 +1096,7 @@ reason = "read_file is allowed"
|
||||
)?;
|
||||
config.validate()?;
|
||||
|
||||
let policy = config.auto_review_policy();
|
||||
let policy = crate::core::authority::auto_review::AutoReviewPolicy::from_config(&config);
|
||||
let shell_context = crate::core::authority::auto_review::AutoReviewContext::from_tool_call(
|
||||
"exec_shell",
|
||||
&serde_json::json!({"command": "cargo test"}),
|
||||
@@ -1146,7 +1146,7 @@ fn auto_review_scenario() -> Result<()> {
|
||||
)?;
|
||||
|
||||
let merged = apply_profile(parsed, Some("strict"))?;
|
||||
let policy = merged.auto_review_policy();
|
||||
let policy = crate::core::authority::auto_review::AutoReviewPolicy::from_config(&merged);
|
||||
|
||||
assert_eq!(policy.block_rules.len(), 1);
|
||||
assert_eq!(
|
||||
@@ -1677,7 +1677,7 @@ fn update_config_scenario() {
|
||||
|
||||
#[test]
|
||||
fn network_policy_toml_maps_proxy_hosts_to_runtime_policy() {
|
||||
let policy: NetworkPolicyToml = toml::from_str(
|
||||
let policy: codewhale_config::NetworkPolicyToml = toml::from_str(
|
||||
r#"
|
||||
default = "allow"
|
||||
proxy = ["github.com", ".githubusercontent.com"]
|
||||
@@ -1686,7 +1686,7 @@ fn network_policy_toml_maps_proxy_hosts_to_runtime_policy() {
|
||||
)
|
||||
.expect("network policy toml");
|
||||
|
||||
let runtime = policy.into_runtime();
|
||||
let runtime = crate::network_policy::NetworkPolicy::from(policy);
|
||||
|
||||
assert_eq!(runtime.proxy, ["github.com", ".githubusercontent.com"]);
|
||||
assert_eq!(runtime.proxy_fake_ip_cidrs, ["198.18.0.0/15"]);
|
||||
|
||||
@@ -238,7 +238,7 @@ impl HostMcpDispatch {
|
||||
let (tx_event, rx_event) = mpsc::channel(64);
|
||||
let pool = McpPool::new(setup.config)
|
||||
.with_disallowed_tools(setup.disallowed_tools.clone())
|
||||
.with_backend(crate::mcp::McpBackend::Host);
|
||||
.with_backend(codewhale_config::McpBackend::Host);
|
||||
Box::new(Self {
|
||||
inner: McpPoolDispatch {
|
||||
pool: Arc::new(AsyncMutex::new(pool)),
|
||||
|
||||
@@ -96,6 +96,19 @@ impl ToolActionKind {
|
||||
}
|
||||
}
|
||||
|
||||
fn from_name(name: &str) -> Option<Self> {
|
||||
[
|
||||
Self::Read,
|
||||
Self::Write,
|
||||
Self::Shell,
|
||||
Self::External,
|
||||
Self::Publish,
|
||||
Self::Destructive,
|
||||
]
|
||||
.into_iter()
|
||||
.find(|kind| kind.as_str() == name)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn from_tool_name(tool_name: &str, category: ToolCategory) -> Self {
|
||||
Self::from_tool_call(tool_name, &Value::Null, category, None)
|
||||
@@ -630,6 +643,53 @@ impl AutoReviewRule {
|
||||
}
|
||||
}
|
||||
|
||||
fn rule_from_config(
|
||||
config: &crate::config::AutoReviewRuleConfig,
|
||||
index: usize,
|
||||
action: AutoReviewAction,
|
||||
) -> AutoReviewRule {
|
||||
let id_prefix = match action {
|
||||
AutoReviewAction::Allow => "allow",
|
||||
AutoReviewAction::Block => "block",
|
||||
AutoReviewAction::AskUser => "ask",
|
||||
};
|
||||
let id = config
|
||||
.id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| format!("config-{id_prefix}-{index}"));
|
||||
let reason = config
|
||||
.reason
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| format!("configured auto-review {id_prefix} rule"));
|
||||
let mut rule = match action {
|
||||
AutoReviewAction::Allow => AutoReviewRule::allow(id, reason),
|
||||
AutoReviewAction::Block | AutoReviewAction::AskUser => AutoReviewRule::block(id, reason),
|
||||
};
|
||||
if let Some(tool) = config
|
||||
.tool
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
rule = rule.tool_name(tool.to_string());
|
||||
}
|
||||
if let Some(action_kind) = config
|
||||
.action_kind
|
||||
.as_deref()
|
||||
.and_then(crate::config::auto_review_action_kind_name)
|
||||
.and_then(ToolActionKind::from_name)
|
||||
{
|
||||
rule = rule.action_kind(action_kind);
|
||||
}
|
||||
rule
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct AutoReviewPolicy {
|
||||
pub allow_rules: Vec<AutoReviewRule>,
|
||||
@@ -637,6 +697,27 @@ pub struct AutoReviewPolicy {
|
||||
}
|
||||
|
||||
impl AutoReviewPolicy {
|
||||
#[must_use]
|
||||
pub fn from_config(config: &crate::config::Config) -> Self {
|
||||
let Some(rules) = config.auto_review.as_ref() else {
|
||||
return Self::default();
|
||||
};
|
||||
Self {
|
||||
allow_rules: rules
|
||||
.allow
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, rule)| rule_from_config(rule, index, AutoReviewAction::Allow))
|
||||
.collect(),
|
||||
block_rules: rules
|
||||
.block
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, rule)| rule_from_config(rule, index, AutoReviewAction::Block))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn evaluate(&self, ctx: &AutoReviewContext<'_>) -> AutoReviewDecision {
|
||||
if let Some(rule) = self.block_rules.iter().find(|rule| rule.matches(ctx)) {
|
||||
|
||||
@@ -501,12 +501,12 @@ pub struct EngineConfig {
|
||||
pub turn_wall_clock: Duration,
|
||||
/// Per-step cap on accumulated streamed content, in bytes (R1). Resolved
|
||||
/// from `[tui].stream_max_content_mb`. Pre-R1 this was the hard-coded
|
||||
/// `STREAM_MAX_CONTENT_BYTES`; it is still finite by default and now
|
||||
/// `DEFAULT_STREAM_MAX_CONTENT_BYTES`; it is still finite by default and now
|
||||
/// overridable.
|
||||
pub stream_max_content_bytes: usize,
|
||||
/// Per-step cap on a single stream's wall-clock duration (R1). Resolved
|
||||
/// from `[tui].stream_max_duration_secs`. Pre-R1 this was the hard-coded
|
||||
/// `STREAM_MAX_DURATION_SECS`.
|
||||
/// `DEFAULT_STREAM_MAX_DURATION_SECS`.
|
||||
pub stream_max_duration: Duration,
|
||||
/// Stream-level retry budgets (#6700): whole-request resumes (also spent
|
||||
/// by stream-open failures, #6699), in-stream transparent retries, and
|
||||
@@ -614,7 +614,7 @@ impl Default for EngineConfig {
|
||||
network_policy: None,
|
||||
snapshots_enabled: true,
|
||||
snapshots_max_workspace_bytes:
|
||||
crate::snapshot::DEFAULT_MAX_WORKSPACE_BYTES_FOR_SNAPSHOT,
|
||||
codewhale_config::DEFAULT_MAX_WORKSPACE_BYTES_FOR_SNAPSHOT,
|
||||
record_restore_points: false,
|
||||
lsp_config: None,
|
||||
runtime_services: RuntimeToolServices::default(),
|
||||
@@ -656,7 +656,7 @@ impl Default for EngineConfig {
|
||||
stream_max_content_bytes: turn_budget::DEFAULT_STREAM_MAX_CONTENT_BYTES,
|
||||
stream_max_duration: Duration::from_secs(turn_budget::DEFAULT_STREAM_MAX_DURATION_SECS),
|
||||
stream_retry_limits: turn_budget::StreamRetryLimits::default(),
|
||||
stream_open_timeout: crate::client::resolve_stream_open_timeout(None),
|
||||
stream_open_timeout: codewhale_config::resolve_stream_open_timeout(None),
|
||||
subagent_heartbeat_timeout: Duration::from_secs(
|
||||
crate::config::DEFAULT_SUBAGENT_HEARTBEAT_TIMEOUT_SECS,
|
||||
),
|
||||
@@ -7574,7 +7574,7 @@ impl Engine {
|
||||
let Some(path) = self.api_config.loaded_config_path.as_deref() else {
|
||||
return self.config.network_policy.clone();
|
||||
};
|
||||
let Some(document) = crate::config::network_policy_from_document(path) else {
|
||||
let Some(document) = crate::network_policy::network_policy_from_document(path) else {
|
||||
// The document carries no `[network]` table. Keep what the session
|
||||
// resolved: removing a table must not ungate a gated run.
|
||||
return self.config.network_policy.clone();
|
||||
@@ -7912,7 +7912,7 @@ impl Engine {
|
||||
)))
|
||||
.await;
|
||||
}
|
||||
pool = pool.with_backend(crate::mcp::McpBackend::from_config(&self.api_config));
|
||||
pool = pool.with_backend(self.api_config.mcp_backend());
|
||||
pool = pool.with_disallowed_tools(self.config.disallowed_tools.clone().unwrap_or_default());
|
||||
if let Some(decider) = self.current_network_decider() {
|
||||
pool = pool.with_network_policy(decider);
|
||||
@@ -10354,7 +10354,7 @@ pub(crate) use tool_execution::pin_replay_span_sequence;
|
||||
mod tool_media;
|
||||
mod tool_preparation;
|
||||
mod tool_setup;
|
||||
pub(crate) mod turn_budget;
|
||||
use codewhale_runtime::turn_budget;
|
||||
pub(crate) mod turn_heartbeat;
|
||||
pub(crate) mod turn_loop;
|
||||
pub(crate) use approval::HumanDecision;
|
||||
@@ -10391,10 +10391,6 @@ use self::streaming::{
|
||||
should_resume_interactive_after_network_drop, should_transparently_retry_stream,
|
||||
sleep_gap_detected, stream_read_error_user_message,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use self::streaming::{
|
||||
MAX_STREAM_ERRORS_BEFORE_FAIL, MAX_STREAM_RETRIES, MAX_TRANSPARENT_STREAM_RETRIES,
|
||||
};
|
||||
use self::tool_catalog::{
|
||||
CODE_EXECUTION_TOOL_NAME, EXECUTE_TOOLS_TOOL_NAME, JS_EXECUTION_TOOL_NAME,
|
||||
REQUEST_USER_INPUT_NAME, ToolSurfacePolicy, active_tools_for_request,
|
||||
@@ -10411,6 +10407,10 @@ use self::tool_catalog::{
|
||||
pub(crate) use self::tool_execution::emit_tool_audit;
|
||||
use self::tool_preparation::{prepare_tool_call, reprepare_tool_call_after_hook};
|
||||
use crate::tools::js_execution::execute_js_execution_tool;
|
||||
#[cfg(test)]
|
||||
use codewhale_runtime::turn_budget::{
|
||||
DEFAULT_STREAM_MAX_ERRORS, DEFAULT_STREAM_MAX_RESUMES, DEFAULT_STREAM_MAX_TRANSPARENT_RETRIES,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests;
|
||||
@@ -134,7 +134,7 @@ impl Engine {
|
||||
if let Err(error) = tokio::task::spawn_blocking(move || {
|
||||
#[cfg(test)]
|
||||
let _membership = crate::test_support::join_env_scope(env_ticket);
|
||||
crate::audit::log_sensitive_event(event, details);
|
||||
codewhale_runtime::audit::log_sensitive_event(event, details);
|
||||
})
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -203,7 +203,7 @@ impl FleetDenialGuard {
|
||||
hasher.update([0]);
|
||||
// Tool JSON preserves insertion order; reordered equivalent keys
|
||||
// must not manufacture a new read request.
|
||||
hasher.update(crate::client::canonical_json(&semantic_input).as_bytes());
|
||||
hasher.update(codewhale_runtime::hashing::canonical_json(&semantic_input).as_bytes());
|
||||
let key: [u8; 32] = hasher.finalize().into();
|
||||
let contents = original_content_digest
|
||||
.unwrap_or_else(|| Sha256::digest(output.content.as_bytes()).into());
|
||||
|
||||
@@ -776,7 +776,9 @@ pub(crate) fn active_tool_catalog_sha256(tools: &[Tool]) -> String {
|
||||
canonical.push('\u{1}');
|
||||
canonical.push_str(&tool.description);
|
||||
canonical.push('\u{1}');
|
||||
canonical.push_str(&crate::client::canonical_json(&tool.input_schema));
|
||||
canonical.push_str(&codewhale_runtime::hashing::canonical_json(
|
||||
&tool.input_schema,
|
||||
));
|
||||
canonical.push('\n');
|
||||
}
|
||||
codewhale_runtime::hashing::sha256_hex(canonical.as_bytes())
|
||||
|
||||
@@ -1004,7 +1004,7 @@ async fn assert_preview_matches_first_wire_body(
|
||||
let first_wire_body: serde_json::Value =
|
||||
serde_json::from_slice(&requests[0].body).expect("first HTTP body is JSON");
|
||||
let first_wire_hash = codewhale_runtime::hashing::sha256_hex(
|
||||
crate::client::canonical_json(&first_wire_body).as_bytes(),
|
||||
codewhale_runtime::hashing::canonical_json(&first_wire_body).as_bytes(),
|
||||
);
|
||||
assert_eq!(
|
||||
preview_hash, first_wire_hash,
|
||||
@@ -1600,7 +1600,7 @@ async fn assert_matrix_route(route: &MatrixRoute) {
|
||||
|
||||
// --- Body identity, byte accounting, and size estimates ----------
|
||||
let facts_body = manifest.body.exact().expect("a prompted body is exact");
|
||||
let canonical = crate::client::canonical_json(&body);
|
||||
let canonical = codewhale_runtime::hashing::canonical_json(&body);
|
||||
assert_eq!(
|
||||
facts_body.body_sha256,
|
||||
codewhale_runtime::hashing::sha256_hex(canonical.as_bytes()),
|
||||
@@ -1647,7 +1647,9 @@ async fn assert_matrix_route(route: &MatrixRoute) {
|
||||
assert_eq!(
|
||||
facts_body.tool_schema_wire_sha256,
|
||||
body.get("tools").map(|tools| {
|
||||
codewhale_runtime::hashing::sha256_hex(crate::client::canonical_json(tools).as_bytes())
|
||||
codewhale_runtime::hashing::sha256_hex(
|
||||
codewhale_runtime::hashing::canonical_json(tools).as_bytes(),
|
||||
)
|
||||
}),
|
||||
"{name}: the tool-schema digest must be over the schemas on the wire"
|
||||
);
|
||||
|
||||
@@ -211,8 +211,6 @@ impl ToolUseState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Maximum total bytes of text, reasoning and tool-argument content before aborting the stream.
|
||||
pub(super) const STREAM_MAX_CONTENT_BYTES: usize = 10 * 1024 * 1024; // 10 MB
|
||||
/// A response can contain many empty tool starts without spending the byte
|
||||
/// budget. Bound that batch before any call is retained or admitted. A lower
|
||||
/// configured per-turn tool budget remains authoritative at execution.
|
||||
@@ -230,31 +228,6 @@ pub(super) fn tool_call_limit_error() -> crate::error_taxonomy::ErrorEnvelope {
|
||||
)
|
||||
}
|
||||
|
||||
/// Sanity backstop for total stream wall-clock duration. **Not** a routine
|
||||
/// kill switch — the stream chunk idle timeout is the primary stall
|
||||
/// detector. The wall-clock cap is here only to bound pathological cases
|
||||
/// (e.g. a server that keeps sending heartbeats forever without progress).
|
||||
///
|
||||
/// History: this used to be 300s (5 min) which was too aggressive — V4
|
||||
/// thinking turns on hard prompts legitimately exceed 5 minutes wall-clock
|
||||
/// while still emitting reasoning_content chunks the whole way. Bumped to
|
||||
/// 30 min in v0.6.6 after long-reasoning turns hit the old cap. Codex defaults to a
|
||||
/// per-chunk idle of 300s with no wall-clock cap; we keep both layers but
|
||||
/// give the wall-clock a generous window so it never fires in practice.
|
||||
pub(super) const STREAM_MAX_DURATION_SECS: u64 = 1800; // 30 minutes (was 300s; #103/#1)
|
||||
/// Hard cap on consecutive recoverable stream errors before we surface a turn
|
||||
/// failure. Bumped 3 → 5 in v0.6.7 along with the HTTP/2 keepalive defaults
|
||||
/// (#103) — keepalive should make spurious decode errors rarer, so we can
|
||||
/// tolerate a longer streak before giving up on the turn. This is the
|
||||
/// default; `[tui].stream_max_errors` overrides it (#6700).
|
||||
pub(super) const MAX_STREAM_ERRORS_BEFORE_FAIL: u32 = 5;
|
||||
/// Cap on transparent stream-level retries — these only happen when the wire
|
||||
/// dies before any content was streamed. The user has seen nothing, but
|
||||
/// provider usage or billing may already exist. Two attempts can ride out a
|
||||
/// flaky edge node without amplifying real outages (#103). This is the
|
||||
/// default; `[tui].stream_max_transparent_retries` overrides it (#6700).
|
||||
pub(super) const MAX_TRANSPARENT_STREAM_RETRIES: u32 = 2;
|
||||
|
||||
/// Decide whether a stream error is eligible for a transparent retry.
|
||||
///
|
||||
/// True only when ALL three conditions hold:
|
||||
@@ -263,7 +236,7 @@ pub(super) const MAX_TRANSPARENT_STREAM_RETRIES: u32 = 2;
|
||||
/// check is not evidence that the provider consumed or billed zero tokens.
|
||||
/// 2. We still have transparent-retry budget remaining (`max_attempts`,
|
||||
/// `[tui].stream_max_transparent_retries`, default
|
||||
/// [`MAX_TRANSPARENT_STREAM_RETRIES`]).
|
||||
/// [`codewhale_runtime::turn_budget::DEFAULT_STREAM_MAX_TRANSPARENT_RETRIES`]).
|
||||
/// 3. The turn has not been cancelled.
|
||||
///
|
||||
/// Extracted as a pure function so the four #103 retry cases can be exercised
|
||||
@@ -277,12 +250,6 @@ pub(super) fn should_transparently_retry_stream(
|
||||
!any_content_received && transparent_attempts < max_attempts && !cancelled
|
||||
}
|
||||
|
||||
/// Default budget for re-issuing the whole request after a dead stream.
|
||||
/// Shared by the nothing-streamed outer retry (#103 Phase 3), the
|
||||
/// sleep-resume retry (#2990), the network-drop resumes, and stream-open
|
||||
/// failures (#6699). Overridable via `[tui].stream_max_resumes` (#6700).
|
||||
pub(super) const MAX_STREAM_RETRIES: u32 = 3;
|
||||
|
||||
/// Typed, engine-internal state for one mid-stream drop recovery.
|
||||
///
|
||||
/// This enum **is** the retry mechanism. A resumed turn used to append a
|
||||
@@ -321,7 +288,7 @@ pub(super) enum StreamResume {
|
||||
///
|
||||
/// Mechanism, not comment: [`StreamRetryBudget::authorize`] is the only way
|
||||
/// to spend a resume and it returns `None` once `limit` resumes (default
|
||||
/// [`MAX_STREAM_RETRIES`]) have been issued, so no call site can loop past
|
||||
/// [`DEFAULT_STREAM_MAX_RESUMES`]) have been issued, so no call site can loop past
|
||||
/// the budget even if a guard predicate is relaxed. A healthy stream round
|
||||
/// resets it.
|
||||
#[derive(Debug)]
|
||||
@@ -332,7 +299,7 @@ pub(super) struct StreamRetryBudget {
|
||||
|
||||
impl Default for StreamRetryBudget {
|
||||
fn default() -> Self {
|
||||
Self::with_limit(MAX_STREAM_RETRIES)
|
||||
Self::with_limit(codewhale_runtime::turn_budget::DEFAULT_STREAM_MAX_RESUMES)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -533,7 +500,7 @@ pub(super) fn should_resume_after_network_drop(
|
||||
/// thinking-only fragment preserves nothing and must not be described as a
|
||||
/// preserved reply. Tool calls are never resumed because an incomplete tool
|
||||
/// call could be re-issued and duplicate side effects. Bounded by
|
||||
/// `MAX_STREAM_RETRIES` and gated on a network/timeout-class error so
|
||||
/// `DEFAULT_STREAM_MAX_RESUMES` and gated on a network/timeout-class error so
|
||||
/// model/parse/auth failures still surface normally.
|
||||
pub(super) fn should_resume_interactive_after_network_drop(
|
||||
terminal_chrome_enabled: bool,
|
||||
|
||||
@@ -275,7 +275,9 @@ async fn verify_next_user_turn_after_loss(failure: Failure) {
|
||||
);
|
||||
}
|
||||
let partial_count = match failure {
|
||||
Failure::TruncatedBody => usize::try_from(super::super::MAX_STREAM_RETRIES).unwrap() + 1,
|
||||
Failure::TruncatedBody => {
|
||||
usize::try_from(super::super::DEFAULT_STREAM_MAX_RESUMES).unwrap() + 1
|
||||
}
|
||||
Failure::StalledBody => 1,
|
||||
};
|
||||
assert_eq!(
|
||||
@@ -290,7 +292,7 @@ async fn verify_next_user_turn_after_loss(failure: Failure) {
|
||||
"terminal parent-request count must match POSTs observed by the loopback"
|
||||
);
|
||||
let expected_resumes = match failure {
|
||||
Failure::TruncatedBody => super::super::MAX_STREAM_RETRIES,
|
||||
Failure::TruncatedBody => super::super::DEFAULT_STREAM_MAX_RESUMES,
|
||||
Failure::StalledBody => 0,
|
||||
};
|
||||
assert_eq!(first_terminal.stream_resumes, expected_resumes);
|
||||
|
||||
@@ -4,7 +4,7 @@ const REPRESENTATIVE_HANDOFF_RELAY: &str = "REPRESENTATIVE_HANDOFF_RELAY";
|
||||
fn ordinary_engine_default_does_not_install_a_step_budget() {
|
||||
assert_eq!(
|
||||
DEFAULT_MODEL_STEPS,
|
||||
crate::core::engine::turn_budget::DEFAULT_MAX_MODEL_STEPS
|
||||
codewhale_runtime::turn_budget::DEFAULT_MAX_MODEL_STEPS
|
||||
);
|
||||
assert_eq!(EngineConfig::default().max_steps, DEFAULT_MODEL_STEPS);
|
||||
let mut turn = TurnContext::new(EngineConfig::default().max_steps);
|
||||
|
||||
@@ -19,7 +19,8 @@ async fn repl_fence_obeys_auto_review_block_under_full_access() {
|
||||
let client: crate::core::model_client::SharedModelClient = mock.clone();
|
||||
let mut config = deterministic_engine_config(workspace.path());
|
||||
// Built through the config entry point, as production does.
|
||||
config.auto_review_policy = Config {
|
||||
config.auto_review_policy =
|
||||
crate::core::authority::auto_review::AutoReviewPolicy::from_config(&Config {
|
||||
auto_review: Some(crate::config::AutoReviewConfig {
|
||||
block: vec![crate::config::AutoReviewRuleConfig {
|
||||
id: Some("no-code".to_string()),
|
||||
@@ -30,8 +31,7 @@ async fn repl_fence_obeys_auto_review_block_under_full_access() {
|
||||
..Default::default()
|
||||
}),
|
||||
..Config::default()
|
||||
}
|
||||
.auto_review_policy();
|
||||
});
|
||||
let (mut engine, handle) = Engine::new_with_model_client(config, &Config::default(), client);
|
||||
engine.session.auto_approve = true;
|
||||
engine.session.add_message(Message {
|
||||
|
||||
@@ -1556,7 +1556,7 @@ fn stream_retry_scenario() {
|
||||
super::should_transparently_retry_stream(
|
||||
false,
|
||||
0,
|
||||
super::MAX_TRANSPARENT_STREAM_RETRIES,
|
||||
super::DEFAULT_STREAM_MAX_TRANSPARENT_RETRIES,
|
||||
false
|
||||
),
|
||||
"first attempt with no content must be eligible for transparent retry"
|
||||
@@ -1565,7 +1565,7 @@ fn stream_retry_scenario() {
|
||||
super::should_transparently_retry_stream(
|
||||
false,
|
||||
1,
|
||||
super::MAX_TRANSPARENT_STREAM_RETRIES,
|
||||
super::DEFAULT_STREAM_MAX_TRANSPARENT_RETRIES,
|
||||
false
|
||||
),
|
||||
"second attempt (one prior retry) with no content must still be eligible"
|
||||
@@ -1581,7 +1581,7 @@ fn stream_retry_scenario() {
|
||||
!super::should_transparently_retry_stream(
|
||||
true,
|
||||
0,
|
||||
super::MAX_TRANSPARENT_STREAM_RETRIES,
|
||||
super::DEFAULT_STREAM_MAX_TRANSPARENT_RETRIES,
|
||||
false
|
||||
),
|
||||
"any content received → no transparent retry, even with full budget"
|
||||
@@ -1590,7 +1590,7 @@ fn stream_retry_scenario() {
|
||||
!super::should_transparently_retry_stream(
|
||||
true,
|
||||
1,
|
||||
super::MAX_TRANSPARENT_STREAM_RETRIES,
|
||||
super::DEFAULT_STREAM_MAX_TRANSPARENT_RETRIES,
|
||||
false
|
||||
),
|
||||
"any content received → no transparent retry on subsequent attempts"
|
||||
@@ -1604,7 +1604,7 @@ fn stream_retry_scenario() {
|
||||
!super::should_transparently_retry_stream(
|
||||
false,
|
||||
0,
|
||||
super::MAX_TRANSPARENT_STREAM_RETRIES,
|
||||
super::DEFAULT_STREAM_MAX_TRANSPARENT_RETRIES,
|
||||
true
|
||||
),
|
||||
"cancelled turn must not be transparently retried"
|
||||
@@ -1613,7 +1613,7 @@ fn stream_retry_scenario() {
|
||||
!super::should_transparently_retry_stream(
|
||||
false,
|
||||
1,
|
||||
super::MAX_TRANSPARENT_STREAM_RETRIES,
|
||||
super::DEFAULT_STREAM_MAX_TRANSPARENT_RETRIES,
|
||||
true
|
||||
),
|
||||
"cancelled turn must not be transparently retried even with budget"
|
||||
@@ -1623,7 +1623,7 @@ fn stream_retry_scenario() {
|
||||
{
|
||||
// "At most one bounded retry per drop" is enforced by types, not by a
|
||||
// comment: `authorize()` is the only way to spend a resume and it refuses
|
||||
// once MAX_STREAM_RETRIES resumes have been issued, whatever the guard
|
||||
// once DEFAULT_STREAM_MAX_RESUMES resumes have been issued, whatever the guard
|
||||
// predicates say. A healthy round resets the chain.
|
||||
let mut budget = super::StreamRetryBudget::default();
|
||||
assert_eq!(budget.spent(), 0);
|
||||
@@ -1633,10 +1633,10 @@ fn stream_retry_scenario() {
|
||||
assert_eq!(
|
||||
budget.authorize(),
|
||||
None,
|
||||
"authorize() must refuse past MAX_STREAM_RETRIES"
|
||||
"authorize() must refuse past DEFAULT_STREAM_MAX_RESUMES"
|
||||
);
|
||||
assert_eq!(budget.authorize(), None, "and keep refusing");
|
||||
assert_eq!(budget.spent(), super::MAX_STREAM_RETRIES);
|
||||
assert_eq!(budget.spent(), super::DEFAULT_STREAM_MAX_RESUMES);
|
||||
budget.reset();
|
||||
assert_eq!(budget.spent(), 0);
|
||||
assert_eq!(budget.authorize(), Some(1));
|
||||
@@ -1648,14 +1648,14 @@ fn stream_retry_scenario() {
|
||||
// HTTP/2 keepalive defaults make spurious decode errors rarer.
|
||||
// This test pins the constant so a future regression to 3 fails loudly.
|
||||
assert_eq!(
|
||||
super::MAX_STREAM_ERRORS_BEFORE_FAIL,
|
||||
super::DEFAULT_STREAM_MAX_ERRORS,
|
||||
5,
|
||||
"the consecutive-stream-error threshold should be 5; \
|
||||
lowering it back to 3 will fail mid-turn under transient flakiness"
|
||||
);
|
||||
// And a regression guard on the transparent-retry cap.
|
||||
assert_eq!(
|
||||
super::MAX_TRANSPARENT_STREAM_RETRIES,
|
||||
super::DEFAULT_STREAM_MAX_TRANSPARENT_RETRIES,
|
||||
2,
|
||||
"transparent-retry cap should be 2; raising it risks hammering the \
|
||||
provider on real outages"
|
||||
|
||||
@@ -33,15 +33,15 @@ fn stream_read_scenario() {
|
||||
|
||||
#[test]
|
||||
fn stream_retry_budget_caps_transparent_retries_at_two() {
|
||||
// Case 4 from issue #103: after MAX_TRANSPARENT_STREAM_RETRIES attempts
|
||||
// Case 4 from issue #103: after DEFAULT_STREAM_MAX_TRANSPARENT_RETRIES attempts
|
||||
// we stop trying transparently and let the outer error path surface.
|
||||
// (The outer per-turn `stream_retry_attempts` retry is a separate layer
|
||||
// and is still in effect at the whole-turn level.)
|
||||
assert!(
|
||||
super::should_transparently_retry_stream(
|
||||
false,
|
||||
super::MAX_TRANSPARENT_STREAM_RETRIES - 1,
|
||||
super::MAX_TRANSPARENT_STREAM_RETRIES,
|
||||
super::DEFAULT_STREAM_MAX_TRANSPARENT_RETRIES - 1,
|
||||
super::DEFAULT_STREAM_MAX_TRANSPARENT_RETRIES,
|
||||
false,
|
||||
),
|
||||
"one short of the cap should still retry"
|
||||
@@ -49,8 +49,8 @@ fn stream_retry_budget_caps_transparent_retries_at_two() {
|
||||
assert!(
|
||||
!super::should_transparently_retry_stream(
|
||||
false,
|
||||
super::MAX_TRANSPARENT_STREAM_RETRIES,
|
||||
super::MAX_TRANSPARENT_STREAM_RETRIES,
|
||||
super::DEFAULT_STREAM_MAX_TRANSPARENT_RETRIES,
|
||||
super::DEFAULT_STREAM_MAX_TRANSPARENT_RETRIES,
|
||||
false,
|
||||
),
|
||||
"at the cap, no further transparent retries"
|
||||
@@ -58,8 +58,8 @@ fn stream_retry_budget_caps_transparent_retries_at_two() {
|
||||
assert!(
|
||||
!super::should_transparently_retry_stream(
|
||||
false,
|
||||
super::MAX_TRANSPARENT_STREAM_RETRIES + 5,
|
||||
super::MAX_TRANSPARENT_STREAM_RETRIES,
|
||||
super::DEFAULT_STREAM_MAX_TRANSPARENT_RETRIES + 5,
|
||||
super::DEFAULT_STREAM_MAX_TRANSPARENT_RETRIES,
|
||||
false,
|
||||
),
|
||||
"well past the cap, definitely no transparent retries"
|
||||
@@ -102,14 +102,14 @@ fn sleep_resume_scenario() {
|
||||
// detected sleep gap retries regardless of streamed content — the
|
||||
// partial output predates the sleep and the user was not watching.
|
||||
assert!(
|
||||
super::should_resume_after_sleep(true, 0, super::MAX_STREAM_RETRIES, false),
|
||||
super::should_resume_after_sleep(true, 0, super::DEFAULT_STREAM_MAX_RESUMES, false),
|
||||
"detected sleep with full budget must resume"
|
||||
);
|
||||
assert!(
|
||||
super::should_resume_after_sleep(
|
||||
true,
|
||||
super::MAX_STREAM_RETRIES - 1,
|
||||
super::MAX_STREAM_RETRIES,
|
||||
super::DEFAULT_STREAM_MAX_RESUMES - 1,
|
||||
super::DEFAULT_STREAM_MAX_RESUMES,
|
||||
false
|
||||
),
|
||||
"detected sleep one short of the budget must still resume"
|
||||
@@ -121,7 +121,7 @@ fn sleep_resume_scenario() {
|
||||
// deliberate no-retry-after-content policy for ordinary flakes (#103)
|
||||
// is preserved.
|
||||
assert!(
|
||||
!super::should_resume_after_sleep(false, 0, super::MAX_STREAM_RETRIES, false),
|
||||
!super::should_resume_after_sleep(false, 0, super::DEFAULT_STREAM_MAX_RESUMES, false),
|
||||
"no sleep gap → never resume via this layer"
|
||||
);
|
||||
}
|
||||
@@ -130,14 +130,14 @@ fn sleep_resume_scenario() {
|
||||
assert!(
|
||||
!super::should_resume_after_sleep(
|
||||
true,
|
||||
super::MAX_STREAM_RETRIES,
|
||||
super::MAX_STREAM_RETRIES,
|
||||
super::DEFAULT_STREAM_MAX_RESUMES,
|
||||
super::DEFAULT_STREAM_MAX_RESUMES,
|
||||
false
|
||||
),
|
||||
"budget exhausted → surface the failure instead of looping"
|
||||
);
|
||||
assert!(
|
||||
!super::should_resume_after_sleep(true, 0, super::MAX_STREAM_RETRIES, true),
|
||||
!super::should_resume_after_sleep(true, 0, super::DEFAULT_STREAM_MAX_RESUMES, true),
|
||||
"cancelled turn must not be resumed behind the user's back"
|
||||
);
|
||||
}
|
||||
@@ -150,7 +150,7 @@ fn sleep_resume_scenario() {
|
||||
// content): the #103 policy surfaced the warning and failed the turn, and
|
||||
// `codewhale exec` exited 1. In a headless host no operator watches the
|
||||
// partial deltas and the fragment is never committed, so the turn loop now
|
||||
// re-issues the request instead (bounded by MAX_STREAM_RETRIES), exactly
|
||||
// re-issues the request instead (bounded by DEFAULT_STREAM_MAX_RESUMES), exactly
|
||||
// like the #2990 sleep-resume.
|
||||
|
||||
#[test]
|
||||
@@ -163,7 +163,7 @@ fn network_drop_scenario() {
|
||||
true,
|
||||
true,
|
||||
0,
|
||||
super::MAX_STREAM_RETRIES,
|
||||
super::DEFAULT_STREAM_MAX_RESUMES,
|
||||
false
|
||||
),
|
||||
"headless host + network-class drop with budget must resume"
|
||||
@@ -173,7 +173,7 @@ fn network_drop_scenario() {
|
||||
false,
|
||||
true,
|
||||
0,
|
||||
super::MAX_STREAM_RETRIES,
|
||||
super::DEFAULT_STREAM_MAX_RESUMES,
|
||||
false
|
||||
),
|
||||
"interactive sessions keep the #103 surface-the-warning policy: \
|
||||
@@ -187,7 +187,7 @@ fn network_drop_scenario() {
|
||||
true,
|
||||
false,
|
||||
0,
|
||||
super::MAX_STREAM_RETRIES,
|
||||
super::DEFAULT_STREAM_MAX_RESUMES,
|
||||
false
|
||||
),
|
||||
"non-network failures (model/parse/auth) must never be replayed"
|
||||
@@ -199,8 +199,8 @@ fn network_drop_scenario() {
|
||||
super::should_resume_after_network_drop(
|
||||
true,
|
||||
true,
|
||||
super::MAX_STREAM_RETRIES - 1,
|
||||
super::MAX_STREAM_RETRIES,
|
||||
super::DEFAULT_STREAM_MAX_RESUMES - 1,
|
||||
super::DEFAULT_STREAM_MAX_RESUMES,
|
||||
false,
|
||||
),
|
||||
"one short of the budget should still resume"
|
||||
@@ -209,8 +209,8 @@ fn network_drop_scenario() {
|
||||
!super::should_resume_after_network_drop(
|
||||
true,
|
||||
true,
|
||||
super::MAX_STREAM_RETRIES,
|
||||
super::MAX_STREAM_RETRIES,
|
||||
super::DEFAULT_STREAM_MAX_RESUMES,
|
||||
super::DEFAULT_STREAM_MAX_RESUMES,
|
||||
false
|
||||
),
|
||||
"budget exhausted → surface the failure instead of looping"
|
||||
@@ -220,7 +220,7 @@ fn network_drop_scenario() {
|
||||
true,
|
||||
true,
|
||||
0,
|
||||
super::MAX_STREAM_RETRIES,
|
||||
super::DEFAULT_STREAM_MAX_RESUMES,
|
||||
true
|
||||
),
|
||||
"cancelled turn must not be resumed behind the operator's back"
|
||||
@@ -250,7 +250,7 @@ fn interactive_network_scenario() {
|
||||
true,
|
||||
true,
|
||||
0,
|
||||
super::MAX_STREAM_RETRIES,
|
||||
super::DEFAULT_STREAM_MAX_RESUMES,
|
||||
false
|
||||
),
|
||||
"interactive TUI + partial text + no tools + budget must resume"
|
||||
@@ -262,7 +262,7 @@ fn interactive_network_scenario() {
|
||||
true,
|
||||
true,
|
||||
0,
|
||||
super::MAX_STREAM_RETRIES,
|
||||
super::DEFAULT_STREAM_MAX_RESUMES,
|
||||
false
|
||||
),
|
||||
"headless hosts must use the headless resume path, not this one"
|
||||
@@ -277,7 +277,7 @@ fn interactive_network_scenario() {
|
||||
false,
|
||||
true,
|
||||
0,
|
||||
super::MAX_STREAM_RETRIES,
|
||||
super::DEFAULT_STREAM_MAX_RESUMES,
|
||||
false
|
||||
),
|
||||
"no streamed content → transparent retry or nothing-streamed path"
|
||||
@@ -289,7 +289,7 @@ fn interactive_network_scenario() {
|
||||
true,
|
||||
false,
|
||||
0,
|
||||
super::MAX_STREAM_RETRIES,
|
||||
super::DEFAULT_STREAM_MAX_RESUMES,
|
||||
false
|
||||
),
|
||||
"in-flight tool calls must never be resumed (side-effect duplication)"
|
||||
@@ -304,7 +304,7 @@ fn interactive_network_scenario() {
|
||||
true,
|
||||
true,
|
||||
0,
|
||||
super::MAX_STREAM_RETRIES,
|
||||
super::DEFAULT_STREAM_MAX_RESUMES,
|
||||
false
|
||||
),
|
||||
"non-network failures must surface normally"
|
||||
@@ -320,8 +320,8 @@ fn interactive_network_drop_resume_respects_budget_and_cancellation() {
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
super::MAX_STREAM_RETRIES - 1,
|
||||
super::MAX_STREAM_RETRIES,
|
||||
super::DEFAULT_STREAM_MAX_RESUMES - 1,
|
||||
super::DEFAULT_STREAM_MAX_RESUMES,
|
||||
false,
|
||||
),
|
||||
"one short of the budget should still resume"
|
||||
@@ -332,8 +332,8 @@ fn interactive_network_drop_resume_respects_budget_and_cancellation() {
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
super::MAX_STREAM_RETRIES,
|
||||
super::MAX_STREAM_RETRIES,
|
||||
super::DEFAULT_STREAM_MAX_RESUMES,
|
||||
super::DEFAULT_STREAM_MAX_RESUMES,
|
||||
false,
|
||||
),
|
||||
"budget exhausted → surface the failure"
|
||||
@@ -345,7 +345,7 @@ fn interactive_network_drop_resume_respects_budget_and_cancellation() {
|
||||
true,
|
||||
true,
|
||||
0,
|
||||
super::MAX_STREAM_RETRIES,
|
||||
super::DEFAULT_STREAM_MAX_RESUMES,
|
||||
true
|
||||
),
|
||||
"cancelled turn must not resume"
|
||||
@@ -787,10 +787,10 @@ async fn stream_open_failure_fails_the_turn_once_the_budget_is_spent() {
|
||||
assert_eq!(status, TurnOutcomeStatus::Failed);
|
||||
assert_eq!(
|
||||
model.calls.load(std::sync::atomic::Ordering::SeqCst),
|
||||
1 + super::MAX_STREAM_RETRIES as usize,
|
||||
1 + super::DEFAULT_STREAM_MAX_RESUMES as usize,
|
||||
"initial attempt plus the default resume budget, then the turn fails"
|
||||
);
|
||||
assert_eq!(terminal.stream_resumes, super::MAX_STREAM_RETRIES);
|
||||
assert_eq!(terminal.stream_resumes, super::DEFAULT_STREAM_MAX_RESUMES);
|
||||
assert!(
|
||||
error
|
||||
.as_deref()
|
||||
|
||||
@@ -534,11 +534,11 @@ async fn reasoning_only_forever_is_bounded_then_fails() {
|
||||
#[tokio::test]
|
||||
async fn headless_turn_fails_with_real_error_after_network_drop_budget_exhausted() {
|
||||
let (model, events) =
|
||||
run_headless_turn_with_flaky_network(1 + super::MAX_STREAM_RETRIES as usize).await;
|
||||
run_headless_turn_with_flaky_network(1 + super::DEFAULT_STREAM_MAX_RESUMES as usize).await;
|
||||
|
||||
assert_eq!(
|
||||
model.calls.load(std::sync::atomic::Ordering::SeqCst),
|
||||
1 + super::MAX_STREAM_RETRIES as usize,
|
||||
1 + super::DEFAULT_STREAM_MAX_RESUMES as usize,
|
||||
"initial attempt plus the bounded resume budget, then the turn fails"
|
||||
);
|
||||
let (status, error) = events
|
||||
@@ -580,10 +580,10 @@ async fn headless_turn_fails_with_real_error_after_network_drop_budget_exhausted
|
||||
Event::Status { message } if message.starts_with("Retry attempt: stream-resume ")
|
||||
))
|
||||
.count(),
|
||||
super::MAX_STREAM_RETRIES as usize,
|
||||
super::DEFAULT_STREAM_MAX_RESUMES as usize,
|
||||
"every admitted resume must have its own numbered progress receipt"
|
||||
);
|
||||
for attempt in 1..=super::MAX_STREAM_RETRIES {
|
||||
for attempt in 1..=super::DEFAULT_STREAM_MAX_RESUMES {
|
||||
assert!(events.iter().any(|event| matches!(event,
|
||||
Event::Status { message } if message.starts_with(&format!("Retry attempt: stream-resume {attempt}/"))
|
||||
)));
|
||||
|
||||
@@ -1498,7 +1498,7 @@ impl Engine {
|
||||
// R1: restart the cumulative per-turn wall-clock budget. This is the
|
||||
// only place it is started, so exactly one turn owns it at a time.
|
||||
self.turn_wall_clock =
|
||||
crate::core::engine::turn_budget::TurnWallClock::start(self.config.turn_wall_clock);
|
||||
codewhale_runtime::turn_budget::TurnWallClock::start(self.config.turn_wall_clock);
|
||||
self.turn_heartbeat.begin_turn(&turn.id);
|
||||
|
||||
// Only interactive TUI hosts own terminal chrome. Headless exec,
|
||||
@@ -1642,7 +1642,7 @@ impl Engine {
|
||||
// Phase 3), the host slept mid-turn (#2990), or a host hit a
|
||||
// mid-stream network drop (v0.9.4 Terminal-Bench P0), we re-issue
|
||||
// the request up to `[tui].stream_max_resumes` times (default
|
||||
// MAX_STREAM_RETRIES) before surfacing the failure to the user. A
|
||||
// DEFAULT_STREAM_MAX_RESUMES) before surfacing the failure to the user. A
|
||||
// stream that never opened (#6699) spends the same budget.
|
||||
// `StreamRetryBudget` enforces that bound in mechanism —
|
||||
// `authorize()` is the only way to spend a resume.
|
||||
@@ -4957,7 +4957,7 @@ impl Engine {
|
||||
// to the conversation, and no tool from the incomplete
|
||||
// response has executed, so break out and let the
|
||||
// post-loop block re-issue the request (bounded by
|
||||
// MAX_STREAM_RETRIES), exactly like the #2990
|
||||
// DEFAULT_STREAM_MAX_RESUMES), exactly like the #2990
|
||||
// sleep-resume. Do NOT emit an error event here: the
|
||||
// exec host forwards every error event onto the
|
||||
// stream-json error channel, and a successful retry
|
||||
@@ -4994,7 +4994,7 @@ impl Engine {
|
||||
// Interactive TUI: a network/timeout-class stream drop
|
||||
// after partial text (but before any tool call) should
|
||||
// preserve the visible fragment and re-issue the
|
||||
// request, bounded by MAX_STREAM_RETRIES. This keeps the
|
||||
// request, bounded by DEFAULT_STREAM_MAX_RESUMES. This keeps the
|
||||
// turn alive instead of failing with a terminal-looking
|
||||
// error. The resume is typed state — no synthetic user
|
||||
// continuation message is appended.
|
||||
@@ -6208,7 +6208,7 @@ mod stream_timeout_tests {
|
||||
fn awaiting_model_bound_covers_the_http1_fallback() {
|
||||
for (open, idle) in [
|
||||
(
|
||||
crate::client::resolve_stream_open_timeout(None),
|
||||
codewhale_config::resolve_stream_open_timeout(None),
|
||||
Duration::from_secs(crate::config::DEFAULT_STREAM_CHUNK_TIMEOUT_SECS),
|
||||
),
|
||||
(Duration::from_secs(300), Duration::from_secs(60)),
|
||||
|
||||
@@ -599,7 +599,7 @@ impl Engine {
|
||||
// when reqwest yields chunk decode errors three times in a row;
|
||||
// most of the time those are recoverable proxy / HTTP/2 issues
|
||||
// and the request can simply be re-issued. Re-issue silently up
|
||||
// to MAX_STREAM_RETRIES, but only when the stream produced
|
||||
// to DEFAULT_STREAM_MAX_RESUMES, but only when the stream produced
|
||||
// nothing actionable — if any tool call landed or text was
|
||||
// streamed, ship the partial state to the rest of the turn
|
||||
// pipeline so we don't double-bill the user by re-running it.
|
||||
|
||||
@@ -352,7 +352,7 @@ pub(crate) async fn doctor_search_probe(
|
||||
if let Some(policy) = config.network.clone().map(|network| {
|
||||
// Doctor is a read-only diagnostic. Evaluate the same typed policy as
|
||||
// web_search, but do not attach the runtime audit writer.
|
||||
NetworkPolicyDecider::new(network.into_runtime(), None)
|
||||
NetworkPolicyDecider::new(crate::network_policy::NetworkPolicy::from(network), None)
|
||||
}) {
|
||||
match policy.evaluate(&target.host, "web_search") {
|
||||
Decision::Allow => {}
|
||||
|
||||
@@ -245,7 +245,7 @@ async fn search_probe_respects_network_policy_without_contacting_the_authority()
|
||||
api_key: None,
|
||||
native: None,
|
||||
}),
|
||||
network: Some(crate::config::NetworkPolicyToml {
|
||||
network: Some(codewhale_config::NetworkPolicyToml {
|
||||
default: "allow".to_string(),
|
||||
deny: vec!["search.example".to_string()],
|
||||
..Default::default()
|
||||
|
||||
@@ -15,7 +15,7 @@ use crate::core::ops::TurnSpec;
|
||||
/// Omission leaves model steps uncapped. Clap rejects `--max-turns 0`;
|
||||
/// explicit positive values retain the documented finite range.
|
||||
pub(crate) fn exec_max_steps(max_turns: Option<u32>) -> u32 {
|
||||
crate::core::engine::turn_budget::resolve_max_model_steps(max_turns)
|
||||
codewhale_runtime::turn_budget::resolve_max_model_steps(max_turns)
|
||||
}
|
||||
|
||||
/// Model-step ceiling for a plain (zero-tool) `exec` run without
|
||||
@@ -500,7 +500,7 @@ pub(crate) async fn run_exec_agent(
|
||||
execution_config
|
||||
.lsp
|
||||
.clone()
|
||||
.map(crate::config::LspConfigToml::into_runtime)
|
||||
.map(crate::lsp::LspConfig::from)
|
||||
})
|
||||
.flatten();
|
||||
let mut engine_features = execution_config.features();
|
||||
@@ -610,7 +610,9 @@ pub(crate) async fn run_exec_agent(
|
||||
subagents_enabled: !fleet_authority_active
|
||||
&& execution_config.subagents_enabled_for_provider(&effective_identity),
|
||||
features: engine_features,
|
||||
auto_review_policy: execution_config.auto_review_policy(),
|
||||
auto_review_policy: crate::core::authority::auto_review::AutoReviewPolicy::from_config(
|
||||
&execution_config,
|
||||
),
|
||||
compaction: compaction.clone(),
|
||||
todos: new_shared_todo_list(),
|
||||
plan_state: new_shared_plan_state(),
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
use super::*;
|
||||
use crate::extension_host::TestManagerGuard;
|
||||
use crate::extension_host::tests::{FixturePlugins, node_for_tests};
|
||||
use crate::mcp::{McpBackend, McpConfig, McpPool};
|
||||
use crate::mcp::{McpConfig, McpPool};
|
||||
use crate::plugins::activation::TestPolicyGuard;
|
||||
use codewhale_config::McpBackend;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
use super::*;
|
||||
use crate::extension_host::tests::{FixturePlugins, node_for_tests};
|
||||
use crate::extension_host::{ExtensionHostOptions, TestManagerGuard};
|
||||
use crate::mcp::{McpBackend, McpConnection, McpTimeouts, McpTransport};
|
||||
use crate::mcp::{McpConnection, McpTimeouts, McpTransport};
|
||||
use crate::plugins::activation::TestPolicyGuard;
|
||||
use codewhale_config::McpBackend;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
const PEER: &str = r#"
|
||||
|
||||
@@ -6,8 +6,9 @@ use crate::extension_host::protocol::RegisterSpecWire;
|
||||
use crate::extension_host::registry::OwnerRegistry;
|
||||
use crate::extension_host::tests::{FixturePlugins, fake_authority, node_for_tests};
|
||||
use crate::extension_host::{ExtensionHostManager, HostAttachment, TestManagerGuard};
|
||||
use crate::mcp::{McpBackend, McpPool};
|
||||
use crate::mcp::McpPool;
|
||||
use crate::plugins::activation::TestPolicyGuard;
|
||||
use codewhale_config::McpBackend;
|
||||
use serde_json::json;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -2047,7 +2047,7 @@ impl HookExecutor {
|
||||
continue;
|
||||
}
|
||||
// Audit-log the *keys* — never the values.
|
||||
crate::audit::log_sensitive_event(
|
||||
codewhale_runtime::audit::log_sensitive_event(
|
||||
"shell_env_hook",
|
||||
serde_json::json!({
|
||||
// Bounded and de-fanged like every other rendering of a
|
||||
|
||||
@@ -523,7 +523,7 @@ pub(crate) fn apply_plan(
|
||||
});
|
||||
doc.current = Some(record.clone());
|
||||
doc.save(&paths.receipt)?;
|
||||
crate::audit::log_sensitive_event_in(
|
||||
codewhale_runtime::audit::log_sensitive_event_in(
|
||||
&paths.codewhale_home,
|
||||
&format!("integration.dsh.{}", event.as_str()),
|
||||
serde_json::json!({
|
||||
@@ -567,7 +567,7 @@ pub(crate) fn set_disabled(paths: &DshPaths, disabled: bool) -> Result<DshConnec
|
||||
});
|
||||
doc.current = Some(record.clone());
|
||||
doc.save(&paths.receipt)?;
|
||||
crate::audit::log_sensitive_event_in(
|
||||
codewhale_runtime::audit::log_sensitive_event_in(
|
||||
&paths.codewhale_home,
|
||||
&format!("integration.dsh.{}", event.as_str()),
|
||||
serde_json::json!({ "overlay_path": paths.overlay.display().to_string() }),
|
||||
@@ -616,7 +616,7 @@ pub(crate) fn remove(paths: &DshPaths) -> Result<Vec<PathBuf>> {
|
||||
)),
|
||||
});
|
||||
doc.save(&paths.receipt)?;
|
||||
crate::audit::log_sensitive_event_in(
|
||||
codewhale_runtime::audit::log_sensitive_event_in(
|
||||
&paths.codewhale_home,
|
||||
"integration.dsh.remove",
|
||||
serde_json::json!({ "removed": removed.iter().map(|p| p.display().to_string()).collect::<Vec<_>>() }),
|
||||
@@ -869,7 +869,7 @@ pub(crate) fn install_bundle(
|
||||
});
|
||||
doc.current = Some(record);
|
||||
doc.save(&paths.receipt)?;
|
||||
crate::audit::log_sensitive_event_in(
|
||||
codewhale_runtime::audit::log_sensitive_event_in(
|
||||
&paths.codewhale_home,
|
||||
"integration.dsh.install_bundle",
|
||||
serde_json::json!({
|
||||
@@ -922,7 +922,7 @@ pub(crate) fn remove_bundle(
|
||||
});
|
||||
doc.current = Some(record);
|
||||
doc.save(&paths.receipt)?;
|
||||
crate::audit::log_sensitive_event_in(
|
||||
codewhale_runtime::audit::log_sensitive_event_in(
|
||||
&paths.codewhale_home,
|
||||
"integration.dsh.remove_bundle",
|
||||
serde_json::json!({ "removed": removed.iter().map(|p| p.display().to_string()).collect::<Vec<_>>() }),
|
||||
|
||||
+16
-13
@@ -20,7 +20,6 @@ mod acp_server;
|
||||
pub mod agent_roster;
|
||||
mod approval_log;
|
||||
mod artifacts;
|
||||
mod audit;
|
||||
mod auto_reasoning;
|
||||
mod automation_manager;
|
||||
mod client;
|
||||
@@ -5561,8 +5560,7 @@ async fn run_doctor(
|
||||
std::sync::Arc::new(plugins.clone()),
|
||||
) {
|
||||
Ok(pool) => {
|
||||
let mut pool =
|
||||
pool.with_backend(crate::mcp::McpBackend::from_config(config));
|
||||
let mut pool = pool.with_backend(config.mcp_backend());
|
||||
let errors = pool.connect_all().await;
|
||||
let failed = errors
|
||||
.iter()
|
||||
@@ -5906,8 +5904,7 @@ async fn run_doctor(
|
||||
let native_enabled = config
|
||||
.features()
|
||||
.enabled(codewhale_runtime::features::Feature::ExtensionHost);
|
||||
let mcp_enabled =
|
||||
crate::mcp::McpBackend::from_config(config) == crate::mcp::McpBackend::Host;
|
||||
let mcp_enabled = config.mcp_backend() == codewhale_config::McpBackend::Host;
|
||||
let enabled = native_enabled || mcp_enabled;
|
||||
let tier = if mcp_enabled {
|
||||
crate::extension_host::tier::HostTier::Builtin
|
||||
@@ -11995,7 +11992,9 @@ async fn run_mcp_command(
|
||||
) -> Result<()> {
|
||||
let config_path = config.mcp_config_path();
|
||||
let network_policy = config.network.clone().map(|network| {
|
||||
crate::network_policy::NetworkPolicyDecider::with_default_audit(network.into_runtime())
|
||||
crate::network_policy::NetworkPolicyDecider::with_default_audit(
|
||||
crate::network_policy::NetworkPolicy::from(network),
|
||||
)
|
||||
});
|
||||
match command {
|
||||
McpCommand::Init { force } => {
|
||||
@@ -12075,7 +12074,7 @@ async fn run_mcp_command(
|
||||
workspace,
|
||||
std::sync::Arc::new(plugins.clone()),
|
||||
)?
|
||||
.with_backend(crate::mcp::McpBackend::from_config(config));
|
||||
.with_backend(config.mcp_backend());
|
||||
if let Some(name) = server {
|
||||
if let Err(err) = pool.get_or_connect(&name).await {
|
||||
if crate::mcp::oauth::error_looks_auth_required(&err) {
|
||||
@@ -12108,7 +12107,7 @@ async fn run_mcp_command(
|
||||
workspace,
|
||||
std::sync::Arc::new(plugins.clone()),
|
||||
)?
|
||||
.with_backend(crate::mcp::McpBackend::from_config(config));
|
||||
.with_backend(config.mcp_backend());
|
||||
if let Some(name) = server {
|
||||
let conn = match pool.get_or_connect(&name).await {
|
||||
Ok(conn) => conn,
|
||||
@@ -12292,7 +12291,7 @@ async fn run_mcp_command(
|
||||
workspace,
|
||||
std::sync::Arc::new(plugins.clone()),
|
||||
)?
|
||||
.with_backend(crate::mcp::McpBackend::from_config(config));
|
||||
.with_backend(config.mcp_backend());
|
||||
let errors = pool.connect_all().await;
|
||||
if errors.is_empty() {
|
||||
println!("MCP config is valid. All enabled servers connected.");
|
||||
@@ -14559,7 +14558,7 @@ async fn initialize_direct_workflow_mcp_pool(
|
||||
tracing::debug!("No MCP config for direct Workflow runtime: {error:#}");
|
||||
crate::mcp::McpPool::new(crate::mcp::McpConfig::default())
|
||||
});
|
||||
pool = pool.with_backend(crate::mcp::McpBackend::from_config(config));
|
||||
pool = pool.with_backend(config.mcp_backend());
|
||||
if let Some(policy) = network_policy {
|
||||
pool = pool.with_network_policy(policy);
|
||||
}
|
||||
@@ -14638,7 +14637,9 @@ async fn build_direct_workflow_tool(
|
||||
crate::core::authority::SandboxNetworkAccess::from_config(config.sandbox_network_access),
|
||||
));
|
||||
let network_policy = config.network.clone().map(|network| {
|
||||
crate::network_policy::NetworkPolicyDecider::with_default_audit(network.into_runtime())
|
||||
crate::network_policy::NetworkPolicyDecider::with_default_audit(
|
||||
crate::network_policy::NetworkPolicy::from(network),
|
||||
)
|
||||
});
|
||||
if let Some(policy) = network_policy.as_ref() {
|
||||
context = context.with_network_policy(policy.clone());
|
||||
@@ -15132,7 +15133,9 @@ fn exec_network_policy(
|
||||
);
|
||||
}
|
||||
config.network.clone().map(|toml_cfg| {
|
||||
crate::network_policy::NetworkPolicyDecider::with_default_audit(toml_cfg.into_runtime())
|
||||
crate::network_policy::NetworkPolicyDecider::with_default_audit(
|
||||
crate::network_policy::NetworkPolicy::from(toml_cfg),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -16256,7 +16259,7 @@ mod doctor_setup_state_tests {
|
||||
approval_policy: Some("never".to_string()),
|
||||
allow_shell: Some(false),
|
||||
sandbox_mode: Some("read-only".to_string()),
|
||||
network: Some(crate::config::NetworkPolicyToml {
|
||||
network: Some(codewhale_config::NetworkPolicyToml {
|
||||
default: "deny".to_string(),
|
||||
..Default::default()
|
||||
}),
|
||||
|
||||
@@ -74,10 +74,10 @@
|
||||
#[test]
|
||||
fn headless_consultant_authority_overrides_network_allow_and_disables_web_search() {
|
||||
let config = Config {
|
||||
network: Some(crate::config::NetworkPolicyToml {
|
||||
network: Some(codewhale_config::NetworkPolicyToml {
|
||||
default: "allow".to_string(),
|
||||
audit: false,
|
||||
..crate::config::NetworkPolicyToml::default()
|
||||
..codewhale_config::NetworkPolicyToml::default()
|
||||
}),
|
||||
..Config::default()
|
||||
};
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
let defaulted = exec_max_steps(args.max_turns);
|
||||
assert_eq!(
|
||||
defaulted,
|
||||
crate::core::engine::turn_budget::DEFAULT_MAX_MODEL_STEPS
|
||||
codewhale_runtime::turn_budget::DEFAULT_MAX_MODEL_STEPS
|
||||
);
|
||||
assert_eq!(
|
||||
crate::core::turn::TurnContext::new(defaulted).step_limit(),
|
||||
@@ -25,7 +25,7 @@
|
||||
assert_eq!(capped.stop_diagnostics.effective_max_steps, Some(7));
|
||||
assert_eq!(
|
||||
exec_max_steps(Some(u32::MAX)),
|
||||
crate::core::engine::turn_budget::DEFAULT_MAX_MODEL_STEPS,
|
||||
codewhale_runtime::turn_budget::DEFAULT_MAX_MODEL_STEPS,
|
||||
"the compatibility sentinel remains unbounded"
|
||||
);
|
||||
}
|
||||
|
||||
+19
-14
@@ -50,20 +50,7 @@ pub use client::{LspTransport, StdioLspTransport};
|
||||
pub use diagnostics::{Diagnostic, DiagnosticBlock, Severity, render_blocks};
|
||||
pub use registry::Language;
|
||||
|
||||
/// User-defined LSP server for one file extension.
|
||||
///
|
||||
/// Registered via `[lsp.custom.<ext>]` in the config. The extension key is the
|
||||
/// file suffix (without the leading dot), e.g. `"php"`, `"rb"`, `"cs"`.
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||
pub struct CustomLspDef {
|
||||
/// LSP `languageId` value used in `textDocument/didOpen`.
|
||||
pub language_id: String,
|
||||
/// Executable to spawn.
|
||||
pub command: String,
|
||||
/// Arguments passed to the executable.
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
}
|
||||
use codewhale_config::CustomLspDef;
|
||||
|
||||
/// `[lsp]` config schema. Mirrors the TOML keys documented in
|
||||
/// `config.example.toml`. Unknown keys are ignored.
|
||||
@@ -91,6 +78,24 @@ pub struct LspConfig {
|
||||
pub custom: HashMap<String, CustomLspDef>,
|
||||
}
|
||||
|
||||
impl From<codewhale_config::LspConfigToml> for LspConfig {
|
||||
fn from(toml: codewhale_config::LspConfigToml) -> Self {
|
||||
let defaults = Self::default();
|
||||
Self {
|
||||
enabled: toml.enabled.unwrap_or(defaults.enabled),
|
||||
poll_after_edit_ms: toml
|
||||
.poll_after_edit_ms
|
||||
.unwrap_or(defaults.poll_after_edit_ms),
|
||||
max_diagnostics_per_file: toml
|
||||
.max_diagnostics_per_file
|
||||
.unwrap_or(defaults.max_diagnostics_per_file),
|
||||
include_warnings: toml.include_warnings.unwrap_or(defaults.include_warnings),
|
||||
servers: toml.servers.unwrap_or_default().into_iter().collect(),
|
||||
custom: toml.custom.unwrap_or_default().into_iter().collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for LspConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
|
||||
+1
-18
@@ -16,6 +16,7 @@ use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use codewhale_config::McpBackend;
|
||||
use futures_util::FutureExt;
|
||||
use parking_lot::RwLock;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -3138,24 +3139,6 @@ pub(crate) async fn authenticate_tool_via_pool(
|
||||
}
|
||||
|
||||
/// Pool of MCP connections for reuse
|
||||
/// Protocol owner selected only by user configuration. Rust is the default;
|
||||
/// Host explicitly selects the SDK and never falls back to Rust on refusal.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum McpBackend {
|
||||
#[default]
|
||||
Rust,
|
||||
Host,
|
||||
}
|
||||
impl McpBackend {
|
||||
pub(crate) fn from_config(config: &crate::config::Config) -> Self {
|
||||
config
|
||||
.extension_host
|
||||
.as_ref()
|
||||
.map_or(Self::Rust, |host| host.mcp_backend)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct McpPool {
|
||||
backend: McpBackend,
|
||||
/// Immutable operator ceiling; source reloads and shared child pools cannot relax it.
|
||||
|
||||
@@ -187,6 +187,46 @@ impl From<Decision> for DecisionToml {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<codewhale_config::NetworkPolicyToml> for NetworkPolicy {
|
||||
fn from(toml: codewhale_config::NetworkPolicyToml) -> Self {
|
||||
Self {
|
||||
default: Decision::parse(&toml.default).into(),
|
||||
allow: toml.allow,
|
||||
deny: toml.deny,
|
||||
proxy: toml.proxy,
|
||||
proxy_fake_ip_cidrs: toml.proxy_fake_ip_cidrs,
|
||||
audit: toml.audit,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-read the `[network]` table from a configuration document.
|
||||
///
|
||||
/// Deliberately narrower than `Config::load`: this runs on the tool-context
|
||||
/// build path, where re-applying the environment, managed, and credential
|
||||
/// layers would be both wasteful and wrong. Only the document's own table comes
|
||||
/// back — the same table `/network allow <host>` edits.
|
||||
///
|
||||
/// `None` means there is nothing usable to adopt: the document is missing,
|
||||
/// unreadable, unparseable, or carries no `[network]` table. Callers keep the
|
||||
/// policy they already hold, which is the conservative direction for an
|
||||
/// allow/deny gate.
|
||||
///
|
||||
/// Known limitation: `[profiles.<name>.network]` is not consulted. `/network`
|
||||
/// does not write it either, so a live session and the command agree on this
|
||||
/// base table.
|
||||
#[must_use]
|
||||
pub fn network_policy_from_document(path: &Path) -> Option<NetworkPolicy> {
|
||||
#[derive(Deserialize)]
|
||||
struct NetworkTable {
|
||||
network: Option<codewhale_config::NetworkPolicyToml>,
|
||||
}
|
||||
|
||||
let contents = fs::read_to_string(path).ok()?;
|
||||
let parsed: NetworkTable = toml::from_str(&contents).ok()?;
|
||||
parsed.network.map(NetworkPolicy::from)
|
||||
}
|
||||
|
||||
impl NetworkPolicy {
|
||||
/// Fold a lower-precedence layer (the user's config document) onto this
|
||||
/// policy without letting it widen what a higher authority set.
|
||||
@@ -397,7 +437,10 @@ impl NetworkAuditor {
|
||||
/// no Codewhale home resolves.
|
||||
#[must_use]
|
||||
pub fn default_path(enabled: bool) -> Option<Self> {
|
||||
Some(Self::new(crate::audit::audit_log_path()?, enabled))
|
||||
Some(Self::new(
|
||||
codewhale_runtime::audit::audit_log_path()?,
|
||||
enabled,
|
||||
))
|
||||
}
|
||||
|
||||
/// Append one line. Best-effort: errors are logged via `eprintln!` but
|
||||
@@ -773,7 +816,7 @@ mod tests {
|
||||
fn default_auditor_writes_to_the_shared_audit_log() {
|
||||
assert_eq!(
|
||||
NetworkAuditor::default_path(true).map(|auditor| auditor.path),
|
||||
crate::audit::audit_log_path()
|
||||
codewhale_runtime::audit::audit_log_path()
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -106,7 +106,7 @@ pub async fn install_from_cli(
|
||||
let _scope = crate::plugins::activation::PolicyScope::propagate(policy);
|
||||
let network = config
|
||||
.network
|
||||
.map(|network| network.into_runtime())
|
||||
.map(crate::network_policy::NetworkPolicy::from)
|
||||
.unwrap_or_default();
|
||||
let source = PluginInstallSource::parse(&source)?;
|
||||
runtime.block_on(execute(
|
||||
|
||||
@@ -17,32 +17,9 @@ use crate::config::{
|
||||
DEEPSEEK_ALIAS_RETIREMENT_UTC, DEFAULT_STEPFUN_BASE_URL, DEFAULT_STEPFUN_PLAN_BASE_URL,
|
||||
ProviderKind, canonical_model_id_for_provider,
|
||||
};
|
||||
use codewhale_config::pricing::CostCurrency;
|
||||
use codewhale_models::{Usage, has_date_snapshot_suffix};
|
||||
|
||||
/// Cost display currency.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CostCurrency {
|
||||
Usd,
|
||||
Cny,
|
||||
}
|
||||
|
||||
impl CostCurrency {
|
||||
pub fn from_setting(value: &str) -> Option<Self> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"usd" | "dollar" | "dollars" | "$" => Some(Self::Usd),
|
||||
"cny" | "rmb" | "yuan" | "¥" => Some(Self::Cny),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn symbol(self) -> &'static str {
|
||||
match self {
|
||||
Self::Usd => "$",
|
||||
Self::Cny => "¥",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cost estimate in displayable currencies.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq)]
|
||||
pub struct CostEstimate {
|
||||
|
||||
@@ -47,7 +47,7 @@ pub(crate) struct ProviderRouteIdentity {
|
||||
endpoint: String,
|
||||
model: String,
|
||||
auth_class: ProviderAuthClass,
|
||||
credential_generation: Option<crate::route_receipt::CredentialGeneration>,
|
||||
credential_generation: Option<codewhale_config::credentials::CredentialGeneration>,
|
||||
}
|
||||
|
||||
pub(crate) fn route_identity_for_model(
|
||||
@@ -634,7 +634,7 @@ impl ProviderReadinessSnapshot {
|
||||
config: &crate::config::Config,
|
||||
identity: &ProviderIdentity,
|
||||
model: &str,
|
||||
generation: crate::route_receipt::CredentialGeneration,
|
||||
generation: codewhale_config::credentials::CredentialGeneration,
|
||||
category: ErrorCategory,
|
||||
message: &str,
|
||||
) {
|
||||
@@ -655,7 +655,7 @@ impl ProviderReadinessSnapshot {
|
||||
config: &crate::config::Config,
|
||||
identity: &ProviderIdentity,
|
||||
model: &str,
|
||||
generation: crate::route_receipt::CredentialGeneration,
|
||||
generation: codewhale_config::credentials::CredentialGeneration,
|
||||
) {
|
||||
let mut route = route_identity_for_model(config, identity, model);
|
||||
route.credential_generation = Some(generation);
|
||||
@@ -799,7 +799,7 @@ pub(crate) struct RouteReadinessScope {
|
||||
endpoint: String,
|
||||
credentials: CredentialState,
|
||||
auth_class: ProviderAuthClass,
|
||||
credential_generation: Option<crate::route_receipt::CredentialGeneration>,
|
||||
credential_generation: Option<codewhale_config::credentials::CredentialGeneration>,
|
||||
}
|
||||
|
||||
impl RouteReadinessScope {
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
//! - unknown stays unknown — never `$0.00` and never an estimate-as-spend.
|
||||
|
||||
use crate::config::{Config, ProviderConfig, ProviderIdentity, ProviderKind};
|
||||
use crate::pricing::{CostCurrency, UnpricedReason, format_cost_amount};
|
||||
use crate::pricing::{UnpricedReason, format_cost_amount};
|
||||
use codewhale_config::pricing::CostCurrency;
|
||||
use codewhale_localization::{Locale, MessageId, tr};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -1216,7 +1217,7 @@ fn xiaomi_is_explicit_pay_as_you_go(config: Option<&ProviderConfig>) -> bool {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::pricing::CostCurrency;
|
||||
use codewhale_config::pricing::CostCurrency;
|
||||
use codewhale_models::Usage;
|
||||
|
||||
fn config_with(provider: ProviderKind, provider_config: ProviderConfig) -> Config {
|
||||
|
||||
@@ -21,9 +21,8 @@
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::config::{ProviderIdentity, ProviderKind};
|
||||
use codewhale_config::credentials::CredentialGeneration;
|
||||
|
||||
/// Endpoint identity for a string that is not a parseable URL.
|
||||
///
|
||||
@@ -52,55 +51,6 @@ pub fn endpoint_identity(base_url: &str) -> String {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// One-way digest proving a credential (and the endpoint it is bound to) is
|
||||
/// still the same one.
|
||||
///
|
||||
/// SHA-256 over a domain-separated, length-prefixed preimage, truncated to 128
|
||||
/// bits. Length prefixing keeps `(base_url, key)` unambiguous, so no pair of
|
||||
/// distinct routes can be made to share a generation by moving bytes across the
|
||||
/// boundary. The value is never rendered: it is credential-derived, and a
|
||||
/// stable public digest of a secret is a secret's shadow.
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct CredentialGeneration(String);
|
||||
|
||||
impl CredentialGeneration {
|
||||
pub(crate) fn derive(base_url: &str, credential: &str) -> Self {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(b"codewhale/turn-route/credential-generation/v1\0");
|
||||
hasher.update(
|
||||
u64::try_from(base_url.len())
|
||||
.unwrap_or(u64::MAX)
|
||||
.to_le_bytes(),
|
||||
);
|
||||
hasher.update(base_url.as_bytes());
|
||||
hasher.update(
|
||||
u64::try_from(credential.len())
|
||||
.unwrap_or(u64::MAX)
|
||||
.to_le_bytes(),
|
||||
);
|
||||
hasher.update(credential.as_bytes());
|
||||
let digest = hasher.finalize();
|
||||
let mut hex = String::with_capacity(32);
|
||||
for byte in &digest[..16] {
|
||||
use fmt::Write as _;
|
||||
let _ = write!(hex, "{byte:02x}");
|
||||
}
|
||||
Self(hex)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Redacted: see the type docs. There is no accessor for the digest string.
|
||||
impl fmt::Debug for CredentialGeneration {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str("<redacted>")
|
||||
}
|
||||
}
|
||||
|
||||
/// Immutable proof of the exact base route a turn's client was installed on.
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct TurnRouteReceipt {
|
||||
|
||||
@@ -5522,7 +5522,7 @@ fn mutation_context_settings(
|
||||
let network = config
|
||||
.network
|
||||
.clone()
|
||||
.map(|p| p.into_runtime())
|
||||
.map(crate::network_policy::NetworkPolicy::from)
|
||||
.unwrap_or_default();
|
||||
let skills_cfg = config.skills.as_ref();
|
||||
let max_size = skills_cfg
|
||||
@@ -6500,7 +6500,7 @@ fn mcp_pool_policy(config: &Config) -> Value {
|
||||
config
|
||||
.network
|
||||
.clone()
|
||||
.map(crate::config::NetworkPolicyToml::into_runtime),
|
||||
.map(crate::network_policy::NetworkPolicy::from),
|
||||
config.mcp_oauth_callback_port,
|
||||
config.mcp_oauth_callback_url,
|
||||
])
|
||||
@@ -6515,15 +6515,15 @@ fn load_mcp_management_pool(state: &RuntimeApiState) -> Result<(McpPool, Value)>
|
||||
let mut pool =
|
||||
McpPool::from_config_path_with_workspace_and_plugins(&path, &state.workspace, plugins)?;
|
||||
let config = state.config.read();
|
||||
pool = pool
|
||||
.with_backend(crate::mcp::McpBackend::from_config(&config))
|
||||
.with_oauth_callback(
|
||||
config.mcp_oauth_callback_port,
|
||||
config.mcp_oauth_callback_url.clone(),
|
||||
);
|
||||
pool = pool.with_backend(config.mcp_backend()).with_oauth_callback(
|
||||
config.mcp_oauth_callback_port,
|
||||
config.mcp_oauth_callback_url.clone(),
|
||||
);
|
||||
if let Some(network) = config.network.clone() {
|
||||
pool = pool.with_network_policy(
|
||||
crate::network_policy::NetworkPolicyDecider::with_default_audit(network.into_runtime()),
|
||||
crate::network_policy::NetworkPolicyDecider::with_default_audit(
|
||||
crate::network_policy::NetworkPolicy::from(network),
|
||||
),
|
||||
);
|
||||
}
|
||||
pool.dynamic_servers = state.workspace_scopes.dynamic_servers.clone();
|
||||
|
||||
@@ -58,7 +58,7 @@ async fn lsp_manager(state: &RuntimeApiState) -> Result<Arc<LspManager>, ApiErro
|
||||
.read()
|
||||
.lsp
|
||||
.clone()
|
||||
.map(|toml| toml.into_runtime())
|
||||
.map(crate::lsp::LspConfig::from)
|
||||
.unwrap_or_default();
|
||||
Ok(state
|
||||
.workspace_scope
|
||||
|
||||
@@ -402,7 +402,7 @@ async fn run_plugin_mutation(
|
||||
config
|
||||
.network
|
||||
.clone()
|
||||
.map(|policy| policy.into_runtime())
|
||||
.map(crate::network_policy::NetworkPolicy::from)
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let ctx = PluginMutationContext {
|
||||
|
||||
@@ -236,10 +236,10 @@ async fn import_into_owner(
|
||||
let (request, journal, request_digest, history_digest) = history_owner_work(move || {
|
||||
let journal = legacy_journal(&request.history)?;
|
||||
let request_digest = codewhale_runtime::hashing::sha256_hex(
|
||||
crate::client::canonical_json(&serde_json::to_value(&request)?).as_bytes(),
|
||||
codewhale_runtime::hashing::canonical_json(&serde_json::to_value(&request)?).as_bytes(),
|
||||
);
|
||||
let history_digest = codewhale_runtime::hashing::sha256_hex(
|
||||
crate::client::canonical_json(&serde_json::to_value(&journal)?).as_bytes(),
|
||||
codewhale_runtime::hashing::canonical_json(&serde_json::to_value(&journal)?).as_bytes(),
|
||||
);
|
||||
Ok((request, journal, request_digest, history_digest))
|
||||
})
|
||||
@@ -712,7 +712,8 @@ fn stable_projection_entries(
|
||||
for (index, entry) in journal.entries.iter_mut().enumerate().skip(retained_count) {
|
||||
let old = entry.id.clone();
|
||||
let digest = codewhale_runtime::hashing::sha256_hex(
|
||||
crate::client::canonical_json(&serde_json::to_value(&entry.kind)?).as_bytes(),
|
||||
codewhale_runtime::hashing::canonical_json(&serde_json::to_value(&entry.kind)?)
|
||||
.as_bytes(),
|
||||
);
|
||||
entry.id = format!(
|
||||
"projection:{}:{}:{index}:{digest}",
|
||||
@@ -744,7 +745,7 @@ pub(crate) fn saved_document_digest(session: &SavedSession) -> Result<String> {
|
||||
let mut size = HistorySizeBound(MAX_CANONICAL_HISTORY_BYTES);
|
||||
serde_json::to_writer(&mut size, &session.transport_form())?;
|
||||
Ok(codewhale_runtime::hashing::sha256_hex(
|
||||
crate::client::canonical_json(&serde_json::to_value(session)?).as_bytes(),
|
||||
codewhale_runtime::hashing::canonical_json(&serde_json::to_value(session)?).as_bytes(),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -805,7 +806,7 @@ pub(crate) async fn mutate_thread_history_in_runtime(
|
||||
|
||||
fn journal_digest(journal: &SessionJournal) -> Result<String> {
|
||||
Ok(codewhale_runtime::hashing::sha256_hex(
|
||||
crate::client::canonical_json(&serde_json::to_value(journal)?).as_bytes(),
|
||||
codewhale_runtime::hashing::canonical_json(&serde_json::to_value(journal)?).as_bytes(),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -813,7 +814,7 @@ pub(crate) fn session_goal_digest(
|
||||
goal: &Option<crate::session_manager::SessionGoalState>,
|
||||
) -> Result<String> {
|
||||
Ok(codewhale_runtime::hashing::sha256_hex(
|
||||
crate::client::canonical_json(&serde_json::to_value(goal)?).as_bytes(),
|
||||
codewhale_runtime::hashing::canonical_json(&serde_json::to_value(goal)?).as_bytes(),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -1053,7 +1054,7 @@ async fn mutate_in_owner(
|
||||
request: CanonicalThreadMutationRequest,
|
||||
) -> Result<CanonicalThreadReceipt> {
|
||||
let request_digest = codewhale_runtime::hashing::sha256_hex(
|
||||
crate::client::canonical_json(&serde_json::to_value(&request)?).as_bytes(),
|
||||
codewhale_runtime::hashing::canonical_json(&serde_json::to_value(&request)?).as_bytes(),
|
||||
);
|
||||
let lookup_runtime = Arc::clone(&runtime);
|
||||
let key = request.operation_key.clone();
|
||||
|
||||
@@ -5427,7 +5427,7 @@ fn runtime_turn_request_fingerprint(
|
||||
payload["maxOutputTokens"] = json!(max_output_tokens);
|
||||
}
|
||||
Ok(codewhale_runtime::hashing::sha256_hex(
|
||||
crate::client::canonical_json(&payload),
|
||||
codewhale_runtime::hashing::canonical_json(&payload),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -12515,7 +12515,7 @@ impl RuntimeThreadManager {
|
||||
);
|
||||
source.validate_stall_state().map_err(anyhow::Error::msg)?;
|
||||
let source_digest = codewhale_runtime::hashing::sha256_hex(
|
||||
crate::client::canonical_json(&serde_json::to_value(&source)?).as_bytes(),
|
||||
codewhale_runtime::hashing::canonical_json(&serde_json::to_value(&source)?).as_bytes(),
|
||||
);
|
||||
let mut target = source;
|
||||
target
|
||||
@@ -12527,7 +12527,7 @@ impl RuntimeThreadManager {
|
||||
}
|
||||
target.normalize_restored_stall_state();
|
||||
let target_digest = codewhale_runtime::hashing::sha256_hex(
|
||||
crate::client::canonical_json(&serde_json::to_value(&target)?).as_bytes(),
|
||||
codewhale_runtime::hashing::canonical_json(&serde_json::to_value(&target)?).as_bytes(),
|
||||
);
|
||||
let fingerprint = operation.key_fingerprint.clone();
|
||||
self.store
|
||||
@@ -15430,21 +15430,21 @@ impl RuntimeThreadManager {
|
||||
req.max_output_tokens,
|
||||
)?;
|
||||
let request_fingerprint = if let Some(workspace) = expected_workspace.as_ref() {
|
||||
codewhale_runtime::hashing::sha256_hex(crate::client::canonical_json(&json!({
|
||||
codewhale_runtime::hashing::sha256_hex(codewhale_runtime::hashing::canonical_json(&json!({
|
||||
"domain": "codewhale:selected-workspace-turn:v1",
|
||||
"historical_fingerprint": request_fingerprint,
|
||||
"expected_workspace": workspace,
|
||||
})).as_bytes())
|
||||
} else { request_fingerprint };
|
||||
let request_fingerprint = if let Some(snapshot) = &req.profile_constitution {
|
||||
codewhale_runtime::hashing::sha256_hex(crate::client::canonical_json(&json!({
|
||||
codewhale_runtime::hashing::sha256_hex(codewhale_runtime::hashing::canonical_json(&json!({
|
||||
"domain": "codewhale:profile-constitution-turn:v1",
|
||||
"historical_fingerprint": request_fingerprint,
|
||||
"profile_constitution": snapshot,
|
||||
})).as_bytes())
|
||||
} else { request_fingerprint };
|
||||
let request_fingerprint = if let Some(owner) = &req.account_model_owner {
|
||||
codewhale_runtime::hashing::sha256_hex(crate::client::canonical_json(&json!({
|
||||
codewhale_runtime::hashing::sha256_hex(codewhale_runtime::hashing::canonical_json(&json!({
|
||||
"domain": "codewhale:account-model-owner-turn:v1",
|
||||
"historical_fingerprint": request_fingerprint,
|
||||
"account_model_owner": owner,
|
||||
@@ -16433,13 +16433,13 @@ impl RuntimeThreadManager {
|
||||
.flatten()
|
||||
.map(|toml_cfg| {
|
||||
crate::network_policy::NetworkPolicyDecider::with_default_audit(
|
||||
toml_cfg.into_runtime(),
|
||||
crate::network_policy::NetworkPolicy::from(toml_cfg),
|
||||
)
|
||||
});
|
||||
let lsp_config = (!isolated_chat)
|
||||
.then(|| cfg.lsp.clone())
|
||||
.flatten()
|
||||
.map(crate::config::LspConfigToml::into_runtime);
|
||||
.map(crate::lsp::LspConfig::from);
|
||||
let max_subagents = cfg
|
||||
.max_subagents_for_provider(&route_identity)
|
||||
.clamp(1, MAX_SUBAGENTS);
|
||||
@@ -16568,7 +16568,8 @@ impl RuntimeThreadManager {
|
||||
subagents_enabled: !isolated_chat
|
||||
&& cfg.subagents_enabled_for_provider(&route_identity),
|
||||
features,
|
||||
auto_review_policy: cfg.auto_review_policy(),
|
||||
auto_review_policy:
|
||||
crate::core::authority::auto_review::AutoReviewPolicy::from_config(&cfg),
|
||||
compaction,
|
||||
todos: new_shared_todo_list(),
|
||||
plan_state: new_shared_plan_state(),
|
||||
|
||||
@@ -798,13 +798,13 @@ impl SessionCostSnapshot {
|
||||
/// Session + subagent cost in USD.
|
||||
pub fn total_usd(&self) -> f64 {
|
||||
self.total_estimate()
|
||||
.amount(crate::pricing::CostCurrency::Usd)
|
||||
.amount(codewhale_config::pricing::CostCurrency::Usd)
|
||||
}
|
||||
|
||||
/// Session + subagent cost in CNY.
|
||||
pub fn total_cny(&self) -> f64 {
|
||||
self.total_estimate()
|
||||
.amount(crate::pricing::CostCurrency::Cny)
|
||||
.amount(codewhale_config::pricing::CostCurrency::Cny)
|
||||
}
|
||||
|
||||
/// Whether this snapshot's coverage state must be shown as unknown.
|
||||
|
||||
@@ -1725,14 +1725,15 @@ impl Settings {
|
||||
self.session_auto_resume = parse_bool(value)?;
|
||||
}
|
||||
"cost_currency" | "currency" => {
|
||||
let Some(currency) = crate::pricing::CostCurrency::from_setting(value) else {
|
||||
let Some(currency) = codewhale_config::pricing::CostCurrency::from_setting(value)
|
||||
else {
|
||||
anyhow::bail!(
|
||||
"Failed to update setting: invalid cost currency '{value}'. Expected: usd, cny, rmb, yuan."
|
||||
);
|
||||
};
|
||||
self.cost_currency = match currency {
|
||||
crate::pricing::CostCurrency::Usd => "usd",
|
||||
crate::pricing::CostCurrency::Cny => "cny",
|
||||
codewhale_config::pricing::CostCurrency::Usd => "usd",
|
||||
codewhale_config::pricing::CostCurrency::Cny => "cny",
|
||||
}
|
||||
.to_string();
|
||||
}
|
||||
@@ -2134,25 +2135,6 @@ impl Settings {
|
||||
true
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // label editing surface is exposed through settings serialization first
|
||||
pub fn set_pinned_model_label(
|
||||
&mut self,
|
||||
provider: &str,
|
||||
model: &str,
|
||||
label: Option<String>,
|
||||
) -> bool {
|
||||
self.pinned_models
|
||||
.iter_mut()
|
||||
.find(|pin| {
|
||||
pin.provider.eq_ignore_ascii_case(provider) && pin.model.eq_ignore_ascii_case(model)
|
||||
})
|
||||
.map(|pin| {
|
||||
pin.label = label.filter(|value| !value.trim().is_empty());
|
||||
true
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn move_pinned_model(&mut self, provider: &str, model: &str, delta: isize) -> bool {
|
||||
let Some(index) = self.pinned_models.iter().position(|pin| {
|
||||
pin.provider.eq_ignore_ascii_case(provider) && pin.model.eq_ignore_ascii_case(model)
|
||||
@@ -5943,7 +5925,7 @@ zai = ["GLM-5.2", "GLM-5.3"]
|
||||
assert_eq!(settings.pinned_models[0].provider, "zai");
|
||||
assert!(settings.move_pinned_model("openrouter", "glm-5.2", -1));
|
||||
assert_eq!(settings.pinned_models[0].provider, "openrouter");
|
||||
assert!(settings.set_pinned_model_label("openrouter", "glm-5.2", Some("fast".to_string())));
|
||||
settings.pinned_models[0].label = Some("fast".to_string());
|
||||
let encoded = toml::to_string(&settings).unwrap();
|
||||
let decoded: Settings = toml::from_str(&encoded).unwrap();
|
||||
assert_eq!(decoded.pinned_models, settings.pinned_models);
|
||||
|
||||
@@ -47,7 +47,7 @@ pub mod repo;
|
||||
|
||||
#[allow(unused_imports)]
|
||||
pub use paths::{snapshot_dir_for, snapshot_git_dir};
|
||||
pub use prune::{DEFAULT_MAX_AGE, prune_older_than};
|
||||
pub use prune::prune_older_than;
|
||||
|
||||
/// Snapshots kept per workspace side-repo, pruned after each new snapshot to
|
||||
/// cap disk usage (#1112): the newest this many, plus the newest this many
|
||||
@@ -58,9 +58,9 @@ pub const DEFAULT_MAX_SNAPSHOTS: usize = 50;
|
||||
pub use delta::{DeltaChange, SnapshotDelta};
|
||||
#[allow(unused_imports)]
|
||||
pub use repo::{
|
||||
DEFAULT_MAX_WORKSPACE_BYTES_FOR_SNAPSHOT, GATE_TOO_LARGE_MARKER, GATE_TOO_MANY_ENTRIES_MARKER,
|
||||
GATE_UNSAFE_LOCATION_MARKER, PathRestoreAction, PathRestoreOutcome, SIZE_WALK_MAX_ENTRIES,
|
||||
Snapshot, SnapshotId, SnapshotPathChange, SnapshotRepo, TakenSnapshot, WorkspaceGate,
|
||||
GATE_TOO_LARGE_MARKER, GATE_TOO_MANY_ENTRIES_MARKER, GATE_UNSAFE_LOCATION_MARKER,
|
||||
PathRestoreAction, PathRestoreOutcome, SIZE_WALK_MAX_ENTRIES, Snapshot, SnapshotId,
|
||||
SnapshotPathChange, SnapshotRepo, TakenSnapshot, WorkspaceGate,
|
||||
estimate_workspace_size_bounded, is_git_metadata_name, workspace_relative_path,
|
||||
};
|
||||
|
||||
|
||||
@@ -11,9 +11,6 @@ use std::time::Duration;
|
||||
use super::paths::snapshot_git_dir;
|
||||
use super::repo::SnapshotRepo;
|
||||
|
||||
/// Default snapshot retention window: 7 days.
|
||||
pub const DEFAULT_MAX_AGE: Duration = Duration::from_secs(7 * 24 * 60 * 60);
|
||||
|
||||
/// Prune snapshots older than `max_age` for the given workspace.
|
||||
///
|
||||
/// If no snapshot repo exists yet (first run) this is a cheap no-op.
|
||||
@@ -45,7 +42,8 @@ mod tests {
|
||||
let tmp = tempdir().unwrap();
|
||||
let _lock = lock_test_env();
|
||||
let _profile = EnvVarGuard::set("CODEWHALE_HOME", tmp.path().join("profile"));
|
||||
let removed = prune_older_than(tmp.path(), DEFAULT_MAX_AGE).unwrap();
|
||||
let removed =
|
||||
prune_older_than(tmp.path(), codewhale_config::DEFAULT_SNAPSHOT_MAX_AGE).unwrap();
|
||||
assert_eq!(removed, 0);
|
||||
}
|
||||
|
||||
|
||||
@@ -211,18 +211,6 @@ const BYTES_PER_MB: u64 = 1024 * 1024;
|
||||
/// so the repo doesn't hit the limit again one snapshot later.
|
||||
const PRUNE_TARGET_MB: u64 = 400;
|
||||
|
||||
/// Default workspace-size ceiling above which snapshots self-disable
|
||||
/// on first use (2 GB of non-excluded content). Reports from users with
|
||||
/// multi-hundred-GB project directories — datasets, model weights,
|
||||
/// docker image dumps that fall outside the built-in excludes —
|
||||
/// surfaced that `git add -A` on first init would hang the TUI for
|
||||
/// minutes-to-hours while indexing the workspace. Snapshots are a
|
||||
/// rollback safety net, not a backup tool; bailing out on workspaces
|
||||
/// that big is the right tradeoff. Users with legitimate large
|
||||
/// monorepos can raise `[snapshots] max_workspace_gb` (or set it to
|
||||
/// `0` to disable the cap entirely).
|
||||
pub const DEFAULT_MAX_WORKSPACE_BYTES_FOR_SNAPSHOT: u64 = 2 * 1024 * 1024 * 1024;
|
||||
|
||||
/// Hard cap on the number of file entries the bounded size estimator
|
||||
/// will inspect before declaring the workspace "too large". Protects
|
||||
/// against a workspace with millions of tiny files (no individual
|
||||
@@ -411,7 +399,10 @@ impl SnapshotRepo {
|
||||
/// the user's global git identity (we don't want our snapshots to
|
||||
/// look like they came from the user).
|
||||
pub fn open_or_init(workspace: &Path) -> io::Result<Self> {
|
||||
Self::open_or_init_with_cap(workspace, DEFAULT_MAX_WORKSPACE_BYTES_FOR_SNAPSHOT)
|
||||
Self::open_or_init_with_cap(
|
||||
workspace,
|
||||
codewhale_config::DEFAULT_MAX_WORKSPACE_BYTES_FOR_SNAPSHOT,
|
||||
)
|
||||
}
|
||||
|
||||
/// Variant of [`Self::open_or_init`] that accepts an explicit
|
||||
|
||||
@@ -167,7 +167,7 @@ mod tests {
|
||||
for bundle in crate::plugins::builtin::materialized_dirs() {
|
||||
inside("built-in plugins", &bundle);
|
||||
}
|
||||
let audit = crate::audit::audit_log_path().expect("audit log path");
|
||||
let audit = codewhale_runtime::audit::audit_log_path().expect("audit log path");
|
||||
assert!(
|
||||
audit.starts_with(std::env::temp_dir()),
|
||||
"the audit log resolved outside the temp dir: {}",
|
||||
|
||||
@@ -1578,7 +1578,7 @@ pub(super) async fn run_child_agent(
|
||||
let config = crate::core::engine::EngineConfig {
|
||||
workspace: runtime.context.workspace.clone(),
|
||||
model: runtime.model.clone(),
|
||||
max_steps: crate::core::engine::turn_budget::resolve_max_model_steps(Some(max_steps)),
|
||||
max_steps: codewhale_runtime::turn_budget::resolve_max_model_steps(Some(max_steps)),
|
||||
compaction: runtime.compaction.clone(),
|
||||
subagent_api_timeout: runtime.step_api_timeout,
|
||||
auto_review_policy: runtime.auto_review_policy.as_ref().clone(),
|
||||
|
||||
@@ -25,7 +25,7 @@ use crate::config::{
|
||||
use crate::core::authority::{ModeSessionPrefs, base_policy_for_mode};
|
||||
use crate::core::events::TurnRoute;
|
||||
use crate::hooks::{HookContext, HookEvent, HookExecutor};
|
||||
use crate::pricing::{CostCurrency, CostEstimate};
|
||||
use crate::pricing::CostEstimate;
|
||||
use crate::reasoning_preference::{EffectiveReasoningEffort, ReasoningEffort};
|
||||
use crate::session_manager::{SessionContextReference, SessionMetadata, SessionWorkState};
|
||||
use crate::settings::{InlineDiffMode, Settings};
|
||||
@@ -48,6 +48,7 @@ use crate::tui::shell_key_routing::Focus;
|
||||
use crate::tui::streaming::StreamingState;
|
||||
use crate::tui::transcript::TranscriptViewCache;
|
||||
use crate::tui::views::ViewStack;
|
||||
use codewhale_config::pricing::CostCurrency;
|
||||
use codewhale_localization::{Locale, MessageId, resolve_locale, tr};
|
||||
use codewhale_models::{ContentBlock, Message, SystemPrompt, Tool, Usage};
|
||||
use codewhale_palette::{self as palette, UiTheme};
|
||||
|
||||
@@ -135,7 +135,7 @@ mod tests {
|
||||
|
||||
// Written where every other audit event goes.
|
||||
let home = tempfile::tempdir().expect("tempdir");
|
||||
crate::audit::log_sensitive_event_in(home.path(), "tool.gate.decision", record);
|
||||
codewhale_runtime::audit::log_sensitive_event_in(home.path(), "tool.gate.decision", record);
|
||||
let log = std::fs::read_to_string(home.path().join("audit.log")).expect("audit.log");
|
||||
assert!(log.contains("\"event\":\"tool.gate.decision\""), "{log}");
|
||||
assert!(
|
||||
|
||||
@@ -1687,8 +1687,12 @@ fn model_cost_label(provider: ProviderKind, model: &str) -> String {
|
||||
// DeepSeek bills by time of day (`pricing_withheld` in
|
||||
// catalog_corrections.json), and live roster rows carry no price at all.
|
||||
// Ask pricing.rs before calling the price unknown, as `/model` does.
|
||||
crate::pricing::model_rate_label(provider, model, crate::pricing::CostCurrency::Usd)
|
||||
.map_or(label, |rate| format!("{rate} per 1M"))
|
||||
crate::pricing::model_rate_label(
|
||||
provider,
|
||||
model,
|
||||
codewhale_config::pricing::CostCurrency::Usd,
|
||||
)
|
||||
.map_or(label, |rate| format!("{rate} per 1M"))
|
||||
}
|
||||
|
||||
/// Slice D two-pane picker: `(model, per-model cost, is_default_route)` rows
|
||||
|
||||
@@ -62,7 +62,7 @@ pub(crate) fn test_app_with_options(options: crate::tui::app::TuiOptions) -> cra
|
||||
app.launch.visible = false;
|
||||
|
||||
// Deterministic presentation regardless of host locale.
|
||||
app.cost_currency = crate::pricing::CostCurrency::Usd;
|
||||
app.cost_currency = codewhale_config::pricing::CostCurrency::Usd;
|
||||
app.ui_locale = codewhale_localization::Locale::En;
|
||||
// Transcript tests must not depend on a concurrently swapped settings
|
||||
// home. Tests for hidden reasoning opt out explicitly.
|
||||
|
||||
@@ -46,7 +46,6 @@ use tracing;
|
||||
#[cfg(target_os = "windows")]
|
||||
use windows::Win32::System::Console::{GetConsoleMode, GetStdHandle, SetConsoleMode};
|
||||
|
||||
use crate::audit::log_sensitive_event;
|
||||
use crate::automation_manager::{AutomationManager, AutomationSchedulerConfig, spawn_scheduler};
|
||||
use crate::client::{
|
||||
CACHE_WARMUP_MAX_TOKENS, CacheWarmupKey, CodewhaleClient, PromptInspection,
|
||||
@@ -110,6 +109,7 @@ use crate::work_graph::task_owner_snapshot;
|
||||
use codewhale_localization::{MessageId, tr};
|
||||
use codewhale_models::{ContentBlock, Message, MessageRequest, SystemPrompt, Usage};
|
||||
use codewhale_palette as palette;
|
||||
use codewhale_runtime::audit::log_sensitive_event;
|
||||
// SelectionAutoscroll unused
|
||||
use crate::tui::motion::{FrameRequester, MotionMode};
|
||||
use crate::tui::session_picker::SessionPickerView;
|
||||
|
||||
@@ -556,7 +556,7 @@ fn accept_goal_control(app: &mut App, engine_handle: &EngineHandle, intent: Goal
|
||||
clear: false
|
||||
}
|
||||
) {
|
||||
crate::audit::log_sensitive_event(
|
||||
codewhale_runtime::audit::log_sensitive_event(
|
||||
"goal.user_completed",
|
||||
serde_json::json!({ "accepted": true }),
|
||||
);
|
||||
@@ -3292,7 +3292,7 @@ pub(crate) async fn apply_approval_decision(
|
||||
// tool — approving one shell command must not approve every shell
|
||||
// command for the session (ops R2). The tool name is recorded as
|
||||
// audit evidence, not as a grant.
|
||||
crate::audit::log_sensitive_event(
|
||||
codewhale_runtime::audit::log_sensitive_event(
|
||||
"tool.approval.session_grant",
|
||||
serde_json::json!({
|
||||
"tool_name": event.tool_name,
|
||||
@@ -3763,7 +3763,7 @@ pub(crate) async fn apply_provider_picker_test_connection_with_verifier(
|
||||
&scoped_config,
|
||||
&identity,
|
||||
&model,
|
||||
crate::route_receipt::CredentialGeneration::derive(
|
||||
codewhale_config::credentials::CredentialGeneration::derive(
|
||||
&base_url,
|
||||
&codewhale_secrets::normalize_api_key(&api_key),
|
||||
),
|
||||
@@ -3780,7 +3780,7 @@ pub(crate) async fn apply_provider_picker_test_connection_with_verifier(
|
||||
&scoped_config,
|
||||
&identity,
|
||||
&model,
|
||||
crate::route_receipt::CredentialGeneration::derive(
|
||||
codewhale_config::credentials::CredentialGeneration::derive(
|
||||
&base_url,
|
||||
&codewhale_secrets::normalize_api_key(&api_key),
|
||||
),
|
||||
@@ -3876,7 +3876,7 @@ pub(crate) async fn apply_provider_picker_api_key_with_verifier(
|
||||
&scoped_config,
|
||||
&identity,
|
||||
&verified_model,
|
||||
crate::route_receipt::CredentialGeneration::derive(
|
||||
codewhale_config::credentials::CredentialGeneration::derive(
|
||||
&base_url,
|
||||
&codewhale_secrets::normalize_api_key(&api_key),
|
||||
),
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
//! UI-side approval disposition and durable denial receipts.
|
||||
|
||||
use crate::audit::log_sensitive_event;
|
||||
use crate::core::engine::EngineHandle;
|
||||
use crate::tui::app::{App, StatusToastLevel};
|
||||
use crate::tui::history::HistoryCell;
|
||||
use codewhale_execpolicy::ApprovalMode;
|
||||
use codewhale_localization::MessageId;
|
||||
use codewhale_runtime::audit::log_sensitive_event;
|
||||
|
||||
pub(super) fn is_session_approved_for_tool(
|
||||
app: &App,
|
||||
|
||||
@@ -979,7 +979,9 @@ pub(crate) fn build_engine_config(app: &App, config: &Config) -> EngineConfig {
|
||||
subagents_enabled: identity
|
||||
.is_some_and(|identity| config.subagents_enabled_for_provider(identity)),
|
||||
features: config.features(),
|
||||
auto_review_policy: config.auto_review_policy(),
|
||||
auto_review_policy: crate::core::authority::auto_review::AutoReviewPolicy::from_config(
|
||||
&config,
|
||||
),
|
||||
compaction: app.compaction_config(),
|
||||
todos: app.todos.clone(),
|
||||
plan_state: app.plan_state.clone(),
|
||||
@@ -1004,7 +1006,9 @@ pub(crate) fn build_engine_config(app: &App, config: &Config) -> EngineConfig {
|
||||
max_tool_calls: None,
|
||||
hook_executor: app.runtime_services.hook_executor.clone(),
|
||||
network_policy: config.network.clone().map(|toml_cfg| {
|
||||
crate::network_policy::NetworkPolicyDecider::with_default_audit(toml_cfg.into_runtime())
|
||||
crate::network_policy::NetworkPolicyDecider::with_default_audit(
|
||||
crate::network_policy::NetworkPolicy::from(toml_cfg),
|
||||
)
|
||||
}),
|
||||
snapshots_enabled: config.snapshots_config().enabled,
|
||||
snapshots_max_workspace_bytes: config
|
||||
@@ -1014,10 +1018,7 @@ pub(crate) fn build_engine_config(app: &App, config: &Config) -> EngineConfig {
|
||||
// The TUI records no snapshot receipts; its post-turn snapshot stays
|
||||
// off the input path (#234).
|
||||
record_restore_points: false,
|
||||
lsp_config: config
|
||||
.lsp
|
||||
.clone()
|
||||
.map(crate::config::LspConfigToml::into_runtime),
|
||||
lsp_config: config.lsp.clone().map(crate::lsp::LspConfig::from),
|
||||
runtime_services: app.runtime_services.clone(),
|
||||
subagent_model_overrides: config.subagent_model_overrides(),
|
||||
fleet_roster: std::sync::Arc::new(crate::fleet::identity::load_effective_roster(
|
||||
|
||||
@@ -551,7 +551,9 @@ fn start_mcp_login(app: &mut App, config: &Config, name: String, scopes: Vec<Str
|
||||
let workspace = app.workspace.clone();
|
||||
let plugin_registry = Arc::clone(&app.plugin_registry);
|
||||
let network_policy = config.network.clone().map(|network| {
|
||||
crate::network_policy::NetworkPolicyDecider::with_default_audit(network.into_runtime())
|
||||
crate::network_policy::NetworkPolicyDecider::with_default_audit(
|
||||
crate::network_policy::NetworkPolicy::from(network),
|
||||
)
|
||||
});
|
||||
let callback_port = config.mcp_oauth_callback_port;
|
||||
let callback_url = config.mcp_oauth_callback_url.clone();
|
||||
@@ -1222,7 +1224,9 @@ pub(crate) async fn handle_mcp_ui_action(
|
||||
}
|
||||
} else if discover {
|
||||
let network_policy = config.network.clone().map(|toml_cfg| {
|
||||
crate::network_policy::NetworkPolicyDecider::with_default_audit(toml_cfg.into_runtime())
|
||||
crate::network_policy::NetworkPolicyDecider::with_default_audit(
|
||||
crate::network_policy::NetworkPolicy::from(toml_cfg),
|
||||
)
|
||||
});
|
||||
mcp::discover_manager_snapshot_with_workspace_and_plugins(
|
||||
&path,
|
||||
@@ -1230,7 +1234,7 @@ pub(crate) async fn handle_mcp_ui_action(
|
||||
network_policy,
|
||||
app.mcp_reload_required,
|
||||
std::sync::Arc::clone(&app.plugin_registry),
|
||||
mcp::McpBackend::from_config(config),
|
||||
config.mcp_backend(),
|
||||
)
|
||||
.await
|
||||
.map(|snapshot| (snapshot, None))
|
||||
@@ -1382,7 +1386,7 @@ pub(crate) async fn handle_skill_mutation_requested(
|
||||
let network = cfg
|
||||
.network
|
||||
.clone()
|
||||
.map(|policy| policy.into_runtime())
|
||||
.map(crate::network_policy::NetworkPolicy::from)
|
||||
.unwrap_or_default();
|
||||
let skills_cfg = cfg.skills.as_ref();
|
||||
let max_size = skills_cfg
|
||||
|
||||
@@ -3722,9 +3722,10 @@ impl ConfigView {
|
||||
&& row.scope == ConfigScope::Saved
|
||||
&& let Some(effective_currency) = effective
|
||||
{
|
||||
let saved_cost_currency = crate::pricing::CostCurrency::from_setting(&row.value);
|
||||
let saved_cost_currency =
|
||||
codewhale_config::pricing::CostCurrency::from_setting(&row.value);
|
||||
let effective_cost_currency =
|
||||
crate::pricing::CostCurrency::from_setting(effective_currency);
|
||||
codewhale_config::pricing::CostCurrency::from_setting(effective_currency);
|
||||
if saved_cost_currency != effective_cost_currency {
|
||||
return format!(
|
||||
"{}{}",
|
||||
@@ -3787,8 +3788,8 @@ fn config_base_url_row_value(app: &App) -> String {
|
||||
|
||||
fn cost_currency_config_value(app: &App) -> String {
|
||||
match app.cost_currency {
|
||||
crate::pricing::CostCurrency::Usd => "usd",
|
||||
crate::pricing::CostCurrency::Cny => "cny",
|
||||
codewhale_config::pricing::CostCurrency::Usd => "usd",
|
||||
codewhale_config::pricing::CostCurrency::Cny => "cny",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
@@ -7517,7 +7518,12 @@ mod tests {
|
||||
|
||||
fn cost_currency_row_for_settings(
|
||||
settings_toml: &str,
|
||||
) -> (String, String, crate::pricing::CostCurrency, Locale) {
|
||||
) -> (
|
||||
String,
|
||||
String,
|
||||
codewhale_config::pricing::CostCurrency,
|
||||
Locale,
|
||||
) {
|
||||
let _guard = ConfigSettingsEnvGuard::new(settings_toml);
|
||||
let app = create_test_app();
|
||||
let view = ConfigView::new_for_app(&app);
|
||||
@@ -8738,7 +8744,10 @@ base_url = "https://api.xiaomimimo.com/v1"
|
||||
let _guard = ConfigSettingsEnvGuard::new("locale = \"zh-Hans\"\ncost_currency = \"usd\"\n");
|
||||
let app = create_test_app();
|
||||
assert_eq!(app.ui_locale, Locale::ZhHans);
|
||||
assert_eq!(app.cost_currency, crate::pricing::CostCurrency::Cny);
|
||||
assert_eq!(
|
||||
app.cost_currency,
|
||||
codewhale_config::pricing::CostCurrency::Cny
|
||||
);
|
||||
|
||||
let view = ConfigView::new_for_app(&app);
|
||||
let row = view
|
||||
@@ -8761,7 +8770,10 @@ base_url = "https://api.xiaomimimo.com/v1"
|
||||
));
|
||||
|
||||
assert_eq!(locale, Locale::ZhHans);
|
||||
assert_eq!(effective_currency, crate::pricing::CostCurrency::Cny);
|
||||
assert_eq!(
|
||||
effective_currency,
|
||||
codewhale_config::pricing::CostCurrency::Cny
|
||||
);
|
||||
assert_eq!(saved_value, alias);
|
||||
assert_eq!(display_value, alias);
|
||||
}
|
||||
@@ -8773,7 +8785,10 @@ base_url = "https://api.xiaomimimo.com/v1"
|
||||
cost_currency_row_for_settings("locale = \"zh-Hans\"\ncost_currency = \"cny\"\n");
|
||||
|
||||
assert_eq!(locale, Locale::ZhHans);
|
||||
assert_eq!(effective_currency, crate::pricing::CostCurrency::Cny);
|
||||
assert_eq!(
|
||||
effective_currency,
|
||||
codewhale_config::pricing::CostCurrency::Cny
|
||||
);
|
||||
assert_eq!(saved_value, "cny");
|
||||
assert_eq!(display_value, "cny");
|
||||
}
|
||||
@@ -8784,7 +8799,10 @@ base_url = "https://api.xiaomimimo.com/v1"
|
||||
cost_currency_row_for_settings("locale = \"en\"\ncost_currency = \"cny\"\n");
|
||||
|
||||
assert_eq!(locale, Locale::En);
|
||||
assert_eq!(effective_currency, crate::pricing::CostCurrency::Cny);
|
||||
assert_eq!(
|
||||
effective_currency,
|
||||
codewhale_config::pricing::CostCurrency::Cny
|
||||
);
|
||||
assert_eq!(saved_value, "cny");
|
||||
assert_eq!(display_value, "cny");
|
||||
}
|
||||
|
||||
+8
-54
@@ -1,7 +1,6 @@
|
||||
//! Utility helpers shared across the `DeepSeek` CLI.
|
||||
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -774,53 +773,6 @@ fn is_stray_atomic_write_temp_name(name: &str) -> bool {
|
||||
random.len() == 6 && random.chars().all(|c| c.is_ascii_alphanumeric())
|
||||
}
|
||||
|
||||
/// Open or create a file for appending at `path`, optionally syncing after
|
||||
/// every write. Use this for append-only logs like `audit.log`.
|
||||
///
|
||||
/// The returned `BufWriter<fs::File>` wraps the append handle. Call
|
||||
/// `.flush()` followed by `.get_ref().sync_all()` after each batch.
|
||||
pub fn open_append(path: &Path) -> std::io::Result<std::io::BufWriter<std::fs::File>> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let file = private_log_options().append(true).open(path)?;
|
||||
restrict_to_owner(&file)?;
|
||||
Ok(std::io::BufWriter::new(file))
|
||||
}
|
||||
|
||||
/// Open options for an owner-only log or lock file: created 0600 and never
|
||||
/// opened through a link at the final component (Unix). Callers add the
|
||||
/// access mode they need.
|
||||
pub fn private_log_options() -> std::fs::OpenOptions {
|
||||
let mut options = std::fs::OpenOptions::new();
|
||||
options.create(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.mode(0o600).custom_flags(libc::O_NOFOLLOW);
|
||||
}
|
||||
options
|
||||
}
|
||||
|
||||
/// Tighten a log created by an earlier version at the default mode. No-op
|
||||
/// outside Unix.
|
||||
pub fn restrict_to_owner(file: &std::fs::File) -> std::io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = file;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Flush a `BufWriter` wrapping a `File`, then `fsync` the underlying file.
|
||||
pub fn flush_and_sync(writer: &mut std::io::BufWriter<std::fs::File>) -> std::io::Result<()> {
|
||||
writer.flush()?;
|
||||
writer.get_ref().sync_all()
|
||||
}
|
||||
|
||||
/// Open a URL in the system's default browser.
|
||||
///
|
||||
/// Dispatches to the platform-appropriate opener:
|
||||
@@ -1644,14 +1596,16 @@ mod atomic_write_tests {
|
||||
|
||||
#[test]
|
||||
fn flush_and_sync_writes_and_syncs() {
|
||||
use std::io::Write as _;
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let path = tmp.path().join("append.log");
|
||||
{
|
||||
let mut writer = open_append(&path).expect("open_append");
|
||||
let mut writer =
|
||||
codewhale_runtime::private_log::open_append(&path).expect("open_append");
|
||||
writeln!(writer, "line 1").expect("write");
|
||||
flush_and_sync(&mut writer).expect("flush_and_sync");
|
||||
codewhale_runtime::private_log::flush_and_sync(&mut writer).expect("flush_and_sync");
|
||||
writeln!(writer, "line 2").expect("write");
|
||||
flush_and_sync(&mut writer).expect("flush_and_sync");
|
||||
codewhale_runtime::private_log::flush_and_sync(&mut writer).expect("flush_and_sync");
|
||||
}
|
||||
let content = fs::read_to_string(&path).expect("read");
|
||||
assert_eq!(content, "line 1\nline 2\n");
|
||||
@@ -2187,13 +2141,13 @@ mod project_mapping_tests {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
|
||||
let log = dir.path().join("audit.log");
|
||||
drop(super::open_append(&log).expect("open"));
|
||||
drop(codewhale_runtime::private_log::open_append(&log).expect("open"));
|
||||
let mode = std::fs::metadata(&log).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o600);
|
||||
|
||||
// A log left world-readable by an earlier version is tightened.
|
||||
std::fs::set_permissions(&log, std::fs::Permissions::from_mode(0o644)).unwrap();
|
||||
drop(super::open_append(&log).expect("reopen"));
|
||||
drop(codewhale_runtime::private_log::open_append(&log).expect("reopen"));
|
||||
let mode = std::fs::metadata(&log).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o600);
|
||||
|
||||
@@ -2201,6 +2155,6 @@ mod project_mapping_tests {
|
||||
std::fs::write(&target, "").unwrap();
|
||||
let link = dir.path().join("linked.log");
|
||||
std::os::unix::fs::symlink(&target, &link).unwrap();
|
||||
assert!(super::open_append(&link).is_err());
|
||||
assert!(codewhale_runtime::private_log::open_append(&link).is_err());
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ pub struct ImageAnalyzeTool {
|
||||
/// generation — precisely the healthy work a 120s cap used to kill. The
|
||||
/// client therefore bounds only the connect handshake, and this envelope
|
||||
/// (the same 30-minute wall clock as engine streaming,
|
||||
/// `STREAM_MAX_DURATION_SECS`) is the sole total bound; a stalled
|
||||
/// `DEFAULT_STREAM_MAX_DURATION_SECS`) is the sole total bound; a stalled
|
||||
/// connection errors out through it instead of hanging.
|
||||
const VISION_REQUEST_ENVELOPE: Duration = Duration::from_secs(1800);
|
||||
|
||||
|
||||
@@ -69,19 +69,6 @@ mod mcp {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
/// `network_policy.rs` resolves its audit file through `crate::audit`. The
|
||||
/// harness has no audit module, so it gets a per-process scratch log: like the
|
||||
/// production cfg(test) path (#6534), a test never appends to the real
|
||||
/// `~/.codewhale/audit.log`.
|
||||
mod audit {
|
||||
pub fn audit_log_path() -> Option<std::path::PathBuf> {
|
||||
Some(
|
||||
std::env::temp_dir()
|
||||
.join(format!("codewhale-it-audit-{}", std::process::id()))
|
||||
.join("audit.log"),
|
||||
)
|
||||
}
|
||||
}
|
||||
#[path = "../../src/skills/package_digest.rs"]
|
||||
#[allow(dead_code)]
|
||||
mod package_digest;
|
||||
|
||||
@@ -77,6 +77,9 @@
|
||||
"path_canonicalize": 2,
|
||||
"std_fs": 6
|
||||
},
|
||||
"crates/runtime/src/private_log.rs": {
|
||||
"std_fs": 2
|
||||
},
|
||||
"crates/secrets/src/file_lock.rs": {
|
||||
"path_canonicalize": 1
|
||||
},
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"_comment": "Ceiling for dead-code suppressions across crates/, counting both `#[allow(dead_code)]` and `#[expect(dead_code)]`. `total` is the sum and is the ratcheted figure. It may go down freely; raising it needs a reviewer to say why in the PR. Regenerate with: python3 scripts/check-dead-code-budget.py --update",
|
||||
"_issue": "https://github.com/codewhale-hq/CodeWhale/issues/4785",
|
||||
"_expect_blind_spot": "Until #6241 this gate counted only the `allow` spelling, so rewriting an allow as an expect lowered the number without removing any dead code. The ceiling was re-based to the true combined count when that was fixed; it is not a regression.",
|
||||
"total": 213,
|
||||
"allow_total": 133,
|
||||
"total": 212,
|
||||
"allow_total": 132,
|
||||
"expect_total": 80,
|
||||
"per_crate": {
|
||||
"cli": {
|
||||
@@ -35,7 +35,7 @@
|
||||
"expect": 0
|
||||
},
|
||||
"tui": {
|
||||
"allow": 109,
|
||||
"allow": 108,
|
||||
"expect": 77
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user