Merge branch origin/copilot/add-slack-discord-notification-agent into main
This commit is contained in:
85
.github/workflows/spawn-runner.yml
vendored
Normal file
85
.github/workflows/spawn-runner.yml
vendored
Normal 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
82
src/notify/index.ts
Normal 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
30
src/notify/types.ts
Normal 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
150
tests/notify.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user