Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions docs/alert_automation_plugin.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,8 +182,29 @@ JSON/body limits and full-batch intake are enforced before acknowledgement.
Repeated deliveries share the same incident key. `both` mode can reconcile
source state through polling while using webhook events for prompt intake.

The authenticated HTTP handler validates and persists the batch, then wakes
work under the context owned by the native service lifecycle. It does not run
the Agent under the inbound HTTP client's Gateway scopes, and it does not grant
that client `operator.write`. This separation is required for native subagent
dispatch from a plugin-authenticated Webhook route.

In Webhook-only mode, source recovery also clears the confirmed imported alert
from the Mosaic UI. The service retries that reconciliation on its maintenance
timer without fetching Alertmanager. A newer unresolved occurrence with the
same fingerprint prevents an older recovery from clearing its UI entry.
`pollSeconds` still controls periodic queue/recovery maintenance in this mode;
new Webhooks wake the service immediately and do not wait for a source poll.

## Task state, report integrity and shutdown

The recipient prompt hook reads its report binding and integrity-checked full
report from the durable state directory. Agent hook registries can be created
without starting the Gateway service; follow-up context must not depend on a
service instance held in one plugin registration's memory. Each applied context
has a witness with the actual input prompt, receiving session, incident and full
report hash/context. If a native channel turn does not expose a run ID, the
witness has a separate generated identifier and a null native run ID.

The queue uses fingerprint **and** alert start time, matching the legacy worker.
An alert firing again later is a new occurrence. Polling silence/inhibition does
not imply resolution. Webhook `resolved` events and complete polling snapshots
Expand Down
Binary file modified helm/mosaic-stack/files/agent-documentation.tar.gz
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// SPDX-License-Identifier: Apache-2.0

import { createHash } from "node:crypto";
import { AsyncResource } from "node:async_hooks";
import {
mkdirSync,
readFileSync,
Expand Down Expand Up @@ -271,6 +272,56 @@ export function matches(alert: Json, labels: Record<string, string>) {
);
}

function reportContextFromDatabase(
db: DatabaseSync,
root: string,
sessionKey: string,
prompt = "",
) {
const records = db
.prepare(
"SELECT * FROM report_contexts WHERE session_key=? OR substr(?,1,length(session_key)+1)=session_key||':' ORDER BY created_at DESC",
)
.all(sessionKey, sessionKey) as Json[];
const selected =
records.find(
(r) => prompt.includes(r.incident_id) || prompt.includes(r.run_id),
) ?? records[0];
if (!selected) return undefined;
const report = readFileSync(
path.join(root, "incidents", selected.incident_id, "published-report.txt"),
"utf8",
);
if (sha256(report) !== selected.report_sha256)
throw new Error("Stored report context integrity mismatch");
return { ...selected, report };
}

/** Agent hook registries need not own a started Gateway service instance. */
export function readStoredReportContext(
root: string,
sessionKey: string,
prompt = "",
) {
const filename = path.join(root, "state.sqlite3");
if (!existsSync(filename)) return undefined;
const db = new DatabaseSync(filename, { readOnly: true });
try {
db.exec("PRAGMA busy_timeout=5000");
if (
!db
.prepare(
"SELECT name FROM sqlite_schema WHERE type='table' AND name='report_contexts'",
)
.get()
)
return undefined;
return reportContextFromDatabase(db, root, sessionKey, prompt);
} finally {
db.close();
}
}

/** Same incident/event schema as the legacy worker, permitting an explicit stopped-worker migration. */
export class Store {
root: string;
Expand Down Expand Up @@ -414,23 +465,7 @@ export class Store {
.run(id, sessionKey, runId, sha256(report), now());
}
reportContext(sessionKey: string, prompt = "") {
const records = this.db
.prepare(
"SELECT * FROM report_contexts WHERE session_key=? OR substr(?,1,length(session_key)+1)=session_key||':' ORDER BY created_at DESC",
)
.all(sessionKey, sessionKey) as Json[];
const selected =
records.find(
(r) => prompt.includes(r.incident_id) || prompt.includes(r.run_id),
) ?? records[0];
if (!selected) return undefined;
const report = readFileSync(
path.join(this.directory(selected.incident_id), "published-report.txt"),
"utf8",
);
if (sha256(report) !== selected.report_sha256)
throw new Error("Stored report context integrity mismatch");
return { ...selected, report };
return reportContextFromDatabase(this.db, this.root, sessionKey, prompt);
}
close() {
this.db.close();
Expand Down Expand Up @@ -598,6 +633,9 @@ export class AlertService {
timer: ReturnType<typeof setInterval> | undefined;
polling = false;
processing: Promise<void> | undefined;
// Created by the native service lifecycle, before any inbound HTTP request.
// Background work must retain service ownership rather than a webhook caller's scopes.
workContext = new AsyncResource("mosaic-alert-automation-service");
logger: { info: (text: string) => void; error: (text: string) => void };
constructor(
config: Config,
Expand Down Expand Up @@ -630,20 +668,24 @@ export class AlertService {
}
start() {
if (this.timer) return;
const tick = () => {
this.timer = setInterval(() => this.wake(), this.config.pollSeconds * 1000);
this.timer.unref();
this.wake();
}
wake() {
if (this.controller.signal.aborted) return;
this.workContext.runInAsyncScope(() => {
void this.tick().catch((error) =>
this.logger.error(`alert-automation tick: ${error.name}`),
);
};
this.timer = setInterval(tick, this.config.pollSeconds * 1000);
this.timer.unref();
tick();
});
}
async tick() {
if (this.polling || this.controller.signal.aborted) return;
this.polling = true;
try {
if (this.config.sourceMode !== "webhook") await this.poll();
else await this.reconcileResolvedUi();
this.kick();
} finally {
this.polling = false;
Expand Down Expand Up @@ -681,12 +723,24 @@ export class AlertService {
at: now(),
selected: selected.length,
});
await this.reconcileResolvedUi(new Set(matching.map((a) => a.fingerprint)));
}
async reconcileResolvedUi(presentFingerprints?: Set<string>) {
const config = this.config;
const present =
presentFingerprints ??
new Set(
this.store
.rows()
.filter((row) => !row.resolved_at)
.map((row) => row.fingerprint),
);
if (config.mosaicUrl)
for (const row of this.store.rows()) {
if (
!row.resolved_at ||
row.ui_cleared_at ||
matching.some((a) => a.fingerprint === row.fingerprint)
present.has(row.fingerprint)
)
continue;
if (
Expand Down Expand Up @@ -749,7 +803,7 @@ export class AlertService {
),
{ at: now(), payload: body },
);
this.kick();
this.wake();
return { accepted: selected.length };
}
kick() {
Expand Down Expand Up @@ -1024,6 +1078,7 @@ export class AlertService {
this.controller.abort();
if (this.timer) clearInterval(this.timer);
this.timer = undefined;
this.workContext.emitDestroy();
// Native Agent runs may outlive shutdown. No new investigation or send is permitted afterwards.
if (this.processing) void this.processing.finally(() => this.store.close());
else this.store.close();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { timingSafeEqual } from "node:crypto";
import { randomUUID, timingSafeEqual } from "node:crypto";
import path from "node:path";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { AlertService, normalizeConfig, save } from "./core.ts";
import {
AlertService,
normalizeConfig,
readStoredReportContext,
save,
} from "./core.ts";
import { nativeRuntime } from "./runtime.ts";

export function registerAlertAutomation(api: OpenClawPluginApi) {
Expand Down Expand Up @@ -35,8 +40,12 @@ export function registerAlertAutomation(api: OpenClawPluginApi) {
// This restores the complete report on every human follow-up, rather than
// relying on a transcript-only delivery mirror or an extra synthetic ACK turn.
api.on("before_prompt_build", (event, ctx) => {
if (!service || !ctx.sessionKey) return;
const binding = service.store.reportContext(
if (!ctx.sessionKey) return;
const root =
service?.store.root ??
path.join(api.runtime.state.resolveStateDir(), config.stateDirectory);
const binding = readStoredReportContext(
root,
ctx.sessionKey,
event.prompt ?? "",
);
Expand All @@ -49,24 +58,30 @@ export function registerAlertAutomation(api: OpenClawPluginApi) {
`Incident: ${binding.incident_id}\nRun ID: ${binding.run_id}\nReport SHA256: ${binding.report_sha256}\n\n` +
binding.report +
"\n[End MOSAIC automated alert report context]";
if (ctx.runId)
save(
path.join(
service.store.directory(binding.incident_id),
"context-use",
ctx.runId.replace(/[^a-zA-Z0-9_-]/g, "_") + ".json",
),
{
at: new Date().toISOString(),
sessionKey: ctx.sessionKey,
runId: ctx.runId,
reportIncidentId: binding.incident_id,
completeReportSha256: binding.report_sha256,
completeReportChars: binding.report.length,
mechanism: "before_prompt_build.prependContext",
completeContext: prependContext,
},
);
const witnessId = ctx.runId ?? randomUUID();
save(
path.join(
root,
"incidents",
binding.incident_id,
"context-use",
witnessId.replace(/[^a-zA-Z0-9_-]/g, "_") + ".json",
),
{
at: new Date().toISOString(),
sessionKey: ctx.sessionKey,
runId: ctx.runId ?? null,
witnessId,
inputPrompt: event.prompt ?? "",
serviceStartedInThisRegistration: Boolean(service),
lookupMode: "durable-readonly",
reportIncidentId: binding.incident_id,
completeReportSha256: binding.report_sha256,
completeReportChars: binding.report.length,
mechanism: "before_prompt_build.prependContext",
completeContext: prependContext,
},
);
return { prependContext };
});
api.registerGatewayMethod(
Expand Down
Loading
Loading