@openclaw/crabline 0.1.9 → 0.1.10

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 (189) hide show
  1. package/README.md +47 -22
  2. package/assets/crabline-banner.svg +20 -0
  3. package/dist/src/bin/crabline.js.map +1 -1
  4. package/dist/src/cli/program.d.ts +25 -1
  5. package/dist/src/cli/program.js +579 -82
  6. package/dist/src/cli/program.js.map +1 -1
  7. package/dist/src/config/load.js +59 -6
  8. package/dist/src/config/load.js.map +1 -1
  9. package/dist/src/config/schema.d.ts +116 -92
  10. package/dist/src/config/schema.js +179 -73
  11. package/dist/src/config/schema.js.map +1 -1
  12. package/dist/src/core/errors.js +9 -2
  13. package/dist/src/core/errors.js.map +1 -1
  14. package/dist/src/core/exit-codes.js.map +1 -1
  15. package/dist/src/core/matcher.js +7 -6
  16. package/dist/src/core/matcher.js.map +1 -1
  17. package/dist/src/core/message-template.js.map +1 -1
  18. package/dist/src/core/nonces.d.ts +1 -0
  19. package/dist/src/core/nonces.js +4 -2
  20. package/dist/src/core/nonces.js.map +1 -1
  21. package/dist/src/core/reporters.d.ts +1 -0
  22. package/dist/src/core/reporters.js +42 -4
  23. package/dist/src/core/reporters.js.map +1 -1
  24. package/dist/src/core/run.d.ts +3 -0
  25. package/dist/src/core/run.js +415 -107
  26. package/dist/src/core/run.js.map +1 -1
  27. package/dist/src/core/safe-regex.d.ts +3 -0
  28. package/dist/src/core/safe-regex.js +21 -0
  29. package/dist/src/core/safe-regex.js.map +1 -0
  30. package/dist/src/index.d.ts +2 -2
  31. package/dist/src/index.js +1 -1
  32. package/dist/src/index.js.map +1 -1
  33. package/dist/src/openclaw/artifact-generation.d.ts +38 -0
  34. package/dist/src/openclaw/artifact-generation.js +314 -0
  35. package/dist/src/openclaw/artifact-generation.js.map +1 -0
  36. package/dist/src/openclaw/bridges/matrix.js +97 -12
  37. package/dist/src/openclaw/bridges/matrix.js.map +1 -1
  38. package/dist/src/openclaw/bridges/mattermost.js +32 -10
  39. package/dist/src/openclaw/bridges/mattermost.js.map +1 -1
  40. package/dist/src/openclaw/bridges/signal.js +55 -18
  41. package/dist/src/openclaw/bridges/signal.js.map +1 -1
  42. package/dist/src/openclaw/bridges/slack.js +85 -7
  43. package/dist/src/openclaw/bridges/slack.js.map +1 -1
  44. package/dist/src/openclaw/bridges/telegram.js +124 -30
  45. package/dist/src/openclaw/bridges/telegram.js.map +1 -1
  46. package/dist/src/openclaw/bridges/whatsapp.js +78 -17
  47. package/dist/src/openclaw/bridges/whatsapp.js.map +1 -1
  48. package/dist/src/openclaw/bridges/zalo.js +14 -5
  49. package/dist/src/openclaw/bridges/zalo.js.map +1 -1
  50. package/dist/src/openclaw/outbound-contract.d.ts +5 -0
  51. package/dist/src/openclaw/outbound-contract.js +40 -0
  52. package/dist/src/openclaw/outbound-contract.js.map +1 -0
  53. package/dist/src/openclaw/private-file.d.ts +26 -0
  54. package/dist/src/openclaw/private-file.js +411 -0
  55. package/dist/src/openclaw/private-file.js.map +1 -0
  56. package/dist/src/openclaw/shared.d.ts +21 -2
  57. package/dist/src/openclaw/shared.js +115 -18
  58. package/dist/src/openclaw/shared.js.map +1 -1
  59. package/dist/src/openclaw/smoke-lock.d.ts +63 -0
  60. package/dist/src/openclaw/smoke-lock.js +949 -0
  61. package/dist/src/openclaw/smoke-lock.js.map +1 -0
  62. package/dist/src/openclaw.d.ts +16 -8
  63. package/dist/src/openclaw.js +194 -52
  64. package/dist/src/openclaw.js.map +1 -1
  65. package/dist/src/providers/builtin/discord.d.ts +10 -1
  66. package/dist/src/providers/builtin/discord.js +144 -18
  67. package/dist/src/providers/builtin/discord.js.map +1 -1
  68. package/dist/src/providers/builtin/external-webhook-auth.d.ts +11 -0
  69. package/dist/src/providers/builtin/external-webhook-auth.js +10 -0
  70. package/dist/src/providers/builtin/external-webhook-auth.js.map +1 -0
  71. package/dist/src/providers/builtin/feishu.d.ts +19 -2
  72. package/dist/src/providers/builtin/feishu.js +307 -25
  73. package/dist/src/providers/builtin/feishu.js.map +1 -1
  74. package/dist/src/providers/builtin/googlechat.d.ts +20 -1
  75. package/dist/src/providers/builtin/googlechat.js +178 -19
  76. package/dist/src/providers/builtin/googlechat.js.map +1 -1
  77. package/dist/src/providers/builtin/imessage.d.ts +4 -0
  78. package/dist/src/providers/builtin/imessage.js +26 -11
  79. package/dist/src/providers/builtin/imessage.js.map +1 -1
  80. package/dist/src/providers/builtin/loopback.js +92 -30
  81. package/dist/src/providers/builtin/loopback.js.map +1 -1
  82. package/dist/src/providers/builtin/matrix.d.ts +11 -1
  83. package/dist/src/providers/builtin/matrix.js +53 -29
  84. package/dist/src/providers/builtin/matrix.js.map +1 -1
  85. package/dist/src/providers/builtin/mattermost.d.ts +10 -0
  86. package/dist/src/providers/builtin/mattermost.js +38 -15
  87. package/dist/src/providers/builtin/mattermost.js.map +1 -1
  88. package/dist/src/providers/builtin/msteams.d.ts +17 -1
  89. package/dist/src/providers/builtin/msteams.js +113 -14
  90. package/dist/src/providers/builtin/msteams.js.map +1 -1
  91. package/dist/src/providers/builtin/native-local-mock.d.ts +2 -9
  92. package/dist/src/providers/builtin/native-local-mock.js +12 -35
  93. package/dist/src/providers/builtin/native-local-mock.js.map +1 -1
  94. package/dist/src/providers/builtin/script.d.ts +11 -15
  95. package/dist/src/providers/builtin/script.js +566 -104
  96. package/dist/src/providers/builtin/script.js.map +1 -1
  97. package/dist/src/providers/builtin/slack.d.ts +6 -0
  98. package/dist/src/providers/builtin/slack.js +99 -40
  99. package/dist/src/providers/builtin/slack.js.map +1 -1
  100. package/dist/src/providers/builtin/telegram.d.ts +6 -0
  101. package/dist/src/providers/builtin/telegram.js +64 -43
  102. package/dist/src/providers/builtin/telegram.js.map +1 -1
  103. package/dist/src/providers/builtin/whatsapp.d.ts +28 -8
  104. package/dist/src/providers/builtin/whatsapp.js +567 -30
  105. package/dist/src/providers/builtin/whatsapp.js.map +1 -1
  106. package/dist/src/providers/builtin/zalo.js +30 -19
  107. package/dist/src/providers/builtin/zalo.js.map +1 -1
  108. package/dist/src/providers/catalog.d.ts +3 -3
  109. package/dist/src/providers/catalog.js +3 -3
  110. package/dist/src/providers/catalog.js.map +1 -1
  111. package/dist/src/providers/local-mock.d.ts +11 -2
  112. package/dist/src/providers/local-mock.js +322 -96
  113. package/dist/src/providers/local-mock.js.map +1 -1
  114. package/dist/src/providers/native-ids.d.ts +1 -0
  115. package/dist/src/providers/native-ids.js +6 -1
  116. package/dist/src/providers/native-ids.js.map +1 -1
  117. package/dist/src/providers/recorder.d.ts +30 -2
  118. package/dist/src/providers/recorder.js +539 -70
  119. package/dist/src/providers/recorder.js.map +1 -1
  120. package/dist/src/providers/registry.d.ts +25 -1
  121. package/dist/src/providers/registry.js +280 -40
  122. package/dist/src/providers/registry.js.map +1 -1
  123. package/dist/src/providers/signed-jwt.d.ts +29 -0
  124. package/dist/src/providers/signed-jwt.js +202 -0
  125. package/dist/src/providers/signed-jwt.js.map +1 -0
  126. package/dist/src/providers/slack-ids.js.map +1 -1
  127. package/dist/src/providers/target-normalizers.d.ts +26 -0
  128. package/dist/src/providers/target-normalizers.js +276 -0
  129. package/dist/src/providers/target-normalizers.js.map +1 -0
  130. package/dist/src/providers/types.d.ts +5 -0
  131. package/dist/src/providers/types.js.map +1 -1
  132. package/dist/src/providers/webhook-server.d.ts +2 -0
  133. package/dist/src/providers/webhook-server.js +72 -25
  134. package/dist/src/providers/webhook-server.js.map +1 -1
  135. package/dist/src/servers/http.d.ts +23 -3
  136. package/dist/src/servers/http.js +213 -20
  137. package/dist/src/servers/http.js.map +1 -1
  138. package/dist/src/servers/index.js +20 -21
  139. package/dist/src/servers/index.js.map +1 -1
  140. package/dist/src/servers/matrix.js +465 -51
  141. package/dist/src/servers/matrix.js.map +1 -1
  142. package/dist/src/servers/mattermost.d.ts +5 -0
  143. package/dist/src/servers/mattermost.js +306 -58
  144. package/dist/src/servers/mattermost.js.map +1 -1
  145. package/dist/src/servers/pending-events.d.ts +2 -0
  146. package/dist/src/servers/pending-events.js +11 -0
  147. package/dist/src/servers/pending-events.js.map +1 -0
  148. package/dist/src/servers/recorder.d.ts +5 -0
  149. package/dist/src/servers/recorder.js +46 -3
  150. package/dist/src/servers/recorder.js.map +1 -1
  151. package/dist/src/servers/signal.d.ts +2 -0
  152. package/dist/src/servers/signal.js +444 -56
  153. package/dist/src/servers/signal.js.map +1 -1
  154. package/dist/src/servers/slack.d.ts +5 -0
  155. package/dist/src/servers/slack.js +515 -49
  156. package/dist/src/servers/slack.js.map +1 -1
  157. package/dist/src/servers/telegram.d.ts +111 -0
  158. package/dist/src/servers/telegram.js +811 -152
  159. package/dist/src/servers/telegram.js.map +1 -1
  160. package/dist/src/servers/webhook-target.d.ts +40 -0
  161. package/dist/src/servers/webhook-target.js +349 -0
  162. package/dist/src/servers/webhook-target.js.map +1 -0
  163. package/dist/src/servers/websocket.d.ts +2 -0
  164. package/dist/src/servers/websocket.js +29 -0
  165. package/dist/src/servers/websocket.js.map +1 -0
  166. package/dist/src/servers/whatsapp-baileys-websocket.d.ts +65 -1
  167. package/dist/src/servers/whatsapp-baileys-websocket.js +723 -99
  168. package/dist/src/servers/whatsapp-baileys-websocket.js.map +1 -1
  169. package/dist/src/servers/whatsapp-jid.d.ts +5 -0
  170. package/dist/src/servers/whatsapp-jid.js +32 -0
  171. package/dist/src/servers/whatsapp-jid.js.map +1 -0
  172. package/dist/src/servers/whatsapp-wire/binary-node.d.ts +5 -0
  173. package/dist/src/servers/whatsapp-wire/binary-node.js +87 -20
  174. package/dist/src/servers/whatsapp-wire/binary-node.js.map +1 -1
  175. package/dist/src/servers/whatsapp-wire/crypto.d.ts +6 -1
  176. package/dist/src/servers/whatsapp-wire/crypto.js +99 -45
  177. package/dist/src/servers/whatsapp-wire/crypto.js.map +1 -1
  178. package/dist/src/servers/whatsapp-wire/handshake.js +39 -4
  179. package/dist/src/servers/whatsapp-wire/handshake.js.map +1 -1
  180. package/dist/src/servers/whatsapp-wire/signal.js.map +1 -1
  181. package/dist/src/servers/whatsapp.d.ts +12 -7
  182. package/dist/src/servers/whatsapp.js +269 -107
  183. package/dist/src/servers/whatsapp.js.map +1 -1
  184. package/dist/src/servers/zalo.d.ts +14 -0
  185. package/dist/src/servers/zalo.js +521 -88
  186. package/dist/src/servers/zalo.js.map +1 -1
  187. package/docs/channel-setup.md +197 -19
  188. package/fixtures/examples/crabline.example.yaml +10 -0
  189. package/package.json +20 -15
@@ -1,12 +1,372 @@
1
- import { appendFile, mkdir, open, readFile } from "node:fs/promises";
1
+ import { lstat, mkdir, open, readFile, readlink, realpath } from "node:fs/promises";
2
2
  import path from "node:path";
3
- function sleep(ms) {
4
- return new Promise((resolve) => setTimeout(resolve, ms));
3
+ import { lock } from "proper-lockfile";
4
+ function sleep(ms, signal) {
5
+ if (signal?.aborted) {
6
+ return Promise.resolve();
7
+ }
8
+ return new Promise((resolve) => {
9
+ const done = () => {
10
+ clearTimeout(timeout);
11
+ signal?.removeEventListener("abort", done);
12
+ resolve();
13
+ };
14
+ const timeout = setTimeout(done, ms);
15
+ signal?.addEventListener("abort", done, { once: true });
16
+ });
17
+ }
18
+ const pendingAppends = new Map();
19
+ const recordIdentityIndexes = new Map();
20
+ const MAX_RECORD_IDENTITY_INDEXES = 128;
21
+ const MAX_RECENT_RECORD_KEYS = 4096;
22
+ const RECORDER_BATCH_VERSION = 1;
23
+ const RECORDER_LOCK_STALE_MS = 30_000;
24
+ const RECORDER_LOCK_UPDATE_MS = 10_000;
25
+ const RECORDER_ROTATION_ATTEMPTS = 3;
26
+ class RecorderRotatedError extends Error {
5
27
  }
6
- function toRecordKey(event) {
7
- return `${event.provider}:${event.threadId}:${event.id}`;
28
+ const CONTINUITY_BYTES = 4096;
29
+ const MAX_INCREMENTAL_READ_BYTES = 256 * 1024;
30
+ const MAX_PENDING_RECORD_BYTES = 4 * 1024 * 1024;
31
+ function createIncrementalReadState() {
32
+ return {
33
+ caughtUp: true,
34
+ continuity: Buffer.alloc(0),
35
+ generation: 0,
36
+ identity: undefined,
37
+ offset: 0,
38
+ pending: Buffer.alloc(0),
39
+ };
40
+ }
41
+ export function createRecordedInboundCursor() {
42
+ return {
43
+ buffered: [],
44
+ readState: createIncrementalReadState(),
45
+ seen: new Set(),
46
+ };
47
+ }
48
+ export function cloneRecordedInboundCursor(cursor) {
49
+ return {
50
+ buffered: [...cursor.buffered],
51
+ readState: {
52
+ caughtUp: cursor.readState.caughtUp,
53
+ continuity: Buffer.from(cursor.readState.continuity),
54
+ generation: cursor.readState.generation,
55
+ identity: cursor.readState.identity ? { ...cursor.readState.identity } : undefined,
56
+ offset: cursor.readState.offset,
57
+ pending: Buffer.from(cursor.readState.pending),
58
+ },
59
+ seen: new Set(cursor.seen),
60
+ };
61
+ }
62
+ function rememberRecentRecord(seen, event) {
63
+ const key = JSON.stringify([
64
+ event.provider,
65
+ event.threadId,
66
+ event.id,
67
+ recordedDirectionOf(event),
68
+ ]);
69
+ if (seen.has(key)) {
70
+ return false;
71
+ }
72
+ seen.add(key);
73
+ if (seen.size > MAX_RECENT_RECORD_KEYS) {
74
+ seen.delete(seen.values().next().value);
75
+ }
76
+ return true;
77
+ }
78
+ function recordedDirectionOf(event) {
79
+ if (event.recordedDirection) {
80
+ return event.recordedDirection;
81
+ }
82
+ return event.raw !== null &&
83
+ typeof event.raw === "object" &&
84
+ "direction" in event.raw &&
85
+ event.raw.direction === "outbound"
86
+ ? "outbound"
87
+ : "inbound";
88
+ }
89
+ function requireNonEmptyString(value, field) {
90
+ if (typeof value !== "string" || value.length === 0) {
91
+ throw new Error(`Recorded inbound envelope ${field} must be a non-empty string.`);
92
+ }
93
+ return value;
94
+ }
95
+ function requireString(value, field) {
96
+ if (typeof value !== "string") {
97
+ throw new Error(`Recorded inbound envelope ${field} must be a string.`);
98
+ }
99
+ return value;
100
+ }
101
+ function parseRecordedEnvelope(value) {
102
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
103
+ throw new Error("Recorded inbound envelope must be an object.");
104
+ }
105
+ const record = value;
106
+ if (record.author !== "assistant" && record.author !== "system" && record.author !== "user") {
107
+ throw new Error("Recorded inbound envelope author must be assistant, system, or user.");
108
+ }
109
+ if (record.recordedDirection !== undefined &&
110
+ record.recordedDirection !== "inbound" &&
111
+ record.recordedDirection !== "outbound") {
112
+ throw new Error("Recorded inbound envelope recordedDirection must be inbound or outbound.");
113
+ }
114
+ return {
115
+ author: record.author,
116
+ id: requireNonEmptyString(record.id, "id"),
117
+ provider: requireNonEmptyString(record.provider, "provider"),
118
+ ...(record.raw !== undefined ? { raw: record.raw } : {}),
119
+ recordedAt: requireNonEmptyString(record.recordedAt, "recordedAt"),
120
+ ...(record.recordedDirection !== undefined
121
+ ? { recordedDirection: record.recordedDirection }
122
+ : {}),
123
+ sentAt: requireNonEmptyString(record.sentAt, "sentAt"),
124
+ text: requireString(record.text, "text"),
125
+ threadId: requireNonEmptyString(record.threadId, "threadId"),
126
+ };
127
+ }
128
+ function parseRecordedLine(line) {
129
+ const parsed = JSON.parse(line);
130
+ if (typeof parsed === "object" &&
131
+ parsed !== null &&
132
+ !Array.isArray(parsed) &&
133
+ "recordType" in parsed &&
134
+ parsed.recordType === "crabline.recorder.batch" &&
135
+ "recorderBatchVersion" in parsed &&
136
+ parsed.recorderBatchVersion === RECORDER_BATCH_VERSION &&
137
+ "events" in parsed &&
138
+ Array.isArray(parsed.events)) {
139
+ return parsed.events.map(parseRecordedEnvelope);
140
+ }
141
+ return [parseRecordedEnvelope(parsed)];
142
+ }
143
+ function resetIncrementalReadState(state) {
144
+ if (state.identity !== undefined ||
145
+ state.offset > 0 ||
146
+ state.pending.length > 0 ||
147
+ state.continuity.length > 0) {
148
+ state.generation += 1;
149
+ }
150
+ state.caughtUp = true;
151
+ state.continuity = Buffer.alloc(0);
152
+ state.offset = 0;
153
+ state.pending = Buffer.alloc(0);
154
+ }
155
+ async function readBufferAt(handle, length, position) {
156
+ const buffer = Buffer.alloc(length);
157
+ let bytesRead = 0;
158
+ while (bytesRead < length) {
159
+ const result = await handle.read(buffer, bytesRead, length - bytesRead, position + bytesRead);
160
+ if (result.bytesRead === 0) {
161
+ break;
162
+ }
163
+ bytesRead += result.bytesRead;
164
+ }
165
+ return buffer.subarray(0, bytesRead);
166
+ }
167
+ function consumeRecordedChunk(state, chunk) {
168
+ const continuity = Buffer.concat([state.continuity, chunk]);
169
+ state.continuity = Buffer.from(continuity.subarray(-CONTINUITY_BYTES));
170
+ const raw = state.pending.length > 0 ? Buffer.concat([state.pending, chunk]) : chunk;
171
+ const lastNewline = raw.lastIndexOf(0x0a);
172
+ if (lastNewline < 0) {
173
+ if (raw.length > MAX_PENDING_RECORD_BYTES) {
174
+ throw new Error(`Recorder record exceeded ${MAX_PENDING_RECORD_BYTES} bytes without a newline.`);
175
+ }
176
+ state.pending = Buffer.from(raw);
177
+ return [];
178
+ }
179
+ state.pending = Buffer.from(raw.subarray(lastNewline + 1));
180
+ if (state.pending.length > MAX_PENDING_RECORD_BYTES) {
181
+ throw new Error(`Recorder record exceeded ${MAX_PENDING_RECORD_BYTES} bytes without a newline.`);
182
+ }
183
+ const events = [];
184
+ for (const line of raw.subarray(0, lastNewline).toString("utf8").split("\n")) {
185
+ if (line.trim()) {
186
+ events.push(...parseRecordedLine(line));
187
+ }
188
+ }
189
+ return events;
190
+ }
191
+ async function appendJsonLine(filePath, line) {
192
+ await serializeAppend(filePath, async (publicationPath, logicalPath) => {
193
+ await appendCommittedLine(publicationPath, logicalPath, line, true);
194
+ });
195
+ }
196
+ async function readRecorderFileIdentity(filePath) {
197
+ try {
198
+ const stats = await lstat(filePath, { bigint: true });
199
+ if (!stats.isFile()) {
200
+ throw new Error(`Recorder path is not a regular file: ${filePath}`);
201
+ }
202
+ return {
203
+ dev: stats.dev,
204
+ ino: stats.ino,
205
+ };
206
+ }
207
+ catch (error) {
208
+ if (error.code === "ENOENT") {
209
+ return undefined;
210
+ }
211
+ throw error;
212
+ }
213
+ }
214
+ function sameRecorderFileIdentity(left, right) {
215
+ return left?.dev === right?.dev && left?.ino === right?.ino;
216
+ }
217
+ async function resolveRecorderPublicationPath(filePath) {
218
+ try {
219
+ return await realpath(filePath);
220
+ }
221
+ catch (error) {
222
+ if (error.code !== "ENOENT") {
223
+ throw error;
224
+ }
225
+ }
226
+ try {
227
+ const stats = await lstat(filePath);
228
+ if (stats.isSymbolicLink()) {
229
+ const target = await readlink(filePath);
230
+ return await resolveRecorderPublicationPath(path.resolve(path.dirname(filePath), target));
231
+ }
232
+ }
233
+ catch (error) {
234
+ if (error.code !== "ENOENT") {
235
+ throw error;
236
+ }
237
+ }
238
+ return path.join(await realpath(path.dirname(filePath)), path.basename(filePath));
239
+ }
240
+ async function prepareRecorderTailForAppend(handle) {
241
+ const stats = await handle.stat();
242
+ if (stats.size === 0) {
243
+ return false;
244
+ }
245
+ const finalByte = await readBufferAt(handle, 1, stats.size - 1);
246
+ if (finalByte[0] === 0x0a) {
247
+ return false;
248
+ }
249
+ const windowSize = Math.min(stats.size, MAX_PENDING_RECORD_BYTES + 1);
250
+ const tailWindow = await readBufferAt(handle, windowSize, stats.size - windowSize);
251
+ const lastNewline = tailWindow.lastIndexOf(0x0a);
252
+ if (lastNewline < 0 && stats.size > MAX_PENDING_RECORD_BYTES) {
253
+ throw new Error(`Recorder record exceeded ${MAX_PENDING_RECORD_BYTES} bytes without a newline.`);
254
+ }
255
+ const tailStart = stats.size - windowSize + lastNewline + 1;
256
+ const tail = tailWindow.subarray(lastNewline + 1).toString("utf8");
257
+ try {
258
+ JSON.parse(tail);
259
+ await handle.writeFile("\n", "utf8");
260
+ return true;
261
+ }
262
+ catch (error) {
263
+ if (!(error instanceof SyntaxError)) {
264
+ throw error;
265
+ }
266
+ await handle.truncate(tailStart);
267
+ return true;
268
+ }
269
+ }
270
+ async function prepareRecorderPathForAppend(publicationPath, logicalPath, durable) {
271
+ const handle = await open(publicationPath, "a+");
272
+ const identity = await handle.stat({ bigint: true });
273
+ const recorderIdentity = { dev: identity.dev, ino: identity.ino };
274
+ try {
275
+ const changed = await prepareRecorderTailForAppend(handle);
276
+ if (changed && durable) {
277
+ await handle.sync();
278
+ }
279
+ }
280
+ finally {
281
+ await handle.close();
282
+ }
283
+ if (!sameRecorderFileIdentity(recorderIdentity, await readRecorderFileIdentity(await resolveRecorderPublicationPath(logicalPath)))) {
284
+ throw new RecorderRotatedError("Recorder rotated while preparing a committed line.");
285
+ }
286
+ return recorderIdentity;
287
+ }
288
+ async function appendCommittedLine(publicationPath, logicalPath, line, durable, expectedIdentity) {
289
+ const handle = await open(publicationPath, "a+");
290
+ const identity = await handle.stat({ bigint: true });
291
+ const recorderIdentity = { dev: identity.dev, ino: identity.ino };
292
+ try {
293
+ if (expectedIdentity !== undefined &&
294
+ !sameRecorderFileIdentity(expectedIdentity, recorderIdentity)) {
295
+ throw new RecorderRotatedError("Recorder rotated before appending a committed line.");
296
+ }
297
+ await prepareRecorderTailForAppend(handle);
298
+ await handle.writeFile(line, "utf8");
299
+ if (durable) {
300
+ await handle.sync();
301
+ }
302
+ }
303
+ finally {
304
+ await handle.close();
305
+ }
306
+ if (!sameRecorderFileIdentity(recorderIdentity, await readRecorderFileIdentity(await resolveRecorderPublicationPath(logicalPath)))) {
307
+ throw new RecorderRotatedError("Recorder rotated while appending a committed line.");
308
+ }
309
+ }
310
+ function recorderLockReleaseError(filePath, operationError, releaseError) {
311
+ return new AggregateError([operationError, releaseError], `Recorder append and lock release both failed for "${filePath}".`, { cause: operationError });
312
+ }
313
+ async function withRecorderLock(filePath, operation) {
314
+ const release = await lock(filePath, {
315
+ realpath: false,
316
+ retries: {
317
+ factor: 1,
318
+ maxTimeout: 10,
319
+ minTimeout: 10,
320
+ retries: 500,
321
+ },
322
+ stale: RECORDER_LOCK_STALE_MS,
323
+ update: RECORDER_LOCK_UPDATE_MS,
324
+ });
325
+ let operationFailed = false;
326
+ let operationError;
327
+ let result;
328
+ try {
329
+ result = await operation();
330
+ }
331
+ catch (error) {
332
+ operationFailed = true;
333
+ operationError = error;
334
+ }
335
+ try {
336
+ await release();
337
+ }
338
+ catch (releaseError) {
339
+ if (operationFailed) {
340
+ throw recorderLockReleaseError(filePath, operationError, releaseError);
341
+ }
342
+ throw releaseError;
343
+ }
344
+ if (operationFailed) {
345
+ throw operationError;
346
+ }
347
+ return result;
348
+ }
349
+ async function serializeAppend(filePath, operation) {
350
+ const logicalPath = path.resolve(filePath);
351
+ const key = await resolveRecorderPublicationPath(logicalPath);
352
+ const previous = pendingAppends.get(key) ?? Promise.resolve();
353
+ let result;
354
+ const current = previous
355
+ .catch(() => { })
356
+ .then(async () => {
357
+ result = await withRecorderLock(key, async () => await operation(key, logicalPath));
358
+ });
359
+ pendingAppends.set(key, current);
360
+ try {
361
+ await current;
362
+ return result;
363
+ }
364
+ finally {
365
+ if (pendingAppends.get(key) === current) {
366
+ pendingAppends.delete(key);
367
+ }
368
+ }
8
369
  }
9
- const MAX_WATCH_SEEN_KEYS = 4096;
10
370
  async function readRecordedInboundAppend(filePath, state) {
11
371
  let handle;
12
372
  try {
@@ -15,54 +375,49 @@ async function readRecordedInboundAppend(filePath, state) {
15
375
  catch (error) {
16
376
  const code = error.code;
17
377
  if (code === "ENOENT") {
18
- state.offset = 0;
19
- state.pending = Buffer.alloc(0);
378
+ resetIncrementalReadState(state);
379
+ state.identity = undefined;
20
380
  return [];
21
381
  }
22
382
  throw error;
23
383
  }
24
384
  try {
25
- const size = (await handle.stat()).size;
26
- if (size < state.offset) {
27
- state.offset = 0;
28
- state.pending = Buffer.alloc(0);
385
+ const stats = await handle.stat();
386
+ const identity = { dev: stats.dev, ino: stats.ino };
387
+ const sameFile = state.identity?.dev === identity.dev && state.identity.ino === identity.ino;
388
+ const rotated = state.identity !== undefined && !sameFile;
389
+ let hasContinuity = stats.size >= state.offset;
390
+ if (hasContinuity && state.offset > 0 && state.continuity.length === 0) {
391
+ hasContinuity = sameFile;
29
392
  }
30
- const chunks = [];
393
+ else if (hasContinuity && state.continuity.length > 0) {
394
+ const actual = await readBufferAt(handle, state.continuity.length, state.offset - state.continuity.length);
395
+ hasContinuity = actual.equals(state.continuity);
396
+ }
397
+ if (!hasContinuity) {
398
+ resetIncrementalReadState(state);
399
+ }
400
+ else if (rotated) {
401
+ state.generation += 1;
402
+ }
403
+ state.identity = identity;
404
+ const events = [];
31
405
  let position = state.offset;
32
- while (position < size) {
33
- const chunk = Buffer.alloc(Math.min(64 * 1024, size - position));
406
+ let remainingBatchBytes = MAX_INCREMENTAL_READ_BYTES;
407
+ let reachedUnexpectedEof = false;
408
+ while (position < stats.size && remainingBatchBytes > 0) {
409
+ const chunk = Buffer.alloc(Math.min(64 * 1024, stats.size - position, remainingBatchBytes));
34
410
  const { bytesRead } = await handle.read(chunk, 0, chunk.length, position);
35
411
  if (bytesRead === 0) {
412
+ reachedUnexpectedEof = true;
36
413
  break;
37
414
  }
38
- chunks.push(chunk.subarray(0, bytesRead));
39
415
  position += bytesRead;
416
+ state.offset = position;
417
+ remainingBatchBytes -= bytesRead;
418
+ events.push(...consumeRecordedChunk(state, chunk.subarray(0, bytesRead)));
40
419
  }
41
- state.offset = position;
42
- const raw = Buffer.concat([state.pending, ...chunks]);
43
- const lastNewline = raw.lastIndexOf(0x0a);
44
- if (lastNewline < 0) {
45
- state.pending = raw;
46
- return [];
47
- }
48
- state.pending = raw.subarray(lastNewline + 1);
49
- const lines = raw
50
- .subarray(0, lastNewline)
51
- .toString("utf8")
52
- .split("\n")
53
- .filter((line) => line.trim().length > 0);
54
- const events = [];
55
- for (const [index, line] of lines.entries()) {
56
- try {
57
- events.push(JSON.parse(line));
58
- }
59
- catch (error) {
60
- if (index === lines.length - 1 && state.pending.length === 0) {
61
- continue;
62
- }
63
- throw error;
64
- }
65
- }
420
+ state.caughtUp = reachedUnexpectedEof || position >= stats.size;
66
421
  return events;
67
422
  }
68
423
  finally {
@@ -75,9 +430,92 @@ export async function appendRecordedInbound(filePath, event) {
75
430
  ...event,
76
431
  recordedAt: new Date().toISOString(),
77
432
  };
78
- await appendFile(filePath, `${JSON.stringify(recorded)}\n`, "utf8");
433
+ const line = `${JSON.stringify(recorded)}\n`;
434
+ if (Buffer.byteLength(line) > MAX_PENDING_RECORD_BYTES) {
435
+ throw new Error(`Recorder record exceeded ${MAX_PENDING_RECORD_BYTES} bytes without a newline.`);
436
+ }
437
+ await appendJsonLine(filePath, line);
79
438
  return recorded;
80
439
  }
440
+ export async function appendRecordedInboundBatch(filePath, events) {
441
+ if (events.length === 0) {
442
+ return [];
443
+ }
444
+ await mkdir(path.dirname(filePath), { recursive: true });
445
+ for (let attempt = 0;; attempt++) {
446
+ try {
447
+ return await serializeAppend(filePath, async (publicationPath, logicalPath) => {
448
+ const generation = await prepareRecorderPathForAppend(publicationPath, logicalPath, true);
449
+ const seen = await syncRecordIdentityIndex(publicationPath);
450
+ const pendingIdentities = new Set();
451
+ const recorded = [];
452
+ for (const event of events) {
453
+ const identity = recordIdentity(event);
454
+ if (seen.has(identity) || pendingIdentities.has(identity)) {
455
+ continue;
456
+ }
457
+ pendingIdentities.add(identity);
458
+ recorded.push({
459
+ ...event,
460
+ recordedAt: new Date().toISOString(),
461
+ });
462
+ }
463
+ if (recorded.length > 0) {
464
+ const batch = {
465
+ events: recorded,
466
+ recordType: "crabline.recorder.batch",
467
+ recorderBatchVersion: RECORDER_BATCH_VERSION,
468
+ };
469
+ const line = `${JSON.stringify(batch)}\n`;
470
+ if (Buffer.byteLength(line) > MAX_PENDING_RECORD_BYTES) {
471
+ throw new Error(`Recorder record exceeded ${MAX_PENDING_RECORD_BYTES} bytes without a newline.`);
472
+ }
473
+ await appendCommittedLine(publicationPath, logicalPath, line, true, generation);
474
+ await syncRecordIdentityIndex(publicationPath);
475
+ }
476
+ else if (!sameRecorderFileIdentity(generation, await readRecorderFileIdentity(await resolveRecorderPublicationPath(logicalPath)))) {
477
+ throw new RecorderRotatedError("Recorder rotated before confirming a duplicate batch.");
478
+ }
479
+ return recorded;
480
+ });
481
+ }
482
+ catch (error) {
483
+ if (!(error instanceof RecorderRotatedError) || attempt + 1 >= RECORDER_ROTATION_ATTEMPTS) {
484
+ throw error;
485
+ }
486
+ }
487
+ }
488
+ }
489
+ function recordIdentity(event) {
490
+ return JSON.stringify([event.provider, event.threadId, event.id, recordedDirectionOf(event)]);
491
+ }
492
+ async function syncRecordIdentityIndex(filePath) {
493
+ const key = path.resolve(filePath);
494
+ let index = recordIdentityIndexes.get(key);
495
+ if (!index) {
496
+ index = { readState: createIncrementalReadState(), seen: new Set() };
497
+ recordIdentityIndexes.set(key, index);
498
+ if (recordIdentityIndexes.size > MAX_RECORD_IDENTITY_INDEXES) {
499
+ recordIdentityIndexes.delete(recordIdentityIndexes.keys().next().value);
500
+ }
501
+ }
502
+ else {
503
+ recordIdentityIndexes.delete(key);
504
+ recordIdentityIndexes.set(key, index);
505
+ }
506
+ let generation = index.readState.generation;
507
+ do {
508
+ const appended = await readRecordedInboundAppend(filePath, index.readState);
509
+ if (index.readState.generation !== generation) {
510
+ index.seen.clear();
511
+ generation = index.readState.generation;
512
+ }
513
+ for (const event of appended) {
514
+ rememberRecentRecord(index.seen, event);
515
+ }
516
+ } while (!index.readState.caughtUp);
517
+ return index.seen;
518
+ }
81
519
  export async function readRecordedInbound(filePath) {
82
520
  let raw = "";
83
521
  try {
@@ -90,62 +528,90 @@ export async function readRecordedInbound(filePath) {
90
528
  }
91
529
  throw error;
92
530
  }
93
- const lines = raw.split("\n").filter((line) => line.trim().length > 0);
531
+ const lastNewline = raw.lastIndexOf("\n");
532
+ const completed = lastNewline >= 0 ? raw.slice(0, lastNewline) : "";
533
+ const tail = raw.slice(lastNewline + 1);
94
534
  const events = [];
95
- for (const [index, line] of lines.entries()) {
535
+ for (const line of completed.split("\n")) {
536
+ if (line.trim()) {
537
+ events.push(...parseRecordedLine(line));
538
+ }
539
+ }
540
+ if (tail.trim()) {
96
541
  try {
97
- events.push(JSON.parse(line));
542
+ events.push(...parseRecordedLine(tail));
98
543
  }
99
544
  catch (error) {
100
- if (index === lines.length - 1) {
101
- continue;
545
+ if (!(error instanceof SyntaxError)) {
546
+ throw error;
102
547
  }
103
- throw error;
548
+ // Ignore a syntactically partial final append; completed lines remain strict.
104
549
  }
105
550
  }
106
551
  return events;
107
552
  }
108
553
  export async function waitForRecordedInbound(params) {
109
554
  const deadline = Date.now() + params.timeoutMs;
110
- const seen = new Set();
111
- while (Date.now() <= deadline) {
112
- const events = await readRecordedInbound(params.filePath);
113
- for (const event of events) {
114
- const key = toRecordKey(event);
115
- if (seen.has(key)) {
555
+ const cursor = params.cursor ?? createRecordedInboundCursor();
556
+ while (!params.signal?.aborted && Date.now() <= deadline) {
557
+ const generation = cursor.readState.generation;
558
+ const events = cursor.buffered.length > 0
559
+ ? cursor.buffered.splice(0)
560
+ : await readRecordedInboundAppend(params.filePath, cursor.readState);
561
+ if (cursor.readState.generation !== generation) {
562
+ cursor.seen.clear();
563
+ }
564
+ for (const [index, event] of events.entries()) {
565
+ if (params.recordedDirection && recordedDirectionOf(event) !== params.recordedDirection) {
566
+ continue;
567
+ }
568
+ // Incremental read state owns progress; this bounded window only filters appended retries.
569
+ if (!rememberRecentRecord(cursor.seen, event)) {
116
570
  continue;
117
571
  }
118
- seen.add(key);
119
572
  if (params.since && new Date(event.sentAt).getTime() < new Date(params.since).getTime()) {
120
573
  continue;
121
574
  }
122
575
  if (params.matches(event)) {
576
+ cursor.buffered.push(...events.slice(index + 1));
123
577
  return event;
124
578
  }
125
579
  }
126
- await sleep(params.pollMs ?? 200);
580
+ if (!cursor.readState.caughtUp) {
581
+ if (Date.now() >= deadline) {
582
+ return null;
583
+ }
584
+ continue;
585
+ }
586
+ const remainingMs = deadline - Date.now();
587
+ if (remainingMs <= 0) {
588
+ return null;
589
+ }
590
+ await sleep(Math.min(params.pollMs ?? 200, remainingMs), params.signal);
127
591
  }
128
592
  return null;
129
593
  }
130
594
  export async function* watchRecordedInbound(params) {
131
- const state = {
132
- offset: 0,
133
- pending: Buffer.alloc(0),
134
- };
595
+ const state = createIncrementalReadState();
135
596
  const seen = new Set();
136
- while (true) {
597
+ while (!params.signal?.aborted) {
598
+ const generation = state.generation;
137
599
  const events = await readRecordedInboundAppend(params.filePath, state);
600
+ if (state.generation !== generation) {
601
+ seen.clear();
602
+ }
603
+ if (params.signal?.aborted) {
604
+ return;
605
+ }
138
606
  for (const event of events) {
139
- const key = toRecordKey(event);
140
- if (seen.has(key)) {
607
+ if (params.signal?.aborted) {
608
+ return;
609
+ }
610
+ if (!rememberRecentRecord(seen, event)) {
141
611
  continue;
142
612
  }
143
- seen.add(key);
144
- if (seen.size > MAX_WATCH_SEEN_KEYS) {
145
- const oldest = seen.values().next().value;
146
- if (oldest !== undefined) {
147
- seen.delete(oldest);
148
- }
613
+ if (params.recordedDirection && recordedDirectionOf(event) !== params.recordedDirection) {
614
+ continue;
149
615
  }
150
616
  if (params.since && new Date(event.sentAt).getTime() < new Date(params.since).getTime()) {
151
617
  continue;
@@ -154,7 +620,10 @@ export async function* watchRecordedInbound(params) {
154
620
  yield event;
155
621
  }
156
622
  }
157
- await sleep(params.pollMs ?? 250);
623
+ if (!state.caughtUp) {
624
+ continue;
625
+ }
626
+ await sleep(params.pollMs ?? 250, params.signal);
158
627
  }
159
628
  }
160
629
  //# sourceMappingURL=recorder.js.map