@omnixal/openclaw-nats-plugin 0.1.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/PLUGIN.md +94 -0
- package/bin/cli.ts +75 -0
- package/cli/bun-setup.ts +133 -0
- package/cli/detect-runtime.ts +40 -0
- package/cli/docker-setup.ts +54 -0
- package/cli/download-nats.ts +110 -0
- package/cli/env-writer.ts +58 -0
- package/cli/lifecycle.ts +109 -0
- package/cli/nats-config.ts +32 -0
- package/cli/paths.ts +20 -0
- package/cli/service-units.ts +168 -0
- package/cli/setup.ts +23 -0
- package/dashboard/dist/assets/index-CafgidIc.css +2 -0
- package/dashboard/dist/assets/index-OUWnIZmb.js +15 -0
- package/dashboard/dist/index.html +13 -0
- package/docker/docker-compose.yml +48 -0
- package/hooks/command-publisher/HOOK.md +13 -0
- package/hooks/command-publisher/handler.ts +23 -0
- package/hooks/gateway-startup/HOOK.md +13 -0
- package/hooks/gateway-startup/handler.ts +31 -0
- package/hooks/lifecycle-publisher/HOOK.md +12 -0
- package/hooks/lifecycle-publisher/handler.ts +20 -0
- package/hooks/shared/sidecar-client.ts +23 -0
- package/index.ts +3 -0
- package/openclaw.plugin.json +8 -0
- package/package.json +48 -0
- package/plugins/nats-context-engine/PLUGIN.md +14 -0
- package/plugins/nats-context-engine/http-handler.ts +131 -0
- package/plugins/nats-context-engine/index.ts +89 -0
- package/sidecar/Dockerfile +11 -0
- package/sidecar/bun.lock +212 -0
- package/sidecar/drizzle.config.ts +10 -0
- package/sidecar/package.json +28 -0
- package/sidecar/src/app.module.ts +33 -0
- package/sidecar/src/auth/api-key.middleware.ts +39 -0
- package/sidecar/src/config.ts +40 -0
- package/sidecar/src/consumer/consumer.module.ts +12 -0
- package/sidecar/src/consumer/consumer.service.ts +113 -0
- package/sidecar/src/db/migrations/0000_complete_mulholland_black.sql +5 -0
- package/sidecar/src/db/migrations/0001_high_psylocke.sql +9 -0
- package/sidecar/src/db/migrations/0002_common_stellaris.sql +1 -0
- package/sidecar/src/db/migrations/meta/0000_snapshot.json +49 -0
- package/sidecar/src/db/migrations/meta/0001_snapshot.json +109 -0
- package/sidecar/src/db/migrations/meta/0002_snapshot.json +117 -0
- package/sidecar/src/db/migrations/meta/_journal.json +27 -0
- package/sidecar/src/db/schema.ts +22 -0
- package/sidecar/src/dedup/dedup.module.ts +9 -0
- package/sidecar/src/dedup/dedup.repository.ts +29 -0
- package/sidecar/src/dedup/dedup.service.ts +38 -0
- package/sidecar/src/gateway/gateway-client.module.ts +8 -0
- package/sidecar/src/gateway/gateway-client.service.ts +131 -0
- package/sidecar/src/health/health.controller.ts +15 -0
- package/sidecar/src/health/health.module.ts +13 -0
- package/sidecar/src/health/health.service.ts +51 -0
- package/sidecar/src/index.ts +21 -0
- package/sidecar/src/nats-streams/nats-adapter.service.ts +133 -0
- package/sidecar/src/nats-streams/nats-streams.module.ts +8 -0
- package/sidecar/src/pending/pending.controller.ts +24 -0
- package/sidecar/src/pending/pending.module.ts +11 -0
- package/sidecar/src/pending/pending.repository.ts +62 -0
- package/sidecar/src/pending/pending.service.ts +38 -0
- package/sidecar/src/pre-handlers/dedup.handler.ts +22 -0
- package/sidecar/src/pre-handlers/enrich.handler.ts +14 -0
- package/sidecar/src/pre-handlers/filter.handler.ts +39 -0
- package/sidecar/src/pre-handlers/pipeline.service.ts +38 -0
- package/sidecar/src/pre-handlers/pre-handler.interface.ts +10 -0
- package/sidecar/src/pre-handlers/pre-handlers.module.ts +14 -0
- package/sidecar/src/pre-handlers/priority.handler.ts +14 -0
- package/sidecar/src/publisher/envelope.ts +36 -0
- package/sidecar/src/publisher/publisher.controller.ts +21 -0
- package/sidecar/src/publisher/publisher.module.ts +12 -0
- package/sidecar/src/publisher/publisher.service.ts +20 -0
- package/sidecar/src/validation/schemas.ts +19 -0
- package/sidecar/tsconfig.json +16 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Controller, Get, BaseController, type OneBunResponse } from '@onebun/core';
|
|
2
|
+
import { HealthService } from './health.service';
|
|
3
|
+
|
|
4
|
+
@Controller('/api/health')
|
|
5
|
+
export class HealthController extends BaseController {
|
|
6
|
+
constructor(private healthService: HealthService) {
|
|
7
|
+
super();
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
@Get('/')
|
|
11
|
+
async getHealth(): Promise<OneBunResponse> {
|
|
12
|
+
const status = await this.healthService.getStatus();
|
|
13
|
+
return this.success(status);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Module } from '@onebun/core';
|
|
2
|
+
import { HealthController } from './health.controller';
|
|
3
|
+
import { HealthService } from './health.service';
|
|
4
|
+
import { NatsStreamsModule } from '../nats-streams/nats-streams.module';
|
|
5
|
+
import { GatewayClientModule } from '../gateway/gateway-client.module';
|
|
6
|
+
import { PendingModule } from '../pending/pending.module';
|
|
7
|
+
|
|
8
|
+
@Module({
|
|
9
|
+
imports: [NatsStreamsModule, GatewayClientModule, PendingModule],
|
|
10
|
+
controllers: [HealthController],
|
|
11
|
+
providers: [HealthService],
|
|
12
|
+
})
|
|
13
|
+
export class HealthModule {}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Service, BaseService } from '@onebun/core';
|
|
2
|
+
import { NatsAdapterService } from '../nats-streams/nats-adapter.service';
|
|
3
|
+
import { GatewayClientService } from '../gateway/gateway-client.service';
|
|
4
|
+
import { PendingService } from '../pending/pending.service';
|
|
5
|
+
|
|
6
|
+
export interface HealthStatus {
|
|
7
|
+
nats: { connected: boolean; url: string };
|
|
8
|
+
gateway: { connected: boolean; url: string };
|
|
9
|
+
pendingCount: number;
|
|
10
|
+
uptimeSeconds: number;
|
|
11
|
+
config: {
|
|
12
|
+
streams: string[];
|
|
13
|
+
consumerName: string;
|
|
14
|
+
dedupTtlSeconds: number;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
@Service()
|
|
19
|
+
export class HealthService extends BaseService {
|
|
20
|
+
private readonly startedAt = Date.now();
|
|
21
|
+
|
|
22
|
+
constructor(
|
|
23
|
+
private nats: NatsAdapterService,
|
|
24
|
+
private gateway: GatewayClientService,
|
|
25
|
+
private pending: PendingService,
|
|
26
|
+
) {
|
|
27
|
+
super();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async getStatus(): Promise<HealthStatus> {
|
|
31
|
+
const pendingCount = await this.pending.countPending();
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
nats: {
|
|
35
|
+
connected: this.nats.isConnected(),
|
|
36
|
+
url: this.config.get('nats.servers'),
|
|
37
|
+
},
|
|
38
|
+
gateway: {
|
|
39
|
+
connected: this.gateway.isAlive(),
|
|
40
|
+
url: this.config.get('gateway.wsUrl'),
|
|
41
|
+
},
|
|
42
|
+
pendingCount,
|
|
43
|
+
uptimeSeconds: Math.floor((Date.now() - this.startedAt) / 1000),
|
|
44
|
+
config: {
|
|
45
|
+
streams: ['agent_inbound', 'agent_events', 'agent_dlq'],
|
|
46
|
+
consumerName: this.config.get('consumer.name'),
|
|
47
|
+
dedupTtlSeconds: this.config.get('dedup.ttlSeconds'),
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { OneBunApplication } from '@onebun/core';
|
|
2
|
+
import { AppModule } from './app.module';
|
|
3
|
+
import { envSchema } from './config';
|
|
4
|
+
|
|
5
|
+
const app = new OneBunApplication(AppModule, {
|
|
6
|
+
development: Bun.env.NODE_ENV !== 'production',
|
|
7
|
+
envSchema,
|
|
8
|
+
envOptions: {
|
|
9
|
+
loadDotEnv: true,
|
|
10
|
+
},
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
app.start()
|
|
14
|
+
.then(() => {
|
|
15
|
+
const logger = app.getLogger({ className: 'Bootstrap' });
|
|
16
|
+
logger.info('nats-sidecar started');
|
|
17
|
+
})
|
|
18
|
+
.catch((error) => {
|
|
19
|
+
console.error('Failed to start:', error);
|
|
20
|
+
process.exit(1);
|
|
21
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { Service, BaseService, type OnModuleInit, type OnModuleDestroy } from '@onebun/core';
|
|
2
|
+
import type { Subscription, MessageHandler, SubscribeOptions, PublishOptions } from '@onebun/core';
|
|
3
|
+
import { JetStreamQueueAdapter, type JetStreamAdapterOptions } from '@onebun/nats';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Manages the JetStreamQueueAdapter lifecycle with graceful degradation.
|
|
7
|
+
*
|
|
8
|
+
* When NATS is unavailable, the service runs in degraded mode:
|
|
9
|
+
* publish and subscribe operations are silently dropped with warnings.
|
|
10
|
+
*
|
|
11
|
+
* Also ensures additional streams (agent_events, agent_dlq) exist.
|
|
12
|
+
*/
|
|
13
|
+
@Service()
|
|
14
|
+
export class NatsAdapterService extends BaseService implements OnModuleInit, OnModuleDestroy {
|
|
15
|
+
private adapter: JetStreamQueueAdapter | null = null;
|
|
16
|
+
private _connected = false;
|
|
17
|
+
|
|
18
|
+
async onModuleInit(): Promise<void> {
|
|
19
|
+
const servers = this.config.get('nats.servers');
|
|
20
|
+
const reconnectTimeWait = this.config.get('nats.reconnectDelayMs');
|
|
21
|
+
const maxReconnectAttempts = this.config.get('nats.maxReconnectAttempts');
|
|
22
|
+
const ackWaitMs = this.config.get('consumer.ackWaitMs');
|
|
23
|
+
const maxDeliver = this.config.get('consumer.maxDeliver');
|
|
24
|
+
|
|
25
|
+
const options: JetStreamAdapterOptions = {
|
|
26
|
+
servers,
|
|
27
|
+
maxReconnectAttempts,
|
|
28
|
+
reconnectTimeWait,
|
|
29
|
+
stream: 'agent_inbound',
|
|
30
|
+
createStream: true,
|
|
31
|
+
streamConfig: {
|
|
32
|
+
subjects: ['agent.inbound.>'],
|
|
33
|
+
retention: 'workqueue',
|
|
34
|
+
storage: 'file',
|
|
35
|
+
replicas: 1,
|
|
36
|
+
},
|
|
37
|
+
consumerConfig: {
|
|
38
|
+
ackWait: ackWaitMs * 1_000_000, // ms → ns
|
|
39
|
+
maxDeliver,
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
this.adapter = new JetStreamQueueAdapter(options);
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
await this.adapter.connect();
|
|
47
|
+
this._connected = true;
|
|
48
|
+
await this.ensureExtraStreams();
|
|
49
|
+
this.logger.info('NATS JetStream connected');
|
|
50
|
+
} catch (err: any) {
|
|
51
|
+
this.logger.warn(`NATS connection failed, running in degraded mode: ${err?.message}`);
|
|
52
|
+
this.adapter = null;
|
|
53
|
+
this._connected = false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
isConnected(): boolean {
|
|
58
|
+
return this._connected && this.adapter !== null && this.adapter.isConnected();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async publish<T>(pattern: string, data: T, options?: PublishOptions): Promise<string | null> {
|
|
62
|
+
if (!this.adapter || !this.isConnected()) {
|
|
63
|
+
this.logger.warn(`NATS not connected, dropping publish to ${pattern}`);
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
return await this.adapter.publish(pattern, data, options);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async subscribe<T>(
|
|
70
|
+
pattern: string,
|
|
71
|
+
handler: MessageHandler<T>,
|
|
72
|
+
options?: SubscribeOptions,
|
|
73
|
+
): Promise<Subscription | null> {
|
|
74
|
+
if (!this.adapter || !this.isConnected()) {
|
|
75
|
+
this.logger.warn(`NATS not connected, cannot subscribe to ${pattern}`);
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
return await this.adapter.subscribe(pattern, handler, options);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async onModuleDestroy(): Promise<void> {
|
|
82
|
+
if (this.adapter) {
|
|
83
|
+
await this.adapter.disconnect();
|
|
84
|
+
this.logger.info('NATS JetStream disconnected');
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private async ensureExtraStreams(): Promise<void> {
|
|
89
|
+
try {
|
|
90
|
+
const nc = (this.adapter as any)?.client?.getConnection?.();
|
|
91
|
+
if (!nc) {
|
|
92
|
+
this.logger.warn('Cannot access NATS connection for stream creation');
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const jsModule = await import('@nats-io/jetstream');
|
|
97
|
+
const jsm = await jsModule.jetstreamManager(nc);
|
|
98
|
+
|
|
99
|
+
const SEVEN_DAYS_NS = 7 * 24 * 60 * 60 * 1e9;
|
|
100
|
+
|
|
101
|
+
await this.ensureStream(jsm, {
|
|
102
|
+
name: 'agent_events',
|
|
103
|
+
subjects: ['agent.events.>'],
|
|
104
|
+
retention: 'limits',
|
|
105
|
+
max_age: SEVEN_DAYS_NS,
|
|
106
|
+
storage: 'file',
|
|
107
|
+
num_replicas: 1,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
await this.ensureStream(jsm, {
|
|
111
|
+
name: 'agent_dlq',
|
|
112
|
+
subjects: ['agent.dlq.>'],
|
|
113
|
+
retention: 'limits',
|
|
114
|
+
max_age: SEVEN_DAYS_NS,
|
|
115
|
+
storage: 'file',
|
|
116
|
+
num_replicas: 1,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
this.logger.info('Additional JetStream streams ensured (agent_events, agent_dlq)');
|
|
120
|
+
} catch (err: any) {
|
|
121
|
+
this.logger.warn(`Failed to ensure extra streams: ${err?.message}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private async ensureStream(jsm: any, config: Record<string, any>): Promise<void> {
|
|
126
|
+
try {
|
|
127
|
+
await jsm.streams.info(config.name);
|
|
128
|
+
await jsm.streams.update(config.name, config);
|
|
129
|
+
} catch {
|
|
130
|
+
await jsm.streams.add(config);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Controller, Get, Post, Body, Param, BaseController, UseMiddleware, type OneBunResponse } from '@onebun/core';
|
|
2
|
+
import { PendingService } from './pending.service';
|
|
3
|
+
import { ApiKeyMiddleware } from '../auth/api-key.middleware';
|
|
4
|
+
import { markDeliveredBodySchema, type MarkDeliveredBody } from '../validation/schemas';
|
|
5
|
+
|
|
6
|
+
@Controller('/api/pending')
|
|
7
|
+
@UseMiddleware(ApiKeyMiddleware)
|
|
8
|
+
export class PendingController extends BaseController {
|
|
9
|
+
constructor(private pendingService: PendingService) {
|
|
10
|
+
super();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
@Get('/:sessionKey')
|
|
14
|
+
async fetchPending(@Param('sessionKey') sessionKey: string): Promise<OneBunResponse> {
|
|
15
|
+
const events = await this.pendingService.fetchPending(sessionKey);
|
|
16
|
+
return this.success(events);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
@Post('/mark-delivered')
|
|
20
|
+
async markDelivered(@Body(markDeliveredBodySchema) body: MarkDeliveredBody): Promise<OneBunResponse> {
|
|
21
|
+
await this.pendingService.markDelivered(body.ids);
|
|
22
|
+
return this.success({ marked: body.ids.length });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Module } from '@onebun/core';
|
|
2
|
+
import { PendingController } from './pending.controller';
|
|
3
|
+
import { PendingService } from './pending.service';
|
|
4
|
+
import { PendingRepository } from './pending.repository';
|
|
5
|
+
|
|
6
|
+
@Module({
|
|
7
|
+
controllers: [PendingController],
|
|
8
|
+
providers: [PendingService, PendingRepository],
|
|
9
|
+
exports: [PendingService],
|
|
10
|
+
})
|
|
11
|
+
export class PendingModule {}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { Service, BaseService } from '@onebun/core';
|
|
2
|
+
import { DrizzleService, eq, and, isNull, inArray, lt, desc, sql } from '@onebun/drizzle';
|
|
3
|
+
import { pendingEvents, type DbPendingEvent } from '../db/schema';
|
|
4
|
+
|
|
5
|
+
@Service()
|
|
6
|
+
export class PendingRepository extends BaseService {
|
|
7
|
+
constructor(private db: DrizzleService) {
|
|
8
|
+
super();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async addPending(event: {
|
|
12
|
+
id: string;
|
|
13
|
+
sessionKey: string;
|
|
14
|
+
subject: string;
|
|
15
|
+
payload: unknown;
|
|
16
|
+
priority: number;
|
|
17
|
+
}): Promise<void> {
|
|
18
|
+
await this.db
|
|
19
|
+
.insert(pendingEvents)
|
|
20
|
+
.values({
|
|
21
|
+
id: event.id,
|
|
22
|
+
sessionKey: event.sessionKey,
|
|
23
|
+
subject: event.subject,
|
|
24
|
+
payload: event.payload,
|
|
25
|
+
priority: event.priority,
|
|
26
|
+
createdAt: new Date(),
|
|
27
|
+
})
|
|
28
|
+
.onConflictDoNothing();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async fetchPending(sessionKey: string): Promise<DbPendingEvent[]> {
|
|
32
|
+
return this.db
|
|
33
|
+
.select()
|
|
34
|
+
.from(pendingEvents)
|
|
35
|
+
.where(and(eq(pendingEvents.sessionKey, sessionKey), isNull(pendingEvents.deliveredAt)))
|
|
36
|
+
.orderBy(desc(pendingEvents.priority));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async markDelivered(ids: string[]): Promise<void> {
|
|
40
|
+
if (ids.length === 0) return;
|
|
41
|
+
await this.db
|
|
42
|
+
.update(pendingEvents)
|
|
43
|
+
.set({ deliveredAt: new Date() })
|
|
44
|
+
.where(inArray(pendingEvents.id, ids));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async countPending(): Promise<number> {
|
|
48
|
+
const result = await this.db
|
|
49
|
+
.select({ count: sql<number>`count(*)` })
|
|
50
|
+
.from(pendingEvents)
|
|
51
|
+
.where(isNull(pendingEvents.deliveredAt));
|
|
52
|
+
return result[0]?.count ?? 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async cleanup(ttlSeconds: number): Promise<number> {
|
|
56
|
+
const cutoff = new Date(Date.now() - ttlSeconds * 1000);
|
|
57
|
+
const result = await this.db
|
|
58
|
+
.delete(pendingEvents)
|
|
59
|
+
.where(and(lt(pendingEvents.deliveredAt, cutoff))) as unknown as { changes: number };
|
|
60
|
+
return result.changes;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Service, BaseService } from '@onebun/core';
|
|
2
|
+
import { PendingRepository } from './pending.repository';
|
|
3
|
+
import type { NatsEventEnvelope } from '../publisher/envelope';
|
|
4
|
+
import type { DbPendingEvent } from '../db/schema';
|
|
5
|
+
|
|
6
|
+
@Service()
|
|
7
|
+
export class PendingService extends BaseService {
|
|
8
|
+
constructor(private repo: PendingRepository) {
|
|
9
|
+
super();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async addPending(envelope: NatsEventEnvelope): Promise<void> {
|
|
13
|
+
await this.repo.addPending({
|
|
14
|
+
id: envelope.id,
|
|
15
|
+
sessionKey: envelope.sessionKey ?? 'default',
|
|
16
|
+
subject: envelope.subject,
|
|
17
|
+
payload: envelope.payload,
|
|
18
|
+
priority: envelope.meta?.priority ?? 5,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async fetchPending(sessionKey: string): Promise<DbPendingEvent[]> {
|
|
23
|
+
return this.repo.fetchPending(sessionKey);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async markDelivered(ids: string[]): Promise<void> {
|
|
27
|
+
return this.repo.markDelivered(ids);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async countPending(): Promise<number> {
|
|
31
|
+
return this.repo.countPending();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async cleanup(): Promise<number> {
|
|
35
|
+
const ttl = this.config.get('dedup.ttlSeconds');
|
|
36
|
+
return this.repo.cleanup(ttl);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Service, BaseService } from '@onebun/core';
|
|
2
|
+
import { DedupService } from '../dedup/dedup.service';
|
|
3
|
+
import type { NatsEventEnvelope } from '../publisher/envelope';
|
|
4
|
+
import type { PreHandler, PipelineContext } from './pre-handler.interface';
|
|
5
|
+
|
|
6
|
+
@Service()
|
|
7
|
+
export class DedupHandler extends BaseService implements PreHandler {
|
|
8
|
+
name = 'dedup';
|
|
9
|
+
|
|
10
|
+
constructor(private dedupService: DedupService) {
|
|
11
|
+
super();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async handle(msg: NatsEventEnvelope, _ctx: PipelineContext): Promise<'pass' | 'drop'> {
|
|
15
|
+
const isDup = await this.dedupService.isDuplicate(msg.id, msg.subject);
|
|
16
|
+
if (isDup) {
|
|
17
|
+
this.logger.debug(`Dropping duplicate event ${msg.id}`);
|
|
18
|
+
return 'drop';
|
|
19
|
+
}
|
|
20
|
+
return 'pass';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Service, BaseService } from '@onebun/core';
|
|
2
|
+
import type { NatsEventEnvelope } from '../publisher/envelope';
|
|
3
|
+
import type { PreHandler, PipelineContext } from './pre-handler.interface';
|
|
4
|
+
|
|
5
|
+
@Service()
|
|
6
|
+
export class EnrichHandler extends BaseService implements PreHandler {
|
|
7
|
+
name = 'enrich';
|
|
8
|
+
|
|
9
|
+
async handle(msg: NatsEventEnvelope, ctx: PipelineContext): Promise<'pass' | 'drop'> {
|
|
10
|
+
ctx.enrichments['processedAt'] = new Date().toISOString();
|
|
11
|
+
ctx.enrichments['source'] = msg.source;
|
|
12
|
+
return 'pass';
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Service, BaseService } from '@onebun/core';
|
|
2
|
+
import type { NatsEventEnvelope } from '../publisher/envelope';
|
|
3
|
+
import type { PreHandler, PipelineContext } from './pre-handler.interface';
|
|
4
|
+
|
|
5
|
+
interface FilterRule {
|
|
6
|
+
subjectPattern: string;
|
|
7
|
+
action: 'pass' | 'drop';
|
|
8
|
+
priority: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
@Service()
|
|
12
|
+
export class FilterHandler extends BaseService implements PreHandler {
|
|
13
|
+
name = 'filter';
|
|
14
|
+
|
|
15
|
+
private rules: FilterRule[] = [];
|
|
16
|
+
|
|
17
|
+
async handle(msg: NatsEventEnvelope, _ctx: PipelineContext): Promise<'pass' | 'drop'> {
|
|
18
|
+
for (const rule of this.rules) {
|
|
19
|
+
if (this.matchSubject(msg.subject, rule.subjectPattern)) {
|
|
20
|
+
return rule.action;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return 'pass'; // default: pass everything
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
matchSubject(subject: string, pattern: string): boolean {
|
|
27
|
+
// NATS-style subject matching:
|
|
28
|
+
// '*' matches a single token, '>' matches one or more tokens at the end
|
|
29
|
+
const subjectParts = subject.split('.');
|
|
30
|
+
const patternParts = pattern.split('.');
|
|
31
|
+
for (let i = 0; i < patternParts.length; i++) {
|
|
32
|
+
if (patternParts[i] === '>') return true; // matches rest
|
|
33
|
+
if (i >= subjectParts.length) return false;
|
|
34
|
+
if (patternParts[i] === '*') continue; // matches single token
|
|
35
|
+
if (patternParts[i] !== subjectParts[i]) return false;
|
|
36
|
+
}
|
|
37
|
+
return subjectParts.length === patternParts.length;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Service, BaseService } from '@onebun/core';
|
|
2
|
+
import type { NatsEventEnvelope } from '../publisher/envelope';
|
|
3
|
+
import type { PreHandler, PipelineContext } from './pre-handler.interface';
|
|
4
|
+
import { DedupHandler } from './dedup.handler';
|
|
5
|
+
import { FilterHandler } from './filter.handler';
|
|
6
|
+
import { EnrichHandler } from './enrich.handler';
|
|
7
|
+
import { PriorityHandler } from './priority.handler';
|
|
8
|
+
|
|
9
|
+
@Service()
|
|
10
|
+
export class PipelineService extends BaseService {
|
|
11
|
+
constructor(
|
|
12
|
+
private dedupHandler: DedupHandler,
|
|
13
|
+
private filterHandler: FilterHandler,
|
|
14
|
+
private enrichHandler: EnrichHandler,
|
|
15
|
+
private priorityHandler: PriorityHandler,
|
|
16
|
+
) {
|
|
17
|
+
super();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async process(envelope: NatsEventEnvelope): Promise<{ result: 'pass' | 'drop'; ctx: PipelineContext }> {
|
|
21
|
+
const ctx: PipelineContext = { enrichments: {} };
|
|
22
|
+
const handlers: PreHandler[] = [
|
|
23
|
+
this.dedupHandler,
|
|
24
|
+
this.filterHandler,
|
|
25
|
+
this.enrichHandler,
|
|
26
|
+
this.priorityHandler,
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
for (const handler of handlers) {
|
|
30
|
+
const result = await handler.handle(envelope, ctx);
|
|
31
|
+
if (result === 'drop') {
|
|
32
|
+
this.logger.debug(`Pipeline dropped by ${handler.name}`, { id: envelope.id });
|
|
33
|
+
return { result: 'drop', ctx };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return { result: 'pass', ctx };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { NatsEventEnvelope } from '../publisher/envelope';
|
|
2
|
+
|
|
3
|
+
export interface PipelineContext {
|
|
4
|
+
enrichments: Record<string, unknown>;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface PreHandler {
|
|
8
|
+
name: string;
|
|
9
|
+
handle(msg: NatsEventEnvelope, ctx: PipelineContext): Promise<'pass' | 'drop'>;
|
|
10
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Module } from '@onebun/core';
|
|
2
|
+
import { DedupModule } from '../dedup/dedup.module';
|
|
3
|
+
import { PipelineService } from './pipeline.service';
|
|
4
|
+
import { DedupHandler } from './dedup.handler';
|
|
5
|
+
import { FilterHandler } from './filter.handler';
|
|
6
|
+
import { EnrichHandler } from './enrich.handler';
|
|
7
|
+
import { PriorityHandler } from './priority.handler';
|
|
8
|
+
|
|
9
|
+
@Module({
|
|
10
|
+
imports: [DedupModule],
|
|
11
|
+
providers: [PipelineService, DedupHandler, FilterHandler, EnrichHandler, PriorityHandler],
|
|
12
|
+
exports: [PipelineService],
|
|
13
|
+
})
|
|
14
|
+
export class PreHandlersModule {}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Service, BaseService } from '@onebun/core';
|
|
2
|
+
import type { NatsEventEnvelope } from '../publisher/envelope';
|
|
3
|
+
import type { PreHandler, PipelineContext } from './pre-handler.interface';
|
|
4
|
+
|
|
5
|
+
@Service()
|
|
6
|
+
export class PriorityHandler extends BaseService implements PreHandler {
|
|
7
|
+
name = 'priority';
|
|
8
|
+
|
|
9
|
+
async handle(msg: NatsEventEnvelope, ctx: PipelineContext): Promise<'pass' | 'drop'> {
|
|
10
|
+
const raw = msg.meta?.priority ?? 5;
|
|
11
|
+
ctx.enrichments['priority'] = Math.max(1, Math.min(10, raw));
|
|
12
|
+
return 'pass';
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { ulid } from 'ulid';
|
|
2
|
+
|
|
3
|
+
export interface EnvelopeMeta {
|
|
4
|
+
priority?: number;
|
|
5
|
+
traceId?: string;
|
|
6
|
+
correlationId?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface NatsEventEnvelope {
|
|
10
|
+
id: string;
|
|
11
|
+
subject: string;
|
|
12
|
+
timestamp: string;
|
|
13
|
+
source: string;
|
|
14
|
+
sessionKey?: string;
|
|
15
|
+
agentTarget?: string;
|
|
16
|
+
payload: unknown;
|
|
17
|
+
meta?: EnvelopeMeta;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function createEnvelope(
|
|
21
|
+
subject: string,
|
|
22
|
+
payload: unknown,
|
|
23
|
+
meta?: EnvelopeMeta,
|
|
24
|
+
options?: { sessionKey?: string; agentTarget?: string; source?: string },
|
|
25
|
+
): NatsEventEnvelope {
|
|
26
|
+
return {
|
|
27
|
+
id: ulid(),
|
|
28
|
+
subject,
|
|
29
|
+
timestamp: new Date().toISOString(),
|
|
30
|
+
source: options?.source ?? 'openclaw-plugin',
|
|
31
|
+
sessionKey: options?.sessionKey,
|
|
32
|
+
agentTarget: options?.agentTarget,
|
|
33
|
+
payload,
|
|
34
|
+
meta: meta ? { priority: meta.priority ?? 5, ...meta } : { priority: 5 },
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Controller, Post, Body, BaseController, UseMiddleware, type OneBunResponse } from '@onebun/core';
|
|
2
|
+
import { PublisherService } from './publisher.service';
|
|
3
|
+
import { ApiKeyMiddleware } from '../auth/api-key.middleware';
|
|
4
|
+
import { publishBodySchema, type PublishBody } from '../validation/schemas';
|
|
5
|
+
|
|
6
|
+
@Controller('/api/publish')
|
|
7
|
+
@UseMiddleware(ApiKeyMiddleware)
|
|
8
|
+
export class PublisherController extends BaseController {
|
|
9
|
+
constructor(private publisherService: PublisherService) {
|
|
10
|
+
super();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
@Post()
|
|
14
|
+
async publish(@Body(publishBodySchema) body: PublishBody): Promise<OneBunResponse> {
|
|
15
|
+
if (!body.subject.startsWith('agent.events.')) {
|
|
16
|
+
return this.error('subject must start with agent.events.', 400, 400);
|
|
17
|
+
}
|
|
18
|
+
await this.publisherService.publish(body.subject, body.payload, body.meta);
|
|
19
|
+
return this.success({ published: true });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Module } from '@onebun/core';
|
|
2
|
+
import { PublisherService } from './publisher.service';
|
|
3
|
+
import { PublisherController } from './publisher.controller';
|
|
4
|
+
import { NatsStreamsModule } from '../nats-streams/nats-streams.module';
|
|
5
|
+
|
|
6
|
+
@Module({
|
|
7
|
+
imports: [NatsStreamsModule],
|
|
8
|
+
controllers: [PublisherController],
|
|
9
|
+
providers: [PublisherService],
|
|
10
|
+
exports: [PublisherService],
|
|
11
|
+
})
|
|
12
|
+
export class PublisherModule {}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Service, BaseService } from '@onebun/core';
|
|
2
|
+
import { NatsAdapterService } from '../nats-streams/nats-adapter.service';
|
|
3
|
+
import { createEnvelope, type EnvelopeMeta } from './envelope';
|
|
4
|
+
|
|
5
|
+
@Service()
|
|
6
|
+
export class PublisherService extends BaseService {
|
|
7
|
+
constructor(private natsAdapter: NatsAdapterService) {
|
|
8
|
+
super();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async publish(subject: string, payload: unknown, meta?: EnvelopeMeta): Promise<void> {
|
|
12
|
+
if (!this.natsAdapter.isConnected()) {
|
|
13
|
+
this.logger.warn(`NATS not connected, dropping publish to ${subject}`);
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
const envelope = createEnvelope(subject, payload, meta);
|
|
17
|
+
await this.natsAdapter.publish(subject, envelope);
|
|
18
|
+
this.logger.debug(`Published to ${subject}`, { id: envelope.id });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type } from 'arktype';
|
|
2
|
+
|
|
3
|
+
export const publishBodySchema = type({
|
|
4
|
+
subject: 'string',
|
|
5
|
+
payload: 'unknown',
|
|
6
|
+
'meta?': {
|
|
7
|
+
'priority?': 'number',
|
|
8
|
+
'traceId?': 'string',
|
|
9
|
+
'correlationId?': 'string',
|
|
10
|
+
},
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export type PublishBody = typeof publishBodySchema.infer;
|
|
14
|
+
|
|
15
|
+
export const markDeliveredBodySchema = type({
|
|
16
|
+
ids: 'string[]',
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export type MarkDeliveredBody = typeof markDeliveredBodySchema.infer;
|