@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.
- package/LICENSE +2 -0
- package/README.md +412 -344
- package/README_EN.md +492 -0
- package/SKILL.md +45 -37
- package/assets/icon.png +0 -0
- package/dist/secret-contract-api.js +6 -0
- package/dist/src/access-policy.js +123 -0
- package/dist/src/accounts.js +170 -23
- package/dist/src/action-scope.js +213 -0
- package/dist/src/actions.js +159 -31
- package/dist/src/api.js +99 -83
- package/dist/src/callbacks.js +27 -7
- package/dist/src/channel-agent-prompt.js +8 -19
- package/dist/src/channel-config.js +25 -3
- package/dist/src/channel-lifecycle.js +28 -5
- package/dist/src/channel-outbound.js +43 -9
- package/dist/src/channel.js +16 -0
- package/dist/src/command-menu.js +199 -0
- package/dist/src/config-schema.js +106 -10
- package/dist/src/deliver.js +144 -48
- package/dist/src/dispatch.js +12 -11
- package/dist/src/gateway-drain.js +40 -0
- package/dist/src/group-mention.js +129 -0
- package/dist/src/inbound-attachments.js +162 -20
- package/dist/src/inbound.js +388 -134
- package/dist/src/media-temp.js +31 -20
- package/dist/src/network.js +216 -0
- package/dist/src/onboarding.js +4 -2
- package/dist/src/polling.js +180 -12
- package/dist/src/presentation.js +11 -0
- package/dist/src/progress-draft.js +168 -0
- package/dist/src/runtime.js +15 -0
- package/dist/src/secret-contract.js +149 -0
- package/dist/src/send.js +250 -46
- package/dist/src/stream-draft.js +83 -41
- package/dist/src/turn-adoption.js +24 -0
- package/dist/src/typing.js +55 -0
- package/dist/src/update-journal.js +267 -0
- package/dist/src/webhook-queue.js +151 -26
- package/dist/src/webhook-runner.js +61 -9
- package/dist/src/webhook.js +34 -15
- package/openclaw.plugin.json +986 -13
- package/package.json +25 -5
- package/src/access-policy.ts +154 -0
- package/src/accounts.ts +260 -25
- package/src/action-scope.ts +274 -0
- package/src/actions.ts +188 -35
- package/src/api.ts +158 -101
- package/src/callbacks.ts +26 -7
- package/src/channel-agent-prompt.ts +8 -19
- package/src/channel-config.ts +32 -3
- package/src/channel-lifecycle.ts +33 -6
- package/src/channel-outbound.ts +50 -7
- package/src/channel.ts +16 -0
- package/src/command-menu.ts +277 -0
- package/src/config-schema.ts +111 -10
- package/src/deliver.ts +174 -55
- package/src/dispatch.ts +12 -11
- package/src/gateway-drain.ts +45 -0
- package/src/group-mention.ts +191 -0
- package/src/inbound-attachments.ts +173 -22
- package/src/inbound.ts +443 -130
- package/src/media-temp.ts +35 -23
- package/src/network.ts +233 -0
- package/src/onboarding.ts +4 -2
- package/src/polling.ts +221 -15
- package/src/presentation.ts +14 -1
- package/src/progress-draft.ts +226 -0
- package/src/runtime.ts +23 -0
- package/src/secret-contract.ts +183 -0
- package/src/send.ts +346 -59
- package/src/stream-draft.ts +102 -41
- package/src/turn-adoption.ts +33 -0
- package/src/types.ts +9 -3
- package/src/typing.ts +77 -0
- package/src/update-journal.ts +302 -0
- package/src/webhook-queue.ts +205 -33
- package/src/webhook-runner.ts +65 -8
- package/src/webhook.ts +39 -18
- package/scripts/check-cycles.mjs +0 -38
- package/scripts/test-api.mjs +0 -169
- package/scripts/test-send.mjs +0 -162
- 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
|
|
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(
|
|
92
|
+
return successor(item);
|
|
54
93
|
const handover = handovers.get(accountId);
|
|
55
94
|
if (handover)
|
|
56
|
-
handover.updates.push(
|
|
95
|
+
handover.updates.push(item);
|
|
57
96
|
else
|
|
58
|
-
handovers.set(accountId, { updates: [
|
|
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(
|
|
101
|
+
inbox.push(item);
|
|
67
102
|
wake?.();
|
|
68
103
|
};
|
|
69
104
|
const siblings = openQueues.get(accountId) ?? new Set();
|
|
70
|
-
siblings.add(
|
|
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(
|
|
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
|
|
91
|
-
successor(
|
|
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
|
-
|
|
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 = (
|
|
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(
|
|
199
|
+
queued.push(item);
|
|
110
200
|
else
|
|
111
|
-
lanes.set(lane, [
|
|
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
|
|
121
|
-
if (!
|
|
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
|
|
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
|
-
|
|
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 {
|
|
12
|
-
import {
|
|
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
|
|
155
|
-
// (see webhook-queue.ts: dispatching from the request context
|
|
156
|
-
// GatewayDrainingError once the request's work admission is
|
|
157
|
-
|
|
158
|
-
const
|
|
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.
|
|
166
|
-
|
|
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, '-');
|