@omnixal/openclaw-nats-plugin 0.2.0 → 0.2.2

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.
Files changed (71) hide show
  1. package/cli/setup.ts +27 -0
  2. package/dashboard/bun.lock +253 -0
  3. package/dashboard/components.json +16 -0
  4. package/dashboard/index.html +22 -0
  5. package/dashboard/package.json +24 -0
  6. package/dashboard/src/App.svelte +107 -0
  7. package/dashboard/src/app.css +232 -0
  8. package/dashboard/src/lib/ConfigPanel.svelte +35 -0
  9. package/dashboard/src/lib/CronPanel.svelte +255 -0
  10. package/dashboard/src/lib/HealthCards.svelte +68 -0
  11. package/dashboard/src/lib/MetricsPanel.svelte +60 -0
  12. package/dashboard/src/lib/PendingTable.svelte +73 -0
  13. package/dashboard/src/lib/RoutesPanel.svelte +178 -0
  14. package/dashboard/src/lib/ThemeToggle.svelte +54 -0
  15. package/dashboard/src/lib/api.ts +141 -0
  16. package/dashboard/src/lib/components/ui/badge/badge.svelte +50 -0
  17. package/dashboard/src/lib/components/ui/badge/index.ts +2 -0
  18. package/dashboard/src/lib/components/ui/button/button.svelte +82 -0
  19. package/dashboard/src/lib/components/ui/button/index.ts +17 -0
  20. package/dashboard/src/lib/components/ui/card/card-action.svelte +20 -0
  21. package/dashboard/src/lib/components/ui/card/card-content.svelte +15 -0
  22. package/dashboard/src/lib/components/ui/card/card-description.svelte +20 -0
  23. package/dashboard/src/lib/components/ui/card/card-footer.svelte +20 -0
  24. package/dashboard/src/lib/components/ui/card/card-header.svelte +23 -0
  25. package/dashboard/src/lib/components/ui/card/card-title.svelte +20 -0
  26. package/dashboard/src/lib/components/ui/card/card.svelte +23 -0
  27. package/dashboard/src/lib/components/ui/card/index.ts +25 -0
  28. package/dashboard/src/lib/components/ui/table/index.ts +28 -0
  29. package/dashboard/src/lib/components/ui/table/table-body.svelte +20 -0
  30. package/dashboard/src/lib/components/ui/table/table-caption.svelte +20 -0
  31. package/dashboard/src/lib/components/ui/table/table-cell.svelte +23 -0
  32. package/dashboard/src/lib/components/ui/table/table-footer.svelte +20 -0
  33. package/dashboard/src/lib/components/ui/table/table-head.svelte +23 -0
  34. package/dashboard/src/lib/components/ui/table/table-header.svelte +20 -0
  35. package/dashboard/src/lib/components/ui/table/table-row.svelte +23 -0
  36. package/dashboard/src/lib/components/ui/table/table.svelte +22 -0
  37. package/dashboard/src/lib/utils.ts +29 -0
  38. package/dashboard/src/main.ts +7 -0
  39. package/dashboard/tsconfig.json +19 -0
  40. package/dashboard/vite.config.ts +30 -0
  41. package/package.json +6 -4
  42. package/plugins/nats-context-engine/http-handler.ts +1 -1
  43. package/plugins/nats-context-engine/index.ts +59 -0
  44. package/sidecar/bun.lock +8 -6
  45. package/sidecar/package.json +4 -4
  46. package/sidecar/src/app.module.ts +9 -2
  47. package/sidecar/src/consumer/consumer.controller.ts +4 -0
  48. package/sidecar/src/consumer/consumer.module.ts +2 -1
  49. package/sidecar/src/db/migrations/0004_familiar_zaladane.sql +17 -0
  50. package/sidecar/src/db/migrations/meta/0004_snapshot.json +306 -0
  51. package/sidecar/src/db/migrations/meta/_journal.json +7 -0
  52. package/sidecar/src/db/schema.ts +20 -0
  53. package/sidecar/src/health/health.service.ts +1 -1
  54. package/sidecar/src/index.ts +6 -0
  55. package/sidecar/src/metrics/metrics.controller.ts +16 -0
  56. package/sidecar/src/metrics/metrics.module.ts +10 -0
  57. package/sidecar/src/metrics/metrics.service.ts +64 -0
  58. package/sidecar/src/publisher/publisher.module.ts +2 -0
  59. package/sidecar/src/publisher/publisher.service.ts +6 -1
  60. package/sidecar/src/router/router.controller.ts +20 -12
  61. package/sidecar/src/router/router.repository.ts +39 -7
  62. package/sidecar/src/router/router.service.ts +10 -2
  63. package/sidecar/src/scheduler/scheduler.controller.ts +68 -0
  64. package/sidecar/src/scheduler/scheduler.module.ts +13 -0
  65. package/sidecar/src/scheduler/scheduler.repository.ts +64 -0
  66. package/sidecar/src/scheduler/scheduler.service.ts +138 -0
  67. package/sidecar/src/validation/schemas.ts +18 -0
  68. package/skills/nats-events/SKILL.md +41 -28
  69. package/dashboard/dist/assets/index--UFIkwvP.js +0 -2
  70. package/dashboard/dist/assets/index-CafgidIc.css +0 -2
  71. package/dashboard/dist/index.html +0 -13
@@ -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;
@@ -5,22 +5,21 @@ description: Event-driven patterns for NATS. Use when the agent needs to publish
5
5
 
6
6
  # NATS Event Bus
7
7
 
8
- You have access to a NATS JetStream event bus via four tools:
8
+ You have access to a NATS JetStream event bus via these tools:
9
9
 
10
10
  | Tool | Purpose |
11
11
  |---|---|
12
12
  | `nats_publish` | Publish an event (subject + payload) |
13
- | `nats_subscribe` | Create a routing rule (pattern -> session) |
13
+ | `nats_subscribe` | Create a routing rule (pattern -> session) — idempotent, safe to repeat |
14
14
  | `nats_unsubscribe` | Remove a routing rule by ID |
15
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 |
16
19
 
17
20
  ## Bootstrap
18
21
 
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:
22
+ Before first use, set up default subscriptions. This is idempotent — safe to run multiple times:
24
23
 
25
24
  ```
26
25
  nats_subscribe(pattern: "agent.events.cron.>", target: "main")
@@ -29,30 +28,45 @@ nats_subscribe(pattern: "agent.events.hook.>", target: "main")
29
28
  nats_subscribe(pattern: "agent.events.custom.>", target: "main")
30
29
  ```
31
30
 
32
- Do NOT repeat bootstrap if subscriptions already exist.
33
-
34
31
  ## Event-Driven Rules
35
32
 
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>`
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>`
40
35
 
41
- ## Creating Cron Tasks
36
+ ## Scheduling Events (Cron)
42
37
 
43
- Always use the bundled script never invoke LLM from cron:
38
+ Use `nats_cron_add` for scheduled events. This fires directly without waking the LLM:
44
39
 
45
- ```bash
46
- # Step 1: Subscribe to the event
47
- nats_subscribe(pattern: "agent.events.cron.daily-report", target: "main")
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
+ ```
48
63
 
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"}'
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")
53
67
  ```
54
68
 
55
- The script only needs `NATS_SIDECAR_URL` and `NATS_PLUGIN_API_KEY` environment variables.
69
+ **Alternative (environments with system crontab):** Use `nats-cron-trigger.sh` script.
56
70
 
57
71
  ## Subject Hierarchy
58
72
 
@@ -78,16 +92,15 @@ The script only needs `NATS_SIDECAR_URL` and `NATS_PLUGIN_API_KEY` environment v
78
92
  **React to subagent completion:**
79
93
  ```
80
94
  nats_subscribe(pattern: "agent.events.subagent.ended", target: "main")
81
- # When subagent finishes, you receive: [NATS:agent.events.subagent.ended] {"subagentId":...,"result":...}
82
95
  ```
83
96
 
84
- **Publish a custom event for external consumers:**
97
+ **Publish a custom event:**
85
98
  ```
86
99
  nats_publish(subject: "agent.events.custom.report-ready", payload: {"reportUrl": "https://..."})
87
100
  ```
88
101
 
89
- **Schedule a recurring task:**
102
+ **Set up daily workflow:**
90
103
  ```
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 '{}'
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"})
93
106
  ```