feat: Claude Code Monitor — lanes, pipelines and a merged workspace
Internal SmartGift build of a Claude Code monitoring dashboard. Lanes: a durable unit of parallel agent work, one per working directory, tracked across session restarts. Managed lanes are git worktrees the dashboard provisions and can reset or remove behind a three-check destroy guard and a counted preflight; adopted lanes are directories you already own and are never destroyable. Pipelines: a lane moves through pipeline stages. A stage the agent declares with evidence renders green; a stage inferred from the tool-event stream renders dashed amber and never counts as done. Detection is forward-only within a 30-minute window, and never writes the declared stage. Workspace: one page at /run with a lane grid, the selected lane's pipeline, and a full Claude console behind a disclosure.
This commit is contained in:
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* @file Universal webhook delivery for fired alerts. A "target" is an outbound
|
||||
* destination described by the provider registry (server/lib/webhook-providers.js)
|
||||
* — Slack, Discord, Teams, Mattermost, Rocket.Chat, Telegram, PagerDuty,
|
||||
* Opsgenie, Splunk On-Call, Zapier, Make, n8n, Pipedream, or a generic endpoint.
|
||||
* When the alerting engine fires an alert (server/lib/alerts.js), it calls
|
||||
* dispatchAlert(), which formats the provider-native payload and POSTs it to
|
||||
* every enabled target (optionally scoped to specific rules) with a timeout and
|
||||
* bounded retry/backoff. Every attempt-chain is recorded in webhook_deliveries.
|
||||
*
|
||||
* Delivery is detached and fully fail-safe: it never throws into, slows, or
|
||||
* blocks the alert path or hook ingestion.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const crypto = require("crypto");
|
||||
const { stmts } = require("../db");
|
||||
const {
|
||||
PROVIDERS,
|
||||
WEBHOOK_TYPES,
|
||||
isGenericFamily,
|
||||
resolveUrl,
|
||||
resolveAuthHeaders,
|
||||
formatPayload,
|
||||
truncate,
|
||||
} = require("./webhook-providers");
|
||||
|
||||
// Tunables (env-overridable so tests can shrink timeouts/backoff). All read at
|
||||
// module load — restart to change.
|
||||
function posEnv(name, fallback) {
|
||||
const raw = parseInt(process.env[name], 10);
|
||||
return Number.isFinite(raw) && raw > 0 ? raw : fallback;
|
||||
}
|
||||
const TIMEOUT_MS = posEnv("WEBHOOK_TIMEOUT_MS", 10_000);
|
||||
const MAX_ATTEMPTS = posEnv("WEBHOOK_MAX_ATTEMPTS", 3);
|
||||
const RETRY_BASE_MS = posEnv("WEBHOOK_RETRY_BASE_MS", 1500);
|
||||
|
||||
// Enabled-target cache. Alert fires are hot; targets only change through the
|
||||
// CRUD routes, which call invalidateWebhookCache().
|
||||
let targetsCache = null;
|
||||
|
||||
function invalidateWebhookCache() {
|
||||
targetsCache = null;
|
||||
}
|
||||
|
||||
/** Parse the JSON columns and coerce the enabled flag for a raw target row. */
|
||||
function normalizeTarget(row) {
|
||||
if (!row) return null;
|
||||
let headers = null;
|
||||
let ruleIds = null;
|
||||
let config = null;
|
||||
try {
|
||||
headers = row.headers ? JSON.parse(row.headers) : null;
|
||||
} catch {
|
||||
/* tolerate hand-edited bad JSON — extra headers simply not applied */
|
||||
}
|
||||
try {
|
||||
ruleIds = row.rule_ids ? JSON.parse(row.rule_ids) : null;
|
||||
} catch {
|
||||
/* tolerate bad JSON — target falls back to "all rules" */
|
||||
}
|
||||
try {
|
||||
config = row.config ? JSON.parse(row.config) : null;
|
||||
} catch {
|
||||
/* tolerate bad JSON — provider config falls back to empty */
|
||||
}
|
||||
return { ...row, enabled: row.enabled === 1, headers, rule_ids: ruleIds, config };
|
||||
}
|
||||
|
||||
function loadEnabledTargets() {
|
||||
if (targetsCache) return targetsCache;
|
||||
targetsCache = stmts.listEnabledWebhookTargets.all().map(normalizeTarget);
|
||||
return targetsCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the HTTP request for a target + alert: resolved URL, provider-native
|
||||
* serialized body, and headers (provider auth headers, plus custom headers and
|
||||
* an optional HMAC-SHA256 signature for the generic family). Exported for tests.
|
||||
*/
|
||||
function buildRequest(target, alert) {
|
||||
const url = resolveUrl(target);
|
||||
if (!url) throw new Error(`no URL resolved for webhook type "${target.type}"`);
|
||||
|
||||
const payload = formatPayload(target.type, alert, target.config || {});
|
||||
const body = JSON.stringify(payload);
|
||||
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "claude-code-agent-monitor/webhooks",
|
||||
...resolveAuthHeaders(target),
|
||||
};
|
||||
|
||||
if (isGenericFamily(target.type)) {
|
||||
if (target.headers && typeof target.headers === "object") {
|
||||
for (const [k, v] of Object.entries(target.headers)) {
|
||||
if (typeof k !== "string" || typeof v !== "string") continue;
|
||||
// Never let a custom header clobber Content-Type or the signature.
|
||||
const lower = k.toLowerCase();
|
||||
if (lower === "content-type" || lower === "x-webhook-signature") continue;
|
||||
headers[k] = v;
|
||||
}
|
||||
}
|
||||
if (target.secret) {
|
||||
const ts = new Date().toISOString();
|
||||
const sig = crypto.createHmac("sha256", target.secret).update(`${ts}.${body}`).digest("hex");
|
||||
headers["X-Webhook-Timestamp"] = ts;
|
||||
headers["X-Webhook-Signature"] = `sha256=${sig}`;
|
||||
}
|
||||
}
|
||||
|
||||
return { url, body, headers };
|
||||
}
|
||||
|
||||
// ── Delivery ────────────────────────────────────────────────────────────────
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => {
|
||||
const t = setTimeout(resolve, ms);
|
||||
if (t.unref) t.unref();
|
||||
});
|
||||
}
|
||||
|
||||
async function postOnce(url, body, headers) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
if (timer.unref) timer.unref();
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body,
|
||||
signal: controller.signal,
|
||||
redirect: "follow",
|
||||
});
|
||||
// Read the response body — some providers (Splunk On-Call) signal failure
|
||||
// in the body despite a 200, so deliver() may need to inspect it. Also
|
||||
// frees the socket promptly. (Named distinctly from the `body` param.)
|
||||
let responseBody = "";
|
||||
try {
|
||||
responseBody = await res.text();
|
||||
} catch {
|
||||
/* body read is best-effort */
|
||||
}
|
||||
return {
|
||||
ok: res.status >= 200 && res.status < 300,
|
||||
status: res.status,
|
||||
error: null,
|
||||
body: responseBody,
|
||||
};
|
||||
} catch (err) {
|
||||
const timedOut = err?.name === "AbortError";
|
||||
return {
|
||||
ok: false,
|
||||
status: null,
|
||||
error: timedOut ? "timeout" : err?.message || "network error",
|
||||
body: "",
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function recordDelivery(target, alertId, { status, statusCode, attempts, error }) {
|
||||
try {
|
||||
stmts.insertWebhookDelivery.run(
|
||||
target.id,
|
||||
target.name,
|
||||
target.type,
|
||||
alertId == null ? null : alertId,
|
||||
status,
|
||||
statusCode == null ? null : statusCode,
|
||||
attempts,
|
||||
error == null ? null : truncate(error, 500)
|
||||
);
|
||||
stmts.pruneWebhookDeliveries.run();
|
||||
} catch (err) {
|
||||
console.warn("[WEBHOOK] delivery log write failed:", err?.message || err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver one alert to one target with bounded retry. Retries on transport
|
||||
* errors, HTTP 429, and 5xx; gives up immediately on other 4xx (misconfigured
|
||||
* URL / bad payload won't fix themselves). Always records the outcome and
|
||||
* never throws. Returns `{ ok, status, attempts, error }`.
|
||||
*/
|
||||
async function deliver(target, alert) {
|
||||
let built;
|
||||
try {
|
||||
built = buildRequest(target, alert);
|
||||
} catch (err) {
|
||||
recordDelivery(target, alert.id, {
|
||||
status: "failed",
|
||||
statusCode: null,
|
||||
attempts: 0,
|
||||
error: `request build failed: ${err?.message || err}`,
|
||||
});
|
||||
return { ok: false, status: null, attempts: 0, error: "request build failed" };
|
||||
}
|
||||
|
||||
let attempts = 0;
|
||||
let status = null;
|
||||
let error = null;
|
||||
const verifyResponse = PROVIDERS[target.type]?.verifyResponse;
|
||||
|
||||
while (attempts < MAX_ATTEMPTS) {
|
||||
attempts += 1;
|
||||
const res = await postOnce(built.url, built.body, built.headers);
|
||||
status = res.status;
|
||||
error = res.error;
|
||||
if (res.ok) {
|
||||
// Some providers (Splunk On-Call) return 200 even on rejection — let the
|
||||
// provider veto a "successful" status by inspecting the response body.
|
||||
const verdict = verifyResponse ? verifyResponse(res.body) : { ok: true };
|
||||
if (verdict.ok) {
|
||||
recordDelivery(target, alert.id, {
|
||||
status: "success",
|
||||
statusCode: status,
|
||||
attempts,
|
||||
error: null,
|
||||
});
|
||||
return { ok: true, status, attempts };
|
||||
}
|
||||
// A logical rejection won't fix on retry — fail immediately.
|
||||
error = verdict.error || "provider reported failure";
|
||||
break;
|
||||
}
|
||||
const retryable = status == null || status === 429 || status >= 500;
|
||||
if (!retryable || attempts >= MAX_ATTEMPTS) break;
|
||||
await sleep(RETRY_BASE_MS * attempts);
|
||||
}
|
||||
|
||||
recordDelivery(target, alert.id, {
|
||||
status: "failed",
|
||||
statusCode: status,
|
||||
attempts,
|
||||
error: error || (status ? `HTTP ${status}` : "request failed"),
|
||||
});
|
||||
return {
|
||||
ok: false,
|
||||
status,
|
||||
attempts,
|
||||
error: error || (status ? `HTTP ${status}` : "request failed"),
|
||||
};
|
||||
}
|
||||
|
||||
/** A target receives an alert when it has no rule scope, or the alert's rule is in scope. */
|
||||
function targetAppliesTo(target, alert) {
|
||||
if (!Array.isArray(target.rule_ids) || target.rule_ids.length === 0) return true;
|
||||
return target.rule_ids.includes(alert.rule_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fan an alert out to every enabled, in-scope target. Returns a promise that
|
||||
* settles when all deliveries finish (used by tests); callers in the alert
|
||||
* path invoke it fire-and-forget. Never rejects.
|
||||
*/
|
||||
function dispatchAlert(alert) {
|
||||
let targets;
|
||||
try {
|
||||
targets = loadEnabledTargets();
|
||||
} catch (err) {
|
||||
console.warn("[WEBHOOK] target load failed:", err?.message || err);
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
const applicable = targets.filter((t) => {
|
||||
try {
|
||||
return targetAppliesTo(t, alert);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
if (applicable.length === 0) return Promise.resolve([]);
|
||||
return Promise.allSettled(applicable.map((t) => deliver(t, alert)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a synthetic test alert to a single (already DB-loaded, un-redacted)
|
||||
* target. Awaits the result so the route can report success/failure inline.
|
||||
*/
|
||||
function sendTest(target) {
|
||||
const alert = {
|
||||
id: null,
|
||||
rule_id: null,
|
||||
rule_name: "Webhook test",
|
||||
rule_type: "test",
|
||||
session_id: null,
|
||||
agent_id: null,
|
||||
message: `Test notification from Claude Code Agent Monitor to "${target.name}". If you can read this, delivery works.`,
|
||||
details: { test: true, target: target.name, type: target.type },
|
||||
triggered_at: new Date().toISOString(),
|
||||
};
|
||||
return deliver(target, alert);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PROVIDERS,
|
||||
WEBHOOK_TYPES,
|
||||
invalidateWebhookCache,
|
||||
loadEnabledTargets,
|
||||
normalizeTarget,
|
||||
formatPayload,
|
||||
buildRequest,
|
||||
deliver,
|
||||
dispatchAlert,
|
||||
sendTest,
|
||||
targetAppliesTo,
|
||||
};
|
||||
Reference in New Issue
Block a user