@omnixal/openclaw-nats-plugin 0.1.18 → 0.2.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/openclaw.plugin.json +1 -0
- package/package.json +2 -1
- package/plugins/nats-context-engine/index.ts +94 -0
- package/sidecar/src/app.module.ts +2 -0
- package/sidecar/src/consumer/consumer.controller.ts +25 -30
- package/sidecar/src/consumer/consumer.module.ts +2 -1
- package/sidecar/src/db/migrations/0003_wet_deathbird.sql +12 -0
- package/sidecar/src/db/migrations/meta/0003_snapshot.json +194 -0
- package/sidecar/src/db/migrations/meta/_journal.json +7 -0
- package/sidecar/src/db/schema.ts +15 -0
- package/sidecar/src/health/health.service.ts +1 -1
- package/sidecar/src/index.ts +0 -5
- package/sidecar/src/publisher/publisher.controller.ts +2 -2
- package/sidecar/src/router/router.controller.ts +68 -0
- package/sidecar/src/router/router.module.ts +11 -0
- package/sidecar/src/router/router.repository.ts +46 -0
- package/sidecar/src/router/router.service.ts +65 -0
- package/skills/nats-events/SKILL.md +93 -0
- package/skills/nats-events/scripts/nats-cron-trigger.sh +29 -0
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@omnixal/openclaw-nats-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "NATS JetStream event-driven plugin for OpenClaw",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"hooks/",
|
|
20
20
|
"plugins/",
|
|
21
21
|
"sidecar/",
|
|
22
|
+
"skills/",
|
|
22
23
|
"docker/",
|
|
23
24
|
"dashboard/dist/",
|
|
24
25
|
"openclaw.plugin.json",
|
|
@@ -78,6 +78,100 @@ export default function (api: any) {
|
|
|
78
78
|
}, { priority: 8 });
|
|
79
79
|
}, { priority: 99 });
|
|
80
80
|
|
|
81
|
+
// ── Agent Tools ─────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
const SIDECAR_URL = process.env.NATS_SIDECAR_URL || 'http://127.0.0.1:3104';
|
|
84
|
+
const SIDECAR_KEY = process.env.NATS_PLUGIN_API_KEY || 'dev-nats-plugin-key';
|
|
85
|
+
|
|
86
|
+
const sidecarFetch = async (path: string, options: RequestInit = {}) => {
|
|
87
|
+
const res = await fetch(`${SIDECAR_URL}${path}`, {
|
|
88
|
+
...options,
|
|
89
|
+
headers: {
|
|
90
|
+
'Content-Type': 'application/json',
|
|
91
|
+
'Authorization': `Bearer ${SIDECAR_KEY}`,
|
|
92
|
+
...options.headers,
|
|
93
|
+
},
|
|
94
|
+
signal: AbortSignal.timeout(5000),
|
|
95
|
+
});
|
|
96
|
+
return res.json();
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
api.registerTool({
|
|
100
|
+
name: 'nats_publish',
|
|
101
|
+
description: 'Publish an event to the NATS event bus. Use for cron triggers, custom events, task notifications.',
|
|
102
|
+
parameters: {
|
|
103
|
+
type: 'object',
|
|
104
|
+
properties: {
|
|
105
|
+
subject: { type: 'string', description: 'Event subject (must start with agent.events.)' },
|
|
106
|
+
payload: { type: 'object', description: 'Event payload data' },
|
|
107
|
+
},
|
|
108
|
+
required: ['subject', 'payload'],
|
|
109
|
+
},
|
|
110
|
+
async execute(_id: string, params: any) {
|
|
111
|
+
const result = await sidecarFetch('/api/publish', {
|
|
112
|
+
method: 'POST',
|
|
113
|
+
body: JSON.stringify({ subject: params.subject, payload: params.payload }),
|
|
114
|
+
});
|
|
115
|
+
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
api.registerTool({
|
|
120
|
+
name: 'nats_subscribe',
|
|
121
|
+
description: 'Subscribe to events matching a pattern. Matched events will be delivered to the target session as messages.',
|
|
122
|
+
parameters: {
|
|
123
|
+
type: 'object',
|
|
124
|
+
properties: {
|
|
125
|
+
pattern: { type: 'string', description: 'Subject pattern (exact, or wildcard with * for one level, > for all descendants)' },
|
|
126
|
+
target: { type: 'string', description: 'Session key to deliver to (default: main)' },
|
|
127
|
+
},
|
|
128
|
+
required: ['pattern'],
|
|
129
|
+
},
|
|
130
|
+
async execute(_id: string, params: any) {
|
|
131
|
+
const result = await sidecarFetch('/api/routes', {
|
|
132
|
+
method: 'POST',
|
|
133
|
+
body: JSON.stringify({ pattern: params.pattern, target: params.target ?? 'main' }),
|
|
134
|
+
});
|
|
135
|
+
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
api.registerTool({
|
|
140
|
+
name: 'nats_unsubscribe',
|
|
141
|
+
description: 'Remove an event subscription by its ID.',
|
|
142
|
+
parameters: {
|
|
143
|
+
type: 'object',
|
|
144
|
+
properties: {
|
|
145
|
+
id: { type: 'string', description: 'Route ID to delete (from nats_subscriptions)' },
|
|
146
|
+
},
|
|
147
|
+
required: ['id'],
|
|
148
|
+
},
|
|
149
|
+
async execute(_id: string, params: any) {
|
|
150
|
+
const result = await sidecarFetch(`/api/routes/${params.id}`, { method: 'DELETE' });
|
|
151
|
+
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
api.registerTool({
|
|
156
|
+
name: 'nats_subscriptions',
|
|
157
|
+
description: 'List event subscriptions. Optionally filter by pattern or target session.',
|
|
158
|
+
parameters: {
|
|
159
|
+
type: 'object',
|
|
160
|
+
properties: {
|
|
161
|
+
pattern: { type: 'string', description: 'Filter: show routes matching this pattern' },
|
|
162
|
+
target: { type: 'string', description: 'Filter: show routes delivering to this session' },
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
async execute(_id: string, params: any) {
|
|
166
|
+
const qs = new URLSearchParams();
|
|
167
|
+
if (params?.pattern) qs.set('pattern', params.pattern);
|
|
168
|
+
if (params?.target) qs.set('target', params.target);
|
|
169
|
+
const path = qs.toString() ? `/api/routes?${qs}` : '/api/routes';
|
|
170
|
+
const result = await sidecarFetch(path);
|
|
171
|
+
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
|
|
81
175
|
// ── Dashboard UI ─────────────────────────────────────────────────
|
|
82
176
|
|
|
83
177
|
api.registerHttpRoute({
|
|
@@ -7,6 +7,7 @@ import { GatewayClientModule } from './gateway/gateway-client.module';
|
|
|
7
7
|
import { ConsumerModule } from './consumer/consumer.module';
|
|
8
8
|
import { PendingModule } from './pending/pending.module';
|
|
9
9
|
import { HealthModule } from './health/health.module';
|
|
10
|
+
import { RouterModule } from './router/router.module';
|
|
10
11
|
|
|
11
12
|
@Module({
|
|
12
13
|
imports: [
|
|
@@ -26,6 +27,7 @@ import { HealthModule } from './health/health.module';
|
|
|
26
27
|
ConsumerModule,
|
|
27
28
|
PendingModule,
|
|
28
29
|
HealthModule,
|
|
30
|
+
RouterModule,
|
|
29
31
|
],
|
|
30
32
|
})
|
|
31
33
|
export class AppModule {}
|
|
@@ -2,6 +2,7 @@ import { Controller, BaseController, Subscribe, OnQueueReady, type Message } fro
|
|
|
2
2
|
import { PipelineService } from '../pre-handlers/pipeline.service';
|
|
3
3
|
import { GatewayClientService } from '../gateway/gateway-client.service';
|
|
4
4
|
import { PendingService } from '../pending/pending.service';
|
|
5
|
+
import { RouterService } from '../router/router.service';
|
|
5
6
|
import type { NatsEventEnvelope } from '../publisher/envelope';
|
|
6
7
|
|
|
7
8
|
@Controller('/consumer')
|
|
@@ -10,6 +11,7 @@ export class ConsumerController extends BaseController {
|
|
|
10
11
|
private pipeline: PipelineService,
|
|
11
12
|
private gatewayClient: GatewayClientService,
|
|
12
13
|
private pendingService: PendingService,
|
|
14
|
+
private routerService: RouterService,
|
|
13
15
|
) {
|
|
14
16
|
super();
|
|
15
17
|
}
|
|
@@ -20,7 +22,7 @@ export class ConsumerController extends BaseController {
|
|
|
20
22
|
this.logger.info(`Queue connected, consuming as ${consumerName}`);
|
|
21
23
|
}
|
|
22
24
|
|
|
23
|
-
@Subscribe('agent.
|
|
25
|
+
@Subscribe('agent.events.>', {
|
|
24
26
|
ackMode: 'manual',
|
|
25
27
|
group: 'openclaw-main',
|
|
26
28
|
})
|
|
@@ -29,29 +31,37 @@ export class ConsumerController extends BaseController {
|
|
|
29
31
|
const envelope = this.extractEnvelope(message);
|
|
30
32
|
|
|
31
33
|
const { result, ctx } = await this.pipeline.process(envelope);
|
|
32
|
-
|
|
33
34
|
if (result === 'drop') {
|
|
34
35
|
await message.ack();
|
|
35
36
|
return;
|
|
36
37
|
}
|
|
37
38
|
|
|
38
|
-
//
|
|
39
|
+
// Check routing rules
|
|
40
|
+
const routes = await this.routerService.findMatchingRoutes(envelope.subject);
|
|
41
|
+
if (routes.length === 0) {
|
|
42
|
+
// No route — just ack (event is in JetStream for audit)
|
|
43
|
+
await message.ack();
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Deliver to each matching target
|
|
39
48
|
if (this.gatewayClient.isAlive()) {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
49
|
+
for (const route of routes) {
|
|
50
|
+
await this.gatewayClient.inject({
|
|
51
|
+
target: route.target,
|
|
52
|
+
message: this.formatMessage(envelope),
|
|
53
|
+
metadata: {
|
|
54
|
+
source: 'nats',
|
|
55
|
+
eventId: envelope.id,
|
|
56
|
+
subject: envelope.subject,
|
|
57
|
+
priority: (ctx.enrichments['priority'] as number) ?? envelope.meta?.priority ?? 5,
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
}
|
|
50
61
|
await message.ack();
|
|
51
62
|
} else {
|
|
52
|
-
// Gateway not available — store as pending for ContextEngine pickup
|
|
53
63
|
await this.pendingService.addPending(envelope);
|
|
54
|
-
await message.ack();
|
|
64
|
+
await message.ack();
|
|
55
65
|
this.logger.warn(`Gateway unavailable, stored pending event ${envelope.id}`);
|
|
56
66
|
}
|
|
57
67
|
} catch (err) {
|
|
@@ -60,29 +70,14 @@ export class ConsumerController extends BaseController {
|
|
|
60
70
|
}
|
|
61
71
|
}
|
|
62
72
|
|
|
63
|
-
/**
|
|
64
|
-
* Extract the NatsEventEnvelope from the adapter message.
|
|
65
|
-
*
|
|
66
|
-
* The JetStreamQueueAdapter wraps messages in its own envelope:
|
|
67
|
-
* { id, pattern, data, timestamp, metadata }
|
|
68
|
-
*
|
|
69
|
-
* Our NatsEventEnvelope is inside `data` when published via PublisherService,
|
|
70
|
-
* or the raw data itself when published externally.
|
|
71
|
-
*/
|
|
72
73
|
private extractEnvelope(message: Message<unknown>): NatsEventEnvelope {
|
|
73
74
|
const data = message.data as any;
|
|
74
|
-
|
|
75
|
-
// If the data already looks like a NatsEventEnvelope (has id, subject, payload),
|
|
76
|
-
// use it directly.
|
|
77
75
|
if (data && typeof data === 'object' && 'subject' in data && 'payload' in data) {
|
|
78
76
|
return data as NatsEventEnvelope;
|
|
79
77
|
}
|
|
80
|
-
|
|
81
|
-
// Otherwise, treat it as a raw payload string that needs parsing
|
|
82
78
|
if (typeof data === 'string') {
|
|
83
79
|
return JSON.parse(data) as NatsEventEnvelope;
|
|
84
80
|
}
|
|
85
|
-
|
|
86
81
|
throw new Error('Unable to extract envelope from message');
|
|
87
82
|
}
|
|
88
83
|
|
|
@@ -2,9 +2,10 @@ import { Module } from '@onebun/core';
|
|
|
2
2
|
import { ConsumerController } from './consumer.controller';
|
|
3
3
|
import { PreHandlersModule } from '../pre-handlers/pre-handlers.module';
|
|
4
4
|
import { PendingModule } from '../pending/pending.module';
|
|
5
|
+
import { RouterModule } from '../router/router.module';
|
|
5
6
|
|
|
6
7
|
@Module({
|
|
7
|
-
imports: [PreHandlersModule, PendingModule],
|
|
8
|
+
imports: [PreHandlersModule, PendingModule, RouterModule],
|
|
8
9
|
controllers: [ConsumerController],
|
|
9
10
|
})
|
|
10
11
|
export class ConsumerModule {}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
CREATE TABLE `event_routes` (
|
|
2
|
+
`id` text PRIMARY KEY NOT NULL,
|
|
3
|
+
`pattern` text NOT NULL,
|
|
4
|
+
`target` text DEFAULT 'main' NOT NULL,
|
|
5
|
+
`enabled` integer DEFAULT true NOT NULL,
|
|
6
|
+
`priority` integer DEFAULT 5 NOT NULL,
|
|
7
|
+
`created_at` integer NOT NULL
|
|
8
|
+
);
|
|
9
|
+
--> statement-breakpoint
|
|
10
|
+
CREATE UNIQUE INDEX `event_routes_pattern_unique` ON `event_routes` (`pattern`);--> statement-breakpoint
|
|
11
|
+
CREATE INDEX `event_routes_pattern_idx` ON `event_routes` (`pattern`);--> statement-breakpoint
|
|
12
|
+
CREATE INDEX `event_routes_target_idx` ON `event_routes` (`target`);
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "6",
|
|
3
|
+
"dialect": "sqlite",
|
|
4
|
+
"id": "cb92737c-c257-4b58-bbf0-798ab1494961",
|
|
5
|
+
"prevId": "3836f3c7-1186-453d-bb01-c2f5e58a1f4f",
|
|
6
|
+
"tables": {
|
|
7
|
+
"dedup_events": {
|
|
8
|
+
"name": "dedup_events",
|
|
9
|
+
"columns": {
|
|
10
|
+
"event_id": {
|
|
11
|
+
"name": "event_id",
|
|
12
|
+
"type": "text",
|
|
13
|
+
"primaryKey": true,
|
|
14
|
+
"notNull": true,
|
|
15
|
+
"autoincrement": false
|
|
16
|
+
},
|
|
17
|
+
"subject": {
|
|
18
|
+
"name": "subject",
|
|
19
|
+
"type": "text",
|
|
20
|
+
"primaryKey": false,
|
|
21
|
+
"notNull": true,
|
|
22
|
+
"autoincrement": false
|
|
23
|
+
},
|
|
24
|
+
"seen_at": {
|
|
25
|
+
"name": "seen_at",
|
|
26
|
+
"type": "integer",
|
|
27
|
+
"primaryKey": false,
|
|
28
|
+
"notNull": true,
|
|
29
|
+
"autoincrement": false
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"indexes": {
|
|
33
|
+
"dedup_events_seen_at_idx": {
|
|
34
|
+
"name": "dedup_events_seen_at_idx",
|
|
35
|
+
"columns": [
|
|
36
|
+
"seen_at"
|
|
37
|
+
],
|
|
38
|
+
"isUnique": false
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"foreignKeys": {},
|
|
42
|
+
"compositePrimaryKeys": {},
|
|
43
|
+
"uniqueConstraints": {},
|
|
44
|
+
"checkConstraints": {}
|
|
45
|
+
},
|
|
46
|
+
"event_routes": {
|
|
47
|
+
"name": "event_routes",
|
|
48
|
+
"columns": {
|
|
49
|
+
"id": {
|
|
50
|
+
"name": "id",
|
|
51
|
+
"type": "text",
|
|
52
|
+
"primaryKey": true,
|
|
53
|
+
"notNull": true,
|
|
54
|
+
"autoincrement": false
|
|
55
|
+
},
|
|
56
|
+
"pattern": {
|
|
57
|
+
"name": "pattern",
|
|
58
|
+
"type": "text",
|
|
59
|
+
"primaryKey": false,
|
|
60
|
+
"notNull": true,
|
|
61
|
+
"autoincrement": false
|
|
62
|
+
},
|
|
63
|
+
"target": {
|
|
64
|
+
"name": "target",
|
|
65
|
+
"type": "text",
|
|
66
|
+
"primaryKey": false,
|
|
67
|
+
"notNull": true,
|
|
68
|
+
"autoincrement": false,
|
|
69
|
+
"default": "'main'"
|
|
70
|
+
},
|
|
71
|
+
"enabled": {
|
|
72
|
+
"name": "enabled",
|
|
73
|
+
"type": "integer",
|
|
74
|
+
"primaryKey": false,
|
|
75
|
+
"notNull": true,
|
|
76
|
+
"autoincrement": false,
|
|
77
|
+
"default": true
|
|
78
|
+
},
|
|
79
|
+
"priority": {
|
|
80
|
+
"name": "priority",
|
|
81
|
+
"type": "integer",
|
|
82
|
+
"primaryKey": false,
|
|
83
|
+
"notNull": true,
|
|
84
|
+
"autoincrement": false,
|
|
85
|
+
"default": 5
|
|
86
|
+
},
|
|
87
|
+
"created_at": {
|
|
88
|
+
"name": "created_at",
|
|
89
|
+
"type": "integer",
|
|
90
|
+
"primaryKey": false,
|
|
91
|
+
"notNull": true,
|
|
92
|
+
"autoincrement": false
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
"indexes": {
|
|
96
|
+
"event_routes_pattern_unique": {
|
|
97
|
+
"name": "event_routes_pattern_unique",
|
|
98
|
+
"columns": [
|
|
99
|
+
"pattern"
|
|
100
|
+
],
|
|
101
|
+
"isUnique": true
|
|
102
|
+
},
|
|
103
|
+
"event_routes_pattern_idx": {
|
|
104
|
+
"name": "event_routes_pattern_idx",
|
|
105
|
+
"columns": [
|
|
106
|
+
"pattern"
|
|
107
|
+
],
|
|
108
|
+
"isUnique": false
|
|
109
|
+
},
|
|
110
|
+
"event_routes_target_idx": {
|
|
111
|
+
"name": "event_routes_target_idx",
|
|
112
|
+
"columns": [
|
|
113
|
+
"target"
|
|
114
|
+
],
|
|
115
|
+
"isUnique": false
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
"foreignKeys": {},
|
|
119
|
+
"compositePrimaryKeys": {},
|
|
120
|
+
"uniqueConstraints": {},
|
|
121
|
+
"checkConstraints": {}
|
|
122
|
+
},
|
|
123
|
+
"pending_events": {
|
|
124
|
+
"name": "pending_events",
|
|
125
|
+
"columns": {
|
|
126
|
+
"id": {
|
|
127
|
+
"name": "id",
|
|
128
|
+
"type": "text",
|
|
129
|
+
"primaryKey": true,
|
|
130
|
+
"notNull": true,
|
|
131
|
+
"autoincrement": false
|
|
132
|
+
},
|
|
133
|
+
"session_key": {
|
|
134
|
+
"name": "session_key",
|
|
135
|
+
"type": "text",
|
|
136
|
+
"primaryKey": false,
|
|
137
|
+
"notNull": true,
|
|
138
|
+
"autoincrement": false
|
|
139
|
+
},
|
|
140
|
+
"subject": {
|
|
141
|
+
"name": "subject",
|
|
142
|
+
"type": "text",
|
|
143
|
+
"primaryKey": false,
|
|
144
|
+
"notNull": true,
|
|
145
|
+
"autoincrement": false
|
|
146
|
+
},
|
|
147
|
+
"payload": {
|
|
148
|
+
"name": "payload",
|
|
149
|
+
"type": "text",
|
|
150
|
+
"primaryKey": false,
|
|
151
|
+
"notNull": false,
|
|
152
|
+
"autoincrement": false
|
|
153
|
+
},
|
|
154
|
+
"priority": {
|
|
155
|
+
"name": "priority",
|
|
156
|
+
"type": "integer",
|
|
157
|
+
"primaryKey": false,
|
|
158
|
+
"notNull": true,
|
|
159
|
+
"autoincrement": false,
|
|
160
|
+
"default": 5
|
|
161
|
+
},
|
|
162
|
+
"created_at": {
|
|
163
|
+
"name": "created_at",
|
|
164
|
+
"type": "integer",
|
|
165
|
+
"primaryKey": false,
|
|
166
|
+
"notNull": true,
|
|
167
|
+
"autoincrement": false
|
|
168
|
+
},
|
|
169
|
+
"delivered_at": {
|
|
170
|
+
"name": "delivered_at",
|
|
171
|
+
"type": "integer",
|
|
172
|
+
"primaryKey": false,
|
|
173
|
+
"notNull": false,
|
|
174
|
+
"autoincrement": false
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
"indexes": {},
|
|
178
|
+
"foreignKeys": {},
|
|
179
|
+
"compositePrimaryKeys": {},
|
|
180
|
+
"uniqueConstraints": {},
|
|
181
|
+
"checkConstraints": {}
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
"views": {},
|
|
185
|
+
"enums": {},
|
|
186
|
+
"_meta": {
|
|
187
|
+
"schemas": {},
|
|
188
|
+
"tables": {},
|
|
189
|
+
"columns": {}
|
|
190
|
+
},
|
|
191
|
+
"internal": {
|
|
192
|
+
"indexes": {}
|
|
193
|
+
}
|
|
194
|
+
}
|
package/sidecar/src/db/schema.ts
CHANGED
|
@@ -20,3 +20,18 @@ export const pendingEvents = sqliteTable('pending_events', {
|
|
|
20
20
|
|
|
21
21
|
export type DbPendingEvent = typeof pendingEvents.$inferSelect;
|
|
22
22
|
export type NewPendingEvent = typeof pendingEvents.$inferInsert;
|
|
23
|
+
|
|
24
|
+
export const eventRoutes = sqliteTable('event_routes', {
|
|
25
|
+
id: text('id').primaryKey(),
|
|
26
|
+
pattern: text('pattern').notNull().unique(),
|
|
27
|
+
target: text('target').notNull().default('main'),
|
|
28
|
+
enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
|
|
29
|
+
priority: integer('priority').notNull().default(5),
|
|
30
|
+
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
|
|
31
|
+
}, (table) => [
|
|
32
|
+
index('event_routes_pattern_idx').on(table.pattern),
|
|
33
|
+
index('event_routes_target_idx').on(table.target),
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
export type DbEventRoute = typeof eventRoutes.$inferSelect;
|
|
37
|
+
export type NewEventRoute = typeof eventRoutes.$inferInsert;
|
|
@@ -49,7 +49,7 @@ export class HealthService extends BaseService {
|
|
|
49
49
|
pendingCount,
|
|
50
50
|
uptimeSeconds: Math.floor((Date.now() - this.startedAt) / 1000),
|
|
51
51
|
config: {
|
|
52
|
-
streams: ['
|
|
52
|
+
streams: ['agent_events', 'agent_dlq'],
|
|
53
53
|
consumerName: this.config.get('consumer.name'),
|
|
54
54
|
dedupTtlSeconds: this.config.get('dedup.ttlSeconds'),
|
|
55
55
|
},
|
package/sidecar/src/index.ts
CHANGED
|
@@ -12,8 +12,8 @@ export class PublisherController extends BaseController {
|
|
|
12
12
|
|
|
13
13
|
@Post()
|
|
14
14
|
async publish(@Body(publishBodySchema) body: PublishBody): Promise<OneBunResponse> {
|
|
15
|
-
if (!body.subject.startsWith('agent.
|
|
16
|
-
return this.error('subject must start with agent.
|
|
15
|
+
if (!body.subject.startsWith('agent.')) {
|
|
16
|
+
return this.error('subject must start with agent.', 400, 400);
|
|
17
17
|
}
|
|
18
18
|
await this.publisherService.publish(body.subject, body.payload, body.meta);
|
|
19
19
|
return this.success({ published: true });
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Controller,
|
|
3
|
+
Get,
|
|
4
|
+
Post,
|
|
5
|
+
Delete,
|
|
6
|
+
Body,
|
|
7
|
+
Param,
|
|
8
|
+
Query,
|
|
9
|
+
BaseController,
|
|
10
|
+
UseMiddleware,
|
|
11
|
+
type OneBunResponse,
|
|
12
|
+
} from '@onebun/core';
|
|
13
|
+
import { type } from 'arktype';
|
|
14
|
+
import { RouterService } from './router.service';
|
|
15
|
+
import { ApiKeyMiddleware } from '../auth/api-key.middleware';
|
|
16
|
+
|
|
17
|
+
const createRouteBody = type({
|
|
18
|
+
pattern: 'string',
|
|
19
|
+
'target?': 'string',
|
|
20
|
+
'priority?': 'number',
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
type CreateRouteBody = typeof createRouteBody.infer;
|
|
24
|
+
|
|
25
|
+
@Controller('/api/routes')
|
|
26
|
+
@UseMiddleware(ApiKeyMiddleware)
|
|
27
|
+
export class RouterController extends BaseController {
|
|
28
|
+
constructor(private routerService: RouterService) {
|
|
29
|
+
super();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
@Get('/status')
|
|
33
|
+
async getStatus(): Promise<OneBunResponse> {
|
|
34
|
+
const status = await this.routerService.status();
|
|
35
|
+
return this.success(status);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
@Get()
|
|
39
|
+
async getRoutes(@Query() query: Record<string, string>): Promise<OneBunResponse> {
|
|
40
|
+
const filters: { pattern?: string; target?: string } = {};
|
|
41
|
+
if (query?.pattern) filters.pattern = query.pattern;
|
|
42
|
+
if (query?.target) filters.target = query.target;
|
|
43
|
+
const routes = await this.routerService.listRoutes(filters);
|
|
44
|
+
return this.success(routes);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
@Post()
|
|
48
|
+
async createRoute(@Body(createRouteBody) body: CreateRouteBody): Promise<OneBunResponse> {
|
|
49
|
+
if (!body.pattern.startsWith('agent.events.')) {
|
|
50
|
+
return this.error('pattern must start with agent.events.', 400, 400);
|
|
51
|
+
}
|
|
52
|
+
const route = await this.routerService.subscribe(
|
|
53
|
+
body.pattern,
|
|
54
|
+
body.target ?? 'main',
|
|
55
|
+
body.priority ?? 5,
|
|
56
|
+
);
|
|
57
|
+
return this.success(route);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
@Delete('/:id')
|
|
61
|
+
async deleteRoute(@Param('id') id: string): Promise<OneBunResponse> {
|
|
62
|
+
const deleted = await this.routerService.deleteById(id);
|
|
63
|
+
if (!deleted) {
|
|
64
|
+
return this.error('Route not found', 404, 404);
|
|
65
|
+
}
|
|
66
|
+
return this.success({ deleted: true });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Module } from '@onebun/core';
|
|
2
|
+
import { RouterRepository } from './router.repository';
|
|
3
|
+
import { RouterService } from './router.service';
|
|
4
|
+
import { RouterController } from './router.controller';
|
|
5
|
+
|
|
6
|
+
@Module({
|
|
7
|
+
controllers: [RouterController],
|
|
8
|
+
providers: [RouterRepository, RouterService],
|
|
9
|
+
exports: [RouterService],
|
|
10
|
+
})
|
|
11
|
+
export class RouterModule {}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Service, BaseService } from '@onebun/core';
|
|
2
|
+
import { DrizzleService, eq } from '@onebun/drizzle';
|
|
3
|
+
import { eventRoutes, type DbEventRoute, type NewEventRoute } from '../db/schema';
|
|
4
|
+
|
|
5
|
+
@Service()
|
|
6
|
+
export class RouterRepository extends BaseService {
|
|
7
|
+
constructor(private db: DrizzleService) {
|
|
8
|
+
super();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async findAll(filters?: { pattern?: string; target?: string }): Promise<DbEventRoute[]> {
|
|
12
|
+
let query = this.db.select().from(eventRoutes);
|
|
13
|
+
if (filters?.pattern) {
|
|
14
|
+
query = query.where(eq(eventRoutes.pattern, filters.pattern)) as any;
|
|
15
|
+
} else if (filters?.target) {
|
|
16
|
+
query = query.where(eq(eventRoutes.target, filters.target)) as any;
|
|
17
|
+
}
|
|
18
|
+
return query.orderBy(eventRoutes.priority) as any;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async findEnabled(): Promise<DbEventRoute[]> {
|
|
22
|
+
return this.db.select().from(eventRoutes)
|
|
23
|
+
.where(eq(eventRoutes.enabled, true))
|
|
24
|
+
.orderBy(eventRoutes.priority) as any;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async create(route: NewEventRoute): Promise<DbEventRoute> {
|
|
28
|
+
const [created] = await this.db.insert(eventRoutes).values(route).returning();
|
|
29
|
+
return created;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async deleteById(id: string): Promise<boolean> {
|
|
33
|
+
const result = await this.db.delete(eventRoutes).where(eq(eventRoutes.id, id)).returning();
|
|
34
|
+
return result.length > 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async deleteByPattern(pattern: string): Promise<boolean> {
|
|
38
|
+
const result = await this.db.delete(eventRoutes).where(eq(eventRoutes.pattern, pattern)).returning();
|
|
39
|
+
return result.length > 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async count(): Promise<number> {
|
|
43
|
+
const rows = await this.db.select().from(eventRoutes);
|
|
44
|
+
return rows.length;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { Service, BaseService } from '@onebun/core';
|
|
2
|
+
import { RouterRepository } from './router.repository';
|
|
3
|
+
import type { DbEventRoute } from '../db/schema';
|
|
4
|
+
import { ulid } from 'ulid';
|
|
5
|
+
|
|
6
|
+
@Service()
|
|
7
|
+
export class RouterService extends BaseService {
|
|
8
|
+
constructor(private repo: RouterRepository) {
|
|
9
|
+
super();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** NATS-style pattern matching: exact, * (one level), > (all descendants) */
|
|
13
|
+
matchPattern(pattern: string, subject: string): boolean {
|
|
14
|
+
const patParts = pattern.split('.');
|
|
15
|
+
const subParts = subject.split('.');
|
|
16
|
+
|
|
17
|
+
for (let i = 0; i < patParts.length; i++) {
|
|
18
|
+
if (patParts[i] === '>') {
|
|
19
|
+
return i < subParts.length; // > must match at least one token after
|
|
20
|
+
}
|
|
21
|
+
if (patParts[i] === '*') {
|
|
22
|
+
if (i >= subParts.length) return false;
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (i >= subParts.length || patParts[i] !== subParts[i]) return false;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return patParts.length === subParts.length;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async findMatchingRoutes(subject: string): Promise<DbEventRoute[]> {
|
|
32
|
+
const routes = await this.repo.findEnabled();
|
|
33
|
+
return routes
|
|
34
|
+
.filter(r => this.matchPattern(r.pattern, subject))
|
|
35
|
+
.sort((a, b) => a.priority - b.priority);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async listRoutes(filters?: { pattern?: string; target?: string }): Promise<DbEventRoute[]> {
|
|
39
|
+
return this.repo.findAll(filters);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async subscribe(pattern: string, target: string = 'main', priority: number = 5): Promise<DbEventRoute> {
|
|
43
|
+
return this.repo.create({
|
|
44
|
+
id: ulid(),
|
|
45
|
+
pattern,
|
|
46
|
+
target,
|
|
47
|
+
enabled: true,
|
|
48
|
+
priority,
|
|
49
|
+
createdAt: new Date(),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async unsubscribe(pattern: string): Promise<boolean> {
|
|
54
|
+
return this.repo.deleteByPattern(pattern);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async deleteById(id: string): Promise<boolean> {
|
|
58
|
+
return this.repo.deleteById(id);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async status(): Promise<{ configured: boolean; count: number }> {
|
|
62
|
+
const count = await this.repo.count();
|
|
63
|
+
return { configured: count > 0, count };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: nats-events
|
|
3
|
+
description: Event-driven patterns for NATS. Use when the agent needs to publish events, subscribe to event patterns, set up cron-based triggers, or react to incoming NATS messages. Triggers on any mention of NATS, events, subscriptions, cron scheduling, or event-driven workflows.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# NATS Event Bus
|
|
7
|
+
|
|
8
|
+
You have access to a NATS JetStream event bus via four tools:
|
|
9
|
+
|
|
10
|
+
| Tool | Purpose |
|
|
11
|
+
|---|---|
|
|
12
|
+
| `nats_publish` | Publish an event (subject + payload) |
|
|
13
|
+
| `nats_subscribe` | Create a routing rule (pattern -> session) |
|
|
14
|
+
| `nats_unsubscribe` | Remove a routing rule by ID |
|
|
15
|
+
| `nats_subscriptions` | List routing rules (optional filters: pattern, target) |
|
|
16
|
+
|
|
17
|
+
## Bootstrap
|
|
18
|
+
|
|
19
|
+
Before first use, check if subscriptions are configured:
|
|
20
|
+
|
|
21
|
+
1. Call `nats_subscriptions` with no arguments
|
|
22
|
+
2. If the list is **non-empty** — setup is complete, proceed with your task
|
|
23
|
+
3. If **empty** — run initial setup:
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
nats_subscribe(pattern: "agent.events.cron.>", target: "main")
|
|
27
|
+
nats_subscribe(pattern: "agent.events.subagent.>", target: "main")
|
|
28
|
+
nats_subscribe(pattern: "agent.events.hook.>", target: "main")
|
|
29
|
+
nats_subscribe(pattern: "agent.events.custom.>", target: "main")
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Do NOT repeat bootstrap if subscriptions already exist.
|
|
33
|
+
|
|
34
|
+
## Event-Driven Rules
|
|
35
|
+
|
|
36
|
+
1. **Never poll** — subscribe to events and react when they arrive as messages in your session
|
|
37
|
+
2. **Cron is OK** but must go through NATS: cron job calls `nats-cron-trigger.sh` -> event published -> delivered to your session -> you react
|
|
38
|
+
3. **Every scheduled task** = cron entry calling `nats-cron-trigger.sh` + subscription on that subject
|
|
39
|
+
4. **Incoming events** arrive as chat messages formatted `[NATS:<subject>] <payload>`
|
|
40
|
+
|
|
41
|
+
## Creating Cron Tasks
|
|
42
|
+
|
|
43
|
+
Always use the bundled script — never invoke LLM from cron:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
# Step 1: Subscribe to the event
|
|
47
|
+
nats_subscribe(pattern: "agent.events.cron.daily-report", target: "main")
|
|
48
|
+
|
|
49
|
+
# Step 2: Create cron entry using the trigger script
|
|
50
|
+
# The script is at: <plugin-dir>/skills/nats-events/scripts/nats-cron-trigger.sh
|
|
51
|
+
*/30 * * * * /path/to/nats-cron-trigger.sh agent.events.cron.check-revenue '{"task":"check_revenue"}'
|
|
52
|
+
0 9 * * * /path/to/nats-cron-trigger.sh agent.events.cron.daily-report '{"task":"daily_report"}'
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The script only needs `NATS_SIDECAR_URL` and `NATS_PLUGIN_API_KEY` environment variables.
|
|
56
|
+
|
|
57
|
+
## Subject Hierarchy
|
|
58
|
+
|
|
59
|
+
| Pattern | Use for |
|
|
60
|
+
|---|---|
|
|
61
|
+
| `agent.events.cron.*` | Scheduled task triggers |
|
|
62
|
+
| `agent.events.subagent.spawned` | Subagent started |
|
|
63
|
+
| `agent.events.subagent.ended` | Subagent completed |
|
|
64
|
+
| `agent.events.hook.*` | External webhook triggers |
|
|
65
|
+
| `agent.events.session.*` | Session lifecycle |
|
|
66
|
+
| `agent.events.tool.*` | Tool execution results |
|
|
67
|
+
| `agent.events.gateway.*` | Gateway startup/restart |
|
|
68
|
+
| `agent.events.custom.*` | Your custom events |
|
|
69
|
+
|
|
70
|
+
## Pattern Matching
|
|
71
|
+
|
|
72
|
+
- Exact: `agent.events.cron.daily-report` — matches only this subject
|
|
73
|
+
- `*` — one level: `agent.events.cron.*` matches `agent.events.cron.daily` but not `agent.events.cron.reports.weekly`
|
|
74
|
+
- `>` — all descendants: `agent.events.cron.>` matches everything under `agent.events.cron.`
|
|
75
|
+
|
|
76
|
+
## Examples
|
|
77
|
+
|
|
78
|
+
**React to subagent completion:**
|
|
79
|
+
```
|
|
80
|
+
nats_subscribe(pattern: "agent.events.subagent.ended", target: "main")
|
|
81
|
+
# When subagent finishes, you receive: [NATS:agent.events.subagent.ended] {"subagentId":...,"result":...}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
**Publish a custom event for external consumers:**
|
|
85
|
+
```
|
|
86
|
+
nats_publish(subject: "agent.events.custom.report-ready", payload: {"reportUrl": "https://..."})
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
**Schedule a recurring task:**
|
|
90
|
+
```
|
|
91
|
+
nats_subscribe(pattern: "agent.events.cron.hourly-check", target: "main")
|
|
92
|
+
# Then create crontab: 0 * * * * nats-cron-trigger.sh agent.events.cron.hourly-check '{}'
|
|
93
|
+
```
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# nats-cron-trigger.sh — Publish a NATS event from cron (no LLM involved).
|
|
3
|
+
#
|
|
4
|
+
# Usage:
|
|
5
|
+
# nats-cron-trigger.sh <subject> [payload_json]
|
|
6
|
+
#
|
|
7
|
+
# Examples:
|
|
8
|
+
# nats-cron-trigger.sh agent.events.cron.daily-report
|
|
9
|
+
# nats-cron-trigger.sh agent.events.cron.check-revenue '{"task":"check_revenue"}'
|
|
10
|
+
#
|
|
11
|
+
# Environment:
|
|
12
|
+
# NATS_SIDECAR_URL — Sidecar HTTP URL (default: http://127.0.0.1:3104)
|
|
13
|
+
# NATS_PLUGIN_API_KEY — Bearer token (required)
|
|
14
|
+
|
|
15
|
+
set -euo pipefail
|
|
16
|
+
|
|
17
|
+
SUBJECT="${1:?Usage: nats-cron-trigger.sh <subject> [payload_json]}"
|
|
18
|
+
PAYLOAD="${2:-"{}"}"
|
|
19
|
+
SIDECAR="${NATS_SIDECAR_URL:-http://127.0.0.1:3104}"
|
|
20
|
+
|
|
21
|
+
if [ -z "${NATS_PLUGIN_API_KEY:-}" ]; then
|
|
22
|
+
echo "Error: NATS_PLUGIN_API_KEY is not set" >&2
|
|
23
|
+
exit 1
|
|
24
|
+
fi
|
|
25
|
+
|
|
26
|
+
exec curl -sf -X POST "${SIDECAR}/api/publish" \
|
|
27
|
+
-H "Authorization: Bearer ${NATS_PLUGIN_API_KEY}" \
|
|
28
|
+
-H "Content-Type: application/json" \
|
|
29
|
+
-d "{\"subject\":\"${SUBJECT}\",\"payload\":${PAYLOAD}}"
|