@rei-standard/amsg-server 2.6.0-next.13 → 2.6.0-next.16

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 (38) hide show
  1. package/README.md +109 -1
  2. package/dist/adapters/d1.d.ts +130 -0
  3. package/dist/adapters/interface.d.ts +67 -0
  4. package/dist/adapters/neon.d.ts +20 -58
  5. package/dist/adapters/pg-shared.d.ts +114 -0
  6. package/dist/adapters/pg.d.ts +20 -58
  7. package/dist/adapters/schema.d.ts +1 -1
  8. package/dist/adapters/schema.sqlite.d.ts +15 -1
  9. package/dist/{chunk-B2FDV7YK.mjs → chunk-34I2YSWE.mjs} +10 -0
  10. package/dist/{chunk-5IOYCCFD.cjs → chunk-H4V7OXHE.cjs} +1326 -384
  11. package/dist/{chunk-TV7N3RTH.mjs → chunk-KVHR3RCU.mjs} +1324 -382
  12. package/dist/{chunk-V2SDWGUB.cjs → chunk-OUPM4HAV.cjs} +10 -0
  13. package/dist/chunk-TFFBMYA2.cjs +95 -0
  14. package/dist/chunk-UYROU4L3.mjs +95 -0
  15. package/dist/cloudflare/single-user-worker.d.ts +35 -2
  16. package/dist/cloudflare.cjs +17 -2
  17. package/dist/cloudflare.d.ts +3 -1
  18. package/dist/cloudflare.mjs +18 -3
  19. package/dist/handlers/outbox.d.ts +7 -0
  20. package/dist/index.cjs +72 -36
  21. package/dist/index.d.cts +5 -2
  22. package/dist/index.d.ts +5 -2
  23. package/dist/index.mjs +53 -17
  24. package/dist/lib/client-state-store.d.ts +18 -3
  25. package/dist/lib/errors.d.ts +93 -0
  26. package/dist/lib/message-processor.d.ts +8 -2
  27. package/dist/lib/outbox-store.d.ts +33 -0
  28. package/dist/lib/push-subscription-store.d.ts +10 -2
  29. package/dist/lib/request.d.ts +36 -0
  30. package/dist/lib/run-tick.d.ts +38 -0
  31. package/dist/lib/schema-version.d.ts +50 -0
  32. package/dist/lib/validation.d.ts +1 -7
  33. package/dist/{neon-3ASBLLCM.cjs → neon-G3G4TDAY.cjs} +52 -112
  34. package/dist/{neon-CUIGAB2T.mjs → neon-GBUF4G76.mjs} +44 -104
  35. package/dist/{pg-IEZTAR43.cjs → pg-C3JZKBUM.cjs} +49 -112
  36. package/dist/{pg-KMJPL6QV.mjs → pg-MRXP5MHJ.mjs} +41 -104
  37. package/dist/single-user.d.ts +4 -0
  38. package/package.json +2 -2
@@ -1,3 +1,7 @@
1
+ import {
2
+ UPDATABLE_COLUMNS
3
+ } from "./chunk-34I2YSWE.mjs";
4
+
1
5
  // src/server/lib/recurrence.js
2
6
  var DAY_MS = 24 * 60 * 60 * 1e3;
3
7
  var MAX_LISTED_SKIPPED_OCCURRENCES = 32;
@@ -121,19 +125,11 @@ function nextFutureOccurrence(occurrenceMs, recurrenceType, nowMs, tzId) {
121
125
  }
122
126
 
123
127
  // src/server/lib/validation.js
124
- import { validateAvatarUrl, validateLlmMessagesShape } from "@rei-standard/amsg-shared";
128
+ import { isValidUrl, validateAvatarUrl, validateLlmMessagesShape } from "@rei-standard/amsg-shared";
125
129
  function isValidISO8601(dateString) {
126
130
  const date = new Date(dateString);
127
131
  return date instanceof Date && !isNaN(date.getTime());
128
132
  }
129
- function isValidUrl(urlString) {
130
- try {
131
- new URL(urlString);
132
- return true;
133
- } catch {
134
- return false;
135
- }
136
- }
137
133
  function isValidUUID(uuid) {
138
134
  const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
139
135
  return uuidRegex.test(uuid);
@@ -168,9 +164,9 @@ function validateSplitPattern(value) {
168
164
  return null;
169
165
  }
170
166
  function validateLlmMessagesArray(messages) {
171
- const err3 = validateLlmMessagesShape(messages);
172
- if (!err3) return null;
173
- const { code, index: i, toolCallIndex: j } = err3;
167
+ const err4 = validateLlmMessagesShape(messages);
168
+ if (!err4) return null;
169
+ const { code, index: i, toolCallIndex: j } = err4;
174
170
  switch (code) {
175
171
  case "MESSAGES_NOT_ARRAY":
176
172
  return "messages must be a non-empty array";
@@ -200,11 +196,32 @@ function validateScheduleMessagePayload(payload) {
200
196
  if (!payload.messageType || !["fixed", "prompted", "auto", "instant"].includes(payload.messageType)) {
201
197
  return { valid: false, errorCode: "INVALID_MESSAGE_TYPE", errorMessage: "\u6D88\u606F\u7C7B\u578B\u65E0\u6548", details: { providedType: payload.messageType, allowedTypes: ["fixed", "prompted", "auto", "instant"] } };
202
198
  }
203
- if (!payload.firstSendTime || !isValidISO8601(payload.firstSendTime)) {
204
- return { valid: false, errorCode: "INVALID_TIMESTAMP", errorMessage: "\u65F6\u95F4\u683C\u5F0F\u65E0\u6548", details: { field: "firstSendTime" } };
199
+ if (payload.immediate !== void 0 && typeof payload.immediate !== "boolean") {
200
+ return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: "immediate \u5FC5\u987B\u662F\u5E03\u5C14\u503C", details: { invalidFields: ["immediate"] } };
205
201
  }
206
- if (payload.firstSendTime && new Date(payload.firstSendTime) <= /* @__PURE__ */ new Date()) {
207
- return { valid: false, errorCode: "INVALID_TIMESTAMP", errorMessage: "\u65F6\u95F4\u5FC5\u987B\u5728\u672A\u6765", details: { field: "firstSendTime", reason: "must be in the future" } };
202
+ const immediate = payload.immediate === true;
203
+ if (immediate && payload.messageType === "instant") {
204
+ return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: "messageType 'instant' \u672C\u5C31\u7ACB\u5373\u6295\u9012\uFF0C\u4E0D\u80FD\u518D\u5E26 immediate", details: { invalidFields: ["immediate"] } };
205
+ }
206
+ if (immediate) {
207
+ if (payload.firstSendTime !== void 0 && payload.firstSendTime !== null && !isValidISO8601(payload.firstSendTime)) {
208
+ return { valid: false, errorCode: "INVALID_TIMESTAMP", errorMessage: "\u65F6\u95F4\u683C\u5F0F\u65E0\u6548", details: { field: "firstSendTime" } };
209
+ }
210
+ } else {
211
+ if (!payload.firstSendTime || !isValidISO8601(payload.firstSendTime)) {
212
+ return { valid: false, errorCode: "INVALID_TIMESTAMP", errorMessage: "\u65F6\u95F4\u683C\u5F0F\u65E0\u6548", details: { field: "firstSendTime" } };
213
+ }
214
+ if (payload.firstSendTime && new Date(payload.firstSendTime) <= /* @__PURE__ */ new Date()) {
215
+ return { valid: false, errorCode: "INVALID_TIMESTAMP", errorMessage: "\u65F6\u95F4\u5FC5\u987B\u5728\u672A\u6765", details: { field: "firstSendTime", reason: "must be in the future" } };
216
+ }
217
+ }
218
+ if (payload.supersedesUuid !== void 0 && payload.supersedesUuid !== null) {
219
+ if (typeof payload.supersedesUuid !== "string" || !isValidUUID(payload.supersedesUuid)) {
220
+ return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: "supersedesUuid \u5FC5\u987B\u662F\u5408\u6CD5 UUID", details: { invalidFields: ["supersedesUuid"] } };
221
+ }
222
+ if (payload.uuid && payload.supersedesUuid === payload.uuid) {
223
+ return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: "supersedesUuid \u4E0D\u80FD\u4E0E\u672C\u6761\u4EFB\u52A1\u7684 uuid \u76F8\u540C", details: { invalidFields: ["supersedesUuid"] } };
224
+ }
208
225
  }
209
226
  if (payload.pushSubscription !== void 0) {
210
227
  return {
@@ -239,10 +256,10 @@ function validateScheduleMessagePayload(payload) {
239
256
  };
240
257
  }
241
258
  if (hasMessages) {
242
- const err3 = validateLlmMessagesArray(payload.messages);
243
- if (err3) {
259
+ const err4 = validateLlmMessagesArray(payload.messages);
260
+ if (err4) {
244
261
  return {
245
- error: { code: "INVALID_PARAMETERS", message: err3, details: { invalidFields: ["messages"] } },
262
+ error: { code: "INVALID_PARAMETERS", message: err4, details: { invalidFields: ["messages"] } },
246
263
  hasCompletePrompt: false,
247
264
  hasMessages: true
248
265
  };
@@ -292,6 +309,9 @@ function validateScheduleMessagePayload(payload) {
292
309
  if (splitErr) {
293
310
  return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: splitErr, details: { invalidFields: ["splitPattern"] } };
294
311
  }
312
+ if (payload.llmExtraBody !== void 0 && payload.llmExtraBody !== null && (typeof payload.llmExtraBody !== "object" || Array.isArray(payload.llmExtraBody))) {
313
+ return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: "llmExtraBody \u5FC5\u987B\u662F\u666E\u901A\u5BF9\u8C61", details: { invalidFields: ["llmExtraBody"] } };
314
+ }
295
315
  return { valid: true };
296
316
  }
297
317
 
@@ -436,6 +456,16 @@ function parseEncryptedBody(body) {
436
456
  }
437
457
  return parsedBody;
438
458
  }
459
+ function errorResponse(status, code, message, details) {
460
+ const error = details === void 0 ? { code, message } : { code, message, details };
461
+ return { status, body: { success: false, error } };
462
+ }
463
+ function requireUserId(headers) {
464
+ const userId = getHeader(headers, "x-user-id");
465
+ if (!userId) return { error: errorResponse(400, "USER_ID_REQUIRED", "\u7F3A\u5C11\u7528\u6237\u6807\u8BC6\u7B26") };
466
+ if (!isValidUUIDv4(userId)) return { error: errorResponse(400, "INVALID_USER_ID_FORMAT", "X-User-Id \u5FC5\u987B\u662F UUID v4 \u683C\u5F0F") };
467
+ return { userId };
468
+ }
439
469
  function getHeader(headers = {}, name) {
440
470
  if (!headers || typeof headers !== "object") {
441
471
  return "";
@@ -459,19 +489,9 @@ function createGetUserKeyHandler(ctx) {
459
489
  return tenantResult.error;
460
490
  }
461
491
  const { masterKey } = tenantResult.context;
462
- const userId = getHeader(effectiveHeaders, "x-user-id");
463
- if (!userId) {
464
- return {
465
- status: 400,
466
- body: { success: false, error: { code: "USER_ID_REQUIRED", message: "\u7F3A\u5C11\u7528\u6237\u6807\u8BC6\u7B26" } }
467
- };
468
- }
469
- if (!isValidUUIDv4(userId)) {
470
- return {
471
- status: 400,
472
- body: { success: false, error: { code: "INVALID_USER_ID_FORMAT", message: "X-User-Id \u5FC5\u987B\u662F UUID v4 \u683C\u5F0F" } }
473
- };
474
- }
492
+ const gate = requireUserId(effectiveHeaders);
493
+ if (gate.error) return gate.error;
494
+ const { userId } = gate;
475
495
  return {
476
496
  status: 200,
477
497
  body: {
@@ -486,6 +506,14 @@ function createGetUserKeyHandler(ctx) {
486
506
  return { GET };
487
507
  }
488
508
 
509
+ // src/server/lib/agentic-fire.js
510
+ import {
511
+ assertValidDecision,
512
+ buildSessionContext,
513
+ extractAssistantMessage,
514
+ extractToolCallsFromDecision
515
+ } from "@rei-standard/amsg-shared";
516
+
489
517
  // src/server/lib/db-errors.js
490
518
  function isUniqueViolation(error) {
491
519
  if (!error || typeof error !== "object") return false;
@@ -495,46 +523,6 @@ function isUniqueViolation(error) {
495
523
  return message.includes("duplicate key") || message.includes("unique constraint");
496
524
  }
497
525
 
498
- // src/server/lib/push-subscription-store.js
499
- function supportsPushSubscriptionStore(db) {
500
- return !!db && typeof db.getPushSubscription === "function" && typeof db.upsertPushSubscription === "function" && typeof db.deletePushSubscription === "function";
501
- }
502
- function isPushSubscriptionShape(subscription) {
503
- return !!subscription && typeof subscription === "object" && !Array.isArray(subscription) && typeof /** @type {{ endpoint?: unknown }} */
504
- subscription.endpoint === "string" && /** @type {{ endpoint: string }} */
505
- subscription.endpoint.trim().length > 0;
506
- }
507
- async function savePushSubscription({ db, userId, userKey, subscription, updatedAt }) {
508
- const at = Number.isInteger(updatedAt) && updatedAt > 0 ? updatedAt : Date.now();
509
- const encrypted = await encryptForStorage(JSON.stringify(subscription), userKey);
510
- await db.upsertPushSubscription(userId, encrypted, at);
511
- return { updatedAt: at };
512
- }
513
- async function loadPushSubscription({ db, userId, userKey }) {
514
- const row = await db.getPushSubscription(userId);
515
- if (!row || typeof row.subscription !== "string" || !row.subscription) return null;
516
- const subscription = JSON.parse(await decryptFromStorage(row.subscription, userKey));
517
- return { subscription, updatedAt: row.updated_at ?? null };
518
- }
519
- async function resolvePushSubscription({ db, userId, userKey }) {
520
- if (!supportsPushSubscriptionStore(db)) {
521
- throw new Error("PUSH_SUBSCRIPTION_STORE_UNSUPPORTED: \u5F53\u524D\u6570\u636E\u5E93\u9002\u914D\u5668\u4E0D\u652F\u6301\u7528\u6237\u7EA7\u63A8\u9001\u8BA2\u9605\u5B58\u50A8");
522
- }
523
- const stored = await loadPushSubscription({ db, userId, userKey });
524
- if (!stored) {
525
- throw new Error("PUSH_SUBSCRIPTION_MISSING: \u8BE5\u7528\u6237\u8FD8\u6CA1\u6709\u767B\u8BB0\u63A8\u9001\u8BA2\u9605\uFF08PUT /push-subscription\uFF09");
526
- }
527
- return stored.subscription;
528
- }
529
-
530
- // src/server/lib/message-processor.js
531
- import {
532
- buildContentPush,
533
- buildReasoningPush,
534
- readReasoningContent,
535
- stripReasoningTags
536
- } from "@rei-standard/amsg-shared";
537
-
538
526
  // src/server/lib/llm.js
539
527
  import {
540
528
  callLlm,
@@ -542,14 +530,6 @@ import {
542
530
  normalizeAiApiUrl
543
531
  } from "@rei-standard/amsg-shared";
544
532
 
545
- // src/server/lib/agentic-fire.js
546
- import {
547
- assertValidDecision,
548
- buildSessionContext,
549
- extractAssistantMessage,
550
- extractToolCallsFromDecision
551
- } from "@rei-standard/amsg-shared";
552
-
553
533
  // src/server/lib/state-chunks.js
554
534
  import { chunkReasoningByUtf8Bytes } from "@rei-standard/amsg-shared";
555
535
  var STATE_CHUNK_SLICE_BYTES = 200 * 1024;
@@ -635,29 +615,32 @@ async function writeClientStateEntries({ db, userId, userKey, entries }) {
635
615
  const physicalRows = [];
636
616
  const cleanups = [];
637
617
  const rootRowIndexes = [];
618
+ const rootRowEntries = [];
638
619
  let deleted = 0;
639
620
  for (const entry of entries) {
621
+ const guardAt = Number.isInteger(entry.version) && entry.version > 0 ? entry.version : entry.updatedAt;
640
622
  cleanups.push({
641
623
  namespace: chunkNamespaceFor(entry.namespace),
642
624
  keyPrefix: chunkKeyPrefixFor(entry.key),
643
- updatedAt: entry.updatedAt
625
+ updatedAt: guardAt
644
626
  });
645
627
  if (entry.value === null) {
646
628
  cleanups.push({
647
629
  namespace: entry.namespace,
648
630
  key: entry.key,
649
- updatedAt: entry.updatedAt
631
+ updatedAt: guardAt
650
632
  });
651
633
  deleted++;
652
634
  continue;
653
635
  }
654
636
  rootRowIndexes.push(physicalRows.length);
637
+ rootRowEntries.push(entry);
655
638
  if (stateValueBytes(entry.value) <= STATE_CHUNK_SLICE_BYTES) {
656
639
  physicalRows.push({
657
640
  namespace: entry.namespace,
658
641
  key: entry.key,
659
642
  value: await encryptForStorage(entry.value, userKey),
660
- updatedAt: entry.updatedAt
643
+ updatedAt: guardAt
661
644
  });
662
645
  } else {
663
646
  const slices = splitStateValue(entry.value);
@@ -665,7 +648,7 @@ async function writeClientStateEntries({ db, userId, userKey, entries }) {
665
648
  namespace: entry.namespace,
666
649
  key: entry.key,
667
650
  value: buildChunkedRootValue(slices.length),
668
- updatedAt: entry.updatedAt
651
+ updatedAt: guardAt
669
652
  });
670
653
  const encryptedSlices = await Promise.all(slices.map((slice) => encryptForStorage(slice, userKey)));
671
654
  for (let c = 0; c < encryptedSlices.length; c++) {
@@ -673,27 +656,33 @@ async function writeClientStateEntries({ db, userId, userKey, entries }) {
673
656
  namespace: chunkNamespaceFor(entry.namespace),
674
657
  key: chunkKeyFor(entry.key, c),
675
658
  value: encryptedSlices[c],
676
- updatedAt: entry.updatedAt
659
+ updatedAt: guardAt
677
660
  });
678
661
  }
679
662
  }
680
663
  }
681
664
  if (physicalRows.length === 0 && cleanups.length === 0) {
682
- return { upserted: 0, skipped: 0, deleted: 0 };
665
+ return { upserted: 0, skipped: 0, deleted: 0, skippedEntries: [] };
683
666
  }
684
667
  const result = await db.upsertClientState(userId, physicalRows, cleanups);
685
668
  let upserted = 0;
686
669
  let skipped = 0;
670
+ const skippedEntries = [];
687
671
  if (Array.isArray(result.outcomes) && result.outcomes.length === physicalRows.length) {
688
- for (const rootIndex of rootRowIndexes) {
689
- if (result.outcomes[rootIndex]) upserted++;
690
- else skipped++;
672
+ for (let i = 0; i < rootRowIndexes.length; i++) {
673
+ if (result.outcomes[rootRowIndexes[i]]) {
674
+ upserted++;
675
+ } else {
676
+ skipped++;
677
+ const entry = rootRowEntries[i];
678
+ skippedEntries.push({ namespace: entry.namespace, key: entry.key });
679
+ }
691
680
  }
692
681
  } else {
693
682
  upserted = result.upserted;
694
683
  skipped = result.skipped;
695
684
  }
696
- return { upserted, skipped, deleted };
685
+ return { upserted, skipped, deleted, skippedEntries };
697
686
  }
698
687
 
699
688
  // src/server/lib/state-accessors.js
@@ -754,11 +743,15 @@ function createStateAccessors({ db, userId, userKey, maxStateValueBytes, now })
754
743
  if (entry.updatedAt !== void 0 && (!Number.isInteger(entry.updatedAt) || entry.updatedAt <= 0)) {
755
744
  throw new TypeError(`writeState: entries[${index}].updatedAt \u5FC5\u987B\u662F\u6B63\u6574\u6570\uFF08epoch \u6BEB\u79D2\uFF09`);
756
745
  }
746
+ if (entry.version !== void 0 && (!Number.isInteger(entry.version) || entry.version <= 0)) {
747
+ throw new TypeError(`writeState: entries[${index}].version \u5FC5\u987B\u662F\u6B63\u6574\u6570\uFF08\u6BEB\u79D2\u65F6\u95F4\u6233\u6216\u5355\u8C03\u9012\u589E\u7248\u672C\u53F7\uFF09`);
748
+ }
757
749
  return {
758
750
  namespace,
759
751
  key: entry.key,
760
752
  value: entry.value,
761
- updatedAt: entry.updatedAt ?? at
753
+ updatedAt: entry.updatedAt ?? at,
754
+ ...entry.version !== void 0 ? { version: entry.version } : {}
762
755
  };
763
756
  });
764
757
  return writeClientStateEntries({ db, userId, userKey, entries: normalized });
@@ -793,10 +786,92 @@ function projectTask(row, decryptedPayload, options = {}) {
793
786
  ...options.includeMetadata ? { metadata: payload.metadata ?? null } : {},
794
787
  // 上一次没发出去的原因(run-tick 记进 payload 的 lastError)。
795
788
  // reason 'stale' 表示错过触发时刻太久被判定不再补发;其余是投递失败的
796
- // 错误信息。没有记录 → null。
797
- lastError: payload.lastError ?? null
789
+ // 错误信息。payload 里没有时退回行上的 last_error 列(同形状的脱敏摘要,
790
+ // 等重试期间的失败也记在那里)。没有记录 → null。
791
+ lastError: payload.lastError ?? parseRowLastError(row.last_error)
798
792
  };
799
793
  }
794
+ function parseRowLastError(raw) {
795
+ if (typeof raw !== "string" || !raw) return null;
796
+ try {
797
+ return JSON.parse(raw);
798
+ } catch (_error) {
799
+ return { reason: raw };
800
+ }
801
+ }
802
+
803
+ // src/server/lib/push-subscription-store.js
804
+ function supportsPushSubscriptionStore(db) {
805
+ return !!db && typeof db.getPushSubscription === "function" && typeof db.upsertPushSubscription === "function" && typeof db.deletePushSubscription === "function";
806
+ }
807
+ function isPushSubscriptionShape(subscription) {
808
+ return !!subscription && typeof subscription === "object" && !Array.isArray(subscription) && typeof /** @type {{ endpoint?: unknown }} */
809
+ subscription.endpoint === "string" && /** @type {{ endpoint: string }} */
810
+ subscription.endpoint.trim().length > 0;
811
+ }
812
+ async function savePushSubscription({ db, userId, userKey, subscription, updatedAt }) {
813
+ const at = Number.isInteger(updatedAt) && updatedAt > 0 ? updatedAt : Date.now();
814
+ const encrypted = await encryptForStorage(JSON.stringify(subscription), userKey);
815
+ await db.upsertPushSubscription(userId, encrypted, at);
816
+ return { updatedAt: at };
817
+ }
818
+ async function loadPushSubscription({ db, userId, userKey }) {
819
+ const row = await db.getPushSubscription(userId);
820
+ if (!row || typeof row.subscription !== "string" || !row.subscription) return null;
821
+ const subscription = JSON.parse(await decryptFromStorage(row.subscription, userKey));
822
+ return { subscription, updatedAt: row.updated_at ?? null };
823
+ }
824
+ function codedError(code, message) {
825
+ const error = new Error(`${code}: ${message}`);
826
+ error.code = code;
827
+ return error;
828
+ }
829
+ async function resolvePushSubscription({ db, userId, userKey, legacyFallback = null }) {
830
+ const fallback = isPushSubscriptionShape(legacyFallback) ? legacyFallback : null;
831
+ if (!supportsPushSubscriptionStore(db)) {
832
+ if (fallback) return fallback;
833
+ throw codedError("PUSH_SUBSCRIPTION_STORE_UNSUPPORTED", "\u5F53\u524D\u6570\u636E\u5E93\u9002\u914D\u5668\u4E0D\u652F\u6301\u7528\u6237\u7EA7\u63A8\u9001\u8BA2\u9605\u5B58\u50A8");
834
+ }
835
+ const stored = await loadPushSubscription({ db, userId, userKey });
836
+ if (!stored) {
837
+ if (fallback) return fallback;
838
+ throw codedError("PUSH_SUBSCRIPTION_MISSING", "\u8BE5\u7528\u6237\u8FD8\u6CA1\u6709\u767B\u8BB0\u63A8\u9001\u8BA2\u9605\uFF08PUT /push-subscription\uFF09");
839
+ }
840
+ return stored.subscription;
841
+ }
842
+
843
+ // src/server/lib/outbox-store.js
844
+ function supportsOutbox(db) {
845
+ return !!db && typeof db.appendOutboxMessages === "function" && typeof db.markOutboxDelivered === "function";
846
+ }
847
+ async function appendPushesToOutbox({ db, userId, userKey, pushes }) {
848
+ if (!supportsOutbox(db) || !pushes || pushes.length === 0) return false;
849
+ try {
850
+ const now = Date.now();
851
+ const rows = await Promise.all(pushes.map(async (push) => ({
852
+ message_id: push.messageId,
853
+ task_uuid: push.taskUuid ?? null,
854
+ session_id: push.sessionId ?? null,
855
+ message_index: push.messageIndex ?? null,
856
+ total_messages: push.totalMessages ?? null,
857
+ payload: await encryptForStorage(JSON.stringify(push), userKey),
858
+ created_at: now
859
+ })));
860
+ await db.appendOutboxMessages(userId, rows);
861
+ return true;
862
+ } catch (error) {
863
+ console.warn("[amsg-server] outbox \u843D\u884C\u5931\u8D25\uFF08\u4E0D\u5F71\u54CD\u6295\u9012\uFF09:", error && error.message);
864
+ return false;
865
+ }
866
+ }
867
+ async function markPushesDelivered({ db, userId, messageIds }) {
868
+ if (!supportsOutbox(db) || !messageIds || messageIds.length === 0) return;
869
+ try {
870
+ await db.markOutboxDelivered(userId, messageIds, Date.now());
871
+ } catch (error) {
872
+ console.warn("[amsg-server] outbox \u6807\u8BB0 delivered \u5931\u8D25\uFF08\u5DF2\u5FFD\u7565\uFF09:", error && error.message);
873
+ }
874
+ }
800
875
 
801
876
  // src/server/lib/agentic-fire.js
802
877
  var DEFAULT_MAX_TOOL_ITERATIONS = 5;
@@ -985,6 +1060,9 @@ async function runAgenticFire({ task, decryptedPayload, userKey, ctx }) {
985
1060
  maxTokens: decryptedPayload.maxTokens ?? null,
986
1061
  temperature: decryptedPayload.temperature ?? null,
987
1062
  splitPattern: decryptedPayload.splitPattern ?? null,
1063
+ // 与凭据同列的投递配置:中转的非标准参数(thinking 之类)跟着任务走,
1064
+ // 自排的后续任务不该突然掉档。
1065
+ llmExtraBody: decryptedPayload.llmExtraBody ?? null,
988
1066
  metadata
989
1067
  };
990
1068
  const encryptedPayload = await encryptForStorage(JSON.stringify(fullTaskData), userKey);
@@ -1018,6 +1096,57 @@ async function runAgenticFire({ task, decryptedPayload, userKey, ctx }) {
1018
1096
  nextSendAt: created.next_send_at ?? nextSendAt
1019
1097
  };
1020
1098
  };
1099
+ const cancelTask = async (uuid) => {
1100
+ if (typeof uuid !== "string" || !uuid.trim()) {
1101
+ throw new TypeError("cancelTask(uuid) \u9700\u8981\u975E\u7A7A\u5B57\u7B26\u4E32 uuid");
1102
+ }
1103
+ if (task.uuid != null && uuid === task.uuid) {
1104
+ throw new RangeError(
1105
+ "cancelTask: \u4E0D\u80FD\u53D6\u6D88\u5F53\u524D\u6B63\u5728 fire \u7684\u4EFB\u52A1\u2014\u2014\u8FD9\u6761\u7684\u6536\u5C3E\u5F52 run-tick \u7BA1\u3002\u8FD9\u6B21\u4E0D\u60F3\u53D1\u7684\u8BDD\uFF0C\u7528 onBeforeFire \u7684 { skip: true } \u6216 onLLMOutput \u7684 'skip-push'\u3002"
1106
+ );
1107
+ }
1108
+ if (!ctx.db || typeof ctx.db.deleteTaskByUuid !== "function") {
1109
+ throw new Error("AGENTIC_CANCEL_UNSUPPORTED: \u5F53\u524D\u6570\u636E\u5E93\u9002\u914D\u5668\u4E0D\u652F\u6301\u5220\u4EFB\u52A1\uFF08\u7F3A deleteTaskByUuid\uFF09");
1110
+ }
1111
+ const cancelled = await ctx.db.deleteTaskByUuid(uuid, task.user_id);
1112
+ return { cancelled: !!cancelled };
1113
+ };
1114
+ const renewTask = async (uuid, nextSendAt) => {
1115
+ if (typeof uuid !== "string" || !uuid.trim()) {
1116
+ throw new TypeError("renewTask(uuid, nextSendAt) \u9700\u8981\u975E\u7A7A\u5B57\u7B26\u4E32 uuid");
1117
+ }
1118
+ if (task.uuid != null && uuid === task.uuid) {
1119
+ throw new RangeError("renewTask: \u4E0D\u80FD\u6539\u5F53\u524D\u6B63\u5728 fire \u7684\u4EFB\u52A1\u2014\u2014\u8FD9\u6761\u7684\u6392\u671F\u63A8\u8FDB\u5F52 run-tick \u7BA1");
1120
+ }
1121
+ if (typeof nextSendAt !== "string" || !nextSendAt.trim()) {
1122
+ throw new RangeError("renewTask: nextSendAt \u5FC5\u586B\uFF0C\u4E14\u5FC5\u987B\u662F ISO 8601 \u5B57\u7B26\u4E32");
1123
+ }
1124
+ const sendAt = new Date(nextSendAt);
1125
+ if (Number.isNaN(sendAt.getTime())) {
1126
+ throw new RangeError(`renewTask: nextSendAt \u89E3\u6790\u4E0D\u51FA\u5408\u6CD5\u65F6\u95F4\uFF1A${nextSendAt}`);
1127
+ }
1128
+ const earliest = nowFn() + MIN_SCHEDULE_LEAD_MS;
1129
+ if (sendAt.getTime() < earliest) {
1130
+ throw new RangeError(
1131
+ `renewTask: nextSendAt \u81F3\u5C11\u8981\u6BD4\u73B0\u5728\u665A ${MIN_SCHEDULE_LEAD_MS / 1e3} \u79D2\uFF08\u6700\u65E9\u53EF\u6392 ${new Date(earliest).toISOString()}\uFF0C\u6536\u5230 ${sendAt.toISOString()}\uFF09`
1132
+ );
1133
+ }
1134
+ if (!ctx.db || typeof ctx.db.getTaskByUuid !== "function" || typeof ctx.db.updateTaskByUuid !== "function") {
1135
+ throw new Error("AGENTIC_RENEW_UNSUPPORTED: \u5F53\u524D\u6570\u636E\u5E93\u9002\u914D\u5668\u4E0D\u652F\u6301\u6539\u4EFB\u52A1\uFF08\u7F3A getTaskByUuid / updateTaskByUuid\uFF09");
1136
+ }
1137
+ const row = await ctx.db.getTaskByUuid(uuid, task.user_id);
1138
+ if (!row) return { renewed: false, reason: "not_found" };
1139
+ const payload = JSON.parse(await decryptFromStorage(row.encrypted_payload, userKey));
1140
+ const nextSendAtIso = sendAt.toISOString();
1141
+ const encrypted = await encryptForStorage(JSON.stringify({ ...payload, firstSendTime: nextSendAtIso }), userKey);
1142
+ const updated = await ctx.db.updateTaskByUuid(uuid, task.user_id, encrypted, {
1143
+ next_send_at: nextSendAtIso,
1144
+ retry_count: 0,
1145
+ ...typeof ctx.db.claimTask === "function" ? { retry_after: null } : {}
1146
+ });
1147
+ if (!updated) return { renewed: false, reason: "not_found" };
1148
+ return { renewed: true, uuid, nextSendAt: nextSendAtIso };
1149
+ };
1021
1150
  const scratch = {};
1022
1151
  const fireCtx = Object.freeze({
1023
1152
  task: buildHookTask(task, decryptedPayload),
@@ -1025,10 +1154,12 @@ async function runAgenticFire({ task, decryptedPayload, userKey, ctx }) {
1025
1154
  readState,
1026
1155
  writeState,
1027
1156
  scheduleTask,
1157
+ cancelTask,
1158
+ renewTask,
1028
1159
  now: new Date(nowFn()),
1029
1160
  scratch
1030
1161
  });
1031
- const progress = { sentCount: 0, total: 0, iterations: 0, skipReason: null };
1162
+ const progress = { sentCount: 0, total: 0, iterations: 0, skipReason: null, usage: null };
1032
1163
  let settledStatus = "failed";
1033
1164
  let settledError = null;
1034
1165
  try {
@@ -1044,6 +1175,8 @@ async function runAgenticFire({ task, decryptedPayload, userKey, ctx }) {
1044
1175
  writeState,
1045
1176
  scratch,
1046
1177
  scheduleTask,
1178
+ cancelTask,
1179
+ renewTask,
1047
1180
  fireCtx,
1048
1181
  progress
1049
1182
  });
@@ -1061,6 +1194,14 @@ async function runAgenticFire({ task, decryptedPayload, userKey, ctx }) {
1061
1194
  total: progress.total,
1062
1195
  iterations: progress.iterations,
1063
1196
  error: settledError,
1197
+ // 解密 payload 里的 metadata 子字段(与 onStaleSkip 同待遇):task 行本
1198
+ // 身是密文,宿主要靠它对上「这是哪个角色的哪类任务」——尤其是 fire 在
1199
+ // onBeforeFire 里就失败、宿主还没来得及自己记账的那种结局。凭据字段
1200
+ // (apiKey / pushSubscription)照旧不透传。
1201
+ metadata: decryptedPayload.metadata ?? null,
1202
+ // 最后一轮 LLM 响应的 usage(prompt/completion tokens;没跑到 LLM →
1203
+ // null)。宿主拿它更新用量记账,不用从 llmResponse 里自己扒。
1204
+ usage: progress.usage,
1064
1205
  scratch,
1065
1206
  readState,
1066
1207
  writeState
@@ -1079,6 +1220,8 @@ async function runFireChain({
1079
1220
  writeState,
1080
1221
  scratch,
1081
1222
  scheduleTask,
1223
+ cancelTask,
1224
+ renewTask,
1082
1225
  fireCtx,
1083
1226
  progress
1084
1227
  }) {
@@ -1117,6 +1260,7 @@ async function runFireChain({
1117
1260
  );
1118
1261
  const assistantMessage = extractAssistantMessage(llmResponse);
1119
1262
  messages = [...messages, assistantMessage];
1263
+ progress.usage = llmResponse && typeof llmResponse === "object" && llmResponse.usage || null;
1120
1264
  const sessionCtx = Object.freeze({
1121
1265
  ...buildSessionContext({
1122
1266
  sessionId,
@@ -1134,7 +1278,9 @@ async function runFireChain({
1134
1278
  occurrenceMs,
1135
1279
  readState,
1136
1280
  writeState,
1137
- scheduleTask
1281
+ scheduleTask,
1282
+ cancelTask,
1283
+ renewTask
1138
1284
  });
1139
1285
  const decision = await hooks.onLLMOutput(sessionCtx);
1140
1286
  assertValidDecision(decision, { inlineToolCalls: true });
@@ -1233,7 +1379,8 @@ async function sendHookPushPayloads({
1233
1379
  const total = pushPayloads.length;
1234
1380
  let sentCount = 0;
1235
1381
  progress.total = total;
1236
- const afterSendBase = { task, total, scratch, readState, writeState };
1382
+ const afterSendBase = { task, total, scratch, readState, writeState, usage: progress.usage };
1383
+ const sentIds = [];
1237
1384
  try {
1238
1385
  if (!ctx.vapid || !ctx.vapid.email || !ctx.vapid.publicKey || !ctx.vapid.privateKey) {
1239
1386
  throw new Error("VAPID configuration missing - push notifications cannot be sent");
@@ -1241,9 +1388,11 @@ async function sendHookPushPayloads({
1241
1388
  const pushSubscription = await resolvePushSubscription({
1242
1389
  db: ctx.db,
1243
1390
  userId: task.user_id,
1244
- userKey
1391
+ userKey,
1392
+ legacyFallback: (decryptedPayload && decryptedPayload.pushSubscription) ?? null
1245
1393
  });
1246
1394
  const messageIdBase = task.id != null ? `msg_task_${task.id}${occurrenceSuffix(task)}` : `msg_${randomUUID()}`;
1395
+ const finalized = [];
1247
1396
  for (let i = 0; i < total; i++) {
1248
1397
  const push = { ...pushPayloads[i] };
1249
1398
  if (typeof push.messageId !== "string" || !push.messageId) push.messageId = `${messageIdBase}_hook_${i}`;
@@ -1252,20 +1401,76 @@ async function sendHookPushPayloads({
1252
1401
  push.messageIndex = i + 1;
1253
1402
  push.totalMessages = total;
1254
1403
  stampTaskIdentity(push, task, decryptedPayload, occurrenceMs);
1255
- await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(push));
1404
+ finalized.push(push);
1405
+ }
1406
+ await appendPushesToOutbox({ db: ctx.db, userId: task.user_id, userKey, pushes: finalized });
1407
+ for (let i = 0; i < total; i++) {
1408
+ await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(finalized[i]));
1256
1409
  sentCount++;
1257
1410
  progress.sentCount = sentCount;
1411
+ sentIds.push(finalized[i].messageId);
1258
1412
  if (i < total - 1) await sleep(SLEEP_BETWEEN_MESSAGES_MS);
1259
1413
  }
1260
1414
  } catch (error) {
1415
+ await markPushesDelivered({ db: ctx.db, userId: task.user_id, messageIds: sentIds });
1261
1416
  await notifyAfterSend(ctx, { ...afterSendBase, sentCount, error });
1262
1417
  throw error;
1263
1418
  }
1419
+ await markPushesDelivered({ db: ctx.db, userId: task.user_id, messageIds: sentIds });
1264
1420
  await notifyAfterSend(ctx, { ...afterSendBase, sentCount, error: null });
1265
1421
  return total;
1266
1422
  }
1267
1423
 
1424
+ // src/server/lib/errors.js
1425
+ var NonRetryableError = class extends Error {
1426
+ /**
1427
+ * @param {string} message
1428
+ * @param {{ code?: string, cause?: unknown }} [options]
1429
+ */
1430
+ constructor(message, options = {}) {
1431
+ super(message, options.cause !== void 0 ? { cause: options.cause } : void 0);
1432
+ this.name = "NonRetryableError";
1433
+ this.permanent = true;
1434
+ if (options.code) this.code = options.code;
1435
+ }
1436
+ };
1437
+ function isNonRetryableError(error) {
1438
+ return !!error && typeof error === "object" && /** @type {any} */
1439
+ error.permanent === true;
1440
+ }
1441
+ function sanitizeErrorSummary(reason) {
1442
+ let s = String(reason ?? "").replace(/\s+/g, " ").trim();
1443
+ s = s.replace(/Bearer\s+[A-Za-z0-9._~+/=-]{8,}/gi, "Bearer [redacted]");
1444
+ s = s.replace(/\b[A-Za-z]{2,6}-[A-Za-z0-9_-]{16,}/g, "[redacted]");
1445
+ s = s.replace(/[A-Za-z0-9+/_.-]{48,}/g, "[redacted]");
1446
+ if (s.length > 500) s = `${s.slice(0, 497)}\u2026`;
1447
+ return s;
1448
+ }
1449
+ function summarizeErrorCause(error, stage) {
1450
+ const raw = error && typeof error === "object" ? (
1451
+ /** @type {any} */
1452
+ error
1453
+ ) : {};
1454
+ const name = typeof raw.name === "string" && raw.name ? raw.name : "Error";
1455
+ const rawMessage = typeof raw.message === "string" && raw.message ? raw.message : String(error ?? "");
1456
+ const cause = {
1457
+ stage,
1458
+ name: sanitizeErrorSummary(name).slice(0, 100),
1459
+ message: sanitizeErrorSummary(rawMessage)
1460
+ };
1461
+ if (typeof raw.code === "string" && raw.code) {
1462
+ cause.code = sanitizeErrorSummary(raw.code).slice(0, 100);
1463
+ }
1464
+ return cause;
1465
+ }
1466
+
1268
1467
  // src/server/lib/message-processor.js
1468
+ import {
1469
+ buildContentPush,
1470
+ buildReasoningPush,
1471
+ readReasoningContent,
1472
+ stripReasoningTags
1473
+ } from "@rei-standard/amsg-shared";
1269
1474
  var DEFAULT_SPLIT_REGEX = /([。!?!?]+)/;
1270
1475
  var SLEEP_BETWEEN_MESSAGES_MS2 = 1500;
1271
1476
  function splitOnceByRegex(chunk, regex) {
@@ -1287,14 +1492,14 @@ function splitMessageIntoSentences(messageContent, splitPattern = null) {
1287
1492
  }
1288
1493
  return chunks.length > 0 ? chunks : [messageContent];
1289
1494
  }
1290
- async function processSingleMessage(task, ctx, providedMasterKey) {
1495
+ async function processSingleMessage(task, ctx, providedMasterKey, predecrypted = null) {
1291
1496
  try {
1292
1497
  const masterKey = providedMasterKey || ctx.masterKey;
1293
1498
  if (!masterKey) {
1294
1499
  return { success: false, messagesSent: 0, error: "TENANT_MASTER_KEY_MISSING" };
1295
1500
  }
1296
- const userKey = await deriveUserEncryptionKey(task.user_id, masterKey);
1297
- const decryptedPayload = JSON.parse(await decryptFromStorage(task.encrypted_payload, userKey));
1501
+ const userKey = predecrypted && predecrypted.userKey || await deriveUserEncryptionKey(task.user_id, masterKey);
1502
+ const decryptedPayload = predecrypted && predecrypted.payload || JSON.parse(await decryptFromStorage(task.encrypted_payload, userKey));
1298
1503
  if (ctx.hooks && typeof ctx.hooks.onBeforeFire === "function" && taskNeedsLlm(decryptedPayload)) {
1299
1504
  const agentic = await runAgenticFire({ task, decryptedPayload, userKey, ctx });
1300
1505
  if (agentic.handled) return agentic.result;
@@ -1329,7 +1534,12 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
1329
1534
  if (!ctx.vapid.email || !ctx.vapid.publicKey || !ctx.vapid.privateKey) {
1330
1535
  throw new Error("VAPID configuration missing - push notifications cannot be sent");
1331
1536
  }
1332
- const pushSubscription = await resolvePushSubscription({ db: ctx.db, userId: task.user_id, userKey });
1537
+ const pushSubscription = await resolvePushSubscription({
1538
+ db: ctx.db,
1539
+ userId: task.user_id,
1540
+ userKey,
1541
+ legacyFallback: decryptedPayload.pushSubscription ?? null
1542
+ });
1333
1543
  const sessionId = task.id != null ? `sess_task_${task.id}${occurrenceSuffix(task)}` : `sess_${randomUUID()}`;
1334
1544
  const source = decryptedPayload.messageType === "instant" ? "instant" : "scheduled";
1335
1545
  const messageSubtype = decryptedPayload.messageSubtype || "chat";
@@ -1337,6 +1547,7 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
1337
1547
  const metadata = decryptedPayload.metadata || {};
1338
1548
  const messageIdBase = task.id != null ? `msg_task_${task.id}${occurrenceSuffix(task)}` : `msg_${randomUUID()}_instant`;
1339
1549
  const occurrenceMs = occurrenceMsOf(task);
1550
+ const pushesToSend = [];
1340
1551
  if (reasoning) {
1341
1552
  const reasoningPush = buildReasoningPush({
1342
1553
  messageType: decryptedPayload.messageType,
@@ -1352,8 +1563,7 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
1352
1563
  metadata
1353
1564
  });
1354
1565
  stampTaskIdentity(reasoningPush, task, decryptedPayload, occurrenceMs);
1355
- await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(reasoningPush));
1356
- await new Promise((resolve) => setTimeout(resolve, SLEEP_BETWEEN_MESSAGES_MS2));
1566
+ pushesToSend.push(reasoningPush);
1357
1567
  }
1358
1568
  for (let i = 0; i < messages.length; i++) {
1359
1569
  const contentPush = buildContentPush({
@@ -1372,14 +1582,30 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
1372
1582
  metadata
1373
1583
  });
1374
1584
  stampTaskIdentity(contentPush, task, decryptedPayload, occurrenceMs);
1375
- await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(contentPush));
1376
- if (i < messages.length - 1) {
1377
- await new Promise((resolve) => setTimeout(resolve, SLEEP_BETWEEN_MESSAGES_MS2));
1585
+ pushesToSend.push(contentPush);
1586
+ }
1587
+ await appendPushesToOutbox({ db: ctx.db, userId: task.user_id, userKey, pushes: pushesToSend });
1588
+ const sentIds = [];
1589
+ try {
1590
+ for (let i = 0; i < pushesToSend.length; i++) {
1591
+ await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(pushesToSend[i]));
1592
+ sentIds.push(pushesToSend[i].messageId);
1593
+ if (i < pushesToSend.length - 1) {
1594
+ await new Promise((resolve) => setTimeout(resolve, SLEEP_BETWEEN_MESSAGES_MS2));
1595
+ }
1378
1596
  }
1597
+ } finally {
1598
+ await markPushesDelivered({ db: ctx.db, userId: task.user_id, messageIds: sentIds });
1379
1599
  }
1380
1600
  return { success: true, messagesSent: messages.length };
1381
1601
  } catch (error) {
1382
- return { success: false, messagesSent: 0, error: error.message };
1602
+ return {
1603
+ success: false,
1604
+ messagesSent: 0,
1605
+ error: error.message,
1606
+ errorCode: error.code || null,
1607
+ permanent: isNonRetryableError(error)
1608
+ };
1383
1609
  }
1384
1610
  }
1385
1611
  async function processMessagesByUuid(uuid, ctx, maxRetries = 2, userId, providedMasterKey) {
@@ -1411,18 +1637,35 @@ async function processMessagesByUuid(uuid, ctx, maxRetries = 2, userId, provided
1411
1637
  }
1412
1638
  const result = await processSingleMessage(task, ctx, masterKey);
1413
1639
  if (!result.success) {
1414
- if (retryCount < maxRetries) {
1640
+ if (!result.permanent && retryCount < maxRetries) {
1415
1641
  retryCount++;
1416
1642
  await new Promise((resolve) => setTimeout(resolve, 1e3 * retryCount));
1417
1643
  continue;
1418
1644
  }
1419
1645
  try {
1420
- await ctx.db.updateTaskById(task.id, { status: "failed", retry_count: retryCount });
1646
+ await ctx.db.updateTaskById(task.id, {
1647
+ status: "failed",
1648
+ retry_count: retryCount,
1649
+ last_error: JSON.stringify({
1650
+ at: (/* @__PURE__ */ new Date()).toISOString(),
1651
+ occurrence: task.next_send_at ?? null,
1652
+ reason: sanitizeErrorSummary(result.error)
1653
+ })
1654
+ });
1421
1655
  } catch (_updateError) {
1656
+ try {
1657
+ await ctx.db.updateTaskById(task.id, { status: "failed", retry_count: retryCount });
1658
+ } catch (_retryError) {
1659
+ }
1422
1660
  }
1423
1661
  return {
1424
1662
  success: false,
1425
- error: { code: "PROCESSING_ERROR", message: result.error, retriesAttempted: retryCount }
1663
+ error: {
1664
+ code: "PROCESSING_ERROR",
1665
+ message: result.error,
1666
+ retriesAttempted: retryCount,
1667
+ ...result.permanent ? { permanent: true } : {}
1668
+ }
1426
1669
  };
1427
1670
  }
1428
1671
  try {
@@ -1457,16 +1700,12 @@ function createScheduleMessageHandler(ctx) {
1457
1700
  const masterKey = tenantCtx.masterKey;
1458
1701
  const isEncrypted = getHeader(headers, "x-payload-encrypted") === "true";
1459
1702
  const encryptionVersion = getHeader(headers, "x-encryption-version");
1460
- const userId = getHeader(headers, "x-user-id");
1461
1703
  if (!isEncrypted) {
1462
1704
  return { status: 400, body: { success: false, error: { code: "ENCRYPTION_REQUIRED", message: "\u8BF7\u6C42\u4F53\u5FC5\u987B\u52A0\u5BC6" } } };
1463
1705
  }
1464
- if (!userId) {
1465
- return { status: 400, body: { success: false, error: { code: "USER_ID_REQUIRED", message: "\u7F3A\u5C11\u7528\u6237\u6807\u8BC6\u7B26" } } };
1466
- }
1467
- if (!isValidUUIDv4(userId)) {
1468
- return { status: 400, body: { success: false, error: { code: "INVALID_USER_ID_FORMAT", message: "X-User-Id \u5FC5\u987B\u662F UUID v4 \u683C\u5F0F" } } };
1469
- }
1706
+ const gate = requireUserId(headers);
1707
+ if (gate.error) return gate.error;
1708
+ const { userId } = gate;
1470
1709
  if (encryptionVersion !== "1") {
1471
1710
  return { status: 400, body: { success: false, error: { code: "UNSUPPORTED_ENCRYPTION_VERSION", message: "\u52A0\u5BC6\u7248\u672C\u4E0D\u652F\u6301" } } };
1472
1711
  }
@@ -1495,13 +1734,22 @@ function createScheduleMessageHandler(ctx) {
1495
1734
  if (!supportsPushSubscriptionStore(db)) {
1496
1735
  return { status: 501, body: { success: false, error: { code: "PUSH_SUBSCRIPTION_NOT_SUPPORTED", message: "\u5F53\u524D\u6570\u636E\u5E93\u9002\u914D\u5668\u4E0D\u652F\u6301\u7528\u6237\u7EA7\u63A8\u9001\u8BA2\u9605\u5B58\u50A8" } } };
1497
1736
  }
1498
- let storedSubscription = null;
1737
+ let subscriptionRow;
1499
1738
  try {
1500
- storedSubscription = await loadPushSubscription({ db, userId, userKey });
1739
+ subscriptionRow = await db.getPushSubscription(userId);
1501
1740
  } catch (_error) {
1502
- storedSubscription = null;
1741
+ return {
1742
+ status: 503,
1743
+ body: {
1744
+ success: false,
1745
+ error: {
1746
+ code: "PUSH_SUBSCRIPTION_LOOKUP_FAILED",
1747
+ message: "\u63A8\u9001\u8BA2\u9605\u8BFB\u53D6\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5"
1748
+ }
1749
+ }
1750
+ };
1503
1751
  }
1504
- if (!storedSubscription) {
1752
+ if (!subscriptionRow || typeof subscriptionRow.subscription !== "string" || !subscriptionRow.subscription) {
1505
1753
  return {
1506
1754
  status: 409,
1507
1755
  body: {
@@ -1514,13 +1762,14 @@ function createScheduleMessageHandler(ctx) {
1514
1762
  };
1515
1763
  }
1516
1764
  const taskUuid = payload.uuid || randomUUID();
1765
+ const effectiveSendTime = payload.immediate === true ? (/* @__PURE__ */ new Date()).toISOString() : payload.firstSendTime;
1517
1766
  const fullTaskData = {
1518
1767
  contactName: payload.contactName,
1519
1768
  avatarUrl: payload.avatarUrl || null,
1520
1769
  messageType: payload.messageType,
1521
1770
  messageSubtype: payload.messageSubtype || "chat",
1522
1771
  userMessage: payload.userMessage || null,
1523
- firstSendTime: payload.firstSendTime,
1772
+ firstSendTime: effectiveSendTime,
1524
1773
  recurrenceType: payload.recurrenceType || "none",
1525
1774
  // daily / weekly 按这个时区的墙钟推进(同一钟点,日期 +1 天 / +7 天)。
1526
1775
  // 不传 → null → 按 UTC 推进。
@@ -1539,6 +1788,9 @@ function createScheduleMessageHandler(ctx) {
1539
1788
  // the message processor to chunk LLM output into individual pushes.
1540
1789
  // null → processor falls back to the default /([。!?!?]+)/ regex.
1541
1790
  splitPattern: payload.splitPattern ?? null,
1791
+ // 透传给 LLM 中转的非标准参数(thinking 之类),buildLlmRequestBody 会
1792
+ // 把它展开进请求体(核心字段优先)。
1793
+ llmExtraBody: payload.llmExtraBody ?? null,
1542
1794
  metadata: payload.metadata || {}
1543
1795
  };
1544
1796
  const encryptedPayload = await encryptForStorage(JSON.stringify(fullTaskData), userKey);
@@ -1563,15 +1815,26 @@ function createScheduleMessageHandler(ctx) {
1563
1815
  };
1564
1816
  }
1565
1817
  }
1818
+ const createParams = {
1819
+ user_id: userId,
1820
+ uuid: taskUuid,
1821
+ encrypted_payload: encryptedPayload,
1822
+ next_send_at: effectiveSendTime,
1823
+ message_type: payload.messageType
1824
+ };
1825
+ const supersedesUuid = payload.supersedesUuid || null;
1826
+ let superseded = false;
1566
1827
  let dbResult;
1567
1828
  try {
1568
- dbResult = await db.createTask({
1569
- user_id: userId,
1570
- uuid: taskUuid,
1571
- encrypted_payload: encryptedPayload,
1572
- next_send_at: payload.firstSendTime,
1573
- message_type: payload.messageType
1574
- });
1829
+ if (supersedesUuid && typeof db.createTaskSuperseding === "function") {
1830
+ dbResult = await db.createTaskSuperseding(createParams, supersedesUuid);
1831
+ superseded = !!(dbResult && dbResult.superseded);
1832
+ } else {
1833
+ if (supersedesUuid) {
1834
+ superseded = await db.deleteTaskByUuid(supersedesUuid, userId);
1835
+ }
1836
+ dbResult = await db.createTask(createParams);
1837
+ }
1575
1838
  } catch (error) {
1576
1839
  if (isUniqueViolation(error)) {
1577
1840
  return {
@@ -1610,7 +1873,8 @@ function createScheduleMessageHandler(ctx) {
1610
1873
  messagesSent: sendResult.messagesSent,
1611
1874
  sentAt: (/* @__PURE__ */ new Date()).toISOString(),
1612
1875
  status: "sent",
1613
- retriesUsed: sendResult.retriesUsed || 0
1876
+ retriesUsed: sendResult.retriesUsed || 0,
1877
+ ...supersedesUuid ? { superseded } : {}
1614
1878
  }
1615
1879
  }
1616
1880
  };
@@ -1628,7 +1892,10 @@ function createScheduleMessageHandler(ctx) {
1628
1892
  contactName: payload.contactName,
1629
1893
  nextSendAt: dbResult.next_send_at,
1630
1894
  status: dbResult.status,
1631
- createdAt: dbResult.created_at
1895
+ createdAt: dbResult.created_at,
1896
+ // supersede 的结果:true = 旧行确实被取消了;false = 旧行本就不存在
1897
+ // (已发出 / 已删除),新任务照常建立。
1898
+ ...supersedesUuid ? { superseded } : {}
1632
1899
  }
1633
1900
  }
1634
1901
  };
@@ -1639,7 +1906,12 @@ function createScheduleMessageHandler(ctx) {
1639
1906
  // src/server/lib/run-tick.js
1640
1907
  var DEFAULT_CLAIM_LEASE_MS = 10 * 60 * 1e3;
1641
1908
  var CLAIM_LEASE_MARGIN_MS = 2 * 60 * 1e3;
1909
+ var DEFAULT_LEASE_HEARTBEAT_MS = 30 * 1e3;
1910
+ var DEFAULT_HEARTBEAT_LEASE_TTL_MS = 90 * 1e3;
1642
1911
  var STALE_AFTER_MS = 60 * 60 * 1e3;
1912
+ function resolveStaleAfterMs(ctx) {
1913
+ return positiveNumber(ctx.staleAfterMs) || STALE_AFTER_MS;
1914
+ }
1643
1915
  function isRecurringType(recurrenceType) {
1644
1916
  return recurrenceType === "daily" || recurrenceType === "weekly";
1645
1917
  }
@@ -1653,12 +1925,53 @@ async function deriveSerializeGroup(userKey, rawKey) {
1653
1925
  return bytesToBase64Url(await hmacSha256(utf8(userKey), utf8(rawKey)));
1654
1926
  }
1655
1927
  async function runScheduledTick(ctx) {
1928
+ const tasks = await ctx.db.getPendingTasks(50);
1929
+ const summary = await deliverTasks(ctx, tasks);
1930
+ await ctx.db.cleanupOldTasks(7);
1931
+ if (typeof ctx.db.cleanupOutbox === "function") {
1932
+ try {
1933
+ await ctx.db.cleanupOutbox({
1934
+ ackedBeforeMs: Date.now() - 7 * 24 * 60 * 60 * 1e3,
1935
+ allBeforeMs: Date.now() - 28 * 24 * 60 * 60 * 1e3
1936
+ });
1937
+ } catch (error) {
1938
+ console.warn("[amsg-server] cleanupOutbox \u5931\u8D25\uFF08\u5DF2\u5FFD\u7565\uFF09:", error && error.message);
1939
+ }
1940
+ }
1941
+ return summary;
1942
+ }
1943
+ async function runTask(ctx, uuid) {
1944
+ if (typeof uuid !== "string" || !uuid.trim()) {
1945
+ throw new TypeError("runTask(ctx, uuid) requires a non-empty uuid string");
1946
+ }
1947
+ const task = await ctx.db.getTaskByUuidOnly(uuid);
1948
+ if (!task) {
1949
+ if (typeof ctx.db.getTaskStatusByUuidOnly === "function") {
1950
+ const settled = await ctx.db.getTaskStatusByUuidOnly(uuid);
1951
+ if (settled) return { ran: false, reason: "already_settled", status: settled.status };
1952
+ }
1953
+ return { ran: false, reason: "not_found" };
1954
+ }
1955
+ const dueMs = Date.parse(task.next_send_at);
1956
+ if (Number.isFinite(dueMs) && dueMs > Date.now()) {
1957
+ return { ran: false, reason: "not_due", nextSendAt: task.next_send_at };
1958
+ }
1959
+ const retryAfterMs = task.retry_after ? Date.parse(task.retry_after) : NaN;
1960
+ if (Number.isFinite(retryAfterMs) && retryAfterMs > Date.now()) {
1961
+ return { ran: false, reason: "retry_pending", retryAfter: task.retry_after };
1962
+ }
1963
+ return { ran: true, summary: await deliverTasks(ctx, [task]) };
1964
+ }
1965
+ async function deliverTasks(ctx, tasks) {
1656
1966
  const db = ctx.db;
1657
1967
  const masterKey = ctx.masterKey;
1658
1968
  const claimLeaseMs = resolveClaimLeaseMs(ctx);
1969
+ const staleAfterMs = resolveStaleAfterMs(ctx);
1659
1970
  const serializeBy = typeof ctx.serializeBy === "function" ? ctx.serializeBy : null;
1971
+ const heartbeatMs = ctx.leaseHeartbeatMs === 0 ? 0 : positiveNumber(ctx.leaseHeartbeatMs) || DEFAULT_LEASE_HEARTBEAT_MS;
1972
+ const heartbeatEnabled = heartbeatMs > 0 && typeof db.claimTask === "function" && typeof db.renewTaskLease === "function";
1973
+ const heartbeatLeaseTtlMs = Math.max(DEFAULT_HEARTBEAT_LEASE_TTL_MS, heartbeatMs * 2);
1660
1974
  const startTime = Date.now();
1661
- const tasks = await db.getPendingTasks(50);
1662
1975
  const MAX_CONCURRENT = 8;
1663
1976
  const results = {
1664
1977
  totalTasks: tasks.length,
@@ -1675,11 +1988,57 @@ async function runScheduledTick(ctx) {
1675
1988
  const supportsClaim = typeof db.claimTask === "function";
1676
1989
  async function claimForThisTick(task, serializeGroup) {
1677
1990
  if (!supportsClaim) return true;
1678
- const leaseUntil = new Date(Date.now() + claimLeaseMs).toISOString();
1991
+ const leaseUntil = new Date(
1992
+ Date.now() + (heartbeatEnabled ? heartbeatLeaseTtlMs : claimLeaseMs)
1993
+ ).toISOString();
1679
1994
  return !!await db.claimTask(task.id, task.next_send_at, leaseUntil, serializeGroup);
1680
1995
  }
1996
+ function startLeaseHeartbeat(task) {
1997
+ if (!heartbeatEnabled) return () => {
1998
+ };
1999
+ let stopped = false;
2000
+ let timer = null;
2001
+ const beat = async () => {
2002
+ if (stopped) return;
2003
+ try {
2004
+ await db.renewTaskLease(task.id, new Date(Date.now() + heartbeatLeaseTtlMs).toISOString());
2005
+ } catch (error) {
2006
+ console.warn("[amsg-server] \u79DF\u7EA6\u7EED\u79DF\u5931\u8D25\uFF08\u4E0B\u4E2A\u5FC3\u8DF3\u518D\u8BD5\uFF09:", error && error.message);
2007
+ }
2008
+ if (!stopped) schedule();
2009
+ };
2010
+ const schedule = () => {
2011
+ timer = setTimeout(beat, heartbeatMs);
2012
+ if (timer && typeof timer.unref === "function") timer.unref();
2013
+ };
2014
+ schedule();
2015
+ return () => {
2016
+ stopped = true;
2017
+ if (timer) clearTimeout(timer);
2018
+ };
2019
+ }
2020
+ const supportsLastError = supportsClaim;
2021
+ async function updateTaskWithLastError(taskId, fields) {
2022
+ try {
2023
+ return await db.updateTaskById(taskId, fields);
2024
+ } catch (error) {
2025
+ if (Object.prototype.hasOwnProperty.call(fields, "last_error") && /last_error/i.test(error.message || "")) {
2026
+ const { last_error: _omit, ...rest } = fields;
2027
+ return db.updateTaskById(taskId, rest);
2028
+ }
2029
+ throw error;
2030
+ }
2031
+ }
2032
+ function lastErrorJson(task, reason, extra) {
2033
+ return JSON.stringify({
2034
+ at: (/* @__PURE__ */ new Date()).toISOString(),
2035
+ occurrence: task.next_send_at ?? null,
2036
+ reason: sanitizeErrorSummary(reason),
2037
+ ...extra || {}
2038
+ });
2039
+ }
1681
2040
  async function updateAndRelease(taskId, fields) {
1682
- return db.updateTaskById(
2041
+ return updateTaskWithLastError(
1683
2042
  taskId,
1684
2043
  supportsClaim ? { ...fields, lease_until: null, retry_after: null } : fields
1685
2044
  );
@@ -1703,8 +2062,9 @@ async function runScheduledTick(ctx) {
1703
2062
  return { taken: true, rawKey: null };
1704
2063
  }
1705
2064
  if (typeof rawKey !== "string" || !rawKey) return { taken: false, rawKey: null };
1706
- if (groupsTakenThisTick.has(rawKey)) return { taken: true, rawKey };
1707
- groupsTakenThisTick.add(rawKey);
2065
+ const scopedKey = `${task.user_id}\0${rawKey}`;
2066
+ if (groupsTakenThisTick.has(scopedKey)) return { taken: true, rawKey };
2067
+ groupsTakenThisTick.add(scopedKey);
1708
2068
  return { taken: false, rawKey };
1709
2069
  }
1710
2070
  async function encryptPayloadWithLastError(task, decryptedPayload, userKey, reason, extra) {
@@ -1731,34 +2091,38 @@ async function runScheduledTick(ctx) {
1731
2091
  console.warn("[amsg-server] onStaleSkip hook \u629B\u9519\uFF08\u5DF2\u5FFD\u7565\uFF09:", hookError && hookError.message);
1732
2092
  }
1733
2093
  }
1734
- async function handleDeliveryFailure(task, reason, recurrenceType, decryptedPayload, userKey) {
2094
+ async function handleDeliveryFailure(task, reason, recurrenceType, decryptedPayload, userKey, errorCode = null, permanentFlag = false) {
1735
2095
  results.failedCount++;
1736
2096
  const tzId = decryptedPayload ? decryptedPayload.tzId ?? null : null;
2097
+ const permanent = permanentFlag === true || errorCode === "PUSH_SUBSCRIPTION_MISSING" || errorCode === "PUSH_SUBSCRIPTION_STORE_UNSUPPORTED";
1737
2098
  try {
1738
- if (task.retry_count >= 3) {
2099
+ if (permanent || task.retry_count >= 3) {
1739
2100
  const encrypted = await encryptPayloadWithLastError(task, decryptedPayload, userKey, reason);
1740
2101
  if (isRecurringType(recurrenceType)) {
1741
2102
  const nextSendAt = nextFutureOccurrence(Date.parse(task.next_send_at), recurrenceType, Date.now(), tzId);
1742
2103
  await updateAndRelease(task.id, {
1743
2104
  next_send_at: nextSendAt,
1744
2105
  retry_count: 0,
1745
- ...encrypted ? { encrypted_payload: encrypted } : {}
2106
+ ...encrypted ? { encrypted_payload: encrypted } : {},
2107
+ ...supportsLastError ? { last_error: lastErrorJson(task, reason) } : {}
1746
2108
  });
1747
- results.failedTasks.push({ taskId: task.id, reason, retryCount: task.retry_count, status: "occurrence_skipped", nextSendAt });
2109
+ results.failedTasks.push({ taskId: task.id, reason, retryCount: task.retry_count, status: "occurrence_skipped", nextSendAt, ...permanent ? { permanent: true } : {} });
1748
2110
  } else {
1749
2111
  await updateAndRelease(task.id, {
1750
2112
  status: "failed",
1751
- ...encrypted ? { encrypted_payload: encrypted } : {}
2113
+ ...encrypted ? { encrypted_payload: encrypted } : {},
2114
+ ...supportsLastError ? { last_error: lastErrorJson(task, reason) } : {}
1752
2115
  });
1753
- results.failedTasks.push({ taskId: task.id, reason, retryCount: task.retry_count, status: "permanently_failed" });
2116
+ results.failedTasks.push({ taskId: task.id, reason, retryCount: task.retry_count, status: "permanently_failed", ...permanent ? { permanent: true } : {} });
1754
2117
  }
1755
2118
  } else {
1756
2119
  const nextRetryTime = new Date(Date.now() + (task.retry_count + 1) * 2 * 60 * 1e3);
1757
2120
  if (supportsClaim) {
1758
- await db.updateTaskById(task.id, {
2121
+ await updateTaskWithLastError(task.id, {
1759
2122
  retry_after: nextRetryTime.toISOString(),
1760
2123
  lease_until: null,
1761
- retry_count: task.retry_count + 1
2124
+ retry_count: task.retry_count + 1,
2125
+ ...supportsLastError ? { last_error: lastErrorJson(task, reason) } : {}
1762
2126
  });
1763
2127
  } else {
1764
2128
  await db.updateTaskById(task.id, { next_send_at: nextRetryTime.toISOString(), retry_count: task.retry_count + 1 });
@@ -1819,6 +2183,16 @@ async function runScheduledTick(ctx) {
1819
2183
  results.claimSkippedTasks++;
1820
2184
  return;
1821
2185
  }
2186
+ const stopHeartbeat = startLeaseHeartbeat(task);
2187
+ try {
2188
+ await deliverClaimedTask(task, decrypted);
2189
+ } finally {
2190
+ stopHeartbeat();
2191
+ }
2192
+ }
2193
+ async function deliverClaimedTask(task, decrypted) {
2194
+ const decryptedPayload = decrypted.ok ? decrypted.payload : null;
2195
+ const userKey = decrypted.ok ? decrypted.userKey : null;
1822
2196
  if (!decrypted.ok) {
1823
2197
  await handleDeliveryFailure(task, decrypted.error && decrypted.error.message || "\u4EFB\u52A1\u8F7D\u8377\u89E3\u5BC6\u5931\u8D25", null, null, null);
1824
2198
  return;
@@ -1826,7 +2200,9 @@ async function runScheduledTick(ctx) {
1826
2200
  const recurrenceType = decryptedPayload.recurrenceType;
1827
2201
  const tzId = decryptedPayload.tzId ?? null;
1828
2202
  const occurrenceMs = Date.parse(task.next_send_at);
1829
- if (Number.isFinite(occurrenceMs) && Date.now() - occurrenceMs > STALE_AFTER_MS && (task.retry_count || 0) === 0) {
2203
+ const retryAfterMs = task.retry_after ? Date.parse(task.retry_after) : NaN;
2204
+ const notOnFreshRetryChain = (task.retry_count || 0) === 0 || Number.isFinite(retryAfterMs) && Date.now() - retryAfterMs > staleAfterMs;
2205
+ if (Number.isFinite(occurrenceMs) && Date.now() - occurrenceMs > staleAfterMs && notOnFreshRetryChain) {
1830
2206
  try {
1831
2207
  const stateAccessors = createStateAccessors({
1832
2208
  db,
@@ -1834,57 +2210,42 @@ async function runScheduledTick(ctx) {
1834
2210
  userKey,
1835
2211
  maxStateValueBytes: ctx.maxStateValueBytes
1836
2212
  });
1837
- if (isRecurringType(recurrenceType)) {
1838
- const plan = planNextOccurrence(occurrenceMs, recurrenceType, Date.now(), tzId);
1839
- const nextSendAt = new Date(plan.nextMs).toISOString();
1840
- const encrypted = await encryptPayloadWithLastError(task, decryptedPayload, userKey, "stale", {
1841
- skippedCount: plan.skippedCount,
1842
- nextSendAt
1843
- });
1844
- await updateAndRelease(task.id, {
1845
- next_send_at: nextSendAt,
1846
- retry_count: 0,
1847
- ...encrypted ? { encrypted_payload: encrypted } : {}
1848
- });
1849
- results.staleTasks.push({
1850
- taskId: task.id,
1851
- reason: "stale",
1852
- action: "fast_forwarded",
1853
- nextSendAt,
1854
- skippedCount: plan.skippedCount
1855
- });
1856
- await notifyStaleSkip(task, {
1857
- reason: "stale",
1858
- action: "fast_forwarded",
1859
- metadata: decryptedPayload.metadata ?? null,
1860
- recurrenceType,
1861
- occurrenceMs,
1862
- skippedCount: plan.skippedCount,
1863
- skippedOccurrences: plan.skippedOccurrences,
1864
- skippedTruncated: plan.skippedTruncated,
1865
- nextSendAt,
1866
- ...stateAccessors
1867
- });
1868
- } else {
1869
- const encrypted = await encryptPayloadWithLastError(task, decryptedPayload, userKey, "stale");
1870
- await updateAndRelease(task.id, {
1871
- status: "failed",
1872
- ...encrypted ? { encrypted_payload: encrypted } : {}
1873
- });
1874
- results.staleTasks.push({ taskId: task.id, reason: "stale", action: "expired", skippedCount: 1 });
1875
- await notifyStaleSkip(task, {
1876
- reason: "stale",
1877
- action: "expired",
1878
- metadata: decryptedPayload.metadata ?? null,
1879
- recurrenceType: recurrenceType || "none",
1880
- occurrenceMs,
1881
- skippedCount: 1,
1882
- skippedOccurrences: [occurrenceMs],
1883
- skippedTruncated: false,
1884
- nextSendAt: null,
1885
- ...stateAccessors
1886
- });
1887
- }
2213
+ const recurring = isRecurringType(recurrenceType);
2214
+ const plan = recurring ? planNextOccurrence(occurrenceMs, recurrenceType, Date.now(), tzId) : null;
2215
+ const nextSendAt = recurring ? new Date(plan.nextMs).toISOString() : null;
2216
+ const action = recurring ? "fast_forwarded" : "expired";
2217
+ const skippedCount = recurring ? plan.skippedCount : 1;
2218
+ const encrypted = await encryptPayloadWithLastError(
2219
+ task,
2220
+ decryptedPayload,
2221
+ userKey,
2222
+ "stale",
2223
+ recurring ? { skippedCount, nextSendAt } : void 0
2224
+ );
2225
+ await updateAndRelease(task.id, {
2226
+ ...recurring ? { next_send_at: nextSendAt, retry_count: 0 } : { status: "failed" },
2227
+ ...encrypted ? { encrypted_payload: encrypted } : {},
2228
+ ...supportsLastError ? { last_error: lastErrorJson(task, "stale", recurring ? { skippedCount, nextSendAt } : void 0) } : {}
2229
+ });
2230
+ results.staleTasks.push({
2231
+ taskId: task.id,
2232
+ reason: "stale",
2233
+ action,
2234
+ ...recurring ? { nextSendAt } : {},
2235
+ skippedCount
2236
+ });
2237
+ await notifyStaleSkip(task, {
2238
+ reason: "stale",
2239
+ action,
2240
+ metadata: decryptedPayload.metadata ?? null,
2241
+ recurrenceType: recurrenceType || "none",
2242
+ occurrenceMs,
2243
+ skippedCount,
2244
+ skippedOccurrences: recurring ? plan.skippedOccurrences : [occurrenceMs],
2245
+ skippedTruncated: recurring ? plan.skippedTruncated : false,
2246
+ nextSendAt,
2247
+ ...stateAccessors
2248
+ });
1888
2249
  } catch (error) {
1889
2250
  results.failedCount++;
1890
2251
  results.failedTasks.push({ taskId: task.id, reason: error.message || "\u8FC7\u671F\u4EFB\u52A1\u5904\u7406\u5931\u8D25", status: "stale_update_failed" });
@@ -1893,13 +2254,34 @@ async function runScheduledTick(ctx) {
1893
2254
  }
1894
2255
  let sendResult;
1895
2256
  try {
1896
- sendResult = await processSingleMessage(task, { ...ctx, db, masterKey }, masterKey);
2257
+ sendResult = await processSingleMessage(
2258
+ task,
2259
+ { ...ctx, db, masterKey },
2260
+ masterKey,
2261
+ { userKey, payload: decryptedPayload }
2262
+ );
1897
2263
  } catch (error) {
1898
- await handleDeliveryFailure(task, error.message || "\u6D88\u606F\u53D1\u9001\u5931\u8D25", recurrenceType, decryptedPayload, userKey);
2264
+ await handleDeliveryFailure(
2265
+ task,
2266
+ error.message || "\u6D88\u606F\u53D1\u9001\u5931\u8D25",
2267
+ recurrenceType,
2268
+ decryptedPayload,
2269
+ userKey,
2270
+ error.code || null,
2271
+ error.permanent === true
2272
+ );
1899
2273
  return;
1900
2274
  }
1901
2275
  if (!sendResult.success) {
1902
- await handleDeliveryFailure(task, sendResult.error || "\u6D88\u606F\u53D1\u9001\u5931\u8D25", recurrenceType, decryptedPayload, userKey);
2276
+ await handleDeliveryFailure(
2277
+ task,
2278
+ sendResult.error || "\u6D88\u606F\u53D1\u9001\u5931\u8D25",
2279
+ recurrenceType,
2280
+ decryptedPayload,
2281
+ userKey,
2282
+ sendResult.errorCode || null,
2283
+ sendResult.permanent === true
2284
+ );
1903
2285
  return;
1904
2286
  }
1905
2287
  try {
@@ -1908,7 +2290,11 @@ async function runScheduledTick(ctx) {
1908
2290
  results.deletedOnceOffTasks++;
1909
2291
  } else {
1910
2292
  const nextSendAt = nextFutureOccurrence(occurrenceMs, recurrenceType, Date.now(), tzId);
1911
- await updateAndRelease(task.id, { next_send_at: nextSendAt, retry_count: 0 });
2293
+ await updateAndRelease(task.id, {
2294
+ next_send_at: nextSendAt,
2295
+ retry_count: 0,
2296
+ ...supportsLastError ? { last_error: null } : {}
2297
+ });
1912
2298
  results.updatedRecurringTasks++;
1913
2299
  }
1914
2300
  results.successCount++;
@@ -1941,7 +2327,6 @@ async function runScheduledTick(ctx) {
1941
2327
  await Promise.race(processing);
1942
2328
  }
1943
2329
  }
1944
- await db.cleanupOldTasks(7);
1945
2330
  const executionTime = Date.now() - startTime;
1946
2331
  return {
1947
2332
  totalTasks: results.totalTasks,
@@ -1977,13 +2362,9 @@ function createUpdateMessageHandler(ctx) {
1977
2362
  if (!taskUuid) {
1978
2363
  return { status: 400, body: { success: false, error: { code: "TASK_ID_REQUIRED", message: "\u7F3A\u5C11\u4EFB\u52A1ID" } } };
1979
2364
  }
1980
- const userId = getHeader(headers, "x-user-id");
1981
- if (!userId) {
1982
- return { status: 400, body: { success: false, error: { code: "USER_ID_REQUIRED", message: "\u7F3A\u5C11\u7528\u6237\u6807\u8BC6\u7B26" } } };
1983
- }
1984
- if (!isValidUUIDv4(userId)) {
1985
- return { status: 400, body: { success: false, error: { code: "INVALID_USER_ID_FORMAT", message: "X-User-Id \u5FC5\u987B\u662F UUID v4 \u683C\u5F0F" } } };
1986
- }
2365
+ const gate = requireUserId(headers);
2366
+ if (gate.error) return gate.error;
2367
+ const { userId } = gate;
1987
2368
  const isEncrypted = getHeader(headers, "x-payload-encrypted") === "true";
1988
2369
  const encryptionVersion = getHeader(headers, "x-encryption-version");
1989
2370
  if (!isEncrypted) {
@@ -2092,7 +2473,11 @@ function createUpdateMessageHandler(ctx) {
2092
2473
  ...Object.prototype.hasOwnProperty.call(updates, "splitPattern") && { splitPattern: updates.splitPattern ?? null }
2093
2474
  };
2094
2475
  const encryptedPayload = await encryptForStorage(JSON.stringify(updatedData), userKey);
2095
- const extraFields = updates.nextSendAt ? { next_send_at: updates.nextSendAt } : void 0;
2476
+ const extraFields = {
2477
+ retry_count: 0,
2478
+ ...typeof db.claimTask === "function" ? { retry_after: null } : {},
2479
+ ...updates.nextSendAt ? { next_send_at: updates.nextSendAt } : {}
2480
+ };
2096
2481
  const result = await db.updateTaskByUuid(taskUuid, userId, encryptedPayload, extraFields);
2097
2482
  if (!result) {
2098
2483
  return { status: 409, body: { success: false, error: { code: "UPDATE_CONFLICT", message: "\u4EFB\u52A1\u66F4\u65B0\u5931\u8D25\uFF0C\u4EFB\u52A1\u53EF\u80FD\u5DF2\u88AB\u4FEE\u6539\u6216\u5220\u9664" } } };
@@ -2125,13 +2510,9 @@ function createCancelMessageHandler(ctx) {
2125
2510
  if (!taskUuid) {
2126
2511
  return { status: 400, body: { success: false, error: { code: "TASK_ID_REQUIRED", message: "\u7F3A\u5C11\u4EFB\u52A1ID" } } };
2127
2512
  }
2128
- const userId = getHeader(headers, "x-user-id");
2129
- if (!userId) {
2130
- return { status: 400, body: { success: false, error: { code: "USER_ID_REQUIRED", message: "\u7F3A\u5C11\u7528\u6237\u6807\u8BC6\u7B26" } } };
2131
- }
2132
- if (!isValidUUIDv4(userId)) {
2133
- return { status: 400, body: { success: false, error: { code: "INVALID_USER_ID_FORMAT", message: "X-User-Id \u5FC5\u987B\u662F UUID v4 \u683C\u5F0F" } } };
2134
- }
2513
+ const gate = requireUserId(headers);
2514
+ if (gate.error) return gate.error;
2515
+ const { userId } = gate;
2135
2516
  const deleted = await db.deleteTaskByUuid(taskUuid, userId);
2136
2517
  if (!deleted) {
2137
2518
  return {
@@ -2160,19 +2541,9 @@ function createMessagesHandler(ctx) {
2160
2541
  const tenantCtx = tenantResult.context;
2161
2542
  const db = tenantCtx.db;
2162
2543
  const masterKey = tenantCtx.masterKey;
2163
- const userId = getHeader(headers, "x-user-id");
2164
- if (!userId) {
2165
- return {
2166
- status: 400,
2167
- body: { success: false, error: { code: "USER_ID_REQUIRED", message: "\u5FC5\u987B\u63D0\u4F9B X-User-Id \u8BF7\u6C42\u5934" } }
2168
- };
2169
- }
2170
- if (!isValidUUIDv4(userId)) {
2171
- return {
2172
- status: 400,
2173
- body: { success: false, error: { code: "INVALID_USER_ID_FORMAT", message: "X-User-Id \u5FC5\u987B\u662F UUID v4 \u683C\u5F0F" } }
2174
- };
2175
- }
2544
+ const gate = requireUserId(headers);
2545
+ if (gate.error) return gate.error;
2546
+ const { userId } = gate;
2176
2547
  const u = new URL(url, "https://dummy");
2177
2548
  const status = u.searchParams.get("status") || "all";
2178
2549
  const limit = Math.min(parseInt(u.searchParams.get("limit") || "20", 10), 100);
@@ -2217,30 +2588,39 @@ function createGetMessageHandler(ctx) {
2217
2588
  const tenantCtx = tenantResult.context;
2218
2589
  const db = tenantCtx.db;
2219
2590
  const masterKey = tenantCtx.masterKey;
2220
- const userId = getHeader(headers, "x-user-id");
2221
- if (!userId) {
2222
- return {
2223
- status: 400,
2224
- body: { success: false, error: { code: "USER_ID_REQUIRED", message: "\u5FC5\u987B\u63D0\u4F9B X-User-Id \u8BF7\u6C42\u5934" } }
2225
- };
2226
- }
2227
- if (!isValidUUIDv4(userId)) {
2228
- return {
2229
- status: 400,
2230
- body: { success: false, error: { code: "INVALID_USER_ID_FORMAT", message: "X-User-Id \u5FC5\u987B\u662F UUID v4 \u683C\u5F0F" } }
2231
- };
2232
- }
2591
+ const gate = requireUserId(headers);
2592
+ if (gate.error) return gate.error;
2593
+ const { userId } = gate;
2233
2594
  const taskUuid = new URL(url, "https://dummy").searchParams.get("id");
2234
2595
  if (!taskUuid) {
2235
2596
  return { status: 400, body: { success: false, error: { code: "TASK_ID_REQUIRED", message: "\u7F3A\u5C11\u4EFB\u52A1ID" } } };
2236
2597
  }
2237
2598
  const row = await db.getTaskByUuid(taskUuid, userId);
2238
2599
  if (!row) {
2239
- const taskStatus = await db.getTaskStatus(taskUuid, userId);
2600
+ const statusInfo = typeof db.getTaskStatusInfo === "function" ? await db.getTaskStatusInfo(taskUuid, userId) : null;
2601
+ const taskStatus = statusInfo ? statusInfo.status : await db.getTaskStatus(taskUuid, userId);
2240
2602
  if (!taskStatus) {
2241
2603
  return { status: 404, body: { success: false, error: { code: "TASK_NOT_FOUND", message: "\u6307\u5B9A\u7684\u4EFB\u52A1\u4E0D\u5B58\u5728\u6216\u5DF2\u88AB\u5220\u9664" } } };
2242
2604
  }
2243
- return { status: 409, body: { success: false, error: { code: "TASK_ALREADY_COMPLETED", message: "\u4EFB\u52A1\u5DF2\u5B8C\u6210\u6216\u5DF2\u5931\u8D25\uFF0C\u65E0\u6CD5\u66F4\u65B0" } } };
2605
+ let lastError = null;
2606
+ if (statusInfo && statusInfo.last_error) {
2607
+ try {
2608
+ lastError = JSON.parse(statusInfo.last_error);
2609
+ } catch (_parseError) {
2610
+ lastError = { reason: statusInfo.last_error };
2611
+ }
2612
+ }
2613
+ return {
2614
+ status: 409,
2615
+ body: {
2616
+ success: false,
2617
+ error: {
2618
+ code: "TASK_ALREADY_COMPLETED",
2619
+ message: "\u4EFB\u52A1\u5DF2\u5B8C\u6210\u6216\u5DF2\u5931\u8D25\uFF0C\u65E0\u6CD5\u66F4\u65B0",
2620
+ details: { status: taskStatus, lastError }
2621
+ }
2622
+ }
2623
+ };
2244
2624
  }
2245
2625
  const userKey = await deriveUserEncryptionKey(userId, masterKey);
2246
2626
  const decrypted = JSON.parse(await decryptFromStorage(row.encrypted_payload, userKey));
@@ -2263,12 +2643,6 @@ function err(status, code, message, details) {
2263
2643
  const error = details === void 0 ? { code, message } : { code, message, details };
2264
2644
  return { status, body: { success: false, error } };
2265
2645
  }
2266
- function requireUserId(headers) {
2267
- const userId = getHeader(headers, "x-user-id");
2268
- if (!userId) return { error: err(400, "USER_ID_REQUIRED", "\u7F3A\u5C11\u7528\u6237\u6807\u8BC6\u7B26") };
2269
- if (!isValidUUIDv4(userId)) return { error: err(400, "INVALID_USER_ID_FORMAT", "X-User-Id \u5FC5\u987B\u662F UUID v4 \u683C\u5F0F") };
2270
- return { userId };
2271
- }
2272
2646
  var UNSUPPORTED = err(
2273
2647
  501,
2274
2648
  "PUSH_SUBSCRIPTION_NOT_SUPPORTED",
@@ -2376,6 +2750,7 @@ var SQLITE_TABLE_SQL = `
2376
2750
  serialize_group TEXT,
2377
2751
  status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'failed')),
2378
2752
  retry_count INTEGER NOT NULL DEFAULT 0,
2753
+ last_error TEXT,
2379
2754
  created_at TEXT NOT NULL,
2380
2755
  updated_at TEXT NOT NULL
2381
2756
  )
@@ -2395,6 +2770,14 @@ var SQLITE_MIGRATIONS = [
2395
2770
  name: "add_serialize_group",
2396
2771
  sql: "ALTER TABLE scheduled_messages ADD COLUMN serialize_group TEXT",
2397
2772
  description: "Serialization group for runScheduledTick serializeBy (2.6.0)"
2773
+ },
2774
+ {
2775
+ name: "add_last_error",
2776
+ // 上一次投递失败的脱敏摘要(JSON:{ at, occurrence, reason })。payload 里
2777
+ // 的 lastError 是密文里的完整记录,这一列是它的明文出口——payload 本身
2778
+ // 解密失败时也写得进去,GET /message 对已失败的行也读得出来。
2779
+ sql: "ALTER TABLE scheduled_messages ADD COLUMN last_error TEXT",
2780
+ description: "Sanitized last delivery-failure summary (2.6.0)"
2398
2781
  }
2399
2782
  ];
2400
2783
  var SQLITE_INDEXES = [
@@ -2463,22 +2846,64 @@ var PUSH_SUBSCRIPTION_TABLE_SQL = `
2463
2846
  updated_at INTEGER NOT NULL
2464
2847
  )
2465
2848
  `;
2849
+ var MESSAGE_OUTBOX_TABLE_SQL = `
2850
+ CREATE TABLE IF NOT EXISTS message_outbox (
2851
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
2852
+ user_id TEXT NOT NULL,
2853
+ message_id TEXT NOT NULL,
2854
+ task_uuid TEXT,
2855
+ session_id TEXT,
2856
+ message_index INTEGER,
2857
+ total_messages INTEGER,
2858
+ payload TEXT NOT NULL,
2859
+ created_at INTEGER NOT NULL,
2860
+ delivered_at INTEGER,
2861
+ acked_at INTEGER,
2862
+ UNIQUE (user_id, message_id)
2863
+ )
2864
+ `;
2865
+ var MESSAGE_OUTBOX_INDEX_SQL = `
2866
+ CREATE INDEX IF NOT EXISTS idx_outbox_unacked
2867
+ ON message_outbox (user_id, id)
2868
+ WHERE acked_at IS NULL
2869
+ `;
2870
+ function parseTableName(sql) {
2871
+ const match = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([A-Za-z_][A-Za-z0-9_]*)/i.exec(sql);
2872
+ return match ? match[1] : "";
2873
+ }
2874
+ function parseColumnNames(sql) {
2875
+ const body = sql.slice(sql.indexOf("(") + 1, sql.lastIndexOf(")"));
2876
+ const parts = [];
2877
+ let depth = 0;
2878
+ let current = "";
2879
+ for (const ch of body) {
2880
+ if (ch === "(") depth++;
2881
+ else if (ch === ")") depth--;
2882
+ if (ch === "," && depth === 0) {
2883
+ parts.push(current);
2884
+ current = "";
2885
+ continue;
2886
+ }
2887
+ current += ch;
2888
+ }
2889
+ parts.push(current);
2890
+ const TABLE_CONSTRAINTS = /* @__PURE__ */ new Set(["primary", "unique", "foreign", "check", "constraint"]);
2891
+ return parts.map((part) => part.trim()).filter(Boolean).map((part) => part.split(/\s+/)[0]).filter((name) => !TABLE_CONSTRAINTS.has(name.toLowerCase()));
2892
+ }
2893
+ function describeTable(sql) {
2894
+ return [parseTableName(sql), parseColumnNames(sql)];
2895
+ }
2896
+ var SQLITE_REQUIRED_SCHEMA = Object.freeze({
2897
+ tables: Object.freeze(Object.fromEntries([
2898
+ describeTable(SQLITE_TABLE_SQL),
2899
+ describeTable(CLIENT_STATE_TABLE_SQL),
2900
+ describeTable(PUSH_SUBSCRIPTION_TABLE_SQL),
2901
+ describeTable(MESSAGE_OUTBOX_TABLE_SQL)
2902
+ ])),
2903
+ indexes: Object.freeze(SQLITE_INDEXES.filter((index) => index.critical).map((index) => index.name))
2904
+ });
2466
2905
 
2467
2906
  // src/server/adapters/d1.js
2468
- var UPDATABLE_COLUMNS = /* @__PURE__ */ new Set([
2469
- "user_id",
2470
- "uuid",
2471
- "encrypted_payload",
2472
- "message_type",
2473
- "next_send_at",
2474
- "lease_until",
2475
- "retry_after",
2476
- "serialize_group",
2477
- "status",
2478
- "retry_count",
2479
- "created_at",
2480
- "updated_at"
2481
- ]);
2482
2907
  function escapeLikePrefix(prefix) {
2483
2908
  return prefix.replace(/[\\%_]/g, (ch) => `\\${ch}`);
2484
2909
  }
@@ -2503,6 +2928,8 @@ var D1Adapter = class {
2503
2928
  await this._db.prepare(SQLITE_TABLE_SQL).run();
2504
2929
  await this._db.prepare(CLIENT_STATE_TABLE_SQL).run();
2505
2930
  await this._db.prepare(PUSH_SUBSCRIPTION_TABLE_SQL).run();
2931
+ await this._db.prepare(MESSAGE_OUTBOX_TABLE_SQL).run();
2932
+ await this._db.prepare(MESSAGE_OUTBOX_INDEX_SQL).run();
2506
2933
  for (const migration of SQLITE_MIGRATIONS) {
2507
2934
  try {
2508
2935
  await this._db.prepare(migration.sql).run();
@@ -2534,10 +2961,36 @@ var D1Adapter = class {
2534
2961
  indexes: indexResults
2535
2962
  };
2536
2963
  }
2964
+ /**
2965
+ * 活库里现在实际有哪些表 / 列 / 索引。
2966
+ *
2967
+ * 只读不写,纯粹如实回报:拿它跟这一版需要的清单对照的活儿在
2968
+ * lib/schema-version.js(`getSchemaVersion` / `ensureSchema`)。库升级后表结
2969
+ * 构变了而老部署没跑过 initSchema 时,cron 会每分钟静默挂在缺的那一列上,
2970
+ * 界面上一切正常——这个方法就是让宿主查得出来。
2971
+ *
2972
+ * @returns {Promise<{ tables: Record<string, string[]>, indexes: string[] }>}
2973
+ */
2974
+ async describeSchema() {
2975
+ const tableRes = await this._db.prepare(
2976
+ `SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`
2977
+ ).all();
2978
+ const tables = {};
2979
+ for (const row of tableRes.results || []) {
2980
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(row.name)) continue;
2981
+ const columnRes = await this._db.prepare(`PRAGMA table_info(${row.name})`).all();
2982
+ tables[row.name] = (columnRes.results || []).map((column) => column.name);
2983
+ }
2984
+ const indexRes = await this._db.prepare(
2985
+ `SELECT name FROM sqlite_master WHERE type = 'index' AND name IS NOT NULL`
2986
+ ).all();
2987
+ return { tables, indexes: (indexRes.results || []).map((row) => row.name) };
2988
+ }
2537
2989
  async dropSchema() {
2538
2990
  await this._db.prepare("DROP TABLE IF EXISTS scheduled_messages").run();
2539
2991
  await this._db.prepare("DROP TABLE IF EXISTS client_state").run();
2540
2992
  await this._db.prepare("DROP TABLE IF EXISTS push_subscriptions").run();
2993
+ await this._db.prepare("DROP TABLE IF EXISTS message_outbox").run();
2541
2994
  }
2542
2995
  async createTask(params) {
2543
2996
  const now = this._now();
@@ -2552,9 +3005,48 @@ var D1Adapter = class {
2552
3005
  `SELECT id, uuid, next_send_at, status, created_at FROM scheduled_messages WHERE id = ?`
2553
3006
  ).bind(id).first();
2554
3007
  }
3008
+ /**
3009
+ * 建新任务的同时取消旧的那条(`POST /schedule-message` 的 supersedesUuid)。
3010
+ *
3011
+ * 两条语句走一次 batch(D1 的隐式事务 + 单次网络往返):不会出现「旧的删了、
3012
+ * 新的没建成」的中间态——INSERT 撞 uuid 唯一索引时整个 batch 回滚,旧行原样
3013
+ * 留着,调用方按既有的 409 冲突路径处理。
3014
+ *
3015
+ * @param {import('./interface.js').InsertTaskParams} params
3016
+ * @param {string} supersedesUuid - 要取消的旧任务 uuid(同一 user_id 下)
3017
+ * @returns {Promise<Object>} createTask 的返回行 + `superseded`(旧行是否
3018
+ * 真的被删掉;false = 旧行本就不存在)
3019
+ */
3020
+ async createTaskSuperseding(params, supersedesUuid) {
3021
+ const now = this._now();
3022
+ const nextSendAt = this._iso(params.next_send_at);
3023
+ const statements = [
3024
+ this._db.prepare(
3025
+ "DELETE FROM scheduled_messages WHERE uuid = ? AND user_id = ?"
3026
+ ).bind(supersedesUuid, params.user_id),
3027
+ this._db.prepare(
3028
+ `INSERT INTO scheduled_messages
3029
+ (user_id, uuid, encrypted_payload, next_send_at, message_type, status, retry_count, created_at, updated_at)
3030
+ VALUES (?, ?, ?, ?, ?, 'pending', 0, ?, ?)`
3031
+ ).bind(params.user_id, params.uuid, params.encrypted_payload, nextSendAt, params.message_type, now, now)
3032
+ ];
3033
+ let results;
3034
+ if (typeof this._db.batch === "function") {
3035
+ results = await this._db.batch(statements);
3036
+ } else {
3037
+ results = [];
3038
+ for (const stmt of statements) results.push(await stmt.run());
3039
+ }
3040
+ const superseded = (results[0].meta.changes || 0) > 0;
3041
+ const id = results[1].meta.last_row_id;
3042
+ const row = await this._db.prepare(
3043
+ "SELECT id, uuid, next_send_at, status, created_at FROM scheduled_messages WHERE id = ?"
3044
+ ).bind(id).first();
3045
+ return { ...row, superseded };
3046
+ }
2555
3047
  async getTaskByUuid(uuid, userId) {
2556
3048
  return this._db.prepare(
2557
- `SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count, created_at, updated_at
3049
+ `SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count, last_error, created_at, updated_at
2558
3050
  FROM scheduled_messages
2559
3051
  WHERE uuid = ? AND user_id = ? AND status = 'pending'
2560
3052
  LIMIT 1`
@@ -2562,12 +3054,25 @@ var D1Adapter = class {
2562
3054
  }
2563
3055
  async getTaskByUuidOnly(uuid) {
2564
3056
  return this._db.prepare(
2565
- `SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count
3057
+ `SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, retry_after, status, retry_count
2566
3058
  FROM scheduled_messages
2567
3059
  WHERE uuid = ? AND status = 'pending'
2568
3060
  LIMIT 1`
2569
3061
  ).bind(uuid).first();
2570
3062
  }
3063
+ /**
3064
+ * 这条 uuid 现在是什么状态——不限用户,也不限状态(getTaskByUuidOnly 只看
3065
+ * pending 行)。`runTask` 用它把「这条已经跑完了」和「压根没这条」分开回报。
3066
+ *
3067
+ * @param {string} uuid
3068
+ * @returns {Promise<{ status: string }|null>}
3069
+ */
3070
+ async getTaskStatusByUuidOnly(uuid) {
3071
+ const row = await this._db.prepare(
3072
+ "SELECT status FROM scheduled_messages WHERE uuid = ? LIMIT 1"
3073
+ ).bind(uuid).first();
3074
+ return row ? { status: row.status } : null;
3075
+ }
2571
3076
  async updateTaskById(taskId, updates) {
2572
3077
  const sets = [];
2573
3078
  const values = [];
@@ -2622,7 +3127,7 @@ var D1Adapter = class {
2622
3127
  async getPendingTasks(limit = 50) {
2623
3128
  const now = this._now();
2624
3129
  const res = await this._db.prepare(
2625
- `SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count
3130
+ `SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, retry_after, status, retry_count
2626
3131
  FROM scheduled_messages
2627
3132
  WHERE status = 'pending' AND next_send_at <= ?
2628
3133
  AND (lease_until IS NULL OR lease_until <= ?)
@@ -2695,6 +3200,22 @@ var D1Adapter = class {
2695
3200
  ).bind(...values).run();
2696
3201
  return (res.meta.changes || 0) > 0;
2697
3202
  }
3203
+ /**
3204
+ * 投递期间的租约续期(run-tick 的心跳用)。只在行仍是 pending 且确实持有
3205
+ * 租约(lease_until 非空)时生效——收尾把租约放掉之后,迟到的心跳不会把
3206
+ * 租约复活。
3207
+ *
3208
+ * @param {number} taskId
3209
+ * @param {string|Date} leaseUntil - 新的租期末尾
3210
+ * @returns {Promise<boolean>} true = 续上了
3211
+ */
3212
+ async renewTaskLease(taskId, leaseUntil) {
3213
+ const res = await this._db.prepare(
3214
+ `UPDATE scheduled_messages SET lease_until = ?
3215
+ WHERE id = ? AND status = 'pending' AND lease_until IS NOT NULL`
3216
+ ).bind(this._iso(leaseUntil), taskId).run();
3217
+ return (res.meta.changes || 0) > 0;
3218
+ }
2698
3219
  async listTasks(userId, opts = {}) {
2699
3220
  const { status = "all", limit = 20, offset = 0 } = opts;
2700
3221
  const conditions = ["user_id = ?"];
@@ -2709,7 +3230,7 @@ var D1Adapter = class {
2709
3230
  ).bind(...params).first();
2710
3231
  const total = Number(countRow.count) || 0;
2711
3232
  const res = await this._db.prepare(
2712
- `SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count, created_at, updated_at
3233
+ `SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count, last_error, created_at, updated_at
2713
3234
  FROM scheduled_messages
2714
3235
  WHERE ${where}
2715
3236
  ORDER BY next_send_at ASC
@@ -2732,6 +3253,20 @@ var D1Adapter = class {
2732
3253
  ).bind(uuid, userId).first();
2733
3254
  return row ? row.status : null;
2734
3255
  }
3256
+ /**
3257
+ * 状态 + 失败摘要(GET /message 用它把「为什么失败」透给已失败的行——那些
3258
+ * 行 getTaskByUuid 读不到,payload 里的 lastError 也就够不着了)。
3259
+ *
3260
+ * @param {string} uuid
3261
+ * @param {string} userId
3262
+ * @returns {Promise<{ status: string, last_error: string|null }|null>}
3263
+ */
3264
+ async getTaskStatusInfo(uuid, userId) {
3265
+ const row = await this._db.prepare(
3266
+ "SELECT status, last_error FROM scheduled_messages WHERE uuid = ? AND user_id = ? LIMIT 1"
3267
+ ).bind(uuid, userId).first();
3268
+ return row ? { status: row.status, last_error: row.last_error ?? null } : null;
3269
+ }
2735
3270
  // ── client_state (single-user cloud state mirror) ──────────────────────
2736
3271
  /**
2737
3272
  * Batch upsert. Last-write-wins per (namespace, key): an entry older
@@ -2862,6 +3397,127 @@ var D1Adapter = class {
2862
3397
  const res = await this._db.prepare("DELETE FROM push_subscriptions WHERE user_id = ?").bind(userId).run();
2863
3398
  return (res.meta.changes || 0) > 0;
2864
3399
  }
3400
+ // ── message_outbox(服务端消息收件箱,客户端 ack)────────────────────────
3401
+ /**
3402
+ * 发送前把这一批 push 落进 outbox(一次 batch)。(user_id, message_id)
3403
+ * 唯一:重试同一 occurrence 带着同一批 messageId 再来时更新 payload、不加
3404
+ * 第二行;客户端已经 ack 过的行不动(ack 是终态,重试不该把它拉回未读)。
3405
+ *
3406
+ * @param {string} userId
3407
+ * @param {Array<{ message_id: string, task_uuid?: string|null, session_id?: string|null,
3408
+ * message_index?: number|null, total_messages?: number|null, payload: string, created_at: number }>} rows
3409
+ * `payload` 是整条 push JSON 的 encryptForStorage 密文。
3410
+ * @returns {Promise<number>} 实际写入/更新的行数
3411
+ */
3412
+ async appendOutboxMessages(userId, rows) {
3413
+ if (!rows || rows.length === 0) return 0;
3414
+ const SQL = `INSERT INTO message_outbox
3415
+ (user_id, message_id, task_uuid, session_id, message_index, total_messages, payload, created_at)
3416
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
3417
+ ON CONFLICT (user_id, message_id) DO UPDATE SET
3418
+ payload = excluded.payload,
3419
+ created_at = excluded.created_at,
3420
+ delivered_at = NULL
3421
+ WHERE message_outbox.acked_at IS NULL`;
3422
+ const statements = rows.map(
3423
+ (r) => this._db.prepare(SQL).bind(
3424
+ userId,
3425
+ r.message_id,
3426
+ r.task_uuid ?? null,
3427
+ r.session_id ?? null,
3428
+ r.message_index ?? null,
3429
+ r.total_messages ?? null,
3430
+ r.payload,
3431
+ r.created_at
3432
+ )
3433
+ );
3434
+ let results;
3435
+ if (typeof this._db.batch === "function") {
3436
+ results = await this._db.batch(statements);
3437
+ } else {
3438
+ results = [];
3439
+ for (const stmt of statements) results.push(await stmt.run());
3440
+ }
3441
+ return results.reduce((n, res) => n + ((res.meta.changes || 0) > 0 ? 1 : 0), 0);
3442
+ }
3443
+ /**
3444
+ * 把这一批标记为「Web Push 已发出」。发送失败的段不标——delivered_at 为
3445
+ * null 的行正是客户端最需要拉的那部分。
3446
+ *
3447
+ * @param {string} userId
3448
+ * @param {string[]} messageIds
3449
+ * @param {number} deliveredAt - epoch 毫秒
3450
+ * @returns {Promise<number>}
3451
+ */
3452
+ async markOutboxDelivered(userId, messageIds, deliveredAt) {
3453
+ if (!messageIds || messageIds.length === 0) return 0;
3454
+ const placeholders = messageIds.map(() => "?").join(", ");
3455
+ const res = await this._db.prepare(
3456
+ `UPDATE message_outbox SET delivered_at = ?
3457
+ WHERE user_id = ? AND message_id IN (${placeholders})`
3458
+ ).bind(deliveredAt, userId, ...messageIds).run();
3459
+ return res.meta.changes || 0;
3460
+ }
3461
+ /**
3462
+ * 未 ack 的行(id 升序,游标翻页)。payload 仍是密文,解密在 handler。
3463
+ *
3464
+ * @param {string} userId
3465
+ * @param {number} sinceId - 上一页游标(0 = 从头)
3466
+ * @param {number} limit
3467
+ * @returns {Promise<Array<{ id: number, message_id: string, task_uuid: string|null,
3468
+ * session_id: string|null, message_index: number|null, total_messages: number|null,
3469
+ * payload: string, created_at: number, delivered_at: number|null }>>}
3470
+ */
3471
+ async listUnackedOutbox(userId, sinceId = 0, limit = 50) {
3472
+ const res = await this._db.prepare(
3473
+ `SELECT id, message_id, task_uuid, session_id, message_index, total_messages, payload, created_at, delivered_at
3474
+ FROM message_outbox
3475
+ WHERE user_id = ? AND acked_at IS NULL AND id > ?
3476
+ ORDER BY id ASC
3477
+ LIMIT ?`
3478
+ ).bind(userId, sinceId, limit).all();
3479
+ return res.results || [];
3480
+ }
3481
+ /**
3482
+ * 客户端确认收到这一批(幂等:已 ack 的行再 ack 不动)。
3483
+ *
3484
+ * @param {string} userId
3485
+ * @param {string[]} messageIds
3486
+ * @param {number} ackedAt - epoch 毫秒
3487
+ * @returns {Promise<number>} 本次真正被 ack 的行数
3488
+ */
3489
+ async ackOutboxMessages(userId, messageIds, ackedAt) {
3490
+ if (!messageIds || messageIds.length === 0) return 0;
3491
+ const placeholders = messageIds.map(() => "?").join(", ");
3492
+ const res = await this._db.prepare(
3493
+ `UPDATE message_outbox SET acked_at = ?
3494
+ WHERE user_id = ? AND acked_at IS NULL AND message_id IN (${placeholders})`
3495
+ ).bind(ackedAt, userId, ...messageIds).run();
3496
+ return res.meta.changes || 0;
3497
+ }
3498
+ /**
3499
+ * outbox 的例行清理(run-tick 每跳顺手调):已 ack 的行留短一些,未 ack 的
3500
+ * 也不无限留(Web Push TTL 上限四周,比它更老的推送谁也收不到了)。
3501
+ *
3502
+ * @param {{ ackedBeforeMs?: number, allBeforeMs?: number }} opts - epoch 毫秒阈值
3503
+ * @returns {Promise<number>} 删掉的行数
3504
+ */
3505
+ async cleanupOutbox({ ackedBeforeMs, allBeforeMs } = {}) {
3506
+ let deleted = 0;
3507
+ if (Number.isFinite(ackedBeforeMs)) {
3508
+ const res = await this._db.prepare(
3509
+ "DELETE FROM message_outbox WHERE acked_at IS NOT NULL AND acked_at < ?"
3510
+ ).bind(ackedBeforeMs).run();
3511
+ deleted += res.meta.changes || 0;
3512
+ }
3513
+ if (Number.isFinite(allBeforeMs)) {
3514
+ const res = await this._db.prepare(
3515
+ "DELETE FROM message_outbox WHERE created_at < ?"
3516
+ ).bind(allBeforeMs).run();
3517
+ deleted += res.meta.changes || 0;
3518
+ }
3519
+ return deleted;
3520
+ }
2865
3521
  };
2866
3522
  function createD1Adapter(db) {
2867
3523
  if (!db || typeof db.prepare !== "function") {
@@ -2981,12 +3637,6 @@ function err2(status, code, message, details) {
2981
3637
  const error = details === void 0 ? { code, message } : { code, message, details };
2982
3638
  return { status, body: { success: false, error } };
2983
3639
  }
2984
- function requireUserId2(headers) {
2985
- const userId = getHeader(headers, "x-user-id");
2986
- if (!userId) return { error: err2(400, "USER_ID_REQUIRED", "\u7F3A\u5C11\u7528\u6237\u6807\u8BC6\u7B26") };
2987
- if (!isValidUUIDv4(userId)) return { error: err2(400, "INVALID_USER_ID_FORMAT", "X-User-Id \u5FC5\u987B\u662F UUID v4 \u683C\u5F0F") };
2988
- return { userId };
2989
- }
2990
3640
  function rejectEntry(entry, index, code, message, extra) {
2991
3641
  const rejection = { index, code, message, ...extra || {} };
2992
3642
  if (entry && typeof entry === "object") {
@@ -3021,6 +3671,10 @@ function validateEntry(entry, index, maxValueBytes) {
3021
3671
  if (!Number.isInteger(entry.updatedAt) || entry.updatedAt <= 0) {
3022
3672
  return rejectEntry(entry, index, "INVALID_STATE_UPDATED_AT", `entries[${index}].updatedAt \u5FC5\u987B\u662F\u6B63\u6574\u6570\uFF08epoch \u6BEB\u79D2\uFF09`);
3023
3673
  }
3674
+ const guard = entry.version ?? entry.builtAt;
3675
+ if (guard !== void 0 && (!Number.isInteger(guard) || guard <= 0)) {
3676
+ return rejectEntry(entry, index, "INVALID_STATE_VERSION", `entries[${index}].version / builtAt \u5FC5\u987B\u662F\u6B63\u6574\u6570\uFF08\u6BEB\u79D2\u65F6\u95F4\u6233\u6216\u5355\u8C03\u9012\u589E\u7248\u672C\u53F7\uFF09`);
3677
+ }
3024
3678
  return null;
3025
3679
  }
3026
3680
  function createClientStateHandler(ctx) {
@@ -3031,7 +3685,7 @@ function createClientStateHandler(ctx) {
3031
3685
  if (getHeader(headers, "x-payload-encrypted") !== "true") {
3032
3686
  return err2(400, "ENCRYPTION_REQUIRED", "\u8BF7\u6C42\u4F53\u5FC5\u987B\u52A0\u5BC6");
3033
3687
  }
3034
- const gate = requireUserId2(headers);
3688
+ const gate = requireUserId(headers);
3035
3689
  if (gate.error) return gate.error;
3036
3690
  const { userId } = gate;
3037
3691
  if (getHeader(headers, "x-encryption-version") !== "1") {
@@ -3062,14 +3716,20 @@ function createClientStateHandler(ctx) {
3062
3716
  const rejected = [];
3063
3717
  for (let i = 0; i < entries.length; i++) {
3064
3718
  const rejection = validateEntry(entries[i], i, maxValueBytes);
3065
- if (rejection) rejected.push(rejection);
3066
- else accepted.push(entries[i]);
3719
+ if (rejection) {
3720
+ rejected.push(rejection);
3721
+ } else {
3722
+ const entry = entries[i];
3723
+ const guard = entry.version ?? entry.builtAt;
3724
+ accepted.push(guard !== void 0 ? { ...entry, version: guard } : entry);
3725
+ }
3067
3726
  }
3068
3727
  if (typeof db.upsertClientState !== "function") {
3069
3728
  return err2(501, "CLIENT_STATE_NOT_SUPPORTED", "\u5F53\u524D\u6570\u636E\u5E93\u9002\u914D\u5668\u4E0D\u652F\u6301 client_state");
3070
3729
  }
3071
- const { upserted, skipped } = await writeClientStateEntries({ db, userId, userKey, entries: accepted });
3730
+ const { upserted, skipped, skippedEntries } = await writeClientStateEntries({ db, userId, userKey, entries: accepted });
3072
3731
  const data = { upserted, skipped };
3732
+ if (skippedEntries.length > 0) data.skippedEntries = skippedEntries;
3073
3733
  if (rejected.length > 0) data.rejected = rejected;
3074
3734
  return { status: 200, body: { success: true, data } };
3075
3735
  }
@@ -3077,7 +3737,7 @@ function createClientStateHandler(ctx) {
3077
3737
  const tenantResult = await ctx.tenantManager.resolveTenant(headers);
3078
3738
  if (!tenantResult.ok) return tenantResult.error;
3079
3739
  const { db, masterKey } = tenantResult.context;
3080
- const gate = requireUserId2(headers);
3740
+ const gate = requireUserId(headers);
3081
3741
  if (gate.error) return gate.error;
3082
3742
  const { userId } = gate;
3083
3743
  const namespace = new URL(url, "https://dummy").searchParams.get("namespace") || "";
@@ -3102,7 +3762,7 @@ function createClientStateHandler(ctx) {
3102
3762
  const tenantResult = await ctx.tenantManager.resolveTenant(headers);
3103
3763
  if (!tenantResult.ok) return tenantResult.error;
3104
3764
  const { db } = tenantResult.context;
3105
- const gate = requireUserId2(headers);
3765
+ const gate = requireUserId(headers);
3106
3766
  if (gate.error) return gate.error;
3107
3767
  const { userId } = gate;
3108
3768
  if (typeof db.clearClientState !== "function") {
@@ -3115,7 +3775,7 @@ function createClientStateHandler(ctx) {
3115
3775
  }
3116
3776
 
3117
3777
  // src/server/lib/version.js
3118
- var SERVER_VERSION = true ? "2.6.0-next.13" : "0.0.0-dev";
3778
+ var SERVER_VERSION = true ? "2.6.0-next.16" : "0.0.0-dev";
3119
3779
 
3120
3780
  // src/server/handlers/capabilities.js
3121
3781
  var SERVER_FEATURES = Object.freeze([
@@ -3158,7 +3818,40 @@ var SERVER_FEATURES = Object.freeze([
3158
3818
  // runScheduledTick 认 serializeBy:同一分组的任务同时只跑一条,跨跳也算。
3159
3819
  "tick-serialize-by",
3160
3820
  // 一次 fire 无论什么结局都调 onFireSettled(发完 / 跳过 / 抛错)。
3161
- "fire-settled-hook"
3821
+ "fire-settled-hook",
3822
+ // PUT /client-state 的 entry 认 version / builtAt 护栏(按内容新旧比较),
3823
+ // 被拦下的 key 在 data.skippedEntries 逐条回报。
3824
+ "client-state-version-guard",
3825
+ // POST /schedule-message 认 immediate: true(不排未来,下一跳 cron 直接触发)。
3826
+ "schedule-immediate",
3827
+ // POST /schedule-message 认 supersedesUuid(建新任务的同一事务里取消旧的)。
3828
+ "schedule-supersede",
3829
+ // 投递期间租约按心跳滚动续租,isolate 死亡后任务 ~90s 内被下一跳接手。
3830
+ "tick-lease-heartbeat",
3831
+ // 导出 runTask(ctx, uuid):单任务投递入口(CF Queue 消费者用)。
3832
+ "run-task-entrypoint",
3833
+ // 任务行有 last_error 列(脱敏失败摘要);GET /message 对已失败的行也透出
3834
+ // (409 的 error.details.lastError)。
3835
+ "task-last-error",
3836
+ // 导出 NonRetryableError:hook 抛它 → 直接终审处置,不进重试阶梯。
3837
+ "non-retryable-error",
3838
+ // fire ctx(fireCtx / sessionCtx)带 cancelTask / renewTask。
3839
+ "agentic-cancel-renew-task",
3840
+ // 服务端消息收件箱:push 发送前落 message_outbox,GET /outbox + POST
3841
+ // /outbox/ack 取代「猜哪些没收到」的补收对账。
3842
+ "message-outbox",
3843
+ // onFireSettled 载荷带解密 metadata 与最后一轮 usage;sessionCtx 带 usage。
3844
+ "fire-settled-metadata",
3845
+ "hook-usage",
3846
+ // schedule-message 认 llmExtraBody(原样展开进 LLM 请求体,核心字段优先)。
3847
+ "llm-extra-body",
3848
+ // 500 响应体的 error.cause 带真实原因(stage / name / message),调用方不用
3849
+ // 再去日志里捞「到底哪儿坏了」。
3850
+ "error-cause",
3851
+ // 导出 getSchemaVersion / ensureSchema:表结构够不够用查得出来,也补得上。
3852
+ "schema-self-check",
3853
+ // 单用户 Worker 上有 runTask(uuid, env):只跑指定那一条任务。
3854
+ "worker-run-task"
3162
3855
  ]);
3163
3856
  function createCapabilitiesHandler(ctx) {
3164
3857
  async function GET(url, headers) {
@@ -3175,6 +3868,116 @@ function createCapabilitiesHandler(ctx) {
3175
3868
  return { GET };
3176
3869
  }
3177
3870
 
3871
+ // src/server/handlers/outbox.js
3872
+ var MAX_OUTBOX_PAGE_SIZE = 100;
3873
+ var DEFAULT_OUTBOX_PAGE_SIZE = 50;
3874
+ var MAX_OUTBOX_ACK_IDS = 200;
3875
+ function err3(status, code, message, details) {
3876
+ const error = details === void 0 ? { code, message } : { code, message, details };
3877
+ return { status, body: { success: false, error } };
3878
+ }
3879
+ function createOutboxHandler(ctx) {
3880
+ async function GET(url, headers) {
3881
+ const tenantResult = await ctx.tenantManager.resolveTenant(headers);
3882
+ if (!tenantResult.ok) return tenantResult.error;
3883
+ const { db, masterKey } = tenantResult.context;
3884
+ const gate = requireUserId(headers);
3885
+ if (gate.error) return gate.error;
3886
+ const { userId } = gate;
3887
+ if (typeof db.listUnackedOutbox !== "function") {
3888
+ return err3(501, "OUTBOX_NOT_SUPPORTED", "\u5F53\u524D\u6570\u636E\u5E93\u9002\u914D\u5668\u4E0D\u652F\u6301 message_outbox");
3889
+ }
3890
+ const params = new URL(url, "https://dummy").searchParams;
3891
+ const sinceRaw = params.get("since");
3892
+ const since = sinceRaw == null || sinceRaw === "" ? 0 : Number(sinceRaw);
3893
+ if (!Number.isInteger(since) || since < 0) {
3894
+ return err3(400, "INVALID_OUTBOX_CURSOR", "since \u5FC5\u987B\u662F\u975E\u8D1F\u6574\u6570\uFF08\u4E0A\u4E00\u9875\u54CD\u5E94\u91CC\u7684 cursor\uFF09");
3895
+ }
3896
+ const limitRaw = params.get("limit");
3897
+ let limit = limitRaw == null || limitRaw === "" ? DEFAULT_OUTBOX_PAGE_SIZE : Number(limitRaw);
3898
+ if (!Number.isInteger(limit) || limit <= 0) {
3899
+ return err3(400, "INVALID_OUTBOX_LIMIT", `limit \u5FC5\u987B\u662F 1-${MAX_OUTBOX_PAGE_SIZE} \u7684\u6574\u6570`);
3900
+ }
3901
+ limit = Math.min(limit, MAX_OUTBOX_PAGE_SIZE);
3902
+ const userKey = await deriveUserEncryptionKey(userId, masterKey);
3903
+ const rows = await db.listUnackedOutbox(userId, since, limit);
3904
+ const entries = [];
3905
+ let cursor = since;
3906
+ for (const row of rows) {
3907
+ cursor = Math.max(cursor, row.id);
3908
+ let push;
3909
+ try {
3910
+ push = JSON.parse(await decryptFromStorage(row.payload, userKey));
3911
+ } catch (error) {
3912
+ console.warn("[amsg-server] outbox \u884C\u89E3\u5BC6\u5931\u8D25\uFF08\u5DF2\u8DF3\u8FC7\uFF09:", row.id, error && error.message);
3913
+ continue;
3914
+ }
3915
+ entries.push({
3916
+ id: row.id,
3917
+ messageId: row.message_id,
3918
+ taskUuid: row.task_uuid ?? null,
3919
+ sessionId: row.session_id ?? null,
3920
+ messageIndex: row.message_index ?? null,
3921
+ totalMessages: row.total_messages ?? null,
3922
+ createdAt: row.created_at,
3923
+ deliveredAt: row.delivered_at ?? null,
3924
+ push
3925
+ });
3926
+ }
3927
+ const data = {
3928
+ entries,
3929
+ cursor,
3930
+ // 按「捞满一页」判断还有没有下一页;解密失败被跳过的行不影响游标推进。
3931
+ hasMore: rows.length === limit
3932
+ };
3933
+ const encryptedResponse = await encryptPayload(data, userKey);
3934
+ return { status: 200, body: { success: true, encrypted: true, version: 1, data: encryptedResponse } };
3935
+ }
3936
+ async function POST(headers, body) {
3937
+ const tenantResult = await ctx.tenantManager.resolveTenant(headers);
3938
+ if (!tenantResult.ok) return tenantResult.error;
3939
+ const { db, masterKey } = tenantResult.context;
3940
+ if (getHeader(headers, "x-payload-encrypted") !== "true") {
3941
+ return err3(400, "ENCRYPTION_REQUIRED", "\u8BF7\u6C42\u4F53\u5FC5\u987B\u52A0\u5BC6");
3942
+ }
3943
+ const gate = requireUserId(headers);
3944
+ if (gate.error) return gate.error;
3945
+ const { userId } = gate;
3946
+ if (getHeader(headers, "x-encryption-version") !== "1") {
3947
+ return err3(400, "UNSUPPORTED_ENCRYPTION_VERSION", "\u52A0\u5BC6\u7248\u672C\u4E0D\u652F\u6301");
3948
+ }
3949
+ const parsedBody = parseEncryptedBody(body);
3950
+ if (!parsedBody.ok) return { status: 400, body: { success: false, error: parsedBody.error } };
3951
+ const userKey = await deriveUserEncryptionKey(userId, masterKey);
3952
+ let payload;
3953
+ try {
3954
+ payload = await decryptPayload(parsedBody.data, userKey);
3955
+ } catch (error) {
3956
+ if (error instanceof SyntaxError) {
3957
+ return err3(400, "INVALID_PAYLOAD_FORMAT", "\u89E3\u5BC6\u540E\u7684\u6570\u636E\u4E0D\u662F\u6709\u6548 JSON");
3958
+ }
3959
+ return err3(400, "DECRYPTION_FAILED", "\u8BF7\u6C42\u4F53\u89E3\u5BC6\u5931\u8D25");
3960
+ }
3961
+ if (!isPlainObject(payload)) return err3(400, "INVALID_PAYLOAD_FORMAT", "\u89E3\u5BC6\u540E\u7684\u6570\u636E\u5FC5\u987B\u662F JSON \u5BF9\u8C61");
3962
+ const messageIds = payload.messageIds;
3963
+ if (!Array.isArray(messageIds) || messageIds.length === 0) {
3964
+ return err3(400, "INVALID_OUTBOX_ACK", "messageIds \u5FC5\u987B\u662F\u975E\u7A7A\u6570\u7EC4");
3965
+ }
3966
+ if (messageIds.length > MAX_OUTBOX_ACK_IDS) {
3967
+ return err3(400, "TOO_MANY_OUTBOX_ACK_IDS", `\u5355\u6B21\u6700\u591A ack ${MAX_OUTBOX_ACK_IDS} \u6761`, { count: messageIds.length });
3968
+ }
3969
+ if (!messageIds.every((id) => typeof id === "string" && id.trim())) {
3970
+ return err3(400, "INVALID_OUTBOX_ACK", "messageIds \u7684\u6BCF\u4E00\u9879\u5FC5\u987B\u662F\u975E\u7A7A\u5B57\u7B26\u4E32");
3971
+ }
3972
+ if (typeof db.ackOutboxMessages !== "function") {
3973
+ return err3(501, "OUTBOX_NOT_SUPPORTED", "\u5F53\u524D\u6570\u636E\u5E93\u9002\u914D\u5668\u4E0D\u652F\u6301 message_outbox");
3974
+ }
3975
+ const acked = await db.ackOutboxMessages(userId, messageIds, Date.now());
3976
+ return { status: 200, body: { success: true, data: { acked } } };
3977
+ }
3978
+ return { GET, POST };
3979
+ }
3980
+
3178
3981
  // src/server/single-user.js
3179
3982
  function createSingleUserServer(config) {
3180
3983
  if (!config || !config.db) throw new Error("[amsg-server single-user] config.db is required");
@@ -3221,65 +4024,50 @@ function createSingleUserServer(config) {
3221
4024
  vapidPublicKey: createVapidPublicKeyHandler(ctx),
3222
4025
  clientState: createClientStateHandler(ctx),
3223
4026
  pushSubscription: createPushSubscriptionHandler(ctx),
3224
- capabilities: createCapabilitiesHandler(ctx)
4027
+ capabilities: createCapabilitiesHandler(ctx),
4028
+ outbox: createOutboxHandler(ctx)
3225
4029
  }
3226
4030
  };
3227
4031
  }
3228
4032
 
3229
- // src/server/lib/webpush-webcrypto.js
3230
- import { sendWebPush as sharedSendWebPush } from "@rei-standard/amsg-shared";
3231
- import { buildVapidJwt, verifyVapidJwt, hmacSha256 as hmacSha2562 } from "@rei-standard/amsg-shared";
3232
- var WEB_PUSH_MAX_BODY_BYTES = 4096;
3233
- var WEB_PUSH_ENCRYPTION_OVERHEAD_BYTES = 16 + 4 + 1 + 65 + 1 + 16;
3234
- var MAX_PUSH_PAYLOAD_BYTES = WEB_PUSH_MAX_BODY_BYTES - WEB_PUSH_ENCRYPTION_OVERHEAD_BYTES;
3235
- var PUSH_ENVELOPE_RESERVED_BYTES = 384;
3236
- var payloadEncoder = new TextEncoder();
3237
- function measurePushPayload(payload, options) {
3238
- const reserveEnvelope = !!(options && options.reserveEnvelope);
3239
- const bytes = payloadEncoder.encode(typeof payload === "string" ? payload : String(payload)).length;
3240
- const envelopeReservedBytes = reserveEnvelope ? PUSH_ENVELOPE_RESERVED_BYTES : 0;
3241
- const maxBytes = MAX_PUSH_PAYLOAD_BYTES - envelopeReservedBytes;
3242
- return {
3243
- bytes,
3244
- maxBytes,
3245
- remainingBytes: maxBytes - bytes,
3246
- withinLimit: bytes <= maxBytes,
3247
- envelopeReservedBytes
3248
- };
4033
+ // src/server/lib/schema-version.js
4034
+ var SCHEMA_VERSION = "2.6.0";
4035
+ function requireIntrospection(db) {
4036
+ if (!db || typeof db.describeSchema !== "function") {
4037
+ throw new Error(
4038
+ "[amsg-server] \u8FD9\u4E2A\u6570\u636E\u5E93\u9002\u914D\u5668\u4E0D\u652F\u6301 schema \u81EA\u67E5\uFF08\u6CA1\u5B9E\u73B0 describeSchema\uFF09\u3002\u5185\u7F6E\u9002\u914D\u5668\u91CC\u76EE\u524D\u53EA\u6709 D1 \u5B9E\u73B0\u4E86\u5B83\u3002"
4039
+ );
4040
+ }
3249
4041
  }
3250
- async function sendWebPush(args) {
3251
- const { payload } = args || {};
3252
- if (typeof payload === "string") {
3253
- const size = measurePushPayload(payload);
3254
- if (!size.withinLimit) {
3255
- const err3 = new Error(
3256
- `sendWebPush: payload is ${size.bytes} bytes, over the ${MAX_PUSH_PAYLOAD_BYTES}-byte limit (push services cap the encrypted body at ${WEB_PUSH_MAX_BODY_BYTES} bytes; aes128gcm adds ${WEB_PUSH_ENCRYPTION_OVERHEAD_BYTES} bytes)`
3257
- );
3258
- err3.code = "PUSH_PAYLOAD_TOO_LARGE";
3259
- err3.bytes = size.bytes;
3260
- err3.maxBytes = MAX_PUSH_PAYLOAD_BYTES;
3261
- throw err3;
4042
+ async function getSchemaVersion(db) {
4043
+ requireIntrospection(db);
4044
+ const live = await db.describeSchema();
4045
+ const liveTables = live && live.tables || {};
4046
+ const liveIndexes = new Set(live && live.indexes || []);
4047
+ const missing = [];
4048
+ for (const [table, columns] of Object.entries(SQLITE_REQUIRED_SCHEMA.tables)) {
4049
+ const liveColumns = liveTables[table];
4050
+ if (!liveColumns) {
4051
+ missing.push(`table:${table}`);
4052
+ continue;
4053
+ }
4054
+ const present = new Set(liveColumns);
4055
+ for (const column of columns) {
4056
+ if (!present.has(column)) missing.push(`column:${table}.${column}`);
3262
4057
  }
3263
4058
  }
3264
- return sharedSendWebPush(args);
4059
+ for (const index of SQLITE_REQUIRED_SCHEMA.indexes) {
4060
+ if (!liveIndexes.has(index)) missing.push(`index:${index}`);
4061
+ }
4062
+ const ok = missing.length === 0;
4063
+ return { current: ok ? SCHEMA_VERSION : null, required: SCHEMA_VERSION, ok, missing };
3265
4064
  }
3266
- var SCHEDULED_DEFAULT_TTL = 2419200;
3267
- function createWebCryptoWebPush(vapid = {}, { ttl = SCHEDULED_DEFAULT_TTL } = {}) {
3268
- return {
3269
- async sendNotification(subscription, payload) {
3270
- return sendWebPush({
3271
- subscription,
3272
- payload,
3273
- ttl,
3274
- vapid: {
3275
- email: vapid.email,
3276
- publicKey: vapid.publicKey,
3277
- privateKey: vapid.privateKey
3278
- },
3279
- fetch: globalThis.fetch
3280
- });
3281
- }
3282
- };
4065
+ async function ensureSchema(db) {
4066
+ const before = await getSchemaVersion(db);
4067
+ if (before.ok) return { ...before, migrated: false, schema: null };
4068
+ const schema = await db.initSchema();
4069
+ const after = await getSchemaVersion(db);
4070
+ return { ...after, migrated: true, schema };
3283
4071
  }
3284
4072
 
3285
4073
  // src/server/cloudflare/single-user-worker.js
@@ -3298,7 +4086,17 @@ var CORS_ALLOW_HEADERS = "Content-Type, X-User-Id, X-Payload-Encrypted, X-Encryp
3298
4086
  var CORS_ALLOW_METHODS = "GET, POST, PUT, DELETE, OPTIONS";
3299
4087
  function corsHeadersFor(cors, requestOrigin) {
3300
4088
  if (!cors || cors.origin == null) return null;
3301
- const allowOrigin = typeof cors.origin === "function" ? cors.origin(requestOrigin) || null : cors.origin;
4089
+ let allowOrigin;
4090
+ if (typeof cors.origin === "function") {
4091
+ try {
4092
+ allowOrigin = cors.origin(requestOrigin) || null;
4093
+ } catch (error) {
4094
+ console.warn("[amsg single-user] cors.origin \u56DE\u8C03\u629B\u9519\uFF0C\u6309\u4E0D\u653E\u884C\u5904\u7406:", error && error.message);
4095
+ allowOrigin = null;
4096
+ }
4097
+ } else {
4098
+ allowOrigin = cors.origin;
4099
+ }
3302
4100
  if (!allowOrigin) return null;
3303
4101
  const headers = {
3304
4102
  "Access-Control-Allow-Origin": allowOrigin,
@@ -3312,15 +4110,82 @@ function corsHeadersFor(cors, requestOrigin) {
3312
4110
  function degradedCorsHeaders(requestOrigin) {
3313
4111
  return corsHeadersFor({ origin: requestOrigin, maxAge: 0 }, requestOrigin);
3314
4112
  }
3315
- function internalErrorResponse(cors) {
3316
- return jsonResponse(500, { success: false, error: { code: "INTERNAL_ERROR", message: "\u670D\u52A1\u5668\u5185\u90E8\u9519\u8BEF" } }, cors);
4113
+ function internalErrorResponse(cors, cause) {
4114
+ return jsonResponse(500, {
4115
+ success: false,
4116
+ error: {
4117
+ code: "INTERNAL_ERROR",
4118
+ message: "\u670D\u52A1\u5668\u5185\u90E8\u9519\u8BEF",
4119
+ ...cause ? { cause } : {}
4120
+ }
4121
+ }, cors);
3317
4122
  }
3318
- function createSingleUserCloudflareWorker(buildConfig) {
4123
+ function pathOf(request) {
4124
+ try {
4125
+ return new URL(request.url).pathname;
4126
+ } catch (_error) {
4127
+ return null;
4128
+ }
4129
+ }
4130
+ function createSingleUserCloudflareWorker(buildConfig, options = {}) {
4131
+ const onError = typeof options.onError === "function" ? options.onError : null;
3319
4132
  async function resolveConfig(env) {
3320
4133
  const cfg = await buildConfig(env);
3321
4134
  if (!cfg.db) cfg.db = createD1Adapter(env.DB);
3322
4135
  return cfg;
3323
4136
  }
4137
+ async function reportError(info) {
4138
+ if (!onError) return;
4139
+ try {
4140
+ await onError(info);
4141
+ } catch (hookError) {
4142
+ console.warn("[amsg single-user] onError hook \u629B\u9519\uFF08\u5DF2\u5FFD\u7565\uFF09:", hookError && hookError.message);
4143
+ }
4144
+ }
4145
+ function buildTickContext(cfg) {
4146
+ return {
4147
+ db: cfg.db,
4148
+ masterKey: cfg.masterKey,
4149
+ vapid: cfg.vapid || {},
4150
+ webpush: cfg.webpush,
4151
+ // Fire-time hooks (optional; see lib/agentic-fire.js). runScheduledTick
4152
+ // spreads its ctx into processSingleMessage, so these ride along.
4153
+ hooks: cfg.hooks || null,
4154
+ maxToolIterations: cfg.maxToolIterations,
4155
+ totalTimeoutMs: cfg.totalTimeoutMs,
4156
+ // hook 的 ctx.writeState() 用它判断单条 value 的上限,和 PUT /client-state 同一个配置。
4157
+ maxStateValueBytes: cfg.maxStateValueBytes,
4158
+ // hook 的 ctx.scheduleTask() 单次 fire 建任务的条数上限(默认 2)。
4159
+ maxScheduledTasksPerFire: cfg.maxScheduledTasksPerFire,
4160
+ // 任务错过触发时刻太久(> 60 分钟)不再补发时的回执 hook:
4161
+ // (task, { reason, action, metadata, skippedCount, …, readState,
4162
+ // writeState })。一次性任务(action 'expired')和循环任务快进
4163
+ //(action 'fast_forwarded')都会调(凭据字段不透传;best-effort,
4164
+ // 见 lib/run-tick.js)。
4165
+ onStaleSkip: cfg.onStaleSkip,
4166
+ // 推送发出(或发挂)之后的 hook:{ task, sentCount, total, error,
4167
+ // scratch, readState, writeState }(best-effort,见 lib/agentic-fire.js)。
4168
+ onAfterSend: cfg.onAfterSend,
4169
+ // 一次 fire 收尾的 hook:{ task, status, skipReason, sentCount, total,
4170
+ // iterations, error, scratch, readState, writeState }。发完 / 跳过 /
4171
+ // 抛错都会调,宿主用它做「开始时占点什么、结束时放掉」那类收尾
4172
+ //(best-effort,见 lib/agentic-fire.js)。
4173
+ onFireSettled: cfg.onFireSettled,
4174
+ // 分组串行:(task) => 分组标识 | null。同一分组的任务同时只跑一条,
4175
+ // 跨跳也算(见 lib/run-tick.js)。不配 = 全并发,与以前一致。
4176
+ serializeBy: cfg.serializeBy,
4177
+ // 任务占位租期(只在心跳关掉时生效;默认 10 分钟,随 totalTimeoutMs 抬高)。
4178
+ claimLeaseMs: cfg.claimLeaseMs,
4179
+ // 租约心跳间隔(默认 30s;0 = 关掉心跳,退回一次性长租约)。
4180
+ leaseHeartbeatMs: cfg.leaseHeartbeatMs,
4181
+ // 补发新鲜度阈值(默认 60 分钟;见 lib/run-tick.js 的 STALE_AFTER_MS)。
4182
+ staleAfterMs: cfg.staleAfterMs
4183
+ };
4184
+ }
4185
+ function pushConfigured(cfg) {
4186
+ const vapid = cfg.vapid || {};
4187
+ return !!(cfg.webpush && vapid.email && vapid.publicKey && vapid.privateKey);
4188
+ }
3324
4189
  async function fetch(request, env) {
3325
4190
  const requestOrigin = request.headers.get("origin") || "";
3326
4191
  const method = request.method.toUpperCase();
@@ -3329,9 +4194,11 @@ function createSingleUserCloudflareWorker(buildConfig) {
3329
4194
  cfg = await resolveConfig(env);
3330
4195
  } catch (error) {
3331
4196
  console.error("[amsg single-user] fetch() config build failed:", error && error.message);
4197
+ const cause = summarizeErrorCause(error, "config");
4198
+ await reportError({ stage: "config", error, cause, path: pathOf(request) });
3332
4199
  const degraded = degradedCorsHeaders(requestOrigin);
3333
4200
  if (method === "OPTIONS" && degraded) return new Response(null, { status: 204, headers: degraded });
3334
- return internalErrorResponse(degraded);
4201
+ return internalErrorResponse(degraded, cause);
3335
4202
  }
3336
4203
  const cors = corsHeadersFor(cfg.cors, requestOrigin);
3337
4204
  try {
@@ -3367,6 +4234,10 @@ function createSingleUserCloudflareWorker(buildConfig) {
3367
4234
  result = await server.handlers.clientState.GET(url, headers);
3368
4235
  } else if (method === "DELETE" && pathname.endsWith("/client-state")) {
3369
4236
  result = await server.handlers.clientState.DELETE(url, headers);
4237
+ } else if (method === "GET" && pathname.endsWith("/outbox")) {
4238
+ result = await server.handlers.outbox.GET(url, headers);
4239
+ } else if (method === "POST" && pathname.endsWith("/outbox/ack")) {
4240
+ result = await server.handlers.outbox.POST(headers, await request.text());
3370
4241
  } else if (method === "PUT" && pathname.endsWith("/push-subscription")) {
3371
4242
  result = await server.handlers.pushSubscription.PUT(headers, await request.text());
3372
4243
  } else if (method === "GET" && pathname.endsWith("/push-subscription")) {
@@ -3379,56 +4250,110 @@ function createSingleUserCloudflareWorker(buildConfig) {
3379
4250
  return jsonResponse(result.status, result.body, cors);
3380
4251
  } catch (error) {
3381
4252
  console.error("[amsg single-user] fetch() unhandled error:", error && error.message);
3382
- return internalErrorResponse(cors);
4253
+ const cause = summarizeErrorCause(error, "request");
4254
+ await reportError({ stage: "request", error, cause, path: pathOf(request) });
4255
+ return internalErrorResponse(cors, cause);
3383
4256
  }
3384
4257
  }
3385
4258
  async function scheduled(event, env) {
3386
- const cfg = await resolveConfig(env);
3387
- const vapid = cfg.vapid || {};
3388
- if (!cfg.webpush || !vapid.email || !vapid.publicKey || !vapid.privateKey) {
4259
+ let cfg;
4260
+ try {
4261
+ cfg = await resolveConfig(env);
4262
+ } catch (error) {
4263
+ console.error("[amsg single-user] scheduled(): config build failed; skipping tick:", error && error.message);
4264
+ const cause = summarizeErrorCause(error, "config");
4265
+ await reportError({ stage: "config", error, cause, path: null });
4266
+ return { ok: false, cause };
4267
+ }
4268
+ if (!pushConfigured(cfg)) {
3389
4269
  console.error("[amsg single-user] scheduled(): VAPID/webpush not configured; skipping tick");
3390
- return;
4270
+ const cause = summarizeErrorCause(
4271
+ { name: "VapidNotConfigured", message: "VAPID / webpush \u672A\u914D\u7F6E\uFF0C\u672C\u8DF3\u8DF3\u8FC7" },
4272
+ "config"
4273
+ );
4274
+ await reportError({ stage: "config", error: null, cause, path: null });
4275
+ return { ok: false, cause };
3391
4276
  }
3392
4277
  try {
3393
- await runScheduledTick({
3394
- db: cfg.db,
3395
- masterKey: cfg.masterKey,
3396
- vapid,
3397
- webpush: cfg.webpush,
3398
- // Fire-time hooks (optional; see lib/agentic-fire.js). runScheduledTick
3399
- // spreads its ctx into processSingleMessage, so these ride along.
3400
- hooks: cfg.hooks || null,
3401
- maxToolIterations: cfg.maxToolIterations,
3402
- totalTimeoutMs: cfg.totalTimeoutMs,
3403
- // hook 的 ctx.writeState() 用它判断单条 value 的上限,和 PUT /client-state 同一个配置。
3404
- maxStateValueBytes: cfg.maxStateValueBytes,
3405
- // hook 的 ctx.scheduleTask() 单次 fire 建任务的条数上限(默认 2)。
3406
- maxScheduledTasksPerFire: cfg.maxScheduledTasksPerFire,
3407
- // 任务错过触发时刻太久(> 60 分钟)不再补发时的回执 hook:
3408
- // (task, { reason, action, metadata, skippedCount, …, readState,
3409
- // writeState })。一次性任务(action 'expired')和循环任务快进
3410
- //(action 'fast_forwarded')都会调(凭据字段不透传;best-effort,
3411
- // 见 lib/run-tick.js)。
3412
- onStaleSkip: cfg.onStaleSkip,
3413
- // 推送发出(或发挂)之后的 hook:{ task, sentCount, total, error,
3414
- // scratch, readState, writeState }(best-effort,见 lib/agentic-fire.js)。
3415
- onAfterSend: cfg.onAfterSend,
3416
- // 一次 fire 收尾的 hook:{ task, status, skipReason, sentCount, total,
3417
- // iterations, error, scratch, readState, writeState }。发完 / 跳过 /
3418
- // 抛错都会调,宿主用它做「开始时占点什么、结束时放掉」那类收尾
3419
- //(best-effort,见 lib/agentic-fire.js)。
3420
- onFireSettled: cfg.onFireSettled,
3421
- // 分组串行:(task) => 分组标识 | null。同一分组的任务同时只跑一条,
3422
- // 跨跳也算(见 lib/run-tick.js)。不配 = 全并发,与以前一致。
3423
- serializeBy: cfg.serializeBy,
3424
- // 任务占位租期(默认 10 分钟,随 totalTimeoutMs 抬高)。
3425
- claimLeaseMs: cfg.claimLeaseMs
3426
- });
4278
+ const summary = await runScheduledTick(buildTickContext(cfg));
4279
+ return { ok: true, summary };
3427
4280
  } catch (error) {
3428
4281
  console.error("[amsg single-user] scheduled(): tick failed:", error && error.message);
4282
+ const cause = summarizeErrorCause(error, "tick");
4283
+ await reportError({ stage: "tick", error, cause, path: null });
4284
+ return { ok: false, cause };
3429
4285
  }
3430
4286
  }
3431
- return { fetch, scheduled };
4287
+ async function runTask2(uuid, env) {
4288
+ const cfg = await resolveConfig(env);
4289
+ if (!pushConfigured(cfg)) return { ran: false, reason: "not_configured" };
4290
+ return runTask(buildTickContext(cfg), uuid);
4291
+ }
4292
+ async function getSchemaVersion2(env) {
4293
+ const cfg = await resolveConfig(env);
4294
+ return getSchemaVersion(cfg.db);
4295
+ }
4296
+ async function ensureSchema2(env) {
4297
+ const cfg = await resolveConfig(env);
4298
+ return ensureSchema(cfg.db);
4299
+ }
4300
+ return { fetch, scheduled, runTask: runTask2, getSchemaVersion: getSchemaVersion2, ensureSchema: ensureSchema2 };
4301
+ }
4302
+
4303
+ // src/server/lib/webpush-webcrypto.js
4304
+ import { sendWebPush as sharedSendWebPush } from "@rei-standard/amsg-shared";
4305
+ import { buildVapidJwt, verifyVapidJwt, hmacSha256 as hmacSha2562 } from "@rei-standard/amsg-shared";
4306
+ var WEB_PUSH_MAX_BODY_BYTES = 4096;
4307
+ var WEB_PUSH_ENCRYPTION_OVERHEAD_BYTES = 16 + 4 + 1 + 65 + 1 + 16;
4308
+ var MAX_PUSH_PAYLOAD_BYTES = WEB_PUSH_MAX_BODY_BYTES - WEB_PUSH_ENCRYPTION_OVERHEAD_BYTES;
4309
+ var PUSH_ENVELOPE_RESERVED_BYTES = 384;
4310
+ var payloadEncoder = new TextEncoder();
4311
+ function measurePushPayload(payload, options) {
4312
+ const reserveEnvelope = !!(options && options.reserveEnvelope);
4313
+ const bytes = payloadEncoder.encode(typeof payload === "string" ? payload : String(payload)).length;
4314
+ const envelopeReservedBytes = reserveEnvelope ? PUSH_ENVELOPE_RESERVED_BYTES : 0;
4315
+ const maxBytes = MAX_PUSH_PAYLOAD_BYTES - envelopeReservedBytes;
4316
+ return {
4317
+ bytes,
4318
+ maxBytes,
4319
+ remainingBytes: maxBytes - bytes,
4320
+ withinLimit: bytes <= maxBytes,
4321
+ envelopeReservedBytes
4322
+ };
4323
+ }
4324
+ async function sendWebPush(args) {
4325
+ const { payload } = args || {};
4326
+ if (typeof payload === "string") {
4327
+ const size = measurePushPayload(payload);
4328
+ if (!size.withinLimit) {
4329
+ const err4 = new Error(
4330
+ `sendWebPush: payload is ${size.bytes} bytes, over the ${MAX_PUSH_PAYLOAD_BYTES}-byte limit (push services cap the encrypted body at ${WEB_PUSH_MAX_BODY_BYTES} bytes; aes128gcm adds ${WEB_PUSH_ENCRYPTION_OVERHEAD_BYTES} bytes)`
4331
+ );
4332
+ err4.code = "PUSH_PAYLOAD_TOO_LARGE";
4333
+ err4.bytes = size.bytes;
4334
+ err4.maxBytes = MAX_PUSH_PAYLOAD_BYTES;
4335
+ throw err4;
4336
+ }
4337
+ }
4338
+ return sharedSendWebPush(args);
4339
+ }
4340
+ var SCHEDULED_DEFAULT_TTL = 2419200;
4341
+ function createWebCryptoWebPush(vapid = {}, { ttl = SCHEDULED_DEFAULT_TTL } = {}) {
4342
+ return {
4343
+ async sendNotification(subscription, payload) {
4344
+ return sendWebPush({
4345
+ subscription,
4346
+ payload,
4347
+ ttl,
4348
+ vapid: {
4349
+ email: vapid.email,
4350
+ publicKey: vapid.publicKey,
4351
+ privateKey: vapid.privateKey
4352
+ },
4353
+ fetch: globalThis.fetch
4354
+ });
4355
+ }
4356
+ };
3432
4357
  }
3433
4358
 
3434
4359
  export {
@@ -3436,9 +4361,9 @@ export {
3436
4361
  advanceOccurrence,
3437
4362
  planNextOccurrence,
3438
4363
  nextFutureOccurrence,
4364
+ isValidUrl,
3439
4365
  validateAvatarUrl,
3440
4366
  isValidISO8601,
3441
- isValidUrl,
3442
4367
  isValidUUID,
3443
4368
  isValidUUIDv4,
3444
4369
  validateSplitPattern,
@@ -3456,8 +4381,20 @@ export {
3456
4381
  encryptForStorage,
3457
4382
  decryptFromStorage,
3458
4383
  createGetUserKeyHandler,
4384
+ DEFAULT_MAX_TOOL_ITERATIONS,
4385
+ DEFAULT_TOTAL_TIMEOUT_MS,
4386
+ DEFAULT_MAX_SCHEDULED_TASKS_PER_FIRE,
4387
+ MIN_SCHEDULE_LEAD_MS,
4388
+ NonRetryableError,
4389
+ isNonRetryableError,
4390
+ sanitizeErrorSummary,
4391
+ summarizeErrorCause,
3459
4392
  createScheduleMessageHandler,
4393
+ DEFAULT_CLAIM_LEASE_MS,
4394
+ DEFAULT_LEASE_HEARTBEAT_MS,
4395
+ DEFAULT_HEARTBEAT_LEASE_TTL_MS,
3460
4396
  runScheduledTick,
4397
+ runTask,
3461
4398
  createUpdateMessageHandler,
3462
4399
  createCancelMessageHandler,
3463
4400
  createMessagesHandler,
@@ -3465,11 +4402,16 @@ export {
3465
4402
  createPushSubscriptionHandler,
3466
4403
  createD1Adapter,
3467
4404
  createSingleUserServer,
4405
+ SCHEMA_VERSION,
4406
+ getSchemaVersion,
4407
+ ensureSchema,
4408
+ CORS_ALLOW_HEADERS,
4409
+ CORS_ALLOW_METHODS,
4410
+ createSingleUserCloudflareWorker,
3468
4411
  WEB_PUSH_MAX_BODY_BYTES,
3469
4412
  WEB_PUSH_ENCRYPTION_OVERHEAD_BYTES,
3470
4413
  MAX_PUSH_PAYLOAD_BYTES,
3471
4414
  PUSH_ENVELOPE_RESERVED_BYTES,
3472
4415
  measurePushPayload,
3473
- createWebCryptoWebPush,
3474
- createSingleUserCloudflareWorker
4416
+ createWebCryptoWebPush
3475
4417
  };