Sweep every first-party crate source (1956 .rs files) to the project comment guidelines: delete redundant restatements, decorative banners, change narration, and end-of-line comments; keep and tighten the crucial ones (invariants, bug rationale, SAFETY blocks, ported-source attribution). No functional code changed. Every edit is proven comment-only against the prior tree by a comment-stripping lexer (string/char/raw-string aware) plus a separate doctest-fence check. Where removing a comment made rustfmt or clippy want to re-lay-out adjacent code, the minimal triggering comment is restored so code tokens stay byte-identical. Gates green: cargo fmt --all --check (0 diffs), cargo check and cargo clippy --workspace --all-targets (0 warnings). Adds scripts/check_codegen_comment_guidelines.py — the enforcement gate for these guidelines (flags banners, end-of-line comments, change narration, and commented-out code).
296 lines
11 KiB
Rust
296 lines
11 KiB
Rust
//! Numeric ↔ string error-code mapping.
|
|
//!
|
|
//! Receivers SHOULD switch on `data.code` (the snake_case string) rather
|
|
//! than the numeric JSON-RPC `error.code`. The numeric is the JSON-RPC
|
|
//! envelope code; the string is the Kigi stable identifier.
|
|
//!
|
|
//! The mapping is a flat table scanned linearly: the set is small and fixed,
|
|
//! so that beats any `HashMap`/`OnceLock`-shaped alternative.
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::error_wire::ToolErrorWire;
|
|
|
|
/// `(numeric_code, string_code)` pairs. Both columns are unique.
|
|
pub const ERROR_CODES: &[(i32, &str)] = &[
|
|
(-32700, "parse_error"),
|
|
(-32600, "invalid_request"),
|
|
(-32601, "method_not_found"),
|
|
(-32602, "invalid_params"),
|
|
(-32603, "internal_error"),
|
|
(-32605, "unsupported_protocol_version"),
|
|
(-32001, "timeout"),
|
|
(-32002, "unauthorized"),
|
|
(-32003, "forbidden"),
|
|
(-32004, "connection_lost"),
|
|
(-32005, "tool_server_gone"),
|
|
(-32006, "session_not_found"),
|
|
(-32008, "session_draining"),
|
|
(-32011, "tool_not_found"),
|
|
(-32012, "tool_already_registered"),
|
|
(-32013, "tool_unavailable"),
|
|
(-32014, "stale_generation"),
|
|
(-32015, "duplicate_client_name"),
|
|
(-32016, "tool_busy"),
|
|
(-32017, "notification_schema_violation"),
|
|
(-32018, "frame_too_large"),
|
|
(-32019, "schema_unknown_kind"),
|
|
(-32020, "behavior_version_unsupported"),
|
|
(-32021, "server_id_in_use"),
|
|
(-32022, "invalid_description"),
|
|
(-32023, "render_limited"),
|
|
(-32024, "terminal_error"),
|
|
(-32099, "rate_limited"),
|
|
];
|
|
|
|
/// Receivers should fall back to `-32603 internal_error` for strings that
|
|
/// are not in the table.
|
|
pub fn numeric_for(code_str: &str) -> Option<i32> {
|
|
ERROR_CODES
|
|
.iter()
|
|
.find_map(|(n, s)| (*s == code_str).then_some(*n))
|
|
}
|
|
|
|
pub fn string_for(code: i32) -> Option<&'static str> {
|
|
ERROR_CODES
|
|
.iter()
|
|
.find_map(|(n, s)| (*n == code).then_some(*s))
|
|
}
|
|
|
|
/// `Custom` always maps to `-32603 internal_error`, since by definition its
|
|
/// `code` string is not in the table.
|
|
pub fn from_tool_error_wire(err: &ToolErrorWire) -> i32 {
|
|
match err {
|
|
ToolErrorWire::ToolNotFound { .. } => -32011,
|
|
ToolErrorWire::SessionMismatch => -32600,
|
|
ToolErrorWire::PermissionDenied { .. } => -32003,
|
|
ToolErrorWire::TransportClosed { .. } => -32004,
|
|
ToolErrorWire::Timeout { .. } => -32001,
|
|
ToolErrorWire::Cancelled { .. } => -32603,
|
|
ToolErrorWire::InvalidArguments { .. } => -32602,
|
|
ToolErrorWire::Execution { .. } => -32603,
|
|
ToolErrorWire::UnsupportedProtocolVersion { .. } => -32605,
|
|
ToolErrorWire::PayloadTooLarge { .. } => -32018,
|
|
ToolErrorWire::BehaviorVersionUnsupported { .. } => -32020,
|
|
ToolErrorWire::Internal { .. } => -32603,
|
|
ToolErrorWire::RenderLimited { .. } => -32023,
|
|
ToolErrorWire::TerminalError { .. } => -32024,
|
|
ToolErrorWire::Custom { .. } => -32603,
|
|
}
|
|
}
|
|
|
|
/// Stable identifier for "this session's workspace (tool) server is gone;
|
|
/// re-provision and retry", used as both the [`ToolErrorWire::Custom`] subcode
|
|
/// and the `details["code"]` value. Reusing `Custom` (not a new variant) keeps
|
|
/// the frame deserializable on older peers.
|
|
pub const WORKSPACE_UNAVAILABLE_SUBCODE: &str = "workspace_unavailable";
|
|
|
|
/// Generic, tenant-data-free message paired with the workspace-gone error.
|
|
pub const WORKSPACE_UNAVAILABLE_MESSAGE: &str = "workspace server gone; re-provision and retry";
|
|
|
|
/// JSON-RPC envelope code paired with the workspace-unavailable error. Shares
|
|
/// the canonical `tool_server_gone` numeric; recognizers key on `data.subcode`,
|
|
/// not this companion.
|
|
pub const WORKSPACE_UNAVAILABLE_JSONRPC_CODE: i32 = -32005;
|
|
|
|
/// Why the workspace (tool) server went away. `Unknown` absorbs values a newer
|
|
/// peer may add, so the typed parse never fails across independently-deployed
|
|
/// hub/SDK versions.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum WorkspaceGoneReason {
|
|
IdleTimeout,
|
|
Disconnect,
|
|
Shutdown,
|
|
/// No owner has bound a tool-server for the session yet (an attach-time
|
|
/// miss), as opposed to a workspace that was bound and then lost.
|
|
NotBound,
|
|
/// Target hub liveness key absent (origin reaper or forward-time check).
|
|
InstanceGone,
|
|
#[serde(other)]
|
|
Unknown,
|
|
}
|
|
|
|
/// When, relative to the failing tool call, the loss was observed. `Unknown`
|
|
/// absorbs values a newer peer may add.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum WorkspaceGonePhase {
|
|
InFlightCancelled,
|
|
RouteMissing,
|
|
/// Observed while resolving a `session_attach_server` request.
|
|
Attach,
|
|
#[serde(other)]
|
|
Unknown,
|
|
}
|
|
|
|
/// Structured payload placed in the wire `details` object. `code` mirrors the
|
|
/// `Custom` subcode (the `ToolError::custom` convention), so it survives a
|
|
/// `Wire → ToolError → Wire` round-trip and is the field recognizers read.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct WorkspaceUnavailableDetails {
|
|
pub code: String,
|
|
pub reason: WorkspaceGoneReason,
|
|
pub phase: WorkspaceGonePhase,
|
|
pub retryable: bool,
|
|
}
|
|
|
|
pub fn workspace_unavailable_wire(
|
|
reason: WorkspaceGoneReason,
|
|
phase: WorkspaceGonePhase,
|
|
) -> ToolErrorWire {
|
|
let details = serde_json::to_value(WorkspaceUnavailableDetails {
|
|
code: WORKSPACE_UNAVAILABLE_SUBCODE.to_owned(),
|
|
reason,
|
|
phase,
|
|
retryable: true,
|
|
});
|
|
// This plain struct serializes infallibly; a missing `details` would make
|
|
// the error unrecognizable, so guard the invariant in debug builds.
|
|
debug_assert!(details.is_ok(), "workspace details must serialize");
|
|
ToolErrorWire::Custom {
|
|
subcode: WORKSPACE_UNAVAILABLE_SUBCODE.to_owned(),
|
|
message: WORKSPACE_UNAVAILABLE_MESSAGE.to_owned(),
|
|
details: details.ok(),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use serde_json::json;
|
|
|
|
const REASONS: [WorkspaceGoneReason; 5] = [
|
|
WorkspaceGoneReason::IdleTimeout,
|
|
WorkspaceGoneReason::Disconnect,
|
|
WorkspaceGoneReason::Shutdown,
|
|
WorkspaceGoneReason::NotBound,
|
|
WorkspaceGoneReason::InstanceGone,
|
|
];
|
|
const PHASES: [WorkspaceGonePhase; 3] = [
|
|
WorkspaceGonePhase::InFlightCancelled,
|
|
WorkspaceGonePhase::RouteMissing,
|
|
WorkspaceGonePhase::Attach,
|
|
];
|
|
|
|
// Exhaustive-match helpers pin the exact snake_case wire strings; adding a
|
|
// variant forces an update here.
|
|
fn reason_wire(r: WorkspaceGoneReason) -> &'static str {
|
|
match r {
|
|
WorkspaceGoneReason::IdleTimeout => "idle_timeout",
|
|
WorkspaceGoneReason::Disconnect => "disconnect",
|
|
WorkspaceGoneReason::Shutdown => "shutdown",
|
|
WorkspaceGoneReason::NotBound => "not_bound",
|
|
WorkspaceGoneReason::InstanceGone => "instance_gone",
|
|
WorkspaceGoneReason::Unknown => "unknown",
|
|
}
|
|
}
|
|
fn phase_wire(p: WorkspaceGonePhase) -> &'static str {
|
|
match p {
|
|
WorkspaceGonePhase::InFlightCancelled => "in_flight_cancelled",
|
|
WorkspaceGonePhase::RouteMissing => "route_missing",
|
|
WorkspaceGonePhase::Attach => "attach",
|
|
WorkspaceGonePhase::Unknown => "unknown",
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn builder_emits_custom_with_code_in_details_for_every_reason_and_phase() {
|
|
for reason in REASONS {
|
|
for phase in PHASES {
|
|
let v = serde_json::to_value(workspace_unavailable_wire(reason, phase)).unwrap();
|
|
assert_eq!(v["code"], json!("custom"), "outer discriminator");
|
|
assert_eq!(v["subcode"], json!(WORKSPACE_UNAVAILABLE_SUBCODE));
|
|
// details.code mirrors the subcode (round-trip identity).
|
|
assert_eq!(v["details"]["code"], json!(WORKSPACE_UNAVAILABLE_SUBCODE));
|
|
assert_eq!(v["details"]["reason"], json!(reason_wire(reason)));
|
|
assert_eq!(v["details"]["phase"], json!(phase_wire(phase)));
|
|
assert_eq!(v["details"]["retryable"], json!(true));
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn builder_uses_the_pinned_generic_message() {
|
|
let ToolErrorWire::Custom { message, .. } = workspace_unavailable_wire(
|
|
WorkspaceGoneReason::IdleTimeout,
|
|
WorkspaceGonePhase::RouteMissing,
|
|
) else {
|
|
panic!("expected Custom variant");
|
|
};
|
|
assert_eq!(message, WORKSPACE_UNAVAILABLE_MESSAGE);
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_reason_and_phase_deserialize_to_unknown() {
|
|
// Independently-deployed peers may emit reason/phase values this build
|
|
// does not know; the typed parse must absorb them, not fail.
|
|
let details: WorkspaceUnavailableDetails = serde_json::from_value(json!({
|
|
"code": WORKSPACE_UNAVAILABLE_SUBCODE,
|
|
"reason": "reason_from_a_newer_hub",
|
|
"phase": "phase_from_a_newer_hub",
|
|
"retryable": true,
|
|
}))
|
|
.expect("typed parse tolerates unknown enum values");
|
|
assert_eq!(details.reason, WorkspaceGoneReason::Unknown);
|
|
assert_eq!(details.phase, WorkspaceGonePhase::Unknown);
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_reason_serializes_and_round_trips() {
|
|
// The route-missing classifier emits `Unknown` ("cause not observed"),
|
|
// so — despite `Unknown` being the `#[serde(other)]` deserialize
|
|
// catch-all — it must serialize to a stable `"unknown"` label and parse
|
|
// back, both in the wire payload and as the bare enum.
|
|
assert_eq!(
|
|
serde_json::to_value(WorkspaceGoneReason::Unknown).unwrap(),
|
|
json!("unknown"),
|
|
);
|
|
let wire = workspace_unavailable_wire(
|
|
WorkspaceGoneReason::Unknown,
|
|
WorkspaceGonePhase::RouteMissing,
|
|
);
|
|
let v = serde_json::to_value(&wire).unwrap();
|
|
assert_eq!(v["details"]["reason"], json!("unknown"));
|
|
let parsed: WorkspaceUnavailableDetails =
|
|
serde_json::from_value(v["details"].clone()).expect("details round-trip");
|
|
assert_eq!(parsed.reason, WorkspaceGoneReason::Unknown);
|
|
}
|
|
|
|
#[test]
|
|
fn custom_variant_tolerates_unknown_future_details_shape() {
|
|
// An unknown subcode carrying richer details must still deserialize
|
|
// rather than failing the whole frame.
|
|
let future = json!({
|
|
"code": "custom",
|
|
"subcode": "some_future_subcode",
|
|
"message": "from a newer peer",
|
|
"details": {
|
|
"code": "some_future_subcode",
|
|
"extra_new_field": {"nested": [1, 2, 3]},
|
|
},
|
|
});
|
|
let wire: ToolErrorWire =
|
|
serde_json::from_value(future).expect("custom variant deserializes");
|
|
let ToolErrorWire::Custom {
|
|
subcode, details, ..
|
|
} = &wire
|
|
else {
|
|
panic!("expected Custom variant");
|
|
};
|
|
assert_eq!(subcode, "some_future_subcode");
|
|
assert!(
|
|
details
|
|
.as_ref()
|
|
.and_then(|d| d.get("extra_new_field"))
|
|
.is_some(),
|
|
"unknown details fields are preserved",
|
|
);
|
|
let reser = serde_json::to_value(&wire).unwrap();
|
|
assert_eq!(
|
|
reser["details"]["extra_new_field"]["nested"],
|
|
json!([1, 2, 3])
|
|
);
|
|
}
|
|
}
|