Add Lucidia auto-spawn rules DSL and spawn runner
Co-authored-by: blackboxprogramming <118287761+blackboxprogramming@users.noreply.github.com>
This commit is contained in:
68
.github/workflows/lucidia-spawn-runner.yml
vendored
Normal file
68
.github/workflows/lucidia-spawn-runner.yml
vendored
Normal 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
7
.gitignore
vendored
@@ -1,3 +1,10 @@
|
|||||||
node_modules
|
node_modules
|
||||||
coverage
|
coverage
|
||||||
|
|
||||||
|
# TypeScript build output
|
||||||
|
*.js
|
||||||
|
!bot/*.js
|
||||||
|
!bot/**/*.js
|
||||||
|
|
||||||
|
# Generated output
|
||||||
|
spawned-agents.json
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
export async function GET() {
|
|
||||||
return Response.json({ status: "ok" });
|
|
||||||
}
|
|
||||||
@@ -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
291
bot/lucidia-spawn-runner.js
Normal 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,
|
||||||
|
};
|
||||||
@@ -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 })] }));
|
|
||||||
}
|
|
||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
@@ -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
52
lucidia.spawn-rules.yml
Normal 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
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
23
src/index.js
23
src/index.js
@@ -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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export {};
|
|
||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
@@ -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" });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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" });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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) }));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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" });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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
397
tests/spawnRunner.test.ts
Normal 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;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user