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

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 (35) hide show
  1. package/dist/adapters/d1.d.ts +106 -0
  2. package/dist/adapters/interface.d.ts +50 -0
  3. package/dist/adapters/neon.d.ts +10 -58
  4. package/dist/adapters/pg-shared.d.ts +114 -0
  5. package/dist/adapters/pg.d.ts +10 -58
  6. package/dist/adapters/schema.d.ts +1 -1
  7. package/dist/adapters/schema.sqlite.d.ts +3 -1
  8. package/dist/{chunk-B2FDV7YK.mjs → chunk-34I2YSWE.mjs} +10 -0
  9. package/dist/{chunk-TV7N3RTH.mjs → chunk-D3DQF647.mjs} +1086 -348
  10. package/dist/{chunk-V2SDWGUB.cjs → chunk-OUPM4HAV.cjs} +10 -0
  11. package/dist/{chunk-5IOYCCFD.cjs → chunk-TCKPLM3P.cjs} +1086 -348
  12. package/dist/chunk-TFFBMYA2.cjs +95 -0
  13. package/dist/chunk-UYROU4L3.mjs +95 -0
  14. package/dist/cloudflare/single-user-worker.d.ts +2 -0
  15. package/dist/cloudflare.cjs +3 -2
  16. package/dist/cloudflare.mjs +2 -1
  17. package/dist/handlers/outbox.d.ts +7 -0
  18. package/dist/index.cjs +64 -36
  19. package/dist/index.d.cts +4 -2
  20. package/dist/index.d.ts +4 -2
  21. package/dist/index.mjs +45 -17
  22. package/dist/lib/client-state-store.d.ts +18 -3
  23. package/dist/lib/errors.d.ts +50 -0
  24. package/dist/lib/message-processor.d.ts +8 -2
  25. package/dist/lib/outbox-store.d.ts +33 -0
  26. package/dist/lib/push-subscription-store.d.ts +10 -2
  27. package/dist/lib/request.d.ts +36 -0
  28. package/dist/lib/run-tick.d.ts +28 -0
  29. package/dist/lib/validation.d.ts +1 -7
  30. package/dist/{neon-CUIGAB2T.mjs → neon-XLWK27RL.mjs} +29 -104
  31. package/dist/{neon-3ASBLLCM.cjs → neon-ZWA2U7IZ.cjs} +37 -112
  32. package/dist/{pg-IEZTAR43.cjs → pg-Q4VU6D75.cjs} +35 -112
  33. package/dist/{pg-KMJPL6QV.mjs → pg-X63HFEV7.mjs} +27 -104
  34. package/dist/single-user.d.ts +4 -0
  35. 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,59 @@ 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
+
1268
1450
  // src/server/lib/message-processor.js
1451
+ import {
1452
+ buildContentPush,
1453
+ buildReasoningPush,
1454
+ readReasoningContent,
1455
+ stripReasoningTags
1456
+ } from "@rei-standard/amsg-shared";
1269
1457
  var DEFAULT_SPLIT_REGEX = /([。!?!?]+)/;
1270
1458
  var SLEEP_BETWEEN_MESSAGES_MS2 = 1500;
1271
1459
  function splitOnceByRegex(chunk, regex) {
@@ -1287,14 +1475,14 @@ function splitMessageIntoSentences(messageContent, splitPattern = null) {
1287
1475
  }
1288
1476
  return chunks.length > 0 ? chunks : [messageContent];
1289
1477
  }
1290
- async function processSingleMessage(task, ctx, providedMasterKey) {
1478
+ async function processSingleMessage(task, ctx, providedMasterKey, predecrypted = null) {
1291
1479
  try {
1292
1480
  const masterKey = providedMasterKey || ctx.masterKey;
1293
1481
  if (!masterKey) {
1294
1482
  return { success: false, messagesSent: 0, error: "TENANT_MASTER_KEY_MISSING" };
1295
1483
  }
1296
- const userKey = await deriveUserEncryptionKey(task.user_id, masterKey);
1297
- const decryptedPayload = JSON.parse(await decryptFromStorage(task.encrypted_payload, userKey));
1484
+ const userKey = predecrypted && predecrypted.userKey || await deriveUserEncryptionKey(task.user_id, masterKey);
1485
+ const decryptedPayload = predecrypted && predecrypted.payload || JSON.parse(await decryptFromStorage(task.encrypted_payload, userKey));
1298
1486
  if (ctx.hooks && typeof ctx.hooks.onBeforeFire === "function" && taskNeedsLlm(decryptedPayload)) {
1299
1487
  const agentic = await runAgenticFire({ task, decryptedPayload, userKey, ctx });
1300
1488
  if (agentic.handled) return agentic.result;
@@ -1329,7 +1517,12 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
1329
1517
  if (!ctx.vapid.email || !ctx.vapid.publicKey || !ctx.vapid.privateKey) {
1330
1518
  throw new Error("VAPID configuration missing - push notifications cannot be sent");
1331
1519
  }
1332
- const pushSubscription = await resolvePushSubscription({ db: ctx.db, userId: task.user_id, userKey });
1520
+ const pushSubscription = await resolvePushSubscription({
1521
+ db: ctx.db,
1522
+ userId: task.user_id,
1523
+ userKey,
1524
+ legacyFallback: decryptedPayload.pushSubscription ?? null
1525
+ });
1333
1526
  const sessionId = task.id != null ? `sess_task_${task.id}${occurrenceSuffix(task)}` : `sess_${randomUUID()}`;
1334
1527
  const source = decryptedPayload.messageType === "instant" ? "instant" : "scheduled";
1335
1528
  const messageSubtype = decryptedPayload.messageSubtype || "chat";
@@ -1337,6 +1530,7 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
1337
1530
  const metadata = decryptedPayload.metadata || {};
1338
1531
  const messageIdBase = task.id != null ? `msg_task_${task.id}${occurrenceSuffix(task)}` : `msg_${randomUUID()}_instant`;
1339
1532
  const occurrenceMs = occurrenceMsOf(task);
1533
+ const pushesToSend = [];
1340
1534
  if (reasoning) {
1341
1535
  const reasoningPush = buildReasoningPush({
1342
1536
  messageType: decryptedPayload.messageType,
@@ -1352,8 +1546,7 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
1352
1546
  metadata
1353
1547
  });
1354
1548
  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));
1549
+ pushesToSend.push(reasoningPush);
1357
1550
  }
1358
1551
  for (let i = 0; i < messages.length; i++) {
1359
1552
  const contentPush = buildContentPush({
@@ -1372,14 +1565,30 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
1372
1565
  metadata
1373
1566
  });
1374
1567
  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));
1568
+ pushesToSend.push(contentPush);
1569
+ }
1570
+ await appendPushesToOutbox({ db: ctx.db, userId: task.user_id, userKey, pushes: pushesToSend });
1571
+ const sentIds = [];
1572
+ try {
1573
+ for (let i = 0; i < pushesToSend.length; i++) {
1574
+ await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(pushesToSend[i]));
1575
+ sentIds.push(pushesToSend[i].messageId);
1576
+ if (i < pushesToSend.length - 1) {
1577
+ await new Promise((resolve) => setTimeout(resolve, SLEEP_BETWEEN_MESSAGES_MS2));
1578
+ }
1378
1579
  }
1580
+ } finally {
1581
+ await markPushesDelivered({ db: ctx.db, userId: task.user_id, messageIds: sentIds });
1379
1582
  }
1380
1583
  return { success: true, messagesSent: messages.length };
1381
1584
  } catch (error) {
1382
- return { success: false, messagesSent: 0, error: error.message };
1585
+ return {
1586
+ success: false,
1587
+ messagesSent: 0,
1588
+ error: error.message,
1589
+ errorCode: error.code || null,
1590
+ permanent: isNonRetryableError(error)
1591
+ };
1383
1592
  }
1384
1593
  }
1385
1594
  async function processMessagesByUuid(uuid, ctx, maxRetries = 2, userId, providedMasterKey) {
@@ -1411,18 +1620,35 @@ async function processMessagesByUuid(uuid, ctx, maxRetries = 2, userId, provided
1411
1620
  }
1412
1621
  const result = await processSingleMessage(task, ctx, masterKey);
1413
1622
  if (!result.success) {
1414
- if (retryCount < maxRetries) {
1623
+ if (!result.permanent && retryCount < maxRetries) {
1415
1624
  retryCount++;
1416
1625
  await new Promise((resolve) => setTimeout(resolve, 1e3 * retryCount));
1417
1626
  continue;
1418
1627
  }
1419
1628
  try {
1420
- await ctx.db.updateTaskById(task.id, { status: "failed", retry_count: retryCount });
1629
+ await ctx.db.updateTaskById(task.id, {
1630
+ status: "failed",
1631
+ retry_count: retryCount,
1632
+ last_error: JSON.stringify({
1633
+ at: (/* @__PURE__ */ new Date()).toISOString(),
1634
+ occurrence: task.next_send_at ?? null,
1635
+ reason: sanitizeErrorSummary(result.error)
1636
+ })
1637
+ });
1421
1638
  } catch (_updateError) {
1639
+ try {
1640
+ await ctx.db.updateTaskById(task.id, { status: "failed", retry_count: retryCount });
1641
+ } catch (_retryError) {
1642
+ }
1422
1643
  }
1423
1644
  return {
1424
1645
  success: false,
1425
- error: { code: "PROCESSING_ERROR", message: result.error, retriesAttempted: retryCount }
1646
+ error: {
1647
+ code: "PROCESSING_ERROR",
1648
+ message: result.error,
1649
+ retriesAttempted: retryCount,
1650
+ ...result.permanent ? { permanent: true } : {}
1651
+ }
1426
1652
  };
1427
1653
  }
1428
1654
  try {
@@ -1457,16 +1683,12 @@ function createScheduleMessageHandler(ctx) {
1457
1683
  const masterKey = tenantCtx.masterKey;
1458
1684
  const isEncrypted = getHeader(headers, "x-payload-encrypted") === "true";
1459
1685
  const encryptionVersion = getHeader(headers, "x-encryption-version");
1460
- const userId = getHeader(headers, "x-user-id");
1461
1686
  if (!isEncrypted) {
1462
1687
  return { status: 400, body: { success: false, error: { code: "ENCRYPTION_REQUIRED", message: "\u8BF7\u6C42\u4F53\u5FC5\u987B\u52A0\u5BC6" } } };
1463
1688
  }
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
- }
1689
+ const gate = requireUserId(headers);
1690
+ if (gate.error) return gate.error;
1691
+ const { userId } = gate;
1470
1692
  if (encryptionVersion !== "1") {
1471
1693
  return { status: 400, body: { success: false, error: { code: "UNSUPPORTED_ENCRYPTION_VERSION", message: "\u52A0\u5BC6\u7248\u672C\u4E0D\u652F\u6301" } } };
1472
1694
  }
@@ -1495,13 +1717,22 @@ function createScheduleMessageHandler(ctx) {
1495
1717
  if (!supportsPushSubscriptionStore(db)) {
1496
1718
  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
1719
  }
1498
- let storedSubscription = null;
1720
+ let subscriptionRow;
1499
1721
  try {
1500
- storedSubscription = await loadPushSubscription({ db, userId, userKey });
1722
+ subscriptionRow = await db.getPushSubscription(userId);
1501
1723
  } catch (_error) {
1502
- storedSubscription = null;
1724
+ return {
1725
+ status: 503,
1726
+ body: {
1727
+ success: false,
1728
+ error: {
1729
+ code: "PUSH_SUBSCRIPTION_LOOKUP_FAILED",
1730
+ message: "\u63A8\u9001\u8BA2\u9605\u8BFB\u53D6\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5"
1731
+ }
1732
+ }
1733
+ };
1503
1734
  }
1504
- if (!storedSubscription) {
1735
+ if (!subscriptionRow || typeof subscriptionRow.subscription !== "string" || !subscriptionRow.subscription) {
1505
1736
  return {
1506
1737
  status: 409,
1507
1738
  body: {
@@ -1514,13 +1745,14 @@ function createScheduleMessageHandler(ctx) {
1514
1745
  };
1515
1746
  }
1516
1747
  const taskUuid = payload.uuid || randomUUID();
1748
+ const effectiveSendTime = payload.immediate === true ? (/* @__PURE__ */ new Date()).toISOString() : payload.firstSendTime;
1517
1749
  const fullTaskData = {
1518
1750
  contactName: payload.contactName,
1519
1751
  avatarUrl: payload.avatarUrl || null,
1520
1752
  messageType: payload.messageType,
1521
1753
  messageSubtype: payload.messageSubtype || "chat",
1522
1754
  userMessage: payload.userMessage || null,
1523
- firstSendTime: payload.firstSendTime,
1755
+ firstSendTime: effectiveSendTime,
1524
1756
  recurrenceType: payload.recurrenceType || "none",
1525
1757
  // daily / weekly 按这个时区的墙钟推进(同一钟点,日期 +1 天 / +7 天)。
1526
1758
  // 不传 → null → 按 UTC 推进。
@@ -1539,6 +1771,9 @@ function createScheduleMessageHandler(ctx) {
1539
1771
  // the message processor to chunk LLM output into individual pushes.
1540
1772
  // null → processor falls back to the default /([。!?!?]+)/ regex.
1541
1773
  splitPattern: payload.splitPattern ?? null,
1774
+ // 透传给 LLM 中转的非标准参数(thinking 之类),buildLlmRequestBody 会
1775
+ // 把它展开进请求体(核心字段优先)。
1776
+ llmExtraBody: payload.llmExtraBody ?? null,
1542
1777
  metadata: payload.metadata || {}
1543
1778
  };
1544
1779
  const encryptedPayload = await encryptForStorage(JSON.stringify(fullTaskData), userKey);
@@ -1563,15 +1798,26 @@ function createScheduleMessageHandler(ctx) {
1563
1798
  };
1564
1799
  }
1565
1800
  }
1801
+ const createParams = {
1802
+ user_id: userId,
1803
+ uuid: taskUuid,
1804
+ encrypted_payload: encryptedPayload,
1805
+ next_send_at: effectiveSendTime,
1806
+ message_type: payload.messageType
1807
+ };
1808
+ const supersedesUuid = payload.supersedesUuid || null;
1809
+ let superseded = false;
1566
1810
  let dbResult;
1567
1811
  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
- });
1812
+ if (supersedesUuid && typeof db.createTaskSuperseding === "function") {
1813
+ dbResult = await db.createTaskSuperseding(createParams, supersedesUuid);
1814
+ superseded = !!(dbResult && dbResult.superseded);
1815
+ } else {
1816
+ if (supersedesUuid) {
1817
+ superseded = await db.deleteTaskByUuid(supersedesUuid, userId);
1818
+ }
1819
+ dbResult = await db.createTask(createParams);
1820
+ }
1575
1821
  } catch (error) {
1576
1822
  if (isUniqueViolation(error)) {
1577
1823
  return {
@@ -1610,7 +1856,8 @@ function createScheduleMessageHandler(ctx) {
1610
1856
  messagesSent: sendResult.messagesSent,
1611
1857
  sentAt: (/* @__PURE__ */ new Date()).toISOString(),
1612
1858
  status: "sent",
1613
- retriesUsed: sendResult.retriesUsed || 0
1859
+ retriesUsed: sendResult.retriesUsed || 0,
1860
+ ...supersedesUuid ? { superseded } : {}
1614
1861
  }
1615
1862
  }
1616
1863
  };
@@ -1628,7 +1875,10 @@ function createScheduleMessageHandler(ctx) {
1628
1875
  contactName: payload.contactName,
1629
1876
  nextSendAt: dbResult.next_send_at,
1630
1877
  status: dbResult.status,
1631
- createdAt: dbResult.created_at
1878
+ createdAt: dbResult.created_at,
1879
+ // supersede 的结果:true = 旧行确实被取消了;false = 旧行本就不存在
1880
+ // (已发出 / 已删除),新任务照常建立。
1881
+ ...supersedesUuid ? { superseded } : {}
1632
1882
  }
1633
1883
  }
1634
1884
  };
@@ -1639,7 +1889,12 @@ function createScheduleMessageHandler(ctx) {
1639
1889
  // src/server/lib/run-tick.js
1640
1890
  var DEFAULT_CLAIM_LEASE_MS = 10 * 60 * 1e3;
1641
1891
  var CLAIM_LEASE_MARGIN_MS = 2 * 60 * 1e3;
1892
+ var DEFAULT_LEASE_HEARTBEAT_MS = 30 * 1e3;
1893
+ var DEFAULT_HEARTBEAT_LEASE_TTL_MS = 90 * 1e3;
1642
1894
  var STALE_AFTER_MS = 60 * 60 * 1e3;
1895
+ function resolveStaleAfterMs(ctx) {
1896
+ return positiveNumber(ctx.staleAfterMs) || STALE_AFTER_MS;
1897
+ }
1643
1898
  function isRecurringType(recurrenceType) {
1644
1899
  return recurrenceType === "daily" || recurrenceType === "weekly";
1645
1900
  }
@@ -1653,12 +1908,47 @@ async function deriveSerializeGroup(userKey, rawKey) {
1653
1908
  return bytesToBase64Url(await hmacSha256(utf8(userKey), utf8(rawKey)));
1654
1909
  }
1655
1910
  async function runScheduledTick(ctx) {
1911
+ const tasks = await ctx.db.getPendingTasks(50);
1912
+ const summary = await deliverTasks(ctx, tasks);
1913
+ await ctx.db.cleanupOldTasks(7);
1914
+ if (typeof ctx.db.cleanupOutbox === "function") {
1915
+ try {
1916
+ await ctx.db.cleanupOutbox({
1917
+ ackedBeforeMs: Date.now() - 7 * 24 * 60 * 60 * 1e3,
1918
+ allBeforeMs: Date.now() - 28 * 24 * 60 * 60 * 1e3
1919
+ });
1920
+ } catch (error) {
1921
+ console.warn("[amsg-server] cleanupOutbox \u5931\u8D25\uFF08\u5DF2\u5FFD\u7565\uFF09:", error && error.message);
1922
+ }
1923
+ }
1924
+ return summary;
1925
+ }
1926
+ async function runTask(ctx, uuid) {
1927
+ if (typeof uuid !== "string" || !uuid.trim()) {
1928
+ throw new TypeError("runTask(ctx, uuid) requires a non-empty uuid string");
1929
+ }
1930
+ const task = await ctx.db.getTaskByUuidOnly(uuid);
1931
+ if (!task) return { ran: false, reason: "not_found" };
1932
+ const dueMs = Date.parse(task.next_send_at);
1933
+ if (Number.isFinite(dueMs) && dueMs > Date.now()) {
1934
+ return { ran: false, reason: "not_due", nextSendAt: task.next_send_at };
1935
+ }
1936
+ const retryAfterMs = task.retry_after ? Date.parse(task.retry_after) : NaN;
1937
+ if (Number.isFinite(retryAfterMs) && retryAfterMs > Date.now()) {
1938
+ return { ran: false, reason: "retry_pending", retryAfter: task.retry_after };
1939
+ }
1940
+ return { ran: true, summary: await deliverTasks(ctx, [task]) };
1941
+ }
1942
+ async function deliverTasks(ctx, tasks) {
1656
1943
  const db = ctx.db;
1657
1944
  const masterKey = ctx.masterKey;
1658
1945
  const claimLeaseMs = resolveClaimLeaseMs(ctx);
1946
+ const staleAfterMs = resolveStaleAfterMs(ctx);
1659
1947
  const serializeBy = typeof ctx.serializeBy === "function" ? ctx.serializeBy : null;
1948
+ const heartbeatMs = ctx.leaseHeartbeatMs === 0 ? 0 : positiveNumber(ctx.leaseHeartbeatMs) || DEFAULT_LEASE_HEARTBEAT_MS;
1949
+ const heartbeatEnabled = heartbeatMs > 0 && typeof db.claimTask === "function" && typeof db.renewTaskLease === "function";
1950
+ const heartbeatLeaseTtlMs = Math.max(DEFAULT_HEARTBEAT_LEASE_TTL_MS, heartbeatMs * 2);
1660
1951
  const startTime = Date.now();
1661
- const tasks = await db.getPendingTasks(50);
1662
1952
  const MAX_CONCURRENT = 8;
1663
1953
  const results = {
1664
1954
  totalTasks: tasks.length,
@@ -1675,11 +1965,57 @@ async function runScheduledTick(ctx) {
1675
1965
  const supportsClaim = typeof db.claimTask === "function";
1676
1966
  async function claimForThisTick(task, serializeGroup) {
1677
1967
  if (!supportsClaim) return true;
1678
- const leaseUntil = new Date(Date.now() + claimLeaseMs).toISOString();
1968
+ const leaseUntil = new Date(
1969
+ Date.now() + (heartbeatEnabled ? heartbeatLeaseTtlMs : claimLeaseMs)
1970
+ ).toISOString();
1679
1971
  return !!await db.claimTask(task.id, task.next_send_at, leaseUntil, serializeGroup);
1680
1972
  }
1973
+ function startLeaseHeartbeat(task) {
1974
+ if (!heartbeatEnabled) return () => {
1975
+ };
1976
+ let stopped = false;
1977
+ let timer = null;
1978
+ const beat = async () => {
1979
+ if (stopped) return;
1980
+ try {
1981
+ await db.renewTaskLease(task.id, new Date(Date.now() + heartbeatLeaseTtlMs).toISOString());
1982
+ } catch (error) {
1983
+ console.warn("[amsg-server] \u79DF\u7EA6\u7EED\u79DF\u5931\u8D25\uFF08\u4E0B\u4E2A\u5FC3\u8DF3\u518D\u8BD5\uFF09:", error && error.message);
1984
+ }
1985
+ if (!stopped) schedule();
1986
+ };
1987
+ const schedule = () => {
1988
+ timer = setTimeout(beat, heartbeatMs);
1989
+ if (timer && typeof timer.unref === "function") timer.unref();
1990
+ };
1991
+ schedule();
1992
+ return () => {
1993
+ stopped = true;
1994
+ if (timer) clearTimeout(timer);
1995
+ };
1996
+ }
1997
+ const supportsLastError = supportsClaim;
1998
+ async function updateTaskWithLastError(taskId, fields) {
1999
+ try {
2000
+ return await db.updateTaskById(taskId, fields);
2001
+ } catch (error) {
2002
+ if (Object.prototype.hasOwnProperty.call(fields, "last_error") && /last_error/i.test(error.message || "")) {
2003
+ const { last_error: _omit, ...rest } = fields;
2004
+ return db.updateTaskById(taskId, rest);
2005
+ }
2006
+ throw error;
2007
+ }
2008
+ }
2009
+ function lastErrorJson(task, reason, extra) {
2010
+ return JSON.stringify({
2011
+ at: (/* @__PURE__ */ new Date()).toISOString(),
2012
+ occurrence: task.next_send_at ?? null,
2013
+ reason: sanitizeErrorSummary(reason),
2014
+ ...extra || {}
2015
+ });
2016
+ }
1681
2017
  async function updateAndRelease(taskId, fields) {
1682
- return db.updateTaskById(
2018
+ return updateTaskWithLastError(
1683
2019
  taskId,
1684
2020
  supportsClaim ? { ...fields, lease_until: null, retry_after: null } : fields
1685
2021
  );
@@ -1703,8 +2039,9 @@ async function runScheduledTick(ctx) {
1703
2039
  return { taken: true, rawKey: null };
1704
2040
  }
1705
2041
  if (typeof rawKey !== "string" || !rawKey) return { taken: false, rawKey: null };
1706
- if (groupsTakenThisTick.has(rawKey)) return { taken: true, rawKey };
1707
- groupsTakenThisTick.add(rawKey);
2042
+ const scopedKey = `${task.user_id}\0${rawKey}`;
2043
+ if (groupsTakenThisTick.has(scopedKey)) return { taken: true, rawKey };
2044
+ groupsTakenThisTick.add(scopedKey);
1708
2045
  return { taken: false, rawKey };
1709
2046
  }
1710
2047
  async function encryptPayloadWithLastError(task, decryptedPayload, userKey, reason, extra) {
@@ -1731,34 +2068,38 @@ async function runScheduledTick(ctx) {
1731
2068
  console.warn("[amsg-server] onStaleSkip hook \u629B\u9519\uFF08\u5DF2\u5FFD\u7565\uFF09:", hookError && hookError.message);
1732
2069
  }
1733
2070
  }
1734
- async function handleDeliveryFailure(task, reason, recurrenceType, decryptedPayload, userKey) {
2071
+ async function handleDeliveryFailure(task, reason, recurrenceType, decryptedPayload, userKey, errorCode = null, permanentFlag = false) {
1735
2072
  results.failedCount++;
1736
2073
  const tzId = decryptedPayload ? decryptedPayload.tzId ?? null : null;
2074
+ const permanent = permanentFlag === true || errorCode === "PUSH_SUBSCRIPTION_MISSING" || errorCode === "PUSH_SUBSCRIPTION_STORE_UNSUPPORTED";
1737
2075
  try {
1738
- if (task.retry_count >= 3) {
2076
+ if (permanent || task.retry_count >= 3) {
1739
2077
  const encrypted = await encryptPayloadWithLastError(task, decryptedPayload, userKey, reason);
1740
2078
  if (isRecurringType(recurrenceType)) {
1741
2079
  const nextSendAt = nextFutureOccurrence(Date.parse(task.next_send_at), recurrenceType, Date.now(), tzId);
1742
2080
  await updateAndRelease(task.id, {
1743
2081
  next_send_at: nextSendAt,
1744
2082
  retry_count: 0,
1745
- ...encrypted ? { encrypted_payload: encrypted } : {}
2083
+ ...encrypted ? { encrypted_payload: encrypted } : {},
2084
+ ...supportsLastError ? { last_error: lastErrorJson(task, reason) } : {}
1746
2085
  });
1747
- results.failedTasks.push({ taskId: task.id, reason, retryCount: task.retry_count, status: "occurrence_skipped", nextSendAt });
2086
+ results.failedTasks.push({ taskId: task.id, reason, retryCount: task.retry_count, status: "occurrence_skipped", nextSendAt, ...permanent ? { permanent: true } : {} });
1748
2087
  } else {
1749
2088
  await updateAndRelease(task.id, {
1750
2089
  status: "failed",
1751
- ...encrypted ? { encrypted_payload: encrypted } : {}
2090
+ ...encrypted ? { encrypted_payload: encrypted } : {},
2091
+ ...supportsLastError ? { last_error: lastErrorJson(task, reason) } : {}
1752
2092
  });
1753
- results.failedTasks.push({ taskId: task.id, reason, retryCount: task.retry_count, status: "permanently_failed" });
2093
+ results.failedTasks.push({ taskId: task.id, reason, retryCount: task.retry_count, status: "permanently_failed", ...permanent ? { permanent: true } : {} });
1754
2094
  }
1755
2095
  } else {
1756
2096
  const nextRetryTime = new Date(Date.now() + (task.retry_count + 1) * 2 * 60 * 1e3);
1757
2097
  if (supportsClaim) {
1758
- await db.updateTaskById(task.id, {
2098
+ await updateTaskWithLastError(task.id, {
1759
2099
  retry_after: nextRetryTime.toISOString(),
1760
2100
  lease_until: null,
1761
- retry_count: task.retry_count + 1
2101
+ retry_count: task.retry_count + 1,
2102
+ ...supportsLastError ? { last_error: lastErrorJson(task, reason) } : {}
1762
2103
  });
1763
2104
  } else {
1764
2105
  await db.updateTaskById(task.id, { next_send_at: nextRetryTime.toISOString(), retry_count: task.retry_count + 1 });
@@ -1819,6 +2160,16 @@ async function runScheduledTick(ctx) {
1819
2160
  results.claimSkippedTasks++;
1820
2161
  return;
1821
2162
  }
2163
+ const stopHeartbeat = startLeaseHeartbeat(task);
2164
+ try {
2165
+ await deliverClaimedTask(task, decrypted);
2166
+ } finally {
2167
+ stopHeartbeat();
2168
+ }
2169
+ }
2170
+ async function deliverClaimedTask(task, decrypted) {
2171
+ const decryptedPayload = decrypted.ok ? decrypted.payload : null;
2172
+ const userKey = decrypted.ok ? decrypted.userKey : null;
1822
2173
  if (!decrypted.ok) {
1823
2174
  await handleDeliveryFailure(task, decrypted.error && decrypted.error.message || "\u4EFB\u52A1\u8F7D\u8377\u89E3\u5BC6\u5931\u8D25", null, null, null);
1824
2175
  return;
@@ -1826,7 +2177,9 @@ async function runScheduledTick(ctx) {
1826
2177
  const recurrenceType = decryptedPayload.recurrenceType;
1827
2178
  const tzId = decryptedPayload.tzId ?? null;
1828
2179
  const occurrenceMs = Date.parse(task.next_send_at);
1829
- if (Number.isFinite(occurrenceMs) && Date.now() - occurrenceMs > STALE_AFTER_MS && (task.retry_count || 0) === 0) {
2180
+ const retryAfterMs = task.retry_after ? Date.parse(task.retry_after) : NaN;
2181
+ const notOnFreshRetryChain = (task.retry_count || 0) === 0 || Number.isFinite(retryAfterMs) && Date.now() - retryAfterMs > staleAfterMs;
2182
+ if (Number.isFinite(occurrenceMs) && Date.now() - occurrenceMs > staleAfterMs && notOnFreshRetryChain) {
1830
2183
  try {
1831
2184
  const stateAccessors = createStateAccessors({
1832
2185
  db,
@@ -1834,57 +2187,42 @@ async function runScheduledTick(ctx) {
1834
2187
  userKey,
1835
2188
  maxStateValueBytes: ctx.maxStateValueBytes
1836
2189
  });
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
- }
2190
+ const recurring = isRecurringType(recurrenceType);
2191
+ const plan = recurring ? planNextOccurrence(occurrenceMs, recurrenceType, Date.now(), tzId) : null;
2192
+ const nextSendAt = recurring ? new Date(plan.nextMs).toISOString() : null;
2193
+ const action = recurring ? "fast_forwarded" : "expired";
2194
+ const skippedCount = recurring ? plan.skippedCount : 1;
2195
+ const encrypted = await encryptPayloadWithLastError(
2196
+ task,
2197
+ decryptedPayload,
2198
+ userKey,
2199
+ "stale",
2200
+ recurring ? { skippedCount, nextSendAt } : void 0
2201
+ );
2202
+ await updateAndRelease(task.id, {
2203
+ ...recurring ? { next_send_at: nextSendAt, retry_count: 0 } : { status: "failed" },
2204
+ ...encrypted ? { encrypted_payload: encrypted } : {},
2205
+ ...supportsLastError ? { last_error: lastErrorJson(task, "stale", recurring ? { skippedCount, nextSendAt } : void 0) } : {}
2206
+ });
2207
+ results.staleTasks.push({
2208
+ taskId: task.id,
2209
+ reason: "stale",
2210
+ action,
2211
+ ...recurring ? { nextSendAt } : {},
2212
+ skippedCount
2213
+ });
2214
+ await notifyStaleSkip(task, {
2215
+ reason: "stale",
2216
+ action,
2217
+ metadata: decryptedPayload.metadata ?? null,
2218
+ recurrenceType: recurrenceType || "none",
2219
+ occurrenceMs,
2220
+ skippedCount,
2221
+ skippedOccurrences: recurring ? plan.skippedOccurrences : [occurrenceMs],
2222
+ skippedTruncated: recurring ? plan.skippedTruncated : false,
2223
+ nextSendAt,
2224
+ ...stateAccessors
2225
+ });
1888
2226
  } catch (error) {
1889
2227
  results.failedCount++;
1890
2228
  results.failedTasks.push({ taskId: task.id, reason: error.message || "\u8FC7\u671F\u4EFB\u52A1\u5904\u7406\u5931\u8D25", status: "stale_update_failed" });
@@ -1893,13 +2231,34 @@ async function runScheduledTick(ctx) {
1893
2231
  }
1894
2232
  let sendResult;
1895
2233
  try {
1896
- sendResult = await processSingleMessage(task, { ...ctx, db, masterKey }, masterKey);
1897
- } catch (error) {
1898
- await handleDeliveryFailure(task, error.message || "\u6D88\u606F\u53D1\u9001\u5931\u8D25", recurrenceType, decryptedPayload, userKey);
2234
+ sendResult = await processSingleMessage(
2235
+ task,
2236
+ { ...ctx, db, masterKey },
2237
+ masterKey,
2238
+ { userKey, payload: decryptedPayload }
2239
+ );
2240
+ } catch (error) {
2241
+ await handleDeliveryFailure(
2242
+ task,
2243
+ error.message || "\u6D88\u606F\u53D1\u9001\u5931\u8D25",
2244
+ recurrenceType,
2245
+ decryptedPayload,
2246
+ userKey,
2247
+ error.code || null,
2248
+ error.permanent === true
2249
+ );
1899
2250
  return;
1900
2251
  }
1901
2252
  if (!sendResult.success) {
1902
- await handleDeliveryFailure(task, sendResult.error || "\u6D88\u606F\u53D1\u9001\u5931\u8D25", recurrenceType, decryptedPayload, userKey);
2253
+ await handleDeliveryFailure(
2254
+ task,
2255
+ sendResult.error || "\u6D88\u606F\u53D1\u9001\u5931\u8D25",
2256
+ recurrenceType,
2257
+ decryptedPayload,
2258
+ userKey,
2259
+ sendResult.errorCode || null,
2260
+ sendResult.permanent === true
2261
+ );
1903
2262
  return;
1904
2263
  }
1905
2264
  try {
@@ -1908,7 +2267,11 @@ async function runScheduledTick(ctx) {
1908
2267
  results.deletedOnceOffTasks++;
1909
2268
  } else {
1910
2269
  const nextSendAt = nextFutureOccurrence(occurrenceMs, recurrenceType, Date.now(), tzId);
1911
- await updateAndRelease(task.id, { next_send_at: nextSendAt, retry_count: 0 });
2270
+ await updateAndRelease(task.id, {
2271
+ next_send_at: nextSendAt,
2272
+ retry_count: 0,
2273
+ ...supportsLastError ? { last_error: null } : {}
2274
+ });
1912
2275
  results.updatedRecurringTasks++;
1913
2276
  }
1914
2277
  results.successCount++;
@@ -1941,7 +2304,6 @@ async function runScheduledTick(ctx) {
1941
2304
  await Promise.race(processing);
1942
2305
  }
1943
2306
  }
1944
- await db.cleanupOldTasks(7);
1945
2307
  const executionTime = Date.now() - startTime;
1946
2308
  return {
1947
2309
  totalTasks: results.totalTasks,
@@ -1977,13 +2339,9 @@ function createUpdateMessageHandler(ctx) {
1977
2339
  if (!taskUuid) {
1978
2340
  return { status: 400, body: { success: false, error: { code: "TASK_ID_REQUIRED", message: "\u7F3A\u5C11\u4EFB\u52A1ID" } } };
1979
2341
  }
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
- }
2342
+ const gate = requireUserId(headers);
2343
+ if (gate.error) return gate.error;
2344
+ const { userId } = gate;
1987
2345
  const isEncrypted = getHeader(headers, "x-payload-encrypted") === "true";
1988
2346
  const encryptionVersion = getHeader(headers, "x-encryption-version");
1989
2347
  if (!isEncrypted) {
@@ -2092,7 +2450,11 @@ function createUpdateMessageHandler(ctx) {
2092
2450
  ...Object.prototype.hasOwnProperty.call(updates, "splitPattern") && { splitPattern: updates.splitPattern ?? null }
2093
2451
  };
2094
2452
  const encryptedPayload = await encryptForStorage(JSON.stringify(updatedData), userKey);
2095
- const extraFields = updates.nextSendAt ? { next_send_at: updates.nextSendAt } : void 0;
2453
+ const extraFields = {
2454
+ retry_count: 0,
2455
+ ...typeof db.claimTask === "function" ? { retry_after: null } : {},
2456
+ ...updates.nextSendAt ? { next_send_at: updates.nextSendAt } : {}
2457
+ };
2096
2458
  const result = await db.updateTaskByUuid(taskUuid, userId, encryptedPayload, extraFields);
2097
2459
  if (!result) {
2098
2460
  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 +2487,9 @@ function createCancelMessageHandler(ctx) {
2125
2487
  if (!taskUuid) {
2126
2488
  return { status: 400, body: { success: false, error: { code: "TASK_ID_REQUIRED", message: "\u7F3A\u5C11\u4EFB\u52A1ID" } } };
2127
2489
  }
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
- }
2490
+ const gate = requireUserId(headers);
2491
+ if (gate.error) return gate.error;
2492
+ const { userId } = gate;
2135
2493
  const deleted = await db.deleteTaskByUuid(taskUuid, userId);
2136
2494
  if (!deleted) {
2137
2495
  return {
@@ -2160,19 +2518,9 @@ function createMessagesHandler(ctx) {
2160
2518
  const tenantCtx = tenantResult.context;
2161
2519
  const db = tenantCtx.db;
2162
2520
  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
- }
2521
+ const gate = requireUserId(headers);
2522
+ if (gate.error) return gate.error;
2523
+ const { userId } = gate;
2176
2524
  const u = new URL(url, "https://dummy");
2177
2525
  const status = u.searchParams.get("status") || "all";
2178
2526
  const limit = Math.min(parseInt(u.searchParams.get("limit") || "20", 10), 100);
@@ -2217,30 +2565,39 @@ function createGetMessageHandler(ctx) {
2217
2565
  const tenantCtx = tenantResult.context;
2218
2566
  const db = tenantCtx.db;
2219
2567
  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
- }
2568
+ const gate = requireUserId(headers);
2569
+ if (gate.error) return gate.error;
2570
+ const { userId } = gate;
2233
2571
  const taskUuid = new URL(url, "https://dummy").searchParams.get("id");
2234
2572
  if (!taskUuid) {
2235
2573
  return { status: 400, body: { success: false, error: { code: "TASK_ID_REQUIRED", message: "\u7F3A\u5C11\u4EFB\u52A1ID" } } };
2236
2574
  }
2237
2575
  const row = await db.getTaskByUuid(taskUuid, userId);
2238
2576
  if (!row) {
2239
- const taskStatus = await db.getTaskStatus(taskUuid, userId);
2577
+ const statusInfo = typeof db.getTaskStatusInfo === "function" ? await db.getTaskStatusInfo(taskUuid, userId) : null;
2578
+ const taskStatus = statusInfo ? statusInfo.status : await db.getTaskStatus(taskUuid, userId);
2240
2579
  if (!taskStatus) {
2241
2580
  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
2581
  }
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" } } };
2582
+ let lastError = null;
2583
+ if (statusInfo && statusInfo.last_error) {
2584
+ try {
2585
+ lastError = JSON.parse(statusInfo.last_error);
2586
+ } catch (_parseError) {
2587
+ lastError = { reason: statusInfo.last_error };
2588
+ }
2589
+ }
2590
+ return {
2591
+ status: 409,
2592
+ body: {
2593
+ success: false,
2594
+ error: {
2595
+ code: "TASK_ALREADY_COMPLETED",
2596
+ message: "\u4EFB\u52A1\u5DF2\u5B8C\u6210\u6216\u5DF2\u5931\u8D25\uFF0C\u65E0\u6CD5\u66F4\u65B0",
2597
+ details: { status: taskStatus, lastError }
2598
+ }
2599
+ }
2600
+ };
2244
2601
  }
2245
2602
  const userKey = await deriveUserEncryptionKey(userId, masterKey);
2246
2603
  const decrypted = JSON.parse(await decryptFromStorage(row.encrypted_payload, userKey));
@@ -2263,12 +2620,6 @@ function err(status, code, message, details) {
2263
2620
  const error = details === void 0 ? { code, message } : { code, message, details };
2264
2621
  return { status, body: { success: false, error } };
2265
2622
  }
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
2623
  var UNSUPPORTED = err(
2273
2624
  501,
2274
2625
  "PUSH_SUBSCRIPTION_NOT_SUPPORTED",
@@ -2376,6 +2727,7 @@ var SQLITE_TABLE_SQL = `
2376
2727
  serialize_group TEXT,
2377
2728
  status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'failed')),
2378
2729
  retry_count INTEGER NOT NULL DEFAULT 0,
2730
+ last_error TEXT,
2379
2731
  created_at TEXT NOT NULL,
2380
2732
  updated_at TEXT NOT NULL
2381
2733
  )
@@ -2395,6 +2747,14 @@ var SQLITE_MIGRATIONS = [
2395
2747
  name: "add_serialize_group",
2396
2748
  sql: "ALTER TABLE scheduled_messages ADD COLUMN serialize_group TEXT",
2397
2749
  description: "Serialization group for runScheduledTick serializeBy (2.6.0)"
2750
+ },
2751
+ {
2752
+ name: "add_last_error",
2753
+ // 上一次投递失败的脱敏摘要(JSON:{ at, occurrence, reason })。payload 里
2754
+ // 的 lastError 是密文里的完整记录,这一列是它的明文出口——payload 本身
2755
+ // 解密失败时也写得进去,GET /message 对已失败的行也读得出来。
2756
+ sql: "ALTER TABLE scheduled_messages ADD COLUMN last_error TEXT",
2757
+ description: "Sanitized last delivery-failure summary (2.6.0)"
2398
2758
  }
2399
2759
  ];
2400
2760
  var SQLITE_INDEXES = [
@@ -2463,22 +2823,29 @@ var PUSH_SUBSCRIPTION_TABLE_SQL = `
2463
2823
  updated_at INTEGER NOT NULL
2464
2824
  )
2465
2825
  `;
2826
+ var MESSAGE_OUTBOX_TABLE_SQL = `
2827
+ CREATE TABLE IF NOT EXISTS message_outbox (
2828
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
2829
+ user_id TEXT NOT NULL,
2830
+ message_id TEXT NOT NULL,
2831
+ task_uuid TEXT,
2832
+ session_id TEXT,
2833
+ message_index INTEGER,
2834
+ total_messages INTEGER,
2835
+ payload TEXT NOT NULL,
2836
+ created_at INTEGER NOT NULL,
2837
+ delivered_at INTEGER,
2838
+ acked_at INTEGER,
2839
+ UNIQUE (user_id, message_id)
2840
+ )
2841
+ `;
2842
+ var MESSAGE_OUTBOX_INDEX_SQL = `
2843
+ CREATE INDEX IF NOT EXISTS idx_outbox_unacked
2844
+ ON message_outbox (user_id, id)
2845
+ WHERE acked_at IS NULL
2846
+ `;
2466
2847
 
2467
2848
  // 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
2849
  function escapeLikePrefix(prefix) {
2483
2850
  return prefix.replace(/[\\%_]/g, (ch) => `\\${ch}`);
2484
2851
  }
@@ -2503,6 +2870,8 @@ var D1Adapter = class {
2503
2870
  await this._db.prepare(SQLITE_TABLE_SQL).run();
2504
2871
  await this._db.prepare(CLIENT_STATE_TABLE_SQL).run();
2505
2872
  await this._db.prepare(PUSH_SUBSCRIPTION_TABLE_SQL).run();
2873
+ await this._db.prepare(MESSAGE_OUTBOX_TABLE_SQL).run();
2874
+ await this._db.prepare(MESSAGE_OUTBOX_INDEX_SQL).run();
2506
2875
  for (const migration of SQLITE_MIGRATIONS) {
2507
2876
  try {
2508
2877
  await this._db.prepare(migration.sql).run();
@@ -2538,6 +2907,7 @@ var D1Adapter = class {
2538
2907
  await this._db.prepare("DROP TABLE IF EXISTS scheduled_messages").run();
2539
2908
  await this._db.prepare("DROP TABLE IF EXISTS client_state").run();
2540
2909
  await this._db.prepare("DROP TABLE IF EXISTS push_subscriptions").run();
2910
+ await this._db.prepare("DROP TABLE IF EXISTS message_outbox").run();
2541
2911
  }
2542
2912
  async createTask(params) {
2543
2913
  const now = this._now();
@@ -2552,9 +2922,48 @@ var D1Adapter = class {
2552
2922
  `SELECT id, uuid, next_send_at, status, created_at FROM scheduled_messages WHERE id = ?`
2553
2923
  ).bind(id).first();
2554
2924
  }
2925
+ /**
2926
+ * 建新任务的同时取消旧的那条(`POST /schedule-message` 的 supersedesUuid)。
2927
+ *
2928
+ * 两条语句走一次 batch(D1 的隐式事务 + 单次网络往返):不会出现「旧的删了、
2929
+ * 新的没建成」的中间态——INSERT 撞 uuid 唯一索引时整个 batch 回滚,旧行原样
2930
+ * 留着,调用方按既有的 409 冲突路径处理。
2931
+ *
2932
+ * @param {import('./interface.js').InsertTaskParams} params
2933
+ * @param {string} supersedesUuid - 要取消的旧任务 uuid(同一 user_id 下)
2934
+ * @returns {Promise<Object>} createTask 的返回行 + `superseded`(旧行是否
2935
+ * 真的被删掉;false = 旧行本就不存在)
2936
+ */
2937
+ async createTaskSuperseding(params, supersedesUuid) {
2938
+ const now = this._now();
2939
+ const nextSendAt = this._iso(params.next_send_at);
2940
+ const statements = [
2941
+ this._db.prepare(
2942
+ "DELETE FROM scheduled_messages WHERE uuid = ? AND user_id = ?"
2943
+ ).bind(supersedesUuid, params.user_id),
2944
+ this._db.prepare(
2945
+ `INSERT INTO scheduled_messages
2946
+ (user_id, uuid, encrypted_payload, next_send_at, message_type, status, retry_count, created_at, updated_at)
2947
+ VALUES (?, ?, ?, ?, ?, 'pending', 0, ?, ?)`
2948
+ ).bind(params.user_id, params.uuid, params.encrypted_payload, nextSendAt, params.message_type, now, now)
2949
+ ];
2950
+ let results;
2951
+ if (typeof this._db.batch === "function") {
2952
+ results = await this._db.batch(statements);
2953
+ } else {
2954
+ results = [];
2955
+ for (const stmt of statements) results.push(await stmt.run());
2956
+ }
2957
+ const superseded = (results[0].meta.changes || 0) > 0;
2958
+ const id = results[1].meta.last_row_id;
2959
+ const row = await this._db.prepare(
2960
+ "SELECT id, uuid, next_send_at, status, created_at FROM scheduled_messages WHERE id = ?"
2961
+ ).bind(id).first();
2962
+ return { ...row, superseded };
2963
+ }
2555
2964
  async getTaskByUuid(uuid, userId) {
2556
2965
  return this._db.prepare(
2557
- `SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count, created_at, updated_at
2966
+ `SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count, last_error, created_at, updated_at
2558
2967
  FROM scheduled_messages
2559
2968
  WHERE uuid = ? AND user_id = ? AND status = 'pending'
2560
2969
  LIMIT 1`
@@ -2562,7 +2971,7 @@ var D1Adapter = class {
2562
2971
  }
2563
2972
  async getTaskByUuidOnly(uuid) {
2564
2973
  return this._db.prepare(
2565
- `SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count
2974
+ `SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, retry_after, status, retry_count
2566
2975
  FROM scheduled_messages
2567
2976
  WHERE uuid = ? AND status = 'pending'
2568
2977
  LIMIT 1`
@@ -2622,7 +3031,7 @@ var D1Adapter = class {
2622
3031
  async getPendingTasks(limit = 50) {
2623
3032
  const now = this._now();
2624
3033
  const res = await this._db.prepare(
2625
- `SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count
3034
+ `SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, retry_after, status, retry_count
2626
3035
  FROM scheduled_messages
2627
3036
  WHERE status = 'pending' AND next_send_at <= ?
2628
3037
  AND (lease_until IS NULL OR lease_until <= ?)
@@ -2695,6 +3104,22 @@ var D1Adapter = class {
2695
3104
  ).bind(...values).run();
2696
3105
  return (res.meta.changes || 0) > 0;
2697
3106
  }
3107
+ /**
3108
+ * 投递期间的租约续期(run-tick 的心跳用)。只在行仍是 pending 且确实持有
3109
+ * 租约(lease_until 非空)时生效——收尾把租约放掉之后,迟到的心跳不会把
3110
+ * 租约复活。
3111
+ *
3112
+ * @param {number} taskId
3113
+ * @param {string|Date} leaseUntil - 新的租期末尾
3114
+ * @returns {Promise<boolean>} true = 续上了
3115
+ */
3116
+ async renewTaskLease(taskId, leaseUntil) {
3117
+ const res = await this._db.prepare(
3118
+ `UPDATE scheduled_messages SET lease_until = ?
3119
+ WHERE id = ? AND status = 'pending' AND lease_until IS NOT NULL`
3120
+ ).bind(this._iso(leaseUntil), taskId).run();
3121
+ return (res.meta.changes || 0) > 0;
3122
+ }
2698
3123
  async listTasks(userId, opts = {}) {
2699
3124
  const { status = "all", limit = 20, offset = 0 } = opts;
2700
3125
  const conditions = ["user_id = ?"];
@@ -2709,7 +3134,7 @@ var D1Adapter = class {
2709
3134
  ).bind(...params).first();
2710
3135
  const total = Number(countRow.count) || 0;
2711
3136
  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
3137
+ `SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count, last_error, created_at, updated_at
2713
3138
  FROM scheduled_messages
2714
3139
  WHERE ${where}
2715
3140
  ORDER BY next_send_at ASC
@@ -2732,6 +3157,20 @@ var D1Adapter = class {
2732
3157
  ).bind(uuid, userId).first();
2733
3158
  return row ? row.status : null;
2734
3159
  }
3160
+ /**
3161
+ * 状态 + 失败摘要(GET /message 用它把「为什么失败」透给已失败的行——那些
3162
+ * 行 getTaskByUuid 读不到,payload 里的 lastError 也就够不着了)。
3163
+ *
3164
+ * @param {string} uuid
3165
+ * @param {string} userId
3166
+ * @returns {Promise<{ status: string, last_error: string|null }|null>}
3167
+ */
3168
+ async getTaskStatusInfo(uuid, userId) {
3169
+ const row = await this._db.prepare(
3170
+ "SELECT status, last_error FROM scheduled_messages WHERE uuid = ? AND user_id = ? LIMIT 1"
3171
+ ).bind(uuid, userId).first();
3172
+ return row ? { status: row.status, last_error: row.last_error ?? null } : null;
3173
+ }
2735
3174
  // ── client_state (single-user cloud state mirror) ──────────────────────
2736
3175
  /**
2737
3176
  * Batch upsert. Last-write-wins per (namespace, key): an entry older
@@ -2862,6 +3301,127 @@ var D1Adapter = class {
2862
3301
  const res = await this._db.prepare("DELETE FROM push_subscriptions WHERE user_id = ?").bind(userId).run();
2863
3302
  return (res.meta.changes || 0) > 0;
2864
3303
  }
3304
+ // ── message_outbox(服务端消息收件箱,客户端 ack)────────────────────────
3305
+ /**
3306
+ * 发送前把这一批 push 落进 outbox(一次 batch)。(user_id, message_id)
3307
+ * 唯一:重试同一 occurrence 带着同一批 messageId 再来时更新 payload、不加
3308
+ * 第二行;客户端已经 ack 过的行不动(ack 是终态,重试不该把它拉回未读)。
3309
+ *
3310
+ * @param {string} userId
3311
+ * @param {Array<{ message_id: string, task_uuid?: string|null, session_id?: string|null,
3312
+ * message_index?: number|null, total_messages?: number|null, payload: string, created_at: number }>} rows
3313
+ * `payload` 是整条 push JSON 的 encryptForStorage 密文。
3314
+ * @returns {Promise<number>} 实际写入/更新的行数
3315
+ */
3316
+ async appendOutboxMessages(userId, rows) {
3317
+ if (!rows || rows.length === 0) return 0;
3318
+ const SQL = `INSERT INTO message_outbox
3319
+ (user_id, message_id, task_uuid, session_id, message_index, total_messages, payload, created_at)
3320
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
3321
+ ON CONFLICT (user_id, message_id) DO UPDATE SET
3322
+ payload = excluded.payload,
3323
+ created_at = excluded.created_at,
3324
+ delivered_at = NULL
3325
+ WHERE message_outbox.acked_at IS NULL`;
3326
+ const statements = rows.map(
3327
+ (r) => this._db.prepare(SQL).bind(
3328
+ userId,
3329
+ r.message_id,
3330
+ r.task_uuid ?? null,
3331
+ r.session_id ?? null,
3332
+ r.message_index ?? null,
3333
+ r.total_messages ?? null,
3334
+ r.payload,
3335
+ r.created_at
3336
+ )
3337
+ );
3338
+ let results;
3339
+ if (typeof this._db.batch === "function") {
3340
+ results = await this._db.batch(statements);
3341
+ } else {
3342
+ results = [];
3343
+ for (const stmt of statements) results.push(await stmt.run());
3344
+ }
3345
+ return results.reduce((n, res) => n + ((res.meta.changes || 0) > 0 ? 1 : 0), 0);
3346
+ }
3347
+ /**
3348
+ * 把这一批标记为「Web Push 已发出」。发送失败的段不标——delivered_at 为
3349
+ * null 的行正是客户端最需要拉的那部分。
3350
+ *
3351
+ * @param {string} userId
3352
+ * @param {string[]} messageIds
3353
+ * @param {number} deliveredAt - epoch 毫秒
3354
+ * @returns {Promise<number>}
3355
+ */
3356
+ async markOutboxDelivered(userId, messageIds, deliveredAt) {
3357
+ if (!messageIds || messageIds.length === 0) return 0;
3358
+ const placeholders = messageIds.map(() => "?").join(", ");
3359
+ const res = await this._db.prepare(
3360
+ `UPDATE message_outbox SET delivered_at = ?
3361
+ WHERE user_id = ? AND message_id IN (${placeholders})`
3362
+ ).bind(deliveredAt, userId, ...messageIds).run();
3363
+ return res.meta.changes || 0;
3364
+ }
3365
+ /**
3366
+ * 未 ack 的行(id 升序,游标翻页)。payload 仍是密文,解密在 handler。
3367
+ *
3368
+ * @param {string} userId
3369
+ * @param {number} sinceId - 上一页游标(0 = 从头)
3370
+ * @param {number} limit
3371
+ * @returns {Promise<Array<{ id: number, message_id: string, task_uuid: string|null,
3372
+ * session_id: string|null, message_index: number|null, total_messages: number|null,
3373
+ * payload: string, created_at: number, delivered_at: number|null }>>}
3374
+ */
3375
+ async listUnackedOutbox(userId, sinceId = 0, limit = 50) {
3376
+ const res = await this._db.prepare(
3377
+ `SELECT id, message_id, task_uuid, session_id, message_index, total_messages, payload, created_at, delivered_at
3378
+ FROM message_outbox
3379
+ WHERE user_id = ? AND acked_at IS NULL AND id > ?
3380
+ ORDER BY id ASC
3381
+ LIMIT ?`
3382
+ ).bind(userId, sinceId, limit).all();
3383
+ return res.results || [];
3384
+ }
3385
+ /**
3386
+ * 客户端确认收到这一批(幂等:已 ack 的行再 ack 不动)。
3387
+ *
3388
+ * @param {string} userId
3389
+ * @param {string[]} messageIds
3390
+ * @param {number} ackedAt - epoch 毫秒
3391
+ * @returns {Promise<number>} 本次真正被 ack 的行数
3392
+ */
3393
+ async ackOutboxMessages(userId, messageIds, ackedAt) {
3394
+ if (!messageIds || messageIds.length === 0) return 0;
3395
+ const placeholders = messageIds.map(() => "?").join(", ");
3396
+ const res = await this._db.prepare(
3397
+ `UPDATE message_outbox SET acked_at = ?
3398
+ WHERE user_id = ? AND acked_at IS NULL AND message_id IN (${placeholders})`
3399
+ ).bind(ackedAt, userId, ...messageIds).run();
3400
+ return res.meta.changes || 0;
3401
+ }
3402
+ /**
3403
+ * outbox 的例行清理(run-tick 每跳顺手调):已 ack 的行留短一些,未 ack 的
3404
+ * 也不无限留(Web Push TTL 上限四周,比它更老的推送谁也收不到了)。
3405
+ *
3406
+ * @param {{ ackedBeforeMs?: number, allBeforeMs?: number }} opts - epoch 毫秒阈值
3407
+ * @returns {Promise<number>} 删掉的行数
3408
+ */
3409
+ async cleanupOutbox({ ackedBeforeMs, allBeforeMs } = {}) {
3410
+ let deleted = 0;
3411
+ if (Number.isFinite(ackedBeforeMs)) {
3412
+ const res = await this._db.prepare(
3413
+ "DELETE FROM message_outbox WHERE acked_at IS NOT NULL AND acked_at < ?"
3414
+ ).bind(ackedBeforeMs).run();
3415
+ deleted += res.meta.changes || 0;
3416
+ }
3417
+ if (Number.isFinite(allBeforeMs)) {
3418
+ const res = await this._db.prepare(
3419
+ "DELETE FROM message_outbox WHERE created_at < ?"
3420
+ ).bind(allBeforeMs).run();
3421
+ deleted += res.meta.changes || 0;
3422
+ }
3423
+ return deleted;
3424
+ }
2865
3425
  };
2866
3426
  function createD1Adapter(db) {
2867
3427
  if (!db || typeof db.prepare !== "function") {
@@ -2981,12 +3541,6 @@ function err2(status, code, message, details) {
2981
3541
  const error = details === void 0 ? { code, message } : { code, message, details };
2982
3542
  return { status, body: { success: false, error } };
2983
3543
  }
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
3544
  function rejectEntry(entry, index, code, message, extra) {
2991
3545
  const rejection = { index, code, message, ...extra || {} };
2992
3546
  if (entry && typeof entry === "object") {
@@ -3021,6 +3575,10 @@ function validateEntry(entry, index, maxValueBytes) {
3021
3575
  if (!Number.isInteger(entry.updatedAt) || entry.updatedAt <= 0) {
3022
3576
  return rejectEntry(entry, index, "INVALID_STATE_UPDATED_AT", `entries[${index}].updatedAt \u5FC5\u987B\u662F\u6B63\u6574\u6570\uFF08epoch \u6BEB\u79D2\uFF09`);
3023
3577
  }
3578
+ const guard = entry.version ?? entry.builtAt;
3579
+ if (guard !== void 0 && (!Number.isInteger(guard) || guard <= 0)) {
3580
+ 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`);
3581
+ }
3024
3582
  return null;
3025
3583
  }
3026
3584
  function createClientStateHandler(ctx) {
@@ -3031,7 +3589,7 @@ function createClientStateHandler(ctx) {
3031
3589
  if (getHeader(headers, "x-payload-encrypted") !== "true") {
3032
3590
  return err2(400, "ENCRYPTION_REQUIRED", "\u8BF7\u6C42\u4F53\u5FC5\u987B\u52A0\u5BC6");
3033
3591
  }
3034
- const gate = requireUserId2(headers);
3592
+ const gate = requireUserId(headers);
3035
3593
  if (gate.error) return gate.error;
3036
3594
  const { userId } = gate;
3037
3595
  if (getHeader(headers, "x-encryption-version") !== "1") {
@@ -3062,14 +3620,20 @@ function createClientStateHandler(ctx) {
3062
3620
  const rejected = [];
3063
3621
  for (let i = 0; i < entries.length; i++) {
3064
3622
  const rejection = validateEntry(entries[i], i, maxValueBytes);
3065
- if (rejection) rejected.push(rejection);
3066
- else accepted.push(entries[i]);
3623
+ if (rejection) {
3624
+ rejected.push(rejection);
3625
+ } else {
3626
+ const entry = entries[i];
3627
+ const guard = entry.version ?? entry.builtAt;
3628
+ accepted.push(guard !== void 0 ? { ...entry, version: guard } : entry);
3629
+ }
3067
3630
  }
3068
3631
  if (typeof db.upsertClientState !== "function") {
3069
3632
  return err2(501, "CLIENT_STATE_NOT_SUPPORTED", "\u5F53\u524D\u6570\u636E\u5E93\u9002\u914D\u5668\u4E0D\u652F\u6301 client_state");
3070
3633
  }
3071
- const { upserted, skipped } = await writeClientStateEntries({ db, userId, userKey, entries: accepted });
3634
+ const { upserted, skipped, skippedEntries } = await writeClientStateEntries({ db, userId, userKey, entries: accepted });
3072
3635
  const data = { upserted, skipped };
3636
+ if (skippedEntries.length > 0) data.skippedEntries = skippedEntries;
3073
3637
  if (rejected.length > 0) data.rejected = rejected;
3074
3638
  return { status: 200, body: { success: true, data } };
3075
3639
  }
@@ -3077,7 +3641,7 @@ function createClientStateHandler(ctx) {
3077
3641
  const tenantResult = await ctx.tenantManager.resolveTenant(headers);
3078
3642
  if (!tenantResult.ok) return tenantResult.error;
3079
3643
  const { db, masterKey } = tenantResult.context;
3080
- const gate = requireUserId2(headers);
3644
+ const gate = requireUserId(headers);
3081
3645
  if (gate.error) return gate.error;
3082
3646
  const { userId } = gate;
3083
3647
  const namespace = new URL(url, "https://dummy").searchParams.get("namespace") || "";
@@ -3102,7 +3666,7 @@ function createClientStateHandler(ctx) {
3102
3666
  const tenantResult = await ctx.tenantManager.resolveTenant(headers);
3103
3667
  if (!tenantResult.ok) return tenantResult.error;
3104
3668
  const { db } = tenantResult.context;
3105
- const gate = requireUserId2(headers);
3669
+ const gate = requireUserId(headers);
3106
3670
  if (gate.error) return gate.error;
3107
3671
  const { userId } = gate;
3108
3672
  if (typeof db.clearClientState !== "function") {
@@ -3115,7 +3679,7 @@ function createClientStateHandler(ctx) {
3115
3679
  }
3116
3680
 
3117
3681
  // src/server/lib/version.js
3118
- var SERVER_VERSION = true ? "2.6.0-next.13" : "0.0.0-dev";
3682
+ var SERVER_VERSION = true ? "2.6.0-next.15" : "0.0.0-dev";
3119
3683
 
3120
3684
  // src/server/handlers/capabilities.js
3121
3685
  var SERVER_FEATURES = Object.freeze([
@@ -3158,7 +3722,33 @@ var SERVER_FEATURES = Object.freeze([
3158
3722
  // runScheduledTick 认 serializeBy:同一分组的任务同时只跑一条,跨跳也算。
3159
3723
  "tick-serialize-by",
3160
3724
  // 一次 fire 无论什么结局都调 onFireSettled(发完 / 跳过 / 抛错)。
3161
- "fire-settled-hook"
3725
+ "fire-settled-hook",
3726
+ // PUT /client-state 的 entry 认 version / builtAt 护栏(按内容新旧比较),
3727
+ // 被拦下的 key 在 data.skippedEntries 逐条回报。
3728
+ "client-state-version-guard",
3729
+ // POST /schedule-message 认 immediate: true(不排未来,下一跳 cron 直接触发)。
3730
+ "schedule-immediate",
3731
+ // POST /schedule-message 认 supersedesUuid(建新任务的同一事务里取消旧的)。
3732
+ "schedule-supersede",
3733
+ // 投递期间租约按心跳滚动续租,isolate 死亡后任务 ~90s 内被下一跳接手。
3734
+ "tick-lease-heartbeat",
3735
+ // 导出 runTask(ctx, uuid):单任务投递入口(CF Queue 消费者用)。
3736
+ "run-task-entrypoint",
3737
+ // 任务行有 last_error 列(脱敏失败摘要);GET /message 对已失败的行也透出
3738
+ // (409 的 error.details.lastError)。
3739
+ "task-last-error",
3740
+ // 导出 NonRetryableError:hook 抛它 → 直接终审处置,不进重试阶梯。
3741
+ "non-retryable-error",
3742
+ // fire ctx(fireCtx / sessionCtx)带 cancelTask / renewTask。
3743
+ "agentic-cancel-renew-task",
3744
+ // 服务端消息收件箱:push 发送前落 message_outbox,GET /outbox + POST
3745
+ // /outbox/ack 取代「猜哪些没收到」的补收对账。
3746
+ "message-outbox",
3747
+ // onFireSettled 载荷带解密 metadata 与最后一轮 usage;sessionCtx 带 usage。
3748
+ "fire-settled-metadata",
3749
+ "hook-usage",
3750
+ // schedule-message 认 llmExtraBody(原样展开进 LLM 请求体,核心字段优先)。
3751
+ "llm-extra-body"
3162
3752
  ]);
3163
3753
  function createCapabilitiesHandler(ctx) {
3164
3754
  async function GET(url, headers) {
@@ -3175,6 +3765,116 @@ function createCapabilitiesHandler(ctx) {
3175
3765
  return { GET };
3176
3766
  }
3177
3767
 
3768
+ // src/server/handlers/outbox.js
3769
+ var MAX_OUTBOX_PAGE_SIZE = 100;
3770
+ var DEFAULT_OUTBOX_PAGE_SIZE = 50;
3771
+ var MAX_OUTBOX_ACK_IDS = 200;
3772
+ function err3(status, code, message, details) {
3773
+ const error = details === void 0 ? { code, message } : { code, message, details };
3774
+ return { status, body: { success: false, error } };
3775
+ }
3776
+ function createOutboxHandler(ctx) {
3777
+ async function GET(url, headers) {
3778
+ const tenantResult = await ctx.tenantManager.resolveTenant(headers);
3779
+ if (!tenantResult.ok) return tenantResult.error;
3780
+ const { db, masterKey } = tenantResult.context;
3781
+ const gate = requireUserId(headers);
3782
+ if (gate.error) return gate.error;
3783
+ const { userId } = gate;
3784
+ if (typeof db.listUnackedOutbox !== "function") {
3785
+ return err3(501, "OUTBOX_NOT_SUPPORTED", "\u5F53\u524D\u6570\u636E\u5E93\u9002\u914D\u5668\u4E0D\u652F\u6301 message_outbox");
3786
+ }
3787
+ const params = new URL(url, "https://dummy").searchParams;
3788
+ const sinceRaw = params.get("since");
3789
+ const since = sinceRaw == null || sinceRaw === "" ? 0 : Number(sinceRaw);
3790
+ if (!Number.isInteger(since) || since < 0) {
3791
+ return err3(400, "INVALID_OUTBOX_CURSOR", "since \u5FC5\u987B\u662F\u975E\u8D1F\u6574\u6570\uFF08\u4E0A\u4E00\u9875\u54CD\u5E94\u91CC\u7684 cursor\uFF09");
3792
+ }
3793
+ const limitRaw = params.get("limit");
3794
+ let limit = limitRaw == null || limitRaw === "" ? DEFAULT_OUTBOX_PAGE_SIZE : Number(limitRaw);
3795
+ if (!Number.isInteger(limit) || limit <= 0) {
3796
+ return err3(400, "INVALID_OUTBOX_LIMIT", `limit \u5FC5\u987B\u662F 1-${MAX_OUTBOX_PAGE_SIZE} \u7684\u6574\u6570`);
3797
+ }
3798
+ limit = Math.min(limit, MAX_OUTBOX_PAGE_SIZE);
3799
+ const userKey = await deriveUserEncryptionKey(userId, masterKey);
3800
+ const rows = await db.listUnackedOutbox(userId, since, limit);
3801
+ const entries = [];
3802
+ let cursor = since;
3803
+ for (const row of rows) {
3804
+ cursor = Math.max(cursor, row.id);
3805
+ let push;
3806
+ try {
3807
+ push = JSON.parse(await decryptFromStorage(row.payload, userKey));
3808
+ } catch (error) {
3809
+ console.warn("[amsg-server] outbox \u884C\u89E3\u5BC6\u5931\u8D25\uFF08\u5DF2\u8DF3\u8FC7\uFF09:", row.id, error && error.message);
3810
+ continue;
3811
+ }
3812
+ entries.push({
3813
+ id: row.id,
3814
+ messageId: row.message_id,
3815
+ taskUuid: row.task_uuid ?? null,
3816
+ sessionId: row.session_id ?? null,
3817
+ messageIndex: row.message_index ?? null,
3818
+ totalMessages: row.total_messages ?? null,
3819
+ createdAt: row.created_at,
3820
+ deliveredAt: row.delivered_at ?? null,
3821
+ push
3822
+ });
3823
+ }
3824
+ const data = {
3825
+ entries,
3826
+ cursor,
3827
+ // 按「捞满一页」判断还有没有下一页;解密失败被跳过的行不影响游标推进。
3828
+ hasMore: rows.length === limit
3829
+ };
3830
+ const encryptedResponse = await encryptPayload(data, userKey);
3831
+ return { status: 200, body: { success: true, encrypted: true, version: 1, data: encryptedResponse } };
3832
+ }
3833
+ async function POST(headers, body) {
3834
+ const tenantResult = await ctx.tenantManager.resolveTenant(headers);
3835
+ if (!tenantResult.ok) return tenantResult.error;
3836
+ const { db, masterKey } = tenantResult.context;
3837
+ if (getHeader(headers, "x-payload-encrypted") !== "true") {
3838
+ return err3(400, "ENCRYPTION_REQUIRED", "\u8BF7\u6C42\u4F53\u5FC5\u987B\u52A0\u5BC6");
3839
+ }
3840
+ const gate = requireUserId(headers);
3841
+ if (gate.error) return gate.error;
3842
+ const { userId } = gate;
3843
+ if (getHeader(headers, "x-encryption-version") !== "1") {
3844
+ return err3(400, "UNSUPPORTED_ENCRYPTION_VERSION", "\u52A0\u5BC6\u7248\u672C\u4E0D\u652F\u6301");
3845
+ }
3846
+ const parsedBody = parseEncryptedBody(body);
3847
+ if (!parsedBody.ok) return { status: 400, body: { success: false, error: parsedBody.error } };
3848
+ const userKey = await deriveUserEncryptionKey(userId, masterKey);
3849
+ let payload;
3850
+ try {
3851
+ payload = await decryptPayload(parsedBody.data, userKey);
3852
+ } catch (error) {
3853
+ if (error instanceof SyntaxError) {
3854
+ return err3(400, "INVALID_PAYLOAD_FORMAT", "\u89E3\u5BC6\u540E\u7684\u6570\u636E\u4E0D\u662F\u6709\u6548 JSON");
3855
+ }
3856
+ return err3(400, "DECRYPTION_FAILED", "\u8BF7\u6C42\u4F53\u89E3\u5BC6\u5931\u8D25");
3857
+ }
3858
+ if (!isPlainObject(payload)) return err3(400, "INVALID_PAYLOAD_FORMAT", "\u89E3\u5BC6\u540E\u7684\u6570\u636E\u5FC5\u987B\u662F JSON \u5BF9\u8C61");
3859
+ const messageIds = payload.messageIds;
3860
+ if (!Array.isArray(messageIds) || messageIds.length === 0) {
3861
+ return err3(400, "INVALID_OUTBOX_ACK", "messageIds \u5FC5\u987B\u662F\u975E\u7A7A\u6570\u7EC4");
3862
+ }
3863
+ if (messageIds.length > MAX_OUTBOX_ACK_IDS) {
3864
+ return err3(400, "TOO_MANY_OUTBOX_ACK_IDS", `\u5355\u6B21\u6700\u591A ack ${MAX_OUTBOX_ACK_IDS} \u6761`, { count: messageIds.length });
3865
+ }
3866
+ if (!messageIds.every((id) => typeof id === "string" && id.trim())) {
3867
+ return err3(400, "INVALID_OUTBOX_ACK", "messageIds \u7684\u6BCF\u4E00\u9879\u5FC5\u987B\u662F\u975E\u7A7A\u5B57\u7B26\u4E32");
3868
+ }
3869
+ if (typeof db.ackOutboxMessages !== "function") {
3870
+ return err3(501, "OUTBOX_NOT_SUPPORTED", "\u5F53\u524D\u6570\u636E\u5E93\u9002\u914D\u5668\u4E0D\u652F\u6301 message_outbox");
3871
+ }
3872
+ const acked = await db.ackOutboxMessages(userId, messageIds, Date.now());
3873
+ return { status: 200, body: { success: true, data: { acked } } };
3874
+ }
3875
+ return { GET, POST };
3876
+ }
3877
+
3178
3878
  // src/server/single-user.js
3179
3879
  function createSingleUserServer(config) {
3180
3880
  if (!config || !config.db) throw new Error("[amsg-server single-user] config.db is required");
@@ -3221,63 +3921,8 @@ function createSingleUserServer(config) {
3221
3921
  vapidPublicKey: createVapidPublicKeyHandler(ctx),
3222
3922
  clientState: createClientStateHandler(ctx),
3223
3923
  pushSubscription: createPushSubscriptionHandler(ctx),
3224
- capabilities: createCapabilitiesHandler(ctx)
3225
- }
3226
- };
3227
- }
3228
-
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
- };
3249
- }
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;
3262
- }
3263
- }
3264
- return sharedSendWebPush(args);
3265
- }
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
- });
3924
+ capabilities: createCapabilitiesHandler(ctx),
3925
+ outbox: createOutboxHandler(ctx)
3281
3926
  }
3282
3927
  };
3283
3928
  }
@@ -3298,7 +3943,17 @@ var CORS_ALLOW_HEADERS = "Content-Type, X-User-Id, X-Payload-Encrypted, X-Encryp
3298
3943
  var CORS_ALLOW_METHODS = "GET, POST, PUT, DELETE, OPTIONS";
3299
3944
  function corsHeadersFor(cors, requestOrigin) {
3300
3945
  if (!cors || cors.origin == null) return null;
3301
- const allowOrigin = typeof cors.origin === "function" ? cors.origin(requestOrigin) || null : cors.origin;
3946
+ let allowOrigin;
3947
+ if (typeof cors.origin === "function") {
3948
+ try {
3949
+ allowOrigin = cors.origin(requestOrigin) || null;
3950
+ } catch (error) {
3951
+ console.warn("[amsg single-user] cors.origin \u56DE\u8C03\u629B\u9519\uFF0C\u6309\u4E0D\u653E\u884C\u5904\u7406:", error && error.message);
3952
+ allowOrigin = null;
3953
+ }
3954
+ } else {
3955
+ allowOrigin = cors.origin;
3956
+ }
3302
3957
  if (!allowOrigin) return null;
3303
3958
  const headers = {
3304
3959
  "Access-Control-Allow-Origin": allowOrigin,
@@ -3367,6 +4022,10 @@ function createSingleUserCloudflareWorker(buildConfig) {
3367
4022
  result = await server.handlers.clientState.GET(url, headers);
3368
4023
  } else if (method === "DELETE" && pathname.endsWith("/client-state")) {
3369
4024
  result = await server.handlers.clientState.DELETE(url, headers);
4025
+ } else if (method === "GET" && pathname.endsWith("/outbox")) {
4026
+ result = await server.handlers.outbox.GET(url, headers);
4027
+ } else if (method === "POST" && pathname.endsWith("/outbox/ack")) {
4028
+ result = await server.handlers.outbox.POST(headers, await request.text());
3370
4029
  } else if (method === "PUT" && pathname.endsWith("/push-subscription")) {
3371
4030
  result = await server.handlers.pushSubscription.PUT(headers, await request.text());
3372
4031
  } else if (method === "GET" && pathname.endsWith("/push-subscription")) {
@@ -3383,7 +4042,13 @@ function createSingleUserCloudflareWorker(buildConfig) {
3383
4042
  }
3384
4043
  }
3385
4044
  async function scheduled(event, env) {
3386
- const cfg = await resolveConfig(env);
4045
+ let cfg;
4046
+ try {
4047
+ cfg = await resolveConfig(env);
4048
+ } catch (error) {
4049
+ console.error("[amsg single-user] scheduled(): config build failed; skipping tick:", error && error.message);
4050
+ return;
4051
+ }
3387
4052
  const vapid = cfg.vapid || {};
3388
4053
  if (!cfg.webpush || !vapid.email || !vapid.publicKey || !vapid.privateKey) {
3389
4054
  console.error("[amsg single-user] scheduled(): VAPID/webpush not configured; skipping tick");
@@ -3421,8 +4086,12 @@ function createSingleUserCloudflareWorker(buildConfig) {
3421
4086
  // 分组串行:(task) => 分组标识 | null。同一分组的任务同时只跑一条,
3422
4087
  // 跨跳也算(见 lib/run-tick.js)。不配 = 全并发,与以前一致。
3423
4088
  serializeBy: cfg.serializeBy,
3424
- // 任务占位租期(默认 10 分钟,随 totalTimeoutMs 抬高)。
3425
- claimLeaseMs: cfg.claimLeaseMs
4089
+ // 任务占位租期(只在心跳关掉时生效;默认 10 分钟,随 totalTimeoutMs 抬高)。
4090
+ claimLeaseMs: cfg.claimLeaseMs,
4091
+ // 租约心跳间隔(默认 30s;0 = 关掉心跳,退回一次性长租约)。
4092
+ leaseHeartbeatMs: cfg.leaseHeartbeatMs,
4093
+ // 补发新鲜度阈值(默认 60 分钟;见 lib/run-tick.js 的 STALE_AFTER_MS)。
4094
+ staleAfterMs: cfg.staleAfterMs
3426
4095
  });
3427
4096
  } catch (error) {
3428
4097
  console.error("[amsg single-user] scheduled(): tick failed:", error && error.message);
@@ -3431,14 +4100,70 @@ function createSingleUserCloudflareWorker(buildConfig) {
3431
4100
  return { fetch, scheduled };
3432
4101
  }
3433
4102
 
4103
+ // src/server/lib/webpush-webcrypto.js
4104
+ import { sendWebPush as sharedSendWebPush } from "@rei-standard/amsg-shared";
4105
+ import { buildVapidJwt, verifyVapidJwt, hmacSha256 as hmacSha2562 } from "@rei-standard/amsg-shared";
4106
+ var WEB_PUSH_MAX_BODY_BYTES = 4096;
4107
+ var WEB_PUSH_ENCRYPTION_OVERHEAD_BYTES = 16 + 4 + 1 + 65 + 1 + 16;
4108
+ var MAX_PUSH_PAYLOAD_BYTES = WEB_PUSH_MAX_BODY_BYTES - WEB_PUSH_ENCRYPTION_OVERHEAD_BYTES;
4109
+ var PUSH_ENVELOPE_RESERVED_BYTES = 384;
4110
+ var payloadEncoder = new TextEncoder();
4111
+ function measurePushPayload(payload, options) {
4112
+ const reserveEnvelope = !!(options && options.reserveEnvelope);
4113
+ const bytes = payloadEncoder.encode(typeof payload === "string" ? payload : String(payload)).length;
4114
+ const envelopeReservedBytes = reserveEnvelope ? PUSH_ENVELOPE_RESERVED_BYTES : 0;
4115
+ const maxBytes = MAX_PUSH_PAYLOAD_BYTES - envelopeReservedBytes;
4116
+ return {
4117
+ bytes,
4118
+ maxBytes,
4119
+ remainingBytes: maxBytes - bytes,
4120
+ withinLimit: bytes <= maxBytes,
4121
+ envelopeReservedBytes
4122
+ };
4123
+ }
4124
+ async function sendWebPush(args) {
4125
+ const { payload } = args || {};
4126
+ if (typeof payload === "string") {
4127
+ const size = measurePushPayload(payload);
4128
+ if (!size.withinLimit) {
4129
+ const err4 = new Error(
4130
+ `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)`
4131
+ );
4132
+ err4.code = "PUSH_PAYLOAD_TOO_LARGE";
4133
+ err4.bytes = size.bytes;
4134
+ err4.maxBytes = MAX_PUSH_PAYLOAD_BYTES;
4135
+ throw err4;
4136
+ }
4137
+ }
4138
+ return sharedSendWebPush(args);
4139
+ }
4140
+ var SCHEDULED_DEFAULT_TTL = 2419200;
4141
+ function createWebCryptoWebPush(vapid = {}, { ttl = SCHEDULED_DEFAULT_TTL } = {}) {
4142
+ return {
4143
+ async sendNotification(subscription, payload) {
4144
+ return sendWebPush({
4145
+ subscription,
4146
+ payload,
4147
+ ttl,
4148
+ vapid: {
4149
+ email: vapid.email,
4150
+ publicKey: vapid.publicKey,
4151
+ privateKey: vapid.privateKey
4152
+ },
4153
+ fetch: globalThis.fetch
4154
+ });
4155
+ }
4156
+ };
4157
+ }
4158
+
3434
4159
  export {
3435
4160
  isValidTimeZoneId,
3436
4161
  advanceOccurrence,
3437
4162
  planNextOccurrence,
3438
4163
  nextFutureOccurrence,
4164
+ isValidUrl,
3439
4165
  validateAvatarUrl,
3440
4166
  isValidISO8601,
3441
- isValidUrl,
3442
4167
  isValidUUID,
3443
4168
  isValidUUIDv4,
3444
4169
  validateSplitPattern,
@@ -3456,8 +4181,19 @@ export {
3456
4181
  encryptForStorage,
3457
4182
  decryptFromStorage,
3458
4183
  createGetUserKeyHandler,
4184
+ DEFAULT_MAX_TOOL_ITERATIONS,
4185
+ DEFAULT_TOTAL_TIMEOUT_MS,
4186
+ DEFAULT_MAX_SCHEDULED_TASKS_PER_FIRE,
4187
+ MIN_SCHEDULE_LEAD_MS,
4188
+ NonRetryableError,
4189
+ isNonRetryableError,
4190
+ sanitizeErrorSummary,
3459
4191
  createScheduleMessageHandler,
4192
+ DEFAULT_CLAIM_LEASE_MS,
4193
+ DEFAULT_LEASE_HEARTBEAT_MS,
4194
+ DEFAULT_HEARTBEAT_LEASE_TTL_MS,
3460
4195
  runScheduledTick,
4196
+ runTask,
3461
4197
  createUpdateMessageHandler,
3462
4198
  createCancelMessageHandler,
3463
4199
  createMessagesHandler,
@@ -3465,11 +4201,13 @@ export {
3465
4201
  createPushSubscriptionHandler,
3466
4202
  createD1Adapter,
3467
4203
  createSingleUserServer,
4204
+ CORS_ALLOW_HEADERS,
4205
+ CORS_ALLOW_METHODS,
4206
+ createSingleUserCloudflareWorker,
3468
4207
  WEB_PUSH_MAX_BODY_BYTES,
3469
4208
  WEB_PUSH_ENCRYPTION_OVERHEAD_BYTES,
3470
4209
  MAX_PUSH_PAYLOAD_BYTES,
3471
4210
  PUSH_ENVELOPE_RESERVED_BYTES,
3472
4211
  measurePushPayload,
3473
- createWebCryptoWebPush,
3474
- createSingleUserCloudflareWorker
4212
+ createWebCryptoWebPush
3475
4213
  };