Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/amsg-agentic-scratch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@rei-standard/amsg-shared": minor
"@rei-standard/amsg-server": minor
---

fire 级 scratch:hook 之间传上下文不再自己维护 Map

单次 fire 开始时库创建一个空对象,`onBeforeFire` 的 fireCtx 和同一次 fire 里每轮 `onLLMOutput` / `executeToolCalls` 的 sessionCtx 都拿到同一个 `scratch` 引用;fire 结束(finish / skip-push / 抛错 / 轮数超限)后随之丢弃。不落库、不进日志、不跨 fire 共享。amsg-shared 的 `buildSessionContext` 新增可选 `scratch` 参数(不传则字段缺席,amsg-instant 行为不变)。
8 changes: 8 additions & 0 deletions .changeset/amsg-capabilities.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@rei-standard/amsg-server": minor
"@rei-standard/amsg-client": minor
---

`GET /capabilities` 特性探测端点 + 客户端 `getCapabilities()`

worker 部署版本落后时,新功能只是「探测不到」而不是静默失效。单用户 worker 新增 `GET /capabilities`,返回 `{ serverVersion, features }`(feature 名如 `client-state` / `client-state-chunking` / `agentic-hooks`,随版本演进追加;表达代码能力,不反映部署配置);鉴权与 `/vapid-public-key` 一致。客户端 SDK 新增 `getCapabilities()`:打到没有该路由的老 worker(404)返回 `null` 不抛错,前端可据此在设置里提示「worker 需要重新部署」。
11 changes: 11 additions & 0 deletions .changeset/amsg-client-state-chunking.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@rei-standard/amsg-server": minor
---

client_state 大值透明分块 + 整批局部失败(单用户 worker)

- `PUT /client-state` 单条 value 不再受 200KB 整批 413 的限制:超过 200KB 的值由服务端切片跨行存储,`GET /client-state` 与 hook 的 `ctx.readState()` 返回拼好的原值,客户端和 hook 作者无感。单条总上限默认 5MB,工厂配置 `maxStateValueBytes` 可调。切片在码点边界(中文 / emoji 不会被劈开);覆盖写变小不残留旧切片;块不齐全(写到一半断了)时该 key 视为不存在,读方走自己的兜底。
- 整批局部失败:批里某条超限 / 非法只拒它自己,其余照常入库。有拒绝时响应带 `data.rejected: [{ index, namespace, key, code, message }]`;全部成功时响应形状与之前完全一致。
- namespace / key 里的控制字符(`\u0000`-`\u001f`)为库内部保留,逐条拒绝。
- adapter 的 `upsertClientState` 新增可选第三参 `cleanups` 与返回值 `outcomes`;自定义 adapter 不实现也能工作(只损失存储卫生,不影响正确性)。
- `DELETE /client-state` 返回的 `deleted` 计数包含内部切片行(有分块值时会大于逻辑条目数)。
1,518 changes: 1,518 additions & 0 deletions docs/superpowers/plans/2026-07-18-amsg-universal-gaps.md

Large diffs are not rendered by default.

52 changes: 51 additions & 1 deletion packages/rei-standard-amsg/client/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,43 @@ export class ReiClient {
return json.publicKey;
}

/**
* Fetch the worker's capability manifest (single-user amsg-server 2.7.0+,
* `GET /capabilities`).
*
* Feature detection for deploy drift: an outdated worker lacks newer
* endpoints/behaviors silently, so the frontend can call this once and
* show a "worker needs a redeploy" hint instead of leaving new features
* dead. Feature names are library-defined strings (e.g. `client-state`,
* `client-state-chunking`, `agentic-hooks`) that grow over time.
*
* Sends `X-Client-Token` when a `serverToken` is configured.
*
* @returns {Promise<{ serverVersion: string, features: string[] } | null>}
* `null` when the worker predates the endpoint (HTTP 404) or the
* response is not JSON (e.g. a proxy error page). Other failures
* (wrong token, 5xx with a JSON envelope) throw.
*/
async getCapabilities() {
const res = await fetch(`${this._baseUrl}/capabilities`, {
method: 'GET',
headers: this._withServerToken({})
});
if (res.status === 404) return null;

let json;
try {
json = await res.json();
} catch {
return null;
}
Comment on lines +487 to +494

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If the server returns a non-200 and non-404 HTTP status (such as a temporary 502 Bad Gateway or 503 Service Unavailable HTML page), res.json() will throw an error. Currently, this error is caught and the method silently returns null. This can mislead the client into thinking the worker is outdated (since null indicates the worker predates the endpoint), prompting incorrect UI redeployment hints. We should check the HTTP status and throw an error if it is not 200 or 404.

    if (res.status === 404) return null;

    let json;
    try {
      json = await res.json();
    } catch {
      if (res.status !== 200) {
        throw new Error('Request failed with status ' + res.status);
      }
      return null;
    }

if (!json?.success) throw new Error(json?.error?.message || 'Failed to fetch capabilities');
return {
serverVersion: typeof json.serverVersion === 'string' ? json.serverVersion : '',
features: Array.isArray(json.features) ? json.features : []
};
}

// ─── Public API ─────────────────────────────────────────────────

/**
Expand Down Expand Up @@ -1007,10 +1044,23 @@ export class ReiClient {
* (requires `init()`); the worker re-encrypts each value at rest
* under the per-user key.
*
* Large values: on amsg-server 2.7.0+ a value over 200KB is stored
* chunked across rows by the worker itself — no client-side splitting
* needed, and reads return the original value reassembled. The default
* per-value ceiling is 5MB (worker factory config `maxStateValueBytes`).
* Older workers reject the whole batch for oversized values; probe with
* `getCapabilities()` (feature `client-state-chunking`) when you need
* to know which behavior you'll get.
*
* Partial failure: an invalid/oversized entry only rejects itself.
* When at least one entry is rejected the response carries
* `data.rejected: [{ index, namespace, key, code, message }]`; when all
* entries are accepted the response shape is unchanged (no `rejected`).
*
* @param {Array<{ namespace: string, key: string, value: string, updatedAt: number }>} entries
* - `value`: pre-serialized string (the SDK does not stringify it for you).
* - `updatedAt`: epoch milliseconds.
* @returns {Promise<Object>} `{ success, data?: { upserted, skipped }, error? }`
* @returns {Promise<Object>} `{ success, data?: { upserted, skipped, rejected? }, error? }`
*/
async putClientState(entries) {
if (!Array.isArray(entries) || entries.length === 0) {
Expand Down
64 changes: 64 additions & 0 deletions packages/rei-standard-amsg/client/test/capabilities.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { ReiClient } from '../src/index.js';

const USER = '550e8400-e29b-41d4-a716-446655440000';

async function withFetch(impl, run) {
const original = globalThis.fetch;
globalThis.fetch = impl;
try {
return await run();
} finally {
globalThis.fetch = original;
}
}

test('getCapabilities() GET /capabilities,带 X-Client-Token,返回 { serverVersion, features }', async () => {
const captured = [];
await withFetch(async (url, init) => {
captured.push({ url: String(url), method: init && init.method, headers: (init && init.headers) || {} });
return new Response(JSON.stringify({
success: true, serverVersion: '2.7.0', features: ['client-state', 'client-state-chunking'],
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
}, async () => {
const client = new ReiClient({ baseUrl: 'https://w.dev', userId: USER, serverToken: 's3cret' });
const caps = await client.getCapabilities();
assert.deepEqual(caps, { serverVersion: '2.7.0', features: ['client-state', 'client-state-chunking'] });
});
assert.equal(captured.length, 1);
assert.equal(captured[0].url, 'https://w.dev/capabilities');
assert.equal(captured[0].method, 'GET');
assert.equal(captured[0].headers['X-Client-Token'], 's3cret');
});

test('老 worker 404(JSON 或非 JSON 页面)→ null 不抛错', async () => {
await withFetch(async () => new Response(
JSON.stringify({ success: false, error: { code: 'NOT_FOUND', message: 'Unknown route' } }),
{ status: 404, headers: { 'Content-Type': 'application/json' } }
), async () => {
const client = new ReiClient({ baseUrl: 'https://w.dev', userId: USER });
assert.equal(await client.getCapabilities(), null);
});
await withFetch(async () => new Response('<html>Not Found</html>', { status: 404 }), async () => {
const client = new ReiClient({ baseUrl: 'https://w.dev', userId: USER });
assert.equal(await client.getCapabilities(), null);
});
});

test('非 404 但响应不是 JSON(代理错误页)→ null', async () => {
await withFetch(async () => new Response('<html>Bad Gateway</html>', { status: 200 }), async () => {
const client = new ReiClient({ baseUrl: 'https://w.dev', userId: USER });
assert.equal(await client.getCapabilities(), null);
});
});

test('success:false(如 token 错 401)→ 抛错带服务端 message', async () => {
await withFetch(async () => new Response(
JSON.stringify({ success: false, error: { code: 'UNAUTHORIZED', message: '缺少或错误的 X-Client-Token' } }),
{ status: 401, headers: { 'Content-Type': 'application/json' } }
), async () => {
const client = new ReiClient({ baseUrl: 'https://w.dev', userId: USER, serverToken: 'wrong' });
await assert.rejects(() => client.getCapabilities(), /X-Client-Token/);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,13 @@

## 端点

`/get-user-key`、`/schedule-message`、`/messages`、`/update-message`、`/cancel-message`、`/init-tenant`、`/vapid-public-key`、`/client-state`。
`/get-user-key`、`/schedule-message`、`/messages`、`/update-message`、`/cancel-message`、`/init-tenant`、`/vapid-public-key`、`/client-state`、`/capabilities`
**没有 HTTP `/send-notifications`**——定时投递由 CF Cron Trigger 直接触发 `scheduled()`。

`GET /vapid-public-key` 返回本 Worker 的 `VAPID_PUBLIC_KEY`,供前端创建 Web Push 订阅时作 `applicationServerKey`;未配置 VAPID 时返回 503。跟其它端点一样受 CORS 和 `serverToken` 约束。

`GET /capabilities` 返回 `{ serverVersion, features }`,给前端做特性探测:worker 部署版本落后时新功能只是探测不到,前端(`client.getCapabilities()`,打到老 worker 时返回 `null`)可以据此在设置页提示重新部署。feature 名如 `client-state` / `client-state-chunking` / `agentic-hooks`,随版本追加。

VAPID 和 webpush 都要配齐:定时投递(cron)和 `instant` 类型消息都靠它推送,缺了就发不出去。

## 客户端状态同步(/client-state)
Expand All @@ -40,12 +42,14 @@ VAPID 和 webpush 都要配齐:定时投递(cron)和 `instant` 类型消

| 端点 | 语义 |
|------|------|
| `PUT /client-state` | 批量 upsert。body =(加密后的)`{ entries: [{ namespace, key, value, updatedAt }] }`。`updatedAt` 是 epoch 毫秒,旧于库内的条目跳过(last-write-wins);单条 `value` ≤ 200KB,单次 ≤ 200 条 |
| `PUT /client-state` | 批量 upsert。body =(加密后的)`{ entries: [{ namespace, key, value, updatedAt }] }`。`updatedAt` 是 epoch 毫秒,旧于库内的条目跳过(last-write-wins);单条 `value` 默认最大 5MB(工厂配置 `maxStateValueBytes` 可调),单次 ≤ 200 条 |
| `GET /client-state?namespace=<ns>` | 取一个 namespace 的全部条目(解密后返回,响应加密) |
| `DELETE /client-state` | 清空该用户的全部状态(设置页做「清除云端状态」按钮用) |

`value` 是任意字符串(想存对象就自己 `JSON.stringify`),落库前用 per-user key 加密。鉴权和加密头跟其它端点完全一样。

大值不用自己切:超过 200KB 的 `value` 由 worker 切片跨行存储,读取(`GET` 和 hooks 的 `ctx.readState()`)拿到的是拼好的原值,客户端无感。批量上传里某条超限/非法只拒它自己,其余照常入库——有拒绝时响应带 `data.rejected`(逐条给 `index / namespace / key / code / message`),全部成功时响应形状不变。namespace / key 里不能带控制字符(`\u0000`-`\u001f`,库内部保留)。

## Fire 时刻 hooks(服务端工具循环)

这是啥:默认情况下,AI 类任务的 prompt 在排程那一刻就冻结进数据库,cron 到点后拿冻结文本调一次 LLM 就推送——上下文停留在排程时。配置 hooks 后,worker 会在**触发那一刻**现场组装 prompt,LLM 要查资料时直接在 worker 里执行工具、多轮循环,最后推送成品,全程不需要客户端在线。
Expand All @@ -57,9 +61,12 @@ export default createSingleUserCloudflareWorker((env) => ({
// ...其余 config
hooks: {
// 触发时组装 prompt。返回 null 则这个任务走冻结 prompt 老链路。
// ctx: { task, userId, readState(namespace), now }
// ctx: { task, userId, readState(namespace), now, scratch }
// task 是解密后的任务字段(不含 apiKey / pushSubscription);
// 自定义字段排程时放 metadata 里,这里原样读回。
// scratch 是本次 fire 的便签对象:在这里塞的东西,同一次 fire 的
// onLLMOutput / executeToolCalls 从 ctx.scratch 拿到同一个引用;
// fire 结束即丢弃,不落库、不跨 fire 共享。
async onBeforeFire(ctx) {
const notes = await ctx.readState('notes'); // [{ namespace, key, value, updatedAt }]
return [
Expand Down
46 changes: 33 additions & 13 deletions packages/rei-standard-amsg/server/src/server/adapters/d1.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ const UPDATABLE_COLUMNS = new Set([
'next_send_at', 'status', 'retry_count', 'created_at', 'updated_at'
]);

// LIKE 前缀转义:用户 key 里的 % _ \ 不能变成通配符/转义符。
function escapeLikePrefix(prefix) {
return prefix.replace(/[\\%_]/g, (ch) => `\\${ch}`);
}

export class D1Adapter {
/** @param {{ prepare: (sql: string) => any }} db - Cloudflare D1 binding */
constructor(db) {
Expand Down Expand Up @@ -230,6 +235,10 @@ export class D1Adapter {
* than the stored row (updatedAt strictly lower) is skipped; equal or
* newer overwrites. Values arrive pre-encrypted (the handler encrypts).
*
* `cleanups` 是分块存储的清理项(见 lib/state-chunks.js):在同一 batch 里
* 先于 upsert 执行,按 (namespace, key 前缀) 删掉旧写入留下的切片行;
* `updated_at <= ?` 条件保证陈旧批次删不动更新写入的行。
*
* Uses D1's batch() — one network round trip for the whole set (implicit
* transaction). The client calls this endpoint inside its few-seconds
* background window, so N sequential round trips could eat the whole
Expand All @@ -238,38 +247,49 @@ export class D1Adapter {
*
* @param {string} userId
* @param {Array<{ namespace: string, key: string, value: string, updatedAt: number }>} entries
* @returns {Promise<{ upserted: number, skipped: number }>}
* @param {Array<{ namespace: string, keyPrefix: string, updatedAt: number }>} [cleanups]
* @returns {Promise<{ upserted: number, skipped: number, outcomes: boolean[] }>}
* `outcomes[i]` 对应 entries[i] 是否真的写入(changes > 0)。
*/
async upsertClientState(userId, entries) {
async upsertClientState(userId, entries, cleanups = []) {
const UPSERT_SQL =
`INSERT INTO client_state (user_id, namespace, key, value, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT (user_id, namespace, key) DO UPDATE SET
value = excluded.value,
updated_at = excluded.updated_at
WHERE excluded.updated_at >= client_state.updated_at`;
const CLEANUP_SQL =
`DELETE FROM client_state
WHERE user_id = ? AND namespace = ? AND key LIKE ? ESCAPE '\\' AND updated_at <= ?`;

const buildStatements = () => [
...cleanups.map((c) =>
this._db.prepare(CLEANUP_SQL).bind(userId, c.namespace, `${escapeLikePrefix(c.keyPrefix)}%`, c.updatedAt)
),
...entries.map((entry) =>
this._db.prepare(UPSERT_SQL).bind(userId, entry.namespace, entry.key, entry.value, entry.updatedAt)
),
];

let results;
if (typeof this._db.batch === 'function') {
const statements = entries.map((entry) =>
this._db.prepare(UPSERT_SQL).bind(userId, entry.namespace, entry.key, entry.value, entry.updatedAt)
);
results = await this._db.batch(statements);
results = await this._db.batch(buildStatements());
} else {
results = [];
for (const entry of entries) {
results.push(
await this._db.prepare(UPSERT_SQL).bind(userId, entry.namespace, entry.key, entry.value, entry.updatedAt).run()
);
for (const stmt of buildStatements()) {
results.push(await stmt.run());
}
}

// cleanup 语句不计数:upserted/skipped/outcomes 只看 entries 对应的语句。
const outcomes = results.slice(cleanups.length).map((res) => res.meta.changes > 0);
let upserted = 0;
let skipped = 0;
for (const res of results) {
if (res.meta.changes > 0) upserted++; else skipped++;
for (const wrote of outcomes) {
if (wrote) upserted++; else skipped++;
}
return { upserted, skipped };
return { upserted, skipped, outcomes };
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,11 @@
* Delete completed / failed tasks older than `days` (default 7).
* @property {(uuid: string, userId: string) => Promise<string|null>} getTaskStatus
* Return the status string of a task (used to distinguish 404 from 409).
* @property {(userId: string, entries: Array<{namespace: string, key: string, value: string, updatedAt: number}>) => Promise<{upserted: number, skipped: number}>} [upsertClientState]
* @property {(userId: string, entries: Array<{namespace: string, key: string, value: string, updatedAt: number}>, cleanups?: Array<{namespace: string, keyPrefix: string, updatedAt: number}>) => Promise<{upserted: number, skipped: number, outcomes?: boolean[]}>} [upsertClientState]
* (optional; single-user/D1 only) Batch upsert of client state, last-write-wins on updatedAt.
* `cleanups` 先于 upsert 在同一事务里按 key 前缀删旧切片行(分块存储清理,见 lib/state-chunks.js;
* 自定义 adapter 可忽略,只损失存储卫生不影响正确性);`outcomes` 逐条报告 entries[i] 是否真的
* 写入(缺席时 handler 按物理行计数兜底)。
* @property {(userId: string, namespace: string) => Promise<Array<{namespace: string, key: string, value: string, updated_at: number}>>} [getClientState]
* (optional; single-user/D1 only) All entries of one namespace; values still encrypted.
* @property {(userId: string) => Promise<number>} [clearClientState]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
* DELETE /cancel-message → delete
* GET /vapid-public-key → this worker's VAPID public key (for the frontend's
* Web Push subscription); 503 if VAPID_PUBLIC_KEY unset
* GET /capabilities → { serverVersion, features }(特性探测;老部署无此路由 → 404)
* PUT /client-state → batch upsert client state (last-write-wins on updatedAt)
* GET /client-state → read one namespace's entries (?namespace=<ns>)
* DELETE /client-state → wipe this user's client state
Expand Down Expand Up @@ -124,6 +125,8 @@ export function createSingleUserCloudflareWorker(buildConfig) {
result = await server.handlers.cancelMessage.DELETE(url, headers);
} else if (method === 'GET' && pathname.endsWith('/vapid-public-key')) {
result = await server.handlers.vapidPublicKey.GET(url, headers);
} else if (method === 'GET' && pathname.endsWith('/capabilities')) {
result = await server.handlers.capabilities.GET(url, headers);
} else if (method === 'PUT' && pathname.endsWith('/client-state')) {
result = await server.handlers.clientState.PUT(headers, await request.text());
} else if (method === 'GET' && pathname.endsWith('/client-state')) {
Expand Down
Loading
Loading