@aspalagin/openclaw-max 0.7.3 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) 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-policy.js +19 -6
  18. package/dist/src/channel.js +16 -0
  19. package/dist/src/command-menu.js +199 -0
  20. package/dist/src/config-schema.js +106 -10
  21. package/dist/src/deliver.js +144 -48
  22. package/dist/src/dispatch.js +12 -11
  23. package/dist/src/gateway-drain.js +40 -0
  24. package/dist/src/group-mention.js +129 -0
  25. package/dist/src/inbound-attachments.js +162 -20
  26. package/dist/src/inbound.js +388 -134
  27. package/dist/src/media-temp.js +31 -20
  28. package/dist/src/network.js +216 -0
  29. package/dist/src/onboarding.js +4 -2
  30. package/dist/src/polling.js +180 -12
  31. package/dist/src/presentation.js +11 -0
  32. package/dist/src/progress-draft.js +168 -0
  33. package/dist/src/runtime.js +15 -0
  34. package/dist/src/secret-contract.js +149 -0
  35. package/dist/src/send.js +250 -46
  36. package/dist/src/stream-draft.js +83 -41
  37. package/dist/src/turn-adoption.js +24 -0
  38. package/dist/src/typing.js +55 -0
  39. package/dist/src/update-journal.js +267 -0
  40. package/dist/src/webhook-queue.js +151 -26
  41. package/dist/src/webhook-runner.js +61 -9
  42. package/dist/src/webhook.js +34 -15
  43. package/openclaw.plugin.json +983 -8
  44. package/package.json +17 -4
  45. package/src/access-policy.ts +154 -0
  46. package/src/accounts.ts +260 -25
  47. package/src/action-scope.ts +274 -0
  48. package/src/actions.ts +188 -35
  49. package/src/api.ts +158 -101
  50. package/src/callbacks.ts +26 -7
  51. package/src/channel-agent-prompt.ts +8 -19
  52. package/src/channel-config.ts +32 -3
  53. package/src/channel-lifecycle.ts +33 -6
  54. package/src/channel-outbound.ts +50 -7
  55. package/src/channel-policy.ts +27 -7
  56. package/src/channel.ts +16 -0
  57. package/src/command-menu.ts +277 -0
  58. package/src/config-schema.ts +111 -10
  59. package/src/deliver.ts +174 -55
  60. package/src/dispatch.ts +12 -11
  61. package/src/gateway-drain.ts +45 -0
  62. package/src/group-mention.ts +191 -0
  63. package/src/inbound-attachments.ts +173 -22
  64. package/src/inbound.ts +443 -130
  65. package/src/media-temp.ts +35 -23
  66. package/src/network.ts +233 -0
  67. package/src/onboarding.ts +4 -2
  68. package/src/polling.ts +221 -15
  69. package/src/presentation.ts +14 -1
  70. package/src/progress-draft.ts +226 -0
  71. package/src/runtime.ts +23 -0
  72. package/src/secret-contract.ts +183 -0
  73. package/src/send.ts +346 -59
  74. package/src/stream-draft.ts +102 -41
  75. package/src/turn-adoption.ts +33 -0
  76. package/src/types.ts +9 -3
  77. package/src/typing.ts +77 -0
  78. package/src/update-journal.ts +302 -0
  79. package/src/webhook-queue.ts +205 -33
  80. package/src/webhook-runner.ts +65 -8
  81. package/src/webhook.ts +39 -18
  82. package/scripts/check-cycles.mjs +0 -38
  83. package/scripts/test-api.mjs +0 -169
  84. package/scripts/test-send.mjs +0 -162
  85. package/scripts/update-schema.mjs +0 -48
@@ -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 { editMaxMessage, type MaxSendButton, sendMaxMessage } from './send.js';
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
- /** Replace the draft with the final text; buttons go onto the same edit. */
22
- finalize(text: string, buttons: MaxSendButton[][] | undefined): Promise<void>;
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
- try {
72
- if (!draftMid) {
73
- // First chunk — send new message
74
- const res = await sendMaxMessage(chatId, trimmed, {
75
- token: account.token,
76
- replyToMessageId: params.replyToId,
77
- format: 'markdown',
78
- });
79
- draftMid = res.messageId || null;
80
- statusSink?.({ lastOutboundAt: Date.now() });
81
- } else {
82
- // Edit existing message
83
- await editMaxMessage(draftMid, trimmed, {
84
- token: account.token,
85
- format: 'markdown',
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
- } catch (err) {
89
- draftStopped = true;
90
- log?.debug?.(`[${account.accountId}] MAX draft stream failed: ${String(err)}`);
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
- if (draftMid && (finalText !== draftLastText || buttons?.length)) {
104
- try {
105
- await editMaxMessage(draftMid, finalText, {
106
- token: account.token,
107
- format: 'markdown',
108
- buttons,
109
- });
110
- draftLastText = finalText;
111
- } catch {
112
- /* best effort */
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
- clear: async () => {
118
- if (draftTimer) {
119
- clearTimeout(draftTimer);
120
- draftTimer = null;
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?: MaxMessage;
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
- body: MaxMessageBody;
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
+ }