@rei-standard/amsg-server 2.6.0-next.5 → 2.6.0-next.7

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.
@@ -559,6 +559,79 @@ async function resolveClientStateEntries(rows, fetchChunkRows, decryptValue) {
559
559
  return entries;
560
560
  }
561
561
 
562
+ // src/server/lib/client-state-store.js
563
+ var utf82 = new TextEncoder();
564
+ var MAX_STATE_ENTRIES_PER_BATCH = 200;
565
+ var MAX_NAMESPACE_CHARS = 128;
566
+ var MAX_KEY_CHARS = 256;
567
+ function stateValueBytes(value) {
568
+ return utf82.encode(value).length;
569
+ }
570
+ async function writeClientStateEntries({ db, userId, userKey, entries }) {
571
+ const physicalRows = [];
572
+ const cleanups = [];
573
+ const rootRowIndexes = [];
574
+ let deleted = 0;
575
+ for (const entry of entries) {
576
+ cleanups.push({
577
+ namespace: chunkNamespaceFor(entry.namespace),
578
+ keyPrefix: chunkKeyPrefixFor(entry.key),
579
+ updatedAt: entry.updatedAt
580
+ });
581
+ if (entry.value === null) {
582
+ cleanups.push({
583
+ namespace: entry.namespace,
584
+ key: entry.key,
585
+ updatedAt: entry.updatedAt
586
+ });
587
+ deleted++;
588
+ continue;
589
+ }
590
+ rootRowIndexes.push(physicalRows.length);
591
+ if (stateValueBytes(entry.value) <= STATE_CHUNK_SLICE_BYTES) {
592
+ physicalRows.push({
593
+ namespace: entry.namespace,
594
+ key: entry.key,
595
+ value: await encryptForStorage(entry.value, userKey),
596
+ updatedAt: entry.updatedAt
597
+ });
598
+ } else {
599
+ const slices = splitStateValue(entry.value);
600
+ physicalRows.push({
601
+ namespace: entry.namespace,
602
+ key: entry.key,
603
+ value: buildChunkedRootValue(slices.length),
604
+ updatedAt: entry.updatedAt
605
+ });
606
+ const encryptedSlices = await Promise.all(slices.map((slice) => encryptForStorage(slice, userKey)));
607
+ for (let c = 0; c < encryptedSlices.length; c++) {
608
+ physicalRows.push({
609
+ namespace: chunkNamespaceFor(entry.namespace),
610
+ key: chunkKeyFor(entry.key, c),
611
+ value: encryptedSlices[c],
612
+ updatedAt: entry.updatedAt
613
+ });
614
+ }
615
+ }
616
+ }
617
+ if (physicalRows.length === 0 && cleanups.length === 0) {
618
+ return { upserted: 0, skipped: 0, deleted: 0 };
619
+ }
620
+ const result = await db.upsertClientState(userId, physicalRows, cleanups);
621
+ let upserted = 0;
622
+ let skipped = 0;
623
+ if (Array.isArray(result.outcomes) && result.outcomes.length === physicalRows.length) {
624
+ for (const rootIndex of rootRowIndexes) {
625
+ if (result.outcomes[rootIndex]) upserted++;
626
+ else skipped++;
627
+ }
628
+ } else {
629
+ upserted = result.upserted;
630
+ skipped = result.skipped;
631
+ }
632
+ return { upserted, skipped, deleted };
633
+ }
634
+
562
635
  // src/server/lib/agentic-fire.js
563
636
  var DEFAULT_MAX_TOOL_ITERATIONS = 5;
564
637
  var DEFAULT_TOTAL_TIMEOUT_MS = 24e4;
@@ -632,11 +705,64 @@ async function runAgenticFire({ task, decryptedPayload, userKey, ctx }) {
632
705
  (value) => decryptFromStorage(value, userKey)
633
706
  );
634
707
  };
708
+ const maxStateValueBytes = Number.isInteger(ctx.maxStateValueBytes) && ctx.maxStateValueBytes > 0 ? ctx.maxStateValueBytes : DEFAULT_MAX_STATE_VALUE_BYTES;
709
+ const writeState = async (namespace, entries) => {
710
+ if (typeof namespace !== "string" || !namespace.trim()) {
711
+ throw new TypeError("writeState(namespace, entries) requires a non-empty string namespace");
712
+ }
713
+ if (namespace.length > MAX_NAMESPACE_CHARS || INTERNAL_STATE_CHAR_RE.test(namespace)) {
714
+ throw new TypeError(
715
+ `writeState: namespace \u5FC5\u987B\u662F 1-${MAX_NAMESPACE_CHARS} \u5B57\u7B26\u4E14\u4E0D\u542B\u63A7\u5236\u5B57\u7B26\uFF08\\u0000-\\u001f \u4E3A\u5E93\u5185\u90E8\u4FDD\u7559\uFF09`
716
+ );
717
+ }
718
+ if (!Array.isArray(entries)) {
719
+ throw new TypeError("writeState(namespace, entries) requires an array of { key, value }");
720
+ }
721
+ if (entries.length === 0) return { upserted: 0, skipped: 0, deleted: 0 };
722
+ if (entries.length > MAX_STATE_ENTRIES_PER_BATCH) {
723
+ throw new RangeError(`writeState: \u5355\u6B21\u6700\u591A ${MAX_STATE_ENTRIES_PER_BATCH} \u6761\uFF0C\u6536\u5230 ${entries.length} \u6761`);
724
+ }
725
+ if (!ctx.db || typeof ctx.db.upsertClientState !== "function") {
726
+ throw new Error("AGENTIC_STATE_WRITE_UNSUPPORTED: \u5F53\u524D\u6570\u636E\u5E93\u9002\u914D\u5668\u4E0D\u652F\u6301 client_state \u5199\u5165");
727
+ }
728
+ const now = nowFn();
729
+ const normalized2 = entries.map((entry, index) => {
730
+ if (!entry || typeof entry !== "object") {
731
+ throw new TypeError(`writeState: entries[${index}] \u5FC5\u987B\u662F\u5BF9\u8C61`);
732
+ }
733
+ if (typeof entry.key !== "string" || !entry.key.trim() || entry.key.length > MAX_KEY_CHARS) {
734
+ throw new TypeError(`writeState: entries[${index}].key \u5FC5\u987B\u662F 1-${MAX_KEY_CHARS} \u5B57\u7B26\u7684\u5B57\u7B26\u4E32`);
735
+ }
736
+ if (INTERNAL_STATE_CHAR_RE.test(entry.key)) {
737
+ throw new TypeError(`writeState: entries[${index}].key \u4E0D\u80FD\u5305\u542B\u63A7\u5236\u5B57\u7B26\uFF08\\u0000-\\u001f \u4E3A\u5E93\u5185\u90E8\u4FDD\u7559\uFF09`);
738
+ }
739
+ if (entry.value !== null && typeof entry.value !== "string") {
740
+ throw new TypeError(`writeState: entries[${index}].value \u5FC5\u987B\u662F\u5B57\u7B26\u4E32\uFF08\u5BBF\u4E3B\u81EA\u884C\u5E8F\u5217\u5316\uFF09\uFF0C\u6216 null \u8868\u793A\u5220\u9664`);
741
+ }
742
+ if (typeof entry.value === "string") {
743
+ const bytes = stateValueBytes(entry.value);
744
+ if (bytes > maxStateValueBytes) {
745
+ throw new RangeError(`writeState: entries[${index}].value \u4E3A ${bytes} \u5B57\u8282\uFF0C\u8D85\u8FC7\u5355\u6761\u4E0A\u9650 ${maxStateValueBytes} \u5B57\u8282`);
746
+ }
747
+ }
748
+ if (entry.updatedAt !== void 0 && (!Number.isInteger(entry.updatedAt) || entry.updatedAt <= 0)) {
749
+ throw new TypeError(`writeState: entries[${index}].updatedAt \u5FC5\u987B\u662F\u6B63\u6574\u6570\uFF08epoch \u6BEB\u79D2\uFF09`);
750
+ }
751
+ return {
752
+ namespace,
753
+ key: entry.key,
754
+ value: entry.value,
755
+ updatedAt: entry.updatedAt ?? now
756
+ };
757
+ });
758
+ return writeClientStateEntries({ db: ctx.db, userId: task.user_id, userKey, entries: normalized2 });
759
+ };
635
760
  const scratch = {};
636
761
  const fireCtx = Object.freeze({
637
762
  task: buildHookTask(task, decryptedPayload),
638
763
  userId: task.user_id,
639
764
  readState,
765
+ writeState,
640
766
  now: new Date(nowFn()),
641
767
  scratch
642
768
  });
@@ -668,16 +794,20 @@ async function runAgenticFire({ task, decryptedPayload, userKey, ctx }) {
668
794
  );
669
795
  const assistantMessage = extractAssistantMessage(llmResponse);
670
796
  messages = [...messages, assistantMessage];
671
- const sessionCtx = buildSessionContext({
672
- sessionId,
673
- messages,
674
- llmResponse,
675
- iteration,
676
- contactName: decryptedPayload.contactName,
677
- avatarUrl: decryptedPayload.avatarUrl || void 0,
678
- charId: decryptedPayload.charId,
679
- metadata: decryptedPayload.metadata,
680
- scratch
797
+ const sessionCtx = Object.freeze({
798
+ ...buildSessionContext({
799
+ sessionId,
800
+ messages,
801
+ llmResponse,
802
+ iteration,
803
+ contactName: decryptedPayload.contactName,
804
+ avatarUrl: decryptedPayload.avatarUrl || void 0,
805
+ charId: decryptedPayload.charId,
806
+ metadata: decryptedPayload.metadata,
807
+ scratch
808
+ }),
809
+ readState,
810
+ writeState
681
811
  });
682
812
  const decision = await hooks.onLLMOutput(sessionCtx);
683
813
  assertValidDecision(decision, { inlineToolCalls: true });
@@ -1091,9 +1221,18 @@ function createScheduleMessageHandler(ctx) {
1091
1221
  }
1092
1222
 
1093
1223
  // src/server/lib/run-tick.js
1224
+ var DEFAULT_CLAIM_LEASE_MS = 10 * 60 * 1e3;
1225
+ var CLAIM_LEASE_MARGIN_MS = 2 * 60 * 1e3;
1226
+ function positiveNumber(value) {
1227
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
1228
+ }
1229
+ function resolveClaimLeaseMs(ctx) {
1230
+ return positiveNumber(ctx.claimLeaseMs) || Math.max(DEFAULT_CLAIM_LEASE_MS, positiveNumber(ctx.totalTimeoutMs) + CLAIM_LEASE_MARGIN_MS);
1231
+ }
1094
1232
  async function runScheduledTick(ctx) {
1095
1233
  const db = ctx.db;
1096
1234
  const masterKey = ctx.masterKey;
1235
+ const claimLeaseMs = resolveClaimLeaseMs(ctx);
1097
1236
  const startTime = Date.now();
1098
1237
  const tasks = await db.getPendingTasks(50);
1099
1238
  const MAX_CONCURRENT = 8;
@@ -1101,19 +1240,29 @@ async function runScheduledTick(ctx) {
1101
1240
  totalTasks: tasks.length,
1102
1241
  successCount: 0,
1103
1242
  failedCount: 0,
1243
+ claimSkippedTasks: 0,
1104
1244
  deletedOnceOffTasks: 0,
1105
1245
  updatedRecurringTasks: 0,
1106
1246
  failedTasks: []
1107
1247
  };
1248
+ const supportsClaim = typeof db.claimTask === "function";
1249
+ async function claimForThisTick(task) {
1250
+ if (!supportsClaim) return true;
1251
+ const leaseUntil = new Date(Date.now() + claimLeaseMs).toISOString();
1252
+ return !!await db.claimTask(task.id, task.next_send_at, leaseUntil);
1253
+ }
1254
+ async function updateAndRelease(taskId, fields) {
1255
+ return db.updateTaskById(taskId, supportsClaim ? { ...fields, lease_until: null } : fields);
1256
+ }
1108
1257
  async function handleDeliveryFailure(task, reason) {
1109
1258
  results.failedCount++;
1110
1259
  try {
1111
1260
  if (task.retry_count >= 3) {
1112
- await db.updateTaskById(task.id, { status: "failed" });
1261
+ await updateAndRelease(task.id, { status: "failed" });
1113
1262
  results.failedTasks.push({ taskId: task.id, reason, retryCount: task.retry_count, status: "permanently_failed" });
1114
1263
  } else {
1115
1264
  const nextRetryTime = new Date(Date.now() + (task.retry_count + 1) * 2 * 60 * 1e3);
1116
- await db.updateTaskById(task.id, { next_send_at: nextRetryTime.toISOString(), retry_count: task.retry_count + 1 });
1265
+ await updateAndRelease(task.id, { next_send_at: nextRetryTime.toISOString(), retry_count: task.retry_count + 1 });
1117
1266
  results.failedTasks.push({ taskId: task.id, reason, retryCount: task.retry_count + 1, nextRetryAt: nextRetryTime.toISOString() });
1118
1267
  }
1119
1268
  } catch (updateError) {
@@ -1124,7 +1273,7 @@ async function runScheduledTick(ctx) {
1124
1273
  results.failedCount++;
1125
1274
  let markedSent = false;
1126
1275
  try {
1127
- await db.updateTaskById(task.id, { status: "sent", retry_count: 0 });
1276
+ await updateAndRelease(task.id, { status: "sent", retry_count: 0 });
1128
1277
  markedSent = true;
1129
1278
  } catch (_markSentError) {
1130
1279
  markedSent = false;
@@ -1137,6 +1286,18 @@ async function runScheduledTick(ctx) {
1137
1286
  });
1138
1287
  }
1139
1288
  async function processTask(task) {
1289
+ let claimed;
1290
+ try {
1291
+ claimed = await claimForThisTick(task);
1292
+ } catch (error) {
1293
+ results.failedCount++;
1294
+ results.failedTasks.push({ taskId: task.id, reason: error.message || "\u4EFB\u52A1\u5360\u4F4D\u5931\u8D25", status: "claim_failed" });
1295
+ return;
1296
+ }
1297
+ if (!claimed) {
1298
+ results.claimSkippedTasks++;
1299
+ return;
1300
+ }
1140
1301
  let sendResult;
1141
1302
  try {
1142
1303
  sendResult = await processSingleMessage(task, { ...ctx, db, masterKey }, masterKey);
@@ -1162,7 +1323,7 @@ async function runScheduledTick(ctx) {
1162
1323
  } else if (decryptedPayload.recurrenceType === "weekly") {
1163
1324
  nextSendAt = new Date(currentSendAt.getTime() + 7 * 24 * 60 * 60 * 1e3);
1164
1325
  }
1165
- await db.updateTaskById(task.id, { next_send_at: nextSendAt.toISOString(), retry_count: 0 });
1326
+ await updateAndRelease(task.id, { next_send_at: nextSendAt.toISOString(), retry_count: 0 });
1166
1327
  results.updatedRecurringTasks++;
1167
1328
  }
1168
1329
  results.successCount++;
@@ -1195,6 +1356,7 @@ async function runScheduledTick(ctx) {
1195
1356
  processedAt: (/* @__PURE__ */ new Date()).toISOString(),
1196
1357
  executionTime,
1197
1358
  details: {
1359
+ claimSkippedTasks: results.claimSkippedTasks,
1198
1360
  deletedOnceOffTasks: results.deletedOnceOffTasks,
1199
1361
  updatedRecurringTasks: results.updatedRecurringTasks,
1200
1362
  failedTasks: results.failedTasks
@@ -1456,12 +1618,20 @@ var SQLITE_TABLE_SQL = `
1456
1618
  encrypted_payload TEXT NOT NULL,
1457
1619
  message_type TEXT NOT NULL CHECK (message_type IN ('fixed', 'prompted', 'auto', 'instant')),
1458
1620
  next_send_at TEXT NOT NULL,
1621
+ lease_until TEXT,
1459
1622
  status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'failed')),
1460
1623
  retry_count INTEGER NOT NULL DEFAULT 0,
1461
1624
  created_at TEXT NOT NULL,
1462
1625
  updated_at TEXT NOT NULL
1463
1626
  )
1464
1627
  `;
1628
+ var SQLITE_MIGRATIONS = [
1629
+ {
1630
+ name: "add_lease_until",
1631
+ sql: "ALTER TABLE scheduled_messages ADD COLUMN lease_until TEXT",
1632
+ description: "Task claim lease (2.6.0)"
1633
+ }
1634
+ ];
1465
1635
  var SQLITE_INDEXES = [
1466
1636
  {
1467
1637
  name: "idx_pending_tasks_optimized",
@@ -1521,6 +1691,7 @@ var UPDATABLE_COLUMNS = /* @__PURE__ */ new Set([
1521
1691
  "encrypted_payload",
1522
1692
  "message_type",
1523
1693
  "next_send_at",
1694
+ "lease_until",
1524
1695
  "status",
1525
1696
  "retry_count",
1526
1697
  "created_at",
@@ -1549,6 +1720,13 @@ var D1Adapter = class {
1549
1720
  async initSchema() {
1550
1721
  await this._db.prepare(SQLITE_TABLE_SQL).run();
1551
1722
  await this._db.prepare(CLIENT_STATE_TABLE_SQL).run();
1723
+ for (const migration of SQLITE_MIGRATIONS) {
1724
+ try {
1725
+ await this._db.prepare(migration.sql).run();
1726
+ } catch (error) {
1727
+ if (!/duplicate column name/i.test(error.message || "")) throw error;
1728
+ }
1729
+ }
1552
1730
  const indexResults = [];
1553
1731
  for (const index of SQLITE_INDEXES) {
1554
1732
  try {
@@ -1566,7 +1744,7 @@ var D1Adapter = class {
1566
1744
  );
1567
1745
  }
1568
1746
  return {
1569
- columnsCreated: 10,
1747
+ columnsCreated: 11,
1570
1748
  indexesCreated: indexResults.filter((r) => r.status === "success").length,
1571
1749
  indexesFailed: indexResults.filter((r) => r.status === "failed").length,
1572
1750
  columns: [],
@@ -1658,15 +1836,53 @@ var D1Adapter = class {
1658
1836
  return res.meta.changes > 0;
1659
1837
  }
1660
1838
  async getPendingTasks(limit = 50) {
1839
+ const now = this._now();
1661
1840
  const res = await this._db.prepare(
1662
1841
  `SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count
1663
1842
  FROM scheduled_messages
1664
1843
  WHERE status = 'pending' AND next_send_at <= ?
1844
+ AND (lease_until IS NULL OR lease_until <= ?)
1665
1845
  ORDER BY next_send_at ASC
1666
1846
  LIMIT ?`
1667
- ).bind(this._now(), limit).all();
1847
+ ).bind(now, now, limit).all();
1668
1848
  return res.results || [];
1669
1849
  }
1850
+ /**
1851
+ * 领取一条到点的任务:在 lease_until 上写下「这条归我管到什么时候」,
1852
+ * 本次投递期间别的 tick 领不走它。
1853
+ *
1854
+ * 租约写在自己的列上,next_send_at 全程不动——那一列是用户设的触发时刻,
1855
+ * 任务列表要读它、循环任务推进下一次也要拿它当基准。
1856
+ *
1857
+ * 两个 tick 抢同一行时只有一个能改到行,另一个拿到 changes = 0,据此跳过。
1858
+ * WHERE 里的两个条件各管一件事:
1859
+ * - lease_until 为空或已过期:没人正在跑这条。领了任务的 tick 中途没了
1860
+ * 也不会把行焊死,租约到期后自然可以被接手。
1861
+ * - next_send_at 等于读这行时看到的值:读出来之后用户又改了排期的话,
1862
+ * 这一跳就不该再按旧时刻发。
1863
+ *
1864
+ * 不加一个 'sending' 状态来表达「正在跑」:建表语句里 status 有
1865
+ * CHECK (status IN ('pending','sent','failed')),加值要重建表。
1866
+ *
1867
+ * expectedNextSendAt 按读到的原样比对,不做时区归一化——老部署里可能还留
1868
+ * 着非归一化写法的行(如 +08:00 结尾),归一化后反而对不上,那条任务会永
1869
+ * 远领不到。
1870
+ *
1871
+ * @param {number} taskId
1872
+ * @param {string} expectedNextSendAt - 读这行时拿到的 next_send_at 原值
1873
+ * @param {string|Date} leaseUntil - 租期末尾
1874
+ * @returns {Promise<boolean>} true = 领到了;false = 别人正拿着租约、排期被改过、或行已不是 pending
1875
+ */
1876
+ async claimTask(taskId, expectedNextSendAt, leaseUntil) {
1877
+ const expected = typeof expectedNextSendAt === "string" ? expectedNextSendAt : this._iso(expectedNextSendAt);
1878
+ const res = await this._db.prepare(
1879
+ `UPDATE scheduled_messages
1880
+ SET lease_until = ?, updated_at = ?
1881
+ WHERE id = ? AND status = 'pending' AND next_send_at = ?
1882
+ AND (lease_until IS NULL OR lease_until <= ?)`
1883
+ ).bind(this._iso(leaseUntil), this._now(), taskId, expected, this._now()).run();
1884
+ return (res.meta.changes || 0) > 0;
1885
+ }
1670
1886
  async listTasks(userId, opts = {}) {
1671
1887
  const { status = "all", limit = 20, offset = 0 } = opts;
1672
1888
  const conditions = ["user_id = ?"];
@@ -1710,9 +1926,15 @@ var D1Adapter = class {
1710
1926
  * than the stored row (updatedAt strictly lower) is skipped; equal or
1711
1927
  * newer overwrites. Values arrive pre-encrypted (the handler encrypts).
1712
1928
  *
1713
- * `cleanups` 是分块存储的清理项(见 lib/state-chunks.js):在同一 batch 里
1714
- * 先于 upsert 执行,按 (namespace, key 前缀) 删掉旧写入留下的切片行;
1715
- * `updated_at <= ?` 条件保证陈旧批次删不动更新写入的行。
1929
+ * `cleanups` 是删除项:在同一 batch 里先于 upsert 执行,`updated_at <= ?`
1930
+ * 条件保证陈旧批次删不动更新写入的行。两种形态——
1931
+ * - `{ namespace, keyPrefix, updatedAt }` 删 key 前缀下的所有行,用来清掉
1932
+ * 大值旧写入留下的切片行(见 lib/state-chunks.js);
1933
+ * - `{ namespace, key, updatedAt }` 删这一个 key,用来删整条状态(前缀会
1934
+ * 连带删掉同前缀的兄弟 key,删单条必须走精确匹配)。
1935
+ *
1936
+ * 删掉一整条状态就是这两种各来一条(切片行走前缀、根行走精确 key),
1937
+ * `entries` 传空数组即可——见 lib/client-state-store.js。
1716
1938
  *
1717
1939
  * Uses D1's batch() — one network round trip for the whole set (implicit
1718
1940
  * transaction). The client calls this endpoint inside its few-seconds
@@ -1722,7 +1944,7 @@ var D1Adapter = class {
1722
1944
  *
1723
1945
  * @param {string} userId
1724
1946
  * @param {Array<{ namespace: string, key: string, value: string, updatedAt: number }>} entries
1725
- * @param {Array<{ namespace: string, keyPrefix: string, updatedAt: number }>} [cleanups]
1947
+ * @param {Array<{ namespace: string, key?: string, keyPrefix?: string, updatedAt: number }>} [cleanups]
1726
1948
  * @returns {Promise<{ upserted: number, skipped: number, outcomes: boolean[] }>}
1727
1949
  * `outcomes[i]` 对应 entries[i] 是否真的写入(changes > 0)。
1728
1950
  */
@@ -1733,12 +1955,12 @@ var D1Adapter = class {
1733
1955
  value = excluded.value,
1734
1956
  updated_at = excluded.updated_at
1735
1957
  WHERE excluded.updated_at >= client_state.updated_at`;
1736
- const CLEANUP_SQL = `DELETE FROM client_state
1958
+ const CLEANUP_PREFIX_SQL = `DELETE FROM client_state
1737
1959
  WHERE user_id = ? AND namespace = ? AND key LIKE ? ESCAPE '\\' AND updated_at <= ?`;
1960
+ const CLEANUP_KEY_SQL = `DELETE FROM client_state
1961
+ WHERE user_id = ? AND namespace = ? AND key = ? AND updated_at <= ?`;
1738
1962
  const buildStatements = () => [
1739
- ...cleanups.map(
1740
- (c) => this._db.prepare(CLEANUP_SQL).bind(userId, c.namespace, `${escapeLikePrefix(c.keyPrefix)}%`, c.updatedAt)
1741
- ),
1963
+ ...cleanups.map((c) => typeof c.key === "string" ? this._db.prepare(CLEANUP_KEY_SQL).bind(userId, c.namespace, c.key, c.updatedAt) : this._db.prepare(CLEANUP_PREFIX_SQL).bind(userId, c.namespace, `${escapeLikePrefix(c.keyPrefix)}%`, c.updatedAt)),
1742
1964
  ...entries.map(
1743
1965
  (entry) => this._db.prepare(UPSERT_SQL).bind(userId, entry.namespace, entry.key, entry.value, entry.updatedAt)
1744
1966
  )
@@ -1901,10 +2123,7 @@ function createVapidPublicKeyHandler(ctx) {
1901
2123
  }
1902
2124
 
1903
2125
  // src/server/handlers/client-state.js
1904
- var MAX_STATE_ENTRIES_PER_REQUEST = 200;
1905
- var MAX_NAMESPACE_CHARS = 128;
1906
- var MAX_KEY_CHARS = 256;
1907
- var utf82 = new TextEncoder();
2126
+ var MAX_STATE_ENTRIES_PER_REQUEST = MAX_STATE_ENTRIES_PER_BATCH;
1908
2127
  function err(status, code, message, details) {
1909
2128
  const error = details === void 0 ? { code, message } : { code, message, details };
1910
2129
  return { status, body: { success: false, error } };
@@ -1942,7 +2161,7 @@ function validateEntry(entry, index, maxValueBytes) {
1942
2161
  if (typeof entry.value !== "string") {
1943
2162
  return rejectEntry(entry, index, "INVALID_STATE_VALUE", `entries[${index}].value \u5FC5\u987B\u662F\u5B57\u7B26\u4E32\uFF08\u5BBF\u4E3B\u81EA\u884C\u5E8F\u5217\u5316\uFF09`);
1944
2163
  }
1945
- const bytes = utf82.encode(entry.value).length;
2164
+ const bytes = stateValueBytes(entry.value);
1946
2165
  if (bytes > maxValueBytes) {
1947
2166
  return rejectEntry(entry, index, "STATE_VALUE_TOO_LARGE", `entries[${index}].value \u8D85\u8FC7\u5355\u6761\u603B\u4E0A\u9650`, { bytes, maxBytes: maxValueBytes });
1948
2167
  }
@@ -1996,56 +2215,7 @@ function createClientStateHandler(ctx) {
1996
2215
  if (typeof db.upsertClientState !== "function") {
1997
2216
  return err(501, "CLIENT_STATE_NOT_SUPPORTED", "\u5F53\u524D\u6570\u636E\u5E93\u9002\u914D\u5668\u4E0D\u652F\u6301 client_state");
1998
2217
  }
1999
- const physicalRows = [];
2000
- const cleanups = [];
2001
- const rootRowIndexes = [];
2002
- for (const entry of accepted) {
2003
- cleanups.push({
2004
- namespace: chunkNamespaceFor(entry.namespace),
2005
- keyPrefix: chunkKeyPrefixFor(entry.key),
2006
- updatedAt: entry.updatedAt
2007
- });
2008
- rootRowIndexes.push(physicalRows.length);
2009
- if (utf82.encode(entry.value).length <= STATE_CHUNK_SLICE_BYTES) {
2010
- physicalRows.push({
2011
- namespace: entry.namespace,
2012
- key: entry.key,
2013
- value: await encryptForStorage(entry.value, userKey),
2014
- updatedAt: entry.updatedAt
2015
- });
2016
- } else {
2017
- const slices = splitStateValue(entry.value);
2018
- physicalRows.push({
2019
- namespace: entry.namespace,
2020
- key: entry.key,
2021
- value: buildChunkedRootValue(slices.length),
2022
- updatedAt: entry.updatedAt
2023
- });
2024
- const encryptedSlices = await Promise.all(slices.map((slice) => encryptForStorage(slice, userKey)));
2025
- for (let c = 0; c < encryptedSlices.length; c++) {
2026
- physicalRows.push({
2027
- namespace: chunkNamespaceFor(entry.namespace),
2028
- key: chunkKeyFor(entry.key, c),
2029
- value: encryptedSlices[c],
2030
- updatedAt: entry.updatedAt
2031
- });
2032
- }
2033
- }
2034
- }
2035
- let upserted = 0;
2036
- let skipped = 0;
2037
- if (physicalRows.length > 0) {
2038
- const result = await db.upsertClientState(userId, physicalRows, cleanups);
2039
- if (Array.isArray(result.outcomes) && result.outcomes.length === physicalRows.length) {
2040
- for (const rootIndex of rootRowIndexes) {
2041
- if (result.outcomes[rootIndex]) upserted++;
2042
- else skipped++;
2043
- }
2044
- } else {
2045
- upserted = result.upserted;
2046
- skipped = result.skipped;
2047
- }
2048
- }
2218
+ const { upserted, skipped } = await writeClientStateEntries({ db, userId, userKey, entries: accepted });
2049
2219
  const data = { upserted, skipped };
2050
2220
  if (rejected.length > 0) data.rejected = rejected;
2051
2221
  return { status: 200, body: { success: true, data } };
@@ -2092,7 +2262,7 @@ function createClientStateHandler(ctx) {
2092
2262
  }
2093
2263
 
2094
2264
  // src/server/lib/version.js
2095
- var SERVER_VERSION = true ? "2.6.0-next.5" : "0.0.0-dev";
2265
+ var SERVER_VERSION = true ? "2.6.0-next.7" : "0.0.0-dev";
2096
2266
 
2097
2267
  // src/server/handlers/capabilities.js
2098
2268
  var SERVER_FEATURES = Object.freeze([
@@ -2101,6 +2271,7 @@ var SERVER_FEATURES = Object.freeze([
2101
2271
  "client-state-partial-failure",
2102
2272
  "agentic-hooks",
2103
2273
  "agentic-scratch",
2274
+ "agentic-write-state",
2104
2275
  "vapid-public-key"
2105
2276
  ]);
2106
2277
  function createCapabilitiesHandler(ctx) {
@@ -2168,6 +2339,19 @@ var NONCE_INFO = utf8("Content-Encoding: nonce\0");
2168
2339
  var VAPID_DEFAULT_TTL = 60;
2169
2340
  var VAPID_TOKEN_LIFETIME = 12 * 3600;
2170
2341
  var RECORD_SIZE = 4096;
2342
+ var WEB_PUSH_MAX_BODY_BYTES = 4096;
2343
+ var WEB_PUSH_ENCRYPTION_OVERHEAD_BYTES = 16 + 4 + 1 + 65 + 1 + 16;
2344
+ var MAX_PUSH_PAYLOAD_BYTES = WEB_PUSH_MAX_BODY_BYTES - WEB_PUSH_ENCRYPTION_OVERHEAD_BYTES;
2345
+ var payloadEncoder = new TextEncoder();
2346
+ function measurePushPayload(payload) {
2347
+ const bytes = payloadEncoder.encode(typeof payload === "string" ? payload : String(payload)).length;
2348
+ return {
2349
+ bytes,
2350
+ maxBytes: MAX_PUSH_PAYLOAD_BYTES,
2351
+ remainingBytes: MAX_PUSH_PAYLOAD_BYTES - bytes,
2352
+ withinLimit: bytes <= MAX_PUSH_PAYLOAD_BYTES
2353
+ };
2354
+ }
2171
2355
  async function sendWebPush({ subscription, payload, vapid, ttl, fetch: fetchImpl }) {
2172
2356
  if (!subscription || typeof subscription.endpoint !== "string") {
2173
2357
  throw new Error("sendWebPush: invalid subscription");
@@ -2182,6 +2366,16 @@ async function sendWebPush({ subscription, payload, vapid, ttl, fetch: fetchImpl
2182
2366
  if (typeof subscriptionKeys.p256dh !== "string" || typeof subscriptionKeys.auth !== "string") {
2183
2367
  throw new Error("sendWebPush: subscription.keys.p256dh and .auth are required");
2184
2368
  }
2369
+ const size = measurePushPayload(payload);
2370
+ if (!size.withinLimit) {
2371
+ const err2 = new Error(
2372
+ `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)`
2373
+ );
2374
+ err2.code = "PUSH_PAYLOAD_TOO_LARGE";
2375
+ err2.bytes = size.bytes;
2376
+ err2.maxBytes = MAX_PUSH_PAYLOAD_BYTES;
2377
+ throw err2;
2378
+ }
2185
2379
  const encryptedBody = await encryptPushPayload({
2186
2380
  plaintext: utf8(payload),
2187
2381
  uaPublicKey: base64UrlToBytes(subscriptionKeys.p256dh),
@@ -2455,7 +2649,11 @@ function createSingleUserCloudflareWorker(buildConfig) {
2455
2649
  // spreads its ctx into processSingleMessage, so these ride along.
2456
2650
  hooks: cfg.hooks || null,
2457
2651
  maxToolIterations: cfg.maxToolIterations,
2458
- totalTimeoutMs: cfg.totalTimeoutMs
2652
+ totalTimeoutMs: cfg.totalTimeoutMs,
2653
+ // hook 的 ctx.writeState() 用它判断单条 value 的上限,和 PUT /client-state 同一个配置。
2654
+ maxStateValueBytes: cfg.maxStateValueBytes,
2655
+ // 任务占位租期(默认 10 分钟,随 totalTimeoutMs 抬高)。
2656
+ claimLeaseMs: cfg.claimLeaseMs
2459
2657
  });
2460
2658
  } catch (error) {
2461
2659
  console.error("[amsg single-user] scheduled(): tick failed:", error && error.message);
@@ -2487,6 +2685,10 @@ export {
2487
2685
  createMessagesHandler,
2488
2686
  createD1Adapter,
2489
2687
  createSingleUserServer,
2688
+ WEB_PUSH_MAX_BODY_BYTES,
2689
+ WEB_PUSH_ENCRYPTION_OVERHEAD_BYTES,
2690
+ MAX_PUSH_PAYLOAD_BYTES,
2691
+ measurePushPayload,
2490
2692
  createWebCryptoWebPush,
2491
2693
  createSingleUserCloudflareWorker
2492
2694
  };