From 20dd81a8da54ca06cd9f72352d434bcfd77ce54a Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 27 Sep 2026 00:02:32 +0000 Subject: [PATCH] feat(contracts): carry problem detail through edit, graph, diagnostics The source-edit and graph-tool client adapters flattened a daemon problem into a project-route error before MCP or CLI output, so a missed writer lock deadline reached clients as a sentence without its typed detail, retry directive, or legal actions. Source-edit refusals and detailed graph-tool refusals now render the whole record through the one application result renderer, which moved into tracedecay-mcp. The graph-tool owner maps a lock deadline to its typed detail. The TypeScript diagnostics producers wrote the unowned file, the searched tsconfigs, and the pending producer into message text. They now build diagnostics_unsupported and diagnostics_pending details through ApplicationProblem::from_detail; unsupported problems may carry detail. The adoption census test lists the graph lookup capabilities the retrieval family composes. --- .../src/operation_stream.rs | 1 + .../src/primitives/runtime.rs | 142 ++++++++------- crates/tracedecay-cli/src/application_cli.rs | 1 + crates/tracedecay-cli/src/tool_command.rs | 39 +++-- .../tool_surface_transport_test.rs | 112 +++++++++++- crates/tracedecay-contracts/src/lib.rs | 22 +-- .../src/remote/protocol.rs | 1 + .../src/result/envelope.rs | 2 + crates/tracedecay-contracts/src/result/mod.rs | 2 +- .../src/result/problem.rs | 26 ++- .../src/result/problem_detail.rs | 98 +++++++++++ .../src/retained_surfaces/service.rs | 1 + .../src/adoption_observation.rs | 32 ++++ .../application_surface/operation_events.rs | 1 + .../src/application_surface/problems.rs | 1 + .../src/invocation/git.rs | 1 + .../src/application_output/mod.rs | 1 + .../src/application_output/tool_result.rs | 101 +++++++++++ crates/tracedecay-mcp/src/handlers/edit.rs | 165 ++++++++++++------ crates/tracedecay-sdk/src/codegen.rs | 2 +- .../project_open_owners/advisory_runtime.rs | 1 + .../mcp/tools/handlers/application_surface.rs | 155 ++++++---------- .../src/mcp/tools/handlers/dispatch_groups.rs | 14 +- .../tracedecay/src/mcp/tools/handlers/mod.rs | 1 - .../mcp/tools/handlers/tool_call_support.rs | 6 - crates/tracedecay/tests/common/fixture.rs | 18 +- .../tests/mcp_suite/diagnostics_read_test.rs | 126 ++++++++++++- .../ast_grep_rewrite_behavior_test.rs | 23 ++- .../multi_str_replace_behavior_test.rs | 36 ++-- .../source_edit_reconcile_test.rs | 37 +++- .../source_edit_rollback_test.rs | 68 ++++---- .../str_replace_behavior_test.rs | 73 +++++++- .../schemas/dashboard-contracts.schema.json | 72 ++++++++ dashboard/src/contracts/generated.ts | 15 ++ sdks/typescript/src/client.ts | 26 ++- sdks/typescript/src/types.ts | 2 +- sdks/typescript/test/client.test.ts | 71 ++++++++ 37 files changed, 1152 insertions(+), 343 deletions(-) create mode 100644 crates/tracedecay-mcp/src/application_output/tool_result.rs diff --git a/crates/tracedecay-application/src/operation_stream.rs b/crates/tracedecay-application/src/operation_stream.rs index b1775d6d1e..be14691dfe 100644 --- a/crates/tracedecay-application/src/operation_stream.rs +++ b/crates/tracedecay-application/src/operation_stream.rs @@ -307,6 +307,7 @@ impl OperationEventError { )?, retry: RetryDirective::Never, legal_actions: vec![LegalAction::ContactAdministrator], + detail: None, }, // Genuinely transient: the resume-token authority could not answer. Self::ResumeUnavailable => ApplicationProblem::unavailable(SafeDiagnostic::new( diff --git a/crates/tracedecay-application/src/primitives/runtime.rs b/crates/tracedecay-application/src/primitives/runtime.rs index d42fcfd8d2..929a16dc2a 100644 --- a/crates/tracedecay-application/src/primitives/runtime.rs +++ b/crates/tracedecay-application/src/primitives/runtime.rs @@ -32,12 +32,12 @@ use tracedecay_contracts::{ ApplicationProblem, ApplicationProblemDetailV1, ApplicationProblemEnvelope, ApplicationProblemKind, ApplicationResult, AuthorityReceipt, CancellationContext, CancellationObservation, CancellationStage, CapabilityGrantId, CapabilityGrantSnapshot, - CoverageCompleteness, CoverageDomainState, Deadline, DisclosureClass, EvidenceCoverage, - EvidenceDomain, EvidencePacket, FreshnessState, LegalAction, Omission, OmissionReason, - OpaqueCursor, OperationBudgetUsage, OperationReceipt, OperationTermination, PageCursor, - PageRequest, PageState, PolicyDecisionRef, RequestAdmission, RequestContext, - RequestCostReceiptV1, RequestId, ResolvedScope, RetrievalEvidence, RetryDirective, - SafeDiagnostic, TemporalState, + CoverageCompleteness, CoverageDomainState, Deadline, DiagnosticsSearchedTsconfigV1, + DisclosureClass, EvidenceCoverage, EvidenceDomain, EvidencePacket, FreshnessState, LegalAction, + Omission, OmissionReason, OpaqueCursor, OperationBudgetUsage, OperationReceipt, + OperationTermination, PageCursor, PageRequest, PageState, PolicyDecisionRef, RequestAdmission, + RequestContext, RequestCostReceiptV1, RequestId, ResolvedScope, RetrievalEvidence, + RetryDirective, SafeDiagnostic, TemporalState, }; use tracedecay_domain::text::forward_slash_path; use tracedecay_domain::{CodeGenerationId, CommitId, ComponentVersion, UtcMicros}; @@ -2074,7 +2074,7 @@ fn unpublished_diagnostics_problem( ) -> Result { let problem = match typescript_diagnostics_availability(project_root, file) { TypeScriptDiagnosticsAvailabilityV1::NoTsconfig { searched } => { - no_tsconfig_problem(file, &searched)? + no_tsconfig_problem(file, &searched) } TypeScriptDiagnosticsAvailabilityV1::CompilerMissing { tsconfig, @@ -2087,28 +2087,29 @@ fn unpublished_diagnostics_problem( TypeScriptDiagnosticsAvailabilityV1::Configured { compiler, last_run, .. } => { - let compiler = compiler - .strip_prefix(project_root) - .unwrap_or(&compiler) - .display() - .to_string(); + let compiler = + forward_slash_path(compiler.strip_prefix(project_root).unwrap_or(&compiler)); match last_run { // A read that found no publication while the last run published // is the same pending state: the producer publishes exactly once // per generation, so the current generation's run is what the // caller is waiting on. - None - | Some( - CompilerProducerRunV1::CodeIndexGenerationUnavailable - | CompilerProducerRunV1::Published { .. }, - ) => ApplicationProblem::unavailable(SafeDiagnostic::new( - "application.diagnostics.pending", - format!( - "The TypeScript producer ({compiler}) has not published diagnostics for \ - this project's current generation yet; it runs after the code index \ - seals a complete generation. Retry shortly." - ), - )?), + None | Some(CompilerProducerRunV1::CodeIndexGenerationUnavailable) => { + ApplicationProblem::from_detail( + ApplicationProblemDetailV1::DiagnosticsPending { + producer: compiler, + generation: None, + }, + ) + } + Some(CompilerProducerRunV1::Published { generation, .. }) => { + ApplicationProblem::from_detail( + ApplicationProblemDetailV1::DiagnosticsPending { + producer: compiler, + generation: Some(generation.as_str().to_owned()), + }, + ) + } Some(CompilerProducerRunV1::NoResolvableDiagnostics { unresolved }) => { ApplicationProblem::Unsupported { diagnostic: SafeDiagnostic::new( @@ -2122,6 +2123,7 @@ fn unpublished_diagnostics_problem( )?, retry: RetryDirective::AfterRevalidate, legal_actions: vec![LegalAction::Refresh], + detail: None, } } Some(CompilerProducerRunV1::CompilerFailed { reason }) => { @@ -2167,7 +2169,7 @@ fn unchecked_owner_problem( .extension() .is_some_and(|ext| ["ts", "tsx", "mts", "cts"].iter().any(|ts| ext == *ts)) => { - no_tsconfig_problem(Some(file), &searched)? + no_tsconfig_problem(Some(file), &searched) } TypeScriptDiagnosticsAvailabilityV1::Configured { .. } | TypeScriptDiagnosticsAvailabilityV1::NoTsconfig { .. } => return Ok(None), @@ -2179,45 +2181,16 @@ fn unchecked_owner_problem( /// Nothing runs a compiler automatically, so the route is publishing the /// project's own check through `tracedecay_diagnose`. A file read names every /// tsconfig location the owner search checked. -fn no_tsconfig_problem( - file: Option<&Path>, - searched: &[SearchedTsconfig], -) -> Result { - let reason = match file { - None => "no tsconfig.json was found under the project root".to_owned(), - Some(file) if searched.is_empty() => { - format!("`{}` is outside the project root", file.display()) - } - Some(file) => { - let searched = searched - .iter() - .map(|candidate| { - let path = forward_slash_path(&candidate.path); - if candidate.present { - format!("{path} (does not include it)") - } else { - path - } - }) - .collect::>() - .join(", "); - format!( - "no tsconfig owns `{}` (searched {searched}, and their project references)", - forward_slash_path(file) - ) - } - }; - Ok(ApplicationProblem::Unsupported { - diagnostic: SafeDiagnostic::new( - "application.diagnostics.unsupported", - safe_problem_message(&format!( - "No diagnostic producer is configured for this scope: {reason}, so no compiler \ - runs automatically. Run the project's own build or type check and publish its \ - output with tracedecay_diagnose (`cargo_output`), then read again." - )), - )?, - retry: RetryDirective::Never, - legal_actions: vec![LegalAction::CorrectRequest], +fn no_tsconfig_problem(file: Option<&Path>, searched: &[SearchedTsconfig]) -> ApplicationProblem { + ApplicationProblem::from_detail(ApplicationProblemDetailV1::DiagnosticsUnsupported { + file: file.map(forward_slash_path), + searched: searched + .iter() + .map(|candidate| DiagnosticsSearchedTsconfigV1 { + path: forward_slash_path(&candidate.path), + present: candidate.present, + }) + .collect(), }) } @@ -2239,6 +2212,7 @@ fn compiler_missing_problem( )?, retry: RetryDirective::AfterRevalidate, legal_actions: vec![LegalAction::Refresh], + detail: None, }) } @@ -2253,6 +2227,7 @@ fn producer_failed_problem(reason: &str) -> Result { ApplicationProblem::not_found_or_not_authorized(RetryDirective::Never) diff --git a/crates/tracedecay-cli/src/tool_command.rs b/crates/tracedecay-cli/src/tool_command.rs index ba2edbef86..5b0e90f770 100644 --- a/crates/tracedecay-cli/src/tool_command.rs +++ b/crates/tracedecay-cli/src/tool_command.rs @@ -742,7 +742,7 @@ async fn dispatch_cli_source_edit( let Some(delay) = outcome .as_ref() .err() - .and_then(tracedecay_contracts::ApplicationProblemRecord::owner_mount_resend_delay) + .and_then(|refusal| refusal.problem.problem.owner_mount_resend_delay()) else { break outcome; }; @@ -804,7 +804,7 @@ async fn dispatch_cli_graph_tool( // A cold daemon refuses with the mounting problem while the project open // warms; that refusal precedes admission, so it is re-sent until the CLI // deadline like every other surface. - let completion = loop { + let outcome = loop { let (request_deadline, cancellation) = cli_request_controls(&request_id, deadline)?; let outcome = tracedecay::mcp::tools::execute_graph_tool_surface( tracedecay_tool_catalog::BindingSurface::Cli, @@ -815,25 +815,28 @@ async fn dispatch_cli_graph_tool( Some(request_deadline), Some(cancellation), ) - .await; - let mounting = outcome.as_ref().err().is_some_and(|error| { - error.project_route_context().is_some_and(|(code, _, _)| { - code == tracedecay_contracts::RUNTIME_MOUNTING_REASON_CODE - }) - }); - if !mounting - || deadline.saturating_duration_since(Instant::now()) <= OWNER_MOUNT_RESEND_DELAY - { - break outcome?; + .await?; + let Some(delay) = outcome + .as_ref() + .err() + .and_then(|refusal| refusal.problem.problem.owner_mount_resend_delay()) + else { + break outcome; + }; + if deadline.saturating_duration_since(Instant::now()) <= delay { + break outcome; } - tokio::time::sleep(OWNER_MOUNT_RESEND_DELAY).await; + tokio::time::sleep(delay).await; }; let response_handle_root = cli_response_handle_root(profile, project.as_deref())?; - let mut result = tracedecay_mcp::handlers::graph_tool::render_graph_tool( - response_handle_root.as_deref(), - &tool_args, - completion, - )?; + let mut result = match outcome { + Ok(completion) => tracedecay_mcp::handlers::graph_tool::render_graph_tool( + response_handle_root.as_deref(), + &tool_args, + completion, + )?, + Err(refusal) => refusal.render(response_handle_root.as_deref(), &tool_args)?, + }; account_tool_result(project.as_deref(), &mut result); tracedecay_mcp::tool_errors::mark_semantic_tool_error(&mut result); print_tool_output(&result.value, raw_json); diff --git a/crates/tracedecay-cli/tests/core_cli_suite/tool_surface_transport_test.rs b/crates/tracedecay-cli/tests/core_cli_suite/tool_surface_transport_test.rs index b1c750598a..08ab0fc9df 100644 --- a/crates/tracedecay-cli/tests/core_cli_suite/tool_surface_transport_test.rs +++ b/crates/tracedecay-cli/tests/core_cli_suite/tool_surface_transport_test.rs @@ -15,8 +15,9 @@ use std::process::Stdio; use std::time::{Duration, Instant}; use crate::common::fixture::{ - TYPESCRIPT_FIXTURE_TSC_INVOCATIONS, TYPESCRIPT_MONOREPO_APP_FILE, TypeScriptFixtureCompiler, - write_typescript_diagnostics_fixture, write_typescript_monorepo_diagnostics_fixture, + TYPESCRIPT_FIXTURE_TSC_INVOCATIONS, TYPESCRIPT_FIXTURE_TSC_RELEASE, + TYPESCRIPT_MONOREPO_APP_FILE, TypeScriptFixtureCompiler, write_typescript_diagnostics_fixture, + write_typescript_monorepo_diagnostics_fixture, }; use crate::common::{ canonical_existing_path, git_program, spawn_tracedecay_daemon, tracedecay_command_with_home, @@ -157,11 +158,32 @@ fn run_surface_tool_from( working_directory: &Path, tool: &str, args: &str, +) -> SurfaceOutcome { + run_tool_from(home, working_directory, tool, &["--args", args]) +} + +/// [`run_surface_tool_from`] with `--json`, which prints the canonical +/// envelope. +fn run_surface_tool_json_from( + home: &Path, + working_directory: &Path, + tool: &str, + args: &str, +) -> SurfaceOutcome { + run_tool_from(home, working_directory, tool, &["--args", args, "--json"]) +} + +fn run_tool_from( + home: &Path, + working_directory: &Path, + tool: &str, + tool_args: &[&str], ) -> SurfaceOutcome { let mut command = tracedecay_command_with_home(home); command .current_dir(working_directory) - .args(["tool", tool, "--args", args]) + .args(["tool", tool]) + .args(tool_args) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); @@ -475,6 +497,90 @@ fn tool_diagnostics_names_the_install_command_without_a_compiler() { ); } +/// `--json` carries the typed facts of a scope no tsconfig owns: the file and +/// every location the owner search checked. +#[test] +fn tool_diagnostics_json_carries_the_unowned_scope_detail() { + let (_home, _project, home_path, project_path) = surface_fixture(); + let _daemon = spawn_tracedecay_daemon(&home_path); + + let outcome = run_surface_tool_json_from( + &home_path, + &project_path, + "diagnostics", + r#"{"scope":"file","path":"src/lib.rs"}"#, + ); + assert!(!outcome.success, "stdout:\n{}", outcome.stdout); + let problem = &outcome.payload()["problem"]; + assert_eq!( + (&problem["code"], &problem["detail"]), + ( + &serde_json::json!("application.diagnostics.unsupported"), + &serde_json::json!({ + "kind": "diagnostics_unsupported", + "file": "src/lib.rs", + "searched": [ + {"path": "src/tsconfig.json", "present": false}, + {"path": "tsconfig.json", "present": false}, + ], + }), + ), + "stdout:\n{}\nstderr:\n{}", + outcome.stdout, + outcome.stderr + ); +} + +/// While the project's own compiler is still running, `--json` carries the +/// pending producer and that it has published no generation yet. +#[test] +fn tool_diagnostics_json_carries_the_pending_producer_detail() { + let home = TempDir::new().unwrap(); + let project = TempDir::new().unwrap(); + let home_path = canonical_existing_path(home.path()); + let project_path = canonical_existing_path(project.path()); + init_typescript_project(&home_path, &project_path, |project| { + write_typescript_diagnostics_fixture(project, TypeScriptFixtureCompiler::Held); + }); + let _daemon = spawn_tracedecay_daemon(&home_path); + + let started = Instant::now(); + let pending = loop { + let outcome = run_surface_tool_json_from( + &home_path, + &project_path, + "diagnostics", + r#"{"scope":"file","path":"src/index.ts"}"#, + ); + match outcome.problem_code().as_deref() { + Some("application.diagnostics.pending") => break outcome.payload(), + Some("application.diagnostics.stale") => { + assert!( + started.elapsed() < SURFACE_TIMEOUT, + "the read never reached the pending producer\nstdout:\n{}", + outcome.stdout + ); + std::thread::sleep(Duration::from_millis(250)); + } + code => panic!( + "a held producer can only be stale or pending, got {code:?}\nstdout:\n{}\nstderr:\n{}", + outcome.stdout, outcome.stderr + ), + } + }; + assert_eq!( + pending["problem"]["detail"], + serde_json::json!({ + "kind": "diagnostics_pending", + "producer": "node_modules/.bin/tsc", + "generation": null, + }), + "{pending}" + ); + std::fs::write(project_path.join(TYPESCRIPT_FIXTURE_TSC_RELEASE), "") + .expect("release the held compiler"); +} + const MONOREPO_APP_FILE_ARGS: &str = r#"{"scope":"file","path":"packages/app/src/index.ts","format":"json"}"#; diff --git a/crates/tracedecay-contracts/src/lib.rs b/crates/tracedecay-contracts/src/lib.rs index 624917f979..d21011e776 100644 --- a/crates/tracedecay-contracts/src/lib.rs +++ b/crates/tracedecay-contracts/src/lib.rs @@ -338,17 +338,17 @@ pub use result::{ ApplicationProblemKind, ApplicationProblemRecord, ApplicationResult, ApplicationUnavailableClassV1, AuthorityReceipt, BudgetClass, CancellationObservation, CancellationStage, ContextMemoryAnalyticsV1, CoverageCompleteness, CoverageDomainState, - EffectId, EffectReceipt, EffectResult, EffectTermination, EvidenceAuthority, EvidenceCoverage, - EvidenceDomain, EvidenceIdentity, EvidencePacket, EvidenceScore, EvidenceScoreKind, - EvidenceScoreValue, FreshnessState, IdempotencyKey, InvocationAnalyticsV1, LegalAction, - Omission, OmissionReason, OpaqueCursor, OperationBudgetUsage, OperationReceipt, - OperationTermination, PageCursor, PageState, PolicyDecisionRef, PrContextAnalyticsV1, - PrContextStageTimingsV1, PreviewId, PreviewResult, ProblemOwningLayer, ProblemTerminality, - RUNTIME_MOUNTING_REASON_CODE, ReconciliationState, RequestCostReceiptV1, ResultContractRef, - ResumeToken, RetrievalEvidence, RetrieverContribution, RetrieverContributionState, - RetryDirective, RetryScope, SafeDiagnostic, ScoreId, StorePointReadsV1, StreamEvent, - StreamEventKind, StreamFrontier, StreamGap, StreamTermination, StreamValidationError, - TemporalState, validate_stream, + DiagnosticsSearchedTsconfigV1, EffectId, EffectReceipt, EffectResult, EffectTermination, + EvidenceAuthority, EvidenceCoverage, EvidenceDomain, EvidenceIdentity, EvidencePacket, + EvidenceScore, EvidenceScoreKind, EvidenceScoreValue, FreshnessState, IdempotencyKey, + InvocationAnalyticsV1, LegalAction, Omission, OmissionReason, OpaqueCursor, + OperationBudgetUsage, OperationReceipt, OperationTermination, PageCursor, PageState, + PolicyDecisionRef, PrContextAnalyticsV1, PrContextStageTimingsV1, PreviewId, PreviewResult, + ProblemOwningLayer, ProblemTerminality, RUNTIME_MOUNTING_REASON_CODE, ReconciliationState, + RequestCostReceiptV1, ResultContractRef, ResumeToken, RetrievalEvidence, RetrieverContribution, + RetrieverContributionState, RetryDirective, RetryScope, SafeDiagnostic, ScoreId, + StorePointReadsV1, StreamEvent, StreamEventKind, StreamFrontier, StreamGap, StreamTermination, + StreamValidationError, TemporalState, validate_stream, }; pub use retained_receipts::{ PreparedRetainedEffect, authority_receipt, effective_memory_deadline, evidence_outcome, diff --git a/crates/tracedecay-contracts/src/remote/protocol.rs b/crates/tracedecay-contracts/src/remote/protocol.rs index 4f6b19f250..7a016b5252 100644 --- a/crates/tracedecay-contracts/src/remote/protocol.rs +++ b/crates/tracedecay-contracts/src/remote/protocol.rs @@ -541,6 +541,7 @@ pub fn remote_protocol_problem( )?, retry: RetryDirective::AfterRevalidate, legal_actions: vec![LegalAction::Refresh], + detail: None, }, RemoteProtocolFailureV1::InsufficientCapability | RemoteProtocolFailureV1::ScopeMismatch => { diff --git a/crates/tracedecay-contracts/src/result/envelope.rs b/crates/tracedecay-contracts/src/result/envelope.rs index 91ba79eaba..10730a390c 100644 --- a/crates/tracedecay-contracts/src/result/envelope.rs +++ b/crates/tracedecay-contracts/src/result/envelope.rs @@ -272,6 +272,7 @@ impl<'de> Deserialize<'de> for ApplicationProblemRecord { let detailed_kind = matches!( wire.kind, ApplicationProblemKind::Stale + | ApplicationProblemKind::Unsupported | ApplicationProblemKind::Unavailable | ApplicationProblemKind::ResetRequired | ApplicationProblemKind::Saturated @@ -325,6 +326,7 @@ impl<'de> Deserialize<'de> for ApplicationProblemRecord { diagnostic, retry: wire.retry, legal_actions: legal_actions.clone(), + detail: detail.clone().map(Box::new), } } (ApplicationProblemKind::Unavailable, Some(diagnostic), None) => { diff --git a/crates/tracedecay-contracts/src/result/mod.rs b/crates/tracedecay-contracts/src/result/mod.rs index cac16ea3d5..ab58d77dda 100644 --- a/crates/tracedecay-contracts/src/result/mod.rs +++ b/crates/tracedecay-contracts/src/result/mod.rs @@ -26,7 +26,7 @@ pub use problem::{ ApplicationUnavailableClassV1, LegalAction, ProblemOwningLayer, ProblemTerminality, RUNTIME_MOUNTING_REASON_CODE, RetryDirective, RetryScope, SafeDiagnostic, }; -pub use problem_detail::ApplicationProblemDetailV1; +pub use problem_detail::{ApplicationProblemDetailV1, DiagnosticsSearchedTsconfigV1}; pub use receipt::{ CancellationObservation, CancellationStage, EffectId, EffectReceipt, EffectResult, EffectTermination, IdempotencyKey, OperationBudgetUsage, OperationReceipt, diff --git a/crates/tracedecay-contracts/src/result/problem.rs b/crates/tracedecay-contracts/src/result/problem.rs index 0b0956509b..006bb59ef6 100644 --- a/crates/tracedecay-contracts/src/result/problem.rs +++ b/crates/tracedecay-contracts/src/result/problem.rs @@ -195,6 +195,7 @@ pub enum ApplicationProblem { diagnostic: SafeDiagnostic, retry: RetryDirective, legal_actions: Vec, + detail: Option>, }, Unavailable { classification: ApplicationUnavailableClassV1, @@ -267,6 +268,8 @@ enum ApplicationProblemWire { diagnostic: SafeDiagnostic, retry: RetryDirective, legal_actions: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + detail: Option>, }, Unavailable { classification: ApplicationUnavailableClassV1, @@ -362,10 +365,12 @@ impl From for ApplicationProblemWire { diagnostic, retry, legal_actions, + detail, } => Self::Unsupported { diagnostic, retry, legal_actions, + detail, }, ApplicationProblem::Unavailable { classification, @@ -509,10 +514,12 @@ impl ApplicationProblem { diagnostic, retry, legal_actions, + detail, } => Self::Unsupported { diagnostic, retry, legal_actions, + detail, }, ApplicationProblemWire::Unavailable { classification, @@ -866,7 +873,9 @@ impl ApplicationProblem { /// A parked code index cannot answer until the operator applies the /// park's remedy, so it is never retried and names reconcile. A stale /// refresh frontier is revalidated from the committed frontier. A lock - /// deadline is capacity: the same request may succeed after a delay. + /// deadline is capacity: the same request may succeed after a delay. A + /// diagnostics scope no compiler owns is routed to publishing the + /// project's own check; a pending producer answers after a delay. pub fn from_detail(detail: ApplicationProblemDetailV1) -> Self { let diagnostic = SafeDiagnostic { code: detail.code().to_owned(), @@ -898,12 +907,26 @@ impl ApplicationProblem { legal_actions: vec![LegalAction::Reset], detail: Some(Box::new(detail)), }, + ApplicationProblemDetailV1::DiagnosticsUnsupported { .. } => Self::Unsupported { + diagnostic, + retry: RetryDirective::Never, + legal_actions: vec![LegalAction::CorrectRequest], + detail: Some(Box::new(detail)), + }, + ApplicationProblemDetailV1::DiagnosticsPending { .. } => Self::Unavailable { + classification: ApplicationUnavailableClassV1::Authority, + diagnostic, + retry: RetryDirective::AfterDelay, + legal_actions: vec![LegalAction::Retry], + detail: Some(Box::new(detail)), + }, } } pub fn detail(&self) -> Option<&ApplicationProblemDetailV1> { match self { Self::Stale { detail, .. } + | Self::Unsupported { detail, .. } | Self::Unavailable { detail, .. } | Self::ResetRequired { detail, .. } | Self::Saturated { detail, .. } => detail.as_deref(), @@ -911,7 +934,6 @@ impl ApplicationProblem { | Self::NotFoundOrNotAuthorized { .. } | Self::Conflict { .. } | Self::PartialEffect { .. } - | Self::Unsupported { .. } | Self::ExecutionFailed { .. } | Self::Cancelled { .. } | Self::TimedOut { .. } => None, diff --git a/crates/tracedecay-contracts/src/result/problem_detail.rs b/crates/tracedecay-contracts/src/result/problem_detail.rs index eb04ce1a30..a02d736689 100644 --- a/crates/tracedecay-contracts/src/result/problem_detail.rs +++ b/crates/tracedecay-contracts/src/result/problem_detail.rs @@ -37,6 +37,33 @@ pub enum ApplicationProblemDetailV1 { reason: String, remedy: String, }, + /// No compiler runs automatically for the diagnostics scope: no tsconfig + /// owns `file`, or, for a workspace read (`file` null), none exists under + /// the project root. `searched` lists the owner search's candidates, + /// nearest first; it is empty for a workspace read or a file outside the + /// project root. + DiagnosticsUnsupported { + file: Option, + searched: Vec, + }, + /// The TypeScript `producer` has not published diagnostics for the + /// current code generation yet. `generation` is the last generation it + /// published, null when it has published none. + DiagnosticsPending { + producer: String, + generation: Option, + }, +} + +/// One tsconfig location the diagnostics owner search checked. +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct DiagnosticsSearchedTsconfigV1 { + /// Relative to the project root, forward-slash separated. + pub path: String, + /// The config exists, but neither it nor its references include the + /// file. + pub present: bool, } impl ApplicationProblemDetailV1 { @@ -89,6 +116,8 @@ impl ApplicationProblemDetailV1 { Self::StaleRefreshFrontier { .. } => "application.retained.refresh-frontier-stale", Self::LockDeadline { .. } => "application.lock-deadline", Self::ResetRequired { .. } => "application.reset-required", + Self::DiagnosticsUnsupported { .. } => "application.diagnostics.unsupported", + Self::DiagnosticsPending { .. } => "application.diagnostics.pending", } } @@ -118,6 +147,37 @@ impl ApplicationProblemDetailV1 { remedy, .. } => format!("The {authority} requires an explicit reset with `{remedy}`: {reason}"), + Self::DiagnosticsUnsupported { file, searched } => { + let reason = match file { + None => "no tsconfig.json was found under the project root".to_owned(), + Some(file) if searched.is_empty() => { + format!("`{file}` is outside the project root") + } + Some(file) => format!( + "no tsconfig owns `{file}` (searched {}, and their project references)", + searched_list(searched) + ), + }; + format!( + "No diagnostic producer is configured for this scope: {reason}, so no \ + compiler runs automatically. Run the project's own build or type check and \ + publish its output with tracedecay_diagnose (`cargo_output`), then read \ + again." + ) + } + Self::DiagnosticsPending { + producer, + generation, + } => { + let last = generation.as_ref().map_or_else(String::new, |generation| { + format!(" (its last publication was generation {generation})") + }); + format!( + "The TypeScript producer ({producer}) has not published diagnostics for this \ + project's current generation yet{last}; it runs after the code index seals \ + a complete generation. Retry shortly." + ) + } }; let folded = tracedecay_domain::fold_control_characters(&text); tracedecay_domain::utf8_prefix_at_or_before(folded.trim(), MAX_RENDERED_MESSAGE_BYTES) @@ -175,6 +235,44 @@ impl ApplicationProblemDetailV1 { fields.push(("Reset remedy", remedy.clone())); fields } + Self::DiagnosticsUnsupported { file, searched } => vec![ + ( + "Diagnostics file", + file.clone().unwrap_or_else(|| "workspace".to_owned()), + ), + ( + "Searched tsconfigs", + if searched.is_empty() { + "none".to_owned() + } else { + searched_list(searched) + }, + ), + ], + Self::DiagnosticsPending { + producer, + generation, + } => vec![ + ("Diagnostics producer", producer.clone()), + ( + "Last published generation", + generation.clone().unwrap_or_else(|| "none".to_owned()), + ), + ], } } } + +fn searched_list(searched: &[DiagnosticsSearchedTsconfigV1]) -> String { + searched + .iter() + .map(|candidate| { + if candidate.present { + format!("{} (does not include it)", candidate.path) + } else { + candidate.path.clone() + } + }) + .collect::>() + .join(", ") +} diff --git a/crates/tracedecay-contracts/src/retained_surfaces/service.rs b/crates/tracedecay-contracts/src/retained_surfaces/service.rs index 691c004e32..f605357dbb 100644 --- a/crates/tracedecay-contracts/src/retained_surfaces/service.rs +++ b/crates/tracedecay-contracts/src/retained_surfaces/service.rs @@ -558,6 +558,7 @@ pub fn retained_surface_execution_problem( ), retry: RetryDirective::Never, legal_actions: vec![LegalAction::CorrectRequest], + detail: None, }, RetainedSurfaceExecutionErrorV1::Saturated => ApplicationProblem::saturated( "application.retained.saturated", diff --git a/crates/tracedecay-daemon-service/src/adoption_observation.rs b/crates/tracedecay-daemon-service/src/adoption_observation.rs index 13c2594e31..9c34b9b9e6 100644 --- a/crates/tracedecay-daemon-service/src/adoption_observation.rs +++ b/crates/tracedecay-daemon-service/src/adoption_observation.rs @@ -312,7 +312,23 @@ mod tests { "capability.application.code-query.type-definition", "available", ), + ("capability.application.primitive.affected", "available"), + ( + "capability.application.primitive.ast-grep-search", + "available", + ), + ("capability.application.primitive.branch-diff", "available"), + ("capability.application.primitive.branch-list", "available"), + ( + "capability.application.primitive.branch-search", + "available", + ), + ( + "capability.application.primitive.by-qualified-name", + "available", + ), ("capability.application.primitive.call-chain", "available"), + ("capability.application.primitive.changelog", "available"), ("capability.application.primitive.circular", "available"), ("capability.application.primitive.code-callers", "available"), ( @@ -327,7 +343,12 @@ mod tests { "capability.application.primitive.code-type-hierarchy", "available", ), + ( + "capability.application.primitive.commit-context", + "available", + ), ("capability.application.primitive.complexity", "available"), + ("capability.application.primitive.config", "available"), ("capability.application.primitive.constructors", "available"), ("capability.application.primitive.context", "available"), ("capability.application.primitive.coupling", "available"), @@ -336,21 +357,29 @@ mod tests { "capability.application.primitive.dependency-depth", "available", ), + ("capability.application.primitive.derives", "available"), ("capability.application.primitive.diagnose", "available"), ( "capability.application.primitive.diagnostics-read", "available", ), + ("capability.application.primitive.diff-context", "available"), ("capability.application.primitive.distribution", "available"), ("capability.application.primitive.doc-coverage", "available"), ("capability.application.primitive.dsm", "available"), ("capability.application.primitive.field-sites", "available"), + ("capability.application.primitive.files", "available"), ( "capability.application.primitive.file-dependents", "available", ), + ( + "capability.application.primitive.find-exact-symbol", + "available", + ), ("capability.application.primitive.gini", "available"), ("capability.application.primitive.god-class", "available"), + ("capability.application.primitive.grep", "available"), ("capability.application.primitive.health", "available"), ("capability.application.primitive.health-delta", "available"), ("capability.application.primitive.health-read", "available"), @@ -365,6 +394,7 @@ mod tests { ("capability.application.primitive.node", "available"), ("capability.application.primitive.port-order", "available"), ("capability.application.primitive.port-status", "available"), + ("capability.application.primitive.pr-context", "available"), ( "capability.application.primitive.qualified-name", "available", @@ -372,6 +402,7 @@ mod tests { ("capability.application.primitive.rank", "available"), ("capability.application.primitive.recursion", "available"), ("capability.application.primitive.redundancy", "available"), + ("capability.application.primitive.retrieve", "available"), ( "capability.application.primitive.rename-preview", "available", @@ -380,6 +411,7 @@ mod tests { "capability.application.primitive.session-lookup", "available", ), + ("capability.application.primitive.signature", "available"), ("capability.application.primitive.similar", "available"), ("capability.application.primitive.source-body", "available"), ("capability.application.primitive.source-lines", "available"), diff --git a/crates/tracedecay-daemon-service/src/application_surface/operation_events.rs b/crates/tracedecay-daemon-service/src/application_surface/operation_events.rs index 668fbe5976..f7cedc8313 100644 --- a/crates/tracedecay-daemon-service/src/application_surface/operation_events.rs +++ b/crates/tracedecay-daemon-service/src/application_surface/operation_events.rs @@ -879,6 +879,7 @@ pub(super) fn operation_event_problem( }, retry: RetryDirective::Never, legal_actions: vec![LegalAction::ContactAdministrator], + detail: None, }, // Genuinely transient: the resume-token authority could not answer. OperationEventError::ResumeUnavailable => ApplicationProblem::unavailable(SafeDiagnostic { diff --git a/crates/tracedecay-daemon-service/src/application_surface/problems.rs b/crates/tracedecay-daemon-service/src/application_surface/problems.rs index 0dbb7ae783..e4332074b1 100644 --- a/crates/tracedecay-daemon-service/src/application_surface/problems.rs +++ b/crates/tracedecay-daemon-service/src/application_surface/problems.rs @@ -91,6 +91,7 @@ pub(super) fn http_adapter_problem( }, retry: RetryDirective::Never, legal_actions: vec![LegalAction::ContactAdministrator], + detail: None, }, // Genuinely transient: the owning daemon transport is not answering. ApplicationSurfaceAdapterError::DaemonUnavailable => { diff --git a/crates/tracedecay-daemon-service/src/invocation/git.rs b/crates/tracedecay-daemon-service/src/invocation/git.rs index 008a9f3d8f..0c44392a36 100644 --- a/crates/tracedecay-daemon-service/src/invocation/git.rs +++ b/crates/tracedecay-daemon-service/src/invocation/git.rs @@ -1107,6 +1107,7 @@ fn map_git_port_problem(error: GitIndexTransactionPortError) -> ApplicationProbl }, retry: RetryDirective::AfterRevalidate, legal_actions: Vec::new(), + detail: None, }, GitIndexTransactionPortError::DaemonUnavailable | GitIndexTransactionPortError::RecoveryRequired diff --git a/crates/tracedecay-mcp/src/application_output/mod.rs b/crates/tracedecay-mcp/src/application_output/mod.rs index f55fcb73bc..6193016a57 100644 --- a/crates/tracedecay-mcp/src/application_output/mod.rs +++ b/crates/tracedecay-mcp/src/application_output/mod.rs @@ -2,4 +2,5 @@ pub mod json; pub mod markdown; +pub mod tool_result; pub mod view; diff --git a/crates/tracedecay-mcp/src/application_output/tool_result.rs b/crates/tracedecay-mcp/src/application_output/tool_result.rs new file mode 100644 index 0000000000..d49b7f8953 --- /dev/null +++ b/crates/tracedecay-mcp/src/application_output/tool_result.rs @@ -0,0 +1,101 @@ +//! The one tool-result rendering of a settled application call, shared by +//! every MCP tool family and the `tracedecay tool` CLI. + +use std::path::Path; + +use serde_json::Value; +use tracedecay_contracts::{ApplicationProblemEnvelope, ApplicationProblemKind, ApplicationResult}; +use tracedecay_daemon_protocol::{RequestedOutputFormat, requested_output_format}; +use tracedecay_domain::errors::Result; +use tracedecay_tool_catalog::{ApplicationSurfaceOperation, BindingId}; + +use super::markdown; +use super::view::CanonicalHumanView; +use crate::ToolResult; +use crate::handlers::support::text_tool_result; +use crate::tools::render::finalize_with_format; +use crate::tools::response_trailers::ResponseTrailer; + +/// A daemon's typed refusal of one application call. The whole problem +/// record travels so every surface renders its detail, retry directive, and +/// legal actions rather than a reason code and a sentence. +#[derive(Clone, Debug)] +pub struct ApplicationRefusal { + pub operation: ApplicationSurfaceOperation, + pub binding_id: BindingId, + pub problem: ApplicationProblemEnvelope, +} + +impl ApplicationRefusal { + /// Renders the refusal in the output format `args` requested. + pub fn render(self, response_handle_root: Option<&Path>, args: &Value) -> Result { + render_application_result( + response_handle_root, + self.operation.as_str(), + &self.binding_id, + &Err(self.problem), + requested_output_format(args), + ) + } +} + +/// Renders one settled application call. A problem is a semantic failure +/// whose whole record rides beside the text as `problem`, which MCP carries +/// as structured content and `--json` prints with the result. +pub fn render_application_result( + response_handle_root: Option<&Path>, + operation: &str, + binding_id: &BindingId, + result: &ApplicationResult, + requested_format: RequestedOutputFormat, +) -> Result { + let (value, failure_message) = match result { + Ok(application) => (serde_json::to_value(application)?, None), + Err(problem) => { + let failure_message = match problem.problem.kind() { + ApplicationProblemKind::NotFoundOrNotAuthorized => { + "application surface was not found or is not authorized" + } + ApplicationProblemKind::Unavailable => "application surface unavailable", + _ => "application surface request failed", + }; + (serde_json::to_value(problem)?, Some(failure_message)) + } + }; + let markdown = match requested_format { + RequestedOutputFormat::Json => None, + RequestedOutputFormat::Markdown => { + let view = CanonicalHumanView::from_application_result(operation, binding_id, result)?; + Some(markdown::render(view).as_str().to_owned()) + } + }; + let text = finalize_with_format(response_handle_root, requested_format, &value, || { + markdown.unwrap_or_default() + }); + let mut rendered = text_tool_result(&text, Vec::new()); + match result { + Ok(envelope) => ResponseTrailer { + touched_files: &envelope.touched_files, + code_graph: envelope.code_graph.as_ref(), + cost: envelope.cost.as_ref(), + } + .attach(&mut rendered), + // Markdown alone would strand the problem in prose no client can + // classify; the legal actions, retry directive, detail, and any + // committed receipt are what a caller acts on. + Err(problem) => { + if let Some(object) = rendered.value.as_object_mut() { + object.insert( + "problem".to_string(), + serde_json::to_value(problem.problem.as_ref())?, + ); + } + } + } + Ok(match failure_message { + Some(failure_message) => rendered + .with_semantic_error(true) + .with_failure_message(failure_message), + None => rendered, + }) +} diff --git a/crates/tracedecay-mcp/src/handlers/edit.rs b/crates/tracedecay-mcp/src/handlers/edit.rs index 8fdd6355e0..42f465523b 100644 --- a/crates/tracedecay-mcp/src/handlers/edit.rs +++ b/crates/tracedecay-mcp/src/handlers/edit.rs @@ -1,17 +1,17 @@ //! Source-edit tools served through the canonical application surface. //! //! Every transport decodes the arguments with the shared source-edit decoder, -//! dispatches the typed invocation, and renders the result as these tools -//! always have: typed refusals stay project-route errors, and a completed -//! edit reports its touched files and failure message. +//! dispatches the typed invocation, and renders the result: a typed refusal +//! renders its whole problem record, and a completed edit reports its touched +//! files and failure message. use std::path::Path; use serde_json::Value; use tracedecay_contracts::source_edit::SourceEditSurfaceResultV1; use tracedecay_contracts::{ - ApplicationOutcome, ApplicationProblem, ApplicationProblemRecord, CancellationSignal, Deadline, - InvocationTarget, PageRequest, RequestId, SafeDiagnostic, + ApplicationOutcome, ApplicationProblem, CancellationSignal, Deadline, InvocationTarget, + PageRequest, RequestId, SafeDiagnostic, }; use tracedecay_daemon_protocol::{ ApplicationSurfaceAdapterError, DaemonInvocationExecutor, RequestedOutputFormat, @@ -24,6 +24,7 @@ use tracedecay_domain::errors::{Result, TraceDecayError}; use tracedecay_tool_catalog::{ApplicationSurfaceOperation, BindingSurface}; use crate::ToolResult; +use crate::application_output::tool_result::ApplicationRefusal; use crate::handlers::{generic_tool_result, rendered_tool_result}; use crate::tools::render; @@ -66,8 +67,7 @@ fn adapter_error(error: ApplicationSurfaceAdapterError) -> TraceDecayError { /// A completed source-edit invocation: the typed result, or the daemon's /// typed refusal. -pub type SourceEditOutcome = - std::result::Result; +pub type SourceEditOutcome = std::result::Result; /// Run one source-edit tool on `surface` and render its tool result. #[hotpath::measure(label = "mcp.edit.total", future = true)] @@ -118,6 +118,7 @@ pub async fn run_source_edit( ) .map_err(adapter_error)?; dispatched.invocation.invocation.scope = target; + let binding_id = dispatched.invocation.binding_id.clone(); let result = hotpath::future!( execute_application_surface(operation, dispatched, Some(executor)), label = "mcp.edit.execute" @@ -126,7 +127,13 @@ pub async fn run_source_edit( .map_err(adapter_error)?; let envelope = match result.result { Ok(envelope) => envelope, - Err(problem) => return Ok(Err(*problem.problem)), + Err(problem) => { + return Ok(Err(ApplicationRefusal { + operation, + binding_id, + problem, + })); + } }; let ApplicationOutcome::Result(value) = envelope.outcome else { return Err(unexpected_outcome()); @@ -136,15 +143,18 @@ pub async fn run_source_edit( .map_err(|_| unexpected_outcome()) } -/// Render a settled source edit as the edit tools always have. +/// Render a settled source edit: its result, or the daemon's whole problem +/// record. pub fn render_source_edit_outcome( response_handle_root: Option<&Path>, operation: ApplicationSurfaceOperation, args: &Value, outcome: SourceEditOutcome, ) -> Result { - let result = outcome.map_err(|problem| problem.into_source().into_trace_decay_error())?; - render_source_edit_result(response_handle_root, operation, args, &result) + match outcome { + Ok(result) => render_source_edit_result(response_handle_root, operation, args, &result), + Err(refusal) => refusal.render(response_handle_root, args), + } } fn unexpected_outcome() -> TraceDecayError { @@ -467,12 +477,12 @@ mod tests { } } - async fn source_edit_refusal( - outcome: DaemonInvocationOutcome, - ) -> tracedecay_domain::errors::TraceDecayError { + /// The problem record a refused `str_replace` renders beside its text, + /// with `isError` set. + async fn source_edit_refusal(outcome: DaemonInvocationOutcome) -> Value { let project = tempdir().unwrap(); let executor = RefusingSourceEditExecutor { outcome }; - source_edit_tool( + let result = source_edit_tool( Some(project.path()), BindingSurface::Mcp, ApplicationSurfaceOperation::StrReplace, @@ -480,25 +490,40 @@ mod tests { invocation_context(Some(&executor)), ) .await - .expect_err("refused source edit must stay a typed failure") + .expect("a refused source edit renders as a tool result"); + assert_eq!(result.semantic_error(), Some(true), "{}", result.value); + result.value["problem"].clone() + } + + fn problem_summary(problem: &Value) -> Value { + json!({ + "kind": problem["kind"], + "code": problem["code"], + "retryable": problem["retryable"], + "legal_actions": problem["legal_actions"], + }) } #[tokio::test] async fn denied_source_edit_preserves_reason_code_and_is_not_retryable() { - let error = source_edit_refusal(DaemonInvocationOutcome::ApplicationProblem { + let problem = source_edit_refusal(DaemonInvocationOutcome::ApplicationProblem { problem: ApplicationProblem::not_found_or_not_authorized(RetryDirective::Never), }) .await; - let (reason_code, retryable, _) = error - .project_route_context() - .expect("denial must stay a typed project-route error"); - assert_eq!(reason_code, "not_found_or_not_authorized"); - assert!(!retryable); + assert_eq!( + problem_summary(&problem), + json!({ + "kind": "not_found_or_not_authorized", + "code": "not_found_or_not_authorized", + "retryable": false, + "legal_actions": [], + }) + ); } #[tokio::test] async fn warming_source_edit_gate_is_retryable_unavailable() { - let error = source_edit_refusal(DaemonInvocationOutcome::ApplicationProblem { + let problem = source_edit_refusal(DaemonInvocationOutcome::ApplicationProblem { problem: ApplicationProblem::unavailable( SafeDiagnostic::new( tracedecay_contracts::RUNTIME_MOUNTING_REASON_CODE, @@ -508,19 +533,20 @@ mod tests { ), }) .await; - let (reason_code, retryable, _) = error - .project_route_context() - .expect("warming must stay a typed project-route error"); assert_eq!( - reason_code, - tracedecay_contracts::RUNTIME_MOUNTING_REASON_CODE + problem_summary(&problem), + json!({ + "kind": "unavailable", + "code": tracedecay_contracts::RUNTIME_MOUNTING_REASON_CODE, + "retryable": true, + "legal_actions": ["retry"], + }) ); - assert!(retryable); } #[tokio::test] async fn kernel_digest_mismatch_reaches_mcp_with_reason_code_and_retryability() { - let error = source_edit_refusal(DaemonInvocationOutcome::ApplicationProblem { + let problem = source_edit_refusal(DaemonInvocationOutcome::ApplicationProblem { problem: ApplicationProblem::stale( SafeDiagnostic::new( "source_edit.expected_state_mismatch", @@ -530,43 +556,82 @@ mod tests { ), }) .await; - let (reason_code, retryable, _) = error - .project_route_context() - .expect("digest mismatch must stay a typed project-route error"); - assert_eq!(reason_code, "source_edit.expected_state_mismatch"); - assert!(retryable); - assert_ne!(reason_code, "not_found_or_not_authorized"); + assert_eq!( + problem_summary(&problem), + json!({ + "kind": "stale", + "code": "source_edit.expected_state_mismatch", + "retryable": true, + "legal_actions": ["refresh"], + }) + ); } #[tokio::test] async fn kernel_conflict_reaches_mcp_with_reason_code_and_retryability() { - let error = source_edit_refusal(DaemonInvocationOutcome::ApplicationProblem { + let problem = source_edit_refusal(DaemonInvocationOutcome::ApplicationProblem { problem: ApplicationProblem::conflict( "source_edit.idempotency_conflict", "source edit idempotency key conflicts with a prior input", ), }) .await; - let (reason_code, retryable, _) = error - .project_route_context() - .expect("idempotency conflict must stay a typed project-route error"); - assert_eq!(reason_code, "source_edit.idempotency_conflict"); - assert!(retryable); - assert_ne!(reason_code, "not_found_or_not_authorized"); + assert_eq!( + problem_summary(&problem), + json!({ + "kind": "conflict", + "code": "source_edit.idempotency_conflict", + "retryable": true, + "legal_actions": ["refresh"], + }) + ); + } + + #[tokio::test] + async fn a_missed_writer_lock_deadline_reaches_mcp_as_its_typed_detail() { + let problem = source_edit_refusal(DaemonInvocationOutcome::ApplicationProblem { + problem: ApplicationProblem::from_detail( + tracedecay_contracts::ApplicationProblemDetailV1::LockDeadline { + resource: "source-edit writer lock".to_owned(), + deadline_ms: 30_000, + }, + ), + }) + .await; + assert_eq!( + (problem_summary(&problem), &problem["detail"]), + ( + json!({ + "kind": "saturated", + "code": "application.lock-deadline", + "retryable": true, + "legal_actions": ["retry"], + }), + &json!({ + "kind": "lock_deadline", + "resource": "source-edit writer lock", + "deadline_ms": 30_000, + }) + ) + ); } #[tokio::test] async fn source_edit_protocol_problem_stays_typed_without_debug_formatting() { - let error = source_edit_refusal(DaemonInvocationOutcome::Problem { + let problem = source_edit_refusal(DaemonInvocationOutcome::Problem { problem: DaemonInvocationProblem::NotFoundOrNotAuthorized, }) .await; - let (reason_code, retryable, _) = error - .project_route_context() - .expect("protocol refusal must stay a typed project-route error"); - assert_eq!(reason_code, "not_found_or_not_authorized"); - assert!(!retryable); - assert!(!error.to_string().contains("NotFoundOrNotAuthorized")); + assert_eq!( + problem_summary(&problem), + json!({ + "kind": "not_found_or_not_authorized", + "code": "not_found_or_not_authorized", + "retryable": false, + "legal_actions": [], + }) + ); + assert!(!problem.to_string().contains("NotFoundOrNotAuthorized")); } #[tokio::test] diff --git a/crates/tracedecay-sdk/src/codegen.rs b/crates/tracedecay-sdk/src/codegen.rs index be019ed12d..095afdbb88 100644 --- a/crates/tracedecay-sdk/src/codegen.rs +++ b/crates/tracedecay-sdk/src/codegen.rs @@ -1033,7 +1033,7 @@ export type CanonicalJsonSchema = Readonly>; export type CanonicalCancellation = Readonly>; export interface ContractRef { schema_id: string; schema_revision: number; [key: string]: unknown } export interface SafeDiagnostic { code: string; message: string; [key: string]: unknown } -export type ApplicationProblemDetailV1 = { kind: "parked"; cause: string; remedy: string; retries_on_wake: boolean; [key: string]: unknown } | { kind: "stale_refresh_frontier"; requested: number; committed: number; active: number; [key: string]: unknown } | { kind: "lock_deadline"; resource: string; deadline_ms: number; [key: string]: unknown }; +export type ApplicationProblemDetailV1 = { kind: "parked"; cause: string; remedy: string; retries_on_wake: boolean; [key: string]: unknown } | { kind: "stale_refresh_frontier"; requested: number; committed: number; active: number; [key: string]: unknown } | { kind: "lock_deadline"; resource: string; deadline_ms: number; [key: string]: unknown } | { kind: "diagnostics_unsupported"; file: string | null; searched: { path: string; present: boolean; [key: string]: unknown }[]; [key: string]: unknown } | { kind: "reset_required"; authority: string; found_version: number | null; required_version: number | null; reason: string; remedy: string; [key: string]: unknown } | { kind: "diagnostics_pending"; producer: string; generation: string | null; [key: string]: unknown }; export interface OperationReceipt { started_at: number; ended_at: number; effective_deadline: unknown; cancellation: unknown | null; budget: { units_consumed: number; bytes_consumed: number; elapsed_micros: number; [key: string]: unknown }; termination: OperationTermination; [key: string]: unknown } export interface EffectReceipt { operation: string; request_id: string; actor: string; scope: Record; effect_class: string; idempotency_key: string; input_digest: string; expected_state: string; policy_digest: string; configuration_digest: string; catalog_digest: string; privacy_digest: string; outcome: EffectTermination; committed_state: string | null; external_proof: string | null; [key: string]: unknown } export type PageCursor = { kind: "opaque"; cursor: string; [key: string]: unknown } | { kind: "fact_search"; cursor: { score_millionths: number; updated_at: number; fact_id: string; [key: string]: unknown }; [key: string]: unknown } | { kind: "fact_list_after"; fact_id: string; [key: string]: unknown }; export interface PageState { sort_contract_id: string; sort_revision: number; total: number | null; returned: number; cursor: PageCursor | null; expires_at: number | null; [key: string]: unknown } diff --git a/crates/tracedecay/src/daemon/project_open_owners/advisory_runtime.rs b/crates/tracedecay/src/daemon/project_open_owners/advisory_runtime.rs index 2d9a531581..f7d788068d 100644 --- a/crates/tracedecay/src/daemon/project_open_owners/advisory_runtime.rs +++ b/crates/tracedecay/src/daemon/project_open_owners/advisory_runtime.rs @@ -2060,6 +2060,7 @@ impl DaemonAdvisoryCycleInvocationPort for ProjectOpenProximityReadOwnerV1 { }, retry: RetryDirective::Never, legal_actions: vec![legal_action], + detail: None, }; if code_index_disabled_for_scope(&self.code_index_schedulers, &self.scope) { return Err(without_generation( diff --git a/crates/tracedecay/src/mcp/tools/handlers/application_surface.rs b/crates/tracedecay/src/mcp/tools/handlers/application_surface.rs index 0ec81f958d..5c59831e86 100644 --- a/crates/tracedecay/src/mcp/tools/handlers/application_surface.rs +++ b/crates/tracedecay/src/mcp/tools/handlers/application_surface.rs @@ -12,12 +12,13 @@ use tracedecay_daemon_protocol::{ }; use tracedecay_daemon_protocol::{DaemonInvocationExecutor, RequestedOutputFormat}; use tracedecay_domain::errors::{Result, TraceDecayError}; -use tracedecay_mcp::application_output::view::CanonicalHumanView; +use tracedecay_mcp::application_output::tool_result::{ + ApplicationRefusal, render_application_result, +}; use tracedecay_mcp::tools::dispatch::{ resolve_mcp_application_surface_for_target, resolve_mcp_application_surface_with_controls_for_target, }; -use tracedecay_mcp::tools::response_trailers::ResponseTrailer; use tracedecay_project::project::TraceDecay; pub(super) fn request_id() -> Result { @@ -207,7 +208,7 @@ pub fn render_application_surface_result( response_handle_root: Option<&std::path::Path>, result: &ApplicationSurfaceInvocationResult, ) -> Result { - render_result_parts( + render_application_result( response_handle_root, result.operation.as_str(), &result.binding_id, @@ -216,71 +217,6 @@ pub fn render_application_surface_result( ) } -fn render_result_parts( - response_handle_root: Option<&std::path::Path>, - operation: &str, - binding_id: &BindingId, - result: &ApplicationResult, - requested_format: RequestedOutputFormat, -) -> Result { - let (value, failure_message) = match result { - Ok(application) => (serde_json::to_value(application)?, None), - Err(problem) => { - let failure_message = match problem.problem.kind() { - ApplicationProblemKind::NotFoundOrNotAuthorized => { - "application surface was not found or is not authorized" - } - ApplicationProblemKind::Unavailable => "application surface unavailable", - _ => "application surface request failed", - }; - (serde_json::to_value(problem)?, Some(failure_message)) - } - }; - let markdown = match requested_format { - RequestedOutputFormat::Json => None, - RequestedOutputFormat::Markdown => { - Some(render_canonical_markdown(operation, binding_id, result)?) - } - }; - let text = tracedecay_mcp::tools::render::finalize_with_format( - response_handle_root, - requested_format, - &value, - || markdown.unwrap_or_default(), - ); - let mut rendered = super::text_tool_result(&text); - match result { - Ok(envelope) => ResponseTrailer { - touched_files: &envelope.touched_files, - code_graph: envelope.code_graph.as_ref(), - cost: envelope.cost.as_ref(), - } - .attach(&mut rendered), - // Keep the typed problem machine-readable in every presentation - // format: markdown rendering alone would strand it in prose that - // clients cannot classify. The whole record travels, not a - // kind/code summary, the parts a caller must *act* on are the - // legal actions, the retry directive, and, for an admitted partial - // effect, the committed receipt. Publishing only kind/code left the - // one instruction that matters ("reconcile this committed effect") - // readable by humans and invisible to every client. - Err(problem) => { - if let Some(object) = rendered.value.as_object_mut() { - object.insert( - "problem".to_string(), - serde_json::to_value(problem.problem.as_ref())?, - ); - } - } - } - Ok(match failure_message { - Some(failure_message) => rendered - .with_semantic_error(true) - .with_failure_message(failure_message), - None => rendered, - }) -} - /// A settled retained tool call, before rendering. pub struct RetainedSurfaceExecution { pub operation: ApplicationSurfaceOperation, @@ -323,7 +259,7 @@ pub fn render_retained_execution( ) -> Result { hotpath::measure_block!( "mcp.retained.render", - render_result_parts( + render_application_result( response_handle_root, execution.operation.as_str(), &execution.binding_id, @@ -517,9 +453,15 @@ pub async fn execute_retained_surface_tool( }) } +/// A settled graph-tool call: the typed completion, or the owner's refusal. +pub type GraphToolOutcome = std::result::Result< + tracedecay_contracts::graph_tool::GraphToolCompletionV1, + ApplicationRefusal, +>; + /// Invoke one graph-tool operation through the project's graph-tool owner and -/// return its typed result. A refusal comes back as the handler's own error -/// kind, so every surface reports the failure it always reported. +/// return its typed result. A refusal with typed detail comes back whole for +/// the surface to render; a code-only refusal stays the handler's error. #[allow(clippy::too_many_arguments)] #[hotpath::measure(future = true, label = "mcp.graph_tool.total")] pub async fn execute_graph_tool_surface( @@ -530,7 +472,7 @@ pub async fn execute_graph_tool_surface( protocol_request_id: Option, deadline: Option, cancellation: Option, -) -> Result { +) -> Result { let request = parse_application_surface_request(operation, args).map_err(|error| { TraceDecayError::Config { message: match error { @@ -568,13 +510,24 @@ pub async fn execute_graph_tool_surface( RequestedOutputFormat::Json, ) .map_err(application_surface_dispatch_error)?; + let binding_id = dispatched.invocation.binding_id.clone(); let result = tracedecay_daemon_service::application_surface::execute_application_surface( operation, dispatched, executor, ) .await .map_err(application_surface_dispatch_error)? .result; - let envelope = result.map_err(|problem| graph_tool_problem_error(&problem.problem))?; + let envelope = match result { + Ok(envelope) => envelope, + Err(problem) if problem.problem.detail.is_some() => { + return Ok(Err(ApplicationRefusal { + operation, + binding_id, + problem, + })); + } + Err(problem) => return Err(graph_tool_problem_error(*problem.problem)), + }; let ApplicationOutcome::Result(value) = envelope.outcome else { return Err(TraceDecayError::project_route( "application_surface_invalid_response", @@ -597,34 +550,43 @@ pub async fn execute_graph_tool_surface( ), ) })?; - Ok(tracedecay_contracts::graph_tool::GraphToolCompletionV1 { - result, - touched_files: envelope.touched_files, - code_graph: envelope.code_graph, - analytics: envelope.analytics, - cost: envelope.cost, - }) + Ok(Ok( + tracedecay_contracts::graph_tool::GraphToolCompletionV1 { + result, + touched_files: envelope.touched_files, + code_graph: envelope.code_graph, + analytics: envelope.analytics, + cost: envelope.cost, + }, + )) } -/// The graph-tool owner reports handler argument errors as invalid requests -/// and every other refusal under its own reason code. +/// A code-only graph-tool refusal is the owner's flattening of a handler +/// error (see [`graph_tool_error_problem`]), so it carries no kind or action +/// of its own: it reports as the handler's argument or project-route error. fn graph_tool_problem_error( - problem: &tracedecay_contracts::ApplicationProblemRecord, + problem: tracedecay_contracts::ApplicationProblemRecord, ) -> TraceDecayError { - let message = problem.diagnostic.as_ref().map_or_else( - || problem.message.clone(), - |diagnostic| diagnostic.message.clone(), - ); + let message = problem + .diagnostic + .map_or(problem.message, |diagnostic| diagnostic.message); match problem.kind { ApplicationProblemKind::InvalidRequest => TraceDecayError::Config { message }, - _ => TraceDecayError::project_route(problem.code.clone(), problem.retryable, message), + _ => TraceDecayError::project_route(problem.code, problem.retryable, message), } } -/// The owner-side counterpart of [`graph_tool_problem_error`]. +/// The graph-tool owner reports handler argument errors as invalid requests, +/// a lock that missed its deadline as its typed detail, and every other +/// refusal under its own reason code. pub(crate) fn graph_tool_error_problem( error: &TraceDecayError, ) -> tracedecay_contracts::ApplicationProblem { + if let Some(detail) = + tracedecay_contracts::ApplicationProblemDetailV1::from_lock_deadline(error) + { + return tracedecay_contracts::ApplicationProblem::from_detail(detail); + } match error { TraceDecayError::Config { message } => { tracedecay_contracts::ApplicationProblem::invalid_request_without_action( @@ -683,17 +645,6 @@ fn graph_tool_unavailable( } } -fn render_canonical_markdown( - operation: &str, - binding_id: &BindingId, - result: &ApplicationResult, -) -> serde_json::Result { - let view = CanonicalHumanView::from_application_result(operation, binding_id, result)?; - Ok(tracedecay_mcp::application_output::markdown::render(view) - .as_str() - .to_owned()) -} - #[cfg(test)] mod tests { use serde_json::json; @@ -707,7 +658,7 @@ mod tests { use tracedecay_mcp::tools::response_trailers::account_tool_result; use tracedecay_tool_catalog::{ApplicationSurfaceOperation, BindingId, SchemaId}; - use super::{complete_protocol_controls, render_result_parts}; + use super::{complete_protocol_controls, render_application_result}; #[test] fn retained_calls_target_the_profile_only_through_their_canonical_selector() { @@ -881,7 +832,7 @@ mod tests { }; let binding = BindingId::new("binding.mcp.code-callers.v1").unwrap(); for format in [RequestedOutputFormat::Markdown, RequestedOutputFormat::Json] { - let mut rendered = render_result_parts( + let mut rendered = render_application_result( Some(root.path()), "code_callers", &binding, diff --git a/crates/tracedecay/src/mcp/tools/handlers/dispatch_groups.rs b/crates/tracedecay/src/mcp/tools/handlers/dispatch_groups.rs index 2a95b46b59..e82f7b8011 100644 --- a/crates/tracedecay/src/mcp/tools/handlers/dispatch_groups.rs +++ b/crates/tracedecay/src/mcp/tools/handlers/dispatch_groups.rs @@ -370,11 +370,15 @@ fn dispatch_application_surface_tools_inner<'a>( options.application_cancellation.clone(), ) .await?; - return tracedecay_mcp::handlers::graph_tool::render_graph_tool( - Some(&cg.store_layout().response_handle_root), - &args, - execution, - ); + let response_handle_root = Some(cg.store_layout().response_handle_root.as_path()); + return match execution { + Ok(completion) => tracedecay_mcp::handlers::graph_tool::render_graph_tool( + response_handle_root, + &args, + completion, + ), + Err(refusal) => refusal.render(response_handle_root, &args), + }; } if source_edit { return edit::source_edit_tool( diff --git a/crates/tracedecay/src/mcp/tools/handlers/mod.rs b/crates/tracedecay/src/mcp/tools/handlers/mod.rs index ba7c5fca3d..bfa5cc3a2d 100644 --- a/crates/tracedecay/src/mcp/tools/handlers/mod.rs +++ b/crates/tracedecay/src/mcp/tools/handlers/mod.rs @@ -157,7 +157,6 @@ mod workflow_dispatch_tests; use std::sync::Arc; pub(crate) use tool_call_support::resolve_registered_project_route_for_tool; -pub(super) use tool_call_support::text_tool_result; use serde_json::Value; use tracedecay_contracts::retrieval::ServedCodeGraphGenerationV1; diff --git a/crates/tracedecay/src/mcp/tools/handlers/tool_call_support.rs b/crates/tracedecay/src/mcp/tools/handlers/tool_call_support.rs index 60fa19a2fc..5ac5c58aac 100644 --- a/crates/tracedecay/src/mcp/tools/handlers/tool_call_support.rs +++ b/crates/tracedecay/src/mcp/tools/handlers/tool_call_support.rs @@ -6,16 +6,10 @@ use tracedecay_domain::errors::{Result, TraceDecayError}; use tracedecay_global_db::RegisteredGlobalDb; use super::support::{registered_project_context, validate_registered_project_selector_aliases}; -use tracedecay_mcp::ToolResult; -use tracedecay_mcp::handlers::text_tool_result as text_result; use tracedecay_mcp::tools::binding::{ tool_accepts_registered_project_selector, tool_dispatches_registered_project_reader, }; -pub(in crate::mcp::tools) fn text_tool_result(text: &str) -> ToolResult { - text_result(text, Vec::new()) -} - pub(super) fn boxed_send<'a, T, F>( future: F, ) -> std::pin::Pin + Send + 'a>> diff --git a/crates/tracedecay/tests/common/fixture.rs b/crates/tracedecay/tests/common/fixture.rs index 4fa502b482..b05f258598 100644 --- a/crates/tracedecay/tests/common/fixture.rs +++ b/crates/tracedecay/tests/common/fixture.rs @@ -833,6 +833,10 @@ pub const TYPESCRIPT_FIXTURE_TSC_REPORT: &str = "src/index.ts(3,14): error TS402 /// the working directory and the arguments it received. pub const TYPESCRIPT_FIXTURE_TSC_INVOCATIONS: &str = "node_modules/tsc-invocations.log"; +/// The file whose creation lets a [`TypeScriptFixtureCompiler::Held`] +/// compiler finish its run. +pub const TYPESCRIPT_FIXTURE_TSC_RELEASE: &str = "node_modules/tsc-release"; + /// Whether the TypeScript fixture project carries its own compiler. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum TypeScriptFixtureCompiler { @@ -840,6 +844,10 @@ pub enum TypeScriptFixtureCompiler { Present, /// No `node_modules` at all: a checkout before `npm install`. Missing, + /// Like `Present`, but each run of the single-project fixture's compiler + /// waits for [`TYPESCRIPT_FIXTURE_TSC_RELEASE`] before reporting, so the + /// producer stays pending until the test lets it publish. + Held, } /// A small TypeScript project whose sources genuinely produce `TS4023` under @@ -879,10 +887,18 @@ pub fn write_typescript_diagnostics_fixture(project: &Path, compiler: TypeScript let bin = project.join("node_modules/.bin"); fs::create_dir_all(&bin).unwrap(); let tsc = bin.join("tsc"); + let hold = if compiler == TypeScriptFixtureCompiler::Held { + format!( + "while [ ! -e \"{}\" ]; do sleep 0.1; done\n", + project.join(TYPESCRIPT_FIXTURE_TSC_RELEASE).display() + ) + } else { + String::new() + }; fs::write( &tsc, format!( - "#!/bin/sh\nprintf '%s %s\\n' \"$(pwd)\" \"$*\" >> \"{}\"\ncat <<'TSC_REPORT'\n{}TSC_REPORT\nexit 2\n", + "#!/bin/sh\nprintf '%s %s\\n' \"$(pwd)\" \"$*\" >> \"{}\"\n{hold}cat <<'TSC_REPORT'\n{}TSC_REPORT\nexit 2\n", project.join(TYPESCRIPT_FIXTURE_TSC_INVOCATIONS).display(), TYPESCRIPT_FIXTURE_TSC_REPORT ), diff --git a/crates/tracedecay/tests/mcp_suite/diagnostics_read_test.rs b/crates/tracedecay/tests/mcp_suite/diagnostics_read_test.rs index d5280e0d62..b29341e7e0 100644 --- a/crates/tracedecay/tests/mcp_suite/diagnostics_read_test.rs +++ b/crates/tracedecay/tests/mcp_suite/diagnostics_read_test.rs @@ -31,19 +31,28 @@ async fn diagnostics_read_names_a_missing_producer_and_rejects_a_bad_scope() { .server(&fixture.project_root) .expect("production project server"); - for (arguments, message) in [ + for (arguments, message, detail) in [ ( json!({"scope": "workspace", "maximum_diagnostics": 1}), ABSENT_PRODUCER_MESSAGE, + workspace_absent_detail(), ), ( json!({"scope": "file", "path": "src/main.rs", "maximum_diagnostics": 1}), ABSENT_FILE_PRODUCER_MESSAGE, + json!({ + "kind": "diagnostics_unsupported", + "file": "src/main.rs", + "searched": [ + {"path": "src/tsconfig.json", "present": false}, + {"path": "tsconfig.json", "present": false}, + ], + }), ), ] { let result = handle_real_server_tool_call(&server, "tracedecay_diagnostics", arguments).await; - assert_absent_producer(&result, message); + assert_absent_producer(&result, message, &detail); } let markdown = handle_real_server_tool_call( @@ -378,10 +387,96 @@ async fn typescript_file_no_tsconfig_owns_names_the_searched_paths() { )), "{problem}" ); + assert_eq!( + problem["detail"], + json!({ + "kind": "diagnostics_unsupported", + "file": "scripts/release.ts", + "searched": [ + {"path": "scripts/tsconfig.json", "present": false}, + {"path": "tsconfig.json", "present": false}, + ], + }), + "{problem}" + ); fixture.harness.shutdown().await; } +/// While the project's own compiler is still running, the read is the pending +/// state, naming the compiler and that nothing has published yet. +#[cfg(unix)] +#[tokio::test] +async fn typescript_producer_still_running_is_pending_with_typed_detail() { + use crate::common::fixture::{ + TYPESCRIPT_FIXTURE_TSC_RELEASE, TypeScriptFixtureCompiler, + write_typescript_diagnostics_fixture, + }; + use crate::support::production_composition_fixture_with_sources; + + let fixture = production_composition_fixture_with_sources(|project| { + write_typescript_diagnostics_fixture(project, TypeScriptFixtureCompiler::Held); + }) + .await; + let server = fixture + .harness + .server(&fixture.project_root) + .expect("production project server"); + wait_for_current_graph(&server).await; + + let arguments = json!({"scope": "file", "path": "src/index.ts", "maximum_diagnostics": 10}); + let mut pending = Value::Null; + for _ in 0..120 { + let result = + handle_real_server_tool_call(&server, "tracedecay_diagnostics", arguments.clone()) + .await; + let code = &result["structuredContent"]["problem"]["code"]; + if code == "application.diagnostics.pending" { + pending = result; + break; + } + assert_eq!( + code, "application.diagnostics.stale", + "a held producer can only be stale or pending: {result}" + ); + tokio::time::sleep(Duration::from_millis(250)).await; + } + let problem = &pending["structuredContent"]["problem"]; + assert_eq!( + ( + &problem["kind"], + &problem["retry"], + &problem["legal_actions"], + &problem["detail"], + ), + ( + &json!("unavailable"), + &json!("after_delay"), + &json!(["retry"]), + &json!({ + "kind": "diagnostics_pending", + "producer": "node_modules/.bin/tsc", + "generation": null, + }), + ), + "{pending}" + ); + assert_eq!( + problem["message"], + "The TypeScript producer (node_modules/.bin/tsc) has not published diagnostics for \ + this project's current generation yet; it runs after the code index seals a \ + complete generation. Retry shortly.", + "{pending}" + ); + + std::fs::write( + fixture.project_root.join(TYPESCRIPT_FIXTURE_TSC_RELEASE), + "", + ) + .expect("release the held compiler"); + fixture.harness.shutdown().await; +} + /// Polls the read until the producer has published for the current /// generation; the pending state is the only one worth waiting through. async fn await_published_diagnostics( @@ -426,7 +521,11 @@ fn rejected_diagnostics_request(detail: &str) -> Value { }) } -fn assert_absent_producer(result: &Value, message: &str) { +fn workspace_absent_detail() -> Value { + json!({"kind": "diagnostics_unsupported", "file": null, "searched": []}) +} + +fn assert_absent_producer(result: &Value, message: &str, detail: &Value) { assert_eq!(result["isError"], json!(true)); assert_eq!(result["content"][0]["type"], "text"); let text = extract_real_server_text(result); @@ -434,7 +533,10 @@ fn assert_absent_producer(result: &Value, message: &str) { panic!("diagnostics JSON should be the problem envelope: {error}\n{text}") }); let request_id = assert_mcp_request_id(envelope["request_id"].as_str()); - assert_eq!(envelope, absent_producer_envelope(&request_id, message)); + assert_eq!( + envelope, + absent_producer_envelope(&request_id, message, detail) + ); assert_eq!(result["structuredContent"]["problem"], envelope["problem"]); } @@ -444,7 +546,11 @@ fn assert_absent_producer_markdown(result: &Value) { assert_mcp_request_id(result["structuredContent"]["problem"]["request_id"].as_str()); assert_eq!( result["structuredContent"]["problem"], - absent_producer_problem(&request_id, ABSENT_PRODUCER_MESSAGE) + absent_producer_problem( + &request_id, + ABSENT_PRODUCER_MESSAGE, + &workspace_absent_detail() + ) ); assert_eq!( extract_real_server_text(result), @@ -464,6 +570,8 @@ fn assert_absent_producer_markdown(result: &Value) { - Request: `{request_id}` - Trace: `{request_id}` - Message: {ABSENT_PRODUCER_MESSAGE_MARKDOWN} +- Diagnostics file: workspace +- Searched tsconfigs: none - Retryable: `false` - Retry: `never` - Retry scope: `none` @@ -486,18 +594,18 @@ fn assert_mcp_request_id(request_id: Option<&str>) -> String { request_id.to_owned() } -fn absent_producer_envelope(request_id: &str, message: &str) -> Value { +fn absent_producer_envelope(request_id: &str, message: &str, detail: &Value) -> Value { json!({ "contract": { "schema_id": "schema.application.primitive.diagnostics-read.result", "schema_revision": 1 }, "request_id": request_id, - "problem": absent_producer_problem(request_id, message) + "problem": absent_producer_problem(request_id, message, detail) }) } -fn absent_producer_problem(request_id: &str, message: &str) -> Value { +fn absent_producer_problem(request_id: &str, message: &str, detail: &Value) -> Value { json!({ "revision": 1, "kind": "unsupported", @@ -507,7 +615,7 @@ fn absent_producer_problem(request_id: &str, message: &str) -> Value { "code": ABSENT_PRODUCER_CODE, "message": message }, - "detail": null, + "detail": detail, "committed_receipt": null, "owning_layer": "application", "terminality": "pre_admission", diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/ast_grep_rewrite_behavior_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/ast_grep_rewrite_behavior_test.rs index 01fed5c1a1..4dea2f9883 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/ast_grep_rewrite_behavior_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/ast_grep_rewrite_behavior_test.rs @@ -367,10 +367,25 @@ async fn ast_grep_rewrite_exact_retry_replays_and_a_different_input_conflicts() "expected_state": bound_state }), ) - .await; - assert_eq!( - expect_tool_error(conflict), - "project route error (source_edit.idempotency_conflict): source edit idempotency key conflicts with a prior input" + .await + .expect("a daemon refusal renders as a tool result"); + assert_eq!(conflict.semantic_error(), Some(true), "{}", conflict.value); + let problem = &conflict.value["problem"]; + assert_eq!( + ( + &problem["kind"], + &problem["code"], + &problem["legal_actions"], + &problem["message"], + ), + ( + &json!("conflict"), + &json!("source_edit.idempotency_conflict"), + &json!(["refresh"]), + &json!("source edit idempotency key conflicts with a prior input"), + ), + "{}", + conflict.value ); assert_file(&fixture, CHECKOUT, CHECKOUT_AFTER); } diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/multi_str_replace_behavior_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/multi_str_replace_behavior_test.rs index 7535a56a8e..81ba4e8d51 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/multi_str_replace_behavior_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/multi_str_replace_behavior_test.rs @@ -261,8 +261,8 @@ async fn preview_apply_and_replay_replace_each_original_span() { ); assert_eq!(read_file(&dir, "src/main.rs"), applied); - let conflict = protocol_error( - &fixture, + let conflict = tools_call( + &server(&fixture), json!({ "path": "src/main.rs", "replacements": [["old-a", "other-a"], ["old-b", "other-b"]], @@ -271,20 +271,24 @@ async fn preview_apply_and_replay_replace_each_original_span() { }), ) .await; - assert_eq!(conflict["code"], -32603, "{conflict}"); - assert_eq!( - conflict["message"], - "tool project route failed: reason_code=source_edit.idempotency_conflict retryable=true: source edit idempotency key conflicts with a prior input", - "{conflict}" - ); - assert_eq!( - conflict["data"], - json!({ - "tool": TOOL, - "reason_code": "source_edit.idempotency_conflict", - "retryable": true, - "detail": "source edit idempotency key conflicts with a prior input" - }), + assert!(conflict["error"].is_null(), "{conflict}"); + assert_eq!(conflict["result"]["isError"], true, "{conflict}"); + let problem = &conflict["result"]["structuredContent"]["problem"]; + assert_eq!( + ( + &problem["kind"], + &problem["code"], + &problem["retry"], + &problem["legal_actions"], + &problem["message"], + ), + ( + &json!("conflict"), + &json!("source_edit.idempotency_conflict"), + &json!("after_revalidate"), + &json!(["refresh"]), + &json!("source edit idempotency key conflicts with a prior input"), + ), "{conflict}" ); assert_eq!(read_file(&dir, "src/main.rs"), applied); diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/source_edit_reconcile_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/source_edit_reconcile_test.rs index 963bbdc529..2b7cfd7e9e 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/source_edit_reconcile_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/source_edit_reconcile_test.rs @@ -22,10 +22,10 @@ const NEW: &str = "pub fn after() {}"; const ABSENT_DIGEST: &str = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; -const NO_JOURNAL: &str = "project route error (source_edit.execution_failed): config error: no source edit effect requires reconciliation"; -const IDENTITY_MISMATCH: &str = "project route error (source_edit.execution_failed): config error: source edit reconciliation identity does not match the retained effect"; -const COMMITTED_MISMATCH: &str = "project route error (source_edit.execution_failed): config error: source edit committed-state inspection does not match the exact preview"; -const ROLLED_BACK_MISMATCH: &str = "project route error (source_edit.execution_failed): config error: source edit rollback inspection does not match the admitted expected state"; +const NO_JOURNAL: &str = "execution_failed source_edit.execution_failed [contact_administrator]: config error: no source edit effect requires reconciliation"; +const IDENTITY_MISMATCH: &str = "execution_failed source_edit.execution_failed [contact_administrator]: config error: source edit reconciliation identity does not match the retained effect"; +const COMMITTED_MISMATCH: &str = "execution_failed source_edit.execution_failed [contact_administrator]: config error: source edit committed-state inspection does not match the exact preview"; +const ROLLED_BACK_MISMATCH: &str = "execution_failed source_edit.execution_failed [contact_administrator]: config error: source edit rollback inspection does not match the admitted expected state"; const CONFIRM_REQUIRED: &str = "config error: source edit reconciliation requires confirm=true from the caller after it inspects the file; do not pause for a human"; const ATTEMPT_KEY_CONFLICT: &str = "config error: reconciliation attempt idempotency key must differ from the original edit key"; @@ -66,6 +66,27 @@ fn refusal(result: Result) -> String { } } +/// The daemon's problem record a refused reconcile renders, as +/// `kind code [legal actions]: message`. +fn problem_refusal(result: Result) -> String { + let result = result.expect("a daemon refusal renders as a tool result"); + assert_eq!(result.semantic_error(), Some(true), "{}", result.value); + let problem = &result.value["problem"]; + let actions = problem["legal_actions"] + .as_array() + .expect("legal actions") + .iter() + .map(|action| action.as_str().expect("legal action")) + .collect::>() + .join(","); + format!( + "{} {} [{actions}]: {}", + problem["kind"].as_str().expect("problem kind"), + problem["code"].as_str().expect("problem code"), + problem["message"].as_str().expect("problem message") + ) +} + async fn call_reconcile( fixture: &ProductionSourceEditFixture, args: Value, @@ -311,7 +332,7 @@ async fn reconcile_refuses_uninspected_and_absent_effects() { let mut absent = base; absent["confirm"] = Value::Bool(true); assert_eq!( - refusal(call_reconcile(&opened.fixture, absent).await), + problem_refusal(call_reconcile(&opened.fixture, absent).await), NO_JOURNAL ); assert_eq!(fs::read(&opened.file).unwrap(), PREIMAGE); @@ -336,7 +357,7 @@ async fn unpublished_effect_confirms_rolled_back_and_releases_the_file() { .to_owned(); assert_eq!( - refusal( + problem_refusal( call_reconcile( &opened.fixture, reconcile_args( @@ -478,7 +499,7 @@ async fn mismatched_inspection_keeps_bytes_and_confirm_committed_keeps_the_posti .to_owned(); assert_eq!( - refusal( + problem_refusal( call_reconcile( &opened.fixture, reconcile_args( @@ -498,7 +519,7 @@ async fn mismatched_inspection_keeps_bytes_and_confirm_committed_keeps_the_posti fs::write(&opened.file, POSTIMAGE).unwrap(); assert_eq!( - refusal( + problem_refusal( call_reconcile( &opened.fixture, reconcile_args( diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/source_edit_rollback_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/source_edit_rollback_test.rs index f370421b08..a7d5893397 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/source_edit_rollback_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/source_edit_rollback_test.rs @@ -309,26 +309,9 @@ async fn source_edit_rollback_restores_move_preimages_and_replays_the_receipt() ), ) .await; - assert_eq!(mismatched["jsonrpc"], "2.0"); - assert_eq!(mismatched["id"], 1); - assert!(mismatched.get("result").is_none_or(Value::is_null)); - assert_eq!(mismatched["error"]["code"], -32602); - assert_eq!( - mismatched["error"]["message"], - "tool project route failed: reason_code=source_edit.execution_failed retryable=false: config error: source edit rollback identity does not match the completed original effect" - ); - assert_eq!( - mismatched["error"]["data"]["tool"], - "tracedecay_source_edit_rollback" - ); - assert_eq!( - mismatched["error"]["data"]["reason_code"], - "source_edit.execution_failed" - ); - assert_eq!(mismatched["error"]["data"]["retryable"], json!(false)); - assert_eq!( - mismatched["error"]["data"]["detail"], - "config error: source edit rollback identity does not match the completed original effect" + assert_rollback_execution_failed( + &mismatched, + "config error: source edit rollback identity does not match the completed original effect", ); assert_original_sources(&moved.project); } @@ -475,24 +458,39 @@ async fn source_edit_rollback_refuses_an_edit_without_retained_preimages() { }), ) .await; - assert_eq!(refused["jsonrpc"], "2.0"); - assert_eq!(refused["id"], 1); - assert!(refused.get("result").is_none_or(Value::is_null)); - assert_eq!(refused["error"]["code"], -32602); - assert_eq!( - refused["error"]["message"], - "tool project route failed: reason_code=source_edit.execution_failed retryable=false: config error: source edit effect has no retained rollback material" - ); - assert_eq!( - refused["error"]["data"]["tool"], - "tracedecay_source_edit_rollback" - ); - assert_eq!( - refused["error"]["data"]["detail"], - "config error: source edit effect has no retained rollback material" + assert_rollback_execution_failed( + &refused, + "config error: source edit effect has no retained rollback material", ); assert_eq!( read_project_file(&project, "src/main.rs"), "fn new_name() {}\n" ); } + +/// A rollback the daemon refused reaches the host as a tool result carrying +/// the daemon's problem record, not a flattened project-route error. +fn assert_rollback_execution_failed(response: &Value, message: &str) { + assert_eq!(response["jsonrpc"], "2.0"); + assert_eq!(response["id"], 1); + assert!(response["error"].is_null(), "{response}"); + assert_eq!(response["result"]["isError"], json!(true), "{response}"); + let problem = &response["result"]["structuredContent"]["problem"]; + assert_eq!( + ( + &problem["kind"], + &problem["code"], + &problem["retryable"], + &problem["legal_actions"], + &problem["message"], + ), + ( + &json!("execution_failed"), + &json!("source_edit.execution_failed"), + &json!(false), + &json!(["contact_administrator"]), + &json!(message), + ), + "{response}" + ); +} diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/str_replace_behavior_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/str_replace_behavior_test.rs index b4de374f59..451b6e262e 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/str_replace_behavior_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/str_replace_behavior_test.rs @@ -10,7 +10,7 @@ use crate::support::{ ProductionSourceEditFixture, TestTempDir, extract_first_json_content, - init_production_source_edit_project, test_temp_dir, + handle_real_server_tool_call, init_production_source_edit_project, test_temp_dir, }; use serde_json::{Value, json}; use std::fs; @@ -709,3 +709,74 @@ async fn str_replace_refuses_project_selectors() { ); assert_eq!(fs::read(&file).unwrap(), initial); } + +/// Another process holds the store's writer lock past the admission deadline: +/// the preview is refused as retryable capacity, and the host reads the lock's +/// typed detail beside the text, not a flattened project-route error. +#[tokio::test] +async fn str_replace_behind_a_held_writer_lock_reports_the_lock_deadline_detail() { + let initial = b"fn price() -> u32 { 12 }\n"; + let (fixture, _dir, file) = open_file(PRICE_FILE, initial).await; + let lock_root = fixture + .harness + .project_data_root(&fixture.project_root) + .await + .expect("project data root") + .join("source-edit-transactions-v1"); + fs::create_dir_all(&lock_root).unwrap(); + let writer = fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(lock_root.join("source-edit.lock")) + .unwrap(); + writer.lock().expect("hold the source-edit writer lock"); + + let server = fixture + .harness + .server(&fixture.project_root) + .expect("production project server"); + let result = handle_real_server_tool_call( + &server, + "tracedecay_str_replace", + json!({"path": PRICE_FILE, "old_str": "12", "new_str": "40", "dry_run": true}), + ) + .await; + writer.unlock().expect("release the writer lock"); + + assert_eq!(result["isError"], true, "{result}"); + let problem = &result["structuredContent"]["problem"]; + assert_eq!( + ( + &problem["kind"], + &problem["code"], + &problem["retry"], + &problem["legal_actions"], + &problem["message"], + &problem["detail"], + ), + ( + &json!("saturated"), + &json!("application.lock-deadline"), + &json!("after_delay"), + &json!(["retry"]), + &json!( + "The source-edit writer lock stayed busy past its 30000ms admission deadline; \ + retry the operation." + ), + &json!({ + "kind": "lock_deadline", + "resource": "source-edit writer lock", + "deadline_ms": 30_000, + }), + ), + "{result}" + ); + assert_eq!( + extract_first_json_content(&result)["problem"], + *problem, + "the text the host reads carries the same record" + ); + assert_eq!(fs::read(&file).unwrap(), initial); +} diff --git a/dashboard/codegen/schemas/dashboard-contracts.schema.json b/dashboard/codegen/schemas/dashboard-contracts.schema.json index 585cccf38a..80b4769d99 100644 --- a/dashboard/codegen/schemas/dashboard-contracts.schema.json +++ b/dashboard/codegen/schemas/dashboard-contracts.schema.json @@ -1460,6 +1460,59 @@ "remedy" ], "type": "object" + }, + { + "additionalProperties": false, + "description": "No compiler runs automatically for the diagnostics scope: no tsconfig\nowns `file`, or, for a workspace read (`file` null), none exists under\nthe project root. `searched` lists the owner search's candidates,\nnearest first; it is empty for a workspace read or a file outside the\nproject root.", + "properties": { + "file": { + "type": [ + "string", + "null" + ] + }, + "kind": { + "const": "diagnostics_unsupported", + "type": "string" + }, + "searched": { + "items": { + "$ref": "#/$defs/DiagnosticsSearchedTsconfigV1" + }, + "type": "array" + } + }, + "required": [ + "kind", + "file", + "searched" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "The TypeScript `producer` has not published diagnostics for the\ncurrent code generation yet. `generation` is the last generation it\npublished, null when it has published none.", + "properties": { + "generation": { + "type": [ + "string", + "null" + ] + }, + "kind": { + "const": "diagnostics_pending", + "type": "string" + }, + "producer": { + "type": "string" + } + }, + "required": [ + "kind", + "producer", + "generation" + ], + "type": "object" } ] }, @@ -10643,6 +10696,25 @@ ], "type": "object" }, + "DiagnosticsSearchedTsconfigV1": { + "additionalProperties": false, + "description": "One tsconfig location the diagnostics owner search checked.", + "properties": { + "path": { + "description": "Relative to the project root, forward-slash separated.", + "type": "string" + }, + "present": { + "description": "The config exists, but neither it nor its references include the\nfile.", + "type": "boolean" + } + }, + "required": [ + "path", + "present" + ], + "type": "object" + }, "DoctorCoverageCompletenessV1": { "description": "Whether Doctor observed all of a family's evidence sources.", "oneOf": [ diff --git a/dashboard/src/contracts/generated.ts b/dashboard/src/contracts/generated.ts index 895a350b9c..c1021de048 100644 --- a/dashboard/src/contracts/generated.ts +++ b/dashboard/src/contracts/generated.ts @@ -386,6 +386,14 @@ export type ApplicationExecutionFailureClassV1 = z.infer DiagnosticsSearchedTsconfigV1Schema)), +}).strict(), z.object({ deadline_ms: z.number().int().safe().min(0), kind: z.literal("lock_deadline"), resource: z.string(), @@ -2225,6 +2233,13 @@ export const DeliverySharedCodeRefV1Schema = z.object({ }); export type DeliverySharedCodeRefV1 = z.infer; +/** One tsconfig location the diagnostics owner search checked. */ +export const DiagnosticsSearchedTsconfigV1Schema = z.object({ + path: z.string(), + present: z.boolean(), +}).strict(); +export type DiagnosticsSearchedTsconfigV1 = z.infer; + /** Whether Doctor observed all of a family's evidence sources. */ export const DoctorCoverageCompletenessV1Schema = z.union([z.literal("complete"), z.literal("partial"), z.literal("unknown")]); export type DoctorCoverageCompletenessV1 = z.infer; diff --git a/sdks/typescript/src/client.ts b/sdks/typescript/src/client.ts index a054592b41..ee4268db1f 100644 --- a/sdks/typescript/src/client.ts +++ b/sdks/typescript/src/client.ts @@ -244,6 +244,30 @@ function isProblemDetail(value: unknown): boolean { ); case "lock_deadline": return typeof value.resource === "string" && isSafeUnsignedInteger(value.deadline_ms); + case "diagnostics_unsupported": + return ( + (value.file === null || typeof value.file === "string") && + Array.isArray(value.searched) && + value.searched.every( + (candidate) => + isRecord(candidate) && + typeof candidate.path === "string" && + typeof candidate.present === "boolean", + ) + ); + case "diagnostics_pending": + return ( + typeof value.producer === "string" && + (value.generation === null || typeof value.generation === "string") + ); + case "reset_required": + return ( + typeof value.authority === "string" && + (value.found_version === null || isSafeInteger(value.found_version)) && + (value.required_version === null || isSafeInteger(value.required_version)) && + typeof value.reason === "string" && + typeof value.remedy === "string" + ); default: return false; } @@ -318,7 +342,7 @@ function isProblemEnvelope(value: unknown): value is HttpProblemEnvelope { const legalActions = Array.isArray(problem.legal_actions) ? problem.legal_actions : null; - const detailKinds = ["stale", "unavailable", "saturated"]; + const detailKinds = ["stale", "unsupported", "unavailable", "reset_required", "saturated"]; const diagnosticKinds = [ "invalid_request", "conflict", "partial_effect", "stale", "unsupported", "unavailable", "execution_failed", "reset_required", "saturated", diff --git a/sdks/typescript/src/types.ts b/sdks/typescript/src/types.ts index bd5a7f905b..9ad2935334 100644 --- a/sdks/typescript/src/types.ts +++ b/sdks/typescript/src/types.ts @@ -15,7 +15,7 @@ export type CanonicalJsonSchema = Readonly>; export type CanonicalCancellation = Readonly>; export interface ContractRef { schema_id: string; schema_revision: number; [key: string]: unknown } export interface SafeDiagnostic { code: string; message: string; [key: string]: unknown } -export type ApplicationProblemDetailV1 = { kind: "parked"; cause: string; remedy: string; retries_on_wake: boolean; [key: string]: unknown } | { kind: "stale_refresh_frontier"; requested: number; committed: number; active: number; [key: string]: unknown } | { kind: "lock_deadline"; resource: string; deadline_ms: number; [key: string]: unknown }; +export type ApplicationProblemDetailV1 = { kind: "parked"; cause: string; remedy: string; retries_on_wake: boolean; [key: string]: unknown } | { kind: "stale_refresh_frontier"; requested: number; committed: number; active: number; [key: string]: unknown } | { kind: "lock_deadline"; resource: string; deadline_ms: number; [key: string]: unknown } | { kind: "diagnostics_unsupported"; file: string | null; searched: { path: string; present: boolean; [key: string]: unknown }[]; [key: string]: unknown } | { kind: "reset_required"; authority: string; found_version: number | null; required_version: number | null; reason: string; remedy: string; [key: string]: unknown } | { kind: "diagnostics_pending"; producer: string; generation: string | null; [key: string]: unknown }; export interface OperationReceipt { started_at: number; ended_at: number; effective_deadline: unknown; cancellation: unknown | null; budget: { units_consumed: number; bytes_consumed: number; elapsed_micros: number; [key: string]: unknown }; termination: OperationTermination; [key: string]: unknown } export interface EffectReceipt { operation: string; request_id: string; actor: string; scope: Record; effect_class: string; idempotency_key: string; input_digest: string; expected_state: string; policy_digest: string; configuration_digest: string; catalog_digest: string; privacy_digest: string; outcome: EffectTermination; committed_state: string | null; external_proof: string | null; [key: string]: unknown } export type PageCursor = { kind: "opaque"; cursor: string; [key: string]: unknown } | { kind: "fact_search"; cursor: { score_millionths: number; updated_at: number; fact_id: string; [key: string]: unknown }; [key: string]: unknown } | { kind: "fact_list_after"; fact_id: string; [key: string]: unknown }; export interface PageState { sort_contract_id: string; sort_revision: number; total: number | null; returned: number; cursor: PageCursor | null; expires_at: number | null; [key: string]: unknown } diff --git a/sdks/typescript/test/client.test.ts b/sdks/typescript/test/client.test.ts index 46c693b877..cfe79089c0 100644 --- a/sdks/typescript/test/client.test.ts +++ b/sdks/typescript/test/client.test.ts @@ -25,6 +25,7 @@ import { TraceDecayProtocolError, TraceDecayResetRequiredError, TraceDecayUnavailableError, + TraceDecayUnsupportedError, createClient, type OperationRequestOptions, } from "../src/client"; @@ -1043,6 +1044,76 @@ describe("TraceDecayClient transport envelopes", () => { ); }); + it("surfaces the diagnostics detail an unsupported problem carries", async () => { + const detail = { + kind: "diagnostics_unsupported", + file: "src/main.rs", + searched: [ + { path: "src/tsconfig.json", present: false }, + { path: "tsconfig.json", present: true }, + ], + }; + const unsupported = problemEnvelope("unsupported", "application.diagnostics.unsupported", { + bindingId: "binding.http.workflow.list_definitions", + legalActions: ["correct_request"], + detail, + }); + const malformed = problemEnvelope("unsupported", "application.diagnostics.unsupported", { + bindingId: "binding.http.workflow.list_definitions", + legalActions: ["correct_request"], + detail: { ...detail, searched: [{ path: "tsconfig.json" }] }, + }); + + await withServer( + [ + (_request, response) => json(response, 422, unsupported), + (_request, response) => json(response, 422, malformed), + ], + async (baseUrl) => { + const client = createClient({ + baseUrl, + projectId: "project.sdk", + token: "sdk-secret", + }); + + const refusal = await requestThroughTransport(client).catch((error: unknown) => error); + expect(refusal).toBeInstanceOf(TraceDecayUnsupportedError); + expect((refusal as TraceDecayUnsupportedError).problem.detail).toEqual(detail); + await expect(requestThroughTransport(client)).rejects.toBeInstanceOf( + TraceDecayMalformedResponseError, + ); + }, + ); + }); + + it("surfaces the reset-required store detail", async () => { + const detail = { + kind: "reset_required", + authority: "project store", + found_version: 3, + required_version: 4, + reason: "the store predates this binary", + remedy: "tracedecay reset --project", + }; + const reset = problemEnvelope("reset_required", "application.reset-required", { + bindingId: "binding.http.workflow.list_definitions", + legalActions: ["reset"], + detail, + }); + + await withServer([(_request, response) => json(response, 503, reset)], async (baseUrl) => { + const client = createClient({ + baseUrl, + projectId: "project.sdk", + token: "sdk-secret", + }); + + const refusal = await requestThroughTransport(client).catch((error: unknown) => error); + expect(refusal).toBeInstanceOf(TraceDecayResetRequiredError); + expect((refusal as TraceDecayResetRequiredError).problem.detail).toEqual(detail); + }); + }); + it("rejects classified admitted terminals that Rust would reject", async () => { const unavailable = problemEnvelope("unavailable", "backend.unavailable", { unavailableClassification: "backend_unavailable",