The PRD's first acceptance gate now holds: grep -RinE '\bx\.ai\b|grok' crates/ --include='*.rs' → 0 matches (exempt: NOTICE and third-party license archives, README provenance, and the required 'Based on Grok Build Open Source' attribution, now sourced from version_attribution.txt). Wire-visible renames (both sides in this repo, changed in lockstep): - Auth method id 'grok.com' → 'kimi-code' (AuthMethodKind::KimiCode). - Every x.ai/* and _x.ai/* ACP ext method and meta key → kigi/* / _kigi/* (~200 names; grokShell → kigiShell). Session-file replay keeps a read-side alias for the legacy '_x.ai/session/update' method so existing updates.jsonl histories load; writes emit only the new name (both directions test-pinned). - Agent types grok-build* → kigi* with a documented legacy-prefix alias at resolution time so persisted sessions keep resolving. - ToolNamespace/BuiltinAgentName GrokBuild* → Kigi* (wire snake_case kigi/kigi_concise/kigi_hashline; schema regenerated); grok_build implementation dirs renamed to kigi*. - x-grok-* headers → x-kigi-*, __GROK_* sentinels → __KIGI_*, themes grokday/groknight → kigiday/kiginight (old persisted values fall back to the default theme), web_fetch allowlist xAI hosts → kimi.com + moonshot platforms, changelog CDN → this repo, grok-build changelog archives deleted. - BYOK default endpoint removed: [endpoints] api_base_url is now truly optional with NO default — consumers fail fast with the flag name when unset (no silent x.ai egress). Mock harnesses inject it explicitly. - System-prompt identity fixed: 'released by xAI' → 'an unofficial community CLI for Kimi' (template + regenerated encrypted form). Also repaired pre-existing grok-era test debt found by the sweep: the stale trace_classify default-model pin, the grok-pager UA label test, pty-harness stale-binary reuse and non-hermetic moonshot routing (a PTY test could previously reach the real api.moonshot.cn), and the outdated oauth fixture scope key. Gates: §9 grep 0; fmt clean; workspace check/clippy 0/0 (-D warnings); FULL cargo test --workspace: 234 suites, 21,961 passed, 0 failed; deny advisories ok.
312 lines
11 KiB
Rust
312 lines
11 KiB
Rust
//! Roster types for the multi-client FleetView dashboard.
|
|
//!
|
|
//! The roster is a list
|
|
//! of dashboard-sized summaries of every session the leader hosts (resident
|
|
//! actors) plus recently-touched on-disk (`Dormant`) sessions. Clients read it
|
|
//! two ways:
|
|
//!
|
|
//! - request/response `kigi/sessions/list` → `{ "sessions": [RosterEntry, …] }`
|
|
//! - broadcast notification `kigi/sessions/changed` →
|
|
//! `{ "upserted": [RosterEntry, …], "removed": ["sess-abc", …] }`
|
|
//!
|
|
//! The wire shape is intentionally small and current-state only — no event
|
|
//! fold or materialized snapshot is required (the snapshot is deferred).
|
|
|
|
use kigi_sampling_types::ReasoningEffort;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::session::persistence::Summary;
|
|
|
|
/// Coarse activity of a session as rendered in the dashboard's status column.
|
|
///
|
|
/// Mirrors the design's `SessionActivity` at dashboard granularity. A full
|
|
/// background-work breakdown (bg tasks / monitors / scheduler / subagents)
|
|
/// lands with a richer `SessionActivity`; the dashboard only needs this
|
|
/// coarse signal to pick a status glyph.
|
|
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum RosterActivity {
|
|
/// A turn (user-originated or autonomous) is running.
|
|
Working,
|
|
/// Resident, no turn in flight.
|
|
Idle,
|
|
/// A permission / question / plan-approval is pending.
|
|
NeedsInput,
|
|
/// On disk, not resident.
|
|
Dormant,
|
|
/// Finished and resumable.
|
|
Completed,
|
|
/// Actor panicked / load failed.
|
|
Dead,
|
|
}
|
|
|
|
/// Where the session lives. Only `Local` is produced today; `Remote` is
|
|
/// reserved for cross-machine roster aggregation.
|
|
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
|
#[serde(rename_all = "snake_case", tag = "kind")]
|
|
pub enum RosterOrigin {
|
|
Local,
|
|
Remote { host: String },
|
|
}
|
|
|
|
/// One dashboard row.
|
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct RosterEntry {
|
|
pub session_id: String,
|
|
/// Generated/display title, if known. Clients fall back to cwd / id.
|
|
#[serde(default)]
|
|
pub title: Option<String>,
|
|
pub cwd: String,
|
|
pub is_worktree: bool,
|
|
#[serde(default)]
|
|
pub model_id: Option<String>,
|
|
/// Per-session reasoning effort for `model_id`. Carried alongside the model
|
|
/// so clients can render the session's effort in the roster without a
|
|
/// separate `model_state` fetch. `None` means "use the model/global
|
|
/// default" (or the session predates per-session effort persistence).
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub reasoning_effort: Option<ReasoningEffort>,
|
|
pub yolo: bool,
|
|
pub activity: RosterActivity,
|
|
/// `true` while a resident actor hosts the session (vs. read from disk).
|
|
pub resident: bool,
|
|
/// Best-effort last-change timestamp (unix millis). Used for sort order.
|
|
pub last_change_unix_ms: i64,
|
|
pub origin: RosterOrigin,
|
|
}
|
|
|
|
/// Response payload for `kigi/sessions/list`.
|
|
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
|
|
pub struct RosterListResponse {
|
|
pub sessions: Vec<RosterEntry>,
|
|
}
|
|
|
|
/// Params payload for the `kigi/sessions/changed` broadcast notification.
|
|
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
|
|
pub struct RosterChanged {
|
|
#[serde(default)]
|
|
pub upserted: Vec<RosterEntry>,
|
|
#[serde(default)]
|
|
pub removed: Vec<String>,
|
|
}
|
|
|
|
/// JSON-RPC method names for the roster API.
|
|
pub const SESSIONS_LIST_METHOD: &str = "kigi/sessions/list";
|
|
pub const SESSIONS_CHANGED_METHOD: &str = "kigi/sessions/changed";
|
|
|
|
/// Merge live `resident` rows with on-disk `summaries` into the sorted roster.
|
|
/// Pure, so it is unit-testable without disk or a live actor.
|
|
///
|
|
/// Resident rows own the live state but carry no title or last-active time, so
|
|
/// each adopts those from its summary — except a `Working` row keeps its "now"
|
|
/// timestamp. Summaries with no resident row become `Dormant`; keying by id
|
|
/// dedups them. Hidden summaries are excluded.
|
|
pub(crate) fn merge_roster(
|
|
mut entries: Vec<RosterEntry>,
|
|
summaries: Vec<Summary>,
|
|
) -> Vec<RosterEntry> {
|
|
let mut by_id: std::collections::HashMap<String, Summary> = summaries
|
|
.into_iter()
|
|
.filter(|s| !s.is_hidden())
|
|
.map(|s| (s.info.id.0.to_string(), s))
|
|
.collect();
|
|
|
|
// Backfill resident rows; remove the summary so it isn't re-emitted below.
|
|
for entry in &mut entries {
|
|
let Some(summary) = by_id.remove(&entry.session_id) else {
|
|
continue;
|
|
};
|
|
if let Some(title) = summary.display_title_opt() {
|
|
entry.title = Some(title);
|
|
}
|
|
if entry.activity != RosterActivity::Working {
|
|
entry.last_change_unix_ms = summary.last_change_unix_ms();
|
|
}
|
|
}
|
|
|
|
// Remaining summaries have no resident row: emit them as dormant.
|
|
entries.extend(by_id.into_values().map(|summary| RosterEntry {
|
|
session_id: summary.info.id.0.to_string(),
|
|
title: summary.display_title_opt(),
|
|
cwd: summary.info.cwd.clone(),
|
|
is_worktree: summary.session_kind.as_deref() == Some("worktree")
|
|
|| summary.source_workspace_dir.is_some(),
|
|
model_id: Some(summary.current_model_id.0.to_string()),
|
|
reasoning_effort: summary.reasoning_effort,
|
|
yolo: false,
|
|
activity: RosterActivity::Dormant,
|
|
resident: false,
|
|
last_change_unix_ms: summary.last_change_unix_ms(),
|
|
origin: RosterOrigin::Local,
|
|
}));
|
|
|
|
// Most-recently-changed first.
|
|
entries.sort_by_key(|entry| std::cmp::Reverse(entry.last_change_unix_ms));
|
|
entries
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod merge_roster_tests {
|
|
use super::*;
|
|
use crate::session::info::Info;
|
|
use crate::session::persistence::default_model_id;
|
|
use agent_client_protocol as acp;
|
|
|
|
fn summary(id: &str, title: Option<&str>, last_active_ms: i64) -> Summary {
|
|
let mut s = Summary::new(
|
|
&Info {
|
|
id: acp::SessionId::new(id),
|
|
cwd: format!("/repo/{id}"),
|
|
},
|
|
default_model_id(),
|
|
)
|
|
.expect("summary");
|
|
s.generated_title = title.map(String::from);
|
|
s.last_active_at = chrono::DateTime::from_timestamp_millis(last_active_ms);
|
|
s
|
|
}
|
|
|
|
fn resident(id: &str, activity: RosterActivity, last_change_unix_ms: i64) -> RosterEntry {
|
|
RosterEntry {
|
|
session_id: id.to_string(),
|
|
title: None,
|
|
cwd: format!("/live/{id}"),
|
|
is_worktree: false,
|
|
model_id: Some("kigi-4".into()),
|
|
reasoning_effort: None,
|
|
yolo: false,
|
|
activity,
|
|
resident: true,
|
|
last_change_unix_ms,
|
|
origin: RosterOrigin::Local,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn idle_resident_adopts_persisted_title_and_last_active() {
|
|
let now = 9_000;
|
|
let out = merge_roster(
|
|
vec![resident("a", RosterActivity::Idle, now)],
|
|
vec![summary("a", Some("Fix the roster"), 1_234)],
|
|
);
|
|
assert_eq!(out.len(), 1, "resident must not be duplicated as dormant");
|
|
assert_eq!(out[0].title.as_deref(), Some("Fix the roster"));
|
|
assert_eq!(out[0].last_change_unix_ms, 1_234, "idle adopts last-active");
|
|
assert!(out[0].resident);
|
|
assert_eq!(out[0].cwd, "/live/a", "live cwd is preserved");
|
|
assert_eq!(out[0].activity, RosterActivity::Idle);
|
|
}
|
|
|
|
#[test]
|
|
fn working_resident_keeps_now_but_adopts_title() {
|
|
let now = 9_000;
|
|
let out = merge_roster(
|
|
vec![resident("a", RosterActivity::Working, now)],
|
|
vec![summary("a", Some("Busy turn"), 1_234)],
|
|
);
|
|
assert_eq!(out[0].title.as_deref(), Some("Busy turn"));
|
|
assert_eq!(out[0].last_change_unix_ms, now, "Working stays 'now'");
|
|
}
|
|
|
|
#[test]
|
|
fn new_resident_without_summary_stays_titleless_now() {
|
|
let now = 9_000;
|
|
let out = merge_roster(vec![resident("a", RosterActivity::Idle, now)], vec![]);
|
|
assert_eq!(out[0].title, None);
|
|
assert_eq!(out[0].last_change_unix_ms, now);
|
|
}
|
|
|
|
#[test]
|
|
fn blank_persisted_title_leaves_row_untitled() {
|
|
let out = merge_roster(
|
|
vec![resident("a", RosterActivity::Idle, 9_000)],
|
|
vec![summary("a", Some(" "), 1_234)],
|
|
);
|
|
assert_eq!(out[0].title, None, "blank title normalizes to None");
|
|
assert_eq!(out[0].last_change_unix_ms, 1_234);
|
|
}
|
|
|
|
#[test]
|
|
fn dormant_sessions_are_emitted_and_sorted_after_residents() {
|
|
let out = merge_roster(
|
|
vec![resident("live", RosterActivity::Idle, 5_000)],
|
|
vec![
|
|
summary("live", Some("Live one"), 4_000),
|
|
summary("old", Some("Dormant one"), 1_000),
|
|
summary("new", Some("Newer dormant"), 8_000),
|
|
],
|
|
);
|
|
let ids: Vec<&str> = out.iter().map(|e| e.session_id.as_str()).collect();
|
|
assert_eq!(
|
|
ids,
|
|
vec!["new", "live", "old"],
|
|
"sorted by last-change desc"
|
|
);
|
|
let dormant = out.iter().find(|e| e.session_id == "new").unwrap();
|
|
assert_eq!(dormant.activity, RosterActivity::Dormant);
|
|
assert!(!dormant.resident);
|
|
}
|
|
|
|
#[test]
|
|
fn duplicate_summaries_are_deduped() {
|
|
let out = merge_roster(
|
|
vec![],
|
|
vec![
|
|
summary("dup", Some("First"), 1_000),
|
|
summary("dup", Some("Second"), 2_000),
|
|
],
|
|
);
|
|
assert_eq!(out.len(), 1, "duplicate ids collapse to one row");
|
|
}
|
|
|
|
#[test]
|
|
fn hidden_summaries_are_excluded() {
|
|
let mut hidden = summary("sub", Some("Subagent"), 5_000);
|
|
hidden.session_kind = Some("subagent".into());
|
|
let out = merge_roster(vec![], vec![hidden]);
|
|
assert!(out.is_empty(), "hidden/subagent summaries are dropped");
|
|
}
|
|
|
|
#[test]
|
|
fn dormant_row_carries_persisted_reasoning_effort() {
|
|
let mut s = summary("dorm", Some("Dormant"), 1_000);
|
|
s.reasoning_effort = Some(ReasoningEffort::Xhigh);
|
|
let out = merge_roster(vec![], vec![s]);
|
|
assert_eq!(out[0].reasoning_effort, Some(ReasoningEffort::Xhigh));
|
|
}
|
|
|
|
#[test]
|
|
fn resident_effort_is_taken_from_the_live_row_not_the_summary() {
|
|
// The live handle is authoritative for a resident session, so the
|
|
// resident row's effort must survive the summary backfill.
|
|
let mut live = resident("a", RosterActivity::Idle, 9_000);
|
|
live.reasoning_effort = Some(ReasoningEffort::High);
|
|
let mut s = summary("a", Some("Title"), 1_234);
|
|
s.reasoning_effort = Some(ReasoningEffort::Low);
|
|
let out = merge_roster(vec![live], vec![s]);
|
|
assert_eq!(out.len(), 1);
|
|
assert_eq!(out[0].reasoning_effort, Some(ReasoningEffort::High));
|
|
}
|
|
|
|
#[test]
|
|
fn reasoning_effort_serializes_as_camel_case_and_skips_when_none() {
|
|
let with_effort = RosterEntry {
|
|
reasoning_effort: Some(ReasoningEffort::Xhigh),
|
|
..resident("a", RosterActivity::Idle, 1)
|
|
};
|
|
let json = serde_json::to_string(&with_effort).unwrap();
|
|
assert!(
|
|
json.contains("\"reasoningEffort\":\"xhigh\""),
|
|
"effort must be camelCase and snake_case-valued: {json}"
|
|
);
|
|
|
|
let without = resident("b", RosterActivity::Idle, 1);
|
|
let json = serde_json::to_string(&without).unwrap();
|
|
assert!(
|
|
!json.contains("reasoningEffort"),
|
|
"a None effort must not be serialized: {json}"
|
|
);
|
|
}
|
|
}
|