@omnixal/openclaw-nats-plugin 0.2.7 → 0.2.8
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/dashboard/src/App.svelte +17 -4
- package/dashboard/src/lib/TimerPanel.svelte +327 -0
- package/dashboard/src/lib/api.ts +35 -0
- package/dashboard/src/lib/utils.ts +1 -0
- package/package.json +1 -1
- package/plugins/nats-context-engine/index.ts +143 -0
- package/sidecar/src/consumer/consumer.controller.ts +3 -1
- package/sidecar/src/db/migrations/0006_cooing_ultimatum.sql +1 -0
- package/sidecar/src/db/migrations/0007_dizzy_komodo.sql +14 -0
- package/sidecar/src/db/migrations/meta/0006_snapshot.json +396 -0
- package/sidecar/src/db/migrations/meta/0007_snapshot.json +485 -0
- package/sidecar/src/db/migrations/meta/_journal.json +14 -0
- package/sidecar/src/db/schema.ts +18 -0
- package/sidecar/src/gateway/gateway-client.service.ts +93 -5
- package/sidecar/src/router/router.controller.ts +1 -2
- package/sidecar/src/router/router.repository.ts +2 -1
- package/sidecar/src/router/router.service.ts +2 -2
- package/sidecar/src/scheduler/scheduler.controller.ts +40 -0
- package/sidecar/src/scheduler/scheduler.repository.ts +49 -2
- package/sidecar/src/scheduler/scheduler.service.ts +107 -0
- package/sidecar/src/validation/schemas.ts +9 -0
- package/skills/nats-events/SKILL.md +81 -12
|
@@ -11,6 +11,25 @@ export interface GatewayInjectPayload {
|
|
|
11
11
|
};
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
export class GatewayRpcError extends Error {
|
|
15
|
+
constructor(
|
|
16
|
+
public readonly rpcId: string,
|
|
17
|
+
public readonly errorCode: string,
|
|
18
|
+
public readonly errorMessage: string,
|
|
19
|
+
) {
|
|
20
|
+
super(`Gateway RPC error [${rpcId}]: ${errorCode} — ${errorMessage}`);
|
|
21
|
+
this.name = 'GatewayRpcError';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface PendingRequest {
|
|
26
|
+
resolve: () => void;
|
|
27
|
+
reject: (err: Error) => void;
|
|
28
|
+
timer: Timer;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const RPC_TIMEOUT_MS = 10_000;
|
|
32
|
+
|
|
14
33
|
@Service()
|
|
15
34
|
export class GatewayClientService extends BaseService implements OnModuleInit, OnModuleDestroy {
|
|
16
35
|
private ws: WebSocket | null = null;
|
|
@@ -21,6 +40,7 @@ export class GatewayClientService extends BaseService implements OnModuleInit, O
|
|
|
21
40
|
private requestId = 0;
|
|
22
41
|
private wsUrl!: string;
|
|
23
42
|
private token!: string;
|
|
43
|
+
private pendingRequests = new Map<string, PendingRequest>();
|
|
24
44
|
|
|
25
45
|
async onModuleInit(): Promise<void> {
|
|
26
46
|
this.wsUrl = this.config.get('gateway.wsUrl');
|
|
@@ -49,6 +69,12 @@ export class GatewayClientService extends BaseService implements OnModuleInit, O
|
|
|
49
69
|
this.ws.onclose = () => {
|
|
50
70
|
this.connected = false;
|
|
51
71
|
this.connectSent = false;
|
|
72
|
+
// Reject all in-flight requests immediately — don't make callers wait for timeout
|
|
73
|
+
for (const [id, pending] of this.pendingRequests) {
|
|
74
|
+
clearTimeout(pending.timer);
|
|
75
|
+
pending.reject(new Error('Gateway WebSocket closed'));
|
|
76
|
+
}
|
|
77
|
+
this.pendingRequests.clear();
|
|
52
78
|
this.scheduleReconnect();
|
|
53
79
|
};
|
|
54
80
|
|
|
@@ -97,12 +123,26 @@ export class GatewayClientService extends BaseService implements OnModuleInit, O
|
|
|
97
123
|
}
|
|
98
124
|
// Regular RPC ok response (e.g. for inject calls)
|
|
99
125
|
this.logger.debug('Received RPC ok response', { id: frame.id });
|
|
126
|
+
this.resolvePending(frame.id);
|
|
100
127
|
return;
|
|
101
128
|
}
|
|
102
129
|
|
|
103
130
|
// Error response
|
|
104
131
|
if (frame.type === 'res' && frame.ok === false) {
|
|
105
|
-
|
|
132
|
+
const errorCode = frame.error?.code ?? frame.error?.errorCode ?? 'UNKNOWN';
|
|
133
|
+
const errorMessage = frame.error?.message ?? frame.error?.errorMessage ?? 'Unknown gateway error';
|
|
134
|
+
this.logger.error('Gateway RPC error', { id: frame.id, errorCode, errorMessage });
|
|
135
|
+
|
|
136
|
+
// If this is a connect error, close and reconnect
|
|
137
|
+
if (frame.id?.startsWith('connect-')) {
|
|
138
|
+
this.logger.error(`Gateway rejected connection: ${errorCode} — ${errorMessage}`);
|
|
139
|
+
this.connected = false;
|
|
140
|
+
this.connectSent = false;
|
|
141
|
+
this.ws?.close();
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
this.rejectPending(frame.id, new GatewayRpcError(frame.id, String(errorCode), String(errorMessage)));
|
|
106
146
|
}
|
|
107
147
|
}
|
|
108
148
|
|
|
@@ -111,7 +151,8 @@ export class GatewayClientService extends BaseService implements OnModuleInit, O
|
|
|
111
151
|
this.connectSent = true;
|
|
112
152
|
this.logger.info('Sending connect frame');
|
|
113
153
|
|
|
114
|
-
|
|
154
|
+
try {
|
|
155
|
+
this.send({
|
|
115
156
|
type: 'req',
|
|
116
157
|
id: `connect-${++this.requestId}`,
|
|
117
158
|
method: 'connect',
|
|
@@ -126,7 +167,7 @@ export class GatewayClientService extends BaseService implements OnModuleInit, O
|
|
|
126
167
|
mode: 'backend',
|
|
127
168
|
},
|
|
128
169
|
role: 'operator',
|
|
129
|
-
scopes: ['operator.read'],
|
|
170
|
+
scopes: ['operator.read', 'operator.write'],
|
|
130
171
|
caps: [],
|
|
131
172
|
commands: [],
|
|
132
173
|
permissions: {},
|
|
@@ -135,11 +176,21 @@ export class GatewayClientService extends BaseService implements OnModuleInit, O
|
|
|
135
176
|
userAgent: 'nats-sidecar/1.0.0',
|
|
136
177
|
},
|
|
137
178
|
});
|
|
179
|
+
} catch (err) {
|
|
180
|
+
this.logger.error('Failed to send connect frame', err);
|
|
181
|
+
this.connectSent = false;
|
|
182
|
+
}
|
|
138
183
|
}
|
|
139
184
|
|
|
140
185
|
private send(frame: unknown): void {
|
|
141
|
-
if (this.ws?.readyState
|
|
186
|
+
if (this.ws?.readyState !== WebSocket.OPEN) {
|
|
187
|
+
throw new Error('WebSocket is not open');
|
|
188
|
+
}
|
|
189
|
+
try {
|
|
142
190
|
this.ws.send(JSON.stringify(frame));
|
|
191
|
+
} catch (err) {
|
|
192
|
+
this.connected = false;
|
|
193
|
+
throw new Error(`WebSocket send failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
143
194
|
}
|
|
144
195
|
}
|
|
145
196
|
|
|
@@ -158,9 +209,11 @@ export class GatewayClientService extends BaseService implements OnModuleInit, O
|
|
|
158
209
|
if (!this.isAlive()) {
|
|
159
210
|
throw new Error('Gateway WebSocket not connected');
|
|
160
211
|
}
|
|
212
|
+
const id = `rpc-${++this.requestId}`;
|
|
213
|
+
const promise = this.trackRequest(id);
|
|
161
214
|
this.send({
|
|
162
215
|
type: 'req',
|
|
163
|
-
id
|
|
216
|
+
id,
|
|
164
217
|
method: 'send',
|
|
165
218
|
params: {
|
|
166
219
|
target: payload.target,
|
|
@@ -169,6 +222,35 @@ export class GatewayClientService extends BaseService implements OnModuleInit, O
|
|
|
169
222
|
idempotencyKey: payload.metadata?.eventId ?? String(this.requestId),
|
|
170
223
|
},
|
|
171
224
|
});
|
|
225
|
+
return promise;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private trackRequest(id: string): Promise<void> {
|
|
229
|
+
return new Promise<void>((resolve, reject) => {
|
|
230
|
+
const timer = setTimeout(() => {
|
|
231
|
+
this.pendingRequests.delete(id);
|
|
232
|
+
reject(new Error(`Gateway RPC timeout after ${RPC_TIMEOUT_MS}ms [${id}]`));
|
|
233
|
+
}, RPC_TIMEOUT_MS);
|
|
234
|
+
this.pendingRequests.set(id, { resolve, reject, timer });
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private resolvePending(id: string): void {
|
|
239
|
+
const pending = this.pendingRequests.get(id);
|
|
240
|
+
if (pending) {
|
|
241
|
+
clearTimeout(pending.timer);
|
|
242
|
+
this.pendingRequests.delete(id);
|
|
243
|
+
pending.resolve();
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private rejectPending(id: string, err: Error): void {
|
|
248
|
+
const pending = this.pendingRequests.get(id);
|
|
249
|
+
if (pending) {
|
|
250
|
+
clearTimeout(pending.timer);
|
|
251
|
+
this.pendingRequests.delete(id);
|
|
252
|
+
pending.reject(err);
|
|
253
|
+
}
|
|
172
254
|
}
|
|
173
255
|
|
|
174
256
|
isAlive(): boolean {
|
|
@@ -180,6 +262,12 @@ export class GatewayClientService extends BaseService implements OnModuleInit, O
|
|
|
180
262
|
clearTimeout(this.reconnectTimer);
|
|
181
263
|
this.reconnectTimer = null;
|
|
182
264
|
}
|
|
265
|
+
// Reject all pending requests
|
|
266
|
+
for (const [id, pending] of this.pendingRequests) {
|
|
267
|
+
clearTimeout(pending.timer);
|
|
268
|
+
pending.reject(new Error('Gateway client shutting down'));
|
|
269
|
+
}
|
|
270
|
+
this.pendingRequests.clear();
|
|
183
271
|
if (this.ws) {
|
|
184
272
|
this.ws.close();
|
|
185
273
|
this.ws = null;
|
|
@@ -35,7 +35,6 @@ export class RouterController extends BaseController {
|
|
|
35
35
|
@Get('/health')
|
|
36
36
|
async getRoutesHealth(): Promise<OneBunResponse> {
|
|
37
37
|
const routes = await this.routerService.listRoutes();
|
|
38
|
-
const now = Date.now();
|
|
39
38
|
const result = routes.map(r => ({
|
|
40
39
|
id: r.id,
|
|
41
40
|
pattern: r.pattern,
|
|
@@ -45,7 +44,7 @@ export class RouterController extends BaseController {
|
|
|
45
44
|
lastDeliveredAt: r.lastDeliveredAt?.toISOString() ?? null,
|
|
46
45
|
lastEventSubject: r.lastEventSubject ?? null,
|
|
47
46
|
deliveryCount: r.deliveryCount ?? 0,
|
|
48
|
-
lagMs: r.
|
|
47
|
+
lagMs: r.lastDeliveryLagMs ?? null,
|
|
49
48
|
}));
|
|
50
49
|
return this.success(result);
|
|
51
50
|
}
|
|
@@ -57,12 +57,13 @@ export class RouterRepository extends BaseService {
|
|
|
57
57
|
return result ?? null;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
async recordDelivery(routeId: string, subject: string): Promise<void> {
|
|
60
|
+
async recordDelivery(routeId: string, subject: string, lagMs: number): Promise<void> {
|
|
61
61
|
await this.db.update(eventRoutes)
|
|
62
62
|
.set({
|
|
63
63
|
lastDeliveredAt: new Date(),
|
|
64
64
|
lastEventSubject: subject,
|
|
65
65
|
deliveryCount: sql`${eventRoutes.deliveryCount} + 1`,
|
|
66
|
+
lastDeliveryLagMs: lagMs,
|
|
66
67
|
})
|
|
67
68
|
.where(eq(eventRoutes.id, routeId));
|
|
68
69
|
}
|
|
@@ -58,8 +58,8 @@ export class RouterService extends BaseService {
|
|
|
58
58
|
return this.repo.updateById(id, fields);
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
async recordDelivery(routeId: string, subject: string): Promise<void> {
|
|
62
|
-
await this.repo.recordDelivery(routeId, subject);
|
|
61
|
+
async recordDelivery(routeId: string, subject: string, lagMs: number): Promise<void> {
|
|
62
|
+
await this.repo.recordDelivery(routeId, subject, lagMs);
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
async unsubscribe(pattern: string): Promise<boolean> {
|
|
@@ -14,6 +14,7 @@ import { ApiKeyMiddleware } from '../auth/api-key.middleware';
|
|
|
14
14
|
import {
|
|
15
15
|
createCronBodySchema, type CreateCronBody,
|
|
16
16
|
updateCronBodySchema, type UpdateCronBody,
|
|
17
|
+
createTimerBodySchema, type CreateTimerBody,
|
|
17
18
|
isValidAgentSubject,
|
|
18
19
|
} from '../validation/schemas';
|
|
19
20
|
|
|
@@ -92,6 +93,37 @@ export class SchedulerController extends BaseController {
|
|
|
92
93
|
return this.success({ deleted: true });
|
|
93
94
|
}
|
|
94
95
|
|
|
96
|
+
// ── Timer Endpoints ──────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
@Post('/timer')
|
|
99
|
+
async createTimer(@Body(createTimerBodySchema) body: CreateTimerBody): Promise<OneBunResponse> {
|
|
100
|
+
if (!isValidAgentSubject(body.subject)) {
|
|
101
|
+
return this.error('subject must start with "agent.events." followed by at least one token and must not end with "."', 400, 400);
|
|
102
|
+
}
|
|
103
|
+
const timer = await this.scheduler.addTimer({
|
|
104
|
+
name: body.name,
|
|
105
|
+
delayMs: body.delayMs,
|
|
106
|
+
subject: body.subject,
|
|
107
|
+
payload: body.payload,
|
|
108
|
+
});
|
|
109
|
+
return this.success(timer);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
@Get('/timer')
|
|
113
|
+
async listTimers(): Promise<OneBunResponse> {
|
|
114
|
+
const timers = await this.scheduler.listTimers();
|
|
115
|
+
return this.success(timers);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
@Delete('/timer/:name')
|
|
119
|
+
async cancelTimer(@Param('name') name: string): Promise<OneBunResponse> {
|
|
120
|
+
const deleted = await this.scheduler.cancelTimer(name);
|
|
121
|
+
if (!deleted) return this.error('Timer not found', 404, 404);
|
|
122
|
+
return this.success({ deleted: true });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── Queue Handlers ────────────────────────────────────────────────
|
|
126
|
+
|
|
95
127
|
@Subscribe('scheduler.fire.*', { group: 'scheduler-handler' })
|
|
96
128
|
async handleFire(message: Message<unknown>): Promise<void> {
|
|
97
129
|
const jobName = message.pattern?.split('.').pop();
|
|
@@ -99,4 +131,12 @@ export class SchedulerController extends BaseController {
|
|
|
99
131
|
await this.scheduler.handleFire(jobName);
|
|
100
132
|
}
|
|
101
133
|
}
|
|
134
|
+
|
|
135
|
+
@Subscribe('scheduler.timer.*', { group: 'scheduler-handler' })
|
|
136
|
+
async handleTimerFire(message: Message<unknown>): Promise<void> {
|
|
137
|
+
const timerName = message.pattern?.split('.').pop();
|
|
138
|
+
if (timerName) {
|
|
139
|
+
await this.scheduler.handleTimerFire(timerName);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
102
142
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Service, BaseService } from '@onebun/core';
|
|
2
|
-
import { DrizzleService, eq, sql } from '@onebun/drizzle';
|
|
3
|
-
import { cronJobs, type DbCronJob, type NewCronJob } from '../db/schema';
|
|
2
|
+
import { DrizzleService, eq, sql, and, lte } from '@onebun/drizzle';
|
|
3
|
+
import { cronJobs, timerJobs, type DbCronJob, type NewCronJob, type DbTimerJob, type NewTimerJob } from '../db/schema';
|
|
4
4
|
|
|
5
5
|
@Service()
|
|
6
6
|
export class SchedulerRepository extends BaseService {
|
|
@@ -69,4 +69,51 @@ export class SchedulerRepository extends BaseService {
|
|
|
69
69
|
.set({ lastRunAt: new Date() })
|
|
70
70
|
.where(eq(cronJobs.name, name));
|
|
71
71
|
}
|
|
72
|
+
|
|
73
|
+
// ── Timer Jobs ──────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
async createTimer(timer: NewTimerJob): Promise<DbTimerJob> {
|
|
76
|
+
const [result] = await this.db
|
|
77
|
+
.insert(timerJobs)
|
|
78
|
+
.values(timer)
|
|
79
|
+
.onConflictDoUpdate({
|
|
80
|
+
target: timerJobs.name,
|
|
81
|
+
set: {
|
|
82
|
+
subject: sql`excluded.subject`,
|
|
83
|
+
payload: sql`excluded.payload`,
|
|
84
|
+
delayMs: sql`excluded.delay_ms`,
|
|
85
|
+
fireAt: sql`excluded.fire_at`,
|
|
86
|
+
fired: sql`0`,
|
|
87
|
+
},
|
|
88
|
+
})
|
|
89
|
+
.returning();
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async findPendingTimers(): Promise<DbTimerJob[]> {
|
|
94
|
+
return this.db.select().from(timerJobs)
|
|
95
|
+
.where(eq(timerJobs.fired, false)) as any;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async findTimerByName(name: string): Promise<DbTimerJob | undefined> {
|
|
99
|
+
const [result] = await this.db.select().from(timerJobs)
|
|
100
|
+
.where(eq(timerJobs.name, name));
|
|
101
|
+
return result;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async markTimerFired(name: string): Promise<void> {
|
|
105
|
+
await this.db.update(timerJobs)
|
|
106
|
+
.set({ fired: true })
|
|
107
|
+
.where(eq(timerJobs.name, name));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async deleteTimerByName(name: string): Promise<boolean> {
|
|
111
|
+
const result = await this.db.delete(timerJobs)
|
|
112
|
+
.where(eq(timerJobs.name, name)).returning();
|
|
113
|
+
return result.length > 0;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async findAllTimers(): Promise<DbTimerJob[]> {
|
|
117
|
+
return this.db.select().from(timerJobs).orderBy(timerJobs.fireAt) as any;
|
|
118
|
+
}
|
|
72
119
|
}
|
|
@@ -12,6 +12,13 @@ interface AddJobInput {
|
|
|
12
12
|
timezone?: string;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
interface AddTimerInput {
|
|
16
|
+
name: string;
|
|
17
|
+
delayMs: number;
|
|
18
|
+
subject: string;
|
|
19
|
+
payload?: unknown;
|
|
20
|
+
}
|
|
21
|
+
|
|
15
22
|
@Service()
|
|
16
23
|
export class SchedulerService extends BaseService {
|
|
17
24
|
private _queueReady = false;
|
|
@@ -51,6 +58,30 @@ export class SchedulerService extends BaseService {
|
|
|
51
58
|
} catch (err) {
|
|
52
59
|
this.logger.error('Failed to restore cron jobs', err);
|
|
53
60
|
}
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
const timers = await this.repo.findPendingTimers();
|
|
64
|
+
let restored = 0;
|
|
65
|
+
for (const timer of timers) {
|
|
66
|
+
const remaining = timer.fireAt.getTime() - Date.now();
|
|
67
|
+
if (remaining <= 0) {
|
|
68
|
+
// Timer expired during downtime — fire immediately
|
|
69
|
+
await this.fireTimer(timer.name);
|
|
70
|
+
} else {
|
|
71
|
+
this.scheduler.addTimeoutJob(
|
|
72
|
+
`timer.${timer.name}`,
|
|
73
|
+
remaining,
|
|
74
|
+
`scheduler.timer.${timer.name}`,
|
|
75
|
+
);
|
|
76
|
+
restored++;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (restored > 0) {
|
|
80
|
+
this.logger.info(`Restored ${restored} pending timers from DB`);
|
|
81
|
+
}
|
|
82
|
+
} catch (err) {
|
|
83
|
+
this.logger.error('Failed to restore timers', err);
|
|
84
|
+
}
|
|
54
85
|
}
|
|
55
86
|
|
|
56
87
|
async add(input: AddJobInput) {
|
|
@@ -201,4 +232,80 @@ export class SchedulerService extends BaseService {
|
|
|
201
232
|
this.logger.error(`Cron fire failed: ${job.name}`, err);
|
|
202
233
|
}
|
|
203
234
|
}
|
|
235
|
+
|
|
236
|
+
// ── Timer (one-shot delayed) Jobs ─────────────────────────────────
|
|
237
|
+
|
|
238
|
+
async addTimer(input: AddTimerInput) {
|
|
239
|
+
const fireAt = new Date(Date.now() + input.delayMs);
|
|
240
|
+
const timer = await this.repo.createTimer({
|
|
241
|
+
id: ulid(),
|
|
242
|
+
name: input.name,
|
|
243
|
+
subject: input.subject,
|
|
244
|
+
payload: input.payload ?? null,
|
|
245
|
+
delayMs: input.delayMs,
|
|
246
|
+
fireAt,
|
|
247
|
+
fired: false,
|
|
248
|
+
createdAt: new Date(),
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
const schedulerName = `timer.${input.name}`;
|
|
252
|
+
if (this.scheduler.hasJob(schedulerName)) {
|
|
253
|
+
this.scheduler.removeJob(schedulerName);
|
|
254
|
+
}
|
|
255
|
+
this.scheduler.addTimeoutJob(
|
|
256
|
+
schedulerName,
|
|
257
|
+
input.delayMs,
|
|
258
|
+
`scheduler.timer.${input.name}`,
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
this.logger.info(`Timer '${input.name}' set: ${input.delayMs}ms -> ${input.subject}`);
|
|
262
|
+
return { ...timer, fireAt: timer.fireAt.getTime(), createdAt: timer.createdAt.getTime() };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async cancelTimer(name: string): Promise<boolean> {
|
|
266
|
+
const deleted = await this.repo.deleteTimerByName(name);
|
|
267
|
+
const schedulerName = `timer.${name}`;
|
|
268
|
+
if (this.scheduler.hasJob(schedulerName)) {
|
|
269
|
+
this.scheduler.removeJob(schedulerName);
|
|
270
|
+
}
|
|
271
|
+
return deleted;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async listTimers() {
|
|
275
|
+
const timers = await this.repo.findAllTimers();
|
|
276
|
+
return timers.map(t => ({
|
|
277
|
+
...t,
|
|
278
|
+
fireAt: t.fireAt.getTime(),
|
|
279
|
+
createdAt: t.createdAt.getTime(),
|
|
280
|
+
remainingMs: t.fired ? 0 : Math.max(0, t.fireAt.getTime() - Date.now()),
|
|
281
|
+
}));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async fireTimer(name: string): Promise<void> {
|
|
285
|
+
const timer = await this.repo.findTimerByName(name);
|
|
286
|
+
if (!timer || timer.fired) return;
|
|
287
|
+
|
|
288
|
+
if (!this._queueReady) {
|
|
289
|
+
this.logger.warn(`Timer '${name}' fire skipped — queue not ready`);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
try {
|
|
294
|
+
const base = (timer.payload && typeof timer.payload === 'object' && !Array.isArray(timer.payload))
|
|
295
|
+
? (timer.payload as Record<string, unknown>)
|
|
296
|
+
: {};
|
|
297
|
+
const payload = { ...base, _timer: { name: timer.name, firedAt: new Date().toISOString() } };
|
|
298
|
+
await this.publisher.publish(timer.subject, payload);
|
|
299
|
+
await this.repo.markTimerFired(name);
|
|
300
|
+
await this.logService.logDelivery(timer.id, timer.subject, JSON.stringify({ type: 'timer', name }));
|
|
301
|
+
this.logger.info(`Timer fired: ${name} -> ${timer.subject}`);
|
|
302
|
+
} catch (err) {
|
|
303
|
+
await this.logService.logError('timer', timer.id, timer.subject, err);
|
|
304
|
+
this.logger.error(`Timer fire failed: ${name}`, err);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async handleTimerFire(timerName: string): Promise<void> {
|
|
309
|
+
await this.fireTimer(timerName);
|
|
310
|
+
}
|
|
204
311
|
}
|
|
@@ -54,6 +54,15 @@ export const updateCronBodySchema = type({
|
|
|
54
54
|
|
|
55
55
|
export type UpdateCronBody = typeof updateCronBodySchema.infer;
|
|
56
56
|
|
|
57
|
+
export const createTimerBodySchema = type({
|
|
58
|
+
name: 'string',
|
|
59
|
+
delayMs: 'number > 0',
|
|
60
|
+
subject: 'string',
|
|
61
|
+
'payload?': 'unknown',
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
export type CreateTimerBody = typeof createTimerBodySchema.infer;
|
|
65
|
+
|
|
57
66
|
/** Validate that subject has content after 'agent.events.' prefix and doesn't end with '.' */
|
|
58
67
|
export function isValidAgentSubject(subject: string): boolean {
|
|
59
68
|
if (!subject.startsWith('agent.events.')) return false;
|
|
@@ -1,22 +1,41 @@
|
|
|
1
1
|
---
|
|
2
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.
|
|
3
|
+
description: Event-driven patterns for NATS. Use when the agent needs to publish events, subscribe to event patterns, set up cron-based triggers, manage timers, or react to incoming NATS messages. Triggers on any mention of NATS, events, subscriptions, cron scheduling, timers, or event-driven workflows.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# NATS Event Bus
|
|
7
7
|
|
|
8
8
|
You have access to a NATS JetStream event bus via these tools:
|
|
9
9
|
|
|
10
|
+
## Core Tools
|
|
11
|
+
|
|
10
12
|
| Tool | Purpose |
|
|
11
13
|
|---|---|
|
|
12
14
|
| `nats_publish` | Publish an event (subject + payload) |
|
|
13
15
|
| `nats_subscribe` | Create a routing rule (pattern -> session) — idempotent, safe to repeat |
|
|
14
16
|
| `nats_unsubscribe` | Remove a routing rule by ID |
|
|
15
17
|
| `nats_subscriptions` | List routing rules (optional filters: pattern, target) |
|
|
18
|
+
| `nats_route_update` | Update a route (target, priority, enabled) by ID |
|
|
19
|
+
|
|
20
|
+
## Cron (Recurring) Tools
|
|
21
|
+
|
|
22
|
+
| Tool | Purpose |
|
|
23
|
+
|---|---|
|
|
16
24
|
| `nats_cron_add` | Schedule a recurring NATS event (no LLM wake) |
|
|
25
|
+
| `nats_cron_update` | Update a cron job (schedule, subject, payload, timezone, enabled) |
|
|
26
|
+
| `nats_cron_toggle` | Toggle a cron job on/off |
|
|
27
|
+
| `nats_cron_run` | Manually fire a cron job right now |
|
|
17
28
|
| `nats_cron_remove` | Remove a scheduled cron job |
|
|
18
29
|
| `nats_cron_list` | List all scheduled cron jobs |
|
|
19
30
|
|
|
31
|
+
## Timer (One-Shot Delayed) Tools
|
|
32
|
+
|
|
33
|
+
| Tool | Purpose |
|
|
34
|
+
|---|---|
|
|
35
|
+
| `nats_timer_set` | Set a one-shot delayed event (fires once after delay) |
|
|
36
|
+
| `nats_timer_cancel` | Cancel a pending timer |
|
|
37
|
+
| `nats_timer_list` | List all timers with remaining time |
|
|
38
|
+
|
|
20
39
|
## Bootstrap
|
|
21
40
|
|
|
22
41
|
Before first use, set up default subscriptions. This is idempotent — safe to run multiple times:
|
|
@@ -26,6 +45,7 @@ nats_subscribe(pattern: "agent.events.cron.>", target: "main")
|
|
|
26
45
|
nats_subscribe(pattern: "agent.events.subagent.>", target: "main")
|
|
27
46
|
nats_subscribe(pattern: "agent.events.hook.>", target: "main")
|
|
28
47
|
nats_subscribe(pattern: "agent.events.custom.>", target: "main")
|
|
48
|
+
nats_subscribe(pattern: "agent.events.timer.>", target: "main")
|
|
29
49
|
```
|
|
30
50
|
|
|
31
51
|
## Event-Driven Rules
|
|
@@ -46,33 +66,76 @@ nats_cron_add(
|
|
|
46
66
|
payload: { "task": "daily_report" }
|
|
47
67
|
)
|
|
48
68
|
|
|
49
|
-
#
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
)
|
|
69
|
+
# Update the schedule to 10am
|
|
70
|
+
nats_cron_update(name: "daily-report", cron: "0 10 * * *")
|
|
71
|
+
|
|
72
|
+
# Temporarily disable
|
|
73
|
+
nats_cron_toggle(name: "daily-report")
|
|
74
|
+
|
|
75
|
+
# Fire manually right now (doesn't affect schedule)
|
|
76
|
+
nats_cron_run(name: "daily-report")
|
|
56
77
|
|
|
57
78
|
# List all scheduled jobs
|
|
58
79
|
nats_cron_list()
|
|
59
80
|
|
|
60
81
|
# Remove a job
|
|
61
|
-
nats_cron_remove(name: "
|
|
82
|
+
nats_cron_remove(name: "daily-report")
|
|
62
83
|
```
|
|
63
84
|
|
|
64
|
-
|
|
85
|
+
## Timers (Delayed One-Shot Events)
|
|
86
|
+
|
|
87
|
+
Use `nats_timer_set` for delayed self-pings, reminders, or deferred checks. Timers survive sidecar restarts.
|
|
88
|
+
|
|
65
89
|
```
|
|
66
|
-
|
|
90
|
+
# Check deploy status in 5 minutes
|
|
91
|
+
nats_timer_set(
|
|
92
|
+
name: "check-deploy",
|
|
93
|
+
delayMs: 300000,
|
|
94
|
+
subject: "agent.events.timer.check-deploy",
|
|
95
|
+
payload: { "task": "verify_deployment", "deployId": "abc123" }
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
# Set a 1-hour reminder
|
|
99
|
+
nats_timer_set(
|
|
100
|
+
name: "followup-reminder",
|
|
101
|
+
delayMs: 3600000,
|
|
102
|
+
subject: "agent.events.timer.reminder",
|
|
103
|
+
payload: { "task": "followup", "context": "check sales" }
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
# Cancel a timer
|
|
107
|
+
nats_timer_cancel(name: "check-deploy")
|
|
108
|
+
|
|
109
|
+
# List pending timers
|
|
110
|
+
nats_timer_list()
|
|
67
111
|
```
|
|
68
112
|
|
|
69
|
-
|
|
113
|
+
Don't forget to subscribe to timer events:
|
|
114
|
+
```
|
|
115
|
+
nats_subscribe(pattern: "agent.events.timer.>", target: "main")
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Managing Routes
|
|
119
|
+
|
|
120
|
+
```
|
|
121
|
+
# List current routes
|
|
122
|
+
nats_subscriptions()
|
|
123
|
+
|
|
124
|
+
# Update a route's priority or target
|
|
125
|
+
nats_route_update(id: "01ABC...", priority: 8)
|
|
126
|
+
nats_route_update(id: "01ABC...", enabled: false)
|
|
127
|
+
nats_route_update(id: "01ABC...", target: "worker-2")
|
|
128
|
+
|
|
129
|
+
# Remove a route
|
|
130
|
+
nats_unsubscribe(id: "01ABC...")
|
|
131
|
+
```
|
|
70
132
|
|
|
71
133
|
## Subject Hierarchy
|
|
72
134
|
|
|
73
135
|
| Pattern | Use for |
|
|
74
136
|
|---|---|
|
|
75
137
|
| `agent.events.cron.*` | Scheduled task triggers |
|
|
138
|
+
| `agent.events.timer.*` | Delayed one-shot triggers |
|
|
76
139
|
| `agent.events.subagent.spawned` | Subagent started |
|
|
77
140
|
| `agent.events.subagent.ended` | Subagent completed |
|
|
78
141
|
| `agent.events.hook.*` | External webhook triggers |
|
|
@@ -104,3 +167,9 @@ nats_publish(subject: "agent.events.custom.report-ready", payload: {"reportUrl":
|
|
|
104
167
|
nats_subscribe(pattern: "agent.events.cron.daily-report", target: "main")
|
|
105
168
|
nats_cron_add(name: "daily-report", cron: "0 9 * * *", subject: "agent.events.cron.daily-report", payload: {"task": "report"})
|
|
106
169
|
```
|
|
170
|
+
|
|
171
|
+
**Delayed check after action:**
|
|
172
|
+
```
|
|
173
|
+
nats_subscribe(pattern: "agent.events.timer.>", target: "main")
|
|
174
|
+
nats_timer_set(name: "verify-action", delayMs: 60000, subject: "agent.events.timer.verify", payload: {"check": "result"})
|
|
175
|
+
```
|