clawnexus 0.2.8 → 0.3.1

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.
@@ -0,0 +1,48 @@
1
+ import { EventEmitter } from "node:events";
2
+ import type { TaskManager } from "./tasks.js";
3
+ import type { AgentRouter } from "./router.js";
4
+ type GwState = "disconnected" | "connecting" | "ready" | "error";
5
+ export interface TaskExecutorOptions {
6
+ tasks: TaskManager;
7
+ gatewayUrl?: string;
8
+ maxConcurrent?: number;
9
+ }
10
+ export declare class TaskExecutor extends EventEmitter {
11
+ private readonly tasks;
12
+ private readonly gatewayUrl;
13
+ private readonly maxConcurrent;
14
+ private router;
15
+ private gwConn;
16
+ private gwState;
17
+ private queue;
18
+ private executing;
19
+ private stateChangeHandler;
20
+ private reconnectTimer;
21
+ private draining;
22
+ private closed;
23
+ constructor(opts: TaskExecutorOptions);
24
+ setRouter(router: AgentRouter): void;
25
+ start(): void;
26
+ enqueue(taskId: string): void;
27
+ getStatus(): {
28
+ gw_state: GwState;
29
+ queue_length: number;
30
+ executing: Array<{
31
+ task_id: string;
32
+ session_key: string;
33
+ }>;
34
+ max_concurrent: number;
35
+ };
36
+ close(): Promise<void>;
37
+ private ensureConnection;
38
+ private connectGatewayV3;
39
+ private handleGwMessage;
40
+ private handleGwEvent;
41
+ private handleTaskFinal;
42
+ private handleTaskError;
43
+ private drainQueue;
44
+ private executeTask;
45
+ private clearTaskTimers;
46
+ private scheduleReconnect;
47
+ }
48
+ export {};
@@ -0,0 +1,374 @@
1
+ "use strict";
2
+ // Layer B — Task Executor
3
+ // Connects to local OpenClaw Gateway via WebSocket, executes accepted inbound tasks,
4
+ // reports results back to the proposer via AgentRouter.
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.TaskExecutor = void 0;
7
+ const node_events_1 = require("node:events");
8
+ const ws_1 = require("ws");
9
+ const node_crypto_1 = require("node:crypto");
10
+ const gateway_js_1 = require("./gateway.js");
11
+ const DEFAULT_GW_URL = "ws://127.0.0.1:18789";
12
+ const HEARTBEAT_INTERVAL_MS = 15_000;
13
+ const DEFAULT_MAX_CONCURRENT = 3;
14
+ class TaskExecutor extends node_events_1.EventEmitter {
15
+ tasks;
16
+ gatewayUrl;
17
+ maxConcurrent;
18
+ router = null;
19
+ gwConn = null;
20
+ gwState = "disconnected";
21
+ // Queue of task IDs waiting to execute
22
+ queue = [];
23
+ // Currently executing tasks
24
+ executing = new Map();
25
+ stateChangeHandler = null;
26
+ reconnectTimer = null;
27
+ draining = false;
28
+ closed = false;
29
+ constructor(opts) {
30
+ super();
31
+ this.tasks = opts.tasks;
32
+ this.gatewayUrl = opts.gatewayUrl ?? DEFAULT_GW_URL;
33
+ this.maxConcurrent = opts.maxConcurrent ?? DEFAULT_MAX_CONCURRENT;
34
+ }
35
+ setRouter(router) {
36
+ this.router = router;
37
+ }
38
+ start() {
39
+ // Listen for accepted inbound tasks
40
+ this.stateChangeHandler = (task, newState) => {
41
+ if (newState === "accepted" && task.direction === "inbound") {
42
+ this.enqueue(task.task_id);
43
+ }
44
+ };
45
+ this.tasks.on("stateChange", this.stateChangeHandler);
46
+ // Also pick up any already-accepted inbound tasks (e.g. after restart)
47
+ for (const task of this.tasks.getActive()) {
48
+ if (task.state === "accepted" && task.direction === "inbound") {
49
+ this.enqueue(task.task_id);
50
+ }
51
+ }
52
+ }
53
+ enqueue(taskId) {
54
+ if (this.executing.has(taskId) || this.queue.includes(taskId))
55
+ return;
56
+ this.queue.push(taskId);
57
+ this.drainQueue();
58
+ }
59
+ getStatus() {
60
+ return {
61
+ gw_state: this.gwState,
62
+ queue_length: this.queue.length,
63
+ executing: Array.from(this.executing.values()).map((e) => ({
64
+ task_id: e.taskId,
65
+ session_key: e.sessionKey,
66
+ })),
67
+ max_concurrent: this.maxConcurrent,
68
+ };
69
+ }
70
+ async close() {
71
+ this.closed = true;
72
+ if (this.stateChangeHandler) {
73
+ this.tasks.off("stateChange", this.stateChangeHandler);
74
+ this.stateChangeHandler = null;
75
+ }
76
+ if (this.reconnectTimer) {
77
+ clearTimeout(this.reconnectTimer);
78
+ this.reconnectTimer = null;
79
+ }
80
+ // Abort all executing tasks
81
+ for (const [taskId, exec] of this.executing) {
82
+ this.clearTaskTimers(exec);
83
+ this.tasks.updateState(taskId, "failed", { error: "Executor shutting down" });
84
+ }
85
+ this.executing.clear();
86
+ this.queue = [];
87
+ if (this.gwConn) {
88
+ this.gwConn.close();
89
+ this.gwConn = null;
90
+ }
91
+ this.gwState = "disconnected";
92
+ }
93
+ // --- Gateway Connection ---
94
+ async ensureConnection() {
95
+ if (this.gwState === "ready" && this.gwConn?.ws?.readyState === ws_1.WebSocket.OPEN) {
96
+ return true;
97
+ }
98
+ if (this.gwState === "connecting") {
99
+ // Already in progress — wait
100
+ return new Promise((resolve) => {
101
+ const onReady = () => { cleanup(); resolve(true); };
102
+ const onError = () => { cleanup(); resolve(false); };
103
+ const cleanup = () => {
104
+ this.off("gw:ready", onReady);
105
+ this.off("gw:error", onError);
106
+ };
107
+ this.once("gw:ready", onReady);
108
+ this.once("gw:error", onError);
109
+ });
110
+ }
111
+ return this.connectGatewayV3();
112
+ }
113
+ async connectGatewayV3() {
114
+ if (this.closed)
115
+ return false;
116
+ this.gwState = "connecting";
117
+ try {
118
+ const conn = await (0, gateway_js_1.connectGateway)({
119
+ gatewayUrl: this.gatewayUrl,
120
+ scopes: ["operator.read", "operator.write"],
121
+ });
122
+ this.gwConn = conn;
123
+ this.gwState = "ready";
124
+ // Set up event listener for runtime messages
125
+ conn.ws.on("message", (data) => {
126
+ let msg;
127
+ try {
128
+ msg = JSON.parse(data.toString());
129
+ }
130
+ catch {
131
+ return;
132
+ }
133
+ this.handleGwMessage(msg);
134
+ });
135
+ conn.ws.on("close", () => {
136
+ const wasReady = this.gwState === "ready";
137
+ this.gwState = "disconnected";
138
+ this.gwConn = null;
139
+ if (wasReady) {
140
+ console.log("[clawnexus] [Executor] Gateway connection closed");
141
+ this.scheduleReconnect();
142
+ }
143
+ });
144
+ conn.ws.on("error", (err) => {
145
+ console.log(`[clawnexus] [Executor] Gateway error: ${err.message}`);
146
+ });
147
+ console.log("[clawnexus] [Executor] Gateway connection ready");
148
+ this.emit("gw:ready");
149
+ return true;
150
+ }
151
+ catch (err) {
152
+ console.log(`[clawnexus] [Executor] Gateway connection failed: ${err.message}`);
153
+ this.gwState = "error";
154
+ this.emit("gw:error", err.message);
155
+ return false;
156
+ }
157
+ }
158
+ handleGwMessage(msg) {
159
+ const type = msg.type;
160
+ // Event frame
161
+ if (type === "event") {
162
+ this.handleGwEvent(msg);
163
+ }
164
+ // Error response
165
+ else if (type === "res" && msg.ok === false) {
166
+ const error = msg.error;
167
+ const id = msg.id;
168
+ if (id) {
169
+ // Find which task sent this request
170
+ for (const [, exec] of this.executing) {
171
+ if (exec.requestId === id) {
172
+ this.handleTaskError(exec.sessionKey, error?.message ?? "Gateway request error");
173
+ break;
174
+ }
175
+ }
176
+ }
177
+ }
178
+ }
179
+ handleGwEvent(msg) {
180
+ const event = msg.event;
181
+ const payload = msg.payload;
182
+ // Chat events use the session key from payload or top-level
183
+ const sessionKey = payload?.sessionKey ?? msg.sessionKey;
184
+ if (!sessionKey)
185
+ return;
186
+ // Find the executing task for this sessionKey
187
+ let execEntry;
188
+ for (const exec of this.executing.values()) {
189
+ if (exec.sessionKey === sessionKey) {
190
+ execEntry = exec;
191
+ break;
192
+ }
193
+ }
194
+ if (!execEntry)
195
+ return;
196
+ if (event === "chat" || event === "chat.update") {
197
+ const state = payload?.state ?? msg.data?.state;
198
+ if (state === "final") {
199
+ this.handleTaskFinal(execEntry, msg);
200
+ }
201
+ else if (state === "error") {
202
+ const errorMessage = payload?.errorMessage ?? "OpenClaw chat error";
203
+ this.handleTaskError(execEntry.sessionKey, errorMessage);
204
+ }
205
+ }
206
+ }
207
+ handleTaskFinal(exec, msg) {
208
+ this.clearTaskTimers(exec);
209
+ const payload = msg.payload;
210
+ const data = payload ?? msg.data;
211
+ // Extract the assistant's reply from the final message
212
+ const messages = data?.messages;
213
+ let result = "";
214
+ if (messages && messages.length > 0) {
215
+ // Last assistant message
216
+ const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant");
217
+ if (lastAssistant) {
218
+ const content = lastAssistant.content;
219
+ if (typeof content === "string") {
220
+ result = content;
221
+ }
222
+ else if (Array.isArray(content)) {
223
+ // Content blocks — extract text
224
+ result = content
225
+ .filter((b) => b.type === "text")
226
+ .map((b) => b.text)
227
+ .join("\n");
228
+ }
229
+ }
230
+ }
231
+ // If no structured messages, try extracting from accumulated content
232
+ if (!result) {
233
+ result = data?.content ?? "Task completed (no output)";
234
+ }
235
+ const task = this.tasks.getById(exec.taskId);
236
+ this.tasks.updateState(exec.taskId, "completed", { result });
237
+ // Send report to proposer
238
+ if (task?.room_id && task.peer_claw_id && this.router) {
239
+ this.router.sendReport(task.room_id, task.peer_claw_id, exec.taskId, "completed", result);
240
+ }
241
+ this.executing.delete(exec.taskId);
242
+ this.emit("task:completed", exec.taskId);
243
+ this.drainQueue();
244
+ }
245
+ handleTaskError(sessionKey, errorMsg) {
246
+ for (const [taskId, exec] of this.executing) {
247
+ if (exec.sessionKey === sessionKey) {
248
+ this.clearTaskTimers(exec);
249
+ const task = this.tasks.getById(taskId);
250
+ this.tasks.updateState(taskId, "failed", { error: errorMsg });
251
+ if (task?.room_id && task.peer_claw_id && this.router) {
252
+ this.router.sendReport(task.room_id, task.peer_claw_id, taskId, "failed", undefined, errorMsg);
253
+ }
254
+ this.executing.delete(taskId);
255
+ this.emit("task:failed", taskId, errorMsg);
256
+ this.drainQueue();
257
+ return;
258
+ }
259
+ }
260
+ }
261
+ // --- Task Execution ---
262
+ async drainQueue() {
263
+ if (this.draining)
264
+ return;
265
+ this.draining = true;
266
+ try {
267
+ while (this.queue.length > 0 && this.executing.size < this.maxConcurrent && !this.closed) {
268
+ const taskId = this.queue.shift();
269
+ const task = this.tasks.getById(taskId);
270
+ if (!task || task.state !== "accepted" || task.direction !== "inbound") {
271
+ continue; // Skip stale entries
272
+ }
273
+ await this.executeTask(task);
274
+ }
275
+ }
276
+ finally {
277
+ this.draining = false;
278
+ }
279
+ }
280
+ async executeTask(task) {
281
+ const connected = await this.ensureConnection();
282
+ if (!connected || !this.gwConn || this.gwConn.ws.readyState !== ws_1.WebSocket.OPEN) {
283
+ console.log(`[clawnexus] [Executor] Cannot execute task ${task.task_id}: gateway not available`);
284
+ this.tasks.updateState(task.task_id, "failed", { error: "OpenClaw Gateway not available" });
285
+ if (task.room_id && task.peer_claw_id && this.router) {
286
+ this.router.sendReport(task.room_id, task.peer_claw_id, task.task_id, "failed", undefined, "OpenClaw Gateway not available");
287
+ }
288
+ this.emit("task:failed", task.task_id, "Gateway not available");
289
+ return;
290
+ }
291
+ const sessionKey = `agent:main:main:dm:clawnexus-task-${task.task_id}`;
292
+ const message = task.task.description + (task.task.input ? "\n\n" + JSON.stringify(task.task.input) : "");
293
+ // Transition to executing
294
+ this.tasks.updateState(task.task_id, "executing");
295
+ const requestId = (0, node_crypto_1.randomUUID)();
296
+ const exec = {
297
+ taskId: task.task_id,
298
+ sessionKey,
299
+ requestId,
300
+ heartbeatTimer: null,
301
+ timeoutTimer: null,
302
+ aborted: false,
303
+ };
304
+ this.executing.set(task.task_id, exec);
305
+ // Send chat message to OpenClaw (v3 protocol frame format)
306
+ const chatMsg = {
307
+ type: "req",
308
+ id: requestId,
309
+ method: "chat.send",
310
+ params: {
311
+ sessionKey,
312
+ message,
313
+ idempotencyKey: requestId,
314
+ },
315
+ };
316
+ this.gwConn.ws.send(JSON.stringify(chatMsg));
317
+ // Start heartbeat (sends Layer B heartbeat to proposer every 15s)
318
+ if (task.room_id && task.peer_claw_id && this.router) {
319
+ exec.heartbeatTimer = setInterval(() => {
320
+ if (this.router && task.room_id) {
321
+ this.router.sendHeartbeat(task.room_id, task.peer_claw_id, task.task_id);
322
+ }
323
+ }, HEARTBEAT_INTERVAL_MS);
324
+ }
325
+ // Set up timeout
326
+ const maxDurationS = task.task.constraints?.max_duration_s ?? 600;
327
+ exec.timeoutTimer = setTimeout(() => {
328
+ if (exec.aborted)
329
+ return;
330
+ exec.aborted = true;
331
+ // Abort the chat
332
+ if (this.gwConn?.ws?.readyState === ws_1.WebSocket.OPEN) {
333
+ this.gwConn.ws.send(JSON.stringify({
334
+ type: "req",
335
+ id: (0, node_crypto_1.randomUUID)(),
336
+ method: "chat.abort",
337
+ params: { sessionKey },
338
+ }));
339
+ }
340
+ this.clearTaskTimers(exec);
341
+ this.tasks.updateState(task.task_id, "timeout");
342
+ if (task.room_id && task.peer_claw_id && this.router) {
343
+ this.router.sendReport(task.room_id, task.peer_claw_id, task.task_id, "failed", undefined, "Task execution timed out");
344
+ }
345
+ this.executing.delete(task.task_id);
346
+ this.emit("task:timeout", task.task_id);
347
+ this.drainQueue();
348
+ }, maxDurationS * 1000);
349
+ this.emit("task:executing", task.task_id);
350
+ console.log(`[clawnexus] [Executor] Executing task ${task.task_id} (session: ${sessionKey})`);
351
+ }
352
+ // --- Helpers ---
353
+ clearTaskTimers(exec) {
354
+ if (exec.heartbeatTimer) {
355
+ clearInterval(exec.heartbeatTimer);
356
+ exec.heartbeatTimer = null;
357
+ }
358
+ if (exec.timeoutTimer) {
359
+ clearTimeout(exec.timeoutTimer);
360
+ exec.timeoutTimer = null;
361
+ }
362
+ }
363
+ scheduleReconnect() {
364
+ if (this.closed || this.reconnectTimer)
365
+ return;
366
+ this.reconnectTimer = setTimeout(() => {
367
+ this.reconnectTimer = null;
368
+ if (!this.closed && (this.executing.size > 0 || this.queue.length > 0)) {
369
+ this.ensureConnection().catch(() => { });
370
+ }
371
+ }, 5000);
372
+ }
373
+ }
374
+ exports.TaskExecutor = TaskExecutor;
@@ -0,0 +1,26 @@
1
+ import { WebSocket } from "ws";
2
+ interface DeviceIdentity {
3
+ deviceId: string;
4
+ publicKeyPem: string;
5
+ privateKeyPem: string;
6
+ }
7
+ export declare function loadOrCreateDeviceIdentity(): DeviceIdentity;
8
+ export interface GatewayConnectionOptions {
9
+ gatewayUrl?: string;
10
+ connectTimeoutMs?: number;
11
+ requestTimeoutMs?: number;
12
+ role?: string;
13
+ scopes?: string[];
14
+ }
15
+ export interface GatewayConnection {
16
+ ws: WebSocket;
17
+ deviceId: string;
18
+ request(method: string, params?: unknown): Promise<unknown>;
19
+ close(): void;
20
+ }
21
+ /**
22
+ * Connect to the OpenClaw Gateway using Protocol v3 handshake.
23
+ * Handles device identity, Ed25519 signing, and device token storage.
24
+ */
25
+ export declare function connectGateway(opts?: GatewayConnectionOptions): Promise<GatewayConnection>;
26
+ export {};