@rei-standard/amsg-server 2.6.0-next.9 → 3.1.0-next.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +923 -43
  3. package/dist/adapters/d1.d.ts +693 -0
  4. package/dist/adapters/factory.d.ts +39 -0
  5. package/dist/adapters/interface.d.ts +387 -0
  6. package/dist/adapters/neon.d.ts +105 -0
  7. package/dist/adapters/pg-shared.d.ts +194 -0
  8. package/dist/adapters/pg.d.ts +101 -0
  9. package/dist/adapters/schema.d.ts +57 -0
  10. package/dist/adapters/schema.sqlite.d.ts +80 -0
  11. package/dist/chunk-3NIYNYZY.cjs +8922 -0
  12. package/dist/chunk-6TCZEACV.mjs +8922 -0
  13. package/dist/chunk-7NMQFTDJ.cjs +148 -0
  14. package/dist/{chunk-GOBCWCJP.mjs → chunk-GN44PST5.mjs} +68 -1
  15. package/dist/chunk-TKDISKCF.cjs +163 -0
  16. package/dist/chunk-XKUWMYVF.mjs +163 -0
  17. package/dist/cloudflare/single-user-worker.d.ts +39 -0
  18. package/dist/cloudflare.cjs +37 -2
  19. package/dist/cloudflare.d.ts +11 -2
  20. package/dist/cloudflare.mjs +38 -3
  21. package/dist/handlers/cancel-message.d.ts +3 -0
  22. package/dist/handlers/capabilities.d.ts +4 -0
  23. package/dist/handlers/client-state-namespaces.d.ts +5 -0
  24. package/dist/handlers/client-state.d.ts +7 -0
  25. package/dist/handlers/cloud-data.d.ts +4 -0
  26. package/dist/handlers/get-message.d.ts +3 -0
  27. package/dist/handlers/get-user-key.d.ts +3 -0
  28. package/dist/handlers/init-tenant.d.ts +23 -0
  29. package/dist/handlers/llm-credentials.d.ts +5 -0
  30. package/dist/handlers/messages.d.ts +3 -0
  31. package/dist/handlers/outbox.d.ts +9 -0
  32. package/dist/handlers/push-subscription.d.ts +5 -0
  33. package/dist/handlers/schedule-message.d.ts +3 -0
  34. package/dist/handlers/send-notifications.d.ts +3 -0
  35. package/dist/handlers/single-user-init.d.ts +13 -0
  36. package/dist/handlers/update-message.d.ts +3 -0
  37. package/dist/handlers/vapid-public-key.d.ts +19 -0
  38. package/dist/index.cjs +135 -44
  39. package/dist/index.d.cts +145 -766
  40. package/dist/index.d.ts +145 -766
  41. package/dist/index.mjs +127 -36
  42. package/dist/lib/agentic-fire.d.ts +178 -0
  43. package/dist/lib/attachments.d.ts +39 -0
  44. package/dist/lib/client-state-store.d.ts +90 -0
  45. package/dist/lib/cloud-data-cleanup.d.ts +36 -0
  46. package/dist/lib/cloud-data-guard.d.ts +14 -0
  47. package/dist/lib/cloud-data-inventory.d.ts +15 -0
  48. package/dist/lib/constant-time.d.ts +14 -0
  49. package/dist/lib/db-errors.d.ts +10 -0
  50. package/dist/lib/encryption.d.ts +48 -0
  51. package/dist/lib/errors.d.ts +273 -0
  52. package/dist/lib/llm-credentials-store.d.ts +147 -0
  53. package/dist/lib/llm.d.ts +12 -0
  54. package/dist/lib/message-processor.d.ts +82 -0
  55. package/dist/lib/outbox-store.d.ts +162 -0
  56. package/dist/lib/push-policy.d.ts +11 -0
  57. package/dist/lib/push-subscription-store.d.ts +94 -0
  58. package/dist/lib/recurrence.d.ts +51 -0
  59. package/dist/lib/request.d.ts +182 -0
  60. package/dist/lib/result-emitter.d.ts +60 -0
  61. package/dist/lib/retry-policy.d.ts +11 -0
  62. package/dist/lib/run-tick.d.ts +121 -0
  63. package/dist/lib/schema-version.d.ts +50 -0
  64. package/dist/lib/state-accessors.d.ts +74 -0
  65. package/dist/lib/state-chunks.d.ts +65 -0
  66. package/dist/lib/task-projection.d.ts +92 -0
  67. package/dist/lib/validation.d.ts +97 -0
  68. package/dist/lib/version.d.ts +7 -0
  69. package/dist/lib/webcrypto-utils.d.ts +1 -0
  70. package/dist/lib/webpush-webcrypto.d.ts +78 -0
  71. package/dist/{neon-QJ7KYHEX.mjs → neon-GKLT4UY3.mjs} +116 -44
  72. package/dist/{neon-V4VOH55N.cjs → neon-XHPJZESV.cjs} +121 -49
  73. package/dist/{pg-6X74HKMX.cjs → pg-FHXQ6BJP.cjs} +109 -48
  74. package/dist/{pg-DFJSFOJQ.mjs → pg-J4YXRQP5.mjs} +104 -43
  75. package/dist/single-user.d.ts +106 -0
  76. package/dist/tenant/blob-store.d.ts +30 -0
  77. package/dist/tenant/context.d.ts +47 -0
  78. package/dist/tenant/single-user-context.d.ts +29 -0
  79. package/dist/tenant/token.d.ts +26 -0
  80. package/package.json +3 -3
  81. package/dist/chunk-3QRVP4JE.cjs +0 -2834
  82. package/dist/chunk-DCKCAGFV.cjs +0 -81
  83. package/dist/chunk-S6O3MXQO.mjs +0 -2834
  84. package/dist/cloudflare-kUY6z-k5.d.cts +0 -4460
  85. package/dist/cloudflare-kUY6z-k5.d.ts +0 -4460
  86. package/dist/cloudflare.d.cts +0 -2
  87. package/dist/neon-D8Z2Nn97.d.cts +0 -295
  88. package/dist/neon-PqNFJIz2.d.ts +0 -295
  89. package/dist/pg-C48FwCWW.d.cts +0 -289
  90. package/dist/pg-C5gtWUfU.d.ts +0 -289
  91. package/dist/schema-BHuyH_6Z.d.cts +0 -88
  92. package/dist/schema-BHuyH_6Z.d.ts +0 -88
package/README.md CHANGED
@@ -2,14 +2,7 @@
2
2
 
3
3
  `@rei-standard/amsg-server` 是 ReiStandard 主动消息标准的服务端 SDK:Blob 租户配置、`tenantToken` / `cronToken` 鉴权、标准路由处理器。API 规范见 [API 技术规范](https://github.com/Tosd0/ReiStandard/blob/main/standards/active-messaging-api.md)。
4
4
 
5
- ## v2.0.1 变更摘要
6
-
7
- - 初始化流程合并为 `POST /api/v1/init-tenant`
8
- - 移除旧端点:`init-database`、`init-master-key`
9
- - 业务端点统一使用 `Authorization: Bearer <tenantToken>`
10
- - `send-notifications` 支持 `cronToken`(Header 或 query token)
11
-
12
- 2.2+ 的字段增量(`messages` 数组、`splitPattern`、`avatarUrl` 软清空策略)在规范的 [§6.1](https://github.com/Tosd0/ReiStandard/blob/main/standards/active-messaging-api.md#61-ai-消息字段约束) / [§6.2](https://github.com/Tosd0/ReiStandard/blob/main/standards/active-messaging-api.md#62-avatarurl-软清空策略)。其中 `splitPattern` 是 server 调度任务的持久化配置;`amsg-instant` 0.8.0 起改为 hook 内自定义 split 函数 + `pushPayloads`。
5
+ 历史变更见各版本 [CHANGELOG](https://github.com/Tosd0/ReiStandard/blob/main/packages/rei-standard-amsg/server/CHANGELOG.md)。2.2+ 的字段增量(`messages` 数组、`splitPattern`、`avatarUrl` 软清空策略)在规范的 [§6.1](https://github.com/Tosd0/ReiStandard/blob/main/standards/active-messaging-api.md#61-ai-消息字段约束) / [§6.2](https://github.com/Tosd0/ReiStandard/blob/main/standards/active-messaging-api.md#62-avatarurl-软清空策略)。其中 `splitPattern` 是 server 调度任务的持久化配置;`amsg-instant` 0.8.0 起改为 hook 内自定义 split 函数 + `pushPayloads`。
13
6
 
14
7
  ## 安装
15
8
 
@@ -22,7 +15,46 @@ npm install @neondatabase/serverless
22
15
  npm install pg
23
16
  ```
24
17
 
25
- ## 快速使用
18
+ ## 两条部署线
19
+
20
+ | | 单用户线(推荐) | 多租户线 |
21
+ |---|---|---|
22
+ | 入口 | `createSingleUserCloudflareWorker` / `createSingleUserServer` | `createReiServer` |
23
+ | 数据库 | D1 | pg / neon |
24
+ | 一个部署 | 服务一个用户 | 服务多个租户(Blob 存租户配置 + token 鉴权) |
25
+ | 服务端收件箱(`GET /outbox` 补拉) | ✅ | ❌ |
26
+ | `client_state` 云端镜像 | ✅ | ❌ |
27
+ | 推送订阅、LLM 凭据、定时 / 周期任务 | ✅ | ✅ |
28
+
29
+ **新接入走单用户线。** 收件箱是这套 SDK 的到达保证:每条 payload 发出去之前先落一行 `message_outbox`,客户端上线 `GET /outbox?since=` 一条不少地补得回来。有了它,到了客户端不会弹通知的 payload(思考过程、工具请求、错误)就不必占用推送通道——这是[哪些 payload 会发推送](#哪些-payload-会发推送)那一节的前提,也是不去赌 iOS 订阅宽限期的唯一办法。
30
+
31
+ 多租户线继续维护,任务、推送、凭据这些照常能用——但它的 pg / neon 适配器还没有 `message_outbox` 和 `client_state`,收件箱相关的端点返回 501,不会弹通知的 payload 也只能照旧推送。
32
+
33
+ > **TODO**:给 pg / neon 适配器补 `message_outbox` 那组方法(D1 的实现见 `src/server/adapters/d1.js`,建表 SQL 见 `adapters/schema.sqlite.js`)。补上之前,多租户线省不掉那些不会弹通知的推送。
34
+
35
+ ## 快速使用(单用户线)
36
+
37
+ 一个 Cloudflare Worker 装完两个入口:`fetch` 收客户端请求,`scheduled` 跑 cron 投递。
38
+
39
+ ```js
40
+ import { createSingleUserCloudflareWorker, createD1Adapter } from '@rei-standard/amsg-server';
41
+
42
+ export default createSingleUserCloudflareWorker((env) => ({
43
+ db: createD1Adapter(env.DB),
44
+ masterKey: env.MASTER_KEY,
45
+ vapid: {
46
+ email: env.VAPID_EMAIL,
47
+ publicKey: env.VAPID_PUBLIC_KEY,
48
+ privateKey: env.VAPID_PRIVATE_KEY,
49
+ },
50
+ }));
51
+ ```
52
+
53
+ 建表、绑定、cron 配置和 fire-time hook 的完整走法见 [`examples/cloudflare-single-user`](https://github.com/Tosd0/ReiStandard/blob/main/packages/rei-standard-amsg/server/examples/cloudflare-single-user/README.md)。
54
+
55
+ 客户端那侧还差一步:**应用启动时拉一次收件箱**。不会弹通知的内容(思考过程、工具请求、错误)只落收件箱、不发推送,不补拉就等于没有——做法见 [`@rei-standard/amsg-client` README 的「上线补一次收件箱」](https://github.com/Tosd0/ReiStandard/blob/main/packages/rei-standard-amsg/client/README.md#上线补一次收件箱)。
56
+
57
+ ## 快速使用(多租户线)
26
58
 
27
59
  ```js
28
60
  import { createReiServer } from '@rei-standard/amsg-server';
@@ -50,13 +82,38 @@ const rei = await createReiServer({
50
82
  // PUT /api/v1/update-message -> rei.handlers.updateMessage.PUT
51
83
  // DELETE /api/v1/cancel-message -> rei.handlers.cancelMessage.DELETE
52
84
  // GET /api/v1/messages -> rei.handlers.messages.GET
85
+ // GET /api/v1/message?id={uuid} -> rei.handlers.getMessage.GET
86
+ // PUT /api/v1/push-subscription -> rei.handlers.pushSubscription.PUT
87
+ // GET /api/v1/push-subscription -> rei.handlers.pushSubscription.GET
88
+ // DELETE /api/v1/push-subscription -> rei.handlers.pushSubscription.DELETE
89
+ // PUT /api/v1/llm-credentials -> rei.handlers.llmCredentials.PUT
90
+ // GET /api/v1/llm-credentials -> rei.handlers.llmCredentials.GET
91
+ // DELETE /api/v1/llm-credentials -> rei.handlers.llmCredentials.DELETE
53
92
  ```
54
93
 
94
+ ## 从 2.x 升级
95
+
96
+ 从 2.5.3 升级到 3.0.0 的多租户部署(`createReiServer`)和排程客户端需按以下顺序迁移:
97
+
98
+ 1. 先升级部署 `@rei-standard/amsg-server@3.0.0`,在多租户路由里挂载 `rei.handlers.pushSubscription`(必挂),并按需挂载 `rei.handlers.getMessage` 与 `rei.handlers.llmCredentials`。升级前已入库的老任务在投递时若查不到用户级订阅,会自动回退读取老任务 payload 内嵌的订阅。
99
+ 2. 客户端升级到 `@rei-standard/amsg-client@2.9.0`(建议同步将 `@rei-standard/amsg-sw` 升到 `2.4.0`)。
100
+ 3. 客户端在 `subscribePush()` 拿到订阅后、以及每次应用启动确认订阅仍有效时,调用 `await client.putPushSubscription(subscription)` 登记用户级订阅。
101
+ 4. 客户端调用 `client.scheduleMessage()` 和 `client.updateMessage()` 时去掉 `pushSubscription` 字段。
102
+
103
+ 主要破坏性变更与行为变化:
104
+
105
+ - **推送订阅改为用户级登记**:`POST /schedule-message` 和 `PUT /update-message` 带 `pushSubscription` 字段返回 `400 PUSH_SUBSCRIPTION_NOT_ACCEPTED`;用户未通过 `PUT /push-subscription` 登记过订阅时,`POST /schedule-message` 返回 `409 PUSH_SUBSCRIPTION_MISSING`。
106
+ - **包根加密工具函数改为 async**:`deriveUserEncryptionKey`、`decryptPayload`、`encryptForStorage`、`decryptFromStorage` 底层改为 Web Crypto API,全部返回 `Promise`,直接调用处需补 `await`;密文格式不变。
107
+ - **自定义数据库适配器接口补充**:通过 `tenant.adapterFactory` 自定义适配器时,需实现 `getPushSubscription` / `upsertPushSubscription` / `deletePushSubscription`(否则返 `501`),以及按需补齐 `claimTask`、`renewTaskLease`、`createTaskSuperseding`、`getTaskStatusInfo`、`getTaskStatusByUuidOnly`、`llm_credentials` 四方法等。内置 `pg` / `neon` 适配器已全部实现。
108
+ - **表结构自动补齐与投递语义更新**:每个进程首次拿到租户适配器时会幂等执行建表(`push_subscriptions`、`llm_credentials`)、`scheduled_messages` 补列(`lease_until`、`retry_after`、`serialize_group`、`last_error`)与建索引(`idx_serialize_group_lease`),数据库账号需有 DDL 权限;错过触发时刻超过 60 分钟的任务不再补发;LLM 上游 `400/401/402/403/404/405/413/422` 与推送服务 `404/410/413` 直接终审不再重试;默认 `messageId` 和 `sessionId` 带 `@<occurrenceMs>` 后缀。
109
+
110
+ 完整的改前/改后代码示例、自定义适配器方法清单及新老版本混用兼容表见 [CHANGELOG.md](./CHANGELOG.md) 的 `3.0.0` 条目。
111
+
55
112
  ## 关于 `messageType: 'instant'`
56
113
 
57
- > **两条 instant 路径,按各自特点选一条(都是正式支持路径):**
58
- > - **本端点的 `messageType: 'instant'`**(create task → process by UUID → delete task):任务先写进数据库再处理,投递不绑在请求连接上——客户端断开也没关系,任务行还在,能继续跑、能重试,想跑多久跑多久。适合**有数据库、需要长时间生成或保证消息零丢失**的场景。
59
- > - **[@rei-standard/amsg-instant](https://github.com/Tosd0/ReiStandard/blob/main/packages/rei-standard-amsg/instant/README.md)**:纯 SSE 流 + Web Push backup,不需要数据库,适合无状态边缘运行时(如 Cloudflare Workers)。它的处理挂在响应连接上,客户端一断开就只剩平台给的那点宽限期把活干完(Deno Deploy 实测 ≈20-30s),所以适合**能快速跑完的短即时消息**。
114
+ > **两条 instant 路径:**
115
+ > - **本端点的 `messageType: 'instant'`**(create task → process by UUID → delete task):任务先写进数据库再处理,投递不绑在请求连接上——客户端断开也没关系,任务行还在,能继续跑、能重试,想跑多久跑多久。落进收件箱的那份客户端上线也补得回来。有数据库就走这条。
116
+ > - **[@rei-standard/amsg-instant](https://github.com/Tosd0/ReiStandard/blob/main/packages/rei-standard-amsg/instant/README.md)**:纯 SSE 流 + Web Push backup,不需要数据库,跑得动无状态边缘运行时。处理挂在响应连接上,客户端一断开就只剩平台给的那点宽限期把活干完(Deno Deploy 实测 ≈20-30s);它也没有服务端收件箱,push 漏了的内容补不回来。这个包现在是维护态,新接入不从它起步。
60
117
 
61
118
  ## AI 接口 `apiUrl` 约束
62
119
 
@@ -119,19 +176,427 @@ const { bytes, remainingBytes, withinLimit } = measurePushPayload(JSON.stringify
119
176
  // remainingBytes = 还能再塞多少字节(已超限时为负)
120
177
  ```
121
178
 
179
+ ### 信封预留:fire-time hook 组 payload 时要多留一截
180
+
181
+ hook 把 `pushPayloads` 交还给库之后,库还会往每条 push 上补一批「这是谁、第几条、什么时候」的字段:
182
+
183
+ ```
184
+ messageId / sessionId / timestamp / messageIndex / totalMessages
185
+ taskId / taskUuid / recurrenceType / occurrenceMs
186
+ ```
187
+
188
+ 也就是说 hook 手里量到的不是最终 payload。这批字段占的字节由导出的 `PUSH_ENVELOPE_RESERVED_BYTES`(384 字节,含 JSON 的引号逗号,按 uuid ≤ 64 字符算)兜住;`measurePushPayload` 传 `{ reserveEnvelope: true }` 就是「库补完字段之后还装得下」的口径:
189
+
190
+ ```js
191
+ import { measurePushPayload, PUSH_ENVELOPE_RESERVED_BYTES } from '@rei-standard/amsg-server';
192
+
193
+ const { remainingBytes } = measurePushPayload(
194
+ JSON.stringify({ ...basePush, message: '' }),
195
+ { reserveEnvelope: true }
196
+ );
197
+ const message = body.length <= remainingBytes ? body : body.slice(0, remainingBytes);
198
+ ```
199
+
200
+ 用比 64 字符更长的 uuid(`scheduleTask` 允许传任意字符串)就自己再多留一点。
201
+
202
+ ### 哪些 payload 会发推送
203
+
204
+ 一条 payload 出门有两条腿:**落进 `message_outbox`**(到达的保证,客户端上线 `GET /outbox?since=` 补拉)和**发一条 Web Push**(及时性,当场叫人回来看)。收件箱那条腿每条 payload 都走,推送这条腿只留给「到了客户端会弹通知」的那些。
205
+
206
+ | payload | 落收件箱 | 发推送 |
207
+ | --- | --- | --- |
208
+ | `content` / `result` | ✅ | ✅ |
209
+ | `reasoning` / `tool_request` / `error` | ✅ | ❌ |
210
+ | 任意 kind + `notification: { show: false }` | ✅ | ❌ |
211
+ | 任意 kind + `notification: { show: 'always' \| 'when-hidden' }` | ✅ | ✅ |
212
+
213
+ 为什么这么分:订阅是按 `userVisibleOnly: true` 建的,每条 push 都欠用户一次可见反馈。`reasoning` / `tool_request` / `error` 在 Service Worker 那边是静默送给页面的,推过去不会有任何可见反馈,却要跟浏览器赊一次账——Firefox 对这类 push 有配额、超了退掉订阅,iOS 给新订阅几天宽限期、过后一条就吊销订阅,而且掉订阅是静默发生的,服务端只看得到后续推送返回 410。而这些内容在收件箱里一个字不少,客户端上线补拉就行(完整取舍见 `@rei-standard/amsg-sw` README 的「不展示通知的代价」一节)。
214
+
215
+ **想让某一条照样弹**,给它带上 `notification: { show: 'always' }`——判定读的是与 Service Worker 同一份规则(`@rei-standard/amsg-shared` 的 `notificationIntent`),宿主说了要弹,发送端就当它值得占用推送通道。逐条控制,不需要在服务端配开关。嫌打扰就配 `tag` 折叠加 `silent`,而不是不弹——只在用户看着页面时想安静的写 `silent: 'when-visible'`,静不静音由 Service Worker 收到时现算(发送端定不了这件事,它不知道用户此刻在不在前台)。`show: 'when-hidden'` 也照推(它到底弹不弹只有 Service Worker 当场知道),但那是给老部署留的兼容档,新代码在「一定弹」和「压根不推」里挑一个。
216
+
217
+ 跳过推送的那条不标 `delivered_at`,行留在收件箱里等客户端补收;这正是能跳过的前提。
218
+
219
+ **前提是这个部署有收件箱。** 内置适配器里只有 D1 实现了 `message_outbox`(见[两条部署线](#两条部署线))。落不进收件箱时——适配器没实现,或者这一批落行失败——推送是这条内容唯一的腿,所有 payload 照旧全推,包括不会弹通知的那些。宁可跟浏览器违约一次,也不能让内容凭空消失。
220
+
221
+ agentic 链路的 `onAfterSend` / `onFireSettled` 回执里,`sentCount` 是这批走完了几段,`pushedCount` 是其中真的占用了推送通道的有几条。`sentCount === total` 照旧表示整批都到位了。
222
+
223
+ ### 装不下就切片:`multipart`
224
+
225
+ 思考过程(reasoning)常常一条 push 装不下。真要把它推出去时(宿主给它配了 `notification.show`,或者这个部署没有收件箱),服务端会把它切成分片逐条发,Service Worker 收齐后还原成原样再走正常派发。切多大一片、最多切几片、收齐前能等多久,都是接收端说了算——所以给 `installReiSW` 传了什么,就把同一份原样传给服务端:
226
+
227
+ ```js
228
+ const multipart = { maxChunkBytes: 1800, maxChunks: 128, maxTotalBytes: 256_000, ttlMs: 60_000 };
229
+
230
+ installReiSW({ multipart }); // 页面
231
+ createReiServer({ tenant: { … }, multipart }); // 多租户
232
+ createSingleUserServer({ db, masterKey, multipart }); // 单用户
233
+ createSingleUserCloudflareWorker((env) => ({ …, multipart })); // CF Worker(cron 与 runTask 都认)
234
+ ```
235
+
236
+ | 键 | 默认值 | 说明 |
237
+ | --- | --- | --- |
238
+ | `maxChunkBytes` | `1800` | 每片装多少字节原文 |
239
+ | `maxChunks` | `128` | 一条消息最多切几片,超了就不发 |
240
+ | `maxTotalBytes` | `256000` | 整条消息的原文上限,超了就不发 |
241
+ | `ttlMs` | `60000` | 接收端收到第一片之后,等齐剩下分片能等多久 |
242
+
243
+ 不配 = 两边都用默认值。发送节奏也按 `ttlMs` 排:片数多时自动收紧每片之间的间隔,保证整批分片在这个窗口内发完;收紧到下限还装不下就一片都不发。
244
+
245
+ `maxChunkBytes` 只用来**收窄**,有上限:每片原文经 base64url 膨胀 4/3、再套上分片信封后,必须仍装得进单条 push 的明文上限(约 3993 字节),换算下来原文一片最多约 2800 字节。配超了服务端在投递时抛 `MULTIPART_CHUNK_BYTES_TOO_LARGE` 的配置错误(错误信息带当前配置下允许的最大值),不会切出一批推送服务收不了的分片。
246
+
247
+ 两边对不上的下场值得记一下:页面把 `maxChunks` 收窄到 32、服务端还按 128 切的话,分片到了接收端会被逐片拒收;节奏排得比窗口长的话,迟到的分片会被当过期丢掉。两种都是「页面上这条思考过程直接没有」,而服务端那边每一片都发成功、看不出任何异常。
248
+
249
+ ### 思考过程没送到时的可见性
250
+
251
+ > 这一节说的是**推送真的发出去过、但发挂了**的情况。有收件箱的部署上思考过程只落行、不推送(见[哪些 payload 会发推送](#哪些-payload-会发推送)),那条路上内容没丢,也就不会有下面这些信号。
252
+
253
+ 思考过程是正文之外的附赠内容:它没发出去不影响正文,任务照样算成功。这件事有三处看得见——
254
+
255
+ - 定时任务的 tick 汇总多一个 `details.reasoningSkippedTasks`:`[{ taskId, reason }]`。这些任务同时计在 `successCount` 里。
256
+ - instant 消息(`POST /schedule-message`)的成功响应带 `reasoningError`(字符串,只在思考过程没送到时出现)。
257
+ - 服务端日志各打一行:一行说原因,一行说是哪条任务。
258
+
259
+ 刻意不写进 `last_error`:那一列说的是「上一次没发出去的原因」,一条正文已经送达的消息挂着它,客户端会当成这次投递失败了。
260
+
261
+ ## 推送订阅(用户级)
262
+
263
+ 推送订阅一个用户存一份,任务行不携带它,到点投递时现读。用户清了站点数据、重装了 PWA、或者推送服务轮换了 endpoint 之后,覆盖这一份就够了——所有已排的任务,包括角色在 fire 里给自己排的、客户端根本不知道存在的那些,下次触发读到的都是新订阅。
264
+
265
+ | 端点 | 语义 |
266
+ |---|---|
267
+ | `PUT /push-subscription` | 登记 / 覆盖。body =(加密后的)`{ subscription, updatedAt? }`,`subscription` 至少要有非空 `endpoint` |
268
+ | `GET /push-subscription` | `{ exists, updatedAt, endpoint }`。不含订阅的密钥部分——判断「登记过没有、是不是我手里这一个」用 `endpoint` 就够 |
269
+ | `DELETE /push-subscription` | 删掉(设置页的「停止接收推送」) |
270
+
271
+ 客户端侧对应 `client.putPushSubscription(subscription)` / `getPushSubscription()` / `deletePushSubscription()`。什么时候调 PUT:`subscribePush()` 拿到订阅之后一次,之后每次应用启动确认订阅仍然有效时再一次(幂等覆盖)。
272
+
273
+ 配套的约束:
274
+
275
+ - `POST /schedule-message` 在这个用户还没登记订阅时返回 `409 PUSH_SUBSCRIPTION_MISSING`——建了也永远发不出去,早点说清楚比让它烂在库里强。
276
+ - `POST /schedule-message` 和 `PUT /update-message` 都不收 `pushSubscription` 字段(带了返回 `400 PUSH_SUBSCRIPTION_NOT_ACCEPTED`):静默丢弃会让人以为「这条任务用的是我传的这个订阅」。
277
+ - 投递时读不到订阅(没登记 / 被删了)→ 任务按投递失败处理,原因记进 payload 的 `lastError`,`GET /messages` 上看得见。
278
+ - 数据库侧是 `push_subscriptions` 表(`user_id` 主键,`subscription` 密文,`updated_at` epoch 毫秒)。内置的 D1 / pg / neon 适配器都实现了,`initSchema()` 会建表。自定义适配器要补 `getPushSubscription` / `upsertPushSubscription` / `deletePushSubscription` 三个方法,缺任何一个这几个端点返回 501。
279
+
122
280
  装不下的内容(长文、附件详情)建议走旁路:正文存进 `client_state`,push 里只带一个引用键,客户端上线后用 `GET /client-state` 取回。单用户 Worker 的 fire-time hook 用 `ctx.writeState()` 写,见 [`examples/cloudflare-single-user/README.md`](https://github.com/Tosd0/ReiStandard/blob/main/packages/rei-standard-amsg/server/examples/cloudflare-single-user/README.md)。
123
281
 
282
+ ## LLM 凭据(用户级)与 credRefs
283
+
284
+ LLM API 凭据(`apiUrl` / `apiKey` / `primaryModel`)有两种给法:
285
+
286
+ - **内联**:随排程请求带三件套,冻结进任务行(一直以来的方式,继续支持)。
287
+ - **引用**:凭据先用 `PUT /llm-credentials` 集中登记,排程 payload 里只带
288
+ `credRefs: { chat: '<credId>' }`。到点投递时按 credId 现读——换 Key 覆盖对应
289
+ 行就够了,所有引用它的任务(包括角色在 fire 里给自己排的、客户端根本不知道
290
+ 存在的那些)下次触发用的都是新凭据。
291
+
292
+ | 端点 | 语义 |
293
+ |---|---|
294
+ | `PUT /llm-credentials` | 批量登记 / 覆盖。body =(加密后的)`{ credentials: [{ credId, value: { apiUrl, apiKey, primaryModel } }] }`,一批 ≤100 条,单用户 ≤500 行 |
295
+ | `GET /llm-credentials` | 对账清单 `{ credentials: [{ credId, updatedAt }] }`。**凭据本体永远不回传** |
296
+ | `DELETE /llm-credentials` | 删除。body =(加密后的)`{ credIds: [...] }` / `{ all: true }` / `{ credIdPrefix: 'char:<charId>/' }`,**三选一**,混着传返回 400(其中 `credIdPrefix` 仅 D1 适配器支持,pg / neon 返回 501) |
297
+
298
+ 客户端侧对应 `client.putLlmCredentials(credentials)` / `listLlmCredentials()` / `deleteLlmCredentials(opts)`。
299
+
300
+ `credId` 是客户端起名的**不透明字符串**(1–128 字符、不含控制字符),服务端不
301
+ 解释语义。约定:`char:<charId>/<purpose>`(角色级)、`global/<purpose>`(全
302
+ 局)。`credRefs` 的 purpose 键里只有 `chat` 由服务端消费(fire 时的主 LLM 调
303
+ 用);其余 purpose(如情绪评估的副 API)归宿主 hook 侧,fire hook 的 ctx 上有
304
+ `resolveLlmCredential(credId)` 可按需取用(每次返回新对象;拿到就用,别挂到
305
+ ctx / metadata / push 上)。
306
+
307
+ 配套的规则:
308
+
309
+ - 排程 / 更新时对 `credRefs` 里的**全部** credId 做存在性检查,缺的返回
310
+ `409 CREDENTIAL_NOT_FOUND` 并点名(先登记再排程)。
311
+ - `credRefs.chat` 与内联三件套在同一个请求里不能都传(`400`):chat 凭据只能
312
+ 有一个来源。
313
+ - fire 时的解析顺序:`credRefs.chat` → 查表;行没了 → 退回任务里的内联三件套
314
+ (如有);都没有 → 本轮失败,`lastError` 记 `CREDENTIAL_MISSING`,走常规重
315
+ 试——补传凭据后下一轮自愈。
316
+ - hook 的 `ctx.scheduleTask()` 自排任务时按 **`credRefs.chat`** 分支:父任务带
317
+ chat 引用 → 复制整份引用、不复制凭据本体,换 Key 自动作用于整条自排链;父任
318
+ 务只带非 chat 引用(如仅 emotion)→ 引用与内联三件套**都**复制(引用归 hook
319
+ 用途,聊天凭据在内联那份里);存量内联任务照旧复制三件套。
320
+ - `prompted` / `auto` 任务 fire 时既无 `credRefs.chat` 也无内联三件套 → 按
321
+ `CREDENTIAL_MISSING` 失败进常规重试(不会被静默判成「不需要 LLM」)。
322
+ `instant` 保持「无凭据 = 纯推送 `userMessage`」的路由语义。
323
+ - 任务投影(`GET /messages` / hook 的 `ctx.task`)带 `credRefs`(只是名字,不
324
+ 是机密),客户端对账用;凭据本体照旧被白名单挡在外面。
325
+ - 数据库侧是 `llm_credentials` 表(`(user_id, cred_id)` 主键,`encrypted_value`
326
+ 密文,时间戳 ISO8601 文本)。内置的 D1 / pg / neon 适配器都实现了,
327
+ `initSchema()` 会建表。自定义适配器要补 `upsertLlmCredentials` /
328
+ `getLlmCredentials` / `listLlmCredentials` / `deleteLlmCredentials` 四个方法,
329
+ 缺任何一个这几个端点返回 501、带 `credRefs` 的排程也会被拒。
330
+ - 特性探测:`GET /capabilities` 的 `features` 含 `'llm-credentials'`。
331
+
332
+ ## 读一条任务:列表 vs 单条
333
+
334
+ | 端点 | 给什么 |
335
+ |---|---|
336
+ | `GET /messages` | 任务列表。每条只带 `charId` / `clientTaskId` 两个 `metadata` 子字段 |
337
+ | `GET /message?id=<uuid>` | 单条任务。同样的形状,外加**完整的 `metadata`** |
338
+
339
+ 什么时候需要单条:`PUT /update-message` 对 `metadata` 是**整体替换**(不深合并),所以「只改 metadata 里的一个键」必须先把完整的那份读回来,改完再整份传上去;只传一部分会把宿主存在里面的其余键(任务指令、锚点时间戳、过期策略之类)一起冲掉。列表不带整份 metadata,是因为一页最多 100 条,每条都驮着它会把响应撑得很大,而列表要的只是「有哪些任务」。
340
+
341
+ `GET /message` 只读得到还没发出去的任务;已完成 / 已失败的返回 `409 TASK_ALREADY_COMPLETED`,不存在返回 `404 TASK_NOT_FOUND`(与 `PUT /update-message` 同一口径)。响应和列表一样是加密的,客户端侧对应 `client.getMessage(uuid)`。
342
+
343
+ ## 更新任务时能改哪些字段
344
+
345
+ `PUT /update-message` 的可写字段:`contactName` / `avatarUrl` / `userMessage` / `completePrompt` / `messages` / `nextSendAt` / `recurrenceType` / `tzId` / `metadata` / `messageSubtype` / `maxTokens` / `temperature` / `splitPattern` / `llmExtraBody`、凭据三件套 `apiUrl` / `apiKey` / `primaryModel`,以及凭据引用 `credRefs`。
346
+
347
+ - `contactName` 必须是非空字符串(口径与排程时一致),空串 / `null` / 非字符串一律 `400`。用户给角色改了名之后,之前排的任务推送出来的通知标题(「来自 <contactName>」)靠它跟着改。
348
+ - `metadata` 是整体替换,不深合并——只改一个子字段的读-改-写流程见上一节。
349
+ - `taskInput` 只在创建或替换任务时设置。更新请求带这个字段(包括 `null`)返回 `400 INVALID_UPDATE_DATA`,`details.invalidFields` 为 `['taskInput']`;整份请求被拒,同传的其他字段也不会修改。
350
+ - `avatarUrl` 显式传 `null` 是「不改」而不是「清空」(§6.2 的软清空策略要求非法头像被摘掉时保留旧头像,「摘掉」和「传了个 null」在这一层是同一件事)。
351
+ - 凭据三件套传 `null` 同样只是忽略:清掉任何一个,任务到点就发不出去。
352
+ - 凭据三件套只对**没存 `credRefs.chat`** 的任务是「凭据刷新」入口。任务已经通过 `credRefs.chat` 引用凭据的话,触发时以凭据表那行为准、内联只是表行缺失的兜底,改内联不会生效——这种组合返回 `409 TASK_USES_CRED_REFS`,换 Key 走 `PUT /llm-credentials` 覆盖对应凭据,或在请求里改用 `credRefs` 指向新凭据。
353
+ - `credRefs` 是整体替换(语义同 `metadata`),同样做存在性检查;与内联三件套在同一个请求里混着传返回 `400`。给存量内联任务补 `credRefs` 时不动已存的三件套——那份留作 fire 时表行缺失的兜底。
354
+ - `pushSubscription` 不收(`400 PUSH_SUBSCRIPTION_NOT_ACCEPTED`),它是用户级的一份,走 `PUT /push-subscription`。
355
+ - `userMessage` 给了就必须是字符串(口径与排程时一致):它到点要过正则切分,别的类型收进来只会在投递时炸。
356
+ - `messageSubtype` / `llmExtraBody` 显式传 `null` 是「改回默认」(分别是投递时的 `'chat'` 和「不透传额外参数」),不会被当成「不改」吞掉。
357
+ - 响应里的 `updatedFields` 只列真正落进这次更新的字段。请求里带了但没被应用的键——这个接口不接受的、拼错的、传了 `null` 走「不改」语义的——不会出现在里面。
358
+
359
+ ## 取消 / 顶替时,没发出去的那几段也会撤掉
360
+
361
+ 适配器实现了 outbox 那组方法(内置 D1 有)时,每条 push 在发出去之前会先落一行 `message_outbox`,客户端离线或推送服务抽风时靠 `GET /outbox` 补收。这就带来一个收尾问题:一条任务投递到一半失败过的话,没发出去的那几段还留在 outbox 里等补收,光删任务行它们不会跟着走。
362
+
363
+ 所以 `DELETE /cancel-message` 和 `POST /schedule-message` 的 `supersedesUuid` 顶替,都会顺手把该任务名下还没发出去的行撤掉。已经推到设备上的分段不动——取消的意思是「别再发后面的」,不是「把用户已经收到的从收件箱里抹掉」,那几条留着让客户端照常 ack。
364
+
365
+ 清理是 best-effort:适配器没实现 outbox、或者清理本身出错,都不影响取消 / 顶替的成功返回(任务行已经删掉了)。
366
+
367
+ ## 推送自带任务身份
368
+
369
+ 每条从任务行发出去的 push(冻结 prompt 路径和 fire-time hook 路径都算)顶层带这四个字段:
370
+
371
+ | 字段 | 是什么 |
372
+ |---|---|
373
+ | `taskId` | 任务行 id;没有行的 in-server instant 路径为 `null` |
374
+ | `taskUuid` | 任务 uuid(排程方选的那个) |
375
+ | `recurrenceType` | `none` / `daily` / `weekly` —— 这条任务还会不会再来 |
376
+ | `occurrenceMs` | 本次触发的名义时刻,epoch 毫秒 |
377
+
378
+ 客户端据此认领任务:角色在 fire 里给自己排的任务,客户端从没见过它,靠这四个字段就能把它记进面板、让用户取消得掉。放在顶层而不是 `metadata` 里——`metadata` 是调用方自己的地盘,库不往里写。
379
+
380
+ hook 在 `pushPayloads` 里自己写了这几个字段的话会被库覆盖:它们描述的是任务行的事实,不是内容。
381
+
124
382
  ## Fire 时刻 hooks
125
383
 
126
384
  配上 `hooks: { onBeforeFire, onLLMOutput, executeToolCalls }` 之后,AI 类任务的 prompt 不再是排程那一刻冻结的文本,而是 cron 触发时现场组装,工具也在服务端就地跑完,全程不需要客户端在线。完整用法见 [`examples/cloudflare-single-user/README.md`](https://github.com/Tosd0/ReiStandard/blob/main/packages/rei-standard-amsg/server/examples/cloudflare-single-user/README.md) 的「Fire 时刻 hooks」。
127
385
 
386
+ `onBeforeFire` 的返回值决定这次触发怎么走:
387
+
388
+ | 返回值 | 这次触发怎么走 |
389
+ |---|---|
390
+ | 消息数组,或 `{ messages, maxToolIterations?, totalTimeoutMs?, tools?, toolChoice?, allowedToolNames? }` | 用这份 prompt 生成 |
391
+ | `{ skip: true }` | 这次不发,按零推送的成功收场:一次性任务删掉、循环任务推进到下一次 |
392
+ | `{ defer: { afterMs } }` | 现在不合适,`afterMs` 毫秒后再来问。见下面[推迟这次触发](#推迟这次触发defer) |
393
+ | `null` | 交还给排程时冻结的 prompt |
394
+ | 抛错 | 按投递失败处理:占一次重试、写 `last_error`、2 / 4 / 6 分钟退避 |
395
+
128
396
  三个 hook 拿到的 ctx 上都有这几个口子:
129
397
 
130
398
  | ctx 上的口子 | 干什么 |
131
399
  |---|---|
132
- | `readState(ns)` / `writeState(ns, entries)` | 读写 `client_state`,和客户端 `GET/PUT /client-state` 是同一份数据 |
400
+ | `readState(ns, { key }?)` / `writeState(ns, entries)` | 读写 `client_state`,按 key 读取时只返回零至一条完整值 |
401
+ | `storeAttachment(value, options?)` | 保存本次触发的附件,返回可放进结果里的完整引用 |
402
+ | `listTasks(options?)` | 分页读取当前用户名下的实际任务,默认带 metadata |
403
+ | `emitResult(payload)` | 给客户端送一条**不是聊天内容**的结果(落收件箱 + 推送) |
133
404
  | `scheduleTask(options)` | 给同一个用户再建一条定时任务 |
134
- | `scratch` | 本次 fire 的便签对象,三个 hook 共享同一个引用,fire 结束即丢弃 |
405
+ | `cancelTask(uuid)` | 取消同一个用户名下另一条待发任务(不能取消当前正在 fire 的任务),并撤掉它尚未投递的收件箱分段,返回 `{ cancelled }` |
406
+ | `renewTask(uuid, nextSendAt)` | 把同一个用户名下另一条待发任务改到新的触发时刻(至少比现在晚 60 秒),返回 `{ renewed: true, uuid, nextSendAt, task }` 或 `{ renewed: false, reason: 'not_found' \| 'in_flight' }` |
407
+ | `resolveLlmCredential(credId)` | 按 `credId` 现读一份已登记的 LLM 凭据(供非 chat 用途的副 API 使用),不存在或不支持时返回 `null` |
408
+ | `signal` / `isCancelled()` / `throwIfCancelled()` | 任务在执行期被取消或顶替时的协作取消信号,详见[取消正在执行的生成与工具](#取消正在执行的生成与工具) |
409
+ | `scratch` | 本次 fire 的便签对象,三个 hook 加上发送后的 `onAfterSend` / `onFireSettled` 共享同一个引用,fire 结束即丢弃 |
410
+
411
+ `onLLMOutput` / `executeToolCalls` 的 ctx 上另外带着任务身份:
412
+
413
+ | 字段 | 是什么 |
414
+ |---|---|
415
+ | `taskId` | 任务行 id |
416
+ | `taskUuid` | 任务 uuid(排程方选的那个) |
417
+ | `occurrenceMs` | 本次触发的名义时刻,epoch 毫秒 |
418
+
419
+ `sessionId` 是给日志和去重用的不透明字符串,格式随版本变,别拆它拿上面这些值。
420
+
421
+ ### 任务输入与按 key 读取状态
422
+
423
+ 排程时要固定下来的一份输入放在 `taskInput`:`POST /schedule-message` 接受对象或 `null`,与任务一起加密保存;`onBeforeFire` 通过 `ctx.task.taskInput` 读取。内置适配器在创建、原子替换任务时一起保存输入,替换请求被拒不会改掉旧任务的输入。任务列表和单条查询都不返回 `taskInput`,也不返回凭据本体。
424
+
425
+ `taskInput` 不能经 `PUT /update-message` 修改;输入有变化时创建替换任务。整份序列化任务(包括输入)共用 `MAX_TASK_PAYLOAD_BYTES` 上限,目前为 995,871 字节,HTTP 超限返回 `400 TASK_PAYLOAD_TOO_LARGE`。凭据用 `credRefs` 引用,别复制进输入里;这样换 Key 后已排任务仍能读到最新凭据。需要在触发时读取的动态配置继续用 `readState`。
426
+
427
+ 输入太大时,可以先写到宿主自己的 `client_state` 命名空间,每份输入使用独立且不再改写的 key,在 `taskInput` 中只保存引用:
428
+
429
+ ```js
430
+ async onBeforeFire(ctx) {
431
+ const { namespace, key } = ctx.task.taskInput;
432
+ const [entry] = await ctx.readState(namespace, { key });
433
+ if (!entry) throw new Error('任务输入不存在');
434
+ return buildMessages(JSON.parse(entry.value));
435
+ }
436
+ ```
437
+
438
+ 宿主负责校验引用、控制输入是否可修改,以及设置所有者和保留时间。`readState(ns, { key })` 返回零至一条已解密、已拼好分块的记录;`readState(ns)` 读取整个命名空间。D1 按索引选根行,只读取它的分块范围。旧自定义适配器忽略选择参数时仍会过滤返回结果,但底层可能读取整个命名空间;实现 `getClientState(userId, namespace, { key?, keyPrefix? })` 才能获得同样的读取范围。
439
+
440
+ 自定义加密受理路由可从包根或 `@rei-standard/amsg-server/cloudflare` 导入 `deriveUserEncryptionKey`、`decryptPayload`、`encryptPayload`。Cloudflare 子路径使用 Web Crypto,不需要导入私有文件或 Node 依赖。
441
+
442
+ ### 工具执行名单
443
+
444
+ `onBeforeFire` 返回对象中的 `allowedToolNames` 控制本次 fire 允许执行哪些工具,与发给模型的 `tools` / `toolChoice` 分开设置:
445
+
446
+ | 值 | 执行规则 |
447
+ |---|---|
448
+ | 不传 | 有效调用按宿主 hook 的原有约定执行 |
449
+ | `[]` | 不执行任何工具 |
450
+ | 非空字符串数组 | 只执行名单里的名称 |
451
+
452
+ `onLLMOutput` 返回的原生调用和文本协议调用都要经过这道检查,再交给 `executeToolCalls`。被挡住的调用收到 `error.code: 'TOOL_NOT_ALLOWED'` 的工具结果,模型可以据此继续下一轮。混入 `null`、缺少有效函数名的条目收到 `INVALID_TOOL_CALL`,不会交给执行器;缺少 id 的调用会补一个避开现有 id 的标识,assistant 声明和工具结果共用这个标识。
453
+
454
+ 宿主仍需安全解析文本协议,并在直接调用的业务操作里做自己的权限判断。分类器应覆盖模型声明的全部原生调用;想拒绝其中一项时要返回对应调用让库处理,不能只把它从列表删掉而留下无人应答的 assistant 声明。
455
+
456
+ ### `ctx.listTasks(options)`
457
+
458
+ `onBeforeFire`、`onLLMOutput` 和 `executeToolCalls` 都能分页读取当前用户的任务:
459
+
460
+ ```js
461
+ const page = await ctx.listTasks({
462
+ status: 'pending', // pending / sent / failed / all
463
+ limit: 100, // 1–100
464
+ offset: 0,
465
+ includeMetadata: true,
466
+ });
467
+ // { tasks, pagination: { total, limit, offset, hasMore } }
468
+ ```
469
+
470
+ 示例中的值都是默认值。结果沿用 HTTP 任务接口的安全字段,默认额外带完整 `metadata`,不包含凭据本体或 `taskInput`。用户边界由库限制,角色等应用归属由宿主按 metadata 筛选。
471
+
472
+ `pending` 包括到期未结算、已占位和正在执行的任务,也可能包含当前 fire 的这一条。一次性任务成功完成后会从列表消失。计算名额或判断某条任务不存在前,应翻完所有页;每页读取的是当时的数据库状态,翻页期间有并发修改时需要再次对账。
473
+
474
+ `scheduleTask` 和 `renewTask` 的成功回执带 `task`,描述已接受的变更。重复 UUID 的创建回执也带可读到的原任务。这些回执不作为持久日志保存;宿主可用 `emitResult` 送回客户端,漏收时用任务列表对账,不能把读取了一份新上下文当成客户端已认领任务的确认。
475
+
476
+ ### 本次触发的附件
477
+
478
+ 长文或结果详情可以保存成附件。推送和 outbox 照样带通知要显示的文字(或摘要),附件只放打开应用后才需要的完整内容:
479
+
480
+ ```js
481
+ const ref = await ctx.storeAttachment(JSON.stringify(result), {
482
+ ttlMs: 7 * 24 * 60 * 60 * 1000,
483
+ owner: { type: 'character', id: characterId },
484
+ kind: 'attachment',
485
+ ownerGeneration,
486
+ });
487
+ // { version: 1, namespace, key, taskUuid, occurrenceMs, expiresAt }
488
+ ```
489
+
490
+ `storeAttachment` 接受已序列化的字符串,每次在 `amsg:attachments` 下生成新 key,保存的内容绑定任务和触发时刻。重试可能留下另一份附件,但不会覆盖先前结果引用的内容。把完整 `ref` 和要显示的文字一起放进推送或结果载荷,客户端用 `client.getAttachment(ref)` 取回原文。应用被关掉时,SW 只显示推送里带着的文字,不会去读附件,所以只带引用的推送弹不出有内容的通知。附件读取不删除也不确认消费,其他设备、补收和推送重试可以继续使用同一引用。
491
+
492
+ 附件归属于角色等应用实体时应显式传 `owner`、`ownerGeneration` 和 `kind`(默认 `'attachment'`)。这些字段进入加密的云端资源元数据和附件内容,普通的所有者清理可以找到它们。原任务的所有者及代次护栏仍生效,不能跨所有者写入,也不能恢复已停用的所有者。
493
+
494
+ 内置适配器中只有 D1 支持附件;pg / neon 调用时抛 `DeploymentConfigError`,code 为 `ATTACHMENT_STORAGE_UNSUPPORTED`。自定义适配器需实现 `client_state` 读写,回执支持情况见客户端 README 的同步队列说明。包支持附件不等于当前部署的适配器支持。
495
+
496
+ 引用的 `ttlMs` 默认且最长为 30 天,可以设得更短;过期、缺失或内容与引用不匹配时,客户端返回 `null`,读库或网络错误仍抛出。物理清理由运行中的 scheduled tick 和适配器的 `cleanupClientState` 执行,默认清掉 30 天前的附件根行及分块。宿主的 `clientStateTtl['amsg:attachments']` 优先:配 7 天会提前移除尚未逻辑过期的附件,配 60 天只延后物理删除,不延长引用的有效期。保留时间应覆盖宿主需要的补收窗口。
497
+
498
+ 附件仍受状态单值及分块限制,默认 5 MiB 的单值上限计算的是包含引用、所有者等字段的整份序列化附件。`onFireSettled` 也提供 `storeAttachment`;与该钩子的 `writeState` 一样,任务取消后仍可保存收尾记录,所有者停用和代次护栏不会因此绕过。
499
+
500
+ ### 特性探测
501
+
502
+ `GET /capabilities` 的 `features` 包含以下标记:
503
+
504
+ | 标记 | 可识别的接口能力 |
505
+ |---|---|
506
+ | `task-input` | 任务输入与创建/替换绑定 |
507
+ | `client-state-key` | 按 key 读取状态 |
508
+ | `agentic-list-tasks` | fire hook 分页列任务 |
509
+ | `agentic-tool-allowlist` | 工具执行名单 |
510
+ | `attachments` | 附件写入及引用读取协议 |
511
+
512
+ 这些标记描述代码能力,不反映部署配置;尤其 `attachments` 不保证当前数据库适配器支持存储。客户端启用功能前先探测标记,别按包版本猜测;更新浏览器 SDK 不会自动更新已部署的 Worker。
513
+
514
+ ### 推迟这次触发(defer)
515
+
516
+ 任务到点了,但宿主这会儿不方便生成——比如同一段对话里正有另一轮回复在生成,想等它结束再说。`onBeforeFire` 返回 `{ defer: { afterMs } }`:
517
+
518
+ ```js
519
+ async onBeforeFire(ctx) {
520
+ if (await isConversationBusy(ctx)) return { defer: { afterMs: 30_000 } };
521
+ return buildMessages(ctx);
522
+ }
523
+ ```
524
+
525
+ 这一次不调 LLM、不推送,也不调 `onLLMOutput` / `onAfterSend`。任务行上只有两处变化:`retry_after` 写成现在 + `afterMs`,租约放掉。到点之后 cron 把它重新捞起来,从 `onBeforeFire` 再走一遍,那时可以继续推迟。
526
+
527
+ 跟抛错重试的区别:
528
+
529
+ | | 抛错 | `{ defer }` |
530
+ |---|---|---|
531
+ | `retry_count` | 加一,用完就终审 | 不变 |
532
+ | `last_error` | 写上这次的原因 | 不写,也不清已有的 |
533
+ | 多久之后再来 | 固定 2 / 4 / 6 分钟 | 宿主给的 `afterMs` |
534
+ | `onFireSettled` 的 `status` | `failed` | `deferred`,带 `retryAfter` |
535
+
536
+ 两边相同的是 `next_send_at` 都不动:它一直是这次触发的名义时刻,`ctx.task.nextSendAt`、`occurrenceMs`、推送的 `messageId` 在推迟前后都是同一套。循环任务被推迟时不会推进到下一次。
537
+
538
+ 几条规矩:
539
+
540
+ - `afterMs` 必须是有限正数,最大 24 小时(导出为 `MAX_DEFER_AFTER_MS`)。不合法的值按 hook 契约违约处理(`AGENTIC_BAD_BEFORE_FIRE`,一跳终审)。没有下限,但 cron 一分钟一跳,实际是「到点之后的第一跳」才会再问;用 `runTask` 自己触发的话,到点之前调它会得到 `retry_pending`。
541
+ - 推迟不会让任务永远挂着。名义时刻过去超过 `staleAfterMs`(默认 60 分钟)的任务照常按过期处理:一次性任务标 `failed`、循环任务快进到下一次,并调 `onStaleSkip`。某次推迟的唤醒时刻已经越过这条线时,库不再等那一轮,当场按过期收场——这时宿主会先收到 `status: 'deferred'` 的 `onFireSettled`,紧接着收到 `onStaleSkip`。
542
+ - 每次被重新问到都是一次新的 fire:`scratch` 是新的,`onFireSettled` 各调各的。
543
+ - 只有定时投递(`runScheduledTick`、单用户 Worker 的 `scheduled()`、`runTask`)支持推迟,并且适配器要实现 `claimTask`(任务表有 `retry_after` 列,内置适配器都有)。`messageType: 'instant'` 在请求里当场投递的任务、以及没实现 `claimTask` 的自定义适配器返回 `{ defer }` 会得到 `AGENTIC_DEFER_UNSUPPORTED`,按[部署配错了算可重试](#部署配错了算可重试)处理。
544
+ - 内容已经落进收件箱、只是推送没发完的重试不调 `onBeforeFire`(见[生成成功之后推送失败:只补推送](#生成成功之后推送失败只补推送)),那一跳也就不会被推迟。
545
+ - 配了 `serializeBy` 时,被推迟的任务不占着分组:同组里排在它后面的任务可以先跑。
546
+
547
+ tick 汇总的 `details.deferredTasks`(`{ taskId, retryAfter }`)列出这一跳被推迟的任务,它们不计入 `successCount` / `failedCount`。`GET /capabilities` 的 features 里有 `before-fire-defer`。
548
+
549
+ ### config 级 hook
550
+
551
+ 这三个挂在 worker 工厂 config 的顶层(不在 `hooks` 里):
552
+
553
+ | hook | 什么时候调 | 调用签名 / 载荷 |
554
+ |---|---|---|
555
+ | `onAfterSend(info)` | fire 的 pushPayloads 逐段发完,或中途发挂 | `{ task, sentCount, pushedCount, total, error, usage, usageTotal, llmCalls, outboxed, scratch, readState, writeState, emitResult }` |
556
+ | `onFireSettled(info)` | 一次 fire 收尾——只要 `onBeforeFire` 被调用过,什么结局都调一次 | `{ task, status, skipReason, retryAfter, willRetry, failureStage, sentCount, pushedCount, total, iterations, error, metadata, usage, usageTotal, llmCalls, outboxed, scratch, readState, writeState, emitResult, storeAttachment, signal, isCancelled, throwIfCancelled }` |
557
+ | `onStaleSkip(task, info)` | 任务错过触发时刻超过 60 分钟、这一次(或这几次)不再补发 | 第一个参数为任务行 `task`,第二个参数 `info` 为 `{ reason, action, metadata, recurrenceType, occurrenceMs, skippedCount, skippedOccurrences, skippedTruncated, nextSendAt, readState, writeState, emitResult }` |
558
+
559
+ 三个 hook 都自带 `readState` / `writeState` / `emitResult`,作用于当前用户,语义与 fire 级那套一致。`onStaleSkip` 尤其需要:服务停摆恢复后的第一跳里可能一次 fire 都没跑过,而那正是它要留痕迹的时候。
560
+
561
+ `onAfterSend` 的 `scratch` 与本次 fire 的 `onBeforeFire` / `onLLMOutput` 是同一个引用——「这次生成了哪几段正文」之类的上下文直接从这里读,不用自建按任务分格的登记表。全部成功时 `error` 为 `null`;第 k 段失败时 `sentCount = k`、`error` 带原始错误,且在错误往上抛之前调用完。
562
+
563
+ `sentCount` 与 `pushedCount` 数的是两件事:`sentCount` 是这批走完了几段(只落收件箱、没占推送通道的那些也算走完),`pushedCount` 是其中真的发了 Web Push 的有几条。判整批跑完没有看 `sentCount === total`。
564
+
565
+ `onFireSettled` 是「这次 fire 结束了」这一个信号,`status` 说明结局:
566
+
567
+ | status | 什么时候 |
568
+ |---|---|
569
+ | `sent` | pushPayloads 全部发完(`sentCount === total`) |
570
+ | `skipped` | 这次不发。`skipReason` 区分是 `onBeforeFire` 直接 `{ skip: true }`(`'before-fire'`)还是模型跑完后判定不发(`'skip-push'`) |
571
+ | `deferred` | `onBeforeFire` 返回了 `{ defer: { afterMs } }`:这次没生成也没发,任务还在。`retryAfter`(ISO 时刻)之后会从 `onBeforeFire` 重新走一遍;这个字段在其余结局都是 `null` |
572
+ | `failed` | 链路抛错,`error` 带原始错误。发到第 k 段挂了也是这个:`sentCount = k`、`total` 是原本要发的段数 |
573
+ | `cancelled` | 任务在投递期间被取消或顶替,见[取消正在执行的任务](#取消正在执行的生成与工具) |
574
+ | `not-handled` | `onBeforeFire` 返回 `null`,这条任务交还给排程时冻结的 prompt 老链路。那条链路不归 fire hook 管,它后面发没发出去不体现在这里 |
575
+
576
+ **用量记账**看这三个字段,两个 hook 都带,`onFireSettled` 的每种结局(发完、跳过、失败)都带——失败也花了钱:
577
+
578
+ | 字段 | 是什么 |
579
+ |---|---|
580
+ | `usage` | 最后一轮 LLM 响应的 `usage` 原样(没跑到 LLM → `null`) |
581
+ | `usageTotal` | 本次 fire 所有 LLM 轮次加起来,形状固定 `{ prompt_tokens, completion_tokens, total_tokens }`。各家 usage 形状不齐时尽量相加:没有 `prompt_tokens` / `completion_tokens` 就认 `input_tokens` / `output_tokens`,某一轮没报 `total_tokens` 就拿那一轮的 prompt + completion 补上;一项所有轮次都没报过就是 `null`,所有轮次都没报 usage(或没跑到 LLM)时整个是 `null` |
582
+ | `llmCalls` | 本次 fire 实际发出的 LLM 请求数,失败的那一次也算;没跑到 LLM → `0` |
583
+
584
+ `status: 'not-handled'`(交还给冻结 prompt 老链路)时 `llmCalls` 是 `0`:老链路那一次生成发生在收尾之后,不归 fire hook 管。
585
+
586
+ `outboxed` 说的是 finish 的这一批有没有整批落进收件箱(没走到 finish → `false`)。`true` 时客户端补收一定拿得到全部 `total` 段;推送没发完的那部分,库在任务重试时**只补推送、不重新生成**,补推那一跳也不再调任何 hook(包括这两个)。所以 `status: 'failed'` 而 `outboxed: true` 的意思是「内容已经生成并落定,只是推送没发完」,而不是「这条消息没了」。详见[投递失败怎么重试](#投递失败怎么重试)。
587
+
588
+ `error.permanent === true` 表示这次失败一跳终审、不会再重试(hook 抛的 `NonRetryableError`、LLM 上游明确拒了这次请求、没落进收件箱的批次推到一半失败)。反过来不成立:推送服务回 404 / 410 / 413、订阅没登记这几种也是一跳终审,但判定在投递侧,错误对象上不带 `permanent`。
589
+
590
+ 跟 `onAfterSend` 的分工:`onAfterSend` 只走「有 push 要发」这条路,所以 hook 判断这次不用说话、或者链路中途抛错时它不会被调到——「开始时占点什么、结束时放掉」的写法要挂 `onFireSettled`(fire 里已经用 `ctx.scheduleTask` 建出来的任务,不记账就成了只活在数据库里的幽灵任务;fire 开头拿的锁,没有可靠释放点就只能等 TTL)。正常发完时两个都会调,`onAfterSend` 在前。`scratch` 是同一个引用。没配 hooks 的部署、以及不需要 LLM 的固定文本任务不走 fire 这条路径,两个都不会调。
591
+
592
+ `onStaleSkip` 的 `action` 分两种:
593
+
594
+ - `expired` —— 一次性任务,这一次永远不会补发了,行已标 `failed`。
595
+ - `fast_forwarded` —— 循环任务,攒下的这几次都跳过,排期已快进到 `nextSendAt`,行仍是 `pending`,下一次照常触发。
596
+
597
+ `skippedCount` 是一共跳过几次(含名义那一次),`skippedOccurrences` 是被跳过的名义时刻列表(epoch 毫秒);超过 32 次时只给首末两个并把 `skippedTruncated` 置 `true`。两种 action 都会把原因写进 payload 的 `lastError`,`GET /messages` 上看得见。
598
+
599
+ 两个 hook 都是 best-effort:自身抛错只记日志,不影响主流程。
135
600
 
136
601
  ### `ctx.scheduleTask(options)`
137
602
 
@@ -142,14 +607,18 @@ const result = await ctx.scheduleTask({
142
607
  firstSendTime: new Date(Date.now() + 90 * 60_000).toISOString(), // 必填,ISO 字符串
143
608
  messageType: 'auto', // 可选,默认继承当前任务
144
609
  recurrenceType: 'none', // 可选,默认 none
610
+ tzId: 'Asia/Tokyo', // 可选,默认继承当前任务;循环推进按这个时区的墙钟走
145
611
  metadata: { beat: 'followup' }, // 可选,整体替换当前任务的 metadata(不深合并)
146
- uuid: `fire-${ctx.task.id}-${ctx.task.nextSendAt}`, // 可选,默认随机
612
+ taskInput: { followup: 'one' }, // 可选,子任务自己的输入;不传就是 null
613
+ uuid: `fire-${ctx.taskId}-${ctx.occurrenceMs}`, // 可选,默认随机
147
614
  });
148
- // → { created: true, id, uuid, nextSendAt }
149
- // 或 { created: false, reason: 'duplicate', uuid }
615
+ // → { created: true, id, uuid, nextSendAt, task }
616
+ // 或 { created: false, reason: 'duplicate', uuid, task }
150
617
  ```
151
618
 
152
- 凭据和投递配置(`pushSubscription` / `apiUrl` / `apiKey` / `primaryModel` / `maxTokens` / `temperature` / `splitPattern`)以及 `contactName` / `avatarUrl` / `messageSubtype` / `userMessage` 从当前任务继承,宿主只说「什么时候、说什么方向」——hook 全程看不到凭据。`completePrompt` / `messages` 不继承(都置 `null`):hook 每次现场重组 prompt,把排程时冻结的旧 prompt 带过去,新任务万一走回冻结 prompt 老链路就会静默发出一条谁也没打算发的文案。
619
+ 新建成功与重复 UUID 两种回执都带 `task`,字段形状一致,包含完整 `metadata` 和凭据引用 `credRefs`,不含凭据本体或 `taskInput`。重复时返回数据库里当前的任务;行读不回来(例如已不再 pending)时为 `null`。`created` 与 `reason` 仍用于判断本次是否新建,不能只看 `task` 是否有值。
620
+
621
+ 凭据和投递配置(`apiUrl` / `apiKey` / `primaryModel` / `maxTokens` / `temperature` / `splitPattern`)以及 `contactName` / `avatarUrl` / `messageSubtype` / `userMessage` / `tzId` 从当前任务继承,宿主只说「什么时候、说什么方向」——hook 全程看不到凭据。推送订阅是用户级的一份,任务不携带、也不用继承。`completePrompt` / `messages` 不继承(都置 `null`):hook 每次现场重组 prompt,把排程时冻结的旧 prompt 带过去,新任务万一走回冻结 prompt 老链路就会静默发出一条谁也没打算发的文案。
153
622
 
154
623
  护栏:
155
624
 
@@ -159,22 +628,77 @@ const result = await ctx.scheduleTask({
159
628
  | `messageType` | 只收 `auto` / `prompted` / `fixed` | `TypeError` | `instant` 的语义是「建行的那一刻就投递」,那条路径归 `POST /schedule-message` 管;从 fire 里造这么一行,投递时机反而说不清 |
160
629
  | `messageType: 'fixed'` | 必须有 `userMessage`(自己传或继承到) | `TypeError` | 固定文本任务没有正文,就是一条永远发空的任务 |
161
630
  | 单次 fire 的建任务条数 | 默认 **2 条**,factory 配置 `maxScheduledTasksPerFire` 可调(`0` = 不许自排) | `RangeError` | 模型自排后续本质上是条能无限延伸的链,没有上限就没人按停止键 |
162
- | `uuid` 撞车 | 不当错误处理 | 返回 `{ created: false, reason: 'duplicate', uuid }` | fire 失败会整条重跑,宿主传一个由「任务 id + 触发时刻」推出来的确定性 uuid 就天然幂等 |
163
- | 数据库适配器没有 `createTask` | — | 抛 `AGENTIC_SCHEDULE_UNSUPPORTED` | 静默成功会让宿主以为后续那条排上了,其实谁也不会触发它 |
631
+ | `uuid` 撞车 | 不当错误处理 | 返回 `{ created: false, reason: 'duplicate', uuid, task }` | fire 失败会整条重跑,宿主传一个由「任务 id + 触发时刻」推出来的确定性 uuid 就天然幂等 |
632
+ | `tzId` | 可用的 IANA 时区 id,或 `null` | `TypeError` | 认不出来的时区会让循环推进悄悄退回 UTC,用户设的钟点从此对不上 |
633
+ | 任务内容大小 | 与 `POST /schedule-message` 同一道闸门 | 抛 `RangeError`(`code: 'TASK_PAYLOAD_TOO_LARGE'`) | 往 `metadata` 里塞一坨大对象会顶穿存储的单行上限,不拦的话到落库那步才炸,报错看不出所以然 |
634
+ | 数据库适配器没有 `createTask` | — | 抛 `DeploymentConfigError`(`code: 'AGENTIC_SCHEDULE_UNSUPPORTED'`) | 静默成功会让宿主以为后续那条排上了,其实谁也不会触发它 |
635
+
636
+ `taskInput` 只使用子任务显式传入的对象,不传或传 `null` 时为 `null`,不会继承父任务的输入。
164
637
 
165
638
  `recurrenceType` 沿用排程接口那套 `none` / `daily` / `weekly`,别的值抛 `TypeError`。参数不合法的调用不占建任务额度;uuid 撞车占(那条任务其实已经建出来了)。
166
639
 
640
+ ### `ctx.emitResult(payload)`
641
+
642
+ 聊天正文之外的产出——整理好的一份数据、一条账目、后台生成的产物——用它送给客户端。
643
+
644
+ ```js
645
+ const { messageId, pushed } = await ctx.emitResult({
646
+ resultKind: 'fire-pack', // 必填:这类结果的名字,客户端按它分流
647
+ packId: 'pack_42', // 以下随便加,形状由你定
648
+ entries: [{ id: 1 }, { id: 2 }],
649
+ notification: { title: '整理好了', body: '点开看看' }, // 可选,见下
650
+ });
651
+ ```
652
+
653
+ 一条结果走两条路,缺一不可:
654
+
655
+ | 路 | 负责什么 |
656
+ |---|---|
657
+ | 落进 `message_outbox` | **到达**。客户端下次 `GET /outbox?since=` 一定拿得到——推送没送到、内容超过一条推送 4KB 的上限,都不会让它丢 |
658
+ | 发一条 Web Push | **及时**。跑完当场弹一下叫人回来看,而不是等客户端下次上线 |
659
+
660
+ 客户端因此不必为每种结果各写一套轮询:补收机制已经在那儿了,结果跟聊天消息从同一个口子回来,靠 `messageKind === 'result'` 分开,再按 `resultKind` 分流。
661
+
662
+ **通知**默认弹(结果与聊天正文同待遇,其余 push 类型是静默送给页面)。标题正文在 `notification` 里自定义,字段与 SW 那套一致(`title` / `body` / `icon` / `tag` / …);不想弹就 `notification: { show: false }`。
663
+
664
+ > 带 `show: false` 的结果不发推送、只落收件箱(见[哪些 payload 会发推送](#哪些-payload-会发推送)):订阅按 `userVisibleOnly: true` 建,收到 push 却不弹通知,Firefox 按配额退订,iOS 在订阅的宽限期过后直接吊销(完整取舍见 `@rei-standard/amsg-sw` README 的「不展示通知的代价」一节)。结果这条本来就有收件箱兜底,客户端上线补拉拿得到。
665
+
666
+ **返回值**里 `pushed` 是「这次推送有没有真的发出去」。两种情况会是 `false`:带 `notification: { show: false }` 的结果按策略压根不推(见[哪些 payload 会发推送](#哪些-payload-会发推送)),以及推了但没送出去(订阅失效、推送服务抽风、payload 超过 4KB)。两种都不算失败,行还在收件箱里等补收,`emitResult` 也不会因此抛错。
667
+
668
+ **落行失败会抛**:收件箱是到达的保证,静默丢掉正是这个能力要修的病。适配器没有 `message_outbox`(自定义适配器)时同样抛,`code` 是 `OUTBOX_UNSUPPORTED`。
669
+
670
+ **取消**与聊天分段同待遇:结果行上带 `task_uuid`,`DELETE /message` 取消、`supersedesUuid` 顶替时,这条任务名下**还没送到**的结果一起撤掉;已经推到设备上的留着让客户端照常 ack(推出去的撤不回来)。
671
+
672
+ **重试**:`messageId` 缺省值掺了任务 id 与本次名义触发时刻,同一次触发重跑时第 n 条结果拿到的还是同一个 id,收件箱靠 `(user_id, message_id)` 唯一约束天然去重,不会补出第二条。想自己控制就在 payload 里传 `messageId`。
673
+
674
+ `GET /capabilities` 的 features 里有 `emit-result`。
675
+
676
+ ### hook 契约违约算确定性失败
677
+
678
+ 宿主 hook 返回了库不认的东西(`onBeforeFire` 的返回形状、`{ defer }` 里不合法的 `afterMs`、`onLLMOutput` 的决策标签),或者建后续任务时 `createTask` 没把行交回来——这些错误带 `permanent: true` 和一个稳定的 `code`(`AGENTIC_BAD_BEFORE_FIRE` / `AGENTIC_BAD_DECISION` / `AGENTIC_SCHEDULE_FAILED` / `TASK_PAYLOAD_TOO_LARGE`),投递侧据此跳过退避阶梯:一次性任务直接标 `failed`,循环任务作废本次 occurrence。重试也是同一个结果,而每重试一轮都要把 `onBeforeFire` 和一整轮 LLM 重跑一遍。
679
+
680
+ 分界线是「谁写错了」:契约由宿主代码定死,重掷一次还是同一个形状;而模型这一轮掷出了什么则是每轮都可能不同的。所以「tool-request 决策里没有能解析的 `toolCalls`」(`AGENTIC_EMPTY_TOOL_REQUEST`)和「轮数用尽也没等到 `finish` / `skip-push`」(`AGENTIC_LOOP_EXCEEDED`)带 `code` 但不带 `permanent`,留在退避阶梯上——隔两分钟重掷一次多半就正常收尾了,判终态的话一次性任务第一次掷歪就永久 `failed`,行离开 `pending` 之后连 `PUT /update-message` 都救不回来(回 409)。
681
+
682
+ ### 部署配错了算可重试
683
+
684
+ 部署缺了必要的能力——没配 `onLLMOutput` / `executeToolCalls`,或者自定义适配器没有 `createTask` / `deleteTaskByUuid` / `getTaskByUuid` / `upsertClientState`——抛的是 `DeploymentConfigError`:带同样的 `code`(`AGENTIC_CONFIG_ERROR` / `AGENTIC_SCHEDULE_UNSUPPORTED` / `AGENTIC_CANCEL_UNSUPPORTED` / `AGENTIC_RENEW_UNSUPPORTED` / `AGENTIC_STATE_WRITE_UNSUPPORTED`),但**不带** `permanent`,走的是普通的退避阶梯。
685
+
686
+ 因为坏的不是这条任务,是这个部署:同一个坏部署下每条到点的任务都会撞同一个错,判终态等于把那段时间里每一条一次性任务都永久标 `failed`,配置改好重新部署也捞不回来(行已不在 `pending`,`PUT /update-message` 回 409)。留在阶梯上的话,配置一修好,下一跳就正常发出去。VAPID 配错回的 400 / 401 / 403 是同一个道理,见下面的推送失败分级。
687
+
688
+ `AGENTIC_TOTAL_TIMEOUT`(整条 fire 链超出 `totalTimeoutMs`)也走退避重试:这一轮慢不代表下一轮也慢。
689
+
167
690
  `GET /capabilities` 的 features 里有 `agentic-schedule-task`,前端可以据此判断部署的 worker 认不认这条链路。
168
691
 
169
692
  ## 导出(新增)
170
693
 
171
- - `validateLlmMessagesArray(messages)` — 同步预校验 messages 数组,返回 `string | null`(错误信息 / 通过)。和 `@rei-standard/amsg-instant` 的校验规则字节级一致。
694
+ - `validateLlmMessagesArray(messages)` — 同步预校验 messages 数组,返回 `string | null`(错误信息 / 通过)。形状规则统一在 `@rei-standard/amsg-shared` 的 `validateLlmMessagesShape`,和 `@rei-standard/amsg-instant` 共用同一实现(含 agentic 会话:assistant 带 `tool_calls` 时 content 可空、`role:'tool'` 要求 `tool_call_id`)。
172
695
  - `validateSplitPattern(value)` — 同步预校验 splitPattern(string / string[] / null),返回 `string | null`。
173
696
  - `MAX_PUSH_PAYLOAD_BYTES` — 一条 push 的明文上限,3993 字节。
697
+ - `PUSH_ENVELOPE_RESERVED_BYTES` — 库在 hook 交还 payload 之后还要补的那批字段占的字节上界,384。
174
698
  - `WEB_PUSH_MAX_BODY_BYTES` / `WEB_PUSH_ENCRYPTION_OVERHEAD_BYTES` — 推送服务的密文 body 上限(4096)与 aes128gcm 固定开销(103),上面那个数就是两者相减。
175
- - `measurePushPayload(payload)` — 量一段 payload 的字节数与剩余额度,返回 `{ bytes, maxBytes, remainingBytes, withinLimit }`。
699
+ - `measurePushPayload(payload, options?)` — 量一段 payload 的字节数与剩余额度,返回 `{ bytes, maxBytes, remainingBytes, withinLimit, envelopeReservedBytes }`。
176
700
 
177
- 以上四个在包根和 `@rei-standard/amsg-server/cloudflare` 两个入口都有。
701
+ 其中 push 容量相关的常量和 `measurePushPayload` 在包根和 `@rei-standard/amsg-server/cloudflare` 两个入口都有;`validateLlmMessagesArray` 和 `validateSplitPattern` 在包根导出。
178
702
 
179
703
  ## 一体化初始化流程
180
704
 
@@ -185,11 +709,104 @@ const result = await ctx.scheduleTask({
185
709
 
186
710
  ## 端点鉴权
187
711
 
188
- - `get-user-key`、`schedule-message`、`update-message`、`cancel-message`、`messages`
712
+ - `get-user-key`、`schedule-message`、`update-message`、`cancel-message`、`messages`、`message`、`push-subscription`、`llm-credentials`
189
713
  - `Authorization: Bearer <tenantToken>`
190
714
  - `send-notifications`
191
715
  - `Authorization: Bearer <cronToken>` 或 `?token=<cronToken>`
192
716
 
717
+ ## 请求体可以压缩(`Content-Encoding: gzip`)
718
+
719
+ 带 `Content-Encoding: gzip` 的请求体在读出来的那一步自动解压,单用户 Worker 上每个带 body 的端点都认(`POST /schedule-message`、`PUT /client-state`、`PUT /llm-credentials`……)。客户端把大 body 压了再传能省下几倍传输量,两边都不用改端点。
720
+
721
+ ```js
722
+ await fetch(`${baseUrl}/client-state`, {
723
+ method: 'PUT',
724
+ headers: { ...encryptionHeaders, 'Content-Encoding': 'gzip' },
725
+ body: await gzip(JSON.stringify(encryptedEnvelope)),
726
+ });
727
+ ```
728
+
729
+ 几条边界:
730
+
731
+ | 情况 | 结果 |
732
+ |---|---|
733
+ | 没有这个头 | 原样读,行为与以前一字不差 |
734
+ | 说是 gzip、字节却是明文 | 按明文处理。有些边缘网关会替你解开请求体却留着这个头,照着头再解一次只会解出乱码 |
735
+ | `br` / `deflate` 之类 | `415 UNSUPPORTED_CONTENT_ENCODING`,不猜着解 |
736
+ | 解压后超过上限 | `413 REQUEST_BODY_TOO_LARGE`。默认 32MB,config 的 `maxRequestBodyBytes` 可调 |
737
+ | 声明了 gzip 但数据是坏的 | `400 INVALID_CONTENT_ENCODING` |
738
+
739
+ 上限只管压缩这条路:几百 KB 的压缩数据能展开成几个 GB,不设上限等于把内存交给调用方决定。不压缩的请求体不受它约束。
740
+
741
+ 自己包路由的宿主用 `readRequestBody(request, { maxBytes })` 代替 `await request.text()` 就能得到同样的行为,`GET /capabilities` 的 features 里有 `gzip-request-body`。
742
+
743
+ ## `client_state` 的过期清理(`clientStateTtl`)
744
+
745
+ 普通 `client_state` 命名空间默认不过期;附件命名空间 `amsg:attachments` 默认按 30 天清理,宿主可用同名配置覆盖。
746
+
747
+ 但「大内容旁路」那类用法写的是一次性内容——一条 push 塞不下的正文先写进状态、push 里只带一个引用键,客户端取走之后没人再回来删它。给这类命名空间配上天数,cron 每跳顺手清一次:
748
+
749
+ ```js
750
+ clientStateTtl: {
751
+ fire_pack: 7, // fire_pack 下超过 7 天没更新的条目自动清掉
752
+ scratch_pad: 1,
753
+ }
754
+ ```
755
+
756
+ - 普通命名空间逐个配置,未配置的不清理;附件命名空间使用默认 30 天,宿主同名配置优先。
757
+ - 判据是行本来就有的 `updated_at` 列,不加列——升级后老库不用改表结构,也就没有「表没跟上、cron 静默挂在缺的那一列上」这一说。
758
+ - 大值分块存储的切片行跟着根行一起走,不留读不出来的垃圾行。
759
+ - 天数不是正数的条目会被跳过并告警一次;清理本身失败只记日志,不影响这一跳的投递。
760
+
761
+ 一个坑说在前头:`PUT /client-state` 和 `writeState()` 的条件写护栏(entry 上的 `version`)落的就是 `updated_at` 这一列。护栏值传的是自增计数器之类的小整数时,这行的 `updated_at` 看起来就像 1970 年,第一次清理就会被扫走。要给某个命名空间配 TTL,就让它的写入方把 `version` 传成毫秒时间戳。
762
+
763
+ 反过来,`updated_at` 大幅领先真实时间的行(设备时钟跑偏时同步上来的)要等真实时间追过去才轮得到清理。这种行不会卡住写入——条件写认得出它来自未来,照样覆盖。
764
+
765
+ `GET /capabilities` 的 features 里有 `client-state-ttl`。
766
+
767
+ ## 清理云端数据:先对账,再按角色删
768
+
769
+ 整表全清(`DELETE /client-state` 不带参数、`DELETE /llm-credentials { all: true }`)之外,还有一组按名字来的口子,给「这个角色本地已经删了,云端那份也该走」这类收尾用。
770
+
771
+ | 端点 | 语义 |
772
+ |---|---|
773
+ | `GET /client-state/namespaces[?limit=<n>]` | 云端有哪些命名空间:`{ namespaces: [{ namespace, entryCount, byteSize, updatedAt }], truncated, limit }`(加密信封)。默认最多 200 条,被截断时 `truncated: true` |
774
+ | `DELETE /client-state?namespace=<ns>` | 只清这一个命名空间,连它的大值切片行一起。返回 `{ deleted, namespace }`;不带 `namespace` 参数仍是整表全清 |
775
+ | `DELETE /llm-credentials { credIdPrefix }` | 按 `cred_id` 前缀删。一个角色名下通常有 `char:<charId>/chat`、`/instant`、`/emotion` 几行,前缀一把清掉 |
776
+ | `DELETE /outbox` | 主动删收件箱的行:body =(加密后的)`{ messageIds: [...] }`(一次 ≤200 条)或 `{ all: true }`。在此之前只能等 cron 的 TTL 老化 |
777
+
778
+ 用法就是三步:拉一份 `GET /client-state/namespaces`,跟本地清单对一遍,本地已经没有的逐个 `DELETE /client-state?namespace=`。
779
+
780
+ 几件容易踩的事:
781
+
782
+ - **命名空间清单里没有保留命名空间。** 单条 value 超过 200KB 时库会把它切片存进一个内部命名空间,那是存储实现细节。统计把它折算进原命名空间:`byteSize` 和 `updatedAt` 算进去,`entryCount` 不算(切片是一个逻辑条目的几段)。按命名空间删也是连切片行一起删,不会留下读不出来的孤儿。
783
+ - **`byteSize` 是存储字节,不是原文字节。** 值落库前都加密过,这个数比明文大一截——它回答的是「这个命名空间在云端占多大地方」。
784
+ - **清单有上限。** `truncated: true` 时手上这份不是全集,别拿它反推「本地有、云端没有 = 可以删」。
785
+ - **`DELETE /outbox` 和 ack 是两回事。** ack 之后行还在(等 TTL 老化),只是不再被 `GET /outbox` 返回;删是把行拿掉,补收不回来——只在确认对完账之后用。
786
+ - **`credIdPrefix` 按字典序前缀匹配,不是通配符。** 前缀里的 `%` `_` `\` 都只是普通字符。
787
+ - 三条新端点各有自己的 feature 名:`client-state-namespaces`、`client-state-delete-namespace`、`llm-credentials-delete-prefix`、`outbox-delete`。老 worker 上探不到就走降级路径(例如退回整表全清,或者先提示用户更新后端)。内置适配器里只有 D1 实现,pg / neon 上这几条返回 501。
788
+
789
+ ## 循环任务的时区(`tzId`)
790
+
791
+ `daily` / `weekly` 任务可以带一个 IANA 时区 id:
792
+
793
+ ```js
794
+ await client.scheduleMessage({
795
+ contactName: 'Rei',
796
+ messageType: 'auto',
797
+ firstSendTime: '2026-03-07T13:00:00.000Z', // 纽约当地 08:00
798
+ recurrenceType: 'daily',
799
+ tzId: 'America/New_York',
800
+ // …
801
+ });
802
+ ```
803
+
804
+ 带了 `tzId` 的任务按**那个时区的墙钟**推进:日期 +1 天 / +7 天,钟点原样保留。用户设的「每天早八点」在夏令时切换前后都还是早八点。不带 `tzId` 的任务按 UTC 推进(等价于固定 +24h / +7×24h)。
805
+
806
+ `PUT /update-message` 也认这个字段:传时区 id 换一个,传 `null` 改回按 UTC 推进(`hasOwnProperty` 判断,不会被吞掉)。`GET /messages` 每条任务多返回一个 `tzId`(没设 → `null`)。
807
+
808
+ 两个边界情况的收敛规则:春令时被跳过的墙钟(例如纽约 2:30 不存在)落到切换之后的等价时刻(当地 3:30);秋令时重复出现的墙钟(当地 1:30 出现两次)取其中一个,不触发两次。时区换算全部走 `Intl`,不手搓偏移加减。
809
+
193
810
  ## 触发任务时的占位
194
811
 
195
812
  `send-notifications`(以及单用户 Worker 的 `scheduled()`)每条任务开跑前会先占位:在这一行的 `lease_until` 上写下「归我管到现在 + 租期为止」,本次投递期间别的 tick 领不走它;占位改到 0 行说明别人先领走了,本次直接跳过。cron 一分钟一跳而带工具的 AI 任务常常跑过一分钟,没有这层占位同一条任务会被相邻几跳重复触发。
@@ -204,27 +821,248 @@ const result = await ctx.scheduleTask({
204
821
 
205
822
  内置适配器都实现了占位。自定义适配器可以不实现 `claimTask`,跑得动,只是回到不占位的行为。
206
823
 
207
- `lease_until` 是这次新加的列。走 `POST /init-tenant`(或任何一次 `initSchema`)会自动给已有的表补上;手工建表的看 `examples/cloudflare-single-user/schema.sql`。
824
+ 投递失败的退避记在 `retry_after` 上,租约同时放掉。两件事分两列记:`lease_until` 只表示「这条正在跑」,`retry_after` 表示「这条没在跑,在等重试」。挤在一列的话,下面的分组串行会把一条正在退避、其实闲着的任务当成「这一组忙着」,同组别的任务白等一轮退避(最长 6 分钟)。
825
+
826
+ ## 投递失败怎么重试
827
+
828
+ 定时任务投递失败后按 2 / 4 / 6 分钟退避,默认再试 **3 次**。用完之后一次性任务标 `failed`,循环任务只作废本次(排期推进到下一次、重试计数归零)。次数可以调低:
829
+
830
+ ```js
831
+ createSingleUserCloudflareWorker((env) => ({ …, maxDeliveryRetries: 1 })); // 0 = 第一次失败就终审
832
+ ```
833
+
834
+ `createSingleUserServer` / `createReiServer` 的 config、直接调 `runScheduledTick` 的 ctx 也认同名字段,默认值导出为 `DEFAULT_MAX_DELIVERY_RETRIES`。它只管定时任务这条退避阶梯;`messageType: 'instant'` 在请求里当场投递的那条路有自己的三轮重试,不受它影响。宿主要是自己按「重试次数到 3」判断终态的,调低之后记得跟着改。
835
+
836
+ ### 一跳终审、不再重试的失败
837
+
838
+ 每重试一次都可能把整条生成(`onBeforeFire` 里宿主的计费调用、每一轮 LLM)重跑一遍,所以重试也好不了的失败不进退避阶梯:
839
+
840
+ | 失败 | 怎么认出来 |
841
+ |---|---|
842
+ | hook 契约违约、hook 抛 `NonRetryableError` | 错误带 `permanent: true`,见[hook 契约违约算确定性失败](#hook-契约违约算确定性失败) |
843
+ | LLM 上游明确拒了这次请求:400 / 401 / 402 / 403 / 404 / 405 / 413 / 422 | 错误带 `code: 'LLM_CALL_FAILED'`、`llmStatus`,并且 `permanent: true` |
844
+ | 推送订阅没登记、推送服务回 404 / 410(订阅没了)/ 413(内容太大) | 失败记录里的 `errorCode` / `pushStatus` |
845
+ | 没落进收件箱的一批推到一半失败 | 见下面「没有收件箱时」 |
846
+
847
+ LLM 这一条只认上游**答复了、并且拒了**的那几种状态码:Key 错了、余额不足、模型名写错、请求体不合法,隔两分钟再发一遍还是同一个结果。下面这些照旧重试:408 / 409 / 425 / 429、所有 5xx、网络直接炸了或超时(没有 `llmStatus`)、中转站回 200 却在响应体里装着报错(真实原因说不准),以及上面没列出来的其余 4xx。
848
+
849
+ `permanent: true` 是在 LLM 调用抛错的那一刻就标好的,所以 `onFireSettled` / `onAfterSend` 拿到的 `error.permanent` 与投递侧的处置一致,宿主可以据此判断「这是终态,该告诉用户了」。
850
+
851
+ ### 生成成功之后推送失败:只补推送
852
+
853
+ 一次触发的整批 push 在发出去之前会先落进收件箱。落进去了,这次触发的内容就定了——之后推送再失败(推送服务 5xx 之类),任务照常进退避阶梯,但重试那一跳**不再调 LLM,也不调任何 fire-time hook**,只把这一批里还没送到的几条原样再推一遍:
854
+
855
+ - 已经推出去的、客户端已经 ack 的不再推;到了客户端不弹通知的那些照旧不推。
856
+ - 推的就是收件箱里那一份:`messageId`、时间戳、正文都跟第一次一模一样。客户端补收拿到的、重推收到的、第一次推到一半已经收到的,是同一份内容。
857
+ - 客户端在两次重试之间已经把整批补收并 ack 了的话,这一跳什么都不用推,直接算成功。
858
+ - 补推再失败就继续退避,下一跳还是只补推;重试用完照常终审,内容仍在收件箱里等客户端补收。
859
+ - 落收件箱排在查 VAPID / 推送订阅之前:生成之后读订阅那一下超时、VAPID 暂时没配齐,同样只补推送。订阅压根没登记的照旧一跳终审,但这次的内容已经在收件箱里,客户端上线补收得到。
860
+
861
+ 冻结 prompt 老链路和 fire-time hook 链路都是这样。tick 汇总的 `details.redeliveredTasks`(`{ taskId, pushedCount }`)列出这一跳里只补推、没有重新生成的任务。
862
+
863
+ 重试那一跳靠适配器的 `listOutboxForTask` 找这次触发落定的那一批(内置 D1 有;没实现的自定义适配器退回翻页扫描未 ack 的行,读不到已 ack 的行,客户端恰好在两次重试之间 ack 了整批的话会重新生成一份)。
864
+
865
+ ### 没有收件箱时
866
+
867
+ 这一批没落进收件箱(多租户线的 pg / neon 适配器没有收件箱,或者这一次落行失败)时,推送是它唯一的腿,重试只能把整条内容重新生成一遍。库按设备上已经有没有东西来分:
868
+
869
+ - **一条都没推出去**:照旧重试,重试时重新生成。会多花一次生成,但客户端只见过重新生成的那一份——没有收件箱兜着,这是唯一能把消息送到的办法。
870
+ - **已经推出去了几条**:一跳终审(错误标 `permanent: true`),没推出去的那几段就此放弃。重新生成的话,设备上会是前半截来自第一次、后半截来自第二次的两份内容。
871
+
872
+ `GET /capabilities` 的 features 里对应 `llm-permanent-errors`、`redeliver-committed-batch`、`hook-usage-total`、`max-delivery-retries`。
873
+
874
+ ## 按任务限制生成重试(`maxGenerationRetries`)
875
+
876
+ 用户主动触发的回复可以第一次生成失败就结束,定时任务则保留默认退避。工厂配置和 `runScheduledTick` / `runTask` 支持 `maxGenerationRetries`,值可以是非负整数,也可以是同步函数:
877
+
878
+ ```js
879
+ createSingleUserCloudflareWorker((env) => ({
880
+ // 这里的 interactive 是宿主自己约定的 metadata,库不认识业务类型。
881
+ maxGenerationRetries: (task) => task.metadata?.interactive ? 0 : undefined,
882
+ // ...其余配置
883
+ }));
884
+ ```
885
+
886
+ 回调收到与 `serializeBy` 相同的安全任务视图(含 metadata,不含 API Key 等凭据),每次尝试解析一次。返回 `undefined` 继承 `maxDeliveryRetries`(默认 3),返回 0 禁止本轮失败后重新生成;显式数值单独控制生成重试上限。不配置时行为不变,不需要修改任务 payload 或迁移数据库,已有任务也生效。所有服务端工厂和旧的 UUID 即时入口都透传这个配置。
887
+
888
+ 这里的“生成阶段”以**最终回复整批进入 outbox**为边界:前置 hook、模型、工具循环、组装和落库失败都使用生成上限;没有 outbox 的适配器发生推送失败时仍可能需要重新生成,因此也使用生成上限。完整批次已入 outbox 后的推送失败改用 `maxDeliveryRetries`,补投不会再次调用模型。hook 的独立 `emitResult` 不等于最终回复提交。
889
+
890
+ `onFireSettled` 新增 `willRetry` 与 `failureStage`:失败时前者为本次的重试决策,后者为 `'generation'` 或 `'delivery'`;成功、跳过、未接管时两者均为 `null`。宿主可以用 `info.status === 'failed' && info.willRetry === false && !info.outboxed` 即时显示生成失败,无需修改错误对象或猜测重试次数。此回执发生在调度器写任务状态之前,`willRetry` 描述同源策略决策,不保证后续数据库写入成功;取消导致的失败不会重试。已有永久性错误判定仍然生效。
891
+
892
+ 策略回调抛错或返回非法值时,不调用生成 hook / 模型,以 `GENERATION_RETRY_POLICY_INVALID` 记录配置错误并沿默认投递上限退避,避免一次坏部署立即作废所有任务;此时尚未进入 fire,因此不触发 `onFireSettled`。
893
+
894
+ ## 同一分组的任务不并发(`serializeBy`)
895
+
896
+ 同一个角色可能有好几条定时任务。撞在一起并发跑的话,用户一口气收到两条互不知情的消息;宿主在 hook 里维护的「我刚才说过什么」台账通常是读进内存 → 改 → 整份写回,两条各改各的再写回,后写的必然盖掉前面那条。
897
+
898
+ `runScheduledTick`(以及单用户 Worker 的 config)收一个可选的 `serializeBy`:
899
+
900
+ ```js
901
+ await runScheduledTick({
902
+ // ...其余 ctx
903
+ serializeBy: (task) => task.metadata?.charId ?? null,
904
+ });
905
+ ```
906
+
907
+ - 参数是与 `onBeforeFire` 的 `ctx.task` 同一份的只读任务视图(凭据已剔除)。
908
+ - 返回 `null` / 空串、或者不配这个函数 → 这条任务不参与串行,行为与以前完全一致。
909
+ - 同一分组同时只放行一条,**跨跳也算**:上一跳的 fire 还拿着租约时,下一跳捞到同组的另一条也不放行。一次 fire 常常跑十几秒到几分钟,只挡同一跳是不够的。
910
+ - 同一跳内同组放行的是**到点更早**的那条;跑完之后不补跑同组剩下的,留给下一跳。
911
+ - 被拦下的任务是**推迟不是丢弃**:`next_send_at` / `status` / `retry_count` 一个字段都不会被动,下一跳原样再捞一次。条数记在 `details.serializeSkippedTasks`(同一跳内拦下的)和 `details.claimSkippedTasks`(跨跳拦下的)里。
912
+ - `serializeBy` 自身抛错时这条任务这一跳不跑:分不清它属于哪一组,就不该冒着破坏台账的风险跑下去。
913
+ - 正在等重试的任务不算「这一组忙着」(退避记在 `retry_after` 上,租约已经放掉)。
914
+
915
+ 判定和占位是同一条 `UPDATE`:先查「这一组忙不忙」再占位的话,两个 tick 的查询会双双在对方占位之前返回「不忙」。分组 key 不明文落库——库拿它和该用户的存储密钥做一次 HMAC,`serialize_group` 列存的是那个派生值。
916
+
917
+ 自定义适配器实现了 `claimTask` 但忽略第四个参数的,分组串行退化成只在同一跳内生效;完全没实现 `claimTask` 的同理。
918
+
919
+ `createReiServer` 内置的 `/send-notifications` 处理器不透传 `serializeBy`(和 `claimLeaseMs` 一样),多租户部署要用就自己调 `runScheduledTick`。单用户 Worker 直接在 config 里写。
920
+
921
+ ## 任务表用到的三列
922
+
923
+ `lease_until` / `retry_after` / `serialize_group`(都可空)。走 `POST /init-tenant`(或任何一次 `initSchema`)会自动给已有的表补上,跑几次都没事;手工维护表结构的看 `examples/cloudflare-single-user/schema.sql`,Postgres 侧对应三句 `ALTER TABLE scheduled_messages ADD COLUMN IF NOT EXISTS …`。分组串行还多一个索引 `idx_serialize_group_lease`,`initSchema` 一并建。
924
+
925
+ ## 表结构自查(`getSchemaVersion` / `ensureSchema`)
926
+
927
+ 建表语句是 `CREATE TABLE IF NOT EXISTS`,已经存在的表不会被改动,后加的列靠 `initSchema()` 里的 ALTER 补。升级到新版之后没人再跑一次建表的话,这个部署的表就停在旧形状上——cron 每分钟挂在缺的那一列上,任务一条都不发,而前端界面一切正常。
928
+
929
+ 这两个函数把「我需要什么 / 现在是什么 / 帮我补齐」露出来:
930
+
931
+ ```js
932
+ import { getSchemaVersion, ensureSchema } from '@rei-standard/amsg-server/cloudflare';
933
+
934
+ const state = await getSchemaVersion(db);
935
+ // → { current: '2.6.0-cloud-data.2' | null, required: '2.6.0-cloud-data.2', ok: true | false, missing: [] }
936
+
937
+ if (!state.ok) {
938
+ const fixed = await ensureSchema(db); // 建表 + 补列 + 建索引,重复调没事
939
+ // → { current, required, ok, missing, migrated: true, schema }
940
+ }
941
+ ```
942
+
943
+ | 字段 | 是什么 |
944
+ |---|---|
945
+ | `required` | 这一版代码需要的表结构版本(导出常量 `SCHEMA_VERSION`)。表结构自己的版本号,只在表 / 列 / 关键索引变化时抬,与包版本各走各的 |
946
+ | `current` | 活库当前满足的版本:够用就是 `required` 那个值,缺东西就是 `null`(只知道不够用,不知道它停在哪一版) |
947
+ | `ok` | 需要的表 / 列 / 关键索引 / 触发器是不是都在 |
948
+ | `missing` | 缺什么,形如 `table:message_outbox` / `column:scheduled_messages.last_error` / `index:uidx_uuid` / `trigger:trg_cloud_metadata_task_delete`。整张表缺席时只报这一张表,不逐列展开 |
949
+ | `migrated`(只有 `ensureSchema` 有) | 这次有没有真的跑 `initSchema()`。本来就够用 → `false`,`schema` 也是 `null` |
950
+
951
+ 「需要什么」是从建表语句里解析出来的,不是另抄一份清单:抄的那份漏掉新列的话,自查会对着一个缺列的库回「一切正常」。
952
+
953
+ 什么时候调、缺了怎么提示用户,由宿主决定——库不会在每次请求里偷偷迁移。`POST /init-tenant` 的行为一点没变(它内部做的就是 `initSchema()`)。
954
+
955
+ 单用户 Worker 上有走 `env` 的同名方法:`worker.getSchemaVersion(env)` / `worker.ensureSchema(env)`,省得自己再造一次适配器。
956
+
957
+ 自查要求适配器实现 `describeSchema()`(活库里现在有哪些表 / 列 / 索引,只读)。内置适配器里目前只有 D1 实现了;别的适配器调这两个函数会抛错,而不是假装一切正常。
958
+
959
+ ## 只跑指定那一条任务(`runTask`)
960
+
961
+ `scheduled()` 的语义是「扫一遍所有到期任务」。刚落库的任务想立刻跑起来时,触发一次全量扫描是能达到目的,但那样多个执行者会去扫同一批任务,宿主只能退回单实例串行才不重复发。
962
+
963
+ `runTask` 只跑指定那一条,走的是 cron 完全同一条投递链(占位、租约心跳、过期守卫、失败重试 / 终态、hook 全套):
964
+
965
+ ```js
966
+ // 单用户 Worker:从 env 拿库和配置
967
+ const result = await worker.runTask(uuid, env);
968
+
969
+ // 自己攒 ctx 的宿主(和 runScheduledTick 同一份 ctx)
970
+ import { runTask } from '@rei-standard/amsg-server/cloudflare';
971
+ const result = await runTask(ctx, uuid);
972
+ ```
973
+
974
+ 跑起来了是 `{ ran: true, summary }`(`summary` 与 `runScheduledTick` 的返回同构,`totalTasks` 恒为 1)。不跑的几种情形分开回报,宿主不用猜:
975
+
976
+ | `reason` | 什么意思 | 附带 |
977
+ |---|---|---|
978
+ | `not_found` | 没有这条 uuid。一次性任务发完即删,所以「已经发完了」的那条也落在这里 | — |
979
+ | `already_settled` | 行还在,但已经是终态 | `status`(`sent` / `failed`) |
980
+ | `not_due` | 还没到触发时刻 | `nextSendAt` |
981
+ | `retry_pending` | 上次投递失败,还在退避窗口里 | `retryAfter` |
982
+ | `not_configured` | VAPID / webpush 没配齐,跑了也只是白扣这条任务一次重试(只有 `worker.runTask` 有这一种) | — |
983
+
984
+ 这个入口只是换了个触发器,不是绕过排期的后门:没到点、在退避窗口里的都不跑。`already_settled` 要求适配器实现 `getTaskStatusByUuidOnly(uuid)`(D1 / pg / neon 都实现了);不实现的自定义适配器把这种情况并进 `not_found`。
985
+
986
+ ## 出错时的真实原因
987
+
988
+ `fetch()` 兜底的 500 一直是 `{ success: false, error: { code: 'INTERNAL_ERROR', message: '服务器内部错误' } }`。这两个字段一个没动,真因加在 `error.cause` 上:
989
+
990
+ ```json
991
+ {
992
+ "success": false,
993
+ "error": {
994
+ "code": "INTERNAL_ERROR",
995
+ "message": "服务器内部错误",
996
+ "cause": {
997
+ "stage": "request",
998
+ "name": "Error",
999
+ "message": "D1_ERROR: no such table: message_outbox"
1000
+ }
1001
+ }
1002
+ }
1003
+ ```
1004
+
1005
+ | 字段 | 是什么 |
1006
+ |---|---|
1007
+ | `stage` | 在哪一段炸的:`config` = 构建配置时(少了 binding、环境变量丢了),`request` = 路由或处理器抛错 |
1008
+ | `name` | 错误类型(`error.name`,认不出来时是 `Error`) |
1009
+ | `message` | 错误消息,长得像凭据的串已遮掉、超长截断到 500 字符。`stage: 'config'` 的响应回给跨域调用方时没有这个字段,见下 |
1010
+ | `code` | 错误自带的 `code` 字符串,有才带 |
1011
+
1012
+ 只带错误类型和消息文本:密钥、用户数据、任务正文都不在 `error.message` 上,也不往这里放。
1013
+
1014
+ `stage: 'config'` 那条路多一层收敛:配置都没建起来时这个部署允许哪些 origin 无从得知,响应头只能回显来访 Origin,于是任意第三方页面一个 `fetch` 就能读到这条响应。而构建期异常的原文往往就是部署信息本身(`env.DB is undefined` 报的是 binding 名)。所以跨域读到的那份 `cause` 只有 `stage` / `name` / `code`,`message` 不出去;同源请求和不带 `Origin` 的调用(`curl`、服务端之间调用)照旧拿全文,`wrangler tail` 里也一直有。配置一修好,响应立刻回到部署自己那套 CORS,`stage: 'request'` 的 500 不受这层影响。
1015
+
1016
+ cron 那条路上没有调用方能读到响应,所以另开两个出口:
1017
+
1018
+ ```js
1019
+ export default createSingleUserCloudflareWorker(buildConfig, {
1020
+ onError({ stage, error, cause, path }) {
1021
+ // fetch / cron 任何一段出错都会调一次(best-effort,自身抛错只记日志)
1022
+ },
1023
+ });
1024
+ ```
1025
+
1026
+ - `onError` 放在工厂的第二个参数上,而不是 `buildConfig` 的返回值里:`buildConfig` 自己抛错时配置里的东西一个都读不到,而那恰恰是最需要被看见的一种故障。`stage` 在 cron 路径上是 `config`(配置构建失败 / VAPID 没配齐)、`tick`(那一跳抛错)或 `cloud-cleanup`(云端数据清理那一步抛错,这一跳的消息照常投递)。VAPID 没配齐这一支没有异常对象,`error` 为 `null`、`cause.name` 是 `VapidNotConfigured`。
1027
+ - `scheduled()` 现在有返回值:`{ ok: true, summary }` 或 `{ ok: false, cause }`,`ok` 只看消息投递这一段。云端数据清理那一步出错时,两种返回值上都会多一个 `cloudCleanupCause`。Cloudflare 不看它,是给「自己包一层再转调 `scheduled`」的宿主和测试用的。
208
1028
 
209
1029
  ## 导出 API(Exports)
210
1030
 
211
- - `createReiServer`
212
- - `createAdapter`
213
- - `createTenantToken`
214
- - `verifyTenantToken`
215
- - `deriveUserEncryptionKey`
216
- - `decryptPayload`
217
- - `encryptForStorage`
218
- - `decryptFromStorage`
219
- - `validateScheduleMessagePayload`
220
- - `measurePushPayload`
221
- - `MAX_PUSH_PAYLOAD_BYTES`
222
- - `WEB_PUSH_MAX_BODY_BYTES`
223
- - `WEB_PUSH_ENCRYPTION_OVERHEAD_BYTES`
224
- - `isValidISO8601`
225
- - `isValidUrl`
226
- - `isValidUUID`
227
- - `isValidUUIDv4`
1031
+ 包根(`@rei-standard/amsg-server`):
1032
+
1033
+ - `createReiServer` — 多租户装配线(标准路由处理器全家)
1034
+ - `createSingleUserServer` — 单用户装配线:没有租户概念,不需要 Blob 租户配置和 tenantToken 体系
1035
+ - `createSingleUserCloudflareWorker` — 单用户 Cloudflare Worker 一键装配(`fetch` + `scheduled` 两个入口)
1036
+ - `CORS_ALLOW_HEADERS` / `CORS_ALLOW_METHODS` — 单用户 Worker 的默认 CORS 允许头与方法列表
1037
+ - `createAdapter` / `createD1Adapter` — pg·neon / Cloudflare D1 数据库适配器
1038
+ - `runScheduledTick` — 手动触发一轮到期任务投递(自定义 cron 宿主、要调 `claimLeaseMs` 时用)
1039
+ - `runTask` — 只跑指定那一条任务(与 cron 同一条投递链)
1040
+ - `DEFAULT_MAX_DELIVERY_RETRIES` / `DEFAULT_CLAIM_LEASE_MS` / `DEFAULT_LEASE_HEARTBEAT_MS` / `DEFAULT_HEARTBEAT_LEASE_TTL_MS` — 投递失败默认重试次数(3)与任务占位/心跳租期默认常量
1041
+ - `sanitizeErrorSummary` — 将异常对象脱敏压缩为 `last_error` 存储摘要
1042
+ - `DEFAULT_MAX_TOOL_ITERATIONS` / `DEFAULT_TOTAL_TIMEOUT_MS` / `DEFAULT_MAX_SCHEDULED_TASKS_PER_FIRE` / `MIN_SCHEDULE_LEAD_MS` / `MAX_DEFER_AFTER_MS` — Fire 时刻 agentic 循环与 `scheduleTask` / `defer` 默认预算常量
1043
+ - `getSchemaVersion` / `ensureSchema` / `SCHEMA_VERSION` — 表结构自查与补齐
1044
+ - `summarizeErrorCause` — 把异常压成响应体里 `error.cause` 那个形状(自己包一层路由、想回同样形状时用同一份)
1045
+ - `NonRetryableError` / `DeploymentConfigError` / `isNonRetryableError` — hook 侧标注确定性失败(`permanent: true`)与部署配置缺失错误(走退避阶梯)
1046
+ - `readRequestBody` / `DEFAULT_MAX_REQUEST_BODY_BYTES` — 请求正文的读取口(`Content-Encoding: gzip` 在这一步还原),自己包路由时代替 `await request.text()`
1047
+ - `createWebCryptoWebPush` — 纯 Web Crypto 的 Web Push 发送器(不依赖 `web-push` 包)
1048
+ - `createTenantToken` / `verifyTenantToken`
1049
+ - `deriveUserEncryptionKey` / `decryptPayload` / `encryptPayload` / `encryptForStorage` / `decryptFromStorage` — 全部为 `async` 函数,返回 `Promise`
1050
+ - `validateScheduleMessagePayload` / `validateLlmMessagesArray` / `validateSplitPattern` / `validateAvatarUrl` / `MAX_TASK_PAYLOAD_BYTES`
1051
+ - `supportsLlmCredentialsStore` / `isValidCredId` / `validateCredRefs` / `validateCredValue` / `hasChatCredRef` / `resolveLlmCredential` / `CRED_ID_MAX_LENGTH` / `CRED_PUT_BATCH_MAX` / `CRED_ROWS_PER_USER_MAX` / `CRED_REFS_MAX_ENTRIES` — 用户级 LLM 凭据存储与 `credRefs` 校验/解析工具
1052
+ - `measurePushPayload` / `MAX_PUSH_PAYLOAD_BYTES` / `PUSH_ENVELOPE_RESERVED_BYTES` / `WEB_PUSH_MAX_BODY_BYTES` / `WEB_PUSH_ENCRYPTION_OVERHEAD_BYTES`
1053
+ - `isValidISO8601` / `isValidUrl` / `isValidUUID` / `isValidUUIDv4` / `isValidTimeZoneId`
1054
+ - `advanceOccurrence` / `nextFutureOccurrence` / `planNextOccurrence` — 循环任务的时区感知推进(宿主想自己算「下次什么时候」时用同一份实现)
1055
+
1056
+ `@rei-standard/amsg-server/cloudflare` 子路径 —— 只含「单用户 + D1 + Web Crypto 推送」这条子图,不引用多租户装配线和 pg / neon / `web-push`,所以 D1-only 安装(不装可选数据库 peer)也能干净打包,Worker 不需要 `nodejs_compat` 兼容 flag:
1057
+
1058
+ - `createSingleUserCloudflareWorker` / `createSingleUserServer` / `createD1Adapter` / `runScheduledTick` / `runTask` / `DEFAULT_MAX_DELIVERY_RETRIES`
1059
+ - `MAX_DEFER_AFTER_MS`
1060
+ - `getSchemaVersion` / `ensureSchema` / `SCHEMA_VERSION`
1061
+ - `summarizeErrorCause` / `NonRetryableError` / `isNonRetryableError`
1062
+ - `createWebCryptoWebPush` / `measurePushPayload` / `MAX_PUSH_PAYLOAD_BYTES` / `PUSH_ENVELOPE_RESERVED_BYTES` / `WEB_PUSH_MAX_BODY_BYTES` / `WEB_PUSH_ENCRYPTION_OVERHEAD_BYTES`
1063
+ - `isValidTimeZoneId` / `advanceOccurrence` / `nextFutureOccurrence` / `planNextOccurrence`
1064
+ - `readRequestBody` / `DEFAULT_MAX_REQUEST_BODY_BYTES`
1065
+ - `deriveUserEncryptionKey` / `decryptPayload` / `encryptPayload` / `encryptForStorage` / `decryptFromStorage`
228
1066
 
229
1067
  ## 运行环境与要求
230
1068
 
@@ -267,7 +1105,7 @@ PUBLIC_BASE_URL=https://your-domain.com
267
1105
  VERCEL_PROTECTION_BYPASS=YOUR_BYPASS_KEY
268
1106
  ```
269
1107
 
270
- Vercel 部署配置可参考 [`examples/vercel.json.example`](https://github.com/Tosd0/ReiStandard/blob/main/examples/vercel.json.example)。
1108
+ 函数超时、响应头这类 Vercel 配置的写法可参考 [`tests/vercel.json.example`](https://github.com/Tosd0/ReiStandard/blob/main/tests/vercel.json.example)(它是 `tests/` 健康检查端点的部署配置,不是本包的部署模板);环境变量用 `vercel env add` 或控制台配置,不写进 `vercel.json`。
271
1109
 
272
1110
  ## 相关链接(绝对 URL)
273
1111
 
@@ -276,3 +1114,45 @@ Vercel 部署配置可参考 [`examples/vercel.json.example`](https://github.com
276
1114
  - [SW 包 README](https://github.com/Tosd0/ReiStandard/blob/main/packages/rei-standard-amsg/sw/README.md)
277
1115
  - [API 技术规范](https://github.com/Tosd0/ReiStandard/blob/main/standards/active-messaging-api.md)
278
1116
  - [Service Worker 规范](https://github.com/Tosd0/ReiStandard/blob/main/standards/service-worker-specification.md)
1117
+
1118
+
1119
+ ## 取消正在执行的生成与工具
1120
+
1121
+ 任务被 `DELETE /cancel-message` 取消或被新任务顶替后,租约心跳发现任务已经失效,会中断正在等待的模型请求,阻止新的模型轮次、工具阶段和推送。`leaseHeartbeatMs` 默认仍为 30000;需要及时停止的交互应用可配置为 1000(每个正在执行的任务每秒续租一次)。这是跨请求的数据库检测,延迟取决于心跳和数据库响应,不承诺瞬时中断。自定义适配器需要支持 `claimTask` / `renewTaskLease`;关掉心跳时也关掉了这条主动检测路径。
1122
+
1123
+ `onBeforeFire`、`onLLMOutput`、`executeToolCalls` 的 context 以及 `onFireSettled` 回执新增:
1124
+
1125
+ - `signal: AbortSignal`:传给工具内部的 `fetch`,与工具自己的超时信号合并。
1126
+ - `isCancelled(): boolean`:当前是否已收到取消信号。
1127
+ - `throwIfCancelled(): void`:已取消时抛出 `code: 'TASK_CANCELLED'` 的错误。每个副作用开始前检查,工具 catch 中也要先调用它,以免取消被转成普通工具失败后继续执行。
1128
+
1129
+ ```js
1130
+ async function executeToolCalls(calls, ctx) {
1131
+ const results = [];
1132
+ for (const call of calls) {
1133
+ ctx.throwIfCancelled();
1134
+ const response = await fetch(toolUrl(call), { signal: ctx.signal });
1135
+ results.push({ tool_call_id: call.id, role: 'tool', content: await response.text() });
1136
+ }
1137
+ return results;
1138
+ }
1139
+ ```
1140
+
1141
+ 取消收尾使用 `onFireSettled({ status: 'cancelled', willRetry: false, error, ... })`,不是生成失败。应用应在这里释放资源、结算已发生的用量,避免发出失败提示或把未送达内容记为已回复。取消时已经开始写入的 outbox 批次会在写完后检查信号并撤掉未投递部分;已经发到客户端的消息仍由客户端处理。应用需要记录停止的任务身份,拦住迟到推送、补收以及尚未显示的本地内容。已完成的外部副作用无法撤销;不支持 AbortSignal 的工具也需要自己在执行步骤之间检查取消。
1142
+
1143
+ 执行阶段的 `ctx.writeState`、`storeAttachment`、`scheduleTask`、`cancelTask`、`renewTask` 和 `emitResult` 会拒绝取消后新发起的操作。`onFireSettled` 的 `info.writeState` 和 `info.storeAttachment` 仍可用,供宿主释放锁、保存账单和取消记录等收尾,所有者及代次护栏仍生效;这两个方法不能用于继续生成阶段的业务动作。`emitResult` 仍受取消信号限制。已经开始的数据库写入不支持回滚,宿主仍需处理并发状态版本。
1144
+
1145
+ ### Cloud data inventory and cleanup (D1)
1146
+
1147
+ Single-user D1 deployments expose `cloud-data-management` in `/capabilities`.
1148
+ The management API lists actual cloud resources, including acknowledged outbox
1149
+ rows, orphan state chunks and resources missing a local character. Cleanup uses
1150
+ server previews, persistent operations and cron continuation. Owner retirement
1151
+ fences future writes and in-flight task outputs; restoration is explicit and
1152
+ returns the generation required on subsequent writes.
1153
+
1154
+ See the repository [cloud data management contract](../../../docs/cloud-data-management.md)
1155
+ for routes, encrypted metadata, host `cloudData.resolveOwner` configuration,
1156
+ retention, and upgrade requirements. Other adapters do not advertise this
1157
+ capability. Use the matching client SDK management methods rather than composing
1158
+ bulk deletes from the browser's local resource list.