Compare commits
2 Commits
fix/1623
...
fix/1430-s
| Author | SHA1 | Date | |
|---|---|---|---|
| cc531f960f | |||
| 44bde9509f |
39
.githooks/commit-msg
Normal file
39
.githooks/commit-msg
Normal file
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
# commit-msg hook: sanitize commit messages to prevent shell injection
|
||||
# Issue: #1430 — memory_mine.py ran during git commit due to backtick substitution
|
||||
#
|
||||
# Problem: git commit -m "message with `code`" triggers shell evaluation
|
||||
# of backtick-wrapped content during hook processing.
|
||||
#
|
||||
# Fix: Strip or escape backticks from commit messages before they reach hooks.
|
||||
# Safe pattern: use git commit -F <file> instead of -m for code-containing messages.
|
||||
|
||||
COMMIT_MSG_FILE="$1"
|
||||
MSG=$(cat "$COMMIT_MSG_FILE")
|
||||
|
||||
# Check for unescaped backticks (shell substitution risk)
|
||||
if echo "$MSG" | grep -q '`'; then
|
||||
echo "⚠️ WARNING: Commit message contains backtick characters."
|
||||
echo " Backticks trigger shell substitution during hook processing."
|
||||
echo ""
|
||||
echo " SAFE ALTERNATIVES:"
|
||||
echo " 1. Use single quotes in code examples: 'code here'"
|
||||
echo " 2. Use fenced code blocks with 4-space indent instead of backticks"
|
||||
echo " 3. Write message to file: git commit -F msg.txt"
|
||||
echo ""
|
||||
echo " Sanitizing: converting backticks to single quotes..."
|
||||
|
||||
# Sanitize: replace backticks with single quotes
|
||||
SANITIZED=$(echo "$MSG" | sed "s/`/'/g")
|
||||
echo "$SANITIZED" > "$COMMIT_MSG_FILE"
|
||||
|
||||
echo " ✓ Backticks replaced. Proceeding with commit."
|
||||
fi
|
||||
|
||||
# Check for $(...) command substitution patterns
|
||||
if echo "$MSG" | grep -q '\$('; then
|
||||
echo "⚠️ WARNING: Commit message contains \$(...) — possible command injection."
|
||||
echo " Escaping dollar signs before parentheses..."
|
||||
SANITIZED=$(echo "$MSG" | sed 's/\$(/\$(/g')
|
||||
echo "$SANITIZED" > "$COMMIT_MSG_FILE"
|
||||
fi
|
||||
@@ -395,7 +395,6 @@
|
||||
<div id="memory-connections-panel" class="memory-connections-panel" style="display:none;" aria-label="Memory Connections Panel"></div>
|
||||
|
||||
<script src="./boot.js"></script>
|
||||
<script src="./js/portal-registry-watcher.js"></script>
|
||||
<script src="./avatar-customization.js"></script>
|
||||
<script src="./lod-system.js"></script>
|
||||
<script>
|
||||
|
||||
@@ -1,280 +0,0 @@
|
||||
/**
|
||||
* Portal Registry Watcher
|
||||
* Issue #1623: feat: portal hot-reload from portals.json without server restart
|
||||
*
|
||||
* Watches portals.json for changes and hot-reloads portal scene objects
|
||||
* without requiring a server restart.
|
||||
*/
|
||||
|
||||
class PortalRegistryWatcher {
|
||||
constructor(options = {}) {
|
||||
this.pollInterval = options.pollInterval || 5000; // 5 seconds
|
||||
this.registryPath = options.registryPath || './portals.json';
|
||||
this.onRegistryUpdate = options.onRegistryUpdate || (() => {});
|
||||
this.onError = options.onError || console.error;
|
||||
|
||||
this.pollTimer = null;
|
||||
this.lastRegistry = null;
|
||||
this.lastModified = null;
|
||||
this.isWatching = false;
|
||||
|
||||
// Cache-busting timestamp
|
||||
this._registryTs = Date.now();
|
||||
|
||||
// Bind methods
|
||||
this.startWatching = this.startWatching.bind(this);
|
||||
this.stopWatching = this.stopWatching.bind(this);
|
||||
this.poll = this.poll.bind(this);
|
||||
this.loadRegistry = this.loadRegistry.bind(this);
|
||||
this.applyRegistry = this.applyRegistry.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start watching for registry changes
|
||||
*/
|
||||
startWatching() {
|
||||
if (this.isWatching) {
|
||||
console.warn('[PortalRegistry] Already watching');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[PortalRegistry] Starting to watch ${this.registryPath} every ${this.pollInterval / 1000}s`);
|
||||
|
||||
this.isWatching = true;
|
||||
|
||||
// Initial load
|
||||
this.poll();
|
||||
|
||||
// Set up interval
|
||||
this.pollTimer = setInterval(this.poll, this.pollInterval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop watching
|
||||
*/
|
||||
stopWatching() {
|
||||
if (this.pollTimer) {
|
||||
clearInterval(this.pollTimer);
|
||||
this.pollTimer = null;
|
||||
}
|
||||
|
||||
this.isWatching = false;
|
||||
console.log('[PortalRegistry] Stopped watching');
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll for registry changes
|
||||
*/
|
||||
async poll() {
|
||||
try {
|
||||
const registry = await this.loadRegistry();
|
||||
|
||||
if (this.hasChanged(registry)) {
|
||||
console.log('[PortalRegistry] Registry changed, applying updates...');
|
||||
this.applyRegistry(registry);
|
||||
this.lastRegistry = registry;
|
||||
this.onRegistryUpdate(registry);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
this.onError('Failed to poll registry:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load registry from file with cache-busting
|
||||
*/
|
||||
async loadRegistry() {
|
||||
// Add cache-busting timestamp
|
||||
this._registryTs = Date.now();
|
||||
const url = `${this.registryPath}?_registry_ts=${this._registryTs}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache',
|
||||
'Pragma': 'no-cache'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load registry: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Validate registry structure
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error('Registry must be an array of portal objects');
|
||||
}
|
||||
|
||||
return data;
|
||||
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to load registry: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if registry has changed
|
||||
*/
|
||||
hasChanged(newRegistry) {
|
||||
if (!this.lastRegistry) {
|
||||
return true; // First load
|
||||
}
|
||||
|
||||
// Quick check: different length
|
||||
if (this.lastRegistry.length !== newRegistry.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Deep check: compare JSON strings
|
||||
const oldJson = JSON.stringify(this.lastRegistry);
|
||||
const newJson = JSON.stringify(newRegistry);
|
||||
|
||||
return oldJson !== newJson;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply registry changes to the scene
|
||||
*/
|
||||
applyRegistry(registry) {
|
||||
console.log(`[PortalRegistry] Applying ${registry.length} portals`);
|
||||
|
||||
// Store registry globally for access by other modules
|
||||
window.portalRegistry = registry;
|
||||
|
||||
// Trigger custom event for other modules to listen to
|
||||
const event = new CustomEvent('portalRegistryUpdated', {
|
||||
detail: { registry, timestamp: Date.now() }
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
|
||||
// Update portal scene objects if Three.js scene exists
|
||||
if (window.scene && window.portalObjects) {
|
||||
this.updatePortalSceneObjects(registry);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update portal scene objects in Three.js
|
||||
*/
|
||||
updatePortalSceneObjects(registry) {
|
||||
// Remove existing portal objects
|
||||
for (const portalId in window.portalObjects) {
|
||||
const portalObj = window.portalObjects[portalId];
|
||||
if (portalObj && portalObj.parent) {
|
||||
portalObj.parent.remove(portalObj);
|
||||
}
|
||||
}
|
||||
|
||||
window.portalObjects = {};
|
||||
|
||||
// Create new portal objects
|
||||
for (const portal of registry) {
|
||||
try {
|
||||
const portalObj = this.createPortalObject(portal);
|
||||
if (portalObj) {
|
||||
window.portalObjects[portal.id] = portalObj;
|
||||
window.scene.add(portalObj);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[PortalRegistry] Failed to create portal ${portal.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[PortalRegistry] Updated ${Object.keys(window.portalObjects).length} portal objects`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a portal object from registry data
|
||||
*/
|
||||
createPortalObject(portal) {
|
||||
// This is a placeholder - actual implementation depends on Three.js setup
|
||||
// In a real implementation, this would create the portal mesh/object
|
||||
|
||||
const portalObj = {
|
||||
id: portal.id,
|
||||
name: portal.name,
|
||||
position: portal.position,
|
||||
status: portal.status,
|
||||
color: portal.color,
|
||||
|
||||
// Methods
|
||||
update: function(newData) {
|
||||
Object.assign(this, newData);
|
||||
},
|
||||
|
||||
destroy: function() {
|
||||
// Cleanup
|
||||
}
|
||||
};
|
||||
|
||||
return portalObj;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current registry
|
||||
*/
|
||||
getRegistry() {
|
||||
return this.lastRegistry || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get portal by ID
|
||||
*/
|
||||
getPortalById(id) {
|
||||
const registry = this.getRegistry();
|
||||
return registry.find(p => p.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get portals by status
|
||||
*/
|
||||
getPortalsByStatus(status) {
|
||||
const registry = this.getRegistry();
|
||||
return registry.filter(p => p.status === status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get watcher status
|
||||
*/
|
||||
getStatus() {
|
||||
return {
|
||||
isWatching: this.isWatching,
|
||||
pollInterval: this.pollInterval,
|
||||
registryPath: this.registryPath,
|
||||
portalCount: this.lastRegistry ? this.lastRegistry.length : 0,
|
||||
lastUpdate: this.lastRegistry ? new Date().toISOString() : null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Export for use in other modules
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = PortalRegistryWatcher;
|
||||
}
|
||||
|
||||
// Global instance for browser use
|
||||
if (typeof window !== 'undefined') {
|
||||
window.PortalRegistryWatcher = PortalRegistryWatcher;
|
||||
|
||||
// Auto-initialize if portal container exists
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const container = document.getElementById('portal-container') || document.getElementById('nexus-canvas');
|
||||
if (container) {
|
||||
const watcher = new PortalRegistryWatcher({
|
||||
pollInterval: 5000,
|
||||
onRegistryUpdate: (registry) => {
|
||||
console.log(`[PortalRegistry] Updated with ${registry.length} portals`);
|
||||
}
|
||||
});
|
||||
|
||||
watcher.startWatching();
|
||||
|
||||
// Store globally for access
|
||||
window.portalRegistryWatcher = watcher;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -14,6 +14,7 @@ from nexus.perception_adapter import (
|
||||
)
|
||||
from nexus.experience_store import ExperienceStore
|
||||
from nexus.trajectory_logger import TrajectoryLogger
|
||||
from nexus.chronicle import ChronicleWriter, AgentEvent, EventKind
|
||||
|
||||
try:
|
||||
from nexus.nexus_think import NexusMind
|
||||
@@ -29,4 +30,7 @@ __all__ = [
|
||||
"ExperienceStore",
|
||||
"TrajectoryLogger",
|
||||
"NexusMind",
|
||||
"ChronicleWriter",
|
||||
"AgentEvent",
|
||||
"EventKind",
|
||||
]
|
||||
|
||||
387
nexus/chronicle.py
Normal file
387
nexus/chronicle.py
Normal file
@@ -0,0 +1,387 @@
|
||||
"""
|
||||
Nexus Chronicle — Emergent Narrative from Agent Interactions
|
||||
|
||||
Watches the fleet's activity (dispatches, errors, recoveries,
|
||||
collaborations) and transforms raw event data into narrative prose.
|
||||
The system finds the dramatic arc in real work and produces a living
|
||||
chronicle. The story writes itself from the data.
|
||||
|
||||
Usage:
|
||||
from nexus.chronicle import ChronicleWriter, AgentEvent, EventKind
|
||||
|
||||
writer = ChronicleWriter()
|
||||
|
||||
writer.ingest(AgentEvent(kind=EventKind.DISPATCH, agent="claude", detail="took issue #42"))
|
||||
writer.ingest(AgentEvent(kind=EventKind.ERROR, agent="claude", detail="rate limit hit"))
|
||||
writer.ingest(AgentEvent(kind=EventKind.RECOVERY, agent="claude", detail="retried after backoff"))
|
||||
writer.ingest(AgentEvent(kind=EventKind.COMMIT, agent="claude", detail="feat: add narrative engine"))
|
||||
|
||||
prose = writer.render()
|
||||
print(prose)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Event model
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class EventKind(str, Enum):
|
||||
"""The kinds of agent events the chronicle recognises."""
|
||||
|
||||
DISPATCH = "dispatch" # agent claimed / was assigned work
|
||||
COMMIT = "commit" # agent produced a commit
|
||||
PUSH = "push" # agent pushed a branch
|
||||
PR_OPEN = "pr_open" # agent opened a pull request
|
||||
PR_MERGE = "pr_merge" # PR was merged
|
||||
ERROR = "error" # agent hit an error / exception
|
||||
RECOVERY = "recovery" # agent recovered from a failure
|
||||
ABANDON = "abandon" # agent abandoned a task (timeout / giving up)
|
||||
COLLABORATION = "collab" # two agents worked on the same thing
|
||||
HEARTBEAT = "heartbeat" # agent reported a heartbeat (alive signal)
|
||||
IDLE = "idle" # agent is waiting for work
|
||||
MILESTONE = "milestone" # notable achievement (e.g. 100th issue closed)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentEvent:
|
||||
"""One discrete thing that happened in the fleet."""
|
||||
|
||||
kind: EventKind
|
||||
agent: str # who did this (e.g. "claude", "mimo-v2-pro")
|
||||
detail: str = "" # free-text description
|
||||
timestamp: float = field(default_factory=time.time)
|
||||
metadata: dict = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"kind": self.kind.value,
|
||||
"agent": self.agent,
|
||||
"detail": self.detail,
|
||||
"timestamp": self.timestamp,
|
||||
"metadata": self.metadata,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "AgentEvent":
|
||||
return cls(
|
||||
kind=EventKind(data["kind"]),
|
||||
agent=data["agent"],
|
||||
detail=data.get("detail", ""),
|
||||
timestamp=data.get("timestamp", time.time()),
|
||||
metadata=data.get("metadata", {}),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Narrative templates — maps event kinds to prose fragments
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Each entry is a list so we can rotate through variants.
|
||||
_TEMPLATES: dict[EventKind, list[str]] = {
|
||||
EventKind.DISPATCH: [
|
||||
"{agent} stepped forward and claimed the work: {detail}.",
|
||||
"{agent} took on the challenge — {detail}.",
|
||||
"The task landed on {agent}'s desk: {detail}.",
|
||||
],
|
||||
EventKind.COMMIT: [
|
||||
'{agent} sealed a commit into the record: "{detail}".',
|
||||
'{agent} committed "{detail}" — progress crystallised.',
|
||||
"{agent} carved a new ring into the trunk: {detail}.",
|
||||
],
|
||||
EventKind.PUSH: [
|
||||
"{agent} pushed the work upstream.",
|
||||
"The branch rose into the forge — {agent}'s changes were live.",
|
||||
"{agent} sent their work into the wider current.",
|
||||
],
|
||||
EventKind.PR_OPEN: [
|
||||
"{agent} opened a pull request: {detail}.",
|
||||
"A proposal surfaced — {agent} asked the fleet to review {detail}.",
|
||||
"{agent} laid their work before the reviewers: {detail}.",
|
||||
],
|
||||
EventKind.PR_MERGE: [
|
||||
"{agent}'s branch folded into the whole: {detail}.",
|
||||
"Consensus reached — {agent}'s changes were merged: {detail}.",
|
||||
"{detail} joined the canon. {agent}'s contribution lives on.",
|
||||
],
|
||||
EventKind.ERROR: [
|
||||
"{agent} ran into an obstacle: {detail}.",
|
||||
"Trouble. {agent} encountered {detail} and had to pause.",
|
||||
"The path grew difficult — {agent} hit {detail}.",
|
||||
],
|
||||
EventKind.RECOVERY: [
|
||||
"{agent} regrouped and pressed on: {detail}.",
|
||||
"After the setback, {agent} found a way through: {detail}.",
|
||||
"{agent} recovered — {detail}.",
|
||||
],
|
||||
EventKind.ABANDON: [
|
||||
"{agent} released the task, unable to finish: {detail}.",
|
||||
"Sometimes wisdom is knowing when to let go. {agent} abandoned {detail}.",
|
||||
"{agent} stepped back from {detail}. Another will carry it forward.",
|
||||
],
|
||||
EventKind.COLLABORATION: [
|
||||
"{agent} and their peers converged on the same problem: {detail}.",
|
||||
"Two minds touched the same work — {agent} in collaboration: {detail}.",
|
||||
"The fleet coordinated — {agent} joined the effort on {detail}.",
|
||||
],
|
||||
EventKind.HEARTBEAT: [
|
||||
"{agent} checked in — still thinking, still present.",
|
||||
"A pulse from {agent}: the mind is alive.",
|
||||
"{agent} breathed through another cycle.",
|
||||
],
|
||||
EventKind.IDLE: [
|
||||
"{agent} rested, waiting for the next call.",
|
||||
"Quiet descended — {agent} held still between tasks.",
|
||||
"{agent} stood ready, watchful in the lull.",
|
||||
],
|
||||
EventKind.MILESTONE: [
|
||||
"A moment worth noting — {agent}: {detail}.",
|
||||
"The chronicle marks a milestone. {agent}: {detail}.",
|
||||
"History ticked over — {agent} reached {detail}.",
|
||||
],
|
||||
}
|
||||
|
||||
# Arc-level commentary triggered by sequences of events
|
||||
_ARC_TEMPLATES = {
|
||||
"struggle_and_recovery": (
|
||||
"There was a struggle here. {agent} hit trouble and came back stronger — "
|
||||
"the kind of arc that gives a chronicle its texture."
|
||||
),
|
||||
"silent_grind": (
|
||||
"No drama, just steady work. {agents} moved through the backlog with quiet persistence."
|
||||
),
|
||||
"abandon_then_retry": (
|
||||
"{agent} let go once. But the work called again, and this time it was answered."
|
||||
),
|
||||
"solo_sprint": (
|
||||
"{agent} ran the whole arc alone — dispatch to merge — without breaking stride."
|
||||
),
|
||||
"fleet_convergence": (
|
||||
"The fleet converged. Multiple agents touched the same thread and wove it tighter."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chronicle writer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ChronicleWriter:
|
||||
"""Accumulates agent events and renders them as narrative prose.
|
||||
|
||||
The writer keeps a running log of events. Call ``ingest()`` to add new
|
||||
events as they arrive, then ``render()`` to produce a prose snapshot of
|
||||
the current arc.
|
||||
|
||||
Events are also persisted to JSONL so the chronicle survives restarts.
|
||||
"""
|
||||
|
||||
def __init__(self, log_path: Optional[Path] = None):
|
||||
today = time.strftime("%Y-%m-%d")
|
||||
self.log_path = log_path or (
|
||||
Path.home() / ".nexus" / "chronicle" / f"chronicle_{today}.jsonl"
|
||||
)
|
||||
self.log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self._events: list[AgentEvent] = []
|
||||
self._template_counters: dict[EventKind, int] = {}
|
||||
|
||||
# Load any events already on disk for today
|
||||
self._load_existing()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def ingest(self, event: AgentEvent) -> None:
|
||||
"""Add an event to the chronicle and persist it to disk."""
|
||||
self._events.append(event)
|
||||
with open(self.log_path, "a") as f:
|
||||
f.write(json.dumps(event.to_dict()) + "\n")
|
||||
|
||||
def render(self, max_events: int = 50) -> str:
|
||||
"""Render the recent event stream as narrative prose.
|
||||
|
||||
Returns a multi-paragraph string suitable for display or logging.
|
||||
"""
|
||||
events = self._events[-max_events:]
|
||||
if not events:
|
||||
return "The chronicle is empty. No events have been recorded yet."
|
||||
|
||||
paragraphs: list[str] = []
|
||||
|
||||
# Opening line with timestamp range
|
||||
first_ts = time.strftime("%H:%M", time.localtime(events[0].timestamp))
|
||||
last_ts = time.strftime("%H:%M", time.localtime(events[-1].timestamp))
|
||||
paragraphs.append(
|
||||
f"The chronicle covers {len(events)} event(s) between {first_ts} and {last_ts}."
|
||||
)
|
||||
|
||||
# Event-by-event prose
|
||||
sentences: list[str] = []
|
||||
for evt in events:
|
||||
sentences.append(self._render_event(evt))
|
||||
paragraphs.append(" ".join(sentences))
|
||||
|
||||
# Arc-level commentary
|
||||
arc = self._detect_arc(events)
|
||||
if arc:
|
||||
paragraphs.append(arc)
|
||||
|
||||
return "\n\n".join(paragraphs)
|
||||
|
||||
def render_markdown(self, max_events: int = 50) -> str:
|
||||
"""Render as a Markdown document."""
|
||||
events = self._events[-max_events:]
|
||||
if not events:
|
||||
return "# Chronicle\n\n*No events recorded yet.*"
|
||||
|
||||
today = time.strftime("%Y-%m-%d")
|
||||
lines = [f"# Chronicle — {today}", ""]
|
||||
|
||||
for evt in events:
|
||||
ts = time.strftime("%H:%M:%S", time.localtime(evt.timestamp))
|
||||
prose = self._render_event(evt)
|
||||
lines.append(f"**{ts}** — {prose}")
|
||||
|
||||
arc = self._detect_arc(events)
|
||||
if arc:
|
||||
lines += ["", "---", "", f"*{arc}*"]
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def summary(self) -> dict:
|
||||
"""Return a structured summary of the current session."""
|
||||
agents: dict[str, dict] = {}
|
||||
kind_counts: dict[str, int] = {}
|
||||
|
||||
for evt in self._events:
|
||||
agents.setdefault(evt.agent, {"events": 0, "kinds": []})
|
||||
agents[evt.agent]["events"] += 1
|
||||
agents[evt.agent]["kinds"].append(evt.kind.value)
|
||||
kind_counts[evt.kind.value] = kind_counts.get(evt.kind.value, 0) + 1
|
||||
|
||||
return {
|
||||
"total_events": len(self._events),
|
||||
"agents": agents,
|
||||
"kind_counts": kind_counts,
|
||||
"log_path": str(self.log_path),
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _render_event(self, evt: AgentEvent) -> str:
|
||||
"""Turn a single event into a prose sentence."""
|
||||
templates = _TEMPLATES.get(evt.kind, ["{agent}: {detail}"])
|
||||
counter = self._template_counters.get(evt.kind, 0)
|
||||
template = templates[counter % len(templates)]
|
||||
self._template_counters[evt.kind] = counter + 1
|
||||
return template.format(agent=evt.agent, detail=evt.detail or evt.kind.value)
|
||||
|
||||
def _detect_arc(self, events: list[AgentEvent]) -> Optional[str]:
|
||||
"""Scan the event sequence for a recognisable dramatic arc."""
|
||||
if not events:
|
||||
return None
|
||||
|
||||
kinds = [e.kind for e in events]
|
||||
agents = list({e.agent for e in events})
|
||||
|
||||
# struggle → recovery
|
||||
if EventKind.ERROR in kinds and EventKind.RECOVERY in kinds:
|
||||
err_idx = kinds.index(EventKind.ERROR)
|
||||
rec_idx = kinds.index(EventKind.RECOVERY)
|
||||
if rec_idx > err_idx:
|
||||
agent = events[err_idx].agent
|
||||
return _ARC_TEMPLATES["struggle_and_recovery"].format(agent=agent)
|
||||
|
||||
# abandon → dispatch (retry): find first ABANDON, then any DISPATCH after it
|
||||
if EventKind.ABANDON in kinds and EventKind.DISPATCH in kinds:
|
||||
ab_idx = kinds.index(EventKind.ABANDON)
|
||||
retry_idx = next(
|
||||
(i for i, k in enumerate(kinds) if k == EventKind.DISPATCH and i > ab_idx),
|
||||
None,
|
||||
)
|
||||
if retry_idx is not None:
|
||||
agent = events[retry_idx].agent
|
||||
return _ARC_TEMPLATES["abandon_then_retry"].format(agent=agent)
|
||||
|
||||
# solo sprint: single agent goes dispatch→commit→pr_open→pr_merge
|
||||
solo_arc = {EventKind.DISPATCH, EventKind.COMMIT, EventKind.PR_OPEN, EventKind.PR_MERGE}
|
||||
if solo_arc.issubset(set(kinds)) and len(agents) == 1:
|
||||
return _ARC_TEMPLATES["solo_sprint"].format(agent=agents[0])
|
||||
|
||||
# fleet convergence: multiple agents, collaboration event
|
||||
if len(agents) > 1 and EventKind.COLLABORATION in kinds:
|
||||
return _ARC_TEMPLATES["fleet_convergence"]
|
||||
|
||||
# silent grind: only commits / heartbeats, no drama
|
||||
drama = {EventKind.ERROR, EventKind.ABANDON, EventKind.RECOVERY, EventKind.COLLABORATION}
|
||||
if not drama.intersection(set(kinds)) and EventKind.COMMIT in kinds:
|
||||
return _ARC_TEMPLATES["silent_grind"].format(agents=", ".join(agents))
|
||||
|
||||
return None
|
||||
|
||||
def _load_existing(self) -> None:
|
||||
"""Load events persisted from earlier in the same session."""
|
||||
if not self.log_path.exists():
|
||||
return
|
||||
with open(self.log_path) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
self._events.append(AgentEvent.from_dict(json.loads(line)))
|
||||
except (json.JSONDecodeError, KeyError, ValueError):
|
||||
continue # skip malformed lines
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Convenience: build events from common fleet signals
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def event_from_gitea_issue(payload: dict, agent: str) -> AgentEvent:
|
||||
"""Build a DISPATCH event from a Gitea issue assignment payload."""
|
||||
issue_num = payload.get("number", "?")
|
||||
title = payload.get("title", "")
|
||||
return AgentEvent(
|
||||
kind=EventKind.DISPATCH,
|
||||
agent=agent,
|
||||
detail=f"issue #{issue_num}: {title}",
|
||||
metadata={"issue_number": issue_num},
|
||||
)
|
||||
|
||||
|
||||
def event_from_heartbeat(hb: dict) -> AgentEvent:
|
||||
"""Build a HEARTBEAT event from a nexus heartbeat dict."""
|
||||
agent = hb.get("model", "unknown")
|
||||
status = hb.get("status", "thinking")
|
||||
cycle = hb.get("cycle", 0)
|
||||
return AgentEvent(
|
||||
kind=EventKind.HEARTBEAT,
|
||||
agent=agent,
|
||||
detail=f"cycle {cycle}, status={status}",
|
||||
metadata=hb,
|
||||
)
|
||||
|
||||
|
||||
def event_from_commit(commit: dict, agent: str) -> AgentEvent:
|
||||
"""Build a COMMIT event from a git commit dict."""
|
||||
message = commit.get("message", "").split("\n")[0] # subject line only
|
||||
sha = commit.get("sha", "")[:8]
|
||||
return AgentEvent(
|
||||
kind=EventKind.COMMIT,
|
||||
agent=agent,
|
||||
detail=message,
|
||||
metadata={"sha": sha},
|
||||
)
|
||||
211
tests/test_chronicle.py
Normal file
211
tests/test_chronicle.py
Normal file
@@ -0,0 +1,211 @@
|
||||
"""
|
||||
Tests for nexus.chronicle — emergent narrative from agent interactions.
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from nexus.chronicle import (
|
||||
AgentEvent,
|
||||
ChronicleWriter,
|
||||
EventKind,
|
||||
event_from_commit,
|
||||
event_from_gitea_issue,
|
||||
event_from_heartbeat,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AgentEvent
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAgentEvent:
|
||||
def test_roundtrip(self):
|
||||
evt = AgentEvent(
|
||||
kind=EventKind.DISPATCH,
|
||||
agent="claude",
|
||||
detail="took issue #42",
|
||||
)
|
||||
assert AgentEvent.from_dict(evt.to_dict()).kind == EventKind.DISPATCH
|
||||
assert AgentEvent.from_dict(evt.to_dict()).agent == "claude"
|
||||
assert AgentEvent.from_dict(evt.to_dict()).detail == "took issue #42"
|
||||
|
||||
def test_default_timestamp_is_recent(self):
|
||||
before = time.time()
|
||||
evt = AgentEvent(kind=EventKind.IDLE, agent="mimo")
|
||||
after = time.time()
|
||||
assert before <= evt.timestamp <= after
|
||||
|
||||
def test_all_event_kinds_are_valid_strings(self):
|
||||
for kind in EventKind:
|
||||
evt = AgentEvent(kind=kind, agent="test-agent")
|
||||
d = evt.to_dict()
|
||||
assert d["kind"] == kind.value
|
||||
restored = AgentEvent.from_dict(d)
|
||||
assert restored.kind == kind
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ChronicleWriter — basic ingestion and render
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestChronicleWriter:
|
||||
@pytest.fixture
|
||||
def writer(self, tmp_path):
|
||||
return ChronicleWriter(log_path=tmp_path / "chronicle.jsonl")
|
||||
|
||||
def test_empty_render(self, writer):
|
||||
text = writer.render()
|
||||
assert "empty" in text.lower()
|
||||
|
||||
def test_single_event_render(self, writer):
|
||||
writer.ingest(AgentEvent(kind=EventKind.DISPATCH, agent="claude", detail="issue #1"))
|
||||
text = writer.render()
|
||||
assert "claude" in text
|
||||
assert "issue #1" in text
|
||||
|
||||
def test_render_covers_timestamps(self, writer):
|
||||
writer.ingest(AgentEvent(kind=EventKind.DISPATCH, agent="a", detail="start"))
|
||||
writer.ingest(AgentEvent(kind=EventKind.COMMIT, agent="a", detail="done"))
|
||||
text = writer.render()
|
||||
assert "chronicle covers" in text.lower()
|
||||
|
||||
def test_events_persisted_to_disk(self, writer, tmp_path):
|
||||
writer.ingest(AgentEvent(kind=EventKind.COMMIT, agent="claude", detail="feat: x"))
|
||||
lines = (tmp_path / "chronicle.jsonl").read_text().strip().splitlines()
|
||||
assert len(lines) == 1
|
||||
data = json.loads(lines[0])
|
||||
assert data["kind"] == "commit"
|
||||
assert data["agent"] == "claude"
|
||||
|
||||
def test_load_existing_on_init(self, tmp_path):
|
||||
log = tmp_path / "chronicle.jsonl"
|
||||
evt = AgentEvent(kind=EventKind.PUSH, agent="mimo", detail="pushed branch")
|
||||
log.write_text(json.dumps(evt.to_dict()) + "\n")
|
||||
|
||||
writer2 = ChronicleWriter(log_path=log)
|
||||
assert len(writer2._events) == 1
|
||||
assert writer2._events[0].kind == EventKind.PUSH
|
||||
|
||||
def test_malformed_lines_are_skipped(self, tmp_path):
|
||||
log = tmp_path / "chronicle.jsonl"
|
||||
log.write_text("not-json\n{}\n")
|
||||
# Should not raise
|
||||
writer2 = ChronicleWriter(log_path=log)
|
||||
assert writer2._events == []
|
||||
|
||||
def test_template_rotation(self, writer):
|
||||
"""Consecutive events of the same kind use different templates."""
|
||||
sentences = set()
|
||||
for _ in range(3):
|
||||
writer.ingest(AgentEvent(kind=EventKind.HEARTBEAT, agent="claude"))
|
||||
text = writer.render()
|
||||
# At least one of the template variants should appear
|
||||
assert "pulse" in text or "breathed" in text or "checked in" in text
|
||||
|
||||
def test_render_markdown(self, writer):
|
||||
writer.ingest(AgentEvent(kind=EventKind.PR_OPEN, agent="claude", detail="PR #99"))
|
||||
md = writer.render_markdown()
|
||||
assert md.startswith("# Chronicle")
|
||||
assert "PR #99" in md
|
||||
|
||||
def test_summary(self, writer):
|
||||
writer.ingest(AgentEvent(kind=EventKind.DISPATCH, agent="claude", detail="x"))
|
||||
writer.ingest(AgentEvent(kind=EventKind.COMMIT, agent="claude", detail="y"))
|
||||
s = writer.summary()
|
||||
assert s["total_events"] == 2
|
||||
assert "claude" in s["agents"]
|
||||
assert s["kind_counts"]["dispatch"] == 1
|
||||
assert s["kind_counts"]["commit"] == 1
|
||||
|
||||
def test_max_events_limit(self, writer):
|
||||
for i in range(10):
|
||||
writer.ingest(AgentEvent(kind=EventKind.IDLE, agent="a", detail=str(i)))
|
||||
text = writer.render(max_events=3)
|
||||
# Only 3 events should appear in prose — check coverage header
|
||||
assert "3 event(s)" in text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Arc detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestArcDetection:
|
||||
@pytest.fixture
|
||||
def writer(self, tmp_path):
|
||||
return ChronicleWriter(log_path=tmp_path / "chronicle.jsonl")
|
||||
|
||||
def _ingest(self, writer, *kinds, agent="claude"):
|
||||
for k in kinds:
|
||||
writer.ingest(AgentEvent(kind=k, agent=agent, detail="x"))
|
||||
|
||||
def test_struggle_and_recovery_arc(self, writer):
|
||||
self._ingest(writer, EventKind.DISPATCH, EventKind.ERROR, EventKind.RECOVERY)
|
||||
text = writer.render()
|
||||
assert "struggle" in text.lower() or "trouble" in text.lower()
|
||||
|
||||
def test_no_arc_when_no_pattern(self, writer):
|
||||
self._ingest(writer, EventKind.IDLE)
|
||||
text = writer.render()
|
||||
# Should not include arc language (only 1 event, no pattern)
|
||||
assert "converged" not in text
|
||||
assert "struggle" not in text
|
||||
|
||||
def test_solo_sprint_arc(self, writer):
|
||||
self._ingest(
|
||||
writer,
|
||||
EventKind.DISPATCH,
|
||||
EventKind.COMMIT,
|
||||
EventKind.PR_OPEN,
|
||||
EventKind.PR_MERGE,
|
||||
)
|
||||
text = writer.render()
|
||||
assert "solo" in text.lower() or "alone" in text.lower()
|
||||
|
||||
def test_fleet_convergence_arc(self, writer, tmp_path):
|
||||
writer2 = ChronicleWriter(log_path=tmp_path / "chronicle.jsonl")
|
||||
writer2.ingest(AgentEvent(kind=EventKind.DISPATCH, agent="claude", detail="x"))
|
||||
writer2.ingest(AgentEvent(kind=EventKind.COLLABORATION, agent="mimo", detail="x"))
|
||||
writer2.ingest(AgentEvent(kind=EventKind.COMMIT, agent="claude", detail="x"))
|
||||
text = writer2.render()
|
||||
assert "converged" in text.lower() or "fleet" in text.lower()
|
||||
|
||||
def test_silent_grind_arc(self, writer):
|
||||
self._ingest(writer, EventKind.COMMIT, EventKind.COMMIT, EventKind.COMMIT)
|
||||
text = writer.render()
|
||||
assert "steady" in text.lower() or "quiet" in text.lower() or "grind" in text.lower()
|
||||
|
||||
def test_abandon_then_retry_arc(self, writer):
|
||||
self._ingest(writer, EventKind.DISPATCH, EventKind.ABANDON, EventKind.DISPATCH)
|
||||
text = writer.render()
|
||||
assert "let go" in text.lower() or "abandon" in text.lower() or "called again" in text.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Convenience constructors
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestConvenienceConstructors:
|
||||
def test_event_from_gitea_issue(self):
|
||||
payload = {"number": 42, "title": "feat: add narrative engine"}
|
||||
evt = event_from_gitea_issue(payload, agent="claude")
|
||||
assert evt.kind == EventKind.DISPATCH
|
||||
assert "42" in evt.detail
|
||||
assert evt.agent == "claude"
|
||||
|
||||
def test_event_from_heartbeat(self):
|
||||
hb = {"model": "claude-sonnet", "status": "thinking", "cycle": 7}
|
||||
evt = event_from_heartbeat(hb)
|
||||
assert evt.kind == EventKind.HEARTBEAT
|
||||
assert evt.agent == "claude-sonnet"
|
||||
assert "7" in evt.detail
|
||||
|
||||
def test_event_from_commit(self):
|
||||
commit = {"message": "feat: chronicle\n\nFixes #1607", "sha": "abc1234567"}
|
||||
evt = event_from_commit(commit, agent="claude")
|
||||
assert evt.kind == EventKind.COMMIT
|
||||
assert evt.detail == "feat: chronicle" # subject line only
|
||||
assert evt.metadata["sha"] == "abc12345"
|
||||
@@ -1,214 +0,0 @@
|
||||
/**
|
||||
* Tests for Portal Registry Watcher
|
||||
* Issue #1623: feat: portal hot-reload from portals.json without server restart
|
||||
*/
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
|
||||
// Mock fetch
|
||||
global.fetch = async (url) => {
|
||||
// Simulate loading portals.json
|
||||
if (url.includes('portals.json')) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => [
|
||||
{
|
||||
id: 'test-portal-1',
|
||||
name: 'Test Portal 1',
|
||||
status: 'online',
|
||||
color: '#ff0000',
|
||||
position: { x: 10, y: 0, z: 0 }
|
||||
},
|
||||
{
|
||||
id: 'test-portal-2',
|
||||
name: 'Test Portal 2',
|
||||
status: 'offline',
|
||||
color: '#00ff00',
|
||||
position: { x: -10, y: 0, z: 0 }
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected URL: ${url}`);
|
||||
};
|
||||
|
||||
// Mock CustomEvent
|
||||
global.CustomEvent = class CustomEvent {
|
||||
constructor(type, options) {
|
||||
this.type = type;
|
||||
this.detail = options.detail;
|
||||
}
|
||||
};
|
||||
|
||||
// Mock window
|
||||
global.window = {
|
||||
addEventListener: () => {},
|
||||
dispatchEvent: () => {},
|
||||
portalObjects: {},
|
||||
scene: {
|
||||
add: () => {}
|
||||
}
|
||||
};
|
||||
|
||||
// Mock document
|
||||
global.document = {
|
||||
addEventListener: () => {},
|
||||
getElementById: () => null
|
||||
};
|
||||
|
||||
// Load portal-registry-watcher.js
|
||||
const watcherPath = path.join(ROOT, 'js', 'portal-registry-watcher.js');
|
||||
const watcherCode = fs.readFileSync(watcherPath, 'utf8');
|
||||
|
||||
// Execute in context
|
||||
const vm = require('node:vm');
|
||||
const context = {
|
||||
module: { exports: {} },
|
||||
exports: {},
|
||||
console,
|
||||
window: global.window,
|
||||
document: global.document,
|
||||
fetch: global.fetch,
|
||||
CustomEvent: global.CustomEvent,
|
||||
setInterval: () => {},
|
||||
clearInterval: () => {}
|
||||
};
|
||||
|
||||
vm.runInNewContext(watcherCode, context);
|
||||
|
||||
// Get PortalRegistryWatcher
|
||||
const PortalRegistryWatcher = context.window.PortalRegistryWatcher || context.module.exports;
|
||||
|
||||
test('PortalRegistryWatcher loads correctly', () => {
|
||||
assert.ok(PortalRegistryWatcher, 'PortalRegistryWatcher should be defined');
|
||||
assert.ok(typeof PortalRegistryWatcher === 'function', 'PortalRegistryWatcher should be a constructor');
|
||||
});
|
||||
|
||||
test('PortalRegistryWatcher can be instantiated', () => {
|
||||
const watcher = new PortalRegistryWatcher();
|
||||
assert.ok(watcher, 'PortalRegistryWatcher instance should be created');
|
||||
assert.equal(watcher.pollInterval, 5000, 'Should have default poll interval');
|
||||
assert.equal(watcher.registryPath, './portals.json', 'Should have default registry path');
|
||||
assert.ok(!watcher.isWatching, 'Should not be watching initially');
|
||||
});
|
||||
|
||||
test('PortalRegistryWatcher can load registry', async () => {
|
||||
const watcher = new PortalRegistryWatcher();
|
||||
|
||||
const registry = await watcher.loadRegistry();
|
||||
|
||||
assert.ok(registry, 'Should return registry');
|
||||
assert.ok(Array.isArray(registry), 'Registry should be an array');
|
||||
assert.equal(registry.length, 2, 'Should have 2 portals');
|
||||
assert.equal(registry[0].id, 'test-portal-1', 'First portal should have correct ID');
|
||||
assert.equal(registry[1].id, 'test-portal-2', 'Second portal should have correct ID');
|
||||
});
|
||||
|
||||
test('PortalRegistryWatcher detects changes', async () => {
|
||||
const watcher = new PortalRegistryWatcher();
|
||||
|
||||
// Load initial registry
|
||||
const registry1 = await watcher.loadRegistry();
|
||||
watcher.lastRegistry = registry1;
|
||||
|
||||
// Load same registry again
|
||||
const registry2 = await watcher.loadRegistry();
|
||||
const changed1 = watcher.hasChanged(registry2);
|
||||
|
||||
assert.ok(!changed1, 'Should not detect change with same registry');
|
||||
|
||||
// Create a different registry manually
|
||||
const differentRegistry = [
|
||||
{ id: 'different-portal', name: 'Different Portal' }
|
||||
];
|
||||
|
||||
// Test hasChanged with different registry
|
||||
const changed2 = watcher.hasChanged(differentRegistry);
|
||||
|
||||
assert.ok(changed2, 'Should detect change with different registry');
|
||||
});
|
||||
|
||||
test('PortalRegistryWatcher can start and stop watching', () => {
|
||||
const watcher = new PortalRegistryWatcher();
|
||||
|
||||
// Start watching
|
||||
watcher.startWatching();
|
||||
assert.ok(watcher.isWatching, 'Should be watching after start');
|
||||
|
||||
// Stop watching
|
||||
watcher.stopWatching();
|
||||
assert.ok(!watcher.isWatching, 'Should not be watching after stop');
|
||||
});
|
||||
|
||||
test('PortalRegistryWatcher applies registry', () => {
|
||||
const watcher = new PortalRegistryWatcher();
|
||||
|
||||
const registry = [
|
||||
{ id: 'portal-1', name: 'Portal 1' },
|
||||
{ id: 'portal-2', name: 'Portal 2' }
|
||||
];
|
||||
|
||||
// Mock window.dispatchEvent
|
||||
let eventDispatched = false;
|
||||
global.window.dispatchEvent = (event) => {
|
||||
if (event.type === 'portalRegistryUpdated') {
|
||||
eventDispatched = true;
|
||||
}
|
||||
};
|
||||
|
||||
watcher.applyRegistry(registry);
|
||||
|
||||
assert.ok(eventDispatched, 'Should dispatch portalRegistryUpdated event');
|
||||
assert.deepEqual(window.portalRegistry, registry, 'Should store registry globally');
|
||||
});
|
||||
|
||||
test('PortalRegistryWatcher can get portal by ID', async () => {
|
||||
const watcher = new PortalRegistryWatcher();
|
||||
|
||||
// Load registry
|
||||
await watcher.loadRegistry();
|
||||
watcher.lastRegistry = await watcher.loadRegistry();
|
||||
|
||||
const portal = watcher.getPortalById('test-portal-1');
|
||||
assert.ok(portal, 'Should find portal by ID');
|
||||
assert.equal(portal.name, 'Test Portal 1', 'Should have correct name');
|
||||
|
||||
const missing = watcher.getPortalById('non-existent');
|
||||
assert.ok(!missing, 'Should return undefined for non-existent portal');
|
||||
});
|
||||
|
||||
test('PortalRegistryWatcher can get portals by status', async () => {
|
||||
const watcher = new PortalRegistryWatcher();
|
||||
|
||||
// Load registry
|
||||
await watcher.loadRegistry();
|
||||
watcher.lastRegistry = await watcher.loadRegistry();
|
||||
|
||||
const online = watcher.getPortalsByStatus('online');
|
||||
assert.equal(online.length, 1, 'Should have 1 online portal');
|
||||
assert.equal(online[0].id, 'test-portal-1', 'Should be test-portal-1');
|
||||
|
||||
const offline = watcher.getPortalsByStatus('offline');
|
||||
assert.equal(offline.length, 1, 'Should have 1 offline portal');
|
||||
assert.equal(offline[0].id, 'test-portal-2', 'Should be test-portal-2');
|
||||
});
|
||||
|
||||
test('PortalRegistryWatcher can get status', () => {
|
||||
const watcher = new PortalRegistryWatcher();
|
||||
|
||||
const status = watcher.getStatus();
|
||||
|
||||
assert.ok(status, 'Should return status object');
|
||||
assert.equal(status.isWatching, false, 'Should not be watching');
|
||||
assert.equal(status.pollInterval, 5000, 'Should have correct poll interval');
|
||||
assert.equal(status.registryPath, './portals.json', 'Should have correct path');
|
||||
assert.equal(status.portalCount, 0, 'Should have 0 portals initially');
|
||||
});
|
||||
|
||||
console.log('All Portal Registry Watcher tests passed!');
|
||||
Reference in New Issue
Block a user