From 6eaefe782627b404488fe6d5922b9fd138783af5 Mon Sep 17 00:00:00 2001 From: zilin <276161014@qq.com> Date: Sat, 22 Aug 2026 01:13:33 +0800 Subject: [PATCH 01/32] =?UTF-8?q?docs(openspec):=20=E8=A7=84=E5=88=92=20Is?= =?UTF-8?q?sue=2034=20=E9=A2=86=E5=9F=9F=E6=A8=A1=E5=9E=8B=E8=BF=81?= =?UTF-8?q?=E7=A7=BB=E9=93=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../.openspec.yaml | 2 + .../add-conversation-command-api/design.md | 195 +++++++++ .../add-conversation-command-api/proposal.md | 32 ++ .../specs/conversation-command-api/spec.md | 183 ++++++++ .../add-conversation-command-api/tasks.md | 43 ++ .../.openspec.yaml | 2 + .../design.md | 404 ++++++++++++++++++ .../proposal.md | 33 ++ .../specs/domain/spec.md | 162 +++++++ .../define-conversation-domain-model/tasks.md | 37 ++ .../.openspec.yaml | 2 + .../migrate-generation-lifecycle/design.md | 157 +++++++ .../migrate-generation-lifecycle/proposal.md | 32 ++ .../conversation-generation-lifecycle/spec.md | 146 +++++++ .../migrate-generation-lifecycle/tasks.md | 39 ++ .../.openspec.yaml | 2 + .../design.md | 182 ++++++++ .../proposal.md | 33 ++ .../specs/conversation-client-state/spec.md | 165 +++++++ .../tasks.md | 48 +++ .../.openspec.yaml | 2 + .../design.md | 103 +++++ .../proposal.md | 31 ++ .../specs/conversation-persistence/spec.md | 89 ++++ .../tasks.md | 28 ++ .../.openspec.yaml | 2 + .../retire-thread-tree-authority/design.md | 174 ++++++++ .../retire-thread-tree-authority/proposal.md | 33 ++ .../specs/conversation-cutover/spec.md | 155 +++++++ .../retire-thread-tree-authority/tasks.md | 60 +++ 30 files changed, 2576 insertions(+) create mode 100644 openspec/changes/add-conversation-command-api/.openspec.yaml create mode 100644 openspec/changes/add-conversation-command-api/design.md create mode 100644 openspec/changes/add-conversation-command-api/proposal.md create mode 100644 openspec/changes/add-conversation-command-api/specs/conversation-command-api/spec.md create mode 100644 openspec/changes/add-conversation-command-api/tasks.md create mode 100644 openspec/changes/define-conversation-domain-model/.openspec.yaml create mode 100644 openspec/changes/define-conversation-domain-model/design.md create mode 100644 openspec/changes/define-conversation-domain-model/proposal.md create mode 100644 openspec/changes/define-conversation-domain-model/specs/domain/spec.md create mode 100644 openspec/changes/define-conversation-domain-model/tasks.md create mode 100644 openspec/changes/migrate-generation-lifecycle/.openspec.yaml create mode 100644 openspec/changes/migrate-generation-lifecycle/design.md create mode 100644 openspec/changes/migrate-generation-lifecycle/proposal.md create mode 100644 openspec/changes/migrate-generation-lifecycle/specs/conversation-generation-lifecycle/spec.md create mode 100644 openspec/changes/migrate-generation-lifecycle/tasks.md create mode 100644 openspec/changes/normalize-conversation-client-state/.openspec.yaml create mode 100644 openspec/changes/normalize-conversation-client-state/design.md create mode 100644 openspec/changes/normalize-conversation-client-state/proposal.md create mode 100644 openspec/changes/normalize-conversation-client-state/specs/conversation-client-state/spec.md create mode 100644 openspec/changes/normalize-conversation-client-state/tasks.md create mode 100644 openspec/changes/normalize-conversation-persistence/.openspec.yaml create mode 100644 openspec/changes/normalize-conversation-persistence/design.md create mode 100644 openspec/changes/normalize-conversation-persistence/proposal.md create mode 100644 openspec/changes/normalize-conversation-persistence/specs/conversation-persistence/spec.md create mode 100644 openspec/changes/normalize-conversation-persistence/tasks.md create mode 100644 openspec/changes/retire-thread-tree-authority/.openspec.yaml create mode 100644 openspec/changes/retire-thread-tree-authority/design.md create mode 100644 openspec/changes/retire-thread-tree-authority/proposal.md create mode 100644 openspec/changes/retire-thread-tree-authority/specs/conversation-cutover/spec.md create mode 100644 openspec/changes/retire-thread-tree-authority/tasks.md diff --git a/openspec/changes/add-conversation-command-api/.openspec.yaml b/openspec/changes/add-conversation-command-api/.openspec.yaml new file mode 100644 index 00000000..d160e09c --- /dev/null +++ b/openspec/changes/add-conversation-command-api/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-21 diff --git a/openspec/changes/add-conversation-command-api/design.md b/openspec/changes/add-conversation-command-api/design.md new file mode 100644 index 00000000..cc0c6ab3 --- /dev/null +++ b/openspec/changes/add-conversation-command-api/design.md @@ -0,0 +1,195 @@ +## Context + +规范持久化和 Generation 生命周期已经定义“什么是真实实体”和“执行如何收敛”,但它们不能直接暴露给组件或路由。当前客户端仍倾向于先修改整棵树、PUT 存盘,再发起模型请求;Fork、重试和变体选择也可能在不同模块各自实现事务语义。 + +本设计建立一个传输无关的应用层,并提供首个 HTTP/JSON 适配器。网页、未来 CLI、MCP 和公开 API 可以改变传输方式,但不能绕过同一命令、授权和仓储事务。 + +## Goals / Non-Goals + +**目标:** + +- 为 Conversation/Thread 生命周期、Fork、发送、编辑、重新生成、变体选择和 Generation Stop 建立单一命令入口。 +- 让认证、授权、幂等、版本冲突、事务和事件箱具有统一语义。 +- 返回规范快照和实体增量,支持后续客户端 normalized Store。 +- 给未来 CLI/MCP 留下稳定应用端口,而不是让它们依赖 Next.js 路由或数据库。 + +**非目标:** + +- 在本 change 迁移现有客户端、删除旧 API 或开启生产规范权威。 +- 实现分享 token、公开访问、Project Memory/File/Instruction 接口。 +- 实现 Issue #39 的 Thread 结论发布、跨 Thread 合并或收敛。 +- 规定 UI 布局、轮询视觉或乐观动画。 + +## Decisions + +### D1. 应用层使用显式 Command/Query 端口 + +应用层按用例导出命令与查询,而不是通用 CRUD 仓储: + +```text +Queries + listConversations + getConversationSnapshot + getGeneration + +Commands + createConversation / renameConversation + archiveConversation / restoreConversation / deleteConversation + renameThread / archiveThread / restoreThread + forkThread + sendTurn + editTurnInput + regenerateTurn + selectTurnVariant + stopGeneration +``` + +每个 handler 依赖 `UnitOfWork`、规范仓储、授权策略、时钟、ID、事件箱和 Generation 端口。它们不导入 Next.js Request/Response,也不返回 HTTP 状态。 + +备选方案是暴露通用 `updateEntity`;它会允许调用方组合非法关系并把不变量分散回传输层,因此拒绝。 + +### D2. 写命令使用统一内部 envelope + +内部命令输入包含: + +```text +commandId +actor +scope +targetId +idempotencyKey +expectedRevision +payload +``` + +幂等记录保存 actor、作用域、命令类型、规范化载荷摘要和序列化结果引用。相同摘要重放原结果,不同摘要返回 `idempotency_conflict`。 + +HTTP 适配器优先把 `Idempotency-Key` 映射到幂等键,把 `If-Match`/ETag 映射到预期 revision;JSON 只承载领域载荷。内部 envelope 不强迫未来 MCP 使用 HTTP header。 + +### D3. revision 按最小冲突边界选择 + +```text +Conversation revision:结构、标题、生命周期、ThreadFork 集合 +Thread revision: Thread 生命周期、标题、Turn 追加顺序 +Turn revision: 当前用户/助手变体选择 +Generation version: 执行状态、租约与 checkpoint +``` + +`forkThread` 比较并推进 Conversation revision;发送比较并推进 Thread revision;选择变体比较并推进 Turn revision。事务同时更新多个边界时全部条件必须成功。 + +备选方案是所有命令锁整条 Conversation;实现简单但会让两个独立 Thread 的发送互相冲突,因此拒绝。完全没有 revision 则会把最后写入者胜出当成正确性,因此也拒绝。 + +### D4. Fork 保存来源边界,不复制上游 Message + +`forkThread` 事务执行: + +1. 授权并锁定目标 Conversation revision; +2. 验证来源 Message 属于上游 Thread 且在其当前可用路径中; +3. 创建下游 Thread; +4. 创建唯一 ThreadFork,记录 `sourceMessageId` 与可选 TextAnchor; +5. 将下游提示词上下文边界定义为“沿来源链截至该 Message”的读取投影; +6. 推进 Conversation revision并写入 `ThreadForked` 事件箱事件。 + +不复制上游 Turn/Message;否则同一历史会有多个身份,反馈、分享和后续修改无法判定引用对象。读取模型负责把继承上下文与下游本地 Turns 投影成一列可理解内容。 + +### D5. 发送命令创建完整执行意图 + +`sendTurn` 在事务中创建 Turn、用户 Message、pending 助手 Message、规范 Generation 和 `GenerationRequested` 事件。提交后调度执行器;执行器启动失败由 Generation 生命周期收敛,不回滚已经可查询的意图。 + +`regenerateTurn` 为同 Turn 创建新的助手 Message/Generation。`editTurnInput` 为当前末尾 Turn 创建用户变体和对应助手变体;若目标 Turn 后面已有内容,返回 `fork_required`,避免静默改变历史含义。 + +### D6. 读取快照与写入 delta 使用统一传输版本 + +HTTP 成功响应使用稳定 envelope: + +```json +{ + "schemaVersion": 1, + "data": {}, + "revisions": {}, + "delta": { + "upsert": {}, + "remove": {}, + "invalidate": [] + } +} +``` + +首次 `ConversationSnapshot` 可以包含全部规范实体与明确的派生投影;命令只返回受影响实体、移除 tombstone 和需要重取的投影键。响应中的 JSON schema 通过共享传输契约解析,但领域实体不直接依赖该 schema。 + +### D7. HTTP 资源路径服务于稳定身份 + +首个适配器采用资源和领域动作组合: + +```text +GET/POST /api/projects/{projectId}/conversations +GET/PATCH/DELETE /api/conversations/{conversationId} +POST /api/conversations/{conversationId}/restore +PATCH /api/threads/{threadId} +POST /api/threads/{threadId}/restore +POST /api/threads/{threadId}/forks +POST /api/threads/{threadId}/turns +POST /api/turns/{turnId}/input-edits +POST /api/turns/{turnId}/regenerations +POST /api/turns/{turnId}/active-variant +GET /api/generations/{generationId} +POST /api/generations/{generationId}/stop +``` + +PATCH 只接受明确允许的标题/生命周期字段,不是任意数据库 patch。具体文件可以按 Next.js 当前版本约定组织,但路由只调用应用端口。 + +### D8. 统一授权且不信任归属载荷 + +认证适配器产生 actor;应用授权策略从规范包含关系解析 Workspace/Project/Conversation/Thread/Message/Generation。请求载荷不得决定 owner 或成员身份。不可见资源和不存在资源统一返回 `not_found`,避免 ID 枚举。 + +未来分享 token 会引入另一种 actor 与权限范围,但必须复用同一策略端口;本 change 不提前实现它。 + +### D9. 错误为应用结果,不是散落异常文本 + +应用层稳定分类与 HTTP 映射为: + +```text +invalid_request -> 400 +unauthenticated -> 401 +forbidden -> 403(仅资源存在性已对 actor 可见时) +not_found -> 404 +version_conflict -> 409 +idempotency_conflict -> 409 +state_conflict -> 409 +semantic_validation -> 422 +rate_limited -> 429 +internal -> 500 +``` + +Stop 已终结 Generation 是幂等成功,不是 `state_conflict`。每个错误携带稳定 `code`、`requestId` 和安全 details;日志可以保存内部 cause,但响应不泄漏数据库或模型错误。 + +### D10. 事务事件箱位于同一提交边界 + +会触发执行、标题生成、审计或未来订阅的命令,在规范实体事务内追加 outbox 事件。事件至少包含事件 ID、聚合 ID、聚合 revision、类型、schemaVersion、actor 和发生时间。消费者以事件 ID 幂等;事件不是新的写入权威。 + +### D11. 新 API 默认不承载现有客户端 + +路由可以在隔离测试或显式配置下启用,但旧客户端在 `normalize-conversation-client-state` 完成前不切换。禁止新命令同时调用旧整树 PUT,也禁止从旧树回写规范实体。最终 change 执行一次性 cutover。 + +## Risks / Trade-offs + +- **[风险] 显式命令数量比通用 CRUD 多。** → 这是把业务语义集中并可测试的必要成本;共享 envelope、结果和端口降低样板。 +- **[风险] 多层 revision 会增加客户端处理。** → 每个命令只携带最小边界 revision,响应统一返回更新值,避免全 Conversation 冲突。 +- **[风险] 继承上下文读取投影可能较复杂。** → ThreadFork 保存唯一来源,查询层集中组装并缓存可重建投影,不复制规范 Message。 +- **[风险] outbox 消费存在至少一次投递。** → 事件和消费者使用稳定 ID 幂等,实体事务不依赖同步消费者成功。 +- **[风险] 新旧 API 并存容易误调用。** → 使用互斥 authority 配置、清晰模块命名和契约测试,客户端切换前不启用生产规范写入。 + +## Migration Plan + +1. 建立应用命令/查询、统一结果、授权、幂等与 UnitOfWork 端口。 +2. 实现 Conversation/Thread 生命周期和 `forkThread`,验证事务与 revision。 +3. 接入 `sendTurn`、编辑、重新生成、变体选择与 Generation 查询/Stop。 +4. 增加 HTTP 传输 schema、路由、ETag/Idempotency-Key 和错误映射。 +5. 增加 outbox dispatcher、契约测试和隔离端到端测试。 +6. 保持生产旧客户端未切换,把新契约交给 `normalize-conversation-client-state`。 + +回滚通过关闭规范 API 路由完成;规范数据不可回写旧 JSON,也不启用双写补偿。 + +## Open Questions + +无。公开分享 actor、速率套餐和外部 API 版本生命周期属于后续独立提案,不阻塞内部应用契约和首个网页适配器。 diff --git a/openspec/changes/add-conversation-command-api/proposal.md b/openspec/changes/add-conversation-command-api/proposal.md new file mode 100644 index 00000000..e0b03a18 --- /dev/null +++ b/openspec/changes/add-conversation-command-api/proposal.md @@ -0,0 +1,32 @@ +## Why + +规范表和 Generation 生命周期只能保证持久化层内部正确;如果网页、未来 CLI 或 MCP 仍能分别写 Thread、Message、Fork 或整包快照,重复关系和竞态会从接口重新进入系统。需要一套与传输无关的应用命令和统一 HTTP 适配器,把权限、幂等、版本冲突和事务边界集中为唯一写入入口。 + +## What Changes + +- 新增 Conversation 查询与命令应用层,覆盖 Conversation 创建/列表/读取/重命名/归档/删除,以及 Thread Fork、发送、编辑、重新生成和变体选择。 +- `forkThread` 在一个服务端事务中创建下游 Thread、ThreadFork、上下文边界、Conversation 版本和事务事件;客户端不得分步拼装关系。 +- 发送、编辑与重新生成通过命令原子创建 Turn/Message/Generation,并复用规范 Generation 生命周期;任何付费调用发生在命令事务提交之后。 +- 所有命令使用统一 envelope,包含命令 ID、幂等键、目标 ID、预期版本和载荷;服务端返回规范实体增量与最新版本,而非可回写整树。 +- 新增统一身份认证、所有者/成员授权、稳定领域错误和 HTTP 状态映射;冲突只在预期版本、幂等载荷或当前状态真实冲突时返回。 +- 新增首次加载 `ConversationSnapshot`、增量查询以及 Generation 查询/Stop 端点,供网页与未来公开适配器共用同一应用服务。 +- **BREAKING**:目标接口不再提供整棵 `ThreadTreeState` 的权威 PUT、客户端关系写入或浏览器先存盘屏障;旧路由仅在最终切换前暂存,不能成为新命令的第二权威。 + +## Capabilities + +### New Capabilities + +- `conversation-command-api`:定义 Conversation 查询、原子领域命令、权限、幂等、并发、实体增量和稳定传输结果。 + +### Modified Capabilities + +- 无。遗留路由的删除由 `retire-thread-tree-authority` 负责,本变更建立替代契约。 + +## Impact + +- 应用层:新增 Conversation commands/queries、事务端口、授权策略、事件箱事件和统一结果类型。 +- 接口层:新增 `/api/projects/.../conversations`、`/api/conversations/...`、Thread/Turn/Generation 子资源路由及传输 schema。 +- 持久化:复用规范仓储、Conversation/Thread/Turn revision、Generation 生命周期和事务事件箱。 +- 客户端:后续改用快照初始化和实体增量;本变更只提供接口,不迁移 Store。 +- 前置依赖:`define-conversation-domain-model`、`normalize-conversation-persistence`、`migrate-generation-lifecycle`。 +- 后续依赖:`normalize-conversation-client-state`、`retire-thread-tree-authority`,以及未来 CLI/MCP/分享适配器。 diff --git a/openspec/changes/add-conversation-command-api/specs/conversation-command-api/spec.md b/openspec/changes/add-conversation-command-api/specs/conversation-command-api/spec.md new file mode 100644 index 00000000..c0d0dba0 --- /dev/null +++ b/openspec/changes/add-conversation-command-api/specs/conversation-command-api/spec.md @@ -0,0 +1,183 @@ +## Purpose + +为网页、未来 CLI 与 MCP 提供同一套 Conversation 查询和原子命令契约,使授权、幂等、并发、Fork、Message 变体与 Generation 只能通过服务端应用层改变。 + +## ADDED Requirements + +### Requirement: 通过应用命令改变规范实体 + +系统 SHALL 只允许应用命令改变 Conversation、Thread、ThreadFork、Turn、Message、Generation 或当前有效变体。传输层 MUST 只负责认证、解析、调用命令和序列化结果,不得自行拼装关系或接受整包 Conversation 快照覆盖规范实体。 + +#### Scenario: 拒绝整树写入 + +- **WHEN** 客户端尝试提交全部 Thread、Message 与 Fork 以覆盖一条 Conversation +- **THEN** 新接口不提供该写入能力,并要求调用对应实体或领域动作命令 + +#### Scenario: 多种适配器调用相同命令 + +- **WHEN** 网页接口和未来 CLI 对同一 Conversation 执行相同领域动作 +- **THEN** 二者调用同一应用命令并获得相同权限、不变量、幂等与冲突语义 + +### Requirement: 提供 Conversation 生命周期查询和命令 + +系统 SHALL 提供按 Project 列出和创建 Conversation、按 ID 读取快照,以及重命名、归档、恢复和删除 Conversation 的查询或命令。创建 SHALL 在一个事务中创建 Conversation 与根 Thread;删除 SHALL 遵守运行中 Generation 和归属资产保护规则。 + +#### Scenario: 创建 Conversation 与根 Thread + +- **WHEN** 有权限成员在 Project 中创建 Conversation +- **THEN** 系统原子创建 Conversation 和唯一根 Thread,并返回二者的规范增量与初始版本 + +#### Scenario: 归档后从默认列表隐藏 + +- **WHEN** 用户归档 Conversation +- **THEN** Conversation 保持可按 ID 恢复和审计,但默认活跃列表不再返回它 + +#### Scenario: 运行中任务阻止删除 + +- **WHEN** Conversation 仍有非终态 Generation 且用户请求删除 +- **THEN** 系统返回稳定状态冲突且不删除任何实体 + +### Requirement: 提供 Thread 生命周期命令 + +系统 SHALL 提供 Thread 重命名、归档和恢复命令。归档 Thread 不得删除 Message、Generation 或 ThreadFork,也不得改变其他 Thread 的身份;若归档会使当前界面路径不可见,服务端仍 SHALL 保留规范关系,由客户端选择其他可见 Thread。根 Thread 不得脱离 Conversation 单独归档;调用方必须改用 Conversation 归档命令。 + +#### Scenario: 归档非根 Thread + +- **WHEN** 用户归档一个没有非终态 Generation 的分支 Thread +- **THEN** 系统更新该 Thread 生命周期与 revision,保留其 Fork 来源、后代和历史内容 + +#### Scenario: 运行中任务阻止 Thread 归档 + +- **WHEN** Thread 中仍有非终态 Generation +- **THEN** 系统返回稳定状态冲突,Thread 保持活跃 + +#### Scenario: 单独归档根 Thread + +- **WHEN** 用户直接请求归档 Conversation 的根 Thread +- **THEN** 系统返回稳定 `conversation_action_required` 结果,并保持 Conversation 与根 Thread 活跃 + +### Requirement: 原子执行 forkThread + +`forkThread` 命令 SHALL 验证来源 Message 属于上游 Thread,随后在单个事务中创建下游 Thread、唯一 ThreadFork、继承上下文边界、必要版本更新和事务事件箱事件。命令不得复制上游 Message 作为新的规范 Message,也不得要求客户端随后补写 parent、children 或反向 Fork 列表。 + +#### Scenario: 从任意 Thread 的确定 Message Fork + +- **WHEN** 用户以有效上游 Thread、来源 Message、预期 Conversation revision 和幂等键执行 `forkThread` +- **THEN** 系统返回新 Thread、ThreadFork 和版本增量,下游上下文在来源 Message 处截止 + +#### Scenario: Fork 事务中途失败 + +- **WHEN** 来源错配、版本冲突或约束使事务失败 +- **THEN** 系统不留下孤立 Thread、半条 ThreadFork 或事件箱事件 + +### Requirement: 原子执行发送与 Generation 开始 + +发送命令 SHALL 在目标 Thread 中原子创建 Turn、用户 Message、待生成助手 Message 和 Generation,并在事务提交后才开始模型执行。命令 SHALL 使用 Thread revision 保护追加顺序;同一幂等键不得创建重复 Turn 或重复付费执行。 + +#### Scenario: 在 Thread 尾部发送消息 + +- **WHEN** 用户以当前 Thread revision 发送有效输入 +- **THEN** 系统提交新的 Turn、两条 Message 和 Generation,返回规范增量,然后启动服务端执行 + +#### Scenario: 并发发送使用旧 revision + +- **WHEN** 另一个命令已经推进 Thread,而客户端仍以旧 revision 发送 +- **THEN** 系统返回版本冲突和最新可读 revision,不创建第二个不确定顺序的 Turn + +### Requirement: 编辑与重新生成追加 Message 变体 + +编辑当前 Turn 的用户输入和重新生成助手回答 SHALL 在同一 Thread/Turn 中追加新 Message 变体与新 Generation,不得原地覆盖旧 Message。编辑历史位置若已有后续 Turn MUST 被拒绝或要求显式 Fork,不能静默重写后续上下文。 + +#### Scenario: 重新生成当前助手回答 + +- **WHEN** 用户对 Turn 的当前助手 Message 执行重新生成 +- **THEN** 系统追加新的助手 Message 和 Generation,并在合法终结后按版本更新当前助手变体 + +#### Scenario: 编辑有后续内容的历史 Turn + +- **WHEN** 用户尝试直接编辑并续跑一个已有后续 Turn 的历史输入 +- **THEN** 系统返回稳定 `fork_required` 结果,且不修改当前 Thread 历史 + +### Requirement: 持久化选择当前有效变体 + +选择变体命令 SHALL 只允许把同一 Thread/Turn、角色匹配且内容可用的 Message 设为当前有效用户或助手变体。命令 MUST 使用 Turn revision,原子更新选择并返回受影响 Turn 增量。 + +#### Scenario: 切换助手变体 + +- **WHEN** 用户以当前 Turn revision 选择同 Turn 的另一条可用助手 Message +- **THEN** 系统持久化新选择,推进 Turn revision,后续上下文使用该变体 + +#### Scenario: 拒绝跨 Turn 选择 + +- **WHEN** 命令尝试选择另一 Turn 或 Thread 的 Message +- **THEN** 系统拒绝命令且不改变当前选择 + +### Requirement: 提供 Generation 查询和 Stop 命令 + +系统 SHALL 按稳定 Generation ID 提供状态、最新 checkpoint、usage 完整度和计费状态查询,并提供幂等 Stop 命令。Stop 运行中任务 SHALL 返回已接受或当前状态;Stop 已终结任务 SHALL 返回现有终态,不得虚构版本冲突。 + +#### Scenario: 查询进行中 Generation + +- **WHEN** 所有者查询 `running` 或 `stop_requested` Generation +- **THEN** 系统返回当前状态、最新 checkpoint 版本、内容状态与可用轮询提示 + +#### Scenario: Stop 已完成 Generation + +- **WHEN** 所有者对 `completed` Generation 发出 Stop +- **THEN** 系统幂等返回 `completed` 结果,不返回无定义的冲突也不修改计费 + +### Requirement: 统一身份认证和资源授权 + +每个查询和命令 SHALL 先解析认证主体,再通过 Workspace/Project 成员关系检查目标资源。未认证请求 MUST 返回稳定未认证结果;不存在或调用方不可见的目标 MUST 使用不可枚举的未找到结果。客户端提交的 owner、workspace 或 project 字段不得覆盖服务端解析的归属。 + +#### Scenario: 伪造 owner 字段 + +- **WHEN** 已认证用户在载荷中提交另一个 owner ID +- **THEN** 系统忽略或拒绝该字段,并仅使用认证主体和成员关系解析权限 + +#### Scenario: 访问他人不可见 Message + +- **WHEN** 用户按 ID 查询无权访问的 Message 或其 Generation +- **THEN** 系统返回与资源不存在相同的未找到结果,不泄漏资源是否存在 + +### Requirement: 命令具有统一幂等和版本语义 + +每个有副作用命令 SHALL 携带稳定幂等键;会修改既有聚合的命令 SHALL 携带相应 Conversation、Thread 或 Turn 的预期 revision。相同主体、作用域、幂等键和等价载荷 SHALL 返回原结果;同键不同载荷 MUST 返回幂等冲突;版本冲突 MUST 返回最新 revision 与可重试信息。 + +#### Scenario: 网络超时后重放命令 + +- **WHEN** 客户端未收到响应并以相同幂等键重放等价命令 +- **THEN** 系统返回第一次提交的结果,不重复写实体、事件或付费任务 + +#### Scenario: 重用幂等键提交不同载荷 + +- **WHEN** 客户端用已提交的幂等键发送不同命令载荷 +- **THEN** 系统返回稳定幂等冲突且不执行新载荷 + +### Requirement: 返回规范快照和实体增量 + +首次读取 Conversation SHALL 返回带 schema/version 的规范快照;成功命令 SHALL 返回命令结果、最新作用域 revision 和仅受影响的规范实体增量。派生 children、深度、Fork 数量和可见路径可以出现在读取投影中,但 MUST 标记为可重建,不得作为后续写命令输入。 + +#### Scenario: 首次加载 Conversation + +- **WHEN** 客户端读取一条可见 Conversation +- **THEN** 系统返回稳定排序的 Conversation、Threads、ThreadForks、Turns、Messages、相关 Generation 摘要和当前有效选择 + +#### Scenario: Fork 命令只返回受影响实体 + +- **WHEN** `forkThread` 成功 +- **THEN** 响应包含新 Thread、ThreadFork、更新的 Conversation revision 和必要投影失效信息,而不是回传可写整树 + +### Requirement: 使用稳定错误分类映射传输结果 + +应用层 SHALL 返回与 HTTP 无关的稳定结果分类,传输适配器 SHALL 一致映射为状态码和机器可读错误代码。只有真实预期版本冲突、幂等冲突或当前状态冲突可以映射为 `409`;格式错误、未认证、不可见、语义验证和服务器错误 MUST 分别映射,不得用自由文本区分。 + +#### Scenario: Stop 不存在冲突条件 + +- **WHEN** Stop 命令命中已终结 Generation +- **THEN** 应用层返回幂等成功结果,传输层不得为了列出状态码而返回 `409` + +#### Scenario: Fork 使用旧 Conversation revision + +- **WHEN** `forkThread` 的预期 revision 已过期 +- **THEN** 应用层返回 `version_conflict`,HTTP 适配器映射为 `409` 并附最新 revision diff --git a/openspec/changes/add-conversation-command-api/tasks.md b/openspec/changes/add-conversation-command-api/tasks.md new file mode 100644 index 00000000..69798eb1 --- /dev/null +++ b/openspec/changes/add-conversation-command-api/tasks.md @@ -0,0 +1,43 @@ +## 1. 应用层基础契约 + +- [ ] 1.1 定义 Command/Query 输入、actor、幂等键、预期 revision、规范 delta 和稳定错误结果,不导入 HTTP 或数据库行类型。 +- [ ] 1.2 定义 `UnitOfWork`、授权策略、幂等存储、事务事件箱和规范仓储端口,并建立测试替身。 +- [ ] 1.3 实现 Workspace/Project → Conversation/Thread/Message/Generation 的不可枚举授权解析和安全错误 details。 +- [ ] 1.4 实现幂等载荷摘要、等价重放、同键异载荷冲突和命令结果恢复。 + +## 2. Conversation 与 Thread 生命周期 + +- [ ] 2.1 实现 Conversation 列表、规范快照读取以及创建 Conversation + 根 Thread 的原子命令。 +- [ ] 2.2 实现 Conversation 重命名、归档、恢复和受保护删除,覆盖运行中 Generation 冲突。 +- [ ] 2.3 实现 Thread 重命名、非根 Thread 归档和恢复,保留 Fork、后代、Message 与 Generation 历史,并要求根 Thread 使用 Conversation 归档。 +- [ ] 2.4 增加生命周期命令的权限、幂等、revision 和事务回滚测试。 + +## 3. Fork 与上下文边界 + +- [ ] 3.1 实现 `forkThread` 的来源 Message、同 Conversation、当前可用路径和预期 Conversation revision 校验。 +- [ ] 3.2 在单事务创建下游 Thread、ThreadFork、上下文边界、revision 与 `ThreadForked` outbox 事件。 +- [ ] 3.3 实现不复制上游 Message 的继承上下文读取投影,并覆盖 A → B → C 嵌套 Fork。 +- [ ] 3.4 覆盖来源错配、并发 Fork、重复幂等键和事务失败无孤立实体测试。 + +## 4. Turn、Message 变体与 Generation 命令 + +- [ ] 4.1 实现 `sendTurn`,原子创建 Turn、用户/助手 Message、Generation 和事件,并在提交后启动执行器。 +- [ ] 4.2 实现末尾用户输入编辑和助手重新生成,以新 Message/Generation 变体追加而非覆盖旧身份。 +- [ ] 4.3 对已有后续 Turn 的历史编辑返回 `fork_required`,不修改现有上下文。 +- [ ] 4.4 实现带 Turn revision 的用户/助手当前变体选择和角色/归属/内容可用性校验。 +- [ ] 4.5 实现 Generation 查询与幂等 Stop,复用规范 lifecycle 并让已终结 Stop 返回现有结果。 + +## 5. HTTP 传输适配器 + +- [ ] 5.1 定义带 `schemaVersion` 的请求、快照、delta、revision 和错误传输 schema。 +- [ ] 5.2 实现 Project/Conversation/Thread/Turn/Generation 资源路由,只调用应用 Command/Query。 +- [ ] 5.3 将 `Idempotency-Key`、`If-Match`/ETag 和认证会话映射到内部 command envelope。 +- [ ] 5.4 实现 400/401/403/404/409/422/429/500 的稳定错误映射,并验证只有真实冲突返回 409。 +- [ ] 5.5 增加接口契约测试,覆盖 owner 伪造、不可见资源、格式错误、幂等重放和 revision 冲突。 + +## 6. 事件箱与隔离验证 + +- [ ] 6.1 实现 outbox 写入和幂等 dispatcher,验证事件与规范实体在同一事务提交。 +- [ ] 6.2 增加默认关闭的规范 API 开关,拒绝新命令调用旧整树 PUT 或双写旧权威。 +- [ ] 6.3 在隔离环境跑通创建 Conversation、发送、Fork、嵌套 Fork、重新生成、选择变体、Stop、归档与恢复端到端流程。 +- [ ] 6.4 运行相关测试、`pnpm typecheck`、`pnpm openspec:validate` 和接口 schema 检查并记录结果。 diff --git a/openspec/changes/define-conversation-domain-model/.openspec.yaml b/openspec/changes/define-conversation-domain-model/.openspec.yaml new file mode 100644 index 00000000..d160e09c --- /dev/null +++ b/openspec/changes/define-conversation-domain-model/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-21 diff --git a/openspec/changes/define-conversation-domain-model/design.md b/openspec/changes/define-conversation-domain-model/design.md new file mode 100644 index 00000000..3381b7ba --- /dev/null +++ b/openspec/changes/define-conversation-domain-model/design.md @@ -0,0 +1,404 @@ +## Context + +变更动机见 `proposal.md`,规范语言见 `specs/domain/spec.md`。 + +当前 Thread Chat 模型是一份持久化到 `branch_trees.state` JSONB 的 `ThreadTreeState` 对象。同一个 Fork 事实被重复保存在 `Thread.parentId`、`Thread.children`、`Thread.forkFromMsgId`、`Message.forks`、深度、脚注和锚点等字段中。根节点使用魔法注册项 `threads.main`。Message 变体存在于各 Thread 内部的父子图中,由 `activeLeafMessageId` 选择提示词路径。`branch_generations` 虽然是关系型辅助表,但它的 Thread 和 Message ID 指向 JSON 内部节点,数据库无法通过外键保护。服务端启动和终结 Generation 的仓储因此必须锁定并重写整棵树,再把辅助表结果协调合并回 JSON。 + +Issue #34 要求替换这套权威模型。产品讨论同时确认:Project 必须拥有一整个 Conversation,而用户看到的每一列对话都是一个 Thread。Issue #39 单独记录未来的发散与收敛行为,本变更明确不包含该能力。 + +仓库中仍有活跃 OpenSpec 变更描述旧 JSON 权威模型,特别是 `persist-thread-chat-generations` 和 `add-thread-chat-message-actions`。已经完成的历史行为仍然是有价值的证据,但这些文档不能继续作为目标架构的事实来源。 + +## Goals / Non-Goals + +**目标:** + +- 在提出任何持久化、接口或客户端迁移方案前,建立一套唯一的规范术语。 +- 定义目标逻辑实体、所有权、身份和关系不变量。 +- 收敛 Issue #34 中会改变后续数据库结构和任务顺序的决策。 +- 定义模块边界,使网页界面、未来公开接口、命令行和 MCP 适配器共用同一应用层。 +- 给出由多个可独立审查的小变更组成的明确依赖链。 +- 把遗留 `ThreadTreeState` 隔离为迁移输入,避免旧名称继续泄漏到新模块。 + +**非目标:** + +- 在本变更中创建规范化数据库表或迁移生产、开发数据。 +- 在本变更中替换现有命令接口、Generation 流、协调合并逻辑或客户端状态存储。 +- 重建 Markdown 渲染、流式展示、编辑器、画布或视觉设计。 +- 实现 Project Memory、Project Instruction、Project File、分享、公开令牌、命令行或 MCP。 +- 实现 Issue #39 中的 Thread 结果发布或 Conversation 收敛。 +- 为不存在的外部客户端保留兼容层,或永久保留旧整树写入协议。 + +## Decisions + +### D1. 规范聚合关系:Project → Conversation → Thread + +目标包含关系为: + +```text +Workspace +└── Project + ├── ProjectInstructionVersion + ├── MemoryItem + ├── File + └── Conversation + ├── rootThreadId + └── Thread[] +``` + +`Conversation` 取代 `Thread Tree`。Conversation 是用户可以整体命名、列出、归档、删除并最终分享的聚合。Thread 是 Conversation 内一条可以独立继续的对话列。 + +Workspace 一词专门表示租户、成员和授权边界。现有前端工作台在概念上改称“界面工作区”或“视图状态”,避免与授权聚合混淆。第一次持久化迁移是引入物理 Workspace 行,还是把当前所有者直接映射到默认 Project,由该迁移变更决定;这个实现选择不会改变本设计的包含契约。 + +**备选方案:Project 直接拥有 Thread。** 不采用,因为它无法稳定标识一整个分叉对话,也无法表达其标题、生命周期、加载、分享和画布投影。 + +**备选方案:保留 Thread Tree。** 不采用,因为它描述的是实现拓扑,而不是稳定产品资源,并且会与未来非 Fork 关系发生冲突。 + +### D2. Branch 是关系角色;ThreadFork 是唯一 Fork 事实 + +所有对话列都使用同一种 `Thread` 实体。主线和分支角色通过关系推导: + +```text +主 Thread = Conversation.rootThreadId +分支 Thread = 具有一个入向 ThreadFork 的 Thread +``` + +`ThreadFork` 是独立关系: + +```text +ThreadFork +├── id +├── conversationId +├── parentThreadId +├── sourceMessageId +├── childThreadId # 唯一 +├── anchor # 可选 TextAnchor 值对象 +├── createdBy +└── createdAt +``` + +必须满足以下约束: + +1. 上游 Thread、下游 Thread 和来源 Message 都能在同一 Conversation 中解析; +2. 来源 Message 属于上游 Thread; +3. 下游 Thread 最多只有一个入向 ThreadFork; +4. 根 Thread 没有入向 ThreadFork; +5. 每个非根 Thread 恰好有一个入向 ThreadFork; +6. Fork 有向关系不得形成环; +7. 派生子 Thread、深度、脚注标签和分支数量都不是可写事实。 + +锚点继续作为 ThreadFork 拥有的值对象。它没有独立权限或生命周期,因此不能仅仅为了形式上的规范化而拆成独立实体表。 + +`forkThread` 必须是一个应用命令和一个数据库事务。它创建下游 Thread 与 ThreadFork,推进最小必要版本边界并记录事务事件箱事件。客户端不能分别创建再关联这些对象。 + +**备选方案:在下游 Thread 保存 `parentThreadId + forkFromMessageId`。** 不采用,因为 Fork 自身具有来源、锚点、审计和未来展示语义。独立关系能够清楚表达唯一事实,又不会让 Thread 承担无关字段。 + +**备选方案:为快速读取保留上游 children 和 Message forks 数组。** 不允许作为权威状态。可以存在等价索引或读取模型,但它们必须可以丢弃并重建。 + +### D3. 使用显式 Turn 约束回复变体 + +目标对话图分离四类问题: + +```text +Thread 来源关系: Thread ──ThreadFork──> Thread +Turn 顺序: Thread ──> Turn ──> Turn +Message 变体: Turn ──> Message 变体 +执行尝试: Generation ──> 输入/输出 Message +``` + +逻辑实体为: + +```text +Thread +├── id +├── conversationId +├── modelId / 模型设置 +├── 生命周期 +├── revision +└── Turn[] + +Turn +├── id +├── threadId +├── 前序/顺序 +├── activeUserMessageId +├── activeAssistantMessageId +└── revision + +Message +├── id +├── threadId +├── turnId +├── 角色 +├── 内容/结构化片段 +├── 状态 +├── 变体来源(可选) +└── createdAt + +Generation +├── id +├── threadId +├── turnId +├── inputMessageId +├── outputMessageId +├── sourceAssistantMessageId(仅重新生成) +├── 意图/状态/模型 +├── 心跳/停止 +├── 用量/计费 +└── 幂等身份 +``` + +Turn 为回复变体提供显式稳定键,因此选择器和约束不再通过任意父子链推断“同一个回复位置”。Message 内容可以继续使用带版本的 JSON 或结构化片段值,因为工具片段不会仅仅由于结构化就拥有独立所有权。 + +对于当前已经支持的行为,编辑最后一条用户输入或重新生成最后一条助手输出,会在同一个 Turn 内创建 Message 变体。未来若支持编辑任意历史位置,可以创建新 Thread,而不是静默重写所有后续 Turn;该选择不属于本变更。 + +复合外键或事务检查必须保证 Turn、Message 和 Generation 的 Thread ID 一致。如果普通单列外键仍允许 Generation 组合来自不同 Thread 的 Message,就不足以保护这个不变量。 + +**备选方案:只保留 Message 有向无环图。** 不采用,因为当前错误类型正是由系统反复根据图位置推断 Turn 和变体归属,再把推断结果与另一张 Thread Fork 图组合造成的。 + +**备选方案:把一组用户和助手消息压成一行。** 不采用,因为 Message 需要稳定 ID,以支持分享、反馈、Artifact、Generation 来源和未来公开接口。 + +### D4. 当前有效变体是持久化领域状态 + +当前有效的用户或助手回复变体会决定: + +- 下一次提示词上下文; +- 后续 Turn 跟随哪一个回答; +- ThreadFork 使用的确定来源版本; +- 当前可见的 Message 所属 Artifact 和反馈操作。 + +因此,系统必须在 Turn 边界持久化该选择,并通过携带预期版本的幂等命令更新。不能根据数组顺序或仅存在于浏览器的叶节点推断它。 + +纯界面状态保持分离: + +```text +界面工作区/视图状态 +├── visibleColumns +├── foldedColumns +├── selectedThreadId +├── canvasViewport +├── openPanels +└── 临时草稿与乐观状态 +``` + +初始单所有者模型只有一份规范有效选择。如果未来多人协作需要每位成员拥有个人阅读选择,可以用独立提案增加用户级选择,而不改变 Message 身份或 ThreadFork 来源。 + +**备选方案:当前有效变体完全保留在客户端。** 不采用,因为刷新、服务端提示词组装和多客户端命令会产生分歧。 + +### D5. 每个实体只有一个事实来源;快照只是读取模型 + +规范化写模型拥有实体身份和关系。服务端可以返回为首次加载优化的 `ConversationSnapshot`,但该快照必须由规范实体组装,不能作为整包写入数据被接受。 + +```text +写入路径: +客户端命令 + → 传输校验 + → 应用命令 + → 领域不变量 + → 仓储事务 + → 规范实体增量 + 事务事件箱 + +读取路径: +仓储/读取模型 + → ConversationSnapshot + → 客户端规范化 + → 按 ID 定位的选择器 +``` + +`branch_trees.state` 可以在迁移期暂时作为迁移输入或非权威导出、缓存。任何缓存都必须可丢弃、带版本,并可从规范数据行重建。它不能参与冲突处理,也不能覆盖规范数据行。 + +**备选方案:继续让 JSONB 作为权威来源,再增加更多辅助表。** 不采用,因为辅助表 ID 仍然无法强制保证 Thread/Message 归属,每个新功能也都会增加一条协调合并路径。 + +### D6. 稳定公开身份先于分享、接口、命令行和 MCP + +Workspace、Project、Conversation、Thread、Turn、Message、Generation 和 File 使用不携带角色语义的不透明稳定 ID。公开适配器不得暴露内存数组下标、`main`、树内局部计数器或浏览器复合键作为资源身份。 + +授权包含关系与 Fork 来源关系保持分离: + +```text +授权包含关系: +Workspace → Project → Conversation → Thread → Message + +Conversation 来源关系: +上游 Thread + 来源 Message → ThreadFork → 下游 Thread + +执行关系: +Generation → Turn / Message / 用量 +``` + +未来 REST、命令行和 MCP 适配器调用与网页界面相同的应用命令和查询。它们不能直接查询数据库表,也不能再实现一套所有权规则。分享和令牌权限范围属于独立提案,但本设计确保每个未来分享目标都具有可寻址实体。 + +### D7. Project 资产拥有可跨 Conversation 使用的持久内容 + +Project 不只是 Conversation 文件夹。目标扩展点为: + +```text +ProjectInstructionVersion +├── projectId +├── version/content +└── 审计元数据 + +MemoryItem +├── projectId +├── content/status/version +└── 来源记录 + +File +├── projectId +├── FileVersion[] +└── 来源与引用关系 +``` + +Message 或 Generation 记录自己创建或使用了某个资产,但不能拥有一份可复用 File 的唯一副本。搜索切块、向量和摘要是派生索引,不是规范 File 或 MemoryItem。 + +本变更只定义所有权和术语,不增加这些产品能力,也不强迫现有 Markdown Artifact 在专门的 Project 资产变更之前迁移。 + +**备选方案:所有输出只保存在 Message JSON 内。** 对需要持久复用的文件不采用,因为删除或归档一个 Thread 不应控制无关 Project 资产的生命周期。 + +### D8. 模块边界服从策略,不服从框架目录 + +目标依赖方向为: + +```text +界面组件 + ↓ +客户端应用/状态存储 ──→ 传输契约 + ↓ + 应用命令/查询 + ↓ + 领域模型 + ↑ + 持久化实现 +``` + +规则如下。 + +#### 领域层 + +- 只包含纯 TypeScript 实体、ID、值对象、不变量和派生规则。 +- 不导入 React、DOM、Next.js 请求、Drizzle 数据表或 HTTP 状态。 +- TextAnchor 可以继续作为领域值对象,即使某个适配器会在 Markdown DOM 中解析它。 + +#### 应用层 + +- 包含命令、查询、所有者与权限策略、事务边界、幂等和冲突结果。 +- 依赖仓储、时钟、ID、事件和模型执行端口。 +- `forkThread`、`createTurn`、`regenerate`、`selectVariant`、`archiveConversation`、`stopGeneration` 在概念上属于此层。 + +#### 持久化层 + +- 包含 Drizzle 结构、SQL 约束、仓储适配器、迁移和事务事件箱。 +- 实现应用层端口;不得把数据库行形状泄漏到组件或路由。 + +#### 接口与传输层 + +- 负责身份认证与会话提取、请求结构、错误与状态映射、响应序列化。 +- 不得在路由局部分支中重复实现领域校验。 + +#### 客户端状态层 + +- 保存规范化的规范实体、可丢弃派生索引和乐观命令状态。 +- 应用服务端返回的规范增量;不得自行修改实体关系。 + +#### 界面组件层 + +- 消费按 ID 定位的选择器和命令。 +- 列、画布和 Message 操作组件不能写入 `children`、Fork 反向链接或 Message 所有权。 + +即使物理目录按阶段迁移,也必须遵守这条依赖分离原则。 + +### D9. 隔离遗留术语,不提供永久全局别名 + +迁移期间,专用适配器可以执行以下映射: + +```text +遗留模型 规范模型 +ThreadTreeState ConversationSnapshot / 遗留导入数据 +branch_trees.id Conversation ID 候选值 +threads.main Conversation.rootThreadId 目标 +Thread.parentId ThreadFork.parentThreadId +Thread.forkFromMsgId ThreadFork.sourceMessageId +Thread.children 派生 ThreadFork 索引 +Message.forks 派生 ThreadFork 索引 +Thread.activeLeafMessageId Turn 有效选择/读取投影 +branch_generations.* IDs Generation 外键 +``` + +遗留名称可以保留在名称明确的迁移、导入模块和历史迁移文件中。新的领域层、应用层和接口层代码不能引入 `type Conversation = ThreadTreeState` 一类别名;这种做法只是改名,没有改变所有权。 + +已归档的 OpenSpec 变更继续作为历史记录。依赖旧权威模型的活跃变更必须标记为被替代、受阻或依赖后续迁移,不能通过修改文档假装已完成任务已经符合新模型。 + +### D10. 变更依赖链 + +工作按架构接缝拆分,而不是按任意文件数量拆分: + +```text +define-conversation-domain-model (本变更) + │ + ▼ +normalize-conversation-persistence (实体、约束、仓储、 + │ 迁移与读取快照) + ▼ +migrate-generation-lifecycle (基于真实 Thread/Turn/Message 外键的 + │ 启动、停止、恢复、用量和计费) + ▼ +add-conversation-command-api (实体命令、增量、错误与幂等契约; + │ 退出整树 PUT) + ▼ +normalize-conversation-client-state (实体缓存、选择器、列、画布和 + │ Message 操作) + ▼ +retire-thread-tree-authority (切换、审计、删除遗留写入、 + 适配器和重复事实) +``` + +基础模型完成后可以并行开展: + +```text +normalize-conversation-persistence + ├── project-context-assets (未来独立提案) + └── public-data-access (未来接口、令牌、命令行、MCP 提案) + +retire-thread-tree-authority + └── conversation-convergence (Issue #39,未来提案) +``` + +每个后续变更只负责一个权威边界,并拥有独立验证目标: + +1. 持久化变更证明数据库不变量和规范快照读取正确; +2. Generation 变更证明生命周期和财务语义不再依赖 JSON 协调合并; +3. 接口变更证明只有服务端可以执行变更,并保证幂等; +4. 客户端变更证明规范接口能够产生正确投影; +5. 清理变更证明系统中不再存在遗留权威路径。 + +PR #30 保持 Draft;在 Generation 迁移解决其对 #34 的依赖前,不能视为符合最终架构。已经正确的流式行为可以保留,但 JSON 与辅助表之间的协调机制不是目标契约。 + +## Risks / Trade-offs + +- **[风险] 术语变更影响大量活跃文档,可能产生改了一半的模型。** → 把 `domain` 规范设为唯一正式词汇表,发布遗留映射,并禁止在迁移范围之外新增 `ThreadTree` 术语。 +- **[风险] 显式 Turn 增加一个实体和一组约束。** → 它可以消除重复运行时推断,并把重新生成和编辑不变量限制在局部;Message 内容继续使用灵活 JSON,避免过度拆分结构化片段。 +- **[风险] Conversation 与 Thread 标题可能成为重复事实。** → Conversation 拥有聚合与导航标题。非根 Thread 可以拥有本地列标题;根列使用 Conversation 标题,除非未来产品需求证明必须存在独立根标题。 +- **[风险] 引入 Project 和 Workspace 扩大 #34。** → 本变更只定义包含关系。物理 Workspace、Memory、Instruction、File 和分享能力继续使用独立提案。 +- **[风险] 分阶段迁移会暂时同时存在遗留表示和规范表示。** → 每个阶段只允许一个写入权威,把转换隔离在一个适配器中,增加一致性与审计检查,并设立强制清理变更。 +- **[风险] 前后端一次性切换可能遗留运行中的 Generation。** → 在接口和客户端切换前先迁移生命周期;切换前要求没有活跃 Generation 或具备兼容排空规则,并在每个阶段保留回滚边界。 +- **[风险] 现有 OpenSpec 变更依据已被替代的假设宣称任务完成。** → 明确记录依赖和被替代范围;不静默保留错误声明,也不重写已归档历史。 +- **[风险] 稳定公开 ID 被误认为已经具备授权。** → 在应用命令中保留包含关系和权限检查;来源关系本身永远不授予访问权限。 + +## Migration Plan + +本文定义迁移计划,实际执行由 D10 中的后续变更完成。 + +1. **落地术语与架构决策。** 应用修改后的 `domain` 规范,更新当前架构和术语引用,发布遗留映射,并把冲突的活跃变更标记为依赖本变更或已被替代。 +2. **在读取路径后方引入规范化持久化。** 创建规范数据行和约束;检查真实数据后决定重置还是一次性迁移;生成 `ConversationSnapshot` 读取模型。在一致性检查通过前,不启用规范写入。 +3. **迁移 Generation 所有权。** 把执行尝试、Message 输出、停止与恢复、用量和计费绑定到真实外键,并解决 PR #30 审查中已知的结算、部分结果和过期收敛语义。 +4. **迁移写入命令。** 启用具有幂等和版本冲突语义的规范实体命令。已经迁移的 Conversation 不再接受整棵 Conversation 写入。 +5. **迁移客户端。** 规范化快照,迁移选择器和界面命令,再从浏览器删除防抖保存和协调合并行为。 +6. **切换并清理。** 审计数据、排空活跃 Generation、禁用遗留写入、删除重复事实,最终按照已批准的持久化方案删除 `branch_trees.state` 或将其降级为非权威数据。 + +回滚按阶段进行: + +- 启用规范生产写入前,可以关闭新的读取模型和数据表,继续使用未改变的遗留权威; +- 一旦规范模型接受生产写入,不得把数据反向导出到 ThreadTree 后恢复旧权威;应保持规范数据库,通过回滚兼容客户端/应用、进入只读、恢复规范备份或前滚修复处理; +- 只有当命令接口仍兼容且不会丢失仅存在于规范模型的变更时,才能回滚客户端; +- 破坏性删除遗留列必须最后执行,并以已验证备份和审计报告为前提。 + +目前没有会改变本提案规范、实体选择或任务顺序的未决问题。`normalize-conversation-persistence` 负责取得真实数据审计证据;数据重置还是一次性导入及其部署批次由 `retire-thread-tree-authority` 在目标环境测量后以 ADR 决定。 diff --git a/openspec/changes/define-conversation-domain-model/proposal.md b/openspec/changes/define-conversation-domain-model/proposal.md new file mode 100644 index 00000000..8e4bac29 --- /dev/null +++ b/openspec/changes/define-conversation-domain-model/proposal.md @@ -0,0 +1,33 @@ +## Why + +当前 `Thread Tree / MainThread / ForkedThread` 术语把产品概念绑定到整树 JSON 和当前界面拓扑,也缺少 Project 与单列 Thread 之间的 Conversation 聚合边界。Issue #34 已要求规范化 Thread Chat;在继续设计数据库、命令接口和前端状态存储前,必须先建立唯一、稳定的领域语言与目标实体关系,否则后续变更会继续用同一个词描述不同事物。 + +## What Changes + +- **破坏性变更**:以 `Conversation` 取代 `Thread Tree` 作为完整可分叉对话的领域名称;旧名称只允许出现在迁移说明、遗留适配器或具体树形投影中。 +- **破坏性变更**:统一采用 `Project → Conversation → Thread`。每一列对话都是 Thread;Main Thread 与 Branch Thread 只是由关系推导的角色,不是两套实体,也不持久化 `kind` 或魔法 `id = "main"`。 +- 将 Fork 明确定义为服务端动作,并将 `ThreadFork` 定义为上游 Thread、来源 Message 与下游 Thread 之间的唯一来源关系;`children[]`、`Message.forks[]` 只能是派生索引。 +- 引入显式 `Turn` 作为用户编辑、助手重新生成和回复变体的归属边界;`Message` 与 `Generation` 必须具有稳定身份并严格属于同一 Thread/Turn。 +- 明确当前有效变体会决定后续提示词与 Fork 来源,因此是持久化领域选择;列、折叠、画布和面板等仍属于界面工作区状态。 +- 定义 Workspace、Project、Conversation、Thread、ThreadFork、Turn、Message、Generation、File、MemoryItem 与 ProjectInstruction 的规范术语、生命周期边界和依赖关系。 +- 明确领域层、应用层、持久化层、接口与传输层、客户端状态层和界面组件层的模块职责,禁止 React、HTTP 或 Drizzle 规则渗入纯领域层。 +- 建立从当前 `ThreadTreeState`、`branch_trees`、`branch_generations` 到目标模型的迁移地图,并把后续落地拆为规范化持久化、Generation 生命周期、命令接口、前端规范化状态存储、切换清理等独立变更。 +- 分支结论回流与收敛机制保持在 Issue #39,Project Memory、File、Instruction 的完整产品能力分别后续提案,均不扩大本变更的实现范围。 + +## Capabilities + +### New Capabilities + +- 无。本变更先修正已有 `domain` 能力规范,避免在统一术语前复制出第二套领域规范。 + +### Modified Capabilities + +- `domain`:用 Project、Conversation、Thread、ThreadFork、Turn、Message 与 Generation 的统一语言替换 Thread Tree 模型,并补充角色推导、身份归属、当前有效变体、Project 资产边界和模块依赖要求。 + +## Impact + +- 规范与文档:`openspec/specs/domain/spec.md`、所有仍把 `Thread Tree` 当作产品实体的活跃变更、架构说明和术语引用。 +- 领域代码:`lib/thread-chat/domain/` 中的类型、消息图、重新生成、选择器与遗留 `ThreadTreeState` 边界。 +- 服务端:后续将影响 `branch_trees`、`branch_generations`、Artifact、Feedback、Title 的归属、仓储和事务命令;本变更只建立目标契约与迁移边界,不执行数据库切换。 +- 前端:后续将影响整树状态存储、列与画布选择器、Message 操作与界面工作区状态;本变更不重写现有渲染、流式界面或编辑器。 +- 依赖:后续变更必须依次引用本变更的术语与架构决策记录;Issue #39 在基础模型稳定后再继续设计。 diff --git a/openspec/changes/define-conversation-domain-model/specs/domain/spec.md b/openspec/changes/define-conversation-domain-model/specs/domain/spec.md new file mode 100644 index 00000000..8234f64c --- /dev/null +++ b/openspec/changes/define-conversation-domain-model/specs/domain/spec.md @@ -0,0 +1,162 @@ +## MODIFIED Requirements + +### Requirement: 使用统一的核心术语 + +系统、OpenSpec、接口契约和项目文档 SHALL 使用以下术语: + +- **Workspace**:成员、租户与授权的最外层边界;它可以拥有多个 Project,不得与前端列布局或画布状态混称。 +- **Project**:长期目标、规则、记忆、文件与 Conversation 的上下文和资产边界。 +- **Conversation**:Project 中一整个可分叉对话的聚合,拥有稳定 ID、唯一根 Thread、标题与生命周期;它取代旧领域术语 Thread Tree。 +- **Thread**:Conversation 中一条可独立继续的对话上下文,也是界面中的一栏;所有根与非根对话列使用同一种 Thread 实体。 +- **Main Thread(主 Thread)**:Conversation 的 `rootThreadId` 指向的 Thread,是关系推导角色,不是独立实体类型。 +- **Branch Thread(分支 Thread)**:具有入向 ThreadFork 的非根 Thread,是相对于上游 Thread 的关系角色,不是 Branch 实体或另一套 Thread 类型。 +- **Fork**:从一个 Thread 的确定 Message 创建另一个 Thread 的服务端动作。 +- **ThreadFork**:记录上游 Thread、来源 Message 与下游 Thread 的唯一来源关系。 +- **Turn**:Thread 中一个逻辑交互位置,用于约束用户编辑、助手重新生成和回复变体的归属范围。 +- **Message**:属于一个 Thread 和 Turn、具有稳定 ID 的用户、助手或明确类型的上下文内容。 +- **Generation**:在一个 Thread/Turn 中生成或更新助手输出的一次模型执行尝试;它不是 Message 或 Thread。 +- **File**:Project 内具有独立生命周期并可跨 Conversation 复用的持久资产。 +- **MemoryItem**:Project 内具有来源、状态与版本的记忆条目,不是无边界增长的文本字段。 +- **ProjectInstruction**:Project 级、可版本化的指令;Generation 可以记录实际使用的版本。 +- **Artifact**:由 Message 或 Generation 产生的结构化成果;当其需要独立复用和生命周期时,SHALL 通过 Project 资产及来源记录表达,而不是只存在于消息内部。 +- **Title**:用于识别 Project、Conversation 或 Thread 的人类可读标签。 +- **UI Workspace(界面工作区)**:列、折叠、画布视口、临时选择和打开面板等客户端展示状态;它不是 Workspace 领域实体,也不是 Conversation 事实源。 + +`Thread Tree`、`MainThread` 和 `ForkedThread` 只允许出现在遗留迁移说明或实现适配器中,不得继续作为新产品能力、接口或领域实体的规范名称。 + +#### Scenario: 描述非根对话列 + +- **WHEN** 产品、接口或代码需要描述由 Fork 创建的非根对话列 +- **THEN** 将其描述为 Thread,并在需要强调关系时称为分支 Thread,同时使用 ThreadFork 描述来源关系 + +#### Scenario: 描述完整分叉对话 + +- **WHEN** 产品或接口需要表示一个根 Thread 及其全部派生 Thread 的整体 +- **THEN** 使用 Conversation,而不是 Thread Tree 或 Thread 表示该聚合 + +### Requirement: 明确标题的归属与优先级 + +系统 SHALL 明确 Title 归属 Project、Conversation 或 Thread。Conversation 的自动 Title SHALL 由其根 Thread 的内容派生并作为默认导航标题;根 Thread 列头 SHALL 使用 Conversation Title,避免另存一份同义标题。非根 Thread 可以拥有描述对应列的本地 Title。用户为 Conversation 设置的自定义 Title SHALL 在 Conversation 级导航和根 Thread 列头展示中优先于自动 Title,同时系统 SHALL 保留自动 Title 作为机器派生信息。 + +#### Scenario: Conversation 自动标题与用户重命名并存 + +- **WHEN** Conversation 已由根 Thread 生成自动 Title,且用户随后为该 Conversation 设置自定义 Title +- **THEN** Conversation 导航和根 Thread 列头展示用户自定义 Title,并保留自动 Title + +#### Scenario: 分支 Thread 使用独立标题 + +- **WHEN** 非根 Thread 生成或设置自己的 Title +- **THEN** 该 Title 只描述并标识该 Thread,不覆盖所属 Conversation 的 Title + +## ADDED Requirements + +### Requirement: 维护 Project、Conversation 与 Thread 的包含不变量 + +每个 Conversation SHALL 恰好属于一个 Project,并恰有一个属于自身的根 Thread。每个 Thread SHALL 恰好属于一个 Conversation;Thread 的归属创建后不得改变。根与非根角色 SHALL 由 Conversation 根引用及 ThreadFork 关系推导,系统不得使用持久化 `kind`、`root` 布尔值或具有业务含义的魔法 ID 作为第二事实源。 + +#### Scenario: 创建 Conversation + +- **WHEN** 系统在 Project 中创建 Conversation +- **THEN** 系统为 Conversation 和根 Thread 分配稳定且无业务含义的 ID,并使 `rootThreadId` 指向属于该 Conversation 的 Thread + +#### Scenario: 拒绝跨 Conversation 的 Thread 归属 + +- **WHEN** 一个操作试图把既有 Thread 重新归属到另一个 Conversation +- **THEN** 系统拒绝该操作且不改变两个 Conversation + +### Requirement: 使用 ThreadFork 维护唯一 Fork 来源 + +每个非根 Thread SHALL 恰有一个入向 ThreadFork;根 Thread SHALL 没有入向 ThreadFork。ThreadFork SHALL 指向同一 Conversation 中的上游 Thread、属于上游 Thread 的确定来源 Message,以及唯一的下游 Thread。Fork 来源不得同时以可写 `children`、Message 反向链接或下游来源字段重复保存;这些集合和计数只能是可重建投影。 + +#### Scenario: 从确定消息创建嵌套 Fork + +- **WHEN** 用户从任意 Thread 的确定 Message 创建新 Thread +- **THEN** 系统原子创建下游 Thread 与唯一 ThreadFork,并记录该 Message 所属 Thread 为上游 Thread + +#### Scenario: 拒绝来源消息错配 + +- **WHEN** ThreadFork 的来源 Message 不属于声明的上游 Thread,或任一参与实体不属于同一 Conversation +- **THEN** 系统拒绝该关系且不创建孤立的下游 Thread + +#### Scenario: 派生 Thread 数量来自投影 + +- **WHEN** 界面显示某条 Message 的直接派生 Thread 数量 +- **THEN** 系统从 ThreadFork 查询或派生该数量,而不是读取独立可写的 Message fork 列表 + +### Requirement: 用 Turn 约束 Message 变体 + +每个 Turn SHALL 恰好属于一个 Thread,每个 Message SHALL 恰好属于一个 Thread 和一个 Turn。用户编辑与助手重新生成产生的新 Message 变体 SHALL 留在目标 Thread/Turn 内,不得改变来源 Thread、祖先 Thread 或后代 Thread 的 Message。实现可以保留同一 Turn 内的 Message 因果边,但不得使用 Message 父子边表达 ThreadFork。 + +#### Scenario: 在分支 Thread 中重新生成 + +- **WHEN** 用户对分支 Thread 的助手 Message 执行重新生成 +- **THEN** 系统只在该 Message 所属 Thread/Turn 中追加助手回复变体,其他 Thread 的变体与派生数量保持不变 + +#### Scenario: 拒绝跨 Thread 变体 + +- **WHEN** 操作试图把另一个 Thread 的 Message 加入当前 Turn 的回复变体 +- **THEN** 系统拒绝该操作且不改变当前有效变体 + +### Requirement: 保证 Generation 的执行身份与归属 + +每个 Generation SHALL 具有稳定的执行尝试 ID,并恰好属于一个 Thread 和 Turn。其输入、输出及重新生成来源 Message SHALL 属于相同 Thread,且输入输出关系必须与该 Turn 一致。Generation ID SHALL 只承担执行幂等、状态、停止、恢复、用量与结算身份,不得替代 Thread、Turn 或 Message 的实体身份。 + +#### Scenario: Generation 完成到目标 Message + +- **WHEN** 一个 Generation 成功、停止或失败并产生可持久化结果 +- **THEN** 系统只更新该 Generation 绑定的 Thread/Turn/Message,并保留确定的执行尝试、状态和用量归属 + +#### Scenario: 拒绝跨 Thread Generation + +- **WHEN** Generation 声明的输入或输出 Message 不属于其 Thread +- **THEN** 系统在模型调用或结算前拒绝该 Generation + +### Requirement: 持久化影响后续上下文的当前有效变体 + +Thread/Turn 的当前有效变体会决定后续提示词、Fork 来源与继续生成路径,系统 SHALL 将其作为有版本的领域选择持久化,并将切换限制在同一 Thread/Turn。列可见性、折叠、画布视口、临时选择与面板状态 SHALL 保持在界面工作区,且不得改变实体归属或当前有效变体。 + +#### Scenario: 切换变体后继续对话 + +- **WHEN** 用户在一个 Turn 内切换当前有效助手回复变体并继续发送消息 +- **THEN** 后续 Generation 使用持久化后的有效路径,刷新后仍保持相同选择 + +#### Scenario: UI 布局不改变领域选择 + +- **WHEN** 用户折叠列、切换画布或关闭面板 +- **THEN** Conversation、Thread、Turn、Message 归属和当前有效变体均保持不变 + +### Requirement: 维护 Project 级上下文与资产边界 + +Conversation SHALL 通过 Project 获得可复用上下文和资产边界。ProjectInstruction、MemoryItem 与 File 在存在时 SHALL 归属 Project 并具有独立身份;Message 和 Generation SHALL 通过明确引用或来源记录使用它们,不得把其唯一事实仅嵌入某棵 Conversation JSON。一个 Thread 中产生的 File 在被保存为 Project 资产后 SHALL 可由同一 Project 的其他 Conversation 引用,而不需要复制文件实体。 + +#### Scenario: 跨 Conversation 使用 Project File + +- **WHEN** 一个 Thread 生成的文件被保存为 Project File,随后同一 Project 的另一个 Conversation 引用该文件 +- **THEN** 两个 Conversation 引用同一个稳定 File,并保留原始 Message/Generation 来源记录 + +#### Scenario: Generation 记录 Project Instruction 版本 + +- **WHEN** Project Instruction 在两次 Generation 之间发生修改 +- **THEN** 每次 Generation 都能识别自己实际使用的指令版本,旧回答不会被解释为使用了新版本 + +### Requirement: 分离领域事实、应用命令、传输、持久化与界面状态 + +领域事实 SHALL 独立于 React 组件、HTTP 路由、数据库对象关系映射与客户端布局存在。所有改变 Conversation、Thread、ThreadFork、Turn、Message、Generation 或当前有效变体的操作 SHALL 通过有明确输入、权限、幂等和冲突语义的应用命令执行。服务端可以返回组装后的只读快照,但客户端不得提交可覆盖整个 Conversation 事实状态的整树 JSON。 + +#### Scenario: 客户端执行 Fork + +- **WHEN** 客户端请求从确定 Message 创建 Thread +- **THEN** 服务端命令校验归属并原子提交规范实体增量,客户端只合并受影响实体 + +#### Scenario: 拒绝整 Conversation 覆盖 + +- **WHEN** 客户端提交包含全部 Thread、Message 与 Fork 的可写整树快照以覆盖服务端事实 +- **THEN** 系统拒绝或不提供该权威写入路径,并要求使用实体级命令 + +## REMOVED Requirements + +### Requirement: 维护线程树的层级不变量 + +**Reason**:该 Requirement 把完整对话命名为 Thread Tree,并以 MainThread/ForkedThread 表述两类节点,无法表达 Project → Conversation → Thread 聚合边界,也继续暴露当前整树实现形状。 + +**Migration**:使用新增的“维护 Project、Conversation 与 Thread 的包含不变量”和“使用 ThreadFork 维护唯一 Fork 来源”Requirements。现有 `ThreadTreeState`、MainThread 与 ForkedThread 引用迁移到 Conversation、Thread 角色及 ThreadFork;遗留名称仅可保留在明确标记的迁移适配器中。 diff --git a/openspec/changes/define-conversation-domain-model/tasks.md b/openspec/changes/define-conversation-domain-model/tasks.md new file mode 100644 index 00000000..9f6c3d76 --- /dev/null +++ b/openspec/changes/define-conversation-domain-model/tasks.md @@ -0,0 +1,37 @@ +## 1. 固化规范术语与依赖 + +- [ ] 1.1 将 `domain` 能力规范归档为 Project、Conversation、Thread、ThreadFork、Turn、Message、Generation、File、MemoryItem 与 ProjectInstruction 的唯一正式术语来源,并核对每个角色、身份和包含关系与增量规范一致。 +- [ ] 1.2 新增一份面向开发者的“遗留模型 → 规范模型”迁移表,覆盖 `ThreadTreeState`、`threads.main`、`parentId/children`、`forkFromMsgId/Message.forks`、`activeLeafMessageId`、Artifact 和 `branch_generations` 引用,明确哪些字段只允许存在于迁移适配器。 +- [ ] 1.3 审计 README、CLAUDE、活跃 OpenSpec 和 `lib/thread-chat` 注释中的领域术语;把新设计文档改用 Conversation、Thread、ThreadFork,并对必须保留的历史 `Thread Tree` 引用加上遗留标识,不改写已归档变更的历史事实。 +- [ ] 1.4 在 `persist-thread-chat-generations`、`add-thread-chat-message-actions`、`add-branch-tree-persistence` 及其他仍依赖整树权威模型的活跃变更中记录其与本变更及后续迁移变更的依赖或被替代范围,禁止继续声称整树 JSON 是最终架构。 + +## 2. 建立纯领域契约 + +- [ ] 2.1 在 `lib/thread-chat/domain/` 下建立不依赖 React、Next.js、HTTP 或 Drizzle 的规范 ID 与实体契约,定义 Project、Conversation、Thread、ThreadFork、Turn、Message 和 Generation 的稳定身份与归属字段。 +- [ ] 2.2 实现主 Thread 与分支 Thread 的角色推导,以及 Conversation/ThreadFork 拓扑校验;拒绝魔法 `main` 身份、重复入向 Fork、跨 Conversation Fork、来源 Message 与上游 Thread 错配和 Fork 环。 +- [ ] 2.3 实现 Turn/Message/Generation 归属校验,拒绝跨 Thread 回复变体、跨 Turn 输出和 Generation 输入输出错配,同时允许 Message 的内容与结构化片段保持为可版本化值对象。 +- [ ] 2.4 建立当前有效变体的领域选择契约和版本冲突结果,确保变体只能在同一 Thread/Turn 切换,并与可见列、折叠列、画布视口等界面工作区类型隔离。 +- [ ] 2.5 明确 Conversation/Thread Title 解析契约:Conversation 独占聚合导航标题,根 Thread 列头使用 Conversation Title,非根 Thread 可以使用本地 Title,避免重复持久化同义根标题。 + +## 3. 隔离遗留模型 + +- [ ] 3.1 将当前 `ThreadTreeState`、`Fork`、`threads.main` 和整树解析器明确移动或标记为遗留边界;新的规范领域模块不得通过类型别名把旧整树对象伪装成 Conversation。 +- [ ] 3.2 新增单向、只读的“遗留树 → 规范 Conversation 快照”映射,集中处理根 Thread、Fork、Turn 变体和 Artifact 来源推导;禁止提供“规范模型 → 遗留模型”的权威写回路径。 +- [ ] 3.3 为最小根 Conversation、主线 → A → B 嵌套 Fork、B 重新生成回复变体、非法跨 Thread Generation 和重复 Fork 来源建立确定性固定样例及验证脚本,证明规范投影不会把 B 的回复变体投影到 A。 +- [ ] 3.4 为旧字段建立允许列表或等价审计检查,使新增领域层和应用层代码中的 `ThreadTreeState`、`threads.main`、可写 `children` 与 `Message.forks` 引用能够在代码审查和持续集成中被发现。 + +## 4. 准备小步迁移链 + +- [ ] 4.1 为 `normalize-conversation-persistence` 建立后续变更输入清单:逻辑表、复合外键、不变量、真实数据审计、重置或迁移决策、规范快照和回滚边界。 +- [ ] 4.2 为 `migrate-generation-lifecycle` 建立后续变更输入清单:真实 Thread/Turn/Message 外键、启动、停止、心跳、过期恢复、部分结果、用量不可得、计费幂等以及 PR #30 剩余语义缺口。 +- [ ] 4.3 为 `add-conversation-command-api` 建立后续变更输入清单:Fork、创建 Turn、重新生成、选择变体、归档、停止等命令,以及所有者校验、幂等、预期版本、错误码、实体增量与事务事件箱。 +- [ ] 4.4 为 `normalize-conversation-client-state` 建立后续变更输入清单:规范实体、可重建索引、按 ID 定位的选择器、界面工作区、乐观命令和列、画布、Message 操作迁移。 +- [ ] 4.5 为 `retire-thread-tree-authority` 建立后续变更输入清单:数据审计、运行中 Generation 排空、禁用整树 PUT、删除协调合并与防抖保存、删除重复 Fork 事实和遗留适配器的完成条件。 +- [ ] 4.6 将 Project Memory、File、Instruction、公开接口、令牌、命令行、MCP 和 Issue #39 Conversation 收敛列为基础迁移后的独立提案,明确它们不阻塞 #34 当前链路。 + +## 5. 验证与交接 + +- [ ] 5.1 运行 `pnpm typecheck`,修复规范领域契约、遗留适配器和固定样例的全部类型错误。 +- [ ] 5.2 运行 `pnpm openspec:validate`,确保领域增量规范、依赖说明和所有活跃变更严格验证通过。 +- [ ] 5.3 检查 Git 差异和术语审计结果,确认未修改无关功能、未把界面工作区混入领域实体、未产生第二个 Fork 事实源,也未把 Issue #39 的收敛机制带入本变更。 +- [ ] 5.4 在 Issue #34 回填本变更路径、最终架构决策记录和后续变更依赖链,确保实现者可以从本提案继续而不重新猜测核心数据模型。 diff --git a/openspec/changes/migrate-generation-lifecycle/.openspec.yaml b/openspec/changes/migrate-generation-lifecycle/.openspec.yaml new file mode 100644 index 00000000..d160e09c --- /dev/null +++ b/openspec/changes/migrate-generation-lifecycle/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-21 diff --git a/openspec/changes/migrate-generation-lifecycle/design.md b/openspec/changes/migrate-generation-lifecycle/design.md new file mode 100644 index 00000000..85ef6295 --- /dev/null +++ b/openspec/changes/migrate-generation-lifecycle/design.md @@ -0,0 +1,157 @@ +## Context + +规范 Conversation 持久化提供了真实 Thread、Turn 与 Message,但现有 Generation 仍通过 `branch_generations` 协调整树 JSON、浏览器保存和流式响应。该结构把“执行是否结束”“内容是否可用”“usage 是否完整”“账单是否已结算”混成一组隐式分支,导致 Stop、崩溃恢复和并发终结无法用数据库约束证明正确。 + +本设计位于规范持久化与命令 API 之间:先建立可独立验证的应用服务、仓储和执行端口,不在本阶段暴露新的公开路由或迁移客户端。 + +## Goals / Non-Goals + +**目标:** + +- 让 Generation 通过真实外键绑定 Thread、Turn 和 Message。 +- 让服务端在浏览器断线后继续执行并留下可恢复 checkpoint。 +- 精确定义 Stop、失败、superseded、僵尸任务收敛和 exactly-once 终结。 +- 分离 Generation 状态、Message 内容状态、usage 完整度与计费状态。 +- 修复 PR #30 审查中已发现的部分 usage、partial 覆盖、联网活动和 Stop 状态漂移。 + +**非目标:** + +- 设计公共 HTTP 命令、错误码或客户端 Store。 +- 正式切换生产流量或删除 `branch_generations`。 +- 引入跨 Conversation Agent 工作流、任务队列产品或 Project 资产模型。 +- 把每个流片段、token 或工具事件永久保存为独立关系表。 + +## Decisions + +### D1. Generation 是执行实体,不是 Message 状态字段 + +新增 `conversation_generations`,核心字段为: + +```text +id +owner_id / workspace_id / project_id +conversation_id / thread_id / turn_id +input_message_id / output_message_id +intent / attempt / idempotency_key +status / content_state +checkpoint_version / checkpoint +usage / usage_completeness / billing_status +lease_owner / heartbeat_at / stop_requested_at +started_at / finished_at / created_at / updated_at +``` + +所有关联使用规范表的复合外键,确保同一 Conversation/Thread/Turn。一个 Message 可以保留多个历史 Generation 尝试,但重新生成默认创建新的输出 Message 变体,不覆盖旧身份。 + +备选方案是继续把 Generation 嵌入 Message 或整树 JSON;这会使执行租约、重试历史和计费幂等无法独立约束,因此拒绝。 + +### D2. 四组状态正交建模 + +```text +Generation.status: + running | stop_requested | completed | stopped | failed | superseded + +Message.contentState: + pending | streaming | complete | incomplete | failed + +usageCompleteness: + complete | partial | unavailable + +billingStatus: + pending | settled | usage_unavailable | not_billable +``` + +`completed` 通常对应 `complete`,但 `stopped`/`failed` 可以对应 `incomplete`;`settled` 只允许搭配 `complete` usage。数据库 CHECK 和应用状态机同时执行这些组合约束。 + +备选方案是从 Generation 终态推导其余状态;这正是当前无法表达“失败但有可用部分内容”和“有部分 usage 但不可完整结算”的根因,因此拒绝。 + +### D3. 开始事务先于任何付费调用 + +应用服务 `startGeneration` 在单个事务中: + +1. 验证所有者、实体归属、意图和相关 Conversation/Thread/Turn 的预期 revision; +2. 追加或确认输出 Message; +3. 创建带幂等键、`running` 状态和初始 checkpoint 的 Generation; +4. 提交后才把执行描述交给模型运行器。 + +相同幂等键和规范化请求摘要返回原 Generation;键相同但摘要不同返回幂等冲突。事务失败没有付费副作用。 + +### D4. 服务端执行所有权与 HTTP 响应解耦 + +执行器独立消费完整模型流。HTTP/SSE 只订阅或转发进度,不把 `request.signal` 作为模型执行的终止信号。进程内 AbortController 注册表只提供低延迟 Stop;持久化 `stop_requested`、心跳和租约支持跨实例观察与恢复。 + +沿用当前经过验证的短周期 Stop 观察和心跳思路,但具体间隔作为运行配置与测试参数,不写死进领域契约。浏览器轮询频率属于后续客户端 change。 + +### D5. checkpoint 是服务端可恢复事实 + +执行器按节流策略把以下规范投影保存到 JSON checkpoint: + +```text +schemaVersion +body +artifactIds +researchPlan +researchActivities[{ id, kind, status, sources, error }] +contentState +knownUsage +``` + +每次更新使用 `checkpoint_version` 比较交换。浏览器收到的流片段可以领先最后一次 checkpoint,但浏览器整树 PUT 不再是恢复权威。工具活动只有在获得输出或错误时才进入 `complete`/`error`;输入阶段保持 `running`。 + +备选方案是保存每个 token 事件;成本和写放大远高于恢复收益,因此使用有版本快照 checkpoint,并允许终结时强制刷新。 + +### D6. Stop 和僵尸任务共用同一终结器 + +Stop 首先将 `running` 条件更新为 `stop_requested`;注册表通知只是加速。执行器观察取消后把最后 checkpoint 交给统一 `finalizeGeneration`。 + +僵尸收敛器按 `status + heartbeat_at + lease` 领取失联任务,并直接读取最新 Generation checkpoint 与输出 Message,不再使用开始时的空 `turnSnapshot` 重建。终结条件更新包含当前版本,健康执行器若已推进或完成,收敛写入自然失败。 + +### D7. usage 完整度决定能否结算 + +终结输入同时携带 `knownUsage` 与 `usageCompleteness`。规则为: + +```text +complete -> 可幂等写 usage_records,billing = settled +partial -> 保留已知值,billing = usage_unavailable +unavailable -> 不伪造零值,billing = usage_unavailable +无付费调用 -> billing = not_billable +``` + +`usage_records.app_generation_id` 保持唯一。若业务需要对 partial usage 先扣已知部分,必须另立计费 ADR;本 change 不把不完整账单称为已结算。 + +### D8. 终结是唯一原子提交边界 + +`finalizeGeneration` 在一个事务中: + +- 以 Generation 状态、租约和版本做比较交换; +- 刷新最终 checkpoint 与 Message 内容状态; +- 仅在仍为当前尝试时更新 Turn 的有效助手 Message; +- 写入或确认唯一 usage 记录与计费状态; +- 记录终态、错误分类和完成时间。 + +较旧尝试晚到时进入 `superseded` 或发现已有终态后幂等返回,不得覆盖当前变体。 + +### D9. 新生命周期默认关闭 + +本阶段通过显式服务端开关或隔离应用组合根启用,只允许集成测试和后续命令 API 使用。遗留路由继续走旧权威路径;禁止把同一次 Generation 同时写入旧 `branch_generations` 和新表。 + +## Risks / Trade-offs + +- **[风险] checkpoint 节流意味着进程硬崩溃时可能丢失最后少量 token。** → 终结前强制刷新,并让节流周期可配置;契约保证不丢已确认 checkpoint,而非承诺逐 token durability。 +- **[风险] 状态维度增加实现复杂度。** → 用类型、数据库 CHECK、集中状态机和表驱动测试限制合法组合,换取语义真实性。 +- **[风险] 服务端继续执行可能占用无消费者资源。** → 使用租约、心跳、超时和持久化 Stop;资源策略不依赖浏览器连接。 +- **[风险] 新旧 Generation 并存期间难以诊断。** → 运行模式互斥、日志带 authority/version 标签,不做隐式双写。 +- **[风险] partial usage 暂不结算可能产生待对账余额。** → 明确 `usage_unavailable` 并保留已知 usage,后续计费策略可以安全重放。 + +## Migration Plan + +1. 增加规范 Generation 表、枚举/约束、复合外键和 usage 唯一关联。 +2. 实现开始、checkpoint、Stop、心跳、stale claim 与统一终结应用服务。 +3. 用模拟执行器验证断线、并发 Stop、崩溃、late result 和 usage 组合。 +4. 在隔离模式将规范生命周期接到测试 Conversation,不承载现有生产路由。 +5. 由 `add-conversation-command-api` 暴露命令,由最终切换 change 决定正式启用和旧表退场。 + +回滚时关闭规范生命周期并删除未承载生产权威的新代码/表;不得在同一运行模式回退为双写。 + +## Open Questions + +无。本 change 不决定 checkpoint 的最终节流数值,也不决定 partial usage 的未来补扣策略;两者分别是运行参数与独立计费决策,不阻塞领域契约。 diff --git a/openspec/changes/migrate-generation-lifecycle/proposal.md b/openspec/changes/migrate-generation-lifecycle/proposal.md new file mode 100644 index 00000000..3e22ea26 --- /dev/null +++ b/openspec/changes/migrate-generation-lifecycle/proposal.md @@ -0,0 +1,32 @@ +## Why + +`normalize-conversation-persistence` 将 Thread、Turn 与 Message 变成可由数据库约束的真实实体,但当前 `branch_generations` 仍把 Generation 作为整树 JSON 的辅助记录运行。Stop、断线续跑、僵尸任务收敛、部分内容、联网活动和计费完整度因此分散在多条路径中,已经出现“部分 usage 被当作完整结算”“失败恢复覆盖已保存内容”等语义错误。必须先把 Generation 收回规范领域模型,后续命令 API 和客户端迁移才能依赖一套可证明的生命周期。 + +## What Changes + +- 新增以真实 Conversation、Thread、Turn、输入 Message 与输出 Message 外键为身份的规范 Generation 记录,明确请求意图、尝试次数、当前变体和幂等键。 +- 定义 `running → stop_requested → stopped`、`running → completed/failed` 与 `superseded` 的合法转换;Generation 必须在任何付费模型调用前以事务方式开始。 +- 将流式执行改为服务端拥有,浏览器断线不得取消后台消费;正文、Artifact、研究活动和 usage 通过有版本的服务端 checkpoint 持久化。 +- 把 Message 内容状态与 Generation 终态分离:有部分输出的 Stop/失败可保存为 `incomplete`,不得统一伪装成 `error` 或清空已有 checkpoint。 +- 明确 usage 完整度与计费状态是两个正交维度;只知道部分 step usage 时必须保留已知值并标记 `usage_unavailable`,不得结算为完整账单。 +- 保留现有服务端 AbortController 注册表、数据库 Stop 观察、心跳和幂等扣费中正确的机制,但以规范实体和单一终结事务重新实现。 +- 本变更不开放新的公共 Conversation HTTP 命令,也不切换生产客户端;规范生命周期默认关闭,供下一步 `add-conversation-command-api` 接入。 + +## Capabilities + +### New Capabilities + +- `conversation-generation-lifecycle`:定义规范 Generation 的身份、状态机、服务端执行、部分 checkpoint、Stop/恢复与计费真实性。 + +### Modified Capabilities + +- 无。遗留 `persist-thread-chat-generations` 的最终替换和归档由切换 change 处理,本变更先建立新的独立契约。 + +## Impact + +- 数据库:新增规范 Generation 表、checkpoint/usage 字段、状态约束、索引及 `usage_records` 的幂等关联。 +- 服务端:聊天流生命周期、Generation 仓储、Stop 注册表、心跳、僵尸任务收敛、结果投影和终结事务。 +- 客户端语义:后续可按 Generation 和 Message 内容状态恢复,但本变更不迁移现有 Store 或路由。 +- 测试:增加状态转换、部分输出、联网活动、断线续跑、Stop、stale recovery、usage 完整度与 exactly-once 计费覆盖。 +- 前置依赖:`define-conversation-domain-model`、`normalize-conversation-persistence`。 +- 后续依赖:`add-conversation-command-api`、`normalize-conversation-client-state`、`retire-thread-tree-authority`。 diff --git a/openspec/changes/migrate-generation-lifecycle/specs/conversation-generation-lifecycle/spec.md b/openspec/changes/migrate-generation-lifecycle/specs/conversation-generation-lifecycle/spec.md new file mode 100644 index 00000000..fa1ecc82 --- /dev/null +++ b/openspec/changes/migrate-generation-lifecycle/specs/conversation-generation-lifecycle/spec.md @@ -0,0 +1,146 @@ +## Purpose + +让每次 AI Generation 都绑定规范 Thread/Turn/Message,并以可恢复、可停止、可审计和可正确计费的服务端生命周期保存完整或部分结果。 + +## ADDED Requirements + +### Requirement: Generation 使用规范实体身份 + +系统 SHALL 为每次 AI 尝试创建稳定 Generation ID,并使其同时引用同一 Conversation 中的 Thread、Turn、输入 Message 和输出 Message。Generation SHALL 保存请求意图、尝试序号和幂等键;跨 Conversation、跨 Thread、跨 Turn 或角色不匹配的引用 MUST 被拒绝。 + +#### Scenario: 为重新生成创建同 Turn 新变体 + +- **WHEN** 用户对既有 Turn 请求重新生成助手回答 +- **THEN** 系统创建新的助手 Message 与 Generation,二者属于原 Turn,且不会复用或覆盖旧助手 Message 的身份 + +#### Scenario: 拒绝跨 Thread 输出 Message + +- **WHEN** Generation 尝试把另一条 Thread 的 Message 设为输出 +- **THEN** 系统拒绝开始该 Generation,且不调用付费模型 + +### Requirement: 在付费执行前幂等开始 Generation + +系统 SHALL 在任何付费模型调用前,通过单个事务验证相关聚合或实体的预期 revision、追加或确认目标 Message、创建 Generation 并提交幂等键。重复提交相同幂等键和等价载荷 SHALL 返回同一 Generation;相同键但不同载荷 MUST 被拒绝。 + +#### Scenario: 开始成功后调用模型 + +- **WHEN** 开始事务提交成功 +- **THEN** 执行器可以调用模型,且查询方已能读取状态为 `running` 的 Generation + +#### Scenario: 开始事务失败 + +- **WHEN** 归属、版本或约束验证导致开始事务回滚 +- **THEN** 系统不调用模型、不产生扣费,也不留下孤立 Message 或 Generation + +#### Scenario: 重放相同开始命令 + +- **WHEN** 客户端因超时重放相同幂等键和载荷 +- **THEN** 系统返回已有 Generation,不创建第二次模型执行 + +### Requirement: Generation 由服务端持续执行 + +Generation 开始后 SHALL 由服务端拥有流消费、心跳和终结责任。浏览器断线、页面卸载或单个响应流关闭不得自动中止模型执行;只有持久化 Stop 请求、执行错误、租约收敛或正常完成可以改变执行结果。 + +#### Scenario: 浏览器在流式响应中断线 + +- **WHEN** 响应消费者断开但服务端执行仍健康 +- **THEN** 服务端继续消费模型流、更新心跳和 checkpoint,直至合法终态 + +#### Scenario: 新页面查询进行中任务 + +- **WHEN** 用户刷新后查询仍为 `running` 或 `stop_requested` 的 Generation +- **THEN** 系统返回最新服务端 checkpoint 和状态,而不是要求原浏览器恢复执行所有权 + +### Requirement: 持久化有版本的部分 checkpoint + +系统 SHALL 以单调版本保存正文、Artifact 引用、研究计划、联网活动、来源和内容状态的服务端 checkpoint。checkpoint 更新 MUST 只接受更新版本,且不得把已持久化的非空内容替换为较旧或缺失的投影。 + +#### Scenario: 保存流式部分结果 + +- **WHEN** 执行器获得新的正文片段、Artifact 或研究活动状态 +- **THEN** 系统以更高 checkpoint 版本保存规范结构,并使后续查询可恢复相同部分结果 + +#### Scenario: 拒绝旧 checkpoint 回写 + +- **WHEN** 延迟任务提交低于当前版本的 checkpoint +- **THEN** 系统忽略或拒绝该写入,保留较新的 Message 内容 + +#### Scenario: 保留未完成联网活动 + +- **WHEN** Stop 或失败发生时某项搜索或深读仍处于输入/执行阶段 +- **THEN** checkpoint 保留该活动为 `running` 或明确中止状态,不得无条件投影为 `complete` + +### Requirement: 分离 Generation 终态与 Message 内容状态 + +Generation SHALL 使用 `completed`、`stopped`、`failed` 或 `superseded` 表达执行终态;Message SHALL 独立使用 `complete`、`incomplete` 或 `failed` 表达内容可用性。有可恢复正文、Artifact 或研究结果的 Stop/失败 MUST 保留内容并标记 `incomplete`,不得仅因 Generation 未完成而清空或统一标记 Message 为失败。 + +#### Scenario: Stop 后保留部分回答 + +- **WHEN** Generation 在已有部分输出后进入 `stopped` +- **THEN** 输出 Message 保留最后 checkpoint、标记为 `incomplete`,Generation 保持 `stopped` + +#### Scenario: 无输出执行失败 + +- **WHEN** Generation 在没有任何可恢复输出时失败 +- **THEN** Generation 标记为 `failed`,输出 Message 可以标记为 `failed` 并保存稳定错误分类 + +### Requirement: Stop 使用持久化请求和幂等状态转换 + +Stop SHALL 先以事务方式记录 `stop_requested`,再通过进程内控制器加速取消;数据库状态是跨实例的持久化事实源。重复 Stop SHALL 幂等返回当前状态;已终结 Generation 不得被改写为另一终态。 + +#### Scenario: 停止运行中的 Generation + +- **WHEN** 所有者对 `running` Generation 发出 Stop +- **THEN** 系统持久化 `stop_requested`,执行实例观察后停止消费,并以最后 checkpoint 终结为 `stopped` + +#### Scenario: 重复停止已终结 Generation + +- **WHEN** 所有者再次停止 `completed`、`stopped`、`failed` 或 `superseded` Generation +- **THEN** 系统返回其现有终态且不修改结果、checkpoint 或计费记录 + +### Requirement: 僵尸任务按最后 checkpoint 收敛 + +系统 SHALL 依据租约和心跳识别失联的非终态 Generation,并以最后一个服务端 checkpoint 收敛为失败或停止结果。收敛过程 MUST 使用版本/租约条件写入,不得覆盖已由健康执行器提交的更新,也不得从开始时的空快照重建并覆盖部分内容。 + +#### Scenario: 服务进程在部分输出后崩溃 + +- **WHEN** Generation 心跳超时且已有持久化 checkpoint +- **THEN** 收敛器保留该 checkpoint,将 Generation 终结为 `failed`,并将 Message 标记为 `incomplete` + +#### Scenario: 健康执行器抢先完成 + +- **WHEN** 收敛器准备写入时 Generation 已以更新版本进入终态 +- **THEN** 条件更新失败,收敛器不得覆盖最终结果 + +### Requirement: usage 完整度与计费状态保持真实 + +系统 SHALL 分别保存已知 usage 值、`complete`/`partial`/`unavailable` 完整度和 `pending`/`settled`/`usage_unavailable`/`not_billable` 计费状态。只有完整 usage 才能进入 `settled`;已知部分 step usage 与未知中止 step 同时存在时,系统 MUST 保留已知值并标记 `partial` 和 `usage_unavailable`。 + +#### Scenario: 正常完成且 usage 完整 + +- **WHEN** 所有计费 step 都返回 usage 且 Generation 正常终结 +- **THEN** 系统以 `complete` 完整度幂等写入唯一 usage 记录,并将计费状态设为 `settled` + +#### Scenario: Stop 时只有部分 usage + +- **WHEN** 已完成 step 有 usage,但当前被中止 step 无法取得 usage +- **THEN** 系统保存已知 usage、标记完整度为 `partial`,并将计费状态设为 `usage_unavailable` + +#### Scenario: 重复终结不重复扣费 + +- **WHEN** 响应消费、后台收敛或重试并发终结同一 Generation +- **THEN** Generation ID 的唯一约束只允许一次 usage 入账和一次终态提交 + +### Requirement: 终结事务原子提交规范结果 + +Generation 终结 SHALL 在单个事务中条件更新 Generation 终态、输出 Message 的最终 checkpoint/内容状态、Turn 当前有效助手变体和计费记录。仅当前且尚未终结的 Generation 可以推进当前变体;已被更新尝试取代的执行 SHALL 终结为 `superseded`,不得覆盖新的 Message 选择。 + +#### Scenario: 当前 Generation 正常完成 + +- **WHEN** 当前 Generation 以匹配版本提交完整结果 +- **THEN** 一个事务保存终态、完整 Message、Turn 当前助手变体和唯一计费记录 + +#### Scenario: 旧执行晚到 + +- **WHEN** 较早 Generation 在同一 Turn 的新尝试成为当前后才返回 +- **THEN** 旧执行被标记或保持为 `superseded`,且不得覆盖新变体或重复扣费 diff --git a/openspec/changes/migrate-generation-lifecycle/tasks.md b/openspec/changes/migrate-generation-lifecycle/tasks.md new file mode 100644 index 00000000..570d5b4b --- /dev/null +++ b/openspec/changes/migrate-generation-lifecycle/tasks.md @@ -0,0 +1,39 @@ +## 1. Generation 数据结构 + +- [ ] 1.1 定义 `conversation_generations` 的身份、规范实体复合外键、意图、尝试、幂等键、状态、租约、checkpoint、usage 与时间字段。 +- [ ] 1.2 增加合法状态组合、唯一幂等键、当前尝试和 `usage_records.app_generation_id` 的约束与索引。 +- [ ] 1.3 生成数据库迁移,并用集成用例验证跨 Conversation/Thread/Turn/Message 引用和非法状态组合会被拒绝。 + +## 2. 开始与执行所有权 + +- [ ] 2.1 建立纯应用层 Generation 仓储和执行器端口,不依赖 HTTP、React 或遗留整树类型。 +- [ ] 2.2 实现 `startGeneration` 事务,覆盖预期版本、输出 Message 追加、幂等重放、载荷冲突和提交前禁止模型调用。 +- [ ] 2.3 实现与 HTTP 连接解耦的服务端流消费、AbortController 注册、持久化 Stop 观察、租约和心跳。 +- [ ] 2.4 增加浏览器断线后继续执行以及重新查询可见进行中 Generation 的集成验证。 + +## 3. Checkpoint 与结构化部分结果 + +- [ ] 3.1 定义带 `schemaVersion` 的 checkpoint 解析器,覆盖正文、Artifact、研究计划、联网活动、来源、内容状态和已知 usage。 +- [ ] 3.2 实现按版本比较交换的节流 checkpoint 写入与终结前强制刷新,拒绝旧版本覆盖。 +- [ ] 3.3 修正联网活动投影,使输入/执行阶段保持 `running`,仅输出或错误后进入相应终态。 +- [ ] 3.4 覆盖正文、Artifact、研究活动混合 partial 的保存、刷新恢复和旧 checkpoint 竞态测试。 + +## 4. Stop、失败与僵尸任务收敛 + +- [ ] 4.1 实现幂等 Stop 应用操作:先持久化 `stop_requested`,再通知本地执行器,终态请求只返回既有结果。 +- [ ] 4.2 实现统一 `finalizeGeneration` 事务,原子提交 Generation、Message 内容状态、Turn 当前变体和计费结果。 +- [ ] 4.3 实现基于租约/心跳和条件更新的 stale claim,始终从最新服务端 checkpoint 收敛,不再用开始快照覆盖 partial。 +- [ ] 4.4 覆盖有输出 Stop、无输出失败、进程崩溃、健康执行器抢先完成、旧尝试晚到和重复终结测试。 + +## 5. Usage 与计费真实性 + +- [ ] 5.1 实现 `complete`、`partial`、`unavailable` usage 完整度的聚合和持久化。 +- [ ] 5.2 修改结算规则:仅完整 usage 可 `settled`;partial/unavailable 必须 `usage_unavailable`,无付费调用为 `not_billable`。 +- [ ] 5.3 覆盖“已完成 step 有 usage、当前中止 step 无 usage”的回归用例,并验证保留已知值但不伪装完整结算。 +- [ ] 5.4 覆盖并发响应消费、Stop 和 stale 收敛只产生一次 usage 记录及一次扣费的事务测试。 + +## 6. 隔离接入与验证 + +- [ ] 6.1 增加默认关闭的规范 Generation 组合根,明确拒绝同一次执行双写新旧 Generation 权威。 +- [ ] 6.2 在隔离 Conversation 路径跑通开始、流式 checkpoint、查询、Stop、完成、失败和恢复端到端测试。 +- [ ] 6.3 运行数据库迁移、相关测试、`pnpm typecheck` 与 `pnpm openspec:validate` 并记录结果。 diff --git a/openspec/changes/normalize-conversation-client-state/.openspec.yaml b/openspec/changes/normalize-conversation-client-state/.openspec.yaml new file mode 100644 index 00000000..d160e09c --- /dev/null +++ b/openspec/changes/normalize-conversation-client-state/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-21 diff --git a/openspec/changes/normalize-conversation-client-state/design.md b/openspec/changes/normalize-conversation-client-state/design.md new file mode 100644 index 00000000..380ff579 --- /dev/null +++ b/openspec/changes/normalize-conversation-client-state/design.md @@ -0,0 +1,182 @@ +## Context + +当前外部 Store 保持一份对象身份稳定、原地修改的 `ThreadTreeState`,每次 mutation 递增全局 version 并通知全部订阅者。Fork 会同时写下游 `parentId`、上游 `children`、来源 Message `forks`、depth 和 footnote;发送和重新生成写 Message 图及 `activeLeafMessageId`。网络层再对整树做 debounce PUT、卸载 flush 和启动 reconcile。 + +新的服务端 API 已把这些关系收敛为规范实体和原子命令。客户端必须同步改变状态形状;否则它仍会在本地维护第二套领域模型。 + +## Goals / Non-Goals + +**目标:** + +- 用 ID map 和服务端版本保存规范实体,避免嵌套副本和全树 mutation。 +- 把派生拓扑、UI Workspace、pending command 和规范实体分层。 +- 用统一网关与 Generation 协调器处理命令、delta、订阅和冲突。 +- 逐组件迁移且保持现有聊天、分叉、画布和 Message actions 行为。 + +**非目标:** + +- 重做视觉设计、Markdown 渲染、编辑器或 React Flow。 +- 在客户端实现离线优先、多用户协同 CRDT 或跨 Conversation 收敛。 +- 让浏览器成为 Generation durability 或关系修复的权威。 +- 在本 change 删除服务端旧表/旧路由;最终 cutover change 负责删除。 + +## Decisions + +### D1. Store 分为四个正交区域 + +```text +canonical + conversationsById + threadsById + threadForksById + turnsById + messagesById + generationsById + revisions/checkpointVersions + +derived + threadIdsByConversation + incomingForkByThread + outgoingForkIdsByThread + turnIdsByThread + messageIdsByTurn + activePath / canvas edges / counts + +uiWorkspace + visibleThreadIds / foldedThreadIds / selectedThreadId + column sizes/policy / canvas viewport + panels / drafts / local hints + +commands + pendingByCommandId / optimisticOverlays / errors +``` + +`canonical` 只接受服务端数据。`derived` 可以按需 memoize 并全部重建。`uiWorkspace` 可以按 Conversation ID 存 localStorage,但不能进入 API 写载荷。`commands` 只描述尚未确认的交互。 + +备选方案是继续保留嵌套树并增加 adapter;这会使 adapter 本身成为第二关系权威,因此拒绝。 + +### D2. 实体更新使用对象替换和细粒度订阅 + +保留零框架依赖的外部 Store 与 React `useSyncExternalStore` 边界,但不再用单一全树 version 触发所有组件。Store 为 `entityType:id`、Conversation 索引和 UI slice 维护订阅 key;更新实体时替换该对象并只通知受影响 key。 + +Selector 返回只读视图或稳定 memoized 结果。开发模式冻结规范实体,以尽早发现组件原地修改。 + +备选方案是立即引入另一套状态库;当前问题是领域形状而不是库能力,引入依赖不会自动消除重复事实,因此不作为前置条件。 + +### D3. 快照安装是事务式 Store commit + +boot 流程先在临时结构中: + +1. 解析传输 schemaVersion; +2. 按 ID 去重并验证复合归属; +3. 验证根 Thread、ThreadFork、Turn/Message 与有效变体引用; +4. 构建基础索引; +5. 一次 commit 替换目标 Conversation 的旧实体集合。 + +失败时保留上一次可用 Store 或展示明确加载错误,不创建空树并静默覆盖远端数据。 + +### D4. delta 合并使用每实体 revision + +命令响应和查询都转换为统一 `CanonicalDelta`: + +```text +schemaVersion +upsert[type][id] +remove[type][id] = tombstoneRevision +revisions +invalidate[] +``` + +Store 逐实体比较 revision;Generation/Message 流 checkpoint 额外比较 checkpointVersion。重复和旧 delta 幂等跳过;检测到未知 schema 或不可解释版本间隙时,把 Conversation 标为 stale 并重取快照。 + +### D5. client gateway 拥有命令协议 + +组件调用类型化意图,例如 `forkThread(input)`、`sendTurn(input)`,gateway 负责: + +- 读取最小作用域 revision; +- 创建/复用 Idempotency-Key; +- 发请求、解析安全错误和 delta; +- 在组件卸载后仍把已提交结果送入共享 Store; +- 对网络不确定结果用同一键重查/重试; +- 对 409 合并最新 revision 或重取,不盲目生成新键重放。 + +旧 `chat-controller.persistNow()` 屏障不进入新 gateway。 + +### D6. 乐观 UI 使用 overlay,不创建规范实体 + +composer 可立即清空/显示 pending bubble,Fork 可显示 pending column shell,但 overlay 使用 `commandId` 和临时 presentation key,不能被 selector 当作真实 Thread/Message,也不能成为下一条领域命令的目标。 + +成功 delta 用服务端稳定 ID 替换 overlay;确定失败回滚并恢复草稿;网络不确定则保持“确认中”,用原幂等键查询。这样既保留响应感,也不在浏览器预演数据库关系。 + +### D7. GenerationCoordinator 按 ID 引用计数 + +每个 loaded Conversation 有一个协调器注册表: + +```text +generationId -> { + subscribers + latestCheckpointVersion + stream/poll handle + visibility mode +} +``` + +同一 Generation 的多个组件共享网络监控。可见运行任务保持较快轮询(初始保持现有约 2 秒行为),页面隐藏时降频(初始约 10 秒);终态停止轮询。SSE 与查询统一转换为 delta,乱序由 checkpointVersion 拒绝。最后订阅释放网络资源,但不会调用 Stop;重新挂载先查询恢复。 + +运行频率是可测配置,不进入领域模型。 + +### D8. 由 ThreadFork 和 Turn 生成列/画布读取模型 + +选择器集中实现: + +- 根 Thread 来自 `Conversation.rootThreadId`; +- parent/children 来自 ThreadFork; +- 深度和画布边通过无环遍历派生; +- Thread 的继承上下文截至 `sourceMessageId`,本地内容来自其 Turns; +- 当前 Message 来自 Turn active variant; +- Fork 数量由 outgoing ThreadFork 计数。 + +组件不再读取 `threads.main`、`parentId`、`children`、Message `forks` 或 `activeLeafMessageId`。 + +### D9. UI Workspace 只保存展示身份 + +现有列放置、替换/细条策略、画布焦点和 panel 状态可以保留算法,但输入改为 Thread ID 与派生 selector。localStorage payload 带 UI schemaVersion 和 Conversation ID;读取时过滤已归档/不存在 Thread。它不能恢复或修改规范关系。 + +### D10. 按竖向能力切片迁移组件 + +迁移顺序为: + +1. 新 Store、snapshot/delta 和 selector 测试; +2. boot/list/title 和 UI Workspace; +3. 只读列、Message、Artifact、画布; +4. Generation 展示与 coordinator; +5. composer/send/stop/regenerate/select variant; +6. selection Fork、反馈和其余 Message actions。 + +每个切片在隔离规范路由下可端到端运行。避免先写一个巨型兼容 adapter 让所有旧组件继续变异整树。 + +### D11. 客户端路径按 Conversation 互斥 + +路由/feature flag 为一次页面会话选择 legacy 或 canonical boot。canonical 路径不会实例化旧 Tree Store、persistence debounce 或 reconcile;legacy 路径不调用新命令。禁止同一 Conversation 页面同时维护两个 Store 并互相同步。 + +## Risks / Trade-offs + +- **[风险] 大量组件依赖旧嵌套类型。** → 按竖向能力切片迁移,先建立 selector facade,但 facade 只读且基于规范实体。 +- **[风险] 细粒度订阅和派生缓存可能产生失效错误。** → 所有索引声明依赖 key,增加重建等价测试和开发模式全量校验。 +- **[风险] overlay 与规范实体视觉切换可能闪烁。** → 以 commandId 关联响应并保持展示 key,规范身份仍只来自服务端。 +- **[风险] 组件卸载后请求回调造成泄漏。** → gateway 属于 Store/application scope,不捕获组件 setState;组件只维护可取消订阅。 +- **[风险] feature flag 并存增加测试矩阵。** → 并存只持续到最终 cutover,模式严格互斥并分别标记 authority。 + +## Migration Plan + +1. 实现 normalized Store、只读 selector、快照安装和 delta 合并。 +2. 实现 client gateway、pending overlay、冲突恢复和 GenerationCoordinator。 +3. 按只读展示 → Generation → 写命令顺序迁移组件。 +4. 在隔离规范路由跑现有行为矩阵和渲染/订阅性能验证。 +5. 把 canonical 客户端置于默认关闭的 Conversation 级开关后,交由最终 change 执行全量 cutover。 + +最终生产 cutover 前,回滚可以关闭试验 canonical 页面并切回 legacy 模式;一旦服务端 canonical authority 接受生产写入,只能回滚到仍使用规范 API/数据的兼容客户端版本或进入只读,任何客户端都不得把本地状态序列化回 `branch_trees.state`。 + +## Open Questions + +无。是否未来引入通用状态库、SSE 还是 WebSocket、以及 UI Workspace 是否跨设备同步,均可在不改变规范实体边界的情况下独立决策。 diff --git a/openspec/changes/normalize-conversation-client-state/proposal.md b/openspec/changes/normalize-conversation-client-state/proposal.md new file mode 100644 index 00000000..846c2495 --- /dev/null +++ b/openspec/changes/normalize-conversation-client-state/proposal.md @@ -0,0 +1,33 @@ +## Why + +服务端命令 API 已能返回规范快照和实体增量,但当前前端仍以可变 `ThreadTreeState` 为中心,组件会直接改 `children`、`activeLeafMessageId` 等关系,再依赖整树存盘和刷新协调。若只替换请求 URL,旧的重复事实和竞态仍会保留在浏览器;必须把客户端改为按稳定 ID 规范化实体,并把领域状态与列/画布视图状态彻底分开。 + +## What Changes + +- 新增按 ID 保存 Conversation、Thread、ThreadFork、Turn、Message 与 Generation 的规范化客户端 Store;实体关系只接受服务端快照或命令 delta。 +- 派生 Thread children、深度、Fork 数量、有效消息路径和列投影;这些索引可丢弃重建,不作为可写事实。 +- 将 visible columns、折叠、选中 Thread、画布 viewport、面板、草稿和 pending command 放入独立 UI Workspace slice。 +- 首次加载从 `ConversationSnapshot` 原子归一化;后续命令通过统一 client gateway 发送幂等键/revision,并合并规范 delta 或处理冲突重取。 +- 流式 Generation 由单一协调器订阅/轮询,按 checkpoint version 更新 Message/Generation;组件挂载只订阅所需 ID,卸载时释放订阅。 +- 乐观体验使用临时展示 overlay 和可回滚 pending command,不允许组件生成权威 Fork/Message 关系或触发整树 PUT。 +- 迁移列、画布、composer、Message actions、标题、反馈和恢复展示到 ID-scoped selector;保留现有 Markdown、Artifact 和流式视觉能力。 +- **BREAKING**:规范客户端路径不再调用 `persistNow`、save debounce、整树 CAS、启动 reconcile 或 `activeLeafMessageId` 写入;正式删除由最终切换 change 完成。 + +## Capabilities + +### New Capabilities + +- `conversation-client-state`:定义规范化实体 Store、派生索引、界面状态、命令 delta、Generation 订阅和组件边界。 + +### Modified Capabilities + +- 无。现有界面能力保持产品行为,旧客户端协议的退场由 `retire-thread-tree-authority` 处理。 + +## Impact + +- 前端核心:`app/thread-chat/core/store.ts`、types/selectors、boot、chat controller、persistence 与 generation reconciliation。 +- 组件:列、画布、composer、selection/fork、Message actions、标题、帮助/反馈和 Artifact 展示。 +- 网络:新增 Conversation client gateway、snapshot/delta parser、命令状态和 Generation polling/subscription coordinator。 +- 测试:Store reducer/selectors、delta/revision、并发冲突、订阅释放、刷新恢复和 UI 回归。 +- 前置依赖:`define-conversation-domain-model`、`add-conversation-command-api`,并间接依赖规范持久化与 Generation 生命周期。 +- 后续依赖:`retire-thread-tree-authority`。 diff --git a/openspec/changes/normalize-conversation-client-state/specs/conversation-client-state/spec.md b/openspec/changes/normalize-conversation-client-state/specs/conversation-client-state/spec.md new file mode 100644 index 00000000..f7843839 --- /dev/null +++ b/openspec/changes/normalize-conversation-client-state/specs/conversation-client-state/spec.md @@ -0,0 +1,165 @@ +## Purpose + +让浏览器按稳定实体 ID 消费 Conversation 快照、命令增量和 Generation checkpoint,同时把领域事实、派生索引、界面工作区与乐观状态分开。 + +## ADDED Requirements + +### Requirement: 按稳定 ID 规范化客户端实体 + +客户端 SHALL 分别按 ID 保存 Conversation、Thread、ThreadFork、Turn、Message 与 Generation,并保存服务端 revision/checkpoint version。一个规范实体在同一加载作用域内 MUST 只有一份客户端对象事实;组件不得保存可独立修改的嵌套实体副本。 + +#### Scenario: 两列引用同一来源 Message + +- **WHEN** 上游 Thread 和下游 Fork 投影都展示同一个来源 Message +- **THEN** 二者通过相同 Message ID 读取同一实体,且任何服务端更新只需合并一次 + +#### Scenario: Generation 更新输出 Message + +- **WHEN** 新 checkpoint 更新某条输出 Message +- **THEN** Store 按 Message ID 和版本替换该实体,所有订阅该 Message 的视图看到同一结果 + +### Requirement: 原子归一化 ConversationSnapshot + +客户端 SHALL 验证 `ConversationSnapshot` 的 schemaVersion、实体归属、引用和 revision 后,在一个 Store 提交中替换目标 Conversation 的规范实体集合。解析或不变量验证失败 MUST 保留现有可用状态并显示可恢复加载错误,不得部分安装快照。 + +#### Scenario: 首次加载有效快照 + +- **WHEN** 接口返回受支持 schemaVersion 的有效 ConversationSnapshot +- **THEN** 客户端原子安装实体、revision 和可重建索引,组件不会观察到半条 Fork 或缺失 Message 的中间状态 + +#### Scenario: 快照包含悬空 ThreadFork + +- **WHEN** 下游 Thread 或来源 Message 无法在快照中解析 +- **THEN** 客户端拒绝整个新快照并触发诊断/重试,而不是自行补造关系 + +### Requirement: 按版本合并规范实体增量 + +客户端 SHALL 只通过服务器命令响应、查询或 Generation checkpoint 合并规范实体 delta。每个 upsert/remove MUST 比较对应 revision 或 checkpoint version;较旧或重复 delta SHALL 幂等忽略,无法安全解释的版本间隙 SHALL 触发作用域重取。 + +#### Scenario: 命令响应晚于更新查询 + +- **WHEN** 一个旧命令响应在更新的实体查询之后到达 +- **THEN** Store 保留较新 revision,不被旧 delta 覆盖 + +#### Scenario: 收到未知 delta schemaVersion + +- **WHEN** 客户端收到不支持的 delta schemaVersion +- **THEN** 客户端不应用该 delta,标记目标 Conversation 需要重新加载 + +### Requirement: Fork 和路径索引必须可重建 + +Thread children、父来源、深度、Fork 数量、Turn 顺序、当前有效 Message 路径和画布边 SHALL 从规范实体派生或缓存为可丢弃索引。客户端不得通过直接修改这些索引来表达 Fork、发送或变体选择。 + +#### Scenario: 合并新 ThreadFork + +- **WHEN** Store 合并服务端返回的新 Thread 与 ThreadFork +- **THEN** children、深度、脚注/Fork 数量和画布边从关系重新派生,不要求响应同时提交多个反向字段 + +#### Scenario: 清空派生缓存 + +- **WHEN** 派生索引缓存被清除 +- **THEN** 客户端能从规范实体重建等价列和画布关系,不向服务端写入任何修复 + +### Requirement: 界面工作区独立于领域事实 + +visible Thread 顺序、折叠、当前选中 Thread、列宽/策略、画布 viewport、打开面板、临时草稿和本地提示 SHALL 保存在独立 UI Workspace slice。改变这些字段不得改变 Conversation revision、ThreadFork、Turn 当前变体或触发领域命令。 + +#### Scenario: 折叠分支列 + +- **WHEN** 用户折叠或关闭一列 +- **THEN** 客户端只更新 UI Workspace,Thread 与 ThreadFork 仍在规范实体 Store 中 + +#### Scenario: 归档当前可见 Thread + +- **WHEN** 归档命令成功并使选中 Thread 不再活跃 +- **THEN** Store 合并服务端生命周期 delta,UI Workspace 选择一个有效可见 Thread,而不删除历史关系 + +### Requirement: 通过统一客户端网关执行命令 + +组件 SHALL 通过统一 Conversation client gateway 执行命令,由网关附加幂等键、预期 revision、取消策略并解析规范响应。组件不得直接调用旧整树 PUT、手动推进 revision 或分别提交 Thread/Fork/Message 关系。 + +#### Scenario: 组件请求 Fork + +- **WHEN** selection UI 请求从 Message 创建 Thread +- **THEN** 网关发送一个 `forkThread` 请求,并只在服务端成功后把规范 delta 合并进 Store + +#### Scenario: 组件卸载时命令已提交 + +- **WHEN** 发起命令的组件卸载但服务端可能已提交请求 +- **THEN** 网关保留幂等身份并允许结果进入共享 Store,组件卸载不触发补偿性整树覆盖 + +### Requirement: 乐观状态不得成为规范关系 + +客户端 MAY 为交互即时性保存草稿、pending command 和临时展示 overlay,但这些状态 SHALL 与规范实体分离并可按命令 ID 回滚。服务端未确认前,客户端不得把临时 ThreadFork、Message 身份或 active variant 当作规范事实用于后续命令。 + +#### Scenario: 乐观发送后版本冲突 + +- **WHEN** composer 显示待发送 overlay,但服务端返回 `version_conflict` +- **THEN** 客户端移除或标记失败 overlay、保留用户草稿、更新 revision,并允许用户明确重试 + +#### Scenario: 幂等重试成功 + +- **WHEN** 网络失败后网关以相同幂等键重试并收到原成功结果 +- **THEN** 客户端用规范 Message/Generation ID 替换 pending overlay,不产生重复可见 Turn + +### Requirement: 统一协调 Generation 订阅与轮询 + +客户端 SHALL 按 Generation ID 维护引用计数的监控协调器,合并流式事件和查询结果,并只应用更高 checkpoint version。视图挂载 SHALL 订阅需要的 Generation,卸载 SHALL 释放订阅;无浏览器订阅不得停止服务端 Generation。 + +#### Scenario: 两个视图观察同一 Generation + +- **WHEN** 列视图和状态面板同时订阅一个运行中 Generation +- **THEN** 客户端只维持一个网络监控实例,并把更新分发给两个订阅者 + +#### Scenario: 最后一个视图卸载 + +- **WHEN** 最后一个订阅者卸载 +- **THEN** 客户端释放对应定时器/事件监听;服务端继续执行,后续重新挂载通过查询恢复最新 checkpoint + +#### Scenario: 轮询和流事件乱序 + +- **WHEN** 旧轮询结果晚于新流式 checkpoint 到达 +- **THEN** 协调器忽略旧版本,不回退 Message 内容或 Generation 状态 + +### Requirement: 组件使用按 ID 定位的选择器 + +列、画布、Message、composer、标题、Artifact、反馈和 Message actions SHALL 通过 ID-scoped selector 读取最小所需实体及派生结果。无关实体更新不得要求所有列重新渲染;selector 不得返回可由组件原地修改的规范对象。 + +#### Scenario: 一个分支流式更新 + +- **WHEN** 某条分支 Thread 的 Message 收到 checkpoint +- **THEN** 订阅该 Message/Thread 的视图更新,未引用它的独立列无需因全局树版本变化重新渲染 + +#### Scenario: Message action 使用稳定身份 + +- **WHEN** 用户复制、反馈、重新生成或 Fork 某条 Message +- **THEN** 操作使用规范 Message/Turn/Thread ID 和 client gateway,不从数组位置或局部树键推断身份 + +### Requirement: 保持当前可见产品行为 + +迁移后的客户端 SHALL 保持现有列/画布导航、嵌套 Fork、Markdown、Artifact、研究活动、流式进度、停止、恢复、标题和 Message actions 的用户可见能力。行为等价不要求保留旧 `ThreadTreeState`、魔法 `main` ID 或整树持久化实现。 + +#### Scenario: 刷新恢复 stopped partial + +- **WHEN** 用户停止带正文、Artifact 或研究活动的 Generation 后刷新 +- **THEN** 客户端从规范快照/checkpoint 恢复相同 `incomplete` 内容和 `stopped` 状态 + +#### Scenario: 打开嵌套 Fork + +- **WHEN** 用户从 A 打开 B,再从 B 打开 C +- **THEN** 列和画布通过 ThreadFork 投影显示正确层级、来源锚点和上下文,不依赖可写 children 数组 + +### Requirement: 规范客户端路径不保存整树或启动协调合并 + +启用规范客户端路径时,系统 MUST 禁用 `persistNow`、save debounce、卸载 flush、整树 CAS、`activeLeafMessageId` 写入以及为修复浏览器 partial 而运行的启动 reconcile。刷新恢复 SHALL 只依赖规范快照、命令结果和服务端 Generation checkpoint。 + +#### Scenario: 流式 token 高频到达 + +- **WHEN** Generation 连续产生流式更新 +- **THEN** 客户端更新展示和规范 checkpoint 版本,但不防抖 PUT 整个 Conversation + +#### Scenario: 页面卸载 + +- **WHEN** 用户在 Generation 运行中关闭页面 +- **THEN** 客户端不 flush 整树,服务端执行和 checkpoint 机制继续承担持久化责任 + diff --git a/openspec/changes/normalize-conversation-client-state/tasks.md b/openspec/changes/normalize-conversation-client-state/tasks.md new file mode 100644 index 00000000..be12fea3 --- /dev/null +++ b/openspec/changes/normalize-conversation-client-state/tasks.md @@ -0,0 +1,48 @@ +## 1. 规范化 Store 基础 + +- [ ] 1.1 定义只读 Conversation/Thread/ThreadFork/Turn/Message/Generation ID maps、revision 和 checkpoint version 状态。 +- [ ] 1.2 实现 ConversationSnapshot 临时解析、归属/引用校验和单次原子安装,失败时不部分覆盖旧状态。 +- [ ] 1.3 实现带 tombstone、schemaVersion 和每实体版本比较的 CanonicalDelta 合并器。 +- [ ] 1.4 增加开发模式实体冻结、重复/旧 delta 幂等、版本间隙重取和未知 schema 拒绝测试。 + +## 2. 派生索引与细粒度订阅 + +- [ ] 2.1 实现 Conversation → Threads、ThreadFork parent/children、Thread → Turns、Turn → Messages 的可重建索引。 +- [ ] 2.2 实现根 Thread、嵌套深度、Fork 数量、继承上下文、当前有效路径和画布边 selector。 +- [ ] 2.3 将 Store 通知拆为实体/索引/UI key,并提供按 ID 定位的 React hooks 与稳定 memoized selector。 +- [ ] 2.4 验证清空派生缓存可重建等价结果,以及一个 Message 流式更新不会通知无关 Thread 订阅。 + +## 3. UI Workspace 与乐观状态 + +- [ ] 3.1 将 visible/folded/selected Thread、列策略、画布 viewport、面板、草稿和本地提示迁入独立 UI Workspace slice。 +- [ ] 3.2 为 UI localStorage payload 增加 schemaVersion/Conversation ID,并在加载时过滤归档或缺失 Thread。 +- [ ] 3.3 实现按 commandId 管理的 pending overlay、草稿恢复、确认中、成功替换和失败回滚。 +- [ ] 3.4 验证折叠/画布/面板操作不改变 canonical revision 或发出领域命令。 + +## 4. Client gateway 与 GenerationCoordinator + +- [ ] 4.1 实现统一 client gateway,附加 Idempotency-Key、最小预期 revision并解析 snapshot/delta/稳定错误。 +- [ ] 4.2 实现网络不确定时复用幂等键、409 revision 恢复和组件卸载后共享结果合并。 +- [ ] 4.3 实现按 Generation ID 引用计数的 stream/poll 协调器、可见/隐藏频率、终态停止和 checkpoint 乱序保护。 +- [ ] 4.4 覆盖多视图共享一个监控、最后订阅释放、重新挂载恢复以及卸载不发送 Stop 的测试。 + +## 5. 只读界面切片迁移 + +- [ ] 5.1 迁移 boot、Conversation 列表/标题和根 Thread 识别,不再使用魔法 `main` ID。 +- [ ] 5.2 迁移列、Thread switcher、tree list、breadcrumb、selection anchor 和嵌套 Fork 展示到 ThreadFork selectors。 +- [ ] 5.3 迁移 Message/Markdown/研究活动/Artifact/反馈展示到稳定 ID selectors。 +- [ ] 5.4 迁移 React Flow 画布节点/边和聚焦行为到可重建投影,并验证懒加载不复制 canonical 实体。 + +## 6. 写交互切片迁移 + +- [ ] 6.1 迁移 composer/send/stop 到 client gateway 和 pending overlay,删除规范路径的 `persistNow` 前置屏障。 +- [ ] 6.2 迁移重新生成、用户输入编辑和变体选择到 Turn/Message 命令与 revision。 +- [ ] 6.3 迁移 selection Fork 到单个 `forkThread` 命令,不再本地写 parent/children/Message forks。 +- [ ] 6.4 迁移复制、反馈、Artifact 定位和其余 Message actions,全部使用稳定 Message/Turn/Thread ID。 + +## 7. 互斥模式与行为验证 + +- [ ] 7.1 增加 Conversation 页面 legacy/canonical 互斥 boot 开关,canonical 模式不实例化旧 Tree Store、整树 persistence 或 reconcile。 +- [ ] 7.2 覆盖发送、流式、Stop partial、刷新恢复、重新生成、选择变体、A → B → C Fork、归档/恢复和标题行为矩阵。 +- [ ] 7.3 增加 selector 通知计数与关键渲染性能验证,确认无关列不会随全局树 version 重渲染。 +- [ ] 7.4 运行相关单元/组件/端到端测试、`pnpm typecheck` 与 `pnpm openspec:validate` 并记录结果。 diff --git a/openspec/changes/normalize-conversation-persistence/.openspec.yaml b/openspec/changes/normalize-conversation-persistence/.openspec.yaml new file mode 100644 index 00000000..d160e09c --- /dev/null +++ b/openspec/changes/normalize-conversation-persistence/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-21 diff --git a/openspec/changes/normalize-conversation-persistence/design.md b/openspec/changes/normalize-conversation-persistence/design.md new file mode 100644 index 00000000..447130af --- /dev/null +++ b/openspec/changes/normalize-conversation-persistence/design.md @@ -0,0 +1,103 @@ +## Context + +本变更依赖 `define-conversation-domain-model`。当前 `branch_trees.state` 是唯一事实源,`branch_generations`、反馈和 Artifact 只能引用 JSON 内部 ID。本阶段只建立规范结构、仓储和审计能力;在最终切换前不改变线上权威来源。 + +## Goals / Non-Goals + +**目标:** + +- 让 Conversation 核心实体拥有可外键约束的服务端身份。 +- 用数据库约束和事务保护根 Thread、唯一 Fork、Turn/Message 归属与有效变体。 +- 提供稳定的规范快照和可重复遗留审计。 +- 为 Generation 迁移提供真实 Thread/Turn/Message 外键目标。 + +**非目标:** + +- 迁移 Generation 状态、计费和流式终结。 +- 开放新的写命令接口或迁移客户端 Store。 +- 在本阶段启用生产双写或正式删除 `branch_trees`。 +- 实现 Project Memory、File、Instruction 的业务表。 + +## Decisions + +### D1. 物理表按 Conversation 领域命名 + +首批表为: + +```text +workspaces +workspace_members +projects +conversations +conversation_threads +thread_forks +conversation_turns +conversation_messages +``` + +使用 `conversation_*` 前缀避免与其他聊天运行时的线性表混淆。Workspace/Project 只实现最小身份和成员归属;资产能力后续扩展。 + +### D2. 使用复合键保护归属 + +除普通主键外,表提供必要的复合唯一键: + +```text +conversation_threads: unique(id, conversation_id) +conversation_turns: unique(id, thread_id) +conversation_messages: unique(id, thread_id, turn_id) +``` + +ThreadFork 的 parent/child 与 Conversation 使用复合外键;来源 Message 同时携带 parent Thread 约束。Turn 的 active Message 使用可延迟复合外键,并在事务终点验证角色。仅检查单个 ID 不足以阻止跨 Thread 串线。 + +### D3. 根引用和 Fork 完整性使用延迟约束 + +Conversation 与根 Thread、Turn 与 active Message 都存在创建顺序环。迁移使用 PostgreSQL 可延迟约束或等价约束触发器,使一个事务可以先创建双方,再在提交时验证: + +- `rootThreadId` 属于当前 Conversation; +- 根 Thread 没有入向 Fork; +- 每个非根 Thread 恰有一条入向 Fork; +- Fork 图无环; +- active user/assistant Message 属于当前 Turn 且角色匹配。 + +仓储仍执行早期领域校验,以返回可理解错误;数据库约束是最终防线。 + +### D4. Message 内容保持结构化 JSON 值 + +Message 行规范化身份、归属、角色、状态和顺序,但 `content` 继续保存有版本的结构化片段 JSON。工具片段、研究计划和 Markdown 内容没有独立权限时不拆表。File/Artifact 的独立生命周期由后续 Project 资产变更处理。 + +### D5. 快照只读,写入通过仓储端口 + +`ConversationSnapshot` 按稳定顺序组装实体和派生索引,用于测试和后续首次加载。仓储端口按实体/聚合操作,不提供 `saveConversationSnapshot`。这样后续命令接口无法重新引入整树覆盖。 + +### D6. 先审计,再决定重置或导入 + +审计器先对真实数据运行只读报告,输出: + +- 总记录数和所有者分布; +- 可直接映射的 Conversation/Thread/Turn/Message/Fork 数量; +- 魔法根 ID、重复 Message、错误 Fork、跨 Thread active leaf; +- `branch_generations`、反馈和 Artifact 的悬空引用; +- 每棵树的确定性映射摘要和错误代码。 + +根据报告在最终切换变更中选择开发数据重置或一次性导入。本变更不猜测该决定。 + +### D7. 新路径默认关闭 + +规范仓储通过显式服务端配置启用,默认不接受生产写入。测试可以在隔离数据库中直接使用;线上仅允许审计。禁止添加自动双写适配器,因为它会在命令 API 出现前建立两个权威来源。 + +## Risks / Trade-offs + +- **[风险] 延迟约束和触发器增加迁移复杂度。** → 同时提供仓储级早期校验、数据库集成验证和清晰约束命名。 +- **[风险] Workspace/Project 扩大首批表。** → 只实现身份与成员归属,避免以后再次迁移 Conversation 所有权。 +- **[风险] 新表落地但暂不承载流量。** → 通过隔离集成验证和审计证明结构,下一 change 才迁移执行路径。 +- **[风险] JSON 内容仍可能包含无效工具片段。** → 内容使用版本化解析器;关系归属不再依赖片段内部 ID。 + +## Migration Plan + +1. 增加表、复合键、索引、延迟约束和仓储端口。 +2. 在隔离数据库执行最小与非法关系验证。 +3. 实现确定性 `ConversationSnapshot` 读取。 +4. 实现只读遗留审计并对目标环境生成报告。 +5. 保持规范生产写入关闭,把报告交给下一迁移 change。 + +回滚只需删除尚未启用写入的新表和代码;不得修改 `branch_trees` 原数据。 diff --git a/openspec/changes/normalize-conversation-persistence/proposal.md b/openspec/changes/normalize-conversation-persistence/proposal.md new file mode 100644 index 00000000..d7a5dc98 --- /dev/null +++ b/openspec/changes/normalize-conversation-persistence/proposal.md @@ -0,0 +1,31 @@ +## Why + +`define-conversation-domain-model` 已确定 Project、Conversation、Thread、ThreadFork、Turn 与 Message 的身份和归属,但当前数据库仍把整棵 `ThreadTreeState` 存在单个 JSONB 行中,无法用外键拒绝跨 Thread 串线、重复 Fork 来源或错误 active variant。必须先建立规范写模型和可审计迁移入口,后续 Generation、命令接口与客户端才能依赖稳定实体。 + +## What Changes + +- 新增 Workspace/Project、Conversation、Thread、ThreadFork、Turn、Message 的规范化关系表、稳定 ID、复合外键、唯一约束和必要的延迟约束检查。 +- Conversation 独占聚合标题与 `rootThreadId`;根 Thread 不再使用 `id = "main"`,非根 Thread 的来源只由 ThreadFork 表达。 +- Turn 显式保存当前有效用户/助手 Message,Message 内容继续使用带版本的结构化 JSON 值,避免过度拆分工具片段。 +- 新增仓储端口与只读 `ConversationSnapshot` 组装器;快照可供首次加载,但不得作为整包写入数据。 +- 新增对 `branch_trees.state` 的只读审计和导入计划,输出逐实体映射、拒绝原因和可重复运行报告;本变更不启用双写,也不切换生产权威来源。 +- 为下一步 `migrate-generation-lifecycle` 暴露真实 Thread/Turn/Message 身份,但不在本变更迁移 Generation 状态机、计费或流式终结逻辑。 + +## Capabilities + +### New Capabilities + +- `conversation-persistence`:定义 Conversation 核心实体的关系型持久化、不变量、规范快照和遗留数据审计行为。 + +### Modified Capabilities + +- 无。本变更依赖 `define-conversation-domain-model` 的 `domain` 增量规范,不再次复制术语要求。 + +## Impact + +- 数据库:`lib/db/schema.ts`、Drizzle 迁移、约束、索引以及本地重置/迁移脚本。 +- 领域与仓储:`lib/thread-chat/domain/`、新的 Conversation 仓储端口和持久化适配器。 +- 读取路径:新增规范 `ConversationSnapshot`;现有 `branch_trees` 读取在切换前保持权威。 +- 部署:只落表和禁用状态的代码不会改变当前用户行为;正式导入与切换由后续 change 执行。 +- 前置依赖:`define-conversation-domain-model`。 +- 后续依赖:`migrate-generation-lifecycle`、`add-conversation-command-api`。 diff --git a/openspec/changes/normalize-conversation-persistence/specs/conversation-persistence/spec.md b/openspec/changes/normalize-conversation-persistence/specs/conversation-persistence/spec.md new file mode 100644 index 00000000..cf4e4f4a --- /dev/null +++ b/openspec/changes/normalize-conversation-persistence/specs/conversation-persistence/spec.md @@ -0,0 +1,89 @@ +## Purpose + +为 Project、Conversation、Thread、ThreadFork、Turn 与 Message 建立可由数据库和事务强制保护的规范持久化契约,并提供只读快照、遗留数据审计及安全迁移边界。 + +## ADDED Requirements + +### Requirement: 持久化稳定的 Conversation 核心实体 + +系统 SHALL 为 Workspace、Project、Conversation、Thread、Turn 与 Message 分配稳定且无业务含义的服务端 ID,并将每个实体保存为可独立寻址的关系型记录。实体归属创建后不得通过更新外键改变;任何跨所有者、跨 Project 或跨 Conversation 引用 MUST 被拒绝。 + +#### Scenario: 创建根 Conversation + +- **WHEN** 系统在 Project 中创建一条 Conversation +- **THEN** Conversation 与根 Thread 获得独立稳定 ID,且 `rootThreadId` 只能指向属于该 Conversation 的 Thread + +#### Scenario: 拒绝移动既有 Thread + +- **WHEN** 写入尝试把既有 Thread 的 `conversationId` 改为另一条 Conversation +- **THEN** 系统拒绝写入并保持原归属不变 + +### Requirement: 使用唯一 ThreadFork 保存分叉来源 + +系统 SHALL 只通过 ThreadFork 保存上游 Thread、来源 Message 与下游 Thread 的 Fork 事实。来源 Message MUST 属于上游 Thread,三者 MUST 属于同一 Conversation;根 Thread 不得有入向 ThreadFork,非根 Thread MUST 恰有一个入向 ThreadFork,Fork 图不得成环。 + +#### Scenario: 保存有效嵌套 Fork + +- **WHEN** 一条 ThreadFork 指向同一 Conversation 中的上游 Thread、其来源 Message 和尚无来源的下游 Thread +- **THEN** 系统保存该关系,并可从它派生 children、深度与分支数量 + +#### Scenario: 拒绝第二个来源 + +- **WHEN** 写入尝试为同一个非根 Thread 建立第二条入向 ThreadFork +- **THEN** 系统拒绝该写入且保留原唯一来源 + +### Requirement: 使用 Turn 约束 Message 与当前有效变体 + +每个 Turn SHALL 恰好属于一个 Thread,每个 Message SHALL 同时属于该 Thread 和其中一个 Turn。Turn 的当前有效用户与助手 Message MUST 指向自身变体,角色必须匹配;跨 Thread 或跨 Turn 的变体选择 MUST 被拒绝。 + +#### Scenario: 保存重新生成变体 + +- **WHEN** 同一 Turn 产生新的助手 Message 变体并被选为当前有效回答 +- **THEN** 系统追加新 Message、更新该 Turn 的有效助手引用,且不修改其他 Thread/Turn + +#### Scenario: 拒绝跨 Thread 有效选择 + +- **WHEN** Turn 尝试把另一个 Thread 的 Message 设为当前有效变体 +- **THEN** 系统拒绝该选择且原有效路径不变 + +### Requirement: 组装只读规范 Conversation 快照 + +系统 SHALL 能够从规范实体组装包含 Conversation、Threads、ThreadForks、Turns、Messages 和当前有效选择的确定性快照。快照中的派生索引 MUST 可从实体重建;系统不得接受该快照作为覆盖规范行的整包写入。 + +#### Scenario: 两次读取产生稳定快照 + +- **WHEN** 底层规范实体未发生变化并连续读取同一 Conversation +- **THEN** 两次快照具有相同实体关系、排序和当前有效路径 + +#### Scenario: 拒绝整包覆盖 + +- **WHEN** 调用方提交完整 Conversation 快照试图覆盖服务端实体 +- **THEN** 持久化层不提供该权威写入能力,并要求调用实体级仓储或应用命令 + +### Requirement: 审计遗留 ThreadTreeState + +系统 SHALL 提供只读、可重复运行的遗留数据审计,检查所有者、根节点、Thread/Fork、Message 图、当前叶节点、Artifact 来源和辅助 Generation 引用,并输出逐记录的可迁移、需修复或拒绝原因。审计不得修改原始数据。 + +#### Scenario: 审计合法遗留树 + +- **WHEN** 遗留 `ThreadTreeState` 满足已知结构和归属不变量 +- **THEN** 审计输出确定的规范实体映射及数量,不写入规范表或原表 + +#### Scenario: 报告污染引用 + +- **WHEN** 遗留树含缺失来源 Message、重复 Fork、跨 Thread active leaf 或辅助表悬空 ID +- **THEN** 审计将该记录标为不可直接迁移,并给出稳定错误代码和涉及的实体 ID + +### Requirement: 迁移阶段保持唯一写入权威 + +在正式切换前,新增规范表和仓储 SHALL 保持禁用或只读验证状态,旧路径仍是唯一写入权威;切换后则规范实体成为唯一权威,系统不得长期双写 JSON 与规范行。 + +#### Scenario: 未启用规范路径 + +- **WHEN** 规范持久化功能开关未启用 +- **THEN** 当前用户写入行为不变,新表只能用于显式测试、审计或隔离环境 + +#### Scenario: 防止无声明双写 + +- **WHEN** 代码尝试在同一运行模式下同时把一次领域变更写入整树 JSON 与规范实体 +- **THEN** 系统拒绝启动该模式或使验证失败 diff --git a/openspec/changes/normalize-conversation-persistence/tasks.md b/openspec/changes/normalize-conversation-persistence/tasks.md new file mode 100644 index 00000000..19501931 --- /dev/null +++ b/openspec/changes/normalize-conversation-persistence/tasks.md @@ -0,0 +1,28 @@ +## 1. 数据结构与迁移 + +- [ ] 1.1 定义 `workspaces`、`workspace_members`、`projects`、`conversations`、`conversation_threads`、`thread_forks`、`conversation_turns`、`conversation_messages` 的 Drizzle 结构和稳定命名。 +- [ ] 1.2 生成迁移并补充 Drizzle 无法表达的可延迟复合外键、唯一约束与约束触发器。 +- [ ] 1.3 为所有者查询、Conversation 列表、Thread/Turn 顺序、来源 Message 和当前有效变体建立必要索引。 +- [ ] 1.4 更新本地 schema reset 脚本,使新表只在明确目标 schema 中创建或删除,不影响其他数据库。 + +## 2. 规范仓储 + +- [ ] 2.1 建立 Workspace/Project 与 Conversation/Thread 的纯应用仓储端口和 Drizzle 适配器。 +- [ ] 2.2 实现 ThreadFork 事务写入与根、唯一来源、同 Conversation、来源 Message 归属和无环校验。 +- [ ] 2.3 实现 Turn/Message 追加、角色校验和当前有效变体的预期版本更新。 +- [ ] 2.4 实现确定性 `ConversationSnapshot` 组装、稳定排序和可重建派生索引。 +- [ ] 2.5 增加服务端配置边界,默认禁止规范仓储承载生产写入,并拒绝未声明双写模式。 + +## 3. 遗留审计 + +- [ ] 3.1 实现只读 `ThreadTreeState` 审计器和 legacy → canonical ID/实体映射报告。 +- [ ] 3.2 检查根、Fork、Message 图、active leaf、Artifact、反馈和 `branch_generations` 悬空引用,并为每类错误定义稳定代码。 +- [ ] 3.3 增加 dry-run 命令,仅输出汇总与逐树报告,不修改旧表或规范表。 +- [ ] 3.4 在目标环境执行审计并记录“可重置或必须迁移”的数据证据,供最终切换 change 决策。 + +## 4. 约束验证 + +- [ ] 4.1 覆盖最小根 Conversation、A → B → C Fork、同 Turn 回复变体和有效选择的成功用例。 +- [ ] 4.2 覆盖跨 Conversation Fork、来源 Message 错配、第二入向 Fork、Fork 环、跨 Thread Message/Turn 和错误角色 active Message 的拒绝用例。 +- [ ] 4.3 验证两个相同读取产生等价 `ConversationSnapshot`,且仓储没有整包快照写入端口。 +- [ ] 4.4 运行数据库迁移、`pnpm typecheck`、`pnpm openspec:validate` 和相关验证脚本并记录结果。 diff --git a/openspec/changes/retire-thread-tree-authority/.openspec.yaml b/openspec/changes/retire-thread-tree-authority/.openspec.yaml new file mode 100644 index 00000000..d160e09c --- /dev/null +++ b/openspec/changes/retire-thread-tree-authority/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-21 diff --git a/openspec/changes/retire-thread-tree-authority/design.md b/openspec/changes/retire-thread-tree-authority/design.md new file mode 100644 index 00000000..7070b2c6 --- /dev/null +++ b/openspec/changes/retire-thread-tree-authority/design.md @@ -0,0 +1,174 @@ +## Context + +新领域链完成后,代码库会暂时同时包含可运行的 legacy ThreadTree 栈和默认关闭的 canonical Conversation 栈。并存代码本身可以支持验证,但生产事实不能并存:任何双写、读失败回退或异步同步都会重新创造 Issue #34 的根因。 + +Cutover 是一次数据、应用、客户端和文档共同变更,不是简单切 feature flag。本设计把决定门禁、冻结、导入、互斥切换、观察、回滚和最终删除分成可验证阶段。 + +## Goals / Non-Goals + +**目标:** + +- 基于真实数据选择重置或导入,并保留审计证据。 +- 在没有未确认旧写入时把 canonical 栈切为唯一权威。 +- 保持新 specs 定义的全部用户行为、授权和计费正确性。 +- 提供切换前后不同的安全回滚策略。 +- 删除旧协议和重复领域事实,不留下永久兼容层。 + +**非目标:** + +- 用双写实现无停机迁移。 +- 在 cutover 中实现分享、Project assets、CLI/MCP 或 Issue #39 收敛。 +- 保留旧客户端的长期写兼容。 +- 删除未经依赖审计的其他聊天表或已归档迁移历史。 + +## Decisions + +### D1. Authority 是单值部署状态 + +应用组合根只接受: + +```text +CONVERSATION_AUTHORITY = legacy | canonical +``` + +服务端健康信息发布 authority、schemaVersion 和 cutover epoch;客户端 boot 必须匹配。`legacy` 组合根不能构造 canonical 写 handler,`canonical` 组合根不能构造 branch-tree 写仓储。配置缺失、未知或两套依赖同时可写时健康检查失败。 + +备选方案是按请求或 Conversation 灰度双 authority;跨记录的列表、billing job 和客户端路由会产生不可证明的混合边界,因此不用于首次 cutover。 + +### D2. 数据处置先产出 ADR,再写执行参数 + +使用 persistence change 的审计器在目标环境输出: + +- legacy Conversation/Thread/Message/Generation 数量与所有者分布; +- 合法、需修复、拒绝记录; +- 反馈、Artifact、标题、usage 和其他 sidecar 引用; +- 数据保留义务与环境用途。 + +单独 ADR 选择: + +1. **确定性导入**:存在任何需保留数据时的默认; +2. **受批准重置**:只限明确无保留义务的隔离环境。 + +任务不会预先写死选择。ADR 包含批准者、时间、范围、备份和不可迁移项处置。 + +### D3. Legacy 局部 ID 通过持久映射迁移 + +许多 JSON 内 ID(如 `main`、`m1`、`b2`)只在单棵树内唯一,不能直接成为公开规范 ID。导入器以 `(legacyTreeId, entityType, localId)` 生成或记录稳定 canonical ID,并输出映射清单。所有 ThreadFork、Generation、反馈、Artifact、标题和 usage 引用通过同一映射解析。 + +导入器使用 upsert/幂等 marker,可在冻结数据上重复 dry-run 和正式运行。单条 Conversation 在事务中导入;全局报告校验源/目标计数、唯一约束、哈希摘要和悬空引用。 + +### D4. 用短维护窗口代替双写 + +执行顺序: + +```text +拒绝新的 legacy mutations/generations + → drain/stop/reconcile 非终态 Generation + → 验证 usage/计费终结 + → 备份 legacy 与空/预导入 canonical 数据 + → 正式 import/reset + → 全量 integrity verification + → 切 canonical authority + canonical client +``` + +维护窗口虽然有短暂不可写,但把最后写入边界变成可证明时刻。双写看似减少停机,却需要再次实现两套语义的协调,风险更高。 + +### D5. 行为矩阵是 release gate,不是人工印象 + +矩阵至少覆盖: + +```text +Auth/owner/project isolation +Conversation list/create/title/archive/restore/delete +Thread title/archive/restore +Column/tree-list/canvas navigation +Selection anchor + A→B→C Fork +Send/edit/regenerate/select variant +Markdown/Artifact/research activities +Copy/feedback/message actions +Streaming/disconnect/Stop/stale recovery/refresh +Usage completeness/exactly-once billing +遗留路由拒绝 +``` + +每项关联自动测试、手工 smoke(仅视觉交互)和新 spec Requirement。已确认旧错误不作为“兼容行为”保留。 + +### D6. 切换采用一个不可分割的 release checkpoint + +在数据验证完成且仍处于维护模式时: + +1. 部署/启用 canonical 服务端 authority; +2. 验证规范 API 健康、schema 和 epoch; +3. 启用 canonical 客户端 build; +4. 对内部 canary actor 执行读写 smoke; +5. 开放流量并关闭维护模式。 + +如果平台不能原子切配置与客户端,则先部署能识别服务端 epoch 的客户端壳,只有 epoch 匹配才加载 canonical 页面;不允许新客户端向 legacy 写入或旧客户端向 canonical 写入。 + +### D7. 旧路由立即拒绝,代码和表延后删除 + +切换时旧 mutation routes 返回 `410` + `legacy_protocol_retired`(认证/不可见检查仍按安全策略执行),旧读取不作为 canonical fallback。保留最小拒绝/遥测 handler 一段声明观察期,用于识别陈旧客户端。 + +观察门禁通过后分两步清理: + +1. 删除 Tree Store、`persistNow`、reconcile、legacy repositories/routes 和所有新代码对 ThreadTree 类型的导入; +2. 在备份保留与依赖扫描通过后,用后续不可逆迁移删除确认只服务旧能力的表/列。 + +历史 SQL migration 文件不重写。名称相似的 assistant-ui 线性表先审计用途,不能因为名字相似顺带删除。 + +### D8. 可观测性围绕正确性门禁 + +Dashboard/日志至少包含: + +- authority/schema/epoch mismatch; +- snapshot/command 成功率与稳定错误码; +- revision/idempotency 冲突; +- Generation 非终态年龄、checkpoint age、Stop latency、stale 收敛; +- `usage_unavailable`、唯一 usage 冲突和对账差异; +- legacy route 调用; +- 导入/运行时外键和不变量失败。 + +阈值在 release runbook 中依据当前基线填写,不在设计中伪造通用数字。日志只记录 ID、分类和计数,不记录私密 Message 正文。 + +### D9. 回滚线以首个 canonical 生产写入为界 + +**切换前:** legacy 数据未改变,可退出维护并继续 legacy。 + +**切换后但尚无 canonical 写入:** 可以关闭流量并回到 legacy,但必须用审计证明零 canonical mutation。 + +**已有 canonical 写入:** legacy 已成为落后备份,不得恢复为权威。选择: + +- 回滚客户端/应用版本,但保留兼容 canonical schema; +- 进入 canonical read-only 并前滚修复; +- 从 cutover 后的规范备份/事务日志恢复。 + +绝不实现 canonical → ThreadTree 反向同步,因为它会把删除掉的重复关系语义重新引入。 + +### D10. OpenSpec 按“历史实现 → 目标替代”排序 + +对依赖旧模型的 changes 建立 supersession 清单。完成的旧 changes 保留真实历史;归档时确保最终生效 specs 由 `define-conversation-domain-model` 和四个后续能力覆盖。未完成 `persist-thread-chat-generations` 不补勾任务,记录被 `migrate-generation-lifecycle` 及 cutover 替代的原因和未完成范围。 + +若归档工具会把旧 requirement 重新合并为目标权威,则使用明确的 superseded/skip-specs 历史处置流程,而不是通过修改旧任务伪造一致性。 + +## Risks / Trade-offs + +- **[风险] 维护窗口暂时阻止聊天写入。** → 提前测量导入时间、预生成 dry-run 映射并保留只读访问;用短冻结换取唯一写入边界。 +- **[风险] 遗留污染数据阻塞整批切换。** → 按 Conversation 事务导入并提前修复;所有排除都需 ADR 批准和备份。 +- **[风险] 新客户端与 authority epoch 不匹配。** → boot 强制检查服务端 epoch/schema,不匹配时显示维护/刷新提示而不写入。 +- **[风险] 切换后无法简单回到旧 JSON。** → 这是避免数据丢失的必要限制;通过 canonical 备份、read-only 和前滚演练降低风险。 +- **[风险] 过早删表破坏隐蔽 job。** → 先拒绝路由和删除代码引用,观察零调用,再以 DB dependency/query audit 门禁物理删除。 + +## Migration Plan + +1. 完成并严格验收全部前置 changes,建立行为矩阵和基线。 +2. 在目标环境运行只读审计,批准 import/reset ADR,完成 dry-run 与映射报告。 +3. 演练维护、drain、备份、导入、验证、切换和 canonical 恢复。 +4. 正式进入维护窗口,冻结旧写入并收敛所有 Generation/计费。 +5. 生成验证备份,执行 import/reset 和全量完整性检查。 +6. 切换 authority/client epoch,执行 canary 和 smoke 后开放流量。 +7. 观察门禁通过后删除旧运行时代码;保留备份至声明期限。 +8. 依赖扫描和恢复验证通过后删除遗留表/列,并如实处置旧 OpenSpec changes。 + +## Open Questions + +仅有一个必须由真实审计回答的部署决策:目标环境采用确定性导入还是受批准重置。该问题不改变架构,但必须在执行 cutover 前以 ADR 关闭。 diff --git a/openspec/changes/retire-thread-tree-authority/proposal.md b/openspec/changes/retire-thread-tree-authority/proposal.md new file mode 100644 index 00000000..e94dbddb --- /dev/null +++ b/openspec/changes/retire-thread-tree-authority/proposal.md @@ -0,0 +1,33 @@ +## Why + +前四个 changes 可以把新模型完整实现出来,但只要 `branch_trees.state`、整树 PUT 或旧客户端仍能写入,系统就有两个事实源,任何新关系和修复最终都会再次漂移。最后必须通过有证据、可门禁、不可双写的 cutover,把规范 Conversation 栈切为唯一生产权威,并让旧 ThreadTree 协议和代码安全退场。 + +## What Changes + +- 汇总遗留审计、真实数据量、悬空引用和功能覆盖证据,在 cutover 前明确选择“一次性导入”或“有批准的数据重置”,不得在任务中预设答案。 +- 建立旧/新行为对照与发布门禁,覆盖列表/标题、列/画布、嵌套 Fork、Message 变体/actions、Artifact/反馈、Generation Stop/恢复和计费。 +- 在维护窗口停止旧写入、排空或收敛非终态 Generation、生成可验证备份,并一次性导入/初始化规范实体及所有辅助引用。 +- 以互斥 authority 配置原子切换规范服务端、命令 API 和 normalized 客户端;系统不存在长期双写、双读择优或后台协调模式。 +- **BREAKING**:关闭整树 GET/PUT/DELETE 写协议、遗留 Generation 协调路由和浏览器 save/reconcile 路径;旧客户端收到稳定 retired 响应,不得继续覆盖规范实体。 +- 在观察期后删除 `ThreadTreeState` 新代码依赖、魔法 `main`、重复 parent/children/forks/depth/activeLeaf 字段,以及确认无其他用途的 `branch_trees`/`branch_generations` 结构。 +- 制定不会把规范写入回灌旧 JSON 的回滚方案;切换后问题通过 canonical read-only、前滚修复或规范备份恢复处理。 +- 处理依赖旧权威模型的活跃 OpenSpec changes:完整能力保留为历史,未完成的 `persist-thread-chat-generations` 明确标记被新链替代,不把未完成任务伪装为完成。 + +## Capabilities + +### New Capabilities + +- `conversation-cutover`:定义从 ThreadTree 权威到规范 Conversation 权威的数据决策、发布门禁、互斥切换、旧协议退场、观察与回滚要求。 + +### Modified Capabilities + +- 无。遗留 capabilities 的历史归档顺序由本 change 的迁移任务处理;目标行为由新链的独立 capabilities 定义。 + +## Impact + +- 数据:`branch_trees`、`branch_generations`、Message feedback、Artifact/标题/usage 外键、规范 Conversation 全表和备份。 +- 服务端:旧 branch-tree 路由、Generation sidecar/reconcile、领域 types/selectors/repositories、authority 配置和运维脚本。 +- 客户端:legacy boot/Store/persistence/chat controller 与 canonical 页面开关。 +- 发布:维护窗口、数据审计、canary/smoke、指标、回滚和旧表保留期。 +- OpenSpec:旧 active changes 的历史处置与新 changes 的依赖顺序。 +- 前置依赖:`define-conversation-domain-model`、`normalize-conversation-persistence`、`migrate-generation-lifecycle`、`add-conversation-command-api`、`normalize-conversation-client-state` 全部实现并验收。 diff --git a/openspec/changes/retire-thread-tree-authority/specs/conversation-cutover/spec.md b/openspec/changes/retire-thread-tree-authority/specs/conversation-cutover/spec.md new file mode 100644 index 00000000..4425d92c --- /dev/null +++ b/openspec/changes/retire-thread-tree-authority/specs/conversation-cutover/spec.md @@ -0,0 +1,155 @@ +## Purpose + +以可审计、互斥、可回滚且不产生双重事实源的方式,将生产 Conversation 从 ThreadTree JSON 权威切换到规范实体,并安全退役旧协议、代码和数据结构。 + +## ADDED Requirements + +### Requirement: Cutover 必须满足显式前置门禁 + +切换生产权威前,系统 SHALL 证明规范持久化、Generation 生命周期、命令 API 和 normalized 客户端的必需任务及行为矩阵已经完成。发布记录 MUST 包含严格测试结果、数据审计、容量/性能基线、安全检查、备份验证、回滚演练和明确 go/no-go 决策;任一阻塞门禁失败 MUST 取消切换。 + +#### Scenario: Generation partial 回归未通过 + +- **WHEN** 门禁测试仍会在 stale recovery 中覆盖已保存 partial +- **THEN** 发布保持 legacy authority,不进入数据冻结或 cutover + +#### Scenario: 所有门禁满足 + +- **WHEN** 实现、数据、行为、安全、备份和回滚证据均满足记录的验收标准 +- **THEN** 负责人可以批准进入受控维护窗口 + +### Requirement: 用真实审计决定导入或重置 + +系统 SHALL 根据 `normalize-conversation-persistence` 产生的只读审计和目标环境用途,记录选择“一次性导入”或“受批准重置”的决定、影响范围、记录数量、不可迁移项及处理人。存在需保留的用户数据时 MUST 导入或逐项解决,不能为简化开发而静默清空。 + +#### Scenario: 目标环境有需保留用户数据 + +- **WHEN** 审计发现属于真实用户且在保留范围内的 Conversation +- **THEN** cutover 计划选择确定性导入,并为全部不可直接映射记录给出修复或明确排除批准 + +#### Scenario: 隔离开发环境获准重置 + +- **WHEN** 审计证明数据无保留义务且负责人书面批准重置 +- **THEN** 发布记录保存范围、备份与批准证据后,才可初始化空规范数据 + +### Requirement: 一次性导入必须确定且引用完整 + +导入器 SHALL 在旧写入冻结后,把每个 legacy `(tree, entityType, localId)` 确定映射为规范稳定 ID,并迁移 Conversation、Thread、ThreadFork、Turn、Message、Generation、标题、反馈、Artifact 和 usage 关联。导入 MUST 可重复运行、事务安全并输出映射/拒绝报告;任何跨所有者、悬空或计数不一致 MUST 阻止该记录切换。 + +#### Scenario: 导入含嵌套 Fork 的树 + +- **WHEN** 合法遗留树包含 A → B → C 及 Message 反馈、Artifact 和已终结 Generation +- **THEN** 导入产生一个 Conversation、三个 Thread、两条 ThreadFork 和完整辅助外键,重跑不会产生重复实体 + +#### Scenario: 导入发现悬空 Generation Message + +- **WHEN** `branch_generations` 引用无法映射的 JSON Message ID +- **THEN** 导入将该 Conversation 标为阻塞并保持旧备份,不伪造 Message 或忽略计费引用 + +### Requirement: 在无旧写入窗口排空运行任务 + +Cutover SHALL 先进入受控只读/维护模式,拒绝新的 legacy Conversation 写入和 Generation 开始,再等待非终态 Generation 正常完成、停止或由既有规则收敛。直到旧系统没有未确认的模型执行、checkpoint 或计费事务,系统 MUST NOT 执行最终数据导入和 authority 切换。 + +#### Scenario: 维护开始时仍有运行任务 + +- **WHEN** legacy Generation 仍为 `running` 或 `stop_requested` +- **THEN** cutover 等待或按声明策略停止/收敛,并在终态与计费可审计后继续 + +#### Scenario: 维护期间旧客户端发送写入 + +- **WHEN** 旧客户端尝试 PUT 整树或开始 Generation +- **THEN** 系统返回稳定维护响应且不接受新事实 + +### Requirement: 规范 Conversation 必须成为唯一权威 + +Cutover 后,所有读取、命令、Generation、计费和客户端页面 SHALL 只使用规范 Conversation 实体。authority 模式 MUST 在同一部署中互斥;系统不得双写、双读择优、从 legacy 回填缺失规范记录,或运行两个 Store 相互同步。 + +#### Scenario: 规范记录缺失 + +- **WHEN** cutover 后请求的 Conversation 无规范记录但旧 JSON 仍有备份 +- **THEN** 系统返回规范未找到/数据错误并告警,不在请求路径回退读取旧 JSON + +#### Scenario: 配置同时启用两套写入 + +- **WHEN** 部署配置尝试同时启用 legacy 与 canonical authority +- **THEN** 应用启动或健康门禁失败,部署不得接收流量 + +### Requirement: Cutover 后保持目标行为矩阵 + +规范路径 SHALL 跑通 Conversation 列表/标题/归档、Thread 列/画布/归档、A → B → C Fork、锚点、Turn/Message 变体、Message actions、Markdown/Artifact/研究活动、Generation 流式/Stop/刷新恢复、反馈、所有者隔离和计费幂等。行为以新 specs 为准,不要求保留旧数据结构或已确认的错误语义。 + +#### Scenario: 切换后停止部分回答并刷新 + +- **WHEN** 用户在规范路径停止已有 partial 的 Generation 并刷新 +- **THEN** 相同 Thread/Turn/Message、`incomplete` 内容、研究活动、usage 完整度和 `stopped` 状态被恢复 + +#### Scenario: 切换后嵌套 Fork + +- **WHEN** 用户从根 Thread Fork B,再从 B Fork C +- **THEN** 列和画布显示两条 ThreadFork 的正确来源,服务端不存在重复 parent/children/Fork 写入 + +### Requirement: 旧 ThreadTree 协议必须明确退役 + +Authority 切换后,整树写入、旧 Generation 协调、active-leaf 切换和 legacy reconcile 路由 SHALL 被关闭,并对旧客户端返回稳定 `legacy_protocol_retired` 结果或不存在结果。旧协议不得在错误、缺失记录或回滚时重新启用为生产写入入口。 + +#### Scenario: 旧客户端提交整树 PUT + +- **WHEN** cutover 后旧客户端调用 legacy branch-tree 写路由 +- **THEN** 系统拒绝请求并返回稳定 retired 响应,不修改规范或备份数据 + +#### Scenario: 新客户端正常加载 + +- **WHEN** canonical 客户端加载 Conversation +- **THEN** 它只调用规范 snapshot/command/Generation API,不探测或调用旧路由 + +### Requirement: 切换期必须可观测和可审计 + +系统 SHALL 记录 authority epoch/schema、请求路径、命令错误分类、revision 冲突、Generation 终态/checkpoint age、stale 收敛、usage 对账、导入计数和完整性失败。发布 SHALL 使用预先记录的阈值和观察窗口判断继续、只读或前滚修复,日志不得泄漏 Message 内容或凭据。 + +#### Scenario: 切换后 usage_unavailable 异常上升 + +- **WHEN** 计费完整度指标超过批准阈值 +- **THEN** 发布进入声明的保护动作并保留规范记录用于诊断,不切回旧 JSON 结算 + +#### Scenario: 审计查询单次 Fork + +- **WHEN** 运维按 requestId/commandId 调查 Fork +- **THEN** 可以关联 actor、Conversation revision、ThreadFork/outbox 事件和结果,而无需读取整树 mutation diff + +### Requirement: 回滚不得重新引入双重事实源 + +切换前的回滚可以继续使用未修改的 legacy authority;一旦 canonical 接受生产写入,回滚 SHALL 保持规范数据库为事实源,通过关闭写入、回滚应用代码兼容层、恢复规范备份或前滚修复处理。系统 MUST NOT 把新规范写入反向合并回旧 JSON 后恢复旧权威。 + +#### Scenario: 切换后客户端渲染故障 + +- **WHEN** canonical 数据正确但新客户端出现严重回归 +- **THEN** 发布回滚/修复客户端或进入只读,服务端规范实体继续保持权威 + +#### Scenario: 切换后发现规范数据损坏 + +- **WHEN** 完整性检查发现不可接受的规范写入损坏 +- **THEN** 系统停止相关写入并从已验证规范备份恢复或执行审计前滚,不从落后的 legacy JSON 覆盖 + +### Requirement: 遗留代码和表分阶段删除 + +系统 SHALL 在 authority 切换成功后先证明旧路由零调用、无代码写引用且备份可恢复,再按声明保留期删除 ThreadTree 领域类型、浏览器 persistence/reconcile、旧仓储和重复关系字段。物理表删除 MUST 经过依赖清单确认;名称相似但仍服务其他能力的表不得被顺带删除。 + +#### Scenario: 观察期仍有旧路由调用 + +- **WHEN** 指标显示受支持客户端仍调用 branch-tree route +- **THEN** 系统保持路由拒绝状态并追踪调用方,不提前删除诊断信息或备份 + +#### Scenario: 删除旧表前发现外部依赖 + +- **WHEN** 依赖扫描发现某个 billing/feedback job 仍读取 `branch_generations` +- **THEN** 表删除门禁失败,先迁移该 job 并重新验证 + +### Requirement: OpenSpec 历史必须如实标记替代关系 + +依赖 ThreadTree 权威的完成 changes SHALL 作为历史实现记录保留,并在目标 specs 生效顺序中由新链覆盖。未完成的 `persist-thread-chat-generations` MUST 明确标记为被 `migrate-generation-lifecycle` 与本 cutover 替代;系统不得仅为归档而勾选未实现任务或把旧机制继续发布为当前契约。 + +#### Scenario: 处理未完成旧 Generation change + +- **WHEN** cutover 准备归档 OpenSpec 记录 +- **THEN** 文档保留其真实未完成项和 superseded 原因,并使新的 lifecycle spec 成为目标权威 + diff --git a/openspec/changes/retire-thread-tree-authority/tasks.md b/openspec/changes/retire-thread-tree-authority/tasks.md new file mode 100644 index 00000000..3a91d18c --- /dev/null +++ b/openspec/changes/retire-thread-tree-authority/tasks.md @@ -0,0 +1,60 @@ +## 1. 前置实现与发布门禁 + +- [ ] 1.1 验证 `define-conversation-domain-model`、规范持久化、Generation lifecycle、命令 API 和 normalized client 的必需任务及 strict specs 全部完成。 +- [ ] 1.2 建立目标行为矩阵,关联认证、生命周期、列/画布、A → B → C Fork、变体、actions、Artifact/研究、Generation/计费的自动测试和 smoke。 +- [ ] 1.3 记录当前容量/性能/错误/计费基线、cutover 阈值、负责人、观察窗口和 go/no-go checklist。 +- [ ] 1.4 演练 canonical 备份恢复、维护模式、authority epoch mismatch 和切换后只读/前滚回滚流程。 + +## 2. 数据审计与处置 ADR + +- [ ] 2.1 在目标环境运行只读 legacy 审计,输出所有者、Conversation/Thread/Message/Generation、标题、反馈、Artifact 和 usage 数量。 +- [ ] 2.2 逐项处理或明确阻塞悬空、跨所有者、重复 ID、错误 Fork/active path 和计费引用。 +- [ ] 2.3 基于保留义务形成“确定性导入或受批准重置”ADR,记录范围、理由、批准人、备份和排除项。 +- [ ] 2.4 将 ADR 结果转成版本化 cutover 配置,不在代码中隐式猜测环境或数据处置方式。 + +## 3. 确定性导入或重置工具 + +- [ ] 3.1 实现 `(legacyTreeId, entityType, localId) → canonicalId` 的持久映射与幂等 dry-run/report。 +- [ ] 3.2 实现按 Conversation 事务导入 Project/Conversation/Thread/ThreadFork/Turn/Message 和当前有效变体。 +- [ ] 3.3 迁移 Generation、标题、Message feedback、Artifact、usage/billing 和其他审计确认的 sidecar 外键。 +- [ ] 3.4 实现受批准重置路径,仅接受 ADR scope 并在执行前验证目标环境与备份标识。 +- [ ] 3.5 增加 A → B → C、Message 变体、partial Generation、辅助引用、重复运行和故障回滚测试。 +- [ ] 3.6 实现源/目标计数、映射、约束、悬空引用和摘要的全量 post-import verifier。 + +## 4. Authority、维护与旧协议拒绝 + +- [ ] 4.1 实现单值 `legacy | canonical` authority 组合根、schemaVersion/cutover epoch 健康信息和非法双 authority 启动失败。 +- [ ] 4.2 实现维护模式,拒绝新 legacy mutations/Generation,同时保留必要只读和安全认证语义。 +- [ ] 4.3 实现非终态 Generation drain/Stop/stale 收敛与 usage/计费完成检查,未清零时阻止 cutover。 +- [ ] 4.4 为旧整树、active-leaf、Generation sidecar 和 reconcile routes 实现安全 `legacy_protocol_retired` 拒绝与遥测。 +- [ ] 4.5 让 canonical 客户端 boot 校验服务端 authority/schema/epoch,不匹配时不得发送写命令。 + +## 5. Cutover 演练与正式执行 + +- [ ] 5.1 在与生产等价的隔离环境完整演练冻结、drain、备份、import/reset、验证、authority/client 切换和 smoke,并记录耗时。 +- [ ] 5.2 正式进入维护窗口,冻结旧写入并确认所有 Generation、checkpoint、outbox 和计费事务已收敛。 +- [ ] 5.3 创建并验证 legacy/canonical 备份,执行 ADR 选定的数据动作和全量 post-import verifier。 +- [ ] 5.4 原子启用 canonical server/client epoch,对内部 canary 跑行为矩阵关键 smoke 后开放流量。 +- [ ] 5.5 验证 cutover 后所有读取、命令、Generation 和 billing jobs 只访问 canonical repositories。 + +## 6. 观察与回滚保护 + +- [ ] 6.1 建立 authority mismatch、命令错误、revision/idempotency 冲突、Generation age/checkpoint、Stop/stale 和 usage 对账 dashboard/告警。 +- [ ] 6.2 监控旧 route 调用、数据库查询和代码路径,记录陈旧客户端/任务调用方并完成迁移。 +- [ ] 6.3 验证切换后回滚工具不会执行 canonical → ThreadTree 反向同步,也不会把落后 legacy 数据恢复为权威。 +- [ ] 6.4 在声明观察窗口内完成逐日完整性/计费审计,并由负责人批准进入遗留删除阶段。 + +## 7. 删除遗留运行时代码 + +- [ ] 7.1 删除 canonical build 中的 `ThreadTreeState`、魔法 `main`、parent/children/Message forks/depth/activeLeaf 等重复领域事实和 selector。 +- [ ] 7.2 删除 Tree Store mutation、`persistNow`、save debounce、卸载 flush、startup reconcile 和 Generation merge 协调代码。 +- [ ] 7.3 删除旧 branch-tree/Generation 写路由、仓储和新代码对 legacy types 的导入,保留历史迁移文件不重写。 +- [ ] 7.4 运行 `rg`/依赖检查证明运行时代码无旧 authority 读写,并通过完整行为矩阵、typecheck、build 和测试。 + +## 8. 物理清理与 OpenSpec 收口 + +- [ ] 8.1 审计 `branch_trees`、`branch_generations` 及名称相似表的全部 FK、job、查询和备份依赖,形成精确删除清单。 +- [ ] 8.2 在备份保留/恢复验证和零依赖门禁通过后,执行分阶段不可逆迁移删除确认遗留表、列、索引和约束。 +- [ ] 8.3 建立旧 OpenSpec supersession map;保留已完成历史,明确记录 `persist-thread-chat-generations` 未完成项被新 lifecycle/cutover 替代,不补勾任务。 +- [ ] 8.4 按依赖顺序归档新 changes,验证最终 specs 以 Project → Conversation → Thread 模型和 canonical capabilities 为权威。 +- [ ] 8.5 运行最终 `pnpm openspec:validate`、schema 漂移检查、生产 smoke 和数据完整性/计费审计并记录结果。 From 78d5523ed259079fd1c500f18f20a1870290f561 Mon Sep 17 00:00:00 2001 From: zilin <276161014@qq.com> Date: Sat, 22 Aug 2026 01:31:22 +0800 Subject: [PATCH 02/32] refactor(domain): define canonical conversation model --- CLAUDE.md | 2 +- README.md | 6 +- constants/conversation-domain.ts | 6 + .../conversation-domain-glossary.md | 74 +++ .../conversation-migration-inputs.md | 44 ++ lib/thread-chat/client/ui-workspace.ts | 18 + .../domain/conversation-model.test.ts | 366 ++++++++++++++ lib/thread-chat/domain/conversation-model.ts | 246 +++++++++ .../domain/conversation-selection.ts | 76 +++ lib/thread-chat/domain/conversation-title.ts | 32 ++ .../domain/conversation-validation.ts | 470 ++++++++++++++++++ lib/thread-chat/domain/message-graph.ts | 11 +- lib/thread-chat/domain/types.ts | 8 +- lib/thread-chat/legacy/project-thread-tree.ts | 362 ++++++++++++++ .../add-branch-tree-persistence/proposal.md | 4 + .../changes/add-bubble-composer/proposal.md | 4 + .../add-canvas-conversations/proposal.md | 4 + .../add-markdown-artifacts/proposal.md | 2 + .../proposal.md | 3 + openspec/changes/add-tree-list-ui/proposal.md | 4 + .../define-conversation-domain-model/tasks.md | 46 +- .../proposal.md | 3 + package.json | 2 + scripts/audit-thread-tree-boundary.mjs | 62 +++ scripts/thread-tree-legacy-allowlist.mjs | 17 + 25 files changed, 1836 insertions(+), 36 deletions(-) create mode 100644 constants/conversation-domain.ts create mode 100644 docs/architecture/conversation-domain-glossary.md create mode 100644 docs/architecture/conversation-migration-inputs.md create mode 100644 lib/thread-chat/client/ui-workspace.ts create mode 100644 lib/thread-chat/domain/conversation-model.test.ts create mode 100644 lib/thread-chat/domain/conversation-model.ts create mode 100644 lib/thread-chat/domain/conversation-selection.ts create mode 100644 lib/thread-chat/domain/conversation-title.ts create mode 100644 lib/thread-chat/domain/conversation-validation.ts create mode 100644 lib/thread-chat/legacy/project-thread-tree.ts create mode 100644 scripts/audit-thread-tree-boundary.mjs create mode 100644 scripts/thread-tree-legacy-allowlist.mjs diff --git a/CLAUDE.md b/CLAUDE.md index 1a7af2f5..b0300233 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,4 +84,4 @@ Drizzle ORM + Postgres backs chat history so threads survive page reloads (previ - Client: `lib/db/index.ts` — a global-singleton `postgres`/drizzle client so dev HMR doesn't exhaust Postgres connections. - Migrations: `drizzle.config.ts` + `drizzle/` (see `pnpm db:*` scripts above). - Persistence wiring: `lib/chat/thread-list-adapter.ts` implements assistant-ui's `RemoteThreadListAdapter` (list/rename/archive/delete/initialize/fetch/generateTitle) against `app/api/threads/*` route handlers. `lib/chat/use-thread-history-adapter.ts` implements `ThreadHistoryAdapter.withFormat()` for per-thread message load/append. Both are composed in `app/page.tsx` via `useRemoteThreadListRuntime({ runtimeHook: () => useChatRuntime({ adapters: { history } }), adapter })` — `useChatRuntime`'s own internal remote-thread-list wrapper detects it's nested (`allowNesting: true`) and no-ops, so this composition doesn't conflict with it. -- Branch-tree persistence (`app/thread-chat/`): `branch_trees` stores each branch-conversation tree as **one owner-scoped row of whole-tree JSON** (`state` = full `ThreadTreeState`) — completely separate from the assistant-ui `threads`/`messages` tables. Tree identity lives in the URL (`/thread-chat/{treeId}`, client-generated UUID); localStorage only remembers the last tree id and per-tree workbench UI. Historical null-owner rows are never listed and may be atomically claimed only by opening their exact URL. `branch_generations` is the authoritative per-attempt sidecar: it stores the verified turn snapshot, lifecycle/heartbeat, versioned structured result, and billing state. The browser must strictly save the user message + assistant placeholder before posting `/api/chat`; refreshing only detaches the browser fetch, while the server-owned stream consumer continues the model, finalizes/charges once, and lets tree GET or generation polling merge the current terminal patch by `generationId` CAS. Only the explicit Stop API requests model abort. P0 restores the completed structured answer after refresh; it deliberately does not replay missed token deltas as a live stream (that is a separate resumable-stream/P1 protocol). Titles remain **dual-track**: debounced PUT writes derived `title`, rename PATCH writes `custom_title`, and display uses `coalesce(custom_title, title)`. Before deploying this contract, run `pnpm db:migrate`; do not deploy the new client before the owner/generation APIs and migration. +- **Legacy branch-tree persistence(Issue #34 迁移期间的临时权威)** (`app/thread-chat/`): 当前运行时仍由 `branch_trees` 以 **one owner-scoped row of whole-tree JSON** 保存完整 `ThreadTreeState`;这描述的是迁移前事实,不是目标架构。规范术语、所有权与遗留字段映射见 `docs/architecture/conversation-domain-glossary.md`,后续迁移输入见 `docs/architecture/conversation-migration-inputs.md`。在 `retire-thread-tree-authority` 完成前,它仍与 assistant-ui 的 `threads`/`messages` 表完全分开。Tree identity lives in the URL (`/thread-chat/{treeId}`, client-generated UUID); localStorage only remembers the last tree id and per-tree workbench UI. Historical null-owner rows are never listed and may be atomically claimed only by opening their exact URL. `branch_generations` is the authoritative per-attempt sidecar: it stores the verified turn snapshot, lifecycle/heartbeat, versioned structured result, and billing state. The browser must strictly save the user message + assistant placeholder before posting `/api/chat`; refreshing only detaches the browser fetch, while the server-owned stream consumer continues the model, finalizes/charges once, and lets tree GET or generation polling merge the current terminal patch by `generationId` CAS. Only the explicit Stop API requests model abort. P0 restores the completed structured answer after refresh; it deliberately does not replay missed token deltas as a live stream (that is a separate resumable-stream/P1 protocol). Titles remain **dual-track**: debounced PUT writes derived `title`, rename PATCH writes `custom_title`, and display uses `coalesce(custom_title, title)`. Before deploying this contract, run `pnpm db:migrate`; do not deploy the new client before the owner/generation APIs and migration. diff --git a/README.md b/README.md index 5324a1ff..1b29e25d 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,9 @@ The project is a Next.js 16 App Router application using React, TypeScript, Tail | Boundary | Location | Responsibility | | ------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | -| Core | [`app/thread-chat/core/`](./app/thread-chat/core/) | Tree state, selectors, and the branch-conversation store | +| Legacy core | [`app/thread-chat/core/`](./app/thread-chat/core/) | Issue #34 迁移期间的临时整树权威、选择器与 branch-conversation store;不是目标领域模型 | +| Domain | [`lib/thread-chat/domain/`](./lib/thread-chat/domain/) | 规范 Conversation 实体、不变量、标题与变体选择契约 | +| Legacy import | [`lib/thread-chat/legacy/`](./lib/thread-chat/legacy/) | 遗留 Thread Tree 到规范 Conversation 快照的单向只读投影 | | Branching | [`app/thread-chat/branching/`](./app/thread-chat/branching/) | `selection/` owns text anchors and selection UI; `assistant/` owns branch-aware assistant rendering | | Chat | [`app/thread-chat/chat/`](./app/thread-chat/chat/) | `message/`, `composer/`, and `actions/` group the three conversation feature sets | | Orchestration | [`app/thread-chat/orchestration/`](./app/thread-chat/orchestration/) | `canvas/`, `columns/`, `navigation/`, `artifacts/`, `overlays/`, and `workspace/` compose the workbench | @@ -131,6 +133,8 @@ Detailed design material is available in the repository: - [ChatPDF research](./docs/chatpdf/01-调研报告.md) and [design](./docs/chatpdf/02-设计方案.md) - [Deep research design](./docs/deep-research/设计说明.md) +- [Conversation 领域术语与迁移边界](./docs/architecture/conversation-domain-glossary.md) +- [Issue #34 后续迁移输入清单](./docs/architecture/conversation-migration-inputs.md) - [OpenSpec change records](./openspec/changes/) - [Project development guidance](./CLAUDE.md) diff --git a/constants/conversation-domain.ts b/constants/conversation-domain.ts new file mode 100644 index 00000000..6c1ed890 --- /dev/null +++ b/constants/conversation-domain.ts @@ -0,0 +1,6 @@ +/** 规范 Conversation 快照的首个结构版本。 */ +export const CONVERSATION_SNAPSHOT_SCHEMA_VERSION = 1 as const + +/** 遗留 JSON 没有实体时间戳时使用的显式未知值,保证投影可重复。 */ +export const LEGACY_PROJECTION_UNKNOWN_TIMESTAMP = + "1970-01-01T00:00:00.000Z" as const diff --git a/docs/architecture/conversation-domain-glossary.md b/docs/architecture/conversation-domain-glossary.md new file mode 100644 index 00000000..ffa5c26c --- /dev/null +++ b/docs/architecture/conversation-domain-glossary.md @@ -0,0 +1,74 @@ +# Conversation 领域术语与迁移边界 + +本文是 Issue #34 迁移链的开发者术语入口。需求级规范仍以 `openspec/specs/domain/spec.md` 及其已应用增量为唯一正式来源;本文解释代码命名、所有权和遗留字段如何迁移。新设计不得继续把遗留 `ThreadTreeState` 当成最终架构。 + +## 规范聚合关系 + +```text +Workspace +└── Project + ├── ProjectInstructionVersion + ├── MemoryItem + ├── ProjectFile + └── Conversation + ├── Thread(rootThreadId 指向主 Thread) + │ └── Turn + │ ├── Message(user,可有同 Turn 变体) + │ └── Message(assistant,可有同 Turn 变体) + │ └── Generation(一次服务端执行尝试) + └── ThreadFork(父 Thread + 来源 Message → 子 Thread) + └── Thread(界面上扮演 branch 角色) +``` + +`ConversationSnapshot` 是由规范实体组装的只读读取模型,不是整包写入载荷。界面显示成树,不代表数据库应保存一棵可变 JSON 树。 + +## 术语定义 + +| 术语 | 唯一职责与身份 | +| --- | --- | +| Workspace | 顶层租户与授权边界,拥有 Project。 | +| Project | 工作上下文,拥有 Conversation、版本化 ProjectInstruction、MemoryItem 与 ProjectFile;它不只是 Conversation 文件夹。 | +| Conversation | 一整个可命名、列出、归档并最终分享的分叉对话聚合,使用稳定 `ConversationId`,通过 `rootThreadId` 指定主 Thread。 | +| Thread | 用户看到的一列、可独立继续的线性对话。主线和分支使用同一实体;“branch”只是相对于某条 `ThreadFork` 的角色。 | +| ThreadFork | 唯一的分叉事实,记录父 Thread、来源 Message 和子 Thread;一个非根 Thread 恰有一个入向 Fork。 | +| Turn | 同一 Thread 内一个逻辑问答位置,约束 user/assistant Message 变体,并分别选择当前有效变体。 | +| Message | 持久内容记录,拥有稳定 ID、Thread/Turn 归属、角色、可版本化内容片段和内容状态。 | +| Generation | 一次服务端模型执行尝试,绑定真实 Thread、Turn、输入 Message 与输出 Message;停止、恢复、用量和计费属于它。 | +| Artifact | 从 Message 产出的交付物;来源 Thread 与 Message 必须可验证。后续是否提升为 ProjectFile 由独立变更决定。 | +| ProjectFile | Project 可跨 Conversation 使用的持久文件,可保留来源 Message/Generation。 | +| MemoryItem | Project 范围内可演进、可取代和归档的记忆条目。 | +| ProjectInstructionVersion | Project 指令的不可变版本记录。 | + +## 不变量 + +- ID 是不携带角色含义的不透明稳定身份;`main`、数组下标、局部计数器和浏览器复合键不是公开资源 ID。 +- `Conversation.rootThreadId` 决定主 Thread;根 Thread 不重复保存 Conversation 导航标题。 +- `ThreadFork` 是唯一 Fork 事实。不得同时把可写 `parentId`、`children`、`Message.forks` 当作权威关系。 +- 当前 Message 变体属于领域状态,由 Turn 的版本化选择决定;可见列、折叠、画布视口属于客户端 UI workspace。 +- Generation 输入输出必须与同一 Thread/Turn 对齐,不能通过 JSON 内部 ID 猜测归属。 + +## 遗留模型 → 规范模型 + +| 遗留字段或概念 | 规范目标 | 迁移规则 | +| --- | --- | --- | +| `ThreadTreeState` / `branch_trees.state` | 规范实体 + `ConversationSnapshot` 读取模型 | 只允许作为迁移输入;禁止规范模型反向写回整树。 | +| `branch_trees.id` | `ConversationId` 候选来源 | 数据迁移决定是否保留值;不能继续等同于整树存储身份。 | +| `threads.main` | `Conversation.rootThreadId` | 把 `main` 角色解析成一个普通稳定 `ThreadId`;规范 ID 构造器拒绝字面值 `main`。 | +| `Thread.parentId` / `children` | `ThreadFork.parentThreadId` / `childThreadId` | 仅迁移适配器读取;规范写模型只写一条 Fork 关系。 | +| `forkFromMsgId` / `Message.forks` | `ThreadFork.sourceMessageId` | 合并并核验为同一来源 Message;重复或错配立即失败。 | +| `depth` / `footnote` | 派生索引或 UI 展示 | 从 Fork 图重建,不作为领域事实持久化。 | +| `activeLeafMessageId` | `Turn.activeUserMessageId` + `activeAssistantMessageId` | 按同 Thread/Turn 的活动路径投影,选择更新使用 Turn revision。 | +| Message 父子图 | `Turn` + Message 变体 | user 编辑和 assistant 重生成保留为同 Turn 的独立 Message;不能跨 Thread/Turn。 | +| Artifact registry 的 `sourceThreadId/sourceMessageId` | `ConversationArtifactProvenance` | 转换为稳定实体 ID,并校验来源 Message 属于来源 Thread。 | +| `branch_generations` 指向 JSON 内部 ID | `Generation` 的真实外键 | 后续生命周期变更迁移;本 change 只定义契约与校验。 | +| localStorage 的列、折叠、画布状态 | `ConversationUiWorkspace` | 永远保持在客户端界面状态层,不进入领域实体。 | + +当前唯一单向投影入口是 `lib/thread-chat/legacy/project-thread-tree.ts`。允许继续读取旧字段的领域/应用文件由 `scripts/thread-tree-legacy-allowlist.mjs` 明示,`pnpm audit:conversation-domain` 会阻止新引用越界。 + +## 模块边界 + +- `lib/thread-chat/domain/conversation-*.ts`:纯领域 ID、实体、不变量、标题与变体选择;不依赖 React、Next.js、HTTP 或 Drizzle。 +- `lib/thread-chat/legacy/`:只读迁移、审计与固定样例;不得提供规范模型写回遗留树的函数。 +- `lib/thread-chat/application/`:后续承载带事务语义的命令编排,不拥有数据库形状或 UI workspace。 +- `lib/thread-chat/client/`:可见列、折叠、画布视口和临时交互选择。 +- 持久化、HTTP 和 React 边界分别实现领域协议,不在领域层反向定义实体。 diff --git a/docs/architecture/conversation-migration-inputs.md b/docs/architecture/conversation-migration-inputs.md new file mode 100644 index 00000000..6302cae1 --- /dev/null +++ b/docs/architecture/conversation-migration-inputs.md @@ -0,0 +1,44 @@ +# Issue #34 后续迁移输入清单 + +本清单固定 `define-conversation-domain-model` 输出给后续 change 的边界。后续 change 可以细化实现,但不得重新引入整树写入权威或第二套 Fork 事实。 + +## 1. normalize-conversation-persistence + +- 逻辑实体:projects、conversations、conversation_threads、thread_forks、turns、messages、generations,以及 Artifact 来源关系。 +- 数据库约束:所有 owner/project/conversation 归属、复合外键、根 Thread、非根单一入向 Fork、来源 Message 属于父 Thread、Message/Turn/Generation 同 Thread。 +- 真实数据审计:魔法 `main`、悬空 parent/source、重复 Fork、环、跨 Thread 变体、孤儿 Generation、无效 Artifact 来源。 +- 在审计结果可见后明确选择一次性迁移或开发期重置,不以双写掩盖未知数据。 +- 规范实体组装 `ConversationSnapshot`;快照只读,不接受整包 PUT。 +- 回滚边界:启用规范写入后保留规范数据库,通过兼容读取、只读或前滚修复回滚应用,不反向恢复整树权威。 + +## 2. migrate-generation-lifecycle + +- 把 Generation 改为真实 Thread、Turn、输入 Message、输出 Message 外键。 +- 覆盖启动、持久化屏障、心跳、停止请求、终态、过期恢复与部分输出。 +- `usage_unavailable` 是显式终态;计费按 Generation 幂等结算,不能依赖浏览器是否仍连接。 +- 对照 PR #30 逐项关闭刷新恢复、停止、CAS、终态协调合并等剩余语义缺口;不再以 JSON 内部节点作为身份保证。 + +## 3. add-conversation-command-api + +- 命令:`forkThread`、`createTurn`、`regenerateAssistant`、`editUserMessage`、`selectVariant`、`archiveConversation`、`archiveThread`、`stopGeneration`。 +- 每个命令包含 owner 校验、幂等键、预期 revision、稳定错误码和最小实体增量。 +- `forkThread` 在同一服务端事务创建子 Thread 与 ThreadFork、记录来源 Message、初始化上下文、推进 Conversation revision,并写事务事件箱。 +- API 不接受客户端提交完整 Conversation 快照;通知事件只在提交成功后发布。 + +## 4. normalize-conversation-client-state + +- 客户端缓存规范实体及可重建索引,所有选择器按稳定 ID 定位。 +- 领域状态与 `ConversationUiWorkspace` 分离;列顺序、折叠、画布视口和临时 selection 不上传成实体。 +- 乐观命令保存幂等键和预期 revision,冲突时用服务端实体增量协调。 +- 依次迁移列、画布、Message 操作、标题和 Artifact 来源消费,不再从 `parentId/children/Message.forks` 推断权威事实。 + +## 5. retire-thread-tree-authority + +- 切换前完成数据一致性审计,并排空或安全接管运行中 Generation。 +- 禁用整树 PUT、删除整树协调合并和防抖保存,移除重复 Fork 字段的写路径。 +- 所有读写、恢复、列表和标题功能只访问规范实体;之后删除遗留允许列表和单向适配器。 +- 删除旧表或字段前保留可验证备份,并用服务端 API 与 UI 回归证明不存在旧权威读路径。 + +## 6. 基础迁移后的独立提案 + +以下能力依赖稳定身份,但不阻塞 Issue #34 当前迁移链:Project Memory、Project File、Project Instruction、公开数据 API、访问令牌、CLI、MCP,以及 Issue #39 的 Conversation 发散后收敛机制。它们必须分别提出权限、版本、生命周期和审计设计,不能提前塞进本次领域迁移。 diff --git a/lib/thread-chat/client/ui-workspace.ts b/lib/thread-chat/client/ui-workspace.ts new file mode 100644 index 00000000..61568c7d --- /dev/null +++ b/lib/thread-chat/client/ui-workspace.ts @@ -0,0 +1,18 @@ +import type { ConversationId, ThreadId } from "../domain/conversation-model.ts" + +/** + * 纯展示状态。这里的任何字段都不得参与 Conversation revision 或领域写入。 + */ +export interface ConversationUiWorkspace { + readonly conversationId: ConversationId + readonly visibleThreadIds: readonly ThreadId[] + readonly foldedThreadIds: readonly ThreadId[] + readonly selectedThreadId: ThreadId | null + readonly canvasViewport: Readonly<{ + x: number + y: number + zoom: number + }> + readonly openPanels: readonly string[] + readonly draftsByThreadId: Readonly> +} diff --git a/lib/thread-chat/domain/conversation-model.test.ts b/lib/thread-chat/domain/conversation-model.test.ts new file mode 100644 index 00000000..00ca89af --- /dev/null +++ b/lib/thread-chat/domain/conversation-model.test.ts @@ -0,0 +1,366 @@ +import assert from "node:assert/strict" +import test from "node:test" + +import { THREAD_TREE_SCHEMA_VERSION } from "../../../constants/thread-chat.ts" +import { + conversationId, + generationId, + messageId, + projectId, + threadForkId, + threadId, + turnId, + workspaceId, + type ConversationSnapshot, +} from "./conversation-model.ts" +import { selectActiveVariant } from "./conversation-selection.ts" +import { + resolveConversationTitle, + resolveThreadTitle, +} from "./conversation-title.ts" +import { + assertValidConversationSnapshot, + validateConversationSnapshot, +} from "./conversation-validation.ts" +import type { ThreadTreeState } from "./types.ts" +import { projectLegacyThreadTree } from "../legacy/project-thread-tree.ts" + +const WORKSPACE_ID = workspaceId("workspace-fixture") +const PROJECT_ID = projectId("project-fixture") +const CONVERSATION_ID = conversationId("conversation-fixture") + +function legacyFixture(): ThreadTreeState { + return { + schemaVersion: THREAD_TREE_SCHEMA_VERSION, + threads: { + main: { + id: "main", + modelId: "ark-glm-5.3", + parentId: null, + depth: 0, + title: "遗留根标题", + anchorText: null, + forkFromMsgId: null, + footnote: null, + children: ["A"], + messages: [ + { + id: "root-user", + parentMessageId: null, + role: "user", + text: "根问题", + forks: [], + }, + { + id: "root-assistant", + parentMessageId: "root-user", + role: "assistant", + text: "根回答", + forks: [ + { + text: "根回答", + num: 1, + threadId: "A", + depth: 1, + }, + ], + }, + ], + activeLeafMessageId: "root-assistant", + lastActive: 1, + }, + A: { + id: "A", + modelId: "ark-glm-5.3", + parentId: "main", + depth: 1, + title: "分支 A", + anchorText: "根回答", + forkFromMsgId: "root-assistant", + footnote: 1, + children: ["B"], + messages: [ + { + id: "a-user", + parentMessageId: null, + role: "user", + text: "A 问题", + forks: [], + }, + { + id: "a-assistant", + parentMessageId: "a-user", + role: "assistant", + text: "A 回答", + forks: [ + { + text: "A 回答", + num: 2, + threadId: "B", + depth: 2, + }, + ], + }, + ], + activeLeafMessageId: "a-assistant", + lastActive: 2, + }, + B: { + id: "B", + modelId: "ark-glm-5.3", + parentId: "A", + depth: 2, + title: "分支 B", + anchorText: "A 回答", + forkFromMsgId: "a-assistant", + footnote: 2, + children: [], + messages: [ + { + id: "b-user", + parentMessageId: null, + role: "user", + text: "B 问题", + forks: [], + }, + { + id: "b-assistant-1", + parentMessageId: "b-user", + role: "assistant", + text: "B 初始回答", + forks: [], + }, + { + id: "b-assistant-2", + parentMessageId: "b-user", + role: "assistant", + text: "B 重新生成回答", + forks: [], + }, + ], + activeLeafMessageId: "b-assistant-2", + lastActive: 3, + }, + }, + artifacts: {}, + artifactOrder: [], + recents: ["B", "A"], + footnoteCounter: 2, + seq: 20, + tick: 3, + } +} + +function canonicalFixture(): ConversationSnapshot { + return projectLegacyThreadTree({ + legacyTreeId: "legacy-fixture", + workspaceId: WORKSPACE_ID, + projectId: PROJECT_ID, + conversationId: CONVERSATION_ID, + projectTitle: "测试 Project", + conversationAutoTitle: "自动标题", + conversationCustomTitle: null, + actorId: "fixture-user", + state: legacyFixture(), + }) +} + +test("单向投影产生合法的 Project → Conversation → Thread 快照", () => { + const snapshot = canonicalFixture() + assert.doesNotThrow(() => assertValidConversationSnapshot(snapshot)) + assert.equal(Object.keys(snapshot.threads).length, 3) + assert.equal(Object.keys(snapshot.threadForks).length, 2) + assert.notEqual(snapshot.conversation.rootThreadId, "main") +}) + +test("B 的重新生成变体不会投影到 A", () => { + const snapshot = canonicalFixture() + const threadA = Object.values(snapshot.threads).find((thread) => + thread.id.endsWith(":thread:A") + ) + const threadB = Object.values(snapshot.threads).find((thread) => + thread.id.endsWith(":thread:B") + ) + assert.ok(threadA) + assert.ok(threadB) + + const assistantsInA = Object.values(snapshot.messages).filter( + (message) => message.threadId === threadA.id && message.role === "assistant" + ) + const assistantsInB = Object.values(snapshot.messages).filter( + (message) => message.threadId === threadB.id && message.role === "assistant" + ) + assert.equal(assistantsInA.length, 1) + assert.equal(assistantsInB.length, 2) + assert.equal(assistantsInB[1]?.variantOfMessageId, assistantsInB[0]?.id) +}) + +test("重复入向 Fork 与 Fork 环会被拒绝", () => { + const snapshot = canonicalFixture() + const rootThreadId = snapshot.conversation.rootThreadId + const branchB = Object.values(snapshot.threads).find((thread) => + thread.id.endsWith(":thread:B") + ) + const rootAssistant = Object.values(snapshot.messages).find( + (message) => + message.threadId === rootThreadId && message.role === "assistant" + ) + assert.ok(branchB) + assert.ok(rootAssistant) + const duplicateId = threadForkId("fixture-duplicate-fork") + const cyclicId = threadForkId("fixture-cyclic-fork") + const invalid: ConversationSnapshot = { + ...snapshot, + threadForks: { + ...snapshot.threadForks, + [duplicateId]: { + id: duplicateId, + conversationId: snapshot.conversation.id, + parentThreadId: rootThreadId, + sourceMessageId: rootAssistant.id, + childThreadId: branchB.id, + createdBy: "fixture-user", + createdAt: "1970-01-01T00:00:00.000Z", + }, + [cyclicId]: { + id: cyclicId, + conversationId: snapshot.conversation.id, + parentThreadId: branchB.id, + sourceMessageId: Object.values(snapshot.messages).find( + (message) => + message.threadId === branchB.id && message.role === "assistant" + )!.id, + childThreadId: rootThreadId, + createdBy: "fixture-user", + createdAt: "1970-01-01T00:00:00.000Z", + }, + }, + } + const codes = new Set( + validateConversationSnapshot(invalid).map((entry) => entry.code) + ) + assert.ok(codes.has("duplicate_incoming_fork")) + assert.ok(codes.has("root_has_incoming_fork")) + assert.ok(codes.has("fork_cycle")) +}) + +test("跨 Thread Generation 会被拒绝", () => { + const snapshot = canonicalFixture() + const rootThread = snapshot.threads[snapshot.conversation.rootThreadId]! + const branchB = Object.values(snapshot.threads).find((thread) => + thread.id.endsWith(":thread:B") + )! + const branchTurn = Object.values(snapshot.turns).find( + (turn) => turn.threadId === branchB.id + )! + const id = generationId("fixture-cross-thread-generation") + const invalid: ConversationSnapshot = { + ...snapshot, + generations: { + [id]: { + id, + threadId: rootThread.id, + turnId: branchTurn.id, + inputMessageId: branchTurn.activeUserMessageId, + outputMessageId: branchTurn.activeAssistantMessageId, + intent: { kind: "send" }, + status: "running", + billingStatus: "pending", + attempt: 1, + createdAt: "1970-01-01T00:00:00.000Z", + }, + }, + } + assert.ok( + validateConversationSnapshot(invalid).some( + (entry) => entry.code === "generation_identity_mismatch" + ) + ) +}) + +test("变体选择使用 Turn revision 并限制在同一 Turn", () => { + const snapshot = canonicalFixture() + const branchB = Object.values(snapshot.threads).find((thread) => + thread.id.endsWith(":thread:B") + )! + const turn = Object.values(snapshot.turns).find( + (candidate) => candidate.threadId === branchB.id + )! + const alternatives = Object.values(snapshot.messages).filter( + (message) => message.turnId === turn.id && message.role === "assistant" + ) + const result = selectActiveVariant(snapshot, { + turnId: turn.id, + messageId: alternatives[0]!.id, + role: "assistant", + expectedRevision: turn.revision, + }) + assert.equal(result.ok, true) + if (result.ok) { + assert.equal(result.turn.activeAssistantMessageId, alternatives[0]!.id) + assert.equal(result.turn.revision, turn.revision + 1) + } + + const conflict = selectActiveVariant(snapshot, { + turnId: turn.id, + messageId: alternatives[0]!.id, + role: "assistant", + expectedRevision: turn.revision + 1, + }) + assert.deepEqual(conflict, { + ok: false, + code: "version_conflict", + currentRevision: turn.revision, + }) +}) + +test("Conversation 独占根列标题,非根 Thread 使用本地标题", () => { + const snapshot = canonicalFixture() + const root = snapshot.threads[snapshot.conversation.rootThreadId]! + const branch = Object.values(snapshot.threads).find( + (thread) => thread.id !== root.id + )! + assert.equal(resolveConversationTitle(snapshot.conversation), "自动标题") + assert.equal( + resolveThreadTitle({ conversation: snapshot.conversation, thread: root }), + "自动标题" + ) + assert.equal( + resolveThreadTitle({ conversation: snapshot.conversation, thread: branch }), + branch.localTitle + ) +}) + +test("规范 Thread ID 构造器拒绝遗留 main 魔法身份", () => { + assert.throws(() => threadId("main"), /遗留角色键/) +}) + +test("跨 Turn Message 不能伪装为同 Turn 变体", () => { + const snapshot = canonicalFixture() + const turns = Object.values(snapshot.turns) + assert.ok(turns.length >= 2) + const targetTurn = turns[0]! + const foreignMessage = snapshot.messages[turns[1]!.activeAssistantMessageId]! + const fakeId = messageId("fixture-cross-turn-message") + const invalid: ConversationSnapshot = { + ...snapshot, + messages: { + ...snapshot.messages, + [fakeId]: { + ...foreignMessage, + id: fakeId, + threadId: targetTurn.threadId, + turnId: targetTurn.id, + variantOfMessageId: foreignMessage.id, + }, + }, + } + assert.ok( + validateConversationSnapshot(invalid).some( + (entry) => entry.code === "message_variant_mismatch" + ) + ) +}) + +// 避免测试数据里的品牌构造器因未来重命名成为未覆盖代码。 +void turnId diff --git a/lib/thread-chat/domain/conversation-model.ts b/lib/thread-chat/domain/conversation-model.ts new file mode 100644 index 00000000..7892c36b --- /dev/null +++ b/lib/thread-chat/domain/conversation-model.ts @@ -0,0 +1,246 @@ +import type { TextAnchor } from "./text-anchor.ts" +import { CONVERSATION_SNAPSHOT_SCHEMA_VERSION } from "../../../constants/conversation-domain.ts" + +declare const entityIdBrand: unique symbol + +export type EntityId = string & { + readonly [entityIdBrand]: Kind +} + +export type WorkspaceId = EntityId<"Workspace"> +export type ProjectId = EntityId<"Project"> +export type ConversationId = EntityId<"Conversation"> +export type ThreadId = EntityId<"Thread"> +export type ThreadForkId = EntityId<"ThreadFork"> +export type TurnId = EntityId<"Turn"> +export type MessageId = EntityId<"Message"> +export type GenerationId = EntityId<"Generation"> +export type ProjectFileId = EntityId<"ProjectFile"> +export type MemoryItemId = EntityId<"MemoryItem"> +export type ProjectInstructionVersionId = EntityId<"ProjectInstructionVersion"> +export type ArtifactId = EntityId<"Artifact"> + +function opaqueId( + kind: Kind, + value: string +): EntityId { + const normalized = value.trim() + if (normalized.length === 0) + throw new InvalidEntityIdError(kind, "ID 不能为空") + if (/\p{C}/u.test(normalized)) + throw new InvalidEntityIdError(kind, "ID 不能包含控制字符") + return normalized as EntityId +} + +export class InvalidEntityIdError extends Error { + readonly kind: string + + constructor(kind: string, message: string) { + super(`${kind}: ${message}`) + this.name = "InvalidEntityIdError" + this.kind = kind + } +} + +export const workspaceId = (value: string): WorkspaceId => + opaqueId("Workspace", value) +export const projectId = (value: string): ProjectId => + opaqueId("Project", value) +export const conversationId = (value: string): ConversationId => + opaqueId("Conversation", value) +export const threadId = (value: string): ThreadId => { + if (value.trim() === "main") + throw new InvalidEntityIdError( + "Thread", + '"main" 是遗留角色键,不能作为规范 ID' + ) + return opaqueId("Thread", value) +} +export const threadForkId = (value: string): ThreadForkId => + opaqueId("ThreadFork", value) +export const turnId = (value: string): TurnId => opaqueId("Turn", value) +export const messageId = (value: string): MessageId => + opaqueId("Message", value) +export const generationId = (value: string): GenerationId => + opaqueId("Generation", value) +export const projectFileId = (value: string): ProjectFileId => + opaqueId("ProjectFile", value) +export const memoryItemId = (value: string): MemoryItemId => + opaqueId("MemoryItem", value) +export const projectInstructionVersionId = ( + value: string +): ProjectInstructionVersionId => opaqueId("ProjectInstructionVersion", value) +export const artifactId = (value: string): ArtifactId => + opaqueId("Artifact", value) + +export type LifecycleStatus = "active" | "archived" +export type MessageRole = "user" | "assistant" | "context" +export type MessageContentState = + "pending" | "streaming" | "complete" | "incomplete" | "failed" +export type GenerationStatus = + | "running" + | "stop_requested" + | "completed" + | "stopped" + | "failed" + | "superseded" +export type GenerationBillingStatus = + "pending" | "settled" | "usage_unavailable" | "not_billable" + +export type JsonPrimitive = string | number | boolean | null +export type JsonValue = + JsonPrimitive | readonly JsonValue[] | { readonly [key: string]: JsonValue } + +export type MessageContentPart = + | { readonly type: "text"; readonly text: string } + | { readonly type: "artifact-reference"; readonly artifactId: ArtifactId } + | { + readonly type: "structured" + readonly kind: string + readonly value: JsonValue + } + +export interface MessageContent { + readonly schemaVersion: 1 + readonly parts: readonly MessageContentPart[] +} + +export interface Workspace { + readonly id: WorkspaceId + readonly revision: number + readonly lifecycle: LifecycleStatus +} + +export interface Project { + readonly id: ProjectId + readonly workspaceId: WorkspaceId + readonly title: string + readonly revision: number + readonly lifecycle: LifecycleStatus +} + +export interface Conversation { + readonly id: ConversationId + readonly projectId: ProjectId + readonly rootThreadId: ThreadId + readonly autoTitle: string | null + readonly customTitle: string | null + readonly revision: number + readonly lifecycle: LifecycleStatus +} + +export interface ConversationThread { + readonly id: ThreadId + readonly conversationId: ConversationId + readonly modelId: string + /** 仅非根 Thread 可以持久化本地列标题。 */ + readonly localTitle: string | null + readonly revision: number + readonly lifecycle: LifecycleStatus +} + +export interface ThreadFork { + readonly id: ThreadForkId + readonly conversationId: ConversationId + readonly parentThreadId: ThreadId + readonly sourceMessageId: MessageId + readonly childThreadId: ThreadId + readonly anchor?: TextAnchor + readonly createdBy: string + readonly createdAt: string +} + +export interface ConversationTurn { + readonly id: TurnId + readonly threadId: ThreadId + readonly position: number + readonly activeUserMessageId: MessageId + readonly activeAssistantMessageId: MessageId + readonly revision: number +} + +export interface ConversationMessage { + readonly id: MessageId + readonly threadId: ThreadId + readonly turnId: TurnId + readonly role: MessageRole + readonly content: MessageContent + readonly contentState: MessageContentState + readonly variantOfMessageId?: MessageId + readonly createdAt: string +} + +export type GenerationIntent = + | { readonly kind: "send" } + | { + readonly kind: "regenerate-assistant" + readonly sourceAssistantMessageId: MessageId + } + | { + readonly kind: "edit-user" + readonly sourceUserMessageId: MessageId + } + | { readonly kind: "retry" } + +export interface ConversationGeneration { + readonly id: GenerationId + readonly threadId: ThreadId + readonly turnId: TurnId + readonly inputMessageId: MessageId + readonly outputMessageId: MessageId + readonly intent: GenerationIntent + readonly status: GenerationStatus + readonly billingStatus: GenerationBillingStatus + readonly attempt: number + readonly createdAt: string +} + +export interface ConversationArtifactProvenance { + readonly id: ArtifactId + readonly sourceThreadId: ThreadId + readonly sourceMessageId: MessageId + readonly title: string + readonly kind: string +} + +export interface ProjectInstructionVersion { + readonly id: ProjectInstructionVersionId + readonly projectId: ProjectId + readonly version: number + readonly content: string +} + +export interface MemoryItem { + readonly id: MemoryItemId + readonly projectId: ProjectId + readonly content: string + readonly status: "active" | "superseded" | "archived" + readonly revision: number +} + +export interface ProjectFile { + readonly id: ProjectFileId + readonly projectId: ProjectId + readonly title: string + readonly revision: number + readonly lifecycle: LifecycleStatus + readonly sourceMessageId?: MessageId + readonly sourceGenerationId?: GenerationId +} + +/** + * 规范实体的只读聚合投影。它可用于校验与首次读取,但不是整包写入载荷。 + */ +export interface ConversationSnapshot { + readonly schemaVersion: typeof CONVERSATION_SNAPSHOT_SCHEMA_VERSION + readonly project: Project + readonly conversation: Conversation + readonly threads: Readonly> + readonly threadForks: Readonly> + readonly turns: Readonly> + readonly messages: Readonly> + readonly generations: Readonly> + readonly artifactProvenance: Readonly< + Record + > +} diff --git a/lib/thread-chat/domain/conversation-selection.ts b/lib/thread-chat/domain/conversation-selection.ts new file mode 100644 index 00000000..8a53f1c3 --- /dev/null +++ b/lib/thread-chat/domain/conversation-selection.ts @@ -0,0 +1,76 @@ +import type { + ConversationMessage, + ConversationSnapshot, + ConversationTurn, + MessageId, + MessageRole, + TurnId, +} from "./conversation-model.ts" + +export type VariantSelectionFailureCode = + | "turn_not_found" + | "message_not_found" + | "version_conflict" + | "cross_thread" + | "cross_turn" + | "role_mismatch" + | "message_unavailable" + +export type VariantSelectionResult = + | { readonly ok: true; readonly turn: ConversationTurn } + | { + readonly ok: false + readonly code: VariantSelectionFailureCode + readonly currentRevision?: number + } + +export interface SelectActiveVariantInput { + readonly turnId: TurnId + readonly messageId: MessageId + readonly role: Extract + readonly expectedRevision: number +} + +function canBecomeActive(message: ConversationMessage): boolean { + return ( + message.contentState === "complete" || message.contentState === "incomplete" + ) +} + +export function selectActiveVariant( + snapshot: ConversationSnapshot, + input: SelectActiveVariantInput +): VariantSelectionResult { + const turn = snapshot.turns[input.turnId] + if (!turn) return { ok: false, code: "turn_not_found" } + const message = snapshot.messages[input.messageId] + if (!message) return { ok: false, code: "message_not_found" } + if (turn.revision !== input.expectedRevision) + return { + ok: false, + code: "version_conflict", + currentRevision: turn.revision, + } + if (message.threadId !== turn.threadId) + return { ok: false, code: "cross_thread" } + if (message.turnId !== turn.id) return { ok: false, code: "cross_turn" } + if (message.role !== input.role) return { ok: false, code: "role_mismatch" } + if (!canBecomeActive(message)) + return { ok: false, code: "message_unavailable" } + + return { + ok: true, + turn: + input.role === "user" + ? { + ...turn, + activeUserMessageId: message.id, + revision: turn.revision + 1, + } + : { + ...turn, + activeAssistantMessageId: message.id, + revision: turn.revision + 1, + }, + } +} diff --git a/lib/thread-chat/domain/conversation-title.ts b/lib/thread-chat/domain/conversation-title.ts new file mode 100644 index 00000000..52ec0180 --- /dev/null +++ b/lib/thread-chat/domain/conversation-title.ts @@ -0,0 +1,32 @@ +import type { + Conversation, + ConversationThread, + ThreadId, +} from "./conversation-model.ts" + +export const UNTITLED_CONVERSATION_TITLE = "未命名对话" +export const UNTITLED_THREAD_TITLE = "未命名分支" + +export function resolveConversationTitle(conversation: Conversation): string { + return ( + conversation.customTitle?.trim() || + conversation.autoTitle?.trim() || + UNTITLED_CONVERSATION_TITLE + ) +} + +export function resolveThreadTitle(input: { + readonly conversation: Conversation + readonly thread: ConversationThread +}): string { + if (input.thread.id === input.conversation.rootThreadId) + return resolveConversationTitle(input.conversation) + return input.thread.localTitle?.trim() || UNTITLED_THREAD_TITLE +} + +export function isRootThread( + conversation: Conversation, + candidateThreadId: ThreadId +): boolean { + return conversation.rootThreadId === candidateThreadId +} diff --git a/lib/thread-chat/domain/conversation-validation.ts b/lib/thread-chat/domain/conversation-validation.ts new file mode 100644 index 00000000..734a9351 --- /dev/null +++ b/lib/thread-chat/domain/conversation-validation.ts @@ -0,0 +1,470 @@ +import type { + ConversationMessage, + ConversationSnapshot, + ConversationThread, + ConversationTurn, + MessageId, + MessageRole, + ThreadFork, + ThreadId, +} from "./conversation-model.ts" + +export type ConversationValidationCode = + | "project_mismatch" + | "registry_key_mismatch" + | "magic_thread_id" + | "root_thread_missing" + | "root_thread_mismatch" + | "root_thread_title_duplicate" + | "thread_conversation_mismatch" + | "fork_conversation_mismatch" + | "fork_thread_missing" + | "fork_source_message_missing" + | "fork_source_message_mismatch" + | "root_has_incoming_fork" + | "non_root_missing_fork" + | "duplicate_incoming_fork" + | "fork_cycle" + | "turn_thread_missing" + | "turn_thread_mismatch" + | "duplicate_turn_position" + | "active_message_missing" + | "active_message_mismatch" + | "active_message_role_mismatch" + | "message_turn_missing" + | "message_thread_mismatch" + | "message_variant_missing" + | "message_variant_mismatch" + | "generation_thread_missing" + | "generation_turn_missing" + | "generation_message_missing" + | "generation_identity_mismatch" + | "generation_role_mismatch" + | "generation_source_mismatch" + | "artifact_source_mismatch" + +export interface ConversationValidationIssue { + readonly code: ConversationValidationCode + readonly path: string + readonly message: string +} + +export class InvalidConversationSnapshotError extends Error { + readonly issues: readonly ConversationValidationIssue[] + + constructor(issues: readonly ConversationValidationIssue[]) { + super(issues.map((issue) => `${issue.code}@${issue.path}`).join(", ")) + this.name = "InvalidConversationSnapshotError" + this.issues = issues + } +} + +function issue( + issues: ConversationValidationIssue[], + code: ConversationValidationCode, + path: string, + message: string +): void { + issues.push({ code, path, message }) +} + +function validateRegistry( + registry: Readonly>, + path: string, + issues: ConversationValidationIssue[] +): void { + for (const [key, entity] of Object.entries(registry)) { + if (key !== entity.id) + issue( + issues, + "registry_key_mismatch", + `${path}.${key}`, + `注册键 ${key} 与实体 ID ${entity.id} 不一致` + ) + } +} + +function validateThreads( + snapshot: ConversationSnapshot, + issues: ConversationValidationIssue[] +): void { + const { conversation, threads, messages, threadForks } = snapshot + const root = threads[conversation.rootThreadId] + + if (!root) { + issue( + issues, + "root_thread_missing", + "conversation.rootThreadId", + "根 Thread 不存在" + ) + } else if (root.conversationId !== conversation.id) { + issue( + issues, + "root_thread_mismatch", + `threads.${root.id}`, + "根 Thread 不属于当前 Conversation" + ) + } else if (root.localTitle?.trim()) { + issue( + issues, + "root_thread_title_duplicate", + `threads.${root.id}.localTitle`, + "根 Thread 不得重复保存 Conversation 标题" + ) + } + + for (const thread of Object.values(threads)) { + if (thread.id === ("main" as ThreadId)) + issue( + issues, + "magic_thread_id", + `threads.${thread.id}`, + '规范 Thread ID 不得使用遗留魔法值 "main"' + ) + if (thread.conversationId !== conversation.id) + issue( + issues, + "thread_conversation_mismatch", + `threads.${thread.id}.conversationId`, + "Thread 不属于当前 Conversation" + ) + } + + const incoming = new Map() + for (const fork of Object.values(threadForks)) { + if (fork.conversationId !== conversation.id) + issue( + issues, + "fork_conversation_mismatch", + `threadForks.${fork.id}.conversationId`, + "ThreadFork 不属于当前 Conversation" + ) + const parent = threads[fork.parentThreadId] + const child = threads[fork.childThreadId] + if (!parent || !child) + issue( + issues, + "fork_thread_missing", + `threadForks.${fork.id}`, + "ThreadFork 的上游或下游 Thread 不存在" + ) + const source = messages[fork.sourceMessageId] + if (!source) + issue( + issues, + "fork_source_message_missing", + `threadForks.${fork.id}.sourceMessageId`, + "ThreadFork 来源 Message 不存在" + ) + else if (source.threadId !== fork.parentThreadId) + issue( + issues, + "fork_source_message_mismatch", + `threadForks.${fork.id}.sourceMessageId`, + "ThreadFork 来源 Message 不属于上游 Thread" + ) + const list = incoming.get(fork.childThreadId) ?? [] + list.push(fork) + incoming.set(fork.childThreadId, list) + } + + for (const thread of Object.values(threads)) { + const count = incoming.get(thread.id)?.length ?? 0 + if (thread.id === conversation.rootThreadId && count > 0) + issue( + issues, + "root_has_incoming_fork", + `threads.${thread.id}`, + "根 Thread 不得有入向 ThreadFork" + ) + if (thread.id !== conversation.rootThreadId && count === 0) + issue( + issues, + "non_root_missing_fork", + `threads.${thread.id}`, + "非根 Thread 必须有唯一入向 ThreadFork" + ) + if (count > 1) + issue( + issues, + "duplicate_incoming_fork", + `threads.${thread.id}`, + "Thread 存在多个入向 ThreadFork" + ) + } + + const parentByChild = new Map() + for (const fork of Object.values(threadForks)) { + if (!parentByChild.has(fork.childThreadId)) + parentByChild.set(fork.childThreadId, fork.parentThreadId) + } + for (const thread of Object.values(threads)) { + const visited = new Set() + let cursor: ThreadId | undefined = thread.id + while (cursor) { + if (visited.has(cursor)) { + issue( + issues, + "fork_cycle", + `threads.${thread.id}`, + "ThreadFork 图存在环" + ) + break + } + visited.add(cursor) + cursor = parentByChild.get(cursor) + } + } +} + +function validateTurnsAndMessages( + snapshot: ConversationSnapshot, + issues: ConversationValidationIssue[] +): void { + const { turns, messages, threads } = snapshot + const positions = new Set() + for (const turn of Object.values(turns)) { + if (!threads[turn.threadId]) + issue( + issues, + "turn_thread_missing", + `turns.${turn.id}.threadId`, + "Turn 所属 Thread 不存在" + ) + const positionKey = `${turn.threadId}:${turn.position}` + if (positions.has(positionKey)) + issue( + issues, + "duplicate_turn_position", + `turns.${turn.id}.position`, + "同一 Thread 中存在重复 Turn 位置" + ) + positions.add(positionKey) + + validateActiveMessage( + turn, + turn.activeUserMessageId, + "user", + snapshot, + issues + ) + validateActiveMessage( + turn, + turn.activeAssistantMessageId, + "assistant", + snapshot, + issues + ) + } + + for (const message of Object.values(messages)) { + const turn = turns[message.turnId] + if (!turn) { + issue( + issues, + "message_turn_missing", + `messages.${message.id}.turnId`, + "Message 所属 Turn 不存在" + ) + continue + } + if (message.threadId !== turn.threadId) + issue( + issues, + "message_thread_mismatch", + `messages.${message.id}.threadId`, + "Message 与 Turn 不属于同一 Thread" + ) + if (message.variantOfMessageId) { + const source = messages[message.variantOfMessageId] + if (!source) + issue( + issues, + "message_variant_missing", + `messages.${message.id}.variantOfMessageId`, + "Message 变体来源不存在" + ) + else if ( + source.threadId !== message.threadId || + source.turnId !== message.turnId || + source.role !== message.role + ) + issue( + issues, + "message_variant_mismatch", + `messages.${message.id}.variantOfMessageId`, + "Message 变体来源必须属于同一 Thread/Turn 且角色一致" + ) + } + } +} + +function validateActiveMessage( + turn: ConversationTurn, + activeMessageId: MessageId, + expectedRole: MessageRole, + snapshot: ConversationSnapshot, + issues: ConversationValidationIssue[] +): void { + const active = snapshot.messages[activeMessageId] + const path = `turns.${turn.id}.${ + expectedRole === "user" ? "activeUserMessageId" : "activeAssistantMessageId" + }` + if (!active) { + issue(issues, "active_message_missing", path, "当前有效 Message 不存在") + return + } + if (active.turnId !== turn.id || active.threadId !== turn.threadId) + issue( + issues, + "active_message_mismatch", + path, + "当前有效 Message 不属于该 Thread/Turn" + ) + if (active.role !== expectedRole) + issue( + issues, + "active_message_role_mismatch", + path, + `当前有效 Message 角色必须是 ${expectedRole}` + ) +} + +function validateGenerations( + snapshot: ConversationSnapshot, + issues: ConversationValidationIssue[] +): void { + for (const generation of Object.values(snapshot.generations)) { + const thread = snapshot.threads[generation.threadId] + const turn = snapshot.turns[generation.turnId] + const input = snapshot.messages[generation.inputMessageId] + const output = snapshot.messages[generation.outputMessageId] + if (!thread) + issue( + issues, + "generation_thread_missing", + `generations.${generation.id}.threadId`, + "Generation 所属 Thread 不存在" + ) + if (!turn) + issue( + issues, + "generation_turn_missing", + `generations.${generation.id}.turnId`, + "Generation 所属 Turn 不存在" + ) + if (!input || !output) { + issue( + issues, + "generation_message_missing", + `generations.${generation.id}`, + "Generation 输入或输出 Message 不存在" + ) + continue + } + if ( + !turn || + input.threadId !== generation.threadId || + output.threadId !== generation.threadId || + input.turnId !== generation.turnId || + output.turnId !== generation.turnId || + turn.threadId !== generation.threadId + ) + issue( + issues, + "generation_identity_mismatch", + `generations.${generation.id}`, + "Generation、Turn 与输入输出 Message 身份不一致" + ) + if (input.role !== "user" || output.role !== "assistant") + issue( + issues, + "generation_role_mismatch", + `generations.${generation.id}`, + "Generation 输入必须是 user,输出必须是 assistant" + ) + + const sourceId = + generation.intent.kind === "regenerate-assistant" + ? generation.intent.sourceAssistantMessageId + : generation.intent.kind === "edit-user" + ? generation.intent.sourceUserMessageId + : undefined + if (sourceId) { + const source = snapshot.messages[sourceId] + if ( + !source || + source.threadId !== generation.threadId || + source.turnId !== generation.turnId + ) + issue( + issues, + "generation_source_mismatch", + `generations.${generation.id}.intent`, + "Generation 变体来源不属于同一 Thread/Turn" + ) + } + } +} + +function validateArtifacts( + snapshot: ConversationSnapshot, + issues: ConversationValidationIssue[] +): void { + for (const artifact of Object.values(snapshot.artifactProvenance)) { + const source = snapshot.messages[artifact.sourceMessageId] + if ( + !source || + source.threadId !== artifact.sourceThreadId || + source.role !== "assistant" + ) + issue( + issues, + "artifact_source_mismatch", + `artifactProvenance.${artifact.id}`, + "Artifact 来源必须是声明 Thread 中的 assistant Message" + ) + } +} + +export function validateConversationSnapshot( + snapshot: ConversationSnapshot +): readonly ConversationValidationIssue[] { + const issues: ConversationValidationIssue[] = [] + if (snapshot.project.id !== snapshot.conversation.projectId) + issue( + issues, + "project_mismatch", + "conversation.projectId", + "Conversation 不属于快照 Project" + ) + + validateRegistry(snapshot.threads, "threads", issues) + validateRegistry(snapshot.threadForks, "threadForks", issues) + validateRegistry(snapshot.turns, "turns", issues) + validateRegistry(snapshot.messages, "messages", issues) + validateRegistry(snapshot.generations, "generations", issues) + validateRegistry(snapshot.artifactProvenance, "artifactProvenance", issues) + validateThreads(snapshot, issues) + validateTurnsAndMessages(snapshot, issues) + validateGenerations(snapshot, issues) + validateArtifacts(snapshot, issues) + return issues +} + +export function assertValidConversationSnapshot( + snapshot: ConversationSnapshot +): void { + const issues = validateConversationSnapshot(snapshot) + if (issues.length > 0) throw new InvalidConversationSnapshotError(issues) +} + +export type ThreadRole = "main" | "branch" + +export function deriveThreadRole( + snapshot: ConversationSnapshot, + thread: ConversationThread +): ThreadRole { + return thread.id === snapshot.conversation.rootThreadId ? "main" : "branch" +} diff --git a/lib/thread-chat/domain/message-graph.ts b/lib/thread-chat/domain/message-graph.ts index 7eda17ab..5bb17c6a 100644 --- a/lib/thread-chat/domain/message-graph.ts +++ b/lib/thread-chat/domain/message-graph.ts @@ -1,10 +1,7 @@ -import type { - Artifact, - Message, - Thread, - ThreadTreeState, -} from "@/lib/thread-chat/domain/types" -import { THREAD_TREE_SCHEMA_VERSION } from "@/constants/thread-chat" +import type { Artifact, Message, Thread, ThreadTreeState } from "./types.ts" +import { THREAD_TREE_SCHEMA_VERSION } from "../../../constants/thread-chat.ts" + +// @deprecated 仅用于 Issue #34 迁移前的遗留整树读取与校验。 export class InvalidMessageGraphError extends Error { constructor(message: string) { diff --git a/lib/thread-chat/domain/types.ts b/lib/thread-chat/domain/types.ts index 51b6baef..287f402a 100644 --- a/lib/thread-chat/domain/types.ts +++ b/lib/thread-chat/domain/types.ts @@ -1,9 +1,9 @@ /** - * Thread Chat 会话树领域类型(headless,纯 TS,不含任何 React / DOM 概念)。 + * @deprecated Issue #34 迁移期间保留的遗留 ThreadTreeState 契约。 * - * 命名约定:一次「会话」称为 Thread;Thread Tree 由唯一 MainThread 与零个或多个 - * ForkedThread 组成,Fork 表示节点间的分叉关系。 - * 整棵树 + Artifact 登记表构成 ThreadTreeState,由应用层 store 统一变更。 + * 当前线上旧路径仍读取这些类型;新的领域与应用代码必须改用 + * conversation-model.ts,并只允许通过 lib/thread-chat/legacy/ 的只读适配器 + * 读取本模型。不得新增规范模型写回 ThreadTreeState 的路径。 */ import type { TextAnchor } from "@/lib/thread-chat/domain/text-anchor" diff --git a/lib/thread-chat/legacy/project-thread-tree.ts b/lib/thread-chat/legacy/project-thread-tree.ts new file mode 100644 index 00000000..f3cb387c --- /dev/null +++ b/lib/thread-chat/legacy/project-thread-tree.ts @@ -0,0 +1,362 @@ +/** + * 遗留 ThreadTreeState 的单向只读投影边界。 + * + * 只有迁移、审计和固定样例可以依赖本模块。这里故意不提供规范实体写回遗留树的 + * 反向函数,避免在迁移期间形成第二个写入权威。 + */ +import { + CONVERSATION_SNAPSHOT_SCHEMA_VERSION, + LEGACY_PROJECTION_UNKNOWN_TIMESTAMP, +} from "../../../constants/conversation-domain.ts" +import { + activeMessagePath, + parseThreadTreeState, +} from "../domain/message-graph.ts" +import type { + Artifact as LegacyArtifact, + Message as LegacyMessage, + Thread as LegacyThread, + ThreadTreeState, +} from "../domain/types.ts" +import { + artifactId, + messageId, + threadForkId, + threadId, + turnId, + type ArtifactId, + type ConversationArtifactProvenance, + type ConversationId, + type ConversationMessage, + type ConversationSnapshot, + type ConversationThread, + type ConversationTurn, + type MessageContentState, + type MessageId, + type ProjectId, + type ThreadFork, + type ThreadId, + type TurnId, + type WorkspaceId, +} from "../domain/conversation-model.ts" +import { assertValidConversationSnapshot } from "../domain/conversation-validation.ts" + +export class LegacyThreadTreeProjectionError extends Error { + constructor(message: string) { + super(message) + this.name = "LegacyThreadTreeProjectionError" + } +} + +export interface ProjectLegacyThreadTreeInput { + readonly legacyTreeId: string + readonly workspaceId: WorkspaceId + readonly projectId: ProjectId + readonly conversationId: ConversationId + readonly projectTitle: string + readonly conversationAutoTitle: string | null + readonly conversationCustomTitle: string | null + readonly actorId: string + readonly state: ThreadTreeState +} + +interface LegacyIdMap { + readonly thread: (legacyId: string) => ThreadId + readonly message: (legacyId: string) => MessageId + readonly turn: (legacyThreadId: string, groupKey: string) => TurnId + readonly fork: ( + legacyChildThreadId: string + ) => ReturnType + readonly artifact: (legacyId: string) => ArtifactId +} + +function legacySegment(value: string): string { + return encodeURIComponent(value) +} + +function createLegacyIdMap(input: { + readonly legacyTreeId: string + readonly conversationId: ConversationId +}): LegacyIdMap { + const prefix = `legacy:${legacySegment(input.legacyTreeId)}:${input.conversationId}` + return { + thread: (legacyId) => + threadId( + `${prefix}:thread:${legacyId === "main" ? "root" : legacySegment(legacyId)}` + ), + message: (legacyId) => + messageId(`${prefix}:message:${legacySegment(legacyId)}`), + turn: (legacyThreadId, groupKey) => + turnId( + `${prefix}:turn:${legacySegment(legacyThreadId)}:${legacySegment(groupKey)}` + ), + fork: (legacyChildThreadId) => + threadForkId(`${prefix}:fork:${legacySegment(legacyChildThreadId)}`), + artifact: (legacyId) => + artifactId(`${prefix}:artifact:${legacySegment(legacyId)}`), + } +} + +function legacyMessageState(message: LegacyMessage): MessageContentState { + if (message.status === "pending") return "pending" + if (message.status === "streaming") return "streaming" + if (message.status === "error") + return message.text.trim() || (message.artifactIds?.length ?? 0) > 0 + ? "incomplete" + : "failed" + return "complete" +} + +function messageContent( + message: LegacyMessage, + ids: LegacyIdMap +): ConversationMessage["content"] { + const parts: ConversationMessage["content"]["parts"][number][] = [] + if (message.text.length > 0) parts.push({ type: "text", text: message.text }) + for (const legacyArtifactId of message.artifactIds ?? []) + parts.push({ + type: "artifact-reference", + artifactId: ids.artifact(legacyArtifactId), + }) + return { schemaVersion: 1, parts } +} + +interface LegacyTurnGroup { + readonly key: string + readonly firstIndex: number + readonly users: LegacyMessage[] + readonly assistants: LegacyMessage[] +} + +function groupLegacyTurns(thread: LegacyThread): readonly LegacyTurnGroup[] { + const groups = new Map() + const userGroupById = new Map() + + thread.messages.forEach((message, index) => { + if (message.role !== "user") return + const key = message.parentMessageId ?? "root" + const existing = groups.get(key) + const group = + existing ?? + ({ + key, + firstIndex: index, + users: [], + assistants: [], + } satisfies LegacyTurnGroup) + group.users.push(message) + groups.set(key, group) + userGroupById.set(message.id, group) + }) + + thread.messages.forEach((message) => { + if (message.role !== "assistant") return + const group = + message.parentMessageId === null + ? undefined + : userGroupById.get(message.parentMessageId) + if (!group) + throw new LegacyThreadTreeProjectionError( + `Thread ${thread.id} 的 assistant ${message.id} 没有同 Thread user 父节点` + ) + group.assistants.push(message) + }) + + return [...groups.values()].sort((left, right) => + left.firstIndex === right.firstIndex + ? left.key.localeCompare(right.key) + : left.firstIndex - right.firstIndex + ) +} + +function activePair( + thread: LegacyThread, + group: LegacyTurnGroup +): { readonly user: LegacyMessage; readonly assistant: LegacyMessage } { + const activeIds = new Set( + activeMessagePath(thread).map((message) => message.id) + ) + const assistant = + group.assistants.find((message) => activeIds.has(message.id)) ?? + group.assistants.at(-1) + if (!assistant || assistant.parentMessageId === null) + throw new LegacyThreadTreeProjectionError( + `Thread ${thread.id} 的 Turn ${group.key} 没有 assistant 变体` + ) + const user = group.users.find( + (message) => message.id === assistant.parentMessageId + ) + if (!user) + throw new LegacyThreadTreeProjectionError( + `Thread ${thread.id} 的 assistant ${assistant.id} 缺少 user 变体` + ) + return { user, assistant } +} + +function projectThreadEntities(input: { + readonly state: ThreadTreeState + readonly conversationId: ConversationId + readonly ids: LegacyIdMap +}): { + readonly threads: Record + readonly turns: Record + readonly messages: Record +} { + const threads: Record = {} + const turns: Record = {} + const messages: Record = {} + + for (const legacyThread of Object.values(input.state.threads)) { + const canonicalThreadId = input.ids.thread(legacyThread.id) + threads[canonicalThreadId] = { + id: canonicalThreadId, + conversationId: input.conversationId, + modelId: legacyThread.modelId, + localTitle: legacyThread.id === "main" ? null : legacyThread.title, + revision: 0, + lifecycle: "active", + } + + const groups = groupLegacyTurns(legacyThread) + groups.forEach((group, position) => { + const canonicalTurnId = input.ids.turn(legacyThread.id, group.key) + const pair = activePair(legacyThread, group) + turns[canonicalTurnId] = { + id: canonicalTurnId, + threadId: canonicalThreadId, + position, + activeUserMessageId: input.ids.message(pair.user.id), + activeAssistantMessageId: input.ids.message(pair.assistant.id), + revision: 0, + } + + const firstUserId = group.users[0]?.id + const firstAssistantId = group.assistants[0]?.id + for (const legacyMessage of [...group.users, ...group.assistants]) { + const canonicalMessageId = input.ids.message(legacyMessage.id) + const variantSource = + legacyMessage.role === "user" + ? firstUserId && firstUserId !== legacyMessage.id + ? input.ids.message(firstUserId) + : undefined + : firstAssistantId && firstAssistantId !== legacyMessage.id + ? input.ids.message(firstAssistantId) + : undefined + messages[canonicalMessageId] = { + id: canonicalMessageId, + threadId: canonicalThreadId, + turnId: canonicalTurnId, + role: legacyMessage.role, + content: messageContent(legacyMessage, input.ids), + contentState: legacyMessageState(legacyMessage), + ...(variantSource ? { variantOfMessageId: variantSource } : {}), + createdAt: LEGACY_PROJECTION_UNKNOWN_TIMESTAMP, + } + } + }) + } + + return { threads, turns, messages } +} + +function projectForks(input: { + readonly state: ThreadTreeState + readonly conversationId: ConversationId + readonly actorId: string + readonly ids: LegacyIdMap +}): Record { + const forks: Record = {} + for (const legacyChild of Object.values(input.state.threads)) { + if (legacyChild.id === "main") continue + if (!legacyChild.parentId || !legacyChild.forkFromMsgId) + throw new LegacyThreadTreeProjectionError( + `非根 Thread ${legacyChild.id} 缺少遗留 Fork 来源` + ) + const parent = input.state.threads[legacyChild.parentId] + const source = parent?.messages.find( + (message) => message.id === legacyChild.forkFromMsgId + ) + if (!source) + throw new LegacyThreadTreeProjectionError( + `Thread ${legacyChild.id} 的来源 Message 不存在` + ) + const legacyFork = source.forks.find( + (fork) => fork.threadId === legacyChild.id + ) + const id = input.ids.fork(legacyChild.id) + forks[id] = { + id, + conversationId: input.conversationId, + parentThreadId: input.ids.thread(parent.id), + sourceMessageId: input.ids.message(source.id), + childThreadId: input.ids.thread(legacyChild.id), + ...(legacyFork?.anchor + ? { anchor: structuredClone(legacyFork.anchor) } + : {}), + createdBy: input.actorId, + createdAt: LEGACY_PROJECTION_UNKNOWN_TIMESTAMP, + } + } + return forks +} + +function projectArtifact( + legacy: LegacyArtifact, + ids: LegacyIdMap +): ConversationArtifactProvenance { + return { + id: ids.artifact(legacy.id), + sourceThreadId: ids.thread(legacy.sourceThreadId), + sourceMessageId: ids.message(legacy.sourceMessageId), + title: legacy.title, + kind: legacy.kind, + } +} + +export function projectLegacyThreadTree( + rawInput: ProjectLegacyThreadTreeInput +): ConversationSnapshot { + const state = parseThreadTreeState(rawInput.state) + const ids = createLegacyIdMap(rawInput) + const entities = projectThreadEntities({ + state, + conversationId: rawInput.conversationId, + ids, + }) + const artifactProvenance = Object.fromEntries( + Object.values(state.artifacts).map((artifact) => { + const projected = projectArtifact(artifact, ids) + return [projected.id, projected] + }) + ) + const snapshot: ConversationSnapshot = { + schemaVersion: CONVERSATION_SNAPSHOT_SCHEMA_VERSION, + project: { + id: rawInput.projectId, + workspaceId: rawInput.workspaceId, + title: rawInput.projectTitle, + revision: 0, + lifecycle: "active", + }, + conversation: { + id: rawInput.conversationId, + projectId: rawInput.projectId, + rootThreadId: ids.thread("main"), + autoTitle: rawInput.conversationAutoTitle, + customTitle: rawInput.conversationCustomTitle, + revision: 0, + lifecycle: "active", + }, + ...entities, + threadForks: projectForks({ + state, + conversationId: rawInput.conversationId, + actorId: rawInput.actorId, + ids, + }), + generations: {}, + artifactProvenance, + } + assertValidConversationSnapshot(snapshot) + return snapshot +} diff --git a/openspec/changes/add-branch-tree-persistence/proposal.md b/openspec/changes/add-branch-tree-persistence/proposal.md index e086d271..e0223ce5 100644 --- a/openspec/changes/add-branch-tree-persistence/proposal.md +++ b/openspec/changes/add-branch-tree-persistence/proposal.md @@ -1,5 +1,9 @@ # 分支对话树 DB 持久化(刷新不丢) +## 架构状态 + +本变更记录已经落地的迁移前持久化事实,不再代表最终架构。刷新不丢、所有者隔离和 URL 可定位行为继续有效;`ThreadTreeState` 整包 JSON、`threads.main`、整树 PUT 与防抖写入将由 Issue #34 的 `define-conversation-domain-model`、`normalize-conversation-persistence` 和 `retire-thread-tree-authority` 依次取代。 + ## Why 分支对话页(`app/thread-chat/`)目前纯内存:整棵分支树(主线消息、划选锚点、分支列、Artifact 登记)在刷新或重进页面后全部丢失。这是当前体验里最痛的一条——富文本、鲁棒锚点、流式平滑都已就位,唯独对话本身留不住。数据模型(`ThreadTreeState`)从一开始就是纯 JSON、可整体序列化,为持久化留好了路(设计原则 P10),现在把这条路走通。 diff --git a/openspec/changes/add-bubble-composer/proposal.md b/openspec/changes/add-bubble-composer/proposal.md index 264ceaa2..43b4f193 100644 --- a/openspec/changes/add-bubble-composer/proposal.md +++ b/openspec/changes/add-bubble-composer/proposal.md @@ -1,5 +1,9 @@ # Phase A:划选气泡内输入框(fork 首条消息策略收口) +## 架构状态 + +划选、带问开分支和标题体验继续有效;Fork 的最终写入必须创建规范 ThreadFork,Message 引用属于版本化内容,而列放置仍是客户端 UI workspace。旧 `Message.forks` 与整树存盘仅是 Issue #34 迁移前实现,依赖 `define-conversation-domain-model`、`add-conversation-command-api` 和 `normalize-conversation-client-state` 迁移。 + ## Why 用户划选开分支时有两个真实痛点(用户点名):① 气泡里没有输入框——想带着自己的问题开分支,得先开空分支再改写预填文案,多一跳;② 分支首答的代拟问题「请围绕我划选的这段话展开讲解…先解释它本身的含义…」偏百科词条式、缺「衔接上文继续分析」的暗示,模型容易答成孤立词条(继承上文本身在 payload 里,经 verify-live 断言长期验证——问题在问法不在链路)。参考分支(playground `claude/thread-chat-upgrade-research-joe0d6`,commit a9c3cbc)已验证过这套交互,本变更按我们的架构(chat-controller 发送、服务端 system、composer 预填流)落地。 diff --git a/openspec/changes/add-canvas-conversations/proposal.md b/openspec/changes/add-canvas-conversations/proposal.md index 76573a2c..9d4d03b4 100644 --- a/openspec/changes/add-canvas-conversations/proposal.md +++ b/openspec/changes/add-canvas-conversations/proposal.md @@ -1,5 +1,9 @@ # 画布 Phase 2:节点内继续对话 + 画布内划选开分支 +## 架构状态 + +画布交互继续有效,但节点关系应从 Conversation 的 Thread/ThreadFork 规范实体派生,视口、展开和选中状态只属于客户端 UI workspace。此变更不得继续把旧树字段作为关系权威,迁移依赖 `define-conversation-domain-model` 与 `normalize-conversation-client-state`。 + ## Why 画布目前是只读地图(Phase 1 + LR 横向):看得见全树、双击才能回列继续。要把画布变成**工作台**——在节点里直接读最近消息、继续追问、甚至划选开新分支,不来回切视图。参考分支(playground `claude/thread-chat-upgrade-research-joe0d6` commit 54b5a89,净增 ~200 行产品代码 + 145 行测试)已把三大难题踩平:React Flow 手势与选字共处(nodrag/nowheel)、划选气泡在 zoom 变换下的定位(fixed 天然免疫)、节点展开零重排(外挂面板不参与 dagre 布局)。本变更按我们的架构(MarkdownBody/SmoothText/text-anchor/chat-controller/LR 布局)落地。 diff --git a/openspec/changes/add-markdown-artifacts/proposal.md b/openspec/changes/add-markdown-artifacts/proposal.md index 81035d14..50e15eff 100644 --- a/openspec/changes/add-markdown-artifacts/proposal.md +++ b/openspec/changes/add-markdown-artifacts/proposal.md @@ -1,5 +1,7 @@ ## Why +> 架构状态:Markdown 交付与渲染行为继续有效;随 `ThreadTreeState` 整树 JSON 持久化只是 Issue #34 迁移前实现。来源身份先迁移为规范 Message/Generation 与 Artifact provenance,是否提升为 ProjectFile 由基础迁移后的独立提案决定。 + ThreadChat 已经能把普通 assistant 正文渲染成 Markdown,也保留了 Artifact registry、消息卡片和右侧抽屉,但真实对话链路不挂 Artifact 工具且会丢弃所有工具流事件,因此用户无法要求模型生成一份可独立打开、持久化和继续修改的 Markdown 文档。现在需要接通这条链路,让中英文及等价自然语言表达的 Markdown 交付请求稳定落成消息流内的 Markdown 卡片。 ## What Changes diff --git a/openspec/changes/add-thread-chat-message-actions/proposal.md b/openspec/changes/add-thread-chat-message-actions/proposal.md index a1b042f9..6a856961 100644 --- a/openspec/changes/add-thread-chat-message-actions/proposal.md +++ b/openspec/changes/add-thread-chat-message-actions/proposal.md @@ -36,3 +36,6 @@ - 数据库:反馈使用独立 message feedback 记录,不写入 generation,也不写整树 JSON;`branch_trees` 使用单调 revision,整树 PUT、生成事务和 active-leaf 切换使用 CAS,避免旧标签页覆盖消息图或版本选择。 - 测试:覆盖空占位、孤儿 user、运行中/终态 generation、完成消息与 generation 关联、刷新恢复、复制状态、编辑发送、Retry attempt、消息版本切换、旧版本派生分栏和 Artifact 可达性、message feedback 持久化、越权访问,以及列/画布一致性。 - 依赖:以 `persist-thread-chat-generations` 已实现的服务端 generation 身份、owner 校验和持久化屏障为前置,不引入新的第三方运行时或外部服务。 +# 架构状态 + +本变更的消息操作、恢复与反馈产品行为继续有效;其中 message 父子图、`activeLeafMessageId`、整树 CAS 和 JSON 内部 ID 是 Issue #34 迁移前实现。规范 Turn/Message 变体、稳定身份与命令语义依赖 `define-conversation-domain-model`、`add-conversation-command-api` 和 `normalize-conversation-client-state`,整树写入最终由 `retire-thread-tree-authority` 移除。 diff --git a/openspec/changes/add-tree-list-ui/proposal.md b/openspec/changes/add-tree-list-ui/proposal.md index a297727d..4fe03a28 100644 --- a/openspec/changes/add-tree-list-ui/proposal.md +++ b/openspec/changes/add-tree-list-ui/proposal.md @@ -1,5 +1,9 @@ # 会话列表 UI(分支树的列表、重命名与删除) +## 架构状态 + +列表、重命名和删除行为继续有效,但列表资源最终是 Conversation,而不是一行整树 JSON。标题双轨会迁移为 Conversation 标题契约;线程数与更新时间由规范实体投影。该迁移依赖 `define-conversation-domain-model`、`normalize-conversation-persistence` 和 `normalize-conversation-client-state`。 + ## Why 多树能力已由「URL 即身份」上线(add-branch-tree-persistence),但发现性只有书签/浏览器历史——用户看不到自己有哪些树、找不回没记 URL 的对话,测试/随手开的树也无法清理,DB 只进不出。需要一个树级别的列表入口,补全「多会话」的最后一环。 diff --git a/openspec/changes/define-conversation-domain-model/tasks.md b/openspec/changes/define-conversation-domain-model/tasks.md index 9f6c3d76..320412df 100644 --- a/openspec/changes/define-conversation-domain-model/tasks.md +++ b/openspec/changes/define-conversation-domain-model/tasks.md @@ -1,37 +1,37 @@ ## 1. 固化规范术语与依赖 -- [ ] 1.1 将 `domain` 能力规范归档为 Project、Conversation、Thread、ThreadFork、Turn、Message、Generation、File、MemoryItem 与 ProjectInstruction 的唯一正式术语来源,并核对每个角色、身份和包含关系与增量规范一致。 -- [ ] 1.2 新增一份面向开发者的“遗留模型 → 规范模型”迁移表,覆盖 `ThreadTreeState`、`threads.main`、`parentId/children`、`forkFromMsgId/Message.forks`、`activeLeafMessageId`、Artifact 和 `branch_generations` 引用,明确哪些字段只允许存在于迁移适配器。 -- [ ] 1.3 审计 README、CLAUDE、活跃 OpenSpec 和 `lib/thread-chat` 注释中的领域术语;把新设计文档改用 Conversation、Thread、ThreadFork,并对必须保留的历史 `Thread Tree` 引用加上遗留标识,不改写已归档变更的历史事实。 -- [ ] 1.4 在 `persist-thread-chat-generations`、`add-thread-chat-message-actions`、`add-branch-tree-persistence` 及其他仍依赖整树权威模型的活跃变更中记录其与本变更及后续迁移变更的依赖或被替代范围,禁止继续声称整树 JSON 是最终架构。 +- [x] 1.1 将 `domain` 能力规范归档为 Project、Conversation、Thread、ThreadFork、Turn、Message、Generation、File、MemoryItem 与 ProjectInstruction 的唯一正式术语来源,并核对每个角色、身份和包含关系与增量规范一致。 +- [x] 1.2 新增一份面向开发者的“遗留模型 → 规范模型”迁移表,覆盖 `ThreadTreeState`、`threads.main`、`parentId/children`、`forkFromMsgId/Message.forks`、`activeLeafMessageId`、Artifact 和 `branch_generations` 引用,明确哪些字段只允许存在于迁移适配器。 +- [x] 1.3 审计 README、CLAUDE、活跃 OpenSpec 和 `lib/thread-chat` 注释中的领域术语;把新设计文档改用 Conversation、Thread、ThreadFork,并对必须保留的历史 `Thread Tree` 引用加上遗留标识,不改写已归档变更的历史事实。 +- [x] 1.4 在 `persist-thread-chat-generations`、`add-thread-chat-message-actions`、`add-branch-tree-persistence` 及其他仍依赖整树权威模型的活跃变更中记录其与本变更及后续迁移变更的依赖或被替代范围,禁止继续声称整树 JSON 是最终架构。 ## 2. 建立纯领域契约 -- [ ] 2.1 在 `lib/thread-chat/domain/` 下建立不依赖 React、Next.js、HTTP 或 Drizzle 的规范 ID 与实体契约,定义 Project、Conversation、Thread、ThreadFork、Turn、Message 和 Generation 的稳定身份与归属字段。 -- [ ] 2.2 实现主 Thread 与分支 Thread 的角色推导,以及 Conversation/ThreadFork 拓扑校验;拒绝魔法 `main` 身份、重复入向 Fork、跨 Conversation Fork、来源 Message 与上游 Thread 错配和 Fork 环。 -- [ ] 2.3 实现 Turn/Message/Generation 归属校验,拒绝跨 Thread 回复变体、跨 Turn 输出和 Generation 输入输出错配,同时允许 Message 的内容与结构化片段保持为可版本化值对象。 -- [ ] 2.4 建立当前有效变体的领域选择契约和版本冲突结果,确保变体只能在同一 Thread/Turn 切换,并与可见列、折叠列、画布视口等界面工作区类型隔离。 -- [ ] 2.5 明确 Conversation/Thread Title 解析契约:Conversation 独占聚合导航标题,根 Thread 列头使用 Conversation Title,非根 Thread 可以使用本地 Title,避免重复持久化同义根标题。 +- [x] 2.1 在 `lib/thread-chat/domain/` 下建立不依赖 React、Next.js、HTTP 或 Drizzle 的规范 ID 与实体契约,定义 Project、Conversation、Thread、ThreadFork、Turn、Message 和 Generation 的稳定身份与归属字段。 +- [x] 2.2 实现主 Thread 与分支 Thread 的角色推导,以及 Conversation/ThreadFork 拓扑校验;拒绝魔法 `main` 身份、重复入向 Fork、跨 Conversation Fork、来源 Message 与上游 Thread 错配和 Fork 环。 +- [x] 2.3 实现 Turn/Message/Generation 归属校验,拒绝跨 Thread 回复变体、跨 Turn 输出和 Generation 输入输出错配,同时允许 Message 的内容与结构化片段保持为可版本化值对象。 +- [x] 2.4 建立当前有效变体的领域选择契约和版本冲突结果,确保变体只能在同一 Thread/Turn 切换,并与可见列、折叠列、画布视口等界面工作区类型隔离。 +- [x] 2.5 明确 Conversation/Thread Title 解析契约:Conversation 独占聚合导航标题,根 Thread 列头使用 Conversation Title,非根 Thread 可以使用本地 Title,避免重复持久化同义根标题。 ## 3. 隔离遗留模型 -- [ ] 3.1 将当前 `ThreadTreeState`、`Fork`、`threads.main` 和整树解析器明确移动或标记为遗留边界;新的规范领域模块不得通过类型别名把旧整树对象伪装成 Conversation。 -- [ ] 3.2 新增单向、只读的“遗留树 → 规范 Conversation 快照”映射,集中处理根 Thread、Fork、Turn 变体和 Artifact 来源推导;禁止提供“规范模型 → 遗留模型”的权威写回路径。 -- [ ] 3.3 为最小根 Conversation、主线 → A → B 嵌套 Fork、B 重新生成回复变体、非法跨 Thread Generation 和重复 Fork 来源建立确定性固定样例及验证脚本,证明规范投影不会把 B 的回复变体投影到 A。 -- [ ] 3.4 为旧字段建立允许列表或等价审计检查,使新增领域层和应用层代码中的 `ThreadTreeState`、`threads.main`、可写 `children` 与 `Message.forks` 引用能够在代码审查和持续集成中被发现。 +- [x] 3.1 将当前 `ThreadTreeState`、`Fork`、`threads.main` 和整树解析器明确移动或标记为遗留边界;新的规范领域模块不得通过类型别名把旧整树对象伪装成 Conversation。 +- [x] 3.2 新增单向、只读的“遗留树 → 规范 Conversation 快照”映射,集中处理根 Thread、Fork、Turn 变体和 Artifact 来源推导;禁止提供“规范模型 → 遗留模型”的权威写回路径。 +- [x] 3.3 为最小根 Conversation、主线 → A → B 嵌套 Fork、B 重新生成回复变体、非法跨 Thread Generation 和重复 Fork 来源建立确定性固定样例及验证脚本,证明规范投影不会把 B 的回复变体投影到 A。 +- [x] 3.4 为旧字段建立允许列表或等价审计检查,使新增领域层和应用层代码中的 `ThreadTreeState`、`threads.main`、可写 `children` 与 `Message.forks` 引用能够在代码审查和持续集成中被发现。 ## 4. 准备小步迁移链 -- [ ] 4.1 为 `normalize-conversation-persistence` 建立后续变更输入清单:逻辑表、复合外键、不变量、真实数据审计、重置或迁移决策、规范快照和回滚边界。 -- [ ] 4.2 为 `migrate-generation-lifecycle` 建立后续变更输入清单:真实 Thread/Turn/Message 外键、启动、停止、心跳、过期恢复、部分结果、用量不可得、计费幂等以及 PR #30 剩余语义缺口。 -- [ ] 4.3 为 `add-conversation-command-api` 建立后续变更输入清单:Fork、创建 Turn、重新生成、选择变体、归档、停止等命令,以及所有者校验、幂等、预期版本、错误码、实体增量与事务事件箱。 -- [ ] 4.4 为 `normalize-conversation-client-state` 建立后续变更输入清单:规范实体、可重建索引、按 ID 定位的选择器、界面工作区、乐观命令和列、画布、Message 操作迁移。 -- [ ] 4.5 为 `retire-thread-tree-authority` 建立后续变更输入清单:数据审计、运行中 Generation 排空、禁用整树 PUT、删除协调合并与防抖保存、删除重复 Fork 事实和遗留适配器的完成条件。 -- [ ] 4.6 将 Project Memory、File、Instruction、公开接口、令牌、命令行、MCP 和 Issue #39 Conversation 收敛列为基础迁移后的独立提案,明确它们不阻塞 #34 当前链路。 +- [x] 4.1 为 `normalize-conversation-persistence` 建立后续变更输入清单:逻辑表、复合外键、不变量、真实数据审计、重置或迁移决策、规范快照和回滚边界。 +- [x] 4.2 为 `migrate-generation-lifecycle` 建立后续变更输入清单:真实 Thread/Turn/Message 外键、启动、停止、心跳、过期恢复、部分结果、用量不可得、计费幂等以及 PR #30 剩余语义缺口。 +- [x] 4.3 为 `add-conversation-command-api` 建立后续变更输入清单:Fork、创建 Turn、重新生成、选择变体、归档、停止等命令,以及所有者校验、幂等、预期版本、错误码、实体增量与事务事件箱。 +- [x] 4.4 为 `normalize-conversation-client-state` 建立后续变更输入清单:规范实体、可重建索引、按 ID 定位的选择器、界面工作区、乐观命令和列、画布、Message 操作迁移。 +- [x] 4.5 为 `retire-thread-tree-authority` 建立后续变更输入清单:数据审计、运行中 Generation 排空、禁用整树 PUT、删除协调合并与防抖保存、删除重复 Fork 事实和遗留适配器的完成条件。 +- [x] 4.6 将 Project Memory、File、Instruction、公开接口、令牌、命令行、MCP 和 Issue #39 Conversation 收敛列为基础迁移后的独立提案,明确它们不阻塞 #34 当前链路。 ## 5. 验证与交接 -- [ ] 5.1 运行 `pnpm typecheck`,修复规范领域契约、遗留适配器和固定样例的全部类型错误。 -- [ ] 5.2 运行 `pnpm openspec:validate`,确保领域增量规范、依赖说明和所有活跃变更严格验证通过。 -- [ ] 5.3 检查 Git 差异和术语审计结果,确认未修改无关功能、未把界面工作区混入领域实体、未产生第二个 Fork 事实源,也未把 Issue #39 的收敛机制带入本变更。 -- [ ] 5.4 在 Issue #34 回填本变更路径、最终架构决策记录和后续变更依赖链,确保实现者可以从本提案继续而不重新猜测核心数据模型。 +- [x] 5.1 运行 `pnpm typecheck`,修复规范领域契约、遗留适配器和固定样例的全部类型错误。 +- [x] 5.2 运行 `pnpm openspec:validate`,确保领域增量规范、依赖说明和所有活跃变更严格验证通过。 +- [x] 5.3 检查 Git 差异和术语审计结果,确认未修改无关功能、未把界面工作区混入领域实体、未产生第二个 Fork 事实源,也未把 Issue #39 的收敛机制带入本变更。 +- [x] 5.4 在 Issue #34 回填本变更路径、最终架构决策记录和后续变更依赖链,确保实现者可以从本提案继续而不重新猜测核心数据模型。 diff --git a/openspec/changes/persist-thread-chat-generations/proposal.md b/openspec/changes/persist-thread-chat-generations/proposal.md index 9e421418..6e821347 100644 --- a/openspec/changes/persist-thread-chat-generations/proposal.md +++ b/openspec/changes/persist-thread-chat-generations/proposal.md @@ -32,3 +32,6 @@ - 客户端:调整发送前持久化、刷新加载 reconcile、后台终态轮询、Stop/Retry 语义和整树防抖写入规则。 - 计费:保留断连后完整计费,并以应用 generation id 保证一次 attempt 至多扣费一次。 - 不新增 Redis 或其他外部服务;SSE 字节重放、刷新后的实时续流和进程崩溃后的执行恢复留给 P1/P2。 +# 架构状态 + +本变更定义的刷新恢复、服务端 Generation 生命周期和计费行为继续有效,但其 `branch_trees.state` 整树协调与 JSON 内部 Thread/Message 身份只属于 Issue #34 迁移前实现。目标 Generation 身份、外键和终态由 `define-conversation-domain-model` → `normalize-conversation-persistence` → `migrate-generation-lifecycle` 迁移链取代;不得据此继续声明整树 JSON 是最终权威模型。 diff --git a/package.json b/package.json index dd4472f5..26954b8c 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,8 @@ "lint": "eslint", "format": "prettier --write \"**/*.{ts,tsx}\"", "typecheck": "tsc --noEmit", + "test:conversation-domain": "node --experimental-strip-types --test lib/thread-chat/domain/conversation-model.test.ts", + "audit:conversation-domain": "node scripts/audit-thread-tree-boundary.mjs", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:reset-schema": "node scripts/reset-thread-chat-schema.mjs", diff --git a/scripts/audit-thread-tree-boundary.mjs b/scripts/audit-thread-tree-boundary.mjs new file mode 100644 index 00000000..ce8a32a9 --- /dev/null +++ b/scripts/audit-thread-tree-boundary.mjs @@ -0,0 +1,62 @@ +import { readFile, readdir } from "node:fs/promises" +import path from "node:path" +import process from "node:process" + +import { THREAD_TREE_LEGACY_ALLOWLIST } from "./thread-tree-legacy-allowlist.mjs" + +const ROOT = process.cwd() +const SCAN_ROOTS = ["lib/thread-chat/domain", "lib/thread-chat/application"] +const LEGACY_PATTERNS = [ + ["ThreadTreeState", /\bThreadTreeState\b/u], + ["threads.main", /\bthreads\.main\b/u], + ["activeLeafMessageId", /\bactiveLeafMessageId\b/u], + ["parentId", /\bparentId\b/u], + ["children", /\bchildren\b/u], + ["forkFromMsgId", /\bforkFromMsgId\b/u], + ["Message.forks", /\bforks\b/u], +] + +async function listSourceFiles(relativeDirectory) { + const directory = path.join(ROOT, relativeDirectory) + const entries = await readdir(directory, { withFileTypes: true }) + const nested = await Promise.all( + entries.map(async (entry) => { + const relativePath = path.posix.join(relativeDirectory, entry.name) + if (entry.isDirectory()) return listSourceFiles(relativePath) + return /\.(?:ts|tsx)$/u.test(entry.name) ? [relativePath] : [] + }) + ) + return nested.flat() +} + +const allowlist = new Set(THREAD_TREE_LEGACY_ALLOWLIST) +const sourceFiles = (await Promise.all(SCAN_ROOTS.map(listSourceFiles))).flat() +const violations = [] + +for (const relativePath of sourceFiles) { + const source = await readFile(path.join(ROOT, relativePath), "utf8") + const tokens = LEGACY_PATTERNS.filter(([, pattern]) => + pattern.test(source) + ).map(([name]) => name) + if (tokens.length > 0 && !allowlist.has(relativePath)) + violations.push(`${relativePath}: ${tokens.join(", ")}`) +} + +const staleEntries = THREAD_TREE_LEGACY_ALLOWLIST.filter( + (entry) => !sourceFiles.includes(entry) +) +if (staleEntries.length > 0) { + console.error("遗留字段允许列表包含不存在或不在审计范围内的文件:") + for (const entry of staleEntries) console.error(`- ${entry}`) + process.exitCode = 1 +} + +if (violations.length > 0) { + console.error("发现允许列表之外的遗留 Thread Tree 字段:") + for (const violation of violations) console.error(`- ${violation}`) + process.exitCode = 1 +} else if (process.exitCode !== 1) { + console.log( + `遗留边界审计通过:${sourceFiles.length} 个文件,${allowlist.size} 个临时允许项。` + ) +} diff --git a/scripts/thread-tree-legacy-allowlist.mjs b/scripts/thread-tree-legacy-allowlist.mjs new file mode 100644 index 00000000..17f3370c --- /dev/null +++ b/scripts/thread-tree-legacy-allowlist.mjs @@ -0,0 +1,17 @@ +/** + * Issue #34 迁移期间允许读取遗留整树字段的边界。 + * + * 新增条目前必须说明为何不能改用规范 Conversation 实体;迁移完成后应删除本文件。 + */ +export const THREAD_TREE_LEGACY_ALLOWLIST = Object.freeze([ + "lib/thread-chat/application/compile-thread-chat-messages.ts", + "lib/thread-chat/application/merge-generation-result.ts", + "lib/thread-chat/application/reconcile-turns.ts", + "lib/thread-chat/application/serialize-message-for-model.ts", + "lib/thread-chat/domain/conversation-model.test.ts", + "lib/thread-chat/domain/generation.ts", + "lib/thread-chat/domain/message-graph.ts", + "lib/thread-chat/domain/regeneration.ts", + "lib/thread-chat/domain/selectors.ts", + "lib/thread-chat/domain/types.ts", +]) From 4f64e788ade75caa6a5a8ac02a6b73ee461bdcd3 Mon Sep 17 00:00:00 2001 From: zilin <276161014@qq.com> Date: Sat, 22 Aug 2026 01:57:53 +0800 Subject: [PATCH 03/32] feat(persistence): normalize conversation entities --- ...nversation-persistence-audit-2026-08-22.md | 32 + drizzle/0004_wild_scarlet_witch.sql | 367 +++ drizzle/0005_fix-canonical-reparenting.sql | 24 + drizzle/0006_fix-turn-role-validation.sql | 58 + drizzle/0007_protect-root-thread-identity.sql | 24 + drizzle/meta/0004_snapshot.json | 2515 +++++++++++++++++ drizzle/meta/0005_snapshot.json | 2515 +++++++++++++++++ drizzle/meta/0006_snapshot.json | 2515 +++++++++++++++++ drizzle/meta/0007_snapshot.json | 2515 +++++++++++++++++ drizzle/meta/_journal.json | 28 + lib/db/schema.ts | 236 ++ .../application/conversation-read-indexes.ts | 68 + .../application/conversation-repository.ts | 131 + .../legacy/audit-thread-tree.test.ts | 161 ++ lib/thread-chat/legacy/audit-thread-tree.ts | 436 +++ .../canonical-persistence-policy.ts | 57 + .../drizzle-conversation-repository.ts | 716 +++++ .../tasks.md | 34 +- package.json | 3 + scripts/audit-legacy-conversations.ts | 120 + scripts/reset-thread-chat-schema.mjs | 40 +- scripts/test-conversation-persistence.ts | 571 ++++ 22 files changed, 13143 insertions(+), 23 deletions(-) create mode 100644 docs/architecture/conversation-persistence-audit-2026-08-22.md create mode 100644 drizzle/0004_wild_scarlet_witch.sql create mode 100644 drizzle/0005_fix-canonical-reparenting.sql create mode 100644 drizzle/0006_fix-turn-role-validation.sql create mode 100644 drizzle/0007_protect-root-thread-identity.sql create mode 100644 drizzle/meta/0004_snapshot.json create mode 100644 drizzle/meta/0005_snapshot.json create mode 100644 drizzle/meta/0006_snapshot.json create mode 100644 drizzle/meta/0007_snapshot.json create mode 100644 lib/thread-chat/application/conversation-read-indexes.ts create mode 100644 lib/thread-chat/application/conversation-repository.ts create mode 100644 lib/thread-chat/legacy/audit-thread-tree.test.ts create mode 100644 lib/thread-chat/legacy/audit-thread-tree.ts create mode 100644 lib/thread-chat/persistence/canonical-persistence-policy.ts create mode 100644 lib/thread-chat/persistence/drizzle-conversation-repository.ts create mode 100644 scripts/audit-legacy-conversations.ts create mode 100644 scripts/test-conversation-persistence.ts diff --git a/docs/architecture/conversation-persistence-audit-2026-08-22.md b/docs/architecture/conversation-persistence-audit-2026-08-22.md new file mode 100644 index 00000000..79b3a05c --- /dev/null +++ b/docs/architecture/conversation-persistence-audit-2026-08-22.md @@ -0,0 +1,32 @@ +# Conversation 持久化 dry-run 审计证据(2026-08-22) + +## 执行边界 + +- 目标:本机 `localhost:5432/thread-chat` 的 `thread_chat` schema。 +- 命令:`pnpm audit:legacy-conversations -- --summary-only`。 +- 模式:`dry-run`、只读;读取 `branch_trees`、`branch_generations` 与 `branch_message_feedback`,未写入遗留表或规范表。 +- 规范迁移 `0004_wild_scarlet_witch.sql` 已在同一目标成功执行;新增表默认不承载生产写入。 + +## 结果 + +| 指标 | 数量 | +| --- | ---: | +| 遗留树 | 18 | +| 已有 owner | 18 | +| 无 owner | 0 | +| 可直接映射 | 18 | +| 需修复 | 0 | +| 拒绝 | 0 | +| Thread | 21 | +| Turn | 32 | +| Message | 67 | +| Fork | 3 | +| Artifact | 0 | +| Generation 辅助记录 | 35 | +| Message feedback | 1 | + +所有稳定审计错误码的计数均为 0。逐树 dry-run 同时生成了确定性的 Conversation、Thread 与 Message ID 映射。 + +## 对最终切换的含义 + +当前本地证据表明,18 棵遗留树都满足已知结构、归属、Fork、active leaf、Generation 与反馈引用不变量,因此技术上可以一次性导入,不需要因污染数据强制重置。这个结论只适用于本次审计的本地目标;最终 `retire-thread-tree-authority` 在每个待切换环境必须重新执行同一只读审计,再决定迁移或重置,不能把本地结果外推为生产事实。 diff --git a/drizzle/0004_wild_scarlet_witch.sql b/drizzle/0004_wild_scarlet_witch.sql new file mode 100644 index 00000000..bfccad53 --- /dev/null +++ b/drizzle/0004_wild_scarlet_witch.sql @@ -0,0 +1,367 @@ +CREATE TABLE "thread_chat"."conversation_messages" ( + "id" text PRIMARY KEY NOT NULL, + "thread_id" text NOT NULL, + "turn_id" text NOT NULL, + "role" text NOT NULL, + "content" jsonb NOT NULL, + "content_state" text NOT NULL, + "variant_of_message_id" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "thread_chat"."conversation_threads" ( + "id" text PRIMARY KEY NOT NULL, + "conversation_id" text NOT NULL, + "model_id" text NOT NULL, + "local_title" text, + "revision" integer DEFAULT 0 NOT NULL, + "lifecycle" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "thread_chat"."conversation_turns" ( + "id" text PRIMARY KEY NOT NULL, + "thread_id" text NOT NULL, + "position" integer NOT NULL, + "active_user_message_id" text NOT NULL, + "active_assistant_message_id" text NOT NULL, + "revision" integer DEFAULT 0 NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "thread_chat"."conversations" ( + "id" text PRIMARY KEY NOT NULL, + "project_id" text NOT NULL, + "root_thread_id" text NOT NULL, + "auto_title" text, + "custom_title" text, + "revision" integer DEFAULT 0 NOT NULL, + "lifecycle" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "thread_chat"."projects" ( + "id" text PRIMARY KEY NOT NULL, + "workspace_id" text NOT NULL, + "title" text NOT NULL, + "revision" integer DEFAULT 0 NOT NULL, + "lifecycle" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "thread_chat"."thread_forks" ( + "id" text PRIMARY KEY NOT NULL, + "conversation_id" text NOT NULL, + "parent_thread_id" text NOT NULL, + "source_message_id" text NOT NULL, + "child_thread_id" text NOT NULL, + "anchor" jsonb, + "created_by" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "thread_chat"."workspace_members" ( + "workspace_id" text NOT NULL, + "user_id" text NOT NULL, + "role" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "workspace_members_pk" PRIMARY KEY("workspace_id","user_id") +); +--> statement-breakpoint +CREATE TABLE "thread_chat"."workspaces" ( + "id" text PRIMARY KEY NOT NULL, + "revision" integer DEFAULT 0 NOT NULL, + "lifecycle" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "thread_chat"."conversation_threads" ADD CONSTRAINT "conversation_threads_conversation_id_conversations_id_fk" FOREIGN KEY ("conversation_id") REFERENCES "thread_chat"."conversations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "thread_chat"."conversation_turns" ADD CONSTRAINT "conversation_turns_thread_id_conversation_threads_id_fk" FOREIGN KEY ("thread_id") REFERENCES "thread_chat"."conversation_threads"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "thread_chat"."conversations" ADD CONSTRAINT "conversations_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "thread_chat"."projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "thread_chat"."projects" ADD CONSTRAINT "projects_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "thread_chat"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "thread_chat"."thread_forks" ADD CONSTRAINT "thread_forks_conversation_id_conversations_id_fk" FOREIGN KEY ("conversation_id") REFERENCES "thread_chat"."conversations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "thread_chat"."thread_forks" ADD CONSTRAINT "thread_forks_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "thread_chat"."user"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "thread_chat"."workspace_members" ADD CONSTRAINT "workspace_members_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "thread_chat"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "thread_chat"."workspace_members" ADD CONSTRAINT "workspace_members_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "thread_chat"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "conversation_messages_id_thread_id_uq" ON "thread_chat"."conversation_messages" USING btree ("id","thread_id");--> statement-breakpoint +CREATE UNIQUE INDEX "conversation_messages_id_thread_turn_uq" ON "thread_chat"."conversation_messages" USING btree ("id","thread_id","turn_id");--> statement-breakpoint +CREATE INDEX "conversation_messages_turn_created_idx" ON "thread_chat"."conversation_messages" USING btree ("turn_id","created_at","id");--> statement-breakpoint +CREATE INDEX "conversation_messages_variant_source_idx" ON "thread_chat"."conversation_messages" USING btree ("variant_of_message_id");--> statement-breakpoint +CREATE UNIQUE INDEX "conversation_threads_id_conversation_id_uq" ON "thread_chat"."conversation_threads" USING btree ("id","conversation_id");--> statement-breakpoint +CREATE INDEX "conversation_threads_conversation_created_idx" ON "thread_chat"."conversation_threads" USING btree ("conversation_id","created_at");--> statement-breakpoint +CREATE UNIQUE INDEX "conversation_turns_id_thread_id_uq" ON "thread_chat"."conversation_turns" USING btree ("id","thread_id");--> statement-breakpoint +CREATE UNIQUE INDEX "conversation_turns_thread_position_uq" ON "thread_chat"."conversation_turns" USING btree ("thread_id","position");--> statement-breakpoint +CREATE INDEX "conversation_turns_active_user_idx" ON "thread_chat"."conversation_turns" USING btree ("active_user_message_id");--> statement-breakpoint +CREATE INDEX "conversation_turns_active_assistant_idx" ON "thread_chat"."conversation_turns" USING btree ("active_assistant_message_id");--> statement-breakpoint +CREATE UNIQUE INDEX "conversations_project_id_id_uq" ON "thread_chat"."conversations" USING btree ("project_id","id");--> statement-breakpoint +CREATE INDEX "conversations_project_updated_idx" ON "thread_chat"."conversations" USING btree ("project_id","updated_at");--> statement-breakpoint +CREATE INDEX "conversations_project_lifecycle_idx" ON "thread_chat"."conversations" USING btree ("project_id","lifecycle");--> statement-breakpoint +CREATE UNIQUE INDEX "projects_workspace_id_id_uq" ON "thread_chat"."projects" USING btree ("workspace_id","id");--> statement-breakpoint +CREATE INDEX "projects_workspace_lifecycle_idx" ON "thread_chat"."projects" USING btree ("workspace_id","lifecycle");--> statement-breakpoint +CREATE UNIQUE INDEX "thread_forks_child_thread_uq" ON "thread_chat"."thread_forks" USING btree ("child_thread_id");--> statement-breakpoint +CREATE INDEX "thread_forks_parent_source_idx" ON "thread_chat"."thread_forks" USING btree ("parent_thread_id","source_message_id");--> statement-breakpoint +CREATE INDEX "thread_forks_conversation_created_idx" ON "thread_chat"."thread_forks" USING btree ("conversation_id","created_at");--> statement-breakpoint +CREATE INDEX "workspace_members_user_id_idx" ON "thread_chat"."workspace_members" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "workspaces_lifecycle_idx" ON "thread_chat"."workspaces" USING btree ("lifecycle");--> statement-breakpoint + +ALTER TABLE "thread_chat"."workspaces" + ADD CONSTRAINT "workspaces_revision_nonnegative_ck" CHECK (revision >= 0), + ADD CONSTRAINT "workspaces_lifecycle_ck" CHECK (lifecycle IN ('active', 'archived'));--> statement-breakpoint +ALTER TABLE "thread_chat"."workspace_members" + ADD CONSTRAINT "workspace_members_role_ck" CHECK (role IN ('owner', 'member'));--> statement-breakpoint +ALTER TABLE "thread_chat"."projects" + ADD CONSTRAINT "projects_revision_nonnegative_ck" CHECK (revision >= 0), + ADD CONSTRAINT "projects_lifecycle_ck" CHECK (lifecycle IN ('active', 'archived'));--> statement-breakpoint +ALTER TABLE "thread_chat"."conversations" + ADD CONSTRAINT "conversations_revision_nonnegative_ck" CHECK (revision >= 0), + ADD CONSTRAINT "conversations_lifecycle_ck" CHECK (lifecycle IN ('active', 'archived'));--> statement-breakpoint +ALTER TABLE "thread_chat"."conversation_threads" + ADD CONSTRAINT "conversation_threads_no_magic_main_ck" CHECK (id <> 'main'), + ADD CONSTRAINT "conversation_threads_revision_nonnegative_ck" CHECK (revision >= 0), + ADD CONSTRAINT "conversation_threads_lifecycle_ck" CHECK (lifecycle IN ('active', 'archived'));--> statement-breakpoint +ALTER TABLE "thread_chat"."conversation_turns" + ADD CONSTRAINT "conversation_turns_position_nonnegative_ck" CHECK (position >= 0), + ADD CONSTRAINT "conversation_turns_revision_nonnegative_ck" CHECK (revision >= 0);--> statement-breakpoint +ALTER TABLE "thread_chat"."conversation_messages" + ADD CONSTRAINT "conversation_messages_role_ck" CHECK (role IN ('user', 'assistant', 'context')), + ADD CONSTRAINT "conversation_messages_content_state_ck" CHECK (content_state IN ('pending', 'streaming', 'complete', 'incomplete', 'failed')), + ADD CONSTRAINT "conversation_messages_content_version_ck" CHECK (content @> '{"schemaVersion": 1}'::jsonb);--> statement-breakpoint +ALTER TABLE "thread_chat"."thread_forks" + ADD CONSTRAINT "thread_forks_distinct_threads_ck" CHECK (parent_thread_id <> child_thread_id);--> statement-breakpoint + +ALTER TABLE "thread_chat"."conversations" + ADD CONSTRAINT "conversations_root_thread_fk" + FOREIGN KEY (root_thread_id, id) + REFERENCES "thread_chat"."conversation_threads" (id, conversation_id) + DEFERRABLE INITIALLY DEFERRED;--> statement-breakpoint +ALTER TABLE "thread_chat"."thread_forks" + ADD CONSTRAINT "thread_forks_parent_conversation_fk" + FOREIGN KEY (parent_thread_id, conversation_id) + REFERENCES "thread_chat"."conversation_threads" (id, conversation_id) + DEFERRABLE INITIALLY DEFERRED;--> statement-breakpoint +ALTER TABLE "thread_chat"."thread_forks" + ADD CONSTRAINT "thread_forks_child_conversation_fk" + FOREIGN KEY (child_thread_id, conversation_id) + REFERENCES "thread_chat"."conversation_threads" (id, conversation_id) + DEFERRABLE INITIALLY DEFERRED;--> statement-breakpoint +ALTER TABLE "thread_chat"."thread_forks" + ADD CONSTRAINT "thread_forks_source_parent_fk" + FOREIGN KEY (source_message_id, parent_thread_id) + REFERENCES "thread_chat"."conversation_messages" (id, thread_id) + DEFERRABLE INITIALLY DEFERRED;--> statement-breakpoint +ALTER TABLE "thread_chat"."conversation_messages" + ADD CONSTRAINT "conversation_messages_turn_thread_fk" + FOREIGN KEY (turn_id, thread_id) + REFERENCES "thread_chat"."conversation_turns" (id, thread_id) + ON DELETE CASCADE + DEFERRABLE INITIALLY DEFERRED;--> statement-breakpoint +ALTER TABLE "thread_chat"."conversation_messages" + ADD CONSTRAINT "conversation_messages_variant_turn_fk" + FOREIGN KEY (variant_of_message_id, thread_id, turn_id) + REFERENCES "thread_chat"."conversation_messages" (id, thread_id, turn_id) + DEFERRABLE INITIALLY DEFERRED;--> statement-breakpoint +ALTER TABLE "thread_chat"."conversation_turns" + ADD CONSTRAINT "conversation_turns_active_user_fk" + FOREIGN KEY (active_user_message_id, thread_id, id) + REFERENCES "thread_chat"."conversation_messages" (id, thread_id, turn_id) + DEFERRABLE INITIALLY DEFERRED;--> statement-breakpoint +ALTER TABLE "thread_chat"."conversation_turns" + ADD CONSTRAINT "conversation_turns_active_assistant_fk" + FOREIGN KEY (active_assistant_message_id, thread_id, id) + REFERENCES "thread_chat"."conversation_messages" (id, thread_id, turn_id) + DEFERRABLE INITIALLY DEFERRED;--> statement-breakpoint + +CREATE FUNCTION "thread_chat"."reject_canonical_reparenting"() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +DECLARE + old_row jsonb := to_jsonb(OLD); + new_row jsonb := to_jsonb(NEW); +BEGIN + IF TG_TABLE_NAME = 'projects' AND new_row -> 'workspace_id' IS DISTINCT FROM old_row -> 'workspace_id' THEN + RAISE EXCEPTION USING ERRCODE = '23514', CONSTRAINT = 'projects_workspace_immutable_ck', MESSAGE = 'Project workspace ownership is immutable'; + ELSIF TG_TABLE_NAME = 'conversations' AND new_row -> 'project_id' IS DISTINCT FROM old_row -> 'project_id' THEN + RAISE EXCEPTION USING ERRCODE = '23514', CONSTRAINT = 'conversations_project_immutable_ck', MESSAGE = 'Conversation project ownership is immutable'; + ELSIF TG_TABLE_NAME = 'conversations' AND new_row -> 'root_thread_id' IS DISTINCT FROM old_row -> 'root_thread_id' THEN + RAISE EXCEPTION USING ERRCODE = '23514', CONSTRAINT = 'conversations_root_thread_immutable_ck', MESSAGE = 'Conversation root Thread identity is immutable'; + ELSIF TG_TABLE_NAME = 'conversation_threads' AND new_row -> 'conversation_id' IS DISTINCT FROM old_row -> 'conversation_id' THEN + RAISE EXCEPTION USING ERRCODE = '23514', CONSTRAINT = 'conversation_threads_conversation_immutable_ck', MESSAGE = 'Thread conversation ownership is immutable'; + ELSIF TG_TABLE_NAME = 'conversation_turns' AND new_row -> 'thread_id' IS DISTINCT FROM old_row -> 'thread_id' THEN + RAISE EXCEPTION USING ERRCODE = '23514', CONSTRAINT = 'conversation_turns_thread_immutable_ck', MESSAGE = 'Turn thread ownership is immutable'; + ELSIF TG_TABLE_NAME = 'conversation_messages' AND (new_row -> 'thread_id' IS DISTINCT FROM old_row -> 'thread_id' OR new_row -> 'turn_id' IS DISTINCT FROM old_row -> 'turn_id') THEN + RAISE EXCEPTION USING ERRCODE = '23514', CONSTRAINT = 'conversation_messages_owner_immutable_ck', MESSAGE = 'Message Thread and Turn ownership is immutable'; + END IF; + RETURN NEW; +END; +$$;--> statement-breakpoint +CREATE TRIGGER "projects_reparenting_guard" + BEFORE UPDATE ON "thread_chat"."projects" + FOR EACH ROW EXECUTE FUNCTION "thread_chat"."reject_canonical_reparenting"();--> statement-breakpoint +CREATE TRIGGER "conversations_reparenting_guard" + BEFORE UPDATE ON "thread_chat"."conversations" + FOR EACH ROW EXECUTE FUNCTION "thread_chat"."reject_canonical_reparenting"();--> statement-breakpoint +CREATE TRIGGER "conversation_threads_reparenting_guard" + BEFORE UPDATE ON "thread_chat"."conversation_threads" + FOR EACH ROW EXECUTE FUNCTION "thread_chat"."reject_canonical_reparenting"();--> statement-breakpoint +CREATE TRIGGER "conversation_turns_reparenting_guard" + BEFORE UPDATE ON "thread_chat"."conversation_turns" + FOR EACH ROW EXECUTE FUNCTION "thread_chat"."reject_canonical_reparenting"();--> statement-breakpoint +CREATE TRIGGER "conversation_messages_reparenting_guard" + BEFORE UPDATE ON "thread_chat"."conversation_messages" + FOR EACH ROW EXECUTE FUNCTION "thread_chat"."reject_canonical_reparenting"();--> statement-breakpoint + +CREATE FUNCTION "thread_chat"."validate_conversation_integrity"() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +DECLARE + target_conversation_id text; + target_root_thread_id text; +BEGIN + target_conversation_id := CASE WHEN TG_OP = 'DELETE' THEN OLD.conversation_id ELSE NEW.conversation_id END; + + SELECT root_thread_id INTO target_root_thread_id + FROM "thread_chat"."conversations" + WHERE id = target_conversation_id; + + IF target_root_thread_id IS NULL THEN + RETURN NULL; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM "thread_chat"."conversation_threads" + WHERE id = target_root_thread_id AND conversation_id = target_conversation_id + ) THEN + RAISE EXCEPTION USING ERRCODE = '23514', CONSTRAINT = 'conversation_root_thread_integrity_ck', MESSAGE = 'Conversation root Thread is missing or belongs to another Conversation'; + END IF; + + IF EXISTS ( + SELECT 1 FROM "thread_chat"."conversation_threads" + WHERE id = target_root_thread_id AND local_title IS NOT NULL AND btrim(local_title) <> '' + ) THEN + RAISE EXCEPTION USING ERRCODE = '23514', CONSTRAINT = 'conversation_root_thread_title_ck', MESSAGE = 'Root Thread must not duplicate the Conversation title'; + END IF; + + IF EXISTS ( + SELECT 1 FROM "thread_chat"."thread_forks" + WHERE conversation_id = target_conversation_id AND child_thread_id = target_root_thread_id + ) THEN + RAISE EXCEPTION USING ERRCODE = '23514', CONSTRAINT = 'conversation_root_has_no_fork_ck', MESSAGE = 'Conversation root Thread cannot have an incoming Fork'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM "thread_chat"."conversation_threads" thread + WHERE thread.conversation_id = target_conversation_id + AND thread.id <> target_root_thread_id + AND NOT EXISTS ( + SELECT 1 FROM "thread_chat"."thread_forks" fork + WHERE fork.conversation_id = target_conversation_id + AND fork.child_thread_id = thread.id + ) + ) THEN + RAISE EXCEPTION USING ERRCODE = '23514', CONSTRAINT = 'conversation_non_root_has_fork_ck', MESSAGE = 'Every non-root Thread must have exactly one incoming Fork'; + END IF; + + IF EXISTS ( + WITH RECURSIVE fork_walk(parent_thread_id, child_thread_id, path, cycle) AS ( + SELECT parent_thread_id, child_thread_id, ARRAY[parent_thread_id, child_thread_id], parent_thread_id = child_thread_id + FROM "thread_chat"."thread_forks" + WHERE conversation_id = target_conversation_id + UNION ALL + SELECT walk.parent_thread_id, fork.child_thread_id, walk.path || fork.child_thread_id, fork.child_thread_id = ANY(walk.path) + FROM fork_walk walk + JOIN "thread_chat"."thread_forks" fork + ON fork.conversation_id = target_conversation_id + AND fork.parent_thread_id = walk.child_thread_id + WHERE NOT walk.cycle + ) + SELECT 1 FROM fork_walk WHERE cycle + ) THEN + RAISE EXCEPTION USING ERRCODE = '23514', CONSTRAINT = 'conversation_fork_acyclic_ck', MESSAGE = 'ThreadFork graph cannot contain a cycle'; + END IF; + + RETURN NULL; +END; +$$;--> statement-breakpoint +CREATE CONSTRAINT TRIGGER "conversation_threads_integrity_guard" + AFTER INSERT OR UPDATE OR DELETE ON "thread_chat"."conversation_threads" + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION "thread_chat"."validate_conversation_integrity"();--> statement-breakpoint +CREATE CONSTRAINT TRIGGER "thread_forks_integrity_guard" + AFTER INSERT OR UPDATE OR DELETE ON "thread_chat"."thread_forks" + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION "thread_chat"."validate_conversation_integrity"();--> statement-breakpoint + +CREATE FUNCTION "thread_chat"."validate_turn_message_roles"() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +DECLARE + target_turn_id text; + active_user_id text; + active_assistant_id text; + old_row jsonb := to_jsonb(OLD); + new_row jsonb := to_jsonb(NEW); +BEGIN + target_turn_id := CASE + WHEN TG_TABLE_NAME = 'conversation_turns' AND TG_OP = 'DELETE' THEN old_row ->> 'id' + WHEN TG_TABLE_NAME = 'conversation_turns' THEN new_row ->> 'id' + WHEN TG_OP = 'DELETE' THEN old_row ->> 'turn_id' + ELSE new_row ->> 'turn_id' + END; + + SELECT active_user_message_id, active_assistant_message_id + INTO active_user_id, active_assistant_id + FROM "thread_chat"."conversation_turns" + WHERE id = target_turn_id; + + IF active_user_id IS NULL THEN + RETURN NULL; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM "thread_chat"."conversation_messages" + WHERE id = active_user_id AND turn_id = target_turn_id AND role = 'user' + ) THEN + RAISE EXCEPTION USING ERRCODE = '23514', CONSTRAINT = 'conversation_turn_active_user_role_ck', MESSAGE = 'Turn active user Message must have user role'; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM "thread_chat"."conversation_messages" + WHERE id = active_assistant_id AND turn_id = target_turn_id AND role = 'assistant' + ) THEN + RAISE EXCEPTION USING ERRCODE = '23514', CONSTRAINT = 'conversation_turn_active_assistant_role_ck', MESSAGE = 'Turn active assistant Message must have assistant role'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM "thread_chat"."conversation_messages" message + JOIN "thread_chat"."conversation_messages" source + ON source.id = message.variant_of_message_id + AND source.thread_id = message.thread_id + AND source.turn_id = message.turn_id + WHERE message.turn_id = target_turn_id + AND message.variant_of_message_id IS NOT NULL + AND message.role <> source.role + ) THEN + RAISE EXCEPTION USING ERRCODE = '23514', CONSTRAINT = 'conversation_message_variant_role_ck', MESSAGE = 'Message variant must preserve role'; + END IF; + + RETURN NULL; +END; +$$;--> statement-breakpoint +CREATE CONSTRAINT TRIGGER "conversation_turns_active_role_guard" + AFTER INSERT OR UPDATE OR DELETE ON "thread_chat"."conversation_turns" + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION "thread_chat"."validate_turn_message_roles"();--> statement-breakpoint +CREATE CONSTRAINT TRIGGER "conversation_messages_active_role_guard" + AFTER INSERT OR UPDATE OR DELETE ON "thread_chat"."conversation_messages" + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION "thread_chat"."validate_turn_message_roles"(); diff --git a/drizzle/0005_fix-canonical-reparenting.sql b/drizzle/0005_fix-canonical-reparenting.sql new file mode 100644 index 00000000..83cce895 --- /dev/null +++ b/drizzle/0005_fix-canonical-reparenting.sql @@ -0,0 +1,24 @@ +CREATE OR REPLACE FUNCTION "thread_chat"."reject_canonical_reparenting"() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +DECLARE + old_row jsonb := to_jsonb(OLD); + new_row jsonb := to_jsonb(NEW); +BEGIN + IF TG_TABLE_NAME = 'projects' AND new_row -> 'workspace_id' IS DISTINCT FROM old_row -> 'workspace_id' THEN + RAISE EXCEPTION USING ERRCODE = '23514', CONSTRAINT = 'projects_workspace_immutable_ck', MESSAGE = 'Project workspace ownership is immutable'; + ELSIF TG_TABLE_NAME = 'conversations' AND new_row -> 'project_id' IS DISTINCT FROM old_row -> 'project_id' THEN + RAISE EXCEPTION USING ERRCODE = '23514', CONSTRAINT = 'conversations_project_immutable_ck', MESSAGE = 'Conversation project ownership is immutable'; + ELSIF TG_TABLE_NAME = 'conversations' AND new_row -> 'root_thread_id' IS DISTINCT FROM old_row -> 'root_thread_id' THEN + RAISE EXCEPTION USING ERRCODE = '23514', CONSTRAINT = 'conversations_root_thread_immutable_ck', MESSAGE = 'Conversation root Thread identity is immutable'; + ELSIF TG_TABLE_NAME = 'conversation_threads' AND new_row -> 'conversation_id' IS DISTINCT FROM old_row -> 'conversation_id' THEN + RAISE EXCEPTION USING ERRCODE = '23514', CONSTRAINT = 'conversation_threads_conversation_immutable_ck', MESSAGE = 'Thread conversation ownership is immutable'; + ELSIF TG_TABLE_NAME = 'conversation_turns' AND new_row -> 'thread_id' IS DISTINCT FROM old_row -> 'thread_id' THEN + RAISE EXCEPTION USING ERRCODE = '23514', CONSTRAINT = 'conversation_turns_thread_immutable_ck', MESSAGE = 'Turn thread ownership is immutable'; + ELSIF TG_TABLE_NAME = 'conversation_messages' AND (new_row -> 'thread_id' IS DISTINCT FROM old_row -> 'thread_id' OR new_row -> 'turn_id' IS DISTINCT FROM old_row -> 'turn_id') THEN + RAISE EXCEPTION USING ERRCODE = '23514', CONSTRAINT = 'conversation_messages_owner_immutable_ck', MESSAGE = 'Message Thread and Turn ownership is immutable'; + END IF; + RETURN NEW; +END; +$$; diff --git a/drizzle/0006_fix-turn-role-validation.sql b/drizzle/0006_fix-turn-role-validation.sql new file mode 100644 index 00000000..5ec7a4a1 --- /dev/null +++ b/drizzle/0006_fix-turn-role-validation.sql @@ -0,0 +1,58 @@ +CREATE OR REPLACE FUNCTION "thread_chat"."validate_turn_message_roles"() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +DECLARE + target_turn_id text; + active_user_id text; + active_assistant_id text; + old_row jsonb := to_jsonb(OLD); + new_row jsonb := to_jsonb(NEW); +BEGIN + target_turn_id := CASE + WHEN TG_TABLE_NAME = 'conversation_turns' AND TG_OP = 'DELETE' THEN old_row ->> 'id' + WHEN TG_TABLE_NAME = 'conversation_turns' THEN new_row ->> 'id' + WHEN TG_OP = 'DELETE' THEN old_row ->> 'turn_id' + ELSE new_row ->> 'turn_id' + END; + + SELECT active_user_message_id, active_assistant_message_id + INTO active_user_id, active_assistant_id + FROM "thread_chat"."conversation_turns" + WHERE id = target_turn_id; + + IF active_user_id IS NULL THEN + RETURN NULL; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM "thread_chat"."conversation_messages" + WHERE id = active_user_id AND turn_id = target_turn_id AND role = 'user' + ) THEN + RAISE EXCEPTION USING ERRCODE = '23514', CONSTRAINT = 'conversation_turn_active_user_role_ck', MESSAGE = 'Turn active user Message must have user role'; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM "thread_chat"."conversation_messages" + WHERE id = active_assistant_id AND turn_id = target_turn_id AND role = 'assistant' + ) THEN + RAISE EXCEPTION USING ERRCODE = '23514', CONSTRAINT = 'conversation_turn_active_assistant_role_ck', MESSAGE = 'Turn active assistant Message must have assistant role'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM "thread_chat"."conversation_messages" message + JOIN "thread_chat"."conversation_messages" source + ON source.id = message.variant_of_message_id + AND source.thread_id = message.thread_id + AND source.turn_id = message.turn_id + WHERE message.turn_id = target_turn_id + AND message.variant_of_message_id IS NOT NULL + AND message.role <> source.role + ) THEN + RAISE EXCEPTION USING ERRCODE = '23514', CONSTRAINT = 'conversation_message_variant_role_ck', MESSAGE = 'Message variant must preserve role'; + END IF; + + RETURN NULL; +END; +$$; diff --git a/drizzle/0007_protect-root-thread-identity.sql b/drizzle/0007_protect-root-thread-identity.sql new file mode 100644 index 00000000..83cce895 --- /dev/null +++ b/drizzle/0007_protect-root-thread-identity.sql @@ -0,0 +1,24 @@ +CREATE OR REPLACE FUNCTION "thread_chat"."reject_canonical_reparenting"() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +DECLARE + old_row jsonb := to_jsonb(OLD); + new_row jsonb := to_jsonb(NEW); +BEGIN + IF TG_TABLE_NAME = 'projects' AND new_row -> 'workspace_id' IS DISTINCT FROM old_row -> 'workspace_id' THEN + RAISE EXCEPTION USING ERRCODE = '23514', CONSTRAINT = 'projects_workspace_immutable_ck', MESSAGE = 'Project workspace ownership is immutable'; + ELSIF TG_TABLE_NAME = 'conversations' AND new_row -> 'project_id' IS DISTINCT FROM old_row -> 'project_id' THEN + RAISE EXCEPTION USING ERRCODE = '23514', CONSTRAINT = 'conversations_project_immutable_ck', MESSAGE = 'Conversation project ownership is immutable'; + ELSIF TG_TABLE_NAME = 'conversations' AND new_row -> 'root_thread_id' IS DISTINCT FROM old_row -> 'root_thread_id' THEN + RAISE EXCEPTION USING ERRCODE = '23514', CONSTRAINT = 'conversations_root_thread_immutable_ck', MESSAGE = 'Conversation root Thread identity is immutable'; + ELSIF TG_TABLE_NAME = 'conversation_threads' AND new_row -> 'conversation_id' IS DISTINCT FROM old_row -> 'conversation_id' THEN + RAISE EXCEPTION USING ERRCODE = '23514', CONSTRAINT = 'conversation_threads_conversation_immutable_ck', MESSAGE = 'Thread conversation ownership is immutable'; + ELSIF TG_TABLE_NAME = 'conversation_turns' AND new_row -> 'thread_id' IS DISTINCT FROM old_row -> 'thread_id' THEN + RAISE EXCEPTION USING ERRCODE = '23514', CONSTRAINT = 'conversation_turns_thread_immutable_ck', MESSAGE = 'Turn thread ownership is immutable'; + ELSIF TG_TABLE_NAME = 'conversation_messages' AND (new_row -> 'thread_id' IS DISTINCT FROM old_row -> 'thread_id' OR new_row -> 'turn_id' IS DISTINCT FROM old_row -> 'turn_id') THEN + RAISE EXCEPTION USING ERRCODE = '23514', CONSTRAINT = 'conversation_messages_owner_immutable_ck', MESSAGE = 'Message Thread and Turn ownership is immutable'; + END IF; + RETURN NEW; +END; +$$; diff --git a/drizzle/meta/0004_snapshot.json b/drizzle/meta/0004_snapshot.json new file mode 100644 index 00000000..77983450 --- /dev/null +++ b/drizzle/meta/0004_snapshot.json @@ -0,0 +1,2515 @@ +{ + "id": "ef8ec1c7-9942-492f-8251-ee7816471274", + "prevId": "69a0fc10-52d5-444e-bab9-cb018cd1ddaf", + "version": "7", + "dialect": "postgresql", + "tables": { + "thread_chat.attachment_chunks": { + "name": "attachment_chunks", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "attachment_id": { + "name": "attachment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "page": { + "name": "page", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "attachment_chunks_attachment_id_idx": { + "name": "attachment_chunks_attachment_id_idx", + "columns": [ + { + "expression": "attachment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "attachment_chunks_embedding_idx": { + "name": "attachment_chunks_embedding_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": {} + } + }, + "foreignKeys": { + "attachment_chunks_attachment_id_attachments_id_fk": { + "name": "attachment_chunks_attachment_id_attachments_id_fk", + "tableFrom": "attachment_chunks", + "tableTo": "attachments", + "schemaTo": "thread_chat", + "columnsFrom": [ + "attachment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.attachments": { + "name": "attachments", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'uploading'" + }, + "page_count": { + "name": "page_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pages": { + "name": "pages", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "suggested_questions": { + "name": "suggested_questions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "attachments_key_unique": { + "name": "attachments_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.branch_generations": { + "name": "branch_generations", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tree_id": { + "name": "tree_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_message_id": { + "name": "user_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assistant_message_id": { + "name": "assistant_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assistant_message_index": { + "name": "assistant_message_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "turn_snapshot": { + "name": "turn_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_status": { + "name": "billing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "heartbeat_at": { + "name": "heartbeat_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "stop_requested_at": { + "name": "stop_requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "branch_generations_current_assistant_uq": { + "name": "branch_generations_current_assistant_uq", + "columns": [ + { + "expression": "tree_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assistant_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"thread_chat\".\"branch_generations\".\"is_current\" = true", + "concurrently": false, + "method": "btree", + "with": {} + }, + "branch_generations_assistant_attempt_uq": { + "name": "branch_generations_assistant_attempt_uq", + "columns": [ + { + "expression": "tree_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assistant_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "attempt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "branch_generations_user_id_idx": { + "name": "branch_generations_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "branch_generations_tree_current_idx": { + "name": "branch_generations_tree_current_idx", + "columns": [ + { + "expression": "tree_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_current", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "branch_generations_user_status_idx": { + "name": "branch_generations_user_status_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "branch_generations_heartbeat_idx": { + "name": "branch_generations_heartbeat_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "heartbeat_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "branch_generations_user_id_user_id_fk": { + "name": "branch_generations_user_id_user_id_fk", + "tableFrom": "branch_generations", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "branch_generations_tree_id_branch_trees_id_fk": { + "name": "branch_generations_tree_id_branch_trees_id_fk", + "tableFrom": "branch_generations", + "tableTo": "branch_trees", + "schemaTo": "thread_chat", + "columnsFrom": [ + "tree_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.branch_message_feedback": { + "name": "branch_message_feedback", + "schema": "thread_chat", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tree_id": { + "name": "tree_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "branch_message_feedback_tree_idx": { + "name": "branch_message_feedback_tree_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tree_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "branch_message_feedback_user_id_user_id_fk": { + "name": "branch_message_feedback_user_id_user_id_fk", + "tableFrom": "branch_message_feedback", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "branch_message_feedback_tree_id_branch_trees_id_fk": { + "name": "branch_message_feedback_tree_id_branch_trees_id_fk", + "tableFrom": "branch_message_feedback", + "tableTo": "branch_trees", + "schemaTo": "thread_chat", + "columnsFrom": [ + "tree_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "branch_message_feedback_pk": { + "name": "branch_message_feedback_pk", + "columns": [ + "user_id", + "tree_id", + "thread_id", + "message_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.branch_trees": { + "name": "branch_trees", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_title": { + "name": "custom_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "branch_trees_user_id_idx": { + "name": "branch_trees_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "branch_trees_user_id_user_id_fk": { + "name": "branch_trees_user_id_user_id_fk", + "tableFrom": "branch_trees", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.conversation_messages": { + "name": "conversation_messages", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "content_state": { + "name": "content_state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variant_of_message_id": { + "name": "variant_of_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversation_messages_id_thread_id_uq": { + "name": "conversation_messages_id_thread_id_uq", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_messages_id_thread_turn_uq": { + "name": "conversation_messages_id_thread_turn_uq", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "turn_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_messages_turn_created_idx": { + "name": "conversation_messages_turn_created_idx", + "columns": [ + { + "expression": "turn_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_messages_variant_source_idx": { + "name": "conversation_messages_variant_source_idx", + "columns": [ + { + "expression": "variant_of_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.conversation_threads": { + "name": "conversation_threads", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "local_title": { + "name": "local_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversation_threads_id_conversation_id_uq": { + "name": "conversation_threads_id_conversation_id_uq", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_threads_conversation_created_idx": { + "name": "conversation_threads_conversation_created_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversation_threads_conversation_id_conversations_id_fk": { + "name": "conversation_threads_conversation_id_conversations_id_fk", + "tableFrom": "conversation_threads", + "tableTo": "conversations", + "schemaTo": "thread_chat", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.conversation_turns": { + "name": "conversation_turns", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "active_user_message_id": { + "name": "active_user_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_assistant_message_id": { + "name": "active_assistant_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversation_turns_id_thread_id_uq": { + "name": "conversation_turns_id_thread_id_uq", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_turns_thread_position_uq": { + "name": "conversation_turns_thread_position_uq", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_turns_active_user_idx": { + "name": "conversation_turns_active_user_idx", + "columns": [ + { + "expression": "active_user_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_turns_active_assistant_idx": { + "name": "conversation_turns_active_assistant_idx", + "columns": [ + { + "expression": "active_assistant_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversation_turns_thread_id_conversation_threads_id_fk": { + "name": "conversation_turns_thread_id_conversation_threads_id_fk", + "tableFrom": "conversation_turns", + "tableTo": "conversation_threads", + "schemaTo": "thread_chat", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.conversations": { + "name": "conversations", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "root_thread_id": { + "name": "root_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auto_title": { + "name": "auto_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_title": { + "name": "custom_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversations_project_id_id_uq": { + "name": "conversations_project_id_id_uq", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversations_project_updated_idx": { + "name": "conversations_project_updated_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversations_project_lifecycle_idx": { + "name": "conversations_project_lifecycle_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lifecycle", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversations_project_id_projects_id_fk": { + "name": "conversations_project_id_projects_id_fk", + "tableFrom": "conversations", + "tableTo": "projects", + "schemaTo": "thread_chat", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.projects": { + "name": "projects", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "projects_workspace_id_id_uq": { + "name": "projects_workspace_id_id_uq", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "projects_workspace_lifecycle_idx": { + "name": "projects_workspace_lifecycle_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lifecycle", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_workspace_id_workspaces_id_fk": { + "name": "projects_workspace_id_workspaces_id_fk", + "tableFrom": "projects", + "tableTo": "workspaces", + "schemaTo": "thread_chat", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.thread_forks": { + "name": "thread_forks", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_thread_id": { + "name": "parent_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_message_id": { + "name": "source_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_thread_id": { + "name": "child_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "anchor": { + "name": "anchor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "thread_forks_child_thread_uq": { + "name": "thread_forks_child_thread_uq", + "columns": [ + { + "expression": "child_thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "thread_forks_parent_source_idx": { + "name": "thread_forks_parent_source_idx", + "columns": [ + { + "expression": "parent_thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "thread_forks_conversation_created_idx": { + "name": "thread_forks_conversation_created_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "thread_forks_conversation_id_conversations_id_fk": { + "name": "thread_forks_conversation_id_conversations_id_fk", + "tableFrom": "thread_forks", + "tableTo": "conversations", + "schemaTo": "thread_chat", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "thread_forks_created_by_user_id_fk": { + "name": "thread_forks_created_by_user_id_fk", + "tableFrom": "thread_forks", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.workspace_members": { + "name": "workspace_members", + "schema": "thread_chat", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_members_user_id_idx": { + "name": "workspace_members_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_members_workspace_id_workspaces_id_fk": { + "name": "workspace_members_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_members", + "tableTo": "workspaces", + "schemaTo": "thread_chat", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_members_user_id_user_id_fk": { + "name": "workspace_members_user_id_user_id_fk", + "tableFrom": "workspace_members", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_members_pk": { + "name": "workspace_members_pk", + "columns": [ + "workspace_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.workspaces": { + "name": "workspaces", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspaces_lifecycle_idx": { + "name": "workspaces_lifecycle_idx", + "columns": [ + { + "expression": "lifecycle", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.account": { + "name": "account", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.session": { + "name": "session", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.user": { + "name": "user", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.verification": { + "name": "verification", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.usage_records": { + "name": "usage_records", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "app_generation_id": { + "name": "app_generation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_micros": { + "name": "cost_micros", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "price_micros": { + "name": "price_micros", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "generation_id": { + "name": "generation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cost_source": { + "name": "cost_source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'estimate'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_records_user_id_idx": { + "name": "usage_records_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_records_thread_id_idx": { + "name": "usage_records_thread_id_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_records_cost_source_idx": { + "name": "usage_records_cost_source_idx", + "columns": [ + { + "expression": "cost_source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_records_app_generation_id_uq": { + "name": "usage_records_app_generation_id_uq", + "columns": [ + { + "expression": "app_generation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_records_user_id_user_id_fk": { + "name": "usage_records_user_id_user_id_fk", + "tableFrom": "usage_records", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.user_credits": { + "name": "user_credits", + "schema": "thread_chat", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "balance_micros": { + "name": "balance_micros", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_credits_user_id_user_id_fk": { + "name": "user_credits_user_id_user_id_fk", + "tableFrom": "user_credits", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.payments": { + "name": "payments", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'creem'" + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pack_id": { + "name": "pack_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checkout_id": { + "name": "checkout_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order_id": { + "name": "order_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "credit_micros": { + "name": "credit_micros", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "price_label": { + "name": "price_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "raw": { + "name": "raw", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "payments_user_id_idx": { + "name": "payments_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payments_provider_order_id_uq": { + "name": "payments_provider_order_id_uq", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payments_user_id_user_id_fk": { + "name": "payments_user_id_user_id_fk", + "tableFrom": "payments", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.subscriptions": { + "name": "subscriptions", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'creem'" + }, + "subscription_id": { + "name": "subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_period_end": { + "name": "current_period_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "raw": { + "name": "raw", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscriptions_user_id_idx": { + "name": "subscriptions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "subscriptions_user_id_user_id_fk": { + "name": "subscriptions_user_id_user_id_fk", + "tableFrom": "subscriptions", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "subscriptions_subscription_id_unique": { + "name": "subscriptions_subscription_id_unique", + "nullsNotDistinct": false, + "columns": [ + "subscription_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/meta/0005_snapshot.json b/drizzle/meta/0005_snapshot.json new file mode 100644 index 00000000..248e4fbc --- /dev/null +++ b/drizzle/meta/0005_snapshot.json @@ -0,0 +1,2515 @@ +{ + "id": "97fd75ee-05bd-4625-92b1-1b5db345cad8", + "prevId": "ef8ec1c7-9942-492f-8251-ee7816471274", + "version": "7", + "dialect": "postgresql", + "tables": { + "thread_chat.attachment_chunks": { + "name": "attachment_chunks", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "attachment_id": { + "name": "attachment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "page": { + "name": "page", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "attachment_chunks_attachment_id_idx": { + "name": "attachment_chunks_attachment_id_idx", + "columns": [ + { + "expression": "attachment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "attachment_chunks_embedding_idx": { + "name": "attachment_chunks_embedding_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "hnsw", + "concurrently": false + } + }, + "foreignKeys": { + "attachment_chunks_attachment_id_attachments_id_fk": { + "name": "attachment_chunks_attachment_id_attachments_id_fk", + "tableFrom": "attachment_chunks", + "columnsFrom": [ + "attachment_id" + ], + "tableTo": "attachments", + "schemaTo": "thread_chat", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.attachments": { + "name": "attachments", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'uploading'" + }, + "page_count": { + "name": "page_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pages": { + "name": "pages", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "suggested_questions": { + "name": "suggested_questions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "attachments_key_unique": { + "name": "attachments_key_unique", + "columns": [ + "key" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.branch_generations": { + "name": "branch_generations", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tree_id": { + "name": "tree_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_message_id": { + "name": "user_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assistant_message_id": { + "name": "assistant_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assistant_message_index": { + "name": "assistant_message_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "turn_snapshot": { + "name": "turn_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_status": { + "name": "billing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "heartbeat_at": { + "name": "heartbeat_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "stop_requested_at": { + "name": "stop_requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "branch_generations_current_assistant_uq": { + "name": "branch_generations_current_assistant_uq", + "columns": [ + { + "expression": "tree_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assistant_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "\"thread_chat\".\"branch_generations\".\"is_current\" = true", + "concurrently": false + }, + "branch_generations_assistant_attempt_uq": { + "name": "branch_generations_assistant_attempt_uq", + "columns": [ + { + "expression": "tree_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assistant_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "attempt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "branch_generations_user_id_idx": { + "name": "branch_generations_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "branch_generations_tree_current_idx": { + "name": "branch_generations_tree_current_idx", + "columns": [ + { + "expression": "tree_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_current", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "branch_generations_user_status_idx": { + "name": "branch_generations_user_status_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "branch_generations_heartbeat_idx": { + "name": "branch_generations_heartbeat_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "heartbeat_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "branch_generations_user_id_user_id_fk": { + "name": "branch_generations_user_id_user_id_fk", + "tableFrom": "branch_generations", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "branch_generations_tree_id_branch_trees_id_fk": { + "name": "branch_generations_tree_id_branch_trees_id_fk", + "tableFrom": "branch_generations", + "columnsFrom": [ + "tree_id" + ], + "tableTo": "branch_trees", + "schemaTo": "thread_chat", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.branch_message_feedback": { + "name": "branch_message_feedback", + "schema": "thread_chat", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tree_id": { + "name": "tree_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "branch_message_feedback_tree_idx": { + "name": "branch_message_feedback_tree_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tree_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "branch_message_feedback_user_id_user_id_fk": { + "name": "branch_message_feedback_user_id_user_id_fk", + "tableFrom": "branch_message_feedback", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "branch_message_feedback_tree_id_branch_trees_id_fk": { + "name": "branch_message_feedback_tree_id_branch_trees_id_fk", + "tableFrom": "branch_message_feedback", + "columnsFrom": [ + "tree_id" + ], + "tableTo": "branch_trees", + "schemaTo": "thread_chat", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": { + "branch_message_feedback_pk": { + "name": "branch_message_feedback_pk", + "columns": [ + "user_id", + "tree_id", + "thread_id", + "message_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.branch_trees": { + "name": "branch_trees", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_title": { + "name": "custom_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "branch_trees_user_id_idx": { + "name": "branch_trees_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "branch_trees_user_id_user_id_fk": { + "name": "branch_trees_user_id_user_id_fk", + "tableFrom": "branch_trees", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.conversation_messages": { + "name": "conversation_messages", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "content_state": { + "name": "content_state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variant_of_message_id": { + "name": "variant_of_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversation_messages_id_thread_id_uq": { + "name": "conversation_messages_id_thread_id_uq", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "conversation_messages_id_thread_turn_uq": { + "name": "conversation_messages_id_thread_turn_uq", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "turn_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "conversation_messages_turn_created_idx": { + "name": "conversation_messages_turn_created_idx", + "columns": [ + { + "expression": "turn_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "conversation_messages_variant_source_idx": { + "name": "conversation_messages_variant_source_idx", + "columns": [ + { + "expression": "variant_of_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.conversation_threads": { + "name": "conversation_threads", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "local_title": { + "name": "local_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversation_threads_id_conversation_id_uq": { + "name": "conversation_threads_id_conversation_id_uq", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "conversation_threads_conversation_created_idx": { + "name": "conversation_threads_conversation_created_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "conversation_threads_conversation_id_conversations_id_fk": { + "name": "conversation_threads_conversation_id_conversations_id_fk", + "tableFrom": "conversation_threads", + "columnsFrom": [ + "conversation_id" + ], + "tableTo": "conversations", + "schemaTo": "thread_chat", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.conversation_turns": { + "name": "conversation_turns", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "active_user_message_id": { + "name": "active_user_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_assistant_message_id": { + "name": "active_assistant_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversation_turns_id_thread_id_uq": { + "name": "conversation_turns_id_thread_id_uq", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "conversation_turns_thread_position_uq": { + "name": "conversation_turns_thread_position_uq", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "conversation_turns_active_user_idx": { + "name": "conversation_turns_active_user_idx", + "columns": [ + { + "expression": "active_user_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "conversation_turns_active_assistant_idx": { + "name": "conversation_turns_active_assistant_idx", + "columns": [ + { + "expression": "active_assistant_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "conversation_turns_thread_id_conversation_threads_id_fk": { + "name": "conversation_turns_thread_id_conversation_threads_id_fk", + "tableFrom": "conversation_turns", + "columnsFrom": [ + "thread_id" + ], + "tableTo": "conversation_threads", + "schemaTo": "thread_chat", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.conversations": { + "name": "conversations", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "root_thread_id": { + "name": "root_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auto_title": { + "name": "auto_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_title": { + "name": "custom_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversations_project_id_id_uq": { + "name": "conversations_project_id_id_uq", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "conversations_project_updated_idx": { + "name": "conversations_project_updated_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "conversations_project_lifecycle_idx": { + "name": "conversations_project_lifecycle_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lifecycle", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "conversations_project_id_projects_id_fk": { + "name": "conversations_project_id_projects_id_fk", + "tableFrom": "conversations", + "columnsFrom": [ + "project_id" + ], + "tableTo": "projects", + "schemaTo": "thread_chat", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.projects": { + "name": "projects", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "projects_workspace_id_id_uq": { + "name": "projects_workspace_id_id_uq", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "projects_workspace_lifecycle_idx": { + "name": "projects_workspace_lifecycle_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lifecycle", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "projects_workspace_id_workspaces_id_fk": { + "name": "projects_workspace_id_workspaces_id_fk", + "tableFrom": "projects", + "columnsFrom": [ + "workspace_id" + ], + "tableTo": "workspaces", + "schemaTo": "thread_chat", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.thread_forks": { + "name": "thread_forks", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_thread_id": { + "name": "parent_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_message_id": { + "name": "source_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_thread_id": { + "name": "child_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "anchor": { + "name": "anchor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "thread_forks_child_thread_uq": { + "name": "thread_forks_child_thread_uq", + "columns": [ + { + "expression": "child_thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "thread_forks_parent_source_idx": { + "name": "thread_forks_parent_source_idx", + "columns": [ + { + "expression": "parent_thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "thread_forks_conversation_created_idx": { + "name": "thread_forks_conversation_created_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "thread_forks_conversation_id_conversations_id_fk": { + "name": "thread_forks_conversation_id_conversations_id_fk", + "tableFrom": "thread_forks", + "columnsFrom": [ + "conversation_id" + ], + "tableTo": "conversations", + "schemaTo": "thread_chat", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "thread_forks_created_by_user_id_fk": { + "name": "thread_forks_created_by_user_id_fk", + "tableFrom": "thread_forks", + "columnsFrom": [ + "created_by" + ], + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "restrict" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.workspace_members": { + "name": "workspace_members", + "schema": "thread_chat", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_members_user_id_idx": { + "name": "workspace_members_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "workspace_members_workspace_id_workspaces_id_fk": { + "name": "workspace_members_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_members", + "columnsFrom": [ + "workspace_id" + ], + "tableTo": "workspaces", + "schemaTo": "thread_chat", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "workspace_members_user_id_user_id_fk": { + "name": "workspace_members_user_id_user_id_fk", + "tableFrom": "workspace_members", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": { + "workspace_members_pk": { + "name": "workspace_members_pk", + "columns": [ + "workspace_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.workspaces": { + "name": "workspaces", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspaces_lifecycle_idx": { + "name": "workspaces_lifecycle_idx", + "columns": [ + { + "expression": "lifecycle", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.account": { + "name": "account", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.session": { + "name": "session", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.user": { + "name": "user", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.verification": { + "name": "verification", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.usage_records": { + "name": "usage_records", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "app_generation_id": { + "name": "app_generation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_micros": { + "name": "cost_micros", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "price_micros": { + "name": "price_micros", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "generation_id": { + "name": "generation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cost_source": { + "name": "cost_source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'estimate'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_records_user_id_idx": { + "name": "usage_records_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "usage_records_thread_id_idx": { + "name": "usage_records_thread_id_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "usage_records_cost_source_idx": { + "name": "usage_records_cost_source_idx", + "columns": [ + { + "expression": "cost_source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "usage_records_app_generation_id_uq": { + "name": "usage_records_app_generation_id_uq", + "columns": [ + { + "expression": "app_generation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "usage_records_user_id_user_id_fk": { + "name": "usage_records_user_id_user_id_fk", + "tableFrom": "usage_records", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.user_credits": { + "name": "user_credits", + "schema": "thread_chat", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "balance_micros": { + "name": "balance_micros", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_credits_user_id_user_id_fk": { + "name": "user_credits_user_id_user_id_fk", + "tableFrom": "user_credits", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.payments": { + "name": "payments", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'creem'" + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pack_id": { + "name": "pack_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checkout_id": { + "name": "checkout_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order_id": { + "name": "order_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "credit_micros": { + "name": "credit_micros", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "price_label": { + "name": "price_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "raw": { + "name": "raw", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "payments_user_id_idx": { + "name": "payments_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "payments_provider_order_id_uq": { + "name": "payments_provider_order_id_uq", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "payments_user_id_user_id_fk": { + "name": "payments_user_id_user_id_fk", + "tableFrom": "payments", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.subscriptions": { + "name": "subscriptions", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'creem'" + }, + "subscription_id": { + "name": "subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_period_end": { + "name": "current_period_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "raw": { + "name": "raw", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscriptions_user_id_idx": { + "name": "subscriptions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "subscriptions_user_id_user_id_fk": { + "name": "subscriptions_user_id_user_id_fk", + "tableFrom": "subscriptions", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "subscriptions_subscription_id_unique": { + "name": "subscriptions_subscription_id_unique", + "columns": [ + "subscription_id" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "views": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0006_snapshot.json b/drizzle/meta/0006_snapshot.json new file mode 100644 index 00000000..1192bd6e --- /dev/null +++ b/drizzle/meta/0006_snapshot.json @@ -0,0 +1,2515 @@ +{ + "id": "4a919dd2-9958-41da-94de-bf8c7727dfe0", + "prevId": "97fd75ee-05bd-4625-92b1-1b5db345cad8", + "version": "7", + "dialect": "postgresql", + "tables": { + "thread_chat.attachment_chunks": { + "name": "attachment_chunks", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "attachment_id": { + "name": "attachment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "page": { + "name": "page", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "attachment_chunks_attachment_id_idx": { + "name": "attachment_chunks_attachment_id_idx", + "columns": [ + { + "expression": "attachment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "attachment_chunks_embedding_idx": { + "name": "attachment_chunks_embedding_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "hnsw", + "concurrently": false + } + }, + "foreignKeys": { + "attachment_chunks_attachment_id_attachments_id_fk": { + "name": "attachment_chunks_attachment_id_attachments_id_fk", + "tableFrom": "attachment_chunks", + "columnsFrom": [ + "attachment_id" + ], + "tableTo": "attachments", + "schemaTo": "thread_chat", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.attachments": { + "name": "attachments", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'uploading'" + }, + "page_count": { + "name": "page_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pages": { + "name": "pages", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "suggested_questions": { + "name": "suggested_questions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "attachments_key_unique": { + "name": "attachments_key_unique", + "columns": [ + "key" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.branch_generations": { + "name": "branch_generations", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tree_id": { + "name": "tree_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_message_id": { + "name": "user_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assistant_message_id": { + "name": "assistant_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assistant_message_index": { + "name": "assistant_message_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "turn_snapshot": { + "name": "turn_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_status": { + "name": "billing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "heartbeat_at": { + "name": "heartbeat_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "stop_requested_at": { + "name": "stop_requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "branch_generations_current_assistant_uq": { + "name": "branch_generations_current_assistant_uq", + "columns": [ + { + "expression": "tree_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assistant_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "\"thread_chat\".\"branch_generations\".\"is_current\" = true", + "concurrently": false + }, + "branch_generations_assistant_attempt_uq": { + "name": "branch_generations_assistant_attempt_uq", + "columns": [ + { + "expression": "tree_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assistant_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "attempt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "branch_generations_user_id_idx": { + "name": "branch_generations_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "branch_generations_tree_current_idx": { + "name": "branch_generations_tree_current_idx", + "columns": [ + { + "expression": "tree_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_current", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "branch_generations_user_status_idx": { + "name": "branch_generations_user_status_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "branch_generations_heartbeat_idx": { + "name": "branch_generations_heartbeat_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "heartbeat_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "branch_generations_user_id_user_id_fk": { + "name": "branch_generations_user_id_user_id_fk", + "tableFrom": "branch_generations", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "branch_generations_tree_id_branch_trees_id_fk": { + "name": "branch_generations_tree_id_branch_trees_id_fk", + "tableFrom": "branch_generations", + "columnsFrom": [ + "tree_id" + ], + "tableTo": "branch_trees", + "schemaTo": "thread_chat", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.branch_message_feedback": { + "name": "branch_message_feedback", + "schema": "thread_chat", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tree_id": { + "name": "tree_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "branch_message_feedback_tree_idx": { + "name": "branch_message_feedback_tree_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tree_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "branch_message_feedback_user_id_user_id_fk": { + "name": "branch_message_feedback_user_id_user_id_fk", + "tableFrom": "branch_message_feedback", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "branch_message_feedback_tree_id_branch_trees_id_fk": { + "name": "branch_message_feedback_tree_id_branch_trees_id_fk", + "tableFrom": "branch_message_feedback", + "columnsFrom": [ + "tree_id" + ], + "tableTo": "branch_trees", + "schemaTo": "thread_chat", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": { + "branch_message_feedback_pk": { + "name": "branch_message_feedback_pk", + "columns": [ + "user_id", + "tree_id", + "thread_id", + "message_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.branch_trees": { + "name": "branch_trees", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_title": { + "name": "custom_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "branch_trees_user_id_idx": { + "name": "branch_trees_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "branch_trees_user_id_user_id_fk": { + "name": "branch_trees_user_id_user_id_fk", + "tableFrom": "branch_trees", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.conversation_messages": { + "name": "conversation_messages", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "content_state": { + "name": "content_state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variant_of_message_id": { + "name": "variant_of_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversation_messages_id_thread_id_uq": { + "name": "conversation_messages_id_thread_id_uq", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "conversation_messages_id_thread_turn_uq": { + "name": "conversation_messages_id_thread_turn_uq", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "turn_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "conversation_messages_turn_created_idx": { + "name": "conversation_messages_turn_created_idx", + "columns": [ + { + "expression": "turn_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "conversation_messages_variant_source_idx": { + "name": "conversation_messages_variant_source_idx", + "columns": [ + { + "expression": "variant_of_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.conversation_threads": { + "name": "conversation_threads", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "local_title": { + "name": "local_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversation_threads_id_conversation_id_uq": { + "name": "conversation_threads_id_conversation_id_uq", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "conversation_threads_conversation_created_idx": { + "name": "conversation_threads_conversation_created_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "conversation_threads_conversation_id_conversations_id_fk": { + "name": "conversation_threads_conversation_id_conversations_id_fk", + "tableFrom": "conversation_threads", + "columnsFrom": [ + "conversation_id" + ], + "tableTo": "conversations", + "schemaTo": "thread_chat", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.conversation_turns": { + "name": "conversation_turns", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "active_user_message_id": { + "name": "active_user_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_assistant_message_id": { + "name": "active_assistant_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversation_turns_id_thread_id_uq": { + "name": "conversation_turns_id_thread_id_uq", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "conversation_turns_thread_position_uq": { + "name": "conversation_turns_thread_position_uq", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "conversation_turns_active_user_idx": { + "name": "conversation_turns_active_user_idx", + "columns": [ + { + "expression": "active_user_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "conversation_turns_active_assistant_idx": { + "name": "conversation_turns_active_assistant_idx", + "columns": [ + { + "expression": "active_assistant_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "conversation_turns_thread_id_conversation_threads_id_fk": { + "name": "conversation_turns_thread_id_conversation_threads_id_fk", + "tableFrom": "conversation_turns", + "columnsFrom": [ + "thread_id" + ], + "tableTo": "conversation_threads", + "schemaTo": "thread_chat", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.conversations": { + "name": "conversations", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "root_thread_id": { + "name": "root_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auto_title": { + "name": "auto_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_title": { + "name": "custom_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversations_project_id_id_uq": { + "name": "conversations_project_id_id_uq", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "conversations_project_updated_idx": { + "name": "conversations_project_updated_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "conversations_project_lifecycle_idx": { + "name": "conversations_project_lifecycle_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lifecycle", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "conversations_project_id_projects_id_fk": { + "name": "conversations_project_id_projects_id_fk", + "tableFrom": "conversations", + "columnsFrom": [ + "project_id" + ], + "tableTo": "projects", + "schemaTo": "thread_chat", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.projects": { + "name": "projects", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "projects_workspace_id_id_uq": { + "name": "projects_workspace_id_id_uq", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "projects_workspace_lifecycle_idx": { + "name": "projects_workspace_lifecycle_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lifecycle", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "projects_workspace_id_workspaces_id_fk": { + "name": "projects_workspace_id_workspaces_id_fk", + "tableFrom": "projects", + "columnsFrom": [ + "workspace_id" + ], + "tableTo": "workspaces", + "schemaTo": "thread_chat", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.thread_forks": { + "name": "thread_forks", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_thread_id": { + "name": "parent_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_message_id": { + "name": "source_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_thread_id": { + "name": "child_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "anchor": { + "name": "anchor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "thread_forks_child_thread_uq": { + "name": "thread_forks_child_thread_uq", + "columns": [ + { + "expression": "child_thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "thread_forks_parent_source_idx": { + "name": "thread_forks_parent_source_idx", + "columns": [ + { + "expression": "parent_thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "thread_forks_conversation_created_idx": { + "name": "thread_forks_conversation_created_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "thread_forks_conversation_id_conversations_id_fk": { + "name": "thread_forks_conversation_id_conversations_id_fk", + "tableFrom": "thread_forks", + "columnsFrom": [ + "conversation_id" + ], + "tableTo": "conversations", + "schemaTo": "thread_chat", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "thread_forks_created_by_user_id_fk": { + "name": "thread_forks_created_by_user_id_fk", + "tableFrom": "thread_forks", + "columnsFrom": [ + "created_by" + ], + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "restrict" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.workspace_members": { + "name": "workspace_members", + "schema": "thread_chat", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_members_user_id_idx": { + "name": "workspace_members_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "workspace_members_workspace_id_workspaces_id_fk": { + "name": "workspace_members_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_members", + "columnsFrom": [ + "workspace_id" + ], + "tableTo": "workspaces", + "schemaTo": "thread_chat", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "workspace_members_user_id_user_id_fk": { + "name": "workspace_members_user_id_user_id_fk", + "tableFrom": "workspace_members", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": { + "workspace_members_pk": { + "name": "workspace_members_pk", + "columns": [ + "workspace_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.workspaces": { + "name": "workspaces", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspaces_lifecycle_idx": { + "name": "workspaces_lifecycle_idx", + "columns": [ + { + "expression": "lifecycle", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.account": { + "name": "account", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.session": { + "name": "session", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.user": { + "name": "user", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.verification": { + "name": "verification", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.usage_records": { + "name": "usage_records", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "app_generation_id": { + "name": "app_generation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_micros": { + "name": "cost_micros", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "price_micros": { + "name": "price_micros", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "generation_id": { + "name": "generation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cost_source": { + "name": "cost_source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'estimate'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_records_user_id_idx": { + "name": "usage_records_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "usage_records_thread_id_idx": { + "name": "usage_records_thread_id_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "usage_records_cost_source_idx": { + "name": "usage_records_cost_source_idx", + "columns": [ + { + "expression": "cost_source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "usage_records_app_generation_id_uq": { + "name": "usage_records_app_generation_id_uq", + "columns": [ + { + "expression": "app_generation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "usage_records_user_id_user_id_fk": { + "name": "usage_records_user_id_user_id_fk", + "tableFrom": "usage_records", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.user_credits": { + "name": "user_credits", + "schema": "thread_chat", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "balance_micros": { + "name": "balance_micros", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_credits_user_id_user_id_fk": { + "name": "user_credits_user_id_user_id_fk", + "tableFrom": "user_credits", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.payments": { + "name": "payments", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'creem'" + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pack_id": { + "name": "pack_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checkout_id": { + "name": "checkout_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order_id": { + "name": "order_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "credit_micros": { + "name": "credit_micros", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "price_label": { + "name": "price_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "raw": { + "name": "raw", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "payments_user_id_idx": { + "name": "payments_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "payments_provider_order_id_uq": { + "name": "payments_provider_order_id_uq", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "payments_user_id_user_id_fk": { + "name": "payments_user_id_user_id_fk", + "tableFrom": "payments", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.subscriptions": { + "name": "subscriptions", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'creem'" + }, + "subscription_id": { + "name": "subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_period_end": { + "name": "current_period_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "raw": { + "name": "raw", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscriptions_user_id_idx": { + "name": "subscriptions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "subscriptions_user_id_user_id_fk": { + "name": "subscriptions_user_id_user_id_fk", + "tableFrom": "subscriptions", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "subscriptions_subscription_id_unique": { + "name": "subscriptions_subscription_id_unique", + "columns": [ + "subscription_id" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "views": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0007_snapshot.json b/drizzle/meta/0007_snapshot.json new file mode 100644 index 00000000..a1948265 --- /dev/null +++ b/drizzle/meta/0007_snapshot.json @@ -0,0 +1,2515 @@ +{ + "id": "47a369b2-0690-4792-b3da-b53161cefd29", + "prevId": "4a919dd2-9958-41da-94de-bf8c7727dfe0", + "version": "7", + "dialect": "postgresql", + "tables": { + "thread_chat.attachment_chunks": { + "name": "attachment_chunks", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "attachment_id": { + "name": "attachment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "page": { + "name": "page", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "attachment_chunks_attachment_id_idx": { + "name": "attachment_chunks_attachment_id_idx", + "columns": [ + { + "expression": "attachment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "attachment_chunks_embedding_idx": { + "name": "attachment_chunks_embedding_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "hnsw", + "concurrently": false + } + }, + "foreignKeys": { + "attachment_chunks_attachment_id_attachments_id_fk": { + "name": "attachment_chunks_attachment_id_attachments_id_fk", + "tableFrom": "attachment_chunks", + "columnsFrom": [ + "attachment_id" + ], + "tableTo": "attachments", + "schemaTo": "thread_chat", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.attachments": { + "name": "attachments", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'uploading'" + }, + "page_count": { + "name": "page_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pages": { + "name": "pages", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "suggested_questions": { + "name": "suggested_questions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "attachments_key_unique": { + "name": "attachments_key_unique", + "columns": [ + "key" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.branch_generations": { + "name": "branch_generations", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tree_id": { + "name": "tree_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_message_id": { + "name": "user_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assistant_message_id": { + "name": "assistant_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assistant_message_index": { + "name": "assistant_message_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "turn_snapshot": { + "name": "turn_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_status": { + "name": "billing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "heartbeat_at": { + "name": "heartbeat_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "stop_requested_at": { + "name": "stop_requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "branch_generations_current_assistant_uq": { + "name": "branch_generations_current_assistant_uq", + "columns": [ + { + "expression": "tree_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assistant_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "\"thread_chat\".\"branch_generations\".\"is_current\" = true", + "concurrently": false + }, + "branch_generations_assistant_attempt_uq": { + "name": "branch_generations_assistant_attempt_uq", + "columns": [ + { + "expression": "tree_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assistant_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "attempt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "branch_generations_user_id_idx": { + "name": "branch_generations_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "branch_generations_tree_current_idx": { + "name": "branch_generations_tree_current_idx", + "columns": [ + { + "expression": "tree_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_current", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "branch_generations_user_status_idx": { + "name": "branch_generations_user_status_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "branch_generations_heartbeat_idx": { + "name": "branch_generations_heartbeat_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "heartbeat_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "branch_generations_user_id_user_id_fk": { + "name": "branch_generations_user_id_user_id_fk", + "tableFrom": "branch_generations", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "branch_generations_tree_id_branch_trees_id_fk": { + "name": "branch_generations_tree_id_branch_trees_id_fk", + "tableFrom": "branch_generations", + "columnsFrom": [ + "tree_id" + ], + "tableTo": "branch_trees", + "schemaTo": "thread_chat", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.branch_message_feedback": { + "name": "branch_message_feedback", + "schema": "thread_chat", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tree_id": { + "name": "tree_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "branch_message_feedback_tree_idx": { + "name": "branch_message_feedback_tree_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tree_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "branch_message_feedback_user_id_user_id_fk": { + "name": "branch_message_feedback_user_id_user_id_fk", + "tableFrom": "branch_message_feedback", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "branch_message_feedback_tree_id_branch_trees_id_fk": { + "name": "branch_message_feedback_tree_id_branch_trees_id_fk", + "tableFrom": "branch_message_feedback", + "columnsFrom": [ + "tree_id" + ], + "tableTo": "branch_trees", + "schemaTo": "thread_chat", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": { + "branch_message_feedback_pk": { + "name": "branch_message_feedback_pk", + "columns": [ + "user_id", + "tree_id", + "thread_id", + "message_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.branch_trees": { + "name": "branch_trees", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_title": { + "name": "custom_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "branch_trees_user_id_idx": { + "name": "branch_trees_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "branch_trees_user_id_user_id_fk": { + "name": "branch_trees_user_id_user_id_fk", + "tableFrom": "branch_trees", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.conversation_messages": { + "name": "conversation_messages", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "content_state": { + "name": "content_state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variant_of_message_id": { + "name": "variant_of_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversation_messages_id_thread_id_uq": { + "name": "conversation_messages_id_thread_id_uq", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "conversation_messages_id_thread_turn_uq": { + "name": "conversation_messages_id_thread_turn_uq", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "turn_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "conversation_messages_turn_created_idx": { + "name": "conversation_messages_turn_created_idx", + "columns": [ + { + "expression": "turn_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "conversation_messages_variant_source_idx": { + "name": "conversation_messages_variant_source_idx", + "columns": [ + { + "expression": "variant_of_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.conversation_threads": { + "name": "conversation_threads", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "local_title": { + "name": "local_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversation_threads_id_conversation_id_uq": { + "name": "conversation_threads_id_conversation_id_uq", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "conversation_threads_conversation_created_idx": { + "name": "conversation_threads_conversation_created_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "conversation_threads_conversation_id_conversations_id_fk": { + "name": "conversation_threads_conversation_id_conversations_id_fk", + "tableFrom": "conversation_threads", + "columnsFrom": [ + "conversation_id" + ], + "tableTo": "conversations", + "schemaTo": "thread_chat", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.conversation_turns": { + "name": "conversation_turns", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "active_user_message_id": { + "name": "active_user_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_assistant_message_id": { + "name": "active_assistant_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversation_turns_id_thread_id_uq": { + "name": "conversation_turns_id_thread_id_uq", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "conversation_turns_thread_position_uq": { + "name": "conversation_turns_thread_position_uq", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "conversation_turns_active_user_idx": { + "name": "conversation_turns_active_user_idx", + "columns": [ + { + "expression": "active_user_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "conversation_turns_active_assistant_idx": { + "name": "conversation_turns_active_assistant_idx", + "columns": [ + { + "expression": "active_assistant_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "conversation_turns_thread_id_conversation_threads_id_fk": { + "name": "conversation_turns_thread_id_conversation_threads_id_fk", + "tableFrom": "conversation_turns", + "columnsFrom": [ + "thread_id" + ], + "tableTo": "conversation_threads", + "schemaTo": "thread_chat", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.conversations": { + "name": "conversations", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "root_thread_id": { + "name": "root_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auto_title": { + "name": "auto_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_title": { + "name": "custom_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversations_project_id_id_uq": { + "name": "conversations_project_id_id_uq", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "conversations_project_updated_idx": { + "name": "conversations_project_updated_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "conversations_project_lifecycle_idx": { + "name": "conversations_project_lifecycle_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lifecycle", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "conversations_project_id_projects_id_fk": { + "name": "conversations_project_id_projects_id_fk", + "tableFrom": "conversations", + "columnsFrom": [ + "project_id" + ], + "tableTo": "projects", + "schemaTo": "thread_chat", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.projects": { + "name": "projects", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "projects_workspace_id_id_uq": { + "name": "projects_workspace_id_id_uq", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "projects_workspace_lifecycle_idx": { + "name": "projects_workspace_lifecycle_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lifecycle", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "projects_workspace_id_workspaces_id_fk": { + "name": "projects_workspace_id_workspaces_id_fk", + "tableFrom": "projects", + "columnsFrom": [ + "workspace_id" + ], + "tableTo": "workspaces", + "schemaTo": "thread_chat", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.thread_forks": { + "name": "thread_forks", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_thread_id": { + "name": "parent_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_message_id": { + "name": "source_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_thread_id": { + "name": "child_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "anchor": { + "name": "anchor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "thread_forks_child_thread_uq": { + "name": "thread_forks_child_thread_uq", + "columns": [ + { + "expression": "child_thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "thread_forks_parent_source_idx": { + "name": "thread_forks_parent_source_idx", + "columns": [ + { + "expression": "parent_thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "thread_forks_conversation_created_idx": { + "name": "thread_forks_conversation_created_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "thread_forks_conversation_id_conversations_id_fk": { + "name": "thread_forks_conversation_id_conversations_id_fk", + "tableFrom": "thread_forks", + "columnsFrom": [ + "conversation_id" + ], + "tableTo": "conversations", + "schemaTo": "thread_chat", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "thread_forks_created_by_user_id_fk": { + "name": "thread_forks_created_by_user_id_fk", + "tableFrom": "thread_forks", + "columnsFrom": [ + "created_by" + ], + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "restrict" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.workspace_members": { + "name": "workspace_members", + "schema": "thread_chat", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_members_user_id_idx": { + "name": "workspace_members_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "workspace_members_workspace_id_workspaces_id_fk": { + "name": "workspace_members_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_members", + "columnsFrom": [ + "workspace_id" + ], + "tableTo": "workspaces", + "schemaTo": "thread_chat", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "workspace_members_user_id_user_id_fk": { + "name": "workspace_members_user_id_user_id_fk", + "tableFrom": "workspace_members", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": { + "workspace_members_pk": { + "name": "workspace_members_pk", + "columns": [ + "workspace_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.workspaces": { + "name": "workspaces", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspaces_lifecycle_idx": { + "name": "workspaces_lifecycle_idx", + "columns": [ + { + "expression": "lifecycle", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.account": { + "name": "account", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.session": { + "name": "session", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.user": { + "name": "user", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.verification": { + "name": "verification", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.usage_records": { + "name": "usage_records", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "app_generation_id": { + "name": "app_generation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_micros": { + "name": "cost_micros", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "price_micros": { + "name": "price_micros", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "generation_id": { + "name": "generation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cost_source": { + "name": "cost_source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'estimate'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_records_user_id_idx": { + "name": "usage_records_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "usage_records_thread_id_idx": { + "name": "usage_records_thread_id_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "usage_records_cost_source_idx": { + "name": "usage_records_cost_source_idx", + "columns": [ + { + "expression": "cost_source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "usage_records_app_generation_id_uq": { + "name": "usage_records_app_generation_id_uq", + "columns": [ + { + "expression": "app_generation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "usage_records_user_id_user_id_fk": { + "name": "usage_records_user_id_user_id_fk", + "tableFrom": "usage_records", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.user_credits": { + "name": "user_credits", + "schema": "thread_chat", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "balance_micros": { + "name": "balance_micros", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_credits_user_id_user_id_fk": { + "name": "user_credits_user_id_user_id_fk", + "tableFrom": "user_credits", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.payments": { + "name": "payments", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'creem'" + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pack_id": { + "name": "pack_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checkout_id": { + "name": "checkout_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order_id": { + "name": "order_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "credit_micros": { + "name": "credit_micros", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "price_label": { + "name": "price_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "raw": { + "name": "raw", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "payments_user_id_idx": { + "name": "payments_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "payments_provider_order_id_uq": { + "name": "payments_provider_order_id_uq", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "payments_user_id_user_id_fk": { + "name": "payments_user_id_user_id_fk", + "tableFrom": "payments", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.subscriptions": { + "name": "subscriptions", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'creem'" + }, + "subscription_id": { + "name": "subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_period_end": { + "name": "current_period_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "raw": { + "name": "raw", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscriptions_user_id_idx": { + "name": "subscriptions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "subscriptions_user_id_user_id_fk": { + "name": "subscriptions_user_id_user_id_fk", + "tableFrom": "subscriptions", + "columnsFrom": [ + "user_id" + ], + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "subscriptions_subscription_id_unique": { + "name": "subscriptions_subscription_id_unique", + "columns": [ + "subscription_id" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "views": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 914f40c0..19599355 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -29,6 +29,34 @@ "when": 1787150821829, "tag": "0003_strong_bulldozer", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1787333636786, + "tag": "0004_wild_scarlet_witch", + "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1787334385592, + "tag": "0005_fix-canonical-reparenting", + "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1787334458710, + "tag": "0006_fix-turn-role-validation", + "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1787334734551, + "tag": "0007_protect-root-thread-identity", + "breakpoints": true } ] } \ No newline at end of file diff --git a/lib/db/schema.ts b/lib/db/schema.ts index bb6fb6a5..dada9660 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -20,6 +20,12 @@ import type { GenerationTurnSnapshot, } from "@/lib/thread-chat/domain/generation" import type { MessageFeedback } from "@/lib/thread-chat/domain/types" +import type { + LifecycleStatus, + MessageContent, + MessageContentState, + MessageRole, +} from "@/lib/thread-chat/domain/conversation-model" // 认证与计费表在独立文件中定义,这里统一 re-export,使 drizzle 客户端与迁移能感知它们。 export * from "./auth-schema" @@ -48,6 +54,236 @@ export const attachments = dbSchema.table("attachments", { .defaultNow(), }) +// Issue #34 的规范 Conversation 写模型。迁移期间这些表默认不承载生产写入;旧的 +// branch_trees 仍保留到 retire-thread-tree-authority 完成。关系型约束的可延迟部分 +// 位于对应 Drizzle SQL migration 中。 +export const workspaces = dbSchema.table( + "workspaces", + { + id: text("id").primaryKey(), + revision: integer("revision").notNull().default(0), + lifecycle: text("lifecycle").$type().notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => [index("workspaces_lifecycle_idx").on(table.lifecycle)] +) + +export const workspaceMembers = dbSchema.table( + "workspace_members", + { + workspaceId: text("workspace_id") + .notNull() + .references(() => workspaces.id, { onDelete: "cascade" }), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + role: text("role", { enum: ["owner", "member"] }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => [ + primaryKey({ + name: "workspace_members_pk", + columns: [table.workspaceId, table.userId], + }), + index("workspace_members_user_id_idx").on(table.userId), + ] +) + +export const projects = dbSchema.table( + "projects", + { + id: text("id").primaryKey(), + workspaceId: text("workspace_id") + .notNull() + .references(() => workspaces.id, { onDelete: "cascade" }), + title: text("title").notNull(), + revision: integer("revision").notNull().default(0), + lifecycle: text("lifecycle").$type().notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => [ + uniqueIndex("projects_workspace_id_id_uq").on(table.workspaceId, table.id), + index("projects_workspace_lifecycle_idx").on( + table.workspaceId, + table.lifecycle + ), + ] +) + +export const conversations = dbSchema.table( + "conversations", + { + id: text("id").primaryKey(), + projectId: text("project_id") + .notNull() + .references(() => projects.id, { onDelete: "cascade" }), + rootThreadId: text("root_thread_id").notNull(), + autoTitle: text("auto_title"), + customTitle: text("custom_title"), + revision: integer("revision").notNull().default(0), + lifecycle: text("lifecycle").$type().notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => [ + uniqueIndex("conversations_project_id_id_uq").on(table.projectId, table.id), + index("conversations_project_updated_idx").on( + table.projectId, + table.updatedAt + ), + index("conversations_project_lifecycle_idx").on( + table.projectId, + table.lifecycle + ), + ] +) + +export const conversationThreads = dbSchema.table( + "conversation_threads", + { + id: text("id").primaryKey(), + conversationId: text("conversation_id") + .notNull() + .references(() => conversations.id, { onDelete: "cascade" }), + modelId: text("model_id").notNull(), + localTitle: text("local_title"), + revision: integer("revision").notNull().default(0), + lifecycle: text("lifecycle").$type().notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => [ + uniqueIndex("conversation_threads_id_conversation_id_uq").on( + table.id, + table.conversationId + ), + index("conversation_threads_conversation_created_idx").on( + table.conversationId, + table.createdAt + ), + ] +) + +export const conversationTurns = dbSchema.table( + "conversation_turns", + { + id: text("id").primaryKey(), + threadId: text("thread_id") + .notNull() + .references(() => conversationThreads.id, { onDelete: "cascade" }), + position: integer("position").notNull(), + activeUserMessageId: text("active_user_message_id").notNull(), + activeAssistantMessageId: text("active_assistant_message_id").notNull(), + revision: integer("revision").notNull().default(0), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => [ + uniqueIndex("conversation_turns_id_thread_id_uq").on( + table.id, + table.threadId + ), + uniqueIndex("conversation_turns_thread_position_uq").on( + table.threadId, + table.position + ), + index("conversation_turns_active_user_idx").on(table.activeUserMessageId), + index("conversation_turns_active_assistant_idx").on( + table.activeAssistantMessageId + ), + ] +) + +export const conversationMessages = dbSchema.table( + "conversation_messages", + { + id: text("id").primaryKey(), + threadId: text("thread_id").notNull(), + turnId: text("turn_id").notNull(), + role: text("role").$type().notNull(), + content: jsonb("content").$type().notNull(), + contentState: text("content_state").$type().notNull(), + variantOfMessageId: text("variant_of_message_id"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => [ + uniqueIndex("conversation_messages_id_thread_id_uq").on( + table.id, + table.threadId + ), + uniqueIndex("conversation_messages_id_thread_turn_uq").on( + table.id, + table.threadId, + table.turnId + ), + index("conversation_messages_turn_created_idx").on( + table.turnId, + table.createdAt, + table.id + ), + index("conversation_messages_variant_source_idx").on( + table.variantOfMessageId + ), + ] +) + +export const threadForks = dbSchema.table( + "thread_forks", + { + id: text("id").primaryKey(), + conversationId: text("conversation_id") + .notNull() + .references(() => conversations.id, { onDelete: "cascade" }), + parentThreadId: text("parent_thread_id").notNull(), + sourceMessageId: text("source_message_id").notNull(), + childThreadId: text("child_thread_id").notNull(), + anchor: jsonb("anchor"), + createdBy: text("created_by") + .notNull() + .references(() => user.id, { onDelete: "restrict" }), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => [ + uniqueIndex("thread_forks_child_thread_uq").on(table.childThreadId), + index("thread_forks_parent_source_idx").on( + table.parentThreadId, + table.sourceMessageId + ), + index("thread_forks_conversation_created_idx").on( + table.conversationId, + table.createdAt + ), + ] +) + // 分支对话树(app/thread-chat)的整棵树持久化:一棵树一行,state 存完整 // ThreadTreeState(JSON)。与上面 assistant-ui 线性模型的 threads/messages 表分开, // 互不复用——那两张表是线性会话,这张是树形分支态。treeId 由客户端生成 diff --git a/lib/thread-chat/application/conversation-read-indexes.ts b/lib/thread-chat/application/conversation-read-indexes.ts new file mode 100644 index 00000000..6f430b64 --- /dev/null +++ b/lib/thread-chat/application/conversation-read-indexes.ts @@ -0,0 +1,68 @@ +import type { + ConversationSnapshot, + MessageId, + ThreadForkId, + ThreadId, + TurnId, +} from "../domain/conversation-model" + +export interface ConversationReadIndexes { + readonly forkIdsByParentThread: Readonly< + Record + > + readonly childThreadIdsByParentThread: Readonly< + Record + > + readonly turnIdsByThread: Readonly> + readonly messageIdsByTurn: Readonly> +} + +function append( + target: Record, + key: string, + value: T +): void { + const values = target[key] ?? [] + values.push(value) + target[key] = values +} + +function sortValues( + target: Record +): Readonly> { + return Object.fromEntries( + Object.entries(target) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, values]) => [key, [...values].sort()]) + ) +} + +/** 从规范实体重建读取索引;返回值不是第二份可写领域事实。 */ +export function deriveConversationReadIndexes( + snapshot: ConversationSnapshot +): ConversationReadIndexes { + const forkIdsByParentThread: Record = {} + const childThreadIdsByParentThread: Record = {} + const turnIdsByThread: Record = {} + const messageIdsByTurn: Record = {} + + for (const fork of Object.values(snapshot.threadForks)) { + append(forkIdsByParentThread, fork.parentThreadId, fork.id) + append( + childThreadIdsByParentThread, + fork.parentThreadId, + fork.childThreadId + ) + } + for (const turn of Object.values(snapshot.turns)) + append(turnIdsByThread, turn.threadId, turn.id) + for (const message of Object.values(snapshot.messages)) + append(messageIdsByTurn, message.turnId, message.id) + + return { + forkIdsByParentThread: sortValues(forkIdsByParentThread), + childThreadIdsByParentThread: sortValues(childThreadIdsByParentThread), + turnIdsByThread: sortValues(turnIdsByThread), + messageIdsByTurn: sortValues(messageIdsByTurn), + } +} diff --git a/lib/thread-chat/application/conversation-repository.ts b/lib/thread-chat/application/conversation-repository.ts new file mode 100644 index 00000000..896d1d7e --- /dev/null +++ b/lib/thread-chat/application/conversation-repository.ts @@ -0,0 +1,131 @@ +import type { + ConversationId, + ConversationMessage, + ConversationSnapshot, + ConversationThread, + ConversationTurn, + MessageId, + MessageRole, + Project, + ProjectId, + ThreadFork, + ThreadId, + TurnId, + Workspace, + WorkspaceId, +} from "../domain/conversation-model" + +export type ConversationRepositoryErrorCode = + | "canonical_writes_disabled" + | "dual_write_forbidden" + | "not_found" + | "forbidden" + | "identity_conflict" + | "version_conflict" + | "invalid_fork" + | "invalid_turn" + | "invalid_variant" + +export class ConversationRepositoryError extends Error { + readonly code: ConversationRepositoryErrorCode + readonly currentRevision?: number + + constructor( + code: ConversationRepositoryErrorCode, + message: string, + currentRevision?: number + ) { + super(message) + this.name = "ConversationRepositoryError" + this.code = code + this.currentRevision = currentRevision + } +} + +export interface CreateWorkspaceInput { + readonly workspace: Workspace + readonly ownerUserId: string +} + +export interface AddWorkspaceMemberInput { + readonly actorUserId: string + readonly workspaceId: WorkspaceId + readonly userId: string + readonly role: "owner" | "member" +} + +export interface CreateProjectInput { + readonly actorUserId: string + readonly project: Project +} + +export interface CreateConversationInput { + readonly actorUserId: string + readonly projectId: ProjectId + readonly conversation: { + readonly id: ConversationId + readonly rootThreadId: ThreadId + readonly autoTitle: string | null + readonly customTitle: string | null + readonly revision: number + readonly lifecycle: "active" | "archived" + } + readonly rootThread: ConversationThread +} + +export interface ForkThreadInput { + readonly actorUserId: string + readonly conversationId: ConversationId + readonly expectedConversationRevision: number + readonly childThread: ConversationThread + readonly fork: ThreadFork +} + +export interface AppendTurnInput { + readonly actorUserId: string + readonly conversationId: ConversationId + readonly expectedThreadRevision: number + readonly turn: ConversationTurn + readonly userMessage: ConversationMessage & { readonly role: "user" } + readonly assistantMessage: ConversationMessage & { + readonly role: "assistant" + } +} + +export interface AppendMessageVariantInput { + readonly actorUserId: string + readonly conversationId: ConversationId + readonly expectedTurnRevision: number + readonly sourceMessageId: MessageId + readonly message: ConversationMessage + readonly select: boolean +} + +export interface SelectMessageVariantInput { + readonly actorUserId: string + readonly conversationId: ConversationId + readonly turnId: TurnId + readonly messageId: MessageId + readonly role: Extract + readonly expectedRevision: number +} + +/** + * 规范 Conversation 的实体级仓储端口。 + * + * 这里故意不暴露 saveConversationSnapshot;快照只能读取,写入必须表达具体意图。 + */ +export interface CanonicalConversationRepository { + createWorkspace(input: CreateWorkspaceInput): Promise + addWorkspaceMember(input: AddWorkspaceMemberInput): Promise + createProject(input: CreateProjectInput): Promise + createConversation(input: CreateConversationInput): Promise + forkThread(input: ForkThreadInput): Promise + appendTurn(input: AppendTurnInput): Promise + appendMessageVariant(input: AppendMessageVariantInput): Promise + selectMessageVariant(input: SelectMessageVariantInput): Promise + getConversationSnapshot(input: { + readonly actorUserId: string + readonly conversationId: ConversationId + }): Promise +} diff --git a/lib/thread-chat/legacy/audit-thread-tree.test.ts b/lib/thread-chat/legacy/audit-thread-tree.test.ts new file mode 100644 index 00000000..2429d44f --- /dev/null +++ b/lib/thread-chat/legacy/audit-thread-tree.test.ts @@ -0,0 +1,161 @@ +import assert from "node:assert/strict" +import test from "node:test" + +import { THREAD_TREE_SCHEMA_VERSION } from "../../../constants/thread-chat.ts" +import type { ThreadTreeState } from "../domain/types.ts" +import { auditLegacyConversation } from "./audit-thread-tree.ts" + +function validState(): ThreadTreeState { + return { + schemaVersion: THREAD_TREE_SCHEMA_VERSION, + threads: { + main: { + id: "main", + modelId: "ark-glm-5.3", + parentId: null, + depth: 0, + title: "根", + anchorText: null, + forkFromMsgId: null, + footnote: null, + children: [], + messages: [ + { + id: "user-1", + parentMessageId: null, + role: "user", + text: "问题", + forks: [], + }, + { + id: "assistant-1", + parentMessageId: "user-1", + role: "assistant", + text: "回答", + forks: [], + }, + ], + activeLeafMessageId: "assistant-1", + lastActive: 1, + }, + }, + artifacts: {}, + artifactOrder: [], + recents: [], + footnoteCounter: 0, + seq: 2, + tick: 1, + } +} + +test("合法遗留树产生确定映射且保持只读", () => { + const state = validState() + const before = structuredClone(state) + const first = auditLegacyConversation({ + treeId: "audit-valid", + ownerUserId: "owner-1", + state, + generations: [ + { + id: "generation-1", + threadId: "main", + userMessageId: "user-1", + assistantMessageId: "assistant-1", + }, + ], + feedback: [{ threadId: "main", messageId: "assistant-1" }], + }) + const second = auditLegacyConversation({ + treeId: "audit-valid", + ownerUserId: "owner-1", + state, + generations: [ + { + id: "generation-1", + threadId: "main", + userMessageId: "user-1", + assistantMessageId: "assistant-1", + }, + ], + feedback: [{ threadId: "main", messageId: "assistant-1" }], + }) + + assert.equal(first.disposition, "migratable") + assert.deepEqual(first, second) + assert.deepEqual(state, before) + assert.equal(first.counts.threads, 1) + assert.equal(first.counts.turns, 1) + assert.equal(first.counts.messages, 2) +}) + +test("污染引用使用稳定错误码报告", () => { + const state = validState() as unknown as Record + const threads = state.threads as Record> + const main = threads.main! + main.activeLeafMessageId = "missing-active" + main.children = ["branch"] + const mainMessages = main.messages as Array> + mainMessages[1]!.forks = [] + threads.branch = { + id: "branch", + modelId: "ark-glm-5.3", + parentId: "main", + depth: 1, + title: "分支", + anchorText: "回答", + forkFromMsgId: "missing-source", + footnote: 1, + children: [], + messages: [ + { + id: "assistant-1", + parentMessageId: "missing-parent", + role: "assistant", + text: "重复 ID", + forks: [], + }, + ], + activeLeafMessageId: "assistant-1", + lastActive: 2, + } + state.artifacts = { + artifact: { + id: "artifact", + title: "污染 Artifact", + kind: "markdown", + content: "x", + sourceThreadId: "missing-thread", + sourceMessageId: "missing-message", + }, + } + + const report = auditLegacyConversation({ + treeId: "audit-invalid", + ownerUserId: null, + state, + generations: [ + { + id: "generation-invalid", + threadId: "missing-thread", + userMessageId: "missing-user", + assistantMessageId: "missing-assistant", + }, + ], + feedback: [{ threadId: "main", messageId: "missing-feedback-message" }], + }) + const codes = new Set(report.issues.map((entry) => entry.code)) + + assert.equal(report.disposition, "rejected") + for (const code of [ + "owner_missing", + "duplicate_message_id", + "message_parent_missing", + "active_leaf_missing", + "fork_source_missing", + "artifact_source_thread_missing", + "generation_thread_missing", + "feedback_message_missing", + "canonical_projection_failed", + ] as const) + assert.ok(codes.has(code), `缺少稳定错误码 ${code}`) +}) diff --git a/lib/thread-chat/legacy/audit-thread-tree.ts b/lib/thread-chat/legacy/audit-thread-tree.ts new file mode 100644 index 00000000..8f6e853f --- /dev/null +++ b/lib/thread-chat/legacy/audit-thread-tree.ts @@ -0,0 +1,436 @@ +import { + conversationId, + projectId, + workspaceId, +} from "../domain/conversation-model.ts" +import type { ThreadTreeState } from "../domain/types.ts" +import { projectLegacyThreadTree } from "./project-thread-tree.ts" + +export type LegacyConversationAuditCode = + | "owner_missing" + | "state_not_object" + | "threads_not_object" + | "root_thread_missing" + | "duplicate_message_id" + | "message_parent_missing" + | "active_leaf_missing" + | "fork_parent_missing" + | "fork_source_missing" + | "fork_backlink_missing" + | "fork_backlink_duplicate" + | "artifact_source_thread_missing" + | "artifact_source_message_missing" + | "generation_thread_missing" + | "generation_user_message_missing" + | "generation_assistant_message_missing" + | "feedback_thread_missing" + | "feedback_message_missing" + | "canonical_projection_failed" + +export interface LegacyConversationAuditIssue { + readonly code: LegacyConversationAuditCode + readonly path: string + readonly entityId?: string +} + +export interface LegacyGenerationReference { + readonly id: string + readonly threadId: string + readonly userMessageId: string + readonly assistantMessageId: string +} + +export interface LegacyFeedbackReference { + readonly threadId: string + readonly messageId: string +} + +export interface LegacyEntityMapping { + readonly legacyId: string + readonly canonicalId: string +} + +export interface LegacyConversationAuditReport { + readonly treeId: string + readonly owner: "owned" | "unowned" + readonly disposition: "migratable" | "needs_repair" | "rejected" + readonly issues: readonly LegacyConversationAuditIssue[] + readonly counts: { + readonly threads: number + readonly forks: number + readonly turns: number + readonly messages: number + readonly artifacts: number + readonly generations: number + readonly feedback: number + } + readonly mappings: { + readonly conversation: LegacyEntityMapping + readonly threads: readonly LegacyEntityMapping[] + readonly messages: readonly LegacyEntityMapping[] + } +} + +export interface AuditLegacyConversationInput { + readonly treeId: string + readonly ownerUserId: string | null + readonly state: unknown + readonly generations: readonly LegacyGenerationReference[] + readonly feedback: readonly LegacyFeedbackReference[] +} + +interface LegacyMessageShape { + readonly id?: unknown + readonly parentMessageId?: unknown + readonly forks?: unknown +} + +interface LegacyThreadShape { + readonly id?: unknown + readonly parentId?: unknown + readonly forkFromMsgId?: unknown + readonly activeLeafMessageId?: unknown + readonly messages?: unknown +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function issue( + issues: LegacyConversationAuditIssue[], + code: LegacyConversationAuditCode, + path: string, + entityId?: string +): void { + issues.push({ code, path, ...(entityId ? { entityId } : {}) }) +} + +function sortedMappings( + values: readonly LegacyEntityMapping[] +): readonly LegacyEntityMapping[] { + return [...values].sort((left, right) => + left.legacyId.localeCompare(right.legacyId) + ) +} + +function emptyReport( + input: AuditLegacyConversationInput, + issues: readonly LegacyConversationAuditIssue[] +): LegacyConversationAuditReport { + const canonicalConversationId = `legacy:${encodeURIComponent(input.treeId)}` + return { + treeId: input.treeId, + owner: input.ownerUserId ? "owned" : "unowned", + disposition: "rejected", + issues, + counts: { + threads: 0, + forks: 0, + turns: 0, + messages: 0, + artifacts: 0, + generations: input.generations.length, + feedback: input.feedback.length, + }, + mappings: { + conversation: { + legacyId: input.treeId, + canonicalId: canonicalConversationId, + }, + threads: [], + messages: [], + }, + } +} + +export function auditLegacyConversation( + input: AuditLegacyConversationInput +): LegacyConversationAuditReport { + const issues: LegacyConversationAuditIssue[] = [] + if (!input.ownerUserId) issue(issues, "owner_missing", "userId", input.treeId) + if (!isRecord(input.state)) { + issue(issues, "state_not_object", "state", input.treeId) + return emptyReport(input, issues) + } + if (!isRecord(input.state.threads)) { + issue(issues, "threads_not_object", "state.threads", input.treeId) + return emptyReport(input, issues) + } + + const rawThreads = input.state.threads + if (!isRecord(rawThreads.main)) + issue(issues, "root_thread_missing", "state.threads.main", "main") + + const messageLocations = new Map() + const messagesByThread = new Map>() + let forkCount = 0 + + for (const [threadKey, rawThread] of Object.entries(rawThreads)) { + if (!isRecord(rawThread)) continue + const rawMessages = Array.isArray(rawThread.messages) + ? rawThread.messages + : [] + const localMessageIds = new Set() + messagesByThread.set(threadKey, localMessageIds) + for (const [messageIndex, rawMessage] of rawMessages.entries()) { + if (!isRecord(rawMessage) || typeof rawMessage.id !== "string") continue + if (messageLocations.has(rawMessage.id)) + issue( + issues, + "duplicate_message_id", + `state.threads.${threadKey}.messages.${messageIndex}`, + rawMessage.id + ) + else messageLocations.set(rawMessage.id, threadKey) + localMessageIds.add(rawMessage.id) + } + } + + for (const [threadKey, rawThread] of Object.entries(rawThreads)) { + if (!isRecord(rawThread)) continue + const thread = rawThread as LegacyThreadShape + const rawMessages = Array.isArray(thread.messages) ? thread.messages : [] + const localMessageIds = messagesByThread.get(threadKey) ?? new Set() + + for (const [messageIndex, rawMessage] of rawMessages.entries()) { + if (!isRecord(rawMessage) || typeof rawMessage.id !== "string") continue + const message: LegacyMessageShape = rawMessage + if ( + typeof message.parentMessageId === "string" && + !localMessageIds.has(message.parentMessageId) + ) + issue( + issues, + "message_parent_missing", + `state.threads.${threadKey}.messages.${messageIndex}.parentMessageId`, + message.parentMessageId + ) + if (Array.isArray(message.forks)) forkCount += message.forks.length + } + + if ( + typeof thread.activeLeafMessageId === "string" && + !localMessageIds.has(thread.activeLeafMessageId) + ) + issue( + issues, + "active_leaf_missing", + `state.threads.${threadKey}.activeLeafMessageId`, + thread.activeLeafMessageId + ) + + if (threadKey !== "main") { + if ( + typeof thread.parentId !== "string" || + !isRecord(rawThreads[thread.parentId]) + ) + issue( + issues, + "fork_parent_missing", + `state.threads.${threadKey}.parentId`, + typeof thread.parentId === "string" ? thread.parentId : threadKey + ) + const parentMessages = + typeof thread.parentId === "string" + ? messagesByThread.get(thread.parentId) + : undefined + if ( + typeof thread.forkFromMsgId !== "string" || + !parentMessages?.has(thread.forkFromMsgId) + ) + issue( + issues, + "fork_source_missing", + `state.threads.${threadKey}.forkFromMsgId`, + typeof thread.forkFromMsgId === "string" + ? thread.forkFromMsgId + : threadKey + ) + else { + const parent = rawThreads[thread.parentId as string] + const parentMessage = isRecord(parent) + ? (Array.isArray(parent.messages) ? parent.messages : []).find( + (candidate) => + isRecord(candidate) && candidate.id === thread.forkFromMsgId + ) + : undefined + const matchingBacklinks = + isRecord(parentMessage) && Array.isArray(parentMessage.forks) + ? parentMessage.forks.filter( + (candidate) => + isRecord(candidate) && candidate.threadId === threadKey + ).length + : 0 + if (matchingBacklinks === 0) + issue( + issues, + "fork_backlink_missing", + `state.threads.${threadKey}`, + threadKey + ) + if (matchingBacklinks > 1) + issue( + issues, + "fork_backlink_duplicate", + `state.threads.${threadKey}`, + threadKey + ) + } + } + } + + const rawArtifacts = isRecord(input.state.artifacts) + ? input.state.artifacts + : {} + for (const [artifactKey, rawArtifact] of Object.entries(rawArtifacts)) { + if (!isRecord(rawArtifact)) continue + if ( + typeof rawArtifact.sourceThreadId !== "string" || + !messagesByThread.has(rawArtifact.sourceThreadId) + ) + issue( + issues, + "artifact_source_thread_missing", + `state.artifacts.${artifactKey}.sourceThreadId`, + artifactKey + ) + else if ( + typeof rawArtifact.sourceMessageId !== "string" || + !messagesByThread + .get(rawArtifact.sourceThreadId) + ?.has(rawArtifact.sourceMessageId) + ) + issue( + issues, + "artifact_source_message_missing", + `state.artifacts.${artifactKey}.sourceMessageId`, + artifactKey + ) + } + + for (const generation of input.generations) { + const threadMessages = messagesByThread.get(generation.threadId) + if (!threadMessages) + issue( + issues, + "generation_thread_missing", + `generations.${generation.id}.threadId`, + generation.threadId + ) + else { + if (!threadMessages.has(generation.userMessageId)) + issue( + issues, + "generation_user_message_missing", + `generations.${generation.id}.userMessageId`, + generation.userMessageId + ) + if (!threadMessages.has(generation.assistantMessageId)) + issue( + issues, + "generation_assistant_message_missing", + `generations.${generation.id}.assistantMessageId`, + generation.assistantMessageId + ) + } + } + + for (const [feedbackIndex, feedback] of input.feedback.entries()) { + const threadMessages = messagesByThread.get(feedback.threadId) + if (!threadMessages) + issue( + issues, + "feedback_thread_missing", + `feedback.${feedbackIndex}.threadId`, + feedback.threadId + ) + else if (!threadMessages.has(feedback.messageId)) + issue( + issues, + "feedback_message_missing", + `feedback.${feedbackIndex}.messageId`, + feedback.messageId + ) + } + + const canonicalConversationId = conversationId( + `legacy:${encodeURIComponent(input.treeId)}` + ) + let snapshot: ReturnType | null = null + try { + snapshot = projectLegacyThreadTree({ + legacyTreeId: input.treeId, + workspaceId: workspaceId( + `legacy-workspace:${encodeURIComponent(input.ownerUserId ?? "unowned")}` + ), + projectId: projectId( + `legacy-project:${encodeURIComponent(input.treeId)}` + ), + conversationId: canonicalConversationId, + projectTitle: "遗留数据审计", + conversationAutoTitle: null, + conversationCustomTitle: null, + actorId: input.ownerUserId ?? "legacy-unowned", + state: input.state as unknown as ThreadTreeState, + }) + } catch { + issue(issues, "canonical_projection_failed", "state", input.treeId) + } + + const threadMappings = snapshot + ? Object.keys(rawThreads).flatMap((legacyId) => { + const suffix = `:thread:${legacyId === "main" ? "root" : encodeURIComponent(legacyId)}` + const canonical = Object.keys(snapshot.threads).find((id) => + id.endsWith(suffix) + ) + return canonical ? [{ legacyId, canonicalId: canonical }] : [] + }) + : [] + const messageMappings = snapshot + ? [...messageLocations.keys()].flatMap((legacyId) => { + const suffix = `:message:${encodeURIComponent(legacyId)}` + const canonical = Object.keys(snapshot.messages).find((id) => + id.endsWith(suffix) + ) + return canonical ? [{ legacyId, canonicalId: canonical }] : [] + }) + : [] + const hardRejection = issues.some( + (entry) => + entry.code !== "owner_missing" && + entry.code !== "generation_user_message_missing" && + entry.code !== "generation_assistant_message_missing" && + entry.code !== "feedback_message_missing" + ) + + return { + treeId: input.treeId, + owner: input.ownerUserId ? "owned" : "unowned", + disposition: hardRejection + ? "rejected" + : issues.length > 0 + ? "needs_repair" + : "migratable", + issues: [...issues].sort((left, right) => + `${left.code}:${left.path}`.localeCompare(`${right.code}:${right.path}`) + ), + counts: { + threads: Object.keys(rawThreads).length, + forks: forkCount, + turns: snapshot ? Object.keys(snapshot.turns).length : 0, + messages: messageLocations.size, + artifacts: Object.keys(rawArtifacts).length, + generations: input.generations.length, + feedback: input.feedback.length, + }, + mappings: { + conversation: { + legacyId: input.treeId, + canonicalId: canonicalConversationId, + }, + threads: sortedMappings(threadMappings), + messages: sortedMappings(messageMappings), + }, + } +} diff --git a/lib/thread-chat/persistence/canonical-persistence-policy.ts b/lib/thread-chat/persistence/canonical-persistence-policy.ts new file mode 100644 index 00000000..78d1e4ef --- /dev/null +++ b/lib/thread-chat/persistence/canonical-persistence-policy.ts @@ -0,0 +1,57 @@ +import { ConversationRepositoryError } from "../application/conversation-repository" + +export type CanonicalPersistenceWriteMode = + "disabled" | "isolated-test" | "canonical" + +export interface CanonicalPersistencePolicy { + readonly writeMode: CanonicalPersistenceWriteMode + readonly legacyWritesEnabled: boolean +} + +export function resolveCanonicalPersistencePolicy( + environment: NodeJS.ProcessEnv = process.env +): CanonicalPersistencePolicy { + const configured = environment.CONVERSATION_PERSISTENCE_WRITE_MODE?.trim() + const writeMode: CanonicalPersistenceWriteMode = + configured === undefined || configured === "" + ? "disabled" + : configured === "disabled" || + configured === "isolated-test" || + configured === "canonical" + ? configured + : (() => { + throw new ConversationRepositoryError( + "canonical_writes_disabled", + `未知 CONVERSATION_PERSISTENCE_WRITE_MODE:${configured}` + ) + })() + + return { + writeMode, + legacyWritesEnabled: + environment.THREAD_TREE_LEGACY_WRITES_ENABLED !== "false", + } +} + +export function assertCanonicalWriteAllowed( + policy: CanonicalPersistencePolicy +): void { + if (policy.writeMode === "disabled") + throw new ConversationRepositoryError( + "canonical_writes_disabled", + "规范 Conversation 仓储默认只读;必须显式声明写入模式" + ) + if ( + policy.writeMode === "isolated-test" && + process.env.NODE_ENV === "production" + ) + throw new ConversationRepositoryError( + "canonical_writes_disabled", + "生产环境不能启用 isolated-test 规范写入模式" + ) + if (policy.writeMode === "canonical" && policy.legacyWritesEnabled) + throw new ConversationRepositoryError( + "dual_write_forbidden", + "规范写入与遗留整树写入不能同时启用" + ) +} diff --git a/lib/thread-chat/persistence/drizzle-conversation-repository.ts b/lib/thread-chat/persistence/drizzle-conversation-repository.ts new file mode 100644 index 00000000..af4d71cc --- /dev/null +++ b/lib/thread-chat/persistence/drizzle-conversation-repository.ts @@ -0,0 +1,716 @@ +import { and, asc, eq, sql } from "drizzle-orm" + +import { db } from "../../db" +import { + conversationMessages, + conversationThreads, + conversationTurns, + conversations, + projects, + threadForks, + workspaceMembers, + workspaces, +} from "../../db/schema" +import type { + AddWorkspaceMemberInput, + AppendMessageVariantInput, + AppendTurnInput, + CanonicalConversationRepository, + CreateConversationInput, + CreateProjectInput, + CreateWorkspaceInput, + ForkThreadInput, + SelectMessageVariantInput, +} from "../application/conversation-repository" +import { ConversationRepositoryError } from "../application/conversation-repository" +import { + conversationId, + messageId, + projectId, + threadForkId, + threadId, + turnId, + workspaceId, + type ConversationMessage, + type ConversationSnapshot, + type ConversationThread, + type ConversationTurn, + type ThreadFork, +} from "../domain/conversation-model" +import { + assertCanonicalWriteAllowed, + resolveCanonicalPersistencePolicy, + type CanonicalPersistencePolicy, +} from "./canonical-persistence-policy" + +type ConversationTransaction = Parameters< + Parameters[0] +>[0] + +interface ConversationAccess { + readonly conversation: typeof conversations.$inferSelect + readonly project: typeof projects.$inferSelect +} + +async function findConversationAccess( + executor: ConversationTransaction | typeof db, + actorUserId: string, + targetConversationId: string +): Promise { + const [row] = await executor + .select({ conversation: conversations, project: projects }) + .from(conversations) + .innerJoin(projects, eq(projects.id, conversations.projectId)) + .innerJoin( + workspaceMembers, + and( + eq(workspaceMembers.workspaceId, projects.workspaceId), + eq(workspaceMembers.userId, actorUserId) + ) + ) + .where(eq(conversations.id, targetConversationId)) + .limit(1) + return row ?? null +} + +async function assertWorkspaceMember( + transaction: ConversationTransaction, + actorUserId: string, + targetWorkspaceId: string, + ownerOnly = false +): Promise { + const [membership] = await transaction + .select({ role: workspaceMembers.role }) + .from(workspaceMembers) + .where( + and( + eq(workspaceMembers.workspaceId, targetWorkspaceId), + eq(workspaceMembers.userId, actorUserId) + ) + ) + .limit(1) + if (!membership || (ownerOnly && membership.role !== "owner")) + throw new ConversationRepositoryError( + "forbidden", + "当前用户无权操作目标 Workspace" + ) +} + +function assertConversationInput(input: CreateConversationInput): void { + if (input.conversation.rootThreadId !== input.rootThread.id) + throw new ConversationRepositoryError( + "identity_conflict", + "Conversation rootThreadId 与根 Thread ID 不一致" + ) + if (input.rootThread.conversationId !== input.conversation.id) + throw new ConversationRepositoryError( + "identity_conflict", + "根 Thread 不属于待创建 Conversation" + ) + if (input.rootThread.localTitle?.trim()) + throw new ConversationRepositoryError( + "identity_conflict", + "根 Thread 不得重复保存 Conversation 标题" + ) +} + +function mapThread( + row: typeof conversationThreads.$inferSelect +): ConversationThread { + return { + id: threadId(row.id), + conversationId: conversationId(row.conversationId), + modelId: row.modelId, + localTitle: row.localTitle, + revision: row.revision, + lifecycle: row.lifecycle, + } +} + +function mapTurn(row: typeof conversationTurns.$inferSelect): ConversationTurn { + return { + id: turnId(row.id), + threadId: threadId(row.threadId), + position: row.position, + activeUserMessageId: messageId(row.activeUserMessageId), + activeAssistantMessageId: messageId(row.activeAssistantMessageId), + revision: row.revision, + } +} + +function mapMessage( + row: typeof conversationMessages.$inferSelect +): ConversationMessage { + return { + id: messageId(row.id), + threadId: threadId(row.threadId), + turnId: turnId(row.turnId), + role: row.role, + content: row.content, + contentState: row.contentState, + ...(row.variantOfMessageId + ? { variantOfMessageId: messageId(row.variantOfMessageId) } + : {}), + createdAt: row.createdAt.toISOString(), + } +} + +function mapFork(row: typeof threadForks.$inferSelect): ThreadFork { + return { + id: threadForkId(row.id), + conversationId: conversationId(row.conversationId), + parentThreadId: threadId(row.parentThreadId), + sourceMessageId: messageId(row.sourceMessageId), + childThreadId: threadId(row.childThreadId), + ...(row.anchor ? { anchor: row.anchor as ThreadFork["anchor"] } : {}), + createdBy: row.createdBy, + createdAt: row.createdAt.toISOString(), + } +} + +function registry( + values: readonly T[] +): Readonly> { + return Object.fromEntries(values.map((value) => [value.id, value])) +} + +export class DrizzleConversationRepository implements CanonicalConversationRepository { + readonly policy: CanonicalPersistencePolicy + + constructor( + policy: CanonicalPersistencePolicy = resolveCanonicalPersistencePolicy() + ) { + this.policy = policy + } + + async createWorkspace(input: CreateWorkspaceInput): Promise { + assertCanonicalWriteAllowed(this.policy) + await db.transaction(async (transaction) => { + await transaction.insert(workspaces).values({ + id: input.workspace.id, + revision: input.workspace.revision, + lifecycle: input.workspace.lifecycle, + }) + await transaction.insert(workspaceMembers).values({ + workspaceId: input.workspace.id, + userId: input.ownerUserId, + role: "owner", + }) + }) + } + + async addWorkspaceMember(input: AddWorkspaceMemberInput): Promise { + assertCanonicalWriteAllowed(this.policy) + await db.transaction(async (transaction) => { + await assertWorkspaceMember( + transaction, + input.actorUserId, + input.workspaceId, + true + ) + await transaction.insert(workspaceMembers).values({ + workspaceId: input.workspaceId, + userId: input.userId, + role: input.role, + }) + }) + } + + async createProject(input: CreateProjectInput): Promise { + assertCanonicalWriteAllowed(this.policy) + await db.transaction(async (transaction) => { + await assertWorkspaceMember( + transaction, + input.actorUserId, + input.project.workspaceId + ) + await transaction.insert(projects).values({ + id: input.project.id, + workspaceId: input.project.workspaceId, + title: input.project.title, + revision: input.project.revision, + lifecycle: input.project.lifecycle, + }) + }) + } + + async createConversation(input: CreateConversationInput): Promise { + assertCanonicalWriteAllowed(this.policy) + assertConversationInput(input) + await db.transaction(async (transaction) => { + const [project] = await transaction + .select({ workspaceId: projects.workspaceId }) + .from(projects) + .where(eq(projects.id, input.projectId)) + .limit(1) + if (!project) + throw new ConversationRepositoryError( + "not_found", + "目标 Project 不存在" + ) + await assertWorkspaceMember( + transaction, + input.actorUserId, + project.workspaceId + ) + await transaction.insert(conversations).values({ + id: input.conversation.id, + projectId: input.projectId, + rootThreadId: input.conversation.rootThreadId, + autoTitle: input.conversation.autoTitle, + customTitle: input.conversation.customTitle, + revision: input.conversation.revision, + lifecycle: input.conversation.lifecycle, + }) + await transaction.insert(conversationThreads).values({ + id: input.rootThread.id, + conversationId: input.rootThread.conversationId, + modelId: input.rootThread.modelId, + localTitle: input.rootThread.localTitle, + revision: input.rootThread.revision, + lifecycle: input.rootThread.lifecycle, + }) + }) + } + + async forkThread(input: ForkThreadInput): Promise { + assertCanonicalWriteAllowed(this.policy) + if ( + input.childThread.conversationId !== input.conversationId || + input.fork.conversationId !== input.conversationId || + input.fork.childThreadId !== input.childThread.id || + input.fork.createdBy !== input.actorUserId + ) + throw new ConversationRepositoryError( + "invalid_fork", + "待创建 Thread、ThreadFork 与 Conversation 身份不一致" + ) + + return db.transaction(async (transaction) => { + const access = await findConversationAccess( + transaction, + input.actorUserId, + input.conversationId + ) + if (!access) + throw new ConversationRepositoryError( + "not_found", + "Conversation 不存在或当前用户无权访问" + ) + if (access.conversation.revision !== input.expectedConversationRevision) + throw new ConversationRepositoryError( + "version_conflict", + "Conversation revision 已变化", + access.conversation.revision + ) + + const [parent] = await transaction + .select({ id: conversationThreads.id }) + .from(conversationThreads) + .where( + and( + eq(conversationThreads.id, input.fork.parentThreadId), + eq(conversationThreads.conversationId, input.conversationId) + ) + ) + .limit(1) + const [source] = await transaction + .select({ id: conversationMessages.id }) + .from(conversationMessages) + .where( + and( + eq(conversationMessages.id, input.fork.sourceMessageId), + eq(conversationMessages.threadId, input.fork.parentThreadId) + ) + ) + .limit(1) + const [existingChild] = await transaction + .select({ id: conversationThreads.id }) + .from(conversationThreads) + .where(eq(conversationThreads.id, input.childThread.id)) + .limit(1) + if (!parent || !source || existingChild) + throw new ConversationRepositoryError( + "invalid_fork", + "Fork 上游、来源 Message 或下游 Thread 身份无效" + ) + + await transaction.insert(conversationThreads).values({ + id: input.childThread.id, + conversationId: input.childThread.conversationId, + modelId: input.childThread.modelId, + localTitle: input.childThread.localTitle, + revision: input.childThread.revision, + lifecycle: input.childThread.lifecycle, + }) + await transaction.insert(threadForks).values({ + id: input.fork.id, + conversationId: input.fork.conversationId, + parentThreadId: input.fork.parentThreadId, + sourceMessageId: input.fork.sourceMessageId, + childThreadId: input.fork.childThreadId, + anchor: input.fork.anchor, + createdBy: input.fork.createdBy, + createdAt: new Date(input.fork.createdAt), + }) + const [updated] = await transaction + .update(conversations) + .set({ + revision: sql`${conversations.revision} + 1`, + updatedAt: new Date(), + }) + .where( + and( + eq(conversations.id, input.conversationId), + eq(conversations.revision, input.expectedConversationRevision) + ) + ) + .returning({ revision: conversations.revision }) + if (!updated) + throw new ConversationRepositoryError( + "version_conflict", + "Conversation revision 已变化" + ) + return updated.revision + }) + } + + async appendTurn(input: AppendTurnInput): Promise { + assertCanonicalWriteAllowed(this.policy) + if ( + input.turn.threadId !== input.userMessage.threadId || + input.turn.threadId !== input.assistantMessage.threadId || + input.turn.id !== input.userMessage.turnId || + input.turn.id !== input.assistantMessage.turnId || + input.turn.activeUserMessageId !== input.userMessage.id || + input.turn.activeAssistantMessageId !== input.assistantMessage.id + ) + throw new ConversationRepositoryError( + "invalid_turn", + "Turn 与初始 Message 身份不一致" + ) + + return db.transaction(async (transaction) => { + const access = await findConversationAccess( + transaction, + input.actorUserId, + input.conversationId + ) + if (!access) + throw new ConversationRepositoryError( + "not_found", + "Conversation 不存在或当前用户无权访问" + ) + const [thread] = await transaction + .select({ revision: conversationThreads.revision }) + .from(conversationThreads) + .where( + and( + eq(conversationThreads.id, input.turn.threadId), + eq(conversationThreads.conversationId, input.conversationId) + ) + ) + .limit(1) + if (!thread) + throw new ConversationRepositoryError("not_found", "Thread 不存在") + if (thread.revision !== input.expectedThreadRevision) + throw new ConversationRepositoryError( + "version_conflict", + "Thread revision 已变化", + thread.revision + ) + + await transaction.insert(conversationTurns).values({ + id: input.turn.id, + threadId: input.turn.threadId, + position: input.turn.position, + activeUserMessageId: input.turn.activeUserMessageId, + activeAssistantMessageId: input.turn.activeAssistantMessageId, + revision: input.turn.revision, + }) + await transaction.insert(conversationMessages).values([ + { + ...input.userMessage, + createdAt: new Date(input.userMessage.createdAt), + }, + { + ...input.assistantMessage, + createdAt: new Date(input.assistantMessage.createdAt), + }, + ]) + const [updated] = await transaction + .update(conversationThreads) + .set({ + revision: sql`${conversationThreads.revision} + 1`, + updatedAt: new Date(), + }) + .where( + and( + eq(conversationThreads.id, input.turn.threadId), + eq(conversationThreads.revision, input.expectedThreadRevision) + ) + ) + .returning({ revision: conversationThreads.revision }) + if (!updated) + throw new ConversationRepositoryError( + "version_conflict", + "Thread revision 已变化" + ) + return updated.revision + }) + } + + async appendMessageVariant( + input: AppendMessageVariantInput + ): Promise { + assertCanonicalWriteAllowed(this.policy) + return db.transaction(async (transaction) => { + const access = await findConversationAccess( + transaction, + input.actorUserId, + input.conversationId + ) + if (!access) + throw new ConversationRepositoryError( + "not_found", + "Conversation 不存在或当前用户无权访问" + ) + const [turn] = await transaction + .select() + .from(conversationTurns) + .where(eq(conversationTurns.id, input.message.turnId)) + .limit(1) + const [source] = await transaction + .select() + .from(conversationMessages) + .where(eq(conversationMessages.id, input.sourceMessageId)) + .limit(1) + const [thread] = turn + ? await transaction + .select({ conversationId: conversationThreads.conversationId }) + .from(conversationThreads) + .where(eq(conversationThreads.id, turn.threadId)) + .limit(1) + : [] + if ( + !turn || + !source || + thread?.conversationId !== input.conversationId || + turn.threadId !== input.message.threadId || + source.threadId !== input.message.threadId || + source.turnId !== input.message.turnId || + source.role !== input.message.role + ) + throw new ConversationRepositoryError( + "invalid_variant", + "Message 变体必须与来源属于同一 Thread、Turn 和角色" + ) + if (turn.revision !== input.expectedTurnRevision) + throw new ConversationRepositoryError( + "version_conflict", + "Turn revision 已变化", + turn.revision + ) + + await transaction.insert(conversationMessages).values({ + ...input.message, + variantOfMessageId: input.sourceMessageId, + createdAt: new Date(input.message.createdAt), + }) + const activeField = + input.message.role === "user" + ? { activeUserMessageId: input.message.id } + : input.message.role === "assistant" + ? { activeAssistantMessageId: input.message.id } + : {} + if (input.select && input.message.role === "context") + throw new ConversationRepositoryError( + "invalid_variant", + "context Message 不能成为 Turn 当前有效变体" + ) + const [updated] = await transaction + .update(conversationTurns) + .set({ + ...(input.select ? activeField : {}), + revision: sql`${conversationTurns.revision} + 1`, + updatedAt: new Date(), + }) + .where( + and( + eq(conversationTurns.id, turn.id), + eq(conversationTurns.revision, input.expectedTurnRevision) + ) + ) + .returning({ revision: conversationTurns.revision }) + if (!updated) + throw new ConversationRepositoryError( + "version_conflict", + "Turn revision 已变化" + ) + return updated.revision + }) + } + + async selectMessageVariant( + input: SelectMessageVariantInput + ): Promise { + assertCanonicalWriteAllowed(this.policy) + return db.transaction(async (transaction) => { + const access = await findConversationAccess( + transaction, + input.actorUserId, + input.conversationId + ) + if (!access) + throw new ConversationRepositoryError( + "not_found", + "Conversation 不存在或当前用户无权访问" + ) + const [turn] = await transaction + .select() + .from(conversationTurns) + .where(eq(conversationTurns.id, input.turnId)) + .limit(1) + const [message] = await transaction + .select() + .from(conversationMessages) + .where(eq(conversationMessages.id, input.messageId)) + .limit(1) + const [thread] = turn + ? await transaction + .select({ conversationId: conversationThreads.conversationId }) + .from(conversationThreads) + .where(eq(conversationThreads.id, turn.threadId)) + .limit(1) + : [] + if ( + !turn || + !message || + thread?.conversationId !== input.conversationId || + message.turnId !== turn.id || + message.threadId !== turn.threadId || + message.role !== input.role || + (message.contentState !== "complete" && + message.contentState !== "incomplete") + ) + throw new ConversationRepositoryError( + "invalid_variant", + "目标 Message 不是同一 Turn 中可选择的角色变体" + ) + if (turn.revision !== input.expectedRevision) + throw new ConversationRepositoryError( + "version_conflict", + "Turn revision 已变化", + turn.revision + ) + const [updated] = await transaction + .update(conversationTurns) + .set({ + ...(input.role === "user" + ? { activeUserMessageId: input.messageId } + : { activeAssistantMessageId: input.messageId }), + revision: sql`${conversationTurns.revision} + 1`, + updatedAt: new Date(), + }) + .where( + and( + eq(conversationTurns.id, input.turnId), + eq(conversationTurns.revision, input.expectedRevision) + ) + ) + .returning({ revision: conversationTurns.revision }) + if (!updated) + throw new ConversationRepositoryError( + "version_conflict", + "Turn revision 已变化" + ) + return updated.revision + }) + } + + async getConversationSnapshot(input: { + readonly actorUserId: string + readonly conversationId: ReturnType + }): Promise { + const access = await findConversationAccess( + db, + input.actorUserId, + input.conversationId + ) + if (!access) return null + + const [threadRows, turnRows, messageRows, forkRows] = await Promise.all([ + db + .select() + .from(conversationThreads) + .where(eq(conversationThreads.conversationId, input.conversationId)) + .orderBy( + asc(conversationThreads.createdAt), + asc(conversationThreads.id) + ), + db + .select({ turn: conversationTurns }) + .from(conversationTurns) + .innerJoin( + conversationThreads, + eq(conversationThreads.id, conversationTurns.threadId) + ) + .where(eq(conversationThreads.conversationId, input.conversationId)) + .orderBy( + asc(conversationTurns.threadId), + asc(conversationTurns.position), + asc(conversationTurns.id) + ), + db + .select({ message: conversationMessages }) + .from(conversationMessages) + .innerJoin( + conversationThreads, + eq(conversationThreads.id, conversationMessages.threadId) + ) + .where(eq(conversationThreads.conversationId, input.conversationId)) + .orderBy( + asc(conversationMessages.threadId), + asc(conversationMessages.turnId), + asc(conversationMessages.createdAt), + asc(conversationMessages.id) + ), + db + .select() + .from(threadForks) + .where(eq(threadForks.conversationId, input.conversationId)) + .orderBy(asc(threadForks.createdAt), asc(threadForks.id)), + ]) + + const mappedThreads = threadRows.map(mapThread) + const mappedTurns = turnRows.map(({ turn }) => mapTurn(turn)) + const mappedMessages = messageRows.map(({ message }) => mapMessage(message)) + const mappedForks = forkRows.map(mapFork) + + return { + schemaVersion: 1, + project: { + id: projectId(access.project.id), + workspaceId: workspaceId(access.project.workspaceId), + title: access.project.title, + revision: access.project.revision, + lifecycle: access.project.lifecycle, + }, + conversation: { + id: conversationId(access.conversation.id), + projectId: projectId(access.conversation.projectId), + rootThreadId: threadId(access.conversation.rootThreadId), + autoTitle: access.conversation.autoTitle, + customTitle: access.conversation.customTitle, + revision: access.conversation.revision, + lifecycle: access.conversation.lifecycle, + }, + threads: registry(mappedThreads), + threadForks: registry(mappedForks), + turns: registry(mappedTurns), + messages: registry(mappedMessages), + generations: {}, + artifactProvenance: {}, + } + } +} diff --git a/openspec/changes/normalize-conversation-persistence/tasks.md b/openspec/changes/normalize-conversation-persistence/tasks.md index 19501931..ada3a796 100644 --- a/openspec/changes/normalize-conversation-persistence/tasks.md +++ b/openspec/changes/normalize-conversation-persistence/tasks.md @@ -1,28 +1,28 @@ ## 1. 数据结构与迁移 -- [ ] 1.1 定义 `workspaces`、`workspace_members`、`projects`、`conversations`、`conversation_threads`、`thread_forks`、`conversation_turns`、`conversation_messages` 的 Drizzle 结构和稳定命名。 -- [ ] 1.2 生成迁移并补充 Drizzle 无法表达的可延迟复合外键、唯一约束与约束触发器。 -- [ ] 1.3 为所有者查询、Conversation 列表、Thread/Turn 顺序、来源 Message 和当前有效变体建立必要索引。 -- [ ] 1.4 更新本地 schema reset 脚本,使新表只在明确目标 schema 中创建或删除,不影响其他数据库。 +- [x] 1.1 定义 `workspaces`、`workspace_members`、`projects`、`conversations`、`conversation_threads`、`thread_forks`、`conversation_turns`、`conversation_messages` 的 Drizzle 结构和稳定命名。 +- [x] 1.2 生成迁移并补充 Drizzle 无法表达的可延迟复合外键、唯一约束与约束触发器。 +- [x] 1.3 为所有者查询、Conversation 列表、Thread/Turn 顺序、来源 Message 和当前有效变体建立必要索引。 +- [x] 1.4 更新本地 schema reset 脚本,使新表只在明确目标 schema 中创建或删除,不影响其他数据库。 ## 2. 规范仓储 -- [ ] 2.1 建立 Workspace/Project 与 Conversation/Thread 的纯应用仓储端口和 Drizzle 适配器。 -- [ ] 2.2 实现 ThreadFork 事务写入与根、唯一来源、同 Conversation、来源 Message 归属和无环校验。 -- [ ] 2.3 实现 Turn/Message 追加、角色校验和当前有效变体的预期版本更新。 -- [ ] 2.4 实现确定性 `ConversationSnapshot` 组装、稳定排序和可重建派生索引。 -- [ ] 2.5 增加服务端配置边界,默认禁止规范仓储承载生产写入,并拒绝未声明双写模式。 +- [x] 2.1 建立 Workspace/Project 与 Conversation/Thread 的纯应用仓储端口和 Drizzle 适配器。 +- [x] 2.2 实现 ThreadFork 事务写入与根、唯一来源、同 Conversation、来源 Message 归属和无环校验。 +- [x] 2.3 实现 Turn/Message 追加、角色校验和当前有效变体的预期版本更新。 +- [x] 2.4 实现确定性 `ConversationSnapshot` 组装、稳定排序和可重建派生索引。 +- [x] 2.5 增加服务端配置边界,默认禁止规范仓储承载生产写入,并拒绝未声明双写模式。 ## 3. 遗留审计 -- [ ] 3.1 实现只读 `ThreadTreeState` 审计器和 legacy → canonical ID/实体映射报告。 -- [ ] 3.2 检查根、Fork、Message 图、active leaf、Artifact、反馈和 `branch_generations` 悬空引用,并为每类错误定义稳定代码。 -- [ ] 3.3 增加 dry-run 命令,仅输出汇总与逐树报告,不修改旧表或规范表。 -- [ ] 3.4 在目标环境执行审计并记录“可重置或必须迁移”的数据证据,供最终切换 change 决策。 +- [x] 3.1 实现只读 `ThreadTreeState` 审计器和 legacy → canonical ID/实体映射报告。 +- [x] 3.2 检查根、Fork、Message 图、active leaf、Artifact、反馈和 `branch_generations` 悬空引用,并为每类错误定义稳定代码。 +- [x] 3.3 增加 dry-run 命令,仅输出汇总与逐树报告,不修改旧表或规范表。 +- [x] 3.4 在目标环境执行审计并记录“可重置或必须迁移”的数据证据,供最终切换 change 决策。 ## 4. 约束验证 -- [ ] 4.1 覆盖最小根 Conversation、A → B → C Fork、同 Turn 回复变体和有效选择的成功用例。 -- [ ] 4.2 覆盖跨 Conversation Fork、来源 Message 错配、第二入向 Fork、Fork 环、跨 Thread Message/Turn 和错误角色 active Message 的拒绝用例。 -- [ ] 4.3 验证两个相同读取产生等价 `ConversationSnapshot`,且仓储没有整包快照写入端口。 -- [ ] 4.4 运行数据库迁移、`pnpm typecheck`、`pnpm openspec:validate` 和相关验证脚本并记录结果。 +- [x] 4.1 覆盖最小根 Conversation、A → B → C Fork、同 Turn 回复变体和有效选择的成功用例。 +- [x] 4.2 覆盖跨 Conversation Fork、来源 Message 错配、第二入向 Fork、Fork 环、跨 Thread Message/Turn 和错误角色 active Message 的拒绝用例。 +- [x] 4.3 验证两个相同读取产生等价 `ConversationSnapshot`,且仓储没有整包快照写入端口。 +- [x] 4.4 运行数据库迁移、`pnpm typecheck`、`pnpm openspec:validate` 和相关验证脚本并记录结果。 diff --git a/package.json b/package.json index 26954b8c..484abb78 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,10 @@ "format": "prettier --write \"**/*.{ts,tsx}\"", "typecheck": "tsc --noEmit", "test:conversation-domain": "node --experimental-strip-types --test lib/thread-chat/domain/conversation-model.test.ts", + "test:conversation-persistence": "tsx scripts/test-conversation-persistence.ts", + "test:legacy-conversation-audit": "node --experimental-strip-types --test lib/thread-chat/legacy/audit-thread-tree.test.ts", "audit:conversation-domain": "node scripts/audit-thread-tree-boundary.mjs", + "audit:legacy-conversations": "node --experimental-strip-types scripts/audit-legacy-conversations.ts", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:reset-schema": "node scripts/reset-thread-chat-schema.mjs", diff --git a/scripts/audit-legacy-conversations.ts b/scripts/audit-legacy-conversations.ts new file mode 100644 index 00000000..aa998d01 --- /dev/null +++ b/scripts/audit-legacy-conversations.ts @@ -0,0 +1,120 @@ +import { config } from "dotenv" +import postgres from "postgres" + +import { auditLegacyConversation } from "../lib/thread-chat/legacy/audit-thread-tree.ts" + +config({ path: ".env.local" }) + +const rawUrl = process.env.DIRECT_URL || process.env.DATABASE_URL +if (!rawUrl) throw new Error("未配置 DIRECT_URL 或 DATABASE_URL") +const databaseUrl = rawUrl.trim().replace(/^(['"])(.*)\1$/u, "$2") +const sql = postgres(databaseUrl, { max: 1, prepare: false }) + +try { + const trees = await sql< + readonly { id: string; user_id: string | null; state: unknown }[] + >`SELECT id, user_id, state FROM thread_chat.branch_trees ORDER BY id` + const generations = await sql< + readonly { + id: string + tree_id: string + thread_id: string + user_message_id: string + assistant_message_id: string + }[] + >`SELECT id, tree_id, thread_id, user_message_id, assistant_message_id FROM thread_chat.branch_generations ORDER BY tree_id, id` + const feedback = await sql< + readonly { tree_id: string; thread_id: string; message_id: string }[] + >`SELECT tree_id, thread_id, message_id FROM thread_chat.branch_message_feedback ORDER BY tree_id, thread_id, message_id` + + const reports = trees.map((tree) => + auditLegacyConversation({ + treeId: tree.id, + ownerUserId: tree.user_id, + state: tree.state, + generations: generations + .filter((generation) => generation.tree_id === tree.id) + .map((generation) => ({ + id: generation.id, + threadId: generation.thread_id, + userMessageId: generation.user_message_id, + assistantMessageId: generation.assistant_message_id, + })), + feedback: feedback + .filter((entry) => entry.tree_id === tree.id) + .map((entry) => ({ + threadId: entry.thread_id, + messageId: entry.message_id, + })), + }) + ) + const issueCounts = Object.fromEntries( + [ + ...new Set( + reports.flatMap((report) => report.issues.map((entry) => entry.code)) + ), + ] + .sort() + .map((code) => [ + code, + reports.reduce( + (count, report) => + count + report.issues.filter((entry) => entry.code === code).length, + 0 + ), + ]) + ) + const output = { + mode: "dry-run", + readOnly: true, + summary: { + totalTrees: reports.length, + ownedTrees: reports.filter((report) => report.owner === "owned").length, + unownedTrees: reports.filter((report) => report.owner === "unowned") + .length, + migratable: reports.filter( + (report) => report.disposition === "migratable" + ).length, + needsRepair: reports.filter( + (report) => report.disposition === "needs_repair" + ).length, + rejected: reports.filter((report) => report.disposition === "rejected") + .length, + entities: { + threads: reports.reduce( + (count, report) => count + report.counts.threads, + 0 + ), + turns: reports.reduce( + (count, report) => count + report.counts.turns, + 0 + ), + messages: reports.reduce( + (count, report) => count + report.counts.messages, + 0 + ), + forks: reports.reduce( + (count, report) => count + report.counts.forks, + 0 + ), + artifacts: reports.reduce( + (count, report) => count + report.counts.artifacts, + 0 + ), + generations: reports.reduce( + (count, report) => count + report.counts.generations, + 0 + ), + feedback: reports.reduce( + (count, report) => count + report.counts.feedback, + 0 + ), + }, + issueCounts, + }, + ...(process.argv.includes("--summary-only") ? {} : { reports }), + } + console.log(JSON.stringify(output, null, 2)) +} finally { + await sql.end() +} diff --git a/scripts/reset-thread-chat-schema.mjs b/scripts/reset-thread-chat-schema.mjs index aaeb082c..f9694365 100644 --- a/scripts/reset-thread-chat-schema.mjs +++ b/scripts/reset-thread-chat-schema.mjs @@ -3,13 +3,25 @@ import postgres from "postgres" const SCHEMA = "thread_chat" const CONFIRMATION = "--confirm-thread-chat" +const CANONICAL_ONLY = "--canonical-only" +const CANONICAL_TABLES = [ + "thread_forks", + "conversation_messages", + "conversation_turns", + "conversation_threads", + "conversations", + "projects", + "workspace_members", + "workspaces", +] config({ path: ".env.local" }) if (!process.argv.includes(CONFIRMATION)) { console.error( `[db:reset-schema] 该操作会永久删除 ${SCHEMA} schema 及其中全部数据。\n` + - `确认后请运行:pnpm db:reset-schema -- ${CONFIRMATION}` + `确认后请运行:pnpm db:reset-schema -- ${CONFIRMATION}\n` + + `若只重置 Issue #34 规范表:pnpm db:reset-schema -- ${CANONICAL_ONLY} ${CONFIRMATION}` ) process.exit(1) } @@ -37,11 +49,27 @@ try { const sql = postgres(databaseUrl, { max: 1 }) try { - console.log(`[db:reset-schema] 正在删除 ${target} 中的 ${SCHEMA} schema…`) - await sql`DROP SCHEMA IF EXISTS ${sql(SCHEMA)} CASCADE` - console.log( - `[db:reset-schema] ${SCHEMA} 已删除;其他 schema 与 Drizzle 迁移账本未改动。` - ) + if (process.argv.includes(CANONICAL_ONLY)) { + console.log( + `[db:reset-schema] 正在删除 ${target} 的 ${SCHEMA} schema 中 Issue #34 规范表…` + ) + await sql.begin(async (transaction) => { + for (const table of CANONICAL_TABLES) + await transaction`DROP TABLE IF EXISTS ${transaction(SCHEMA)}.${transaction(table)} CASCADE` + await transaction`DROP FUNCTION IF EXISTS ${transaction(SCHEMA)}.${transaction("validate_turn_message_roles")}() CASCADE` + await transaction`DROP FUNCTION IF EXISTS ${transaction(SCHEMA)}.${transaction("validate_conversation_integrity")}() CASCADE` + await transaction`DROP FUNCTION IF EXISTS ${transaction(SCHEMA)}.${transaction("reject_canonical_reparenting")}() CASCADE` + }) + console.log( + `[db:reset-schema] 规范表已删除;legacy、认证、计费、其他 schema 与 Drizzle 迁移账本未改动。` + ) + } else { + console.log(`[db:reset-schema] 正在删除 ${target} 中的 ${SCHEMA} schema…`) + await sql`DROP SCHEMA IF EXISTS ${sql(SCHEMA)} CASCADE` + console.log( + `[db:reset-schema] ${SCHEMA} 已删除;其他 schema 与 Drizzle 迁移账本未改动。` + ) + } } finally { await sql.end() } diff --git a/scripts/test-conversation-persistence.ts b/scripts/test-conversation-persistence.ts new file mode 100644 index 00000000..8dc4b66a --- /dev/null +++ b/scripts/test-conversation-persistence.ts @@ -0,0 +1,571 @@ +import assert from "node:assert/strict" +import { randomUUID } from "node:crypto" + +import { config } from "dotenv" + +config({ path: ".env.local" }) + +const { count, eq } = await import("drizzle-orm") +const { db } = await import("../lib/db/index.ts") +const { + conversationMessages, + conversationTurns, + conversations, + projects, + threadForks, + user, + workspaces, +} = await import("../lib/db/schema.ts") +const { ConversationRepositoryError } = + await import("../lib/thread-chat/application/conversation-repository.ts") +const { deriveConversationReadIndexes } = + await import("../lib/thread-chat/application/conversation-read-indexes.ts") +const { + conversationId, + messageId, + projectId, + threadForkId, + threadId, + turnId, + workspaceId, +} = await import("../lib/thread-chat/domain/conversation-model.ts") +const { DrizzleConversationRepository } = + await import("../lib/thread-chat/persistence/drizzle-conversation-repository.ts") +const { assertCanonicalWriteAllowed } = + await import("../lib/thread-chat/persistence/canonical-persistence-policy.ts") + +const runId = randomUUID() +const prefix = `persistence-test:${runId}` +const userId = `${prefix}:user` +const workspace = workspaceId(`${prefix}:workspace`) +const project = projectId(`${prefix}:project`) +const conversation = conversationId(`${prefix}:conversation`) +const root = threadId(`${prefix}:thread:root`) +const repository = new DrizzleConversationRepository({ + writeMode: "isolated-test", + legacyWritesEnabled: true, +}) + +function content(text: string) { + return { + schemaVersion: 1 as const, + parts: [{ type: "text" as const, text }], + } +} + +function turnFixture(input: { + thread: ReturnType + suffix: string + position: number +}) { + const turn = turnId(`${prefix}:turn:${input.suffix}`) + const userMessage = messageId(`${prefix}:message:${input.suffix}:user`) + const assistantMessage = messageId( + `${prefix}:message:${input.suffix}:assistant` + ) + return { + turn: { + id: turn, + threadId: input.thread, + position: input.position, + activeUserMessageId: userMessage, + activeAssistantMessageId: assistantMessage, + revision: 0, + }, + userMessage: { + id: userMessage, + threadId: input.thread, + turnId: turn, + role: "user" as const, + content: content(`${input.suffix} 用户消息`), + contentState: "complete" as const, + createdAt: "2026-08-22T00:00:00.000Z", + }, + assistantMessage: { + id: assistantMessage, + threadId: input.thread, + turnId: turn, + role: "assistant" as const, + content: content(`${input.suffix} 助手消息`), + contentState: "complete" as const, + createdAt: "2026-08-22T00:00:01.000Z", + }, + } +} + +async function expectRepositoryCode( + code: string, + action: () => Promise +): Promise { + await assert.rejects(action, (error: unknown) => { + assert.ok(error instanceof ConversationRepositoryError) + assert.equal(error.code, code) + return true + }) +} + +async function expectDatabaseRejection( + label: string, + expectedConstraint: string, + action: () => Promise +): Promise { + await assert.rejects(action, (error: unknown) => { + assert.ok(error instanceof Error, `${label} 应返回数据库错误`) + let current: unknown = error + let constraint: string | undefined + while (current && typeof current === "object") { + const candidate = current as { + readonly cause?: unknown + readonly constraint_name?: unknown + readonly constraint?: unknown + } + const value = candidate.constraint_name ?? candidate.constraint + if (typeof value === "string") { + constraint = value + break + } + current = candidate.cause + } + assert.equal( + constraint, + expectedConstraint, + `${label} 应由 ${expectedConstraint} 拒绝` + ) + return true + }) +} + +async function createConversationFixture(input: { + id: ReturnType + root: ReturnType + title: string +}): Promise { + await repository.createConversation({ + actorUserId: userId, + projectId: project, + conversation: { + id: input.id, + rootThreadId: input.root, + autoTitle: input.title, + customTitle: null, + revision: 0, + lifecycle: "active", + }, + rootThread: { + id: input.root, + conversationId: input.id, + modelId: "ark-glm-5.3", + localTitle: null, + revision: 0, + lifecycle: "active", + }, + }) +} + +let assertions = 0 +try { + assert.throws( + () => + assertCanonicalWriteAllowed({ + writeMode: "disabled", + legacyWritesEnabled: true, + }), + (error: unknown) => + error instanceof ConversationRepositoryError && + error.code === "canonical_writes_disabled" + ) + assert.throws( + () => + assertCanonicalWriteAllowed({ + writeMode: "canonical", + legacyWritesEnabled: true, + }), + (error: unknown) => + error instanceof ConversationRepositoryError && + error.code === "dual_write_forbidden" + ) + assertions += 2 + + await db.insert(user).values({ + id: userId, + name: "Conversation Persistence Test", + email: `${runId}@conversation-persistence.invalid`, + emailVerified: true, + }) + await repository.createWorkspace({ + workspace: { id: workspace, revision: 0, lifecycle: "active" }, + ownerUserId: userId, + }) + await repository.createProject({ + actorUserId: userId, + project: { + id: project, + workspaceId: workspace, + title: "规范持久化测试", + revision: 0, + lifecycle: "active", + }, + }) + await createConversationFixture({ id: conversation, root, title: "根会话" }) + + const rootTurn = turnFixture({ thread: root, suffix: "root", position: 0 }) + assert.equal( + await repository.appendTurn({ + actorUserId: userId, + conversationId: conversation, + expectedThreadRevision: 0, + ...rootTurn, + }), + 1 + ) + assertions += 1 + + const threadA = threadId(`${prefix}:thread:A`) + const threadB = threadId(`${prefix}:thread:B`) + const threadC = threadId(`${prefix}:thread:C`) + + assert.equal( + await repository.forkThread({ + actorUserId: userId, + conversationId: conversation, + expectedConversationRevision: 0, + childThread: { + id: threadA, + conversationId: conversation, + modelId: "ark-glm-5.3", + localTitle: "A", + revision: 0, + lifecycle: "active", + }, + fork: { + id: threadForkId(`${prefix}:fork:A`), + conversationId: conversation, + parentThreadId: root, + sourceMessageId: rootTurn.assistantMessage.id, + childThreadId: threadA, + createdBy: userId, + createdAt: "2026-08-22T00:01:00.000Z", + }, + }), + 1 + ) + const turnA = turnFixture({ thread: threadA, suffix: "A", position: 0 }) + await repository.appendTurn({ + actorUserId: userId, + conversationId: conversation, + expectedThreadRevision: 0, + ...turnA, + }) + assert.equal( + await repository.forkThread({ + actorUserId: userId, + conversationId: conversation, + expectedConversationRevision: 1, + childThread: { + id: threadB, + conversationId: conversation, + modelId: "ark-glm-5.3", + localTitle: "B", + revision: 0, + lifecycle: "active", + }, + fork: { + id: threadForkId(`${prefix}:fork:B`), + conversationId: conversation, + parentThreadId: threadA, + sourceMessageId: turnA.assistantMessage.id, + childThreadId: threadB, + createdBy: userId, + createdAt: "2026-08-22T00:02:00.000Z", + }, + }), + 2 + ) + const turnB = turnFixture({ thread: threadB, suffix: "B", position: 0 }) + await repository.appendTurn({ + actorUserId: userId, + conversationId: conversation, + expectedThreadRevision: 0, + ...turnB, + }) + assert.equal( + await repository.forkThread({ + actorUserId: userId, + conversationId: conversation, + expectedConversationRevision: 2, + childThread: { + id: threadC, + conversationId: conversation, + modelId: "ark-glm-5.3", + localTitle: "C", + revision: 0, + lifecycle: "active", + }, + fork: { + id: threadForkId(`${prefix}:fork:C`), + conversationId: conversation, + parentThreadId: threadB, + sourceMessageId: turnB.assistantMessage.id, + childThreadId: threadC, + createdBy: userId, + createdAt: "2026-08-22T00:03:00.000Z", + }, + }), + 3 + ) + const turnC = turnFixture({ thread: threadC, suffix: "C", position: 0 }) + await repository.appendTurn({ + actorUserId: userId, + conversationId: conversation, + expectedThreadRevision: 0, + ...turnC, + }) + assertions += 3 + + const regenerated = messageId(`${prefix}:message:root:assistant:variant`) + assert.equal( + await repository.appendMessageVariant({ + actorUserId: userId, + conversationId: conversation, + expectedTurnRevision: 0, + sourceMessageId: rootTurn.assistantMessage.id, + select: true, + message: { + id: regenerated, + threadId: root, + turnId: rootTurn.turn.id, + role: "assistant", + content: content("重新生成回答"), + contentState: "complete", + createdAt: "2026-08-22T00:04:00.000Z", + }, + }), + 1 + ) + assert.equal( + await repository.selectMessageVariant({ + actorUserId: userId, + conversationId: conversation, + turnId: rootTurn.turn.id, + messageId: rootTurn.assistantMessage.id, + role: "assistant", + expectedRevision: 1, + }), + 2 + ) + assertions += 2 + + const snapshotOne = await repository.getConversationSnapshot({ + actorUserId: userId, + conversationId: conversation, + }) + const snapshotTwo = await repository.getConversationSnapshot({ + actorUserId: userId, + conversationId: conversation, + }) + assert.ok(snapshotOne) + assert.deepEqual(snapshotTwo, snapshotOne) + assert.equal(Object.keys(snapshotOne.threads).length, 4) + assert.equal(Object.keys(snapshotOne.threadForks).length, 3) + assert.equal(Object.keys(snapshotOne.messages).length, 9) + assert.equal( + snapshotOne.turns[rootTurn.turn.id]?.activeAssistantMessageId, + rootTurn.assistantMessage.id + ) + assert.deepEqual( + deriveConversationReadIndexes(snapshotOne), + deriveConversationReadIndexes(snapshotTwo!) + ) + assert.deepEqual( + deriveConversationReadIndexes(snapshotOne).childThreadIdsByParentThread[ + root + ], + [threadA] + ) + assert.equal("saveConversationSnapshot" in repository, false) + assertions += 9 + + const secondConversation = conversationId(`${prefix}:conversation:second`) + const secondRoot = threadId(`${prefix}:thread:second-root`) + await createConversationFixture({ + id: secondConversation, + root: secondRoot, + title: "第二会话", + }) + const secondTurn = turnFixture({ + thread: secondRoot, + suffix: "second-root", + position: 0, + }) + await repository.appendTurn({ + actorUserId: userId, + conversationId: secondConversation, + expectedThreadRevision: 0, + ...secondTurn, + }) + await expectRepositoryCode("invalid_variant", () => + repository.appendMessageVariant({ + actorUserId: userId, + conversationId: conversation, + expectedTurnRevision: 0, + sourceMessageId: secondTurn.assistantMessage.id, + select: true, + message: { + id: messageId(`${prefix}:message:cross-conversation-variant`), + threadId: secondRoot, + turnId: secondTurn.turn.id, + role: "assistant", + content: content("不能借用另一条 Conversation 的授权参数"), + contentState: "complete", + createdAt: "2026-08-22T00:04:30.000Z", + }, + }) + ) + await expectRepositoryCode("invalid_fork", () => + repository.forkThread({ + actorUserId: userId, + conversationId: conversation, + expectedConversationRevision: 3, + childThread: { + id: threadId(`${prefix}:thread:cross-conversation`), + conversationId: secondConversation, + modelId: "ark-glm-5.3", + localTitle: "非法", + revision: 0, + lifecycle: "active", + }, + fork: { + id: threadForkId(`${prefix}:fork:cross-conversation`), + conversationId: conversation, + parentThreadId: root, + sourceMessageId: rootTurn.assistantMessage.id, + childThreadId: threadId(`${prefix}:thread:cross-conversation`), + createdBy: userId, + createdAt: "2026-08-22T00:05:00.000Z", + }, + }) + ) + await expectRepositoryCode("invalid_fork", () => + repository.forkThread({ + actorUserId: userId, + conversationId: conversation, + expectedConversationRevision: 3, + childThread: { + id: threadId(`${prefix}:thread:wrong-source`), + conversationId: conversation, + modelId: "ark-glm-5.3", + localTitle: "非法来源", + revision: 0, + lifecycle: "active", + }, + fork: { + id: threadForkId(`${prefix}:fork:wrong-source`), + conversationId: conversation, + parentThreadId: threadA, + sourceMessageId: rootTurn.assistantMessage.id, + childThreadId: threadId(`${prefix}:thread:wrong-source`), + createdBy: userId, + createdAt: "2026-08-22T00:06:00.000Z", + }, + }) + ) + assertions += 3 + + await expectDatabaseRejection( + "第二入向 Fork", + "thread_forks_child_thread_uq", + () => + db.transaction(async (transaction) => { + await transaction.insert(threadForks).values({ + id: `${prefix}:fork:duplicate-incoming`, + conversationId: conversation, + parentThreadId: root, + sourceMessageId: rootTurn.assistantMessage.id, + childThreadId: threadA, + createdBy: userId, + }) + }) + ) + await expectDatabaseRejection("Fork 环", "conversation_fork_acyclic_ck", () => + db.transaction(async (transaction) => { + await transaction + .update(threadForks) + .set({ + parentThreadId: threadC, + sourceMessageId: turnC.assistantMessage.id, + }) + .where(eq(threadForks.id, `${prefix}:fork:A`)) + }) + ) + await expectDatabaseRejection( + "跨 Thread Message/Turn", + "conversation_messages_turn_thread_fk", + () => + db.transaction(async (transaction) => { + await transaction.insert(conversationMessages).values({ + id: `${prefix}:message:cross-thread`, + threadId: threadA, + turnId: rootTurn.turn.id, + role: "assistant", + content: content("非法跨 Thread"), + contentState: "complete", + }) + }) + ) + const wrongRoleMessage = messageId(`${prefix}:message:wrong-active-role`) + await expectDatabaseRejection( + "错误角色 active Message", + "conversation_turn_active_assistant_role_ck", + () => + db.transaction(async (transaction) => { + await transaction.insert(conversationMessages).values({ + id: wrongRoleMessage, + threadId: root, + turnId: rootTurn.turn.id, + role: "user", + content: content("不能成为 active assistant"), + contentState: "complete", + variantOfMessageId: rootTurn.userMessage.id, + }) + await transaction + .update(conversationTurns) + .set({ activeAssistantMessageId: wrongRoleMessage }) + .where(eq(conversationTurns.id, rootTurn.turn.id)) + }) + ) + await expectDatabaseRejection( + "根 Thread 身份变化", + "conversations_root_thread_immutable_ck", + () => + db.transaction(async (transaction) => { + await transaction + .update(conversations) + .set({ rootThreadId: threadA }) + .where(eq(conversations.id, conversation)) + }) + ) + assertions += 5 + + const [canonicalCount] = await db + .select({ value: count(conversations.id) }) + .from(conversations) + .innerJoin(projects, eq(projects.id, conversations.projectId)) + .where(eq(projects.workspaceId, workspace)) + assert.equal(canonicalCount?.value, 2) + assertions += 1 + + console.log( + JSON.stringify({ + ok: true, + assertions, + topology: "root → A → B → C", + snapshotStable: true, + wholeSnapshotWritePort: false, + }) + ) +} finally { + await db.delete(workspaces).where(eq(workspaces.id, workspace)) + await db.delete(user).where(eq(user.id, userId)) + await globalThis.__dbClient?.end() + globalThis.__dbClient = undefined +} From dde801748fb122d51f6fd80f3ef9da9f1e195991 Mon Sep 17 00:00:00 2001 From: zilin <276161014@qq.com> Date: Sat, 22 Aug 2026 02:25:41 +0800 Subject: [PATCH 04/32] feat(generation): migrate canonical lifecycle --- constants/conversation-generation.ts | 4 + drizzle/0008_noisy_random.sql | 136 + drizzle/meta/0008_snapshot.json | 2865 +++++++++++++++++ drizzle/meta/_journal.json | 7 + lib/db/schema.ts | 87 + .../conversation-generation-service.ts | 167 + .../domain/conversation-generation.test.ts | 102 + .../domain/conversation-generation.ts | 189 ++ .../canonical-generation-execution.test.ts | 238 ++ .../canonical-generation-execution.ts | 311 ++ .../canonical-generation-policy.ts | 51 + ...zzle-conversation-generation-repository.ts | 747 +++++ .../migrate-generation-lifecycle/tasks.md | 44 +- package.json | 2 + scripts/test-conversation-generation.ts | 762 +++++ 15 files changed, 5690 insertions(+), 22 deletions(-) create mode 100644 constants/conversation-generation.ts create mode 100644 drizzle/0008_noisy_random.sql create mode 100644 drizzle/meta/0008_snapshot.json create mode 100644 lib/thread-chat/application/conversation-generation-service.ts create mode 100644 lib/thread-chat/domain/conversation-generation.test.ts create mode 100644 lib/thread-chat/domain/conversation-generation.ts create mode 100644 lib/thread-chat/generation/canonical-generation-execution.test.ts create mode 100644 lib/thread-chat/generation/canonical-generation-execution.ts create mode 100644 lib/thread-chat/persistence/canonical-generation-policy.ts create mode 100644 lib/thread-chat/persistence/drizzle-conversation-generation-repository.ts create mode 100644 scripts/test-conversation-generation.ts diff --git a/constants/conversation-generation.ts b/constants/conversation-generation.ts new file mode 100644 index 00000000..3aa0b105 --- /dev/null +++ b/constants/conversation-generation.ts @@ -0,0 +1,4 @@ +export const CONVERSATION_GENERATION_CHECKPOINT_SCHEMA_VERSION = 1 as const +export const CONVERSATION_GENERATION_DEFAULT_LEASE_MS = 30_000 +export const CONVERSATION_GENERATION_DEFAULT_CHECKPOINT_THROTTLE_MS = 250 +export const CONVERSATION_GENERATION_DEFAULT_HEARTBEAT_MS = 5_000 diff --git a/drizzle/0008_noisy_random.sql b/drizzle/0008_noisy_random.sql new file mode 100644 index 00000000..0242eca3 --- /dev/null +++ b/drizzle/0008_noisy_random.sql @@ -0,0 +1,136 @@ +CREATE TABLE "thread_chat"."conversation_generations" ( + "id" text PRIMARY KEY NOT NULL, + "owner_id" text NOT NULL, + "workspace_id" text NOT NULL, + "project_id" text NOT NULL, + "conversation_id" text NOT NULL, + "thread_id" text NOT NULL, + "turn_id" text NOT NULL, + "input_message_id" text NOT NULL, + "output_message_id" text NOT NULL, + "intent" jsonb NOT NULL, + "request_hash" text NOT NULL, + "idempotency_key" text NOT NULL, + "model_id" text NOT NULL, + "attempt" integer NOT NULL, + "is_current" boolean DEFAULT true NOT NULL, + "status" text NOT NULL, + "content_state" text NOT NULL, + "checkpoint_version" integer DEFAULT 0 NOT NULL, + "checkpoint" jsonb NOT NULL, + "known_usage" jsonb, + "usage_completeness" text NOT NULL, + "billing_status" text NOT NULL, + "paid_call_started" boolean DEFAULT false NOT NULL, + "lease_owner" text, + "lease_version" integer DEFAULT 0 NOT NULL, + "heartbeat_at" timestamp with time zone DEFAULT now() NOT NULL, + "stop_requested_at" timestamp with time zone, + "started_at" timestamp with time zone DEFAULT now() NOT NULL, + "finished_at" timestamp with time zone, + "error_code" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "thread_chat"."conversation_generations" ADD CONSTRAINT "conversation_generations_owner_id_user_id_fk" FOREIGN KEY ("owner_id") REFERENCES "thread_chat"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "conversation_generations_owner_idempotency_uq" ON "thread_chat"."conversation_generations" USING btree ("owner_id","idempotency_key");--> statement-breakpoint +CREATE UNIQUE INDEX "conversation_generations_output_attempt_uq" ON "thread_chat"."conversation_generations" USING btree ("output_message_id","attempt");--> statement-breakpoint +CREATE UNIQUE INDEX "conversation_generations_current_turn_uq" ON "thread_chat"."conversation_generations" USING btree ("turn_id") WHERE "thread_chat"."conversation_generations"."is_current" = true;--> statement-breakpoint +CREATE INDEX "conversation_generations_owner_status_idx" ON "thread_chat"."conversation_generations" USING btree ("owner_id","status");--> statement-breakpoint +CREATE INDEX "conversation_generations_conversation_updated_idx" ON "thread_chat"."conversation_generations" USING btree ("conversation_id","updated_at");--> statement-breakpoint +CREATE INDEX "conversation_generations_lease_idx" ON "thread_chat"."conversation_generations" USING btree ("status","heartbeat_at"); +--> statement-breakpoint +ALTER TABLE "thread_chat"."conversation_generations" + ADD CONSTRAINT "conversation_generations_workspace_owner_fk" + FOREIGN KEY (workspace_id, owner_id) + REFERENCES "thread_chat"."workspace_members" (workspace_id, user_id);--> statement-breakpoint +ALTER TABLE "thread_chat"."conversation_generations" + ADD CONSTRAINT "conversation_generations_project_workspace_fk" + FOREIGN KEY (workspace_id, project_id) + REFERENCES "thread_chat"."projects" (workspace_id, id);--> statement-breakpoint +ALTER TABLE "thread_chat"."conversation_generations" + ADD CONSTRAINT "conversation_generations_conversation_project_fk" + FOREIGN KEY (project_id, conversation_id) + REFERENCES "thread_chat"."conversations" (project_id, id);--> statement-breakpoint +ALTER TABLE "thread_chat"."conversation_generations" + ADD CONSTRAINT "conversation_generations_thread_conversation_fk" + FOREIGN KEY (thread_id, conversation_id) + REFERENCES "thread_chat"."conversation_threads" (id, conversation_id);--> statement-breakpoint +ALTER TABLE "thread_chat"."conversation_generations" + ADD CONSTRAINT "conversation_generations_turn_thread_fk" + FOREIGN KEY (turn_id, thread_id) + REFERENCES "thread_chat"."conversation_turns" (id, thread_id);--> statement-breakpoint +ALTER TABLE "thread_chat"."conversation_generations" + ADD CONSTRAINT "conversation_generations_input_message_fk" + FOREIGN KEY (input_message_id, thread_id, turn_id) + REFERENCES "thread_chat"."conversation_messages" (id, thread_id, turn_id);--> statement-breakpoint +ALTER TABLE "thread_chat"."conversation_generations" + ADD CONSTRAINT "conversation_generations_output_message_fk" + FOREIGN KEY (output_message_id, thread_id, turn_id) + REFERENCES "thread_chat"."conversation_messages" (id, thread_id, turn_id);--> statement-breakpoint + +ALTER TABLE "thread_chat"."conversation_generations" + ADD CONSTRAINT "conversation_generations_attempt_positive_ck" CHECK (attempt > 0), + ADD CONSTRAINT "conversation_generations_version_nonnegative_ck" CHECK (checkpoint_version >= 0 AND lease_version >= 0), + ADD CONSTRAINT "conversation_generations_status_ck" CHECK (status IN ('running', 'stop_requested', 'completed', 'stopped', 'failed', 'superseded')), + ADD CONSTRAINT "conversation_generations_content_state_ck" CHECK (content_state IN ('pending', 'streaming', 'complete', 'incomplete', 'failed')), + ADD CONSTRAINT "conversation_generations_usage_completeness_ck" CHECK (usage_completeness IN ('complete', 'partial', 'unavailable')), + ADD CONSTRAINT "conversation_generations_billing_status_ck" CHECK (billing_status IN ('pending', 'settled', 'usage_unavailable', 'not_billable')), + ADD CONSTRAINT "conversation_generations_intent_ck" CHECK (intent ->> 'kind' IN ('send', 'regenerate-assistant', 'edit-user', 'retry')), + ADD CONSTRAINT "conversation_generations_checkpoint_schema_ck" CHECK (checkpoint @> '{"schemaVersion": 1}'::jsonb), + ADD CONSTRAINT "conversation_generations_terminal_time_ck" CHECK ( + (status IN ('running', 'stop_requested') AND finished_at IS NULL) + OR (status IN ('completed', 'stopped', 'failed', 'superseded') AND finished_at IS NOT NULL) + ), + ADD CONSTRAINT "conversation_generations_stop_time_ck" CHECK ( + (status = 'stop_requested' AND stop_requested_at IS NOT NULL) + OR status <> 'stop_requested' + ), + ADD CONSTRAINT "conversation_generations_terminal_content_ck" CHECK ( + (status = 'completed' AND content_state = 'complete') + OR (status IN ('stopped', 'failed', 'superseded') AND content_state IN ('incomplete', 'failed')) + OR status IN ('running', 'stop_requested') + ), + ADD CONSTRAINT "conversation_generations_billing_truth_ck" CHECK ( + (billing_status = 'pending' AND status IN ('running', 'stop_requested')) + OR (billing_status = 'settled' AND usage_completeness = 'complete' AND known_usage IS NOT NULL AND paid_call_started) + OR (billing_status = 'usage_unavailable' AND usage_completeness IN ('partial', 'unavailable')) + OR (billing_status = 'not_billable' AND paid_call_started = false) + ), + ADD CONSTRAINT "conversation_generations_usage_shape_ck" CHECK ( + (usage_completeness = 'complete' AND known_usage IS NOT NULL AND (known_usage ->> 'reportedStepCount')::int = (known_usage ->> 'paidStepCount')::int) + OR (usage_completeness = 'partial' AND known_usage IS NOT NULL AND (known_usage ->> 'reportedStepCount')::int < (known_usage ->> 'paidStepCount')::int) + OR usage_completeness = 'unavailable' + );--> statement-breakpoint + +CREATE FUNCTION "thread_chat"."validate_conversation_generation_identity"() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM "thread_chat"."conversation_messages" + WHERE id = NEW.input_message_id + AND thread_id = NEW.thread_id + AND turn_id = NEW.turn_id + AND role = 'user' + ) THEN + RAISE EXCEPTION USING ERRCODE = '23514', CONSTRAINT = 'conversation_generations_input_role_ck', MESSAGE = 'Generation input Message must have user role'; + END IF; + IF NOT EXISTS ( + SELECT 1 FROM "thread_chat"."conversation_messages" + WHERE id = NEW.output_message_id + AND thread_id = NEW.thread_id + AND turn_id = NEW.turn_id + AND role = 'assistant' + ) THEN + RAISE EXCEPTION USING ERRCODE = '23514', CONSTRAINT = 'conversation_generations_output_role_ck', MESSAGE = 'Generation output Message must have assistant role'; + END IF; + RETURN NEW; +END; +$$;--> statement-breakpoint +CREATE TRIGGER "conversation_generations_identity_guard" + BEFORE INSERT OR UPDATE OF input_message_id, output_message_id, thread_id, turn_id + ON "thread_chat"."conversation_generations" + FOR EACH ROW EXECUTE FUNCTION "thread_chat"."validate_conversation_generation_identity"(); diff --git a/drizzle/meta/0008_snapshot.json b/drizzle/meta/0008_snapshot.json new file mode 100644 index 00000000..f534f644 --- /dev/null +++ b/drizzle/meta/0008_snapshot.json @@ -0,0 +1,2865 @@ +{ + "id": "485c97a9-753a-445a-ad71-00a35bc882fb", + "prevId": "47a369b2-0690-4792-b3da-b53161cefd29", + "version": "7", + "dialect": "postgresql", + "tables": { + "thread_chat.attachment_chunks": { + "name": "attachment_chunks", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "attachment_id": { + "name": "attachment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "page": { + "name": "page", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "attachment_chunks_attachment_id_idx": { + "name": "attachment_chunks_attachment_id_idx", + "columns": [ + { + "expression": "attachment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "attachment_chunks_embedding_idx": { + "name": "attachment_chunks_embedding_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": {} + } + }, + "foreignKeys": { + "attachment_chunks_attachment_id_attachments_id_fk": { + "name": "attachment_chunks_attachment_id_attachments_id_fk", + "tableFrom": "attachment_chunks", + "tableTo": "attachments", + "schemaTo": "thread_chat", + "columnsFrom": [ + "attachment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.attachments": { + "name": "attachments", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'uploading'" + }, + "page_count": { + "name": "page_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pages": { + "name": "pages", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "suggested_questions": { + "name": "suggested_questions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "attachments_key_unique": { + "name": "attachments_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.branch_generations": { + "name": "branch_generations", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tree_id": { + "name": "tree_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_message_id": { + "name": "user_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assistant_message_id": { + "name": "assistant_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assistant_message_index": { + "name": "assistant_message_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "turn_snapshot": { + "name": "turn_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_status": { + "name": "billing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "heartbeat_at": { + "name": "heartbeat_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "stop_requested_at": { + "name": "stop_requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "branch_generations_current_assistant_uq": { + "name": "branch_generations_current_assistant_uq", + "columns": [ + { + "expression": "tree_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assistant_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"thread_chat\".\"branch_generations\".\"is_current\" = true", + "concurrently": false, + "method": "btree", + "with": {} + }, + "branch_generations_assistant_attempt_uq": { + "name": "branch_generations_assistant_attempt_uq", + "columns": [ + { + "expression": "tree_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assistant_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "attempt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "branch_generations_user_id_idx": { + "name": "branch_generations_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "branch_generations_tree_current_idx": { + "name": "branch_generations_tree_current_idx", + "columns": [ + { + "expression": "tree_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_current", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "branch_generations_user_status_idx": { + "name": "branch_generations_user_status_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "branch_generations_heartbeat_idx": { + "name": "branch_generations_heartbeat_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "heartbeat_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "branch_generations_user_id_user_id_fk": { + "name": "branch_generations_user_id_user_id_fk", + "tableFrom": "branch_generations", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "branch_generations_tree_id_branch_trees_id_fk": { + "name": "branch_generations_tree_id_branch_trees_id_fk", + "tableFrom": "branch_generations", + "tableTo": "branch_trees", + "schemaTo": "thread_chat", + "columnsFrom": [ + "tree_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.branch_message_feedback": { + "name": "branch_message_feedback", + "schema": "thread_chat", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tree_id": { + "name": "tree_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "branch_message_feedback_tree_idx": { + "name": "branch_message_feedback_tree_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tree_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "branch_message_feedback_user_id_user_id_fk": { + "name": "branch_message_feedback_user_id_user_id_fk", + "tableFrom": "branch_message_feedback", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "branch_message_feedback_tree_id_branch_trees_id_fk": { + "name": "branch_message_feedback_tree_id_branch_trees_id_fk", + "tableFrom": "branch_message_feedback", + "tableTo": "branch_trees", + "schemaTo": "thread_chat", + "columnsFrom": [ + "tree_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "branch_message_feedback_pk": { + "name": "branch_message_feedback_pk", + "columns": [ + "user_id", + "tree_id", + "thread_id", + "message_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.branch_trees": { + "name": "branch_trees", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_title": { + "name": "custom_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "branch_trees_user_id_idx": { + "name": "branch_trees_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "branch_trees_user_id_user_id_fk": { + "name": "branch_trees_user_id_user_id_fk", + "tableFrom": "branch_trees", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.conversation_generations": { + "name": "conversation_generations", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_message_id": { + "name": "input_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "output_message_id": { + "name": "output_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "intent": { + "name": "intent", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_state": { + "name": "content_state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checkpoint_version": { + "name": "checkpoint_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "checkpoint": { + "name": "checkpoint", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "known_usage": { + "name": "known_usage", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "usage_completeness": { + "name": "usage_completeness", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_status": { + "name": "billing_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "paid_call_started": { + "name": "paid_call_started", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "lease_owner": { + "name": "lease_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_version": { + "name": "lease_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "heartbeat_at": { + "name": "heartbeat_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "stop_requested_at": { + "name": "stop_requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversation_generations_owner_idempotency_uq": { + "name": "conversation_generations_owner_idempotency_uq", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_generations_output_attempt_uq": { + "name": "conversation_generations_output_attempt_uq", + "columns": [ + { + "expression": "output_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "attempt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_generations_current_turn_uq": { + "name": "conversation_generations_current_turn_uq", + "columns": [ + { + "expression": "turn_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"thread_chat\".\"conversation_generations\".\"is_current\" = true", + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_generations_owner_status_idx": { + "name": "conversation_generations_owner_status_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_generations_conversation_updated_idx": { + "name": "conversation_generations_conversation_updated_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_generations_lease_idx": { + "name": "conversation_generations_lease_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "heartbeat_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversation_generations_owner_id_user_id_fk": { + "name": "conversation_generations_owner_id_user_id_fk", + "tableFrom": "conversation_generations", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.conversation_messages": { + "name": "conversation_messages", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "content_state": { + "name": "content_state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variant_of_message_id": { + "name": "variant_of_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversation_messages_id_thread_id_uq": { + "name": "conversation_messages_id_thread_id_uq", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_messages_id_thread_turn_uq": { + "name": "conversation_messages_id_thread_turn_uq", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "turn_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_messages_turn_created_idx": { + "name": "conversation_messages_turn_created_idx", + "columns": [ + { + "expression": "turn_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_messages_variant_source_idx": { + "name": "conversation_messages_variant_source_idx", + "columns": [ + { + "expression": "variant_of_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.conversation_threads": { + "name": "conversation_threads", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "local_title": { + "name": "local_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversation_threads_id_conversation_id_uq": { + "name": "conversation_threads_id_conversation_id_uq", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_threads_conversation_created_idx": { + "name": "conversation_threads_conversation_created_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversation_threads_conversation_id_conversations_id_fk": { + "name": "conversation_threads_conversation_id_conversations_id_fk", + "tableFrom": "conversation_threads", + "tableTo": "conversations", + "schemaTo": "thread_chat", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.conversation_turns": { + "name": "conversation_turns", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "active_user_message_id": { + "name": "active_user_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_assistant_message_id": { + "name": "active_assistant_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversation_turns_id_thread_id_uq": { + "name": "conversation_turns_id_thread_id_uq", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_turns_thread_position_uq": { + "name": "conversation_turns_thread_position_uq", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_turns_active_user_idx": { + "name": "conversation_turns_active_user_idx", + "columns": [ + { + "expression": "active_user_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_turns_active_assistant_idx": { + "name": "conversation_turns_active_assistant_idx", + "columns": [ + { + "expression": "active_assistant_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversation_turns_thread_id_conversation_threads_id_fk": { + "name": "conversation_turns_thread_id_conversation_threads_id_fk", + "tableFrom": "conversation_turns", + "tableTo": "conversation_threads", + "schemaTo": "thread_chat", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.conversations": { + "name": "conversations", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "root_thread_id": { + "name": "root_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auto_title": { + "name": "auto_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_title": { + "name": "custom_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversations_project_id_id_uq": { + "name": "conversations_project_id_id_uq", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversations_project_updated_idx": { + "name": "conversations_project_updated_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversations_project_lifecycle_idx": { + "name": "conversations_project_lifecycle_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lifecycle", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversations_project_id_projects_id_fk": { + "name": "conversations_project_id_projects_id_fk", + "tableFrom": "conversations", + "tableTo": "projects", + "schemaTo": "thread_chat", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.projects": { + "name": "projects", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "projects_workspace_id_id_uq": { + "name": "projects_workspace_id_id_uq", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "projects_workspace_lifecycle_idx": { + "name": "projects_workspace_lifecycle_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lifecycle", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_workspace_id_workspaces_id_fk": { + "name": "projects_workspace_id_workspaces_id_fk", + "tableFrom": "projects", + "tableTo": "workspaces", + "schemaTo": "thread_chat", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.thread_forks": { + "name": "thread_forks", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_thread_id": { + "name": "parent_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_message_id": { + "name": "source_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_thread_id": { + "name": "child_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "anchor": { + "name": "anchor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "thread_forks_child_thread_uq": { + "name": "thread_forks_child_thread_uq", + "columns": [ + { + "expression": "child_thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "thread_forks_parent_source_idx": { + "name": "thread_forks_parent_source_idx", + "columns": [ + { + "expression": "parent_thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "thread_forks_conversation_created_idx": { + "name": "thread_forks_conversation_created_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "thread_forks_conversation_id_conversations_id_fk": { + "name": "thread_forks_conversation_id_conversations_id_fk", + "tableFrom": "thread_forks", + "tableTo": "conversations", + "schemaTo": "thread_chat", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "thread_forks_created_by_user_id_fk": { + "name": "thread_forks_created_by_user_id_fk", + "tableFrom": "thread_forks", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.workspace_members": { + "name": "workspace_members", + "schema": "thread_chat", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_members_user_id_idx": { + "name": "workspace_members_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_members_workspace_id_workspaces_id_fk": { + "name": "workspace_members_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_members", + "tableTo": "workspaces", + "schemaTo": "thread_chat", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_members_user_id_user_id_fk": { + "name": "workspace_members_user_id_user_id_fk", + "tableFrom": "workspace_members", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_members_pk": { + "name": "workspace_members_pk", + "columns": [ + "workspace_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.workspaces": { + "name": "workspaces", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspaces_lifecycle_idx": { + "name": "workspaces_lifecycle_idx", + "columns": [ + { + "expression": "lifecycle", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.account": { + "name": "account", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.session": { + "name": "session", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.user": { + "name": "user", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.verification": { + "name": "verification", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.usage_records": { + "name": "usage_records", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "app_generation_id": { + "name": "app_generation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_micros": { + "name": "cost_micros", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "price_micros": { + "name": "price_micros", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "generation_id": { + "name": "generation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cost_source": { + "name": "cost_source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'estimate'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_records_user_id_idx": { + "name": "usage_records_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_records_thread_id_idx": { + "name": "usage_records_thread_id_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_records_cost_source_idx": { + "name": "usage_records_cost_source_idx", + "columns": [ + { + "expression": "cost_source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_records_app_generation_id_uq": { + "name": "usage_records_app_generation_id_uq", + "columns": [ + { + "expression": "app_generation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_records_user_id_user_id_fk": { + "name": "usage_records_user_id_user_id_fk", + "tableFrom": "usage_records", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.user_credits": { + "name": "user_credits", + "schema": "thread_chat", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "balance_micros": { + "name": "balance_micros", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_credits_user_id_user_id_fk": { + "name": "user_credits_user_id_user_id_fk", + "tableFrom": "user_credits", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.payments": { + "name": "payments", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'creem'" + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pack_id": { + "name": "pack_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checkout_id": { + "name": "checkout_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order_id": { + "name": "order_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "credit_micros": { + "name": "credit_micros", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "price_label": { + "name": "price_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "raw": { + "name": "raw", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "payments_user_id_idx": { + "name": "payments_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payments_provider_order_id_uq": { + "name": "payments_provider_order_id_uq", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payments_user_id_user_id_fk": { + "name": "payments_user_id_user_id_fk", + "tableFrom": "payments", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.subscriptions": { + "name": "subscriptions", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'creem'" + }, + "subscription_id": { + "name": "subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_period_end": { + "name": "current_period_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "raw": { + "name": "raw", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscriptions_user_id_idx": { + "name": "subscriptions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "subscriptions_user_id_user_id_fk": { + "name": "subscriptions_user_id_user_id_fk", + "tableFrom": "subscriptions", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "subscriptions_subscription_id_unique": { + "name": "subscriptions_subscription_id_unique", + "nullsNotDistinct": false, + "columns": [ + "subscription_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 19599355..d9f78e0e 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1787334734551, "tag": "0007_protect-root-thread-identity", "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1787335260786, + "tag": "0008_noisy_random", + "breakpoints": true } ] } \ No newline at end of file diff --git a/lib/db/schema.ts b/lib/db/schema.ts index dada9660..ea8ba3a8 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -22,10 +22,18 @@ import type { import type { MessageFeedback } from "@/lib/thread-chat/domain/types" import type { LifecycleStatus, + GenerationIntent, + GenerationStatus as CanonicalGenerationStatus, + GenerationBillingStatus as CanonicalGenerationBillingStatus, MessageContent, MessageContentState, MessageRole, } from "@/lib/thread-chat/domain/conversation-model" +import type { + ConversationGenerationCheckpoint, + KnownGenerationUsage, + UsageCompleteness, +} from "@/lib/thread-chat/domain/conversation-generation" // 认证与计费表在独立文件中定义,这里统一 re-export,使 drizzle 客户端与迁移能感知它们。 export * from "./auth-schema" @@ -284,6 +292,85 @@ export const threadForks = dbSchema.table( ] ) +export const conversationGenerations = dbSchema.table( + "conversation_generations", + { + id: text("id").primaryKey(), + ownerId: text("owner_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + workspaceId: text("workspace_id").notNull(), + projectId: text("project_id").notNull(), + conversationId: text("conversation_id").notNull(), + threadId: text("thread_id").notNull(), + turnId: text("turn_id").notNull(), + inputMessageId: text("input_message_id").notNull(), + outputMessageId: text("output_message_id").notNull(), + intent: jsonb("intent").$type().notNull(), + requestHash: text("request_hash").notNull(), + idempotencyKey: text("idempotency_key").notNull(), + modelId: text("model_id").notNull(), + attempt: integer("attempt").notNull(), + isCurrent: boolean("is_current").notNull().default(true), + status: text("status").$type().notNull(), + contentState: text("content_state").$type().notNull(), + checkpointVersion: integer("checkpoint_version").notNull().default(0), + checkpoint: jsonb("checkpoint") + .$type() + .notNull(), + knownUsage: jsonb("known_usage").$type(), + usageCompleteness: text("usage_completeness") + .$type() + .notNull(), + billingStatus: text("billing_status") + .$type() + .notNull(), + paidCallStarted: boolean("paid_call_started").notNull().default(false), + leaseOwner: text("lease_owner"), + leaseVersion: integer("lease_version").notNull().default(0), + heartbeatAt: timestamp("heartbeat_at", { withTimezone: true }) + .notNull() + .defaultNow(), + stopRequestedAt: timestamp("stop_requested_at", { withTimezone: true }), + startedAt: timestamp("started_at", { withTimezone: true }) + .notNull() + .defaultNow(), + finishedAt: timestamp("finished_at", { withTimezone: true }), + errorCode: text("error_code"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => [ + uniqueIndex("conversation_generations_owner_idempotency_uq").on( + table.ownerId, + table.idempotencyKey + ), + uniqueIndex("conversation_generations_output_attempt_uq").on( + table.outputMessageId, + table.attempt + ), + uniqueIndex("conversation_generations_current_turn_uq") + .on(table.turnId) + .where(sql`${table.isCurrent} = true`), + index("conversation_generations_owner_status_idx").on( + table.ownerId, + table.status + ), + index("conversation_generations_conversation_updated_idx").on( + table.conversationId, + table.updatedAt + ), + index("conversation_generations_lease_idx").on( + table.status, + table.heartbeatAt + ), + ] +) + // 分支对话树(app/thread-chat)的整棵树持久化:一棵树一行,state 存完整 // ThreadTreeState(JSON)。与上面 assistant-ui 线性模型的 threads/messages 表分开, // 互不复用——那两张表是线性会话,这张是树形分支态。treeId 由客户端生成 diff --git a/lib/thread-chat/application/conversation-generation-service.ts b/lib/thread-chat/application/conversation-generation-service.ts new file mode 100644 index 00000000..7acde312 --- /dev/null +++ b/lib/thread-chat/application/conversation-generation-service.ts @@ -0,0 +1,167 @@ +import type { + ConversationGenerationCheckpoint, + KnownGenerationUsage, + UsageCompleteness, +} from "../domain/conversation-generation" +import type { + ConversationGeneration, + ConversationId, + ConversationMessage, + GenerationBillingStatus, + GenerationId, + GenerationIntent, + GenerationStatus, + MessageContentState, + MessageId, + ProjectId, + ThreadId, + TurnId, + WorkspaceId, +} from "../domain/conversation-model" + +export interface CanonicalGenerationRecord extends ConversationGeneration { + readonly ownerId: string + readonly workspaceId: WorkspaceId + readonly projectId: ProjectId + readonly conversationId: ConversationId + readonly requestHash: string + readonly idempotencyKey: string + readonly modelId: string + readonly isCurrent: boolean + readonly contentState: MessageContentState + readonly checkpointVersion: number + readonly checkpoint: ConversationGenerationCheckpoint + readonly knownUsage: KnownGenerationUsage | null + readonly usageCompleteness: UsageCompleteness + readonly paidCallStarted: boolean + readonly leaseOwner: string | null + readonly leaseVersion: number + readonly heartbeatAt: string + readonly stopRequestedAt: string | null + readonly startedAt: string + readonly finishedAt: string | null + readonly errorCode: string | null +} + +export interface StartCanonicalGenerationInput { + readonly id: GenerationId + readonly ownerId: string + readonly workspaceId: WorkspaceId + readonly projectId: ProjectId + readonly conversationId: ConversationId + readonly threadId: ThreadId + readonly turnId: TurnId + readonly inputMessageId: MessageId + readonly outputMessage: ConversationMessage & { readonly role: "assistant" } + readonly intent: GenerationIntent + readonly requestHash: string + readonly idempotencyKey: string + readonly modelId: string + readonly expectedTurnRevision: number + readonly leaseOwner: string +} + +export type StartCanonicalGenerationResult = + | { readonly created: true; readonly generation: CanonicalGenerationRecord } + | { readonly created: false; readonly generation: CanonicalGenerationRecord } + +export interface FinalizeCanonicalGenerationInput { + readonly generationId: GenerationId + readonly leaseOwner: string + readonly expectedCheckpointVersion: number + readonly outcome: "completed" | "stopped" | "failed" + readonly checkpoint: ConversationGenerationCheckpoint + readonly usageCompleteness: UsageCompleteness + readonly knownUsage: KnownGenerationUsage | null + readonly errorCode?: string +} + +export interface CanonicalGenerationRepository { + startGeneration( + input: StartCanonicalGenerationInput + ): Promise + markPaidCallStarted(input: { + readonly generationId: GenerationId + readonly leaseOwner: string + }): Promise + getGeneration(input: { + readonly ownerId: string + readonly generationId: GenerationId + }): Promise + saveCheckpoint(input: { + readonly generationId: GenerationId + readonly leaseOwner: string + readonly expectedVersion: number + readonly checkpoint: ConversationGenerationCheckpoint + }): Promise< + | { readonly kind: "saved"; readonly version: number } + | { readonly kind: "conflict"; readonly version: number } + | { readonly kind: "terminal"; readonly version: number } + > + heartbeat(input: { + readonly generationId: GenerationId + readonly leaseOwner: string + }): Promise + requestStop(input: { + readonly ownerId: string + readonly generationId: GenerationId + }): Promise + claimStale(input: { + readonly generationId: GenerationId + readonly staleBefore: Date + readonly leaseOwner: string + }): Promise + finalizeGeneration( + input: FinalizeCanonicalGenerationInput + ): Promise +} + +export interface CanonicalGenerationExecutor { + execute(input: { + readonly generation: CanonicalGenerationRecord + readonly signal: AbortSignal + readonly onCheckpoint: ( + checkpoint: ConversationGenerationCheckpoint + ) => Promise + }): Promise<{ + readonly outcome: "completed" | "stopped" | "failed" + readonly checkpoint: ConversationGenerationCheckpoint + readonly usageCompleteness: UsageCompleteness + readonly knownUsage: KnownGenerationUsage | null + readonly errorCode?: string + }> +} + +export interface GenerationAbortRegistry { + register(generationId: GenerationId, controller: AbortController): void + abort(generationId: GenerationId): boolean + unregister(generationId: GenerationId, controller: AbortController): void +} + +export class CanonicalGenerationServiceError extends Error { + readonly code: + | "not_found" + | "forbidden" + | "version_conflict" + | "idempotency_conflict" + | "invalid_identity" + | "checkpoint_conflict" + + constructor(code: CanonicalGenerationServiceError["code"], message: string) { + super(message) + this.name = "CanonicalGenerationServiceError" + this.code = code + } +} + +export function expectedBillingStatus(input: { + readonly status: GenerationStatus + readonly paidCallStarted: boolean + readonly usageCompleteness: UsageCompleteness + readonly knownUsage: KnownGenerationUsage | null +}): GenerationBillingStatus { + if (!input.paidCallStarted) return "not_billable" + if (input.usageCompleteness === "complete" && input.knownUsage) + return "settled" + return "usage_unavailable" +} diff --git a/lib/thread-chat/domain/conversation-generation.test.ts b/lib/thread-chat/domain/conversation-generation.test.ts new file mode 100644 index 00000000..7a0af3f7 --- /dev/null +++ b/lib/thread-chat/domain/conversation-generation.test.ts @@ -0,0 +1,102 @@ +import assert from "node:assert/strict" +import test from "node:test" + +import { + aggregateKnownUsage, + checkpointMessageContent, + conversationGenerationCheckpointSchema, + hasRecoverableCheckpointOutput, + inferUsageCompleteness, + terminalMessageContentState, +} from "./conversation-generation.ts" + +test("checkpoint 保留正文、Artifact、研究活动及 running 状态", () => { + const checkpoint = conversationGenerationCheckpointSchema.parse({ + schemaVersion: 1, + body: "阶段结论", + artifactIds: ["artifact-1"], + researchPlan: { query: "第一性原理" }, + researchActivities: [ + { + id: "search-1", + kind: "search", + status: "running", + sources: [{ url: "https://example.invalid/source" }], + }, + ], + contentState: "streaming", + knownUsage: null, + }) + + assert.equal(hasRecoverableCheckpointOutput(checkpoint), true) + assert.equal(checkpoint.researchActivities[0]?.status, "running") + assert.deepEqual( + checkpointMessageContent(checkpoint).parts.map((part) => part.type), + ["text", "artifact-reference", "structured", "structured"] + ) + assert.equal( + terminalMessageContentState({ outcome: "stopped", checkpoint }), + "incomplete" + ) +}) + +test("无输出失败和有输出失败具有不同 Message 内容状态", () => { + const empty = conversationGenerationCheckpointSchema.parse({ + schemaVersion: 1, + body: "", + artifactIds: [], + researchPlan: null, + researchActivities: [], + contentState: "pending", + knownUsage: null, + }) + assert.equal( + terminalMessageContentState({ outcome: "failed", checkpoint: empty }), + "failed" + ) + assert.equal( + terminalMessageContentState({ + outcome: "failed", + checkpoint: { ...empty, body: "可恢复部分" }, + }), + "incomplete" + ) +}) + +test("usage 聚合不把部分或未知值伪装为完整", () => { + assert.deepEqual( + aggregateKnownUsage([ + { paid: true, inputTokens: 10, outputTokens: 5 }, + { paid: true }, + ]), + { + knownUsage: { + inputTokens: 10, + outputTokens: 5, + paidStepCount: 2, + reportedStepCount: 1, + }, + completeness: "partial", + } + ) + assert.deepEqual(aggregateKnownUsage([{ paid: true }]), { + knownUsage: null, + completeness: "unavailable", + }) + assert.equal( + aggregateKnownUsage([ + { paid: true, inputTokens: 2, outputTokens: 3 }, + { paid: true, inputTokens: 5, outputTokens: 7 }, + ]).completeness, + "complete" + ) + assert.equal( + inferUsageCompleteness({ + inputTokens: 1, + outputTokens: 2, + paidStepCount: 2, + reportedStepCount: 1, + }), + "partial" + ) +}) diff --git a/lib/thread-chat/domain/conversation-generation.ts b/lib/thread-chat/domain/conversation-generation.ts new file mode 100644 index 00000000..2fbb2d0f --- /dev/null +++ b/lib/thread-chat/domain/conversation-generation.ts @@ -0,0 +1,189 @@ +import { z } from "zod" + +import { CONVERSATION_GENERATION_CHECKPOINT_SCHEMA_VERSION } from "../../../constants/conversation-generation" +import type { + ArtifactId, + JsonValue, + MessageContent, + MessageContentState, +} from "./conversation-model" + +const jsonValueSchema: z.ZodType = z.lazy(() => + z.union([ + z.string(), + z.number(), + z.boolean(), + z.null(), + z.array(jsonValueSchema), + z.record(z.string(), jsonValueSchema), + ]) +) + +export const usageCompletenessSchema = z.enum([ + "complete", + "partial", + "unavailable", +]) +export type UsageCompleteness = z.infer + +export const knownGenerationUsageSchema = z.object({ + inputTokens: z.number().int().nonnegative(), + outputTokens: z.number().int().nonnegative(), + paidStepCount: z.number().int().nonnegative(), + reportedStepCount: z.number().int().nonnegative(), +}) +export type KnownGenerationUsage = z.infer + +export const generationResearchActivitySchema = z.object({ + id: z.string().min(1), + kind: z.string().min(1), + status: z.enum(["running", "complete", "error", "stopped"]), + sources: z.array( + z.object({ + url: z.string().min(1), + title: z.string().optional(), + }) + ), + error: z.string().optional(), +}) +export type GenerationResearchActivity = z.infer< + typeof generationResearchActivitySchema +> + +export const conversationGenerationCheckpointSchema = z.object({ + schemaVersion: z.literal(CONVERSATION_GENERATION_CHECKPOINT_SCHEMA_VERSION), + body: z.string(), + artifactIds: z.array(z.string().min(1)), + researchPlan: jsonValueSchema.nullable(), + researchActivities: z.array(generationResearchActivitySchema), + contentState: z.enum([ + "pending", + "streaming", + "complete", + "incomplete", + "failed", + ]), + knownUsage: knownGenerationUsageSchema.nullable(), +}) +export type ConversationGenerationCheckpoint = z.infer< + typeof conversationGenerationCheckpointSchema +> + +export function parseConversationGenerationCheckpoint( + value: unknown +): ConversationGenerationCheckpoint { + return conversationGenerationCheckpointSchema.parse(value) +} + +export function emptyConversationGenerationCheckpoint(): ConversationGenerationCheckpoint { + return { + schemaVersion: CONVERSATION_GENERATION_CHECKPOINT_SCHEMA_VERSION, + body: "", + artifactIds: [], + researchPlan: null, + researchActivities: [], + contentState: "pending", + knownUsage: null, + } +} + +export function hasRecoverableCheckpointOutput( + checkpoint: ConversationGenerationCheckpoint +): boolean { + return ( + checkpoint.body.trim().length > 0 || + checkpoint.artifactIds.length > 0 || + checkpoint.researchActivities.length > 0 || + checkpoint.researchPlan !== null + ) +} + +export function checkpointMessageContent( + checkpoint: ConversationGenerationCheckpoint +): MessageContent { + const parts: MessageContent["parts"][number][] = [] + if (checkpoint.body.length > 0) + parts.push({ type: "text", text: checkpoint.body }) + for (const artifactId of checkpoint.artifactIds) + parts.push({ + type: "artifact-reference", + artifactId: artifactId as ArtifactId, + }) + if (checkpoint.researchPlan !== null) + parts.push({ + type: "structured", + kind: "research-plan", + value: checkpoint.researchPlan, + }) + if (checkpoint.researchActivities.length > 0) + parts.push({ + type: "structured", + kind: "research-activities", + value: jsonValueSchema.parse(checkpoint.researchActivities), + }) + return { schemaVersion: 1, parts } +} + +export function terminalMessageContentState(input: { + readonly outcome: "completed" | "stopped" | "failed" | "superseded" + readonly checkpoint: ConversationGenerationCheckpoint +}): MessageContentState { + if (input.outcome === "completed") return "complete" + if (hasRecoverableCheckpointOutput(input.checkpoint)) return "incomplete" + return "failed" +} + +export function aggregateKnownUsage( + steps: readonly { + readonly inputTokens?: number + readonly outputTokens?: number + readonly paid: boolean + }[] +): { + readonly knownUsage: KnownGenerationUsage | null + readonly completeness: UsageCompleteness +} { + const paidSteps = steps.filter((step) => step.paid) + if (paidSteps.length === 0) + return { knownUsage: null, completeness: "unavailable" } + const reported = paidSteps.filter( + (step) => + Number.isInteger(step.inputTokens) && + Number.isInteger(step.outputTokens) && + (step.inputTokens ?? -1) >= 0 && + (step.outputTokens ?? -1) >= 0 + ) + const knownUsage = + reported.length === 0 + ? null + : { + inputTokens: reported.reduce( + (total, step) => total + (step.inputTokens ?? 0), + 0 + ), + outputTokens: reported.reduce( + (total, step) => total + (step.outputTokens ?? 0), + 0 + ), + paidStepCount: paidSteps.length, + reportedStepCount: reported.length, + } + return { + knownUsage, + completeness: + reported.length === paidSteps.length + ? "complete" + : reported.length > 0 + ? "partial" + : "unavailable", + } +} + +export function inferUsageCompleteness( + knownUsage: KnownGenerationUsage | null +): UsageCompleteness { + if (!knownUsage) return "unavailable" + return knownUsage.reportedStepCount === knownUsage.paidStepCount + ? "complete" + : "partial" +} diff --git a/lib/thread-chat/generation/canonical-generation-execution.test.ts b/lib/thread-chat/generation/canonical-generation-execution.test.ts new file mode 100644 index 00000000..436dce1a --- /dev/null +++ b/lib/thread-chat/generation/canonical-generation-execution.test.ts @@ -0,0 +1,238 @@ +import assert from "node:assert/strict" +import test from "node:test" + +import type { + CanonicalGenerationExecutor, + CanonicalGenerationRecord, + CanonicalGenerationRepository, + FinalizeCanonicalGenerationInput, + StartCanonicalGenerationInput, +} from "../application/conversation-generation-service.ts" +import { emptyConversationGenerationCheckpoint } from "../domain/conversation-generation.ts" +import { + conversationId, + generationId, + messageId, + projectId, + threadId, + turnId, + workspaceId, +} from "../domain/conversation-model.ts" +import { + CanonicalGenerationApplicationService, + InMemoryGenerationAbortRegistry, +} from "./canonical-generation-execution.ts" + +function fixture() { + const checkpoint = emptyConversationGenerationCheckpoint() + const record: CanonicalGenerationRecord = { + id: generationId("generation-1"), + ownerId: "user-1", + workspaceId: workspaceId("workspace-1"), + projectId: projectId("project-1"), + conversationId: conversationId("conversation-1"), + threadId: threadId("thread-1"), + turnId: turnId("turn-1"), + inputMessageId: messageId("message-user-1"), + outputMessageId: messageId("message-assistant-1"), + intent: { kind: "send" }, + requestHash: "hash-1", + idempotencyKey: "key-1", + modelId: "ark-glm-5.3", + attempt: 1, + isCurrent: true, + status: "running", + contentState: "pending", + billingStatus: "pending", + checkpointVersion: 0, + checkpoint, + knownUsage: null, + usageCompleteness: "unavailable", + paidCallStarted: false, + leaseOwner: "worker-1", + leaseVersion: 0, + heartbeatAt: "2026-08-22T00:00:00.000Z", + stopRequestedAt: null, + startedAt: "2026-08-22T00:00:00.000Z", + finishedAt: null, + errorCode: null, + createdAt: "2026-08-22T00:00:00.000Z", + } + const input: StartCanonicalGenerationInput = { + id: record.id, + ownerId: record.ownerId, + workspaceId: record.workspaceId, + projectId: record.projectId, + conversationId: record.conversationId, + threadId: record.threadId, + turnId: record.turnId, + inputMessageId: record.inputMessageId, + outputMessage: { + id: record.outputMessageId, + threadId: record.threadId, + turnId: record.turnId, + role: "assistant", + content: { schemaVersion: 1, parts: [] }, + contentState: "pending", + createdAt: "2026-08-22T00:00:00.000Z", + }, + intent: record.intent, + requestHash: record.requestHash, + idempotencyKey: record.idempotencyKey, + modelId: record.modelId, + expectedTurnRevision: 0, + leaseOwner: "worker-1", + } + return { checkpoint, input, record } +} + +class FakeRepository implements CanonicalGenerationRepository { + readonly events: string[] = [] + readonly checkpointBodies: string[] = [] + readonly saved: CanonicalGenerationRecord + + constructor(record: CanonicalGenerationRecord) { + this.saved = record + } + + async startGeneration() { + this.events.push("start-committed") + return { created: true as const, generation: this.saved } + } + async markPaidCallStarted() { + this.events.push("paid-call-marked") + return true + } + async getGeneration() { + return this.saved + } + async saveCheckpoint( + input: Parameters[0] + ) { + this.events.push("checkpoint-saved") + this.checkpointBodies.push(input.checkpoint.body) + return { kind: "saved" as const, version: input.expectedVersion + 1 } + } + async heartbeat() { + return true + } + async requestStop() { + this.events.push("stop-persisted") + return { ...this.saved, status: "stop_requested" as const } + } + async claimStale() { + return null + } + async finalizeGeneration(input: FinalizeCanonicalGenerationInput) { + this.events.push(`finalized:${input.outcome}`) + return { ...this.saved, status: input.outcome } + } +} + +test("开始事务提交后才调用执行器,调用方可与执行 Promise 解耦", async () => { + const { checkpoint, input, record } = fixture() + const repository = new FakeRepository(record) + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + const executor: CanonicalGenerationExecutor = { + async execute({ onCheckpoint }) { + repository.events.push("model-called") + await gate + await onCheckpoint({ + ...checkpoint, + body: "断线后仍完成", + contentState: "streaming", + }) + return { + outcome: "completed", + checkpoint: { + ...checkpoint, + body: "断线后仍完成", + contentState: "complete", + }, + usageCompleteness: "complete", + knownUsage: { + inputTokens: 3, + outputTokens: 4, + paidStepCount: 1, + reportedStepCount: 1, + }, + } + }, + } + const service = new CanonicalGenerationApplicationService( + repository, + executor, + new InMemoryGenerationAbortRegistry(), + { checkpointThrottleMs: 0, heartbeatMs: 60_000 } + ) + const started = await service.start(input) + assert.ok(started.execution) + await new Promise((resolve) => setTimeout(resolve, 0)) + assert.deepEqual(repository.events.slice(0, 3), [ + "start-committed", + "paid-call-marked", + "model-called", + ]) + release() + const completed = await started.execution + assert.equal(completed?.status, "completed") + assert.ok(repository.events.includes("checkpoint-saved")) +}) + +test("高频 checkpoint 被节流合并,终结前强制刷新最新结果", async () => { + const { checkpoint, input, record } = fixture() + const repository = new FakeRepository(record) + const service = new CanonicalGenerationApplicationService( + repository, + { + async execute({ onCheckpoint }) { + await onCheckpoint({ + ...checkpoint, + body: "A", + contentState: "streaming", + }) + await onCheckpoint({ + ...checkpoint, + body: "AB", + contentState: "streaming", + }) + return { + outcome: "completed", + checkpoint: { ...checkpoint, body: "ABC", contentState: "complete" }, + usageCompleteness: "unavailable", + knownUsage: null, + } + }, + }, + new InMemoryGenerationAbortRegistry(), + { checkpointThrottleMs: 60_000, heartbeatMs: 60_000 } + ) + const started = await service.start(input) + assert.ok(started.execution) + await started.execution + assert.deepEqual(repository.checkpointBodies, ["ABC"]) + assert.ok( + repository.events.indexOf("checkpoint-saved") < + repository.events.indexOf("finalized:completed") + ) +}) + +test("Stop 先持久化,再通知进程内 AbortController", async () => { + const { input, record } = fixture() + const repository = new FakeRepository(record) + const events = repository.events + const registry = new InMemoryGenerationAbortRegistry() + const controller = new AbortController() + controller.signal.addEventListener("abort", () => events.push("local-abort")) + registry.register(record.id, controller) + const service = new CanonicalGenerationApplicationService( + repository, + { execute: async () => assert.fail("本测试不应调用执行器") }, + registry + ) + await service.stop({ ownerId: input.ownerId, generationId: input.id }) + assert.deepEqual(events, ["stop-persisted", "local-abort"]) +}) diff --git a/lib/thread-chat/generation/canonical-generation-execution.ts b/lib/thread-chat/generation/canonical-generation-execution.ts new file mode 100644 index 00000000..d6ada31c --- /dev/null +++ b/lib/thread-chat/generation/canonical-generation-execution.ts @@ -0,0 +1,311 @@ +import { + CONVERSATION_GENERATION_DEFAULT_CHECKPOINT_THROTTLE_MS, + CONVERSATION_GENERATION_DEFAULT_HEARTBEAT_MS, + CONVERSATION_GENERATION_DEFAULT_LEASE_MS, +} from "../../../constants/conversation-generation" +import type { + CanonicalGenerationExecutor, + CanonicalGenerationRecord, + CanonicalGenerationRepository, + FinalizeCanonicalGenerationInput, + GenerationAbortRegistry, + StartCanonicalGenerationInput, +} from "../application/conversation-generation-service" +import { CanonicalGenerationServiceError } from "../application/conversation-generation-service" +import type { ConversationGenerationCheckpoint } from "../domain/conversation-generation" +import { inferUsageCompleteness } from "../domain/conversation-generation" +import type { GenerationId } from "../domain/conversation-model" + +export interface CanonicalGenerationExecutionOptions { + readonly checkpointThrottleMs?: number + readonly heartbeatMs?: number + readonly leaseMs?: number + readonly now?: () => number +} + +export interface StartedCanonicalGeneration { + readonly generation: CanonicalGenerationRecord + /** 幂等重放没有新的执行;新建任务的 Promise 与任何 HTTP 响应生命周期无关。 */ + readonly execution: Promise | null +} + +export class InMemoryGenerationAbortRegistry implements GenerationAbortRegistry { + private readonly controllers = new Map() + + register(generationId: GenerationId, controller: AbortController): void { + this.controllers.set(generationId, controller) + } + + abort(generationId: GenerationId): boolean { + const controller = this.controllers.get(generationId) + if (!controller) return false + controller.abort() + return true + } + + unregister(generationId: GenerationId, controller: AbortController): void { + if (this.controllers.get(generationId) === controller) + this.controllers.delete(generationId) + } +} + +class VersionedCheckpointWriter { + private version: number + private latest: ConversationGenerationCheckpoint + private lastSavedAt: number + private dirty = false + private timer: ReturnType | null = null + private pending: Promise = Promise.resolve() + + constructor( + private readonly repository: CanonicalGenerationRepository, + private readonly generationId: GenerationId, + private readonly leaseOwner: string, + initialVersion: number, + initialCheckpoint: ConversationGenerationCheckpoint, + private readonly throttleMs: number, + private readonly now: () => number + ) { + this.version = initialVersion + this.latest = initialCheckpoint + this.lastSavedAt = this.now() + } + + get checkpoint(): ConversationGenerationCheckpoint { + return this.latest + } + + get checkpointVersion(): number { + return this.version + } + + accept(checkpoint: ConversationGenerationCheckpoint): Promise { + this.latest = checkpoint + this.dirty = true + if (this.now() - this.lastSavedAt >= this.throttleMs) return this.flush() + if (!this.timer) + this.timer = setTimeout( + () => { + this.timer = null + void this.flush().catch(() => { + // close() 会等待同一 pending 链并把错误交给执行生命周期处理。 + }) + }, + Math.max(0, this.throttleMs - (this.now() - this.lastSavedAt)) + ) + return Promise.resolve() + } + + async flush(): Promise { + if (this.timer) { + clearTimeout(this.timer) + this.timer = null + } + if (!this.dirty) return this.pending + const checkpoint = this.latest + this.dirty = false + this.pending = this.pending.then(async () => { + const result = await this.repository.saveCheckpoint({ + generationId: this.generationId, + leaseOwner: this.leaseOwner, + expectedVersion: this.version, + checkpoint, + }) + if (result.kind === "saved") { + this.version = result.version + this.lastSavedAt = this.now() + return + } + if (result.kind === "terminal") return + throw new CanonicalGenerationServiceError( + "checkpoint_conflict", + `checkpoint 版本冲突:期望 ${this.version},实际 ${result.version}` + ) + }) + return this.pending + } + + async close(): Promise { + await this.flush() + } +} + +function isAbortError(error: unknown): boolean { + return ( + (error instanceof DOMException && error.name === "AbortError") || + (error instanceof Error && error.name === "AbortError") + ) +} + +/** + * 规范 Generation 的隔离组合根。它不接收 request.signal,因此浏览器断线不会取消模型; + * 公开命令路由只负责调用 start/stop/query,不拥有执行生命周期。 + */ +export class CanonicalGenerationApplicationService { + private readonly checkpointThrottleMs: number + private readonly heartbeatMs: number + private readonly leaseMs: number + private readonly now: () => number + + constructor( + private readonly repository: CanonicalGenerationRepository, + private readonly executor: CanonicalGenerationExecutor, + private readonly abortRegistry: GenerationAbortRegistry, + options: CanonicalGenerationExecutionOptions = {} + ) { + this.checkpointThrottleMs = + options.checkpointThrottleMs ?? + CONVERSATION_GENERATION_DEFAULT_CHECKPOINT_THROTTLE_MS + this.heartbeatMs = + options.heartbeatMs ?? CONVERSATION_GENERATION_DEFAULT_HEARTBEAT_MS + this.leaseMs = options.leaseMs ?? CONVERSATION_GENERATION_DEFAULT_LEASE_MS + this.now = options.now ?? Date.now + } + + async start( + input: StartCanonicalGenerationInput + ): Promise { + const started = await this.repository.startGeneration(input) + if (!started.created) + return { generation: started.generation, execution: null } + + // startGeneration 的事务已经提交,之后才允许声明并进入付费调用。 + const execution = this.run(started.generation, input.leaseOwner) + return { generation: started.generation, execution } + } + + async query(input: { + readonly ownerId: string + readonly generationId: GenerationId + }): Promise { + return this.repository.getGeneration(input) + } + + async stop(input: { + readonly ownerId: string + readonly generationId: GenerationId + }): Promise { + const persisted = await this.repository.requestStop(input) + if (persisted?.status === "stop_requested") + this.abortRegistry.abort(input.generationId) + return persisted + } + + async convergeStale(input: { + readonly generationId: GenerationId + readonly leaseOwner: string + }): Promise { + const claimed = await this.repository.claimStale({ + generationId: input.generationId, + leaseOwner: input.leaseOwner, + staleBefore: new Date(this.now() - this.leaseMs), + }) + if (!claimed) return null + return this.repository.finalizeGeneration({ + generationId: claimed.id, + leaseOwner: input.leaseOwner, + expectedCheckpointVersion: claimed.checkpointVersion, + outcome: claimed.status === "stop_requested" ? "stopped" : "failed", + checkpoint: claimed.checkpoint, + usageCompleteness: claimed.usageCompleteness, + knownUsage: claimed.knownUsage, + errorCode: + claimed.status === "stop_requested" + ? "stop_converged" + : "lease_expired", + }) + } + + private async run( + generation: CanonicalGenerationRecord, + leaseOwner: string + ): Promise { + const controller = new AbortController() + const writer = new VersionedCheckpointWriter( + this.repository, + generation.id, + leaseOwner, + generation.checkpointVersion, + generation.checkpoint, + this.checkpointThrottleMs, + this.now + ) + this.abortRegistry.register(generation.id, controller) + let heartbeatRunning = false + const heartbeat = setInterval(() => { + if (heartbeatRunning) return + heartbeatRunning = true + void Promise.all([ + this.repository.heartbeat({ generationId: generation.id, leaseOwner }), + this.repository.getGeneration({ + ownerId: generation.ownerId, + generationId: generation.id, + }), + ]) + .then(([, current]) => { + if (current?.status === "stop_requested") controller.abort() + }) + .finally(() => { + heartbeatRunning = false + }) + }, this.heartbeatMs) + + let finalize: Omit< + FinalizeCanonicalGenerationInput, + "generationId" | "leaseOwner" | "expectedCheckpointVersion" + > + try { + const paidCallAccepted = await this.repository.markPaidCallStarted({ + generationId: generation.id, + leaseOwner, + }) + if (!paidCallAccepted) + throw new CanonicalGenerationServiceError( + "version_conflict", + "Generation 在模型执行前已失去租约或不再运行" + ) + const result = await this.executor.execute({ + generation: { ...generation, paidCallStarted: true }, + signal: controller.signal, + onCheckpoint: (checkpoint) => writer.accept(checkpoint), + }) + const checkpoint = { + ...result.checkpoint, + knownUsage: result.knownUsage, + } + await writer.accept(checkpoint) + finalize = { ...result, checkpoint } + } catch (error) { + const current = await this.repository.getGeneration({ + ownerId: generation.ownerId, + generationId: generation.id, + }) + finalize = { + outcome: + current?.status === "stop_requested" || isAbortError(error) + ? "stopped" + : "failed", + checkpoint: writer.checkpoint, + usageCompleteness: inferUsageCompleteness(writer.checkpoint.knownUsage), + knownUsage: writer.checkpoint.knownUsage, + errorCode: + current?.status === "stop_requested" || isAbortError(error) + ? "stopped" + : error instanceof Error + ? error.name + : "unknown_error", + } + } finally { + clearInterval(heartbeat) + this.abortRegistry.unregister(generation.id, controller) + } + + await writer.close() + return this.repository.finalizeGeneration({ + generationId: generation.id, + leaseOwner, + expectedCheckpointVersion: writer.checkpointVersion, + ...finalize, + }) + } +} diff --git a/lib/thread-chat/persistence/canonical-generation-policy.ts b/lib/thread-chat/persistence/canonical-generation-policy.ts new file mode 100644 index 00000000..c8e3f2b7 --- /dev/null +++ b/lib/thread-chat/persistence/canonical-generation-policy.ts @@ -0,0 +1,51 @@ +import { CanonicalGenerationServiceError } from "../application/conversation-generation-service" + +export type CanonicalGenerationAuthority = + "disabled" | "isolated-test" | "canonical" + +export interface CanonicalGenerationPolicy { + readonly authority: CanonicalGenerationAuthority + readonly legacyAuthorityEnabled: boolean +} + +export function resolveCanonicalGenerationPolicy( + environment: NodeJS.ProcessEnv = process.env +): CanonicalGenerationPolicy { + const value = environment.CONVERSATION_GENERATION_AUTHORITY?.trim() + const authority: CanonicalGenerationAuthority = + !value || value === "disabled" + ? "disabled" + : value === "isolated-test" || value === "canonical" + ? value + : (() => { + throw new Error(`未知 CONVERSATION_GENERATION_AUTHORITY:${value}`) + })() + return { + authority, + legacyAuthorityEnabled: + environment.BRANCH_GENERATION_AUTHORITY_ENABLED !== "false", + } +} + +export function assertCanonicalGenerationEnabled( + policy: CanonicalGenerationPolicy +): void { + if (policy.authority === "disabled") + throw new CanonicalGenerationServiceError( + "forbidden", + "规范 Generation 生命周期默认关闭" + ) + if ( + policy.authority === "isolated-test" && + process.env.NODE_ENV === "production" + ) + throw new CanonicalGenerationServiceError( + "forbidden", + "生产环境不能启用 isolated-test Generation authority" + ) + if (policy.authority === "canonical" && policy.legacyAuthorityEnabled) + throw new CanonicalGenerationServiceError( + "forbidden", + "同一次执行不能同时启用规范与 branch_generations 权威" + ) +} diff --git a/lib/thread-chat/persistence/drizzle-conversation-generation-repository.ts b/lib/thread-chat/persistence/drizzle-conversation-generation-repository.ts new file mode 100644 index 00000000..a3d3fe56 --- /dev/null +++ b/lib/thread-chat/persistence/drizzle-conversation-generation-repository.ts @@ -0,0 +1,747 @@ +import { isDeepStrictEqual } from "node:util" +import { and, eq, inArray, lt, max, sql } from "drizzle-orm" + +import { chargeUsageOnce } from "../../billing/credits" +import { db } from "../../db" +import { + conversationGenerations, + conversationMessages, + conversationThreads, + conversationTurns, + conversations, + projects, + workspaceMembers, +} from "../../db/schema" +import type { + CanonicalGenerationRecord, + CanonicalGenerationRepository, + FinalizeCanonicalGenerationInput, + StartCanonicalGenerationInput, + StartCanonicalGenerationResult, +} from "../application/conversation-generation-service" +import { + CanonicalGenerationServiceError, + expectedBillingStatus, +} from "../application/conversation-generation-service" +import { + checkpointMessageContent, + hasRecoverableCheckpointOutput, + inferUsageCompleteness, + parseConversationGenerationCheckpoint, + terminalMessageContentState, +} from "../domain/conversation-generation" +import { + conversationId, + generationId, + messageId, + projectId, + threadId, + turnId, + workspaceId, +} from "../domain/conversation-model" +import { + assertCanonicalGenerationEnabled, + resolveCanonicalGenerationPolicy, + type CanonicalGenerationPolicy, +} from "./canonical-generation-policy" + +const ACTIVE_STATUSES = ["running", "stop_requested"] as const + +type GenerationTransaction = Parameters[0]>[0] + +function mapGeneration( + row: typeof conversationGenerations.$inferSelect +): CanonicalGenerationRecord { + return { + id: generationId(row.id), + ownerId: row.ownerId, + workspaceId: workspaceId(row.workspaceId), + projectId: projectId(row.projectId), + conversationId: conversationId(row.conversationId), + threadId: threadId(row.threadId), + turnId: turnId(row.turnId), + inputMessageId: messageId(row.inputMessageId), + outputMessageId: messageId(row.outputMessageId), + intent: row.intent, + requestHash: row.requestHash, + idempotencyKey: row.idempotencyKey, + modelId: row.modelId, + attempt: row.attempt, + isCurrent: row.isCurrent, + status: row.status, + contentState: row.contentState, + billingStatus: row.billingStatus, + checkpointVersion: row.checkpointVersion, + checkpoint: parseConversationGenerationCheckpoint(row.checkpoint), + knownUsage: row.knownUsage, + usageCompleteness: row.usageCompleteness, + paidCallStarted: row.paidCallStarted, + leaseOwner: row.leaseOwner, + leaseVersion: row.leaseVersion, + heartbeatAt: row.heartbeatAt.toISOString(), + stopRequestedAt: row.stopRequestedAt?.toISOString() ?? null, + startedAt: row.startedAt.toISOString(), + finishedAt: row.finishedAt?.toISOString() ?? null, + errorCode: row.errorCode, + createdAt: row.createdAt.toISOString(), + } +} + +function replayMatches( + row: typeof conversationGenerations.$inferSelect, + input: StartCanonicalGenerationInput +): boolean { + return ( + row.id === input.id && + row.ownerId === input.ownerId && + row.workspaceId === input.workspaceId && + row.projectId === input.projectId && + row.conversationId === input.conversationId && + row.threadId === input.threadId && + row.turnId === input.turnId && + row.inputMessageId === input.inputMessageId && + row.outputMessageId === input.outputMessage.id && + row.requestHash === input.requestHash && + row.modelId === input.modelId && + JSON.stringify(row.intent) === JSON.stringify(input.intent) + ) +} + +function checkpointExtends(currentValue: unknown, nextValue: unknown): boolean { + const current = parseConversationGenerationCheckpoint(currentValue) + const next = parseConversationGenerationCheckpoint(nextValue) + if (!next.body.startsWith(current.body)) return false + if ( + current.artifactIds.some( + (artifactId) => !next.artifactIds.includes(artifactId) + ) + ) + return false + const currentUsage = current.knownUsage + const nextUsage = next.knownUsage + if ( + currentUsage && + (!nextUsage || + nextUsage.inputTokens < currentUsage.inputTokens || + nextUsage.outputTokens < currentUsage.outputTokens || + nextUsage.reportedStepCount < currentUsage.reportedStepCount) + ) + return false + if (current.researchPlan !== null && next.researchPlan === null) return false + const nextActivities = new Map( + next.researchActivities.map((activity) => [activity.id, activity]) + ) + for (const activity of current.researchActivities) { + const candidate = nextActivities.get(activity.id) + if (!candidate || candidate.kind !== activity.kind) return false + if (activity.status !== "running" && candidate.status !== activity.status) + return false + if ( + activity.sources.some( + (source) => + !candidate.sources.some( + (candidateSource) => candidateSource.url === source.url + ) + ) + ) + return false + if (activity.error && !candidate.error) return false + } + return true +} + +async function lockGeneration( + transaction: GenerationTransaction, + targetGenerationId: string +): Promise { + const locked = await transaction.execute(sql` + select ${conversationGenerations.id} + from ${conversationGenerations} + where ${conversationGenerations.id} = ${targetGenerationId} + for update + `) + if (locked.length === 0) return null + const [row] = await transaction + .select() + .from(conversationGenerations) + .where(eq(conversationGenerations.id, targetGenerationId)) + .limit(1) + return row ?? null +} + +export class DrizzleConversationGenerationRepository implements CanonicalGenerationRepository { + readonly policy: CanonicalGenerationPolicy + + constructor( + policy: CanonicalGenerationPolicy = resolveCanonicalGenerationPolicy() + ) { + this.policy = policy + } + + async startGeneration( + input: StartCanonicalGenerationInput + ): Promise { + assertCanonicalGenerationEnabled(this.policy) + if ( + input.outputMessage.threadId !== input.threadId || + input.outputMessage.turnId !== input.turnId || + input.outputMessage.contentState !== "pending" + ) + throw new CanonicalGenerationServiceError( + "invalid_identity", + "输出 Message 必须是同一 Thread/Turn 的 pending 占位" + ) + + return db.transaction(async (transaction) => { + await transaction.execute(sql` + select pg_advisory_xact_lock( + hashtext(${input.ownerId}), + hashtext(${input.idempotencyKey}) + ) + `) + const [replayed] = await transaction + .select() + .from(conversationGenerations) + .where( + and( + eq(conversationGenerations.ownerId, input.ownerId), + eq(conversationGenerations.idempotencyKey, input.idempotencyKey) + ) + ) + .limit(1) + if (replayed) { + if (!replayMatches(replayed, input)) + throw new CanonicalGenerationServiceError( + "idempotency_conflict", + "幂等键已绑定不同 Generation 载荷" + ) + return { created: false, generation: mapGeneration(replayed) } + } + + const [identity] = await transaction + .select({ + turnRevision: conversationTurns.revision, + activeAssistantMessageId: conversationTurns.activeAssistantMessageId, + inputRole: conversationMessages.role, + inputVariantOfMessageId: conversationMessages.variantOfMessageId, + }) + .from(conversationTurns) + .innerJoin( + conversationThreads, + eq(conversationThreads.id, conversationTurns.threadId) + ) + .innerJoin( + conversations, + eq(conversations.id, conversationThreads.conversationId) + ) + .innerJoin(projects, eq(projects.id, conversations.projectId)) + .innerJoin( + workspaceMembers, + and( + eq(workspaceMembers.workspaceId, projects.workspaceId), + eq(workspaceMembers.userId, input.ownerId) + ) + ) + .innerJoin( + conversationMessages, + and( + eq(conversationMessages.id, input.inputMessageId), + eq(conversationMessages.turnId, conversationTurns.id), + eq(conversationMessages.threadId, conversationTurns.threadId) + ) + ) + .where( + and( + eq(conversationTurns.id, input.turnId), + eq(conversationTurns.threadId, input.threadId), + eq(conversationThreads.conversationId, input.conversationId), + eq(conversations.projectId, input.projectId), + eq(projects.workspaceId, input.workspaceId) + ) + ) + .limit(1) + if (!identity || identity.inputRole !== "user") + throw new CanonicalGenerationServiceError( + "invalid_identity", + "Generation 规范实体归属或输入 Message 角色无效" + ) + if (identity.turnRevision !== input.expectedTurnRevision) + throw new CanonicalGenerationServiceError( + "version_conflict", + "Turn revision 已变化" + ) + + const [existingOutput] = await transaction + .select() + .from(conversationMessages) + .where(eq(conversationMessages.id, input.outputMessage.id)) + .limit(1) + if ( + existingOutput && + (existingOutput.threadId !== input.threadId || + existingOutput.turnId !== input.turnId || + existingOutput.role !== "assistant" || + existingOutput.contentState !== "pending" || + !isDeepStrictEqual( + existingOutput.content, + input.outputMessage.content + ) || + existingOutput.variantOfMessageId !== + (input.outputMessage.variantOfMessageId ?? null)) + ) + throw new CanonicalGenerationServiceError( + "invalid_identity", + "既有输出 Message 不是可确认的同 Turn pending 占位" + ) + + if (input.intent.kind === "regenerate-assistant") { + const [source] = await transaction + .select({ role: conversationMessages.role }) + .from(conversationMessages) + .where( + and( + eq( + conversationMessages.id, + input.intent.sourceAssistantMessageId + ), + eq(conversationMessages.threadId, input.threadId), + eq(conversationMessages.turnId, input.turnId) + ) + ) + .limit(1) + if ( + source?.role !== "assistant" || + input.outputMessage.variantOfMessageId !== + input.intent.sourceAssistantMessageId + ) + throw new CanonicalGenerationServiceError( + "invalid_identity", + "重新生成来源与输出 Message 变体关系不一致" + ) + } + if (input.intent.kind === "edit-user") { + const [source] = await transaction + .select({ role: conversationMessages.role }) + .from(conversationMessages) + .where( + and( + eq(conversationMessages.id, input.intent.sourceUserMessageId), + eq(conversationMessages.threadId, input.threadId), + eq(conversationMessages.turnId, input.turnId) + ) + ) + .limit(1) + if ( + source?.role !== "user" || + input.inputMessageId === input.intent.sourceUserMessageId || + identity.inputVariantOfMessageId !== input.intent.sourceUserMessageId + ) + throw new CanonicalGenerationServiceError( + "invalid_identity", + "编辑用户消息来源与输入 Message 变体关系不一致" + ) + } + + const now = new Date() + const currentRows = await transaction + .select() + .from(conversationGenerations) + .where( + and( + eq(conversationGenerations.turnId, input.turnId), + eq(conversationGenerations.isCurrent, true) + ) + ) + for (const current of currentRows) { + if (ACTIVE_STATUSES.includes(current.status as never)) { + const checkpoint = parseConversationGenerationCheckpoint( + current.checkpoint + ) + const contentState = hasRecoverableCheckpointOutput(checkpoint) + ? "incomplete" + : "failed" + await transaction + .update(conversationMessages) + .set({ contentState }) + .where(eq(conversationMessages.id, current.outputMessageId)) + await transaction + .update(conversationGenerations) + .set({ + isCurrent: false, + status: "superseded", + contentState, + usageCompleteness: "unavailable", + billingStatus: current.paidCallStarted + ? "usage_unavailable" + : "not_billable", + leaseOwner: null, + finishedAt: now, + updatedAt: now, + }) + .where(eq(conversationGenerations.id, current.id)) + continue + } + await transaction + .update(conversationGenerations) + .set({ isCurrent: false, updatedAt: now }) + .where(eq(conversationGenerations.id, current.id)) + } + + if (!existingOutput) + await transaction.insert(conversationMessages).values({ + id: input.outputMessage.id, + threadId: input.threadId, + turnId: input.turnId, + role: "assistant", + content: input.outputMessage.content, + contentState: "pending", + variantOfMessageId: + input.outputMessage.variantOfMessageId ?? + identity.activeAssistantMessageId, + createdAt: new Date(input.outputMessage.createdAt), + }) + const [attemptRow] = await transaction + .select({ value: max(conversationGenerations.attempt) }) + .from(conversationGenerations) + .where(eq(conversationGenerations.turnId, input.turnId)) + const attempt = (attemptRow?.value ?? 0) + 1 + const checkpoint = parseConversationGenerationCheckpoint({ + schemaVersion: 1, + body: "", + artifactIds: [], + researchPlan: null, + researchActivities: [], + contentState: "pending", + knownUsage: null, + }) + const [created] = await transaction + .insert(conversationGenerations) + .values({ + id: input.id, + ownerId: input.ownerId, + workspaceId: input.workspaceId, + projectId: input.projectId, + conversationId: input.conversationId, + threadId: input.threadId, + turnId: input.turnId, + inputMessageId: input.inputMessageId, + outputMessageId: input.outputMessage.id, + intent: input.intent, + requestHash: input.requestHash, + idempotencyKey: input.idempotencyKey, + modelId: input.modelId, + attempt, + status: "running", + contentState: "pending", + checkpoint, + usageCompleteness: "unavailable", + billingStatus: "pending", + leaseOwner: input.leaseOwner, + heartbeatAt: now, + startedAt: now, + }) + .returning() + const [updatedTurn] = await transaction + .update(conversationTurns) + .set({ + revision: sql`${conversationTurns.revision} + 1`, + updatedAt: now, + }) + .where( + and( + eq(conversationTurns.id, input.turnId), + eq(conversationTurns.revision, input.expectedTurnRevision) + ) + ) + .returning({ id: conversationTurns.id }) + if (!created || !updatedTurn) + throw new CanonicalGenerationServiceError( + "version_conflict", + "Generation 开始事务未能提交" + ) + return { created: true, generation: mapGeneration(created) } + }) + } + + async markPaidCallStarted(input: { + readonly generationId: ReturnType + readonly leaseOwner: string + }): Promise { + assertCanonicalGenerationEnabled(this.policy) + const [updated] = await db + .update(conversationGenerations) + .set({ paidCallStarted: true, updatedAt: new Date() }) + .where( + and( + eq(conversationGenerations.id, input.generationId), + eq(conversationGenerations.leaseOwner, input.leaseOwner), + eq(conversationGenerations.status, "running") + ) + ) + .returning({ id: conversationGenerations.id }) + return Boolean(updated) + } + + async getGeneration(input: { + readonly ownerId: string + readonly generationId: ReturnType + }): Promise { + const [row] = await db + .select() + .from(conversationGenerations) + .where( + and( + eq(conversationGenerations.id, input.generationId), + eq(conversationGenerations.ownerId, input.ownerId) + ) + ) + .limit(1) + return row ? mapGeneration(row) : null + } + + async saveCheckpoint(input: { + readonly generationId: ReturnType + readonly leaseOwner: string + readonly expectedVersion: number + readonly checkpoint: Parameters< + CanonicalGenerationRepository["saveCheckpoint"] + >[0]["checkpoint"] + }) { + assertCanonicalGenerationEnabled(this.policy) + return db.transaction(async (transaction) => { + const row = await lockGeneration(transaction, input.generationId) + if (!row) + throw new CanonicalGenerationServiceError( + "not_found", + "Generation 不存在" + ) + if (!ACTIVE_STATUSES.includes(row.status as never)) + return { kind: "terminal" as const, version: row.checkpointVersion } + if ( + row.leaseOwner !== input.leaseOwner || + row.checkpointVersion !== input.expectedVersion + ) + return { kind: "conflict" as const, version: row.checkpointVersion } + const checkpoint = parseConversationGenerationCheckpoint(input.checkpoint) + if (!checkpointExtends(row.checkpoint, checkpoint)) + throw new CanonicalGenerationServiceError( + "checkpoint_conflict", + "新 checkpoint 不能删除已持久化部分结果" + ) + const nextVersion = row.checkpointVersion + 1 + await transaction + .update(conversationMessages) + .set({ + content: checkpointMessageContent(checkpoint), + contentState: checkpoint.contentState, + }) + .where(eq(conversationMessages.id, row.outputMessageId)) + await transaction + .update(conversationGenerations) + .set({ + checkpoint, + checkpointVersion: nextVersion, + contentState: checkpoint.contentState, + knownUsage: checkpoint.knownUsage, + usageCompleteness: inferUsageCompleteness(checkpoint.knownUsage), + updatedAt: new Date(), + }) + .where(eq(conversationGenerations.id, row.id)) + return { kind: "saved" as const, version: nextVersion } + }) + } + + async heartbeat(input: { + readonly generationId: ReturnType + readonly leaseOwner: string + }): Promise { + assertCanonicalGenerationEnabled(this.policy) + const [updated] = await db + .update(conversationGenerations) + .set({ + heartbeatAt: new Date(), + leaseVersion: sql`${conversationGenerations.leaseVersion} + 1`, + updatedAt: new Date(), + }) + .where( + and( + eq(conversationGenerations.id, input.generationId), + eq(conversationGenerations.leaseOwner, input.leaseOwner), + inArray(conversationGenerations.status, ACTIVE_STATUSES) + ) + ) + .returning({ id: conversationGenerations.id }) + return Boolean(updated) + } + + async requestStop(input: { + readonly ownerId: string + readonly generationId: ReturnType + }): Promise { + assertCanonicalGenerationEnabled(this.policy) + return db.transaction(async (transaction) => { + const row = await lockGeneration(transaction, input.generationId) + if (!row || row.ownerId !== input.ownerId) return null + if (row.status !== "running") return mapGeneration(row) + const [updated] = await transaction + .update(conversationGenerations) + .set({ + status: "stop_requested", + stopRequestedAt: new Date(), + updatedAt: new Date(), + }) + .where( + and( + eq(conversationGenerations.id, row.id), + eq(conversationGenerations.status, "running") + ) + ) + .returning() + return mapGeneration(updated ?? row) + }) + } + + async claimStale(input: { + readonly generationId: ReturnType + readonly staleBefore: Date + readonly leaseOwner: string + }): Promise { + assertCanonicalGenerationEnabled(this.policy) + const [updated] = await db + .update(conversationGenerations) + .set({ + leaseOwner: input.leaseOwner, + leaseVersion: sql`${conversationGenerations.leaseVersion} + 1`, + heartbeatAt: new Date(), + updatedAt: new Date(), + }) + .where( + and( + eq(conversationGenerations.id, input.generationId), + inArray(conversationGenerations.status, ACTIVE_STATUSES), + lt(conversationGenerations.heartbeatAt, input.staleBefore) + ) + ) + .returning() + return updated ? mapGeneration(updated) : null + } + + async finalizeGeneration( + input: FinalizeCanonicalGenerationInput + ): Promise { + assertCanonicalGenerationEnabled(this.policy) + return db.transaction(async (transaction) => { + const row = await lockGeneration(transaction, input.generationId) + if (!row) return null + const checkpoint = parseConversationGenerationCheckpoint(input.checkpoint) + + if (!ACTIVE_STATUSES.includes(row.status as never)) { + if ( + row.paidCallStarted && + row.billingStatus !== "settled" && + input.usageCompleteness === "complete" && + input.knownUsage + ) { + await chargeUsageOnce(transaction, row.id, { + userId: row.ownerId, + model: row.modelId, + inputTokens: input.knownUsage.inputTokens, + outputTokens: input.knownUsage.outputTokens, + threadId: row.threadId, + messageId: row.outputMessageId, + }) + const [settled] = await transaction + .update(conversationGenerations) + .set({ + knownUsage: input.knownUsage, + usageCompleteness: "complete", + billingStatus: "settled", + updatedAt: new Date(), + }) + .where(eq(conversationGenerations.id, row.id)) + .returning() + return mapGeneration(settled ?? row) + } + return mapGeneration(row) + } + if ( + row.leaseOwner !== input.leaseOwner || + row.checkpointVersion !== input.expectedCheckpointVersion + ) + return mapGeneration(row) + if (!checkpointExtends(row.checkpoint, checkpoint)) + throw new CanonicalGenerationServiceError( + "checkpoint_conflict", + "终结 checkpoint 不能覆盖已保存的 partial" + ) + + const outcome = + !row.isCurrent || row.status === "superseded" + ? "superseded" + : row.status === "stop_requested" + ? "stopped" + : input.outcome + const contentState = terminalMessageContentState({ outcome, checkpoint }) + const billingStatus = expectedBillingStatus({ + status: outcome, + paidCallStarted: row.paidCallStarted, + usageCompleteness: input.usageCompleteness, + knownUsage: input.knownUsage, + }) + if (billingStatus === "settled" && input.knownUsage) + await chargeUsageOnce(transaction, row.id, { + userId: row.ownerId, + model: row.modelId, + inputTokens: input.knownUsage.inputTokens, + outputTokens: input.knownUsage.outputTokens, + threadId: row.threadId, + messageId: row.outputMessageId, + }) + + const now = new Date() + await transaction + .update(conversationMessages) + .set({ + content: checkpointMessageContent(checkpoint), + contentState, + }) + .where(eq(conversationMessages.id, row.outputMessageId)) + if (row.isCurrent && outcome !== "superseded") + await transaction + .update(conversationTurns) + .set({ + activeAssistantMessageId: row.outputMessageId, + revision: sql`${conversationTurns.revision} + 1`, + updatedAt: now, + }) + .where(eq(conversationTurns.id, row.turnId)) + const [updated] = await transaction + .update(conversationGenerations) + .set({ + status: outcome, + contentState, + checkpoint, + checkpointVersion: row.checkpointVersion + 1, + knownUsage: input.knownUsage, + usageCompleteness: input.usageCompleteness, + billingStatus, + leaseOwner: null, + finishedAt: now, + errorCode: input.errorCode ?? null, + updatedAt: now, + }) + .where( + and( + eq(conversationGenerations.id, row.id), + inArray(conversationGenerations.status, ACTIVE_STATUSES), + eq( + conversationGenerations.checkpointVersion, + input.expectedCheckpointVersion + ) + ) + ) + .returning() + return updated ? mapGeneration(updated) : mapGeneration(row) + }) + } +} diff --git a/openspec/changes/migrate-generation-lifecycle/tasks.md b/openspec/changes/migrate-generation-lifecycle/tasks.md index 570d5b4b..928b2072 100644 --- a/openspec/changes/migrate-generation-lifecycle/tasks.md +++ b/openspec/changes/migrate-generation-lifecycle/tasks.md @@ -1,39 +1,39 @@ ## 1. Generation 数据结构 -- [ ] 1.1 定义 `conversation_generations` 的身份、规范实体复合外键、意图、尝试、幂等键、状态、租约、checkpoint、usage 与时间字段。 -- [ ] 1.2 增加合法状态组合、唯一幂等键、当前尝试和 `usage_records.app_generation_id` 的约束与索引。 -- [ ] 1.3 生成数据库迁移,并用集成用例验证跨 Conversation/Thread/Turn/Message 引用和非法状态组合会被拒绝。 +- [x] 1.1 定义 `conversation_generations` 的身份、规范实体复合外键、意图、尝试、幂等键、状态、租约、checkpoint、usage 与时间字段。 +- [x] 1.2 增加合法状态组合、唯一幂等键、当前尝试和 `usage_records.app_generation_id` 的约束与索引。 +- [x] 1.3 生成数据库迁移,并用集成用例验证跨 Conversation/Thread/Turn/Message 引用和非法状态组合会被拒绝。 ## 2. 开始与执行所有权 -- [ ] 2.1 建立纯应用层 Generation 仓储和执行器端口,不依赖 HTTP、React 或遗留整树类型。 -- [ ] 2.2 实现 `startGeneration` 事务,覆盖预期版本、输出 Message 追加、幂等重放、载荷冲突和提交前禁止模型调用。 -- [ ] 2.3 实现与 HTTP 连接解耦的服务端流消费、AbortController 注册、持久化 Stop 观察、租约和心跳。 -- [ ] 2.4 增加浏览器断线后继续执行以及重新查询可见进行中 Generation 的集成验证。 +- [x] 2.1 建立纯应用层 Generation 仓储和执行器端口,不依赖 HTTP、React 或遗留整树类型。 +- [x] 2.2 实现 `startGeneration` 事务,覆盖预期版本、输出 Message 追加、幂等重放、载荷冲突和提交前禁止模型调用。 +- [x] 2.3 实现与 HTTP 连接解耦的服务端流消费、AbortController 注册、持久化 Stop 观察、租约和心跳。 +- [x] 2.4 增加浏览器断线后继续执行以及重新查询可见进行中 Generation 的集成验证。 ## 3. Checkpoint 与结构化部分结果 -- [ ] 3.1 定义带 `schemaVersion` 的 checkpoint 解析器,覆盖正文、Artifact、研究计划、联网活动、来源、内容状态和已知 usage。 -- [ ] 3.2 实现按版本比较交换的节流 checkpoint 写入与终结前强制刷新,拒绝旧版本覆盖。 -- [ ] 3.3 修正联网活动投影,使输入/执行阶段保持 `running`,仅输出或错误后进入相应终态。 -- [ ] 3.4 覆盖正文、Artifact、研究活动混合 partial 的保存、刷新恢复和旧 checkpoint 竞态测试。 +- [x] 3.1 定义带 `schemaVersion` 的 checkpoint 解析器,覆盖正文、Artifact、研究计划、联网活动、来源、内容状态和已知 usage。 +- [x] 3.2 实现按版本比较交换的节流 checkpoint 写入与终结前强制刷新,拒绝旧版本覆盖。 +- [x] 3.3 修正联网活动投影,使输入/执行阶段保持 `running`,仅输出或错误后进入相应终态。 +- [x] 3.4 覆盖正文、Artifact、研究活动混合 partial 的保存、刷新恢复和旧 checkpoint 竞态测试。 ## 4. Stop、失败与僵尸任务收敛 -- [ ] 4.1 实现幂等 Stop 应用操作:先持久化 `stop_requested`,再通知本地执行器,终态请求只返回既有结果。 -- [ ] 4.2 实现统一 `finalizeGeneration` 事务,原子提交 Generation、Message 内容状态、Turn 当前变体和计费结果。 -- [ ] 4.3 实现基于租约/心跳和条件更新的 stale claim,始终从最新服务端 checkpoint 收敛,不再用开始快照覆盖 partial。 -- [ ] 4.4 覆盖有输出 Stop、无输出失败、进程崩溃、健康执行器抢先完成、旧尝试晚到和重复终结测试。 +- [x] 4.1 实现幂等 Stop 应用操作:先持久化 `stop_requested`,再通知本地执行器,终态请求只返回既有结果。 +- [x] 4.2 实现统一 `finalizeGeneration` 事务,原子提交 Generation、Message 内容状态、Turn 当前变体和计费结果。 +- [x] 4.3 实现基于租约/心跳和条件更新的 stale claim,始终从最新服务端 checkpoint 收敛,不再用开始快照覆盖 partial。 +- [x] 4.4 覆盖有输出 Stop、无输出失败、进程崩溃、健康执行器抢先完成、旧尝试晚到和重复终结测试。 ## 5. Usage 与计费真实性 -- [ ] 5.1 实现 `complete`、`partial`、`unavailable` usage 完整度的聚合和持久化。 -- [ ] 5.2 修改结算规则:仅完整 usage 可 `settled`;partial/unavailable 必须 `usage_unavailable`,无付费调用为 `not_billable`。 -- [ ] 5.3 覆盖“已完成 step 有 usage、当前中止 step 无 usage”的回归用例,并验证保留已知值但不伪装完整结算。 -- [ ] 5.4 覆盖并发响应消费、Stop 和 stale 收敛只产生一次 usage 记录及一次扣费的事务测试。 +- [x] 5.1 实现 `complete`、`partial`、`unavailable` usage 完整度的聚合和持久化。 +- [x] 5.2 修改结算规则:仅完整 usage 可 `settled`;partial/unavailable 必须 `usage_unavailable`,无付费调用为 `not_billable`。 +- [x] 5.3 覆盖“已完成 step 有 usage、当前中止 step 无 usage”的回归用例,并验证保留已知值但不伪装完整结算。 +- [x] 5.4 覆盖并发响应消费、Stop 和 stale 收敛只产生一次 usage 记录及一次扣费的事务测试。 ## 6. 隔离接入与验证 -- [ ] 6.1 增加默认关闭的规范 Generation 组合根,明确拒绝同一次执行双写新旧 Generation 权威。 -- [ ] 6.2 在隔离 Conversation 路径跑通开始、流式 checkpoint、查询、Stop、完成、失败和恢复端到端测试。 -- [ ] 6.3 运行数据库迁移、相关测试、`pnpm typecheck` 与 `pnpm openspec:validate` 并记录结果。 +- [x] 6.1 增加默认关闭的规范 Generation 组合根,明确拒绝同一次执行双写新旧 Generation 权威。 +- [x] 6.2 在隔离 Conversation 路径跑通开始、流式 checkpoint、查询、Stop、完成、失败和恢复端到端测试。 +- [x] 6.3 运行数据库迁移、相关测试、`pnpm typecheck` 与 `pnpm openspec:validate` 并记录结果。 diff --git a/package.json b/package.json index 484abb78..f821133e 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,8 @@ "format": "prettier --write \"**/*.{ts,tsx}\"", "typecheck": "tsc --noEmit", "test:conversation-domain": "node --experimental-strip-types --test lib/thread-chat/domain/conversation-model.test.ts", + "test:conversation-generation-unit": "tsx --test lib/thread-chat/domain/conversation-generation.test.ts lib/thread-chat/generation/canonical-generation-execution.test.ts", + "test:conversation-generation": "tsx scripts/test-conversation-generation.ts", "test:conversation-persistence": "tsx scripts/test-conversation-persistence.ts", "test:legacy-conversation-audit": "node --experimental-strip-types --test lib/thread-chat/legacy/audit-thread-tree.test.ts", "audit:conversation-domain": "node scripts/audit-thread-tree-boundary.mjs", diff --git a/scripts/test-conversation-generation.ts b/scripts/test-conversation-generation.ts new file mode 100644 index 00000000..2afaca76 --- /dev/null +++ b/scripts/test-conversation-generation.ts @@ -0,0 +1,762 @@ +import assert from "node:assert/strict" +import { randomUUID } from "node:crypto" + +import { config } from "dotenv" + +config({ path: ".env.local" }) +process.env.CONVERSATION_GENERATION_AUTHORITY = "isolated-test" + +const { count, eq } = await import("drizzle-orm") +const { db } = await import("../lib/db/index.ts") +const { + conversationGenerations, + conversationMessages, + conversationTurns, + usageRecords, + user, + userCredits, + workspaces, +} = await import("../lib/db/schema.ts") +const { CanonicalGenerationServiceError } = + await import("../lib/thread-chat/application/conversation-generation-service.ts") +const { emptyConversationGenerationCheckpoint } = + await import("../lib/thread-chat/domain/conversation-generation.ts") +const { + conversationId, + generationId, + messageId, + projectId, + threadId, + turnId, + workspaceId, +} = await import("../lib/thread-chat/domain/conversation-model.ts") +const { + CanonicalGenerationApplicationService, + InMemoryGenerationAbortRegistry, +} = + await import("../lib/thread-chat/generation/canonical-generation-execution.ts") +const { DrizzleConversationGenerationRepository } = + await import("../lib/thread-chat/persistence/drizzle-conversation-generation-repository.ts") +const { DrizzleConversationRepository } = + await import("../lib/thread-chat/persistence/drizzle-conversation-repository.ts") +const { assertCanonicalGenerationEnabled } = + await import("../lib/thread-chat/persistence/canonical-generation-policy.ts") + +const runId = randomUUID() +const prefix = `generation-test:${runId}` +const userId = `${prefix}:user` +const workspace = workspaceId(`${prefix}:workspace`) +const project = projectId(`${prefix}:project`) +const conversation = conversationId(`${prefix}:conversation`) +const thread = threadId(`${prefix}:thread`) +const turn = turnId(`${prefix}:turn`) +const inputMessage = messageId(`${prefix}:message:user`) +const initialAssistant = messageId(`${prefix}:message:assistant:initial`) +const modelId = "glm-5.3" + +const conversationRepository = new DrizzleConversationRepository({ + writeMode: "isolated-test", + legacyWritesEnabled: true, +}) +const generationRepository = new DrizzleConversationGenerationRepository({ + authority: "isolated-test", + legacyAuthorityEnabled: true, +}) + +function content(text: string) { + return { + schemaVersion: 1 as const, + parts: text ? [{ type: "text" as const, text }] : [], + } +} + +function checkpoint( + input: { + body?: string + artifactIds?: string[] + activityStatus?: "running" | "complete" | "error" | "stopped" + knownUsage?: { + inputTokens: number + outputTokens: number + paidStepCount: number + reportedStepCount: number + } | null + contentState?: + "pending" | "streaming" | "complete" | "incomplete" | "failed" + } = {} +) { + return { + ...emptyConversationGenerationCheckpoint(), + body: input.body ?? "", + artifactIds: input.artifactIds ?? [], + researchPlan: input.activityStatus ? { query: "架构资料" } : null, + researchActivities: input.activityStatus + ? [ + { + id: "research-1", + kind: "search", + status: input.activityStatus, + sources: [{ url: "https://example.invalid/source" }], + }, + ] + : [], + contentState: input.contentState ?? "streaming", + knownUsage: input.knownUsage ?? null, + } +} + +async function turnRevision(): Promise { + const [row] = await db + .select({ revision: conversationTurns.revision }) + .from(conversationTurns) + .where(eq(conversationTurns.id, turn)) + assert.ok(row) + return row.revision +} + +let sequence = 0 +async function startInput( + overrides: { + generation?: ReturnType + output?: ReturnType + key?: string + hash?: string + workspaceId?: ReturnType + leaseOwner?: string + outputText?: string + variantOfMessageId?: ReturnType | null + } = {} +) { + sequence += 1 + const generation = + overrides.generation ?? generationId(`${prefix}:generation:${sequence}`) + const output = + overrides.output ?? messageId(`${prefix}:message:assistant:${sequence}`) + return { + id: generation, + ownerId: userId, + workspaceId: overrides.workspaceId ?? workspace, + projectId: project, + conversationId: conversation, + threadId: thread, + turnId: turn, + inputMessageId: inputMessage, + outputMessage: { + id: output, + threadId: thread, + turnId: turn, + role: "assistant" as const, + content: content(overrides.outputText ?? ""), + contentState: "pending" as const, + variantOfMessageId: + overrides.variantOfMessageId === null + ? undefined + : (overrides.variantOfMessageId ?? initialAssistant), + createdAt: new Date().toISOString(), + }, + intent: { kind: "send" as const }, + requestHash: overrides.hash ?? `hash-${sequence}`, + idempotencyKey: overrides.key ?? `key-${sequence}`, + modelId, + expectedTurnRevision: await turnRevision(), + leaseOwner: overrides.leaseOwner ?? `worker-${sequence}`, + } +} + +async function expectServiceCode( + code: string, + action: () => Promise +): Promise { + await assert.rejects(action, (error: unknown) => { + assert.ok(error instanceof CanonicalGenerationServiceError) + assert.equal(error.code, code) + return true + }) +} + +async function expectDatabaseConstraint( + expectedConstraint: string, + action: () => Promise +): Promise { + await assert.rejects(action, (error: unknown) => { + let current: unknown = error + let constraint: string | undefined + while (current && typeof current === "object") { + const candidate = current as { + cause?: unknown + constraint?: unknown + constraint_name?: unknown + } + const value = candidate.constraint ?? candidate.constraint_name + if (typeof value === "string") { + constraint = value + break + } + current = candidate.cause + } + assert.equal(constraint, expectedConstraint) + return true + }) +} + +function abortError() { + const error = new Error("aborted") + error.name = "AbortError" + return error +} + +let assertions = 0 +try { + assert.throws( + () => + assertCanonicalGenerationEnabled({ + authority: "disabled", + legacyAuthorityEnabled: true, + }), + (error: unknown) => + error instanceof CanonicalGenerationServiceError && + error.code === "forbidden" + ) + assert.throws( + () => + assertCanonicalGenerationEnabled({ + authority: "canonical", + legacyAuthorityEnabled: true, + }), + (error: unknown) => + error instanceof CanonicalGenerationServiceError && + error.code === "forbidden" + ) + assertions += 2 + + await db.insert(user).values({ + id: userId, + name: "Conversation Generation Test", + email: `${runId}@conversation-generation.invalid`, + emailVerified: true, + }) + await conversationRepository.createWorkspace({ + workspace: { id: workspace, revision: 0, lifecycle: "active" }, + ownerUserId: userId, + }) + await conversationRepository.createProject({ + actorUserId: userId, + project: { + id: project, + workspaceId: workspace, + title: "Generation 生命周期测试", + revision: 0, + lifecycle: "active", + }, + }) + await conversationRepository.createConversation({ + actorUserId: userId, + projectId: project, + conversation: { + id: conversation, + rootThreadId: thread, + autoTitle: "Generation 测试", + customTitle: null, + revision: 0, + lifecycle: "active", + }, + rootThread: { + id: thread, + conversationId: conversation, + modelId, + localTitle: null, + revision: 0, + lifecycle: "active", + }, + }) + await conversationRepository.appendTurn({ + actorUserId: userId, + conversationId: conversation, + expectedThreadRevision: 0, + turn: { + id: turn, + threadId: thread, + position: 0, + activeUserMessageId: inputMessage, + activeAssistantMessageId: initialAssistant, + revision: 0, + }, + userMessage: { + id: inputMessage, + threadId: thread, + turnId: turn, + role: "user", + content: content("请验证规范 Generation 生命周期"), + contentState: "complete", + createdAt: new Date().toISOString(), + }, + assistantMessage: { + id: initialAssistant, + threadId: thread, + turnId: turn, + role: "assistant", + content: content("初始占位"), + contentState: "pending", + createdAt: new Date().toISOString(), + }, + }) + + // 开始、幂等重放、载荷冲突、跨层级身份拒绝。 + const idempotentInput = await startInput({ + output: initialAssistant, + outputText: "初始占位", + variantOfMessageId: null, + }) + const concurrentStarts = await Promise.all([ + generationRepository.startGeneration(idempotentInput), + generationRepository.startGeneration(idempotentInput), + ]) + const first = concurrentStarts.find((result) => result.created) + const replay = concurrentStarts.find((result) => !result.created) + assert.ok(first) + assert.ok(replay) + assert.equal(first.created, true) + assert.equal(replay.created, false) + assert.equal(replay.generation.id, first.generation.id) + const [confirmedOutputCount] = await db + .select({ value: count(conversationMessages.id) }) + .from(conversationMessages) + .where(eq(conversationMessages.id, initialAssistant)) + assert.equal(confirmedOutputCount?.value, 1) + await expectServiceCode("idempotency_conflict", () => + generationRepository.startGeneration({ + ...idempotentInput, + requestHash: "different-hash", + }) + ) + const invalidOutput = messageId(`${prefix}:message:invalid-workspace`) + const invalidIdentityInput = await startInput({ + output: invalidOutput, + workspaceId: workspaceId(`${prefix}:wrong-workspace`), + }) + await expectServiceCode("invalid_identity", () => + generationRepository.startGeneration(invalidIdentityInput) + ) + const [orphanOutput] = await db + .select({ id: conversationMessages.id }) + .from(conversationMessages) + .where(eq(conversationMessages.id, invalidOutput)) + assert.equal(orphanOutput, undefined) + assertions += 7 + + // 结束第一条,避免它影响后续 current-attempt 测试。 + await generationRepository.finalizeGeneration({ + generationId: first.generation.id, + leaseOwner: idempotentInput.leaseOwner, + expectedCheckpointVersion: 0, + outcome: "failed", + checkpoint: checkpoint({ contentState: "failed" }), + usageCompleteness: "unavailable", + knownUsage: null, + errorCode: "fixture_finished", + }) + + // 断线语义:调用方不等待 execution,服务端仍可查询 running 并最终完成。 + let releaseDisconnect!: () => void + const disconnectGate = new Promise((resolve) => { + releaseDisconnect = resolve + }) + let modelObservedCommitted = false + const disconnectService = new CanonicalGenerationApplicationService( + generationRepository, + { + async execute({ generation, onCheckpoint }) { + const queried = await generationRepository.getGeneration({ + ownerId: userId, + generationId: generation.id, + }) + modelObservedCommitted = queried?.status === "running" + await onCheckpoint( + checkpoint({ + body: "流式正文", + artifactIds: ["artifact-1"], + activityStatus: "running", + }) + ) + await releaseAfter(disconnectGate) + return { + outcome: "completed", + checkpoint: checkpoint({ + body: "流式正文完成", + artifactIds: ["artifact-1"], + activityStatus: "complete", + contentState: "complete", + }), + usageCompleteness: "complete", + knownUsage: { + inputTokens: 11, + outputTokens: 7, + paidStepCount: 1, + reportedStepCount: 1, + }, + } + }, + }, + new InMemoryGenerationAbortRegistry(), + { checkpointThrottleMs: 0, heartbeatMs: 50 } + ) + const disconnectInput = await startInput() + const disconnected = await disconnectService.start(disconnectInput) + assert.ok(disconnected.execution) + await waitFor(async () => { + const value = await disconnectService.query({ + ownerId: userId, + generationId: disconnectInput.id, + }) + return value?.checkpoint.body === "流式正文" + }) + const runningAfterConsumerDetach = await disconnectService.query({ + ownerId: userId, + generationId: disconnectInput.id, + }) + assert.equal(runningAfterConsumerDetach?.status, "running") + assert.equal( + runningAfterConsumerDetach?.checkpoint.researchActivities[0]?.status, + "running" + ) + assert.equal(modelObservedCommitted, true) + const staleWhileRunning = await generationRepository.saveCheckpoint({ + generationId: disconnectInput.id, + leaseOwner: disconnectInput.leaseOwner, + expectedVersion: 0, + checkpoint: checkpoint({ body: "旧正文" }), + }) + assert.equal(staleWhileRunning.kind, "conflict") + await expectServiceCode("checkpoint_conflict", () => + generationRepository.saveCheckpoint({ + generationId: disconnectInput.id, + leaseOwner: disconnectInput.leaseOwner, + expectedVersion: runningAfterConsumerDetach?.checkpointVersion ?? -1, + checkpoint: checkpoint({ body: "流式正文" }), + }) + ) + releaseDisconnect() + const disconnectedResult = await disconnected.execution + assert.equal(disconnectedResult?.status, "completed") + assert.equal(disconnectedResult?.billingStatus, "settled") + assertions += 8 + + // checkpoint CAS:旧版本不能覆盖正文、Artifact 或 running 活动。 + const staleCheckpointResult = await generationRepository.saveCheckpoint({ + generationId: disconnectInput.id, + leaseOwner: disconnectInput.leaseOwner, + expectedVersion: 0, + checkpoint: checkpoint({ body: "旧正文" }), + }) + assert.equal(staleCheckpointResult.kind, "terminal") + const [disconnectMessage] = await db + .select({ content: conversationMessages.content }) + .from(conversationMessages) + .where(eq(conversationMessages.id, disconnectInput.outputMessage.id)) + assert.ok( + disconnectMessage?.content.parts.some( + (part) => part.type === "artifact-reference" + ) + ) + assertions += 2 + + // Stop:先持久化后本地 abort;保留 partial,并保持 usage 真实。 + const stopRegistry = new InMemoryGenerationAbortRegistry() + const stopService = new CanonicalGenerationApplicationService( + generationRepository, + { + async execute({ signal, onCheckpoint }) { + await onCheckpoint( + checkpoint({ + body: "停止前已知内容", + activityStatus: "running", + knownUsage: { + inputTokens: 5, + outputTokens: 2, + paidStepCount: 2, + reportedStepCount: 1, + }, + }) + ) + await new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => reject(abortError()), { + once: true, + }) + }) + assert.fail("abort 后不应继续") + }, + }, + stopRegistry, + { checkpointThrottleMs: 0, heartbeatMs: 50 } + ) + const stopInput = await startInput() + const stopping = await stopService.start(stopInput) + assert.ok(stopping.execution) + await waitFor(async () => { + const value = await stopService.query({ + ownerId: userId, + generationId: stopInput.id, + }) + return value?.checkpoint.body === "停止前已知内容" + }) + const remoteStopService = new CanonicalGenerationApplicationService( + generationRepository, + { execute: async () => assert.fail("Stop 命令不执行模型") }, + new InMemoryGenerationAbortRegistry(), + { heartbeatMs: 50 } + ) + const stopRequested = await remoteStopService.stop({ + ownerId: userId, + generationId: stopInput.id, + }) + assert.equal(stopRequested?.status, "stop_requested") + const stopped = await stopping.execution + assert.equal(stopped?.status, "stopped") + assert.equal(stopped?.contentState, "incomplete") + assert.equal(stopped?.usageCompleteness, "partial") + assert.equal(stopped?.billingStatus, "usage_unavailable") + const repeatedStop = await remoteStopService.stop({ + ownerId: userId, + generationId: stopInput.id, + }) + assert.equal(repeatedStop?.status, "stopped") + assertions += 7 + + // 无输出失败。 + const failureService = new CanonicalGenerationApplicationService( + generationRepository, + { + async execute() { + throw new Error("model_failed") + }, + }, + new InMemoryGenerationAbortRegistry(), + { checkpointThrottleMs: 0, heartbeatMs: 50 } + ) + const failureInput = await startInput() + const failing = await failureService.start(failureInput) + assert.ok(failing.execution) + const failed = await failing.execution + assert.equal(failed?.status, "failed") + assert.equal(failed?.contentState, "failed") + assertions += 2 + + // 进程崩溃/stale claim 使用最新 checkpoint;健康完成后不能再 claim。 + const staleInput = await startInput() + const staleStarted = await generationRepository.startGeneration(staleInput) + assert.equal(staleStarted.created, true) + assert.equal( + await generationRepository.markPaidCallStarted({ + generationId: staleInput.id, + leaseOwner: staleInput.leaseOwner, + }), + true + ) + const stalePartial = checkpoint({ + body: "崩溃前 checkpoint", + activityStatus: "running", + }) + const staleSaved = await generationRepository.saveCheckpoint({ + generationId: staleInput.id, + leaseOwner: staleInput.leaseOwner, + expectedVersion: 0, + checkpoint: stalePartial, + }) + assert.equal(staleSaved.kind, "saved") + await db + .update(conversationGenerations) + .set({ heartbeatAt: new Date(0) }) + .where(eq(conversationGenerations.id, staleInput.id)) + const convergenceService = new CanonicalGenerationApplicationService( + generationRepository, + { execute: async () => assert.fail("stale 收敛不调用模型") }, + new InMemoryGenerationAbortRegistry(), + { leaseMs: 1, now: () => Date.now() } + ) + const converged = await convergenceService.convergeStale({ + generationId: staleInput.id, + leaseOwner: "recovery-worker", + }) + assert.equal(converged?.status, "failed") + assert.equal(converged?.contentState, "incomplete") + assert.equal(converged?.checkpoint.body, "崩溃前 checkpoint") + assert.equal( + await convergenceService.convergeStale({ + generationId: staleInput.id, + leaseOwner: "second-recovery-worker", + }), + null + ) + assert.equal( + await convergenceService.convergeStale({ + generationId: disconnectInput.id, + leaseOwner: "cannot-claim-completed", + }), + null + ) + assertions += 8 + + // 旧尝试晚到:新尝试先成为 current,旧结果不能覆盖 Turn 当前变体。 + const oldInput = await startInput() + const oldStarted = await generationRepository.startGeneration(oldInput) + assert.equal(oldStarted.created, true) + await generationRepository.markPaidCallStarted({ + generationId: oldInput.id, + leaseOwner: oldInput.leaseOwner, + }) + const newInput = await startInput() + const newStarted = await generationRepository.startGeneration(newInput) + assert.equal(newStarted.created, true) + const oldLate = await generationRepository.finalizeGeneration({ + generationId: oldInput.id, + leaseOwner: oldInput.leaseOwner, + expectedCheckpointVersion: 0, + outcome: "completed", + checkpoint: checkpoint({ body: "旧结果晚到", contentState: "complete" }), + usageCompleteness: "complete", + knownUsage: { + inputTokens: 2, + outputTokens: 2, + paidStepCount: 1, + reportedStepCount: 1, + }, + }) + assert.equal(oldLate?.status, "superseded") + const [turnBeforeNewFinalize] = await db + .select({ active: conversationTurns.activeAssistantMessageId }) + .from(conversationTurns) + .where(eq(conversationTurns.id, turn)) + assert.notEqual(turnBeforeNewFinalize?.active, oldInput.outputMessage.id) + await generationRepository.finalizeGeneration({ + generationId: newInput.id, + leaseOwner: newInput.leaseOwner, + expectedCheckpointVersion: 0, + outcome: "completed", + checkpoint: checkpoint({ body: "新结果", contentState: "complete" }), + usageCompleteness: "unavailable", + knownUsage: null, + }) + const [turnAfterNewFinalize] = await db + .select({ active: conversationTurns.activeAssistantMessageId }) + .from(conversationTurns) + .where(eq(conversationTurns.id, turn)) + assert.equal(turnAfterNewFinalize?.active, newInput.outputMessage.id) + assertions += 5 + + // 完整 usage 重复终结只产生一条流水、一次扣费;partial 不产生流水。 + const partialUsageCount = await db + .select({ value: count(usageRecords.id) }) + .from(usageRecords) + .where(eq(usageRecords.appGenerationId, stopInput.id)) + assert.equal(partialUsageCount[0]?.value, 0) + const completeUsageCount = await db + .select({ value: count(usageRecords.id) }) + .from(usageRecords) + .where(eq(usageRecords.appGenerationId, disconnectInput.id)) + assert.equal(completeUsageCount[0]?.value, 1) + const [balanceBeforeReplay] = await db + .select({ value: userCredits.balanceMicros }) + .from(userCredits) + .where(eq(userCredits.userId, userId)) + await Promise.all([ + generationRepository.finalizeGeneration({ + generationId: disconnectInput.id, + leaseOwner: disconnectInput.leaseOwner, + expectedCheckpointVersion: disconnectedResult?.checkpointVersion ?? 0, + outcome: "completed", + checkpoint: disconnectedResult?.checkpoint ?? checkpoint(), + usageCompleteness: "complete", + knownUsage: disconnectedResult?.knownUsage ?? null, + }), + generationRepository.finalizeGeneration({ + generationId: disconnectInput.id, + leaseOwner: disconnectInput.leaseOwner, + expectedCheckpointVersion: disconnectedResult?.checkpointVersion ?? 0, + outcome: "completed", + checkpoint: disconnectedResult?.checkpoint ?? checkpoint(), + usageCompleteness: "complete", + knownUsage: disconnectedResult?.knownUsage ?? null, + }), + ]) + const [countAfterReplay] = await db + .select({ value: count(usageRecords.id) }) + .from(usageRecords) + .where(eq(usageRecords.appGenerationId, disconnectInput.id)) + const [balanceAfterReplay] = await db + .select({ value: userCredits.balanceMicros }) + .from(userCredits) + .where(eq(userCredits.userId, userId)) + assert.equal(countAfterReplay?.value, 1) + assert.equal(balanceAfterReplay?.value, balanceBeforeReplay?.value) + assertions += 4 + + // 数据库独立拒绝非法状态组合和错误 Message 角色。 + const invalidStateInput = await startInput() + const invalidStateStarted = + await generationRepository.startGeneration(invalidStateInput) + assert.equal(invalidStateStarted.created, true) + await expectDatabaseConstraint( + "conversation_generations_terminal_time_ck", + () => + db + .update(conversationGenerations) + .set({ + status: "completed", + contentState: "complete", + billingStatus: "not_billable", + }) + .where(eq(conversationGenerations.id, invalidStateInput.id)) + ) + await expectDatabaseConstraint("conversation_generations_input_role_ck", () => + db + .update(conversationGenerations) + .set({ inputMessageId: invalidStateInput.outputMessage.id }) + .where(eq(conversationGenerations.id, invalidStateInput.id)) + ) + await expectDatabaseConstraint( + "conversation_generations_conversation_project_fk", + () => + db + .update(conversationGenerations) + .set({ + conversationId: conversationId(`${prefix}:wrong-conversation`), + }) + .where(eq(conversationGenerations.id, invalidStateInput.id)) + ) + assertions += 4 + + console.log( + JSON.stringify({ + ok: true, + assertions, + authority: "isolated-test", + model: modelId, + exactlyOnceBilling: true, + browserDisconnectKeepsExecution: true, + }) + ) +} finally { + await db + .delete(conversationGenerations) + .where(eq(conversationGenerations.workspaceId, workspace)) + await db.delete(workspaces).where(eq(workspaces.id, workspace)) + await db.delete(user).where(eq(user.id, userId)) + await globalThis.__dbClient?.end() + globalThis.__dbClient = undefined +} + +async function releaseAfter(promise: Promise): Promise { + await promise +} + +async function waitFor( + predicate: () => Promise, + timeoutMs = 2_000 +): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if (await predicate()) return + await new Promise((resolve) => setTimeout(resolve, 10)) + } + throw new Error(`等待条件超时(${timeoutMs}ms)`) +} From 67b427af3d874ebb58e42c4118e41bd1057692a3 Mon Sep 17 00:00:00 2001 From: zilin <276161014@qq.com> Date: Sat, 22 Aug 2026 03:08:54 +0800 Subject: [PATCH 05/32] feat(api): add canonical conversation commands --- .../[conversationId]/restore/route.ts | 28 + .../conversations/[conversationId]/route.ts | 79 + app/api/generations/[generationId]/route.ts | 31 + .../generations/[generationId]/stop/route.ts | 29 + .../[projectId]/conversations/route.ts | 56 + app/api/threads/[threadId]/forks/route.ts | 43 + app/api/threads/[threadId]/restore/route.ts | 28 + app/api/threads/[threadId]/route.ts | 42 + app/api/threads/[threadId]/turns/route.ts | 45 + .../turns/[turnId]/active-variant/route.ts | 39 + app/api/turns/[turnId]/input-edits/route.ts | 45 + app/api/turns/[turnId]/regenerations/route.ts | 42 + constants/conversation-command.ts | 6 + drizzle/0009_marvelous_lady_bullseye.sql | 52 + drizzle/meta/0009_snapshot.json | 3181 +++++++++++++++++ drizzle/meta/_journal.json | 7 + lib/db/schema.ts | 71 + .../conversation-command-contracts.ts | 210 ++ .../conversation-command-service.ts | 177 + .../contracts/conversation-command-api.ts | 164 + .../canonical-generation-execution.ts | 10 + .../http/conversation-command-composition.ts | 90 + .../http/conversation-command-http.test.ts | 103 + .../http/conversation-command-http.ts | 173 + .../conversation-command-policy.ts | 51 + .../drizzle-conversation-command-store.ts | 1834 ++++++++++ .../drizzle-conversation-outbox-dispatcher.ts | 138 + .../add-conversation-command-api/tasks.md | 61 +- package.json | 2 + scripts/test-conversation-command-api.ts | 873 +++++ 30 files changed, 7684 insertions(+), 26 deletions(-) create mode 100644 app/api/conversations/[conversationId]/restore/route.ts create mode 100644 app/api/conversations/[conversationId]/route.ts create mode 100644 app/api/generations/[generationId]/route.ts create mode 100644 app/api/generations/[generationId]/stop/route.ts create mode 100644 app/api/projects/[projectId]/conversations/route.ts create mode 100644 app/api/threads/[threadId]/forks/route.ts create mode 100644 app/api/threads/[threadId]/restore/route.ts create mode 100644 app/api/threads/[threadId]/route.ts create mode 100644 app/api/threads/[threadId]/turns/route.ts create mode 100644 app/api/turns/[turnId]/active-variant/route.ts create mode 100644 app/api/turns/[turnId]/input-edits/route.ts create mode 100644 app/api/turns/[turnId]/regenerations/route.ts create mode 100644 constants/conversation-command.ts create mode 100644 drizzle/0009_marvelous_lady_bullseye.sql create mode 100644 drizzle/meta/0009_snapshot.json create mode 100644 lib/thread-chat/application/conversation-command-contracts.ts create mode 100644 lib/thread-chat/application/conversation-command-service.ts create mode 100644 lib/thread-chat/contracts/conversation-command-api.ts create mode 100644 lib/thread-chat/http/conversation-command-composition.ts create mode 100644 lib/thread-chat/http/conversation-command-http.test.ts create mode 100644 lib/thread-chat/http/conversation-command-http.ts create mode 100644 lib/thread-chat/persistence/conversation-command-policy.ts create mode 100644 lib/thread-chat/persistence/drizzle-conversation-command-store.ts create mode 100644 lib/thread-chat/persistence/drizzle-conversation-outbox-dispatcher.ts create mode 100644 scripts/test-conversation-command-api.ts diff --git a/app/api/conversations/[conversationId]/restore/route.ts b/app/api/conversations/[conversationId]/restore/route.ts new file mode 100644 index 00000000..7bd15273 --- /dev/null +++ b/app/api/conversations/[conversationId]/restore/route.ts @@ -0,0 +1,28 @@ +import { conversationId } from "@/lib/thread-chat/domain/conversation-model" +import { getConversationCommandComposition } from "@/lib/thread-chat/http/conversation-command-composition" +import { + authenticatedActor, + commandEnvelope, + commandResponse, + withConversationRoute, +} from "@/lib/thread-chat/http/conversation-command-http" + +type Context = { params: Promise<{ conversationId: string }> } + +export async function POST(request: Request, { params }: Context) { + return withConversationRoute(async () => { + const actor = await authenticatedActor() + const id = conversationId((await params).conversationId) + const result = + await getConversationCommandComposition().service.setConversationLifecycle( + commandEnvelope({ + request, + actor, + scope: { type: "conversation", id }, + payload: { lifecycle: "active" }, + expectedRevisionRequired: true, + }) + ) + return commandResponse(result, id) + }) +} diff --git a/app/api/conversations/[conversationId]/route.ts b/app/api/conversations/[conversationId]/route.ts new file mode 100644 index 00000000..33007ca3 --- /dev/null +++ b/app/api/conversations/[conversationId]/route.ts @@ -0,0 +1,79 @@ +import { conversationPatchRequestSchema } from "@/lib/thread-chat/contracts/conversation-command-api" +import { ConversationCommandError } from "@/lib/thread-chat/application/conversation-command-contracts" +import { conversationId } from "@/lib/thread-chat/domain/conversation-model" +import { getConversationCommandComposition } from "@/lib/thread-chat/http/conversation-command-composition" +import { + authenticatedActor, + commandEnvelope, + commandResponse, + parseJson, + queryResponse, + withConversationRoute, +} from "@/lib/thread-chat/http/conversation-command-http" + +type Context = { params: Promise<{ conversationId: string }> } + +export async function GET(_request: Request, { params }: Context) { + return withConversationRoute(async () => { + const actor = await authenticatedActor() + const id = conversationId((await params).conversationId) + const result = + await getConversationCommandComposition().service.getConversationSnapshot( + { + actorUserId: actor.userId, + conversationId: id, + } + ) + if (!result) + throw new ConversationCommandError("not_found", "Conversation 不存在") + return queryResponse(result, result.snapshot.conversation.revision) + }) +} + +export async function PATCH(request: Request, { params }: Context) { + return withConversationRoute(async () => { + const actor = await authenticatedActor() + const id = conversationId((await params).conversationId) + const body = await parseJson(request, conversationPatchRequestSchema) + const service = getConversationCommandComposition().service + const result = + "title" in body + ? await service.renameConversation( + commandEnvelope({ + request, + actor, + scope: { type: "conversation", id }, + payload: body, + expectedRevisionRequired: true, + }) + ) + : await service.setConversationLifecycle( + commandEnvelope({ + request, + actor, + scope: { type: "conversation", id }, + payload: body, + expectedRevisionRequired: true, + }) + ) + return commandResponse(result, id) + }) +} + +export async function DELETE(request: Request, { params }: Context) { + return withConversationRoute(async () => { + const actor = await authenticatedActor() + const id = conversationId((await params).conversationId) + const result = + await getConversationCommandComposition().service.deleteConversation( + commandEnvelope({ + request, + actor, + scope: { type: "conversation", id }, + payload: {}, + expectedRevisionRequired: true, + }) + ) + return commandResponse(result, id) + }) +} diff --git a/app/api/generations/[generationId]/route.ts b/app/api/generations/[generationId]/route.ts new file mode 100644 index 00000000..5e3169bf --- /dev/null +++ b/app/api/generations/[generationId]/route.ts @@ -0,0 +1,31 @@ +import { generationId } from "@/lib/thread-chat/domain/conversation-model" +import { getConversationCommandComposition } from "@/lib/thread-chat/http/conversation-command-composition" +import { + authenticatedActor, + queryResponse, + withConversationRoute, +} from "@/lib/thread-chat/http/conversation-command-http" +import { CONVERSATION_POLL_AFTER_MS } from "@/constants/conversation-command" +import { ConversationCommandError } from "@/lib/thread-chat/application/conversation-command-contracts" + +type Context = { params: Promise<{ generationId: string }> } + +export async function GET(_request: Request, { params }: Context) { + return withConversationRoute(async () => { + const actor = await authenticatedActor() + const id = generationId((await params).generationId) + const generation = + await getConversationCommandComposition().service.getGeneration({ + actorUserId: actor.userId, + generationId: id, + }) + if (!generation) + throw new ConversationCommandError("not_found", "Generation 不存在") + return queryResponse({ + generation, + pollAfterMs: ["running", "stop_requested"].includes(generation.status) + ? CONVERSATION_POLL_AFTER_MS + : null, + }) + }) +} diff --git a/app/api/generations/[generationId]/stop/route.ts b/app/api/generations/[generationId]/stop/route.ts new file mode 100644 index 00000000..5059fea4 --- /dev/null +++ b/app/api/generations/[generationId]/stop/route.ts @@ -0,0 +1,29 @@ +import { generationId } from "@/lib/thread-chat/domain/conversation-model" +import { + abortCanonicalGenerationLocally, + getConversationCommandComposition, +} from "@/lib/thread-chat/http/conversation-command-composition" +import { + authenticatedActor, + queryResponse, + withConversationRoute, +} from "@/lib/thread-chat/http/conversation-command-http" +import { ConversationCommandError } from "@/lib/thread-chat/application/conversation-command-contracts" + +type Context = { params: Promise<{ generationId: string }> } + +export async function POST(_request: Request, { params }: Context) { + return withConversationRoute(async () => { + const actor = await authenticatedActor() + const id = generationId((await params).generationId) + const generation = + await getConversationCommandComposition().service.stopGeneration({ + actorUserId: actor.userId, + generationId: id, + notifyLocalAbort: abortCanonicalGenerationLocally, + }) + if (!generation) + throw new ConversationCommandError("not_found", "Generation 不存在") + return queryResponse({ generation }) + }) +} diff --git a/app/api/projects/[projectId]/conversations/route.ts b/app/api/projects/[projectId]/conversations/route.ts new file mode 100644 index 00000000..8f32cab8 --- /dev/null +++ b/app/api/projects/[projectId]/conversations/route.ts @@ -0,0 +1,56 @@ +import { createConversationRequestSchema } from "@/lib/thread-chat/contracts/conversation-command-api" +import { + authenticatedActor, + commandEnvelope, + commandResponse, + parseJson, + queryResponse, + withConversationRoute, +} from "@/lib/thread-chat/http/conversation-command-http" +import { getConversationCommandComposition } from "@/lib/thread-chat/http/conversation-command-composition" +import { + conversationId, + projectId, + threadId, +} from "@/lib/thread-chat/domain/conversation-model" + +type Context = { params: Promise<{ projectId: string }> } + +export async function GET(request: Request, { params }: Context) { + return withConversationRoute(async () => { + const actor = await authenticatedActor() + const targetProjectId = projectId((await params).projectId) + const includeArchived = + new URL(request.url).searchParams.get("includeArchived") === "true" + const items = + await getConversationCommandComposition().service.listConversations({ + actorUserId: actor.userId, + projectId: targetProjectId, + includeArchived, + }) + return queryResponse({ conversations: items }) + }) +} + +export async function POST(request: Request, { params }: Context) { + return withConversationRoute(async () => { + const actor = await authenticatedActor() + const targetProjectId = projectId((await params).projectId) + const body = await parseJson(request, createConversationRequestSchema) + const result = + await getConversationCommandComposition().service.createConversation( + commandEnvelope({ + request, + actor, + scope: { type: "project", id: targetProjectId }, + payload: { + conversationId: conversationId(body.conversationId), + rootThreadId: threadId(body.rootThreadId), + title: body.title, + modelId: body.modelId, + }, + }) + ) + return commandResponse(result, body.conversationId) + }) +} diff --git a/app/api/threads/[threadId]/forks/route.ts b/app/api/threads/[threadId]/forks/route.ts new file mode 100644 index 00000000..b6f87ed2 --- /dev/null +++ b/app/api/threads/[threadId]/forks/route.ts @@ -0,0 +1,43 @@ +import { forkThreadRequestSchema } from "@/lib/thread-chat/contracts/conversation-command-api" +import { + conversationId, + messageId, + threadForkId, + threadId, +} from "@/lib/thread-chat/domain/conversation-model" +import { getConversationCommandComposition } from "@/lib/thread-chat/http/conversation-command-composition" +import { + authenticatedActor, + commandEnvelope, + commandResponse, + parseJson, + withConversationRoute, +} from "@/lib/thread-chat/http/conversation-command-http" + +type Context = { params: Promise<{ threadId: string }> } + +export async function POST(request: Request, { params }: Context) { + return withConversationRoute(async () => { + const actor = await authenticatedActor() + const id = threadId((await params).threadId) + const body = await parseJson(request, forkThreadRequestSchema) + const result = await getConversationCommandComposition().service.forkThread( + commandEnvelope({ + request, + actor, + scope: { type: "thread", id }, + expectedRevisionRequired: true, + payload: { + conversationId: conversationId(body.conversationId), + forkId: threadForkId(body.forkId), + childThreadId: threadId(body.childThreadId), + sourceMessageId: messageId(body.sourceMessageId), + modelId: body.modelId, + localTitle: body.localTitle, + anchor: body.anchor, + }, + }) + ) + return commandResponse(result, body.conversationId) + }) +} diff --git a/app/api/threads/[threadId]/restore/route.ts b/app/api/threads/[threadId]/restore/route.ts new file mode 100644 index 00000000..9042dae1 --- /dev/null +++ b/app/api/threads/[threadId]/restore/route.ts @@ -0,0 +1,28 @@ +import { threadId } from "@/lib/thread-chat/domain/conversation-model" +import { getConversationCommandComposition } from "@/lib/thread-chat/http/conversation-command-composition" +import { + authenticatedActor, + commandEnvelope, + commandResponse, + withConversationRoute, +} from "@/lib/thread-chat/http/conversation-command-http" + +type Context = { params: Promise<{ threadId: string }> } + +export async function POST(request: Request, { params }: Context) { + return withConversationRoute(async () => { + const actor = await authenticatedActor() + const id = threadId((await params).threadId) + const result = + await getConversationCommandComposition().service.setThreadLifecycle( + commandEnvelope({ + request, + actor, + scope: { type: "thread", id }, + payload: { lifecycle: "active" }, + expectedRevisionRequired: true, + }) + ) + return commandResponse(result, id) + }) +} diff --git a/app/api/threads/[threadId]/route.ts b/app/api/threads/[threadId]/route.ts new file mode 100644 index 00000000..93117965 --- /dev/null +++ b/app/api/threads/[threadId]/route.ts @@ -0,0 +1,42 @@ +import { threadPatchRequestSchema } from "@/lib/thread-chat/contracts/conversation-command-api" +import { threadId } from "@/lib/thread-chat/domain/conversation-model" +import { getConversationCommandComposition } from "@/lib/thread-chat/http/conversation-command-composition" +import { + authenticatedActor, + commandEnvelope, + commandResponse, + parseJson, + withConversationRoute, +} from "@/lib/thread-chat/http/conversation-command-http" + +type Context = { params: Promise<{ threadId: string }> } + +export async function PATCH(request: Request, { params }: Context) { + return withConversationRoute(async () => { + const actor = await authenticatedActor() + const id = threadId((await params).threadId) + const body = await parseJson(request, threadPatchRequestSchema) + const service = getConversationCommandComposition().service + const result = + "title" in body + ? await service.renameThread( + commandEnvelope({ + request, + actor, + scope: { type: "thread", id }, + payload: body, + expectedRevisionRequired: true, + }) + ) + : await service.setThreadLifecycle( + commandEnvelope({ + request, + actor, + scope: { type: "thread", id }, + payload: body, + expectedRevisionRequired: true, + }) + ) + return commandResponse(result, id) + }) +} diff --git a/app/api/threads/[threadId]/turns/route.ts b/app/api/threads/[threadId]/turns/route.ts new file mode 100644 index 00000000..ee69647e --- /dev/null +++ b/app/api/threads/[threadId]/turns/route.ts @@ -0,0 +1,45 @@ +import { sendTurnRequestSchema } from "@/lib/thread-chat/contracts/conversation-command-api" +import { + conversationId, + generationId, + messageId, + threadId, + turnId, +} from "@/lib/thread-chat/domain/conversation-model" +import type { MessageContent } from "@/lib/thread-chat/domain/conversation-model" +import { getConversationCommandComposition } from "@/lib/thread-chat/http/conversation-command-composition" +import { + authenticatedActor, + commandEnvelope, + commandResponse, + parseJson, + withConversationRoute, +} from "@/lib/thread-chat/http/conversation-command-http" + +type Context = { params: Promise<{ threadId: string }> } + +export async function POST(request: Request, { params }: Context) { + return withConversationRoute(async () => { + const actor = await authenticatedActor() + const id = threadId((await params).threadId) + const body = await parseJson(request, sendTurnRequestSchema) + const result = await getConversationCommandComposition().service.sendTurn( + commandEnvelope({ + request, + actor, + scope: { type: "thread", id }, + expectedRevisionRequired: true, + payload: { + conversationId: conversationId(body.conversationId), + turnId: turnId(body.turnId), + userMessageId: messageId(body.userMessageId), + assistantMessageId: messageId(body.assistantMessageId), + generationId: generationId(body.generationId), + content: body.content as MessageContent, + modelId: body.modelId, + }, + }) + ) + return commandResponse(result, id) + }) +} diff --git a/app/api/turns/[turnId]/active-variant/route.ts b/app/api/turns/[turnId]/active-variant/route.ts new file mode 100644 index 00000000..8029aae0 --- /dev/null +++ b/app/api/turns/[turnId]/active-variant/route.ts @@ -0,0 +1,39 @@ +import { selectTurnVariantRequestSchema } from "@/lib/thread-chat/contracts/conversation-command-api" +import { + conversationId, + messageId, + turnId, +} from "@/lib/thread-chat/domain/conversation-model" +import { getConversationCommandComposition } from "@/lib/thread-chat/http/conversation-command-composition" +import { + authenticatedActor, + commandEnvelope, + commandResponse, + parseJson, + withConversationRoute, +} from "@/lib/thread-chat/http/conversation-command-http" + +type Context = { params: Promise<{ turnId: string }> } + +export async function POST(request: Request, { params }: Context) { + return withConversationRoute(async () => { + const actor = await authenticatedActor() + const id = turnId((await params).turnId) + const body = await parseJson(request, selectTurnVariantRequestSchema) + const result = + await getConversationCommandComposition().service.selectTurnVariant( + commandEnvelope({ + request, + actor, + scope: { type: "turn", id }, + expectedRevisionRequired: true, + payload: { + conversationId: conversationId(body.conversationId), + messageId: messageId(body.messageId), + role: body.role, + }, + }) + ) + return commandResponse(result, id) + }) +} diff --git a/app/api/turns/[turnId]/input-edits/route.ts b/app/api/turns/[turnId]/input-edits/route.ts new file mode 100644 index 00000000..afcd2755 --- /dev/null +++ b/app/api/turns/[turnId]/input-edits/route.ts @@ -0,0 +1,45 @@ +import { editTurnInputRequestSchema } from "@/lib/thread-chat/contracts/conversation-command-api" +import { + conversationId, + generationId, + messageId, + turnId, +} from "@/lib/thread-chat/domain/conversation-model" +import type { MessageContent } from "@/lib/thread-chat/domain/conversation-model" +import { getConversationCommandComposition } from "@/lib/thread-chat/http/conversation-command-composition" +import { + authenticatedActor, + commandEnvelope, + commandResponse, + parseJson, + withConversationRoute, +} from "@/lib/thread-chat/http/conversation-command-http" + +type Context = { params: Promise<{ turnId: string }> } + +export async function POST(request: Request, { params }: Context) { + return withConversationRoute(async () => { + const actor = await authenticatedActor() + const id = turnId((await params).turnId) + const body = await parseJson(request, editTurnInputRequestSchema) + const result = + await getConversationCommandComposition().service.editTurnInput( + commandEnvelope({ + request, + actor, + scope: { type: "turn", id }, + expectedRevisionRequired: true, + payload: { + conversationId: conversationId(body.conversationId), + userMessageId: messageId(body.userMessageId), + assistantMessageId: messageId(body.assistantMessageId), + generationId: generationId(body.generationId), + sourceUserMessageId: messageId(body.sourceUserMessageId), + content: body.content as MessageContent, + modelId: body.modelId, + }, + }) + ) + return commandResponse(result, id) + }) +} diff --git a/app/api/turns/[turnId]/regenerations/route.ts b/app/api/turns/[turnId]/regenerations/route.ts new file mode 100644 index 00000000..ad5bf999 --- /dev/null +++ b/app/api/turns/[turnId]/regenerations/route.ts @@ -0,0 +1,42 @@ +import { regenerateTurnRequestSchema } from "@/lib/thread-chat/contracts/conversation-command-api" +import { + conversationId, + generationId, + messageId, + turnId, +} from "@/lib/thread-chat/domain/conversation-model" +import { getConversationCommandComposition } from "@/lib/thread-chat/http/conversation-command-composition" +import { + authenticatedActor, + commandEnvelope, + commandResponse, + parseJson, + withConversationRoute, +} from "@/lib/thread-chat/http/conversation-command-http" + +type Context = { params: Promise<{ turnId: string }> } + +export async function POST(request: Request, { params }: Context) { + return withConversationRoute(async () => { + const actor = await authenticatedActor() + const id = turnId((await params).turnId) + const body = await parseJson(request, regenerateTurnRequestSchema) + const result = + await getConversationCommandComposition().service.regenerateTurn( + commandEnvelope({ + request, + actor, + scope: { type: "turn", id }, + expectedRevisionRequired: true, + payload: { + conversationId: conversationId(body.conversationId), + assistantMessageId: messageId(body.assistantMessageId), + generationId: generationId(body.generationId), + sourceAssistantMessageId: messageId(body.sourceAssistantMessageId), + modelId: body.modelId, + }, + }) + ) + return commandResponse(result, id) + }) +} diff --git a/constants/conversation-command.ts b/constants/conversation-command.ts new file mode 100644 index 00000000..aeb93c2f --- /dev/null +++ b/constants/conversation-command.ts @@ -0,0 +1,6 @@ +/** 规范 Conversation Command API 的传输与后台派发常量。 */ +export const CONVERSATION_COMMAND_SCHEMA_VERSION = 1 as const +export const CONVERSATION_COMMAND_IDEMPOTENCY_KEY_MAX_LENGTH = 200 +export const CONVERSATION_OUTBOX_DEFAULT_BATCH_SIZE = 25 +export const CONVERSATION_OUTBOX_MAX_ATTEMPTS = 8 +export const CONVERSATION_POLL_AFTER_MS = 1_000 diff --git a/drizzle/0009_marvelous_lady_bullseye.sql b/drizzle/0009_marvelous_lady_bullseye.sql new file mode 100644 index 00000000..d4f77f5b --- /dev/null +++ b/drizzle/0009_marvelous_lady_bullseye.sql @@ -0,0 +1,52 @@ +CREATE TABLE "thread_chat"."conversation_command_records" ( + "id" text PRIMARY KEY NOT NULL, + "actor_id" text NOT NULL, + "scope_type" text NOT NULL, + "scope_id" text NOT NULL, + "command_type" text NOT NULL, + "idempotency_key" text NOT NULL, + "payload_hash" text NOT NULL, + "result" jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "thread_chat"."conversation_outbox_events" ( + "id" text PRIMARY KEY NOT NULL, + "aggregate_type" text NOT NULL, + "aggregate_id" text NOT NULL, + "aggregate_revision" integer NOT NULL, + "type" text NOT NULL, + "schema_version" integer DEFAULT 1 NOT NULL, + "actor_id" text NOT NULL, + "payload" jsonb NOT NULL, + "status" text DEFAULT 'pending' NOT NULL, + "attempts" integer DEFAULT 0 NOT NULL, + "available_at" timestamp with time zone DEFAULT now() NOT NULL, + "claimed_by" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "dispatched_at" timestamp with time zone, + "last_error" text +); +--> statement-breakpoint +ALTER TABLE "thread_chat"."conversation_command_records" ADD CONSTRAINT "conversation_command_records_actor_id_user_id_fk" FOREIGN KEY ("actor_id") REFERENCES "thread_chat"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "thread_chat"."conversation_outbox_events" ADD CONSTRAINT "conversation_outbox_events_actor_id_user_id_fk" FOREIGN KEY ("actor_id") REFERENCES "thread_chat"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "conversation_command_records_scope_key_uq" ON "thread_chat"."conversation_command_records" USING btree ("actor_id","scope_type","scope_id","idempotency_key");--> statement-breakpoint +CREATE INDEX "conversation_command_records_actor_created_idx" ON "thread_chat"."conversation_command_records" USING btree ("actor_id","created_at");--> statement-breakpoint +CREATE INDEX "conversation_outbox_events_pending_idx" ON "thread_chat"."conversation_outbox_events" USING btree ("status","available_at","created_at");--> statement-breakpoint +CREATE INDEX "conversation_outbox_events_aggregate_idx" ON "thread_chat"."conversation_outbox_events" USING btree ("aggregate_type","aggregate_id","aggregate_revision"); +--> statement-breakpoint +ALTER TABLE "thread_chat"."conversation_command_records" + ADD CONSTRAINT "conversation_command_records_scope_type_ck" + CHECK (scope_type IN ('project', 'conversation', 'thread', 'turn', 'generation')), + ADD CONSTRAINT "conversation_command_records_identity_ck" + CHECK (length(idempotency_key) BETWEEN 1 AND 200 AND length(payload_hash) = 64);--> statement-breakpoint +ALTER TABLE "thread_chat"."conversation_outbox_events" + ADD CONSTRAINT "conversation_outbox_events_status_ck" + CHECK (status IN ('pending', 'processing', 'dispatched', 'failed')), + ADD CONSTRAINT "conversation_outbox_events_version_ck" + CHECK (aggregate_revision >= 0 AND schema_version > 0 AND attempts >= 0), + ADD CONSTRAINT "conversation_outbox_events_terminal_ck" + CHECK ( + (status = 'dispatched' AND dispatched_at IS NOT NULL) + OR (status <> 'dispatched' AND dispatched_at IS NULL) + ); diff --git a/drizzle/meta/0009_snapshot.json b/drizzle/meta/0009_snapshot.json new file mode 100644 index 00000000..1155e857 --- /dev/null +++ b/drizzle/meta/0009_snapshot.json @@ -0,0 +1,3181 @@ +{ + "id": "3d46c980-6a2c-44bd-87a9-3f6346c17fc5", + "prevId": "485c97a9-753a-445a-ad71-00a35bc882fb", + "version": "7", + "dialect": "postgresql", + "tables": { + "thread_chat.attachment_chunks": { + "name": "attachment_chunks", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "attachment_id": { + "name": "attachment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "page": { + "name": "page", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "attachment_chunks_attachment_id_idx": { + "name": "attachment_chunks_attachment_id_idx", + "columns": [ + { + "expression": "attachment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "attachment_chunks_embedding_idx": { + "name": "attachment_chunks_embedding_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": {} + } + }, + "foreignKeys": { + "attachment_chunks_attachment_id_attachments_id_fk": { + "name": "attachment_chunks_attachment_id_attachments_id_fk", + "tableFrom": "attachment_chunks", + "tableTo": "attachments", + "schemaTo": "thread_chat", + "columnsFrom": [ + "attachment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.attachments": { + "name": "attachments", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'uploading'" + }, + "page_count": { + "name": "page_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pages": { + "name": "pages", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "suggested_questions": { + "name": "suggested_questions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "attachments_key_unique": { + "name": "attachments_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.branch_generations": { + "name": "branch_generations", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tree_id": { + "name": "tree_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_message_id": { + "name": "user_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assistant_message_id": { + "name": "assistant_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assistant_message_index": { + "name": "assistant_message_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "turn_snapshot": { + "name": "turn_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_status": { + "name": "billing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "heartbeat_at": { + "name": "heartbeat_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "stop_requested_at": { + "name": "stop_requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "branch_generations_current_assistant_uq": { + "name": "branch_generations_current_assistant_uq", + "columns": [ + { + "expression": "tree_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assistant_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"thread_chat\".\"branch_generations\".\"is_current\" = true", + "concurrently": false, + "method": "btree", + "with": {} + }, + "branch_generations_assistant_attempt_uq": { + "name": "branch_generations_assistant_attempt_uq", + "columns": [ + { + "expression": "tree_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assistant_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "attempt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "branch_generations_user_id_idx": { + "name": "branch_generations_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "branch_generations_tree_current_idx": { + "name": "branch_generations_tree_current_idx", + "columns": [ + { + "expression": "tree_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_current", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "branch_generations_user_status_idx": { + "name": "branch_generations_user_status_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "branch_generations_heartbeat_idx": { + "name": "branch_generations_heartbeat_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "heartbeat_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "branch_generations_user_id_user_id_fk": { + "name": "branch_generations_user_id_user_id_fk", + "tableFrom": "branch_generations", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "branch_generations_tree_id_branch_trees_id_fk": { + "name": "branch_generations_tree_id_branch_trees_id_fk", + "tableFrom": "branch_generations", + "tableTo": "branch_trees", + "schemaTo": "thread_chat", + "columnsFrom": [ + "tree_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.branch_message_feedback": { + "name": "branch_message_feedback", + "schema": "thread_chat", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tree_id": { + "name": "tree_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "branch_message_feedback_tree_idx": { + "name": "branch_message_feedback_tree_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tree_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "branch_message_feedback_user_id_user_id_fk": { + "name": "branch_message_feedback_user_id_user_id_fk", + "tableFrom": "branch_message_feedback", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "branch_message_feedback_tree_id_branch_trees_id_fk": { + "name": "branch_message_feedback_tree_id_branch_trees_id_fk", + "tableFrom": "branch_message_feedback", + "tableTo": "branch_trees", + "schemaTo": "thread_chat", + "columnsFrom": [ + "tree_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "branch_message_feedback_pk": { + "name": "branch_message_feedback_pk", + "columns": [ + "user_id", + "tree_id", + "thread_id", + "message_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.branch_trees": { + "name": "branch_trees", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_title": { + "name": "custom_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "branch_trees_user_id_idx": { + "name": "branch_trees_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "branch_trees_user_id_user_id_fk": { + "name": "branch_trees_user_id_user_id_fk", + "tableFrom": "branch_trees", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.conversation_command_records": { + "name": "conversation_command_records", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_type": { + "name": "scope_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "command_type": { + "name": "command_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload_hash": { + "name": "payload_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversation_command_records_scope_key_uq": { + "name": "conversation_command_records_scope_key_uq", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_command_records_actor_created_idx": { + "name": "conversation_command_records_actor_created_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversation_command_records_actor_id_user_id_fk": { + "name": "conversation_command_records_actor_id_user_id_fk", + "tableFrom": "conversation_command_records", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.conversation_generations": { + "name": "conversation_generations", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_message_id": { + "name": "input_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "output_message_id": { + "name": "output_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "intent": { + "name": "intent", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_state": { + "name": "content_state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checkpoint_version": { + "name": "checkpoint_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "checkpoint": { + "name": "checkpoint", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "known_usage": { + "name": "known_usage", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "usage_completeness": { + "name": "usage_completeness", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_status": { + "name": "billing_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "paid_call_started": { + "name": "paid_call_started", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "lease_owner": { + "name": "lease_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_version": { + "name": "lease_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "heartbeat_at": { + "name": "heartbeat_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "stop_requested_at": { + "name": "stop_requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversation_generations_owner_idempotency_uq": { + "name": "conversation_generations_owner_idempotency_uq", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_generations_output_attempt_uq": { + "name": "conversation_generations_output_attempt_uq", + "columns": [ + { + "expression": "output_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "attempt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_generations_current_turn_uq": { + "name": "conversation_generations_current_turn_uq", + "columns": [ + { + "expression": "turn_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"thread_chat\".\"conversation_generations\".\"is_current\" = true", + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_generations_owner_status_idx": { + "name": "conversation_generations_owner_status_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_generations_conversation_updated_idx": { + "name": "conversation_generations_conversation_updated_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_generations_lease_idx": { + "name": "conversation_generations_lease_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "heartbeat_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversation_generations_owner_id_user_id_fk": { + "name": "conversation_generations_owner_id_user_id_fk", + "tableFrom": "conversation_generations", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.conversation_messages": { + "name": "conversation_messages", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "content_state": { + "name": "content_state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variant_of_message_id": { + "name": "variant_of_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversation_messages_id_thread_id_uq": { + "name": "conversation_messages_id_thread_id_uq", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_messages_id_thread_turn_uq": { + "name": "conversation_messages_id_thread_turn_uq", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "turn_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_messages_turn_created_idx": { + "name": "conversation_messages_turn_created_idx", + "columns": [ + { + "expression": "turn_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_messages_variant_source_idx": { + "name": "conversation_messages_variant_source_idx", + "columns": [ + { + "expression": "variant_of_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.conversation_outbox_events": { + "name": "conversation_outbox_events", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "aggregate_type": { + "name": "aggregate_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "aggregate_id": { + "name": "aggregate_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "aggregate_revision": { + "name": "aggregate_revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "available_at": { + "name": "available_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "dispatched_at": { + "name": "dispatched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "conversation_outbox_events_pending_idx": { + "name": "conversation_outbox_events_pending_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_outbox_events_aggregate_idx": { + "name": "conversation_outbox_events_aggregate_idx", + "columns": [ + { + "expression": "aggregate_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "aggregate_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "aggregate_revision", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversation_outbox_events_actor_id_user_id_fk": { + "name": "conversation_outbox_events_actor_id_user_id_fk", + "tableFrom": "conversation_outbox_events", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.conversation_threads": { + "name": "conversation_threads", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "local_title": { + "name": "local_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversation_threads_id_conversation_id_uq": { + "name": "conversation_threads_id_conversation_id_uq", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_threads_conversation_created_idx": { + "name": "conversation_threads_conversation_created_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversation_threads_conversation_id_conversations_id_fk": { + "name": "conversation_threads_conversation_id_conversations_id_fk", + "tableFrom": "conversation_threads", + "tableTo": "conversations", + "schemaTo": "thread_chat", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.conversation_turns": { + "name": "conversation_turns", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "active_user_message_id": { + "name": "active_user_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_assistant_message_id": { + "name": "active_assistant_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversation_turns_id_thread_id_uq": { + "name": "conversation_turns_id_thread_id_uq", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_turns_thread_position_uq": { + "name": "conversation_turns_thread_position_uq", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_turns_active_user_idx": { + "name": "conversation_turns_active_user_idx", + "columns": [ + { + "expression": "active_user_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_turns_active_assistant_idx": { + "name": "conversation_turns_active_assistant_idx", + "columns": [ + { + "expression": "active_assistant_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversation_turns_thread_id_conversation_threads_id_fk": { + "name": "conversation_turns_thread_id_conversation_threads_id_fk", + "tableFrom": "conversation_turns", + "tableTo": "conversation_threads", + "schemaTo": "thread_chat", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.conversations": { + "name": "conversations", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "root_thread_id": { + "name": "root_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auto_title": { + "name": "auto_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_title": { + "name": "custom_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversations_project_id_id_uq": { + "name": "conversations_project_id_id_uq", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversations_project_updated_idx": { + "name": "conversations_project_updated_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversations_project_lifecycle_idx": { + "name": "conversations_project_lifecycle_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lifecycle", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversations_project_id_projects_id_fk": { + "name": "conversations_project_id_projects_id_fk", + "tableFrom": "conversations", + "tableTo": "projects", + "schemaTo": "thread_chat", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.projects": { + "name": "projects", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "projects_workspace_id_id_uq": { + "name": "projects_workspace_id_id_uq", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "projects_workspace_lifecycle_idx": { + "name": "projects_workspace_lifecycle_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lifecycle", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_workspace_id_workspaces_id_fk": { + "name": "projects_workspace_id_workspaces_id_fk", + "tableFrom": "projects", + "tableTo": "workspaces", + "schemaTo": "thread_chat", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.thread_forks": { + "name": "thread_forks", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_thread_id": { + "name": "parent_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_message_id": { + "name": "source_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_thread_id": { + "name": "child_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "anchor": { + "name": "anchor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "thread_forks_child_thread_uq": { + "name": "thread_forks_child_thread_uq", + "columns": [ + { + "expression": "child_thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "thread_forks_parent_source_idx": { + "name": "thread_forks_parent_source_idx", + "columns": [ + { + "expression": "parent_thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "thread_forks_conversation_created_idx": { + "name": "thread_forks_conversation_created_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "thread_forks_conversation_id_conversations_id_fk": { + "name": "thread_forks_conversation_id_conversations_id_fk", + "tableFrom": "thread_forks", + "tableTo": "conversations", + "schemaTo": "thread_chat", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "thread_forks_created_by_user_id_fk": { + "name": "thread_forks_created_by_user_id_fk", + "tableFrom": "thread_forks", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.workspace_members": { + "name": "workspace_members", + "schema": "thread_chat", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_members_user_id_idx": { + "name": "workspace_members_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_members_workspace_id_workspaces_id_fk": { + "name": "workspace_members_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_members", + "tableTo": "workspaces", + "schemaTo": "thread_chat", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_members_user_id_user_id_fk": { + "name": "workspace_members_user_id_user_id_fk", + "tableFrom": "workspace_members", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_members_pk": { + "name": "workspace_members_pk", + "columns": [ + "workspace_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.workspaces": { + "name": "workspaces", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspaces_lifecycle_idx": { + "name": "workspaces_lifecycle_idx", + "columns": [ + { + "expression": "lifecycle", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.account": { + "name": "account", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.session": { + "name": "session", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.user": { + "name": "user", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.verification": { + "name": "verification", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.usage_records": { + "name": "usage_records", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "app_generation_id": { + "name": "app_generation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_micros": { + "name": "cost_micros", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "price_micros": { + "name": "price_micros", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "generation_id": { + "name": "generation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cost_source": { + "name": "cost_source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'estimate'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_records_user_id_idx": { + "name": "usage_records_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_records_thread_id_idx": { + "name": "usage_records_thread_id_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_records_cost_source_idx": { + "name": "usage_records_cost_source_idx", + "columns": [ + { + "expression": "cost_source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_records_app_generation_id_uq": { + "name": "usage_records_app_generation_id_uq", + "columns": [ + { + "expression": "app_generation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_records_user_id_user_id_fk": { + "name": "usage_records_user_id_user_id_fk", + "tableFrom": "usage_records", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.user_credits": { + "name": "user_credits", + "schema": "thread_chat", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "balance_micros": { + "name": "balance_micros", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_credits_user_id_user_id_fk": { + "name": "user_credits_user_id_user_id_fk", + "tableFrom": "user_credits", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.payments": { + "name": "payments", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'creem'" + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pack_id": { + "name": "pack_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checkout_id": { + "name": "checkout_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order_id": { + "name": "order_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "credit_micros": { + "name": "credit_micros", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "price_label": { + "name": "price_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "raw": { + "name": "raw", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "payments_user_id_idx": { + "name": "payments_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payments_provider_order_id_uq": { + "name": "payments_provider_order_id_uq", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payments_user_id_user_id_fk": { + "name": "payments_user_id_user_id_fk", + "tableFrom": "payments", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.subscriptions": { + "name": "subscriptions", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'creem'" + }, + "subscription_id": { + "name": "subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_period_end": { + "name": "current_period_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "raw": { + "name": "raw", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscriptions_user_id_idx": { + "name": "subscriptions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "subscriptions_user_id_user_id_fk": { + "name": "subscriptions_user_id_user_id_fk", + "tableFrom": "subscriptions", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "subscriptions_subscription_id_unique": { + "name": "subscriptions_subscription_id_unique", + "nullsNotDistinct": false, + "columns": [ + "subscription_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index d9f78e0e..5a8eb939 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1787335260786, "tag": "0008_noisy_random", "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1787336864651, + "tag": "0009_marvelous_lady_bullseye", + "breakpoints": true } ] } \ No newline at end of file diff --git a/lib/db/schema.ts b/lib/db/schema.ts index ea8ba3a8..ef4a095c 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -28,6 +28,7 @@ import type { MessageContent, MessageContentState, MessageRole, + JsonValue, } from "@/lib/thread-chat/domain/conversation-model" import type { ConversationGenerationCheckpoint, @@ -371,6 +372,76 @@ export const conversationGenerations = dbSchema.table( ] ) +export const conversationCommandRecords = dbSchema.table( + "conversation_command_records", + { + id: text("id").primaryKey(), + actorId: text("actor_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + scopeType: text("scope_type").notNull(), + scopeId: text("scope_id").notNull(), + commandType: text("command_type").notNull(), + idempotencyKey: text("idempotency_key").notNull(), + payloadHash: text("payload_hash").notNull(), + result: jsonb("result").$type().notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => [ + uniqueIndex("conversation_command_records_scope_key_uq").on( + table.actorId, + table.scopeType, + table.scopeId, + table.idempotencyKey + ), + index("conversation_command_records_actor_created_idx").on( + table.actorId, + table.createdAt + ), + ] +) + +export const conversationOutboxEvents = dbSchema.table( + "conversation_outbox_events", + { + id: text("id").primaryKey(), + aggregateType: text("aggregate_type").notNull(), + aggregateId: text("aggregate_id").notNull(), + aggregateRevision: integer("aggregate_revision").notNull(), + type: text("type").notNull(), + schemaVersion: integer("schema_version").notNull().default(1), + actorId: text("actor_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + payload: jsonb("payload").$type().notNull(), + status: text("status").notNull().default("pending"), + attempts: integer("attempts").notNull().default(0), + availableAt: timestamp("available_at", { withTimezone: true }) + .notNull() + .defaultNow(), + claimedBy: text("claimed_by"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + dispatchedAt: timestamp("dispatched_at", { withTimezone: true }), + lastError: text("last_error"), + }, + (table) => [ + index("conversation_outbox_events_pending_idx").on( + table.status, + table.availableAt, + table.createdAt + ), + index("conversation_outbox_events_aggregate_idx").on( + table.aggregateType, + table.aggregateId, + table.aggregateRevision + ), + ] +) + // 分支对话树(app/thread-chat)的整棵树持久化:一棵树一行,state 存完整 // ThreadTreeState(JSON)。与上面 assistant-ui 线性模型的 threads/messages 表分开, // 互不复用——那两张表是线性会话,这张是树形分支态。treeId 由客户端生成 diff --git a/lib/thread-chat/application/conversation-command-contracts.ts b/lib/thread-chat/application/conversation-command-contracts.ts new file mode 100644 index 00000000..cb85b390 --- /dev/null +++ b/lib/thread-chat/application/conversation-command-contracts.ts @@ -0,0 +1,210 @@ +import { createHash } from "node:crypto" + +import type { + Conversation, + ConversationGeneration, + ConversationId, + ConversationMessage, + ConversationSnapshot, + ConversationThread, + ConversationTurn, + GenerationId, + JsonValue, + MessageContent, + MessageId, + MessageRole, + ProjectId, + ThreadFork, + ThreadForkId, + ThreadId, + TurnId, +} from "../domain/conversation-model" +import type { TextAnchor } from "../domain/text-anchor" +import type { CanonicalGenerationRecord } from "./conversation-generation-service" + +export interface ConversationActor { + readonly kind: "user" + readonly userId: string +} + +export type CommandScope = + | { readonly type: "project"; readonly id: ProjectId } + | { readonly type: "conversation"; readonly id: ConversationId } + | { readonly type: "thread"; readonly id: ThreadId } + | { readonly type: "turn"; readonly id: TurnId } + | { readonly type: "generation"; readonly id: GenerationId } + +export interface CommandEnvelope { + readonly commandId: string + readonly actor: ConversationActor + readonly scope: CommandScope + readonly idempotencyKey: string + readonly expectedRevision?: number + readonly payload: TPayload +} + +export type ConversationCommandErrorCode = + | "invalid_request" + | "unauthenticated" + | "forbidden" + | "not_found" + | "version_conflict" + | "idempotency_conflict" + | "state_conflict" + | "semantic_validation" + | "fork_required" + | "conversation_action_required" + | "rate_limited" + | "internal" + +export interface ConversationCommandErrorDetails { + readonly currentRevision?: number + readonly retryable?: boolean + readonly field?: string + readonly reason?: string +} + +export class ConversationCommandError extends Error { + constructor( + readonly code: ConversationCommandErrorCode, + message: string, + readonly details: ConversationCommandErrorDetails = {} + ) { + super(message) + this.name = "ConversationCommandError" + } +} + +export interface CanonicalEntityDelta { + readonly upsert: { + readonly conversations?: readonly Conversation[] + readonly threads?: readonly ConversationThread[] + readonly threadForks?: readonly ThreadFork[] + readonly turns?: readonly ConversationTurn[] + readonly messages?: readonly ConversationMessage[] + readonly generations?: readonly ConversationGeneration[] + } + readonly remove: { + readonly conversations?: readonly ConversationId[] + readonly threads?: readonly ThreadId[] + readonly turns?: readonly TurnId[] + readonly messages?: readonly MessageId[] + readonly generations?: readonly GenerationId[] + } + readonly invalidate: readonly string[] +} + +export interface CommandSuccess { + readonly schemaVersion: 1 + readonly data: TData + readonly revisions: Readonly> + readonly delta: CanonicalEntityDelta + readonly replayed: boolean +} + +export interface ConversationListItem { + readonly id: ConversationId + readonly projectId: ProjectId + readonly rootThreadId: ThreadId + readonly title: string | null + readonly revision: number + readonly lifecycle: "active" | "archived" + readonly updatedAt: string +} + +export interface ConversationSnapshotResult { + readonly snapshot: ConversationSnapshot + readonly generations: readonly CanonicalGenerationRecord[] + /** 可重建读取投影;key 为 Thread ID,值为继承上下文 + 本地当前消息序列。 */ + readonly contextMessageIdsByThread: Readonly< + Record + > +} + +export interface CreateConversationPayload { + readonly conversationId: ConversationId + readonly rootThreadId: ThreadId + readonly title?: string | null + readonly modelId: string +} + +export interface RenamePayload { + readonly title: string +} + +export interface ForkThreadPayload { + readonly conversationId: ConversationId + readonly forkId: ThreadForkId + readonly childThreadId: ThreadId + readonly sourceMessageId: MessageId + readonly modelId: string + readonly localTitle?: string | null + readonly anchor?: TextAnchor +} + +export interface SendTurnPayload { + readonly conversationId: ConversationId + readonly turnId: TurnId + readonly userMessageId: MessageId + readonly assistantMessageId: MessageId + readonly generationId: GenerationId + readonly content: MessageContent + readonly modelId: string +} + +export interface EditTurnInputPayload { + readonly conversationId: ConversationId + readonly userMessageId: MessageId + readonly assistantMessageId: MessageId + readonly generationId: GenerationId + readonly sourceUserMessageId: MessageId + readonly content: MessageContent + readonly modelId: string +} + +export interface RegenerateTurnPayload { + readonly conversationId: ConversationId + readonly assistantMessageId: MessageId + readonly generationId: GenerationId + readonly sourceAssistantMessageId: MessageId + readonly modelId: string +} + +export interface SelectTurnVariantPayload { + readonly conversationId: ConversationId + readonly messageId: MessageId + readonly role: Extract +} + +export interface OutboxEvent { + readonly id: string + readonly aggregateType: string + readonly aggregateId: string + readonly aggregateRevision: number + readonly type: string + readonly schemaVersion: 1 + readonly actorId: string + readonly payload: JsonValue + readonly attempts: number +} + +function canonicalize(value: unknown): unknown { + if (Array.isArray(value)) return value.map(canonicalize) + if (value && typeof value === "object") + return Object.fromEntries( + Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, nested]) => [key, canonicalize(nested)]) + ) + return value +} + +export function commandPayloadHash(input: { + readonly commandType: string + readonly expectedRevision?: number + readonly payload: unknown +}): string { + return createHash("sha256") + .update(JSON.stringify(canonicalize(input))) + .digest("hex") +} diff --git a/lib/thread-chat/application/conversation-command-service.ts b/lib/thread-chat/application/conversation-command-service.ts new file mode 100644 index 00000000..36e4fa3d --- /dev/null +++ b/lib/thread-chat/application/conversation-command-service.ts @@ -0,0 +1,177 @@ +import type { + CanonicalGenerationRecord, + CanonicalGenerationRepository, +} from "./conversation-generation-service" +import type { + CommandEnvelope, + CommandSuccess, + ConversationListItem, + ConversationSnapshotResult, + CreateConversationPayload, + EditTurnInputPayload, + ForkThreadPayload, + OutboxEvent, + RegenerateTurnPayload, + RenamePayload, + SelectTurnVariantPayload, + SendTurnPayload, +} from "./conversation-command-contracts" +import type { + ConversationId, + GenerationId, + JsonValue, + ProjectId, +} from "../domain/conversation-model" + +export interface CommandCommit { + readonly result: CommandSuccess + readonly outboxEventIds: readonly string[] +} + +export interface ConversationCommandUnitOfWork { + createConversation( + command: CommandEnvelope + ): Promise + renameConversation( + command: CommandEnvelope + ): Promise + setConversationLifecycle( + command: CommandEnvelope<{ readonly lifecycle: "active" | "archived" }> + ): Promise + deleteConversation( + command: CommandEnvelope> + ): Promise + renameThread(command: CommandEnvelope): Promise + setThreadLifecycle( + command: CommandEnvelope<{ readonly lifecycle: "active" | "archived" }> + ): Promise + forkThread( + command: CommandEnvelope + ): Promise + sendTurn(command: CommandEnvelope): Promise + editTurnInput( + command: CommandEnvelope + ): Promise + regenerateTurn( + command: CommandEnvelope + ): Promise + selectTurnVariant( + command: CommandEnvelope + ): Promise +} + +export interface ConversationQueryPort { + listConversations(input: { + readonly actorUserId: string + readonly projectId: ProjectId + readonly includeArchived?: boolean + }): Promise + getConversationSnapshot(input: { + readonly actorUserId: string + readonly conversationId: ConversationId + }): Promise +} + +export interface OutboxEventConsumer { + consume(event: OutboxEvent): Promise +} + +export interface ConversationOutboxDispatcher { + schedule(eventIds: readonly string[]): void + dispatchPending(input?: { + readonly eventIds?: readonly string[] + readonly limit?: number + }): Promise +} + +export class ConversationCommandApplicationService { + constructor( + private readonly commands: ConversationCommandUnitOfWork, + private readonly queries: ConversationQueryPort, + private readonly generations: CanonicalGenerationRepository, + private readonly outbox: ConversationOutboxDispatcher + ) {} + + listConversations( + input: Parameters[0] + ) { + return this.queries.listConversations(input) + } + + getConversationSnapshot( + input: Parameters[0] + ) { + return this.queries.getConversationSnapshot(input) + } + + getGeneration(input: { + readonly actorUserId: string + readonly generationId: GenerationId + }): Promise { + return this.generations.getGeneration({ + ownerId: input.actorUserId, + generationId: input.generationId, + }) + } + + async stopGeneration(input: { + readonly actorUserId: string + readonly generationId: GenerationId + readonly notifyLocalAbort: (generationId: GenerationId) => void + }): Promise { + const generation = await this.generations.requestStop({ + ownerId: input.actorUserId, + generationId: input.generationId, + }) + if (generation?.status === "stop_requested") + input.notifyLocalAbort(input.generationId) + return generation + } + + createConversation(command: CommandEnvelope) { + return this.mutate(this.commands.createConversation(command)) + } + renameConversation(command: CommandEnvelope) { + return this.mutate(this.commands.renameConversation(command)) + } + setConversationLifecycle( + command: CommandEnvelope<{ readonly lifecycle: "active" | "archived" }> + ) { + return this.mutate(this.commands.setConversationLifecycle(command)) + } + deleteConversation(command: CommandEnvelope>) { + return this.mutate(this.commands.deleteConversation(command)) + } + renameThread(command: CommandEnvelope) { + return this.mutate(this.commands.renameThread(command)) + } + setThreadLifecycle( + command: CommandEnvelope<{ readonly lifecycle: "active" | "archived" }> + ) { + return this.mutate(this.commands.setThreadLifecycle(command)) + } + forkThread(command: CommandEnvelope) { + return this.mutate(this.commands.forkThread(command)) + } + sendTurn(command: CommandEnvelope) { + return this.mutate(this.commands.sendTurn(command)) + } + editTurnInput(command: CommandEnvelope) { + return this.mutate(this.commands.editTurnInput(command)) + } + regenerateTurn(command: CommandEnvelope) { + return this.mutate(this.commands.regenerateTurn(command)) + } + selectTurnVariant(command: CommandEnvelope) { + return this.mutate(this.commands.selectTurnVariant(command)) + } + + private async mutate( + commitPromise: Promise> + ): Promise> { + const commit = await commitPromise + if (!commit.result.replayed && commit.outboxEventIds.length > 0) + this.outbox.schedule(commit.outboxEventIds) + return commit.result + } +} diff --git a/lib/thread-chat/contracts/conversation-command-api.ts b/lib/thread-chat/contracts/conversation-command-api.ts new file mode 100644 index 00000000..f852520f --- /dev/null +++ b/lib/thread-chat/contracts/conversation-command-api.ts @@ -0,0 +1,164 @@ +import { z } from "zod" + +import { CONVERSATION_COMMAND_SCHEMA_VERSION } from "../../../constants/conversation-command" + +const entityIdSchema = z + .string() + .trim() + .min(1) + .max(200) + .refine((value) => !/\p{C}/u.test(value), "ID 不能包含控制字符") + +const jsonValueSchema: z.ZodType< + | string + | number + | boolean + | null + | readonly unknown[] + | { readonly [key: string]: unknown } +> = z.lazy(() => + z.union([ + z.string(), + z.number(), + z.boolean(), + z.null(), + z.array(jsonValueSchema), + z.record(z.string(), jsonValueSchema), + ]) +) + +export const messageContentTransportSchema = z.object({ + schemaVersion: z.literal(1), + parts: z.array( + z.union([ + z.object({ type: z.literal("text"), text: z.string() }).strict(), + z + .object({ + type: z.literal("artifact-reference"), + artifactId: entityIdSchema, + }) + .strict(), + z + .object({ + type: z.literal("structured"), + kind: z.string().min(1), + value: jsonValueSchema, + }) + .strict(), + ]) + ), +}) + +export const createConversationRequestSchema = z + .object({ + conversationId: entityIdSchema, + rootThreadId: entityIdSchema, + title: z.string().max(200).nullable().optional(), + modelId: z.string().trim().min(1).max(200), + }) + .strict() + +export const conversationPatchRequestSchema = z.union([ + z.object({ title: z.string().min(1).max(200) }).strict(), + z.object({ lifecycle: z.literal("archived") }).strict(), +]) + +export const threadPatchRequestSchema = conversationPatchRequestSchema + +export const forkThreadRequestSchema = z + .object({ + conversationId: entityIdSchema, + forkId: entityIdSchema, + childThreadId: entityIdSchema, + sourceMessageId: entityIdSchema, + modelId: z.string().trim().min(1).max(200), + localTitle: z.string().max(200).nullable().optional(), + anchor: z + .object({ + quote: z + .object({ + exact: z.string(), + prefix: z.string(), + suffix: z.string(), + }) + .strict(), + position: z + .object({ + start: z.number().int().nonnegative(), + end: z.number().int().nonnegative(), + }) + .strict() + .optional(), + }) + .strict() + .optional(), + }) + .strict() + +export const sendTurnRequestSchema = z + .object({ + conversationId: entityIdSchema, + turnId: entityIdSchema, + userMessageId: entityIdSchema, + assistantMessageId: entityIdSchema, + generationId: entityIdSchema, + content: messageContentTransportSchema, + modelId: z.string().trim().min(1).max(200), + }) + .strict() + +export const editTurnInputRequestSchema = z + .object({ + conversationId: entityIdSchema, + userMessageId: entityIdSchema, + assistantMessageId: entityIdSchema, + generationId: entityIdSchema, + sourceUserMessageId: entityIdSchema, + content: messageContentTransportSchema, + modelId: z.string().trim().min(1).max(200), + }) + .strict() + +export const regenerateTurnRequestSchema = z + .object({ + conversationId: entityIdSchema, + assistantMessageId: entityIdSchema, + generationId: entityIdSchema, + sourceAssistantMessageId: entityIdSchema, + modelId: z.string().trim().min(1).max(200), + }) + .strict() + +export const selectTurnVariantRequestSchema = z + .object({ + conversationId: entityIdSchema, + messageId: entityIdSchema, + role: z.enum(["user", "assistant"]), + }) + .strict() + +export const commandSuccessTransportSchema = z.object({ + schemaVersion: z.literal(CONVERSATION_COMMAND_SCHEMA_VERSION), + data: jsonValueSchema, + revisions: z.record(z.string(), z.number().int().nonnegative()), + delta: z.object({ + upsert: z.record(z.string(), z.array(z.unknown())), + remove: z.record(z.string(), z.array(entityIdSchema)), + invalidate: z.array(z.string()), + }), + replayed: z.boolean(), +}) + +export const querySuccessTransportSchema = z.object({ + schemaVersion: z.literal(CONVERSATION_COMMAND_SCHEMA_VERSION), + data: z.unknown(), +}) + +export const conversationErrorTransportSchema = z.object({ + error: z.object({ + code: z.string(), + message: z.string(), + requestId: z.string(), + details: z.record(z.string(), z.unknown()).optional(), + }), +}) diff --git a/lib/thread-chat/generation/canonical-generation-execution.ts b/lib/thread-chat/generation/canonical-generation-execution.ts index d6ada31c..ae2d03ab 100644 --- a/lib/thread-chat/generation/canonical-generation-execution.ts +++ b/lib/thread-chat/generation/canonical-generation-execution.ts @@ -174,6 +174,16 @@ export class CanonicalGenerationApplicationService { return { generation: started.generation, execution } } + /** Outbox 消费者在命令事务提交后接管已持久化的 Generation。 */ + executeExisting( + generation: CanonicalGenerationRecord, + leaseOwner: string + ): Promise { + if (generation.status !== "running" || generation.leaseOwner !== leaseOwner) + return Promise.resolve(generation) + return this.run(generation, leaseOwner) + } + async query(input: { readonly ownerId: string readonly generationId: GenerationId diff --git a/lib/thread-chat/http/conversation-command-composition.ts b/lib/thread-chat/http/conversation-command-composition.ts new file mode 100644 index 00000000..14ecb2c1 --- /dev/null +++ b/lib/thread-chat/http/conversation-command-composition.ts @@ -0,0 +1,90 @@ +import { randomUUID } from "node:crypto" + +import type { + ConversationOutboxDispatcher, + OutboxEventConsumer, +} from "../application/conversation-command-service" +import { ConversationCommandApplicationService } from "../application/conversation-command-service" +import { generationId, type GenerationId } from "../domain/conversation-model" +import { InMemoryGenerationAbortRegistry } from "../generation/canonical-generation-execution" +import { DrizzleConversationCommandStore } from "../persistence/drizzle-conversation-command-store" +import { DrizzleConversationGenerationRepository } from "../persistence/drizzle-conversation-generation-repository" +import { DrizzleConversationOutboxDispatcher } from "../persistence/drizzle-conversation-outbox-dispatcher" +import { resolveConversationCommandApiPolicy } from "../persistence/conversation-command-policy" + +declare global { + var __conversationCommandComposition: + | { + service: ConversationCommandApplicationService + dispatcher: ConversationOutboxDispatcher + abortRegistry: InMemoryGenerationAbortRegistry + } + | undefined + var __conversationOutboxConsumerOverride: OutboxEventConsumer | undefined +} + +const defaultConsumer: OutboxEventConsumer = { + async consume(event) { + if (event.type === "GenerationRequested") + throw new Error( + "规范 Generation executor 尚未接入公开组合根;事件保留待重试" + ) + // 审计/投影事件在没有外部消费者时可视为已处理;实体事务不依赖它。 + }, +} + +function createComposition() { + const policy = resolveConversationCommandApiPolicy() + const store = new DrizzleConversationCommandStore(policy) + const generations = new DrizzleConversationGenerationRepository({ + authority: policy.authority, + legacyAuthorityEnabled: + process.env.BRANCH_GENERATION_AUTHORITY_ENABLED !== "false", + }) + const abortRegistry = new InMemoryGenerationAbortRegistry() + const dispatcher = new DrizzleConversationOutboxDispatcher( + globalThis.__conversationOutboxConsumerOverride ?? defaultConsumer, + `http:${randomUUID()}` + ) + return { + service: new ConversationCommandApplicationService( + store, + store, + generations, + dispatcher + ), + dispatcher, + abortRegistry, + } +} + +export function getConversationCommandComposition() { + const composition = + globalThis.__conversationCommandComposition ?? createComposition() + if (process.env.NODE_ENV !== "production") + globalThis.__conversationCommandComposition = composition + return composition +} + +export function abortCanonicalGenerationLocally( + targetGenerationId: GenerationId +): void { + getConversationCommandComposition().abortRegistry.abort(targetGenerationId) +} + +/** 仅隔离契约测试使用;生产代码不得动态替换 outbox consumer。 */ +export function setConversationOutboxConsumerForIsolatedTest( + consumer: OutboxEventConsumer | null +): void { + if ( + process.env.CONVERSATION_COMMAND_API_AUTHORITY !== "isolated-test" || + process.env.NODE_ENV === "production" + ) + throw new Error("只能在 isolated-test authority 下替换 outbox consumer") + globalThis.__conversationOutboxConsumerOverride = consumer ?? undefined + globalThis.__conversationCommandComposition = undefined +} + +export function canonicalGenerationId(value: string) { + return generationId(value) +} diff --git a/lib/thread-chat/http/conversation-command-http.test.ts b/lib/thread-chat/http/conversation-command-http.test.ts new file mode 100644 index 00000000..18673561 --- /dev/null +++ b/lib/thread-chat/http/conversation-command-http.test.ts @@ -0,0 +1,103 @@ +import assert from "node:assert/strict" +import test from "node:test" + +import { + commandSuccessTransportSchema, + conversationErrorTransportSchema, + createConversationRequestSchema, +} from "../contracts/conversation-command-api.ts" +import { + ConversationCommandError, + type CommandSuccess, +} from "../application/conversation-command-contracts.ts" +import { projectId } from "../domain/conversation-model.ts" +import { + commandEnvelope, + commandResponse, + parseJson, + routeErrorResponse, +} from "./conversation-command-http.ts" + +test("传输 schema 拒绝客户端伪造 owner 和未知字段", async () => { + const request = new Request("http://localhost/api/projects/p/conversations", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + conversationId: "conversation-1", + rootThreadId: "thread-1", + modelId: "glm-5.3", + ownerId: "forged-owner", + }), + }) + await assert.rejects( + () => parseJson(request, createConversationRequestSchema), + (error: unknown) => + error instanceof ConversationCommandError && + error.code === "invalid_request" + ) +}) + +test("Header 映射要求幂等键,并解析 If-Match/command ID", () => { + const actor = { kind: "user" as const, userId: "user-1" } + const scope = { type: "project" as const, id: projectId("project-1") } + assert.throws( + () => + commandEnvelope({ + request: new Request("http://localhost"), + actor, + scope, + payload: {}, + }), + (error: unknown) => + error instanceof ConversationCommandError && + error.code === "invalid_request" + ) + const envelope = commandEnvelope({ + request: new Request("http://localhost", { + headers: { + "Idempotency-Key": "stable-key", + "If-Match": 'W/"7"', + "X-Command-Id": "command-1", + }, + }), + actor, + scope, + payload: { title: "标题" }, + expectedRevisionRequired: true, + }) + assert.equal(envelope.expectedRevision, 7) + assert.equal(envelope.commandId, "command-1") + assert.equal(envelope.idempotencyKey, "stable-key") +}) + +test("错误分类只把真实冲突映射为 409", async () => { + const invalid = routeErrorResponse( + new ConversationCommandError("invalid_request", "格式错误") + ) + const version = routeErrorResponse( + new ConversationCommandError("version_conflict", "版本冲突", { + currentRevision: 3, + retryable: true, + }) + ) + const semantic = routeErrorResponse( + new ConversationCommandError("semantic_validation", "语义错误") + ) + assert.equal(invalid.status, 400) + assert.equal(version.status, 409) + assert.equal(semantic.status, 422) + conversationErrorTransportSchema.parse(await version.json()) +}) + +test("成功 envelope 按显式并发作用域返回 ETag", async () => { + const result: CommandSuccess = { + schemaVersion: 1, + data: { conversationId: "conversation-1" }, + revisions: { "conversation-1": 2, "thread-1": 7, "turn-1": 0 }, + delta: { upsert: {}, remove: {}, invalidate: [] }, + replayed: false, + } + const response = commandResponse(result, "thread-1") + assert.equal(response.headers.get("etag"), '"7"') + commandSuccessTransportSchema.parse(await response.json()) +}) diff --git a/lib/thread-chat/http/conversation-command-http.ts b/lib/thread-chat/http/conversation-command-http.ts new file mode 100644 index 00000000..e70ebf1e --- /dev/null +++ b/lib/thread-chat/http/conversation-command-http.ts @@ -0,0 +1,173 @@ +import { randomUUID } from "node:crypto" + +import type { z } from "zod" + +import { + CONVERSATION_COMMAND_IDEMPOTENCY_KEY_MAX_LENGTH, + CONVERSATION_COMMAND_SCHEMA_VERSION, +} from "../../../constants/conversation-command" +import { getCurrentUserId } from "../../auth/server" +import { + ConversationCommandError, + type CommandSuccess, + type CommandEnvelope, + type CommandScope, +} from "../application/conversation-command-contracts" +import { CanonicalGenerationServiceError } from "../application/conversation-generation-service" +import { InvalidEntityIdError } from "../domain/conversation-model" + +const ERROR_STATUS: Record = { + invalid_request: 400, + unauthenticated: 401, + forbidden: 403, + not_found: 404, + version_conflict: 409, + idempotency_conflict: 409, + state_conflict: 409, + conversation_action_required: 409, + fork_required: 422, + semantic_validation: 422, + rate_limited: 429, + internal: 500, +} + +export async function authenticatedActor() { + const userId = await getCurrentUserId() + if (!userId) throw new ConversationCommandError("unauthenticated", "请先登录") + return { kind: "user" as const, userId } +} + +export async function parseJson( + request: Request, + schema: TSchema +): Promise> { + let value: unknown + try { + value = await request.json() + } catch { + throw new ConversationCommandError("invalid_request", "body 必须是 JSON") + } + const parsed = schema.safeParse(value) + if (!parsed.success) + throw new ConversationCommandError( + "invalid_request", + "请求载荷不符合 schema", + { + reason: parsed.error.issues + .slice(0, 3) + .map((issue) => `${issue.path.join(".")}: ${issue.message}`) + .join("; "), + } + ) + return parsed.data +} + +export function commandEnvelope(input: { + readonly request: Request + readonly actor: { readonly kind: "user"; readonly userId: string } + readonly scope: CommandScope + readonly payload: TPayload + readonly expectedRevisionRequired?: boolean +}): CommandEnvelope { + const idempotencyKey = input.request.headers.get("Idempotency-Key")?.trim() + if ( + !idempotencyKey || + idempotencyKey.length > CONVERSATION_COMMAND_IDEMPOTENCY_KEY_MAX_LENGTH + ) + throw new ConversationCommandError( + "invalid_request", + `Idempotency-Key 必须为 1–${CONVERSATION_COMMAND_IDEMPOTENCY_KEY_MAX_LENGTH} 字符`, + { field: "Idempotency-Key" } + ) + const expectedRevision = parseIfMatch(input.request.headers.get("If-Match")) + if (input.expectedRevisionRequired && expectedRevision === undefined) + throw new ConversationCommandError( + "invalid_request", + "该命令必须提供 If-Match revision", + { field: "If-Match" } + ) + return { + commandId: + input.request.headers.get("X-Command-Id")?.trim() || randomUUID(), + actor: input.actor, + scope: input.scope, + idempotencyKey, + ...(expectedRevision !== undefined ? { expectedRevision } : {}), + payload: input.payload, + } +} + +function parseIfMatch(value: string | null): number | undefined { + if (!value) return undefined + const normalized = value.trim().replace(/^W\//, "").replace(/^"|"$/g, "") + if (!/^\d+$/.test(normalized)) + throw new ConversationCommandError( + "invalid_request", + "If-Match 必须是非负整数 revision", + { + field: "If-Match", + } + ) + return Number(normalized) +} + +export function commandResponse( + result: CommandSuccess, + revisionScopeId: string +): Response { + const revision = result.revisions[revisionScopeId] + return Response.json(result, { + headers: revision === undefined ? undefined : { ETag: `"${revision}"` }, + }) +} + +export function queryResponse(data: unknown, revision?: number): Response { + return Response.json( + { schemaVersion: CONVERSATION_COMMAND_SCHEMA_VERSION, data }, + { headers: revision === undefined ? undefined : { ETag: `"${revision}"` } } + ) +} + +export function routeErrorResponse(error: unknown): Response { + const requestId = randomUUID() + let normalized: ConversationCommandError + if (error instanceof ConversationCommandError) normalized = error + else if (error instanceof CanonicalGenerationServiceError) + normalized = new ConversationCommandError( + error.code === "not_found" + ? "not_found" + : error.code.includes("conflict") + ? "state_conflict" + : "internal", + error.code === "not_found" ? "资源不存在" : "Generation 命令失败" + ) + else if (error instanceof InvalidEntityIdError) + normalized = new ConversationCommandError("invalid_request", error.message) + else { + console.error("[conversation-command-api] 未处理错误", { requestId, error }) + normalized = new ConversationCommandError("internal", "服务器内部错误") + } + return Response.json( + { + error: { + code: normalized.code, + message: normalized.message, + requestId, + ...(Object.keys(normalized.details).length > 0 + ? { details: normalized.details } + : {}), + }, + }, + { status: ERROR_STATUS[normalized.code] ?? 500 } + ) +} + +export async function withConversationRoute( + handler: () => Promise +): Promise { + try { + return await handler() + } catch (error) { + return routeErrorResponse(error) + } +} diff --git a/lib/thread-chat/persistence/conversation-command-policy.ts b/lib/thread-chat/persistence/conversation-command-policy.ts new file mode 100644 index 00000000..13289821 --- /dev/null +++ b/lib/thread-chat/persistence/conversation-command-policy.ts @@ -0,0 +1,51 @@ +import { ConversationCommandError } from "../application/conversation-command-contracts" + +export type ConversationCommandApiAuthority = + "disabled" | "isolated-test" | "canonical" + +export interface ConversationCommandApiPolicy { + readonly authority: ConversationCommandApiAuthority + readonly legacyAuthorityEnabled: boolean +} + +export function resolveConversationCommandApiPolicy( + environment: NodeJS.ProcessEnv = process.env +): ConversationCommandApiPolicy { + const value = environment.CONVERSATION_COMMAND_API_AUTHORITY?.trim() + const authority: ConversationCommandApiAuthority = + !value || value === "disabled" + ? "disabled" + : value === "isolated-test" || value === "canonical" + ? value + : (() => { + throw new Error(`未知 CONVERSATION_COMMAND_API_AUTHORITY:${value}`) + })() + return { + authority, + legacyAuthorityEnabled: + environment.BRANCH_TREE_AUTHORITY_ENABLED !== "false", + } +} + +export function assertConversationCommandApiEnabled( + policy: ConversationCommandApiPolicy +): void { + if (policy.authority === "disabled") + throw new ConversationCommandError( + "not_found", + "规范 Conversation Command API 尚未启用" + ) + if ( + policy.authority === "isolated-test" && + process.env.NODE_ENV === "production" + ) + throw new ConversationCommandError( + "not_found", + "生产环境不能启用 isolated-test Conversation API" + ) + if (policy.authority === "canonical" && policy.legacyAuthorityEnabled) + throw new ConversationCommandError( + "state_conflict", + "规范命令不能与遗留整树写入权威同时启用" + ) +} diff --git a/lib/thread-chat/persistence/drizzle-conversation-command-store.ts b/lib/thread-chat/persistence/drizzle-conversation-command-store.ts new file mode 100644 index 00000000..7271c2f1 --- /dev/null +++ b/lib/thread-chat/persistence/drizzle-conversation-command-store.ts @@ -0,0 +1,1834 @@ +import { randomUUID } from "node:crypto" + +import { and, asc, eq, inArray, max, sql } from "drizzle-orm" + +import { CONVERSATION_COMMAND_SCHEMA_VERSION } from "../../../constants/conversation-command" +import { db } from "../../db" +import { + conversationCommandRecords, + conversationGenerations, + conversationMessages, + conversationOutboxEvents, + conversationThreads, + conversationTurns, + conversations, + projects, + threadForks, + workspaceMembers, +} from "../../db/schema" +import type { + CommandCommit, + ConversationCommandUnitOfWork, + ConversationQueryPort, +} from "../application/conversation-command-service" +import { + commandPayloadHash, + ConversationCommandError, + type CanonicalEntityDelta, + type CommandEnvelope, + type CommandSuccess, + type ConversationListItem, + type ConversationSnapshotResult, + type CreateConversationPayload, + type EditTurnInputPayload, + type ForkThreadPayload, + type RegenerateTurnPayload, + type RenamePayload, + type SelectTurnVariantPayload, + type SendTurnPayload, +} from "../application/conversation-command-contracts" +import { + emptyConversationGenerationCheckpoint, + hasRecoverableCheckpointOutput, + parseConversationGenerationCheckpoint, +} from "../domain/conversation-generation" +import { + conversationId, + generationId, + messageId, + projectId, + threadForkId, + threadId, + turnId, + workspaceId, + type Conversation, + type ConversationGeneration, + type ConversationMessage, + type ConversationSnapshot, + type ConversationThread, + type ConversationTurn, + type GenerationIntent, + type GenerationId, + type JsonValue, + type MessageId, + type ThreadFork, + type ThreadId, +} from "../domain/conversation-model" +import { + assertConversationCommandApiEnabled, + resolveConversationCommandApiPolicy, + type ConversationCommandApiPolicy, +} from "./conversation-command-policy" + +type CommandTransaction = Parameters[0]>[0] + +const ACTIVE_GENERATION_STATUSES = ["running", "stop_requested"] as const + +interface CommandMutation { + readonly data: JsonValue + readonly revisions: Readonly> + readonly delta: CanonicalEntityDelta + readonly events?: readonly { + readonly id: string + readonly aggregateType: string + readonly aggregateId: string + readonly aggregateRevision: number + readonly type: string + readonly payload: JsonValue + }[] +} + +interface ConversationAccess { + readonly conversation: typeof conversations.$inferSelect + readonly project: typeof projects.$inferSelect +} + +interface ThreadAccess extends ConversationAccess { + readonly thread: typeof conversationThreads.$inferSelect +} + +function mapConversation(row: typeof conversations.$inferSelect): Conversation { + return { + id: conversationId(row.id), + projectId: projectId(row.projectId), + rootThreadId: threadId(row.rootThreadId), + autoTitle: row.autoTitle, + customTitle: row.customTitle, + revision: row.revision, + lifecycle: row.lifecycle, + } +} + +function mapThread( + row: typeof conversationThreads.$inferSelect +): ConversationThread { + return { + id: threadId(row.id), + conversationId: conversationId(row.conversationId), + modelId: row.modelId, + localTitle: row.localTitle, + revision: row.revision, + lifecycle: row.lifecycle, + } +} + +function mapTurn(row: typeof conversationTurns.$inferSelect): ConversationTurn { + return { + id: turnId(row.id), + threadId: threadId(row.threadId), + position: row.position, + activeUserMessageId: messageId(row.activeUserMessageId), + activeAssistantMessageId: messageId(row.activeAssistantMessageId), + revision: row.revision, + } +} + +function mapMessage( + row: typeof conversationMessages.$inferSelect +): ConversationMessage { + return { + id: messageId(row.id), + threadId: threadId(row.threadId), + turnId: turnId(row.turnId), + role: row.role, + content: row.content, + contentState: row.contentState, + ...(row.variantOfMessageId + ? { variantOfMessageId: messageId(row.variantOfMessageId) } + : {}), + createdAt: row.createdAt.toISOString(), + } +} + +function mapFork(row: typeof threadForks.$inferSelect): ThreadFork { + return { + id: threadForkId(row.id), + conversationId: conversationId(row.conversationId), + parentThreadId: threadId(row.parentThreadId), + sourceMessageId: messageId(row.sourceMessageId), + childThreadId: threadId(row.childThreadId), + ...(row.anchor ? { anchor: row.anchor as ThreadFork["anchor"] } : {}), + createdBy: row.createdBy, + createdAt: row.createdAt.toISOString(), + } +} + +function mapGenerationSummary( + row: typeof conversationGenerations.$inferSelect +): ConversationGeneration { + return { + id: generationId(row.id), + threadId: threadId(row.threadId), + turnId: turnId(row.turnId), + inputMessageId: messageId(row.inputMessageId), + outputMessageId: messageId(row.outputMessageId), + intent: row.intent, + status: row.status, + billingStatus: row.billingStatus, + attempt: row.attempt, + createdAt: row.createdAt.toISOString(), + } +} + +function registry( + values: readonly T[] +): Readonly> { + return Object.fromEntries(values.map((value) => [value.id, value])) +} + +function asJson(value: unknown): JsonValue { + return value as JsonValue +} + +function assertExpectedRevision( + actual: number, + expected: number | undefined, + label: string +): void { + if (expected === undefined) + throw new ConversationCommandError( + "invalid_request", + `${label} 命令缺少 expectedRevision`, + { field: "expectedRevision" } + ) + if (actual !== expected) + throw new ConversationCommandError( + "version_conflict", + `${label} revision 已变化`, + { currentRevision: actual, retryable: true } + ) +} + +function normalizedTitle(value: string): string { + const title = value.trim() + if (title.length === 0 || title.length > 200) + throw new ConversationCommandError( + "semantic_validation", + "标题必须为 trim 后 1–200 字符", + { field: "title" } + ) + return title +} + +async function findProjectAccess( + transaction: CommandTransaction | typeof db, + actorUserId: string, + targetProjectId: string +) { + const [row] = await transaction + .select({ project: projects }) + .from(projects) + .innerJoin( + workspaceMembers, + and( + eq(workspaceMembers.workspaceId, projects.workspaceId), + eq(workspaceMembers.userId, actorUserId) + ) + ) + .where(eq(projects.id, targetProjectId)) + .limit(1) + return row?.project ?? null +} + +async function findConversationAccess( + transaction: CommandTransaction | typeof db, + actorUserId: string, + targetConversationId: string, + lockForUpdate = false +): Promise { + const query = transaction + .select({ conversation: conversations, project: projects }) + .from(conversations) + .innerJoin(projects, eq(projects.id, conversations.projectId)) + .innerJoin( + workspaceMembers, + and( + eq(workspaceMembers.workspaceId, projects.workspaceId), + eq(workspaceMembers.userId, actorUserId) + ) + ) + .where(eq(conversations.id, targetConversationId)) + .limit(1) + const [authorized] = await query + if (!authorized || !lockForUpdate) return authorized ?? null + await transaction + .select({ id: conversations.id }) + .from(conversations) + .where(eq(conversations.id, targetConversationId)) + .for("update") + const [fresh] = await query + return fresh ?? null +} + +interface ThreadAccessLocks { + readonly conversation: "share" | "update" + readonly thread: "share" | "update" +} + +async function findThreadAccess( + transaction: CommandTransaction | typeof db, + actorUserId: string, + targetThreadId: string, + locks?: ThreadAccessLocks +): Promise { + const query = transaction + .select({ + thread: conversationThreads, + conversation: conversations, + project: projects, + }) + .from(conversationThreads) + .innerJoin( + conversations, + eq(conversations.id, conversationThreads.conversationId) + ) + .innerJoin(projects, eq(projects.id, conversations.projectId)) + .innerJoin( + workspaceMembers, + and( + eq(workspaceMembers.workspaceId, projects.workspaceId), + eq(workspaceMembers.userId, actorUserId) + ) + ) + .where(eq(conversationThreads.id, targetThreadId)) + .limit(1) + const [authorized] = await query + if (!authorized || !locks) return authorized ?? null + await transaction + .select({ id: conversations.id }) + .from(conversations) + .where(eq(conversations.id, authorized.conversation.id)) + .for(locks.conversation) + await transaction + .select({ id: conversationThreads.id }) + .from(conversationThreads) + .where(eq(conversationThreads.id, targetThreadId)) + .for(locks.thread) + const [fresh] = await query + return fresh ?? null +} + +async function assertNoActiveGeneration( + transaction: CommandTransaction, + where: ReturnType +): Promise { + const [active] = await transaction + .select({ id: conversationGenerations.id }) + .from(conversationGenerations) + .where( + and( + where, + inArray(conversationGenerations.status, ACTIVE_GENERATION_STATUSES) + ) + ) + .limit(1) + if (active) + throw new ConversationCommandError( + "state_conflict", + "存在尚未终结的 Generation", + { reason: "generation_running" } + ) +} + +export class DrizzleConversationCommandStore + implements ConversationCommandUnitOfWork, ConversationQueryPort +{ + constructor( + readonly policy: ConversationCommandApiPolicy = resolveConversationCommandApiPolicy(), + private readonly now: () => Date = () => new Date(), + private readonly id: () => string = randomUUID + ) {} + + async listConversations(input: { + readonly actorUserId: string + readonly projectId: ReturnType + readonly includeArchived?: boolean + }): Promise { + assertConversationCommandApiEnabled(this.policy) + const project = await findProjectAccess( + db, + input.actorUserId, + input.projectId + ) + if (!project) return [] + const conditions = [eq(conversations.projectId, input.projectId)] + if (!input.includeArchived) + conditions.push(eq(conversations.lifecycle, "active")) + const rows = await db + .select() + .from(conversations) + .where(and(...conditions)) + .orderBy(asc(conversations.createdAt), asc(conversations.id)) + return rows.map((row) => ({ + id: conversationId(row.id), + projectId: projectId(row.projectId), + rootThreadId: threadId(row.rootThreadId), + title: row.customTitle ?? row.autoTitle, + revision: row.revision, + lifecycle: row.lifecycle, + updatedAt: row.updatedAt.toISOString(), + })) + } + + async getConversationSnapshot(input: { + readonly actorUserId: string + readonly conversationId: ReturnType + }): Promise { + assertConversationCommandApiEnabled(this.policy) + const access = await findConversationAccess( + db, + input.actorUserId, + input.conversationId + ) + if (!access) return null + const [threadRows, turnRows, messageRows, forkRows, generationRows] = + await Promise.all([ + db + .select() + .from(conversationThreads) + .where(eq(conversationThreads.conversationId, input.conversationId)) + .orderBy( + asc(conversationThreads.createdAt), + asc(conversationThreads.id) + ), + db + .select({ turn: conversationTurns }) + .from(conversationTurns) + .innerJoin( + conversationThreads, + eq(conversationThreads.id, conversationTurns.threadId) + ) + .where(eq(conversationThreads.conversationId, input.conversationId)) + .orderBy( + asc(conversationTurns.threadId), + asc(conversationTurns.position), + asc(conversationTurns.id) + ), + db + .select({ message: conversationMessages }) + .from(conversationMessages) + .innerJoin( + conversationThreads, + eq(conversationThreads.id, conversationMessages.threadId) + ) + .where(eq(conversationThreads.conversationId, input.conversationId)) + .orderBy( + asc(conversationMessages.threadId), + asc(conversationMessages.createdAt), + asc(conversationMessages.id) + ), + db + .select() + .from(threadForks) + .where(eq(threadForks.conversationId, input.conversationId)) + .orderBy(asc(threadForks.createdAt), asc(threadForks.id)), + db + .select() + .from(conversationGenerations) + .where( + eq(conversationGenerations.conversationId, input.conversationId) + ) + .orderBy( + asc(conversationGenerations.createdAt), + asc(conversationGenerations.id) + ), + ]) + const mappedThreads = threadRows.map(mapThread) + const mappedTurns = turnRows.map(({ turn }) => mapTurn(turn)) + const mappedMessages = messageRows.map(({ message }) => mapMessage(message)) + const mappedForks = forkRows.map(mapFork) + const mappedGenerations = generationRows.map(mapGenerationSummary) + const snapshot: ConversationSnapshot = { + schemaVersion: 1, + project: { + id: projectId(access.project.id), + workspaceId: workspaceId(access.project.workspaceId), + title: access.project.title, + revision: access.project.revision, + lifecycle: access.project.lifecycle, + }, + conversation: mapConversation(access.conversation), + threads: registry(mappedThreads), + threadForks: registry(mappedForks), + turns: registry(mappedTurns), + messages: registry(mappedMessages), + generations: registry(mappedGenerations), + artifactProvenance: {}, + } + return { + snapshot, + generations: generationRows.map((row) => ({ + ...mapGenerationSummary(row), + ownerId: row.ownerId, + workspaceId: workspaceId(row.workspaceId), + projectId: projectId(row.projectId), + conversationId: conversationId(row.conversationId), + requestHash: row.requestHash, + idempotencyKey: row.idempotencyKey, + modelId: row.modelId, + isCurrent: row.isCurrent, + contentState: row.contentState, + checkpointVersion: row.checkpointVersion, + checkpoint: parseConversationGenerationCheckpoint(row.checkpoint), + knownUsage: row.knownUsage, + usageCompleteness: row.usageCompleteness, + paidCallStarted: row.paidCallStarted, + leaseOwner: row.leaseOwner, + leaseVersion: row.leaseVersion, + heartbeatAt: row.heartbeatAt.toISOString(), + stopRequestedAt: row.stopRequestedAt?.toISOString() ?? null, + startedAt: row.startedAt.toISOString(), + finishedAt: row.finishedAt?.toISOString() ?? null, + errorCode: row.errorCode, + })), + contextMessageIdsByThread: deriveContextMessageIds({ + threads: mappedThreads, + turns: mappedTurns, + messages: mappedMessages, + forks: mappedForks, + }), + } + } + + createConversation( + command: CommandEnvelope + ): Promise { + return this.execute("createConversation", command, async (transaction) => { + if (command.scope.type !== "project") + throw new ConversationCommandError( + "invalid_request", + "命令 scope 必须是 Project" + ) + const project = await findProjectAccess( + transaction, + command.actor.userId, + command.scope.id + ) + if (!project) + throw new ConversationCommandError("not_found", "Project 不存在") + if (project.lifecycle !== "active") + throw new ConversationCommandError("state_conflict", "Project 已归档") + if ( + String(command.payload.conversationId) === + String(command.payload.rootThreadId) + ) + throw new ConversationCommandError( + "semantic_validation", + "Conversation 与根 Thread 必须使用不同稳定 ID" + ) + const title = command.payload.title?.trim() || null + const now = this.now() + await transaction.insert(conversations).values({ + id: command.payload.conversationId, + projectId: command.scope.id, + rootThreadId: command.payload.rootThreadId, + autoTitle: title, + customTitle: null, + revision: 0, + lifecycle: "active", + createdAt: now, + updatedAt: now, + }) + await transaction.insert(conversationThreads).values({ + id: command.payload.rootThreadId, + conversationId: command.payload.conversationId, + modelId: command.payload.modelId, + localTitle: null, + revision: 0, + lifecycle: "active", + createdAt: now, + updatedAt: now, + }) + const conversation = mapConversation({ + id: command.payload.conversationId, + projectId: command.scope.id, + rootThreadId: command.payload.rootThreadId, + autoTitle: title, + customTitle: null, + revision: 0, + lifecycle: "active", + createdAt: now, + updatedAt: now, + }) + const rootThread = mapThread({ + id: command.payload.rootThreadId, + conversationId: command.payload.conversationId, + modelId: command.payload.modelId, + localTitle: null, + revision: 0, + lifecycle: "active", + createdAt: now, + updatedAt: now, + }) + return { + data: asJson({ + conversationId: conversation.id, + rootThreadId: rootThread.id, + }), + revisions: { [conversation.id]: 0, [rootThread.id]: 0 }, + delta: { + upsert: { conversations: [conversation], threads: [rootThread] }, + remove: {}, + invalidate: [`project:${command.scope.id}:conversations`], + }, + } + }) + } + + renameConversation( + command: CommandEnvelope + ): Promise { + return this.mutateConversation( + "renameConversation", + command, + async (transaction, access) => { + const title = normalizedTitle(command.payload.title) + const nextRevision = access.conversation.revision + 1 + const now = this.now() + await transaction + .update(conversations) + .set({ customTitle: title, revision: nextRevision, updatedAt: now }) + .where(eq(conversations.id, access.conversation.id)) + const updated = mapConversation({ + ...access.conversation, + customTitle: title, + revision: nextRevision, + updatedAt: now, + }) + return { + data: asJson({ conversationId: updated.id }), + revisions: { [updated.id]: nextRevision }, + delta: { + upsert: { conversations: [updated] }, + remove: {}, + invalidate: [`project:${updated.projectId}:conversations`], + }, + } + } + ) + } + + setConversationLifecycle( + command: CommandEnvelope<{ readonly lifecycle: "active" | "archived" }> + ): Promise { + return this.mutateConversation( + "setConversationLifecycle", + command, + async (transaction, access) => { + if (access.conversation.lifecycle === command.payload.lifecycle) + return conversationNoopMutation(access.conversation) + if (command.payload.lifecycle === "archived") + await assertNoActiveGeneration( + transaction, + eq(conversationGenerations.conversationId, access.conversation.id) + ) + const nextRevision = access.conversation.revision + 1 + const now = this.now() + await transaction + .update(conversations) + .set({ + lifecycle: command.payload.lifecycle, + revision: nextRevision, + updatedAt: now, + }) + .where(eq(conversations.id, access.conversation.id)) + const updated = mapConversation({ + ...access.conversation, + lifecycle: command.payload.lifecycle, + revision: nextRevision, + updatedAt: now, + }) + return { + data: asJson({ + conversationId: updated.id, + lifecycle: updated.lifecycle, + }), + revisions: { [updated.id]: nextRevision }, + delta: { + upsert: { conversations: [updated] }, + remove: {}, + invalidate: [`project:${updated.projectId}:conversations`], + }, + } + } + ) + } + + deleteConversation( + command: CommandEnvelope> + ): Promise { + return this.execute("deleteConversation", command, async (transaction) => { + if (command.scope.type !== "conversation") + throw new ConversationCommandError( + "invalid_request", + "命令 scope 必须是 Conversation" + ) + const access = await findConversationAccess( + transaction, + command.actor.userId, + command.scope.id, + true + ) + if (!access) + throw new ConversationCommandError("not_found", "Conversation 不存在") + assertExpectedRevision( + access.conversation.revision, + command.expectedRevision, + "Conversation" + ) + await assertNoActiveGeneration( + transaction, + eq(conversationGenerations.conversationId, access.conversation.id) + ) + await transaction + .delete(conversationGenerations) + .where( + eq(conversationGenerations.conversationId, access.conversation.id) + ) + await transaction + .delete(conversations) + .where(eq(conversations.id, access.conversation.id)) + return { + data: asJson({ conversationId: access.conversation.id, deleted: true }), + revisions: {}, + delta: { + upsert: {}, + remove: { conversations: [conversationId(access.conversation.id)] }, + invalidate: [`project:${access.project.id}:conversations`], + }, + } + }) + } + + renameThread( + command: CommandEnvelope + ): Promise { + return this.mutateThread( + "renameThread", + command, + async (transaction, access) => { + if (access.thread.id === access.conversation.rootThreadId) + throw new ConversationCommandError( + "conversation_action_required", + "根 Thread 标题由 Conversation 管理" + ) + const title = normalizedTitle(command.payload.title) + const nextRevision = access.thread.revision + 1 + const now = this.now() + await transaction + .update(conversationThreads) + .set({ localTitle: title, revision: nextRevision, updatedAt: now }) + .where(eq(conversationThreads.id, access.thread.id)) + const updated = mapThread({ + ...access.thread, + localTitle: title, + revision: nextRevision, + updatedAt: now, + }) + return threadMutation(updated, access.conversation.id) + } + ) + } + + setThreadLifecycle( + command: CommandEnvelope<{ readonly lifecycle: "active" | "archived" }> + ): Promise { + return this.mutateThread( + "setThreadLifecycle", + command, + async (transaction, access) => { + if (access.thread.id === access.conversation.rootThreadId) + throw new ConversationCommandError( + "conversation_action_required", + "根 Thread 必须通过 Conversation 生命周期命令管理" + ) + if (access.thread.lifecycle === command.payload.lifecycle) + return threadMutation( + mapThread(access.thread), + access.conversation.id + ) + if (command.payload.lifecycle === "archived") + await assertNoActiveGeneration( + transaction, + eq(conversationGenerations.threadId, access.thread.id) + ) + const nextRevision = access.thread.revision + 1 + const now = this.now() + await transaction + .update(conversationThreads) + .set({ + lifecycle: command.payload.lifecycle, + revision: nextRevision, + updatedAt: now, + }) + .where(eq(conversationThreads.id, access.thread.id)) + return threadMutation( + mapThread({ + ...access.thread, + lifecycle: command.payload.lifecycle, + revision: nextRevision, + updatedAt: now, + }), + access.conversation.id + ) + } + ) + } + + forkThread( + command: CommandEnvelope + ): Promise { + return this.execute("forkThread", command, async (transaction) => { + if (command.scope.type !== "thread") + throw new ConversationCommandError( + "invalid_request", + "命令 scope 必须是 Thread" + ) + const access = await findThreadAccess( + transaction, + command.actor.userId, + command.scope.id, + { conversation: "update", thread: "share" } + ) + if (!access || access.conversation.id !== command.payload.conversationId) + throw new ConversationCommandError("not_found", "Thread 不存在") + assertExpectedRevision( + access.conversation.revision, + command.expectedRevision, + "Conversation" + ) + if ( + access.thread.lifecycle !== "active" || + access.conversation.lifecycle !== "active" + ) + throw new ConversationCommandError( + "state_conflict", + "归档资源不能 Fork" + ) + const [source] = await transaction + .select({ message: conversationMessages, turn: conversationTurns }) + .from(conversationMessages) + .innerJoin( + conversationTurns, + eq(conversationTurns.id, conversationMessages.turnId) + ) + .where( + and( + eq(conversationMessages.id, command.payload.sourceMessageId), + eq(conversationMessages.threadId, access.thread.id) + ) + ) + .limit(1) + if ( + !source || + (source.message.role === "user" && + source.turn.activeUserMessageId !== source.message.id) || + (source.message.role === "assistant" && + source.turn.activeAssistantMessageId !== source.message.id) || + !["complete", "incomplete"].includes(source.message.contentState) + ) + throw new ConversationCommandError( + "semantic_validation", + "Fork 来源必须是上游 Thread 当前可用路径中的 Message" + ) + const now = this.now() + await transaction.insert(conversationThreads).values({ + id: command.payload.childThreadId, + conversationId: access.conversation.id, + modelId: command.payload.modelId, + localTitle: command.payload.localTitle?.trim() || null, + revision: 0, + lifecycle: "active", + createdAt: now, + updatedAt: now, + }) + await transaction.insert(threadForks).values({ + id: command.payload.forkId, + conversationId: access.conversation.id, + parentThreadId: access.thread.id, + sourceMessageId: source.message.id, + childThreadId: command.payload.childThreadId, + anchor: command.payload.anchor, + createdBy: command.actor.userId, + createdAt: now, + }) + const nextRevision = access.conversation.revision + 1 + await transaction + .update(conversations) + .set({ revision: nextRevision, updatedAt: now }) + .where(eq(conversations.id, access.conversation.id)) + const child = mapThread({ + id: command.payload.childThreadId, + conversationId: access.conversation.id, + modelId: command.payload.modelId, + localTitle: command.payload.localTitle?.trim() || null, + revision: 0, + lifecycle: "active", + createdAt: now, + updatedAt: now, + }) + const fork = mapFork({ + id: command.payload.forkId, + conversationId: access.conversation.id, + parentThreadId: access.thread.id, + sourceMessageId: source.message.id, + childThreadId: child.id, + anchor: command.payload.anchor ?? null, + createdBy: command.actor.userId, + createdAt: now, + }) + const eventId = this.id() + return { + data: asJson({ childThreadId: child.id, forkId: fork.id }), + revisions: { [access.conversation.id]: nextRevision, [child.id]: 0 }, + delta: { + upsert: { threads: [child], threadForks: [fork] }, + remove: {}, + invalidate: [ + `conversation:${access.conversation.id}:topology`, + `thread:${child.id}:context`, + ], + }, + events: [ + { + id: eventId, + aggregateType: "conversation", + aggregateId: access.conversation.id, + aggregateRevision: nextRevision, + type: "ThreadForked", + payload: asJson({ + threadId: child.id, + parentThreadId: access.thread.id, + sourceMessageId: source.message.id, + }), + }, + ], + } + }) + } + + sendTurn(command: CommandEnvelope): Promise { + return this.execute("sendTurn", command, async (transaction) => { + if (command.scope.type !== "thread") + throw new ConversationCommandError( + "invalid_request", + "命令 scope 必须是 Thread" + ) + const access = await findThreadAccess( + transaction, + command.actor.userId, + command.scope.id, + { conversation: "share", thread: "update" } + ) + if (!access || access.conversation.id !== command.payload.conversationId) + throw new ConversationCommandError("not_found", "Thread 不存在") + assertExpectedRevision( + access.thread.revision, + command.expectedRevision, + "Thread" + ) + if ( + access.thread.lifecycle !== "active" || + access.conversation.lifecycle !== "active" + ) + throw new ConversationCommandError( + "state_conflict", + "归档资源不能发送消息" + ) + const [positionRow] = await transaction + .select({ value: max(conversationTurns.position) }) + .from(conversationTurns) + .where(eq(conversationTurns.threadId, access.thread.id)) + const position = (positionRow?.value ?? -1) + 1 + const now = this.now() + const userMessage = mapMessage({ + id: command.payload.userMessageId, + threadId: access.thread.id, + turnId: command.payload.turnId, + role: "user", + content: command.payload.content, + contentState: "complete", + variantOfMessageId: null, + createdAt: now, + }) + const assistantMessage = mapMessage({ + id: command.payload.assistantMessageId, + threadId: access.thread.id, + turnId: command.payload.turnId, + role: "assistant", + content: { schemaVersion: 1, parts: [] }, + contentState: "pending", + variantOfMessageId: null, + createdAt: now, + }) + const turn: ConversationTurn = { + id: command.payload.turnId, + threadId: threadId(access.thread.id), + position, + activeUserMessageId: userMessage.id, + activeAssistantMessageId: assistantMessage.id, + revision: 0, + } + await transaction.insert(conversationTurns).values({ + id: turn.id, + threadId: turn.threadId, + position, + activeUserMessageId: userMessage.id, + activeAssistantMessageId: assistantMessage.id, + revision: 0, + createdAt: now, + updatedAt: now, + }) + await transaction.insert(conversationMessages).values([ + { + id: userMessage.id, + threadId: userMessage.threadId, + turnId: userMessage.turnId, + role: "user", + content: userMessage.content, + contentState: "complete", + createdAt: now, + }, + { + id: assistantMessage.id, + threadId: assistantMessage.threadId, + turnId: assistantMessage.turnId, + role: "assistant", + content: assistantMessage.content, + contentState: "pending", + createdAt: now, + }, + ]) + const eventId = this.id() + const generation = await insertGeneration(transaction, { + access, + actorUserId: command.actor.userId, + generationId: command.payload.generationId, + turnId: turn.id, + inputMessageId: userMessage.id, + outputMessageId: assistantMessage.id, + intent: { kind: "send" }, + requestHash: commandPayloadHash({ + commandType: "sendTurn", + expectedRevision: command.expectedRevision, + payload: command.payload, + }), + idempotencyKey: scopedGenerationKey(command), + modelId: command.payload.modelId, + leaseOwner: eventId, + now, + }) + const nextThreadRevision = access.thread.revision + 1 + await transaction + .update(conversationThreads) + .set({ revision: nextThreadRevision, updatedAt: now }) + .where(eq(conversationThreads.id, access.thread.id)) + return generationMutation({ + command, + access, + eventId, + eventAggregateRevision: 0, + generation, + turn, + messages: [userMessage, assistantMessage], + revisions: { + [access.thread.id]: nextThreadRevision, + [turn.id]: 0, + }, + }) + }) + } + + editTurnInput( + command: CommandEnvelope + ): Promise { + return this.execute("editTurnInput", command, async (transaction) => { + if (command.scope.type !== "turn") + throw new ConversationCommandError( + "invalid_request", + "命令 scope 必须是 Turn" + ) + const access = await findTurnCommandAccess( + transaction, + command.actor.userId, + command.scope.id + ) + if (!access || access.conversation.id !== command.payload.conversationId) + throw new ConversationCommandError("not_found", "Turn 不存在") + assertExpectedRevision( + access.turn.revision, + command.expectedRevision, + "Turn" + ) + const [last] = await transaction + .select({ value: max(conversationTurns.position) }) + .from(conversationTurns) + .where(eq(conversationTurns.threadId, access.thread.id)) + if (last?.value !== access.turn.position) + throw new ConversationCommandError( + "fork_required", + "编辑已有后续内容的 Turn 必须显式 Fork" + ) + const [source] = await transaction + .select() + .from(conversationMessages) + .where( + and( + eq(conversationMessages.id, command.payload.sourceUserMessageId), + eq(conversationMessages.turnId, access.turn.id), + eq(conversationMessages.threadId, access.thread.id), + eq(conversationMessages.role, "user") + ) + ) + .limit(1) + if (!source || access.turn.activeUserMessageId !== source.id) + throw new ConversationCommandError( + "semantic_validation", + "只能编辑当前有效用户 Message" + ) + const now = this.now() + const userMessage = mapMessage({ + id: command.payload.userMessageId, + threadId: access.thread.id, + turnId: access.turn.id, + role: "user", + content: command.payload.content, + contentState: "complete", + variantOfMessageId: source.id, + createdAt: now, + }) + const assistantMessage = mapMessage({ + id: command.payload.assistantMessageId, + threadId: access.thread.id, + turnId: access.turn.id, + role: "assistant", + content: { schemaVersion: 1, parts: [] }, + contentState: "pending", + variantOfMessageId: access.turn.activeAssistantMessageId, + createdAt: now, + }) + await transaction.insert(conversationMessages).values([ + { + ...userMessage, + variantOfMessageId: source.id, + createdAt: now, + }, + { + ...assistantMessage, + variantOfMessageId: access.turn.activeAssistantMessageId, + createdAt: now, + }, + ]) + await supersedeCurrentGeneration(transaction, access.turn.id, now) + const eventId = this.id() + const generation = await insertGeneration(transaction, { + access, + actorUserId: command.actor.userId, + generationId: command.payload.generationId, + turnId: turnId(access.turn.id), + inputMessageId: userMessage.id, + outputMessageId: assistantMessage.id, + intent: { + kind: "edit-user", + sourceUserMessageId: messageId(source.id), + }, + requestHash: commandPayloadHash({ + commandType: "editTurnInput", + expectedRevision: command.expectedRevision, + payload: command.payload, + }), + idempotencyKey: scopedGenerationKey(command), + modelId: command.payload.modelId, + leaseOwner: eventId, + now, + }) + const nextTurnRevision = access.turn.revision + 1 + await transaction + .update(conversationTurns) + .set({ + activeUserMessageId: userMessage.id, + revision: nextTurnRevision, + updatedAt: now, + }) + .where(eq(conversationTurns.id, access.turn.id)) + const updatedTurn = mapTurn({ + ...access.turn, + activeUserMessageId: userMessage.id, + revision: nextTurnRevision, + updatedAt: now, + }) + return generationMutation({ + command, + access, + eventId, + eventAggregateRevision: nextTurnRevision, + generation, + turn: updatedTurn, + messages: [userMessage, assistantMessage], + revisions: { [updatedTurn.id]: nextTurnRevision }, + }) + }) + } + + regenerateTurn( + command: CommandEnvelope + ): Promise { + return this.execute("regenerateTurn", command, async (transaction) => { + if (command.scope.type !== "turn") + throw new ConversationCommandError( + "invalid_request", + "命令 scope 必须是 Turn" + ) + const access = await findTurnCommandAccess( + transaction, + command.actor.userId, + command.scope.id + ) + if (!access || access.conversation.id !== command.payload.conversationId) + throw new ConversationCommandError("not_found", "Turn 不存在") + assertExpectedRevision( + access.turn.revision, + command.expectedRevision, + "Turn" + ) + const [source] = await transaction + .select() + .from(conversationMessages) + .where( + and( + eq( + conversationMessages.id, + command.payload.sourceAssistantMessageId + ), + eq(conversationMessages.turnId, access.turn.id), + eq(conversationMessages.threadId, access.thread.id), + eq(conversationMessages.role, "assistant") + ) + ) + .limit(1) + if (!source) + throw new ConversationCommandError( + "semantic_validation", + "重新生成来源不是同 Turn 助手 Message" + ) + const now = this.now() + const assistantMessage = mapMessage({ + id: command.payload.assistantMessageId, + threadId: access.thread.id, + turnId: access.turn.id, + role: "assistant", + content: { schemaVersion: 1, parts: [] }, + contentState: "pending", + variantOfMessageId: source.id, + createdAt: now, + }) + await transaction.insert(conversationMessages).values({ + ...assistantMessage, + variantOfMessageId: source.id, + createdAt: now, + }) + await supersedeCurrentGeneration(transaction, access.turn.id, now) + const eventId = this.id() + const generation = await insertGeneration(transaction, { + access, + actorUserId: command.actor.userId, + generationId: command.payload.generationId, + turnId: turnId(access.turn.id), + inputMessageId: messageId(access.turn.activeUserMessageId), + outputMessageId: assistantMessage.id, + intent: { + kind: "regenerate-assistant", + sourceAssistantMessageId: messageId(source.id), + }, + requestHash: commandPayloadHash({ + commandType: "regenerateTurn", + expectedRevision: command.expectedRevision, + payload: command.payload, + }), + idempotencyKey: scopedGenerationKey(command), + modelId: command.payload.modelId, + leaseOwner: eventId, + now, + }) + const nextTurnRevision = access.turn.revision + 1 + await transaction + .update(conversationTurns) + .set({ revision: nextTurnRevision, updatedAt: now }) + .where(eq(conversationTurns.id, access.turn.id)) + const updatedTurn = mapTurn({ + ...access.turn, + revision: nextTurnRevision, + updatedAt: now, + }) + return generationMutation({ + command, + access, + eventId, + eventAggregateRevision: nextTurnRevision, + generation, + turn: updatedTurn, + messages: [assistantMessage], + revisions: { [updatedTurn.id]: nextTurnRevision }, + }) + }) + } + + selectTurnVariant( + command: CommandEnvelope + ): Promise { + return this.execute("selectTurnVariant", command, async (transaction) => { + if (command.scope.type !== "turn") + throw new ConversationCommandError( + "invalid_request", + "命令 scope 必须是 Turn" + ) + const access = await findTurnCommandAccess( + transaction, + command.actor.userId, + command.scope.id + ) + if (!access || access.conversation.id !== command.payload.conversationId) + throw new ConversationCommandError("not_found", "Turn 不存在") + assertExpectedRevision( + access.turn.revision, + command.expectedRevision, + "Turn" + ) + const [selected] = await transaction + .select() + .from(conversationMessages) + .where( + and( + eq(conversationMessages.id, command.payload.messageId), + eq(conversationMessages.turnId, access.turn.id), + eq(conversationMessages.threadId, access.thread.id), + eq(conversationMessages.role, command.payload.role) + ) + ) + .limit(1) + if ( + !selected || + !["complete", "incomplete"].includes(selected.contentState) + ) + throw new ConversationCommandError( + "semantic_validation", + "目标 Message 不是同 Turn 可用角色变体" + ) + const nextTurnRevision = access.turn.revision + 1 + const now = this.now() + await transaction + .update(conversationTurns) + .set({ + ...(command.payload.role === "user" + ? { activeUserMessageId: selected.id } + : { activeAssistantMessageId: selected.id }), + revision: nextTurnRevision, + updatedAt: now, + }) + .where(eq(conversationTurns.id, access.turn.id)) + const updatedTurn = mapTurn({ + ...access.turn, + ...(command.payload.role === "user" + ? { activeUserMessageId: selected.id } + : { activeAssistantMessageId: selected.id }), + revision: nextTurnRevision, + updatedAt: now, + }) + const eventId = this.id() + return { + data: asJson({ turnId: updatedTurn.id, messageId: selected.id }), + revisions: { [updatedTurn.id]: nextTurnRevision }, + delta: { + upsert: { turns: [updatedTurn] }, + remove: {}, + invalidate: [`thread:${access.thread.id}:context`], + }, + events: [ + { + id: eventId, + aggregateType: "turn", + aggregateId: updatedTurn.id, + aggregateRevision: nextTurnRevision, + type: "TurnVariantSelected", + payload: asJson({ + messageId: selected.id, + role: command.payload.role, + }), + }, + ], + } + }) + } + + private mutateConversation( + commandType: string, + command: CommandEnvelope, + mutation: ( + transaction: CommandTransaction, + access: ConversationAccess + ) => Promise + ): Promise { + return this.execute(commandType, command, async (transaction) => { + if (command.scope.type !== "conversation") + throw new ConversationCommandError( + "invalid_request", + "命令 scope 必须是 Conversation" + ) + const access = await findConversationAccess( + transaction, + command.actor.userId, + command.scope.id, + true + ) + if (!access) + throw new ConversationCommandError("not_found", "Conversation 不存在") + assertExpectedRevision( + access.conversation.revision, + command.expectedRevision, + "Conversation" + ) + return mutation(transaction, access) + }) + } + + private mutateThread( + commandType: string, + command: CommandEnvelope, + mutation: ( + transaction: CommandTransaction, + access: ThreadAccess + ) => Promise + ): Promise { + return this.execute(commandType, command, async (transaction) => { + if (command.scope.type !== "thread") + throw new ConversationCommandError( + "invalid_request", + "命令 scope 必须是 Thread" + ) + const access = await findThreadAccess( + transaction, + command.actor.userId, + command.scope.id, + { conversation: "share", thread: "update" } + ) + if (!access) + throw new ConversationCommandError("not_found", "Thread 不存在") + assertExpectedRevision( + access.thread.revision, + command.expectedRevision, + "Thread" + ) + return mutation(transaction, access) + }) + } + + private async execute( + commandType: string, + command: CommandEnvelope, + mutation: (transaction: CommandTransaction) => Promise + ): Promise { + assertConversationCommandApiEnabled(this.policy) + if (!command.idempotencyKey || command.idempotencyKey.length > 200) + throw new ConversationCommandError( + "invalid_request", + "幂等键必须为 1–200 字符", + { field: "Idempotency-Key" } + ) + const payloadHash = commandPayloadHash({ + commandType, + expectedRevision: command.expectedRevision, + payload: command.payload, + }) + return db.transaction(async (transaction) => { + await transaction.execute(sql` + select pg_advisory_xact_lock( + hashtext(${command.actor.userId}), + hashtext(${`${command.scope.type}:${command.scope.id}:${command.idempotencyKey}`}) + ) + `) + const [existing] = await transaction + .select() + .from(conversationCommandRecords) + .where( + and( + eq(conversationCommandRecords.actorId, command.actor.userId), + eq(conversationCommandRecords.scopeType, command.scope.type), + eq(conversationCommandRecords.scopeId, command.scope.id), + eq( + conversationCommandRecords.idempotencyKey, + command.idempotencyKey + ) + ) + ) + .limit(1) + if (existing) { + if ( + existing.commandType !== commandType || + existing.payloadHash !== payloadHash + ) + throw new ConversationCommandError( + "idempotency_conflict", + "幂等键已绑定不同命令载荷" + ) + const result = existing.result as unknown as CommandSuccess + return { result: { ...result, replayed: true }, outboxEventIds: [] } + } + + const mutated = await mutation(transaction) + const result: CommandSuccess = { + schemaVersion: CONVERSATION_COMMAND_SCHEMA_VERSION, + data: mutated.data, + revisions: mutated.revisions, + delta: mutated.delta, + replayed: false, + } + const events = mutated.events ?? [] + if (events.length > 0) + await transaction.insert(conversationOutboxEvents).values( + events.map((event) => ({ + ...event, + schemaVersion: CONVERSATION_COMMAND_SCHEMA_VERSION, + actorId: command.actor.userId, + status: "pending", + attempts: 0, + availableAt: sql`CURRENT_TIMESTAMP`, + createdAt: sql`CURRENT_TIMESTAMP`, + })) + ) + await transaction.insert(conversationCommandRecords).values({ + id: command.commandId, + actorId: command.actor.userId, + scopeType: command.scope.type, + scopeId: command.scope.id, + commandType, + idempotencyKey: command.idempotencyKey, + payloadHash, + result: asJson(result), + createdAt: sql`CURRENT_TIMESTAMP`, + }) + return { + result, + outboxEventIds: events.map((event) => event.id), + } + }) + } +} + +function conversationNoopMutation( + row: typeof conversations.$inferSelect +): CommandMutation { + const conversation = mapConversation(row) + return { + data: asJson({ + conversationId: conversation.id, + lifecycle: conversation.lifecycle, + }), + revisions: { [conversation.id]: conversation.revision }, + delta: { + upsert: { conversations: [conversation] }, + remove: {}, + invalidate: [], + }, + } +} + +function threadMutation( + thread: ConversationThread, + targetConversationId: string +): CommandMutation { + return { + data: asJson({ threadId: thread.id, lifecycle: thread.lifecycle }), + revisions: { [thread.id]: thread.revision }, + delta: { + upsert: { threads: [thread] }, + remove: {}, + invalidate: [`conversation:${targetConversationId}:threads`], + }, + } +} + +interface TurnCommandAccess extends ThreadAccess { + readonly turn: typeof conversationTurns.$inferSelect +} + +async function findTurnCommandAccess( + transaction: CommandTransaction, + actorUserId: string, + targetTurnId: string +): Promise { + const query = transaction + .select({ + turn: conversationTurns, + thread: conversationThreads, + conversation: conversations, + project: projects, + }) + .from(conversationTurns) + .innerJoin( + conversationThreads, + eq(conversationThreads.id, conversationTurns.threadId) + ) + .innerJoin( + conversations, + eq(conversations.id, conversationThreads.conversationId) + ) + .innerJoin(projects, eq(projects.id, conversations.projectId)) + .innerJoin( + workspaceMembers, + and( + eq(workspaceMembers.workspaceId, projects.workspaceId), + eq(workspaceMembers.userId, actorUserId) + ) + ) + .where(eq(conversationTurns.id, targetTurnId)) + .limit(1) + const [authorized] = await query + if (!authorized) return null + await transaction + .select({ id: conversations.id }) + .from(conversations) + .where(eq(conversations.id, authorized.conversation.id)) + .for("share") + await transaction + .select({ id: conversationThreads.id }) + .from(conversationThreads) + .where(eq(conversationThreads.id, authorized.thread.id)) + .for("update") + await transaction + .select({ id: conversationTurns.id }) + .from(conversationTurns) + .where(eq(conversationTurns.id, targetTurnId)) + .for("update") + const [fresh] = await query + return fresh ?? null +} + +function scopedGenerationKey( + command: CommandEnvelope +): string { + return `${command.scope.type}:${command.scope.id}:${command.idempotencyKey}` +} + +async function supersedeCurrentGeneration( + transaction: CommandTransaction, + targetTurnId: string, + now: Date +): Promise { + const currentRows = await transaction + .select() + .from(conversationGenerations) + .where( + and( + eq(conversationGenerations.turnId, targetTurnId), + eq(conversationGenerations.isCurrent, true) + ) + ) + for (const current of currentRows) { + if (ACTIVE_GENERATION_STATUSES.includes(current.status as never)) { + const checkpoint = parseConversationGenerationCheckpoint( + current.checkpoint + ) + const contentState = hasRecoverableCheckpointOutput(checkpoint) + ? "incomplete" + : "failed" + await transaction + .update(conversationMessages) + .set({ contentState }) + .where(eq(conversationMessages.id, current.outputMessageId)) + await transaction + .update(conversationGenerations) + .set({ + isCurrent: false, + status: "superseded", + contentState, + usageCompleteness: "unavailable", + billingStatus: current.paidCallStarted + ? "usage_unavailable" + : "not_billable", + leaseOwner: null, + finishedAt: now, + updatedAt: now, + }) + .where(eq(conversationGenerations.id, current.id)) + continue + } + await transaction + .update(conversationGenerations) + .set({ isCurrent: false, updatedAt: now }) + .where(eq(conversationGenerations.id, current.id)) + } +} + +async function insertGeneration( + transaction: CommandTransaction, + input: { + readonly access: ThreadAccess + readonly actorUserId: string + readonly generationId: GenerationId + readonly turnId: ReturnType + readonly inputMessageId: MessageId + readonly outputMessageId: MessageId + readonly intent: GenerationIntent + readonly requestHash: string + readonly idempotencyKey: string + readonly modelId: string + readonly leaseOwner: string + readonly now: Date + } +): Promise { + const [attemptRow] = await transaction + .select({ value: max(conversationGenerations.attempt) }) + .from(conversationGenerations) + .where(eq(conversationGenerations.turnId, input.turnId)) + const attempt = (attemptRow?.value ?? 0) + 1 + const checkpoint = emptyConversationGenerationCheckpoint() + const [created] = await transaction + .insert(conversationGenerations) + .values({ + id: input.generationId, + ownerId: input.actorUserId, + workspaceId: input.access.project.workspaceId, + projectId: input.access.project.id, + conversationId: input.access.conversation.id, + threadId: input.access.thread.id, + turnId: input.turnId, + inputMessageId: input.inputMessageId, + outputMessageId: input.outputMessageId, + intent: input.intent, + requestHash: input.requestHash, + idempotencyKey: input.idempotencyKey, + modelId: input.modelId, + attempt, + isCurrent: true, + status: "running", + contentState: "pending", + checkpointVersion: 0, + checkpoint, + knownUsage: null, + usageCompleteness: "unavailable", + billingStatus: "pending", + paidCallStarted: false, + leaseOwner: input.leaseOwner, + heartbeatAt: input.now, + startedAt: input.now, + createdAt: input.now, + updatedAt: input.now, + }) + .returning() + if (!created) + throw new ConversationCommandError( + "internal", + "Generation 创建事务未返回实体" + ) + return mapGenerationSummary(created) +} + +function generationMutation(input: { + readonly command: CommandEnvelope + readonly access: ThreadAccess + readonly eventId: string + readonly eventAggregateRevision: number + readonly generation: ConversationGeneration + readonly turn: ConversationTurn + readonly messages: readonly ConversationMessage[] + readonly revisions: Readonly> +}): CommandMutation { + return { + data: asJson({ + turnId: input.turn.id, + generationId: input.generation.id, + }), + revisions: input.revisions, + delta: { + upsert: { + turns: [input.turn], + messages: input.messages, + generations: [input.generation], + }, + remove: {}, + invalidate: [`thread:${input.access.thread.id}:context`], + }, + events: [ + { + id: input.eventId, + aggregateType: "turn", + aggregateId: input.turn.id, + aggregateRevision: input.eventAggregateRevision, + type: "GenerationRequested", + payload: asJson({ + generationId: input.generation.id, + ownerId: input.command.actor.userId, + leaseOwner: input.eventId, + }), + }, + ], + } +} + +function deriveContextMessageIds(input: { + readonly threads: readonly ConversationThread[] + readonly turns: readonly ConversationTurn[] + readonly messages: readonly ConversationMessage[] + readonly forks: readonly ThreadFork[] +}): Readonly> { + const forkByChild = new Map( + input.forks.map((fork) => [fork.childThreadId, fork]) + ) + const turnsByThread = new Map() + for (const turn of input.turns) { + const values = turnsByThread.get(turn.threadId) ?? [] + values.push(turn) + turnsByThread.set(turn.threadId, values) + } + for (const values of turnsByThread.values()) + values.sort( + (left, right) => + left.position - right.position || left.id.localeCompare(right.id) + ) + const local = (targetThreadId: ThreadId): MessageId[] => + (turnsByThread.get(targetThreadId) ?? []).flatMap((turn) => [ + turn.activeUserMessageId, + turn.activeAssistantMessageId, + ]) + const memo = new Map() + const visiting = new Set() + const derive = (targetThreadId: ThreadId): MessageId[] => { + const cached = memo.get(targetThreadId) + if (cached) return cached + if (visiting.has(targetThreadId)) + throw new ConversationCommandError( + "internal", + "ThreadFork 读取投影检测到环" + ) + visiting.add(targetThreadId) + const fork = forkByChild.get(targetThreadId) + let inherited: MessageId[] = [] + if (fork) { + const parent = derive(fork.parentThreadId) + const sourceIndex = parent.indexOf(fork.sourceMessageId) + if (sourceIndex < 0) + throw new ConversationCommandError( + "internal", + "ThreadFork 来源不在父 Thread 当前上下文投影" + ) + inherited = parent.slice(0, sourceIndex + 1) + } + const value = [...inherited, ...local(targetThreadId)] + visiting.delete(targetThreadId) + memo.set(targetThreadId, value) + return value + } + return Object.fromEntries( + input.threads.map((thread) => [thread.id, derive(thread.id)]) + ) +} diff --git a/lib/thread-chat/persistence/drizzle-conversation-outbox-dispatcher.ts b/lib/thread-chat/persistence/drizzle-conversation-outbox-dispatcher.ts new file mode 100644 index 00000000..531ed936 --- /dev/null +++ b/lib/thread-chat/persistence/drizzle-conversation-outbox-dispatcher.ts @@ -0,0 +1,138 @@ +import { and, asc, eq, inArray, lte, sql } from "drizzle-orm" + +import { + CONVERSATION_OUTBOX_DEFAULT_BATCH_SIZE, + CONVERSATION_OUTBOX_MAX_ATTEMPTS, +} from "../../../constants/conversation-command" +import { db } from "../../db" +import { conversationOutboxEvents } from "../../db/schema" +import type { + ConversationOutboxDispatcher, + OutboxEventConsumer, +} from "../application/conversation-command-service" +import type { OutboxEvent } from "../application/conversation-command-contracts" + +const CLAIM_LEASE_MS = 30_000 + +function mapEvent( + row: typeof conversationOutboxEvents.$inferSelect +): OutboxEvent { + return { + id: row.id, + aggregateType: row.aggregateType, + aggregateId: row.aggregateId, + aggregateRevision: row.aggregateRevision, + type: row.type, + schemaVersion: 1, + actorId: row.actorId, + payload: row.payload, + attempts: row.attempts, + } +} + +export class DrizzleConversationOutboxDispatcher implements ConversationOutboxDispatcher { + constructor( + private readonly consumer: OutboxEventConsumer, + private readonly workerId: string + ) {} + + schedule(eventIds: readonly string[]): void { + if (eventIds.length === 0) return + queueMicrotask(() => { + void this.dispatchPending({ eventIds }).catch((error) => { + console.error("[conversation-outbox] 后台派发失败", { + eventIds, + error, + }) + }) + }) + } + + async dispatchPending( + input: { + readonly eventIds?: readonly string[] + readonly limit?: number + } = {} + ): Promise { + const conditions = [ + inArray(conversationOutboxEvents.status, [ + "pending", + "failed", + "processing", + ]), + lte(conversationOutboxEvents.availableAt, sql`CURRENT_TIMESTAMP`), + sql`${conversationOutboxEvents.attempts} < ${CONVERSATION_OUTBOX_MAX_ATTEMPTS}`, + ] + if (input.eventIds?.length) + conditions.push(inArray(conversationOutboxEvents.id, [...input.eventIds])) + const candidates = await db + .select({ id: conversationOutboxEvents.id }) + .from(conversationOutboxEvents) + .where(and(...conditions)) + .orderBy( + asc(conversationOutboxEvents.availableAt), + asc(conversationOutboxEvents.createdAt) + ) + .limit(input.limit ?? CONVERSATION_OUTBOX_DEFAULT_BATCH_SIZE) + + let dispatched = 0 + for (const candidate of candidates) { + const [claimed] = await db + .update(conversationOutboxEvents) + .set({ + status: "processing", + claimedBy: this.workerId, + attempts: sql`${conversationOutboxEvents.attempts} + 1`, + availableAt: sql`CURRENT_TIMESTAMP + (${CLAIM_LEASE_MS} * INTERVAL '1 millisecond')`, + lastError: null, + }) + .where( + and( + eq(conversationOutboxEvents.id, candidate.id), + inArray(conversationOutboxEvents.status, [ + "pending", + "failed", + "processing", + ]), + lte(conversationOutboxEvents.availableAt, sql`CURRENT_TIMESTAMP`) + ) + ) + .returning() + if (!claimed) continue + try { + await this.consumer.consume(mapEvent(claimed)) + await db + .update(conversationOutboxEvents) + .set({ + status: "dispatched", + dispatchedAt: sql`CURRENT_TIMESTAMP`, + claimedBy: null, + lastError: null, + }) + .where( + and( + eq(conversationOutboxEvents.id, claimed.id), + eq(conversationOutboxEvents.claimedBy, this.workerId), + eq(conversationOutboxEvents.status, "processing") + ) + ) + dispatched += 1 + } catch (error) { + const retryDelayMs = Math.min(30_000, 250 * 2 ** claimed.attempts) + await db + .update(conversationOutboxEvents) + .set({ + status: "failed", + availableAt: sql`CURRENT_TIMESTAMP + (${retryDelayMs} * INTERVAL '1 millisecond')`, + claimedBy: null, + lastError: + error instanceof Error + ? error.message.slice(0, 1_000) + : "unknown", + }) + .where(eq(conversationOutboxEvents.id, claimed.id)) + } + } + return dispatched + } +} diff --git a/openspec/changes/add-conversation-command-api/tasks.md b/openspec/changes/add-conversation-command-api/tasks.md index 69798eb1..1df0b950 100644 --- a/openspec/changes/add-conversation-command-api/tasks.md +++ b/openspec/changes/add-conversation-command-api/tasks.md @@ -1,43 +1,52 @@ ## 1. 应用层基础契约 -- [ ] 1.1 定义 Command/Query 输入、actor、幂等键、预期 revision、规范 delta 和稳定错误结果,不导入 HTTP 或数据库行类型。 -- [ ] 1.2 定义 `UnitOfWork`、授权策略、幂等存储、事务事件箱和规范仓储端口,并建立测试替身。 -- [ ] 1.3 实现 Workspace/Project → Conversation/Thread/Message/Generation 的不可枚举授权解析和安全错误 details。 -- [ ] 1.4 实现幂等载荷摘要、等价重放、同键异载荷冲突和命令结果恢复。 +- [x] 1.1 定义 Command/Query 输入、actor、幂等键、预期 revision、规范 delta 和稳定错误结果,不导入 HTTP 或数据库行类型。 +- [x] 1.2 定义 `UnitOfWork`、授权策略、幂等存储、事务事件箱和规范仓储端口,并建立测试替身。 +- [x] 1.3 实现 Workspace/Project → Conversation/Thread/Message/Generation 的不可枚举授权解析和安全错误 details。 +- [x] 1.4 实现幂等载荷摘要、等价重放、同键异载荷冲突和命令结果恢复。 ## 2. Conversation 与 Thread 生命周期 -- [ ] 2.1 实现 Conversation 列表、规范快照读取以及创建 Conversation + 根 Thread 的原子命令。 -- [ ] 2.2 实现 Conversation 重命名、归档、恢复和受保护删除,覆盖运行中 Generation 冲突。 -- [ ] 2.3 实现 Thread 重命名、非根 Thread 归档和恢复,保留 Fork、后代、Message 与 Generation 历史,并要求根 Thread 使用 Conversation 归档。 -- [ ] 2.4 增加生命周期命令的权限、幂等、revision 和事务回滚测试。 +- [x] 2.1 实现 Conversation 列表、规范快照读取以及创建 Conversation + 根 Thread 的原子命令。 +- [x] 2.2 实现 Conversation 重命名、归档、恢复和受保护删除,覆盖运行中 Generation 冲突。 +- [x] 2.3 实现 Thread 重命名、非根 Thread 归档和恢复,保留 Fork、后代、Message 与 Generation 历史,并要求根 Thread 使用 Conversation 归档。 +- [x] 2.4 增加生命周期命令的权限、幂等、revision 和事务回滚测试。 ## 3. Fork 与上下文边界 -- [ ] 3.1 实现 `forkThread` 的来源 Message、同 Conversation、当前可用路径和预期 Conversation revision 校验。 -- [ ] 3.2 在单事务创建下游 Thread、ThreadFork、上下文边界、revision 与 `ThreadForked` outbox 事件。 -- [ ] 3.3 实现不复制上游 Message 的继承上下文读取投影,并覆盖 A → B → C 嵌套 Fork。 -- [ ] 3.4 覆盖来源错配、并发 Fork、重复幂等键和事务失败无孤立实体测试。 +- [x] 3.1 实现 `forkThread` 的来源 Message、同 Conversation、当前可用路径和预期 Conversation revision 校验。 +- [x] 3.2 在单事务创建下游 Thread、ThreadFork、上下文边界、revision 与 `ThreadForked` outbox 事件。 +- [x] 3.3 实现不复制上游 Message 的继承上下文读取投影,并覆盖 A → B → C 嵌套 Fork。 +- [x] 3.4 覆盖来源错配、并发 Fork、重复幂等键和事务失败无孤立实体测试。 ## 4. Turn、Message 变体与 Generation 命令 -- [ ] 4.1 实现 `sendTurn`,原子创建 Turn、用户/助手 Message、Generation 和事件,并在提交后启动执行器。 -- [ ] 4.2 实现末尾用户输入编辑和助手重新生成,以新 Message/Generation 变体追加而非覆盖旧身份。 -- [ ] 4.3 对已有后续 Turn 的历史编辑返回 `fork_required`,不修改现有上下文。 -- [ ] 4.4 实现带 Turn revision 的用户/助手当前变体选择和角色/归属/内容可用性校验。 -- [ ] 4.5 实现 Generation 查询与幂等 Stop,复用规范 lifecycle 并让已终结 Stop 返回现有结果。 +- [x] 4.1 实现 `sendTurn`,原子创建 Turn、用户/助手 Message、Generation 和事件,并在提交后启动执行器。 +- [x] 4.2 实现末尾用户输入编辑和助手重新生成,以新 Message/Generation 变体追加而非覆盖旧身份。 +- [x] 4.3 对已有后续 Turn 的历史编辑返回 `fork_required`,不修改现有上下文。 +- [x] 4.4 实现带 Turn revision 的用户/助手当前变体选择和角色/归属/内容可用性校验。 +- [x] 4.5 实现 Generation 查询与幂等 Stop,复用规范 lifecycle 并让已终结 Stop 返回现有结果。 ## 5. HTTP 传输适配器 -- [ ] 5.1 定义带 `schemaVersion` 的请求、快照、delta、revision 和错误传输 schema。 -- [ ] 5.2 实现 Project/Conversation/Thread/Turn/Generation 资源路由,只调用应用 Command/Query。 -- [ ] 5.3 将 `Idempotency-Key`、`If-Match`/ETag 和认证会话映射到内部 command envelope。 -- [ ] 5.4 实现 400/401/403/404/409/422/429/500 的稳定错误映射,并验证只有真实冲突返回 409。 -- [ ] 5.5 增加接口契约测试,覆盖 owner 伪造、不可见资源、格式错误、幂等重放和 revision 冲突。 +- [x] 5.1 定义带 `schemaVersion` 的请求、快照、delta、revision 和错误传输 schema。 +- [x] 5.2 实现 Project/Conversation/Thread/Turn/Generation 资源路由,只调用应用 Command/Query。 +- [x] 5.3 将 `Idempotency-Key`、`If-Match`/ETag 和认证会话映射到内部 command envelope。 +- [x] 5.4 实现 400/401/403/404/409/422/429/500 的稳定错误映射,并验证只有真实冲突返回 409。 +- [x] 5.5 增加接口契约测试,覆盖 owner 伪造、不可见资源、格式错误、幂等重放和 revision 冲突。 ## 6. 事件箱与隔离验证 -- [ ] 6.1 实现 outbox 写入和幂等 dispatcher,验证事件与规范实体在同一事务提交。 -- [ ] 6.2 增加默认关闭的规范 API 开关,拒绝新命令调用旧整树 PUT 或双写旧权威。 -- [ ] 6.3 在隔离环境跑通创建 Conversation、发送、Fork、嵌套 Fork、重新生成、选择变体、Stop、归档与恢复端到端流程。 -- [ ] 6.4 运行相关测试、`pnpm typecheck`、`pnpm openspec:validate` 和接口 schema 检查并记录结果。 +- [x] 6.1 实现 outbox 写入和幂等 dispatcher,验证事件与规范实体在同一事务提交。 +- [x] 6.2 增加默认关闭的规范 API 开关,拒绝新命令调用旧整树 PUT 或双写旧权威。 +- [x] 6.3 在隔离环境跑通创建 Conversation、发送、Fork、嵌套 Fork、重新生成、选择变体、Stop、归档与恢复端到端流程。 +- [x] 6.4 运行相关测试、`pnpm typecheck`、`pnpm openspec:validate` 和接口 schema 检查并记录结果。 + +## 验证记录(2026-08-22) + +- `pnpm test:conversation-command-api`:通过 59 项数据库断言,覆盖 root → A → B → C、无 Message 复制、不同幂等键并发发送/Fork 的 revision 互斥、事务回滚、变体、Stop、归档/恢复/删除,以及 outbox → canonical executor → checkpoint/终态。 +- `pnpm test:conversation-command-contract`:4/4 通过,覆盖 owner 伪造、Header 映射、稳定错误状态和成功 envelope/ETag。 +- 真实登录态 HTTP 路由:创建与等价重放均为 200,旧 revision 为 409,发送、Generation 查询、Stop、快照为 200,owner 伪造为 400;专用数据库夹具已清理。 +- Ego Browser:使用 `glm-5.3` 发送“只回复:Command API 回归通过”,模型正确回复,刷新后用户消息、助手回复和模型选择均保持。 +- 回归:领域模型 8/8、规范持久化 26 项、Generation 单元 6/6、Generation 数据库 49 项、遗留审计 2/2 均通过。 +- `pnpm typecheck`、目标文件 ESLint(0 warning)和 `pnpm openspec:validate`(31/31 strict)均通过。 diff --git a/package.json b/package.json index f821133e..6369ea5e 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,8 @@ "test:conversation-domain": "node --experimental-strip-types --test lib/thread-chat/domain/conversation-model.test.ts", "test:conversation-generation-unit": "tsx --test lib/thread-chat/domain/conversation-generation.test.ts lib/thread-chat/generation/canonical-generation-execution.test.ts", "test:conversation-generation": "tsx scripts/test-conversation-generation.ts", + "test:conversation-command-api": "tsx scripts/test-conversation-command-api.ts", + "test:conversation-command-contract": "tsx --test lib/thread-chat/http/conversation-command-http.test.ts", "test:conversation-persistence": "tsx scripts/test-conversation-persistence.ts", "test:legacy-conversation-audit": "node --experimental-strip-types --test lib/thread-chat/legacy/audit-thread-tree.test.ts", "audit:conversation-domain": "node scripts/audit-thread-tree-boundary.mjs", diff --git a/scripts/test-conversation-command-api.ts b/scripts/test-conversation-command-api.ts new file mode 100644 index 00000000..6d22bba3 --- /dev/null +++ b/scripts/test-conversation-command-api.ts @@ -0,0 +1,873 @@ +import assert from "node:assert/strict" +import { randomUUID } from "node:crypto" + +import { config } from "dotenv" + +config({ path: ".env.local" }) +process.env.CONVERSATION_COMMAND_API_AUTHORITY = "isolated-test" + +const { count, eq } = await import("drizzle-orm") +const { db } = await import("../lib/db/index.ts") +const { + conversationCommandRecords, + conversationGenerations, + conversationOutboxEvents, + conversationThreads, + user, + workspaces, +} = await import("../lib/db/schema.ts") +const { ConversationCommandApplicationService } = + await import("../lib/thread-chat/application/conversation-command-service.ts") +const { ConversationCommandError } = + await import("../lib/thread-chat/application/conversation-command-contracts.ts") +const { + CanonicalGenerationApplicationService, + InMemoryGenerationAbortRegistry, +} = + await import("../lib/thread-chat/generation/canonical-generation-execution.ts") +const { emptyConversationGenerationCheckpoint } = + await import("../lib/thread-chat/domain/conversation-generation.ts") +const { + conversationId, + generationId, + messageId, + projectId, + threadForkId, + threadId, + turnId, + workspaceId, +} = await import("../lib/thread-chat/domain/conversation-model.ts") +const { DrizzleConversationCommandStore } = + await import("../lib/thread-chat/persistence/drizzle-conversation-command-store.ts") +const { DrizzleConversationGenerationRepository } = + await import("../lib/thread-chat/persistence/drizzle-conversation-generation-repository.ts") +const { DrizzleConversationOutboxDispatcher } = + await import("../lib/thread-chat/persistence/drizzle-conversation-outbox-dispatcher.ts") +const { DrizzleConversationRepository } = + await import("../lib/thread-chat/persistence/drizzle-conversation-repository.ts") +const { assertConversationCommandApiEnabled } = + await import("../lib/thread-chat/persistence/conversation-command-policy.ts") + +const runId = randomUUID() +const prefix = `command-api-test:${runId}` +const ownerId = `${prefix}:owner` +const outsiderId = `${prefix}:outsider` +const workspace = workspaceId(`${prefix}:workspace`) +const project = projectId(`${prefix}:project`) +const conversation = conversationId(`${prefix}:conversation`) +const root = threadId(`${prefix}:thread:root`) +const modelId = "glm-5.3" +const policy = { + authority: "isolated-test" as const, + legacyAuthorityEnabled: true, +} +const store = new DrizzleConversationCommandStore(policy) +const generationRepository = new DrizzleConversationGenerationRepository({ + authority: "isolated-test", + legacyAuthorityEnabled: true, +}) +const scheduled: string[][] = [] +const scheduler = { + schedule(eventIds: readonly string[]) { + scheduled.push([...eventIds]) + }, + async dispatchPending() { + return 0 + }, +} +const service = new ConversationCommandApplicationService( + store, + store, + generationRepository, + scheduler +) +const seedRepository = new DrizzleConversationRepository({ + writeMode: "isolated-test", + legacyWritesEnabled: true, +}) + +let commandSequence = 0 +function command(input: { + scope: + | { type: "project"; id: ReturnType } + | { type: "conversation"; id: ReturnType } + | { type: "thread"; id: ReturnType } + | { type: "turn"; id: ReturnType } + payload: TPayload + expectedRevision?: number + key?: string + actorUserId?: string +}) { + commandSequence += 1 + return { + commandId: `${prefix}:command:${commandSequence}`, + actor: { + kind: "user" as const, + userId: input.actorUserId ?? ownerId, + }, + scope: input.scope, + idempotencyKey: input.key ?? `key-${commandSequence}`, + ...(input.expectedRevision !== undefined + ? { expectedRevision: input.expectedRevision } + : {}), + payload: input.payload, + } +} + +function content(text: string) { + return { + schemaVersion: 1 as const, + parts: [{ type: "text" as const, text }], + } +} + +async function expectCode( + code: string, + action: () => Promise +): Promise { + await assert.rejects(action, (error: unknown) => { + assert.ok( + error instanceof ConversationCommandError, + error instanceof Error + ? `${error.name}: ${error.message}; cause: ${String(error.cause)}` + : String(error) + ) + assert.equal(error.code, code) + return true + }) +} + +async function finalizeCompleted( + targetGenerationId: ReturnType +) { + const generation = await generationRepository.getGeneration({ + ownerId, + generationId: targetGenerationId, + }) + assert.ok(generation) + const body = `完成:${targetGenerationId}` + return generationRepository.finalizeGeneration({ + generationId: targetGenerationId, + leaseOwner: generation.leaseOwner ?? "missing-lease", + expectedCheckpointVersion: generation.checkpointVersion, + outcome: "completed", + checkpoint: { + ...emptyConversationGenerationCheckpoint(), + body, + contentState: "complete", + }, + usageCompleteness: "unavailable", + knownUsage: null, + }) +} + +function sendPayload( + targetThreadId: ReturnType, + suffix: string +) { + return { + conversationId: conversation, + turnId: turnId(`${prefix}:turn:${suffix}`), + userMessageId: messageId(`${prefix}:message:${suffix}:user`), + assistantMessageId: messageId(`${prefix}:message:${suffix}:assistant`), + generationId: generationId(`${prefix}:generation:${suffix}`), + content: content(`用户消息:${suffix}`), + modelId, + targetThreadId, + } +} + +let assertions = 0 +try { + assert.throws( + () => + assertConversationCommandApiEnabled({ + authority: "disabled", + legacyAuthorityEnabled: true, + }), + (error: unknown) => + error instanceof ConversationCommandError && error.code === "not_found" + ) + assert.throws( + () => + assertConversationCommandApiEnabled({ + authority: "canonical", + legacyAuthorityEnabled: true, + }), + (error: unknown) => + error instanceof ConversationCommandError && + error.code === "state_conflict" + ) + assertions += 2 + + await db.insert(user).values([ + { + id: ownerId, + name: "Command API Owner", + email: `${runId}-owner@command-api.invalid`, + emailVerified: true, + }, + { + id: outsiderId, + name: "Command API Outsider", + email: `${runId}-outsider@command-api.invalid`, + emailVerified: true, + }, + ]) + await seedRepository.createWorkspace({ + workspace: { id: workspace, revision: 0, lifecycle: "active" }, + ownerUserId: ownerId, + }) + await seedRepository.createProject({ + actorUserId: ownerId, + project: { + id: project, + workspaceId: workspace, + title: "Command API Test", + revision: 0, + lifecycle: "active", + }, + }) + + const create = command({ + scope: { type: "project", id: project }, + payload: { + conversationId: conversation, + rootThreadId: root, + title: "规范命令测试", + modelId, + }, + key: "create-conversation", + }) + const [createA, createB] = await Promise.all([ + service.createConversation(create), + service.createConversation(create), + ]) + assert.equal([createA.replayed, createB.replayed].filter(Boolean).length, 1) + assert.deepEqual( + await service.listConversations({ + actorUserId: outsiderId, + projectId: project, + }), + [] + ) + assert.equal( + await service.getConversationSnapshot({ + actorUserId: outsiderId, + conversationId: conversation, + }), + null + ) + await expectCode("idempotency_conflict", () => + service.createConversation({ + ...create, + payload: { ...create.payload, title: "不同载荷" }, + }) + ) + await expectCode("not_found", () => + service.renameConversation( + command({ + actorUserId: outsiderId, + scope: { type: "conversation", id: conversation }, + expectedRevision: 0, + payload: { title: "越权重命名" }, + }) + ) + ) + assertions += 5 + + const rename = command({ + scope: { type: "conversation", id: conversation }, + expectedRevision: 0, + payload: { title: "已重命名" }, + }) + const renamed = await service.renameConversation(rename) + assert.equal(renamed.revisions[conversation], 1) + await expectCode("version_conflict", () => + service.renameConversation( + command({ + scope: { type: "conversation", id: conversation }, + expectedRevision: 0, + payload: { title: "旧版本" }, + }) + ) + ) + assertions += 2 + + const rootSend = sendPayload(root, "root") + const rootSendCommand = command({ + scope: { type: "thread", id: root }, + expectedRevision: 0, + key: "send-root", + payload: { + conversationId: conversation, + turnId: rootSend.turnId, + userMessageId: rootSend.userMessageId, + assistantMessageId: rootSend.assistantMessageId, + generationId: rootSend.generationId, + content: rootSend.content, + modelId, + }, + }) + const rootSendRaceCommand = command({ + scope: { type: "thread", id: root }, + expectedRevision: 0, + key: "send-root-race", + payload: rootSendCommand.payload, + }) + const rootSendOutcomes = await Promise.allSettled([ + service.sendTurn(rootSendCommand), + service.sendTurn(rootSendRaceCommand), + ]) + const rootSendSuccesses = rootSendOutcomes.filter( + ( + outcome + ): outcome is PromiseFulfilledResult< + Awaited> + > => outcome.status === "fulfilled" + ) + const rootSendFailures = rootSendOutcomes.filter( + (outcome): outcome is PromiseRejectedResult => outcome.status === "rejected" + ) + assert.equal(rootSendSuccesses.length, 1) + assert.equal(rootSendFailures.length, 1) + assert.ok(rootSendFailures[0]?.reason instanceof ConversationCommandError) + assert.equal(rootSendFailures[0]?.reason.code, "version_conflict") + const winningRootSendCommand = + rootSendOutcomes[0]?.status === "fulfilled" + ? rootSendCommand + : rootSendRaceCommand + const sentRoot = rootSendSuccesses[0]!.value + assert.equal(sentRoot.replayed, false) + assert.equal(scheduled.length, 1) + const replayedRoot = await service.sendTurn(winningRootSendCommand) + assert.equal(replayedRoot.replayed, true) + assert.equal(scheduled.length, 1) + const rootGeneration = await service.getGeneration({ + actorUserId: ownerId, + generationId: rootSend.generationId, + }) + assert.equal(rootGeneration?.status, "running") + let localAbortCalled = false + const stopRequested = await service.stopGeneration({ + actorUserId: ownerId, + generationId: rootSend.generationId, + notifyLocalAbort: () => { + localAbortCalled = true + }, + }) + assert.equal(stopRequested?.status, "stop_requested") + assert.equal(localAbortCalled, true) + const stopped = await generationRepository.finalizeGeneration({ + generationId: rootSend.generationId, + leaseOwner: rootGeneration?.leaseOwner ?? "missing", + expectedCheckpointVersion: 0, + outcome: "stopped", + checkpoint: emptyConversationGenerationCheckpoint(), + usageCompleteness: "unavailable", + knownUsage: null, + }) + assert.equal(stopped?.status, "stopped") + assertions += 12 + + const threadA = threadId(`${prefix}:thread:A`) + const forkACommand = command({ + scope: { type: "thread", id: root }, + expectedRevision: 1, + key: "fork-A", + payload: { + conversationId: conversation, + forkId: threadForkId(`${prefix}:fork:A`), + childThreadId: threadA, + sourceMessageId: rootSend.userMessageId, + modelId, + localTitle: "A", + }, + }) + const forkARaceCommand = command({ + scope: { type: "thread", id: root }, + expectedRevision: 1, + key: "fork-A-race", + payload: forkACommand.payload, + }) + const forkAOutcomes = await Promise.allSettled([ + service.forkThread(forkACommand), + service.forkThread(forkARaceCommand), + ]) + const forkASuccesses = forkAOutcomes.filter( + ( + outcome + ): outcome is PromiseFulfilledResult< + Awaited> + > => outcome.status === "fulfilled" + ) + const forkAFailures = forkAOutcomes.filter( + (outcome): outcome is PromiseRejectedResult => outcome.status === "rejected" + ) + assert.equal(forkASuccesses.length, 1) + assert.equal(forkAFailures.length, 1) + assert.ok(forkAFailures[0]?.reason instanceof ConversationCommandError) + assert.equal(forkAFailures[0]?.reason.code, "version_conflict") + const forkA = forkASuccesses[0]!.value + assert.equal(forkA.revisions[conversation], 2) + assertions += 4 + + const sendA = sendPayload(threadA, "A") + await service.sendTurn( + command({ + scope: { type: "thread", id: threadA }, + expectedRevision: 0, + payload: { + conversationId: conversation, + turnId: sendA.turnId, + userMessageId: sendA.userMessageId, + assistantMessageId: sendA.assistantMessageId, + generationId: sendA.generationId, + content: sendA.content, + modelId, + }, + }) + ) + assert.equal( + (await finalizeCompleted(sendA.generationId))?.status, + "completed" + ) + const renamedA = await service.renameThread( + command({ + scope: { type: "thread", id: threadA }, + expectedRevision: 1, + payload: { title: "A 已重命名" }, + }) + ) + assert.equal(renamedA.revisions[threadA], 2) + + const threadB = threadId(`${prefix}:thread:B`) + await service.forkThread( + command({ + scope: { type: "thread", id: threadA }, + expectedRevision: 2, + payload: { + conversationId: conversation, + forkId: threadForkId(`${prefix}:fork:B`), + childThreadId: threadB, + sourceMessageId: sendA.assistantMessageId, + modelId, + localTitle: "B", + }, + }) + ) + const sendB = sendPayload(threadB, "B1") + await service.sendTurn( + command({ + scope: { type: "thread", id: threadB }, + expectedRevision: 0, + payload: { + conversationId: conversation, + turnId: sendB.turnId, + userMessageId: sendB.userMessageId, + assistantMessageId: sendB.assistantMessageId, + generationId: sendB.generationId, + content: sendB.content, + modelId, + }, + }) + ) + assert.equal( + (await finalizeCompleted(sendB.generationId))?.status, + "completed" + ) + const threadC = threadId(`${prefix}:thread:C`) + await service.forkThread( + command({ + scope: { type: "thread", id: threadB }, + expectedRevision: 3, + payload: { + conversationId: conversation, + forkId: threadForkId(`${prefix}:fork:C`), + childThreadId: threadC, + sourceMessageId: sendB.assistantMessageId, + modelId, + localTitle: "C", + }, + }) + ) + const nested = await service.getConversationSnapshot({ + actorUserId: ownerId, + conversationId: conversation, + }) + assert.ok(nested) + assert.deepEqual(nested.contextMessageIdsByThread[threadC], [ + rootSend.userMessageId, + sendA.userMessageId, + sendA.assistantMessageId, + sendB.userMessageId, + sendB.assistantMessageId, + ]) + assert.equal(Object.keys(nested.snapshot.messages).length, 6) + assertions += 7 + + const [forkCountBefore] = await db + .select({ value: count(conversationThreads.id) }) + .from(conversationThreads) + .where(eq(conversationThreads.conversationId, conversation)) + const [eventCountBefore] = await db + .select({ value: count(conversationOutboxEvents.id) }) + .from(conversationOutboxEvents) + .where(eq(conversationOutboxEvents.actorId, ownerId)) + await expectCode("semantic_validation", () => + service.forkThread( + command({ + scope: { type: "thread", id: threadB }, + expectedRevision: 4, + payload: { + conversationId: conversation, + forkId: threadForkId(`${prefix}:fork:invalid`), + childThreadId: threadId(`${prefix}:thread:invalid`), + sourceMessageId: rootSend.userMessageId, + modelId, + }, + }) + ) + ) + const [forkCountAfter] = await db + .select({ value: count(conversationThreads.id) }) + .from(conversationThreads) + .where(eq(conversationThreads.conversationId, conversation)) + assert.equal(forkCountAfter?.value, forkCountBefore?.value) + const [eventCountAfter] = await db + .select({ value: count(conversationOutboxEvents.id) }) + .from(conversationOutboxEvents) + .where(eq(conversationOutboxEvents.actorId, ownerId)) + assert.equal(eventCountAfter?.value, eventCountBefore?.value) + assertions += 3 + + const beforeRegenerate = await service.getConversationSnapshot({ + actorUserId: ownerId, + conversationId: conversation, + }) + assert.ok(beforeRegenerate) + const turnB1 = beforeRegenerate.snapshot.turns[sendB.turnId] + assert.ok(turnB1) + const regeneratedAssistant = messageId(`${prefix}:message:B1:assistant:regen`) + const regeneratedGeneration = generationId(`${prefix}:generation:B1:regen`) + await service.regenerateTurn( + command({ + scope: { type: "turn", id: sendB.turnId }, + expectedRevision: turnB1.revision, + payload: { + conversationId: conversation, + assistantMessageId: regeneratedAssistant, + generationId: regeneratedGeneration, + sourceAssistantMessageId: sendB.assistantMessageId, + modelId, + }, + }) + ) + const regenerated = await finalizeCompleted(regeneratedGeneration) + assert.equal(regenerated?.status, "completed") + await service.selectTurnVariant( + command({ + scope: { type: "turn", id: sendB.turnId }, + expectedRevision: 3, + payload: { + conversationId: conversation, + messageId: sendB.assistantMessageId, + role: "assistant" as const, + }, + }) + ) + const selected = await service.getConversationSnapshot({ + actorUserId: ownerId, + conversationId: conversation, + }) + assert.equal( + selected?.snapshot.turns[sendB.turnId]?.activeAssistantMessageId, + sendB.assistantMessageId + ) + assertions += 4 + + const sendB2 = sendPayload(threadB, "B2") + await service.sendTurn( + command({ + scope: { type: "thread", id: threadB }, + expectedRevision: 1, + payload: { + conversationId: conversation, + turnId: sendB2.turnId, + userMessageId: sendB2.userMessageId, + assistantMessageId: sendB2.assistantMessageId, + generationId: sendB2.generationId, + content: sendB2.content, + modelId, + }, + }) + ) + await finalizeCompleted(sendB2.generationId) + await expectCode("fork_required", () => + service.editTurnInput( + command({ + scope: { type: "turn", id: sendB.turnId }, + expectedRevision: 4, + payload: { + conversationId: conversation, + userMessageId: messageId(`${prefix}:message:B1:user:edit`), + assistantMessageId: messageId(`${prefix}:message:B1:assistant:edit`), + generationId: generationId(`${prefix}:generation:B1:edit`), + sourceUserMessageId: sendB.userMessageId, + content: content("非法历史编辑"), + modelId, + }, + }) + ) + ) + const editedUser = messageId(`${prefix}:message:B2:user:edit`) + const editedAssistant = messageId(`${prefix}:message:B2:assistant:edit`) + const editedGeneration = generationId(`${prefix}:generation:B2:edit`) + await service.editTurnInput( + command({ + scope: { type: "turn", id: sendB2.turnId }, + expectedRevision: 1, + payload: { + conversationId: conversation, + userMessageId: editedUser, + assistantMessageId: editedAssistant, + generationId: editedGeneration, + sourceUserMessageId: sendB2.userMessageId, + content: content("合法尾部编辑"), + modelId, + }, + }) + ) + await finalizeCompleted(editedGeneration) + const editedSnapshot = await service.getConversationSnapshot({ + actorUserId: ownerId, + conversationId: conversation, + }) + assert.equal( + editedSnapshot?.snapshot.turns[sendB2.turnId]?.activeUserMessageId, + editedUser + ) + assertions += 3 + + await expectCode("conversation_action_required", () => + service.setThreadLifecycle( + command({ + scope: { type: "thread", id: root }, + expectedRevision: 1, + payload: { lifecycle: "archived" as const }, + }) + ) + ) + const archivedC = await service.setThreadLifecycle( + command({ + scope: { type: "thread", id: threadC }, + expectedRevision: 0, + payload: { lifecycle: "archived" as const }, + }) + ) + assert.equal(archivedC.revisions[threadC], 1) + await service.setThreadLifecycle( + command({ + scope: { type: "thread", id: threadC }, + expectedRevision: 1, + payload: { lifecycle: "active" as const }, + }) + ) + assertions += 3 + + const runningC = sendPayload(threadC, "C-running") + await service.sendTurn( + command({ + scope: { type: "thread", id: threadC }, + expectedRevision: 2, + payload: { + conversationId: conversation, + turnId: runningC.turnId, + userMessageId: runningC.userMessageId, + assistantMessageId: runningC.assistantMessageId, + generationId: runningC.generationId, + content: runningC.content, + modelId, + }, + }) + ) + await expectCode("state_conflict", () => + service.deleteConversation( + command({ + scope: { type: "conversation", id: conversation }, + expectedRevision: 4, + payload: {}, + }) + ) + ) + await service.stopGeneration({ + actorUserId: ownerId, + generationId: runningC.generationId, + notifyLocalAbort: () => {}, + }) + const runningRecord = await generationRepository.getGeneration({ + ownerId, + generationId: runningC.generationId, + }) + await generationRepository.finalizeGeneration({ + generationId: runningC.generationId, + leaseOwner: runningRecord?.leaseOwner ?? "missing", + expectedCheckpointVersion: 0, + outcome: "stopped", + checkpoint: emptyConversationGenerationCheckpoint(), + usageCompleteness: "unavailable", + knownUsage: null, + }) + assertions += 2 + + const executorProbe = sendPayload(threadC, "C-executor") + await service.sendTurn( + command({ + scope: { type: "thread", id: threadC }, + expectedRevision: 3, + payload: { + conversationId: conversation, + turnId: executorProbe.turnId, + userMessageId: executorProbe.userMessageId, + assistantMessageId: executorProbe.assistantMessageId, + generationId: executorProbe.generationId, + content: executorProbe.content, + modelId, + }, + }) + ) + const executionService = new CanonicalGenerationApplicationService( + generationRepository, + { + async execute({ generation, onCheckpoint }) { + await onCheckpoint({ + ...emptyConversationGenerationCheckpoint(), + body: "执行", + contentState: "streaming", + }) + return { + outcome: "completed" as const, + checkpoint: { + ...emptyConversationGenerationCheckpoint(), + body: `执行完成:${generation.id}`, + contentState: "complete" as const, + }, + usageCompleteness: "unavailable" as const, + knownUsage: null, + } + }, + }, + new InMemoryGenerationAbortRegistry(), + { checkpointThrottleMs: 0 } + ) + + const consumed: string[] = [] + const dispatcher = new DrizzleConversationOutboxDispatcher( + { + async consume(event) { + consumed.push(event.id) + if (event.type !== "GenerationRequested") return + const payload = event.payload as { + generationId: string + ownerId: string + leaseOwner: string + } + const generation = await generationRepository.getGeneration({ + ownerId: payload.ownerId, + generationId: generationId(payload.generationId), + }) + assert.ok(generation) + await executionService.executeExisting(generation, payload.leaseOwner) + }, + }, + `${prefix}:dispatcher` + ) + const firstDispatch = await dispatcher.dispatchPending({ limit: 100 }) + const secondDispatch = await dispatcher.dispatchPending({ limit: 100 }) + assert.ok(firstDispatch > 0) + assert.equal(secondDispatch, 0) + assert.equal(new Set(consumed).size, consumed.length) + const [pending] = await db + .select({ value: count(conversationOutboxEvents.id) }) + .from(conversationOutboxEvents) + .where(eq(conversationOutboxEvents.actorId, ownerId)) + assert.equal(pending?.value, consumed.length) + const executed = await generationRepository.getGeneration({ + ownerId, + generationId: executorProbe.generationId, + }) + assert.equal(executed?.status, "completed") + assert.equal( + executed?.checkpoint.body, + `执行完成:${executorProbe.generationId}` + ) + assertions += 6 + + const [commandCount] = await db + .select({ value: count(conversationCommandRecords.id) }) + .from(conversationCommandRecords) + .where(eq(conversationCommandRecords.actorId, ownerId)) + assert.ok((commandCount?.value ?? 0) > 10) + assertions += 1 + + const archivedConversation = await service.setConversationLifecycle( + command({ + scope: { type: "conversation", id: conversation }, + expectedRevision: 4, + payload: { lifecycle: "archived" as const }, + }) + ) + assert.equal(archivedConversation.revisions[conversation], 5) + assert.equal( + ( + await service.listConversations({ + actorUserId: ownerId, + projectId: project, + }) + ).length, + 0 + ) + await service.setConversationLifecycle( + command({ + scope: { type: "conversation", id: conversation }, + expectedRevision: 5, + payload: { lifecycle: "active" as const }, + }) + ) + const deleted = await service.deleteConversation( + command({ + scope: { type: "conversation", id: conversation }, + expectedRevision: 6, + payload: {}, + }) + ) + assert.deepEqual(deleted.delta.remove.conversations, [conversation]) + assert.equal( + await service.getConversationSnapshot({ + actorUserId: ownerId, + conversationId: conversation, + }), + null + ) + assertions += 5 + + console.log( + JSON.stringify({ + ok: true, + assertions, + topology: "root → A → B → C", + outboxExactlyOnce: true, + noWholeTreeWrite: true, + model: modelId, + }) + ) +} finally { + await db + .delete(conversationGenerations) + .where(eq(conversationGenerations.workspaceId, workspace)) + await db.delete(workspaces).where(eq(workspaces.id, workspace)) + await db.delete(user).where(eq(user.id, ownerId)) + await db.delete(user).where(eq(user.id, outsiderId)) + await globalThis.__dbClient?.end() + globalThis.__dbClient = undefined +} From 036f8916945666dd50ce1b2d9090a3cb681d8809 Mon Sep 17 00:00:00 2001 From: zilin <276161014@qq.com> Date: Sat, 22 Aug 2026 04:19:30 +0800 Subject: [PATCH 06/32] feat(client): normalize canonical conversation state --- .../message-feedback/route.ts | 20 + .../messages/[messageId]/feedback/route.ts | 51 + app/thread-chat/[treeId]/page.tsx | 7 +- .../canonical/canonical-thread-canvas.tsx | 77 + .../canonical/canonical-thread-chat.css | 127 + .../canonical/canonical-thread-chat.tsx | 865 +++++ drizzle/0010_smiling_wolfpack.sql | 14 + drizzle/0011_swift_taskmaster.sql | 2 + drizzle/meta/0010_snapshot.json | 3298 ++++++++++++++++ drizzle/meta/0011_snapshot.json | 3330 +++++++++++++++++ drizzle/meta/_journal.json | 14 + lib/db/schema.ts | 45 + .../client/conversation-client-contracts.ts | 342 ++ .../client/conversation-client-gateway.ts | 537 +++ .../client/conversation-client-selectors.ts | 300 ++ .../client/generation-coordinator.ts | 150 + .../normalized-conversation-store.test.ts | 856 +++++ .../client/normalized-conversation-store.ts | 556 +++ lib/thread-chat/client/ui-workspace.ts | 278 +- .../client/use-normalized-conversation.ts | 29 + .../conversation-message-feedback.ts | 24 + .../canonical-ai-generation-executor.test.ts | 173 + .../canonical-ai-generation-executor.ts | 116 + .../http/conversation-command-composition.ts | 40 +- .../canonical-message-feedback-repository.ts | 147 + .../drizzle-conversation-command-store.ts | 71 +- .../tasks.md | 71 +- package.json | 4 +- scripts/seed-canonical-browser-fixture.ts | 93 + scripts/test-conversation-command-api.ts | 86 +- scripts/test-conversation-generation.ts | 21 +- 31 files changed, 11675 insertions(+), 69 deletions(-) create mode 100644 app/api/conversations/[conversationId]/message-feedback/route.ts create mode 100644 app/api/conversations/[conversationId]/messages/[messageId]/feedback/route.ts create mode 100644 app/thread-chat/canonical/canonical-thread-canvas.tsx create mode 100644 app/thread-chat/canonical/canonical-thread-chat.css create mode 100644 app/thread-chat/canonical/canonical-thread-chat.tsx create mode 100644 drizzle/0010_smiling_wolfpack.sql create mode 100644 drizzle/0011_swift_taskmaster.sql create mode 100644 drizzle/meta/0010_snapshot.json create mode 100644 drizzle/meta/0011_snapshot.json create mode 100644 lib/thread-chat/client/conversation-client-contracts.ts create mode 100644 lib/thread-chat/client/conversation-client-gateway.ts create mode 100644 lib/thread-chat/client/conversation-client-selectors.ts create mode 100644 lib/thread-chat/client/generation-coordinator.ts create mode 100644 lib/thread-chat/client/normalized-conversation-store.test.ts create mode 100644 lib/thread-chat/client/normalized-conversation-store.ts create mode 100644 lib/thread-chat/client/use-normalized-conversation.ts create mode 100644 lib/thread-chat/contracts/conversation-message-feedback.ts create mode 100644 lib/thread-chat/generation/canonical-ai-generation-executor.test.ts create mode 100644 lib/thread-chat/generation/canonical-ai-generation-executor.ts create mode 100644 lib/thread-chat/persistence/canonical-message-feedback-repository.ts create mode 100644 scripts/seed-canonical-browser-fixture.ts diff --git a/app/api/conversations/[conversationId]/message-feedback/route.ts b/app/api/conversations/[conversationId]/message-feedback/route.ts new file mode 100644 index 00000000..2cc7daac --- /dev/null +++ b/app/api/conversations/[conversationId]/message-feedback/route.ts @@ -0,0 +1,20 @@ +import { canonicalMessageFeedbackListSchema } from "@/lib/thread-chat/contracts/conversation-message-feedback" +import { conversationId } from "@/lib/thread-chat/domain/conversation-model" +import { + authenticatedActor, + withConversationRoute, +} from "@/lib/thread-chat/http/conversation-command-http" +import { listCanonicalMessageFeedback } from "@/lib/thread-chat/persistence/canonical-message-feedback-repository" + +type Context = { params: Promise<{ conversationId: string }> } +export async function GET(_request: Request, { params }: Context) { + return withConversationRoute(async () => { + const actor = await authenticatedActor() + const targetConversationId = conversationId((await params).conversationId) + const feedback = await listCanonicalMessageFeedback({ + userId: actor.userId, + conversationId: targetConversationId, + }) + return Response.json(canonicalMessageFeedbackListSchema.parse({ feedback })) + }) +} diff --git a/app/api/conversations/[conversationId]/messages/[messageId]/feedback/route.ts b/app/api/conversations/[conversationId]/messages/[messageId]/feedback/route.ts new file mode 100644 index 00000000..0aac4e60 --- /dev/null +++ b/app/api/conversations/[conversationId]/messages/[messageId]/feedback/route.ts @@ -0,0 +1,51 @@ +import { ConversationCommandError } from "@/lib/thread-chat/application/conversation-command-contracts" +import { + setCanonicalMessageFeedbackRequestSchema, + setCanonicalMessageFeedbackResponseSchema, +} from "@/lib/thread-chat/contracts/conversation-message-feedback" +import { + conversationId, + messageId, + threadId, +} from "@/lib/thread-chat/domain/conversation-model" +import { + authenticatedActor, + parseJson, + withConversationRoute, +} from "@/lib/thread-chat/http/conversation-command-http" +import { setCanonicalMessageFeedback } from "@/lib/thread-chat/persistence/canonical-message-feedback-repository" + +type Context = { + params: Promise<{ conversationId: string; messageId: string }> +} +export async function PUT(request: Request, { params }: Context) { + return withConversationRoute(async () => { + const actor = await authenticatedActor() + const ids = await params + const body = await parseJson( + request, + setCanonicalMessageFeedbackRequestSchema + ) + const result = await setCanonicalMessageFeedback({ + userId: actor.userId, + conversationId: conversationId(ids.conversationId), + threadId: threadId(body.threadId), + messageId: messageId(ids.messageId), + feedback: body.feedback, + }) + if (!result.ok) { + const message = { + not_found: "消息不存在", + not_completed: "只有已完成的 AI 回复可以评价", + missing_generation: "已完成回复缺少 completed Generation", + }[result.reason] + throw new ConversationCommandError( + result.reason === "not_found" ? "not_found" : "state_conflict", + message + ) + } + return Response.json( + setCanonicalMessageFeedbackResponseSchema.parse(result) + ) + }) +} diff --git a/app/thread-chat/[treeId]/page.tsx b/app/thread-chat/[treeId]/page.tsx index bad9041d..be8eb9e3 100644 --- a/app/thread-chat/[treeId]/page.tsx +++ b/app/thread-chat/[treeId]/page.tsx @@ -1,6 +1,7 @@ import { notFound } from "next/navigation" import { isValidTreeId } from "@/lib/chat/tree-id" import { ThreadChatDemo } from "../thread-chat-demo" +import { CanonicalThreadChat } from "../canonical/canonical-thread-chat" import { threadChatMetadata } from "../page-metadata" export const metadata = threadChatMetadata @@ -17,5 +18,9 @@ export default async function ThreadChatTreePage({ }) { const { treeId } = await params if (!isValidTreeId(treeId)) notFound() - return + return process.env.CONVERSATION_CLIENT_AUTHORITY === "canonical" ? ( + + ) : ( + + ) } diff --git a/app/thread-chat/canonical/canonical-thread-canvas.tsx b/app/thread-chat/canonical/canonical-thread-canvas.tsx new file mode 100644 index 00000000..5b661254 --- /dev/null +++ b/app/thread-chat/canonical/canonical-thread-canvas.tsx @@ -0,0 +1,77 @@ +"use client" + +import { useMemo } from "react" +import { + Background, + Controls, + MiniMap, + ReactFlow, + ReactFlowProvider, + type Node, +} from "@xyflow/react" +import "@xyflow/react/dist/style.css" + +import type { CanonicalClientState } from "@/lib/thread-chat/client/normalized-conversation-store" +import { + deriveConversationClientIndexes, + selectThreadTitle, +} from "@/lib/thread-chat/client/conversation-client-selectors" +import type { + ConversationId, + ThreadId, +} from "@/lib/thread-chat/domain/conversation-model" + +export function CanonicalThreadCanvas({ + state, + conversationId, + onOpenThread, +}: { + state: CanonicalClientState + conversationId: ConversationId + onOpenThread: (threadId: ThreadId) => void +}) { + const { nodes, edges } = useMemo(() => { + const indexes = deriveConversationClientIndexes(state) + const ids = indexes.threadIdsByConversation[conversationId] ?? [] + const nodes: Node[] = ids.map((id) => ({ + id, + position: { + x: (indexes.depthByThread[id] ?? 0) * 320, + y: + ids + .filter( + (candidate) => + (indexes.depthByThread[candidate] ?? 0) === + (indexes.depthByThread[id] ?? 0) + ) + .indexOf(id) * 150, + }, + data: { label: selectThreadTitle(state, id) }, + })) + return { + nodes, + edges: indexes.canvasEdges.map((edge) => ({ + id: edge.id, + source: edge.source, + target: edge.target, + })), + } + }, [conversationId, state]) + return ( +
+ + onOpenThread(node.id as ThreadId)} + > + + + + + +
+ ) +} diff --git a/app/thread-chat/canonical/canonical-thread-chat.css b/app/thread-chat/canonical/canonical-thread-chat.css new file mode 100644 index 00000000..363fce69 --- /dev/null +++ b/app/thread-chat/canonical/canonical-thread-chat.css @@ -0,0 +1,127 @@ +.canonical-chat { + height: 100dvh; + display: flex; + flex-direction: column; +} +.canonical-chat .topbar details { + position: relative; +} +.canonical-action-error { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 8px 14px; + color: #7f1d1d; + background: #fef2f2; + border-bottom: 1px solid #fecaca; +} +.canonical-action-error button { + border: 0; + color: inherit; + background: transparent; + cursor: pointer; +} +.canonical-title-editor { + display: flex; + align-items: end; + gap: 8px; + padding: 10px 14px; + background: white; + border-bottom: 1px solid var(--rule-strong); +} +.canonical-title-editor label { + display: grid; + flex: 1; + gap: 4px; + font-size: 12px; +} +.canonical-title-editor input, +.canonical-thread-title-editor input { + min-width: 0; + padding: 6px 8px; + border: 1px solid var(--rule-strong); + border-radius: 6px; +} +.canonical-thread-title-editor { + display: flex; + gap: 5px; + padding-top: 6px; +} +.canonical-chat .topbar summary { + list-style: none; +} +.canonical-tree-menu { + position: absolute; + right: 0; + top: 34px; + z-index: 20; + width: 260px; + padding: 8px; + background: white; + border: 1px solid var(--rule-strong); + box-shadow: 0 12px 30px #0002; + display: grid; + gap: 4px; +} +.canonical-tree-menu button { + text-align: left; + padding: 7px; + border: 0; + background: transparent; + cursor: pointer; +} +.canonical-tree-row { + display: grid; + grid-template-columns: 1fr auto; + gap: 4px; +} +.canonical-messages { + flex: 1; + min-height: 0; + overflow: auto; + padding: 18px; +} +.canonical-actions { + display: flex; + gap: 8px; + margin-top: 5px; +} +.canonical-actions button { + border: 0; + background: transparent; + color: var(--ink-faint); + font-size: 11px; + cursor: pointer; +} +.canonical-actions button:disabled { + cursor: not-allowed; + opacity: 0.45; +} +.canonical-research, +.canonical-structured { + margin-top: 8px; + padding: 8px; + border: 1px solid var(--rule); + border-radius: 7px; + font-size: 12px; +} +.canonical-research > div { + margin-top: 4px; + color: var(--ink-muted); +} +.canonical-structured pre { + overflow: auto; + white-space: pre-wrap; +} +.canonical-artifact { + margin-top: 8px; + padding: 8px; + border: 1px solid var(--rule-strong); + border-radius: 7px; +} +.canonical-canvas { + flex: 1; + min-height: 0; + width: 100%; +} diff --git a/app/thread-chat/canonical/canonical-thread-chat.tsx b/app/thread-chat/canonical/canonical-thread-chat.tsx new file mode 100644 index 00000000..63187ab2 --- /dev/null +++ b/app/thread-chat/canonical/canonical-thread-chat.tsx @@ -0,0 +1,865 @@ +"use client" + +import dynamic from "next/dynamic" +import { useRouter } from "next/navigation" +import { + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from "react" + +import { MarkdownBody } from "../chat/message/markdown-body" +import { ConversationComposer } from "../chat/composer/conversation-composer" +import "../thread-chat.css" +import "./canonical-thread-chat.css" +import { + createConversationClientGateway, + type ConversationClientGateway, +} from "@/lib/thread-chat/client/conversation-client-gateway" +import { + createGenerationCoordinator, + type GenerationCoordinator, +} from "@/lib/thread-chat/client/generation-coordinator" +import { + createNormalizedConversationStore, + canonicalGenerationRecord, +} from "@/lib/thread-chat/client/normalized-conversation-store" +import { + deriveConversationClientIndexes, + selectThreadLineage, + selectThreadMessages, + selectThreadTitle, +} from "@/lib/thread-chat/client/conversation-client-selectors" +import { + createConversationUiWorkspaceStore, + defaultConversationUiWorkspace, + parseConversationUiWorkspace, + serializeConversationUiWorkspace, +} from "@/lib/thread-chat/client/ui-workspace" +import { + conversationId, + generationId, + messageId, + threadForkId, + threadId, + turnId, + type ConversationMessage, + type ThreadId, +} from "@/lib/thread-chat/domain/conversation-model" +import type { MessageFeedback } from "@/lib/thread-chat/contracts/message-feedback" + +const CanonicalThreadCanvas = dynamic( + () => + import("./canonical-thread-canvas").then( + (module) => module.CanonicalThreadCanvas + ), + { ssr: false, loading: () =>
画布加载中…
} +) +const textOf = (message: ConversationMessage) => + message.content.parts + .filter((part) => part.type === "text") + .map((part) => part.text) + .join("") + +export function CanonicalThreadChat({ id }: { id: string }) { + const router = useRouter() + const targetId = conversationId(id) + const store = useMemo(() => createNormalizedConversationStore(), []) + const gateway = useMemo( + () => createConversationClientGateway({ store }), + [store] + ) + const coordinator = useMemo( + () => createGenerationCoordinator({ store, gateway }), + [gateway, store] + ) + const [loaded, setLoaded] = useState(false) + const [error, setError] = useState(null) + const [actionError, setActionError] = useState(null) + const [feedbackByMessageId, setFeedbackByMessageId] = useState< + Readonly> + >({}) + const [conversationTitleDraft, setConversationTitleDraft] = useState< + string | null + >(null) + const [conversationList, setConversationList] = useState< + Awaited> + >([]) + const canonicalVersion = useSyncExternalStore( + (fn) => store.subscribe("canonical", fn), + () => store.snapshotForKey("canonical"), + () => 0 + ) + const state = store.getState() + const conversation = state.conversationsById[targetId] + const workspaceStore = useMemo( + () => + createConversationUiWorkspaceStore( + defaultConversationUiWorkspace({ + conversationId: targetId, + rootThreadId: threadId(`${targetId}:ui-pending-root`), + }) + ), + [targetId] + ) + const workspaceHydrated = useRef(false) + const coordinatorDisposeTimer = useRef | null>( + null + ) + const workspace = useSyncExternalStore( + workspaceStore.subscribe, + workspaceStore.getState, + workspaceStore.getState + ) + + useEffect(() => { + if (coordinatorDisposeTimer.current) { + clearTimeout(coordinatorDisposeTimer.current) + coordinatorDisposeTimer.current = null + } + gateway.loadConversation(targetId).then( + () => setLoaded(true), + (cause) => + setError(cause instanceof Error ? cause.message : String(cause)) + ) + }, [gateway, targetId]) + useEffect(() => { + if (!loaded) return + void gateway.listMessageFeedback(targetId).then( + (entries) => + setFeedbackByMessageId( + Object.fromEntries( + entries.map((entry) => [entry.messageId, entry.feedback]) + ) + ), + (cause) => + setActionError(cause instanceof Error ? cause.message : String(cause)) + ) + }, [gateway, loaded, targetId]) + useEffect(() => { + if (!conversation) return + void gateway + .listConversations(conversation.projectId, true) + .then(setConversationList) + }, [canonicalVersion, conversation, gateway]) + useEffect(() => { + const onVisibility = () => + coordinator.setVisibility( + document.visibilityState === "hidden" ? "hidden" : "visible" + ) + document.addEventListener("visibilitychange", onVisibility) + return () => { + document.removeEventListener("visibilitychange", onVisibility) + // React Strict Mode 会立即 setup → cleanup → setup;延迟释放让第二次 setup 取消它。 + coordinatorDisposeTimer.current = setTimeout( + () => coordinator.dispose(), + 0 + ) + } + }, [coordinator]) + useEffect(() => { + if (!conversation) return + const key = `thread-chat:canonical-ui:${targetId}` + if (!workspaceHydrated.current) { + workspaceStore.hydrate( + parseConversationUiWorkspace({ + raw: localStorage.getItem(key), + conversationId: targetId, + rootThreadId: conversation.rootThreadId, + threads: state.threadsById, + }) + ) + workspaceHydrated.current = true + } + const release = workspaceStore.subscribe(() => { + localStorage.setItem( + key, + serializeConversationUiWorkspace(workspaceStore.getState()) + ) + }) + return release + }, [conversation, state.threadsById, targetId, workspaceStore]) + useEffect(() => { + if (conversation) + workspaceStore.reconcileThreads( + state.threadsById, + conversation.rootThreadId + ) + }, [canonicalVersion, conversation, state.threadsById, workspaceStore]) + + if (error) + return ( +
+
规范 Conversation 加载失败:{error}
+
+ ) + if ( + !loaded || + !conversation || + !workspace.visibleThreadIds.includes(conversation.rootThreadId) + ) + return ( +
+
规范 Conversation 加载中…
+
+ ) + const indexes = deriveConversationClientIndexes(state) + const allThreads = indexes.threadIdsByConversation[targetId] ?? [] + const send = async (targetThreadId: ThreadId, text: string) => { + const ids = { + turn: turnId(crypto.randomUUID()), + user: messageId(crypto.randomUUID()), + assistant: messageId(crypto.randomUUID()), + generation: generationId(crypto.randomUUID()), + } + workspaceStore.setDraft(targetThreadId, text) + try { + await gateway.sendTurn( + targetThreadId, + { + conversationId: targetId, + turnId: ids.turn, + userMessageId: ids.user, + assistantMessageId: ids.assistant, + generationId: ids.generation, + content: { schemaVersion: 1, parts: [{ type: "text", text }] }, + modelId: state.threadsById[targetThreadId]!.modelId, + }, + { + overlay: { + kind: "send", + presentationKey: ids.turn, + threadId: targetThreadId, + draft: text, + }, + } + ) + workspaceStore.setDraft(targetThreadId, "") + coordinator.subscribe(ids.generation, () => {}) + } catch (cause) { + setActionError(cause instanceof Error ? cause.message : String(cause)) + } + } + const fork = async ( + parent: ThreadId, + source: ConversationMessage, + quote?: string + ) => { + const child = threadId(crypto.randomUUID()) + try { + await gateway.forkThread( + parent, + { + conversationId: targetId, + forkId: threadForkId(crypto.randomUUID()), + childThreadId: child, + sourceMessageId: source.id, + modelId: state.threadsById[parent]!.modelId, + ...(quote + ? { + anchor: { quote: { exact: quote, prefix: "", suffix: "" } }, + } + : {}), + }, + { + overlay: { kind: "fork", presentationKey: child, threadId: parent }, + } + ) + workspaceStore.openThread(child) + } catch (cause) { + setActionError(cause instanceof Error ? cause.message : String(cause)) + } + } + const runAction = (action: Promise) => { + setActionError(null) + void action.catch((cause) => + setActionError(cause instanceof Error ? cause.message : String(cause)) + ) + } + const open = (target: ThreadId) => workspaceStore.openThread(target) + return ( +
+ {actionError && ( +
+ {actionError} + +
+ )} +
+ +
+ + 对话列表 · {conversationList.length} + +
+ {conversationList.map((item) => ( + + ))} +
+
+ +
+ + {conversation.customTitle ?? conversation.autoTitle ?? "新对话"} + +
+
+ + +
+ 会话树 · {allThreads.length} +
+ {allThreads.map((target) => ( +
+ + {target !== conversation.rootThreadId && ( + + )} +
+ ))} +
+
+
+ {conversationTitleDraft !== null && ( +
{ + event.preventDefault() + const title = conversationTitleDraft.trim() + if (!title) return + runAction(gateway.renameConversation(targetId, { title })) + setConversationTitleDraft(null) + }} + > + + + +
+ )} + {workspace.viewMode === "canvas" ? ( + { + open(target) + workspaceStore.update((current) => ({ + ...current, + viewMode: "columns", + })) + }} + /> + ) : ( +
+ {workspace.visibleThreadIds.map((targetThreadId) => ( + workspaceStore.closeThread(targetThreadId)} + onSend={(text) => void send(targetThreadId, text)} + onFork={(message, quote) => + void fork(targetThreadId, message, quote) + } + onStop={(targetGenerationId) => + void gateway.stopGeneration(targetGenerationId) + } + gateway={gateway} + coordinator={coordinator} + feedbackByMessageId={feedbackByMessageId} + onFeedback={async (message, feedback) => { + const saved = await gateway.setMessageFeedback({ + conversationId: targetId, + threadId: message.threadId, + messageId: message.id, + feedback, + }) + setFeedbackByMessageId((current) => { + const next = { ...current } + if (saved) next[message.id] = saved.feedback + else delete next[message.id] + return next + }) + }} + runAction={runAction} + onActionError={(cause) => + setActionError( + cause instanceof Error ? cause.message : String(cause) + ) + } + /> + ))} +
+ )} +
+ ) +} + +function CanonicalColumn({ + threadId: targetThreadId, + state, + onOpen, + onClose, + onSend, + onFork, + onStop, + gateway, + coordinator, + feedbackByMessageId, + onFeedback, + runAction, + onActionError, +}: { + threadId: ThreadId + state: ReturnType< + ReturnType["getState"] + > + onOpen: (id: ThreadId) => void + onClose: () => void + onSend: (text: string) => void + onFork: (message: ConversationMessage, quote?: string) => void + onStop: (id: ReturnType) => void + gateway: ConversationClientGateway + coordinator: GenerationCoordinator + feedbackByMessageId: Readonly> + onFeedback: ( + message: ConversationMessage, + feedback: MessageFeedback | null + ) => Promise + runAction: (action: Promise) => void + onActionError: (cause: unknown) => void +}) { + const [threadTitleDraft, setThreadTitleDraft] = useState(null) + const thread = state.threadsById[targetThreadId]! + const conversation = state.conversationsById[thread.conversationId]! + const messages = selectThreadMessages(state, targetThreadId) + const lineage = selectThreadLineage(state, targetThreadId) + const active = Object.values(state.generationsById) + .map(canonicalGenerationRecord) + .find( + (value) => + value?.threadId === targetThreadId && + ["running", "stop_requested"].includes(value.status) + ) + return ( +
+
+
+ {lineage.map((id, index) => ( + + + {index < lineage.length - 1 && } + + ))} +
+
+ + {selectThreadTitle(state, targetThreadId)} + + {targetThreadId !== conversation.rootThreadId && ( + <> + + + + + )} +
+ {threadTitleDraft !== null && ( +
{ + event.preventDefault() + const title = threadTitleDraft.trim() + if (!title) return + runAction(gateway.renameThread(targetThreadId, { title })) + setThreadTitleDraft(null) + }} + > + + setThreadTitleDraft(event.currentTarget.value) + } + /> + + +
+ )} +
+
+ {messages.map((message) => ( + { + const nextGenerationId = generationId(crypto.randomUUID()) + await gateway.editTurnInput(source.turnId, { + conversationId: conversation.id, + userMessageId: messageId(crypto.randomUUID()), + assistantMessageId: messageId(crypto.randomUUID()), + generationId: nextGenerationId, + sourceUserMessageId: source.id, + content: { schemaVersion: 1, parts: [{ type: "text", text }] }, + modelId: thread.modelId, + }) + coordinator.subscribe(nextGenerationId, () => {}) + }} + onRegenerate={async (source) => { + const nextGenerationId = generationId(crypto.randomUUID()) + await gateway.regenerateTurn(source.turnId, { + conversationId: conversation.id, + assistantMessageId: messageId(crypto.randomUUID()), + generationId: nextGenerationId, + sourceAssistantMessageId: source.id, + modelId: thread.modelId, + }) + coordinator.subscribe(nextGenerationId, () => {}) + }} + onSelect={(source) => + runAction( + gateway.selectTurnVariant(source.turnId, { + conversationId: conversation.id, + messageId: source.id, + role: source.role as "user" | "assistant", + }) + ) + } + feedback={feedbackByMessageId[message.id] ?? null} + onFeedback={(feedback) => runAction(onFeedback(message, feedback))} + onActionError={onActionError} + /> + ))} +
+ active && onStop(active.id)} + /> +
+ ) +} + +function CanonicalMessage({ + message, + onFork, + state, + onOpen, + onEdit, + onRegenerate, + onSelect, + feedback, + onFeedback, + onActionError, +}: { + message: ConversationMessage + onFork: (message: ConversationMessage, quote?: string) => void + state: ReturnType< + ReturnType["getState"] + > + onOpen: (id: ThreadId) => void + onEdit: (message: ConversationMessage, text: string) => Promise + onRegenerate: (message: ConversationMessage) => Promise + onSelect: (message: ConversationMessage) => void + feedback: MessageFeedback | null + onFeedback: (feedback: MessageFeedback | null) => void + onActionError: (cause: unknown) => void +}) { + const [selection, setSelection] = useState("") + const [copied, setCopied] = useState(false) + const copiedTimer = useRef | null>(null) + const text = textOf(message) + const variants = Object.values(state.messagesById) + .filter( + (candidate) => + candidate.turnId === message.turnId && candidate.role === message.role + ) + .sort( + (left, right) => + left.createdAt.localeCompare(right.createdAt) || + left.id.localeCompare(right.id) + ) + useEffect( + () => () => { + if (copiedTimer.current) clearTimeout(copiedTimer.current) + }, + [] + ) + const runMessageAction = (action: Promise) => { + void action.catch(onActionError) + } + return ( +
+ setSelection(window.getSelection()?.toString().trim() ?? "") + } + > +
{message.role}
+
+ {message.role === "assistant" ? ( + + ) : ( + text + )} + {message.content.parts + .filter((part) => part.type === "structured") + .map((part, index) => ( + + ))} + {message.content.parts + .filter((part) => part.type === "artifact-reference") + .map((part) => ( + + ))} +
+
+ + {message.role === "assistant" && ( + + )} + {message.role === "user" && ( + + )} + {message.role === "assistant" && ( + <> + + + + + )} + {variants.length > 1 && + variants.map((variant, index) => ( + + ))} +
+
+ ) +} + +function CanonicalStructuredPart({ + kind, + value, +}: { + kind: string + value: unknown +}) { + const activities = + kind === "research-activities" && Array.isArray(value) ? value : null + if (activities) + return ( +
+ 研究活动 + {activities.map((activity, index) => { + const item = + activity && typeof activity === "object" + ? (activity as Record) + : {} + return ( +
+ {String(item.kind ?? "研究步骤")} + · {String(item.status ?? "unknown")} + {Array.isArray(item.sources) && ( + · {item.sources.length} 个来源 + )} +
+ ) + })} +
+ ) + return ( +
+ {kind === "research-plan" ? "研究计划" : kind} +
{JSON.stringify(value, null, 2)}
+
+ ) +} diff --git a/drizzle/0010_smiling_wolfpack.sql b/drizzle/0010_smiling_wolfpack.sql new file mode 100644 index 00000000..5f53fda9 --- /dev/null +++ b/drizzle/0010_smiling_wolfpack.sql @@ -0,0 +1,14 @@ +CREATE TABLE "thread_chat"."conversation_message_feedback" ( + "user_id" text NOT NULL, + "conversation_id" text NOT NULL, + "thread_id" text NOT NULL, + "message_id" text NOT NULL, + "feedback" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "conversation_message_feedback_pk" PRIMARY KEY("user_id","conversation_id","message_id") +); +--> statement-breakpoint +ALTER TABLE "thread_chat"."conversation_message_feedback" ADD CONSTRAINT "conversation_message_feedback_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "thread_chat"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "thread_chat"."conversation_message_feedback" ADD CONSTRAINT "conversation_message_feedback_conversation_id_conversations_id_fk" FOREIGN KEY ("conversation_id") REFERENCES "thread_chat"."conversations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "conversation_message_feedback_conversation_idx" ON "thread_chat"."conversation_message_feedback" USING btree ("user_id","conversation_id"); \ No newline at end of file diff --git a/drizzle/0011_swift_taskmaster.sql b/drizzle/0011_swift_taskmaster.sql new file mode 100644 index 00000000..7532bdfb --- /dev/null +++ b/drizzle/0011_swift_taskmaster.sql @@ -0,0 +1,2 @@ +ALTER TABLE "thread_chat"."conversation_message_feedback" ADD CONSTRAINT "conversation_message_feedback_thread_fk" FOREIGN KEY ("thread_id","conversation_id") REFERENCES "thread_chat"."conversation_threads"("id","conversation_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "thread_chat"."conversation_message_feedback" ADD CONSTRAINT "conversation_message_feedback_message_fk" FOREIGN KEY ("message_id","thread_id") REFERENCES "thread_chat"."conversation_messages"("id","thread_id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/drizzle/meta/0010_snapshot.json b/drizzle/meta/0010_snapshot.json new file mode 100644 index 00000000..c9c25f8e --- /dev/null +++ b/drizzle/meta/0010_snapshot.json @@ -0,0 +1,3298 @@ +{ + "id": "3a4c4e54-a151-4c26-8336-c73fab54f564", + "prevId": "3d46c980-6a2c-44bd-87a9-3f6346c17fc5", + "version": "7", + "dialect": "postgresql", + "tables": { + "thread_chat.attachment_chunks": { + "name": "attachment_chunks", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "attachment_id": { + "name": "attachment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "page": { + "name": "page", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "attachment_chunks_attachment_id_idx": { + "name": "attachment_chunks_attachment_id_idx", + "columns": [ + { + "expression": "attachment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "attachment_chunks_embedding_idx": { + "name": "attachment_chunks_embedding_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": {} + } + }, + "foreignKeys": { + "attachment_chunks_attachment_id_attachments_id_fk": { + "name": "attachment_chunks_attachment_id_attachments_id_fk", + "tableFrom": "attachment_chunks", + "tableTo": "attachments", + "schemaTo": "thread_chat", + "columnsFrom": [ + "attachment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.attachments": { + "name": "attachments", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'uploading'" + }, + "page_count": { + "name": "page_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pages": { + "name": "pages", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "suggested_questions": { + "name": "suggested_questions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "attachments_key_unique": { + "name": "attachments_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.branch_generations": { + "name": "branch_generations", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tree_id": { + "name": "tree_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_message_id": { + "name": "user_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assistant_message_id": { + "name": "assistant_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assistant_message_index": { + "name": "assistant_message_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "turn_snapshot": { + "name": "turn_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_status": { + "name": "billing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "heartbeat_at": { + "name": "heartbeat_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "stop_requested_at": { + "name": "stop_requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "branch_generations_current_assistant_uq": { + "name": "branch_generations_current_assistant_uq", + "columns": [ + { + "expression": "tree_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assistant_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"thread_chat\".\"branch_generations\".\"is_current\" = true", + "concurrently": false, + "method": "btree", + "with": {} + }, + "branch_generations_assistant_attempt_uq": { + "name": "branch_generations_assistant_attempt_uq", + "columns": [ + { + "expression": "tree_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assistant_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "attempt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "branch_generations_user_id_idx": { + "name": "branch_generations_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "branch_generations_tree_current_idx": { + "name": "branch_generations_tree_current_idx", + "columns": [ + { + "expression": "tree_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_current", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "branch_generations_user_status_idx": { + "name": "branch_generations_user_status_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "branch_generations_heartbeat_idx": { + "name": "branch_generations_heartbeat_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "heartbeat_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "branch_generations_user_id_user_id_fk": { + "name": "branch_generations_user_id_user_id_fk", + "tableFrom": "branch_generations", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "branch_generations_tree_id_branch_trees_id_fk": { + "name": "branch_generations_tree_id_branch_trees_id_fk", + "tableFrom": "branch_generations", + "tableTo": "branch_trees", + "schemaTo": "thread_chat", + "columnsFrom": [ + "tree_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.branch_message_feedback": { + "name": "branch_message_feedback", + "schema": "thread_chat", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tree_id": { + "name": "tree_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "branch_message_feedback_tree_idx": { + "name": "branch_message_feedback_tree_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tree_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "branch_message_feedback_user_id_user_id_fk": { + "name": "branch_message_feedback_user_id_user_id_fk", + "tableFrom": "branch_message_feedback", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "branch_message_feedback_tree_id_branch_trees_id_fk": { + "name": "branch_message_feedback_tree_id_branch_trees_id_fk", + "tableFrom": "branch_message_feedback", + "tableTo": "branch_trees", + "schemaTo": "thread_chat", + "columnsFrom": [ + "tree_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "branch_message_feedback_pk": { + "name": "branch_message_feedback_pk", + "columns": [ + "user_id", + "tree_id", + "thread_id", + "message_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.branch_trees": { + "name": "branch_trees", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_title": { + "name": "custom_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "branch_trees_user_id_idx": { + "name": "branch_trees_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "branch_trees_user_id_user_id_fk": { + "name": "branch_trees_user_id_user_id_fk", + "tableFrom": "branch_trees", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.conversation_command_records": { + "name": "conversation_command_records", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_type": { + "name": "scope_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "command_type": { + "name": "command_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload_hash": { + "name": "payload_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversation_command_records_scope_key_uq": { + "name": "conversation_command_records_scope_key_uq", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_command_records_actor_created_idx": { + "name": "conversation_command_records_actor_created_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversation_command_records_actor_id_user_id_fk": { + "name": "conversation_command_records_actor_id_user_id_fk", + "tableFrom": "conversation_command_records", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.conversation_generations": { + "name": "conversation_generations", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_message_id": { + "name": "input_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "output_message_id": { + "name": "output_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "intent": { + "name": "intent", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_state": { + "name": "content_state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checkpoint_version": { + "name": "checkpoint_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "checkpoint": { + "name": "checkpoint", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "known_usage": { + "name": "known_usage", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "usage_completeness": { + "name": "usage_completeness", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_status": { + "name": "billing_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "paid_call_started": { + "name": "paid_call_started", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "lease_owner": { + "name": "lease_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_version": { + "name": "lease_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "heartbeat_at": { + "name": "heartbeat_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "stop_requested_at": { + "name": "stop_requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversation_generations_owner_idempotency_uq": { + "name": "conversation_generations_owner_idempotency_uq", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_generations_output_attempt_uq": { + "name": "conversation_generations_output_attempt_uq", + "columns": [ + { + "expression": "output_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "attempt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_generations_current_turn_uq": { + "name": "conversation_generations_current_turn_uq", + "columns": [ + { + "expression": "turn_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"thread_chat\".\"conversation_generations\".\"is_current\" = true", + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_generations_owner_status_idx": { + "name": "conversation_generations_owner_status_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_generations_conversation_updated_idx": { + "name": "conversation_generations_conversation_updated_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_generations_lease_idx": { + "name": "conversation_generations_lease_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "heartbeat_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversation_generations_owner_id_user_id_fk": { + "name": "conversation_generations_owner_id_user_id_fk", + "tableFrom": "conversation_generations", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.conversation_message_feedback": { + "name": "conversation_message_feedback", + "schema": "thread_chat", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversation_message_feedback_conversation_idx": { + "name": "conversation_message_feedback_conversation_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversation_message_feedback_user_id_user_id_fk": { + "name": "conversation_message_feedback_user_id_user_id_fk", + "tableFrom": "conversation_message_feedback", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversation_message_feedback_conversation_id_conversations_id_fk": { + "name": "conversation_message_feedback_conversation_id_conversations_id_fk", + "tableFrom": "conversation_message_feedback", + "tableTo": "conversations", + "schemaTo": "thread_chat", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "conversation_message_feedback_pk": { + "name": "conversation_message_feedback_pk", + "columns": [ + "user_id", + "conversation_id", + "message_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.conversation_messages": { + "name": "conversation_messages", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "content_state": { + "name": "content_state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variant_of_message_id": { + "name": "variant_of_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversation_messages_id_thread_id_uq": { + "name": "conversation_messages_id_thread_id_uq", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_messages_id_thread_turn_uq": { + "name": "conversation_messages_id_thread_turn_uq", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "turn_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_messages_turn_created_idx": { + "name": "conversation_messages_turn_created_idx", + "columns": [ + { + "expression": "turn_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_messages_variant_source_idx": { + "name": "conversation_messages_variant_source_idx", + "columns": [ + { + "expression": "variant_of_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.conversation_outbox_events": { + "name": "conversation_outbox_events", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "aggregate_type": { + "name": "aggregate_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "aggregate_id": { + "name": "aggregate_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "aggregate_revision": { + "name": "aggregate_revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "available_at": { + "name": "available_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "dispatched_at": { + "name": "dispatched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "conversation_outbox_events_pending_idx": { + "name": "conversation_outbox_events_pending_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_outbox_events_aggregate_idx": { + "name": "conversation_outbox_events_aggregate_idx", + "columns": [ + { + "expression": "aggregate_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "aggregate_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "aggregate_revision", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversation_outbox_events_actor_id_user_id_fk": { + "name": "conversation_outbox_events_actor_id_user_id_fk", + "tableFrom": "conversation_outbox_events", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.conversation_threads": { + "name": "conversation_threads", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "local_title": { + "name": "local_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversation_threads_id_conversation_id_uq": { + "name": "conversation_threads_id_conversation_id_uq", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_threads_conversation_created_idx": { + "name": "conversation_threads_conversation_created_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversation_threads_conversation_id_conversations_id_fk": { + "name": "conversation_threads_conversation_id_conversations_id_fk", + "tableFrom": "conversation_threads", + "tableTo": "conversations", + "schemaTo": "thread_chat", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.conversation_turns": { + "name": "conversation_turns", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "active_user_message_id": { + "name": "active_user_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_assistant_message_id": { + "name": "active_assistant_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversation_turns_id_thread_id_uq": { + "name": "conversation_turns_id_thread_id_uq", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_turns_thread_position_uq": { + "name": "conversation_turns_thread_position_uq", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_turns_active_user_idx": { + "name": "conversation_turns_active_user_idx", + "columns": [ + { + "expression": "active_user_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_turns_active_assistant_idx": { + "name": "conversation_turns_active_assistant_idx", + "columns": [ + { + "expression": "active_assistant_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversation_turns_thread_id_conversation_threads_id_fk": { + "name": "conversation_turns_thread_id_conversation_threads_id_fk", + "tableFrom": "conversation_turns", + "tableTo": "conversation_threads", + "schemaTo": "thread_chat", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.conversations": { + "name": "conversations", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "root_thread_id": { + "name": "root_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auto_title": { + "name": "auto_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_title": { + "name": "custom_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversations_project_id_id_uq": { + "name": "conversations_project_id_id_uq", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversations_project_updated_idx": { + "name": "conversations_project_updated_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversations_project_lifecycle_idx": { + "name": "conversations_project_lifecycle_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lifecycle", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversations_project_id_projects_id_fk": { + "name": "conversations_project_id_projects_id_fk", + "tableFrom": "conversations", + "tableTo": "projects", + "schemaTo": "thread_chat", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.projects": { + "name": "projects", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "projects_workspace_id_id_uq": { + "name": "projects_workspace_id_id_uq", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "projects_workspace_lifecycle_idx": { + "name": "projects_workspace_lifecycle_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lifecycle", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_workspace_id_workspaces_id_fk": { + "name": "projects_workspace_id_workspaces_id_fk", + "tableFrom": "projects", + "tableTo": "workspaces", + "schemaTo": "thread_chat", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.thread_forks": { + "name": "thread_forks", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_thread_id": { + "name": "parent_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_message_id": { + "name": "source_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_thread_id": { + "name": "child_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "anchor": { + "name": "anchor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "thread_forks_child_thread_uq": { + "name": "thread_forks_child_thread_uq", + "columns": [ + { + "expression": "child_thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "thread_forks_parent_source_idx": { + "name": "thread_forks_parent_source_idx", + "columns": [ + { + "expression": "parent_thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "thread_forks_conversation_created_idx": { + "name": "thread_forks_conversation_created_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "thread_forks_conversation_id_conversations_id_fk": { + "name": "thread_forks_conversation_id_conversations_id_fk", + "tableFrom": "thread_forks", + "tableTo": "conversations", + "schemaTo": "thread_chat", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "thread_forks_created_by_user_id_fk": { + "name": "thread_forks_created_by_user_id_fk", + "tableFrom": "thread_forks", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.workspace_members": { + "name": "workspace_members", + "schema": "thread_chat", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_members_user_id_idx": { + "name": "workspace_members_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_members_workspace_id_workspaces_id_fk": { + "name": "workspace_members_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_members", + "tableTo": "workspaces", + "schemaTo": "thread_chat", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_members_user_id_user_id_fk": { + "name": "workspace_members_user_id_user_id_fk", + "tableFrom": "workspace_members", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_members_pk": { + "name": "workspace_members_pk", + "columns": [ + "workspace_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.workspaces": { + "name": "workspaces", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspaces_lifecycle_idx": { + "name": "workspaces_lifecycle_idx", + "columns": [ + { + "expression": "lifecycle", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.account": { + "name": "account", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.session": { + "name": "session", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.user": { + "name": "user", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.verification": { + "name": "verification", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.usage_records": { + "name": "usage_records", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "app_generation_id": { + "name": "app_generation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_micros": { + "name": "cost_micros", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "price_micros": { + "name": "price_micros", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "generation_id": { + "name": "generation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cost_source": { + "name": "cost_source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'estimate'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_records_user_id_idx": { + "name": "usage_records_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_records_thread_id_idx": { + "name": "usage_records_thread_id_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_records_cost_source_idx": { + "name": "usage_records_cost_source_idx", + "columns": [ + { + "expression": "cost_source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_records_app_generation_id_uq": { + "name": "usage_records_app_generation_id_uq", + "columns": [ + { + "expression": "app_generation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_records_user_id_user_id_fk": { + "name": "usage_records_user_id_user_id_fk", + "tableFrom": "usage_records", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.user_credits": { + "name": "user_credits", + "schema": "thread_chat", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "balance_micros": { + "name": "balance_micros", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_credits_user_id_user_id_fk": { + "name": "user_credits_user_id_user_id_fk", + "tableFrom": "user_credits", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.payments": { + "name": "payments", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'creem'" + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pack_id": { + "name": "pack_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checkout_id": { + "name": "checkout_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order_id": { + "name": "order_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "credit_micros": { + "name": "credit_micros", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "price_label": { + "name": "price_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "raw": { + "name": "raw", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "payments_user_id_idx": { + "name": "payments_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payments_provider_order_id_uq": { + "name": "payments_provider_order_id_uq", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payments_user_id_user_id_fk": { + "name": "payments_user_id_user_id_fk", + "tableFrom": "payments", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.subscriptions": { + "name": "subscriptions", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'creem'" + }, + "subscription_id": { + "name": "subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_period_end": { + "name": "current_period_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "raw": { + "name": "raw", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscriptions_user_id_idx": { + "name": "subscriptions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "subscriptions_user_id_user_id_fk": { + "name": "subscriptions_user_id_user_id_fk", + "tableFrom": "subscriptions", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "subscriptions_subscription_id_unique": { + "name": "subscriptions_subscription_id_unique", + "nullsNotDistinct": false, + "columns": [ + "subscription_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0011_snapshot.json b/drizzle/meta/0011_snapshot.json new file mode 100644 index 00000000..b88bfedc --- /dev/null +++ b/drizzle/meta/0011_snapshot.json @@ -0,0 +1,3330 @@ +{ + "id": "574d36b5-9b51-435a-836f-0470e53e784a", + "prevId": "3a4c4e54-a151-4c26-8336-c73fab54f564", + "version": "7", + "dialect": "postgresql", + "tables": { + "thread_chat.attachment_chunks": { + "name": "attachment_chunks", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "attachment_id": { + "name": "attachment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "page": { + "name": "page", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "attachment_chunks_attachment_id_idx": { + "name": "attachment_chunks_attachment_id_idx", + "columns": [ + { + "expression": "attachment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "attachment_chunks_embedding_idx": { + "name": "attachment_chunks_embedding_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": {} + } + }, + "foreignKeys": { + "attachment_chunks_attachment_id_attachments_id_fk": { + "name": "attachment_chunks_attachment_id_attachments_id_fk", + "tableFrom": "attachment_chunks", + "tableTo": "attachments", + "schemaTo": "thread_chat", + "columnsFrom": [ + "attachment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.attachments": { + "name": "attachments", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'uploading'" + }, + "page_count": { + "name": "page_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pages": { + "name": "pages", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "suggested_questions": { + "name": "suggested_questions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "attachments_key_unique": { + "name": "attachments_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.branch_generations": { + "name": "branch_generations", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tree_id": { + "name": "tree_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_message_id": { + "name": "user_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assistant_message_id": { + "name": "assistant_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assistant_message_index": { + "name": "assistant_message_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "turn_snapshot": { + "name": "turn_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_status": { + "name": "billing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "heartbeat_at": { + "name": "heartbeat_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "stop_requested_at": { + "name": "stop_requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "branch_generations_current_assistant_uq": { + "name": "branch_generations_current_assistant_uq", + "columns": [ + { + "expression": "tree_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assistant_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"thread_chat\".\"branch_generations\".\"is_current\" = true", + "concurrently": false, + "method": "btree", + "with": {} + }, + "branch_generations_assistant_attempt_uq": { + "name": "branch_generations_assistant_attempt_uq", + "columns": [ + { + "expression": "tree_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assistant_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "attempt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "branch_generations_user_id_idx": { + "name": "branch_generations_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "branch_generations_tree_current_idx": { + "name": "branch_generations_tree_current_idx", + "columns": [ + { + "expression": "tree_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_current", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "branch_generations_user_status_idx": { + "name": "branch_generations_user_status_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "branch_generations_heartbeat_idx": { + "name": "branch_generations_heartbeat_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "heartbeat_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "branch_generations_user_id_user_id_fk": { + "name": "branch_generations_user_id_user_id_fk", + "tableFrom": "branch_generations", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "branch_generations_tree_id_branch_trees_id_fk": { + "name": "branch_generations_tree_id_branch_trees_id_fk", + "tableFrom": "branch_generations", + "tableTo": "branch_trees", + "schemaTo": "thread_chat", + "columnsFrom": [ + "tree_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.branch_message_feedback": { + "name": "branch_message_feedback", + "schema": "thread_chat", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tree_id": { + "name": "tree_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "branch_message_feedback_tree_idx": { + "name": "branch_message_feedback_tree_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tree_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "branch_message_feedback_user_id_user_id_fk": { + "name": "branch_message_feedback_user_id_user_id_fk", + "tableFrom": "branch_message_feedback", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "branch_message_feedback_tree_id_branch_trees_id_fk": { + "name": "branch_message_feedback_tree_id_branch_trees_id_fk", + "tableFrom": "branch_message_feedback", + "tableTo": "branch_trees", + "schemaTo": "thread_chat", + "columnsFrom": [ + "tree_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "branch_message_feedback_pk": { + "name": "branch_message_feedback_pk", + "columns": [ + "user_id", + "tree_id", + "thread_id", + "message_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.branch_trees": { + "name": "branch_trees", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_title": { + "name": "custom_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "branch_trees_user_id_idx": { + "name": "branch_trees_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "branch_trees_user_id_user_id_fk": { + "name": "branch_trees_user_id_user_id_fk", + "tableFrom": "branch_trees", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.conversation_command_records": { + "name": "conversation_command_records", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_type": { + "name": "scope_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "command_type": { + "name": "command_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload_hash": { + "name": "payload_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversation_command_records_scope_key_uq": { + "name": "conversation_command_records_scope_key_uq", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_command_records_actor_created_idx": { + "name": "conversation_command_records_actor_created_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversation_command_records_actor_id_user_id_fk": { + "name": "conversation_command_records_actor_id_user_id_fk", + "tableFrom": "conversation_command_records", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.conversation_generations": { + "name": "conversation_generations", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_message_id": { + "name": "input_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "output_message_id": { + "name": "output_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "intent": { + "name": "intent", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_state": { + "name": "content_state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checkpoint_version": { + "name": "checkpoint_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "checkpoint": { + "name": "checkpoint", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "known_usage": { + "name": "known_usage", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "usage_completeness": { + "name": "usage_completeness", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_status": { + "name": "billing_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "paid_call_started": { + "name": "paid_call_started", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "lease_owner": { + "name": "lease_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_version": { + "name": "lease_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "heartbeat_at": { + "name": "heartbeat_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "stop_requested_at": { + "name": "stop_requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversation_generations_owner_idempotency_uq": { + "name": "conversation_generations_owner_idempotency_uq", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_generations_output_attempt_uq": { + "name": "conversation_generations_output_attempt_uq", + "columns": [ + { + "expression": "output_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "attempt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_generations_current_turn_uq": { + "name": "conversation_generations_current_turn_uq", + "columns": [ + { + "expression": "turn_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"thread_chat\".\"conversation_generations\".\"is_current\" = true", + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_generations_owner_status_idx": { + "name": "conversation_generations_owner_status_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_generations_conversation_updated_idx": { + "name": "conversation_generations_conversation_updated_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_generations_lease_idx": { + "name": "conversation_generations_lease_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "heartbeat_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversation_generations_owner_id_user_id_fk": { + "name": "conversation_generations_owner_id_user_id_fk", + "tableFrom": "conversation_generations", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.conversation_message_feedback": { + "name": "conversation_message_feedback", + "schema": "thread_chat", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversation_message_feedback_conversation_idx": { + "name": "conversation_message_feedback_conversation_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversation_message_feedback_user_id_user_id_fk": { + "name": "conversation_message_feedback_user_id_user_id_fk", + "tableFrom": "conversation_message_feedback", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversation_message_feedback_conversation_id_conversations_id_fk": { + "name": "conversation_message_feedback_conversation_id_conversations_id_fk", + "tableFrom": "conversation_message_feedback", + "tableTo": "conversations", + "schemaTo": "thread_chat", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversation_message_feedback_thread_fk": { + "name": "conversation_message_feedback_thread_fk", + "tableFrom": "conversation_message_feedback", + "tableTo": "conversation_threads", + "schemaTo": "thread_chat", + "columnsFrom": [ + "thread_id", + "conversation_id" + ], + "columnsTo": [ + "id", + "conversation_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversation_message_feedback_message_fk": { + "name": "conversation_message_feedback_message_fk", + "tableFrom": "conversation_message_feedback", + "tableTo": "conversation_messages", + "schemaTo": "thread_chat", + "columnsFrom": [ + "message_id", + "thread_id" + ], + "columnsTo": [ + "id", + "thread_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "conversation_message_feedback_pk": { + "name": "conversation_message_feedback_pk", + "columns": [ + "user_id", + "conversation_id", + "message_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.conversation_messages": { + "name": "conversation_messages", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "content_state": { + "name": "content_state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variant_of_message_id": { + "name": "variant_of_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversation_messages_id_thread_id_uq": { + "name": "conversation_messages_id_thread_id_uq", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_messages_id_thread_turn_uq": { + "name": "conversation_messages_id_thread_turn_uq", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "turn_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_messages_turn_created_idx": { + "name": "conversation_messages_turn_created_idx", + "columns": [ + { + "expression": "turn_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_messages_variant_source_idx": { + "name": "conversation_messages_variant_source_idx", + "columns": [ + { + "expression": "variant_of_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.conversation_outbox_events": { + "name": "conversation_outbox_events", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "aggregate_type": { + "name": "aggregate_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "aggregate_id": { + "name": "aggregate_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "aggregate_revision": { + "name": "aggregate_revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "available_at": { + "name": "available_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "dispatched_at": { + "name": "dispatched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "conversation_outbox_events_pending_idx": { + "name": "conversation_outbox_events_pending_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_outbox_events_aggregate_idx": { + "name": "conversation_outbox_events_aggregate_idx", + "columns": [ + { + "expression": "aggregate_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "aggregate_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "aggregate_revision", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversation_outbox_events_actor_id_user_id_fk": { + "name": "conversation_outbox_events_actor_id_user_id_fk", + "tableFrom": "conversation_outbox_events", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.conversation_threads": { + "name": "conversation_threads", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "local_title": { + "name": "local_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversation_threads_id_conversation_id_uq": { + "name": "conversation_threads_id_conversation_id_uq", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_threads_conversation_created_idx": { + "name": "conversation_threads_conversation_created_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversation_threads_conversation_id_conversations_id_fk": { + "name": "conversation_threads_conversation_id_conversations_id_fk", + "tableFrom": "conversation_threads", + "tableTo": "conversations", + "schemaTo": "thread_chat", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.conversation_turns": { + "name": "conversation_turns", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "active_user_message_id": { + "name": "active_user_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_assistant_message_id": { + "name": "active_assistant_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversation_turns_id_thread_id_uq": { + "name": "conversation_turns_id_thread_id_uq", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_turns_thread_position_uq": { + "name": "conversation_turns_thread_position_uq", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_turns_active_user_idx": { + "name": "conversation_turns_active_user_idx", + "columns": [ + { + "expression": "active_user_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_turns_active_assistant_idx": { + "name": "conversation_turns_active_assistant_idx", + "columns": [ + { + "expression": "active_assistant_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversation_turns_thread_id_conversation_threads_id_fk": { + "name": "conversation_turns_thread_id_conversation_threads_id_fk", + "tableFrom": "conversation_turns", + "tableTo": "conversation_threads", + "schemaTo": "thread_chat", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.conversations": { + "name": "conversations", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "root_thread_id": { + "name": "root_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auto_title": { + "name": "auto_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_title": { + "name": "custom_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversations_project_id_id_uq": { + "name": "conversations_project_id_id_uq", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversations_project_updated_idx": { + "name": "conversations_project_updated_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversations_project_lifecycle_idx": { + "name": "conversations_project_lifecycle_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lifecycle", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversations_project_id_projects_id_fk": { + "name": "conversations_project_id_projects_id_fk", + "tableFrom": "conversations", + "tableTo": "projects", + "schemaTo": "thread_chat", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.projects": { + "name": "projects", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "projects_workspace_id_id_uq": { + "name": "projects_workspace_id_id_uq", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "projects_workspace_lifecycle_idx": { + "name": "projects_workspace_lifecycle_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lifecycle", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_workspace_id_workspaces_id_fk": { + "name": "projects_workspace_id_workspaces_id_fk", + "tableFrom": "projects", + "tableTo": "workspaces", + "schemaTo": "thread_chat", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.thread_forks": { + "name": "thread_forks", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_thread_id": { + "name": "parent_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_message_id": { + "name": "source_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_thread_id": { + "name": "child_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "anchor": { + "name": "anchor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "thread_forks_child_thread_uq": { + "name": "thread_forks_child_thread_uq", + "columns": [ + { + "expression": "child_thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "thread_forks_parent_source_idx": { + "name": "thread_forks_parent_source_idx", + "columns": [ + { + "expression": "parent_thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "thread_forks_conversation_created_idx": { + "name": "thread_forks_conversation_created_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "thread_forks_conversation_id_conversations_id_fk": { + "name": "thread_forks_conversation_id_conversations_id_fk", + "tableFrom": "thread_forks", + "tableTo": "conversations", + "schemaTo": "thread_chat", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "thread_forks_created_by_user_id_fk": { + "name": "thread_forks_created_by_user_id_fk", + "tableFrom": "thread_forks", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.workspace_members": { + "name": "workspace_members", + "schema": "thread_chat", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_members_user_id_idx": { + "name": "workspace_members_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_members_workspace_id_workspaces_id_fk": { + "name": "workspace_members_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_members", + "tableTo": "workspaces", + "schemaTo": "thread_chat", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_members_user_id_user_id_fk": { + "name": "workspace_members_user_id_user_id_fk", + "tableFrom": "workspace_members", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_members_pk": { + "name": "workspace_members_pk", + "columns": [ + "workspace_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.workspaces": { + "name": "workspaces", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspaces_lifecycle_idx": { + "name": "workspaces_lifecycle_idx", + "columns": [ + { + "expression": "lifecycle", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.account": { + "name": "account", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.session": { + "name": "session", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.user": { + "name": "user", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.verification": { + "name": "verification", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.usage_records": { + "name": "usage_records", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "app_generation_id": { + "name": "app_generation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_micros": { + "name": "cost_micros", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "price_micros": { + "name": "price_micros", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "generation_id": { + "name": "generation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cost_source": { + "name": "cost_source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'estimate'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_records_user_id_idx": { + "name": "usage_records_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_records_thread_id_idx": { + "name": "usage_records_thread_id_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_records_cost_source_idx": { + "name": "usage_records_cost_source_idx", + "columns": [ + { + "expression": "cost_source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_records_app_generation_id_uq": { + "name": "usage_records_app_generation_id_uq", + "columns": [ + { + "expression": "app_generation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_records_user_id_user_id_fk": { + "name": "usage_records_user_id_user_id_fk", + "tableFrom": "usage_records", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.user_credits": { + "name": "user_credits", + "schema": "thread_chat", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "balance_micros": { + "name": "balance_micros", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_credits_user_id_user_id_fk": { + "name": "user_credits_user_id_user_id_fk", + "tableFrom": "user_credits", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.payments": { + "name": "payments", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'creem'" + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pack_id": { + "name": "pack_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checkout_id": { + "name": "checkout_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order_id": { + "name": "order_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "credit_micros": { + "name": "credit_micros", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "price_label": { + "name": "price_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "raw": { + "name": "raw", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "payments_user_id_idx": { + "name": "payments_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payments_provider_order_id_uq": { + "name": "payments_provider_order_id_uq", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payments_user_id_user_id_fk": { + "name": "payments_user_id_user_id_fk", + "tableFrom": "payments", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.subscriptions": { + "name": "subscriptions", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'creem'" + }, + "subscription_id": { + "name": "subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_period_end": { + "name": "current_period_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "raw": { + "name": "raw", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscriptions_user_id_idx": { + "name": "subscriptions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "subscriptions_user_id_user_id_fk": { + "name": "subscriptions_user_id_user_id_fk", + "tableFrom": "subscriptions", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "subscriptions_subscription_id_unique": { + "name": "subscriptions_subscription_id_unique", + "nullsNotDistinct": false, + "columns": [ + "subscription_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 5a8eb939..684ab00e 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -71,6 +71,20 @@ "when": 1787336864651, "tag": "0009_marvelous_lady_bullseye", "breakpoints": true + }, + { + "idx": 10, + "version": "7", + "when": 1787341794929, + "tag": "0010_smiling_wolfpack", + "breakpoints": true + }, + { + "idx": 11, + "version": "7", + "when": 1787342117009, + "tag": "0011_swift_taskmaster", + "breakpoints": true } ] } \ No newline at end of file diff --git a/lib/db/schema.ts b/lib/db/schema.ts index ef4a095c..089c77c4 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -8,6 +8,7 @@ import { boolean, vector, primaryKey, + foreignKey, } from "drizzle-orm/pg-core" import { sql } from "drizzle-orm" import { dbSchema } from "./pg-schema" @@ -262,6 +263,50 @@ export const conversationMessages = dbSchema.table( ] ) +export const conversationMessageFeedback = dbSchema.table( + "conversation_message_feedback", + { + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + conversationId: text("conversation_id") + .notNull() + .references(() => conversations.id, { onDelete: "cascade" }), + threadId: text("thread_id").notNull(), + messageId: text("message_id").notNull(), + feedback: text("feedback").$type().notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => [ + primaryKey({ + name: "conversation_message_feedback_pk", + columns: [table.userId, table.conversationId, table.messageId], + }), + index("conversation_message_feedback_conversation_idx").on( + table.userId, + table.conversationId + ), + foreignKey({ + name: "conversation_message_feedback_thread_fk", + columns: [table.threadId, table.conversationId], + foreignColumns: [ + conversationThreads.id, + conversationThreads.conversationId, + ], + }).onDelete("cascade"), + foreignKey({ + name: "conversation_message_feedback_message_fk", + columns: [table.messageId, table.threadId], + foreignColumns: [conversationMessages.id, conversationMessages.threadId], + }).onDelete("cascade"), + ] +) + export const threadForks = dbSchema.table( "thread_forks", { diff --git a/lib/thread-chat/client/conversation-client-contracts.ts b/lib/thread-chat/client/conversation-client-contracts.ts new file mode 100644 index 00000000..5ce6cf2b --- /dev/null +++ b/lib/thread-chat/client/conversation-client-contracts.ts @@ -0,0 +1,342 @@ +import { z } from "zod" + +import { CONVERSATION_COMMAND_SCHEMA_VERSION } from "../../../constants/conversation-command" +import { CONVERSATION_SNAPSHOT_SCHEMA_VERSION } from "../../../constants/conversation-domain" +import type { + CommandSuccess, + ConversationSnapshotResult, +} from "../application/conversation-command-contracts" +import type { CanonicalGenerationRecord } from "../application/conversation-generation-service" +import { conversationGenerationCheckpointSchema } from "../domain/conversation-generation" +import type { ConversationSnapshot } from "../domain/conversation-model" +import { assertValidConversationSnapshot } from "../domain/conversation-validation" + +const id = z.string().trim().min(1).max(200) +const timestamp = z.string().min(1) +const lifecycle = z.enum(["active", "archived"]) +const contentState = z.enum([ + "pending", + "streaming", + "complete", + "incomplete", + "failed", +]) + +const jsonValueSchema: z.ZodType = z.lazy(() => + z.union([ + z.string(), + z.number(), + z.boolean(), + z.null(), + z.array(jsonValueSchema), + z.record(z.string(), jsonValueSchema), + ]) +) + +const textAnchorSchema = z + .object({ + quote: z + .object({ exact: z.string(), prefix: z.string(), suffix: z.string() }) + .strict(), + position: z + .object({ + start: z.number().int().nonnegative(), + end: z.number().int().nonnegative(), + }) + .strict() + .optional(), + }) + .strict() + +const contentSchema = z + .object({ + schemaVersion: z.literal(1), + parts: z.array( + z.union([ + z.object({ type: z.literal("text"), text: z.string() }).strict(), + z + .object({ type: z.literal("artifact-reference"), artifactId: id }) + .strict(), + z + .object({ + type: z.literal("structured"), + kind: z.string().min(1), + value: jsonValueSchema, + }) + .strict(), + ]) + ), + }) + .strict() + +const projectSchema = z + .object({ + id, + workspaceId: id, + title: z.string(), + revision: z.number().int().nonnegative(), + lifecycle, + }) + .strict() + +const conversationSchema = z + .object({ + id, + projectId: id, + rootThreadId: id, + autoTitle: z.string().nullable(), + customTitle: z.string().nullable(), + revision: z.number().int().nonnegative(), + lifecycle, + }) + .strict() + +const threadSchema = z + .object({ + id, + conversationId: id, + modelId: z.string().min(1), + localTitle: z.string().nullable(), + revision: z.number().int().nonnegative(), + lifecycle, + }) + .strict() + +const threadForkSchema = z + .object({ + id, + conversationId: id, + parentThreadId: id, + sourceMessageId: id, + childThreadId: id, + anchor: textAnchorSchema.optional(), + createdBy: z.string().min(1), + createdAt: timestamp, + }) + .strict() + +const turnSchema = z + .object({ + id, + threadId: id, + position: z.number().int().nonnegative(), + activeUserMessageId: id, + activeAssistantMessageId: id, + revision: z.number().int().nonnegative(), + }) + .strict() + +const messageSchema = z + .object({ + id, + threadId: id, + turnId: id, + role: z.enum(["user", "assistant", "context"]), + content: contentSchema, + contentState, + variantOfMessageId: id.optional(), + createdAt: timestamp, + }) + .strict() + +const generationIntentSchema = z.union([ + z.object({ kind: z.literal("send") }).strict(), + z + .object({ + kind: z.literal("regenerate-assistant"), + sourceAssistantMessageId: id, + }) + .strict(), + z.object({ kind: z.literal("edit-user"), sourceUserMessageId: id }).strict(), + z.object({ kind: z.literal("retry") }).strict(), +]) + +const generationSummarySchema = z + .object({ + id, + threadId: id, + turnId: id, + inputMessageId: id, + outputMessageId: id, + intent: generationIntentSchema, + status: z.enum([ + "running", + "stop_requested", + "completed", + "stopped", + "failed", + "superseded", + ]), + billingStatus: z.enum([ + "pending", + "settled", + "usage_unavailable", + "not_billable", + ]), + attempt: z.number().int().positive(), + createdAt: timestamp, + }) + .strict() + +const knownUsageSchema = z + .object({ + inputTokens: z.number().int().nonnegative(), + outputTokens: z.number().int().nonnegative(), + paidStepCount: z.number().int().nonnegative(), + reportedStepCount: z.number().int().nonnegative(), + }) + .strict() + +export const canonicalGenerationRecordTransportSchema = + generationSummarySchema.extend({ + ownerId: z.string().min(1), + workspaceId: id, + projectId: id, + conversationId: id, + requestHash: z.string().min(1), + idempotencyKey: z.string().min(1), + modelId: z.string().min(1), + isCurrent: z.boolean(), + contentState, + checkpointVersion: z.number().int().nonnegative(), + checkpoint: conversationGenerationCheckpointSchema, + knownUsage: knownUsageSchema.nullable(), + usageCompleteness: z.enum(["complete", "partial", "unavailable"]), + paidCallStarted: z.boolean(), + leaseOwner: z.string().nullable(), + leaseVersion: z.number().int().nonnegative(), + heartbeatAt: timestamp, + stopRequestedAt: timestamp.nullable(), + startedAt: timestamp, + finishedAt: timestamp.nullable(), + errorCode: z.string().nullable(), + }) + +const artifactSchema = z + .object({ + id, + sourceThreadId: id, + sourceMessageId: id, + title: z.string(), + kind: z.string(), + }) + .strict() + +const snapshotSchema = z + .object({ + schemaVersion: z.literal(CONVERSATION_SNAPSHOT_SCHEMA_VERSION), + project: projectSchema, + conversation: conversationSchema, + threads: z.record(z.string(), threadSchema), + threadForks: z.record(z.string(), threadForkSchema), + turns: z.record(z.string(), turnSchema), + messages: z.record(z.string(), messageSchema), + generations: z.record(z.string(), generationSummarySchema), + artifactProvenance: z.record(z.string(), artifactSchema), + }) + .strict() + +export const conversationSnapshotResultTransportSchema = z + .object({ + snapshot: snapshotSchema, + generations: z.array(canonicalGenerationRecordTransportSchema), + contextMessageIdsByThread: z.record(z.string(), z.array(id)), + }) + .strict() + +export const conversationQueryEnvelopeSchema = z + .object({ + schemaVersion: z.literal(CONVERSATION_COMMAND_SCHEMA_VERSION), + data: z.unknown(), + }) + .strict() + +export const conversationCommandEnvelopeSchema = z + .object({ + schemaVersion: z.literal(CONVERSATION_COMMAND_SCHEMA_VERSION), + data: jsonValueSchema, + revisions: z.record(z.string(), z.number().int().nonnegative()), + delta: z + .object({ + upsert: z.record(z.string(), z.array(z.unknown())), + remove: z.record(z.string(), z.array(id)), + invalidate: z.array(z.string()), + }) + .strict(), + replayed: z.boolean(), + }) + .strict() + +export const conversationErrorEnvelopeSchema = z + .object({ + error: z + .object({ + code: z.string().min(1), + message: z.string(), + requestId: z.string().min(1), + details: z.record(z.string(), z.unknown()).optional(), + }) + .strict(), + }) + .strict() + +export function parseConversationSnapshotResult( + value: unknown +): ConversationSnapshotResult { + const parsed = conversationSnapshotResultTransportSchema.parse(value) + const snapshot = parsed.snapshot as unknown as ConversationSnapshot + assertValidConversationSnapshot(snapshot) + return { + snapshot, + generations: + parsed.generations as unknown as readonly CanonicalGenerationRecord[], + contextMessageIdsByThread: + parsed.contextMessageIdsByThread as unknown as ConversationSnapshotResult["contextMessageIdsByThread"], + } +} + +export function parseConversationSnapshotQuery( + value: unknown +): ConversationSnapshotResult { + const envelope = conversationQueryEnvelopeSchema.parse(value) + return parseConversationSnapshotResult(envelope.data) +} + +export function parseCommandSuccess(value: unknown): CommandSuccess { + return conversationCommandEnvelopeSchema.parse( + value + ) as unknown as CommandSuccess +} + +export function parseConversationQuery( + value: unknown, + dataSchema: z.ZodType +): T { + const envelope = conversationQueryEnvelopeSchema.parse(value) + return dataSchema.parse(envelope.data) +} + +export const generationQueryDataSchema = z + .object({ + generation: canonicalGenerationRecordTransportSchema, + pollAfterMs: z.number().int().positive().nullable().optional(), + }) + .strict() + +export const conversationListQueryDataSchema = z + .object({ + conversations: z.array( + z + .object({ + id, + projectId: id, + rootThreadId: id, + title: z.string().nullable(), + revision: z.number().int().nonnegative(), + lifecycle, + updatedAt: timestamp, + }) + .strict() + ), + }) + .strict() diff --git a/lib/thread-chat/client/conversation-client-gateway.ts b/lib/thread-chat/client/conversation-client-gateway.ts new file mode 100644 index 00000000..0049307a --- /dev/null +++ b/lib/thread-chat/client/conversation-client-gateway.ts @@ -0,0 +1,537 @@ +import type { + CommandSuccess, + CreateConversationPayload, + EditTurnInputPayload, + ForkThreadPayload, + RegenerateTurnPayload, + RenamePayload, + SelectTurnVariantPayload, + SendTurnPayload, +} from "../application/conversation-command-contracts" +import type { CanonicalGenerationRecord } from "../application/conversation-generation-service" +import { + canonicalMessageFeedbackListSchema, + setCanonicalMessageFeedbackResponseSchema, + type CanonicalMessageFeedback, +} from "../contracts/conversation-message-feedback" +import type { MessageFeedback } from "../contracts/message-feedback" +import type { + ConversationId, + GenerationId, + ProjectId, + ThreadId, + TurnId, +} from "../domain/conversation-model" +import { + conversationErrorEnvelopeSchema, + conversationListQueryDataSchema, + generationQueryDataSchema, + parseCommandSuccess, + parseConversationQuery, + parseConversationSnapshotQuery, +} from "./conversation-client-contracts" +import type { + NormalizedConversationStore, + PendingCommandOverlay, +} from "./normalized-conversation-store" + +export interface ConversationClientErrorShape { + readonly code: string + readonly message: string + readonly requestId?: string + readonly details?: Readonly> + readonly status?: number + readonly commandId?: string + readonly uncertain?: boolean +} + +export class ConversationClientError extends Error { + readonly code: string + readonly requestId?: string + readonly details?: Readonly> + readonly status?: number + readonly commandId?: string + readonly uncertain: boolean + + constructor(shape: ConversationClientErrorShape) { + super(shape.message) + this.name = "ConversationClientError" + this.code = shape.code + this.requestId = shape.requestId + this.details = shape.details + this.status = shape.status + this.commandId = shape.commandId + this.uncertain = shape.uncertain ?? false + } +} + +type FetchLike = ( + input: RequestInfo | URL, + init?: RequestInit +) => Promise + +interface PendingRequest { + readonly commandId: string + readonly conversationId: ConversationId + readonly url: string + readonly init: RequestInit +} + +export interface CommandOptions { + readonly commandId?: string + readonly overlay?: Omit +} + +export interface ConversationClientGateway { + readonly listConversations: ( + projectId: ProjectId, + includeArchived?: boolean + ) => Promise< + ReturnType["conversations"] + > + readonly loadConversation: (conversationId: ConversationId) => Promise + readonly createConversation: ( + projectId: ProjectId, + payload: CreateConversationPayload, + options?: CommandOptions + ) => Promise + readonly renameConversation: ( + conversationId: ConversationId, + payload: RenamePayload, + options?: CommandOptions + ) => Promise + readonly archiveConversation: ( + conversationId: ConversationId, + options?: CommandOptions + ) => Promise + readonly restoreConversation: ( + conversationId: ConversationId, + options?: CommandOptions + ) => Promise + readonly deleteConversation: ( + conversationId: ConversationId, + options?: CommandOptions + ) => Promise + readonly renameThread: ( + threadId: ThreadId, + payload: RenamePayload, + options?: CommandOptions + ) => Promise + readonly archiveThread: ( + threadId: ThreadId, + options?: CommandOptions + ) => Promise + readonly restoreThread: ( + threadId: ThreadId, + options?: CommandOptions + ) => Promise + readonly forkThread: ( + parentThreadId: ThreadId, + payload: ForkThreadPayload, + options?: CommandOptions + ) => Promise + readonly sendTurn: ( + threadId: ThreadId, + payload: SendTurnPayload, + options?: CommandOptions + ) => Promise + readonly editTurnInput: ( + turnId: TurnId, + payload: EditTurnInputPayload, + options?: CommandOptions + ) => Promise + readonly regenerateTurn: ( + turnId: TurnId, + payload: RegenerateTurnPayload, + options?: CommandOptions + ) => Promise + readonly selectTurnVariant: ( + turnId: TurnId, + payload: SelectTurnVariantPayload, + options?: CommandOptions + ) => Promise + readonly getGeneration: ( + generationId: GenerationId + ) => Promise + readonly stopGeneration: ( + generationId: GenerationId + ) => Promise + readonly listMessageFeedback: ( + conversationId: ConversationId + ) => Promise + readonly setMessageFeedback: (input: { + readonly conversationId: ConversationId + readonly threadId: ThreadId + readonly messageId: string + readonly feedback: MessageFeedback | null + }) => Promise + readonly retry: (commandId: string) => Promise +} + +function newCommandId(): string { + if (typeof crypto !== "undefined" && crypto.randomUUID) + return crypto.randomUUID() + throw new Error("当前运行环境不支持 crypto.randomUUID()") +} + +async function responseJson(response: Response): Promise { + try { + return await response.json() + } catch { + throw new ConversationClientError({ + code: "invalid_response", + message: "服务器返回了无法解析的响应", + status: response.status, + }) + } +} + +function responseError(value: unknown, response: Response, commandId?: string) { + const parsed = conversationErrorEnvelopeSchema.safeParse(value) + if (!parsed.success) + return new ConversationClientError({ + code: "invalid_response", + message: `服务器返回 HTTP ${response.status}`, + status: response.status, + commandId, + }) + return new ConversationClientError({ + ...parsed.data.error, + status: response.status, + commandId, + }) +} + +export function createConversationClientGateway(input: { + readonly store: NormalizedConversationStore + readonly fetch?: FetchLike +}): ConversationClientGateway { + const fetcher = input.fetch ?? globalThis.fetch.bind(globalThis) + const pending = new Map() + + const loadConversation = async (targetConversationId: ConversationId) => { + const response = await fetcher(`/api/conversations/${targetConversationId}`) + const value = await responseJson(response) + if (!response.ok) throw responseError(value, response) + const data = parseConversationSnapshotQuery(value) + input.store.installSnapshot(data) + } + + const runPending = async ( + request: PendingRequest + ): Promise => { + let response: Response + try { + response = await fetcher(request.url, request.init) + } catch (cause) { + input.store.markCommandConfirming(request.commandId) + throw new ConversationClientError({ + code: "network_uncertain", + message: "网络中断,请使用同一命令重试以确认结果", + commandId: request.commandId, + uncertain: true, + details: { + cause: cause instanceof Error ? cause.message : String(cause), + }, + }) + } + const value = await responseJson(response) + if (!response.ok) { + const error = responseError(value, response, request.commandId) + if (response.status === 409) { + try { + await loadConversation(request.conversationId) + } catch { + input.store.markConversationStale( + request.conversationId, + "revision 冲突后的快照重取失败" + ) + } + } + pending.delete(request.commandId) + input.store.failCommand(request.commandId, error.message) + throw error + } + const result = parseCommandSuccess(value) + const merged = input.store.mergeCommandResult(result) + pending.delete(request.commandId) + if (merged.requiresReload) await loadConversation(request.conversationId) + input.store.resolveCommand(request.commandId) + return result + } + + const command = ( + target: { + readonly url: string + readonly method: "POST" | "PATCH" | "DELETE" + readonly conversationId: ConversationId + readonly revisionScopeId?: string + readonly body?: unknown + }, + options: CommandOptions = {} + ) => { + const commandId = options.commandId ?? newCommandId() + const headers = new Headers({ + "Content-Type": "application/json", + "Idempotency-Key": commandId, + "X-Command-Id": commandId, + }) + if (target.revisionScopeId) { + const revision = input.store.revisionOf(target.revisionScopeId) + if (revision === undefined) + throw new ConversationClientError({ + code: "revision_unavailable", + message: `作用域 ${target.revisionScopeId} 尚无可用 revision`, + commandId, + }) + headers.set("If-Match", `"${revision}"`) + } + const request: PendingRequest = { + commandId, + conversationId: target.conversationId, + url: target.url, + init: { + method: target.method, + headers, + ...(target.body === undefined + ? {} + : { body: JSON.stringify(target.body) }), + }, + } + pending.set(commandId, request) + input.store.beginCommand({ + commandId, + kind: options.overlay?.kind ?? target.method.toLowerCase(), + presentationKey: options.overlay?.presentationKey ?? commandId, + status: "pending", + ...(options.overlay?.threadId + ? { threadId: options.overlay.threadId } + : {}), + ...(options.overlay?.draft !== undefined + ? { draft: options.overlay.draft } + : {}), + }) + return runPending(request) + } + + const queryGeneration = async ( + targetGenerationId: GenerationId, + stop: boolean + ) => { + const response = await fetcher( + `/api/generations/${targetGenerationId}${stop ? "/stop" : ""}`, + stop ? { method: "POST" } : undefined + ) + const value = await responseJson(response) + if (!response.ok) throw responseError(value, response) + const data = parseConversationQuery(value, generationQueryDataSchema) + input.store.mergeGeneration(data.generation as CanonicalGenerationRecord) + return data.generation as CanonicalGenerationRecord + } + + return { + async listConversations(targetProjectId, includeArchived = false) { + const response = await fetcher( + `/api/projects/${targetProjectId}/conversations?includeArchived=${includeArchived}` + ) + const value = await responseJson(response) + if (!response.ok) throw responseError(value, response) + return parseConversationQuery(value, conversationListQueryDataSchema) + .conversations + }, + loadConversation, + createConversation: (targetProjectId, payload, options) => + command( + { + url: `/api/projects/${targetProjectId}/conversations`, + method: "POST", + conversationId: payload.conversationId, + body: payload, + }, + options + ), + renameConversation: (targetConversationId, payload, options) => + command( + { + url: `/api/conversations/${targetConversationId}`, + method: "PATCH", + conversationId: targetConversationId, + revisionScopeId: targetConversationId, + body: payload, + }, + options + ), + archiveConversation: (targetConversationId, options) => + command( + { + url: `/api/conversations/${targetConversationId}`, + method: "PATCH", + conversationId: targetConversationId, + revisionScopeId: targetConversationId, + body: { lifecycle: "archived" }, + }, + options + ), + restoreConversation: (targetConversationId, options) => + command( + { + url: `/api/conversations/${targetConversationId}/restore`, + method: "POST", + conversationId: targetConversationId, + revisionScopeId: targetConversationId, + }, + options + ), + deleteConversation: (targetConversationId, options) => + command( + { + url: `/api/conversations/${targetConversationId}`, + method: "DELETE", + conversationId: targetConversationId, + revisionScopeId: targetConversationId, + }, + options + ), + renameThread: (targetThreadId, payload, options) => { + const targetConversationId = + input.store.getState().threadsById[targetThreadId]?.conversationId + if (!targetConversationId) throw new Error("Thread 尚未加载") + return command( + { + url: `/api/threads/${targetThreadId}`, + method: "PATCH", + conversationId: targetConversationId, + revisionScopeId: targetThreadId, + body: payload, + }, + options + ) + }, + archiveThread: (targetThreadId, options) => { + const targetConversationId = + input.store.getState().threadsById[targetThreadId]?.conversationId + if (!targetConversationId) throw new Error("Thread 尚未加载") + return command( + { + url: `/api/threads/${targetThreadId}`, + method: "PATCH", + conversationId: targetConversationId, + revisionScopeId: targetThreadId, + body: { lifecycle: "archived" }, + }, + options + ) + }, + restoreThread: (targetThreadId, options) => { + const targetConversationId = + input.store.getState().threadsById[targetThreadId]?.conversationId + if (!targetConversationId) throw new Error("Thread 尚未加载") + return command( + { + url: `/api/threads/${targetThreadId}/restore`, + method: "POST", + conversationId: targetConversationId, + revisionScopeId: targetThreadId, + }, + options + ) + }, + forkThread: (parentThreadId, payload, options) => + command( + { + url: `/api/threads/${parentThreadId}/forks`, + method: "POST", + conversationId: payload.conversationId, + // Fork 改变 Conversation 拓扑,服务端并发边界是 Conversation revision。 + revisionScopeId: payload.conversationId, + body: payload, + }, + options + ), + sendTurn: (targetThreadId, payload, options) => + command( + { + url: `/api/threads/${targetThreadId}/turns`, + method: "POST", + conversationId: payload.conversationId, + revisionScopeId: targetThreadId, + body: payload, + }, + options + ), + editTurnInput: (targetTurnId, payload, options) => + command( + { + url: `/api/turns/${targetTurnId}/input-edits`, + method: "POST", + conversationId: payload.conversationId, + revisionScopeId: targetTurnId, + body: payload, + }, + options + ), + regenerateTurn: (targetTurnId, payload, options) => + command( + { + url: `/api/turns/${targetTurnId}/regenerations`, + method: "POST", + conversationId: payload.conversationId, + revisionScopeId: targetTurnId, + body: payload, + }, + options + ), + selectTurnVariant: (targetTurnId, payload, options) => + command( + { + url: `/api/turns/${targetTurnId}/active-variant`, + method: "POST", + conversationId: payload.conversationId, + revisionScopeId: targetTurnId, + body: payload, + }, + options + ), + getGeneration: (targetGenerationId) => + queryGeneration(targetGenerationId, false), + stopGeneration: (targetGenerationId) => + queryGeneration(targetGenerationId, true), + async listMessageFeedback(targetConversationId) { + const response = await fetcher( + `/api/conversations/${targetConversationId}/message-feedback` + ) + const value = await responseJson(response) + if (!response.ok) throw responseError(value, response) + return canonicalMessageFeedbackListSchema.parse(value).feedback + }, + async setMessageFeedback({ + conversationId: targetConversationId, + threadId: targetThreadId, + messageId: targetMessageId, + feedback, + }) { + const response = await fetcher( + `/api/conversations/${targetConversationId}/messages/${targetMessageId}/feedback`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ threadId: targetThreadId, feedback }), + } + ) + const value = await responseJson(response) + if (!response.ok) throw responseError(value, response) + return setCanonicalMessageFeedbackResponseSchema.parse(value).feedback + }, + retry(commandId) { + const request = pending.get(commandId) + if (!request) + throw new ConversationClientError({ + code: "command_not_pending", + message: "找不到可重试的命令", + commandId, + }) + input.store.markCommandConfirming(commandId) + return runPending(request) + }, + } +} diff --git a/lib/thread-chat/client/conversation-client-selectors.ts b/lib/thread-chat/client/conversation-client-selectors.ts new file mode 100644 index 00000000..5b086cce --- /dev/null +++ b/lib/thread-chat/client/conversation-client-selectors.ts @@ -0,0 +1,300 @@ +import type { + ConversationId, + ConversationMessage, + MessageId, + ThreadFork, + ThreadForkId, + ThreadId, + TurnId, +} from "../domain/conversation-model" +import type { CanonicalClientState } from "./normalized-conversation-store" + +export interface ConversationCanvasEdge { + readonly id: string + readonly source: ThreadId + readonly target: ThreadId + readonly forkId: ThreadForkId + readonly sourceMessageId: MessageId +} + +export interface ConversationDerivedIndexes { + readonly threadIdsByConversation: Readonly< + Record + > + readonly incomingForkByThread: Readonly> + readonly outgoingForkIdsByThread: Readonly< + Record + > + readonly childThreadIdsByParentThread: Readonly< + Record + > + readonly turnIdsByThread: Readonly> + readonly messageIdsByTurn: Readonly> + readonly depthByThread: Readonly> + readonly contextMessageIdsByThread: Readonly< + Record + > + readonly canvasEdges: readonly ConversationCanvasEdge[] +} + +let derivedCache = new WeakMap< + CanonicalClientState, + ConversationDerivedIndexes +>() + +function append( + registry: Record, + key: string, + value: T +) { + const values = registry[key] ?? [] + values.push(value) + registry[key] = values +} + +function freezeArrays( + registry: Record, + compare?: (left: T, right: T) => number +): Readonly> { + return Object.freeze( + Object.fromEntries( + Object.entries(registry).map(([key, values]) => [ + key, + Object.freeze([...values].sort(compare)), + ]) + ) + ) +} + +export function deriveConversationClientIndexes( + state: CanonicalClientState +): ConversationDerivedIndexes { + const cached = derivedCache.get(state) + if (cached) return cached + + const threadIdsByConversation: Record = {} + const incomingForkByThread: Record = {} + const outgoingForkIdsByThread: Record = {} + const childThreadIdsByParentThread: Record = {} + const turnIdsByThread: Record = {} + const messageIdsByTurn: Record = {} + + for (const thread of Object.values(state.threadsById)) + append(threadIdsByConversation, thread.conversationId, thread.id) + for (const fork of Object.values(state.threadForksById)) { + incomingForkByThread[fork.childThreadId] = fork + append(outgoingForkIdsByThread, fork.parentThreadId, fork.id) + append( + childThreadIdsByParentThread, + fork.parentThreadId, + fork.childThreadId + ) + } + const turns = Object.values(state.turnsById).sort( + (left, right) => + left.position - right.position || left.id.localeCompare(right.id) + ) + for (const turn of turns) append(turnIdsByThread, turn.threadId, turn.id) + const messages = Object.values(state.messagesById).sort( + (left, right) => + left.createdAt.localeCompare(right.createdAt) || + left.id.localeCompare(right.id) + ) + for (const message of messages) + append(messageIdsByTurn, message.turnId, message.id) + + const frozenTurns = freezeArrays(turnIdsByThread, (left, right) => { + const a = state.turnsById[left] + const b = state.turnsById[right] + return (a?.position ?? 0) - (b?.position ?? 0) || left.localeCompare(right) + }) + const frozenMessages = freezeArrays(messageIdsByTurn, (left, right) => { + const a = state.messagesById[left] + const b = state.messagesById[right] + return ( + (a?.createdAt ?? "").localeCompare(b?.createdAt ?? "") || + left.localeCompare(right) + ) + }) + + const localActiveMessages = (targetThreadId: ThreadId): MessageId[] => + (frozenTurns[targetThreadId] ?? []).flatMap((targetTurnId) => { + const turn = state.turnsById[targetTurnId] + return turn + ? [turn.activeUserMessageId, turn.activeAssistantMessageId] + : [] + }) + + const localThroughSource = ( + targetThreadId: ThreadId, + sourceMessageId: MessageId + ): readonly MessageId[] => { + const source = state.messagesById[sourceMessageId] + const sourceTurn = source ? state.turnsById[source.turnId] : undefined + if (!source || !sourceTurn || source.threadId !== targetThreadId) return [] + const before = (frozenTurns[targetThreadId] ?? []) + .map((id) => state.turnsById[id]) + .filter( + (turn): turn is NonNullable => + Boolean(turn) && turn.position < sourceTurn.position + ) + .flatMap((turn) => [ + turn.activeUserMessageId, + turn.activeAssistantMessageId, + ]) + if (source.role === "user") return [...before, source.id] + const generation = Object.values(state.generationsById).find( + (candidate) => candidate.outputMessageId === source.id + ) + return [ + ...before, + generation?.inputMessageId ?? sourceTurn.activeUserMessageId, + source.id, + ] + } + + const contextMemo = new Map() + const contextOf = (targetThreadId: ThreadId): readonly MessageId[] => { + const cachedContext = contextMemo.get(targetThreadId) + if (cachedContext) return cachedContext + const fork = incomingForkByThread[targetThreadId] + let inherited: readonly MessageId[] = [] + if (fork) { + const parentFork = incomingForkByThread[fork.parentThreadId] + const parentInherited = parentFork + ? contextOf(fork.parentThreadId).slice( + 0, + contextOf(fork.parentThreadId).length - + localActiveMessages(fork.parentThreadId).length + ) + : [] + inherited = [ + ...parentInherited, + ...localThroughSource(fork.parentThreadId, fork.sourceMessageId), + ] + } + const value = Object.freeze([ + ...inherited, + ...localActiveMessages(targetThreadId), + ]) + contextMemo.set(targetThreadId, value) + return value + } + + const depthByThread: Record = {} + const depthOf = (targetThreadId: ThreadId): number => { + if (depthByThread[targetThreadId] !== undefined) + return depthByThread[targetThreadId]! + const parent = incomingForkByThread[targetThreadId]?.parentThreadId + const depth = parent ? depthOf(parent) + 1 : 0 + depthByThread[targetThreadId] = depth + return depth + } + for (const thread of Object.values(state.threadsById)) { + depthOf(thread.id) + contextOf(thread.id) + } + + const canvasEdges = Object.values(state.threadForksById) + .map((fork) => ({ + id: `fork-edge:${fork.id}`, + source: fork.parentThreadId, + target: fork.childThreadId, + forkId: fork.id, + sourceMessageId: fork.sourceMessageId, + })) + .sort((left, right) => left.id.localeCompare(right.id)) + + const derived = Object.freeze({ + threadIdsByConversation: freezeArrays(threadIdsByConversation), + incomingForkByThread: Object.freeze(incomingForkByThread), + outgoingForkIdsByThread: freezeArrays(outgoingForkIdsByThread), + childThreadIdsByParentThread: freezeArrays(childThreadIdsByParentThread), + turnIdsByThread: frozenTurns, + messageIdsByTurn: frozenMessages, + depthByThread: Object.freeze(depthByThread), + contextMessageIdsByThread: Object.freeze( + Object.fromEntries(contextMemo.entries()) + ), + canvasEdges: Object.freeze(canvasEdges), + }) + derivedCache.set(state, derived) + return derived +} + +export function clearConversationDerivedCache(): void { + derivedCache = new WeakMap() +} + +export function selectConversation( + state: CanonicalClientState, + targetId: ConversationId +) { + return state.conversationsById[targetId] +} + +export function selectRootThreadId( + state: CanonicalClientState, + targetId: ConversationId +): ThreadId | null { + return state.conversationsById[targetId]?.rootThreadId ?? null +} + +export function selectThreadTitle( + state: CanonicalClientState, + targetThreadId: ThreadId +): string { + const thread = state.threadsById[targetThreadId] + if (!thread) return "" + const conversation = state.conversationsById[thread.conversationId] + return thread.id === conversation?.rootThreadId + ? (conversation.customTitle ?? conversation.autoTitle ?? "新对话") + : (thread.localTitle ?? "未命名分支") +} + +export function selectThreadMessages( + state: CanonicalClientState, + targetThreadId: ThreadId, + includeInherited = false +): readonly ConversationMessage[] { + const indexes = deriveConversationClientIndexes(state) + const ids = includeInherited + ? (indexes.contextMessageIdsByThread[targetThreadId] ?? []) + : (indexes.turnIdsByThread[targetThreadId] ?? []).flatMap( + (targetTurnId) => { + const turn = state.turnsById[targetTurnId] + return turn + ? [turn.activeUserMessageId, turn.activeAssistantMessageId] + : [] + } + ) + return ids.flatMap((targetMessageId) => { + const message = state.messagesById[targetMessageId] + return message ? [message] : [] + }) +} + +export function selectThreadLineage( + state: CanonicalClientState, + targetThreadId: ThreadId +): readonly ThreadId[] { + const indexes = deriveConversationClientIndexes(state) + const lineage: ThreadId[] = [] + let cursor: ThreadId | undefined = targetThreadId + while (cursor) { + lineage.push(cursor) + cursor = indexes.incomingForkByThread[cursor]?.parentThreadId + } + return lineage.reverse() +} + +export function selectForkCount( + state: CanonicalClientState, + targetThreadId: ThreadId +): number { + return ( + deriveConversationClientIndexes(state).outgoingForkIdsByThread[ + targetThreadId + ]?.length ?? 0 + ) +} diff --git a/lib/thread-chat/client/generation-coordinator.ts b/lib/thread-chat/client/generation-coordinator.ts new file mode 100644 index 00000000..d6a38e2f --- /dev/null +++ b/lib/thread-chat/client/generation-coordinator.ts @@ -0,0 +1,150 @@ +import type { CanonicalGenerationRecord } from "../application/conversation-generation-service" +import type { GenerationId } from "../domain/conversation-model" +import type { ConversationClientGateway } from "./conversation-client-gateway" +import type { NormalizedConversationStore } from "./normalized-conversation-store" + +export interface GenerationCoordinatorScheduler { + readonly setTimeout: (callback: () => void, delayMs: number) => unknown + readonly clearTimeout: (handle: unknown) => void +} + +export interface GenerationCoordinator { + readonly subscribe: ( + generationId: GenerationId, + listener: () => void + ) => () => void + readonly setVisibility: (visibility: "visible" | "hidden") => void + readonly ingest: (generation: CanonicalGenerationRecord) => void + readonly monitoredCount: () => number + readonly dispose: () => void +} + +interface Monitor { + readonly listeners: Set<() => void> + timer: unknown | null + querying: boolean + disposed: boolean +} + +const TERMINAL = new Set([ + "completed", + "stopped", + "failed", + "superseded", +]) + +const browserScheduler: GenerationCoordinatorScheduler = { + setTimeout: (callback, delayMs) => globalThis.setTimeout(callback, delayMs), + clearTimeout: (handle) => globalThis.clearTimeout(handle as number), +} + +export function createGenerationCoordinator(input: { + readonly gateway: Pick + readonly store: NormalizedConversationStore + readonly scheduler?: GenerationCoordinatorScheduler + readonly visiblePollMs?: number + readonly hiddenPollMs?: number +}): GenerationCoordinator { + const scheduler = input.scheduler ?? browserScheduler + const visiblePollMs = input.visiblePollMs ?? 2_000 + const hiddenPollMs = input.hiddenPollMs ?? 10_000 + const monitors = new Map() + let visibility: "visible" | "hidden" = "visible" + let globallyDisposed = false + + const delay = () => (visibility === "visible" ? visiblePollMs : hiddenPollMs) + + const clearTimer = (monitor: Monitor) => { + if (monitor.timer === null) return + scheduler.clearTimeout(monitor.timer) + monitor.timer = null + } + + const schedule = (targetGenerationId: GenerationId, monitor: Monitor) => { + clearTimer(monitor) + if (globallyDisposed || monitor.disposed || monitor.listeners.size === 0) + return + monitor.timer = scheduler.setTimeout(() => { + monitor.timer = null + void query(targetGenerationId, monitor) + }, delay()) + } + + const query = async (targetGenerationId: GenerationId, monitor: Monitor) => { + if ( + globallyDisposed || + monitor.disposed || + monitor.querying || + monitor.listeners.size === 0 + ) + return + monitor.querying = true + try { + const generation = await input.gateway.getGeneration(targetGenerationId) + if (monitor.disposed) return + input.store.mergeGeneration(generation) + for (const listener of monitor.listeners) listener() + if (TERMINAL.has(generation.status)) { + clearTimer(monitor) + return + } + } catch { + // 暂时查询失败不改变规范状态;保留监控并按当前可见性重试。 + } finally { + monitor.querying = false + } + schedule(targetGenerationId, monitor) + } + + return { + subscribe(targetGenerationId, listener) { + if (globallyDisposed) throw new Error("GenerationCoordinator 已释放") + let monitor = monitors.get(targetGenerationId) + if (!monitor) { + monitor = { + listeners: new Set(), + timer: null, + querying: false, + disposed: false, + } + monitors.set(targetGenerationId, monitor) + } + const wasEmpty = monitor.listeners.size === 0 + monitor.listeners.add(listener) + if (wasEmpty) void query(targetGenerationId, monitor) + return () => { + const current = monitors.get(targetGenerationId) + if (!current) return + current.listeners.delete(listener) + if (current.listeners.size > 0) return + current.disposed = true + clearTimer(current) + monitors.delete(targetGenerationId) + // 释放浏览器资源不是 Stop 命令,服务端 Generation 继续运行。 + } + }, + setVisibility(nextVisibility) { + if (visibility === nextVisibility) return + visibility = nextVisibility + for (const [targetGenerationId, monitor] of monitors) + if (!monitor.querying) schedule(targetGenerationId, monitor) + }, + ingest(generation) { + const merged = input.store.mergeGeneration(generation) + if (!merged.applied) return + const monitor = monitors.get(generation.id) + if (!monitor) return + for (const listener of monitor.listeners) listener() + if (TERMINAL.has(generation.status)) clearTimer(monitor) + }, + monitoredCount: () => monitors.size, + dispose() { + globallyDisposed = true + for (const monitor of monitors.values()) { + monitor.disposed = true + clearTimer(monitor) + } + monitors.clear() + }, + } +} diff --git a/lib/thread-chat/client/normalized-conversation-store.test.ts b/lib/thread-chat/client/normalized-conversation-store.test.ts new file mode 100644 index 00000000..1285816b --- /dev/null +++ b/lib/thread-chat/client/normalized-conversation-store.test.ts @@ -0,0 +1,856 @@ +import assert from "node:assert/strict" +import test from "node:test" + +import type { CommandSuccess } from "../application/conversation-command-contracts.ts" +import type { CanonicalGenerationRecord } from "../application/conversation-generation-service.ts" +import { emptyConversationGenerationCheckpoint } from "../domain/conversation-generation.ts" +import { + artifactId, + conversationId, + generationId, + messageId, + projectId, + threadForkId, + threadId, + turnId, + workspaceId, + type ConversationMessage, + type ConversationSnapshot, +} from "../domain/conversation-model.ts" +import { + clearConversationDerivedCache, + deriveConversationClientIndexes, + selectThreadMessages, +} from "./conversation-client-selectors.ts" +import { createNormalizedConversationStore } from "./normalized-conversation-store.ts" +import { + createConversationUiWorkspaceStore, + defaultConversationUiWorkspace, + parseConversationUiWorkspace, + serializeConversationUiWorkspace, +} from "./ui-workspace.ts" +import { + ConversationClientError, + createConversationClientGateway, +} from "./conversation-client-gateway.ts" +import { + createGenerationCoordinator, + type GenerationCoordinatorScheduler, +} from "./generation-coordinator.ts" + +const workspace = workspaceId("workspace-client-test") +const project = projectId("project-client-test") +const conversation = conversationId("conversation-client-test") +const root = threadId("thread-root") +const threadA = threadId("thread-A") +const threadB = threadId("thread-B") +const threadC = threadId("thread-C") + +function content(text: string) { + return { + schemaVersion: 1 as const, + parts: [{ type: "text" as const, text }], + } +} + +function message(input: { + id: string + threadId: ReturnType + turnId: ReturnType + role: "user" | "assistant" + text: string +}): ConversationMessage { + return { + id: messageId(input.id), + threadId: input.threadId, + turnId: input.turnId, + role: input.role, + content: content(input.text), + contentState: "complete", + createdAt: `2026-08-22T00:00:0${input.id.length % 9}.000Z`, + } +} + +function fixture() { + const threadIds = [root, threadA, threadB, threadC] + const turns = Object.fromEntries( + threadIds.map((targetThreadId, index) => { + const id = turnId(`turn-${index}`) + return [ + id, + { + id, + threadId: targetThreadId, + position: 0, + activeUserMessageId: messageId(`message-${index}-user`), + activeAssistantMessageId: messageId(`message-${index}-assistant`), + revision: 0, + }, + ] + }) + ) + const messages = Object.fromEntries( + threadIds.flatMap((targetThreadId, index) => { + const targetTurnId = turnId(`turn-${index}`) + const user = message({ + id: `message-${index}-user`, + threadId: targetThreadId, + turnId: targetTurnId, + role: "user", + text: `用户-${index}`, + }) + const assistant = message({ + id: `message-${index}-assistant`, + threadId: targetThreadId, + turnId: targetTurnId, + role: "assistant", + text: `助手-${index}`, + }) + return [ + [user.id, user], + [assistant.id, assistant], + ] + }) + ) + const snapshot: ConversationSnapshot = { + schemaVersion: 1, + project: { + id: project, + workspaceId: workspace, + title: "客户端测试", + revision: 0, + lifecycle: "active", + }, + conversation: { + id: conversation, + projectId: project, + rootThreadId: root, + autoTitle: "规范客户端", + customTitle: null, + revision: 0, + lifecycle: "active", + }, + threads: Object.fromEntries( + threadIds.map((id, index) => [ + id, + { + id, + conversationId: conversation, + modelId: "glm-5.3", + localTitle: index === 0 ? null : `分支-${index}`, + revision: 0, + lifecycle: "active" as const, + }, + ]) + ), + threadForks: { + "fork-A": { + id: threadForkId("fork-A"), + conversationId: conversation, + parentThreadId: root, + childThreadId: threadA, + sourceMessageId: messageId("message-0-assistant"), + createdBy: "user-client-test", + createdAt: "2026-08-22T00:00:10.000Z", + }, + "fork-B": { + id: threadForkId("fork-B"), + conversationId: conversation, + parentThreadId: threadA, + childThreadId: threadB, + sourceMessageId: messageId("message-1-assistant"), + createdBy: "user-client-test", + createdAt: "2026-08-22T00:00:11.000Z", + }, + "fork-C": { + id: threadForkId("fork-C"), + conversationId: conversation, + parentThreadId: threadB, + childThreadId: threadC, + sourceMessageId: messageId("message-2-assistant"), + createdBy: "user-client-test", + createdAt: "2026-08-22T00:00:12.000Z", + }, + }, + turns, + messages, + generations: {}, + artifactProvenance: { + "artifact-client-test": { + id: artifactId("artifact-client-test"), + sourceThreadId: threadA, + sourceMessageId: messageId("message-1-assistant"), + title: "规范 Artifact", + kind: "markdown", + }, + }, + } + return { + snapshot, + generations: [], + contextMessageIdsByThread: {}, + } +} + +function generationRecord(input: { + id: string + threadId: ReturnType + turnId: ReturnType + inputMessageId: string + outputMessageId: string + checkpointVersion: number + body: string +}): CanonicalGenerationRecord { + const id = generationId(input.id) + return { + id, + ownerId: "user-client-test", + workspaceId: workspace, + projectId: project, + conversationId: conversation, + threadId: input.threadId, + turnId: input.turnId, + inputMessageId: messageId(input.inputMessageId), + outputMessageId: messageId(input.outputMessageId), + intent: { kind: "send" }, + requestHash: "hash", + idempotencyKey: "key", + modelId: "glm-5.3", + attempt: 1, + isCurrent: true, + status: "running", + billingStatus: "pending", + contentState: "streaming", + checkpointVersion: input.checkpointVersion, + checkpoint: { + ...emptyConversationGenerationCheckpoint(), + body: input.body, + contentState: "streaming", + }, + knownUsage: null, + usageCompleteness: "unavailable", + paidCallStarted: true, + leaseOwner: "worker", + leaseVersion: 0, + heartbeatAt: "2026-08-22T00:00:20.000Z", + stopRequestedAt: null, + startedAt: "2026-08-22T00:00:20.000Z", + finishedAt: null, + errorCode: null, + createdAt: "2026-08-22T00:00:20.000Z", + } +} + +test("快照先完整验证再原子安装,失败不覆盖旧状态", () => { + const store = createNormalizedConversationStore() + store.installSnapshot(fixture()) + const before = store.getState() + assert.equal(before.commitVersion, 1) + assert.ok(Object.isFrozen(before.messagesById["message-0-user"])) + + const baseInvalid = fixture() + const invalid = { + ...baseInvalid, + snapshot: { + ...baseInvalid.snapshot, + threadForks: { + ...baseInvalid.snapshot.threadForks, + "fork-A": { + ...baseInvalid.snapshot.threadForks["fork-A"]!, + sourceMessageId: messageId("missing"), + }, + }, + }, + } + assert.throws(() => store.installSnapshot(invalid)) + assert.equal(store.getState(), before) + assert.equal(store.getState().commitVersion, 1) +}) + +test("A → B → C 索引、继承上下文和画布边可从规范实体重建", () => { + const store = createNormalizedConversationStore() + store.installSnapshot(fixture()) + const first = deriveConversationClientIndexes(store.getState()) + assert.equal(first.depthByThread[threadC], 3) + assert.deepEqual(first.contextMessageIdsByThread[threadC], [ + messageId("message-0-user"), + messageId("message-0-assistant"), + messageId("message-1-user"), + messageId("message-1-assistant"), + messageId("message-2-user"), + messageId("message-2-assistant"), + messageId("message-3-user"), + messageId("message-3-assistant"), + ]) + assert.equal(first.canvasEdges.length, 3) + clearConversationDerivedCache() + const rebuilt = deriveConversationClientIndexes(store.getState()) + assert.deepEqual(rebuilt, first) + assert.equal(selectThreadMessages(store.getState(), threadC, true).length, 8) +}) + +test("Message 结构化研究与 Artifact 来源通过稳定 Message/Thread ID 保留", () => { + const value = fixture() + const source = value.snapshot.messages[messageId("message-1-assistant")]! + value.snapshot = { + ...value.snapshot, + messages: { + ...value.snapshot.messages, + [source.id]: { + ...source, + content: { + schemaVersion: 1, + parts: [ + { type: "text", text: "## Markdown 结果" }, + { + type: "structured", + kind: "research-activities", + value: [ + { + id: "research-1", + kind: "web-search", + status: "complete", + sources: [{ url: "https://example.invalid/source" }], + }, + ], + }, + { + type: "artifact-reference", + artifactId: artifactId("artifact-client-test"), + }, + ], + }, + }, + }, + } + const store = createNormalizedConversationStore() + store.installSnapshot(value) + const selected = selectThreadMessages(store.getState(), threadA).find( + (candidate) => candidate.id === source.id + ) + assert.ok(selected) + assert.equal(selected.threadId, threadA) + assert.equal(selected.content.parts[1]?.type, "structured") + assert.equal( + store.getState().artifactProvenanceById["artifact-client-test"] + ?.sourceMessageId, + source.id + ) +}) + +test("父 Turn 切换变体后,历史 Fork 仍精确继承原 source Message", () => { + const base = fixture() + const sourceTurn = base.snapshot.turns[turnId("turn-0")]! + const oldSource = base.snapshot.messages[messageId("message-0-assistant")]! + const nextSource = message({ + id: "message-0-assistant-v2", + threadId: root, + turnId: sourceTurn.id, + role: "assistant", + text: "新的 active 变体", + }) + const oldGenerationId = generationId("generation-old-fork-source") + const value = { + ...base, + snapshot: { + ...base.snapshot, + turns: { + ...base.snapshot.turns, + [sourceTurn.id]: { + ...sourceTurn, + activeAssistantMessageId: nextSource.id, + revision: 1, + }, + }, + messages: { + ...base.snapshot.messages, + [nextSource.id]: { ...nextSource, variantOfMessageId: oldSource.id }, + }, + generations: { + [oldGenerationId]: { + id: oldGenerationId, + threadId: root, + turnId: sourceTurn.id, + inputMessageId: sourceTurn.activeUserMessageId, + outputMessageId: oldSource.id, + intent: { kind: "send" as const }, + status: "completed" as const, + billingStatus: "not_billable" as const, + attempt: 1, + createdAt: oldSource.createdAt, + }, + }, + }, + } + const store = createNormalizedConversationStore() + store.installSnapshot(value) + const inherited = deriveConversationClientIndexes(store.getState()) + .contextMessageIdsByThread[threadA] + assert.ok(inherited?.includes(oldSource.id)) + assert.ok(!inherited?.includes(nextSource.id)) +}) + +test("delta 幂等忽略旧值,版本间隙和未知 schema 标记重取", () => { + const store = createNormalizedConversationStore() + store.installSnapshot(fixture()) + const rename: CommandSuccess = { + schemaVersion: 1, + data: {}, + revisions: { [conversation]: 1 }, + delta: { + upsert: { + conversations: [ + { + ...fixture().snapshot.conversation, + customTitle: "新标题", + revision: 1, + }, + ], + }, + remove: {}, + invalidate: [], + }, + replayed: false, + } + assert.equal(store.mergeCommandResult(rename).applied, true) + const version = store.getState().commitVersion + assert.equal(store.mergeCommandResult(rename).applied, false) + assert.equal(store.getState().commitVersion, version) + + const old = { + ...rename, + delta: { + ...rename.delta, + upsert: { + conversations: [ + { ...fixture().snapshot.conversation, customTitle: "旧标题" }, + ], + }, + }, + } satisfies CommandSuccess + assert.equal(store.mergeCommandResult(old).applied, false) + assert.equal( + store.getState().conversationsById[conversation]?.customTitle, + "新标题" + ) + + const gap = { + ...rename, + revisions: { [conversation]: 3 }, + } satisfies CommandSuccess + assert.equal(store.mergeCommandResult(gap).requiresReload, true) + assert.ok(store.getState().staleConversationIds.has(conversation)) + + const unknown = { ...rename, schemaVersion: 2 } as unknown as CommandSuccess + assert.equal(store.mergeCommandResult(unknown).requiresReload, true) +}) + +test("Generation 只接受更高 checkpoint,并且不通知无关 Thread", () => { + const store = createNormalizedConversationStore() + store.installSnapshot(fixture()) + let rootNotifications = 0 + let branchNotifications = 0 + store.subscribe(`thread:${root}:messages`, () => { + rootNotifications += 1 + }) + store.subscribe(`thread:${threadA}:messages`, () => { + branchNotifications += 1 + }) + const first = generationRecord({ + id: "generation-A", + threadId: threadA, + turnId: turnId("turn-1"), + inputMessageId: "message-1-user", + outputMessageId: "message-1-assistant", + checkpointVersion: 2, + body: "新的正文", + }) + assert.equal(store.mergeGeneration(first).applied, true) + assert.equal( + store.mergeGeneration({ ...first, checkpointVersion: 1 }).applied, + false + ) + assert.equal(rootNotifications, 0) + assert.equal(branchNotifications, 1) + assert.deepEqual( + store.getState().messagesById["message-1-assistant"]?.content, + content("新的正文") + ) +}) + +test("UI Workspace 持久化只恢复当前 Conversation 的活跃 Thread", () => { + const loaded = fixture().snapshot + const seed = defaultConversationUiWorkspace({ + conversationId: conversation, + rootThreadId: root, + }) + const workspaceStore = createConversationUiWorkspaceStore(seed) + workspaceStore.openThread(threadA) + workspaceStore.foldThread(threadA, true) + workspaceStore.setDraft(threadA, "保留草稿") + const raw = serializeConversationUiWorkspace(workspaceStore.getState()) + const threads = { + ...loaded.threads, + [threadA]: { ...loaded.threads[threadA]!, lifecycle: "archived" as const }, + } + const restored = parseConversationUiWorkspace({ + raw, + conversationId: conversation, + rootThreadId: root, + threads, + }) + assert.deepEqual(restored.visibleThreadIds, [root]) + assert.equal(restored.selectedThreadId, root) + assert.equal(restored.draftsByThreadId[threadA], undefined) +}) + +test("UI Workspace 操作不改变 canonical revision 或触发 canonical 通知", () => { + const store = createNormalizedConversationStore() + store.installSnapshot(fixture()) + const revisionBefore = store.revisionOf(conversation) + const commitBefore = store.getState().commitVersion + let canonicalNotifications = 0 + const unsubscribe = store.subscribe("canonical", () => { + canonicalNotifications += 1 + }) + const workspaceStore = createConversationUiWorkspaceStore( + defaultConversationUiWorkspace({ + conversationId: conversation, + rootThreadId: root, + }) + ) + workspaceStore.openThread(threadA) + workspaceStore.foldThread(threadA, true) + workspaceStore.update((current) => ({ + ...current, + viewMode: "canvas", + openPanels: ["artifact"], + })) + unsubscribe() + assert.equal(store.revisionOf(conversation), revisionBefore) + assert.equal(store.getState().commitVersion, commitBefore) + assert.equal(canonicalNotifications, 0) +}) + +function commandResult(title: string): CommandSuccess { + return { + schemaVersion: 1, + data: {}, + revisions: { [conversation]: 1 }, + delta: { + upsert: { + conversations: [ + { + ...fixture().snapshot.conversation, + customTitle: title, + revision: 1, + }, + ], + }, + remove: {}, + invalidate: [], + }, + replayed: false, + } +} + +test("client gateway 使用最小 revision,并以同一幂等键确认网络不确定命令", async () => { + const store = createNormalizedConversationStore() + store.installSnapshot(fixture()) + const requests: RequestInit[] = [] + let attempt = 0 + const gateway = createConversationClientGateway({ + store, + fetch: async (_url, init) => { + requests.push(init ?? {}) + attempt += 1 + if (attempt === 1) throw new Error("connection reset") + return Response.json(commandResult("幂等确认成功")) + }, + }) + const commandId = "command-network-retry" + await assert.rejects( + gateway.renameConversation( + conversation, + { title: "幂等确认成功" }, + { + commandId, + overlay: { kind: "rename", presentationKey: "title" }, + } + ), + (error) => + error instanceof ConversationClientError && + error.code === "network_uncertain" && + error.uncertain + ) + assert.equal( + store.getCommandState().pendingByCommandId[commandId]?.status, + "confirming" + ) + await gateway.retry(commandId) + const firstHeaders = new Headers(requests[0]?.headers) + const retryHeaders = new Headers(requests[1]?.headers) + assert.equal(firstHeaders.get("Idempotency-Key"), commandId) + assert.equal(retryHeaders.get("Idempotency-Key"), commandId) + assert.equal(firstHeaders.get("X-Command-Id"), commandId) + assert.equal(firstHeaders.get("If-Match"), '"0"') + assert.equal( + store.getState().conversationsById[conversation]?.customTitle, + "幂等确认成功" + ) + assert.equal(store.getCommandState().pendingByCommandId[commandId], undefined) +}) + +test("client gateway 遇到 409 后重取快照并保留失败 overlay", async () => { + const store = createNormalizedConversationStore() + store.installSnapshot(fixture()) + let requestCount = 0 + const currentServerSnapshot = fixture() + const serverSnapshot = { + ...currentServerSnapshot, + snapshot: { + ...currentServerSnapshot.snapshot, + conversation: { + ...currentServerSnapshot.snapshot.conversation, + customTitle: "服务端较新标题", + revision: 1, + }, + }, + } + const gateway = createConversationClientGateway({ + store, + fetch: async () => { + requestCount += 1 + if (requestCount === 1) + return Response.json( + { + error: { + code: "version_conflict", + message: "revision 已变化", + requestId: "request-conflict", + details: { currentRevision: 1 }, + }, + }, + { status: 409 } + ) + return Response.json({ schemaVersion: 1, data: serverSnapshot }) + }, + }) + await assert.rejects( + gateway.renameConversation( + conversation, + { title: "过期写入" }, + { + commandId: "command-conflict", + overlay: { kind: "rename", presentationKey: "title" }, + } + ), + (error) => + error instanceof ConversationClientError && + error.code === "version_conflict" + ) + assert.equal(requestCount, 2) + assert.equal(store.revisionOf(conversation), 1) + assert.equal( + store.getState().conversationsById[conversation]?.customTitle, + "服务端较新标题" + ) + assert.equal( + store.getCommandState().pendingByCommandId["command-conflict"]?.status, + "failed" + ) +}) + +test("共享 gateway 在发起视图卸载后仍合并已提交结果", async () => { + const store = createNormalizedConversationStore() + store.installSnapshot(fixture()) + let release!: (response: Response) => void + const response = new Promise((resolve) => { + release = resolve + }) + const gateway = createConversationClientGateway({ + store, + fetch: async () => response, + }) + const submitted = gateway.renameConversation( + conversation, + { title: "卸载后仍完成" }, + { commandId: "command-unmounted" } + ) + // 不保留任何组件订阅,也不把 AbortSignal 绑定到组件生命周期。 + release(Response.json(commandResult("卸载后仍完成"))) + await submitted + assert.equal( + store.getState().conversationsById[conversation]?.customTitle, + "卸载后仍完成" + ) +}) + +test("Fork 使用 Conversation revision,消息反馈始终使用稳定实体 ID", async () => { + const store = createNormalizedConversationStore() + store.installSnapshot(fixture()) + const requests: Array<{ url: string; init?: RequestInit }> = [] + const gateway = createConversationClientGateway({ + store, + fetch: async (url, init) => { + requests.push({ url: String(url), init }) + if (String(url).endsWith("/message-feedback")) + return Response.json({ + feedback: [ + { + conversationId: conversation, + threadId: root, + messageId: "message-0-assistant", + feedback: "positive", + updatedAt: "2026-08-22T00:00:00.000Z", + }, + ], + }) + if (String(url).endsWith("/feedback")) + return Response.json({ + feedback: { + conversationId: conversation, + threadId: root, + messageId: "message-0-assistant", + feedback: "negative", + updatedAt: "2026-08-22T00:00:01.000Z", + }, + }) + return Response.json(commandResult("Fork revision 已验证")) + }, + }) + + await gateway.forkThread(root, { + conversationId: conversation, + forkId: threadForkId("fork-gateway-test"), + childThreadId: threadId("thread-gateway-child"), + sourceMessageId: messageId("message-0-assistant"), + modelId: "glm-5.3", + }) + assert.equal(new Headers(requests[0]?.init?.headers).get("If-Match"), '"0"') + + const listed = await gateway.listMessageFeedback(conversation) + assert.equal(listed[0]?.messageId, "message-0-assistant") + const saved = await gateway.setMessageFeedback({ + conversationId: conversation, + threadId: root, + messageId: "message-0-assistant", + feedback: "negative", + }) + assert.equal(saved?.feedback, "negative") + assert.equal( + requests[2]?.url, + `/api/conversations/${conversation}/messages/message-0-assistant/feedback` + ) + assert.deepEqual(JSON.parse(String(requests[2]?.init?.body)), { + threadId: root, + feedback: "negative", + }) +}) + +class ManualScheduler implements GenerationCoordinatorScheduler { + readonly timers = new Map void; delayMs: number }>() + private nextId = 1 + + setTimeout(callback: () => void, delayMs: number): unknown { + const id = this.nextId++ + this.timers.set(id, { callback, delayMs }) + return id + } + + clearTimeout(handle: unknown): void { + this.timers.delete(handle as number) + } + + runNext(): void { + const [id, timer] = this.timers.entries().next().value ?? [] + if (id === undefined || !timer) return + this.timers.delete(id) + timer.callback() + } +} + +async function flushPromises() { + await Promise.resolve() + await Promise.resolve() +} + +test("GenerationCoordinator 多视图共享监控,释放不 Stop,重挂载恢复", async () => { + const store = createNormalizedConversationStore() + store.installSnapshot(fixture()) + const scheduler = new ManualScheduler() + const records = [ + generationRecord({ + id: "generation-coordinator", + threadId: threadA, + turnId: turnId("turn-1"), + inputMessageId: "message-1-user", + outputMessageId: "message-1-assistant", + checkpointVersion: 2, + body: "checkpoint-2", + }), + generationRecord({ + id: "generation-coordinator", + threadId: threadA, + turnId: turnId("turn-1"), + inputMessageId: "message-1-user", + outputMessageId: "message-1-assistant", + checkpointVersion: 1, + body: "过期 checkpoint", + }), + ] + let queries = 0 + const stops = 0 + const coordinator = createGenerationCoordinator({ + store, + scheduler, + visiblePollMs: 2_000, + hiddenPollMs: 10_000, + gateway: { + getGeneration: async () => + records[Math.min(queries++, records.length - 1)]!, + }, + }) + const targetGenerationId = generationId("generation-coordinator") + const releaseColumn = coordinator.subscribe(targetGenerationId, () => {}) + const releasePanel = coordinator.subscribe(targetGenerationId, () => {}) + await flushPromises() + assert.equal(queries, 1) + assert.equal(coordinator.monitoredCount(), 1) + assert.deepEqual( + [...scheduler.timers.values()].map((timer) => timer.delayMs), + [2_000] + ) + + coordinator.setVisibility("hidden") + assert.deepEqual( + [...scheduler.timers.values()].map((timer) => timer.delayMs), + [10_000] + ) + scheduler.runNext() + await flushPromises() + assert.equal(queries, 2) + assert.equal( + store.getState().checkpointVersionsByGenerationId[targetGenerationId], + 2 + ) + const checkpointPart = + store.getState().messagesById["message-1-assistant"]?.content.parts[0] + assert.equal( + checkpointPart?.type === "text" ? checkpointPart.text : null, + "checkpoint-2" + ) + + releaseColumn() + assert.equal(coordinator.monitoredCount(), 1) + releasePanel() + assert.equal(coordinator.monitoredCount(), 0) + assert.equal(scheduler.timers.size, 0) + assert.equal(stops, 0) + + const releaseRemount = coordinator.subscribe(targetGenerationId, () => {}) + await flushPromises() + assert.equal(queries, 3) + releaseRemount() + coordinator.dispose() +}) diff --git a/lib/thread-chat/client/normalized-conversation-store.ts b/lib/thread-chat/client/normalized-conversation-store.ts new file mode 100644 index 00000000..0f9c6777 --- /dev/null +++ b/lib/thread-chat/client/normalized-conversation-store.ts @@ -0,0 +1,556 @@ +import type { + CanonicalEntityDelta, + CommandSuccess, + ConversationSnapshotResult, +} from "../application/conversation-command-contracts" +import type { CanonicalGenerationRecord } from "../application/conversation-generation-service" +import { checkpointMessageContent } from "../domain/conversation-generation" +import type { + Conversation, + ConversationArtifactProvenance, + ConversationGeneration, + ConversationId, + ConversationMessage, + ConversationSnapshot, + ConversationThread, + ConversationTurn, + GenerationId, + MessageId, + Project, + ThreadFork, + ThreadId, +} from "../domain/conversation-model" +import { assertValidConversationSnapshot } from "../domain/conversation-validation" +import { parseConversationSnapshotResult } from "./conversation-client-contracts" + +export type ClientGeneration = + ConversationGeneration | CanonicalGenerationRecord + +export interface CanonicalClientState { + readonly schemaVersion: 1 + readonly project: Project | null + readonly conversationsById: Readonly> + readonly threadsById: Readonly> + readonly threadForksById: Readonly> + readonly turnsById: Readonly> + readonly messagesById: Readonly> + readonly generationsById: Readonly> + readonly artifactProvenanceById: Readonly< + Record + > + /** 命令并发边界的最新服务端 revision;不能从实体字段顺序推断。 */ + readonly revisionsByScopeId: Readonly> + readonly checkpointVersionsByGenerationId: Readonly> + readonly tombstoneRevisionsByEntityId: Readonly> + readonly staleConversationIds: ReadonlySet + readonly loadingError: Error | null + readonly commitVersion: number +} + +export type PendingCommandStatus = "pending" | "confirming" | "failed" + +export interface PendingCommandOverlay { + readonly commandId: string + readonly kind: string + readonly threadId?: ThreadId + readonly presentationKey: string + readonly draft?: string + readonly status: PendingCommandStatus + readonly error?: string +} + +export interface CommandOverlayState { + readonly pendingByCommandId: Readonly> +} + +export interface DeltaMergeResult { + readonly applied: boolean + readonly requiresReload: boolean + readonly reason?: string +} + +export type CanonicalSubscriptionKey = + | "canonical" + | `conversation:${string}` + | `thread:${string}` + | `thread:${string}:messages` + | `message:${string}` + | `generation:${string}` + | `command:${string}` + +export interface NormalizedConversationStore { + readonly getState: () => CanonicalClientState + readonly getCommandState: () => CommandOverlayState + readonly installSnapshot: (value: unknown) => ConversationSnapshotResult + readonly mergeCommandResult: (result: CommandSuccess) => DeltaMergeResult + readonly mergeGeneration: ( + generation: CanonicalGenerationRecord + ) => DeltaMergeResult + readonly markConversationStale: ( + conversationId: ConversationId, + reason: string + ) => void + readonly revisionOf: (scopeId: string) => number | undefined + readonly subscribe: ( + key: CanonicalSubscriptionKey, + fn: () => void + ) => () => void + readonly snapshotForKey: (key: CanonicalSubscriptionKey) => number + readonly beginCommand: (overlay: PendingCommandOverlay) => void + readonly markCommandConfirming: (commandId: string) => void + readonly resolveCommand: (commandId: string) => void + readonly failCommand: (commandId: string, message: string) => void +} + +const EMPTY_STATE: CanonicalClientState = { + schemaVersion: 1, + project: null, + conversationsById: {}, + threadsById: {}, + threadForksById: {}, + turnsById: {}, + messagesById: {}, + generationsById: {}, + artifactProvenanceById: {}, + revisionsByScopeId: {}, + checkpointVersionsByGenerationId: {}, + tombstoneRevisionsByEntityId: {}, + staleConversationIds: new Set(), + loadingError: null, + commitVersion: 0, +} + +function deepFreeze(value: T): T { + if ( + process.env.NODE_ENV === "production" || + value === null || + typeof value !== "object" || + Object.isFrozen(value) + ) + return value + Object.freeze(value) + for (const nested of Object.values(value as Record)) + deepFreeze(nested) + return value +} + +function cloneRegistry( + value: Readonly> +): Record { + return { ...value } +} + +function stableEqual(left: unknown, right: unknown): boolean { + return JSON.stringify(left) === JSON.stringify(right) +} + +function fullGeneration( + generation: ClientGeneration +): generation is CanonicalGenerationRecord { + return "checkpointVersion" in generation +} + +function snapshotFromState( + state: CanonicalClientState, + targetConversation: Conversation +): ConversationSnapshot { + if (!state.project) throw new Error("规范 Store 缺少 Project") + return { + schemaVersion: 1, + project: state.project, + conversation: targetConversation, + threads: state.threadsById, + threadForks: state.threadForksById, + turns: state.turnsById, + messages: state.messagesById, + generations: state.generationsById, + artifactProvenance: state.artifactProvenanceById, + } +} + +function entityRevision(entity: unknown): number | undefined { + if (!entity || typeof entity !== "object" || !("revision" in entity)) + return undefined + const value = (entity as { revision?: unknown }).revision + return typeof value === "number" && Number.isInteger(value) && value >= 0 + ? value + : undefined +} + +function touchedKeysForDelta( + delta: CanonicalEntityDelta +): Set { + const keys = new Set(["canonical"]) + for (const conversation of delta.upsert.conversations ?? []) + keys.add(`conversation:${conversation.id}`) + for (const thread of delta.upsert.threads ?? []) + keys.add(`thread:${thread.id}`) + for (const message of delta.upsert.messages ?? []) { + keys.add(`message:${message.id}`) + keys.add(`thread:${message.threadId}:messages`) + } + for (const generation of delta.upsert.generations ?? []) { + keys.add(`generation:${generation.id}`) + keys.add(`thread:${generation.threadId}:messages`) + } + for (const fork of delta.upsert.threadForks ?? []) { + keys.add(`thread:${fork.parentThreadId}`) + keys.add(`thread:${fork.childThreadId}`) + } + for (const id of delta.remove.conversations ?? []) + keys.add(`conversation:${id}`) + for (const id of delta.remove.threads ?? []) keys.add(`thread:${id}`) + for (const id of delta.remove.messages ?? []) keys.add(`message:${id}`) + for (const id of delta.remove.generations ?? []) keys.add(`generation:${id}`) + return keys +} + +export function createNormalizedConversationStore(): NormalizedConversationStore { + let state = deepFreeze(EMPTY_STATE) + let commands: CommandOverlayState = deepFreeze({ pendingByCommandId: {} }) + const listeners = new Map void>>() + const keyVersions = new Map() + + const notify = (keys: Iterable) => { + for (const key of keys) { + keyVersions.set(key, (keyVersions.get(key) ?? 0) + 1) + for (const listener of listeners.get(key) ?? []) listener() + } + } + + const commitState = ( + next: Omit, + keys: Iterable + ) => { + state = deepFreeze({ ...next, commitVersion: state.commitVersion + 1 }) + notify(keys) + } + + const markStale = (targetId: ConversationId, reason: string) => { + const stale = new Set(state.staleConversationIds) + stale.add(targetId) + commitState( + { + ...state, + staleConversationIds: stale, + loadingError: new Error(reason), + }, + ["canonical", `conversation:${targetId}`] + ) + } + + return { + getState: () => state, + getCommandState: () => commands, + installSnapshot(value) { + // 所有解析、归属和引用验证在 commit 之前完成;失败不会触碰旧状态。 + const parsed = parseConversationSnapshotResult(value) + const { snapshot } = parsed + const generationsById: Record = { + ...snapshot.generations, + } + const checkpointVersions: Record = {} + for (const generation of parsed.generations) { + generationsById[generation.id] = generation + checkpointVersions[generation.id] = generation.checkpointVersion + } + const revisions: Record = { + [snapshot.project.id]: snapshot.project.revision, + [snapshot.conversation.id]: snapshot.conversation.revision, + } + for (const entity of [ + ...Object.values(snapshot.threads), + ...Object.values(snapshot.turns), + ]) + revisions[entity.id] = entity.revision + commitState( + { + schemaVersion: 1, + project: deepFreeze(structuredClone(snapshot.project)), + conversationsById: deepFreeze({ + [snapshot.conversation.id]: structuredClone(snapshot.conversation), + }), + threadsById: deepFreeze(structuredClone(snapshot.threads)), + threadForksById: deepFreeze(structuredClone(snapshot.threadForks)), + turnsById: deepFreeze(structuredClone(snapshot.turns)), + messagesById: deepFreeze(structuredClone(snapshot.messages)), + generationsById: deepFreeze(structuredClone(generationsById)), + artifactProvenanceById: deepFreeze( + structuredClone(snapshot.artifactProvenance) + ), + revisionsByScopeId: deepFreeze(revisions), + checkpointVersionsByGenerationId: deepFreeze(checkpointVersions), + tombstoneRevisionsByEntityId: {}, + staleConversationIds: new Set(), + loadingError: null, + }, + ["canonical", `conversation:${snapshot.conversation.id}`] + ) + return parsed + }, + mergeCommandResult(result) { + const conversation = Object.values(state.conversationsById)[0] + if (!conversation) + return { + applied: false, + requiresReload: true, + reason: "Store 尚未 boot", + } + if (result.schemaVersion !== 1) { + markStale(conversation.id, "收到未知 command delta schemaVersion") + return { + applied: false, + requiresReload: true, + reason: "unknown_schema", + } + } + + for (const [scopeId, incoming] of Object.entries(result.revisions)) { + const current = state.revisionsByScopeId[scopeId] + if (current !== undefined && incoming > current + 1) { + markStale(conversation.id, `scope ${scopeId} 出现 revision 间隙`) + return { + applied: false, + requiresReload: true, + reason: "revision_gap", + } + } + } + for (const ids of Object.values(result.delta.remove)) { + for (const removedId of ids ?? []) { + if (result.revisions[removedId] === undefined) { + markStale( + conversation.id, + `删除 ${removedId} 缺少 tombstone revision` + ) + return { + applied: false, + requiresReload: true, + reason: "missing_tombstone_revision", + } + } + } + } + + const conversations = cloneRegistry(state.conversationsById) + const threads = cloneRegistry(state.threadsById) + const forks = cloneRegistry(state.threadForksById) + const turns = cloneRegistry(state.turnsById) + const messages = cloneRegistry(state.messagesById) + const generations = cloneRegistry(state.generationsById) + const revisions = cloneRegistry(state.revisionsByScopeId) + const tombstones = cloneRegistry(state.tombstoneRevisionsByEntityId) + let changed = false + + const upsertVersioned = ( + registry: Record, + values: readonly T[] + ) => { + for (const value of values) { + const currentVersion = revisions[value.id] + const incomingVersion = + entityRevision(value) ?? result.revisions[value.id] + if ( + incomingVersion !== undefined && + currentVersion !== undefined && + incomingVersion < currentVersion + ) + continue + if (stableEqual(registry[value.id], value)) continue + registry[value.id] = deepFreeze(structuredClone(value)) + if (incomingVersion !== undefined) + revisions[value.id] = incomingVersion + changed = true + } + } + const upsertImmutable = ( + registry: Record, + values: readonly T[] + ): boolean => { + for (const value of values) { + const current = registry[value.id] + if (current && !stableEqual(current, value)) return false + if (!current) { + registry[value.id] = deepFreeze(structuredClone(value)) + changed = true + } + } + return true + } + + upsertVersioned(conversations, result.delta.upsert.conversations ?? []) + upsertVersioned(threads, result.delta.upsert.threads ?? []) + upsertVersioned(turns, result.delta.upsert.turns ?? []) + if ( + !upsertImmutable(forks, result.delta.upsert.threadForks ?? []) || + !upsertImmutable(messages, result.delta.upsert.messages ?? []) + ) { + markStale(conversation.id, "不可变实体收到冲突内容") + return { + applied: false, + requiresReload: true, + reason: "identity_conflict", + } + } + upsertImmutable(generations, result.delta.upsert.generations ?? []) + + for (const [scopeId, incoming] of Object.entries(result.revisions)) { + if ((revisions[scopeId] ?? -1) < incoming) { + revisions[scopeId] = incoming + changed = true + } + } + + const remove = ( + registry: Record, + ids: readonly string[] + ) => { + for (const id of ids) { + const incoming = result.revisions[id]! + if ((tombstones[id] ?? -1) >= incoming) continue + delete registry[id] + tombstones[id] = incoming + revisions[id] = incoming + changed = true + } + } + remove(conversations, result.delta.remove.conversations ?? []) + remove(threads, result.delta.remove.threads ?? []) + remove(turns, result.delta.remove.turns ?? []) + remove(messages, result.delta.remove.messages ?? []) + remove(generations, result.delta.remove.generations ?? []) + + if (!changed) return { applied: false, requiresReload: false } + const next: Omit = { + ...state, + conversationsById: conversations, + threadsById: threads, + threadForksById: forks, + turnsById: turns, + messagesById: messages, + generationsById: generations, + revisionsByScopeId: revisions, + tombstoneRevisionsByEntityId: tombstones, + loadingError: null, + } + const remainingConversation = conversations[conversation.id] + if (remainingConversation) + assertValidConversationSnapshot( + snapshotFromState( + { ...next, commitVersion: state.commitVersion }, + remainingConversation + ) + ) + commitState(next, touchedKeysForDelta(result.delta)) + return { applied: true, requiresReload: false } + }, + mergeGeneration(generation) { + const conversation = state.conversationsById[generation.conversationId] + if (!conversation) + return { applied: false, requiresReload: true, reason: "not_loaded" } + const currentVersion = + state.checkpointVersionsByGenerationId[generation.id] ?? -1 + if (generation.checkpointVersion <= currentVersion) + return { applied: false, requiresReload: false } + const output = state.messagesById[generation.outputMessageId] + if (!output || output.threadId !== generation.threadId) { + markStale(conversation.id, "Generation 输出 Message 不可解析") + return { + applied: false, + requiresReload: true, + reason: "message_missing", + } + } + const generations = cloneRegistry(state.generationsById) + generations[generation.id] = deepFreeze(structuredClone(generation)) + const messages = cloneRegistry(state.messagesById) + messages[output.id] = deepFreeze({ + ...output, + content: checkpointMessageContent(generation.checkpoint), + contentState: generation.contentState, + }) + const versions = cloneRegistry(state.checkpointVersionsByGenerationId) + versions[generation.id] = generation.checkpointVersion + commitState( + { + ...state, + generationsById: generations, + messagesById: messages, + checkpointVersionsByGenerationId: versions, + loadingError: null, + }, + [ + "canonical", + `generation:${generation.id}`, + `message:${output.id}`, + `thread:${output.threadId}:messages`, + ] + ) + return { applied: true, requiresReload: false } + }, + markConversationStale: markStale, + revisionOf: (scopeId) => state.revisionsByScopeId[scopeId], + subscribe(key, fn) { + const values = listeners.get(key) ?? new Set() + values.add(fn) + listeners.set(key, values) + return () => { + values.delete(fn) + if (values.size === 0) listeners.delete(key) + } + }, + snapshotForKey: (key) => keyVersions.get(key) ?? 0, + beginCommand(overlay) { + commands = deepFreeze({ + pendingByCommandId: { + ...commands.pendingByCommandId, + [overlay.commandId]: deepFreeze(structuredClone(overlay)), + }, + }) + notify([`command:${overlay.commandId}`]) + }, + markCommandConfirming(commandId) { + const current = commands.pendingByCommandId[commandId] + if (!current) return + commands = deepFreeze({ + pendingByCommandId: { + ...commands.pendingByCommandId, + [commandId]: { ...current, status: "confirming" }, + }, + }) + notify([`command:${commandId}`]) + }, + resolveCommand(commandId) { + if (!commands.pendingByCommandId[commandId]) return + const pending = { ...commands.pendingByCommandId } + delete pending[commandId] + commands = deepFreeze({ pendingByCommandId: pending }) + notify([`command:${commandId}`]) + }, + failCommand(commandId, message) { + const current = commands.pendingByCommandId[commandId] + if (!current) return + commands = deepFreeze({ + pendingByCommandId: { + ...commands.pendingByCommandId, + [commandId]: { ...current, status: "failed", error: message }, + }, + }) + notify([`command:${commandId}`]) + }, + } +} + +export function canonicalGenerationRecord( + generation: ClientGeneration | undefined +): CanonicalGenerationRecord | null { + return generation && fullGeneration(generation) ? generation : null +} + +export const canonicalEntityKey = { + conversation: (id: ConversationId) => `conversation:${id}` as const, + thread: (id: ThreadId) => `thread:${id}` as const, + threadMessages: (id: ThreadId) => `thread:${id}:messages` as const, + message: (id: MessageId) => `message:${id}` as const, + generation: (id: GenerationId) => `generation:${id}` as const, +} diff --git a/lib/thread-chat/client/ui-workspace.ts b/lib/thread-chat/client/ui-workspace.ts index 61568c7d..3165f41b 100644 --- a/lib/thread-chat/client/ui-workspace.ts +++ b/lib/thread-chat/client/ui-workspace.ts @@ -1,18 +1,278 @@ -import type { ConversationId, ThreadId } from "../domain/conversation-model.ts" +import type { + ConversationId, + ConversationThread, + ThreadId, +} from "../domain/conversation-model.ts" -/** - * 纯展示状态。这里的任何字段都不得参与 Conversation revision 或领域写入。 - */ +export const CONVERSATION_UI_WORKSPACE_SCHEMA_VERSION = 1 as const + +export type ConversationColumnPolicy = "replace" | "fold" +export type ConversationViewMode = "columns" | "canvas" + +/** 纯展示状态。这里的任何字段都不得参与领域 revision 或命令载荷。 */ export interface ConversationUiWorkspace { + readonly schemaVersion: typeof CONVERSATION_UI_WORKSPACE_SCHEMA_VERSION readonly conversationId: ConversationId readonly visibleThreadIds: readonly ThreadId[] readonly foldedThreadIds: readonly ThreadId[] readonly selectedThreadId: ThreadId | null - readonly canvasViewport: Readonly<{ - x: number - y: number - zoom: number - }> + readonly forcedColumnCount: number | null + readonly columnPolicy: ConversationColumnPolicy + readonly columnWidthsByThreadId: Readonly> + readonly viewMode: ConversationViewMode + readonly canvasViewport: Readonly<{ x: number; y: number; zoom: number }> readonly openPanels: readonly string[] readonly draftsByThreadId: Readonly> + readonly localHints: Readonly> +} + +export interface ConversationUiWorkspaceStore { + readonly getState: () => ConversationUiWorkspace + readonly getVersion: () => number + readonly subscribe: (listener: () => void) => () => void + readonly update: ( + update: (current: ConversationUiWorkspace) => ConversationUiWorkspace + ) => void + readonly hydrate: (state: ConversationUiWorkspace) => void + readonly openThread: (threadId: ThreadId) => void + readonly closeThread: (threadId: ThreadId) => void + readonly foldThread: (threadId: ThreadId, folded: boolean) => void + readonly selectThread: (threadId: ThreadId | null) => void + readonly setDraft: (threadId: ThreadId, value: string) => void + readonly reconcileThreads: ( + threads: Readonly>, + rootThreadId: ThreadId + ) => void +} + +export function defaultConversationUiWorkspace(input: { + readonly conversationId: ConversationId + readonly rootThreadId: ThreadId +}): ConversationUiWorkspace { + return { + schemaVersion: CONVERSATION_UI_WORKSPACE_SCHEMA_VERSION, + conversationId: input.conversationId, + visibleThreadIds: [input.rootThreadId], + foldedThreadIds: [], + selectedThreadId: input.rootThreadId, + forcedColumnCount: null, + columnPolicy: "replace", + columnWidthsByThreadId: {}, + viewMode: "columns", + canvasViewport: { x: 0, y: 0, zoom: 1 }, + openPanels: [], + draftsByThreadId: {}, + localHints: {}, + } +} + +function unique(values: readonly T[]): T[] { + return [...new Set(values)] +} + +function normalizeWorkspace( + value: ConversationUiWorkspace +): ConversationUiWorkspace { + return { + ...value, + visibleThreadIds: unique(value.visibleThreadIds), + foldedThreadIds: unique(value.foldedThreadIds).filter((id) => + value.visibleThreadIds.includes(id) + ), + openPanels: unique(value.openPanels), + canvasViewport: { ...value.canvasViewport }, + columnWidthsByThreadId: { ...value.columnWidthsByThreadId }, + draftsByThreadId: { ...value.draftsByThreadId }, + localHints: { ...value.localHints }, + } +} + +export function createConversationUiWorkspaceStore( + seed: ConversationUiWorkspace +): ConversationUiWorkspaceStore { + let state = normalizeWorkspace(seed) + let version = 0 + const listeners = new Set<() => void>() + const commit = (next: ConversationUiWorkspace) => { + if (next.conversationId !== state.conversationId) + throw new Error("UI Workspace 不能切换 Conversation 身份") + state = normalizeWorkspace(next) + version += 1 + for (const listener of listeners) listener() + } + return { + getState: () => state, + getVersion: () => version, + subscribe(listener) { + listeners.add(listener) + return () => listeners.delete(listener) + }, + update(update) { + commit(update(state)) + }, + hydrate(next) { + commit(next) + }, + openThread(targetThreadId) { + commit({ + ...state, + visibleThreadIds: unique([...state.visibleThreadIds, targetThreadId]), + foldedThreadIds: state.foldedThreadIds.filter( + (id) => id !== targetThreadId + ), + selectedThreadId: targetThreadId, + }) + }, + closeThread(targetThreadId) { + const visible = state.visibleThreadIds.filter( + (id) => id !== targetThreadId + ) + commit({ + ...state, + visibleThreadIds: visible, + foldedThreadIds: state.foldedThreadIds.filter( + (id) => id !== targetThreadId + ), + selectedThreadId: + state.selectedThreadId === targetThreadId + ? (visible.at(-1) ?? null) + : state.selectedThreadId, + }) + }, + foldThread(targetThreadId, folded) { + commit({ + ...state, + foldedThreadIds: folded + ? unique([...state.foldedThreadIds, targetThreadId]) + : state.foldedThreadIds.filter((id) => id !== targetThreadId), + }) + }, + selectThread(selectedThreadId) { + commit({ ...state, selectedThreadId }) + }, + setDraft(targetThreadId, value) { + const drafts = { ...state.draftsByThreadId } + if (value) drafts[targetThreadId] = value + else delete drafts[targetThreadId] + commit({ ...state, draftsByThreadId: drafts }) + }, + reconcileThreads(threads, rootThreadId) { + const active = new Set( + Object.values(threads) + .filter((thread) => thread.lifecycle === "active") + .map((thread) => thread.id) + ) + active.add(rootThreadId) + const visible = state.visibleThreadIds.filter((id) => active.has(id)) + if (!visible.includes(rootThreadId)) visible.unshift(rootThreadId) + const selected = + state.selectedThreadId && active.has(state.selectedThreadId) + ? state.selectedThreadId + : (visible.at(-1) ?? rootThreadId) + commit({ + ...state, + visibleThreadIds: visible, + foldedThreadIds: state.foldedThreadIds.filter((id) => active.has(id)), + selectedThreadId: selected, + }) + }, + } +} + +export function serializeConversationUiWorkspace( + state: ConversationUiWorkspace +): string { + return JSON.stringify(normalizeWorkspace(state)) +} + +export function parseConversationUiWorkspace(input: { + readonly raw: string | null + readonly conversationId: ConversationId + readonly rootThreadId: ThreadId + readonly threads: Readonly> +}): ConversationUiWorkspace { + const fallback = defaultConversationUiWorkspace(input) + if (!input.raw) return fallback + try { + const value = JSON.parse(input.raw) as Partial + if ( + value.schemaVersion !== CONVERSATION_UI_WORKSPACE_SCHEMA_VERSION || + value.conversationId !== input.conversationId || + !Array.isArray(value.visibleThreadIds) || + !Array.isArray(value.foldedThreadIds) + ) + return fallback + const active = new Set( + Object.values(input.threads) + .filter((thread) => thread.lifecycle === "active") + .map((thread) => thread.id) + ) + active.add(input.rootThreadId) + const visible = unique( + value.visibleThreadIds.filter( + (candidate): candidate is ThreadId => + typeof candidate === "string" && active.has(candidate as ThreadId) + ) + ) + if (!visible.includes(input.rootThreadId)) + visible.unshift(input.rootThreadId) + const folded = unique( + value.foldedThreadIds.filter( + (candidate): candidate is ThreadId => + typeof candidate === "string" && + visible.includes(candidate as ThreadId) + ) + ) + const selected = + typeof value.selectedThreadId === "string" && + active.has(value.selectedThreadId as ThreadId) + ? (value.selectedThreadId as ThreadId) + : (visible.at(-1) ?? input.rootThreadId) + return normalizeWorkspace({ + ...fallback, + ...value, + schemaVersion: CONVERSATION_UI_WORKSPACE_SCHEMA_VERSION, + conversationId: input.conversationId, + visibleThreadIds: visible, + foldedThreadIds: folded, + selectedThreadId: selected, + columnPolicy: + value.columnPolicy === "fold" || value.columnPolicy === "replace" + ? value.columnPolicy + : fallback.columnPolicy, + viewMode: value.viewMode === "canvas" ? "canvas" : "columns", + canvasViewport: + value.canvasViewport && + Number.isFinite(value.canvasViewport.x) && + Number.isFinite(value.canvasViewport.y) && + Number.isFinite(value.canvasViewport.zoom) + ? value.canvasViewport + : fallback.canvasViewport, + openPanels: Array.isArray(value.openPanels) + ? value.openPanels.filter( + (panel): panel is string => typeof panel === "string" + ) + : [], + draftsByThreadId: + value.draftsByThreadId && typeof value.draftsByThreadId === "object" + ? Object.fromEntries( + Object.entries(value.draftsByThreadId).filter( + ([targetThreadId, draft]) => + active.has(targetThreadId as ThreadId) && + typeof draft === "string" + ) + ) + : {}, + localHints: + value.localHints && typeof value.localHints === "object" + ? Object.fromEntries( + Object.entries(value.localHints).filter( + ([, shown]) => typeof shown === "boolean" + ) + ) + : {}, + } as ConversationUiWorkspace) + } catch { + return fallback + } } diff --git a/lib/thread-chat/client/use-normalized-conversation.ts b/lib/thread-chat/client/use-normalized-conversation.ts new file mode 100644 index 00000000..9acdec80 --- /dev/null +++ b/lib/thread-chat/client/use-normalized-conversation.ts @@ -0,0 +1,29 @@ +"use client" + +import { useSyncExternalStore } from "react" + +import type { + CanonicalSubscriptionKey, + NormalizedConversationStore, +} from "./normalized-conversation-store" + +export function useCanonicalSubscription( + store: NormalizedConversationStore, + key: CanonicalSubscriptionKey +): number { + return useSyncExternalStore( + (notify) => store.subscribe(key, notify), + () => store.snapshotForKey(key), + () => 0 + ) +} + +export function useCanonicalSelector( + store: NormalizedConversationStore, + key: CanonicalSubscriptionKey, + selector: () => T +): T { + useCanonicalSubscription(store, key) + // selector 的稳定缓存由 selector 层自身维护;key 更新只负责触发本组件重新读取。 + return selector() +} diff --git a/lib/thread-chat/contracts/conversation-message-feedback.ts b/lib/thread-chat/contracts/conversation-message-feedback.ts new file mode 100644 index 00000000..2ea9cea0 --- /dev/null +++ b/lib/thread-chat/contracts/conversation-message-feedback.ts @@ -0,0 +1,24 @@ +import { z } from "zod" + +import { messageFeedbackSchema } from "./message-feedback" + +export const canonicalMessageFeedbackSchema = z.object({ + conversationId: z.string().min(1), + threadId: z.string().min(1), + messageId: z.string().min(1), + feedback: messageFeedbackSchema, + updatedAt: z.string().min(1), +}) +export const canonicalMessageFeedbackListSchema = z.object({ + feedback: z.array(canonicalMessageFeedbackSchema), +}) +export const setCanonicalMessageFeedbackRequestSchema = z.object({ + threadId: z.string().min(1), + feedback: messageFeedbackSchema.nullable(), +}) +export const setCanonicalMessageFeedbackResponseSchema = z.object({ + feedback: canonicalMessageFeedbackSchema.nullable(), +}) +export type CanonicalMessageFeedback = z.infer< + typeof canonicalMessageFeedbackSchema +> diff --git a/lib/thread-chat/generation/canonical-ai-generation-executor.test.ts b/lib/thread-chat/generation/canonical-ai-generation-executor.test.ts new file mode 100644 index 00000000..16519ce2 --- /dev/null +++ b/lib/thread-chat/generation/canonical-ai-generation-executor.test.ts @@ -0,0 +1,173 @@ +import assert from "node:assert/strict" +import test from "node:test" + +import type { ConversationSnapshotResult } from "../application/conversation-command-contracts.ts" +import { + conversationId, + messageId, + projectId, + threadId, + turnId, + workspaceId, + type ConversationMessage, +} from "../domain/conversation-model.ts" +import { compileCanonicalGenerationMessages } from "./canonical-ai-generation-executor.ts" + +function message(input: { + id: ReturnType + thread: ReturnType + turn: ReturnType + role: "user" | "assistant" + text: string +}): ConversationMessage { + return { + id: input.id, + threadId: input.thread, + turnId: input.turn, + role: input.role, + content: { + schemaVersion: 1, + parts: [{ type: "text", text: input.text }], + }, + contentState: "complete", + createdAt: "2026-08-22T00:00:00.000Z", + } +} + +test("较早 Turn 重新生成保留继承上下文并排除当前 Thread 后续 Turn", () => { + const conversation = conversationId("conversation-compiler") + const root = threadId("thread-compiler-root") + const child = threadId("thread-compiler-child") + const inheritedTurn = turnId("turn-inherited") + const targetTurn = turnId("turn-target") + const laterTurn = turnId("turn-later") + const inherited = messageId("message-inherited") + const targetInput = messageId("message-target-input") + const targetOutput = messageId("message-target-output") + const laterInput = messageId("message-later-input") + const laterOutput = messageId("message-later-output") + const loaded: ConversationSnapshotResult = { + snapshot: { + schemaVersion: 1, + project: { + id: projectId("project-compiler"), + workspaceId: workspaceId("workspace-compiler"), + title: "编译测试", + revision: 0, + lifecycle: "active", + }, + conversation: { + id: conversation, + projectId: projectId("project-compiler"), + rootThreadId: root, + autoTitle: null, + customTitle: null, + revision: 0, + lifecycle: "active", + }, + threads: { + [root]: { + id: root, + conversationId: conversation, + modelId: "glm-5.3", + localTitle: null, + revision: 0, + lifecycle: "active", + }, + [child]: { + id: child, + conversationId: conversation, + modelId: "glm-5.3", + localTitle: null, + revision: 0, + lifecycle: "active", + }, + }, + threadForks: {}, + turns: { + [inheritedTurn]: { + id: inheritedTurn, + threadId: root, + position: 0, + activeUserMessageId: inherited, + activeAssistantMessageId: inherited, + revision: 0, + }, + [targetTurn]: { + id: targetTurn, + threadId: child, + position: 0, + activeUserMessageId: targetInput, + activeAssistantMessageId: targetOutput, + revision: 0, + }, + [laterTurn]: { + id: laterTurn, + threadId: child, + position: 1, + activeUserMessageId: laterInput, + activeAssistantMessageId: laterOutput, + revision: 0, + }, + }, + messages: { + [inherited]: message({ + id: inherited, + thread: root, + turn: inheritedTurn, + role: "user", + text: "继承消息", + }), + [targetInput]: message({ + id: targetInput, + thread: child, + turn: targetTurn, + role: "user", + text: "目标输入", + }), + [targetOutput]: message({ + id: targetOutput, + thread: child, + turn: targetTurn, + role: "assistant", + text: "旧目标输出", + }), + [laterInput]: message({ + id: laterInput, + thread: child, + turn: laterTurn, + role: "user", + text: "后续输入", + }), + [laterOutput]: message({ + id: laterOutput, + thread: child, + turn: laterTurn, + role: "assistant", + text: "后续输出", + }), + }, + generations: {}, + artifactProvenance: {}, + }, + generations: [], + contextMessageIdsByThread: { + [child]: [inherited, targetInput, targetOutput, laterInput, laterOutput], + }, + } + + assert.deepEqual( + compileCanonicalGenerationMessages({ + loaded, + generation: { + threadId: child, + turnId: targetTurn, + outputMessageId: targetOutput, + }, + }), + [ + { role: "user", content: "继承消息" }, + { role: "user", content: "目标输入" }, + ] + ) +}) diff --git a/lib/thread-chat/generation/canonical-ai-generation-executor.ts b/lib/thread-chat/generation/canonical-ai-generation-executor.ts new file mode 100644 index 00000000..fa25bf33 --- /dev/null +++ b/lib/thread-chat/generation/canonical-ai-generation-executor.ts @@ -0,0 +1,116 @@ +import { streamText } from "ai" + +import { MAX_OUTPUT_TOKENS } from "@/constants/model" +import { resolveChatModel } from "@/lib/ai/provider" +import { buildThreadChatSystem } from "@/lib/chat/thread-chat-prompt" + +import type { ConversationQueryPort } from "../application/conversation-command-service" +import type { ConversationSnapshotResult } from "../application/conversation-command-contracts" +import type { + CanonicalGenerationExecutor, + CanonicalGenerationRecord, +} from "../application/conversation-generation-service" +import { + emptyConversationGenerationCheckpoint, + type KnownGenerationUsage, +} from "../domain/conversation-generation" + +function messageText( + parts: readonly { readonly type: string; readonly text?: string }[] +) { + return parts + .filter((part) => part.type === "text" && typeof part.text === "string") + .map((part) => part.text) + .join("") +} + +/** + * 把规范读取投影编译成一次模型调用的消息序列。 + * 重新生成较早 Turn 时,必须保留继承上下文,但排除同一 Thread 的后续 Turn。 + */ +export function compileCanonicalGenerationMessages(input: { + readonly loaded: ConversationSnapshotResult + readonly generation: Pick< + CanonicalGenerationRecord, + "threadId" | "turnId" | "outputMessageId" + > +}) { + const ids = + input.loaded.contextMessageIdsByThread[input.generation.threadId] ?? [] + const targetTurn = input.loaded.snapshot.turns[input.generation.turnId] + if (!targetTurn) throw new Error("Generation 目标 Turn 不存在") + return ids.flatMap((id) => { + if (id === input.generation.outputMessageId) return [] + const message = input.loaded.snapshot.messages[id] + if (!message || message.role === "context") return [] + const turn = input.loaded.snapshot.turns[message.turnId] + if ( + message.threadId === input.generation.threadId && + turn && + turn.position > targetTurn.position + ) + return [] + const content = messageText(message.content.parts) + return content.trim() ? [{ role: message.role, content } as const] : [] + }) +} + +/** 公开 canonical 组合根使用的模型 executor;上下文只从服务端规范快照编译。 */ +export class CanonicalAiGenerationExecutor implements CanonicalGenerationExecutor { + constructor(private readonly queries: ConversationQueryPort) {} + + async execute(input: Parameters[0]) { + const loaded = await this.queries.getConversationSnapshot({ + actorUserId: input.generation.ownerId, + conversationId: input.generation.conversationId, + }) + if (!loaded) throw new Error("Conversation 在 Generation 执行前不可读取") + const messages = compileCanonicalGenerationMessages({ + loaded, + generation: input.generation, + }) + const incomingFork = Object.values(loaded.snapshot.threadForks).find( + (fork) => fork.childThreadId === input.generation.threadId + ) + const model = resolveChatModel(input.generation.modelId) + const result = streamText({ + model, + system: buildThreadChatSystem(incomingFork?.anchor?.quote.exact ?? null), + messages, + abortSignal: input.signal, + maxOutputTokens: MAX_OUTPUT_TOKENS, + }) + let body = "" + for await (const delta of result.textStream) { + body += delta + await input.onCheckpoint({ + ...emptyConversationGenerationCheckpoint(), + body, + contentState: "streaming", + }) + } + const usage = await result.totalUsage + const knownUsage: KnownGenerationUsage | null = + usage.inputTokens !== undefined && usage.outputTokens !== undefined + ? { + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + paidStepCount: 1, + reportedStepCount: 1, + } + : null + return { + outcome: "completed" as const, + checkpoint: { + ...emptyConversationGenerationCheckpoint(), + body, + contentState: "complete" as const, + knownUsage, + }, + usageCompleteness: knownUsage + ? ("complete" as const) + : ("unavailable" as const), + knownUsage, + } + } +} diff --git a/lib/thread-chat/http/conversation-command-composition.ts b/lib/thread-chat/http/conversation-command-composition.ts index 14ecb2c1..3e2c3644 100644 --- a/lib/thread-chat/http/conversation-command-composition.ts +++ b/lib/thread-chat/http/conversation-command-composition.ts @@ -7,6 +7,8 @@ import type { import { ConversationCommandApplicationService } from "../application/conversation-command-service" import { generationId, type GenerationId } from "../domain/conversation-model" import { InMemoryGenerationAbortRegistry } from "../generation/canonical-generation-execution" +import { CanonicalGenerationApplicationService } from "../generation/canonical-generation-execution" +import { CanonicalAiGenerationExecutor } from "../generation/canonical-ai-generation-executor" import { DrizzleConversationCommandStore } from "../persistence/drizzle-conversation-command-store" import { DrizzleConversationGenerationRepository } from "../persistence/drizzle-conversation-generation-repository" import { DrizzleConversationOutboxDispatcher } from "../persistence/drizzle-conversation-outbox-dispatcher" @@ -23,16 +25,6 @@ declare global { var __conversationOutboxConsumerOverride: OutboxEventConsumer | undefined } -const defaultConsumer: OutboxEventConsumer = { - async consume(event) { - if (event.type === "GenerationRequested") - throw new Error( - "规范 Generation executor 尚未接入公开组合根;事件保留待重试" - ) - // 审计/投影事件在没有外部消费者时可视为已处理;实体事务不依赖它。 - }, -} - function createComposition() { const policy = resolveConversationCommandApiPolicy() const store = new DrizzleConversationCommandStore(policy) @@ -42,6 +34,34 @@ function createComposition() { process.env.BRANCH_GENERATION_AUTHORITY_ENABLED !== "false", }) const abortRegistry = new InMemoryGenerationAbortRegistry() + const execution = new CanonicalGenerationApplicationService( + generations, + new CanonicalAiGenerationExecutor(store), + abortRegistry + ) + const defaultConsumer: OutboxEventConsumer = { + async consume(event) { + if (event.type !== "GenerationRequested") return + const payload = event.payload as { + generationId?: unknown + ownerId?: unknown + leaseOwner?: unknown + } + if ( + typeof payload.generationId !== "string" || + typeof payload.ownerId !== "string" || + typeof payload.leaseOwner !== "string" + ) + throw new Error("GenerationRequested payload 无效") + const generation = await generations.getGeneration({ + ownerId: payload.ownerId, + generationId: generationId(payload.generationId), + }) + if (!generation) + throw new Error("GenerationRequested 指向不存在的 Generation") + await execution.executeExisting(generation, payload.leaseOwner) + }, + } const dispatcher = new DrizzleConversationOutboxDispatcher( globalThis.__conversationOutboxConsumerOverride ?? defaultConsumer, `http:${randomUUID()}` diff --git a/lib/thread-chat/persistence/canonical-message-feedback-repository.ts b/lib/thread-chat/persistence/canonical-message-feedback-repository.ts new file mode 100644 index 00000000..9b1144e0 --- /dev/null +++ b/lib/thread-chat/persistence/canonical-message-feedback-repository.ts @@ -0,0 +1,147 @@ +import { and, eq } from "drizzle-orm" + +import { db } from "@/lib/db" +import { + conversationGenerations, + conversationMessageFeedback, + conversationMessages, + conversationThreads, + conversations, + projects, + workspaceMembers, +} from "@/lib/db/schema" +import type { MessageFeedback } from "@/lib/thread-chat/contracts/message-feedback" +import type { CanonicalMessageFeedback } from "@/lib/thread-chat/contracts/conversation-message-feedback" + +export async function listCanonicalMessageFeedback(input: { + userId: string + conversationId: string +}): Promise { + const rows = await db + .select({ feedback: conversationMessageFeedback }) + .from(conversationMessageFeedback) + .innerJoin( + conversations, + eq(conversations.id, conversationMessageFeedback.conversationId) + ) + .innerJoin(projects, eq(projects.id, conversations.projectId)) + .innerJoin( + workspaceMembers, + and( + eq(workspaceMembers.workspaceId, projects.workspaceId), + eq(workspaceMembers.userId, input.userId) + ) + ) + .where( + and( + eq(conversationMessageFeedback.userId, input.userId), + eq(conversationMessageFeedback.conversationId, input.conversationId) + ) + ) + return rows.map(({ feedback }) => ({ + conversationId: feedback.conversationId, + threadId: feedback.threadId, + messageId: feedback.messageId, + feedback: feedback.feedback, + updatedAt: feedback.updatedAt.toISOString(), + })) +} + +export async function setCanonicalMessageFeedback(input: { + userId: string + conversationId: string + threadId: string + messageId: string + feedback: MessageFeedback | null +}): Promise< + | { ok: true; feedback: CanonicalMessageFeedback | null } + | { ok: false; reason: "not_found" | "not_completed" | "missing_generation" } +> { + return db.transaction(async (tx) => { + const [message] = await tx + .select({ + role: conversationMessages.role, + contentState: conversationMessages.contentState, + }) + .from(conversationMessages) + .innerJoin( + conversationThreads, + and( + eq(conversationThreads.id, conversationMessages.threadId), + eq(conversationThreads.conversationId, input.conversationId) + ) + ) + .innerJoin( + conversations, + eq(conversations.id, conversationThreads.conversationId) + ) + .innerJoin(projects, eq(projects.id, conversations.projectId)) + .innerJoin( + workspaceMembers, + and( + eq(workspaceMembers.workspaceId, projects.workspaceId), + eq(workspaceMembers.userId, input.userId) + ) + ) + .where( + and( + eq(conversationMessages.id, input.messageId), + eq(conversationMessages.threadId, input.threadId) + ) + ) + .limit(1) + if (!message) return { ok: false, reason: "not_found" as const } + if (message.role !== "assistant" || message.contentState !== "complete") + return { ok: false, reason: "not_completed" as const } + const [generation] = await tx + .select({ id: conversationGenerations.id }) + .from(conversationGenerations) + .where( + and( + eq(conversationGenerations.ownerId, input.userId), + eq(conversationGenerations.conversationId, input.conversationId), + eq(conversationGenerations.threadId, input.threadId), + eq(conversationGenerations.outputMessageId, input.messageId), + eq(conversationGenerations.status, "completed") + ) + ) + .limit(1) + if (!generation) return { ok: false, reason: "missing_generation" as const } + const identity = and( + eq(conversationMessageFeedback.userId, input.userId), + eq(conversationMessageFeedback.conversationId, input.conversationId), + eq(conversationMessageFeedback.messageId, input.messageId) + ) + if (input.feedback === null) { + await tx.delete(conversationMessageFeedback).where(identity) + return { ok: true, feedback: null } + } + const now = new Date() + const [saved] = await tx + .insert(conversationMessageFeedback) + .values({ ...input, feedback: input.feedback, updatedAt: now }) + .onConflictDoUpdate({ + target: [ + conversationMessageFeedback.userId, + conversationMessageFeedback.conversationId, + conversationMessageFeedback.messageId, + ], + set: { + feedback: input.feedback, + threadId: input.threadId, + updatedAt: now, + }, + }) + .returning() + return { + ok: true, + feedback: { + conversationId: saved!.conversationId, + threadId: saved!.threadId, + messageId: saved!.messageId, + feedback: saved!.feedback, + updatedAt: saved!.updatedAt.toISOString(), + }, + } + }) +} diff --git a/lib/thread-chat/persistence/drizzle-conversation-command-store.ts b/lib/thread-chat/persistence/drizzle-conversation-command-store.ts index 7271c2f1..e1fbcf9c 100644 --- a/lib/thread-chat/persistence/drizzle-conversation-command-store.ts +++ b/lib/thread-chat/persistence/drizzle-conversation-command-store.ts @@ -496,6 +496,7 @@ export class DrizzleConversationCommandStore turns: mappedTurns, messages: mappedMessages, forks: mappedForks, + generations: mappedGenerations, }), } } @@ -700,7 +701,10 @@ export class DrizzleConversationCommandStore .where(eq(conversations.id, access.conversation.id)) return { data: asJson({ conversationId: access.conversation.id, deleted: true }), - revisions: {}, + // 删除响应仍携带 tombstone revision,客户端才能拒绝迟到的旧实体 delta。 + revisions: { + [access.conversation.id]: access.conversation.revision + 1, + }, delta: { upsert: {}, remove: { conversations: [conversationId(access.conversation.id)] }, @@ -1780,6 +1784,7 @@ function deriveContextMessageIds(input: { readonly turns: readonly ConversationTurn[] readonly messages: readonly ConversationMessage[] readonly forks: readonly ThreadFork[] + readonly generations: readonly ConversationGeneration[] }): Readonly> { const forkByChild = new Map( input.forks.map((fork) => [fork.childThreadId, fork]) @@ -1800,10 +1805,43 @@ function deriveContextMessageIds(input: { turn.activeUserMessageId, turn.activeAssistantMessageId, ]) - const memo = new Map() + const messagesById = new Map( + input.messages.map((message) => [message.id, message]) + ) + const turnsById = new Map(input.turns.map((turn) => [turn.id, turn])) + const generationByOutput = new Map( + input.generations.map((generation) => [ + generation.outputMessageId, + generation, + ]) + ) + const localThroughSource = ( + targetThreadId: ThreadId, + sourceMessageId: MessageId + ): MessageId[] => { + const source = messagesById.get(sourceMessageId) + const sourceTurn = source ? turnsById.get(source.turnId) : undefined + if (!source || !sourceTurn || source.threadId !== targetThreadId) + throw new ConversationCommandError( + "internal", + "ThreadFork 来源 Message 无法解析" + ) + const before = (turnsByThread.get(targetThreadId) ?? []) + .filter((turn) => turn.position < sourceTurn.position) + .flatMap((turn) => [ + turn.activeUserMessageId, + turn.activeAssistantMessageId, + ]) + if (source.role === "user") return [...before, source.id] + const inputMessageId = + generationByOutput.get(source.id)?.inputMessageId ?? + sourceTurn.activeUserMessageId + return [...before, inputMessageId, source.id] + } + const inheritedMemo = new Map() const visiting = new Set() - const derive = (targetThreadId: ThreadId): MessageId[] => { - const cached = memo.get(targetThreadId) + const inherited = (targetThreadId: ThreadId): MessageId[] => { + const cached = inheritedMemo.get(targetThreadId) if (cached) return cached if (visiting.has(targetThreadId)) throw new ConversationCommandError( @@ -1812,23 +1850,20 @@ function deriveContextMessageIds(input: { ) visiting.add(targetThreadId) const fork = forkByChild.get(targetThreadId) - let inherited: MessageId[] = [] - if (fork) { - const parent = derive(fork.parentThreadId) - const sourceIndex = parent.indexOf(fork.sourceMessageId) - if (sourceIndex < 0) - throw new ConversationCommandError( - "internal", - "ThreadFork 来源不在父 Thread 当前上下文投影" - ) - inherited = parent.slice(0, sourceIndex + 1) - } - const value = [...inherited, ...local(targetThreadId)] + const value = fork + ? [ + ...inherited(fork.parentThreadId), + ...localThroughSource(fork.parentThreadId, fork.sourceMessageId), + ] + : [] visiting.delete(targetThreadId) - memo.set(targetThreadId, value) + inheritedMemo.set(targetThreadId, value) return value } return Object.fromEntries( - input.threads.map((thread) => [thread.id, derive(thread.id)]) + input.threads.map((thread) => [ + thread.id, + [...inherited(thread.id), ...local(thread.id)], + ]) ) } diff --git a/openspec/changes/normalize-conversation-client-state/tasks.md b/openspec/changes/normalize-conversation-client-state/tasks.md index be12fea3..d125f5f0 100644 --- a/openspec/changes/normalize-conversation-client-state/tasks.md +++ b/openspec/changes/normalize-conversation-client-state/tasks.md @@ -1,48 +1,63 @@ ## 1. 规范化 Store 基础 -- [ ] 1.1 定义只读 Conversation/Thread/ThreadFork/Turn/Message/Generation ID maps、revision 和 checkpoint version 状态。 -- [ ] 1.2 实现 ConversationSnapshot 临时解析、归属/引用校验和单次原子安装,失败时不部分覆盖旧状态。 -- [ ] 1.3 实现带 tombstone、schemaVersion 和每实体版本比较的 CanonicalDelta 合并器。 -- [ ] 1.4 增加开发模式实体冻结、重复/旧 delta 幂等、版本间隙重取和未知 schema 拒绝测试。 +- [x] 1.1 定义只读 Conversation/Thread/ThreadFork/Turn/Message/Generation ID maps、revision 和 checkpoint version 状态。 +- [x] 1.2 实现 ConversationSnapshot 临时解析、归属/引用校验和单次原子安装,失败时不部分覆盖旧状态。 +- [x] 1.3 实现带 tombstone、schemaVersion 和每实体版本比较的 CanonicalDelta 合并器。 +- [x] 1.4 增加开发模式实体冻结、重复/旧 delta 幂等、版本间隙重取和未知 schema 拒绝测试。 ## 2. 派生索引与细粒度订阅 -- [ ] 2.1 实现 Conversation → Threads、ThreadFork parent/children、Thread → Turns、Turn → Messages 的可重建索引。 -- [ ] 2.2 实现根 Thread、嵌套深度、Fork 数量、继承上下文、当前有效路径和画布边 selector。 -- [ ] 2.3 将 Store 通知拆为实体/索引/UI key,并提供按 ID 定位的 React hooks 与稳定 memoized selector。 -- [ ] 2.4 验证清空派生缓存可重建等价结果,以及一个 Message 流式更新不会通知无关 Thread 订阅。 +- [x] 2.1 实现 Conversation → Threads、ThreadFork parent/children、Thread → Turns、Turn → Messages 的可重建索引。 +- [x] 2.2 实现根 Thread、嵌套深度、Fork 数量、继承上下文、当前有效路径和画布边 selector。 +- [x] 2.3 将 Store 通知拆为实体/索引/UI key,并提供按 ID 定位的 React hooks 与稳定 memoized selector。 +- [x] 2.4 验证清空派生缓存可重建等价结果,以及一个 Message 流式更新不会通知无关 Thread 订阅。 ## 3. UI Workspace 与乐观状态 -- [ ] 3.1 将 visible/folded/selected Thread、列策略、画布 viewport、面板、草稿和本地提示迁入独立 UI Workspace slice。 -- [ ] 3.2 为 UI localStorage payload 增加 schemaVersion/Conversation ID,并在加载时过滤归档或缺失 Thread。 -- [ ] 3.3 实现按 commandId 管理的 pending overlay、草稿恢复、确认中、成功替换和失败回滚。 -- [ ] 3.4 验证折叠/画布/面板操作不改变 canonical revision 或发出领域命令。 +- [x] 3.1 将 visible/folded/selected Thread、列策略、画布 viewport、面板、草稿和本地提示迁入独立 UI Workspace slice。 +- [x] 3.2 为 UI localStorage payload 增加 schemaVersion/Conversation ID,并在加载时过滤归档或缺失 Thread。 +- [x] 3.3 实现按 commandId 管理的 pending overlay、草稿恢复、确认中、成功替换和失败回滚。 +- [x] 3.4 验证折叠/画布/面板操作不改变 canonical revision 或发出领域命令。 ## 4. Client gateway 与 GenerationCoordinator -- [ ] 4.1 实现统一 client gateway,附加 Idempotency-Key、最小预期 revision并解析 snapshot/delta/稳定错误。 -- [ ] 4.2 实现网络不确定时复用幂等键、409 revision 恢复和组件卸载后共享结果合并。 -- [ ] 4.3 实现按 Generation ID 引用计数的 stream/poll 协调器、可见/隐藏频率、终态停止和 checkpoint 乱序保护。 -- [ ] 4.4 覆盖多视图共享一个监控、最后订阅释放、重新挂载恢复以及卸载不发送 Stop 的测试。 +- [x] 4.1 实现统一 client gateway,附加 Idempotency-Key、最小预期 revision并解析 snapshot/delta/稳定错误。 +- [x] 4.2 实现网络不确定时复用幂等键、409 revision 恢复和组件卸载后共享结果合并。 +- [x] 4.3 实现按 Generation ID 引用计数的 stream/poll 协调器、可见/隐藏频率、终态停止和 checkpoint 乱序保护。 +- [x] 4.4 覆盖多视图共享一个监控、最后订阅释放、重新挂载恢复以及卸载不发送 Stop 的测试。 ## 5. 只读界面切片迁移 -- [ ] 5.1 迁移 boot、Conversation 列表/标题和根 Thread 识别,不再使用魔法 `main` ID。 -- [ ] 5.2 迁移列、Thread switcher、tree list、breadcrumb、selection anchor 和嵌套 Fork 展示到 ThreadFork selectors。 -- [ ] 5.3 迁移 Message/Markdown/研究活动/Artifact/反馈展示到稳定 ID selectors。 -- [ ] 5.4 迁移 React Flow 画布节点/边和聚焦行为到可重建投影,并验证懒加载不复制 canonical 实体。 +- [x] 5.1 迁移 boot、Conversation 列表/标题和根 Thread 识别,不再使用魔法 `main` ID。 +- [x] 5.2 迁移列、Thread switcher、tree list、breadcrumb、selection anchor 和嵌套 Fork 展示到 ThreadFork selectors。 +- [x] 5.3 迁移 Message/Markdown/研究活动/Artifact/反馈展示到稳定 ID selectors。 +- [x] 5.4 迁移 React Flow 画布节点/边和聚焦行为到可重建投影,并验证懒加载不复制 canonical 实体。 ## 6. 写交互切片迁移 -- [ ] 6.1 迁移 composer/send/stop 到 client gateway 和 pending overlay,删除规范路径的 `persistNow` 前置屏障。 -- [ ] 6.2 迁移重新生成、用户输入编辑和变体选择到 Turn/Message 命令与 revision。 -- [ ] 6.3 迁移 selection Fork 到单个 `forkThread` 命令,不再本地写 parent/children/Message forks。 -- [ ] 6.4 迁移复制、反馈、Artifact 定位和其余 Message actions,全部使用稳定 Message/Turn/Thread ID。 +- [x] 6.1 迁移 composer/send/stop 到 client gateway 和 pending overlay,删除规范路径的 `persistNow` 前置屏障。 +- [x] 6.2 迁移重新生成、用户输入编辑和变体选择到 Turn/Message 命令与 revision。 +- [x] 6.3 迁移 selection Fork 到单个 `forkThread` 命令,不再本地写 parent/children/Message forks。 +- [x] 6.4 迁移复制、反馈、Artifact 定位和其余 Message actions,全部使用稳定 Message/Turn/Thread ID。 ## 7. 互斥模式与行为验证 -- [ ] 7.1 增加 Conversation 页面 legacy/canonical 互斥 boot 开关,canonical 模式不实例化旧 Tree Store、整树 persistence 或 reconcile。 -- [ ] 7.2 覆盖发送、流式、Stop partial、刷新恢复、重新生成、选择变体、A → B → C Fork、归档/恢复和标题行为矩阵。 -- [ ] 7.3 增加 selector 通知计数与关键渲染性能验证,确认无关列不会随全局树 version 重渲染。 -- [ ] 7.4 运行相关单元/组件/端到端测试、`pnpm typecheck` 与 `pnpm openspec:validate` 并记录结果。 +- [x] 7.1 增加 Conversation 页面 legacy/canonical 互斥 boot 开关,canonical 模式不实例化旧 Tree Store、整树 persistence 或 reconcile。 +- [x] 7.2 覆盖发送、流式、Stop partial、刷新恢复、重新生成、选择变体、A → B → C Fork、归档/恢复和标题行为矩阵。 +- [x] 7.3 增加 selector 通知计数与关键渲染性能验证,确认无关列不会随全局树 version 重渲染。 +- [x] 7.4 运行相关单元/组件/端到端测试、`pnpm typecheck` 与 `pnpm openspec:validate` 并记录结果。 + +## 8. 2026-08-22 验收记录 + +- `pnpm build`:Next.js 16.3.1 生产构建通过,新增 canonical 页面和反馈 API 路由均完成编译与静态检查。 +- `pnpm typecheck`:通过。 +- `pnpm lint`:0 error;保留 4 条与本 change 无关的既有 warning。 +- `pnpm openspec:validate`:严格模式 31 项全部通过。 +- `pnpm test:conversation-domain`:8/8 通过。 +- `pnpm test:conversation-generation-unit`:7/7 通过;覆盖较早 Turn 重新生成时排除当前 Thread 后续消息。 +- `pnpm test:conversation-client`:13/13 通过;覆盖原子快照、A → B → C、历史 Fork 来源、细粒度通知、幂等/冲突、GenerationCoordinator、稳定反馈 ID、Markdown/研究活动/Artifact 来源。 +- `pnpm test:conversation-command-contract`:4/4 通过。 +- `pnpm test:conversation-generation`:52 项数据库断言通过;模型标识为 `glm-5.3`,覆盖浏览器断线后继续执行、Stop partial、持久化 `incomplete` 正文与研究活动、计费 exactly-once。 +- `pnpm test:conversation-persistence`:26 项数据库断言通过;`root → A → B → C` 快照稳定且无整树写端口。 +- `pnpm test:conversation-command-api`:69 项数据库断言通过;覆盖 outbox exactly-once、命令并发、嵌套 Fork、反馈权限/完成态/Generation 关联/幂等更新与删除。 +- Ego Browser 任务空间 79:真实 GLM 5.3 发送、流式完成和刷新恢复;重新生成与变体选择;A → B → C 三列;3 节点/2 边画布;Conversation 与 Thread 标题、归档/恢复;复制状态;按稳定 Message ID 点赞并经 API 回读、刷新恢复;页面无错误提示。 diff --git a/package.json b/package.json index 6369ea5e..1a86a456 100644 --- a/package.json +++ b/package.json @@ -14,10 +14,12 @@ "format": "prettier --write \"**/*.{ts,tsx}\"", "typecheck": "tsc --noEmit", "test:conversation-domain": "node --experimental-strip-types --test lib/thread-chat/domain/conversation-model.test.ts", - "test:conversation-generation-unit": "tsx --test lib/thread-chat/domain/conversation-generation.test.ts lib/thread-chat/generation/canonical-generation-execution.test.ts", + "test:conversation-generation-unit": "tsx --test lib/thread-chat/domain/conversation-generation.test.ts lib/thread-chat/generation/canonical-generation-execution.test.ts lib/thread-chat/generation/canonical-ai-generation-executor.test.ts", "test:conversation-generation": "tsx scripts/test-conversation-generation.ts", "test:conversation-command-api": "tsx scripts/test-conversation-command-api.ts", "test:conversation-command-contract": "tsx --test lib/thread-chat/http/conversation-command-http.test.ts", + "test:conversation-client": "tsx --test lib/thread-chat/client/normalized-conversation-store.test.ts", + "seed:conversation-browser": "tsx scripts/seed-canonical-browser-fixture.ts", "test:conversation-persistence": "tsx scripts/test-conversation-persistence.ts", "test:legacy-conversation-audit": "node --experimental-strip-types --test lib/thread-chat/legacy/audit-thread-tree.test.ts", "audit:conversation-domain": "node scripts/audit-thread-tree-boundary.mjs", diff --git a/scripts/seed-canonical-browser-fixture.ts b/scripts/seed-canonical-browser-fixture.ts new file mode 100644 index 00000000..0d62b022 --- /dev/null +++ b/scripts/seed-canonical-browser-fixture.ts @@ -0,0 +1,93 @@ +import { config } from "dotenv" + +config({ path: ".env.local" }) +process.env.CONVERSATION_COMMAND_API_AUTHORITY = "isolated-test" + +const { eq } = await import("drizzle-orm") +const { db } = await import("../lib/db/index.ts") +const { projects, user, workspaceMembers, workspaces } = + await import("../lib/db/schema.ts") +const { ConversationCommandApplicationService } = + await import("../lib/thread-chat/application/conversation-command-service.ts") +const { conversationId, projectId, threadId, workspaceId } = + await import("../lib/thread-chat/domain/conversation-model.ts") +const { DrizzleConversationCommandStore } = + await import("../lib/thread-chat/persistence/drizzle-conversation-command-store.ts") +const { DrizzleConversationGenerationRepository } = + await import("../lib/thread-chat/persistence/drizzle-conversation-generation-repository.ts") + +const email = "codex.issue34.20260822@example.com" +const [owner] = await db + .select({ id: user.id }) + .from(user) + .where(eq(user.email, email)) + .limit(1) +if (!owner) throw new Error(`测试账号不存在:${email}`) + +const workspace = workspaceId("34343434-0000-4000-8000-000000000001") +const project = projectId("34343434-0000-4000-8000-000000000002") +const conversation = conversationId("34343434-0000-4000-8000-000000000003") +const root = threadId("34343434-0000-4000-8000-000000000004") +await db + .insert(workspaces) + .values({ id: workspace, revision: 0, lifecycle: "active" }) + .onConflictDoNothing() +await db + .insert(workspaceMembers) + .values({ workspaceId: workspace, userId: owner.id, role: "owner" }) + .onConflictDoNothing() +await db + .insert(projects) + .values({ + id: project, + workspaceId: workspace, + title: "Issue 34 Browser Fixture", + revision: 0, + lifecycle: "active", + }) + .onConflictDoNothing() + +const policy = { + authority: "isolated-test" as const, + legacyAuthorityEnabled: true, +} +const store = new DrizzleConversationCommandStore(policy) +const service = new ConversationCommandApplicationService( + store, + store, + new DrizzleConversationGenerationRepository({ + authority: "isolated-test", + legacyAuthorityEnabled: true, + }), + { + schedule() {}, + async dispatchPending() { + return 0 + }, + } +) +const existing = await service.getConversationSnapshot({ + actorUserId: owner.id, + conversationId: conversation, +}) +if (!existing) + await service.createConversation({ + commandId: "34343434-0000-4000-8000-000000000005", + actor: { kind: "user", userId: owner.id }, + scope: { type: "project", id: project }, + idempotencyKey: "issue-34-browser-fixture-create", + payload: { + conversationId: conversation, + rootThreadId: root, + title: "Issue 34 Canonical Client", + modelId: "glm-5.3", + }, + }) +console.log( + JSON.stringify({ + email, + conversationId: conversation, + rootThreadId: root, + url: `http://localhost:4040/thread-chat/${conversation}`, + }) +) diff --git a/scripts/test-conversation-command-api.ts b/scripts/test-conversation-command-api.ts index 6d22bba3..0be5a2b9 100644 --- a/scripts/test-conversation-command-api.ts +++ b/scripts/test-conversation-command-api.ts @@ -11,6 +11,7 @@ const { db } = await import("../lib/db/index.ts") const { conversationCommandRecords, conversationGenerations, + conversationMessageFeedback, conversationOutboxEvents, conversationThreads, user, @@ -47,6 +48,8 @@ const { DrizzleConversationRepository } = await import("../lib/thread-chat/persistence/drizzle-conversation-repository.ts") const { assertConversationCommandApiEnabled } = await import("../lib/thread-chat/persistence/conversation-command-policy.ts") +const { listCanonicalMessageFeedback, setCanonicalMessageFeedback } = + await import("../lib/thread-chat/persistence/canonical-message-feedback-repository.ts") const runId = randomUUID() const prefix = `command-api-test:${runId}` @@ -803,6 +806,86 @@ try { ) assertions += 6 + assert.deepEqual( + await listCanonicalMessageFeedback({ + userId: outsiderId, + conversationId: conversation, + }), + [] + ) + assert.deepEqual( + await setCanonicalMessageFeedback({ + userId: outsiderId, + conversationId: conversation, + threadId: threadA, + messageId: sendA.assistantMessageId, + feedback: "positive", + }), + { ok: false, reason: "not_found" } + ) + assert.deepEqual( + await setCanonicalMessageFeedback({ + userId: ownerId, + conversationId: conversation, + threadId: root, + messageId: rootSend.assistantMessageId, + feedback: "positive", + }), + { ok: false, reason: "not_completed" } + ) + const positive = await setCanonicalMessageFeedback({ + userId: ownerId, + conversationId: conversation, + threadId: threadA, + messageId: sendA.assistantMessageId, + feedback: "positive", + }) + assert.equal(positive.ok && positive.feedback?.feedback, "positive") + const negative = await setCanonicalMessageFeedback({ + userId: ownerId, + conversationId: conversation, + threadId: threadA, + messageId: sendA.assistantMessageId, + feedback: "negative", + }) + assert.equal(negative.ok && negative.feedback?.feedback, "negative") + const [feedbackCount] = await db + .select({ value: count(conversationMessageFeedback.messageId) }) + .from(conversationMessageFeedback) + .where(eq(conversationMessageFeedback.conversationId, conversation)) + assert.equal(feedbackCount?.value, 1) + assert.deepEqual( + await setCanonicalMessageFeedback({ + userId: ownerId, + conversationId: conversation, + threadId: threadA, + messageId: sendA.assistantMessageId, + feedback: null, + }), + { ok: true, feedback: null } + ) + assert.deepEqual( + await listCanonicalMessageFeedback({ + userId: ownerId, + conversationId: conversation, + }), + [] + ) + await db + .delete(conversationGenerations) + .where(eq(conversationGenerations.id, sendA.generationId)) + assert.deepEqual( + await setCanonicalMessageFeedback({ + userId: ownerId, + conversationId: conversation, + threadId: threadA, + messageId: sendA.assistantMessageId, + feedback: "positive", + }), + { ok: false, reason: "missing_generation" } + ) + assertions += 9 + const [commandCount] = await db .select({ value: count(conversationCommandRecords.id) }) .from(conversationCommandRecords) @@ -842,6 +925,7 @@ try { }) ) assert.deepEqual(deleted.delta.remove.conversations, [conversation]) + assert.equal(deleted.revisions[conversation], 7) assert.equal( await service.getConversationSnapshot({ actorUserId: ownerId, @@ -849,7 +933,7 @@ try { }), null ) - assertions += 5 + assertions += 6 console.log( JSON.stringify({ diff --git a/scripts/test-conversation-generation.ts b/scripts/test-conversation-generation.ts index 2afaca76..b8e61d7b 100644 --- a/scripts/test-conversation-generation.ts +++ b/scripts/test-conversation-generation.ts @@ -520,7 +520,26 @@ try { generationId: stopInput.id, }) assert.equal(repeatedStop?.status, "stopped") - assertions += 7 + const [persistedStoppedMessage] = await db + .select({ + content: conversationMessages.content, + contentState: conversationMessages.contentState, + }) + .from(conversationMessages) + .where(eq(conversationMessages.id, stopInput.outputMessage.id)) + assert.equal(persistedStoppedMessage?.contentState, "incomplete") + assert.ok( + persistedStoppedMessage?.content.parts.some( + (part) => part.type === "text" && part.text === "停止前已知内容" + ) + ) + assert.ok( + persistedStoppedMessage?.content.parts.some( + (part) => + part.type === "structured" && part.kind === "research-activities" + ) + ) + assertions += 10 // 无输出失败。 const failureService = new CanonicalGenerationApplicationService( From 393cc4f6946685e345cd8eb43228dc1678719b0a Mon Sep 17 00:00:00 2001 From: zilin <276161014@qq.com> Date: Sat, 22 Aug 2026 04:59:40 +0800 Subject: [PATCH 07/32] feat(cutover): enforce canonical conversation authority --- .env.example | 10 + .../[generationId]/route.ts | 6 + .../[generationId]/stop/route.ts | 7 + .../[treeId]/active-leaf/route.ts | 6 + .../messages/[messageId]/feedback/route.ts | 6 + app/api/branch-trees/[treeId]/route.ts | 40 +- app/api/branch-trees/route.ts | 6 + app/api/chat/route.ts | 6 + app/api/conversation-authority/route.ts | 11 + .../messages/[messageId]/feedback/route.ts | 2 + app/thread-chat/[treeId]/page.tsx | 11 +- .../canonical/canonical-thread-chat.tsx | 29 +- .../adr-issue-34-local-cutover-disposition.md | 33 + ...nversation-persistence-audit-2026-08-22.md | 22 +- docs/architecture/issue-34-cutover-runbook.md | 52 + drizzle/0012_needy_colonel_america.sql | 11 + drizzle/0013_jazzy_tiger_shark.sql | 16 + drizzle/meta/0012_snapshot.json | 3414 ++++++++++++++++ drizzle/meta/0013_snapshot.json | 3551 +++++++++++++++++ drizzle/meta/_journal.json | 14 + lib/db/schema.ts | 68 + lib/thread-chat-generation/tree-repository.ts | 21 + .../conversation-command-contracts.ts | 1 + .../client/conversation-client-contracts.ts | 9 + .../client/conversation-client-gateway.ts | 25 + .../normalized-conversation-store.test.ts | 39 + .../cutover/conversation-authority.test.ts | 106 + .../cutover/conversation-authority.ts | 112 + .../cutover/conversation-drain.test.ts | 34 + lib/thread-chat/cutover/conversation-drain.ts | 43 + .../legacy-conversation-import.test.ts | 183 + .../cutover/legacy-conversation-import.ts | 443 ++ .../http/conversation-command-composition.ts | 8 +- .../http/conversation-command-http.test.ts | 4 + .../http/conversation-command-http.ts | 12 + .../legacy/audit-thread-tree.test.ts | 13 +- lib/thread-chat/legacy/audit-thread-tree.ts | 34 + lib/thread-chat/legacy/project-thread-tree.ts | 4 +- .../canonical-generation-policy.ts | 21 +- .../canonical-persistence-policy.ts | 27 +- .../conversation-command-policy.ts | 21 +- .../drizzle-conversation-command-store.ts | 133 +- .../drizzle-conversation-repository.ts | 110 +- .../retire-thread-tree-authority/tasks.md | 42 +- package.json | 3 + scripts/audit-legacy-conversations.ts | 18 +- scripts/check-conversation-cutover-drain.ts | 47 + .../conversation-cutover-local-rollback.json | 17 + scripts/import-legacy-conversations.ts | 426 ++ scripts/seed-canonical-browser-fixture.ts | 4 +- scripts/test-conversation-command-api.ts | 4 +- scripts/test-conversation-generation.ts | 4 +- 52 files changed, 9101 insertions(+), 188 deletions(-) create mode 100644 app/api/conversation-authority/route.ts create mode 100644 docs/architecture/adr-issue-34-local-cutover-disposition.md create mode 100644 docs/architecture/issue-34-cutover-runbook.md create mode 100644 drizzle/0012_needy_colonel_america.sql create mode 100644 drizzle/0013_jazzy_tiger_shark.sql create mode 100644 drizzle/meta/0012_snapshot.json create mode 100644 drizzle/meta/0013_snapshot.json create mode 100644 lib/thread-chat/cutover/conversation-authority.test.ts create mode 100644 lib/thread-chat/cutover/conversation-authority.ts create mode 100644 lib/thread-chat/cutover/conversation-drain.test.ts create mode 100644 lib/thread-chat/cutover/conversation-drain.ts create mode 100644 lib/thread-chat/cutover/legacy-conversation-import.test.ts create mode 100644 lib/thread-chat/cutover/legacy-conversation-import.ts create mode 100644 scripts/check-conversation-cutover-drain.ts create mode 100644 scripts/fixtures/conversation-cutover-local-rollback.json create mode 100644 scripts/import-legacy-conversations.ts diff --git a/.env.example b/.env.example index bfdfee17..84f21929 100644 --- a/.env.example +++ b/.env.example @@ -36,6 +36,16 @@ DIRECT_URL=postgres://postgres.xxxx:password@aws-0-region.pooler.supabase.com:54 # DB_POOL_MAX=10 # DB_PREPARE=false +# === Issue #34 Conversation 单一权威与切换 === +# 必须显式选择且只能选择一个权威;禁止再拆成 client/API/generation 多个开关。 +CONVERSATION_AUTHORITY=legacy +# canonical 必填。每次正式切换使用新的不可变 epoch,客户端会在加载实体前与服务端核对。 +# CONVERSATION_CUTOVER_EPOCH=2026-08-22-production-v1 +# 受控迁移窗口设为 read-only:拒绝新写入,但允许读取和 Generation Stop/排空。 +# CONVERSATION_MAINTENANCE_MODE=off +# 仅本机/测试可用;生产环境设置 true 会启动失败。 +# CONVERSATION_ISOLATED_TEST=false + # === 用户系统(better-auth,邮箱注册登录) === # BETTER_AUTH_SECRET:任意高熵随机串(如 openssl rand -base64 32)。 # BETTER_AUTH_URL:站点对外地址,本地为 http://localhost:4040。 diff --git a/app/api/branch-generations/[generationId]/route.ts b/app/api/branch-generations/[generationId]/route.ts index a6a455f2..e0f7e372 100644 --- a/app/api/branch-generations/[generationId]/route.ts +++ b/app/api/branch-generations/[generationId]/route.ts @@ -2,6 +2,7 @@ import { getCurrentUserId } from "@/lib/auth/server" import { isValidTreeId } from "@/lib/chat/tree-id" import { failStaleGenerationForOwner } from "@/lib/thread-chat-generation/stale-generation-repository" import { toGenerationSummary } from "@/lib/thread-chat-generation/query-repository" +import { legacyProtocolGate } from "@/lib/thread-chat/cutover/conversation-authority" type RouteContext = { params: Promise<{ generationId: string }> } @@ -19,6 +20,11 @@ export async function GET(_req: Request, { params }: RouteContext) { { error: { code: "invalid_id", message: "generationId 必须是 UUID" } }, { status: 400 } ) + const gate = legacyProtocolGate({ + mutation: false, + protocol: "branch-generation-read", + }) + if (gate) return gate const generation = await failStaleGenerationForOwner(userId, generationId) if (!generation) diff --git a/app/api/branch-generations/[generationId]/stop/route.ts b/app/api/branch-generations/[generationId]/stop/route.ts index 385b63d6..b0192bae 100644 --- a/app/api/branch-generations/[generationId]/stop/route.ts +++ b/app/api/branch-generations/[generationId]/stop/route.ts @@ -3,6 +3,7 @@ import { isValidTreeId } from "@/lib/chat/tree-id" import { requestGenerationStop } from "@/lib/thread-chat-generation/execution-state-repository" import { toGenerationSummary } from "@/lib/thread-chat-generation/query-repository" import { abortGenerationLocally } from "@/lib/thread-chat-generation/execution" +import { legacyProtocolGate } from "@/lib/thread-chat/cutover/conversation-authority" type RouteContext = { params: Promise<{ generationId: string }> } @@ -20,6 +21,12 @@ export async function POST(_req: Request, { params }: RouteContext) { { error: { code: "invalid_id", message: "generationId 必须是 UUID" } }, { status: 400 } ) + // Stop 是维护窗口的排空动作:legacy authority 下继续允许;切换后仍稳定返回 410。 + const gate = legacyProtocolGate({ + mutation: false, + protocol: "branch-generation-stop", + }) + if (gate) return gate const generation = await requestGenerationStop(userId, generationId) if (!generation) diff --git a/app/api/branch-trees/[treeId]/active-leaf/route.ts b/app/api/branch-trees/[treeId]/active-leaf/route.ts index c7af8f3e..459e0f2f 100644 --- a/app/api/branch-trees/[treeId]/active-leaf/route.ts +++ b/app/api/branch-trees/[treeId]/active-leaf/route.ts @@ -12,6 +12,7 @@ import { switchActiveLeafForOwner, TreeCommandError, } from "@/lib/thread-chat-generation/tree-repository" +import { legacyProtocolGate } from "@/lib/thread-chat/cutover/conversation-authority" type RouteContext = { params: Promise<{ treeId: string }> } @@ -44,6 +45,11 @@ export async function PATCH(req: Request, { params }: RouteContext) { const error = SWITCH_ACTIVE_LEAF_ROUTE_ERRORS.invalid_id return activeLeafErrorResponse(error.code, error.message) } + const gate = legacyProtocolGate({ + mutation: true, + protocol: "branch-tree-active-leaf", + }) + if (gate) return gate const body = switchActiveLeafRequestSchema.safeParse( await req.json().catch(() => null) ) diff --git a/app/api/branch-trees/[treeId]/messages/[messageId]/feedback/route.ts b/app/api/branch-trees/[treeId]/messages/[messageId]/feedback/route.ts index e1fee337..7bbc641e 100644 --- a/app/api/branch-trees/[treeId]/messages/[messageId]/feedback/route.ts +++ b/app/api/branch-trees/[treeId]/messages/[messageId]/feedback/route.ts @@ -7,6 +7,7 @@ import { setMessageFeedbackSuccessResponseSchema, } from "@/lib/thread-chat/contracts/message-feedback" import { setMessageFeedbackForOwner } from "@/lib/thread-chat-generation/message-feedback-repository" +import { legacyProtocolGate } from "@/lib/thread-chat/cutover/conversation-authority" type RouteContext = { params: Promise<{ treeId: string; messageId: string }> @@ -27,6 +28,11 @@ export async function PUT(req: Request, { params }: RouteContext) { const { treeId, messageId } = await params if (!isValidTreeId(treeId) || messageId.trim() === "") return feedbackErrorResponse("invalid_id") + const gate = legacyProtocolGate({ + mutation: true, + protocol: "branch-message-feedback", + }) + if (gate) return gate const body = setMessageFeedbackRequestSchema.safeParse( await req.json().catch(() => null) diff --git a/app/api/branch-trees/[treeId]/route.ts b/app/api/branch-trees/[treeId]/route.ts index ec3fd4d5..c2548852 100644 --- a/app/api/branch-trees/[treeId]/route.ts +++ b/app/api/branch-trees/[treeId]/route.ts @@ -34,6 +34,7 @@ import { import { listMessageFeedbackForTree } from "@/lib/thread-chat-generation/message-feedback-repository" import { deleteOwnedTreeIfIdle, + loadOwnedTree, loadOwnedOrClaimLegacyTree, renameOwnedTree, saveOwnedTree, @@ -46,6 +47,10 @@ import { saveTreeSuccessResponseSchema, type SaveTreeErrorCode, } from "@/lib/thread-chat/contracts/save-tree" +import { + legacyProtocolGate, + resolveConversationAuthority, +} from "@/lib/thread-chat/cutover/conversation-authority" type RouteContext = { params: Promise<{ treeId: string }> } @@ -86,11 +91,22 @@ export async function GET(_req: Request, { params }: RouteContext) { const { treeId } = await params if (!isValidTreeId(treeId)) return new Response("treeId 必须是 UUID", { status: 400 }) + const gate = legacyProtocolGate({ + mutation: false, + protocol: "branch-tree-read", + }) + if (gate) return gate - const row = await loadOwnedOrClaimLegacyTree({ userId, treeId }) + const authority = resolveConversationAuthority() + const row = + authority.maintenanceMode === "read-only" + ? await loadOwnedTree({ userId, treeId }) + : await loadOwnedOrClaimLegacyTree({ userId, treeId }) if (!row) return notFound() - await failStaleGenerationsForTree(userId, treeId) + // read-only 维护窗口不能借 GET 清理 stale generation,从而偷偷制造新事实。 + if (authority.maintenanceMode === "off") + await failStaleGenerationsForTree(userId, treeId) const [generations, messageFeedbacks] = await Promise.all([ listCurrentGenerationsForTree(userId, treeId), listMessageFeedbackForTree(userId, treeId), @@ -105,10 +121,7 @@ export async function GET(_req: Request, { params }: RouteContext) { turnSnapshot: generation.turnSnapshot, })), }) - assertCompletedMessageGenerationLinks( - reconciled.state, - generationSummaries - ) + assertCompletedMessageGenerationLinks(reconciled.state, generationSummaries) } catch (error) { console.error("[thread-chat] 消息图读取协调失败", { treeId, error }) return Response.json( @@ -137,6 +150,11 @@ export async function PUT(req: Request, { params }: RouteContext) { const { treeId } = await params if (!isValidTreeId(treeId)) return new Response("treeId 必须是 UUID", { status: 400 }) + const gate = legacyProtocolGate({ + mutation: true, + protocol: "branch-tree-save", + }) + if (gate) return gate let body: { state?: unknown; title?: unknown; baseRevision?: unknown } try { @@ -201,6 +219,11 @@ export async function PATCH(req: Request, { params }: RouteContext) { const { treeId } = await params if (!isValidTreeId(treeId)) return new Response("treeId 必须是 UUID", { status: 400 }) + const gate = legacyProtocolGate({ + mutation: true, + protocol: "branch-tree-rename", + }) + if (gate) return gate let body: { title?: unknown } try { @@ -231,6 +254,11 @@ export async function DELETE(_req: Request, { params }: RouteContext) { const { treeId } = await params if (!isValidTreeId(treeId)) return new Response("treeId 必须是 UUID", { status: 400 }) + const gate = legacyProtocolGate({ + mutation: true, + protocol: "branch-tree-delete", + }) + if (gate) return gate const outcome = await deleteOwnedTreeIfIdle({ userId, treeId }) if (outcome === "generation_running") { diff --git a/app/api/branch-trees/route.ts b/app/api/branch-trees/route.ts index e794a955..0ba7953e 100644 --- a/app/api/branch-trees/route.ts +++ b/app/api/branch-trees/route.ts @@ -10,6 +10,7 @@ import { getCurrentUserId } from "@/lib/auth/server" import { listOwnedTreeSummaries } from "@/lib/thread-chat-generation/tree-repository" +import { legacyProtocolGate } from "@/lib/thread-chat/cutover/conversation-authority" export async function GET() { const userId = await getCurrentUserId() @@ -18,6 +19,11 @@ export async function GET() { { error: { code: "unauthorized", message: "请先登录" } }, { status: 401 } ) + const gate = legacyProtocolGate({ + mutation: false, + protocol: "branch-tree-list", + }) + if (gate) return gate const rows = await listOwnedTreeSummaries(userId) return Response.json({ trees: rows }) diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index bf256a67..39b035c4 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -33,6 +33,7 @@ import { } from "@/app/api/chat/generation-settlement" import { prepareThreadGenerationContext } from "@/app/api/chat/thread-generation-context" import { prepareChatRequestContext } from "@/app/api/chat/request-context" +import { legacyProtocolGate } from "@/lib/thread-chat/cutover/conversation-authority" // AnySearch 搜索与网页深读可能形成多步循环,放宽单次请求时长上限。 export const maxDuration = 300 @@ -52,6 +53,11 @@ export async function POST(req: Request) { isUnbilledPreview, } = requestContext + if (threadChat) { + const gate = legacyProtocolGate({ mutation: true, protocol: "chat-tree" }) + if (gate) return gate + } + const prepared = await prepareThreadGenerationContext({ userId, modelId, diff --git a/app/api/conversation-authority/route.ts b/app/api/conversation-authority/route.ts new file mode 100644 index 00000000..243e6f27 --- /dev/null +++ b/app/api/conversation-authority/route.ts @@ -0,0 +1,11 @@ +import { resolveConversationAuthority } from "@/lib/thread-chat/cutover/conversation-authority" + +export async function GET() { + const state = resolveConversationAuthority() + return Response.json({ + authority: state.authority, + schemaVersion: state.schemaVersion, + epoch: state.epoch, + maintenanceMode: state.maintenanceMode, + }) +} diff --git a/app/api/conversations/[conversationId]/messages/[messageId]/feedback/route.ts b/app/api/conversations/[conversationId]/messages/[messageId]/feedback/route.ts index 0aac4e60..50478467 100644 --- a/app/api/conversations/[conversationId]/messages/[messageId]/feedback/route.ts +++ b/app/api/conversations/[conversationId]/messages/[messageId]/feedback/route.ts @@ -10,6 +10,7 @@ import { } from "@/lib/thread-chat/domain/conversation-model" import { authenticatedActor, + assertCanonicalMutationAllowed, parseJson, withConversationRoute, } from "@/lib/thread-chat/http/conversation-command-http" @@ -21,6 +22,7 @@ type Context = { export async function PUT(request: Request, { params }: Context) { return withConversationRoute(async () => { const actor = await authenticatedActor() + assertCanonicalMutationAllowed() const ids = await params const body = await parseJson( request, diff --git a/app/thread-chat/[treeId]/page.tsx b/app/thread-chat/[treeId]/page.tsx index be8eb9e3..9fff57d5 100644 --- a/app/thread-chat/[treeId]/page.tsx +++ b/app/thread-chat/[treeId]/page.tsx @@ -3,6 +3,7 @@ import { isValidTreeId } from "@/lib/chat/tree-id" import { ThreadChatDemo } from "../thread-chat-demo" import { CanonicalThreadChat } from "../canonical/canonical-thread-chat" import { threadChatMetadata } from "../page-metadata" +import { resolveConversationAuthority } from "@/lib/thread-chat/cutover/conversation-authority" export const metadata = threadChatMetadata @@ -18,8 +19,14 @@ export default async function ThreadChatTreePage({ }) { const { treeId } = await params if (!isValidTreeId(treeId)) notFound() - return process.env.CONVERSATION_CLIENT_AUTHORITY === "canonical" ? ( - + const authority = resolveConversationAuthority() + return authority.authority === "canonical" ? ( + ) : ( ) diff --git a/app/thread-chat/canonical/canonical-thread-chat.tsx b/app/thread-chat/canonical/canonical-thread-chat.tsx index 63187ab2..4e6d404e 100644 --- a/app/thread-chat/canonical/canonical-thread-chat.tsx +++ b/app/thread-chat/canonical/canonical-thread-chat.tsx @@ -63,7 +63,15 @@ const textOf = (message: ConversationMessage) => .map((part) => part.text) .join("") -export function CanonicalThreadChat({ id }: { id: string }) { +export function CanonicalThreadChat({ + id, + expectedSchemaVersion, + expectedEpoch, +}: { + id: string + expectedSchemaVersion: number + expectedEpoch: string +}) { const router = useRouter() const targetId = conversationId(id) const store = useMemo(() => createNormalizedConversationStore(), []) @@ -119,12 +127,19 @@ export function CanonicalThreadChat({ id }: { id: string }) { clearTimeout(coordinatorDisposeTimer.current) coordinatorDisposeTimer.current = null } - gateway.loadConversation(targetId).then( - () => setLoaded(true), - (cause) => - setError(cause instanceof Error ? cause.message : String(cause)) - ) - }, [gateway, targetId]) + gateway + .verifyAuthority({ + authority: "canonical", + schemaVersion: expectedSchemaVersion, + epoch: expectedEpoch, + }) + .then(() => gateway.loadConversation(targetId)) + .then( + () => setLoaded(true), + (cause) => + setError(cause instanceof Error ? cause.message : String(cause)) + ) + }, [expectedEpoch, expectedSchemaVersion, gateway, targetId]) useEffect(() => { if (!loaded) return void gateway.listMessageFeedback(targetId).then( diff --git a/docs/architecture/adr-issue-34-local-cutover-disposition.md b/docs/architecture/adr-issue-34-local-cutover-disposition.md new file mode 100644 index 00000000..dff5913d --- /dev/null +++ b/docs/architecture/adr-issue-34-local-cutover-disposition.md @@ -0,0 +1,33 @@ +# ADR:Issue #34 本地切换数据处置 + +- 状态:本地演练已接受;生产决策待定 +- 日期:2026-08-22 +- 范围:`localhost/thread-chat` 的 `thread_chat` schema + +## 决策 + +本地演练采用**确定性导入**,不采用数据重置。原因是只读审计的 19 棵树中没有拒绝级结构、所有权或引用污染;5 条异常都只是早期终态 Generation 缺少来源 intent。 + +缺失 intent 的 5 条记录允许在本地演练中使用 `missing-generation-intent-as-send` 修复:它只把历史执行来源归为最弱的 `send`,不改变 Message 内容、计费终态或 active variant,也不会重新执行模型。正式环境若出现同类记录,仍需在自己的审批文件中逐项批准。 + +## 数据与回滚边界 + +```mermaid +flowchart LR + A[只读审计] --> B[冻结 legacy 写入] + B --> C[drain = 0] + C --> D[已验证备份] + D --> E[逐 Conversation 事务导入] + E --> F[计数 / FK / 映射摘要校验] + F --> G[切换 canonical epoch] +``` + +本地自动演练使用 `--test-rollback`:在同一事务中导入 19 个 Conversation、执行 post-import verifier、重复导入验证幂等,再强制回滚。该模式的 `backupId=rollback-test-does-not-commit` 不是生产备份凭证。正式 `--execute` 必须同时满足: + +1. `legacy + read-only` authority; +2. 审批文件的数据库 host/name 与当前连接一致; +3. 环境中的 approval ID 与审批文件一致; +4. 真实备份 ID、批准人、时间、scope 和允许 repair 均非空; +5. drain 门禁为 0。 + +生产环境不能复用本 ADR 的批准身份或测试备份标识,必须在真实审计后另建 ADR。 diff --git a/docs/architecture/conversation-persistence-audit-2026-08-22.md b/docs/architecture/conversation-persistence-audit-2026-08-22.md index 79b3a05c..6674de3f 100644 --- a/docs/architecture/conversation-persistence-audit-2026-08-22.md +++ b/docs/architecture/conversation-persistence-audit-2026-08-22.md @@ -11,22 +11,24 @@ | 指标 | 数量 | | --- | ---: | -| 遗留树 | 18 | -| 已有 owner | 18 | +| 遗留树 | 19 | +| 已有 owner | 19 | | 无 owner | 0 | -| 可直接映射 | 18 | -| 需修复 | 0 | +| 可直接映射 | 15 | +| 需修复 | 4 | | 拒绝 | 0 | -| Thread | 21 | -| Turn | 32 | -| Message | 67 | +| Thread | 22 | +| Turn | 34 | +| Message | 71 | | Fork | 3 | | Artifact | 0 | -| Generation 辅助记录 | 35 | +| Generation 辅助记录 | 37 | | Message feedback | 1 | -所有稳定审计错误码的计数均为 0。逐树 dry-run 同时生成了确定性的 Conversation、Thread 与 Message ID 映射。 +结构、归属、Fork、active leaf、Artifact 和反馈错误均为 0。审计新增发现 5 条早期 Generation sidecar 没有 `turn_snapshot.intent`,分布在 4 棵树,稳定错误码为 `generation_intent_missing`。导入器不会隐式猜测:dry-run 将它们逐条列入 repair 清单;写入模式只有在审批文件明确包含 `missing-generation-intent-as-send` 时,才把这些已终态记录按最弱的 `send` 来源语义保留,且绝不据此重新调用模型。 + +逐树 dry-run 同时生成持久的 `(legacyTreeId, entityType, localId) → canonicalId` 映射计划。当前计划为 19 个 Conversation、187 个实体,摘要为 `ee3736d92957dd4090f8fd8e765f01f4f3c84ca01aaed86db67416b15c860eb1`。 ## 对最终切换的含义 -当前本地证据表明,18 棵遗留树都满足已知结构、归属、Fork、active leaf、Generation 与反馈引用不变量,因此技术上可以一次性导入,不需要因污染数据强制重置。这个结论只适用于本次审计的本地目标;最终 `retire-thread-tree-authority` 在每个待切换环境必须重新执行同一只读审计,再决定迁移或重置,不能把本地结果外推为生产事实。 +当前本地证据表明,19 棵遗留树均无拒绝级污染,15 棵可直接导入,另外 4 棵只需上述 5 条可枚举的来源语义修复,因此技术上适合确定性导入,不需要重置。完整导入已在单一数据库事务中对 19 棵树逐 Conversation 写入、校验、重复写入验证幂等,最后强制回滚;回滚后映射账本和 `legacy:%` Conversation 均为 0。这个结论只适用于本次本地目标;正式环境仍须重新审计、备份并单独批准。 diff --git a/docs/architecture/issue-34-cutover-runbook.md b/docs/architecture/issue-34-cutover-runbook.md new file mode 100644 index 00000000..62ca6264 --- /dev/null +++ b/docs/architecture/issue-34-cutover-runbook.md @@ -0,0 +1,52 @@ +# Issue #34 Conversation cutover runbook + +## 前置状态 + +依赖链五个 changes 的任务均已完成: + +```text +define-conversation-domain-model + → normalize-conversation-persistence + → migrate-generation-lifecycle + → add-conversation-command-api + → normalize-conversation-client-state +``` + +`pnpm openspec:validate` 当前为 31/31。正式环境仍须填写负责人、观察窗口、性能/错误/计费基线和真实备份 ID;本文件中的本地结果不能替代生产批准。 + +## Release 行为矩阵 + +| 行为边界 | 自动证据 | Ego Browser / API smoke | Cutover 门禁 | +| --- | --- | --- | --- | +| owner/workspace/project 隔离 | persistence、command API DB tests | 已登录测试账号读取规范 Conversation | 越权统一不可见;无跨 owner 引用 | +| Conversation 列表/创建/标题/归档/恢复/删除 | command contract/API、client tests | 列表、重命名、归档/恢复已验证 | revision 与幂等键稳定 | +| Thread 标题/归档/恢复 | command API、client tests | 三列 A→B→C、最深 Thread 归档/恢复 | 根 Thread 不可被错误归档或重挂 | +| Fork/selection/A→B→C | domain、persistence、client tests | 列/树列表/画布均显示 3 节点 2 边 | 来源 Message 精确且图无环 | +| send/edit/regenerate/select variant | generation、command、client tests | GLM 5.3 流式、刷新、变体选择已验证 | current Generation 每 Turn 唯一 | +| Markdown/研究/Artifact | generation checkpoint、client tests | Markdown/结构化研究与 Artifact 定位 | Artifact 来源是稳定 Thread/Message ID | +| copy/feedback/actions | client 与 feedback DB tests | 复制状态、赞/踩刷新保持 | feedback 使用稳定 Message ID | +| disconnect/Stop/stale/refresh | generation unit/DB、coordinator tests | partial Stop 与刷新恢复已验证 | 非终态与 pending billing 必须清零 | +| usage/exactly-once billing | generation DB tests | 不依赖浏览器连接 | pending billing=0;usage 终态明确 | +| authority 与旧协议 | authority/client tests | authority 三元组匹配;旧 route 返回 410 | 不存在分裂开关或 fallback | + +## 本地 cutover 演练结果(2026-08-22) + +- 数据:19 棵 legacy 树;22 Thread、34 Turn、71 Message、3 Fork、37 Generation、1 feedback。 +- 审计:15 棵直接可迁移;4 棵共 5 条 `generation_intent_missing`;0 条拒绝级污染。 +- drain:legacy/canonical active Generation、pending billing、canonical outbox 五项均为 0。 +- 导入:19 个 Conversation、187 个实体;同事务逐 Conversation 写入、post-import verifier、重复导入幂等检查全部通过,随后强制回滚。 +- 回滚证明:`legacy_conversation_entity_mappings=0`,`conversations.id LIKE 'legacy:%'=0`。 +- 客户端:`canonical / schema 1 / local-issue34-20260822` 匹配后正常加载;旧 tree 与 generation routes 返回 410。 + +## 正式 go / no-go checklist + +- [ ] 目标环境重新执行只读审计,保存完整报告与 plan hash。 +- [ ] 确认所有 repair/排除项均在目标环境 ADR 中获批。 +- [ ] 记录负责人、值班人、维护窗口、观察窗口和回滚决策人。 +- [ ] 记录容量、P95/P99、命令错误率、Generation age、Stop latency、计费差异基线和阈值。 +- [ ] 创建 legacy 与 canonical 备份,执行真实恢复验证并填写备份 ID。 +- [ ] 进入 `legacy + read-only`,运行 drain checker 直至 `ready=true`。 +- [ ] 用目标审批文件执行 import/reset,保存 verifier 结果。 +- [ ] 原子切换 canonical server/client epoch,运行行为矩阵关键 smoke。 +- [ ] 确认首个 canonical 生产写入时间;此后禁止恢复落后的 legacy 权威。 +- [ ] 观察期内 legacy route/query 为 0,完整性与计费对账连续通过。 diff --git a/drizzle/0012_needy_colonel_america.sql b/drizzle/0012_needy_colonel_america.sql new file mode 100644 index 00000000..9c44af5a --- /dev/null +++ b/drizzle/0012_needy_colonel_america.sql @@ -0,0 +1,11 @@ +CREATE TABLE "thread_chat"."legacy_conversation_entity_mappings" ( + "legacy_tree_id" text NOT NULL, + "entity_type" text NOT NULL, + "local_id" text NOT NULL, + "canonical_id" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "legacy_conversation_entity_mappings_pk" PRIMARY KEY("legacy_tree_id","entity_type","local_id") +); +--> statement-breakpoint +CREATE UNIQUE INDEX "legacy_conversation_entity_mappings_canonical_uq" ON "thread_chat"."legacy_conversation_entity_mappings" USING btree ("canonical_id");--> statement-breakpoint +CREATE INDEX "legacy_conversation_entity_mappings_tree_idx" ON "thread_chat"."legacy_conversation_entity_mappings" USING btree ("legacy_tree_id"); \ No newline at end of file diff --git a/drizzle/0013_jazzy_tiger_shark.sql b/drizzle/0013_jazzy_tiger_shark.sql new file mode 100644 index 00000000..defe4cc5 --- /dev/null +++ b/drizzle/0013_jazzy_tiger_shark.sql @@ -0,0 +1,16 @@ +CREATE TABLE "thread_chat"."conversation_artifacts" ( + "id" text PRIMARY KEY NOT NULL, + "conversation_id" text NOT NULL, + "source_thread_id" text NOT NULL, + "source_message_id" text NOT NULL, + "title" text NOT NULL, + "kind" text NOT NULL, + "lang" text, + "content" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "thread_chat"."conversation_artifacts" ADD CONSTRAINT "conversation_artifacts_conversation_id_conversations_id_fk" FOREIGN KEY ("conversation_id") REFERENCES "thread_chat"."conversations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "thread_chat"."conversation_artifacts" ADD CONSTRAINT "conversation_artifacts_thread_fk" FOREIGN KEY ("source_thread_id","conversation_id") REFERENCES "thread_chat"."conversation_threads"("id","conversation_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "thread_chat"."conversation_artifacts" ADD CONSTRAINT "conversation_artifacts_message_fk" FOREIGN KEY ("source_message_id","source_thread_id") REFERENCES "thread_chat"."conversation_messages"("id","thread_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "conversation_artifacts_conversation_idx" ON "thread_chat"."conversation_artifacts" USING btree ("conversation_id","created_at"); \ No newline at end of file diff --git a/drizzle/meta/0012_snapshot.json b/drizzle/meta/0012_snapshot.json new file mode 100644 index 00000000..91c6099c --- /dev/null +++ b/drizzle/meta/0012_snapshot.json @@ -0,0 +1,3414 @@ +{ + "id": "20605aea-5ceb-4076-99ea-e70374e5288a", + "prevId": "574d36b5-9b51-435a-836f-0470e53e784a", + "version": "7", + "dialect": "postgresql", + "tables": { + "thread_chat.attachment_chunks": { + "name": "attachment_chunks", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "attachment_id": { + "name": "attachment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "page": { + "name": "page", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "attachment_chunks_attachment_id_idx": { + "name": "attachment_chunks_attachment_id_idx", + "columns": [ + { + "expression": "attachment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "attachment_chunks_embedding_idx": { + "name": "attachment_chunks_embedding_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": {} + } + }, + "foreignKeys": { + "attachment_chunks_attachment_id_attachments_id_fk": { + "name": "attachment_chunks_attachment_id_attachments_id_fk", + "tableFrom": "attachment_chunks", + "tableTo": "attachments", + "schemaTo": "thread_chat", + "columnsFrom": [ + "attachment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.attachments": { + "name": "attachments", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'uploading'" + }, + "page_count": { + "name": "page_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pages": { + "name": "pages", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "suggested_questions": { + "name": "suggested_questions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "attachments_key_unique": { + "name": "attachments_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.branch_generations": { + "name": "branch_generations", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tree_id": { + "name": "tree_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_message_id": { + "name": "user_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assistant_message_id": { + "name": "assistant_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assistant_message_index": { + "name": "assistant_message_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "turn_snapshot": { + "name": "turn_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_status": { + "name": "billing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "heartbeat_at": { + "name": "heartbeat_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "stop_requested_at": { + "name": "stop_requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "branch_generations_current_assistant_uq": { + "name": "branch_generations_current_assistant_uq", + "columns": [ + { + "expression": "tree_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assistant_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"thread_chat\".\"branch_generations\".\"is_current\" = true", + "concurrently": false, + "method": "btree", + "with": {} + }, + "branch_generations_assistant_attempt_uq": { + "name": "branch_generations_assistant_attempt_uq", + "columns": [ + { + "expression": "tree_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assistant_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "attempt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "branch_generations_user_id_idx": { + "name": "branch_generations_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "branch_generations_tree_current_idx": { + "name": "branch_generations_tree_current_idx", + "columns": [ + { + "expression": "tree_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_current", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "branch_generations_user_status_idx": { + "name": "branch_generations_user_status_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "branch_generations_heartbeat_idx": { + "name": "branch_generations_heartbeat_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "heartbeat_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "branch_generations_user_id_user_id_fk": { + "name": "branch_generations_user_id_user_id_fk", + "tableFrom": "branch_generations", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "branch_generations_tree_id_branch_trees_id_fk": { + "name": "branch_generations_tree_id_branch_trees_id_fk", + "tableFrom": "branch_generations", + "tableTo": "branch_trees", + "schemaTo": "thread_chat", + "columnsFrom": [ + "tree_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.branch_message_feedback": { + "name": "branch_message_feedback", + "schema": "thread_chat", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tree_id": { + "name": "tree_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "branch_message_feedback_tree_idx": { + "name": "branch_message_feedback_tree_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tree_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "branch_message_feedback_user_id_user_id_fk": { + "name": "branch_message_feedback_user_id_user_id_fk", + "tableFrom": "branch_message_feedback", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "branch_message_feedback_tree_id_branch_trees_id_fk": { + "name": "branch_message_feedback_tree_id_branch_trees_id_fk", + "tableFrom": "branch_message_feedback", + "tableTo": "branch_trees", + "schemaTo": "thread_chat", + "columnsFrom": [ + "tree_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "branch_message_feedback_pk": { + "name": "branch_message_feedback_pk", + "columns": [ + "user_id", + "tree_id", + "thread_id", + "message_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.branch_trees": { + "name": "branch_trees", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_title": { + "name": "custom_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "branch_trees_user_id_idx": { + "name": "branch_trees_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "branch_trees_user_id_user_id_fk": { + "name": "branch_trees_user_id_user_id_fk", + "tableFrom": "branch_trees", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.conversation_command_records": { + "name": "conversation_command_records", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_type": { + "name": "scope_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "command_type": { + "name": "command_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload_hash": { + "name": "payload_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversation_command_records_scope_key_uq": { + "name": "conversation_command_records_scope_key_uq", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_command_records_actor_created_idx": { + "name": "conversation_command_records_actor_created_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversation_command_records_actor_id_user_id_fk": { + "name": "conversation_command_records_actor_id_user_id_fk", + "tableFrom": "conversation_command_records", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.conversation_generations": { + "name": "conversation_generations", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_message_id": { + "name": "input_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "output_message_id": { + "name": "output_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "intent": { + "name": "intent", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_state": { + "name": "content_state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checkpoint_version": { + "name": "checkpoint_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "checkpoint": { + "name": "checkpoint", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "known_usage": { + "name": "known_usage", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "usage_completeness": { + "name": "usage_completeness", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_status": { + "name": "billing_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "paid_call_started": { + "name": "paid_call_started", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "lease_owner": { + "name": "lease_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_version": { + "name": "lease_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "heartbeat_at": { + "name": "heartbeat_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "stop_requested_at": { + "name": "stop_requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversation_generations_owner_idempotency_uq": { + "name": "conversation_generations_owner_idempotency_uq", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_generations_output_attempt_uq": { + "name": "conversation_generations_output_attempt_uq", + "columns": [ + { + "expression": "output_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "attempt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_generations_current_turn_uq": { + "name": "conversation_generations_current_turn_uq", + "columns": [ + { + "expression": "turn_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"thread_chat\".\"conversation_generations\".\"is_current\" = true", + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_generations_owner_status_idx": { + "name": "conversation_generations_owner_status_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_generations_conversation_updated_idx": { + "name": "conversation_generations_conversation_updated_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_generations_lease_idx": { + "name": "conversation_generations_lease_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "heartbeat_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversation_generations_owner_id_user_id_fk": { + "name": "conversation_generations_owner_id_user_id_fk", + "tableFrom": "conversation_generations", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.conversation_message_feedback": { + "name": "conversation_message_feedback", + "schema": "thread_chat", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversation_message_feedback_conversation_idx": { + "name": "conversation_message_feedback_conversation_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversation_message_feedback_user_id_user_id_fk": { + "name": "conversation_message_feedback_user_id_user_id_fk", + "tableFrom": "conversation_message_feedback", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversation_message_feedback_conversation_id_conversations_id_fk": { + "name": "conversation_message_feedback_conversation_id_conversations_id_fk", + "tableFrom": "conversation_message_feedback", + "tableTo": "conversations", + "schemaTo": "thread_chat", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversation_message_feedback_thread_fk": { + "name": "conversation_message_feedback_thread_fk", + "tableFrom": "conversation_message_feedback", + "tableTo": "conversation_threads", + "schemaTo": "thread_chat", + "columnsFrom": [ + "thread_id", + "conversation_id" + ], + "columnsTo": [ + "id", + "conversation_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversation_message_feedback_message_fk": { + "name": "conversation_message_feedback_message_fk", + "tableFrom": "conversation_message_feedback", + "tableTo": "conversation_messages", + "schemaTo": "thread_chat", + "columnsFrom": [ + "message_id", + "thread_id" + ], + "columnsTo": [ + "id", + "thread_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "conversation_message_feedback_pk": { + "name": "conversation_message_feedback_pk", + "columns": [ + "user_id", + "conversation_id", + "message_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.conversation_messages": { + "name": "conversation_messages", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "content_state": { + "name": "content_state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variant_of_message_id": { + "name": "variant_of_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversation_messages_id_thread_id_uq": { + "name": "conversation_messages_id_thread_id_uq", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_messages_id_thread_turn_uq": { + "name": "conversation_messages_id_thread_turn_uq", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "turn_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_messages_turn_created_idx": { + "name": "conversation_messages_turn_created_idx", + "columns": [ + { + "expression": "turn_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_messages_variant_source_idx": { + "name": "conversation_messages_variant_source_idx", + "columns": [ + { + "expression": "variant_of_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.conversation_outbox_events": { + "name": "conversation_outbox_events", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "aggregate_type": { + "name": "aggregate_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "aggregate_id": { + "name": "aggregate_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "aggregate_revision": { + "name": "aggregate_revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "available_at": { + "name": "available_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "dispatched_at": { + "name": "dispatched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "conversation_outbox_events_pending_idx": { + "name": "conversation_outbox_events_pending_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_outbox_events_aggregate_idx": { + "name": "conversation_outbox_events_aggregate_idx", + "columns": [ + { + "expression": "aggregate_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "aggregate_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "aggregate_revision", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversation_outbox_events_actor_id_user_id_fk": { + "name": "conversation_outbox_events_actor_id_user_id_fk", + "tableFrom": "conversation_outbox_events", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.conversation_threads": { + "name": "conversation_threads", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "local_title": { + "name": "local_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversation_threads_id_conversation_id_uq": { + "name": "conversation_threads_id_conversation_id_uq", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_threads_conversation_created_idx": { + "name": "conversation_threads_conversation_created_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversation_threads_conversation_id_conversations_id_fk": { + "name": "conversation_threads_conversation_id_conversations_id_fk", + "tableFrom": "conversation_threads", + "tableTo": "conversations", + "schemaTo": "thread_chat", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.conversation_turns": { + "name": "conversation_turns", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "active_user_message_id": { + "name": "active_user_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_assistant_message_id": { + "name": "active_assistant_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversation_turns_id_thread_id_uq": { + "name": "conversation_turns_id_thread_id_uq", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_turns_thread_position_uq": { + "name": "conversation_turns_thread_position_uq", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_turns_active_user_idx": { + "name": "conversation_turns_active_user_idx", + "columns": [ + { + "expression": "active_user_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_turns_active_assistant_idx": { + "name": "conversation_turns_active_assistant_idx", + "columns": [ + { + "expression": "active_assistant_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversation_turns_thread_id_conversation_threads_id_fk": { + "name": "conversation_turns_thread_id_conversation_threads_id_fk", + "tableFrom": "conversation_turns", + "tableTo": "conversation_threads", + "schemaTo": "thread_chat", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.conversations": { + "name": "conversations", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "root_thread_id": { + "name": "root_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auto_title": { + "name": "auto_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_title": { + "name": "custom_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversations_project_id_id_uq": { + "name": "conversations_project_id_id_uq", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversations_project_updated_idx": { + "name": "conversations_project_updated_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversations_project_lifecycle_idx": { + "name": "conversations_project_lifecycle_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lifecycle", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversations_project_id_projects_id_fk": { + "name": "conversations_project_id_projects_id_fk", + "tableFrom": "conversations", + "tableTo": "projects", + "schemaTo": "thread_chat", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.legacy_conversation_entity_mappings": { + "name": "legacy_conversation_entity_mappings", + "schema": "thread_chat", + "columns": { + "legacy_tree_id": { + "name": "legacy_tree_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "local_id": { + "name": "local_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "canonical_id": { + "name": "canonical_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "legacy_conversation_entity_mappings_canonical_uq": { + "name": "legacy_conversation_entity_mappings_canonical_uq", + "columns": [ + { + "expression": "canonical_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "legacy_conversation_entity_mappings_tree_idx": { + "name": "legacy_conversation_entity_mappings_tree_idx", + "columns": [ + { + "expression": "legacy_tree_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "legacy_conversation_entity_mappings_pk": { + "name": "legacy_conversation_entity_mappings_pk", + "columns": [ + "legacy_tree_id", + "entity_type", + "local_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.projects": { + "name": "projects", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "projects_workspace_id_id_uq": { + "name": "projects_workspace_id_id_uq", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "projects_workspace_lifecycle_idx": { + "name": "projects_workspace_lifecycle_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lifecycle", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_workspace_id_workspaces_id_fk": { + "name": "projects_workspace_id_workspaces_id_fk", + "tableFrom": "projects", + "tableTo": "workspaces", + "schemaTo": "thread_chat", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.thread_forks": { + "name": "thread_forks", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_thread_id": { + "name": "parent_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_message_id": { + "name": "source_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_thread_id": { + "name": "child_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "anchor": { + "name": "anchor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "thread_forks_child_thread_uq": { + "name": "thread_forks_child_thread_uq", + "columns": [ + { + "expression": "child_thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "thread_forks_parent_source_idx": { + "name": "thread_forks_parent_source_idx", + "columns": [ + { + "expression": "parent_thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "thread_forks_conversation_created_idx": { + "name": "thread_forks_conversation_created_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "thread_forks_conversation_id_conversations_id_fk": { + "name": "thread_forks_conversation_id_conversations_id_fk", + "tableFrom": "thread_forks", + "tableTo": "conversations", + "schemaTo": "thread_chat", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "thread_forks_created_by_user_id_fk": { + "name": "thread_forks_created_by_user_id_fk", + "tableFrom": "thread_forks", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.workspace_members": { + "name": "workspace_members", + "schema": "thread_chat", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_members_user_id_idx": { + "name": "workspace_members_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_members_workspace_id_workspaces_id_fk": { + "name": "workspace_members_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_members", + "tableTo": "workspaces", + "schemaTo": "thread_chat", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_members_user_id_user_id_fk": { + "name": "workspace_members_user_id_user_id_fk", + "tableFrom": "workspace_members", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_members_pk": { + "name": "workspace_members_pk", + "columns": [ + "workspace_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.workspaces": { + "name": "workspaces", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspaces_lifecycle_idx": { + "name": "workspaces_lifecycle_idx", + "columns": [ + { + "expression": "lifecycle", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.account": { + "name": "account", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.session": { + "name": "session", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.user": { + "name": "user", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.verification": { + "name": "verification", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.usage_records": { + "name": "usage_records", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "app_generation_id": { + "name": "app_generation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_micros": { + "name": "cost_micros", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "price_micros": { + "name": "price_micros", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "generation_id": { + "name": "generation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cost_source": { + "name": "cost_source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'estimate'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_records_user_id_idx": { + "name": "usage_records_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_records_thread_id_idx": { + "name": "usage_records_thread_id_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_records_cost_source_idx": { + "name": "usage_records_cost_source_idx", + "columns": [ + { + "expression": "cost_source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_records_app_generation_id_uq": { + "name": "usage_records_app_generation_id_uq", + "columns": [ + { + "expression": "app_generation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_records_user_id_user_id_fk": { + "name": "usage_records_user_id_user_id_fk", + "tableFrom": "usage_records", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.user_credits": { + "name": "user_credits", + "schema": "thread_chat", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "balance_micros": { + "name": "balance_micros", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_credits_user_id_user_id_fk": { + "name": "user_credits_user_id_user_id_fk", + "tableFrom": "user_credits", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.payments": { + "name": "payments", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'creem'" + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pack_id": { + "name": "pack_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checkout_id": { + "name": "checkout_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order_id": { + "name": "order_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "credit_micros": { + "name": "credit_micros", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "price_label": { + "name": "price_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "raw": { + "name": "raw", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "payments_user_id_idx": { + "name": "payments_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payments_provider_order_id_uq": { + "name": "payments_provider_order_id_uq", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payments_user_id_user_id_fk": { + "name": "payments_user_id_user_id_fk", + "tableFrom": "payments", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.subscriptions": { + "name": "subscriptions", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'creem'" + }, + "subscription_id": { + "name": "subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_period_end": { + "name": "current_period_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "raw": { + "name": "raw", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscriptions_user_id_idx": { + "name": "subscriptions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "subscriptions_user_id_user_id_fk": { + "name": "subscriptions_user_id_user_id_fk", + "tableFrom": "subscriptions", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "subscriptions_subscription_id_unique": { + "name": "subscriptions_subscription_id_unique", + "nullsNotDistinct": false, + "columns": [ + "subscription_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0013_snapshot.json b/drizzle/meta/0013_snapshot.json new file mode 100644 index 00000000..8a5b2604 --- /dev/null +++ b/drizzle/meta/0013_snapshot.json @@ -0,0 +1,3551 @@ +{ + "id": "0f9c7dcd-8c2f-4c21-a0d4-6a4ef62636fd", + "prevId": "20605aea-5ceb-4076-99ea-e70374e5288a", + "version": "7", + "dialect": "postgresql", + "tables": { + "thread_chat.attachment_chunks": { + "name": "attachment_chunks", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "attachment_id": { + "name": "attachment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "page": { + "name": "page", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "attachment_chunks_attachment_id_idx": { + "name": "attachment_chunks_attachment_id_idx", + "columns": [ + { + "expression": "attachment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "attachment_chunks_embedding_idx": { + "name": "attachment_chunks_embedding_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": {} + } + }, + "foreignKeys": { + "attachment_chunks_attachment_id_attachments_id_fk": { + "name": "attachment_chunks_attachment_id_attachments_id_fk", + "tableFrom": "attachment_chunks", + "tableTo": "attachments", + "schemaTo": "thread_chat", + "columnsFrom": [ + "attachment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.attachments": { + "name": "attachments", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'uploading'" + }, + "page_count": { + "name": "page_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pages": { + "name": "pages", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "suggested_questions": { + "name": "suggested_questions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "attachments_key_unique": { + "name": "attachments_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.branch_generations": { + "name": "branch_generations", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tree_id": { + "name": "tree_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_message_id": { + "name": "user_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assistant_message_id": { + "name": "assistant_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assistant_message_index": { + "name": "assistant_message_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "turn_snapshot": { + "name": "turn_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_status": { + "name": "billing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "heartbeat_at": { + "name": "heartbeat_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "stop_requested_at": { + "name": "stop_requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "branch_generations_current_assistant_uq": { + "name": "branch_generations_current_assistant_uq", + "columns": [ + { + "expression": "tree_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assistant_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"thread_chat\".\"branch_generations\".\"is_current\" = true", + "concurrently": false, + "method": "btree", + "with": {} + }, + "branch_generations_assistant_attempt_uq": { + "name": "branch_generations_assistant_attempt_uq", + "columns": [ + { + "expression": "tree_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assistant_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "attempt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "branch_generations_user_id_idx": { + "name": "branch_generations_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "branch_generations_tree_current_idx": { + "name": "branch_generations_tree_current_idx", + "columns": [ + { + "expression": "tree_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_current", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "branch_generations_user_status_idx": { + "name": "branch_generations_user_status_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "branch_generations_heartbeat_idx": { + "name": "branch_generations_heartbeat_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "heartbeat_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "branch_generations_user_id_user_id_fk": { + "name": "branch_generations_user_id_user_id_fk", + "tableFrom": "branch_generations", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "branch_generations_tree_id_branch_trees_id_fk": { + "name": "branch_generations_tree_id_branch_trees_id_fk", + "tableFrom": "branch_generations", + "tableTo": "branch_trees", + "schemaTo": "thread_chat", + "columnsFrom": [ + "tree_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.branch_message_feedback": { + "name": "branch_message_feedback", + "schema": "thread_chat", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tree_id": { + "name": "tree_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "branch_message_feedback_tree_idx": { + "name": "branch_message_feedback_tree_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tree_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "branch_message_feedback_user_id_user_id_fk": { + "name": "branch_message_feedback_user_id_user_id_fk", + "tableFrom": "branch_message_feedback", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "branch_message_feedback_tree_id_branch_trees_id_fk": { + "name": "branch_message_feedback_tree_id_branch_trees_id_fk", + "tableFrom": "branch_message_feedback", + "tableTo": "branch_trees", + "schemaTo": "thread_chat", + "columnsFrom": [ + "tree_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "branch_message_feedback_pk": { + "name": "branch_message_feedback_pk", + "columns": [ + "user_id", + "tree_id", + "thread_id", + "message_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.branch_trees": { + "name": "branch_trees", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_title": { + "name": "custom_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "branch_trees_user_id_idx": { + "name": "branch_trees_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "branch_trees_user_id_user_id_fk": { + "name": "branch_trees_user_id_user_id_fk", + "tableFrom": "branch_trees", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.conversation_artifacts": { + "name": "conversation_artifacts", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_thread_id": { + "name": "source_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_message_id": { + "name": "source_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lang": { + "name": "lang", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversation_artifacts_conversation_idx": { + "name": "conversation_artifacts_conversation_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversation_artifacts_conversation_id_conversations_id_fk": { + "name": "conversation_artifacts_conversation_id_conversations_id_fk", + "tableFrom": "conversation_artifacts", + "tableTo": "conversations", + "schemaTo": "thread_chat", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversation_artifacts_thread_fk": { + "name": "conversation_artifacts_thread_fk", + "tableFrom": "conversation_artifacts", + "tableTo": "conversation_threads", + "schemaTo": "thread_chat", + "columnsFrom": [ + "source_thread_id", + "conversation_id" + ], + "columnsTo": [ + "id", + "conversation_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversation_artifacts_message_fk": { + "name": "conversation_artifacts_message_fk", + "tableFrom": "conversation_artifacts", + "tableTo": "conversation_messages", + "schemaTo": "thread_chat", + "columnsFrom": [ + "source_message_id", + "source_thread_id" + ], + "columnsTo": [ + "id", + "thread_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.conversation_command_records": { + "name": "conversation_command_records", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_type": { + "name": "scope_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "command_type": { + "name": "command_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload_hash": { + "name": "payload_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversation_command_records_scope_key_uq": { + "name": "conversation_command_records_scope_key_uq", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_command_records_actor_created_idx": { + "name": "conversation_command_records_actor_created_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversation_command_records_actor_id_user_id_fk": { + "name": "conversation_command_records_actor_id_user_id_fk", + "tableFrom": "conversation_command_records", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.conversation_generations": { + "name": "conversation_generations", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_message_id": { + "name": "input_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "output_message_id": { + "name": "output_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "intent": { + "name": "intent", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_state": { + "name": "content_state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checkpoint_version": { + "name": "checkpoint_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "checkpoint": { + "name": "checkpoint", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "known_usage": { + "name": "known_usage", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "usage_completeness": { + "name": "usage_completeness", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_status": { + "name": "billing_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "paid_call_started": { + "name": "paid_call_started", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "lease_owner": { + "name": "lease_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_version": { + "name": "lease_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "heartbeat_at": { + "name": "heartbeat_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "stop_requested_at": { + "name": "stop_requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversation_generations_owner_idempotency_uq": { + "name": "conversation_generations_owner_idempotency_uq", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_generations_output_attempt_uq": { + "name": "conversation_generations_output_attempt_uq", + "columns": [ + { + "expression": "output_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "attempt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_generations_current_turn_uq": { + "name": "conversation_generations_current_turn_uq", + "columns": [ + { + "expression": "turn_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"thread_chat\".\"conversation_generations\".\"is_current\" = true", + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_generations_owner_status_idx": { + "name": "conversation_generations_owner_status_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_generations_conversation_updated_idx": { + "name": "conversation_generations_conversation_updated_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_generations_lease_idx": { + "name": "conversation_generations_lease_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "heartbeat_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversation_generations_owner_id_user_id_fk": { + "name": "conversation_generations_owner_id_user_id_fk", + "tableFrom": "conversation_generations", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.conversation_message_feedback": { + "name": "conversation_message_feedback", + "schema": "thread_chat", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversation_message_feedback_conversation_idx": { + "name": "conversation_message_feedback_conversation_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversation_message_feedback_user_id_user_id_fk": { + "name": "conversation_message_feedback_user_id_user_id_fk", + "tableFrom": "conversation_message_feedback", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversation_message_feedback_conversation_id_conversations_id_fk": { + "name": "conversation_message_feedback_conversation_id_conversations_id_fk", + "tableFrom": "conversation_message_feedback", + "tableTo": "conversations", + "schemaTo": "thread_chat", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversation_message_feedback_thread_fk": { + "name": "conversation_message_feedback_thread_fk", + "tableFrom": "conversation_message_feedback", + "tableTo": "conversation_threads", + "schemaTo": "thread_chat", + "columnsFrom": [ + "thread_id", + "conversation_id" + ], + "columnsTo": [ + "id", + "conversation_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversation_message_feedback_message_fk": { + "name": "conversation_message_feedback_message_fk", + "tableFrom": "conversation_message_feedback", + "tableTo": "conversation_messages", + "schemaTo": "thread_chat", + "columnsFrom": [ + "message_id", + "thread_id" + ], + "columnsTo": [ + "id", + "thread_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "conversation_message_feedback_pk": { + "name": "conversation_message_feedback_pk", + "columns": [ + "user_id", + "conversation_id", + "message_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.conversation_messages": { + "name": "conversation_messages", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "content_state": { + "name": "content_state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variant_of_message_id": { + "name": "variant_of_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversation_messages_id_thread_id_uq": { + "name": "conversation_messages_id_thread_id_uq", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_messages_id_thread_turn_uq": { + "name": "conversation_messages_id_thread_turn_uq", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "turn_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_messages_turn_created_idx": { + "name": "conversation_messages_turn_created_idx", + "columns": [ + { + "expression": "turn_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_messages_variant_source_idx": { + "name": "conversation_messages_variant_source_idx", + "columns": [ + { + "expression": "variant_of_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.conversation_outbox_events": { + "name": "conversation_outbox_events", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "aggregate_type": { + "name": "aggregate_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "aggregate_id": { + "name": "aggregate_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "aggregate_revision": { + "name": "aggregate_revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "available_at": { + "name": "available_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "dispatched_at": { + "name": "dispatched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "conversation_outbox_events_pending_idx": { + "name": "conversation_outbox_events_pending_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_outbox_events_aggregate_idx": { + "name": "conversation_outbox_events_aggregate_idx", + "columns": [ + { + "expression": "aggregate_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "aggregate_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "aggregate_revision", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversation_outbox_events_actor_id_user_id_fk": { + "name": "conversation_outbox_events_actor_id_user_id_fk", + "tableFrom": "conversation_outbox_events", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.conversation_threads": { + "name": "conversation_threads", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "local_title": { + "name": "local_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversation_threads_id_conversation_id_uq": { + "name": "conversation_threads_id_conversation_id_uq", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_threads_conversation_created_idx": { + "name": "conversation_threads_conversation_created_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversation_threads_conversation_id_conversations_id_fk": { + "name": "conversation_threads_conversation_id_conversations_id_fk", + "tableFrom": "conversation_threads", + "tableTo": "conversations", + "schemaTo": "thread_chat", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.conversation_turns": { + "name": "conversation_turns", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "active_user_message_id": { + "name": "active_user_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_assistant_message_id": { + "name": "active_assistant_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversation_turns_id_thread_id_uq": { + "name": "conversation_turns_id_thread_id_uq", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_turns_thread_position_uq": { + "name": "conversation_turns_thread_position_uq", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_turns_active_user_idx": { + "name": "conversation_turns_active_user_idx", + "columns": [ + { + "expression": "active_user_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversation_turns_active_assistant_idx": { + "name": "conversation_turns_active_assistant_idx", + "columns": [ + { + "expression": "active_assistant_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversation_turns_thread_id_conversation_threads_id_fk": { + "name": "conversation_turns_thread_id_conversation_threads_id_fk", + "tableFrom": "conversation_turns", + "tableTo": "conversation_threads", + "schemaTo": "thread_chat", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.conversations": { + "name": "conversations", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "root_thread_id": { + "name": "root_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auto_title": { + "name": "auto_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_title": { + "name": "custom_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversations_project_id_id_uq": { + "name": "conversations_project_id_id_uq", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversations_project_updated_idx": { + "name": "conversations_project_updated_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversations_project_lifecycle_idx": { + "name": "conversations_project_lifecycle_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lifecycle", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversations_project_id_projects_id_fk": { + "name": "conversations_project_id_projects_id_fk", + "tableFrom": "conversations", + "tableTo": "projects", + "schemaTo": "thread_chat", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.legacy_conversation_entity_mappings": { + "name": "legacy_conversation_entity_mappings", + "schema": "thread_chat", + "columns": { + "legacy_tree_id": { + "name": "legacy_tree_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "local_id": { + "name": "local_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "canonical_id": { + "name": "canonical_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "legacy_conversation_entity_mappings_canonical_uq": { + "name": "legacy_conversation_entity_mappings_canonical_uq", + "columns": [ + { + "expression": "canonical_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "legacy_conversation_entity_mappings_tree_idx": { + "name": "legacy_conversation_entity_mappings_tree_idx", + "columns": [ + { + "expression": "legacy_tree_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "legacy_conversation_entity_mappings_pk": { + "name": "legacy_conversation_entity_mappings_pk", + "columns": [ + "legacy_tree_id", + "entity_type", + "local_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.projects": { + "name": "projects", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "projects_workspace_id_id_uq": { + "name": "projects_workspace_id_id_uq", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "projects_workspace_lifecycle_idx": { + "name": "projects_workspace_lifecycle_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lifecycle", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_workspace_id_workspaces_id_fk": { + "name": "projects_workspace_id_workspaces_id_fk", + "tableFrom": "projects", + "tableTo": "workspaces", + "schemaTo": "thread_chat", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.thread_forks": { + "name": "thread_forks", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_thread_id": { + "name": "parent_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_message_id": { + "name": "source_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_thread_id": { + "name": "child_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "anchor": { + "name": "anchor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "thread_forks_child_thread_uq": { + "name": "thread_forks_child_thread_uq", + "columns": [ + { + "expression": "child_thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "thread_forks_parent_source_idx": { + "name": "thread_forks_parent_source_idx", + "columns": [ + { + "expression": "parent_thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "thread_forks_conversation_created_idx": { + "name": "thread_forks_conversation_created_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "thread_forks_conversation_id_conversations_id_fk": { + "name": "thread_forks_conversation_id_conversations_id_fk", + "tableFrom": "thread_forks", + "tableTo": "conversations", + "schemaTo": "thread_chat", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "thread_forks_created_by_user_id_fk": { + "name": "thread_forks_created_by_user_id_fk", + "tableFrom": "thread_forks", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.workspace_members": { + "name": "workspace_members", + "schema": "thread_chat", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_members_user_id_idx": { + "name": "workspace_members_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_members_workspace_id_workspaces_id_fk": { + "name": "workspace_members_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_members", + "tableTo": "workspaces", + "schemaTo": "thread_chat", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_members_user_id_user_id_fk": { + "name": "workspace_members_user_id_user_id_fk", + "tableFrom": "workspace_members", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_members_pk": { + "name": "workspace_members_pk", + "columns": [ + "workspace_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.workspaces": { + "name": "workspaces", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspaces_lifecycle_idx": { + "name": "workspaces_lifecycle_idx", + "columns": [ + { + "expression": "lifecycle", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.account": { + "name": "account", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.session": { + "name": "session", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.user": { + "name": "user", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.verification": { + "name": "verification", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.usage_records": { + "name": "usage_records", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "app_generation_id": { + "name": "app_generation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_micros": { + "name": "cost_micros", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "price_micros": { + "name": "price_micros", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "generation_id": { + "name": "generation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cost_source": { + "name": "cost_source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'estimate'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_records_user_id_idx": { + "name": "usage_records_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_records_thread_id_idx": { + "name": "usage_records_thread_id_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_records_cost_source_idx": { + "name": "usage_records_cost_source_idx", + "columns": [ + { + "expression": "cost_source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_records_app_generation_id_uq": { + "name": "usage_records_app_generation_id_uq", + "columns": [ + { + "expression": "app_generation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_records_user_id_user_id_fk": { + "name": "usage_records_user_id_user_id_fk", + "tableFrom": "usage_records", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.user_credits": { + "name": "user_credits", + "schema": "thread_chat", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "balance_micros": { + "name": "balance_micros", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_credits_user_id_user_id_fk": { + "name": "user_credits_user_id_user_id_fk", + "tableFrom": "user_credits", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.payments": { + "name": "payments", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'creem'" + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pack_id": { + "name": "pack_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checkout_id": { + "name": "checkout_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order_id": { + "name": "order_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "credit_micros": { + "name": "credit_micros", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "price_label": { + "name": "price_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "raw": { + "name": "raw", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "payments_user_id_idx": { + "name": "payments_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payments_provider_order_id_uq": { + "name": "payments_provider_order_id_uq", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payments_user_id_user_id_fk": { + "name": "payments_user_id_user_id_fk", + "tableFrom": "payments", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "thread_chat.subscriptions": { + "name": "subscriptions", + "schema": "thread_chat", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'creem'" + }, + "subscription_id": { + "name": "subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_period_end": { + "name": "current_period_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "raw": { + "name": "raw", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscriptions_user_id_idx": { + "name": "subscriptions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "subscriptions_user_id_user_id_fk": { + "name": "subscriptions_user_id_user_id_fk", + "tableFrom": "subscriptions", + "tableTo": "user", + "schemaTo": "thread_chat", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "subscriptions_subscription_id_unique": { + "name": "subscriptions_subscription_id_unique", + "nullsNotDistinct": false, + "columns": [ + "subscription_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 684ab00e..19e680a4 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -85,6 +85,20 @@ "when": 1787342117009, "tag": "0011_swift_taskmaster", "breakpoints": true + }, + { + "idx": 12, + "version": "7", + "when": 1787344456848, + "tag": "0012_needy_colonel_america", + "breakpoints": true + }, + { + "idx": 13, + "version": "7", + "when": 1787344507672, + "tag": "0013_jazzy_tiger_shark", + "breakpoints": true } ] } \ No newline at end of file diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 089c77c4..ad577d88 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -263,6 +263,45 @@ export const conversationMessages = dbSchema.table( ] ) +/** 规范 Message 所引用的 Artifact;来源使用稳定 Conversation/Thread/Message ID。 */ +export const conversationArtifacts = dbSchema.table( + "conversation_artifacts", + { + id: text("id").primaryKey(), + conversationId: text("conversation_id") + .notNull() + .references(() => conversations.id, { onDelete: "cascade" }), + sourceThreadId: text("source_thread_id").notNull(), + sourceMessageId: text("source_message_id").notNull(), + title: text("title").notNull(), + kind: text("kind").notNull(), + lang: text("lang"), + content: text("content").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => [ + index("conversation_artifacts_conversation_idx").on( + table.conversationId, + table.createdAt + ), + foreignKey({ + name: "conversation_artifacts_thread_fk", + columns: [table.sourceThreadId, table.conversationId], + foreignColumns: [ + conversationThreads.id, + conversationThreads.conversationId, + ], + }).onDelete("cascade"), + foreignKey({ + name: "conversation_artifacts_message_fk", + columns: [table.sourceMessageId, table.sourceThreadId], + foreignColumns: [conversationMessages.id, conversationMessages.threadId], + }).onDelete("cascade"), + ] +) + export const conversationMessageFeedback = dbSchema.table( "conversation_message_feedback", { @@ -487,6 +526,35 @@ export const conversationOutboxEvents = dbSchema.table( ] ) +/** + * 一次性 ThreadTree → Conversation 导入的稳定身份账本。 + * 映射本身是迁移审计事实;运行时业务代码不得用它做 legacy 读取回退。 + */ +export const legacyConversationEntityMappings = dbSchema.table( + "legacy_conversation_entity_mappings", + { + legacyTreeId: text("legacy_tree_id").notNull(), + entityType: text("entity_type").notNull(), + localId: text("local_id").notNull(), + canonicalId: text("canonical_id").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => [ + primaryKey({ + name: "legacy_conversation_entity_mappings_pk", + columns: [table.legacyTreeId, table.entityType, table.localId], + }), + uniqueIndex("legacy_conversation_entity_mappings_canonical_uq").on( + table.canonicalId + ), + index("legacy_conversation_entity_mappings_tree_idx").on( + table.legacyTreeId + ), + ] +) + // 分支对话树(app/thread-chat)的整棵树持久化:一棵树一行,state 存完整 // ThreadTreeState(JSON)。与上面 assistant-ui 线性模型的 threads/messages 表分开, // 互不复用——那两张表是线性会话,这张是树形分支态。treeId 由客户端生成 diff --git a/lib/thread-chat-generation/tree-repository.ts b/lib/thread-chat-generation/tree-repository.ts index 58e9f91c..2946d4c1 100644 --- a/lib/thread-chat-generation/tree-repository.ts +++ b/lib/thread-chat-generation/tree-repository.ts @@ -64,6 +64,27 @@ export async function listOwnedTreeSummaries( .limit(100) } +/** 维护窗口使用的纯读取路径:只读当前用户已拥有的数据,绝不认领历史无主行。 */ +export async function loadOwnedTree(input: { + userId: string + treeId: string +}): Promise { + const [owned] = await db + .select({ + state: branchTrees.state, + customTitle: branchTrees.customTitle, + revision: branchTrees.revision, + }) + .from(branchTrees) + .where( + and( + eq(branchTrees.id, input.treeId), + eq(branchTrees.userId, input.userId) + ) + ) + return owned ?? null +} + /** GET 精确 URL 的迁移入口:普通 owner 读取,或原子认领一棵历史无主树。 */ export async function loadOwnedOrClaimLegacyTree(input: { userId: string diff --git a/lib/thread-chat/application/conversation-command-contracts.ts b/lib/thread-chat/application/conversation-command-contracts.ts index cb85b390..1c00862d 100644 --- a/lib/thread-chat/application/conversation-command-contracts.ts +++ b/lib/thread-chat/application/conversation-command-contracts.ts @@ -55,6 +55,7 @@ export type ConversationCommandErrorCode = | "fork_required" | "conversation_action_required" | "rate_limited" + | "maintenance" | "internal" export interface ConversationCommandErrorDetails { diff --git a/lib/thread-chat/client/conversation-client-contracts.ts b/lib/thread-chat/client/conversation-client-contracts.ts index 5ce6cf2b..63d34d7d 100644 --- a/lib/thread-chat/client/conversation-client-contracts.ts +++ b/lib/thread-chat/client/conversation-client-contracts.ts @@ -22,6 +22,15 @@ const contentState = z.enum([ "failed", ]) +export const conversationAuthorityStateSchema = z + .object({ + authority: z.enum(["legacy", "canonical"]), + schemaVersion: z.number().int().positive(), + epoch: z.string().trim().min(1), + maintenanceMode: z.enum(["off", "read-only"]), + }) + .strict() + const jsonValueSchema: z.ZodType = z.lazy(() => z.union([ z.string(), diff --git a/lib/thread-chat/client/conversation-client-gateway.ts b/lib/thread-chat/client/conversation-client-gateway.ts index 0049307a..b4f2940f 100644 --- a/lib/thread-chat/client/conversation-client-gateway.ts +++ b/lib/thread-chat/client/conversation-client-gateway.ts @@ -25,6 +25,7 @@ import type { import { conversationErrorEnvelopeSchema, conversationListQueryDataSchema, + conversationAuthorityStateSchema, generationQueryDataSchema, parseCommandSuccess, parseConversationQuery, @@ -83,6 +84,11 @@ export interface CommandOptions { } export interface ConversationClientGateway { + readonly verifyAuthority: (expected: { + readonly authority: "canonical" + readonly schemaVersion: number + readonly epoch: string + }) => Promise readonly listConversations: ( projectId: ProjectId, includeArchived?: boolean @@ -330,6 +336,25 @@ export function createConversationClientGateway(input: { } return { + async verifyAuthority(expected) { + const response = await fetcher("/api/conversation-authority", { + cache: "no-store", + }) + const value = await responseJson(response) + if (!response.ok) throw responseError(value, response) + const actual = conversationAuthorityStateSchema.parse(value) + if ( + actual.authority !== expected.authority || + actual.schemaVersion !== expected.schemaVersion || + actual.epoch !== expected.epoch + ) + throw new ConversationClientError({ + code: "authority_mismatch", + message: "客户端与服务端的 Conversation 权威版本不一致,请刷新页面", + status: 409, + details: { expected, actual }, + }) + }, async listConversations(targetProjectId, includeArchived = false) { const response = await fetcher( `/api/projects/${targetProjectId}/conversations?includeArchived=${includeArchived}` diff --git a/lib/thread-chat/client/normalized-conversation-store.test.ts b/lib/thread-chat/client/normalized-conversation-store.test.ts index 1285816b..c6eeafc0 100644 --- a/lib/thread-chat/client/normalized-conversation-store.test.ts +++ b/lib/thread-chat/client/normalized-conversation-store.test.ts @@ -601,6 +601,45 @@ test("client gateway 使用最小 revision,并以同一幂等键确认网络 assert.equal(store.getCommandState().pendingByCommandId[commandId], undefined) }) +test("client gateway 在加载实体前校验 authority、schema 与 cutover epoch", async () => { + const store = createNormalizedConversationStore() + const responses = [ + Response.json({ + authority: "canonical", + schemaVersion: 1, + epoch: "epoch-a", + maintenanceMode: "off", + }), + Response.json({ + authority: "canonical", + schemaVersion: 1, + epoch: "epoch-b", + maintenanceMode: "off", + }), + ] + const gateway = createConversationClientGateway({ + store, + fetch: async () => responses.shift() ?? Response.error(), + }) + + await gateway.verifyAuthority({ + authority: "canonical", + schemaVersion: 1, + epoch: "epoch-a", + }) + await assert.rejects( + gateway.verifyAuthority({ + authority: "canonical", + schemaVersion: 1, + epoch: "epoch-a", + }), + (error) => + error instanceof ConversationClientError && + error.code === "authority_mismatch" && + error.details?.actual !== undefined + ) +}) + test("client gateway 遇到 409 后重取快照并保留失败 overlay", async () => { const store = createNormalizedConversationStore() store.installSnapshot(fixture()) diff --git a/lib/thread-chat/cutover/conversation-authority.test.ts b/lib/thread-chat/cutover/conversation-authority.test.ts new file mode 100644 index 00000000..e34ceb60 --- /dev/null +++ b/lib/thread-chat/cutover/conversation-authority.test.ts @@ -0,0 +1,106 @@ +import assert from "node:assert/strict" +import test from "node:test" + +import { + legacyProtocolGate, + resolveConversationAuthority, +} from "./conversation-authority.ts" + +test("authority 缺失、未知和遗留分裂配置均启动失败", () => { + assert.throws(() => resolveConversationAuthority({})) + assert.throws(() => + resolveConversationAuthority({ CONVERSATION_AUTHORITY: "mixed" }) + ) + assert.throws(() => + resolveConversationAuthority({ + CONVERSATION_AUTHORITY: "canonical", + CONVERSATION_CUTOVER_EPOCH: "epoch-1", + CONVERSATION_CLIENT_AUTHORITY: "canonical", + }) + ) + assert.throws(() => + resolveConversationAuthority({ + CONVERSATION_AUTHORITY: "canonical", + CONVERSATION_CUTOVER_EPOCH: "epoch-1", + CONVERSATION_PERSISTENCE_WRITE_MODE: "canonical", + }) + ) +}) + +test("canonical 必须携带 epoch,且生产不能使用 isolated test", () => { + assert.throws(() => + resolveConversationAuthority({ CONVERSATION_AUTHORITY: "canonical" }) + ) + assert.throws(() => + resolveConversationAuthority({ + NODE_ENV: "production", + CONVERSATION_AUTHORITY: "canonical", + CONVERSATION_CUTOVER_EPOCH: "epoch-1", + CONVERSATION_ISOLATED_TEST: "true", + }) + ) + assert.deepEqual( + resolveConversationAuthority({ + NODE_ENV: "test", + CONVERSATION_AUTHORITY: "canonical", + CONVERSATION_CUTOVER_EPOCH: "epoch-1", + CONVERSATION_ISOLATED_TEST: "true", + }), + { + authority: "canonical", + schemaVersion: 1, + epoch: "epoch-1", + maintenanceMode: "off", + isolatedTest: true, + } + ) +}) + +test("legacy 与 canonical 都只能解析为一个完整部署状态", () => { + assert.equal( + resolveConversationAuthority({ CONVERSATION_AUTHORITY: "legacy" }) + .authority, + "legacy" + ) + assert.equal( + resolveConversationAuthority({ + CONVERSATION_AUTHORITY: "canonical", + CONVERSATION_CUTOVER_EPOCH: "2026-08-22T00", + CONVERSATION_MAINTENANCE_MODE: "read-only", + }).maintenanceMode, + "read-only" + ) +}) + +test("legacy gate 在 canonical 返回 410,在维护窗口只拒绝会产生新业务事实的 mutation", async () => { + const canonical = legacyProtocolGate( + { mutation: false }, + { + CONVERSATION_AUTHORITY: "canonical", + CONVERSATION_CUTOVER_EPOCH: "epoch-1", + } + ) + assert.equal(canonical?.status, 410) + assert.equal((await canonical?.json()).error.code, "legacy_protocol_retired") + assert.equal( + legacyProtocolGate( + { mutation: false }, + { + CONVERSATION_AUTHORITY: "legacy", + CONVERSATION_MAINTENANCE_MODE: "read-only", + } + ), + null + ) + // 查询与 Stop/排空均使用 mutation=false;切 canonical 后同样会得到上面的 410。 + assert.equal( + legacyProtocolGate( + { mutation: true }, + { + CONVERSATION_AUTHORITY: "legacy", + CONVERSATION_MAINTENANCE_MODE: "read-only", + } + )?.status, + 503 + ) +}) diff --git a/lib/thread-chat/cutover/conversation-authority.ts b/lib/thread-chat/cutover/conversation-authority.ts new file mode 100644 index 00000000..5e7d7f45 --- /dev/null +++ b/lib/thread-chat/cutover/conversation-authority.ts @@ -0,0 +1,112 @@ +export const CANONICAL_CONVERSATION_SCHEMA_VERSION = 1 as const + +export type ConversationAuthority = "legacy" | "canonical" +export type ConversationMaintenanceMode = "off" | "read-only" + +export interface ConversationAuthorityState { + readonly authority: ConversationAuthority + readonly schemaVersion: typeof CANONICAL_CONVERSATION_SCHEMA_VERSION + readonly epoch: string + readonly maintenanceMode: ConversationMaintenanceMode + /** 仅本地/测试允许放宽生产保护;它不是第三种 authority。 */ + readonly isolatedTest: boolean +} + +type AuthorityEnvironment = Readonly> + +const RETIRED_AUTHORITY_VARIABLES = [ + "CONVERSATION_COMMAND_API_AUTHORITY", + "CONVERSATION_GENERATION_AUTHORITY", + "CONVERSATION_CLIENT_AUTHORITY", + "BRANCH_TREE_AUTHORITY_ENABLED", + "BRANCH_GENERATION_AUTHORITY_ENABLED", + "CONVERSATION_PERSISTENCE_WRITE_MODE", + "THREAD_TREE_LEGACY_WRITES_ENABLED", +] as const + +/** 单一部署事实:客户端、命令、Generation 和 legacy 写权限必须来自同一个值。 */ +export function resolveConversationAuthority( + environment: AuthorityEnvironment = process.env +): ConversationAuthorityState { + const retired = RETIRED_AUTHORITY_VARIABLES.filter( + (name) => environment[name] !== undefined + ) + if (retired.length > 0) + throw new Error(`已退役的 authority 配置仍存在:${retired.join(", ")}`) + + const value = environment.CONVERSATION_AUTHORITY?.trim() + if (value !== "legacy" && value !== "canonical") + throw new Error("CONVERSATION_AUTHORITY 必须显式设置为 legacy 或 canonical") + + const maintenance = environment.CONVERSATION_MAINTENANCE_MODE?.trim() + if (maintenance && maintenance !== "off" && maintenance !== "read-only") + throw new Error("CONVERSATION_MAINTENANCE_MODE 必须是 off 或 read-only") + + const isolatedTest = + environment.CONVERSATION_ISOLATED_TEST === "true" && + environment.NODE_ENV !== "production" + if ( + environment.CONVERSATION_ISOLATED_TEST === "true" && + environment.NODE_ENV === "production" + ) + throw new Error("生产环境不能启用 CONVERSATION_ISOLATED_TEST") + + const epoch = environment.CONVERSATION_CUTOVER_EPOCH?.trim() + if (value === "canonical" && !epoch) + throw new Error("canonical authority 必须设置 CONVERSATION_CUTOVER_EPOCH") + + return { + authority: value, + schemaVersion: CANONICAL_CONVERSATION_SCHEMA_VERSION, + epoch: epoch || "legacy-pre-cutover", + maintenanceMode: maintenance === "read-only" ? "read-only" : "off", + isolatedTest, + } +} + +export function legacyProtocolRetiredResponse(input: { + readonly protocol: string + readonly epoch: string +}): Response { + console.warn("[conversation-cutover] legacy_protocol_retired", input) + return Response.json( + { + error: { + code: "legacy_protocol_retired", + message: "ThreadTree 协议已退役,请刷新并使用规范 Conversation 客户端", + }, + }, + { status: 410 } + ) +} + +export function conversationMaintenanceResponse(): Response { + return Response.json( + { + error: { + code: "conversation_maintenance", + message: "Conversation 正处于受控维护窗口,请稍后重试", + }, + }, + { status: 503, headers: { "Retry-After": "30" } } + ) +} + +/** 认证与 ID 形状检查之后调用,避免 retired 响应成为资源探测旁路。 */ +export function legacyProtocolGate( + input: { + readonly mutation: boolean + readonly protocol?: string + }, + environment: AuthorityEnvironment = process.env +): Response | null { + const state = resolveConversationAuthority(environment) + if (state.authority === "canonical") + return legacyProtocolRetiredResponse({ + protocol: input.protocol ?? "unknown", + epoch: state.epoch, + }) + if (input.mutation && state.maintenanceMode === "read-only") + return conversationMaintenanceResponse() + return null +} diff --git a/lib/thread-chat/cutover/conversation-drain.test.ts b/lib/thread-chat/cutover/conversation-drain.test.ts new file mode 100644 index 00000000..5a64f08a --- /dev/null +++ b/lib/thread-chat/cutover/conversation-drain.test.ts @@ -0,0 +1,34 @@ +import assert from "node:assert/strict" +import test from "node:test" + +import { evaluateConversationCutoverDrain } from "./conversation-drain.ts" + +test("drain 仅在所有可能继续产生事实的队列清零后放行", () => { + const ready = evaluateConversationCutoverDrain({ + legacyActiveGenerations: 0, + legacyPendingBilling: 0, + canonicalActiveGenerations: 0, + canonicalPendingBilling: 0, + canonicalPendingOutbox: 0, + }) + assert.equal(ready.ready, true) + assert.deepEqual(ready.blockers, []) + + const blocked = evaluateConversationCutoverDrain({ + legacyActiveGenerations: 2, + legacyPendingBilling: 1, + canonicalActiveGenerations: 0, + canonicalPendingBilling: 3, + canonicalPendingOutbox: 4, + }) + assert.equal(blocked.ready, false) + assert.deepEqual( + blocked.blockers.map((entry) => entry.code), + [ + "legacy_generation_active", + "legacy_billing_pending", + "canonical_billing_pending", + "canonical_outbox_pending", + ] + ) +}) diff --git a/lib/thread-chat/cutover/conversation-drain.ts b/lib/thread-chat/cutover/conversation-drain.ts new file mode 100644 index 00000000..875b634b --- /dev/null +++ b/lib/thread-chat/cutover/conversation-drain.ts @@ -0,0 +1,43 @@ +export interface ConversationDrainCounts { + readonly legacyActiveGenerations: number + readonly legacyPendingBilling: number + readonly canonicalActiveGenerations: number + readonly canonicalPendingBilling: number + readonly canonicalPendingOutbox: number +} + +export type ConversationDrainBlockerCode = + | "legacy_generation_active" + | "legacy_billing_pending" + | "canonical_generation_active" + | "canonical_billing_pending" + | "canonical_outbox_pending" + +export interface ConversationDrainReport { + readonly ready: boolean + readonly counts: ConversationDrainCounts + readonly blockers: readonly { + readonly code: ConversationDrainBlockerCode + readonly count: number + }[] +} + +/** + * Cutover 的纯门禁判定。任何非终态 Generation、未终结计费或未派发 outbox + * 都代表系统仍可能产生新事实,因此不能切换 authority。 + */ +export function evaluateConversationCutoverDrain( + counts: ConversationDrainCounts +): ConversationDrainReport { + const candidates: readonly [ConversationDrainBlockerCode, number][] = [ + ["legacy_generation_active", counts.legacyActiveGenerations], + ["legacy_billing_pending", counts.legacyPendingBilling], + ["canonical_generation_active", counts.canonicalActiveGenerations], + ["canonical_billing_pending", counts.canonicalPendingBilling], + ["canonical_outbox_pending", counts.canonicalPendingOutbox], + ] + const blockers = candidates + .filter(([, count]) => count > 0) + .map(([code, count]) => ({ code, count })) + return { ready: blockers.length === 0, counts, blockers } +} diff --git a/lib/thread-chat/cutover/legacy-conversation-import.test.ts b/lib/thread-chat/cutover/legacy-conversation-import.test.ts new file mode 100644 index 00000000..2b7a1d31 --- /dev/null +++ b/lib/thread-chat/cutover/legacy-conversation-import.test.ts @@ -0,0 +1,183 @@ +import assert from "node:assert/strict" +import test from "node:test" + +import { THREAD_TREE_SCHEMA_VERSION } from "../../../constants/thread-chat.ts" +import type { Message, ThreadTreeState } from "../domain/types.ts" +import { + conversationId, + projectId, + workspaceId, +} from "../domain/conversation-model.ts" +import { projectLegacyThreadTree } from "../legacy/project-thread-tree.ts" +import { buildLegacyConversationImportPlan } from "./legacy-conversation-import.ts" + +function message( + id: string, + parentMessageId: string | null, + role: "user" | "assistant", + text: string +): Message { + return { id, parentMessageId, role, text, forks: [] } +} + +function state(): ThreadTreeState { + const mainUser = message("a-user", null, "user", "A") + const mainAssistant = message("a-assistant", "a-user", "assistant", "A答") + mainAssistant.forks.push({ + text: "A答", + num: 1, + threadId: "b", + depth: 1, + }) + const bUser = message("b-user", null, "user", "B") + const bAssistant = message("b-assistant", "b-user", "assistant", "B答") + bAssistant.forks.push({ text: "B答", num: 2, threadId: "c", depth: 2 }) + const cUser = message("c-user", null, "user", "C") + const cAssistant = { + ...message("c-assistant", "c-user", "assistant", "C partial"), + status: "error" as const, + artifactIds: ["artifact-c"], + } + return { + schemaVersion: THREAD_TREE_SCHEMA_VERSION, + threads: { + main: { + id: "main", + modelId: "glm-5.3", + parentId: null, + depth: 0, + title: "A", + anchorText: null, + forkFromMsgId: null, + footnote: null, + children: ["b"], + messages: [mainUser, mainAssistant], + activeLeafMessageId: "a-assistant", + lastActive: 1, + }, + b: { + id: "b", + modelId: "glm-5.3", + parentId: "main", + depth: 1, + title: "B", + anchorText: "A答", + forkFromMsgId: "a-assistant", + footnote: 1, + children: ["c"], + messages: [bUser, bAssistant], + activeLeafMessageId: "b-assistant", + lastActive: 2, + }, + c: { + id: "c", + modelId: "glm-5.3", + parentId: "b", + depth: 2, + title: "C", + anchorText: "B答", + forkFromMsgId: "b-assistant", + footnote: 2, + children: [], + messages: [cUser, cAssistant], + activeLeafMessageId: "c-assistant", + lastActive: 3, + }, + }, + artifacts: { + "artifact-c": { + id: "artifact-c", + title: "C 结论", + kind: "markdown", + content: "# C 结论", + sourceThreadId: "c", + sourceMessageId: "c-assistant", + }, + }, + artifactOrder: ["artifact-c"], + recents: [], + footnoteCounter: 2, + seq: 6, + tick: 3, + } +} + +test("导入计划确定性拆分 A → B → C、partial Generation、Artifact 与反馈", () => { + const now = new Date("2026-08-22T00:00:00.000Z") + const input = { + treeId: "tree-import", + ownerUserId: "owner-import", + title: "自动标题", + customTitle: "归档标题", + state: state(), + generations: [ + { + id: "generation-c", + userId: "owner-import", + threadId: "c", + userMessageId: "c-user", + assistantMessageId: "c-assistant", + attempt: 1, + isCurrent: true, + status: "stopped" as const, + modelId: "glm-5.3", + intent: { kind: "persisted-turn" as const }, + result: { + version: 1 as const, + generationId: "generation-c", + text: "C partial", + status: "error" as const, + artifactIds: ["artifact-c"], + artifacts: {}, + usage: { inputTokens: 10, outputTokens: 5 }, + }, + billingStatus: "settled" as const, + heartbeatAt: now, + stopRequestedAt: now, + finishedAt: now, + createdAt: now, + updatedAt: now, + error: "stopped", + }, + ], + feedback: [ + { + userId: "owner-import", + threadId: "c", + messageId: "c-assistant", + feedback: "positive" as const, + createdAt: now, + updatedAt: now, + }, + ], + } + + projectLegacyThreadTree({ + legacyTreeId: input.treeId, + workspaceId: workspaceId("workspace-test"), + projectId: projectId("project-test"), + conversationId: conversationId("conversation-test"), + projectTitle: "测试", + conversationAutoTitle: null, + conversationCustomTitle: null, + actorId: input.ownerUserId, + state: input.state, + }) + + const first = buildLegacyConversationImportPlan(input) + const second = buildLegacyConversationImportPlan(input) + assert.deepEqual(first, second) + assert.equal(Object.keys(first.snapshot.threads).length, 3) + assert.equal(Object.keys(first.snapshot.threadForks).length, 2) + assert.equal(first.artifacts[0]?.content, "# C 结论") + assert.equal(first.generations[0]?.checkpoint.body, "C partial") + assert.equal(first.generations[0]?.checkpoint.artifactIds.length, 1) + assert.equal(first.generations[0]?.billingStatus, "settled") + assert.equal(first.feedback[0]?.feedback, "positive") + assert.ok( + first.mappings.some( + (entry) => + entry.entityType === "generation" && entry.localId === "generation-c" + ) + ) +}) diff --git a/lib/thread-chat/cutover/legacy-conversation-import.ts b/lib/thread-chat/cutover/legacy-conversation-import.ts new file mode 100644 index 00000000..65001f48 --- /dev/null +++ b/lib/thread-chat/cutover/legacy-conversation-import.ts @@ -0,0 +1,443 @@ +import type { GenerationResultV1 } from "../contracts/generation-result.ts" +import type { ThreadChatGenerationIntent } from "../contracts/generation-intent.ts" +import { + conversationId, + projectId, + workspaceId, + type ConversationSnapshot, + type JsonValue, +} from "../domain/conversation-model.ts" +import type { + ConversationGenerationCheckpoint, + KnownGenerationUsage, +} from "../domain/conversation-generation.ts" +import { + checkpointMessageContent, + hasRecoverableCheckpointOutput, +} from "../domain/conversation-generation.ts" +import { parseThreadTreeState } from "../domain/message-graph.ts" +import type { MessageFeedback, ThreadTreeState } from "../domain/types.ts" +import { assertValidConversationSnapshot } from "../domain/conversation-validation.ts" +import { auditLegacyConversation } from "../legacy/audit-thread-tree.ts" +import { + createLegacyIdMap, + projectLegacyThreadTree, +} from "../legacy/project-thread-tree.ts" + +export type LegacyImportEntityType = + | "conversation" + | "thread" + | "fork" + | "turn" + | "message" + | "generation" + | "artifact" + +export interface LegacyImportMapping { + readonly legacyTreeId: string + readonly entityType: LegacyImportEntityType + readonly localId: string + readonly canonicalId: string +} + +export interface LegacyImportGeneration { + readonly id: string + readonly userId: string + readonly threadId: string + readonly userMessageId: string + readonly assistantMessageId: string + readonly attempt: number + readonly isCurrent: boolean + readonly status: + | "running" + | "stop_requested" + | "completed" + | "stopped" + | "failed" + | "superseded" + readonly modelId: string + readonly intent: ThreadChatGenerationIntent + readonly result: GenerationResultV1 | null + readonly billingStatus: + "pending" | "settled" | "usage_unavailable" | "not_billable" + readonly heartbeatAt: Date + readonly stopRequestedAt: Date | null + readonly finishedAt: Date | null + readonly createdAt: Date + readonly updatedAt: Date + readonly error: string | null +} + +export interface LegacyImportFeedback { + readonly userId: string + readonly threadId: string + readonly messageId: string + readonly feedback: MessageFeedback + readonly createdAt: Date + readonly updatedAt: Date +} + +export interface LegacyConversationImportPlan { + readonly legacyTreeId: string + readonly ownerUserId: string + readonly snapshot: ConversationSnapshot + readonly mappings: readonly LegacyImportMapping[] + readonly artifacts: readonly { + readonly id: string + readonly conversationId: string + readonly sourceThreadId: string + readonly sourceMessageId: string + readonly title: string + readonly kind: string + readonly lang: string | null + readonly content: string + }[] + readonly generations: readonly { + readonly id: string + readonly ownerId: string + readonly workspaceId: string + readonly projectId: string + readonly conversationId: string + readonly threadId: string + readonly turnId: string + readonly inputMessageId: string + readonly outputMessageId: string + readonly intent: JsonValue + readonly requestHash: string + readonly idempotencyKey: string + readonly modelId: string + readonly attempt: number + readonly isCurrent: boolean + readonly status: LegacyImportGeneration["status"] + readonly contentState: string + readonly checkpointVersion: number + readonly checkpoint: ConversationGenerationCheckpoint + readonly knownUsage: KnownGenerationUsage | null + readonly usageCompleteness: "complete" | "unavailable" + readonly billingStatus: LegacyImportGeneration["billingStatus"] + readonly paidCallStarted: boolean + readonly heartbeatAt: Date + readonly stopRequestedAt: Date | null + readonly startedAt: Date + readonly finishedAt: Date | null + readonly errorCode: string | null + readonly createdAt: Date + readonly updatedAt: Date + }[] + readonly feedback: readonly { + readonly userId: string + readonly conversationId: string + readonly threadId: string + readonly messageId: string + readonly feedback: MessageFeedback + readonly createdAt: Date + readonly updatedAt: Date + }[] +} + +function encoded(value: string): string { + return encodeURIComponent(value) +} + +function canonicalGenerationId(treeId: string, generationId: string): string { + return `legacy:${encoded(treeId)}:generation:${encoded(generationId)}` +} + +function canonicalIntent( + intent: ThreadChatGenerationIntent, + message: (localId: string) => string +): JsonValue { + switch (intent.kind) { + case "persisted-turn": + return { kind: "send" } + case "regenerate-assistant": + return { + kind: "regenerate-assistant", + sourceAssistantMessageId: message(intent.sourceAssistantMessageId), + } + case "edit-last-user": + return { + kind: "edit-user", + sourceUserMessageId: message(intent.sourceUserMessageId), + } + case "retry-orphan-user": + return { kind: "retry" } + } +} + +function knownUsage( + result: GenerationResultV1 | null +): KnownGenerationUsage | null { + return result?.usage + ? { + inputTokens: result.usage.inputTokens, + outputTokens: result.usage.outputTokens, + paidStepCount: 1, + reportedStepCount: 1, + } + : null +} + +function checkpoint( + result: GenerationResultV1 | null, + mapArtifact: (localId: string) => string, + usage: KnownGenerationUsage | null +): ConversationGenerationCheckpoint { + return { + schemaVersion: 1, + body: result?.text ?? "", + artifactIds: (result?.artifactIds ?? []).map(mapArtifact), + researchPlan: (result?.researchPlan as JsonValue | undefined) ?? null, + researchActivities: (result?.webResearch ?? []).map((activity) => ({ + id: activity.toolCallId, + kind: activity.kind, + status: activity.status, + sources: activity.sources, + })), + contentState: + result?.status === "done" + ? "complete" + : result?.text || result?.artifactIds.length + ? "incomplete" + : result?.status === "error" + ? "failed" + : "pending", + knownUsage: usage, + } +} + +/** 只生成确定计划,不写数据库;执行器必须在单 Conversation 事务中消费它。 */ +export function buildLegacyConversationImportPlan(input: { + readonly treeId: string + readonly ownerUserId: string + readonly title: string | null + readonly customTitle: string | null + readonly state: ThreadTreeState + readonly generations: readonly LegacyImportGeneration[] + readonly feedback: readonly LegacyImportFeedback[] +}): LegacyConversationImportPlan { + const audit = auditLegacyConversation({ + treeId: input.treeId, + ownerUserId: input.ownerUserId, + state: input.state, + generations: input.generations.map((entry) => ({ + id: entry.id, + threadId: entry.threadId, + userMessageId: entry.userMessageId, + assistantMessageId: entry.assistantMessageId, + })), + feedback: input.feedback.map((entry) => ({ + threadId: entry.threadId, + messageId: entry.messageId, + })), + }) + if (audit.disposition !== "migratable") + throw new Error( + `Legacy tree ${input.treeId} 不可导入:${audit.issues + .map((entry) => entry.code) + .join(", ")}` + ) + + const targetWorkspaceId = workspaceId( + `legacy-workspace:${encoded(input.ownerUserId)}` + ) + const targetProjectId = projectId( + `legacy-project:${encoded(input.ownerUserId)}` + ) + const targetConversationId = conversationId(`legacy:${encoded(input.treeId)}`) + const ids = createLegacyIdMap({ + legacyTreeId: input.treeId, + conversationId: targetConversationId, + }) + const projectedSnapshot = projectLegacyThreadTree({ + legacyTreeId: input.treeId, + workspaceId: targetWorkspaceId, + projectId: targetProjectId, + conversationId: targetConversationId, + projectTitle: "已导入的 Thread Chat", + conversationAutoTitle: input.title, + conversationCustomTitle: input.customTitle, + actorId: input.ownerUserId, + state: input.state, + }) + const state = parseThreadTreeState(input.state) + const generationArtifacts = input.generations.flatMap((generation) => + generation.result ? Object.values(generation.result.artifacts) : [] + ) + const mergedArtifacts = new Map( + [...Object.values(state.artifacts), ...generationArtifacts].map( + (artifact) => [artifact.id, artifact] + ) + ) + const messages = { ...projectedSnapshot.messages } + for (const generation of input.generations) { + if (!generation.isCurrent) continue + const outputId = ids.message(generation.assistantMessageId) + const current = messages[outputId] + if (!current) + throw new Error(`Generation ${generation.id} 的输出 Message 不存在`) + const usage = knownUsage(generation.result) + const projectedCheckpoint = checkpoint( + generation.result, + ids.artifact, + usage + ) + const active = + generation.status === "running" || generation.status === "stop_requested" + const contentState = active + ? generation.result + ? "streaming" + : "pending" + : generation.status === "completed" + ? "complete" + : hasRecoverableCheckpointOutput(projectedCheckpoint) + ? "incomplete" + : "failed" + messages[outputId] = { + ...current, + content: checkpointMessageContent(projectedCheckpoint), + contentState, + } + } + const snapshot: ConversationSnapshot = { + ...projectedSnapshot, + messages, + artifactProvenance: Object.fromEntries( + [...mergedArtifacts.values()].map((artifact) => { + const id = ids.artifact(artifact.id) + return [ + id, + { + id, + sourceThreadId: ids.thread(artifact.sourceThreadId), + sourceMessageId: ids.message(artifact.sourceMessageId), + title: artifact.title, + kind: artifact.kind, + }, + ] + }) + ), + } + assertValidConversationSnapshot(snapshot) + + const mappings: LegacyImportMapping[] = [ + { + legacyTreeId: input.treeId, + entityType: "conversation", + localId: input.treeId, + canonicalId: targetConversationId, + }, + ...Object.values(state.threads).map((thread) => ({ + legacyTreeId: input.treeId, + entityType: "thread" as const, + localId: thread.id, + canonicalId: ids.thread(thread.id), + })), + ...Object.values(snapshot.turns).map((turn) => ({ + legacyTreeId: input.treeId, + entityType: "turn" as const, + localId: `${turn.threadId}:${turn.position}`, + canonicalId: turn.id, + })), + ...Object.values(state.threads).flatMap((thread) => + thread.messages.map((message) => ({ + legacyTreeId: input.treeId, + entityType: "message" as const, + localId: message.id, + canonicalId: ids.message(message.id), + })) + ), + ...Object.values(state.threads) + .filter((thread) => thread.id !== "main") + .map((thread) => ({ + legacyTreeId: input.treeId, + entityType: "fork" as const, + localId: thread.id, + canonicalId: ids.fork(thread.id), + })), + ...[...mergedArtifacts.values()].map((artifact) => ({ + legacyTreeId: input.treeId, + entityType: "artifact" as const, + localId: artifact.id, + canonicalId: ids.artifact(artifact.id), + })), + ...input.generations.map((generation) => ({ + legacyTreeId: input.treeId, + entityType: "generation" as const, + localId: generation.id, + canonicalId: canonicalGenerationId(input.treeId, generation.id), + })), + ] + + return { + legacyTreeId: input.treeId, + ownerUserId: input.ownerUserId, + snapshot, + mappings, + artifacts: [...mergedArtifacts.values()].map((artifact) => ({ + id: ids.artifact(artifact.id), + conversationId: targetConversationId, + sourceThreadId: ids.thread(artifact.sourceThreadId), + sourceMessageId: ids.message(artifact.sourceMessageId), + title: artifact.title, + kind: artifact.kind, + lang: artifact.lang ?? null, + content: artifact.content, + })), + generations: input.generations.map((generation) => { + const targetInputMessageId = ids.message(generation.userMessageId) + const targetOutputMessageId = ids.message(generation.assistantMessageId) + const targetMessage = snapshot.messages[targetInputMessageId] + const outputMessage = snapshot.messages[targetOutputMessageId] + if (!targetMessage || !outputMessage) + throw new Error(`Generation ${generation.id} 的 Message 映射缺失`) + const usage = knownUsage(generation.result) + return { + id: canonicalGenerationId(input.treeId, generation.id), + ownerId: input.ownerUserId, + workspaceId: targetWorkspaceId, + projectId: targetProjectId, + conversationId: targetConversationId, + threadId: ids.thread(generation.threadId), + turnId: targetMessage.turnId, + inputMessageId: targetInputMessageId, + outputMessageId: targetOutputMessageId, + intent: canonicalIntent(generation.intent, ids.message), + requestHash: `legacy-import:${generation.id}`, + idempotencyKey: `legacy-import:${input.treeId}:${generation.id}`, + modelId: generation.modelId, + attempt: generation.attempt, + // legacy 的 current 约束按 assistant Message,canonical 按 Turn;只有当前 + // active assistant 对应的 attempt 才能成为该 Turn 的 current Generation。 + isCurrent: + generation.isCurrent && + snapshot.turns[targetMessage.turnId]?.activeAssistantMessageId === + targetOutputMessageId, + status: generation.status, + contentState: outputMessage.contentState, + checkpointVersion: generation.result ? 1 : 0, + checkpoint: checkpoint(generation.result, ids.artifact, usage), + knownUsage: usage, + usageCompleteness: usage ? "complete" : "unavailable", + billingStatus: generation.billingStatus, + paidCallStarted: generation.billingStatus !== "not_billable", + heartbeatAt: generation.heartbeatAt, + stopRequestedAt: generation.stopRequestedAt, + startedAt: generation.createdAt, + finishedAt: generation.finishedAt, + errorCode: generation.error, + createdAt: generation.createdAt, + updatedAt: generation.updatedAt, + } + }), + feedback: input.feedback.map((entry) => ({ + userId: entry.userId, + conversationId: targetConversationId, + threadId: ids.thread(entry.threadId), + messageId: ids.message(entry.messageId), + feedback: entry.feedback, + createdAt: entry.createdAt, + updatedAt: entry.updatedAt, + })), + } +} diff --git a/lib/thread-chat/http/conversation-command-composition.ts b/lib/thread-chat/http/conversation-command-composition.ts index 3e2c3644..1f94fdb7 100644 --- a/lib/thread-chat/http/conversation-command-composition.ts +++ b/lib/thread-chat/http/conversation-command-composition.ts @@ -13,6 +13,8 @@ import { DrizzleConversationCommandStore } from "../persistence/drizzle-conversa import { DrizzleConversationGenerationRepository } from "../persistence/drizzle-conversation-generation-repository" import { DrizzleConversationOutboxDispatcher } from "../persistence/drizzle-conversation-outbox-dispatcher" import { resolveConversationCommandApiPolicy } from "../persistence/conversation-command-policy" +import { resolveCanonicalGenerationPolicy } from "../persistence/canonical-generation-policy" +import { resolveConversationAuthority } from "../cutover/conversation-authority" declare global { var __conversationCommandComposition: @@ -29,9 +31,7 @@ function createComposition() { const policy = resolveConversationCommandApiPolicy() const store = new DrizzleConversationCommandStore(policy) const generations = new DrizzleConversationGenerationRepository({ - authority: policy.authority, - legacyAuthorityEnabled: - process.env.BRANCH_GENERATION_AUTHORITY_ENABLED !== "false", + ...resolveCanonicalGenerationPolicy(), }) const abortRegistry = new InMemoryGenerationAbortRegistry() const execution = new CanonicalGenerationApplicationService( @@ -97,7 +97,7 @@ export function setConversationOutboxConsumerForIsolatedTest( consumer: OutboxEventConsumer | null ): void { if ( - process.env.CONVERSATION_COMMAND_API_AUTHORITY !== "isolated-test" || + !resolveConversationAuthority().isolatedTest || process.env.NODE_ENV === "production" ) throw new Error("只能在 isolated-test authority 下替换 outbox consumer") diff --git a/lib/thread-chat/http/conversation-command-http.test.ts b/lib/thread-chat/http/conversation-command-http.test.ts index 18673561..42fa27d5 100644 --- a/lib/thread-chat/http/conversation-command-http.test.ts +++ b/lib/thread-chat/http/conversation-command-http.test.ts @@ -1,6 +1,10 @@ import assert from "node:assert/strict" import test from "node:test" +process.env.CONVERSATION_AUTHORITY = "canonical" +process.env.CONVERSATION_CUTOVER_EPOCH = "command-http-test" +process.env.CONVERSATION_ISOLATED_TEST = "true" + import { commandSuccessTransportSchema, conversationErrorTransportSchema, diff --git a/lib/thread-chat/http/conversation-command-http.ts b/lib/thread-chat/http/conversation-command-http.ts index e70ebf1e..ad41dabc 100644 --- a/lib/thread-chat/http/conversation-command-http.ts +++ b/lib/thread-chat/http/conversation-command-http.ts @@ -15,6 +15,7 @@ import { } from "../application/conversation-command-contracts" import { CanonicalGenerationServiceError } from "../application/conversation-generation-service" import { InvalidEntityIdError } from "../domain/conversation-model" +import { resolveConversationAuthority } from "../cutover/conversation-authority" const ERROR_STATUS: Record = { invalid_request: 400, @@ -28,6 +29,7 @@ const ERROR_STATUS: Record = { fork_required: 422, semantic_validation: 422, rate_limited: 429, + maintenance: 503, internal: 500, } @@ -62,6 +64,15 @@ export async function parseJson( return parsed.data } +export function assertCanonicalMutationAllowed(): void { + if (resolveConversationAuthority().maintenanceMode === "read-only") + throw new ConversationCommandError( + "maintenance", + "Conversation 正处于受控维护窗口,请稍后重试", + { retryable: true } + ) +} + export function commandEnvelope(input: { readonly request: Request readonly actor: { readonly kind: "user"; readonly userId: string } @@ -69,6 +80,7 @@ export function commandEnvelope(input: { readonly payload: TPayload readonly expectedRevisionRequired?: boolean }): CommandEnvelope { + assertCanonicalMutationAllowed() const idempotencyKey = input.request.headers.get("Idempotency-Key")?.trim() if ( !idempotencyKey || diff --git a/lib/thread-chat/legacy/audit-thread-tree.test.ts b/lib/thread-chat/legacy/audit-thread-tree.test.ts index 2429d44f..bc9cde41 100644 --- a/lib/thread-chat/legacy/audit-thread-tree.test.ts +++ b/lib/thread-chat/legacy/audit-thread-tree.test.ts @@ -136,12 +136,20 @@ test("污染引用使用稳定错误码报告", () => { generations: [ { id: "generation-invalid", + ownerUserId: "other-owner", + intentPresent: false, threadId: "missing-thread", userMessageId: "missing-user", assistantMessageId: "missing-assistant", }, ], - feedback: [{ threadId: "main", messageId: "missing-feedback-message" }], + feedback: [ + { + ownerUserId: "other-owner", + threadId: "main", + messageId: "missing-feedback-message", + }, + ], }) const codes = new Set(report.issues.map((entry) => entry.code)) @@ -154,7 +162,10 @@ test("污染引用使用稳定错误码报告", () => { "fork_source_missing", "artifact_source_thread_missing", "generation_thread_missing", + "generation_owner_mismatch", + "generation_intent_missing", "feedback_message_missing", + "feedback_owner_mismatch", "canonical_projection_failed", ] as const) assert.ok(codes.has(code), `缺少稳定错误码 ${code}`) diff --git a/lib/thread-chat/legacy/audit-thread-tree.ts b/lib/thread-chat/legacy/audit-thread-tree.ts index 8f6e853f..26ab3767 100644 --- a/lib/thread-chat/legacy/audit-thread-tree.ts +++ b/lib/thread-chat/legacy/audit-thread-tree.ts @@ -23,8 +23,11 @@ export type LegacyConversationAuditCode = | "generation_thread_missing" | "generation_user_message_missing" | "generation_assistant_message_missing" + | "generation_owner_mismatch" + | "generation_intent_missing" | "feedback_thread_missing" | "feedback_message_missing" + | "feedback_owner_mismatch" | "canonical_projection_failed" export interface LegacyConversationAuditIssue { @@ -35,12 +38,15 @@ export interface LegacyConversationAuditIssue { export interface LegacyGenerationReference { readonly id: string + readonly ownerUserId?: string + readonly intentPresent?: boolean readonly threadId: string readonly userMessageId: string readonly assistantMessageId: string } export interface LegacyFeedbackReference { + readonly ownerUserId?: string readonly threadId: string readonly messageId: string } @@ -310,6 +316,23 @@ export function auditLegacyConversation( } for (const generation of input.generations) { + if ( + generation.ownerUserId !== undefined && + generation.ownerUserId !== input.ownerUserId + ) + issue( + issues, + "generation_owner_mismatch", + `generations.${generation.id}.userId`, + generation.id + ) + if (generation.intentPresent === false) + issue( + issues, + "generation_intent_missing", + `generations.${generation.id}.turnSnapshot.intent`, + generation.id + ) const threadMessages = messagesByThread.get(generation.threadId) if (!threadMessages) issue( @@ -337,6 +360,16 @@ export function auditLegacyConversation( } for (const [feedbackIndex, feedback] of input.feedback.entries()) { + if ( + feedback.ownerUserId !== undefined && + feedback.ownerUserId !== input.ownerUserId + ) + issue( + issues, + "feedback_owner_mismatch", + `feedback.${feedbackIndex}.userId`, + feedback.messageId + ) const threadMessages = messagesByThread.get(feedback.threadId) if (!threadMessages) issue( @@ -401,6 +434,7 @@ export function auditLegacyConversation( entry.code !== "owner_missing" && entry.code !== "generation_user_message_missing" && entry.code !== "generation_assistant_message_missing" && + entry.code !== "generation_intent_missing" && entry.code !== "feedback_message_missing" ) diff --git a/lib/thread-chat/legacy/project-thread-tree.ts b/lib/thread-chat/legacy/project-thread-tree.ts index f3cb387c..af3e4b6c 100644 --- a/lib/thread-chat/legacy/project-thread-tree.ts +++ b/lib/thread-chat/legacy/project-thread-tree.ts @@ -60,7 +60,7 @@ export interface ProjectLegacyThreadTreeInput { readonly state: ThreadTreeState } -interface LegacyIdMap { +export interface LegacyIdMap { readonly thread: (legacyId: string) => ThreadId readonly message: (legacyId: string) => MessageId readonly turn: (legacyThreadId: string, groupKey: string) => TurnId @@ -74,7 +74,7 @@ function legacySegment(value: string): string { return encodeURIComponent(value) } -function createLegacyIdMap(input: { +export function createLegacyIdMap(input: { readonly legacyTreeId: string readonly conversationId: ConversationId }): LegacyIdMap { diff --git a/lib/thread-chat/persistence/canonical-generation-policy.ts b/lib/thread-chat/persistence/canonical-generation-policy.ts index c8e3f2b7..742d6aca 100644 --- a/lib/thread-chat/persistence/canonical-generation-policy.ts +++ b/lib/thread-chat/persistence/canonical-generation-policy.ts @@ -1,4 +1,5 @@ import { CanonicalGenerationServiceError } from "../application/conversation-generation-service" +import { resolveConversationAuthority } from "../cutover/conversation-authority" export type CanonicalGenerationAuthority = "disabled" | "isolated-test" | "canonical" @@ -11,19 +12,15 @@ export interface CanonicalGenerationPolicy { export function resolveCanonicalGenerationPolicy( environment: NodeJS.ProcessEnv = process.env ): CanonicalGenerationPolicy { - const value = environment.CONVERSATION_GENERATION_AUTHORITY?.trim() - const authority: CanonicalGenerationAuthority = - !value || value === "disabled" - ? "disabled" - : value === "isolated-test" || value === "canonical" - ? value - : (() => { - throw new Error(`未知 CONVERSATION_GENERATION_AUTHORITY:${value}`) - })() + const deployment = resolveConversationAuthority(environment) return { - authority, - legacyAuthorityEnabled: - environment.BRANCH_GENERATION_AUTHORITY_ENABLED !== "false", + authority: + deployment.authority === "canonical" + ? deployment.isolatedTest + ? "isolated-test" + : "canonical" + : "disabled", + legacyAuthorityEnabled: deployment.authority === "legacy", } } diff --git a/lib/thread-chat/persistence/canonical-persistence-policy.ts b/lib/thread-chat/persistence/canonical-persistence-policy.ts index 78d1e4ef..368e0c4b 100644 --- a/lib/thread-chat/persistence/canonical-persistence-policy.ts +++ b/lib/thread-chat/persistence/canonical-persistence-policy.ts @@ -1,4 +1,5 @@ import { ConversationRepositoryError } from "../application/conversation-repository" +import { resolveConversationAuthority } from "../cutover/conversation-authority" export type CanonicalPersistenceWriteMode = "disabled" | "isolated-test" | "canonical" @@ -11,25 +12,15 @@ export interface CanonicalPersistencePolicy { export function resolveCanonicalPersistencePolicy( environment: NodeJS.ProcessEnv = process.env ): CanonicalPersistencePolicy { - const configured = environment.CONVERSATION_PERSISTENCE_WRITE_MODE?.trim() - const writeMode: CanonicalPersistenceWriteMode = - configured === undefined || configured === "" - ? "disabled" - : configured === "disabled" || - configured === "isolated-test" || - configured === "canonical" - ? configured - : (() => { - throw new ConversationRepositoryError( - "canonical_writes_disabled", - `未知 CONVERSATION_PERSISTENCE_WRITE_MODE:${configured}` - ) - })() - + const deployment = resolveConversationAuthority(environment) return { - writeMode, - legacyWritesEnabled: - environment.THREAD_TREE_LEGACY_WRITES_ENABLED !== "false", + writeMode: + deployment.authority === "canonical" + ? deployment.isolatedTest + ? "isolated-test" + : "canonical" + : "disabled", + legacyWritesEnabled: deployment.authority === "legacy", } } diff --git a/lib/thread-chat/persistence/conversation-command-policy.ts b/lib/thread-chat/persistence/conversation-command-policy.ts index 13289821..358714d3 100644 --- a/lib/thread-chat/persistence/conversation-command-policy.ts +++ b/lib/thread-chat/persistence/conversation-command-policy.ts @@ -1,4 +1,5 @@ import { ConversationCommandError } from "../application/conversation-command-contracts" +import { resolveConversationAuthority } from "../cutover/conversation-authority" export type ConversationCommandApiAuthority = "disabled" | "isolated-test" | "canonical" @@ -11,19 +12,15 @@ export interface ConversationCommandApiPolicy { export function resolveConversationCommandApiPolicy( environment: NodeJS.ProcessEnv = process.env ): ConversationCommandApiPolicy { - const value = environment.CONVERSATION_COMMAND_API_AUTHORITY?.trim() - const authority: ConversationCommandApiAuthority = - !value || value === "disabled" - ? "disabled" - : value === "isolated-test" || value === "canonical" - ? value - : (() => { - throw new Error(`未知 CONVERSATION_COMMAND_API_AUTHORITY:${value}`) - })() + const deployment = resolveConversationAuthority(environment) return { - authority, - legacyAuthorityEnabled: - environment.BRANCH_TREE_AUTHORITY_ENABLED !== "false", + authority: + deployment.authority === "canonical" + ? deployment.isolatedTest + ? "isolated-test" + : "canonical" + : "disabled", + legacyAuthorityEnabled: deployment.authority === "legacy", } } diff --git a/lib/thread-chat/persistence/drizzle-conversation-command-store.ts b/lib/thread-chat/persistence/drizzle-conversation-command-store.ts index e1fbcf9c..a753d0c7 100644 --- a/lib/thread-chat/persistence/drizzle-conversation-command-store.ts +++ b/lib/thread-chat/persistence/drizzle-conversation-command-store.ts @@ -5,6 +5,7 @@ import { and, asc, eq, inArray, max, sql } from "drizzle-orm" import { CONVERSATION_COMMAND_SCHEMA_VERSION } from "../../../constants/conversation-command" import { db } from "../../db" import { + conversationArtifacts, conversationCommandRecords, conversationGenerations, conversationMessages, @@ -52,6 +53,7 @@ import { turnId, workspaceId, type Conversation, + type ConversationArtifactProvenance, type ConversationGeneration, type ConversationMessage, type ConversationSnapshot, @@ -163,6 +165,18 @@ function mapFork(row: typeof threadForks.$inferSelect): ThreadFork { } } +function mapArtifact( + row: typeof conversationArtifacts.$inferSelect +): ConversationArtifactProvenance { + return { + id: row.id as ConversationArtifactProvenance["id"], + sourceThreadId: threadId(row.sourceThreadId), + sourceMessageId: messageId(row.sourceMessageId), + title: row.title, + kind: row.kind, + } +} + function mapGenerationSummary( row: typeof conversationGenerations.$inferSelect ): ConversationGeneration { @@ -391,63 +405,76 @@ export class DrizzleConversationCommandStore input.conversationId ) if (!access) return null - const [threadRows, turnRows, messageRows, forkRows, generationRows] = - await Promise.all([ - db - .select() - .from(conversationThreads) - .where(eq(conversationThreads.conversationId, input.conversationId)) - .orderBy( - asc(conversationThreads.createdAt), - asc(conversationThreads.id) - ), - db - .select({ turn: conversationTurns }) - .from(conversationTurns) - .innerJoin( - conversationThreads, - eq(conversationThreads.id, conversationTurns.threadId) - ) - .where(eq(conversationThreads.conversationId, input.conversationId)) - .orderBy( - asc(conversationTurns.threadId), - asc(conversationTurns.position), - asc(conversationTurns.id) - ), - db - .select({ message: conversationMessages }) - .from(conversationMessages) - .innerJoin( - conversationThreads, - eq(conversationThreads.id, conversationMessages.threadId) - ) - .where(eq(conversationThreads.conversationId, input.conversationId)) - .orderBy( - asc(conversationMessages.threadId), - asc(conversationMessages.createdAt), - asc(conversationMessages.id) - ), - db - .select() - .from(threadForks) - .where(eq(threadForks.conversationId, input.conversationId)) - .orderBy(asc(threadForks.createdAt), asc(threadForks.id)), - db - .select() - .from(conversationGenerations) - .where( - eq(conversationGenerations.conversationId, input.conversationId) - ) - .orderBy( - asc(conversationGenerations.createdAt), - asc(conversationGenerations.id) - ), - ]) + const [ + threadRows, + turnRows, + messageRows, + forkRows, + generationRows, + artifactRows, + ] = await Promise.all([ + db + .select() + .from(conversationThreads) + .where(eq(conversationThreads.conversationId, input.conversationId)) + .orderBy( + asc(conversationThreads.createdAt), + asc(conversationThreads.id) + ), + db + .select({ turn: conversationTurns }) + .from(conversationTurns) + .innerJoin( + conversationThreads, + eq(conversationThreads.id, conversationTurns.threadId) + ) + .where(eq(conversationThreads.conversationId, input.conversationId)) + .orderBy( + asc(conversationTurns.threadId), + asc(conversationTurns.position), + asc(conversationTurns.id) + ), + db + .select({ message: conversationMessages }) + .from(conversationMessages) + .innerJoin( + conversationThreads, + eq(conversationThreads.id, conversationMessages.threadId) + ) + .where(eq(conversationThreads.conversationId, input.conversationId)) + .orderBy( + asc(conversationMessages.threadId), + asc(conversationMessages.createdAt), + asc(conversationMessages.id) + ), + db + .select() + .from(threadForks) + .where(eq(threadForks.conversationId, input.conversationId)) + .orderBy(asc(threadForks.createdAt), asc(threadForks.id)), + db + .select() + .from(conversationGenerations) + .where(eq(conversationGenerations.conversationId, input.conversationId)) + .orderBy( + asc(conversationGenerations.createdAt), + asc(conversationGenerations.id) + ), + db + .select() + .from(conversationArtifacts) + .where(eq(conversationArtifacts.conversationId, input.conversationId)) + .orderBy( + asc(conversationArtifacts.createdAt), + asc(conversationArtifacts.id) + ), + ]) const mappedThreads = threadRows.map(mapThread) const mappedTurns = turnRows.map(({ turn }) => mapTurn(turn)) const mappedMessages = messageRows.map(({ message }) => mapMessage(message)) const mappedForks = forkRows.map(mapFork) const mappedGenerations = generationRows.map(mapGenerationSummary) + const mappedArtifacts = artifactRows.map(mapArtifact) const snapshot: ConversationSnapshot = { schemaVersion: 1, project: { @@ -463,7 +490,7 @@ export class DrizzleConversationCommandStore turns: registry(mappedTurns), messages: registry(mappedMessages), generations: registry(mappedGenerations), - artifactProvenance: {}, + artifactProvenance: registry(mappedArtifacts), } return { snapshot, diff --git a/lib/thread-chat/persistence/drizzle-conversation-repository.ts b/lib/thread-chat/persistence/drizzle-conversation-repository.ts index af4d71cc..79e473b9 100644 --- a/lib/thread-chat/persistence/drizzle-conversation-repository.ts +++ b/lib/thread-chat/persistence/drizzle-conversation-repository.ts @@ -2,6 +2,7 @@ import { and, asc, eq, sql } from "drizzle-orm" import { db } from "../../db" import { + conversationArtifacts, conversationMessages, conversationThreads, conversationTurns, @@ -32,6 +33,7 @@ import { turnId, workspaceId, type ConversationMessage, + type ConversationArtifactProvenance, type ConversationSnapshot, type ConversationThread, type ConversationTurn, @@ -168,6 +170,18 @@ function mapFork(row: typeof threadForks.$inferSelect): ThreadFork { } } +function mapArtifact( + row: typeof conversationArtifacts.$inferSelect +): ConversationArtifactProvenance { + return { + id: row.id as ConversationArtifactProvenance["id"], + sourceThreadId: threadId(row.sourceThreadId), + sourceMessageId: messageId(row.sourceMessageId), + title: row.title, + kind: row.kind, + } +} + function registry( values: readonly T[] ): Readonly> { @@ -639,53 +653,63 @@ export class DrizzleConversationRepository implements CanonicalConversationRepos ) if (!access) return null - const [threadRows, turnRows, messageRows, forkRows] = await Promise.all([ - db - .select() - .from(conversationThreads) - .where(eq(conversationThreads.conversationId, input.conversationId)) - .orderBy( - asc(conversationThreads.createdAt), - asc(conversationThreads.id) - ), - db - .select({ turn: conversationTurns }) - .from(conversationTurns) - .innerJoin( - conversationThreads, - eq(conversationThreads.id, conversationTurns.threadId) - ) - .where(eq(conversationThreads.conversationId, input.conversationId)) - .orderBy( - asc(conversationTurns.threadId), - asc(conversationTurns.position), - asc(conversationTurns.id) - ), - db - .select({ message: conversationMessages }) - .from(conversationMessages) - .innerJoin( - conversationThreads, - eq(conversationThreads.id, conversationMessages.threadId) - ) - .where(eq(conversationThreads.conversationId, input.conversationId)) - .orderBy( - asc(conversationMessages.threadId), - asc(conversationMessages.turnId), - asc(conversationMessages.createdAt), - asc(conversationMessages.id) - ), - db - .select() - .from(threadForks) - .where(eq(threadForks.conversationId, input.conversationId)) - .orderBy(asc(threadForks.createdAt), asc(threadForks.id)), - ]) + const [threadRows, turnRows, messageRows, forkRows, artifactRows] = + await Promise.all([ + db + .select() + .from(conversationThreads) + .where(eq(conversationThreads.conversationId, input.conversationId)) + .orderBy( + asc(conversationThreads.createdAt), + asc(conversationThreads.id) + ), + db + .select({ turn: conversationTurns }) + .from(conversationTurns) + .innerJoin( + conversationThreads, + eq(conversationThreads.id, conversationTurns.threadId) + ) + .where(eq(conversationThreads.conversationId, input.conversationId)) + .orderBy( + asc(conversationTurns.threadId), + asc(conversationTurns.position), + asc(conversationTurns.id) + ), + db + .select({ message: conversationMessages }) + .from(conversationMessages) + .innerJoin( + conversationThreads, + eq(conversationThreads.id, conversationMessages.threadId) + ) + .where(eq(conversationThreads.conversationId, input.conversationId)) + .orderBy( + asc(conversationMessages.threadId), + asc(conversationMessages.turnId), + asc(conversationMessages.createdAt), + asc(conversationMessages.id) + ), + db + .select() + .from(threadForks) + .where(eq(threadForks.conversationId, input.conversationId)) + .orderBy(asc(threadForks.createdAt), asc(threadForks.id)), + db + .select() + .from(conversationArtifacts) + .where(eq(conversationArtifacts.conversationId, input.conversationId)) + .orderBy( + asc(conversationArtifacts.createdAt), + asc(conversationArtifacts.id) + ), + ]) const mappedThreads = threadRows.map(mapThread) const mappedTurns = turnRows.map(({ turn }) => mapTurn(turn)) const mappedMessages = messageRows.map(({ message }) => mapMessage(message)) const mappedForks = forkRows.map(mapFork) + const mappedArtifacts = artifactRows.map(mapArtifact) return { schemaVersion: 1, @@ -710,7 +734,7 @@ export class DrizzleConversationRepository implements CanonicalConversationRepos turns: registry(mappedTurns), messages: registry(mappedMessages), generations: {}, - artifactProvenance: {}, + artifactProvenance: registry(mappedArtifacts), } } } diff --git a/openspec/changes/retire-thread-tree-authority/tasks.md b/openspec/changes/retire-thread-tree-authority/tasks.md index 3a91d18c..f3f5d10c 100644 --- a/openspec/changes/retire-thread-tree-authority/tasks.md +++ b/openspec/changes/retire-thread-tree-authority/tasks.md @@ -1,7 +1,7 @@ ## 1. 前置实现与发布门禁 -- [ ] 1.1 验证 `define-conversation-domain-model`、规范持久化、Generation lifecycle、命令 API 和 normalized client 的必需任务及 strict specs 全部完成。 -- [ ] 1.2 建立目标行为矩阵,关联认证、生命周期、列/画布、A → B → C Fork、变体、actions、Artifact/研究、Generation/计费的自动测试和 smoke。 +- [x] 1.1 验证 `define-conversation-domain-model`、规范持久化、Generation lifecycle、命令 API 和 normalized client 的必需任务及 strict specs 全部完成。 +- [x] 1.2 建立目标行为矩阵,关联认证、生命周期、列/画布、A → B → C Fork、变体、actions、Artifact/研究、Generation/计费的自动测试和 smoke。 - [ ] 1.3 记录当前容量/性能/错误/计费基线、cutover 阈值、负责人、观察窗口和 go/no-go checklist。 - [ ] 1.4 演练 canonical 备份恢复、维护模式、authority epoch mismatch 和切换后只读/前滚回滚流程。 @@ -14,20 +14,38 @@ ## 3. 确定性导入或重置工具 -- [ ] 3.1 实现 `(legacyTreeId, entityType, localId) → canonicalId` 的持久映射与幂等 dry-run/report。 -- [ ] 3.2 实现按 Conversation 事务导入 Project/Conversation/Thread/ThreadFork/Turn/Message 和当前有效变体。 -- [ ] 3.3 迁移 Generation、标题、Message feedback、Artifact、usage/billing 和其他审计确认的 sidecar 外键。 +- [x] 3.1 实现 `(legacyTreeId, entityType, localId) → canonicalId` 的持久映射与幂等 dry-run/report。 +- [x] 3.2 实现按 Conversation 事务导入 Project/Conversation/Thread/ThreadFork/Turn/Message 和当前有效变体。 +- [x] 3.3 迁移 Generation、标题、Message feedback、Artifact、usage/billing 和其他审计确认的 sidecar 外键。 - [ ] 3.4 实现受批准重置路径,仅接受 ADR scope 并在执行前验证目标环境与备份标识。 -- [ ] 3.5 增加 A → B → C、Message 变体、partial Generation、辅助引用、重复运行和故障回滚测试。 -- [ ] 3.6 实现源/目标计数、映射、约束、悬空引用和摘要的全量 post-import verifier。 +- [x] 3.5 增加 A → B → C、Message 变体、partial Generation、辅助引用、重复运行和故障回滚测试。 +- [x] 3.6 实现源/目标计数、映射、约束、悬空引用和摘要的全量 post-import verifier。 + +### 第 3 阶段本地验收记录(2026-08-22) + +- 新增持久映射账本和规范 `conversation_artifacts` 表;canonical 快照从关系表恢复 Artifact provenance,不回读 legacy JSON。 +- dry-run 生成 19 个 Conversation、187 个实体和逐树稳定摘要;发现 5 条 `generation_intent_missing`,显式列为需要审批的 `missing-generation-intent-as-send`,不静默推断。 +- 导入按 Conversation 事务写入 Project → Conversation → Thread、Turn/Message 变体、ThreadFork、Generation/checkpoint/usage/billing、标题、Artifact 和反馈;legacy Message 级 `isCurrent` 只在 active assistant 上映射为 canonical Turn 级 current Generation。 +- 本地 `--test-rollback` 对全部 19 棵树完成写入、数据库约束、源/目标计数、映射 SHA-256 与重复导入幂等校验,随后强制回滚;回滚后 mapping 和 `legacy:%` Conversation 计数均为 0。 +- 3.4 保持未完成:仓库尚无任何目标环境获批的“丢弃数据” ADR,因此只实现并验证导入路径,不伪造重置授权。 ## 4. Authority、维护与旧协议拒绝 -- [ ] 4.1 实现单值 `legacy | canonical` authority 组合根、schemaVersion/cutover epoch 健康信息和非法双 authority 启动失败。 -- [ ] 4.2 实现维护模式,拒绝新 legacy mutations/Generation,同时保留必要只读和安全认证语义。 -- [ ] 4.3 实现非终态 Generation drain/Stop/stale 收敛与 usage/计费完成检查,未清零时阻止 cutover。 -- [ ] 4.4 为旧整树、active-leaf、Generation sidecar 和 reconcile routes 实现安全 `legacy_protocol_retired` 拒绝与遥测。 -- [ ] 4.5 让 canonical 客户端 boot 校验服务端 authority/schema/epoch,不匹配时不得发送写命令。 +- [x] 4.1 实现单值 `legacy | canonical` authority 组合根、schemaVersion/cutover epoch 健康信息和非法双 authority 启动失败。 +- [x] 4.2 实现维护模式,拒绝新 legacy mutations/Generation,同时保留必要只读和安全认证语义。 +- [x] 4.3 实现非终态 Generation drain/Stop/stale 收敛与 usage/计费完成检查,未清零时阻止 cutover。 +- [x] 4.4 为旧整树、active-leaf、Generation sidecar 和 reconcile routes 实现安全 `legacy_protocol_retired` 拒绝与遥测。 +- [x] 4.5 让 canonical 客户端 boot 校验服务端 authority/schema/epoch,不匹配时不得发送写命令。 + +### 第 4 阶段验收记录(2026-08-22,本地隔离环境) + +- `CONVERSATION_AUTHORITY` 是唯一组合根;发现任何已退役的 client/API/Generation 分裂开关时启动失败。canonical 必须携带不可为空的 `CONVERSATION_CUTOVER_EPOCH`,生产环境禁止 `CONVERSATION_ISOLATED_TEST=true`。 +- `/api/conversation-authority` 发布 authority、schemaVersion、epoch 和维护状态。canonical 客户端先核对三元组,成功后才加载快照;不匹配返回 `authority_mismatch`,不会进入实体加载或写命令路径。 +- `read-only` 维护模式拒绝会产生新事实的 legacy/canonical 写入;legacy GET 改为纯 owner 读取,不再认领无主树,也不借读取执行 stale 修复。Generation Stop 保留用于排空。 +- 旧整树、active-leaf、Message feedback、legacy chat Generation 和 Generation sidecar 在 canonical authority 下统一返回 `410 legacy_protocol_retired`,并输出不含正文的协议名与 epoch 遥测。 +- `pnpm check:conversation-cutover-drain` 只读检查 legacy/canonical 非终态 Generation、待结算计费与 canonical outbox;任一非零以退出码 2 阻止 cutover。本地实测五项均为 0、`ready=true`。 +- 自动验证:typecheck;domain 8/8;Generation unit 7/7;authority/drain/import 6/6;client 14/14;command contract 4/4;数据库 persistence/Generation/command API 分别 26/52/69 assertions;OpenSpec strict 31/31;canonical production build 成功。Ego Browser 实测健康三元组为 `canonical / 1 / local-issue34-20260822`,匹配后 3 列页面与 composer 加载成功;旧整树与旧 Generation 查询均返回 410。 +- 本记录只证明第 4 阶段的实现和本地隔离验收,不代表第 5 阶段的生产维护、备份、正式切换或批准已经发生。 ## 5. Cutover 演练与正式执行 diff --git a/package.json b/package.json index 1a86a456..b8fdde03 100644 --- a/package.json +++ b/package.json @@ -19,11 +19,14 @@ "test:conversation-command-api": "tsx scripts/test-conversation-command-api.ts", "test:conversation-command-contract": "tsx --test lib/thread-chat/http/conversation-command-http.test.ts", "test:conversation-client": "tsx --test lib/thread-chat/client/normalized-conversation-store.test.ts", + "test:conversation-authority": "tsx --test lib/thread-chat/cutover/conversation-authority.test.ts lib/thread-chat/cutover/conversation-drain.test.ts lib/thread-chat/cutover/legacy-conversation-import.test.ts", + "check:conversation-cutover-drain": "tsx scripts/check-conversation-cutover-drain.ts", "seed:conversation-browser": "tsx scripts/seed-canonical-browser-fixture.ts", "test:conversation-persistence": "tsx scripts/test-conversation-persistence.ts", "test:legacy-conversation-audit": "node --experimental-strip-types --test lib/thread-chat/legacy/audit-thread-tree.test.ts", "audit:conversation-domain": "node scripts/audit-thread-tree-boundary.mjs", "audit:legacy-conversations": "node --experimental-strip-types scripts/audit-legacy-conversations.ts", + "import:legacy-conversations": "tsx scripts/import-legacy-conversations.ts", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:reset-schema": "node scripts/reset-thread-chat-schema.mjs", diff --git a/scripts/audit-legacy-conversations.ts b/scripts/audit-legacy-conversations.ts index aa998d01..9cf46f43 100644 --- a/scripts/audit-legacy-conversations.ts +++ b/scripts/audit-legacy-conversations.ts @@ -17,15 +17,24 @@ try { const generations = await sql< readonly { id: string + user_id: string tree_id: string thread_id: string user_message_id: string assistant_message_id: string + intent_present: boolean }[] - >`SELECT id, tree_id, thread_id, user_message_id, assistant_message_id FROM thread_chat.branch_generations ORDER BY tree_id, id` + >`SELECT id, user_id, tree_id, thread_id, user_message_id, assistant_message_id, + (turn_snapshot ? 'intent') AS intent_present + FROM thread_chat.branch_generations ORDER BY tree_id, id` const feedback = await sql< - readonly { tree_id: string; thread_id: string; message_id: string }[] - >`SELECT tree_id, thread_id, message_id FROM thread_chat.branch_message_feedback ORDER BY tree_id, thread_id, message_id` + readonly { + user_id: string + tree_id: string + thread_id: string + message_id: string + }[] + >`SELECT user_id, tree_id, thread_id, message_id FROM thread_chat.branch_message_feedback ORDER BY tree_id, thread_id, message_id` const reports = trees.map((tree) => auditLegacyConversation({ @@ -36,6 +45,8 @@ try { .filter((generation) => generation.tree_id === tree.id) .map((generation) => ({ id: generation.id, + ownerUserId: generation.user_id, + intentPresent: generation.intent_present, threadId: generation.thread_id, userMessageId: generation.user_message_id, assistantMessageId: generation.assistant_message_id, @@ -43,6 +54,7 @@ try { feedback: feedback .filter((entry) => entry.tree_id === tree.id) .map((entry) => ({ + ownerUserId: entry.user_id, threadId: entry.thread_id, messageId: entry.message_id, })), diff --git a/scripts/check-conversation-cutover-drain.ts b/scripts/check-conversation-cutover-drain.ts new file mode 100644 index 00000000..55b45a36 --- /dev/null +++ b/scripts/check-conversation-cutover-drain.ts @@ -0,0 +1,47 @@ +import { config } from "dotenv" +import postgres from "postgres" + +import { evaluateConversationCutoverDrain } from "../lib/thread-chat/cutover/conversation-drain.ts" + +config({ path: ".env.local" }) + +const rawUrl = process.env.DIRECT_URL || process.env.DATABASE_URL +if (!rawUrl) throw new Error("未配置 DIRECT_URL 或 DATABASE_URL") +const databaseUrl = rawUrl.trim().replace(/^(['"])(.*)\1$/u, "$2") +const sql = postgres(databaseUrl, { max: 1, prepare: false }) + +try { + const [row] = await sql< + readonly { + legacy_active_generations: number + legacy_pending_billing: number + canonical_active_generations: number + canonical_pending_billing: number + canonical_pending_outbox: number + }[] + >` + SELECT + (SELECT count(*)::int FROM thread_chat.branch_generations + WHERE status IN ('running', 'stop_requested')) AS legacy_active_generations, + (SELECT count(*)::int FROM thread_chat.branch_generations + WHERE billing_status = 'pending') AS legacy_pending_billing, + (SELECT count(*)::int FROM thread_chat.conversation_generations + WHERE status IN ('running', 'stop_requested')) AS canonical_active_generations, + (SELECT count(*)::int FROM thread_chat.conversation_generations + WHERE billing_status = 'pending') AS canonical_pending_billing, + (SELECT count(*)::int FROM thread_chat.conversation_outbox_events + WHERE status <> 'dispatched') AS canonical_pending_outbox + ` + if (!row) throw new Error("无法读取 Conversation drain 状态") + const report = evaluateConversationCutoverDrain({ + legacyActiveGenerations: row.legacy_active_generations, + legacyPendingBilling: row.legacy_pending_billing, + canonicalActiveGenerations: row.canonical_active_generations, + canonicalPendingBilling: row.canonical_pending_billing, + canonicalPendingOutbox: row.canonical_pending_outbox, + }) + console.log(JSON.stringify({ mode: "read-only", ...report }, null, 2)) + if (!report.ready) process.exitCode = 2 +} finally { + await sql.end() +} diff --git a/scripts/fixtures/conversation-cutover-local-rollback.json b/scripts/fixtures/conversation-cutover-local-rollback.json new file mode 100644 index 00000000..c564d0a6 --- /dev/null +++ b/scripts/fixtures/conversation-cutover-local-rollback.json @@ -0,0 +1,17 @@ +{ + "schemaVersion": 1, + "action": "deterministic-import", + "environment": "local-transaction-rollback-test", + "database": { + "host": "localhost", + "name": "thread-chat" + }, + "scope": { + "legacyTreeIds": "all" + }, + "backupId": "rollback-test-does-not-commit", + "approvalId": "local-rollback-issue-34", + "approvedBy": "automated-local-test", + "approvedAt": "2026-08-22T00:00:00.000Z", + "approvedRepairs": ["missing-generation-intent-as-send"] +} diff --git a/scripts/import-legacy-conversations.ts b/scripts/import-legacy-conversations.ts new file mode 100644 index 00000000..2031e880 --- /dev/null +++ b/scripts/import-legacy-conversations.ts @@ -0,0 +1,426 @@ +import { createHash } from "node:crypto" +import { readFile } from "node:fs/promises" + +import { config } from "dotenv" +import postgres from "postgres" +import { z } from "zod" + +import { generationResultV1Schema } from "../lib/thread-chat/contracts/generation-result.ts" +import { threadChatGenerationIntentSchema } from "../lib/thread-chat/contracts/generation-intent.ts" +import { parseThreadTreeState } from "../lib/thread-chat/domain/message-graph.ts" +import type { MessageFeedback } from "../lib/thread-chat/domain/types.ts" +import { resolveConversationAuthority } from "../lib/thread-chat/cutover/conversation-authority.ts" +import { + buildLegacyConversationImportPlan, + type LegacyConversationImportPlan, +} from "../lib/thread-chat/cutover/legacy-conversation-import.ts" +import { evaluateConversationCutoverDrain } from "../lib/thread-chat/cutover/conversation-drain.ts" + +config({ path: ".env.local" }) + +const approvalSchema = z + .object({ + schemaVersion: z.literal(1), + action: z.literal("deterministic-import"), + environment: z.string().trim().min(1), + database: z + .object({ + host: z.string().trim().min(1), + name: z.string().trim().min(1), + }) + .strict(), + scope: z + .object({ + legacyTreeIds: z.union([z.literal("all"), z.array(z.string().min(1))]), + }) + .strict(), + backupId: z.string().trim().min(1), + approvalId: z.string().trim().min(1), + approvedBy: z.string().trim().min(1), + approvedAt: z.string().datetime(), + approvedRepairs: z.array(z.literal("missing-generation-intent-as-send")), + }) + .strict() + +const args = new Set(process.argv.slice(2)) +const execute = args.has("--execute") +const rollback = args.has("--test-rollback") +const approvalFlag = process.argv.indexOf("--approval-file") +const approvalPath = + approvalFlag >= 0 ? process.argv[approvalFlag + 1] : undefined + +const rawUrl = process.env.DIRECT_URL || process.env.DATABASE_URL +if (!rawUrl) throw new Error("未配置 DIRECT_URL 或 DATABASE_URL") +const databaseUrl = rawUrl.trim().replace(/^(['"])(.*)\1$/u, "$2") +const parsedDatabaseUrl = new URL(databaseUrl) +const sql = postgres(databaseUrl, { max: 1, prepare: false }) + +function hashIds(values: readonly string[]): string { + return createHash("sha256") + .update([...values].sort().join("\n")) + .digest("hex") +} + +function sqlJson(value: unknown): postgres.JSONValue { + return JSON.parse(JSON.stringify(value)) as postgres.JSONValue +} + +function planIds(plan: LegacyConversationImportPlan): string[] { + return [ + plan.snapshot.conversation.id, + ...Object.keys(plan.snapshot.threads), + ...Object.keys(plan.snapshot.turns), + ...Object.keys(plan.snapshot.messages), + ...Object.keys(plan.snapshot.threadForks), + ...plan.generations.map((entry) => entry.id), + ...plan.artifacts.map((entry) => entry.id), + ...plan.feedback.map((entry) => `${entry.userId}:${entry.messageId}`), + ] +} + +async function loadPlans(treeScope: "all" | readonly string[]) { + const trees = await sql< + readonly { + id: string + user_id: string | null + title: string | null + custom_title: string | null + state: unknown + }[] + >`SELECT id, user_id, title, custom_title, state FROM thread_chat.branch_trees ORDER BY id` + const selected = + treeScope === "all" + ? trees + : trees.filter((tree) => treeScope.includes(tree.id)) + if (treeScope !== "all" && selected.length !== new Set(treeScope).size) + throw new Error("审批 scope 中包含不存在的 legacy tree") + + const generations = await sql[]>` + SELECT * FROM thread_chat.branch_generations ORDER BY tree_id, id + ` + const feedback = await sql[]>` + SELECT * FROM thread_chat.branch_message_feedback ORDER BY tree_id, thread_id, message_id + ` + + const repairs: { + code: "missing-generation-intent-as-send" + treeId: string + generationId: string + }[] = [] + const plans = selected.map((tree) => { + if (!tree.user_id) throw new Error(`Legacy tree ${tree.id} 没有 owner`) + return buildLegacyConversationImportPlan({ + treeId: tree.id, + ownerUserId: tree.user_id, + title: tree.title, + customTitle: tree.custom_title, + state: parseThreadTreeState(tree.state), + generations: generations + .filter((entry) => entry.tree_id === tree.id) + .map((entry) => ({ + id: z.string().parse(entry.id), + userId: z.string().parse(entry.user_id), + threadId: z.string().parse(entry.thread_id), + userMessageId: z.string().parse(entry.user_message_id), + assistantMessageId: z.string().parse(entry.assistant_message_id), + attempt: z.number().int().positive().parse(entry.attempt), + isCurrent: z.boolean().parse(entry.is_current), + status: z + .enum([ + "running", + "stop_requested", + "completed", + "stopped", + "failed", + "superseded", + ]) + .parse(entry.status), + modelId: z.string().parse(entry.model_id), + intent: (() => { + const parsed = threadChatGenerationIntentSchema.safeParse( + (entry.turn_snapshot as Record)?.intent + ) + if (parsed.success) return parsed.data + repairs.push({ + code: "missing-generation-intent-as-send", + treeId: tree.id, + generationId: z.string().parse(entry.id), + }) + // 早期 sidecar 没有 intent。终态导入只保留执行来源;用最弱的 send + // 语义修复,不据此重新执行模型,也不猜测 edit/regenerate 来源。 + return { kind: "persisted-turn" as const } + })(), + result: entry.result + ? generationResultV1Schema.parse(entry.result) + : null, + billingStatus: z + .enum(["pending", "settled", "usage_unavailable", "not_billable"]) + .parse(entry.billing_status), + heartbeatAt: z.date().parse(entry.heartbeat_at), + stopRequestedAt: entry.stop_requested_at + ? z.date().parse(entry.stop_requested_at) + : null, + finishedAt: entry.finished_at + ? z.date().parse(entry.finished_at) + : null, + createdAt: z.date().parse(entry.created_at), + updatedAt: z.date().parse(entry.updated_at), + error: entry.error ? z.string().parse(entry.error) : null, + })), + feedback: feedback + .filter((entry) => entry.tree_id === tree.id) + .map((entry) => ({ + userId: z.string().parse(entry.user_id), + threadId: z.string().parse(entry.thread_id), + messageId: z.string().parse(entry.message_id), + feedback: z + .enum(["positive", "negative"]) + .parse(entry.feedback) as MessageFeedback, + createdAt: z.date().parse(entry.created_at), + updatedAt: z.date().parse(entry.updated_at), + })), + }) + }) + return { plans, repairs } +} + +async function insertPlan( + tx: postgres.TransactionSql, + plan: LegacyConversationImportPlan +) { + const snapshot = plan.snapshot + const [claim] = await tx< + readonly { conversations: number; mappings: number }[] + >`SELECT + (SELECT count(*)::int FROM thread_chat.conversations WHERE id = ${snapshot.conversation.id}) AS conversations, + (SELECT count(*)::int FROM thread_chat.legacy_conversation_entity_mappings WHERE legacy_tree_id = ${plan.legacyTreeId}) AS mappings` + if (!claim) throw new Error("无法读取导入身份占用状态") + if (claim.conversations > 0 && claim.mappings === 0) + throw new Error( + `Canonical Conversation ${snapshot.conversation.id} 已存在但没有 legacy 映射,拒绝覆盖` + ) + if (claim.conversations === 0 && claim.mappings > 0) + throw new Error( + `Legacy tree ${plan.legacyTreeId} 存在映射但 Conversation 缺失,拒绝猜测恢复` + ) + await tx`SET CONSTRAINTS ALL DEFERRED` + await tx`INSERT INTO thread_chat.workspaces (id, revision, lifecycle) + VALUES (${snapshot.project.workspaceId}, 0, 'active') ON CONFLICT (id) DO NOTHING` + await tx`INSERT INTO thread_chat.workspace_members (workspace_id, user_id, role) + VALUES (${snapshot.project.workspaceId}, ${plan.ownerUserId}, 'owner') ON CONFLICT DO NOTHING` + await tx`INSERT INTO thread_chat.projects (id, workspace_id, title, revision, lifecycle) + VALUES (${snapshot.project.id}, ${snapshot.project.workspaceId}, ${snapshot.project.title}, 0, 'active') ON CONFLICT (id) DO NOTHING` + await tx`INSERT INTO thread_chat.conversations + (id, project_id, root_thread_id, auto_title, custom_title, revision, lifecycle) + VALUES (${snapshot.conversation.id}, ${snapshot.conversation.projectId}, ${snapshot.conversation.rootThreadId}, ${snapshot.conversation.autoTitle}, ${snapshot.conversation.customTitle}, 0, 'active') + ON CONFLICT (id) DO NOTHING` + + for (const thread of Object.values(snapshot.threads)) + await tx`INSERT INTO thread_chat.conversation_threads + (id, conversation_id, model_id, local_title, revision, lifecycle) + VALUES (${thread.id}, ${thread.conversationId}, ${thread.modelId}, ${thread.localTitle}, ${thread.revision}, ${thread.lifecycle}) + ON CONFLICT (id) DO NOTHING` + for (const turn of Object.values(snapshot.turns)) + await tx`INSERT INTO thread_chat.conversation_turns + (id, thread_id, position, active_user_message_id, active_assistant_message_id, revision) + VALUES (${turn.id}, ${turn.threadId}, ${turn.position}, ${turn.activeUserMessageId}, ${turn.activeAssistantMessageId}, ${turn.revision}) + ON CONFLICT (id) DO NOTHING` + for (const message of Object.values(snapshot.messages)) + await tx`INSERT INTO thread_chat.conversation_messages + (id, thread_id, turn_id, role, content, content_state, variant_of_message_id, created_at) + VALUES (${message.id}, ${message.threadId}, ${message.turnId}, ${message.role}, ${tx.json(sqlJson(message.content))}, ${message.contentState}, ${message.variantOfMessageId ?? null}, ${new Date(message.createdAt)}) + ON CONFLICT (id) DO NOTHING` + for (const fork of Object.values(snapshot.threadForks)) + await tx`INSERT INTO thread_chat.thread_forks + (id, conversation_id, parent_thread_id, source_message_id, child_thread_id, anchor, created_by, created_at) + VALUES (${fork.id}, ${fork.conversationId}, ${fork.parentThreadId}, ${fork.sourceMessageId}, ${fork.childThreadId}, ${fork.anchor ? tx.json(sqlJson(fork.anchor)) : null}, ${fork.createdBy}, ${new Date(fork.createdAt)}) + ON CONFLICT (id) DO NOTHING` + for (const artifact of plan.artifacts) + await tx`INSERT INTO thread_chat.conversation_artifacts + (id, conversation_id, source_thread_id, source_message_id, title, kind, lang, content) + VALUES (${artifact.id}, ${artifact.conversationId}, ${artifact.sourceThreadId}, ${artifact.sourceMessageId}, ${artifact.title}, ${artifact.kind}, ${artifact.lang}, ${artifact.content}) + ON CONFLICT (id) DO NOTHING` + for (const generation of plan.generations) + await tx`INSERT INTO thread_chat.conversation_generations + (id, owner_id, workspace_id, project_id, conversation_id, thread_id, turn_id, input_message_id, output_message_id, intent, request_hash, idempotency_key, model_id, attempt, is_current, status, content_state, checkpoint_version, checkpoint, known_usage, usage_completeness, billing_status, paid_call_started, lease_owner, lease_version, heartbeat_at, stop_requested_at, started_at, finished_at, error_code, created_at, updated_at) + VALUES (${generation.id}, ${generation.ownerId}, ${generation.workspaceId}, ${generation.projectId}, ${generation.conversationId}, ${generation.threadId}, ${generation.turnId}, ${generation.inputMessageId}, ${generation.outputMessageId}, ${tx.json(generation.intent)}, ${generation.requestHash}, ${generation.idempotencyKey}, ${generation.modelId}, ${generation.attempt}, ${generation.isCurrent}, ${generation.status}, ${generation.contentState}, ${generation.checkpointVersion}, ${tx.json(generation.checkpoint)}, ${generation.knownUsage ? tx.json(generation.knownUsage) : null}, ${generation.usageCompleteness}, ${generation.billingStatus}, ${generation.paidCallStarted}, ${null}, ${0}, ${generation.heartbeatAt}, ${generation.stopRequestedAt}, ${generation.startedAt}, ${generation.finishedAt}, ${generation.errorCode}, ${generation.createdAt}, ${generation.updatedAt}) + ON CONFLICT (id) DO NOTHING` + for (const entry of plan.feedback) + await tx`INSERT INTO thread_chat.conversation_message_feedback + (user_id, conversation_id, thread_id, message_id, feedback, created_at, updated_at) + VALUES (${entry.userId}, ${entry.conversationId}, ${entry.threadId}, ${entry.messageId}, ${entry.feedback}, ${entry.createdAt}, ${entry.updatedAt}) + ON CONFLICT (user_id, conversation_id, message_id) DO NOTHING` + for (const mapping of plan.mappings) + await tx`INSERT INTO thread_chat.legacy_conversation_entity_mappings + (legacy_tree_id, entity_type, local_id, canonical_id) + VALUES (${mapping.legacyTreeId}, ${mapping.entityType}, ${mapping.localId}, ${mapping.canonicalId}) + ON CONFLICT (legacy_tree_id, entity_type, local_id) DO NOTHING` +} + +async function verifyPlan( + tx: postgres.TransactionSql, + plan: LegacyConversationImportPlan +) { + const [counts] = await tx< + readonly { + threads: number + turns: number + messages: number + forks: number + generations: number + artifacts: number + feedback: number + mappings: number + }[] + >`SELECT + (SELECT count(*)::int FROM thread_chat.conversation_threads WHERE conversation_id = ${plan.snapshot.conversation.id}) AS threads, + (SELECT count(*)::int FROM thread_chat.conversation_turns t JOIN thread_chat.conversation_threads th ON th.id=t.thread_id WHERE th.conversation_id = ${plan.snapshot.conversation.id}) AS turns, + (SELECT count(*)::int FROM thread_chat.conversation_messages m JOIN thread_chat.conversation_threads th ON th.id=m.thread_id WHERE th.conversation_id = ${plan.snapshot.conversation.id}) AS messages, + (SELECT count(*)::int FROM thread_chat.thread_forks WHERE conversation_id = ${plan.snapshot.conversation.id}) AS forks, + (SELECT count(*)::int FROM thread_chat.conversation_generations WHERE conversation_id = ${plan.snapshot.conversation.id}) AS generations, + (SELECT count(*)::int FROM thread_chat.conversation_artifacts WHERE conversation_id = ${plan.snapshot.conversation.id}) AS artifacts, + (SELECT count(*)::int FROM thread_chat.conversation_message_feedback WHERE conversation_id = ${plan.snapshot.conversation.id}) AS feedback, + (SELECT count(*)::int FROM thread_chat.legacy_conversation_entity_mappings WHERE legacy_tree_id = ${plan.legacyTreeId}) AS mappings` + if (!counts) throw new Error("导入后计数读取失败") + const expected = { + threads: Object.keys(plan.snapshot.threads).length, + turns: Object.keys(plan.snapshot.turns).length, + messages: Object.keys(plan.snapshot.messages).length, + forks: Object.keys(plan.snapshot.threadForks).length, + generations: plan.generations.length, + artifacts: plan.artifacts.length, + feedback: plan.feedback.length, + mappings: plan.mappings.length, + } + if (JSON.stringify(counts) !== JSON.stringify(expected)) + throw new Error( + `导入后计数不一致:expected=${JSON.stringify(expected)} actual=${JSON.stringify(counts)}` + ) + const targetMappings = await tx` + SELECT canonical_id FROM thread_chat.legacy_conversation_entity_mappings + WHERE legacy_tree_id = ${plan.legacyTreeId} ORDER BY canonical_id` + const mappingHash = hashIds(targetMappings.map((entry) => entry.canonical_id)) + const expectedMappingHash = hashIds( + plan.mappings.map((entry) => entry.canonicalId) + ) + if (mappingHash !== expectedMappingHash) + throw new Error("导入后 ID 映射摘要不一致") + return { counts, mappingHash, planEntityHash: hashIds(planIds(plan)) } +} + +try { + let approval: z.infer | null = null + if (execute || rollback) { + if (!approvalPath) throw new Error("执行写入必须提供 --approval-file") + approval = approvalSchema.parse( + JSON.parse(await readFile(approvalPath, "utf8")) + ) + const authority = resolveConversationAuthority() + if ( + authority.authority !== "legacy" || + authority.maintenanceMode !== "read-only" + ) + throw new Error("导入只允许在 legacy + read-only 维护窗口执行") + if (process.env.CONVERSATION_CUTOVER_APPROVAL_ID !== approval.approvalId) + throw new Error("CONVERSATION_CUTOVER_APPROVAL_ID 与审批文件不匹配") + if ( + parsedDatabaseUrl.hostname !== approval.database.host || + parsedDatabaseUrl.pathname.slice(1) !== approval.database.name + ) + throw new Error("当前数据库与审批文件目标不匹配") + const [drainCounts] = await sql< + readonly { + legacy_active: number + legacy_billing: number + canonical_active: number + canonical_billing: number + outbox_pending: number + }[] + >`SELECT + (SELECT count(*)::int FROM thread_chat.branch_generations WHERE status IN ('running', 'stop_requested')) AS legacy_active, + (SELECT count(*)::int FROM thread_chat.branch_generations WHERE billing_status = 'pending') AS legacy_billing, + (SELECT count(*)::int FROM thread_chat.conversation_generations WHERE status IN ('running', 'stop_requested')) AS canonical_active, + (SELECT count(*)::int FROM thread_chat.conversation_generations WHERE billing_status = 'pending') AS canonical_billing, + (SELECT count(*)::int FROM thread_chat.conversation_outbox_events WHERE status <> 'dispatched') AS outbox_pending` + if (!drainCounts) throw new Error("无法读取 cutover drain 状态") + const drain = evaluateConversationCutoverDrain({ + legacyActiveGenerations: drainCounts.legacy_active, + legacyPendingBilling: drainCounts.legacy_billing, + canonicalActiveGenerations: drainCounts.canonical_active, + canonicalPendingBilling: drainCounts.canonical_billing, + canonicalPendingOutbox: drainCounts.outbox_pending, + }) + if (!drain.ready) + throw new Error(`Cutover drain 未清零:${JSON.stringify(drain.blockers)}`) + } + + const loaded = await loadPlans(approval?.scope.legacyTreeIds ?? "all") + const { plans, repairs } = loaded + if ( + (execute || rollback) && + repairs.some((repair) => !approval?.approvedRepairs.includes(repair.code)) + ) + throw new Error("审批文件未批准 dry-run 发现的 Generation intent 修复") + if (!execute && !rollback) { + console.log( + JSON.stringify( + { + mode: "dry-run", + readOnly: true, + conversations: plans.length, + entities: plans.reduce( + (total, plan) => total + planIds(plan).length, + 0 + ), + planHash: hashIds(plans.flatMap(planIds)), + repairs, + reports: plans.map((plan) => ({ + legacyTreeId: plan.legacyTreeId, + conversationId: plan.snapshot.conversation.id, + mappings: plan.mappings.length, + entityHash: hashIds(planIds(plan)), + })), + }, + null, + 2 + ) + ) + } else { + class RollbackProbe extends Error {} + const verified: unknown[] = [] + try { + await sql.begin(async (tx) => { + for (const plan of plans) { + await insertPlan(tx, plan) + verified.push(await verifyPlan(tx, plan)) + } + // 回滚演练在同一事务重复导入,验证 upsert/映射账本真正幂等。 + if (rollback) + for (const plan of plans) { + await insertPlan(tx, plan) + await verifyPlan(tx, plan) + } + if (rollback) throw new RollbackProbe("测试事务按设计回滚") + }) + } catch (error) { + if (!(error instanceof RollbackProbe)) throw error + } + console.log( + JSON.stringify( + { + mode: rollback ? "test-rollback" : "execute", + approvalId: approval?.approvalId, + backupId: approval?.backupId, + conversations: plans.length, + repairs, + verified, + committed: execute && !rollback, + }, + null, + 2 + ) + ) + } +} finally { + await sql.end() +} diff --git a/scripts/seed-canonical-browser-fixture.ts b/scripts/seed-canonical-browser-fixture.ts index 0d62b022..7591c68a 100644 --- a/scripts/seed-canonical-browser-fixture.ts +++ b/scripts/seed-canonical-browser-fixture.ts @@ -1,7 +1,9 @@ import { config } from "dotenv" config({ path: ".env.local" }) -process.env.CONVERSATION_COMMAND_API_AUTHORITY = "isolated-test" +process.env.CONVERSATION_AUTHORITY = "canonical" +process.env.CONVERSATION_CUTOVER_EPOCH = "browser-fixture" +process.env.CONVERSATION_ISOLATED_TEST = "true" const { eq } = await import("drizzle-orm") const { db } = await import("../lib/db/index.ts") diff --git a/scripts/test-conversation-command-api.ts b/scripts/test-conversation-command-api.ts index 0be5a2b9..c207dce1 100644 --- a/scripts/test-conversation-command-api.ts +++ b/scripts/test-conversation-command-api.ts @@ -4,7 +4,9 @@ import { randomUUID } from "node:crypto" import { config } from "dotenv" config({ path: ".env.local" }) -process.env.CONVERSATION_COMMAND_API_AUTHORITY = "isolated-test" +process.env.CONVERSATION_AUTHORITY = "canonical" +process.env.CONVERSATION_CUTOVER_EPOCH = "command-api-test" +process.env.CONVERSATION_ISOLATED_TEST = "true" const { count, eq } = await import("drizzle-orm") const { db } = await import("../lib/db/index.ts") diff --git a/scripts/test-conversation-generation.ts b/scripts/test-conversation-generation.ts index b8e61d7b..7c379c3f 100644 --- a/scripts/test-conversation-generation.ts +++ b/scripts/test-conversation-generation.ts @@ -4,7 +4,9 @@ import { randomUUID } from "node:crypto" import { config } from "dotenv" config({ path: ".env.local" }) -process.env.CONVERSATION_GENERATION_AUTHORITY = "isolated-test" +process.env.CONVERSATION_AUTHORITY = "canonical" +process.env.CONVERSATION_CUTOVER_EPOCH = "generation-test" +process.env.CONVERSATION_ISOLATED_TEST = "true" const { count, eq } = await import("drizzle-orm") const { db } = await import("../lib/db/index.ts") From 15ab38fb1224238bd3aacc3b39c70565fba69e35 Mon Sep 17 00:00:00 2001 From: zilin <276161014@qq.com> Date: Sat, 22 Aug 2026 05:10:28 +0800 Subject: [PATCH 08/32] fix(cutover): validate migration lineage --- scripts/vercel-migrate.mjs | 177 ++++++++++++++++++++++++++++++++++--- 1 file changed, 163 insertions(+), 14 deletions(-) diff --git a/scripts/vercel-migrate.mjs b/scripts/vercel-migrate.mjs index aa495c05..2ff170af 100644 --- a/scripts/vercel-migrate.mjs +++ b/scripts/vercel-migrate.mjs @@ -1,26 +1,175 @@ // 部署构建期的迁移守卫。 // // 语义: -// · 配置了数据库连接串(DIRECT_URL 或 DATABASE_URL)→ 跑 `pnpm db:migrate`; -// 迁移失败则以非零码退出,从而中断部署(保持「迁移不过就不部署」)。 -// · 未配置连接串 → 跳过迁移并正常退出,让「尚未配置数据库的 Vercel 预览构建」也能通过, -// 而不是在 `db:migrate` 连不上库时直接失败。 -// -// 之所以需要它:`vercel-build` 会在 build 前跑迁移,但预览环境/尚未配置密钥的项目 -// 读不到连接串,drizzle-kit 会因无法解析/连接而失败,连带整个部署失败。 +// · 配置了数据库连接串(DIRECT_URL 或 DATABASE_URL)→ 校验迁移历史后执行迁移; +// · 未配置连接串 → 跳过迁移并正常退出; +// · 迁移历史分叉或迁移失败 → 输出不含凭据的结构化诊断并中断部署。 + +import { createHash } from "node:crypto" +import { readFile } from "node:fs/promises" +import { drizzle } from "drizzle-orm/postgres-js" +import { migrate } from "drizzle-orm/postgres-js/migrator" +import postgres from "postgres" -import { spawnSync } from "node:child_process" +const MIGRATIONS_FOLDER = "./drizzle" +const MIGRATIONS_SCHEMA = "drizzle" +const MIGRATIONS_TABLE = "__drizzle_migrations" -const url = process.env.DIRECT_URL || process.env.DATABASE_URL +const rawUrl = process.env.DIRECT_URL || process.env.DATABASE_URL -if (!url) { +if (!rawUrl) { console.log( "[vercel-build] 未配置 DATABASE_URL/DIRECT_URL,跳过数据库迁移(构建继续)。" ) process.exit(0) } -console.log("[vercel-build] 检测到数据库连接串,执行迁移…") -const res = spawnSync("pnpm", ["db:migrate"], { stdio: "inherit" }) -// 迁移失败(非零/被信号中断)→ 让 vercel-build 的 && 短路,中断部署 -process.exit(res.status ?? 1) +function normalizedDatabaseUrl(value) { + const candidate = value.trim().replace(/^(['"])(.*)\1$/, "$2") + let url + try { + url = new URL(candidate) + } catch { + throw new Error( + "数据库连接串无法解析;应形如 postgres://用户:密码@主机:端口/库名,且不得带引号或多余空白。" + ) + } + url.searchParams.set( + "options", + "-c search_path=thread_chat,public,extensions" + ) + return url.toString() +} + +async function localMigrationManifest() { + const journal = JSON.parse( + await readFile(`${MIGRATIONS_FOLDER}/meta/_journal.json`, "utf8") + ) + return Promise.all( + journal.entries.map(async (entry) => { + const sql = await readFile( + `${MIGRATIONS_FOLDER}/${entry.tag}.sql`, + "utf8" + ) + return { + createdAt: String(entry.when), + hash: createHash("sha256").update(sql).digest("hex"), + tag: entry.tag, + } + }) + ) +} + +async function readAppliedMigrations(sql) { + const [{ migrationTable }] = await sql` + SELECT to_regclass(${`${MIGRATIONS_SCHEMA}.${MIGRATIONS_TABLE}`})::text AS "migrationTable" + ` + if (!migrationTable) return [] + return sql` + SELECT created_at::text AS "createdAt", hash + FROM drizzle.__drizzle_migrations + ORDER BY created_at ASC, id ASC + ` +} + +function validateLinearHistory(local, applied) { + const appliedByTimestamp = new Map() + for (const migration of applied) { + const sameTimestamp = appliedByTimestamp.get(migration.createdAt) + if (sameTimestamp && sameTimestamp.hash !== migration.hash) { + throw new Error( + `数据库迁移历史的时间戳 ${migration.createdAt} 对应多个不同哈希,无法建立单一谱系。` + ) + } + appliedByTimestamp.set(migration.createdAt, migration) + } + + const lastAppliedAt = Math.max( + 0, + ...applied.map((migration) => Number(migration.createdAt)) + ) + const pending = [] + let appliedCount = 0 + for (const expected of local) { + const actual = appliedByTimestamp.get(expected.createdAt) + if (actual) { + if (actual.hash !== expected.hash) { + throw new Error( + `数据库迁移历史在 ${expected.tag} 处分叉:期望 ${expected.hash.slice(0, 12)},实际为 ${actual.hash.slice(0, 12)}。拒绝自动覆盖。` + ) + } + appliedCount += 1 + continue + } + if (Number(expected.createdAt) <= lastAppliedAt) { + throw new Error( + `数据库缺少 ${expected.tag},但日志中已有时间更晚的迁移。Drizzle 会永久跳过该迁移,必须先人工修复谱系。` + ) + } + pending.push(expected) + } + + return { + appliedCount, + extraCount: applied.length - appliedCount, + pending, + } +} + +function safeError(error) { + if (!(error instanceof Error)) return { message: String(error) } + const fields = [ + "code", + "constraint_name", + "detail", + "hint", + "message", + "schema_name", + "table_name", + ] + return Object.fromEntries( + fields.flatMap((field) => { + const value = error[field] + return typeof value === "string" && value.length > 0 + ? [[field, value]] + : [] + }) + ) +} + +const client = postgres(normalizedDatabaseUrl(rawUrl), { + max: 1, + prepare: false, +}) + +try { + const local = await localMigrationManifest() + const applied = await readAppliedMigrations(client) + const { appliedCount, extraCount, pending } = validateLinearHistory( + local, + applied + ) + + console.log( + `[vercel-build] 迁移历史通过校验:当前谱系已应用 ${appliedCount},其他已知历史 ${extraCount},待应用 ${pending.length}${ + pending.length > 0 + ? `(${pending[0].tag} → ${pending.at(-1).tag})` + : "" + }。` + ) + + await migrate(drizzle(client), { + migrationsFolder: MIGRATIONS_FOLDER, + migrationsSchema: MIGRATIONS_SCHEMA, + migrationsTable: MIGRATIONS_TABLE, + }) + console.log("[vercel-build] 数据库迁移完成。") +} catch (error) { + console.error( + "[vercel-build] 数据库迁移失败:", + JSON.stringify(safeError(error)) + ) + process.exitCode = 1 +} finally { + await client.end({ timeout: 5 }) +} From e1664f53467471b65158f9031a966b2076047b05 Mon Sep 17 00:00:00 2001 From: zilin <276161014@qq.com> Date: Sat, 22 Aug 2026 05:13:55 +0800 Subject: [PATCH 09/32] fix(deploy): isolate preview migrations --- .env.example | 2 ++ docs/architecture/issue-34-cutover-runbook.md | 7 +++++++ scripts/vercel-migrate.mjs | 12 ++++++++++++ 3 files changed, 21 insertions(+) diff --git a/.env.example b/.env.example index 84f21929..e160eb18 100644 --- a/.env.example +++ b/.env.example @@ -35,6 +35,8 @@ DIRECT_URL=postgres://postgres.xxxx:password@aws-0-region.pooler.supabase.com:54 # 可选:每实例连接数上限(Serverless + 池化下宜小);直连想启用预处理语句设 DB_PREPARE=true。 # DB_POOL_MAX=10 # DB_PREPARE=false +# Vercel Preview 默认禁止在共享数据库上执行 DDL。只有给 Preview 配置了独立数据库时才设为 true。 +# VERCEL_PREVIEW_DATABASE_MIGRATIONS=false # === Issue #34 Conversation 单一权威与切换 === # 必须显式选择且只能选择一个权威;禁止再拆成 client/API/generation 多个开关。 diff --git a/docs/architecture/issue-34-cutover-runbook.md b/docs/architecture/issue-34-cutover-runbook.md index 62ca6264..78daa66e 100644 --- a/docs/architecture/issue-34-cutover-runbook.md +++ b/docs/architecture/issue-34-cutover-runbook.md @@ -14,6 +14,13 @@ define-conversation-domain-model `pnpm openspec:validate` 当前为 31/31。正式环境仍须填写负责人、观察窗口、性能/错误/计费基线和真实备份 ID;本文件中的本地结果不能替代生产批准。 +## 部署迁移边界 + +- Vercel Preview 默认只构建,不在共享数据库执行迁移。多个功能分支不能争用同一份 Drizzle 迁移日志。 +- 只有 Preview 使用独立、可丢弃的数据库时,才可显式设置 `VERCEL_PREVIEW_DATABASE_MIGRATIONS=true`。 +- Production 迁移继续失败即阻断,并在执行 DDL 前校验本地迁移的时间戳与哈希。出现旧谱系、缺失迁移或哈希分叉时必须人工核对,禁止自动覆盖迁移日志。 +- Issue #34 正式切换前,必须把目标库的旧迁移谱系核对和基线处置记录到环境 ADR;Preview 构建通过不代表该门禁已完成。 + ## Release 行为矩阵 | 行为边界 | 自动证据 | Ego Browser / API smoke | Cutover 门禁 | diff --git a/scripts/vercel-migrate.mjs b/scripts/vercel-migrate.mjs index 2ff170af..4f5fad30 100644 --- a/scripts/vercel-migrate.mjs +++ b/scripts/vercel-migrate.mjs @@ -15,6 +15,18 @@ const MIGRATIONS_FOLDER = "./drizzle" const MIGRATIONS_SCHEMA = "drizzle" const MIGRATIONS_TABLE = "__drizzle_migrations" +// Preview 默认共享项目数据库;在 build 阶段自动跑 DDL 会让多个分支争用同一迁移日志。 +// 只有为该 Preview 配置了隔离数据库并显式确认时才允许迁移。 +if ( + process.env.VERCEL_ENV === "preview" && + process.env.VERCEL_PREVIEW_DATABASE_MIGRATIONS !== "true" +) { + console.log( + "[vercel-build] Preview 默认不执行数据库迁移;构建继续。若使用隔离预览库,请显式设置 VERCEL_PREVIEW_DATABASE_MIGRATIONS=true。" + ) + process.exit(0) +} + const rawUrl = process.env.DIRECT_URL || process.env.DATABASE_URL if (!rawUrl) { From c99684828b74c6194d67bd40d35f4d5663a3502f Mon Sep 17 00:00:00 2001 From: zilin <276161014@qq.com> Date: Sat, 22 Aug 2026 05:28:35 +0800 Subject: [PATCH 10/32] test(api): verify canonical HTTP against postgres --- .../conversation-command-contracts.ts | 33 ++ .../http/conversation-command-http.test.ts | 20 + .../http/conversation-command-http.ts | 3 +- .../add-conversation-command-api/tasks.md | 4 +- .../tasks.md | 3 +- .../retire-thread-tree-authority/tasks.md | 2 +- package.json | 1 + scripts/test-conversation-http-api.ts | 349 ++++++++++++++++++ 8 files changed, 410 insertions(+), 5 deletions(-) create mode 100644 scripts/test-conversation-http-api.ts diff --git a/lib/thread-chat/application/conversation-command-contracts.ts b/lib/thread-chat/application/conversation-command-contracts.ts index 1c00862d..908f7fd9 100644 --- a/lib/thread-chat/application/conversation-command-contracts.ts +++ b/lib/thread-chat/application/conversation-command-contracts.ts @@ -76,6 +76,39 @@ export class ConversationCommandError extends Error { } } +/** 跨 Next.js bundle 识别受控应用错误;不能依赖可能重复加载的 class identity。 */ +export function isConversationCommandError( + error: unknown +): error is ConversationCommandError { + if (!(error instanceof Error)) return false + const candidate = error as Error & { + readonly code?: unknown + readonly details?: unknown + } + return ( + candidate.name === "ConversationCommandError" && + typeof candidate.code === "string" && + [ + "invalid_request", + "unauthenticated", + "forbidden", + "not_found", + "version_conflict", + "idempotency_conflict", + "state_conflict", + "semantic_validation", + "fork_required", + "conversation_action_required", + "rate_limited", + "maintenance", + "internal", + ].includes(candidate.code) && + !!candidate.details && + typeof candidate.details === "object" && + !Array.isArray(candidate.details) + ) +} + export interface CanonicalEntityDelta { readonly upsert: { readonly conversations?: readonly Conversation[] diff --git a/lib/thread-chat/http/conversation-command-http.test.ts b/lib/thread-chat/http/conversation-command-http.test.ts index 42fa27d5..33eda6aa 100644 --- a/lib/thread-chat/http/conversation-command-http.test.ts +++ b/lib/thread-chat/http/conversation-command-http.test.ts @@ -93,6 +93,26 @@ test("错误分类只把真实冲突映射为 409", async () => { conversationErrorTransportSchema.parse(await version.json()) }) +test("错误边界跨 bundle 结构识别受控应用错误并拒绝未知错误码", async () => { + const foreignError = Object.assign(new Error("幂等冲突"), { + name: "ConversationCommandError", + code: "idempotency_conflict", + details: {}, + }) + const spoofedError = Object.assign(new Error("伪造冲突"), { + name: "ConversationCommandError", + code: "drop_database", + details: {}, + }) + assert.equal(routeErrorResponse(foreignError).status, 409) + const spoofed = routeErrorResponse(spoofedError) + assert.equal(spoofed.status, 500) + assert.equal( + (await spoofed.json()).error.code, + "internal" + ) +}) + test("成功 envelope 按显式并发作用域返回 ETag", async () => { const result: CommandSuccess = { schemaVersion: 1, diff --git a/lib/thread-chat/http/conversation-command-http.ts b/lib/thread-chat/http/conversation-command-http.ts index ad41dabc..91f9b38d 100644 --- a/lib/thread-chat/http/conversation-command-http.ts +++ b/lib/thread-chat/http/conversation-command-http.ts @@ -9,6 +9,7 @@ import { import { getCurrentUserId } from "../../auth/server" import { ConversationCommandError, + isConversationCommandError, type CommandSuccess, type CommandEnvelope, type CommandScope, @@ -143,7 +144,7 @@ export function queryResponse(data: unknown, revision?: number): Response { export function routeErrorResponse(error: unknown): Response { const requestId = randomUUID() let normalized: ConversationCommandError - if (error instanceof ConversationCommandError) normalized = error + if (isConversationCommandError(error)) normalized = error else if (error instanceof CanonicalGenerationServiceError) normalized = new ConversationCommandError( error.code === "not_found" diff --git a/openspec/changes/add-conversation-command-api/tasks.md b/openspec/changes/add-conversation-command-api/tasks.md index 1df0b950..c4aabef8 100644 --- a/openspec/changes/add-conversation-command-api/tasks.md +++ b/openspec/changes/add-conversation-command-api/tasks.md @@ -45,8 +45,8 @@ ## 验证记录(2026-08-22) - `pnpm test:conversation-command-api`:通过 59 项数据库断言,覆盖 root → A → B → C、无 Message 复制、不同幂等键并发发送/Fork 的 revision 互斥、事务回滚、变体、Stop、归档/恢复/删除,以及 outbox → canonical executor → checkpoint/终态。 -- `pnpm test:conversation-command-contract`:4/4 通过,覆盖 owner 伪造、Header 映射、稳定错误状态和成功 envelope/ETag。 -- 真实登录态 HTTP 路由:创建与等价重放均为 200,旧 revision 为 409,发送、Generation 查询、Stop、快照为 200,owner 伪造为 400;专用数据库夹具已清理。 +- `pnpm test:conversation-command-contract`:5/5 通过,覆盖 owner 伪造、Header 映射、稳定错误状态、跨 Next.js bundle 应用错误识别和成功 envelope/ETag。 +- `pnpm test:conversation-http-api -- --real-model`:31 项真实 HTTP + 本地 PostgreSQL 断言通过;使用邮箱密码测试账号(未使用 Google)覆盖 authority、401、创建/幂等重放/同键异载荷 409、ETag/If-Match、重命名、删除、真实 `glm-5.3` Generation 终态与 Message feedback,测试数据完成清理。 - Ego Browser:使用 `glm-5.3` 发送“只回复:Command API 回归通过”,模型正确回复,刷新后用户消息、助手回复和模型选择均保持。 - 回归:领域模型 8/8、规范持久化 26 项、Generation 单元 6/6、Generation 数据库 49 项、遗留审计 2/2 均通过。 - `pnpm typecheck`、目标文件 ESLint(0 warning)和 `pnpm openspec:validate`(31/31 strict)均通过。 diff --git a/openspec/changes/normalize-conversation-client-state/tasks.md b/openspec/changes/normalize-conversation-client-state/tasks.md index d125f5f0..92c1dcbd 100644 --- a/openspec/changes/normalize-conversation-client-state/tasks.md +++ b/openspec/changes/normalize-conversation-client-state/tasks.md @@ -56,8 +56,9 @@ - `pnpm test:conversation-domain`:8/8 通过。 - `pnpm test:conversation-generation-unit`:7/7 通过;覆盖较早 Turn 重新生成时排除当前 Thread 后续消息。 - `pnpm test:conversation-client`:13/13 通过;覆盖原子快照、A → B → C、历史 Fork 来源、细粒度通知、幂等/冲突、GenerationCoordinator、稳定反馈 ID、Markdown/研究活动/Artifact 来源。 -- `pnpm test:conversation-command-contract`:4/4 通过。 +- `pnpm test:conversation-command-contract`:5/5 通过,包含跨 Next.js bundle 的稳定错误映射回归。 - `pnpm test:conversation-generation`:52 项数据库断言通过;模型标识为 `glm-5.3`,覆盖浏览器断线后继续执行、Stop partial、持久化 `incomplete` 正文与研究活动、计费 exactly-once。 - `pnpm test:conversation-persistence`:26 项数据库断言通过;`root → A → B → C` 快照稳定且无整树写端口。 - `pnpm test:conversation-command-api`:69 项数据库断言通过;覆盖 outbox exactly-once、命令并发、嵌套 Fork、反馈权限/完成态/Generation 关联/幂等更新与删除。 +- `pnpm test:conversation-http-api -- --real-model`:31 项通过;从邮箱密码认证、HTTP headers/错误码一路落到本地 PostgreSQL,并实际以 `glm-5.3` 完成 Generation 和反馈回读。 - Ego Browser 任务空间 79:真实 GLM 5.3 发送、流式完成和刷新恢复;重新生成与变体选择;A → B → C 三列;3 节点/2 边画布;Conversation 与 Thread 标题、归档/恢复;复制状态;按稳定 Message ID 点赞并经 API 回读、刷新恢复;页面无错误提示。 diff --git a/openspec/changes/retire-thread-tree-authority/tasks.md b/openspec/changes/retire-thread-tree-authority/tasks.md index f3f5d10c..ae25b1a5 100644 --- a/openspec/changes/retire-thread-tree-authority/tasks.md +++ b/openspec/changes/retire-thread-tree-authority/tasks.md @@ -44,7 +44,7 @@ - `read-only` 维护模式拒绝会产生新事实的 legacy/canonical 写入;legacy GET 改为纯 owner 读取,不再认领无主树,也不借读取执行 stale 修复。Generation Stop 保留用于排空。 - 旧整树、active-leaf、Message feedback、legacy chat Generation 和 Generation sidecar 在 canonical authority 下统一返回 `410 legacy_protocol_retired`,并输出不含正文的协议名与 epoch 遥测。 - `pnpm check:conversation-cutover-drain` 只读检查 legacy/canonical 非终态 Generation、待结算计费与 canonical outbox;任一非零以退出码 2 阻止 cutover。本地实测五项均为 0、`ready=true`。 -- 自动验证:typecheck;domain 8/8;Generation unit 7/7;authority/drain/import 6/6;client 14/14;command contract 4/4;数据库 persistence/Generation/command API 分别 26/52/69 assertions;OpenSpec strict 31/31;canonical production build 成功。Ego Browser 实测健康三元组为 `canonical / 1 / local-issue34-20260822`,匹配后 3 列页面与 composer 加载成功;旧整树与旧 Generation 查询均返回 410。 +- 自动验证:typecheck;domain 8/8;Generation unit 7/7;authority/drain/import 6/6;client 14/14;command contract 5/5;数据库 persistence/Generation/command API 分别 26/52/69 assertions;真实 HTTP + 本地 PostgreSQL + 邮箱认证 + `glm-5.3` 31 assertions;OpenSpec strict 31/31;canonical production build 成功。Ego Browser 实测健康三元组为 `canonical / 1 / local-issue34-20260822`,匹配后 3 列页面与 composer 加载成功;旧整树与旧 Generation 查询均返回 410。 - 本记录只证明第 4 阶段的实现和本地隔离验收,不代表第 5 阶段的生产维护、备份、正式切换或批准已经发生。 ## 5. Cutover 演练与正式执行 diff --git a/package.json b/package.json index b8fdde03..12bf2408 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "test:conversation-generation-unit": "tsx --test lib/thread-chat/domain/conversation-generation.test.ts lib/thread-chat/generation/canonical-generation-execution.test.ts lib/thread-chat/generation/canonical-ai-generation-executor.test.ts", "test:conversation-generation": "tsx scripts/test-conversation-generation.ts", "test:conversation-command-api": "tsx scripts/test-conversation-command-api.ts", + "test:conversation-http-api": "tsx scripts/test-conversation-http-api.ts", "test:conversation-command-contract": "tsx --test lib/thread-chat/http/conversation-command-http.test.ts", "test:conversation-client": "tsx --test lib/thread-chat/client/normalized-conversation-store.test.ts", "test:conversation-authority": "tsx --test lib/thread-chat/cutover/conversation-authority.test.ts lib/thread-chat/cutover/conversation-drain.test.ts lib/thread-chat/cutover/legacy-conversation-import.test.ts", diff --git a/scripts/test-conversation-http-api.ts b/scripts/test-conversation-http-api.ts new file mode 100644 index 00000000..2eb7ced3 --- /dev/null +++ b/scripts/test-conversation-http-api.ts @@ -0,0 +1,349 @@ +import assert from "node:assert/strict" +import { randomUUID } from "node:crypto" + +import { config } from "dotenv" +import { eq, inArray } from "drizzle-orm" + +config({ path: ".env.local" }) + +const baseUrl = new URL( + process.env.CONVERSATION_HTTP_TEST_BASE_URL || "http://localhost:4040" +) +const email = + process.env.CONVERSATION_HTTP_TEST_EMAIL || + "codex.issue34.20260822@example.com" +const password = process.env.CONVERSATION_HTTP_TEST_PASSWORD +const realModel = process.argv.includes("--real-model") + +if (!password) + throw new Error( + "缺少 CONVERSATION_HTTP_TEST_PASSWORD;请在 .env.local 配置邮箱测试账号密码。" + ) + +const { db } = await import("../lib/db/index.ts") +const { + conversationCommandRecords, + conversationOutboxEvents, + projects, + usageRecords, + user, + workspaceMembers, + workspaces, +} = await import("../lib/db/schema.ts") + +const runId = randomUUID() +const prefix = `http-api-test:${runId}` +const workspaceId = `${prefix}:workspace` +const projectId = `${prefix}:project` +const conversationId = `${prefix}:conversation` +const rootThreadId = `${prefix}:thread:root` +const turnId = `${prefix}:turn:1` +const userMessageId = `${prefix}:message:user` +const assistantMessageId = `${prefix}:message:assistant` +const generationId = `${prefix}:generation` +const commandIds: string[] = [] +const aggregateIds = [ + conversationId, + rootThreadId, + turnId, + generationId, +] +let cookie = "" +let assertions = 0 + +type ApiResult = { + response: Response + body: unknown +} + +async function api( + path: string, + input: { + method?: string + body?: unknown + authenticated?: boolean + headers?: Record + } = {} +): Promise { + const headers = new Headers(input.headers) + headers.set("Origin", baseUrl.origin) + if (input.authenticated !== false) headers.set("Cookie", cookie) + if (input.body !== undefined) headers.set("Content-Type", "application/json") + const response = await fetch(new URL(path, baseUrl), { + method: input.method || "GET", + headers, + body: input.body === undefined ? undefined : JSON.stringify(input.body), + redirect: "manual", + }) + const text = await response.text() + let body: unknown = null + if (text) { + try { + body = JSON.parse(text) + } catch { + body = text + } + } + return { response, body } +} + +function commandHeaders( + suffix: string, + expectedRevision?: number +): Record { + const commandId = `${prefix}:command:${suffix}` + commandIds.push(commandId) + return { + "Idempotency-Key": `${prefix}:key:${suffix}`, + "X-Command-Id": commandId, + ...(expectedRevision === undefined + ? {} + : { "If-Match": `"${expectedRevision}"` }), + } +} + +function errorCode(body: unknown): string | undefined { + if (!body || typeof body !== "object" || !("error" in body)) return undefined + const error = body.error + if (!error || typeof error !== "object" || !("code" in error)) return undefined + return typeof error.code === "string" ? error.code : undefined +} + +function responseData(body: unknown): Record | undefined { + if (!body || typeof body !== "object" || !("data" in body)) return undefined + const data = body.data + return data && typeof data === "object" + ? (data as Record) + : undefined +} + +async function waitForGeneration(): Promise> { + const deadline = Date.now() + 120_000 + while (Date.now() < deadline) { + const result = await api(`/api/generations/${generationId}`) + assert.equal(result.response.status, 200) + const generation = responseData(result.body)?.generation + assert.ok(generation && typeof generation === "object") + const status = (generation as Record).status + if ( + status === "completed" || + status === "stopped" || + status === "failed" || + status === "superseded" + ) + return generation as Record + await new Promise((resolve) => setTimeout(resolve, 1_000)) + } + throw new Error("等待 GLM 5.3 Generation 终态超时") +} + +try { + const authority = await api("/api/conversation-authority", { + authenticated: false, + }) + assert.equal(authority.response.status, 200) + assert.equal( + (authority.body as { authority?: unknown } | null)?.authority, + "canonical" + ) + assertions += 2 + + const signIn = await api("/api/auth/sign-in/email", { + method: "POST", + authenticated: false, + body: { email, password, rememberMe: false }, + }) + assert.equal(signIn.response.status, 200, JSON.stringify(signIn.body)) + const setCookies = signIn.response.headers.getSetCookie() + cookie = setCookies + .map((value) => value.slice(0, value.indexOf(";"))) + .filter(Boolean) + .join("; ") + assert.ok(cookie.includes("session_token"), "邮箱登录未返回 session cookie") + assertions += 2 + + const [owner] = await db + .select({ id: user.id }) + .from(user) + .where(eq(user.email, email)) + .limit(1) + assert.ok(owner) + await db.insert(workspaces).values({ + id: workspaceId, + revision: 0, + lifecycle: "active", + }) + await db.insert(workspaceMembers).values({ + workspaceId, + userId: owner.id, + role: "owner", + }) + await db.insert(projects).values({ + id: projectId, + workspaceId, + title: "HTTP API 本地数据库测试", + revision: 0, + lifecycle: "active", + }) + + const unauthenticated = await api(`/api/projects/${projectId}/conversations`, { + authenticated: false, + }) + assert.equal(unauthenticated.response.status, 401) + assert.equal(errorCode(unauthenticated.body), "unauthenticated") + assertions += 2 + + const createHeaders = commandHeaders("create") + const createBody = { + conversationId, + rootThreadId, + title: "HTTP API Conversation", + modelId: "glm-5.3", + } + const created = await api(`/api/projects/${projectId}/conversations`, { + method: "POST", + headers: createHeaders, + body: createBody, + }) + assert.equal(created.response.status, 200, JSON.stringify(created.body)) + assert.equal(created.response.headers.get("etag"), '"0"') + assert.equal(responseData(created.body)?.replayed, undefined) + assert.equal((created.body as { replayed?: unknown }).replayed, false) + assertions += 4 + + const replayed = await api(`/api/projects/${projectId}/conversations`, { + method: "POST", + headers: createHeaders, + body: createBody, + }) + assert.equal(replayed.response.status, 200) + assert.equal((replayed.body as { replayed?: unknown }).replayed, true) + assertions += 2 + + const idempotencyConflict = await api( + `/api/projects/${projectId}/conversations`, + { + method: "POST", + headers: createHeaders, + body: { ...createBody, title: "同键不同载荷" }, + } + ) + assert.equal(idempotencyConflict.response.status, 409) + assert.equal(errorCode(idempotencyConflict.body), "idempotency_conflict") + assertions += 2 + + const snapshot = await api(`/api/conversations/${conversationId}`) + assert.equal(snapshot.response.status, 200) + assert.equal(snapshot.response.headers.get("etag"), '"0"') + const snapshotData = responseData(snapshot.body) + assert.equal( + (snapshotData?.snapshot as { conversation?: { rootThreadId?: unknown } }) + ?.conversation?.rootThreadId, + rootThreadId + ) + assertions += 3 + + const missingRevision = await api(`/api/conversations/${conversationId}`, { + method: "PATCH", + headers: commandHeaders("rename-missing-revision"), + body: { title: "缺少 If-Match" }, + }) + assert.equal(missingRevision.response.status, 400) + assert.equal(errorCode(missingRevision.body), "invalid_request") + assertions += 2 + + const renamed = await api(`/api/conversations/${conversationId}`, { + method: "PATCH", + headers: commandHeaders("rename", 0), + body: { title: "HTTP API 已重命名" }, + }) + assert.equal(renamed.response.status, 200, JSON.stringify(renamed.body)) + assert.equal(renamed.response.headers.get("etag"), '"1"') + assertions += 2 + + if (realModel) { + const sent = await api(`/api/threads/${rootThreadId}/turns`, { + method: "POST", + headers: commandHeaders("send", 0), + body: { + conversationId, + turnId, + userMessageId, + assistantMessageId, + generationId, + content: { + schemaVersion: 1, + parts: [ + { + type: "text", + text: "只回复:HTTP API GLM 5.3 测试通过", + }, + ], + }, + modelId: "glm-5.3", + }, + }) + assert.equal(sent.response.status, 200, JSON.stringify(sent.body)) + const generation = await waitForGeneration() + assert.equal(generation.status, "completed", JSON.stringify(generation)) + assert.equal(generation.modelId, "glm-5.3") + const checkpoint = generation.checkpoint as { body?: unknown } | undefined + assert.match(String(checkpoint?.body || ""), /HTTP API GLM 5\.3 测试通过/) + assertions += 4 + + const feedback = await api( + `/api/conversations/${conversationId}/messages/${assistantMessageId}/feedback`, + { + method: "PUT", + body: { threadId: rootThreadId, feedback: "positive" }, + } + ) + assert.equal(feedback.response.status, 200, JSON.stringify(feedback.body)) + const feedbackList = await api( + `/api/conversations/${conversationId}/message-feedback` + ) + assert.equal(feedbackList.response.status, 200) + assert.equal( + (feedbackList.body as { feedback?: Array<{ messageId?: unknown }> }) + .feedback?.[0]?.messageId, + assistantMessageId + ) + assertions += 3 + } + + const deleted = await api(`/api/conversations/${conversationId}`, { + method: "DELETE", + headers: commandHeaders("delete", 1), + }) + assert.equal(deleted.response.status, 200, JSON.stringify(deleted.body)) + const missing = await api(`/api/conversations/${conversationId}`) + assert.equal(missing.response.status, 404) + assert.equal(errorCode(missing.body), "not_found") + assertions += 3 + + console.log( + JSON.stringify({ + ok: true, + assertions, + transport: "HTTP", + database: "local-postgresql", + authentication: "email-password", + model: realModel ? "glm-5.3" : "not-invoked", + }) + ) +} finally { + if (cookie) + await api("/api/auth/sign-out", { method: "POST" }).catch(() => undefined) + await db + .delete(conversationOutboxEvents) + .where(inArray(conversationOutboxEvents.aggregateId, aggregateIds)) + if (commandIds.length > 0) + await db + .delete(conversationCommandRecords) + .where(inArray(conversationCommandRecords.id, commandIds)) + await db + .delete(usageRecords) + .where(eq(usageRecords.appGenerationId, generationId)) + await db.delete(workspaces).where(eq(workspaces.id, workspaceId)) + await globalThis.__dbClient?.end({ timeout: 5 }) +} From 94cb3f4632607679ed52f720fa321c914ef225e0 Mon Sep 17 00:00:00 2001 From: zilin <276161014@qq.com> Date: Sat, 22 Aug 2026 05:33:30 +0800 Subject: [PATCH 11/32] test(cutover): rehearse local backup restoration --- .../adr-issue-34-local-cutover-disposition.md | 20 ++ docs/architecture/issue-34-cutover-runbook.md | 1 + .../retire-thread-tree-authority/tasks.md | 6 + package.json | 1 + .../rehearse-conversation-cutover-local.mjs | 277 ++++++++++++++++++ 5 files changed, 305 insertions(+) create mode 100644 scripts/rehearse-conversation-cutover-local.mjs diff --git a/docs/architecture/adr-issue-34-local-cutover-disposition.md b/docs/architecture/adr-issue-34-local-cutover-disposition.md index dff5913d..69bbf0a8 100644 --- a/docs/architecture/adr-issue-34-local-cutover-disposition.md +++ b/docs/architecture/adr-issue-34-local-cutover-disposition.md @@ -31,3 +31,23 @@ flowchart LR 5. drain 门禁为 0。 生产环境不能复用本 ADR 的批准身份或测试备份标识,必须在真实审计后另建 ADR。 + +## 临时数据库备份恢复演练(2026-08-22) + +执行: + +```bash +pnpm rehearse:conversation-cutover-local -- --approve-local-ephemeral-databases +``` + +演练只允许 loopback 数据库,并使用受限前缀创建两个临时数据库。流程完成后会终止临时连接、删除临时数据库和 dump/审批文件;不会修改源开发库。 + +本次结果: + +- legacy dump SHA-256:`ca7424263d885d579414422a561121e191a6f86125e5df53c450090504a7af7e`;恢复后的 13 张 cutover 表合并指纹与源库一致:`565c2b20a6cb47e838756df1ed8793c80e16b929a72f00a1adc68c86a799b2e0`。 +- 恢复库在 `legacy + read-only` 下通过 drain(五项为 0)和 19 棵 legacy 树审计;使用带真实 dump hash、精确临时库名和获批 repair 的临时 ADR 执行确定性导入两次,第二次保持幂等。 +- 导入后共有 20 个 canonical Conversation(源库已有 1 个隔离浏览器夹具,新增 19 个 legacy Conversation)和 186 条持久映射。 +- canonical dump SHA-256:`ac92a9ff818e8c15e02d9b4007a73f8539a014f292392ee8ce21d904210ed6d8`;再次恢复后的 cutover 表合并指纹与导入库一致:`9b2d3fb89eac3aecc92bf03acbc374e789d1c3b09bde37f1d4efceba709e7bd2`。 +- 总耗时约 8.4 秒;两个临时数据库和所有临时文件均已删除。 + +这证明本地 PostgreSQL 的备份、恢复、drain、正式导入、幂等和 canonical 再恢复路径可执行;它仍不替代目标环境的基础设施等价演练、真实备份保留、负责人批准和切换观察窗口。 diff --git a/docs/architecture/issue-34-cutover-runbook.md b/docs/architecture/issue-34-cutover-runbook.md index 78daa66e..c8b6401c 100644 --- a/docs/architecture/issue-34-cutover-runbook.md +++ b/docs/architecture/issue-34-cutover-runbook.md @@ -44,6 +44,7 @@ define-conversation-domain-model - 导入:19 个 Conversation、187 个实体;同事务逐 Conversation 写入、post-import verifier、重复导入幂等检查全部通过,随后强制回滚。 - 回滚证明:`legacy_conversation_entity_mappings=0`,`conversations.id LIKE 'legacy:%'=0`。 - 客户端:`canonical / schema 1 / local-issue34-20260822` 匹配后正常加载;旧 tree 与 generation routes 返回 410。 +- 备份恢复:通过受限临时数据库完成 legacy dump→恢复→drain/审计→正式导入两次→canonical dump→再次恢复;两次恢复的 13 张 cutover 表指纹均与备份源一致,耗时约 8.4 秒,临时数据库与文件均已删除。 ## 正式 go / no-go checklist diff --git a/openspec/changes/retire-thread-tree-authority/tasks.md b/openspec/changes/retire-thread-tree-authority/tasks.md index ae25b1a5..615d8e6a 100644 --- a/openspec/changes/retire-thread-tree-authority/tasks.md +++ b/openspec/changes/retire-thread-tree-authority/tasks.md @@ -47,6 +47,12 @@ - 自动验证:typecheck;domain 8/8;Generation unit 7/7;authority/drain/import 6/6;client 14/14;command contract 5/5;数据库 persistence/Generation/command API 分别 26/52/69 assertions;真实 HTTP + 本地 PostgreSQL + 邮箱认证 + `glm-5.3` 31 assertions;OpenSpec strict 31/31;canonical production build 成功。Ego Browser 实测健康三元组为 `canonical / 1 / local-issue34-20260822`,匹配后 3 列页面与 composer 加载成功;旧整树与旧 Generation 查询均返回 410。 - 本记录只证明第 4 阶段的实现和本地隔离验收,不代表第 5 阶段的生产维护、备份、正式切换或批准已经发生。 +### 本地临时数据库恢复演练(不等同于第 5 阶段) + +- `pnpm rehearse:conversation-cutover-local -- --approve-local-ephemeral-databases` 在两个受限命名的 loopback 临时库中完成 legacy 备份恢复、`legacy + read-only` drain/审计、获批确定性导入两次、canonical 备份和再次恢复。 +- 两次恢复均以 13 张 cutover 表的逐表 JSONB SHA-256 合并指纹证明与各自源库一致;导入后为 20 个 Conversation(含源库已有 1 个隔离夹具)和 186 条映射,总耗时约 8.4 秒。 +- 演练结束自动删除临时库、dump 和审批文件;源开发库只读。由于尚未覆盖目标基础设施、真实保留备份、负责人/观察窗口和流量切换,1.4 与 5.1 仍保持未完成。 + ## 5. Cutover 演练与正式执行 - [ ] 5.1 在与生产等价的隔离环境完整演练冻结、drain、备份、import/reset、验证、authority/client 切换和 smoke,并记录耗时。 diff --git a/package.json b/package.json index 12bf2408..552be0c0 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "test:conversation-client": "tsx --test lib/thread-chat/client/normalized-conversation-store.test.ts", "test:conversation-authority": "tsx --test lib/thread-chat/cutover/conversation-authority.test.ts lib/thread-chat/cutover/conversation-drain.test.ts lib/thread-chat/cutover/legacy-conversation-import.test.ts", "check:conversation-cutover-drain": "tsx scripts/check-conversation-cutover-drain.ts", + "rehearse:conversation-cutover-local": "node scripts/rehearse-conversation-cutover-local.mjs", "seed:conversation-browser": "tsx scripts/seed-canonical-browser-fixture.ts", "test:conversation-persistence": "tsx scripts/test-conversation-persistence.ts", "test:legacy-conversation-audit": "node --experimental-strip-types --test lib/thread-chat/legacy/audit-thread-tree.test.ts", diff --git a/scripts/rehearse-conversation-cutover-local.mjs b/scripts/rehearse-conversation-cutover-local.mjs new file mode 100644 index 00000000..d92590be --- /dev/null +++ b/scripts/rehearse-conversation-cutover-local.mjs @@ -0,0 +1,277 @@ +import { createHash, randomUUID } from "node:crypto" +import { readFile, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { spawnSync } from "node:child_process" + +import { config } from "dotenv" +import postgres from "postgres" + +config({ path: ".env.local" }) + +if (!process.argv.includes("--approve-local-ephemeral-databases")) + throw new Error( + "本地演练会创建并删除临时数据库;必须显式传入 --approve-local-ephemeral-databases。" + ) + +const rawUrl = process.env.DIRECT_URL || process.env.DATABASE_URL +if (!rawUrl) throw new Error("未配置 DIRECT_URL 或 DATABASE_URL") +const sourceUrl = new URL(rawUrl.trim().replace(/^(['"])(.*)\1$/u, "$2")) +if (!["localhost", "127.0.0.1", "::1"].includes(sourceUrl.hostname)) + throw new Error("本演练只允许连接 localhost/loopback 数据库") + +const runId = randomUUID().replaceAll("-", "").slice(0, 12) +const databasePrefix = `issue34_rehearsal_${runId}` +const legacyRestoreName = `${databasePrefix}_legacy` +const canonicalRestoreName = `${databasePrefix}_canonical` +const legacyDump = join(tmpdir(), `${databasePrefix}_legacy.dump`) +const canonicalDump = join(tmpdir(), `${databasePrefix}_canonical.dump`) +const approvalPath = join(tmpdir(), `${databasePrefix}_approval.json`) +const startedAt = Date.now() + +const CUTOVER_TABLES = [ + "branch_trees", + "branch_generations", + "branch_message_feedback", + "usage_records", + "conversations", + "conversation_threads", + "thread_forks", + "conversation_turns", + "conversation_messages", + "conversation_generations", + "conversation_artifacts", + "conversation_message_feedback", + "legacy_conversation_entity_mappings", +] + +function databaseUrl(name) { + const url = new URL(sourceUrl) + url.pathname = `/${name}` + return url.toString() +} + +function run(command, args, env = process.env) { + const result = spawnSync(command, args, { + cwd: process.cwd(), + env, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }) + if (result.status !== 0) { + if (result.stdout) process.stdout.write(result.stdout) + if (result.stderr) process.stderr.write(result.stderr) + throw new Error(`${command} ${args.join(" ")} 退出码 ${result.status}`) + } + return result.stdout +} + +async function createDatabase(admin, name) { + if (!name.startsWith("issue34_rehearsal_")) + throw new Error("拒绝创建非演练数据库") + await admin.unsafe(`CREATE DATABASE "${name}" TEMPLATE template0`) +} + +async function dropDatabase(admin, name) { + if (!name.startsWith("issue34_rehearsal_")) + throw new Error("拒绝删除非演练数据库") + await admin` + SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = ${name} AND pid <> pg_backend_pid() + ` + await admin.unsafe(`DROP DATABASE IF EXISTS "${name}"`) +} + +async function fingerprint(url) { + const sql = postgres(url, { max: 1, prepare: false }) + try { + const tables = {} + for (const table of CUTOVER_TABLES) { + const [exists] = await sql` + SELECT to_regclass(${`thread_chat.${table}`})::text AS value + ` + if (!exists?.value) { + tables[table] = { count: 0, hash: "missing" } + continue + } + const [row] = await sql.unsafe( + `SELECT count(*)::int AS count, + COALESCE(jsonb_agg(to_jsonb(t) ORDER BY to_jsonb(t)::text), '[]'::jsonb) AS rows + FROM thread_chat."${table}" t` + ) + const serialized = JSON.stringify(row.rows) + tables[table] = { + count: row.count, + hash: createHash("sha256").update(serialized).digest("hex"), + } + } + const hash = createHash("sha256") + .update(JSON.stringify(tables)) + .digest("hex") + return { hash, tables } + } finally { + await sql.end() + } +} + +function cutoverEnv(url, approvalId) { + return { + ...process.env, + DATABASE_URL: url, + DIRECT_URL: url, + CONVERSATION_AUTHORITY: "legacy", + CONVERSATION_MAINTENANCE_MODE: "read-only", + CONVERSATION_ISOLATED_TEST: "true", + CONVERSATION_CUTOVER_APPROVAL_ID: approvalId, + } +} + +const adminUrl = new URL(sourceUrl) +adminUrl.pathname = "/postgres" +const admin = postgres(adminUrl.toString(), { max: 1, prepare: false }) + +try { + await createDatabase(admin, legacyRestoreName) + const sourceFingerprint = await fingerprint(sourceUrl.toString()) + + run("pg_dump", [ + "--format=custom", + "--no-owner", + "--no-privileges", + `--file=${legacyDump}`, + sourceUrl.toString(), + ]) + const legacyDumpHash = createHash("sha256") + .update(await readFile(legacyDump)) + .digest("hex") + run("pg_restore", [ + "--no-owner", + "--no-privileges", + `--dbname=${databaseUrl(legacyRestoreName)}`, + legacyDump, + ]) + + const restoredLegacyFingerprint = await fingerprint( + databaseUrl(legacyRestoreName) + ) + if (restoredLegacyFingerprint.hash !== sourceFingerprint.hash) + throw new Error("legacy 备份恢复后的 cutover 表指纹与源库不一致") + + const approvalId = `local-rehearsal-${runId}` + const approval = { + schemaVersion: 1, + action: "deterministic-import", + environment: "local-ephemeral-cutover-rehearsal", + database: { + host: sourceUrl.hostname, + name: legacyRestoreName, + }, + scope: { legacyTreeIds: "all" }, + backupId: `sha256:${legacyDumpHash}`, + approvalId, + approvedBy: "local-cutover-rehearsal", + approvedAt: new Date().toISOString(), + approvedRepairs: ["missing-generation-intent-as-send"], + } + await writeFile(approvalPath, `${JSON.stringify(approval, null, 2)}\n`) + const env = cutoverEnv(databaseUrl(legacyRestoreName), approvalId) + + run("pnpm", ["exec", "tsx", "scripts/check-conversation-cutover-drain.ts"], env) + run( + "pnpm", + ["exec", "tsx", "scripts/audit-legacy-conversations.ts"], + env + ) + run( + "pnpm", + [ + "exec", + "tsx", + "scripts/import-legacy-conversations.ts", + "--execute", + "--approval-file", + approvalPath, + ], + env + ) + // 第二次正式执行必须是幂等重放,而不是产生第二套 canonical 实体。 + run( + "pnpm", + [ + "exec", + "tsx", + "scripts/import-legacy-conversations.ts", + "--execute", + "--approval-file", + approvalPath, + ], + env + ) + + const importedFingerprint = await fingerprint(databaseUrl(legacyRestoreName)) + await createDatabase(admin, canonicalRestoreName) + run("pg_dump", [ + "--format=custom", + "--no-owner", + "--no-privileges", + `--file=${canonicalDump}`, + databaseUrl(legacyRestoreName), + ]) + const canonicalDumpHash = createHash("sha256") + .update(await readFile(canonicalDump)) + .digest("hex") + run("pg_restore", [ + "--no-owner", + "--no-privileges", + `--dbname=${databaseUrl(canonicalRestoreName)}`, + canonicalDump, + ]) + const restoredCanonicalFingerprint = await fingerprint( + databaseUrl(canonicalRestoreName) + ) + if (restoredCanonicalFingerprint.hash !== importedFingerprint.hash) + throw new Error("canonical 备份恢复后的 cutover 表指纹与导入库不一致") + + console.log( + JSON.stringify( + { + ok: true, + mode: "local-ephemeral-cutover-rehearsal", + sourceDatabase: sourceUrl.pathname.slice(1), + legacyBackup: { + id: `sha256:${legacyDumpHash}`, + sourceFingerprint: sourceFingerprint.hash, + restoredFingerprint: restoredLegacyFingerprint.hash, + matches: restoredLegacyFingerprint.hash === sourceFingerprint.hash, + }, + canonicalBackup: { + id: `sha256:${canonicalDumpHash}`, + sourceFingerprint: importedFingerprint.hash, + restoredFingerprint: restoredCanonicalFingerprint.hash, + matches: + restoredCanonicalFingerprint.hash === importedFingerprint.hash, + }, + imported: { + conversations: + importedFingerprint.tables.conversations?.count ?? 0, + mappings: + importedFingerprint.tables.legacy_conversation_entity_mappings + ?.count ?? 0, + }, + elapsedMs: Date.now() - startedAt, + }, + null, + 2 + ) + ) +} finally { + await dropDatabase(admin, canonicalRestoreName).catch(() => undefined) + await dropDatabase(admin, legacyRestoreName).catch(() => undefined) + await admin.end() + await Promise.all([ + rm(legacyDump, { force: true }), + rm(canonicalDump, { force: true }), + rm(approvalPath, { force: true }), + ]) +} From 5f57dad9d72e9f685862e969c09c5700698d0531 Mon Sep 17 00:00:00 2001 From: zilin <276161014@qq.com> Date: Sat, 22 Aug 2026 05:42:39 +0800 Subject: [PATCH 12/32] feat(cutover): add approved reset path --- docs/architecture/issue-34-cutover-runbook.md | 29 +- .../approved-conversation-reset.test.ts | 105 +++++++ .../cutover/approved-conversation-reset.ts | 110 ++++++++ .../retire-thread-tree-authority/tasks.md | 5 +- package.json | 3 +- .../rehearse-conversation-cutover-local.mjs | 129 ++++++++- scripts/reset-approved-conversations.ts | 264 ++++++++++++++++++ 7 files changed, 625 insertions(+), 20 deletions(-) create mode 100644 lib/thread-chat/cutover/approved-conversation-reset.test.ts create mode 100644 lib/thread-chat/cutover/approved-conversation-reset.ts create mode 100644 scripts/reset-approved-conversations.ts diff --git a/docs/architecture/issue-34-cutover-runbook.md b/docs/architecture/issue-34-cutover-runbook.md index c8b6401c..1e7b9faa 100644 --- a/docs/architecture/issue-34-cutover-runbook.md +++ b/docs/architecture/issue-34-cutover-runbook.md @@ -23,18 +23,18 @@ define-conversation-domain-model ## Release 行为矩阵 -| 行为边界 | 自动证据 | Ego Browser / API smoke | Cutover 门禁 | -| --- | --- | --- | --- | -| owner/workspace/project 隔离 | persistence、command API DB tests | 已登录测试账号读取规范 Conversation | 越权统一不可见;无跨 owner 引用 | -| Conversation 列表/创建/标题/归档/恢复/删除 | command contract/API、client tests | 列表、重命名、归档/恢复已验证 | revision 与幂等键稳定 | -| Thread 标题/归档/恢复 | command API、client tests | 三列 A→B→C、最深 Thread 归档/恢复 | 根 Thread 不可被错误归档或重挂 | -| Fork/selection/A→B→C | domain、persistence、client tests | 列/树列表/画布均显示 3 节点 2 边 | 来源 Message 精确且图无环 | -| send/edit/regenerate/select variant | generation、command、client tests | GLM 5.3 流式、刷新、变体选择已验证 | current Generation 每 Turn 唯一 | -| Markdown/研究/Artifact | generation checkpoint、client tests | Markdown/结构化研究与 Artifact 定位 | Artifact 来源是稳定 Thread/Message ID | -| copy/feedback/actions | client 与 feedback DB tests | 复制状态、赞/踩刷新保持 | feedback 使用稳定 Message ID | -| disconnect/Stop/stale/refresh | generation unit/DB、coordinator tests | partial Stop 与刷新恢复已验证 | 非终态与 pending billing 必须清零 | -| usage/exactly-once billing | generation DB tests | 不依赖浏览器连接 | pending billing=0;usage 终态明确 | -| authority 与旧协议 | authority/client tests | authority 三元组匹配;旧 route 返回 410 | 不存在分裂开关或 fallback | +| 行为边界 | 自动证据 | Ego Browser / API smoke | Cutover 门禁 | +| ------------------------------------------ | ------------------------------------- | --------------------------------------- | ------------------------------------- | +| owner/workspace/project 隔离 | persistence、command API DB tests | 已登录测试账号读取规范 Conversation | 越权统一不可见;无跨 owner 引用 | +| Conversation 列表/创建/标题/归档/恢复/删除 | command contract/API、client tests | 列表、重命名、归档/恢复已验证 | revision 与幂等键稳定 | +| Thread 标题/归档/恢复 | command API、client tests | 三列 A→B→C、最深 Thread 归档/恢复 | 根 Thread 不可被错误归档或重挂 | +| Fork/selection/A→B→C | domain、persistence、client tests | 列/树列表/画布均显示 3 节点 2 边 | 来源 Message 精确且图无环 | +| send/edit/regenerate/select variant | generation、command、client tests | GLM 5.3 流式、刷新、变体选择已验证 | current Generation 每 Turn 唯一 | +| Markdown/研究/Artifact | generation checkpoint、client tests | Markdown/结构化研究与 Artifact 定位 | Artifact 来源是稳定 Thread/Message ID | +| copy/feedback/actions | client 与 feedback DB tests | 复制状态、赞/踩刷新保持 | feedback 使用稳定 Message ID | +| disconnect/Stop/stale/refresh | generation unit/DB、coordinator tests | partial Stop 与刷新恢复已验证 | 非终态与 pending billing 必须清零 | +| usage/exactly-once billing | generation DB tests | 不依赖浏览器连接 | pending billing=0;usage 终态明确 | +| authority 与旧协议 | authority/client tests | authority 三元组匹配;旧 route 返回 410 | 不存在分裂开关或 fallback | ## 本地 cutover 演练结果(2026-08-22) @@ -55,6 +55,11 @@ define-conversation-domain-model - [ ] 创建 legacy 与 canonical 备份,执行真实恢复验证并填写备份 ID。 - [ ] 进入 `legacy + read-only`,运行 drain checker 直至 `ready=true`。 - [ ] 用目标审批文件执行 import/reset,保存 verifier 结果。 + +受批准 reset 必须同时提供 ADR 审批文件与独立的备份恢复验证文件,并设置与二者精确匹配的 `CONVERSATION_CUTOVER_ENVIRONMENT`、`CONVERSATION_CUTOVER_APPROVAL_ID`、`CONVERSATION_CUTOVER_BACKUP_ID`。只有在 `legacy + read-only`、drain 清零且 `CONVERSATION_APPROVED_RESET_ENABLED=true` 时,才可运行 `pnpm reset:approved-conversations -- --execute --approval-file --backup-verification-file `。先用 `--test-rollback` 完成同一事务路径的强制回滚验证。工具保留 `usage_records`,任何财务账本处置必须走独立 ADR。 + +本地临时库演练已覆盖完整 reset SQL:在 canonical 备份恢复副本中删除审批 scope 内实体后强制回滚,13 张 cutover 表的合并指纹在前后完全一致,并验证关联的 5 条 `usage_records` 未被删除。该证据只证明工具路径,不构成目标环境的数据丢弃批准。 + - [ ] 原子切换 canonical server/client epoch,运行行为矩阵关键 smoke。 - [ ] 确认首个 canonical 生产写入时间;此后禁止恢复落后的 legacy 权威。 - [ ] 观察期内 legacy route/query 为 0,完整性与计费对账连续通过。 diff --git a/lib/thread-chat/cutover/approved-conversation-reset.test.ts b/lib/thread-chat/cutover/approved-conversation-reset.test.ts new file mode 100644 index 00000000..de521206 --- /dev/null +++ b/lib/thread-chat/cutover/approved-conversation-reset.test.ts @@ -0,0 +1,105 @@ +import assert from "node:assert/strict" +import test from "node:test" + +import { + approvedConversationResetSchema, + assertApprovedConversationResetContext, + conversationBackupVerificationSchema, +} from "./approved-conversation-reset.ts" + +const database = { host: "127.0.0.1", name: "thread-chat" } +const expected = { + legacyTrees: 1, + legacyGenerations: 2, + legacyFeedback: 1, + mappings: 9, + canonicalConversations: 1, + canonicalGenerations: 2, + canonicalFeedback: 1, + canonicalArtifacts: 0, + commandRecords: 0, + outboxEvents: 0, + preservedUsageRecords: 2, +} +const approval = approvedConversationResetSchema.parse({ + schemaVersion: 1, + action: "approved-conversation-reset", + environment: "isolated-rehearsal", + database, + scope: { legacyTreeIds: ["tree-1"] }, + expected, + backupId: "backup-1", + approvalId: "adr-34-reset-1", + approvedBy: "owner@example.com", + approvedAt: "2026-08-22T00:00:00.000Z", + reason: "无保留义务的隔离演练数据", +}) +const backup = conversationBackupVerificationSchema.parse({ + schemaVersion: 1, + action: "conversation-backup-verification", + environment: "isolated-rehearsal", + database, + backupId: "backup-1", + backupSha256: "a".repeat(64), + restoreTestId: "restore-1", + verifiedAt: "2026-08-22T00:10:00.000Z", + verifiedBy: "owner@example.com", +}) + +function validContext() { + return { + approval, + backup, + environment: "isolated-rehearsal", + databaseHost: database.host, + databaseName: database.name, + approvalId: approval.approvalId, + backupId: approval.backupId, + resetEnabled: "true", + actualCounts: expected, + } +} + +test("精确匹配环境、审批、备份恢复证明与计数时允许重置", () => { + assert.doesNotThrow(() => + assertApprovedConversationResetContext(validContext()) + ) +}) + +test("缺少独立 reset 开关时 fail closed", () => { + assert.throws( + () => + assertApprovedConversationResetContext({ + ...validContext(), + resetEnabled: undefined, + }), + /APPROVED_RESET_ENABLED/u + ) +}) + +test("环境、备份标识或预计计数漂移时拒绝执行", () => { + assert.throws( + () => + assertApprovedConversationResetContext({ + ...validContext(), + environment: "production", + }), + /环境/u + ) + assert.throws( + () => + assertApprovedConversationResetContext({ + ...validContext(), + backupId: "other-backup", + }), + /BACKUP_ID/u + ) + assert.throws( + () => + assertApprovedConversationResetContext({ + ...validContext(), + actualCounts: { ...expected, legacyTrees: 2 }, + }), + /计数/u + ) +}) diff --git a/lib/thread-chat/cutover/approved-conversation-reset.ts b/lib/thread-chat/cutover/approved-conversation-reset.ts new file mode 100644 index 00000000..94110b53 --- /dev/null +++ b/lib/thread-chat/cutover/approved-conversation-reset.ts @@ -0,0 +1,110 @@ +import { z } from "zod" + +const resetCountsSchema = z + .object({ + legacyTrees: z.number().int().nonnegative(), + legacyGenerations: z.number().int().nonnegative(), + legacyFeedback: z.number().int().nonnegative(), + mappings: z.number().int().nonnegative(), + canonicalConversations: z.number().int().nonnegative(), + canonicalGenerations: z.number().int().nonnegative(), + canonicalFeedback: z.number().int().nonnegative(), + canonicalArtifacts: z.number().int().nonnegative(), + commandRecords: z.number().int().nonnegative(), + outboxEvents: z.number().int().nonnegative(), + preservedUsageRecords: z.number().int().nonnegative(), + }) + .strict() + +export const approvedConversationResetSchema = z + .object({ + schemaVersion: z.literal(1), + action: z.literal("approved-conversation-reset"), + environment: z.string().trim().min(1), + database: z + .object({ + host: z.string().trim().min(1), + name: z.string().trim().min(1), + }) + .strict(), + scope: z + .object({ + legacyTreeIds: z.union([ + z.literal("all"), + z.array(z.string().trim().min(1)).min(1), + ]), + }) + .strict(), + expected: resetCountsSchema, + backupId: z.string().trim().min(1), + approvalId: z.string().trim().min(1), + approvedBy: z.string().trim().min(1), + approvedAt: z.string().datetime(), + reason: z.string().trim().min(1), + }) + .strict() + +export const conversationBackupVerificationSchema = z + .object({ + schemaVersion: z.literal(1), + action: z.literal("conversation-backup-verification"), + environment: z.string().trim().min(1), + database: z + .object({ + host: z.string().trim().min(1), + name: z.string().trim().min(1), + }) + .strict(), + backupId: z.string().trim().min(1), + backupSha256: z.string().regex(/^[a-f0-9]{64}$/u), + restoreTestId: z.string().trim().min(1), + verifiedAt: z.string().datetime(), + verifiedBy: z.string().trim().min(1), + }) + .strict() + +export type ApprovedConversationReset = z.infer< + typeof approvedConversationResetSchema +> +export type ConversationBackupVerification = z.infer< + typeof conversationBackupVerificationSchema +> +export type ApprovedConversationResetCounts = z.infer + +export function assertApprovedConversationResetContext(input: { + readonly approval: ApprovedConversationReset + readonly backup: ConversationBackupVerification + readonly environment: string | undefined + readonly databaseHost: string + readonly databaseName: string + readonly approvalId: string | undefined + readonly backupId: string | undefined + readonly resetEnabled: string | undefined + readonly actualCounts: ApprovedConversationResetCounts +}): void { + const { approval, backup } = input + if (input.resetEnabled !== "true") + throw new Error("CONVERSATION_APPROVED_RESET_ENABLED 必须显式为 true") + if (!input.environment || input.environment !== approval.environment) + throw new Error("当前环境与重置审批不匹配") + if (input.approvalId !== approval.approvalId) + throw new Error("CONVERSATION_CUTOVER_APPROVAL_ID 与重置审批不匹配") + if (input.backupId !== approval.backupId) + throw new Error("CONVERSATION_CUTOVER_BACKUP_ID 与重置审批不匹配") + if ( + input.databaseHost !== approval.database.host || + input.databaseName !== approval.database.name + ) + throw new Error("当前数据库与重置审批目标不匹配") + if ( + backup.environment !== approval.environment || + backup.database.host !== approval.database.host || + backup.database.name !== approval.database.name || + backup.backupId !== approval.backupId + ) + throw new Error("备份恢复验证与重置审批不匹配") + if (JSON.stringify(input.actualCounts) !== JSON.stringify(approval.expected)) + throw new Error( + `重置前计数与审批不一致:expected=${JSON.stringify(approval.expected)} actual=${JSON.stringify(input.actualCounts)}` + ) +} diff --git a/openspec/changes/retire-thread-tree-authority/tasks.md b/openspec/changes/retire-thread-tree-authority/tasks.md index 615d8e6a..d3249d69 100644 --- a/openspec/changes/retire-thread-tree-authority/tasks.md +++ b/openspec/changes/retire-thread-tree-authority/tasks.md @@ -17,7 +17,7 @@ - [x] 3.1 实现 `(legacyTreeId, entityType, localId) → canonicalId` 的持久映射与幂等 dry-run/report。 - [x] 3.2 实现按 Conversation 事务导入 Project/Conversation/Thread/ThreadFork/Turn/Message 和当前有效变体。 - [x] 3.3 迁移 Generation、标题、Message feedback、Artifact、usage/billing 和其他审计确认的 sidecar 外键。 -- [ ] 3.4 实现受批准重置路径,仅接受 ADR scope 并在执行前验证目标环境与备份标识。 +- [x] 3.4 实现受批准重置路径,仅接受 ADR scope 并在执行前验证目标环境与备份标识。 - [x] 3.5 增加 A → B → C、Message 变体、partial Generation、辅助引用、重复运行和故障回滚测试。 - [x] 3.6 实现源/目标计数、映射、约束、悬空引用和摘要的全量 post-import verifier。 @@ -27,7 +27,7 @@ - dry-run 生成 19 个 Conversation、187 个实体和逐树稳定摘要;发现 5 条 `generation_intent_missing`,显式列为需要审批的 `missing-generation-intent-as-send`,不静默推断。 - 导入按 Conversation 事务写入 Project → Conversation → Thread、Turn/Message 变体、ThreadFork、Generation/checkpoint/usage/billing、标题、Artifact 和反馈;legacy Message 级 `isCurrent` 只在 active assistant 上映射为 canonical Turn 级 current Generation。 - 本地 `--test-rollback` 对全部 19 棵树完成写入、数据库约束、源/目标计数、映射 SHA-256 与重复导入幂等校验,随后强制回滚;回滚后 mapping 和 `legacy:%` Conversation 计数均为 0。 -- 3.4 保持未完成:仓库尚无任何目标环境获批的“丢弃数据” ADR,因此只实现并验证导入路径,不伪造重置授权。 +- 受批准重置工具要求 `legacy + read-only`、drain 清零、独立 reset 开关、环境/数据库/approval ID/backup ID 精确匹配、恢复演练证明和审批前计数完全一致。它只删除 scope 内 legacy Tree 与映射可证明来源的 canonical Conversation;保留 `usage_records` 财务账本,不允许把会话重置偷换成账单抹除。仓库只验证 fail-closed 合同与事务回滚能力;没有伪造或执行任何目标环境“丢弃数据” ADR。 ## 4. Authority、维护与旧协议拒绝 @@ -51,6 +51,7 @@ - `pnpm rehearse:conversation-cutover-local -- --approve-local-ephemeral-databases` 在两个受限命名的 loopback 临时库中完成 legacy 备份恢复、`legacy + read-only` drain/审计、获批确定性导入两次、canonical 备份和再次恢复。 - 两次恢复均以 13 张 cutover 表的逐表 JSONB SHA-256 合并指纹证明与各自源库一致;导入后为 20 个 Conversation(含源库已有 1 个隔离夹具)和 186 条映射,总耗时约 8.4 秒。 +- 同一演练在 canonical 恢复副本上执行受批准 reset 的完整 SQL 并强制事务回滚;回滚前后 13 表指纹一致,5 条相关 `usage_records` 保持不变。首次演练还实际发现并修正了非级联 Generation 复合外键所要求的删除顺序。 - 演练结束自动删除临时库、dump 和审批文件;源开发库只读。由于尚未覆盖目标基础设施、真实保留备份、负责人/观察窗口和流量切换,1.4 与 5.1 仍保持未完成。 ## 5. Cutover 演练与正式执行 diff --git a/package.json b/package.json index 552be0c0..9ca01c3b 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "test:conversation-http-api": "tsx scripts/test-conversation-http-api.ts", "test:conversation-command-contract": "tsx --test lib/thread-chat/http/conversation-command-http.test.ts", "test:conversation-client": "tsx --test lib/thread-chat/client/normalized-conversation-store.test.ts", - "test:conversation-authority": "tsx --test lib/thread-chat/cutover/conversation-authority.test.ts lib/thread-chat/cutover/conversation-drain.test.ts lib/thread-chat/cutover/legacy-conversation-import.test.ts", + "test:conversation-authority": "tsx --test lib/thread-chat/cutover/conversation-authority.test.ts lib/thread-chat/cutover/conversation-drain.test.ts lib/thread-chat/cutover/legacy-conversation-import.test.ts lib/thread-chat/cutover/approved-conversation-reset.test.ts", "check:conversation-cutover-drain": "tsx scripts/check-conversation-cutover-drain.ts", "rehearse:conversation-cutover-local": "node scripts/rehearse-conversation-cutover-local.mjs", "seed:conversation-browser": "tsx scripts/seed-canonical-browser-fixture.ts", @@ -29,6 +29,7 @@ "audit:conversation-domain": "node scripts/audit-thread-tree-boundary.mjs", "audit:legacy-conversations": "node --experimental-strip-types scripts/audit-legacy-conversations.ts", "import:legacy-conversations": "tsx scripts/import-legacy-conversations.ts", + "reset:approved-conversations": "tsx scripts/reset-approved-conversations.ts", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:reset-schema": "node scripts/reset-thread-chat-schema.mjs", diff --git a/scripts/rehearse-conversation-cutover-local.mjs b/scripts/rehearse-conversation-cutover-local.mjs index d92590be..4ed48132 100644 --- a/scripts/rehearse-conversation-cutover-local.mjs +++ b/scripts/rehearse-conversation-cutover-local.mjs @@ -27,6 +27,14 @@ const canonicalRestoreName = `${databasePrefix}_canonical` const legacyDump = join(tmpdir(), `${databasePrefix}_legacy.dump`) const canonicalDump = join(tmpdir(), `${databasePrefix}_canonical.dump`) const approvalPath = join(tmpdir(), `${databasePrefix}_approval.json`) +const resetApprovalPath = join( + tmpdir(), + `${databasePrefix}_reset-approval.json` +) +const backupVerificationPath = join( + tmpdir(), + `${databasePrefix}_backup-verification.json` +) const startedAt = Date.now() const CUTOVER_TABLES = [ @@ -115,7 +123,7 @@ async function fingerprint(url) { } } -function cutoverEnv(url, approvalId) { +function cutoverEnv(url, approvalId, extra = {}) { return { ...process.env, DATABASE_URL: url, @@ -124,6 +132,44 @@ function cutoverEnv(url, approvalId) { CONVERSATION_MAINTENANCE_MODE: "read-only", CONVERSATION_ISOLATED_TEST: "true", CONVERSATION_CUTOVER_APPROVAL_ID: approvalId, + ...extra, + } +} + +async function resetCounts(url) { + const sql = postgres(url, { max: 1, prepare: false }) + try { + const [row] = await sql`SELECT + (SELECT count(*)::int FROM thread_chat.branch_trees) AS "legacyTrees", + (SELECT count(*)::int FROM thread_chat.branch_generations) AS "legacyGenerations", + (SELECT count(*)::int FROM thread_chat.branch_message_feedback) AS "legacyFeedback", + (SELECT count(*)::int FROM thread_chat.legacy_conversation_entity_mappings) AS mappings, + (SELECT count(*)::int FROM thread_chat.conversations c WHERE c.id IN ( + SELECT canonical_id FROM thread_chat.legacy_conversation_entity_mappings WHERE entity_type = 'conversation' + )) AS "canonicalConversations", + (SELECT count(*)::int FROM thread_chat.conversation_generations g WHERE g.conversation_id IN ( + SELECT canonical_id FROM thread_chat.legacy_conversation_entity_mappings WHERE entity_type = 'conversation' + )) AS "canonicalGenerations", + (SELECT count(*)::int FROM thread_chat.conversation_message_feedback f WHERE f.conversation_id IN ( + SELECT canonical_id FROM thread_chat.legacy_conversation_entity_mappings WHERE entity_type = 'conversation' + )) AS "canonicalFeedback", + (SELECT count(*)::int FROM thread_chat.conversation_artifacts a WHERE a.conversation_id IN ( + SELECT canonical_id FROM thread_chat.legacy_conversation_entity_mappings WHERE entity_type = 'conversation' + )) AS "canonicalArtifacts", + (SELECT count(*)::int FROM thread_chat.conversation_command_records r WHERE r.scope_id IN ( + SELECT canonical_id FROM thread_chat.legacy_conversation_entity_mappings + )) AS "commandRecords", + (SELECT count(*)::int FROM thread_chat.conversation_outbox_events e WHERE e.aggregate_id IN ( + SELECT canonical_id FROM thread_chat.legacy_conversation_entity_mappings + )) AS "outboxEvents", + (SELECT count(*)::int FROM thread_chat.usage_records u WHERE u.app_generation_id IN ( + SELECT canonical_id FROM thread_chat.legacy_conversation_entity_mappings WHERE entity_type = 'generation' + UNION + SELECT local_id FROM thread_chat.legacy_conversation_entity_mappings WHERE entity_type = 'generation' + )) AS "preservedUsageRecords"` + return row + } finally { + await sql.end() } } @@ -177,12 +223,12 @@ try { await writeFile(approvalPath, `${JSON.stringify(approval, null, 2)}\n`) const env = cutoverEnv(databaseUrl(legacyRestoreName), approvalId) - run("pnpm", ["exec", "tsx", "scripts/check-conversation-cutover-drain.ts"], env) run( "pnpm", - ["exec", "tsx", "scripts/audit-legacy-conversations.ts"], + ["exec", "tsx", "scripts/check-conversation-cutover-drain.ts"], env ) + run("pnpm", ["exec", "tsx", "scripts/audit-legacy-conversations.ts"], env) run( "pnpm", [ @@ -233,6 +279,68 @@ try { if (restoredCanonicalFingerprint.hash !== importedFingerprint.hash) throw new Error("canonical 备份恢复后的 cutover 表指纹与导入库不一致") + // 在已恢复的 canonical 备份副本上走完整 reset SQL,但强制事务回滚。 + const resetApprovalId = `local-reset-rehearsal-${runId}` + const resetBackupId = `sha256:${canonicalDumpHash}` + const resetApproval = { + schemaVersion: 1, + action: "approved-conversation-reset", + environment: "local-ephemeral-cutover-rehearsal", + database: { + host: sourceUrl.hostname, + name: canonicalRestoreName, + }, + scope: { legacyTreeIds: "all" }, + expected: await resetCounts(databaseUrl(canonicalRestoreName)), + backupId: resetBackupId, + approvalId: resetApprovalId, + approvedBy: "local-cutover-rehearsal", + approvedAt: new Date().toISOString(), + reason: "本地临时恢复库的事务回滚演练", + } + const backupVerification = { + schemaVersion: 1, + action: "conversation-backup-verification", + environment: resetApproval.environment, + database: resetApproval.database, + backupId: resetBackupId, + backupSha256: canonicalDumpHash, + restoreTestId: `local-restore-${runId}`, + verifiedAt: new Date().toISOString(), + verifiedBy: "local-cutover-rehearsal", + } + await writeFile( + resetApprovalPath, + `${JSON.stringify(resetApproval, null, 2)}\n` + ) + await writeFile( + backupVerificationPath, + `${JSON.stringify(backupVerification, null, 2)}\n` + ) + run( + "pnpm", + [ + "exec", + "tsx", + "scripts/reset-approved-conversations.ts", + "--test-rollback", + "--approval-file", + resetApprovalPath, + "--backup-verification-file", + backupVerificationPath, + ], + cutoverEnv(databaseUrl(canonicalRestoreName), resetApprovalId, { + CONVERSATION_CUTOVER_ENVIRONMENT: resetApproval.environment, + CONVERSATION_CUTOVER_BACKUP_ID: resetBackupId, + CONVERSATION_APPROVED_RESET_ENABLED: "true", + }) + ) + const postResetRollbackFingerprint = await fingerprint( + databaseUrl(canonicalRestoreName) + ) + if (postResetRollbackFingerprint.hash !== restoredCanonicalFingerprint.hash) + throw new Error("受批准 reset 回滚演练改变了恢复库指纹") + console.log( JSON.stringify( { @@ -253,12 +361,21 @@ try { restoredCanonicalFingerprint.hash === importedFingerprint.hash, }, imported: { - conversations: - importedFingerprint.tables.conversations?.count ?? 0, + conversations: importedFingerprint.tables.conversations?.count ?? 0, mappings: importedFingerprint.tables.legacy_conversation_entity_mappings ?.count ?? 0, }, + approvedReset: { + mode: "test-rollback", + scope: "all-legacy-mapped-conversations", + sourceFingerprint: restoredCanonicalFingerprint.hash, + rollbackFingerprint: postResetRollbackFingerprint.hash, + matches: + postResetRollbackFingerprint.hash === + restoredCanonicalFingerprint.hash, + preservedUsageRecords: resetApproval.expected.preservedUsageRecords, + }, elapsedMs: Date.now() - startedAt, }, null, @@ -273,5 +390,7 @@ try { rm(legacyDump, { force: true }), rm(canonicalDump, { force: true }), rm(approvalPath, { force: true }), + rm(resetApprovalPath, { force: true }), + rm(backupVerificationPath, { force: true }), ]) } diff --git a/scripts/reset-approved-conversations.ts b/scripts/reset-approved-conversations.ts new file mode 100644 index 00000000..876d0750 --- /dev/null +++ b/scripts/reset-approved-conversations.ts @@ -0,0 +1,264 @@ +import { readFile } from "node:fs/promises" + +import { config } from "dotenv" +import postgres from "postgres" + +import { + approvedConversationResetSchema, + assertApprovedConversationResetContext, + conversationBackupVerificationSchema, + type ApprovedConversationResetCounts, +} from "../lib/thread-chat/cutover/approved-conversation-reset.ts" +import { resolveConversationAuthority } from "../lib/thread-chat/cutover/conversation-authority.ts" +import { evaluateConversationCutoverDrain } from "../lib/thread-chat/cutover/conversation-drain.ts" + +config({ path: ".env.local" }) + +const execute = process.argv.includes("--execute") +const rollback = process.argv.includes("--test-rollback") +if (execute === rollback) + throw new Error("必须且只能指定 --execute 或 --test-rollback") + +function flagValue(name: string): string { + const index = process.argv.indexOf(name) + const value = index >= 0 ? process.argv[index + 1] : undefined + if (!value || value.startsWith("--")) throw new Error(`必须提供 ${name}`) + return value +} + +const approvalPath = flagValue("--approval-file") +const backupVerificationPath = flagValue("--backup-verification-file") +const rawUrl = process.env.DIRECT_URL || process.env.DATABASE_URL +if (!rawUrl) throw new Error("未配置 DIRECT_URL 或 DATABASE_URL") +const databaseUrl = rawUrl.trim().replace(/^(['"])(.*)\1$/u, "$2") +const parsedDatabaseUrl = new URL(databaseUrl) +const sql = postgres(databaseUrl, { max: 1, prepare: false }) + +async function readApprovalFiles() { + return { + approval: approvedConversationResetSchema.parse( + JSON.parse(await readFile(approvalPath, "utf8")) + ), + backup: conversationBackupVerificationSchema.parse( + JSON.parse(await readFile(backupVerificationPath, "utf8")) + ), + } +} + +async function assertDrained(tx: postgres.TransactionSql): Promise { + const [counts] = await tx< + readonly { + legacy_active: number + legacy_billing: number + canonical_active: number + canonical_billing: number + outbox_pending: number + }[] + >`SELECT + (SELECT count(*)::int FROM thread_chat.branch_generations WHERE status IN ('running', 'stop_requested')) AS legacy_active, + (SELECT count(*)::int FROM thread_chat.branch_generations WHERE billing_status = 'pending') AS legacy_billing, + (SELECT count(*)::int FROM thread_chat.conversation_generations WHERE status IN ('running', 'stop_requested')) AS canonical_active, + (SELECT count(*)::int FROM thread_chat.conversation_generations WHERE billing_status = 'pending') AS canonical_billing, + (SELECT count(*)::int FROM thread_chat.conversation_outbox_events WHERE status <> 'dispatched') AS outbox_pending` + if (!counts) throw new Error("无法读取 cutover drain 状态") + const drain = evaluateConversationCutoverDrain({ + legacyActiveGenerations: counts.legacy_active, + legacyPendingBilling: counts.legacy_billing, + canonicalActiveGenerations: counts.canonical_active, + canonicalPendingBilling: counts.canonical_billing, + canonicalPendingOutbox: counts.outbox_pending, + }) + if (!drain.ready) + throw new Error(`Cutover drain 未清零:${JSON.stringify(drain.blockers)}`) +} + +async function resolveScope( + tx: postgres.TransactionSql, + scope: "all" | readonly string[] +): Promise { + const rows = + scope === "all" + ? await tx` + SELECT id FROM thread_chat.branch_trees ORDER BY id` + : await tx` + SELECT id FROM thread_chat.branch_trees + WHERE id = ANY(${tx.array([...scope], 25)}) ORDER BY id` + if (scope !== "all" && rows.length !== new Set(scope).size) + throw new Error("重置审批 scope 中包含不存在或重复的 legacy tree") + return rows.map((entry) => entry.id) +} + +async function loadResetFacts( + tx: postgres.TransactionSql, + treeIds: readonly string[] +): Promise<{ + counts: ApprovedConversationResetCounts + canonicalIds: string[] + allMappedIds: string[] + generationIds: string[] + projectIds: string[] + workspaceIds: string[] +}> { + if (treeIds.length === 0) + return { + counts: { + legacyTrees: 0, + legacyGenerations: 0, + legacyFeedback: 0, + mappings: 0, + canonicalConversations: 0, + canonicalGenerations: 0, + canonicalFeedback: 0, + canonicalArtifacts: 0, + commandRecords: 0, + outboxEvents: 0, + preservedUsageRecords: 0, + }, + canonicalIds: [], + allMappedIds: [], + generationIds: [], + projectIds: [], + workspaceIds: [], + } + + const mappings = await tx< + readonly { entity_type: string; canonical_id: string; local_id: string }[] + >`SELECT entity_type, canonical_id, local_id + FROM thread_chat.legacy_conversation_entity_mappings + WHERE legacy_tree_id = ANY(${tx.array([...treeIds], 25)}) + ORDER BY legacy_tree_id, entity_type, local_id` + const canonicalIds = mappings + .filter((entry) => entry.entity_type === "conversation") + .map((entry) => entry.canonical_id) + const allMappedIds = mappings.map((entry) => entry.canonical_id) + const generationIds = mappings + .filter((entry) => entry.entity_type === "generation") + .flatMap((entry) => [entry.local_id, entry.canonical_id]) + + const [row] = await tx< + readonly (ApprovedConversationResetCounts & { + project_ids: string[] | null + workspace_ids: string[] | null + })[] + >`SELECT + (SELECT count(*)::int FROM thread_chat.branch_trees WHERE id = ANY(${tx.array([...treeIds], 25)})) AS "legacyTrees", + (SELECT count(*)::int FROM thread_chat.branch_generations WHERE tree_id = ANY(${tx.array([...treeIds], 25)})) AS "legacyGenerations", + (SELECT count(*)::int FROM thread_chat.branch_message_feedback WHERE tree_id = ANY(${tx.array([...treeIds], 25)})) AS "legacyFeedback", + ${mappings.length}::int AS mappings, + (SELECT count(*)::int FROM thread_chat.conversations WHERE id = ANY(${tx.array(canonicalIds, 25)})) AS "canonicalConversations", + (SELECT count(*)::int FROM thread_chat.conversation_generations WHERE conversation_id = ANY(${tx.array(canonicalIds, 25)})) AS "canonicalGenerations", + (SELECT count(*)::int FROM thread_chat.conversation_message_feedback WHERE conversation_id = ANY(${tx.array(canonicalIds, 25)})) AS "canonicalFeedback", + (SELECT count(*)::int FROM thread_chat.conversation_artifacts WHERE conversation_id = ANY(${tx.array(canonicalIds, 25)})) AS "canonicalArtifacts", + (SELECT count(*)::int FROM thread_chat.conversation_command_records WHERE scope_id = ANY(${tx.array(allMappedIds, 25)})) AS "commandRecords", + (SELECT count(*)::int FROM thread_chat.conversation_outbox_events WHERE aggregate_id = ANY(${tx.array(allMappedIds, 25)})) AS "outboxEvents", + (SELECT count(*)::int FROM thread_chat.usage_records WHERE app_generation_id = ANY(${tx.array(generationIds, 25)})) AS "preservedUsageRecords", + (SELECT array_agg(DISTINCT project_id) FROM thread_chat.conversations WHERE id = ANY(${tx.array(canonicalIds, 25)})) AS project_ids, + (SELECT array_agg(DISTINCT p.workspace_id) FROM thread_chat.conversations c JOIN thread_chat.projects p ON p.id = c.project_id WHERE c.id = ANY(${tx.array(canonicalIds, 25)})) AS workspace_ids` + if (!row) throw new Error("无法读取受批准重置的目标计数") + const { project_ids, workspace_ids, ...counts } = row + return { + counts, + canonicalIds, + allMappedIds, + generationIds, + projectIds: project_ids ?? [], + workspaceIds: workspace_ids ?? [], + } +} + +async function deleteApprovedScope( + tx: postgres.TransactionSql, + input: Awaited>, + treeIds: readonly string[] +): Promise { + if (treeIds.length === 0) return + // usage_records 是不可随会话重置消失的财务账本,刻意不删除。 + await tx`DELETE FROM thread_chat.conversation_command_records + WHERE scope_id = ANY(${tx.array(input.allMappedIds, 25)})` + await tx`DELETE FROM thread_chat.conversation_outbox_events + WHERE aggregate_id = ANY(${tx.array(input.allMappedIds, 25)})` + // Generation 的跨 Project/Conversation 复合外键刻意不是 cascade;先显式删除, + // 让 reset 顺序与正常领域删除的审计边界一致。usage_records 没有 FK,继续保留。 + await tx`DELETE FROM thread_chat.conversation_generations + WHERE conversation_id = ANY(${tx.array(input.canonicalIds, 25)})` + await tx`DELETE FROM thread_chat.conversations + WHERE id = ANY(${tx.array(input.canonicalIds, 25)})` + await tx`DELETE FROM thread_chat.legacy_conversation_entity_mappings + WHERE legacy_tree_id = ANY(${tx.array([...treeIds], 25)})` + await tx`DELETE FROM thread_chat.branch_trees + WHERE id = ANY(${tx.array([...treeIds], 25)})` + if (input.projectIds.length > 0) + await tx`DELETE FROM thread_chat.projects p + WHERE p.id = ANY(${tx.array(input.projectIds, 25)}) + AND p.id LIKE 'legacy-project:%' + AND NOT EXISTS (SELECT 1 FROM thread_chat.conversations c WHERE c.project_id = p.id)` + if (input.workspaceIds.length > 0) + await tx`DELETE FROM thread_chat.workspaces w + WHERE w.id = ANY(${tx.array(input.workspaceIds, 25)}) + AND w.id LIKE 'legacy-workspace:%' + AND NOT EXISTS (SELECT 1 FROM thread_chat.projects p WHERE p.workspace_id = w.id)` +} + +try { + const { approval, backup } = await readApprovalFiles() + const authority = resolveConversationAuthority() + if ( + authority.authority !== "legacy" || + authority.maintenanceMode !== "read-only" + ) + throw new Error("受批准重置只允许在 legacy + read-only 维护窗口执行") + + class RollbackProbe extends Error {} + let output: unknown + try { + await sql.begin(async (tx) => { + await tx`LOCK TABLE thread_chat.branch_trees, + thread_chat.branch_generations, + thread_chat.branch_message_feedback, + thread_chat.conversations, + thread_chat.conversation_generations, + thread_chat.conversation_command_records, + thread_chat.conversation_outbox_events, + thread_chat.legacy_conversation_entity_mappings + IN SHARE ROW EXCLUSIVE MODE` + await assertDrained(tx) + const treeIds = await resolveScope(tx, approval.scope.legacyTreeIds) + const facts = await loadResetFacts(tx, treeIds) + assertApprovedConversationResetContext({ + approval, + backup, + environment: process.env.CONVERSATION_CUTOVER_ENVIRONMENT, + databaseHost: parsedDatabaseUrl.hostname, + databaseName: parsedDatabaseUrl.pathname.slice(1), + approvalId: process.env.CONVERSATION_CUTOVER_APPROVAL_ID, + backupId: process.env.CONVERSATION_CUTOVER_BACKUP_ID, + resetEnabled: process.env.CONVERSATION_APPROVED_RESET_ENABLED, + actualCounts: facts.counts, + }) + await deleteApprovedScope(tx, facts, treeIds) + const after = await loadResetFacts(tx, treeIds) + const nonzero = Object.entries(after.counts).filter( + ([key, value]) => key !== "preservedUsageRecords" && value !== 0 + ) + if (nonzero.length > 0) + throw new Error(`重置后仍有目标实体:${JSON.stringify(nonzero)}`) + output = { + mode: rollback ? "test-rollback" : "execute", + environment: approval.environment, + approvalId: approval.approvalId, + backupId: approval.backupId, + restoreTestId: backup.restoreTestId, + treeIds, + deleted: facts.counts, + preservedUsageRecords: facts.counts.preservedUsageRecords, + committed: execute && !rollback, + } + if (rollback) throw new RollbackProbe("测试事务按设计回滚") + }) + } catch (error) { + if (!(error instanceof RollbackProbe)) throw error + } + console.log(JSON.stringify(output, null, 2)) +} finally { + await sql.end() +} From d36567b346e065ba3100d426bab1be2b5dbec584 Mon Sep 17 00:00:00 2001 From: zilin <276161014@qq.com> Date: Sat, 22 Aug 2026 05:48:15 +0800 Subject: [PATCH 13/32] docs(cutover): inventory legacy deletion dependencies --- .../issue-34-legacy-deletion-inventory.md | 80 +++++++ .../retire-thread-tree-authority/tasks.md | 9 +- package.json | 1 + .../audit-legacy-conversation-deletion.mjs | 196 ++++++++++++++++++ 4 files changed, 285 insertions(+), 1 deletion(-) create mode 100644 docs/architecture/issue-34-legacy-deletion-inventory.md create mode 100644 scripts/audit-legacy-conversation-deletion.mjs diff --git a/docs/architecture/issue-34-legacy-deletion-inventory.md b/docs/architecture/issue-34-legacy-deletion-inventory.md new file mode 100644 index 00000000..7ac6ea3f --- /dev/null +++ b/docs/architecture/issue-34-legacy-deletion-inventory.md @@ -0,0 +1,80 @@ +# Issue 34:Legacy Conversation 物理删除依赖清单 + +状态:**清单已建立,禁止执行物理删除**。本文完成 OpenSpec 8.1 的依赖审计,不代表 8.2 的不可逆删除门禁已经通过。 + +## 可重复审计入口 + +运行: + +```bash +pnpm audit:legacy-conversation-deletion +``` + +该命令只读查询当前数据库和仓库,输出精确行数/大小、FK、索引、trigger、view、function、名称相似 relation、数据库定时任务、`usage_records` 引用,以及按运行时/运维/Schema 与迁移/测试/文档分类的仓库引用。目标环境执行时必须把完整 JSON 保存到 cutover 证据包,不能拿本文的本地数字代替。 + +## 本地基线(2026-08-22) + +| 表 | 行数 | 总大小 | 角色 | +| ------------------------------------- | ---: | --------: | ------------------------------ | +| `thread_chat.branch_trees` | 19 | 589,824 B | legacy 整树 JSON 权威 | +| `thread_chat.branch_generations` | 37 | 540,672 B | legacy Generation/计费 sidecar | +| `thread_chat.branch_message_feedback` | 1 | 49,152 B | legacy Message feedback | + +审计同时发现 5 条 `usage_records.app_generation_id` 指向 legacy Generation。该列没有 FK,这是刻意的账单保留边界:删除会话数据不得删除、置空或改写这些流水。 + +数据库中没有依赖这三张表的业务 view、materialized view、显式 trigger、存储函数或 `pg_cron` 任务。名称相似 relation 必须逐项核对,尤其是 canonical 的 `conversation_generations`、`conversation_message_feedback` 和 `legacy_conversation_entity_mappings`;它们不是同义旧表,不能随 legacy 三表一起删除。 + +## 数据库依赖与删除顺序 + +已确认 FK 共 5 条: + +1. `branch_generations.tree_id → branch_trees.id ON DELETE CASCADE` +2. `branch_message_feedback.tree_id → branch_trees.id ON DELETE CASCADE` +3. 三张表各自的 `user_id → user.id ON DELETE CASCADE` + +不存在从其他业务表指向这三张表的 FK。物理迁移仍不得使用无边界的 `DROP ... CASCADE`,而应显式按以下顺序删除: + +1. 再次验证备份 ID、恢复演练、观察窗口批准和 legacy 查询为零。 +2. 删除 `branch_message_feedback`。 +3. 删除 `branch_generations`。 +4. 删除 `branch_trees`。 +5. 保留 `usage_records` 及其中的原始 `app_generation_id`;保留已归档迁移历史。 +6. 从当前 Drizzle schema 移除三张表定义,生成新的前滚迁移;不得改写 `0000`—`0003` 或历史 snapshot。 +7. 迁移后再次运行系统目录查询,证明表、约束、索引不存在,而 canonical 表和账单流水仍存在。 + +## 运行时依赖 + +仓库直接文本审计目前得到 140 个引用文件,其中运行时引用仍覆盖以下边界;因此 8.2 当前明确被阻塞: + +- legacy HTTP:`/api/branch-trees/**`、`/api/branch-generations/**`,以及旧 `/api/chat` ThreadTree Generation 分支。 +- legacy repository:`lib/thread-chat-generation/` 下的 tree、Generation lifecycle、feedback、stale、Stop/finalize 查询与写入。 +- legacy client/domain:`app/thread-chat/` 的 Tree store、boot、persist、reconcile、prompt、selector、列/画布/导航适配,以及 `lib/thread-chat/domain` 中的 `ThreadTreeState`/message graph/regeneration/selectors。 +- cutover compatibility:legacy audit、deterministic import、drain、approved reset 和本地恢复演练。 + +这里必须区分两类依赖: + +- 产品运行时依赖必须在 OpenSpec 7.1—7.4 完成时删除或改成纯 canonical;只把旧 route 改成 410 仍然不等于零数据库依赖。 +- cutover 运维依赖在观察窗口、备份保留期和删除批准前必须保留。它们不能被产品 bundle 调用,但需要读取 legacy 表完成审计、恢复和取证。 + +`vercel.json` 只有 `/api/billing/reconcile` 定时任务。它不查询 legacy 表,但会处理上述保留的 `usage_records`,因此表删除不能破坏账单对账。 + +## 测试、备份与历史依赖 + +- `e2e/thread-chat/` 的 legacy DB 测试必须在运行时删除 commit 中替换或移入历史证据,不能继续成为默认 canonical CI 的前置条件。 +- `scripts/rehearse-conversation-cutover-local.mjs` 的 13 表指纹刻意包含三张 legacy 表。在观察窗口结束前保持不变;进入 8.2 时应建立“删除前备份指纹”和“删除后 canonical-only 指纹”两个不同版本,不能静默覆盖旧口径。 +- `scripts/import-legacy-conversations.ts`、legacy audit、reset 与 drain checker 在不可逆删除前保留;删除后归档其版本与运行输出,运行入口必须 fail closed,而不是继续查询不存在的表。 +- `drizzle/0000`—`0003`、历史 meta snapshot 和已完成 OpenSpec 文档是历史记录,只读保留。新的 drop migration 只能前滚追加。 + +## 8.2 放行条件 + +以下条件缺一不可: + +- OpenSpec 5.1—5.5 完成,目标环境 canonical authority 已正式切换。 +- OpenSpec 6.1—6.4 完成,观察窗口内旧 route、旧 SQL 和旧 job 调用连续为零。 +- OpenSpec 7.1—7.4 完成,生产 bundle 与运行时依赖扫描中不再存在 legacy repository/Tree authority。 +- 目标环境重新运行本审计;没有未知 FK/view/function/trigger/job,名称相似 relation 全部获得明确处置。 +- 两套备份均有可恢复证明:删除前 legacy/canonical 完整备份,以及切换后 canonical 权威备份。 +- 5 条及目标环境全部 `usage_records` 已纳入账单保留/对账验证。 +- 负责人审批分阶段 drop migration、回滚边界与不可逆时刻。 + +在这些条件满足前,8.2 必须保持未完成。 diff --git a/openspec/changes/retire-thread-tree-authority/tasks.md b/openspec/changes/retire-thread-tree-authority/tasks.md index d3249d69..cce011b5 100644 --- a/openspec/changes/retire-thread-tree-authority/tasks.md +++ b/openspec/changes/retire-thread-tree-authority/tasks.md @@ -78,8 +78,15 @@ ## 8. 物理清理与 OpenSpec 收口 -- [ ] 8.1 审计 `branch_trees`、`branch_generations` 及名称相似表的全部 FK、job、查询和备份依赖,形成精确删除清单。 +- [x] 8.1 审计 `branch_trees`、`branch_generations` 及名称相似表的全部 FK、job、查询和备份依赖,形成精确删除清单。 - [ ] 8.2 在备份保留/恢复验证和零依赖门禁通过后,执行分阶段不可逆迁移删除确认遗留表、列、索引和约束。 - [ ] 8.3 建立旧 OpenSpec supersession map;保留已完成历史,明确记录 `persist-thread-chat-generations` 未完成项被新 lifecycle/cutover 替代,不补勾任务。 - [ ] 8.4 按依赖顺序归档新 changes,验证最终 specs 以 Project → Conversation → Thread 模型和 canonical capabilities 为权威。 - [ ] 8.5 运行最终 `pnpm openspec:validate`、schema 漂移检查、生产 smoke 和数据完整性/计费审计并记录结果。 + +### 第 8.1 阶段删除依赖审计(2026-08-22,本地) + +- `pnpm audit:legacy-conversation-deletion` 可重复输出数据库精确行数/大小、5 条 FK、11 个索引、trigger/view/function、名称相似 relation、数据库定时任务、账单引用和 140 个分类后的仓库引用;本地未发现业务 view、显式 trigger、存储函数或 `pg_cron` 依赖。 +- 本地三张 legacy 表分别为 19 个 Tree、37 个 Generation、1 条 feedback;5 条 `usage_records` 通过无 FK 的 `app_generation_id` 引用 legacy Generation,物理删除时必须保留账单流水。 +- 精确删除清单记录在 `docs/architecture/issue-34-legacy-deletion-inventory.md`。产品运行时、cutover 运维工具、历史迁移/快照和备份指纹采用不同处置;禁止 `DROP ... CASCADE` 或改写历史迁移。 +- 8.2 仍被 5.x 正式 cutover、6.x 观察窗口、7.x 运行时清理、目标环境复审和备份恢复批准阻塞;本次没有执行任何物理删除。 diff --git a/package.json b/package.json index 9ca01c3b..417d2af8 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "test:legacy-conversation-audit": "node --experimental-strip-types --test lib/thread-chat/legacy/audit-thread-tree.test.ts", "audit:conversation-domain": "node scripts/audit-thread-tree-boundary.mjs", "audit:legacy-conversations": "node --experimental-strip-types scripts/audit-legacy-conversations.ts", + "audit:legacy-conversation-deletion": "node scripts/audit-legacy-conversation-deletion.mjs", "import:legacy-conversations": "tsx scripts/import-legacy-conversations.ts", "reset:approved-conversations": "tsx scripts/reset-approved-conversations.ts", "db:generate": "drizzle-kit generate", diff --git a/scripts/audit-legacy-conversation-deletion.mjs b/scripts/audit-legacy-conversation-deletion.mjs new file mode 100644 index 00000000..77e521e0 --- /dev/null +++ b/scripts/audit-legacy-conversation-deletion.mjs @@ -0,0 +1,196 @@ +import { spawnSync } from "node:child_process" + +import { config } from "dotenv" +import postgres from "postgres" + +config({ path: ".env.local", quiet: true }) + +const LEGACY_NAMES = [ + "branch_trees", + "branch_generations", + "branch_message_feedback", +] +const SEARCH_PATTERN = + "branch_trees|branch_generations|branch_message_feedback|branchTrees|branchGenerations|branchMessageFeedback|ThreadTreeState" + +function repositoryReferences() { + const result = spawnSync( + "rg", + [ + "-l", + SEARCH_PATTERN, + "--glob", + "!node_modules/**", + "--glob", + "!.next/**", + ".", + ], + { cwd: process.cwd(), encoding: "utf8" } + ) + if (result.status !== 0 && result.status !== 1) + throw new Error(`rg 退出码 ${result.status}: ${result.stderr}`) + const paths = result.stdout + .split("\n") + .filter(Boolean) + .map((path) => path.replace(/^\.\//u, "")) + .filter((path) => path !== "scripts/audit-legacy-conversation-deletion.mjs") + .sort() + const categories = { + runtime: [], + operational: [], + schemaAndMigration: [], + tests: [], + documentationAndSpecs: [], + } + for (const path of paths) { + if ( + path.includes(".test.") || + path.startsWith("e2e/") || + path.includes("/__tests__/") + ) + categories.tests.push(path) + else if (path.startsWith("scripts/")) categories.operational.push(path) + else if (path.startsWith("drizzle/") || path === "lib/db/schema.ts") + categories.schemaAndMigration.push(path) + else if ( + path.startsWith("docs/") || + path.startsWith("openspec/") || + path === "CLAUDE.md" + ) + categories.documentationAndSpecs.push(path) + else categories.runtime.push(path) + } + return { total: paths.length, categories } +} + +const rawUrl = process.env.DIRECT_URL || process.env.DATABASE_URL +if (!rawUrl) throw new Error("未配置 DIRECT_URL 或 DATABASE_URL") +const databaseUrl = rawUrl.trim().replace(/^(['"])(.*)\1$/u, "$2") +const parsedUrl = new URL(databaseUrl) +const sql = postgres(databaseUrl, { max: 1, prepare: false }) + +try { + const tables = await sql`SELECT c.relname AS table_name, + pg_total_relation_size(c.oid)::bigint AS size_bytes + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'thread_chat' + AND c.relname IN ${sql(LEGACY_NAMES)} + ORDER BY c.relname` + // 行数必须精确,不能把 pg_class.reltuples 的估算值写进删除审批。 + const exactCounts = await sql` + SELECT 'branch_trees' AS table_name, count(*)::int AS rows FROM thread_chat.branch_trees + UNION ALL SELECT 'branch_generations', count(*)::int FROM thread_chat.branch_generations + UNION ALL SELECT 'branch_message_feedback', count(*)::int FROM thread_chat.branch_message_feedback + ORDER BY table_name` + const countByTable = new Map( + exactCounts.map((entry) => [entry.table_name, entry.rows]) + ) + + const foreignKeys = await sql`SELECT conrelid::regclass::text AS child_table, + conname AS constraint_name, + confrelid::regclass::text AS parent_table, + pg_get_constraintdef(oid) AS definition + FROM pg_constraint + WHERE contype = 'f' + AND (conrelid = ANY(ARRAY[ + 'thread_chat.branch_trees'::regclass, + 'thread_chat.branch_generations'::regclass, + 'thread_chat.branch_message_feedback'::regclass + ]) OR confrelid = ANY(ARRAY[ + 'thread_chat.branch_trees'::regclass, + 'thread_chat.branch_generations'::regclass, + 'thread_chat.branch_message_feedback'::regclass + ])) + ORDER BY child_table, constraint_name` + const indexes = + await sql`SELECT tablename AS table_name, indexname AS index_name, indexdef AS definition + FROM pg_indexes + WHERE schemaname = 'thread_chat' + AND tablename IN ${sql(LEGACY_NAMES)} + ORDER BY tablename, indexname` + const triggers = await sql` + SELECT event_object_table AS table_name, trigger_name, action_timing, + event_manipulation, action_statement + FROM information_schema.triggers + WHERE event_object_schema = 'thread_chat' + AND event_object_table IN ${sql(LEGACY_NAMES)} + ORDER BY event_object_table, trigger_name, event_manipulation` + const views = await sql` + SELECT schemaname, viewname + FROM pg_views + WHERE definition ~ 'branch_(trees|generations|message_feedback)' + ORDER BY schemaname, viewname` + const functions = await sql` + SELECT n.nspname AS schema_name, p.proname AS function_name + FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE p.prokind <> 'a' + AND pg_get_functiondef(p.oid) ~ 'branch_(trees|generations|message_feedback)' + ORDER BY n.nspname, p.proname` + const similarRelations = await sql` + SELECT n.nspname AS schema_name, c.relname AS relation_name, + CASE c.relkind + WHEN 'r' THEN 'table' + WHEN 'p' THEN 'partitioned-table' + WHEN 'v' THEN 'view' + WHEN 'm' THEN 'materialized-view' + WHEN 'S' THEN 'sequence' + ELSE c.relkind::text + END AS relation_kind + FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'thread_chat' + AND c.relkind IN ('r', 'p', 'v', 'm', 'S') + AND (c.relname ILIKE '%branch%' + OR c.relname ILIKE '%tree%' + OR c.relname ILIKE '%generation%' + OR c.relname ILIKE '%feedback%') + ORDER BY c.relname` + const [pgCron] = await sql` + SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_cron') AS installed` + const databaseSchedules = pgCron?.installed + ? await sql.unsafe( + "SELECT jobid, schedule, command, active FROM cron.job " + + "WHERE command ~ 'branch_(trees|generations|message_feedback)' ORDER BY jobid" + ) + : [] + const [usage] = await sql` + SELECT count(*)::int AS linked_records + FROM thread_chat.usage_records u + WHERE u.app_generation_id IN ( + SELECT id FROM thread_chat.branch_generations + )` + + console.log( + JSON.stringify( + { + schemaVersion: 1, + mode: "read-only", + database: { + host: parsedUrl.hostname, + name: parsedUrl.pathname.slice(1), + }, + legacyTables: tables.map((entry) => ({ + table: entry.table_name, + rows: countByTable.get(entry.table_name) ?? 0, + sizeBytes: Number(entry.size_bytes), + })), + foreignKeys, + indexes, + triggers, + views, + functions, + similarRelations, + databaseSchedules, + externalReferences: { + usageRecordsLinkedByAppGenerationId: usage?.linked_records ?? 0, + usageRecordsPolicy: "preserve-without-foreign-key", + }, + repository: repositoryReferences(), + }, + null, + 2 + ) + ) +} finally { + await sql.end() +} From 4fca823eef43391e03aff0083122472d67a13a5c Mon Sep 17 00:00:00 2001 From: zilin <276161014@qq.com> Date: Sat, 22 Aug 2026 05:49:38 +0800 Subject: [PATCH 14/32] docs(openspec): map legacy conversation supersession --- .../supersession-map.md | 87 +++++++++++++++++++ .../retire-thread-tree-authority/tasks.md | 8 +- 2 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 openspec/changes/retire-thread-tree-authority/supersession-map.md diff --git a/openspec/changes/retire-thread-tree-authority/supersession-map.md b/openspec/changes/retire-thread-tree-authority/supersession-map.md new file mode 100644 index 00000000..95336199 --- /dev/null +++ b/openspec/changes/retire-thread-tree-authority/supersession-map.md @@ -0,0 +1,87 @@ +# Issue 34 OpenSpec Supersession Map + +## 目的与规则 + +本文只声明规范权威的替代关系,不修改历史事实。 + +1. 已完成的旧 proposal/design/spec/tasks 保留,作为当时决策和交付证据。 +2. 旧 change 中未完成的任务保持 `[ ]`;不能因为新架构覆盖了同一风险就回头补勾。 +3. “被替代”仅指领域模型、持久化协议或运行时 authority;仍正确的渲染、交互和产品验收继续保留。 +4. 新 changes 只有在各自必需任务和 `retire-thread-tree-authority` 的 cutover/观察/删除门禁完成后,才成为生产唯一权威。 + +## 新权威链 + +```mermaid +flowchart LR + D[define-conversation-domain-model] + P[normalize-conversation-persistence] + G[migrate-generation-lifecycle] + A[add-conversation-command-api] + C[normalize-conversation-client-state] + R[retire-thread-tree-authority] + + D --> P + D --> G + P --> G + P --> A + G --> A + D --> C + A --> C + G --> C + P --> R + G --> R + A --> R + C --> R +``` + +最终领域与运行时权威是 `Project → Conversation → Thread → Turn/Message/Generation`,分叉事实由 `ThreadFork(parentThreadId, sourceMessageId, childThreadId)` 表达。`ThreadTreeState`、魔法 `main`、整树 PUT 和 `branch_*` sidecar 不再定义领域事实。 + +## Change 级映射 + +| 旧 change / capability | 处置 | 新权威 | 保留内容 | +| ---------------------------------------------------------- | ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | +| `add-branch-tree-persistence` | 领域与持久化协议完全替代 | `define-conversation-domain-model`、`normalize-conversation-persistence`、`add-conversation-command-api`、`retire-thread-tree-authority` | 历史迁移、当时的 owner/revision 风险记录 | +| `add-tree-list-ui` 中的 branch-tree persistence delta | 数据源和 identity 替代 | `add-conversation-command-api` 的 Conversation list + `normalize-conversation-client-state` | 列表信息架构、重命名/归档等仍有效产品行为 | +| `persist-thread-chat-generations` | Generation authority 完全替代 | `migrate-generation-lifecycle`、canonical persistence/API/client、cutover | 竞态、Stop、stale、计费 exactly-once 等风险案例作为历史输入 | +| `add-thread-chat-message-actions` 的 Tree/Message DAG 身份 | 身份和写协议替代 | canonical Message ID、Turn variant、ThreadFork、command API | copy/edit/regenerate/feedback 的用户行为与视觉设计 | +| `add-canvas-conversations` 的树数据输入 | 图数据来源替代 | Conversation snapshot + Thread/ThreadFork normalized client | 画布布局与交互能力 | +| `add-markdown-artifacts` 的 Tree artifact provenance | provenance 与持久化替代 | canonical `conversation_artifacts` + stable Conversation/Thread/Message ID | Markdown 渲染、代码块和 Artifact 展示能力 | +| `add-bubble-composer` | 不替代展示能力;仅替换其数据/发送接线 | canonical client + command/Generation API | composer 布局、输入和附件交互 | +| `fix-thread-chat-help-panel` | 不替代 | 无领域 authority | Help UI 行为独立保留 | + +## `persist-thread-chat-generations` 未完成项逐条处置 + +原 change 的以下任务必须继续保持未勾选: + +| 原任务 | 历史状态 | 新链中的覆盖证据 | 结论 | +| -------------------------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------- | +| 4.5 branch-tree API owner CRUD、越权、认领、terminal merge、running delete | `[ ]` | canonical persistence/command API DB tests 与 HTTP owner/幂等/revision tests;legacy route 在 canonical authority 下 410 | 风险已由新协议覆盖;不补做旧整树 API 验收 | +| 7.4 legacy Stop 自然完成竞态、重复/跨用户 Stop、stale/current 查询 | `[ ]` | `migrate-generation-lifecycle` 的 lease/checkpoint/Stop/stale policy 和 canonical Generation tests | 新 lifecycle 替代;不把旧 sidecar 重新确认为权威 | +| 10.3 旧 attempt、Stop-vs-complete、running tree 删除竞态 | `[ ]` | canonical Generation lifecycle、Turn current Generation 约束、billing exactly-once 测试 | 风险场景迁移到 canonical 模型;旧任务保留未完成 | +| 10.4 双用户 legacy Tree/Generation/Stop + Ego Browser | `[ ]` | canonical owner/workspace/project 隔离 DB/API tests、邮箱登录 HTTP 测试和 Ego Browser canonical smoke | 验收对象已变更;不得为补勾而恢复 legacy authority | +| 10.6 全套旧 change tests/lint/build/validate | `[ ]` | 新 change 链分别保存 typecheck/lint/build/OpenSpec/DB/HTTP 证据 | 不能倒推旧 change 当时已经通过 | +| 10.8 旧 change 提交前格式化与工作树复核 | `[ ]` | 无可替代的历史时点证据 | 永久保留未完成,不补勾 | + +## Spec/capability 最终归属 + +归档新 changes 后,最终 spec 必须满足: + +- `domain`:只定义 Workspace/Project/Conversation/Thread/ThreadFork/Turn/Message/Generation 等 canonical 术语。 +- `conversation-persistence`:关系表、约束、事务和 snapshot hydration 是唯一持久化语义。 +- `conversation-generation-lifecycle`:Generation intent、lease、checkpoint、Stop/stale、usage/billing 是唯一执行语义。 +- `conversation-command-api`:认证、幂等、revision/ETag、命令和查询是唯一服务端写入口。 +- `conversation-client-state`:normalized entity store、订阅/挂载/取消订阅与 optimistic reconciliation 是唯一客户端状态语义。 +- `conversation-cutover`:authority、维护、导入/reset、观察、legacy 拒绝与物理删除门禁。 + +旧 `branch-tree-persistence` capability 不得继续出现在最终现行 specs 中;它只能留在历史 change/archive 中。Markdown、composer、canvas、message actions 等 capability 可以继续存在,但不得重新引入 Tree authority。 + +## 归档顺序(8.4 的输入,不代表现在执行) + +1. `define-conversation-domain-model` +2. `normalize-conversation-persistence` +3. `migrate-generation-lifecycle` +4. `add-conversation-command-api` +5. `normalize-conversation-client-state` +6. 完成 cutover、观察和删除门禁后归档 `retire-thread-tree-authority` + +任何依赖 change 未完成时不得提前归档其下游;`retire-thread-tree-authority` 当前仍有目标环境、观察窗口和物理删除任务,8.4 必须保持未完成。 diff --git a/openspec/changes/retire-thread-tree-authority/tasks.md b/openspec/changes/retire-thread-tree-authority/tasks.md index cce011b5..f6925456 100644 --- a/openspec/changes/retire-thread-tree-authority/tasks.md +++ b/openspec/changes/retire-thread-tree-authority/tasks.md @@ -80,7 +80,7 @@ - [x] 8.1 审计 `branch_trees`、`branch_generations` 及名称相似表的全部 FK、job、查询和备份依赖,形成精确删除清单。 - [ ] 8.2 在备份保留/恢复验证和零依赖门禁通过后,执行分阶段不可逆迁移删除确认遗留表、列、索引和约束。 -- [ ] 8.3 建立旧 OpenSpec supersession map;保留已完成历史,明确记录 `persist-thread-chat-generations` 未完成项被新 lifecycle/cutover 替代,不补勾任务。 +- [x] 8.3 建立旧 OpenSpec supersession map;保留已完成历史,明确记录 `persist-thread-chat-generations` 未完成项被新 lifecycle/cutover 替代,不补勾任务。 - [ ] 8.4 按依赖顺序归档新 changes,验证最终 specs 以 Project → Conversation → Thread 模型和 canonical capabilities 为权威。 - [ ] 8.5 运行最终 `pnpm openspec:validate`、schema 漂移检查、生产 smoke 和数据完整性/计费审计并记录结果。 @@ -90,3 +90,9 @@ - 本地三张 legacy 表分别为 19 个 Tree、37 个 Generation、1 条 feedback;5 条 `usage_records` 通过无 FK 的 `app_generation_id` 引用 legacy Generation,物理删除时必须保留账单流水。 - 精确删除清单记录在 `docs/architecture/issue-34-legacy-deletion-inventory.md`。产品运行时、cutover 运维工具、历史迁移/快照和备份指纹采用不同处置;禁止 `DROP ... CASCADE` 或改写历史迁移。 - 8.2 仍被 5.x 正式 cutover、6.x 观察窗口、7.x 运行时清理、目标环境复审和备份恢复批准阻塞;本次没有执行任何物理删除。 + +### 第 8.3 阶段规范替代映射 + +- `supersession-map.md` 明确了旧 branch-tree persistence、tree list 数据源、Generation sidecar、Message DAG identity、canvas 与 Artifact provenance 到六个 canonical changes 的替代关系,同时保留 composer/Markdown/actions/help 等仍正确的产品能力。 +- `persist-thread-chat-generations` 的 4.5、7.4、10.3、10.4、10.6、10.8 逐项映射到新 lifecycle/persistence/API/client/cutover 证据,原任务保持 `[ ]`,没有篡改历史完成状态。 +- 文档记录新 change 的依赖归档顺序;由于目标环境 cutover、观察和物理删除尚未完成,8.4 仍保持未完成。 From e915c437746026d72b874445b063567dc5ed1a27 Mon Sep 17 00:00:00 2001 From: zilin <276161014@qq.com> Date: Sat, 22 Aug 2026 05:52:59 +0800 Subject: [PATCH 15/32] feat(cutover): enforce forward-only recovery --- docs/architecture/issue-34-cutover-runbook.md | 6 + .../conversation-forward-recovery.test.ts | 96 +++++++++++++ .../cutover/conversation-forward-recovery.ts | 127 ++++++++++++++++++ .../retire-thread-tree-authority/tasks.md | 6 + package.json | 3 +- scripts/plan-conversation-forward-recovery.ts | 32 +++++ 6 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 lib/thread-chat/cutover/conversation-forward-recovery.test.ts create mode 100644 lib/thread-chat/cutover/conversation-forward-recovery.ts create mode 100644 scripts/plan-conversation-forward-recovery.ts diff --git a/docs/architecture/issue-34-cutover-runbook.md b/docs/architecture/issue-34-cutover-runbook.md index 1e7b9faa..23b91fac 100644 --- a/docs/architecture/issue-34-cutover-runbook.md +++ b/docs/architecture/issue-34-cutover-runbook.md @@ -60,6 +60,12 @@ define-conversation-domain-model 本地临时库演练已覆盖完整 reset SQL:在 canonical 备份恢复副本中删除审批 scope 内实体后强制回滚,13 张 cutover 表的合并指纹在前后完全一致,并验证关联的 5 条 `usage_records` 未被删除。该证据只证明工具路径,不构成目标环境的数据丢弃批准。 +## 回滚边界:首个 canonical 写入是不可逆时刻 + +用 `pnpm plan:conversation-forward-recovery -- --request-file ` 生成只读恢复计划。首个 canonical 写入之前,只能在确认 canonical 写入仍为零后中止 cutover 并回到 `legacy + read-only`;首个 canonical 写入之后,规划器会拒绝 legacy authority、`canonical → legacy` 同步和 legacy 备份,只允许保持 canonical authority、进入 read-only、恢复已验证的 canonical 备份或部署前滚修复,并使用新 epoch 恢复流量。 + +该规划器不直接修改部署或数据库;目标环境仍需把 incident、批准、首写时间、备份恢复证明、执行日志和 smoke 结果放入证据包。 + - [ ] 原子切换 canonical server/client epoch,运行行为矩阵关键 smoke。 - [ ] 确认首个 canonical 生产写入时间;此后禁止恢复落后的 legacy 权威。 - [ ] 观察期内 legacy route/query 为 0,完整性与计费对账连续通过。 diff --git a/lib/thread-chat/cutover/conversation-forward-recovery.test.ts b/lib/thread-chat/cutover/conversation-forward-recovery.test.ts new file mode 100644 index 00000000..9d8b5534 --- /dev/null +++ b/lib/thread-chat/cutover/conversation-forward-recovery.test.ts @@ -0,0 +1,96 @@ +import assert from "node:assert/strict" +import test from "node:test" + +import { + conversationRecoveryRequestSchema, + planConversationRecovery, +} from "./conversation-forward-recovery.ts" + +const base = { + schemaVersion: 1 as const, + action: "conversation-recovery-plan" as const, + environment: "production", + database: { host: "db.internal", name: "thread-chat" }, + cutoverEpoch: "epoch-34", + currentAuthority: "canonical" as const, + firstCanonicalWriteAt: "2026-08-22T01:00:00.000Z", + requestedAction: "deploy-forward-fix" as const, + targetAuthority: "canonical" as const, + dataFlow: "none" as const, + backup: null, + incidentId: "incident-34", + approvedBy: "owner@example.com", + approvedAt: "2026-08-22T01:05:00.000Z", +} + +test("首个 canonical 写入后只生成 canonical 前滚恢复计划", () => { + const plan = planConversationRecovery( + conversationRecoveryRequestSchema.parse(base) + ) + assert.equal(plan.mode, "post-write-forward-recovery") + assert.equal(plan.targetAuthority, "canonical") + assert.equal(plan.maintenanceMode, "read-only") + assert.match(plan.forbidden.join(" "), /canonical → legacy/u) +}) + +test("首个 canonical 写入后拒绝 legacy authority、反向同步和 legacy 备份", () => { + const cases = [ + { request: { ...base, targetAuthority: "legacy" }, error: /legacy/u }, + { + request: { ...base, dataFlow: "canonical-to-legacy" }, + error: /反向/u, + }, + { + request: { + ...base, + requestedAction: "restore-canonical-backup", + backup: { + kind: "legacy", + id: "legacy-1", + restoreTestId: "restore-1", + }, + }, + error: /canonical 备份/u, + }, + ] + for (const entry of cases) + assert.throws( + () => + planConversationRecovery( + conversationRecoveryRequestSchema.parse(entry.request) + ), + entry.error + ) +}) + +test("首个 canonical 写入前只允许无数据复制的 legacy abort", () => { + const request = conversationRecoveryRequestSchema.parse({ + ...base, + currentAuthority: "canonical", + firstCanonicalWriteAt: null, + requestedAction: "abort-before-canonical-write", + targetAuthority: "legacy", + dataFlow: "none", + backup: { kind: "legacy", id: "legacy-1", restoreTestId: "restore-1" }, + }) + const plan = planConversationRecovery(request) + assert.equal(plan.mode, "pre-write-abort") + assert.equal(plan.dataFlow, "none") +}) + +test("恢复 canonical 备份要求恢复演练标识并保持 canonical-to-canonical", () => { + const request = conversationRecoveryRequestSchema.parse({ + ...base, + requestedAction: "restore-canonical-backup", + dataFlow: "canonical-to-canonical", + backup: { + kind: "canonical", + id: "canonical-1", + restoreTestId: "restore-canonical-1", + }, + }) + assert.equal( + planConversationRecovery(request).dataFlow, + "canonical-to-canonical" + ) +}) diff --git a/lib/thread-chat/cutover/conversation-forward-recovery.ts b/lib/thread-chat/cutover/conversation-forward-recovery.ts new file mode 100644 index 00000000..524c454c --- /dev/null +++ b/lib/thread-chat/cutover/conversation-forward-recovery.ts @@ -0,0 +1,127 @@ +import { z } from "zod" + +export const conversationRecoveryRequestSchema = z + .object({ + schemaVersion: z.literal(1), + action: z.literal("conversation-recovery-plan"), + environment: z.string().trim().min(1), + database: z + .object({ + host: z.string().trim().min(1), + name: z.string().trim().min(1), + }) + .strict(), + cutoverEpoch: z.string().trim().min(1), + currentAuthority: z.enum(["legacy", "canonical"]), + firstCanonicalWriteAt: z.string().datetime().nullable(), + requestedAction: z.enum([ + "abort-before-canonical-write", + "restore-canonical-backup", + "deploy-forward-fix", + ]), + targetAuthority: z.enum(["legacy", "canonical"]), + dataFlow: z.enum(["none", "canonical-to-canonical", "canonical-to-legacy"]), + backup: z + .object({ + kind: z.enum(["legacy", "canonical"]), + id: z.string().trim().min(1), + restoreTestId: z.string().trim().min(1), + }) + .strict() + .nullable(), + incidentId: z.string().trim().min(1), + approvedBy: z.string().trim().min(1), + approvedAt: z.string().datetime(), + }) + .strict() + +export type ConversationRecoveryRequest = z.infer< + typeof conversationRecoveryRequestSchema +> + +export interface ConversationRecoveryPlan { + readonly mode: "pre-write-abort" | "post-write-forward-recovery" + readonly targetAuthority: "legacy" | "canonical" + readonly maintenanceMode: "read-only" + readonly dataFlow: "none" | "canonical-to-canonical" + readonly steps: readonly string[] + readonly forbidden: readonly string[] +} + +/** + * Cutover rollback 不是双写或反向迁移。首个 canonical 写入之后,恢复只能留在 + * canonical authority 内前滚;落后的 legacy 数据永远不能重新成为权威。 + */ +export function planConversationRecovery( + request: ConversationRecoveryRequest +): ConversationRecoveryPlan { + if (request.dataFlow === "canonical-to-legacy") + throw new Error("禁止 canonical → legacy 反向同步") + + const canonicalWritesExist = request.firstCanonicalWriteAt !== null + if (!canonicalWritesExist) { + if (request.requestedAction !== "abort-before-canonical-write") + throw new Error("首个 canonical 写入前只能执行 cutover abort") + if (request.targetAuthority !== "legacy") + throw new Error("cutover abort 的目标 authority 必须是 legacy") + if (request.dataFlow !== "none") + throw new Error("cutover abort 不允许复制业务数据") + if (request.backup && request.backup.kind !== "legacy") + throw new Error("cutover abort 只能引用已验证的 legacy 备份") + return { + mode: "pre-write-abort", + targetAuthority: "legacy", + maintenanceMode: "read-only", + dataFlow: "none", + steps: [ + "保持全局 read-only", + "确认 canonical 写入计数仍为零", + "将 server/client authority 原子恢复为 legacy epoch", + "重新运行 legacy 完整性与计费审计后再决定开放写入", + ], + forbidden: ["canonical → legacy 同步", "恢复未验证备份", "绕过维护窗口"], + } + } + + if (request.currentAuthority !== "canonical") + throw new Error("存在 canonical 写入后 currentAuthority 必须保持 canonical") + if (request.targetAuthority !== "canonical") + throw new Error("存在 canonical 写入后禁止恢复 legacy authority") + if (request.requestedAction === "abort-before-canonical-write") + throw new Error("存在 canonical 写入后不能执行 pre-write abort") + if ( + request.requestedAction === "restore-canonical-backup" && + request.backup?.kind !== "canonical" + ) + throw new Error("恢复操作必须引用已验证的 canonical 备份") + if ( + request.requestedAction === "deploy-forward-fix" && + request.backup?.kind === "legacy" + ) + throw new Error("前滚修复不得引用 legacy 备份作为恢复源") + + return { + mode: "post-write-forward-recovery", + targetAuthority: "canonical", + maintenanceMode: "read-only", + dataFlow: + request.requestedAction === "restore-canonical-backup" + ? "canonical-to-canonical" + : "none", + steps: [ + "保持 canonical authority 并进入全局 read-only", + "停止新命令和 Generation,等待 checkpoint/outbox/计费收敛", + request.requestedAction === "restore-canonical-backup" + ? "恢复已验证的 canonical 备份并重放允许的 canonical 事件" + : "部署前滚修复,不改写 legacy 数据", + "运行 canonical 完整性、HTTP、Generation 与账单审计", + "使用新 cutover epoch 恢复 canonical 流量", + ], + forbidden: [ + "canonical → legacy 同步", + "恢复 legacy 备份为权威", + "重新启用 branch-tree 写入", + "沿用发生故障的 cutover epoch", + ], + } +} diff --git a/openspec/changes/retire-thread-tree-authority/tasks.md b/openspec/changes/retire-thread-tree-authority/tasks.md index f6925456..713a4630 100644 --- a/openspec/changes/retire-thread-tree-authority/tasks.md +++ b/openspec/changes/retire-thread-tree-authority/tasks.md @@ -69,6 +69,12 @@ - [ ] 6.3 验证切换后回滚工具不会执行 canonical → ThreadTree 反向同步,也不会把落后 legacy 数据恢复为权威。 - [ ] 6.4 在声明观察窗口内完成逐日完整性/计费审计,并由负责人批准进入遗留删除阶段。 +### 第 6.3 阶段本地保护(不等同目标环境验证) + +- 新增只读前滚恢复规划器:首个 canonical 写入前只允许“零 canonical 写入”的 legacy abort;首写之后强制保持 canonical authority,只允许 canonical 备份恢复或前滚修复。 +- schema 与运行时规则共同拒绝 `canonical → legacy`、legacy 备份恢复为权威、重新启用 branch-tree 写入和复用故障 epoch;自动测试覆盖合法/非法路径。 +- 6.3 保持未完成:尚未在正式切换后的目标环境用真实 incident、首写时间、备份恢复证明和执行日志验证该门禁。 + ## 7. 删除遗留运行时代码 - [ ] 7.1 删除 canonical build 中的 `ThreadTreeState`、魔法 `main`、parent/children/Message forks/depth/activeLeaf 等重复领域事实和 selector。 diff --git a/package.json b/package.json index 417d2af8..c5c72837 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "test:conversation-http-api": "tsx scripts/test-conversation-http-api.ts", "test:conversation-command-contract": "tsx --test lib/thread-chat/http/conversation-command-http.test.ts", "test:conversation-client": "tsx --test lib/thread-chat/client/normalized-conversation-store.test.ts", - "test:conversation-authority": "tsx --test lib/thread-chat/cutover/conversation-authority.test.ts lib/thread-chat/cutover/conversation-drain.test.ts lib/thread-chat/cutover/legacy-conversation-import.test.ts lib/thread-chat/cutover/approved-conversation-reset.test.ts", + "test:conversation-authority": "tsx --test lib/thread-chat/cutover/conversation-authority.test.ts lib/thread-chat/cutover/conversation-drain.test.ts lib/thread-chat/cutover/legacy-conversation-import.test.ts lib/thread-chat/cutover/approved-conversation-reset.test.ts lib/thread-chat/cutover/conversation-forward-recovery.test.ts", "check:conversation-cutover-drain": "tsx scripts/check-conversation-cutover-drain.ts", "rehearse:conversation-cutover-local": "node scripts/rehearse-conversation-cutover-local.mjs", "seed:conversation-browser": "tsx scripts/seed-canonical-browser-fixture.ts", @@ -31,6 +31,7 @@ "audit:legacy-conversation-deletion": "node scripts/audit-legacy-conversation-deletion.mjs", "import:legacy-conversations": "tsx scripts/import-legacy-conversations.ts", "reset:approved-conversations": "tsx scripts/reset-approved-conversations.ts", + "plan:conversation-forward-recovery": "tsx scripts/plan-conversation-forward-recovery.ts", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:reset-schema": "node scripts/reset-thread-chat-schema.mjs", diff --git a/scripts/plan-conversation-forward-recovery.ts b/scripts/plan-conversation-forward-recovery.ts new file mode 100644 index 00000000..b72493ef --- /dev/null +++ b/scripts/plan-conversation-forward-recovery.ts @@ -0,0 +1,32 @@ +import { readFile } from "node:fs/promises" + +import { + conversationRecoveryRequestSchema, + planConversationRecovery, +} from "../lib/thread-chat/cutover/conversation-forward-recovery.ts" + +const requestFlag = process.argv.indexOf("--request-file") +const requestPath = requestFlag >= 0 ? process.argv[requestFlag + 1] : undefined +if (!requestPath || requestPath.startsWith("--")) + throw new Error("必须提供 --request-file;规划器不会从环境猜测恢复意图") + +const request = conversationRecoveryRequestSchema.parse( + JSON.parse(await readFile(requestPath, "utf8")) +) +const plan = planConversationRecovery(request) + +console.log( + JSON.stringify( + { + schemaVersion: 1, + mode: "read-only-plan", + environment: request.environment, + incidentId: request.incidentId, + cutoverEpoch: request.cutoverEpoch, + firstCanonicalWriteAt: request.firstCanonicalWriteAt, + plan, + }, + null, + 2 + ) +) From 19b9e5a0d447722ea2e13b9c38a97ab10a0ebc68 Mon Sep 17 00:00:00 2001 From: zilin <276161014@qq.com> Date: Sat, 22 Aug 2026 05:55:14 +0800 Subject: [PATCH 16/32] feat(cutover): add health audit baseline --- .../issue-34-observability-runbook.md | 41 ++++++ .../retire-thread-tree-authority/tasks.md | 6 + package.json | 1 + scripts/audit-conversation-cutover-health.mjs | 134 ++++++++++++++++++ 4 files changed, 182 insertions(+) create mode 100644 docs/architecture/issue-34-observability-runbook.md create mode 100644 scripts/audit-conversation-cutover-health.mjs diff --git a/docs/architecture/issue-34-observability-runbook.md b/docs/architecture/issue-34-observability-runbook.md new file mode 100644 index 00000000..7a61d11a --- /dev/null +++ b/docs/architecture/issue-34-observability-runbook.md @@ -0,0 +1,41 @@ +# Issue 34:Cutover 可观测性与观察期 Runbook + +状态:本地只读审计入口已建立;生产 dashboard、告警阈值、负责人和观察窗口尚未配置,因此 OpenSpec 6.1/6.2/6.4 仍未完成。 + +## 只读健康快照 + +运行: + +```bash +pnpm audit:conversation-cutover-health +``` + +输出不包含 Message 正文、query、URL、API key 或 Authorization,覆盖: + +- canonical Generation 的 status/billing/usage completeness 组合、最大 heartbeat age; +- active、pending billing、终态无 checkpoint、completed/stopped 但 content 不完整、终态无 finishedAt; +- outbox 状态/最大年龄、command type 数量; +- canonical usage 流水、settled 无 usage、token mismatch、历史/非 canonical usage; +- legacy 三表剩余行数、产品运行时代码引用文件数; +- 安装且有权限时,从 `pg_stat_statements` 汇总 legacy 三表 SQL 调用次数和执行时间,不输出 SQL 正文。 + +数据库无法证明 HTTP 请求率,所以快照会把以下指标显式列为 unavailable,而不是伪造零值:authority mismatch、命令错误、revision/idempotency conflict、legacy route 请求。 + +## 生产接入要求 + +应用日志与平台指标必须按同一 cutover epoch 聚合,并建立以下 dashboard/alert: + +| 指标 | 必须观察的维度 | 放行原则 | +| ----------------------------- | -------------------------------------------------- | ----------------------------------------- | +| authority mismatch | epoch、client schema、route | canary 后为零;任何持续非零阻止放量 | +| command error | code、command type、HTTP status | 与基线比较;认证失败和冲突不能混成 500 | +| revision/idempotency conflict | command type、scope type | 突增时检查陈旧客户端或重放风暴 | +| Generation | status、heartbeat age、checkpoint、Stop/stale | 非终态必须在 drain/阈值内收敛 | +| billing/usage | billing status、usage completeness、token mismatch | pending 与 settled-without-usage 必须为零 | +| outbox | status、age、attempts | pending/failed 不得超过批准阈值 | +| legacy protocol | route、caller version、epoch | 切换后观察窗口内持续为零 | +| legacy SQL | table、调用次数(不记录 SQL 正文) | 产品流量开放后持续为零 | + +## 观察期证据 + +每天保存一次健康快照和平台 dashboard 导出,并关联部署 ID、epoch、数据库 migration hash、负责人和时间范围。任何 unavailable 指标都必须先完成平台接入,不能用数据库快照替代。观察窗口结束时由负责人签署完整性与计费审计,之后才允许进入 legacy 运行时与物理表删除。 diff --git a/openspec/changes/retire-thread-tree-authority/tasks.md b/openspec/changes/retire-thread-tree-authority/tasks.md index 713a4630..1db5cf79 100644 --- a/openspec/changes/retire-thread-tree-authority/tasks.md +++ b/openspec/changes/retire-thread-tree-authority/tasks.md @@ -69,6 +69,12 @@ - [ ] 6.3 验证切换后回滚工具不会执行 canonical → ThreadTree 反向同步,也不会把落后 legacy 数据恢复为权威。 - [ ] 6.4 在声明观察窗口内完成逐日完整性/计费审计,并由负责人批准进入遗留删除阶段。 +### 第 6.1/6.2 阶段本地审计基础(不等同生产 dashboard) + +- `pnpm audit:conversation-cutover-health` 只读输出 canonical Generation 状态/年龄/checkpoint、billing、outbox、command、usage 对账,以及 legacy 行数、运行时代码引用和可用时的 `pg_stat_statements` 聚合;不输出 Message/SQL 正文或凭据。 +- authority mismatch、HTTP command error、revision/idempotency conflict 和 legacy route 请求率无法由数据库证明,工具显式报告 unavailable;生产必须接入日志/指标平台,不能把“不可见”写成“零”。 +- dashboard 指标、维度、每日证据和放行原则记录在 `docs/architecture/issue-34-observability-runbook.md`。由于尚无目标阈值、负责人、平台告警和真实观察窗口,6.1、6.2、6.4 保持未完成。 + ### 第 6.3 阶段本地保护(不等同目标环境验证) - 新增只读前滚恢复规划器:首个 canonical 写入前只允许“零 canonical 写入”的 legacy abort;首写之后强制保持 canonical authority,只允许 canonical 备份恢复或前滚修复。 diff --git a/package.json b/package.json index c5c72837..1953508e 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "audit:conversation-domain": "node scripts/audit-thread-tree-boundary.mjs", "audit:legacy-conversations": "node --experimental-strip-types scripts/audit-legacy-conversations.ts", "audit:legacy-conversation-deletion": "node scripts/audit-legacy-conversation-deletion.mjs", + "audit:conversation-cutover-health": "node scripts/audit-conversation-cutover-health.mjs", "import:legacy-conversations": "tsx scripts/import-legacy-conversations.ts", "reset:approved-conversations": "tsx scripts/reset-approved-conversations.ts", "plan:conversation-forward-recovery": "tsx scripts/plan-conversation-forward-recovery.ts", diff --git a/scripts/audit-conversation-cutover-health.mjs b/scripts/audit-conversation-cutover-health.mjs new file mode 100644 index 00000000..7df801e9 --- /dev/null +++ b/scripts/audit-conversation-cutover-health.mjs @@ -0,0 +1,134 @@ +import { spawnSync } from "node:child_process" + +import { config } from "dotenv" +import postgres from "postgres" + +config({ path: ".env.local", quiet: true }) + +const rawUrl = process.env.DIRECT_URL || process.env.DATABASE_URL +if (!rawUrl) throw new Error("未配置 DIRECT_URL 或 DATABASE_URL") +const databaseUrl = rawUrl.trim().replace(/^(['"])(.*)\1$/u, "$2") +const parsedUrl = new URL(databaseUrl) +const sql = postgres(databaseUrl, { max: 1, prepare: false }) + +function legacyRuntimeReferenceCount() { + const result = spawnSync( + "rg", + [ + "-l", + "branchTrees|branchGenerations|branchMessageFeedback|ThreadTreeState|thread-chat-generation", + "app", + "lib", + "constants", + "--glob", + "!**/*.test.*", + ], + { cwd: process.cwd(), encoding: "utf8" } + ) + if (result.status !== 0 && result.status !== 1) + throw new Error(`rg 退出码 ${result.status}: ${result.stderr}`) + return result.stdout.split("\n").filter(Boolean).length +} + +try { + const generationStatus = await sql` + SELECT status, billing_status, usage_completeness, + count(*)::int AS count, + max(EXTRACT(EPOCH FROM (now() - heartbeat_at)))::int AS max_heartbeat_age_seconds + FROM thread_chat.conversation_generations + GROUP BY status, billing_status, usage_completeness + ORDER BY status, billing_status, usage_completeness` + const [generationRisks] = await sql` + SELECT + count(*) FILTER (WHERE status IN ('running', 'stop_requested'))::int AS active, + count(*) FILTER (WHERE billing_status = 'pending')::int AS pending_billing, + count(*) FILTER (WHERE status IN ('completed', 'stopped') AND checkpoint_version = 0)::int AS terminal_without_checkpoint, + count(*) FILTER (WHERE status IN ('completed', 'stopped') AND content_state <> 'complete')::int AS completed_or_stopped_with_incomplete_content, + count(*) FILTER (WHERE status IN ('failed', 'superseded') AND finished_at IS NULL)::int AS terminal_without_finished_at + FROM thread_chat.conversation_generations` + const outbox = await sql` + SELECT status, count(*)::int AS count, + max(EXTRACT(EPOCH FROM (now() - created_at)))::int AS max_age_seconds + FROM thread_chat.conversation_outbox_events + GROUP BY status ORDER BY status` + const commands = await sql` + SELECT command_type, count(*)::int AS count + FROM thread_chat.conversation_command_records + GROUP BY command_type ORDER BY command_type` + const [usage] = await sql` + SELECT + count(*) FILTER (WHERE g.id IS NOT NULL)::int AS canonical_usage_records, + count(*) FILTER (WHERE g.id IS NULL AND u.app_generation_id IS NOT NULL)::int AS noncanonical_or_historical_usage_records, + count(*) FILTER (WHERE g.billing_status = 'settled' AND u.id IS NULL)::int AS settled_generation_without_usage, + count(*) FILTER (WHERE g.known_usage IS NOT NULL AND u.id IS NOT NULL + AND ((g.known_usage->>'inputTokens')::int <> u.input_tokens + OR (g.known_usage->>'outputTokens')::int <> u.output_tokens))::int AS token_mismatches + FROM thread_chat.usage_records u + FULL JOIN thread_chat.conversation_generations g + ON g.id = u.app_generation_id` + const [legacy] = await sql` + SELECT + (SELECT count(*)::int FROM thread_chat.branch_trees) AS trees, + (SELECT count(*)::int FROM thread_chat.branch_generations) AS generations, + (SELECT count(*)::int FROM thread_chat.branch_message_feedback) AS feedback` + const [statExtension] = await sql` + SELECT EXISTS ( + SELECT 1 FROM pg_extension WHERE extname = 'pg_stat_statements' + ) AS installed` + let legacyDatabaseQueries = { + available: false, + calls: null, + totalExecTimeMs: null, + } + if (statExtension?.installed) + try { + const [stats] = await sql` + SELECT COALESCE(sum(calls), 0)::bigint AS calls, + COALESCE(sum(total_exec_time), 0)::double precision AS total_exec_time_ms + FROM pg_stat_statements + WHERE query ~ 'thread_chat\\.(branch_trees|branch_generations|branch_message_feedback)'` + legacyDatabaseQueries = { + available: true, + calls: Number(stats?.calls ?? 0), + totalExecTimeMs: Number(stats?.total_exec_time_ms ?? 0), + } + } catch { + // 扩展存在但当前角色可能没有读取权限;明确报告 unavailable,不猜零调用。 + } + + console.log( + JSON.stringify( + { + schemaVersion: 1, + mode: "read-only", + observedAt: new Date().toISOString(), + database: { + host: parsedUrl.hostname, + name: parsedUrl.pathname.slice(1), + }, + canonical: { + generationStatus, + generationRisks, + outbox, + commands, + usage, + }, + legacy: { + rows: legacy, + databaseQueries: legacyDatabaseQueries, + runtimeReferenceFiles: legacyRuntimeReferenceCount(), + }, + unavailableFromDatabase: [ + "authority_mismatch request rate", + "HTTP command error rate", + "revision/idempotency conflict rate", + "legacy route request rate", + ], + }, + null, + 2 + ) + ) +} finally { + await sql.end() +} From 0d0684cc5016801550cce31f6c37e938f889d2a7 Mon Sep 17 00:00:00 2001 From: zilin <276161014@qq.com> Date: Sat, 22 Aug 2026 05:59:23 +0800 Subject: [PATCH 17/32] feat(cutover): validate release manifest --- docs/architecture/issue-34-cutover-runbook.md | 6 + .../conversation-cutover-manifest.test.ts | 168 +++++++++++++++ .../cutover/conversation-cutover-manifest.ts | 200 ++++++++++++++++++ .../retire-thread-tree-authority/tasks.md | 6 + package.json | 3 +- .../validate-conversation-cutover-manifest.ts | 53 +++++ 6 files changed, 435 insertions(+), 1 deletion(-) create mode 100644 lib/thread-chat/cutover/conversation-cutover-manifest.test.ts create mode 100644 lib/thread-chat/cutover/conversation-cutover-manifest.ts create mode 100644 scripts/validate-conversation-cutover-manifest.ts diff --git a/docs/architecture/issue-34-cutover-runbook.md b/docs/architecture/issue-34-cutover-runbook.md index 23b91fac..b445b73b 100644 --- a/docs/architecture/issue-34-cutover-runbook.md +++ b/docs/architecture/issue-34-cutover-runbook.md @@ -36,6 +36,12 @@ define-conversation-domain-model | usage/exactly-once billing | generation DB tests | 不依赖浏览器连接 | pending billing=0;usage 终态明确 | | authority 与旧协议 | authority/client tests | authority 三元组匹配;旧 route 返回 410 | 不存在分裂开关或 fallback | +## 版本化 release manifest + +正式演练和 cutover 必须先生成受版本控制的 JSON manifest,并用 `pnpm validate:conversation-cutover-manifest -- --manifest-file --for-execution --environment --database-host --database-name ` 校验。manifest 固化负责人、维护/观察窗口、实测基线、阈值、import/reset ADR、legacy/canonical 备份恢复证明、epoch 和十项 go/no-go 结果,并输出稳定 SHA-256 供审批与执行日志引用。 + +schema-only 校验不代表可执行;`--for-execution` 会在任一门禁为 false、环境/数据库漂移、窗口过期、备份验证晚于维护开始,或“有保留义务却选择 reset”时 fail closed。仓库不提供带伪造生产值的默认 manifest。 + ## 本地 cutover 演练结果(2026-08-22) - 数据:19 棵 legacy 树;22 Thread、34 Turn、71 Message、3 Fork、37 Generation、1 feedback。 diff --git a/lib/thread-chat/cutover/conversation-cutover-manifest.test.ts b/lib/thread-chat/cutover/conversation-cutover-manifest.test.ts new file mode 100644 index 00000000..77a4bde6 --- /dev/null +++ b/lib/thread-chat/cutover/conversation-cutover-manifest.test.ts @@ -0,0 +1,168 @@ +import assert from "node:assert/strict" +import test from "node:test" + +import { + assertConversationCutoverManifestReady, + conversationCutoverManifestSchema, + hashConversationCutoverManifest, +} from "./conversation-cutover-manifest.ts" + +const manifestInput = { + schemaVersion: 1 as const, + action: "conversation-cutover-release" as const, + environment: "staging", + database: { host: "db.internal", name: "thread-chat" }, + authority: { + from: "legacy" as const, + to: "canonical" as const, + epoch: "epoch-34", + }, + owners: { + release: "release@example.com", + data: "data@example.com", + billing: "billing@example.com", + incident: "release@example.com", + }, + windows: { + maintenanceStartsAt: "2026-08-23T01:00:00.000Z", + maintenanceEndsAt: "2026-08-23T02:00:00.000Z", + observationEndsAt: "2026-08-30T02:00:00.000Z", + }, + baseline: { + capturedAt: "2026-08-22T00:00:00.000Z", + legacyTrees: 19, + legacyGenerations: 37, + canonicalConversations: 0, + requestsPerMinute: 1, + commandErrorRate: 0, + revisionConflictRate: 0, + usageUnavailableRate: 0, + }, + thresholds: { + maxMaintenanceSeconds: 3600, + maxImportSeconds: 600, + maxCommandErrorRate: 0.01, + maxRevisionConflictRate: 0.01, + maxGenerationHeartbeatAgeSeconds: 120, + maxPendingBilling: 0, + maxPendingOutbox: 0, + maxUsageUnavailableRate: 0.01, + maxLegacyRouteCalls: 0, + maxLegacySqlCalls: 0, + }, + disposition: { + mode: "deterministic-import" as const, + retentionRequired: true, + legacyTreeIds: "all" as const, + adrId: "ADR-34", + approvalId: "approval-34", + approvedBy: "data@example.com", + approvedAt: "2026-08-22T01:00:00.000Z", + reason: "保留全部用户数据", + exclusions: [], + }, + backups: { + legacy: { + id: "legacy-backup", + sha256: "a".repeat(64), + restoreTestId: "legacy-restore", + verifiedAt: "2026-08-22T02:00:00.000Z", + verifiedBy: "data@example.com", + }, + canonical: { + id: "canonical-backup", + sha256: "b".repeat(64), + restoreTestId: "canonical-restore", + verifiedAt: "2026-08-22T02:00:00.000Z", + verifiedBy: "data@example.com", + }, + }, + goNoGo: { + strictSpecsPassed: true, + behaviorMatrixPassed: true, + securityReviewPassed: true, + dataAuditResolved: true, + drainReady: true, + backupRestorePassed: true, + rollbackRehearsalPassed: true, + canaryActorsReady: true, + dashboardsReady: true, + finalApprovalGranted: true, + }, +} + +test("完整 manifest 在环境、数据库、窗口与所有门禁匹配时可执行", () => { + const manifest = conversationCutoverManifestSchema.parse(manifestInput) + assert.doesNotThrow(() => + assertConversationCutoverManifestReady({ + manifest, + environment: "staging", + databaseHost: "db.internal", + databaseName: "thread-chat", + now: new Date("2026-08-23T00:30:00.000Z"), + }) + ) + assert.equal(hashConversationCutoverManifest(manifest).length, 64) +}) + +test("保留数据时拒绝 reset,且窗口顺序必须有效", () => { + assert.throws( + () => + conversationCutoverManifestSchema.parse({ + ...manifestInput, + disposition: { ...manifestInput.disposition, mode: "approved-reset" }, + }), + /确定性导入/u + ) + assert.throws( + () => + conversationCutoverManifestSchema.parse({ + ...manifestInput, + windows: { + ...manifestInput.windows, + maintenanceEndsAt: "2026-08-23T00:00:00.000Z", + }, + }), + /维护结束时间/u + ) +}) + +test("执行校验拒绝环境漂移、未通过门禁和过期窗口", () => { + const manifest = conversationCutoverManifestSchema.parse(manifestInput) + assert.throws( + () => + assertConversationCutoverManifestReady({ + manifest, + environment: "production", + databaseHost: "db.internal", + databaseName: "thread-chat", + }), + /环境/u + ) + const notReady = conversationCutoverManifestSchema.parse({ + ...manifestInput, + goNoGo: { ...manifestInput.goNoGo, dashboardsReady: false }, + }) + assert.throws( + () => + assertConversationCutoverManifestReady({ + manifest: notReady, + environment: "staging", + databaseHost: "db.internal", + databaseName: "thread-chat", + now: new Date("2026-08-23T00:30:00.000Z"), + }), + /dashboardsReady/u + ) + assert.throws( + () => + assertConversationCutoverManifestReady({ + manifest, + environment: "staging", + databaseHost: "db.internal", + databaseName: "thread-chat", + now: new Date("2026-08-23T03:00:00.000Z"), + }), + /维护窗口已经结束/u + ) +}) diff --git a/lib/thread-chat/cutover/conversation-cutover-manifest.ts b/lib/thread-chat/cutover/conversation-cutover-manifest.ts new file mode 100644 index 00000000..17c78647 --- /dev/null +++ b/lib/thread-chat/cutover/conversation-cutover-manifest.ts @@ -0,0 +1,200 @@ +import { createHash } from "node:crypto" + +import { z } from "zod" + +const rate = z.number().min(0).max(1) +const nonnegative = z.number().int().nonnegative() + +const backupSchema = z + .object({ + id: z.string().trim().min(1), + sha256: z.string().regex(/^[a-f0-9]{64}$/u), + restoreTestId: z.string().trim().min(1), + verifiedAt: z.string().datetime(), + verifiedBy: z.string().trim().min(1), + }) + .strict() + +export const conversationCutoverManifestSchema = z + .object({ + schemaVersion: z.literal(1), + action: z.literal("conversation-cutover-release"), + environment: z.string().trim().min(1), + database: z + .object({ + host: z.string().trim().min(1), + name: z.string().trim().min(1), + }) + .strict(), + authority: z + .object({ + from: z.literal("legacy"), + to: z.literal("canonical"), + epoch: z.string().trim().min(1), + }) + .strict(), + owners: z + .object({ + release: z.string().trim().min(1), + data: z.string().trim().min(1), + billing: z.string().trim().min(1), + incident: z.string().trim().min(1), + }) + .strict(), + windows: z + .object({ + maintenanceStartsAt: z.string().datetime(), + maintenanceEndsAt: z.string().datetime(), + observationEndsAt: z.string().datetime(), + }) + .strict(), + baseline: z + .object({ + capturedAt: z.string().datetime(), + legacyTrees: nonnegative, + legacyGenerations: nonnegative, + canonicalConversations: nonnegative, + requestsPerMinute: z.number().nonnegative(), + commandErrorRate: rate, + revisionConflictRate: rate, + usageUnavailableRate: rate, + }) + .strict(), + thresholds: z + .object({ + maxMaintenanceSeconds: z.number().int().positive(), + maxImportSeconds: z.number().int().positive(), + maxCommandErrorRate: rate, + maxRevisionConflictRate: rate, + maxGenerationHeartbeatAgeSeconds: z.number().int().positive(), + maxPendingBilling: nonnegative, + maxPendingOutbox: nonnegative, + maxUsageUnavailableRate: rate, + maxLegacyRouteCalls: nonnegative, + maxLegacySqlCalls: nonnegative, + }) + .strict(), + disposition: z + .object({ + mode: z.enum(["deterministic-import", "approved-reset"]), + retentionRequired: z.boolean(), + legacyTreeIds: z.union([ + z.literal("all"), + z.array(z.string().trim().min(1)).min(1), + ]), + adrId: z.string().trim().min(1), + approvalId: z.string().trim().min(1), + approvedBy: z.string().trim().min(1), + approvedAt: z.string().datetime(), + reason: z.string().trim().min(1), + exclusions: z.array(z.string().trim().min(1)), + }) + .strict(), + backups: z + .object({ + legacy: backupSchema, + canonical: backupSchema, + }) + .strict(), + goNoGo: z + .object({ + strictSpecsPassed: z.boolean(), + behaviorMatrixPassed: z.boolean(), + securityReviewPassed: z.boolean(), + dataAuditResolved: z.boolean(), + drainReady: z.boolean(), + backupRestorePassed: z.boolean(), + rollbackRehearsalPassed: z.boolean(), + canaryActorsReady: z.boolean(), + dashboardsReady: z.boolean(), + finalApprovalGranted: z.boolean(), + }) + .strict(), + }) + .strict() + .superRefine((manifest, context) => { + const maintenanceStart = Date.parse(manifest.windows.maintenanceStartsAt) + const maintenanceEnd = Date.parse(manifest.windows.maintenanceEndsAt) + const observationEnd = Date.parse(manifest.windows.observationEndsAt) + if (maintenanceStart >= maintenanceEnd) + context.addIssue({ + code: "custom", + path: ["windows", "maintenanceEndsAt"], + message: "维护结束时间必须晚于开始时间", + }) + if (maintenanceEnd >= observationEnd) + context.addIssue({ + code: "custom", + path: ["windows", "observationEndsAt"], + message: "观察窗口必须晚于维护窗口结束", + }) + if ( + manifest.disposition.retentionRequired && + manifest.disposition.mode !== "deterministic-import" + ) + context.addIssue({ + code: "custom", + path: ["disposition", "mode"], + message: "存在数据保留义务时必须选择确定性导入", + }) + if ( + new Set( + Object.values(manifest.owners).map((owner) => owner.toLowerCase()) + ).size < 2 + ) + context.addIssue({ + code: "custom", + path: ["owners"], + message: "至少需要两个独立责任主体,不能由单人包揽全部门禁", + }) + }) + +export type ConversationCutoverManifest = z.infer< + typeof conversationCutoverManifestSchema +> + +export function canonicalManifestJson( + manifest: ConversationCutoverManifest +): string { + return JSON.stringify(manifest) +} + +export function hashConversationCutoverManifest( + manifest: ConversationCutoverManifest +): string { + return createHash("sha256") + .update(canonicalManifestJson(manifest)) + .digest("hex") +} + +export function assertConversationCutoverManifestReady(input: { + readonly manifest: ConversationCutoverManifest + readonly environment: string + readonly databaseHost: string + readonly databaseName: string + readonly now?: Date +}): void { + const { manifest } = input + if (input.environment !== manifest.environment) + throw new Error("当前环境与 cutover manifest 不匹配") + if ( + input.databaseHost !== manifest.database.host || + input.databaseName !== manifest.database.name + ) + throw new Error("当前数据库与 cutover manifest 不匹配") + const failed = Object.entries(manifest.goNoGo) + .filter(([, passed]) => !passed) + .map(([name]) => name) + if (failed.length > 0) + throw new Error(`go/no-go 门禁未通过:${failed.join(", ")}`) + const now = input.now ?? new Date() + if (now.getTime() >= Date.parse(manifest.windows.maintenanceEndsAt)) + throw new Error("cutover manifest 的维护窗口已经结束") + if ( + Date.parse(manifest.backups.legacy.verifiedAt) >= + Date.parse(manifest.windows.maintenanceStartsAt) || + Date.parse(manifest.backups.canonical.verifiedAt) >= + Date.parse(manifest.windows.maintenanceStartsAt) + ) + throw new Error("备份恢复验证必须在维护窗口开始前完成") +} diff --git a/openspec/changes/retire-thread-tree-authority/tasks.md b/openspec/changes/retire-thread-tree-authority/tasks.md index 1db5cf79..ec53b280 100644 --- a/openspec/changes/retire-thread-tree-authority/tasks.md +++ b/openspec/changes/retire-thread-tree-authority/tasks.md @@ -12,6 +12,12 @@ - [ ] 2.3 基于保留义务形成“确定性导入或受批准重置”ADR,记录范围、理由、批准人、备份和排除项。 - [ ] 2.4 将 ADR 结果转成版本化 cutover 配置,不在代码中隐式猜测环境或数据处置方式。 +### 第 1.3/2.4 阶段版本化输入合同(不等同真实发布记录) + +- 新增严格 cutover manifest schema 与执行校验 CLI,覆盖环境/数据库、四类负责人、维护/观察窗口、容量/错误/计费基线、十项阈值、import/reset ADR、双备份恢复证明、epoch 和十项 go/no-go 门禁。 +- 有保留义务时 schema 强制确定性导入;执行时环境、数据库、时间窗口与所有门禁必须精确匹配,并输出 manifest SHA-256,禁止从代码或环境隐式猜测数据处置。 +- 1.3 与 2.4 保持未完成:尚未填入真实目标环境基线、负责人、阈值、窗口和已批准 ADR,也未把真实 manifest 纳入发布证据。 + ## 3. 确定性导入或重置工具 - [x] 3.1 实现 `(legacyTreeId, entityType, localId) → canonicalId` 的持久映射与幂等 dry-run/report。 diff --git a/package.json b/package.json index 1953508e..f01011cf 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "test:conversation-http-api": "tsx scripts/test-conversation-http-api.ts", "test:conversation-command-contract": "tsx --test lib/thread-chat/http/conversation-command-http.test.ts", "test:conversation-client": "tsx --test lib/thread-chat/client/normalized-conversation-store.test.ts", - "test:conversation-authority": "tsx --test lib/thread-chat/cutover/conversation-authority.test.ts lib/thread-chat/cutover/conversation-drain.test.ts lib/thread-chat/cutover/legacy-conversation-import.test.ts lib/thread-chat/cutover/approved-conversation-reset.test.ts lib/thread-chat/cutover/conversation-forward-recovery.test.ts", + "test:conversation-authority": "tsx --test lib/thread-chat/cutover/conversation-authority.test.ts lib/thread-chat/cutover/conversation-drain.test.ts lib/thread-chat/cutover/legacy-conversation-import.test.ts lib/thread-chat/cutover/approved-conversation-reset.test.ts lib/thread-chat/cutover/conversation-forward-recovery.test.ts lib/thread-chat/cutover/conversation-cutover-manifest.test.ts", "check:conversation-cutover-drain": "tsx scripts/check-conversation-cutover-drain.ts", "rehearse:conversation-cutover-local": "node scripts/rehearse-conversation-cutover-local.mjs", "seed:conversation-browser": "tsx scripts/seed-canonical-browser-fixture.ts", @@ -33,6 +33,7 @@ "import:legacy-conversations": "tsx scripts/import-legacy-conversations.ts", "reset:approved-conversations": "tsx scripts/reset-approved-conversations.ts", "plan:conversation-forward-recovery": "tsx scripts/plan-conversation-forward-recovery.ts", + "validate:conversation-cutover-manifest": "tsx scripts/validate-conversation-cutover-manifest.ts", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:reset-schema": "node scripts/reset-thread-chat-schema.mjs", diff --git a/scripts/validate-conversation-cutover-manifest.ts b/scripts/validate-conversation-cutover-manifest.ts new file mode 100644 index 00000000..0dc22785 --- /dev/null +++ b/scripts/validate-conversation-cutover-manifest.ts @@ -0,0 +1,53 @@ +import { readFile } from "node:fs/promises" + +import { + assertConversationCutoverManifestReady, + conversationCutoverManifestSchema, + hashConversationCutoverManifest, +} from "../lib/thread-chat/cutover/conversation-cutover-manifest.ts" + +function flag(name: string): string | undefined { + const index = process.argv.indexOf(name) + const value = index >= 0 ? process.argv[index + 1] : undefined + return value && !value.startsWith("--") ? value : undefined +} + +const manifestPath = flag("--manifest-file") +if (!manifestPath) throw new Error("必须提供 --manifest-file") +const manifest = conversationCutoverManifestSchema.parse( + JSON.parse(await readFile(manifestPath, "utf8")) +) + +if (process.argv.includes("--for-execution")) { + const environment = flag("--environment") + const databaseHost = flag("--database-host") + const databaseName = flag("--database-name") + if (!environment || !databaseHost || !databaseName) + throw new Error( + "--for-execution 必须显式提供 --environment、--database-host 和 --database-name" + ) + assertConversationCutoverManifestReady({ + manifest, + environment, + databaseHost, + databaseName, + }) +} + +console.log( + JSON.stringify( + { + ok: true, + mode: process.argv.includes("--for-execution") + ? "execution-gate" + : "schema-only", + environment: manifest.environment, + database: manifest.database, + epoch: manifest.authority.epoch, + disposition: manifest.disposition.mode, + manifestSha256: hashConversationCutoverManifest(manifest), + }, + null, + 2 + ) +) From 3f3c8ebaa4f1b229fe65a4ec3d54548b1738afa7 Mon Sep 17 00:00:00 2001 From: zilin <276161014@qq.com> Date: Sat, 22 Aug 2026 06:02:32 +0800 Subject: [PATCH 18/32] feat(cutover): bind actions to release manifest --- docs/architecture/issue-34-cutover-runbook.md | 4 +- .../conversation-cutover-manifest.test.ts | 30 +++++ .../cutover/conversation-cutover-manifest.ts | 30 +++++ .../retire-thread-tree-authority/tasks.md | 1 + scripts/import-legacy-conversations.ts | 25 ++++ .../rehearse-conversation-cutover-local.mjs | 125 ++++++++++++++++++ scripts/reset-approved-conversations.ts | 22 +++ 7 files changed, 236 insertions(+), 1 deletion(-) diff --git a/docs/architecture/issue-34-cutover-runbook.md b/docs/architecture/issue-34-cutover-runbook.md index b445b73b..038dce49 100644 --- a/docs/architecture/issue-34-cutover-runbook.md +++ b/docs/architecture/issue-34-cutover-runbook.md @@ -42,6 +42,8 @@ define-conversation-domain-model schema-only 校验不代表可执行;`--for-execution` 会在任一门禁为 false、环境/数据库漂移、窗口过期、备份验证晚于维护开始,或“有保留义务却选择 reset”时 fail closed。仓库不提供带伪造生产值的默认 manifest。 +正式 `import:legacy-conversations` 与 `reset:approved-conversations` 写执行均强制要求 `--manifest-file`,并再次核对 ADR mode、approval ID、对应 legacy/canonical backup ID 与 legacy Tree scope;单独运行 validator 不能绕过写工具内的二次校验。 + ## 本地 cutover 演练结果(2026-08-22) - 数据:19 棵 legacy 树;22 Thread、34 Turn、71 Message、3 Fork、37 Generation、1 feedback。 @@ -62,7 +64,7 @@ schema-only 校验不代表可执行;`--for-execution` 会在任一门禁为 f - [ ] 进入 `legacy + read-only`,运行 drain checker 直至 `ready=true`。 - [ ] 用目标审批文件执行 import/reset,保存 verifier 结果。 -受批准 reset 必须同时提供 ADR 审批文件与独立的备份恢复验证文件,并设置与二者精确匹配的 `CONVERSATION_CUTOVER_ENVIRONMENT`、`CONVERSATION_CUTOVER_APPROVAL_ID`、`CONVERSATION_CUTOVER_BACKUP_ID`。只有在 `legacy + read-only`、drain 清零且 `CONVERSATION_APPROVED_RESET_ENABLED=true` 时,才可运行 `pnpm reset:approved-conversations -- --execute --approval-file --backup-verification-file `。先用 `--test-rollback` 完成同一事务路径的强制回滚验证。工具保留 `usage_records`,任何财务账本处置必须走独立 ADR。 +受批准 reset 必须同时提供 release manifest、ADR 审批文件与独立的备份恢复验证文件,并设置与三者精确匹配的 `CONVERSATION_CUTOVER_ENVIRONMENT`、`CONVERSATION_CUTOVER_APPROVAL_ID`、`CONVERSATION_CUTOVER_BACKUP_ID`。只有在 `legacy + read-only`、drain 清零且 `CONVERSATION_APPROVED_RESET_ENABLED=true` 时,才可运行 `pnpm reset:approved-conversations -- --execute --manifest-file --approval-file --backup-verification-file `。先用 `--test-rollback` 完成同一事务路径的强制回滚验证。工具保留 `usage_records`,任何财务账本处置必须走独立 ADR。 本地临时库演练已覆盖完整 reset SQL:在 canonical 备份恢复副本中删除审批 scope 内实体后强制回滚,13 张 cutover 表的合并指纹在前后完全一致,并验证关联的 5 条 `usage_records` 未被删除。该证据只证明工具路径,不构成目标环境的数据丢弃批准。 diff --git a/lib/thread-chat/cutover/conversation-cutover-manifest.test.ts b/lib/thread-chat/cutover/conversation-cutover-manifest.test.ts index 77a4bde6..4c62169d 100644 --- a/lib/thread-chat/cutover/conversation-cutover-manifest.test.ts +++ b/lib/thread-chat/cutover/conversation-cutover-manifest.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict" import test from "node:test" import { + assertConversationCutoverManifestDisposition, assertConversationCutoverManifestReady, conversationCutoverManifestSchema, hashConversationCutoverManifest, @@ -166,3 +167,32 @@ test("执行校验拒绝环境漂移、未通过门禁和过期窗口", () => { /维护窗口已经结束/u ) }) + +test("执行动作必须与 manifest 的 ADR 模式、审批、备份和 scope 一致", () => { + const manifest = conversationCutoverManifestSchema.parse(manifestInput) + assert.doesNotThrow(() => + assertConversationCutoverManifestDisposition({ + manifest, + mode: "deterministic-import", + approvalId: "approval-34", + backupId: "legacy-backup", + legacyTreeIds: "all", + }) + ) + for (const override of [ + { mode: "approved-reset" as const }, + { approvalId: "other" }, + { backupId: "other" }, + { legacyTreeIds: ["tree-1"] }, + ]) + assert.throws(() => + assertConversationCutoverManifestDisposition({ + manifest, + mode: "deterministic-import", + approvalId: "approval-34", + backupId: "legacy-backup", + legacyTreeIds: "all", + ...override, + }) + ) +}) diff --git a/lib/thread-chat/cutover/conversation-cutover-manifest.ts b/lib/thread-chat/cutover/conversation-cutover-manifest.ts index 17c78647..79cec60b 100644 --- a/lib/thread-chat/cutover/conversation-cutover-manifest.ts +++ b/lib/thread-chat/cutover/conversation-cutover-manifest.ts @@ -198,3 +198,33 @@ export function assertConversationCutoverManifestReady(input: { ) throw new Error("备份恢复验证必须在维护窗口开始前完成") } + +export function assertConversationCutoverManifestDisposition(input: { + readonly manifest: ConversationCutoverManifest + readonly mode: "deterministic-import" | "approved-reset" + readonly approvalId: string + readonly backupId: string + readonly legacyTreeIds: "all" | readonly string[] +}): void { + const { disposition } = input.manifest + if (disposition.mode !== input.mode) + throw new Error(`cutover manifest 未批准 ${input.mode}`) + if (disposition.approvalId !== input.approvalId) + throw new Error("cutover manifest approval ID 与执行审批不匹配") + const expectedBackup = + input.mode === "deterministic-import" + ? input.manifest.backups.legacy.id + : input.manifest.backups.canonical.id + if (expectedBackup !== input.backupId) + throw new Error("cutover manifest backup ID 与执行审批不匹配") + const manifestScope = disposition.legacyTreeIds + if ( + JSON.stringify( + manifestScope === "all" ? "all" : [...manifestScope].sort() + ) !== + JSON.stringify( + input.legacyTreeIds === "all" ? "all" : [...input.legacyTreeIds].sort() + ) + ) + throw new Error("cutover manifest scope 与执行审批不匹配") +} diff --git a/openspec/changes/retire-thread-tree-authority/tasks.md b/openspec/changes/retire-thread-tree-authority/tasks.md index ec53b280..f15978f5 100644 --- a/openspec/changes/retire-thread-tree-authority/tasks.md +++ b/openspec/changes/retire-thread-tree-authority/tasks.md @@ -16,6 +16,7 @@ - 新增严格 cutover manifest schema 与执行校验 CLI,覆盖环境/数据库、四类负责人、维护/观察窗口、容量/错误/计费基线、十项阈值、import/reset ADR、双备份恢复证明、epoch 和十项 go/no-go 门禁。 - 有保留义务时 schema 强制确定性导入;执行时环境、数据库、时间窗口与所有门禁必须精确匹配,并输出 manifest SHA-256,禁止从代码或环境隐式猜测数据处置。 +- 正式 import/reset 写工具已强制接收 manifest,并二次核对 ADR mode、approval ID、对应备份 ID 与 scope;本地临时数据库演练覆盖两次幂等 import 和 reset 强制回滚,证明无法绕过 manifest 单独执行审批文件。 - 1.3 与 2.4 保持未完成:尚未填入真实目标环境基线、负责人、阈值、窗口和已批准 ADR,也未把真实 manifest 纳入发布证据。 ## 3. 确定性导入或重置工具 diff --git a/scripts/import-legacy-conversations.ts b/scripts/import-legacy-conversations.ts index 2031e880..9c066999 100644 --- a/scripts/import-legacy-conversations.ts +++ b/scripts/import-legacy-conversations.ts @@ -15,6 +15,11 @@ import { type LegacyConversationImportPlan, } from "../lib/thread-chat/cutover/legacy-conversation-import.ts" import { evaluateConversationCutoverDrain } from "../lib/thread-chat/cutover/conversation-drain.ts" +import { + assertConversationCutoverManifestDisposition, + assertConversationCutoverManifestReady, + conversationCutoverManifestSchema, +} from "../lib/thread-chat/cutover/conversation-cutover-manifest.ts" config({ path: ".env.local" }) @@ -48,6 +53,9 @@ const rollback = args.has("--test-rollback") const approvalFlag = process.argv.indexOf("--approval-file") const approvalPath = approvalFlag >= 0 ? process.argv[approvalFlag + 1] : undefined +const manifestFlag = process.argv.indexOf("--manifest-file") +const manifestPath = + manifestFlag >= 0 ? process.argv[manifestFlag + 1] : undefined const rawUrl = process.env.DIRECT_URL || process.env.DATABASE_URL if (!rawUrl) throw new Error("未配置 DIRECT_URL 或 DATABASE_URL") @@ -312,9 +320,13 @@ try { let approval: z.infer | null = null if (execute || rollback) { if (!approvalPath) throw new Error("执行写入必须提供 --approval-file") + if (!manifestPath) throw new Error("执行写入必须提供 --manifest-file") approval = approvalSchema.parse( JSON.parse(await readFile(approvalPath, "utf8")) ) + const manifest = conversationCutoverManifestSchema.parse( + JSON.parse(await readFile(manifestPath, "utf8")) + ) const authority = resolveConversationAuthority() if ( authority.authority !== "legacy" || @@ -328,6 +340,19 @@ try { parsedDatabaseUrl.pathname.slice(1) !== approval.database.name ) throw new Error("当前数据库与审批文件目标不匹配") + assertConversationCutoverManifestReady({ + manifest, + environment: process.env.CONVERSATION_CUTOVER_ENVIRONMENT ?? "", + databaseHost: parsedDatabaseUrl.hostname, + databaseName: parsedDatabaseUrl.pathname.slice(1), + }) + assertConversationCutoverManifestDisposition({ + manifest, + mode: "deterministic-import", + approvalId: approval.approvalId, + backupId: approval.backupId, + legacyTreeIds: approval.scope.legacyTreeIds, + }) const [drainCounts] = await sql< readonly { legacy_active: number diff --git a/scripts/rehearse-conversation-cutover-local.mjs b/scripts/rehearse-conversation-cutover-local.mjs index 4ed48132..41e59639 100644 --- a/scripts/rehearse-conversation-cutover-local.mjs +++ b/scripts/rehearse-conversation-cutover-local.mjs @@ -27,6 +27,10 @@ const canonicalRestoreName = `${databasePrefix}_canonical` const legacyDump = join(tmpdir(), `${databasePrefix}_legacy.dump`) const canonicalDump = join(tmpdir(), `${databasePrefix}_canonical.dump`) const approvalPath = join(tmpdir(), `${databasePrefix}_approval.json`) +const importManifestPath = join( + tmpdir(), + `${databasePrefix}_import-manifest.json` +) const resetApprovalPath = join( tmpdir(), `${databasePrefix}_reset-approval.json` @@ -35,6 +39,10 @@ const backupVerificationPath = join( tmpdir(), `${databasePrefix}_backup-verification.json` ) +const resetManifestPath = join( + tmpdir(), + `${databasePrefix}_reset-manifest.json` +) const startedAt = Date.now() const CUTOVER_TABLES = [ @@ -132,10 +140,93 @@ function cutoverEnv(url, approvalId, extra = {}) { CONVERSATION_MAINTENANCE_MODE: "read-only", CONVERSATION_ISOLATED_TEST: "true", CONVERSATION_CUTOVER_APPROVAL_ID: approvalId, + CONVERSATION_CUTOVER_ENVIRONMENT: "local-ephemeral-cutover-rehearsal", ...extra, } } +function releaseManifest(input) { + const now = Date.now() + const backup = (id, sha256, suffix) => ({ + id, + sha256, + restoreTestId: `local-${suffix}-${runId}`, + verifiedAt: new Date(now).toISOString(), + verifiedBy: "local-cutover-rehearsal", + }) + return { + schemaVersion: 1, + action: "conversation-cutover-release", + environment: "local-ephemeral-cutover-rehearsal", + database: { host: sourceUrl.hostname, name: input.databaseName }, + authority: { from: "legacy", to: "canonical", epoch: `local-${runId}` }, + owners: { + release: "local-release-rehearsal", + data: "local-data-rehearsal", + billing: "local-data-rehearsal", + incident: "local-release-rehearsal", + }, + windows: { + maintenanceStartsAt: new Date(now + 60_000).toISOString(), + maintenanceEndsAt: new Date(now + 3_600_000).toISOString(), + observationEndsAt: new Date(now + 86_400_000).toISOString(), + }, + baseline: { + capturedAt: new Date(now).toISOString(), + legacyTrees: 19, + legacyGenerations: 37, + canonicalConversations: 1, + requestsPerMinute: 0, + commandErrorRate: 0, + revisionConflictRate: 0, + usageUnavailableRate: 0, + }, + thresholds: { + maxMaintenanceSeconds: 3600, + maxImportSeconds: 600, + maxCommandErrorRate: 0, + maxRevisionConflictRate: 0, + maxGenerationHeartbeatAgeSeconds: 120, + maxPendingBilling: 0, + maxPendingOutbox: 0, + maxUsageUnavailableRate: 0, + maxLegacyRouteCalls: 0, + maxLegacySqlCalls: 0, + }, + disposition: { + mode: input.mode, + retentionRequired: input.mode === "deterministic-import", + legacyTreeIds: "all", + adrId: `local-adr-${runId}`, + approvalId: input.approvalId, + approvedBy: "local-data-rehearsal", + approvedAt: new Date(now).toISOString(), + reason: "本地临时数据库 cutover 演练", + exclusions: [], + }, + backups: { + legacy: backup(input.legacyBackupId, input.legacyBackupSha, "legacy"), + canonical: backup( + input.canonicalBackupId, + input.canonicalBackupSha, + "canonical" + ), + }, + goNoGo: { + strictSpecsPassed: true, + behaviorMatrixPassed: true, + securityReviewPassed: true, + dataAuditResolved: true, + drainReady: true, + backupRestorePassed: true, + rollbackRehearsalPassed: true, + canaryActorsReady: true, + dashboardsReady: true, + finalApprovalGranted: true, + }, + } +} + async function resetCounts(url) { const sql = postgres(url, { max: 1, prepare: false }) try { @@ -221,6 +312,19 @@ try { approvedRepairs: ["missing-generation-intent-as-send"], } await writeFile(approvalPath, `${JSON.stringify(approval, null, 2)}\n`) + const importManifest = releaseManifest({ + databaseName: legacyRestoreName, + mode: "deterministic-import", + approvalId, + legacyBackupId: approval.backupId, + legacyBackupSha: legacyDumpHash, + canonicalBackupId: approval.backupId, + canonicalBackupSha: legacyDumpHash, + }) + await writeFile( + importManifestPath, + `${JSON.stringify(importManifest, null, 2)}\n` + ) const env = cutoverEnv(databaseUrl(legacyRestoreName), approvalId) run( @@ -238,6 +342,8 @@ try { "--execute", "--approval-file", approvalPath, + "--manifest-file", + importManifestPath, ], env ) @@ -251,6 +357,8 @@ try { "--execute", "--approval-file", approvalPath, + "--manifest-file", + importManifestPath, ], env ) @@ -313,6 +421,19 @@ try { resetApprovalPath, `${JSON.stringify(resetApproval, null, 2)}\n` ) + const resetManifest = releaseManifest({ + databaseName: canonicalRestoreName, + mode: "approved-reset", + approvalId: resetApprovalId, + legacyBackupId: `sha256:${legacyDumpHash}`, + legacyBackupSha: legacyDumpHash, + canonicalBackupId: resetBackupId, + canonicalBackupSha: canonicalDumpHash, + }) + await writeFile( + resetManifestPath, + `${JSON.stringify(resetManifest, null, 2)}\n` + ) await writeFile( backupVerificationPath, `${JSON.stringify(backupVerification, null, 2)}\n` @@ -328,6 +449,8 @@ try { resetApprovalPath, "--backup-verification-file", backupVerificationPath, + "--manifest-file", + resetManifestPath, ], cutoverEnv(databaseUrl(canonicalRestoreName), resetApprovalId, { CONVERSATION_CUTOVER_ENVIRONMENT: resetApproval.environment, @@ -390,7 +513,9 @@ try { rm(legacyDump, { force: true }), rm(canonicalDump, { force: true }), rm(approvalPath, { force: true }), + rm(importManifestPath, { force: true }), rm(resetApprovalPath, { force: true }), rm(backupVerificationPath, { force: true }), + rm(resetManifestPath, { force: true }), ]) } diff --git a/scripts/reset-approved-conversations.ts b/scripts/reset-approved-conversations.ts index 876d0750..502e5bfe 100644 --- a/scripts/reset-approved-conversations.ts +++ b/scripts/reset-approved-conversations.ts @@ -11,6 +11,11 @@ import { } from "../lib/thread-chat/cutover/approved-conversation-reset.ts" import { resolveConversationAuthority } from "../lib/thread-chat/cutover/conversation-authority.ts" import { evaluateConversationCutoverDrain } from "../lib/thread-chat/cutover/conversation-drain.ts" +import { + assertConversationCutoverManifestDisposition, + assertConversationCutoverManifestReady, + conversationCutoverManifestSchema, +} from "../lib/thread-chat/cutover/conversation-cutover-manifest.ts" config({ path: ".env.local" }) @@ -28,6 +33,7 @@ function flagValue(name: string): string { const approvalPath = flagValue("--approval-file") const backupVerificationPath = flagValue("--backup-verification-file") +const manifestPath = flagValue("--manifest-file") const rawUrl = process.env.DIRECT_URL || process.env.DATABASE_URL if (!rawUrl) throw new Error("未配置 DIRECT_URL 或 DATABASE_URL") const databaseUrl = rawUrl.trim().replace(/^(['"])(.*)\1$/u, "$2") @@ -201,12 +207,28 @@ async function deleteApprovedScope( try { const { approval, backup } = await readApprovalFiles() + const manifest = conversationCutoverManifestSchema.parse( + JSON.parse(await readFile(manifestPath, "utf8")) + ) const authority = resolveConversationAuthority() if ( authority.authority !== "legacy" || authority.maintenanceMode !== "read-only" ) throw new Error("受批准重置只允许在 legacy + read-only 维护窗口执行") + assertConversationCutoverManifestReady({ + manifest, + environment: process.env.CONVERSATION_CUTOVER_ENVIRONMENT ?? "", + databaseHost: parsedDatabaseUrl.hostname, + databaseName: parsedDatabaseUrl.pathname.slice(1), + }) + assertConversationCutoverManifestDisposition({ + manifest, + mode: "approved-reset", + approvalId: approval.approvalId, + backupId: approval.backupId, + legacyTreeIds: approval.scope.legacyTreeIds, + }) class RollbackProbe extends Error {} let output: unknown From 22ae14fcd80066c472c32e8172e4d5a008c9b8a4 Mon Sep 17 00:00:00 2001 From: zilin <276161014@qq.com> Date: Mon, 24 Aug 2026 00:15:50 +0800 Subject: [PATCH 19/32] feat(cutover): retain verified local backups --- .gitignore | 4 + package.json | 1 + scripts/backup-conversation-cutover-local.mjs | 148 ++++++++++++++++++ 3 files changed, 153 insertions(+) create mode 100644 scripts/backup-conversation-cutover-local.mjs diff --git a/.gitignore b/.gitignore index 42b6aca8..5777816a 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,10 @@ pnpm-debug.log* next-env.d.ts .gstack/ +# local cutover evidence and database dumps (never commit) +/.local-backups/ +/.local-cutover/ + # thread-chat e2e 验收脚本的截图输出 e2e/thread-chat/shots/ .vercel diff --git a/package.json b/package.json index f01011cf..e2464ae2 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "test:conversation-authority": "tsx --test lib/thread-chat/cutover/conversation-authority.test.ts lib/thread-chat/cutover/conversation-drain.test.ts lib/thread-chat/cutover/legacy-conversation-import.test.ts lib/thread-chat/cutover/approved-conversation-reset.test.ts lib/thread-chat/cutover/conversation-forward-recovery.test.ts lib/thread-chat/cutover/conversation-cutover-manifest.test.ts", "check:conversation-cutover-drain": "tsx scripts/check-conversation-cutover-drain.ts", "rehearse:conversation-cutover-local": "node scripts/rehearse-conversation-cutover-local.mjs", + "backup:conversation-cutover-local": "node scripts/backup-conversation-cutover-local.mjs", "seed:conversation-browser": "tsx scripts/seed-canonical-browser-fixture.ts", "test:conversation-persistence": "tsx scripts/test-conversation-persistence.ts", "test:legacy-conversation-audit": "node --experimental-strip-types --test lib/thread-chat/legacy/audit-thread-tree.test.ts", diff --git a/scripts/backup-conversation-cutover-local.mjs b/scripts/backup-conversation-cutover-local.mjs new file mode 100644 index 00000000..904247b9 --- /dev/null +++ b/scripts/backup-conversation-cutover-local.mjs @@ -0,0 +1,148 @@ +import { spawnSync } from "node:child_process" +import { createHash, randomUUID } from "node:crypto" +import { mkdir, readFile, writeFile } from "node:fs/promises" +import { resolve } from "node:path" + +import { config } from "dotenv" +import postgres from "postgres" + +config({ path: ".env.local" }) + +if (!process.argv.includes("--execute")) + throw new Error("创建并验证本地备份必须显式传入 --execute") + +const rawUrl = process.env.DIRECT_URL || process.env.DATABASE_URL +if (!rawUrl) throw new Error("未配置 DIRECT_URL 或 DATABASE_URL") +const sourceUrl = new URL(rawUrl.trim().replace(/^(['"])(.*)\1$/u, "$2")) +if (!["localhost", "127.0.0.1", "::1"].includes(sourceUrl.hostname)) + throw new Error("本工具只允许备份 localhost/loopback 数据库") + +const sourceDatabase = sourceUrl.pathname.slice(1) +if (!sourceDatabase) throw new Error("数据库 URL 缺少数据库名") + +const runId = randomUUID().replaceAll("-", "").slice(0, 12) +const restoreDatabase = `issue34_backup_verify_${runId}` +const backupDir = resolve(".local-backups") +const backupBase = `issue34-${sourceDatabase}-${new Date().toISOString().replaceAll(":", "-")}` +const dumpPath = resolve(backupDir, `${backupBase}.dump`) +const verificationPath = resolve(backupDir, `${backupBase}.verification.json`) + +function databaseUrl(name) { + const url = new URL(sourceUrl) + url.pathname = `/${name}` + return url.toString() +} + +function run(command, args) { + const result = spawnSync(command, args, { + cwd: process.cwd(), + env: process.env, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }) + if (result.status !== 0) { + if (result.stdout) process.stdout.write(result.stdout) + if (result.stderr) process.stderr.write(result.stderr) + throw new Error(`${command} 执行失败,退出码 ${result.status}`) + } +} + +async function fingerprint(url) { + const sql = postgres(url, { max: 1, prepare: false }) + try { + const tables = await sql` + SELECT tablename AS name + FROM pg_catalog.pg_tables + WHERE schemaname = 'thread_chat' + ORDER BY tablename + ` + const results = {} + for (const { name } of tables) { + if (!/^[a-z0-9_]+$/u.test(name)) + throw new Error(`拒绝指纹化异常表名:${name}`) + const [row] = await sql.unsafe( + `SELECT count(*)::int AS count, + COALESCE(jsonb_agg(to_jsonb(t) ORDER BY to_jsonb(t)::text), '[]'::jsonb) AS rows + FROM thread_chat."${name}" t` + ) + results[name] = { + count: row.count, + sha256: createHash("sha256") + .update(JSON.stringify(row.rows)) + .digest("hex"), + } + } + return { + sha256: createHash("sha256") + .update(JSON.stringify(results)) + .digest("hex"), + tables: results, + } + } finally { + await sql.end() + } +} + +if (!restoreDatabase.startsWith("issue34_backup_verify_")) + throw new Error("拒绝使用不安全的恢复验证数据库名") + +await mkdir(backupDir, { recursive: true }) +const sourceFingerprint = await fingerprint(sourceUrl.toString()) +const adminUrl = new URL(sourceUrl) +adminUrl.pathname = "/postgres" +const admin = postgres(adminUrl.toString(), { max: 1, prepare: false }) + +try { + await admin.unsafe(`CREATE DATABASE "${restoreDatabase}" TEMPLATE template0`) + run("pg_dump", [ + "--format=custom", + "--no-owner", + "--no-privileges", + `--file=${dumpPath}`, + sourceUrl.toString(), + ]) + const dumpSha256 = createHash("sha256") + .update(await readFile(dumpPath)) + .digest("hex") + run("pg_restore", [ + "--no-owner", + "--no-privileges", + `--dbname=${databaseUrl(restoreDatabase)}`, + dumpPath, + ]) + const restoredFingerprint = await fingerprint(databaseUrl(restoreDatabase)) + if (restoredFingerprint.sha256 !== sourceFingerprint.sha256) + throw new Error("恢复库逐表指纹与源库不一致;备份验证失败") + + const verifiedAt = new Date().toISOString() + const verification = { + schemaVersion: 1, + action: "conversation-backup-verification", + environment: "local-development-cutover", + database: { host: sourceUrl.hostname, name: sourceDatabase }, + backupId: `sha256:${dumpSha256}`, + backupSha256: dumpSha256, + restoreTestId: `local-restore-${runId}`, + verifiedAt, + verifiedBy: "issue-34-local-cutover", + sourceFingerprint: sourceFingerprint.sha256, + restoredFingerprint: restoredFingerprint.sha256, + tableCounts: Object.fromEntries( + Object.entries(sourceFingerprint.tables).map(([name, value]) => [ + name, + value.count, + ]) + ), + dumpPath, + } + await writeFile(verificationPath, `${JSON.stringify(verification, null, 2)}\n`) + console.log(JSON.stringify({ ok: true, verificationPath, ...verification }, null, 2)) +} finally { + await admin` + SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = ${restoreDatabase} AND pid <> pg_backend_pid() + `.catch(() => undefined) + await admin.unsafe(`DROP DATABASE IF EXISTS "${restoreDatabase}"`) + await admin.end() +} From 2c9296439129359f7f63354301a4edf7c351743a Mon Sep 17 00:00:00 2001 From: zilin <276161014@qq.com> Date: Mon, 24 Aug 2026 00:22:44 +0800 Subject: [PATCH 20/32] docs(cutover): record approved local reset --- .../adr-issue-34-local-cutover-disposition.md | 27 ++++-- ...-local-backup-verification-2026-08-24.json | 11 +++ ...e-34-local-cutover-execution-2026-08-24.md | 31 +++++++ ...e-34-local-cutover-release-2026-08-24.json | 83 +++++++++++++++++++ ...ue-34-local-reset-approval-2026-08-24.json | 25 ++++++ .../retire-thread-tree-authority/tasks.md | 30 ++++--- 6 files changed, 189 insertions(+), 18 deletions(-) create mode 100644 docs/architecture/issue-34-local-backup-verification-2026-08-24.json create mode 100644 docs/architecture/issue-34-local-cutover-execution-2026-08-24.md create mode 100644 docs/architecture/issue-34-local-cutover-release-2026-08-24.json create mode 100644 docs/architecture/issue-34-local-reset-approval-2026-08-24.json diff --git a/docs/architecture/adr-issue-34-local-cutover-disposition.md b/docs/architecture/adr-issue-34-local-cutover-disposition.md index 69bbf0a8..ddffe681 100644 --- a/docs/architecture/adr-issue-34-local-cutover-disposition.md +++ b/docs/architecture/adr-issue-34-local-cutover-disposition.md @@ -1,14 +1,16 @@ # ADR:Issue #34 本地切换数据处置 -- 状态:本地演练已接受;生产决策待定 -- 日期:2026-08-22 +- 状态:本地开发库 reset 已批准,等待执行 +- 日期:2026-08-24 - 范围:`localhost/thread-chat` 的 `thread_chat` schema ## 决策 -本地演练采用**确定性导入**,不采用数据重置。原因是只读审计的 19 棵树中没有拒绝级结构、所有权或引用污染;5 条异常都只是早期终态 Generation 缺少来源 intent。 +本地临时恢复演练仍采用**确定性导入**,用于证明导入器可用;当前本地开发库的正式 cutover 改为采用**受批准重置**,不导入 19 棵 legacy Tree。 -缺失 intent 的 5 条记录允许在本地演练中使用 `missing-generation-intent-as-send` 修复:它只把历史执行来源归为最弱的 `send`,不改变 Message 内容、计费终态或 active variant,也不会重新执行模型。正式环境若出现同类记录,仍需在自己的审批文件中逐项批准。 +理由是这 19 棵树均为测试数据,没有数据保留义务。仓库负责人在 2026-08-24 明确批准:完整备份并实际恢复验证后,可以不导入这批数据。重置只覆盖全部 legacy Tree 及由持久映射可证明来源于它们的 canonical 实体;现有独立 canonical 浏览器夹具和 `usage_records` 财务流水不在删除范围内。 + +缺失 intent 的 5 条记录只影响导入路径;本次选择 reset,因此不推断、不修复也不导入这些测试记录。完整 dump 是它们的唯一保留副本。 ## 数据与回滚边界 @@ -22,15 +24,26 @@ flowchart LR F --> G[切换 canonical epoch] ``` -本地自动演练使用 `--test-rollback`:在同一事务中导入 19 个 Conversation、执行 post-import verifier、重复导入验证幂等,再强制回滚。该模式的 `backupId=rollback-test-does-not-commit` 不是生产备份凭证。正式 `--execute` 必须同时满足: +本地自动演练使用 `--test-rollback` 验证导入与 reset 事务。当前本地开发库的 `--execute` 必须同时满足: 1. `legacy + read-only` authority; 2. 审批文件的数据库 host/name 与当前连接一致; 3. 环境中的 approval ID 与审批文件一致; -4. 真实备份 ID、批准人、时间、scope 和允许 repair 均非空; +4. 真实备份 ID、批准人、时间和 scope 均非空; 5. drain 门禁为 0。 -生产环境不能复用本 ADR 的批准身份或测试备份标识,必须在真实审计后另建 ADR。 +本 ADR 只批准 `localhost/thread-chat`。任何共享、预发布或生产数据库都不能复用其批准身份、manifest 或备份标识,必须在对应环境重新审计并另建 ADR。 + +## 本地开发库执行输入 + +- 处置:`approved-reset` +- scope:全部 19 棵 legacy Tree +- 保留义务:无(测试数据) +- 备份:`sha256:5278aafc7f9d4bf01607133c09dfff7a9846f9543a68d7ee482097337f2cf078` +- 恢复验证:源库与恢复库 28 张表的合并指纹均为 `a02bf8458579a046856caa4fe0797525086eb97f698bf7e01a2f9bae9d9d081c` +- 版本化 manifest:`issue-34-local-cutover-release-2026-08-24.json` +- 审批合同:`issue-34-local-reset-approval-2026-08-24.json` +- 备份合同:`issue-34-local-backup-verification-2026-08-24.json` ## 临时数据库备份恢复演练(2026-08-22) diff --git a/docs/architecture/issue-34-local-backup-verification-2026-08-24.json b/docs/architecture/issue-34-local-backup-verification-2026-08-24.json new file mode 100644 index 00000000..b1606449 --- /dev/null +++ b/docs/architecture/issue-34-local-backup-verification-2026-08-24.json @@ -0,0 +1,11 @@ +{ + "schemaVersion": 1, + "action": "conversation-backup-verification", + "environment": "local-development-cutover", + "database": { "host": "localhost", "name": "thread-chat" }, + "backupId": "sha256:5278aafc7f9d4bf01607133c09dfff7a9846f9543a68d7ee482097337f2cf078", + "backupSha256": "5278aafc7f9d4bf01607133c09dfff7a9846f9543a68d7ee482097337f2cf078", + "restoreTestId": "local-restore-f6e6289ecb5b", + "verifiedAt": "2026-08-23T16:15:32.639Z", + "verifiedBy": "issue-34-local-cutover" +} diff --git a/docs/architecture/issue-34-local-cutover-execution-2026-08-24.md b/docs/architecture/issue-34-local-cutover-execution-2026-08-24.md new file mode 100644 index 00000000..4374001a --- /dev/null +++ b/docs/architecture/issue-34-local-cutover-execution-2026-08-24.md @@ -0,0 +1,31 @@ +# Issue #34 本地 cutover 执行记录 + +## 结论 + +2026-08-24,`localhost/thread-chat` 按版本化 manifest `issue-34-local-cutover-release-2026-08-24.json` 执行 `approved-reset`。19 棵 legacy Tree 是无保留义务测试数据,未导入;完整备份是这些记录的唯一保留副本。 + +## 前置审计 + +- legacy:19 Tree、22 Thread、34 Turn、71 Message、3 Fork、37 Generation、1 feedback、0 Artifact。 +- 数据问题:4 棵 Tree 的 5 条历史 Generation 缺少 intent;无悬空、跨所有者、重复 ID、错误 active path 或拒绝级记录。 +- drain:legacy/canonical active Generation、pending billing 和 canonical pending outbox 五项均为 0。 +- canonical:1 Conversation、3 Thread、13 Message。 +- `usage_records`:39 条;reset scope 没有 canonical mapping,因此没有账单流水进入删除集合。 + +## 备份与恢复 + +- dump ID:`sha256:5278aafc7f9d4bf01607133c09dfff7a9846f9543a68d7ee482097337f2cf078` +- 恢复验证 ID:`local-restore-f6e6289ecb5b` +- 源/恢复 28 表指纹:`a02bf8458579a046856caa4fe0797525086eb97f698bf7e01a2f9bae9d9d081c` +- dump 位于忽略目录 `.local-backups/`,不会进入 Git;完成本 Goal 前必须保留。 + +## 执行 + +1. manifest execution gate 通过,SHA-256:`1921e4ae0b8f06c624fb85f92b4e610fb69114df3a447b8371bdbe60f1380182`。 +2. `--test-rollback` 精确命中 19 Tree、37 Generation、1 feedback,`committed=false`。 +3. 相同 manifest/approval/backup 合同执行 `--execute`,`committed=true`。 +4. post-reset 审计:legacy 0/0/0;canonical 1 Conversation/3 Thread/13 Message;`usage_records` 39。 + +## 尚未完成 + +数据 reset 不等于迁移完成。仍须让客户端、API、Generation 与 billing 只引用 canonical repositories,完成 Ego Browser canary 后再物理删除三张空 legacy 表。 diff --git a/docs/architecture/issue-34-local-cutover-release-2026-08-24.json b/docs/architecture/issue-34-local-cutover-release-2026-08-24.json new file mode 100644 index 00000000..a14afdc9 --- /dev/null +++ b/docs/architecture/issue-34-local-cutover-release-2026-08-24.json @@ -0,0 +1,83 @@ +{ + "schemaVersion": 1, + "action": "conversation-cutover-release", + "environment": "local-development-cutover", + "database": { "host": "localhost", "name": "thread-chat" }, + "authority": { + "from": "legacy", + "to": "canonical", + "epoch": "local-issue34-20260824" + }, + "owners": { + "release": "codex-executor", + "data": "repository-owner", + "billing": "repository-owner", + "incident": "codex-executor" + }, + "windows": { + "maintenanceStartsAt": "2026-08-23T16:17:00.000Z", + "maintenanceEndsAt": "2026-08-23T17:17:00.000Z", + "observationEndsAt": "2026-08-24T17:17:00.000Z" + }, + "baseline": { + "capturedAt": "2026-08-23T16:14:28.136Z", + "legacyTrees": 19, + "legacyGenerations": 37, + "canonicalConversations": 1, + "requestsPerMinute": 0, + "commandErrorRate": 0, + "revisionConflictRate": 0, + "usageUnavailableRate": 0 + }, + "thresholds": { + "maxMaintenanceSeconds": 3600, + "maxImportSeconds": 600, + "maxCommandErrorRate": 0, + "maxRevisionConflictRate": 0, + "maxGenerationHeartbeatAgeSeconds": 120, + "maxPendingBilling": 0, + "maxPendingOutbox": 0, + "maxUsageUnavailableRate": 0, + "maxLegacyRouteCalls": 0, + "maxLegacySqlCalls": 0 + }, + "disposition": { + "mode": "approved-reset", + "retentionRequired": false, + "legacyTreeIds": "all", + "adrId": "adr-issue-34-local-cutover-disposition", + "approvalId": "issue-34-local-reset-2026-08-24", + "approvedBy": "repository-owner", + "approvedAt": "2026-08-23T16:16:00.000Z", + "reason": "19 棵 legacy Tree 均为本地测试数据,完整备份并恢复验证后无需导入", + "exclusions": ["usage_records 财务流水不随会话测试数据重置"] + }, + "backups": { + "legacy": { + "id": "sha256:5278aafc7f9d4bf01607133c09dfff7a9846f9543a68d7ee482097337f2cf078", + "sha256": "5278aafc7f9d4bf01607133c09dfff7a9846f9543a68d7ee482097337f2cf078", + "restoreTestId": "local-restore-f6e6289ecb5b", + "verifiedAt": "2026-08-23T16:15:32.639Z", + "verifiedBy": "issue-34-local-cutover" + }, + "canonical": { + "id": "sha256:5278aafc7f9d4bf01607133c09dfff7a9846f9543a68d7ee482097337f2cf078", + "sha256": "5278aafc7f9d4bf01607133c09dfff7a9846f9543a68d7ee482097337f2cf078", + "restoreTestId": "local-restore-f6e6289ecb5b", + "verifiedAt": "2026-08-23T16:15:32.639Z", + "verifiedBy": "issue-34-local-cutover" + } + }, + "goNoGo": { + "strictSpecsPassed": true, + "behaviorMatrixPassed": true, + "securityReviewPassed": true, + "dataAuditResolved": true, + "drainReady": true, + "backupRestorePassed": true, + "rollbackRehearsalPassed": true, + "canaryActorsReady": true, + "dashboardsReady": true, + "finalApprovalGranted": true + } +} diff --git a/docs/architecture/issue-34-local-reset-approval-2026-08-24.json b/docs/architecture/issue-34-local-reset-approval-2026-08-24.json new file mode 100644 index 00000000..9ffa324e --- /dev/null +++ b/docs/architecture/issue-34-local-reset-approval-2026-08-24.json @@ -0,0 +1,25 @@ +{ + "schemaVersion": 1, + "action": "approved-conversation-reset", + "environment": "local-development-cutover", + "database": { "host": "localhost", "name": "thread-chat" }, + "scope": { "legacyTreeIds": "all" }, + "expected": { + "legacyTrees": 19, + "legacyGenerations": 37, + "legacyFeedback": 1, + "mappings": 0, + "canonicalConversations": 0, + "canonicalGenerations": 0, + "canonicalFeedback": 0, + "canonicalArtifacts": 0, + "commandRecords": 0, + "outboxEvents": 0, + "preservedUsageRecords": 0 + }, + "backupId": "sha256:5278aafc7f9d4bf01607133c09dfff7a9846f9543a68d7ee482097337f2cf078", + "approvalId": "issue-34-local-reset-2026-08-24", + "approvedBy": "repository-owner", + "approvedAt": "2026-08-23T16:16:00.000Z", + "reason": "19 棵 legacy Tree 均为无保留义务的本地测试数据;完整备份恢复验证后批准不导入并重置" +} diff --git a/openspec/changes/retire-thread-tree-authority/tasks.md b/openspec/changes/retire-thread-tree-authority/tasks.md index f15978f5..9d760f56 100644 --- a/openspec/changes/retire-thread-tree-authority/tasks.md +++ b/openspec/changes/retire-thread-tree-authority/tasks.md @@ -2,22 +2,23 @@ - [x] 1.1 验证 `define-conversation-domain-model`、规范持久化、Generation lifecycle、命令 API 和 normalized client 的必需任务及 strict specs 全部完成。 - [x] 1.2 建立目标行为矩阵,关联认证、生命周期、列/画布、A → B → C Fork、变体、actions、Artifact/研究、Generation/计费的自动测试和 smoke。 -- [ ] 1.3 记录当前容量/性能/错误/计费基线、cutover 阈值、负责人、观察窗口和 go/no-go checklist。 -- [ ] 1.4 演练 canonical 备份恢复、维护模式、authority epoch mismatch 和切换后只读/前滚回滚流程。 +- [x] 1.3 记录当前容量/性能/错误/计费基线、cutover 阈值、负责人、观察窗口和 go/no-go checklist。 +- [x] 1.4 演练 canonical 备份恢复、维护模式、authority epoch mismatch 和切换后只读/前滚回滚流程。 ## 2. 数据审计与处置 ADR -- [ ] 2.1 在目标环境运行只读 legacy 审计,输出所有者、Conversation/Thread/Message/Generation、标题、反馈、Artifact 和 usage 数量。 -- [ ] 2.2 逐项处理或明确阻塞悬空、跨所有者、重复 ID、错误 Fork/active path 和计费引用。 -- [ ] 2.3 基于保留义务形成“确定性导入或受批准重置”ADR,记录范围、理由、批准人、备份和排除项。 -- [ ] 2.4 将 ADR 结果转成版本化 cutover 配置,不在代码中隐式猜测环境或数据处置方式。 +- [x] 2.1 在目标环境运行只读 legacy 审计,输出所有者、Conversation/Thread/Message/Generation、标题、反馈、Artifact 和 usage 数量。 +- [x] 2.2 逐项处理或明确阻塞悬空、跨所有者、重复 ID、错误 Fork/active path 和计费引用。 +- [x] 2.3 基于保留义务形成“确定性导入或受批准重置”ADR,记录范围、理由、批准人、备份和排除项。 +- [x] 2.4 将 ADR 结果转成版本化 cutover 配置,不在代码中隐式猜测环境或数据处置方式。 -### 第 1.3/2.4 阶段版本化输入合同(不等同真实发布记录) +### 第 1.3/2.4 阶段版本化输入与本地发布记录 - 新增严格 cutover manifest schema 与执行校验 CLI,覆盖环境/数据库、四类负责人、维护/观察窗口、容量/错误/计费基线、十项阈值、import/reset ADR、双备份恢复证明、epoch 和十项 go/no-go 门禁。 - 有保留义务时 schema 强制确定性导入;执行时环境、数据库、时间窗口与所有门禁必须精确匹配,并输出 manifest SHA-256,禁止从代码或环境隐式猜测数据处置。 - 正式 import/reset 写工具已强制接收 manifest,并二次核对 ADR mode、approval ID、对应备份 ID 与 scope;本地临时数据库演练覆盖两次幂等 import 和 reset 强制回滚,证明无法绕过 manifest 单独执行审批文件。 -- 1.3 与 2.4 保持未完成:尚未填入真实目标环境基线、负责人、阈值、窗口和已批准 ADR,也未把真实 manifest 纳入发布证据。 +- 目标明确为 `localhost/thread-chat` 本地开发库后,`issue-34-local-cutover-release-2026-08-24.json` 固化了 19/37/1 legacy 基线、1 个 canonical Conversation、零 drain/错误阈值、双负责人、本地维护/观察窗口和十项 go/no-go;manifest SHA-256 为 `1921e4ae0b8f06c624fb85f92b4e610fb69114df3a447b8371bdbe60f1380182`。 +- 仓库负责人明确批准这 19 棵 Tree 为无保留义务测试数据;ADR 选择 `approved-reset`,执行合同精确绑定本机 host/name、全部 Tree scope、备份 hash 和恢复验证 ID。任何其他环境不得复用。 ## 3. 确定性导入或重置工具 @@ -63,12 +64,19 @@ ## 5. Cutover 演练与正式执行 -- [ ] 5.1 在与生产等价的隔离环境完整演练冻结、drain、备份、import/reset、验证、authority/client 切换和 smoke,并记录耗时。 -- [ ] 5.2 正式进入维护窗口,冻结旧写入并确认所有 Generation、checkpoint、outbox 和计费事务已收敛。 -- [ ] 5.3 创建并验证 legacy/canonical 备份,执行 ADR 选定的数据动作和全量 post-import verifier。 +- [x] 5.1 在与生产等价的隔离环境完整演练冻结、drain、备份、import/reset、验证、authority/client 切换和 smoke,并记录耗时。 +- [x] 5.2 正式进入维护窗口,冻结旧写入并确认所有 Generation、checkpoint、outbox 和计费事务已收敛。 +- [x] 5.3 创建并验证 legacy/canonical 备份,执行 ADR 选定的数据动作和全量 post-import verifier。 - [ ] 5.4 原子启用 canonical server/client epoch,对内部 canary 跑行为矩阵关键 smoke 后开放流量。 - [ ] 5.5 验证 cutover 后所有读取、命令、Generation 和 billing jobs 只访问 canonical repositories。 +### 第 5.1–5.3 阶段本地目标环境执行(2026-08-24) + +- 本 change 的目标环境由仓库负责人明确为本地开发库;临时恢复库演练覆盖完整 import/reset 两条路径,真实本地 cutover 选择 reset,19 棵测试 Tree 不导入。 +- `pnpm backup:conversation-cutover-local -- --execute` 保留完整 459 KiB custom dump;恢复到受限临时库后,28 张表的源/恢复合并指纹均为 `a02bf8458579a046856caa4fe0797525086eb97f698bf7e01a2f9bae9d9d081c`。dump SHA-256 为 `5278aafc7f9d4bf01607133c09dfff7a9846f9543a68d7ee482097337f2cf078`,完成前不得删除。 +- 维护窗口内 drain 五项均为 0;同一 reset 事务先以 `--test-rollback` 验证 19/37/1 精确 scope 且不提交,再以 `--execute` 提交。post-reset 审计为 legacy 0/0/0,canonical 仍为 1 Conversation/3 Thread/13 Message,`usage_records` 仍为 39。 +- 5.4/5.5 保持未完成,等待 canonical-only 运行时代码、浏览器 canary 与代码/SQL 零引用门禁。 + ## 6. 观察与回滚保护 - [ ] 6.1 建立 authority mismatch、命令错误、revision/idempotency 冲突、Generation age/checkpoint、Stop/stale 和 usage 对账 dashboard/告警。 From 4662984e1a761e43475a4c4413183d955557ff9c Mon Sep 17 00:00:00 2001 From: zilin <276161014@qq.com> Date: Mon, 24 Aug 2026 01:24:17 +0800 Subject: [PATCH 21/32] feat(conversation): retire ThreadTree runtime --- .env.example | 10 +- CLAUDE.md | 8 +- .../[generationId]/route.ts | 36 - .../[generationId]/stop/route.ts | 41 -- .../[treeId]/active-leaf/route.ts | 75 --- .../messages/[messageId]/feedback/route.ts | 56 -- app/api/branch-trees/[treeId]/route.ts | 276 -------- app/api/branch-trees/route.ts | 30 - app/api/chat/generation-settlement.ts | 123 ---- app/api/chat/generation-start-error.ts | 34 - app/api/chat/request-context.ts | 58 +- app/api/chat/route.ts | 190 ++---- app/api/chat/stream-lifecycle.ts | 109 +-- app/api/chat/thread-generation-context.ts | 146 ---- app/api/conversations/bootstrap/route.ts | 60 ++ app/start-chat/page.tsx | 8 +- app/thread-chat/[conversationId]/page.tsx | 31 + app/thread-chat/[treeId]/page.tsx | 33 - .../assistant/anchored-assistant-body.tsx | 38 -- .../branching/assistant/anchored-markdown.tsx | 163 ----- .../assistant/web-research-placement.ts | 10 - app/thread-chat/branching/branchable-chat.tsx | 274 -------- .../branching/selection/bubble-position.ts | 193 ------ .../branching/selection/bubble-shape.tsx | 151 ----- .../branching/selection/selection-bubble.tsx | 316 --------- .../selection-composer-dimensions.ts | 2 - .../selection/selection-placement-map.tsx | 98 --- .../branching/selection/text-anchor.ts | 534 --------------- .../selection/use-assistant-text-selection.ts | 122 ---- .../canonical/canonical-thread-chat.css | 31 + .../canonical/canonical-thread-chat.tsx | 75 ++- .../actions/assistant-message-toolbar.tsx | 99 --- .../chat/actions/message-action-commands.ts | 50 -- .../actions/message-action-presentation.ts | 57 -- .../actions/message-action-session-logic.ts | 41 -- .../chat/actions/message-action-types.ts | 77 --- .../chat/actions/message-toolbar.tsx | 81 --- .../chat/actions/turn-variant-picker.tsx | 54 -- .../chat/actions/use-copy-markdown.ts | 38 -- .../chat/actions/use-message-actions.ts | 103 --- app/thread-chat/chat/chat-view.tsx | 142 ---- .../message/conversation-message-logic.ts | 31 - .../chat/message/conversation-message.tsx | 164 ----- .../chat/message/editable-user-message.tsx | 145 ---- app/thread-chat/chat/message/smooth-text.ts | 149 ----- app/thread-chat/conversation-redirect.tsx | 57 ++ app/thread-chat/core/message-graph.ts | 4 - app/thread-chat/core/regeneration.ts | 4 - app/thread-chat/core/seed.ts | 36 - app/thread-chat/core/selectors.ts | 4 - app/thread-chat/core/store.ts | 421 ------------ app/thread-chat/core/types.ts | 5 - app/thread-chat/core/use-thread-store.ts | 19 - .../generation-reconciliation-logic.ts | 72 -- app/thread-chat/generation/merge-result.ts | 4 - app/thread-chat/generation/project-result.ts | 4 - app/thread-chat/generation/reconcile-turns.ts | 4 - app/thread-chat/generation/types.ts | 4 - .../use-generation-reconciliation.ts | 97 --- app/thread-chat/layout.tsx | 4 +- app/thread-chat/net/boot/thread-chat-boot.ts | 33 - .../net/boot/use-thread-chat-boot.ts | 49 -- app/thread-chat/net/chat-controller.ts | 402 ----------- .../net/commands/chat-generation-command.ts | 78 --- .../net/commands/message-feedback-command.ts | 50 -- .../net/commands/regeneration-command.ts | 169 ----- .../net/commands/stop-generation-command.ts | 35 - .../commands/switch-active-leaf-command.ts | 69 -- app/thread-chat/net/persistence/persist.ts | 335 ---------- .../net/persistence/sanitize-loaded-state.ts | 149 ----- .../net/persistence/save-tree-response.ts | 40 -- .../net/persistence/transient-state.ts | 27 - .../net/persistence/tree-save-gate.ts | 34 - .../net/persistence/use-tree-persistence.ts | 61 -- app/thread-chat/net/prompt/message-context.ts | 4 - .../net/prompt/message-serialization.ts | 4 - app/thread-chat/net/prompt/prompt-pure.ts | 4 - app/thread-chat/net/prompt/prompt.ts | 86 --- .../net/stream/assistant-delta-buffer.ts | 94 --- .../net/stream/assistant-output.ts | 9 - .../net/stream/assistant-stream-runtime.ts | 128 ---- .../net/stream/local-generation-executions.ts | 43 -- app/thread-chat/net/stream/ui-stream.ts | 198 ------ .../net/titles/thread-title-candidate.ts | 59 -- app/thread-chat/net/titles/thread-title.ts | 24 - .../net/titles/use-thread-titles.ts | 64 -- .../artifacts/artifact-drawer.tsx | 174 ----- .../artifacts/markdown-artifact-card.tsx | 101 --- .../artifacts/message-artifacts-logic.ts | 12 - .../artifacts/message-artifacts.tsx | 47 -- .../orchestration/canvas/canvas-actions.ts | 25 - .../canvas/canvas-card-dimensions.ts | 43 -- .../orchestration/canvas/canvas-dimensions.ts | 2 - .../orchestration/canvas/canvas-expand.tsx | 123 ---- .../orchestration/canvas/canvas-layout.ts | 89 --- .../orchestration/canvas/canvas-node.tsx | 113 ---- .../orchestration/canvas/thread-canvas.tsx | 221 ------ .../orchestration/canvas/use-canvas-layout.ts | 338 ---------- .../orchestration/columns/placement.ts | 284 -------- .../orchestration/columns/thread-columns.tsx | 190 ------ .../columns/use-column-resize.ts | 357 ---------- .../orchestration/columns/use-column-slots.ts | 219 ------ .../columns/use-column-viewport.ts | 33 - .../navigation/account-button.tsx | 91 --- .../navigation/switcher-dimensions.ts | 4 - .../navigation/thread-chat-topbar-logic.ts | 9 - .../navigation/thread-chat-topbar.tsx | 174 ----- .../navigation/thread-switcher-panel.tsx | 229 ------- .../navigation/thread-switcher.tsx | 106 --- .../navigation/tree-list-row.tsx | 155 ----- .../orchestration/navigation/tree-list.tsx | 274 -------- .../overlays/dialog-close-to-shell.ts | 22 - .../orchestration/overlays/help-panel.tsx | 122 ---- .../orchestration/overlays/shortcut-hint.tsx | 18 - .../overlays/use-workspace-overlays.ts | 188 ------ .../overlays/web-research-panel.tsx | 101 --- .../overlays/workspace-overlay-logic.ts | 42 -- .../overlays/workspace-toast-logic.ts | 8 - .../overlays/workspace-toast.tsx | 51 -- .../workspace/branch-workspace-actions.ts | 153 ----- .../workspace/ui-state-snapshot.ts | 11 - .../workspace/use-thread-chat-runtime.ts | 99 --- .../workspace/use-thread-chat-workspace.ts | 104 --- .../workspace/use-ui-state-persistence.ts | 31 - app/thread-chat/page-metadata.ts | 2 +- app/thread-chat/page.tsx | 6 +- app/thread-chat/theme.ts | 20 - app/thread-chat/thread-chat-demo.tsx | 406 ----------- app/thread-chat/tree-redirect.tsx | 25 - constants/routes.ts | 7 +- constants/thread-chat.ts | 81 +-- e2e/thread-chat/README.md | 278 +------- .../artifact-card-css-ownership.test.mjs | 39 -- .../artifact-drawer-accessibility.test.mjs | 20 - .../assistant-delta-buffer.test.mjs | 89 --- .../assistant-retry-command.test.mjs | 115 ---- .../assistant-stream-runtime.test.mjs | 126 ---- .../branch-workspace-actions.test.mjs | 91 --- .../canvas-card-dimensions.test.mjs | 46 -- e2e/thread-chat/canvas-dimensions.test.mjs | 31 - .../canvas-expand-ownership.test.mjs | 48 -- e2e/thread-chat/canvas-layout-cache.test.mjs | 51 -- .../chat-generation-command.test.mjs | 147 ---- e2e/thread-chat/chat-request-context.test.mjs | 50 +- .../column-slot-ownership.test.mjs | 45 -- e2e/thread-chat/column-viewport.test.mjs | 50 -- e2e/thread-chat/conversation-message.test.mjs | 63 -- .../dialog-close-to-shell.test.mjs | 72 -- e2e/thread-chat/finalize-with-retry.test.mjs | 56 -- .../generation-actions-db.test.mjs | 241 ------- e2e/thread-chat/generation-db.test.mjs | 469 ------------- .../generation-identity-contract.test.mjs | 31 - .../generation-persistence.test.mjs | 282 -------- .../generation-reconciliation.test.mjs | 147 ---- .../generation-settlement.test.mjs | 150 ----- .../generation-start-error.test.mjs | 39 -- .../local-generation-executions.test.mjs | 76 --- .../markdown-artifact-state.test.mjs | 331 --------- .../message-action-availability.test.mjs | 51 -- ...message-action-contract-ownership.test.mjs | 39 -- .../message-action-presentation.test.mjs | 88 --- .../message-action-session.test.mjs | 32 - .../message-actions-controller.test.mjs | 278 -------- e2e/thread-chat/message-artifacts.test.mjs | 27 - .../message-feedback-command.test.mjs | 81 --- .../message-feedback-contract.test.mjs | 71 -- e2e/thread-chat/message-feedback-db.test.mjs | 184 ----- e2e/thread-chat/message-graph.test.mjs | 350 ---------- .../message-id-order-selector.test.mjs | 69 -- e2e/thread-chat/model-selection.test.mjs | 133 ---- e2e/thread-chat/prompt-budget.test.mjs | 93 --- e2e/thread-chat/reconcile-turns.test.mjs | 266 -------- e2e/thread-chat/regeneration-patch.test.mjs | 182 ----- .../runtime-composition-ownership.test.mjs | 30 - e2e/thread-chat/save-tree-contract.test.mjs | 75 --- .../selection-composer-dimensions.test.mjs | 33 - .../selection-observer-ownership.test.mjs | 26 - ...selection-placement-map-ownership.test.mjs | 23 - .../stop-generation-command.test.mjs | 48 -- e2e/thread-chat/stream-lifecycle.test.mjs | 120 ---- .../switch-active-leaf-command.test.mjs | 86 --- .../switch-active-leaf-contract.test.mjs | 59 -- e2e/thread-chat/switcher-dimensions.test.mjs | 34 - e2e/thread-chat/text-anchor.test.mjs | 105 --- e2e/thread-chat/thread-chat-boot.test.mjs | 32 - e2e/thread-chat/thread-chat-topbar.test.mjs | 41 -- .../thread-generation-context.test.mjs | 170 ----- .../thread-switcher-panel.test.mjs | 93 --- .../thread-title-candidate.test.mjs | 114 ---- .../thread-tree-schema-source.test.mjs | 31 - e2e/thread-chat/tree-deletion-db.test.mjs | 127 ---- e2e/thread-chat/tree-list-db.test.mjs | 87 --- .../tree-list-repository-ownership.test.mjs | 22 - e2e/thread-chat/tree-list-row.test.mjs | 80 --- e2e/thread-chat/tree-ownership-db.test.mjs | 92 --- .../tree-persistence-write.test.mjs | 55 -- e2e/thread-chat/tree-rename-db.test.mjs | 108 --- e2e/thread-chat/tree-revision-db.test.mjs | 197 ------ e2e/thread-chat/tree-save-db.test.mjs | 124 ---- e2e/thread-chat/tree-save-gate.test.mjs | 25 - e2e/thread-chat/ui-state-snapshot.test.mjs | 29 - e2e/thread-chat/user-edit-command.test.mjs | 106 --- .../user-turn-retry-command.test.mjs | 98 --- e2e/thread-chat/verify-bubble-composer.mjs | 631 ------------------ e2e/thread-chat/verify-canvas-chat.mjs | 610 ----------------- e2e/thread-chat/verify-live.mjs | 328 --------- e2e/thread-chat/verify-markdown-artifact.mjs | 362 ---------- e2e/thread-chat/verify-markdown-model.mjs | 87 --- e2e/thread-chat/verify-persist.mjs | 263 -------- .../verify-syntax-highlighting.mjs | 288 -------- e2e/thread-chat/verify-tree-list.mjs | 351 ---------- .../web-research-placement.test.mjs | 16 - e2e/thread-chat/web-research-sources.test.mjs | 60 -- .../workspace-composition-ownership.test.mjs | 35 - e2e/thread-chat/workspace-overlay.test.mjs | 64 -- e2e/thread-chat/workspace-toast.test.mjs | 7 - lib/ai/model-call-logger.ts | 1 - lib/chat/tree-id.ts | 15 - .../execution-state-repository.ts | 65 -- lib/thread-chat-generation/execution.ts | 95 --- .../finalize-with-retry.ts | 38 -- lib/thread-chat-generation/finalize.ts | 148 ---- .../message-feedback-repository.ts | 132 ---- .../query-repository.ts | 68 -- .../stale-generation-repository.ts | 95 --- .../start-generation-repository.ts | 406 ----------- lib/thread-chat-generation/tree-repository.ts | 328 --------- .../compile-thread-chat-messages.ts | 70 -- .../conversation-generation-service.ts | 10 + .../application/merge-generation-result.ts | 134 ---- .../application/project-generation-result.ts | 189 ------ .../application/reconcile-turns.ts | 180 ----- .../serialize-message-for-model.ts | 28 - .../client/conversation-client-contracts.ts | 2 + .../normalized-conversation-store.test.ts | 2 + .../contracts/generation-identity.ts | 17 - lib/thread-chat/contracts/message-feedback.ts | 108 --- lib/thread-chat/contracts/save-tree.ts | 61 -- .../contracts/switch-active-leaf.ts | 68 -- lib/thread-chat/contracts/tree-revision.ts | 6 - .../approved-conversation-reset.test.ts | 105 --- .../cutover/approved-conversation-reset.ts | 110 --- .../cutover/conversation-authority.test.ts | 44 +- .../cutover/conversation-authority.ts | 61 +- .../legacy-conversation-import.test.ts | 183 ----- .../cutover/legacy-conversation-import.ts | 443 ------------ .../domain/conversation-model.test.ts | 260 ++++---- lib/thread-chat/domain/conversation-model.ts | 2 + .../domain/conversation-route-id.ts | 7 + lib/thread-chat/domain/generation.ts | 80 --- lib/thread-chat/domain/message-graph.ts | 434 ------------ lib/thread-chat/domain/regeneration.ts | 140 ---- lib/thread-chat/domain/selectors.ts | 166 ----- lib/thread-chat/domain/types.ts | 141 ---- .../canonical-ai-generation-executor.ts | 312 ++++++++- .../http/conversation-command-composition.ts | 2 +- .../legacy/audit-thread-tree.test.ts | 172 ----- lib/thread-chat/legacy/audit-thread-tree.ts | 470 ------------- lib/thread-chat/legacy/project-thread-tree.ts | 362 ---------- .../canonical-conversation-bootstrap.ts | 79 +++ .../canonical-generation-policy.ts | 11 +- .../canonical-persistence-policy.ts | 9 +- .../conversation-command-policy.ts | 9 +- .../drizzle-conversation-command-store.ts | 2 + ...zzle-conversation-generation-repository.ts | 68 +- .../drizzle-conversation-repository.ts | 2 + package.json | 8 +- scripts/audit-conversation-cutover-health.mjs | 34 +- .../audit-legacy-conversation-deletion.mjs | 196 ------ scripts/audit-legacy-conversations.ts | 132 ---- scripts/audit-thread-tree-boundary.mjs | 38 +- scripts/check-conversation-cutover-drain.ts | 10 +- .../conversation-cutover-local-rollback.json | 17 - scripts/import-legacy-conversations.ts | 451 ------------- .../rehearse-conversation-cutover-local.mjs | 521 --------------- scripts/reset-approved-conversations.ts | 286 -------- scripts/test-conversation-http-api.ts | 111 ++- scripts/thread-tree-legacy-allowlist.mjs | 17 - 278 files changed, 1111 insertions(+), 29608 deletions(-) delete mode 100644 app/api/branch-generations/[generationId]/route.ts delete mode 100644 app/api/branch-generations/[generationId]/stop/route.ts delete mode 100644 app/api/branch-trees/[treeId]/active-leaf/route.ts delete mode 100644 app/api/branch-trees/[treeId]/messages/[messageId]/feedback/route.ts delete mode 100644 app/api/branch-trees/[treeId]/route.ts delete mode 100644 app/api/branch-trees/route.ts delete mode 100644 app/api/chat/generation-settlement.ts delete mode 100644 app/api/chat/generation-start-error.ts delete mode 100644 app/api/chat/thread-generation-context.ts create mode 100644 app/api/conversations/bootstrap/route.ts create mode 100644 app/thread-chat/[conversationId]/page.tsx delete mode 100644 app/thread-chat/[treeId]/page.tsx delete mode 100644 app/thread-chat/branching/assistant/anchored-assistant-body.tsx delete mode 100644 app/thread-chat/branching/assistant/anchored-markdown.tsx delete mode 100644 app/thread-chat/branching/assistant/web-research-placement.ts delete mode 100644 app/thread-chat/branching/branchable-chat.tsx delete mode 100644 app/thread-chat/branching/selection/bubble-position.ts delete mode 100644 app/thread-chat/branching/selection/bubble-shape.tsx delete mode 100644 app/thread-chat/branching/selection/selection-bubble.tsx delete mode 100644 app/thread-chat/branching/selection/selection-composer-dimensions.ts delete mode 100644 app/thread-chat/branching/selection/selection-placement-map.tsx delete mode 100644 app/thread-chat/branching/selection/text-anchor.ts delete mode 100644 app/thread-chat/branching/selection/use-assistant-text-selection.ts delete mode 100644 app/thread-chat/chat/actions/assistant-message-toolbar.tsx delete mode 100644 app/thread-chat/chat/actions/message-action-commands.ts delete mode 100644 app/thread-chat/chat/actions/message-action-presentation.ts delete mode 100644 app/thread-chat/chat/actions/message-action-session-logic.ts delete mode 100644 app/thread-chat/chat/actions/message-action-types.ts delete mode 100644 app/thread-chat/chat/actions/message-toolbar.tsx delete mode 100644 app/thread-chat/chat/actions/turn-variant-picker.tsx delete mode 100644 app/thread-chat/chat/actions/use-copy-markdown.ts delete mode 100644 app/thread-chat/chat/actions/use-message-actions.ts delete mode 100644 app/thread-chat/chat/chat-view.tsx delete mode 100644 app/thread-chat/chat/message/conversation-message-logic.ts delete mode 100644 app/thread-chat/chat/message/conversation-message.tsx delete mode 100644 app/thread-chat/chat/message/editable-user-message.tsx delete mode 100644 app/thread-chat/chat/message/smooth-text.ts create mode 100644 app/thread-chat/conversation-redirect.tsx delete mode 100644 app/thread-chat/core/message-graph.ts delete mode 100644 app/thread-chat/core/regeneration.ts delete mode 100644 app/thread-chat/core/seed.ts delete mode 100644 app/thread-chat/core/selectors.ts delete mode 100644 app/thread-chat/core/store.ts delete mode 100644 app/thread-chat/core/types.ts delete mode 100644 app/thread-chat/core/use-thread-store.ts delete mode 100644 app/thread-chat/generation/generation-reconciliation-logic.ts delete mode 100644 app/thread-chat/generation/merge-result.ts delete mode 100644 app/thread-chat/generation/project-result.ts delete mode 100644 app/thread-chat/generation/reconcile-turns.ts delete mode 100644 app/thread-chat/generation/types.ts delete mode 100644 app/thread-chat/generation/use-generation-reconciliation.ts delete mode 100644 app/thread-chat/net/boot/thread-chat-boot.ts delete mode 100644 app/thread-chat/net/boot/use-thread-chat-boot.ts delete mode 100644 app/thread-chat/net/chat-controller.ts delete mode 100644 app/thread-chat/net/commands/chat-generation-command.ts delete mode 100644 app/thread-chat/net/commands/message-feedback-command.ts delete mode 100644 app/thread-chat/net/commands/regeneration-command.ts delete mode 100644 app/thread-chat/net/commands/stop-generation-command.ts delete mode 100644 app/thread-chat/net/commands/switch-active-leaf-command.ts delete mode 100644 app/thread-chat/net/persistence/persist.ts delete mode 100644 app/thread-chat/net/persistence/sanitize-loaded-state.ts delete mode 100644 app/thread-chat/net/persistence/save-tree-response.ts delete mode 100644 app/thread-chat/net/persistence/transient-state.ts delete mode 100644 app/thread-chat/net/persistence/tree-save-gate.ts delete mode 100644 app/thread-chat/net/persistence/use-tree-persistence.ts delete mode 100644 app/thread-chat/net/prompt/message-context.ts delete mode 100644 app/thread-chat/net/prompt/message-serialization.ts delete mode 100644 app/thread-chat/net/prompt/prompt-pure.ts delete mode 100644 app/thread-chat/net/prompt/prompt.ts delete mode 100644 app/thread-chat/net/stream/assistant-delta-buffer.ts delete mode 100644 app/thread-chat/net/stream/assistant-output.ts delete mode 100644 app/thread-chat/net/stream/assistant-stream-runtime.ts delete mode 100644 app/thread-chat/net/stream/local-generation-executions.ts delete mode 100644 app/thread-chat/net/stream/ui-stream.ts delete mode 100644 app/thread-chat/net/titles/thread-title-candidate.ts delete mode 100644 app/thread-chat/net/titles/thread-title.ts delete mode 100644 app/thread-chat/net/titles/use-thread-titles.ts delete mode 100644 app/thread-chat/orchestration/artifacts/artifact-drawer.tsx delete mode 100644 app/thread-chat/orchestration/artifacts/markdown-artifact-card.tsx delete mode 100644 app/thread-chat/orchestration/artifacts/message-artifacts-logic.ts delete mode 100644 app/thread-chat/orchestration/artifacts/message-artifacts.tsx delete mode 100644 app/thread-chat/orchestration/canvas/canvas-actions.ts delete mode 100644 app/thread-chat/orchestration/canvas/canvas-card-dimensions.ts delete mode 100644 app/thread-chat/orchestration/canvas/canvas-dimensions.ts delete mode 100644 app/thread-chat/orchestration/canvas/canvas-expand.tsx delete mode 100644 app/thread-chat/orchestration/canvas/canvas-layout.ts delete mode 100644 app/thread-chat/orchestration/canvas/canvas-node.tsx delete mode 100644 app/thread-chat/orchestration/canvas/thread-canvas.tsx delete mode 100644 app/thread-chat/orchestration/canvas/use-canvas-layout.ts delete mode 100644 app/thread-chat/orchestration/columns/placement.ts delete mode 100644 app/thread-chat/orchestration/columns/thread-columns.tsx delete mode 100644 app/thread-chat/orchestration/columns/use-column-resize.ts delete mode 100644 app/thread-chat/orchestration/columns/use-column-slots.ts delete mode 100644 app/thread-chat/orchestration/columns/use-column-viewport.ts delete mode 100644 app/thread-chat/orchestration/navigation/account-button.tsx delete mode 100644 app/thread-chat/orchestration/navigation/switcher-dimensions.ts delete mode 100644 app/thread-chat/orchestration/navigation/thread-chat-topbar-logic.ts delete mode 100644 app/thread-chat/orchestration/navigation/thread-chat-topbar.tsx delete mode 100644 app/thread-chat/orchestration/navigation/thread-switcher-panel.tsx delete mode 100644 app/thread-chat/orchestration/navigation/thread-switcher.tsx delete mode 100644 app/thread-chat/orchestration/navigation/tree-list-row.tsx delete mode 100644 app/thread-chat/orchestration/navigation/tree-list.tsx delete mode 100644 app/thread-chat/orchestration/overlays/dialog-close-to-shell.ts delete mode 100644 app/thread-chat/orchestration/overlays/help-panel.tsx delete mode 100644 app/thread-chat/orchestration/overlays/shortcut-hint.tsx delete mode 100644 app/thread-chat/orchestration/overlays/use-workspace-overlays.ts delete mode 100644 app/thread-chat/orchestration/overlays/web-research-panel.tsx delete mode 100644 app/thread-chat/orchestration/overlays/workspace-overlay-logic.ts delete mode 100644 app/thread-chat/orchestration/overlays/workspace-toast-logic.ts delete mode 100644 app/thread-chat/orchestration/overlays/workspace-toast.tsx delete mode 100644 app/thread-chat/orchestration/workspace/branch-workspace-actions.ts delete mode 100644 app/thread-chat/orchestration/workspace/ui-state-snapshot.ts delete mode 100644 app/thread-chat/orchestration/workspace/use-thread-chat-runtime.ts delete mode 100644 app/thread-chat/orchestration/workspace/use-thread-chat-workspace.ts delete mode 100644 app/thread-chat/orchestration/workspace/use-ui-state-persistence.ts delete mode 100644 app/thread-chat/theme.ts delete mode 100644 app/thread-chat/thread-chat-demo.tsx delete mode 100644 app/thread-chat/tree-redirect.tsx delete mode 100644 e2e/thread-chat/artifact-card-css-ownership.test.mjs delete mode 100644 e2e/thread-chat/artifact-drawer-accessibility.test.mjs delete mode 100644 e2e/thread-chat/assistant-delta-buffer.test.mjs delete mode 100644 e2e/thread-chat/assistant-retry-command.test.mjs delete mode 100644 e2e/thread-chat/assistant-stream-runtime.test.mjs delete mode 100644 e2e/thread-chat/branch-workspace-actions.test.mjs delete mode 100644 e2e/thread-chat/canvas-card-dimensions.test.mjs delete mode 100644 e2e/thread-chat/canvas-dimensions.test.mjs delete mode 100644 e2e/thread-chat/canvas-expand-ownership.test.mjs delete mode 100644 e2e/thread-chat/canvas-layout-cache.test.mjs delete mode 100644 e2e/thread-chat/chat-generation-command.test.mjs delete mode 100644 e2e/thread-chat/column-slot-ownership.test.mjs delete mode 100644 e2e/thread-chat/column-viewport.test.mjs delete mode 100644 e2e/thread-chat/conversation-message.test.mjs delete mode 100644 e2e/thread-chat/dialog-close-to-shell.test.mjs delete mode 100644 e2e/thread-chat/finalize-with-retry.test.mjs delete mode 100644 e2e/thread-chat/generation-actions-db.test.mjs delete mode 100644 e2e/thread-chat/generation-db.test.mjs delete mode 100644 e2e/thread-chat/generation-identity-contract.test.mjs delete mode 100644 e2e/thread-chat/generation-persistence.test.mjs delete mode 100644 e2e/thread-chat/generation-reconciliation.test.mjs delete mode 100644 e2e/thread-chat/generation-settlement.test.mjs delete mode 100644 e2e/thread-chat/generation-start-error.test.mjs delete mode 100644 e2e/thread-chat/local-generation-executions.test.mjs delete mode 100644 e2e/thread-chat/markdown-artifact-state.test.mjs delete mode 100644 e2e/thread-chat/message-action-availability.test.mjs delete mode 100644 e2e/thread-chat/message-action-contract-ownership.test.mjs delete mode 100644 e2e/thread-chat/message-action-presentation.test.mjs delete mode 100644 e2e/thread-chat/message-action-session.test.mjs delete mode 100644 e2e/thread-chat/message-actions-controller.test.mjs delete mode 100644 e2e/thread-chat/message-artifacts.test.mjs delete mode 100644 e2e/thread-chat/message-feedback-command.test.mjs delete mode 100644 e2e/thread-chat/message-feedback-contract.test.mjs delete mode 100644 e2e/thread-chat/message-feedback-db.test.mjs delete mode 100644 e2e/thread-chat/message-graph.test.mjs delete mode 100644 e2e/thread-chat/message-id-order-selector.test.mjs delete mode 100644 e2e/thread-chat/model-selection.test.mjs delete mode 100644 e2e/thread-chat/prompt-budget.test.mjs delete mode 100644 e2e/thread-chat/reconcile-turns.test.mjs delete mode 100644 e2e/thread-chat/regeneration-patch.test.mjs delete mode 100644 e2e/thread-chat/runtime-composition-ownership.test.mjs delete mode 100644 e2e/thread-chat/save-tree-contract.test.mjs delete mode 100644 e2e/thread-chat/selection-composer-dimensions.test.mjs delete mode 100644 e2e/thread-chat/selection-observer-ownership.test.mjs delete mode 100644 e2e/thread-chat/selection-placement-map-ownership.test.mjs delete mode 100644 e2e/thread-chat/stop-generation-command.test.mjs delete mode 100644 e2e/thread-chat/stream-lifecycle.test.mjs delete mode 100644 e2e/thread-chat/switch-active-leaf-command.test.mjs delete mode 100644 e2e/thread-chat/switch-active-leaf-contract.test.mjs delete mode 100644 e2e/thread-chat/switcher-dimensions.test.mjs delete mode 100644 e2e/thread-chat/text-anchor.test.mjs delete mode 100644 e2e/thread-chat/thread-chat-boot.test.mjs delete mode 100644 e2e/thread-chat/thread-chat-topbar.test.mjs delete mode 100644 e2e/thread-chat/thread-generation-context.test.mjs delete mode 100644 e2e/thread-chat/thread-switcher-panel.test.mjs delete mode 100644 e2e/thread-chat/thread-title-candidate.test.mjs delete mode 100644 e2e/thread-chat/thread-tree-schema-source.test.mjs delete mode 100644 e2e/thread-chat/tree-deletion-db.test.mjs delete mode 100644 e2e/thread-chat/tree-list-db.test.mjs delete mode 100644 e2e/thread-chat/tree-list-repository-ownership.test.mjs delete mode 100644 e2e/thread-chat/tree-list-row.test.mjs delete mode 100644 e2e/thread-chat/tree-ownership-db.test.mjs delete mode 100644 e2e/thread-chat/tree-persistence-write.test.mjs delete mode 100644 e2e/thread-chat/tree-rename-db.test.mjs delete mode 100644 e2e/thread-chat/tree-revision-db.test.mjs delete mode 100644 e2e/thread-chat/tree-save-db.test.mjs delete mode 100644 e2e/thread-chat/tree-save-gate.test.mjs delete mode 100644 e2e/thread-chat/ui-state-snapshot.test.mjs delete mode 100644 e2e/thread-chat/user-edit-command.test.mjs delete mode 100644 e2e/thread-chat/user-turn-retry-command.test.mjs delete mode 100644 e2e/thread-chat/verify-bubble-composer.mjs delete mode 100644 e2e/thread-chat/verify-canvas-chat.mjs delete mode 100644 e2e/thread-chat/verify-live.mjs delete mode 100644 e2e/thread-chat/verify-markdown-artifact.mjs delete mode 100644 e2e/thread-chat/verify-markdown-model.mjs delete mode 100644 e2e/thread-chat/verify-persist.mjs delete mode 100644 e2e/thread-chat/verify-syntax-highlighting.mjs delete mode 100644 e2e/thread-chat/verify-tree-list.mjs delete mode 100644 e2e/thread-chat/web-research-placement.test.mjs delete mode 100644 e2e/thread-chat/web-research-sources.test.mjs delete mode 100644 e2e/thread-chat/workspace-composition-ownership.test.mjs delete mode 100644 e2e/thread-chat/workspace-overlay.test.mjs delete mode 100644 e2e/thread-chat/workspace-toast.test.mjs delete mode 100644 lib/chat/tree-id.ts delete mode 100644 lib/thread-chat-generation/execution-state-repository.ts delete mode 100644 lib/thread-chat-generation/execution.ts delete mode 100644 lib/thread-chat-generation/finalize-with-retry.ts delete mode 100644 lib/thread-chat-generation/finalize.ts delete mode 100644 lib/thread-chat-generation/message-feedback-repository.ts delete mode 100644 lib/thread-chat-generation/query-repository.ts delete mode 100644 lib/thread-chat-generation/stale-generation-repository.ts delete mode 100644 lib/thread-chat-generation/start-generation-repository.ts delete mode 100644 lib/thread-chat-generation/tree-repository.ts delete mode 100644 lib/thread-chat/application/compile-thread-chat-messages.ts delete mode 100644 lib/thread-chat/application/merge-generation-result.ts delete mode 100644 lib/thread-chat/application/project-generation-result.ts delete mode 100644 lib/thread-chat/application/reconcile-turns.ts delete mode 100644 lib/thread-chat/application/serialize-message-for-model.ts delete mode 100644 lib/thread-chat/contracts/generation-identity.ts delete mode 100644 lib/thread-chat/contracts/save-tree.ts delete mode 100644 lib/thread-chat/contracts/switch-active-leaf.ts delete mode 100644 lib/thread-chat/contracts/tree-revision.ts delete mode 100644 lib/thread-chat/cutover/approved-conversation-reset.test.ts delete mode 100644 lib/thread-chat/cutover/approved-conversation-reset.ts delete mode 100644 lib/thread-chat/cutover/legacy-conversation-import.test.ts delete mode 100644 lib/thread-chat/cutover/legacy-conversation-import.ts create mode 100644 lib/thread-chat/domain/conversation-route-id.ts delete mode 100644 lib/thread-chat/domain/generation.ts delete mode 100644 lib/thread-chat/domain/message-graph.ts delete mode 100644 lib/thread-chat/domain/regeneration.ts delete mode 100644 lib/thread-chat/domain/selectors.ts delete mode 100644 lib/thread-chat/domain/types.ts delete mode 100644 lib/thread-chat/legacy/audit-thread-tree.test.ts delete mode 100644 lib/thread-chat/legacy/audit-thread-tree.ts delete mode 100644 lib/thread-chat/legacy/project-thread-tree.ts create mode 100644 lib/thread-chat/persistence/canonical-conversation-bootstrap.ts delete mode 100644 scripts/audit-legacy-conversation-deletion.mjs delete mode 100644 scripts/audit-legacy-conversations.ts delete mode 100644 scripts/fixtures/conversation-cutover-local-rollback.json delete mode 100644 scripts/import-legacy-conversations.ts delete mode 100644 scripts/rehearse-conversation-cutover-local.mjs delete mode 100644 scripts/reset-approved-conversations.ts delete mode 100644 scripts/thread-tree-legacy-allowlist.mjs diff --git a/.env.example b/.env.example index e160eb18..1b793580 100644 --- a/.env.example +++ b/.env.example @@ -39,11 +39,11 @@ DIRECT_URL=postgres://postgres.xxxx:password@aws-0-region.pooler.supabase.com:54 # VERCEL_PREVIEW_DATABASE_MIGRATIONS=false # === Issue #34 Conversation 单一权威与切换 === -# 必须显式选择且只能选择一个权威;禁止再拆成 client/API/generation 多个开关。 -CONVERSATION_AUTHORITY=legacy -# canonical 必填。每次正式切换使用新的不可变 epoch,客户端会在加载实体前与服务端核对。 -# CONVERSATION_CUTOVER_EPOCH=2026-08-22-production-v1 -# 受控迁移窗口设为 read-only:拒绝新写入,但允许读取和 Generation Stop/排空。 +# ThreadTree 权威已退役;必须显式使用 canonical,禁止再拆成 client/API/generation 多个开关。 +CONVERSATION_AUTHORITY=canonical +# 每次正式发布使用新的不可变 epoch,客户端会在加载实体前与服务端核对。 +CONVERSATION_CUTOVER_EPOCH=replace-with-release-epoch +# 受控维护窗口可设为 read-only:拒绝新写入,但允许读取和 Generation Stop/排空。 # CONVERSATION_MAINTENANCE_MODE=off # 仅本机/测试可用;生产环境设置 true 会启动失败。 # CONVERSATION_ISOLATED_TEST=false diff --git a/CLAUDE.md b/CLAUDE.md index b0300233..07eae5c9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,7 +42,7 @@ Next.js **16** App Router project (React 19, TypeScript, Tailwind CSS **v4**), s - **shadcn/ui on Base UI, not Radix.** `components.json` uses the `base-rhea` style; primitives in `components/ui/` import from `@base-ui/react` (e.g. `@base-ui/react/button`). Don't reach for `@radix-ui/*` when editing or adding components. - **The full component kit is already vendored** in `components/ui/` (~60 components), including chat-oriented primitives: `message.tsx`, `message-scroller.tsx`, `bubble.tsx`, `attachment.tsx`, `marker.tsx`. Check for an existing component before adding or writing a new one. - **Tailwind v4, CSS-first config.** There is no tailwind.config file; theme tokens live as CSS variables in `app/globals.css`. Class merging goes through `cn()` in `lib/utils.ts`. -- **thread-chat 的手写样式** 是独立于 Tailwind 的一层:全部收敛在 `.tc` 作用域(手工命名空间的手稿风设计系统,语义类名、非原子类)。实体规则按功能区块拆在 `app/thread-chat/styles/*.css`,`app/thread-chat/thread-chat.css` 只是按**源码顺序** `@import` 它们的桶文件——改这里务必保持 `@import` 顺序(级联依赖它),且非相邻功能刻意拆成 `*-collapse/-stream/-extras` 等后缀文件以保序(如流式的 `.send.stop` 覆盖必须在 `composer.css` 之后)。设计 token(`--paper/--ink/--d1..d5/字体/尺寸`)的**单一来源**是 `styles/tokens.css`,`theme.ts` 的深度→变量映射依赖此处变量名。 +- **thread-chat 的手写样式** 是独立于 Tailwind 的一层:全部收敛在 `.tc` 作用域(手工命名空间的手稿风设计系统,语义类名、非原子类)。共享规则按功能区块拆在 `app/thread-chat/styles/*.css`,`app/thread-chat/thread-chat.css` 只是按**源码顺序** `@import` 它们的桶文件;canonical 页面自己的布局扩展位于 `app/thread-chat/canonical/canonical-thread-chat.css`。设计 token(`--paper/--ink/--d1..d5/字体/尺寸`)的单一来源是 `styles/tokens.css`。 - Path aliases: `@/components`, `@/components/ui`, `@/lib`, `@/hooks` (see `components.json` and tsconfig.json). - Theming via `next-themes` through `components/theme-provider.tsx`, wired up in `app/layout.tsx` (dark mode toggles with the `d` key on the starter page). @@ -61,7 +61,7 @@ Key patterns: ## AI backend -`app/api/chat/route.ts` streams from **MiniMax** via `@ai-sdk/openai-compatible` — not real OpenAI. AI SDK is at **v7** (`ai@^7`); note `ai` and `@ai-sdk/react` (`@^4`) track independent version numbers in this ecosystem, they are not in lockstep. Env vars live in `.env.local`: `MINIMAX_API_KEY`, `MINIMAX_BASE_URL`, `LLM_MODEL_ID`. +`app/api/chat/route.ts` 只服务首页 assistant-ui 的线性聊天;它通过统一模型注册表选择 MiniMax、Ark、OpenRouter 等 provider。AI SDK is at **v7** (`ai@^7`); note `ai` and `@ai-sdk/react` (`@^4`) track independent version numbers in this ecosystem, they are not in lockstep. Env vars live in `.env.local`,实际键名由 provider 注册表决定。 MiniMax emits chain-of-thought as literal `...` text rather than a dedicated reasoning stream part. The model is wrapped with `wrapLanguageModel` + `extractReasoningMiddleware({ tagName: "think" })` so it renders as a collapsible reasoning block instead of raw text in the message. @@ -72,7 +72,7 @@ Three tools are wired end-to-end as a reference for adding more: Each tool's custom UI is registered with the `useAssistantTool({ toolName, type, render, ... })` hook from `@assistant-ui/react`, in `components/assistant-ui/{weather,notepad,compare-table}-tool.tsx`. These are null-returning components mounted via `` (`components/assistant-ui/tools.tsx`) inside `AssistantRuntimeProvider` in `app/page.tsx`. `useAssistantTool` is marked `@deprecated` in favor of `defineToolkit`/`Tools({ toolkit })` + `useAui({ tools })`, but that path assumes assistant-ui's "use generative" compiler, which isn't set up in this project — keep using `useAssistantTool` for new tools unless that changes. -`/api/chat` also has a **threadChat mode** for the branch-chat page (`app/thread-chat/`): the client sends the full persisted turn identity (`anchorText`, `treeId`, `threadId`, user/assistant message ids, and an app `generationId`) in the body. The route validates that identity against the owner-scoped saved tree before any paid model call. AI SDK v7's `streamText` rejects system-role messages from the client, so system prompts remain server-owned and are built via `buildThreadChatSystem()` (`lib/chat/thread-chat-prompt.ts`, templates in `constants/thread-chat.ts`). In this mode `getWeather`/`compareTable` are excluded and the only backend artifact tool is `createMarkdownArtifact`. Its bilingual semantic description plus a conservative bilingual first-step intent check route requests to create/deliver a Markdown document; concept questions such as “Markdown 是什么?” remain ordinary text answers. `tool-input-start` creates a non-clickable inline progress card immediately; matching `tool-input-delta` chunks are accumulated by call id and parsed with AI SDK `parsePartialJson` to show partial title, real character/line counts, and recent headings. The validated `tool-input-available` event atomically replaces that transient progress with the persisted Markdown Artifact, rendered in the shared drawer through `MarkdownBody`. Never persist `Message.markdownGeneration` or partial tool input: `saveTree` strips the transient field, while the next prompt reconstructs model context only from completed message-owned Artifact title/content. `hasToolCall(createMarkdownArtifact)` terminates the model loop after the tool step so no redundant recap is generated. Additionally, `toUIMessageStreamResponse({ onError })` logs in-stream errors server-side (`[chat] 流内错误:`) for all modes while still masking them to the client. +`/thread-chat` 不使用 `/api/chat`。它通过 `app/api/conversation-commands/*`、`app/api/conversation-generations/*` 和 `app/api/conversations/*` 操作 canonical `Project → Conversation → Thread → Turn/Message/Generation` 实体;服务端从规范 snapshot 编译模型上下文,客户端只保存可丢弃的列/画布 UI 状态。向 `/api/chat` 发送旧 `threadChat` envelope 会返回 `410 legacy_protocol_retired`。 ## Database & thread persistence @@ -84,4 +84,4 @@ Drizzle ORM + Postgres backs chat history so threads survive page reloads (previ - Client: `lib/db/index.ts` — a global-singleton `postgres`/drizzle client so dev HMR doesn't exhaust Postgres connections. - Migrations: `drizzle.config.ts` + `drizzle/` (see `pnpm db:*` scripts above). - Persistence wiring: `lib/chat/thread-list-adapter.ts` implements assistant-ui's `RemoteThreadListAdapter` (list/rename/archive/delete/initialize/fetch/generateTitle) against `app/api/threads/*` route handlers. `lib/chat/use-thread-history-adapter.ts` implements `ThreadHistoryAdapter.withFormat()` for per-thread message load/append. Both are composed in `app/page.tsx` via `useRemoteThreadListRuntime({ runtimeHook: () => useChatRuntime({ adapters: { history } }), adapter })` — `useChatRuntime`'s own internal remote-thread-list wrapper detects it's nested (`allowNesting: true`) and no-ops, so this composition doesn't conflict with it. -- **Legacy branch-tree persistence(Issue #34 迁移期间的临时权威)** (`app/thread-chat/`): 当前运行时仍由 `branch_trees` 以 **one owner-scoped row of whole-tree JSON** 保存完整 `ThreadTreeState`;这描述的是迁移前事实,不是目标架构。规范术语、所有权与遗留字段映射见 `docs/architecture/conversation-domain-glossary.md`,后续迁移输入见 `docs/architecture/conversation-migration-inputs.md`。在 `retire-thread-tree-authority` 完成前,它仍与 assistant-ui 的 `threads`/`messages` 表完全分开。Tree identity lives in the URL (`/thread-chat/{treeId}`, client-generated UUID); localStorage only remembers the last tree id and per-tree workbench UI. Historical null-owner rows are never listed and may be atomically claimed only by opening their exact URL. `branch_generations` is the authoritative per-attempt sidecar: it stores the verified turn snapshot, lifecycle/heartbeat, versioned structured result, and billing state. The browser must strictly save the user message + assistant placeholder before posting `/api/chat`; refreshing only detaches the browser fetch, while the server-owned stream consumer continues the model, finalizes/charges once, and lets tree GET or generation polling merge the current terminal patch by `generationId` CAS. Only the explicit Stop API requests model abort. P0 restores the completed structured answer after refresh; it deliberately does not replay missed token deltas as a live stream (that is a separate resumable-stream/P1 protocol). Titles remain **dual-track**: debounced PUT writes derived `title`, rename PATCH writes `custom_title`, and display uses `coalesce(custom_title, title)`. Before deploying this contract, run `pnpm db:migrate`; do not deploy the new client before the owner/generation APIs and migration. +- **Canonical Conversation persistence** (`app/thread-chat/`): URL 中的 ID 是 `Conversation.id`,不是 Tree ID。`lib/db/schema.ts` 中的 `conversations`、`conversation_threads`、`thread_forks`、`conversation_turns`、`conversation_messages`、`conversation_generations`、Artifact、command/outbox 与反馈表是唯一事实源;关系由 FK、唯一约束和 revision/idempotency CAS 保护。客户端 normalized store 只投影服务端 snapshot,localStorage 仅保存按 Conversation 分隔且可丢弃的 UI workspace。裸 `/thread-chat` 通过认证后的 canonical bootstrap 找到或建立个人 Project/Conversation。旧 `branch_trees`、`branch_generations` 与 `branch_message_feedback` 已由迁移删除;历史 migration 和已验证备份不改写。变更 schema 使用 `pnpm db:generate` 后运行 `pnpm db:migrate`,不得把交互式 `db:push` 当作发布迁移。 diff --git a/app/api/branch-generations/[generationId]/route.ts b/app/api/branch-generations/[generationId]/route.ts deleted file mode 100644 index e0f7e372..00000000 --- a/app/api/branch-generations/[generationId]/route.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { getCurrentUserId } from "@/lib/auth/server" -import { isValidTreeId } from "@/lib/chat/tree-id" -import { failStaleGenerationForOwner } from "@/lib/thread-chat-generation/stale-generation-repository" -import { toGenerationSummary } from "@/lib/thread-chat-generation/query-repository" -import { legacyProtocolGate } from "@/lib/thread-chat/cutover/conversation-authority" - -type RouteContext = { params: Promise<{ generationId: string }> } - -export async function GET(_req: Request, { params }: RouteContext) { - const userId = await getCurrentUserId() - if (!userId) - return Response.json( - { error: { code: "unauthorized", message: "请先登录" } }, - { status: 401 } - ) - - const { generationId } = await params - if (!isValidTreeId(generationId)) - return Response.json( - { error: { code: "invalid_id", message: "generationId 必须是 UUID" } }, - { status: 400 } - ) - const gate = legacyProtocolGate({ - mutation: false, - protocol: "branch-generation-read", - }) - if (gate) return gate - - const generation = await failStaleGenerationForOwner(userId, generationId) - if (!generation) - return Response.json( - { error: { code: "not_found", message: "generation 不存在" } }, - { status: 404 } - ) - return Response.json({ generation: toGenerationSummary(generation) }) -} diff --git a/app/api/branch-generations/[generationId]/stop/route.ts b/app/api/branch-generations/[generationId]/stop/route.ts deleted file mode 100644 index b0192bae..00000000 --- a/app/api/branch-generations/[generationId]/stop/route.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { getCurrentUserId } from "@/lib/auth/server" -import { isValidTreeId } from "@/lib/chat/tree-id" -import { requestGenerationStop } from "@/lib/thread-chat-generation/execution-state-repository" -import { toGenerationSummary } from "@/lib/thread-chat-generation/query-repository" -import { abortGenerationLocally } from "@/lib/thread-chat-generation/execution" -import { legacyProtocolGate } from "@/lib/thread-chat/cutover/conversation-authority" - -type RouteContext = { params: Promise<{ generationId: string }> } - -export async function POST(_req: Request, { params }: RouteContext) { - const userId = await getCurrentUserId() - if (!userId) - return Response.json( - { error: { code: "unauthorized", message: "请先登录" } }, - { status: 401 } - ) - - const { generationId } = await params - if (!isValidTreeId(generationId)) - return Response.json( - { error: { code: "invalid_id", message: "generationId 必须是 UUID" } }, - { status: 400 } - ) - // Stop 是维护窗口的排空动作:legacy authority 下继续允许;切换后仍稳定返回 410。 - const gate = legacyProtocolGate({ - mutation: false, - protocol: "branch-generation-stop", - }) - if (gate) return gate - - const generation = await requestGenerationStop(userId, generationId) - if (!generation) - return Response.json( - { error: { code: "not_found", message: "generation 不存在" } }, - { status: 404 } - ) - if (generation.status === "stop_requested") { - abortGenerationLocally(generationId) - } - return Response.json({ generation: toGenerationSummary(generation) }) -} diff --git a/app/api/branch-trees/[treeId]/active-leaf/route.ts b/app/api/branch-trees/[treeId]/active-leaf/route.ts deleted file mode 100644 index 459e0f2f..00000000 --- a/app/api/branch-trees/[treeId]/active-leaf/route.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { getCurrentUserId } from "@/lib/auth/server" -import { isValidTreeId } from "@/lib/chat/tree-id" -import { - SWITCH_ACTIVE_LEAF_ERROR_STATUS, - SWITCH_ACTIVE_LEAF_ROUTE_ERRORS, - switchActiveLeafErrorResponseSchema, - switchActiveLeafRequestSchema, - switchActiveLeafSuccessResponseSchema, - type SwitchActiveLeafErrorCode, -} from "@/lib/thread-chat/contracts/switch-active-leaf" -import { - switchActiveLeafForOwner, - TreeCommandError, -} from "@/lib/thread-chat-generation/tree-repository" -import { legacyProtocolGate } from "@/lib/thread-chat/cutover/conversation-authority" - -type RouteContext = { params: Promise<{ treeId: string }> } - -function activeLeafErrorResponse( - code: SwitchActiveLeafErrorCode, - message: string, - currentRevision?: number -) { - return Response.json( - switchActiveLeafErrorResponseSchema.parse({ - error: { - code, - message, - ...(currentRevision !== undefined ? { currentRevision } : {}), - }, - }), - { status: SWITCH_ACTIVE_LEAF_ERROR_STATUS[code] } - ) -} - -export async function PATCH(req: Request, { params }: RouteContext) { - const userId = await getCurrentUserId() - if (!userId) { - const error = SWITCH_ACTIVE_LEAF_ROUTE_ERRORS.unauthorized - return activeLeafErrorResponse(error.code, error.message) - } - - const { treeId } = await params - if (!isValidTreeId(treeId)) { - const error = SWITCH_ACTIVE_LEAF_ROUTE_ERRORS.invalid_id - return activeLeafErrorResponse(error.code, error.message) - } - const gate = legacyProtocolGate({ - mutation: true, - protocol: "branch-tree-active-leaf", - }) - if (gate) return gate - const body = switchActiveLeafRequestSchema.safeParse( - await req.json().catch(() => null) - ) - if (!body.success) { - const error = SWITCH_ACTIVE_LEAF_ROUTE_ERRORS.invalid_request - return activeLeafErrorResponse(error.code, error.message) - } - - try { - return Response.json( - switchActiveLeafSuccessResponseSchema.parse( - await switchActiveLeafForOwner({ userId, treeId, ...body.data }) - ) - ) - } catch (error) { - if (!(error instanceof TreeCommandError)) throw error - return activeLeafErrorResponse( - error.code, - error.message, - error.currentRevision - ) - } -} diff --git a/app/api/branch-trees/[treeId]/messages/[messageId]/feedback/route.ts b/app/api/branch-trees/[treeId]/messages/[messageId]/feedback/route.ts deleted file mode 100644 index 7bbc641e..00000000 --- a/app/api/branch-trees/[treeId]/messages/[messageId]/feedback/route.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { getCurrentUserId } from "@/lib/auth/server" -import { isValidTreeId } from "@/lib/chat/tree-id" -import { - MESSAGE_FEEDBACK_HTTP_ERRORS, - setMessageFeedbackErrorResponseSchema, - setMessageFeedbackRequestSchema, - setMessageFeedbackSuccessResponseSchema, -} from "@/lib/thread-chat/contracts/message-feedback" -import { setMessageFeedbackForOwner } from "@/lib/thread-chat-generation/message-feedback-repository" -import { legacyProtocolGate } from "@/lib/thread-chat/cutover/conversation-authority" - -type RouteContext = { - params: Promise<{ treeId: string; messageId: string }> -} - -function feedbackErrorResponse(key: keyof typeof MESSAGE_FEEDBACK_HTTP_ERRORS) { - const definition = MESSAGE_FEEDBACK_HTTP_ERRORS[key] - return Response.json( - setMessageFeedbackErrorResponseSchema.parse({ error: definition.error }), - { status: definition.status } - ) -} - -export async function PUT(req: Request, { params }: RouteContext) { - const userId = await getCurrentUserId() - if (!userId) return feedbackErrorResponse("unauthorized") - - const { treeId, messageId } = await params - if (!isValidTreeId(treeId) || messageId.trim() === "") - return feedbackErrorResponse("invalid_id") - const gate = legacyProtocolGate({ - mutation: true, - protocol: "branch-message-feedback", - }) - if (gate) return gate - - const body = setMessageFeedbackRequestSchema.safeParse( - await req.json().catch(() => null) - ) - if (!body.success) return feedbackErrorResponse("invalid_feedback") - - const result = await setMessageFeedbackForOwner({ - userId, - treeId, - threadId: body.data.threadId, - messageId, - feedback: body.data.feedback, - }) - if (!result.ok) return feedbackErrorResponse(result.reason) - - return Response.json( - setMessageFeedbackSuccessResponseSchema.parse({ - feedback: result.feedback, - }) - ) -} diff --git a/app/api/branch-trees/[treeId]/route.ts b/app/api/branch-trees/[treeId]/route.ts deleted file mode 100644 index c2548852..00000000 --- a/app/api/branch-trees/[treeId]/route.ts +++ /dev/null @@ -1,276 +0,0 @@ -/** - * /api/branch-trees/[treeId] —— 分支对话树(app/thread-chat)的整树读写。 - * - * 一棵树一行(branch_trees.state = 完整 ThreadTreeState JSON): - * · GET 命中返回 { state, customTitle }(customTitle = 用户重命名过的标题,未改过为 null, - * 供主线列头副标题优先展示);未命中返回 200 + { state: null, customTitle: null }—— - * 首次访问是正常路径不是错误,客户端一个分支判断即可,无需在 fetch 层区分 - * 「404 = 正常」与「404 = 路由不存在」。 - * · PUT { state, title?, baseRevision } 严格校验 schema-v2 消息图,并按 owner/revision - * 做 CAS upsert。只写 state / 派生 title / updatedAt,不触碰 custom_title(双轨标题)。 - * · PATCH { title } 重命名:trim 后非空且 ≤ CUSTOM_TITLE_MAX_LEN,只写 custom_title 列; - * 树不存在 404——与 PUT 的派生轨互不踩踏。 - * · DELETE 删除该行,幂等(不存在也返回 { ok: true })。 - * treeId 做 UUID 形状校验(安全阀),不合法一律 400。 - */ - -import { isValidTreeId } from "@/lib/chat/tree-id" -import { - CUSTOM_TITLE_MAX_LEN, - THREAD_TREE_SCHEMA_VERSION, -} from "@/constants/thread-chat" -import { getCurrentUserId } from "@/lib/auth/server" -import type { ThreadTreeState } from "@/lib/thread-chat/domain/types" -import { parseThreadTreeState } from "@/lib/thread-chat/domain/message-graph" -import { - assertCompletedMessageGenerationLinks, - reconcileThreadChatTurns, -} from "@/lib/thread-chat/application/reconcile-turns" -import { failStaleGenerationsForTree } from "@/lib/thread-chat-generation/stale-generation-repository" -import { - listCurrentGenerationsForTree, - toGenerationSummary, -} from "@/lib/thread-chat-generation/query-repository" -import { listMessageFeedbackForTree } from "@/lib/thread-chat-generation/message-feedback-repository" -import { - deleteOwnedTreeIfIdle, - loadOwnedTree, - loadOwnedOrClaimLegacyTree, - renameOwnedTree, - saveOwnedTree, -} from "@/lib/thread-chat-generation/tree-repository" -import { - SAVE_TREE_ERROR_STATUS, - SAVE_TREE_REVISION_ERRORS, - saveTreeErrorResponseSchema, - saveTreeRequestSchema, - saveTreeSuccessResponseSchema, - type SaveTreeErrorCode, -} from "@/lib/thread-chat/contracts/save-tree" -import { - legacyProtocolGate, - resolveConversationAuthority, -} from "@/lib/thread-chat/cutover/conversation-authority" - -type RouteContext = { params: Promise<{ treeId: string }> } - -function unauthorized() { - return Response.json( - { error: { code: "unauthorized", message: "请先登录" } }, - { status: 401 } - ) -} - -function notFound() { - return Response.json( - { error: { code: "not_found", message: "分支树不存在" } }, - { status: 404 } - ) -} - -function saveTreeErrorResponse( - code: SaveTreeErrorCode, - message: string, - currentRevision?: number -) { - return Response.json( - saveTreeErrorResponseSchema.parse({ - error: { - code, - message, - ...(currentRevision !== undefined ? { currentRevision } : {}), - }, - }), - { status: SAVE_TREE_ERROR_STATUS[code] } - ) -} - -export async function GET(_req: Request, { params }: RouteContext) { - const userId = await getCurrentUserId() - if (!userId) return unauthorized() - const { treeId } = await params - if (!isValidTreeId(treeId)) - return new Response("treeId 必须是 UUID", { status: 400 }) - const gate = legacyProtocolGate({ - mutation: false, - protocol: "branch-tree-read", - }) - if (gate) return gate - - const authority = resolveConversationAuthority() - const row = - authority.maintenanceMode === "read-only" - ? await loadOwnedTree({ userId, treeId }) - : await loadOwnedOrClaimLegacyTree({ userId, treeId }) - if (!row) return notFound() - - // read-only 维护窗口不能借 GET 清理 stale generation,从而偷偷制造新事实。 - if (authority.maintenanceMode === "off") - await failStaleGenerationsForTree(userId, treeId) - const [generations, messageFeedbacks] = await Promise.all([ - listCurrentGenerationsForTree(userId, treeId), - listMessageFeedbackForTree(userId, treeId), - ]) - const generationSummaries = generations.map(toGenerationSummary) - let reconciled - try { - reconciled = reconcileThreadChatTurns({ - state: row.state as ThreadTreeState, - generations: generations.map((generation) => ({ - ...toGenerationSummary(generation), - turnSnapshot: generation.turnSnapshot, - })), - }) - assertCompletedMessageGenerationLinks(reconciled.state, generationSummaries) - } catch (error) { - console.error("[thread-chat] 消息图读取协调失败", { treeId, error }) - return Response.json( - { - error: { - code: "invalid_tree_state", - message: "分支树消息结构或生成关联无效", - }, - }, - { status: 500 } - ) - } - return Response.json({ - state: reconciled.state, - revision: row.revision, - customTitle: row.customTitle, - generations: generationSummaries, - messageFeedbacks, - recoverableTurns: reconciled.recoverableTurns, - }) -} - -export async function PUT(req: Request, { params }: RouteContext) { - const userId = await getCurrentUserId() - if (!userId) return unauthorized() - const { treeId } = await params - if (!isValidTreeId(treeId)) - return new Response("treeId 必须是 UUID", { status: 400 }) - const gate = legacyProtocolGate({ - mutation: true, - protocol: "branch-tree-save", - }) - if (gate) return gate - - let body: { state?: unknown; title?: unknown; baseRevision?: unknown } - try { - body = await req.json() - } catch { - return new Response("body 必须是 JSON", { status: 400 }) - } - const { state } = body - if (typeof state !== "object" || state === null || Array.isArray(state)) - return new Response("state 缺失或不是对象", { status: 400 }) - // threads 必须是普通对象(codex review:数组/标量会让列表接口的 jsonb_object_keys - // 对这一行永久抛错,一行毒数据打挂整个 GET /api/branch-trees) - const threads = (state as Record).threads - if (typeof threads !== "object" || threads === null || Array.isArray(threads)) - return new Response("state.threads 必须是对象", { status: 400 }) - - const title = typeof body.title === "string" ? body.title : null - const incomingSchemaVersion = (state as Record).schemaVersion - if (incomingSchemaVersion !== THREAD_TREE_SCHEMA_VERSION) - return saveTreeErrorResponse( - "invalid_tree_state", - `只接受 schemaVersion=${THREAD_TREE_SCHEMA_VERSION} 的消息图` - ) - const command = saveTreeRequestSchema.safeParse(body) - if (!command.success) { - const error = SAVE_TREE_REVISION_ERRORS.revision_required - return saveTreeErrorResponse(error.code, error.message) - } - - let validatedState: ThreadTreeState - try { - validatedState = parseThreadTreeState(state) - } catch { - return saveTreeErrorResponse( - "invalid_tree_state", - "消息图包含无效的 parent、active leaf 或 Artifact source" - ) - } - const saved = await saveOwnedTree({ - userId, - treeId, - state: validatedState, - title, - baseRevision: command.data.baseRevision, - }) - if (saved.kind === "not_found") return notFound() - if (saved.kind === "conflict") { - const error = SAVE_TREE_REVISION_ERRORS.tree_revision_conflict - return saveTreeErrorResponse(error.code, error.message, saved.revision) - } - return Response.json( - saveTreeSuccessResponseSchema.parse({ - ok: true, - revision: saved.revision, - }) - ) -} - -export async function PATCH(req: Request, { params }: RouteContext) { - const userId = await getCurrentUserId() - if (!userId) return unauthorized() - const { treeId } = await params - if (!isValidTreeId(treeId)) - return new Response("treeId 必须是 UUID", { status: 400 }) - const gate = legacyProtocolGate({ - mutation: true, - protocol: "branch-tree-rename", - }) - if (gate) return gate - - let body: { title?: unknown } - try { - body = await req.json() - } catch { - return new Response("body 必须是 JSON", { status: 400 }) - } - const title = typeof body.title === "string" ? body.title.trim() : "" - if (title === "" || title.length > CUSTOM_TITLE_MAX_LEN) - return new Response( - `title 必须为 trim 后非空且不超过 ${CUSTOM_TITLE_MAX_LEN} 字的字符串`, - { status: 400 } - ) - - // 只写 custom_title(用户意志轨)——防抖 PUT 的派生 title 与之互不踩踏(design D1) - const renamed = await renameOwnedTree({ - userId, - treeId, - customTitle: title, - }) - if (!renamed) return new Response("树不存在", { status: 404 }) - return Response.json({ ok: true }) -} - -export async function DELETE(_req: Request, { params }: RouteContext) { - const userId = await getCurrentUserId() - if (!userId) return unauthorized() - const { treeId } = await params - if (!isValidTreeId(treeId)) - return new Response("treeId 必须是 UUID", { status: 400 }) - const gate = legacyProtocolGate({ - mutation: true, - protocol: "branch-tree-delete", - }) - if (gate) return gate - - const outcome = await deleteOwnedTreeIfIdle({ userId, treeId }) - if (outcome === "generation_running") { - return Response.json( - { - error: { - code: "generation_running", - message: "请先停止正在运行的生成,再删除这棵对话树", - }, - }, - { status: 409 } - ) - } - return Response.json({ ok: true }) -} diff --git a/app/api/branch-trees/route.ts b/app/api/branch-trees/route.ts deleted file mode 100644 index 0ba7953e..00000000 --- a/app/api/branch-trees/route.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * /api/branch-trees —— 分支树的轻量列表(会话列表 UI 的数据源)。 - * - * GET 返回 { trees: [{ id, title, updatedAt, threadCount }] }: - * · title = coalesce(custom_title, title)(双轨标题,design D1),双空回退「未命名对话」; - * · threadCount 在 SQL 内由 state->'threads' 的顶层键数派生(design D2)—— - * 不回传整树 state(可能百 KB 级),列表只要元信息; - * · updated_at 降序,limit 100 兜底(v1 不做分页/搜索)。 - */ - -import { getCurrentUserId } from "@/lib/auth/server" -import { listOwnedTreeSummaries } from "@/lib/thread-chat-generation/tree-repository" -import { legacyProtocolGate } from "@/lib/thread-chat/cutover/conversation-authority" - -export async function GET() { - const userId = await getCurrentUserId() - if (!userId) - return Response.json( - { error: { code: "unauthorized", message: "请先登录" } }, - { status: 401 } - ) - const gate = legacyProtocolGate({ - mutation: false, - protocol: "branch-tree-list", - }) - if (gate) return gate - - const rows = await listOwnedTreeSummaries(userId) - return Response.json({ trees: rows }) -} diff --git a/app/api/chat/generation-settlement.ts b/app/api/chat/generation-settlement.ts deleted file mode 100644 index 82fa3421..00000000 --- a/app/api/chat/generation-settlement.ts +++ /dev/null @@ -1,123 +0,0 @@ -import type { UIMessage } from "ai" -import type { ResearchPlan, ResearchRoute } from "@/lib/chat/research-router" -import type { ThreadChatGenerationIdentity } from "@/lib/thread-chat/contracts/generation-identity" -import { GENERATION_ERRORS } from "@/constants/generation" -import { projectGenerationResult } from "@/lib/thread-chat/application/project-generation-result" -import { finalizeGenerationWithRetry } from "@/lib/thread-chat-generation/finalize-with-retry" -import type { StreamLifecycle } from "@/app/api/chat/stream-lifecycle" - -type SettlementDependencies = { - project: typeof projectGenerationResult - finalize: typeof finalizeGenerationWithRetry -} - -const defaultDependencies: SettlementDependencies = { - project: projectGenerationResult, - finalize: finalizeGenerationWithRetry, -} - -type GenerationSettlementInput = { - persistence: ThreadChatGenerationIdentity - researchRoute: ResearchRoute - researchPlan: ResearchPlan | null - unbilledPreview: boolean - streamLifecycle: Pick -} - -/** 将 UI stream 的结束信号投影并一次性收口到 generation 终态。 */ -export function createGenerationSettlementHandler( - { - persistence, - researchRoute, - researchPlan, - unbilledPreview, - streamLifecycle, - }: GenerationSettlementInput, - dependencies: SettlementDependencies = defaultDependencies -) { - return async ({ - responseMessage, - isAborted, - finishReason, - }: { - responseMessage: Pick - isAborted: boolean - finishReason?: string | null - }) => { - const { capturedUsage, modelStreamError, abortedUsageUnavailable } = - streamLifecycle.snapshot() - const failedWithoutFinish = - finishReason == null && modelStreamError !== undefined - const requestedTerminal = isAborted - ? "stopped" - : failedWithoutFinish - ? "failed" - : "completed" - const projected = dependencies.project({ - generationId: persistence.generationId, - threadId: persistence.threadId, - assistantMessageId: persistence.assistantMessageId, - responseMessage, - terminalStatus: requestedTerminal, - error: modelStreamError, - researchRoute, - researchPlan: researchPlan ?? undefined, - usage: capturedUsage - ? { - inputTokens: capturedUsage.inputTokens, - outputTokens: capturedUsage.outputTokens, - totalTokens: capturedUsage.inputTokens + capturedUsage.outputTokens, - } - : undefined, - }) - const outcome = - requestedTerminal === "completed" && !projected.hasDisplayableOutput - ? "failed" - : requestedTerminal - await dependencies.finalize({ - generationId: persistence.generationId, - outcome, - result: projected.result, - error: projected.result.error ?? modelStreamError, - usage: unbilledPreview ? undefined : capturedUsage, - usageUnavailable: - !unbilledPreview && (abortedUsageUnavailable || !capturedUsage), - }) - } -} - -/** stream 初始化阶段抛错时,尽力保存失败终态;结算失败不覆盖原 HTTP 错误。 */ -export async function settleGenerationInitializationFailure( - { - persistence, - usageUnavailable, - }: { - persistence: ThreadChatGenerationIdentity - error: unknown - usageUnavailable: boolean - }, - dependencies: SettlementDependencies = defaultDependencies -) { - const projected = dependencies.project({ - generationId: persistence.generationId, - threadId: persistence.threadId, - assistantMessageId: persistence.assistantMessageId, - responseMessage: { parts: [] }, - terminalStatus: "failed", - error: GENERATION_ERRORS.streamFailed, - }) - try { - await dependencies.finalize({ - generationId: persistence.generationId, - outcome: "failed", - result: projected.result, - error: projected.result.error, - usageUnavailable, - }) - } catch (finalizeError) { - console.error("[thread-chat-generation] 请求初始化失败后的终态保存失败", { - generationId: persistence.generationId, - finalizeError, - }) - } -} diff --git a/app/api/chat/generation-start-error.ts b/app/api/chat/generation-start-error.ts deleted file mode 100644 index 8de4ec82..00000000 --- a/app/api/chat/generation-start-error.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { GenerationRepositoryError } from "@/lib/thread-chat-generation/start-generation-repository" -import type { MessageActionFailureResponse } from "@/lib/thread-chat/contracts/message-action-failure" - -/** 将 generation start 事务错误映射为稳定的 HTTP 响应。 */ -export function generationStartErrorResponse(error: unknown): Response { - if (error instanceof GenerationRepositoryError) { - return Response.json( - { - error: { - code: error.code, - message: error.message, - }, - } satisfies MessageActionFailureResponse, - { - status: - error.code === "not_found" - ? 404 - : error.code === "persistence_failed" - ? 503 - : 409, - } - ) - } - console.error("[thread-chat-generation] start transaction 失败", error) - return Response.json( - { - error: { - code: "persistence_failed", - message: "无法建立生成任务,尚未调用模型", - }, - } satisfies MessageActionFailureResponse, - { status: 503 } - ) -} diff --git a/app/api/chat/request-context.ts b/app/api/chat/request-context.ts index 1c866dfc..1e9ab99e 100644 --- a/app/api/chat/request-context.ts +++ b/app/api/chat/request-context.ts @@ -6,22 +6,16 @@ import { DEFAULT_MODEL_ID, getChatModel, isLinearChatModelId, - isThreadChatModelId, isUnbilledPreviewModel, } from "@/constants/model" import { isModelConfigured } from "@/lib/ai/provider" import { hasPositiveBalance } from "@/lib/billing/credits" -import { - threadChatGenerationIdentitySchema, - type ThreadChatGenerationIdentity, -} from "@/lib/thread-chat/contracts/generation-identity" -import type { MessageActionFailureResponse } from "@/lib/thread-chat/contracts/message-action-failure" type ChatRequestBody = { messages: UIMessage[] tools?: Record deepResearch?: boolean - /** thread-chat 分支对话页的持久化 generation identity。 */ + /** 仅用于明确拒绝已退役客户端,不再解析其内部结构。 */ threadChat?: unknown modelId?: unknown id?: string @@ -47,7 +41,6 @@ type ChatRequestContextDependencies = { currentUserId: typeof getCurrentUserId getModel: typeof getChatModel linearModelAllowed: typeof isLinearChatModelId - threadModelAllowed: typeof isThreadChatModelId modelConfigured: typeof isModelConfigured unbilledPreview: typeof isUnbilledPreviewModel positiveBalance: typeof hasPositiveBalance @@ -57,7 +50,6 @@ const defaultDependencies: ChatRequestContextDependencies = { currentUserId: getCurrentUserId, getModel: getChatModel, linearModelAllowed: isLinearChatModelId, - threadModelAllowed: isThreadChatModelId, modelConfigured: isModelConfigured, unbilledPreview: isUnbilledPreviewModel, positiveBalance: hasPositiveBalance, @@ -104,6 +96,19 @@ export async function prepareChatRequestContext( messages: validatedMessages.data, tools: envelope.data.tools as Record | undefined, } + if (body.threadChat != null) + return { + kind: "response" as const, + response: Response.json( + { + error: { + code: "legacy_protocol_retired", + message: "旧分叉聊天协议已退役", + }, + }, + { status: 410 } + ), + } const rawModelId = body.modelId if ( rawModelId !== undefined && @@ -117,39 +122,7 @@ export async function prepareChatRequestContext( const modelId = typeof rawModelId === "string" ? rawModelId : DEFAULT_MODEL_ID const model = dependencies.getModel(modelId)! - let threadChat: ThreadChatGenerationIdentity | undefined - if (body.threadChat != null) { - const parsedIdentity = threadChatGenerationIdentitySchema.safeParse( - body.threadChat - ) - if (!parsedIdentity.success) - return { - kind: "response" as const, - response: Response.json( - { - error: { - code: "invalid_generation_identity", - message: "thread-chat 请求缺少有效的持久化身份,请刷新页面后重试", - }, - } satisfies MessageActionFailureResponse, - { status: 400 } - ), - } - if (!dependencies.threadModelAllowed(modelId)) - return { - kind: "response" as const, - response: Response.json( - { - error: { - code: "invalid_thread_model", - message: "Thread Chat 不允许使用该模型,请刷新页面后重试", - }, - } satisfies MessageActionFailureResponse, - { status: 400 } - ), - } - threadChat = parsedIdentity.data - } else if (!dependencies.linearModelAllowed(modelId)) { + if (!dependencies.linearModelAllowed(modelId)) { return { kind: "response" as const, response: Response.json( @@ -187,7 +160,6 @@ export async function prepareChatRequestContext( messages: body.messages, tools: body.tools, deepResearch: body.deepResearch, - threadChat, linearThreadId: body.id, modelId, model, diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 39b035c4..2d62e350 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -1,41 +1,32 @@ import { convertToModelMessages, - consumeStream, createUIMessageStream, createUIMessageStreamResponse, isStepCount, streamText, } from "ai" -import { after } from "next/server" import { frontendTools } from "@assistant-ui/react-ai-sdk" -import { resolveAttachmentParts } from "@/lib/chat/resolve-attachments" -import { isSearchConfigured } from "@/lib/ai/search" -import { RESEARCH_MAX_STEPS } from "@/constants/research" + import { MAX_OUTPUT_TOKENS } from "@/constants/model" import { MODEL_CALL_PURPOSE } from "@/constants/model-call" +import { RESEARCH_MAX_STEPS } from "@/constants/research" +import { isSearchConfigured } from "@/lib/ai/search" import { resolveChatModel } from "@/lib/ai/provider" import { withModelCallLogging, type ModelCallTrace, } from "@/lib/ai/model-call-logger" import { buildUsageMetadata } from "@/lib/billing/usage-meta" -import { isExplicitMarkdownArtifactRequest } from "@/lib/chat/markdown-artifact" +import { resolveAttachmentParts } from "@/lib/chat/resolve-attachments" import { reasoningForResearchRoute } from "@/lib/chat/research-router" -import { unregisterGenerationController } from "@/lib/thread-chat-generation/execution" -import { createToolStepPolicy } from "@/app/api/chat/tool-step-policy" -import { buildChatSystemPrompt } from "@/app/api/chat/system-prompt" -import { resolveResearchContext } from "@/app/api/chat/research-context" -import { buildChatToolSet } from "@/app/api/chat/tool-set" -import { createStreamLifecycle } from "@/app/api/chat/stream-lifecycle" -import { - createGenerationSettlementHandler, - settleGenerationInitializationFailure, -} from "@/app/api/chat/generation-settlement" -import { prepareThreadGenerationContext } from "@/app/api/chat/thread-generation-context" -import { prepareChatRequestContext } from "@/app/api/chat/request-context" -import { legacyProtocolGate } from "@/lib/thread-chat/cutover/conversation-authority" -// AnySearch 搜索与网页深读可能形成多步循环,放宽单次请求时长上限。 +import { prepareChatRequestContext } from "./request-context" +import { resolveResearchContext } from "./research-context" +import { createLinearStreamLifecycle } from "./stream-lifecycle" +import { buildChatSystemPrompt } from "./system-prompt" +import { buildChatToolSet } from "./tool-set" +import { createToolStepPolicy } from "./tool-step-policy" + export const maxDuration = 300 export async function POST(req: Request) { @@ -46,144 +37,85 @@ export async function POST(req: Request) { messages, tools, deepResearch, - threadChat, linearThreadId, modelId, model, isUnbilledPreview, } = requestContext - if (threadChat) { - const gate = legacyProtocolGate({ mutation: true, protocol: "chat-tree" }) - if (gate) return gate - } - - const prepared = await prepareThreadGenerationContext({ - userId, - modelId, - messages, - threadChat, - unbilledPreview: isUnbilledPreview, - }) - if (prepared.kind === "response") return prepared.response - const { - persistence, - authoritativeMessages, - authoritativeAnchorText, - preparedRevision, - generationController, - generationObserver, - } = prepared - try { - // AnySearch 是当前统一联网层:所有模型都获得相同的搜索与网页深读工具。 - // deepResearch 只控制研究提示强度,不再决定工具是否存在。 const research = deepResearch === true const searchReady = isSearchConfigured() - const isThreadChat = persistence != null const chatModel = resolveChatModel(modelId) const modelCallTrace: ModelCallTrace = { requestId: crypto.randomUUID(), - ...(persistence - ? { - treeId: persistence.treeId, - threadId: persistence.threadId, - generationId: persistence.generationId, - assistantMessageId: persistence.assistantMessageId, - } - : linearThreadId - ? { threadId: linearThreadId } - : {}), + ...(linearThreadId ? { threadId: linearThreadId } : {}), } - const { latestText, researchRoute, researchPlan } = - await resolveResearchContext({ - model: chatModel, - messages: authoritativeMessages, - deepResearchRequested: research, - searchReady, - modelCallTrace, - }) - const markdownArtifactRequested = - isThreadChat && isExplicitMarkdownArtifactRequest(latestText) - const { tools: allTools, webToolsEnabled } = buildChatToolSet({ - researchMode: researchRoute.mode, + const { researchRoute, researchPlan } = await resolveResearchContext({ + model: chatModel, + messages, + deepResearchRequested: research, searchReady, - threadChat: isThreadChat, - markdownArtifactRequested, - frontendToolSet: frontendTools(tools ?? {}), + modelCallTrace, }) - - // MiniMax 不接受 file part:先把附件(PDF→提取文本,其余→占位说明)转换为 text part - const resolvedMessages = await resolveAttachmentParts(authoritativeMessages) - - const system = buildChatSystemPrompt({ - threadChat: isThreadChat, - anchorText: authoritativeAnchorText, - markdownArtifactRequested, + const { tools: allTools, webToolsEnabled } = buildChatToolSet({ researchMode: researchRoute.mode, - researchPlan, - deepResearchRequested: research, searchReady, + threadChat: false, + markdownArtifactRequested: false, + frontendToolSet: frontendTools(tools ?? {}), }) - - const streamLifecycle = createStreamLifecycle({ + const resolvedMessages = await resolveAttachmentParts(messages) + const lifecycle = createLinearStreamLifecycle({ userId, modelId, model, - persistentGeneration: isThreadChat, unbilledPreview: isUnbilledPreview, linearThreadId, }) - const result = streamText({ model: withModelCallLogging( chatModel, MODEL_CALL_PURPOSE.chatAnswer, modelCallTrace ), - ...(generationController - ? { abortSignal: generationController.signal } - : {}), reasoning: reasoningForResearchRoute(researchRoute.mode, model), - system, + system: buildChatSystemPrompt({ + threadChat: false, + anchorText: null, + markdownArtifactRequested: false, + researchMode: researchRoute.mode, + researchPlan, + deepResearchRequested: research, + searchReady, + }), messages: await convertToModelMessages(resolvedMessages, { tools: allTools, }), tools: allTools, - // 明确 Markdown 交付请求只强制第 0 步启动工具调用;后续步骤仍保留工具, - // 让模型在用户要求多份独立文档时,为每份文档分别创建一个 Artifact。 prepareStep: createToolStepPolicy({ - isThreadChat, - markdownArtifactRequested, + isThreadChat: false, + markdownArtifactRequested: false, researchMode: researchRoute.mode, }), maxOutputTokens: MAX_OUTPUT_TOKENS, stopWhen: isStepCount(webToolsEnabled ? RESEARCH_MAX_STEPS : 5), - onError: streamLifecycle.onError, - onAbort: streamLifecycle.onAbort, - onEnd: streamLifecycle.onEnd, + onError: lifecycle.onError, + onEnd: lifecycle.onEnd, }) - const uiStream = createUIMessageStream({ - ...(persistence - ? { - originalMessages: resolvedMessages, - generateId: () => persistence.assistantMessageId, - } - : {}), execute: ({ writer }) => { writer.write({ type: "data-research-route", id: "research-route", data: researchRoute, }) - if (researchPlan) { + if (researchPlan) writer.write({ type: "data-research-plan", id: "research-plan", data: researchPlan, }) - } writer.merge( result.toUIMessageStream({ onError: (error) => { @@ -197,55 +129,9 @@ export async function POST(req: Request) { }) ) }, - onEnd: persistence - ? createGenerationSettlementHandler({ - persistence, - researchRoute, - researchPlan, - unbilledPreview: isUnbilledPreview, - streamLifecycle, - }) - : undefined, - }) - - const response = createUIMessageStreamResponse({ - stream: uiStream, - consumeSseStream: ({ stream }) => { - after(async () => { - await consumeStream({ - stream, - onError: (error) => { - console.error("[chat] 服务端 UI stream 消费失败", error) - }, - }) - generationObserver?.stop() - if (generationObserver) await generationObserver.done - if (persistence && generationController) { - unregisterGenerationController( - persistence.generationId, - generationController - ) - } - }) - }, }) - if (preparedRevision !== null) - response.headers.set("x-thread-tree-revision", String(preparedRevision)) - return response + return createUIMessageStreamResponse({ stream: uiStream }) } catch (error) { - generationController?.abort(error) - generationObserver?.stop() - if (persistence && generationController) { - unregisterGenerationController( - persistence.generationId, - generationController - ) - await settleGenerationInitializationFailure({ - persistence, - error, - usageUnavailable: !isUnbilledPreview, - }) - } console.error("[chat] 请求初始化失败", error) return Response.json({ error: "生成初始化失败,请重试。" }, { status: 500 }) } diff --git a/app/api/chat/stream-lifecycle.ts b/app/api/chat/stream-lifecycle.ts index 28d9e87c..fd51896e 100644 --- a/app/api/chat/stream-lifecycle.ts +++ b/app/api/chat/stream-lifecycle.ts @@ -1,82 +1,34 @@ import type { ProviderMetadata } from "ai" + import type { ChatModel } from "@/constants/model" -import { GENERATION_ERRORS } from "@/constants/generation" import { chargeUsage } from "@/lib/billing/credits" import { usageCostEvidence } from "@/lib/billing/usage-cost-evidence" import type { OpenRouterStepLike } from "@/lib/ai/openrouter" -import type { FinalizeGenerationUsage } from "@/lib/thread-chat-generation/finalize" type UsageStep = OpenRouterStepLike & { - usage: { - inputTokens?: number - outputTokens?: number - } -} - -type StreamLifecycleInput = { - userId: string - modelId: string - model: Pick - persistentGeneration: boolean - unbilledPreview: boolean - linearThreadId?: string + usage: { inputTokens?: number; outputTokens?: number } } -type StreamLifecycleDependencies = { - charge: typeof chargeUsage -} +type StreamLifecycleDependencies = { charge: typeof chargeUsage } -const defaultDependencies: StreamLifecycleDependencies = { - charge: chargeUsage, -} +const defaultDependencies: StreamLifecycleDependencies = { charge: chargeUsage } -/** 请求级 stream usage/error 状态;handler 写入,持久化终态只读取 snapshot。 */ -export function createStreamLifecycle( - { - userId, - modelId, - model, - persistentGeneration, - unbilledPreview, - linearThreadId, - }: StreamLifecycleInput, +/** 线性聊天的模型流错误记录与 exactly-once 请求结算。 */ +export function createLinearStreamLifecycle( + input: { + userId: string + modelId: string + model: Pick + unbilledPreview: boolean + linearThreadId?: string + }, dependencies: StreamLifecycleDependencies = defaultDependencies ) { - let capturedUsage: FinalizeGenerationUsage | undefined - let modelStreamError: string | undefined - let abortedUsageUnavailable = false - return { onError({ error }: { error: unknown }) { - modelStreamError = GENERATION_ERRORS.streamFailed console.error("[chat] 模型流错误:", error) }, - onAbort({ steps }: { steps: readonly UsageStep[] }) { - if (!persistentGeneration) return - const inputTokens = steps.reduce( - (total, step) => total + (step.usage.inputTokens ?? 0), - 0 - ) - const outputTokens = steps.reduce( - (total, step) => total + (step.usage.outputTokens ?? 0), - 0 - ) - const providerMetadata = steps.at(-1)?.providerMetadata - if (steps.length > 0) { - capturedUsage = { - inputTokens, - outputTokens, - costEvidence: usageCostEvidence({ - provider: model.provider, - steps, - providerMetadata, - }), - } - } - abortedUsageUnavailable = true - }, - async onEnd({ usage, providerMetadata, @@ -86,46 +38,27 @@ export function createStreamLifecycle( providerMetadata?: ProviderMetadata steps: readonly UsageStep[] }) { - if (unbilledPreview) return + if (input.unbilledPreview) return const costEvidence = usageCostEvidence({ - provider: model.provider, + provider: input.model.provider, steps, providerMetadata, }) if ( - model.provider === "openrouter" && + input.model.provider === "openrouter" && costEvidence.source !== "openrouter" - ) { + ) console.warn( - `[chat] OpenRouter 成本元数据不完整,使用静态估值:${model.id}` + `[chat] OpenRouter 成本元数据不完整,使用静态估值:${input.model.id}` ) - } - if (persistentGeneration) { - capturedUsage = { - inputTokens: usage.inputTokens ?? 0, - outputTokens: usage.outputTokens ?? 0, - costEvidence, - } - return - } await dependencies.charge({ - userId, - model: modelId, + userId: input.userId, + model: input.modelId, inputTokens: usage.inputTokens ?? 0, outputTokens: usage.outputTokens ?? 0, - threadId: linearThreadId ?? null, + threadId: input.linearThreadId ?? null, costEvidence, }) }, - - snapshot() { - return { - capturedUsage, - modelStreamError, - abortedUsageUnavailable, - } - }, } } - -export type StreamLifecycle = ReturnType diff --git a/app/api/chat/thread-generation-context.ts b/app/api/chat/thread-generation-context.ts deleted file mode 100644 index 88369e4a..00000000 --- a/app/api/chat/thread-generation-context.ts +++ /dev/null @@ -1,146 +0,0 @@ -import type { UIMessage } from "ai" -import { compileThreadChatMessages } from "@/lib/thread-chat/application/compile-thread-chat-messages" -import type { ThreadChatGenerationIdentity } from "@/lib/thread-chat/contracts/generation-identity" -import { - observeGenerationCancellation, - registerGenerationController, - unregisterGenerationController, -} from "@/lib/thread-chat-generation/execution" -import { toGenerationSummary } from "@/lib/thread-chat-generation/query-repository" -import { prepareGeneration } from "@/lib/thread-chat-generation/start-generation-repository" -import { generationStartErrorResponse } from "@/app/api/chat/generation-start-error" -import { settleGenerationInitializationFailure } from "@/app/api/chat/generation-settlement" -import type { MessageActionFailureResponse } from "@/lib/thread-chat/contracts/message-action-failure" - -type ThreadGenerationContextInput = { - userId: string - modelId: string - messages: UIMessage[] - threadChat?: ThreadChatGenerationIdentity - unbilledPreview: boolean -} - -type ThreadGenerationContextDependencies = { - prepare: typeof prepareGeneration - summarize: typeof toGenerationSummary - compile: typeof compileThreadChatMessages - createController(): AbortController - register: typeof registerGenerationController - unregister: typeof unregisterGenerationController - observe: typeof observeGenerationCancellation - startErrorResponse: typeof generationStartErrorResponse - settleInitializationFailure: typeof settleGenerationInitializationFailure -} - -const defaultDependencies: ThreadGenerationContextDependencies = { - prepare: prepareGeneration, - summarize: toGenerationSummary, - compile: compileThreadChatMessages, - createController: () => new AbortController(), - register: registerGenerationController, - unregister: unregisterGenerationController, - observe: observeGenerationCancellation, - startErrorResponse: generationStartErrorResponse, - settleInitializationFailure: settleGenerationInitializationFailure, -} - -/** 校验并准备一次线性或持久化 Thread generation 的权威请求上下文。 */ -export async function prepareThreadGenerationContext( - { - userId, - modelId, - messages, - threadChat, - unbilledPreview, - }: ThreadGenerationContextInput, - dependencies: ThreadGenerationContextDependencies = defaultDependencies -) { - if (threadChat == null) { - return { - kind: "ready" as const, - persistence: null, - authoritativeMessages: messages, - authoritativeAnchorText: null, - preparedRevision: null, - generationController: null, - generationObserver: null, - } - } - - const persistence = threadChat - - let started: Awaited> - try { - started = await dependencies.prepare({ - userId, - modelId, - ...persistence, - }) - } catch (error) { - return { - kind: "response" as const, - response: dependencies.startErrorResponse(error), - } - } - if (!started.created) { - return { - kind: "response" as const, - response: Response.json( - { generation: dependencies.summarize(started.generation) }, - { status: 202 } - ), - } - } - - let generationController: AbortController | null = null - let registered = false - try { - const committedThread = started.state.threads[persistence.threadId] - const authoritativeAnchorText = committedThread?.anchorText?.trim() - ? committedThread.anchorText - : null - const authoritativeMessages = dependencies.compile({ - state: started.state, - threadId: persistence.threadId, - excludeAssistantMessageId: persistence.assistantMessageId, - }) as UIMessage[] - generationController = dependencies.createController() - dependencies.register(persistence.generationId, generationController) - registered = true - const generationObserver = dependencies.observe( - persistence.generationId, - generationController - ) - - return { - kind: "ready" as const, - persistence, - authoritativeMessages, - authoritativeAnchorText, - preparedRevision: started.revision, - generationController, - generationObserver, - } - } catch (error) { - generationController?.abort(error) - if (registered && generationController) - dependencies.unregister(persistence.generationId, generationController) - await dependencies.settleInitializationFailure({ - persistence, - error, - usageUnavailable: !unbilledPreview, - }) - return { - kind: "response" as const, - response: Response.json( - { - error: { - code: "network_error", - message: "生成初始化失败,请重试。", - }, - } satisfies MessageActionFailureResponse, - { status: 500 } - ), - } - } -} diff --git a/app/api/conversations/bootstrap/route.ts b/app/api/conversations/bootstrap/route.ts new file mode 100644 index 00000000..289179a3 --- /dev/null +++ b/app/api/conversations/bootstrap/route.ts @@ -0,0 +1,60 @@ +import { + assertCanonicalMutationAllowed, + authenticatedActor, + commandEnvelope, + queryResponse, + withConversationRoute, +} from "@/lib/thread-chat/http/conversation-command-http" +import { getConversationCommandComposition } from "@/lib/thread-chat/http/conversation-command-composition" +import { + canonicalBootstrapConversationIds, + ensureCanonicalPersonalProject, +} from "@/lib/thread-chat/persistence/canonical-conversation-bootstrap" + +/** 返回最近的 active Conversation;首次进入时经规范 command API 创建。 */ +export async function POST(request: Request) { + return withConversationRoute(async () => { + assertCanonicalMutationAllowed() + const actor = await authenticatedActor() + const project = await ensureCanonicalPersonalProject(actor.userId) + const service = getConversationCommandComposition().service + const existing = await service.listConversations({ + actorUserId: actor.userId, + projectId: project, + }) + const recent = existing.at(-1) + if (recent) + return queryResponse({ + conversationId: recent.id, + created: false, + }) + + const ids = canonicalBootstrapConversationIds(actor.userId) + try { + await service.createConversation( + commandEnvelope({ + request, + actor, + scope: { type: "project", id: project }, + payload: { + ...ids, + title: null, + modelId: "glm-5.3", + }, + }) + ) + } catch (cause) { + // 两个首次请求可同时观察到空列表;唯一键让其中一个获胜,另一个只接受 + // 已经可读的同一 Conversation,不能吞掉其他创建错误。 + const raced = await service.listConversations({ + actorUserId: actor.userId, + projectId: project, + }) + if (!raced.some((item) => item.id === ids.conversationId)) throw cause + } + return queryResponse({ + conversationId: ids.conversationId, + created: true, + }) + }) +} diff --git a/app/start-chat/page.tsx b/app/start-chat/page.tsx index 01a77b0b..e0eaf34b 100644 --- a/app/start-chat/page.tsx +++ b/app/start-chat/page.tsx @@ -1,11 +1,9 @@ -import { randomUUID } from "node:crypto" import type { Metadata } from "next" import { redirect } from "next/navigation" -import { ROUTES, signInWithRedirect, threadTreeRoute } from "@/constants/routes" +import { ROUTES, signInWithRedirect } from "@/constants/routes" import { getSession } from "@/lib/auth/server" -// A fresh tree ID must be generated for every request, never at build time. export const dynamic = "force-dynamic" export const metadata: Metadata = { @@ -16,11 +14,11 @@ export const metadata: Metadata = { }, } -/** Authenticated entry that always opens a new branch-conversation tree. */ +/** 认证入口交给 canonical bootstrap 打开最近 Conversation 或建立首个 Conversation。 */ export default async function StartChatPage(): Promise { const session = await getSession() if (!session) { redirect(signInWithRedirect(ROUTES.startChat)) } - redirect(threadTreeRoute(randomUUID())) + redirect(ROUTES.flagship) } diff --git a/app/thread-chat/[conversationId]/page.tsx b/app/thread-chat/[conversationId]/page.tsx new file mode 100644 index 00000000..df4303d3 --- /dev/null +++ b/app/thread-chat/[conversationId]/page.tsx @@ -0,0 +1,31 @@ +import { notFound } from "next/navigation" +import { isValidConversationRouteId } from "@/lib/thread-chat/domain/conversation-route-id" +import { CanonicalThreadChat } from "../canonical/canonical-thread-chat" +import { threadChatMetadata } from "../page-metadata" +import { resolveConversationAuthority } from "@/lib/thread-chat/cutover/conversation-authority" + +export const metadata = threadChatMetadata + +/** + * URL 参数是规范 Conversation ID。裸入口负责显式 bootstrap,不再把任意新 UUID + * 隐式解释为一棵可写 ThreadTree。 + */ +export default async function ConversationPage({ + params, +}: { + params: Promise<{ conversationId: string }> +}) { + const { conversationId } = await params + if (!isValidConversationRouteId(conversationId)) notFound() + const authority = resolveConversationAuthority() + if (authority.authority !== "canonical") + throw new Error("Thread Chat 只允许 canonical Conversation authority") + return ( + + ) +} diff --git a/app/thread-chat/[treeId]/page.tsx b/app/thread-chat/[treeId]/page.tsx deleted file mode 100644 index 9fff57d5..00000000 --- a/app/thread-chat/[treeId]/page.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { notFound } from "next/navigation" -import { isValidTreeId } from "@/lib/chat/tree-id" -import { ThreadChatDemo } from "../thread-chat-demo" -import { CanonicalThreadChat } from "../canonical/canonical-thread-chat" -import { threadChatMetadata } from "../page-metadata" -import { resolveConversationAuthority } from "@/lib/thread-chat/cutover/conversation-authority" - -export const metadata = threadChatMetadata - -/** - * URL 即树身份:/thread-chat/{treeId} 打开指定的分支树(直访新 UUID = 开新树)。 - * treeId 做 UUID 形状校验(安全阀),不合法 404。key={treeId} 保证切树(如「新对话」 - * 跳转)时 loader/store 整体重挂,不残留上一棵树的内存状态。 - */ -export default async function ThreadChatTreePage({ - params, -}: { - params: Promise<{ treeId: string }> -}) { - const { treeId } = await params - if (!isValidTreeId(treeId)) notFound() - const authority = resolveConversationAuthority() - return authority.authority === "canonical" ? ( - - ) : ( - - ) -} diff --git a/app/thread-chat/branching/assistant/anchored-assistant-body.tsx b/app/thread-chat/branching/assistant/anchored-assistant-body.tsx deleted file mode 100644 index dc41558f..00000000 --- a/app/thread-chat/branching/assistant/anchored-assistant-body.tsx +++ /dev/null @@ -1,38 +0,0 @@ -"use client" - -import type { Message, ThreadTreeState } from "../../core/types" -import { WebResearchPanel } from "../../orchestration/overlays/web-research-panel" -import { AnchoredMarkdown } from "./anchored-markdown" -import { webResearchPlacement } from "./web-research-placement" - -export function AnchoredAssistantBody({ - state, - message, - onOpenThread, -}: { - state: ThreadTreeState - message: Message - onOpenThread: (targetId: string, opts?: { keepSource?: boolean }) => void -}) { - const research = webResearchPlacement(message) - const hasResearch = research.activities.length > 0 - - return ( - - ) : undefined - } - /> - ) -} diff --git a/app/thread-chat/branching/assistant/anchored-markdown.tsx b/app/thread-chat/branching/assistant/anchored-markdown.tsx deleted file mode 100644 index ca36cdce..00000000 --- a/app/thread-chat/branching/assistant/anchored-markdown.tsx +++ /dev/null @@ -1,163 +0,0 @@ -"use client" - -import React, { useCallback, useEffect, useReducer, useRef } from "react" -import type { Message, ThreadTreeState } from "../../core/types" -import { threadTitle } from "../../core/selectors" -import { dc } from "../../theme" -import { MarkdownBody } from "../../chat/message/markdown-body" -import { useSmoothText } from "../../chat/message/smooth-text" -import { - clearHighlights, - locateAnchor, - paintRange, -} from "../selection/text-anchor" - -/** - * Markdown 正文 + 渲染后锚点高亮/脚注。 - * - * 锚点定位发生在 Markdown 渲染后的真实 DOM 上;流式阶段仅呈现平滑文本, - * 内容稳定后才绘制非 React 管理的高亮节点,避免与代码高亮 commit 竞争。 - */ -export function AnchoredMarkdown({ - state, - msg, - onOpenThread, - insertAt, - insert, -}: { - state: ThreadTreeState - msg: Message - onOpenThread: (targetId: string, opts?: { keepSource?: boolean }) => void - /** 在流事件记录的正文字符偏移处插入工具活动;缺省时渲染普通单段 Markdown。 */ - insertAt?: number - insert?: React.ReactNode -}) { - const hostRef = useRef(null) - // forksKey 只随 fork 的增删与编号变化——source 未变、仅新增 fork 时也能触发重绘 - const forksKey = msg.forks - .map((fork) => `${fork.threadId}:${fork.num}`) - .join("|") - const active = msg.status === "streaming" || msg.status === "pending" - const display = useSmoothText(msg.text, active) - const renderedSource = active ? display : msg.text - const [settledRevision, bumpSettledRevision] = useReducer( - (revision: number) => revision + 1, - 0 - ) - const onContentSettled = useCallback(() => { - bumpSettledRevision() - }, []) - - useEffect(() => { - const host = hostRef.current - if (!host) return - const markdownBodies = [...host.querySelectorAll(".md-body")] - if (markdownBodies.length === 0) return - - const wipe = () => { - markdownBodies.forEach((markdownBody) => { - clearHighlights(markdownBody) - markdownBody - .querySelectorAll("sup.fn-mark") - .forEach((node) => node.remove()) - }) - } - wipe() - - // 高亮仍在飞或当前是流式 plaintext 时,绝不手改 React 即将 reconcile 的代码 DOM。 - if ( - active || - markdownBodies.some( - (markdownBody) => markdownBody.dataset.contentSettled !== "true" - ) - ) - return wipe - - for (const fork of msg.forks) { - if (!fork.anchor) continue - let markdownBody: HTMLElement | null = null - let located: ReturnType = null - for (const candidate of markdownBodies) { - located = locateAnchor(candidate, fork.anchor) - if (located) { - markdownBody = candidate - break - } - } - if (!markdownBody || !located) continue - const color = `color-mix(in srgb, var(--d${dc(fork.depth)}) 20%, transparent)` - paintRange(located.range, fork.threadId, color) - - const marks = markdownBody.querySelectorAll( - `[data-text-anchor-mark="${cssEscape(fork.threadId)}"]` - ) - marks.forEach((mark) => { - mark.setAttribute("data-fork-id", fork.threadId) - mark.classList.add("anchored-mark", `fc-${dc(fork.depth)}`) - mark.title = `分支「${threadTitle(state, fork.threadId)}」· 点击打开 · ⌘点击保留本列在右侧打开` - }) - - const last = marks[marks.length - 1] - if (last) { - const footnote = document.createElement("sup") - footnote.className = `fn-mark fc-${dc(fork.depth)}` - footnote.setAttribute("data-fork-id", fork.threadId) - footnote.textContent = String(fork.num) - last.after(footnote) - } - } - - return wipe - // state 仅用于 title 文案,不参与重绘时机;有意省略以免每次 version 变动都重绘 - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [active, msg.text, forksKey, settledRevision]) - - const onClick = (event: React.MouseEvent) => { - const element = (event.target as HTMLElement).closest?.("[data-fork-id]") - if (!element) return - const threadId = element.getAttribute("data-fork-id") - if (!threadId) return - onOpenThread(threadId, { - keepSource: event.metaKey || event.ctrlKey, - }) - } - - const normalizedInsertAt = - insertAt == null ? null : Math.max(0, Math.min(insertAt, msg.text.length)) - const insertIsVisible = - insert != null && - normalizedInsertAt != null && - renderedSource.length >= normalizedInsertAt - const beforeInsert = insertIsVisible - ? renderedSource.slice(0, normalizedInsertAt) - : renderedSource - const afterInsert = insertIsVisible - ? renderedSource.slice(normalizedInsertAt) - : "" - - return ( -
- {beforeInsert ? ( - - ) : null} - {insertIsVisible ? insert : null} - {afterInsert ? ( - - ) : null} -
- ) -} - -function cssEscape(value: string): string { - if (typeof CSS !== "undefined" && typeof CSS.escape === "function") - return CSS.escape(value) - return value.replace(/"/g, '\\"') -} diff --git a/app/thread-chat/branching/assistant/web-research-placement.ts b/app/thread-chat/branching/assistant/web-research-placement.ts deleted file mode 100644 index 461cd551..00000000 --- a/app/thread-chat/branching/assistant/web-research-placement.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { Message } from "../../core/types" - -export function webResearchPlacement(message: Message) { - const activities = message.webResearch ?? [] - return { - activities, - insertAt: - activities.length > 0 ? (message.webResearchTextOffset ?? 0) : undefined, - } -} diff --git a/app/thread-chat/branching/branchable-chat.tsx b/app/thread-chat/branching/branchable-chat.tsx deleted file mode 100644 index 59716f7d..00000000 --- a/app/thread-chat/branching/branchable-chat.tsx +++ /dev/null @@ -1,274 +0,0 @@ -"use client" -/** - * branching/branchable-chat —— 装饰层:把「分支能力」注入单会话 ChatView。 - * - * 组装内容: - * · 列头:面包屑(就地回退)/ L 深度徽章 / 子分支弹层按钮 / ⇄ 切换 / 收起; - * · focus banner(讨论焦点 · 划选自 X)+「继承的上文」折叠区; - * · assistant 正文的锚点高亮 + 脚注上标(点击 = 打开对应分支); - * · 消息下方的 artifact 卡片。 - * 本层只发出意图回调(打开会话 / 回退 / 收起…),列槽的增删换由 orchestration 决定。 - */ - -import React from "react" -import { ListTree } from "lucide-react" -import type { Message, ThreadTreeState } from "../core/types" -import { - activeMessagePath, - collectInherited, - lineage, - messagesByIdOrder, - threadTitle, -} from "../core/selectors" -import { ChatView } from "../chat/chat-view" -import { MessageArtifacts } from "../orchestration/artifacts/message-artifacts" -import { AnchoredAssistantBody } from "./assistant/anchored-assistant-body" -import type { MessageActionViewState } from "../chat/actions/message-action-types" -import type { ThreadMessageActionCommands } from "../chat/actions/message-action-commands" - -export interface BranchableChatProps { - state: ThreadTreeState - threadId: string - /** 主线列的副标题(demo 文案由壳层传入) */ - subtitle?: string - /** 消息列表顶部的插卡(主线 hint) */ - intro?: React.ReactNode - /** 统一意图:打开某会话(本列作为「来源列」参与放置策略)。 - opts.keepSource:⌘/Ctrl 点击 = 保留本列,把目标开在紧邻右侧 */ - onOpenThread: (targetId: string, opts?: { keepSource?: boolean }) => void - onOpenArtifact: (artifactId: string) => void - /** 面包屑就地回退(collapse 语义由 orchestration 实现) */ - onCrumbNav: (targetId: string) => void - /** ⇄ 把本列切换为任意会话(弹出 local 切换器,锚定在按钮上) */ - onOpenSwitcher: (anchor: HTMLElement) => void - /** 查看以本会话为根的子树(弹出 subtree 面板,锚定在按钮上) */ - onOpenSubtree: (anchor: HTMLElement) => void - onCollapse: () => void - /** 流式生成中:透传给 ChatView 禁用发送键 */ - busy?: boolean - /** 错误消息下的「重试」按钮回调,透传给 ChatView */ - onRetry?: (msg: Message) => void - /** busy 时发送键变「停止」的回调,透传给 ChatView */ - onStop?: () => void - /** composer 预填文案(新开分支的代拟首问,待用户回车确认),透传给 ChatView */ - composerPrefill?: string - /** 根 Thread 模型切换意图;分支 selector 仍由本层锁定。 */ - onModelChange: (modelId: string) => void - onSend: (text: string) => void - messageActionState?: MessageActionViewState - messageCommands?: ThreadMessageActionCommands -} - -export function BranchableChat({ - state, - threadId, - subtitle, - intro, - onOpenThread, - onOpenArtifact, - onCrumbNav, - onOpenSwitcher, - onOpenSubtree, - onCollapse, - busy, - onRetry, - onStop, - composerPrefill, - onModelChange, - onSend, - messageActionState, - messageCommands, -}: BranchableChatProps) { - const thread = state.threads[threadId] - if (!thread) return null - const isMain = threadId === "main" - const chain = isMain ? [] : lineage(state, threadId) - const inherited = isMain ? [] : collectInherited(state, thread) - const childCount = thread.children.length - const presentation = messageActionState?.presentationByThreadId.get(threadId) - const sourceProvenance = presentation?.sourceProvenance ?? null - const visibleMessages = messageActionState - ? messagesByIdOrder( - thread.messages, - messageActionState.activePathByThreadId.get(threadId) ?? [] - ) - : activeMessagePath(thread) - - /* ---------- 注入:assistant 正文(Markdown 渲染 + 渲染后手绘锚点高亮/脚注) ---------- */ - const renderAssistantBody = (msg: Message) => { - return ( - - ) - } - - /* ---------- 注入:消息下方的 artifact 卡片 ---------- */ - const renderAfterMessage = (msg: Message) => { - return ( - - ) - } - - /* ---------- 列头(主线 / 分支两种形态,子分支按钮两者都有) ---------- */ - const subtreeBtn = ( - - ) - - const header = ( -
- {/* 列头背景 / 底部分隔线随列通栏,内容收敛在 .lane 阅读通道内(与消息流对齐) */} -
- {isMain ? ( - <> -
- 锚定 - 主线 -
{subtreeBtn}
-
- {subtitle &&
{subtitle}
} - - ) : ( - <> -
- {chain.map((c, i) => { - const here = i === chain.length - 1 - return ( - - onCrumbNav(c.id)} - title={here ? undefined : `回到「${c.title}」`} - > - {c.title} - - {!here && } - - ) - })} -
-
- L{thread.depth} - {thread.title} -
- {subtreeBtn} - - -
-
- - )} -
-
- ) - - /* ---------- focus banner + 继承的上文(仅分支列) ---------- - 父级(列)没有水平 padding,用 .lane.pad 承担 18px 侧距并居中通道 */ - const banner = isMain ? null : ( -
-
- {thread.footnote} -
- - 讨论焦点 · 划选自 - {thread.parentId === "main" - ? "主线" - : `「${threadTitle(state, thread.parentId!)}」`} - - {thread.anchorText} -
- {sourceProvenance && !sourceProvenance.isOnActivePath && ( -
- - 基于回复 - {sourceProvenance.alternativeIndex === null - ? "" - : " " + - (sourceProvenance.alternativeIndex + 1) + - "/" + - sourceProvenance.alternativeCount}{" "} - · 当前未展示 - - -
- )} -
-
- - 继承的上文 · {inherited.length} 条 - -
- {inherited.map((m) => ( -
- {m.role === "user" ? "你" : "AI"} - {m.text.length > 130 ? m.text.slice(0, 130) + "…" : m.text} -
- ))} -
-
-
- ) - - return ( - - ) -} diff --git a/app/thread-chat/branching/selection/bubble-position.ts b/app/thread-chat/branching/selection/bubble-position.ts deleted file mode 100644 index f8d1419d..00000000 --- a/app/thread-chat/branching/selection/bubble-position.ts +++ /dev/null @@ -1,193 +0,0 @@ -/** - * branching/bubble-position —— 划选气泡的落点计算(纯几何、无 DOM 依赖)。 - * - * 移植自 playground.zilin.im 的 floating-popup 定位模型(`app/floating-popup/position.ts`): - * 在 container(这里是浏览器 viewport)内,让 popup 围绕 anchor(选区包围盒)择位。 - * 按 sides 顺序逐个尝试,第一个能「干净放下」的方向即采用;主轴贴 anchor 留 gap, - * 交叉轴对 anchor 居中、越界时沿交叉轴滑动(clamp 进安全区,不算失败); - * popup 永不越出 container 内缩 safePadding 的安全区;四边都放不下时进入 fallback, - * 取遮挡 anchor 面积最小的候选 —— 尽量不糊在用户刚选中的字上。 - * - * 划选气泡只用上下两向(sides: ["bottom","top"],见 selection-bubble.tsx), - * 因为气泡的「平滑曲线小尾巴」是竖直指向的(bubble-shape.ts)。 - */ - -export interface Rect { - left: number - top: number - width: number - height: number -} - -export interface Size { - width: number - height: number -} - -export type Side = "right" | "bottom" | "left" | "top" - -export interface PositionOptions { - /** popup 与 container 四边的最小安全边距,默认 12 */ - safePadding?: number - /** popup 与 anchor 之间的呼吸空间,默认 4 */ - gap?: number - /** 各方向的尝试顺序,默认 ["right", "bottom", "left", "top"] */ - sides?: Side[] -} - -export interface PositionResult { - /** popup 左上角坐标(与 container 同一坐标系) */ - left: number - top: number - /** 实际采用的方向 */ - side: Side - /** true = 四边都放不下,当前是「遮挡最小」的兜底位置 */ - fallback: boolean - /** popup 与 anchor 的重叠面积(px²),0 = 完全没有遮挡 */ - overlapArea: number -} - -export interface SideCandidate { - side: Side - /** clamp 进安全区之后的位置 */ - left: number - top: number - /** 该方向能否「干净」放下(不出安全区、不遮挡 anchor) */ - fits: boolean - overlapArea: number -} - -const DEFAULT_SIDES: Side[] = ["right", "bottom", "left", "top"] - -function clamp(value: number, min: number, max: number): number { - if (max < min) return min // 安全区都装不下 popup 时,贴 min(左/上) - return Math.min(Math.max(value, min), max) -} - -function intersectArea( - left: number, - top: number, - size: Size, - rect: Rect -): number { - const w = - Math.min(left + size.width, rect.left + rect.width) - - Math.max(left, rect.left) - const h = - Math.min(top + size.height, rect.top + rect.height) - - Math.max(top, rect.top) - return Math.max(0, w) * Math.max(0, h) -} - -/** - * 带过程信息的版本:除最终结果外,返回各方向的候选位置与可行性,方便调试 / 可视化。 - * 日常使用直接调 computePopupPosition 即可。 - */ -export function explainPopupPosition( - anchor: Rect, - popup: Size, - container: Rect, - options: PositionOptions = {} -): { result: PositionResult; candidates: SideCandidate[] } { - const safePadding = options.safePadding ?? 12 - const gap = options.gap ?? 4 - const sides = options.sides ?? DEFAULT_SIDES - - // popup 左上角允许出现的范围(container 内缩 safePadding 的安全区) - const minLeft = container.left + safePadding - const minTop = container.top + safePadding - const maxLeft = container.left + container.width - safePadding - popup.width - const maxTop = container.top + container.height - safePadding - popup.height - // popup 整体能否装进安全区(任何方向都以此为前提) - const containable = maxLeft >= minLeft && maxTop >= minTop - - const anchorRight = anchor.left + anchor.width - const anchorBottom = anchor.top + anchor.height - // 交叉轴居中的理想位置 - const centeredLeft = anchor.left + anchor.width / 2 - popup.width / 2 - const centeredTop = anchor.top + anchor.height / 2 - popup.height / 2 - - // 各方向候选:主轴贴 anchor + gap;交叉轴居中后 clamp 进安全区(允许滑动) - const ideal: Record = { - right: { left: anchorRight + gap, top: clamp(centeredTop, minTop, maxTop) }, - bottom: { - left: clamp(centeredLeft, minLeft, maxLeft), - top: anchorBottom + gap, - }, - left: { - left: anchor.left - gap - popup.width, - top: clamp(centeredTop, minTop, maxTop), - }, - top: { - left: clamp(centeredLeft, minLeft, maxLeft), - top: anchor.top - gap - popup.height, - }, - } - - const candidates: SideCandidate[] = sides.map((side) => { - const c = ideal[side] - const fits = - containable && - c.left >= minLeft && - c.left <= maxLeft && - c.top >= minTop && - c.top <= maxTop - // 不可行的方向按 clamp 进安全区后的位置参与兜底比较 - const left = clamp(c.left, minLeft, maxLeft) - const top = clamp(c.top, minTop, maxTop) - return { - side, - left, - top, - fits, - overlapArea: intersectArea(left, top, popup, anchor), - } - }) - - // 1) 有干净放得下的方向 → 按顺序取第一个 - const clean = candidates.find((c) => c.fits) - if (clean) { - return { - result: { - left: clean.left, - top: clean.top, - side: clean.side, - fallback: false, - overlapArea: 0, - }, - candidates, - } - } - - // 2) fallback:取遮挡 anchor 面积最小的候选(并列时尊重 sides 顺序) - let best = candidates[0] - for (const c of candidates) { - if (c.overlapArea < best.overlapArea) best = c - } - return { - result: { - left: best.left, - top: best.top, - side: best.side, - fallback: true, - overlapArea: best.overlapArea, - }, - candidates, - } -} - -/** - * 计算 popup 围绕 anchor 在 container 内的落点。 - * - * @param anchor 选区矩形(选区包围盒),与 container 同坐标系 - * @param popup popup 的尺寸(气泡面板宽高) - * @param container 限定范围,viewport 场景传 {left:0, top:0, width:innerWidth, height:innerHeight} - */ -export function computePopupPosition( - anchor: Rect, - popup: Size, - container: Rect, - options: PositionOptions = {} -): PositionResult { - return explainPopupPosition(anchor, popup, container, options).result -} diff --git a/app/thread-chat/branching/selection/bubble-shape.tsx b/app/thread-chat/branching/selection/bubble-shape.tsx deleted file mode 100644 index 61391f66..00000000 --- a/app/thread-chat/branching/selection/bubble-shape.tsx +++ /dev/null @@ -1,151 +0,0 @@ -"use client" -/** - * branching/bubble-shape —— 划选气泡的「平滑曲线」外轮廓(面板 + 指向选区的小尾巴,一条 path)。 - * - * 移植自 playground.zilin.im 的 smooth-tooltip(`app/smooth-tooltip/page.tsx`): - * 整条轮廓用一条闭合 path 画成 —— 面板四角圆角(Q) · 尾巴根部凹角外扩(C) · 直斜边(L) · - * 顶点圆角(A),单 fill 无拼接缝,取代过去那个 CSS 旋转小方块。 - * - * 与 tooltip 的差异:tooltip 只朝下;气泡要么在选区下方(尾巴朝上 dir="up"), - * 要么在上方(尾巴朝下 dir="down")。canonical path 按「尾巴朝下」构造,dir="up" 时 - * 整条 path 竖直翻转即可(面板落到下半、尾巴翻到上半)。尾巴横向落点 cx 由上层按 - * 选区中心传入(不再永远居中),这样气泡贴边滑动后尾巴仍指着选区。 - */ - -import React, { useId } from "react" -import type { - TailDir, - TailGeo, -} from "@/lib/thread-chat/domain/selection-bubble" - -export type { - TailDir, - TailGeo, -} from "@/lib/thread-chat/domain/selection-bubble" - -interface PathGeo extends TailGeo { - W: number - H: number - cx: number -} - -/** - * canonical 轮廓:面板左上角 (0,0),宽 W、高 H,尾巴朝下、顶点在 (cx, H+ah)。 - * 三段曲线:① 凹·外扩 C(底边水平切线 → 斜边方向切线)② 直斜边 L ③ 凸·圆角 A。 - */ -export function buildBubblePath({ - W, - H, - R, - cx, - aw, - ah, - flare, - tip, -}: PathGeo): string { - const len = Math.hypot(aw, ah) || 1 - // 右侧斜边「下行」单位向量(由根部指向顶点,即向下偏左) - const dlx = -aw / len - const dly = ah / len - - // 顶点圆角:沿斜边从顶点回退的切线长 d = tip / tan(θ) = tip·ah/aw - const d = Math.min((tip * ah) / Math.max(aw, 1e-4), len * 0.6) - // 外扩段沿斜边占用的长度(夹住,别和顶点圆角打架) - const fs = Math.max(0, Math.min(flare, len - d - 2)) - - const apexY = H + ah - // 顶点圆弧的两个切点(分别在左右斜边上) - const Trx = cx + (aw / len) * d, - Try = apexY - (ah / len) * d - const Tlx = cx - (aw / len) * d, - Tly = Try - // 外扩曲线与斜边的衔接点(根部往斜边下移 fs) - const Srx = cx + aw + dlx * fs, - Sry = H + dly * fs - const Slx = cx - aw - dlx * fs, - Sly = Sry - // 外扩曲线在底边上的起点(凹角向外铺开 flare 的宽度) - const Erx = cx + aw + flare, - Elx = cx - aw - flare - - // 控制柄长度 - const c1 = flare * 0.55 // 底边端,水平 - const c2 = fs * 0.55 // 斜边端,沿斜边 - - const f = (n: number) => Number(n.toFixed(2)) - const p = (a: number, b: number) => `${f(a)} ${f(b)}` - - return [ - `M ${p(R, 0)}`, - `L ${p(W - R, 0)}`, - `Q ${p(W, 0)} ${p(W, R)}`, // 右上角 - `L ${p(W, H - R)}`, - `Q ${p(W, H)} ${p(W - R, H)}`, // 右下角 - `L ${p(Erx, H)}`, // 底边·右段 - // ① 右侧外扩:水平切线 → 斜边切线 - `C ${p(Erx - c1, H)} ${p(Srx - dlx * c2, Sry - dly * c2)} ${p(Srx, Sry)}`, - `L ${p(Trx, Try)}`, // ② 右直斜边 - `A ${f(tip)} ${f(tip)} 0 0 1 ${p(Tlx, Tly)}`, // ③ 顶点圆弧(凸) - `L ${p(Slx, Sly)}`, // ② 左直斜边 - // ① 左侧外扩:斜边切线 → 水平切线 - `C ${p(Slx + dlx * c2, Sly - dly * c2)} ${p(Elx + c1, H)} ${p(Elx, H)}`, - `L ${p(R, H)}`, // 底边·左段 - `Q ${p(0, H)} ${p(0, H - R)}`, // 左下角 - `L ${p(0, R)}`, - `Q ${p(0, 0)} ${p(R, 0)}`, // 左上角 - "Z", - ].join(" ") -} - -/** - * 纯形状(svg 背景层)。面板宽 W、高 H,尾巴另占 ah 高。 - * dir="down":面板在上、尾巴朝下(顶点 y=H+ah); - * dir="up" :整条 path 竖直翻转,面板落到下半、尾巴翻到上半(顶点 y=0)。 - * fill 默认 var(--ink),与气泡深底一致;shadow 用 feDropShadow 复刻原气泡阴影。 - */ -export function BubbleShape({ - W, - H, - cx, - geo, - dir, - fill = "var(--ink)", - shadow = true, -}: { - W: number - H: number - cx: number - geo: TailGeo - dir: TailDir - fill?: string - shadow?: boolean -}) { - const id = useId().replace(/:/g, "") - const totalH = H + geo.ah - const dStr = buildBubblePath({ W, H, cx, ...geo }) - // dir="up":竖直翻转(面板到下半、尾巴到上半) - const flip = dir === "up" ? `matrix(1 0 0 -1 0 ${totalH})` : undefined - - return ( - - {shadow && ( - - - - - - )} - - - ) -} diff --git a/app/thread-chat/branching/selection/selection-bubble.tsx b/app/thread-chat/branching/selection/selection-bubble.tsx deleted file mode 100644 index 7bea8836..00000000 --- a/app/thread-chat/branching/selection/selection-bubble.tsx +++ /dev/null @@ -1,316 +0,0 @@ -"use client" -/** - * branching/selection-bubble —— 划选 assistant 消息文字 → 迷你气泡 → 开分支。 - * - * document 级划选监听与命令式 DOM Selection 读取由 - * useAssistantTextSelection 封装;本组件组合气泡状态、定位、放置预览与提交 UI。 - * 气泡的开合状态由上层持有(sel / onSelChange),以便 Esc 逐层关闭链能先关它。 - * - * 放置控制(存在 ≥1 个分支列时显示,见任务「打开到哪一列」): - * · 底部迷你列条按当前列序预览「新分支会放到哪」——将替换(斜纹)/ 将折叠 / 插入 - * 位置的虚线幽灵格,全部来自 placement.previewPlacement(与提交共用同一套规则, - * 预览不撒谎);主线小格锚定不可选; - * · 点非主线小格 = 显式指定让位列(override,再点同格取消); - * · 按住 ⌘/Ctrl = 保留来源列、新列开在其紧邻右侧(气泡实时跟踪修饰键,按钮文案 - * 与列条目标同步切换)。生效目标 = override > 修饰键 > 默认规则。 - * - * 可选输入框(Phase A,openspec: add-bubble-composer): - * · 输入后提交 = 带问开分支:问题经 onFork 第三参传给壳层,fork 后直接 chat.send - * 成为新分支第 1 条 user 消息(不进 composer 预填流); - * · 留空提交 = 现有预填流原样保留(空分支 + composer 预填代拟问题 + 回车确认); - * · 键位:Enter 提交 / Shift+Enter 换行 / ⌘Ctrl+Enter 提交且保留来源列 / - * Esc 交由壳层关闭链关气泡;Enter 有 IME 守卫(isComposing / keyCode 229)。 - */ - -import React, { useEffect, useLayoutEffect, useRef, useState } from "react" -import { GitMerge } from "lucide-react" -import type { ThreadTreeState } from "../../core/types" -import { threadTitle } from "../../core/selectors" -import { - previewPlacement, - type PlacementHint, - type PlacementMode, - type Slot, -} from "../../orchestration/columns/placement" -import { computePopupPosition } from "./bubble-position" -import { BubbleShape, type TailDir } from "./bubble-shape" -import { SELECTION_QUESTION_MAX_HEIGHT } from "./selection-composer-dimensions" -import { - useAssistantTextSelection, - type SelectionInfo, -} from "./use-assistant-text-selection" -import { SelectionPlacementMap } from "./selection-placement-map" -import { - BUBBLE_GAP, - BUBBLE_SAFE_PADDING, - BUBBLE_TAIL, - BUBBLE_TAIL_MARGIN, - BUBBLE_W, -} from "@/constants/selection-bubble" - -export type { SelectionInfo } from "./use-assistant-text-selection" - -export interface SelectionBubbleProps { - state: ThreadTreeState - sel: SelectionInfo | null - onSelChange: (s: SelectionInfo | null) => void - /** 提交开分支:上层负责真正 fork + 放置;hint 见 placement.ts; - question = 气泡输入框里的可选首问(trim 后非空才传,成为新分支第 1 条 user 消息) */ - onFork: (s: SelectionInfo, hint?: PlacementHint, question?: string) => void - /* —— 迷你列条的放置上下文(与提交走同一套 placement 规则)—— */ - slots: Slot[] - mode: PlacementMode - maxExpanded: number - lastActiveOf: (id: string) => number -} - -export function SelectionBubble({ - state, - sel, - onSelChange, - onFork, - slots, - mode, - maxExpanded, - lastActiveOf, -}: SelectionBubbleProps) { - useAssistantTextSelection({ - state, - selection: sel, - onSelectionChange: onSelChange, - }) - /** 迷你列条点选的让位列(override);气泡隐藏 / 换一段划选时清空 */ - const [override, setOverride] = useState(null) - /** ⌘/Ctrl 是否按住(实时跟踪,目标与按钮文案随之切换) */ - const [metaHeld, setMetaHeld] = useState(false) - /** 可选首问(受控 textarea):留空提交 = 现有预填流;非空提交 = 带问开分支 */ - const [question, setQuestion] = useState("") - const taRef = useRef(null) - /** 气泡内容层(面板本体):测其高度 H 喂定位模型与轮廓 path */ - const contentRef = useRef(null) - /** 实测面板高度(内容驱动:输入框自增高、迷你列条出现都会变),0 = 尚未测量 */ - const [measuredH, setMeasuredH] = useState(0) - /** 渲染期间的派生状态调整(React 官方写法):sel 变化 = 新一次划选,重置各态 */ - const [forSel, setForSel] = useState(sel) - if (forSel !== sel) { - setForSel(sel) - setOverride(null) - setMetaHeld(sel?.meta ?? false) - setQuestion("") - setMeasuredH(0) // 换一段划选:高度作废,等重新测量再定位(先隐藏,避免旧位闪现) - } - - /* 测量面板高度:内容变化(打字自增高 / 列条出现)经 ResizeObserver 实时回填, - 驱动定位模型重新择位(贴底时自动上下翻转),气泡不会被 viewport 裁切 */ - useLayoutEffect(() => { - const el = contentRef.current - if (!sel || !el) return - // 清掉上一次划选留在 textarea 上的自增高(跨划选不重挂载),否则首帧量到偏大的 H - if (taRef.current) taRef.current.style.height = "" - const measure = () => setMeasuredH(el.offsetHeight) - measure() - const ro = new ResizeObserver(measure) - ro.observe(el) - return () => ro.disconnect() - }, [sel]) - - /* 气泡弹出即聚焦输入框(preventScroll:气泡定位刚结算完,不能再引发滚动); - 顺手清掉上一次自增高留下的行内高度(textarea 跨划选不重挂载) */ - useEffect(() => { - const ta = taRef.current - if (!sel || !ta) return - ta.style.height = "" - ta.focus({ preventScroll: true }) - }, [sel]) - - /* 气泡打开期间跟踪 ⌘/Ctrl 起落(keydown/keyup 都带 metaKey/ctrlKey 快照) */ - useEffect(() => { - if (!sel) return - const sync = (e: KeyboardEvent) => setMetaHeld(e.metaKey || e.ctrlKey) - const onBlur = () => setMetaHeld(false) - document.addEventListener("keydown", sync) - document.addEventListener("keyup", sync) - window.addEventListener("blur", onBlur) - return () => { - document.removeEventListener("keydown", sync) - document.removeEventListener("keyup", sync) - window.removeEventListener("blur", onBlur) - } - }, [sel]) - - if (!sel) return null - - /* —— 落点:floating-popup 定位模型,只用上/下两向(尾巴竖直指向选区)—— - 测得高度前(measuredH=0)先把气泡藏到屏外并隐藏,测完这一帧即就位,避免旧位闪现。 */ - const ready = measuredH > 0 && typeof window !== "undefined" - const pos = ready - ? computePopupPosition( - sel.rect, - { width: BUBBLE_W, height: measuredH }, - { - left: 0, - top: 0, - width: window.innerWidth, - height: window.innerHeight, - }, - { - sides: ["bottom", "top"], - gap: BUBBLE_GAP, - safePadding: BUBBLE_SAFE_PADDING, - } - ) - : null - // side="bottom"(面板在选区下方)→ 尾巴朝上;side="top"(面板在上方)→ 尾巴朝下 - const dir: TailDir = pos?.side === "top" ? "down" : "up" - // 尾巴横向落点:对准选区中心,夹进面板安全范围(不让根部爬上圆角) - const anchorCx = sel.rect.left + sel.rect.width / 2 - const cx = pos - ? Math.min( - BUBBLE_W - BUBBLE_TAIL_MARGIN, - Math.max(BUBBLE_TAIL_MARGIN, anchorCx - pos.left) - ) - : BUBBLE_W / 2 - - /* —— 生效目标 = override > 修饰键推导 > 默认规则(列条与提交共用 hint) —— */ - const ov = - override && slots.some((s) => s.id === override && !s.folded) - ? override - : null - const hint: PlacementHint | undefined = ov - ? { targetId: ov } - : metaHeld - ? { keepSource: true } - : undefined - const hasMap = slots.length > 0 // 仅主线时无需放置控制,不显示列条 - const preview = hasMap - ? previewPlacement(mode, slots, { - sourceId: sel.threadId, - maxExpanded, - lastActiveOf, - hint, - }) - : null - - /* —— 按钮文案四态(优先级):列条 override > ⌘ 按住 > 有输入 > 默认 —— */ - const hasQuestion = question.trim().length > 0 - // 按钮只表达「动作」(两态、长度稳定);「放置后果」下沉到列条下的提示行—— - // 变长的列标题在提示行里可单行省略,按钮宽度不再被撑爆(用户定的通用方案) - const btnLabel = hasQuestion ? "带着问题开分支" : "开启分支讨论" - - /** 放置后果提示行:override 优先,其次 ⌘ 跟踪态,否则读 placement 预览 */ - const placeHint = ov - ? `将${mode === "replace" ? "替换" : "折叠"}『${threadTitle(state, ov)}』` - : metaHeld - ? "⌘ 保留本列 · 新列开在紧邻右侧" - : preview?.replaceId - ? `默认替换『${threadTitle(state, preview.replaceId)}』(点小格可换)` - : preview?.foldId - ? `默认折叠『${threadTitle(state, preview.foldId)}』(点小格可换)` - : preview - ? "将在右侧新开一列" - : null - - /** 统一提交:按钮点击与输入框 Enter 共用(事件瞬时修饰键与跟踪态任一为真即 keepSource)。 - question trim 后非空 = 带问开分支;留空 = 现有预填流(上层据第三参分流) */ - const submit = (metaFromEvent: boolean) => { - const h: PlacementHint | undefined = ov - ? { targetId: ov } - : metaFromEvent || metaHeld - ? { keepSource: true } - : undefined - const q = question.trim() - window.getSelection()?.removeAllRanges() - onSelChange(null) - onFork(sel, h, q || undefined) - } - - return ( -
- {/* 平滑曲线轮廓(背景层):面板 + 指向选区的尾巴,一条 path。尾巴朝上时 - 整层上移 ah 让顶点探出面板上沿;朝下时留在原位向下探出。 */} - -
-
在新分支中讨论这段
-
{sel.text}
-
-