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:
ZacharyZhang-NY committed 2026-07-17 05:31:01 -04:00
commit d6c20fc13f
2612 files changed
+1353757

No files matched your search

@@ -0,0 +1,456 @@
//! Concurrency-safe, field-correct writes to a session's `summary.json`.
//!
//! The same `summary.json` is mutated by several writers and, on reconnect, by
//! more than one persistence actor. A whole-summary read-modify-write with no
//! lock loses updates: a writer holding a stale read overwrites a concurrent
//! writer's field on write-back, which silently reverted `last_active_at` and
//! `num_messages` (the active session then sank in the `/resume` picker).
//!
//! [`SummaryPatch`] expresses *intent* (a partial update) rather than a
//! whole-struct snapshot, and [`apply_patch_locked`] applies it under an
//! exclusive lock on a sidecar `summary.json.lock` (never renamed, so the lock
//! spans the entire read-modify-write). All writers funnel through it, so the
//! read-modify-writes serialize across actors and processes.
use std::fs::{File, OpenOptions};
use std::io;
use std::path::Path;
use agent_client_protocol as acp;
use chrono::{DateTime, Utc};
use fs2::FileExt;
use kigi_sampling_types::ReasoningEffort;
use crate::session::persistence::Summary;
/// How a counter field changes. `Increment` is applied to the in-lock fresh
/// read (never precomputed by the caller, which would re-open the race); `Set`
/// is an absolute rewrite (compaction / rewind).
#[derive(Debug, Clone)]
pub(crate) enum CounterOp {
Increment(usize),
Set(usize),
}
impl CounterOp {
fn apply(&self, current: usize) -> usize {
match self {
CounterOp::Increment(n) => current.saturating_add(*n),
CounterOp::Set(n) => *n,
}
}
}
/// Model / agent / reasoning-effort update. Each `None` leaves the existing
/// value unchanged (matches the legacy `update_current_model` semantics).
#[derive(Debug, Clone)]
pub(crate) struct ModelPatch {
pub model_id: acp::ModelId,
pub agent_name: Option<String>,
pub reasoning_effort: Option<Option<ReasoningEffort>>,
}
/// Persisted git HEAD. `commit` and `branch` are last-writer-wins, including
/// being cleared to `None`.
#[derive(Debug, Clone)]
pub(crate) struct GitHeadPatch {
pub commit: Option<String>,
pub branch: Option<String>,
}
/// Telemetry trace bookkeeping. `next_trace_turn` is monotonic; `request_id`
/// is applied only when this turn wins, so a stale lower-turn write cannot
/// leave a high `next_trace_turn` paired with an older `request_id` (these
/// were set together in the legacy read-modify-write path).
#[derive(Debug, Clone)]
pub(crate) struct TraceTurnPatch {
pub next_trace_turn: u64,
pub request_id: Option<String>,
}
/// A typed, partial mutation of a `Summary`. Only the set fields change; the
/// rest are read fresh under the lock and preserved. Per-field merge rules
/// (see [`Summary::apply_patch`]): `last_active_at` / `next_trace_turn` /
/// `chat_format_version` are monotonic (never lowered), counters apply to the
/// fresh read, everything else is last-writer-wins on that field alone.
#[derive(Debug, Clone, Default)]
pub(crate) struct SummaryPatch {
pub record_activity: bool,
pub messages: Option<CounterOp>,
pub chat_messages: Option<CounterOp>,
pub chat_format_version: Option<u8>,
pub trace_turn: Option<TraceTurnPatch>,
pub model: Option<ModelPatch>,
pub git_head: Option<GitHeadPatch>,
pub collection_id: Option<String>,
/// Set the session title unconditionally (last-writer-wins). Used by the
/// manual `/rename` (`/title`) path, which must always win. Also marks the
/// title manual (`Summary::title_is_manual`).
pub generated_title: Option<String>,
/// Set the session title only when the session has no title yet. Used by
/// automatic LLM title generation so it never clobbers a title the user
/// set via `/rename`. Ignored when `generated_title` is also set.
pub generated_title_if_absent: Option<String>,
}
impl Summary {
/// Apply `patch` in place using the per-field merge rules. `now` is the
/// single timestamp used for both `last_active_at` (when activity is
/// recorded) and `updated_at`.
///
/// Returns `true` iff a `generated_title_if_absent` was applied (i.e. the
/// session had no prior title). Callers use this to decide whether to
/// propagate an auto-generated title to remote replicas; every other field
/// always applies and is not reflected in the return value.
pub(crate) fn apply_patch(&mut self, patch: &SummaryPatch, now: DateTime<Utc>) -> bool {
if patch.record_activity {
// Monotonic: a stale concurrent writer can never move it backwards.
self.last_active_at = Some(
self.last_active_at
.map_or(now, |existing| existing.max(now)),
);
}
if let Some(op) = &patch.messages {
self.num_messages = op.apply(self.num_messages);
}
if let Some(op) = &patch.chat_messages {
self.num_chat_messages = op.apply(self.num_chat_messages);
}
if let Some(version) = patch.chat_format_version {
self.chat_format_version = self.chat_format_version.max(version);
}
if let Some(trace_turn) = &patch.trace_turn {
// next_trace_turn is monotonic; keep request_id paired with the
// winning turn so a stale lower-turn write can't re-pair them.
if trace_turn.next_trace_turn >= self.next_trace_turn {
self.next_trace_turn = trace_turn.next_trace_turn;
if let Some(request_id) = &trace_turn.request_id {
self.request_id = Some(request_id.clone());
}
}
}
if let Some(model) = &patch.model {
self.current_model_id = model.model_id.clone();
if let Some(agent_name) = &model.agent_name {
self.agent_name = Some(agent_name.clone());
}
if let Some(reasoning_effort) = &model.reasoning_effort {
self.reasoning_effort = *reasoning_effort;
}
}
if let Some(git_head) = &patch.git_head {
self.head_commit = git_head.commit.clone();
self.head_branch = git_head.branch.clone();
}
if let Some(collection_id) = &patch.collection_id {
self.collection_id = Some(collection_id.clone());
}
let mut absent_title_applied = false;
if let Some(title) = &patch.generated_title {
self.set_title(title);
// Manual `/rename`: recorded so clients can restore the
// prompt-border title on resume.
self.title_is_manual = true;
} else if let Some(title) = &patch.generated_title_if_absent {
// Auto-generated titles defer to any title already present, so a
// manual `/rename` is never overwritten by a racing LLM title.
if self.display_title().trim().is_empty() {
self.set_title(title);
// Defensive: an adopted auto title is never manual.
self.title_is_manual = false;
absent_title_applied = true;
}
}
self.updated_at = now;
absent_title_applied
}
/// Set `generated_title`, mirroring into `session_summary` while that field
/// is still empty so older clients that only read `session_summary` see the
/// title too.
fn set_title(&mut self, title: &str) {
self.generated_title = Some(title.to_owned());
if self.session_summary.is_empty() {
self.session_summary = title.to_owned();
}
}
}
/// Read → apply `patch` → write `summary_path`, serialized by an exclusive lock
/// on the sidecar `lock_path`. The lock is held across the whole read-modify-
/// write so concurrent writers cannot lose each other's updates. Synchronous:
/// callers run it on `spawn_blocking` because the lock acquisition blocks.
///
/// Returns whether a `generated_title_if_absent` was applied (see
/// [`Summary::apply_patch`]). Because the read-modify-write happens under the
/// lock, this "set the title only if absent" check is atomic against a
/// concurrent manual rename.
pub(crate) fn apply_patch_locked(
summary_path: &Path,
lock_path: &Path,
patch: &SummaryPatch,
) -> io::Result<bool> {
let lock = open_lock_file(lock_path)?;
lock.lock_exclusive()?;
let result = read_modify_write(summary_path, patch);
let _ = lock.unlock();
result
}
fn read_modify_write(summary_path: &Path, patch: &SummaryPatch) -> io::Result<bool> {
let mut summary = read_summary(summary_path)?;
let absent_title_applied = summary.apply_patch(patch, Utc::now());
write_summary_atomic(summary_path, &summary)?;
Ok(absent_title_applied)
}
fn open_lock_file(path: &Path) -> io::Result<File> {
OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(path)
}
fn read_summary(path: &Path) -> io::Result<Summary> {
let bytes = std::fs::read(path)?;
if bytes.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("summary.json is empty (0 bytes): {}", path.display()),
));
}
serde_json::from_slice::<Summary>(&bytes)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
}
fn write_summary_atomic(summary_path: &Path, summary: &Summary) -> io::Result<()> {
let bytes = serde_json::to_vec_pretty(summary)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
let tmp = summary_path.with_extension("json.tmp");
std::fs::write(&tmp, &bytes)?;
std::fs::rename(&tmp, summary_path)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::session::info::Info;
use crate::session::storage::StorageAdapter;
use crate::session::storage::jsonl::JsonlStorageAdapter;
use std::sync::Arc;
use tempfile::TempDir;
use tokio::sync::Barrier;
fn test_info() -> Info {
Info {
id: acp::SessionId::new("concurrent-summary-test"),
cwd: "/test".into(),
}
}
/// Regression guard for the `/resume` "frozen `last_active_at`" lost-update
/// race. Two adapters (standing in for two persistence actors) hammer the
/// SAME `summary.json` concurrently: one appends, the other writes metadata.
/// Every write is a whole-summary read-modify-write, so without the sidecar
/// lock the metadata writer reverts the appender's `num_messages` /
/// `last_active_at` (and vice versa). The invariants below are exact, so a
/// regression that drops the lock fails this deterministically: the counter
/// must equal the number of appends and the monotonic field must not regress.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn concurrent_writes_do_not_lose_updates() {
const N: usize = 300;
let dir = TempDir::new().unwrap();
let session_dir = dir.path().join("session");
let info = test_info();
let init = JsonlStorageAdapter::with_explicit_session_dir(session_dir.clone());
init.init_session(&info, acp::ModelId::new("test-model"))
.await
.unwrap();
let appender = JsonlStorageAdapter::with_explicit_session_dir(session_dir.clone());
let metadata = JsonlStorageAdapter::with_explicit_session_dir(session_dir.clone());
let barrier = Arc::new(Barrier::new(2));
let info_a = info.clone();
let barrier_a = barrier.clone();
let task_a = tokio::spawn(async move {
barrier_a.wait().await;
for _ in 0..N {
appender
.apply_summary_patch(
&info_a,
SummaryPatch {
record_activity: true,
messages: Some(CounterOp::Increment(1)),
..Default::default()
},
)
.await
.unwrap();
}
});
let info_b = info.clone();
let barrier_b = barrier.clone();
let task_b = tokio::spawn(async move {
barrier_b.wait().await;
for turn in 0..N {
metadata
.apply_summary_patch(
&info_b,
SummaryPatch {
trace_turn: Some(TraceTurnPatch {
next_trace_turn: turn as u64,
request_id: None,
}),
..Default::default()
},
)
.await
.unwrap();
}
});
task_a.await.unwrap();
task_b.await.unwrap();
let summary = read_summary(&session_dir.join("summary.json")).unwrap();
assert_eq!(
summary.num_messages, N,
"lost an append increment to a racing metadata write",
);
assert_eq!(
summary.next_trace_turn,
(N - 1) as u64,
"monotonic next_trace_turn regressed under contention",
);
assert!(
summary.last_active_at.is_some(),
"activity timestamp was lost",
);
}
/// A freshly-initialized (untitled) session: returns its adapter and the
/// path to the on-disk `summary.json`.
async fn new_session(dir: &TempDir) -> (JsonlStorageAdapter, Info, std::path::PathBuf) {
let session_dir = dir.path().join("session");
let info = test_info();
let adapter = JsonlStorageAdapter::with_explicit_session_dir(session_dir.clone());
adapter
.init_session(&info, acp::ModelId::new("test-model"))
.await
.unwrap();
(adapter, info, session_dir.join("summary.json"))
}
/// Auto title generation writes (and reports `true`) when the session has
/// no title yet, mirroring into `session_summary` for old clients.
#[tokio::test]
async fn auto_title_applies_when_session_has_no_title() {
let dir = TempDir::new().unwrap();
let (adapter, info, summary_path) = new_session(&dir).await;
let applied = adapter
.set_generated_title_if_absent(&info, "Auto Title".into())
.await
.unwrap();
assert!(applied);
let summary = read_summary(&summary_path).unwrap();
assert_eq!(summary.display_title(), "Auto Title");
assert_eq!(summary.session_summary, "Auto Title");
assert!(!summary.title_is_manual);
assert!(summary.manual_title_opt().is_none());
}
/// Regression guard for the `/rename`-during-turn race: an auto-generated
/// title that lands after a manual `/rename` must not overwrite it, and
/// must report `false` so callers skip the remote/registry sync.
#[tokio::test]
async fn auto_title_does_not_clobber_manual_rename() {
let dir = TempDir::new().unwrap();
let (adapter, info, summary_path) = new_session(&dir).await;
adapter
.update_session_title(&info, "Manual Title".into())
.await
.unwrap();
let applied = adapter
.set_generated_title_if_absent(&info, "Auto Title".into())
.await
.unwrap();
assert!(!applied);
let summary = read_summary(&summary_path).unwrap();
assert_eq!(summary.display_title(), "Manual Title");
assert_eq!(summary.manual_title_opt().as_deref(), Some("Manual Title"));
}
/// A manual `/rename` overwrites a title that was already auto-generated.
#[tokio::test]
async fn manual_rename_overrides_existing_auto_title() {
let dir = TempDir::new().unwrap();
let (adapter, info, summary_path) = new_session(&dir).await;
adapter
.set_generated_title_if_absent(&info, "Auto Title".into())
.await
.unwrap();
adapter
.update_session_title(&info, "Manual Title".into())
.await
.unwrap();
let summary = read_summary(&summary_path).unwrap();
assert_eq!(summary.display_title(), "Manual Title");
assert!(summary.title_is_manual, "manual rename must mark the title");
}
/// The race resolved under contention: whichever of a concurrent manual
/// rename / auto title generation grabs the summary lock first, the manual
/// title is always the final on-disk value (the unconditional manual write
/// wins if it lands last; the auto write defers if it lands last). Many
/// iterations so a regression to an unconditional auto overwrite — or
/// moving the "if absent" check outside the lock — fails reliably.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn concurrent_manual_rename_always_wins_over_auto_title() {
for _ in 0..100 {
let dir = TempDir::new().unwrap();
let (adapter, info, summary_path) = new_session(&dir).await;
let barrier = Arc::new(Barrier::new(2));
let manual = adapter.clone();
let info_m = info.clone();
let barrier_m = barrier.clone();
let task_m = tokio::spawn(async move {
barrier_m.wait().await;
manual
.update_session_title(&info_m, "Manual Title".into())
.await
.unwrap();
});
let auto = adapter.clone();
let info_a = info.clone();
let barrier_a = barrier.clone();
let task_a = tokio::spawn(async move {
barrier_a.wait().await;
auto.set_generated_title_if_absent(&info_a, "Auto Title".into())
.await
.unwrap();
});
task_m.await.unwrap();
task_a.await.unwrap();
let summary = read_summary(&summary_path).unwrap();
assert_eq!(summary.display_title(), "Manual Title");
// Manual-ness survives the race in either landing order, so the
// prompt-border title is restored on resume.
assert!(summary.title_is_manual);
}
}
}