feat(ao): add OpenClaw notifier plugin for AO escalations
Adds AO notifier-openclaw (webhook-first Phase 0), registry wiring, tests, docs, and BugBot fixes.
This commit is contained in:
parent
cc2031f0f6
commit
a99d37b1d0
|
|
@ -0,0 +1,265 @@
|
|||
# DESIGN: OpenClaw Integration for AO (Revised)
|
||||
|
||||
## Executive Summary
|
||||
This design is revised to match how OpenClaw actually works and to minimize time-to-value.
|
||||
|
||||
- Phase 0-2 uses existing primitives:
|
||||
- AO `notifier-openclaw` (outbound)
|
||||
- OpenClaw built-in webhook ingress (`POST /hooks/agent`)
|
||||
- OpenClaw agent `exec` tool and plugin commands (`api.registerCommand`) for reverse control
|
||||
- No new AO `peer` slot in early phases.
|
||||
- Full structured peer protocol, HMAC, and RBAC are deferred to Phase 3 (cross-network / multi-tenant hardening).
|
||||
|
||||
This ships fast while solving current operational fragility.
|
||||
|
||||
## What Was Correct vs What Changes
|
||||
|
||||
## Keep
|
||||
- Escalation envelope concept and session-supervision goals are sound.
|
||||
- Need bidirectional path and human override.
|
||||
- Need durable session identity, dead-session detection, crash forensics, and send reliability signal.
|
||||
|
||||
## Change
|
||||
- Do not start with a custom OpenClaw bridge extension for event ingress.
|
||||
- Do not add AO reverse-command API in Phase 0-2.
|
||||
- Do not introduce AO `peer` slot yet.
|
||||
|
||||
## Ground Truth Architecture
|
||||
|
||||
## OpenClaw capabilities (used here)
|
||||
OpenClaw already supports:
|
||||
- Webhook ingress: `POST /hooks/agent`, `POST /hooks/wake`
|
||||
- Plugin system: commands, services, gateway methods/handlers, tools, channels, CLI
|
||||
- Auto-reply command registration: `api.registerCommand(...)` (no AI turn)
|
||||
- Agent execution path with shell/exec tools
|
||||
|
||||
## AO capabilities (used here)
|
||||
- Escalation event production via `lifecycle-manager.ts`
|
||||
- Notifier plugin interface (`notify`)
|
||||
- Reliable-ish session send path in `session-manager.send(...)` including confirmation heuristics
|
||||
|
||||
## Phase 0 Design (Ship in ~1 day)
|
||||
|
||||
## Flow
|
||||
1. AO escalation event triggers `notifier-openclaw.notify(...)`.
|
||||
2. `notifier-openclaw` posts to OpenClaw webhook:
|
||||
- `POST http://127.0.0.1:18789/hooks/agent`
|
||||
- `Authorization: Bearer <hooks.token>`
|
||||
3. OpenClaw runs an agent turn and delivers to chat channel(s).
|
||||
4. Human replies in chat; OpenClaw agent runs `ao send/ao kill/ao session ...` via exec tools.
|
||||
|
||||
## Request payload (Phase 0)
|
||||
```json
|
||||
{
|
||||
"message": "[AO Escalation] ao-5 failed CI 5 times on feat/ci-auto-injection. Last error: type mismatch in codex plugin. PR: github.com/ComposioHQ/agent-orchestrator/pull/123. Actions available: retry, skip, kill. Context: {\"sessionId\":\"ao-5\",\"projectId\":\"ao\",\"reason\":\"ci_failed\",\"attempts\":5}",
|
||||
"name": "AO",
|
||||
"sessionKey": "hook:ao:ao-5",
|
||||
"wakeMode": "now",
|
||||
"deliver": true
|
||||
}
|
||||
```
|
||||
|
||||
## Session key strategy (required)
|
||||
Use one OpenClaw session per AO session:
|
||||
- `hook:ao:<ao-session-id>` (examples: `hook:ao:ao-5`, `hook:ao:ao-12`)
|
||||
|
||||
Benefits:
|
||||
- Preserves per-session escalation history.
|
||||
- Enables continuity for retries/human follow-up.
|
||||
- Avoids cross-session context bleed.
|
||||
|
||||
Security config:
|
||||
- `hooks.allowRequestSessionKey: true`
|
||||
- `hooks.allowedSessionKeyPrefixes: ["hook:ao:"]`
|
||||
|
||||
## Why this is the right Phase 0
|
||||
- Zero OpenClaw plugin code required.
|
||||
- Uses stable OpenClaw ingress/auth/session behavior.
|
||||
- Immediate bidirectional operations through existing agent exec path.
|
||||
|
||||
## Phase 1 Design (Lightweight OpenClaw plugin)
|
||||
|
||||
Add a small OpenClaw plugin focused on UX + ops speed, not transport replacement.
|
||||
|
||||
## Plugin responsibilities
|
||||
1. Register auto-reply commands (`api.registerCommand`):
|
||||
- `/ao status <id>`
|
||||
- `/ao sessions`
|
||||
- `/ao retry <id>`
|
||||
- `/ao kill <id>`
|
||||
|
||||
These execute without invoking an AI turn for fast deterministic actions.
|
||||
|
||||
2. Register background service (`api.registerService`):
|
||||
- Periodic AO health polling (`ao session ls/status`), summarize anomalies.
|
||||
- Trigger chat updates when dead/stuck sessions detected.
|
||||
|
||||
3. Keep complex tasks on normal agent path:
|
||||
- For multi-step remediation, let AI run with exec tools (`ao send`, diagnostics, fixes).
|
||||
|
||||
## Phase 2 Design (Structured OpenClaw plugin)
|
||||
|
||||
Add structured AO interactions while keeping Phase 0 compatibility.
|
||||
|
||||
## Additions
|
||||
- Gateway HTTP handler(s) in plugin for structured AO event ingress (optional alongside `/hooks/agent`).
|
||||
- Agent tools for AO structured reads/actions (e.g., `ao_session_info`, `ao_session_send`).
|
||||
- Better supervisor event formatting and correlation across chat threads.
|
||||
|
||||
Still no AO peer slot required.
|
||||
|
||||
## Phase 3 Design (Optional hardened peer protocol)
|
||||
|
||||
Only if needed (cross-host, multi-tenant, compliance):
|
||||
- Dedicated AO peer abstraction.
|
||||
- Signed envelopes (HMAC), replay protection, RBAC, strict command API.
|
||||
|
||||
## Escalation Message Contract (Phase 0-2)
|
||||
|
||||
Keep escalation semantics consistent even in text form.
|
||||
|
||||
Canonical logical shape:
|
||||
```json
|
||||
{
|
||||
"type": "escalation",
|
||||
"from": "ao-5",
|
||||
"reason": "ci_failed",
|
||||
"attempts": 5,
|
||||
"context": {
|
||||
"projectId": "ao",
|
||||
"branch": "feat/ci-auto-injection",
|
||||
"pr": "https://github.com/.../pull/123",
|
||||
"lastError": "..."
|
||||
},
|
||||
"actions": ["retry", "skip", "kill"]
|
||||
}
|
||||
```
|
||||
|
||||
In Phase 0 this is embedded in webhook `message` text plus compact JSON context. In Phase 2+ it can be fully structured.
|
||||
|
||||
## Session Supervision Requirements (Operational)
|
||||
|
||||
## 1) Health monitoring
|
||||
- AO should emit/update session health snapshots periodically.
|
||||
- OpenClaw Phase 1 service polls and reports dead/stuck sessions.
|
||||
|
||||
Simplest Phase 0 bootstrap:
|
||||
- AO writes status snapshots to a known file.
|
||||
- OpenClaw reads during heartbeat cycle and surfaces anomalies.
|
||||
|
||||
## 2) Auto-respawn
|
||||
- If session dies unexpectedly, workflow should attempt `ao session restore <id>` first.
|
||||
- If restore fails, spawn replacement with preserved task metadata and explicit mapping notice.
|
||||
|
||||
## 3) Stable session identity
|
||||
Persist task identity independent of numeric ID:
|
||||
- `logicalSessionKey` (task/issue/PR anchored)
|
||||
- `taskRef`
|
||||
- `branch`
|
||||
- `prUrl`
|
||||
|
||||
Respawn/replacement must carry the same logical identity.
|
||||
|
||||
## 4) Crash forensics
|
||||
On failure detection, capture before cleanup:
|
||||
- last pane output (`tmux capture-pane` tail),
|
||||
- AO/agent/runtime error signature,
|
||||
- known classifiers (e.g. permission/auth/config crash).
|
||||
|
||||
Include this in escalation message.
|
||||
|
||||
## 5) `ao send` delivery confidence
|
||||
Expose send result confidence in escalations/acks:
|
||||
- `accepted`
|
||||
- `confirmed`
|
||||
- `uncertain`
|
||||
- `failed`
|
||||
|
||||
`uncertain` must not be shown as success.
|
||||
|
||||
## Graceful Degradation
|
||||
|
||||
If OpenClaw is down/unreachable:
|
||||
- `notifier-openclaw` fails over per policy to:
|
||||
- desktop notifier and/or
|
||||
- webhook/file sink for later replay
|
||||
- AO must log unsent escalation payloads with retry metadata.
|
||||
|
||||
## Rate Limiting / Debounce
|
||||
|
||||
Avoid chat spam when many sessions fail simultaneously:
|
||||
- Batch window: e.g. 10-30s aggregation by `projectId/reason`.
|
||||
- Collapse repeated identical escalations per `sessionId` within cooldown.
|
||||
- Send summary + top actionable items when burst detected.
|
||||
|
||||
## Security by Phase
|
||||
|
||||
## Phase 0-1 (localhost)
|
||||
- Loopback transport + OpenClaw `hooks.token` auth is sufficient.
|
||||
- No HMAC/RBAC requirement initially.
|
||||
|
||||
## Phase 2+
|
||||
- Add tighter sender policy for plugin commands.
|
||||
|
||||
## Phase 3
|
||||
- HMAC signatures, replay protection, AO-side RBAC for structured API.
|
||||
|
||||
## Reference OpenClaw plugin patterns to follow
|
||||
When implementing Phase 1/2 plugin, model structure after:
|
||||
- Voice Call plugin (`@openclaw/voice-call`): command + tool + service + RPC pattern.
|
||||
- Teams/Matrix channel plugins for robust bidirectional routing patterns.
|
||||
- Memory plugins for slot/service/tool separation patterns.
|
||||
|
||||
## `agent-orchestrator.yaml` (Phase 0)
|
||||
|
||||
```yaml
|
||||
defaults:
|
||||
notifiers: [desktop, openclaw]
|
||||
|
||||
notifiers:
|
||||
openclaw:
|
||||
plugin: openclaw
|
||||
url: "http://127.0.0.1:18789/hooks/agent"
|
||||
token: "${OPENCLAW_HOOKS_TOKEN}"
|
||||
retries: 3
|
||||
retryDelayMs: 1000
|
||||
|
||||
notificationRouting:
|
||||
urgent: [openclaw, desktop]
|
||||
action: [openclaw]
|
||||
warning: [openclaw]
|
||||
info: [openclaw]
|
||||
```
|
||||
|
||||
OpenClaw config requirements:
|
||||
```json5
|
||||
{
|
||||
hooks: {
|
||||
enabled: true,
|
||||
token: "${OPENCLAW_HOOKS_TOKEN}",
|
||||
allowRequestSessionKey: true,
|
||||
allowedSessionKeyPrefixes: ["hook:ao:"],
|
||||
defaultSessionKey: "hook:ao:default"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Required AO changes for Phase 0
|
||||
1. Implement `notifier-openclaw` using webhook POST semantics.
|
||||
2. Add payload formatter producing action-oriented escalation text + compact context.
|
||||
3. Add burst control (debounce/batch) in notifier path.
|
||||
4. Add fallback routing when OpenClaw is unavailable.
|
||||
|
||||
## Required AO/OpenClaw changes for Phase 1
|
||||
1. OpenClaw plugin with `api.registerCommand` for deterministic `/ao ...` commands.
|
||||
2. OpenClaw service with `api.registerService` for periodic AO health polling.
|
||||
3. AO metadata additions for logical session identity and crash forensics fields.
|
||||
|
||||
## Revised rollout plan
|
||||
- Phase 0: AO notifier -> OpenClaw `/hooks/agent`, per-session `hook:ao:*` keys, agent exec for reverse actions.
|
||||
- Phase 1: OpenClaw plugin commands + health polling service.
|
||||
- Phase 2: Structured plugin handlers/tools for AO supervisor events and richer automation.
|
||||
- Phase 3: Optional dedicated peer protocol/security hardening.
|
||||
|
||||
## Final Recommendation
|
||||
Use existing mechanisms first: notifier + webhook + exec + plugin commands. This delivers immediate operational value and directly addresses session durability pain. Defer new abstractions until Phase 3, when complexity is justified.
|
||||
|
|
@ -40,6 +40,7 @@
|
|||
"@composio/ao-plugin-agent-opencode": "workspace:*",
|
||||
"@composio/ao-plugin-notifier-composio": "workspace:*",
|
||||
"@composio/ao-plugin-notifier-desktop": "workspace:*",
|
||||
"@composio/ao-plugin-notifier-openclaw": "workspace:*",
|
||||
"@composio/ao-plugin-notifier-slack": "workspace:*",
|
||||
"@composio/ao-plugin-notifier-webhook": "workspace:*",
|
||||
"@composio/ao-plugin-runtime-process": "workspace:*",
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@
|
|||
"./types": {
|
||||
"types": "./dist/types.d.ts",
|
||||
"import": "./dist/types.js"
|
||||
},
|
||||
"./utils": {
|
||||
"types": "./dist/utils.d.ts",
|
||||
"import": "./dist/utils.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
|
|
|
|||
|
|
@ -185,7 +185,6 @@ describe("loadBuiltins", () => {
|
|||
});
|
||||
|
||||
expect(fakeWebhookNotifier.create).toHaveBeenCalledWith({
|
||||
plugin: "webhook",
|
||||
url: "http://127.0.0.1:8787/hook",
|
||||
retries: 2,
|
||||
retryDelayMs: 500,
|
||||
|
|
@ -211,11 +210,61 @@ describe("loadBuiltins", () => {
|
|||
});
|
||||
|
||||
expect(fakeWebhookNotifier.create).toHaveBeenCalledWith({
|
||||
plugin: "webhook",
|
||||
url: "http://127.0.0.1:8787/custom-hook",
|
||||
retries: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it("passes notifier config from config.notifiers when loading builtins", async () => {
|
||||
const registry = createPluginRegistry();
|
||||
const fakeOpenClaw = makePlugin("notifier", "openclaw");
|
||||
const cfg = makeOrchestratorConfig({
|
||||
notifiers: {
|
||||
openclaw: {
|
||||
plugin: "openclaw",
|
||||
url: "http://127.0.0.1:18789/hooks/agent",
|
||||
token: "tok",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await registry.loadBuiltins(cfg, async (pkg: string) => {
|
||||
if (pkg === "@composio/ao-plugin-notifier-openclaw") return fakeOpenClaw;
|
||||
throw new Error(`Not found: ${pkg}`);
|
||||
});
|
||||
|
||||
expect(fakeOpenClaw.create).toHaveBeenCalledWith({
|
||||
url: "http://127.0.0.1:18789/hooks/agent",
|
||||
token: "tok",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not match notifier key when explicit plugin points to another notifier", async () => {
|
||||
const registry = createPluginRegistry();
|
||||
const fakeOpenClaw = makePlugin("notifier", "openclaw");
|
||||
const fakeWebhook = makePlugin("notifier", "webhook");
|
||||
const cfg = makeOrchestratorConfig({
|
||||
notifiers: {
|
||||
openclaw: {
|
||||
plugin: "webhook",
|
||||
url: "http://127.0.0.1:8787/hook",
|
||||
retries: 3,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await registry.loadBuiltins(cfg, async (pkg: string) => {
|
||||
if (pkg === "@composio/ao-plugin-notifier-openclaw") return fakeOpenClaw;
|
||||
if (pkg === "@composio/ao-plugin-notifier-webhook") return fakeWebhook;
|
||||
throw new Error(`Not found: ${pkg}`);
|
||||
});
|
||||
|
||||
expect(fakeOpenClaw.create).toHaveBeenCalledWith(undefined);
|
||||
expect(fakeWebhook.create).toHaveBeenCalledWith({
|
||||
url: "http://127.0.0.1:8787/hook",
|
||||
retries: 3,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractPluginConfig (via register with config)", () => {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { describe, it, expect, afterEach } from "vitest";
|
|||
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { readLastJsonlEntry } from "../utils.js";
|
||||
import { isRetryableHttpStatus, normalizeRetryConfig, readLastJsonlEntry } from "../utils.js";
|
||||
|
||||
describe("readLastJsonlEntry", () => {
|
||||
let tmpDir: string;
|
||||
|
|
@ -85,3 +85,32 @@ describe("readLastJsonlEntry", () => {
|
|||
expect(result!.modifiedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
describe("retry utilities", () => {
|
||||
it("marks 429 and 5xx statuses as retryable", () => {
|
||||
expect(isRetryableHttpStatus(429)).toBe(true);
|
||||
expect(isRetryableHttpStatus(500)).toBe(true);
|
||||
expect(isRetryableHttpStatus(503)).toBe(true);
|
||||
});
|
||||
|
||||
it("marks 4xx statuses (except 429) as non-retryable", () => {
|
||||
expect(isRetryableHttpStatus(400)).toBe(false);
|
||||
expect(isRetryableHttpStatus(401)).toBe(false);
|
||||
expect(isRetryableHttpStatus(404)).toBe(false);
|
||||
});
|
||||
|
||||
it("normalizes retry config with defaults", () => {
|
||||
expect(normalizeRetryConfig(undefined)).toEqual({ retries: 2, retryDelayMs: 1000 });
|
||||
});
|
||||
|
||||
it("normalizes retry config values and clamps invalid input", () => {
|
||||
expect(normalizeRetryConfig({ retries: 4, retryDelayMs: 250 })).toEqual({
|
||||
retries: 4,
|
||||
retryDelayMs: 250,
|
||||
});
|
||||
expect(normalizeRetryConfig({ retries: -1, retryDelayMs: -50 })).toEqual({
|
||||
retries: 0,
|
||||
retryDelayMs: 1000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -60,7 +60,14 @@ export { generateOrchestratorPrompt } from "./orchestrator-prompt.js";
|
|||
export type { OrchestratorPromptConfig } from "./orchestrator-prompt.js";
|
||||
|
||||
// Shared utilities
|
||||
export { shellEscape, escapeAppleScript, validateUrl, readLastJsonlEntry } from "./utils.js";
|
||||
export {
|
||||
shellEscape,
|
||||
escapeAppleScript,
|
||||
validateUrl,
|
||||
isRetryableHttpStatus,
|
||||
normalizeRetryConfig,
|
||||
readLastJsonlEntry,
|
||||
} from "./utils.js";
|
||||
export { asValidOpenCodeSessionId } from "./opencode-session-id.js";
|
||||
export { normalizeOrchestratorSessionStrategy } from "./orchestrator-session-strategy.js";
|
||||
export type { NormalizedOrchestratorSessionStrategy } from "./orchestrator-session-strategy.js";
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ const BUILTIN_PLUGINS: Array<{ slot: PluginSlot; name: string; pkg: string }> =
|
|||
// Notifiers
|
||||
{ slot: "notifier", name: "composio", pkg: "@composio/ao-plugin-notifier-composio" },
|
||||
{ slot: "notifier", name: "desktop", pkg: "@composio/ao-plugin-notifier-desktop" },
|
||||
{ slot: "notifier", name: "openclaw", pkg: "@composio/ao-plugin-notifier-openclaw" },
|
||||
{ slot: "notifier", name: "slack", pkg: "@composio/ao-plugin-notifier-slack" },
|
||||
{ slot: "notifier", name: "webhook", pkg: "@composio/ao-plugin-notifier-webhook" },
|
||||
// Terminals
|
||||
|
|
@ -56,14 +57,19 @@ function extractPluginConfig(
|
|||
name: string,
|
||||
config: OrchestratorConfig,
|
||||
): Record<string, unknown> | undefined {
|
||||
// Notifiers are configured under config.notifiers.<id>.
|
||||
// Match by key (e.g. "openclaw") or explicit plugin field.
|
||||
if (slot === "notifier") {
|
||||
for (const notifierConfig of Object.values(config.notifiers)) {
|
||||
if (
|
||||
notifierConfig &&
|
||||
typeof notifierConfig === "object" &&
|
||||
notifierConfig["plugin"] === name
|
||||
) {
|
||||
return notifierConfig;
|
||||
for (const [notifierName, notifierConfig] of Object.entries(config.notifiers ?? {})) {
|
||||
if (!notifierConfig || typeof notifierConfig !== "object") continue;
|
||||
const configuredPlugin = (notifierConfig as Record<string, unknown>)["plugin"];
|
||||
const hasExplicitPlugin = typeof configuredPlugin === "string" && configuredPlugin.length > 0;
|
||||
const matches = hasExplicitPlugin
|
||||
? configuredPlugin === name
|
||||
: notifierName === name;
|
||||
if (matches) {
|
||||
const { plugin: _plugin, ...rest } = notifierConfig as Record<string, unknown>;
|
||||
return rest;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,30 @@ export function validateUrl(url: string, label: string): void {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if an HTTP status code should be retried.
|
||||
* Retry only 429 (rate-limit) and 5xx (server) failures.
|
||||
*/
|
||||
export function isRetryableHttpStatus(status: number): boolean {
|
||||
return status === 429 || status >= 500;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize retry config from plugin config with sane defaults.
|
||||
*/
|
||||
export function normalizeRetryConfig(
|
||||
config: Record<string, unknown> | undefined,
|
||||
defaults: { retries: number; retryDelayMs: number } = { retries: 2, retryDelayMs: 1000 },
|
||||
): { retries: number; retryDelayMs: number } {
|
||||
const rawRetries = config?.retries as number | undefined;
|
||||
const rawDelay = config?.retryDelayMs as number | undefined;
|
||||
const retries = Number.isFinite(rawRetries) ? Math.max(0, rawRetries ?? 0) : defaults.retries;
|
||||
const retryDelayMs = Number.isFinite(rawDelay) && (rawDelay ?? -1) >= 0
|
||||
? (rawDelay as number)
|
||||
: defaults.retryDelayMs;
|
||||
return { retries, retryDelayMs };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the last line from a file by reading backwards from the end.
|
||||
* Pure Node.js — no external binaries. Handles any file size.
|
||||
|
|
@ -93,7 +117,6 @@ export async function readLastJsonlEntry(
|
|||
try {
|
||||
const [line, fileStat] = await Promise.all([readLastLine(filePath), stat(filePath)]);
|
||||
|
||||
|
||||
if (!line) return null;
|
||||
|
||||
const parsed: unknown = JSON.parse(line);
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@composio/ao-plugin-notifier-desktop": "workspace:*",
|
||||
"@composio/ao-plugin-notifier-openclaw": "workspace:*",
|
||||
"@composio/ao-plugin-notifier-slack": "workspace:*",
|
||||
"@composio/ao-plugin-notifier-webhook": "workspace:*",
|
||||
"@composio/ao-plugin-notifier-composio": "workspace:*",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,116 @@
|
|||
/**
|
||||
* Integration tests for notifier-openclaw.
|
||||
*
|
||||
* Mocks network boundary only (global fetch + timers).
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { NotifyAction } from "@composio/ao-core";
|
||||
import openClawPlugin from "@composio/ao-plugin-notifier-openclaw";
|
||||
import { makeEvent } from "./helpers/event-factory.js";
|
||||
|
||||
describe("notifier-openclaw integration", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
delete process.env.OPENCLAW_HOOKS_TOKEN;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("notify posts OpenClaw hooks payload with per-session key", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = openClawPlugin.create({
|
||||
url: "http://127.0.0.1:18789/hooks/agent",
|
||||
token: "tok",
|
||||
sessionKeyPrefix: "hook:ao:",
|
||||
});
|
||||
|
||||
await notifier.notify(
|
||||
makeEvent({
|
||||
type: "reaction.escalated",
|
||||
priority: "urgent",
|
||||
sessionId: "ao-12",
|
||||
message: "CI failed 5 times",
|
||||
data: { attempts: 5, reason: "ci_failed" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const [url, opts] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe("http://127.0.0.1:18789/hooks/agent");
|
||||
|
||||
const body = JSON.parse(opts.body);
|
||||
expect(body.name).toBe("AO");
|
||||
expect(body.sessionKey).toBe("hook:ao:ao-12");
|
||||
expect(body.wakeMode).toBe("now");
|
||||
expect(body.deliver).toBe(true);
|
||||
expect(body.message).toContain("[AO URGENT]");
|
||||
expect(body.message).toContain("CI failed 5 times");
|
||||
});
|
||||
|
||||
it("notifyWithActions formats escalation header/context and appends action labels", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const actions: NotifyAction[] = [{ label: "retry" }, { label: "kill" }];
|
||||
|
||||
const notifier = openClawPlugin.create({ token: "tok" });
|
||||
await notifier.notifyWithActions!(
|
||||
makeEvent({
|
||||
priority: "action",
|
||||
type: "ci.failing",
|
||||
sessionId: "ao-5",
|
||||
message: "CI check failed on app-1",
|
||||
data: { checkName: "lint" },
|
||||
}),
|
||||
actions,
|
||||
);
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
expect(body.sessionKey).toBe("hook:ao:ao-5");
|
||||
expect(body.message).toContain("[AO ACTION] ao-5 ci.failing");
|
||||
expect(body.message).toContain('Context: {"checkName":"lint"}');
|
||||
expect(body.message).toContain("Actions available: retry, kill");
|
||||
});
|
||||
|
||||
it("uses explicit deliver=true in hooks payload", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = openClawPlugin.create({
|
||||
token: "tok",
|
||||
deliver: true,
|
||||
wakeMode: "now",
|
||||
});
|
||||
await notifier.notify(makeEvent({ sessionId: "ao-21" }));
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
expect(body.deliver).toBe(true);
|
||||
expect(body.wakeMode).toBe("now");
|
||||
});
|
||||
|
||||
it("retries 503 then succeeds", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ ok: false, status: 503, text: () => Promise.resolve("down") })
|
||||
.mockResolvedValueOnce({ ok: true });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = openClawPlugin.create({ token: "tok", retries: 1, retryDelayMs: 100 });
|
||||
const promise = notifier.notify(makeEvent());
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
await promise;
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
# notifier-openclaw
|
||||
|
||||
OpenClaw notifier plugin for AO escalation events.
|
||||
|
||||
## Required OpenClaw config (`openclaw.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"enabled": true,
|
||||
"token": "<your-hooks-token>",
|
||||
"allowRequestSessionKey": true,
|
||||
"allowedSessionKeyPrefixes": ["hook:"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## AO config (`agent-orchestrator.yaml`)
|
||||
|
||||
```yaml
|
||||
notifiers:
|
||||
openclaw:
|
||||
plugin: openclaw
|
||||
url: http://127.0.0.1:18789/hooks/agent
|
||||
token: ${OPENCLAW_HOOKS_TOKEN}
|
||||
```
|
||||
|
||||
## Behavior
|
||||
|
||||
- Sends `POST /hooks/agent` payloads with per-session key `hook:ao:<sessionId>`.
|
||||
- Defaults `wakeMode: now` and `deliver: true`.
|
||||
- Retries on `429` and `5xx` responses with exponential backoff.
|
||||
|
||||
## Token rotation
|
||||
|
||||
1. Rotate `hooks.token` in OpenClaw.
|
||||
2. Update `OPENCLAW_HOOKS_TOKEN` used by AO.
|
||||
3. Verify old token returns `401` and new token returns `200`.
|
||||
|
||||
## Known limitation (Phase 0)
|
||||
|
||||
- OpenClaw hook ingest is not idempotent by default. Replayed webhook payloads are processed as separate runs.
|
||||
- Owner: AO integration.
|
||||
- Follow-up: add stable event id/idempotency key support.
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
{
|
||||
"name": "@composio/ao-plugin-notifier-openclaw",
|
||||
"version": "0.1.0",
|
||||
"description": "Notifier plugin: OpenClaw webhook notifications",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { NotifyAction, OrchestratorEvent } from "@composio/ao-core";
|
||||
import { create, manifest } from "./index.js";
|
||||
|
||||
function makeEvent(overrides: Partial<OrchestratorEvent> = {}): OrchestratorEvent {
|
||||
return {
|
||||
id: "evt-1",
|
||||
type: "reaction.escalated",
|
||||
priority: "urgent",
|
||||
sessionId: "ao-5",
|
||||
projectId: "ao",
|
||||
timestamp: new Date("2026-03-08T12:00:00Z"),
|
||||
message: "Reaction escalated after retries",
|
||||
data: { attempts: 5, reason: "ci_failed" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("notifier-openclaw", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
delete process.env.OPENCLAW_HOOKS_TOKEN;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("has correct manifest", () => {
|
||||
expect(manifest.name).toBe("openclaw");
|
||||
expect(manifest.slot).toBe("notifier");
|
||||
});
|
||||
|
||||
it("uses default OpenClaw hooks endpoint", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = create({ token: "tok" });
|
||||
await notifier.notify(makeEvent());
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(fetchMock.mock.calls[0][0]).toBe("http://127.0.0.1:18789/hooks/agent");
|
||||
});
|
||||
|
||||
it("uses token from OPENCLAW_HOOKS_TOKEN env", async () => {
|
||||
process.env.OPENCLAW_HOOKS_TOKEN = "env-token";
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = create();
|
||||
await notifier.notify(makeEvent());
|
||||
|
||||
const headers = fetchMock.mock.calls[0][1].headers;
|
||||
expect(headers["Authorization"]).toBe("Bearer env-token");
|
||||
});
|
||||
|
||||
it("warns and sends without Authorization when token missing", async () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = create();
|
||||
await notifier.notify(makeEvent());
|
||||
|
||||
const headers = fetchMock.mock.calls[0][1].headers as Record<string, string>;
|
||||
expect(headers["Authorization"]).toBeUndefined();
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("No token configured"));
|
||||
});
|
||||
|
||||
it("builds per-session OpenClaw session key", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = create({ token: "tok", sessionKeyPrefix: "hook:ao:" });
|
||||
await notifier.notify(makeEvent({ sessionId: "ao-12" }));
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
expect(body.sessionKey).toBe("hook:ao:ao-12");
|
||||
});
|
||||
|
||||
it("sanitizes invalid characters in session id", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = create({ token: "tok" });
|
||||
await notifier.notify(makeEvent({ sessionId: "ao/12?x" }));
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
expect(body.sessionKey).toBe("hook:ao:ao-12-x");
|
||||
});
|
||||
|
||||
it("notifyWithActions appends action labels", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = create({ token: "tok" });
|
||||
const actions: NotifyAction[] = [{ label: "retry" }, { label: "kill" }];
|
||||
await notifier.notifyWithActions!(makeEvent(), actions);
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
expect(body.message).toContain("Actions available: retry, kill");
|
||||
});
|
||||
|
||||
it("post uses context sessionId when provided", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = create({ token: "tok" });
|
||||
await notifier.post!("ready", { sessionId: "ao-77" });
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
expect(body.sessionKey).toBe("hook:ao:ao-77");
|
||||
expect(body.message).toBe("ready");
|
||||
});
|
||||
|
||||
it("defaults wakeMode=now and deliver=true", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = create({ token: "tok" });
|
||||
await notifier.notify(makeEvent());
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
expect(body.wakeMode).toBe("now");
|
||||
expect(body.deliver).toBe(true);
|
||||
});
|
||||
|
||||
it("supports wakeMode=next-heartbeat when configured", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = create({ token: "tok", wakeMode: "next-heartbeat" });
|
||||
await notifier.notify(makeEvent());
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
expect(body.wakeMode).toBe("next-heartbeat");
|
||||
});
|
||||
|
||||
it("retries on 5xx response", async () => {
|
||||
vi.useFakeTimers();
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ ok: false, status: 503, text: () => Promise.resolve("down") })
|
||||
.mockResolvedValueOnce({ ok: true });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = create({ token: "tok", retries: 1, retryDelayMs: 50 });
|
||||
const promise = notifier.notify(makeEvent());
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
await promise;
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Retry 1/1 for session=ao-5 after HTTP 503"),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not retry on 4xx response", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: false, status: 401, text: () => Promise.resolve("unauthorized") });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = create({ token: "tok", retries: 2, retryDelayMs: 1 });
|
||||
await expect(notifier.notify(makeEvent())).rejects.toThrow("OpenClaw webhook failed (401)");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,193 @@
|
|||
import {
|
||||
type EventPriority,
|
||||
type Notifier,
|
||||
type NotifyAction,
|
||||
type NotifyContext,
|
||||
type OrchestratorEvent,
|
||||
type PluginModule,
|
||||
} from "@composio/ao-core";
|
||||
import {
|
||||
isRetryableHttpStatus,
|
||||
normalizeRetryConfig,
|
||||
validateUrl,
|
||||
} from "@composio/ao-core/utils";
|
||||
|
||||
export const manifest = {
|
||||
name: "openclaw",
|
||||
slot: "notifier" as const,
|
||||
description: "Notifier plugin: OpenClaw webhook notifications",
|
||||
version: "0.1.0",
|
||||
};
|
||||
|
||||
type WakeMode = "now" | "next-heartbeat";
|
||||
|
||||
interface OpenClawWebhookPayload {
|
||||
message: string;
|
||||
name?: string;
|
||||
sessionKey?: string;
|
||||
wakeMode?: WakeMode;
|
||||
deliver?: boolean;
|
||||
}
|
||||
|
||||
async function postWithRetry(
|
||||
url: string,
|
||||
payload: OpenClawWebhookPayload,
|
||||
headers: Record<string, string>,
|
||||
retries: number,
|
||||
retryDelayMs: number,
|
||||
context: { sessionId: string },
|
||||
): Promise<void> {
|
||||
let lastError: Error | undefined;
|
||||
|
||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (response.ok) return;
|
||||
|
||||
const body = await response.text();
|
||||
lastError = new Error(`OpenClaw webhook failed (${response.status}): ${body}`);
|
||||
|
||||
if (!isRetryableHttpStatus(response.status)) {
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
if (attempt < retries) {
|
||||
console.warn(
|
||||
`[notifier-openclaw] Retry ${attempt + 1}/${retries} for session=${context.sessionId} after HTTP ${response.status}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err === lastError) throw err;
|
||||
lastError = err instanceof Error ? err : new Error(String(err));
|
||||
|
||||
if (attempt < retries) {
|
||||
console.warn(
|
||||
`[notifier-openclaw] Retry ${attempt + 1}/${retries} for session=${context.sessionId} after network error: ${lastError.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (attempt < retries) {
|
||||
const delay = retryDelayMs * 2 ** attempt;
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
function sanitizeSessionId(id: string): string {
|
||||
return id.replace(/[^a-zA-Z0-9:_-]/g, "-");
|
||||
}
|
||||
|
||||
function eventHeadline(event: OrchestratorEvent): string {
|
||||
const priorityTag: Record<EventPriority, string> = {
|
||||
urgent: "URGENT",
|
||||
action: "ACTION",
|
||||
warning: "WARNING",
|
||||
info: "INFO",
|
||||
};
|
||||
return `[AO ${priorityTag[event.priority]}] ${event.sessionId} ${event.type}`;
|
||||
}
|
||||
|
||||
function stringifyData(data: Record<string, unknown>): string {
|
||||
const entries = Object.entries(data);
|
||||
if (entries.length === 0) return "";
|
||||
return `Context: ${JSON.stringify(data)}`;
|
||||
}
|
||||
|
||||
function formatEscalationMessage(event: OrchestratorEvent): string {
|
||||
const parts = [eventHeadline(event), event.message, stringifyData(event.data)].filter(Boolean);
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
function formatActionsLine(actions: NotifyAction[]): string {
|
||||
if (actions.length === 0) return "";
|
||||
const labels = actions.map((a) => a.label).join(", ");
|
||||
return `Actions available: ${labels}`;
|
||||
}
|
||||
|
||||
export function create(config?: Record<string, unknown>): Notifier {
|
||||
const url = (typeof config?.url === "string" ? config.url : undefined) ??
|
||||
"http://127.0.0.1:18789/hooks/agent";
|
||||
const token =
|
||||
(typeof config?.token === "string" ? config.token : undefined) ??
|
||||
process.env.OPENCLAW_HOOKS_TOKEN;
|
||||
const senderName = typeof config?.name === "string" ? config.name : "AO";
|
||||
const sessionKeyPrefix =
|
||||
typeof config?.sessionKeyPrefix === "string" ? config.sessionKeyPrefix : "hook:ao:";
|
||||
const wakeMode: WakeMode = config?.wakeMode === "next-heartbeat" ? "next-heartbeat" : "now";
|
||||
const deliver = typeof config?.deliver === "boolean" ? config.deliver : true;
|
||||
|
||||
const { retries, retryDelayMs } = normalizeRetryConfig(config);
|
||||
|
||||
validateUrl(url, "notifier-openclaw");
|
||||
|
||||
if (!token) {
|
||||
console.warn(
|
||||
"[notifier-openclaw] No token configured (token or OPENCLAW_HOOKS_TOKEN). Sending without Authorization header.",
|
||||
);
|
||||
}
|
||||
|
||||
async function sendPayload(payload: OpenClawWebhookPayload): Promise<void> {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
|
||||
const sessionId = payload.sessionKey?.slice(sessionKeyPrefix.length) ?? "default";
|
||||
|
||||
await postWithRetry(url, payload, headers, retries, retryDelayMs, { sessionId });
|
||||
}
|
||||
|
||||
return {
|
||||
name: "openclaw",
|
||||
|
||||
async notify(event: OrchestratorEvent): Promise<void> {
|
||||
const sessionKey = `${sessionKeyPrefix}${sanitizeSessionId(event.sessionId)}`;
|
||||
await sendPayload({
|
||||
message: formatEscalationMessage(event),
|
||||
name: senderName,
|
||||
sessionKey,
|
||||
wakeMode,
|
||||
deliver,
|
||||
});
|
||||
},
|
||||
|
||||
async notifyWithActions(event: OrchestratorEvent, actions: NotifyAction[]): Promise<void> {
|
||||
const sessionKey = `${sessionKeyPrefix}${sanitizeSessionId(event.sessionId)}`;
|
||||
const actionsLine = formatActionsLine(actions);
|
||||
const message = [formatEscalationMessage(event), actionsLine].filter(Boolean).join("\n");
|
||||
|
||||
await sendPayload({
|
||||
message,
|
||||
name: senderName,
|
||||
sessionKey,
|
||||
wakeMode,
|
||||
deliver,
|
||||
});
|
||||
},
|
||||
|
||||
async post(message: string, context?: NotifyContext): Promise<string | null> {
|
||||
const sessionId = context?.sessionId ? sanitizeSessionId(context.sessionId) : "default";
|
||||
const sessionKey = `${sessionKeyPrefix}${sessionId}`;
|
||||
|
||||
await sendPayload({
|
||||
message,
|
||||
name: senderName,
|
||||
sessionKey,
|
||||
wakeMode,
|
||||
deliver,
|
||||
});
|
||||
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default { manifest, create } satisfies PluginModule<Notifier>;
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
|
@ -1,11 +1,15 @@
|
|||
import {
|
||||
validateUrl,
|
||||
type PluginModule,
|
||||
type Notifier,
|
||||
type OrchestratorEvent,
|
||||
type NotifyAction,
|
||||
type NotifyContext,
|
||||
} from "@composio/ao-core";
|
||||
import {
|
||||
isRetryableHttpStatus,
|
||||
normalizeRetryConfig,
|
||||
validateUrl,
|
||||
} from "@composio/ao-core/utils";
|
||||
|
||||
export const manifest = {
|
||||
name: "webhook",
|
||||
|
|
@ -35,15 +39,6 @@ interface WebhookPayload {
|
|||
context?: NotifyContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the HTTP status code should be retried.
|
||||
* Only 429 (Too Many Requests) and 5xx (server errors) are retryable.
|
||||
* 4xx client errors (400, 401, 403, 404, etc.) are permanent failures.
|
||||
*/
|
||||
function isRetryableStatus(status: number): boolean {
|
||||
return status === 429 || status >= 500;
|
||||
}
|
||||
|
||||
async function postWithRetry(
|
||||
url: string,
|
||||
payload: WebhookPayload,
|
||||
|
|
@ -70,7 +65,7 @@ async function postWithRetry(
|
|||
lastError = new Error(`Webhook POST failed (${response.status}): ${body}`);
|
||||
|
||||
// Only retry on 429 or 5xx — 4xx client errors are permanent
|
||||
if (!isRetryableStatus(response.status)) {
|
||||
if (!isRetryableHttpStatus(response.status)) {
|
||||
throw lastError;
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
@ -110,10 +105,7 @@ export function create(config?: Record<string, unknown>): Notifier {
|
|||
if (typeof v === "string") customHeaders[k] = v;
|
||||
}
|
||||
}
|
||||
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;
|
||||
const { retries, retryDelayMs } = normalizeRetryConfig(config);
|
||||
|
||||
if (!url) {
|
||||
console.warn("[notifier-webhook] No url configured — notifications will be no-ops");
|
||||
|
|
|
|||
|
|
@ -62,6 +62,9 @@ importers:
|
|||
'@composio/ao-plugin-notifier-desktop':
|
||||
specifier: workspace:*
|
||||
version: link:../plugins/notifier-desktop
|
||||
'@composio/ao-plugin-notifier-openclaw':
|
||||
specifier: workspace:*
|
||||
version: link:../plugins/notifier-openclaw
|
||||
'@composio/ao-plugin-notifier-slack':
|
||||
specifier: workspace:*
|
||||
version: link:../plugins/notifier-slack
|
||||
|
|
@ -185,6 +188,9 @@ importers:
|
|||
'@composio/ao-plugin-notifier-desktop':
|
||||
specifier: workspace:*
|
||||
version: link:../plugins/notifier-desktop
|
||||
'@composio/ao-plugin-notifier-openclaw':
|
||||
specifier: workspace:*
|
||||
version: link:../plugins/notifier-openclaw
|
||||
'@composio/ao-plugin-notifier-slack':
|
||||
specifier: workspace:*
|
||||
version: link:../plugins/notifier-slack
|
||||
|
|
@ -309,6 +315,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':
|
||||
|
|
|
|||
Loading…
Reference in New Issue