@omnixal/openclaw-nats-plugin 0.1.18 → 0.2.1
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 +4 -2
- package/plugins/nats-context-engine/http-handler.ts +1 -1
- package/plugins/nats-context-engine/index.ts +153 -0
- package/sidecar/bun.lock +8 -6
- package/sidecar/package.json +4 -4
- package/sidecar/src/app.module.ts +11 -2
- package/sidecar/src/consumer/consumer.controller.ts +29 -30
- package/sidecar/src/consumer/consumer.module.ts +3 -1
- package/sidecar/src/db/migrations/0003_wet_deathbird.sql +12 -0
- package/sidecar/src/db/migrations/0004_familiar_zaladane.sql +17 -0
- package/sidecar/src/db/migrations/meta/0003_snapshot.json +194 -0
- package/sidecar/src/db/migrations/meta/0004_snapshot.json +306 -0
- package/sidecar/src/db/migrations/meta/_journal.json +14 -0
- package/sidecar/src/db/schema.ts +35 -0
- package/sidecar/src/health/health.service.ts +1 -1
- package/sidecar/src/index.ts +6 -5
- package/sidecar/src/metrics/metrics.controller.ts +16 -0
- package/sidecar/src/metrics/metrics.module.ts +10 -0
- package/sidecar/src/metrics/metrics.service.ts +64 -0
- package/sidecar/src/publisher/publisher.controller.ts +2 -2
- package/sidecar/src/publisher/publisher.module.ts +2 -0
- package/sidecar/src/publisher/publisher.service.ts +6 -1
- package/sidecar/src/router/router.controller.ts +76 -0
- package/sidecar/src/router/router.module.ts +11 -0
- package/sidecar/src/router/router.repository.ts +78 -0
- package/sidecar/src/router/router.service.ts +73 -0
- package/sidecar/src/scheduler/scheduler.controller.ts +68 -0
- package/sidecar/src/scheduler/scheduler.module.ts +13 -0
- package/sidecar/src/scheduler/scheduler.repository.ts +64 -0
- package/sidecar/src/scheduler/scheduler.service.ts +138 -0
- package/sidecar/src/validation/schemas.ts +18 -0
- package/skills/nats-events/SKILL.md +106 -0
- package/skills/nats-events/scripts/nats-cron-trigger.sh +29 -0
- package/dashboard/dist/assets/index--UFIkwvP.js +0 -2
- package/dashboard/dist/assets/index-CafgidIc.css +0 -2
- package/dashboard/dist/index.html +0 -13
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { Service, BaseService } from '@onebun/core';
|
|
2
|
+
import { DrizzleService, eq, sql } 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
|
+
// drizzle type limitation: chained .where()/.orderBy() loses type info
|
|
13
|
+
let query = this.db.select().from(eventRoutes) as any;
|
|
14
|
+
if (filters?.pattern) {
|
|
15
|
+
query = query.where(eq(eventRoutes.pattern, filters.pattern));
|
|
16
|
+
} else if (filters?.target) {
|
|
17
|
+
query = query.where(eq(eventRoutes.target, filters.target));
|
|
18
|
+
}
|
|
19
|
+
return query.orderBy(eventRoutes.priority);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async findEnabled(): Promise<DbEventRoute[]> {
|
|
23
|
+
// drizzle type limitation: chained .where()/.orderBy() loses type info
|
|
24
|
+
return this.db.select().from(eventRoutes)
|
|
25
|
+
.where(eq(eventRoutes.enabled, true))
|
|
26
|
+
.orderBy(eventRoutes.priority) as any;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async create(route: NewEventRoute): Promise<DbEventRoute> {
|
|
30
|
+
const [created] = await this.db.insert(eventRoutes).values(route).returning();
|
|
31
|
+
return created;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async upsert(route: NewEventRoute): Promise<{ route: DbEventRoute; created: boolean }> {
|
|
35
|
+
const [result] = await this.db
|
|
36
|
+
.insert(eventRoutes)
|
|
37
|
+
.values(route)
|
|
38
|
+
.onConflictDoUpdate({
|
|
39
|
+
target: eventRoutes.pattern,
|
|
40
|
+
set: {
|
|
41
|
+
target: sql`excluded.target`,
|
|
42
|
+
priority: sql`excluded.priority`,
|
|
43
|
+
enabled: sql`excluded.enabled`,
|
|
44
|
+
},
|
|
45
|
+
})
|
|
46
|
+
.returning();
|
|
47
|
+
|
|
48
|
+
const created = result.createdAt.getTime() === route.createdAt!.getTime();
|
|
49
|
+
return { route: result, created };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async recordDelivery(routeId: string, subject: string): Promise<void> {
|
|
53
|
+
await this.db.update(eventRoutes)
|
|
54
|
+
.set({
|
|
55
|
+
lastDeliveredAt: new Date(),
|
|
56
|
+
lastEventSubject: subject,
|
|
57
|
+
deliveryCount: sql`${eventRoutes.deliveryCount} + 1`,
|
|
58
|
+
})
|
|
59
|
+
.where(eq(eventRoutes.id, routeId));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async deleteById(id: string): Promise<boolean> {
|
|
63
|
+
const result = await this.db.delete(eventRoutes).where(eq(eventRoutes.id, id)).returning();
|
|
64
|
+
return result.length > 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async deleteByPattern(pattern: string): Promise<boolean> {
|
|
68
|
+
const result = await this.db.delete(eventRoutes).where(eq(eventRoutes.pattern, pattern)).returning();
|
|
69
|
+
return result.length > 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async count(): Promise<number> {
|
|
73
|
+
const result = await this.db
|
|
74
|
+
.select({ count: sql<number>`count(*)` })
|
|
75
|
+
.from(eventRoutes);
|
|
76
|
+
return result[0]?.count ?? 0;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
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(
|
|
43
|
+
pattern: string,
|
|
44
|
+
target: string = 'main',
|
|
45
|
+
priority: number = 5,
|
|
46
|
+
): Promise<{ route: DbEventRoute; created: boolean }> {
|
|
47
|
+
return this.repo.upsert({
|
|
48
|
+
id: ulid(),
|
|
49
|
+
pattern,
|
|
50
|
+
target,
|
|
51
|
+
enabled: true,
|
|
52
|
+
priority,
|
|
53
|
+
createdAt: new Date(),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async recordDelivery(routeId: string, subject: string): Promise<void> {
|
|
58
|
+
await this.repo.recordDelivery(routeId, subject);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async unsubscribe(pattern: string): Promise<boolean> {
|
|
62
|
+
return this.repo.deleteByPattern(pattern);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async deleteById(id: string): Promise<boolean> {
|
|
66
|
+
return this.repo.deleteById(id);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async status(): Promise<{ configured: boolean; count: number }> {
|
|
70
|
+
const count = await this.repo.count();
|
|
71
|
+
return { configured: count > 0, count };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Controller, Get, Post, Patch, Delete,
|
|
3
|
+
Body, Param, BaseController,
|
|
4
|
+
UseMiddleware, Subscribe,
|
|
5
|
+
type Message,
|
|
6
|
+
type OneBunResponse,
|
|
7
|
+
} from '@onebun/core';
|
|
8
|
+
import { SchedulerService } from './scheduler.service';
|
|
9
|
+
import { ApiKeyMiddleware } from '../auth/api-key.middleware';
|
|
10
|
+
import { createCronBodySchema, type CreateCronBody } from '../validation/schemas';
|
|
11
|
+
|
|
12
|
+
@Controller('/api/cron')
|
|
13
|
+
@UseMiddleware(ApiKeyMiddleware)
|
|
14
|
+
export class SchedulerController extends BaseController {
|
|
15
|
+
constructor(private scheduler: SchedulerService) {
|
|
16
|
+
super();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
@Post()
|
|
20
|
+
async createJob(@Body(createCronBodySchema) body: CreateCronBody): Promise<OneBunResponse> {
|
|
21
|
+
if (!body.subject.startsWith('agent.events.')) {
|
|
22
|
+
return this.error('subject must start with agent.events.', 400, 400);
|
|
23
|
+
}
|
|
24
|
+
const job = await this.scheduler.add({
|
|
25
|
+
name: body.name,
|
|
26
|
+
expr: body.cron,
|
|
27
|
+
subject: body.subject,
|
|
28
|
+
payload: body.payload,
|
|
29
|
+
timezone: body.timezone,
|
|
30
|
+
});
|
|
31
|
+
return this.success(job);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
@Get()
|
|
35
|
+
async listJobs(): Promise<OneBunResponse> {
|
|
36
|
+
const jobs = await this.scheduler.list();
|
|
37
|
+
return this.success(jobs);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
@Patch('/:name/toggle')
|
|
41
|
+
async toggleJob(@Param('name') name: string): Promise<OneBunResponse> {
|
|
42
|
+
const result = await this.scheduler.toggle(name);
|
|
43
|
+
if (!result) return this.error('Job not found', 404, 404);
|
|
44
|
+
return this.success(result);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
@Post('/:name/run')
|
|
48
|
+
async runJob(@Param('name') name: string): Promise<OneBunResponse> {
|
|
49
|
+
const fired = await this.scheduler.fireNow(name);
|
|
50
|
+
if (!fired) return this.error('Job not found', 404, 404);
|
|
51
|
+
return this.success({ fired: true });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
@Delete('/:name')
|
|
55
|
+
async deleteJob(@Param('name') name: string): Promise<OneBunResponse> {
|
|
56
|
+
const deleted = await this.scheduler.remove(name);
|
|
57
|
+
if (!deleted) return this.error('Job not found', 404, 404);
|
|
58
|
+
return this.success({ deleted: true });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
@Subscribe('scheduler.fire.*')
|
|
62
|
+
async handleFire(message: Message<unknown>): Promise<void> {
|
|
63
|
+
const jobName = message.pattern?.split('.').pop();
|
|
64
|
+
if (jobName) {
|
|
65
|
+
await this.scheduler.handleFire(jobName);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Module } from '@onebun/core';
|
|
2
|
+
import { PublisherModule } from '../publisher/publisher.module';
|
|
3
|
+
import { SchedulerRepository } from './scheduler.repository';
|
|
4
|
+
import { SchedulerService } from './scheduler.service';
|
|
5
|
+
import { SchedulerController } from './scheduler.controller';
|
|
6
|
+
|
|
7
|
+
@Module({
|
|
8
|
+
imports: [PublisherModule],
|
|
9
|
+
controllers: [SchedulerController],
|
|
10
|
+
providers: [SchedulerRepository, SchedulerService],
|
|
11
|
+
exports: [SchedulerService],
|
|
12
|
+
})
|
|
13
|
+
export class SchedulerModule {}
|
|
@@ -0,0 +1,64 @@
|
|
|
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';
|
|
4
|
+
|
|
5
|
+
@Service()
|
|
6
|
+
export class SchedulerRepository extends BaseService {
|
|
7
|
+
constructor(private db: DrizzleService) {
|
|
8
|
+
super();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async upsert(job: NewCronJob): Promise<DbCronJob> {
|
|
12
|
+
const [result] = await this.db
|
|
13
|
+
.insert(cronJobs)
|
|
14
|
+
.values(job)
|
|
15
|
+
.onConflictDoUpdate({
|
|
16
|
+
target: cronJobs.name,
|
|
17
|
+
set: {
|
|
18
|
+
expr: sql`excluded.expr`,
|
|
19
|
+
subject: sql`excluded.subject`,
|
|
20
|
+
payload: sql`excluded.payload`,
|
|
21
|
+
timezone: sql`excluded.timezone`,
|
|
22
|
+
enabled: sql`excluded.enabled`,
|
|
23
|
+
},
|
|
24
|
+
})
|
|
25
|
+
.returning();
|
|
26
|
+
return result;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async findAll(): Promise<DbCronJob[]> {
|
|
30
|
+
// drizzle type limitation: chained .orderBy() loses type info
|
|
31
|
+
return this.db.select().from(cronJobs).orderBy(cronJobs.name) as any;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async findAllEnabled(): Promise<DbCronJob[]> {
|
|
35
|
+
// drizzle type limitation: chained .where()/.orderBy() loses type info
|
|
36
|
+
return this.db.select().from(cronJobs)
|
|
37
|
+
.where(eq(cronJobs.enabled, true))
|
|
38
|
+
.orderBy(cronJobs.name) as any;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async findByName(name: string): Promise<DbCronJob | undefined> {
|
|
42
|
+
const [result] = await this.db.select().from(cronJobs)
|
|
43
|
+
.where(eq(cronJobs.name, name));
|
|
44
|
+
return result;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async deleteByName(name: string): Promise<boolean> {
|
|
48
|
+
const result = await this.db.delete(cronJobs)
|
|
49
|
+
.where(eq(cronJobs.name, name)).returning();
|
|
50
|
+
return result.length > 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async setEnabled(name: string, enabled: boolean): Promise<void> {
|
|
54
|
+
await this.db.update(cronJobs)
|
|
55
|
+
.set({ enabled })
|
|
56
|
+
.where(eq(cronJobs.name, name));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async updateLastRun(name: string): Promise<void> {
|
|
60
|
+
await this.db.update(cronJobs)
|
|
61
|
+
.set({ lastRunAt: new Date() })
|
|
62
|
+
.where(eq(cronJobs.name, name));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { Service, BaseService, QueueService, OnModuleInit } from '@onebun/core';
|
|
2
|
+
import { SchedulerRepository } from './scheduler.repository';
|
|
3
|
+
import { PublisherService } from '../publisher/publisher.service';
|
|
4
|
+
import { ulid } from 'ulid';
|
|
5
|
+
|
|
6
|
+
interface AddJobInput {
|
|
7
|
+
name: string;
|
|
8
|
+
expr: string;
|
|
9
|
+
subject: string;
|
|
10
|
+
payload?: unknown;
|
|
11
|
+
timezone?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
@Service()
|
|
15
|
+
export class SchedulerService extends BaseService implements OnModuleInit {
|
|
16
|
+
constructor(
|
|
17
|
+
private repo: SchedulerRepository,
|
|
18
|
+
private queueService: QueueService,
|
|
19
|
+
private publisher: PublisherService,
|
|
20
|
+
) {
|
|
21
|
+
super();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
private get scheduler() {
|
|
25
|
+
return this.queueService.getScheduler();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async onModuleInit(): Promise<void> {
|
|
29
|
+
const jobs = await this.repo.findAllEnabled();
|
|
30
|
+
for (const job of jobs) {
|
|
31
|
+
this.scheduler.addCronJob(
|
|
32
|
+
job.name,
|
|
33
|
+
job.expr,
|
|
34
|
+
`scheduler.fire.${job.name}`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
if (jobs.length > 0) {
|
|
38
|
+
this.logger.info(`Restored ${jobs.length} cron jobs from DB`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async add(input: AddJobInput) {
|
|
43
|
+
const job = await this.repo.upsert({
|
|
44
|
+
id: ulid(),
|
|
45
|
+
name: input.name,
|
|
46
|
+
expr: input.expr,
|
|
47
|
+
subject: input.subject,
|
|
48
|
+
payload: input.payload ?? null,
|
|
49
|
+
timezone: input.timezone ?? 'UTC',
|
|
50
|
+
enabled: true,
|
|
51
|
+
createdAt: new Date(),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
if (this.scheduler.hasJob(input.name)) {
|
|
55
|
+
// Remove and re-add to update the cron expression
|
|
56
|
+
this.scheduler.removeJob(input.name);
|
|
57
|
+
}
|
|
58
|
+
this.scheduler.addCronJob(
|
|
59
|
+
input.name,
|
|
60
|
+
input.expr,
|
|
61
|
+
`scheduler.fire.${input.name}`,
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
this.logger.info(`Cron job '${input.name}' registered: ${input.expr} -> ${input.subject}`);
|
|
65
|
+
return job;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async remove(name: string): Promise<boolean> {
|
|
69
|
+
const deleted = await this.repo.deleteByName(name);
|
|
70
|
+
if (this.scheduler.hasJob(name)) {
|
|
71
|
+
this.scheduler.removeJob(name);
|
|
72
|
+
}
|
|
73
|
+
return deleted;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async list() {
|
|
77
|
+
const dbJobs = await this.repo.findAll();
|
|
78
|
+
return dbJobs.map(job => {
|
|
79
|
+
const runtime = this.scheduler.getJob(job.name);
|
|
80
|
+
return {
|
|
81
|
+
...job,
|
|
82
|
+
nextRun: runtime?.nextRun ?? null,
|
|
83
|
+
isRunning: runtime?.isRunning ?? false,
|
|
84
|
+
};
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async toggle(name: string) {
|
|
89
|
+
const job = await this.repo.findByName(name);
|
|
90
|
+
if (!job) return null;
|
|
91
|
+
|
|
92
|
+
const newEnabled = !job.enabled;
|
|
93
|
+
await this.repo.setEnabled(name, newEnabled);
|
|
94
|
+
|
|
95
|
+
if (newEnabled) {
|
|
96
|
+
this.scheduler.addCronJob(
|
|
97
|
+
name,
|
|
98
|
+
job.expr,
|
|
99
|
+
`scheduler.fire.${name}`,
|
|
100
|
+
);
|
|
101
|
+
this.logger.info(`Cron job '${name}' enabled`);
|
|
102
|
+
} else {
|
|
103
|
+
if (this.scheduler.hasJob(name)) {
|
|
104
|
+
this.scheduler.removeJob(name);
|
|
105
|
+
}
|
|
106
|
+
this.logger.info(`Cron job '${name}' disabled`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return this.repo.findByName(name);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async fireNow(name: string): Promise<boolean> {
|
|
113
|
+
const job = await this.repo.findByName(name);
|
|
114
|
+
if (!job) return false;
|
|
115
|
+
|
|
116
|
+
const base = (job.payload && typeof job.payload === 'object' && !Array.isArray(job.payload))
|
|
117
|
+
? (job.payload as Record<string, unknown>)
|
|
118
|
+
: {};
|
|
119
|
+
const payload = { ...base, _cron: { jobName: job.name, firedAt: new Date().toISOString(), manual: true } };
|
|
120
|
+
await this.publisher.publish(job.subject, payload);
|
|
121
|
+
await this.repo.updateLastRun(name);
|
|
122
|
+
this.logger.info(`Cron job '${name}' manually fired -> ${job.subject}`);
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async handleFire(jobName: string): Promise<void> {
|
|
127
|
+
const job = await this.repo.findByName(jobName);
|
|
128
|
+
if (!job || !job.enabled) return;
|
|
129
|
+
|
|
130
|
+
const base = (job.payload && typeof job.payload === 'object' && !Array.isArray(job.payload))
|
|
131
|
+
? (job.payload as Record<string, unknown>)
|
|
132
|
+
: {};
|
|
133
|
+
const payload = { ...base, _cron: { jobName: job.name, firedAt: new Date().toISOString() } };
|
|
134
|
+
await this.publisher.publish(job.subject, payload);
|
|
135
|
+
await this.repo.updateLastRun(job.name);
|
|
136
|
+
this.logger.debug(`Cron fired: ${job.name} -> ${job.subject}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -17,3 +17,21 @@ export const markDeliveredBodySchema = type({
|
|
|
17
17
|
});
|
|
18
18
|
|
|
19
19
|
export type MarkDeliveredBody = typeof markDeliveredBodySchema.infer;
|
|
20
|
+
|
|
21
|
+
export const createRouteBodySchema = type({
|
|
22
|
+
pattern: 'string',
|
|
23
|
+
'target?': 'string',
|
|
24
|
+
'priority?': 'number',
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export type CreateRouteBody = typeof createRouteBodySchema.infer;
|
|
28
|
+
|
|
29
|
+
export const createCronBodySchema = type({
|
|
30
|
+
name: 'string',
|
|
31
|
+
cron: 'string',
|
|
32
|
+
subject: 'string',
|
|
33
|
+
'payload?': 'unknown',
|
|
34
|
+
'timezone?': 'string',
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
export type CreateCronBody = typeof createCronBodySchema.infer;
|
|
@@ -0,0 +1,106 @@
|
|
|
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 these tools:
|
|
9
|
+
|
|
10
|
+
| Tool | Purpose |
|
|
11
|
+
|---|---|
|
|
12
|
+
| `nats_publish` | Publish an event (subject + payload) |
|
|
13
|
+
| `nats_subscribe` | Create a routing rule (pattern -> session) — idempotent, safe to repeat |
|
|
14
|
+
| `nats_unsubscribe` | Remove a routing rule by ID |
|
|
15
|
+
| `nats_subscriptions` | List routing rules (optional filters: pattern, target) |
|
|
16
|
+
| `nats_cron_add` | Schedule a recurring NATS event (no LLM wake) |
|
|
17
|
+
| `nats_cron_remove` | Remove a scheduled cron job |
|
|
18
|
+
| `nats_cron_list` | List all scheduled cron jobs |
|
|
19
|
+
|
|
20
|
+
## Bootstrap
|
|
21
|
+
|
|
22
|
+
Before first use, set up default subscriptions. This is idempotent — safe to run multiple times:
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
nats_subscribe(pattern: "agent.events.cron.>", target: "main")
|
|
26
|
+
nats_subscribe(pattern: "agent.events.subagent.>", target: "main")
|
|
27
|
+
nats_subscribe(pattern: "agent.events.hook.>", target: "main")
|
|
28
|
+
nats_subscribe(pattern: "agent.events.custom.>", target: "main")
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Event-Driven Rules
|
|
32
|
+
|
|
33
|
+
1. **Never poll** — subscribe to events and react when they arrive as messages
|
|
34
|
+
2. **Incoming events** arrive as chat messages formatted `[NATS:<subject>] <payload>`
|
|
35
|
+
|
|
36
|
+
## Scheduling Events (Cron)
|
|
37
|
+
|
|
38
|
+
Use `nats_cron_add` for scheduled events. This fires directly without waking the LLM:
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
# Schedule a daily report trigger at 9am UTC
|
|
42
|
+
nats_cron_add(
|
|
43
|
+
name: "daily-report",
|
|
44
|
+
cron: "0 9 * * *",
|
|
45
|
+
subject: "agent.events.cron.daily-report",
|
|
46
|
+
payload: { "task": "daily_report" }
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# Schedule hourly revenue check
|
|
50
|
+
nats_cron_add(
|
|
51
|
+
name: "hourly-check",
|
|
52
|
+
cron: "0 * * * *",
|
|
53
|
+
subject: "agent.events.cron.check-revenue",
|
|
54
|
+
payload: { "task": "check_revenue" }
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# List all scheduled jobs
|
|
58
|
+
nats_cron_list()
|
|
59
|
+
|
|
60
|
+
# Remove a job
|
|
61
|
+
nats_cron_remove(name: "hourly-check")
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Don't forget to also subscribe to the cron subject so you receive the events:
|
|
65
|
+
```
|
|
66
|
+
nats_subscribe(pattern: "agent.events.cron.>", target: "main")
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
**Alternative (environments with system crontab):** Use `nats-cron-trigger.sh` script.
|
|
70
|
+
|
|
71
|
+
## Subject Hierarchy
|
|
72
|
+
|
|
73
|
+
| Pattern | Use for |
|
|
74
|
+
|---|---|
|
|
75
|
+
| `agent.events.cron.*` | Scheduled task triggers |
|
|
76
|
+
| `agent.events.subagent.spawned` | Subagent started |
|
|
77
|
+
| `agent.events.subagent.ended` | Subagent completed |
|
|
78
|
+
| `agent.events.hook.*` | External webhook triggers |
|
|
79
|
+
| `agent.events.session.*` | Session lifecycle |
|
|
80
|
+
| `agent.events.tool.*` | Tool execution results |
|
|
81
|
+
| `agent.events.gateway.*` | Gateway startup/restart |
|
|
82
|
+
| `agent.events.custom.*` | Your custom events |
|
|
83
|
+
|
|
84
|
+
## Pattern Matching
|
|
85
|
+
|
|
86
|
+
- Exact: `agent.events.cron.daily-report` — matches only this subject
|
|
87
|
+
- `*` — one level: `agent.events.cron.*` matches `agent.events.cron.daily` but not `agent.events.cron.reports.weekly`
|
|
88
|
+
- `>` — all descendants: `agent.events.cron.>` matches everything under `agent.events.cron.`
|
|
89
|
+
|
|
90
|
+
## Examples
|
|
91
|
+
|
|
92
|
+
**React to subagent completion:**
|
|
93
|
+
```
|
|
94
|
+
nats_subscribe(pattern: "agent.events.subagent.ended", target: "main")
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
**Publish a custom event:**
|
|
98
|
+
```
|
|
99
|
+
nats_publish(subject: "agent.events.custom.report-ready", payload: {"reportUrl": "https://..."})
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
**Set up daily workflow:**
|
|
103
|
+
```
|
|
104
|
+
nats_subscribe(pattern: "agent.events.cron.daily-report", target: "main")
|
|
105
|
+
nats_cron_add(name: "daily-report", cron: "0 9 * * *", subject: "agent.events.cron.daily-report", payload: {"task": "report"})
|
|
106
|
+
```
|
|
@@ -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}}"
|