@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
package/src/stream-draft.ts
CHANGED
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Edit streaming (streamMode "partial"): the first partial reply is sent as a
|
|
3
3
|
* message, later partials edit it (throttled), and the final answer replaces
|
|
4
|
-
* the draft together with its inline keyboard.
|
|
4
|
+
* the draft together with its inline keyboard. The draft holds one MAX
|
|
5
|
+
* message: a longer final answer is split by the reply delivery, which puts
|
|
6
|
+
* the first chunk into the draft and sends the rest as new messages.
|
|
5
7
|
*/
|
|
6
8
|
|
|
7
9
|
import type { ChannelLogSink } from 'openclaw/plugin-sdk/channel-contract';
|
|
8
10
|
|
|
9
11
|
import type { ResolvedMaxAccount } from './accounts.js';
|
|
10
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
deleteMaxMessage,
|
|
14
|
+
editMaxMessage,
|
|
15
|
+
type MaxSendButton,
|
|
16
|
+
resolveMaxSendFlags,
|
|
17
|
+
sendMaxMessage,
|
|
18
|
+
} from './send.js';
|
|
11
19
|
|
|
12
20
|
const DRAFT_THROTTLE_MS = 1200;
|
|
13
21
|
const DRAFT_MAX_CHARS = 4000;
|
|
@@ -16,12 +24,28 @@ const DRAFT_MIN_CHARS = 30; // Don't send until we have enough text
|
|
|
16
24
|
export interface MaxDraftStream {
|
|
17
25
|
/** Mid of the draft message once the first partial was sent. */
|
|
18
26
|
readonly messageId: string | null;
|
|
27
|
+
/** The draft carries a final text (finalize succeeded). */
|
|
28
|
+
readonly finalized: boolean;
|
|
19
29
|
/** Show a partial reply (throttled; stops on errors or past 4000 chars). */
|
|
20
30
|
update(text: string): Promise<void>;
|
|
21
|
-
/**
|
|
22
|
-
|
|
31
|
+
/**
|
|
32
|
+
* Replace the draft with the final text (at most one MAX message); buttons
|
|
33
|
+
* go onto the same edit. Resolves false when MAX refused the edit.
|
|
34
|
+
*/
|
|
35
|
+
finalize(text: string, buttons: MaxSendButton[][] | undefined): Promise<boolean>;
|
|
36
|
+
/**
|
|
37
|
+
* Delete the draft and stop streaming (its final edit was refused, or the
|
|
38
|
+
* reply went as voice only); resolves false when that fails too.
|
|
39
|
+
*/
|
|
40
|
+
discard(): Promise<boolean>;
|
|
23
41
|
/** Cancel a pending update and stop streaming. */
|
|
24
42
|
clear(): Promise<void>;
|
|
43
|
+
/**
|
|
44
|
+
* The final answer is coming: stop streaming and wait for a send or edit in
|
|
45
|
+
* flight, so `messageId` is settled (a draft still being sent is not left
|
|
46
|
+
* behind as a second message).
|
|
47
|
+
*/
|
|
48
|
+
settle(): Promise<void>;
|
|
25
49
|
}
|
|
26
50
|
|
|
27
51
|
/** Draft stream for edit-streaming (like Telegram's partial reply approach). */
|
|
@@ -38,6 +62,19 @@ export function createMaxDraftStream(params: {
|
|
|
38
62
|
let draftLastEditAt = 0;
|
|
39
63
|
let draftTimer: ReturnType<typeof setTimeout> | null = null;
|
|
40
64
|
let draftStopped = false;
|
|
65
|
+
let draftFinalized = false;
|
|
66
|
+
// Sends and edits run one at a time: a deferred update or the final never
|
|
67
|
+
// races the first send (which would leave a second, orphaned draft).
|
|
68
|
+
let inflight: Promise<void> = Promise.resolve();
|
|
69
|
+
|
|
70
|
+
const stop = async (): Promise<void> => {
|
|
71
|
+
if (draftTimer) {
|
|
72
|
+
clearTimeout(draftTimer);
|
|
73
|
+
draftTimer = null;
|
|
74
|
+
}
|
|
75
|
+
draftStopped = true;
|
|
76
|
+
await inflight;
|
|
77
|
+
};
|
|
41
78
|
|
|
42
79
|
const draftUpdate = async (text: string) => {
|
|
43
80
|
if (draftStopped || !text) return;
|
|
@@ -68,58 +105,82 @@ export function createMaxDraftStream(params: {
|
|
|
68
105
|
draftLastText = trimmed;
|
|
69
106
|
draftLastEditAt = now;
|
|
70
107
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
108
|
+
const run = inflight.then(async () => {
|
|
109
|
+
// Stopped meanwhile (the final answer took over).
|
|
110
|
+
if (draftStopped) return;
|
|
111
|
+
try {
|
|
112
|
+
if (!draftMid) {
|
|
113
|
+
// First chunk — send new message
|
|
114
|
+
// The draft is the message the user is notified about: account defaults apply.
|
|
115
|
+
const res = await sendMaxMessage(chatId, trimmed, {
|
|
116
|
+
token: account.token,
|
|
117
|
+
replyToMessageId: params.replyToId,
|
|
118
|
+
format: 'markdown',
|
|
119
|
+
...resolveMaxSendFlags(account.config),
|
|
120
|
+
});
|
|
121
|
+
draftMid = res.messageId || null;
|
|
122
|
+
statusSink?.({ lastOutboundAt: Date.now() });
|
|
123
|
+
} else {
|
|
124
|
+
// Edit existing message
|
|
125
|
+
await editMaxMessage(draftMid, trimmed, {
|
|
126
|
+
token: account.token,
|
|
127
|
+
format: 'markdown',
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
} catch (err) {
|
|
131
|
+
draftStopped = true;
|
|
132
|
+
log?.debug?.(`[${account.accountId}] MAX draft stream failed: ${String(err)}`);
|
|
87
133
|
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
}
|
|
134
|
+
});
|
|
135
|
+
inflight = run;
|
|
136
|
+
await run;
|
|
92
137
|
};
|
|
93
138
|
|
|
94
139
|
return {
|
|
95
140
|
get messageId() {
|
|
96
141
|
return draftMid;
|
|
97
142
|
},
|
|
143
|
+
get finalized() {
|
|
144
|
+
return draftFinalized;
|
|
145
|
+
},
|
|
98
146
|
update: draftUpdate,
|
|
99
147
|
finalize: async (finalText, buttons) => {
|
|
100
148
|
// Final delivery replaces the draft message with final text. The
|
|
101
149
|
// keyboard (presentation buttons) goes onto the same edit, otherwise
|
|
102
150
|
// the final answer would lose its buttons.
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
151
|
+
await stop();
|
|
152
|
+
if (!draftMid) return false;
|
|
153
|
+
if (finalText === draftLastText && !buttons?.length) {
|
|
154
|
+
draftFinalized = true;
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
await editMaxMessage(draftMid, finalText, {
|
|
159
|
+
token: account.token,
|
|
160
|
+
format: 'markdown',
|
|
161
|
+
buttons,
|
|
162
|
+
});
|
|
163
|
+
draftLastText = finalText;
|
|
164
|
+
draftFinalized = true;
|
|
165
|
+
return true;
|
|
166
|
+
} catch (err) {
|
|
167
|
+
log?.warn(`[${account.accountId}] MAX draft final edit failed: ${String(err)}`);
|
|
168
|
+
return false;
|
|
114
169
|
}
|
|
115
|
-
draftStopped = true;
|
|
116
170
|
},
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
171
|
+
discard: async () => {
|
|
172
|
+
await stop();
|
|
173
|
+
if (!draftMid) return true;
|
|
174
|
+
try {
|
|
175
|
+
await deleteMaxMessage(draftMid, { token: account.token });
|
|
176
|
+
draftMid = null;
|
|
177
|
+
return true;
|
|
178
|
+
} catch (err) {
|
|
179
|
+
log?.warn(`[${account.accountId}] MAX draft delete failed: ${String(err)}`);
|
|
180
|
+
return false;
|
|
121
181
|
}
|
|
122
|
-
draftStopped = true;
|
|
123
182
|
},
|
|
183
|
+
clear: stop,
|
|
184
|
+
settle: stop,
|
|
124
185
|
};
|
|
125
186
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hand-off between a journaled webhook update and core's own restart recovery.
|
|
3
|
+
*
|
|
4
|
+
* Core records a recovery claim when it adopts an agent turn and resumes that
|
|
5
|
+
* turn after a crash by itself. The journal row of the update must therefore
|
|
6
|
+
* be completed at adoption, not only when the dispatch returns: replaying it
|
|
7
|
+
* after a crash mid-turn would run the turn twice (SDK durable ingress rule:
|
|
8
|
+
* "mark the event complete at dispatch adoption"). The webhook queue and the
|
|
9
|
+
* polling loop run each journaled dispatch with its completion callback (for
|
|
10
|
+
* polling: remembering the dedupe key); the reply dispatch passes
|
|
11
|
+
* it on as the public `turnAdoptionLifecycle` reply option. Without `admission`
|
|
12
|
+
* or `ownerKey` core groups queued follow-ups exactly as without a lifecycle.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
16
|
+
|
|
17
|
+
const adoption = new AsyncLocalStorage<() => Promise<void>>();
|
|
18
|
+
|
|
19
|
+
/** Run a dispatch whose agent turn, once adopted by core, calls `onAdopted`. */
|
|
20
|
+
export function runWithMaxTurnAdoption<T>(
|
|
21
|
+
onAdopted: () => Promise<void>,
|
|
22
|
+
dispatch: () => Promise<T>,
|
|
23
|
+
): Promise<T> {
|
|
24
|
+
return adoption.run(onAdopted, dispatch);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Reply options fragment carrying the current update's adoption callback, if any. */
|
|
28
|
+
export function maxTurnAdoptionReplyOptions(): {
|
|
29
|
+
turnAdoptionLifecycle?: { onAdopted: () => Promise<void> };
|
|
30
|
+
} {
|
|
31
|
+
const onAdopted = adoption.getStore();
|
|
32
|
+
return onAdopted ? { turnAdoptionLifecycle: { onAdopted } } : {};
|
|
33
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -75,11 +75,16 @@ export interface MaxAttachment {
|
|
|
75
75
|
[key: string]: unknown;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
/**
|
|
79
|
+
* LinkedMessage (schema.yaml): the forwarded or replied-to message. `message`
|
|
80
|
+
* is a MessageBody (mid, text, attachments, markup), not a full Message;
|
|
81
|
+
* `sender` is null for posts made on behalf of a channel.
|
|
82
|
+
*/
|
|
78
83
|
export interface MaxLinkedMessage {
|
|
79
84
|
type: 'forward' | 'reply';
|
|
80
|
-
sender?: MaxUser;
|
|
85
|
+
sender?: MaxUser | null;
|
|
81
86
|
chat_id?: number;
|
|
82
|
-
message?:
|
|
87
|
+
message?: MaxMessageBody | null;
|
|
83
88
|
}
|
|
84
89
|
|
|
85
90
|
export interface MaxUpdatesResponse {
|
|
@@ -224,7 +229,8 @@ export interface MaxMessage {
|
|
|
224
229
|
recipient: MaxRecipient;
|
|
225
230
|
timestamp: number;
|
|
226
231
|
link?: MaxLinkedMessage | null;
|
|
227
|
-
|
|
232
|
+
/** Null when the message consists only of a forward (content in link.message). */
|
|
233
|
+
body: MaxMessageBody | null;
|
|
228
234
|
stat?: { views?: number } | null;
|
|
229
235
|
url?: string | null;
|
|
230
236
|
}
|
package/src/typing.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MAX typing indicator ("typing_on") on the SDK typing lifecycle.
|
|
3
|
+
*
|
|
4
|
+
* MAX shows the indicator for a few seconds and has no explicit stop action,
|
|
5
|
+
* so a long turn needs periodic refreshes. The shared SDK keepalive owns the
|
|
6
|
+
* refresh loop, the failure circuit breaker and the safety TTL; core stops it
|
|
7
|
+
* when the run completes, fails or is aborted.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createTypingCallbacks } from 'openclaw/plugin-sdk/channel-outbound';
|
|
11
|
+
import type { OpenClawConfig } from 'openclaw/plugin-sdk/core';
|
|
12
|
+
|
|
13
|
+
import { resolveMaxAccount } from './accounts.js';
|
|
14
|
+
import { MaxApi } from './api.js';
|
|
15
|
+
import { resolveMaxTarget } from './send.js';
|
|
16
|
+
|
|
17
|
+
/** Refresh period: shorter than the indicator lifetime on MAX clients. */
|
|
18
|
+
export const MAX_TYPING_KEEPALIVE_MS = 4_000;
|
|
19
|
+
|
|
20
|
+
/** Deadline for one typing request; a slow one is dropped, not retried. */
|
|
21
|
+
const MAX_TYPING_TIMEOUT_MS = 5_000;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* One typing_on request. Best effort: a single attempt with a short deadline,
|
|
25
|
+
* since a retried indicator could land after the reply it announced. The
|
|
26
|
+
* actions endpoint is not a message send, so the per-chat send limiter does
|
|
27
|
+
* not apply; the keepalive period keeps it far below 2 requests per second.
|
|
28
|
+
*/
|
|
29
|
+
export async function sendMaxTypingAction(
|
|
30
|
+
api: MaxApi,
|
|
31
|
+
chatId: number,
|
|
32
|
+
signal?: AbortSignal,
|
|
33
|
+
): Promise<void> {
|
|
34
|
+
await api.sendAction(chatId, 'typing_on', {
|
|
35
|
+
retryAttempts: 0,
|
|
36
|
+
timeoutMs: MAX_TYPING_TIMEOUT_MS,
|
|
37
|
+
...(signal ? { signal } : {}),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Typing callbacks for one inbound turn in `chatId`; failures are only logged. */
|
|
42
|
+
export function createMaxTypingCallbacks(params: {
|
|
43
|
+
api: MaxApi;
|
|
44
|
+
chatId: number;
|
|
45
|
+
onError: (err: unknown) => void;
|
|
46
|
+
}): ReturnType<typeof createTypingCallbacks> {
|
|
47
|
+
return createTypingCallbacks({
|
|
48
|
+
start: () => sendMaxTypingAction(params.api, params.chatId),
|
|
49
|
+
onStartError: params.onError,
|
|
50
|
+
keepaliveIntervalMs: MAX_TYPING_KEEPALIVE_MS,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* heartbeat.sendTyping / sendTypingGuarded: core wraps them in the same
|
|
56
|
+
* keepalive lifecycle (heartbeat runs; gateway runs and task progress use the
|
|
57
|
+
* guarded variant with a cancellation signal and a send-authorization check). Only chat targets can show typing (the actions endpoint takes
|
|
58
|
+
* a chat id); user:<id> targets and accounts without a token are skipped.
|
|
59
|
+
*/
|
|
60
|
+
export async function sendMaxHeartbeatTyping(params: {
|
|
61
|
+
cfg: OpenClawConfig;
|
|
62
|
+
to: string;
|
|
63
|
+
accountId?: string | null;
|
|
64
|
+
/** Guarded variant: cancellation of the owning run. */
|
|
65
|
+
signal?: AbortSignal;
|
|
66
|
+
/** Guarded variant: throws when the run may no longer send to the platform. */
|
|
67
|
+
assertPlatformSendAuthorized?: () => void;
|
|
68
|
+
}): Promise<void> {
|
|
69
|
+
const account = resolveMaxAccount({ cfg: params.cfg, accountId: params.accountId });
|
|
70
|
+
if (!account.token) return;
|
|
71
|
+
const api = new MaxApi({ token: account.token });
|
|
72
|
+
const target = await resolveMaxTarget(api, params.to);
|
|
73
|
+
if (!('chat_id' in target) || params.signal?.aborted) return;
|
|
74
|
+
// Recheck right before the physical send: the owner may have ended meanwhile.
|
|
75
|
+
params.assertPlatformSendAuthorized?.();
|
|
76
|
+
await sendMaxTypingAction(api, target.chat_id, params.signal);
|
|
77
|
+
}
|
|
@@ -0,0 +1,302 @@
|
|
|
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
|
+
|
|
20
|
+
import * as fs from 'node:fs/promises';
|
|
21
|
+
import * as path from 'node:path';
|
|
22
|
+
|
|
23
|
+
import { readJsonFileWithFallback, writeJsonFileAtomically } from 'openclaw/plugin-sdk/json-store';
|
|
24
|
+
import { resolveStateDir } from 'openclaw/plugin-sdk/state-paths';
|
|
25
|
+
|
|
26
|
+
import type { MaxUpdate } from './api.js';
|
|
27
|
+
|
|
28
|
+
/** How long a handled update's key rejects a redelivery (MAX retries within 8 h). */
|
|
29
|
+
export const MAX_JOURNAL_COMPLETED_TTL_MS = 24 * 60 * 60 * 1000;
|
|
30
|
+
/** Most dedupe keys kept; the oldest go first. */
|
|
31
|
+
export const MAX_JOURNAL_COMPLETED_MAX_ENTRIES = 5000;
|
|
32
|
+
/** Unknown files (e.g. a temp file of a write cut by a crash) older than this are removed. */
|
|
33
|
+
const STRAY_FILE_MIN_AGE_MS = 60_000;
|
|
34
|
+
|
|
35
|
+
const ENTRY_FILE = /^u-(\d{13}-\d{6})\.json$/;
|
|
36
|
+
const COMPLETED_FILE = 'completed.json';
|
|
37
|
+
|
|
38
|
+
export type MaxJournalEntry = {
|
|
39
|
+
v: 1;
|
|
40
|
+
seq: string;
|
|
41
|
+
receivedAt: number;
|
|
42
|
+
key?: string;
|
|
43
|
+
update: MaxUpdate;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
type CompletedFile = { v: 1; keys: Record<string, number> };
|
|
47
|
+
|
|
48
|
+
export function resolveMaxJournalDir(accountId: string): string {
|
|
49
|
+
const safeId = accountId.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
50
|
+
return path.join(resolveStateDir(), 'max', `inbox-${safeId}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type MaxUpdateJournalOptions = {
|
|
54
|
+
accountId: string;
|
|
55
|
+
/** Default: resolveMaxJournalDir(accountId). */
|
|
56
|
+
dir?: string;
|
|
57
|
+
now?: () => number;
|
|
58
|
+
completedTtlMs?: number;
|
|
59
|
+
completedMaxEntries?: number;
|
|
60
|
+
warn?: (message: string) => void;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** One journal per directory in a process: overlapping account tasks share ownership. */
|
|
64
|
+
const openJournals = new Map<string, MaxUpdateJournal>();
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Open (or reuse) the journal of an account. Rejects when the directory cannot
|
|
68
|
+
* be created or written; callers then fall back to the in-memory queue.
|
|
69
|
+
*/
|
|
70
|
+
export async function openMaxUpdateJournal(
|
|
71
|
+
options: MaxUpdateJournalOptions,
|
|
72
|
+
): Promise<MaxUpdateJournal> {
|
|
73
|
+
const dir = options.dir ?? resolveMaxJournalDir(options.accountId);
|
|
74
|
+
const existing = openJournals.get(dir);
|
|
75
|
+
if (existing) {
|
|
76
|
+
if (options.warn) existing.warn = options.warn;
|
|
77
|
+
return existing;
|
|
78
|
+
}
|
|
79
|
+
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
80
|
+
await fs.chmod(dir, 0o700);
|
|
81
|
+
await fs.access(dir, fs.constants.W_OK);
|
|
82
|
+
const journal = new MaxUpdateJournal(dir, options);
|
|
83
|
+
await journal.loadCompleted();
|
|
84
|
+
const raced = openJournals.get(dir);
|
|
85
|
+
if (raced) return raced;
|
|
86
|
+
openJournals.set(dir, journal);
|
|
87
|
+
return journal;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export class MaxUpdateJournal {
|
|
91
|
+
warn?: (message: string) => void;
|
|
92
|
+
private readonly now: () => number;
|
|
93
|
+
private readonly completedTtlMs: number;
|
|
94
|
+
private readonly completedMaxEntries: number;
|
|
95
|
+
/** Dedupe key → completion time, oldest first. */
|
|
96
|
+
private readonly completed = new Map<string, number>();
|
|
97
|
+
/** Entries this process holds (queued or dispatching): recovery skips them. */
|
|
98
|
+
private readonly owned = new Set<string>();
|
|
99
|
+
/** Keys of entries still on disk. */
|
|
100
|
+
private readonly pendingKeys = new Set<string>();
|
|
101
|
+
private lastSeqMs = 0;
|
|
102
|
+
private seqCounter = 0;
|
|
103
|
+
private scheduledWrite: Promise<void> | undefined;
|
|
104
|
+
private lastWrite: Promise<void> = Promise.resolve();
|
|
105
|
+
|
|
106
|
+
constructor(
|
|
107
|
+
readonly dir: string,
|
|
108
|
+
options: MaxUpdateJournalOptions,
|
|
109
|
+
) {
|
|
110
|
+
this.warn = options.warn;
|
|
111
|
+
this.now = options.now ?? Date.now;
|
|
112
|
+
this.completedTtlMs = options.completedTtlMs ?? MAX_JOURNAL_COMPLETED_TTL_MS;
|
|
113
|
+
this.completedMaxEntries = options.completedMaxEntries ?? MAX_JOURNAL_COMPLETED_MAX_ENTRIES;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** @internal */
|
|
117
|
+
async loadCompleted(): Promise<void> {
|
|
118
|
+
const file = path.join(this.dir, COMPLETED_FILE);
|
|
119
|
+
const { value, exists } = await readJsonFileWithFallback<CompletedFile | null>(file, null);
|
|
120
|
+
const keys = value && typeof value === 'object' ? value.keys : undefined;
|
|
121
|
+
if (!keys || typeof keys !== 'object' || Array.isArray(keys)) {
|
|
122
|
+
if (exists)
|
|
123
|
+
this.warn?.(`MAX journal ${this.dir}: unreadable ${COMPLETED_FILE}, starting empty`);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const entries = Object.entries(keys)
|
|
127
|
+
.filter((entry): entry is [string, number] => typeof entry[1] === 'number')
|
|
128
|
+
.sort((a, b) => a[1] - b[1]);
|
|
129
|
+
for (const [key, at] of entries) this.completed.set(key, at);
|
|
130
|
+
this.pruneCompleted();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** True when the key belongs to a pending update or was handled within the TTL. */
|
|
134
|
+
isDuplicate(key: string): boolean {
|
|
135
|
+
return this.pendingKeys.has(key) || this.hasCompleted(key);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
hasCompleted(key: string): boolean {
|
|
139
|
+
const at = this.completed.get(key);
|
|
140
|
+
return at !== undefined && this.now() - at < this.completedTtlMs;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Durably record an accepted update; resolves once it is on disk. */
|
|
144
|
+
async append(update: MaxUpdate, key: string | undefined): Promise<MaxJournalEntry> {
|
|
145
|
+
const receivedAt = this.now();
|
|
146
|
+
const entry: MaxJournalEntry = {
|
|
147
|
+
v: 1,
|
|
148
|
+
seq: this.nextSeq(receivedAt),
|
|
149
|
+
receivedAt,
|
|
150
|
+
...(key ? { key } : {}),
|
|
151
|
+
update,
|
|
152
|
+
};
|
|
153
|
+
// Owned before the file exists, so a concurrent recovery never takes it.
|
|
154
|
+
this.owned.add(entry.seq);
|
|
155
|
+
if (key) this.pendingKeys.add(key);
|
|
156
|
+
try {
|
|
157
|
+
await writeJsonFileAtomically(this.entryPath(entry.seq), entry);
|
|
158
|
+
} catch (err) {
|
|
159
|
+
this.owned.delete(entry.seq);
|
|
160
|
+
if (key) this.pendingKeys.delete(key);
|
|
161
|
+
await fs.rm(this.entryPath(entry.seq), { force: true }).catch(() => undefined);
|
|
162
|
+
throw err;
|
|
163
|
+
}
|
|
164
|
+
return entry;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Entries on disk that this process does not hold (left by an earlier
|
|
169
|
+
* process), oldest first. They become owned. Unreadable entries are deleted
|
|
170
|
+
* and counted.
|
|
171
|
+
*/
|
|
172
|
+
async readPending(): Promise<{ entries: MaxJournalEntry[]; unreadable: number }> {
|
|
173
|
+
const names = await fs.readdir(this.dir);
|
|
174
|
+
const entries: MaxJournalEntry[] = [];
|
|
175
|
+
let unreadable = 0;
|
|
176
|
+
for (const name of names.sort()) {
|
|
177
|
+
const match = ENTRY_FILE.exec(name);
|
|
178
|
+
if (!match) {
|
|
179
|
+
await this.removeStray(name);
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
const seq = match[1] as string;
|
|
183
|
+
if (this.owned.has(seq)) continue;
|
|
184
|
+
const file = this.entryPath(seq);
|
|
185
|
+
const { value } = await readJsonFileWithFallback<unknown>(file, null);
|
|
186
|
+
if (!isJournalEntry(value, seq)) {
|
|
187
|
+
unreadable += 1;
|
|
188
|
+
await fs.rm(file, { force: true }).catch(() => undefined);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
this.owned.add(seq);
|
|
192
|
+
if (value.key) this.pendingKeys.add(value.key);
|
|
193
|
+
entries.push(value);
|
|
194
|
+
}
|
|
195
|
+
return { entries, unreadable };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The update was handled (or deliberately skipped): remember its key, then
|
|
200
|
+
* delete the entry. A failed tombstone write is logged, the entry is still
|
|
201
|
+
* deleted — replaying a handled update on every start would be worse.
|
|
202
|
+
*/
|
|
203
|
+
async complete(entry: MaxJournalEntry): Promise<void> {
|
|
204
|
+
if (entry.key) await this.remember(entry.key);
|
|
205
|
+
try {
|
|
206
|
+
await fs.rm(this.entryPath(entry.seq), { force: true });
|
|
207
|
+
} catch (err) {
|
|
208
|
+
this.warn?.(`MAX journal: could not delete handled entry ${entry.seq}: ${errorCode(err)}`);
|
|
209
|
+
}
|
|
210
|
+
this.owned.delete(entry.seq);
|
|
211
|
+
if (entry.key) this.pendingKeys.delete(entry.key);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Stop holding an entry without handling it: the next recovery takes it. */
|
|
215
|
+
release(entry: MaxJournalEntry): void {
|
|
216
|
+
this.owned.delete(entry.seq);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Record a handled update's key (polling uses this without an entry). */
|
|
220
|
+
async remember(key: string): Promise<void> {
|
|
221
|
+
this.completed.delete(key);
|
|
222
|
+
this.completed.set(key, this.now());
|
|
223
|
+
this.pruneCompleted();
|
|
224
|
+
try {
|
|
225
|
+
await this.saveCompleted();
|
|
226
|
+
} catch (err) {
|
|
227
|
+
this.warn?.(`MAX journal: could not save ${COMPLETED_FILE}: ${errorCode(err)}`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private pruneCompleted(): void {
|
|
232
|
+
const cutoff = this.now() - this.completedTtlMs;
|
|
233
|
+
for (const [key, at] of this.completed) {
|
|
234
|
+
if (at > cutoff && this.completed.size <= this.completedMaxEntries) break;
|
|
235
|
+
this.completed.delete(key);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Coalesced, serialized writes: a caller resolves once a write with its change is done. */
|
|
240
|
+
private saveCompleted(): Promise<void> {
|
|
241
|
+
if (this.scheduledWrite) return this.scheduledWrite;
|
|
242
|
+
const write = this.lastWrite
|
|
243
|
+
.catch(() => undefined)
|
|
244
|
+
.then(async () => {
|
|
245
|
+
this.scheduledWrite = undefined;
|
|
246
|
+
const file: CompletedFile = { v: 1, keys: Object.fromEntries(this.completed) };
|
|
247
|
+
await writeJsonFileAtomically(path.join(this.dir, COMPLETED_FILE), file);
|
|
248
|
+
});
|
|
249
|
+
this.scheduledWrite = write;
|
|
250
|
+
this.lastWrite = write;
|
|
251
|
+
return write;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private async removeStray(name: string): Promise<void> {
|
|
255
|
+
if (name === COMPLETED_FILE) return;
|
|
256
|
+
const file = path.join(this.dir, name);
|
|
257
|
+
try {
|
|
258
|
+
const stat = await fs.lstat(file);
|
|
259
|
+
if (stat.isFile() && this.now() - stat.mtimeMs > STRAY_FILE_MIN_AGE_MS) {
|
|
260
|
+
await fs.rm(file, { force: true });
|
|
261
|
+
}
|
|
262
|
+
} catch {
|
|
263
|
+
// Gone meanwhile or not ours to judge: leave it.
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private nextSeq(at: number): string {
|
|
268
|
+
const ms = Math.max(at, this.lastSeqMs);
|
|
269
|
+
this.seqCounter = ms === this.lastSeqMs ? this.seqCounter + 1 : 0;
|
|
270
|
+
this.lastSeqMs = ms;
|
|
271
|
+
return `${String(ms).padStart(13, '0')}-${String(this.seqCounter).padStart(6, '0')}`;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private entryPath(seq: string): string {
|
|
275
|
+
return path.join(this.dir, `u-${seq}.json`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function isJournalEntry(value: unknown, seq: string): value is MaxJournalEntry {
|
|
280
|
+
if (!value || typeof value !== 'object') return false;
|
|
281
|
+
const entry = value as Partial<MaxJournalEntry>;
|
|
282
|
+
return (
|
|
283
|
+
entry.v === 1 &&
|
|
284
|
+
entry.seq === seq &&
|
|
285
|
+
typeof entry.receivedAt === 'number' &&
|
|
286
|
+
(entry.key === undefined || typeof entry.key === 'string') &&
|
|
287
|
+
!!entry.update &&
|
|
288
|
+
typeof entry.update === 'object' &&
|
|
289
|
+
typeof (entry.update as { update_type?: unknown }).update_type === 'string'
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Error code only: messages of fs errors carry paths, never needed in logs here. */
|
|
294
|
+
function errorCode(err: unknown): string {
|
|
295
|
+
const code = (err as { code?: unknown })?.code;
|
|
296
|
+
return typeof code === 'string' ? code : String(err);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** @internal test helper: forget cached journals. */
|
|
300
|
+
export function resetMaxUpdateJournalsForTest(): void {
|
|
301
|
+
openJournals.clear();
|
|
302
|
+
}
|