@workclaw/openclaw-workclaw 1.0.17 → 1.0.18

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.
Files changed (51) hide show
  1. package/README.md +21 -1
  2. package/index.ts +210 -210
  3. package/openclaw.plugin.json +1 -0
  4. package/package.json +11 -4
  5. package/setup-entry.ts +6 -0
  6. package/skills/openclaw-workclaw-cron/SKILL.md +45 -28
  7. package/src/accounts.ts +62 -37
  8. package/src/api/accounts-api.ts +88 -89
  9. package/src/api/prompts-api.ts +70 -77
  10. package/src/api/session-api.ts +99 -108
  11. package/src/api/skills-api.ts +35 -37
  12. package/src/api/workspace.ts +27 -29
  13. package/src/channel.ts +200 -202
  14. package/src/config-schema.ts +9 -9
  15. package/src/connection/workclaw-client.ts +554 -567
  16. package/src/gateway/agent-handlers.ts +392 -426
  17. package/src/gateway/config-writer.ts +228 -243
  18. package/src/gateway/message-context.ts +534 -362
  19. package/src/gateway/message-dispatcher.ts +529 -489
  20. package/src/gateway/reconnect.ts +217 -113
  21. package/src/gateway/skills-handler.ts +408 -472
  22. package/src/gateway/skills-list-handler.ts +9 -9
  23. package/src/gateway/tools-list-handler.ts +70 -72
  24. package/src/gateway/workclaw-gateway.ts +328 -486
  25. package/src/media/upload.ts +83 -94
  26. package/src/outbound/index.ts +57 -55
  27. package/src/outbound/workclaw-sender.ts +134 -133
  28. package/src/runtime.ts +291 -194
  29. package/src/send.ts +1 -1
  30. package/src/tools/openclaw-workclaw-cron/api/index.ts +6 -6
  31. package/src/tools/openclaw-workclaw-cron/src/add/params.ts +20 -19
  32. package/src/tools/openclaw-workclaw-cron/src/add/sync.ts +2 -2
  33. package/src/tools/openclaw-workclaw-cron/src/disable/params.ts +1 -1
  34. package/src/tools/openclaw-workclaw-cron/src/disable/sync.ts +3 -3
  35. package/src/tools/openclaw-workclaw-cron/src/enable/params.ts +1 -1
  36. package/src/tools/openclaw-workclaw-cron/src/enable/sync.ts +3 -3
  37. package/src/tools/openclaw-workclaw-cron/src/notify/sync.ts +2 -2
  38. package/src/tools/openclaw-workclaw-cron/src/remove/params.ts +1 -1
  39. package/src/tools/openclaw-workclaw-cron/src/remove/sync.ts +3 -3
  40. package/src/tools/openclaw-workclaw-cron/src/update/params.ts +195 -197
  41. package/src/tools/openclaw-workclaw-cron/src/update/sync.ts +4 -4
  42. package/src/tools/openclaw-workclaw-system/src/get/index.ts +2 -2
  43. package/src/tools/openclaw-workclaw-system/src/token/index.ts +4 -4
  44. package/src/types.ts +38 -40
  45. package/src/utils/content.ts +16 -21
  46. package/tests/accounts.test.ts +285 -0
  47. package/tests/message-context.test.ts +313 -0
  48. package/tests/reconnect.test.ts +257 -0
  49. package/tests/workclaw-client.test.ts +112 -0
  50. package/tsconfig.json +8 -5
  51. package/vitest.config.ts +8 -0
@@ -3,147 +3,251 @@
3
3
  * Supports both per-account and per-appKey strategies
4
4
  */
5
5
 
6
- import type { WorkClawBaseConfig } from '../types.js'
7
- import { clearOpenclawWorkclawTokenCache } from '../connection/workclaw-client.js'
8
- import { clearOpenclawWorkclawWsConnection, setOpenclawWorkclawWsConnection } from '../runtime.js'
6
+ import { clearWorkclawTokenCache } from "../connection/workclaw-client.js";
7
+ import { openWorkclawConnection } from "../connection/workclaw-client.js";
8
+ import { Agent, WebSocket } from "undici";
9
+ import { setWorkclawWsConnection, clearWorkclawWsConnection } from "../runtime.js";
10
+ import type { WorkClawBaseConfig } from "../types.js";
9
11
 
10
12
  export interface ReconnectSchedulerOptions {
11
- /** Key for WebSocket storage: accountId (per-account) or appKey (per-appKey) */
12
- key: string
13
- config: WorkClawBaseConfig
14
- log?: {
15
- info?: (msg: string) => void
16
- warn?: (msg: string) => void
17
- error?: (msg: string) => void
18
- debug?: (msg: string) => void
19
- }
20
- onMessage: (data: string) => void
21
- onReconnectSuccess?: () => void
13
+ /** Key for WebSocket storage: accountId (per-account) or appKey (per-appKey) */
14
+ key: string;
15
+ config: WorkClawBaseConfig;
16
+ log?: {
17
+ info?: (msg: string) => void;
18
+ warn?: (msg: string) => void;
19
+ error?: (msg: string) => void;
20
+ debug?: (msg: string) => void;
21
+ };
22
+ onMessage: (data: string) => void;
23
+ onReconnectSuccess?: () => void;
24
+ /** Called when connection closes (after initial connect), to trigger reconnect */
25
+ onClose?: () => void;
26
+ /** Called after WebSocket is registered (before onopen), useful for per-appKey lock release */
27
+ onConnectingStarted?: (ws: WebSocket) => void;
22
28
  }
23
29
 
24
30
  export function createReconnectScheduler(options: ReconnectSchedulerOptions): {
25
- scheduleReconnect: () => void
26
- stopReconnect: () => void
31
+ connect: () => Promise<void>;
32
+ scheduleReconnect: () => void;
33
+ stopReconnect: () => void;
27
34
  } {
28
- const { key, config, log, onMessage, onReconnectSuccess } = options
29
-
30
- let reconnectBackoff = 5000
31
- let shouldStopReconnect = false
32
- let pendingReconnectTimer: NodeJS.Timeout | null = null
33
-
34
- const scheduleReconnect = (): void => {
35
- if (shouldStopReconnect) {
36
- log?.info?.(`Reconnect: Stopped due to previous auth failure`)
37
- return
38
- }
39
- if (pendingReconnectTimer !== null) {
40
- log?.info?.(`Reconnect: Already scheduled, skipping duplicate`)
41
- return
42
- }
43
-
44
- const jitter = Math.floor(Math.random() * 3000)
45
- const delayMs = reconnectBackoff + jitter
46
- log?.info?.(`Reconnect: Scheduling in ${delayMs}ms (base=${reconnectBackoff} + jitter=${jitter}) key=${key}`)
35
+ const { key, config, log, onMessage, onReconnectSuccess, onClose, onConnectingStarted } = options;
36
+
37
+ let reconnectBackoff = 5000;
38
+ let shouldStopReconnect = false;
39
+ let pendingReconnectTimer: NodeJS.Timeout | null = null;
40
+ /** 正在进行的 doConnect() 的 promise,所有 caller 共享同一个结果 */
41
+ let pendingDoConnectPromise: Promise<void> | null = null;
42
+
43
+ /**
44
+ * 核心建连逻辑,首次连接和重连复用同一套代码
45
+ * 返回 Promise,连接建立后 resolve,失败后决定是否重连
46
+ * 关键:重试时复用 pendingDoConnectPromise,所有 caller 共享同一个结果
47
+ * - 失败时:不删除 promise,让后续 connect() 调用者等待重试结果
48
+ * - 重试定时器触发时:复用同一 promise,不创建新的
49
+ */
50
+ async function doConnect(): Promise<void> {
51
+ // 如果已有进行中的 doConnect(失败后重试期间),复用同一 promise
52
+ if (pendingDoConnectPromise !== null) {
53
+ return pendingDoConnectPromise;
54
+ }
47
55
 
48
- pendingReconnectTimer = setTimeout(async () => {
49
- pendingReconnectTimer = null
50
- try {
51
- log?.info?.(`Reconnect: Attempting... key=${key}`)
56
+ let resolveThis: (() => void) | undefined;
57
+ let rejectThis: ((err: unknown) => void) | undefined;
58
+ const p = new Promise<void>((resolve, reject) => {
59
+ resolveThis = resolve;
60
+ rejectThis = reject;
61
+ });
62
+ // 设置后再调用 connection,后续 connect() 调用能立即看到它
63
+ pendingDoConnectPromise = p;
52
64
 
53
- const tokenCacheKey = config.appKey || key
54
- clearOpenclawWorkclawTokenCache(tokenCacheKey)
65
+ const tokenCacheKey = config.appKey || key;
66
+ clearWorkclawTokenCache(tokenCacheKey);
55
67
 
56
68
  const connConfig = {
57
- baseUrl: config.baseUrl || '',
58
- websocketUrl: config.websocketUrl,
59
- appKey: config.appKey,
60
- appSecret: config.appSecret,
61
- localIp: config.localIp,
62
- allowInsecureTls: config.allowInsecureTls,
63
- requestTimeout: config.requestTimeout,
69
+ baseUrl: config.baseUrl || "",
70
+ websocketUrl: config.websocketUrl,
71
+ appKey: config.appKey,
72
+ appSecret: config.appSecret,
73
+ localIp: config.localIp,
74
+ allowInsecureTls: config.allowInsecureTls,
75
+ requestTimeout: config.requestTimeout,
76
+ };
77
+
78
+ let endpoint: string;
79
+ let ticket: string;
80
+ try {
81
+ const result = await openWorkclawConnection(tokenCacheKey, connConfig);
82
+ endpoint = result.endpoint;
83
+ ticket = result.ticket;
84
+ } catch (err) {
85
+ // openWorkclawConnection 失败时(如 AbortError),由 handleConnectError 决定是否重试
86
+ // 如果不重试则 reject promise;如果重试则保持 pending(后续 timer 会触发重试)
87
+ const willRetry = handleConnectError(String(err));
88
+ if (!willRetry) {
89
+ pendingDoConnectPromise = null;
90
+ rejectThis!(err);
91
+ }
92
+ throw err;
64
93
  }
94
+ const url = `${endpoint}?ticket=${encodeURIComponent(ticket)}`;
65
95
 
66
- const { openOpenclawWorkclawConnection } = await import('../connection/workclaw-client.js')
67
- const { endpoint, ticket } = await openOpenclawWorkclawConnection(tokenCacheKey, connConfig)
68
- const url = `${endpoint}?ticket=${encodeURIComponent(ticket)}`
69
-
70
- const { Agent } = await import('undici')
71
96
  const dispatcher = connConfig.allowInsecureTls
72
- ? new Agent({ connect: { rejectUnauthorized: false } })
73
- : undefined
74
- const wsOptions = dispatcher ? { dispatcher } : undefined
97
+ ? new Agent({ connect: { rejectUnauthorized: false } })
98
+ : undefined;
99
+ const wsOptions = dispatcher ? { dispatcher } : undefined;
100
+
101
+ const ws = new WebSocket(url, wsOptions);
102
+ setWorkclawWsConnection(key, ws);
75
103
 
76
- const ws = new (await import('undici')).WebSocket(url, wsOptions)
77
- setOpenclawWorkclawWsConnection(key, ws)
104
+ // onopen 之前调用,让 waitForConnect() 释放锁
105
+ onConnectingStarted?.(ws);
78
106
 
107
+ // 统一用 resolveThis/rejectThis,ws.onopen 调用 resolveThis 让 await Promise 完成
79
108
  ws.onopen = () => {
80
- log?.info?.(`Reconnect: Success`)
81
- reconnectBackoff = 5000
82
- onReconnectSuccess?.()
83
- }
109
+ log?.info?.(`Gateway WebSocket connected endpoint=${endpoint}`);
110
+ reconnectBackoff = 5000;
111
+ onReconnectSuccess?.();
112
+ resolveThis!();
113
+ };
84
114
 
85
- ws.onmessage = (event: any) => {
86
- const data = typeof event.data === 'string' ? event.data : String(event.data ?? '')
87
- log?.info?.(`Reconnect: Message bytes=${data.length}`)
88
- onMessage(data)
89
- }
115
+ ws.onerror = (event: any) => {
116
+ log?.error?.(`Gateway WebSocket error: ${String(event?.message ?? event)}`);
117
+ rejectThis!(new Error(String(event?.message ?? event)));
118
+ };
90
119
 
91
120
  ws.onclose = (event: any) => {
92
- const code = String(event?.code ?? '')
93
- const reason = String(event?.reason ?? '')
94
- log?.warn?.(`Reconnect: Closed code=${code} reason=${reason}`)
95
- clearOpenclawWorkclawWsConnection(key)
96
- scheduleReconnect()
97
- }
121
+ const code = String(event?.code ?? "");
122
+ const reason = String(event?.reason ?? "");
123
+ log?.warn?.(`Gateway WebSocket closed code=${code} reason=${reason}`);
124
+ clearWorkclawWsConnection(key);
125
+ if (onClose) {
126
+ onClose();
127
+ } else {
128
+ scheduleReconnect();
129
+ }
130
+ rejectThis!(new Error(`WebSocket closed code=${code} reason=${reason}`));
131
+ };
98
132
 
99
- ws.onerror = (event: any) => {
100
- log?.error?.(`Reconnect: Error: ${String(event?.message ?? event)}`)
133
+ ws.onmessage = (event: any) => {
134
+ const data = typeof event.data === "string" ? event.data : String(event.data ?? "");
135
+ log?.info?.(`Gateway WebSocket message received bytes=${data.length}`);
136
+ onMessage(data);
137
+ };
138
+
139
+ // 等待 ws.onopen 完成(resolveThis)或 ws.onerror/ws.onclose 失败(rejectThis)
140
+ await new Promise<void>((resolve, reject) => {
141
+ const timeout = setTimeout(() => reject(new Error("websocket connection timeout")), 30000);
142
+ ws.addEventListener("open", () => { clearTimeout(timeout); resolve(); }, { once: true });
143
+ ws.addEventListener("error", (event: any) => { clearTimeout(timeout); reject(new Error(String(event?.message ?? event))); }, { once: true });
144
+ ws.addEventListener("close", (event: any) => { clearTimeout(timeout); reject(new Error(`websocket closed before ready code=${event?.code ?? ""} reason=${event?.reason ?? ""}`)); }, { once: true });
145
+ });
146
+ }
147
+
148
+ const scheduleReconnect = () => {
149
+ if (shouldStopReconnect) {
150
+ log?.info?.(`Reconnect: Stopped due to previous auth failure`);
151
+ return;
152
+ }
153
+ if (pendingReconnectTimer !== null) {
154
+ log?.info?.(`Reconnect: Already scheduled, skipping duplicate`);
155
+ return;
101
156
  }
102
- }
103
- catch (err) {
104
- const errorStr = String(err)
105
- log?.error?.(`Reconnect: Failed: ${errorStr}`)
106
-
107
- if (errorStr.includes('访问过于频繁')) {
108
- log?.warn?.(`Reconnect: Rate limited, retrying with minimal backoff`)
109
- if (pendingReconnectTimer !== null) {
110
- clearTimeout(pendingReconnectTimer)
111
- pendingReconnectTimer = null
112
- }
113
- reconnectBackoff = 1000
114
- scheduleReconnect()
115
- return
157
+
158
+ const jitter = Math.floor(Math.random() * 3000);
159
+ const delayMs = reconnectBackoff + jitter;
160
+ log?.info?.(`Reconnect: Scheduling in ${delayMs}ms (base=${reconnectBackoff} + jitter=${jitter}) key=${key}`);
161
+
162
+ pendingReconnectTimer = setTimeout(async () => {
163
+ pendingReconnectTimer = null;
164
+ // 清除旧的 pendingDoConnectPromise,确保 doConnect() 真正建立新连接
165
+ // 否则 doConnect() 会直接返回已 resolved 的旧 promise(第一次失败后 promise 没被清除)
166
+ pendingDoConnectPromise = null;
167
+ try {
168
+ log?.info?.(`Reconnect: Attempting... key=${key}`);
169
+ await doConnect();
170
+ log?.info?.(`Reconnect: Success`);
171
+ } catch (err) {
172
+ const errorStr = String(err);
173
+ log?.error?.(`Reconnect: Failed: ${errorStr}`);
174
+ const willRetry = handleConnectError(errorStr);
175
+ if (!willRetry) {
176
+ // 永久失败,清除 pending promise
177
+ pendingDoConnectPromise = null;
178
+ }
179
+ }
180
+ }, delayMs);
181
+ };
182
+
183
+ /** 返回 true=会重试(promise 保持 pending),false=不重试(promise 应被 reject) */
184
+ function handleConnectError(errorStr: string): boolean {
185
+ if (errorStr.includes("访问过于频繁")) {
186
+ log?.warn?.(`Reconnect: Rate limited, retrying with minimal backoff`);
187
+ if (pendingReconnectTimer !== null) {
188
+ clearTimeout(pendingReconnectTimer);
189
+ pendingReconnectTimer = null;
190
+ }
191
+ reconnectBackoff = 1000;
192
+ scheduleReconnect();
193
+ return true; // 会重试
116
194
  }
117
195
 
118
- const isAuthError
119
- = errorStr.includes('"errCode":2001')
120
- || errorStr.includes('"code":2001')
121
- || errorStr.includes('鉴权失败')
122
- || errorStr.includes('invalid credentials')
123
- || errorStr.includes('unauthorized')
196
+ const isAuthError =
197
+ errorStr.includes('"errCode":2001') ||
198
+ errorStr.includes('"code":2001') ||
199
+ errorStr.includes("鉴权失败") ||
200
+ errorStr.includes("invalid credentials") ||
201
+ errorStr.includes("unauthorized");
124
202
 
125
203
  if (isAuthError) {
126
- log?.error?.(`Reconnect: Authentication failed, stopping`)
127
- shouldStopReconnect = true
128
- if (pendingReconnectTimer !== null) {
129
- clearTimeout(pendingReconnectTimer)
130
- pendingReconnectTimer = null
131
- }
132
- return
204
+ log?.error?.(`Reconnect: Authentication failed, stopping`);
205
+ shouldStopReconnect = true;
206
+ if (pendingReconnectTimer !== null) {
207
+ clearTimeout(pendingReconnectTimer);
208
+ pendingReconnectTimer = null;
209
+ }
210
+ return false; // 不重试
133
211
  }
134
212
 
135
- scheduleReconnect()
136
- }
137
- }, delayMs)
138
- }
213
+ // 其他错误:重试
214
+ return true;
215
+ }
139
216
 
140
- const stopReconnect = (): void => {
141
- shouldStopReconnect = true
142
- if (pendingReconnectTimer !== null) {
143
- clearTimeout(pendingReconnectTimer)
144
- pendingReconnectTimer = null
217
+ /**
218
+ * 首次连接或重连:调用 doConnect(),返回其 promise
219
+ * 如果 doConnect() 因失败正在重试(promise pending),返回同一 promise
220
+ * 调用方 await 此方法,连接建立后 resolve,失败且不重试时 reject
221
+ */
222
+ async function connect(): Promise<void> {
223
+ // 如果 doConnect() 正在进行中(失败后重试期间),返回同一 promise 让 caller 等待
224
+ if (pendingDoConnectPromise !== null) {
225
+ return pendingDoConnectPromise;
226
+ }
227
+ try {
228
+ return await doConnect();
229
+ } catch (err) {
230
+ const errorStr = String(err);
231
+ log?.error?.(`Gateway connect failed: ${errorStr}`);
232
+ const willRetry = handleConnectError(errorStr);
233
+ if (!willRetry) {
234
+ // 不重试时,清除 pending promise,后续 connect() 可创建新的
235
+ pendingDoConnectPromise = null;
236
+ throw err;
237
+ }
238
+ // handleConnectError 返回 true 时,安排后续重试
239
+ // 注意:pendingDoConnectPromise 仍 pending,下次定时器触发时会清除它再真正重连
240
+ scheduleReconnect();
241
+ }
145
242
  }
146
- }
147
243
 
148
- return { scheduleReconnect, stopReconnect }
244
+ const stopReconnect = () => {
245
+ shouldStopReconnect = true;
246
+ if (pendingReconnectTimer !== null) {
247
+ clearTimeout(pendingReconnectTimer);
248
+ pendingReconnectTimer = null;
249
+ }
250
+ };
251
+
252
+ return { connect, scheduleReconnect, stopReconnect };
149
253
  }