Adds notification system for full visibility into the DevClaw pipeline: Events and targets: - workerStart: Posted to project group when worker spawns/resumes - workerComplete: Posted to project group when DEV done/QA pass/fail/refine - heartbeat: Posted to orchestrator DM with tick summary Implementation: - New lib/notify.ts module with buildMessage() and sendMessage() - Integrated into task_pickup, task_complete, and heartbeat_tick - Uses OpenClaw gateway to invoke message tool Configuration (optional): - orchestratorDm: Chat ID for heartbeat notifications - notifications.heartbeatDm: Enable/disable heartbeat DM (default: true) - notifications.workerStart: Enable/disable start notifications (default: true) - notifications.workerComplete: Enable/disable completion notifications (default: true) Notifications fail silently (logged but don't break main flow).
This commit is contained in:
25
index.ts
25
index.ts
@@ -36,6 +36,31 @@ const plugin = {
|
||||
description: "Work mode: parallel (each project independent) or sequential (1 DEV + 1 QA globally)",
|
||||
default: "parallel",
|
||||
},
|
||||
orchestratorDm: {
|
||||
type: "string",
|
||||
description: "Telegram/WhatsApp chat ID for orchestrator DM notifications (heartbeat summaries)",
|
||||
},
|
||||
notifications: {
|
||||
type: "object",
|
||||
description: "Notification settings for worker lifecycle events",
|
||||
properties: {
|
||||
heartbeatDm: {
|
||||
type: "boolean",
|
||||
description: "Send heartbeat summaries to orchestrator DM. Default: true",
|
||||
default: true,
|
||||
},
|
||||
workerStart: {
|
||||
type: "boolean",
|
||||
description: "Post when worker starts a task. Default: true",
|
||||
default: true,
|
||||
},
|
||||
workerComplete: {
|
||||
type: "boolean",
|
||||
description: "Post when worker completes a task. Default: true",
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
242
lib/notify.ts
Normal file
242
lib/notify.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* notify.ts — Programmatic alerting for worker lifecycle events.
|
||||
*
|
||||
* Sends notifications to project groups and orchestrator DM for visibility
|
||||
* into the DevClaw pipeline.
|
||||
*
|
||||
* Event types:
|
||||
* - workerStart: Worker spawned/resumed for a task (→ project group)
|
||||
* - workerComplete: Worker completed task (→ project group)
|
||||
* - heartbeat: Heartbeat tick summary (→ orchestrator DM)
|
||||
*/
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { log as auditLog } from "./audit.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export type NotificationConfig = {
|
||||
/** Send heartbeat summaries to orchestrator DM. Default: true */
|
||||
heartbeatDm?: boolean;
|
||||
/** Post when worker starts a task. Default: true */
|
||||
workerStart?: boolean;
|
||||
/** Post when worker completes a task. Default: true */
|
||||
workerComplete?: boolean;
|
||||
};
|
||||
|
||||
export type NotifyEvent =
|
||||
| {
|
||||
type: "workerStart";
|
||||
project: string;
|
||||
groupId: string;
|
||||
issueId: number;
|
||||
issueTitle: string;
|
||||
role: "dev" | "qa";
|
||||
model: string;
|
||||
sessionAction: "spawn" | "send";
|
||||
}
|
||||
| {
|
||||
type: "workerComplete";
|
||||
project: string;
|
||||
groupId: string;
|
||||
issueId: number;
|
||||
role: "dev" | "qa";
|
||||
result: "done" | "pass" | "fail" | "refine";
|
||||
summary?: string;
|
||||
nextState?: string;
|
||||
}
|
||||
| {
|
||||
type: "heartbeat";
|
||||
projectsScanned: number;
|
||||
healthFixes: number;
|
||||
pickups: number;
|
||||
skipped: number;
|
||||
dryRun: boolean;
|
||||
pickupDetails?: Array<{
|
||||
project: string;
|
||||
issueId: number;
|
||||
role: "dev" | "qa";
|
||||
}>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a human-readable message for a notification event.
|
||||
*/
|
||||
function buildMessage(event: NotifyEvent): string {
|
||||
switch (event.type) {
|
||||
case "workerStart": {
|
||||
const action = event.sessionAction === "spawn" ? "🚀 Started" : "▶️ Resumed";
|
||||
return `${action} ${event.role.toUpperCase()} (${event.model}) on #${event.issueId}: ${event.issueTitle}`;
|
||||
}
|
||||
|
||||
case "workerComplete": {
|
||||
const icons: Record<string, string> = {
|
||||
done: "✅",
|
||||
pass: "🎉",
|
||||
fail: "❌",
|
||||
refine: "🤔",
|
||||
};
|
||||
const icon = icons[event.result] ?? "📋";
|
||||
const resultText: Record<string, string> = {
|
||||
done: "completed",
|
||||
pass: "PASSED",
|
||||
fail: "FAILED",
|
||||
refine: "needs refinement",
|
||||
};
|
||||
const text = resultText[event.result] ?? event.result;
|
||||
let msg = `${icon} ${event.role.toUpperCase()} ${text} #${event.issueId}`;
|
||||
if (event.summary) {
|
||||
msg += ` — ${event.summary}`;
|
||||
}
|
||||
if (event.nextState) {
|
||||
msg += ` → ${event.nextState}`;
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
case "heartbeat": {
|
||||
if (event.dryRun) {
|
||||
return `🔄 Heartbeat (dry run): scanned ${event.projectsScanned} projects, would pick up ${event.pickups} tasks`;
|
||||
}
|
||||
const parts = [`🔄 Heartbeat: scanned ${event.projectsScanned} projects`];
|
||||
if (event.healthFixes > 0) {
|
||||
parts.push(`fixed ${event.healthFixes} zombie(s)`);
|
||||
}
|
||||
if (event.pickups > 0) {
|
||||
parts.push(`spawned ${event.pickups} worker(s)`);
|
||||
if (event.pickupDetails && event.pickupDetails.length > 0) {
|
||||
const details = event.pickupDetails
|
||||
.map((p) => `${p.project}#${p.issueId}(${p.role})`)
|
||||
.join(", ");
|
||||
parts.push(`[${details}]`);
|
||||
}
|
||||
}
|
||||
if (event.pickups === 0 && event.healthFixes === 0) {
|
||||
parts.push("no actions needed");
|
||||
}
|
||||
return parts.join(", ");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a notification message to a Telegram/WhatsApp target.
|
||||
*
|
||||
* Uses the OpenClaw CLI to invoke the message tool.
|
||||
* Fails silently (logs error but doesn't throw) to avoid breaking the main flow.
|
||||
*/
|
||||
async function sendMessage(
|
||||
target: string,
|
||||
message: string,
|
||||
channel: "telegram" | "whatsapp",
|
||||
workspaceDir: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
// Use openclaw agent to send via message tool
|
||||
// The message tool requires action=send, target, message
|
||||
await execFileAsync(
|
||||
"openclaw",
|
||||
[
|
||||
"gateway",
|
||||
"call",
|
||||
"tools.invoke",
|
||||
"--params",
|
||||
JSON.stringify({
|
||||
tool: "message",
|
||||
params: {
|
||||
action: "send",
|
||||
target,
|
||||
message,
|
||||
},
|
||||
}),
|
||||
],
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
return true;
|
||||
} catch (err) {
|
||||
// Log but don't throw — notifications shouldn't break the main flow
|
||||
await auditLog(workspaceDir, "notify_error", {
|
||||
target,
|
||||
channel,
|
||||
error: (err as Error).message,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a notification for a worker lifecycle event.
|
||||
*
|
||||
* Respects notification config settings.
|
||||
* Returns true if notification was sent (or skipped due to config), false on error.
|
||||
*/
|
||||
export async function notify(
|
||||
event: NotifyEvent,
|
||||
opts: {
|
||||
workspaceDir: string;
|
||||
config?: NotificationConfig;
|
||||
/** Target for project-scoped notifications (groupId) */
|
||||
groupId?: string;
|
||||
/** Channel type for routing */
|
||||
channel?: "telegram" | "whatsapp";
|
||||
/** Target for DM notifications (orchestrator) */
|
||||
orchestratorDm?: string;
|
||||
},
|
||||
): Promise<boolean> {
|
||||
const config = opts.config ?? {};
|
||||
const channel = opts.channel ?? "telegram";
|
||||
|
||||
// Check if notification is enabled
|
||||
if (event.type === "workerStart" && config.workerStart === false) {
|
||||
return true; // Skipped, not an error
|
||||
}
|
||||
if (event.type === "workerComplete" && config.workerComplete === false) {
|
||||
return true;
|
||||
}
|
||||
if (event.type === "heartbeat" && config.heartbeatDm === false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const message = buildMessage(event);
|
||||
|
||||
// Determine target
|
||||
let target: string | undefined;
|
||||
if (event.type === "heartbeat") {
|
||||
target = opts.orchestratorDm;
|
||||
} else {
|
||||
target = opts.groupId ?? (event as { groupId?: string }).groupId;
|
||||
}
|
||||
|
||||
if (!target) {
|
||||
// No target specified, can't send
|
||||
await auditLog(opts.workspaceDir, "notify_skip", {
|
||||
eventType: event.type,
|
||||
reason: "no target",
|
||||
});
|
||||
return true; // Not an error, just nothing to do
|
||||
}
|
||||
|
||||
// Audit the notification attempt
|
||||
await auditLog(opts.workspaceDir, "notify", {
|
||||
eventType: event.type,
|
||||
target,
|
||||
channel,
|
||||
message,
|
||||
});
|
||||
|
||||
return sendMessage(target, message, channel, opts.workspaceDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get notification config from plugin config.
|
||||
*/
|
||||
export function getNotificationConfig(
|
||||
pluginConfig?: Record<string, unknown>,
|
||||
): NotificationConfig {
|
||||
const notifications = pluginConfig?.notifications as NotificationConfig | undefined;
|
||||
return {
|
||||
heartbeatDm: notifications?.heartbeatDm ?? true,
|
||||
workerStart: notifications?.workerStart ?? true,
|
||||
workerComplete: notifications?.workerComplete ?? true,
|
||||
};
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import type { ToolContext } from "../types.js";
|
||||
import { detectContext, generateGuardrails } from "../context-guard.js";
|
||||
import { type Tier } from "../tiers.js";
|
||||
import { log as auditLog } from "../audit.js";
|
||||
import { notify, getNotificationConfig } from "../notify.js";
|
||||
|
||||
/** Labels that map to DEV role */
|
||||
const DEV_LABELS: StateLabel[] = ["To Do", "To Improve"];
|
||||
@@ -496,6 +497,32 @@ export function createHeartbeatTickTool(api: OpenClawPluginApi) {
|
||||
skipped: result.skipped.length,
|
||||
});
|
||||
|
||||
// Send heartbeat notification to orchestrator DM
|
||||
const notifyConfig = getNotificationConfig(pluginConfig);
|
||||
const orchestratorDm = pluginConfig?.orchestratorDm as string | undefined;
|
||||
|
||||
await notify(
|
||||
{
|
||||
type: "heartbeat",
|
||||
projectsScanned: projectEntries.length,
|
||||
healthFixes: result.healthFixes.length,
|
||||
pickups: result.pickups.length,
|
||||
skipped: result.skipped.length,
|
||||
dryRun,
|
||||
pickupDetails: result.pickups.map((p) => ({
|
||||
project: p.project,
|
||||
issueId: p.issueId,
|
||||
role: p.role,
|
||||
})),
|
||||
},
|
||||
{
|
||||
workspaceDir,
|
||||
config: notifyConfig,
|
||||
orchestratorDm,
|
||||
channel: "telegram",
|
||||
},
|
||||
);
|
||||
|
||||
return jsonResult(result);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
readProjects,
|
||||
} from "../projects.js";
|
||||
import type { ToolContext } from "../types.js";
|
||||
import { notify, getNotificationConfig } from "../notify.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -266,6 +267,42 @@ export function createTaskCompleteTool(api: OpenClawPluginApi) {
|
||||
output.announcement = `🤔 QA REFINE #${issueId}${summary ? ` — ${summary}` : ""}. Awaiting human decision.`;
|
||||
}
|
||||
|
||||
// Send notification to project group
|
||||
const pluginConfig = api.pluginConfig as Record<string, unknown> | undefined;
|
||||
const notifyConfig = getNotificationConfig(pluginConfig);
|
||||
|
||||
// Determine next state for the notification
|
||||
let nextState: string | undefined;
|
||||
if (role === "dev" && result === "done") {
|
||||
nextState = "QA queue";
|
||||
} else if (role === "qa" && result === "pass") {
|
||||
nextState = "Done!";
|
||||
} else if (role === "qa" && result === "fail") {
|
||||
nextState = "back to DEV";
|
||||
} else if (role === "qa" && result === "refine") {
|
||||
nextState = "awaiting human decision";
|
||||
}
|
||||
|
||||
await notify(
|
||||
{
|
||||
type: "workerComplete",
|
||||
project: project.name,
|
||||
groupId,
|
||||
issueId,
|
||||
role,
|
||||
result,
|
||||
summary,
|
||||
nextState,
|
||||
},
|
||||
{
|
||||
workspaceDir,
|
||||
config: notifyConfig,
|
||||
groupId,
|
||||
// Channel detection: default to telegram if not available
|
||||
channel: "telegram",
|
||||
},
|
||||
);
|
||||
|
||||
// Audit log
|
||||
await auditLog(workspaceDir, "task_complete", {
|
||||
project: project.name,
|
||||
|
||||
@@ -21,6 +21,7 @@ import { getProject, getWorker, readProjects } from "../projects.js";
|
||||
import type { ToolContext } from "../types.js";
|
||||
import { detectContext, generateGuardrails } from "../context-guard.js";
|
||||
import { isDevTier, isTier, type Tier } from "../tiers.js";
|
||||
import { notify, getNotificationConfig } from "../notify.js";
|
||||
|
||||
/** Labels that map to DEV role */
|
||||
const DEV_LABELS: StateLabel[] = ["To Do", "To Improve"];
|
||||
@@ -300,7 +301,28 @@ export function createTaskPickupTool(api: OpenClawPluginApi) {
|
||||
pluginConfig,
|
||||
});
|
||||
|
||||
// 9. Build result
|
||||
// 9. Send notification to project group
|
||||
const notifyConfig = getNotificationConfig(pluginConfig);
|
||||
await notify(
|
||||
{
|
||||
type: "workerStart",
|
||||
project: project.name,
|
||||
groupId,
|
||||
issueId: issue.iid,
|
||||
issueTitle: issue.title,
|
||||
role,
|
||||
model: dispatchResult.modelAlias,
|
||||
sessionAction: dispatchResult.sessionAction,
|
||||
},
|
||||
{
|
||||
workspaceDir,
|
||||
config: notifyConfig,
|
||||
groupId,
|
||||
channel: context.channel,
|
||||
},
|
||||
);
|
||||
|
||||
// 10. Build result
|
||||
const result: Record<string, unknown> = {
|
||||
success: true,
|
||||
project: project.name,
|
||||
|
||||
Reference in New Issue
Block a user