@omnixal/openclaw-nats-plugin 0.2.9 → 0.2.11

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 CHANGED
@@ -53,6 +53,7 @@ Plugin hooks make lightweight HTTP calls to a NATS sidecar service for event pub
53
53
  | Subject | Trigger |
54
54
  |---|---|
55
55
  | `agent.events.gateway.startup` | Gateway starts |
56
+ | `agent.events.gateway.stopped` | Gateway stops |
56
57
  | `agent.events.session.new` | `/new` command |
57
58
  | `agent.events.session.reset` | `/reset` command |
58
59
  | `agent.events.session.stop` | `/stop` command |
@@ -60,10 +61,13 @@ Plugin hooks make lightweight HTTP calls to a NATS sidecar service for event pub
60
61
  | `agent.events.session.ended` | Session ends |
61
62
  | `agent.events.tool.{name}.completed` | Tool succeeds |
62
63
  | `agent.events.tool.{name}.failed` | Tool fails |
64
+ | `agent.events.message.received` | Inbound message received |
65
+ | `agent.events.message.sent` | Message delivered |
66
+ | `agent.events.llm.output` | LLM response received |
67
+ | `agent.events.subagent.spawning` | Subagent about to spawn |
63
68
  | `agent.events.subagent.spawned` | Subagent created |
64
69
  | `agent.events.subagent.ended` | Subagent finished |
65
70
  | `agent.events.agent.run_ended` | Agent run completes |
66
- | `agent.events.message.sent` | Message delivered |
67
71
  | `agent.events.context.compacted` | Context history compressed |
68
72
 
69
73
  ## Dashboard
@@ -0,0 +1,59 @@
1
+ <script lang="ts">
2
+ import { Modal } from '$lib/components/ui/modal';
3
+ import * as Table from '$lib/components/ui/table';
4
+
5
+ interface Props {
6
+ open: boolean;
7
+ onClose: () => void;
8
+ }
9
+
10
+ let { open, onClose }: Props = $props();
11
+
12
+ const events = [
13
+ { subject: 'agent.events.gateway.startup', trigger: 'Gateway started', payload: '{}' },
14
+ { subject: 'agent.events.gateway.stopped', trigger: 'Gateway stopped', payload: '{ reason }' },
15
+ { subject: 'agent.events.session.new', trigger: '/new command', payload: '{ sessionKey, command }' },
16
+ { subject: 'agent.events.session.reset', trigger: '/reset command', payload: '{ sessionKey, command }' },
17
+ { subject: 'agent.events.session.stop', trigger: '/stop command', payload: '{ sessionKey, command }' },
18
+ { subject: 'agent.events.session.started', trigger: 'Session began', payload: '{ sessionKey, sessionId, channel }' },
19
+ { subject: 'agent.events.session.ended', trigger: 'Session ended', payload: '{ sessionKey, sessionId, channel }' },
20
+ { subject: 'agent.events.agent.run_ended', trigger: 'Agent run completed', payload: '{ sessionKey, runId, messageCount }' },
21
+ { subject: 'agent.events.tool.{name}.completed', trigger: 'Tool succeeded', payload: '{ sessionKey, toolName, durationMs }' },
22
+ { subject: 'agent.events.tool.{name}.failed', trigger: 'Tool failed', payload: '{ sessionKey, toolName, durationMs }' },
23
+ { subject: 'agent.events.message.received', trigger: 'Inbound message', payload: '{ from, content, metadata }' },
24
+ { subject: 'agent.events.message.sent', trigger: 'Message delivered', payload: '{ sessionKey, to, success, error }' },
25
+ { subject: 'agent.events.llm.output', trigger: 'LLM response', payload: '{ provider, model, usage }' },
26
+ { subject: 'agent.events.subagent.spawning', trigger: 'Subagent creating', payload: '{ childSessionKey, agentId, label, mode }' },
27
+ { subject: 'agent.events.subagent.spawned', trigger: 'Subagent created', payload: '{ sessionKey, subagentId, task }' },
28
+ { subject: 'agent.events.subagent.ended', trigger: 'Subagent finished', payload: '{ sessionKey, subagentId, result, durationMs }' },
29
+ { subject: 'agent.events.context.compacted', trigger: 'Context compressed', payload: '{ sessionKey }' },
30
+ { subject: 'agent.events.cron.*', trigger: 'Cron trigger', payload: '(user-defined)' },
31
+ { subject: 'agent.events.custom.*', trigger: 'Custom event', payload: '(user-defined)' },
32
+ ];
33
+ </script>
34
+
35
+ <Modal {open} title="Events Reference" {onClose} class="max-w-3xl">
36
+ <div class="max-h-[60vh] overflow-y-auto -mx-6 px-6">
37
+ <p class="text-xs text-muted-foreground mb-3">
38
+ All events published by the NATS plugin. Each event also includes a <code class="text-xs">timestamp</code> field.
39
+ </p>
40
+ <Table.Root>
41
+ <Table.Header>
42
+ <Table.Row>
43
+ <Table.Head>Subject</Table.Head>
44
+ <Table.Head>Trigger</Table.Head>
45
+ <Table.Head>Payload</Table.Head>
46
+ </Table.Row>
47
+ </Table.Header>
48
+ <Table.Body>
49
+ {#each events as ev}
50
+ <Table.Row>
51
+ <Table.Cell class="font-mono text-xs whitespace-nowrap">{ev.subject}</Table.Cell>
52
+ <Table.Cell class="text-xs">{ev.trigger}</Table.Cell>
53
+ <Table.Cell class="font-mono text-xs text-muted-foreground">{ev.payload}</Table.Cell>
54
+ </Table.Row>
55
+ {/each}
56
+ </Table.Body>
57
+ </Table.Root>
58
+ </div>
59
+ </Modal>
@@ -1,15 +1,19 @@
1
1
  <script lang="ts">
2
2
  import * as Table from '$lib/components/ui/table';
3
3
  import { Badge } from '$lib/components/ui/badge';
4
+ import { Button } from '$lib/components/ui/button';
4
5
  import * as Card from '$lib/components/ui/card';
5
6
  import type { SubjectMetric } from '$lib/api';
6
7
  import { relativeAge } from '$lib/utils';
8
+ import CircleHelp from '@lucide/svelte/icons/circle-help';
9
+ import EventsReferenceModal from './EventsReferenceModal.svelte';
7
10
 
8
11
  interface Props {
9
12
  metrics: SubjectMetric[];
10
13
  }
11
14
 
12
15
  let { metrics }: Props = $props();
16
+ let showEventsRef = $state(false);
13
17
 
14
18
  let totalPublished = $derived(metrics.reduce((acc, m) => acc + m.published, 0));
15
19
  let totalConsumed = $derived(metrics.reduce((acc, m) => acc + m.consumed, 0));
@@ -17,7 +21,12 @@
17
21
 
18
22
  <Card.Root>
19
23
  <Card.Header class="pb-2">
20
- <Card.Title class="text-sm font-medium">Queue Metrics</Card.Title>
24
+ <div class="flex items-center justify-between">
25
+ <Card.Title class="text-sm font-medium">Queue Metrics</Card.Title>
26
+ <Button variant="ghost" size="icon-sm" onclick={() => showEventsRef = true} title="Events reference">
27
+ <CircleHelp size={14} />
28
+ </Button>
29
+ </div>
21
30
  </Card.Header>
22
31
  <Card.Content>
23
32
  <div class="flex items-center gap-2 mb-3">
@@ -58,3 +67,5 @@
58
67
  {/if}
59
68
  </Card.Content>
60
69
  </Card.Root>
70
+
71
+ <EventsReferenceModal open={showEventsRef} onClose={() => showEventsRef = false} />
@@ -8,6 +8,7 @@
8
8
  onClose: () => void;
9
9
  children: Snippet;
10
10
  actions?: Snippet;
11
+ class?: string;
11
12
  }
12
13
 
13
14
  let {
@@ -16,6 +17,7 @@
16
17
  onClose,
17
18
  children,
18
19
  actions,
20
+ class: className,
19
21
  }: Props = $props();
20
22
 
21
23
  function handleKeydown(e: KeyboardEvent) {
@@ -31,7 +33,7 @@
31
33
  >
32
34
  <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
33
35
  <div class="fixed inset-0" onclick={onClose}></div>
34
- <div class="relative z-10 w-full max-w-lg rounded-lg border bg-background p-6 shadow-lg">
36
+ <div class="relative z-10 w-full {className ?? 'max-w-lg'} rounded-lg border bg-background p-6 shadow-lg">
35
37
  <div class="flex items-center justify-between mb-4">
36
38
  <h3 class="text-lg font-semibold">{title}</h3>
37
39
  <Button variant="ghost" size="icon-sm" onclick={onClose}>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omnixal/openclaw-nats-plugin",
3
- "version": "0.2.9",
3
+ "version": "0.2.11",
4
4
  "description": "NATS JetStream event-driven plugin for OpenClaw",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -78,6 +78,52 @@ export default function (api: any) {
78
78
  }, { priority: 8 });
79
79
  }, { priority: 99 });
80
80
 
81
+ // ── Gateway stop ───────────────────────────────────────────────
82
+
83
+ api.on('gateway_stop', (event: any) => {
84
+ void publishToSidecar('agent.events.gateway.stopped', {
85
+ reason: event.reason,
86
+ timestamp: new Date().toISOString(),
87
+ });
88
+ }, { priority: 99 });
89
+
90
+ // ── Message received ─────────────────────────────────────────────
91
+
92
+ api.on('message_received', (event: any) => {
93
+ void publishToSidecar('agent.events.message.received', {
94
+ from: event.from,
95
+ content: event.content,
96
+ metadata: event.metadata,
97
+ timestamp: new Date().toISOString(),
98
+ });
99
+ }, { priority: 99 });
100
+
101
+ // ── LLM output ───────────────────────────────────────────────────
102
+
103
+ api.on('llm_output', (event: any) => {
104
+ void publishToSidecar('agent.events.llm.output', {
105
+ sessionKey: event.sessionId,
106
+ runId: event.runId,
107
+ provider: event.provider,
108
+ model: event.model,
109
+ usage: event.usage,
110
+ timestamp: new Date().toISOString(),
111
+ });
112
+ }, { priority: 99 });
113
+
114
+ // ── Subagent spawning ────────────────────────────────────────────
115
+
116
+ api.on('subagent_spawning', (event: any) => {
117
+ void publishToSidecar('agent.events.subagent.spawning', {
118
+ childSessionKey: event.childSessionKey,
119
+ agentId: event.agentId,
120
+ label: event.label,
121
+ mode: event.mode,
122
+ timestamp: new Date().toISOString(),
123
+ });
124
+ return { status: 'ok' };
125
+ }, { priority: 99 });
126
+
81
127
  // ── Agent Tools ─────────────────────────────────────────────────
82
128
 
83
129
  const SIDECAR_URL = process.env.NATS_SIDECAR_URL || 'http://127.0.0.1:3104';
@@ -17,7 +17,7 @@ export const envSchema = {
17
17
  },
18
18
  gateway: {
19
19
  wsUrl: Env.string({ default: 'ws://localhost:18789', env: 'OPENCLAW_WS_URL' }),
20
- token: Env.string({ default: '', env: 'OPENCLAW_SIDECAR_DEVICE_TOKEN' }),
20
+ token: Env.string({ default: '', env: 'OPENCLAW_DEVICE_TOKEN' }),
21
21
  },
22
22
  consumer: {
23
23
  name: Env.string({ default: 'openclaw-main', env: 'NATS_CONSUMER_NAME' }),
@@ -58,14 +58,9 @@ export class ConsumerController extends BaseController {
58
58
  try {
59
59
  const injectStart = performance.now();
60
60
  await this.gatewayClient.inject({
61
- target: route.target,
61
+ to: route.target,
62
62
  message: this.formatMessage(envelope),
63
- metadata: {
64
- source: 'nats',
65
- eventId: envelope.id,
66
- subject: envelope.subject,
67
- priority: (ctx.enrichments['priority'] as number) ?? envelope.meta?.priority ?? 5,
68
- },
63
+ eventId: envelope.id,
69
64
  });
70
65
  const lagMs = Math.round(performance.now() - injectStart);
71
66
  await this.routerService.recordDelivery(route.id, envelope.subject, lagMs);
@@ -0,0 +1,146 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ export interface DeviceIdentity {
6
+ deviceId: string;
7
+ publicKeyPem: string;
8
+ privateKeyPem: string;
9
+ }
10
+
11
+ interface StoredIdentity {
12
+ version: 1;
13
+ deviceId: string;
14
+ publicKeyPem: string;
15
+ privateKeyPem: string;
16
+ createdAtMs: number;
17
+ }
18
+
19
+ export interface SignChallengeParams {
20
+ deviceId: string;
21
+ clientId: string;
22
+ clientMode: string;
23
+ role: string;
24
+ scopes: string[];
25
+ signedAtMs: number;
26
+ token: string;
27
+ nonce: string;
28
+ platform?: string;
29
+ deviceFamily?: string;
30
+ }
31
+
32
+ /** Ed25519 SPKI DER prefix (12 bytes) before the 32-byte raw public key */
33
+ const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
34
+
35
+ function base64UrlEncode(buf: Buffer): string {
36
+ return buf.toString('base64').replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/g, '');
37
+ }
38
+
39
+ /** Extract raw 32-byte Ed25519 public key from PEM-encoded SPKI */
40
+ function derivePublicKeyRaw(publicKeyPem: string): Buffer {
41
+ const key = crypto.createPublicKey(publicKeyPem);
42
+ const spki = key.export({ type: 'spki', format: 'der' }) as Buffer;
43
+ if (
44
+ spki.length === ED25519_SPKI_PREFIX.length + 32 &&
45
+ spki.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX)
46
+ ) {
47
+ return spki.subarray(ED25519_SPKI_PREFIX.length);
48
+ }
49
+ return spki;
50
+ }
51
+
52
+ /** Derive deviceId as SHA-256(rawPublicKey).hex — matches OpenClaw's deriveDeviceIdFromPublicKey */
53
+ function fingerprintPublicKey(publicKeyPem: string): string {
54
+ const raw = derivePublicKeyRaw(publicKeyPem);
55
+ return crypto.createHash('sha256').update(raw).digest('hex');
56
+ }
57
+
58
+ /** Normalize metadata for auth payload — ASCII lowercase + trim (matches OpenClaw's normalizeDeviceMetadataForAuth) */
59
+ function normalizeMetadata(value?: string | null): string {
60
+ if (typeof value !== 'string') return '';
61
+ const trimmed = value.trim();
62
+ if (!trimmed) return '';
63
+ return trimmed.replace(/[A-Z]/g, (c) => String.fromCharCode(c.charCodeAt(0) + 32));
64
+ }
65
+
66
+ /** Generate a new Ed25519 keypair and derive device identity */
67
+ export function generateIdentity(): DeviceIdentity {
68
+ const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
69
+ const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }).toString();
70
+ const privateKeyPem = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
71
+ const deviceId = fingerprintPublicKey(publicKeyPem);
72
+ return { deviceId, publicKeyPem, privateKeyPem };
73
+ }
74
+
75
+ /** Load existing device identity from file, or generate and persist a new one */
76
+ export function loadOrCreateIdentity(filePath: string): DeviceIdentity {
77
+ try {
78
+ if (fs.existsSync(filePath)) {
79
+ const raw = fs.readFileSync(filePath, 'utf8');
80
+ const parsed = JSON.parse(raw) as StoredIdentity;
81
+ if (
82
+ parsed?.version === 1 &&
83
+ typeof parsed.deviceId === 'string' &&
84
+ typeof parsed.publicKeyPem === 'string' &&
85
+ typeof parsed.privateKeyPem === 'string'
86
+ ) {
87
+ // Re-derive deviceId to handle corrupted stored IDs
88
+ const derivedId = fingerprintPublicKey(parsed.publicKeyPem);
89
+ return {
90
+ deviceId: derivedId,
91
+ publicKeyPem: parsed.publicKeyPem,
92
+ privateKeyPem: parsed.privateKeyPem,
93
+ };
94
+ }
95
+ }
96
+ } catch {
97
+ // fall through to regenerate
98
+ }
99
+
100
+ const identity = generateIdentity();
101
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
102
+ const stored: StoredIdentity = {
103
+ version: 1,
104
+ deviceId: identity.deviceId,
105
+ publicKeyPem: identity.publicKeyPem,
106
+ privateKeyPem: identity.privateKeyPem,
107
+ createdAtMs: Date.now(),
108
+ };
109
+ fs.writeFileSync(filePath, `${JSON.stringify(stored, null, 2)}\n`, { mode: 0o600 });
110
+ return identity;
111
+ }
112
+
113
+ /** Get base64url-encoded raw public key (for the connect frame device.publicKey field) */
114
+ export function publicKeyToBase64Url(publicKeyPem: string): string {
115
+ return base64UrlEncode(derivePublicKeyRaw(publicKeyPem));
116
+ }
117
+
118
+ /**
119
+ * Build v3 signature payload and sign it with Ed25519 private key.
120
+ * Returns base64url-encoded signature.
121
+ *
122
+ * v3 payload format: v3|deviceId|clientId|clientMode|role|scopes|signedAtMs|token|nonce|platform|deviceFamily
123
+ */
124
+ export function signChallenge(privateKeyPem: string, params: SignChallengeParams): string {
125
+ const scopes = params.scopes.join(',');
126
+ const token = params.token ?? '';
127
+ const platform = normalizeMetadata(params.platform);
128
+ const deviceFamily = normalizeMetadata(params.deviceFamily);
129
+ const payload = [
130
+ 'v3',
131
+ params.deviceId,
132
+ params.clientId,
133
+ params.clientMode,
134
+ params.role,
135
+ scopes,
136
+ String(params.signedAtMs),
137
+ token,
138
+ params.nonce,
139
+ platform,
140
+ deviceFamily,
141
+ ].join('|');
142
+
143
+ const key = crypto.createPrivateKey(privateKeyPem);
144
+ const sig = crypto.sign(null, Buffer.from(payload, 'utf8'), key);
145
+ return base64UrlEncode(sig);
146
+ }
@@ -1,14 +1,13 @@
1
1
  import { Service, BaseService, type OnModuleInit, type OnModuleDestroy } from '@onebun/core';
2
+ import path from 'node:path';
3
+ import { loadOrCreateIdentity, publicKeyToBase64Url, signChallenge, type DeviceIdentity } from './device-identity';
2
4
 
3
5
  export interface GatewayInjectPayload {
4
- target: string;
6
+ /** OpenClaw session key or recipient address (maps to `to` in the send frame) */
7
+ to: string;
5
8
  message: string;
6
- metadata?: {
7
- source: 'nats';
8
- eventId: string;
9
- subject: string;
10
- priority: number;
11
- };
9
+ /** Internal tracking metadata — NOT sent to gateway (additionalProperties: false) */
10
+ eventId?: string;
12
11
  }
13
12
 
14
13
  export class GatewayRpcError extends Error {
@@ -40,12 +39,20 @@ export class GatewayClientService extends BaseService implements OnModuleInit, O
40
39
  private requestId = 0;
41
40
  private wsUrl!: string;
42
41
  private token!: string;
42
+ private identity!: DeviceIdentity;
43
+ private publicKeyBase64Url!: string;
44
+ private challengeNonce = '';
43
45
  private pendingRequests = new Map<string, PendingRequest>();
44
46
 
45
47
  async onModuleInit(): Promise<void> {
46
48
  this.wsUrl = this.config.get('gateway.wsUrl');
47
49
  this.token = this.config.get('gateway.token');
48
50
  if (this.wsUrl && this.token) {
51
+ const dbPath = this.config.get('database.url');
52
+ const identityPath = path.join(path.dirname(dbPath), 'device-identity.json');
53
+ this.identity = loadOrCreateIdentity(identityPath);
54
+ this.publicKeyBase64Url = publicKeyToBase64Url(this.identity.publicKeyPem);
55
+ this.logger.info('Device identity loaded', { deviceId: this.identity.deviceId });
49
56
  this.connect();
50
57
  } else {
51
58
  this.logger.warn('Gateway WebSocket not configured — skipping connection (need wsUrl + deviceToken)');
@@ -99,7 +106,8 @@ export class GatewayClientService extends BaseService implements OnModuleInit, O
99
106
 
100
107
  // Server challenge — respond with connect frame
101
108
  if (frame.type === 'event' && frame.event === 'connect.challenge') {
102
- this.logger.debug('Received connect.challenge from server');
109
+ this.challengeNonce = frame.payload?.nonce ?? '';
110
+ this.logger.debug('Received connect.challenge from server', { hasNonce: !!this.challengeNonce });
103
111
  this.sendConnectFrame();
104
112
  return;
105
113
  }
@@ -162,33 +170,59 @@ export class GatewayClientService extends BaseService implements OnModuleInit, O
162
170
  private sendConnectFrame(): void {
163
171
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN || this.connectSent) return;
164
172
  this.connectSent = true;
165
- this.logger.info('Sending connect frame');
173
+
174
+ const signedAt = Date.now();
175
+ const scopes = ['operator.read', 'operator.write'];
176
+ const client = {
177
+ id: 'gateway-client' as const,
178
+ displayName: 'nats-sidecar',
179
+ version: '1.0.0',
180
+ platform: 'linux',
181
+ mode: 'backend' as const,
182
+ };
183
+
184
+ const signature = signChallenge(this.identity.privateKeyPem, {
185
+ deviceId: this.identity.deviceId,
186
+ clientId: client.id,
187
+ clientMode: client.mode,
188
+ role: 'operator',
189
+ scopes,
190
+ signedAtMs: signedAt,
191
+ token: this.token,
192
+ nonce: this.challengeNonce,
193
+ platform: client.platform,
194
+ });
195
+
196
+ this.logger.info('Sending connect frame with device identity', {
197
+ deviceId: this.identity.deviceId,
198
+ });
166
199
 
167
200
  try {
168
201
  this.send({
169
- type: 'req',
170
- id: `connect-${++this.requestId}`,
171
- method: 'connect',
172
- params: {
173
- minProtocol: 3,
174
- maxProtocol: 3,
175
- client: {
176
- id: 'gateway-client',
177
- displayName: 'nats-sidecar',
178
- version: '1.0.0',
179
- platform: 'linux',
180
- mode: 'backend',
202
+ type: 'req',
203
+ id: `connect-${++this.requestId}`,
204
+ method: 'connect',
205
+ params: {
206
+ minProtocol: 3,
207
+ maxProtocol: 3,
208
+ client,
209
+ role: 'operator',
210
+ scopes,
211
+ caps: [],
212
+ commands: [],
213
+ permissions: {},
214
+ auth: { token: this.token },
215
+ device: {
216
+ id: this.identity.deviceId,
217
+ publicKey: this.publicKeyBase64Url,
218
+ signature,
219
+ signedAt,
220
+ nonce: this.challengeNonce,
221
+ },
222
+ locale: 'en-US',
223
+ userAgent: 'nats-sidecar/1.0.0',
181
224
  },
182
- role: 'operator',
183
- scopes: ['operator.read', 'operator.write'],
184
- caps: [],
185
- commands: [],
186
- permissions: {},
187
- auth: { token: this.token },
188
- locale: 'en-US',
189
- userAgent: 'nats-sidecar/1.0.0',
190
- },
191
- });
225
+ });
192
226
  } catch (err) {
193
227
  this.logger.error('Failed to send connect frame', err);
194
228
  this.connectSent = false;
@@ -229,10 +263,9 @@ export class GatewayClientService extends BaseService implements OnModuleInit, O
229
263
  id,
230
264
  method: 'send',
231
265
  params: {
232
- target: payload.target,
266
+ to: payload.to,
233
267
  message: payload.message,
234
- metadata: payload.metadata,
235
- idempotencyKey: payload.metadata?.eventId ?? String(this.requestId),
268
+ idempotencyKey: payload.eventId ?? String(this.requestId),
236
269
  },
237
270
  });
238
271
  return promise;
@@ -56,7 +56,7 @@ export class LogRepository extends BaseService {
56
56
  .select({ count: sql<number>`count(*)` })
57
57
  .from(executionLogs)
58
58
  .where(this.buildWhereConditions(filters));
59
- return result[0]?.count ?? 0;
59
+ return (result[0] as unknown as { count: number })?.count ?? 0;
60
60
  }
61
61
 
62
62
  async findRecent(limit: number = 20): Promise<DbExecutionLog[]> {
@@ -58,7 +58,7 @@ export class LogService extends BaseService {
58
58
  }
59
59
  }
60
60
 
61
- async logError(entityType: 'route' | 'cron', entityId: string, subject: string, error: unknown): Promise<void> {
61
+ async logError(entityType: 'route' | 'cron' | 'timer', entityId: string, subject: string, error: unknown): Promise<void> {
62
62
  try {
63
63
  const detail = error instanceof Error
64
64
  ? JSON.stringify({ message: error.message, stack: error.stack })
@@ -49,7 +49,7 @@ export class PendingRepository extends BaseService {
49
49
  .select({ count: sql<number>`count(*)` })
50
50
  .from(pendingEvents)
51
51
  .where(isNull(pendingEvents.deliveredAt));
52
- return result[0]?.count ?? 0;
52
+ return (result[0] as unknown as { count: number })?.count ?? 0;
53
53
  }
54
54
 
55
55
  async cleanup(ttlSeconds: number): Promise<number> {
@@ -82,6 +82,6 @@ export class RouterRepository extends BaseService {
82
82
  const result = await this.db
83
83
  .select({ count: sql<number>`count(*)` })
84
84
  .from(eventRoutes);
85
- return result[0]?.count ?? 0;
85
+ return (result[0] as unknown as { count: number })?.count ?? 0;
86
86
  }
87
87
  }