@coffeexdev/openclaw-sentinel 0.2.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -8
- package/dist/callbackEnvelope.d.ts +8 -0
- package/dist/callbackEnvelope.js +100 -0
- package/dist/cli.js +0 -0
- package/dist/configSchema.js +0 -1
- package/dist/index.js +132 -19
- package/dist/strategies/base.d.ts +4 -1
- package/dist/strategies/websocket.js +13 -4
- package/dist/template.d.ts +4 -1
- package/dist/template.js +16 -12
- package/dist/tool.d.ts +7 -1
- package/dist/tool.js +19 -3
- package/dist/toolSchema.d.ts +11 -1
- package/dist/toolSchema.js +24 -1
- package/dist/types.d.ts +26 -1
- package/dist/validator.js +24 -1
- package/dist/watcherManager.d.ts +13 -3
- package/dist/watcherManager.js +79 -16
- package/openclaw.plugin.json +0 -1
- package/package.json +13 -14
package/README.md
CHANGED
|
@@ -58,6 +58,14 @@ openclaw gateway restart
|
|
|
58
58
|
"fire": {
|
|
59
59
|
"webhookPath": "/hooks/sentinel",
|
|
60
60
|
"eventName": "eth_target_hit",
|
|
61
|
+
"intent": "price_threshold_review",
|
|
62
|
+
"contextTemplate": {
|
|
63
|
+
"asset": "ETH",
|
|
64
|
+
"priceUsd": "${payload.ethereum.usd}",
|
|
65
|
+
"workflow": "alerts"
|
|
66
|
+
},
|
|
67
|
+
"priority": "high",
|
|
68
|
+
"deadlineTemplate": "${timestamp}",
|
|
61
69
|
"payloadTemplate": {
|
|
62
70
|
"event": "${event.name}",
|
|
63
71
|
"price": "${payload.ethereum.usd}",
|
|
@@ -87,12 +95,35 @@ Use `sentinel_control`:
|
|
|
87
95
|
## What happens when a watcher fires?
|
|
88
96
|
|
|
89
97
|
1. Sentinel evaluates conditions.
|
|
90
|
-
2. On match, it dispatches to `localDispatchBase + webhookPath`.
|
|
91
|
-
3.
|
|
92
|
-
4.
|
|
98
|
+
2. On match, it dispatches a generic callback envelope (`type: "sentinel.callback"`) to `localDispatchBase + webhookPath`.
|
|
99
|
+
3. The envelope includes stable keys (`intent`, `context`, `watcher`, `trigger`, bounded `payload`, `deliveryTargets`, `source`) so downstream agent behavior is workflow-agnostic.
|
|
100
|
+
4. It also sends a notification message to each configured `deliveryTargets` destination (defaults to the current chat context when watcher is created from a channel session).
|
|
101
|
+
5. For `/hooks/sentinel`, the plugin route enqueues an instruction-prefixed system event plus structured JSON envelope and requests heartbeat wake.
|
|
102
|
+
6. OpenClaw wakes and processes that event in the configured session (`hookSessionKey`, default `agent:main:main`).
|
|
93
103
|
|
|
94
104
|
The `/hooks/sentinel` route is auto-registered on plugin startup (idempotent).
|
|
95
105
|
|
|
106
|
+
Sample emitted envelope:
|
|
107
|
+
|
|
108
|
+
```json
|
|
109
|
+
{
|
|
110
|
+
"type": "sentinel.callback",
|
|
111
|
+
"version": "1",
|
|
112
|
+
"intent": "price_threshold_review",
|
|
113
|
+
"actionable": true,
|
|
114
|
+
"watcher": { "id": "eth-price-watch", "skillId": "skills.alerts", "eventName": "eth_target_hit" },
|
|
115
|
+
"trigger": {
|
|
116
|
+
"matchedAt": "2026-03-04T15:00:00.000Z",
|
|
117
|
+
"dedupeKey": "<sha256>",
|
|
118
|
+
"priority": "high"
|
|
119
|
+
},
|
|
120
|
+
"context": { "asset": "ETH", "priceUsd": 5001, "workflow": "alerts" },
|
|
121
|
+
"payload": { "ethereum": { "usd": 5001 } },
|
|
122
|
+
"deliveryTargets": [{ "channel": "telegram", "to": "5613673222" }],
|
|
123
|
+
"source": { "plugin": "openclaw-sentinel", "route": "/hooks/sentinel" }
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
96
127
|
## Why Sentinel
|
|
97
128
|
|
|
98
129
|
Sentinel runs watcher lifecycles inside the gateway with fixed strategies and declarative conditions.
|
|
@@ -143,11 +174,17 @@ It **does not** execute user-authored code from watcher definitions.
|
|
|
143
174
|
"ts": "${timestamp}"
|
|
144
175
|
}
|
|
145
176
|
},
|
|
146
|
-
"retry": { "maxRetries": 5, "baseMs": 250, "maxMs": 5000 }
|
|
177
|
+
"retry": { "maxRetries": 5, "baseMs": 250, "maxMs": 5000 },
|
|
178
|
+
"deliveryTargets": [
|
|
179
|
+
{ "channel": "telegram", "to": "5613673222" },
|
|
180
|
+
{ "channel": "discord", "to": "123456789012345678", "accountId": "main" }
|
|
181
|
+
]
|
|
147
182
|
}
|
|
148
183
|
}
|
|
149
184
|
```
|
|
150
185
|
|
|
186
|
+
`deliveryTargets` is optional. If omitted on `create`, Sentinel infers a default target from the current tool/session context (channel + current peer).
|
|
187
|
+
|
|
151
188
|
## Runtime controls
|
|
152
189
|
|
|
153
190
|
```json
|
|
@@ -185,10 +222,10 @@ openclaw-sentinel audit
|
|
|
185
222
|
## Development
|
|
186
223
|
|
|
187
224
|
```bash
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
225
|
+
pnpm install
|
|
226
|
+
pnpm run lint
|
|
227
|
+
pnpm test
|
|
228
|
+
pnpm run build
|
|
192
229
|
```
|
|
193
230
|
|
|
194
231
|
## License
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { WatcherDefinition } from "./types.js";
|
|
2
|
+
export declare function createCallbackEnvelope(args: {
|
|
3
|
+
watcher: WatcherDefinition;
|
|
4
|
+
payload: unknown;
|
|
5
|
+
payloadBody: Record<string, unknown>;
|
|
6
|
+
matchedAt: string;
|
|
7
|
+
webhookPath: string;
|
|
8
|
+
}): Record<string, unknown>;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { renderTemplate } from "./template.js";
|
|
3
|
+
const MAX_PAYLOAD_JSON_CHARS = 4000;
|
|
4
|
+
function toIntent(eventName) {
|
|
5
|
+
return (eventName
|
|
6
|
+
.replace(/[^a-zA-Z0-9]+/g, " ")
|
|
7
|
+
.trim()
|
|
8
|
+
.toLowerCase()
|
|
9
|
+
.replace(/\s+/g, "_") || "sentinel_event");
|
|
10
|
+
}
|
|
11
|
+
function summarizePayload(payload) {
|
|
12
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
13
|
+
return { summary: String(payload) };
|
|
14
|
+
}
|
|
15
|
+
const obj = payload;
|
|
16
|
+
const out = {};
|
|
17
|
+
for (const key of Object.keys(obj).slice(0, 12))
|
|
18
|
+
out[key] = obj[key];
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
function truncatePayload(payload) {
|
|
22
|
+
const serialized = JSON.stringify(payload);
|
|
23
|
+
if (!serialized)
|
|
24
|
+
return payload;
|
|
25
|
+
if (serialized.length <= MAX_PAYLOAD_JSON_CHARS)
|
|
26
|
+
return payload;
|
|
27
|
+
return {
|
|
28
|
+
truncated: true,
|
|
29
|
+
maxChars: MAX_PAYLOAD_JSON_CHARS,
|
|
30
|
+
preview: serialized.slice(0, MAX_PAYLOAD_JSON_CHARS),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function getPath(obj, path) {
|
|
34
|
+
return path.split(".").reduce((acc, part) => acc?.[part], obj);
|
|
35
|
+
}
|
|
36
|
+
function getTemplateString(value, context) {
|
|
37
|
+
if (!value)
|
|
38
|
+
return undefined;
|
|
39
|
+
if (!value.includes("${"))
|
|
40
|
+
return value;
|
|
41
|
+
if (/^\$\{[^}]+\}$/.test(value)) {
|
|
42
|
+
const rendered = renderTemplate({ value }, context);
|
|
43
|
+
const resolved = rendered.value;
|
|
44
|
+
if (resolved === undefined || resolved === null)
|
|
45
|
+
return undefined;
|
|
46
|
+
return String(resolved);
|
|
47
|
+
}
|
|
48
|
+
return value.replaceAll(/\$\{([^}]+)\}/g, (_full, path) => {
|
|
49
|
+
if (!/^(watcher\.(id|skillId)|event\.(name)|payload\.[a-zA-Z0-9_.-]+|timestamp)$/.test(path)) {
|
|
50
|
+
throw new Error(`Template placeholder not allowed: $\{${path}\}`);
|
|
51
|
+
}
|
|
52
|
+
const resolved = getPath(context, path);
|
|
53
|
+
if (resolved === undefined || resolved === null) {
|
|
54
|
+
throw new Error(`Template placeholder unresolved: $\{${path}\}`);
|
|
55
|
+
}
|
|
56
|
+
return String(resolved);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
export function createCallbackEnvelope(args) {
|
|
60
|
+
const { watcher, payload, payloadBody, matchedAt, webhookPath } = args;
|
|
61
|
+
const context = {
|
|
62
|
+
watcher,
|
|
63
|
+
event: { name: watcher.fire.eventName },
|
|
64
|
+
payload,
|
|
65
|
+
timestamp: matchedAt,
|
|
66
|
+
};
|
|
67
|
+
const intent = watcher.fire.intent ?? toIntent(watcher.fire.eventName);
|
|
68
|
+
const renderedContext = watcher.fire.contextTemplate
|
|
69
|
+
? renderTemplate(watcher.fire.contextTemplate, context)
|
|
70
|
+
: payloadBody;
|
|
71
|
+
const priority = watcher.fire.priority ?? "normal";
|
|
72
|
+
const deadline = getTemplateString(watcher.fire.deadlineTemplate, context);
|
|
73
|
+
const dedupeSeed = getTemplateString(watcher.fire.dedupeKeyTemplate, context) ??
|
|
74
|
+
`${watcher.id}|${watcher.fire.eventName}|${matchedAt}`;
|
|
75
|
+
const dedupeKey = createHash("sha256").update(dedupeSeed).digest("hex");
|
|
76
|
+
return {
|
|
77
|
+
type: "sentinel.callback",
|
|
78
|
+
version: "1",
|
|
79
|
+
intent,
|
|
80
|
+
actionable: true,
|
|
81
|
+
watcher: {
|
|
82
|
+
id: watcher.id,
|
|
83
|
+
skillId: watcher.skillId,
|
|
84
|
+
eventName: watcher.fire.eventName,
|
|
85
|
+
},
|
|
86
|
+
trigger: {
|
|
87
|
+
matchedAt,
|
|
88
|
+
dedupeKey,
|
|
89
|
+
priority,
|
|
90
|
+
...(deadline ? { deadline } : {}),
|
|
91
|
+
},
|
|
92
|
+
context: renderedContext ?? summarizePayload(payload),
|
|
93
|
+
payload: truncatePayload(payload),
|
|
94
|
+
deliveryTargets: watcher.deliveryTargets ?? [],
|
|
95
|
+
source: {
|
|
96
|
+
plugin: "openclaw-sentinel",
|
|
97
|
+
route: webhookPath,
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
package/dist/cli.js
CHANGED
|
File without changes
|
package/dist/configSchema.js
CHANGED
package/dist/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { sentinelConfigSchema } from "./configSchema.js";
|
|
2
3
|
import { registerSentinelControl } from "./tool.js";
|
|
3
4
|
import { DEFAULT_SENTINEL_WEBHOOK_PATH } from "./types.js";
|
|
@@ -5,14 +6,9 @@ import { WatcherManager } from "./watcherManager.js";
|
|
|
5
6
|
const registeredWebhookPathsByRegistrar = new WeakMap();
|
|
6
7
|
const DEFAULT_HOOK_SESSION_KEY = "agent:main:main";
|
|
7
8
|
const MAX_SENTINEL_WEBHOOK_BODY_BYTES = 64 * 1024;
|
|
8
|
-
const MAX_SENTINEL_WEBHOOK_TEXT_CHARS =
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
if (!trimmed)
|
|
12
|
-
return DEFAULT_SENTINEL_WEBHOOK_PATH;
|
|
13
|
-
const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
14
|
-
return withSlash.length > 1 && withSlash.endsWith("/") ? withSlash.slice(0, -1) : withSlash;
|
|
15
|
-
}
|
|
9
|
+
const MAX_SENTINEL_WEBHOOK_TEXT_CHARS = 8000;
|
|
10
|
+
const MAX_SENTINEL_PAYLOAD_JSON_CHARS = 2500;
|
|
11
|
+
const SENTINEL_EVENT_INSTRUCTION_PREFIX = "SENTINEL_TRIGGER: This system event came from /hooks/sentinel. Evaluate action policy, decide whether to notify configured deliveryTargets, and execute safe follow-up actions.";
|
|
16
12
|
function trimText(value, max) {
|
|
17
13
|
return value.length <= max ? value : `${value.slice(0, max)}…`;
|
|
18
14
|
}
|
|
@@ -22,22 +18,93 @@ function asString(value) {
|
|
|
22
18
|
const trimmed = value.trim();
|
|
23
19
|
return trimmed.length > 0 ? trimmed : undefined;
|
|
24
20
|
}
|
|
21
|
+
function asIsoString(value) {
|
|
22
|
+
const text = asString(value);
|
|
23
|
+
if (!text)
|
|
24
|
+
return undefined;
|
|
25
|
+
const timestamp = Date.parse(text);
|
|
26
|
+
return Number.isNaN(timestamp) ? undefined : new Date(timestamp).toISOString();
|
|
27
|
+
}
|
|
25
28
|
function isRecord(value) {
|
|
26
29
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
27
30
|
}
|
|
28
|
-
function
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
function isDeliveryTarget(value) {
|
|
32
|
+
return (isRecord(value) &&
|
|
33
|
+
typeof value.channel === "string" &&
|
|
34
|
+
typeof value.to === "string" &&
|
|
35
|
+
(value.accountId === undefined || typeof value.accountId === "string"));
|
|
36
|
+
}
|
|
37
|
+
function normalizePath(path) {
|
|
38
|
+
const trimmed = path.trim();
|
|
39
|
+
if (!trimmed)
|
|
40
|
+
return DEFAULT_SENTINEL_WEBHOOK_PATH;
|
|
41
|
+
const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
42
|
+
return withSlash.length > 1 && withSlash.endsWith("/") ? withSlash.slice(0, -1) : withSlash;
|
|
43
|
+
}
|
|
44
|
+
function clipPayloadForPrompt(value) {
|
|
45
|
+
const serialized = JSON.stringify(value);
|
|
46
|
+
if (!serialized)
|
|
47
|
+
return value;
|
|
48
|
+
if (serialized.length <= MAX_SENTINEL_PAYLOAD_JSON_CHARS)
|
|
49
|
+
return value;
|
|
50
|
+
const clipped = serialized.slice(0, MAX_SENTINEL_PAYLOAD_JSON_CHARS);
|
|
51
|
+
const overflow = serialized.length - clipped.length;
|
|
52
|
+
return {
|
|
53
|
+
__truncated: true,
|
|
54
|
+
truncatedChars: overflow,
|
|
55
|
+
maxChars: MAX_SENTINEL_PAYLOAD_JSON_CHARS,
|
|
56
|
+
preview: `${clipped}…`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
function buildSentinelEventEnvelope(payload) {
|
|
60
|
+
const watcherId = asString(payload.watcherId) ??
|
|
61
|
+
(isRecord(payload.watcher) ? asString(payload.watcher.id) : undefined);
|
|
33
62
|
const eventName = asString(payload.eventName) ??
|
|
34
63
|
(isRecord(payload.event) ? asString(payload.event.name) : undefined);
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
64
|
+
const skillId = asString(payload.skillId) ??
|
|
65
|
+
(isRecord(payload.watcher) ? asString(payload.watcher.skillId) : undefined) ??
|
|
66
|
+
undefined;
|
|
67
|
+
const matchedAt = asIsoString(payload.matchedAt) ?? asIsoString(payload.timestamp) ?? new Date().toISOString();
|
|
68
|
+
const rawPayload = payload.payload ??
|
|
69
|
+
(isRecord(payload.event) ? (payload.event.payload ?? payload.event.data) : undefined) ??
|
|
70
|
+
payload;
|
|
71
|
+
const boundedPayload = clipPayloadForPrompt(rawPayload);
|
|
72
|
+
const dedupeSeed = JSON.stringify({
|
|
73
|
+
watcherId: watcherId ?? null,
|
|
74
|
+
eventName: eventName ?? null,
|
|
75
|
+
matchedAt,
|
|
76
|
+
});
|
|
77
|
+
const generatedDedupe = createHash("sha256").update(dedupeSeed).digest("hex").slice(0, 16);
|
|
78
|
+
const dedupeKey = asString(payload.dedupeKey) ??
|
|
79
|
+
asString(payload.correlationId) ??
|
|
80
|
+
asString(payload.correlationID) ??
|
|
81
|
+
generatedDedupe;
|
|
82
|
+
const deliveryTargets = Array.isArray(payload.deliveryTargets)
|
|
83
|
+
? payload.deliveryTargets.filter(isDeliveryTarget)
|
|
84
|
+
: undefined;
|
|
85
|
+
const envelope = {
|
|
86
|
+
watcherId: watcherId ?? null,
|
|
87
|
+
eventName: eventName ?? null,
|
|
88
|
+
matchedAt,
|
|
89
|
+
payload: boundedPayload,
|
|
90
|
+
dedupeKey,
|
|
91
|
+
correlationId: dedupeKey,
|
|
92
|
+
source: {
|
|
93
|
+
route: DEFAULT_SENTINEL_WEBHOOK_PATH,
|
|
94
|
+
plugin: "openclaw-sentinel",
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
if (skillId)
|
|
98
|
+
envelope.skillId = skillId;
|
|
99
|
+
if (deliveryTargets && deliveryTargets.length > 0)
|
|
100
|
+
envelope.deliveryTargets = deliveryTargets;
|
|
101
|
+
return envelope;
|
|
102
|
+
}
|
|
103
|
+
function buildSentinelSystemEvent(payload) {
|
|
104
|
+
const envelope = buildSentinelEventEnvelope(payload);
|
|
105
|
+
const jsonEnvelope = JSON.stringify(envelope, null, 2);
|
|
106
|
+
const text = `${SENTINEL_EVENT_INSTRUCTION_PREFIX}\nSENTINEL_ENVELOPE_JSON:\n${jsonEnvelope}`;
|
|
107
|
+
return trimText(text, MAX_SENTINEL_WEBHOOK_TEXT_CHARS);
|
|
41
108
|
}
|
|
42
109
|
async function readSentinelWebhookPayload(req) {
|
|
43
110
|
const preParsed = req.body;
|
|
@@ -70,6 +137,47 @@ async function readSentinelWebhookPayload(req) {
|
|
|
70
137
|
}
|
|
71
138
|
return parsed;
|
|
72
139
|
}
|
|
140
|
+
async function notifyDeliveryTarget(api, target, message) {
|
|
141
|
+
switch (target.channel) {
|
|
142
|
+
case "telegram":
|
|
143
|
+
await api.runtime.channel.telegram.sendMessageTelegram(target.to, message, {
|
|
144
|
+
accountId: target.accountId,
|
|
145
|
+
});
|
|
146
|
+
return;
|
|
147
|
+
case "discord":
|
|
148
|
+
await api.runtime.channel.discord.sendMessageDiscord(target.to, message, {
|
|
149
|
+
accountId: target.accountId,
|
|
150
|
+
});
|
|
151
|
+
return;
|
|
152
|
+
case "slack":
|
|
153
|
+
await api.runtime.channel.slack.sendMessageSlack(target.to, message, {
|
|
154
|
+
accountId: target.accountId,
|
|
155
|
+
});
|
|
156
|
+
return;
|
|
157
|
+
case "signal":
|
|
158
|
+
await api.runtime.channel.signal.sendMessageSignal(target.to, message, {
|
|
159
|
+
accountId: target.accountId,
|
|
160
|
+
});
|
|
161
|
+
return;
|
|
162
|
+
case "imessage":
|
|
163
|
+
await api.runtime.channel.imessage.sendMessageIMessage(target.to, message, {
|
|
164
|
+
accountId: target.accountId,
|
|
165
|
+
});
|
|
166
|
+
return;
|
|
167
|
+
case "whatsapp":
|
|
168
|
+
await api.runtime.channel.whatsapp.sendMessageWhatsApp(target.to, message, {
|
|
169
|
+
accountId: target.accountId,
|
|
170
|
+
});
|
|
171
|
+
return;
|
|
172
|
+
case "line":
|
|
173
|
+
await api.runtime.channel.line.sendMessageLine(target.to, message, {
|
|
174
|
+
accountId: target.accountId,
|
|
175
|
+
});
|
|
176
|
+
return;
|
|
177
|
+
default:
|
|
178
|
+
throw new Error(`Unsupported delivery target channel: ${target.channel}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
73
181
|
export function createSentinelPlugin(overrides) {
|
|
74
182
|
const config = {
|
|
75
183
|
allowedHosts: [],
|
|
@@ -102,6 +210,11 @@ export function createSentinelPlugin(overrides) {
|
|
|
102
210
|
await manager.init();
|
|
103
211
|
},
|
|
104
212
|
register(api) {
|
|
213
|
+
manager.setNotifier({
|
|
214
|
+
async notify(target, message) {
|
|
215
|
+
await notifyDeliveryTarget(api, target, message);
|
|
216
|
+
},
|
|
217
|
+
});
|
|
105
218
|
registerSentinelControl(api.registerTool.bind(api), manager);
|
|
106
219
|
const path = normalizePath(DEFAULT_SENTINEL_WEBHOOK_PATH);
|
|
107
220
|
if (!api.registerHttpRoute) {
|
|
@@ -1,2 +1,5 @@
|
|
|
1
1
|
import { WatcherDefinition } from "../types.js";
|
|
2
|
-
export
|
|
2
|
+
export interface StrategyCallbacks {
|
|
3
|
+
onConnect?: () => void;
|
|
4
|
+
}
|
|
5
|
+
export type StrategyHandler = (watcher: WatcherDefinition, onPayload: (payload: unknown) => Promise<void>, onError: (error: unknown) => Promise<void>, callbacks?: StrategyCallbacks) => Promise<() => void>;
|
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
import WebSocket from "ws";
|
|
2
|
-
export const websocketStrategy = async (watcher, onPayload, onError) => {
|
|
2
|
+
export const websocketStrategy = async (watcher, onPayload, onError, callbacks) => {
|
|
3
3
|
let active = true;
|
|
4
4
|
let ws = null;
|
|
5
5
|
const connect = () => {
|
|
6
|
+
let pendingError = null;
|
|
7
|
+
let failureReported = false;
|
|
6
8
|
ws = new WebSocket(watcher.endpoint, { headers: watcher.headers });
|
|
9
|
+
ws.on("open", () => {
|
|
10
|
+
if (!active)
|
|
11
|
+
return;
|
|
12
|
+
callbacks?.onConnect?.();
|
|
13
|
+
});
|
|
7
14
|
ws.on("message", async (data) => {
|
|
8
15
|
if (!active)
|
|
9
16
|
return;
|
|
@@ -18,12 +25,14 @@ export const websocketStrategy = async (watcher, onPayload, onError) => {
|
|
|
18
25
|
ws.on("error", (err) => {
|
|
19
26
|
if (!active)
|
|
20
27
|
return;
|
|
21
|
-
|
|
28
|
+
pendingError = err instanceof Error ? err : new Error(String(err));
|
|
22
29
|
});
|
|
23
30
|
ws.on("close", (code) => {
|
|
24
|
-
if (!active)
|
|
31
|
+
if (!active || failureReported)
|
|
25
32
|
return;
|
|
26
|
-
|
|
33
|
+
failureReported = true;
|
|
34
|
+
const reason = pendingError?.message ?? `websocket closed: ${code}`;
|
|
35
|
+
void onError(new Error(reason));
|
|
27
36
|
});
|
|
28
37
|
};
|
|
29
38
|
connect();
|
package/dist/template.d.ts
CHANGED
|
@@ -1 +1,4 @@
|
|
|
1
|
-
export
|
|
1
|
+
export type TemplateValue = string | number | boolean | null | {
|
|
2
|
+
[key: string]: TemplateValue;
|
|
3
|
+
} | TemplateValue[];
|
|
4
|
+
export declare function renderTemplate(template: Record<string, TemplateValue>, context: Record<string, unknown>): Record<string, unknown>;
|
package/dist/template.js
CHANGED
|
@@ -2,17 +2,12 @@ const placeholderPattern = /^\$\{(watcher\.(id|skillId)|event\.(name)|payload\.[
|
|
|
2
2
|
function getPath(obj, path) {
|
|
3
3
|
return path.split(".").reduce((acc, part) => acc?.[part], obj);
|
|
4
4
|
}
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
}
|
|
12
|
-
if (!value.startsWith("${")) {
|
|
13
|
-
out[key] = value;
|
|
14
|
-
continue;
|
|
15
|
-
}
|
|
5
|
+
function renderValue(value, context) {
|
|
6
|
+
if (value === null || typeof value === "number" || typeof value === "boolean")
|
|
7
|
+
return value;
|
|
8
|
+
if (typeof value === "string") {
|
|
9
|
+
if (!value.startsWith("${"))
|
|
10
|
+
return value;
|
|
16
11
|
if (!placeholderPattern.test(value)) {
|
|
17
12
|
throw new Error(`Template placeholder not allowed: ${value}`);
|
|
18
13
|
}
|
|
@@ -20,7 +15,16 @@ export function renderTemplate(template, context) {
|
|
|
20
15
|
const resolved = getPath(context, path);
|
|
21
16
|
if (resolved === undefined)
|
|
22
17
|
throw new Error(`Template placeholder unresolved: ${value}`);
|
|
23
|
-
|
|
18
|
+
return resolved;
|
|
19
|
+
}
|
|
20
|
+
if (Array.isArray(value))
|
|
21
|
+
return value.map((item) => renderValue(item, context));
|
|
22
|
+
const out = {};
|
|
23
|
+
for (const [key, child] of Object.entries(value)) {
|
|
24
|
+
out[key] = renderValue(child, context);
|
|
24
25
|
}
|
|
25
26
|
return out;
|
|
26
27
|
}
|
|
28
|
+
export function renderTemplate(template, context) {
|
|
29
|
+
return renderValue(template, context);
|
|
30
|
+
}
|
package/dist/tool.d.ts
CHANGED
|
@@ -3,6 +3,12 @@ import type { Static } from "@sinclair/typebox";
|
|
|
3
3
|
import { WatcherManager } from "./watcherManager.js";
|
|
4
4
|
import { SentinelToolSchema } from "./toolSchema.js";
|
|
5
5
|
export type SentinelToolParams = Static<typeof SentinelToolSchema>;
|
|
6
|
-
type
|
|
6
|
+
type SentinelToolContext = {
|
|
7
|
+
messageChannel?: string;
|
|
8
|
+
requesterSenderId?: string;
|
|
9
|
+
agentAccountId?: string;
|
|
10
|
+
sessionKey?: string;
|
|
11
|
+
};
|
|
12
|
+
type RegisterToolFn = (tool: AnyAgentTool | ((ctx: SentinelToolContext) => AnyAgentTool)) => void;
|
|
7
13
|
export declare function registerSentinelControl(registerTool: RegisterToolFn, manager: WatcherManager): void;
|
|
8
14
|
export {};
|
package/dist/tool.js
CHANGED
|
@@ -11,8 +11,22 @@ function validateParams(params) {
|
|
|
11
11
|
}
|
|
12
12
|
return candidate;
|
|
13
13
|
}
|
|
14
|
+
function inferDefaultDeliveryTargets(ctx) {
|
|
15
|
+
const channel = ctx.messageChannel?.trim();
|
|
16
|
+
if (!channel)
|
|
17
|
+
return [];
|
|
18
|
+
const fromSender = ctx.requesterSenderId?.trim();
|
|
19
|
+
if (fromSender) {
|
|
20
|
+
return [{ channel, to: fromSender, accountId: ctx.agentAccountId }];
|
|
21
|
+
}
|
|
22
|
+
const sessionPeer = ctx.sessionKey?.split(":").at(-1)?.trim();
|
|
23
|
+
if (sessionPeer) {
|
|
24
|
+
return [{ channel, to: sessionPeer, accountId: ctx.agentAccountId }];
|
|
25
|
+
}
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
14
28
|
export function registerSentinelControl(registerTool, manager) {
|
|
15
|
-
registerTool({
|
|
29
|
+
registerTool((ctx) => ({
|
|
16
30
|
name: "sentinel_control",
|
|
17
31
|
label: "sentinel_control",
|
|
18
32
|
description: "Create/manage sentinel watchers",
|
|
@@ -21,7 +35,9 @@ export function registerSentinelControl(registerTool, manager) {
|
|
|
21
35
|
const payload = validateParams(params);
|
|
22
36
|
switch (payload.action) {
|
|
23
37
|
case "create":
|
|
24
|
-
return jsonResult(await manager.create(payload.watcher
|
|
38
|
+
return jsonResult(await manager.create(payload.watcher, {
|
|
39
|
+
deliveryTargets: inferDefaultDeliveryTargets(ctx),
|
|
40
|
+
}));
|
|
25
41
|
case "enable":
|
|
26
42
|
return jsonResult(await manager.enable(payload.id ?? ""));
|
|
27
43
|
case "disable":
|
|
@@ -34,5 +50,5 @@ export function registerSentinelControl(registerTool, manager) {
|
|
|
34
50
|
return jsonResult(manager.list());
|
|
35
51
|
}
|
|
36
52
|
},
|
|
37
|
-
});
|
|
53
|
+
}));
|
|
38
54
|
}
|
package/dist/toolSchema.d.ts
CHANGED
|
@@ -21,7 +21,12 @@ export declare const SentinelToolSchema: import("@sinclair/typebox").TObject<{
|
|
|
21
21
|
fire: import("@sinclair/typebox").TObject<{
|
|
22
22
|
webhookPath: import("@sinclair/typebox").TString;
|
|
23
23
|
eventName: import("@sinclair/typebox").TString;
|
|
24
|
-
payloadTemplate: import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString,
|
|
24
|
+
payloadTemplate: import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, any>;
|
|
25
|
+
intent: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
26
|
+
contextTemplate: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, any>>;
|
|
27
|
+
priority: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"low">, import("@sinclair/typebox").TLiteral<"normal">, import("@sinclair/typebox").TLiteral<"high">, import("@sinclair/typebox").TLiteral<"critical">]>>;
|
|
28
|
+
deadlineTemplate: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
29
|
+
dedupeKeyTemplate: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
25
30
|
}>;
|
|
26
31
|
retry: import("@sinclair/typebox").TObject<{
|
|
27
32
|
maxRetries: import("@sinclair/typebox").TNumber;
|
|
@@ -29,6 +34,11 @@ export declare const SentinelToolSchema: import("@sinclair/typebox").TObject<{
|
|
|
29
34
|
maxMs: import("@sinclair/typebox").TNumber;
|
|
30
35
|
}>;
|
|
31
36
|
fireOnce: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
|
|
37
|
+
deliveryTargets: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TObject<{
|
|
38
|
+
channel: import("@sinclair/typebox").TString;
|
|
39
|
+
to: import("@sinclair/typebox").TString;
|
|
40
|
+
accountId: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
41
|
+
}>>>;
|
|
32
42
|
metadata: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TString>>;
|
|
33
43
|
}>>;
|
|
34
44
|
}>;
|
package/dist/toolSchema.js
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
import { Type } from "@sinclair/typebox";
|
|
2
|
+
const TemplateValueSchema = Type.Recursive((Self) => Type.Union([
|
|
3
|
+
Type.String(),
|
|
4
|
+
Type.Number(),
|
|
5
|
+
Type.Boolean(),
|
|
6
|
+
Type.Null(),
|
|
7
|
+
Type.Array(Self),
|
|
8
|
+
Type.Record(Type.String(), Self),
|
|
9
|
+
]));
|
|
2
10
|
const ConditionSchema = Type.Object({
|
|
3
11
|
path: Type.String({ description: "JSONPath expression to evaluate against the response" }),
|
|
4
12
|
op: Type.Union([
|
|
@@ -23,15 +31,27 @@ const FireConfigSchema = Type.Object({
|
|
|
23
31
|
description: "Path appended to localDispatchBase for webhook delivery",
|
|
24
32
|
}),
|
|
25
33
|
eventName: Type.String({ description: "Event name included in the dispatched payload" }),
|
|
26
|
-
payloadTemplate: Type.Record(Type.String(),
|
|
34
|
+
payloadTemplate: Type.Record(Type.String(), TemplateValueSchema, {
|
|
27
35
|
description: "Key-value template for the webhook payload. Supports ${...} interpolation from matched response data.",
|
|
28
36
|
}),
|
|
37
|
+
intent: Type.Optional(Type.String({ description: "Generic callback intent for downstream agent routing" })),
|
|
38
|
+
contextTemplate: Type.Optional(Type.Record(Type.String(), TemplateValueSchema, {
|
|
39
|
+
description: "Structured callback context template. Supports ${...} interpolation from matched response data.",
|
|
40
|
+
})),
|
|
41
|
+
priority: Type.Optional(Type.Union([Type.Literal("low"), Type.Literal("normal"), Type.Literal("high"), Type.Literal("critical")], { description: "Callback urgency hint" })),
|
|
42
|
+
deadlineTemplate: Type.Optional(Type.String({ description: "Optional templated deadline string for callback consumers" })),
|
|
43
|
+
dedupeKeyTemplate: Type.Optional(Type.String({ description: "Optional template to derive deterministic trigger dedupe key" })),
|
|
29
44
|
});
|
|
30
45
|
const RetryPolicySchema = Type.Object({
|
|
31
46
|
maxRetries: Type.Number({ description: "Maximum number of retry attempts" }),
|
|
32
47
|
baseMs: Type.Number({ description: "Base delay in milliseconds for exponential backoff" }),
|
|
33
48
|
maxMs: Type.Number({ description: "Maximum delay cap in milliseconds" }),
|
|
34
49
|
});
|
|
50
|
+
const DeliveryTargetSchema = Type.Object({
|
|
51
|
+
channel: Type.String({ description: "Channel/provider id (e.g. telegram, discord)" }),
|
|
52
|
+
to: Type.String({ description: "Destination id within the channel" }),
|
|
53
|
+
accountId: Type.Optional(Type.String({ description: "Optional account id for multi-account channels" })),
|
|
54
|
+
}, { additionalProperties: false });
|
|
35
55
|
const WatcherSchema = Type.Object({
|
|
36
56
|
id: Type.String({ description: "Unique watcher identifier" }),
|
|
37
57
|
skillId: Type.String({ description: "ID of the skill that owns this watcher" }),
|
|
@@ -61,6 +81,9 @@ const WatcherSchema = Type.Object({
|
|
|
61
81
|
fire: FireConfigSchema,
|
|
62
82
|
retry: RetryPolicySchema,
|
|
63
83
|
fireOnce: Type.Optional(Type.Boolean({ description: "If true, the watcher disables itself after firing once" })),
|
|
84
|
+
deliveryTargets: Type.Optional(Type.Array(DeliveryTargetSchema, {
|
|
85
|
+
description: "Optional notification delivery targets. Defaults to the current chat/session context when omitted.",
|
|
86
|
+
})),
|
|
64
87
|
metadata: Type.Optional(Type.Record(Type.String(), Type.String(), { description: "Arbitrary key-value metadata" })),
|
|
65
88
|
}, { description: "Full watcher definition" });
|
|
66
89
|
export const SentinelToolSchema = Type.Object({
|
package/dist/types.d.ts
CHANGED
|
@@ -6,16 +6,27 @@ export interface Condition {
|
|
|
6
6
|
value?: unknown;
|
|
7
7
|
}
|
|
8
8
|
export declare const DEFAULT_SENTINEL_WEBHOOK_PATH = "/hooks/sentinel";
|
|
9
|
+
export type PriorityLevel = "low" | "normal" | "high" | "critical";
|
|
9
10
|
export interface FireConfig {
|
|
10
11
|
webhookPath?: string;
|
|
11
12
|
eventName: string;
|
|
12
|
-
payloadTemplate: Record<string,
|
|
13
|
+
payloadTemplate: Record<string, import("./template.js").TemplateValue>;
|
|
14
|
+
intent?: string;
|
|
15
|
+
contextTemplate?: Record<string, import("./template.js").TemplateValue>;
|
|
16
|
+
priority?: PriorityLevel;
|
|
17
|
+
deadlineTemplate?: string;
|
|
18
|
+
dedupeKeyTemplate?: string;
|
|
13
19
|
}
|
|
14
20
|
export interface RetryPolicy {
|
|
15
21
|
maxRetries: number;
|
|
16
22
|
baseMs: number;
|
|
17
23
|
maxMs: number;
|
|
18
24
|
}
|
|
25
|
+
export interface DeliveryTarget {
|
|
26
|
+
channel: string;
|
|
27
|
+
to: string;
|
|
28
|
+
accountId?: string;
|
|
29
|
+
}
|
|
19
30
|
export interface WatcherDefinition {
|
|
20
31
|
id: string;
|
|
21
32
|
skillId: string;
|
|
@@ -32,6 +43,7 @@ export interface WatcherDefinition {
|
|
|
32
43
|
fire: FireConfig;
|
|
33
44
|
retry: RetryPolicy;
|
|
34
45
|
fireOnce?: boolean;
|
|
46
|
+
deliveryTargets?: DeliveryTarget[];
|
|
35
47
|
metadata?: Record<string, string>;
|
|
36
48
|
}
|
|
37
49
|
export interface WatcherRuntimeState {
|
|
@@ -39,9 +51,22 @@ export interface WatcherRuntimeState {
|
|
|
39
51
|
lastError?: string;
|
|
40
52
|
lastResponseAt?: string;
|
|
41
53
|
consecutiveFailures: number;
|
|
54
|
+
reconnectAttempts: number;
|
|
42
55
|
lastPayloadHash?: string;
|
|
43
56
|
lastPayload?: unknown;
|
|
44
57
|
lastEvaluated?: string;
|
|
58
|
+
lastConnectAt?: string;
|
|
59
|
+
lastDisconnectAt?: string;
|
|
60
|
+
lastDisconnectReason?: string;
|
|
61
|
+
lastDelivery?: {
|
|
62
|
+
attemptedAt: string;
|
|
63
|
+
successCount: number;
|
|
64
|
+
failureCount: number;
|
|
65
|
+
failures?: Array<{
|
|
66
|
+
target: DeliveryTarget;
|
|
67
|
+
error: string;
|
|
68
|
+
}>;
|
|
69
|
+
};
|
|
45
70
|
}
|
|
46
71
|
export interface SentinelStateFile {
|
|
47
72
|
watchers: WatcherDefinition[];
|
package/dist/validator.js
CHANGED
|
@@ -3,6 +3,14 @@ import { Value } from "@sinclair/typebox/value";
|
|
|
3
3
|
import { DEFAULT_SENTINEL_WEBHOOK_PATH } from "./types.js";
|
|
4
4
|
const codeyKeyPattern = /(script|code|eval|handler|function|import|require)/i;
|
|
5
5
|
const codeyValuePattern = /(=>|\bfunction\b|\bimport\s+|\brequire\s*\(|\beval\s*\()/i;
|
|
6
|
+
const TemplateValueSchema = Type.Recursive((Self) => Type.Union([
|
|
7
|
+
Type.String(),
|
|
8
|
+
Type.Number(),
|
|
9
|
+
Type.Boolean(),
|
|
10
|
+
Type.Null(),
|
|
11
|
+
Type.Array(Self),
|
|
12
|
+
Type.Record(Type.String(), Self),
|
|
13
|
+
]));
|
|
6
14
|
const ConditionSchema = Type.Object({
|
|
7
15
|
path: Type.String({ minLength: 1 }),
|
|
8
16
|
op: Type.Union([
|
|
@@ -41,7 +49,17 @@ const WatcherSchema = Type.Object({
|
|
|
41
49
|
fire: Type.Object({
|
|
42
50
|
webhookPath: Type.Optional(Type.String({ pattern: "^/" })),
|
|
43
51
|
eventName: Type.String({ minLength: 1 }),
|
|
44
|
-
payloadTemplate: Type.Record(Type.String(),
|
|
52
|
+
payloadTemplate: Type.Record(Type.String(), TemplateValueSchema),
|
|
53
|
+
intent: Type.Optional(Type.String({ minLength: 1 })),
|
|
54
|
+
contextTemplate: Type.Optional(Type.Record(Type.String(), TemplateValueSchema)),
|
|
55
|
+
priority: Type.Optional(Type.Union([
|
|
56
|
+
Type.Literal("low"),
|
|
57
|
+
Type.Literal("normal"),
|
|
58
|
+
Type.Literal("high"),
|
|
59
|
+
Type.Literal("critical"),
|
|
60
|
+
])),
|
|
61
|
+
deadlineTemplate: Type.Optional(Type.String({ minLength: 1 })),
|
|
62
|
+
dedupeKeyTemplate: Type.Optional(Type.String({ minLength: 1 })),
|
|
45
63
|
}, { additionalProperties: false }),
|
|
46
64
|
retry: Type.Object({
|
|
47
65
|
maxRetries: Type.Integer({ minimum: 0, maximum: 20 }),
|
|
@@ -49,6 +67,11 @@ const WatcherSchema = Type.Object({
|
|
|
49
67
|
maxMs: Type.Integer({ minimum: 100, maximum: 300000 }),
|
|
50
68
|
}, { additionalProperties: false }),
|
|
51
69
|
fireOnce: Type.Optional(Type.Boolean()),
|
|
70
|
+
deliveryTargets: Type.Optional(Type.Array(Type.Object({
|
|
71
|
+
channel: Type.String({ minLength: 1 }),
|
|
72
|
+
to: Type.String({ minLength: 1 }),
|
|
73
|
+
accountId: Type.Optional(Type.String({ minLength: 1 })),
|
|
74
|
+
}, { additionalProperties: false }), { minItems: 1 })),
|
|
52
75
|
metadata: Type.Optional(Type.Record(Type.String(), Type.String())),
|
|
53
76
|
}, { additionalProperties: false });
|
|
54
77
|
function scanNoCodeLike(input, parentKey = "") {
|
package/dist/watcherManager.d.ts
CHANGED
|
@@ -1,18 +1,28 @@
|
|
|
1
|
-
import { GatewayWebhookDispatcher, SentinelConfig, WatcherDefinition, WatcherRuntimeState } from "./types.js";
|
|
1
|
+
import { DeliveryTarget, GatewayWebhookDispatcher, SentinelConfig, WatcherDefinition, WatcherRuntimeState } from "./types.js";
|
|
2
|
+
export declare const RESET_BACKOFF_AFTER_MS = 60000;
|
|
3
|
+
export interface WatcherCreateContext {
|
|
4
|
+
deliveryTargets?: DeliveryTarget[];
|
|
5
|
+
}
|
|
6
|
+
export interface WatcherNotifier {
|
|
7
|
+
notify(target: DeliveryTarget, message: string): Promise<void>;
|
|
8
|
+
}
|
|
9
|
+
export declare const backoff: (base: number, max: number, failures: number) => number;
|
|
2
10
|
export declare class WatcherManager {
|
|
3
11
|
private config;
|
|
4
12
|
private dispatcher;
|
|
13
|
+
private notifier?;
|
|
5
14
|
private watchers;
|
|
6
15
|
private runtime;
|
|
7
16
|
private stops;
|
|
8
17
|
private retryTimers;
|
|
9
18
|
private statePath;
|
|
10
19
|
private webhookRegistration;
|
|
11
|
-
constructor(config: SentinelConfig, dispatcher: GatewayWebhookDispatcher);
|
|
20
|
+
constructor(config: SentinelConfig, dispatcher: GatewayWebhookDispatcher, notifier?: WatcherNotifier | undefined);
|
|
12
21
|
init(): Promise<void>;
|
|
13
|
-
create(input: unknown): Promise<WatcherDefinition>;
|
|
22
|
+
create(input: unknown, ctx?: WatcherCreateContext): Promise<WatcherDefinition>;
|
|
14
23
|
list(): WatcherDefinition[];
|
|
15
24
|
status(id: string): WatcherRuntimeState | undefined;
|
|
25
|
+
setNotifier(notifier: WatcherNotifier | undefined): void;
|
|
16
26
|
setWebhookRegistrationStatus(status: "ok" | "error", message?: string, path?: string): void;
|
|
17
27
|
enable(id: string): Promise<void>;
|
|
18
28
|
disable(id: string): Promise<void>;
|
package/dist/watcherManager.js
CHANGED
|
@@ -3,12 +3,14 @@ import { assertHostAllowed, assertWatcherLimits } from "./limits.js";
|
|
|
3
3
|
import { defaultStatePath, loadState, saveState } from "./stateStore.js";
|
|
4
4
|
import { renderTemplate } from "./template.js";
|
|
5
5
|
import { validateWatcherDefinition } from "./validator.js";
|
|
6
|
+
import { createCallbackEnvelope } from "./callbackEnvelope.js";
|
|
6
7
|
import { httpPollStrategy } from "./strategies/httpPoll.js";
|
|
7
8
|
import { httpLongPollStrategy } from "./strategies/httpLongPoll.js";
|
|
8
9
|
import { sseStrategy } from "./strategies/sse.js";
|
|
9
10
|
import { websocketStrategy } from "./strategies/websocket.js";
|
|
10
11
|
import { DEFAULT_SENTINEL_WEBHOOK_PATH, } from "./types.js";
|
|
11
|
-
const
|
|
12
|
+
export const RESET_BACKOFF_AFTER_MS = 60_000;
|
|
13
|
+
export const backoff = (base, max, failures) => {
|
|
12
14
|
const raw = Math.min(max, base * 2 ** failures);
|
|
13
15
|
const jitter = Math.floor(raw * 0.25 * (Math.random() * 2 - 1));
|
|
14
16
|
return Math.max(base, raw + jitter);
|
|
@@ -16,6 +18,7 @@ const backoff = (base, max, failures) => {
|
|
|
16
18
|
export class WatcherManager {
|
|
17
19
|
config;
|
|
18
20
|
dispatcher;
|
|
21
|
+
notifier;
|
|
19
22
|
watchers = new Map();
|
|
20
23
|
runtime = {};
|
|
21
24
|
stops = new Map();
|
|
@@ -25,9 +28,10 @@ export class WatcherManager {
|
|
|
25
28
|
path: DEFAULT_SENTINEL_WEBHOOK_PATH,
|
|
26
29
|
status: "pending",
|
|
27
30
|
};
|
|
28
|
-
constructor(config, dispatcher) {
|
|
31
|
+
constructor(config, dispatcher, notifier) {
|
|
29
32
|
this.config = config;
|
|
30
33
|
this.dispatcher = dispatcher;
|
|
34
|
+
this.notifier = notifier;
|
|
31
35
|
this.statePath = config.stateFilePath ?? defaultStatePath();
|
|
32
36
|
}
|
|
33
37
|
async init() {
|
|
@@ -41,28 +45,33 @@ export class WatcherManager {
|
|
|
41
45
|
this.watchers.set(watcher.id, watcher);
|
|
42
46
|
}
|
|
43
47
|
catch (err) {
|
|
48
|
+
const prev = this.runtime[rawWatcher.id];
|
|
44
49
|
this.runtime[rawWatcher.id] = {
|
|
45
50
|
id: rawWatcher.id,
|
|
46
|
-
consecutiveFailures: (
|
|
51
|
+
consecutiveFailures: (prev?.consecutiveFailures ?? 0) + 1,
|
|
52
|
+
reconnectAttempts: prev?.reconnectAttempts ?? 0,
|
|
47
53
|
lastError: `Invalid persisted watcher: ${String(err?.message ?? err)}`,
|
|
48
|
-
lastResponseAt:
|
|
49
|
-
lastEvaluated:
|
|
50
|
-
lastPayloadHash:
|
|
51
|
-
lastPayload:
|
|
54
|
+
lastResponseAt: prev?.lastResponseAt,
|
|
55
|
+
lastEvaluated: prev?.lastEvaluated,
|
|
56
|
+
lastPayloadHash: prev?.lastPayloadHash,
|
|
57
|
+
lastPayload: prev?.lastPayload,
|
|
52
58
|
};
|
|
53
59
|
}
|
|
54
60
|
}
|
|
55
61
|
for (const watcher of this.list().filter((w) => w.enabled))
|
|
56
62
|
await this.startWatcher(watcher.id);
|
|
57
63
|
}
|
|
58
|
-
async create(input) {
|
|
64
|
+
async create(input, ctx) {
|
|
59
65
|
const watcher = validateWatcherDefinition(input);
|
|
66
|
+
if (!watcher.deliveryTargets?.length && ctx?.deliveryTargets?.length) {
|
|
67
|
+
watcher.deliveryTargets = ctx.deliveryTargets;
|
|
68
|
+
}
|
|
60
69
|
assertHostAllowed(this.config, watcher.endpoint);
|
|
61
70
|
assertWatcherLimits(this.config, this.list(), watcher);
|
|
62
71
|
if (this.watchers.has(watcher.id))
|
|
63
72
|
throw new Error(`Watcher already exists: ${watcher.id}`);
|
|
64
73
|
this.watchers.set(watcher.id, watcher);
|
|
65
|
-
this.runtime[watcher.id] = { id: watcher.id, consecutiveFailures: 0 };
|
|
74
|
+
this.runtime[watcher.id] = { id: watcher.id, consecutiveFailures: 0, reconnectAttempts: 0 };
|
|
66
75
|
if (watcher.enabled)
|
|
67
76
|
await this.startWatcher(watcher.id);
|
|
68
77
|
await this.persist();
|
|
@@ -74,6 +83,9 @@ export class WatcherManager {
|
|
|
74
83
|
status(id) {
|
|
75
84
|
return this.runtime[id];
|
|
76
85
|
}
|
|
86
|
+
setNotifier(notifier) {
|
|
87
|
+
this.notifier = notifier;
|
|
88
|
+
}
|
|
77
89
|
setWebhookRegistrationStatus(status, message, path) {
|
|
78
90
|
this.webhookRegistration = {
|
|
79
91
|
path: path ?? this.webhookRegistration.path,
|
|
@@ -117,9 +129,19 @@ export class WatcherManager {
|
|
|
117
129
|
"http-long-poll": httpLongPollStrategy,
|
|
118
130
|
}[watcher.strategy];
|
|
119
131
|
const handleFailure = async (err) => {
|
|
120
|
-
const rt = this.runtime[id] ?? { id, consecutiveFailures: 0 };
|
|
132
|
+
const rt = this.runtime[id] ?? { id, consecutiveFailures: 0, reconnectAttempts: 0 };
|
|
133
|
+
rt.reconnectAttempts ??= 0;
|
|
134
|
+
const errMsg = String(err instanceof Error ? err.message : err);
|
|
135
|
+
rt.lastDisconnectAt = new Date().toISOString();
|
|
136
|
+
rt.lastDisconnectReason = errMsg;
|
|
137
|
+
rt.lastError = errMsg;
|
|
138
|
+
if (rt.lastConnectAt) {
|
|
139
|
+
const connectedMs = Date.now() - new Date(rt.lastConnectAt).getTime();
|
|
140
|
+
if (connectedMs >= RESET_BACKOFF_AFTER_MS) {
|
|
141
|
+
rt.consecutiveFailures = 0;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
121
144
|
rt.consecutiveFailures += 1;
|
|
122
|
-
rt.lastError = String(err?.message ?? err);
|
|
123
145
|
this.runtime[id] = rt;
|
|
124
146
|
if (this.retryTimers.has(id)) {
|
|
125
147
|
await this.persist();
|
|
@@ -127,6 +149,7 @@ export class WatcherManager {
|
|
|
127
149
|
}
|
|
128
150
|
const delay = backoff(watcher.retry.baseMs, watcher.retry.maxMs, rt.consecutiveFailures);
|
|
129
151
|
if (rt.consecutiveFailures <= watcher.retry.maxRetries && watcher.enabled) {
|
|
152
|
+
rt.reconnectAttempts += 1;
|
|
130
153
|
await this.stopWatcher(id);
|
|
131
154
|
const timer = setTimeout(() => {
|
|
132
155
|
this.retryTimers.delete(id);
|
|
@@ -137,7 +160,7 @@ export class WatcherManager {
|
|
|
137
160
|
await this.persist();
|
|
138
161
|
};
|
|
139
162
|
const stop = await handler(watcher, async (payload) => {
|
|
140
|
-
const rt = this.runtime[id] ?? { id, consecutiveFailures: 0 };
|
|
163
|
+
const rt = this.runtime[id] ?? { id, consecutiveFailures: 0, reconnectAttempts: 0 };
|
|
141
164
|
const previousPayload = rt.lastPayload;
|
|
142
165
|
const matched = evaluateConditions(watcher.conditions, watcher.match, payload, previousPayload);
|
|
143
166
|
rt.lastPayloadHash = hashPayload(payload);
|
|
@@ -145,23 +168,63 @@ export class WatcherManager {
|
|
|
145
168
|
rt.lastResponseAt = new Date().toISOString();
|
|
146
169
|
rt.lastEvaluated = rt.lastResponseAt;
|
|
147
170
|
rt.consecutiveFailures = 0;
|
|
171
|
+
rt.reconnectAttempts = 0;
|
|
148
172
|
rt.lastError = undefined;
|
|
149
173
|
this.runtime[id] = rt;
|
|
150
174
|
if (matched) {
|
|
151
|
-
const
|
|
175
|
+
const matchedAt = new Date().toISOString();
|
|
176
|
+
const payloadBody = renderTemplate(watcher.fire.payloadTemplate, {
|
|
152
177
|
watcher,
|
|
153
178
|
event: { name: watcher.fire.eventName },
|
|
154
179
|
payload,
|
|
155
|
-
timestamp:
|
|
180
|
+
timestamp: matchedAt,
|
|
156
181
|
});
|
|
157
|
-
|
|
182
|
+
const webhookPath = watcher.fire.webhookPath ?? DEFAULT_SENTINEL_WEBHOOK_PATH;
|
|
183
|
+
const body = createCallbackEnvelope({
|
|
184
|
+
watcher,
|
|
185
|
+
payload,
|
|
186
|
+
payloadBody,
|
|
187
|
+
matchedAt,
|
|
188
|
+
webhookPath,
|
|
189
|
+
});
|
|
190
|
+
await this.dispatcher.dispatch(webhookPath, body);
|
|
191
|
+
if (watcher.deliveryTargets?.length && this.notifier) {
|
|
192
|
+
const attemptedAt = new Date().toISOString();
|
|
193
|
+
const message = JSON.stringify(body);
|
|
194
|
+
const failures = [];
|
|
195
|
+
let successCount = 0;
|
|
196
|
+
await Promise.all(watcher.deliveryTargets.map(async (target) => {
|
|
197
|
+
try {
|
|
198
|
+
await this.notifier?.notify(target, message);
|
|
199
|
+
successCount += 1;
|
|
200
|
+
}
|
|
201
|
+
catch (err) {
|
|
202
|
+
failures.push({
|
|
203
|
+
target,
|
|
204
|
+
error: String(err?.message ?? err),
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
}));
|
|
208
|
+
rt.lastDelivery = {
|
|
209
|
+
attemptedAt,
|
|
210
|
+
successCount,
|
|
211
|
+
failureCount: failures.length,
|
|
212
|
+
failures: failures.length > 0 ? failures : undefined,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
158
215
|
if (watcher.fireOnce) {
|
|
159
216
|
watcher.enabled = false;
|
|
160
217
|
await this.stopWatcher(id);
|
|
161
218
|
}
|
|
162
219
|
}
|
|
163
220
|
await this.persist();
|
|
164
|
-
}, handleFailure
|
|
221
|
+
}, handleFailure, {
|
|
222
|
+
onConnect: () => {
|
|
223
|
+
const rt = this.runtime[id] ?? { id, consecutiveFailures: 0, reconnectAttempts: 0 };
|
|
224
|
+
rt.lastConnectAt = new Date().toISOString();
|
|
225
|
+
this.runtime[id] = rt;
|
|
226
|
+
},
|
|
227
|
+
});
|
|
165
228
|
this.stops.set(id, stop);
|
|
166
229
|
}
|
|
167
230
|
async stopWatcher(id) {
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coffeexdev/openclaw-sentinel",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Secure declarative gateway-native watcher plugin for OpenClaw",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"openclaw",
|
|
@@ -29,18 +29,6 @@
|
|
|
29
29
|
"publishConfig": {
|
|
30
30
|
"access": "public"
|
|
31
31
|
},
|
|
32
|
-
"scripts": {
|
|
33
|
-
"build": "tsc -p tsconfig.json",
|
|
34
|
-
"test": "vitest run",
|
|
35
|
-
"lint": "tsc --noEmit",
|
|
36
|
-
"format": "oxfmt --write .",
|
|
37
|
-
"format:check": "oxfmt --check .",
|
|
38
|
-
"changeset": "changeset",
|
|
39
|
-
"version-packages": "changeset version",
|
|
40
|
-
"release": "changeset publish",
|
|
41
|
-
"prepack": "npm run build",
|
|
42
|
-
"prepare": "husky"
|
|
43
|
-
},
|
|
44
32
|
"dependencies": {
|
|
45
33
|
"@sinclair/typebox": "^0.34.48",
|
|
46
34
|
"re2-wasm": "^1.0.2",
|
|
@@ -74,5 +62,16 @@
|
|
|
74
62
|
"extensions": [
|
|
75
63
|
"dist/index.js"
|
|
76
64
|
]
|
|
65
|
+
},
|
|
66
|
+
"scripts": {
|
|
67
|
+
"build": "tsc -p tsconfig.json",
|
|
68
|
+
"test": "vitest run",
|
|
69
|
+
"test:smoke-install": "./scripts/smoke-install.sh",
|
|
70
|
+
"lint": "tsc --noEmit",
|
|
71
|
+
"format": "oxfmt --write .",
|
|
72
|
+
"format:check": "oxfmt --check .",
|
|
73
|
+
"changeset": "changeset",
|
|
74
|
+
"version-packages": "changeset version",
|
|
75
|
+
"release": "changeset publish"
|
|
77
76
|
}
|
|
78
|
-
}
|
|
77
|
+
}
|