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,44 @@
|
||||
[package]
|
||||
license = "Apache-2.0"
|
||||
name = "kigi-file-utils"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
description = "Local data collection: per-turn event tracking"
|
||||
authors = ["xAI"]
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
aws-sdk-s3 = { version = "1", default-features = false, features = [
|
||||
"rt-tokio",
|
||||
] }
|
||||
aws-config = { version = "1", default-features = false, features = [
|
||||
"rt-tokio",
|
||||
"sso",
|
||||
"credentials-process",
|
||||
] }
|
||||
aws-smithy-http-client = { version = "1", features = ["rustls-ring"] }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
strum = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "sync", "time", "io-util", "fs"] }
|
||||
sha2 = { workspace = true, features = ["force-soft"] }
|
||||
opentelemetry = { workspace = true }
|
||||
tracing-opentelemetry = { workspace = true }
|
||||
|
||||
|
||||
[features]
|
||||
default-bazel = []
|
||||
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "sync", "net", "time"] }
|
||||
opentelemetry_sdk = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,179 @@
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use chrono::Utc;
|
||||
use serde::Serialize;
|
||||
|
||||
use super::types::Event;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct EventEntry {
|
||||
ts: String,
|
||||
#[serde(flatten)]
|
||||
event: Event,
|
||||
}
|
||||
|
||||
const EVENTS_FILE: &str = "events.jsonl";
|
||||
|
||||
/// Shared event writer for `events.jsonl`. `Clone + Send + Sync`.
|
||||
#[derive(Clone)]
|
||||
pub struct EventWriter {
|
||||
inner: Arc<EventWriterInner>,
|
||||
}
|
||||
|
||||
struct EventWriterInner {
|
||||
file: Mutex<Option<File>>,
|
||||
error_logged: AtomicBool,
|
||||
}
|
||||
|
||||
impl EventWriter {
|
||||
pub fn open(session_dir: &Path) -> Self {
|
||||
let path = session_dir.join(EVENTS_FILE);
|
||||
let file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)
|
||||
.map_err(|e| {
|
||||
tracing::warn!(path = %path.display(), error = %e, "failed to open {EVENTS_FILE}");
|
||||
e
|
||||
})
|
||||
.ok();
|
||||
Self {
|
||||
inner: Arc::new(EventWriterInner {
|
||||
file: Mutex::new(file),
|
||||
error_logged: AtomicBool::new(false),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// No-op writer that discards all events.
|
||||
pub fn noop() -> Self {
|
||||
Self {
|
||||
inner: Arc::new(EventWriterInner {
|
||||
file: Mutex::new(None),
|
||||
error_logged: AtomicBool::new(true), // suppress error logging
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn emit(&self, event: Event) {
|
||||
let entry = EventEntry {
|
||||
ts: Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
|
||||
event,
|
||||
};
|
||||
let Ok(mut line) = serde_json::to_vec(&entry) else {
|
||||
return;
|
||||
};
|
||||
line.push(b'\n');
|
||||
|
||||
let Ok(mut guard) = self.inner.file.lock() else {
|
||||
return;
|
||||
};
|
||||
if let Some(ref mut f) = *guard
|
||||
&& let Err(e) = f.write_all(&line)
|
||||
&& !self.inner.error_logged.swap(true, Ordering::Relaxed)
|
||||
{
|
||||
tracing::warn!(error = %e, "{EVENTS_FILE} write failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for EventWriter {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("EventWriter").finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::events::types::{
|
||||
EVENT_SCHEMA_VERSION, Event, SessionRelationship, TurnOutcomeLabel,
|
||||
};
|
||||
|
||||
fn _assert_event_writer_is_send_sync_clone()
|
||||
where
|
||||
EventWriter: Send + Sync + Clone,
|
||||
{
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_emit_writes_jsonl() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let writer = EventWriter::open(dir.path());
|
||||
|
||||
writer.emit(Event::TurnStarted {
|
||||
session_id: "test-session".into(),
|
||||
turn_number: 1,
|
||||
model_id: "grok-3".into(),
|
||||
yolo_mode: false,
|
||||
conversation_message_count: 0,
|
||||
session_relationship: SessionRelationship::Primary,
|
||||
schema_version: EVENT_SCHEMA_VERSION.into(),
|
||||
redirect_kind: None,
|
||||
});
|
||||
writer.emit(Event::FirstToken);
|
||||
writer.emit(Event::TurnEnded {
|
||||
outcome: TurnOutcomeLabel::Completed,
|
||||
cancellation_category: None,
|
||||
cancellation_context: None,
|
||||
});
|
||||
|
||||
let text = std::fs::read_to_string(dir.path().join("events.jsonl")).unwrap();
|
||||
let lines: Vec<&str> = text.trim().split('\n').collect();
|
||||
assert_eq!(lines.len(), 3);
|
||||
|
||||
let first: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
|
||||
assert_eq!(first["type"], "turn_started");
|
||||
assert_eq!(first["session_id"], "test-session");
|
||||
assert!(first["ts"].as_str().is_some());
|
||||
|
||||
let second: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
|
||||
assert_eq!(second["type"], "first_token");
|
||||
|
||||
let third: serde_json::Value = serde_json::from_str(lines[2]).unwrap();
|
||||
assert_eq!(third["type"], "turn_ended");
|
||||
assert_eq!(third["outcome"], "completed");
|
||||
assert!(third.get("cancellation_category").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cloned_writer_shares_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let w1 = EventWriter::open(dir.path());
|
||||
let w2 = w1.clone();
|
||||
|
||||
w1.emit(Event::FirstToken);
|
||||
w2.emit(Event::FirstToken);
|
||||
|
||||
let text = std::fs::read_to_string(dir.path().join("events.jsonl")).unwrap();
|
||||
let lines: Vec<&str> = text.trim().split('\n').collect();
|
||||
assert_eq!(lines.len(), 2, "both writes should go to the same file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_server_failed_serializes_enum_error_type() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let w = EventWriter::open(dir.path());
|
||||
|
||||
w.emit(Event::McpServerFailed {
|
||||
server_name: "confluence".into(),
|
||||
transport: Some("http".into()),
|
||||
target: Some("https://mcp.confluence.example.com".into()),
|
||||
error_type: crate::events::types::McpErrorCategory::Timeout,
|
||||
error_message: "timed out after 10s".into(),
|
||||
duration_ms: Some(10002),
|
||||
timeout_sec: Some(10),
|
||||
});
|
||||
|
||||
let text = std::fs::read_to_string(dir.path().join("events.jsonl")).unwrap();
|
||||
let val: serde_json::Value = serde_json::from_str(text.trim()).unwrap();
|
||||
assert_eq!(val["type"], "mcp_server_failed");
|
||||
assert_eq!(val["error_type"], "timeout");
|
||||
assert_eq!(val["server_name"], "confluence");
|
||||
assert_eq!(val["duration_ms"], 10002);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//! Per-session event log (`events.jsonl`).
|
||||
|
||||
pub mod log;
|
||||
pub mod tracker;
|
||||
pub mod types;
|
||||
|
||||
pub use log::EventWriter;
|
||||
pub use tracker::EventTracker;
|
||||
pub use types::{
|
||||
CancellationCategory, EVENT_SCHEMA_VERSION, Event, McpConfigServer, McpErrorCategory,
|
||||
PermissionDecision, Phase, SessionRelationship, ToolOutcome, TurnOutcomeLabel,
|
||||
};
|
||||
@@ -0,0 +1,261 @@
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::path::Path;
|
||||
use std::time::Instant;
|
||||
|
||||
use super::log::EventWriter;
|
||||
use super::types::{CancellationCategory, Event, RedirectKind, TurnOutcomeLabel};
|
||||
|
||||
/// Per-session event state. `!Send` — lives on the session actor.
|
||||
/// Background tasks use `tracker.writer()` to get a `Clone + Send + Sync` handle.
|
||||
pub struct EventTracker {
|
||||
writer: EventWriter,
|
||||
turn_ended_emitted: Cell<bool>,
|
||||
active_tool: RefCell<Option<(String, Instant)>>,
|
||||
turn_tool_count: Cell<u32>,
|
||||
/// Cross-turn one-shot: the *fatal* user-interrupt cause that cancelled the
|
||||
/// most recent turn (set by the cancel paths), consumed by the *next* real
|
||||
/// user prompt to tag `UserItem::prior_turn_interrupt`. Deliberately NOT
|
||||
/// reset by `begin_turn` — it must survive into the next turn; the consumer
|
||||
/// clears it via `take_prior_interrupt_category`. Interjections are NOT
|
||||
/// recorded here (they don't cancel the turn; see `Event::Interjected`).
|
||||
prior_interrupt_category: Cell<Option<CancellationCategory>>,
|
||||
/// Cross-turn one-shot: the redirect mechanism for the NEXT turn after a
|
||||
/// mid-turn abort — `CancelThenSend` (nothing was queued) or
|
||||
/// `QueuedAfterCancel` (a prompt sat queued behind the aborted turn). Set
|
||||
/// by `cancel_running_task`, consumed by the next user `turn_started` to
|
||||
/// stamp `Event::TurnStarted::redirect_kind`. Like `prior_interrupt_category`
|
||||
/// it deliberately survives `begin_turn` so it reaches the next real turn.
|
||||
prior_redirect_kind: Cell<Option<RedirectKind>>,
|
||||
/// Cross-turn one-shot: armed by the cancel path only when a turn was aborted
|
||||
/// mid-stream with NO tool in flight, so neither the dangling-tool-call
|
||||
/// repair nor a permission tool-result will tell the model it was
|
||||
/// interrupted. Consumed by the next *real* user prompt to inject an
|
||||
/// interrupt `<system-reminder>`. Like the markers above it deliberately
|
||||
/// survives `begin_turn` so it reaches the next real turn.
|
||||
pending_interrupt_reminder: Cell<bool>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for EventTracker {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let active_tool = self.active_tool.borrow();
|
||||
f.debug_struct("EventTracker")
|
||||
.field("writer", &self.writer)
|
||||
.field("turn_ended_emitted", &self.turn_ended_emitted.get())
|
||||
.field("turn_tool_count", &self.turn_tool_count.get())
|
||||
.field("active_tool", &active_tool.as_ref().map(|(name, _)| name))
|
||||
.field(
|
||||
"prior_interrupt_category",
|
||||
&self.prior_interrupt_category.get(),
|
||||
)
|
||||
.field("prior_redirect_kind", &self.prior_redirect_kind.get())
|
||||
.field(
|
||||
"pending_interrupt_reminder",
|
||||
&self.pending_interrupt_reminder.get(),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventTracker {
|
||||
pub fn new(session_dir: &Path) -> Self {
|
||||
Self {
|
||||
writer: EventWriter::open(session_dir),
|
||||
turn_ended_emitted: Cell::new(false),
|
||||
active_tool: RefCell::new(None),
|
||||
turn_tool_count: Cell::new(0),
|
||||
prior_interrupt_category: Cell::new(None),
|
||||
prior_redirect_kind: Cell::new(None),
|
||||
pending_interrupt_reminder: Cell::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Clone the writer for background tasks.
|
||||
pub fn writer(&self) -> EventWriter {
|
||||
self.writer.clone()
|
||||
}
|
||||
|
||||
pub fn emit(&self, event: Event) {
|
||||
self.writer.emit(event);
|
||||
}
|
||||
|
||||
/// Reset per-turn state. Called at the start of each turn.
|
||||
pub fn begin_turn(&self) {
|
||||
self.turn_ended_emitted.set(false);
|
||||
self.turn_tool_count.set(0);
|
||||
}
|
||||
|
||||
/// Emit `turn_ended` with a double-emission guard.
|
||||
pub fn emit_turn_ended(
|
||||
&self,
|
||||
outcome: TurnOutcomeLabel,
|
||||
category: Option<CancellationCategory>,
|
||||
context: Option<serde_json::Value>,
|
||||
) {
|
||||
if self.turn_ended_emitted.replace(true) {
|
||||
return;
|
||||
}
|
||||
self.emit(Event::TurnEnded {
|
||||
outcome,
|
||||
cancellation_category: category,
|
||||
cancellation_context: context,
|
||||
});
|
||||
}
|
||||
|
||||
/// Set the active tool for cancellation tracking and return the start instant.
|
||||
pub fn tool_started(&self, tool_name: String) -> Instant {
|
||||
let now = Instant::now();
|
||||
*self.active_tool.borrow_mut() = Some((tool_name, now));
|
||||
self.turn_tool_count.set(self.turn_tool_count.get() + 1);
|
||||
now
|
||||
}
|
||||
|
||||
pub fn tool_count_this_turn(&self) -> u32 {
|
||||
self.turn_tool_count.get()
|
||||
}
|
||||
|
||||
pub fn has_active_tool(&self) -> bool {
|
||||
self.active_tool.borrow().is_some()
|
||||
}
|
||||
|
||||
pub fn tool_finished(&self) {
|
||||
*self.active_tool.borrow_mut() = None;
|
||||
}
|
||||
|
||||
/// Cancel in-flight tool and emit `ToolCompleted(cancelled)`.
|
||||
/// Called from `cancel_running_task()` before `turn_ended`.
|
||||
pub fn cancel_active_tool(&self) {
|
||||
if let Some((tool_name, start)) = self.active_tool.borrow_mut().take() {
|
||||
self.emit(Event::ToolCompleted {
|
||||
tool_name,
|
||||
duration_ms: start.elapsed().as_millis() as u64,
|
||||
outcome: super::types::ToolOutcome::Cancelled,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Record the *fatal* user-interrupt cause that cancelled this turn so the
|
||||
/// *next* real user prompt can be tagged. Overwrites any prior value (latest
|
||||
/// cause wins).
|
||||
pub fn set_prior_interrupt_category(&self, category: CancellationCategory) {
|
||||
self.prior_interrupt_category.set(Some(category));
|
||||
}
|
||||
|
||||
/// Take (and clear) the recorded prior-turn interrupt cause.
|
||||
pub fn take_prior_interrupt_category(&self) -> Option<CancellationCategory> {
|
||||
self.prior_interrupt_category.take()
|
||||
}
|
||||
|
||||
/// Record the redirect mechanism (`CancelThenSend` / `QueuedAfterCancel`)
|
||||
/// for the next turn after a mid-turn abort. Overwrites any prior value
|
||||
/// (latest abort wins).
|
||||
pub fn set_prior_redirect_kind(&self, kind: RedirectKind) {
|
||||
self.prior_redirect_kind.set(Some(kind));
|
||||
}
|
||||
|
||||
/// Take (and clear) the recorded prior-turn redirect kind.
|
||||
pub fn take_prior_redirect_kind(&self) -> Option<RedirectKind> {
|
||||
self.prior_redirect_kind.take()
|
||||
}
|
||||
|
||||
/// Arm the one-shot interrupt reminder for the next real user prompt. Set
|
||||
/// only on the cancel path when no tool was in flight (the case where the
|
||||
/// model would otherwise get no signal that it was interrupted).
|
||||
pub fn set_pending_interrupt_reminder(&self) {
|
||||
self.pending_interrupt_reminder.set(true);
|
||||
}
|
||||
|
||||
/// Take (and clear) the pending interrupt-reminder flag.
|
||||
pub fn take_pending_interrupt_reminder(&self) -> bool {
|
||||
self.pending_interrupt_reminder.replace(false)
|
||||
}
|
||||
|
||||
/// Emit PhaseChanged(PermissionPrompt) → PermissionRequested.
|
||||
/// Returns the Instant for `permission_resolved()` to compute wait_ms.
|
||||
pub fn permission_requested(&self, tool_name: &str) -> Instant {
|
||||
self.emit(Event::PhaseChanged {
|
||||
phase: super::types::Phase::PermissionPrompt,
|
||||
});
|
||||
self.emit(Event::PermissionRequested {
|
||||
tool_name: tool_name.to_string(),
|
||||
});
|
||||
Instant::now()
|
||||
}
|
||||
|
||||
pub fn permission_resolved(
|
||||
&self,
|
||||
tool_name: &str,
|
||||
decision: super::types::PermissionDecision,
|
||||
start: Instant,
|
||||
) {
|
||||
self.emit(Event::PermissionResolved {
|
||||
tool_name: tool_name.to_string(),
|
||||
decision,
|
||||
wait_ms: start.elapsed().as_millis() as u64,
|
||||
});
|
||||
self.emit(Event::PhaseChanged {
|
||||
phase: super::types::Phase::ToolExecution,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn prior_interrupt_markers_are_one_shot_and_survive_begin_turn() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let t = EventTracker::new(dir.path());
|
||||
|
||||
// Defaults: nothing recorded.
|
||||
assert_eq!(t.take_prior_interrupt_category(), None);
|
||||
assert!(t.take_prior_redirect_kind().is_none());
|
||||
assert!(!t.take_pending_interrupt_reminder());
|
||||
|
||||
// Cancel cause is consumed exactly once.
|
||||
t.set_prior_interrupt_category(CancellationCategory::MidTurnAbort);
|
||||
assert_eq!(
|
||||
t.take_prior_interrupt_category(),
|
||||
Some(CancellationCategory::MidTurnAbort)
|
||||
);
|
||||
assert_eq!(t.take_prior_interrupt_category(), None);
|
||||
|
||||
// Interrupt-reminder flag is consumed exactly once.
|
||||
t.set_pending_interrupt_reminder();
|
||||
assert!(t.take_pending_interrupt_reminder());
|
||||
assert!(!t.take_pending_interrupt_reminder());
|
||||
|
||||
// Redirect kind is consumed exactly once.
|
||||
t.set_prior_redirect_kind(RedirectKind::QueuedAfterCancel);
|
||||
assert!(matches!(
|
||||
t.take_prior_redirect_kind(),
|
||||
Some(RedirectKind::QueuedAfterCancel)
|
||||
));
|
||||
assert!(t.take_prior_redirect_kind().is_none());
|
||||
|
||||
// `begin_turn` runs at the START of a turn — BEFORE the next real user
|
||||
// prompt consumes the markers — so it must NOT clear these cross-turn
|
||||
// markers (it only resets per-turn counters). A regression here would
|
||||
// silently drop the `prior_turn_interrupt` tag / `redirect_kind`.
|
||||
t.set_prior_interrupt_category(CancellationCategory::PermissionRejected);
|
||||
t.set_prior_redirect_kind(RedirectKind::CancelThenSend);
|
||||
t.set_pending_interrupt_reminder();
|
||||
t.begin_turn();
|
||||
assert_eq!(
|
||||
t.take_prior_interrupt_category(),
|
||||
Some(CancellationCategory::PermissionRejected),
|
||||
"begin_turn must preserve the cross-turn interrupt cause"
|
||||
);
|
||||
assert!(
|
||||
matches!(
|
||||
t.take_prior_redirect_kind(),
|
||||
Some(RedirectKind::CancelThenSend)
|
||||
),
|
||||
"begin_turn must preserve the cross-turn redirect kind"
|
||||
);
|
||||
assert!(
|
||||
t.take_pending_interrupt_reminder(),
|
||||
"begin_turn must preserve the pending interrupt reminder"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,855 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Schema version for the event log format. Bumped on breaking changes.
|
||||
pub const EVENT_SCHEMA_VERSION: &str = "1.0";
|
||||
|
||||
/// A single event in the per-turn event log.
|
||||
///
|
||||
/// Each variant maps to a line in `events.jsonl`. The `type` field is the
|
||||
/// snake_case variant name (via `#[serde(tag = "type")]`). The `ts` field
|
||||
/// is added by [`super::log::EventWriter::emit`] at recording time.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum Event {
|
||||
TurnStarted {
|
||||
session_id: String,
|
||||
turn_number: u64,
|
||||
model_id: String,
|
||||
yolo_mode: bool,
|
||||
conversation_message_count: usize,
|
||||
session_relationship: SessionRelationship,
|
||||
schema_version: String,
|
||||
/// Set when this turn is the user's redirect after a Ctrl+C / Esc abort
|
||||
/// of the previous turn: `cancel_then_send` (the user typed a fresh
|
||||
/// prompt) or `queued_after_cancel` (a prompt sat queued behind the
|
||||
/// aborted turn and was promoted). `None` for normal turns. Pairs with
|
||||
/// the `interjected` event's `redirect_kind` so the trace pipeline can
|
||||
/// query every user redirect through one shared field.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
redirect_kind: Option<RedirectKind>,
|
||||
},
|
||||
PhaseChanged {
|
||||
phase: Phase,
|
||||
},
|
||||
FirstToken,
|
||||
LoopStarted {
|
||||
loop_index: u32,
|
||||
},
|
||||
ToolStarted {
|
||||
tool_name: String,
|
||||
},
|
||||
ToolCompleted {
|
||||
tool_name: String,
|
||||
duration_ms: u64,
|
||||
outcome: ToolOutcome,
|
||||
},
|
||||
PermissionRequested {
|
||||
tool_name: String,
|
||||
},
|
||||
PermissionResolved {
|
||||
tool_name: String,
|
||||
decision: PermissionDecision,
|
||||
wait_ms: u64,
|
||||
},
|
||||
TurnEnded {
|
||||
outcome: TurnOutcomeLabel,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
cancellation_category: Option<CancellationCategory>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
cancellation_context: Option<serde_json::Value>,
|
||||
},
|
||||
/// A mid-turn user interjection was merged into the running turn. Unlike
|
||||
/// `TurnEnded`, an interjection never ends the turn — the user steered
|
||||
/// in-flight (Ctrl+Enter) or promoted a queued prompt into the running
|
||||
/// turn. `source` distinguishes those two paths; `image_count` is how
|
||||
/// many images rode along (0 for text-only). Emitted at enqueue time,
|
||||
/// once per interjection.
|
||||
Interjected {
|
||||
source: InterjectionSource,
|
||||
image_count: u32,
|
||||
/// Always [`RedirectKind::Interjection`]. Carried so the shared
|
||||
/// `redirect_kind` field is queryable uniformly across every redirect
|
||||
/// event (`interjected` + the next-turn-after-abort `turn_started`).
|
||||
redirect_kind: RedirectKind,
|
||||
},
|
||||
YoloToggled {
|
||||
enabled: bool,
|
||||
},
|
||||
/// Emitted when goal mode auto-pauses an active goal. The `reason`
|
||||
/// records which automatic trigger fired: user cancel, infra-classified
|
||||
/// turn error, consecutive-failed-turn back-off, or verification block.
|
||||
GoalAutoPaused {
|
||||
reason: GoalPauseReasonTelemetry,
|
||||
},
|
||||
/// Runtime TodoGate nudged the model because a content-only turn ended
|
||||
/// with pending or unbacked in_progress todos. `reason` is the
|
||||
/// `TODO_GATE_*` discriminator constant in `kigi-shell::session::events`.
|
||||
TodoGateFired {
|
||||
fires: u32,
|
||||
pending: usize,
|
||||
in_progress: usize,
|
||||
reason: &'static str,
|
||||
},
|
||||
/// TodoGate hit its per-prompt fire cap. Distinct event so cap-exhaustion
|
||||
/// is not conflated with a normal fire in the dashboards.
|
||||
TodoGateExhausted {
|
||||
pending: usize,
|
||||
},
|
||||
/// Layer-3 LazinessDetector classifier completed and produced a verdict.
|
||||
/// Fires even in observation-only mode (`max_nudges_per_session = 0`)
|
||||
/// so dashboards can validate classification quality before any nudges
|
||||
/// are injected. `category` is one of the `LAZINESS_*` discriminator
|
||||
/// constants in `kigi-shell::session::events`.
|
||||
LazinessClassifierFired {
|
||||
model_id: String,
|
||||
category: &'static str,
|
||||
confidence: f32,
|
||||
},
|
||||
/// Layer-3 LazinessDetector injected a system-reminder nudge into the
|
||||
/// session. Always preceded by a `LazinessClassifierFired` for the
|
||||
/// same classification. Suppressed when the per-session cap is 0.
|
||||
LazinessNudgeFired {
|
||||
model_id: String,
|
||||
category: &'static str,
|
||||
nudges_remaining: u32,
|
||||
},
|
||||
/// Layer-3 LazinessDetector terminated without producing a verdict.
|
||||
/// `reason` is one of the `LAZINESS_ABORT_*` discriminator constants
|
||||
/// in `kigi-shell::session::events`.
|
||||
LazinessClassifierAborted {
|
||||
reason: &'static str,
|
||||
},
|
||||
/// Goal-achievement classifier subagent was invoked. Fires once per
|
||||
/// classifier attempt regardless of outcome; pairs with exactly one
|
||||
/// of `GoalClassifierVerdict`, `GoalClassifierFailOpen`, or
|
||||
/// `GoalClassifierFailClosed` once the run terminates.
|
||||
GoalClassifierFired {
|
||||
attempt: u32,
|
||||
max_runs: u32,
|
||||
model_id: String,
|
||||
},
|
||||
/// Goal-achievement classifier returned a parsed verdict (Achieved or
|
||||
/// NotAchieved). `latency_ms` is the spawn-to-parse wall clock.
|
||||
GoalClassifierVerdict {
|
||||
verdict: GoalClassifierVerdictTelemetry,
|
||||
attempt: u32,
|
||||
latency_ms: u64,
|
||||
},
|
||||
/// Goal-achievement classifier could not produce a usable verdict due
|
||||
/// to an INFRA-class failure (timeout, sampler error, abort, file IO).
|
||||
/// Caller fails OPEN — treats as Achieved — and records the reason.
|
||||
GoalClassifierFailOpen {
|
||||
reason: &'static str,
|
||||
attempt: u32,
|
||||
latency_ms: u64,
|
||||
},
|
||||
/// Goal-achievement classifier could not produce a usable verdict due
|
||||
/// to a PARSE-class failure (malformed terminal token, missing details
|
||||
/// file). Caller fails CLOSED — treats as NotAchieved.
|
||||
GoalClassifierFailClosed {
|
||||
reason: &'static str,
|
||||
attempt: u32,
|
||||
},
|
||||
/// Goal-achievement classifier hit the per-goal run cap. Distinct event
|
||||
/// so cap exhaustion is not conflated with a normal verdict.
|
||||
GoalClassifierCapReached {
|
||||
attempt: u32,
|
||||
},
|
||||
/// Mid-turn `update_goal(completed: true)` was deferred to the next
|
||||
/// turn-end drain (Guard 2). `pending_depth` is the queue length
|
||||
/// AFTER the push so dashboards can spot accumulation in real time.
|
||||
GoalClassifierMidTurnDeferred {
|
||||
pending_depth: u32,
|
||||
},
|
||||
/// `update_goal(completed: true)` arrived AFTER the classifier
|
||||
/// cap had already auto-paused the goal. `attempts_seen` is the
|
||||
/// real `classifier_runs_attempted` snapshot (typically the cap),
|
||||
/// never `0`.
|
||||
GoalClassifierDroppedAfterCap {
|
||||
attempts_seen: u32,
|
||||
},
|
||||
/// A cap-pause cleared the pending-classifier-completions queue.
|
||||
/// One summary event per pause, not per-entry — `dropped` is the
|
||||
/// total entry count.
|
||||
GoalClassifierPendingQueueCleared {
|
||||
dropped: u32,
|
||||
},
|
||||
/// Goal planner subagent was invoked. Fires once per attempt;
|
||||
/// pairs with exactly one of `GoalPlannerCompleted` or
|
||||
/// `GoalPlannerFailClosed` once the run terminates. `max_runs`
|
||||
/// mirrors the classifier event for dashboard symmetry — the
|
||||
/// planner cap is always `1` today.
|
||||
GoalPlannerFired {
|
||||
attempt: u32,
|
||||
max_runs: u32,
|
||||
model_id: String,
|
||||
},
|
||||
/// Planner subagent wrote a plan file successfully.
|
||||
/// `latency_ms` is the spawn-to-write wall clock.
|
||||
GoalPlannerCompleted {
|
||||
attempt: u32,
|
||||
latency_ms: u64,
|
||||
},
|
||||
/// Planner subagent failed and the harness paused the goal
|
||||
/// fail-closed. `reason` is one of the `GOAL_PLANNER_FAIL_CLOSED_*`
|
||||
/// discriminator constants in `kigi-shell::session::events`.
|
||||
GoalPlannerFailClosed {
|
||||
reason: &'static str,
|
||||
attempt: u32,
|
||||
latency_ms: u64,
|
||||
},
|
||||
/// Stall-triggered strategist subagent was invoked after
|
||||
/// `consecutive_failures` consecutive `NotAchieved` verifications.
|
||||
/// Fires once per trigger (at N, 2N, …); pairs with exactly one of
|
||||
/// `GoalStrategistCompleted` or `GoalStrategistFailed`. Unlike the
|
||||
/// planner the strategist is fail-OPEN — a failure never pauses the
|
||||
/// goal. `attempt` is the verifier attempt that triggered it. `every`
|
||||
/// is the resolved cadence N, so a configured override is observable.
|
||||
GoalStrategistFired {
|
||||
attempt: u32,
|
||||
consecutive_failures: u32,
|
||||
every: u32,
|
||||
model_id: String,
|
||||
},
|
||||
/// Strategist subagent wrote a strategy note successfully.
|
||||
/// `latency_ms` is the spawn-to-write wall clock.
|
||||
GoalStrategistCompleted {
|
||||
attempt: u32,
|
||||
consecutive_failures: u32,
|
||||
latency_ms: u64,
|
||||
},
|
||||
/// Strategist subagent failed; the harness logged it and continued
|
||||
/// the normal loop (fail-OPEN — the goal is NOT paused). `reason` is
|
||||
/// one of the `GOAL_STRATEGIST_FAILED_*` discriminator constants in
|
||||
/// `kigi-shell::session::events`.
|
||||
GoalStrategistFailed {
|
||||
reason: &'static str,
|
||||
attempt: u32,
|
||||
consecutive_failures: u32,
|
||||
latency_ms: u64,
|
||||
},
|
||||
/// The plan.md-safety guard could not restore the verifier-judged
|
||||
/// contract to its pre-strategist bytes (a write/remove failed, or a
|
||||
/// symlink was planted at the path). The contract may be corrupted —
|
||||
/// surfaced so it is observable rather than a silent `warn!`. `reason`
|
||||
/// is one of the `GOAL_STRATEGIST_RESTORE_*` discriminator constants in
|
||||
/// `kigi-shell::session::events`.
|
||||
GoalStrategistContractRestoreFailed {
|
||||
reason: &'static str,
|
||||
attempt: u32,
|
||||
},
|
||||
/// Goal summarizer subagent was invoked ONCE after the goal was
|
||||
/// verified-achieved (real `Achieved`, not the infra fail-open), to
|
||||
/// generate the closing user-facing summary. Pairs with exactly one of
|
||||
/// `GoalSummarizerCompleted` or `GoalSummarizerFailOpen`. Fail-OPEN — a
|
||||
/// failure never blocks completion. `attempt` is the achieving verifier
|
||||
/// attempt; `model_id` is the inherited session model.
|
||||
GoalSummarizerFired {
|
||||
attempt: u32,
|
||||
model_id: String,
|
||||
},
|
||||
/// Summarizer returned a non-empty summary; the harness surfaced it as the
|
||||
/// goal turn's closing message. `latency_ms` is the spawn-to-summary wall
|
||||
/// clock.
|
||||
GoalSummarizerCompleted {
|
||||
attempt: u32,
|
||||
latency_ms: u64,
|
||||
},
|
||||
/// Summarizer failed (transport / runtime / cancel / empty output); the
|
||||
/// harness skipped the closing summary and completed the goal normally
|
||||
/// (fail-OPEN — completion is never blocked). `reason` is one of the
|
||||
/// `GOAL_SUMMARIZER_FAIL_OPEN_*` discriminator constants in
|
||||
/// `kigi-shell::session::events`.
|
||||
GoalSummarizerFailOpen {
|
||||
reason: &'static str,
|
||||
attempt: u32,
|
||||
latency_ms: u64,
|
||||
},
|
||||
|
||||
/// A `/goal` subagent role (planner, strategist, or a skeptic index)
|
||||
/// committed to an explicit model+toolset selection. `role` is one of
|
||||
/// `planner|strategist|skeptic`; `skeptic_idx` is set only for the
|
||||
/// skeptic panel. `source` is the resolution provenance: a
|
||||
/// committed explicit pair is always `remote` (the only non-inherit
|
||||
/// source); `default`/kill-switch resolutions inherit the current
|
||||
/// model and do not emit this event. Emitted once per role/skeptic-
|
||||
/// index when an explicit selection is committed.
|
||||
GoalRoleModelResolved {
|
||||
role: &'static str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
skeptic_idx: Option<u32>,
|
||||
model_id: String,
|
||||
agent_type: String,
|
||||
source: &'static str,
|
||||
},
|
||||
/// A `/goal` subagent role fell open to the current model because its
|
||||
/// configured pair was unusable. `role` is one of
|
||||
/// `planner|strategist|skeptic`; `skeptic_idx` is set only for the
|
||||
/// skeptic panel. `reason` is one of the
|
||||
/// `GOAL_ROLE_MODEL_FAIL_OPEN_*` discriminator constants in
|
||||
/// `kigi-shell::session::events`. Fail-open never pauses the goal.
|
||||
GoalRoleModelFailOpen {
|
||||
role: &'static str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
skeptic_idx: Option<u32>,
|
||||
reason: &'static str,
|
||||
},
|
||||
|
||||
/// One skeptic in the adversarial panel returned a verdict. Fires
|
||||
/// `N` times per verification stage (where N is
|
||||
/// `goal_verifier_count`). `confidence` is the JSON `confidence`
|
||||
/// field; the wire vocabulary is `high|medium|low|unknown`.
|
||||
/// `latency_ms` is the per-skeptic spawn-to-verdict wall clock —
|
||||
/// dashboards can surface slow outliers even though the panel-
|
||||
/// level emission is batched via `join_all`.
|
||||
GoalVerifierSkepticVerdict {
|
||||
attempt: u32,
|
||||
skeptic_idx: u32,
|
||||
refuted: bool,
|
||||
confidence: &'static str,
|
||||
latency_ms: u64,
|
||||
},
|
||||
/// Aggregate verdict across all N skeptics. `refuted_count` /
|
||||
/// `total` is the majority-refute fraction; `achieved` is the
|
||||
/// stage's final verdict (true ⇒ survives, false ⇒ majority-refute).
|
||||
GoalVerifierAggregateVerdict {
|
||||
attempt: u32,
|
||||
refuted_count: u32,
|
||||
total: u32,
|
||||
achieved: bool,
|
||||
},
|
||||
/// The stop-detector matched a known bail/hand-off/verdict
|
||||
/// pattern in the LAST paragraph of the assistant's turn-final
|
||||
/// text while the goal stayed `Active` with pending todos. The
|
||||
/// harness defeated the premature stop by queuing the bail-specific
|
||||
/// continuation reminder; this event records the matched pattern
|
||||
/// label so dashboards can audit precision/recall of the regex
|
||||
/// panel. `pattern` is one of the stable labels
|
||||
/// enumerated by
|
||||
/// `kigi-shell::session::goal_stop_detector::PATTERN_LABELS`;
|
||||
/// the source-string provenance for each label is pinned by the
|
||||
/// adjacent `STOP_REGEX_SOURCES` table.
|
||||
///
|
||||
/// Under-counts by design: fires only when a fresh bail continuation
|
||||
/// is queued. If a classifier-rejection nudge is already pending, the
|
||||
/// shared idempotency gate suppresses both the duplicate push and
|
||||
/// JSON-RPC message and was skipped instead of tearing down the
|
||||
/// this event, so dashboards see a lower bound.
|
||||
GoalPrematureStopDetected {
|
||||
pattern: &'static str,
|
||||
},
|
||||
|
||||
// ── MCP Diagnostics ──────────────────────────────────────────
|
||||
McpConfigResolved {
|
||||
servers: Vec<McpConfigServer>,
|
||||
disabled: Vec<String>,
|
||||
},
|
||||
McpManagedConfigResult {
|
||||
server_count: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
},
|
||||
#[serde(rename = "mcp_oauth_discovery_timeout")]
|
||||
McpOAuthDiscoveryTimeout {
|
||||
server_name: String,
|
||||
url: String,
|
||||
},
|
||||
McpServerStarting {
|
||||
server_name: String,
|
||||
transport: String,
|
||||
target: String,
|
||||
timeout_sec: u64,
|
||||
},
|
||||
McpServerConnected {
|
||||
server_name: String,
|
||||
transport: String,
|
||||
tool_count: u32,
|
||||
duration_ms: u64,
|
||||
tools: Vec<String>,
|
||||
},
|
||||
McpServerFailed {
|
||||
server_name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
transport: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
target: Option<String>,
|
||||
error_type: McpErrorCategory,
|
||||
error_message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
duration_ms: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
timeout_sec: Option<u64>,
|
||||
},
|
||||
McpToolRegistrationFailed {
|
||||
server_name: String,
|
||||
tool_name: String,
|
||||
error: String,
|
||||
},
|
||||
McpInitCompleted {
|
||||
total_servers: u32,
|
||||
succeeded: u32,
|
||||
failed: u32,
|
||||
auth_required: u32,
|
||||
total_tools: u32,
|
||||
duration_ms: u64,
|
||||
is_reinit: bool,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
failed_servers: Vec<String>,
|
||||
},
|
||||
McpInitCancelled {
|
||||
reason: String,
|
||||
},
|
||||
McpToolCallStarted {
|
||||
server_name: String,
|
||||
tool_name: String,
|
||||
call_id: String,
|
||||
timeout_sec: u64,
|
||||
},
|
||||
McpToolCallCompleted {
|
||||
server_name: String,
|
||||
tool_name: String,
|
||||
call_id: String,
|
||||
duration_ms: u64,
|
||||
success: bool,
|
||||
is_timeout: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
reconnect_attempted: bool,
|
||||
auth_retry_attempted: bool,
|
||||
},
|
||||
McpTransportError {
|
||||
server_name: String,
|
||||
tool_name: String,
|
||||
error: String,
|
||||
},
|
||||
/// A line on an MCP stdio server's stdout could not be decoded as a
|
||||
/// transport. Surfaces the otherwise-invisible "connector shows but
|
||||
/// doesn't work" case (a server logging to stdout, a JSON-RPC batch
|
||||
/// array, or an off-spec response). Distinct from `McpTransportError`,
|
||||
/// environment; either the orchestrator called
|
||||
/// which is a per-tool-call transport failure.
|
||||
McpTransportDecodeError {
|
||||
server_name: String,
|
||||
error: String,
|
||||
/// Truncated copy of the offending line, for diagnosis.
|
||||
sample: String,
|
||||
},
|
||||
McpTransportReconnect {
|
||||
server_name: String,
|
||||
success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
},
|
||||
McpAuthRetry {
|
||||
server_name: String,
|
||||
trigger: String,
|
||||
success: bool,
|
||||
},
|
||||
McpHealthCheck {
|
||||
server_name: String,
|
||||
healthy: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
client_state: Option<String>,
|
||||
},
|
||||
McpServerToggled {
|
||||
server_name: String,
|
||||
enabled: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// Where a mid-turn interjection originated. Drives the `source` field on
|
||||
/// [`Event::Interjected`].
|
||||
#[derive(Debug, Clone, Copy, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum InterjectionSource {
|
||||
/// Direct `x.ai/interject` while a turn was running (Ctrl+Enter).
|
||||
Direct,
|
||||
/// A queued (not-yet-running) prompt promoted into the running turn via
|
||||
/// `InterjectQueuedPrompt` (queue "send now").
|
||||
Queue,
|
||||
}
|
||||
|
||||
/// The user-redirect mechanism behind an event — the shared discriminator that
|
||||
/// lets the trace pipeline query every user steer through one field. Present on
|
||||
/// [`Event::Interjected`] (always [`RedirectKind::Interjection`]) and, for the
|
||||
/// next turn after a Ctrl+C / Esc abort, on [`Event::TurnStarted`]
|
||||
/// ([`RedirectKind::CancelThenSend`] / [`RedirectKind::QueuedAfterCancel`]).
|
||||
#[derive(Debug, Clone, Copy, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RedirectKind {
|
||||
/// Mid-turn interjection — Ctrl+O / `x.ai/interject`, or "Send now" on a
|
||||
/// queued row. The turn keeps running; nothing is cancelled.
|
||||
Interjection,
|
||||
/// The turn was aborted (Ctrl+C / Esc) and the user then typed and sent a
|
||||
/// fresh prompt as the next turn.
|
||||
CancelThenSend,
|
||||
/// The turn was aborted (Ctrl+C / Esc) while a prompt sat queued behind it;
|
||||
/// that queued prompt was promoted as the next turn.
|
||||
QueuedAfterCancel,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum McpErrorCategory {
|
||||
SpawnFailed,
|
||||
Timeout,
|
||||
HandshakeFailed,
|
||||
AuthRequired,
|
||||
ClientError,
|
||||
}
|
||||
|
||||
/// Server entry in `McpConfigResolved`.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct McpConfigServer {
|
||||
pub name: String,
|
||||
pub transport: String,
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
/// Telemetry mirror of `kigi-shell`'s `GoalClassifierVerdict`. Two
|
||||
/// crates due to the orphan rule; the conversion lives in
|
||||
/// `kigi-shell/src/session/events.rs`.
|
||||
#[derive(Debug, Clone, Copy, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum GoalClassifierVerdictTelemetry {
|
||||
Achieved,
|
||||
NotAchieved,
|
||||
}
|
||||
|
||||
/// Telemetry mirror of `kigi-shell`'s `GoalPauseReason`. The two types
|
||||
/// live in separate crates (orphan rule); the conversion lives in
|
||||
/// `kigi-shell/src/session/events.rs`.
|
||||
///
|
||||
/// **Invariant:** when adding a new variant to either side, add the
|
||||
/// matching variant here so the compiler-enforced `From` impl on the
|
||||
/// shell side catches the drift at build time.
|
||||
#[derive(Debug, Clone, Copy, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum GoalPauseReasonTelemetry {
|
||||
User,
|
||||
BackOff,
|
||||
/// Verification stage saw no fingerprint change in the flagged gaps
|
||||
/// across consecutive attempts and auto-paused before the run cap.
|
||||
NoProgress,
|
||||
/// Verification determined the goal is not achievable in this
|
||||
/// `update_goal(blocked_reason: ...)`, or every refuter classified
|
||||
/// `update_goal(blocked_reason: ...)`, or every refuter classified
|
||||
/// its gap as a contradiction / unverifiable blocker.
|
||||
Verification,
|
||||
/// Turn finished with `PromptTurnResult::Err` (infrastructure failure).
|
||||
Infra,
|
||||
}
|
||||
|
||||
/// Outcome of a single tool call. More granular than a boolean -- distinguishes
|
||||
/// between tools that executed vs tools that were never run.
|
||||
#[derive(Debug, Clone, Copy, Serialize, strum::IntoStaticStr)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[strum(serialize_all = "snake_case")]
|
||||
pub enum ToolOutcome {
|
||||
/// Tool executed and returned a result.
|
||||
Success,
|
||||
/// Tool executed but returned an error.
|
||||
Error,
|
||||
/// User rejected the permission prompt.
|
||||
PermissionRejected,
|
||||
/// User cancelled the permission prompt (Cmd+C).
|
||||
PermissionCancelled,
|
||||
/// User provided a followup message instead of approving.
|
||||
Followup,
|
||||
/// A user-configured hook blocked execution.
|
||||
HookDenied,
|
||||
/// Tool not found or arguments couldn't be parsed.
|
||||
InvalidTool,
|
||||
/// Tool was running when the turn was cancelled (Cmd+C).
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Phase {
|
||||
WaitingForModel,
|
||||
StreamingText,
|
||||
StreamingReasoning,
|
||||
ToolExecution,
|
||||
PermissionPrompt,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SessionRelationship {
|
||||
Primary,
|
||||
#[allow(dead_code)]
|
||||
Subagent,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TurnOutcomeLabel {
|
||||
Completed,
|
||||
Cancelled,
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PermissionDecision {
|
||||
Allow,
|
||||
Deny,
|
||||
Cancelled,
|
||||
Followup,
|
||||
}
|
||||
|
||||
// `Deserialize`/`PartialEq`/`Eq`/`Hash` let the workspace decode
|
||||
// `cancellation_category` strings back into this enum. `snake_case` keeps the
|
||||
// wire form identical, so adding `Deserialize` doesn't change serialization.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CancellationCategory {
|
||||
HookDenied,
|
||||
PermissionRejected,
|
||||
PermissionCancelled,
|
||||
MidTurnAbort,
|
||||
}
|
||||
|
||||
// Note: `From<&permission::Decision> for PermissionDecision` crosses the
|
||||
// crate boundary (orphan rule) and lives in
|
||||
// `kigi-shell/src/session/events.rs`.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Every variant must survive a `to_value` -> `from_value` round-trip.
|
||||
#[test]
|
||||
fn cancellation_category_round_trips_every_variant() {
|
||||
for variant in [
|
||||
CancellationCategory::HookDenied,
|
||||
CancellationCategory::PermissionRejected,
|
||||
CancellationCategory::PermissionCancelled,
|
||||
CancellationCategory::MidTurnAbort,
|
||||
] {
|
||||
let value = serde_json::to_value(variant).unwrap();
|
||||
let decoded: CancellationCategory = serde_json::from_value(value).unwrap();
|
||||
assert_eq!(decoded, variant, "{variant:?} must round-trip");
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialization is unchanged by the added derives (bare snake_case strings).
|
||||
#[test]
|
||||
fn cancellation_category_serializes_snake_case() {
|
||||
for (variant, expected) in [
|
||||
(CancellationCategory::HookDenied, "\"hook_denied\""),
|
||||
(
|
||||
CancellationCategory::PermissionRejected,
|
||||
"\"permission_rejected\"",
|
||||
),
|
||||
(
|
||||
CancellationCategory::PermissionCancelled,
|
||||
"\"permission_cancelled\"",
|
||||
),
|
||||
(CancellationCategory::MidTurnAbort, "\"mid_turn_abort\""),
|
||||
] {
|
||||
let json = serde_json::to_string(&variant).unwrap();
|
||||
assert_eq!(json, expected, "{variant:?} must serialize to {expected}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interjected_event_serializes_tag_source_and_count() {
|
||||
let ev = Event::Interjected {
|
||||
source: InterjectionSource::Direct,
|
||||
image_count: 2,
|
||||
redirect_kind: RedirectKind::Interjection,
|
||||
};
|
||||
let v = serde_json::to_value(&ev).unwrap();
|
||||
assert_eq!(v["type"], "interjected");
|
||||
assert_eq!(v["source"], "direct");
|
||||
assert_eq!(v["image_count"], 2);
|
||||
// Shared discriminator: always present on interjected events.
|
||||
assert_eq!(v["redirect_kind"], "interjection");
|
||||
|
||||
let queue = serde_json::to_value(Event::Interjected {
|
||||
source: InterjectionSource::Queue,
|
||||
image_count: 0,
|
||||
redirect_kind: RedirectKind::Interjection,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(queue["source"], "queue");
|
||||
assert_eq!(queue["image_count"], 0);
|
||||
assert_eq!(queue["redirect_kind"], "interjection");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redirect_kind_serializes_snake_case() {
|
||||
for (variant, expected) in [
|
||||
(RedirectKind::Interjection, "\"interjection\""),
|
||||
(RedirectKind::CancelThenSend, "\"cancel_then_send\""),
|
||||
(RedirectKind::QueuedAfterCancel, "\"queued_after_cancel\""),
|
||||
] {
|
||||
let json = serde_json::to_string(&variant).unwrap();
|
||||
assert_eq!(json, expected, "{variant:?} must serialize to {expected}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turn_started_redirect_kind_present_when_set_omitted_when_none() {
|
||||
let with_kind = serde_json::to_value(Event::TurnStarted {
|
||||
session_id: "s".into(),
|
||||
turn_number: 2,
|
||||
model_id: "grok-4".into(),
|
||||
yolo_mode: false,
|
||||
conversation_message_count: 3,
|
||||
session_relationship: SessionRelationship::Primary,
|
||||
schema_version: EVENT_SCHEMA_VERSION.into(),
|
||||
redirect_kind: Some(RedirectKind::QueuedAfterCancel),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(with_kind["type"], "turn_started");
|
||||
assert_eq!(with_kind["redirect_kind"], "queued_after_cancel");
|
||||
|
||||
let normal = serde_json::to_value(Event::TurnStarted {
|
||||
session_id: "s".into(),
|
||||
turn_number: 1,
|
||||
model_id: "grok-4".into(),
|
||||
yolo_mode: false,
|
||||
conversation_message_count: 0,
|
||||
session_relationship: SessionRelationship::Primary,
|
||||
schema_version: EVENT_SCHEMA_VERSION.into(),
|
||||
redirect_kind: None,
|
||||
})
|
||||
.unwrap();
|
||||
assert!(
|
||||
normal.get("redirect_kind").is_none(),
|
||||
"redirect_kind must be omitted on a normal turn, got {normal}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn goal_pause_reason_telemetry_serializes_snake_case() {
|
||||
for (variant, expected) in [
|
||||
(GoalPauseReasonTelemetry::User, "\"user\""),
|
||||
(GoalPauseReasonTelemetry::BackOff, "\"back_off\""),
|
||||
(GoalPauseReasonTelemetry::NoProgress, "\"no_progress\""),
|
||||
(GoalPauseReasonTelemetry::Verification, "\"verification\""),
|
||||
(GoalPauseReasonTelemetry::Infra, "\"infra\""),
|
||||
] {
|
||||
let json = serde_json::to_string(&variant).unwrap();
|
||||
assert_eq!(json, expected, "{variant:?} must serialize to {expected}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn goal_strategist_fired_serializes_cadence_field() {
|
||||
// `every` must serialize as a plain number on the wire.
|
||||
let ev = Event::GoalStrategistFired {
|
||||
attempt: 2,
|
||||
consecutive_failures: 6,
|
||||
every: 3,
|
||||
model_id: "grok-4".to_string(),
|
||||
};
|
||||
let v = serde_json::to_value(&ev).unwrap();
|
||||
assert_eq!(v["type"], "goal_strategist_fired");
|
||||
assert_eq!(v["attempt"], 2);
|
||||
assert_eq!(v["consecutive_failures"], 6);
|
||||
assert_eq!(v["every"], 3);
|
||||
assert_eq!(v["model_id"], "grok-4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn goal_summarizer_events_serialize_tag_and_fields() {
|
||||
let fired = Event::GoalSummarizerFired {
|
||||
attempt: 2,
|
||||
model_id: "grok-4".to_string(),
|
||||
};
|
||||
let v = serde_json::to_value(&fired).unwrap();
|
||||
assert_eq!(v["type"], "goal_summarizer_fired");
|
||||
assert_eq!(v["attempt"], 2);
|
||||
assert_eq!(v["model_id"], "grok-4");
|
||||
|
||||
let completed = Event::GoalSummarizerCompleted {
|
||||
attempt: 2,
|
||||
latency_ms: 42,
|
||||
};
|
||||
let v = serde_json::to_value(&completed).unwrap();
|
||||
assert_eq!(v["type"], "goal_summarizer_completed");
|
||||
assert_eq!(v["attempt"], 2);
|
||||
assert_eq!(v["latency_ms"], 42);
|
||||
|
||||
let failed = Event::GoalSummarizerFailOpen {
|
||||
reason: "transport",
|
||||
attempt: 2,
|
||||
latency_ms: 7,
|
||||
};
|
||||
let v = serde_json::to_value(&failed).unwrap();
|
||||
assert_eq!(v["type"], "goal_summarizer_fail_open");
|
||||
assert_eq!(v["reason"], "transport");
|
||||
assert_eq!(v["attempt"], 2);
|
||||
assert_eq!(v["latency_ms"], 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn goal_role_model_resolved_serializes_tag_and_fields() {
|
||||
let ev = Event::GoalRoleModelResolved {
|
||||
role: "skeptic",
|
||||
skeptic_idx: Some(2),
|
||||
model_id: "grok-4".to_string(),
|
||||
agent_type: "general-purpose".to_string(),
|
||||
source: "remote",
|
||||
};
|
||||
let v = serde_json::to_value(&ev).unwrap();
|
||||
assert_eq!(v["type"], "goal_role_model_resolved");
|
||||
assert_eq!(v["role"], "skeptic");
|
||||
assert_eq!(v["skeptic_idx"], 2);
|
||||
assert_eq!(v["model_id"], "grok-4");
|
||||
assert_eq!(v["agent_type"], "general-purpose");
|
||||
assert_eq!(v["source"], "remote");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn goal_role_model_resolved_omits_skeptic_idx_when_none() {
|
||||
let ev = Event::GoalRoleModelResolved {
|
||||
role: "planner",
|
||||
skeptic_idx: None,
|
||||
model_id: "grok-4".to_string(),
|
||||
agent_type: "general-purpose".to_string(),
|
||||
source: "remote",
|
||||
};
|
||||
let obj = serde_json::to_value(&ev).unwrap();
|
||||
assert!(
|
||||
obj.get("skeptic_idx").is_none(),
|
||||
"skeptic_idx must be omitted when None, got {obj}"
|
||||
);
|
||||
assert_eq!(obj["role"], "planner");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn goal_role_model_fail_open_serializes_tag_and_fields() {
|
||||
let ev = Event::GoalRoleModelFailOpen {
|
||||
role: "skeptic",
|
||||
skeptic_idx: Some(1),
|
||||
reason: "toolset_unavailable",
|
||||
};
|
||||
let v = serde_json::to_value(&ev).unwrap();
|
||||
assert_eq!(v["type"], "goal_role_model_fail_open");
|
||||
assert_eq!(v["role"], "skeptic");
|
||||
assert_eq!(v["skeptic_idx"], 1);
|
||||
assert_eq!(v["reason"], "toolset_unavailable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn goal_role_model_fail_open_omits_skeptic_idx_when_none() {
|
||||
let ev = Event::GoalRoleModelFailOpen {
|
||||
role: "strategist",
|
||||
skeptic_idx: None,
|
||||
reason: "model_unauthorized",
|
||||
};
|
||||
let obj = serde_json::to_value(&ev).unwrap();
|
||||
assert!(
|
||||
obj.get("skeptic_idx").is_none(),
|
||||
"skeptic_idx must be omitted when None, got {obj}"
|
||||
);
|
||||
assert_eq!(obj["type"], "goal_role_model_fail_open");
|
||||
assert_eq!(obj["role"], "strategist");
|
||||
assert_eq!(obj["reason"], "model_unauthorized");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//! Local file utilities: per-turn event tracking, content hashing, and
|
||||
//! project-directory classification.
|
||||
pub mod events;
|
||||
pub mod s3;
|
||||
pub mod trace_context;
|
||||
pub mod workspace_classifier;
|
||||
|
||||
/// Directory names that are always skipped when scanning a workspace for
|
||||
/// project content (dependency caches, build output, editor state, …).
|
||||
pub const SKIP_DIR_NAMES: &[&str] = &[
|
||||
"node_modules",
|
||||
"__pycache__",
|
||||
".venv",
|
||||
"venv",
|
||||
"env",
|
||||
".env",
|
||||
"target",
|
||||
"dist",
|
||||
"build",
|
||||
"out",
|
||||
".next",
|
||||
".nuxt",
|
||||
".output",
|
||||
".cache",
|
||||
".parcel-cache",
|
||||
".turbo",
|
||||
"vendor",
|
||||
"bower_components",
|
||||
".tox",
|
||||
".nox",
|
||||
".eggs",
|
||||
".idea",
|
||||
".vscode",
|
||||
".gradle",
|
||||
".dart_tool",
|
||||
"coverage",
|
||||
".nyc_output",
|
||||
"htmlcov",
|
||||
".pytest_cache",
|
||||
".mypy_cache",
|
||||
".ruff_cache",
|
||||
];
|
||||
|
||||
/// [`SKIP_DIR_NAMES`] as a set for O(1) membership checks.
|
||||
pub fn skip_dir_set() -> &'static std::collections::HashSet<&'static str> {
|
||||
use std::collections::HashSet;
|
||||
use std::sync::LazyLock;
|
||||
static SET: LazyLock<HashSet<&str>> =
|
||||
LazyLock::new(|| SKIP_DIR_NAMES.iter().copied().collect());
|
||||
&SET
|
||||
}
|
||||
|
||||
/// Compute SHA256 hash of content as a hex string.
|
||||
pub fn sha256_hex(content: &[u8]) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(content);
|
||||
format!("{:x}", hasher.finalize())
|
||||
}
|
||||
/// Compute SHA256 hash of a file by streaming, without loading entire file into memory.
|
||||
/// If `max_bytes` is set (> 0), only hash up to that many bytes.
|
||||
pub fn sha256_hex_from_file(
|
||||
path: &std::path::Path,
|
||||
max_bytes: Option<u64>,
|
||||
) -> std::io::Result<String> {
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::io::Read;
|
||||
let file = std::fs::File::open(path)?;
|
||||
let mut reader: Box<dyn Read> = if let Some(limit) = max_bytes {
|
||||
Box::new(file.take(limit))
|
||||
} else {
|
||||
Box::new(file)
|
||||
};
|
||||
let mut hasher = Sha256::new();
|
||||
let mut buffer = [0u8; 8192];
|
||||
loop {
|
||||
let bytes_read = reader.read(&mut buffer)?;
|
||||
if bytes_read == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buffer[..bytes_read]);
|
||||
}
|
||||
Ok(format!("{:x}", hasher.finalize()))
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
//! Presigned-URL helpers for S3-compatible endpoints.
|
||||
//!
|
||||
//! Used by tools that hand a user-configured bucket a short-lived PUT/GET URL
|
||||
//! (e.g. the ZDR video-generation output path). No upload client lives here.
|
||||
|
||||
use anyhow::Context;
|
||||
|
||||
/// Parse credential content (JSON or INI format) into AWS SDK credentials.
|
||||
fn parse_aws_credentials(content: &str) -> anyhow::Result<aws_sdk_s3::config::Credentials> {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct JsonCreds {
|
||||
aws_access_key_id: String,
|
||||
aws_secret_access_key: String,
|
||||
#[serde(default)]
|
||||
aws_session_token: Option<String>,
|
||||
}
|
||||
|
||||
if let Ok(parsed) = serde_json::from_str::<JsonCreds>(content) {
|
||||
return Ok(aws_sdk_s3::config::Credentials::new(
|
||||
&parsed.aws_access_key_id,
|
||||
&parsed.aws_secret_access_key,
|
||||
parsed.aws_session_token,
|
||||
None,
|
||||
"grok-shell-trace-upload",
|
||||
));
|
||||
}
|
||||
|
||||
let strip_comment = |v: &str| {
|
||||
v.split_once('#')
|
||||
.map_or(v, |(before, _)| before)
|
||||
.trim()
|
||||
.to_owned()
|
||||
};
|
||||
let mut key_id = None;
|
||||
let mut secret = None;
|
||||
let mut token = None;
|
||||
for line in content.lines() {
|
||||
if let Some((k, v)) = line.split_once('=') {
|
||||
match k.trim() {
|
||||
"aws_access_key_id" => key_id = Some(strip_comment(v)),
|
||||
"aws_secret_access_key" => secret = Some(strip_comment(v)),
|
||||
"aws_session_token" => token = Some(strip_comment(v)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match (key_id, secret) {
|
||||
(Some(k), Some(s)) => Ok(aws_sdk_s3::config::Credentials::new(
|
||||
&k,
|
||||
&s,
|
||||
token,
|
||||
None,
|
||||
"grok-shell-trace-upload",
|
||||
)),
|
||||
_ => anyhow::bail!(
|
||||
"AWS credentials are neither valid JSON \
|
||||
nor contain aws_access_key_id and aws_secret_access_key"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an S3 client. Uses path-style addressing when `endpoint_url` is set.
|
||||
///
|
||||
/// Reads `HTTPS_PROXY` / `HTTP_PROXY` / `ALL_PROXY` / `NO_PROXY` environment
|
||||
/// variables so that S3 traffic can route through a corporate HTTP proxy when
|
||||
/// the S3-compatible endpoint is not directly reachable.
|
||||
pub(crate) async fn build_s3_client(
|
||||
region: &str,
|
||||
credentials_content: Option<&str>,
|
||||
credentials_file: Option<&str>,
|
||||
endpoint_url: Option<&str>,
|
||||
) -> anyhow::Result<aws_sdk_s3::Client> {
|
||||
let proxy_config = aws_smithy_http_client::proxy::ProxyConfig::from_env();
|
||||
let http_client = aws_smithy_http_client::Builder::new().build_with_connector_fn(
|
||||
move |settings, _runtime_components| {
|
||||
let mut builder =
|
||||
aws_smithy_http_client::Connector::builder().proxy_config(proxy_config.clone());
|
||||
if let Some(s) = settings {
|
||||
builder.set_connector_settings(Some(s.clone()));
|
||||
}
|
||||
builder
|
||||
.tls_provider(aws_smithy_http_client::tls::Provider::Rustls(
|
||||
aws_smithy_http_client::tls::rustls_provider::CryptoMode::Ring,
|
||||
))
|
||||
.build()
|
||||
},
|
||||
);
|
||||
|
||||
let mut config_loader = aws_config::defaults(aws_config::BehaviorVersion::latest())
|
||||
.http_client(http_client)
|
||||
.region(aws_config::Region::new(region.to_owned()));
|
||||
|
||||
let resolved_content = match (credentials_content, credentials_file) {
|
||||
(Some(inline), _) => Some(inline.to_owned()),
|
||||
(None, Some(path)) => Some(
|
||||
tokio::fs::read_to_string(path)
|
||||
.await
|
||||
.with_context(|| format!("Failed to read AWS credentials file: {path}"))?,
|
||||
),
|
||||
(None, None) => None,
|
||||
};
|
||||
|
||||
if let Some(ref content) = resolved_content {
|
||||
config_loader = config_loader.credentials_provider(parse_aws_credentials(content)?);
|
||||
} else if endpoint_url.is_some() {
|
||||
config_loader = config_loader.credentials_provider(aws_sdk_s3::config::Credentials::new(
|
||||
"test",
|
||||
"test",
|
||||
None,
|
||||
None,
|
||||
"grok-shell-test",
|
||||
));
|
||||
}
|
||||
|
||||
let sdk_config = config_loader.load().await;
|
||||
let mut builder =
|
||||
aws_sdk_s3::config::Builder::from(&sdk_config).force_path_style(endpoint_url.is_some());
|
||||
if let Some(url) = endpoint_url {
|
||||
builder = builder.endpoint_url(url);
|
||||
}
|
||||
Ok(aws_sdk_s3::Client::from_conf(builder.build()))
|
||||
}
|
||||
|
||||
/// Static access-key credentials for presigning S3 URLs.
|
||||
///
|
||||
/// `Debug` is intentionally redacted — the struct holds plaintext secrets.
|
||||
#[derive(Clone)]
|
||||
pub struct S3StaticCredentials {
|
||||
pub access_key_id: String,
|
||||
pub secret_access_key: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for S3StaticCredentials {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("S3StaticCredentials")
|
||||
.field("access_key_id", &"[redacted]")
|
||||
.field("secret_access_key", &"[redacted]")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl S3StaticCredentials {
|
||||
fn to_credentials_content(&self) -> String {
|
||||
serde_json::json!({
|
||||
"aws_access_key_id": self.access_key_id,
|
||||
"aws_secret_access_key": self.secret_access_key,
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn presign_put_url(
|
||||
region: &str,
|
||||
endpoint_url: Option<&str>,
|
||||
creds: &S3StaticCredentials,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
content_type: &str,
|
||||
expires_in: std::time::Duration,
|
||||
) -> anyhow::Result<String> {
|
||||
let content = creds.to_credentials_content();
|
||||
let client = build_s3_client(region, Some(&content), None, endpoint_url).await?;
|
||||
let presigning_config = aws_sdk_s3::presigning::PresigningConfig::expires_in(expires_in)?;
|
||||
let presigned = client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.content_type(content_type)
|
||||
.presigned(presigning_config)
|
||||
.await?;
|
||||
Ok(presigned.uri().to_string())
|
||||
}
|
||||
|
||||
pub async fn presign_get_url(
|
||||
region: &str,
|
||||
endpoint_url: Option<&str>,
|
||||
creds: &S3StaticCredentials,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
expires_in: std::time::Duration,
|
||||
) -> anyhow::Result<String> {
|
||||
let content = creds.to_credentials_content();
|
||||
let client = build_s3_client(region, Some(&content), None, endpoint_url).await?;
|
||||
let presigning_config = aws_sdk_s3::presigning::PresigningConfig::expires_in(expires_in)?;
|
||||
let presigned = client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.presigned(presigning_config)
|
||||
.await?;
|
||||
Ok(presigned.uri().to_string())
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
use opentelemetry::global;
|
||||
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
|
||||
use tracing_opentelemetry::OpenTelemetrySpanExt;
|
||||
|
||||
/// Extract the current span's W3C `traceparent` string for propagation
|
||||
/// across channel/task boundaries where span context is lost.
|
||||
pub fn current_traceparent() -> Option<String> {
|
||||
let current_span = tracing::Span::current();
|
||||
if current_span.is_none() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let cx = current_span.context();
|
||||
let mut carrier = std::collections::HashMap::new();
|
||||
global::get_text_map_propagator(|p| {
|
||||
p.inject_context(&cx, &mut carrier);
|
||||
});
|
||||
|
||||
carrier.remove("traceparent")
|
||||
}
|
||||
|
||||
pub fn inject_trace_context_into_request(
|
||||
mut builder: reqwest::RequestBuilder,
|
||||
) -> reqwest::RequestBuilder {
|
||||
let mut headers = HeaderMap::new();
|
||||
inject_trace_context(&mut headers);
|
||||
|
||||
// Insert new headers into the request builder
|
||||
for (name, value) in headers.iter() {
|
||||
builder = builder.header(name, value);
|
||||
}
|
||||
|
||||
builder
|
||||
}
|
||||
|
||||
pub(crate) fn inject_trace_context(headers: &mut HeaderMap) {
|
||||
// Prefer the context from the current tracing span (set by OpenTelemetryLayer).
|
||||
// Fall back to opentelemetry::Context::current() (thread-local) for code paths
|
||||
// that run outside a tracing span but on a thread that has an attached OTel context
|
||||
// (e.g. tasks created via spawn_local that inherit the thread-local context).
|
||||
let current_span = tracing::Span::current();
|
||||
let cx = if current_span.is_none() {
|
||||
opentelemetry::Context::current()
|
||||
} else {
|
||||
current_span.context()
|
||||
};
|
||||
|
||||
global::get_text_map_propagator(|propagator| {
|
||||
propagator.inject_context(&cx, &mut HeaderMapInjector(headers));
|
||||
});
|
||||
}
|
||||
|
||||
struct HeaderMapInjector<'a>(&'a mut HeaderMap);
|
||||
|
||||
impl opentelemetry::propagation::Injector for HeaderMapInjector<'_> {
|
||||
fn set(&mut self, key: &str, value: String) {
|
||||
match (HeaderName::try_from(key), HeaderValue::try_from(&value)) {
|
||||
(Ok(name), Ok(val)) => {
|
||||
self.0.insert(name, val);
|
||||
}
|
||||
(Err(e), _) => {
|
||||
tracing::debug!("Invalid header name '{}': {}", key, e);
|
||||
}
|
||||
(_, Err(e)) => {
|
||||
tracing::debug!("Invalid header value for '{}': {}", key, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a tracing span parented to `_meta.traceparent`.
|
||||
/// Used as a callback for `with_on_meta` in ACP session/server builders.
|
||||
pub fn span_from_meta_traceparent(
|
||||
meta: &serde_json::Map<String, serde_json::Value>,
|
||||
) -> tracing::Span {
|
||||
let span = tracing::info_span!("acp_dispatch");
|
||||
if let Some(ctx) = meta
|
||||
.get("traceparent")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(extract_context)
|
||||
{
|
||||
let _ = span.set_parent(ctx);
|
||||
}
|
||||
span
|
||||
}
|
||||
|
||||
/// Link the current span to a W3C `traceparent` carried inside a JSON `_meta`
|
||||
/// (or top-level) object. Call this at the top of a `#[tracing::instrument]`
|
||||
/// function so the span created by the macro becomes a child of the client's
|
||||
/// distributed trace.
|
||||
pub fn link_current_span_to_meta(meta: &serde_json::Value) {
|
||||
if let Some(ctx) = meta
|
||||
.get("traceparent")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(extract_context)
|
||||
{
|
||||
let _ = tracing::Span::current().set_parent(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_context(traceparent: &str) -> Option<opentelemetry::Context> {
|
||||
use opentelemetry::trace::TraceContextExt;
|
||||
|
||||
let mut carrier = std::collections::HashMap::new();
|
||||
carrier.insert("traceparent".to_string(), traceparent.to_string());
|
||||
|
||||
let ctx = opentelemetry::global::get_text_map_propagator(|p| p.extract(&carrier));
|
||||
ctx.span().span_context().is_valid().then_some(ctx)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_inject_trace_context_no_active_span() {
|
||||
// When there's no active span, no headers should be added
|
||||
let mut headers = HeaderMap::new();
|
||||
inject_trace_context(&mut headers);
|
||||
|
||||
// Without an active OpenTelemetry span, no traceparent header is added
|
||||
// (the propagator only injects if there's a valid span context)
|
||||
assert!(headers.get("traceparent").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_header_map_injector_valid_header() {
|
||||
let mut headers = HeaderMap::new();
|
||||
{
|
||||
let mut injector = HeaderMapInjector(&mut headers);
|
||||
opentelemetry::propagation::Injector::set(
|
||||
&mut injector,
|
||||
"traceparent",
|
||||
"00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
headers.get("traceparent").map(|v| v.to_str().unwrap()),
|
||||
Some("00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_header_map_injector_invalid_header_name() {
|
||||
let mut headers = HeaderMap::new();
|
||||
{
|
||||
let mut injector = HeaderMapInjector(&mut headers);
|
||||
// Invalid header name (contains space) should be silently ignored
|
||||
opentelemetry::propagation::Injector::set(
|
||||
&mut injector,
|
||||
"invalid header",
|
||||
"value".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
assert!(headers.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_header_map_injector_invalid_header_value() {
|
||||
let mut headers = HeaderMap::new();
|
||||
{
|
||||
let mut injector = HeaderMapInjector(&mut headers);
|
||||
// Invalid header value (contains non-visible ASCII) should be silently ignored
|
||||
opentelemetry::propagation::Injector::set(
|
||||
&mut injector,
|
||||
"traceparent",
|
||||
"invalid\x00value".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
assert!(headers.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_context_rejects_invalid_traceparent() {
|
||||
assert!(extract_context("not-a-valid-traceparent").is_none());
|
||||
assert!(extract_context("").is_none());
|
||||
}
|
||||
|
||||
/// E2E: _meta.traceparent -> link_current_span_to_meta -> current span
|
||||
/// -> inject_trace_context_into_request -> outbound HTTP header carries same traceId.
|
||||
#[test]
|
||||
fn test_link_meta_then_inject_propagates_trace_id() {
|
||||
use opentelemetry::trace::TracerProvider as _;
|
||||
use opentelemetry_sdk::propagation::TraceContextPropagator;
|
||||
use opentelemetry_sdk::trace::SdkTracerProvider;
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
|
||||
opentelemetry::global::set_text_map_propagator(TraceContextPropagator::new());
|
||||
|
||||
let provider = SdkTracerProvider::builder().build();
|
||||
let tracer = provider.tracer("test");
|
||||
let otel_layer = tracing_opentelemetry::layer()
|
||||
.with_tracer(tracer)
|
||||
.with_context_activation(false);
|
||||
let subscriber = tracing_subscriber::Registry::default().with(otel_layer);
|
||||
let _subscriber_guard = tracing::subscriber::set_default(subscriber);
|
||||
|
||||
let browser_trace_id = "0af7651916cd43dd8448eb211c80319c";
|
||||
let meta = serde_json::json!({
|
||||
"traceparent": format!("00-{browser_trace_id}-b7ad6b7169203331-01"),
|
||||
});
|
||||
|
||||
let span = tracing::info_span!("test_span");
|
||||
let _entered = span.enter();
|
||||
link_current_span_to_meta(&meta);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let builder = client.get("https://cli-chat-proxy.example.com/v1/chat/completions");
|
||||
let builder = inject_trace_context_into_request(builder);
|
||||
let request = builder.build().expect("Failed to build request");
|
||||
|
||||
let traceparent = request
|
||||
.headers()
|
||||
.get("traceparent")
|
||||
.expect("traceparent header missing")
|
||||
.to_str()
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
traceparent.starts_with(&format!("00-{browser_trace_id}-")),
|
||||
"outbound traceId should match browser's. got: {traceparent}"
|
||||
);
|
||||
assert!(
|
||||
traceparent.ends_with("-01"),
|
||||
"sampled flag should be set. got: {traceparent}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inject_trace_context_into_request_preserves_existing_headers() {
|
||||
use opentelemetry::trace::{
|
||||
SpanContext, SpanId, TraceContextExt, TraceFlags, TraceId, TraceState,
|
||||
};
|
||||
use opentelemetry_sdk::propagation::TraceContextPropagator;
|
||||
|
||||
// Initialize the global text map propagator for this test
|
||||
// This is necessary because by default the global propagator is a no-op
|
||||
opentelemetry::global::set_text_map_propagator(TraceContextPropagator::new());
|
||||
|
||||
// Create a valid span context with known trace_id and span_id
|
||||
let trace_id = TraceId::from_hex("0af7651916cd43dd8448eb211c80319c").unwrap();
|
||||
let span_id = SpanId::from_hex("b7ad6b7169203331").unwrap();
|
||||
let span_context = SpanContext::new(
|
||||
trace_id,
|
||||
span_id,
|
||||
TraceFlags::SAMPLED,
|
||||
true, // is_remote
|
||||
TraceState::default(),
|
||||
);
|
||||
|
||||
// Create a context with this span context attached
|
||||
let cx = opentelemetry::Context::current().with_remote_span_context(span_context);
|
||||
let _guard = cx.attach();
|
||||
|
||||
// Create a client and request builder with existing headers
|
||||
let client = reqwest::Client::new();
|
||||
let builder = client
|
||||
.get("https://example.com")
|
||||
.header("x-custom-header", "custom-value")
|
||||
.header("authorization", "Bearer token123");
|
||||
|
||||
// Inject trace context into the request
|
||||
let builder = inject_trace_context_into_request(builder);
|
||||
let request = builder.build().expect("Failed to build request");
|
||||
let headers = request.headers();
|
||||
|
||||
// Verify existing headers are preserved
|
||||
assert_eq!(
|
||||
headers.get("x-custom-header").map(|v| v.to_str().unwrap()),
|
||||
Some("custom-value"),
|
||||
"Custom header should be preserved after injecting trace context"
|
||||
);
|
||||
assert_eq!(
|
||||
headers.get("authorization").map(|v| v.to_str().unwrap()),
|
||||
Some("Bearer token123"),
|
||||
"Authorization header should be preserved after injecting trace context"
|
||||
);
|
||||
|
||||
// Verify the traceparent header was injected with correct trace context
|
||||
let traceparent = headers
|
||||
.get("traceparent")
|
||||
.expect("traceparent header should be present with active span")
|
||||
.to_str()
|
||||
.unwrap();
|
||||
|
||||
// traceparent format: {version}-{trace-id}-{parent-id}-{trace-flags}
|
||||
// e.g., "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"
|
||||
assert!(
|
||||
traceparent.starts_with("00-0af7651916cd43dd8448eb211c80319c-"),
|
||||
"traceparent should contain the correct trace_id, got: {}",
|
||||
traceparent
|
||||
);
|
||||
assert!(
|
||||
traceparent.contains("b7ad6b7169203331"),
|
||||
"traceparent should contain the correct span_id, got: {}",
|
||||
traceparent
|
||||
);
|
||||
assert!(
|
||||
traceparent.ends_with("-01"),
|
||||
"traceparent should have sampled flag set, got: {}",
|
||||
traceparent
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const EXCLUDED_DIR_NAMES: &[&str] = &[
|
||||
".kigi", ".cache", ".daemon", ".config", ".npm", ".cargo", ".rustup", ".vscode", ".gemini",
|
||||
".hermes", ".claude",
|
||||
];
|
||||
|
||||
fn known_os_dirs() -> Vec<PathBuf> {
|
||||
[
|
||||
dirs::desktop_dir(),
|
||||
dirs::download_dir(),
|
||||
dirs::document_dir(),
|
||||
dirs::audio_dir(),
|
||||
dirs::video_dir(),
|
||||
dirs::picture_dir(),
|
||||
dirs::public_dir(),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn is_project_dir(cwd: &Path) -> bool {
|
||||
if cwd.as_os_str().is_empty() || cwd.parent().is_none() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if cwd.ancestors().any(|p| p.join(".git").exists()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if has_excluded_component(cwd) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if is_platform_system_dir(cwd) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let Some(home) = dirs::home_dir() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if cwd == home {
|
||||
return false;
|
||||
}
|
||||
|
||||
if is_platform_home_excluded(cwd, &home) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if known_os_dirs().iter().any(|d| cwd == d) {
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn is_platform_system_dir(cwd: &Path) -> bool {
|
||||
if cwd == Path::new("/tmp")
|
||||
|| cwd.starts_with("/tmp/")
|
||||
|| cwd == Path::new("/var/tmp")
|
||||
|| cwd.starts_with("/var/tmp/")
|
||||
|| cwd.starts_with("/var/folders/")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
if cwd == Path::new("/private/tmp")
|
||||
|| cwd.starts_with("/private/tmp/")
|
||||
|| cwd == Path::new("/private/var/tmp")
|
||||
|| cwd.starts_with("/private/var/tmp/")
|
||||
|| cwd.starts_with("/private/var/folders/")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
if cwd == Path::new("/root") {
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn is_platform_system_dir(cwd: &Path) -> bool {
|
||||
if let Ok(temp) = std::env::var("TEMP").or_else(|_| std::env::var("TMP")) {
|
||||
if cwd.starts_with(&temp) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
let path_lower = cwd.to_string_lossy().to_lowercase();
|
||||
if path_lower.contains("\\windows\\")
|
||||
|| path_lower.ends_with("\\windows")
|
||||
|| path_lower.contains("\\program files")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if cwd.parent().map_or(false, |p| p.parent().is_none()) && cwd.to_string_lossy().len() <= 3 {
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn is_platform_home_excluded(cwd: &Path, home: &Path) -> bool {
|
||||
if cwd.starts_with(home.join("Library"))
|
||||
&& !cwd.starts_with(home.join("Library/Mobile Documents"))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn is_platform_home_excluded(cwd: &Path, home: &Path) -> bool {
|
||||
let Ok(relative) = cwd.strip_prefix(home) else {
|
||||
return false;
|
||||
};
|
||||
if relative.components().count() != 1 {
|
||||
return false;
|
||||
}
|
||||
let Some(std::path::Component::Normal(name)) = relative.components().next() else {
|
||||
return false;
|
||||
};
|
||||
let name = name.to_string_lossy().to_lowercase();
|
||||
[
|
||||
"desktop",
|
||||
"downloads",
|
||||
"documents",
|
||||
"pictures",
|
||||
"music",
|
||||
"videos",
|
||||
]
|
||||
.contains(&name.as_str())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn is_platform_home_excluded(_cwd: &Path, _home: &Path) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn has_excluded_component(path: &Path) -> bool {
|
||||
for component in path.components() {
|
||||
if let std::path::Component::Normal(name) = component {
|
||||
let name_lower = name.to_string_lossy().to_lowercase();
|
||||
|
||||
if EXCLUDED_DIR_NAMES.contains(&name_lower.as_str()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if name_lower.starts_with(".kigi-") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
mod posix {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn root_is_unsafe() {
|
||||
assert!(!is_project_dir(Path::new("/")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tmp_is_unsafe() {
|
||||
assert!(!is_project_dir(Path::new("/tmp")));
|
||||
assert!(!is_project_dir(Path::new("/tmp/scratch")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tmp_prefix_not_greedy() {
|
||||
assert!(is_project_dir(Path::new("/tmpdata/foo")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn var_folders_is_unsafe() {
|
||||
assert!(!is_project_dir(Path::new("/var/folders/ab/cd")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_project_is_safe() {
|
||||
assert!(is_project_dir(Path::new("/Users/someone/my-project/src")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn home_subdir_is_safe() {
|
||||
assert!(is_project_dir(Path::new("/Users/someone/my-project")));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn private_tmp_is_unsafe() {
|
||||
assert!(!is_project_dir(Path::new("/private/tmp")));
|
||||
assert!(!is_project_dir(Path::new("/private/tmp/scratch")));
|
||||
assert!(!is_project_dir(Path::new("/private/var/folders/ab/cd")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_is_unsafe() {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
assert!(!is_project_dir(&home.join("Library")));
|
||||
assert!(!is_project_dir(&home.join("Library/Caches")));
|
||||
assert!(!is_project_dir(&home.join("Library/Application Support")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icloud_drive_projects_are_safe() {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
assert!(is_project_dir(&home.join(
|
||||
"Library/Mobile Documents/com~apple~CloudDocs/Projects/my-app"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bare_root_is_unsafe() {
|
||||
assert!(!is_project_dir(Path::new("/root")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_project_is_safe() {
|
||||
assert!(is_project_dir(Path::new("/root/my-project")));
|
||||
}
|
||||
}
|
||||
|
||||
mod config_and_cache {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn grok_dirs_are_unsafe() {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
assert!(!is_project_dir(&home.join(".kigi")));
|
||||
assert!(!is_project_dir(&home.join(".kigi/bin")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grok_prefixed_dirs_are_unsafe() {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
assert!(!is_project_dir(&home.join(".kigi-proxy-work")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_dirs_are_unsafe() {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
assert!(!is_project_dir(&home.join(".cache/zoe-proc")));
|
||||
assert!(!is_project_dir(&home.join(".config/nvim")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_ai_tool_dirs_are_unsafe() {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
assert!(!is_project_dir(&home.join(".gemini/antigravity")));
|
||||
assert!(!is_project_dir(&home.join(".hermes/kanban")));
|
||||
assert!(!is_project_dir(&home.join(".claude/projects")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod home_and_os_dirs {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn home_is_unsafe() {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
assert!(!is_project_dir(&home));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn home_project_is_safe() {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
assert!(is_project_dir(&home.join("my-project")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_desktop_is_unsafe() {
|
||||
if let Some(d) = dirs::desktop_dir() {
|
||||
assert!(!is_project_dir(&d));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_project_is_safe() {
|
||||
if let Some(d) = dirs::desktop_dir() {
|
||||
assert!(is_project_dir(&d.join("my-project")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_downloads_is_unsafe() {
|
||||
if let Some(d) = dirs::download_dir() {
|
||||
assert!(!is_project_dir(&d));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_documents_is_unsafe() {
|
||||
if let Some(d) = dirs::document_dir() {
|
||||
assert!(!is_project_dir(&d));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod edge_cases {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn empty_path_is_unsafe() {
|
||||
assert!(!is_project_dir(Path::new("")));
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
#[test]
|
||||
fn unicode_paths_work() {
|
||||
assert!(is_project_dir(Path::new(
|
||||
"/Users/me/code/\u{D3F4}\u{B9AC}\u{B9C8}\u{CF13}"
|
||||
)));
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
#[test]
|
||||
fn spaces_work() {
|
||||
assert!(is_project_dir(Path::new("/Users/me/My Projects/cool app")));
|
||||
}
|
||||
}
|
||||
|
||||
mod git_detection {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn inside_git_repo_is_safe() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir(tmp.path().join(".git")).unwrap();
|
||||
assert!(is_project_dir(tmp.path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subdirectory_of_git_repo_is_safe() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir(tmp.path().join(".git")).unwrap();
|
||||
let sub = tmp.path().join("deep/sub/dir");
|
||||
std::fs::create_dir_all(&sub).unwrap();
|
||||
assert!(is_project_dir(&sub));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user