diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index ef4feac81..8ff773d64 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -92,8 +92,8 @@ const OrchestratorConfigSchema = z.object({ projects: z.record(ProjectConfigSchema), notifiers: z.record(NotifierConfigSchema).default({}), notificationRouting: z.record(z.array(z.string())).default({ - urgent: ["desktop", "composio"], - action: ["desktop", "composio"], + urgent: ["desktop", "composio", "openclaw"], + action: ["desktop", "composio", "openclaw"], warning: ["composio"], info: ["composio"], }), diff --git a/packages/plugins/notifier-openclaw/package.json b/packages/plugins/notifier-openclaw/package.json new file mode 100644 index 000000000..62e0187d2 --- /dev/null +++ b/packages/plugins/notifier-openclaw/package.json @@ -0,0 +1,44 @@ +{ + "name": "@composio/ao-plugin-notifier-openclaw", + "version": "0.1.0", + "description": "Notifier plugin: OpenClaw gateway", + "license": "MIT", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "repository": { + "type": "git", + "url": "https://github.com/ComposioHQ/agent-orchestrator.git", + "directory": "packages/plugins/notifier-openclaw" + }, + "homepage": "https://github.com/ComposioHQ/agent-orchestrator", + "bugs": { + "url": "https://github.com/ComposioHQ/agent-orchestrator/issues" + }, + "engines": { + "node": ">=20.0.0" + }, + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "clean": "rm -rf dist" + }, + "dependencies": { + "@composio/ao-core": "workspace:*" + }, + "devDependencies": { + "@types/node": "^25.2.3", + "typescript": "^5.7.0", + "vitest": "^3.0.0" + } +} diff --git a/packages/plugins/notifier-openclaw/src/index.test.ts b/packages/plugins/notifier-openclaw/src/index.test.ts new file mode 100644 index 000000000..7a2713964 --- /dev/null +++ b/packages/plugins/notifier-openclaw/src/index.test.ts @@ -0,0 +1,264 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { OrchestratorEvent } from "@composio/ao-core"; +import { manifest, create } from "./index.js"; + +function makeEvent(overrides: Partial = {}): OrchestratorEvent { + return { + id: "evt-1", + type: "ci.failing", + priority: "action", + sessionId: "app-1", + projectId: "my-project", + timestamp: new Date("2025-06-15T12:00:00Z"), + message: "CI check failed on app-1", + data: { branch: "feat/add-login", status: "failing" }, + ...overrides, + }; +} + +describe("notifier-openclaw", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + describe("manifest", () => { + it("has correct metadata", () => { + expect(manifest.name).toBe("openclaw"); + expect(manifest.slot).toBe("notifier"); + expect(manifest.version).toBe("0.1.0"); + }); + }); + + describe("create", () => { + it("returns a notifier with name 'openclaw'", () => { + const notifier = create({ host: "localhost", port: 8080, token: "tok123" }); + expect(notifier.name).toBe("openclaw"); + }); + + it("warns when no token configured", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + create(); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("No token configured")); + }); + + it("uses default host and port when not provided", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ token: "tok123" }); + await notifier.notify(makeEvent()); + + expect(fetchMock.mock.calls[0][0]).toBe("http://localhost:8080/api/sessions/main/message"); + }); + }); + + describe("notify", () => { + it("does nothing when no token", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + const notifier = create(); + await notifier.notify(makeEvent()); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("POSTs message to the OpenClaw gateway", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ host: "myhost", port: 9000, token: "tok123" }); + await notifier.notify(makeEvent()); + + expect(fetchMock).toHaveBeenCalledOnce(); + expect(fetchMock.mock.calls[0][0]).toBe("http://myhost:9000/api/sessions/main/message"); + + const opts = fetchMock.mock.calls[0][1]; + expect(opts.method).toBe("POST"); + expect(opts.headers["Content-Type"]).toBe("application/json"); + expect(opts.headers["Authorization"]).toBe("Bearer tok123"); + + const body = JSON.parse(opts.body); + expect(body.message).toContain("my-project"); + expect(body.message).toContain("app-1"); + expect(body.message).toContain("CI check failed on app-1"); + }); + + it("includes session ID, project, branch, and status in message", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ token: "tok123" }); + await notifier.notify(makeEvent()); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.message).toContain("[my-project/app-1]"); + expect(body.message).toContain("(feat/add-login)"); + expect(body.message).toContain("status=failing"); + expect(body.message).toContain("CI check failed on app-1"); + }); + + it("omits branch when not in event data", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ token: "tok123" }); + await notifier.notify(makeEvent({ data: {} })); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.message).not.toContain("("); + expect(body.message).toContain("[my-project/app-1]"); + }); + + it("omits status when not in event data", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ token: "tok123" }); + await notifier.notify(makeEvent({ data: {} })); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.message).not.toContain("status="); + }); + + it("skips events not in the default allowed set", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ token: "tok123" }); + await notifier.notify(makeEvent({ type: "session.working" })); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("sends events that are in the default allowed set", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ token: "tok123" }); + + for (const type of [ + "session.spawned", + "session.exited", + "ci.failing", + "pr.created", + "merge.ready", + "summary.all_complete", + ] as const) { + await notifier.notify(makeEvent({ type })); + } + + expect(fetchMock).toHaveBeenCalledTimes(6); + }); + + it("respects custom events filter", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ + token: "tok123", + events: ["session.spawned", "session.exited"], + }); + + await notifier.notify(makeEvent({ type: "session.spawned" })); + expect(fetchMock).toHaveBeenCalledOnce(); + + await notifier.notify(makeEvent({ type: "ci.failing" })); + expect(fetchMock).toHaveBeenCalledOnce(); // still 1, ci.failing filtered out + }); + }); + + describe("retry logic", () => { + it("retries on 5xx and succeeds", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ + ok: false, + status: 503, + text: () => Promise.resolve("unavailable"), + }) + .mockResolvedValueOnce({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ token: "tok123", retries: 2, retryDelayMs: 1 }); + await notifier.notify(makeEvent()); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("retries on 429 Too Many Requests", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ + ok: false, + status: 429, + text: () => Promise.resolve("rate limited"), + }) + .mockResolvedValueOnce({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ token: "tok123", retries: 2, retryDelayMs: 1 }); + await notifier.notify(makeEvent()); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("does NOT retry on 400 Bad Request", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue({ ok: false, status: 400, text: () => Promise.resolve("bad request") }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ token: "tok123", retries: 2, retryDelayMs: 1 }); + await expect(notifier.notify(makeEvent())).rejects.toThrow("OpenClaw POST failed (400)"); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("does NOT retry on 401 Unauthorized", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue({ ok: false, status: 401, text: () => Promise.resolve("unauthorized") }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ token: "tok123", retries: 2, retryDelayMs: 1 }); + await expect(notifier.notify(makeEvent())).rejects.toThrow("OpenClaw POST failed (401)"); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("throws after all retries exhausted on 5xx", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue({ ok: false, status: 500, text: () => Promise.resolve("error") }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ token: "tok123", retries: 2, retryDelayMs: 1 }); + await expect(notifier.notify(makeEvent())).rejects.toThrow( + "OpenClaw POST failed (500): error", + ); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it("retries on network errors", async () => { + const fetchMock = vi + .fn() + .mockRejectedValueOnce(new Error("ECONNREFUSED")) + .mockResolvedValueOnce({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ token: "tok123", retries: 1, retryDelayMs: 1 }); + await notifier.notify(makeEvent()); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("respects retries=0 (no retries)", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue({ ok: false, status: 500, text: () => Promise.resolve("fail") }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ token: "tok123", retries: 0, retryDelayMs: 1 }); + await expect(notifier.notify(makeEvent())).rejects.toThrow("OpenClaw POST failed"); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/plugins/notifier-openclaw/src/index.ts b/packages/plugins/notifier-openclaw/src/index.ts new file mode 100644 index 000000000..7bd26a88d --- /dev/null +++ b/packages/plugins/notifier-openclaw/src/index.ts @@ -0,0 +1,144 @@ +import type { + PluginModule, + Notifier, + OrchestratorEvent, + EventType, +} from "@composio/ao-core"; + +export const manifest = { + name: "openclaw", + slot: "notifier" as const, + description: "Notifier plugin: OpenClaw gateway", + version: "0.1.0", +}; + +const DEFAULT_EVENTS: ReadonlySet = new Set([ + "session.spawned", + "session.exited", + "session.killed", + "session.stuck", + "session.needs_input", + "session.errored", + "pr.created", + "pr.merged", + "ci.failing", + "ci.fix_failed", + "review.changes_requested", + "merge.ready", + "merge.conflicts", + "summary.all_complete", +]); + +/** + * Returns true if the HTTP status code should be retried. + * Only 429 (Too Many Requests) and 5xx (server errors) are retryable. + */ +function isRetryableStatus(status: number): boolean { + return status === 429 || status >= 500; +} + +async function postWithRetry( + url: string, + body: { message: string }, + token: string, + retries: number, + retryDelayMs: number, +): Promise { + let lastError: Error | undefined; + + for (let attempt = 0; attempt <= retries; attempt++) { + try { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(body), + }); + + if (response.ok) return; + + const text = await response.text(); + lastError = new Error(`OpenClaw POST failed (${response.status}): ${text}`); + + if (!isRetryableStatus(response.status)) { + throw lastError; + } + } catch (err) { + if (err === lastError) throw err; + lastError = err instanceof Error ? err : new Error(String(err)); + } + + if (attempt < retries) { + const delay = retryDelayMs * 2 ** attempt; + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + + throw lastError; +} + +function formatMessage(event: OrchestratorEvent): string { + const branch = + typeof event.data["branch"] === "string" ? event.data["branch"] : null; + const status = + typeof event.data["status"] === "string" ? event.data["status"] : null; + + const parts: string[] = [ + `[${event.projectId}/${event.sessionId}]`, + ]; + + if (branch) { + parts.push(`(${branch})`); + } + + if (status) { + parts.push(`status=${status}`); + } + + parts.push(event.message); + + return parts.join(" "); +} + +function buildUrl(host: string, port: number): string { + return `http://${host}:${port}/api/sessions/main/message`; +} + +export function create(config?: Record): Notifier { + const host = (config?.host as string | undefined) ?? "localhost"; + const port = (config?.port as number | undefined) ?? 8080; + const token = config?.token as string | undefined; + const rawRetries = (config?.retries as number) ?? 2; + const rawDelay = (config?.retryDelayMs as number) ?? 1000; + const retries = Number.isFinite(rawRetries) ? Math.max(0, rawRetries) : 2; + const retryDelayMs = Number.isFinite(rawDelay) && rawDelay >= 0 ? rawDelay : 1000; + + // Parse optional event filter + const rawEvents = config?.events as string[] | undefined; + const allowedEvents: ReadonlySet = + Array.isArray(rawEvents) && rawEvents.length > 0 + ? new Set(rawEvents) + : DEFAULT_EVENTS; + + if (!token) { + console.warn("[notifier-openclaw] No token configured — notifications will be no-ops"); + } + + const url = buildUrl(host, port); + + return { + name: "openclaw", + + async notify(event: OrchestratorEvent): Promise { + if (!token) return; + if (!allowedEvents.has(event.type)) return; + + const message = formatMessage(event); + await postWithRetry(url, { message }, token, retries, retryDelayMs); + }, + }; +} + +export default { manifest, create } satisfies PluginModule; diff --git a/packages/plugins/notifier-openclaw/tsconfig.json b/packages/plugins/notifier-openclaw/tsconfig.json new file mode 100644 index 000000000..e1b71318a --- /dev/null +++ b/packages/plugins/notifier-openclaw/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1157163e8..b9840ff00 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -258,6 +258,22 @@ importers: specifier: ^3.0.0 version: 3.2.4(@types/node@25.2.3)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + packages/plugins/notifier-openclaw: + dependencies: + '@composio/ao-core': + specifier: workspace:* + version: link:../../core + devDependencies: + '@types/node': + specifier: ^25.2.3 + version: 25.2.3 + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/node@25.2.3)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + packages/plugins/notifier-slack: dependencies: '@composio/ao-core':