M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
@@ -0,0 +1,300 @@
|
||||
//! 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 Grok stable identifier.
|
||||
//!
|
||||
//! Implemented as a `&'static [(i32, &'static str)]` table; the table is
|
||||
//! a small fixed set so a linear scan is faster than 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"),
|
||||
];
|
||||
|
||||
/// Returns `None` for strings not in the table. Receivers should fall
|
||||
/// back to `-32603 internal_error` for unknown strings.
|
||||
pub fn numeric_for(code_str: &str) -> Option<i32> {
|
||||
ERROR_CODES
|
||||
.iter()
|
||||
.find_map(|(n, s)| (*s == code_str).then_some(*n))
|
||||
}
|
||||
|
||||
/// Returns `None` for codes not in the table.
|
||||
pub fn string_for(code: i32) -> Option<&'static str> {
|
||||
ERROR_CODES
|
||||
.iter()
|
||||
.find_map(|(n, s)| (*n == code).then_some(*s))
|
||||
}
|
||||
|
||||
/// Numeric code most-appropriate for a [`ToolErrorWire`] variant.
|
||||
/// `Custom` always maps to `-32603 internal_error` since its `code`
|
||||
/// string is not in the table by definition.
|
||||
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,
|
||||
}
|
||||
|
||||
/// Build the recognizable "workspace gone" error as a [`ToolErrorWire::Custom`].
|
||||
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");
|
||||
};
|
||||
// Exact, tenant-data-free contract.
|
||||
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 + richer future details must still deserialize rather than failing the 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",
|
||||
);
|
||||
// Re-serialization preserves the unknown fields.
|
||||
let reser = serde_json::to_value(&wire).unwrap();
|
||||
assert_eq!(
|
||||
reser["details"]["extra_new_field"]["nested"],
|
||||
json!([1, 2, 3])
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user