@aspalagin/openclaw-max 0.7.2 → 0.8.0

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 (83) hide show
  1. package/LICENSE +2 -0
  2. package/README.md +412 -344
  3. package/README_EN.md +492 -0
  4. package/SKILL.md +45 -37
  5. package/assets/icon.png +0 -0
  6. package/dist/secret-contract-api.js +6 -0
  7. package/dist/src/access-policy.js +123 -0
  8. package/dist/src/accounts.js +170 -23
  9. package/dist/src/action-scope.js +213 -0
  10. package/dist/src/actions.js +159 -31
  11. package/dist/src/api.js +99 -83
  12. package/dist/src/callbacks.js +27 -7
  13. package/dist/src/channel-agent-prompt.js +8 -19
  14. package/dist/src/channel-config.js +25 -3
  15. package/dist/src/channel-lifecycle.js +28 -5
  16. package/dist/src/channel-outbound.js +43 -9
  17. package/dist/src/channel.js +16 -0
  18. package/dist/src/command-menu.js +199 -0
  19. package/dist/src/config-schema.js +106 -10
  20. package/dist/src/deliver.js +144 -48
  21. package/dist/src/dispatch.js +12 -11
  22. package/dist/src/gateway-drain.js +40 -0
  23. package/dist/src/group-mention.js +129 -0
  24. package/dist/src/inbound-attachments.js +162 -20
  25. package/dist/src/inbound.js +388 -134
  26. package/dist/src/media-temp.js +31 -20
  27. package/dist/src/network.js +216 -0
  28. package/dist/src/onboarding.js +4 -2
  29. package/dist/src/polling.js +180 -12
  30. package/dist/src/presentation.js +11 -0
  31. package/dist/src/progress-draft.js +168 -0
  32. package/dist/src/runtime.js +15 -0
  33. package/dist/src/secret-contract.js +149 -0
  34. package/dist/src/send.js +250 -46
  35. package/dist/src/stream-draft.js +83 -41
  36. package/dist/src/turn-adoption.js +24 -0
  37. package/dist/src/typing.js +55 -0
  38. package/dist/src/update-journal.js +267 -0
  39. package/dist/src/webhook-queue.js +151 -26
  40. package/dist/src/webhook-runner.js +61 -9
  41. package/dist/src/webhook.js +34 -15
  42. package/openclaw.plugin.json +986 -13
  43. package/package.json +25 -5
  44. package/src/access-policy.ts +154 -0
  45. package/src/accounts.ts +260 -25
  46. package/src/action-scope.ts +274 -0
  47. package/src/actions.ts +188 -35
  48. package/src/api.ts +158 -101
  49. package/src/callbacks.ts +26 -7
  50. package/src/channel-agent-prompt.ts +8 -19
  51. package/src/channel-config.ts +32 -3
  52. package/src/channel-lifecycle.ts +33 -6
  53. package/src/channel-outbound.ts +50 -7
  54. package/src/channel.ts +16 -0
  55. package/src/command-menu.ts +277 -0
  56. package/src/config-schema.ts +111 -10
  57. package/src/deliver.ts +174 -55
  58. package/src/dispatch.ts +12 -11
  59. package/src/gateway-drain.ts +45 -0
  60. package/src/group-mention.ts +191 -0
  61. package/src/inbound-attachments.ts +173 -22
  62. package/src/inbound.ts +443 -130
  63. package/src/media-temp.ts +35 -23
  64. package/src/network.ts +233 -0
  65. package/src/onboarding.ts +4 -2
  66. package/src/polling.ts +221 -15
  67. package/src/presentation.ts +14 -1
  68. package/src/progress-draft.ts +226 -0
  69. package/src/runtime.ts +23 -0
  70. package/src/secret-contract.ts +183 -0
  71. package/src/send.ts +346 -59
  72. package/src/stream-draft.ts +102 -41
  73. package/src/turn-adoption.ts +33 -0
  74. package/src/types.ts +9 -3
  75. package/src/typing.ts +77 -0
  76. package/src/update-journal.ts +302 -0
  77. package/src/webhook-queue.ts +205 -33
  78. package/src/webhook-runner.ts +65 -8
  79. package/src/webhook.ts +39 -18
  80. package/scripts/check-cycles.mjs +0 -38
  81. package/scripts/test-api.mjs +0 -169
  82. package/scripts/test-send.mjs +0 -162
  83. package/scripts/update-schema.mjs +0 -48
@@ -0,0 +1,267 @@
1
+ /**
2
+ * Durable journal of MAX updates: accepted webhook updates survive a gateway
3
+ * restart or crash, and completion tombstones reject a redelivered update
4
+ * across restarts (webhook and polling).
5
+ *
6
+ * Why a plugin-owned journal: the SDK's durable ingress
7
+ * (createChannelIngressMonitor over runtime.state.openChannelIngressQueue) is
8
+ * refused to plugins that are neither bundled nor trusted official installs
9
+ * (PluginTrustRefusalError), and this plugin is neither. The journal follows
10
+ * the same contract: append before the webhook ack, one row per event,
11
+ * completion tombstones for replay dedupe, bounded retention.
12
+ *
13
+ * Layout: <stateDir>/max/inbox-<account>/ (0700)
14
+ * u-<seq>.json one pending update (0600), deleted once handled
15
+ * completed.json dedupe keys of handled updates with their completion time
16
+ *
17
+ * Update bodies are conversation content: they never reach the log.
18
+ */
19
+ import * as fs from 'node:fs/promises';
20
+ import * as path from 'node:path';
21
+ import { readJsonFileWithFallback, writeJsonFileAtomically } from 'openclaw/plugin-sdk/json-store';
22
+ import { resolveStateDir } from 'openclaw/plugin-sdk/state-paths';
23
+ /** How long a handled update's key rejects a redelivery (MAX retries within 8 h). */
24
+ export const MAX_JOURNAL_COMPLETED_TTL_MS = 24 * 60 * 60 * 1000;
25
+ /** Most dedupe keys kept; the oldest go first. */
26
+ export const MAX_JOURNAL_COMPLETED_MAX_ENTRIES = 5000;
27
+ /** Unknown files (e.g. a temp file of a write cut by a crash) older than this are removed. */
28
+ const STRAY_FILE_MIN_AGE_MS = 60_000;
29
+ const ENTRY_FILE = /^u-(\d{13}-\d{6})\.json$/;
30
+ const COMPLETED_FILE = 'completed.json';
31
+ export function resolveMaxJournalDir(accountId) {
32
+ const safeId = accountId.replace(/[^a-zA-Z0-9_-]/g, '_');
33
+ return path.join(resolveStateDir(), 'max', `inbox-${safeId}`);
34
+ }
35
+ /** One journal per directory in a process: overlapping account tasks share ownership. */
36
+ const openJournals = new Map();
37
+ /**
38
+ * Open (or reuse) the journal of an account. Rejects when the directory cannot
39
+ * be created or written; callers then fall back to the in-memory queue.
40
+ */
41
+ export async function openMaxUpdateJournal(options) {
42
+ const dir = options.dir ?? resolveMaxJournalDir(options.accountId);
43
+ const existing = openJournals.get(dir);
44
+ if (existing) {
45
+ if (options.warn)
46
+ existing.warn = options.warn;
47
+ return existing;
48
+ }
49
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 });
50
+ await fs.chmod(dir, 0o700);
51
+ await fs.access(dir, fs.constants.W_OK);
52
+ const journal = new MaxUpdateJournal(dir, options);
53
+ await journal.loadCompleted();
54
+ const raced = openJournals.get(dir);
55
+ if (raced)
56
+ return raced;
57
+ openJournals.set(dir, journal);
58
+ return journal;
59
+ }
60
+ export class MaxUpdateJournal {
61
+ dir;
62
+ warn;
63
+ now;
64
+ completedTtlMs;
65
+ completedMaxEntries;
66
+ /** Dedupe key → completion time, oldest first. */
67
+ completed = new Map();
68
+ /** Entries this process holds (queued or dispatching): recovery skips them. */
69
+ owned = new Set();
70
+ /** Keys of entries still on disk. */
71
+ pendingKeys = new Set();
72
+ lastSeqMs = 0;
73
+ seqCounter = 0;
74
+ scheduledWrite;
75
+ lastWrite = Promise.resolve();
76
+ constructor(dir, options) {
77
+ this.dir = dir;
78
+ this.warn = options.warn;
79
+ this.now = options.now ?? Date.now;
80
+ this.completedTtlMs = options.completedTtlMs ?? MAX_JOURNAL_COMPLETED_TTL_MS;
81
+ this.completedMaxEntries = options.completedMaxEntries ?? MAX_JOURNAL_COMPLETED_MAX_ENTRIES;
82
+ }
83
+ /** @internal */
84
+ async loadCompleted() {
85
+ const file = path.join(this.dir, COMPLETED_FILE);
86
+ const { value, exists } = await readJsonFileWithFallback(file, null);
87
+ const keys = value && typeof value === 'object' ? value.keys : undefined;
88
+ if (!keys || typeof keys !== 'object' || Array.isArray(keys)) {
89
+ if (exists)
90
+ this.warn?.(`MAX journal ${this.dir}: unreadable ${COMPLETED_FILE}, starting empty`);
91
+ return;
92
+ }
93
+ const entries = Object.entries(keys)
94
+ .filter((entry) => typeof entry[1] === 'number')
95
+ .sort((a, b) => a[1] - b[1]);
96
+ for (const [key, at] of entries)
97
+ this.completed.set(key, at);
98
+ this.pruneCompleted();
99
+ }
100
+ /** True when the key belongs to a pending update or was handled within the TTL. */
101
+ isDuplicate(key) {
102
+ return this.pendingKeys.has(key) || this.hasCompleted(key);
103
+ }
104
+ hasCompleted(key) {
105
+ const at = this.completed.get(key);
106
+ return at !== undefined && this.now() - at < this.completedTtlMs;
107
+ }
108
+ /** Durably record an accepted update; resolves once it is on disk. */
109
+ async append(update, key) {
110
+ const receivedAt = this.now();
111
+ const entry = {
112
+ v: 1,
113
+ seq: this.nextSeq(receivedAt),
114
+ receivedAt,
115
+ ...(key ? { key } : {}),
116
+ update,
117
+ };
118
+ // Owned before the file exists, so a concurrent recovery never takes it.
119
+ this.owned.add(entry.seq);
120
+ if (key)
121
+ this.pendingKeys.add(key);
122
+ try {
123
+ await writeJsonFileAtomically(this.entryPath(entry.seq), entry);
124
+ }
125
+ catch (err) {
126
+ this.owned.delete(entry.seq);
127
+ if (key)
128
+ this.pendingKeys.delete(key);
129
+ await fs.rm(this.entryPath(entry.seq), { force: true }).catch(() => undefined);
130
+ throw err;
131
+ }
132
+ return entry;
133
+ }
134
+ /**
135
+ * Entries on disk that this process does not hold (left by an earlier
136
+ * process), oldest first. They become owned. Unreadable entries are deleted
137
+ * and counted.
138
+ */
139
+ async readPending() {
140
+ const names = await fs.readdir(this.dir);
141
+ const entries = [];
142
+ let unreadable = 0;
143
+ for (const name of names.sort()) {
144
+ const match = ENTRY_FILE.exec(name);
145
+ if (!match) {
146
+ await this.removeStray(name);
147
+ continue;
148
+ }
149
+ const seq = match[1];
150
+ if (this.owned.has(seq))
151
+ continue;
152
+ const file = this.entryPath(seq);
153
+ const { value } = await readJsonFileWithFallback(file, null);
154
+ if (!isJournalEntry(value, seq)) {
155
+ unreadable += 1;
156
+ await fs.rm(file, { force: true }).catch(() => undefined);
157
+ continue;
158
+ }
159
+ this.owned.add(seq);
160
+ if (value.key)
161
+ this.pendingKeys.add(value.key);
162
+ entries.push(value);
163
+ }
164
+ return { entries, unreadable };
165
+ }
166
+ /**
167
+ * The update was handled (or deliberately skipped): remember its key, then
168
+ * delete the entry. A failed tombstone write is logged, the entry is still
169
+ * deleted — replaying a handled update on every start would be worse.
170
+ */
171
+ async complete(entry) {
172
+ if (entry.key)
173
+ await this.remember(entry.key);
174
+ try {
175
+ await fs.rm(this.entryPath(entry.seq), { force: true });
176
+ }
177
+ catch (err) {
178
+ this.warn?.(`MAX journal: could not delete handled entry ${entry.seq}: ${errorCode(err)}`);
179
+ }
180
+ this.owned.delete(entry.seq);
181
+ if (entry.key)
182
+ this.pendingKeys.delete(entry.key);
183
+ }
184
+ /** Stop holding an entry without handling it: the next recovery takes it. */
185
+ release(entry) {
186
+ this.owned.delete(entry.seq);
187
+ }
188
+ /** Record a handled update's key (polling uses this without an entry). */
189
+ async remember(key) {
190
+ this.completed.delete(key);
191
+ this.completed.set(key, this.now());
192
+ this.pruneCompleted();
193
+ try {
194
+ await this.saveCompleted();
195
+ }
196
+ catch (err) {
197
+ this.warn?.(`MAX journal: could not save ${COMPLETED_FILE}: ${errorCode(err)}`);
198
+ }
199
+ }
200
+ pruneCompleted() {
201
+ const cutoff = this.now() - this.completedTtlMs;
202
+ for (const [key, at] of this.completed) {
203
+ if (at > cutoff && this.completed.size <= this.completedMaxEntries)
204
+ break;
205
+ this.completed.delete(key);
206
+ }
207
+ }
208
+ /** Coalesced, serialized writes: a caller resolves once a write with its change is done. */
209
+ saveCompleted() {
210
+ if (this.scheduledWrite)
211
+ return this.scheduledWrite;
212
+ const write = this.lastWrite
213
+ .catch(() => undefined)
214
+ .then(async () => {
215
+ this.scheduledWrite = undefined;
216
+ const file = { v: 1, keys: Object.fromEntries(this.completed) };
217
+ await writeJsonFileAtomically(path.join(this.dir, COMPLETED_FILE), file);
218
+ });
219
+ this.scheduledWrite = write;
220
+ this.lastWrite = write;
221
+ return write;
222
+ }
223
+ async removeStray(name) {
224
+ if (name === COMPLETED_FILE)
225
+ return;
226
+ const file = path.join(this.dir, name);
227
+ try {
228
+ const stat = await fs.lstat(file);
229
+ if (stat.isFile() && this.now() - stat.mtimeMs > STRAY_FILE_MIN_AGE_MS) {
230
+ await fs.rm(file, { force: true });
231
+ }
232
+ }
233
+ catch {
234
+ // Gone meanwhile or not ours to judge: leave it.
235
+ }
236
+ }
237
+ nextSeq(at) {
238
+ const ms = Math.max(at, this.lastSeqMs);
239
+ this.seqCounter = ms === this.lastSeqMs ? this.seqCounter + 1 : 0;
240
+ this.lastSeqMs = ms;
241
+ return `${String(ms).padStart(13, '0')}-${String(this.seqCounter).padStart(6, '0')}`;
242
+ }
243
+ entryPath(seq) {
244
+ return path.join(this.dir, `u-${seq}.json`);
245
+ }
246
+ }
247
+ function isJournalEntry(value, seq) {
248
+ if (!value || typeof value !== 'object')
249
+ return false;
250
+ const entry = value;
251
+ return (entry.v === 1 &&
252
+ entry.seq === seq &&
253
+ typeof entry.receivedAt === 'number' &&
254
+ (entry.key === undefined || typeof entry.key === 'string') &&
255
+ !!entry.update &&
256
+ typeof entry.update === 'object' &&
257
+ typeof entry.update.update_type === 'string');
258
+ }
259
+ /** Error code only: messages of fs errors carry paths, never needed in logs here. */
260
+ function errorCode(err) {
261
+ const code = err?.code;
262
+ return typeof code === 'string' ? code : String(err);
263
+ }
264
+ /** @internal test helper: forget cached journals. */
265
+ export function resetMaxUpdateJournalsForTest() {
266
+ openJournals.clear();
267
+ }
@@ -11,6 +11,8 @@
11
11
  * admitted like the polling loop's. Every dispatch therefore has to start
12
12
  * from the consumer (`consume()`), never from `push()`.
13
13
  */
14
+ import { isMaxGatewayDrainingError, waitForMaxDrainRetry } from './gateway-drain.js';
15
+ import { runWithMaxTurnAdoption } from './turn-adoption.js';
14
16
  /** Updates handled at once across chats; one chat is always sequential. */
15
17
  export const MAX_WEBHOOK_CHAT_CONCURRENCY = 4;
16
18
  /** Accepted-but-unprocessed updates kept per account before new ones are dropped. */
@@ -19,6 +21,40 @@ export const MAX_WEBHOOK_PENDING_LIMIT = 5000;
19
21
  export const MAX_WEBHOOK_STOP_GRACE_MS = 3000;
20
22
  /** How long a restarted consumer waits for its predecessor's in-flight dispatches. */
21
23
  export const MAX_WEBHOOK_HANDOVER_WAIT_MS = 60_000;
24
+ /** Default age past which updates the bot would answer are skipped (maxEventAgeMinutes). */
25
+ export const MAX_EVENT_MAX_AGE_MINUTES = 60;
26
+ /** Updates a late answer would confuse; registry updates (bot_added, …) are always handled. */
27
+ const AGE_FENCED_UPDATE_TYPES = new Set([
28
+ 'message_created',
29
+ 'message_edited',
30
+ 'message_callback',
31
+ 'bot_started',
32
+ ]);
33
+ /** maxEventAgeMinutes in ms; 0 (or less) turns the age fence off. */
34
+ export function resolveMaxEventMaxAgeMs(minutes) {
35
+ const value = minutes ?? MAX_EVENT_MAX_AGE_MINUTES;
36
+ return value > 0 ? value * 60_000 : 0;
37
+ }
38
+ /**
39
+ * Age of an update the bot would answer, when it is past `maxAgeMs`: MAX's
40
+ * event time, else when it was received. Undefined when fresh or not fenced.
41
+ */
42
+ export function staleMaxUpdateAgeMs(update, maxAgeMs, now, receivedAt) {
43
+ if (maxAgeMs <= 0 || !AGE_FENCED_UPDATE_TYPES.has(update.update_type))
44
+ return undefined;
45
+ const at = typeof update.timestamp === 'number' ? update.timestamp : receivedAt;
46
+ if (at === undefined)
47
+ return undefined;
48
+ const age = now - at;
49
+ return age > maxAgeMs ? age : undefined;
50
+ }
51
+ /** The queue could not take an update: the route answers non-200 so MAX redelivers. */
52
+ export class MaxWebhookQueueRejectedError extends Error {
53
+ constructor(message) {
54
+ super(message);
55
+ this.name = 'MaxWebhookQueueRejectedError';
56
+ }
57
+ }
22
58
  /** Serialization lane: the chat an update belongs to. */
23
59
  export function maxWebhookUpdateLane(update) {
24
60
  return String(update.message?.recipient?.chat_id ?? update.chat_id ?? 'global');
@@ -32,7 +68,10 @@ const handovers = new Map();
32
68
  /** Open queues per account: a stopping task hands leftovers to an overlapping successor. */
33
69
  const openQueues = new Map();
34
70
  export function createMaxWebhookUpdateQueue(params) {
35
- const { accountId } = params;
71
+ const { accountId, journal } = params;
72
+ const overflow = params.overflow ?? 'reject';
73
+ const maxAgeMs = params.maxAgeMs ?? 0;
74
+ const now = params.now ?? Date.now;
36
75
  const concurrency = Math.max(1, params.concurrency ?? MAX_WEBHOOK_CHAT_CONCURRENCY);
37
76
  const pendingLimit = params.pendingLimit ?? MAX_WEBHOOK_PENDING_LIMIT;
38
77
  const stopGraceMs = params.stopGraceMs ?? MAX_WEBHOOK_STOP_GRACE_MS;
@@ -44,38 +83,88 @@ export function createMaxWebhookUpdateQueue(params) {
44
83
  let pending = inbox.length;
45
84
  let closed = false;
46
85
  let wake;
47
- let warn;
48
- const push = (update) => {
86
+ let warn = params.onWarn;
87
+ const enqueue = (item) => {
49
88
  if (closed) {
50
89
  // Route still selected this target while the task was stopping.
51
90
  const successor = [...siblings].at(-1);
52
91
  if (successor)
53
- return successor(update);
92
+ return successor(item);
54
93
  const handover = handovers.get(accountId);
55
94
  if (handover)
56
- handover.updates.push(update);
95
+ handover.updates.push(item);
57
96
  else
58
- handovers.set(accountId, { updates: [update], settled: Promise.resolve() });
59
- return;
60
- }
61
- if (pending >= pendingLimit) {
62
- warn?.(`[${accountId}] MAX webhook queue full (${pendingLimit}); dropping ${update.update_type}`);
97
+ handovers.set(accountId, { updates: [item], settled: Promise.resolve() });
63
98
  return;
64
99
  }
65
100
  pending += 1;
66
- inbox.push(update);
101
+ inbox.push(item);
67
102
  wake?.();
68
103
  };
69
104
  const siblings = openQueues.get(accountId) ?? new Set();
70
- siblings.add(push);
105
+ siblings.add(enqueue);
71
106
  openQueues.set(accountId, siblings);
107
+ const push = (update) => {
108
+ if (!closed && pending >= pendingLimit) {
109
+ warn?.(`[${accountId}] MAX webhook queue full (${pendingLimit}); dropping ${update.update_type}`);
110
+ return;
111
+ }
112
+ enqueue({ update });
113
+ };
114
+ const admit = async (update, dedupeKey) => {
115
+ const staleMs = staleMaxUpdateAgeMs(update, maxAgeMs, now());
116
+ if (staleMs !== undefined) {
117
+ warn?.(`[${accountId}] MAX webhook: ${update.update_type} from ${Math.round(staleMs / 60_000)} min ago skipped (older than maxEventAgeMinutes)`);
118
+ return 'stale';
119
+ }
120
+ if (journal && dedupeKey && journal.isDuplicate(dedupeKey))
121
+ return 'duplicate';
122
+ if (!closed && pending >= pendingLimit) {
123
+ if (overflow === 'reject') {
124
+ throw new MaxWebhookQueueRejectedError(`MAX webhook queue full (${pendingLimit} pending); ${update.update_type} refused for redelivery`);
125
+ }
126
+ warn?.(`[${accountId}] MAX webhook queue full (${pendingLimit}); dropping ${update.update_type}`);
127
+ return 'dropped';
128
+ }
129
+ const entry = journal ? await journal.append(update, dedupeKey) : undefined;
130
+ enqueue({ update, entry });
131
+ return 'queued';
132
+ };
133
+ const recover = async () => {
134
+ if (!journal)
135
+ return { recovered: 0, stale: 0, unreadable: 0 };
136
+ const { entries, unreadable } = await journal.readPending();
137
+ const recovered = [];
138
+ let stale = 0;
139
+ for (const entry of entries) {
140
+ if (staleMaxUpdateAgeMs(entry.update, maxAgeMs, now(), entry.receivedAt) !== undefined) {
141
+ stale += 1;
142
+ await journal.complete(entry);
143
+ continue;
144
+ }
145
+ recovered.push({ update: entry.update, entry });
146
+ }
147
+ if (recovered.length > 0) {
148
+ if (closed) {
149
+ for (const item of recovered)
150
+ enqueue(item);
151
+ }
152
+ else {
153
+ // An earlier process accepted these before anything queued here.
154
+ inbox.unshift(...recovered);
155
+ pending += recovered.length;
156
+ wake?.();
157
+ }
158
+ }
159
+ return { recovered: recovered.length, stale, unreadable };
160
+ };
72
161
  /** Stop accepting and pass undispatched updates (lanes first, then inbox) on. */
73
162
  const close = (fromLanes, settled) => {
74
163
  if (closed)
75
164
  return;
76
165
  closed = true;
77
166
  wake = undefined;
78
- siblings.delete(push);
167
+ siblings.delete(enqueue);
79
168
  if (siblings.size === 0 && openQueues.get(accountId) === siblings) {
80
169
  openQueues.delete(accountId);
81
170
  }
@@ -87,8 +176,8 @@ export function createMaxWebhookUpdateQueue(params) {
87
176
  const successor = [...siblings].at(-1);
88
177
  if (successor) {
89
178
  // A newer task of this account is already consuming: queue there.
90
- for (const update of leftover)
91
- successor(update);
179
+ for (const item of leftover)
180
+ successor(item);
92
181
  warn?.(`[${accountId}] MAX webhook stopped with ${leftover.length} update(s) not dispatched; moved to the running successor`);
93
182
  return;
94
183
  }
@@ -96,37 +185,73 @@ export function createMaxWebhookUpdateQueue(params) {
96
185
  handovers.set(accountId, { updates: [...leftover, ...late], settled });
97
186
  warn?.(`[${accountId}] MAX webhook stopped with ${leftover.length} update(s) not dispatched; handed over to the next start`);
98
187
  };
99
- const consume = async ({ dispatch, abortSignal, onError, onWarn, }) => {
100
- warn = onWarn;
188
+ const consume = async ({ dispatch, abortSignal, onError, onWarn, onInfo, }) => {
189
+ if (onWarn)
190
+ warn = onWarn;
101
191
  const lanes = new Map();
102
192
  const ready = [];
103
193
  const active = new Set();
104
194
  const inflight = new Set();
105
- const route = (update) => {
106
- const lane = maxWebhookUpdateLane(update);
195
+ const route = (item) => {
196
+ const lane = maxWebhookUpdateLane(item.update);
107
197
  const queued = lanes.get(lane);
108
198
  if (queued)
109
- queued.push(update);
199
+ queued.push(item);
110
200
  else
111
- lanes.set(lane, [update]);
201
+ lanes.set(lane, [item]);
112
202
  if (!active.has(lane) && !ready.includes(lane))
113
203
  ready.push(lane);
114
204
  };
205
+ /** Back to the head of its lane, ahead of the chat's later updates; after the stop, passed on. */
206
+ const requeueFirst = (lane, item) => {
207
+ if (closed)
208
+ return enqueue(item);
209
+ pending += 1;
210
+ const queued = lanes.get(lane);
211
+ if (queued)
212
+ queued.unshift(item);
213
+ else
214
+ lanes.set(lane, [item]);
215
+ };
115
216
  const runLane = async (lane) => {
116
217
  try {
117
218
  for (;;) {
118
219
  if (abortSignal.aborted)
119
220
  return;
120
- const update = lanes.get(lane)?.shift();
121
- if (!update)
221
+ const item = lanes.get(lane)?.shift();
222
+ if (!item)
122
223
  return;
123
224
  pending -= 1;
225
+ const { entry } = item;
226
+ let completion;
227
+ // At core's turn adoption (core then owns crash recovery) or when the
228
+ // dispatch returns, whichever is first. A failed turn is not replayed.
229
+ const complete = () => (completion ??= entry && journal ? journal.complete(entry) : Promise.resolve());
124
230
  try {
125
- await dispatch(update);
231
+ await (entry
232
+ ? runWithMaxTurnAdoption(complete, () => dispatch(item.update))
233
+ : dispatch(item.update));
234
+ if (item.refusals) {
235
+ onInfo?.(`[${accountId}] MAX webhook: ${item.update.update_type} accepted after ${item.refusals} refusal(s) of the draining gateway`);
236
+ }
126
237
  }
127
238
  catch (err) {
128
- onError(err, update);
239
+ // Refused by the draining gateway before core adopted the turn: the
240
+ // turn never started. The update stays at the head of its chat and
241
+ // is retried after a pause; a stop meanwhile hands it over with the
242
+ // leftovers and its journal row stays on disk for the next process.
243
+ if (entry && !completion && isMaxGatewayDrainingError(err)) {
244
+ item.refusals = (item.refusals ?? 0) + 1;
245
+ if (item.refusals === 1) {
246
+ warn?.(`[${accountId}] MAX webhook: the gateway is draining and refused ${item.update.update_type}; kept queued, retried until accepted or the channel stops (then after the restart)`);
247
+ }
248
+ requeueFirst(lane, item);
249
+ await waitForMaxDrainRetry(item.refusals, abortSignal);
250
+ continue;
251
+ }
252
+ onError(err, item.update);
129
253
  }
254
+ await complete();
130
255
  }
131
256
  }
132
257
  finally {
@@ -183,7 +308,7 @@ export function createMaxWebhookUpdateQueue(params) {
183
308
  await waitAtMost(settled, stopGraceMs);
184
309
  }
185
310
  };
186
- return { push, consume, close: () => close([], Promise.resolve()) };
311
+ return { admit, push, recover, consume, close: () => close([], Promise.resolve()) };
187
312
  }
188
313
  /** Resolves when `promise` settles, `ms` elapse or `signal` aborts — whichever is first. */
189
314
  async function waitAtMost(promise, ms, signal) {
@@ -8,8 +8,9 @@ import { channelReadyPatch } from 'openclaw/plugin-sdk/gateway-runtime';
8
8
  import { readSecretFile } from './accounts.js';
9
9
  import { dispatchUpdate } from './dispatch.js';
10
10
  import { MAX_SUBSCRIBED_UPDATE_TYPES, } from './monitor-types.js';
11
- import { registerMaxWebhookRoute, registerMaxWebhookTarget, resolveMaxWebhookPath, subscribeMaxWebhook, } from './webhook.js';
12
- import { createMaxWebhookUpdateQueue } from './webhook-queue.js';
11
+ import { openMaxUpdateJournal } from './update-journal.js';
12
+ import { maxUpdateDedupeKey, registerMaxWebhookRoute, registerMaxWebhookTarget, resolveMaxWebhookPath, subscribeMaxWebhook, } from './webhook.js';
13
+ import { createMaxWebhookUpdateQueue, resolveMaxEventMaxAgeMs } from './webhook-queue.js';
13
14
  /** MAX secret format (SubscriptionRequestBody.secret): 5–256 of [A-Za-z0-9_-]. */
14
15
  const MAX_WEBHOOK_SECRET_PATTERN = /^[\w-]{5,256}$/;
15
16
  /**
@@ -26,6 +27,10 @@ export async function resolveMaxWebhookSecret(account, state) {
26
27
  }
27
28
  return secret;
28
29
  };
30
+ // A webhookSecret SecretRef that did not resolve never falls back to the
31
+ // file or a generated secret.
32
+ if (account.secretErrors?.webhookSecret)
33
+ throw new Error(account.secretErrors.webhookSecret);
29
34
  const configured = account.config.webhookSecret?.trim();
30
35
  if (configured)
31
36
  return validate(configured, 'webhookSecret');
@@ -151,19 +156,43 @@ export async function startMaxWebhook(opts) {
151
156
  lastTransportActivityAt: null,
152
157
  });
153
158
  // MAX requires HTTP 200 within 30s while agent runs regularly take minutes.
154
- // The route handler acks and only enqueues; this account task dispatches
155
- // (see webhook-queue.ts: dispatching from the request context fails with
156
- // GatewayDrainingError once the request's work admission is released).
157
- const queue = createMaxWebhookUpdateQueue({ accountId: account.accountId });
158
- const onUpdate = (update) => {
159
+ // The route handler records and enqueues before the ack; this account task
160
+ // dispatches (see webhook-queue.ts: dispatching from the request context
161
+ // fails with GatewayDrainingError once the request's work admission is
162
+ // released).
163
+ const journal = await openWebhookJournal(opts);
164
+ const queueConfig = account.config.webhookQueue;
165
+ const queue = createMaxWebhookUpdateQueue({
166
+ accountId: account.accountId,
167
+ journal,
168
+ pendingLimit: queueConfig?.maxPending,
169
+ overflow: queueConfig?.overflow,
170
+ maxAgeMs: resolveMaxEventMaxAgeMs(account.config.maxEventAgeMinutes),
171
+ onWarn: (message) => log?.warn(message),
172
+ });
173
+ if (journal) {
174
+ // Before the route exists: what an earlier process accepted goes first.
175
+ try {
176
+ const { recovered, stale, unreadable } = await queue.recover();
177
+ if (recovered + stale + unreadable > 0) {
178
+ log?.warn(`[${account.accountId}] MAX webhook queue: ${recovered} update(s) accepted before the restart queued again, ${stale} stale skipped (maxEventAgeMinutes), ${unreadable} unreadable dropped`);
179
+ }
180
+ }
181
+ catch (err) {
182
+ log?.error(`[${account.accountId}] MAX webhook queue recovery failed: ${String(err)}`);
183
+ }
184
+ }
185
+ const onUpdate = async (update) => {
159
186
  const at = Date.now();
160
187
  statusSink?.(channelReadyPatch({
161
188
  lastConnectedAt: at,
162
189
  lastEventAt: at,
163
190
  mode: 'webhook',
164
191
  }));
165
- queue.push(update);
166
- return Promise.resolve();
192
+ const result = await queue.admit(update, maxUpdateDedupeKey(update));
193
+ if (result === 'duplicate') {
194
+ log?.debug?.(`[${account.accountId}] MAX webhook: duplicate ${update.update_type} skipped`);
195
+ }
167
196
  };
168
197
  const target = {
169
198
  account,
@@ -216,6 +245,7 @@ export async function startMaxWebhook(opts) {
216
245
  abortSignal,
217
246
  onError: (err, update) => log?.error(`[${account.accountId}] Webhook update dispatch failed (${update.update_type}): ${String(err)}`),
218
247
  onWarn: (message) => log?.warn(message),
248
+ onInfo: (message) => log?.info(message),
219
249
  });
220
250
  stopSubscriptionWatch();
221
251
  // The subscription is kept on purpose: a restart or config reload must not
@@ -227,6 +257,28 @@ export async function startMaxWebhook(opts) {
227
257
  statusSink?.({ mode: 'webhook', connected: false });
228
258
  log?.info(`[${account.accountId}] MAX webhook mode stopped (subscription kept)`);
229
259
  }
260
+ /**
261
+ * The account's durable webhook journal, or undefined for the in-memory queue
262
+ * (webhookQueue.mode "memory", or the state directory is not writable).
263
+ */
264
+ async function openWebhookJournal(opts) {
265
+ const { account, log } = opts;
266
+ if (account.config.webhookQueue?.mode === 'memory') {
267
+ log?.info(`[${account.accountId}] MAX webhook queue in memory (webhookQueue.mode "memory"): updates acknowledged but not yet handled are lost on restart`);
268
+ return undefined;
269
+ }
270
+ try {
271
+ return await openMaxUpdateJournal({
272
+ accountId: account.accountId,
273
+ warn: (message) => log?.warn(`[${account.accountId}] ${message}`),
274
+ });
275
+ }
276
+ catch (err) {
277
+ const code = err?.code;
278
+ log?.warn(`[${account.accountId}] MAX webhook durable queue unavailable (${typeof code === 'string' ? code : String(err)}); using the in-memory queue — updates acknowledged but not yet handled are lost on restart`);
279
+ return undefined;
280
+ }
281
+ }
230
282
  /** MAX webhook secret: 5–256 chars of [A-Za-z0-9-]. */
231
283
  function generateWebhookSecret() {
232
284
  return randomBytes(24).toString('base64url').replace(/_/g, '-');