Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 1 addition & 11 deletions crates/tracedecay-agent-hosts/src/hooks/claude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -239,17 +239,7 @@ async fn claude_post_tool_use_response(
#[hotpath::measure(future = true, label = "hosts.hooks.claude.stop")]
pub async fn hook_stop(runtime: &HookRuntimeV1) -> i32 {
let started = Instant::now();
let event = match super::read_stdin_bounded() {
Ok(super::HookStdinRead::Event(event)) => event,
Ok(super::HookStdinRead::Oversized) => {
eprintln!(
"tracedecay hook: stdin exceeds wire message bound ({})",
tracedecay_framing::WIRE_RECORD_TOO_LARGE
);
return 1;
}
Err(_) => String::new(),
};
let event = read_hook_event!();
let (root, output) = claude_stop_response_for_event(runtime, &event, started).await;
if !super::write_hook_output(
root.as_deref(),
Expand Down
66 changes: 53 additions & 13 deletions crates/tracedecay-agent-hosts/src/hooks/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -366,19 +366,9 @@ fn hook_output_owner_event_id(

macro_rules! read_hook_event {
() => {{
match $crate::hooks::read_stdin_bounded() {
Ok($crate::hooks::HookStdinRead::Event(event)) => event,
Ok($crate::hooks::HookStdinRead::Oversized) => {
eprintln!(
"tracedecay hook: stdin exceeds wire message bound ({})",
::tracedecay_framing::WIRE_RECORD_TOO_LARGE
);
return 0;
}
Err(e) => {
eprintln!("tracedecay hook: failed to read stdin: {e}");
return 1;
}
match $crate::hooks::take_hook_event($crate::hooks::read_stdin_bounded()) {
Ok(event) => event,
Err(code) => return code,
}
}};
}
Expand Down Expand Up @@ -1209,6 +1199,56 @@ pub(crate) enum HookStdinRead {
Oversized,
}

/// Shared stdin admission for every host hook. Policy lives here so a shell
/// cannot invent a second exit code or continue on an unread event.
pub(crate) enum HookStdinAdmission {
Event(String),
Oversized,
ReadFailed(std::io::Error),
}

pub(crate) fn classify_hook_stdin(read: std::io::Result<HookStdinRead>) -> HookStdinAdmission {
match read {
Ok(HookStdinRead::Event(event)) => HookStdinAdmission::Event(event),
Ok(HookStdinRead::Oversized) => HookStdinAdmission::Oversized,
Err(error) => HookStdinAdmission::ReadFailed(error),
}
}

/// Exit code for a rejected stdin read. `None` means the event was admitted.
pub(crate) fn hook_stdin_exit_code(admission: &HookStdinAdmission) -> Option<i32> {
match admission {
HookStdinAdmission::Event(_) => None,
HookStdinAdmission::Oversized => Some(0),
HookStdinAdmission::ReadFailed(_) => Some(1),
}
}

fn report_rejected_hook_stdin(admission: &HookStdinAdmission) {
match admission {
HookStdinAdmission::Oversized => {
eprintln!(
"tracedecay hook: stdin exceeds wire message bound ({})",
tracedecay_framing::WIRE_RECORD_TOO_LARGE
);
}
HookStdinAdmission::ReadFailed(error) => {
eprintln!("tracedecay hook: failed to read stdin: {error}");
}
HookStdinAdmission::Event(_) => {}
}
}

pub(crate) fn take_hook_event(read: std::io::Result<HookStdinRead>) -> Result<String, i32> {
match classify_hook_stdin(read) {
HookStdinAdmission::Event(event) => Ok(event),
admission => {
report_rejected_hook_stdin(&admission);
Err(hook_stdin_exit_code(&admission).unwrap_or(1))
}
}
}

/// Read host-hook stdin with the host wire message byte cap enforced before
/// whole-body materialization.
pub(crate) fn read_stdin_bounded() -> std::io::Result<HookStdinRead> {
Expand Down
21 changes: 19 additions & 2 deletions crates/tracedecay-agent-hosts/src/hooks/wire_stdin_bound_tests.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
use super::{HookStdinRead, read_stdin_bounded_from};
use std::io::{self, Read};
use std::io::{self, ErrorKind, Read};

use super::{
HookStdinAdmission, HookStdinRead, classify_hook_stdin, hook_stdin_exit_code,
read_stdin_bounded_from,
};
use tracedecay_framing::MAX_WIRE_MESSAGE_BYTES;

struct ChunkedHostileReader {
Expand Down Expand Up @@ -36,6 +40,19 @@ fn hook_stdin_streams_hostile_input_and_returns_oversized_without_payload() {
assert!(hostile.remaining < MAX_WIRE_MESSAGE_BYTES + 512 * 1024);
}

#[test]
fn hook_stdin_admission_is_one_policy() {
let event = classify_hook_stdin(Ok(HookStdinRead::Event("{\"stop\":true}".to_owned())));
assert!(matches!(event, HookStdinAdmission::Event(_)));
assert_eq!(hook_stdin_exit_code(&event), None);

let oversized = classify_hook_stdin(Ok(HookStdinRead::Oversized));
assert_eq!(hook_stdin_exit_code(&oversized), Some(0));

let failed = classify_hook_stdin(Err(io::Error::new(ErrorKind::BrokenPipe, "closed")));
assert_eq!(hook_stdin_exit_code(&failed), Some(1));
}

#[test]
fn hook_stdin_accepts_exact_wire_cap() {
let body = vec![b'a'; MAX_WIRE_MESSAGE_BYTES];
Expand Down
74 changes: 60 additions & 14 deletions crates/tracedecay-api/src/configuration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,29 +115,49 @@ pub fn parse_code_index_worker_settings_patch(
.map_err(|error| patch_shape_error("code index worker settings", &error))
}

/// Validate the transport-owned user patch invariants. The executable supplies
/// the duration parser because profile configuration remains its authority.
pub fn validate_user_settings_patch(
/// User-settings fields after the HTTP boundary has parsed display strings.
///
/// `watcher_debounce_ms` is already a positive millisecond count. Planners
/// write that unsigned value and do not parse the wire string again.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AdmittedUserSettingsPatch {
pub upload_enabled: Option<bool>,
pub watcher_debounce_ms: Option<u64>,
pub extraction_timeout_secs: Option<u64>,
}

/// Parse a user settings patch once. The executable supplies the duration
/// parser because profile configuration remains its authority.
pub fn admit_user_settings_patch(
patch: &UserSettingsPatch,
duration_is_valid: impl Fn(&str) -> bool,
) -> Result<(), DashboardConfigurationRouteErrorV1> {
parse_debounce_millis: impl Fn(&str) -> Option<u64>,
) -> Result<AdmittedUserSettingsPatch, DashboardConfigurationRouteErrorV1> {
let mut errors = Vec::new();
if let Some(debounce) = &patch.watcher_debounce
&& !duration_is_valid(debounce)
{
errors.push(validation_error(
"watcher_debounce",
"watcher_debounce must be a duration like \"2s\", \"15s\", or \"1m\"",
));
}
let watcher_debounce_ms = match patch.watcher_debounce.as_deref() {
Some(debounce) => match parse_debounce_millis(debounce) {
Some(millis) => Some(millis),
None => {
errors.push(validation_error(
"watcher_debounce",
"watcher_debounce must be a duration like \"2s\", \"15s\", or \"1m\"",
));
None
}
},
None => None,
};
if patch.extraction_timeout_secs == Some(0) {
errors.push(validation_error(
"extraction_timeout_secs",
"extraction_timeout_secs must be at least 1 second",
));
}
if errors.is_empty() {
Ok(())
Ok(AdmittedUserSettingsPatch {
upload_enabled: patch.upload_enabled,
watcher_debounce_ms,
extraction_timeout_secs: patch.extraction_timeout_secs,
})
} else {
Err(settings_validation_error(errors))
}
Expand Down Expand Up @@ -289,6 +309,32 @@ mod tests {
assert_eq!(body["validation_errors"][0]["field"], "code_index_workers");
}

#[test]
fn user_settings_patch_parses_debounce_once() {
let patch = parse_user_settings_patch(json!({
"expected_revision_id": "configuration.revision.fixture",
"idempotency_key": "configuration.idempotency.fixture",
"watcher_debounce": "15s",
"extraction_timeout_secs": 60
}))
.unwrap();
let admitted =
admit_user_settings_patch(&patch, |value| (value == "15s").then_some(15_000)).unwrap();
assert_eq!(admitted.watcher_debounce_ms, Some(15_000));
assert_eq!(admitted.extraction_timeout_secs, Some(60));

let invalid = parse_user_settings_patch(json!({
"expected_revision_id": "configuration.revision.fixture",
"idempotency_key": "configuration.idempotency.fixture",
"watcher_debounce": "0s"
}))
.unwrap();
let (status, Json(body)) = admit_user_settings_patch(&invalid, |_| None)
.expect_err("zero duration must be denied");
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["validation_errors"][0]["field"], "watcher_debounce");
}

#[test]
fn project_backed_user_patch_rejects_mixed_profile_worker_mutation() {
let result = parse_user_settings_patch(json!({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ pub async fn analytics_diagnostics_with_db(
let observatory = hotpath::measure_block!("analytics.observatory", {
let observatory =
crate::observability::observatory_read_model(gdb, project_filter.as_deref(), 0).await;
crate::observability::observatory_cli_value(&observatory).map_err(cli_error)?
serde_json::to_value(&observatory).map_err(cli_error)?
});
let provider_scope = if all_projects {
None
Expand All @@ -105,7 +105,7 @@ pub async fn analytics_diagnostics_with_db(
0,
)
.await;
crate::observability::costs_cli_value(&costs).map_err(cli_error)?
serde_json::to_value(&costs).map_err(cli_error)?
});
let event_rows: Vec<Value> = events.iter().map(durable_analytics_event_row).collect();

Expand Down
79 changes: 17 additions & 62 deletions crates/tracedecay-application/src/observability.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,8 @@ mod workflow_emit;

pub use cost_latency::{provider_latency_read_model, unavailable_provider_latency};
pub use costs::{
costs_cli_value, costs_export_bytes, costs_mcp_value, costs_read_model,
costs_read_model_with_provider_usage, costs_read_model_with_provider_usage_and_observability,
costs_unavailable_read_model,
costs_read_model, costs_read_model_with_provider_usage,
costs_read_model_with_provider_usage_and_observability, costs_unavailable_read_model,
};
pub use delivery_recorder::{
BoundedDeliverySettlementRecorderV1, DeliverySettlementRecordOutcomeV1,
Expand Down Expand Up @@ -101,40 +100,6 @@ const ANALYTICS_DESCRIPTOR: &str = "analytics-events.v1";
pub(super) const COST_DESCRIPTOR: &str = "provider-costs.v1";
const FEEDBACK_DESCRIPTOR: &str = "feedback-system-quality.v1";

/// Canonical wire projection used by every dashboard wire surface. Adapters may wrap the
/// value in their transport framing but may not recompute metrics or coverage.
fn canonical_observatory_value(
model: &ObservatoryReadModelV1,
) -> Result<serde_json::Value, serde_json::Error> {
serde_json::to_value(model)
}

pub fn observatory_cli_value(
model: &ObservatoryReadModelV1,
) -> Result<serde_json::Value, serde_json::Error> {
canonical_observatory_value(model)
}

pub fn observatory_mcp_value(
model: &ObservatoryReadModelV1,
) -> Result<serde_json::Value, serde_json::Error> {
canonical_observatory_value(model)
}

pub fn observatory_http_value(
model: &ObservatoryReadModelV1,
) -> Result<serde_json::Value, serde_json::Error> {
canonical_observatory_value(model)
}

/// Bounded public JSON export. It is the same canonical model as interactive
/// surfaces, including absent values, exact denominator, and coverage state.
pub fn observatory_export_bytes(
model: &ObservatoryReadModelV1,
) -> Result<Vec<u8>, serde_json::Error> {
serde_json::to_vec(model)
}

fn coverage(
eligible: Option<u64>,
observed: u64,
Expand Down Expand Up @@ -529,46 +494,36 @@ mod tests {
10,
"fixture_source_unavailable",
);
let cli = observatory_cli_value(&observatory).expect("CLI JSON");
let mcp = observatory_mcp_value(&observatory).expect("MCP JSON");
let http = observatory_http_value(&observatory).expect("HTTP JSON");
let dashboard = serde_json::to_value(&observatory).expect("dashboard payload");
let wire = serde_json::to_value(&observatory).expect("observatory wire");
let export: serde_json::Value =
serde_json::from_slice(&observatory_export_bytes(&observatory).expect("export JSON"))
.expect("decode export JSON");
assert_eq!(cli, mcp);
assert_eq!(cli, http);
assert_eq!(cli, dashboard);
assert_eq!(cli, export);
assert_eq!(cli["metrics"][0]["value"], serde_json::Value::Null);
serde_json::from_slice(&serde_json::to_vec(&observatory).expect("observatory export"))
.expect("decode observatory export");
assert_eq!(wire, export);
assert_eq!(wire["metrics"][0]["value"], serde_json::Value::Null);
assert_eq!(
cli["metrics"][0]["denominator_value"],
wire["metrics"][0]["denominator_value"],
serde_json::Value::Null
);
assert_eq!(cli["metrics"][0]["coverage"]["state"], "unknown");
assert_eq!(wire["metrics"][0]["coverage"]["state"], "unknown");
assert_eq!(
cli["metrics"][0]["unavailable_reason"],
wire["metrics"][0]["unavailable_reason"],
"fixture_source_unavailable"
);

let costs =
costs_unavailable_read_model(Some("scope:parity"), 10, "fixture_cost_unavailable");
let cli = costs_cli_value(&costs).expect("CLI costs JSON");
let mcp = costs_mcp_value(&costs).expect("MCP costs JSON");
let dashboard = serde_json::to_value(&costs).expect("dashboard costs payload");
let wire = serde_json::to_value(&costs).expect("costs wire");
let export: serde_json::Value =
serde_json::from_slice(&costs_export_bytes(&costs).expect("costs export JSON"))
.expect("decode costs export JSON");
assert_eq!(cli, mcp);
assert_eq!(cli, dashboard);
assert_eq!(cli, export);
assert_eq!(cli["usage"][0]["coverage"]["state"], "unknown");
serde_json::from_slice(&serde_json::to_vec(&costs).expect("costs export"))
.expect("decode costs export");
assert_eq!(wire, export);
assert_eq!(wire["usage"][0]["coverage"]["state"], "unknown");
assert_eq!(
cli["usage"][0]["denominator_value"],
wire["usage"][0]["denominator_value"],
serde_json::Value::Null
);
assert_eq!(
cli["usage"][0]["unavailable_reason"],
wire["usage"][0]["unavailable_reason"],
"fixture_cost_unavailable"
);
}
Expand Down
21 changes: 4 additions & 17 deletions crates/tracedecay-application/src/observability/costs.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
//! Canonical Costs read-model composition and wire serialization.
//! Canonical Costs read-model composition.
//!
//! Transport adapters serialize [`CostsReadModelV1`] themselves. This module
//! does not name a CLI, MCP, or HTTP wire.

use tracedecay_contracts::{CostsReadModelV1, MetricCoverageV1, MetricSourceV1, now_micros};
use tracedecay_domain::{CoverageStateV1, ObservationScopeV1};
Expand All @@ -15,22 +18,6 @@ use tracedecay_session_memory::provider_usage::{
price_provider_usage, provider_usage_aggregate,
};

fn canonical_costs_value(model: &CostsReadModelV1) -> Result<serde_json::Value, serde_json::Error> {
serde_json::to_value(model)
}

pub fn costs_cli_value(model: &CostsReadModelV1) -> Result<serde_json::Value, serde_json::Error> {
canonical_costs_value(model)
}

pub fn costs_mcp_value(model: &CostsReadModelV1) -> Result<serde_json::Value, serde_json::Error> {
canonical_costs_value(model)
}

pub fn costs_export_bytes(model: &CostsReadModelV1) -> Result<Vec<u8>, serde_json::Error> {
serde_json::to_vec(model)
}

pub fn costs_unavailable_read_model(
scope_ref: Option<&str>,
since_seconds: i64,
Expand Down
Loading
Loading