@rei-standard/amsg-server 3.0.0 → 3.1.0-next.1

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.
@@ -147,14 +147,33 @@ import {
147
147
  // src/server/lib/encryption.js
148
148
  var base64ToBytes = base64UrlToBytes;
149
149
  var TAG_LENGTH_BYTES = 16;
150
+ var AES_KEY_CACHE_MAX = 128;
151
+ var aesKeyCache = /* @__PURE__ */ new Map();
150
152
  function importAesKey(hexKey, usage) {
151
- return globalThis.crypto.subtle.importKey(
152
- "raw",
153
- hexToBytes(hexKey),
154
- { name: "AES-GCM" },
155
- false,
156
- [usage]
157
- );
153
+ const cacheKey = `${usage}:${hexKey}`;
154
+ const cached = aesKeyCache.get(cacheKey);
155
+ if (cached) return cached;
156
+ let promise;
157
+ try {
158
+ promise = globalThis.crypto.subtle.importKey(
159
+ "raw",
160
+ hexToBytes(hexKey),
161
+ { name: "AES-GCM" },
162
+ false,
163
+ [usage]
164
+ ).catch((err6) => {
165
+ aesKeyCache.delete(cacheKey);
166
+ throw err6;
167
+ });
168
+ } catch (err6) {
169
+ return Promise.reject(err6);
170
+ }
171
+ if (aesKeyCache.size >= AES_KEY_CACHE_MAX) {
172
+ const oldest = aesKeyCache.keys().next().value;
173
+ if (oldest !== void 0) aesKeyCache.delete(oldest);
174
+ }
175
+ aesKeyCache.set(cacheKey, promise);
176
+ return promise;
158
177
  }
159
178
  async function aesGcmSeal(hexKey, iv, plaintextBytes) {
160
179
  const key = await importAesKey(hexKey, "encrypt");
@@ -428,6 +447,9 @@ function taskPayloadByteLength(serializedPayload) {
428
447
  return taskPayloadEncoder.encode(serializedPayload).length;
429
448
  }
430
449
  function validateScheduleMessagePayload(payload) {
450
+ if (payload.taskInput != null && (typeof payload.taskInput !== "object" || Array.isArray(payload.taskInput))) {
451
+ return { valid: false, errorCode: "INVALID_TASK_INPUT", errorMessage: "taskInput must be an object or null" };
452
+ }
431
453
  if (!payload.contactName || typeof payload.contactName !== "string") {
432
454
  return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: "\u7F3A\u5C11\u5FC5\u9700\u53C2\u6570\u6216\u53C2\u6570\u683C\u5F0F\u9519\u8BEF", details: { missingFields: ["contactName"] } };
433
455
  }
@@ -982,6 +1004,40 @@ function createWebCryptoWebPush(vapid = {}, { ttl = SCHEDULED_DEFAULT_TTL } = {}
982
1004
  };
983
1005
  }
984
1006
 
1007
+ // src/server/lib/attachments.js
1008
+ var ATTACHMENT_NAMESPACE = "amsg:attachments";
1009
+ var ATTACHMENT_RETENTION_DAYS = 30;
1010
+ var MAX_ATTACHMENT_TTL_MS = ATTACHMENT_RETENTION_DAYS * 24 * 60 * 60 * 1e3;
1011
+ function createAttachmentStore({ task, occurrenceMs, writeState, now }) {
1012
+ return async function storeAttachment(value, { ttlMs = MAX_ATTACHMENT_TTL_MS, owner, kind = "attachment", ownerGeneration } = {}) {
1013
+ if (typeof value !== "string") throw new TypeError("storeAttachment value must be a serialized string");
1014
+ if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0 || ttlMs > MAX_ATTACHMENT_TTL_MS) {
1015
+ throw new RangeError("storeAttachment ttlMs must be between 1 millisecond and 30 days");
1016
+ }
1017
+ if (typeof task.uuid !== "string" || !task.uuid || !Number.isFinite(occurrenceMs)) {
1018
+ throw new TypeError("storeAttachment requires a task UUID and a valid occurrence");
1019
+ }
1020
+ const at = now();
1021
+ const ref = { version: (
1022
+ /** @type {1} */
1023
+ 1
1024
+ ), namespace: ATTACHMENT_NAMESPACE, key: randomUUID(), taskUuid: task.uuid, occurrenceMs, expiresAt: at + ttlMs };
1025
+ let result;
1026
+ try {
1027
+ result = await writeState(ATTACHMENT_NAMESPACE, [{ key: ref.key, value: JSON.stringify({ ...ref, owner, kind, ownerGeneration, value }), updatedAt: at, owner, kind, ownerGeneration }]);
1028
+ } catch (error) {
1029
+ if (error?.code === "AGENTIC_STATE_WRITE_UNSUPPORTED") {
1030
+ throw new DeploymentConfigError("ATTACHMENT_STORAGE_UNSUPPORTED: attachments require a client_state adapter (built-in support: D1)", { code: "ATTACHMENT_STORAGE_UNSUPPORTED" });
1031
+ }
1032
+ throw error;
1033
+ }
1034
+ if (result.upserted !== 1 || result.skipped !== 0) {
1035
+ throw new Error("ATTACHMENT_WRITE_INCOMPLETE: attachment was not stored");
1036
+ }
1037
+ return ref;
1038
+ };
1039
+ }
1040
+
985
1041
  // src/server/lib/agentic-fire.js
986
1042
  import {
987
1043
  assertValidDecision,
@@ -1192,6 +1248,7 @@ async function writeClientStateEntries({ db, userId, userKey, entries, now }) {
1192
1248
  let upserted = 0;
1193
1249
  let skipped = 0;
1194
1250
  let deleted = 0;
1251
+ let unknown = 0;
1195
1252
  const skippedEntries = [];
1196
1253
  if (Array.isArray(result.outcomes) && result.outcomes.length === physicalRows.length) {
1197
1254
  for (let i = 0; i < rootRowIndexes.length; i++) {
@@ -1203,9 +1260,10 @@ async function writeClientStateEntries({ db, userId, userKey, entries, now }) {
1203
1260
  skippedEntries.push({ namespace: entry.namespace, key: entry.key });
1204
1261
  }
1205
1262
  }
1263
+ } else if (result.upserted === physicalRows.length && result.skipped === 0) {
1264
+ upserted = rootRowIndexes.length;
1206
1265
  } else {
1207
- upserted = result.upserted;
1208
- skipped = result.skipped;
1266
+ unknown = rootRowIndexes.length;
1209
1267
  }
1210
1268
  const cleanupOutcomes = Array.isArray(result.cleanupOutcomes) && result.cleanupOutcomes.length === cleanups.length ? result.cleanupOutcomes : null;
1211
1269
  for (const { cleanupIndex, entry } of deletions) {
@@ -1216,22 +1274,26 @@ async function writeClientStateEntries({ db, userId, userKey, entries, now }) {
1216
1274
  deleted++;
1217
1275
  }
1218
1276
  }
1219
- return { upserted, skipped, deleted, skippedEntries };
1277
+ return { upserted, skipped, deleted, skippedEntries, ...unknown > 0 ? { unknown } : {} };
1220
1278
  }
1221
1279
 
1222
1280
  // src/server/lib/state-accessors.js
1223
1281
  function createStateAccessors({ db, userId, userKey, maxStateValueBytes, now }) {
1224
1282
  const nowFn = typeof now === "function" ? now : Date.now;
1225
1283
  const valueCeiling = Number.isInteger(maxStateValueBytes) && maxStateValueBytes > 0 ? maxStateValueBytes : DEFAULT_MAX_STATE_VALUE_BYTES;
1226
- const readState = async (namespace) => {
1284
+ const readState = async (namespace, { key } = {}) => {
1227
1285
  if (typeof namespace !== "string" || !namespace.trim()) {
1228
1286
  throw new TypeError("readState(namespace) requires a non-empty string");
1229
1287
  }
1288
+ if (key !== void 0 && (typeof key !== "string" || !key.trim() || key.length > MAX_KEY_CHARS || INTERNAL_STATE_CHAR_RE.test(key))) {
1289
+ throw new TypeError(`readState key must be a non-empty string of at most ${MAX_KEY_CHARS} characters without control characters`);
1290
+ }
1230
1291
  if (!db || typeof db.getClientState !== "function") return [];
1231
- const rows = await db.getClientState(userId, namespace);
1292
+ const rows = await db.getClientState(userId, namespace, key === void 0 ? void 0 : { key });
1232
1293
  return resolveClientStateEntries(
1233
- rows,
1234
- () => db.getClientState(userId, chunkNamespaceFor(namespace)),
1294
+ // 老适配器可能忽略第三个参数,仍在解密前过滤根行,保持返回契约。
1295
+ key === void 0 ? rows : rows.filter((row) => row.key === key),
1296
+ () => db.getClientState(userId, chunkNamespaceFor(namespace), key === void 0 ? void 0 : { keyPrefix: chunkKeyPrefixFor(key) }),
1235
1297
  (value) => decryptFromStorage(value, userKey)
1236
1298
  );
1237
1299
  };
@@ -1287,6 +1349,9 @@ function createStateAccessors({ db, userId, userKey, maxStateValueBytes, now })
1287
1349
  namespace,
1288
1350
  key: entry.key,
1289
1351
  value: entry.value,
1352
+ owner: entry.owner,
1353
+ kind: entry.kind,
1354
+ ownerGeneration: entry.ownerGeneration,
1290
1355
  updatedAt: entry.updatedAt ?? at,
1291
1356
  ...entry.version !== void 0 ? { version: entry.version } : {}
1292
1357
  };
@@ -1618,7 +1683,7 @@ function projectTask(row, decryptedPayload, options = {}) {
1618
1683
  // 凭据引用只是名字(cred_id),本体在 llm_credentials 表里,凭据字段本身
1619
1684
  // 照旧被白名单挡在外面。
1620
1685
  credRefs: payload.credRefs ?? null,
1621
- // 整份 metadata 只在单条查询里给(见上面的 includeMetadata)。
1686
+ // 整份 metadata 由 includeMetadata 控制,单条查询与 hook 回执会带。
1622
1687
  ...options.includeMetadata ? { metadata: payload.metadata ?? null } : {},
1623
1688
  // 上一次没发出去的原因。reason 'stale' 表示错过触发时刻太久被判定不再补
1624
1689
  // 发;其余是投递失败的错误信息。没有记录 → null。
@@ -1709,6 +1774,9 @@ function normalizeBeforeFireResult(result) {
1709
1774
  return { messages: result };
1710
1775
  }
1711
1776
  if (result && typeof result === "object" && Array.isArray(result.messages)) {
1777
+ if (result.allowedToolNames !== void 0 && (!Array.isArray(result.allowedToolNames) || result.allowedToolNames.some((name) => typeof name !== "string" || !name.trim()))) {
1778
+ throw markPermanent(new TypeError("allowedToolNames must be an array of non-empty strings"), "AGENTIC_BAD_BEFORE_FIRE");
1779
+ }
1712
1780
  return {
1713
1781
  messages: result.messages,
1714
1782
  maxToolIterations: result.maxToolIterations,
@@ -1717,7 +1785,8 @@ function normalizeBeforeFireResult(result) {
1717
1785
  // 用真值。真正决定请求体带不带 tools 的规则住在 llm.js 的
1718
1786
  // buildAiRequestBody(那边有测试钉着),这层只是不把空数组往下传。
1719
1787
  tools: Array.isArray(result.tools) && result.tools.length > 0 ? result.tools : void 0,
1720
- toolChoice: result.toolChoice
1788
+ toolChoice: result.toolChoice,
1789
+ allowedToolNames: result.allowedToolNames === void 0 ? void 0 : new Set(result.allowedToolNames)
1721
1790
  };
1722
1791
  }
1723
1792
  throw markPermanent(new TypeError(BAD_BEFORE_FIRE_MESSAGE), "AGENTIC_BAD_BEFORE_FIRE");
@@ -1769,6 +1838,8 @@ async function runAgenticFire({ task, decryptedPayload, userKey, ctx }) {
1769
1838
  const sessionId = task.id != null ? `sess_task_${task.id}${occurrenceSuffix(task)}` : `sess_${randomUUID()}`;
1770
1839
  const messageIdBase = task.id != null ? `msg_task_${task.id}${occurrenceSuffix(task)}` : `msg_${randomUUID()}`;
1771
1840
  const occurrenceMs = occurrenceMsOf(task);
1841
+ const storeAttachment = createAttachmentStore({ task, occurrenceMs, writeState, now: nowFn });
1842
+ const storeSettledAttachment = createAttachmentStore({ task, occurrenceMs, writeState: writeSettledState, now: nowFn });
1772
1843
  const { emitResult } = createResultEmitter({
1773
1844
  db: ctx.db,
1774
1845
  task,
@@ -1791,17 +1862,37 @@ async function runAgenticFire({ task, decryptedPayload, userKey, ctx }) {
1791
1862
  const row = await ctx.db.getTaskByUuid(uuid, task.user_id);
1792
1863
  if (!row) return null;
1793
1864
  const payload = JSON.parse(await decryptFromStorage(row.encrypted_payload, userKey));
1794
- return projectTask(row, payload);
1865
+ return projectTask(row, payload, { includeMetadata: true });
1795
1866
  } catch (error) {
1796
1867
  console.warn("[amsg-server] scheduleTask: \u8BFB\u53D6\u649E\u8F66\u4EFB\u52A1\u5931\u8D25\uFF08\u5DF2\u5FFD\u7565\uFF09:", error && error.message);
1797
1868
  return null;
1798
1869
  }
1799
1870
  }
1871
+ const listTasks = async (options = {}) => {
1872
+ throwIfCancelled();
1873
+ const { status = "pending", limit = 100, offset = 0, includeMetadata = true } = options;
1874
+ if (!["pending", "sent", "failed", "all"].includes(status) || !Number.isInteger(limit) || limit < 1 || limit > 100 || !Number.isInteger(offset) || offset < 0) {
1875
+ throw new TypeError("listTasks requires a valid status, limit 1-100, and non-negative offset");
1876
+ }
1877
+ if (typeof ctx.db?.listTasks !== "function") {
1878
+ throw new DeploymentConfigError("AGENTIC_TASK_VIEW_UNSUPPORTED: adapter has no listTasks", { code: "AGENTIC_TASK_VIEW_UNSUPPORTED" });
1879
+ }
1880
+ const { tasks, total } = await ctx.db.listTasks(task.user_id, { status, limit, offset });
1881
+ const projected = await Promise.all(tasks.map(async (row) => projectTask(
1882
+ row,
1883
+ JSON.parse(await decryptFromStorage(row.encrypted_payload, userKey)),
1884
+ { includeMetadata }
1885
+ )));
1886
+ return { tasks: projected, pagination: { total, limit, offset, hasMore: offset + tasks.length < total } };
1887
+ };
1800
1888
  const scheduleTask = async (options) => {
1801
1889
  throwIfCancelled();
1802
1890
  if (!options || typeof options !== "object" || Array.isArray(options)) {
1803
1891
  throw new TypeError("scheduleTask(options) \u9700\u8981\u4E00\u4E2A\u5BF9\u8C61\uFF0C\u81F3\u5C11\u5305\u542B { firstSendTime }");
1804
1892
  }
1893
+ if (options.taskInput != null && (typeof options.taskInput !== "object" || Array.isArray(options.taskInput))) {
1894
+ throw new TypeError("scheduleTask: taskInput must be an object or null");
1895
+ }
1805
1896
  if (typeof options.firstSendTime !== "string" || !options.firstSendTime.trim()) {
1806
1897
  throw new RangeError("scheduleTask: firstSendTime \u5FC5\u586B\uFF0C\u4E14\u5FC5\u987B\u662F ISO 8601 \u5B57\u7B26\u4E32");
1807
1898
  }
@@ -1896,7 +1987,8 @@ async function runAgenticFire({ task, decryptedPayload, userKey, ctx }) {
1896
1987
  // 与凭据同列的投递配置:中转的非标准参数(thinking 之类)跟着任务走,
1897
1988
  // 自排的后续任务不该突然掉档。
1898
1989
  llmExtraBody: decryptedPayload.llmExtraBody ?? null,
1899
- metadata
1990
+ metadata,
1991
+ taskInput: options.taskInput ?? null
1900
1992
  };
1901
1993
  const serializedTaskData = JSON.stringify(fullTaskData);
1902
1994
  const sizeError = validateTaskPayloadSize(serializedTaskData);
@@ -1950,7 +2042,8 @@ async function runAgenticFire({ task, decryptedPayload, userKey, ctx }) {
1950
2042
  created: true,
1951
2043
  id: created.id ?? null,
1952
2044
  uuid: created.uuid ?? uuid,
1953
- nextSendAt: created.next_send_at ?? nextSendAt
2045
+ nextSendAt: created.next_send_at ?? nextSendAt,
2046
+ task: projectTask({ status: "pending", next_send_at: nextSendAt, ...created }, fullTaskData, { includeMetadata: true })
1954
2047
  };
1955
2048
  };
1956
2049
  const cancelTask = async (uuid) => {
@@ -2018,7 +2111,11 @@ async function runAgenticFire({ task, decryptedPayload, userKey, ctx }) {
2018
2111
  const stillPending = await ctx.db.getTaskByUuid(uuid, task.user_id);
2019
2112
  return { renewed: false, reason: stillPending ? "in_flight" : "not_found" };
2020
2113
  }
2021
- return { renewed: true, uuid, nextSendAt: nextSendAtIso };
2114
+ return { renewed: true, uuid, nextSendAt: nextSendAtIso, task: projectTask(
2115
+ { ...row, next_send_at: nextSendAtIso, retry_count: 0 },
2116
+ payload,
2117
+ { includeMetadata: true }
2118
+ ) };
2022
2119
  };
2023
2120
  const resolveLlmCredential2 = async (credId) => {
2024
2121
  if (typeof credId !== "string" || !credId.trim()) {
@@ -2035,7 +2132,9 @@ async function runAgenticFire({ task, decryptedPayload, userKey, ctx }) {
2035
2132
  readState,
2036
2133
  writeState,
2037
2134
  emitResult,
2135
+ storeAttachment,
2038
2136
  scheduleTask,
2137
+ listTasks,
2039
2138
  cancelTask,
2040
2139
  renewTask,
2041
2140
  resolveLlmCredential: resolveLlmCredential2,
@@ -2068,8 +2167,10 @@ async function runAgenticFire({ task, decryptedPayload, userKey, ctx }) {
2068
2167
  readState,
2069
2168
  writeState,
2070
2169
  emitResult,
2170
+ storeAttachment,
2071
2171
  scratch,
2072
2172
  scheduleTask,
2173
+ listTasks,
2073
2174
  cancelTask,
2074
2175
  renewTask,
2075
2176
  resolveLlmCredential: resolveLlmCredential2,
@@ -2128,7 +2229,8 @@ async function runAgenticFire({ task, decryptedPayload, userKey, ctx }) {
2128
2229
  scratch,
2129
2230
  readState,
2130
2231
  writeState: writeSettledState,
2131
- emitResult
2232
+ emitResult,
2233
+ storeAttachment: storeSettledAttachment
2132
2234
  });
2133
2235
  }
2134
2236
  }
@@ -2143,8 +2245,10 @@ async function runFireChain({
2143
2245
  readState,
2144
2246
  writeState,
2145
2247
  emitResult,
2248
+ storeAttachment,
2146
2249
  scratch,
2147
2250
  scheduleTask,
2251
+ listTasks,
2148
2252
  cancelTask,
2149
2253
  renewTask,
2150
2254
  resolveLlmCredential: resolveLlmCredential2,
@@ -2243,15 +2347,35 @@ async function runFireChain({
2243
2347
  readState,
2244
2348
  writeState,
2245
2349
  emitResult,
2350
+ storeAttachment,
2246
2351
  scheduleTask,
2352
+ listTasks,
2247
2353
  cancelTask,
2248
2354
  renewTask,
2249
2355
  resolveLlmCredential: resolveLlmCredential2
2250
2356
  });
2251
2357
  const decision = await hooks.onLLMOutput(sessionCtx);
2252
2358
  ctx.throwIfCancelled();
2359
+ const rawCalls = decision?.decision === "tool-request" ? extractToolCallsFromDecision(decision) : [];
2360
+ const validId = (call) => typeof call?.id === "string" && call.id.trim().length > 0;
2361
+ const nativeCalls = Array.isArray(assistantMessage.tool_calls) ? assistantMessage.tool_calls : [];
2362
+ const usedIds = new Set([...rawCalls, ...nativeCalls].filter(validId).map((call) => call.id));
2363
+ const malformedIds = /* @__PURE__ */ new Set();
2364
+ const toolCalls = rawCalls.map((call, index) => {
2365
+ let id = validId(call) ? call.id : `call_${iteration + 1}_${index + 1}`;
2366
+ if (!validId(call)) {
2367
+ while (usedIds.has(id)) id += "_";
2368
+ usedIds.add(id);
2369
+ }
2370
+ if (typeof call?.function?.name !== "string" || !call.function.name.trim()) {
2371
+ malformedIds.add(id);
2372
+ return { id, type: "function", function: { name: "invalid_tool_call", arguments: "{}" } };
2373
+ }
2374
+ return { ...call, id };
2375
+ });
2376
+ const validationDecision = decision?.decision === "tool-request" && Array.isArray(decision.toolCalls) && decision.toolCalls.length > 0 ? { ...decision, toolCalls } : decision;
2253
2377
  try {
2254
- assertValidDecision(decision, { inlineToolCalls: true });
2378
+ assertValidDecision(validationDecision, { inlineToolCalls: true });
2255
2379
  } catch (error) {
2256
2380
  throw markPermanent(error, "AGENTIC_BAD_DECISION");
2257
2381
  }
@@ -2282,14 +2406,19 @@ async function runFireChain({
2282
2406
  });
2283
2407
  return { handled: true, result: { success: true, messagesSent, status: "finished", iterations: iteration + 1 } };
2284
2408
  }
2285
- const toolCalls = extractToolCallsFromDecision(decision);
2286
2409
  if (toolCalls.length === 0) {
2287
2410
  throw taggedRetryableError(
2288
2411
  "AGENTIC_EMPTY_TOOL_REQUEST: tool-request decision carried no toolCalls (neither decision.toolCalls nor pushPayloads[].toolCalls)",
2289
2412
  "AGENTIC_EMPTY_TOOL_REQUEST"
2290
2413
  );
2291
2414
  }
2292
- if (typeof hooks.executeToolCalls !== "function") {
2415
+ const permittedCalls = toolCalls.filter((call) => !malformedIds.has(call.id) && (normalized.allowedToolNames === void 0 || normalized.allowedToolNames.has(call.function.name)));
2416
+ const deniedResults = toolCalls.filter((call) => !permittedCalls.includes(call)).map((call) => ({
2417
+ role: "tool",
2418
+ tool_call_id: call.id,
2419
+ content: JSON.stringify({ error: malformedIds.has(call.id) ? { code: "INVALID_TOOL_CALL", message: "Tool call requires a function name" } : { code: "TOOL_NOT_ALLOWED", message: "Tool execution is not allowed for this fire" } })
2420
+ }));
2421
+ if (permittedCalls.length > 0 && typeof hooks.executeToolCalls !== "function") {
2293
2422
  throw new DeploymentConfigError(
2294
2423
  "AGENTIC_CONFIG_ERROR: onLLMOutput returned tool-request but hooks.executeToolCalls is not configured",
2295
2424
  { code: "AGENTIC_CONFIG_ERROR" }
@@ -2301,7 +2430,7 @@ async function runFireChain({
2301
2430
  let toolResults;
2302
2431
  try {
2303
2432
  ctx.throwIfCancelled();
2304
- toolResults = await hooks.executeToolCalls(toolCalls, sessionCtx);
2433
+ toolResults = permittedCalls.length > 0 ? await hooks.executeToolCalls(permittedCalls, sessionCtx) : [];
2305
2434
  ctx.throwIfCancelled();
2306
2435
  if (!Array.isArray(toolResults)) {
2307
2436
  throw new TypeError('executeToolCalls must resolve to an array of { tool_call_id, role: "tool", content }');
@@ -2309,16 +2438,17 @@ async function runFireChain({
2309
2438
  } catch (error) {
2310
2439
  ctx.throwIfCancelled();
2311
2440
  if (isTaskCancelledError(error)) throw error;
2312
- toolResults = toolCalls.map((toolCall) => ({
2441
+ toolResults = permittedCalls.map((toolCall) => ({
2313
2442
  tool_call_id: toolCall && typeof toolCall === "object" && typeof toolCall.id === "string" ? toolCall.id : "",
2314
2443
  role: "tool",
2315
2444
  content: `Tool execution failed: ${error?.message ?? String(error)}`
2316
2445
  }));
2317
2446
  }
2318
- const nativeCalls = Array.isArray(assistantMessage.tool_calls) ? assistantMessage.tool_calls : [];
2319
- const nativeIds = new Set(nativeCalls.map((tc) => tc && tc.id));
2320
- const synthesized = toolCalls.filter((tc) => !nativeIds.has(tc && tc.id));
2321
- const assistantWithTools = synthesized.length === 0 ? assistantMessage : { ...assistantMessage, tool_calls: [...nativeCalls, ...synthesized] };
2447
+ toolResults = [...toolResults, ...deniedResults];
2448
+ const declared = nativeCalls.filter(validId).map((call) => toolCalls.find((item) => item.id === call.id) || call);
2449
+ const nativeIds = new Set(declared.map((call) => call.id));
2450
+ const synthesized = toolCalls.filter((call) => !nativeIds.has(call.id));
2451
+ const assistantWithTools = { ...assistantMessage, tool_calls: [...declared, ...synthesized] };
2322
2452
  messages = [...messages.slice(0, -1), assistantWithTools, ...toolResults];
2323
2453
  }
2324
2454
  throw taggedRetryableError(
@@ -3392,6 +3522,9 @@ function createScheduleMessageHandler(ctx) {
3392
3522
  owner: payload.owner,
3393
3523
  kind: payload.kind,
3394
3524
  ownerGeneration: payload.ownerGeneration,
3525
+ // Input is persisted with the task in the same create/supersede transaction.
3526
+ // Shared credentials remain references and continue to resolve at fire time.
3527
+ taskInput: payload.taskInput ?? null,
3395
3528
  metadata: payload.metadata || {}
3396
3529
  };
3397
3530
  const serializedTaskData = JSON.stringify(fullTaskData);
@@ -3599,7 +3732,7 @@ async function runScheduledTick(ctx) {
3599
3732
  }
3600
3733
  async function cleanupExpiredClientState(ctx) {
3601
3734
  if (typeof ctx.db.cleanupClientState !== "function") return;
3602
- const targets = planClientStateCleanup(ctx.clientStateTtl, Date.now());
3735
+ const targets = planClientStateCleanup({ [ATTACHMENT_NAMESPACE]: ATTACHMENT_RETENTION_DAYS, ...ctx.clientStateTtl }, Date.now());
3603
3736
  if (targets.length === 0) return;
3604
3737
  try {
3605
3738
  await ctx.db.cleanupClientState(targets);
@@ -4259,6 +4392,9 @@ function createUpdateMessageHandler(ctx) {
4259
4392
  if (!isPlainObject(updates)) {
4260
4393
  return { status: 400, body: { success: false, error: { code: "INVALID_UPDATE_DATA", message: "\u66F4\u65B0\u6570\u636E\u683C\u5F0F\u9519\u8BEF" } } };
4261
4394
  }
4395
+ if (Object.prototype.hasOwnProperty.call(updates, "taskInput")) {
4396
+ return { status: 400, body: { success: false, error: { code: "INVALID_UPDATE_DATA", message: "taskInput \u53EA\u80FD\u5728\u521B\u5EFA\u6216\u66FF\u6362\u4EFB\u52A1\u65F6\u8BBE\u7F6E", details: { invalidFields: ["taskInput"] } } } };
4397
+ }
4262
4398
  if (updates.nextSendAt && !isValidISO8601(updates.nextSendAt)) {
4263
4399
  return { status: 400, body: { success: false, error: { code: "INVALID_UPDATE_DATA", message: "\u66F4\u65B0\u6570\u636E\u683C\u5F0F\u9519\u8BEF", details: { invalidFields: ["nextSendAt"] } } } };
4264
4400
  }
@@ -5334,12 +5470,37 @@ var D1Adapter = class {
5334
5470
  const row = await this._db.prepare("SELECT encrypted_value FROM cloud_resource_metadata WHERE user_id=? AND resource_key=?").bind(userId, resourceKey).first();
5335
5471
  return row?.encrypted_value ?? null;
5336
5472
  }
5473
+ async getOutboxPayload(userId, messageId) {
5474
+ const row = await this._db.prepare(
5475
+ "SELECT payload FROM message_outbox WHERE user_id = ? AND message_id = ? LIMIT 1"
5476
+ ).bind(userId, messageId).first();
5477
+ return row?.payload ?? null;
5478
+ }
5337
5479
  async listCloudResourceRows(userId) {
5338
- const tables = { task: "scheduled_messages", state: "client_state", credential: "llm_credentials", outbox: "message_outbox", subscription: "push_subscriptions", metadata: "cloud_resource_metadata" };
5480
+ const queries = {
5481
+ task: "SELECT * FROM scheduled_messages WHERE user_id = ?",
5482
+ state: `SELECT user_id, namespace, key,
5483
+ CASE WHEN LENGTH(value) <= 1024 THEN value ELSE NULL END AS value,
5484
+ LENGTH(CAST(value AS BLOB)) AS byte_size,
5485
+ substr(value, 1, 128) AS value_prefix,
5486
+ CASE WHEN LENGTH(value) > 128 THEN substr(value, -64) ELSE '' END AS value_suffix,
5487
+ updated_at
5488
+ FROM client_state WHERE user_id = ?`,
5489
+ credential: "SELECT * FROM llm_credentials WHERE user_id = ?",
5490
+ outbox: `SELECT id, user_id, message_id, task_uuid, session_id, message_index, total_messages,
5491
+ CASE WHEN LENGTH(payload) <= 1024 THEN payload ELSE NULL END AS payload,
5492
+ LENGTH(CAST(payload AS BLOB)) AS byte_size,
5493
+ substr(payload, 1, 128) AS payload_prefix,
5494
+ CASE WHEN LENGTH(payload) > 128 THEN substr(payload, -64) ELSE '' END AS payload_suffix,
5495
+ created_at, delivered_at, acked_at
5496
+ FROM message_outbox WHERE user_id = ?`,
5497
+ subscription: "SELECT * FROM push_subscriptions WHERE user_id = ?",
5498
+ metadata: "SELECT * FROM cloud_resource_metadata WHERE user_id = ?"
5499
+ };
5339
5500
  const result = { gaps: [] };
5340
- await Promise.all(Object.entries(tables).map(async ([type, table]) => {
5501
+ await Promise.all(Object.entries(queries).map(async ([type, sql]) => {
5341
5502
  try {
5342
- const response = await this._db.prepare(`SELECT * FROM ${table} WHERE user_id = ?`).bind(userId).all();
5503
+ const response = await this._db.prepare(sql).bind(userId).all();
5343
5504
  result[type] = response.results || [];
5344
5505
  } catch {
5345
5506
  result[type] = [];
@@ -5404,7 +5565,10 @@ var D1Adapter = class {
5404
5565
  if (idempotencyKey) {
5405
5566
  return this._cloudRecord(await this._db.prepare(`SELECT * FROM cloud_data_records WHERE user_id=? AND kind=? AND idempotency_key=?`).bind(userId, kind, idempotencyKey).first());
5406
5567
  }
5407
- return this.getCloudDataRecord(userId, kind, id);
5568
+ if (createOnly) {
5569
+ return this.getCloudDataRecord(userId, kind, id);
5570
+ }
5571
+ return { id, userId, kind, data, updatedAt: now };
5408
5572
  }
5409
5573
  async getCloudDataRecord(userId, kind, id) {
5410
5574
  return this._cloudRecord(await this._db.prepare("SELECT * FROM cloud_data_records WHERE user_id=? AND kind=? AND id=?").bind(userId, kind, id).first());
@@ -5613,6 +5777,29 @@ var D1Adapter = class {
5613
5777
  const row = await this._db.prepare(`SELECT ${condition.sql} AS valid`).bind(...condition.args).first();
5614
5778
  return !!row?.valid;
5615
5779
  }
5780
+ _rowSnapshotMatch(spec, row) {
5781
+ const clauses = [];
5782
+ const args = [];
5783
+ for (const column of spec.columns) {
5784
+ if (!Object.hasOwn(row, column)) continue;
5785
+ if ((column === "value" || column === "payload") && row[column] === null && typeof row.byte_size === "number") {
5786
+ clauses.push(
5787
+ `LENGTH(CAST(${column} AS BLOB)) IS ?`,
5788
+ `substr(${column}, 1, 128) IS ?`,
5789
+ `(CASE WHEN LENGTH(${column}) > 128 THEN substr(${column}, -64) ELSE '' END) IS ?`
5790
+ );
5791
+ args.push(
5792
+ row.byte_size,
5793
+ row[`${column}_prefix`] ?? null,
5794
+ row[`${column}_suffix`] ?? ""
5795
+ );
5796
+ } else {
5797
+ clauses.push(`${column} IS ?`);
5798
+ args.push(row[column]);
5799
+ }
5800
+ }
5801
+ return { predicate: clauses.join(" AND "), values: args };
5802
+ }
5616
5803
  /** Delete only the exact physical snapshot; no untrusted SQL identifiers. */
5617
5804
  async deleteCloudResourceRows(userId, locators, { operationId, leaseToken } = {}) {
5618
5805
  const specs = {
@@ -5660,9 +5847,7 @@ var D1Adapter = class {
5660
5847
  ).bind(userId, namespace, key, "amsg-chunks" + namespace, key + "", key + " ", identities));
5661
5848
  }
5662
5849
  for (const row of locator.rows) {
5663
- const columns = spec.columns.filter((column) => Object.hasOwn(row, column));
5664
- const predicate = columns.map((column) => `${column} IS ?`).join(" AND ");
5665
- const values = columns.map((column) => row[column]);
5850
+ const { predicate, values } = this._rowSnapshotMatch(spec, row);
5666
5851
  const identity = spec.keys.map((column) => `${column} IS ?`).join(" AND ");
5667
5852
  statements.push(this._db.prepare(
5668
5853
  `INSERT INTO cloud_guard_assertions (allowed)
@@ -5671,8 +5856,8 @@ var D1Adapter = class {
5671
5856
  ).bind(...spec.keys.map((column) => row[column]), ...values));
5672
5857
  }
5673
5858
  for (const row of locator.rows) {
5674
- const columns = spec.columns.filter((column) => Object.hasOwn(row, column));
5675
- statements.push(this._db.prepare(`DELETE FROM ${spec.table} WHERE ${columns.map((column) => `${column} IS ?`).join(" AND ")}`).bind(...columns.map((column) => row[column])));
5859
+ const { predicate, values } = this._rowSnapshotMatch(spec, row);
5860
+ statements.push(this._db.prepare(`DELETE FROM ${spec.table} WHERE ${predicate}`).bind(...values));
5676
5861
  }
5677
5862
  if (locator.metadataKey) statements.push(this._db.prepare("DELETE FROM cloud_resource_metadata WHERE user_id=? AND resource_key=?").bind(userId, locator.metadataKey));
5678
5863
  try {
@@ -6175,24 +6360,33 @@ var D1Adapter = class {
6175
6360
  return result;
6176
6361
  }
6177
6362
  /**
6178
- * All entries of one namespace (values still encrypted).
6363
+ * Entries of one namespace (values still encrypted); optional exact/prefix selection.
6179
6364
  *
6180
6365
  * @param {string} userId
6181
6366
  * @param {string} namespace
6182
6367
  * @returns {Promise<Array<{ namespace: string, key: string, value: string, updated_at: number }>>}
6183
6368
  */
6184
- async getClientState(userId, namespace) {
6369
+ async getClientState(userId, namespace, options = {}) {
6370
+ const params = [userId, namespace];
6371
+ let selector = "";
6372
+ if (options.key !== void 0) {
6373
+ selector = " AND key = ?";
6374
+ params.push(options.key);
6375
+ } else if (options.keyPrefix !== void 0) {
6376
+ selector = " AND key >= ? AND key < ?";
6377
+ params.push(options.keyPrefix, prefixRangeEnd(options.keyPrefix));
6378
+ }
6185
6379
  const res = await this._db.prepare(
6186
6380
  `SELECT namespace, key, value, updated_at
6187
6381
  FROM client_state
6188
- WHERE user_id = ? AND namespace = ?
6382
+ WHERE user_id = ? AND namespace = ?${selector}
6189
6383
  ORDER BY key ASC`
6190
- ).bind(userId, namespace).all();
6384
+ ).bind(...params).all();
6191
6385
  return res.results || [];
6192
6386
  }
6193
6387
  /**
6194
6388
  * 例行清理:把指定命名空间下太久没更新的条目删掉(run-tick 每跳顺手调,
6195
- * 宿主配了 `clientStateTtl` 才会调)。
6389
+ * 附件默认保留 30 天,宿主 `clientStateTtl` 可覆盖或配置其他命名空间)。
6196
6390
  *
6197
6391
  * 不限用户——「这个命名空间只留最近 N 天」是命名空间级的约定,单用户部署
6198
6392
  * 下也就是这一个用户的行。指令由 lib/client-state-store.js 的
@@ -6924,8 +7118,9 @@ function createClientStateHandler(ctx) {
6924
7118
  if (typeof db.upsertClientState !== "function") {
6925
7119
  return err3(501, "CLIENT_STATE_NOT_SUPPORTED", "\u5F53\u524D\u6570\u636E\u5E93\u9002\u914D\u5668\u4E0D\u652F\u6301 client_state");
6926
7120
  }
6927
- const { upserted, skipped, deleted, skippedEntries } = await writeClientStateEntries({ db, userId, userKey, entries: accepted });
7121
+ const { upserted, skipped, deleted, skippedEntries, unknown } = await writeClientStateEntries({ db, userId, userKey, entries: accepted });
6928
7122
  const data = { upserted, skipped };
7123
+ if (unknown > 0) data.unknown = unknown;
6929
7124
  if (deleted > 0) data.deleted = deleted;
6930
7125
  if (skippedEntries.length > 0) data.skippedEntries = skippedEntries;
6931
7126
  if (rejected.length > 0) data.rejected = rejected;
@@ -6938,7 +7133,12 @@ function createClientStateHandler(ctx) {
6938
7133
  const gate = requireUserId(headers);
6939
7134
  if (gate.error) return gate.error;
6940
7135
  const { userId } = gate;
6941
- const namespace = new URL(url, "https://dummy").searchParams.get("namespace") || "";
7136
+ const query = new URL(url, "https://dummy").searchParams;
7137
+ const namespace = query.get("namespace") || "";
7138
+ const key = query.get("key");
7139
+ if (key !== null && (!key.trim() || key.length > MAX_KEY_CHARS || INTERNAL_STATE_CHAR_RE.test(key))) {
7140
+ return err3(400, "INVALID_STATE_KEY", "key must be non-empty, at most 256 characters, without control characters");
7141
+ }
6942
7142
  if (!namespace.trim()) return err3(400, "NAMESPACE_REQUIRED", "\u5FC5\u987B\u63D0\u4F9B namespace \u67E5\u8BE2\u53C2\u6570");
6943
7143
  if (INTERNAL_STATE_CHAR_RE.test(namespace)) {
6944
7144
  return err3(400, "INVALID_STATE_NAMESPACE", "namespace \u4E0D\u80FD\u5305\u542B\u63A7\u5236\u5B57\u7B26\uFF08\\u0000-\\u001f \u4E3A\u5E93\u5185\u90E8\u4FDD\u7559\uFF09");
@@ -6947,10 +7147,10 @@ function createClientStateHandler(ctx) {
6947
7147
  return err3(501, "CLIENT_STATE_NOT_SUPPORTED", "\u5F53\u524D\u6570\u636E\u5E93\u9002\u914D\u5668\u4E0D\u652F\u6301 client_state");
6948
7148
  }
6949
7149
  const userKey = await deriveUserEncryptionKey(userId, masterKey);
6950
- const rows = await db.getClientState(userId, namespace);
7150
+ const rows = await db.getClientState(userId, namespace, key === null ? void 0 : { key });
6951
7151
  const decrypted = await resolveClientStateEntries(
6952
- rows,
6953
- () => db.getClientState(userId, chunkNamespaceFor(namespace)),
7152
+ key === null ? rows : rows.filter((row) => row.key === key),
7153
+ () => db.getClientState(userId, chunkNamespaceFor(namespace), key === null ? void 0 : { keyPrefix: chunkKeyPrefixFor(key) }),
6954
7154
  (value) => decryptFromStorage(value, userKey)
6955
7155
  );
6956
7156
  const encryptedResponse = await encryptPayload({ namespace, entries: decrypted }, userKey);
@@ -7028,7 +7228,7 @@ function createClientStateNamespacesHandler(ctx) {
7028
7228
  }
7029
7229
 
7030
7230
  // src/server/lib/version.js
7031
- var SERVER_VERSION = true ? "3.0.0" : "0.0.0-dev";
7231
+ var SERVER_VERSION = true ? "3.1.0-next.1" : "0.0.0-dev";
7032
7232
 
7033
7233
  // src/server/handlers/capabilities.js
7034
7234
  var SERVER_FEATURES = Object.freeze([
@@ -7144,7 +7344,12 @@ var SERVER_FEATURES = Object.freeze([
7144
7344
  "max-generation-retries",
7145
7345
  // onBeforeFire 认 { defer: { afterMs } }:这次不生成,过一会儿再来问(不占重试
7146
7346
  // 次数);onFireSettled 的 status 多一种 'deferred',带 retryAfter。
7147
- "before-fire-defer"
7347
+ "before-fire-defer",
7348
+ "task-input",
7349
+ "client-state-key",
7350
+ "agentic-list-tasks",
7351
+ "agentic-tool-allowlist",
7352
+ "attachments"
7148
7353
  ]);
7149
7354
  function createCapabilitiesHandler(ctx) {
7150
7355
  async function GET(url, headers) {
@@ -7191,6 +7396,39 @@ function objectOrText(value) {
7191
7396
  return value;
7192
7397
  }
7193
7398
  }
7399
+ function utf8ByteLength(text) {
7400
+ if (typeof text !== "string" || text.length === 0) return 0;
7401
+ return /[^\x00-\x7f]/.test(text) ? encoder.encode(text).length : text.length;
7402
+ }
7403
+ function rowByteSize(row) {
7404
+ if (typeof row.byte_size === "number") return row.byte_size;
7405
+ return utf8ByteLength(
7406
+ String(
7407
+ row.value ?? row.encrypted_payload ?? row.encrypted_value ?? row.payload ?? row.subscription ?? ""
7408
+ )
7409
+ );
7410
+ }
7411
+ var STORAGE_PREFIX_RE = /^[0-9a-f]{32}:[0-9a-f]{24,32}:[0-9a-f]*$/i;
7412
+ var HEX_RE = /^[0-9a-f]*$/i;
7413
+ function isValidStorageCiphertext(fullText, prefix, suffix, byteSize) {
7414
+ if (typeof fullText === "string") {
7415
+ if (!STORAGE_PREFIX_RE.test(fullText)) return false;
7416
+ const secondColon = fullText.indexOf(":", 33);
7417
+ return secondColon > 32 && (fullText.length - secondColon - 1) % 2 === 0;
7418
+ }
7419
+ if (typeof prefix === "string" && typeof byteSize === "number") {
7420
+ if (!STORAGE_PREFIX_RE.test(prefix)) return false;
7421
+ if (typeof suffix === "string" && !HEX_RE.test(suffix)) return false;
7422
+ const secondColon = prefix.indexOf(":", 33);
7423
+ return secondColon > 32 && (byteSize - secondColon - 1) % 2 === 0;
7424
+ }
7425
+ return false;
7426
+ }
7427
+ function hasCompleteStoredMetadata(stored) {
7428
+ return Boolean(
7429
+ stored && safeOwner(stored.owner) !== null && typeof stored.kind === "string" && stored.kind.length > 0
7430
+ );
7431
+ }
7194
7432
  async function buildCloudDataInventory({
7195
7433
  db,
7196
7434
  userId,
@@ -7203,6 +7441,20 @@ async function buildCloudDataInventory({
7203
7441
  const metadata = new Map(
7204
7442
  (raw.metadata || []).map((row) => [row.resource_key, row.encrypted_value])
7205
7443
  );
7444
+ const readStoredMetadata = async (metadataKey) => {
7445
+ const encryptedMetadata = metadata.get(metadataKey) || null;
7446
+ if (!encryptedMetadata) {
7447
+ return { encryptedMetadata: null, stored: null, unreadable: false };
7448
+ }
7449
+ try {
7450
+ const stored = JSON.parse(
7451
+ await decryptFromStorage(encryptedMetadata, userKey)
7452
+ );
7453
+ return { encryptedMetadata, stored, unreadable: false };
7454
+ } catch {
7455
+ return { encryptedMetadata, stored: null, unreadable: true };
7456
+ }
7457
+ };
7206
7458
  const taskPayloads = /* @__PURE__ */ new Map();
7207
7459
  const taskByUuid = /* @__PURE__ */ new Map();
7208
7460
  for (const row of raw.task || []) {
@@ -7227,23 +7479,19 @@ async function buildCloudDataInventory({
7227
7479
  status = null,
7228
7480
  namespace,
7229
7481
  key,
7230
- credId
7482
+ credId,
7483
+ metadataInfo
7231
7484
  }) => {
7232
7485
  const metadataKey = JSON.stringify([type, ...identity]);
7233
7486
  const id = await cloudDataHash(JSON.stringify([userId, metadataKey]));
7234
- let stored = null;
7235
- const encryptedMetadata = metadata.get(metadataKey);
7236
- if (encryptedMetadata) {
7237
- try {
7238
- stored = JSON.parse(
7239
- await decryptFromStorage(encryptedMetadata, userKey)
7240
- );
7241
- } catch {
7242
- unreadable = true;
7243
- }
7244
- }
7487
+ const {
7488
+ encryptedMetadata,
7489
+ stored,
7490
+ unreadable: metadataUnreadable
7491
+ } = metadataInfo ?? await readStoredMetadata(metadataKey);
7492
+ if (metadataUnreadable) unreadable = true;
7245
7493
  let resolved = null;
7246
- if (resolveOwner) {
7494
+ if (resolveOwner && !hasCompleteStoredMetadata(stored)) {
7247
7495
  try {
7248
7496
  resolved = await resolveOwner({
7249
7497
  type,
@@ -7266,14 +7514,7 @@ async function buildCloudDataInventory({
7266
7514
  const owner = safeOwner(stored?.owner) || safeOwner(payload?.__cloudOwnerGuard?.owner) || safeOwner(payload?.metadata?.cloudOwner) || safeOwner(payload?.metadata?.owner) || safeOwner(payload?.owner) || safeOwner(task?.__cloudOwnerGuard?.owner) || safeOwner(task?.metadata?.cloudOwner) || safeOwner(task?.metadata?.owner) || safeOwner(task?.owner) || safeOwner(resolved?.owner);
7267
7515
  const kind = stored?.kind ?? payload?.metadata?.kind ?? payload?.kind ?? resolved?.kind ?? null;
7268
7516
  const times = rows.map((row) => timestamp(row.updated_at ?? row.created_at)).filter((time) => time !== null);
7269
- const byteSize = rows.reduce(
7270
- (sum, row) => sum + encoder.encode(
7271
- String(
7272
- row.value ?? row.encrypted_payload ?? row.encrypted_value ?? row.payload ?? row.subscription ?? ""
7273
- )
7274
- ).length,
7275
- 0
7276
- );
7517
+ const byteSize = rows.reduce((sum, row) => sum + rowByteSize(row), 0);
7277
7518
  const label = type === "state" ? `${namespace}/${key}` : type === "credential" ? credId : type === "task" ? rows[0].uuid || `task row ${rows[0].id}` : type === "outbox" ? rows[0].message_id : "Web Push subscription";
7278
7519
  entries.push({
7279
7520
  resource: {
@@ -7328,22 +7569,75 @@ async function buildCloudDataInventory({
7328
7569
  const slices = chunks.get(group) || [];
7329
7570
  chunks.delete(group);
7330
7571
  const rows = [row, ...slices.sort((a, b) => a.key.localeCompare(b.key))];
7331
- let value, unreadable = false;
7572
+ const metadataKey = JSON.stringify(["state", row.namespace, row.key]);
7573
+ const metadataInfo = await readStoredMetadata(metadataKey);
7574
+ const skipFullDecrypt = !metadataInfo.unreadable && hasCompleteStoredMetadata(metadataInfo.stored);
7575
+ let value, unreadable = metadataInfo.unreadable;
7332
7576
  try {
7333
- const count = parseChunkedRootCount(row.value);
7334
- if (count === null) value = await decryptFromStorage(row.value, userKey);
7335
- else {
7577
+ const rootMarker = row.value ?? row.value_prefix ?? "";
7578
+ const count = parseChunkedRootCount(rootMarker);
7579
+ if (count === null) {
7580
+ if (typeof row.value === "string" && (!skipFullDecrypt || row.value.length <= 1024)) {
7581
+ value = await decryptFromStorage(row.value, userKey);
7582
+ } else if (skipFullDecrypt) {
7583
+ if (!isValidStorageCiphertext(
7584
+ row.value,
7585
+ row.value_prefix,
7586
+ row.value_suffix,
7587
+ row.byte_size
7588
+ )) {
7589
+ throw new Error("Invalid state ciphertext");
7590
+ }
7591
+ } else {
7592
+ const fetched = typeof db.getClientState === "function" ? await db.getClientState(userId, row.namespace, { key: row.key }) : [];
7593
+ const rawValue = fetched?.[0]?.value;
7594
+ if (typeof rawValue !== "string") {
7595
+ throw new Error("Missing state value");
7596
+ }
7597
+ value = await decryptFromStorage(rawValue, userKey);
7598
+ }
7599
+ } else {
7336
7600
  if (count > slices.length) throw new Error("Missing state chunks");
7337
- const parts = [];
7601
+ const sliceByKey = new Map(
7602
+ slices.map((candidate) => [candidate.key, candidate])
7603
+ );
7604
+ const orderedSlices = [];
7338
7605
  for (let i = 0; i < count; i++) {
7339
- const slice = slices.find(
7340
- (candidate) => candidate.key === chunkKeyFor(row.key, i)
7341
- );
7606
+ const slice = sliceByKey.get(chunkKeyFor(row.key, i));
7342
7607
  if (!slice || slice.updated_at !== row.updated_at)
7343
7608
  throw new Error("Mismatched state chunks");
7344
- parts.push(await decryptFromStorage(slice.value, userKey));
7609
+ if (!isValidStorageCiphertext(
7610
+ slice.value,
7611
+ slice.value_prefix,
7612
+ slice.value_suffix,
7613
+ slice.byte_size
7614
+ )) {
7615
+ throw new Error("Corrupt state chunk");
7616
+ }
7617
+ orderedSlices.push(slice);
7618
+ }
7619
+ if (!skipFullDecrypt) {
7620
+ let fetchedByKey = null;
7621
+ if (orderedSlices.some((slice) => typeof slice.value !== "string")) {
7622
+ const fetchedSlices = typeof db.getClientState === "function" ? await db.getClientState(
7623
+ userId,
7624
+ CHUNK_NAMESPACE_PREFIX + row.namespace,
7625
+ { keyPrefix: `${row.key}` }
7626
+ ) : [];
7627
+ fetchedByKey = new Map(
7628
+ fetchedSlices.map((candidate) => [candidate.key, candidate.value])
7629
+ );
7630
+ }
7631
+ const parts = [];
7632
+ for (const slice of orderedSlices) {
7633
+ const rawSliceValue = typeof slice.value === "string" ? slice.value : fetchedByKey?.get(slice.key);
7634
+ if (typeof rawSliceValue !== "string") {
7635
+ throw new Error("Missing state chunk value");
7636
+ }
7637
+ parts.push(await decryptFromStorage(rawSliceValue, userKey));
7638
+ }
7639
+ value = parts.join("");
7345
7640
  }
7346
- value = parts.join("");
7347
7641
  }
7348
7642
  } catch {
7349
7643
  unreadable = true;
@@ -7356,7 +7650,8 @@ async function buildCloudDataInventory({
7356
7650
  rows,
7357
7651
  value,
7358
7652
  unreadable,
7359
- status: null
7653
+ status: null,
7654
+ metadataInfo
7360
7655
  });
7361
7656
  }
7362
7657
  for (const [group, rows] of chunks) {
@@ -7380,9 +7675,29 @@ async function buildCloudDataInventory({
7380
7675
  });
7381
7676
  }
7382
7677
  for (const row of raw.outbox || []) {
7383
- let payload, unreadable = false;
7678
+ const metadataKey = JSON.stringify(["outbox", row.message_id]);
7679
+ const metadataInfo = await readStoredMetadata(metadataKey);
7680
+ const skipFullDecrypt = !metadataInfo.unreadable && hasCompleteStoredMetadata(metadataInfo.stored);
7681
+ let payload, unreadable = metadataInfo.unreadable;
7384
7682
  try {
7385
- payload = objectOrText(await decryptFromStorage(row.payload, userKey));
7683
+ if (typeof row.payload === "string" && (!skipFullDecrypt || row.payload.length <= 1024)) {
7684
+ payload = objectOrText(await decryptFromStorage(row.payload, userKey));
7685
+ } else if (skipFullDecrypt) {
7686
+ if (!isValidStorageCiphertext(
7687
+ row.payload,
7688
+ row.payload_prefix,
7689
+ row.payload_suffix,
7690
+ row.byte_size
7691
+ )) {
7692
+ throw new Error("Invalid outbox ciphertext");
7693
+ }
7694
+ } else {
7695
+ const rawPayload = typeof db.getOutboxPayload === "function" ? await db.getOutboxPayload(userId, row.message_id) : null;
7696
+ if (typeof rawPayload !== "string") {
7697
+ throw new Error("Missing outbox payload");
7698
+ }
7699
+ payload = objectOrText(await decryptFromStorage(rawPayload, userKey));
7700
+ }
7386
7701
  } catch {
7387
7702
  unreadable = true;
7388
7703
  }
@@ -7393,7 +7708,8 @@ async function buildCloudDataInventory({
7393
7708
  payload,
7394
7709
  task: taskByUuid.get(row.task_uuid),
7395
7710
  unreadable,
7396
- status: row.acked_at != null ? "acked" : row.delivered_at != null ? "delivered" : "pending"
7711
+ status: row.acked_at != null ? "acked" : row.delivered_at != null ? "delivered" : "pending",
7712
+ metadataInfo
7397
7713
  });
7398
7714
  }
7399
7715
  for (const row of raw.subscription || []) {
@@ -7516,10 +7832,12 @@ async function makeCleanupPlan(ctx, db, userId, key, input) {
7516
7832
  "\u4E91\u7AEF\u6E05\u5355\u4E0D\u5B8C\u6574\uFF0C\u6682\u4E0D\u80FD\u751F\u6210\u6E05\u7406\u8BA1\u5212",
7517
7833
  409
7518
7834
  );
7835
+ const resourceIdSet = resourceIds ? new Set(resourceIds) : null;
7836
+ const typeSet = types ? new Set(types) : null;
7519
7837
  const entries = inventory.entries.filter(
7520
- (e) => (!owner || ownerEquals(e.resource.owner, owner)) && (!resourceIds || resourceIds.includes(e.resource.id)) && (!types || types.includes(e.resource.type))
7838
+ (e) => (!owner || ownerEquals(e.resource.owner, owner)) && (!resourceIdSet || resourceIdSet.has(e.resource.id)) && (!typeSet || typeSet.has(e.resource.type))
7521
7839
  );
7522
- if (resourceIds && new Set(resourceIds).size !== entries.length)
7840
+ if (resourceIdSet && resourceIdSet.size !== entries.length)
7523
7841
  throw cloudError(
7524
7842
  "CLOUD_RESOURCE_NOT_FOUND",
7525
7843
  "\u90E8\u5206\u9009\u4E2D\u8D44\u6E90\u4E0D\u5B58\u5728\u6216\u4E0D\u5C5E\u4E8E\u5F53\u524D\u8303\u56F4",
@@ -7677,6 +7995,7 @@ async function advanceCleanup(ctx, db, userId, key, operation) {
7677
7995
  if (operation.status === "completed" || operation.status === "failed")
7678
7996
  return operation;
7679
7997
  operation.status = "running";
7998
+ let retirementInventory = null;
7680
7999
  if (operation.mode === "retire-owner") {
7681
8000
  if (operation.fencedGeneration == null) {
7682
8001
  const state = await db.setCloudOwnerActive(
@@ -7704,6 +8023,7 @@ async function advanceCleanup(ctx, db, userId, key, operation) {
7704
8023
  "\u65E0\u6CD5\u786E\u8BA4\u5F85\u6E05\u7406\u8D44\u6E90",
7705
8024
  503
7706
8025
  );
8026
+ retirementInventory = inventory;
7707
8027
  const known = new Set(operation.knownResourceIds || []);
7708
8028
  operation.knownResourceVersions ||= {};
7709
8029
  operation.entries = inventory.entries.filter((entry) => {
@@ -7730,7 +8050,7 @@ async function advanceCleanup(ctx, db, userId, key, operation) {
7730
8050
  ).length;
7731
8051
  await save(db, userId, key, "operation", operation, { leaseToken });
7732
8052
  }
7733
- const beforeBatch = await cloudInventory(ctx, db, userId, key);
8053
+ const beforeBatch = retirementInventory ?? await cloudInventory(ctx, db, userId, key);
7734
8054
  if (!beforeBatch.complete)
7735
8055
  throw cloudError("CLOUD_INVENTORY_INCOMPLETE", "\u65E0\u6CD5\u786E\u8BA4\u5F85\u6E05\u7406\u8D44\u6E90", 503);
7736
8056
  const currentById = new Map(
@@ -7768,7 +8088,6 @@ async function advanceCleanup(ctx, db, userId, key, operation) {
7768
8088
  delete entry.locator;
7769
8089
  operation.cursor++;
7770
8090
  operation.updatedAt = Date.now();
7771
- await save(db, userId, key, "operation", operation, { leaseToken });
7772
8091
  }
7773
8092
  await renewLease();
7774
8093
  const current = await cloudInventory(ctx, db, userId, key);
@@ -7779,8 +8098,9 @@ async function advanceCleanup(ctx, db, userId, key, operation) {
7779
8098
  503
7780
8099
  );
7781
8100
  const selected = new Set(operation.entries.map((e) => e.resource.id));
8101
+ const knownIds = new Set(operation.knownResourceIds || []);
7782
8102
  const remaining = current.entries.filter(
7783
- (e) => operation.mode === "retire-owner" ? ownerEquals(e.resource.owner, operation.owner) || (operation.knownResourceIds || []).includes(e.resource.id) : selected.has(e.resource.id)
8103
+ (e) => operation.mode === "retire-owner" ? ownerEquals(e.resource.owner, operation.owner) || knownIds.has(e.resource.id) : selected.has(e.resource.id)
7784
8104
  );
7785
8105
  for (const count of operation.counts)
7786
8106
  count.remaining = remaining.filter(
@@ -8718,6 +9038,7 @@ export {
8718
9038
  timingSafeEqualBytes,
8719
9039
  deriveUserEncryptionKey,
8720
9040
  decryptPayload,
9041
+ encryptPayload,
8721
9042
  encryptForStorage,
8722
9043
  decryptFromStorage,
8723
9044
  CRED_ID_MAX_LENGTH,