@omnixal/openclaw-nats-plugin 0.2.10 → 0.2.12
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 +7 -1
- package/cli/bun-setup.ts +3 -3
- package/dashboard/src/lib/EventsReferenceModal.svelte +59 -0
- package/dashboard/src/lib/MetricsPanel.svelte +12 -1
- package/dashboard/src/lib/components/ui/modal/modal.svelte +3 -1
- package/docker/docker-compose.yml +1 -1
- package/package.json +1 -1
- package/plugins/nats-context-engine/index.ts +46 -0
- package/sidecar/bun.lock +1 -0
- package/sidecar/package.json +2 -1
- package/sidecar/src/config.ts +2 -2
- package/sidecar/src/consumer/consumer.controller.ts +1 -7
- package/sidecar/src/gateway/gateway-client.service.ts +31 -291
- package/sidecar/src/health/health.service.ts +1 -1
- package/sidecar/src/logs/log.repository.ts +1 -1
- package/sidecar/src/logs/log.service.ts +1 -1
- package/sidecar/src/pending/pending.repository.ts +1 -1
- package/sidecar/src/router/router.repository.ts +1 -1
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
|
|
@@ -90,6 +94,8 @@ Environment variables (auto-configured by setup):
|
|
|
90
94
|
- `NATS_SIDECAR_URL` — Sidecar URL (default: `http://127.0.0.1:3104`)
|
|
91
95
|
- `NATS_PLUGIN_API_KEY` — API key for sidecar auth (auto-generated)
|
|
92
96
|
- `NATS_SERVERS` — NATS server URL (default: `nats://127.0.0.1:4222`)
|
|
97
|
+
- `OPENCLAW_GATEWAY_URL` — Gateway HTTP URL (default: `http://127.0.0.1:18789`)
|
|
98
|
+
- `OPENCLAW_HOOK_TOKEN` — Webhook token for event delivery to agent session (from `hooks.token` in gateway config)
|
|
93
99
|
|
|
94
100
|
## Requirements
|
|
95
101
|
|
package/cli/bun-setup.ts
CHANGED
|
@@ -54,18 +54,18 @@ export async function bunSetup(): Promise<void> {
|
|
|
54
54
|
NATS_SIDECAR_URL: 'http://127.0.0.1:3104',
|
|
55
55
|
NATS_PLUGIN_API_KEY: apiKey,
|
|
56
56
|
NATS_SERVERS: 'nats://127.0.0.1:4222',
|
|
57
|
-
|
|
57
|
+
OPENCLAW_GATEWAY_URL: 'http://127.0.0.1:18789',
|
|
58
58
|
};
|
|
59
59
|
writeEnvVariables(envVars);
|
|
60
60
|
|
|
61
61
|
// Write .env into sidecar dir so loadDotEnv picks it up
|
|
62
|
-
// Explicit localhost values override any container-level env
|
|
62
|
+
// Explicit localhost values override any container-level env
|
|
63
63
|
const sidecarEnv = [
|
|
64
64
|
`PORT=3104`,
|
|
65
65
|
`DB_PATH=${join(DATA_DIR, 'nats-sidecar.db')}`,
|
|
66
66
|
`NATS_SERVERS=nats://127.0.0.1:4222`,
|
|
67
67
|
`NATS_PLUGIN_API_KEY=${apiKey}`,
|
|
68
|
-
`
|
|
68
|
+
`OPENCLAW_GATEWAY_URL=http://127.0.0.1:18789`,
|
|
69
69
|
].join('\n');
|
|
70
70
|
writeFileSync(join(SIDECAR_DIR, '.env'), sidecarEnv, 'utf-8');
|
|
71
71
|
|
|
@@ -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
|
-
<
|
|
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}>
|
|
@@ -29,7 +29,7 @@ services:
|
|
|
29
29
|
- PORT=3104
|
|
30
30
|
- DB_PATH=/app/data/nats-sidecar.db
|
|
31
31
|
- NATS_SERVERS=nats://nats:4222
|
|
32
|
-
-
|
|
32
|
+
- OPENCLAW_GATEWAY_URL=http://host.docker.internal:18789
|
|
33
33
|
- NATS_PLUGIN_API_KEY=${NATS_PLUGIN_API_KEY}
|
|
34
34
|
volumes:
|
|
35
35
|
- sidecar-data:/app/data
|
package/package.json
CHANGED
|
@@ -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';
|
package/sidecar/bun.lock
CHANGED
package/sidecar/package.json
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"typecheck": "bunx tsc --noEmit",
|
|
12
12
|
"db:generate": "bunx onebun-drizzle generate",
|
|
13
13
|
"db:push": "bunx onebun-drizzle push",
|
|
14
|
-
"db:studio": "bunx onebun-drizzle studio"
|
|
14
|
+
"db:studio": "bunx onebun-drizzle studio"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
17
|
"@onebun/core": "^0.2.15",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"@onebun/envs": "^0.2.2",
|
|
20
20
|
"@onebun/logger": "^0.2.1",
|
|
21
21
|
"@onebun/nats": "^0.2.6",
|
|
22
|
+
"@onebun/requests": "^0.2.1",
|
|
22
23
|
"arktype": "^2.2.0",
|
|
23
24
|
"ulid": "^2.3.0"
|
|
24
25
|
},
|
package/sidecar/src/config.ts
CHANGED
|
@@ -16,8 +16,8 @@ export const envSchema = {
|
|
|
16
16
|
maxReconnectAttempts: Env.number({ default: -1, env: 'NATS_MAX_RECONNECT_ATTEMPTS' }),
|
|
17
17
|
},
|
|
18
18
|
gateway: {
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
url: Env.string({ default: 'http://localhost:18789', env: 'OPENCLAW_GATEWAY_URL' }),
|
|
20
|
+
hookToken: Env.string({ default: '', env: 'OPENCLAW_HOOK_TOKEN' }),
|
|
21
21
|
},
|
|
22
22
|
consumer: {
|
|
23
23
|
name: Env.string({ default: 'openclaw-main', env: 'NATS_CONSUMER_NAME' }),
|
|
@@ -58,14 +58,8 @@ export class ConsumerController extends BaseController {
|
|
|
58
58
|
try {
|
|
59
59
|
const injectStart = performance.now();
|
|
60
60
|
await this.gatewayClient.inject({
|
|
61
|
-
target: route.target,
|
|
62
61
|
message: this.formatMessage(envelope),
|
|
63
|
-
|
|
64
|
-
source: 'nats',
|
|
65
|
-
eventId: envelope.id,
|
|
66
|
-
subject: envelope.subject,
|
|
67
|
-
priority: (ctx.enrichments['priority'] as number) ?? envelope.meta?.priority ?? 5,
|
|
68
|
-
},
|
|
62
|
+
eventId: envelope.id,
|
|
69
63
|
});
|
|
70
64
|
const lagMs = Math.round(performance.now() - injectStart);
|
|
71
65
|
await this.routerService.recordDelivery(route.id, envelope.subject, lagMs);
|
|
@@ -1,16 +1,9 @@
|
|
|
1
|
-
import { Service, BaseService, type OnModuleInit
|
|
2
|
-
import
|
|
3
|
-
import { loadOrCreateIdentity, publicKeyToBase64Url, signChallenge, type DeviceIdentity } from './device-identity';
|
|
1
|
+
import { Service, BaseService, type OnModuleInit } from '@onebun/core';
|
|
2
|
+
import { HttpClient, isErrorResponse } from '@onebun/requests';
|
|
4
3
|
|
|
5
4
|
export interface GatewayInjectPayload {
|
|
6
|
-
target: string;
|
|
7
5
|
message: string;
|
|
8
|
-
|
|
9
|
-
source: 'nats';
|
|
10
|
-
eventId: string;
|
|
11
|
-
subject: string;
|
|
12
|
-
priority: number;
|
|
13
|
-
};
|
|
6
|
+
eventId?: string;
|
|
14
7
|
}
|
|
15
8
|
|
|
16
9
|
export class GatewayRpcError extends Error {
|
|
@@ -24,305 +17,52 @@ export class GatewayRpcError extends Error {
|
|
|
24
17
|
}
|
|
25
18
|
}
|
|
26
19
|
|
|
27
|
-
interface PendingRequest {
|
|
28
|
-
resolve: () => void;
|
|
29
|
-
reject: (err: Error) => void;
|
|
30
|
-
timer: Timer;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
const RPC_TIMEOUT_MS = 10_000;
|
|
34
|
-
|
|
35
20
|
@Service()
|
|
36
|
-
export class GatewayClientService extends BaseService implements OnModuleInit
|
|
37
|
-
private
|
|
38
|
-
private
|
|
39
|
-
private connectSent = false;
|
|
40
|
-
private reconnectAttempt = 0;
|
|
41
|
-
private reconnectTimer: Timer | null = null;
|
|
21
|
+
export class GatewayClientService extends BaseService implements OnModuleInit {
|
|
22
|
+
private client!: HttpClient;
|
|
23
|
+
private configured = false;
|
|
42
24
|
private requestId = 0;
|
|
43
|
-
private wsUrl!: string;
|
|
44
|
-
private token!: string;
|
|
45
|
-
private identity!: DeviceIdentity;
|
|
46
|
-
private publicKeyBase64Url!: string;
|
|
47
|
-
private challengeNonce = '';
|
|
48
|
-
private pendingRequests = new Map<string, PendingRequest>();
|
|
49
25
|
|
|
50
26
|
async onModuleInit(): Promise<void> {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
private connect(): void {
|
|
66
|
-
try {
|
|
67
|
-
this.connectSent = false;
|
|
68
|
-
this.ws = new WebSocket(this.wsUrl);
|
|
69
|
-
|
|
70
|
-
this.ws.onopen = () => {
|
|
71
|
-
this.reconnectAttempt = 0;
|
|
72
|
-
this.logger.info('Gateway WebSocket opened, waiting for connect.challenge');
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
this.ws.onmessage = (event) => {
|
|
76
|
-
this.handleMessage(String(event.data));
|
|
77
|
-
};
|
|
78
|
-
|
|
79
|
-
this.ws.onclose = () => {
|
|
80
|
-
this.connected = false;
|
|
81
|
-
this.connectSent = false;
|
|
82
|
-
// Reject all in-flight requests immediately — don't make callers wait for timeout
|
|
83
|
-
for (const [id, pending] of this.pendingRequests) {
|
|
84
|
-
clearTimeout(pending.timer);
|
|
85
|
-
pending.reject(new Error('Gateway WebSocket closed'));
|
|
86
|
-
}
|
|
87
|
-
this.pendingRequests.clear();
|
|
88
|
-
this.scheduleReconnect();
|
|
89
|
-
};
|
|
90
|
-
|
|
91
|
-
this.ws.onerror = () => {
|
|
92
|
-
this.logger.warn('Gateway WebSocket error');
|
|
93
|
-
this.connected = false;
|
|
94
|
-
};
|
|
95
|
-
} catch (err) {
|
|
96
|
-
this.logger.warn('Failed to connect to Gateway WebSocket', err);
|
|
97
|
-
this.scheduleReconnect();
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
private handleMessage(data: string): void {
|
|
102
|
-
let frame: any;
|
|
103
|
-
try {
|
|
104
|
-
frame = JSON.parse(data);
|
|
105
|
-
} catch {
|
|
106
|
-
this.logger.warn(`Failed to parse WebSocket message: ${data.slice(0, 200)}`);
|
|
107
|
-
return;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// Server challenge — respond with connect frame
|
|
111
|
-
if (frame.type === 'event' && frame.event === 'connect.challenge') {
|
|
112
|
-
this.challengeNonce = frame.payload?.nonce ?? '';
|
|
113
|
-
this.logger.debug('Received connect.challenge from server', { hasNonce: !!this.challengeNonce });
|
|
114
|
-
this.sendConnectFrame();
|
|
115
|
-
return;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// Some gateway versions send an event before challenge; treat any pre-connect event as trigger
|
|
119
|
-
if (!this.connectSent && frame.type === 'event') {
|
|
120
|
-
this.logger.debug('Received event before connect sent, sending connect frame');
|
|
121
|
-
this.sendConnectFrame();
|
|
122
|
-
return;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// Successful connect response — must be hello-ok
|
|
126
|
-
if (frame.type === 'res' && frame.ok === true) {
|
|
127
|
-
const payload = frame.payload;
|
|
128
|
-
if (payload?.type === 'hello-ok') {
|
|
129
|
-
if (!this.connected) {
|
|
130
|
-
this.connected = true;
|
|
131
|
-
const grantedScopes = payload.auth?.scopes ?? [];
|
|
132
|
-
const serverVersion = payload.server?.version ?? 'unknown';
|
|
133
|
-
this.logger.info('OpenClaw handshake complete', {
|
|
134
|
-
protocol: payload.protocol,
|
|
135
|
-
serverVersion,
|
|
136
|
-
grantedScopes,
|
|
137
|
-
connId: payload.server?.connId,
|
|
138
|
-
});
|
|
139
|
-
if (grantedScopes.length > 0 && !grantedScopes.includes('operator.write')) {
|
|
140
|
-
this.logger.error(
|
|
141
|
-
`Gateway did NOT grant operator.write scope! Granted: [${grantedScopes.join(', ')}]. ` +
|
|
142
|
-
'Message delivery will fail. Rotate the device token with --scope operator.write',
|
|
143
|
-
);
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
return;
|
|
147
|
-
}
|
|
148
|
-
// Regular RPC ok response (e.g. for inject calls)
|
|
149
|
-
this.logger.debug('Received RPC ok response', { id: frame.id });
|
|
150
|
-
this.resolvePending(frame.id);
|
|
151
|
-
return;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
// Error response
|
|
155
|
-
if (frame.type === 'res' && frame.ok === false) {
|
|
156
|
-
const errorCode = frame.error?.code ?? frame.error?.errorCode ?? 'UNKNOWN';
|
|
157
|
-
const errorMessage = frame.error?.message ?? frame.error?.errorMessage ?? 'Unknown gateway error';
|
|
158
|
-
this.logger.error('Gateway RPC error', { id: frame.id, errorCode, errorMessage });
|
|
159
|
-
|
|
160
|
-
// If this is a connect error, close and reconnect
|
|
161
|
-
if (frame.id?.startsWith('connect-')) {
|
|
162
|
-
this.logger.error(`Gateway rejected connection: ${errorCode} — ${errorMessage}`);
|
|
163
|
-
this.connected = false;
|
|
164
|
-
this.connectSent = false;
|
|
165
|
-
this.ws?.close();
|
|
166
|
-
return;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
this.rejectPending(frame.id, new GatewayRpcError(frame.id, String(errorCode), String(errorMessage)));
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
private sendConnectFrame(): void {
|
|
174
|
-
if (!this.ws || this.ws.readyState !== WebSocket.OPEN || this.connectSent) return;
|
|
175
|
-
this.connectSent = true;
|
|
176
|
-
|
|
177
|
-
const signedAt = Date.now();
|
|
178
|
-
const scopes = ['operator.read', 'operator.write'];
|
|
179
|
-
const client = {
|
|
180
|
-
id: 'gateway-client' as const,
|
|
181
|
-
displayName: 'nats-sidecar',
|
|
182
|
-
version: '1.0.0',
|
|
183
|
-
platform: 'linux',
|
|
184
|
-
mode: 'backend' as const,
|
|
185
|
-
};
|
|
186
|
-
|
|
187
|
-
const signature = signChallenge(this.identity.privateKeyPem, {
|
|
188
|
-
deviceId: this.identity.deviceId,
|
|
189
|
-
clientId: client.id,
|
|
190
|
-
clientMode: client.mode,
|
|
191
|
-
role: 'operator',
|
|
192
|
-
scopes,
|
|
193
|
-
signedAtMs: signedAt,
|
|
194
|
-
token: this.token,
|
|
195
|
-
nonce: this.challengeNonce,
|
|
196
|
-
platform: client.platform,
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
this.logger.info('Sending connect frame with device identity', {
|
|
200
|
-
deviceId: this.identity.deviceId,
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
try {
|
|
204
|
-
this.send({
|
|
205
|
-
type: 'req',
|
|
206
|
-
id: `connect-${++this.requestId}`,
|
|
207
|
-
method: 'connect',
|
|
208
|
-
params: {
|
|
209
|
-
minProtocol: 3,
|
|
210
|
-
maxProtocol: 3,
|
|
211
|
-
client,
|
|
212
|
-
role: 'operator',
|
|
213
|
-
scopes,
|
|
214
|
-
caps: [],
|
|
215
|
-
commands: [],
|
|
216
|
-
permissions: {},
|
|
217
|
-
auth: { token: this.token },
|
|
218
|
-
device: {
|
|
219
|
-
id: this.identity.deviceId,
|
|
220
|
-
publicKey: this.publicKeyBase64Url,
|
|
221
|
-
signature,
|
|
222
|
-
signedAt,
|
|
223
|
-
nonce: this.challengeNonce,
|
|
224
|
-
},
|
|
225
|
-
locale: 'en-US',
|
|
226
|
-
userAgent: 'nats-sidecar/1.0.0',
|
|
27
|
+
const gatewayUrl = this.config.get('gateway.url');
|
|
28
|
+
const hookToken = this.config.get('gateway.hookToken');
|
|
29
|
+
|
|
30
|
+
if (gatewayUrl && hookToken) {
|
|
31
|
+
this.client = new HttpClient({
|
|
32
|
+
baseUrl: gatewayUrl,
|
|
33
|
+
timeout: 10_000,
|
|
34
|
+
auth: { type: 'bearer', token: hookToken },
|
|
35
|
+
retries: {
|
|
36
|
+
max: 2,
|
|
37
|
+
backoff: 'exponential',
|
|
38
|
+
delay: 500,
|
|
39
|
+
retryOn: [502, 503, 504],
|
|
227
40
|
},
|
|
228
41
|
});
|
|
229
|
-
|
|
230
|
-
this.logger.
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
private send(frame: unknown): void {
|
|
236
|
-
if (this.ws?.readyState !== WebSocket.OPEN) {
|
|
237
|
-
throw new Error('WebSocket is not open');
|
|
238
|
-
}
|
|
239
|
-
try {
|
|
240
|
-
this.ws.send(JSON.stringify(frame));
|
|
241
|
-
} catch (err) {
|
|
242
|
-
this.connected = false;
|
|
243
|
-
throw new Error(`WebSocket send failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
42
|
+
this.configured = true;
|
|
43
|
+
this.logger.info('Gateway webhook configured', { url: gatewayUrl });
|
|
44
|
+
} else {
|
|
45
|
+
this.logger.warn('Gateway webhook not configured — need url + hookToken');
|
|
244
46
|
}
|
|
245
47
|
}
|
|
246
48
|
|
|
247
|
-
private scheduleReconnect(): void {
|
|
248
|
-
if (this.reconnectTimer) return;
|
|
249
|
-
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempt), 30_000);
|
|
250
|
-
this.reconnectAttempt++;
|
|
251
|
-
this.logger.debug(`Reconnecting to Gateway in ${delay}ms (attempt ${this.reconnectAttempt})`);
|
|
252
|
-
this.reconnectTimer = setTimeout(() => {
|
|
253
|
-
this.reconnectTimer = null;
|
|
254
|
-
this.connect();
|
|
255
|
-
}, delay);
|
|
256
|
-
}
|
|
257
|
-
|
|
258
49
|
async inject(payload: GatewayInjectPayload): Promise<void> {
|
|
259
|
-
if (!this.
|
|
260
|
-
throw new Error('Gateway
|
|
50
|
+
if (!this.configured) {
|
|
51
|
+
throw new Error('Gateway webhook not configured');
|
|
261
52
|
}
|
|
262
53
|
const id = `rpc-${++this.requestId}`;
|
|
263
|
-
const promise = this.trackRequest(id);
|
|
264
|
-
this.send({
|
|
265
|
-
type: 'req',
|
|
266
|
-
id,
|
|
267
|
-
method: 'send',
|
|
268
|
-
params: {
|
|
269
|
-
target: payload.target,
|
|
270
|
-
message: payload.message,
|
|
271
|
-
metadata: payload.metadata,
|
|
272
|
-
idempotencyKey: payload.metadata?.eventId ?? String(this.requestId),
|
|
273
|
-
},
|
|
274
|
-
});
|
|
275
|
-
return promise;
|
|
276
|
-
}
|
|
277
54
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
this.pendingRequests.delete(id);
|
|
282
|
-
reject(new Error(`Gateway RPC timeout after ${RPC_TIMEOUT_MS}ms [${id}]`));
|
|
283
|
-
}, RPC_TIMEOUT_MS);
|
|
284
|
-
this.pendingRequests.set(id, { resolve, reject, timer });
|
|
55
|
+
const response = await this.client.post('/hooks/wake', {
|
|
56
|
+
text: payload.message,
|
|
57
|
+
mode: 'now',
|
|
285
58
|
});
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
private resolvePending(id: string): void {
|
|
289
|
-
const pending = this.pendingRequests.get(id);
|
|
290
|
-
if (pending) {
|
|
291
|
-
clearTimeout(pending.timer);
|
|
292
|
-
this.pendingRequests.delete(id);
|
|
293
|
-
pending.resolve();
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
59
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
if (pending) {
|
|
300
|
-
clearTimeout(pending.timer);
|
|
301
|
-
this.pendingRequests.delete(id);
|
|
302
|
-
pending.reject(err);
|
|
60
|
+
if (isErrorResponse(response)) {
|
|
61
|
+
throw new GatewayRpcError(id, String(response.code), response.message ?? response.error);
|
|
303
62
|
}
|
|
304
63
|
}
|
|
305
64
|
|
|
306
65
|
isAlive(): boolean {
|
|
307
|
-
return this.
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
async onModuleDestroy(): Promise<void> {
|
|
311
|
-
if (this.reconnectTimer) {
|
|
312
|
-
clearTimeout(this.reconnectTimer);
|
|
313
|
-
this.reconnectTimer = null;
|
|
314
|
-
}
|
|
315
|
-
// Reject all pending requests
|
|
316
|
-
for (const [id, pending] of this.pendingRequests) {
|
|
317
|
-
clearTimeout(pending.timer);
|
|
318
|
-
pending.reject(new Error('Gateway client shutting down'));
|
|
319
|
-
}
|
|
320
|
-
this.pendingRequests.clear();
|
|
321
|
-
if (this.ws) {
|
|
322
|
-
this.ws.close();
|
|
323
|
-
this.ws = null;
|
|
324
|
-
}
|
|
325
|
-
this.connected = false;
|
|
326
|
-
this.connectSent = false;
|
|
66
|
+
return this.configured;
|
|
327
67
|
}
|
|
328
68
|
}
|
|
@@ -44,7 +44,7 @@ export class HealthService extends BaseService {
|
|
|
44
44
|
},
|
|
45
45
|
gateway: {
|
|
46
46
|
connected: this.gateway.isAlive(),
|
|
47
|
-
url: this.config.get('gateway.
|
|
47
|
+
url: this.config.get('gateway.url'),
|
|
48
48
|
},
|
|
49
49
|
pendingCount,
|
|
50
50
|
uptimeSeconds: Math.floor((Date.now() - this.startedAt) / 1000),
|
|
@@ -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> {
|