Add Lucidia auto-spawn rules DSL and spawn runner

Co-authored-by: blackboxprogramming <118287761+blackboxprogramming@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2025-11-24 23:14:17 +00:00
parent be66a374d3
commit 9e4fe64ad3
26 changed files with 815 additions and 338 deletions

View File

@@ -0,0 +1,68 @@
name: 🧬 Lucidia Spawn Runner Agent Auto-Spawn Monitor
on:
schedule:
# Run every 6 hours
- cron: "0 */6 * * *"
workflow_dispatch:
inputs:
escalations_last_7_days:
description: "Number of escalations in last 7 days"
required: false
default: "0"
digest_count:
description: "Current digest count"
required: false
default: "0"
average_blocked_pct:
description: "Average blocked percentage"
required: false
default: "0"
created_repos_last_72h:
description: "Repos created in last 72 hours"
required: false
default: "0"
overdue_issues_count:
description: "Count of overdue issues"
required: false
default: "0"
emoji_threshold:
description: "Emoji reaction threshold"
required: false
default: "0"
emoji_type:
description: "Emoji type to check (e.g., 🛟)"
required: false
default: ""
jobs:
spawn-runner:
runs-on: ubuntu-latest
steps:
- name: 🧬 Checkout Repo
uses: actions/checkout@v3
- name: 🧠 Set up Node
uses: actions/setup-node@v3
with:
node-version: 18
- name: 🚦 Run Lucidia Spawn Runner
id: spawn
env:
ESCALATIONS_LAST_7_DAYS: ${{ github.event.inputs.escalations_last_7_days || '0' }}
DIGEST_COUNT: ${{ github.event.inputs.digest_count || '0' }}
AVERAGE_BLOCKED_PCT: ${{ github.event.inputs.average_blocked_pct || '0' }}
CREATED_REPOS_LAST_72H: ${{ github.event.inputs.created_repos_last_72h || '0' }}
OVERDUE_ISSUES_COUNT: ${{ github.event.inputs.overdue_issues_count || '0' }}
EMOJI_THRESHOLD: ${{ github.event.inputs.emoji_threshold || '0' }}
EMOJI_TYPE: ${{ github.event.inputs.emoji_type || '' }}
run: node bot/lucidia-spawn-runner.js
- name: 📤 Upload Spawned Agents Manifest
if: always()
uses: actions/upload-artifact@v3
with:
name: spawned-agents
path: spawned-agents.json
if-no-files-found: ignore

7
.gitignore vendored
View File

@@ -1,3 +1,10 @@
node_modules node_modules
coverage coverage
# TypeScript build output
*.js
!bot/*.js
!bot/**/*.js
# Generated output
spawned-agents.json

View File

@@ -1,3 +0,0 @@
export async function GET() {
return Response.json({ status: "ok" });
}

View File

@@ -1,5 +0,0 @@
import { getBuildInfo } from "../../../src/utils/buildInfo";
export async function GET() {
const info = getBuildInfo();
return Response.json({ version: info.version, commit: info.commit });
}

291
bot/lucidia-spawn-runner.js Normal file
View File

@@ -0,0 +1,291 @@
// bot/lucidia-spawn-runner.js
// 🧬 Lucidia Spawn Runner Evaluates spawn rules and instantiates agents
const fs = require("fs");
const path = require("path");
/**
* Parse YAML-like spawn rules (simple parser for our DSL)
* @param {string} content - Raw YAML content
* @returns {object} Parsed spawn rules
*/
function parseSpawnRules(content) {
const rules = [];
let currentRule = null;
let inIf = false;
let inThen = false;
let inConfig = false;
let currentIndent = 0;
const lines = content.split("\n");
for (const line of lines) {
const trimmed = line.trim();
// Skip empty lines and comments
if (!trimmed || trimmed.startsWith("#")) continue;
// Detect rule start
if (trimmed === "- if:") {
if (currentRule) {
rules.push(currentRule);
}
currentRule = { if: {}, then: {} };
inIf = true;
inThen = false;
inConfig = false;
continue;
}
if (trimmed === "then:") {
inIf = false;
inThen = true;
inConfig = false;
continue;
}
if (trimmed === "config:") {
inConfig = true;
if (!currentRule.then.config) {
currentRule.then.config = {};
}
continue;
}
// Parse key-value pairs
const kvMatch = trimmed.match(/^(\w+):\s*(.*)$/);
if (kvMatch) {
const key = kvMatch[1];
let value = kvMatch[2];
// Handle quoted strings
if (value.startsWith('"') && value.endsWith('"')) {
value = value.slice(1, -1);
}
// Handle arrays
if (value.startsWith("[") && value.endsWith("]")) {
value = value
.slice(1, -1)
.split(",")
.map((v) => v.trim().replace(/^["']|["']$/g, ""));
}
if (inIf && currentRule) {
currentRule.if[key] = value;
} else if (inConfig && currentRule) {
currentRule.then.config[key] = value;
} else if (inThen && currentRule) {
currentRule.then[key] = value;
}
}
}
// Push the last rule
if (currentRule) {
rules.push(currentRule);
}
return { spawn_rules: rules };
}
/**
* Evaluate a condition against current metrics
* @param {string} condition - Condition string (e.g., "> 10", "> 20%")
* @param {number} value - Current metric value
* @returns {boolean} Whether condition is met
*/
function evaluateCondition(condition, value) {
if (typeof condition !== "string") return false;
// Handle percentage conditions
const isPct = condition.includes("%");
const condStr = condition.replace("%", "").trim();
const match = condStr.match(/^([><=!]+)\s*(\d+(?:\.\d+)?)$/);
if (!match) return false;
const operator = match[1];
const threshold = parseFloat(match[2]);
switch (operator) {
case ">":
return value > threshold;
case ">=":
return value >= threshold;
case "<":
return value < threshold;
case "<=":
return value <= threshold;
case "=":
case "==":
return value === threshold;
case "!=":
return value !== threshold;
default:
return false;
}
}
/**
* Evaluate all conditions in a rule's "if" block
* @param {object} conditions - Object of condition key-value pairs
* @param {object} metrics - Current system metrics
* @returns {boolean} Whether all conditions are met
*/
function evaluateRule(conditions, metrics) {
for (const [key, condition] of Object.entries(conditions)) {
const value = metrics[key];
if (value === undefined) {
console.log(`⚠️ Metric "${key}" not found, skipping condition`);
continue;
}
if (!evaluateCondition(condition, value)) {
return false;
}
}
return true;
}
/**
* Spawn an agent based on the rule configuration
* @param {object} spawnConfig - The "then" block from a spawn rule
* @param {object} context - Additional context for spawning
* @returns {object} Spawned agent info
*/
function spawnAgent(spawnConfig, context = {}) {
const agentId = spawnConfig.spawn;
const config = spawnConfig.config || {};
const agent = {
id: `${agentId}-${Date.now()}`,
type: agentId,
role: config.role || "worker",
traits: config.traits || [],
parent: config.parent || config.inherits_from || null,
ttl: parseTTL(config.ttl),
outputs: config.outputs || [],
log_channel: config.log_channel || "agent.log",
spawned_at: new Date().toISOString(),
context,
};
console.log(`🧬 Spawning agent: ${agent.id}`);
console.log(` Role: ${agent.role}`);
console.log(` Traits: ${agent.traits.join(", ") || "none"}`);
if (agent.parent) {
console.log(` Parent: ${agent.parent}`);
}
if (agent.ttl) {
console.log(` TTL: ${config.ttl}`);
}
return agent;
}
/**
* Parse TTL string to milliseconds
* @param {string} ttl - TTL string (e.g., "14d", "24h", "7d")
* @returns {number|null} TTL in milliseconds or null
*/
function parseTTL(ttl) {
if (!ttl) return null;
const match = String(ttl).match(/^(\d+)([dhms])$/);
if (!match) return null;
const value = parseInt(match[1], 10);
const unit = match[2];
const multipliers = {
s: 1000,
m: 60 * 1000,
h: 60 * 60 * 1000,
d: 24 * 60 * 60 * 1000,
};
return value * (multipliers[unit] || 0);
}
/**
* Run the spawn rule evaluator
* @param {string} rulesPath - Path to spawn rules YAML file
* @param {object} metrics - Current system metrics
* @returns {object[]} Array of spawned agents
*/
function runSpawnEvaluator(rulesPath, metrics) {
console.log("🧠 Lucidia Spawn Runner Activated");
console.log(`📄 Loading rules from: ${rulesPath}`);
if (!fs.existsSync(rulesPath)) {
console.error(`🚫 Spawn rules file not found: ${rulesPath}`);
return [];
}
const content = fs.readFileSync(rulesPath, "utf8");
const parsed = parseSpawnRules(content);
const rules = parsed.spawn_rules || [];
console.log(`📋 Found ${rules.length} spawn rules`);
console.log(`📊 Current metrics:`, JSON.stringify(metrics, null, 2));
const spawnedAgents = [];
for (let i = 0; i < rules.length; i++) {
const rule = rules[i];
console.log(`\n🔍 Evaluating rule ${i + 1}...`);
if (evaluateRule(rule.if, metrics)) {
console.log(`✅ Rule ${i + 1} conditions met!`);
const agent = spawnAgent(rule.then, { rule_index: i, metrics });
spawnedAgents.push(agent);
} else {
console.log(`⏭️ Rule ${i + 1} conditions not met, skipping`);
}
}
console.log(`\n🧬 Total agents spawned: ${spawnedAgents.length}`);
return spawnedAgents;
}
/**
* Get default metrics (can be overridden by environment or external sources)
* @returns {object} Default metrics object
*/
function getDefaultMetrics() {
return {
escalations_last_7_days: parseInt(process.env.ESCALATIONS_LAST_7_DAYS || "0", 10),
digest_count: parseInt(process.env.DIGEST_COUNT || "0", 10),
average_blocked_pct: parseFloat(process.env.AVERAGE_BLOCKED_PCT || "0"),
created_repos_last_72h: parseInt(process.env.CREATED_REPOS_LAST_72H || "0", 10),
overdue_issues_count: parseInt(process.env.OVERDUE_ISSUES_COUNT || "0", 10),
emoji_threshold: parseInt(process.env.EMOJI_THRESHOLD || "0", 10),
emoji_type: process.env.EMOJI_TYPE || "",
};
}
// Main execution
if (require.main === module) {
const rulesPath = path.resolve(__dirname, "../lucidia.spawn-rules.yml");
const metrics = getDefaultMetrics();
const spawnedAgents = runSpawnEvaluator(rulesPath, metrics);
// Output spawned agents as JSON for downstream processing
if (spawnedAgents.length > 0) {
const outputPath = path.resolve(__dirname, "../spawned-agents.json");
fs.writeFileSync(outputPath, JSON.stringify(spawnedAgents, null, 2));
console.log(`\n📤 Spawned agents written to: ${outputPath}`);
}
}
// Export functions for testing and external use
module.exports = {
parseSpawnRules,
evaluateCondition,
evaluateRule,
spawnAgent,
parseTTL,
runSpawnEvaluator,
getDefaultMetrics,
};

View File

@@ -1,5 +0,0 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { StatusPill } from "./StatusPill";
export function EnvCard({ env }) {
return (_jsxs("div", { className: "env-card", children: [_jsx("div", { className: "env-region", style: { textTransform: "uppercase", fontSize: "0.85rem" }, children: env.region }), _jsx("h2", { children: env.name }), _jsxs("div", { children: ["Env ID: ", env.id] }), _jsx(StatusPill, { status: env.status })] }));
}

View File

@@ -1,10 +0,0 @@
import { jsx as _jsx } from "react/jsx-runtime";
const statusConfig = {
healthy: { label: "Healthy", className: "status-pill status-pill--healthy" },
degraded: { label: "Degraded", className: "status-pill status-pill--degraded" },
down: { label: "Down", className: "status-pill status-pill--down" }
};
export function StatusPill({ status }) {
const config = statusConfig[status];
return _jsx("span", { className: config.className, children: config.label });
}

View File

@@ -1,18 +0,0 @@
const mockEnvironments = [
{ id: "env_1", name: "Development", region: "us-east-1", status: "healthy" },
{ id: "env_2", name: "Staging", region: "eu-west-1", status: "degraded" }
];
export async function getEnvironments() {
return mockEnvironments;
}
export async function getEnvById(id) {
return mockEnvironments.find((env) => env.id === id);
}
export async function getHealth() {
return { status: "ok", uptime: process.uptime() };
}
export async function getVersion() {
const version = process.env.APP_VERSION || "1.0.0";
const commit = process.env.APP_COMMIT || "unknown";
return { version, commit };
}

52
lucidia.spawn-rules.yml Normal file
View File

@@ -0,0 +1,52 @@
# 🧬 lucidia.spawn-rules.yml
# Auto-spawn rules for agent replication based on system conditions
spawn_rules:
- if:
escalations_last_7_days: "> 10"
then:
spawn: guardian-clone-agent
config:
role: sentinel
traits: ["incident-response", "human-routing"]
parent: guardian-agent
ttl: 14d
log_channel: escalations.log
- if:
digest_count: "> 4"
average_blocked_pct: "> 20%"
then:
spawn: digest-auditor-agent
config:
role: analyst
traits: ["summary-check", "anomaly-detector"]
inherits_from: codex-digest-agent
- if:
created_repos_last_72h: "> 5"
then:
spawn: repo-mapper-agent
config:
role: indexer
outputs: ["org-registry.json", "repo-trees.mdx"]
- if:
overdue_issues_count: "> 20"
then:
spawn: overdue-cleaner-agent
config:
role: sweeper
traits: ["issue-triage", "stale-detection"]
ttl: 7d
- if:
emoji_threshold: "> 50"
emoji_type: "🛟"
then:
spawn: escalation-swarm-agent
config:
role: sentinel
traits: ["batch-escalation", "priority-router"]
parent: guardian-agent
ttl: 24h

View File

@@ -1,8 +0,0 @@
import express from "express";
import { createMetaRouter } from "./routes/meta";
export function createApp() {
const app = express();
app.use(express.json());
app.use("/internal", createMetaRouter());
return app;
}

View File

@@ -1,23 +0,0 @@
import cron from "node-cron";
import { Queue } from "bullmq";
export function buildHeartbeatQueue(connection = { host: "localhost", port: 6379 }) {
return new Queue("heartbeat", { connection });
}
let defaultQueue = null;
function getDefaultQueue() {
if (!defaultQueue) {
defaultQueue = buildHeartbeatQueue();
}
return defaultQueue;
}
export async function enqueueHeartbeat(queue = getDefaultQueue()) {
const payload = { ts: Date.now() };
await queue.add("heartbeat", payload);
return payload;
}
export function startHeartbeatScheduler(queue = getDefaultQueue()) {
const task = cron.schedule("*/5 * * * *", () => {
enqueueHeartbeat(queue);
});
return task;
}

View File

@@ -1,23 +0,0 @@
import Fastify from "fastify";
import { getBuildInfo } from "./utils/buildInfo";
export async function createServer() {
const server = Fastify({ logger: true });
server.get("/health", async () => ({ status: "ok" }));
server.get("/version", async () => {
const info = getBuildInfo();
return { version: info.version, commit: info.commit };
});
return server;
}
if (require.main === module) {
const port = Number(process.env.PORT || 3000);
createServer()
.then((server) => server.listen({ port, host: "0.0.0.0" }))
.then((address) => {
console.log(`Server listening at ${address}`);
})
.catch((err) => {
console.error(err);
process.exit(1);
});
}

View File

@@ -1,11 +0,0 @@
import { Worker } from "bullmq";
export function registerSampleJobProcessor(connection = { host: "localhost", port: 6379 }) {
const worker = new Worker("sample", async (job) => {
console.log(`Processing job ${job.id}`);
return job.data;
}, { connection });
worker.on("failed", (job, err) => {
console.error(`Job ${job?.id} failed`, err);
});
return worker;
}

View File

@@ -1,13 +0,0 @@
import { Router } from "express";
import { getBuildInfo } from "../utils/buildInfo";
export function createMetaRouter() {
const router = Router();
router.get("/health", (_req, res) => {
res.json({ status: "ok" });
});
router.get("/version", (_req, res) => {
const info = getBuildInfo();
res.json({ version: info.version, commit: info.commit });
});
return router;
}

View File

@@ -1 +0,0 @@
export {};

View File

@@ -1,15 +0,0 @@
import * as childProcess from "child_process";
export function readGitCommit() {
try {
return childProcess.execSync("git rev-parse HEAD", { stdio: "pipe" }).toString().trim();
}
catch {
return undefined;
}
}
export function getBuildInfo(gitReader = readGitCommit) {
const version = process.env.APP_VERSION || "1.0.0";
const commit = process.env.APP_COMMIT || gitReader() || "unknown";
const buildTime = new Date().toISOString();
return { version, commit, buildTime };
}

View File

@@ -1,36 +0,0 @@
import request from "supertest";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { createApp } from "../src/app";
import { createServer } from "../src/index";
vi.mock("../src/utils/buildInfo", () => ({
getBuildInfo: () => ({ version: "test-version", commit: "test-commit", buildTime: "now" })
}));
describe("Express internal routes", () => {
const app = createApp();
it("returns health", async () => {
const response = await request(app).get("/internal/health");
expect(response.status).toBe(200);
expect(response.body).toEqual({ status: "ok" });
});
it("returns version", async () => {
const response = await request(app).get("/internal/version");
expect(response.status).toBe(200);
expect(response.body).toEqual({ version: "test-version", commit: "test-commit" });
});
});
describe("Fastify public routes", () => {
let server;
beforeEach(async () => {
server = await createServer();
});
it("returns health", async () => {
const response = await server.inject({ method: "GET", url: "/health" });
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ status: "ok" });
});
it("returns version", async () => {
const response = await server.inject({ method: "GET", url: "/version" });
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ version: "test-version", commit: "test-commit" });
});
});

View File

@@ -1,23 +0,0 @@
import { describe, expect, it, vi, afterEach } from "vitest";
import { getBuildInfo } from "../src/utils/buildInfo";
const originalEnv = { ...process.env };
afterEach(() => {
process.env = { ...originalEnv };
vi.restoreAllMocks();
});
describe("getBuildInfo", () => {
it("uses env vars when provided", () => {
process.env.APP_VERSION = "3.0.0";
process.env.APP_COMMIT = "xyz";
const info = getBuildInfo();
expect(info.version).toBe("3.0.0");
expect(info.commit).toBe("xyz");
expect(new Date(info.buildTime).toString()).not.toBe("Invalid Date");
});
it("falls back to git when env missing", () => {
const gitReader = vi.fn().mockReturnValue("abcdef");
delete process.env.APP_COMMIT;
const info = getBuildInfo(gitReader);
expect(info.commit).toBe("abcdef");
});
});

View File

@@ -1,17 +0,0 @@
import { jsx as _jsx } from "react/jsx-runtime";
import { render, screen } from "@testing-library/react";
import { EnvCard } from "../components/EnvCard";
describe("EnvCard", () => {
const env = {
id: "env_123",
name: "Production",
region: "us-west-2",
status: "healthy"
};
it("renders name, region, and id", () => {
render(_jsx(EnvCard, { env: env }));
expect(screen.getByText(env.region)).toBeInTheDocument();
expect(screen.getByText(env.name)).toBeInTheDocument();
expect(screen.getByText(`Env ID: ${env.id}`)).toBeInTheDocument();
});
});

View File

@@ -1,29 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { getEnvironments, getEnvById, getHealth, getVersion } from "../lib/fetcher";
const originalEnv = { ...process.env };
afterEach(() => {
process.env = { ...originalEnv };
});
describe("fetcher", () => {
it("returns mock environments", async () => {
const envs = await getEnvironments();
expect(envs).toHaveLength(2);
expect(envs[0]).toEqual(expect.objectContaining({ id: "env_1", name: "Development", region: "us-east-1" }));
});
it("returns environment by id", async () => {
const env = await getEnvById("env_2");
expect(env?.name).toBe("Staging");
expect(await getEnvById("missing"))?.toBeUndefined();
});
it("returns health with uptime", async () => {
vi.spyOn(process, "uptime").mockReturnValue(42);
const health = await getHealth();
expect(health).toEqual({ status: "ok", uptime: 42 });
});
it("returns version info", async () => {
process.env.APP_VERSION = "2.0.0";
process.env.APP_COMMIT = "abc123";
const info = await getVersion();
expect(info).toEqual({ version: "2.0.0", commit: "abc123" });
});
});

View File

@@ -1,23 +0,0 @@
import { vi, describe, it, expect, beforeEach } from "vitest";
import cron from "node-cron";
import { startHeartbeatScheduler } from "../src/heartbeat";
vi.mock("node-cron", () => {
return {
default: {
schedule: vi.fn((expression, callback) => ({ fireOnTick: callback, expression }))
}
};
});
describe("startHeartbeatScheduler", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("schedules heartbeat every five minutes and enqueues payload", async () => {
const add = vi.fn();
const task = startHeartbeatScheduler({ add });
expect(cron.schedule).toHaveBeenCalledWith("*/5 * * * *", expect.any(Function));
// fire the cron callback
task.fireOnTick();
expect(add).toHaveBeenCalledWith("heartbeat", expect.objectContaining({ ts: expect.any(Number) }));
});
});

View File

@@ -1,18 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { GET as health } from "../app/api/health/route";
import { GET as version } from "../app/api/version/route";
vi.mock("../src/utils/buildInfo", () => ({
getBuildInfo: () => ({ version: "api-version", commit: "api-commit", buildTime: "now" })
}));
describe("Next API routes", () => {
it("returns health response", async () => {
const res = await health();
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ status: "ok" });
});
it("returns version response", async () => {
const res = await version();
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ version: "api-version", commit: "api-commit" });
});
});

View File

@@ -1,31 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { registerSampleJobProcessor } from "../src/jobs/sample.job";
vi.mock("bullmq", () => {
class MockWorker {
constructor(_name, processor, _opts) {
this.handlers = {};
this.processor = processor;
}
on(event, handler) {
this.handlers[event] = this.handlers[event] || [];
this.handlers[event].push(handler);
}
}
return { Worker: MockWorker };
});
describe("registerSampleJobProcessor", () => {
it("registers worker and handlers", () => {
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => { });
const consoleError = vi.spyOn(console, "error").mockImplementation(() => { });
const worker = registerSampleJobProcessor({ host: "localhost", port: 6379 });
expect(worker.processor).toBeInstanceOf(Function);
expect(worker.handlers.failed).toHaveLength(1);
// simulate processing and failure
worker.processor({ id: 1, data: { hello: "world" } });
worker.handlers.failed[0]({ id: 1 }, new Error("boom"));
expect(consoleLog).toHaveBeenCalledWith("Processing job 1");
expect(consoleError).toHaveBeenCalled();
consoleLog.mockRestore();
consoleError.mockRestore();
});
});

397
tests/spawnRunner.test.ts Normal file
View File

@@ -0,0 +1,397 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import path from "path";
import fs from "fs";
// Import the module functions
const spawnRunner = require("../bot/lucidia-spawn-runner.js");
describe("lucidia-spawn-runner", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("parseTTL", () => {
it("parses days correctly", () => {
expect(spawnRunner.parseTTL("14d")).toBe(14 * 24 * 60 * 60 * 1000);
expect(spawnRunner.parseTTL("7d")).toBe(7 * 24 * 60 * 60 * 1000);
});
it("parses hours correctly", () => {
expect(spawnRunner.parseTTL("24h")).toBe(24 * 60 * 60 * 1000);
expect(spawnRunner.parseTTL("1h")).toBe(60 * 60 * 1000);
});
it("parses minutes correctly", () => {
expect(spawnRunner.parseTTL("30m")).toBe(30 * 60 * 1000);
});
it("parses seconds correctly", () => {
expect(spawnRunner.parseTTL("60s")).toBe(60 * 1000);
});
it("returns null for invalid TTL", () => {
expect(spawnRunner.parseTTL(null)).toBeNull();
expect(spawnRunner.parseTTL("invalid")).toBeNull();
expect(spawnRunner.parseTTL("")).toBeNull();
});
});
describe("evaluateCondition", () => {
it("evaluates > condition correctly", () => {
expect(spawnRunner.evaluateCondition("> 10", 15)).toBe(true);
expect(spawnRunner.evaluateCondition("> 10", 10)).toBe(false);
expect(spawnRunner.evaluateCondition("> 10", 5)).toBe(false);
});
it("evaluates >= condition correctly", () => {
expect(spawnRunner.evaluateCondition(">= 10", 15)).toBe(true);
expect(spawnRunner.evaluateCondition(">= 10", 10)).toBe(true);
expect(spawnRunner.evaluateCondition(">= 10", 5)).toBe(false);
});
it("evaluates < condition correctly", () => {
expect(spawnRunner.evaluateCondition("< 10", 5)).toBe(true);
expect(spawnRunner.evaluateCondition("< 10", 10)).toBe(false);
});
it("evaluates <= condition correctly", () => {
expect(spawnRunner.evaluateCondition("<= 10", 5)).toBe(true);
expect(spawnRunner.evaluateCondition("<= 10", 10)).toBe(true);
expect(spawnRunner.evaluateCondition("<= 10", 15)).toBe(false);
});
it("evaluates = and == condition correctly", () => {
expect(spawnRunner.evaluateCondition("= 10", 10)).toBe(true);
expect(spawnRunner.evaluateCondition("== 10", 10)).toBe(true);
expect(spawnRunner.evaluateCondition("= 10", 5)).toBe(false);
});
it("evaluates != condition correctly", () => {
expect(spawnRunner.evaluateCondition("!= 10", 5)).toBe(true);
expect(spawnRunner.evaluateCondition("!= 10", 10)).toBe(false);
});
it("handles percentage conditions", () => {
expect(spawnRunner.evaluateCondition("> 20%", 25)).toBe(true);
expect(spawnRunner.evaluateCondition("> 20%", 15)).toBe(false);
});
it("returns false for non-string condition", () => {
expect(spawnRunner.evaluateCondition(null, 10)).toBe(false);
expect(spawnRunner.evaluateCondition(123, 10)).toBe(false);
});
it("returns false for invalid condition format", () => {
expect(spawnRunner.evaluateCondition("invalid", 10)).toBe(false);
});
});
describe("evaluateRule", () => {
it("returns true when all conditions are met", () => {
const conditions = {
escalations_last_7_days: "> 10",
};
const metrics = {
escalations_last_7_days: 15,
};
expect(spawnRunner.evaluateRule(conditions, metrics)).toBe(true);
});
it("returns false when any condition is not met", () => {
const conditions = {
escalations_last_7_days: "> 10",
digest_count: "> 5",
};
const metrics = {
escalations_last_7_days: 15,
digest_count: 3,
};
expect(spawnRunner.evaluateRule(conditions, metrics)).toBe(false);
});
it("returns true when multiple conditions are all met", () => {
const conditions = {
digest_count: "> 4",
average_blocked_pct: "> 20%",
};
const metrics = {
digest_count: 6,
average_blocked_pct: 25,
};
expect(spawnRunner.evaluateRule(conditions, metrics)).toBe(true);
});
it("continues evaluation when metric is missing", () => {
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const conditions = {
unknown_metric: "> 10",
known_metric: "> 5",
};
const metrics = {
known_metric: 10,
};
expect(spawnRunner.evaluateRule(conditions, metrics)).toBe(true);
consoleSpy.mockRestore();
});
});
describe("spawnAgent", () => {
it("creates an agent with correct properties", () => {
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const spawnConfig = {
spawn: "guardian-clone-agent",
config: {
role: "sentinel",
traits: ["incident-response", "human-routing"],
parent: "guardian-agent",
ttl: "14d",
log_channel: "escalations.log",
},
};
const agent = spawnRunner.spawnAgent(spawnConfig);
expect(agent.type).toBe("guardian-clone-agent");
expect(agent.role).toBe("sentinel");
expect(agent.traits).toEqual(["incident-response", "human-routing"]);
expect(agent.parent).toBe("guardian-agent");
expect(agent.ttl).toBe(14 * 24 * 60 * 60 * 1000);
expect(agent.log_channel).toBe("escalations.log");
expect(agent.spawned_at).toBeDefined();
expect(agent.id).toContain("guardian-clone-agent-");
consoleSpy.mockRestore();
});
it("uses inherits_from when parent is not specified", () => {
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const spawnConfig = {
spawn: "digest-auditor-agent",
config: {
role: "analyst",
inherits_from: "codex-digest-agent",
},
};
const agent = spawnRunner.spawnAgent(spawnConfig);
expect(agent.parent).toBe("codex-digest-agent");
consoleSpy.mockRestore();
});
it("handles missing config gracefully", () => {
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const spawnConfig = {
spawn: "simple-agent",
};
const agent = spawnRunner.spawnAgent(spawnConfig);
expect(agent.type).toBe("simple-agent");
expect(agent.role).toBe("worker");
expect(agent.traits).toEqual([]);
expect(agent.parent).toBeNull();
consoleSpy.mockRestore();
});
});
describe("parseSpawnRules", () => {
it("parses a simple spawn rule", () => {
const yaml = `
spawn_rules:
- if:
escalations_last_7_days: "> 10"
then:
spawn: guardian-clone-agent
config:
role: sentinel
ttl: 14d
`;
const result = spawnRunner.parseSpawnRules(yaml);
expect(result.spawn_rules).toHaveLength(1);
expect(result.spawn_rules[0].if.escalations_last_7_days).toBe("> 10");
expect(result.spawn_rules[0].then.spawn).toBe("guardian-clone-agent");
expect(result.spawn_rules[0].then.config.role).toBe("sentinel");
expect(result.spawn_rules[0].then.config.ttl).toBe("14d");
});
it("parses multiple spawn rules", () => {
const yaml = `
spawn_rules:
- if:
escalations_last_7_days: "> 10"
then:
spawn: guardian-clone-agent
- if:
digest_count: "> 4"
then:
spawn: digest-auditor-agent
`;
const result = spawnRunner.parseSpawnRules(yaml);
expect(result.spawn_rules).toHaveLength(2);
expect(result.spawn_rules[0].then.spawn).toBe("guardian-clone-agent");
expect(result.spawn_rules[1].then.spawn).toBe("digest-auditor-agent");
});
it("handles array values in config", () => {
const yaml = `
spawn_rules:
- if:
created_repos_last_72h: "> 5"
then:
spawn: repo-mapper-agent
config:
outputs: ["org-registry.json", "repo-trees.mdx"]
`;
const result = spawnRunner.parseSpawnRules(yaml);
expect(result.spawn_rules[0].then.config.outputs).toEqual([
"org-registry.json",
"repo-trees.mdx",
]);
});
it("skips comments and empty lines", () => {
const yaml = `
# This is a comment
spawn_rules:
- if:
escalations_last_7_days: "> 10"
then:
spawn: guardian-clone-agent
`;
const result = spawnRunner.parseSpawnRules(yaml);
expect(result.spawn_rules).toHaveLength(1);
});
});
describe("runSpawnEvaluator", () => {
it("returns empty array when rules file does not exist", () => {
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const result = spawnRunner.runSpawnEvaluator("/nonexistent/path.yml", {});
expect(result).toEqual([]);
expect(consoleErrorSpy).toHaveBeenCalledWith(
"🚫 Spawn rules file not found: /nonexistent/path.yml"
);
consoleSpy.mockRestore();
consoleErrorSpy.mockRestore();
});
it("spawns agents when conditions are met", () => {
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
// Create a temp file with test spawn rules
const tempPath = "/tmp/test-spawn-rules.yml";
fs.writeFileSync(tempPath, `
spawn_rules:
- if:
escalations_last_7_days: "> 10"
then:
spawn: guardian-clone-agent
config:
role: sentinel
`);
const metrics = {
escalations_last_7_days: 15,
};
const result = spawnRunner.runSpawnEvaluator(tempPath, metrics);
expect(result).toHaveLength(1);
expect(result[0].type).toBe("guardian-clone-agent");
expect(result[0].role).toBe("sentinel");
// Cleanup
fs.unlinkSync(tempPath);
consoleSpy.mockRestore();
});
it("does not spawn agents when conditions are not met", () => {
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
// Create a temp file with test spawn rules
const tempPath = "/tmp/test-spawn-rules-2.yml";
fs.writeFileSync(tempPath, `
spawn_rules:
- if:
escalations_last_7_days: "> 10"
then:
spawn: guardian-clone-agent
`);
const metrics = {
escalations_last_7_days: 5,
};
const result = spawnRunner.runSpawnEvaluator(tempPath, metrics);
expect(result).toHaveLength(0);
// Cleanup
fs.unlinkSync(tempPath);
consoleSpy.mockRestore();
});
});
describe("getDefaultMetrics", () => {
it("returns metrics from environment variables", () => {
const originalEnv = process.env;
process.env = {
...originalEnv,
ESCALATIONS_LAST_7_DAYS: "12",
DIGEST_COUNT: "5",
AVERAGE_BLOCKED_PCT: "25.5",
CREATED_REPOS_LAST_72H: "8",
OVERDUE_ISSUES_COUNT: "30",
EMOJI_THRESHOLD: "60",
EMOJI_TYPE: "🛟",
};
const metrics = spawnRunner.getDefaultMetrics();
expect(metrics.escalations_last_7_days).toBe(12);
expect(metrics.digest_count).toBe(5);
expect(metrics.average_blocked_pct).toBe(25.5);
expect(metrics.created_repos_last_72h).toBe(8);
expect(metrics.overdue_issues_count).toBe(30);
expect(metrics.emoji_threshold).toBe(60);
expect(metrics.emoji_type).toBe("🛟");
process.env = originalEnv;
});
it("returns default zero values when env vars are not set", () => {
const originalEnv = process.env;
process.env = { ...originalEnv };
delete process.env.ESCALATIONS_LAST_7_DAYS;
delete process.env.DIGEST_COUNT;
delete process.env.AVERAGE_BLOCKED_PCT;
delete process.env.CREATED_REPOS_LAST_72H;
delete process.env.OVERDUE_ISSUES_COUNT;
delete process.env.EMOJI_THRESHOLD;
delete process.env.EMOJI_TYPE;
const metrics = spawnRunner.getDefaultMetrics();
expect(metrics.escalations_last_7_days).toBe(0);
expect(metrics.digest_count).toBe(0);
expect(metrics.average_blocked_pct).toBe(0);
expect(metrics.created_repos_last_72h).toBe(0);
expect(metrics.overdue_issues_count).toBe(0);
expect(metrics.emoji_threshold).toBe(0);
expect(metrics.emoji_type).toBe("");
process.env = originalEnv;
});
});
});

View File

@@ -1,16 +0,0 @@
import { jsx as _jsx } from "react/jsx-runtime";
import { render, screen } from "@testing-library/react";
import { StatusPill } from "../components/StatusPill";
describe("StatusPill", () => {
const cases = [
["healthy", "Healthy", "status-pill--healthy"],
["degraded", "Degraded", "status-pill--degraded"],
["down", "Down", "status-pill--down"]
];
it.each(cases)("renders %s status", (status, label, className) => {
render(_jsx(StatusPill, { status: status }));
const pill = screen.getByText(label);
expect(pill).toBeInTheDocument();
expect(pill.className).toContain(className);
});
});

View File

@@ -1,10 +0,0 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
setupFiles: "./vitest.setup.ts",
globals: true
}
});