feat(kei-registry): status subcommand — cross-cutting substrate dashboard
Phase 3 of substrate-unified-registry: a single command shows every
live artefact across the three sources without merging stores.
`kei-registry status` joins:
1. `blocks` table (kei-registry SQLite) — active counts per BlockType,
plus the registered path-atoms with DNA prefix + body sha8.
2. `git for-each-ref refs/heads` (shell-out, no DB persistence) — local
branches, current marker, ahead/behind via `upstream:track,nobracket`.
3. `agents` table (kei-ledger SQLite) — fork counts per status
(running/done/failed/merged/rejected). Missing ledger DB → section
skipped, never an error.
Output: ASCII multi-section table by default; `--format json` for
machine consumption.
Files:
- `_primitives/_rust/kei-registry/src/status.rs` — new module, ~270
LOC. Pure read-side per Constructor Pattern. 7 unit tests cover
`parse_track` (in sync / ahead / behind / both / "gone"), DNA prefix
rendering, and empty-status section presence.
- `_primitives/_rust/kei-registry/src/cli.rs` — new `Status` variant
with `--db`, `--git-repo`, `--ledger-db`, `--format` flags.
- `_primitives/_rust/kei-registry/src/handlers.rs` — `handle_status`
dispatcher, ASCII/JSON branching.
- `_primitives/_rust/kei-registry/src/lib.rs` — module export.
End-to-end run from kit root shows the prior gap: 17 local branches
(many `worktree-agent-*` orphans), kei-ledger summary 4 running /
158 done / 35 failed / 7 merged / 0 rejected — visibility the user
asked for ("в каждой сессии видеть, чтобы не бегать по диску в
поисках несмерженных").
What this does NOT do (Phase 4):
- No orphan detection (`kei-status orphans`) — counts only.
- No auto-registration of branches into kei-ledger (Phase 2). Branches
come from live `git for-each-ref` shell-out; if the repo moves or
is deleted the row vanishes from the dashboard. Acceptable for v1.
=== STATUS-TRUTH MARKER ===
shipped: functional
stubs: 0
cargo-check: PASS
behaviour-verified: yes
follow-up-required:
- Phase 2 (post-checkout hook → kei-ledger auto-register)
- Phase 4 (orphan detection: branches with no commits in N days,
path-atoms with no consumers, agent forks stuck running)
- --filter flags (--type, --status) for targeted queries
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3422bdc8c3
commit
d6dbdee870
4 changed files with 392 additions and 0 deletions
|
|
@ -147,4 +147,25 @@ pub enum Command {
|
|||
#[arg(long, default_value_t = false)]
|
||||
dry_run: bool,
|
||||
},
|
||||
|
||||
/// Cross-cutting status dashboard: blocks per type, registered path
|
||||
/// atoms, local git branches with ahead/behind, and agent forks from
|
||||
/// `kei-ledger` (if present).
|
||||
Status {
|
||||
/// Registry SQLite path (default: `$KEI_REGISTRY_DB` or
|
||||
/// `~/.claude/registry.sqlite`).
|
||||
#[arg(long)]
|
||||
db: Option<PathBuf>,
|
||||
/// Local git repo to scan for branches (default: current dir).
|
||||
#[arg(long, default_value = ".")]
|
||||
git_repo: PathBuf,
|
||||
/// kei-ledger SQLite path (default: `$KEI_LEDGER_DB` or
|
||||
/// `~/.claude/agents/ledger.sqlite`). Missing file → agent
|
||||
/// section is skipped, never an error.
|
||||
#[arg(long)]
|
||||
ledger_db: Option<PathBuf>,
|
||||
/// Output format: `ascii` (default) or `json`.
|
||||
#[arg(long, default_value = "ascii")]
|
||||
format: String,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,9 +68,37 @@ pub fn dispatch(cmd: Command) -> Result<Outcome> {
|
|||
Command::IndexSubstrate { kit_root, db, dry_run } => {
|
||||
index_substrate::handle_index_substrate(Some(kit_root), db, dry_run)
|
||||
}
|
||||
Command::Status {
|
||||
db,
|
||||
git_repo,
|
||||
ledger_db,
|
||||
format,
|
||||
} => handle_status(db, git_repo, ledger_db, format),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_status(
|
||||
db: Option<PathBuf>,
|
||||
git_repo: PathBuf,
|
||||
ledger_db: Option<PathBuf>,
|
||||
format: String,
|
||||
) -> Result<Outcome> {
|
||||
let db_path = resolve_db(db);
|
||||
let conn = open_db(&db_path)?;
|
||||
let ledger = ledger_db.unwrap_or_else(crate::status::default_ledger_path);
|
||||
let snap = crate::status::compute_status(&conn, Some(&git_repo), Some(&ledger))?;
|
||||
match format.as_str() {
|
||||
"json" => {
|
||||
println!("{}", serde_json::to_string_pretty(&snap)?);
|
||||
}
|
||||
"ascii" | "" => {
|
||||
print!("{}", crate::status::render_ascii(&snap));
|
||||
}
|
||||
other => anyhow::bail!("unknown --format '{other}' (use ascii or json)"),
|
||||
}
|
||||
Ok(Outcome::Ok)
|
||||
}
|
||||
|
||||
fn handle_init(db: Option<PathBuf>) -> Result<Outcome> {
|
||||
let path = resolve_db(db);
|
||||
if let Some(parent) = path.parent() {
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ pub mod related;
|
|||
pub mod scan_orchestrator;
|
||||
pub mod scanners;
|
||||
pub mod stats;
|
||||
pub mod status;
|
||||
pub mod store;
|
||||
|
||||
pub use block::{Block, BlockType};
|
||||
|
|
@ -35,4 +36,5 @@ pub use dna_block::{compose_for_block, compose_for_block_with_nonce};
|
|||
pub use registry::{find_by_path, get, list, list_by_type, mark_superseded, register};
|
||||
pub use related::{find_related, RelatedHit};
|
||||
pub use stats::{compute_stats, Stats};
|
||||
pub use status::{compute_status, render_ascii, Status};
|
||||
pub use store::{open_db, SCHEMA_VERSION};
|
||||
|
|
|
|||
341
_primitives/_rust/kei-registry/src/status.rs
Normal file
341
_primitives/_rust/kei-registry/src/status.rs
Normal file
|
|
@ -0,0 +1,341 @@
|
|||
//! Cross-cutting "what is alive right now" view.
|
||||
//!
|
||||
//! Constructor Pattern: pure read-side cube. Joins three sources for a
|
||||
//! single dashboard:
|
||||
//! 1. `blocks` table from kei-registry — atoms, skills, rules, hooks,
|
||||
//! primitives, plus path-atoms (atoms whose source file is
|
||||
//! `_blocks/path-*.md`).
|
||||
//! 2. `agents` table from `~/.claude/agents/ledger.sqlite` if present —
|
||||
//! agent forks per RULE 0.12, with status (running / done / failed /
|
||||
//! merged / rejected).
|
||||
//! 3. `git for-each-ref refs/heads` shell-out — local branches with
|
||||
//! `ahead`, `behind` and `dirty` flags relative to their upstream.
|
||||
//!
|
||||
//! No I/O beyond DB reads + one git invocation. No writes. The handler
|
||||
//! formats the gathered struct into either an ASCII table (default) or
|
||||
//! JSON (`--format json`).
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use rusqlite::Connection;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use crate::block::BlockType;
|
||||
|
||||
/// Aggregate snapshot returned by `compute_status`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct Status {
|
||||
pub blocks_by_type: BTreeMap<String, u64>,
|
||||
pub path_atoms: Vec<PathAtomRow>,
|
||||
pub branches: Vec<BranchRow>,
|
||||
pub agents: Option<AgentSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PathAtomRow {
|
||||
pub name: String,
|
||||
pub dna_prefix: String,
|
||||
pub body_sha8: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BranchRow {
|
||||
pub name: String,
|
||||
pub current: bool,
|
||||
pub upstream: Option<String>,
|
||||
pub ahead: u32,
|
||||
pub behind: u32,
|
||||
pub last_commit: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct AgentSummary {
|
||||
pub running: u64,
|
||||
pub done: u64,
|
||||
pub failed: u64,
|
||||
pub merged: u64,
|
||||
pub rejected: u64,
|
||||
}
|
||||
|
||||
/// Compute the full status snapshot. `git_repo` is the path to scan for
|
||||
/// branches (typically the current working directory). `ledger_db` is
|
||||
/// the optional path to `~/.claude/agents/ledger.sqlite`; if it doesn't
|
||||
/// exist, `agents` field is `None`.
|
||||
pub fn compute_status(
|
||||
conn: &Connection,
|
||||
git_repo: Option<&Path>,
|
||||
ledger_db: Option<&Path>,
|
||||
) -> Result<Status> {
|
||||
let mut s = Status::default();
|
||||
s.blocks_by_type = block_counts(conn)?;
|
||||
s.path_atoms = path_atom_rows(conn)?;
|
||||
if let Some(repo) = git_repo {
|
||||
s.branches = git_branches(repo).unwrap_or_default();
|
||||
}
|
||||
if let Some(db) = ledger_db {
|
||||
if db.exists() {
|
||||
s.agents = ledger_agent_summary(db).ok();
|
||||
}
|
||||
}
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
fn block_counts(conn: &Connection) -> Result<BTreeMap<String, u64>> {
|
||||
let mut out = BTreeMap::new();
|
||||
for bt in BlockType::all() {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT COUNT(*) FROM blocks \
|
||||
WHERE block_type = ?1 AND superseded_by IS NULL",
|
||||
)
|
||||
.context("prepare block_counts")?;
|
||||
let n: i64 = stmt
|
||||
.query_row(rusqlite::params![bt.as_str()], |r| r.get(0))
|
||||
.context("query block_counts")?;
|
||||
out.insert(bt.as_str().to_string(), n as u64);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn path_atom_rows(conn: &Connection) -> Result<Vec<PathAtomRow>> {
|
||||
// Convention: path-atoms are atoms whose source file matches
|
||||
// `_blocks/path-<name>.md`. SQL LIKE keeps it server-side; the
|
||||
// resulting rows are sorted by name for stable output.
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT name, dna, body_sha FROM blocks \
|
||||
WHERE block_type = 'atom' \
|
||||
AND superseded_by IS NULL \
|
||||
AND path LIKE '%/_blocks/path-%.md' \
|
||||
ORDER BY name",
|
||||
)
|
||||
.context("prepare path_atom_rows")?;
|
||||
let rows = stmt
|
||||
.query_map([], |r| {
|
||||
let dna: String = r.get(1)?;
|
||||
let dna_prefix = dna_prefix(&dna);
|
||||
Ok(PathAtomRow {
|
||||
name: r.get(0)?,
|
||||
dna_prefix,
|
||||
body_sha8: r.get(2)?,
|
||||
})
|
||||
})?
|
||||
.filter_map(Result::ok)
|
||||
.collect();
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Take the first three segments of a `<role>::<caps>::<scope_sha8>::...`
|
||||
/// DNA so the displayed prefix is readable but identifying.
|
||||
fn dna_prefix(dna: &str) -> String {
|
||||
let mut parts = dna.split("::").take(3).collect::<Vec<_>>();
|
||||
if parts.len() < 3 {
|
||||
return dna.to_string();
|
||||
}
|
||||
parts.push("…");
|
||||
parts.join("::")
|
||||
}
|
||||
|
||||
fn git_branches(repo: &Path) -> Result<Vec<BranchRow>> {
|
||||
let current_branch = run_git(repo, &["rev-parse", "--abbrev-ref", "HEAD"]).ok();
|
||||
let out = run_git(
|
||||
repo,
|
||||
&[
|
||||
"for-each-ref",
|
||||
"--format=%(refname:short)\t%(upstream:short)\t%(upstream:track,nobracket)\t%(objectname:short)",
|
||||
"refs/heads",
|
||||
],
|
||||
)?;
|
||||
let mut rows = Vec::new();
|
||||
for line in out.lines() {
|
||||
let parts: Vec<&str> = line.split('\t').collect();
|
||||
if parts.len() < 4 {
|
||||
continue;
|
||||
}
|
||||
let name = parts[0].to_string();
|
||||
let upstream = if parts[1].is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(parts[1].to_string())
|
||||
};
|
||||
let (ahead, behind) = parse_track(parts[2]);
|
||||
rows.push(BranchRow {
|
||||
current: current_branch.as_deref() == Some(&name),
|
||||
name,
|
||||
upstream,
|
||||
ahead,
|
||||
behind,
|
||||
last_commit: parts[3].to_string(),
|
||||
});
|
||||
}
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Parse `upstream:track,nobracket` output. Examples:
|
||||
/// `""` (in sync), `"ahead 3"`, `"behind 1"`, `"ahead 3, behind 1"`,
|
||||
/// `"gone"` (upstream deleted).
|
||||
fn parse_track(s: &str) -> (u32, u32) {
|
||||
let mut ahead = 0u32;
|
||||
let mut behind = 0u32;
|
||||
for part in s.split(',') {
|
||||
let part = part.trim();
|
||||
if let Some(n) = part.strip_prefix("ahead ") {
|
||||
ahead = n.parse().unwrap_or(0);
|
||||
} else if let Some(n) = part.strip_prefix("behind ") {
|
||||
behind = n.parse().unwrap_or(0);
|
||||
}
|
||||
}
|
||||
(ahead, behind)
|
||||
}
|
||||
|
||||
fn run_git(repo: &Path, args: &[&str]) -> Result<String> {
|
||||
let out = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(repo)
|
||||
.args(args)
|
||||
.output()
|
||||
.context("spawn git")?;
|
||||
if !out.status.success() {
|
||||
anyhow::bail!(
|
||||
"git {:?} failed: {}",
|
||||
args,
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&out.stdout).trim_end().to_string())
|
||||
}
|
||||
|
||||
fn ledger_agent_summary(db: &Path) -> Result<AgentSummary> {
|
||||
let conn = rusqlite::Connection::open_with_flags(
|
||||
db,
|
||||
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
|
||||
)
|
||||
.context("open ledger DB read-only")?;
|
||||
let mut s = AgentSummary::default();
|
||||
for (status, slot) in [
|
||||
("running", &mut s.running),
|
||||
("done", &mut s.done),
|
||||
("failed", &mut s.failed),
|
||||
("merged", &mut s.merged),
|
||||
("rejected", &mut s.rejected),
|
||||
] {
|
||||
let mut stmt = conn.prepare("SELECT COUNT(*) FROM agents WHERE status = ?1")?;
|
||||
let n: i64 = stmt.query_row(rusqlite::params![status], |r| r.get(0))?;
|
||||
*slot = n as u64;
|
||||
}
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// Render `Status` as a multi-section ASCII report.
|
||||
pub fn render_ascii(s: &Status) -> String {
|
||||
let mut out = String::new();
|
||||
out.push_str("=== Substrate Status ===\n\n");
|
||||
out.push_str("[Blocks — active count by type]\n");
|
||||
for (k, v) in &s.blocks_by_type {
|
||||
out.push_str(&format!(" {:<14} {}\n", k, v));
|
||||
}
|
||||
out.push('\n');
|
||||
|
||||
out.push_str(&format!("[Path Atoms — {}]\n", s.path_atoms.len()));
|
||||
for p in &s.path_atoms {
|
||||
out.push_str(&format!(
|
||||
" {:<14} {:<28} body:{}\n",
|
||||
p.name, p.dna_prefix, p.body_sha8
|
||||
));
|
||||
}
|
||||
if s.path_atoms.is_empty() {
|
||||
out.push_str(" (none registered)\n");
|
||||
}
|
||||
out.push('\n');
|
||||
|
||||
out.push_str(&format!("[Local Branches — {}]\n", s.branches.len()));
|
||||
for b in &s.branches {
|
||||
let marker = if b.current { "*" } else { " " };
|
||||
let track = match (b.ahead, b.behind) {
|
||||
(0, 0) => "in sync".to_string(),
|
||||
(a, 0) => format!("ahead {a}"),
|
||||
(0, b_) => format!("behind {b_}"),
|
||||
(a, b_) => format!("ahead {a}, behind {b_}"),
|
||||
};
|
||||
let upstream = b.upstream.as_deref().unwrap_or("(none)");
|
||||
out.push_str(&format!(
|
||||
" {} {:<40} → {:<25} {} @ {}\n",
|
||||
marker, b.name, upstream, track, b.last_commit
|
||||
));
|
||||
}
|
||||
out.push('\n');
|
||||
|
||||
if let Some(a) = &s.agents {
|
||||
out.push_str("[Agent Forks — kei-ledger]\n");
|
||||
out.push_str(&format!(
|
||||
" running:{} done:{} merged:{} failed:{} rejected:{}\n",
|
||||
a.running, a.done, a.merged, a.failed, a.rejected
|
||||
));
|
||||
} else {
|
||||
out.push_str("[Agent Forks]\n (no kei-ledger DB found)\n");
|
||||
}
|
||||
out.push('\n');
|
||||
out
|
||||
}
|
||||
|
||||
/// Default ledger path: `$KEI_LEDGER_DB` or `~/.claude/agents/ledger.sqlite`.
|
||||
pub fn default_ledger_path() -> PathBuf {
|
||||
if let Some(v) = std::env::var_os("KEI_LEDGER_DB") {
|
||||
return PathBuf::from(v);
|
||||
}
|
||||
let home = std::env::var_os("HOME").unwrap_or_default();
|
||||
PathBuf::from(home).join(".claude/agents/ledger.sqlite")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_track_in_sync() {
|
||||
assert_eq!(parse_track(""), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_track_ahead() {
|
||||
assert_eq!(parse_track("ahead 3"), (3, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_track_behind() {
|
||||
assert_eq!(parse_track("behind 7"), (0, 7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_track_both() {
|
||||
assert_eq!(parse_track("ahead 3, behind 1"), (3, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_track_gone_treated_as_zero() {
|
||||
// Upstream deleted — git emits "gone"; we don't surface it as
|
||||
// ahead/behind, so callers see (0, 0). Acceptable for a status
|
||||
// dashboard; a future field could carry it explicitly.
|
||||
assert_eq!(parse_track("gone"), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dna_prefix_three_segments() {
|
||||
let dna = "atom::md::1a771d51::b8f9e85f-abc12345";
|
||||
assert_eq!(dna_prefix(dna), "atom::md::1a771d51::…");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_ascii_empty_status_has_all_sections() {
|
||||
let s = Status::default();
|
||||
let out = render_ascii(&s);
|
||||
assert!(out.contains("Blocks"));
|
||||
assert!(out.contains("Path Atoms"));
|
||||
assert!(out.contains("Local Branches"));
|
||||
assert!(out.contains("Agent Forks"));
|
||||
assert!(out.contains("(none registered)"));
|
||||
assert!(out.contains("(no kei-ledger DB found)"));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue