revert: remove flat-history publication flow

This commit is contained in:
YBF
2026-09-14 10:00:23 +08:00
parent 8184a86827
commit 211816c16b
16 changed files with 321 additions and 687 deletions
@@ -65,67 +65,67 @@ OneTalkContactProfileStore.discardPendingProfile(input: {
### Source and identity
- MAIN world constructs a new whitelist object from `window.__conversationListData__` and `im-conversation-list:syncData`; it never forwards a raw row or response.
- `onetalk.contact.snapshot` is an account-level command with the exact payload `{ action: "onetalk.contact.snapshot" }`; it reads only already-loaded direct profiles. It is attempted once only when the same account has a usable page, Bright is authenticated, and the durable ledger has no record for that account. Repeated hello, replay, selection change, Port reconnect or Bright reconnect must not create a second initial snapshot.
- `onetalk.contact.collect` has the exact payload `{ action: "onetalk.contact.collect", conversationId: string }`. MAIN finds only that direct conversation in the current list; it does not use the selected conversation, switch UI, scan history or infer a missing identity. `syncData` may still publish a previously seen direct profile; publication is not upload eligibility.
- A live `messageType: "new"` from either direction requests a targeted collect for its own `conversationId`. History and manual history sync never request a profile collect.
- `channelAccountId` comes only from the logged-in page identity (`currentUserAccountId` or `IcbuIM.UserUtil.currentUser.accountId`). URL `activeAccountId` identifies the selected counterpart and is never an account fallback.
- The durable profile key is `[channelAccountId, conversationId]`; `aliId` remains a profile field and does not define ownership. A profile may be persisted before the technical conversation row is discovered.
- Profile observation is intentionally independent from message observation: `__conversationListData__` and message history/live callbacks may arrive in either order. Do not join a profile snapshot into a `business_card` while decoding a message; the message fact stores only the marker and the server read path may later use this current profile by the scoped key.
- Avatar candidates are validated absolute HTTP(S) URLs. CRM avatar data is accepted only after `buyerInfo` and the OneTalk customer identifier match; otherwise the observation is discarded.
- `conversation.discovered` is emitted with exactly `conversationType: "direct"`. The client does not guess a conversation type from UI labels, and the server does not infer direct from a missing value.
- MAIN world `window.__conversationListData__` `im-conversation-list:syncData` 构造新的白名单对象;绝不转发原始行或原始响应。
- `onetalk.contact.snapshot` 是账号级命令,payload 严格为 `{ action: "onetalk.contact.snapshot" }`;它只读取已加载的 direct 资料。仅当同一账号存在可用页面、Bright 已认证且该账号的 durable ledger 无记录时尝试一次。重复 helloreplay、选择变化、Port 重连或 Bright 重连不得产生第二次 initial snapshot
- `onetalk.contact.collect` payload 严格为 `{ action: "onetalk.contact.collect", conversationId: string }`MAIN 只在当前列表中查找该 direct 会话;不使用 selected 会话、不切换 UI、不扫描历史、不推断缺失身份。`syncData` 仍可重复发布已见过的 direct profile;发布不等于上传资格。
- 任一方向的实时 `messageType: "new"` 只触发其自身 `conversationId` 的 targeted collect。历史同步和手动历史同步从不请求 profile collect
- `channelAccountId` 只能来自页面登录人身份(`currentUserAccountId` `IcbuIM.UserUtil.currentUser.accountId`)。URL `activeAccountId` 标识当前对话客户,绝不能作为账号回退。
- durable profile 的键是 `[channelAccountId, conversationId]``aliId` 只是资料字段,不定义归属。资料可以在技术会话行被发现之前持久化。
- 资料观察有意独立于消息观察:`__conversationListData__` 与消息 history/live 回调可能以任意顺序到达。解码消息时不得把 profile snapshot 拼接进 `business_card`;消息事实只存 marker,服务端读取路径稍后按 scope 键使用这份当前资料。
- 头像候选必须是校验通过的绝对 HTTP(S) URLCRM 头像数据只在 `buyerInfo` OneTalk 客户标识匹配后接受;否则丢弃该观察。
- `conversation.discovered` 只以 `conversationType: "direct"` 发出。客户端不从 UI 文案猜测会话类型,服务端也不从缺失值推断 direct。
### Bright WebSocket boundary
- Protocol version is `5`. `contact.profile.observed` is a plugin-direction frame with `profiles.length` in `1..100` and serialized UTF-8 size at most `256 KiB`; the extension additionally emits at most 50 profiles per frame. Every profile has the exact 13 whitelisted fields above.
- `contact.profile.ack` is a plugin-direction frame with `{ status: "delivered"; profileCount: number }`. It means the Bright transaction and its authorization/connection fences completed; it is not a Mind HTTP response and does not mean CRM business data was committed elsewhere.
- Profile ingestion uses the existing plugin binding, `sync` operation and `read` permission. No second socket, page credential, Cookie, Mind user/workspace field or profile HTTP endpoint is introduced.
- If any profile has `observedAtMs > receivedAtMs + 5 minutes`, Bright rejects the whole batch with `ws.error` code `profile_observed_at_future`; it performs no profile DB write and sends no profile ACK. The coordinator discards only the matching pending request entries so the rejected batch is not retried forever.
- Bright OneTalk wire `protocolVersion` `5`——它与页面桥 envelope`ONE_TALK_PAGE_BRIDGE_VERSION = 4`)和消息 `content.version`1)是相互独立的版本轴;见 [runtime-sync.md](./runtime-sync.md) 的版本对照表。`contact.profile.observed` 是插件方向 frame`profiles.length` `1..100`,序列化 UTF-8 大小不超过 `256 KiB`;扩展每 frame 额外最多发送 50 profile。每条 profile 恰好包含上述 13 个白名单字段。
- `contact.profile.ack` 是插件方向 frame,内容为 `{ status: "delivered"; profileCount: number }`。它表示 Bright 事务及其授权/连接 fence 已完成;它不是 Mind HTTP 响应,也不代表 CRM 业务数据已在别处提交。
- 资料摄取使用既有插件 binding`sync` 操作和 `read` 权限。不引入第二个 socket、页面凭证、CookieMind user/workspace 字段或 profile HTTP endpoint
- 任何 profile `observedAtMs > receivedAtMs + 5 分钟` 时,Bright 整批拒绝并返回 `ws.error` `profile_observed_at_future`;不写 profile 数据库、不发 profile ACK。协调器只丢弃匹配的 pending 请求项,使被拒批次不会无限重试。
### Durable lifecycle
- `ONE_TALK_SYNC_DATABASE_VERSION` is `8`. The independent `onetalk_contact_profiles` store is keyed by the account/conversation pair; message, candidate, checkpoint and anomaly stores remain separate.
- The durable profile fingerprint is the only upload-deduplication gate. For every received profile, read its `[channelAccountId, conversationId]` record before `putPendingProfile()`. If either `pending.fingerprint` or `lastUploadedFingerprint` equals the incoming fingerprint, end after that read: do not update a timestamp/high-water mark, write IndexedDB, emit `profile_observed` or flush/send. A different fingerprint follows the existing durable-first pending write.
- Same-key observations serialize the `getProfile → putPendingProfile` transition. Concurrent equal fingerprints may each perform their own read, but at most one may write, diagnose or flush. A read failure stays observable to the caller; tracker cleanup must not create a second unhandled rejection. If the coordinator is disposed while a durable read is pending, its completion performs no write, diagnostic or flush.
- An ACK marks uploaded only after a readwrite transaction and only when the current pending fingerprint and `observedAtMs` still match. Older, unknown, duplicate or mismatched ACKs are no-ops. A future-skew error removes only the exact matching pending snapshot and records its rejected observation watermark.
- Reconnect, Service Worker restart and a newly authenticated Bright client rebuild sends from durable pending records. There is no profile deletion, TTL, or fallback to a message row/Mind database.
- Page/configuration callbacks use page, connection and configuration identity fences. A stale callback cannot route a snapshot to another account or operate a replacement coordinator.
- `ONE_TALK_SYNC_DATABASE_VERSION` `8`。独立的 `onetalk_contact_profiles` store 以账号/会话对为键;消息、candidatecheckpointanomaly store 保持独立。
- durable profile 指纹是上传去重的唯一闸门。每收到一条 profile,先在 `putPendingProfile()` 之前读取它的 `[channelAccountId, conversationId]` 记录。若 `pending.fingerprint` `lastUploadedFingerprint` 与来入指纹相同,读后即结束:不更新时间戳/高水位、不写 IndexedDB、不发 `profile_observed` 诊断、不 flush、不发送。指纹不同时走既有的 durable-first pending 写入。
- 同键观察对 `getProfile → putPendingProfile` 转换做串行化。并发的相同指纹可以各自执行读取,但最多一个能写入、诊断或 flush。读取失败对调用方保持可见;tracker 清理不得制造第二个未处理 rejection。durable 读进行期间协调器被 dispose 时,其完成不产生写入、诊断或 flush
- 只有 readwrite transaction 完成且当前 pending fingerprint `observedAtMs` 仍然匹配时,ACK 才标记 uploaded。更旧、未知、重复或不匹配的 ACK 一律 no-opfuture-skew 错误只移除精确匹配的 pending snapshot,并记录其被拒观察水位。
- 重连、Service Worker 重启和新认证的 Bright client 都从 durable pending 记录重建发送。不存在 profile 删除、TTL,也不回退到消息行或 Mind 数据库。
- 页面/配置回调受 pageconnectionconfiguration 三重身份 fence 约束。过期的回调不能把 snapshot 路由到别的账号,也不能操作替换后的协调器。
### Sensitive-field boundary
`chatToken`, `aliIdEncrypt`, `accountIdEncrypt`, `loginIdEncrypt`, `kHTAccessToken`, Cookie, raw rows/responses and Mind user/workspace fields never cross MAIN→bridge, page→Port, Bright business payloads, diagnostics or the profile ledger. `deviceId` remains only in the existing plugin connection scope/connection-integrity context; it is not a profile field or database key.
`chatToken``aliIdEncrypt``accountIdEncrypt``loginIdEncrypt``kHTAccessToken`Cookie、原始行/响应以及 Mind user/workspace 字段不得跨越 MAIN→bridgepage→PortBright 业务 payload、诊断或 profile ledger`deviceId` 仍只存在于既有插件连接 scope/connection-integrity 上下文;它不是 profile 字段,也不是数据库键。
## 4. Validation & Error Matrix
| Condition | Result |
| 条件 | 行为 |
| --- | --- |
| Missing `conversationId`/`aliId`, invalid fingerprint/time/status or extra profile key | reject as `invalid_message`; no send |
| Group row, missing logged-in identity or CRM identifier mismatch | drop observation; do not write the old account or unmatched customer |
| Extra frame key, sensitive key or wrong frame direction/scope | reject as `invalid_message`; no secret echo |
| Empty or over-limit profile batch | `invalid_message`; no Bright DB write, send or ACK |
| Equal pending/uploaded fingerprint | exactly one ledger read for that observation, then no write, diagnostic, flush or send |
| Different fingerprint | durable-first pending write, `profile_observed` diagnostic and bounded flush are allowed |
| Any profile beyond the five-minute future skew | whole batch `profile_observed_at_future`; no DB write/ACK, matching pending is discarded |
| IndexedDB write/commit abort | no Bright send or uploaded state; failure remains explicit |
| Bright offline, transport error or authorization/connection fence failure | no ACK; pending remains unless the explicit future-skew error was received |
| ACK does not match request count or current fingerprint/time | no uploaded mark; pending/current record is preserved |
| Auth, page/account/configuration epoch or coordinator becomes stale during the initial ledger check | no snapshot command; an old setup claim cannot block a later authenticated setup |
| Coordinator is disposed after a durable read starts | no pending write, diagnostic or flush; a real read failure remains a caller-visible failure |
| 缺少 `conversationId`/`aliId`、指纹/时间/状态非法或出现额外 profile 字段 | 以 `invalid_message` 拒绝;不发送 |
| 群聊行、缺登录身份或 CRM 客户标识不匹配 | 丢弃观察;不写旧账号或不匹配的客户 |
| frame 出现额外字段、敏感字段或方向/scope 错误 | 以 `invalid_message` 拒绝;不回显秘密 |
| profile 批次为空或超限 | `invalid_message`;不写 Bright 数据库、不发送、不 ACK |
| pending/已上传指纹与来入指纹相同 | 该观察只做一次 ledger 读取,然后不写入、不诊断、不 flush、不发送 |
| 指纹不同 | 允许 durable-first pending 写入、`profile_observed` 诊断和有界 flush |
| 任何 profile 超过 5 分钟 future-skew | 整批 `profile_observed_at_future`;不写库、不 ACK,丢弃匹配 pending |
| IndexedDB 写入/提交中止 | 不发送 Bright、不标记 uploaded;失败保持显式 |
| Bright 离线、传输错误或授权/连接 fence 失败 | ACK;除非收到明确的 future-skew 错误,pending 保留 |
| ACK 与请求计数或当前指纹/时间不匹配 | 不标记 uploaded;保留 pending/当前记录 |
| initial ledger 检查期间授权、page/account/configuration epoch coordinator 过期 | 不发 snapshot 命令;旧的 setup claim 不能阻塞后续已认证的 setup |
| durable 读开始后 coordinator dispose | 不写 pending、不诊断、不 flush;真实的读取失败仍对调用方可见 |
## 5. Good / Base / Bad Cases
- Good: MAIN derives a fixed profile from the logged-in account, durable state commits under `[account, conversation]`, Bright receives a bounded frame, and the exact current ACK fence marks it uploaded.
- Good: an old profile arrives again through `syncData` or a targeted live collect; Service Worker reads its ledger fingerprint once and returns without a write, diagnostic or Bright frame.
- Base: a profile with nullable business fields is a valid partial snapshot; a later strictly newer snapshot may overwrite every stored field, including explicit `null`.
- Bad: using MAIN `seen`, page selection, a timer or a second account baseline as upload dedupe; treating an equal fingerprint as a timestamp refresh; routing a live message to a full list scan; or clearing pending on send start.
- GoodMAIN 从登录人账号导出固定 profile,durable 状态按 `[account, conversation]` 提交,Bright 收到有界 frame,精确匹配当前的 ACK fence 后才标记 uploaded
- Good:旧 profile `syncData` targeted live collect 再次到达;Service Worker 只读一次 ledger 指纹即返回,不写入、不诊断、不发 Bright frame
- Base:含可空业务字段的 profile 是合法的 partial snapshot;之后严格更新的 snapshot 可以覆盖全部存储字段,包括显式 `null`
- Bad:用 MAIN `seen`、页面选择、timer 或第二个账号基线做上传去重;把相同指纹当作时间戳刷新;把实时消息路由成全列表扫描;或在发送开始时清空 pending。
## 6. Tests Required
- Contract: exact profile/frame keys, protocol version, direction/scope, direct discovery type, sensitive/unknown key rejection, empty/over-limit batches and `256 KiB` byte limit.
- Observer: initial snapshot, repeatable `syncData` publication, exact snapshot/collect payload validation, targeted direct lookup, group/unknown target exclusion, logged-in identity, logout/account switch, CRM customer matching and avatar URL validation.
- Ledger: the pre-v7 (`oldVersion < 7`) upgrade clears every old OneTalk store before recreating current profile and message-sync state, and the v7→v8 upgrade keeps the five existing stores; account/conversation key, durable-first ordering, same-pending/uploaded one-read-zero-write behavior, same-key concurrent writes, dispose-during-read, explicit read rejection, ACK/CAS, future-skew discard, reconnect and restart recovery. The upgrade must preserve configuration/deviceId and must not rekey/retry an old pending ledger.
- Service Worker: existing Bright binding/read/sync authorization, first-auth setup in both page-first and auth-first order, auth-loss-after-ledger-read, live sent/received targeted collect, history exclusion, 50/51 profile batches, ACK count, future error mapping and stale callback/page identity fences; direct discovery always carries `conversationType: "direct"`.
- Direct typecheck, contract/extension focused and full tests, format check and `git diff --check` are required. Real Chromium, Bright PostgreSQL and production Mind integration are separate external checks.
- Contract:精确 profile/frame 字段、协议版本、方向/scopedirect discovery 类型、敏感/未知字段拒绝、空/超限批次和 `256 KiB` 字节上限。
- Observerinitial snapshot、可重复的 `syncData` 发布、精确 snapshot/collect payload 校验、targeted direct 查找、群聊/未知目标排除、登录身份、登出/切账号、CRM 客户匹配和头像 URL 校验。
- Ledgerpre-v7`oldVersion < 7`)升级先清空全部旧 OneTalk store 再重建当前 profile 与消息同步状态,v7→v8 升级保留五个既有 store;账号/会话键、durable-first 顺序、同 pending/已上传指纹一次读零写、同键并发写、读中 dispose、显式读取失败、ACK/CASfuture-skew 丢弃、重连与重启恢复。升级必须保留 configuration/deviceId,且不得 rekey 或重试旧 pending ledger
- Service Worker:既有 Bright binding/read/sync 授权、page-first auth-first 两种顺序的首次认证 setup、ledger 读取后授权丢失、实时 sent/received targeted collect、历史排除、50/51 profile 批次、ACK 计数、future 错误映射和过期回调/页面身份 fencedirect discovery 始终携带 `conversationType: "direct"`
- 必须执行定向 typecheckcontract/extension focused 与全量测试、format check `git diff --check`。真实 ChromiumBright PostgreSQL 和生产 Mind 集成是另行执行的外部检查。
## 7. Wrong vs Correct
@@ -139,7 +139,7 @@ await store.markProfileUploaded({ channelAccountId, aliId, fingerprint, uploaded
### Correct
```ts
// Do not let a page-local seen set or observedAt timestamp decide upload eligibility.
// 不要让页面本地 seen 集合或 observedAt 时间戳决定上传资格。
const existing = await store.getProfile(channelAccountId, profile.conversationId);
if (
existing?.pending?.fingerprint === profile.profileFingerprint ||
@@ -149,7 +149,7 @@ if (
}
await store.putPendingProfile(channelAccountId, profile);
const requestId = bright.sendContactProfiles({ profiles: [profile] });
// On the matching ACK, mark only the current pending [account, conversation, fingerprint, time].
// 匹配的 ACK 到达时,只标记当前 pending [account, conversation, fingerprint, time]
await store.markProfileUploaded({
channelAccountId,
conversationId: profile.conversationId,
@@ -161,4 +161,4 @@ await store.markProfileUploaded({
### Design Decision: Bright owns profile facts
The profile is a Bright-readable fact because the conversation read API must combine the current snapshot in memory without a second Mind data path or a SQL JOIN. The existing plugin WebSocket remains the transport and authorization boundary; a separate Mind delivery adapter would create a second owner, duplicate retry/ACK semantics and make the account/conversation invariant unverifiable.
profile 作为 Bright 可读的事实存在,是因为会话读取 API 必须在内存中组合当前 snapshot,而不引入第二条 Mind 数据路径或 SQL JOIN。既有插件 WebSocket 仍是传输与授权边界;单独的 Mind delivery 适配器会制造第二个 owner、重复的重试/ACK 语义,并使账号/会话不变量无法验证。
@@ -2,16 +2,18 @@
## 1. Scope / Trigger
当 MAIN-world 页面脚本需要主动读取 OneTalk 全部会话的历史时使用本契约。目标是触发页面官方 SDK 的只读历史请求,让现有 WebSocket observer 继续观察响应;不得改变已读状态、当前会话、页面滚动位置或页面消息 store。
当 MAIN-world 页面脚本需要主动读取 OneTalk 全部会话的历史时使用本契约。目标是触发页面官方 SDK 的只读历史请求,让现有 WebSocket observer 继续观察响应;不得改变已读状态、当前会话、页面滚动位置或页面消息 store。目录名 `current-conversation-history` 是早期仅同步当前会话时期的遗留命名;本契约与该目录现状同时覆盖全会话遍历与 Service Worker 下发的单会话历史命令。
实现必须使用功能目录并按变化原因拆分:
```text
current-conversation-history/
├── entry.ts # 只安装页面全局入口
├── index.ts # 只编排同步、节流、进度与并发
├── sdk.ts # 只处理 URL/SDK、会话游标和 no-read 请求
── model.ts # 只拥有领域类型、规范化和错误契约
├── entry.ts # 只安装页面全局入口
├── index.ts # 只编排同步、节流、进度与并发
├── all-conversations.ts # 只编排 OneTalk 全会话只读历史同步
── page-command.ts # 只处理 Service Worker 下发的历史同步命令
├── sdk.ts # 只处理 URL/SDK、会话游标和 no-read 请求
└── model.ts # 只拥有领域类型、规范化和错误契约
```
历史同步错误码只由功能内的 `model.ts` 拥有;抛出 `Error` 并返回 `never` 的无业务基础原语由 `src/lib/error.ts` 提供。OneTalk 账号 ID 的类型和守卫属于多个 main-page 功能可共享的页面领域概念,由 `src/onetalk/main-page/model.ts` 拥有。分页游标身份辅助函数遵循项目级最近共同所有者规则:只服务一个分页实现时留在该实现;多个同层分页实现确定共享时,提升到最近共同父目录;只有不含渠道语义时才可进入 `lib`。不要把这些概念放进编排入口或业务 `utils.ts`,也不要用业务化包装函数重复基础原语。
@@ -123,7 +123,12 @@ ISOLATED 只做以下动作:
Port 断开或发送失败时,丢弃当前内存消息,不建立页面本地队列、重试或持久化。
bridge v4 只允许 exact-shape normalized message 和聚合后的安全诊断。每条业务消息还必须携带精确的内部来源证明 `upstreamType: 1`MAIN 只在 raw 上游 `type === 1` 后生成它,decoder 拒绝缺失或错误值,Service Worker 在 durable 转换前再次验证并剥离该字段。`type === 2` 的平台消息不进入 bridge;其它缺失或未知 raw type 作为 invalid observation 暴露。`unsupported_skipped` 或媒体 anomaly 只含稳定 code/类型/计数;不得携带消息 ID、正文、URL 或 raw payload。ISOLATED 与 Service Worker 不得重新解析 Base64、`custom.data`、平台文案或推测媒体链接。
bridge v4 只允许 exact-shape normalized message 和聚合后的安全诊断
- 每条业务消息必须携带内部来源证明 `upstreamType: 1`MAIN 只在 raw 上游 `type === 1` 后生成它,decoder 拒绝缺失或错误值,Service Worker 在 durable 转换前再次验证并剥离。
- `type === 2` 的平台消息不进入 bridge;其它缺失或未知 raw type 作为 invalid observation 暴露。
- `unsupported_skipped` 或媒体 anomaly 只含稳定 code/类型/计数,不得携带消息 ID、正文、URL 或 raw payload。
- ISOLATED 与 Service Worker 不得重新解析 Base64、`custom.data`、平台文案或推测媒体链接。
每个 `historyProgress` 必须携带从 OneTalk 会话列表 `lastContactTimeLong` 归一化得到的 `latestMessageAtMs: number | null`。该字段只描述会话列表活动时间,不改变消息历史的 `(sentAtMs, messageId)` 分页。
@@ -157,9 +162,7 @@ Profile envelope 必须显式携带当次读取的 channelAccountId。Service Wo
- 账号级同步命令只发送到唯一同账号页面;
- onetalk.sync.conversation 只在同一 MAIN 页面已完成 onetalk.discover-conversations 并缓存 direct 会话后使用;它必须令 command 与 route 的 conversationId 相等,并投递到唯一同账号页面,不要求该页面当前 selected 会话就是历史目标;MAIN cache 继续是目标会话存在性的唯一证明;
- \`onetalk.sync\` 保持 \`channelAccountId + conversationId\` 的精确页面路由;
- \`onetalk.send\` 只要求唯一同账号页面,忽略该页面当前 selected conversationcommand 的 conversationId 交给 MAIN 后,SDK input 必须以同值 \`cid\` 指定目标,\`conversationCode\` 只作兼容字段
- \`onetalk.send\` 的零页面和多页面分别返回 \`waiting_for_page\`、\`ambiguous_page_route\`
- 页面当前 selected 会话与发送目标不一致,不属于发送路由拒绝;
- \`onetalk.send\` 只投递唯一同账号页面;其零/多页面 reason、selected 状态处理、SDK 输入与确认语义以 [OneTalk PWA 出站发送 SOP](./send-sop.md) 为唯一契约,此处不重复
- 页面身份与 binding/账号不一致时仍 fail closed。
\`sendRequestId\`、页面 \`requestId\` 和 Port pending map 只用于当前内存关联。发送终态、页面断线或身份变化后,不把 command 写入 IndexedDB、消息表或任务表。
@@ -175,9 +178,6 @@ Profile envelope 必须显式携带当次读取的 channelAccountId。Service Wo
| 页面账号无有效 active binding | fail closed |
| onetalk.sync.conversation 的 command/route ID 不同、无同账号页面或同账号多页 | 不投递;分别维持精确路由、waiting_for_page 或 ambiguous_page_route |
| \`onetalk.sync\` 无精确页面 | 返回同步对应的 \`waiting_for_page\`、\`page_identity_mismatch\` 或 \`ambiguous_page_route\` |
| \`onetalk.send\` 无同账号页面 | \`rejected_before_send/waiting_for_page\` |
| \`onetalk.send\` 同账号页面多于一个 | \`rejected_before_send/ambiguous_page_route\`,不广播 |
| \`onetalk.send\` 页面当前会话不是目标 | 仍投递到唯一同账号页面,不返回 \`page_identity_mismatch\` |
| 页面 Port post 失败或断开 | \`delivery_unknown/send_connection_lost\`,不重试 |
| 页面 hello 身份变化 | 清理旧 pending correlation,待定命令返回 \`delivery_unknown/send_connection_lost\` |
| Service Worker 重新实例化 | 页面注册表为空,等待页面重新连接 |
@@ -189,7 +189,6 @@ Profile envelope 必须显式携带当次读取的 channelAccountId。Service Wo
## 5. Good / Base / Bad Cases
- Good:页面先发送合法 helloService Worker 按账号和 command 类型选择唯一 Port;发送目标通过 command payload 传给 MAIN。
- Good:当前页面打开会话 A,但发送目标为会话 B;只要同账号页面唯一,command 仍投递,不改变页面 selected 状态。
- Good:全量 discovery 已在同页缓存会话 B,页面当前选中会话 Aonetalk.sync.conversation(B) 仍投递到该唯一账号页面,并由 MAIN cache 解析 B。
- Goodlive message 指向 direct 会话 B 时,Service Worker 把精确 collect(B) 投递到唯一同账号页面;B 未选中也不会改变路由结果。
- Base:页面 selected 状态变化后重新发送 hello;旧请求不会被新身份的迟到结果完成。
@@ -202,8 +201,7 @@ Profile envelope 必须显式携带当次读取的 channelAccountId。Service Wo
- hello 的唯一/零/多 selected 状态能正确编码,且 URL 不变时 selected 变化能刷新身份。
- onetalk.sync.conversation 在当前 selected 会话不同但同账号页面唯一时仍投递;同账号多页时必须拒绝且不广播。
- \`onetalk.sync\` 继续使用精确会话路由。
- \`onetalk.send\` 在当前 selected 为其它会话、零 selected 或多 selected 时,仍向唯一同账号页面投递
- \`onetalk.send\` 同账号零页面返回 \`waiting_for_page\`,多页面返回 \`ambiguous_page_route\`,均不广播。
- \`onetalk.send\` 的页面选择与 reason 语义按 [OneTalk PWA 出站发送 SOP](./send-sop.md) 的 Tests Required 覆盖,桥接层测试不重复断言
- 页面 post 失败、Port 断开和身份变化都返回带 reason 的 unknown。
- contact snapshot 和 collect 都覆盖 exact payload rejectioncollect 覆盖非 selected direct target、缺失 target、群聊和缺失页面登录身份。
- 页面桥 build 产物为自包含入口,Manifest 路径、world 和 Port 名称一致。
@@ -212,18 +210,6 @@ Profile envelope 必须显式携带当次读取的 channelAccountId。Service Wo
## 7. Wrong vs Correct
\`\`\`ts
// Wrong: 发送也要求当前页面 selected 会话先等于目标。
if (page.conversationId !== command.conversationId) {
return { status: "rejected_before_send", reason: "page_identity_mismatch" };
}
// Correct: 发送只路由到唯一同账号页面,目标由 MAIN 传给 SDK 的 cid 指定。
const page = findUniqueAccountPage(channelAccountId);
if (!page) return { status: "rejected_before_send", reason: "waiting_for_page" };
page.port.postMessage(createPageCommand(command));
\`\`\`
\`\`\`ts
// Wrong: 页面断开后重新广播,可能造成重复发送。
for (const port of allPagePorts) port.postMessage(command);
@@ -75,7 +75,7 @@ class ConnectionStatusTooltip {
- `test/onetalk-action-status-tooltip.test.js` 覆盖同 `id` 去重、多个 `id` 的纵向插入顺序、`update` / `close` 的 boolean 结果、预设颜色和原始 CSS 色拒绝。
- 动作提示测试必须覆盖容器或行被外部移除后,通过 `update` 或重复安装恢复同一 facade、全部活动行和原始顺序。
- 断线提示测试覆盖与 bootstrap 行并列、重复 `true``false` 关闭及容器重挂载。
- 修改控件后执行该定向测试、`pnpm --filter @trade-message-center/chrome-extension typecheck`,以及经 `scripts/with-build-hash.mjs` 注入构建标识的扩展构建。
- 修改控件后执行该定向测试、`pnpm --filter @trade-message-center/chrome-extension typecheck`,以及经仓库根 `scripts/with-build-hash.mjs` 注入构建标识的扩展构建。
## 7. Wrong vs Correct
@@ -57,7 +57,7 @@ Bright WebSocket
## 3. Global ownership
- MAIN world 只拥有 OneTalk SDK 访问、页面事实采集和页面命令执行;不得持有 Bright WebSocket、认证凭证或扩展 IndexedDB。
- bootstrap 的发现/逐会话提示是 MAIN world 内单页 UI 状态:页面入口拥有一个控制器,控制器拥有总数和已结算 ID,通用 tooltip facade 只拥有 DOM 行。发现命令可携带已有 bootstrap marker 的受限 `terminalConversationIds`,控制器只用其与新发现列表的交集预结算。它不是 `historyProgress` 的展示投影,也不进入 observation schema、Service Worker 状态、IndexedDB、Bright 或诊断。
- bootstrap 的发现/逐会话提示是 MAIN world 内单页 UI 状态,唯一契约见 [OneTalk 页面增强控件](./page-controls.md)它不是 `historyProgress` 的展示投影,也不进入 observation schema、Service Worker 状态、IndexedDB、Bright 或诊断。
- ISOLATED Content Script 只拥有页面桥和 runtime.Port;不得解释业务 payload、保存同步状态或选择备用页面。
- Service Worker 拥有 Bright 插件 WebSocket、页面连接注册、账号隔离、command 路由、上传编排、IndexedDB 和状态投影。
- 共享页面消息契约由 page bridge model/decoder 唯一拥有;Bright wire frame 契约由 onetalk-contract 唯一拥有。
@@ -133,6 +133,16 @@ const sendObservation = createSendObservationCorrelator();
const publish = createOneTalkObservedPublisher(sink, sendObservation.observe);
```
### 3.2 Version vocabulary
各文档提到版本号时必须使用下表名称,不得统一简写为 "version" 或“协议版本”;三者互相独立演进:
| 版本 | 唯一所有者 | 表达的边界 |
| --- | --- | --- |
| `protocolVersion=5` | `@trade-message-center/onetalk-contract` | Bright 插件 WebSocket wire frame 协议 |
| `ONE_TALK_PAGE_BRIDGE_VERSION = 4` | `src/onetalk/page-bridge/model.ts` | MAIN/ISOLATED/Service Worker 页面消息 envelope |
| `content.version=1` | shared page message content contractpage bridge model/decoder | 消息正文 versioned `text \| image \| file \| business_card \| inquiry \| order` content 结构 |
## 4. Service Worker transport、frame router 与业务 Flow
### 4.1 Scope / Trigger
@@ -223,7 +233,7 @@ writer.sendSendConfirmation(result);
- 页面命令不得广播、跨账号回退或随机选择。
- bootstrap 重试仅在 `onetalk.sync.conversation` 命令中携带私有布尔 `finalAttempt`;它只决定页面本地提示何时将失败会话视为终态,不能加入 observation、checkpoint、持久化、Bright frame 或诊断 payload。
- onetalk.sync 使用精确的账号/会话页面路由。
- onetalk.send 只要求唯一同账号页面;目标会话交给 MAIN 后必须由 SDK input 的 cid 指定,conversationCode 仅为同值兼容字段,当前 selected 会话不作为发送前置条件
- onetalk.send 的页面选择、SDK 输入与确认语义唯一见 [OneTalk PWA 出站发送 SOP](./send-sop.md)
- 页面身份变化、Port 断开和 Service Worker 重启必须让旧 correlation 失效;不得由迟到结果恢复旧请求。
- 相同 page identity 的重复 hello 不重复启动 snapshotprofile page/config/connection epoch 失效时不得操作替换后的 engine/coordinator。
- Profile 初始 setup 同时受 Bright authenticated、page identity、configuration epoch 和 active coordinator fence 约束;任何 await 后都要重验。ledger read 后认证丢失、页面/账号切换或 coordinator 替换时不得发送 snapshot;失效 claim 不得阻塞下一次有效 setup。
@@ -241,7 +251,7 @@ writer.sendSendConfirmation(result);
### Send and protocol boundaries
- PWA 出站发送只调用已验证的 sendUIMessages({ cid, conversationCode, content, ext })cid 是权威目标字段,conversationCode 必须与 cid 同值
- PWA 出站发送的 SDK 输入、页面路由与旁路关联确认以 [OneTalk PWA 出站发送 SOP](./send-sop.md) 为唯一契约;本节只保留跨层不变量
- SDK 返回的 opId、undefined、页面显示文本和任意普通对象都不是 sent fact。
- 发送事实只由 WebSocket 旁路观察到的目标会话、完整、direction: sent 消息证明。
- 页面任意 JSON result 必须经过 page-send-outcome adapterBright 只接受 canonical OneTalkSendResultPayload。
@@ -282,7 +292,12 @@ const resolveDirection = (
- self 输入必须来自页面 owner 的 `accountId` 与登录账号一致、以及 owner 的 `aliId` 与 participant 的明确关系;participant 必须是完整的两个、无重复、无空白且形如单一 `@icbu` suffix 的原始值。
- participant 的排列只保留页面收到的原始顺序,不表达 self 角色;`senderId` 永远等于原始 `message.sender.uid`
- `senderId === selfParticipant` 才产生 `sent`sender 是另一个 participant 才产生 `received`sender 不在集合或 self 无法唯一确定时不写 `direction`
- 不得使用 URL `activeAccountId`、participant 固定位置、时间顺序、`target.id``fromAccId`/`toAccId`、将 accountId 与 aliId 直接比较/拼接,或 UI 文本猜测 self。
- self 判定禁止使用以下任何替代来源:
- URL `activeAccountId`,或 participant 的固定位置;
- 消息时间顺序;
- `target.id``fromAccId`/`toAccId`
- accountId 与 aliId 的直接比较或拼接;
- UI 文本猜测。
- self participant 只存在于 MAIN 函数参数/内存上下文,不进入 `ObservedOneTalkMessage`、Page Bridge、Bright wire、IndexedDB 或 server schema。
### 6.4 Validation & Error Matrix
@@ -9,7 +9,7 @@
- `module``moduleResolution` 都是 `NodeNext`
- `forceConsistentCasingInFileNames: true`
- `resolveJsonModule: true`
- `skipLibCheck: true`
- `skipLibCheck: true`
- 扩展覆盖为 `module: ESNext``moduleResolution: Bundler``lib: [ES2022, DOM]``types: []``noEmit: true`
`tsconfig.json``include` 必须覆盖 `src/``popup/``vite.config.ts`,确保业务模块、通用能力和 UI 入口都经过同一套严格检查;不要创建更宽松的局部配置绕过错误。
+61 -179
View File
@@ -1,122 +1,88 @@
# Code Reuse Thinking Guide
# 代码复用思维指南
> **Purpose**: Stop and think before creating new code - does it already exist?
> 写新代码前先确认它是否已经存在。本指南是思考清单;正典归属规则见 [模块组织](../project/module-organization.md)。
---
## 为什么复用重要
## The Problem
复制或重写既有逻辑时,bug 修复不会传播、行为随时间分叉、代码库更难理解。重复代码是不一致 bug 的首要来源。
**Duplicated code is the #1 source of inconsistency bugs.**
## 写新代码之前
When you copy-paste or rewrite existing logic:
- Bug fixes don't propagate
- Behavior diverges over time
- Codebase becomes harder to understand
---
## Before Writing New Code
### Step 1: Search First
先搜索:
```bash
# Search for similar function names
# 搜索相似的函数名
grep -r "functionName" .
# Search for similar logic
# 搜索相似的逻辑
grep -r "keyword" .
```
### Step 2: Ask These Questions
再回答:
| Question | If Yes... |
|----------|-----------|
| Does a similar function exist? | Use or extend it |
| Is this pattern used elsewhere? | Follow the existing pattern |
| Could this be a shared utility? | Create it in the right place |
| Am I copying code from another file? | **STOP** - extract to shared |
| 问题 | 如果是 |
| --- | --- |
| 已有相似函数? | 使用或扩展它 |
| 该模式在别处出现过? | 沿用既有模式 |
| 可以成为共享工具? | 放到正确位置创建 |
| 正在从别的文件复制代码? | 停下,抽取为共享实现 |
---
## 常见重复模式
## Common Duplication Patterns
1. **复制的函数**:不要把校验函数复制到另一个文件;抽取到共享位置,按需导入。
2. **相似组件**:不要新建 80% 相同的组件;用 props/变体扩展现有组件。
3. **重复常量**:常量只定义一处,其余位置导入。
4. **重复的 payload 字段提取**:多个消费者各自 cast 同一 JSON/event 字段,即使只有两行也是重复的契约逻辑——每个消费者都私有定义了“合法 payload 是什么”:
### Pattern 1: Copy-Paste Functions
```typescript
const description = (ev as { description?: string }).description;
const context = (ev as { context?: ContextEntry[] }).context;
```
**Bad**: Copying a validation function to another file
把 decoder、类型守卫或投影放在数据 owner 旁边:
**Good**: Extract to shared utilities, import where needed
```typescript
if (isThreadEvent(ev)) {
renderThreadEvent(ev);
}
```
### Pattern 2: Similar Components
同一未定型 payload 字段被 2 处以上读取时,先建共享守卫/规范化/投影,再允许第三个读者。
**Bad**: Creating a new component that's 80% similar to existing
## 何时抽象
**Good**: Extend existing component with props/variants
**抽象**
### Pattern 3: Repeated Constants
- 重复物是契约(payload 字段形状、类型守卫、规范化、错误码、reducer 转移):出现第二个真实消费者就收敛,见上方模式 4 与跨层指南的错误 4。
- 重复物是普通实现逻辑:出现 3 次以上(rule of three)。
- 逻辑复杂到两份拷贝会各自产生 bug。
**Bad**: Defining the same constant in multiple files
**不抽象**
**Good**: Single source of truth, import everywhere
- 只有一个消费者且不是契约。领域守卫即使只有一个消费者也是契约——它直接放在所有者处,由消费者导入(见 [module-organization](../project/module-organization.md))。
- 平凡的一行代码。
- 抽象后比重复更复杂。
- 只是“将来可能有人用”——假设性复用不构成提升理由;位置永远按 module-organization 的最近共同所有者规则。
### Pattern 4: Repeated Payload Field Extraction
## 批量修改之后
**Bad**: Multiple consumers cast the same JSON/event fields locally:
对多个文件做了相似修改后:
1. 复查是否覆盖全部实例。
2. grep 搜索遗漏。
3. 考虑是否应该抽象。
### 状态转移使用穷举结构
状态由 `action`/`kind`/`status`/`phase` 这类值派生时,优先用一个 `switch` reducer,而不是分散的 `if/else` 更新:
```typescript
const description = (ev as { description?: string }).description;
const context = (ev as { context?: ContextEntry[] }).context;
```
This is duplicated contract logic even when the code is only two lines. Each
consumer now has its own definition of what a valid payload means.
**Good**: Put the decoder, type guard, or projection next to the data owner:
```typescript
if (isThreadEvent(ev)) {
renderThreadEvent(ev);
}
```
**Rule**: If the same untyped payload field is read in 2+ places, create a
shared type guard / normalizer / projection before adding a third reader.
---
## When to Abstract
**Abstract when**:
- Same code appears 3+ times
- Logic is complex enough to have bugs
- Multiple people might need this
**Don't abstract when**:
- Only used once
- Trivial one-liner
- Abstraction would be more complex than duplication
---
## After Batch Modifications
When you've made similar changes to multiple files:
1. **Review**: Did you catch all instances?
2. **Search**: Run grep to find any missed
3. **Consider**: Should this be abstracted?
### Reducers Should Use Exhaustive Structure
When state is derived from action-like values (`action`, `kind`, `status`,
`phase`), prefer a reducer with one `switch` over scattered `if/else` updates.
```typescript
// BAD - action-specific state transitions are hard to audit
// 差:按 action 分散的状态转移难以审计
if (action === "opened") { ... }
else if (action === "comment") { ... }
else if (action === "status") { ... }
// GOOD - one reducer owns the transition table
// 好:一个 reducer 拥有整张转移表
switch (event.action) {
case "opened":
...
@@ -127,97 +93,13 @@ switch (event.action) {
}
```
This matters when the event log is the source of truth. A reducer is the
documented replay model; display code and commands should not duplicate pieces
of that replay model.
事件日志是事实源时尤其重要:reducer 是成文的回放模型,展示代码和命令不得复制回放模型的片段。
---
## 提交前检查
## Checklist Before Commit
- [ ] Searched for existing similar code
- [ ] No copy-pasted logic that should be shared
- [ ] No repeated untyped payload field extraction outside a shared decoder
- [ ] Constants defined in one place
- [ ] Similar patterns follow same structure
- [ ] Reducer/action transitions live in one reducer or command dispatcher
---
## Gotcha: Python if/elif/else Exhaustive Check
**Problem**: Python's if/elif/else chains have no compile-time exhaustive check. When you add a new value to a `Literal` type (e.g., `Platform`), existing if/elif/else chains silently fall through to `else` with wrong defaults.
**Symptom**: New platform works partially — some methods return Claude defaults instead of platform-specific values. No error is raised.
**Example** (`cli_adapter.py`):
```python
# BAD: "gemini" falls through to else, returns "claude"
@property
def cli_name(self) -> str:
if self.platform == "opencode":
return "opencode"
else:
return "claude" # gemini silently gets "claude"!
# GOOD: explicit branch for every platform
@property
def cli_name(self) -> str:
if self.platform == "opencode":
return "opencode"
elif self.platform == "gemini":
return "gemini"
else:
return "claude"
```
**Prevention**: When adding a new value to a Python `Literal` type, search for ALL if/elif/else chains that switch on that type and add explicit branches. Don't rely on `else` being correct for new values.
---
## Gotcha: Asymmetric Mechanisms Producing Same Output
**Problem**: When two different mechanisms must produce the same file set (e.g., recursive directory copy for init vs. manual `files.set()` for update), structural changes (renaming, moving, adding subdirectories) only propagate through the automatic mechanism. The manual one silently drifts.
**Symptom**: Init works perfectly, but update creates files at wrong paths or misses files entirely.
**Prevention**:
- **Best**: Eliminate the asymmetry — have the manual path call the automatic one (e.g., `collectTemplateFiles()` calls `getAllScripts()` instead of maintaining its own list)
- **If asymmetry is unavoidable**: Add a regression test that compares outputs from both mechanisms
- When migrating directory structures, search for ALL code paths that reference the old structure
**Real example**: `trellis update` had a manual `files.set()` list for 11 scripts that `getAllScripts()` already tracked. Fix: replaced the manual list with a `for..of getAllScripts()` loop. See `update.ts` refactor in v0.4.0-beta.3.
---
## Template File Registration (Trellis-specific)
When adding new files to `src/templates/trellis/scripts/`:
**Single registration point**: `src/templates/trellis/index.ts`
1. Add `export const xxxScript = readTemplate("scripts/path/file.py");`
2. Add to `getAllScripts()` Map
That's it. `commands/update.ts` uses `getAllScripts()` directly — no manual sync needed.
**Why this matters**: Without registration in `getAllScripts()`, `trellis update` won't sync the file to user projects. Bug fixes and features won't propagate.
**History**: Before v0.4.0-beta.3, `update.ts` had its own hand-maintained file list that frequently fell out of sync with `getAllScripts()`. This caused 11 Python files to be silently skipped during `trellis update`. The fix was to eliminate the duplicate list and use `getAllScripts()` as the single source of truth.
### Quick Checklist for New Scripts
```bash
# After adding a new .py file, verify it's in getAllScripts():
grep -l "newFileName" src/templates/trellis/index.ts # Should match
```
### Template Sync Convention
`.trellis/scripts/` (dogfooded) and `packages/cli/src/templates/trellis/scripts/` (template) must stay identical. After editing `.trellis/scripts/`, always sync:
```bash
rsync -av --delete --exclude='__pycache__' .trellis/scripts/ packages/cli/src/templates/trellis/scripts/
```
**Gotcha**: Running rsync with wrong source/destination paths can create nested garbage directories (e.g., `.trellis/scripts/packages/cli/...`). Always double-check paths before running.
- [ ] 已搜索既有相似代码
- [ ] 没有应当共享的复制粘贴逻辑
- [ ] 共享 decoder 之外没有重复的未定型 payload 字段提取
- [ ] 常量只定义在一处
- [ ] 相似模式遵循相同结构
- [ ] reducer/action 转移集中在一个 reducer 或命令分发器中
@@ -1,327 +1,96 @@
# Cross-Layer Thinking Guide
# 跨层思维指南
> **Purpose**: Think through data flow across layers before implementing.
> 实现前先梳理数据在层间的流动。本指南是思考清单,不替代各层正典规范。
---
## 为什么跨层容易出 bug
## The Problem
多数 bug 发生在层边界而不是层内部:
**Most bugs happen at layer boundaries**, not within layers.
- API 返回格式 A,前端按格式 B 解析。
- 数据库存 X,Service 转成 Y 时丢失字段。
- 多层各自实现同一逻辑,行为逐渐分叉。
Common cross-layer bugs:
## 实现跨层功能之前
- API returns format A, frontend expects format B
- Database stores X, service transforms to Y, but loses data
- Multiple layers implement the same logic differently
---
## Before Implementing Cross-Layer Features
### Step 1: Map the Data Flow
Draw out how data moves:
### 第一步:画出数据流
```
Source → Transform → Store → Retrieve → Transform → Display
```
For each arrow, ask:
对每个箭头问三个问题:数据在这里是什么格式?可能出什么错?谁负责校验?
- What format is the data in?
- What could go wrong?
- Who is responsible for validation?
### 第二步:识别边界
### Step 2: Identify Boundaries
| 边界 | 常见问题 |
| --- | --- |
| API ↔ Service | 类型不匹配、字段缺失 |
| Service ↔ Database | 格式转换、null 处理 |
| 后端 ↔ 前端 | 序列化、日期格式 |
| 组件 ↔ 组件 | props 形状变化 |
| Boundary | Common Issues |
| --------------------- | --------------------------------- |
| API ↔ Service | Type mismatches, missing fields |
| Service ↔ Database | Format conversions, null handling |
| Backend ↔ Frontend | Serialization, date formats |
| Component ↔ Component | Props shape changes |
### 第三步:定义契约
### Step 3: Define Contracts
对每个边界明确:输入的精确格式、输出的精确格式、可能发生的错误。
For each boundary:
## 常见跨层错误
- What is the exact input format?
- What is the exact output format?
- What errors can occur?
1. **隐式格式假设**:不检查就假设日期或字段格式;在边界处显式转换。
2. **分散校验**:同一件事在多层各校验一遍,规则逐渐漂移;在入口校验一次。
3. **泄漏的抽象**:组件知道数据库 schema;每层只认识相邻层。
4. **每个消费者自己解析 payload**:命令代码对 JSONL 事件逐字段 inline cast
---
```typescript
const thread = (ev as { thread?: string }).thread;
const labels = (ev as { labels?: string[] }).labels;
```
## Common Cross-Layer Mistakes
看似局部,实际意味着每个消费者私有维护一份事件契约,下一次字段变更只会改到其中一个命令。在事件边界解码一次,导出定型投影:
### Mistake 1: Implicit Format Assumptions
```typescript
if (!isThreadEvent(ev)) return false;
return ev.thread === filter.thread;
```
**Bad**: Assuming date format without checking
对 append-only 日志、JSON 流、RPC payload 或配置文件,为以下内容各建唯一 owner:事件/payload 类型定义;从 `unknown` 出发的类型守卫与规范化;UI 命令使用的元数据投影;从事实源回放状态的 reducer。渲染代码可以格式化字段,但不得重新定义 payload 契约。
**Good**: Explicit format conversion at boundaries
2 读者的阈值针对契约所有权而非提升:共享 owner 放在真实消费者的最近共同父目录(见 [module-organization](../project/module-organization.md)),不是包级 utils。
### Mistake 2: Scattered Validation
## 跨层功能检查单
**Bad**: Validating the same thing in multiple layers
实现前:
**Good**: Validate once at the entry point
- [ ] 画出了完整数据流
- [ ] 识别了全部层边界
- [ ] 定义了每个边界的格式
- [ ] 决定了校验发生的位置
### Mistake 3: Leaky Abstractions
实现后:
**Bad**: Component knows about database schema
- [ ] 用边界情况测试(null、空、非法)
- [ ] 验证了每个边界的错误处理
- [ ] 确认数据可以完整往返
- [ ] 确认消费者导入共享 decoder/投影,而不是局部 cast payload 字段
- [ ] 确认派生状态回指来源事件标识(`seq`/`id`/`version`),而不是发明第二个 cursor
**Good**: Each layer only knows its neighbors
## 何时写流程文档
### Mistake 4: Every Consumer Parses The Same Payload
满足任一条件时创建详细流程文档:功能跨 3 层以上;多个团队参与;数据格式复杂;该功能曾出过 bug。
**Bad**: A command reads JSONL events and casts fields inline:
## 事件日志与投影边界
```typescript
const thread = (ev as { thread?: string }).thread;
const labels = (ev as { labels?: string[] }).labels;
```
This looks local, but it means every consumer owns a private version of the
event contract. The next field change will update one command and miss another.
**Good**: Decode once at the event boundary, then export typed projections:
```typescript
if (!isThreadEvent(ev)) return false;
return ev.thread === filter.thread;
```
**Rule**: For append-only logs, JSON streams, RPC payloads, or config files,
create one owner for:
- event / payload type definitions
- type guards and normalization from `unknown`
- metadata projections used by UI commands
- reducers that replay state from the source of truth
Rendering code may format fields, but it must not redefine the payload contract.
---
## Checklist for Cross-Layer Features
Before implementation:
- [ ] Mapped the complete data flow
- [ ] Identified all layer boundaries
- [ ] Defined format at each boundary
- [ ] Decided where validation happens
After implementation:
- [ ] Tested with edge cases (null, empty, invalid)
- [ ] Verified error handling at each boundary
- [ ] Checked data survives round-trip
- [ ] Checked that consumers import shared decoders / projections instead of
casting payload fields locally
- [ ] Checked that derived state points back to the source event identifier
(`seq`, `id`, `version`) instead of inventing a second cursor
---
## Cross-Platform Template Consistency
In Trellis, command templates (e.g., `record-session.md`) exist in **multiple platforms** with identical or near-identical content. This is a cross-layer boundary.
### Checklist: After Modifying Any Command Template
- [ ] Find all platforms with the same command: `find src/templates/*/commands/trellis/ -name "<command>.*"`
- [ ] Update all platform copies (Markdown `.md` and TOML `.toml`)
- [ ] For Gemini TOML: adapt line continuations (`\\` vs `\`) and triple-quoted strings
- [ ] Run `/trellis:check-cross-layer` to verify nothing was missed
**Real-world example**: Updated `record-session.md` in Claude to use `--mode record`, but forgot iFlow, Kilo, OpenCode, and Gemini — caught by cross-layer check.
---
## Generated Runtime Template Upgrade Consistency
Some generated files are both documentation and runtime input. In Trellis,
`.trellis/workflow.md` is parsed by `get_context.py`, `workflow_phase.py`,
SessionStart filters, and per-turn hooks. Template changes must be validated
against both fresh init and upgrade paths.
### Checklist: After Modifying A Runtime-Parsed Template
- [ ] Identify every runtime parser that reads the template, not just the file
writer that installs it
- [ ] Check whether relevant syntax lives outside obvious managed regions
such as tag blocks
- [ ] Verify fresh `init` output and a versioned `update` scenario that writes
the older `.trellis/.version`
- [ ] Add an upgrade regression using an older pristine template fixture, then
assert the installed file reaches the current packaged shape
- [ ] Update the backend spec that owns the runtime contract
---
## Versioned Documentation Boundary
Versioned documentation is a cross-layer boundary: source paths, `docs.json`
version routing, and the rendered version selector must all describe the same
release line.
### Checklist: Before Editing Versioned Docs
- [ ] Identify the target release line: stable, beta, or RC
- [ ] Verify the edited MDX path matches that line:
- stable: `docs-site/{start,advanced,...}` and `docs-site/zh/{start,advanced,...}`
- beta: `docs-site/beta/**` and `docs-site/zh/beta/**`
- RC: `docs-site/rc/**` and `docs-site/zh/rc/**`
- [ ] Verify `docs.json` navigation points the version label to the same paths
- [ ] Grep the opposite tree for release-line-specific terms before committing
- [ ] Treat beta content appearing under root release paths as a source-path bug,
not a rendering bug
**Real-world example**: A beta-only task workflow change documented
`prd.md` + `design.md` + `implement.md`, task-creation consent, and Codex
mode banners under root `start/` and `advanced/` paths. The docs site then
served 0.6 beta behavior under the Release selector. The fix was to restore root
release docs, move the 0.6 content to `beta/` and `zh/beta/`, and add a grep
audit for beta markers against the root release tree.
**Real-world example**: Codex inline mode changed workflow platform markers from
`[Codex]` / `[Kilo, Antigravity, Windsurf]` to `[codex-sub-agent]` /
`[codex-inline, Kilo, Antigravity, Windsurf]`. Fresh init was correct, but
`trellis update` only merged `[workflow-state:*]` blocks and preserved stale
markers outside those blocks. Result: upgraded projects got new hook scripts
but old workflow routing, so `get_context.py --mode phase --platform codex`
could return empty Phase 2.1 detail.
---
## Mode-Detection Probe Checklist
When a CLI auto-detects a mode by probing a remote resource (e.g., checking if `index.json` exists to decide marketplace vs direct download):
### Before implementing:
- [ ] Probe runs in **ALL** code paths that use the result (interactive, `-y`, `--flag` combos)
- [ ] 404 vs transient error are distinguished — don't treat both as "not found"
- [ ] Transient errors **abort or retry**, never silently switch modes
- [ ] Shared state (caches, prefetched data) is **reset** when context changes (e.g., user switches source)
- [ ] **Shortcut paths** (e.g., `--template` skipping picker) must have the same error-handling quality as the probed path — check that downstream functions don't call catch-all wrappers
### After implementing:
- [ ] Trace every path from probe result to the mode-decision branch — no fallthrough
- [ ] External format contracts (giget URI, raw URLs) are tested or at least documented as comments
- [ ] Metadata reads consume a complete response or use a streaming parser — never parse a fixed-size prefix as full JSON
- [ ] When reconstructing a composite identifier from parsed parts, verify **all** fields are included and in the **correct position** (e.g., `provider:repo/path#ref` not `provider:repo#ref/path`)
- [ ] Verify that **action functions** called after a shortcut don't internally use the old catch-all fetch — they must use the probe-quality variant when error distinction matters
**Real-world example**: Custom registry flow had 8 bugs across 3 review rounds: (1) probe only ran in interactive mode, (2) transient errors fell through to wrong mode, (3) giget URI had `#ref` in wrong position, (4) prefetched templates leaked across source switches, (5) `--template` shortcut bypassed probe but `downloadTemplateById` internally used catch-all `fetchTemplateIndex`, turning timeouts into "Template not found".
**Real-world example**: Agent-session update hints fetched npm `latest` metadata with `response.read(4096)` and then parsed it as complete JSON. The `@mindfoldhq/trellis` package metadata exceeded 4 KB, so the JSON was truncated, parse failed silently, and the first session injection showed no update hint. Fix: read the complete response before parsing, and add a regression where `version` is followed by an 8 KB metadata tail.
---
## Cross-Platform Template Consistency
In Trellis, command templates (e.g., `record-session.md`) exist in **multiple platforms** with identical or near-identical content. This is a cross-layer boundary.
### Checklist: After Modifying Any Command Template
- [ ] Find all platforms with the same command: `find src/templates/*/commands/trellis/ -name "<command>.*"`
- [ ] Update all platform copies (Markdown `.md` and TOML `.toml`)
- [ ] For Gemini TOML: adapt line continuations (`\\` vs `\`) and triple-quoted strings
- [ ] Run `/trellis:check-cross-layer` to verify nothing was missed
**Real-world example**: Updated `record-session.md` in Claude to use `--mode record`, but forgot iFlow, Kilo, OpenCode, and Gemini — caught by cross-layer check.
---
## Generated Runtime Template Upgrade Consistency
Some generated files are both documentation and runtime input. In Trellis,
`.trellis/workflow.md` is parsed by `get_context.py`, `workflow_phase.py`,
SessionStart filters, and per-turn hooks. Template changes must be validated
against both fresh init and upgrade paths.
### Checklist: After Modifying A Runtime-Parsed Template
- [ ] Identify every runtime parser that reads the template, not just the file
writer that installs it
- [ ] Check whether relevant syntax lives outside obvious managed regions
such as tag blocks
- [ ] Verify fresh `init` output and a versioned `update` scenario that writes
the older `.trellis/.version`
- [ ] Add an upgrade regression using an older pristine template fixture, then
assert the installed file reaches the current packaged shape
- [ ] Update the backend spec that owns the runtime contract
**Real-world example**: Codex inline mode changed workflow platform markers from
`[Codex]` / `[Kilo, Antigravity, Windsurf]` to `[codex-sub-agent]` /
`[codex-inline, Kilo, Antigravity, Windsurf]`. Fresh init was correct, but
`trellis update` only merged `[workflow-state:*]` blocks and preserved stale
markers outside those blocks. Result: upgraded projects got new hook scripts
but old workflow routing, so `get_context.py --mode phase --platform codex`
could return empty Phase 2.1 detail.
---
## Mode-Detection Probe Checklist
When a CLI auto-detects a mode by probing a remote resource (e.g., checking if `index.json` exists to decide marketplace vs direct download):
### Before implementing:
- [ ] Probe runs in **ALL** code paths that use the result (interactive, `-y`, `--flag` combos)
- [ ] 404 vs transient error are distinguished — don't treat both as "not found"
- [ ] Transient errors **abort or retry**, never silently switch modes
- [ ] Shared state (caches, prefetched data) is **reset** when context changes (e.g., user switches source)
- [ ] **Shortcut paths** (e.g., `--template` skipping picker) must have the same error-handling quality as the probed path — check that downstream functions don't call catch-all wrappers
### After implementing:
- [ ] Trace every path from probe result to the mode-decision branch — no fallthrough
- [ ] External format contracts (giget URI, raw URLs) are tested or at least documented as comments
- [ ] Metadata reads consume a complete response or use a streaming parser — never parse a fixed-size prefix as full JSON
- [ ] When reconstructing a composite identifier from parsed parts, verify **all** fields are included and in the **correct position** (e.g., `provider:repo/path#ref` not `provider:repo#ref/path`)
- [ ] Verify that **action functions** called after a shortcut don't internally use the old catch-all fetch — they must use the probe-quality variant when error distinction matters
**Real-world example**: Custom registry flow had 8 bugs across 3 review rounds: (1) probe only ran in interactive mode, (2) transient errors fell through to wrong mode, (3) giget URI had `#ref` in wrong position, (4) prefetched templates leaked across source switches, (5) `--template` shortcut bypassed probe but `downloadTemplateById` internally used catch-all `fetchTemplateIndex`, turning timeouts into "Template not found".
**Real-world example**: Agent-session update hints fetched npm `latest` metadata with `response.read(4096)` and then parsed it as complete JSON. The `@mindfoldhq/trellis` package metadata exceeded 4 KB, so the JSON was truncated, parse failed silently, and the first session injection showed no update hint. Fix: read the complete response before parsing, and add a regression where `version` is followed by an 8 KB metadata tail.
---
## When to Create Flow Documentation
Create detailed flow docs when:
- Feature spans 3+ layers
- Multiple teams are involved
- Data format is complex
- Feature has caused bugs before
---
## Event Log / Projection Boundary
Append-only logs are cross-layer contracts. A single event travels through:
append-only 日志是跨层契约。一条事件经过:
```
CLI input → event writer → events.jsonl → reader → filter → reducer → display
```
### Checklist: After Adding A New Event Kind Or Field
新增 event kind 或字段之后:
- [ ] Add the event kind to the central event taxonomy
- [ ] Add a typed event variant or type guard at the event layer
- [ ] Add normalization helpers for array/object fields that come from
user input or JSON
- [ ] Keep `seq` / `id` assignment in the event writer only
- [ ] Make filters and reducers consume the typed event guard, not local casts
- [ ] Make display code consume reducer output or typed events, not raw JSON
- [ ] Add at least one regression that proves history replay and live filtering
use the same filter model
**Real-world example**: Thread channels added `kind: "thread"`, `description`,
`context`, labels, and `lastSeq`. The first implementation replayed thread
state correctly, but several commands still re-parsed event payload fields with
local casts. The fix was to make the core event layer own `ThreadChannelEvent`
and `isThreadEvent`, make `reduceChannelMetadata` the only channel metadata
projection, and make `reduceThreads` the only thread replay reducer.
- [ ] 把 kind 加入中央事件分类表
- [ ] 在事件层添加定型事件变体或类型守卫
- [ ] 为来自用户输入或 JSON 的数组/对象字段添加规范化辅助
- [ ] `seq`/`id` 的分配只保留在 event writer
- [ ] filter 和 reducer 消费定型事件守卫,不做局部 cast
- [ ] 展示代码消费 reducer 输出或定型事件,不消费原始 JSON
- [ ] 至少一条回归证明历史回放与实时过滤使用同一过滤模型
+34 -78
View File
@@ -1,99 +1,55 @@
# Thinking Guides
# 思维指南
> **Purpose**: Expand your thinking to catch things you might not have considered.
> 本目录只提供编码前的思考清单,不重复代码归属和文件职责规则;正典规则见 [项目级开发规范](../project/index.md)。
项目内任何编码任务都必须先读取 [项目级开发规范](../project/index.md)。本目录只提供思考清单,不重复代码归属和文件职责规则。
## 可用指南
---
| 指南 | 用途 | 使用时机 |
| --- | --- | --- |
| [代码复用思维指南](./code-reuse-thinking-guide.md) | 识别重复模式并收敛 | 发现相似代码、修改常量或配置、新建工具函数 |
| [跨层思维指南](./cross-layer-thinking-guide.md) | 梳理跨层数据流 | 功能跨 3 层以上、数据格式跨层变化、新增事件/RPC/配置字段 |
## Why Thinking Guides?
## 何时考虑代码复用
**Most bugs and tech debt come from "didn't think of that"**, not from lack of skill:
- 正在编写与现有代码相似的逻辑,或同一模式已出现 3 次以上。
- 正在多处新增同一字段,或修改任何常量/配置(先搜索全部引用)。
- 正在新建工具/辅助函数(先确认是否已存在)。
- 两个文件各自用局部 cast 读取同一个未定型 payload 字段。
- 多个分支从 `kind`/`action` 更新同一份派生状态。
- Didn't think about what happens at layer boundaries → cross-layer bugs
- Didn't think about code patterns repeating → duplicated code everywhere
- Didn't think about edge cases → runtime errors
- Didn't think about future maintainers → unreadable code
→ 阅读 [代码复用思维指南](./code-reuse-thinking-guide.md)
These guides help you **ask the right questions before coding**.
## 何时考虑跨层问题
---
- 功能涉及 3 层以上(API、Service、组件、数据库)。
- 数据格式在层间发生变化,或多个消费者需要同一数据。
- 正在新增 event kind、JSONL 记录、RPC payload 或配置字段。
- UI 或命令代码开始直接 cast 原始 payload 字段。
- 不确定某段逻辑应该放在哪一层。
## Available Guides
→ 阅读 [跨层思维指南](./cross-layer-thinking-guide.md)
| Guide | Purpose | When to Use |
|-------|---------|-------------|
| [Code Reuse Thinking Guide](./code-reuse-thinking-guide.md) | Identify patterns and reduce duplication | When you notice repeated patterns |
| [Cross-Layer Thinking Guide](./cross-layer-thinking-guide.md) | Think through data flow across layers | Features spanning multiple layers |
## 修改前的固定动作
---
## Quick Reference: Thinking Triggers
### When to Think About Cross-Layer Issues
- [ ] Feature touches 3+ layers (API, Service, Component, Database)
- [ ] Data format changes between layers
- [ ] Multiple consumers need the same data
- [ ] You're not sure where to put some logic
- [ ] You are adding an event kind, JSONL record, RPC payload, or config field
- [ ] UI / command code starts casting raw payload fields directly
→ Read [Cross-Layer Thinking Guide](./cross-layer-thinking-guide.md)
### When to Think About Code Reuse
- [ ] You're writing similar code to something that exists
- [ ] You see the same pattern repeated 3+ times
- [ ] You're adding a new field to multiple places
- [ ] **You're modifying any constant or config**
- [ ] **You're creating a new utility/helper function** ← Search first!
- [ ] Two files read the same untyped payload field with local casts
- [ ] Multiple branches update the same derived state from `kind` / `action`
→ Read [Code Reuse Thinking Guide](./code-reuse-thinking-guide.md)
### When Verifying AI Cross-Review Results
- [ ] Reviewer claims "user input can be malicious" → Check the actual data source (internal manifest? user config? external API?)
- [ ] Reviewer flags "missing validation" → Is the data from a trusted internal source?
- [ ] Reviewer says "behavior change" → Read the code comments — is it intentional design?
- [ ] Reviewer identifies a "bug" in test → Mentally delete the feature being tested — does the test still pass? If yes → tautological test
**Common AI reviewer false-positive patterns**:
1. **Trust boundary confusion**: Treating internal data (bundled JSON manifests) as untrusted external input
2. **Ignoring design comments**: Flagging intentional behavior documented in code comments as bugs
3. **Variable misreading**: Not tracing a variable to its actual definition (e.g., Map keyed by path vs name)
**Verification rule**: Every CRITICAL/WARNING finding must be verified against the actual code before prioritizing. Budget ~35% false-positive rate for AI reviews.
---
## Pre-Modification Rule (CRITICAL)
> **Before changing ANY value, ALWAYS search first!**
修改任何值之前,先搜索它的全部引用:
```bash
# Search for the value you're about to change
grep -r "value_to_change" .
```
This single habit prevents most "forgot to update X" bugs.
这一个习惯能避免大多数“改了 A 忘了改 B”的问题。
---
## 核对 AI 评审结论
## How to Use This Directory
AI 评审的每条 CRITICAL/WARNING 结论必须对照真实代码核实后才能排入优先级;AI 评审存在不可忽略的误报率。常见误报模式:
1. **Before coding**: Skim the relevant thinking guide
2. **During coding**: If something feels repetitive or complex, check the guides
3. **After bugs**: Add new insights to the relevant guide (learn from mistakes)
1. **信任边界误判**:把内部数据(打包的 JSON manifest 等)当作不可信外部输入。
2. **忽略设计注释**:把代码注释中已说明的既定行为当作 bug。
3. **变量误读**:没有追溯变量的真实定义(例如 Map 按路径还是按名称为键)。
---
核对方法:
## Contributing
Found a new "didn't think of that" moment? Add it to the relevant guide.
---
**Core Principle**: 30 minutes of thinking saves 3 hours of debugging.
- 评审称“用户输入可能恶意”→ 确认数据实际来源(内部 manifest、用户配置还是外部 API)。
- 评审称“缺少校验”→ 确认数据是否来自可信内部来源。
- 评审称“行为变更”→ 读代码注释,确认是否既定设计。
- 评审称测试存在“bug”→ 心理上删除被测功能:若测试仍通过,则是同义反复测试。
+10 -10
View File
@@ -10,16 +10,16 @@
## 2. 主题导航
| 主题 | Canonical 文档 | 旧章节映射 | 负责内容 |
| --- | --- | --- | --- |
| 模块组织 | [module-organization.md](./module-organization.md) | Signatures / Roles、3.13.8;原 4–7 中模块相关规则、案例、测试和反例 | 文件角色、功能聚合、共享层级、上下文适配、职责和拆分时机 |
| 类型所有权与模块出口 | [module-ownership.md](./module-ownership.md) | 3.10;原 4–7 中类型出口相关规则、案例、测试和反例 | 类型唯一所有者、canonical import path、公共 facade 与 re-export |
| 异步流程与状态管理 | [async-state-boundaries.md](./async-state-boundaries.md) | 新增项目约束 | 状态唯一 owner、等待后重新确认、一次性完成和安全重构 |
| 源文件约定 | [source-file-conventions.md](./source-file-conventions.md) | 3.9、3.12;原 4–7 中文件排列和函数风格相关规则、案例、测试和反例 | bottom-up、main-last、文件头/主函数注释、箭头函数和 `fail` 收窄 |
| 格式化 | [formatting.md](./formatting.md) | 3.11;原 4–7 中格式化相关规则、案例和测试 | Oxfmt、VSCode 保存、`.editorconfig` 与提交/CI 一致性 |
| 缺失值与默认值 | [missing-values.md](./missing-values.md) | 3.13 全部七段;原 4–7 中缺失值相关规则、案例、测试和反例 | 缺失值补偿、`BUILD_HASH` 单一生成点和边界错误契约 |
| 结构化值等价性 | [structured-value-equality.md](./structured-value-equality.md) | 新增项目约束 | 事实等价、快照/集合/序列关系与序列化边界 |
| 数据库查询组合 | [database-query-composition.md](./database-query-composition.md) | 新增项目约束 | SQL JOIN 禁止默认、受限查询与内存组合、例外证据门槛 |
| 主题 | Canonical 文档 | 负责内容 |
| --- | --- | --- |
| 模块组织 | [module-organization.md](./module-organization.md) | 文件角色、功能聚合、共享层级、上下文适配、职责和拆分时机 |
| 类型所有权与模块出口 | [module-ownership.md](./module-ownership.md) | 类型唯一所有者、canonical import path、公共 facade 与 re-export |
| 异步流程与状态管理 | [async-state-boundaries.md](./async-state-boundaries.md) | 状态唯一 owner、等待后重新确认、一次性完成和安全重构 |
| 源文件约定 | [source-file-conventions.md](./source-file-conventions.md) | bottom-up、main-last、文件头/主函数注释、箭头函数和 `fail` 收窄 |
| 格式化 | [formatting.md](./formatting.md) | Oxfmt、VSCode 保存、`.editorconfig` 与提交/CI 一致性 |
| 缺失值与默认值 | [missing-values.md](./missing-values.md) | 缺失值补偿、`BUILD_HASH` 单一生成点和边界错误契约 |
| 结构化值等价性 | [structured-value-equality.md](./structured-value-equality.md) | 事实等价、快照/集合/序列关系与序列化边界 |
| 数据库查询组合 | [database-query-composition.md](./database-query-composition.md) | SQL JOIN 禁止默认、受限查询与内存组合、例外证据门槛 |
## 3. 阅读顺序
+16 -10
View File
@@ -58,6 +58,12 @@ package/context/
归属判断顺序固定为:判断参数/返回值/行为是否含领域语义;找真实或已确定消费者;选择最近共同所有者;检查提升后是否产生反向或循环依赖。
合并与归属是两个独立判断:
- 重复物是契约(payload 字段形状、类型守卫、规范化、错误码、状态转移表)时,出现第二个真实消费者就必须收敛到唯一所有者,不等第三次:两份定义已经是第二事实源。
- 重复物是无契约语义的实现细节时,等到第三次出现、或逻辑复杂到两份拷贝会各自漂移再合并。
- 两种情况的所有者位置都按上文最近共同所有者规则选择;两个功能共享放共同父目录,不是包级。“不得因可能复用就提升”禁止的是只有一个消费者时的预防性提升,不禁止已出现真实重复后的收敛。
```ts
// src/lib/error.ts
export const fail = (message: string): never => { throw new Error(message); };
@@ -70,8 +76,8 @@ fail(featureErrorCodes.invalidPage);
```ts
export type OneTalkAccountId = string | number;
export const isAccountId = (value: unknown): value is OneTalkAccountId =>
(typeof value === "string" && value.length > 0) ||
(typeof value === "number" && Number.isFinite(value));
(typeof value === "string" && value.length > 0) ||
(typeof value === "number" && Number.isFinite(value));
```
基础 URL 形状判断不理解渠道,属于基础设施;账号 ID 守卫属于渠道领域;功能错误码属于功能契约。函数归属依据契约、语义和依赖范围,不依据名字、长度或当前调用次数。
@@ -85,11 +91,11 @@ export const isAccountId = (value: unknown): value is OneTalkAccountId =>
```ts
type ContextHost = { location: Pick<Location, "href"> };
export const readContextKey = (host: ContextHost): string | null => {
try { return new URL(host.location.href).searchParams.get("contextKey"); }
catch { return null; }
try { return new URL(host.location.href).searchParams.get("contextKey"); }
catch { return null; }
};
export const isSameContext = (host: ContextHost, expected: string): boolean =>
readContextKey(host) === expected;
readContextKey(host) === expected;
if (!isSameContext(host, contextKey)) return fail(featureErrorCodes.contextChanged);
```
@@ -408,15 +414,15 @@ return router.dispatch(context, frame);
```ts
// Wrong:分发器继续读取并转换 Variant A 内部字段。
export const parseFeatureInput = (data: unknown): DomainModel[] => {
if (isVariantA(data)) { /* 解析 A 的字段…… */ }
return [];
if (isVariantA(data)) { /* 解析 A 的字段…… */ }
return [];
};
// Correct:分发器只识别并委派。
export const parseFeatureInput = (data: unknown): DomainModel[] => {
if (isVariantA(data)) return parseVariantA(data);
if (isVariantB(data)) return parseVariantB(data);
return [];
if (isVariantA(data)) return parseVariantA(data);
if (isVariantB(data)) return parseVariantB(data);
return [];
};
```
@@ -69,7 +69,7 @@ export const syncHistory = (): void => { loadPage(); };
```ts
const normalize = (value: unknown): Record<string, unknown> => {
if (!isRecord(value)) return fail("invalid_value");
return value;
if (!isRecord(value)) return fail("invalid_value");
return value;
};
```
@@ -11,8 +11,9 @@
- `onetalk_conversation`:插件发现的技术会话和共享同步锚点。`channel_account_id + conversation_id` 复合主键,不按 binding 或设备复制;`conversation_kind` 只接受显式 `direct`,未知历史会话保持 `null``sync_phase``sync_result``latest_message_id``history_complete` 表达同步进度及锚点状态,并允许零消息会话。
- `onetalk_contact_profile`Bright 当前联系人资料事实。`channel_account_id + conversation_id` 复合主键,不建立到技术会话表的外键;资料字段允许显式 `null`,只有严格较新的 `observed_at_ms` 才能覆盖整行。它是名片读取 view 的唯一客户资料来源,不是消息事实的嵌入列。
- `onetalk_message_anomaly`:缺字段、协议和同步异常的独立诊断事实。`fingerprint` 仅用于诊断合并;`payload` 必须由写入边界清洗,不能被消息读取、发送或锚点流程消费。
- `onetalk_buyer_fact`:买家标签、特征与联系细节的当前事实,独立于联系人资料。`channel_account_id + conversation_id` 复合主键;`tags_state`/`features_state`/`contact_details_state` 各来源独立表达 confirmed/failedconfirmed 来源整体替换、failed 只更新自身审计;`fact_fingerprint` 必须由最终合并后的集合计算。
生成的初始迁移为 `apps/server/drizzle/0000_rapid_winter_soldier.sql`,其中显式维护 PostgreSQL 表/字段 `COMMENT ON` 备注(Drizzle 当前版本不会从 TypeScript 注释自动生成数据库备注)。未确认发送不进入任何一张表,普通运行路径不提供物理删除;profile 当前行由读取服务另行受限读取后在内存组合,不复制进 conversation 或 message。媒体切换 migration `0005_young_squadron_supreme` 是一次性开发数据重置:仅 `DELETE` 本仓库拥有的 OneTalk message/anomaly/profile/conversation 事实,再删除 `text/content_type` 并为 `content` 加 v1 kind CHECK;不触及授权、binding 或其它渠道。后续 `0011_mushy_baron_strucker` 在建立名片 exact CHECK 前,将已有带客户资料字段的 `business_card` content 归一化为 marker。
生成的初始迁移为 `apps/server/drizzle/0000_rapid_winter_soldier.sql`,其中显式维护 PostgreSQL 表/字段 `COMMENT ON` 备注(Drizzle 当前版本不会从 TypeScript 注释自动生成数据库备注)。未确认发送不进入任何一张表,普通运行路径不提供物理删除;profile 当前行由读取服务另行受限读取后在内存组合,不复制进 conversation 或 message。媒体切换 migration `0005_young_squadron_supreme` 是一次性开发数据重置:仅 `DELETE` 本仓库拥有的 OneTalk message/anomaly/profile/conversation 事实,再删除 `text/content_type` 并为 `content` 加 v1 kind CHECK;不触及授权、binding 或其它渠道。后续增量迁移:`0006_onetalk_buyer_facts` 新增 `onetalk_buyer_fact` 表;`0007_center_contact_time``onetalk_conversation` 增加规范会话活动时间 `last_contact_time_ms` 与白名单 `message_preview``0008_lucky_captain_universe` 为 buyer fact 增加 contact details 白名单列;`0009_remove_image_dimensions` 移除 image content 的 `width`/`height``0010_wooden_naoko` 将 content v1 CHECK 扩展到六类 kind`0011_mushy_baron_strucker` 在建立名片 exact CHECK 前,将已有带客户资料字段的 `business_card` content 归一化为 marker。
## Scenario: Schema 注释与 PostgreSQL 备注
+18 -1
View File
@@ -1,6 +1,6 @@
# 服务端开发规范
> 这份规范记录 `@trade-message-center/server` 当前已经确认的服务端基线。OneTalk WebSocket 契约、Bright 历史读取、Mind HTTP 授权适配器和传输错误码已建立;真实生产 Mind 服务的联调仍留待 `08-26-mind-integration-cutover`
> 这份规范记录 `@trade-message-center/server` 当前已经确认的服务端基线。OneTalk WebSocket 契约、Bright 历史读取、Mind HTTP 授权适配器和传输错误码已建立;真实生产 Mind 服务的联调尚未完成,需在真实 Mind 环境单独验收
## 当前基线
@@ -9,6 +9,23 @@
- 根 TypeScript 配置使用严格模式、`ES2022``NodeNext`,服务端代码应复用该配置。
- 当前已建立 Drizzle 迁移配置、OneTalk 事实 schema 和一次性执行入口;本地 Mind 授权模拟与 Bright 联调页归独立的 `mind-test-harness`,不属于 server。生产日志库仍未引入。OneTalk 协议使用共享 contract 包,测试使用 Node.js 内置 `node:test`
## 共享术语
本目录各契约文档共用以下术语,正文不再重复定义:
| 术语 | 含义 |
| --- | --- |
| canonical connection | `connection-store` 中唯一登记的已认证 WebSocket 连接;每个副作用边界前都复核当前操作仍属于它 |
| generation | 连接代数;连接替换或重连后递增,旧 generation 的回调与副作用一律失效 |
| policy epoch | `OneTalkCutoverPolicy` 的单调计数;`pause()` 使旧 epoch 失效并关闭既有连接 |
| admission | cutover policy 对新连接/帧的准入判定(`enabled`/`paused`/`epoch`);不准入则拒绝,不进入业务处理 |
| commit guard | 随事务传入的 `OneTalkCommitGuard`;事务前后复核,失效即回滚,不返回伪造成功 |
| post-write fence | 事务提交成功后、ACK/发布前对 policy epoch、canonical connection、generation 和授权的最后一轮同步复核 |
| pending-send / SendAttempt | registry 预占的发送尝试状态机(`reserved → … → terminal`);每个 `sendRequestId` 只有一个 attempt |
| fail closed | 依赖不可用或校验失败时拒绝动作并暴露稳定错误码;绝不降级为成功、空数据或默认值 |
| HWMhigh-water mark | 已上传/已拒绝观察时间的单调水位;不高于水位的观察直接跳过 |
| future-skew | 观察 `observedAtMs` 超过 Bright 接收时间 5 分钟;整批拒绝且只丢弃匹配 pending |
## 规范目录
| 文档 | 内容 | 状态 |
@@ -64,7 +64,7 @@
- contact.profile.observed 复用 plugin 的 `sync` + `read` 授权;资料写入 Bright 的 profile repository,不调用 Mind profile endpoint,也不读取/转发任何 profile HTTP response body。
- profile transaction 完成后,WebSocket handler 仍必须确认 binding、完整 Mind scope、authorizationVersion、read、canonical connection、policy epoch 和 commit guard;远程 revoke/version/read removal 不能产生迟到 ACK。future-skew 则在二次授权前返回稳定的 `profile_observed_at_future`,不写库、不 ACK。
## 8. Bright v3 operation fences
## 4. Bright v3 operation fences
- `OneTalkCutoverPolicy` 只拥有 Bright v3 的 `enabled``paused` 和单调 `epoch``capture()`/`isCurrent(epoch)` 必须在每个异步副作用边界前后使用。`pause()` 使旧 epoch 失效并通知 registry 关闭既有 WebSocket,关闭语义为 `1013/authorization_unavailable`,不得先发送 `ws.error`
- registry 是 `SendAttempt` 的唯一 owner。`sendRequestId` 在任何 Mind/plugin authorization `await` 前同步预占,状态只能按 `reserved -> authorizing -> dispatched -> awaiting_confirmation -> confirming -> terminal` 推进;wire send 前失败是 `rejected_before_send`wire send 后只能是 `confirmed_sent``delivery_unknown`
@@ -72,7 +72,7 @@
- 远程 plugin observation、conversation discovery 和 sync completion 只能调用 required guarded repository portsguard 必须进入真实数据库 transaction,并覆盖查询、insert、update、duplicate read 和 callback 返回前的边界。缺 guard 时不得 fallback 为无保护写入。
- 保留 Bright 的共享消息/anchor 主键语义,不将 `deviceId` 加入事实键;新 binding 按完整 Mind scope 替换旧 plugin routing。无 Mind revocation push/lease 时,只能声明下一次授权调用 fail closed,不能宣称无输入即时获知 takeover。
## 4. Validation & Error Matrix
## 5. Validation & Error Matrix
| 条件 | Mind/Bright 结果 |
| --- | --- |
@@ -85,14 +85,14 @@
| WS Origin 非对应连接类型 allowlist | 拒绝握手并关闭连接 |
| 返回授权版本与连接初始值不同 | Bright 返回 authorization_version_changed 并关闭连接 |
## 5. Good / Base / Bad Cases
## 6. Good / Base / Bad Cases
- Good:插件只提交账号和 binding;页面只携带浏览器 CookieBright 使用 Mind 返回的真实 scope 做精确路由。
- GoodMind 接管新 binding 后旧 binding endpoint 立即拒绝,Bright 关闭旧连接,迟到发送结果保持 delivery_unknown。
- Base:本地开发使用内存 authorization fixture;它模拟两个 endpoint 的结果,但不被称为 Mind view,也不连接 Mind DB。
- BadBright 直接查询 Mind session/binding 表、把 mindUserId header 当作主体、把 Cookie 放到 URL/WS payload,或在授权失败时沿用旧缓存。
## 6. Tests Required
## 7. Tests Required
- 两个 Mind endpoint 的 contract test:成功上下文、字段严格解码、Session 无效、scope/权限拒绝、binding 撤销和版本变化。
- HTTP client failure test:超时、网络错误、5xx、非 JSON、未知 code、缺字段均映射为 authorization_unavailable。
@@ -102,7 +102,7 @@
- SendAttempt/commit fence test:同 ID 并发只 dispatch 一次,pre-wire 与 post-wire 断线映射不同,confirmation terminal/late no-oppause/disconnect 在各数据库异步边界回滚未提交写入。
- 根 pnpm format:check、pnpm typecheck、pnpm test、pnpm build 和 git diff --check。
## 7. Wrong vs Correct
## 8. Wrong vs Correct
### Wrong
@@ -18,42 +18,42 @@ startServer(): Promise<void>
## 3. Contracts
- Required environment keys: `HOST`, `PORT`, `DATABASE_URL`
- Optional `NODE_ENV` is normalized at the configuration boundary; only exact trimmed `development` enables the development-only loopback Mind HTTP configuration, while missing/unknown values remain fail-closed。
- `GET /health` returns `{ "status": "ok" }`
- WebSocket routes are `GET /ws/plugin` and `GET /ws/mind`; each route binds its connection type before upgrade and checks its exact Origin allowlist. `GET /ws` is reject-only compatibility behavior (`426 onetalk_protocol_upgrade_required`) and has no legacy handler, outbox or dispatch path. Malformed or unsupported protocol frames close with code `1003`, and connection/handler errors close with `1011`
- `createApp` never calls `listen``entry.ts` 是唯一进程入口,它调用 `runtime.ts` 的双 listener lifecycle`runtime.ts` 只绑定 public `HOST:PORT` 与 internal `0.0.0.0:7777`,并在任一 bind 失败时关闭两者。业务/HTTP 模块不得直接监听端口。
- Database resources are closed through the app `onClose` hook; URL and credentials never enter responses or logs
- `createApp` composes one injected/default `OneTalkService`, `OneTalkProfileService` and `OneTalkReadService` over the same `database.db`; `AppDependencies` may inject these domain ports, the connection registry and publisher failure sink for tests or deployment adapters
- WebSocket business frames cross through the service boundary; successful observation order is database commit → plugin `message.ack`authorized Mind `message.created``message.created` 与 HTTP history 都必须从同一 normalized JSONB fact 投影 shared `OneTalkCenterMessage`,不得暴露顶层 `text/contentType` 或 raw payload。
- `contact.profile.observed` is a Bright persistence path: canonical binding/read/sync authorization → profile service → guarded Bright transaction → post-write fence → `contact.profile.ack`; it never calls Mind profile HTTP or the message service. `AppDependencies.profileService` is the test/deployment seam.
- 必填环境变量:`HOST``PORT``DATABASE_URL`
- 可选的 `NODE_ENV` 在配置边界规范化;只有 trim 后精确等于 `development` 的值才启用仅开发环境的 loopback Mind HTTP 配置,缺失或未知值保持 fail-closed。
- `GET /health` 返回 `{ "status": "ok" }`
- WebSocket 路由是 `GET /ws/plugin` `GET /ws/mind`;每条路由在 upgrade 前绑定自己的连接类型并检查精确的 Origin allowlist`GET /ws` 是仅拒绝的兼容行为(`426 onetalk_protocol_upgrade_required`),没有 legacy handleroutbox 或分发路径。畸形或不支持的协议帧以 `1003` 关闭,连接/handler 错误以 `1011` 关闭
- `createApp` 永不调用 `listen``entry.ts` 是唯一进程入口,它调用 `runtime.ts` 的双 listener lifecycle`runtime.ts` 只绑定 public `HOST:PORT` 与 internal `0.0.0.0:7777`,并在任一 bind 失败时关闭两者。业务/HTTP 模块不得直接监听端口。
- 数据库资源通过应用 `onClose` hook 关闭;URL 和凭证不进入响应或日志
- `createApp` 在同一个 `database.db` 上组合注入/默认的 `OneTalkService``OneTalkProfileService` `OneTalkReadService``AppDependencies` 可以为测试或部署适配器注入这些领域端口、连接 registry publisher 失败 sink
- WebSocket 业务帧经过 service boundary;成功观察的顺序是数据库提交 → 插件 `message.ack`已授权 Mind `message.created``message.created` 与 HTTP history 都必须从同一 normalized JSONB fact 投影 shared `OneTalkCenterMessage`,不得暴露顶层 `text/contentType` 或 raw payload。
- `contact.profile.observed` Bright 持久化路径:canonical binding/read/sync 授权 → profile service → guarded Bright transaction → post-write fence → `contact.profile.ack`;它不调用 Mind profile HTTP 或消息服务。`AppDependencies.profileService` 是测试/部署接缝。
## 4. Validation & Error Matrix
| Condition | Result |
| 条件 | 行为 |
| --- | --- |
| Missing/blank `HOST` | throw `Missing HOST` |
| Missing/blank `PORT` | throw `Missing PORT` |
| `PORT` outside `1..65535` or non-integer | throw `Invalid PORT: expected integer 1-65535` |
| Missing/blank `DATABASE_URL` | throw `Missing DATABASE_URL` |
| Malformed or unsupported WebSocket frame received | send the stable protocol error, then close current socket with `1003` |
| WebSocket error received | close current socket with `1011` |
| Profile future-skew or guarded persistence rejected | stable `profile_observed_at_future`/database failure; no profile ACK |
| Profile transaction succeeds but authorization/connection/policy fence is stale | no late ACK; plugin pending remains |
| 缺失或空白的 `HOST` | 抛出 `Missing HOST` |
| 缺失或空白的 `PORT` | 抛出 `Missing PORT` |
| `PORT` 不在 `1..65535` 或非整数 | 抛出 `Invalid PORT: expected integer 1-65535` |
| 缺失或空白的 `DATABASE_URL` | 抛出 `Missing DATABASE_URL` |
| 收到畸形或不支持的 WebSocket 帧 | 发送稳定协议错误,然后以 `1003` 关闭当前 socket |
| 收到 WebSocket 错误 | 以 `1011` 关闭当前 socket |
| profile future-skew guarded 持久化被拒绝 | 稳定的 `profile_observed_at_future`/数据库失败;无 profile ACK |
| profile 事务成功但授权/连接/policy fence 已过期 | 无迟到 ACK;插件 pending 保留 |
## 5. Good / Base / Bad Cases
- Good: tests inject `{ db, close }`, call `app.ready()`, verify `/health`, then observe one close call
- Base: production startup creates a Drizzle client from `DATABASE_URL` and listens on configured host/port。
- Bad: route handler reads `process.env`, creates a second postgres client, or returns the connection URL in an error
- Good:测试注入 `{ db, close }`,调用 `app.ready()`,验证 `/health`,然后观察 close 恰好被调用一次
- Base:生产启动从 `DATABASE_URL` 创建 Drizzle client,并在配置的 host/port 上监听
- Bad:路由 handler 读取 `process.env`、创建第二个 postgres client,或在错误中返回连接 URL
## 6. Tests Required
- Configuration test asserts missing URL and invalid port errors contain field/error class but not secret values
- Health test asserts HTTP `200` and exact `{ status: "ok" }` response
- Lifecycle test asserts injected database `close` runs once on `app.close()`
- WebSocket registration test asserts `websocketServer` exists, `GET /ws/plugin`/`GET /ws/mind` are registered, and `GET /ws` is reject-only; OneTalk protocol tests assert mock-authorized handshake, heartbeat, version rejection, and authorization failures
- OneTalk business tests assert raw observation validation, conversation discovery, sync completion, anchor snapshot, plugin-only writes and publish-after-commit behavior
- 配置测试断言缺失 URL 和非法端口的错误包含字段/错误类别,但不包含秘密值
- Health 测试断言 HTTP `200` 和精确的 `{ status: "ok" }` 响应
- Lifecycle 测试断言注入的数据库 `close` `app.close()` 时恰好执行一次
- WebSocket 注册测试断言 `websocketServer` 存在、`GET /ws/plugin``GET /ws/mind` 已注册、`GET /ws` 为仅拒绝;OneTalk 协议测试断言 mock 授权握手、heartbeat、版本拒绝和授权失败
- OneTalk 业务测试断言原始观察校验、会话发现、同步完成、anchor snapshot、仅插件写入和提交后发布行为
## 7. Wrong vs Correct
@@ -69,7 +69,7 @@ app.get("/health", async () => ({ databaseUrl: process.env.DATABASE_URL }));
app.get("/health", async () => ({ status: "ok" }));
```
The health boundary is stable and secret-free; database probing belongs in a later operational contract
health 边界稳定且不含秘密;数据库探测属于后续的运维契约
## Scenario: OneTalk authenticated WebSocket route metadata
@@ -119,10 +119,10 @@ defineOneTalkEndpointRouteHandler(type, handler) -> OneTalkEndpointRouteHandler
### 7. Wrong vs Correct
```ts
// Wrong: endpoint type list and production selector can drift independently.
// Wrong: endpoint type list 与生产选择器可能各自漂移。
if (frame.type === "send.request") return sendRequestFlow.handleRequest(socket, frame, epoch);
// Correct: the fixed endpoint dispatches an exact handler table validated by shared metadata.
// Correct: 固定 endpoint 分发由 shared metadata 校验过的精确 handler table。
const router = createOneTalkEndpointAuthenticatedRouter("mind_page", metadata, handlers);
return router.dispatch(frame);
```
@@ -460,24 +460,24 @@ createOneTalkProfileFlow({ profileService, registry, reauthorize, ...narrowPorts
### 7. Wrong vs Correct
```ts
// Wrong: non-client classification can hide a cross-session frame from the scope guard.
// Wrong: non-client 分类可能让跨会话 frame 绕过 scope guard
if (!isAuthenticatedClientFrame(frame)) return sendUnknown(frame);
if (!isSameOneTalkScope(session.scope, frame.scope)) return closeScopeMismatch(frame);
```
```ts
// Correct: a decoder-valid frame must belong to the current session before route classification.
// Correct: 在路由分类之前,decoder 合法的 frame 必须先确认属于当前会话。
if (!isSameOneTalkScope(session.scope, frame.scope)) return closeScopeMismatch(frame);
if (!isAuthenticatedClientFrame(frame)) return sendUnknown(frame);
return router.dispatch(context, frame);
```
```ts
// Wrong: the shared transport chooses endpoint business behavior.
// Wrong: 共享 transport 决定 endpoint 的业务行为。
if (state.connectionType === "plugin") return pluginSyncFlow.handle(frame);
return mindSendFlow.handle(frame);
// Correct: each fixed endpoint injects only its own hello and authenticated callbacks.
// Correct: 每个固定 endpoint 只注入自己的 hello 和已认证回调。
return createOneTalkWebSocketHandler(
{ ...sharedOptions, expectedConnectionType: "plugin" },
{ create: createPluginEndpointCallbacks },