@omnixal/openclaw-nats-plugin 0.2.4 → 0.2.6

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 (33) hide show
  1. package/PLUGIN.md +9 -6
  2. package/README.md +12 -1
  3. package/cli/docker-setup.ts +3 -3
  4. package/cli/setup.ts +0 -6
  5. package/dashboard/src/lib/CronPanel.svelte +206 -27
  6. package/dashboard/src/lib/LogsPanel.svelte +211 -0
  7. package/dashboard/src/lib/RoutesPanel.svelte +157 -13
  8. package/dashboard/src/lib/api.ts +77 -0
  9. package/dashboard/src/lib/components/ui/modal/index.ts +1 -0
  10. package/dashboard/src/lib/components/ui/modal/modal.svelte +49 -0
  11. package/dashboard/src/lib/utils.ts +8 -0
  12. package/package.json +1 -1
  13. package/sidecar/bun.lock +2 -2
  14. package/sidecar/package.json +1 -1
  15. package/sidecar/src/app.module.ts +2 -0
  16. package/sidecar/src/consumer/consumer.controller.ts +20 -12
  17. package/sidecar/src/consumer/consumer.module.ts +2 -1
  18. package/sidecar/src/db/migrations/0005_strong_supernaut.sql +13 -0
  19. package/sidecar/src/db/migrations/meta/0005_snapshot.json +389 -0
  20. package/sidecar/src/db/migrations/meta/_journal.json +7 -0
  21. package/sidecar/src/db/schema.ts +17 -0
  22. package/sidecar/src/logs/log.controller.ts +50 -0
  23. package/sidecar/src/logs/log.module.ts +11 -0
  24. package/sidecar/src/logs/log.repository.ts +78 -0
  25. package/sidecar/src/logs/log.service.ts +116 -0
  26. package/sidecar/src/router/router.controller.ts +28 -6
  27. package/sidecar/src/router/router.repository.ts +8 -0
  28. package/sidecar/src/router/router.service.ts +4 -0
  29. package/sidecar/src/scheduler/scheduler.controller.ts +32 -3
  30. package/sidecar/src/scheduler/scheduler.module.ts +2 -1
  31. package/sidecar/src/scheduler/scheduler.repository.ts +8 -0
  32. package/sidecar/src/scheduler/scheduler.service.ts +91 -25
  33. package/sidecar/src/validation/schemas.ts +27 -0
@@ -2,6 +2,7 @@ import {
2
2
  Controller,
3
3
  Get,
4
4
  Post,
5
+ Patch,
5
6
  Delete,
6
7
  Body,
7
8
  Param,
@@ -12,7 +13,11 @@ import {
12
13
  } from '@onebun/core';
13
14
  import { RouterService } from './router.service';
14
15
  import { ApiKeyMiddleware } from '../auth/api-key.middleware';
15
- import { createRouteBodySchema, type CreateRouteBody } from '../validation/schemas';
16
+ import {
17
+ createRouteBodySchema, type CreateRouteBody,
18
+ updateRouteBodySchema, type UpdateRouteBody,
19
+ isValidAgentSubject,
20
+ } from '../validation/schemas';
16
21
 
17
22
  @Controller('/api/routes')
18
23
  @UseMiddleware(ApiKeyMiddleware)
@@ -32,8 +37,10 @@ export class RouterController extends BaseController {
32
37
  const routes = await this.routerService.listRoutes();
33
38
  const now = Date.now();
34
39
  const result = routes.map(r => ({
40
+ id: r.id,
35
41
  pattern: r.pattern,
36
42
  target: r.target,
43
+ priority: r.priority,
37
44
  enabled: r.enabled,
38
45
  lastDeliveredAt: r.lastDeliveredAt?.toISOString() ?? null,
39
46
  lastEventSubject: r.lastEventSubject ?? null,
@@ -44,18 +51,21 @@ export class RouterController extends BaseController {
44
51
  }
45
52
 
46
53
  @Get()
47
- async getRoutes(@Query() query: Record<string, string>): Promise<OneBunResponse> {
54
+ async getRoutes(
55
+ @Query('pattern') pattern?: string,
56
+ @Query('target') target?: string,
57
+ ): Promise<OneBunResponse> {
48
58
  const filters: { pattern?: string; target?: string } = {};
49
- if (query?.pattern) filters.pattern = query.pattern;
50
- if (query?.target) filters.target = query.target;
59
+ if (pattern) filters.pattern = pattern;
60
+ if (target) filters.target = target;
51
61
  const routes = await this.routerService.listRoutes(filters);
52
62
  return this.success(routes);
53
63
  }
54
64
 
55
65
  @Post()
56
66
  async createRoute(@Body(createRouteBodySchema) body: CreateRouteBody): Promise<OneBunResponse> {
57
- if (!body.pattern.startsWith('agent.events.')) {
58
- return this.error('pattern must start with agent.events.', 400, 400);
67
+ if (!isValidAgentSubject(body.pattern)) {
68
+ return this.error('pattern must start with "agent.events." followed by at least one token and must not end with "."', 400, 400);
59
69
  }
60
70
  const { route, created } = await this.routerService.subscribe(
61
71
  body.pattern,
@@ -65,6 +75,18 @@ export class RouterController extends BaseController {
65
75
  return this.success({ ...route, created });
66
76
  }
67
77
 
78
+ @Patch('/:id')
79
+ async updateRoute(
80
+ @Param('id') id: string,
81
+ @Body(updateRouteBodySchema) body: UpdateRouteBody,
82
+ ): Promise<OneBunResponse> {
83
+ const updated = await this.routerService.updateById(id, body);
84
+ if (!updated) {
85
+ return this.error('Route not found', 404, 404);
86
+ }
87
+ return this.success(updated);
88
+ }
89
+
68
90
  @Delete('/:id')
69
91
  async deleteRoute(@Param('id') id: string): Promise<OneBunResponse> {
70
92
  const deleted = await this.routerService.deleteById(id);
@@ -49,6 +49,14 @@ export class RouterRepository extends BaseService {
49
49
  return { route: result, created };
50
50
  }
51
51
 
52
+ async updateById(id: string, fields: Partial<Pick<DbEventRoute, 'target' | 'priority' | 'enabled'>>): Promise<DbEventRoute | null> {
53
+ const [result] = await this.db.update(eventRoutes)
54
+ .set(fields)
55
+ .where(eq(eventRoutes.id, id))
56
+ .returning();
57
+ return result ?? null;
58
+ }
59
+
52
60
  async recordDelivery(routeId: string, subject: string): Promise<void> {
53
61
  await this.db.update(eventRoutes)
54
62
  .set({
@@ -54,6 +54,10 @@ export class RouterService extends BaseService {
54
54
  });
55
55
  }
56
56
 
57
+ async updateById(id: string, fields: { target?: string; priority?: number; enabled?: boolean }): Promise<DbEventRoute | null> {
58
+ return this.repo.updateById(id, fields);
59
+ }
60
+
57
61
  async recordDelivery(routeId: string, subject: string): Promise<void> {
58
62
  await this.repo.recordDelivery(routeId, subject);
59
63
  }
@@ -4,10 +4,18 @@ import {
4
4
  UseMiddleware, Subscribe, OnQueueReady,
5
5
  type Message,
6
6
  type OneBunResponse,
7
+ OnQueueError,
8
+ OnMessageReceived,
9
+ OnMessageProcessed,
10
+ OnMessageFailed,
7
11
  } from '@onebun/core';
8
12
  import { SchedulerService } from './scheduler.service';
9
13
  import { ApiKeyMiddleware } from '../auth/api-key.middleware';
10
- import { createCronBodySchema, type CreateCronBody } from '../validation/schemas';
14
+ import {
15
+ createCronBodySchema, type CreateCronBody,
16
+ updateCronBodySchema, type UpdateCronBody,
17
+ isValidAgentSubject,
18
+ } from '../validation/schemas';
11
19
 
12
20
  @Controller('/api/cron')
13
21
  @UseMiddleware(ApiKeyMiddleware)
@@ -18,13 +26,21 @@ export class SchedulerController extends BaseController {
18
26
 
19
27
  @OnQueueReady()
20
28
  async onQueueReady(): Promise<void> {
29
+ this.logger.info('Queue connected, starting scheduler');
30
+ this.scheduler.markQueueReady();
21
31
  await this.scheduler.restoreJobs();
22
32
  }
23
33
 
34
+ @OnQueueError()
35
+ handleError(error: Error) {
36
+ this.logger.error('Queue error (publish stopped)', error);
37
+ this.scheduler.markQueueReady(false);
38
+ }
39
+
24
40
  @Post()
25
41
  async createJob(@Body(createCronBodySchema) body: CreateCronBody): Promise<OneBunResponse> {
26
- if (!body.subject.startsWith('agent.events.')) {
27
- return this.error('subject must start with agent.events.', 400, 400);
42
+ if (!isValidAgentSubject(body.subject)) {
43
+ return this.error('subject must start with "agent.events." followed by at least one token and must not end with "."', 400, 400);
28
44
  }
29
45
  const job = await this.scheduler.add({
30
46
  name: body.name,
@@ -42,6 +58,19 @@ export class SchedulerController extends BaseController {
42
58
  return this.success(jobs);
43
59
  }
44
60
 
61
+ @Patch('/:name')
62
+ async updateJob(
63
+ @Param('name') name: string,
64
+ @Body(updateCronBodySchema) body: UpdateCronBody,
65
+ ): Promise<OneBunResponse> {
66
+ if (body.subject !== undefined && !isValidAgentSubject(body.subject)) {
67
+ return this.error('subject must start with "agent.events." followed by at least one token and must not end with "."', 400, 400);
68
+ }
69
+ const updated = await this.scheduler.update(name, body);
70
+ if (!updated) return this.error('Job not found', 404, 404);
71
+ return this.success(updated);
72
+ }
73
+
45
74
  @Patch('/:name/toggle')
46
75
  async toggleJob(@Param('name') name: string): Promise<OneBunResponse> {
47
76
  const result = await this.scheduler.toggle(name);
@@ -1,11 +1,12 @@
1
1
  import { Module } from '@onebun/core';
2
2
  import { PublisherModule } from '../publisher/publisher.module';
3
+ import { LogModule } from '../logs/log.module';
3
4
  import { SchedulerRepository } from './scheduler.repository';
4
5
  import { SchedulerService } from './scheduler.service';
5
6
  import { SchedulerController } from './scheduler.controller';
6
7
 
7
8
  @Module({
8
- imports: [PublisherModule],
9
+ imports: [PublisherModule, LogModule],
9
10
  controllers: [SchedulerController],
10
11
  providers: [SchedulerRepository, SchedulerService],
11
12
  exports: [SchedulerService],
@@ -44,6 +44,14 @@ export class SchedulerRepository extends BaseService {
44
44
  return result;
45
45
  }
46
46
 
47
+ async updateByName(name: string, fields: Partial<Pick<DbCronJob, 'expr' | 'subject' | 'payload' | 'timezone' | 'enabled'>>): Promise<DbCronJob | null> {
48
+ const [result] = await this.db.update(cronJobs)
49
+ .set(fields)
50
+ .where(eq(cronJobs.name, name))
51
+ .returning();
52
+ return result ?? null;
53
+ }
54
+
47
55
  async deleteByName(name: string): Promise<boolean> {
48
56
  const result = await this.db.delete(cronJobs)
49
57
  .where(eq(cronJobs.name, name)).returning();
@@ -1,6 +1,7 @@
1
1
  import { Service, BaseService, QueueService } from '@onebun/core';
2
2
  import { SchedulerRepository } from './scheduler.repository';
3
3
  import { PublisherService } from '../publisher/publisher.service';
4
+ import { LogService } from '../logs/log.service';
4
5
  import { ulid } from 'ulid';
5
6
 
6
7
  interface AddJobInput {
@@ -13,10 +14,13 @@ interface AddJobInput {
13
14
 
14
15
  @Service()
15
16
  export class SchedulerService extends BaseService {
17
+ private _queueReady = false;
18
+
16
19
  constructor(
17
20
  private repo: SchedulerRepository,
18
21
  private queueService: QueueService,
19
22
  private publisher: PublisherService,
23
+ private logService: LogService,
20
24
  ) {
21
25
  super();
22
26
  }
@@ -25,17 +29,27 @@ export class SchedulerService extends BaseService {
25
29
  return this.queueService.getScheduler();
26
30
  }
27
31
 
32
+ markQueueReady(isReady = true): void {
33
+ this._queueReady = isReady;
34
+ }
35
+
28
36
  async restoreJobs(): 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`);
37
+ try {
38
+ const jobs = await this.repo.findAllEnabled();
39
+ for (const job of jobs) {
40
+ this.scheduler.addCronJob(
41
+ job.name,
42
+ job.expr,
43
+ `scheduler.fire.${job.name}`,
44
+ );
45
+ }
46
+ if (jobs.length > 0) {
47
+ this.logger.info(`Restored ${jobs.length} cron jobs from DB`);
48
+ } else {
49
+ this.logger.info('No cron jobs found in DB');
50
+ }
51
+ } catch (err) {
52
+ this.logger.error('Failed to restore cron jobs', err);
39
53
  }
40
54
  }
41
55
 
@@ -65,6 +79,33 @@ export class SchedulerService extends BaseService {
65
79
  return job;
66
80
  }
67
81
 
82
+ async update(name: string, fields: { cron?: string; subject?: string; payload?: unknown; timezone?: string; enabled?: boolean }) {
83
+ const updates: Record<string, unknown> = {};
84
+ if (fields.cron !== undefined) updates.expr = fields.cron;
85
+ if (fields.subject !== undefined) updates.subject = fields.subject;
86
+ if (fields.payload !== undefined) updates.payload = fields.payload;
87
+ if (fields.timezone !== undefined) updates.timezone = fields.timezone;
88
+ if (fields.enabled !== undefined) updates.enabled = fields.enabled;
89
+
90
+ if (Object.keys(updates).length === 0) {
91
+ return this.repo.findByName(name);
92
+ }
93
+
94
+ const updated = await this.repo.updateByName(name, updates as any);
95
+ if (!updated) return null;
96
+
97
+ // Re-register in runtime scheduler if expr or enabled changed
98
+ if (this.scheduler.hasJob(name)) {
99
+ this.scheduler.removeJob(name);
100
+ }
101
+ if (updated.enabled) {
102
+ this.scheduler.addCronJob(name, updated.expr, `scheduler.fire.${name}`);
103
+ }
104
+
105
+ this.logger.info(`Cron job '${name}' updated`);
106
+ return updated;
107
+ }
108
+
68
109
  async remove(name: string): Promise<boolean> {
69
110
  const deleted = await this.repo.deleteByName(name);
70
111
  if (this.scheduler.hasJob(name)) {
@@ -79,6 +120,8 @@ export class SchedulerService extends BaseService {
79
120
  const runtime = this.scheduler.getJob(job.name);
80
121
  return {
81
122
  ...job,
123
+ lastRunAt: job.lastRunAt?.getTime() ?? null,
124
+ createdAt: job.createdAt.getTime(),
82
125
  nextRun: runtime?.nextRun ?? null,
83
126
  isRunning: runtime?.isRunning ?? false,
84
127
  };
@@ -109,30 +152,53 @@ export class SchedulerService extends BaseService {
109
152
  return this.repo.findByName(name);
110
153
  }
111
154
 
155
+ private buildCronPayload(job: { name: string; payload: unknown }, manual: boolean): Record<string, unknown> {
156
+ const base = (job.payload && typeof job.payload === 'object' && !Array.isArray(job.payload))
157
+ ? (job.payload as Record<string, unknown>)
158
+ : {};
159
+ return { ...base, _cron: { jobName: job.name, firedAt: new Date().toISOString(), ...(manual ? { manual: true } : {}) } };
160
+ }
161
+
112
162
  async fireNow(name: string): Promise<boolean> {
113
163
  const job = await this.repo.findByName(name);
114
164
  if (!job) return false;
115
165
 
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;
166
+ if (!this._queueReady) {
167
+ this.logger.warn(`Cron job '${name}' fire skipped — queue not ready`);
168
+ return false;
169
+ }
170
+
171
+ try {
172
+ const payload = this.buildCronPayload(job, true);
173
+ await this.publisher.publish(job.subject, payload);
174
+ await this.repo.updateLastRun(name);
175
+ await this.logService.logCronFire(job.id, job.subject, true);
176
+ this.logger.info(`Cron job '${name}' manually fired -> ${job.subject}`);
177
+ return true;
178
+ } catch (err) {
179
+ await this.logService.logError('cron', job.id, job.subject, err);
180
+ throw err;
181
+ }
124
182
  }
125
183
 
126
184
  async handleFire(jobName: string): Promise<void> {
127
185
  const job = await this.repo.findByName(jobName);
128
186
  if (!job || !job.enabled) return;
129
187
 
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}`);
188
+ if (!this._queueReady) {
189
+ this.logger.warn(`Cron fire skipped for '${jobName}' — queue not ready`);
190
+ return;
191
+ }
192
+
193
+ try {
194
+ const payload = this.buildCronPayload(job, false);
195
+ await this.publisher.publish(job.subject, payload);
196
+ await this.repo.updateLastRun(job.name);
197
+ await this.logService.logCronFire(job.id, job.subject, false);
198
+ this.logger.debug(`Cron fired: ${job.name} -> ${job.subject}`);
199
+ } catch (err) {
200
+ await this.logService.logError('cron', job.id, job.subject, err);
201
+ this.logger.error(`Cron fire failed: ${job.name}`, err);
202
+ }
137
203
  }
138
204
  }
@@ -35,3 +35,30 @@ export const createCronBodySchema = type({
35
35
  });
36
36
 
37
37
  export type CreateCronBody = typeof createCronBodySchema.infer;
38
+
39
+ export const updateRouteBodySchema = type({
40
+ 'target?': 'string',
41
+ 'priority?': 'number',
42
+ 'enabled?': 'boolean',
43
+ });
44
+
45
+ export type UpdateRouteBody = typeof updateRouteBodySchema.infer;
46
+
47
+ export const updateCronBodySchema = type({
48
+ 'cron?': 'string',
49
+ 'subject?': 'string',
50
+ 'payload?': 'unknown',
51
+ 'timezone?': 'string',
52
+ 'enabled?': 'boolean',
53
+ });
54
+
55
+ export type UpdateCronBody = typeof updateCronBodySchema.infer;
56
+
57
+ /** Validate that subject has content after 'agent.events.' prefix and doesn't end with '.' */
58
+ export function isValidAgentSubject(subject: string): boolean {
59
+ if (!subject.startsWith('agent.events.')) return false;
60
+ const rest = subject.slice('agent.events.'.length);
61
+ if (rest.length === 0) return false;
62
+ if (rest.endsWith('.')) return false;
63
+ return true;
64
+ }