@omnixal/openclaw-nats-plugin 0.2.15 → 0.2.16
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/package.json +1 -1
- package/plugins/nats-context-engine/http-handler.ts +12 -67
- package/sidecar/src/config.ts +4 -0
- package/sidecar/src/pending/pending-flush.service.ts +70 -0
- package/sidecar/src/pending/pending.module.ts +5 -1
- package/sidecar/src/pending/pending.repository.ts +6 -2
- package/sidecar/src/pending/pending.service.ts +2 -2
- package/sidecar/src/scheduler/scheduler.controller.ts +2 -2
package/package.json
CHANGED
|
@@ -2,21 +2,12 @@ import fs from 'node:fs/promises';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { existsSync } from 'node:fs';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
|
-
import http from 'node:http';
|
|
6
5
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
7
6
|
|
|
8
7
|
const ROUTE_PREFIX = '/nats-dashboard';
|
|
9
8
|
const SIDECAR_URL = process.env.NATS_SIDECAR_URL || 'http://127.0.0.1:3104';
|
|
10
9
|
const API_KEY = process.env.NATS_PLUGIN_API_KEY || 'dev-nats-plugin-key';
|
|
11
10
|
|
|
12
|
-
let sidecarParsed: URL;
|
|
13
|
-
try {
|
|
14
|
-
sidecarParsed = new URL(SIDECAR_URL);
|
|
15
|
-
} catch (e) {
|
|
16
|
-
console.error(`[nats-dashboard] Invalid NATS_SIDECAR_URL: ${SIDECAR_URL}`, e);
|
|
17
|
-
sidecarParsed = new URL('http://127.0.0.1:3104');
|
|
18
|
-
}
|
|
19
|
-
|
|
20
11
|
// Stable location (copied during setup) takes priority over in-package dist
|
|
21
12
|
const STABLE_DIST = path.join(homedir(), '.openclaw', 'nats-plugin', 'dashboard');
|
|
22
13
|
const PACKAGE_DIST = path.resolve(__dirname, '../../dashboard/dist');
|
|
@@ -46,7 +37,7 @@ export function createDashboardHandler() {
|
|
|
46
37
|
subPath = url.pathname;
|
|
47
38
|
}
|
|
48
39
|
|
|
49
|
-
// Debug endpoint
|
|
40
|
+
// Debug endpoint
|
|
50
41
|
if (subPath === '/api/_debug') {
|
|
51
42
|
res.statusCode = 200;
|
|
52
43
|
res.setHeader('content-type', 'application/json');
|
|
@@ -55,15 +46,8 @@ export function createDashboardHandler() {
|
|
|
55
46
|
pathname: url.pathname,
|
|
56
47
|
subPath,
|
|
57
48
|
sidecarUrl: SIDECAR_URL,
|
|
58
|
-
sidecarHost: sidecarParsed.hostname,
|
|
59
|
-
sidecarPort: sidecarParsed.port,
|
|
60
|
-
apiKey: API_KEY ? `${API_KEY.slice(0, 4)}...` : '(not set)',
|
|
61
49
|
distDir: DIST_DIR,
|
|
62
50
|
distExists: existsSync(path.join(DIST_DIR, 'index.html')),
|
|
63
|
-
env: {
|
|
64
|
-
NATS_SIDECAR_URL: process.env.NATS_SIDECAR_URL || '(default)',
|
|
65
|
-
NATS_PLUGIN_API_KEY: process.env.NATS_PLUGIN_API_KEY ? 'set' : '(default)',
|
|
66
|
-
},
|
|
67
51
|
}, null, 2));
|
|
68
52
|
return true;
|
|
69
53
|
}
|
|
@@ -91,6 +75,7 @@ async function proxyToSidecar(
|
|
|
91
75
|
res: ServerResponse,
|
|
92
76
|
): Promise<boolean> {
|
|
93
77
|
try {
|
|
78
|
+
const targetUrl = `${SIDECAR_URL}${subPath}${search}`;
|
|
94
79
|
const headers: Record<string, string> = {
|
|
95
80
|
'Authorization': `Bearer ${API_KEY}`,
|
|
96
81
|
};
|
|
@@ -103,69 +88,29 @@ async function proxyToSidecar(
|
|
|
103
88
|
body = await readBody(req);
|
|
104
89
|
}
|
|
105
90
|
|
|
106
|
-
|
|
107
|
-
const upstream = await httpRequest({
|
|
108
|
-
hostname: sidecarParsed.hostname,
|
|
109
|
-
port: Number(sidecarParsed.port),
|
|
110
|
-
path: `${subPath}${search}`,
|
|
91
|
+
const upstream = await fetch(targetUrl, {
|
|
111
92
|
method: req.method || 'GET',
|
|
112
93
|
headers,
|
|
113
|
-
|
|
114
|
-
|
|
94
|
+
body,
|
|
95
|
+
signal: AbortSignal.timeout(10_000),
|
|
96
|
+
});
|
|
115
97
|
|
|
116
|
-
res.statusCode = upstream.
|
|
117
|
-
res.setHeader('content-type', upstream.headers
|
|
118
|
-
|
|
98
|
+
res.statusCode = upstream.status;
|
|
99
|
+
res.setHeader('content-type', upstream.headers.get('content-type') || 'application/json');
|
|
100
|
+
const responseBody = await upstream.text();
|
|
101
|
+
res.end(responseBody);
|
|
119
102
|
} catch (err) {
|
|
120
103
|
const message = err instanceof Error ? err.message : String(err);
|
|
121
|
-
|
|
122
|
-
console.error(`[nats-dashboard] Sidecar proxy error: ${message} (target=${sidecarParsed.hostname}:${sidecarParsed.port}${subPath})`);
|
|
123
|
-
if (stack) console.error(stack);
|
|
104
|
+
console.error(`[nats-dashboard] Sidecar proxy error: ${message} (url=${SIDECAR_URL}${subPath})`);
|
|
124
105
|
res.statusCode = 502;
|
|
125
106
|
res.setHeader('content-type', 'application/json');
|
|
126
|
-
res.end(JSON.stringify({
|
|
127
|
-
error: 'Sidecar unreachable',
|
|
128
|
-
detail: message,
|
|
129
|
-
target: `${sidecarParsed.hostname}:${sidecarParsed.port}${subPath}`,
|
|
130
|
-
sidecarUrl: SIDECAR_URL,
|
|
131
|
-
hint: 'Open /nats-dashboard/api/_debug for full diagnostics',
|
|
132
|
-
}));
|
|
107
|
+
res.end(JSON.stringify({ error: 'Sidecar unreachable', detail: message }));
|
|
133
108
|
}
|
|
134
109
|
return true;
|
|
135
110
|
}
|
|
136
111
|
|
|
137
112
|
const MAX_BODY_BYTES = 1_048_576; // 1MB
|
|
138
113
|
|
|
139
|
-
interface HttpResponse {
|
|
140
|
-
statusCode: number;
|
|
141
|
-
headers: Record<string, string | string[] | undefined>;
|
|
142
|
-
body: string;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
function httpRequest(
|
|
146
|
-
opts: http.RequestOptions,
|
|
147
|
-
body?: string,
|
|
148
|
-
): Promise<HttpResponse> {
|
|
149
|
-
return new Promise((resolve, reject) => {
|
|
150
|
-
const req = http.request(opts, (res) => {
|
|
151
|
-
const chunks: Buffer[] = [];
|
|
152
|
-
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
|
153
|
-
res.on('end', () => {
|
|
154
|
-
resolve({
|
|
155
|
-
statusCode: res.statusCode || 500,
|
|
156
|
-
headers: res.headers,
|
|
157
|
-
body: Buffer.concat(chunks).toString(),
|
|
158
|
-
});
|
|
159
|
-
});
|
|
160
|
-
res.on('error', reject);
|
|
161
|
-
});
|
|
162
|
-
req.on('error', reject);
|
|
163
|
-
req.on('timeout', () => { req.destroy(); reject(new Error('Request timeout')); });
|
|
164
|
-
if (body) req.write(body);
|
|
165
|
-
req.end();
|
|
166
|
-
});
|
|
167
|
-
}
|
|
168
|
-
|
|
169
114
|
function readBody(req: IncomingMessage): Promise<string> {
|
|
170
115
|
return new Promise((resolve, reject) => {
|
|
171
116
|
const chunks: Buffer[] = [];
|
package/sidecar/src/config.ts
CHANGED
|
@@ -28,6 +28,10 @@ export const envSchema = {
|
|
|
28
28
|
ttlSeconds: Env.number({ default: 60, env: 'NATS_DEDUP_TTL_SECONDS' }),
|
|
29
29
|
cleanupIntervalMs: Env.number({ default: 300000, env: 'NATS_DEDUP_CLEANUP_INTERVAL_MS' }),
|
|
30
30
|
},
|
|
31
|
+
pending: {
|
|
32
|
+
flushIntervalMs: Env.number({ default: 30000, env: 'NATS_PENDING_FLUSH_INTERVAL_MS' }),
|
|
33
|
+
flushBatchSize: Env.number({ default: 10, env: 'NATS_PENDING_FLUSH_BATCH_SIZE' }),
|
|
34
|
+
},
|
|
31
35
|
auth: {
|
|
32
36
|
pluginApiKey: Env.string({ default: 'dev-nats-plugin-key', env: 'NATS_PLUGIN_API_KEY' }),
|
|
33
37
|
},
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Service, BaseService, type OnModuleInit, type OnModuleDestroy } from '@onebun/core';
|
|
2
|
+
import { PendingService } from './pending.service';
|
|
3
|
+
import { GatewayClientService, GatewayRpcError } from '../gateway/gateway-client.service';
|
|
4
|
+
import { MetricsService } from '../metrics/metrics.service';
|
|
5
|
+
import { LogService } from '../logs/log.service';
|
|
6
|
+
|
|
7
|
+
@Service()
|
|
8
|
+
export class PendingFlushService extends BaseService implements OnModuleInit, OnModuleDestroy {
|
|
9
|
+
private flushTimer?: ReturnType<typeof setInterval>;
|
|
10
|
+
|
|
11
|
+
constructor(
|
|
12
|
+
private pendingService: PendingService,
|
|
13
|
+
private gatewayClient: GatewayClientService,
|
|
14
|
+
private metrics: MetricsService,
|
|
15
|
+
private logService: LogService,
|
|
16
|
+
) {
|
|
17
|
+
super();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async onModuleInit(): Promise<void> {
|
|
21
|
+
const intervalMs = this.config.get('pending.flushIntervalMs');
|
|
22
|
+
this.flush().catch((e) => {
|
|
23
|
+
this.logger.warn('Initial pending flush failed', { error: String(e) });
|
|
24
|
+
});
|
|
25
|
+
this.flushTimer = setInterval(() => {
|
|
26
|
+
this.flush().catch((e) => {
|
|
27
|
+
this.logger.warn('Pending flush failed', { error: String(e) });
|
|
28
|
+
});
|
|
29
|
+
}, intervalMs);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async onModuleDestroy(): Promise<void> {
|
|
33
|
+
if (this.flushTimer) {
|
|
34
|
+
clearInterval(this.flushTimer);
|
|
35
|
+
this.flushTimer = undefined;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async flush(): Promise<void> {
|
|
40
|
+
if (!this.gatewayClient.isAlive()) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const batchSize = this.config.get('pending.flushBatchSize');
|
|
45
|
+
const pending = await this.pendingService.fetchPending('default', batchSize);
|
|
46
|
+
if (pending.length === 0) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
this.logger.info(`Flushing ${pending.length} pending event(s)`);
|
|
51
|
+
|
|
52
|
+
for (const event of pending) {
|
|
53
|
+
try {
|
|
54
|
+
const message = `[NATS:${event.subject}] ${JSON.stringify(event.payload)}`;
|
|
55
|
+
await this.gatewayClient.inject({ message, eventId: event.id });
|
|
56
|
+
await this.pendingService.markDelivered([event.id]);
|
|
57
|
+
this.metrics.recordConsume(event.subject);
|
|
58
|
+
await this.logService.logDelivery('pending-flush', event.subject, JSON.stringify({ eventId: event.id }));
|
|
59
|
+
} catch (err) {
|
|
60
|
+
await this.logService.logError('route', 'pending-flush', event.subject, err);
|
|
61
|
+
if (err instanceof GatewayRpcError) {
|
|
62
|
+
this.logger.error(`Pending flush: gateway rejected event ${event.id}: ${err.errorCode}`);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
this.logger.error(`Pending flush: network error on event ${event.id}, stopping batch`, err);
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -2,10 +2,14 @@ import { Module } from '@onebun/core';
|
|
|
2
2
|
import { PendingController } from './pending.controller';
|
|
3
3
|
import { PendingService } from './pending.service';
|
|
4
4
|
import { PendingRepository } from './pending.repository';
|
|
5
|
+
import { PendingFlushService } from './pending-flush.service';
|
|
6
|
+
import { MetricsModule } from '../metrics/metrics.module';
|
|
7
|
+
import { LogModule } from '../logs/log.module';
|
|
5
8
|
|
|
6
9
|
@Module({
|
|
10
|
+
imports: [MetricsModule, LogModule],
|
|
7
11
|
controllers: [PendingController],
|
|
8
|
-
providers: [PendingService, PendingRepository],
|
|
12
|
+
providers: [PendingService, PendingRepository, PendingFlushService],
|
|
9
13
|
exports: [PendingService],
|
|
10
14
|
})
|
|
11
15
|
export class PendingModule {}
|
|
@@ -28,12 +28,16 @@ export class PendingRepository extends BaseService {
|
|
|
28
28
|
.onConflictDoNothing();
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
async fetchPending(sessionKey: string): Promise<DbPendingEvent[]> {
|
|
32
|
-
|
|
31
|
+
async fetchPending(sessionKey: string, limit?: number): Promise<DbPendingEvent[]> {
|
|
32
|
+
const query = this.db
|
|
33
33
|
.select()
|
|
34
34
|
.from(pendingEvents)
|
|
35
35
|
.where(and(eq(pendingEvents.sessionKey, sessionKey), isNull(pendingEvents.deliveredAt)))
|
|
36
36
|
.orderBy(desc(pendingEvents.priority));
|
|
37
|
+
if (limit !== undefined) {
|
|
38
|
+
return query.limit(limit);
|
|
39
|
+
}
|
|
40
|
+
return query;
|
|
37
41
|
}
|
|
38
42
|
|
|
39
43
|
async markDelivered(ids: string[]): Promise<void> {
|
|
@@ -19,8 +19,8 @@ export class PendingService extends BaseService {
|
|
|
19
19
|
});
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
async fetchPending(sessionKey: string): Promise<DbPendingEvent[]> {
|
|
23
|
-
return this.repo.fetchPending(sessionKey);
|
|
22
|
+
async fetchPending(sessionKey: string, limit?: number): Promise<DbPendingEvent[]> {
|
|
23
|
+
return this.repo.fetchPending(sessionKey, limit);
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
async markDelivered(ids: string[]): Promise<void> {
|
|
@@ -124,7 +124,7 @@ export class SchedulerController extends BaseController {
|
|
|
124
124
|
|
|
125
125
|
// ── Queue Handlers ────────────────────────────────────────────────
|
|
126
126
|
|
|
127
|
-
@Subscribe('scheduler.fire.*', { group: 'scheduler-handler' })
|
|
127
|
+
@Subscribe('scheduler.fire.*', { group: 'scheduler-cron-handler' })
|
|
128
128
|
async handleFire(message: Message<unknown>): Promise<void> {
|
|
129
129
|
const jobName = message.pattern?.split('.').pop();
|
|
130
130
|
if (jobName) {
|
|
@@ -132,7 +132,7 @@ export class SchedulerController extends BaseController {
|
|
|
132
132
|
}
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
-
@Subscribe('scheduler.timer.*', { group: 'scheduler-handler' })
|
|
135
|
+
@Subscribe('scheduler.timer.*', { group: 'scheduler-timer-handler' })
|
|
136
136
|
async handleTimerFire(message: Message<unknown>): Promise<void> {
|
|
137
137
|
const timerName = message.pattern?.split('.').pop();
|
|
138
138
|
if (timerName) {
|