Merge branch origin/copilot/add-slack-discord-notification-agent into main

This commit is contained in:
Alexa Amundson
2025-11-25 13:49:49 -06:00
4 changed files with 347 additions and 0 deletions

85
.github/workflows/spawn-runner.yml vendored Normal file
View File

@@ -0,0 +1,85 @@
name: 🧬 Spawn Runner Lucidia Agent Dispatcher
on:
workflow_dispatch:
inputs:
agent_name:
description: "Agent name/identifier (e.g., guardian-clone-vault)"
required: true
type: string
purpose:
description: "Purpose of the spawned agent"
required: true
type: string
summary:
description: "Summary of why the agent was spawned"
required: true
type: string
ttl:
description: "Time-to-live for the agent (e.g., 96h)"
required: false
default: "96h"
type: string
awaiting_approval:
description: "User to await approval from"
required: false
default: "alexa"
type: string
permissions:
contents: read
jobs:
spawn-agent:
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: 📦 Install Dependencies
run: npm ci
- name: 🚀 Execute Spawn Runner
id: spawn
run: |
echo "Spawning agent: ${{ inputs.agent_name }}"
echo "Purpose: ${{ inputs.purpose }}"
echo "Summary: ${{ inputs.summary }}"
echo "TTL: ${{ inputs.ttl }}"
echo "Awaiting approval from: @${{ inputs.awaiting_approval }}"
- name: 📢 Notify Discord/Slack
if: ${{ secrets.LUCIDIA_WEBHOOK != '' }}
env:
LUCIDIA_WEBHOOK: ${{ secrets.LUCIDIA_WEBHOOK }}
run: |
# Build JSON payload safely using jq to prevent shell injection
REPO_URL="${{ github.server_url }}/${{ github.repository }}"
RUN_URL="${REPO_URL}/actions/runs/${{ github.run_id }}"
MESSAGE=$(cat <<EOF
🚀 Lucidia has spawned a new agent!
🔗 Run: ${RUN_URL}
🧬 Agent: ${{ inputs.agent_name }}
📦 Purpose: ${{ inputs.purpose }}
💬 Summary: ${{ inputs.summary }}
⏱️ TTL: ${{ inputs.ttl }}
👁️ Awaiting approval from @${{ inputs.awaiting_approval }}
EOF
)
# Use jq to safely construct JSON payload
PAYLOAD=$(jq -n --arg content "$MESSAGE" --arg text "$MESSAGE" \
'{content: $content, text: $text}')
curl -X POST \
-H "Content-Type: application/json" \
-d "$PAYLOAD" \
"$LUCIDIA_WEBHOOK"
echo "✅ Notification sent to webhook"

82
src/notify/index.ts Normal file
View File

@@ -0,0 +1,82 @@
/**
* Lucidia Agent PR Notification Module
*
* Sends notifications to Discord or Slack when a new agent PR is spawned.
*/
import type { AgentPRPayload, WebhookResponse, NotifyOptions } from "./types";
/**
* Formats the notification message for Discord/Slack
*/
export function formatNotificationMessage(payload: AgentPRPayload): string {
return [
`🚀 Lucidia has opened a new auto-generated PR:`,
`🔗 ${payload.prURL}`,
`🧬 Agent: ${payload.agentName}`,
`📦 Purpose: ${payload.purpose}`,
`💬 Summary: ${payload.summary}`,
`⏱️ TTL: ${payload.ttl}`,
`👁️ Awaiting approval from @${payload.awaitingApproval}`
].join("\n");
}
/**
* Builds the webhook payload for Discord/Slack
* Works with both Discord and Slack incoming webhooks
*/
export function buildWebhookPayload(payload: AgentPRPayload): { content: string; text?: string } {
const content = formatNotificationMessage(payload);
return {
content, // Discord format
text: content // Slack format (for compatibility)
};
}
/**
* Sends a notification to the configured Discord/Slack webhook
*/
export async function sendAgentPRNotification(options: NotifyOptions): Promise<WebhookResponse> {
const { webhookURL, payload } = options;
if (!webhookURL) {
return {
success: false,
error: "No webhook URL provided. Set LUCIDIA_WEBHOOK environment variable."
};
}
const webhookPayload = buildWebhookPayload(payload);
try {
const response = await fetch(webhookURL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(webhookPayload)
});
if (!response.ok) {
return {
success: false,
error: `Webhook request failed with status: ${response.status}`
};
}
return { success: true };
} catch (error) {
return {
success: false,
error: `Failed to send notification: ${error instanceof Error ? error.message : String(error)}`
};
}
}
/**
* Convenience function to notify from environment configuration
*/
export async function notifyAgentPR(payload: AgentPRPayload): Promise<WebhookResponse> {
const webhookURL = process.env.LUCIDIA_WEBHOOK || "";
return sendAgentPRNotification({ webhookURL, payload });
}
export type { AgentPRPayload, WebhookResponse, NotifyOptions } from "./types";

30
src/notify/types.ts Normal file
View File

@@ -0,0 +1,30 @@
/**
* Types for Lucidia Agent PR Notification System
*/
export interface AgentPRPayload {
/** The full URL to the PR */
prURL: string;
/** The agent name/identifier (e.g., "guardian-clone-vault") */
agentName: string;
/** Purpose of the spawned agent */
purpose: string;
/** Summary of why the agent was spawned */
summary: string;
/** Time-to-live for the agent (e.g., "96h") */
ttl: string;
/** User to await approval from */
awaitingApproval: string;
}
export interface WebhookResponse {
success: boolean;
error?: string;
}
export interface NotifyOptions {
/** The webhook URL for Discord or Slack */
webhookURL: string;
/** The payload containing PR information */
payload: AgentPRPayload;
}

150
tests/notify.test.ts Normal file
View File

@@ -0,0 +1,150 @@
import { vi, describe, it, expect, beforeEach, afterEach } from "vitest";
import {
formatNotificationMessage,
buildWebhookPayload,
sendAgentPRNotification,
notifyAgentPR
} from "../src/notify";
import type { AgentPRPayload } from "../src/notify/types";
const mockPayload: AgentPRPayload = {
prURL: "https://github.com/BlackRoad-OS/blackroad-os/pull/123",
agentName: "guardian-clone-vault",
purpose: "Sentinel overflow instance (TTL: 96h)",
summary: "Spawned due to 18 escalations in past 72h",
ttl: "96h",
awaitingApproval: "alexa"
};
describe("formatNotificationMessage", () => {
it("formats the message correctly with all fields", () => {
const message = formatNotificationMessage(mockPayload);
expect(message).toContain("🚀 Lucidia has opened a new auto-generated PR:");
expect(message).toContain("🔗 https://github.com/BlackRoad-OS/blackroad-os/pull/123");
expect(message).toContain("🧬 Agent: guardian-clone-vault");
expect(message).toContain("📦 Purpose: Sentinel overflow instance (TTL: 96h)");
expect(message).toContain("💬 Summary: Spawned due to 18 escalations in past 72h");
expect(message).toContain("⏱️ TTL: 96h");
expect(message).toContain("👁️ Awaiting approval from @alexa");
});
});
describe("buildWebhookPayload", () => {
it("builds payload with both content and text fields", () => {
const webhookPayload = buildWebhookPayload(mockPayload);
expect(webhookPayload.content).toBeDefined();
expect(webhookPayload.text).toBeDefined();
expect(webhookPayload.content).toBe(webhookPayload.text);
expect(webhookPayload.content).toContain("guardian-clone-vault");
});
});
describe("sendAgentPRNotification", () => {
const originalFetch = global.fetch;
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
global.fetch = originalFetch;
});
it("returns error when no webhook URL is provided", async () => {
const result = await sendAgentPRNotification({
webhookURL: "",
payload: mockPayload
});
expect(result.success).toBe(false);
expect(result.error).toContain("No webhook URL provided");
});
it("sends notification successfully", async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200
});
const result = await sendAgentPRNotification({
webhookURL: "https://discord.com/api/webhooks/test",
payload: mockPayload
});
expect(result.success).toBe(true);
expect(result.error).toBeUndefined();
expect(global.fetch).toHaveBeenCalledWith(
"https://discord.com/api/webhooks/test",
expect.objectContaining({
method: "POST",
headers: { "Content-Type": "application/json" }
})
);
});
it("handles webhook request failure", async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 500
});
const result = await sendAgentPRNotification({
webhookURL: "https://discord.com/api/webhooks/test",
payload: mockPayload
});
expect(result.success).toBe(false);
expect(result.error).toContain("status: 500");
});
it("handles network errors", async () => {
global.fetch = vi.fn().mockRejectedValue(new Error("Network error"));
const result = await sendAgentPRNotification({
webhookURL: "https://discord.com/api/webhooks/test",
payload: mockPayload
});
expect(result.success).toBe(false);
expect(result.error).toContain("Network error");
});
});
describe("notifyAgentPR", () => {
const originalEnv = process.env;
const originalFetch = global.fetch;
beforeEach(() => {
vi.clearAllMocks();
process.env = { ...originalEnv };
});
afterEach(() => {
process.env = originalEnv;
global.fetch = originalFetch;
});
it("uses LUCIDIA_WEBHOOK from environment", async () => {
process.env.LUCIDIA_WEBHOOK = "https://slack.com/webhook/test";
global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200 });
const result = await notifyAgentPR(mockPayload);
expect(result.success).toBe(true);
expect(global.fetch).toHaveBeenCalledWith(
"https://slack.com/webhook/test",
expect.any(Object)
);
});
it("returns error when LUCIDIA_WEBHOOK is not set", async () => {
delete process.env.LUCIDIA_WEBHOOK;
const result = await notifyAgentPR(mockPayload);
expect(result.success).toBe(false);
expect(result.error).toContain("No webhook URL provided");
});
});