Compare commits
1 Commits
burn/1144-
...
nexusburn/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4488847c13 |
2
app.js
2
app.js
@@ -9,6 +9,7 @@ import { MemoryBirth } from './nexus/components/memory-birth.js';
|
||||
import { MemoryOptimizer } from './nexus/components/memory-optimizer.js';
|
||||
import { MemoryInspect } from './nexus/components/memory-inspect.js';
|
||||
import { MemoryPulse } from './nexus/components/memory-pulse.js';
|
||||
import { ReasoningTrace } from './nexus/components/reasoning-trace.js';
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// NEXUS v1.1 — Portal System Update
|
||||
@@ -758,6 +759,7 @@ async function init() {
|
||||
SpatialAudio.bindSpatialMemory(SpatialMemory);
|
||||
MemoryInspect.init({ onNavigate: _navigateToMemory });
|
||||
MemoryPulse.init(SpatialMemory);
|
||||
ReasoningTrace.init();
|
||||
updateLoad(90);
|
||||
|
||||
loadSession();
|
||||
|
||||
@@ -1,463 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Fleet Audit — Deduplicate Agents, One Identity Per Machine.
|
||||
|
||||
Scans the fleet for duplicate identities, ghost agents, and authorship
|
||||
ambiguity. Produces a machine-readable audit report and remediation plan.
|
||||
|
||||
Usage:
|
||||
python3 bin/fleet_audit.py # full audit
|
||||
python3 bin/fleet_audit.py --identity-check # identity registry only
|
||||
python3 bin/fleet_audit.py --git-authors # git authorship audit
|
||||
python3 bin/fleet_audit.py --gitea-members # Gitea org member audit
|
||||
python3 bin/fleet_audit.py --report fleet/audit-report.json # output path
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from collections import Counter, defaultdict
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import yaml
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data model
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class AgentIdentity:
|
||||
"""One identity per machine — enforced by the registry."""
|
||||
name: str
|
||||
machine: str # hostname or IP
|
||||
role: str
|
||||
gitea_user: Optional[str] = None
|
||||
active: bool = True
|
||||
lane: Optional[str] = None
|
||||
created: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuditFinding:
|
||||
severity: str # critical, warning, info
|
||||
category: str # duplicate, ghost, orphan, authorship
|
||||
description: str
|
||||
affected: list = field(default_factory=list)
|
||||
remediation: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuditReport:
|
||||
timestamp: str
|
||||
findings: list = field(default_factory=list)
|
||||
registry_valid: bool = True
|
||||
duplicate_count: int = 0
|
||||
ghost_count: int = 0
|
||||
total_agents: int = 0
|
||||
summary: str = ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Identity registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DEFAULT_REGISTRY_PATH = Path(__file__).resolve().parent.parent / "fleet" / "identity-registry.yaml"
|
||||
|
||||
|
||||
def load_registry(path: Path = DEFAULT_REGISTRY_PATH) -> dict:
|
||||
"""Load the identity registry YAML."""
|
||||
if not path.exists():
|
||||
return {"version": 1, "agents": [], "rules": {}}
|
||||
with open(path) as f:
|
||||
return yaml.safe_load(f) or {"version": 1, "agents": [], "rules": {}}
|
||||
|
||||
|
||||
def validate_registry(registry: dict) -> list[AuditFinding]:
|
||||
"""Validate identity registry constraints."""
|
||||
findings = []
|
||||
agents = registry.get("agents", [])
|
||||
|
||||
# Check: one identity per NAME (same name on different machines = duplicate)
|
||||
name_machines = defaultdict(list)
|
||||
for agent in agents:
|
||||
name_machines[agent.get("name", "unknown")].append(agent.get("machine", "unknown"))
|
||||
|
||||
for name, machines in name_machines.items():
|
||||
known = [m for m in machines if m != "unknown"]
|
||||
if len(known) > 1:
|
||||
findings.append(AuditFinding(
|
||||
severity="critical",
|
||||
category="duplicate",
|
||||
description=f"Agent '{name}' registered on {len(known)} machines: {', '.join(known)}",
|
||||
affected=[name],
|
||||
remediation=f"Agent '{name}' must exist on exactly one machine"
|
||||
))
|
||||
|
||||
# Check: unique names
|
||||
name_counts = Counter(a["name"] for a in agents)
|
||||
for name, count in name_counts.items():
|
||||
if count > 1:
|
||||
findings.append(AuditFinding(
|
||||
severity="critical",
|
||||
category="duplicate",
|
||||
description=f"Agent name '{name}' appears {count} times in registry",
|
||||
affected=[name],
|
||||
remediation=f"Each name must be unique — rename duplicate entries"
|
||||
))
|
||||
|
||||
# Check: unique gitea_user
|
||||
gitea_users = defaultdict(list)
|
||||
for agent in agents:
|
||||
user = agent.get("gitea_user")
|
||||
if user:
|
||||
gitea_users[user].append(agent["name"])
|
||||
for user, names in gitea_users.items():
|
||||
if len(names) > 1:
|
||||
findings.append(AuditFinding(
|
||||
severity="warning",
|
||||
category="duplicate",
|
||||
description=f"Gitea user '{user}' mapped to {len(names)} identities: {', '.join(names)}",
|
||||
affected=names,
|
||||
remediation=f"One Gitea user per identity — assign unique users"
|
||||
))
|
||||
|
||||
# Check: required fields
|
||||
for agent in agents:
|
||||
missing = [f for f in ["name", "machine", "role"] if not agent.get(f)]
|
||||
if missing:
|
||||
findings.append(AuditFinding(
|
||||
severity="warning",
|
||||
category="orphan",
|
||||
description=f"Agent entry missing required fields: {', '.join(missing)}",
|
||||
affected=[agent.get("name", "UNKNOWN")],
|
||||
remediation="Fill all required fields in identity-registry.yaml"
|
||||
))
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Git authorship audit
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def audit_git_authors(repo_path: Path = None, days: int = 30) -> list[AuditFinding]:
|
||||
"""Check git log for authorship patterns — detect ambiguous or duplicate committers."""
|
||||
if repo_path is None:
|
||||
repo_path = Path(__file__).resolve().parent.parent
|
||||
|
||||
findings = []
|
||||
|
||||
# Get recent commits
|
||||
result = subprocess.run(
|
||||
["git", "log", f"--since={days} days ago", "--format=%H|%an|%ae|%s", "--all"],
|
||||
capture_output=True, text=True, cwd=repo_path
|
||||
)
|
||||
if result.returncode != 0:
|
||||
findings.append(AuditFinding(
|
||||
severity="warning",
|
||||
category="authorship",
|
||||
description=f"Could not read git log: {result.stderr.strip()}"
|
||||
))
|
||||
return findings
|
||||
|
||||
commits = []
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split("|", 3)
|
||||
if len(parts) == 4:
|
||||
commits.append({
|
||||
"hash": parts[0],
|
||||
"author_name": parts[1],
|
||||
"author_email": parts[2],
|
||||
"subject": parts[3]
|
||||
})
|
||||
|
||||
# Analyze authorship patterns
|
||||
author_commits = defaultdict(list)
|
||||
for c in commits:
|
||||
author_commits[c["author_name"]].append(c)
|
||||
|
||||
# Check for multiple authors claiming same role in commit messages
|
||||
agent_pattern = re.compile(r'\[(\w+)\]|\b(\w+)\s+agent\b', re.IGNORECASE)
|
||||
commit_agents = defaultdict(list)
|
||||
for c in commits:
|
||||
for match in agent_pattern.finditer(c["subject"]):
|
||||
agent = match.group(1) or match.group(2)
|
||||
commit_agents[agent.lower()].append(c["author_name"])
|
||||
|
||||
for agent, authors in commit_agents.items():
|
||||
unique_authors = set(authors)
|
||||
if len(unique_authors) > 1:
|
||||
findings.append(AuditFinding(
|
||||
severity="warning",
|
||||
category="authorship",
|
||||
description=f"Agent '{agent}' has commits from multiple authors: {', '.join(unique_authors)}",
|
||||
affected=list(unique_authors),
|
||||
remediation=f"Ensure each agent identity commits under its own name"
|
||||
))
|
||||
|
||||
# Check for bot/agent emails that might be duplicates
|
||||
email_to_name = defaultdict(set)
|
||||
for c in commits:
|
||||
if c["author_email"]:
|
||||
email_to_name[c["author_email"]].add(c["author_name"])
|
||||
|
||||
for email, names in email_to_name.items():
|
||||
if len(names) > 1:
|
||||
findings.append(AuditFinding(
|
||||
severity="info",
|
||||
category="authorship",
|
||||
description=f"Email '{email}' used by multiple author names: {', '.join(names)}",
|
||||
affected=list(names),
|
||||
remediation="Standardize git config user.name for this email"
|
||||
))
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gitea org member audit
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def audit_gitea_members(token: str = None) -> list[AuditFinding]:
|
||||
"""Audit Gitea org members for ghost/duplicate accounts."""
|
||||
findings = []
|
||||
|
||||
if not token:
|
||||
token_path = Path.home() / ".config" / "gitea" / "token"
|
||||
if token_path.exists():
|
||||
token = token_path.read_text().strip()
|
||||
else:
|
||||
findings.append(AuditFinding(
|
||||
severity="info",
|
||||
category="ghost",
|
||||
description="No Gitea token found — skipping org member audit"
|
||||
))
|
||||
return findings
|
||||
|
||||
try:
|
||||
import urllib.request
|
||||
req = urllib.request.Request(
|
||||
"https://forge.alexanderwhitestone.com/api/v1/orgs/Timmy_Foundation/members?limit=100",
|
||||
headers={"Authorization": f"token {token}"}
|
||||
)
|
||||
resp = urllib.request.urlopen(req)
|
||||
members = json.loads(resp.read())
|
||||
except Exception as e:
|
||||
findings.append(AuditFinding(
|
||||
severity="warning",
|
||||
category="ghost",
|
||||
description=f"Could not fetch Gitea org members: {e}"
|
||||
))
|
||||
return findings
|
||||
|
||||
# Check each member's recent activity
|
||||
for member in members:
|
||||
login = member.get("login", "unknown")
|
||||
try:
|
||||
# Check recent issues
|
||||
req2 = urllib.request.Request(
|
||||
f"https://forge.alexanderwhitestone.com/api/v1/repos/Timmy_Foundation/the-nexus/issues"
|
||||
f"?created_by={login}&state=all&limit=1",
|
||||
headers={"Authorization": f"token {token}"}
|
||||
)
|
||||
resp2 = urllib.request.urlopen(req2)
|
||||
issues = json.loads(resp2.read())
|
||||
|
||||
# Check recent PRs
|
||||
req3 = urllib.request.Request(
|
||||
f"https://forge.alexanderwhitestone.com/api/v1/repos/Timmy_Foundation/the-nexus/pulls"
|
||||
f"?state=all&limit=50",
|
||||
headers={"Authorization": f"token {token}"}
|
||||
)
|
||||
resp3 = urllib.request.urlopen(req3)
|
||||
prs = json.loads(resp3.read())
|
||||
user_prs = [p for p in prs if p.get("user", {}).get("login") == login]
|
||||
|
||||
if not issues and not user_prs:
|
||||
findings.append(AuditFinding(
|
||||
severity="info",
|
||||
category="ghost",
|
||||
description=f"Gitea member '{login}' has no issues or PRs in the-nexus",
|
||||
affected=[login],
|
||||
remediation="Consider removing from org if truly unused"
|
||||
))
|
||||
except Exception:
|
||||
pass # Individual member check failed, skip
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fleet inventory from fleet-routing.json
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def load_fleet_inventory(repo_path: Path = None) -> list[dict]:
|
||||
"""Load agents from fleet-routing.json."""
|
||||
if repo_path is None:
|
||||
repo_path = Path(__file__).resolve().parent.parent
|
||||
|
||||
routing_path = repo_path / "fleet" / "fleet-routing.json"
|
||||
if not routing_path.exists():
|
||||
return []
|
||||
|
||||
with open(routing_path) as f:
|
||||
data = json.load(f)
|
||||
|
||||
return data.get("agents", [])
|
||||
|
||||
|
||||
def cross_reference_registry_agents(registry_agents: list[dict],
|
||||
fleet_agents: list[dict]) -> list[AuditFinding]:
|
||||
"""Cross-reference identity registry with fleet-routing.json."""
|
||||
findings = []
|
||||
|
||||
registry_names = {a["name"].lower() for a in registry_agents}
|
||||
fleet_names = {a["name"].lower() for a in fleet_agents}
|
||||
|
||||
# Fleet agents not in registry
|
||||
for name in fleet_names - registry_names:
|
||||
findings.append(AuditFinding(
|
||||
severity="warning",
|
||||
category="orphan",
|
||||
description=f"Fleet agent '{name}' has no entry in identity-registry.yaml",
|
||||
affected=[name],
|
||||
remediation="Add to identity-registry.yaml or remove from fleet-routing.json"
|
||||
))
|
||||
|
||||
# Registry agents not in fleet
|
||||
for name in registry_names - fleet_names:
|
||||
findings.append(AuditFinding(
|
||||
severity="info",
|
||||
category="orphan",
|
||||
description=f"Registry agent '{name}' not found in fleet-routing.json",
|
||||
affected=[name],
|
||||
remediation="Add to fleet-routing.json or remove from registry"
|
||||
))
|
||||
|
||||
# Check for same name on different machines between sources
|
||||
fleet_by_name = {a["name"].lower(): a for a in fleet_agents}
|
||||
reg_by_name = {a["name"].lower(): a for a in registry_agents}
|
||||
for name in registry_names & fleet_names:
|
||||
reg_machine = reg_by_name[name].get("machine", "")
|
||||
fleet_location = fleet_by_name[name].get("location", "")
|
||||
if reg_machine and fleet_location and reg_machine.lower() not in fleet_location.lower():
|
||||
findings.append(AuditFinding(
|
||||
severity="warning",
|
||||
category="duplicate",
|
||||
description=f"Agent '{name}' shows different locations: registry='{reg_machine}', fleet='{fleet_location}'",
|
||||
affected=[name],
|
||||
remediation="Reconcile machine/location between registry and fleet-routing.json"
|
||||
))
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Full audit pipeline
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def run_full_audit(repo_path: Path = None, token: str = None,
|
||||
gitea: bool = True) -> AuditReport:
|
||||
"""Run the complete fleet audit pipeline."""
|
||||
if repo_path is None:
|
||||
repo_path = Path(__file__).resolve().parent.parent
|
||||
|
||||
findings = []
|
||||
report = AuditReport(timestamp=datetime.now(timezone.utc).isoformat())
|
||||
|
||||
# 1. Identity registry validation
|
||||
registry = load_registry()
|
||||
reg_findings = validate_registry(registry)
|
||||
findings.extend(reg_findings)
|
||||
|
||||
# 2. Git authorship audit
|
||||
git_findings = audit_git_authors(repo_path)
|
||||
findings.extend(git_findings)
|
||||
|
||||
# 3. Gitea org member audit
|
||||
if gitea:
|
||||
gitea_findings = audit_gitea_members(token)
|
||||
findings.extend(gitea_findings)
|
||||
|
||||
# 4. Cross-reference registry vs fleet-routing.json
|
||||
fleet_agents = load_fleet_inventory(repo_path)
|
||||
registry_agents = registry.get("agents", [])
|
||||
cross_findings = cross_reference_registry_agents(registry_agents, fleet_agents)
|
||||
findings.extend(cross_findings)
|
||||
|
||||
# Compile report
|
||||
report.findings = [asdict(f) for f in findings]
|
||||
report.registry_valid = not any(f.severity == "critical" for f in reg_findings)
|
||||
report.duplicate_count = sum(1 for f in findings if f.category == "duplicate")
|
||||
report.ghost_count = sum(1 for f in findings if f.category == "ghost")
|
||||
report.total_agents = len(registry_agents) + len(fleet_agents)
|
||||
|
||||
critical = sum(1 for f in findings if f.severity == "critical")
|
||||
warnings = sum(1 for f in findings if f.severity == "warning")
|
||||
report.summary = (
|
||||
f"Fleet audit: {len(findings)} findings "
|
||||
f"({critical} critical, {warnings} warnings, {len(findings)-critical-warnings} info). "
|
||||
f"Registry {'VALID' if report.registry_valid else 'INVALID — DUPLICATES FOUND'}. "
|
||||
f"{report.total_agents} agent identities across registry + fleet config."
|
||||
)
|
||||
|
||||
return report
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Fleet Audit — Deduplicate Agents, One Identity Per Machine")
|
||||
parser.add_argument("--report", default=None, help="Output JSON report path")
|
||||
parser.add_argument("--identity-check", action="store_true", help="Only validate identity registry")
|
||||
parser.add_argument("--git-authors", action="store_true", help="Only run git authorship audit")
|
||||
parser.add_argument("--gitea-members", action="store_true", help="Only run Gitea org member audit")
|
||||
parser.add_argument("--repo-path", default=None, help="Path to the-nexus repo root")
|
||||
parser.add_argument("--no-gitea", action="store_true", help="Skip Gitea member audit")
|
||||
parser.add_argument("--token", default=None, help="Gitea API token (or read from ~/.config/gitea/token)")
|
||||
|
||||
args = parser.parse_args()
|
||||
repo_path = Path(args.repo_path) if args.repo_path else Path(__file__).resolve().parent.parent
|
||||
|
||||
if args.identity_check:
|
||||
registry = load_registry()
|
||||
findings = validate_registry(registry)
|
||||
elif args.git_authors:
|
||||
findings = audit_git_authors(repo_path)
|
||||
elif args.gitea_members:
|
||||
findings = audit_gitea_members(args.token)
|
||||
else:
|
||||
report = run_full_audit(repo_path, args.token, gitea=not args.no_gitea)
|
||||
output = asdict(report)
|
||||
|
||||
if args.report:
|
||||
report_path = Path(args.report)
|
||||
report_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(report_path, "w") as f:
|
||||
json.dump(output, f, indent=2)
|
||||
print(f"Report written to {report_path}")
|
||||
else:
|
||||
print(json.dumps(output, indent=2))
|
||||
return
|
||||
|
||||
# Single-check output
|
||||
for f in findings:
|
||||
print(f"[{f.severity.upper()}] {f.category}: {f.description}")
|
||||
if f.remediation:
|
||||
print(f" -> {f.remediation}")
|
||||
print(f"\n{len(findings)} findings.")
|
||||
sys.exit(1 if any(f.severity == "critical" for f in findings) else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,121 +0,0 @@
|
||||
version: 1
|
||||
rules:
|
||||
one_identity_per_machine: true
|
||||
unique_gitea_user: true
|
||||
required_fields:
|
||||
- name
|
||||
- machine
|
||||
- role
|
||||
agents:
|
||||
- name: timmy
|
||||
machine: local-mac
|
||||
role: father-house
|
||||
gitea_user: timmy
|
||||
active: true
|
||||
lane: orchestration
|
||||
notes: The father. Runs on Alexander's Mac. Hermes default profile.
|
||||
- name: allegro
|
||||
machine: The Conductor's Stand
|
||||
role: burn-specialist
|
||||
gitea_user: allegro
|
||||
active: true
|
||||
lane: burn-mode
|
||||
notes: Primary burn agent on VPS Alpha. Fast execution.
|
||||
- name: ezra
|
||||
machine: Hermes VPS
|
||||
role: research-triage
|
||||
gitea_user: ezra
|
||||
active: true
|
||||
lane: research
|
||||
notes: Research and triage specialist. VPS Ezra.
|
||||
- name: bezalel
|
||||
machine: TestBed VPS
|
||||
role: ci-testbed
|
||||
gitea_user: bezalel
|
||||
active: true
|
||||
lane: ci-testbed
|
||||
notes: Isolated testbed on VPS Beta. Build verification and security audits.
|
||||
- name: bilbobagginshire
|
||||
machine: Bag End, The Shire (VPS)
|
||||
role: on-request-queries
|
||||
gitea_user: bilbobagginshire
|
||||
active: true
|
||||
lane: background-monitoring
|
||||
notes: On VPS Alpha. Ollama-backed. Low-priority Q&A only.
|
||||
- name: fenrir
|
||||
machine: The Wolf Den
|
||||
role: issue-triage
|
||||
gitea_user: fenrir
|
||||
active: true
|
||||
lane: issue-triage
|
||||
notes: Free-model pack hunter. Backlog triage.
|
||||
- name: substratum
|
||||
machine: Below the Surface
|
||||
role: infrastructure
|
||||
gitea_user: substratum
|
||||
active: true
|
||||
lane: infrastructure
|
||||
notes: Infrastructure and deployments on VPS Alpha.
|
||||
- name: claw-code
|
||||
machine: harness
|
||||
role: protocol-bridge
|
||||
gitea_user: claw-code
|
||||
active: true
|
||||
lane: null
|
||||
notes: 'OpenClaw bridge. Protocol adapter, not an endpoint. See #836.'
|
||||
- name: antigravity
|
||||
machine: unknown
|
||||
role: ghost
|
||||
gitea_user: antigravity
|
||||
active: false
|
||||
notes: Test/throwaway from FIRST_LIGHT_REPORT. Zero activity.
|
||||
- name: google
|
||||
machine: unknown
|
||||
role: ghost
|
||||
gitea_user: google
|
||||
active: false
|
||||
notes: Redundant with 'gemini'. Use gemini for all Google/Gemini work.
|
||||
- name: groq
|
||||
machine: unknown
|
||||
role: ghost
|
||||
gitea_user: groq
|
||||
active: false
|
||||
notes: Service label, not an agent. groq_worker.py is infrastructure.
|
||||
- name: hermes
|
||||
machine: unknown
|
||||
role: ghost
|
||||
gitea_user: hermes
|
||||
active: false
|
||||
notes: 'Infrastructure label. Real wizards: allegro, ezra.'
|
||||
- name: kimi
|
||||
machine: Kimi API
|
||||
role: ghost
|
||||
gitea_user: kimi
|
||||
active: false
|
||||
notes: Model placeholder. KimiClaw is the real account if active.
|
||||
- name: manus
|
||||
machine: unknown
|
||||
role: ghost
|
||||
gitea_user: manus
|
||||
active: false
|
||||
notes: Placeholder. No harness configured.
|
||||
- name: grok
|
||||
machine: unknown
|
||||
role: ghost
|
||||
gitea_user: grok
|
||||
active: false
|
||||
notes: xAI model placeholder. No active harness.
|
||||
- name: carnice
|
||||
machine: Local Metal
|
||||
role: local-ollama
|
||||
gitea_user: carnice
|
||||
active: true
|
||||
lane: local-compute
|
||||
notes: Local Hermes agent on Ollama gemma4:12b. Code generation.
|
||||
- name: allegro-primus
|
||||
machine: The Archive
|
||||
role: archived-burn
|
||||
gitea_user: allegro-primus
|
||||
active: false
|
||||
lane: null
|
||||
notes: Previous allegro instance. Deprecated in favor of current allegro.
|
||||
13
index.html
13
index.html
@@ -101,6 +101,19 @@
|
||||
<div class="panel-header">ADAPTIVE CALIBRATOR</div>
|
||||
<div id="calibrator-log-content" class="panel-content"></div>
|
||||
</div>
|
||||
<div class="hud-panel" id="reasoning-trace">
|
||||
<div class="trace-header-container">
|
||||
<div class="panel-header"><span class="trace-icon">🧠</span> REASONING TRACE</div>
|
||||
<div class="trace-controls">
|
||||
<button class="trace-btn" id="trace-clear" title="Clear trace">🗑️</button>
|
||||
<button class="trace-btn" id="trace-toggle" title="Toggle visibility">👁️</button>
|
||||
<button class="trace-btn" id="trace-export" title="Export trace">📤</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="trace-task" id="trace-task">No active task</div>
|
||||
<div class="trace-counter" id="trace-counter">0 steps</div>
|
||||
<div id="reasoning-trace-content" class="panel-content trace-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Evennia Room Snapshot Panel -->
|
||||
|
||||
451
nexus/components/reasoning-trace.js
Normal file
451
nexus/components/reasoning-trace.js
Normal file
@@ -0,0 +1,451 @@
|
||||
// ═══════════════════════════════════════════════════
|
||||
// REASONING TRACE HUD COMPONENT
|
||||
// ═══════════════════════════════════════════════════
|
||||
//
|
||||
// Displays a real-time trace of the agent's reasoning
|
||||
// steps during complex task execution. Shows the chain
|
||||
// of thought, decision points, and confidence levels.
|
||||
//
|
||||
// Usage:
|
||||
// ReasoningTrace.init();
|
||||
// ReasoningTrace.addStep(step);
|
||||
// ReasoningTrace.clear();
|
||||
// ReasoningTrace.toggle();
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
const ReasoningTrace = (() => {
|
||||
// ── State ─────────────────────────────────────────
|
||||
let _container = null;
|
||||
let _content = null;
|
||||
let _header = null;
|
||||
let _steps = [];
|
||||
let _maxSteps = 20;
|
||||
let _isVisible = true;
|
||||
let _currentTask = null;
|
||||
let _stepCounter = 0;
|
||||
|
||||
// ── Config ────────────────────────────────────────
|
||||
const STEP_TYPES = {
|
||||
THINK: { icon: '💭', color: '#4af0c0', label: 'THINK' },
|
||||
DECIDE: { icon: '⚖️', color: '#ffd700', label: 'DECIDE' },
|
||||
RECALL: { icon: '🔍', color: '#7b5cff', label: 'RECALL' },
|
||||
PLAN: { icon: '📋', color: '#ff8c42', label: 'PLAN' },
|
||||
EXECUTE: { icon: '⚡', color: '#ff4466', label: 'EXECUTE' },
|
||||
VERIFY: { icon: '✅', color: '#4af0c0', label: 'VERIFY' },
|
||||
DOUBT: { icon: '❓', color: '#ff8c42', label: 'DOUBT' },
|
||||
MEMORY: { icon: '💾', color: '#7b5cff', label: 'MEMORY' }
|
||||
};
|
||||
|
||||
// ── Helpers ───────────────────────────────────────
|
||||
|
||||
function _escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function _formatTimestamp(timestamp) {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function _getConfidenceBar(confidence) {
|
||||
if (confidence === undefined || confidence === null) return '';
|
||||
const percent = Math.max(0, Math.min(100, Math.round(confidence * 100)));
|
||||
const bars = Math.round(percent / 10);
|
||||
const filled = '█'.repeat(bars);
|
||||
const empty = '░'.repeat(10 - bars);
|
||||
return `<span class="confidence-bar" title="${percent}% confidence">${filled}${empty}</span>`;
|
||||
}
|
||||
|
||||
// ── DOM Setup ─────────────────────────────────────
|
||||
|
||||
function _createDOM() {
|
||||
// Create container if it doesn't exist
|
||||
if (_container) return;
|
||||
|
||||
_container = document.createElement('div');
|
||||
_container.id = 'reasoning-trace';
|
||||
_container.className = 'hud-panel reasoning-trace';
|
||||
|
||||
_header = document.createElement('div');
|
||||
_header.className = 'panel-header';
|
||||
_header.innerHTML = `<span class="trace-icon">🧠</span> REASONING TRACE`;
|
||||
|
||||
// Task indicator
|
||||
const taskIndicator = document.createElement('div');
|
||||
taskIndicator.className = 'trace-task';
|
||||
taskIndicator.id = 'trace-task';
|
||||
taskIndicator.textContent = 'No active task';
|
||||
|
||||
// Step counter
|
||||
const stepCounter = document.createElement('div');
|
||||
stepCounter.className = 'trace-counter';
|
||||
stepCounter.id = 'trace-counter';
|
||||
stepCounter.textContent = '0 steps';
|
||||
|
||||
// Controls
|
||||
const controls = document.createElement('div');
|
||||
controls.className = 'trace-controls';
|
||||
controls.innerHTML = `
|
||||
<button class="trace-btn" id="trace-clear" title="Clear trace">🗑️</button>
|
||||
<button class="trace-btn" id="trace-toggle" title="Toggle visibility">👁️</button>
|
||||
<button class="trace-btn" id="trace-export" title="Export trace">📤</button>
|
||||
`;
|
||||
|
||||
// Header container
|
||||
const headerContainer = document.createElement('div');
|
||||
headerContainer.className = 'trace-header-container';
|
||||
headerContainer.appendChild(_header);
|
||||
headerContainer.appendChild(controls);
|
||||
|
||||
// Content area
|
||||
_content = document.createElement('div');
|
||||
_content.className = 'panel-content trace-content';
|
||||
_content.id = 'reasoning-trace-content';
|
||||
|
||||
// Assemble
|
||||
_container.appendChild(headerContainer);
|
||||
_container.appendChild(taskIndicator);
|
||||
_container.appendChild(stepCounter);
|
||||
_container.appendChild(_content);
|
||||
|
||||
// Add to HUD
|
||||
const hud = document.getElementById('hud');
|
||||
if (hud) {
|
||||
const gofaiHud = hud.querySelector('.gofai-hud');
|
||||
if (gofaiHud) {
|
||||
gofaiHud.appendChild(_container);
|
||||
} else {
|
||||
hud.appendChild(_container);
|
||||
}
|
||||
}
|
||||
|
||||
// Add event listeners
|
||||
document.getElementById('trace-clear')?.addEventListener('click', clear);
|
||||
document.getElementById('trace-toggle')?.addEventListener('click', toggle);
|
||||
document.getElementById('trace-export')?.addEventListener('click', exportTrace);
|
||||
}
|
||||
|
||||
// ── Rendering ─────────────────────────────────────
|
||||
|
||||
function _renderStep(step, index) {
|
||||
const typeConfig = STEP_TYPES[step.type] || STEP_TYPES.THINK;
|
||||
const timestamp = _formatTimestamp(step.timestamp);
|
||||
const confidence = _getConfidenceBar(step.confidence);
|
||||
|
||||
const stepEl = document.createElement('div');
|
||||
stepEl.className = `trace-step trace-step-${step.type.toLowerCase()}`;
|
||||
stepEl.dataset.stepId = step.id;
|
||||
|
||||
// Step header
|
||||
const header = document.createElement('div');
|
||||
header.className = 'trace-step-header';
|
||||
header.innerHTML = `
|
||||
<span class="step-icon">${typeConfig.icon}</span>
|
||||
<span class="step-type" style="color: ${typeConfig.color}">${typeConfig.label}</span>
|
||||
<span class="step-time">${timestamp}</span>
|
||||
${confidence}
|
||||
`;
|
||||
|
||||
// Step content
|
||||
const content = document.createElement('div');
|
||||
content.className = 'trace-step-content';
|
||||
|
||||
if (step.thought) {
|
||||
const thought = document.createElement('div');
|
||||
thought.className = 'step-thought';
|
||||
thought.textContent = step.thought;
|
||||
content.appendChild(thought);
|
||||
}
|
||||
|
||||
if (step.reasoning) {
|
||||
const reasoning = document.createElement('div');
|
||||
reasoning.className = 'step-reasoning';
|
||||
reasoning.textContent = step.reasoning;
|
||||
content.appendChild(reasoning);
|
||||
}
|
||||
|
||||
if (step.decision) {
|
||||
const decision = document.createElement('div');
|
||||
decision.className = 'step-decision';
|
||||
decision.innerHTML = `<strong>Decision:</strong> ${_escapeHtml(step.decision)}`;
|
||||
content.appendChild(decision);
|
||||
}
|
||||
|
||||
if (step.alternatives && step.alternatives.length > 0) {
|
||||
const alternatives = document.createElement('div');
|
||||
alternatives.className = 'step-alternatives';
|
||||
alternatives.innerHTML = `<strong>Alternatives:</strong> ${step.alternatives.map(a => _escapeHtml(a)).join(', ')}`;
|
||||
content.appendChild(alternatives);
|
||||
}
|
||||
|
||||
if (step.source) {
|
||||
const source = document.createElement('div');
|
||||
source.className = 'step-source';
|
||||
source.innerHTML = `<strong>Source:</strong> ${_escapeHtml(step.source)}`;
|
||||
content.appendChild(source);
|
||||
}
|
||||
|
||||
stepEl.appendChild(header);
|
||||
stepEl.appendChild(content);
|
||||
|
||||
return stepEl;
|
||||
}
|
||||
|
||||
function _render() {
|
||||
if (!_content) return;
|
||||
|
||||
// Clear content
|
||||
_content.innerHTML = '';
|
||||
|
||||
// Update task indicator
|
||||
const taskEl = document.getElementById('trace-task');
|
||||
if (taskEl) {
|
||||
taskEl.textContent = _currentTask || 'No active task';
|
||||
taskEl.className = _currentTask ? 'trace-task active' : 'trace-task';
|
||||
}
|
||||
|
||||
// Update step counter
|
||||
const counterEl = document.getElementById('trace-counter');
|
||||
if (counterEl) {
|
||||
counterEl.textContent = `${_steps.length} step${_steps.length !== 1 ? 's' : ''}`;
|
||||
}
|
||||
|
||||
// Render steps (newest first)
|
||||
const sortedSteps = [..._steps].sort((a, b) => b.timestamp - a.timestamp);
|
||||
|
||||
for (let i = 0; i < sortedSteps.length; i++) {
|
||||
const stepEl = _renderStep(sortedSteps[i], i);
|
||||
_content.appendChild(stepEl);
|
||||
|
||||
// Add separator between steps
|
||||
if (i < sortedSteps.length - 1) {
|
||||
const separator = document.createElement('div');
|
||||
separator.className = 'trace-separator';
|
||||
_content.appendChild(separator);
|
||||
}
|
||||
}
|
||||
|
||||
// Show empty state if no steps
|
||||
if (_steps.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'trace-empty';
|
||||
empty.innerHTML = `
|
||||
<span class="empty-icon">💭</span>
|
||||
<span class="empty-text">No reasoning steps yet</span>
|
||||
<span class="empty-hint">Start a task to see the trace</span>
|
||||
`;
|
||||
_content.appendChild(empty);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────
|
||||
|
||||
function init() {
|
||||
_createDOM();
|
||||
_render();
|
||||
console.info('[ReasoningTrace] Initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a reasoning step to the trace.
|
||||
* @param {Object} step - The reasoning step
|
||||
* @param {string} step.type - Step type (THINK, DECIDE, RECALL, PLAN, EXECUTE, VERIFY, DOUBT, MEMORY)
|
||||
* @param {string} step.thought - The main thought/content
|
||||
* @param {string} [step.reasoning] - Detailed reasoning
|
||||
* @param {string} [step.decision] - Decision made
|
||||
* @param {string[]} [step.alternatives] - Alternative options considered
|
||||
* @param {string} [step.source] - Source of information
|
||||
* @param {number} [step.confidence] - Confidence level (0-1)
|
||||
* @param {string} [step.taskId] - Associated task ID
|
||||
*/
|
||||
function addStep(step) {
|
||||
if (!step || !step.thought) return;
|
||||
|
||||
// Generate unique ID
|
||||
const id = `step-${++_stepCounter}-${Date.now()}`;
|
||||
|
||||
// Create step object
|
||||
const newStep = {
|
||||
id,
|
||||
timestamp: Date.now(),
|
||||
type: step.type || 'THINK',
|
||||
thought: step.thought,
|
||||
reasoning: step.reasoning || null,
|
||||
decision: step.decision || null,
|
||||
alternatives: step.alternatives || null,
|
||||
source: step.source || null,
|
||||
confidence: step.confidence !== undefined ? Math.max(0, Math.min(1, step.confidence)) : null,
|
||||
taskId: step.taskId || _currentTask
|
||||
};
|
||||
|
||||
// Add to steps array
|
||||
_steps.unshift(newStep);
|
||||
|
||||
// Limit number of steps
|
||||
if (_steps.length > _maxSteps) {
|
||||
_steps = _steps.slice(0, _maxSteps);
|
||||
}
|
||||
|
||||
// Update task if provided
|
||||
if (step.taskId && step.taskId !== _currentTask) {
|
||||
setTask(step.taskId);
|
||||
}
|
||||
|
||||
// Re-render
|
||||
_render();
|
||||
|
||||
// Log to console for debugging
|
||||
console.debug(`[ReasoningTrace] ${newStep.type}: ${newStep.thought}`);
|
||||
|
||||
return newStep.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the current task being traced.
|
||||
* @param {string} taskId - Task identifier
|
||||
*/
|
||||
function setTask(taskId) {
|
||||
_currentTask = taskId;
|
||||
_render();
|
||||
console.info(`[ReasoningTrace] Task set: ${taskId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all steps from the trace.
|
||||
*/
|
||||
function clear() {
|
||||
_steps = [];
|
||||
_stepCounter = 0;
|
||||
_render();
|
||||
console.info('[ReasoningTrace] Cleared');
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the visibility of the trace panel.
|
||||
*/
|
||||
function toggle() {
|
||||
_isVisible = !_isVisible;
|
||||
if (_container) {
|
||||
_container.style.display = _isVisible ? 'block' : 'none';
|
||||
}
|
||||
console.info(`[ReasoningTrace] Visibility: ${_isVisible ? 'shown' : 'hidden'}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export the trace as JSON.
|
||||
* @returns {string} JSON string of the trace
|
||||
*/
|
||||
function exportTrace() {
|
||||
const exportData = {
|
||||
task: _currentTask,
|
||||
exportedAt: new Date().toISOString(),
|
||||
steps: _steps.map(step => ({
|
||||
type: step.type,
|
||||
thought: step.thought,
|
||||
reasoning: step.reasoning,
|
||||
decision: step.decision,
|
||||
alternatives: step.alternatives,
|
||||
source: step.source,
|
||||
confidence: step.confidence,
|
||||
timestamp: new Date(step.timestamp).toISOString()
|
||||
}))
|
||||
};
|
||||
|
||||
const json = JSON.stringify(exportData, null, 2);
|
||||
|
||||
// Copy to clipboard
|
||||
navigator.clipboard.writeText(json).then(() => {
|
||||
console.info('[ReasoningTrace] Copied to clipboard');
|
||||
// Show feedback
|
||||
const btn = document.getElementById('trace-export');
|
||||
if (btn) {
|
||||
const original = btn.innerHTML;
|
||||
btn.innerHTML = '✅';
|
||||
setTimeout(() => { btn.innerHTML = original; }, 1000);
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error('[ReasoningTrace] Failed to copy:', err);
|
||||
});
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current trace data.
|
||||
* @returns {Object} Current trace state
|
||||
*/
|
||||
function getTrace() {
|
||||
return {
|
||||
task: _currentTask,
|
||||
steps: [..._steps],
|
||||
stepCount: _steps.length,
|
||||
isVisible: _isVisible
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get steps filtered by type.
|
||||
* @param {string} type - Step type to filter by
|
||||
* @returns {Array} Filtered steps
|
||||
*/
|
||||
function getStepsByType(type) {
|
||||
return _steps.filter(step => step.type === type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get steps for a specific task.
|
||||
* @param {string} taskId - Task ID to filter by
|
||||
* @returns {Array} Filtered steps
|
||||
*/
|
||||
function getStepsByTask(taskId) {
|
||||
return _steps.filter(step => step.taskId === taskId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the current task as complete.
|
||||
* @param {string} [result] - Optional result description
|
||||
*/
|
||||
function completeTask(result) {
|
||||
if (_currentTask) {
|
||||
addStep({
|
||||
type: 'VERIFY',
|
||||
thought: `Task completed: ${result || 'Success'}`,
|
||||
taskId: _currentTask
|
||||
});
|
||||
|
||||
// Clear current task after a delay
|
||||
setTimeout(() => {
|
||||
_currentTask = null;
|
||||
_render();
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Return Public API ─────────────────────────────
|
||||
|
||||
return {
|
||||
init,
|
||||
addStep,
|
||||
setTask,
|
||||
clear,
|
||||
toggle,
|
||||
exportTrace,
|
||||
getTrace,
|
||||
getStepsByType,
|
||||
getStepsByTask,
|
||||
completeTask,
|
||||
STEP_TYPES
|
||||
};
|
||||
})();
|
||||
|
||||
export { ReasoningTrace };
|
||||
249
style.css
249
style.css
@@ -2685,3 +2685,252 @@ body.operator-mode #mode-label {
|
||||
color: #ffd700;
|
||||
}
|
||||
|
||||
/* ═══ REASONING TRACE COMPONENT ═══ */
|
||||
|
||||
.reasoning-trace {
|
||||
width: 320px;
|
||||
max-height: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.trace-header-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.trace-header-container .panel-header {
|
||||
margin-bottom: 0;
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.trace-icon {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.trace-controls {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.trace-btn {
|
||||
background: rgba(74, 240, 192, 0.1);
|
||||
border: 1px solid rgba(74, 240, 192, 0.2);
|
||||
color: #4af0c0;
|
||||
padding: 2px 6px;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
border-radius: 2px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.trace-btn:hover {
|
||||
background: rgba(74, 240, 192, 0.2);
|
||||
border-color: #4af0c0;
|
||||
}
|
||||
|
||||
.trace-task {
|
||||
font-size: 9px;
|
||||
color: #8899aa;
|
||||
margin-bottom: 4px;
|
||||
padding: 2px 6px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 2px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.trace-task.active {
|
||||
color: #4af0c0;
|
||||
background: rgba(74, 240, 192, 0.1);
|
||||
border-left: 2px solid #4af0c0;
|
||||
}
|
||||
|
||||
.trace-counter {
|
||||
font-size: 9px;
|
||||
color: #667788;
|
||||
margin-bottom: 6px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.trace-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
.trace-step {
|
||||
margin-bottom: 8px;
|
||||
padding: 6px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 3px;
|
||||
border-left: 3px solid #4af0c0;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.trace-step-think {
|
||||
border-left-color: #4af0c0;
|
||||
}
|
||||
|
||||
.trace-step-decide {
|
||||
border-left-color: #ffd700;
|
||||
}
|
||||
|
||||
.trace-step-recall {
|
||||
border-left-color: #7b5cff;
|
||||
}
|
||||
|
||||
.trace-step-plan {
|
||||
border-left-color: #ff8c42;
|
||||
}
|
||||
|
||||
.trace-step-execute {
|
||||
border-left-color: #ff4466;
|
||||
}
|
||||
|
||||
.trace-step-verify {
|
||||
border-left-color: #4af0c0;
|
||||
}
|
||||
|
||||
.trace-step-doubt {
|
||||
border-left-color: #ff8c42;
|
||||
}
|
||||
|
||||
.trace-step-memory {
|
||||
border-left-color: #7b5cff;
|
||||
}
|
||||
|
||||
.trace-step-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.step-icon {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.step-type {
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.step-time {
|
||||
color: #667788;
|
||||
font-size: 9px;
|
||||
margin-left: auto;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.confidence-bar {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 9px;
|
||||
color: #4af0c0;
|
||||
letter-spacing: -1px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.trace-step-content {
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
color: #d9f7ff;
|
||||
}
|
||||
|
||||
.step-thought {
|
||||
margin-bottom: 4px;
|
||||
font-style: italic;
|
||||
color: #e0f0ff;
|
||||
}
|
||||
|
||||
.step-reasoning {
|
||||
margin-bottom: 4px;
|
||||
color: #aabbcc;
|
||||
font-size: 10px;
|
||||
padding-left: 8px;
|
||||
border-left: 1px solid rgba(74, 240, 192, 0.2);
|
||||
}
|
||||
|
||||
.step-decision {
|
||||
margin-bottom: 4px;
|
||||
color: #ffd700;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.step-alternatives {
|
||||
margin-bottom: 4px;
|
||||
color: #8899aa;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.step-source {
|
||||
margin-bottom: 4px;
|
||||
color: #7b5cff;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.trace-separator {
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(74, 240, 192, 0.2), transparent);
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
.trace-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
color: #667788;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 24px;
|
||||
margin-bottom: 8px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 11px;
|
||||
margin-bottom: 4px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
font-size: 9px;
|
||||
color: #445566;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
/* Animation for new steps */
|
||||
@keyframes trace-step-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.trace-step {
|
||||
animation: trace-step-in 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.reasoning-trace {
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
.trace-content {
|
||||
max-height: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
"""Tests for fleet_audit — Deduplicate Agents, One Identity Per Machine."""
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
# Adjust import path
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "bin"))
|
||||
|
||||
from fleet_audit import (
|
||||
AuditFinding,
|
||||
validate_registry,
|
||||
cross_reference_registry_agents,
|
||||
audit_git_authors,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Identity registry validation tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestValidateRegistry:
|
||||
"""Test identity registry validation rules."""
|
||||
|
||||
def _make_registry(self, agents):
|
||||
return {"version": 1, "agents": agents, "rules": {"one_identity_per_machine": True}}
|
||||
|
||||
def test_clean_registry_passes(self):
|
||||
registry = self._make_registry([
|
||||
{"name": "allegro", "machine": "167.99.126.228", "role": "burn", "gitea_user": "allegro"},
|
||||
{"name": "ezra", "machine": "143.198.27.163", "role": "triage", "gitea_user": "ezra"},
|
||||
])
|
||||
findings = validate_registry(registry)
|
||||
critical = [f for f in findings if f.severity == "critical"]
|
||||
assert len(critical) == 0
|
||||
|
||||
def test_same_name_on_different_machines_detected(self):
|
||||
registry = self._make_registry([
|
||||
{"name": "allegro", "machine": "167.99.126.228", "role": "burn"},
|
||||
{"name": "allegro", "machine": "104.131.15.18", "role": "burn"},
|
||||
])
|
||||
findings = validate_registry(registry)
|
||||
critical = [f for f in findings if f.severity == "critical" and f.category == "duplicate"]
|
||||
# Two findings: one for name-on-multiple-machines, one for duplicate name
|
||||
assert len(critical) >= 1
|
||||
machine_findings = [f for f in critical if "registered on" in f.description]
|
||||
assert len(machine_findings) == 1
|
||||
assert "167.99.126.228" in machine_findings[0].description
|
||||
assert "104.131.15.18" in machine_findings[0].description
|
||||
|
||||
def test_multiple_agents_same_machine_ok(self):
|
||||
# Multiple different agents on the same VPS is normal.
|
||||
registry = self._make_registry([
|
||||
{"name": "allegro", "machine": "167.99.126.228", "role": "burn"},
|
||||
{"name": "bilbo", "machine": "167.99.126.228", "role": "queries"},
|
||||
])
|
||||
findings = validate_registry(registry)
|
||||
critical = [f for f in findings if f.severity == "critical"]
|
||||
assert len(critical) == 0
|
||||
|
||||
def test_duplicate_name_detected(self):
|
||||
registry = self._make_registry([
|
||||
{"name": "bezalel", "machine": "104.131.15.18", "role": "ci"},
|
||||
{"name": "bezalel", "machine": "167.99.126.228", "role": "ci"},
|
||||
])
|
||||
findings = validate_registry(registry)
|
||||
name_dupes = [f for f in findings if f.severity == "critical" and "bezalel" in f.description.lower() and "registered on" in f.description.lower()]
|
||||
assert len(name_dupes) == 1
|
||||
|
||||
def test_duplicate_gitea_user_detected(self):
|
||||
registry = self._make_registry([
|
||||
{"name": "agent-a", "machine": "host1", "role": "x", "gitea_user": "shared"},
|
||||
{"name": "agent-b", "machine": "host2", "role": "x", "gitea_user": "shared"},
|
||||
])
|
||||
findings = validate_registry(registry)
|
||||
gitea_dupes = [f for f in findings if "Gitea user 'shared'" in f.description]
|
||||
assert len(gitea_dupes) == 1
|
||||
assert "agent-a" in gitea_dupes[0].affected
|
||||
assert "agent-b" in gitea_dupes[0].affected
|
||||
|
||||
def test_missing_required_fields(self):
|
||||
registry = self._make_registry([
|
||||
{"name": "incomplete-agent"},
|
||||
])
|
||||
findings = validate_registry(registry)
|
||||
missing = [f for f in findings if f.category == "orphan"]
|
||||
assert len(missing) >= 1
|
||||
assert "machine" in missing[0].description or "role" in missing[0].description
|
||||
|
||||
def test_empty_registry_passes(self):
|
||||
registry = self._make_registry([])
|
||||
findings = validate_registry(registry)
|
||||
assert len(findings) == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cross-reference tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCrossReference:
|
||||
"""Test registry vs fleet-routing.json cross-reference."""
|
||||
|
||||
def test_orphan_in_fleet_not_registry(self):
|
||||
reg_agents = [{"name": "allegro", "machine": "x", "role": "y"}]
|
||||
fleet_agents = [{"name": "allegro", "location": "x"}, {"name": "unknown-agent", "location": "y"}]
|
||||
findings = cross_reference_registry_agents(reg_agents, fleet_agents)
|
||||
orphans = [f for f in findings if f.category == "orphan" and "unknown-agent" in f.description]
|
||||
assert len(orphans) == 1
|
||||
|
||||
def test_location_mismatch_detected(self):
|
||||
reg_agents = [{"name": "allegro", "machine": "167.99.126.228", "role": "y"}]
|
||||
fleet_agents = [{"name": "allegro", "location": "totally-different-host"}]
|
||||
findings = cross_reference_registry_agents(reg_agents, fleet_agents)
|
||||
mismatches = [f for f in findings if f.category == "duplicate" and "different locations" in f.description]
|
||||
assert len(mismatches) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration test against actual registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRealRegistry:
|
||||
"""Test against the actual identity-registry.yaml in the repo."""
|
||||
|
||||
def test_registry_loads(self):
|
||||
reg_path = Path(__file__).resolve().parent.parent / "fleet" / "identity-registry.yaml"
|
||||
if reg_path.exists():
|
||||
with open(reg_path) as f:
|
||||
registry = yaml.safe_load(f)
|
||||
assert registry["version"] == 1
|
||||
assert len(registry["agents"]) > 0
|
||||
|
||||
def test_registry_no_critical_findings(self):
|
||||
reg_path = Path(__file__).resolve().parent.parent / "fleet" / "identity-registry.yaml"
|
||||
if reg_path.exists():
|
||||
with open(reg_path) as f:
|
||||
registry = yaml.safe_load(f)
|
||||
findings = validate_registry(registry)
|
||||
critical = [f for f in findings if f.severity == "critical"]
|
||||
assert len(critical) == 0, f"Critical findings: {[f.description for f in critical]}"
|
||||
Reference in New Issue
Block a user