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