diff --git a/docs/architecture-audit-2026-07-22/WorktreeDataFlow-Fixes.md b/docs/architecture-audit-2026-07-22/WorktreeDataFlow-Fixes.md index 1017bd3a8..30aa95644 100644 --- a/docs/architecture-audit-2026-07-22/WorktreeDataFlow-Fixes.md +++ b/docs/architecture-audit-2026-07-22/WorktreeDataFlow-Fixes.md @@ -11,46 +11,46 @@ ## Workspace 状态机 -| 模式 | 前端请求 | Rust agent | CLI agent | 删除所有权 | -| --- | --- | --- | --- | --- | -| 当前仓库 | `workspacePath` | local workspace | local workspace | 不清理 checkout | -| 新建隔离 worktree | `workspacePath + isolate + worktreeBaseRef?` | 创建 `agent/` | 创建 `agent/` | session 拥有;先清理 Git,成功后删 DB | -| 复用已有 worktree | `workspacePath + worktreePath` | canonicalize 并验证 registered linked worktree | 同一验证与持久化语义 | 借用;删除 session 时不删除 worktree | +| 模式 | 前端请求 | Rust agent | CLI agent | 删除所有权 | +| ----------------- | -------------------------------------------- | ---------------------------------------------- | ---------------------- | ------------------------------------- | +| 当前仓库 | `workspacePath` | local workspace | local workspace | 不清理 checkout | +| 新建隔离 worktree | `workspacePath + isolate + worktreeBaseRef?` | 创建 `agent/` | 创建 `agent/` | session 拥有;先清理 Git,成功后删 DB | +| 复用已有 worktree | `workspacePath + worktreePath` | canonicalize 并验证 registered linked worktree | 同一验证与持久化语义 | 借用;删除 session 时不删除 worktree | 互斥条件由 TypeScript 类型、严格 Zod schema、Rust command validation 和 CLI command validation 共同执行:`isolate` 不能与 `worktreePath` 同时出现,`worktreeBaseRef` 只允许用于 fresh isolation。 ## 审计项处置 -| ID | 处置 | 关键变化 | -| --- | --- | --- | -| WT-01 | fixed | worktree 列表项保留 canonical path,Session Creator 生产路径会发送 `worktreePath`;删除未接通的独立 atom | -| WT-02 | fixed | CLI bridge 与 Rust result 都返回 workspace/worktree path、实际 checkout branch 和 base ref | -| WT-03 | fixed | source 绑定 repo key;repo 切换同步清理;PR、GitHub 和 branch 异步结果都有 stale-generation/identity 防护 | -| WT-04 | fixed | `branch` 不再承载 worktree base;新增 `worktreeBaseRef`、`worktreeBranch`、`baseRef`,前端优先消费 authoritative branch | -| WT-05 | fixed | 复用路径必须存在、是目录、属于目标 repo 的 `git worktree list`,且不能是 main checkout | -| WT-06 | fixed | session-owned worktree 先做 Git-aware cleanup,再删会话记录;失败保留记录;borrowed worktree 不清理 | -| WT-07 | fixed with bounded fallback | setup hook 有 300 秒硬 deadline,超时终止进程组/进程树并回收输出管道 | -| WT-08 | fixed | `SessionService` 不再把普通 `repoPath` 隐式写为 worktree;调用方使用显式 `worktreePath` | -| WT-09 | fixed | launch input 改为 strict schema,并覆盖字段互斥、未知字段和三种 workspace 模式测试 | +| ID | 处置 | 关键变化 | +| ----- | --------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| WT-01 | fixed | worktree 列表项保留 canonical path,Session Creator 生产路径会发送 `worktreePath`;删除未接通的独立 atom | +| WT-02 | fixed | CLI bridge 与 Rust result 都返回 workspace/worktree path、实际 checkout branch 和 base ref | +| WT-03 | fixed | source 绑定 repo key;repo 切换同步清理;PR、GitHub 和 branch 异步结果都有 stale-generation/identity 防护 | +| WT-04 | fixed | `branch` 不再承载 worktree base;新增 `worktreeBaseRef`、`worktreeBranch`、`baseRef`,前端优先消费 authoritative branch | +| WT-05 | fixed | 复用路径必须存在、是目录、属于目标 repo 的 `git worktree list`,且不能是 main checkout | +| WT-06 | fixed | session-owned worktree 先做 Git-aware cleanup,再删会话记录;失败保留记录;borrowed worktree 不清理 | +| WT-07 | fixed with bounded fallback | setup hook 有 300 秒硬 deadline,超时终止进程组/进程树并回收输出管道 | +| WT-08 | fixed | `SessionService` 不再把普通 `repoPath` 隐式写为 worktree;调用方使用显式 `worktreePath` | +| WT-09 | fixed | launch input 改为 strict schema,并覆盖字段互斥、未知字段和三种 workspace 模式测试 | ## 补充复查处置 -| Finding | Verdict | Change | -| --- | --- | --- | -| CLI create 后 runner 启动失败会遗留 session/worktree | fixed | bridge 在 `cli_agent_run` 失败时调用完整 delete lifecycle,并把 cleanup failure 合并到原始错误 | +| Finding | Verdict | Change | +| ------------------------------------------------------------------- | --------------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| CLI create 后 runner 启动失败会遗留 session/worktree | fixed | bridge 在 `cli_agent_run` 失败时调用完整 delete lifecycle,并把 cleanup failure 合并到原始错误 | | hosted-key 在 DB create/后续 launch 失败时可能遗留 proxy allocation | fixed with bounded fallback | DB row 存在时先释放 token 再删 row;DB row 尚未创建时直接使用 allocation credentials 释放;网络失败仍由服务端 TTL 兜底 | -| async create 路径直接执行同步 Git cleanup | fixed | 统一通过 `spawn_blocking` helper 执行 worktree removal,并传播 join/Git 错误 | -| borrowed worktree 可进入 destructive CLI lifecycle | fixed | delete、merge、discard 只允许 `base_branch` 标记的 session-owned isolation;borrowed checkout 仅随 session 解绑定 | -| Git branch verification error 被静默忽略 | fixed | `rev-parse` 执行失败进入聚合 cleanup error,DB row 保留供重试 | +| async create 路径直接执行同步 Git cleanup | fixed | 统一通过 `spawn_blocking` helper 执行 worktree removal,并传播 join/Git 错误 | +| borrowed worktree 可进入 destructive CLI lifecycle | fixed | delete、merge、discard 只允许 `base_branch` 标记的 session-owned isolation;borrowed checkout 仅随 session 解绑定 | +| Git branch verification error 被静默忽略 | fixed | `rev-parse` 执行失败进入聚合 cleanup error,DB row 保留供重试 | ## 性能与隔离 -| Area | Verdict | Change | Verification | -| --- | --- | --- | --- | -| setup 子进程 | pass | 300 秒 deadline;Unix kill process group,Windows `taskkill /T /F`;总是 reap child 与 reader threads | Unix hung-command 单测通过 | -| diff summary cache | pass (code/check) | TTL prune + 128 entry hard cap + oldest eviction | 容量单测已添加;macOS 执行被既有 Windows-only test import 阻塞 | -| GitHub source cache | pass | key 包含 endpoint、connection id、credential source、username 与 repo;账号切换主动淘汰同 repo 的旧身份 entry;提交前重查 auth identity | auth/repo isolation 与 identity eviction 单测;generation guard 静态检查 | -| repo/branch async | pass | repo 切换同步 invalidate;GitHub、branch、PR resolve 的迟到结果不能写入当前选择 | stale repo payload 单测;入口 guard | +| Area | Verdict | Change | Verification | +| ------------------- | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | +| setup 子进程 | pass | 300 秒 deadline;Unix kill process group,Windows `taskkill /T /F`;总是 reap child 与 reader threads | Unix hung-command 单测通过 | +| diff summary cache | pass (code/check) | TTL prune + 128 entry hard cap + oldest eviction | 容量单测已添加;macOS 执行被既有 Windows-only test import 阻塞 | +| GitHub source cache | pass | key 包含 endpoint、connection id、credential source、username 与 repo;账号切换主动淘汰同 repo 的旧身份 entry;提交前重查 auth identity | auth/repo isolation 与 identity eviction 单测;generation guard 静态检查 | +| repo/branch async | pass | repo 切换同步 invalidate;GitHub、branch、PR resolve 的迟到结果不能写入当前选择 | stale repo payload 单测;入口 guard | ## 验证与剩余风险 @@ -64,15 +64,15 @@ ## 修复后 10 层检查 -| 层 | Verdict | Evidence | -| --- | --- | --- | -| 1. 编译正确性 | pass with baseline caveat | `cargo check -p org2`、相关 Rust/前端单测与 ESLint 通过;TypeScript 仅剩未改文件的既有错误 | -| 2. 死代码/重复路径 | pass | 删除独立 existing-worktree atom;生产 UI、payload 与测试共用 repo-scoped source | -| 3. 命名一致性 | pass | base ref、worktree path、checkout branch 在 wire/result 中使用独立字段 | -| 4. 语义重载 | pass | local、fresh isolation、reuse registered 三态互斥;普通 working path 不再冒充 worktree path | -| 5. 默认分支 | pass | fresh worktree 缺省 `HEAD` 保持显式设计;base ref 在非 isolate 模式会被拒绝 | -| 6. 跨域泄漏 | pass | source 绑定 repo key;GitHub cache 绑定 endpoint/connection/source/user/repo 并在身份切换时淘汰 | -| 7. 新开发者可理解性 | pass | TS 类型、Zod、Rust DTO 与注释共同描述三态 workspace contract 和所有权 | -| 8. Wire protocol | pass | strict input schema、互斥校验、Rust/CLI 字段 parity、authoritative launch result | -| 9. 入口初始化一致性 | pass | Session Creator、SessionService、Rust agent、CLI agent 均覆盖 local/fresh/reuse 矩阵 | -| 10. Resolver symmetry | pass | repo/source 失效链一致;path/branch/base 从 prepared workspace/CLI persisted session 原样返回 | +| 层 | Verdict | Evidence | +| --------------------- | ------------------------- | ----------------------------------------------------------------------------------------------- | +| 1. 编译正确性 | pass with baseline caveat | `cargo check -p org2`、相关 Rust/前端单测与 ESLint 通过;TypeScript 仅剩未改文件的既有错误 | +| 2. 死代码/重复路径 | pass | 删除独立 existing-worktree atom;生产 UI、payload 与测试共用 repo-scoped source | +| 3. 命名一致性 | pass | base ref、worktree path、checkout branch 在 wire/result 中使用独立字段 | +| 4. 语义重载 | pass | local、fresh isolation、reuse registered 三态互斥;普通 working path 不再冒充 worktree path | +| 5. 默认分支 | pass | fresh worktree 缺省 `HEAD` 保持显式设计;base ref 在非 isolate 模式会被拒绝 | +| 6. 跨域泄漏 | pass | source 绑定 repo key;GitHub cache 绑定 endpoint/connection/source/user/repo 并在身份切换时淘汰 | +| 7. 新开发者可理解性 | pass | TS 类型、Zod、Rust DTO 与注释共同描述三态 workspace contract 和所有权 | +| 8. Wire protocol | pass | strict input schema、互斥校验、Rust/CLI 字段 parity、authoritative launch result | +| 9. 入口初始化一致性 | pass | Session Creator、SessionService、Rust agent、CLI agent 均覆盖 local/fresh/reuse 矩阵 | +| 10. Resolver symmetry | pass | repo/source 失效链一致;path/branch/base 从 prepared workspace/CLI persisted session 原样返回 | diff --git a/docs/architecture-audit-2026-07-22/WorktreeDataFlow.md b/docs/architecture-audit-2026-07-22/WorktreeDataFlow.md index 7ce19050d..cb29225b7 100644 --- a/docs/architecture-audit-2026-07-22/WorktreeDataFlow.md +++ b/docs/architecture-audit-2026-07-22/WorktreeDataFlow.md @@ -80,50 +80,50 @@ delete_session(sessionId) ## 发现 -| ID | 优先级 | 发现 | 证据 | 影响 | 建议 | -| --- | --- | --- | --- | --- | --- | -| WT-01 | 高 | “复用已有 worktree”是未接通的死路径 | `selectedWorktreePathAtom.ts:13` 定义状态;生产写入只见 `ChatPanel/index.tsx:309-337` 的清空。`WorktreeSourceModal.tsx:352-372` 展示 worktree 分组,但 `worktreeBranchSource.ts:169-195` 转换时不保留 `option.worktreePath`。只有 `launchPayload.test.ts:422` 人工覆盖非空值 | 用户看到已有 worktree,但选择后会创建新的隔离 worktree;产品语义与真实动作相反 | 将 source 建模为显式 union:`currentRepo | createFromRef | reuseWorktree`。modal 选择已有 worktree 时必须携带 canonical path,并补生产级组件/E2E 覆盖;若产品不支持复用,则删除 atom、payload 分支和误导 UI | -| WT-02 | 高 | Rust 与 CLI 对 `worktreePath` 和 launch result 不对称 | Rust `launch.rs:161-178` 把非空 `worktree_path` 识别为 Worktree;`CliLaunchParams`(`foundation/session_bridge.rs:40-69`)无该字段;`launch.rs:324-377` 构造 CLI 请求时不传,并固定返回 `worktree_path: None`。CLI 实际在 `cli/commands.rs:143-221` 创建并保存 worktree,但 bridge 在 `agent_core_bridge.rs:55-82` 只保留 session id/created_at | 同一 RPC 随 agent 类型改变语义;CLI 创建成功后,前端立即态拿不到真实路径;已有 worktree 请求可能退化为 local launch | 让 Rust/CLI 共用一个 typed workspace target 和同一 authoritative launch result;CLI bridge 返回完整 workspace metadata,不在适配层丢字段 | -| WT-03 | 高 | repo 切换后 worktree source 可跨 repo 泄漏 | `worktreeLaunchSourceAtom` 是全局、非 repo-keyed 状态;`ChatPanel/index.tsx:313-320` 只在离开 worktree 模式时清理。`useSessionCreatorChatPanelHandlers.ts:109-146` 切 repo 不清理 source。`launchPayload.ts:226-246,273-277` 最后用 source base ref 覆盖前面求得的 branch | repo A 的 SHA/ref 可发送给 repo B;不存在时启动失败,恰好同名时可能静默从错误基准启动 | source 与 `{repoId, canonicalRepoPath}` 绑定;repo/identity generation 变化时同步失效,late async result 必须带 generation guard;增加 A→B 切换回归测试 | -| WT-04 | 高 | `branch` 同时表示 base ref、展示 branch 和实际 checkout branch | UI 发送的 `branch` 可为 PR SHA;`launch.rs:141-145,238` 原样回传。`git/worktree.rs:339-340` 实际生成 `agent/`;launch result 没有 worktree branch。`launchPayload.ts` 的 `buildSessionFromLaunchResult()` 又把 `result.branch` 写入 session branch | 新 session 的立即态可能显示 base branch/SHA,而工作目录实际位于 `agent/`;刷新前后语义可能漂移 | wire result 显式返回 `baseRef`、`baseBranch`、`worktreeBranch`、`workspaceRoot`、`workingDirectory`;禁止用一个 `branch` 字段承载三个概念 | -| WT-05 | 高 | 已有 worktree path 未验证归属和有效性 | `launch_workspace.rs:56-61` 直接调用 `SessionWorkspace::new_worktree`;`workspace.rs:137-143` 仅赋值,`is_worktree()` 只比较路径。Git 删除路由在 `git/src/worktree.rs:557-581` 已有 registered-path 校验,但 launch 未复用 | 不存在目录、普通目录或其他 repo 的目录可能先被持久化并返回成功,随后首个 turn 才异步失败;错误边界过晚 | canonicalize;要求目录存在;从 `git worktree list --porcelain` 验证属于 workspace repo;校验通过后再持久化和启动 | -| WT-06 | 高 | 删除先删 DB,Git 清理失败后缺少可重试上下文 | `agent-core/.../persistence/crud/ops.rs:637-724` 先 `delete_session_cascade`,后尝试 worktree cleanup,失败只记录日志并返回成功。`housekeeping_orphans.rs:227-284` 主要直接删目录;周期 prune 又依赖仍存活的 session repo | 可永久遗留 stale worktree registration、`agent/` 分支和磁盘目录;用户看不到 session,也无法从 DB 恢复 cleanup target | 清理成功后再删主记录,或写 durable cleanup tombstone(repo、path、branch、session id、attempt state);housekeeping 必须走 Git-aware cleanup 并有有界重试 | -| WT-07 | 高 | setup command 无超时、取消与进程回收策略 | `git/src/worktree.rs:421-470,504-548` 通过 `sh -c` / `cmd /C` 调用 `.output()`;调用发生于新建 linked/session worktree 的阻塞任务内 | 任意挂起脚本会让 launch 一直等待;重复启动可积累阻塞线程/子进程 | 设置可配置硬超时和 kill-on-timeout;记录命令、耗时、退出原因;对 launch cancellation 和 app shutdown 明确处置 | -| WT-08 | 中 | `SessionService.create()` 把一般工作目录重载为 `worktreePath` | `SessionService.ts:146-169` 同时传 `workspacePath: projectRepoPath || repoPath` 与 `worktreePath: repoPath`;`services/types.ts:20-23` 又把 `repoPath` 定义为 agent working path。Rust 因非空 path 统一识别为 Worktree | 普通 cwd、alternate working dir 与 registered worktree 被压成一个概念;response 可判为 worktree,而 DB 在 root==working_dir 时保存为 local,形成瞬时 split-brain | 调用方传 discriminated workspace target;若确需 alternate cwd,单独命名,不得借用 `worktreePath` | -| WT-09 | 中 | wire schema 对 launch input 几乎不做约束 | `src/api/tauri/rpc/schemas/agentSession.ts:345-347` 使用 `z.record(z.string(), z.unknown())`;结果 schema 才显式声明 worktreePath | TS/Rust 字段遗漏、互斥条件错误和新增字段漂移无法在编译或运行时边界暴露,WT-02 因而能长期存在 | 用 discriminated Zod schema 与 Rust DTO 对齐;覆盖 unknown-key、互斥字段和 Rust/CLI parity contract tests | +| ID | 优先级 | 发现 | 证据 | 影响 | 建议 | +| ----- | ------ | -------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| WT-01 | 高 | “复用已有 worktree”是未接通的死路径 | `selectedWorktreePathAtom.ts:13` 定义状态;生产写入只见 `ChatPanel/index.tsx:309-337` 的清空。`WorktreeSourceModal.tsx:352-372` 展示 worktree 分组,但 `worktreeBranchSource.ts:169-195` 转换时不保留 `option.worktreePath`。只有 `launchPayload.test.ts:422` 人工覆盖非空值 | 用户看到已有 worktree,但选择后会创建新的隔离 worktree;产品语义与真实动作相反 | 将 source 建模为显式 union:`currentRepo | createFromRef | reuseWorktree`。modal 选择已有 worktree 时必须携带 canonical path,并补生产级组件/E2E 覆盖;若产品不支持复用,则删除 atom、payload 分支和误导 UI | +| WT-02 | 高 | Rust 与 CLI 对 `worktreePath` 和 launch result 不对称 | Rust `launch.rs:161-178` 把非空 `worktree_path` 识别为 Worktree;`CliLaunchParams`(`foundation/session_bridge.rs:40-69`)无该字段;`launch.rs:324-377` 构造 CLI 请求时不传,并固定返回 `worktree_path: None`。CLI 实际在 `cli/commands.rs:143-221` 创建并保存 worktree,但 bridge 在 `agent_core_bridge.rs:55-82` 只保留 session id/created_at | 同一 RPC 随 agent 类型改变语义;CLI 创建成功后,前端立即态拿不到真实路径;已有 worktree 请求可能退化为 local launch | 让 Rust/CLI 共用一个 typed workspace target 和同一 authoritative launch result;CLI bridge 返回完整 workspace metadata,不在适配层丢字段 | +| WT-03 | 高 | repo 切换后 worktree source 可跨 repo 泄漏 | `worktreeLaunchSourceAtom` 是全局、非 repo-keyed 状态;`ChatPanel/index.tsx:313-320` 只在离开 worktree 模式时清理。`useSessionCreatorChatPanelHandlers.ts:109-146` 切 repo 不清理 source。`launchPayload.ts:226-246,273-277` 最后用 source base ref 覆盖前面求得的 branch | repo A 的 SHA/ref 可发送给 repo B;不存在时启动失败,恰好同名时可能静默从错误基准启动 | source 与 `{repoId, canonicalRepoPath}` 绑定;repo/identity generation 变化时同步失效,late async result 必须带 generation guard;增加 A→B 切换回归测试 | +| WT-04 | 高 | `branch` 同时表示 base ref、展示 branch 和实际 checkout branch | UI 发送的 `branch` 可为 PR SHA;`launch.rs:141-145,238` 原样回传。`git/worktree.rs:339-340` 实际生成 `agent/`;launch result 没有 worktree branch。`launchPayload.ts` 的 `buildSessionFromLaunchResult()` 又把 `result.branch` 写入 session branch | 新 session 的立即态可能显示 base branch/SHA,而工作目录实际位于 `agent/`;刷新前后语义可能漂移 | wire result 显式返回 `baseRef`、`baseBranch`、`worktreeBranch`、`workspaceRoot`、`workingDirectory`;禁止用一个 `branch` 字段承载三个概念 | +| WT-05 | 高 | 已有 worktree path 未验证归属和有效性 | `launch_workspace.rs:56-61` 直接调用 `SessionWorkspace::new_worktree`;`workspace.rs:137-143` 仅赋值,`is_worktree()` 只比较路径。Git 删除路由在 `git/src/worktree.rs:557-581` 已有 registered-path 校验,但 launch 未复用 | 不存在目录、普通目录或其他 repo 的目录可能先被持久化并返回成功,随后首个 turn 才异步失败;错误边界过晚 | canonicalize;要求目录存在;从 `git worktree list --porcelain` 验证属于 workspace repo;校验通过后再持久化和启动 | +| WT-06 | 高 | 删除先删 DB,Git 清理失败后缺少可重试上下文 | `agent-core/.../persistence/crud/ops.rs:637-724` 先 `delete_session_cascade`,后尝试 worktree cleanup,失败只记录日志并返回成功。`housekeeping_orphans.rs:227-284` 主要直接删目录;周期 prune 又依赖仍存活的 session repo | 可永久遗留 stale worktree registration、`agent/` 分支和磁盘目录;用户看不到 session,也无法从 DB 恢复 cleanup target | 清理成功后再删主记录,或写 durable cleanup tombstone(repo、path、branch、session id、attempt state);housekeeping 必须走 Git-aware cleanup 并有有界重试 | +| WT-07 | 高 | setup command 无超时、取消与进程回收策略 | `git/src/worktree.rs:421-470,504-548` 通过 `sh -c` / `cmd /C` 调用 `.output()`;调用发生于新建 linked/session worktree 的阻塞任务内 | 任意挂起脚本会让 launch 一直等待;重复启动可积累阻塞线程/子进程 | 设置可配置硬超时和 kill-on-timeout;记录命令、耗时、退出原因;对 launch cancellation 和 app shutdown 明确处置 | +| WT-08 | 中 | `SessionService.create()` 把一般工作目录重载为 `worktreePath` | `SessionService.ts:146-169` 同时传 `workspacePath: projectRepoPath | | repoPath`与`worktreePath: repoPath`;`services/types.ts:20-23`又把`repoPath` 定义为 agent working path。Rust 因非空 path 统一识别为 Worktree | 普通 cwd、alternate working dir 与 registered worktree 被压成一个概念;response 可判为 worktree,而 DB 在 root==working_dir 时保存为 local,形成瞬时 split-brain | 调用方传 discriminated workspace target;若确需 alternate cwd,单独命名,不得借用 `worktreePath` | +| WT-09 | 中 | wire schema 对 launch input 几乎不做约束 | `src/api/tauri/rpc/schemas/agentSession.ts:345-347` 使用 `z.record(z.string(), z.unknown())`;结果 schema 才显式声明 worktreePath | TS/Rust 字段遗漏、互斥条件错误和新增字段漂移无法在编译或运行时边界暴露,WT-02 因而能长期存在 | 用 discriminated Zod schema 与 Rust DTO 对齐;覆盖 unknown-key、互斥字段和 Rust/CLI parity contract tests | ## 10 层架构检查 -| 层 | 结论 | 说明 | -| --- | --- | --- | -| 1. 编译正确性 | 定向通过 | 64 个前端单元测试与 33 个 Rust worktree 单元测试通过。此分支只新增文档,未做全量 app 编译、clippy 或 rendered E2E | -| 2. 死代码/重复路径 | 失败 | existing-worktree payload 分支无生产写入;`RunningLocationPill.tsx` 无生产引用;diff summary 未发现前端调用 | -| 3. 命名一致性 | 失败 | `branch`、`repoPath`、`worktreePath` 在 UI、RPC、session service、Git 层含义不同 | -| 4. 语义重载 | 失败 | base ref / session display branch / checkout branch 共用 `branch`;alternate cwd / registered worktree 共用 `worktreePath` | -| 5. 默认分支 | 风险 | worktree 模式在 source 缺失时默认 current HEAD;CLI 缺失 worktree_path 字段时静默走 local/fresh 逻辑,而不是拒绝不支持的请求 | -| 6. 跨域泄漏 | 失败 | SessionService 的一般 working path 被解释为 Git worktree;UI source 状态跨 repo 泄漏 | -| 7. 新开发者可理解性 | 失败 | “Worktrees” 分组看似代表复用,实际只选 branch;同一 launch result 的 branch/path 含义依 agent 类型不同 | -| 8. Wire protocol | 失败 | 输入为任意 record;CLI 适配层丢 `worktreePath` 和实际创建结果;未返回 authoritative worktree branch | -| 9. 入口初始化一致性 | 失败 | Rust agent、CLI agent、Session Creator、SessionService 对 local/fresh/reuse 三种 workspace 模式支持矩阵不一致 | -| 10. Resolver symmetry | 失败 | repo/source 没有同一 fallback/失效链;路径、branch 和 metadata 在 result、DB、WebSocket 间来源不对称 | +| 层 | 结论 | 说明 | +| --------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------- | +| 1. 编译正确性 | 定向通过 | 64 个前端单元测试与 33 个 Rust worktree 单元测试通过。此分支只新增文档,未做全量 app 编译、clippy 或 rendered E2E | +| 2. 死代码/重复路径 | 失败 | existing-worktree payload 分支无生产写入;`RunningLocationPill.tsx` 无生产引用;diff summary 未发现前端调用 | +| 3. 命名一致性 | 失败 | `branch`、`repoPath`、`worktreePath` 在 UI、RPC、session service、Git 层含义不同 | +| 4. 语义重载 | 失败 | base ref / session display branch / checkout branch 共用 `branch`;alternate cwd / registered worktree 共用 `worktreePath` | +| 5. 默认分支 | 风险 | worktree 模式在 source 缺失时默认 current HEAD;CLI 缺失 worktree_path 字段时静默走 local/fresh 逻辑,而不是拒绝不支持的请求 | +| 6. 跨域泄漏 | 失败 | SessionService 的一般 working path 被解释为 Git worktree;UI source 状态跨 repo 泄漏 | +| 7. 新开发者可理解性 | 失败 | “Worktrees” 分组看似代表复用,实际只选 branch;同一 launch result 的 branch/path 含义依 agent 类型不同 | +| 8. Wire protocol | 失败 | 输入为任意 record;CLI 适配层丢 `worktreePath` 和实际创建结果;未返回 authoritative worktree branch | +| 9. 入口初始化一致性 | 失败 | Rust agent、CLI agent、Session Creator、SessionService 对 local/fresh/reuse 三种 workspace 模式支持矩阵不一致 | +| 10. Resolver symmetry | 失败 | repo/source 没有同一 fallback/失效链;路径、branch 和 metadata 在 result、DB、WebSocket 间来源不对称 | ## 入口一致性矩阵 -| 入口/模式 | Local | 新建隔离 worktree | 复用已有 worktree | 返回真实 path | 返回真实 checkout branch | -| --- | --- | --- | --- | --- | --- | -| Session Creator → Rust agent | 支持 | 支持 | helper 支持,但 UI 不可达 | 是 | 否 | -| Session Creator → CLI agent | 支持 | 支持 | 不支持且未显式报错 | 否 | 否 | -| `SessionService.create()` → Rust agent | 支持语义模糊 | 可触发 | 任意 `repoPath` 被当作 worktree path | 调用方丢弃 | 否 | -| `SessionService.create()` → CLI agent | 支持 | 由 isolate 决定 | `worktreePath` 被忽略 | 调用方丢弃 | 否 | +| 入口/模式 | Local | 新建隔离 worktree | 复用已有 worktree | 返回真实 path | 返回真实 checkout branch | +| -------------------------------------- | ------------ | ----------------- | ------------------------------------ | ------------- | ------------------------ | +| Session Creator → Rust agent | 支持 | 支持 | helper 支持,但 UI 不可达 | 是 | 否 | +| Session Creator → CLI agent | 支持 | 支持 | 不支持且未显式报错 | 否 | 否 | +| `SessionService.create()` → Rust agent | 支持语义模糊 | 可触发 | 任意 `repoPath` 被当作 worktree path | 调用方丢弃 | 否 | +| `SessionService.create()` → CLI agent | 支持 | 由 isolate 决定 | `worktreePath` 被忽略 | 调用方丢弃 | 否 | ## 性能与生命周期检查 -| Area | Verdict | Evidence | Change or reason kept | Verification | -| --- | --- | --- | --- | --- | -| Background work | fix | setup hook 由 worktree create 拥有,但 `.output()` 无 deadline/cancel;挂起时 launch 与 blocking worker 无终止态 | 增加 timeout、kill、cancellation 与可观测状态 | 需补“永久等待脚本”单测和真实进程回收测试 | -| Memory | fix | `git-api/routes/worktrees.rs:24-39,194-252` 的 app-lifetime `HashMap<(path, headSha), DiffCacheEntry>` 只在 lookup 检查 TTL,无 cap/全局 prune | 删除未使用 diff summary,或改 bounded LRU/TTL 并在 repo/worktree 删除时 eviction | 当前仅确认 endpoint 无前端调用;尚无容量/淘汰测试 | -| Scope/isolation | fix | `worktreeSourceCache.ts:38-51` 只用 repo id/path;GitHub PR/issue cache 未包含 endpoint + authenticated user,旧请求也无 identity generation guard | key 加 endpoint/user/resource;登录态切换清空;提交结果前比较 generation | 需补 account/endpoint switch 与 stale completion rejection 测试 | -| Rendering/hot path | keep | worktree map cache cap=16;source cache cap=8;同 key 请求有 in-flight single-flight;branch 与 GitHub 数据并行加载 | 现有有界/并发结构可保留,修复 identity key 即可 | 相关 64 个前端单元测试通过;未做 rendered profiling | +| Area | Verdict | Evidence | Change or reason kept | Verification | +| ------------------ | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | --------------------------------------------------------------- | +| Background work | fix | setup hook 由 worktree create 拥有,但 `.output()` 无 deadline/cancel;挂起时 launch 与 blocking worker 无终止态 | 增加 timeout、kill、cancellation 与可观测状态 | 需补“永久等待脚本”单测和真实进程回收测试 | +| Memory | fix | `git-api/routes/worktrees.rs:24-39,194-252` 的 app-lifetime `HashMap<(path, headSha), DiffCacheEntry>` 只在 lookup 检查 TTL,无 cap/全局 prune | 删除未使用 diff summary,或改 bounded LRU/TTL 并在 repo/worktree 删除时 eviction | 当前仅确认 endpoint 无前端调用;尚无容量/淘汰测试 | +| Scope/isolation | fix | `worktreeSourceCache.ts:38-51` 只用 repo id/path;GitHub PR/issue cache 未包含 endpoint + authenticated user,旧请求也无 identity generation guard | key 加 endpoint/user/resource;登录态切换清空;提交结果前比较 generation | 需补 account/endpoint switch 与 stale completion rejection 测试 | +| Rendering/hot path | keep | worktree map cache cap=16;source cache cap=8;同 key 请求有 in-flight single-flight;branch 与 GitHub 数据并行加载 | 现有有界/并发结构可保留,修复 identity key 即可 | 相关 64 个前端单元测试通过;未做 rendered profiling | **Performance verdict: fail** — 仍存在无上限缓存、不可终止的后台子进程,以及跨身份缓存命中/旧请求回写路径。 diff --git a/docs/architecture-audit-2026-07-23/Issue443.md b/docs/architecture-audit-2026-07-23/Issue443.md new file mode 100644 index 000000000..91fd18f35 --- /dev/null +++ b/docs/architecture-audit-2026-07-23/Issue443.md @@ -0,0 +1,43 @@ +# Issue #443 architecture audit + +Scope: bounded replay for all built-in imported-history sources, managed CLI mirrors, replay consumers, SQLite turn-index projection, and the native-SDE isolation boundary. + +## Ten-layer review + +| Layer | Evidence checked | Verdict | +| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1. Compilation and contracts | Rust/TS wire schemas, targeted `cargo check`, typecheck, serialized compatibility tests | Pass for the #443 change. Workspace `cargo check`, typecheck, lint, full Vitest, circular analysis, and task-scoped `-D warnings` clippy completed. Strict workspace clippy is blocked only by byte-identical `develop` baseline findings listed below. | +| 2. Old/dead paths | Global caller sweep for `loadFullTranscriptChunks`, `supportsWindowedReplay`, `cursorIdeFullRefresh`, `cli_agent_chunks`, and provider full loaders | Pass; production callers are zero. Removed the remaining Cursor full-refresh API. Legacy provider loaders remain only as parser/test compatibility surfaces. | +| 3. Naming consistency | `ReplayCursor`, generation/revision, source/session identity, descriptor/payload naming | Pass | +| 4. Semantic overloading | Core descriptor encoding versus app `PayloadRefEncoding`; EventStore hydration modes | Pass after making generation resets use `set_external_replay_window`. The two encoding enums intentionally separate persistent-core compatibility from the public event wire. | +| 5. Defaults and fallbacks | Unknown source routing, missing adapter behavior, legacy cached payload descriptors, watcher fallback | Pass. Unknown built-ins fail closed; only old cached descriptors may infer encoding. Watcher failure returns the typed active/visible bounded poll fallback. | +| 6. Domain boundaries | Native SDE versus imported/managed CLI, collaboration snapshot secondary reads | Pass. Native adapters never resolve the primary replay target; Rust validates identities before creating watcher/request state. | +| 7. Registry clarity | Rust authoritative 15-source registry, TS metadata mirror, managed native transcript remap | Pass after centralizing an exhaustive compile-time storage-driver classifier shared by sync and payload reads. | +| 8. Wire and memory bounds | 10 turns, 200 events, 4 MiB IPC, 256 KiB payload ranges/cloud segments, EventStore byte cap | Pass in code, wire tests, deterministic 30/300 MiB performance tests, a 3.0 GiB real SQLite DB migration, and both core and rendered-app runs of the 335 MiB Issue 272 Codex JSONL. | +| 9. Entry-point parity | Open, poll, older/seek, prewarm, metadata/hover, provenance, Fork, Cloud, Raw Transcript, JSON/Markdown export | Pass. Background/read-only consumers use compact indexes or bounded scans and do not acquire foreground watchers. | +| 10. Resolver symmetry | Source/session validation, public-to-native cursor remap, generation/revision pinning, reset/release/late-result guards | Pass. Sync and payload range now share the same exhaustive driver routing. | + +## Findings fixed during the audit + +| Severity | Finding | Resolution | +| -------- | ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| P1 | A replay generation reset called generic `EventStore::set`, marking a bounded suffix as fully hydrated. | Use `set_external_replay_window` and assert `HydrationMode::RoundWindow`. | +| P1 | Driver dispatch used guarded matches plus runtime catch-alls, so a future source could compile without a real adapter. | Added exhaustive `replay_driver_kind` and a storage-family contract test. | +| P2 | Cursor IDE's unused `load_full_refresh_for_session` production API remained after consumer migration. | Removed the type/function; retained the single-bubble parser used by SQLite/KV replay. | +| P1 | Payload meaning was inferred from a dotted field path and array export recursion reused the parent path. | Added explicit encoding/body projection and indexed array paths, with legacy-cache compatibility only. | + +## Acceptance boundary + +- All 15 built-in sources must stay `Incremental` and route through the exhaustive driver classifier. +- A native SDE session must make zero external replay calls and retain its existing EventStore/FSM behavior. +- No renderer API may expose a built-in full-transcript loader. +- Completed evidence on the final tree includes 711 Vitest files / 6,426 tests, 482 passing `orgtrack_core` tests (4 intentionally ignored), 416 event-pipeline tests, 3,088 passing `agent_core` tests plus both loopback-only tests rerun successfully outside the sandbox, 68 `perf_utils` tests, 42 `orgtrack_cli` tests, 13 turn-index tests, the #425 ignored RSS stress, streamed 327.1 MiB export hash verification, bounded 30→300 MiB growth, and first-open/reopen of the real DB. +- The Issue 272 core run parsed 52,477 rows from a 335.2 MiB JSONL into a 200-event/10-turn window, then performed an unchanged poll with zero parsed bytes and reopened in 55.64 ms. Cold open completed in 19.637 seconds with 39.4 MiB peak RSS growth. +- The final-tree real SQLite clone migrated 5,227 events / 199 turns from index v11 to v12 in 595.138 ms, reopened in 44.888 ms, and increased peak RSS by 93.0 MiB. The original `~/.orgii/sessions.db` was never opened writable. +- The final-tree rendered Issue 272 run opened and released the real session ten times with 26 events per window. First-open growth was 0 MiB relative to the sampled 609.9 MiB startup baseline, measured-tail step growth was 0 MiB, backend step growth was 0 MiB, and the 30-second idle settled at 494.1 MiB with 0 MiB growth. The #435 native/vmmap comparison was 609.9 MiB versus 566.7 MiB, a 43.2 MiB difference within the acceptance threshold. +- A separate Computer Use pass opened the same 351,503,887-byte Issue 272 source through the visible sidebar and used `Command+5` to inspect runtime behavior. Reopen performed one bounded `es_merge_events`, twelve idle seconds produced zero API calls / zero likely polling, switching away issued `unsubscribe_session_events`, and A→B→A restored the same newest turn without a late result. +- Deterministic performance stayed within every hard gate: 30 MiB cold-open growth was 9.7 MiB, extending to 300 MiB added 52.5 MiB, and the 327.1 MiB streamed export retained SHA-256 `a8ba9a1712ca6758f85f52101855d530b410f6fa12b716c78b0d7932c1ecfa3d` while adding 16.1 MiB peak RSS. +- New #443 production files stay below the 1,500-line hard limit. The largest is the Codex JSONL driver at 1,370 lines; the Qoder sidecar driver is 1,316 lines and the managed-chunk bridge is 1,301 lines. +- The live dual-instance sharing/presence spec now loads correctly and reports all 12 live scenarios as `SKIP`; execution remains externally blocked because no `E2E_CLOUD_*` test credentials are configured. +- `pnpm check:circular` processed 5,849 files and found no circular dependency. ESLint completed with zero errors and two non-blocking warnings in byte-identical `develop` Cloud Realtime code. +- Strict workspace clippy is still blocked by pre-existing findings in files byte-identical to `upstream/develop`: an item-after-test-module in `bin-gateway-chat-cli`, duplicated test modules in `perf_utils`, the existing usage-dashboard argument list, Key Vault enum naming, several existing Agent Core type/trait/style lints, and two existing ORG2 doc/constant-assertion lints. A scoped `-D warnings` run for all six #443 crates passed after allowing only those verified baseline lint classes on the command line; no source-level blanket allow was added. diff --git a/docs/e2e-testing-2026-07-23/Issue443.md b/docs/e2e-testing-2026-07-23/Issue443.md new file mode 100644 index 000000000..663e72d16 --- /dev/null +++ b/docs/e2e-testing-2026-07-23/Issue443.md @@ -0,0 +1,29 @@ +# Issue #443 rendered E2E evidence + +Scope: the real 335 MiB Codex “Issue 272” session, external replay open/switch/close lifecycle, native memory attribution, and the requested dual-instance sharing/presence smoke. + +## Evidence matrix + +| Surface | Result | Production-path evidence | Result details | +| --------------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Real Codex first open | PASS | The rendered Tauri app indexes the isolated external root through the production rescan command, resolves the imported Codex session through the production bounded-replay adapter, and opens the chat session. | 26 events were hydrated; first-open Physical Footprint did not exceed the sampled 609.9 MiB startup baseline, against the 400 MiB growth limit. | +| Ten open/release cycles | PASS | Each cycle opens the same real external session, switches back to New Session, releases the replay owner, and measures the app process tree. | Measured-tail step growth was 0 MiB and backend step growth was 0 MiB. | +| Idle release / #446 compatibility | PASS | After the final explicit release, the app remains idle while native process memory is sampled every five seconds for a bounded 30-second window. | The app settled to 0 MiB over baseline without lowering the 250 MiB threshold. | +| #435 native attribution | PASS | Every process row returned by `get_app_memory_snapshot_v1` is compared with `/usr/bin/vmmap -summary`. | Native total 609.9 MiB versus vmmap 566.7 MiB; 43.2 MiB difference is within `max(10%, 50 MiB)`. | +| Hard event bound | PASS | The open-session result returns only compact identity and event count; it does not serialize the full event store back through WebDriver. | Every cycle returned 26 events, below the 200-event hard cap. | +| `Command+5` manual runtime check | PASS | Computer Use opens the 351,503,887-byte source from the visible sidebar, observes the monitor, switches away, and reopens the same session. | One bounded merge per reopen, zero calls during a 12-second idle window, normal unsubscribe on release, and no A→B→A late-result resurrection. | +| Dual-instance sharing/presence | BLOCKED | `cloud-dual-instance-ui.spec.mjs` loads on the current tree and reaches its live credential gate. | All 12 live scenarios are explicitly skipped because `E2E_CLOUD_SUPABASE_URL`, `E2E_CLOUD_ANON_KEY`, and a service-key or email/password credential are absent. | + +## Failure-driven harness correction + +An earlier post-merge run sampled each release after only one second and failed with a renderer-dominated 330.4 MiB “settled” growth even though the backend and measured-tail step growth stayed bounded. The acceptance test preserves the same 250 MiB limit but adds a bounded 30-second idle sampling window, distinguishing delayed allocator reclamation from retained live replay state without manufacturing a pass. + +The first current-head rerun used a brand-new isolated ORGII home and correctly failed before memory measurement because the test tried to open the imported session before any catalog scan. The harness now establishes the same precondition as the real sidebar/data-source flow by calling the production `external_history_rescan_source` command. It also sets `ORGII_EXTERNAL_HISTORY_HOME` to an isolated root containing only the real Issue 272 JSONL. The open/release assertions still use the production session adapter; no full cache or debug-only success path is seeded. + +## Commands + +- `E2E_ISOLATED_RUN=1 E2E_ORGII_HOME= ORGII_EXTERNAL_HISTORY_HOME= E2E_ISSUE_443_REAL_CODEX_SESSION_ID=codexapp-rollout-2026-07-12T01-13-48-019f522b-c7de-7c50-885f-ab6790d68469 E2E_CHAT_RENDERING_SCENARIOS=issue-443-real-codex pnpm test -- --spec './specs/core/external-replay-ui.spec.mjs'`: PASS on the final tree, one real rendered scenario in 2m 41s including service startup and graceful cleanup. +- Computer Use: launched the current debug binary from a uniquely identified temporary app bundle, opened the real Issue 272 source through the normal sidebar, toggled `Command+5`, exercised expand, idle, switch, reopen, and quit, then verified the frontend, IDE server, and WebDriver ports were all released. +- `E2E_ISOLATED_RUN=1 E2E_ORGII_HOME= pnpm test -- --spec './specs/core/cloud-dual-instance-ui.spec.mjs'`: current-tree infrastructure gate reached; all 12 live scenarios explicitly BLOCKED/SKIPPED for missing cloud credentials. + +The large real JSONL and isolated ORGII homes remain outside Git. diff --git a/docs/frontend-ui-audit-2026-07-22/BranchPalette.md b/docs/frontend-ui-audit-2026-07-22/BranchPalette.md index e2a5ab8cf..a4cd73c4f 100644 --- a/docs/frontend-ui-audit-2026-07-22/BranchPalette.md +++ b/docs/frontend-ui-audit-2026-07-22/BranchPalette.md @@ -6,27 +6,27 @@ ## D1 — Raw HTML vs Design System -| Line | Element | Verdict | Reason | Suggested change | -|---|---|---|---|---| -| — | No raw interactive elements in the changed surface | keep with reason | The palette continues to compose existing Spotlight and modal components. | — | +| Line | Element | Verdict | Reason | Suggested change | +| ---- | -------------------------------------------------- | ---------------- | ------------------------------------------------------------------------- | ---------------- | +| — | No raw interactive elements in the changed surface | keep with reason | The palette continues to compose existing Spotlight and modal components. | — | ## D2 — Arbitrary Tailwind Value vs Token -| Line | Value | Verdict | Reason | Suggested change | -|---|---|---|---|---| -| — | No hits | keep with reason | No arbitrary color/token values were introduced. | — | +| Line | Value | Verdict | Reason | Suggested change | +| ---- | ------- | ---------------- | ------------------------------------------------ | ---------------- | +| — | No hits | keep with reason | No arbitrary color/token values were introduced. | — | ## D3 — Hardcoded Sizes / Colors -| Line | Value | Verdict | Reason | Suggested change | -|---|---|---|---|---| -| — | No hits | keep with reason | No pixel or raw-color literals were introduced. | — | +| Line | Value | Verdict | Reason | Suggested change | +| ---- | ------- | ---------------- | ----------------------------------------------- | ---------------- | +| — | No hits | keep with reason | No pixel or raw-color literals were introduced. | — | ## D4 — Accessibility -| Line | Element | Verdict | Reason | Suggested change | -|---|---|---|---|---| -| — | No new interactive markup | keep with reason | The change is a type adapter that unwraps the repo-scoped selection for the existing handler. | — | +| Line | Element | Verdict | Reason | Suggested change | +| ---- | ------------------------- | ---------------- | --------------------------------------------------------------------------------------------- | ---------------- | +| — | No new interactive markup | keep with reason | The change is a type adapter that unwraps the repo-scoped selection for the existing handler. | — | ## D5 — Visual Patterns Observed diff --git a/docs/frontend-ui-audit-2026-07-22/ChatPanel.md b/docs/frontend-ui-audit-2026-07-22/ChatPanel.md index e21a176f8..46435bd5a 100644 --- a/docs/frontend-ui-audit-2026-07-22/ChatPanel.md +++ b/docs/frontend-ui-audit-2026-07-22/ChatPanel.md @@ -6,30 +6,30 @@ ## D1 — Raw HTML vs Design System -| Line | Element | Verdict | Reason | Suggested change | -|---|---|---|---|---| -| 937 | Full-width launch ` + + + + + ) : null} + + {!isExternalReplayPayload && fullPayload !== null && payloadKey && (
{fullPayload.length.toLocaleString()} bytes loaded @@ -391,6 +530,15 @@ const BlockOutput: React.FC = memo(
)} + {payloadLoadError ? ( +
+ {payloadLoadError} +
+ ) : null} + {!useTopCollapsedOverlay ? expandOverlay : null} ); diff --git a/src/engines/ChatPanel/components/SessionRawTranscriptDialog/SessionRawTranscriptContent.tsx b/src/engines/ChatPanel/components/SessionRawTranscriptDialog/SessionRawTranscriptContent.tsx index 0e121d3d4..0b8d15af5 100644 --- a/src/engines/ChatPanel/components/SessionRawTranscriptDialog/SessionRawTranscriptContent.tsx +++ b/src/engines/ChatPanel/components/SessionRawTranscriptDialog/SessionRawTranscriptContent.tsx @@ -1,48 +1,357 @@ -import React, { memo } from "react"; +import React, { + memo, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { useTranslation } from "react-i18next"; +import { Virtuoso } from "react-virtuoso"; +import { + type ExternalReplayTarget, + externalReplayReadPayloadRangeForTarget, +} from "@src/api/tauri/externalHistory/replay"; +import { ReplayRequestGuard } from "@src/api/tauri/externalHistory/replayRequestGuard"; +import Button from "@src/components/Button"; +import type { + PayloadRef, + SessionEvent, +} from "@src/engines/SessionCore/core/types"; import { CodeMirrorEditor } from "@src/features/CodeMirror"; +import { + RAW_TRANSCRIPT_VIRTUAL_BASE_INDEX, + type ReplayVirtualAnchorState, + reconcileReplayVirtualAnchor, +} from "./replayVirtualAnchor"; +import type { RawTranscriptSnapshot } from "./transcript"; + +const PAYLOAD_PAGE_BYTES = 256 * 1024; + +type ReplayPayloadRef = PayloadRef & { + replayGeneration?: string; + replaySourceEventId?: string; +}; + +interface LoadedPayloadRange { + key: string; + offset: number; + nextOffset: number; + eof: boolean; + totalBytes: number; + text: string; +} + +function ReplayPayloadRanges({ + event, + generation, + sessionId, + target, +}: { + event: SessionEvent; + generation: string; + sessionId: string; + target: ExternalReplayTarget; +}) { + const { t } = useTranslation(["sessions", "common"]); + const [loaded, setLoaded] = useState(null); + const [loadingKey, setLoadingKey] = useState(null); + const [error, setError] = useState(null); + const requestGuard = useRef(new ReplayRequestGuard()); + const refs = (event.payloadRefs ?? []) as ReplayPayloadRef[]; + + useEffect(() => { + const guard = requestGuard.current; + guard.invalidate(); + setLoaded(null); + setLoadingKey(null); + setError(null); + return () => guard.invalidate(); + }, [event.id, generation, sessionId]); + + const readRange = useCallback( + async (ref: ReplayPayloadRef, offset: number) => { + const key = `${ref.fieldPath}:${offset}`; + const requestEpoch = requestGuard.current.begin(); + setLoadingKey(key); + setError(null); + try { + const range = await externalReplayReadPayloadRangeForTarget({ + target, + generation: ref.replayGeneration ?? generation, + eventId: ref.replaySourceEventId ?? event.chunk_id ?? ref.eventId, + fieldPath: ref.fieldPath, + offset, + maxBytes: PAYLOAD_PAGE_BYTES, + }); + if (!requestGuard.current.isCurrent(requestEpoch)) return; + setLoaded({ key: ref.fieldPath, ...range }); + } catch (loadError) { + if (!requestGuard.current.isCurrent(requestEpoch)) return; + setError( + loadError instanceof Error ? loadError.message : String(loadError) + ); + } finally { + if (requestGuard.current.isCurrent(requestEpoch)) { + setLoadingKey(null); + } + } + }, + [event.chunk_id, generation, target] + ); + + if (refs.length === 0) return null; + return ( +
+ {refs.map((ref) => { + const isSelected = loaded?.key === ref.fieldPath; + return ( +
+
+ {ref.fieldPath} + {ref.fullSizeBytes.toLocaleString()} bytes + +
+ {isSelected && loaded ? ( +
+
+                  {loaded.text}
+                
+
+ + {loaded.offset.toLocaleString()}– + {loaded.nextOffset.toLocaleString()} /{" "} + {loaded.totalBytes.toLocaleString()} bytes + +
+ + +
+
+
+ ) : null} +
+ ); + })} + {error ? ( +
+ {error} +
+ ) : null} +
+ ); +} + +const ReplayEventRow = memo( + ({ + event, + generation, + sessionId, + target, + }: { + event: SessionEvent; + generation: string; + sessionId: string; + target: ExternalReplayTarget; + }) => { + const json = useMemo(() => JSON.stringify(event, null, 2), [event]); + return ( +
+
+ {event.id} + +
+
+          {json}
+        
+ +
+ ); + } +); + +ReplayEventRow.displayName = "ReplayEventRow"; + export interface SessionRawTranscriptContentProps { + entries: SessionEvent[]; error: string | null; filePath?: string; - loaded: boolean; loading: boolean; + loadingOlder: boolean; + onLoadOlder: () => void; + snapshot: RawTranscriptSnapshot | null; transcriptJson: string; } const SessionRawTranscriptContent: React.FC = - memo(({ error, filePath, loaded, loading, transcriptJson }) => { - const { t } = useTranslation("common"); + memo( + ({ + entries, + error, + filePath, + loading, + loadingOlder, + onLoadOlder, + snapshot, + transcriptJson, + }) => { + const { t } = useTranslation("common"); + const replay = snapshot?.replay; + const replayTarget = + snapshot?.source.kind === "external-history" + ? snapshot.source.target + : null; + const [replayVirtualAnchor, setReplayVirtualAnchor] = + useState(null); + let replayFirstItemIndex = Math.max( + 0, + RAW_TRANSCRIPT_VIRTUAL_BASE_INDEX - entries.length + ); + if (replayTarget && replay) { + const nextAnchor = reconcileReplayVirtualAnchor(replayVirtualAnchor, { + sessionId: snapshot.sessionId, + generation: replay.cursor.generation, + revision: replay.cursor.revision, + throughSequence: replay.cursor.throughSequence, + newerContentReleased: Boolean(replay.newerContentReleased), + entries, + }); + replayFirstItemIndex = nextAnchor.firstItemIndex; + if (nextAnchor !== replayVirtualAnchor) { + // React's adjust-state-during-render pattern ensures Virtuoso never + // commits new rows with the previous logical index. + setReplayVirtualAnchor(nextAnchor); + } + } + const canRetryOlder = + Boolean(replay?.hasOlder) && + !loadingOlder && + (Boolean(error) || Boolean(replay?.olderPageNeedsRetry)); - return ( -
- {error ? ( -
- {error} -
- ) : null} - -
- ); - }); + return ( +
+ {error ? ( +
+ {error} +
+ ) : null} + {replayTarget && replay ? ( + event.id} + startReached={() => { + if ( + replay.hasOlder && + !loadingOlder && + !replay.olderPageNeedsRetry && + !error + ) { + onLoadOlder(); + } + }} + components={{ + Header: () => + loadingOlder || + replay.newerContentReleased || + canRetryOlder ? ( +
+ {loadingOlder ? ( + t("common:status.loading", { + defaultValue: "Loading…", + }) + ) : replay.newerContentReleased ? ( + t("sessions:chat.rawTranscript.newerReleased", { + defaultValue: + "Newer rows were released to keep memory bounded. Refresh to return to the latest turn.", + }) + ) : ( + + )} +
+ ) : null, + }} + itemContent={(_, event) => ( + + )} + /> + ) : ( + + )} +
+ ); + } + ); SessionRawTranscriptContent.displayName = "SessionRawTranscriptContent"; diff --git a/src/engines/ChatPanel/components/SessionRawTranscriptDialog/index.tsx b/src/engines/ChatPanel/components/SessionRawTranscriptDialog/index.tsx index 48a9fd6ae..52ee0c046 100644 --- a/src/engines/ChatPanel/components/SessionRawTranscriptDialog/index.tsx +++ b/src/engines/ChatPanel/components/SessionRawTranscriptDialog/index.tsx @@ -1,8 +1,9 @@ -import { Clipboard, RefreshCw } from "lucide-react"; -import React, { memo } from "react"; +import { Clipboard, FileDown, RefreshCw } from "lucide-react"; +import React, { memo, useCallback, useState } from "react"; import { useTranslation } from "react-i18next"; import Button from "@src/components/Button"; +import Message from "@src/components/Message"; import Modal from "@src/scaffold/ModalSystem"; import SessionRawTranscriptContent from "./SessionRawTranscriptContent"; @@ -18,6 +19,30 @@ const SessionRawTranscriptDialog: React.FC = memo(({ sessionId, visible, onClose }) => { const { t } = useTranslation("sessions"); const transcript = useSessionRawTranscript(sessionId, visible); + const [exporting, setExporting] = useState(false); + const isExternal = transcript.snapshot?.source.kind === "external-history"; + + const handleExportAll = useCallback(async () => { + if (!sessionId || !isExternal) return; + setExporting(true); + try { + const result = await transcript.exportTranscript(); + if (!result) return; + Message.success( + t("chat.rawTranscript.exportSuccess", { + defaultValue: "Raw transcript exported", + }) + ); + } catch { + Message.error( + t("chat.rawTranscript.exportFailed", { + defaultValue: "Could not export the raw transcript", + }) + ); + } finally { + setExporting(false); + } + }, [isExternal, sessionId, t, transcript]); return ( = + {isExternal ? ( + + ) : null} @@ -56,12 +105,15 @@ const SessionRawTranscriptDialog: React.FC = >
void transcript.loadOlder()} + snapshot={transcript.snapshot} transcriptJson={transcript.transcriptJson} />
diff --git a/src/engines/ChatPanel/components/SessionRawTranscriptDialog/replayVirtualAnchor.test.ts b/src/engines/ChatPanel/components/SessionRawTranscriptDialog/replayVirtualAnchor.test.ts new file mode 100644 index 000000000..4fa2bb893 --- /dev/null +++ b/src/engines/ChatPanel/components/SessionRawTranscriptDialog/replayVirtualAnchor.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; + +import { + RAW_TRANSCRIPT_VIRTUAL_BASE_INDEX, + type ReplayVirtualAnchorInput, + reconcileReplayVirtualAnchor, +} from "./replayVirtualAnchor"; + +function input( + ids: string[], + overrides: Partial = {} +): ReplayVirtualAnchorInput { + return { + sessionId: "codexapp-session-1", + generation: "g1", + revision: 1, + throughSequence: 2_000, + newerContentReleased: false, + entries: ids.map((id) => ({ id })), + ...overrides, + }; +} + +describe("Raw Transcript replay virtual anchor", () => { + it("decreases by the rows actually prepended", () => { + const current = reconcileReplayVirtualAnchor( + null, + input(["event-10", "event-11"]) + ); + const next = reconcileReplayVirtualAnchor( + current, + input(["event-8", "event-9", "event-10", "event-11"], { + throughSequence: 9, + }) + ); + + expect(next.firstItemIndex).toBe(current.firstItemIndex - 2); + }); + + it("keeps decreasing when the bounded window length stays constant", () => { + const latestIds = Array.from( + { length: 1_000 }, + (_, index) => `event-${1_000 + index}` + ); + const current = reconcileReplayVirtualAnchor(null, input(latestIds)); + const boundedOlderIds = Array.from( + { length: 1_000 }, + (_, index) => `event-${800 + index}` + ); + const next = reconcileReplayVirtualAnchor( + current, + input(boundedOlderIds, { + revision: 2, + throughSequence: 999, + newerContentReleased: true, + }) + ); + + expect(next.entries).toHaveLength(current.entries.length); + expect(next.firstItemIndex).toBe(current.firstItemIndex - 200); + }); + + it("resets when the source returns to a newer or replacement window", () => { + const current = reconcileReplayVirtualAnchor( + null, + input(["old-1", "latest-1"], { + throughSequence: 10, + newerContentReleased: true, + }) + ); + const latest = input(["latest-1", "latest-2"], { + throughSequence: 20, + newerContentReleased: false, + }); + const reset = reconcileReplayVirtualAnchor(current, latest); + + expect(reset.firstItemIndex).toBe( + RAW_TRANSCRIPT_VIRTUAL_BASE_INDEX - latest.entries.length + ); + + const replacement = input(["replacement"], { + generation: "g2", + revision: 1, + throughSequence: 0, + }); + expect( + reconcileReplayVirtualAnchor(reset, replacement).firstItemIndex + ).toBe(RAW_TRANSCRIPT_VIRTUAL_BASE_INDEX - 1); + }); +}); diff --git a/src/engines/ChatPanel/components/SessionRawTranscriptDialog/replayVirtualAnchor.ts b/src/engines/ChatPanel/components/SessionRawTranscriptDialog/replayVirtualAnchor.ts new file mode 100644 index 000000000..347007d0a --- /dev/null +++ b/src/engines/ChatPanel/components/SessionRawTranscriptDialog/replayVirtualAnchor.ts @@ -0,0 +1,73 @@ +export const RAW_TRANSCRIPT_VIRTUAL_BASE_INDEX = 1_000_000_000; + +interface ReplayVirtualEntry { + id: string; +} + +export interface ReplayVirtualAnchorInput { + sessionId: string; + generation: string; + revision: number; + throughSequence: number; + newerContentReleased: boolean; + entries: readonly ReplayVirtualEntry[]; +} + +export interface ReplayVirtualAnchorState extends ReplayVirtualAnchorInput { + firstItemIndex: number; +} + +function resetReplayVirtualAnchor( + input: ReplayVirtualAnchorInput +): ReplayVirtualAnchorState { + return { + ...input, + firstItemIndex: Math.max( + 0, + RAW_TRANSCRIPT_VIRTUAL_BASE_INDEX - input.entries.length + ), + }; +} + +/** + * Keep Virtuoso's logical first index aligned with the number of rows that + * were actually prepended, not the size of the bounded in-memory window. + * + * Once Raw Transcript reaches its event/byte cap, loading an older page adds a + * prefix and releases the same number of newer rows. `entries.length` then + * stays constant, but Virtuoso still needs a decreasing `firstItemIndex` to + * preserve the user's scroll anchor. + */ +export function reconcileReplayVirtualAnchor( + current: ReplayVirtualAnchorState | null, + input: ReplayVirtualAnchorInput +): ReplayVirtualAnchorState { + if (current?.entries === input.entries) return current; + + const sourceReset = + !current || + current.sessionId !== input.sessionId || + current.generation !== input.generation || + input.throughSequence > current.throughSequence || + (current.newerContentReleased && !input.newerContentReleased); + if (sourceReset) return resetReplayVirtualAnchor(input); + + const previousFirstId = current.entries[0]?.id; + if (!previousFirstId || input.entries.length === 0) { + return resetReplayVirtualAnchor(input); + } + + const prependedCount = input.entries.findIndex( + (entry) => entry.id === previousFirstId + ); + if (prependedCount < 0) { + // A same-generation refresh can replace an older browsing window with the + // latest bounded tail. With no shared leading anchor, treat it as a reset. + return resetReplayVirtualAnchor(input); + } + + return { + ...input, + firstItemIndex: Math.max(0, current.firstItemIndex - prependedCount), + }; +} diff --git a/src/engines/ChatPanel/components/SessionRawTranscriptDialog/transcript.serialization.test.ts b/src/engines/ChatPanel/components/SessionRawTranscriptDialog/transcript.serialization.test.ts new file mode 100644 index 000000000..da7e55c31 --- /dev/null +++ b/src/engines/ChatPanel/components/SessionRawTranscriptDialog/transcript.serialization.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; + +import { canCopyRawTranscript, stringifyJsonArrayBounded } from "./transcript"; + +describe("bounded raw transcript serialization", () => { + it("never labels a released external window as Copy All complete", () => { + const base = { + sessionId: "codexapp-session-1", + source: { + kind: "external-history" as const, + sourceId: "codex_app", + displayName: "Codex App", + target: { + sourceId: "codex_app" as const, + sessionId: "codexapp-session-1", + }, + }, + loadedAt: "2026-07-18T00:00:03.000Z", + entries: [], + replay: { + cursor: { + sourceId: "codex_app" as const, + sessionId: "codexapp-session-1", + generation: "g1", + revision: 1, + throughSequence: 2, + }, + windowStartSequence: 2, + turnHeaders: [], + totalTurnCount: 2, + hasOlder: false, + ipcBytes: 100, + }, + }; + + expect(canCopyRawTranscript(base, 1_024)).toBe(true); + expect( + canCopyRawTranscript( + { + ...base, + replay: { ...base.replay, newerContentReleased: true }, + }, + 1_024 + ) + ).toBe(false); + expect( + canCopyRawTranscript( + { ...base, replay: { ...base.replay, hasOlder: true } }, + 1_024 + ) + ).toBe(false); + }); + + it("pretty-prints a small array within the requested byte budget", () => { + const serialized = stringifyJsonArrayBounded( + [{ id: "one", text: "hello" }], + 1_024 + ); + + expect(serialized).not.toBeNull(); + expect(JSON.parse(serialized!)).toEqual([{ id: "one", text: "hello" }]); + expect( + new TextEncoder().encode(serialized!).byteLength + ).toBeLessThanOrEqual(1_024); + }); + + it("fails before joining a Copy All value beyond the hard budget", () => { + expect( + stringifyJsonArrayBounded([{ text: "x".repeat(4_096) }], 1_024) + ).toBeNull(); + }); +}); diff --git a/src/engines/ChatPanel/components/SessionRawTranscriptDialog/transcript.test.ts b/src/engines/ChatPanel/components/SessionRawTranscriptDialog/transcript.test.ts index 10fe088e6..518dbd498 100644 --- a/src/engines/ChatPanel/components/SessionRawTranscriptDialog/transcript.test.ts +++ b/src/engines/ChatPanel/components/SessionRawTranscriptDialog/transcript.test.ts @@ -2,10 +2,18 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { SessionEvent } from "@src/engines/SessionCore/core/types"; -import { loadRawSessionTranscript, mergeRawSessionEvents } from "./transcript"; +import { + type RawTranscriptSnapshot, + hasSameRawReplayVersion, + loadOlderRawSessionTranscript, + loadRawSessionTranscript, + mergeRawSessionEvents, +} from "./transcript"; const mocks = vi.hoisted(() => ({ getImportedHistorySourceBySessionId: vi.fn(), + resolveSecondaryReplayTarget: vi.fn(), + externalReplayQueryWindowForTarget: vi.fn(), getPersistedEvents: vi.fn(), getEvents: vi.fn(), })); @@ -15,6 +23,11 @@ vi.mock("@src/api/tauri/externalHistory", () => ({ mocks.getImportedHistorySourceBySessionId, })); +vi.mock("@src/api/tauri/externalHistory/replay", () => ({ + resolveSecondaryReplayTarget: mocks.resolveSecondaryReplayTarget, + externalReplayQueryWindowForTarget: mocks.externalReplayQueryWindowForTarget, +})); + vi.mock("@src/engines/SessionCore/core/store/EventStoreProxy", () => ({ eventStoreProxy: { getPersistedEvents: mocks.getPersistedEvents, @@ -50,6 +63,7 @@ describe("raw session transcript loading", () => { beforeEach(() => { vi.resetAllMocks(); mocks.getImportedHistorySourceBySessionId.mockReturnValue(undefined); + mocks.resolveSecondaryReplayTarget.mockResolvedValue(null); }); it("keeps durable history, overlays live updates, and excludes other sessions", () => { @@ -71,30 +85,646 @@ describe("raw session transcript loading", () => { ]); }); - it("loads the original full transcript for an externally imported session", async () => { - const rawChunks = [ - { type: "user", message: { content: "hello" } }, - { type: "assistant", message: { content: "hi" } }, + it("loads only the bounded replay window for an externally imported session", async () => { + const entries = [ + event("1", "codexapp-session-1", "2026-07-18T00:00:00.000Z", "hello"), + event("2", "codexapp-session-1", "2026-07-18T00:00:01.000Z", "hi"), ]; - const loadFullTranscriptChunks = vi.fn().mockResolvedValue(rawChunks); + const target = { + sourceId: "codex_app", + sessionId: "codexapp-session-1", + } as const; + mocks.resolveSecondaryReplayTarget.mockResolvedValue(target); mocks.getImportedHistorySourceBySessionId.mockReturnValue({ - sourceId: "codex-app", + sourceId: "codex_app", displayName: "Codex App", - loadFullTranscriptChunks, + }); + mocks.externalReplayQueryWindowForTarget.mockResolvedValue({ + cursor: { + sourceId: "codex_app", + sessionId: "codexapp-session-1", + generation: "g1", + revision: 1, + throughSequence: 9, + }, + events: entries, + windowStartSequence: 8, + turnHeaders: [ + { + turnId: "turn-1", + turnIndex: 1, + startSequence: 8, + endSequence: null, + startedAt: "2026-07-18T00:00:00.000Z", + endedAt: null, + eventCount: 2, + }, + ], + totalEventCount: entries.length, + totalTurnCount: 8, + hasOlder: true, + watcherAvailable: false, + stats: { + parsedBytes: 0, + parsedRows: 0, + normalizedEvents: 2, + upsertedEvents: 2, + removedEvents: 0, + ipcBytes: 2048, + notReady: false, + }, }); const snapshot = await loadRawSessionTranscript("codexapp-session-1"); - expect(loadFullTranscriptChunks).toHaveBeenCalledWith("codexapp-session-1"); + expect(mocks.externalReplayQueryWindowForTarget).toHaveBeenCalledWith({ + target, + }); expect(snapshot.source).toEqual({ kind: "external-history", - sourceId: "codex-app", + sourceId: "codex_app", displayName: "Codex App", + target, }); - expect(snapshot.entries).toBe(rawChunks); + expect(snapshot.entries).toBe(entries); + expect(snapshot.replay?.hasOlder).toBe(true); expect(mocks.getPersistedEvents).not.toHaveBeenCalled(); }); + it("loads a snapshot-backed native fork without hydrating EventStore history", async () => { + const target = { + sourceId: "collaboration_snapshot" as const, + sessionId: "agentsession-cloud-fork", + }; + const entries = [ + event( + "fork~assistant", + target.sessionId, + "2026-07-18T00:00:01.000Z", + "bounded fork row" + ), + ]; + mocks.resolveSecondaryReplayTarget.mockResolvedValue(target); + mocks.externalReplayQueryWindowForTarget.mockResolvedValue({ + cursor: { + ...target, + generation: "snapshot-g1", + revision: 2, + throughSequence: 10, + }, + events: entries, + windowStartSequence: 10, + turnHeaders: [], + totalEventCount: 5_000, + totalTurnCount: 200, + hasOlder: true, + watcherAvailable: false, + stats: { ipcBytes: 1_024 }, + }); + + const snapshot = await loadRawSessionTranscript(target.sessionId); + + expect(snapshot.source).toEqual({ + kind: "external-history", + sourceId: "collaboration_snapshot", + displayName: "collaboration snapshot", + target, + }); + expect(snapshot.entries).toBe(entries); + expect(mocks.externalReplayQueryWindowForTarget).toHaveBeenCalledWith({ + target, + }); + expect(mocks.getPersistedEvents).not.toHaveBeenCalled(); + expect(mocks.getEvents).not.toHaveBeenCalled(); + }); + + it("prepends older replay pages and reopens the latest window on generation or revision changes", async () => { + const current = event( + "new", + "codexapp-session-1", + "2026-07-18T00:00:02.000Z", + "new" + ); + const older = event( + "old", + "codexapp-session-1", + "2026-07-18T00:00:01.000Z", + "old" + ); + const snapshot = { + sessionId: "codexapp-session-1", + source: { + kind: "external-history" as const, + sourceId: "codex_app", + displayName: "Codex App", + target: { + sourceId: "codex_app" as const, + sessionId: "codexapp-session-1", + }, + }, + loadedAt: "2026-07-18T00:00:03.000Z", + entries: [current], + replay: { + cursor: { + sourceId: "codex_app" as const, + sessionId: "codexapp-session-1", + generation: "g1", + revision: 1, + throughSequence: 2, + }, + windowStartSequence: 2, + turnHeaders: [ + { + turnId: "new-turn", + turnIndex: 1, + startSequence: 2, + endSequence: null, + startedAt: current.createdAt, + endedAt: null, + eventCount: 1, + }, + ], + totalEventCount: 1, + totalTurnCount: 2, + hasOlder: true, + ipcBytes: 100, + }, + }; + mocks.externalReplayQueryWindowForTarget.mockResolvedValueOnce({ + cursor: { ...snapshot.replay.cursor, throughSequence: 1 }, + events: [older], + windowStartSequence: 1, + turnHeaders: [ + { + turnId: "old-turn", + turnIndex: 0, + startSequence: 0, + endSequence: 2, + startedAt: older.createdAt, + endedAt: older.createdAt, + eventCount: 1, + }, + ], + totalEventCount: 1, + totalTurnCount: 2, + hasOlder: false, + watcherAvailable: false, + stats: { ipcBytes: 80 }, + }); + + const paged = await loadOlderRawSessionTranscript(snapshot); + expect(mocks.externalReplayQueryWindowForTarget).toHaveBeenCalledWith({ + target: snapshot.source.target, + beforeSequence: 2, + }); + expect(paged.entries.map((item) => item.id)).toEqual(["old", "new"]); + expect(paged.replay?.ipcBytes).toBe(180); + + mocks.externalReplayQueryWindowForTarget.mockResolvedValueOnce({ + cursor: { + ...snapshot.replay.cursor, + generation: "g2", + throughSequence: 0, + }, + events: [older], + windowStartSequence: 0, + turnHeaders: [], + totalEventCount: 1, + totalTurnCount: 1, + hasOlder: false, + watcherAvailable: false, + stats: { ipcBytes: 75 }, + }); + const latestAfterGeneration = event( + "latest-g2", + snapshot.sessionId, + "2026-07-18T00:00:04.000Z", + "latest generation" + ); + mocks.externalReplayQueryWindowForTarget.mockResolvedValueOnce({ + cursor: { + ...snapshot.replay.cursor, + generation: "g2", + revision: 1, + throughSequence: 4, + }, + events: [latestAfterGeneration], + windowStartSequence: 4, + turnHeaders: [], + totalEventCount: 1, + totalTurnCount: 1, + hasOlder: false, + watcherAvailable: false, + stats: { ipcBytes: 90 }, + }); + const reset = await loadOlderRawSessionTranscript(snapshot); + expect(reset.entries.map((item) => item.id)).toEqual(["latest-g2"]); + expect(reset.replay?.ipcBytes).toBe(90); + expect(mocks.externalReplayQueryWindowForTarget).toHaveBeenLastCalledWith({ + target: snapshot.source.target, + }); + + mocks.externalReplayQueryWindowForTarget.mockResolvedValueOnce({ + cursor: { + ...snapshot.replay.cursor, + revision: 2, + throughSequence: 1, + }, + events: [older], + windowStartSequence: 1, + turnHeaders: [], + totalEventCount: 1, + totalTurnCount: 2, + hasOlder: false, + watcherAvailable: false, + stats: { ipcBytes: 75 }, + }); + const latestAfterRevision = event( + "latest-r2", + snapshot.sessionId, + "2026-07-18T00:00:05.000Z", + "latest revision" + ); + mocks.externalReplayQueryWindowForTarget.mockResolvedValueOnce({ + cursor: { + ...snapshot.replay.cursor, + revision: 2, + throughSequence: 5, + }, + events: [latestAfterRevision], + windowStartSequence: 5, + turnHeaders: [], + totalEventCount: 1, + totalTurnCount: 2, + hasOlder: true, + watcherAvailable: false, + stats: { ipcBytes: 95 }, + }); + const revisionReset = await loadOlderRawSessionTranscript(snapshot); + expect(revisionReset.entries.map((item) => item.id)).toEqual(["latest-r2"]); + expect(revisionReset.replay?.cursor.revision).toBe(2); + }); + + it("pages through one oversized turn from the actual returned sequence", async () => { + const target = { + sourceId: "managed_cli" as const, + sessionId: "cliagent-large-turn", + }; + const latest = event( + "event-251", + target.sessionId, + "2026-07-18T00:00:02.000Z", + "latest slice" + ); + let snapshot: RawTranscriptSnapshot = { + sessionId: target.sessionId, + source: { + kind: "external-history", + sourceId: target.sourceId, + displayName: "Managed CLI", + target, + }, + loadedAt: "2026-07-18T00:00:03.000Z", + entries: [latest], + replay: { + cursor: { + ...target, + generation: "g1", + revision: 11, + throughSequence: 450, + }, + windowStartSequence: 251, + turnHeaders: [ + { + turnId: "user-0", + turnIndex: 0, + startSequence: 0, + endSequence: 450, + startedAt: "2026-07-18T00:00:00.000Z", + endedAt: "2026-07-18T00:00:02.000Z", + eventCount: 451, + }, + ], + totalTurnCount: 1, + hasOlder: true, + ipcBytes: 100, + }, + }; + const oldest = event( + "event-0", + target.sessionId, + "2026-07-18T00:00:00.000Z", + "oldest slice" + ); + const replay = snapshot.replay!; + mocks.externalReplayQueryWindowForTarget + .mockResolvedValueOnce({ + cursor: { ...replay.cursor, throughSequence: 250 }, + // This page was scanned but every row was intentionally filtered by + // normalization. Its source boundary must still advance pagination. + events: [], + windowStartSequence: 51, + turnHeaders: replay.turnHeaders, + totalEventCount: 451, + totalTurnCount: 1, + hasOlder: true, + watcherAvailable: false, + stats: { ipcBytes: 100 }, + }) + .mockResolvedValueOnce({ + cursor: { ...replay.cursor, throughSequence: 50 }, + events: [oldest], + windowStartSequence: 0, + turnHeaders: replay.turnHeaders, + totalEventCount: 451, + totalTurnCount: 1, + hasOlder: false, + watcherAvailable: false, + stats: { ipcBytes: 100 }, + }); + + snapshot = await loadOlderRawSessionTranscript(snapshot); + expect(mocks.externalReplayQueryWindowForTarget).toHaveBeenNthCalledWith( + 1, + { + target, + beforeSequence: 251, + } + ); + expect(mocks.externalReplayQueryWindowForTarget).toHaveBeenNthCalledWith( + 2, + { + target, + beforeSequence: 51, + } + ); + expect(mocks.externalReplayQueryWindowForTarget).toHaveBeenLastCalledWith({ + target, + beforeSequence: 51, + }); + expect(snapshot.entries.map((entry) => entry.id)).toEqual([ + "event-0", + "event-251", + ]); + expect(snapshot.replay?.hasOlder).toBe(false); + }); + + it("bounds empty backward-page scans and leaves an explicit retry state", async () => { + const target = { + sourceId: "managed_cli" as const, + sessionId: "cliagent-filtered-pages", + }; + const latest = event( + "event-500", + target.sessionId, + "2026-07-18T00:00:05.000Z", + "latest" + ); + const snapshot: RawTranscriptSnapshot = { + sessionId: target.sessionId, + source: { + kind: "external-history", + sourceId: target.sourceId, + displayName: "Managed CLI", + target, + }, + loadedAt: "2026-07-18T00:00:06.000Z", + entries: [latest], + replay: { + cursor: { + ...target, + generation: "g1", + revision: 3, + throughSequence: 500, + }, + windowStartSequence: 500, + turnHeaders: [], + totalTurnCount: 10, + hasOlder: true, + ipcBytes: 10, + }, + }; + for (const boundary of [400, 300, 200, 100]) { + mocks.externalReplayQueryWindowForTarget.mockResolvedValueOnce({ + cursor: { + ...snapshot.replay!.cursor, + throughSequence: boundary - 1, + }, + events: [], + windowStartSequence: boundary, + turnHeaders: [], + totalEventCount: 501, + totalTurnCount: 10, + hasOlder: true, + watcherAvailable: false, + stats: { ipcBytes: 5 }, + }); + } + + const paged = await loadOlderRawSessionTranscript(snapshot); + + expect(mocks.externalReplayQueryWindowForTarget).toHaveBeenCalledTimes(4); + expect(paged.entries).toEqual([latest]); + expect(paged.replay?.windowStartSequence).toBe(100); + expect(paged.replay?.ipcBytes).toBe(30); + expect(paged.replay?.olderPageNeedsRetry).toBe(true); + }); + + it("rejects an empty backward page that does not advance its source boundary", async () => { + const target = { + sourceId: "managed_cli" as const, + sessionId: "cliagent-stalled-page", + }; + const snapshot: RawTranscriptSnapshot = { + sessionId: target.sessionId, + source: { + kind: "external-history", + sourceId: target.sourceId, + displayName: "Managed CLI", + target, + }, + loadedAt: "2026-07-18T00:00:06.000Z", + entries: [ + event( + "event-500", + target.sessionId, + "2026-07-18T00:00:05.000Z", + "latest" + ), + ], + replay: { + cursor: { + ...target, + generation: "g1", + revision: 3, + throughSequence: 500, + }, + windowStartSequence: 500, + turnHeaders: [], + totalTurnCount: 10, + hasOlder: true, + ipcBytes: 10, + }, + }; + mocks.externalReplayQueryWindowForTarget.mockResolvedValueOnce({ + cursor: { ...snapshot.replay!.cursor, throughSequence: 499 }, + events: [], + windowStartSequence: 500, + turnHeaders: [], + totalEventCount: 501, + totalTurnCount: 10, + hasOlder: true, + watcherAvailable: false, + stats: { ipcBytes: 5 }, + }); + + await expect(loadOlderRawSessionTranscript(snapshot)).rejects.toThrow( + "made no progress" + ); + }); + + it("matches raw replay snapshots only within the same immutable version", () => { + const target = { + sourceId: "codex_app" as const, + sessionId: "codexapp-version", + }; + const snapshot: RawTranscriptSnapshot = { + sessionId: target.sessionId, + source: { + kind: "external-history", + sourceId: target.sourceId, + displayName: "Codex App", + target, + }, + loadedAt: "2026-07-18T00:00:06.000Z", + entries: [], + replay: { + cursor: { + ...target, + generation: "g1", + revision: 7, + throughSequence: 10, + }, + windowStartSequence: 10, + turnHeaders: [], + totalTurnCount: 1, + hasOlder: false, + ipcBytes: 10, + }, + }; + + expect(hasSameRawReplayVersion(snapshot, snapshot)).toBe(true); + expect( + hasSameRawReplayVersion(snapshot, { + ...snapshot, + replay: { + ...snapshot.replay!, + cursor: { ...snapshot.replay!.cursor, revision: 8 }, + }, + }) + ).toBe(false); + expect( + hasSameRawReplayVersion(snapshot, { + ...snapshot, + replay: { + ...snapshot.replay!, + cursor: { ...snapshot.replay!.cursor, generation: "g2" }, + }, + }) + ).toBe(false); + }); + + it("retains only the current backward page headers across many pages", async () => { + const target = { + sourceId: "codex_app" as const, + sessionId: "codexapp-session-many-pages", + }; + let snapshot: RawTranscriptSnapshot = { + sessionId: target.sessionId, + source: { + kind: "external-history" as const, + sourceId: target.sourceId, + displayName: "Codex App", + target, + }, + loadedAt: "2026-07-18T00:00:50.000Z", + entries: [ + event( + "event-50", + target.sessionId, + "2026-07-18T00:00:50.000Z", + "page 50" + ), + ], + replay: { + cursor: { + ...target, + generation: "g1", + revision: 1, + throughSequence: 50, + }, + windowStartSequence: 50, + turnHeaders: [ + { + turnId: "turn-50", + turnIndex: 50, + startSequence: 50, + endSequence: null, + startedAt: "2026-07-18T00:00:50.000Z", + endedAt: null, + eventCount: 1, + }, + ], + totalTurnCount: 51, + hasOlder: true, + ipcBytes: 100, + }, + }; + mocks.externalReplayQueryWindowForTarget.mockImplementation( + async ({ beforeSequence }: { beforeSequence?: number }) => { + const sequence = (beforeSequence ?? 1) - 1; + const createdAt = `2026-07-18T00:00:${String(sequence).padStart(2, "0")}.000Z`; + return { + cursor: { + ...snapshot.replay!.cursor, + throughSequence: sequence, + }, + events: [ + event( + `event-${sequence}`, + target.sessionId, + createdAt, + `page ${sequence}` + ), + ], + windowStartSequence: sequence, + turnHeaders: [ + { + turnId: `turn-${sequence}`, + turnIndex: sequence, + startSequence: sequence, + endSequence: beforeSequence ?? null, + startedAt: createdAt, + endedAt: createdAt, + eventCount: 1, + }, + ], + totalEventCount: 51, + totalTurnCount: 51, + hasOlder: sequence > 0, + watcherAvailable: false, + stats: { ipcBytes: 100 }, + }; + } + ); + + for (let page = 0; page < 30; page += 1) { + snapshot = await loadOlderRawSessionTranscript(snapshot); + expect(snapshot.replay?.turnHeaders).toHaveLength(1); + } + expect(snapshot.replay?.turnHeaders[0]?.startSequence).toBe(20); + }); + it("merges durable and in-memory EventStore data for an ORGII session", async () => { mocks.getPersistedEvents.mockResolvedValue([ event("1", "session-a", "2026-07-18T00:00:00.000Z", "persisted"), @@ -110,6 +740,10 @@ describe("raw session transcript loading", () => { kind: "orgii-event-store", displayName: "ORGII EventStore", }); + expect(mocks.resolveSecondaryReplayTarget).toHaveBeenCalledWith( + "session-a" + ); + expect(mocks.externalReplayQueryWindowForTarget).not.toHaveBeenCalled(); expect( (snapshot.entries as SessionEvent[]).map((item) => [ item.id, diff --git a/src/engines/ChatPanel/components/SessionRawTranscriptDialog/transcript.ts b/src/engines/ChatPanel/components/SessionRawTranscriptDialog/transcript.ts index be57bddff..f99e4bff2 100644 --- a/src/engines/ChatPanel/components/SessionRawTranscriptDialog/transcript.ts +++ b/src/engines/ChatPanel/components/SessionRawTranscriptDialog/transcript.ts @@ -1,4 +1,11 @@ import { getImportedHistorySourceBySessionId } from "@src/api/tauri/externalHistory"; +import { + type ExternalReplayCursor, + type ExternalReplayTarget, + type ExternalReplayWindow, + externalReplayQueryWindowForTarget, + resolveSecondaryReplayTarget, +} from "@src/api/tauri/externalHistory/replay"; import { eventStoreProxy } from "@src/engines/SessionCore/core/store/EventStoreProxy"; import type { SessionEvent } from "@src/engines/SessionCore/core/types"; @@ -7,6 +14,7 @@ export type RawTranscriptSource = kind: "external-history"; sourceId: string; displayName: string; + target: ExternalReplayTarget; } | { kind: "orgii-event-store"; @@ -17,7 +25,66 @@ export interface RawTranscriptSnapshot { sessionId: string; source: RawTranscriptSource; loadedAt: string; - entries: unknown[]; + entries: SessionEvent[]; + replay?: RawTranscriptReplayState; +} + +export interface RawTranscriptReplayState { + cursor: ExternalReplayCursor; + windowStartSequence: number | null; + turnHeaders: ExternalReplayWindow["turnHeaders"]; + totalTurnCount: number; + hasOlder: boolean; + ipcBytes: number; + newerContentReleased?: boolean; + olderPageNeedsRetry?: boolean; +} + +const RAW_REPLAY_EVENT_BUDGET = 1_000; +const RAW_REPLAY_BYTE_BUDGET = 16 * 1024 * 1024; +const RAW_REPLAY_EMPTY_PAGE_ATTEMPTS = 4; + +export function canCopyRawTranscript( + snapshot: RawTranscriptSnapshot | null, + maxBytes: number +): boolean { + if (!snapshot) return false; + if (snapshot.source.kind !== "external-history") return true; + return Boolean( + snapshot.replay && + !snapshot.replay.hasOlder && + !snapshot.replay.newerContentReleased && + snapshot.replay.ipcBytes <= maxBytes + ); +} + +/** + * Pretty-print an array without ever joining a string larger than `maxBytes`. + * External replay uses this for the optional small-transcript Copy action; + * large histories must use the Rust streamed exporter instead. + */ +export function stringifyJsonArrayBounded( + entries: readonly unknown[], + maxBytes: number +): string | null { + if (!Number.isSafeInteger(maxBytes) || maxBytes < 4) return null; + const encoder = new TextEncoder(); + const pieces = ["[\n"]; + let bytes = 2; + + for (const [index, entry] of entries.entries()) { + const serialized = JSON.stringify(entry, null, 2); + if (serialized === undefined) return null; + const indented = serialized.replace(/^/gm, " "); + const piece = `${index === 0 ? "" : ",\n"}${indented}`; + const pieceBytes = encoder.encode(piece).byteLength; + if (bytes + pieceBytes + 2 > maxBytes) return null; + pieces.push(piece); + bytes += pieceBytes; + } + + pieces.push("\n]"); + return pieces.join(""); } export function mergeRawSessionEvents( @@ -38,21 +105,55 @@ export function mergeRawSessionEvents( }); } +export function hasSameRawReplayVersion( + left: RawTranscriptSnapshot | null, + right: RawTranscriptSnapshot +): boolean { + if ( + !left || + left.sessionId !== right.sessionId || + left.source.kind !== "external-history" || + right.source.kind !== "external-history" || + !left.replay || + !right.replay + ) { + return false; + } + return ( + left.replay.cursor.generation === right.replay.cursor.generation && + left.replay.cursor.revision === right.replay.cursor.revision + ); +} + export async function loadRawSessionTranscript( sessionId: string ): Promise { - const externalSource = getImportedHistorySourceBySessionId(sessionId); - if (externalSource) { - const entries = await externalSource.loadFullTranscriptChunks(sessionId); + const replayTarget = await resolveSecondaryReplayTarget(sessionId); + if (replayTarget) { + const externalSource = getImportedHistorySourceBySessionId(sessionId); + const window = await externalReplayQueryWindowForTarget({ + target: replayTarget, + }); return { sessionId, source: { kind: "external-history", - sourceId: externalSource.sourceId, - displayName: externalSource.displayName, + sourceId: replayTarget.sourceId, + displayName: + externalSource?.displayName ?? + replayTarget.sourceId.replace(/_/g, " "), + target: replayTarget, }, loadedAt: new Date().toISOString(), - entries, + entries: window.events, + replay: { + cursor: window.cursor, + windowStartSequence: window.windowStartSequence, + turnHeaders: window.turnHeaders, + totalTurnCount: window.totalTurnCount, + hasOlder: window.hasOlder, + ipcBytes: window.stats.ipcBytes, + }, }; } @@ -80,3 +181,136 @@ export async function loadRawSessionTranscript( entries: mergeRawSessionEvents(persistedEvents, liveEvents, sessionId), }; } + +function mergeReplayEvents( + older: SessionEvent[], + current: SessionEvent[], + anticipatedBytes: number +): { entries: SessionEvent[]; bytes: number; released: boolean } { + const seen = new Set(); + const merged: SessionEvent[] = []; + for (const event of [...older, ...current]) { + if (seen.has(event.id)) continue; + seen.add(event.id); + merged.push(event); + } + if ( + merged.length <= RAW_REPLAY_EVENT_BUDGET && + anticipatedBytes <= RAW_REPLAY_BYTE_BUDGET + ) { + return { entries: merged, bytes: anticipatedBytes, released: false }; + } + + // Keep the page the user is currently scrolling into (the older prefix) + // and release the far-away newer tail. Each event is serialized alone, so + // the budget check never creates a session-sized intermediate string. + const bounded: SessionEvent[] = []; + let bytes = 0; + const encoder = new TextEncoder(); + for (const event of merged) { + const eventBytes = encoder.encode(JSON.stringify(event)).byteLength; + if ( + bounded.length >= RAW_REPLAY_EVENT_BUDGET || + (bounded.length > 0 && bytes + eventBytes > RAW_REPLAY_BYTE_BUDGET) + ) { + break; + } + bounded.push(event); + bytes += eventBytes; + } + return { entries: bounded, bytes, released: bounded.length < merged.length }; +} + +/** + * Read the preceding bounded replay page. The generation check is mandatory: + * a truncate/replace between pages must reset the virtual transcript instead + * of joining rows from two different source files. + */ +export async function loadOlderRawSessionTranscript( + snapshot: RawTranscriptSnapshot +): Promise { + if ( + snapshot.source.kind !== "external-history" || + !snapshot.replay?.hasOlder + ) { + return snapshot; + } + const oldestSequence = snapshot.replay.windowStartSequence; + if (oldestSequence === null) return snapshot; + + let beforeSequence = oldestSequence; + let scannedIpcBytes = 0; + let window: ExternalReplayWindow | null = null; + for ( + let attempt = 0; + attempt < RAW_REPLAY_EMPTY_PAGE_ATTEMPTS; + attempt += 1 + ) { + const candidate = await externalReplayQueryWindowForTarget({ + target: snapshot.source.target, + beforeSequence, + }); + const sourceChanged = + candidate.cursor.generation !== snapshot.replay.cursor.generation || + candidate.cursor.revision !== snapshot.replay.cursor.revision; + if (sourceChanged) { + // A stale `beforeSequence` belongs to the old immutable snapshot. + // Re-open the latest bounded window instead of presenting an arbitrary + // older page from the new generation/revision as the canonical reset. + const latest = await externalReplayQueryWindowForTarget({ + target: snapshot.source.target, + }); + return { + ...snapshot, + loadedAt: new Date().toISOString(), + entries: latest.events, + replay: { + cursor: latest.cursor, + windowStartSequence: latest.windowStartSequence, + turnHeaders: latest.turnHeaders, + totalTurnCount: latest.totalTurnCount, + hasOlder: latest.hasOlder, + ipcBytes: latest.stats.ipcBytes, + newerContentReleased: false, + olderPageNeedsRetry: false, + }, + }; + } + window = candidate; + scannedIpcBytes += candidate.stats.ipcBytes; + if (candidate.events.length > 0 || !candidate.hasOlder) break; + const nextBoundary = candidate.windowStartSequence; + if (nextBoundary === null || nextBoundary >= beforeSequence) { + throw new Error( + `Raw transcript pagination made no progress before sequence ${beforeSequence}` + ); + } + beforeSequence = nextBoundary; + } + if (!window) return snapshot; + + const merged = mergeReplayEvents( + window.events, + snapshot.entries, + snapshot.replay.ipcBytes + scannedIpcBytes + ); + return { + ...snapshot, + loadedAt: new Date().toISOString(), + entries: merged.entries, + replay: { + cursor: window.cursor, + windowStartSequence: window.windowStartSequence, + // Only the newly loaded page's oldest boundary is needed for the next + // backward query. Retaining prior headers made metadata grow without + // bound even after the corresponding event rows had been released. + turnHeaders: window.turnHeaders, + totalTurnCount: window.totalTurnCount, + hasOlder: window.hasOlder, + ipcBytes: merged.bytes, + newerContentReleased: + snapshot.replay.newerContentReleased || merged.released, + olderPageNeedsRetry: window.events.length === 0 && window.hasOlder, + }, + }; +} diff --git a/src/engines/ChatPanel/components/SessionRawTranscriptDialog/useSessionRawTranscript.ts b/src/engines/ChatPanel/components/SessionRawTranscriptDialog/useSessionRawTranscript.ts index 443d8b03e..966c535b5 100644 --- a/src/engines/ChatPanel/components/SessionRawTranscriptDialog/useSessionRawTranscript.ts +++ b/src/engines/ChatPanel/components/SessionRawTranscriptDialog/useSessionRawTranscript.ts @@ -2,6 +2,7 @@ import { useAtomValue } from "jotai"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; +import { externalReplayStreamExportForTarget } from "@src/api/tauri/externalHistory/replay"; import Message from "@src/components/Message"; import { eventsAtom } from "@src/engines/SessionCore/core/atoms"; import type { SessionEvent } from "@src/engines/SessionCore/core/types"; @@ -9,8 +10,12 @@ import { copyText } from "@src/util/data/clipboard"; import { type RawTranscriptSnapshot, + canCopyRawTranscript, + hasSameRawReplayVersion, + loadOlderRawSessionTranscript, loadRawSessionTranscript, mergeRawSessionEvents, + stringifyJsonArrayBounded, } from "./transcript"; interface SessionRawTranscriptState { @@ -19,6 +24,8 @@ interface SessionRawTranscriptState { snapshot: RawTranscriptSnapshot | null; } +const COPY_ALL_MAX_BYTES = 4 * 1024 * 1024; + export function useSessionRawTranscript( sessionId: string | null, enabled = true @@ -26,12 +33,18 @@ export function useSessionRawTranscript( const { t } = useTranslation("sessions"); const liveEvents = useAtomValue(eventsAtom); const requestIdRef = useRef(0); + const loadingTranscriptRef = useRef(false); + const loadingOlderRef = useRef(false); const [state, setState] = useState(null); const [loadingSessionId, setLoadingSessionId] = useState(null); + const [loadingOlder, setLoadingOlder] = useState(false); const loadTranscript = useCallback(async () => { if (!sessionId) return; const requestId = ++requestIdRef.current; + loadingTranscriptRef.current = true; + loadingOlderRef.current = false; + setLoadingOlder(false); setLoadingSessionId(sessionId); setState((current) => current?.sessionId === sessionId @@ -51,7 +64,10 @@ export function useSessionRawTranscript( snapshot: null, }); } finally { - if (requestId === requestIdRef.current) setLoadingSessionId(null); + if (requestId === requestIdRef.current) { + loadingTranscriptRef.current = false; + setLoadingSessionId(null); + } } }, [sessionId]); @@ -77,14 +93,82 @@ export function useSessionRawTranscript( snapshot.sessionId ); }, [liveEvents, snapshot]); - const transcriptJson = useMemo( - () => JSON.stringify(entries, null, 2), - [entries] - ); + // Never build one session-sized JSON string for external replay. Native + // EventStore retains the existing editor behaviour; replay rows are + // serialized individually by the virtual list. + const transcriptJson = useMemo(() => { + if (snapshot?.source.kind === "external-history") return ""; + return JSON.stringify(entries, null, 2); + }, [entries, snapshot?.source.kind]); + + const canCopyAll = canCopyRawTranscript(snapshot, COPY_ALL_MAX_BYTES); + + const loadOlder = useCallback(async () => { + if ( + !snapshot?.replay?.hasOlder || + loadingOlderRef.current || + loadingTranscriptRef.current + ) { + return; + } + // Pagination owns the newest request token too. Without incrementing it, + // a page built from the pre-refresh snapshot can publish after Refresh + // has already replaced the visible generation/revision. + const requestId = ++requestIdRef.current; + loadingOlderRef.current = true; + setLoadingOlder(true); + try { + const next = await loadOlderRawSessionTranscript(snapshot); + if (requestId !== requestIdRef.current) return; + setState((current) => { + if ( + current?.sessionId !== snapshot.sessionId || + !hasSameRawReplayVersion(current.snapshot, snapshot) + ) { + return current; + } + return { error: null, sessionId: snapshot.sessionId, snapshot: next }; + }); + } catch (loadError) { + if (requestId !== requestIdRef.current) return; + setState((current) => { + if ( + current?.sessionId !== snapshot.sessionId || + !hasSameRawReplayVersion(current.snapshot, snapshot) + ) { + return current; + } + return { + error: + loadError instanceof Error ? loadError.message : String(loadError), + sessionId: snapshot.sessionId, + snapshot, + }; + }); + } finally { + if (requestId === requestIdRef.current) { + loadingOlderRef.current = false; + setLoadingOlder(false); + } + } + }, [snapshot]); const copyTranscript = useCallback(async () => { try { - await copyText(transcriptJson); + if (!snapshot || !canCopyAll) { + throw new Error("Transcript exceeds the Copy All memory budget"); + } + const copyValue = + snapshot.source.kind === "external-history" + ? stringifyJsonArrayBounded(entries, COPY_ALL_MAX_BYTES) + : transcriptJson; + if (copyValue === null) { + throw new Error("Transcript exceeds the Copy All memory budget"); + } + if (new TextEncoder().encode(copyValue).byteLength > COPY_ALL_MAX_BYTES) { + throw new Error("Transcript exceeds the Copy All memory budget"); + } + await copyText(copyValue); Message.success( t("chat.rawTranscript.copySuccess", { defaultValue: "Raw transcript copied", @@ -93,17 +177,34 @@ export function useSessionRawTranscript( } catch { Message.error( t("chat.rawTranscript.copyFailed", { - defaultValue: "Could not copy the raw transcript", + defaultValue: + "This transcript is too large to copy safely. Use Export All instead.", }) ); } - }, [t, transcriptJson]); + }, [canCopyAll, entries, snapshot, t, transcriptJson]); + + const exportTranscript = useCallback(async () => { + if (!snapshot || snapshot.source.kind !== "external-history") return null; + const safeSessionName = + snapshot.sessionId.replace(/[/\\?%*:|"<>]/g, "-").slice(0, 120) || + "session"; + return externalReplayStreamExportForTarget({ + target: snapshot.source.target, + suggestedFileName: `raw-transcript-${safeSessionName}.json`, + format: "json", + }); + }, [snapshot]); return { + canCopyAll, copyTranscript, entries, error, + exportTranscript, + loadOlder, loadTranscript, + loadingOlder, loading, snapshot, sourceLabel: snapshot?.source.displayName ?? "", diff --git a/src/engines/ChatPanel/components/SessionRawTranscriptView/index.tsx b/src/engines/ChatPanel/components/SessionRawTranscriptView/index.tsx index f3309e7a8..937d7371b 100644 --- a/src/engines/ChatPanel/components/SessionRawTranscriptView/index.tsx +++ b/src/engines/ChatPanel/components/SessionRawTranscriptView/index.tsx @@ -16,10 +16,13 @@ const SessionRawTranscriptView: React.FC = memo( className="flex min-h-0 flex-1 flex-col" > void transcript.loadOlder()} + snapshot={transcript.snapshot} transcriptJson={transcript.transcriptJson} /> diff --git a/src/engines/ChatPanel/externalHistoryFork.test.ts b/src/engines/ChatPanel/externalHistoryFork.test.ts index b45f274d0..1c79c98ce 100644 --- a/src/engines/ChatPanel/externalHistoryFork.test.ts +++ b/src/engines/ChatPanel/externalHistoryFork.test.ts @@ -4,19 +4,22 @@ import { type ImportedHistorySource, getImportedHistorySourceBySessionId, } from "@src/api/tauri/externalHistory"; +import { externalReplayHandoff } from "@src/api/tauri/externalHistory/replay"; import { SessionService } from "@src/engines/SessionCore/services/SessionService"; import { requestForkSessionSetup } from "@src/features/TeamCollaboration/forkSession"; import { resolveShareableScopeKeys } from "@src/features/TeamCollaboration/repoScopeResolver"; -import type { ActivityChunk } from "@src/types/session/session"; import { - buildExternalHistoryHandoffPrompt, + buildExternalHistoryHandoffPromptFromItems, forkExternalHistoryIntoOrgiiSession, } from "./externalHistoryFork"; vi.mock("@src/api/tauri/externalHistory", () => ({ getImportedHistorySourceBySessionId: vi.fn(), })); +vi.mock("@src/api/tauri/externalHistory/replay", () => ({ + externalReplayHandoff: vi.fn(), +})); vi.mock("@src/engines/SessionCore/services/SessionService", () => ({ SessionService: { create: vi.fn() }, })); @@ -27,37 +30,13 @@ vi.mock("@src/features/TeamCollaboration/repoScopeResolver", () => ({ resolveShareableScopeKeys: vi.fn(), })); -function chunk( - id: string, - actionType: string, - functionName: string, - result: Record -): ActivityChunk { - return { - chunk_id: id, - action_type: actionType, - function: functionName, - args: {}, - result, - created_at: "2026-07-13T00:00:00.000Z", - }; -} - describe("buildExternalHistoryHandoffPrompt", () => { - it("works for every registered source label and excludes private reasoning", () => { - const prompt = buildExternalHistoryHandoffPrompt( + it("wraps backend-folded semantic items in the existing safety prompt", () => { + const prompt = buildExternalHistoryHandoffPromptFromItems( [ - chunk("u1", "raw", "user_message", { message: "fix the sync" }), - chunk("r1", "reasoning", "thinking", { - content: "private chain of thought", - }), - { - ...chunk("t1", "tool_call", "read_file", { output: "old file" }), - args: { path: "src/sync.ts" }, - }, - chunk("a1", "assistant_message", "assistant_message", { - content: "I found the issue", - }), + "User: fix the sync", + "[Imported Claude App action]\nTool: read_file\nResult at that time: old file", + "Assistant: I found the issue", ], "continue and verify it", "Claude App" @@ -69,12 +48,13 @@ describe("buildExternalHistoryHandoffPrompt", () => { expect(prompt).toContain("Tool: read_file"); expect(prompt).toContain("Assistant: I found the issue"); expect(prompt).toContain("continue and verify it"); - expect(prompt).not.toContain("private chain of thought"); + expect(prompt).toContain( + "Reasoning/thinking chunks were intentionally skipped." + ); }); }); describe("forkExternalHistoryIntoOrgiiSession", () => { - const loadFullTranscriptChunks = vi.fn(); const source: ImportedHistorySource = { sourceId: "codex_app", listCategory: "external_history:codex_app", @@ -84,10 +64,7 @@ describe("forkExternalHistoryIntoOrgiiSession", () => { groupLabel: "Codex App", listable: true, replayable: true, - supportsWindowedReplay: false, dispatchCategory: "external_history", - loadPreviewChunks: vi.fn(), - loadFullTranscriptChunks, }; beforeEach(() => { @@ -104,9 +81,12 @@ describe("forkExternalHistoryIntoOrgiiSession", () => { model: "gpt-test", }, }); - loadFullTranscriptChunks.mockResolvedValue([ - chunk("u1", "user_message", "user_message", { message: "old ask" }), - ]); + vi.mocked(externalReplayHandoff).mockResolvedValue({ + items: ["User: old ask"], + generation: "generation-1", + scannedBytes: 1024, + scannedEvents: 1, + }); vi.mocked(SessionService.create).mockResolvedValue({ sessionId: "agentsession-forked", }); @@ -125,13 +105,14 @@ describe("forkExternalHistoryIntoOrgiiSession", () => { }, }; }); - loadFullTranscriptChunks.mockImplementation(async () => { + vi.mocked(externalReplayHandoff).mockImplementation(async () => { callOrder.push("transcript"); - return [ - chunk("u1", "user_message", "user_message", { - message: "old ask", - }), - ]; + return { + items: ["User: old ask"], + generation: "generation-1", + scannedBytes: 1024, + scannedEvents: 1, + }; }); const sessionId = await forkExternalHistoryIntoOrgiiSession({ @@ -157,6 +138,10 @@ describe("forkExternalHistoryIntoOrgiiSession", () => { sourceScopeKey: "github.com/org/repo", sourceModel: "gpt-source", }); + expect(externalReplayHandoff).toHaveBeenCalledWith({ + sessionId: "codexapp-source-1", + sourceName: "Codex App", + }); expect(SessionService.create).toHaveBeenCalledTimes(1); expect(SessionService.create).toHaveBeenCalledWith( expect.objectContaining({ @@ -187,7 +172,28 @@ describe("forkExternalHistoryIntoOrgiiSession", () => { userMessage: "continue", }) ).rejects.toThrow("cancelled"); - expect(loadFullTranscriptChunks).not.toHaveBeenCalled(); + expect(externalReplayHandoff).not.toHaveBeenCalled(); expect(SessionService.create).not.toHaveBeenCalled(); }); + + it("uses the backend's already-paged handoff items without receiving SessionEvents", async () => { + vi.mocked(externalReplayHandoff).mockResolvedValueOnce({ + items: ["User: usable older ask", "Assistant: current answer"], + generation: "generation-1", + scannedBytes: 4096, + scannedEvents: 12, + }); + + await forkExternalHistoryIntoOrgiiSession({ + sourceSessionId: "codexapp-source-1", + userMessage: "continue", + }); + + expect(externalReplayHandoff).toHaveBeenCalledTimes(1); + expect(SessionService.create).toHaveBeenCalledWith( + expect.objectContaining({ + task: expect.stringContaining("usable older ask"), + }) + ); + }); }); diff --git a/src/engines/ChatPanel/externalHistoryFork.ts b/src/engines/ChatPanel/externalHistoryFork.ts index 7debf2fdc..788cda357 100644 --- a/src/engines/ChatPanel/externalHistoryFork.ts +++ b/src/engines/ChatPanel/externalHistoryFork.ts @@ -1,95 +1,15 @@ import { getImportedHistorySourceBySessionId } from "@src/api/tauri/externalHistory"; +import { externalReplayHandoff } from "@src/api/tauri/externalHistory/replay"; import { SessionService } from "@src/engines/SessionCore/services/SessionService"; import { requestForkSessionSetup } from "@src/features/TeamCollaboration/forkSession"; import { resolveShareableScopeKeys } from "@src/features/TeamCollaboration/repoScopeResolver"; import type { Session } from "@src/store/session"; -import type { ActivityChunk } from "@src/types/session/session"; -const MAX_HISTORY_ITEMS = 80; -const MAX_TEXT_LENGTH = 1200; - -function textValue(value: unknown): string | undefined { - if (typeof value === "string") { - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; - } - if (Array.isArray(value)) { - const parts = value.map(textValue).filter(Boolean); - return parts.length > 0 ? parts.join("\n") : undefined; - } - if (value && typeof value === "object") { - const object = value as Record; - return ( - textValue(object.text) ?? - textValue(object.content) ?? - textValue(object.message) ?? - textValue(object.output) ?? - textValue(object.summary) - ); - } - return undefined; -} - -function truncateText(text: string): string { - return text.length > MAX_TEXT_LENGTH - ? `${text.slice(0, MAX_TEXT_LENGTH)}…` - : text; -} - -function summarizeToolChunk( - chunk: ActivityChunk, - sourceName: string -): string | undefined { - const functionName = chunk.function || "unknown_tool"; - const argsText = textValue(chunk.args); - const resultText = textValue(chunk.result); - const lines = [`[Imported ${sourceName} action]`, `Tool: ${functionName}`]; - if (argsText) lines.push(`Input: ${truncateText(argsText)}`); - if (resultText) - lines.push(`Result at that time: ${truncateText(resultText)}`); - return lines.join("\n"); -} - -function chunkToHandoffItem( - chunk: ActivityChunk, - sourceName: string -): string | undefined { - const actionType = chunk.action_type; - if (actionType.includes("thinking") || actionType.includes("reasoning")) { - return undefined; - } - - const resultText = textValue(chunk.result); - const argsText = textValue(chunk.args); - const content = resultText ?? argsText; - - if (actionType === "user_message" || chunk.function === "user_message") { - return content ? `User: ${truncateText(content)}` : undefined; - } - if ( - actionType === "assistant_message" || - actionType === "llm_response" || - chunk.function === "assistant_message" - ) { - return content ? `Assistant: ${truncateText(content)}` : undefined; - } - if (actionType === "tool_call" || actionType.includes("tool")) { - return summarizeToolChunk(chunk, sourceName); - } - - return content ? `Assistant context: ${truncateText(content)}` : undefined; -} - -export function buildExternalHistoryHandoffPrompt( - chunks: ActivityChunk[], +export function buildExternalHistoryHandoffPromptFromItems( + items: string[], userMessage: string, sourceName: string ): string { - const items = chunks - .map((chunk) => chunkToHandoffItem(chunk, sourceName)) - .filter((item): item is string => Boolean(item)) - .slice(-MAX_HISTORY_ITEMS); - return [ `You are continuing work from an imported ${sourceName} history inside a new ORGII-owned session.`, `The imported ${sourceName} history is read-only historical context. Do not treat its tool calls as ORGII-executed tools or current workspace state.`, @@ -131,9 +51,12 @@ export async function forkExternalHistoryIntoOrgiiSession(params: { sourceScopeKey: sourceScopeKeys?.[0], sourceModel: params.sourceSession?.model, }); - const chunks = await source.loadFullTranscriptChunks(params.sourceSessionId); - const content = buildExternalHistoryHandoffPrompt( - chunks, + const handoff = await externalReplayHandoff({ + sessionId: params.sourceSessionId, + sourceName: source.displayName, + }); + const content = buildExternalHistoryHandoffPromptFromItems( + handoff.items, params.userMessage, source.displayName ); diff --git a/src/engines/SessionCore/core/store/useSessionEvents.ts b/src/engines/SessionCore/core/store/useSessionEvents.ts index cf399ce9c..b4d4e5121 100644 --- a/src/engines/SessionCore/core/store/useSessionEvents.ts +++ b/src/engines/SessionCore/core/store/useSessionEvents.ts @@ -5,10 +5,8 @@ * events as nested blocks. The hook: * * 1. Subscribes to `es:changed` for the session via `subscribeSession` - * 2. For `cursoride-*` session ids (Cursor IDE history child composers), - * pre-warms the EventStore by reading bubbles from Cursor's SQLite via - * `ensureCursorIdeEventsInStore`. Live CLI / agent sessions skip this - * step — their events are written by the live event handler. + * 2. For any bounded external replay session, pre-warms one capped replay + * window. Native agent sessions continue to use their SQLite cache. * 3. Lazy-loads events from Rust EventStore via `es_load_from_cache` + * `es_get_snapshot`. If the session is already in memory (live subagent * or freshly pre-warmed cursor history), `es_load_from_cache` triggers a @@ -23,10 +21,10 @@ */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { ensureCursorIdeEventsInStore } from "@src/engines/SessionCore/sync/adapters/cursorIdeAdapter"; +import { resolveExternalReplayTarget } from "@src/api/tauri/externalHistory/replay"; +import { ensureExternalReplayEventsInStore } from "@src/engines/SessionCore/sync/adapters/externalReplayPrewarm"; import { createLogger } from "@src/hooks/logger"; import { formatInvokeError } from "@src/util/formatInvokeError"; -import { isCursorIdeSession } from "@src/util/session/sessionDispatch"; import type { SessionEvent } from "../types"; import { @@ -152,17 +150,11 @@ export function useSessionEvents( } try { - // Cursor IDE history sessions (parent OR child composer) are not in - // our SQLite event cache — they live in Cursor's `state.vscdb`. - // Pre-warm the EventStore from there before falling through to the - // generic load path. After this, `loadFromCache` finds the events - // in memory and just schedules a notify, identical to the - // already-loaded live subagent case. - if (isCursorIdeSession(sessionId!)) { - await ensureCursorIdeEventsInStore(sessionId!); - if (cancelled) return; + if (resolveExternalReplayTarget(sessionId!)) { + await ensureExternalReplayEventsInStore(sessionId!); + } else { + await eventStoreProxy.loadFromCache(sessionId!); } - await eventStoreProxy.loadFromCache(sessionId!); if (cancelled) return; loadedRef.current = sessionId!; diff --git a/src/engines/SessionCore/core/types.ts b/src/engines/SessionCore/core/types.ts index 5ed9de288..ef55f692c 100644 --- a/src/engines/SessionCore/core/types.ts +++ b/src/engines/SessionCore/core/types.ts @@ -72,6 +72,11 @@ export interface PayloadRef { preview: string; fullSizeBytes: number; truncated: boolean; + /** Opaque source locator for range-backed external replay payloads. */ + replayEncoding?: "json_value" | "utf8_text"; + replaySourceId?: string; + replayGeneration?: string; + replaySourceEventId?: string; } export interface EventPayloadBody { diff --git a/src/engines/SessionCore/derived/__tests__/sessionScopedChatEventsRetention.test.ts b/src/engines/SessionCore/derived/__tests__/sessionScopedChatEventsRetention.test.ts index ea8b75140..f4a967b98 100644 --- a/src/engines/SessionCore/derived/__tests__/sessionScopedChatEventsRetention.test.ts +++ b/src/engines/SessionCore/derived/__tests__/sessionScopedChatEventsRetention.test.ts @@ -18,6 +18,9 @@ import { } from "@src/engines/SessionCore/derived/sessionScopedChatEvents"; const subscribers = new Map void>(); +const { loadFromCache } = vi.hoisted(() => ({ + loadFromCache: vi.fn(() => Promise.resolve()), +})); vi.mock("@src/engines/SessionCore/core/store/EventStoreProxy", () => ({ eventStoreProxy: { @@ -29,7 +32,7 @@ vi.mock("@src/engines/SessionCore/core/store/EventStoreProxy", () => ({ subscribers.set(sessionId, listener); return () => subscribers.delete(sessionId); }, - loadFromCache: () => Promise.resolve(), + loadFromCache, }, isStreamingSnapshot: (snapshot: unknown) => Boolean((snapshot as { streaming?: boolean })?.streaming), @@ -40,6 +43,7 @@ describe("session-scoped atom family retention", () => { beforeEach(() => { vi.useFakeTimers(); + loadFromCache.mockClear(); store = createStore(); }); @@ -112,4 +116,25 @@ describe("session-scoped atom family retention", () => { vi.advanceTimersByTime(2); expect(chatEventsForSessionAtomFamily(sessionId)).not.toBe(chatAtomBefore); }); + + it("does not hydrate an inactive external session when a derived surface mounts it", () => { + const unsub = store.sub( + chatEventsForSessionAtomFamily("codexapp-inactive-large-session"), + () => {} + ); + + expect(loadFromCache).not.toHaveBeenCalled(); + unsub(); + }); + + it("keeps native session-scoped cache hydration unchanged", () => { + const unsub = store.sub( + chatEventsForSessionAtomFamily("sdeagent-native-session"), + () => {} + ); + + expect(loadFromCache).toHaveBeenCalledOnce(); + expect(loadFromCache).toHaveBeenCalledWith("sdeagent-native-session"); + unsub(); + }); }); diff --git a/src/engines/SessionCore/derived/sessionScopedChatEvents.ts b/src/engines/SessionCore/derived/sessionScopedChatEvents.ts index 2ca04cda6..7ae9ab135 100644 --- a/src/engines/SessionCore/derived/sessionScopedChatEvents.ts +++ b/src/engines/SessionCore/derived/sessionScopedChatEvents.ts @@ -28,6 +28,7 @@ import { atom } from "jotai"; import { atomFamily } from "jotai-family"; +import { resolveExternalReplayTarget } from "@src/api/tauri/externalHistory/replay"; import { createLogger } from "@src/hooks/logger"; import { isInteractiveTool } from "../core/interactiveTools"; @@ -128,19 +129,23 @@ const sessionSnapshotAtomFamily = atomFamily((sessionId: string) => { } ); - // Best-effort hydration. If the session is already in the Rust LRU - // cache this triggers a `schedule_notify` and the snapshot lands via - // the subscription above; if it is not loaded yet, Rust loads it from - // SQLite. We do not await — the subscription handles the push. - void eventStoreProxy.loadFromCache(sessionId).catch((err: unknown) => { - // Swallow load errors here: the consumer (ChatHistory) is allowed - // to render an empty state. `useSessionEvents` already covers - // explicit error surfacing for callers that need it. - log.warn( - `[sessionScopedChatEvents] loadFromCache(${sessionId}) failed:`, - err - ); - }); + // A mounted session-scoped atom is not proof that the session is visible: + // sidebar planning indicators, hover surfaces and merged group views also + // mount this family. Cold-indexing an external transcript here can + // therefore hydrate a huge inactive session during app startup. Foreground + // external sessions are opened by the bounded-replay transport; this + // fallback remains native-only. + if (!resolveExternalReplayTarget(sessionId)) { + void eventStoreProxy.loadFromCache(sessionId).catch((err: unknown) => { + // Swallow load errors here: the consumer (ChatHistory) is allowed + // to render an empty state. `useSessionEvents` already covers + // explicit error surfacing for callers that need it. + log.warn( + `[sessionScopedChatEvents] native hydration(${sessionId}) failed:`, + err + ); + }); + } return () => { disposed = true; diff --git a/src/engines/SessionCore/payloads/loadedPayloadRegistry.test.ts b/src/engines/SessionCore/payloads/loadedPayloadRegistry.test.ts new file mode 100644 index 000000000..27ae247db --- /dev/null +++ b/src/engines/SessionCore/payloads/loadedPayloadRegistry.test.ts @@ -0,0 +1,92 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + clearLoadedPayloads, + getLoadedPayload, + getLoadedPayloadStats, + getPendingPayloadLoad, + markPayloadLoaded, + trackPendingPayloadLoad, +} from "./loadedPayloadRegistry"; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +describe("loaded payload lifecycle bounds", () => { + beforeEach(() => { + clearLoadedPayloads(); + }); + + afterEach(() => { + clearLoadedPayloads(); + vi.useRealTimers(); + }); + + it("enforces both byte budget and least-recently-used eviction", () => { + vi.useFakeTimers(); + vi.setSystemTime(1_000); + const threeMiBUtf16 = "x".repeat((3 * 1024 * 1024) / 2); + + markPayloadLoaded("oldest", threeMiBUtf16); + vi.setSystemTime(2_000); + markPayloadLoaded("middle", threeMiBUtf16); + vi.setSystemTime(3_000); + markPayloadLoaded("newest", threeMiBUtf16); + + expect(getLoadedPayload("oldest")).toBeNull(); + expect(getLoadedPayload("middle")).toBe(threeMiBUtf16); + expect(getLoadedPayload("newest")).toBe(threeMiBUtf16); + expect(getLoadedPayloadStats()).toEqual({ + entries: 2, + bytes: 6 * 1024 * 1024, + }); + }); + + it("expires payload bodies after the three-minute idle TTL", () => { + vi.useFakeTimers(); + vi.setSystemTime(10_000); + markPayloadLoaded("payload", "body"); + + vi.setSystemTime(10_000 + 3 * 60 * 1_000 - 1); + expect(getLoadedPayload("payload")).toBe("body"); + + vi.setSystemTime(10_000 + 6 * 60 * 1_000 - 1); + expect(getLoadedPayload("payload")).toBeNull(); + expect(getLoadedPayloadStats()).toEqual({ entries: 0, bytes: 0 }); + }); + + it("cannot repopulate the cache from a request released by switch or close", async () => { + const load = deferred(); + const tracked = trackPendingPayloadLoad("stale", load.promise); + expect(getPendingPayloadLoad("stale")).toBe(load.promise); + + clearLoadedPayloads(); + load.resolve("late body"); + + await expect(tracked).resolves.toBe("late body"); + expect(getLoadedPayload("stale")).toBeNull(); + expect(getPendingPayloadLoad("stale")).toBeNull(); + }); + + it("a superseded completion cannot delete or overwrite the newer load", async () => { + const older = deferred(); + const newer = deferred(); + const olderTracked = trackPendingPayloadLoad("same-key", older.promise); + const newerTracked = trackPendingPayloadLoad("same-key", newer.promise); + + older.resolve("older body"); + await olderTracked; + expect(getPendingPayloadLoad("same-key")).toBe(newer.promise); + expect(getLoadedPayload("same-key")).toBeNull(); + + newer.resolve("newer body"); + await newerTracked; + expect(getPendingPayloadLoad("same-key")).toBeNull(); + expect(getLoadedPayload("same-key")).toBe("newer body"); + }); +}); diff --git a/src/engines/SessionCore/payloads/loadedPayloadRegistry.ts b/src/engines/SessionCore/payloads/loadedPayloadRegistry.ts index 212428c96..bae794d40 100644 --- a/src/engines/SessionCore/payloads/loadedPayloadRegistry.ts +++ b/src/engines/SessionCore/payloads/loadedPayloadRegistry.ts @@ -1,5 +1,6 @@ const MAX_LOADED_PAYLOADS = 6; const MAX_LOADED_PAYLOAD_BYTES = 8 * 1024 * 1024; +const LOADED_PAYLOAD_TTL_MS = 3 * 60 * 1000; interface LoadedPayloadEntry { key: string; @@ -9,7 +10,16 @@ interface LoadedPayloadEntry { } const loadedPayloads = new Map(); -const pendingLoads = new Map>(); +interface PendingPayloadLoad { + promise: Promise; + registryEpoch: number; +} + +const pendingLoads = new Map(); +// Clearing on switch/close invalidates completions that are already across +// the async boundary. Without this epoch, a late range response could +// repopulate the cache after its owning replay episode was released. +let registryEpoch = 0; function estimateStringBytes(value: string): number { return value.length * 2; @@ -24,6 +34,7 @@ export function getPayloadRegistryKey( } export function getLoadedPayload(key: string): string | null { + pruneLoadedPayloads(); const entry = loadedPayloads.get(key); if (!entry) return null; entry.lastAccessedAt = Date.now(); @@ -33,22 +44,29 @@ export function getLoadedPayload(key: string): string | null { export function getPendingPayloadLoad( key: string ): Promise | null { - return pendingLoads.get(key) ?? null; + return pendingLoads.get(key)?.promise ?? null; } export async function trackPendingPayloadLoad( key: string, load: Promise ): Promise { - pendingLoads.set(key, load); + const pending = { promise: load, registryEpoch }; + pendingLoads.set(key, pending); try { const body = await load; - if (body !== null) { + if ( + body !== null && + registryEpoch === pending.registryEpoch && + pendingLoads.get(key) === pending + ) { markPayloadLoaded(key, body); } return body; } finally { - pendingLoads.delete(key); + if (pendingLoads.get(key) === pending) { + pendingLoads.delete(key); + } } } @@ -67,11 +85,13 @@ export function unloadPayload(key: string): void { } export function clearLoadedPayloads(): void { + registryEpoch += 1; loadedPayloads.clear(); pendingLoads.clear(); } export function getLoadedPayloadStats(): { entries: number; bytes: number } { + pruneLoadedPayloads(); return { entries: loadedPayloads.size, bytes: loadedPayloadBytes(), @@ -97,6 +117,12 @@ function oldestLoadedPayloadEntry(): LoadedPayloadEntry | null { } function pruneLoadedPayloads(): void { + const now = Date.now(); + for (const [key, entry] of loadedPayloads) { + if (now - entry.lastAccessedAt >= LOADED_PAYLOAD_TTL_MS) { + loadedPayloads.delete(key); + } + } let totalBytes = loadedPayloadBytes(); while ( loadedPayloads.size > MAX_LOADED_PAYLOADS || diff --git a/src/engines/SessionCore/sync/__tests__/getAdapterForSession.test.ts b/src/engines/SessionCore/sync/__tests__/getAdapterForSession.test.ts index 4e585dd19..b4515b88f 100644 --- a/src/engines/SessionCore/sync/__tests__/getAdapterForSession.test.ts +++ b/src/engines/SessionCore/sync/__tests__/getAdapterForSession.test.ts @@ -34,11 +34,19 @@ describe("getAdapterForSession", () => { expect(getAdapterForSession("opencodeapp-abc")?.category).toBe( "external_history" ); + expect( + getAdapterForSession("imported-session-collaboration")?.category + ).toBe("external_history"); }); it("still routes CLI and agent sessions to their own adapters", () => { - expect(getAdapterForSession("cliagent-abc")?.category).toBe("cli"); - expect(getAdapterForSession("osagent-abc")?.category).toBe("agent"); + const cli = getAdapterForSession("cliagent-abc"); + const agent = getAdapterForSession("osagent-abc"); + expect(cli?.category).toBe("cli"); + expect(cli?.historyMode).toBe("bounded-replay"); + expect(agent?.category).toBe("agent"); + expect(agent?.historyMode).toBe("persisted-db"); + expect(agent && "loadHistory" in agent).toBe(true); }); it("returns undefined for unknown session ids", () => { diff --git a/src/engines/SessionCore/sync/__tests__/sessionSwitchOrchestrator.test.ts b/src/engines/SessionCore/sync/__tests__/sessionSwitchOrchestrator.test.ts index 810d2cfa0..395b43424 100644 --- a/src/engines/SessionCore/sync/__tests__/sessionSwitchOrchestrator.test.ts +++ b/src/engines/SessionCore/sync/__tests__/sessionSwitchOrchestrator.test.ts @@ -38,10 +38,6 @@ vi.mock("@src/util/session/sessionDispatch", () => ({ isImportedHistorySession: () => false, })); -vi.mock("../sessionSyncDerivedState", () => ({ - isCursorIdeSessionId: () => false, -})); - vi.mock("../sessionSyncPlanApproval", () => ({ rehydratePendingPlanApproval: mocks.rehydratePendingPlanApproval, })); @@ -95,7 +91,8 @@ describe("runSessionSwitchOrchestrator reconciliation", () => { "reconciles only an in-flight session (status=%s)", async (runStatus, shouldReconcile) => { const adapter = { - category: "cli", + category: "agent", + historyMode: "persisted-db", loadHistory: vi.fn(), postLoad: vi .fn() @@ -103,10 +100,10 @@ describe("runSessionSwitchOrchestrator reconciliation", () => { } as unknown as SessionAdapter; runSessionSwitchOrchestrator({ - sessionId: "cli-session", + sessionId: "sdeagent-session", adapter, abortController: new AbortController(), - refs: { liveSessionIdRef: { current: "cli-session" } }, + refs: { liveSessionIdRef: { current: "sdeagent-session" } }, actions: createActions(), setPendingPlanApprovals: vi.fn(), logger: { error: vi.fn() } as never, diff --git a/src/engines/SessionCore/sync/__tests__/sessionSyncUtils.test.ts b/src/engines/SessionCore/sync/__tests__/sessionSyncUtils.test.ts index e2a446caa..b9620e62b 100644 --- a/src/engines/SessionCore/sync/__tests__/sessionSyncUtils.test.ts +++ b/src/engines/SessionCore/sync/__tests__/sessionSyncUtils.test.ts @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { SessionEvent } from "@src/engines/SessionCore/core/types"; import { loadPersistedHistory } from "../sessionSyncUtils"; -import type { SessionAdapter } from "../types"; +import type { PersistedDbSessionAdapter } from "../types"; const cacheAdapterMock = vi.hoisted(() => ({ loadInitialTurnWindow: vi.fn(), @@ -22,11 +22,12 @@ function makeEvent(id: string): SessionEvent { function makeAdapter( category: string, historyEvents: SessionEvent[] -): SessionAdapter { +): PersistedDbSessionAdapter { return { category, + historyMode: "persisted-db", loadHistory: vi.fn(async () => historyEvents), - } as unknown as SessionAdapter; + } as unknown as PersistedDbSessionAdapter; } describe("loadPersistedHistory", () => { diff --git a/src/engines/SessionCore/sync/adapters/__tests__/externalHistoryAdapter.loading.test.ts b/src/engines/SessionCore/sync/adapters/__tests__/externalHistoryAdapter.loading.test.ts deleted file mode 100644 index 1d77c4635..000000000 --- a/src/engines/SessionCore/sync/adapters/__tests__/externalHistoryAdapter.loading.test.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -import type { ActivityChunk } from "@src/types/session/session"; - -import { - forgetTranscriptSignature, - getTranscriptSignature, -} from "../../externalHistoryTranscriptSignatures"; -import { externalHistoryAdapter } from "../externalHistoryAdapter"; - -const mocks = vi.hoisted(() => ({ - getSource: vi.fn(), - processChunks: vi.fn(), -})); - -vi.mock("@src/api/tauri/externalHistory", () => ({ - getImportedHistorySourceBySessionId: mocks.getSource, -})); - -vi.mock("@src/engines/SessionCore/ingestion/rustBridge", () => ({ - processChunksRust: mocks.processChunks, -})); - -function chunk(): ActivityChunk { - return { - chunk_id: "chunk-1", - action_type: "raw", - function: "user_message", - args: {}, - result: {}, - created_at: "2026-07-22T12:00:00.000Z", - }; -} - -describe("external history loading", () => { - beforeEach(() => { - mocks.getSource.mockReset(); - mocks.processChunks.mockReset(); - forgetTranscriptSignature("codexapp-large"); - }); - - it("shares one parse across overlapping initial and refresh loads", async () => { - let resolveChunks: ((chunks: ActivityChunk[]) => void) | undefined; - const loadPreviewChunks = vi.fn( - () => - new Promise((resolve) => { - resolveChunks = resolve; - }) - ); - const statTranscript = vi - .fn() - .mockResolvedValue({ mtimeMs: 100, sizeBytes: 200 }); - mocks.getSource.mockReturnValue({ - supportsWindowedReplay: true, - statTranscript, - loadPreviewChunks, - }); - const events = [{ id: "event-1" }]; - mocks.processChunks.mockResolvedValue(events); - - const first = externalHistoryAdapter.loadHistory( - "codexapp-large", - new AbortController().signal - ); - const second = externalHistoryAdapter.loadHistory( - "codexapp-large", - new AbortController().signal - ); - - await vi.waitFor(() => expect(loadPreviewChunks).toHaveBeenCalledTimes(1)); - resolveChunks?.([chunk()]); - - await expect(Promise.all([first, second])).resolves.toEqual([ - events, - events, - ]); - expect(mocks.processChunks).toHaveBeenCalledTimes(1); - expect(statTranscript).toHaveBeenCalledTimes(2); - expect(getTranscriptSignature("codexapp-large")).toBe("100:200"); - }); - - it("does not let one aborted consumer cancel the shared snapshot", async () => { - let resolveChunks: ((chunks: ActivityChunk[]) => void) | undefined; - const loadPreviewChunks = vi.fn( - () => - new Promise((resolve) => { - resolveChunks = resolve; - }) - ); - mocks.getSource.mockReturnValue({ - loadPreviewChunks, - statTranscript: vi - .fn() - .mockResolvedValue({ mtimeMs: 100, sizeBytes: 200 }), - }); - const events = [{ id: "event-1" }]; - mocks.processChunks.mockResolvedValue(events); - const cancelled = new AbortController(); - - const first = externalHistoryAdapter.loadHistory( - "codexapp-large", - cancelled.signal - ); - const second = externalHistoryAdapter.loadHistory( - "codexapp-large", - new AbortController().signal - ); - cancelled.abort(); - await vi.waitFor(() => expect(loadPreviewChunks).toHaveBeenCalledTimes(1)); - resolveChunks?.([chunk()]); - - await expect(first).resolves.toEqual([]); - await expect(second).resolves.toEqual(events); - expect(mocks.processChunks).toHaveBeenCalledTimes(1); - }); - - it("reuses an observed signature and only probes once after parsing", async () => { - const loadPreviewChunks = vi.fn().mockResolvedValue([chunk()]); - const statTranscript = vi - .fn() - .mockResolvedValue({ mtimeMs: 100, sizeBytes: 200 }); - mocks.getSource.mockReturnValue({ - supportsWindowedReplay: true, - statTranscript, - loadPreviewChunks, - }); - const events = [{ id: "event-1" }]; - mocks.processChunks.mockResolvedValue(events); - - await expect( - externalHistoryAdapter.loadHistoryFromObservedSignature!( - "codexapp-large", - new AbortController().signal, - "100:200" - ) - ).resolves.toEqual(events); - - expect(loadPreviewChunks).toHaveBeenCalledTimes(1); - expect(statTranscript).toHaveBeenCalledTimes(1); - expect(getTranscriptSignature("codexapp-large")).toBe("100:200"); - }); - - it("keeps a changed transcript eligible for another refresh", async () => { - mocks.getSource.mockReturnValue({ - supportsWindowedReplay: true, - statTranscript: vi - .fn() - .mockResolvedValue({ mtimeMs: 101, sizeBytes: 250 }), - loadPreviewChunks: vi.fn().mockResolvedValue([chunk()]), - }); - mocks.processChunks.mockResolvedValue([{ id: "event-1" }]); - - await externalHistoryAdapter.loadHistoryFromObservedSignature( - "codexapp-large", - new AbortController().signal, - "100:200" - ); - - expect(getTranscriptSignature("codexapp-large")).toBeUndefined(); - }); - - it("remembers a stable empty transcript instead of reloading it forever", async () => { - const statTranscript = vi - .fn() - .mockResolvedValue({ mtimeMs: 100, sizeBytes: 0 }); - mocks.getSource.mockReturnValue({ - supportsWindowedReplay: true, - statTranscript, - loadPreviewChunks: vi.fn().mockResolvedValue([]), - }); - - await expect( - externalHistoryAdapter.loadHistoryFromObservedSignature( - "codexapp-large", - new AbortController().signal, - "100:0" - ) - ).resolves.toEqual([]); - - expect(statTranscript).toHaveBeenCalledTimes(1); - expect(mocks.processChunks).not.toHaveBeenCalled(); - expect(getTranscriptSignature("codexapp-large")).toBe("100:0"); - }); -}); diff --git a/src/engines/SessionCore/sync/adapters/__tests__/externalHistoryAdapter.test.ts b/src/engines/SessionCore/sync/adapters/__tests__/externalHistoryAdapter.test.ts index d525fd747..6679a2bb8 100644 --- a/src/engines/SessionCore/sync/adapters/__tests__/externalHistoryAdapter.test.ts +++ b/src/engines/SessionCore/sync/adapters/__tests__/externalHistoryAdapter.test.ts @@ -1,71 +1,16 @@ import { describe, expect, it } from "vitest"; -import type { ActivityChunk } from "@src/types/session/session"; +import { cliAdapter } from "../cliAdapter"; +import { externalHistoryAdapter } from "../externalHistoryAdapter"; -import { selectExternalHistoryInitialWindow } from "../externalHistoryAdapter"; - -function chunk(index: number, actionType = "tool_call", fn = "run_shell") { - return { - chunk_id: `chunk-${index}`, - action_type: actionType, - function: fn, - args: {}, - result: {}, - created_at: `2026-02-11T06:${String(index % 60).padStart(2, "0")}:00.000Z`, - } satisfies ActivityChunk; -} - -describe("external history initial window", () => { - it("returns short histories unchanged", () => { - const chunks = [chunk(0, "raw", "user_message"), chunk(1)]; - - expect(selectExternalHistoryInitialWindow(chunks)).toBe(chunks); - }); - - it("returns full histories for non-windowed sources", () => { - const chunks = Array.from({ length: 250 }, (_, index) => - chunk(index, index === 0 ? "raw" : "tool_call", "user_message") - ); - - expect( - selectExternalHistoryInitialWindow(chunks, { - supportsWindowedReplay: false, - }) - ).toBe(chunks); +describe("bounded replay adapter contracts", () => { + it("does not expose a full-history loader for imported history", () => { + expect(externalHistoryAdapter.historyMode).toBe("bounded-replay"); + expect("loadHistory" in externalHistoryAdapter).toBe(false); }); - it("preserves source-level placeholders for windowed sources", () => { - const chunks = Array.from({ length: 250 }, (_, index) => - chunk(index, index % 2 === 0 ? "raw" : "tool_call", "user_message") - ); - - expect( - selectExternalHistoryInitialWindow(chunks, { - supportsWindowedReplay: true, - }) - ).toBe(chunks); - }); - - it("expands the tail window back to the current user round", () => { - const chunks = [ - chunk(0, "raw", "user_message"), - chunk(1), - chunk(2, "raw", "user_message"), - ...Array.from({ length: 200 }, (_, index) => chunk(index + 3)), - ]; - - const window = selectExternalHistoryInitialWindow(chunks); - - expect(window[0].chunk_id).toBe("chunk-2"); - expect(window).toHaveLength(201); - }); - - it("falls back to the fixed tail when no user boundary is available", () => { - const chunks = Array.from({ length: 205 }, (_, index) => chunk(index)); - - const window = selectExternalHistoryInitialWindow(chunks); - - expect(window[0].chunk_id).toBe("chunk-5"); - expect(window).toHaveLength(200); + it("routes every managed CLI through the same bounded transport", () => { + expect(cliAdapter.historyMode).toBe("bounded-replay"); + expect("loadHistory" in cliAdapter).toBe(false); }); }); diff --git a/src/engines/SessionCore/sync/adapters/__tests__/externalReplayPrewarm.test.ts b/src/engines/SessionCore/sync/adapters/__tests__/externalReplayPrewarm.test.ts new file mode 100644 index 000000000..f489889f0 --- /dev/null +++ b/src/engines/SessionCore/sync/adapters/__tests__/externalReplayPrewarm.test.ts @@ -0,0 +1,139 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { ExternalReplayWindow } from "@src/api/tauri/externalHistory/replay"; + +import { ensureExternalReplayEventsInStore } from "../externalReplayPrewarm"; + +const mocks = vi.hoisted(() => ({ + prewarmWindow: vi.fn(), + getLatestSessionSnapshot: vi.fn(() => null), + setAtom: vi.fn(), + foregroundOpen: vi.fn(), +})); + +vi.mock("@src/api/tauri/externalHistory/replay", () => ({ + resolveExternalReplayTarget: (sessionId: string) => + sessionId.startsWith("sdeagent-") + ? null + : { + sourceId: sessionId.startsWith("cursoride-") + ? "cursor_ide" + : "codex_app", + sessionId, + }, + externalReplayPrewarmWindow: mocks.prewarmWindow, + externalReplayOpenWindow: mocks.foregroundOpen, +})); + +vi.mock("@src/engines/SessionCore/core/store/EventStoreProxy", () => ({ + eventStoreProxy: { + getLatestSessionSnapshot: mocks.getLatestSessionSnapshot, + }, +})); + +vi.mock("@src/util/core/state/instrumentedStore", () => ({ + getInstrumentedStore: () => ({ set: mocks.setAtom }), +})); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +function windowResult( + generation: string, + revision: number +): ExternalReplayWindow { + return { + cursor: { + sourceId: "cursor_ide", + sessionId: "cursoride-guard-test", + generation, + revision, + throughSequence: revision, + }, + events: [], + windowStartSequence: null, + turnHeaders: [], + totalEventCount: 0, + totalTurnCount: 0, + hasOlder: false, + watcherAvailable: false, + stats: { + parsedBytes: 0, + parsedRows: 0, + normalizedEvents: 0, + upsertedEvents: 0, + removedEvents: 0, + ipcBytes: 0, + notReady: false, + }, + }; +} + +describe("external replay Rust-owned prewarm guards", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getLatestSessionSnapshot.mockReturnValue(null); + }); + + it("prewarms through one Rust-owned bounded request", async () => { + mocks.prewarmWindow.mockResolvedValue(windowResult("g1", 7)); + + await ensureExternalReplayEventsInStore("cursoride-guard-test", { + forceReload: true, + }); + + expect(mocks.prewarmWindow).toHaveBeenCalledWith( + "cursoride-guard-test", + expect.any(Number) + ); + expect(mocks.setAtom).toHaveBeenCalledTimes(1); + expect(mocks.foregroundOpen).not.toHaveBeenCalled(); + }); + + it("drops an A prewarm result after a newer A episode", async () => { + const oldA = deferred(); + mocks.prewarmWindow + .mockReturnValueOnce(oldA.promise) + .mockResolvedValueOnce(windowResult("g2", 2)); + + const stale = ensureExternalReplayEventsInStore("cursoride-guard-test", { + forceReload: true, + }); + const current = ensureExternalReplayEventsInStore("cursoride-guard-test", { + forceReload: true, + }); + await current; + oldA.resolve(windowResult("g1", 1)); + await stale; + + expect(mocks.setAtom).toHaveBeenCalledTimes(1); + const firstEpisode = mocks.prewarmWindow.mock.calls[0]?.[1] as number; + const secondEpisode = mocks.prewarmWindow.mock.calls[1]?.[1] as number; + expect(secondEpisode).toBeGreaterThan(firstEpisode); + }); + + it("uses the same bounded prewarm for non-Cursor external sessions", async () => { + const window = windowResult("codex-generation", 9); + window.cursor.sourceId = "codex_app"; + window.cursor.sessionId = "codexapp-nested"; + mocks.prewarmWindow.mockResolvedValue(window); + + await ensureExternalReplayEventsInStore("codexapp-nested"); + + expect(mocks.prewarmWindow).toHaveBeenCalledWith( + "codexapp-nested", + expect.any(Number) + ); + }); + + it("never sends a native SDE session through external replay", async () => { + await ensureExternalReplayEventsInStore("sdeagent-native"); + + expect(mocks.prewarmWindow).not.toHaveBeenCalled(); + }); +}); diff --git a/src/engines/SessionCore/sync/adapters/__tests__/externalReplayTurnCatalog.test.ts b/src/engines/SessionCore/sync/adapters/__tests__/externalReplayTurnCatalog.test.ts new file mode 100644 index 000000000..45ca5ff72 --- /dev/null +++ b/src/engines/SessionCore/sync/adapters/__tests__/externalReplayTurnCatalog.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; + +import type { ExternalReplayWindow } from "@src/api/tauri/externalHistory/replay"; + +import { + buildExternalReplayTurnSummaries, + externalReplayPlaceholderId, + externalReplayTurnIndexFromId, +} from "../../externalReplayTurnState"; + +function replayWindow(totalTurnCount: number): ExternalReplayWindow { + const latestIndex = totalTurnCount - 1; + return { + cursor: { + sourceId: "cursor_ide", + sessionId: "cursoride-composer-1", + generation: "g1", + revision: 1, + throughSequence: 1, + }, + events: [ + { + id: "cursoride-user-provider-stable-id", + sessionId: "cursoride-composer-1", + source: "user", + displayText: "latest prompt", + result: {}, + } as ExternalReplayWindow["events"][number], + ], + windowStartSequence: 1, + turnHeaders: [ + { + turnId: "cursoride-user-provider-stable-id", + turnIndex: latestIndex, + startSequence: 1, + endSequence: 1, + startedAt: "2026-07-22T00:00:00Z", + endedAt: "2026-07-22T00:00:01Z", + eventCount: 2, + }, + ], + totalEventCount: totalTurnCount * 2, + totalTurnCount, + hasOlder: totalTurnCount > 1, + watcherAvailable: false, + stats: { + parsedBytes: 0, + parsedRows: 0, + normalizedEvents: 0, + upsertedEvents: 0, + removedEvents: 0, + ipcBytes: 0, + notReady: false, + }, + }; +} + +describe("external replay bounded turn catalog", () => { + it("keeps the exact turn count without allocating every placeholder", () => { + const summaries = buildExternalReplayTurnSummaries(replayWindow(100_000)); + + expect(summaries).toHaveLength(100_000); + expect(Object.keys(summaries)).toEqual(["99999"]); + expect(summaries[0]?.turnId).toBe(externalReplayPlaceholderId(0)); + expect(summaries[50_000]?.turnIndex).toBe(50_000); + expect(summaries[99_999]?.turnId).toBe("cursoride-user-provider-stable-id"); + expect(summaries[99_999]?.userPreview).toBe("latest prompt"); + }); + + it("round-trips a virtual turn index and rejects unrelated ids", () => { + const id = externalReplayPlaceholderId(42); + expect(externalReplayTurnIndexFromId(id)).toBe(42); + expect(externalReplayTurnIndexFromId("cursoride-user-42")).toBeNull(); + expect(externalReplayTurnIndexFromId(`${id}.5`)).toBeNull(); + }); +}); diff --git a/src/engines/SessionCore/sync/adapters/cli/cliHistory.ts b/src/engines/SessionCore/sync/adapters/cli/cliHistory.ts index 88b69c5d9..ae568ff4b 100644 --- a/src/engines/SessionCore/sync/adapters/cli/cliHistory.ts +++ b/src/engines/SessionCore/sync/adapters/cli/cliHistory.ts @@ -1,12 +1,7 @@ -import { convertFileSrc, invoke as tauriInvoke } from "@tauri-apps/api/core"; +import { invoke as tauriInvoke } from "@tauri-apps/api/core"; -import type { SessionEvent } from "@src/engines/SessionCore/core/types"; -import { processChunksRust } from "@src/engines/SessionCore/ingestion/rustBridge"; import { createLogger } from "@src/hooks/logger"; -import type { - ActivityChunk, - CliSessionStatus, -} from "@src/types/session/session"; +import type { CliSessionStatus } from "@src/types/session/session"; import { registerSessionTranscriptSource } from "../../nativeTranscriptReconcile"; import type { PostLoadResult } from "../../types"; @@ -21,28 +16,6 @@ interface StoredSession { transcriptSource?: string; } -function convertResultImages(event: SessionEvent): SessionEvent { - const result = event.result as Record | undefined; - if (!result?.images || !Array.isArray(result.images)) return event; - const converted = (result.images as string[]).map((imgRef) => - imgRef.startsWith("data:") ? imgRef : convertFileSrc(imgRef) - ); - return { ...event, result: { ...result, images: converted } }; -} - -export async function loadCliHistory( - sessionId: string, - signal: AbortSignal -): Promise { - const chunks = await tauriInvoke("cli_agent_chunks", { - sessionId, - }); - if (signal.aborted || !Array.isArray(chunks)) return []; - const events = await processChunksRust(chunks, sessionId); - if (signal.aborted) return []; - return events.map(convertResultImages); -} - export async function postLoadCliSession( sessionId: string, signal: AbortSignal diff --git a/src/engines/SessionCore/sync/adapters/cli/cliLifecycle.test.ts b/src/engines/SessionCore/sync/adapters/cli/cliLifecycle.test.ts new file mode 100644 index 000000000..acd046520 --- /dev/null +++ b/src/engines/SessionCore/sync/adapters/cli/cliLifecycle.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { waitForCliTerminalBoundary } from "./cliLifecycle"; + +const mocks = vi.hoisted(() => ({ + invoke: vi.fn(), +})); + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: mocks.invoke, +})); + +describe("CLI lifecycle polling cancellation", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("aborts the 250ms terminal wait without issuing another status request", async () => { + mocks.invoke.mockResolvedValue({ status: "running", updatedAt: "u-1" }); + const controller = new AbortController(); + const wait = waitForCliTerminalBoundary("cliagent-a", "u-0", { + signal: controller.signal, + pollIntervalMs: 60_000, + }); + + await vi.waitFor(() => expect(mocks.invoke).toHaveBeenCalledTimes(1)); + controller.abort(); + + await expect(wait).resolves.toEqual({ + status: "running", + updatedAt: "u-1", + }); + expect(mocks.invoke).toHaveBeenCalledTimes(1); + }); + + it("stops when the exact replay episode is no longer active", async () => { + let active = true; + mocks.invoke.mockImplementation(async () => { + active = false; + return { status: "running", updatedAt: "u-1" }; + }); + + await expect( + waitForCliTerminalBoundary("cliagent-a", "u-0", { + isSessionActive: () => active, + pollIntervalMs: 0, + }) + ).resolves.toEqual({ status: "running", updatedAt: "u-1" }); + expect(mocks.invoke).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/engines/SessionCore/sync/adapters/cli/cliLifecycle.ts b/src/engines/SessionCore/sync/adapters/cli/cliLifecycle.ts index 0119336a7..016036ee0 100644 --- a/src/engines/SessionCore/sync/adapters/cli/cliLifecycle.ts +++ b/src/engines/SessionCore/sync/adapters/cli/cliLifecycle.ts @@ -1,7 +1,6 @@ import { invoke as tauriInvoke } from "@tauri-apps/api/core"; import { confirmTurnRunning } from "@src/engines/SessionCore/control/turnLifecycle"; -import { loadSessionAtom } from "@src/engines/SessionCore/core/atoms"; import { isTurnBlockingRuntimeEvent } from "@src/engines/SessionCore/core/runningEventGate"; import { eventStoreProxy } from "@src/engines/SessionCore/core/store/EventStoreProxy"; import type { SessionEvent } from "@src/engines/SessionCore/core/types"; @@ -13,8 +12,10 @@ import { isStoreInitialized, } from "@src/util/core/state/instrumentedStore"; -import { isNativeTranscriptSession } from "../../nativeTranscriptReconcile"; -import { loadCliHistory } from "./cliHistory"; +import { + getActiveExternalReplayLease, + pollExternalReplaySession, +} from "../../externalReplayTransport"; const log = createLogger("CliAdapter"); @@ -23,6 +24,43 @@ export type CliStatusResponse = { updatedAt?: string; }; +export interface CliPollingWaitOptions { + signal?: AbortSignal; + /** Exact visible replay-episode guard; prevents A→B→A reuse. */ + isSessionActive?: () => boolean; + timeoutMs?: number; + pollIntervalMs?: number; +} + +function isPollingCancelled(options: CliPollingWaitOptions): boolean { + return Boolean( + options.signal?.aborted || options.isSessionActive?.() === false + ); +} + +function waitForPollingDelay( + delayMs: number, + options: CliPollingWaitOptions +): Promise { + if (isPollingCancelled(options)) return Promise.resolve(false); + return new Promise((resolve) => { + let settled = false; + const finish = (completed: boolean): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + options.signal?.removeEventListener("abort", onAbort); + resolve(completed); + }; + const onAbort = (): void => finish(false); + const timer = setTimeout( + () => finish(!isPollingCancelled(options)), + delayMs + ); + options.signal?.addEventListener("abort", onAbort, { once: true }); + }); +} + const CLI_TERMINAL_STATUSES = new Set([ "completed", "failed", @@ -54,14 +92,17 @@ export async function readCliStatus( export async function waitForCliRunBoundary( sessionId: string, - previousStatus: CliStatusResponse | null + previousStatus: CliStatusResponse | null, + options: CliPollingWaitOptions = {} ): Promise { - const deadline = Date.now() + 15_000; + const deadline = Date.now() + (options.timeoutMs ?? 15_000); + const pollIntervalMs = options.pollIntervalMs ?? 100; const previousUpdatedAt = previousStatus?.updatedAt; const previousWasTerminal = isCliTerminalStatus(previousStatus?.status); let lastStatus: CliStatusResponse | null = null; - while (Date.now() < deadline) { + while (Date.now() < deadline && !isPollingCancelled(options)) { lastStatus = await readCliStatus(sessionId); + if (isPollingCancelled(options)) return lastStatus; const hasNewStatus = !previousUpdatedAt || lastStatus?.updatedAt !== previousUpdatedAt; const hasDurableBoundary = @@ -75,9 +116,13 @@ export async function waitForCliRunBoundary( ) { return lastStatus; } - await new Promise((resolve) => setTimeout(resolve, 100)); + if (!(await waitForPollingDelay(pollIntervalMs, options))) { + return lastStatus; + } } + if (isPollingCancelled(options)) return lastStatus; + throw new Error( `CLI run boundary was not observed for ${sessionId}; lastStatus=${JSON.stringify(lastStatus)}` ); @@ -149,18 +194,22 @@ export function markObservedCliTerminalStatus( export async function waitForCliTerminalBoundary( sessionId: string, previousUpdatedAt: string | null | undefined, - timeoutMs = 90_000 + options: CliPollingWaitOptions = {} ): Promise { - const deadline = Date.now() + timeoutMs; + const deadline = Date.now() + (options.timeoutMs ?? 90_000); + const pollIntervalMs = options.pollIntervalMs ?? 250; let lastStatus: CliStatusResponse | null = null; - while (Date.now() < deadline) { + while (Date.now() < deadline && !isPollingCancelled(options)) { lastStatus = await readCliStatus(sessionId); + if (isPollingCancelled(options)) return lastStatus; const hasNewStatus = !previousUpdatedAt || lastStatus?.updatedAt !== previousUpdatedAt; if (hasNewStatus && isCliTerminalStatus(lastStatus?.status)) { return lastStatus; } - await new Promise((resolve) => setTimeout(resolve, 250)); + if (!(await waitForPollingDelay(pollIntervalMs, options))) { + return lastStatus; + } } return lastStatus; } @@ -169,16 +218,14 @@ async function refreshLoadedCliHistory( sessionId: string ): Promise { if (!isStoreInitialized()) return []; - const events = await loadCliHistory(sessionId, new AbortController().signal); - if (events.length === 0) return events; - // Native-transcript sessions render the live turn from in-memory events - // only. The replay is read here purely to observe persistence for the send - // handshake; terminal reconcile remains the single on-screen replacement. - if (!isNativeTranscriptSession(sessionId)) { - await eventStoreProxy.mergeEvents(events, sessionId); - getInstrumentedStore().set(loadSessionAtom, { sessionId, events }); - } - return events; + const lease = getActiveExternalReplayLease(sessionId); + if (!lease) return []; + const delta = await pollExternalReplaySession(lease); + if (delta?.events.length) return delta.events; + // A focus/watcher refresh may have consumed the same source delta first. + // The active EventStore is itself bounded, so this does not rematerialize + // the source transcript or cross the Rust → JS → Rust ingestion path. + return eventStoreProxy.getEvents(sessionId); } function eventContainsText(event: SessionEvent, text: string): boolean { @@ -187,18 +234,26 @@ function eventContainsText(event: SessionEvent, text: string): boolean { export async function waitForPersistedCliUserEvent( sessionId: string, - content: string + content: string, + options: CliPollingWaitOptions = {} ): Promise { - const deadline = Date.now() + 15_000; + const deadline = Date.now() + (options.timeoutMs ?? 15_000); + const pollIntervalMs = options.pollIntervalMs ?? 250; let lastEventCount = 0; - while (Date.now() < deadline) { + let lastEvents: SessionEvent[] = []; + while (Date.now() < deadline && !isPollingCancelled(options)) { const events = await refreshLoadedCliHistory(sessionId); + lastEvents = events; + if (isPollingCancelled(options)) return lastEvents; lastEventCount = events.length; if (events.some((event) => eventContainsText(event, content))) { return events; } - await new Promise((resolve) => setTimeout(resolve, 250)); + if (!(await waitForPollingDelay(pollIntervalMs, options))) { + return lastEvents; + } } + if (isPollingCancelled(options)) return lastEvents; throw new Error( `CLI user event was not persisted for ${sessionId}; eventCount=${lastEventCount}` ); diff --git a/src/engines/SessionCore/sync/adapters/cli/cliTransport.ts b/src/engines/SessionCore/sync/adapters/cli/cliTransport.ts index 52d486fb1..4e64fdb9a 100644 --- a/src/engines/SessionCore/sync/adapters/cli/cliTransport.ts +++ b/src/engines/SessionCore/sync/adapters/cli/cliTransport.ts @@ -7,6 +7,7 @@ import { markTurnTerminal, toTurnTerminalStatus, } from "@src/engines/SessionCore/control/turnLifecycle"; +import { getActiveExternalReplayLease } from "@src/engines/SessionCore/sync/externalReplayTransport"; import type { AdapterSendInput } from "../../types"; import { @@ -35,11 +36,29 @@ export async function sendCliMessage(input: AdapterSendInput): Promise { if (!isResume && content.trim()) { await enterAgentOrgSessionIntervention(sessionId); } + // Bind every status/history wait to the exact visible replay episode that + // initiated this send. A stale A wait must not resume after A→B→A. + const replayLease = getActiveExternalReplayLease(sessionId); + const isSendEpisodeActive = (): boolean => + replayLease !== null && + !replayLease.signal.aborted && + getActiveExternalReplayLease(sessionId)?.episodeId === + replayLease.episodeId; + const waitOptions = { + signal: replayLease?.signal, + isSessionActive: isSendEpisodeActive, + }; const previousStatus = await readCliStatus(sessionId); - protectedRunningTurnBySession.set(sessionId, { + const protectedTurn = { content, startedAt: Date.now(), - }); + }; + const clearProtectedTurn = (): void => { + if (protectedRunningTurnBySession.get(sessionId) === protectedTurn) { + protectedRunningTurnBySession.delete(sessionId); + } + }; + protectedRunningTurnBySession.set(sessionId, protectedTurn); markCliRuntimeRunning(sessionId); try { await tauriInvoke("cli_agent_message", { @@ -54,23 +73,36 @@ export async function sendCliMessage(input: AdapterSendInput): Promise { ...(adeContext ? { ideContext: adeContext } : {}), }); } catch (error) { - protectedRunningTurnBySession.delete(sessionId); + clearProtectedTurn(); throw error; } - const acceptedStatus = await waitForCliRunBoundary(sessionId, previousStatus); + const acceptedStatus = await waitForCliRunBoundary( + sessionId, + previousStatus, + waitOptions + ); + if (!isSendEpisodeActive()) { + clearProtectedTurn(); + return; + } markCliRuntimeRunning(sessionId); // Capture this dispatch's generation so a late terminal can never close a // newer turn. const dispatchGeneration = getTurnGeneration(sessionId); const persistedEvents = await waitForPersistedCliUserEvent( sessionId, - content + content, + waitOptions ); + if (!isSendEpisodeActive()) { + clearProtectedTurn(); + return; + } const acceptedTerminalIsCurrentTurn = isCliTerminalStatus(acceptedStatus?.status) && hasRuntimeOutputAfterUserEvent(persistedEvents, content); if (acceptedTerminalIsCurrentTurn) { - protectedRunningTurnBySession.delete(sessionId); + clearProtectedTurn(); markObservedCliTerminalStatus(sessionId, acceptedStatus.status); markTurnTerminal( sessionId, @@ -82,15 +114,20 @@ export async function sendCliMessage(input: AdapterSendInput): Promise { void waitForCliTerminalBoundary( sessionId, - acceptedStatus?.updatedAt ?? previousStatus?.updatedAt - ).then((terminalStatus) => { - if (!isCliTerminalStatus(terminalStatus?.status)) return; - protectedRunningTurnBySession.delete(sessionId); - markObservedCliTerminalStatus(sessionId, terminalStatus.status); - markTurnTerminal(sessionId, toTurnTerminalStatus(terminalStatus.status), { - generation: dispatchGeneration, + acceptedStatus?.updatedAt ?? previousStatus?.updatedAt, + waitOptions + ) + .then((terminalStatus) => { + if (!isSendEpisodeActive()) return; + if (!isCliTerminalStatus(terminalStatus?.status)) return; + markObservedCliTerminalStatus(sessionId, terminalStatus.status); + markTurnTerminal(sessionId, toTurnTerminalStatus(terminalStatus.status), { + generation: dispatchGeneration, + }); + }) + .finally(() => { + clearProtectedTurn(); }); - }); } export async function stopCliSession( diff --git a/src/engines/SessionCore/sync/adapters/cliAdapter.ts b/src/engines/SessionCore/sync/adapters/cliAdapter.ts index 512d35e1f..d2447e2a4 100644 --- a/src/engines/SessionCore/sync/adapters/cliAdapter.ts +++ b/src/engines/SessionCore/sync/adapters/cliAdapter.ts @@ -6,13 +6,13 @@ * message transport. */ import type { SessionAdapter } from "../types"; -import { loadCliHistory, postLoadCliSession } from "./cli/cliHistory"; +import { postLoadCliSession } from "./cli/cliHistory"; import { sendCliMessage, stopCliSession } from "./cli/cliTransport"; import { createCliEventHandler } from "./cli/createCliEventHandler"; export const cliAdapter: SessionAdapter = { category: "cli", - loadHistory: loadCliHistory, + historyMode: "bounded-replay", postLoad: postLoadCliSession, createEventHandler: createCliEventHandler, sendMessage: sendCliMessage, diff --git a/src/engines/SessionCore/sync/adapters/createRustAgentAdapter.ts b/src/engines/SessionCore/sync/adapters/createRustAgentAdapter.ts index c37f17ab8..5c196c1e2 100644 --- a/src/engines/SessionCore/sync/adapters/createRustAgentAdapter.ts +++ b/src/engines/SessionCore/sync/adapters/createRustAgentAdapter.ts @@ -227,6 +227,7 @@ export function createRustAgentAdapter( return { category, + historyMode: "persisted-db", async loadHistory( sessionId: string, diff --git a/src/engines/SessionCore/sync/adapters/cursorIdeAdapter.ts b/src/engines/SessionCore/sync/adapters/cursorIdeAdapter.ts deleted file mode 100644 index cc0210b48..000000000 --- a/src/engines/SessionCore/sync/adapters/cursorIdeAdapter.ts +++ /dev/null @@ -1,193 +0,0 @@ -/** - * Cursor IDE EventStore preload helpers. - * - * Cursor IDE history is loaded through the generic external-history adapter. - * This module only keeps Cursor-specific lazy preload/snapshot helpers used by - * turn expansion and session-switch freshness checks. - */ -import { - cursorIdeFullRefresh, - cursorIdeInitialWindow, -} from "@src/api/tauri/externalHistory"; -import { cursorIdeComposerLastUpdatedAt } from "@src/api/tauri/externalHistory/cursorIde"; -import { eventStoreProxy } from "@src/engines/SessionCore/core/store/EventStoreProxy"; -import { processChunksRust } from "@src/engines/SessionCore/ingestion/rustBridge"; -import { cursorIdeTurnSummariesAtomFamily } from "@src/store/session/cursorIdeTurnSummariesAtom"; -import { getInstrumentedStore } from "@src/util/core/state/instrumentedStore"; -import { - composerIdFromSessionId, - isCursorIdeSession, -} from "@src/util/session/sessionDispatch"; - -const CURSOR_IDE_INITIAL_RECENT_BUBBLE_LIMIT = 100; - -const cursorIdeSnapshotLastUpdatedAtBySession = new Map(); - -export function getCursorIdeSnapshotLastUpdatedAt( - sessionId: string -): number | null { - return cursorIdeSnapshotLastUpdatedAtBySession.get(sessionId) ?? null; -} - -async function refreshCursorIdeSnapshotLastUpdatedAt( - sessionId: string -): Promise { - const composerId = composerIdFromSessionId(sessionId); - if (!composerId) return; - const lastUpdatedAt = await cursorIdeComposerLastUpdatedAt(composerId); - if (lastUpdatedAt !== null) { - cursorIdeSnapshotLastUpdatedAtBySession.set(sessionId, lastUpdatedAt); - } -} - -interface CursorIdeReloadState { - inFlight: Promise | null; - needsReloadAfterCurrent: boolean; - debounceTimer: ReturnType | null; - debouncedPromise: Promise | null; - resolveDebounced: (() => void) | null; - rejectDebounced: ((error: unknown) => void) | null; -} - -const cursorIdeReloadStates = new Map(); -const CURSOR_IDE_FORCED_RELOAD_DEBOUNCE_MS = 250; - -function getCursorIdeReloadState(sessionId: string): CursorIdeReloadState { - let state = cursorIdeReloadStates.get(sessionId); - if (!state) { - state = { - inFlight: null, - needsReloadAfterCurrent: false, - debounceTimer: null, - debouncedPromise: null, - resolveDebounced: null, - rejectDebounced: null, - }; - cursorIdeReloadStates.set(sessionId, state); - } - return state; -} - -/** - * Lazy-load a Cursor IDE session's events into the EventStore so any - * `useSessionEvents(sessionId)` consumer (notably nested SubagentBlocks - * expanded inside a parent Cursor history view) can replay them. - * - * Idempotent and safe to call repeatedly: - * - returns immediately if the session id is not a `cursoride-*` id - * - returns immediately if the EventStore already has events for this id - * - coalesces concurrent in-flight loads on the same id - * - * The EventStore push uses `set` (not `mergeEvents`) because cursor history - * is immutable on disk — there is nothing to merge with, and `set` is - * cheaper. The events live alongside CLI/agent sessions in the same Rust - * LRU; eviction is fine because we can always reload from `state.vscdb`. - */ -export async function ensureCursorIdeEventsInStore( - sessionId: string, - options?: { forceReload?: boolean } -): Promise { - if (!isCursorIdeSession(sessionId)) return; - - const force = options?.forceReload === true; - if (!force) { - const existing = eventStoreProxy.getLatestSessionSnapshot(sessionId); - if (existing && existing.eventCount > 0) return; - } - - const state = getCursorIdeReloadState(sessionId); - if (!force && state.inFlight) return state.inFlight; - if (!force) return runCursorIdeScheduledReload(sessionId, state, false); - return scheduleCursorIdeForcedReload(sessionId, state); -} - -function scheduleCursorIdeForcedReload( - sessionId: string, - state: CursorIdeReloadState -): Promise { - if (state.inFlight) { - state.needsReloadAfterCurrent = true; - return state.inFlight; - } - - if (state.debounceTimer) { - clearTimeout(state.debounceTimer); - state.debounceTimer = null; - } - - if (!state.debouncedPromise) { - state.debouncedPromise = new Promise((resolve, reject) => { - state.resolveDebounced = resolve; - state.rejectDebounced = reject; - }); - } - - state.debounceTimer = setTimeout(() => { - state.debounceTimer = null; - const promiseToResolve = state.resolveDebounced; - const promiseToReject = state.rejectDebounced; - state.debouncedPromise = null; - state.resolveDebounced = null; - state.rejectDebounced = null; - - void runCursorIdeScheduledReload(sessionId, state, true).then( - () => promiseToResolve?.(), - (error: unknown) => promiseToReject?.(error) - ); - }, CURSOR_IDE_FORCED_RELOAD_DEBOUNCE_MS); - - return state.debouncedPromise; -} - -function runCursorIdeScheduledReload( - sessionId: string, - state: CursorIdeReloadState, - force: boolean -): Promise { - if (state.inFlight) { - if (force) state.needsReloadAfterCurrent = true; - return state.inFlight; - } - - const work = (async () => { - try { - await loadCursorIdeEventsIntoStore(sessionId, force); - while (state.needsReloadAfterCurrent) { - state.needsReloadAfterCurrent = false; - await loadCursorIdeEventsIntoStore(sessionId, true); - } - } finally { - state.inFlight = null; - if (!state.debounceTimer && !state.debouncedPromise) { - cursorIdeReloadStates.delete(sessionId); - } - } - })(); - state.inFlight = work; - return work; -} - -async function loadCursorIdeEventsIntoStore( - sessionId: string, - force: boolean -): Promise { - const loadResult = force - ? await cursorIdeFullRefresh(sessionId) - : await cursorIdeInitialWindow({ - sessionId, - recentLimit: CURSOR_IDE_INITIAL_RECENT_BUBBLE_LIMIT, - }); - - getInstrumentedStore().set( - cursorIdeTurnSummariesAtomFamily(sessionId), - loadResult.turns - ); - - if (!Array.isArray(loadResult.chunks) || loadResult.chunks.length === 0) { - return; - } - const events = await processChunksRust(loadResult.chunks, sessionId); - if (events.length === 0) return; - await eventStoreProxy.set(events, sessionId); - await refreshCursorIdeSnapshotLastUpdatedAt(sessionId); -} diff --git a/src/engines/SessionCore/sync/adapters/externalHistoryAdapter.ts b/src/engines/SessionCore/sync/adapters/externalHistoryAdapter.ts index 437b826cd..dcec39aae 100644 --- a/src/engines/SessionCore/sync/adapters/externalHistoryAdapter.ts +++ b/src/engines/SessionCore/sync/adapters/externalHistoryAdapter.ts @@ -1,13 +1,3 @@ -import { getImportedHistorySourceBySessionId } from "@src/api/tauri/externalHistory"; -import type { SessionEvent } from "@src/engines/SessionCore/core/types"; -import { processChunksRust } from "@src/engines/SessionCore/ingestion/rustBridge"; -import { createLogger } from "@src/hooks/logger"; -import type { ActivityChunk } from "@src/types/session/session"; - -import { - forgetTranscriptSignature, - rememberTranscriptSignature, -} from "../externalHistoryTranscriptSignatures"; import type { AdapterSendInput, EventHandlerCallbacks, @@ -15,57 +5,6 @@ import type { SessionEventHandler, } from "../types"; -const logger = createLogger("ExternalHistoryAdapter"); -const EXTERNAL_HISTORY_INITIAL_CHUNK_LIMIT = 200; -const MAX_IN_FLIGHT_EXTERNAL_HISTORY_LOADS = 8; -const inFlightExternalHistoryLoads = new Map>(); - -interface ExternalHistorySessionAdapter extends SessionAdapter { - loadHistoryFromObservedSignature( - sessionId: string, - signal: AbortSignal, - observedSignature: string - ): Promise; -} - -function isUserMessageChunk(chunk: ActivityChunk): boolean { - return ( - chunk.action_type === "raw" && - (chunk.function === "user_message" || chunk.function === "user") - ); -} - -export function selectExternalHistoryInitialWindow( - chunks: ActivityChunk[], - options: { supportsWindowedReplay?: boolean } = {} -): ActivityChunk[] { - // A source-level window already contains lightweight headers/placeholders - // for older turns. Slicing that response here would make those turns - // unreachable even though their bodies are available through the turn - // loader. Trust the source's bounded wire contract. - if (options.supportsWindowedReplay !== undefined) { - return chunks; - } - - if (chunks.length <= EXTERNAL_HISTORY_INITIAL_CHUNK_LIMIT) { - return chunks; - } - - let startIndex = Math.max( - 0, - chunks.length - EXTERNAL_HISTORY_INITIAL_CHUNK_LIMIT - ); - const tailStartIndex = startIndex; - while (startIndex > 0 && !isUserMessageChunk(chunks[startIndex])) { - startIndex -= 1; - } - if (!isUserMessageChunk(chunks[startIndex])) { - startIndex = tailStartIndex; - } - - return chunks.slice(startIndex); -} - function createNoopEventHandler(): SessionEventHandler { return { handleEvent(): void {}, @@ -77,109 +16,9 @@ function createNoopEventHandler(): SessionEventHandler { }; } -async function readTranscriptSignature( - source: NonNullable>, - sessionId: string -): Promise { - if (!source.statTranscript) return null; - try { - const stat = await source.statTranscript(sessionId); - return stat ? `${stat.mtimeMs}:${stat.sizeBytes}` : null; - } catch { - return null; - } -} - -function recordLoadedTranscriptSignature( - sessionId: string, - signatureBefore: string | null, - signatureAfter: string | null -): void { - if (signatureBefore && signatureBefore === signatureAfter) { - // Seed the auto-refresh guard with the exact snapshot just rendered. The - // old path left it empty, so the first 3–5 second tick parsed and - // normalized the same (occasionally hundreds-of-MiB) transcript again. - rememberTranscriptSignature(sessionId, signatureBefore); - } else if (signatureBefore !== signatureAfter) { - // The writer moved while this load was in flight. Keep the next cheap stat - // eligible to refresh instead of claiming the newer snapshot was shown. - forgetTranscriptSignature(sessionId); - } -} - -async function loadExternalHistorySnapshot( - sessionId: string, - observedSignature?: string -): Promise { - const source = getImportedHistorySourceBySessionId(sessionId); - if (!source) { - logger.warn("No external history loader registered for session", sessionId); - return []; - } - const signatureBefore = - observedSignature ?? (await readTranscriptSignature(source, sessionId)); - const chunks = await source.loadPreviewChunks(sessionId); - if (!Array.isArray(chunks) || chunks.length === 0) { - const signatureAfter = await readTranscriptSignature(source, sessionId); - recordLoadedTranscriptSignature(sessionId, signatureBefore, signatureAfter); - return []; - } - const initialWindow = selectExternalHistoryInitialWindow(chunks, { - supportsWindowedReplay: source.supportsWindowedReplay, - }); - const events = await processChunksRust(initialWindow, sessionId); - const signatureAfter = await readTranscriptSignature(source, sessionId); - recordLoadedTranscriptSignature(sessionId, signatureBefore, signatureAfter); - return events; -} - -function getOrStartExternalHistoryLoad( - sessionId: string, - observedSignature?: string -): Promise { - const existing = inFlightExternalHistoryLoads.get(sessionId); - if (existing) return existing; - - const request = loadExternalHistorySnapshot(sessionId, observedSignature); - if ( - inFlightExternalHistoryLoads.size < MAX_IN_FLIGHT_EXTERNAL_HISTORY_LOADS - ) { - inFlightExternalHistoryLoads.set(sessionId, request); - void request.then( - () => { - if (inFlightExternalHistoryLoads.get(sessionId) === request) { - inFlightExternalHistoryLoads.delete(sessionId); - } - }, - () => { - if (inFlightExternalHistoryLoads.get(sessionId) === request) { - inFlightExternalHistoryLoads.delete(sessionId); - } - } - ); - } - return request; -} - -async function loadExternalHistory( - sessionId: string, - signal: AbortSignal, - observedSignature?: string -): Promise { - const events = await getOrStartExternalHistoryLoad( - sessionId, - observedSignature - ); - return signal.aborted ? [] : events; -} - -export const externalHistoryAdapter: ExternalHistorySessionAdapter = { +export const externalHistoryAdapter: SessionAdapter = { category: "external_history", - - loadHistory: loadExternalHistory, - - loadHistoryFromObservedSignature: (sessionId, signal, observedSignature) => - loadExternalHistory(sessionId, signal, observedSignature), + historyMode: "bounded-replay", async postLoad() { return { runStatus: "completed" }; diff --git a/src/engines/SessionCore/sync/adapters/externalReplayPrewarm.ts b/src/engines/SessionCore/sync/adapters/externalReplayPrewarm.ts new file mode 100644 index 000000000..1c538853e --- /dev/null +++ b/src/engines/SessionCore/sync/adapters/externalReplayPrewarm.ts @@ -0,0 +1,55 @@ +/** Source-neutral prewarming on top of the bounded replay state. */ +import { + externalReplayPrewarmWindow, + resolveExternalReplayTarget, +} from "@src/api/tauri/externalHistory/replay"; +import { eventStoreProxy } from "@src/engines/SessionCore/core/store/EventStoreProxy"; +import { + captureExternalReplayTurnEpisode, + isCurrentExternalReplayTurnEpisode, + mergeExternalReplayTurnWindow, + startExternalReplayTurnEpisode, +} from "@src/engines/SessionCore/sync/externalReplayTurnState"; + +const inFlightLoads = new Map>(); + +/** + * Pre-warm a parent or nested Cursor composer without a full-bubble fallback. + * Repeated and concurrent calls are coalesced; the Rust command owns the + * EventStore write, so the bounded SessionEvent window crosses IPC only once. + */ +export async function ensureExternalReplayEventsInStore( + sessionId: string, + options?: { forceReload?: boolean } +): Promise { + if (!resolveExternalReplayTarget(sessionId)) return; + if (!options?.forceReload) { + const existing = eventStoreProxy.getLatestSessionSnapshot(sessionId); + if (existing && existing.eventCount > 0) return; + } + + const inFlight = inFlightLoads.get(sessionId); + if (inFlight && !options?.forceReload) return inFlight; + const episode = options?.forceReload + ? startExternalReplayTurnEpisode(sessionId) + : captureExternalReplayTurnEpisode(sessionId); + const work: Promise = externalReplayPrewarmWindow(sessionId, episode.id) + .then((window) => { + if (!isCurrentExternalReplayTurnEpisode(sessionId, episode)) return; + mergeExternalReplayTurnWindow(sessionId, window); + }) + .catch((error: unknown) => { + // A newer prewarm may reach Rust before an older queued IPC request. + // That old request is expected to fail its monotonic episode guard and + // must not surface as a load error for the now-current session. + if (!isCurrentExternalReplayTurnEpisode(sessionId, episode)) return; + throw error; + }) + .finally(() => { + if (inFlightLoads.get(sessionId) === work) { + inFlightLoads.delete(sessionId); + } + }); + inFlightLoads.set(sessionId, work); + return work; +} diff --git a/src/engines/SessionCore/sync/externalHistoryAutoRefresh.test.ts b/src/engines/SessionCore/sync/externalHistoryAutoRefresh.test.ts deleted file mode 100644 index 23daa34c6..000000000 --- a/src/engines/SessionCore/sync/externalHistoryAutoRefresh.test.ts +++ /dev/null @@ -1,288 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import { - type ExternalHistoryRefreshSchedulerEnvironment, - type TranscriptSettleState, - refreshImportedHistorySession, - shouldWaitForStableTranscript, - startExternalHistoryRefreshScheduler, -} from "./externalHistoryAutoRefresh"; - -const mocks = vi.hoisted(() => ({ - loadHistory: vi.fn(), - loadHistoryFromObservedSignature: vi.fn(), - getAdapterForSession: vi.fn(), -})); - -vi.mock("./types", () => ({ - getAdapterForSession: mocks.getAdapterForSession, -})); - -class RefreshSchedulerEnvironment implements ExternalHistoryRefreshSchedulerEnvironment { - hidden = false; - focused = true; - private readonly focusListeners = new Set<() => void>(); - private readonly blurListeners = new Set<() => void>(); - private readonly visibilityListeners = new Set<() => void>(); - - isHidden(): boolean { - return this.hidden; - } - - isFocused(): boolean { - return this.focused && !this.hidden; - } - - setTimer( - callback: () => void, - delayMs: number - ): ReturnType { - return setTimeout(callback, delayMs); - } - - clearTimer(timer: ReturnType): void { - clearTimeout(timer); - } - - subscribeFocus(callback: () => void): () => void { - this.focusListeners.add(callback); - return () => this.focusListeners.delete(callback); - } - - subscribeBlur(callback: () => void): () => void { - this.blurListeners.add(callback); - return () => this.blurListeners.delete(callback); - } - - subscribeVisibility(callback: () => void): () => void { - this.visibilityListeners.add(callback); - return () => this.visibilityListeners.delete(callback); - } - - setFocused(focused: boolean): void { - this.focused = focused; - const listeners = focused ? this.focusListeners : this.blurListeners; - for (const listener of listeners) listener(); - } - - setHidden(hidden: boolean): void { - this.hidden = hidden; - for (const listener of this.visibilityListeners) listener(); - } -} - -describe("refreshImportedHistorySession", () => { - beforeEach(() => { - mocks.loadHistory.mockReset(); - mocks.loadHistoryFromObservedSignature.mockReset(); - mocks.getAdapterForSession.mockReset().mockReturnValue({ - category: "external_history", - loadHistory: mocks.loadHistory, - loadHistoryFromObservedSignature: mocks.loadHistoryFromObservedSignature, - }); - }); - - it("reuses a signature already observed by the refresh scheduler", async () => { - const events = [ - { - id: "event-1", - sessionId: "codexapp-active", - createdAt: "2026-07-16T05:00:00.000Z", - }, - ]; - mocks.loadHistoryFromObservedSignature.mockResolvedValue(events); - const dispatchLoadSession = vi.fn(); - const controller = new AbortController(); - - await expect( - refreshImportedHistorySession( - "codexapp-active", - controller.signal, - dispatchLoadSession, - "100:200" - ) - ).resolves.toBe(true); - - expect(mocks.loadHistoryFromObservedSignature).toHaveBeenCalledWith( - "codexapp-active", - controller.signal, - "100:200" - ); - expect(mocks.loadHistory).not.toHaveBeenCalled(); - }); - - it("reloads and publishes the currently open external transcript", async () => { - const events = [ - { - id: "event-1", - sessionId: "codexapp-active", - createdAt: "2026-07-16T05:00:00.000Z", - }, - ]; - mocks.loadHistory.mockResolvedValue(events); - const dispatchLoadSession = vi.fn(); - const controller = new AbortController(); - - await expect( - refreshImportedHistorySession( - "codexapp-active", - controller.signal, - dispatchLoadSession - ) - ).resolves.toBe(true); - - expect(mocks.loadHistory).toHaveBeenCalledWith( - "codexapp-active", - controller.signal - ); - expect(dispatchLoadSession).toHaveBeenCalledWith({ - sessionId: "codexapp-active", - events, - replace: true, - }); - }); - - it("does not poll a native ORGII session", async () => { - await expect( - refreshImportedHistorySession( - "osagent-native", - new AbortController().signal, - vi.fn() - ) - ).resolves.toBe(false); - - expect(mocks.getAdapterForSession).not.toHaveBeenCalled(); - }); -}); - -describe("startExternalHistoryRefreshScheduler", () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it("schedules the actual background cadence instead of foreground wakeups", async () => { - const environment = new RefreshSchedulerEnvironment(); - environment.focused = false; - const poll = vi.fn(() => Promise.resolve()); - const stop = startExternalHistoryRefreshScheduler({ - poll, - foregroundIntervalMs: 3_000, - environment, - }); - - await vi.advanceTimersByTimeAsync(59_999); - expect(poll).not.toHaveBeenCalled(); - await vi.advanceTimersByTimeAsync(1); - expect(poll).toHaveBeenCalledTimes(1); - - stop(); - expect(vi.getTimerCount()).toBe(0); - }); - - it("owns no hidden timer and refreshes once when visible or focused", async () => { - const environment = new RefreshSchedulerEnvironment(); - const poll = vi.fn(() => Promise.resolve()); - const onHidden = vi.fn(); - const stop = startExternalHistoryRefreshScheduler({ - poll, - foregroundIntervalMs: 3_000, - onHidden, - environment, - }); - - expect(vi.getTimerCount()).toBe(1); - environment.setHidden(true); - expect(onHidden).toHaveBeenCalledTimes(1); - expect(vi.getTimerCount()).toBe(0); - await vi.advanceTimersByTimeAsync(60_000); - expect(poll).not.toHaveBeenCalled(); - - environment.setHidden(false); - expect(poll).toHaveBeenCalledTimes(1); - await Promise.resolve(); - expect(vi.getTimerCount()).toBe(1); - environment.setFocused(true); - expect(poll).toHaveBeenCalledTimes(1); - - environment.setFocused(false); - await vi.advanceTimersByTimeAsync(3_000); - expect(poll).toHaveBeenCalledTimes(1); - environment.setFocused(true); - expect(poll).toHaveBeenCalledTimes(2); - - stop(); - expect(vi.getTimerCount()).toBe(0); - }); - - it("never overlaps and does not reschedule after disposal", async () => { - const environment = new RefreshSchedulerEnvironment(); - let resolvePoll!: () => void; - const poll = vi.fn( - () => - new Promise((resolve) => { - resolvePoll = resolve; - }) - ); - const stop = startExternalHistoryRefreshScheduler({ - poll, - foregroundIntervalMs: 1_000, - environment, - }); - - await vi.advanceTimersByTimeAsync(1_000); - expect(poll).toHaveBeenCalledTimes(1); - environment.setFocused(false); - environment.setFocused(true); - expect(poll).toHaveBeenCalledTimes(1); - - stop(); - resolvePoll(); - await Promise.resolve(); - expect(vi.getTimerCount()).toBe(0); - }); -}); - -describe("shouldWaitForStableTranscript", () => { - it("waits for the same changed signature to remain stable", () => { - const state: TranscriptSettleState = { - signature: null, - firstObservedAt: 0, - }; - - expect(shouldWaitForStableTranscript(state, "100:200", 1_000, 5_000)).toBe( - true - ); - expect(shouldWaitForStableTranscript(state, "100:200", 5_999, 5_000)).toBe( - true - ); - expect(shouldWaitForStableTranscript(state, "100:200", 6_000, 5_000)).toBe( - false - ); - }); - - it("restarts settling when a live transcript changes again", () => { - const state: TranscriptSettleState = { - signature: "100:200", - firstObservedAt: 1_000, - }; - - expect(shouldWaitForStableTranscript(state, "101:250", 5_000, 5_000)).toBe( - true - ); - expect(state).toEqual({ signature: "101:250", firstObservedAt: 5_000 }); - }); - - it("does not block sources that cannot provide a signature", () => { - const state: TranscriptSettleState = { - signature: null, - firstObservedAt: 0, - }; - expect(shouldWaitForStableTranscript(state, null, 1_000, 5_000)).toBe( - false - ); - }); -}); diff --git a/src/engines/SessionCore/sync/externalHistoryAutoRefresh.ts b/src/engines/SessionCore/sync/externalHistoryAutoRefresh.ts deleted file mode 100644 index 594198c17..000000000 --- a/src/engines/SessionCore/sync/externalHistoryAutoRefresh.ts +++ /dev/null @@ -1,353 +0,0 @@ -import { useAtomValue } from "jotai"; -import { useEffect } from "react"; - -import { getImportedHistorySourceBySessionId } from "@src/api/tauri/externalHistory"; -import type { SessionEvent } from "@src/engines/SessionCore/core/types"; -import { createLogger } from "@src/hooks/logger"; -import { externalSessionsEnabledAtom } from "@src/store/session/dataSourceConfigAtom"; -import { isWindowFocused } from "@src/util/core/windowFocus"; -import { isImportedHistorySession } from "@src/util/session/sessionDispatch"; - -import { - getTranscriptSignature, - rememberTranscriptSignature, -} from "./externalHistoryTranscriptSignatures"; -import { type SessionAdapter, getAdapterForSession } from "./types"; - -const logger = createLogger("ExternalHistoryAutoRefresh"); - -// Refresh floor while the window is unfocused; the configured 3s-1m cadence -// only applies to a chat someone is looking at. -const UNFOCUSED_REFRESH_INTERVAL_MS = 60_000; -const MIN_TRANSCRIPT_SETTLE_MS = 2_000; - -type RefreshTimer = ReturnType; - -export interface ExternalHistoryRefreshSchedulerEnvironment { - isHidden(): boolean; - isFocused(): boolean; - setTimer(callback: () => void, delayMs: number): RefreshTimer; - clearTimer(timer: RefreshTimer): void; - subscribeFocus(callback: () => void): () => void; - subscribeBlur(callback: () => void): () => void; - subscribeVisibility(callback: () => void): () => void; -} - -const browserRefreshSchedulerEnvironment: ExternalHistoryRefreshSchedulerEnvironment = - { - isHidden: () => document.visibilityState === "hidden", - isFocused: isWindowFocused, - setTimer: (callback, delayMs) => setTimeout(callback, delayMs), - clearTimer: (timer) => clearTimeout(timer), - subscribeFocus: (callback) => { - window.addEventListener("focus", callback); - return () => window.removeEventListener("focus", callback); - }, - subscribeBlur: (callback) => { - window.addEventListener("blur", callback); - return () => window.removeEventListener("blur", callback); - }, - subscribeVisibility: (callback) => { - document.addEventListener("visibilitychange", callback); - return () => document.removeEventListener("visibilitychange", callback); - }, - }; - -/** - * Own exactly one focus-adaptive refresh timer. - * - * Hidden documents own no timer. An unfocused visible window schedules the - * next wakeup at the actual one-minute cadence instead of waking at the - * foreground interval merely to decide not to poll. - */ -export function startExternalHistoryRefreshScheduler(options: { - poll: () => Promise; - foregroundIntervalMs: number; - onHidden?: () => void; - environment?: ExternalHistoryRefreshSchedulerEnvironment; -}): () => void { - const { - poll, - foregroundIntervalMs, - onHidden, - environment = browserRefreshSchedulerEnvironment, - } = options; - let timer: RefreshTimer | undefined; - let disposed = false; - let inFlight = false; - let rerunAfterFlight = false; - let visibilityCatchupAlreadyFocused = false; - - const clearScheduledTimer = () => { - if (timer === undefined) return; - environment.clearTimer(timer); - timer = undefined; - }; - - const schedule = () => { - clearScheduledTimer(); - if (disposed || inFlight || environment.isHidden()) return; - const delayMs = environment.isFocused() - ? foregroundIntervalMs - : UNFOCUSED_REFRESH_INTERVAL_MS; - timer = environment.setTimer(() => { - timer = undefined; - void run(); - }, delayMs); - }; - - const run = async () => { - if (disposed || inFlight || environment.isHidden()) return; - clearScheduledTimer(); - inFlight = true; - try { - await poll(); - } finally { - inFlight = false; - if (rerunAfterFlight && !disposed && !environment.isHidden()) { - rerunAfterFlight = false; - void run(); - } else { - schedule(); - } - } - }; - - const handleFocus = () => { - if (visibilityCatchupAlreadyFocused) { - visibilityCatchupAlreadyFocused = false; - return; - } - if (environment.isHidden() || inFlight) return; - clearScheduledTimer(); - void run(); - }; - const handleBlur = () => { - visibilityCatchupAlreadyFocused = false; - schedule(); - }; - const handleVisibility = () => { - clearScheduledTimer(); - if (environment.isHidden()) { - rerunAfterFlight = false; - visibilityCatchupAlreadyFocused = false; - onHidden?.(); - return; - } - // Browsers commonly emit visibilitychange followed by focus for the same - // foreground transition. The visibility catch-up already covers it. - visibilityCatchupAlreadyFocused = environment.isFocused(); - if (inFlight) { - rerunAfterFlight = true; - } else { - void run(); - } - }; - - const unsubscribeFocus = environment.subscribeFocus(handleFocus); - const unsubscribeBlur = environment.subscribeBlur(handleBlur); - const unsubscribeVisibility = - environment.subscribeVisibility(handleVisibility); - schedule(); - - return () => { - disposed = true; - rerunAfterFlight = false; - visibilityCatchupAlreadyFocused = false; - clearScheduledTimer(); - unsubscribeFocus(); - unsubscribeBlur(); - unsubscribeVisibility(); - }; -} - -export type TranscriptSettleState = { - signature: string | null; - firstObservedAt: number; -}; - -export function shouldWaitForStableTranscript( - state: TranscriptSettleState, - signature: string | null, - nowMs: number, - settleMs: number -): boolean { - if (!signature) return false; - if (state.signature !== signature) { - state.signature = signature; - state.firstObservedAt = nowMs; - return true; - } - return nowMs - state.firstObservedAt < settleMs; -} - -type DispatchSessionLoad = (payload: { - sessionId: string; - events: SessionEvent[]; - replace?: boolean; -}) => void; - -interface ObservedSignatureHistoryAdapter extends SessionAdapter { - loadHistoryFromObservedSignature( - sessionId: string, - signal: AbortSignal, - observedSignature: string - ): Promise; -} - -function supportsObservedSignatureLoad( - adapter: SessionAdapter -): adapter is ObservedSignatureHistoryAdapter { - return ( - "loadHistoryFromObservedSignature" in adapter && - typeof adapter.loadHistoryFromObservedSignature === "function" - ); -} - -/** - * Incremental guard: probe the transcript's (mtime, size) and report whether - * a full reload is needed. Errs on the side of reloading (stat unsupported - * for the source, file missing, probe failed). - */ -async function transcriptChanged( - sessionId: string, - signal: AbortSignal -): Promise<{ changed: boolean; signature: string | null }> { - const source = getImportedHistorySourceBySessionId(sessionId); - if (!source?.statTranscript) return { changed: true, signature: null }; - try { - const stat = await source.statTranscript(sessionId); - if (signal.aborted || !stat) return { changed: true, signature: null }; - const signature = `${stat.mtimeMs}:${stat.sizeBytes}`; - return { - changed: getTranscriptSignature(sessionId) !== signature, - signature, - }; - } catch { - return { changed: true, signature: null }; - } -} - -/** Re-read one imported transcript without rescanning every provider cache. */ -export async function refreshImportedHistorySession( - sessionId: string, - signal: AbortSignal, - dispatchLoadSession: DispatchSessionLoad, - observedSignature?: string | null -): Promise { - if (!isImportedHistorySession(sessionId)) return false; - const adapter = getAdapterForSession(sessionId); - if (!adapter || adapter.category !== "external_history") return false; - - let signature = observedSignature; - if (signature === undefined) { - const probe = await transcriptChanged(sessionId, signal); - if (!probe.changed || signal.aborted) return false; - signature = probe.signature; - } else if (signal.aborted) { - return false; - } - - let usedObservedSignature = false; - let events: SessionEvent[]; - if (signature !== null && supportsObservedSignatureLoad(adapter)) { - usedObservedSignature = true; - events = await adapter.loadHistoryFromObservedSignature( - sessionId, - signal, - signature - ); - } else { - events = await adapter.loadHistory(sessionId, signal); - } - if (signal.aborted || events.length === 0) return false; - const source = getImportedHistorySourceBySessionId(sessionId); - dispatchLoadSession({ - sessionId, - events, - // A source-level window is a complete bounded snapshot, not an append - // delta. Replacing prevents yesterday's loaded turn bodies from surviving - // beside today's placeholders as a live external transcript grows. - replace: source?.supportsWindowedReplay === true, - }); - // The signature-aware adapter owns the post-load stat check. Remembering - // the outer signature again here would hide a transcript mutation that - // happened while the snapshot was being parsed. - if (signature && !usedObservedSignature) { - rememberTranscriptSignature(sessionId, signature); - } - return true; -} - -export function useExternalHistoryAutoRefresh(options: { - sessionId: string | null; - intervalMs: number; - dispatchLoadSession: DispatchSessionLoad; -}): void { - const { sessionId, intervalMs, dispatchLoadSession } = options; - const externalSessionsEnabled = useAtomValue(externalSessionsEnabledAtom); - - useEffect(() => { - if (!externalSessionsEnabled) return; - if (!sessionId || !isImportedHistorySession(sessionId)) return; - - let activeController: AbortController | null = null; - const settleState: TranscriptSettleState = { - signature: null, - firstObservedAt: 0, - }; - const settleMs = Math.max(intervalMs, MIN_TRANSCRIPT_SETTLE_MS); - const refresh = async () => { - const controller = new AbortController(); - activeController = controller; - try { - // A live Codex/Claude transcript can grow several times per second. - // Replacing the windowed replay on every observed size change keeps - // the UI in a loading state and retains large transient allocations. - // Wait until the same new signature survives one configured refresh - // period; the initial session load is handled by the switch adapter - // and is therefore never delayed by this auto-refresh guard. - const probe = await transcriptChanged(sessionId, controller.signal); - if (!probe.changed || controller.signal.aborted) { - settleState.signature = null; - settleState.firstObservedAt = 0; - return; - } - if ( - shouldWaitForStableTranscript( - settleState, - probe.signature, - Date.now(), - settleMs - ) - ) { - return; - } - await refreshImportedHistorySession( - sessionId, - controller.signal, - dispatchLoadSession, - probe.signature - ); - settleState.signature = null; - settleState.firstObservedAt = 0; - } catch (error) { - if (!controller.signal.aborted) { - logger.warn(`Failed to refresh ${sessionId}:`, error); - } - } finally { - if (activeController === controller) activeController = null; - } - }; - - const stopScheduler = startExternalHistoryRefreshScheduler({ - poll: refresh, - foregroundIntervalMs: intervalMs, - onHidden: () => activeController?.abort(), - }); - return () => { - stopScheduler(); - activeController?.abort(); - }; - }, [dispatchLoadSession, externalSessionsEnabled, intervalMs, sessionId]); -} diff --git a/src/engines/SessionCore/sync/externalHistoryTranscriptSignatures.ts b/src/engines/SessionCore/sync/externalHistoryTranscriptSignatures.ts deleted file mode 100644 index 9eaf73ca5..000000000 --- a/src/engines/SessionCore/sync/externalHistoryTranscriptSignatures.ts +++ /dev/null @@ -1,30 +0,0 @@ -const transcriptSignatures = new Map(); -const MAX_TRANSCRIPT_SIGNATURES = 64; - -/** - * Remember the stable on-disk snapshot that produced the currently displayed - * replay. The registry is deliberately tiny and process-local: only the one - * open external session is polled, while a short LRU tail covers tab switches. - */ -export function rememberTranscriptSignature( - sessionId: string, - signature: string -): void { - transcriptSignatures.delete(sessionId); - transcriptSignatures.set(sessionId, signature); - while (transcriptSignatures.size > MAX_TRANSCRIPT_SIGNATURES) { - const oldest = transcriptSignatures.keys().next().value as - | string - | undefined; - if (!oldest) break; - transcriptSignatures.delete(oldest); - } -} - -export function getTranscriptSignature(sessionId: string): string | undefined { - return transcriptSignatures.get(sessionId); -} - -export function forgetTranscriptSignature(sessionId: string): void { - transcriptSignatures.delete(sessionId); -} diff --git a/src/engines/SessionCore/sync/externalReplayAutoRefresh.test.ts b/src/engines/SessionCore/sync/externalReplayAutoRefresh.test.ts new file mode 100644 index 000000000..6c30f1f6c --- /dev/null +++ b/src/engines/SessionCore/sync/externalReplayAutoRefresh.test.ts @@ -0,0 +1,530 @@ +// @vitest-environment jsdom +import { act, createElement } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import { + type ExternalReplayRefreshSchedulerEnvironment, + refreshBoundedReplaySession, + startExternalReplayRefreshScheduler, + useExternalReplayAutoRefresh, +} from "./externalReplayAutoRefresh"; + +const mocks = vi.hoisted(() => ({ + getAdapterForSession: vi.fn(), + getActiveExternalReplayLease: vi.fn(), + getExternalReplayWatcherAvailable: vi.fn(() => false), + pollExternalReplaySession: vi.fn(), + listen: vi.fn(), + unlisten: vi.fn(), + isWindowFocused: vi.fn(() => true), + onWindowFocusRegained: vi.fn(() => vi.fn()), + useAtomValue: vi.fn(() => true), +})); + +const actEnvironment = globalThis as { + IS_REACT_ACT_ENVIRONMENT?: boolean; +}; +beforeAll(() => { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; +}); +afterAll(() => { + Reflect.deleteProperty(actEnvironment, "IS_REACT_ACT_ENVIRONMENT"); +}); + +vi.mock("@tauri-apps/api/event", () => ({ + listen: mocks.listen, +})); +vi.mock("jotai", () => ({ + useAtomValue: mocks.useAtomValue, +})); +vi.mock("@src/store/session/dataSourceConfigAtom", () => ({ + externalSessionsEnabledAtom: {}, +})); +vi.mock("@src/util/core/windowFocus", () => ({ + isWindowFocused: mocks.isWindowFocused, + onWindowFocusRegained: mocks.onWindowFocusRegained, +})); + +vi.mock("./types", () => ({ + getAdapterForSession: mocks.getAdapterForSession, +})); + +vi.mock("./externalReplayTransport", () => ({ + getActiveExternalReplayLease: mocks.getActiveExternalReplayLease, + getExternalReplayWatcherAvailable: mocks.getExternalReplayWatcherAvailable, + pollExternalReplaySession: mocks.pollExternalReplaySession, +})); + +class RefreshSchedulerEnvironment implements ExternalReplayRefreshSchedulerEnvironment { + hidden = false; + focused = true; + private readonly focusListeners = new Set<() => void>(); + private readonly blurListeners = new Set<() => void>(); + private readonly visibilityListeners = new Set<() => void>(); + + isHidden(): boolean { + return this.hidden; + } + + isFocused(): boolean { + return this.focused && !this.hidden; + } + + setTimer( + callback: () => void, + delayMs: number + ): ReturnType { + return setTimeout(callback, delayMs); + } + + clearTimer(timer: ReturnType): void { + clearTimeout(timer); + } + + subscribeFocus(callback: () => void): () => void { + this.focusListeners.add(callback); + return () => this.focusListeners.delete(callback); + } + + subscribeBlur(callback: () => void): () => void { + this.blurListeners.add(callback); + return () => this.blurListeners.delete(callback); + } + + subscribeVisibility(callback: () => void): () => void { + this.visibilityListeners.add(callback); + return () => this.visibilityListeners.delete(callback); + } + + setFocused(focused: boolean): void { + this.focused = focused; + const listeners = focused ? this.focusListeners : this.blurListeners; + for (const listener of listeners) listener(); + } + + setHidden(hidden: boolean): void { + this.hidden = hidden; + for (const listener of this.visibilityListeners) listener(); + } +} + +describe("refreshBoundedReplaySession", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getAdapterForSession.mockReturnValue({ + category: "external_history", + historyMode: "bounded-replay", + }); + mocks.getActiveExternalReplayLease.mockReturnValue({ + sessionId: "codexapp-active", + episodeId: 1, + }); + mocks.pollExternalReplaySession.mockResolvedValue({ + cursor: {}, + events: [{ id: "event-1" }], + removedEventIds: [], + resetRequired: false, + watcherAvailable: false, + stats: { notReady: false }, + }); + mocks.listen.mockResolvedValue(mocks.unlisten); + }); + + it("polls one true delta for the active replay lease", async () => { + const controller = new AbortController(); + + await expect( + refreshBoundedReplaySession("codexapp-active", controller.signal) + ).resolves.toBe(true); + + expect(mocks.pollExternalReplaySession).toHaveBeenCalledWith( + { sessionId: "codexapp-active", episodeId: 1 }, + controller.signal + ); + }); + + it("does not invoke replay for a native ORGII agent", async () => { + mocks.getAdapterForSession.mockReturnValue({ + category: "agent", + historyMode: "persisted-db", + }); + + await expect( + refreshBoundedReplaySession( + "osagent-native", + new AbortController().signal + ) + ).resolves.toBe(false); + + expect(mocks.getActiveExternalReplayLease).not.toHaveBeenCalled(); + expect(mocks.pollExternalReplaySession).not.toHaveBeenCalled(); + }); + + it("does no frontend work for an unchanged or not-ready source", async () => { + mocks.pollExternalReplaySession.mockResolvedValue({ + cursor: {}, + events: [], + removedEventIds: [], + resetRequired: false, + watcherAvailable: false, + stats: { notReady: true }, + }); + + await expect( + refreshBoundedReplaySession( + "codexapp-active", + new AbortController().signal + ) + ).resolves.toBe(false); + }); +}); + +function setDocumentVisibility(value: "visible" | "hidden"): void { + Object.defineProperty(document, "visibilityState", { + configurable: true, + value, + }); +} + +function RefreshHarness(props: { + sessionId: string | null; + intervalMs: number; +}) { + useExternalReplayAutoRefresh(props); + return null; +} + +describe("bounded replay refresh lifecycle", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + setDocumentVisibility("visible"); + mocks.isWindowFocused.mockReturnValue(true); + mocks.useAtomValue.mockReturnValue(true); + mocks.getAdapterForSession.mockReturnValue({ + category: "external_history", + historyMode: "bounded-replay", + }); + mocks.getActiveExternalReplayLease.mockReturnValue({ + sessionId: "codexapp-active", + episodeId: 1, + }); + mocks.getExternalReplayWatcherAvailable.mockReturnValue(false); + mocks.pollExternalReplaySession.mockResolvedValue({ + cursor: {}, + events: [], + removedEventIds: [], + resetRequired: false, + watcherAvailable: false, + stats: { notReady: false }, + }); + mocks.listen.mockResolvedValue(mocks.unlisten); + mocks.onWindowFocusRegained.mockReturnValue(vi.fn()); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.useRealTimers(); + }); + + async function renderHarness(sessionId: string | null): Promise { + await act(async () => { + root.render( + createElement(RefreshHarness, { sessionId, intervalMs: 5_000 }) + ); + await Promise.resolve(); + await Promise.resolve(); + }); + } + + function emitReplayInvalidation(payload: unknown): void { + const listener = mocks.listen.mock.calls[0]?.[1] as + | ((event: { payload: unknown }) => void) + | undefined; + expect(listener).toBeTypeOf("function"); + listener?.({ payload }); + } + + it("uses push invalidation plus a 60s safety tick when Rust reports a watcher", async () => { + mocks.getExternalReplayWatcherAvailable.mockReturnValue(true); + await renderHarness("codexapp-active"); + + await act(async () => vi.advanceTimersByTimeAsync(5_000)); + expect(mocks.pollExternalReplaySession).not.toHaveBeenCalled(); + + await act(async () => vi.advanceTimersByTimeAsync(55_000)); + expect(mocks.pollExternalReplaySession).toHaveBeenCalledTimes(1); + }); + + it("polls one bounded delta for a matching watcher invalidation only", async () => { + mocks.getExternalReplayWatcherAvailable.mockReturnValue(true); + await renderHarness("codexapp-active"); + + await act(async () => { + emitReplayInvalidation({ + sessionId: "codexapp-other", + sourceId: "codex_app", + generation: "generation-1", + }); + await Promise.resolve(); + }); + expect(mocks.pollExternalReplaySession).not.toHaveBeenCalled(); + + await act(async () => { + emitReplayInvalidation({ + sessionId: "codexapp-active", + sourceId: "codex_app", + generation: "generation-1", + }); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(mocks.pollExternalReplaySession).toHaveBeenCalledTimes(1); + }); + + it("does not dispatch watcher work while the replay is hidden", async () => { + setDocumentVisibility("hidden"); + mocks.getExternalReplayWatcherAvailable.mockReturnValue(true); + await renderHarness("codexapp-active"); + + await act(async () => { + emitReplayInvalidation({ + sessionId: "codexapp-active", + sourceId: "codex_app", + generation: "generation-1", + }); + await Promise.resolve(); + }); + expect(mocks.pollExternalReplaySession).not.toHaveBeenCalled(); + }); + + it("uses the configured short delta fallback when Rust reports no watcher", async () => { + mocks.getExternalReplayWatcherAvailable.mockReturnValue(false); + await renderHarness("codexapp-active"); + + await act(async () => vi.advanceTimersByTimeAsync(4_999)); + expect(mocks.pollExternalReplaySession).not.toHaveBeenCalled(); + await act(async () => vi.advanceTimersByTimeAsync(1)); + expect(mocks.pollExternalReplaySession).toHaveBeenCalledTimes(1); + }); + + it("upgrades an already-armed fallback to the 60s safety tick without an extra poll", async () => { + mocks.getExternalReplayWatcherAvailable.mockReturnValue(false); + await renderHarness("codexapp-active"); + + mocks.getExternalReplayWatcherAvailable.mockReturnValue(true); + await act(async () => vi.advanceTimersByTimeAsync(5_000)); + expect(mocks.pollExternalReplaySession).not.toHaveBeenCalled(); + + await act(async () => vi.advanceTimersByTimeAsync(59_999)); + expect(mocks.pollExternalReplaySession).not.toHaveBeenCalled(); + await act(async () => vi.advanceTimersByTimeAsync(1)); + expect(mocks.pollExternalReplaySession).toHaveBeenCalledTimes(1); + }); + + it("keeps ORGII collaboration snapshots live when vendor history discovery is disabled", async () => { + mocks.useAtomValue.mockReturnValue(false); + mocks.getActiveExternalReplayLease.mockReturnValue({ + sessionId: "imported-session-active", + episodeId: 1, + }); + await renderHarness("imported-session-active"); + + await act(async () => vi.advanceTimersByTimeAsync(5_000)); + expect(mocks.pollExternalReplaySession).toHaveBeenCalledTimes(1); + }); + + it("does not refresh discovered vendor history while discovery is disabled", async () => { + mocks.useAtomValue.mockReturnValue(false); + await renderHarness("codexapp-active"); + + await act(async () => vi.advanceTimersByTimeAsync(120_000)); + expect(mocks.pollExternalReplaySession).not.toHaveBeenCalled(); + }); + + it("owns no refresh timer while hidden or inactive", async () => { + setDocumentVisibility("hidden"); + await renderHarness("codexapp-active"); + await act(async () => vi.advanceTimersByTimeAsync(120_000)); + expect(mocks.pollExternalReplaySession).not.toHaveBeenCalled(); + + setDocumentVisibility("visible"); + await act(async () => { + root.render( + createElement(RefreshHarness, { sessionId: null, intervalMs: 5_000 }) + ); + await Promise.resolve(); + }); + await act(async () => vi.advanceTimersByTimeAsync(120_000)); + expect(mocks.pollExternalReplaySession).not.toHaveBeenCalled(); + }); + + it("uses only the 60s integrity cadence while the window is unfocused", async () => { + mocks.isWindowFocused.mockReturnValue(false); + await renderHarness("codexapp-active"); + + await act(async () => vi.advanceTimersByTimeAsync(59_999)); + expect(mocks.pollExternalReplaySession).not.toHaveBeenCalled(); + await act(async () => vi.advanceTimersByTimeAsync(1)); + expect(mocks.pollExternalReplaySession).toHaveBeenCalledTimes(1); + }); + + it("aborts in-flight refresh work and removes the watcher listener on session switch", async () => { + let finishFirstPoll!: () => void; + let firstSignal: AbortSignal | undefined; + mocks.getActiveExternalReplayLease.mockImplementation((sessionId) => ({ + sessionId, + episodeId: sessionId === "codexapp-a" ? 1 : 2, + })); + mocks.pollExternalReplaySession.mockImplementationOnce( + (_lease, signal: AbortSignal) => { + firstSignal = signal; + return new Promise((resolve) => { + finishFirstPoll = () => + resolve({ + cursor: {}, + events: [], + removedEventIds: [], + resetRequired: false, + watcherAvailable: false, + stats: { notReady: false }, + }); + }); + } + ); + await renderHarness("codexapp-a"); + + await act(async () => vi.advanceTimersByTimeAsync(5_000)); + expect(mocks.pollExternalReplaySession).toHaveBeenCalledTimes(1); + expect(firstSignal?.aborted).toBe(false); + + await renderHarness("codexapp-b"); + + expect(firstSignal?.aborted).toBe(true); + expect(mocks.unlisten).toHaveBeenCalledTimes(1); + finishFirstPoll(); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + // The old completion sees its disposed hook episode and cannot arm a new + // timer. Only B's own fallback tick may produce the next poll. + await act(async () => vi.advanceTimersByTimeAsync(4_999)); + expect(mocks.pollExternalReplaySession).toHaveBeenCalledTimes(1); + await act(async () => vi.advanceTimersByTimeAsync(1)); + expect(mocks.pollExternalReplaySession).toHaveBeenCalledTimes(2); + expect(mocks.pollExternalReplaySession.mock.calls[1]?.[0]).toEqual({ + sessionId: "codexapp-b", + episodeId: 2, + }); + }); +}); + +describe("startExternalReplayRefreshScheduler", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("schedules the actual background cadence instead of foreground wakeups", async () => { + const environment = new RefreshSchedulerEnvironment(); + environment.focused = false; + const poll = vi.fn(() => Promise.resolve()); + const scheduler = startExternalReplayRefreshScheduler({ + poll, + foregroundIntervalMs: 3_000, + environment, + }); + + await vi.advanceTimersByTimeAsync(59_999); + expect(poll).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(poll).toHaveBeenCalledTimes(1); + + scheduler.stop(); + expect(vi.getTimerCount()).toBe(0); + }); + + it("owns no hidden timer and refreshes once when visible or focused", async () => { + const environment = new RefreshSchedulerEnvironment(); + const poll = vi.fn(() => Promise.resolve()); + const onHidden = vi.fn(); + const scheduler = startExternalReplayRefreshScheduler({ + poll, + foregroundIntervalMs: 3_000, + onHidden, + environment, + }); + + expect(vi.getTimerCount()).toBe(1); + environment.setHidden(true); + expect(onHidden).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); + await vi.advanceTimersByTimeAsync(60_000); + expect(poll).not.toHaveBeenCalled(); + + environment.setHidden(false); + expect(poll).toHaveBeenCalledTimes(1); + await Promise.resolve(); + expect(vi.getTimerCount()).toBe(1); + environment.setFocused(true); + expect(poll).toHaveBeenCalledTimes(1); + + environment.setFocused(false); + await vi.advanceTimersByTimeAsync(3_000); + expect(poll).toHaveBeenCalledTimes(1); + environment.setFocused(true); + expect(poll).toHaveBeenCalledTimes(2); + + scheduler.stop(); + expect(vi.getTimerCount()).toBe(0); + }); + + it("never overlaps and does not reschedule after disposal", async () => { + const environment = new RefreshSchedulerEnvironment(); + let resolvePoll!: () => void; + const poll = vi.fn( + () => + new Promise((resolve) => { + resolvePoll = resolve; + }) + ); + const scheduler = startExternalReplayRefreshScheduler({ + poll, + foregroundIntervalMs: 1_000, + environment, + }); + + await vi.advanceTimersByTimeAsync(1_000); + expect(poll).toHaveBeenCalledTimes(1); + environment.setFocused(false); + environment.setFocused(true); + expect(poll).toHaveBeenCalledTimes(1); + + scheduler.stop(); + resolvePoll(); + await Promise.resolve(); + expect(vi.getTimerCount()).toBe(0); + }); +}); diff --git a/src/engines/SessionCore/sync/externalReplayAutoRefresh.ts b/src/engines/SessionCore/sync/externalReplayAutoRefresh.ts new file mode 100644 index 000000000..2e60c63dc --- /dev/null +++ b/src/engines/SessionCore/sync/externalReplayAutoRefresh.ts @@ -0,0 +1,293 @@ +import { type UnlistenFn, listen } from "@tauri-apps/api/event"; +import { useAtomValue } from "jotai"; +import { useEffect } from "react"; + +import { + EXTERNAL_REPLAY_INVALIDATED_EVENT, + type ExternalReplayInvalidation, +} from "@src/api/tauri/externalHistory/replay"; +import { ExternalReplayInvalidationSchema } from "@src/api/tauri/rpc/schemas/externalReplay"; +import { createLogger } from "@src/hooks/logger"; +import { externalSessionsEnabledAtom } from "@src/store/session/dataSourceConfigAtom"; +import { isWindowFocused } from "@src/util/core/windowFocus"; +import { getExternalHistorySourceId } from "@src/util/session/sessionDispatch"; + +import { + getActiveExternalReplayLease, + getExternalReplayWatcherAvailable, + pollExternalReplaySession, +} from "./externalReplayTransport"; +import { getAdapterForSession } from "./types"; + +const logger = createLogger("ExternalReplayAutoRefresh"); +const REPLAY_WATCHER_SAFETY_INTERVAL_MS = 60_000; +const UNFOCUSED_REFRESH_INTERVAL_MS = 60_000; + +type RefreshTimer = ReturnType; + +export interface ExternalReplayRefreshSchedulerEnvironment { + isHidden(): boolean; + isFocused(): boolean; + setTimer(callback: () => void, delayMs: number): RefreshTimer; + clearTimer(timer: RefreshTimer): void; + subscribeFocus(callback: () => void): () => void; + subscribeBlur(callback: () => void): () => void; + subscribeVisibility(callback: () => void): () => void; +} + +export interface ExternalReplayRefreshScheduler { + trigger(): void; + reschedule(): void; + stop(): void; +} + +const browserRefreshSchedulerEnvironment: ExternalReplayRefreshSchedulerEnvironment = + { + isHidden: () => document.visibilityState === "hidden", + isFocused: isWindowFocused, + setTimer: (callback, delayMs) => setTimeout(callback, delayMs), + clearTimer: (timer) => clearTimeout(timer), + subscribeFocus: (callback) => { + window.addEventListener("focus", callback); + return () => window.removeEventListener("focus", callback); + }, + subscribeBlur: (callback) => { + window.addEventListener("blur", callback); + return () => window.removeEventListener("blur", callback); + }, + subscribeVisibility: (callback) => { + document.addEventListener("visibilitychange", callback); + return () => document.removeEventListener("visibilitychange", callback); + }, + }; + +/** + * Own one focus-adaptive, single-flight refresh schedule. + * + * Hidden sessions own no timer. Unfocused but visible windows use only the + * one-minute integrity cadence. The foreground interval may change when a + * source watcher becomes available, so a previously armed fallback timer + * upgrades without performing an unnecessary poll. + */ +export function startExternalReplayRefreshScheduler(options: { + poll: () => Promise; + foregroundIntervalMs: number | (() => number); + onHidden?: () => void; + environment?: ExternalReplayRefreshSchedulerEnvironment; +}): ExternalReplayRefreshScheduler { + const { + poll, + foregroundIntervalMs, + onHidden, + environment = browserRefreshSchedulerEnvironment, + } = options; + let timer: RefreshTimer | undefined; + let disposed = false; + let inFlight = false; + let rerunAfterFlight = false; + let visibilityCatchupAlreadyFocused = false; + + const clearScheduledTimer = (): void => { + if (timer === undefined) return; + environment.clearTimer(timer); + timer = undefined; + }; + + const requestedForegroundDelay = (): number => + typeof foregroundIntervalMs === "function" + ? foregroundIntervalMs() + : foregroundIntervalMs; + + const desiredDelay = (): number => + environment.isFocused() + ? requestedForegroundDelay() + : UNFOCUSED_REFRESH_INTERVAL_MS; + + const schedule = (): void => { + clearScheduledTimer(); + if (disposed || inFlight || environment.isHidden()) return; + const delayMs = desiredDelay(); + timer = environment.setTimer(() => { + timer = undefined; + if (desiredDelay() > delayMs) { + schedule(); + return; + } + void run(); + }, delayMs); + }; + + const run = async (): Promise => { + if (disposed || environment.isHidden()) return; + if (inFlight) { + rerunAfterFlight = true; + return; + } + clearScheduledTimer(); + inFlight = true; + try { + await poll(); + } finally { + inFlight = false; + if (rerunAfterFlight && !disposed && !environment.isHidden()) { + rerunAfterFlight = false; + void run(); + } else { + schedule(); + } + } + }; + + const trigger = (): void => { + if (disposed || environment.isHidden()) return; + void run(); + }; + const handleFocus = (): void => { + if (visibilityCatchupAlreadyFocused) { + visibilityCatchupAlreadyFocused = false; + return; + } + trigger(); + }; + const handleBlur = (): void => { + visibilityCatchupAlreadyFocused = false; + schedule(); + }; + const handleVisibility = (): void => { + clearScheduledTimer(); + if (environment.isHidden()) { + rerunAfterFlight = false; + visibilityCatchupAlreadyFocused = false; + onHidden?.(); + return; + } + // Browsers commonly emit visibilitychange followed by focus for the same + // foreground transition. The visibility catch-up already covers it. + visibilityCatchupAlreadyFocused = environment.isFocused(); + trigger(); + }; + + const unsubscribeFocus = environment.subscribeFocus(handleFocus); + const unsubscribeBlur = environment.subscribeBlur(handleBlur); + const unsubscribeVisibility = + environment.subscribeVisibility(handleVisibility); + schedule(); + + return { + trigger, + reschedule: schedule, + stop(): void { + disposed = true; + rerunAfterFlight = false; + visibilityCatchupAlreadyFocused = false; + clearScheduledTimer(); + unsubscribeFocus(); + unsubscribeBlur(); + unsubscribeVisibility(); + }, + }; +} + +function isReplayForeground(): boolean { + return document.visibilityState !== "hidden"; +} + +/** Poll exactly one bounded delta for the currently visible replay episode. */ +export async function refreshBoundedReplaySession( + sessionId: string, + signal: AbortSignal +): Promise { + const adapter = getAdapterForSession(sessionId); + if (adapter?.historyMode !== "bounded-replay") return false; + const lease = getActiveExternalReplayLease(sessionId); + if (!lease || signal.aborted) return false; + + const delta = await pollExternalReplaySession(lease, signal); + if (!delta || signal.aborted || delta.stats.notReady) return false; + return ( + delta.resetRequired || + delta.events.length > 0 || + delta.removedEventIds.length > 0 + ); +} + +export function useExternalReplayAutoRefresh(options: { + sessionId: string | null; + intervalMs: number; +}): void { + const { sessionId, intervalMs } = options; + const externalSessionsEnabled = useAtomValue(externalSessionsEnabledAtom); + + useEffect(() => { + if (!sessionId) return; + const adapter = getAdapterForSession(sessionId); + if (adapter?.historyMode !== "bounded-replay") return; + // The preference controls local vendor-history discovery only. ORGII-owned + // collaboration snapshots remain replayable even when that discovery is + // disabled, matching their pre-bounded-replay lifecycle. + if (getExternalHistorySourceId(sessionId) && !externalSessionsEnabled) { + return; + } + + let disposed = false; + let activeController: AbortController | null = null; + let unlistenInvalidation: UnlistenFn | null = null; + + const refresh = async (): Promise => { + if (!isReplayForeground()) return; + // The transport coordinator is the authoritative single-flight gate. + // This controller only invalidates this hook episode on hide/unmount. + const controller = new AbortController(); + activeController = controller; + try { + await refreshBoundedReplaySession(sessionId, controller.signal); + } catch (error) { + if (!controller.signal.aborted) { + logger.warn(`Failed to refresh ${sessionId}:`, error); + } + } finally { + if (activeController === controller) activeController = null; + } + }; + const scheduler = startExternalReplayRefreshScheduler({ + poll: refresh, + foregroundIntervalMs: () => { + const lease = getActiveExternalReplayLease(sessionId); + return lease && getExternalReplayWatcherAvailable(lease) + ? REPLAY_WATCHER_SAFETY_INTERVAL_MS + : Math.max(intervalMs, 1_000); + }, + onHidden: () => activeController?.abort(), + }); + + void listen( + EXTERNAL_REPLAY_INVALIDATED_EVENT, + (event) => { + const parsed = ExternalReplayInvalidationSchema.safeParse( + event.payload + ); + if (!parsed.success || parsed.data.sessionId !== sessionId) return; + scheduler.trigger(); + } + ) + .then((unlisten) => { + if (disposed) { + unlisten(); + } else { + unlistenInvalidation = unlisten; + } + }) + .catch((error) => { + if (!disposed) { + logger.warn("Failed to subscribe to replay invalidation:", error); + } + }); + + return () => { + disposed = true; + scheduler.stop(); + activeController?.abort(); + unlistenInvalidation?.(); + }; + }, [externalSessionsEnabled, intervalMs, sessionId]); +} diff --git a/src/engines/SessionCore/sync/externalReplayBridge.test.ts b/src/engines/SessionCore/sync/externalReplayBridge.test.ts new file mode 100644 index 000000000..1c04992f1 --- /dev/null +++ b/src/engines/SessionCore/sync/externalReplayBridge.test.ts @@ -0,0 +1,137 @@ +// @vitest-environment jsdom +import { Provider, createStore } from "jotai"; +import { createElement } from "react"; +import { act } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import { eventsAtom } from "@src/engines/SessionCore/core/atoms/events"; +import { sessionIdAtom } from "@src/engines/SessionCore/core/atoms/metadata"; +import { useEventStoreBridge } from "@src/engines/SessionCore/core/store/useEventStoreBridge"; +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; + +const mocks = vi.hoisted(() => ({ + init: vi.fn(async () => {}), + subscribe: vi.fn(), + detachTauri: vi.fn(), + getLatestSessionSnapshot: vi.fn(() => null), +})); + +const actEnvironment = globalThis as { + IS_REACT_ACT_ENVIRONMENT?: boolean; +}; +beforeAll(() => { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; +}); +afterAll(() => { + Reflect.deleteProperty(actEnvironment, "IS_REACT_ACT_ENVIRONMENT"); +}); + +vi.mock("@src/engines/SessionCore/core/store/EventStoreProxy", () => ({ + eventStoreProxy: { + init: mocks.init, + subscribe: mocks.subscribe, + detachTauri: mocks.detachTauri, + getLatestSessionSnapshot: mocks.getLatestSessionSnapshot, + }, + isStreamingSnapshot: () => false, +})); + +function event(id: string): SessionEvent { + return { + id, + chunk_id: id, + sessionId: "codexapp-visible", + createdAt: "2026-07-22T00:00:00.000Z", + functionName: "assistant", + uiCanonical: "assistant", + actionType: "raw", + args: {}, + result: {}, + source: "assistant", + displayText: id, + displayStatus: "completed", + displayVariant: "message", + activityStatus: "processed", + }; +} + +function snapshot(version: number, events: SessionEvent[]) { + return { + version, + eventCount: events.length, + events, + chatEvents: events, + messagesEvents: events, + sortedSimulatorEvents: events, + lastEvent: events.at(-1) ?? null, + eventIndex: Object.fromEntries(events.map((row, index) => [row.id, index])), + chatEventCount: events.length, + hasRunningEvent: false, + }; +} + +function BridgeHarness() { + useEventStoreBridge(); + return null; +} + +describe("external replay EventStore bridge", () => { + let root: Root; + let container: HTMLDivElement; + let listener: ((value: unknown, sessionId: string) => void) | null; + + beforeEach(() => { + vi.clearAllMocks(); + listener = null; + mocks.subscribe.mockImplementation((next) => { + listener = next; + return vi.fn(); + }); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + }); + + it("projects Rust replay upserts, removals and resets into the visible atoms", async () => { + const store = createStore(); + store.set(sessionIdAtom, "codexapp-visible"); + await act(async () => { + root.render( + createElement(Provider, { store }, createElement(BridgeHarness)) + ); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(listener).not.toBeNull(); + + const first = event("event-old"); + await act(async () => { + listener?.(snapshot(1, [first]), "codexapp-visible"); + }); + expect(store.get(eventsAtom).map((row) => row.id)).toEqual(["event-old"]); + + // This is the full snapshot emitted after Rust atomically applies a + // replay delta/reset: old id removed, new id upserted. No JS→Rust write is + // needed for the visible projection to converge. + const replacement = event("event-new"); + await act(async () => { + listener?.(snapshot(2, [replacement]), "codexapp-visible"); + }); + expect(store.get(eventsAtom).map((row) => row.id)).toEqual(["event-new"]); + }); +}); diff --git a/src/engines/SessionCore/sync/externalReplayTransport.test.ts b/src/engines/SessionCore/sync/externalReplayTransport.test.ts new file mode 100644 index 000000000..884c34bc0 --- /dev/null +++ b/src/engines/SessionCore/sync/externalReplayTransport.test.ts @@ -0,0 +1,295 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { externalReplayQueryWindow } from "@src/api/tauri/externalHistory/replay"; + +import { + type ExternalReplaySessionLease, + activateExternalReplaySession, + deactivateExternalReplaySession, + getExternalReplayCursorForTest, + openExternalReplaySession, + pollExternalReplaySession, + readExternalReplaySession, +} from "./externalReplayTransport"; + +const mocks = vi.hoisted(() => ({ + openWindow: vi.fn(), + pollDelta: vi.fn(), + readWindow: vi.fn(), + queryWindow: vi.fn(), + release: vi.fn(async () => {}), + deactivateTurnState: vi.fn(), +})); + +vi.mock("@src/api/tauri/externalHistory/replay", () => ({ + resolveExternalReplayTarget: (sessionId: string) => ({ + sourceId: sessionId.startsWith("cliagent-") ? "managed_cli" : "codex_app", + sessionId, + }), + externalReplayOpenWindow: mocks.openWindow, + externalReplayPollDelta: mocks.pollDelta, + externalReplayReadWindow: mocks.readWindow, + externalReplayQueryWindow: mocks.queryWindow, + externalReplayRelease: mocks.release, +})); + +vi.mock("./externalReplayTurnState", () => ({ + deactivateExternalReplayTurnState: mocks.deactivateTurnState, +})); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +function cursor(sessionId: string, revision: number) { + return { + sourceId: "codex_app" as const, + sessionId, + generation: "generation-1", + revision, + throughSequence: revision, + }; +} + +function windowResult(sessionId: string, revision: number) { + return { + cursor: cursor(sessionId, revision), + events: [], + turnHeaders: [], + totalEventCount: 0, + totalTurnCount: 0, + hasOlder: false, + watcherAvailable: false, + stats: { + parsedBytes: 0, + parsedRows: 0, + normalizedEvents: 0, + upsertedEvents: 0, + removedEvents: 0, + ipcBytes: 0, + notReady: false, + }, + }; +} + +function deltaResult(sessionId: string, revision: number) { + return { + cursor: cursor(sessionId, revision), + events: [], + removedEventIds: [], + resetRequired: false, + watcherAvailable: false, + stats: { + parsedBytes: 0, + parsedRows: 0, + normalizedEvents: 0, + upsertedEvents: 0, + removedEvents: 0, + ipcBytes: 0, + notReady: false, + }, + }; +} + +let currentLease: ExternalReplaySessionLease | null = null; + +describe("external replay transport coordinator", () => { + beforeEach(() => { + vi.clearAllMocks(); + currentLease = null; + }); + + afterEach(() => { + if (currentLease) deactivateExternalReplaySession(currentLease); + }); + + it("single-flights concurrent polls and advances one cursor", async () => { + currentLease = activateExternalReplaySession("codexapp-session-1"); + mocks.openWindow.mockResolvedValue(windowResult(currentLease.sessionId, 1)); + await openExternalReplaySession(currentLease); + + const pending = deferred>(); + mocks.pollDelta.mockReturnValue(pending.promise); + const first = pollExternalReplaySession(currentLease); + const second = pollExternalReplaySession(currentLease); + + expect(mocks.pollDelta).toHaveBeenCalledTimes(1); + pending.resolve(deltaResult(currentLease.sessionId, 2)); + await expect(first).resolves.toEqual( + deltaResult(currentLease.sessionId, 2) + ); + await expect(second).resolves.toEqual( + deltaResult(currentLease.sessionId, 2) + ); + expect(getExternalReplayCursorForTest(currentLease)?.revision).toBe(2); + }); + + it("keeps an in-flight foreground poll valid while a pure hover query runs", async () => { + currentLease = activateExternalReplaySession("codexapp-session-1"); + mocks.openWindow.mockResolvedValue(windowResult(currentLease.sessionId, 1)); + await openExternalReplaySession(currentLease); + + const pending = deferred>(); + mocks.pollDelta.mockReturnValue(pending.promise); + mocks.queryWindow.mockResolvedValue( + windowResult(currentLease.sessionId, 99) + ); + const poll = pollExternalReplaySession(currentLease); + await externalReplayQueryWindow({ + sessionId: currentLease.sessionId, + limits: { maxTurns: 1, maxEvents: 1, maxIpcBytes: 64 * 1024 }, + }); + + pending.resolve(deltaResult(currentLease.sessionId, 2)); + await expect(poll).resolves.toEqual(deltaResult(currentLease.sessionId, 2)); + expect(getExternalReplayCursorForTest(currentLease)?.revision).toBe(2); + expect(mocks.openWindow).toHaveBeenCalledTimes(1); + expect(mocks.pollDelta).toHaveBeenCalledTimes(1); + }); + + it("serializes an older-page read behind an in-flight foreground poll", async () => { + currentLease = activateExternalReplaySession("codexapp-session-1"); + mocks.openWindow.mockResolvedValue(windowResult(currentLease.sessionId, 1)); + await openExternalReplaySession(currentLease); + + const pendingPoll = deferred>(); + mocks.pollDelta.mockReturnValue(pendingPoll.promise); + const poll = pollExternalReplaySession(currentLease); + mocks.readWindow.mockResolvedValue(windowResult(currentLease.sessionId, 1)); + const read = readExternalReplaySession(currentLease, { turnIndex: 0 }); + + expect(mocks.readWindow).not.toHaveBeenCalled(); + pendingPoll.resolve(deltaResult(currentLease.sessionId, 2)); + await expect(poll).resolves.toEqual(deltaResult(currentLease.sessionId, 2)); + await expect(read).resolves.toEqual( + windowResult(currentLease.sessionId, 1) + ); + expect(mocks.readWindow).toHaveBeenCalledWith({ + sessionId: currentLease.sessionId, + episodeId: currentLease.episodeId, + turnIndex: 0, + }); + expect(getExternalReplayCursorForTest(currentLease)?.revision).toBe(2); + }); + + it("queues different older pages and suppresses polls until both finish", async () => { + currentLease = activateExternalReplaySession("codexapp-session-1"); + mocks.openWindow.mockResolvedValue(windowResult(currentLease.sessionId, 3)); + await openExternalReplaySession(currentLease); + + const firstPage = deferred>(); + const secondPage = deferred>(); + mocks.readWindow + .mockReturnValueOnce(firstPage.promise) + .mockReturnValueOnce(secondPage.promise); + const first = readExternalReplaySession(currentLease, { turnIndex: 1 }); + const second = readExternalReplaySession(currentLease, { turnIndex: 0 }); + + await vi.waitFor(() => expect(mocks.readWindow).toHaveBeenCalledTimes(1)); + await expect(pollExternalReplaySession(currentLease)).resolves.toBeNull(); + expect(mocks.pollDelta).not.toHaveBeenCalled(); + + firstPage.resolve(windowResult(currentLease.sessionId, 3)); + await expect(first).resolves.toEqual( + windowResult(currentLease.sessionId, 3) + ); + await vi.waitFor(() => expect(mocks.readWindow).toHaveBeenCalledTimes(2)); + secondPage.resolve(windowResult(currentLease.sessionId, 3)); + await expect(second).resolves.toEqual( + windowResult(currentLease.sessionId, 3) + ); + expect( + mocks.readWindow.mock.calls.map(([options]) => options.turnIndex) + ).toEqual([1, 0]); + expect(getExternalReplayCursorForTest(currentLease)?.revision).toBe(3); + }); + + it("releases the exact backend episode on deactivate", () => { + const lease = activateExternalReplaySession("codexapp-session-release"); + currentLease = lease; + expect(lease.signal.aborted).toBe(false); + deactivateExternalReplaySession(lease); + currentLease = null; + expect(lease.signal.aborted).toBe(true); + expect(mocks.release).toHaveBeenCalledWith( + lease.sessionId, + lease.episodeId + ); + expect(mocks.deactivateTurnState).toHaveBeenCalledWith(lease.sessionId); + }); + + it("drops a late A result after switching A→B→A", async () => { + const firstA = activateExternalReplaySession("codexapp-session-a"); + const staleOpen = deferred>(); + mocks.openWindow.mockReturnValueOnce(staleOpen.promise); + const staleResult = openExternalReplaySession(firstA); + + deactivateExternalReplaySession(firstA); + expect(firstA.signal.aborted).toBe(true); + const sessionB = activateExternalReplaySession("codexapp-session-b"); + deactivateExternalReplaySession(sessionB); + expect(sessionB.signal.aborted).toBe(true); + currentLease = activateExternalReplaySession("codexapp-session-a"); + expect(currentLease.signal.aborted).toBe(false); + + staleOpen.resolve(windowResult(firstA.sessionId, 99)); + await expect(staleResult).resolves.toBeNull(); + expect(getExternalReplayCursorForTest(currentLease)).toBeNull(); + + mocks.openWindow.mockResolvedValueOnce( + windowResult(currentLease.sessionId, 1) + ); + await openExternalReplaySession(currentLease); + expect(getExternalReplayCursorForTest(currentLease)?.revision).toBe(1); + }); + + it("drops a late A poll and ignores its delayed cleanup after A→B→A", async () => { + const firstA = activateExternalReplaySession("codexapp-session-a"); + mocks.openWindow.mockResolvedValue(windowResult(firstA.sessionId, 1)); + await openExternalReplaySession(firstA); + + const stalePoll = deferred>(); + mocks.pollDelta.mockReturnValueOnce(stalePoll.promise); + const staleResult = pollExternalReplaySession(firstA); + + const sessionB = activateExternalReplaySession("codexapp-session-b"); + expect(firstA.signal.aborted).toBe(true); + deactivateExternalReplaySession(sessionB); + currentLease = activateExternalReplaySession("codexapp-session-a"); + const releaseCountBeforeStaleCleanup = mocks.release.mock.calls.length; + + // React can deliver an old effect cleanup after the same public session + // id has already entered a new visible episode. It must not release A2. + deactivateExternalReplaySession(firstA); + expect(mocks.release).toHaveBeenCalledTimes(releaseCountBeforeStaleCleanup); + expect(currentLease.signal.aborted).toBe(false); + + stalePoll.resolve(deltaResult(firstA.sessionId, 99)); + await expect(staleResult).resolves.toBeNull(); + expect(getExternalReplayCursorForTest(currentLease)).toBeNull(); + + mocks.openWindow.mockResolvedValueOnce( + windowResult(currentLease.sessionId, 2) + ); + await openExternalReplaySession(currentLease); + expect(getExternalReplayCursorForTest(currentLease)?.revision).toBe(2); + }); + + it("invalidates an in-flight completion when its signal aborts", async () => { + currentLease = activateExternalReplaySession("codexapp-session-1"); + const pending = deferred>(); + mocks.openWindow.mockReturnValue(pending.promise); + const controller = new AbortController(); + const result = openExternalReplaySession(currentLease, controller.signal); + + controller.abort(); + pending.resolve(windowResult(currentLease.sessionId, 3)); + + await expect(result).resolves.toBeNull(); + expect(getExternalReplayCursorForTest(currentLease)).toBeNull(); + }); +}); diff --git a/src/engines/SessionCore/sync/externalReplayTransport.ts b/src/engines/SessionCore/sync/externalReplayTransport.ts new file mode 100644 index 000000000..54a783eb2 --- /dev/null +++ b/src/engines/SessionCore/sync/externalReplayTransport.ts @@ -0,0 +1,234 @@ +import { + type ExternalReplayCursor, + type ExternalReplayDelta, + type ExternalReplayWindow, + externalReplayOpenWindow, + externalReplayPollDelta, + externalReplayReadWindow, + externalReplayRelease, + resolveExternalReplayTarget, +} from "@src/api/tauri/externalHistory/replay"; +import { createLogger } from "@src/hooks/logger"; + +import { deactivateExternalReplayTurnState } from "./externalReplayTurnState"; + +const log = createLogger("externalReplayTransport"); + +export interface ExternalReplaySessionLease { + readonly sessionId: string; + readonly episodeId: number; + /** Aborted exactly when this visible replay episode is superseded/released. */ + readonly signal: AbortSignal; +} + +interface ActiveReplaySession { + lease: ExternalReplaySessionLease; + controller: AbortController; + cursor: ExternalReplayCursor | null; + watcherAvailable: boolean; + openInFlight: Promise | null; + pollInFlight: Promise | null; + /** Serialized foreground older-page reads; Rust request tokens are exclusive. */ + readTail: Promise | null; +} + +// Monotonic across ordinary renderer reloads as well as A→B→A switches. +// The backend compares this episode id before accepting delayed open/release +// commands, so an old cleanup cannot tear down a newly opened watcher. +let nextReplayEpisodeId = Date.now() * 1_000; +let activeReplaySession: ActiveReplaySession | null = null; + +function releaseBackendEpisode(lease: ExternalReplaySessionLease): void { + void externalReplayRelease(lease.sessionId, lease.episodeId).catch( + (error) => { + log.warn("Failed to release external replay foreground lease", error); + } + ); +} + +function isCurrentLease(lease: ExternalReplaySessionLease): boolean { + return ( + activeReplaySession?.lease.sessionId === lease.sessionId && + activeReplaySession.lease.episodeId === lease.episodeId + ); +} + +/** Begin a new visible replay episode. Every switch/reload gets a new episode id. */ +export function activateExternalReplaySession( + sessionId: string +): ExternalReplaySessionLease { + if (!resolveExternalReplayTarget(sessionId)) { + throw new Error(`Cannot activate bounded replay for ${sessionId}`); + } + if (activeReplaySession) { + activeReplaySession.controller.abort(); + releaseBackendEpisode(activeReplaySession.lease); + deactivateExternalReplayTurnState(activeReplaySession.lease.sessionId); + } + const controller = new AbortController(); + const lease = Object.freeze({ + sessionId, + episodeId: ++nextReplayEpisodeId, + signal: controller.signal, + }); + activeReplaySession = { + lease, + controller, + cursor: null, + watcherAvailable: false, + openInFlight: null, + pollInFlight: null, + readTail: null, + }; + return lease; +} + +/** Invalidate all late completions owned by this visible replay episode. */ +export function deactivateExternalReplaySession( + lease: ExternalReplaySessionLease +): void { + if (!isCurrentLease(lease)) return; + activeReplaySession?.controller.abort(); + activeReplaySession = null; + nextReplayEpisodeId += 1; + deactivateExternalReplayTurnState(lease.sessionId); + releaseBackendEpisode(lease); +} + +export function getActiveExternalReplayLease( + sessionId: string +): ExternalReplaySessionLease | null { + return activeReplaySession?.lease.sessionId === sessionId + ? activeReplaySession.lease + : null; +} + +export async function openExternalReplaySession( + lease: ExternalReplaySessionLease, + signal?: AbortSignal +): Promise { + if (!isCurrentLease(lease) || signal?.aborted) return null; + const state = activeReplaySession; + if (!state) return null; + + const request = + state.openInFlight ?? + externalReplayOpenWindow(lease.sessionId, lease.episodeId); + state.openInFlight = request; + try { + const window = await request; + if (!isCurrentLease(lease) || signal?.aborted) return null; + activeReplaySession!.cursor = window.cursor; + activeReplaySession!.watcherAvailable = window.watcherAvailable; + return window; + } finally { + if ( + isCurrentLease(lease) && + activeReplaySession?.openInFlight === request + ) { + activeReplaySession.openInFlight = null; + } + } +} + +/** + * Poll one true source delta. Concurrent timer/focus/watcher triggers share + * one request; late A→B→A completions cannot advance the new episode cursor. + */ +export async function pollExternalReplaySession( + lease: ExternalReplaySessionLease, + signal?: AbortSignal +): Promise { + if (!isCurrentLease(lease) || signal?.aborted) return null; + const state = activeReplaySession; + if (!state?.cursor || state.openInFlight || state.readTail) return null; + + const cursor = state.cursor; + const request = + state.pollInFlight ?? + externalReplayPollDelta(lease.sessionId, lease.episodeId, cursor); + state.pollInFlight = request; + try { + const delta = await request; + if (!isCurrentLease(lease) || signal?.aborted) return null; + activeReplaySession!.cursor = delta.cursor; + activeReplaySession!.watcherAvailable = delta.watcherAvailable; + return delta; + } finally { + if ( + isCurrentLease(lease) && + activeReplaySession?.pollInFlight === request + ) { + activeReplaySession.pollInFlight = null; + } + } +} + +type ExternalReplayReadSelection = Omit< + Parameters[0], + "sessionId" | "episodeId" +>; + +/** + * Read one foreground older page without racing Rust's exclusive request + * episode. Different older pages queue behind each other, and a read that + * arrives during a poll waits for that poll before it enters the backend. + * The page cursor intentionally does not replace the live poll cursor. + */ +export async function readExternalReplaySession( + lease: ExternalReplaySessionLease, + selection: ExternalReplayReadSelection, + signal?: AbortSignal +): Promise { + if (!isCurrentLease(lease) || signal?.aborted) return null; + const state = activeReplaySession; + if (!state) return null; + + const priorRead = state.readTail; + const priorPoll = state.pollInFlight; + const priorOpen = state.openInFlight; + const request = (async () => { + const blockers: Promise[] = []; + if (priorOpen) blockers.push(priorOpen); + if (priorPoll) blockers.push(priorPoll); + if (priorRead) blockers.push(priorRead); + await Promise.allSettled(blockers); + if (!isCurrentLease(lease) || signal?.aborted) return null; + return externalReplayReadWindow({ + sessionId: lease.sessionId, + episodeId: lease.episodeId, + ...selection, + }); + })(); + const tail = request.then( + () => undefined, + () => undefined + ); + state.readTail = tail; + + try { + const window = await request; + if (!window || !isCurrentLease(lease) || signal?.aborted) return null; + activeReplaySession!.watcherAvailable = window.watcherAvailable; + return window; + } finally { + if (isCurrentLease(lease) && activeReplaySession?.readTail === tail) { + activeReplaySession.readTail = null; + } + } +} + +export function getExternalReplayWatcherAvailable( + lease: ExternalReplaySessionLease +): boolean { + return isCurrentLease(lease) + ? (activeReplaySession?.watcherAvailable ?? false) + : false; +} + +/** Test-only observability without exposing mutable coordinator state. */ +export function getExternalReplayCursorForTest( + lease: ExternalReplaySessionLease +): ExternalReplayCursor | null { + return isCurrentLease(lease) ? (activeReplaySession?.cursor ?? null) : null; +} diff --git a/src/engines/SessionCore/sync/externalReplayTurnState.test.ts b/src/engines/SessionCore/sync/externalReplayTurnState.test.ts new file mode 100644 index 000000000..ec0d857ed --- /dev/null +++ b/src/engines/SessionCore/sync/externalReplayTurnState.test.ts @@ -0,0 +1,149 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { + ExternalReplayTurnSummary, + ExternalReplayWindow, +} from "@src/api/tauri/externalHistory"; + +import { + MAX_LOADED_EXTERNAL_REPLAY_TURN_SUMMARIES, + deactivateExternalReplayTurnState, + mergeExternalReplayTurnWindow, +} from "./externalReplayTurnState"; + +const mocks = vi.hoisted(() => ({ + summaries: [] as ExternalReplayTurnSummary[], + setAtom: vi.fn( + ( + _atom: unknown, + update: + | ExternalReplayTurnSummary[] + | (( + current: ExternalReplayTurnSummary[] + ) => ExternalReplayTurnSummary[]) + ) => { + mocks.summaries = + typeof update === "function" ? update(mocks.summaries) : update; + } + ), +})); + +vi.mock("@src/util/core/state/instrumentedStore", () => ({ + getInstrumentedStore: () => ({ set: mocks.setAtom }), +})); + +function replayWindow( + generation: string, + totalTurnCount: number, + turnIndex: number +): ExternalReplayWindow { + const turnId = `${generation}-turn-${turnIndex}`; + return { + cursor: { + sourceId: "codex_app", + sessionId: "codexapp-turn-state", + generation, + revision: 1, + throughSequence: turnIndex, + }, + events: [ + { + id: turnId, + sessionId: "codexapp-turn-state", + source: "user", + displayText: turnId, + result: {}, + } as ExternalReplayWindow["events"][number], + ], + windowStartSequence: turnIndex, + turnHeaders: [ + { + turnId, + turnIndex, + startSequence: turnIndex, + endSequence: turnIndex, + startedAt: "2026-07-23T00:00:00Z", + endedAt: "2026-07-23T00:00:01Z", + eventCount: 2, + }, + ], + totalEventCount: totalTurnCount * 2, + totalTurnCount, + hasOlder: turnIndex > 0, + watcherAvailable: false, + stats: { + parsedBytes: 0, + parsedRows: 0, + normalizedEvents: 0, + upsertedEvents: 0, + removedEvents: 0, + ipcBytes: 0, + notReady: false, + }, + }; +} + +describe("external replay virtual turn state", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.summaries = []; + }); + + it("merges distinct older pages once within one generation", () => { + mergeExternalReplayTurnWindow( + "codexapp-turn-state", + replayWindow("g1", 3, 2) + ); + mergeExternalReplayTurnWindow( + "codexapp-turn-state", + replayWindow("g1", 3, 1) + ); + + expect(Object.keys(mocks.summaries)).toEqual(["1", "2"]); + expect(mocks.summaries[1]?.turnId).toBe("g1-turn-1"); + expect(mocks.summaries[2]?.turnId).toBe("g1-turn-2"); + }); + + it("drops old headers when the source generation resets", () => { + mergeExternalReplayTurnWindow( + "codexapp-turn-state", + replayWindow("g1", 3, 2) + ); + mergeExternalReplayTurnWindow( + "codexapp-turn-state", + replayWindow("g2", 1, 0) + ); + + expect(mocks.summaries).toHaveLength(1); + expect(Object.keys(mocks.summaries)).toEqual(["0"]); + expect(mocks.summaries[0]?.turnId).toBe("g2-turn-0"); + }); + + it("keeps visited headers in a small LRU while retaining the current neighbours", () => { + for (let turnIndex = 0; turnIndex < 50; turnIndex += 1) { + mergeExternalReplayTurnWindow( + "codexapp-turn-state", + replayWindow("g1", 100, turnIndex) + ); + } + + expect(Object.keys(mocks.summaries)).toHaveLength( + MAX_LOADED_EXTERNAL_REPLAY_TURN_SUMMARIES + ); + expect(mocks.summaries[48]?.turnId).toBe("g1-turn-48"); + expect(mocks.summaries[49]?.turnId).toBe("g1-turn-49"); + expect(mocks.summaries[0]?.turnId).toContain( + "__external_replay_turn_index__" + ); + }); + + it("releases the compact summary window when the foreground session leaves", () => { + mergeExternalReplayTurnWindow( + "codexapp-turn-state", + replayWindow("g1", 3, 2) + ); + deactivateExternalReplayTurnState("codexapp-turn-state"); + + expect(mocks.summaries).toEqual([]); + }); +}); diff --git a/src/engines/SessionCore/sync/externalReplayTurnState.ts b/src/engines/SessionCore/sync/externalReplayTurnState.ts new file mode 100644 index 000000000..5e70ed980 --- /dev/null +++ b/src/engines/SessionCore/sync/externalReplayTurnState.ts @@ -0,0 +1,285 @@ +import type { ExternalReplayTurnSummary } from "@src/api/tauri/externalHistory"; +import type { ExternalReplayWindow } from "@src/api/tauri/externalHistory/replay"; +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; +import { externalReplayTurnSummariesAtomFamily } from "@src/store/session/externalReplayTurnSummariesAtom"; +import { getInstrumentedStore } from "@src/util/core/state/instrumentedStore"; + +export const EXTERNAL_REPLAY_TURN_PLACEHOLDER_PREFIX = + "__external_replay_turn_index__:"; + +// One server window is capped at 10 turns. Two extra slots retain an already +// visited neighbour on either edge while the LRU is trimmed, so browsing a +// very large transcript never leaves one header object per historical turn. +export const MAX_LOADED_EXTERNAL_REPLAY_TURN_SUMMARIES = 12; + +export interface ExternalReplayTurnEpisode { + id: number; + generation: string | null; +} + +let nextExternalReplayTurnEpisode = 0; +const replayEpisodes = new Map(); +const loadedSummariesByArray = new WeakMap< + ExternalReplayTurnSummary[], + ReadonlyMap +>(); +const summaryGenerationsByArray = new WeakMap< + ExternalReplayTurnSummary[], + string +>(); + +export function externalReplayPlaceholderId(turnIndex: number): string { + return `${EXTERNAL_REPLAY_TURN_PLACEHOLDER_PREFIX}${turnIndex}`; +} + +export function externalReplayTurnIndexFromId(turnId: string): number | null { + if (!turnId.startsWith(EXTERNAL_REPLAY_TURN_PLACEHOLDER_PREFIX)) return null; + const value = Number( + turnId.slice(EXTERNAL_REPLAY_TURN_PLACEHOLDER_PREFIX.length) + ); + return Number.isSafeInteger(value) && value >= 0 ? value : null; +} + +export function startExternalReplayTurnEpisode( + sessionId: string, + generation: string | null = null +): ExternalReplayTurnEpisode { + const episode = { + id: ++nextExternalReplayTurnEpisode, + generation, + }; + replayEpisodes.set(sessionId, episode); + return episode; +} + +export function captureExternalReplayTurnEpisode( + sessionId: string +): ExternalReplayTurnEpisode { + return ( + replayEpisodes.get(sessionId) ?? startExternalReplayTurnEpisode(sessionId) + ); +} + +export function isCurrentExternalReplayTurnEpisode( + sessionId: string, + episode: ExternalReplayTurnEpisode +): boolean { + return replayEpisodes.get(sessionId)?.id === episode.id; +} + +export function deactivateExternalReplayTurnState(sessionId: string): void { + replayEpisodes.delete(sessionId); + getInstrumentedStore().set( + externalReplayTurnSummariesAtomFamily(sessionId), + [] + ); +} + +function eventPreview(event: SessionEvent | undefined): string { + if (!event) return ""; + if (event.displayText.trim()) return event.displayText.trim(); + const result = event.result as Record; + const direct = result.content ?? result.message ?? result.text; + if (typeof direct === "string") return direct.trim(); + if (direct && typeof direct === "object") { + const content = (direct as Record).content; + if (typeof content === "string") return content.trim(); + } + return ""; +} + +function summaryFromHeader( + window: ExternalReplayWindow, + header: ExternalReplayWindow["turnHeaders"][number], + nextTurnId: string | null +): ExternalReplayTurnSummary { + const userEvent = + window.events.find((event) => event.id === header.turnId) ?? + window.events.find((event) => event.source === "user"); + const startedMs = Date.parse(header.startedAt); + const endedMs = header.endedAt ? Date.parse(header.endedAt) : Number.NaN; + const durationMs = + Number.isFinite(startedMs) && Number.isFinite(endedMs) + ? Math.max(0, endedMs - startedMs) + : null; + return { + turnId: header.turnId, + nextTurnId, + turnIndex: header.turnIndex, + startedAt: header.startedAt, + endedAt: header.endedAt, + durationMs, + userPreview: eventPreview(userEvent), + eventCount: header.eventCount, + bodyEventCount: Math.max(0, header.eventCount - 1), + }; +} + +function placeholderSummary( + turnIndex: number, + totalTurnCount: number +): ExternalReplayTurnSummary { + return { + turnId: externalReplayPlaceholderId(turnIndex), + nextTurnId: + turnIndex + 1 < totalTurnCount + ? externalReplayPlaceholderId(turnIndex + 1) + : null, + turnIndex, + startedAt: "", + endedAt: null, + durationMs: null, + userPreview: "", + // The exact size is unknown until Rust reads this turn. Keeping this + // non-zero makes selecting the virtual page trigger the bounded loader. + eventCount: 1, + bodyEventCount: 1, + }; +} + +function parseArrayIndex( + property: string | symbol, + length: number +): number | null { + if (typeof property !== "string" || !/^(0|[1-9]\d*)$/.test(property)) { + return null; + } + const index = Number(property); + return Number.isSafeInteger(index) && index >= 0 && index < length + ? index + : null; +} + +/** + * A logical array with `totalTurnCount` entries that allocates objects only + * for headers already returned by Rust. Direct indexed reads synthesize a + * placeholder; array iteration still visits only the loaded sparse entries. + */ +function createVirtualSummaryArray( + totalTurnCount: number, + loadedInput: ReadonlyMap +): ExternalReplayTurnSummary[] { + const loaded = new Map(); + for (const [turnIndex, summary] of loadedInput) { + if (turnIndex < 0 || turnIndex >= totalTurnCount) continue; + loaded.set(turnIndex, summary); + } + for (const [turnIndex, summary] of loaded) { + loaded.set(turnIndex, { + ...summary, + nextTurnId: + loaded.get(turnIndex + 1)?.turnId ?? + (turnIndex + 1 < totalTurnCount + ? externalReplayPlaceholderId(turnIndex + 1) + : null), + }); + } + + const target: ExternalReplayTurnSummary[] = []; + target.length = totalTurnCount; + for (const [turnIndex, summary] of loaded) target[turnIndex] = summary; + + const virtual = new Proxy(target, { + get(array, property, receiver) { + const index = parseArrayIndex(property, totalTurnCount); + if (index !== null && array[index] === undefined) { + return placeholderSummary(index, totalTurnCount); + } + return Reflect.get(array, property, receiver); + }, + }); + loadedSummariesByArray.set(virtual, loaded); + return virtual; +} + +function touchLoadedSummary( + loaded: Map, + turnIndex: number, + summary: ExternalReplayTurnSummary +): void { + // Map iteration order is our tiny LRU. Updating an existing entry does not + // move it, so delete before set. + loaded.delete(turnIndex); + loaded.set(turnIndex, summary); +} + +function trimLoadedSummaries( + loaded: Map, + pinned: ReadonlySet +): void { + if (loaded.size <= MAX_LOADED_EXTERNAL_REPLAY_TURN_SUMMARIES) return; + for (const turnIndex of loaded.keys()) { + if (loaded.size <= MAX_LOADED_EXTERNAL_REPLAY_TURN_SUMMARIES) break; + if (!pinned.has(turnIndex)) loaded.delete(turnIndex); + } + // The protocol currently caps one page at 10 headers, below this limit. + // Keep the memory invariant fail-closed if a future backend violates that + // contract: the newest map entries win. + for (const turnIndex of loaded.keys()) { + if (loaded.size <= MAX_LOADED_EXTERNAL_REPLAY_TURN_SUMMARIES) break; + loaded.delete(turnIndex); + } +} + +export function buildExternalReplayTurnSummaries( + window: ExternalReplayWindow +): ExternalReplayTurnSummary[] { + const loaded = new Map(); + for (const header of window.turnHeaders) { + touchLoadedSummary( + loaded, + header.turnIndex, + summaryFromHeader(window, header, null) + ); + } + trimLoadedSummaries(loaded, new Set(loaded.keys())); + const summaries = createVirtualSummaryArray(window.totalTurnCount, loaded); + summaryGenerationsByArray.set(summaries, window.cursor.generation); + return summaries; +} + +export function mergeExternalReplayTurnWindow( + sessionId: string, + window: ExternalReplayWindow +): void { + const episode = replayEpisodes.get(sessionId); + if (episode) episode.generation = window.cursor.generation; + else startExternalReplayTurnEpisode(sessionId, window.cursor.generation); + const store = getInstrumentedStore(); + const atom = externalReplayTurnSummariesAtomFamily(sessionId); + store.set(atom, (current) => { + const currentGeneration = summaryGenerationsByArray.get(current); + const generationChanged = + currentGeneration !== undefined && + currentGeneration !== window.cursor.generation; + if (generationChanged || current.length === 0) { + return buildExternalReplayTurnSummaries(window); + } + const loaded = new Map( + loadedSummariesByArray.get(current) ?? [] + ); + // Accept a dense array left in memory by an older renderer build. forEach + // skips virtual holes, so this stays proportional to loaded headers. + current.forEach((summary, turnIndex) => { + if (!summary.turnId.startsWith(EXTERNAL_REPLAY_TURN_PLACEHOLDER_PREFIX)) { + if (!loaded.has(turnIndex)) loaded.set(turnIndex, summary); + } + }); + const pinned = new Set(); + for (const header of window.turnHeaders) { + touchLoadedSummary( + loaded, + header.turnIndex, + summaryFromHeader(window, header, null) + ); + for (const nearby of [header.turnIndex - 1, header.turnIndex + 1]) { + if (loaded.has(nearby)) pinned.add(nearby); + } + pinned.add(header.turnIndex); + } + trimLoadedSummaries(loaded, pinned); + const summaries = createVirtualSummaryArray(window.totalTurnCount, loaded); + summaryGenerationsByArray.set(summaries, window.cursor.generation); + return summaries; + }); +} diff --git a/src/engines/SessionCore/sync/nativeTranscriptReconcile.ts b/src/engines/SessionCore/sync/nativeTranscriptReconcile.ts index 4c7ef8f2b..642869c4e 100644 --- a/src/engines/SessionCore/sync/nativeTranscriptReconcile.ts +++ b/src/engines/SessionCore/sync/nativeTranscriptReconcile.ts @@ -1,29 +1,26 @@ /** - * Post-turn reconcile for native-transcript CLI sessions. - * - * Native-mode sessions stream ephemeral (in-memory only) events during a - * turn; the transcript of record is the CLI's own store, read back through - * `cli_agent_chunks` (which routes to the imported-history loaders). When a - * turn reaches a terminal status we reload once after a short settle delay - * so the in-memory events are replaced by the canonical parse, and retry - * once more in case the CLI flushed its store slightly after exiting. - * - * The registry is populated by the CLI adapter's postLoad (from - * `cli_agent_status.transcriptSource`); legacy sessions never reconcile. + * Post-turn reconcile for managed CLI sessions whose transcript of record is + * the vendor's native store. Reconcile now asks the bounded replay transport + * for true deltas; it never calls `cli_agent_chunks` or replaces history from + * a session-sized JS array. */ -import type { SessionEvent } from "@src/engines/SessionCore/core/types"; const transcriptSourceBySession = new Map(); +const MAX_TRANSCRIPT_SOURCE_ENTRIES = 64; const RECONCILE_SETTLE_MS = 600; -const RECONCILE_RETRY_MS = 2000; +const RECONCILE_RETRY_MS = 2_000; export function registerSessionTranscriptSource( sessionId: string, transcriptSource: string | undefined ): void { - if (transcriptSource) { - transcriptSourceBySession.set(sessionId, transcriptSource); + if (!transcriptSource) return; + transcriptSourceBySession.delete(sessionId); + transcriptSourceBySession.set(sessionId, transcriptSource); + if (transcriptSourceBySession.size > MAX_TRANSCRIPT_SOURCE_ENTRIES) { + const oldest = transcriptSourceBySession.keys().next().value; + if (oldest) transcriptSourceBySession.delete(oldest); } } @@ -32,18 +29,7 @@ export function isNativeTranscriptSession(sessionId: string): boolean { } interface ReconcileDeps { - loadHistory: (sessionId: string) => Promise; - dispatchLoadSession: (payload: { - sessionId: string; - events: SessionEvent[]; - /** - * The native replay IS the canonical transcript: loadSessionAtom must - * replace the in-memory turn events (synthetic user bubble, streamed - * placeholders) instead of merging next to them — their ids never match - * the replayed rows, so a merge renders every turn twice. - */ - replace?: boolean; - }) => void; + pollReplay: (sessionId: string) => Promise; /** The session still on screen? Stale reconciles are dropped. */ isSessionLive: (sessionId: string) => boolean; } @@ -58,35 +44,23 @@ export function scheduleNativeTranscriptReconcile( if (pendingReconciles.has(sessionId)) return; pendingReconciles.add(sessionId); - const runOnce = async (): Promise => { - if (!deps.isSessionLive(sessionId)) return -1; - const events = await deps.loadHistory(sessionId); - if (!deps.isSessionLive(sessionId)) return -1; - if (events.length > 0) { - deps.dispatchLoadSession({ sessionId, events, replace: true }); - } - return events.length; + const runOnce = async (): Promise => { + if (!deps.isSessionLive(sessionId)) return false; + await deps.pollReplay(sessionId); + return deps.isSessionLive(sessionId); }; void (async () => { try { await new Promise((resolve) => setTimeout(resolve, RECONCILE_SETTLE_MS)); - const firstCount = await runOnce(); - if (firstCount < 0) return; - // One retry catches a store flushed slightly after process exit; only - // re-dispatch when the parse actually grew (no pointless flicker). + if (!(await runOnce())) return; + // One bounded retry catches a native store flushed just after process + // exit. Unchanged sources produce zero parse/upsert work in Rust. await new Promise((resolve) => setTimeout(resolve, RECONCILE_RETRY_MS)); - if (!deps.isSessionLive(sessionId)) return; - const events = await deps.loadHistory(sessionId); - if ( - events.length > Math.max(firstCount, 0) && - deps.isSessionLive(sessionId) - ) { - deps.dispatchLoadSession({ sessionId, events, replace: true }); - } + await runOnce(); } catch { - // Best-effort: the ephemeral in-memory events remain on screen; the - // next session open replays from the native store anyway. + // Best effort: the visible fallback timer and the next open share the + // same cursor, so they can safely complete the reconcile later. } finally { pendingReconciles.delete(sessionId); } diff --git a/src/engines/SessionCore/sync/sessionSwitchEffectRunner.test.ts b/src/engines/SessionCore/sync/sessionSwitchEffectRunner.test.ts new file mode 100644 index 000000000..90c4cf784 --- /dev/null +++ b/src/engines/SessionCore/sync/sessionSwitchEffectRunner.test.ts @@ -0,0 +1,145 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { runSessionSwitchEffect } from "./sessionSwitchEffectRunner"; + +const mocks = vi.hoisted(() => ({ + getAdapterForSession: vi.fn(), + runOrchestrator: vi.fn(), + activateReplay: vi.fn(), + deactivateReplay: vi.fn(), + disposeHandler: vi.fn(), + resetReloadGuard: vi.fn(), + resetSwitchState: vi.fn(), + createCallbacks: vi.fn(() => ({})), +})); + +vi.mock("./types", () => ({ + getAdapterForSession: mocks.getAdapterForSession, +})); +vi.mock("./sessionSwitchOrchestrator", () => ({ + runSessionSwitchOrchestrator: mocks.runOrchestrator, +})); +vi.mock("./externalReplayTransport", () => ({ + activateExternalReplaySession: mocks.activateReplay, + deactivateExternalReplaySession: mocks.deactivateReplay, +})); +vi.mock("./sessionSyncLifecycle", () => ({ + disposeCurrentHandler: mocks.disposeHandler, + resetReloadGuardForSession: mocks.resetReloadGuard, +})); +vi.mock("./sessionSyncStateHelpers", () => ({ + resetSessionSwitchState: mocks.resetSwitchState, + createSessionEventHandlerCallbacks: mocks.createCallbacks, +})); + +describe("runSessionSwitchEffect history transport isolation", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("never activates external replay for a native SDE adapter", () => { + const handler = { + handleEvent: vi.fn(), + reset: vi.fn(), + isStreaming: false, + dispose: vi.fn(), + }; + const nativeLoadHistory = vi.fn(); + mocks.getAdapterForSession.mockReturnValue({ + category: "agent", + historyMode: "persisted-db", + loadHistory: nativeLoadHistory, + createEventHandler: vi.fn(() => handler), + sendMessage: vi.fn(), + stopSession: vi.fn(), + }); + const refs = { + adapterRef: { current: null }, + handlerRef: { current: null }, + prevSessionIdRef: { current: null }, + prevReloadEpochRef: { current: 0 }, + liveSessionIdRef: { current: "sdeagent-native" }, + }; + + const cleanup = runSessionSwitchEffect({ + sessionId: "sdeagent-native", + reloadEpoch: 0, + refs, + switchActions: {} as never, + loadActions: {} as never, + handlerActions: {} as never, + setPendingPlanApprovals: vi.fn(), + logStatusChange: vi.fn(), + logger: {} as never, + }); + + expect(mocks.activateReplay).not.toHaveBeenCalled(); + expect(nativeLoadHistory).not.toHaveBeenCalled(); + expect(mocks.runOrchestrator).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: "sdeagent-native", + replayLease: undefined, + }) + ); + + cleanup(); + expect(mocks.deactivateReplay).not.toHaveBeenCalled(); + }); + + it("aborts and releases the exact bounded-replay episode on switch-away", () => { + const replayLease = { + sessionId: "codexapp-external", + episodeId: 42, + }; + const handler = { + handleEvent: vi.fn(), + reset: vi.fn(), + isStreaming: false, + dispose: vi.fn(), + }; + mocks.getAdapterForSession.mockReturnValue({ + category: "external_history", + historyMode: "bounded-replay", + createEventHandler: vi.fn(() => handler), + sendMessage: vi.fn(), + stopSession: vi.fn(), + }); + mocks.activateReplay.mockReturnValue(replayLease); + const refs = { + adapterRef: { current: null }, + handlerRef: { current: null }, + prevSessionIdRef: { current: null }, + prevReloadEpochRef: { current: 0 }, + liveSessionIdRef: { current: "codexapp-external" }, + }; + + const cleanup = runSessionSwitchEffect({ + sessionId: "codexapp-external", + reloadEpoch: 0, + refs, + switchActions: {} as never, + loadActions: {} as never, + handlerActions: {} as never, + setPendingPlanApprovals: vi.fn(), + logStatusChange: vi.fn(), + logger: {} as never, + }); + + const orchestratorOptions = mocks.runOrchestrator.mock.calls[0]?.[0]; + expect(mocks.activateReplay).toHaveBeenCalledWith("codexapp-external"); + expect(orchestratorOptions).toEqual( + expect.objectContaining({ replayLease }) + ); + expect(orchestratorOptions.abortController.signal.aborted).toBe(false); + + cleanup(); + + expect(orchestratorOptions.abortController.signal.aborted).toBe(true); + expect(mocks.deactivateReplay).toHaveBeenCalledTimes(1); + expect(mocks.deactivateReplay).toHaveBeenCalledWith(replayLease); + expect(mocks.resetReloadGuard).toHaveBeenCalledWith( + "codexapp-external", + refs + ); + }); +}); diff --git a/src/engines/SessionCore/sync/sessionSwitchEffectRunner.ts b/src/engines/SessionCore/sync/sessionSwitchEffectRunner.ts index 263884863..f54d091fa 100644 --- a/src/engines/SessionCore/sync/sessionSwitchEffectRunner.ts +++ b/src/engines/SessionCore/sync/sessionSwitchEffectRunner.ts @@ -1,3 +1,7 @@ +import { + activateExternalReplaySession, + deactivateExternalReplaySession, +} from "./externalReplayTransport"; import { runSessionSwitchOrchestrator } from "./sessionSwitchOrchestrator"; import { disposeCurrentHandler, @@ -58,6 +62,10 @@ export function runSessionSwitchEffect( } refs.adapterRef.current = adapter; + const replayLease = + adapter.historyMode === "bounded-replay" + ? activateExternalReplaySession(sessionId) + : undefined; refs.handlerRef.current = adapter.createEventHandler( sessionId, createSessionEventHandlerCallbacks( @@ -75,10 +83,12 @@ export function runSessionSwitchEffect( actions: loadActions, setPendingPlanApprovals, logger, + replayLease, }); return () => { abortController.abort(); + if (replayLease) deactivateExternalReplaySession(replayLease); resetReloadGuardForSession(sessionId, refs); }; } diff --git a/src/engines/SessionCore/sync/sessionSwitchOrchestrator.ts b/src/engines/SessionCore/sync/sessionSwitchOrchestrator.ts index 028758419..3c88d526a 100644 --- a/src/engines/SessionCore/sync/sessionSwitchOrchestrator.ts +++ b/src/engines/SessionCore/sync/sessionSwitchOrchestrator.ts @@ -1,16 +1,20 @@ -import { cursorIdeComposerLastUpdatedAt } from "@src/api/tauri/externalHistory/cursorIde"; import { Message } from "@src/components/Message"; import { eventStoreProxy } from "@src/engines/SessionCore/core/store/EventStoreProxy"; import { isVisibleInChat } from "@src/engines/SessionCore/ingestion/visibilityFilters"; +import { + mergeExternalReplayTurnWindow, + startExternalReplayTurnEpisode, +} from "@src/engines/SessionCore/sync/externalReplayTurnState"; import type { Logger } from "@src/hooks/logger"; import { - composerIdFromSessionId, isCollaborationImportedSession, isImportedHistorySession, } from "@src/util/session/sessionDispatch"; -import { getCursorIdeSnapshotLastUpdatedAt } from "./adapters/cursorIdeAdapter"; -import { isCursorIdeSessionId } from "./sessionSyncDerivedState"; +import { + type ExternalReplaySessionLease, + openExternalReplaySession, +} from "./externalReplayTransport"; import { rehydratePendingPlanApproval } from "./sessionSyncPlanApproval"; import { reconcileInFlightHistory } from "./sessionSyncReconcile"; import { @@ -33,6 +37,7 @@ interface SessionSwitchOrchestratorOptions { actions: SessionLoadStateActions; setPendingPlanApprovals: Parameters[2]; logger: Logger; + replayLease?: ExternalReplaySessionLease; } export function runSessionSwitchOrchestrator( @@ -51,6 +56,20 @@ export function runSessionSwitchOrchestrator( try { const cacheHit = await eventStoreProxy.switchSession(sessionId); if (abortController.signal.aborted) return; + if (adapter.historyMode === "bounded-replay") { + if (!options.replayLease) { + throw new Error(`Missing bounded replay lease for ${sessionId}`); + } + await handleBoundedReplaySession({ + sessionId, + adapter, + abortController, + actions, + setPendingPlanApprovals, + replayLease: options.replayLease, + }); + return; + } if (cacheHit) { await handleCacheHit({ sessionId, @@ -90,6 +109,63 @@ export function runSessionSwitchOrchestrator( void switchSession(); } +async function handleBoundedReplaySession( + options: Pick< + SessionSwitchOrchestratorOptions, + | "sessionId" + | "adapter" + | "abortController" + | "actions" + | "setPendingPlanApprovals" + | "replayLease" + > +): Promise { + const { + sessionId, + adapter, + abortController, + actions, + setPendingPlanApprovals, + replayLease, + } = options; + if (!replayLease) return; + + actions.setLoadStatus("loading"); + const postResult = adapter.postLoad + ? await adapter.postLoad(sessionId, abortController.signal) + : null; + if (abortController.signal.aborted) return; + + const window = await openExternalReplaySession( + replayLease, + abortController.signal + ); + if (!window || abortController.signal.aborted) return; + startExternalReplayTurnEpisode(sessionId, window.cursor.generation); + mergeExternalReplayTurnWindow(sessionId, window); + + // A just-launched managed native CLI may not have emitted its vendor id + // yet. Rust leaves the existing live EventStore untouched in that state. + const displayEvents = window.stats.notReady + ? await eventStoreProxy.getEvents(sessionId) + : window.events; + if (abortController.signal.aborted) return; + + actions.dispatchLoadSession({ + sessionId, + events: displayEvents, + // The replay generation is canonical. Do not retain rows from a previous + // source generation or a wider cached window in the Jotai projection. + replace: !window.stats.notReady, + }); + rehydratePendingPlanApproval( + sessionId, + abortController, + setPendingPlanApprovals + ); + applyPostLoadResult(sessionId, postResult, actions); +} + async function handleCacheHit( options: Pick< SessionSwitchOrchestratorOptions, @@ -110,14 +186,10 @@ async function handleCacheHit( setPendingPlanApprovals, } = options; - if (isCursorIdeSessionId(sessionId)) { - const handled = await handleCursorIdeCacheHit( - sessionId, - adapter, - abortController, - actions - ); - if (handled) return; + // Bounded replay is handled before this function. Keeping the assertion + // here prevents a future caller from silently reintroducing loadHistory. + if (adapter.historyMode !== "persisted-db") { + throw new Error(`Unexpected bounded replay cache path for ${sessionId}`); } actions.setLoadStatus("loading"); @@ -194,37 +266,6 @@ async function handleCacheHit( applyPostLoadResult(sessionId, postResult, actions); } -async function handleCursorIdeCacheHit( - sessionId: string, - adapter: SessionAdapter, - abortController: AbortController, - actions: Pick< - SessionLoadStateActions, - "dispatchLoadSession" | "setLoadStatus" - > -): Promise { - actions.setLoadStatus("loading"); - const composerId = composerIdFromSessionId(sessionId); - const currentUpdatedAt = composerId - ? await cursorIdeComposerLastUpdatedAt(composerId) - : null; - if (abortController.signal.aborted) return true; - const cachedUpdatedAt = getCursorIdeSnapshotLastUpdatedAt(sessionId); - if (currentUpdatedAt !== null && cachedUpdatedAt === currentUpdatedAt) { - const cachedEvents = await eventStoreProxy.getEvents(); - if (abortController.signal.aborted) return true; - actions.dispatchLoadSession({ sessionId, events: cachedEvents }); - return true; - } - - const events = await adapter.loadHistory(sessionId, abortController.signal); - if (abortController.signal.aborted) return true; - await eventStoreProxy.set(events, sessionId); - if (abortController.signal.aborted) return true; - actions.dispatchLoadSession({ sessionId, events }); - return true; -} - async function handleCacheMiss( options: Pick< SessionSwitchOrchestratorOptions, @@ -245,6 +286,10 @@ async function handleCacheMiss( setPendingPlanApprovals, } = options; + if (adapter.historyMode !== "persisted-db") { + throw new Error(`Unexpected bounded replay miss path for ${sessionId}`); + } + actions.setLoadStatus("loading"); const missPostResult = adapter.postLoad diff --git a/src/engines/SessionCore/sync/sessionSyncDerivedState.ts b/src/engines/SessionCore/sync/sessionSyncDerivedState.ts index 4605e17ba..517157ccd 100644 --- a/src/engines/SessionCore/sync/sessionSyncDerivedState.ts +++ b/src/engines/SessionCore/sync/sessionSyncDerivedState.ts @@ -1,5 +1,3 @@ -import { isCursorIdeSession } from "@src/util/session/sessionDispatch"; - export function isDuplicateSessionSyncInvocation( sessionId: string, reloadEpoch: number, @@ -8,7 +6,3 @@ export function isDuplicateSessionSyncInvocation( ): boolean { return previousSessionId === sessionId && previousReloadEpoch === reloadEpoch; } - -export function isCursorIdeSessionId(sessionId: string): boolean { - return isCursorIdeSession(sessionId); -} diff --git a/src/engines/SessionCore/sync/sessionSyncReconcile.ts b/src/engines/SessionCore/sync/sessionSyncReconcile.ts index 6abab66db..7b40aa7b7 100644 --- a/src/engines/SessionCore/sync/sessionSyncReconcile.ts +++ b/src/engines/SessionCore/sync/sessionSyncReconcile.ts @@ -1,7 +1,5 @@ -import { eventStoreProxy } from "@src/engines/SessionCore/core/store/EventStoreProxy"; import { type SessionStatus, updateSessionStatus } from "@src/store/session"; -import { isNativeTranscriptSession } from "./nativeTranscriptReconcile"; import { type SessionLoadStateActions, applyPostLoadResult, @@ -15,7 +13,7 @@ import { toCliSessionStatus, waitForReconcileDelay, } from "./sessionSyncUtils"; -import type { SessionAdapter } from "./types"; +import type { PersistedDbSessionAdapter } from "./types"; type PostLoadStateActions = Pick< SessionLoadStateActions, @@ -33,7 +31,7 @@ type ReconcileStateActions = Pick< export function reconcileInFlightHistory( sessionId: string, - adapter: SessionAdapter, + adapter: PersistedDbSessionAdapter, refs: Pick, actions: ReconcileStateActions ): void { @@ -60,39 +58,13 @@ export function reconcileInFlightHistory( continue; } - // Native-transcript CLI sessions: the live turn renders from in-memory - // events only (optimistic user bubble + streamed broadcasts). Repeated - // replay merges here were the "~5s" duplicate-bubble source — each tick - // parked replay rows (synthesized fallback, then the real - // `codex-user-N` parse) next to them under never-matching ids. Only an - // EMPTY store (switched into a still-running session after a restart or - // eviction) is hydrated, with replace semantics so a retry tick stays - // idempotent; the terminal reconcile owns the final canonical replace. - if (isNativeTranscriptSession(sessionId)) { - const existingEvents = await eventStoreProxy.getEvents(sessionId); - if (refs.liveSessionIdRef.current !== sessionId) return; - if (existingEvents.length === 0) { - await hydrateSessionStoreBeforeDisplay( - sessionId, - persistedEvents, - "replace" - ); - if (refs.liveSessionIdRef.current !== sessionId) return; - actions.dispatchLoadSession({ - sessionId, - events: persistedEvents, - replace: true, - }); - } - } else { - await hydrateSessionStoreBeforeDisplay( - sessionId, - persistedEvents, - "merge" - ); - if (refs.liveSessionIdRef.current !== sessionId) return; - actions.dispatchLoadSession({ sessionId, events: persistedEvents }); - } + await hydrateSessionStoreBeforeDisplay( + sessionId, + persistedEvents, + "merge" + ); + if (refs.liveSessionIdRef.current !== sessionId) return; + actions.dispatchLoadSession({ sessionId, events: persistedEvents }); if (postResult?.contextTokens !== undefined) { actions.setSessionContextTokens(postResult.contextTokens); diff --git a/src/engines/SessionCore/sync/sessionSyncUtils.ts b/src/engines/SessionCore/sync/sessionSyncUtils.ts index a09654643..d816f205a 100644 --- a/src/engines/SessionCore/sync/sessionSyncUtils.ts +++ b/src/engines/SessionCore/sync/sessionSyncUtils.ts @@ -17,7 +17,7 @@ import { createLogger } from "@src/hooks/logger"; import type { CliSessionStatus } from "@src/types/session/session"; import { isCollaborationImportedSession } from "@src/util/session/sessionDispatch"; -import type { SessionAdapter } from "./types"; +import type { PersistedDbSessionAdapter } from "./types"; const logger = createLogger("SessionSync"); @@ -116,7 +116,7 @@ export async function loadOwnSessionInitialEvents( } export async function loadPersistedHistory( - adapter: SessionAdapter, + adapter: PersistedDbSessionAdapter, sessionId: string, signal: AbortSignal ): Promise { diff --git a/src/engines/SessionCore/sync/types.ts b/src/engines/SessionCore/sync/types.ts index 38f5151b5..121d3fcaa 100644 --- a/src/engines/SessionCore/sync/types.ts +++ b/src/engines/SessionCore/sync/types.ts @@ -176,16 +176,10 @@ export interface AdapterSendInput { * Adapter interface for session-type-specific logic. * Each session type (SDE Agent, OS Agent, CLI, Cursor IDE) implements this. */ -export interface SessionAdapter { +interface SessionAdapterBase { /** Which session category this adapter handles. */ readonly category: string; - /** - * Load persisted history from Tauri SQLite → SessionEvent[]. - * Pure async function — no side effects. - */ - loadHistory(sessionId: string, signal: AbortSignal): Promise; - /** * Post-load setup: restore session status, token counts, etc. * Returns metadata for the unified hook to apply to global atoms. @@ -213,6 +207,26 @@ export interface SessionAdapter { stopSession(sessionId: string, reason: CancelReason): Promise; } +/** Native ORGII sessions whose history is persisted in the app database. */ +export interface PersistedDbSessionAdapter extends SessionAdapterBase { + readonly historyMode: "persisted-db"; + /** Pure read; the switch orchestrator owns EventStore hydration. */ + loadHistory(sessionId: string, signal: AbortSignal): Promise; +} + +/** + * Imported history and managed CLI sessions backed by the source-aware Rust + * replay registry. These adapters intentionally expose no full-history + * loader: open/read/poll always use the bounded replay RPC contract. + */ +export interface BoundedReplaySessionAdapter extends SessionAdapterBase { + readonly historyMode: "bounded-replay"; +} + +export type SessionAdapter = + | PersistedDbSessionAdapter + | BoundedReplaySessionAdapter; + // ============================================================================ // Shared info types (used by callbacks) // ============================================================================ @@ -326,7 +340,8 @@ export function getAdapterForSession( // Cursor IDE history carries a distinct `cursor_ide` display category (to // separate imported IDE history from launched Cursor CLI sessions), but for // *loading* it is read-only external history like the rest — route it to the - // same adapter. `isImportedHistorySession` = cursor_ide OR external_history. + // same adapter. `isImportedHistorySession` covers vendor history, Cursor + // IDE, and ORGII-owned read-only collaboration snapshots. if (isImportedHistorySession(sessionId)) { return adapterRegistry.get("external_history"); } diff --git a/src/engines/SessionCore/sync/useSessionSync.ts b/src/engines/SessionCore/sync/useSessionSync.ts index 4dddbeb88..6a685620d 100644 --- a/src/engines/SessionCore/sync/useSessionSync.ts +++ b/src/engines/SessionCore/sync/useSessionSync.ts @@ -40,7 +40,11 @@ import { pendingPlanApprovalsAtom } from "@src/store/session/planApprovalAtom"; import { wpReadOnlyAtom } from "@src/store/ui/chatPanelAtom"; import "./adapters"; -import { useExternalHistoryAutoRefresh } from "./externalHistoryAutoRefresh"; +import { useExternalReplayAutoRefresh } from "./externalReplayAutoRefresh"; +import { + getActiveExternalReplayLease, + pollExternalReplaySession, +} from "./externalReplayTransport"; import { scheduleNativeTranscriptReconcile } from "./nativeTranscriptReconcile"; import { resetEmptySessionRefs, @@ -190,21 +194,19 @@ export function useSessionSync( ] ); - const scheduleReconcile = useCallback( - (sid: string) => { - scheduleNativeTranscriptReconcile(sid, { - loadHistory: async (target) => { - const adapter = getAdapterForSession(target); - if (!adapter) return []; - const controller = new AbortController(); - return adapter.loadHistory(target, controller.signal); - }, - dispatchLoadSession, - isSessionLive: (target) => liveSessionIdRef.current === target, - }); - }, - [dispatchLoadSession] - ); + const scheduleReconcile = useCallback((sid: string) => { + scheduleNativeTranscriptReconcile(sid, { + pollReplay: async (target) => { + const adapter = getAdapterForSession(target); + if (adapter?.historyMode !== "bounded-replay") return; + const lease = getActiveExternalReplayLease(target); + if (!lease) return; + const controller = new AbortController(); + await pollExternalReplaySession(lease, controller.signal); + }, + isSessionLive: (target) => liveSessionIdRef.current === target, + }); + }, []); const handlerActions = useMemo( () => ({ @@ -297,13 +299,12 @@ export function useSessionSync( ); useSessionChannel(sessionId, handleChannelEvent); - useExternalHistoryAutoRefresh({ + useExternalReplayAutoRefresh({ sessionId, intervalMs: ACTIVE_EXTERNAL_SESSION_REFRESH_INTERVAL_MS[ activeExternalSessionRefreshFrequency ], - dispatchLoadSession, }); useEventStoreCacheSync(sessionId); diff --git a/src/engines/SessionCore/turns/codexAppTurnLoader.ts b/src/engines/SessionCore/turns/codexAppTurnLoader.ts deleted file mode 100644 index fd44744ee..000000000 --- a/src/engines/SessionCore/turns/codexAppTurnLoader.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { codexAppTurnWindow } from "@src/api/tauri/externalHistory"; -import { eventStoreProxy } from "@src/engines/SessionCore/core/store/EventStoreProxy"; -import { processChunksRust } from "@src/engines/SessionCore/ingestion/rustBridge"; -import { isCodexAppSession } from "@src/util/session/sessionDispatch"; - -import type { SessionTurnLoader } from "./types"; - -export const codexAppTurnLoader: SessionTurnLoader = { - async loadTurnBodyIntoStore({ sessionId, turnId }) { - if (!isCodexAppSession(sessionId)) return; - - const turnWindow = await codexAppTurnWindow({ sessionId, turnId }); - if (!Array.isArray(turnWindow.chunks) || turnWindow.chunks.length === 0) { - return; - } - const events = await processChunksRust(turnWindow.chunks, sessionId); - if (events.length === 0) return; - await eventStoreProxy.mergeEvents(events, sessionId); - }, -}; diff --git a/src/engines/SessionCore/turns/cursorIdeTurnLoader.ts b/src/engines/SessionCore/turns/cursorIdeTurnLoader.ts deleted file mode 100644 index 2e33de571..000000000 --- a/src/engines/SessionCore/turns/cursorIdeTurnLoader.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { cursorIdeTurnWindow } from "@src/api/tauri/externalHistory"; -import { eventStoreProxy } from "@src/engines/SessionCore/core/store/EventStoreProxy"; -import { processChunksRust } from "@src/engines/SessionCore/ingestion/rustBridge"; -import { isCursorIdeSession } from "@src/util/session/sessionDispatch"; - -import type { SessionTurnLoader } from "./types"; - -const inFlightTurnLoads = new Map>(); - -export const cursorIdeTurnLoader: SessionTurnLoader = { - async loadTurnBodyIntoStore({ sessionId, turnId }) { - if (!isCursorIdeSession(sessionId)) return; - - const loadKey = `${sessionId}:${turnId}`; - const inFlight = inFlightTurnLoads.get(loadKey); - if (inFlight) return inFlight; - - const work = (async () => { - try { - const turnWindow = await cursorIdeTurnWindow({ - sessionId, - userBubbleId: turnId, - }); - const { chunks } = turnWindow; - if (!Array.isArray(chunks) || chunks.length === 0) return; - const events = await processChunksRust(chunks, sessionId); - if (events.length === 0) return; - await eventStoreProxy.mergeEvents(events, sessionId); - } finally { - inFlightTurnLoads.delete(loadKey); - } - })(); - - inFlightTurnLoads.set(loadKey, work); - return work; - }, -}; diff --git a/src/engines/SessionCore/turns/externalReplayTurnLoader.ts b/src/engines/SessionCore/turns/externalReplayTurnLoader.ts new file mode 100644 index 000000000..990b49cf9 --- /dev/null +++ b/src/engines/SessionCore/turns/externalReplayTurnLoader.ts @@ -0,0 +1,84 @@ +import { resolveExternalReplayTarget } from "@src/api/tauri/externalHistory/replay"; +import { + getActiveExternalReplayLease, + openExternalReplaySession, + readExternalReplaySession, +} from "@src/engines/SessionCore/sync/externalReplayTransport"; +import { + captureExternalReplayTurnEpisode, + externalReplayTurnIndexFromId, + isCurrentExternalReplayTurnEpisode, + mergeExternalReplayTurnWindow, + startExternalReplayTurnEpisode, +} from "@src/engines/SessionCore/sync/externalReplayTurnState"; + +import type { SessionTurnLoader } from "./types"; + +const inFlightTurnLoads = new Map>(); + +/** + * Load one older turn through the foreground bounded-replay lease. + * + * `externalReplayReadWindow` applies the bounded body directly in Rust, so + * there is no renderer-side ActivityChunk round trip. A generation change + * triggers an authoritative bounded reopen; it never merges a page from the + * replacement source into the previous generation's presentation catalog. + */ +export const externalReplayTurnLoader: SessionTurnLoader = { + async loadTurnBodyIntoStore({ sessionId, turnId }) { + if (!resolveExternalReplayTarget(sessionId)) return; + + const lease = getActiveExternalReplayLease(sessionId); + if (!lease) { + throw new Error(`Missing foreground replay lease for ${sessionId}`); + } + + const loadKey = `${sessionId}:${turnId}`; + const inFlight = inFlightTurnLoads.get(loadKey); + if (inFlight) return inFlight; + + const turnIndex = externalReplayTurnIndexFromId(turnId); + const episode = captureExternalReplayTurnEpisode(sessionId); + const expectedGeneration = episode.generation; + const work = readExternalReplaySession(lease, { + ...(turnIndex === null ? { turnId } : { turnIndex }), + limits: { maxTurns: 1, maxEvents: 200, maxIpcBytes: 4 * 1024 * 1024 }, + }) + .then(async (window) => { + if (!window) return; + if (!isCurrentExternalReplayTurnEpisode(sessionId, episode)) return; + if ( + getActiveExternalReplayLease(sessionId)?.episodeId !== lease.episodeId + ) { + return; + } + if ( + expectedGeneration !== null && + window.cursor.generation !== expectedGeneration + ) { + // `read_window` is bounded but it is not allowed to join two source + // generations. Reopen replaces the Rust EventStore window and the + // compact turn catalog atomically from the renderer's perspective. + const resetEpisode = startExternalReplayTurnEpisode(sessionId); + const resetWindow = await openExternalReplaySession(lease); + if ( + !resetWindow || + !isCurrentExternalReplayTurnEpisode(sessionId, resetEpisode) + ) { + return; + } + mergeExternalReplayTurnWindow(sessionId, resetWindow); + return; + } + mergeExternalReplayTurnWindow(sessionId, window); + }) + .finally(() => { + if (inFlightTurnLoads.get(loadKey) === work) { + inFlightTurnLoads.delete(loadKey); + } + }); + + inFlightTurnLoads.set(loadKey, work); + return work; + }, +}; diff --git a/src/engines/SessionCore/turns/loadedTurnRegistry.ts b/src/engines/SessionCore/turns/loadedTurnRegistry.ts index 45fd30392..643c903f3 100644 --- a/src/engines/SessionCore/turns/loadedTurnRegistry.ts +++ b/src/engines/SessionCore/turns/loadedTurnRegistry.ts @@ -1,5 +1,5 @@ +import { resolveExternalReplayTarget } from "@src/api/tauri/externalHistory/replay"; import { eventStoreProxy } from "@src/engines/SessionCore/core/store/EventStoreProxy"; -import { isCursorIdeSession } from "@src/util/session/sessionDispatch"; import { MAX_LOADED_HISTORICAL_TURN_BODIES } from "./turnWindowConfig"; @@ -62,18 +62,18 @@ export function markTurnBodyLoaded( export async function pruneLoadedTurnBodies( sessionId: string, protectedTurnIds: Iterable -): Promise { - if (isCursorIdeSession(sessionId)) return; - +): Promise { + const boundedReplay = Boolean(resolveExternalReplayTarget(sessionId)); const loadedTurns = loadedTurnsBySession.get(sessionId); if (!loadedTurns || loadedTurns.size <= MAX_LOADED_HISTORICAL_TURN_BODIES) { - return; + return []; } const protectedSet = new Set(protectedTurnIds); const unloadCandidates = [...loadedTurns.entries()] .filter(([turnId]) => !protectedSet.has(turnId)) .sort((left, right) => left[1] - right[1]); + const prunedTurnIds: string[] = []; while ( loadedTurns.size > MAX_LOADED_HISTORICAL_TURN_BODIES && @@ -83,8 +83,15 @@ export async function pruneLoadedTurnBodies( if (!candidate) break; const [turnId] = candidate; loadedTurns.delete(turnId); - await eventStoreProxy.unloadTurnBody(sessionId, turnId); + prunedTurnIds.push(turnId); + // External replay already evicts bodies under its byte cap. Calling the + // native unload command would cross storage modes; dropping the small JS + // bookkeeping entry is enough and lets a revisit fetch the turn again. + if (!boundedReplay) { + await eventStoreProxy.unloadTurnBody(sessionId, turnId); + } } + return prunedTurnIds; } export function clearLoadedTurnRegistry(sessionId: string): void { diff --git a/src/engines/SessionCore/turns/turnLoaderRegistry.test.ts b/src/engines/SessionCore/turns/turnLoaderRegistry.test.ts new file mode 100644 index 000000000..f12e4f356 --- /dev/null +++ b/src/engines/SessionCore/turns/turnLoaderRegistry.test.ts @@ -0,0 +1,255 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { ExternalReplayWindow } from "@src/api/tauri/externalHistory/replay"; + +import { + clearLoadedTurnRegistry, + getSessionTurnLoader, + loadSessionTurnBodyIntoStore, + pruneLoadedTurnBodies, +} from "."; +import { externalReplayTurnLoader } from "./externalReplayTurnLoader"; +import { + captureLoadedTurnRegistryGeneration, + markTurnBodyLoaded, +} from "./loadedTurnRegistry"; +import { ownDbTurnLoader } from "./ownDbTurnLoader"; + +const mocks = vi.hoisted(() => { + let nextEpisodeId = 0; + const episodes = new Map(); + return { + readWindow: vi.fn(), + openWindow: vi.fn(), + mergeWindow: vi.fn(), + loadNativeTurn: vi.fn(async () => ({ turnId: "native", events: [] })), + mergeNativeEvents: vi.fn(async () => {}), + unloadNativeTurn: vi.fn(async () => 0), + resetEpisodes() { + nextEpisodeId = 0; + episodes.clear(); + }, + startEpisode(sessionId: string, generation: string | null = null) { + const episode = { id: ++nextEpisodeId, generation }; + episodes.set(sessionId, episode); + return episode; + }, + captureEpisode(sessionId: string) { + const existing = episodes.get(sessionId); + if (existing) return existing; + const episode = { id: ++nextEpisodeId, generation: "g1" }; + episodes.set(sessionId, episode); + return episode; + }, + isCurrentEpisode( + sessionId: string, + episode: { id: number; generation: string | null } + ) { + return episodes.get(sessionId)?.id === episode.id; + }, + }; +}); + +vi.mock("@src/api/tauri/externalHistory/replay", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("@src/api/tauri/externalHistory/replay") + >(); + return { ...actual, externalReplayReadWindow: mocks.readWindow }; +}); + +vi.mock("@src/engines/SessionCore/sync/externalReplayTransport", () => ({ + getActiveExternalReplayLease: (sessionId: string) => ({ + sessionId, + episodeId: 41, + }), + openExternalReplaySession: mocks.openWindow, + readExternalReplaySession: ( + lease: { sessionId: string; episodeId: number }, + selection: Record + ) => + mocks.readWindow({ + sessionId: lease.sessionId, + episodeId: lease.episodeId, + ...selection, + }), +})); + +vi.mock("@src/engines/SessionCore/sync/externalReplayTurnState", () => ({ + captureExternalReplayTurnEpisode: mocks.captureEpisode, + externalReplayTurnIndexFromId: (turnId: string) => { + const prefix = "__external_replay_turn_index__:"; + return turnId.startsWith(prefix) + ? Number(turnId.slice(prefix.length)) + : null; + }, + isCurrentExternalReplayTurnEpisode: mocks.isCurrentEpisode, + mergeExternalReplayTurnWindow: mocks.mergeWindow, + startExternalReplayTurnEpisode: mocks.startEpisode, +})); + +vi.mock("@src/engines/SessionCore/storage/cacheAdapter", () => ({ + loadTurnBody: mocks.loadNativeTurn, +})); + +vi.mock("@src/engines/SessionCore/core/store/EventStoreProxy", () => ({ + eventStoreProxy: { + mergeRoundWindowEvents: mocks.mergeNativeEvents, + unloadTurnBody: mocks.unloadNativeTurn, + }, +})); + +function replayWindow( + sessionId: string, + generation = "g1" +): ExternalReplayWindow { + return { + cursor: { + sourceId: sessionId.startsWith("cliagent-") ? "managed_cli" : "codex_app", + sessionId, + generation, + revision: 1, + throughSequence: 1, + }, + events: [], + windowStartSequence: null, + turnHeaders: [], + totalEventCount: 0, + totalTurnCount: 0, + hasOlder: false, + watcherAvailable: false, + stats: { + parsedBytes: 0, + parsedRows: 0, + normalizedEvents: 0, + upsertedEvents: 0, + removedEvents: 0, + ipcBytes: 0, + notReady: false, + }, + }; +} + +const BOUNDED_SESSION_IDS = [ + "codexapp-session", + "claudecodeapp-session", + "opencodeapp-session", + "clineapp-session", + "cliagent-session", + "imported-session-snapshot", +] as const; + +describe("session turn loader routing", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.resetEpisodes(); + for (const sessionId of BOUNDED_SESSION_IDS) { + clearLoadedTurnRegistry(sessionId); + } + clearLoadedTurnRegistry("sdeagent-native"); + clearLoadedTurnRegistry("agentsession-cloud-fork"); + mocks.readWindow.mockImplementation( + ({ sessionId }: { sessionId: string }) => + Promise.resolve(replayWindow(sessionId)) + ); + mocks.openWindow.mockImplementation( + ({ sessionId }: { sessionId: string }) => + Promise.resolve(replayWindow(sessionId, "g2")) + ); + }); + + it.each(BOUNDED_SESSION_IDS)( + "routes %s through foreground bounded readWindow", + async (sessionId) => { + expect(getSessionTurnLoader(sessionId)).toBe(externalReplayTurnLoader); + await loadSessionTurnBodyIntoStore({ + sessionId, + turnId: "__external_replay_turn_index__:3", + }); + expect(mocks.readWindow).toHaveBeenCalledWith({ + sessionId, + episodeId: 41, + turnIndex: 3, + limits: { + maxTurns: 1, + maxEvents: 200, + maxIpcBytes: 4 * 1024 * 1024, + }, + }); + expect(mocks.loadNativeTurn).not.toHaveBeenCalled(); + } + ); + + it.each(["sdeagent-native", "agentsession-cloud-fork"])( + "keeps %s on the native turn loader", + async (sessionId) => { + expect(getSessionTurnLoader(sessionId)).toBe(ownDbTurnLoader); + await loadSessionTurnBodyIntoStore({ sessionId, turnId: "native-turn" }); + expect(mocks.loadNativeTurn).toHaveBeenCalledWith( + sessionId, + "native-turn" + ); + expect(mocks.readWindow).not.toHaveBeenCalled(); + } + ); + + it("bounds external loaded-turn bookkeeping without native unload RPCs", async () => { + const sessionId = "codexapp-registry-bound"; + clearLoadedTurnRegistry(sessionId); + const generation = captureLoadedTurnRegistryGeneration(sessionId); + for (let index = 0; index < 12; index += 1) { + markTurnBodyLoaded(sessionId, `turn-${index}`, generation); + } + + const pruned = await pruneLoadedTurnBodies(sessionId, [ + "turn-10", + "turn-11", + ]); + + expect(pruned).toHaveLength(4); + expect(pruned).toEqual(["turn-0", "turn-1", "turn-2", "turn-3"]); + expect(mocks.unloadNativeTurn).not.toHaveBeenCalled(); + clearLoadedTurnRegistry(sessionId); + }); + + it("single-flights the same older page and does not apply it twice", async () => { + let resolve!: (window: ExternalReplayWindow) => void; + mocks.readWindow.mockReturnValue( + new Promise((done) => { + resolve = done; + }) + ); + const args = { + sessionId: "codexapp-session", + turnId: "__external_replay_turn_index__:1", + }; + const first = loadSessionTurnBodyIntoStore(args); + const second = loadSessionTurnBodyIntoStore(args); + expect(mocks.readWindow).toHaveBeenCalledTimes(1); + resolve(replayWindow(args.sessionId)); + await Promise.all([first, second]); + expect(mocks.mergeWindow).toHaveBeenCalledTimes(1); + }); + + it("reopens authoritatively when an older page belongs to a new generation", async () => { + mocks.readWindow.mockResolvedValue(replayWindow("codexapp-session", "g2")); + mocks.openWindow.mockResolvedValue(replayWindow("codexapp-session", "g2")); + + await loadSessionTurnBodyIntoStore({ + sessionId: "codexapp-session", + turnId: "__external_replay_turn_index__:0", + }); + + expect(mocks.openWindow).toHaveBeenCalledWith({ + sessionId: "codexapp-session", + episodeId: 41, + }); + expect(mocks.mergeWindow).toHaveBeenCalledTimes(1); + expect(mocks.mergeWindow).toHaveBeenCalledWith( + "codexapp-session", + expect.objectContaining({ + cursor: expect.objectContaining({ generation: "g2" }), + }) + ); + }); +}); diff --git a/src/engines/SessionCore/turns/turnLoaderRegistry.ts b/src/engines/SessionCore/turns/turnLoaderRegistry.ts index 075670dae..a9261da44 100644 --- a/src/engines/SessionCore/turns/turnLoaderRegistry.ts +++ b/src/engines/SessionCore/turns/turnLoaderRegistry.ts @@ -1,10 +1,6 @@ -import { - isCodexAppSession, - isCursorIdeSession, -} from "@src/util/session/sessionDispatch"; +import { resolveExternalReplayTarget } from "@src/api/tauri/externalHistory/replay"; -import { codexAppTurnLoader } from "./codexAppTurnLoader"; -import { cursorIdeTurnLoader } from "./cursorIdeTurnLoader"; +import { externalReplayTurnLoader } from "./externalReplayTurnLoader"; import { captureLoadedTurnRegistryGeneration, getPendingTurnLoad, @@ -15,11 +11,8 @@ import { ownDbTurnLoader } from "./ownDbTurnLoader"; import type { LoadTurnBodyIntoStoreArgs, SessionTurnLoader } from "./types"; export function getSessionTurnLoader(sessionId: string): SessionTurnLoader { - if (isCursorIdeSession(sessionId)) { - return cursorIdeTurnLoader; - } - if (isCodexAppSession(sessionId)) { - return codexAppTurnLoader; + if (resolveExternalReplayTarget(sessionId)) { + return externalReplayTurnLoader; } return ownDbTurnLoader; } diff --git a/src/features/Org2Cloud/CloudShareImportDialog.tsx b/src/features/Org2Cloud/CloudShareImportDialog.tsx index 3191b9796..fba5f27b3 100644 --- a/src/features/Org2Cloud/CloudShareImportDialog.tsx +++ b/src/features/Org2Cloud/CloudShareImportDialog.tsx @@ -43,7 +43,7 @@ import { } from "./cloudShareImportModel"; import type { CloudEndpoint } from "./config"; import { commitRefreshedAuth, org2CloudAuthAtom } from "./org2CloudAuthAtom"; -import { buildCloudSessionFetchClient } from "./org2CloudBackendAdapter"; +import { buildCloudSessionWirePageClient } from "./org2CloudBackendAdapter"; import { ensureFreshSession } from "./org2CloudClient"; import { consumeOrg2CloudPendingShareAtom, @@ -244,7 +244,7 @@ const CloudShareImportDialog: React.FC = () => { const result = await importRemoteSession({ // The JWT proves registration; the share token authorizes every // segments read without requiring source-org membership. - client: buildCloudSessionFetchClient( + client: buildCloudSessionWirePageClient( fresh.accessToken, resolved.endpoint ), diff --git a/src/features/Org2Cloud/addressCommentsRun.test.ts b/src/features/Org2Cloud/addressCommentsRun.test.ts index ed555d611..c4f7acab7 100644 --- a/src/features/Org2Cloud/addressCommentsRun.test.ts +++ b/src/features/Org2Cloud/addressCommentsRun.test.ts @@ -33,8 +33,22 @@ import { } from "./org2CloudCommentsClient"; import type { CloudSessionComment } from "./org2CloudCommentsClient"; +const replayMocks = vi.hoisted(() => ({ + resolveExternalReplayTarget: vi.fn(), + resolveSecondaryReplayTarget: vi.fn(), + queryWindowForTarget: vi.fn(), +})); +const eventStoreMocks = vi.hoisted(() => ({ + getPersistedEvents: vi.fn(), +})); + +vi.mock("@src/api/tauri/externalHistory/replay", () => ({ + resolveExternalReplayTarget: replayMocks.resolveExternalReplayTarget, + resolveSecondaryReplayTarget: replayMocks.resolveSecondaryReplayTarget, + externalReplayQueryWindowForTarget: replayMocks.queryWindowForTarget, +})); vi.mock("@src/engines/SessionCore/core/store/EventStoreProxy", () => ({ - eventStoreProxy: { getPersistedEvents: vi.fn(async () => []) }, + eventStoreProxy: { getPersistedEvents: eventStoreMocks.getPersistedEvents }, })); vi.mock("./org2CloudClient", () => ({ ensureFreshSession: vi.fn(async (state: unknown) => state), @@ -84,6 +98,9 @@ const AUTH: Org2CloudAuthState = { beforeEach(() => { vi.clearAllMocks(); + eventStoreMocks.getPersistedEvents.mockResolvedValue([]); + replayMocks.resolveExternalReplayTarget.mockReturnValue(null); + replayMocks.resolveSecondaryReplayTarget.mockResolvedValue(null); if (!isStoreInitialized()) createInstrumentedStore(); getInstrumentedStore().set(org2CloudAuthAtom, AUTH); resetTurnLifecycleForTests(); @@ -198,12 +215,80 @@ describe("runAddressCommentsRound", () => { }, }); expect(dispatchedAgentContent).toContain("id: c-1"); + expect(replayMocks.resolveExternalReplayTarget).toHaveBeenCalledWith( + "local-1" + ); + expect(eventStoreMocks.getPersistedEvents).toHaveBeenCalledWith("local-1"); + expect(replayMocks.resolveSecondaryReplayTarget).not.toHaveBeenCalled(); expect(result).toEqual({ status: "ran", threadCount: 1, replyCount: 0 }); expect( getInstrumentedStore().get(addressRunActiveAtom)["local-1"] ).toBeUndefined(); }); + it("loads managed owner anchors from bounded replay instead of persisted history", async () => { + vi.mocked(listSessionComments).mockResolvedValue({ + comments: [comment({ id: "c-1", eventId: "turn-7" })], + viewerOwnsSession: true, + }); + const replayTarget = { + sourceId: "managed_cli" as const, + sessionId: "cliagent-owner", + }; + replayMocks.resolveExternalReplayTarget.mockReturnValue(replayTarget); + replayMocks.queryWindowForTarget.mockResolvedValue({ + cursor: { + ...replayTarget, + generation: "managed-g1", + revision: 4, + throughSequence: 9, + }, + events: [ + { + id: "turn-7", + source: "user", + displayText: "bounded managed anchor", + }, + ], + turnHeaders: [], + totalEventCount: 10_000, + totalTurnCount: 2_000, + hasOlder: true, + watcherAvailable: false, + stats: { ipcBytes: 1_024 }, + }); + + await expect( + runAddressCommentsRound({ + orgId: "org-1", + cloudSessionId: "cliagent-owner", + localSessionId: "cliagent-owner", + dispatchTurn: async ({ turnIntentId }) => { + const generation = beginTurnDispatch("cliagent-owner"); + publishTurnIntentDispatch(turnIntentId, { + sessionId: "cliagent-owner", + generation, + }); + markTurnTerminal("cliagent-owner", "completed", { generation }); + }, + }) + ).resolves.toEqual({ status: "ran", threadCount: 1, replyCount: 0 }); + + expect(replayMocks.queryWindowForTarget).toHaveBeenCalledWith({ + target: replayTarget, + // Primary managed replay ids are already source ids; only copied + // collaboration snapshots carry the local-session namespace. + turnId: "turn-7", + limits: { + maxTurns: 1, + maxEvents: 4, + maxIpcBytes: 64 * 1024, + }, + }); + expect(replayMocks.resolveSecondaryReplayTarget).not.toHaveBeenCalled(); + expect(eventStoreMocks.getPersistedEvents).not.toHaveBeenCalled(); + }); + it("registers the run before dispatch so an immediate tool reply succeeds", async () => { vi.mocked(listSessionComments).mockResolvedValue({ comments: [comment({ id: "c-1" })], @@ -281,9 +366,35 @@ describe("runAddressCommentsRound", () => { it("allows a verified local fork to address its owner's source comments", async () => { vi.mocked(listSessionComments).mockResolvedValue({ - comments: [comment({ id: "c-1" })], + comments: [comment({ id: "c-1", eventId: "evt-a" })], viewerOwnsSession: true, }); + const replayTarget = { + sourceId: "collaboration_snapshot" as const, + sessionId: "agentsession-cloud-fork", + }; + replayMocks.resolveSecondaryReplayTarget.mockResolvedValue(replayTarget); + replayMocks.queryWindowForTarget.mockResolvedValue({ + cursor: { + ...replayTarget, + generation: "snapshot-g1", + revision: 3, + throughSequence: 10, + }, + events: [ + { + id: "agentsession-cloud-fork~evt-a", + source: "assistant", + displayText: "bounded anchor", + }, + ], + turnHeaders: [], + totalEventCount: 500, + totalTurnCount: 50, + hasOlder: true, + watcherAvailable: false, + stats: { ipcBytes: 1_024 }, + }); const provenance = vi .spyOn(forkSession, "getSessionForkedFrom") .mockReturnValue({ @@ -299,14 +410,16 @@ describe("runAddressCommentsRound", () => { runAddressCommentsRound({ orgId: "org-1", cloudSessionId: "cloud-session-1", - localSessionId: "fork-session-1", + localSessionId: "agentsession-cloud-fork", dispatchTurn: async ({ turnIntentId }) => { - const generation = beginTurnDispatch("fork-session-1"); + const generation = beginTurnDispatch("agentsession-cloud-fork"); publishTurnIntentDispatch(turnIntentId, { - sessionId: "fork-session-1", + sessionId: "agentsession-cloud-fork", + generation, + }); + markTurnTerminal("agentsession-cloud-fork", "completed", { generation, }); - markTurnTerminal("fork-session-1", "completed", { generation }); }, }) ).resolves.toEqual({ @@ -314,6 +427,19 @@ describe("runAddressCommentsRound", () => { threadCount: 1, replyCount: 0, }); + expect(replayMocks.resolveSecondaryReplayTarget).toHaveBeenCalledWith( + "agentsession-cloud-fork" + ); + expect(replayMocks.queryWindowForTarget).toHaveBeenCalledWith({ + target: replayTarget, + turnId: "agentsession-cloud-fork~evt-a", + limits: { + maxTurns: 1, + maxEvents: 4, + maxIpcBytes: 64 * 1024, + }, + }); + expect(eventStoreMocks.getPersistedEvents).not.toHaveBeenCalled(); } finally { provenance.mockRestore(); } diff --git a/src/features/Org2Cloud/addressCommentsRun.ts b/src/features/Org2Cloud/addressCommentsRun.ts index 148d17161..447391a87 100644 --- a/src/features/Org2Cloud/addressCommentsRun.ts +++ b/src/features/Org2Cloud/addressCommentsRun.ts @@ -8,6 +8,12 @@ */ import { atom } from "jotai"; +import { + type ExternalReplayTarget, + externalReplayQueryWindowForTarget, + resolveExternalReplayTarget, + resolveSecondaryReplayTarget, +} from "@src/api/tauri/externalHistory/replay"; import { type TurnIntentDispatch, waitForTurnIntentDispatch, @@ -23,7 +29,10 @@ import { createLogger } from "@src/hooks/logger"; import { sessionByIdAtom } from "@src/store/session/sessionAtom"; import { getInstrumentedStore } from "@src/util/core/state/instrumentedStore"; -import { stripCopyEventNamespace } from "../TeamCollaboration/copyEventId"; +import { + namespaceCopyEventId, + stripCopyEventNamespace, +} from "../TeamCollaboration/copyEventId"; import { type AddressableThread, buildAddressCommentsBriefing, @@ -280,6 +289,34 @@ export function attachAnchorExcerpts( }); } +async function loadBoundedAnchorEvents( + target: ExternalReplayTarget, + threads: readonly AddressableThread[], + copyNamespaced: boolean +): Promise { + const anchors = [ + ...new Set( + threads + .map((thread) => thread.anchorEventId) + .filter((id): id is string => Boolean(id)) + ), + ]; + const events = new Map(); + for (const anchorId of anchors) { + const window = await externalReplayQueryWindowForTarget({ + target, + turnId: copyNamespaced + ? namespaceCopyEventId(target.sessionId, anchorId) + : anchorId, + limits: { maxTurns: 1, maxEvents: 4, maxIpcBytes: 64 * 1024 }, + }); + for (const event of window.events) { + events.set(event.id, event); + } + } + return [...events.values()]; +} + export function seedActiveAddressRunForTest(run: ActiveAddressRun): () => void { activeAddressRuns.set(run.localSessionId, run); return () => { @@ -333,6 +370,7 @@ export async function runAddressCommentsRound( } = input; const finishRunActivity = beginRunActivity(localSessionId, selectedHeadIds); let run: ActiveAddressRun | null = null; + let verifiedLocalFork = false; try { const listToken = await freshAccessToken(); const { comments, viewerOwnsSession } = await listSessionComments( @@ -362,6 +400,7 @@ export async function runAddressCommentsRound( "@agent may use only a verified local fork of the owner's cloud source" ); } + verifiedLocalFork = true; } let threads = collectAddressableThreads(comments); if (selectedHeadIds !== undefined) { @@ -369,9 +408,18 @@ export async function runAddressCommentsRound( threads = threads.filter((thread) => selected.has(thread.headId)); } if (threads.length === 0) return { status: "no_threads" }; - const anchorEvents = await eventStoreProxy - .getPersistedEvents(localSessionId) - .catch(() => []); + const replayTarget = verifiedLocalFork + ? await resolveSecondaryReplayTarget(localSessionId) + : resolveExternalReplayTarget(localSessionId); + const anchorEvents = replayTarget + ? await loadBoundedAnchorEvents( + replayTarget, + threads, + verifiedLocalFork + ).catch(() => []) + : await eventStoreProxy + .getPersistedEvents(localSessionId) + .catch(() => []); threads = attachAnchorExcerpts(threads, anchorEvents, localSessionId); const turnIntentId = mintTurnIntentId(); diff --git a/src/features/Org2Cloud/org2CloudBackendAdapter.test.ts b/src/features/Org2Cloud/org2CloudBackendAdapter.test.ts index 598e3e023..ee66bdea7 100644 --- a/src/features/Org2Cloud/org2CloudBackendAdapter.test.ts +++ b/src/features/Org2Cloud/org2CloudBackendAdapter.test.ts @@ -2,19 +2,15 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { SessionEvent } from "@src/engines/SessionCore/core/types"; -import { computeSegmentHash } from "../TeamCollaboration/sync/collabGzip"; +import { bytesToBase64 } from "../TeamCollaboration/sync/collabGzip"; +import { toFrozenSegmentStorage } from "../TeamCollaboration/sync/segmentCodec"; import { - toFrozenSegmentStorage, - toFrozenSegmentWire, - toTailWire, -} from "../TeamCollaboration/sync/segmentCodec"; -import { - buildCloudSessionFetchClient, + buildCloudSessionWirePageClient, cloudSessionIdFromRowId, } from "./org2CloudBackendAdapter"; import { createGuestReplayObjectReader } from "./org2CloudReplaySignedReads"; import { downloadReplayObject } from "./org2CloudStorageClient"; -import type { CloudSessionEventsSnapshot } from "./org2CloudSyncClient"; +import type { CloudSessionEventWirePage } from "./org2CloudSyncClient"; import { Org2CloudSyncError, isOrg2SyncErrorCode } from "./org2CloudSyncClient"; vi.mock("./org2CloudSyncClient", async (importOriginal) => { @@ -37,6 +33,20 @@ const getSessionEventsMock = vi.mocked(getSessionEvents); const downloadReplayObjectMock = vi.mocked(downloadReplayObject); const createGuestReaderMock = vi.mocked(createGuestReplayObjectReader); +function emptyPage(): CloudSessionEventWirePage { + return { + epoch: null, + frozenSeq: null, + tailHash: null, + count: null, + segments: [], + tailIncluded: true, + hasMore: false, + nextCursor: null, + returnedWireBytes: 0, + }; +} + function makeEvent(id: string): SessionEvent { return { id, @@ -45,124 +55,148 @@ function makeEvent(id: string): SessionEvent { } as unknown as SessionEvent; } -const frozen1 = [makeEvent("f1"), makeEvent("f2")]; -const frozen2 = [makeEvent("f3")]; -const tail = [makeEvent("t1"), makeEvent("t2")]; - -async function cloudSnapshot(): Promise { - const tailWire = await toTailWire(tail); - return { - epoch: 2, - frozenSeq: 2, - tailHash: tailWire!.segmentHash, - count: 5, - segments: [ - await toFrozenSegmentWire({ seq: 1, events: frozen1 }), - await toFrozenSegmentWire({ seq: 2, events: frozen2 }), - // Tail is the seq-0 row on the cloud wire (self-hosted marks it with - // an isTail flag instead). - { ...tailWire!, seq: 0 }, - ], - }; -} - -describe("buildCloudSessionFetchClient", () => { +describe("cloud bounded replay adapter", () => { beforeEach(() => { getSessionEventsMock.mockReset(); downloadReplayObjectMock.mockReset(); createGuestReaderMock.mockReset(); }); - it("maps the cloud response into the self-hosted snapshot shape", async () => { - getSessionEventsMock.mockResolvedValue(await cloudSnapshot()); - const client = buildCloudSessionFetchClient("jwt-token"); + it("passes through a bounded raw page without renderer decoding", async () => { + const wire = { + seq: 42, + payloadGz: "opaque-v2-continuation", + eventCount: 0, + segmentHash: "physical-hash", + }; + const page: CloudSessionEventWirePage = { + epoch: 3, + frozenSeq: 100, + tailHash: null, + count: 80, + segments: [wire], + tailIncluded: true, + hasMore: true, + nextCursor: { direction: "backward", beforeSeq: 42 }, + returnedWireBytes: new TextEncoder().encode(JSON.stringify(wire)) + .byteLength, + }; + getSessionEventsMock.mockResolvedValue(page); + const client = buildCloudSessionWirePageClient("jwt-token"); - const snapshot = await client.getSessionEventSegments({ + const result = await client.getSessionEventWirePage({ orgId: "org-1", sessionRowId: "org-1:user-1:agentsession-abc", + cursor: { direction: "backward" }, + includeTail: true, + maxSegments: 16, + maxWireBytes: 1024 * 1024, }); - // JWT + bare cloud session id (not the composite row id) on the wire; - // no shareToken → no options (member path stays byte-identical). expect(getSessionEventsMock).toHaveBeenCalledWith( "jwt-token", "org-1", "agentsession-abc", - undefined + { + boundedWirePage: true, + cursor: { direction: "backward" }, + includeTail: true, + maxSegments: 16, + maxWireBytes: 1024 * 1024, + } ); - - expect(snapshot.epoch).toBe(2); - expect(snapshot.frozenSeq).toBe(2); - expect(snapshot.count).toBe(5); - expect(snapshot.tailHash).toBe((await toTailWire(tail))!.segmentHash); - expect(snapshot.segments).toHaveLength(3); - - const [seg1, seg2, tailSeg] = snapshot.segments; - expect(seg1).toMatchObject({ seq: 1, isTail: false, eventCount: 2 }); - expect(seg1.events).toEqual(frozen1); - expect(seg2).toMatchObject({ seq: 2, isTail: false, eventCount: 1 }); - expect(seg2.events).toEqual(frozen2); - expect(tailSeg).toMatchObject({ seq: 0, isTail: true, eventCount: 2 }); - expect(tailSeg.events).toEqual(tail); + expect(result).toMatchObject({ + epoch: 3, + frozenSeq: 100, + segments: [wire], + nextCursor: { direction: "backward", beforeSeq: 42 }, + }); + expect(result.segments[0]).not.toHaveProperty("events"); + expect(downloadReplayObjectMock).not.toHaveBeenCalled(); }); - it("downloads storagePath segments and decodes their raw gzip bytes (mixed page)", async () => { - const stored = await toFrozenSegmentStorage({ seq: 2, events: frozen2 }); - const storagePath = `org-1/agentsession-abc/2/2-${stored.segmentHash}.gz`; - const tailWire = await toTailWire(tail); + it("passes one oversized legacy candidate through for Rust verification", async () => { + const wire = { + seq: 1, + payloadGz: "x".repeat(257 * 1024), + eventCount: 1, + segmentHash: "legacy-hash", + }; getSessionEventsMock.mockResolvedValue({ - epoch: 2, - frozenSeq: 2, - tailHash: tailWire!.segmentHash, - count: 5, - segments: [ - await toFrozenSegmentWire({ seq: 1, events: frozen1 }), - { - seq: 2, - storagePath, - eventCount: 1, - segmentHash: stored.segmentHash, - }, - { ...tailWire!, seq: 0 }, - ], - }); - downloadReplayObjectMock.mockResolvedValue(stored.bytes); - const client = buildCloudSessionFetchClient("jwt-token"); + ...emptyPage(), + epoch: 1, + frozenSeq: 1, + count: 1, + segments: [wire], + returnedWireBytes: 100, + } as CloudSessionEventWirePage); + const client = buildCloudSessionWirePageClient("jwt-token"); - const snapshot = await client.getSessionEventSegments({ + const result = await client.getSessionEventWirePage({ orgId: "org-1", - sessionRowId: "org-1:user-1:agentsession-abc", + sessionRowId: "agentsession-legacy", + cursor: { direction: "backward" }, + includeTail: true, + maxSegments: 16, + maxWireBytes: 1024 * 1024, }); - expect(downloadReplayObjectMock).toHaveBeenCalledTimes(1); - expect(downloadReplayObjectMock).toHaveBeenCalledWith( - "jwt-token", - storagePath, - undefined, - undefined - ); - const [seg1, seg2, tailSeg] = snapshot.segments; - expect(seg1.events).toEqual(frozen1); - expect(seg2).toMatchObject({ seq: 2, isTail: false, eventCount: 1 }); - expect(seg2.events).toEqual(frozen2); - // Integrity contract downstream: the hash is over pre-gzip bytes, so the - // downloaded segment must still satisfy validateSegmentIntegrity. - expect(await computeSegmentHash(seg2.events)).toBe(seg2.segmentHash); - expect(tailSeg.events).toEqual(tail); + expect(result.segments).toEqual([wire]); }); - it("threads the pinned endpoint into member storage downloads", async () => { - const stored = await toFrozenSegmentStorage({ seq: 1, events: frozen1 }); - const storagePath = `org-1/agentsession-abc/1/1-${stored.segmentHash}.gz`; + it("rejects two oversized legacy candidates in one materialized page", async () => { + const segments = [1, 2].map((seq) => ({ + seq, + payloadGz: "x".repeat(257 * 1024), + eventCount: 1, + segmentHash: `legacy-${seq}`, + })); getSessionEventsMock.mockResolvedValue({ + ...emptyPage(), epoch: 1, - frozenSeq: 1, - tailHash: null, + frozenSeq: 2, count: 2, + segments, + returnedWireBytes: 200, + } as CloudSessionEventWirePage); + const client = buildCloudSessionWirePageClient("jwt-token"); + + await expect( + client.getSessionEventWirePage({ + orgId: "org-1", + sessionRowId: "agentsession-legacy", + cursor: { direction: "backward" }, + includeTail: true, + maxSegments: 16, + maxWireBytes: 1024 * 1024, + }) + ).rejects.toThrow(/more than one oversized legacy V1 candidate/); + }); + + it("materializes a storage row as opaque gzip without parsing events", async () => { + const stored = await toFrozenSegmentStorage({ + seq: 7, + events: [makeEvent("stored")], + }); + const storagePath = `org-1/session/1/7-${stored.segmentHash}.gz`; + getSessionEventsMock.mockResolvedValue({ + epoch: 1, + frozenSeq: 7, + tailHash: null, + count: 1, segments: [ - { seq: 1, storagePath, eventCount: 2, segmentHash: stored.segmentHash }, + { + seq: 7, + storagePath, + eventCount: stored.eventCount, + segmentHash: stored.segmentHash, + }, ], - }); + tailIncluded: false, + hasMore: false, + nextCursor: null, + returnedWireBytes: 64, + } as CloudSessionEventWirePage); downloadReplayObjectMock.mockResolvedValue(stored.bytes); const endpoint = { webOrigin: "https://app.custom.example.com", @@ -170,32 +204,55 @@ describe("buildCloudSessionFetchClient", () => { anonKey: "custom-anon", isOfficial: false, }; - const client = buildCloudSessionFetchClient("jwt-member", endpoint); + const controller = new AbortController(); + const client = buildCloudSessionWirePageClient("jwt-token", endpoint); - await client.getSessionEventSegments({ + const result = await client.getSessionEventWirePage({ orgId: "org-1", - sessionRowId: "org-1:user-1:agentsession-abc", + sessionRowId: "org-1:user-1:session", + cursor: { direction: "forward", afterSeq: 6 }, + includeTail: false, + maxSegments: 1, + maxWireBytes: 1024 * 1024, + signal: controller.signal, }); expect(downloadReplayObjectMock).toHaveBeenCalledWith( - "jwt-member", + "jwt-token", storagePath, endpoint, - undefined + controller.signal ); + expect(result.segments).toEqual([ + { + seq: 7, + payloadGz: bytesToBase64(stored.bytes), + eventCount: stored.eventCount, + segmentHash: stored.segmentHash, + }, + ]); + expect(result.segments[0]).not.toHaveProperty("events"); expect(createGuestReaderMock).not.toHaveBeenCalled(); }); - it("reads share-token storage segments through the signed-url flow", async () => { - const stored = await toFrozenSegmentStorage({ seq: 1, events: frozen1 }); + it("reads share-token storage rows through the signed-url flow", async () => { + const stored = await toFrozenSegmentStorage({ + seq: 1, + events: [makeEvent("shared")], + }); const storagePath = `org-1/agentsession-abc/1/1-${stored.segmentHash}.gz`; getSessionEventsMock.mockResolvedValue({ + ...emptyPage(), epoch: 1, frozenSeq: 1, - tailHash: null, - count: 2, + count: 1, segments: [ - { seq: 1, storagePath, eventCount: 2, segmentHash: stored.segmentHash }, + { + seq: 1, + storagePath, + eventCount: stored.eventCount, + segmentHash: stored.segmentHash, + }, ], }); const download = vi.fn(async () => stored.bytes); @@ -206,44 +263,57 @@ describe("buildCloudSessionFetchClient", () => { anonKey: "custom-anon", isOfficial: false, }; - const client = buildCloudSessionFetchClient("jwt-non-member", endpoint); - - const snapshot = await client.getSessionEventSegments({ + const client = buildCloudSessionWirePageClient("jwt-guest", endpoint); + const input = { orgId: "org-1", sessionRowId: "org-1:user-1:agentsession-abc", + cursor: { direction: "backward" as const }, + includeTail: true, + maxSegments: 16, + maxWireBytes: 1024 * 1024, shareToken: "t".repeat(64), - }); + }; + const first = await client.getSessionEventWirePage(input); + const second = await client.getSessionEventWirePage(input); + + expect(createGuestReaderMock).toHaveBeenCalledTimes(1); expect(createGuestReaderMock).toHaveBeenCalledWith({ orgId: "org-1", sessionId: "agentsession-abc", shareToken: "t".repeat(64), endpoint, }); + expect(download).toHaveBeenCalledTimes(2); expect(download).toHaveBeenCalledWith(storagePath, undefined); expect(downloadReplayObjectMock).not.toHaveBeenCalled(); - expect(snapshot.segments[0].events).toEqual(frozen1); - - // Same import, second fetch: the reader (and its signed-url cache) is - // shared instead of re-minting a grant per call. - await client.getSessionEventSegments({ - orgId: "org-1", - sessionRowId: "org-1:user-1:agentsession-abc", - shareToken: "t".repeat(64), + expect(first.segments[0]).toEqual({ + seq: 1, + payloadGz: bytesToBase64(stored.bytes), + eventCount: stored.eventCount, + segmentHash: stored.segmentHash, }); - expect(createGuestReaderMock).toHaveBeenCalledTimes(1); + expect(second.segments[0]).not.toHaveProperty("events"); }); - it("falls back to the member download when the authorize RPC is missing", async () => { - const stored = await toFrozenSegmentStorage({ seq: 1, events: frozen1 }); + it("falls back to the member storage read when signer RPC is missing", async () => { + const stored = await toFrozenSegmentStorage({ + seq: 1, + events: [makeEvent("fallback")], + }); const storagePath = `org-1/agentsession-abc/1/1-${stored.segmentHash}.gz`; getSessionEventsMock.mockResolvedValue({ + ...emptyPage(), epoch: 1, frozenSeq: 1, - tailHash: null, - count: 2, + count: 1, segments: [ - { seq: 1, storagePath, eventCount: 2, segmentHash: stored.segmentHash }, + { + seq: 1, + storagePath, + eventCount: stored.eventCount, + segmentHash: stored.segmentHash, + }, ], }); createGuestReaderMock.mockReturnValue({ @@ -252,33 +322,41 @@ describe("buildCloudSessionFetchClient", () => { }), }); downloadReplayObjectMock.mockResolvedValue(stored.bytes); - const client = buildCloudSessionFetchClient("jwt-non-member"); + const client = buildCloudSessionWirePageClient("jwt-guest"); - const snapshot = await client.getSessionEventSegments({ + const result = await client.getSessionEventWirePage({ orgId: "org-1", sessionRowId: "org-1:user-1:agentsession-abc", + cursor: { direction: "backward" }, + includeTail: true, + maxSegments: 16, + maxWireBytes: 1024 * 1024, shareToken: "t".repeat(64), }); expect(downloadReplayObjectMock).toHaveBeenCalledWith( - "jwt-non-member", + "jwt-guest", storagePath, undefined, undefined ); - expect(snapshot.segments[0].events).toEqual(frozen1); + expect(result.segments[0].payloadGz).toBe(bytesToBase64(stored.bytes)); }); - it("propagates guest signed-read failures without a member fallback", async () => { - const stored = await toFrozenSegmentStorage({ seq: 1, events: frozen1 }); - const storagePath = `org-1/agentsession-abc/1/1-${stored.segmentHash}.gz`; + it("propagates signed-read authorization failures without member fallback", async () => { + const storagePath = "org-1/agentsession-abc/1/1-hash.gz"; getSessionEventsMock.mockResolvedValue({ + ...emptyPage(), epoch: 1, frozenSeq: 1, - tailHash: null, - count: 2, + count: 1, segments: [ - { seq: 1, storagePath, eventCount: 2, segmentHash: stored.segmentHash }, + { + seq: 1, + storagePath, + eventCount: 1, + segmentHash: "hash", + }, ], }); createGuestReaderMock.mockReturnValue({ @@ -286,12 +364,16 @@ describe("buildCloudSessionFetchClient", () => { throw new Org2CloudSyncError("ORG2_FORBIDDEN", 403); }), }); - const client = buildCloudSessionFetchClient("jwt-non-member"); + const client = buildCloudSessionWirePageClient("jwt-guest"); await expect( - client.getSessionEventSegments({ + client.getSessionEventWirePage({ orgId: "org-1", sessionRowId: "org-1:user-1:agentsession-abc", + cursor: { direction: "backward" }, + includeTail: true, + maxSegments: 16, + maxWireBytes: 1024 * 1024, shareToken: "t".repeat(64), }) ).rejects.toSatisfy((error: unknown) => @@ -300,156 +382,104 @@ describe("buildCloudSessionFetchClient", () => { expect(downloadReplayObjectMock).not.toHaveBeenCalled(); }); - it("fails closed on a segment carrying neither payloadGz nor storagePath", async () => { - getSessionEventsMock.mockResolvedValue({ - epoch: 1, - frozenSeq: 1, - tailHash: null, - count: 1, - segments: [{ seq: 1, eventCount: 1, segmentHash: "h1" }], - }); - const client = buildCloudSessionFetchClient("jwt-token"); - - await expect( - client.getSessionEventSegments({ - orgId: "org-1", - sessionRowId: "org-1:user-1:agentsession-abc", - }) - ).rejects.toThrow(/neither payloadGz nor storagePath/); - expect(downloadReplayObjectMock).not.toHaveBeenCalled(); - }); - - it("passes afterSeq to the server range read and drops any smuggled prefix", async () => { - getSessionEventsMock.mockResolvedValue(await cloudSnapshot()); - const client = buildCloudSessionFetchClient("jwt-token"); - - const snapshot = await client.getSessionEventSegments({ - orgId: "org-1", - sessionRowId: "org-1:user-1:agentsession-abc", - afterSeq: 1, - }); - - // The cursor rides the wire (p_after_seq server-side range read). - expect(getSessionEventsMock).toHaveBeenCalledWith( - "jwt-token", - "org-1", - "agentsession-abc", - { afterSeq: 1 } - ); - // Defense in depth: a legacy/full response must still be filtered so an - // already-held frozen prefix never re-enters the incremental splice. - expect(snapshot.segments.map((s) => s.seq)).toEqual([2, 0]); - expect(snapshot.segments.map((s) => s.isTail)).toEqual([false, true]); - }); - - it("registered non-member path threads JWT and share token (0012)", async () => { - getSessionEventsMock.mockResolvedValue({ - epoch: 1, - frozenSeq: 0, - tailHash: null, - count: 0, - segments: [], - }); - const client = buildCloudSessionFetchClient("jwt-non-member"); - - await client.getSessionEventSegments({ - orgId: "org-1", - sessionRowId: "org-1:user-1:agentsession-abc", - shareToken: "t".repeat(64), - }); - - expect(getSessionEventsMock).toHaveBeenCalledWith( - "jwt-non-member", - "org-1", - "agentsession-abc", - { shareToken: "t".repeat(64) } - ); - }); - - it("pins registered-link segment reads to the endpoint used for resolve", async () => { - getSessionEventsMock.mockResolvedValue({ - epoch: 1, - frozenSeq: 0, - tailHash: null, - count: 0, - segments: [], - }); + it("threads share capability, endpoint and cancellation on the raw path", async () => { + getSessionEventsMock.mockResolvedValue(emptyPage()); const endpoint = { webOrigin: "https://app.custom.example.com", supabaseUrl: "https://db.custom.example.com", anonKey: "custom-anon", isOfficial: false, }; - const client = buildCloudSessionFetchClient("jwt-non-member", endpoint); + const controller = new AbortController(); + const client = buildCloudSessionWirePageClient("jwt-guest", endpoint); - await client.getSessionEventSegments({ + await client.getSessionEventWirePage({ orgId: "org-1", sessionRowId: "org-1:user-1:agentsession-abc", - shareToken: "t".repeat(64), + cursor: { direction: "forward", afterSeq: 4, throughSeq: 8 }, + includeTail: true, + maxSegments: 16, + maxWireBytes: 1024, + shareToken: "share-token", + signal: controller.signal, }); expect(getSessionEventsMock).toHaveBeenCalledWith( - "jwt-non-member", + "jwt-guest", "org-1", "agentsession-abc", - { shareToken: "t".repeat(64), endpoint } + expect.objectContaining({ + boundedWirePage: true, + cursor: { direction: "forward", afterSeq: 4, throughSeq: 8 }, + endpoint, + shareToken: "share-token", + signal: controller.signal, + }) ); }); - it("propagates ORG2_RETENTION_EXPIRED unswallowed", async () => { + it("fails closed when a row has neither inline nor storage payload", async () => { + getSessionEventsMock.mockResolvedValue({ + ...emptyPage(), + segments: [{ seq: 1, eventCount: 1, segmentHash: "hash" }], + } as unknown as CloudSessionEventWirePage); + const client = buildCloudSessionWirePageClient("jwt-token"); + + await expect( + client.getSessionEventWirePage({ + orgId: "org-1", + sessionRowId: "agentsession-bare-id", + cursor: { direction: "backward" }, + includeTail: true, + maxSegments: 16, + maxWireBytes: 1024, + }) + ).rejects.toThrow(/neither payloadGz nor storagePath/); + }); + + it("propagates retention errors without a decoded fallback", async () => { getSessionEventsMock.mockRejectedValue( new Org2CloudSyncError("ORG2_RETENTION_EXPIRED", 400) ); - const client = buildCloudSessionFetchClient("jwt-token"); + const client = buildCloudSessionWirePageClient("jwt-token"); - const attempt = client.getSessionEventSegments({ + const attempt = client.getSessionEventWirePage({ orgId: "org-1", - sessionRowId: "org-1:user-1:agentsession-abc", + sessionRowId: "agentsession-bare-id", + cursor: { direction: "backward" }, + includeTail: true, + maxSegments: 16, + maxWireBytes: 1024, }); await expect(attempt).rejects.toSatisfy((error: unknown) => isOrg2SyncErrorCode(error, "ORG2_RETENTION_EXPIRED") ); + expect(getSessionEventsMock).toHaveBeenCalledTimes(1); }); - it("passes an empty (never-published) summary through as nulls", async () => { - getSessionEventsMock.mockResolvedValue({ - epoch: null, - frozenSeq: null, - tailHash: null, - count: null, - segments: [], - }); - const client = buildCloudSessionFetchClient("jwt-token"); + it("passes through a never-published bounded summary", async () => { + getSessionEventsMock.mockResolvedValue(emptyPage()); + const client = buildCloudSessionWirePageClient("jwt-token"); - const snapshot = await client.getSessionEventSegments({ + const result = await client.getSessionEventWirePage({ orgId: "org-1", sessionRowId: "agentsession-bare-id", + cursor: { direction: "backward" }, + includeTail: true, + maxSegments: 16, + maxWireBytes: 1024, }); - expect(getSessionEventsMock).toHaveBeenCalledWith( - "jwt-token", - "org-1", - "agentsession-bare-id", - undefined - ); - expect(snapshot).toEqual({ - epoch: null, - frozenSeq: null, - tailHash: null, - count: null, - segments: [], - }); + expect(result).toEqual(emptyPage()); }); }); describe("cloudSessionIdFromRowId", () => { - it("extracts the bare session id from a composite row id", () => { + it("extracts the bare id while preserving colons inside session ids", () => { expect(cloudSessionIdFromRowId("org:user:agentsession-x")).toBe( "agentsession-x" ); - // Session ids may themselves contain colons — everything after the - // second colon belongs to the id. expect(cloudSessionIdFromRowId("org:user:a:b")).toBe("a:b"); expect(cloudSessionIdFromRowId("agentsession-x")).toBe("agentsession-x"); }); diff --git a/src/features/Org2Cloud/org2CloudBackendAdapter.ts b/src/features/Org2Cloud/org2CloudBackendAdapter.ts index b2bd288bb..184c79b7a 100644 --- a/src/features/Org2Cloud/org2CloudBackendAdapter.ts +++ b/src/features/Org2Cloud/org2CloudBackendAdapter.ts @@ -1,43 +1,23 @@ /** - * Cloud → collab backend adapter (replay/fork wiring for managed orgs). + * Cloud → collaboration backend adapter for bounded replay and fork reads. * - * `importRemoteSession` / `forkTeammateSession` are backend-agnostic: they - * only ever call `client.getSessionEventSegments(...)`. This module gives - * the managed ORG2 Cloud backend that one capability by wrapping - * `cloud_get_session_events` (org2CloudSyncClient) in the EXACT - * canonical `SessionEventSegmentsSnapshot` shape: - * - * - cloud `seq = 0` is the mutable tail row → canonical `isTail: true`; - * - cloud `payloadGz` (gzipped base64 event array) → decoded `events` via - * the SHARED `decodeSegmentEvents` codec (byte-identical wire format — - * both backends push through `segmentCodec`); a 0006 `storagePath` - * segment downloads its raw gzip object from the replay bucket instead; - * - cloud `{epoch, frozenSeq, tailHash, count}` map 1:1 to the snapshot - * summary fields (`cloud_get_session_events` was built as a mirror of - * `orgii_get_session_event_segments`); - * - the cloud RPC has no `after_seq` parameter (always returns the full - * epoch), so the importer's incremental contract ("frozen segments with - * seq strictly greater than afterSeq; tail always included") is applied - * client-side by filtering. - * - * Errors are NOT swallowed: `Org2CloudSyncError` (notably code - * ORG2_RETENTION_EXPIRED, raised when a replay click races past the - * server-side retention filter) propagates to the caller so the panel can - * show an upgrade prompt instead of a generic failure. + * The collaboration importer consumes opaque, byte-bounded physical rows. + * Inline cloud rows already contain base64 gzip payloads. Storage-offloaded + * rows are downloaded as raw gzip bytes and base64-encoded without decoding + * their event arrays in the renderer. */ -import type { SessionEvent } from "@src/engines/SessionCore/core/types"; - import type { CollabSyncBackendClient, - GetSessionEventSegmentsInput, - SessionEventSegmentRecord, - SessionEventSegmentsSnapshot, + GetSessionEventWirePageInput, + SessionEventSegmentWireRecord, + SessionEventWirePage, } from "../TeamCollaboration/sync/CollabSyncBackend"; import { - decodeSegmentEvents, - decodeSegmentEventsFromBytes, - mapSegmentsBounded, -} from "../TeamCollaboration/sync/segmentCodec"; + SESSION_EVENT_WIRE_MAX_LEGACY_V1_SEGMENT_BYTES, + SESSION_EVENT_WIRE_MAX_PAGE_BYTES, + SESSION_EVENT_WIRE_MAX_SEGMENT_BYTES, +} from "../TeamCollaboration/sync/CollabSyncBackend"; +import { bytesToBase64 } from "../TeamCollaboration/sync/collabGzip"; import type { CloudEndpoint } from "./config"; import { type GuestReplayObjectReader, @@ -47,14 +27,15 @@ import { import { downloadReplayObject } from "./org2CloudStorageClient"; import { type CloudSegmentWire, + type CloudSessionEventWirePage, + CloudSessionWirePageContractError, getSessionEvents, - streamSessionEvents, } from "./org2CloudSyncClient"; -/** The one capability replay/fork need from a backend. */ -export type CloudSessionFetchClient = Pick< +/** Raw bounded client used by the Rust-backed import path. */ +export type CloudSessionWirePageClient = Pick< CollabSyncBackendClient, - "getSessionEventSegments" | "streamSessionEventSegments" + "getSessionEventWirePage" >; type SegmentObjectDownload = ( @@ -62,79 +43,113 @@ type SegmentObjectDownload = ( signal?: AbortSignal ) => Promise; -async function decodeCloudSegmentEvents( +const wireEncoder = new TextEncoder(); + +function segmentWireBytes(segment: SessionEventSegmentWireRecord): number { + return ( + wireEncoder.encode(JSON.stringify({ ...segment, payloadGz: "" })) + .byteLength + segment.payloadGz.length + ); +} + +async function materializeCloudSegment( segment: CloudSegmentWire, downloadObject: SegmentObjectDownload, - signal?: AbortSignal -): Promise { - if (segment.payloadGz != null) { - return decodeSegmentEvents(segment.payloadGz); - } - if (segment.storagePath != null) { - return decodeSegmentEventsFromBytes( - await downloadObject(segment.storagePath, signal) + signal: AbortSignal | undefined +): Promise { + const seq = segment.seq ?? 0; + const payloadGz = + segment.payloadGz ?? + (segment.storagePath + ? bytesToBase64(await downloadObject(segment.storagePath, signal)) + : null); + if (payloadGz === null) { + throw new CloudSessionWirePageContractError( + `cloud segment ${seq} carries neither payloadGz nor storagePath` ); } - throw new Error("cloud segment carries neither payloadGz nor storagePath"); + return { + seq, + payloadGz, + eventCount: segment.eventCount, + segmentHash: segment.segmentHash, + }; } -async function decodeCloudSegments( - segments: readonly CloudSegmentWire[], - afterSeq: number, - downloadObject: SegmentObjectDownload, - signal?: AbortSignal -): Promise { - return mapSegmentsBounded( - segments.filter((segment) => { - const seq = segment.seq ?? 0; - return seq === 0 || seq > afterSeq; - }), - async (segment) => { - const seq = segment.seq ?? 0; - return { - seq, - isTail: seq === 0, - events: await decodeCloudSegmentEvents(segment, downloadObject, signal), - eventCount: segment.eventCount, - segmentHash: segment.segmentHash, - }; - }, - signal - ); +async function materializeCloudPage( + page: CloudSessionEventWirePage, + input: GetSessionEventWirePageInput, + downloadObject: SegmentObjectDownload +): Promise { + const segments: SessionEventSegmentWireRecord[] = []; + let returnedWireBytes = 0; + let legacyV1CandidateCount = 0; + for (const segment of page.segments) { + if (input.signal?.aborted) { + throw input.signal.reason ?? new DOMException("Aborted", "AbortError"); + } + const materialized = await materializeCloudSegment( + segment, + downloadObject, + input.signal + ); + const bytes = segmentWireBytes(materialized); + if (bytes > SESSION_EVENT_WIRE_MAX_SEGMENT_BYTES) { + if (bytes > SESSION_EVENT_WIRE_MAX_LEGACY_V1_SEGMENT_BYTES) { + throw new CloudSessionWirePageContractError( + `materialized cloud segment ${materialized.seq} is ${bytes} bytes ` + + `(legacy V1 limit ${SESSION_EVENT_WIRE_MAX_LEGACY_V1_SEGMENT_BYTES})` + ); + } + legacyV1CandidateCount += 1; + if (legacyV1CandidateCount > 1) { + throw new CloudSessionWirePageContractError( + "materialized cloud page contains more than one oversized legacy V1 candidate" + ); + } + } + returnedWireBytes += bytes; + const pageLimit = + legacyV1CandidateCount === 0 + ? input.maxWireBytes + : SESSION_EVENT_WIRE_MAX_PAGE_BYTES + + SESSION_EVENT_WIRE_MAX_LEGACY_V1_SEGMENT_BYTES; + if (returnedWireBytes > pageLimit) { + throw new CloudSessionWirePageContractError( + `materialized cloud page is ${returnedWireBytes} bytes ` + + `(limit ${pageLimit})` + ); + } + segments.push(materialized); + } + return { + epoch: page.epoch, + frozenSeq: page.frozenSeq, + tailHash: page.tailHash, + count: page.count, + segments, + tailIncluded: page.tailIncluded, + hasMore: page.hasMore, + nextCursor: page.nextCursor, + returnedWireBytes, + }; } /** - * The importer passes `remoteSession.id` (`${orgId}:${ownerUserId}: - * ${sourceSessionId}`, built by `toRemoteMetadata`) as `sessionRowId`, - * while the cloud RPC keys on the bare `session_id` (= sourceSessionId — - * see `Org2CloudSyncEngine.upsertMetadataIfChanged`). orgId and - * ownerUserId are UUIDs (colon-free), so the cloud key is everything after - * the second colon; a colon-free input is already a bare session id. + * The importer passes `${orgId}:${ownerUserId}:${sourceSessionId}` while the + * cloud RPC keys on the bare source session id. UUID org/user prefixes are + * colon-free, so everything after the second colon is the cloud session id. */ export function cloudSessionIdFromRowId(sessionRowId: string): string { const parts = sessionRowId.split(":"); return parts.length >= 3 ? parts.slice(2).join(":") : sessionRowId; } -/** - * Build the segments-fetch client `importRemoteSession` / `forkSession` - * expect, bound to one cloud access token (caller refreshes via - * `ensureFreshSession` first — RPC wrappers do not refresh). - * - * Link imports still require a registered user's access token. The optional - * `input.shareToken` — threaded by the importer from - * `RemoteSessionFetchOptions` — grants that signed-in user access without - * requiring membership in the source org. Storage-offloaded segments then - * read through the signed-url flow (`org2CloudReplaySignedReads`): the guest - * JWT cannot pass the replay bucket's member RLS. The signed-url map is - * cached per (session, share token) for this client's lifetime — one import. - * A backend without the authorize RPC falls back to the member download so - * the import surfaces exactly the failure it had before the signer existed. - */ -export function buildCloudSessionFetchClient( +/** Build the only replay/fork read capability: bounded opaque wire pages. */ +export function buildCloudSessionWirePageClient( accessToken: string, endpoint?: CloudEndpoint -): CloudSessionFetchClient { +): CloudSessionWirePageClient { const guestReaders = new Map(); const downloadForInput = (input: { orgId: string; @@ -176,74 +191,27 @@ export function buildCloudSessionFetchClient( }; }; return { - async getSessionEventSegments( - input: GetSessionEventSegmentsInput - ): Promise { - const afterSeq = input.afterSeq ?? 0; - const snapshot = await getSessionEvents( - accessToken, - input.orgId, - cloudSessionIdFromRowId(input.sessionRowId), - input.shareToken !== undefined || - endpoint !== undefined || - afterSeq > 0 || - input.signal !== undefined - ? { - ...(input.shareToken !== undefined - ? { shareToken: input.shareToken } - : {}), - ...(endpoint !== undefined ? { endpoint } : {}), - // Server-side range read (p_after_seq): an incremental pull - // must not download the frozen prefix it already holds. - ...(afterSeq > 0 ? { afterSeq } : {}), - ...(input.signal !== undefined ? { signal: input.signal } : {}), - } - : undefined - ); - const segments = await decodeCloudSegments( - snapshot.segments, - afterSeq, - downloadForInput(input), - input.signal - ); - return { - epoch: snapshot.epoch, - frozenSeq: snapshot.frozenSeq, - tailHash: snapshot.tailHash, - count: snapshot.count, - segments, - }; - }, - async streamSessionEventSegments(input, onPage) { - const afterSeq = input.afterSeq ?? 0; - const downloadObject = downloadForInput(input); - return streamSessionEvents( + async getSessionEventWirePage( + input: GetSessionEventWirePageInput + ): Promise { + const page = await getSessionEvents( accessToken, input.orgId, cloudSessionIdFromRowId(input.sessionRowId), - async (page) => { - await onPage({ - epoch: page.epoch, - frozenSeq: page.frozenSeq, - tailHash: page.tailHash, - count: page.count, - segments: await decodeCloudSegments( - page.segments, - afterSeq, - downloadObject, - input.signal - ), - }); - }, { + boundedWirePage: true, + cursor: input.cursor, + includeTail: input.includeTail, + maxSegments: input.maxSegments, + maxWireBytes: input.maxWireBytes, ...(input.shareToken !== undefined ? { shareToken: input.shareToken } : {}), ...(endpoint !== undefined ? { endpoint } : {}), - ...(afterSeq > 0 ? { afterSeq } : {}), ...(input.signal !== undefined ? { signal: input.signal } : {}), } ); + return materializeCloudPage(page, input, downloadForInput(input)); }, }; } diff --git a/src/features/Org2Cloud/org2CloudSessionSync.ts b/src/features/Org2Cloud/org2CloudSessionSync.ts index 6498acd72..6d6a55560 100644 --- a/src/features/Org2Cloud/org2CloudSessionSync.ts +++ b/src/features/Org2Cloud/org2CloudSessionSync.ts @@ -1,12 +1,19 @@ -import { getImportedHistorySourceBySessionId } from "@src/api/tauri/externalHistory"; +import { + type ExternalReplayCloudManifest, + type ExternalReplayTarget, + externalReplayCloudPrefixHash, + externalReplayCloudPrepareForTarget, + externalReplayCloudReadBatch, + externalReplayCloudRelease, + resolveExternalReplayTarget, + resolveSecondaryReplayTarget, +} from "@src/api/tauri/externalHistory/replay"; import { eventStoreProxy } from "@src/engines/SessionCore/core/store/EventStoreProxy"; import type { SessionEvent } from "@src/engines/SessionCore/core/types"; -import { processChunksRust } from "@src/engines/SessionCore/ingestion/rustBridge"; import { createLogger } from "@src/hooks/logger"; import { COLLAB_SESSION_ACCESS_MODE } from "@src/store/collaboration/types"; import type { RemoteTeammateSessionMetadata } from "@src/store/collaboration/types"; import type { Session } from "@src/store/session/sessionAtom/types"; -import { isImportedHistorySession } from "@src/util/session/sessionDispatch"; import { sha256Hex, @@ -16,7 +23,10 @@ import { computeFrozenEventCount, splitFrozenIntoSegments, } from "../TeamCollaboration/engine/collabSyncEngineHelpers"; +import { getSessionForkedFrom } from "../TeamCollaboration/forkSession"; +import { SESSION_EVENT_WIRE_MAX_SEGMENT_BYTES } from "../TeamCollaboration/sync/CollabSyncBackend"; import { computeSegmentHash } from "../TeamCollaboration/sync/collabGzip"; +import type { SegmentWirePayload } from "../TeamCollaboration/sync/segmentCodec"; import type { CloudPushAccess } from "./org2CloudAccessSettings"; import type { Org2CloudAuthState } from "./org2CloudAuthAtom"; import { broadcastOrgControlChangedToPeers } from "./org2CloudControlBus"; @@ -31,7 +41,10 @@ import type { PreparedPushPlan, } from "./org2CloudSessionSync.types"; import type { CollabSessionPushCursor } from "./org2CloudSyncAtoms"; -import { isOrg2SyncErrorCode } from "./org2CloudSyncClient"; +import { + type CloudStoredSegmentWire, + isOrg2SyncErrorCode, +} from "./org2CloudSyncClient"; import type { CloudStore } from "./org2CloudSyncLifecycle"; export { @@ -42,8 +55,8 @@ export type { Org2CloudSyncClientDeps } from "./org2CloudSessionSync.types"; const log = createLogger("Org2CloudSyncEngine"); -/** Largest cursor accepted by the backend's int4 `p_after_seq` argument. */ -const HEAD_READ_AFTER_SEQ = 2_147_483_647; +/** Server-epoch probes need only one compact physical row. */ +const HEAD_READ_MAX_WIRE_BYTES = 64 * 1024; /** * Keep every segment mutation comfortably below PostgREST / PostgreSQL @@ -65,6 +78,17 @@ interface SessionPushRetryState { retryAtMs: number; } +interface PreparedExternalPush { + stampAtRead: number; + manifest: ExternalReplayCloudManifest; +} + +function throwIfAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted) { + throw signal.reason ?? new DOMException("Aborted", "AbortError"); + } +} + async function hashEventsBounded(events: SessionEvent[]): Promise { const hashes = new Array(events.length); for (let start = 0; start < events.length; start += EVENT_HASH_CONCURRENCY) { @@ -94,6 +118,11 @@ export class Org2CloudSessionSync extends Org2CloudSessionSyncState { string, SessionPushRetryState >(); + /** External replay spools are prepared once and reused across orgs/pass. */ + private readonly passExternalPrepareCache = new Map< + string, + Promise + >(); constructor( getStore: () => CloudStore | null, @@ -103,8 +132,26 @@ export class Org2CloudSessionSync extends Org2CloudSessionSyncState { } override reset(): void { + this.releaseExternalPassSpools(); super.reset(); this.sessionPushRetryStates.clear(); + this.passExternalPrepareCache.clear(); + } + + override beginPass(): void { + this.releaseExternalPassSpools(); + super.beginPass(); + this.passExternalPrepareCache.clear(); + } + + private releaseExternalPassSpools(): void { + for (const prepared of this.passExternalPrepareCache.values()) { + void prepared + .then(({ manifest }) => externalReplayCloudRelease(manifest.token)) + .catch((error: unknown) => { + log.warn("failed to release external replay cloud spool", error); + }); + } } override prune( @@ -159,6 +206,353 @@ export class Org2CloudSessionSync extends Org2CloudSessionSyncState { ); } + private async pushExternalSession( + auth: Org2CloudAuthState, + orgId: string, + session: Session, + scopeKey: string | null, + access: CloudPushAccess, + replayTarget: ExternalReplayTarget, + signal?: AbortSignal + ): Promise { + throwIfAborted(signal); + const sessionId = session.session_id; + const { stampAtRead, manifest } = + await this.prepareExternalPushForPass(replayTarget); + throwIfAborted(signal); + const cursor = this.getCursor(orgId, sessionId); + if (!cursor && manifest.totalCount === 0) { + await this.upsertMetadataIfChanged( + auth, + orgId, + session, + scopeKey, + access + ); + throwIfAborted(signal); + this.markEventPlaneClean(orgId, session, stampAtRead); + return; + } + const sourceShrank = + cursor !== undefined && manifest.totalCount < cursor.pushedCount; + if (cursor && sourceShrank) { + log.warn( + `bounded replay spool for ${sessionId} has ${manifest.totalCount} ` + + `events but the cloud cursor covers ${cursor.pushedCount}; ` + + "rewriting the cloud epoch" + ); + } + + let frozenIntact = + !sourceShrank && + cursor !== undefined && + manifest.frozenEventCount >= cursor.frozenEventCount; + if (cursor && frozenIntact && cursor.frozenEventCount > 0) { + const prefix = await externalReplayCloudPrefixHash({ + token: manifest.token, + eventCount: cursor.frozenEventCount, + }); + throwIfAborted(signal); + frozenIntact = prefix.frozenChainHash === cursor.frozenChainHash; + } + if ( + cursor && + frozenIntact && + manifest.frozenEventCount === cursor.frozenEventCount && + manifest.tailHash === cursor.tailHash && + manifest.totalCount === cursor.pushedCount + ) { + await this.upsertMetadataIfChanged( + auth, + orgId, + session, + scopeKey, + access + ); + throwIfAborted(signal); + this.markEventPlaneClean(orgId, session, stampAtRead); + return; + } + + throwIfAborted(signal); + await this.upsertMetadataIfChanged(auth, orgId, session, scopeKey, access); + throwIfAborted(signal); + if (cursor && frozenIntact) { + try { + await this.appendExternalSpool( + auth, + orgId, + sessionId, + manifest, + cursor, + signal + ); + } catch (error) { + if (!isOrg2SyncErrorCode(error, "ORG2_CONFLICT")) throw error; + await this.rewriteExternalSpool( + auth, + orgId, + sessionId, + manifest, + null, + signal + ); + } + } else { + await this.rewriteExternalSpool( + auth, + orgId, + sessionId, + manifest, + cursor ? cursor.epoch + 1 : 1, + signal + ); + } + throwIfAborted(signal); + this.markEventPlaneClean(orgId, session, stampAtRead); + } + + private async readExternalFrozenBatch( + manifest: ExternalReplayCloudManifest, + startEventIndex: number, + startSegmentIndex?: number, + signal?: AbortSignal + ) { + throwIfAborted(signal); + if (startEventIndex >= manifest.frozenEventCount) { + return { + segments: [], + startEventIndex, + nextEventIndex: startEventIndex, + startSegmentIndex: startSegmentIndex ?? 0, + nextSegmentIndex: startSegmentIndex ?? 0, + eof: true, + serializedBytes: 0, + }; + } + const batch = await externalReplayCloudReadBatch({ + token: manifest.token, + startEventIndex, + endEventIndex: manifest.frozenEventCount, + ...(startSegmentIndex !== undefined ? { startSegmentIndex } : {}), + // Leave room for JSON array punctuation in the 256 KiB wire segment. + maxBytes: SESSION_EVENT_WIRE_MAX_SEGMENT_BYTES - 16 * 1024, + }); + throwIfAborted(signal); + if ( + !batch.eof && + batch.nextEventIndex <= startEventIndex && + batch.nextSegmentIndex <= batch.startSegmentIndex + ) { + throw new Error("External replay cloud batch cursor did not advance"); + } + return batch; + } + + private async readExternalTail( + manifest: ExternalReplayCloudManifest, + signal?: AbortSignal + ): Promise | null> { + throwIfAborted(signal); + if (manifest.tailEventCount === 0) return null; + const batch = await externalReplayCloudReadBatch({ + token: manifest.token, + startEventIndex: manifest.frozenEventCount, + endEventIndex: manifest.totalCount, + maxBytes: SESSION_EVENT_WIRE_MAX_SEGMENT_BYTES, + }); + throwIfAborted(signal); + if ( + !batch.eof || + batch.serializedBytes > SESSION_EVENT_WIRE_MAX_SEGMENT_BYTES || + batch.segments.length !== 1 + ) { + throw new Error( + `External replay mutable tail exceeds the ${SESSION_EVENT_WIRE_MAX_SEGMENT_BYTES}-byte cloud wire budget` + ); + } + const segment = batch.segments[0]; + if (!segment) return null; + return { + payloadGz: segment.payloadGz, + eventCount: segment.eventCount, + segmentHash: segment.segmentHash, + }; + } + + private async appendExternalSpool( + auth: Org2CloudAuthState, + orgId: string, + sessionId: string, + manifest: ExternalReplayCloudManifest, + cursor: CollabSessionPushCursor, + signal?: AbortSignal + ): Promise { + throwIfAborted(signal); + let eventIndex = cursor.frozenEventCount; + let segmentIndex: number | undefined; + let frozenSeq = cursor.frozenSeq; + let expectedTailHash = cursor.tailHash; + do { + const batch = await this.readExternalFrozenBatch( + manifest, + eventIndex, + segmentIndex, + signal + ); + const finalFrozenBatch = batch.eof; + const tail = finalFrozenBatch + ? await this.readExternalTail(manifest, signal) + : null; + const segments = batch.segments.map((segment, index) => ({ + seq: frozenSeq + index + 1, + payloadGz: segment.payloadGz, + eventCount: segment.eventCount, + segmentHash: segment.segmentHash, + })); + await this.client.appendSessionEventWires(auth.accessToken, { + orgId, + sessionId, + expectedEpoch: cursor.epoch, + expectedFrozenSeq: frozenSeq, + expectedTailHash, + newFrozenSegments: segments, + tail, + totalCount: batch.nextEventIndex + (tail?.eventCount ?? 0), + }); + throwIfAborted(signal); + eventIndex = batch.nextEventIndex; + segmentIndex = batch.nextSegmentIndex; + frozenSeq += segments.length; + expectedTailHash = finalFrozenBatch ? manifest.tailHash : null; + if (finalFrozenBatch) break; + } while (eventIndex < manifest.frozenEventCount); + throwIfAborted(signal); + this.setExternalCursor(orgId, sessionId, manifest, cursor.epoch, frozenSeq); + broadcastOrgControlChangedToPeers(orgId, "sessions"); + } + + private async rewriteExternalSpool( + auth: Org2CloudAuthState, + orgId: string, + sessionId: string, + manifest: ExternalReplayCloudManifest, + requestedEpoch: number | null, + signal?: AbortSignal + ): Promise { + throwIfAborted(signal); + let epoch = + requestedEpoch ?? + (await this.readServerEpoch(auth, orgId, sessionId, signal)) + 1; + let reanchored = requestedEpoch === null; + for (;;) { + try { + await this.rewriteExternalAtEpoch( + auth, + orgId, + sessionId, + manifest, + epoch, + signal + ); + return; + } catch (error) { + if (!isOrg2SyncErrorCode(error, "ORG2_CONFLICT") || reanchored) { + throw error; + } + reanchored = true; + epoch = + (await this.readServerEpoch(auth, orgId, sessionId, signal)) + 1; + } + } + } + + private async rewriteExternalAtEpoch( + auth: Org2CloudAuthState, + orgId: string, + sessionId: string, + manifest: ExternalReplayCloudManifest, + epoch: number, + signal?: AbortSignal + ): Promise { + throwIfAborted(signal); + // Upload the unpublished epoch first. Only compact storage references are + // retained across batches; the visible session row is not changed until + // the final rewrite RPC atomically installs every frozen reference and + // the mutable tail together. + const storedSegments: CloudStoredSegmentWire[] = []; + let eventIndex = 0; + let segmentIndex: number | undefined; + let frozenSeq = 0; + while (eventIndex < manifest.frozenEventCount) { + const batch = await this.readExternalFrozenBatch( + manifest, + eventIndex, + segmentIndex, + signal + ); + const segments = batch.segments.map((segment, index) => ({ + seq: frozenSeq + index + 1, + payloadGz: segment.payloadGz, + eventCount: segment.eventCount, + segmentHash: segment.segmentHash, + })); + const uploaded = await this.client.uploadSessionEventWires( + auth.accessToken, + { + orgId, + sessionId, + epoch, + frozenSegments: segments, + ...(signal !== undefined ? { signal } : {}), + } + ); + storedSegments.push(...uploaded); + throwIfAborted(signal); + if ( + batch.nextEventIndex <= eventIndex && + batch.nextSegmentIndex <= (segmentIndex ?? 0) + ) { + throw new Error("External replay upload cursor did not advance"); + } + eventIndex = batch.nextEventIndex; + segmentIndex = batch.nextSegmentIndex; + frozenSeq += uploaded.length; + } + const tail = await this.readExternalTail(manifest, signal); + throwIfAborted(signal); + await this.client.rewriteSessionEventWires(auth.accessToken, { + orgId, + sessionId, + newEpoch: epoch, + frozenSegments: storedSegments, + tail, + totalCount: manifest.totalCount, + }); + throwIfAborted(signal); + this.setExternalCursor(orgId, sessionId, manifest, epoch, frozenSeq); + broadcastOrgControlChangedToPeers(orgId, "sessions"); + } + + private setExternalCursor( + orgId: string, + sessionId: string, + manifest: ExternalReplayCloudManifest, + epoch: number, + frozenSeq: number + ): void { + this.setCursor({ + orgId, + sessionId, + epoch, + frozenSeq, + pushedCount: manifest.totalCount, + frozenEventCount: manifest.frozenEventCount, + frozenChainHash: manifest.frozenChainHash, + tailHash: manifest.tailHash, + }); + } + /** * Seed the volatile cold-start caches from a server-authoritative listing. * For imported CLI sessions the local `updated_at` comes from the source @@ -209,7 +603,7 @@ export class Org2CloudSessionSync extends Org2CloudSessionSyncState { await sha256Hex(stableStringify(localMetadata)) ); this.setPushedMetadataMarker(orgId, session.session_id); - if (!isImportedHistorySession(session.session_id)) return; + if (!resolveExternalReplayTarget(session.session_id)) return; const cursor = this.getCursor(orgId, session.session_id); if ( !cursor || @@ -276,18 +670,25 @@ export class Org2CloudSessionSync extends Org2CloudSessionSyncState { broadcastOrgControlChangedToPeers(orgId, "sessions"); } - /** Load the complete native or external-history transcript for upload. */ + /** Native SDE path only; external/managed CLI sessions use Rust spools. */ async loadPushEvents(sessionId: string): Promise { - if (isImportedHistorySession(sessionId)) { - const source = getImportedHistorySourceBySessionId(sessionId); - if (!source) return []; - const chunks = await source.loadFullTranscriptChunks(sessionId); - if (!Array.isArray(chunks) || chunks.length === 0) return []; - return processChunksRust(chunks, sessionId); - } return eventStoreProxy.getPersistedEvents(sessionId); } + private prepareExternalPushForPass( + target: ExternalReplayTarget + ): Promise { + const { sessionId } = target; + const cached = this.passExternalPrepareCache.get(sessionId); + if (cached) return cached; + const prepared = (async (): Promise => ({ + stampAtRead: this.eventActivityStamps.get(sessionId) ?? 0, + manifest: await externalReplayCloudPrepareForTarget(target), + }))(); + this.passExternalPrepareCache.set(sessionId, prepared); + return prepared; + } + private preparePushEventsForPass( sessionId: string ): Promise { @@ -333,7 +734,8 @@ export class Org2CloudSessionSync extends Org2CloudSessionSyncState { orgId: string, session: Session, scopeKey: string | null, - access: CloudPushAccess + access: CloudPushAccess, + signal?: AbortSignal ): Promise { const sessionId = session.session_id; if ( @@ -352,7 +754,14 @@ export class Org2CloudSessionSync extends Org2CloudSessionSyncState { return; } try { - await this.pushSessionOnce(auth, orgId, session, scopeKey, access); + await this.pushSessionOnce( + auth, + orgId, + session, + scopeKey, + access, + signal + ); this.clearSessionPushFailure(orgId, sessionId); } catch (error) { if (this.shouldBackOffSessionFailure(error)) { @@ -367,8 +776,10 @@ export class Org2CloudSessionSync extends Org2CloudSessionSyncState { orgId: string, session: Session, scopeKey: string | null, - access: CloudPushAccess + access: CloudPushAccess, + signal?: AbortSignal ): Promise { + throwIfAborted(signal); const sessionId = session.session_id; if (access.accessMode === COLLAB_SESSION_ACCESS_MODE.METADATA_ONLY) { await this.upsertMetadataIfChanged( @@ -397,6 +808,25 @@ export class Org2CloudSessionSync extends Org2CloudSessionSyncState { scopeKey, access ); + throwIfAborted(signal); + return; + } + const primaryReplayTarget = resolveExternalReplayTarget(sessionId); + const replayTarget = + primaryReplayTarget ?? + (getSessionForkedFrom(session) + ? await resolveSecondaryReplayTarget(sessionId) + : null); + if (replayTarget) { + await this.pushExternalSession( + auth, + orgId, + session, + scopeKey, + access, + replayTarget, + signal + ); return; } const { stampAtRead, events, plan } = @@ -642,56 +1072,29 @@ export class Org2CloudSessionSync extends Org2CloudSessionSyncState { ); for (;;) { try { - const progressive = - frozenSegments.length > SESSION_SEGMENT_UPLOAD_BATCH_SIZE; - const initialSegments = progressive - ? frozenSegments.slice(0, SESSION_SEGMENT_UPLOAD_BATCH_SIZE) - : frozenSegments; - const initialFrozenEventCount = initialSegments.reduce( - (count, segment) => count + segment.events.length, - 0 - ); - const initialChainHash = - initialFrozenEventCount === plan.frozenEventCount - ? plan.frozenChainHash - : await this.computeFrozenChainHash( - plan.perEventHashes, - initialFrozenEventCount - ); + // The rewrite RPC is the publication boundary. Frozen payloads may be + // uploaded to Storage first, but the old epoch remains authoritative + // until this one transaction installs the complete reference set. + // Never expose a first-page epoch and append the remainder afterward. await this.client.rewriteSessionEvents(auth.accessToken, { orgId, sessionId, newEpoch: epoch, - frozenSegments: initialSegments, - tail: - !progressive && plan.tailEvents.length > 0 ? plan.tailEvents : null, - totalCount: progressive - ? initialFrozenEventCount - : plan.events.length, + frozenSegments, + tail: plan.tailEvents.length > 0 ? plan.tailEvents : null, + totalCount: plan.events.length, }); const cursor: CollabSessionPushCursor = { orgId, sessionId, epoch, - frozenSeq: initialSegments.length, - pushedCount: progressive - ? initialFrozenEventCount - : plan.events.length, - frozenEventCount: initialFrozenEventCount, - frozenChainHash: initialChainHash, - tailHash: progressive ? null : plan.tailHash, + frozenSeq: frozenSegments.length, + pushedCount: plan.events.length, + frozenEventCount: plan.frozenEventCount, + frozenChainHash: plan.frozenChainHash, + tailHash: plan.tailHash, }; this.setCursor(cursor); - if (progressive) { - await this.appendSessionBatches( - auth, - orgId, - sessionId, - cursor, - frozenSegments.slice(initialSegments.length), - plan - ); - } broadcastOrgControlChangedToPeers(orgId, "sessions"); return; } catch (error) { @@ -707,14 +1110,24 @@ export class Org2CloudSessionSync extends Org2CloudSessionSyncState { private async readServerEpoch( auth: Org2CloudAuthState, orgId: string, - sessionId: string + sessionId: string, + signal?: AbortSignal ): Promise { + throwIfAborted(signal); const snapshot = await this.client.getSessionEvents( auth.accessToken, orgId, sessionId, - { afterSeq: HEAD_READ_AFTER_SEQ } + { + boundedWirePage: true, + cursor: { direction: "backward" }, + includeTail: false, + maxSegments: 1, + maxWireBytes: HEAD_READ_MAX_WIRE_BYTES, + ...(signal !== undefined ? { signal } : {}), + } ); + throwIfAborted(signal); return snapshot.epoch ?? 0; } } diff --git a/src/features/Org2Cloud/org2CloudSessionSync.types.ts b/src/features/Org2Cloud/org2CloudSessionSync.types.ts index fb36207b3..3921b71ac 100644 --- a/src/features/Org2Cloud/org2CloudSessionSync.types.ts +++ b/src/features/Org2Cloud/org2CloudSessionSync.types.ts @@ -8,17 +8,33 @@ import type { SessionEvent } from "@src/engines/SessionCore/core/types"; import * as org2CloudSyncClient from "./org2CloudSyncClient"; /** Client seam so tests inject fetch-free fakes. */ -export type Org2CloudSyncClientDeps = Pick< +type Org2CloudSyncClientMethods = Pick< typeof org2CloudSyncClient, | "upsertSessionMetadata" | "appendSessionEvents" + | "appendSessionEventWires" | "rewriteSessionEvents" - | "getSessionEvents" + | "rewriteSessionEventWires" + | "uploadSessionEventWires" | "getOrgRepoScopes" | "listOrgSessions" | "deleteSession" >; +export interface Org2CloudSyncClientDeps extends Org2CloudSyncClientMethods { + getSessionEvents( + accessToken: string, + orgId: string, + sessionId: string, + options?: + | org2CloudSyncClient.GetSessionEventsOptions + | org2CloudSyncClient.GetSessionEventWirePageOptions + ): Promise< + | org2CloudSyncClient.CloudSessionEventsSnapshot + | org2CloudSyncClient.CloudSessionEventWirePage + >; +} + export interface PreparedPushPlan { perEventHashes: string[]; frozenEventCount: number; diff --git a/src/features/Org2Cloud/org2CloudSyncClient.test.ts b/src/features/Org2Cloud/org2CloudSyncClient.test.ts index 76197e6f2..6a1d456a8 100644 --- a/src/features/Org2Cloud/org2CloudSyncClient.test.ts +++ b/src/features/Org2Cloud/org2CloudSyncClient.test.ts @@ -2,7 +2,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { SessionEvent } from "@src/engines/SessionCore/core/types"; -import { computeSegmentHash } from "../TeamCollaboration/sync/collabGzip"; +import { + bytesToBase64, + computeSegmentHash, +} from "../TeamCollaboration/sync/collabGzip"; import { decodeSegmentEvents, decodeSegmentEventsFromBytes, @@ -14,16 +17,20 @@ import { } from "./config"; import { getCloudCapabilities } from "./org2CloudCapabilities"; import { + CloudSessionWirePageContractError, Org2CloudSyncError, __SESSION_LISTING_INTERNALS, __STORAGE_SEGMENTS_INTERNALS, + appendSessionEventWires, appendSessionEvents, getOrgRepoScopes, getSessionEvents, isOrg2SyncErrorCode, listOrgSessions, + rewriteSessionEventWires, rewriteSessionEvents, setOrgRepoScopes, + uploadSessionEventWires, upsertSessionMetadata, } from "./org2CloudSyncClient"; @@ -155,6 +162,49 @@ describe("cloud_upsert_session_metadata", () => { }); describe("cloud_append_session_events", () => { + it("forwards Rust-prepared segment wires without decoding them", async () => { + const wire = { + seq: 9, + payloadGz: "opaque-rust-gzip", + eventCount: 1, + segmentHash: "rust-hash", + }; + await appendSessionEventWires("jwt-1", { + orgId: "org-1", + sessionId: "s-1", + expectedEpoch: 2, + expectedFrozenSeq: 8, + expectedTailHash: null, + newFrozenSegments: [wire], + tail: null, + totalCount: 9, + }); + expect(lastBody().new_frozen_segments).toEqual([wire]); + }); + + it("fails closed before fetch when any encoded segment exceeds 256 KiB", async () => { + await expect( + appendSessionEventWires("jwt-1", { + orgId: "org-1", + sessionId: "s-1", + expectedEpoch: 1, + expectedFrozenSeq: 0, + expectedTailHash: null, + newFrozenSegments: [ + { + seq: 1, + payloadGz: "x".repeat(256 * 1024), + eventCount: 1, + segmentHash: "oversized", + }, + ], + tail: null, + totalCount: 1, + }) + ).rejects.toThrow("versioned attachment wire is required"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it("builds shared-codec segment wire payloads with OCC anchors", async () => { const frozen = [makeEvent("f1")]; const tail = [makeEvent("t1")]; @@ -216,6 +266,43 @@ describe("cloud_append_session_events", () => { }); describe("cloud_rewrite_session_events", () => { + it("forwards a bounded Rust rewrite wire unchanged", async () => { + const wire = { + seq: 1, + payloadGz: "opaque-rust-gzip", + eventCount: 1, + segmentHash: "rust-hash", + }; + await rewriteSessionEventWires("jwt-1", { + orgId: "org-1", + sessionId: "s-1", + newEpoch: 4, + frozenSegments: [wire], + tail: null, + totalCount: 1, + }); + expect(lastBody().frozen_segments).toEqual([wire]); + }); + + it("publishes a complete stored-reference epoch in one rewrite RPC", async () => { + const stored = Array.from({ length: 32 }, (_, index) => ({ + seq: index + 1, + storagePath: `org-1/s-1/5/${index + 1}-hash-${index}.gz`, + eventCount: 1, + segmentHash: `hash-${index}`, + })); + await rewriteSessionEventWires("jwt-1", { + orgId: "org-1", + sessionId: "s-1", + newEpoch: 5, + frozenSegments: stored, + tail: null, + totalCount: stored.length, + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(lastBody().frozen_segments).toEqual(stored); + }); + it("ships the rewrite body with new_epoch", async () => { await rewriteSessionEvents("jwt-1", { orgId: "org-1", @@ -312,6 +399,65 @@ describe("storage segment offload (0006)", () => { expect(tailWire).not.toHaveProperty("storagePath"); }); + it("uploads opaque Rust wires and returns compact epoch references", async () => { + const gzip = new Uint8Array([31, 139, 8, 0, 1, 2, 3, 4]); + const stored = await uploadSessionEventWires("jwt-1", { + orgId: "org-1", + sessionId: "s-1", + epoch: 9, + frozenSegments: [ + { + seq: 7, + payloadGz: bytesToBase64(gzip), + eventCount: 1, + segmentHash: "opaque-hash", + }, + ], + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect((fetchMock.mock.calls[0] as [string, RequestInit])[0]).toBe( + `${ORG2_CLOUD_OFFICIAL_SUPABASE_URL}/storage/v1/object/replay/org-1/s-1/9/7-opaque-hash.gz` + ); + expect( + Array.from( + (fetchMock.mock.calls[0] as [string, RequestInit])[1].body as Uint8Array + ) + ).toEqual(Array.from(gzip)); + expect(stored).toEqual([ + { + seq: 7, + storagePath: "org-1/s-1/9/7-opaque-hash.gz", + eventCount: 1, + segmentHash: "opaque-hash", + }, + ]); + }); + + it("fails before publication when storage segments are unavailable", async () => { + capabilitiesMock.mockResolvedValue({ + broadcastSignals: false, + storageSegments: false, + homeEndpoints: false, + }); + await expect( + uploadSessionEventWires("jwt-1", { + orgId: "org-1", + sessionId: "s-1", + epoch: 9, + frozenSegments: [ + { + seq: 1, + payloadGz: bytesToBase64(new Uint8Array([31, 139])), + eventCount: 1, + segmentHash: "hash", + }, + ], + }) + ).rejects.toThrow(/storage-segment support/); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it("rewrite keys the object paths by the new epoch", async () => { const frozen = [makeEvent("f1")]; await rewriteSessionEvents("jwt-1", { @@ -803,4 +949,281 @@ describe("cloud_list_org_sessions keyset pagination (0005)", () => { since: "2026-07-22T00:00:00.000Z", }); }); + + it("requests a byte-bounded latest page without decoding its wires", async () => { + const frozen = { + seq: 7, + payloadGz: "frozen-wire", + eventCount: 1, + segmentHash: "frozen-hash", + }; + const tail = { + seq: 0, + payloadGz: "tail-wire", + eventCount: 2, + segmentHash: "tail-hash", + }; + const returnedWireBytes = [frozen, tail].reduce( + (total, segment) => + total + new TextEncoder().encode(JSON.stringify(segment)).byteLength, + 0 + ); + fetchMock.mockResolvedValueOnce( + jsonResponse({ + epoch: 4, + frozenSeq: 9, + tailHash: "tail-hash", + count: 12, + segments: [frozen, tail], + direction: "backward", + tailIncluded: true, + hasMore: true, + nextCursor: { direction: "backward", beforeSeq: 7 }, + returnedWireBytes, + }) + ); + + const page = await getSessionEvents("jwt-1", "org-1", "s-1", { + boundedWirePage: true, + cursor: { direction: "backward" }, + includeTail: true, + maxSegments: 16, + maxWireBytes: 1024 * 1024, + }); + + expect(lastBody()).toEqual({ + p_org_id: "org-1", + p_session_id: "s-1", + p_direction: "backward", + p_include_tail: true, + p_max_segments: 16, + p_max_wire_bytes: 1024 * 1024, + }); + expect(page.segments).toEqual([frozen, tail]); + expect(page.nextCursor).toEqual({ + direction: "backward", + beforeSeq: 7, + }); + }); + + it("passes the backward continuation cursor for older frozen rows", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ + epoch: 4, + frozenSeq: 9, + tailHash: null, + count: 12, + segments: [], + direction: "backward", + tailIncluded: false, + hasMore: false, + nextCursor: null, + returnedWireBytes: 0, + }) + ); + + await getSessionEvents("jwt-1", "org-1", "s-1", { + boundedWirePage: true, + cursor: { direction: "backward", beforeSeq: 7 }, + includeTail: false, + maxSegments: 16, + maxWireBytes: 1024, + }); + + expect(lastBody()).toMatchObject({ + p_direction: "backward", + p_before_seq: 7, + p_include_tail: false, + p_max_segments: 16, + p_max_wire_bytes: 1024, + }); + }); + + it("pins a multi-page forward cursor while accepting V2 eventCount zero rows", async () => { + const continuation = { + seq: 1, + payloadGz: "v2-part-1", + eventCount: 0, + segmentHash: "part-1-hash", + }; + const finalPart = { + seq: 2, + payloadGz: "v2-part-2", + eventCount: 1, + segmentHash: "part-2-hash", + }; + const bytes = (segment: typeof continuation): number => + new TextEncoder().encode(JSON.stringify(segment)).byteLength; + fetchMock + .mockResolvedValueOnce( + jsonResponse({ + epoch: 8, + frozenSeq: 2, + tailHash: null, + count: 1, + segments: [continuation], + direction: "forward", + tailIncluded: false, + hasMore: true, + nextCursor: { + direction: "forward", + afterSeq: 1, + throughSeq: 2, + }, + returnedWireBytes: bytes(continuation), + }) + ) + .mockResolvedValueOnce( + jsonResponse({ + epoch: 8, + frozenSeq: 2, + tailHash: null, + count: 1, + segments: [finalPart], + direction: "forward", + tailIncluded: false, + hasMore: false, + nextCursor: null, + returnedWireBytes: bytes(finalPart), + }) + ); + + const first = await getSessionEvents("jwt-1", "org-1", "s-1", { + boundedWirePage: true, + cursor: { direction: "forward", afterSeq: 0 }, + includeTail: false, + maxSegments: 1, + maxWireBytes: 1024, + }); + expect(first.segments[0].eventCount).toBe(0); + expect(first.nextCursor).toEqual({ + direction: "forward", + afterSeq: 1, + throughSeq: 2, + }); + + await getSessionEvents("jwt-1", "org-1", "s-1", { + boundedWirePage: true, + cursor: first.nextCursor!, + includeTail: false, + maxSegments: 1, + maxWireBytes: 1024, + }); + expect(lastBody()).toMatchObject({ + p_direction: "forward", + p_after_seq: 1, + p_through_seq: 2, + p_max_segments: 1, + p_max_wire_bytes: 1024, + }); + }); + + it("fails closed when an old server omits bounded pagination metadata", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ epoch: 1, frozenSeq: 0, segments: [] }) + ); + + await expect( + getSessionEvents("jwt-1", "org-1", "s-1", { + boundedWirePage: true, + cursor: { direction: "backward" }, + includeTail: true, + maxSegments: 16, + maxWireBytes: 1024, + }) + ).rejects.toThrow(); + }); + + it("stops reading when a response exceeds the legacy migration body cap", async () => { + fetchMock.mockResolvedValueOnce( + new Response("{}", { + headers: { + "content-type": "application/json", + "content-length": String(70 * 1024 * 1024), + }, + }) + ); + + await expect( + getSessionEvents("jwt-1", "org-1", "s-1", { + boundedWirePage: true, + cursor: { direction: "backward" }, + includeTail: true, + maxSegments: 1, + maxWireBytes: 1024, + }) + ).rejects.toThrow(/response (declares|exceeded)/); + }); + + it("admits one oversized legacy V1 candidate for Rust verification", async () => { + const oversized = { + seq: 1, + payloadGz: "x".repeat(257 * 1024), + eventCount: 0, + segmentHash: "oversized", + }; + fetchMock.mockResolvedValueOnce( + jsonResponse({ + epoch: 1, + frozenSeq: 1, + tailHash: null, + count: 0, + segments: [oversized], + direction: "forward", + tailIncluded: false, + hasMore: false, + nextCursor: null, + returnedWireBytes: new TextEncoder().encode(JSON.stringify(oversized)) + .byteLength, + }) + ); + + const page = await getSessionEvents("jwt-1", "org-1", "s-1", { + boundedWirePage: true, + cursor: { direction: "forward", afterSeq: 0 }, + includeTail: false, + maxSegments: 16, + maxWireBytes: 1024 * 1024, + }); + expect(page.segments).toEqual([oversized]); + }); + + it("rejects pages with two oversized legacy candidates", async () => { + const oversized = (seq: number) => ({ + seq, + payloadGz: "x".repeat(257 * 1024), + eventCount: 1, + segmentHash: `oversized-${seq}`, + }); + const segments = [oversized(1), oversized(2)]; + fetchMock.mockResolvedValueOnce( + jsonResponse({ + epoch: 1, + frozenSeq: 2, + tailHash: null, + count: 2, + segments, + direction: "forward", + tailIncluded: false, + hasMore: false, + nextCursor: null, + returnedWireBytes: segments.reduce( + (total, segment) => + total + + new TextEncoder().encode(JSON.stringify(segment)).byteLength, + 0 + ), + }) + ); + + await expect( + getSessionEvents("jwt-1", "org-1", "s-1", { + boundedWirePage: true, + cursor: { direction: "forward", afterSeq: 0 }, + includeTail: false, + maxSegments: 16, + maxWireBytes: 1024 * 1024, + }) + ).rejects.toBeInstanceOf(CloudSessionWirePageContractError); + }); }); diff --git a/src/features/Org2Cloud/org2CloudSyncClient.ts b/src/features/Org2Cloud/org2CloudSyncClient.ts index 80c892869..b67e38bb9 100644 --- a/src/features/Org2Cloud/org2CloudSyncClient.ts +++ b/src/features/Org2Cloud/org2CloudSyncClient.ts @@ -23,8 +23,18 @@ import type { RemoteTeammateSessionMetadata, } from "@src/store/collaboration/types"; -import type { SessionEventsSegmentInput } from "../TeamCollaboration/sync/CollabSyncBackend"; import { + SESSION_EVENT_WIRE_MAX_LEGACY_V1_SEGMENT_BYTES, + SESSION_EVENT_WIRE_MAX_PAGE_BYTES, + SESSION_EVENT_WIRE_MAX_PAGE_SEGMENTS, + SESSION_EVENT_WIRE_MAX_SEGMENT_BYTES, + type SessionEventWirePage, + type SessionEventWirePageCursor, + type SessionEventsSegmentInput, +} from "../TeamCollaboration/sync/CollabSyncBackend"; +import { base64ToBytes } from "../TeamCollaboration/sync/collabGzip"; +import { + type SegmentWirePayload, mapSegmentsBounded, toFrozenSegmentStorage, toFrozenSegmentWire, @@ -45,6 +55,36 @@ import { uploadReplayObject, } from "./org2CloudStorageClient"; +const cloudSegmentWireEncoder = new TextEncoder(); +/** JSON envelope/cursors in addition to the server-counted segment bytes. */ +const CLOUD_WIRE_PAGE_RESPONSE_OVERHEAD_BYTES = 64 * 1024; + +function cloudSegmentWireBytes(segment: CloudSegmentWire): number { + if (typeof segment.payloadGz !== "string") { + return cloudSegmentWireEncoder.encode(JSON.stringify(segment)).byteLength; + } + // Base64 contains no JSON escape characters. Measure the small envelope + // with an empty payload and add the existing string length instead of + // allocating a second copy of a legacy row that may be tens of MiB. + return ( + cloudSegmentWireEncoder.encode( + JSON.stringify({ ...segment, payloadGz: "" }) + ).byteLength + segment.payloadGz.length + ); +} + +function assertCloudSegmentWireBudget( + segment: SegmentWirePayload | Omit +): void { + const wireBytes = cloudSegmentWireBytes(segment); + if (wireBytes > SESSION_EVENT_WIRE_MAX_SEGMENT_BYTES) { + throw new Error( + `Cloud segment wire is ${wireBytes} bytes (limit ${SESSION_EVENT_WIRE_MAX_SEGMENT_BYTES}); ` + + "the current SessionEvent[] RPC cannot split one event, so a versioned attachment wire is required" + ); + } +} + // --------------------------------------------------------------------------- // Error model // --------------------------------------------------------------------------- @@ -115,7 +155,8 @@ async function callSyncRpc( body: Record, endpoint: CloudEndpoint = getCloudEndpoint(), signal?: AbortSignal, - timeoutMs?: number + timeoutMs?: number, + responseByteLimit?: number ): Promise { const execute = async (requestSignal?: AbortSignal): Promise => { const response = await fetchWithTransportRetry( @@ -127,7 +168,7 @@ async function callSyncRpc( signal: requestSignal, } ); - const text = await response.text(); + const text = await readSyncRpcResponseText(response, responseByteLimit); let payload: unknown = null; try { payload = text ? JSON.parse(text) : null; @@ -150,18 +191,69 @@ async function callSyncRpc( return runCloudRequestWithTimeout(execute, timeoutMs, signal); } +async function readSyncRpcResponseText( + response: Response, + byteLimit?: number +): Promise { + if (byteLimit === undefined) return response.text(); + + const declaredBytes = Number(response.headers.get("content-length")); + if (Number.isFinite(declaredBytes) && declaredBytes > byteLimit) { + throw new CloudSessionWirePageContractError( + `cloud_get_session_events response declares ${declaredBytes} bytes (limit ${byteLimit})` + ); + } + if (!response.body) { + const text = await response.text(); + const bytes = cloudSegmentWireEncoder.encode(text).byteLength; + if (bytes > byteLimit) { + throw new CloudSessionWirePageContractError( + `cloud_get_session_events response is ${bytes} bytes (limit ${byteLimit})` + ); + } + return text; + } + + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let totalBytes = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + totalBytes += value.byteLength; + if (totalBytes > byteLimit) { + await reader.cancel("bounded cloud response exceeded byte limit"); + throw new CloudSessionWirePageContractError( + `cloud_get_session_events response exceeded ${byteLimit} bytes` + ); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + const body = new Uint8Array(totalBytes); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + return new TextDecoder().decode(body); +} + // --------------------------------------------------------------------------- // Wire schemas // --------------------------------------------------------------------------- const CloudSegmentWireSchema = z .object({ - seq: z.number(), + seq: z.number().int().nonnegative(), // 0006 storage offload: a frozen segment carries storagePath XOR the // legacy inline payloadGz. The tail (seq 0) is always inline. payloadGz: z.string().nullish(), storagePath: z.string().nullish(), - eventCount: z.number(), + eventCount: z.number().int().nonnegative(), segmentHash: z.string(), }) .refine( @@ -169,6 +261,21 @@ const CloudSegmentWireSchema = z { message: "segment carries neither payloadGz nor storagePath" } ); +const CloudSessionEventWirePageCursorSchema = z.discriminatedUnion( + "direction", + [ + z.object({ + direction: z.literal("forward"), + afterSeq: z.number().int().nonnegative(), + throughSeq: z.number().int().nonnegative().optional(), + }), + z.object({ + direction: z.literal("backward"), + beforeSeq: z.number().int().positive().optional(), + }), + ] +); + const CloudSessionEventsSchema = z.object({ epoch: z.number().nullish().default(null), frozenSeq: z.number().nullish().default(null), @@ -177,6 +284,24 @@ const CloudSessionEventsSchema = z.object({ segments: z.array(CloudSegmentWireSchema).default([]), }); +/** + * New bounded reads deliberately require every pagination field. Parsing an + * old server's full-snapshot response with this schema fails closed instead + * of silently recreating the #443 full-history path. + */ +const CloudSessionEventWirePageSchema = z.object({ + epoch: z.number().nullish().default(null), + frozenSeq: z.number().int().nonnegative().nullish().default(null), + tailHash: z.string().nullish().default(null), + count: z.number().int().nonnegative().nullish().default(null), + segments: z.array(CloudSegmentWireSchema), + direction: z.enum(["forward", "backward"]), + tailIncluded: z.boolean(), + hasMore: z.boolean(), + nextCursor: CloudSessionEventWirePageCursorSchema.nullable(), + returnedWireBytes: z.number().int().nonnegative(), +}); + const CloudSessionEventsPageSchema = CloudSessionEventsSchema.extend({ nextAfterSeq: z.number().int().nonnegative(), hasMore: z.boolean(), @@ -247,6 +372,21 @@ export interface CloudSessionEventsSnapshot { segments: CloudSegmentWire[]; } +export interface CloudSessionEventWirePage extends Omit< + SessionEventWirePage, + "segments" +> { + segments: CloudSegmentWire[]; +} + +/** A server/client contract failure; callers must not retry via full fetch. */ +export class CloudSessionWirePageContractError extends Error { + constructor(message: string) { + super(message); + this.name = "CloudSessionWirePageContractError"; + } +} + export type CloudSessionEventsSummary = Omit< CloudSessionEventsSnapshot, "segments" @@ -293,13 +433,6 @@ async function shouldUseStorageSegments( return (await getCloudCapabilities(accessToken)).storageSegments; } -interface CloudStorageSegmentWire { - seq: number; - storagePath: string; - eventCount: number; - segmentHash: string; -} - /** Upload each frozen segment's raw gzip bytes, then describe it by path. */ async function uploadFrozenSegmentsToStorage( accessToken: string, @@ -308,7 +441,7 @@ async function uploadFrozenSegmentsToStorage( sessionId: string, epoch: number, segments: SessionEventsSegmentInput[] -): Promise { +): Promise { return mapSegmentsBounded(segments, async (segment) => { const encoded = await toFrozenSegmentStorage(segment); const storagePath = buildReplayObjectPath( @@ -426,6 +559,108 @@ export interface CloudRewriteSessionEventsInput { totalCount: number; } +export interface CloudRewriteSessionEventWiresInput { + orgId: string; + sessionId: string; + newEpoch: number; + frozenSegments: CloudFrozenSegmentWrite[]; + tail: Omit | null; + totalCount: number; +} + +export interface CloudStoredSegmentWire { + seq: number; + storagePath: string; + eventCount: number; + segmentHash: string; +} + +export type CloudFrozenSegmentWrite = + | SegmentWirePayload + | CloudStoredSegmentWire; + +export interface CloudUploadSessionEventWiresInput { + orgId: string; + sessionId: string; + epoch: number; + frozenSegments: SegmentWirePayload[]; + signal?: AbortSignal; +} + +function isInlineSegmentWire( + segment: CloudFrozenSegmentWrite +): segment is SegmentWirePayload { + return "payloadGz" in segment; +} + +/** + * Upload already-compressed Rust spool rows one batch at a time and retain + * only compact object references in the renderer. A full epoch rewrite can + * then publish all references in one atomic RPC without hydrating the + * transcript or exposing its first batch early. + */ +export async function uploadSessionEventWires( + accessToken: string, + input: CloudUploadSessionEventWiresInput +): Promise { + const endpoint = getCloudEndpoint(); + if (!(await shouldUseStorageSegments(accessToken, endpoint))) { + throw new Org2CloudSyncError( + "Atomic external replay rewrites require Cloud storage-segment support" + ); + } + const stored: CloudStoredSegmentWire[] = []; + for (const segment of input.frozenSegments) { + if (input.signal?.aborted) { + throw input.signal.reason ?? new DOMException("Aborted", "AbortError"); + } + assertCloudSegmentWireBudget(segment); + if (segment.seq === undefined) { + throw new Error("Frozen Cloud segment wire is missing its sequence"); + } + const storagePath = buildReplayObjectPath( + input.orgId, + input.sessionId, + input.epoch, + segment.seq, + segment.segmentHash + ); + await uploadReplayObject( + accessToken, + storagePath, + base64ToBytes(segment.payloadGz), + endpoint, + input.signal + ); + stored.push({ + seq: segment.seq, + storagePath, + eventCount: segment.eventCount, + segmentHash: segment.segmentHash, + }); + } + return stored; +} + +/** Owner: forward Rust-prepared bounded wires without decoding/re-encoding. */ +export async function rewriteSessionEventWires( + accessToken: string, + input: CloudRewriteSessionEventWiresInput +): Promise { + for (const segment of input.frozenSegments) { + if (isInlineSegmentWire(segment)) assertCloudSegmentWireBudget(segment); + } + if (input.tail) assertCloudSegmentWireBudget(input.tail); + await callSyncRpc("cloud_rewrite_session_events", accessToken, { + p_org_id: input.orgId, + p_session_id: input.sessionId, + new_epoch: input.newEpoch, + frozen_segments: input.frozenSegments, + tail: input.tail, + total_count: input.totalCount, + }); +} + /** Owner: epoch-bumped full rewrite of the session's segments. */ export async function rewriteSessionEvents( accessToken: string, @@ -492,6 +727,38 @@ export interface CloudAppendSessionEventsInput { totalCount: number; } +export interface CloudAppendSessionEventWiresInput { + orgId: string; + sessionId: string; + expectedEpoch: number; + expectedFrozenSeq: number; + expectedTailHash: string | null; + newFrozenSegments: SegmentWirePayload[]; + tail: Omit | null; + totalCount: number; +} + +/** Owner: forward Rust-prepared bounded wires without renderer hydration. */ +export async function appendSessionEventWires( + accessToken: string, + input: CloudAppendSessionEventWiresInput +): Promise { + for (const segment of input.newFrozenSegments) { + assertCloudSegmentWireBudget(segment); + } + if (input.tail) assertCloudSegmentWireBudget(input.tail); + await callSyncRpc("cloud_append_session_events", accessToken, { + p_org_id: input.orgId, + p_session_id: input.sessionId, + expected_epoch: input.expectedEpoch, + expected_frozen_seq: input.expectedFrozenSeq, + expected_tail_hash: input.expectedTailHash, + new_frozen_segments: input.newFrozenSegments, + tail: input.tail, + total_count: input.totalCount, + }); +} + /** Owner: incremental append (new frozen segments + tail replace). */ export async function appendSessionEvents( accessToken: string, @@ -650,43 +917,289 @@ export async function listOrgSessions( }; } -export interface GetSessionEventsOptions { +interface GetSessionEventsTransportOptions { /** * Link-share capability (0012): when set, a registered non-member can read * the shared session. The user JWT proves registration; this token grants * access to the one session. */ shareToken?: string; - /** Server-side incremental fetch (frozen past the cursor + tail always). */ - afterSeq?: number; /** Endpoint snapshot shared with a preceding share-token resolve. */ endpoint?: CloudEndpoint; /** Cancels the network read (dialog close / attempt supersession). */ signal?: AbortSignal; } +/** Legacy decoded-snapshot callers. New imports must use the bounded shape. */ +export interface GetSessionEventsOptions extends GetSessionEventsTransportOptions { + /** Server-side incremental fetch (frozen past the cursor + tail always). */ + afterSeq?: number; +} + /** - * Member: full segments snapshot for one shared session. Raises - * ORG2_RETENTION_EXPIRED when the session left the plan's window. Frozen - * segments are fetched through bounded server pages and reassembled in wire - * order; the tail is returned only on the final page. This prevents a large - * replay from forcing PostgreSQL to aggregate the entire history into one - * JSON value (and hitting the managed 15 s statement timeout). + * Raw bounded physical-row request. The discriminant prevents a caller from + * accidentally supplying limits while still receiving the permissive legacy + * response schema. + */ +export interface GetSessionEventWirePageOptions extends GetSessionEventsTransportOptions { + boundedWirePage: true; + cursor: SessionEventWirePageCursor; + includeTail: boolean; + maxSegments: number; + maxWireBytes: number; +} + +function assertSafeSequence(value: number, field: string): void { + if (!Number.isSafeInteger(value) || value < 0) { + throw new CloudSessionWirePageContractError( + `${field} must be a non-negative safe integer` + ); + } +} + +function assertWirePageRequest(options: GetSessionEventWirePageOptions): void { + if ( + !Number.isSafeInteger(options.maxSegments) || + options.maxSegments < 1 || + options.maxSegments > SESSION_EVENT_WIRE_MAX_PAGE_SEGMENTS + ) { + throw new CloudSessionWirePageContractError( + `maxSegments must be between 1 and ${SESSION_EVENT_WIRE_MAX_PAGE_SEGMENTS}` + ); + } + if ( + !Number.isSafeInteger(options.maxWireBytes) || + options.maxWireBytes < 1 || + options.maxWireBytes > SESSION_EVENT_WIRE_MAX_PAGE_BYTES + ) { + throw new CloudSessionWirePageContractError( + `maxWireBytes must be between 1 and ${SESSION_EVENT_WIRE_MAX_PAGE_BYTES}` + ); + } + if (options.cursor.direction === "forward") { + assertSafeSequence(options.cursor.afterSeq, "afterSeq"); + if (options.cursor.throughSeq !== undefined) { + assertSafeSequence(options.cursor.throughSeq, "throughSeq"); + if (options.cursor.throughSeq < options.cursor.afterSeq) { + throw new CloudSessionWirePageContractError( + "throughSeq must not precede afterSeq" + ); + } + } + } else if (options.cursor.beforeSeq !== undefined) { + assertSafeSequence(options.cursor.beforeSeq, "beforeSeq"); + if (options.cursor.beforeSeq === 0) { + throw new CloudSessionWirePageContractError( + "beforeSeq must be positive because seq 0 is the mutable tail" + ); + } + } +} + +function failWirePage(message: string): never { + throw new CloudSessionWirePageContractError(message); +} + +function validateWirePageResponse( + page: z.output, + options: GetSessionEventWirePageOptions +): CloudSessionEventWirePage { + if (page.direction !== options.cursor.direction) { + failWirePage( + `server returned ${page.direction} page for a ${options.cursor.direction} request` + ); + } + if (page.tailIncluded !== options.includeTail) { + failWirePage("server did not honor the requested tail inclusion state"); + } + if (page.segments.length > options.maxSegments) { + failWirePage( + `server returned ${page.segments.length} segments (requested at most ${options.maxSegments})` + ); + } + + const seenSeq = new Set(); + const frozenSeqs: number[] = []; + let tailSegment: (typeof page.segments)[number] | null = null; + let actualWireBytes = 0; + let legacyV1CandidateCount = 0; + for (const [index, segment] of page.segments.entries()) { + if (seenSeq.has(segment.seq)) { + failWirePage(`server returned duplicate physical seq ${segment.seq}`); + } + seenSeq.add(segment.seq); + const segmentBytes = cloudSegmentWireBytes(segment); + if (segmentBytes > SESSION_EVENT_WIRE_MAX_SEGMENT_BYTES) { + if (segmentBytes > SESSION_EVENT_WIRE_MAX_LEGACY_V1_SEGMENT_BYTES) { + failWirePage( + `server returned ${segmentBytes}-byte segment ${segment.seq} ` + + `(legacy V1 limit ${SESSION_EVENT_WIRE_MAX_LEGACY_V1_SEGMENT_BYTES})` + ); + } + legacyV1CandidateCount += 1; + if (legacyV1CandidateCount > 1) { + failWirePage( + "server returned more than one oversized legacy V1 candidate" + ); + } + } + actualWireBytes += segmentBytes; + if (segment.seq === 0) { + if (index !== page.segments.length - 1) { + failWirePage("server returned the mutable tail before frozen rows"); + } + tailSegment = segment; + } else { + const previousFrozenSeq = frozenSeqs.at(-1); + if (previousFrozenSeq !== undefined && segment.seq <= previousFrozenSeq) { + failWirePage("server returned frozen rows out of physical-seq order"); + } + frozenSeqs.push(segment.seq); + } + } + const compatibilityPageLimit = + legacyV1CandidateCount === 0 + ? Math.min(options.maxWireBytes, SESSION_EVENT_WIRE_MAX_PAGE_BYTES) + : SESSION_EVENT_WIRE_MAX_PAGE_BYTES + + SESSION_EVENT_WIRE_MAX_LEGACY_V1_SEGMENT_BYTES; + if (actualWireBytes > compatibilityPageLimit) { + failWirePage( + `server returned ${actualWireBytes} wire bytes (limit ${compatibilityPageLimit})` + ); + } + if (page.returnedWireBytes !== actualWireBytes) { + failWirePage( + `server reported ${page.returnedWireBytes} wire bytes but returned ${actualWireBytes}` + ); + } + if (tailSegment && !page.tailIncluded) { + failWirePage("server returned a mutable tail on a tail-free page"); + } + if (page.tailIncluded && page.tailHash !== null && !tailSegment) { + failWirePage("server omitted the requested non-empty mutable tail"); + } + if (tailSegment && tailSegment.segmentHash !== page.tailHash) { + failWirePage("server tail row does not match the snapshot tailHash"); + } + if (page.hasMore !== (page.nextCursor !== null)) { + failWirePage("hasMore and nextCursor disagree"); + } + if (page.nextCursor && page.nextCursor.direction !== page.direction) { + failWirePage("nextCursor changes pagination direction"); + } + + const firstFrozenSeq = frozenSeqs[0]; + const lastFrozenSeq = frozenSeqs.at(-1); + if (options.cursor.direction === "forward") { + const requestedThrough = options.cursor.throughSeq; + const effectiveThrough = requestedThrough ?? page.frozenSeq; + for (const seq of frozenSeqs) { + if ( + seq <= options.cursor.afterSeq || + (effectiveThrough !== null && seq > effectiveThrough) + ) { + failWirePage(`forward page returned out-of-range physical seq ${seq}`); + } + } + if (page.nextCursor) { + if (page.nextCursor.direction !== "forward") { + failWirePage("forward page returned a backward continuation"); + } + if ( + lastFrozenSeq === undefined || + page.nextCursor.afterSeq !== lastFrozenSeq || + page.nextCursor.afterSeq <= options.cursor.afterSeq + ) { + failWirePage("forward nextCursor does not advance to the last row"); + } + if ( + page.nextCursor.throughSeq === undefined || + effectiveThrough === null || + page.nextCursor.throughSeq !== effectiveThrough + ) { + failWirePage( + "forward nextCursor did not preserve the snapshot high-water mark" + ); + } + } + } else { + const exclusiveUpper = + options.cursor.beforeSeq ?? + (page.frozenSeq === null ? null : page.frozenSeq + 1); + for (const seq of frozenSeqs) { + if (exclusiveUpper !== null && seq >= exclusiveUpper) { + failWirePage(`backward page returned out-of-range physical seq ${seq}`); + } + } + if (page.nextCursor) { + if (page.nextCursor.direction !== "backward") { + failWirePage("backward page returned a forward continuation"); + } + if ( + firstFrozenSeq === undefined || + page.nextCursor.beforeSeq !== firstFrozenSeq + ) { + failWirePage( + "backward nextCursor does not continue before the first row" + ); + } + } + } + + return { + epoch: page.epoch, + frozenSeq: page.frozenSeq, + tailHash: page.tailHash, + count: page.count, + segments: page.segments, + tailIncluded: page.tailIncluded, + hasMore: page.hasMore, + nextCursor: page.nextCursor, + returnedWireBytes: page.returnedWireBytes, + }; +} + +/** + * Read physical segment wires for one shared session. Legacy callers may + * still request the paged full-snapshot-compatible response. New imports + * pass `boundedWirePage: true`; that overload requires server pagination + * metadata and enforces the requested row/byte budgets before returning raw + * wires to the Rust ingester. * * With `options.shareToken` this becomes a registered-link read that does not * require org membership (opaque ORG2_UNAUTHORIZED on every capability - * failure). - * - * A backend that predates the paged RPC receives one compatibility attempt - * through `cloud_get_session_events`. Official cloud is upgraded in lockstep; - * the fallback keeps existing small-session self-hosted deployments usable. + * failure). The bounded contract requires a deployed + * `cloud_get_session_events` that accepts direction/after/before/through and + * max-segment/max-byte parameters; an older response fails closed. */ -export async function getSessionEvents( +export function getSessionEvents( + accessToken: string, + orgId: string, + sessionId: string, + options: GetSessionEventWirePageOptions +): Promise; +export function getSessionEvents( accessToken: string, orgId: string, sessionId: string, options?: GetSessionEventsOptions -): Promise { +): Promise; +export async function getSessionEvents( + accessToken: string, + orgId: string, + sessionId: string, + options?: GetSessionEventsOptions | GetSessionEventWirePageOptions +): Promise { + const boundedOptions = + options && "boundedWirePage" in options ? options : null; + if (boundedOptions) { + return getSessionEventWirePage( + accessToken, + orgId, + sessionId, + boundedOptions + ); + } const segments: CloudSegmentWire[] = []; const summary = await streamSessionEvents( accessToken, @@ -695,11 +1208,57 @@ export async function getSessionEvents( async (page) => { segments.push(...page.segments); }, - options + options ?? undefined ); return { ...summary, segments }; } +async function getSessionEventWirePage( + accessToken: string, + orgId: string, + sessionId: string, + options: GetSessionEventWirePageOptions +): Promise { + assertWirePageRequest(options); + const payload = await callSyncRpc( + "cloud_get_session_events", + accessToken, + { + p_org_id: orgId, + p_session_id: sessionId, + ...(options.shareToken !== undefined + ? { p_share_token: options.shareToken } + : {}), + p_direction: options.cursor.direction, + ...(options.cursor.direction === "forward" + ? { + p_after_seq: options.cursor.afterSeq, + ...(options.cursor.throughSeq !== undefined + ? { p_through_seq: options.cursor.throughSeq } + : {}), + } + : options.cursor.beforeSeq !== undefined + ? { p_before_seq: options.cursor.beforeSeq } + : {}), + p_include_tail: options.includeTail, + p_max_segments: options.maxSegments, + p_max_wire_bytes: options.maxWireBytes, + }, + options.endpoint, + options.signal, + undefined, + Math.max( + options.maxWireBytes, + SESSION_EVENT_WIRE_MAX_PAGE_BYTES + + SESSION_EVENT_WIRE_MAX_LEGACY_V1_SEGMENT_BYTES + ) + CLOUD_WIRE_PAGE_RESPONSE_OVERHEAD_BYTES + ); + return validateWirePageResponse( + CloudSessionEventWirePageSchema.parse(payload), + options + ); +} + /** * Bounded-memory variant used by large replay imports. A page is released as * soon as `onPage` resolves; callers must not retain it when they need a diff --git a/src/features/Org2Cloud/org2CloudSyncEngine.externalReplay.test.ts b/src/features/Org2Cloud/org2CloudSyncEngine.externalReplay.test.ts new file mode 100644 index 000000000..7a6bb7f69 --- /dev/null +++ b/src/features/Org2Cloud/org2CloudSyncEngine.externalReplay.test.ts @@ -0,0 +1,534 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + SCOPE_KEY, + SESSION, + cleanupEngineFixture, + conflictError, + createEngineFixture, + engineTestDeps, + eventStoreMock, + externalReplayCloudPrefixHashMock, + externalReplayCloudPrepareMock, + externalReplayCloudReadBatchMock, + externalReplayCloudReleaseMock, + makeEvent, +} from "./org2CloudSyncEngine.testUtils"; +import type { EngineFixture } from "./org2CloudSyncEngine.testUtils"; + +const { + EXTERNAL_HISTORY_ACTIVITY_DEBOUNCE_MS, + cloudOrgToken, + org2CloudAccessSettingsAtom, + org2CloudOrgsAtom, + org2CloudPushCursorsAtom, + org2CloudRepoScopesAtom, + org2CloudSharingFloorAtom, + sessionOrgTagsAtom, + sessionsAtom, +} = engineTestDeps; + +describe("Org2CloudSyncEngine bounded external replay publishing", () => { + let fixture: EngineFixture; + let store: EngineFixture["store"]; + let client: EngineFixture["client"]; + let engine: EngineFixture["engine"]; + + beforeEach(() => { + fixture = createEngineFixture(); + ({ store, client, engine } = fixture); + }); + + afterEach(() => { + cleanupEngineFixture(engine); + }); + + it("uploads one bounded external spool only to the active org", async () => { + const events = [makeEvent("x1"), makeEvent("x2"), makeEvent("x3")]; + externalReplayCloudPrepareMock.mockResolvedValueOnce({ + token: "shared-external-spool", + generation: "g1", + totalCount: 3, + frozenEventCount: 3, + tailEventCount: 0, + frozenChainHash: "chain-3", + tailHash: null, + }); + externalReplayCloudReadBatchMock.mockImplementation( + async ({ startEventIndex, endEventIndex }) => ({ + segments: + startEventIndex < endEventIndex + ? [ + { + payloadGz: `wire-${events[startEventIndex]?.id}`, + eventCount: 1, + segmentHash: `hash-${events[startEventIndex]?.id}`, + wireBytes: 100, + }, + ] + : [], + startEventIndex, + nextEventIndex: Math.min(startEventIndex + 1, endEventIndex), + startSegmentIndex: startEventIndex, + nextSegmentIndex: startEventIndex + 1, + eof: startEventIndex + 1 >= endEventIndex, + serializedBytes: 100, + }) + ); + store.set(org2CloudOrgsAtom, [ + { orgId: "corg-1", name: "Cloud Team", role: "member" }, + { orgId: "corg-2", name: "Other Team", role: "member" }, + ]); + store.set(org2CloudRepoScopesAtom, { + "corg-1": [SCOPE_KEY], + "corg-2": [SCOPE_KEY], + }); + store.set(org2CloudAccessSettingsAtom, { + "corg-1": { sessionModes: {}, sessionVisibility: {} }, + "corg-2": { sessionModes: {}, sessionVisibility: {} }, + }); + store.set(org2CloudSharingFloorAtom, { + "corg-1": "full_replay", + "corg-2": "full_replay", + }); + store.set(sessionOrgTagsAtom, { + "cursoride-thread-1": [cloudOrgToken("corg-1"), cloudOrgToken("corg-2")], + }); + store.set(sessionsAtom, [ + { ...SESSION, session_id: "cursoride-thread-1", orgId: "personal-org" }, + ]); + + await engine.runSyncPass(); + + expect(externalReplayCloudPrepareMock).not.toHaveBeenCalled(); + + vi.setSystemTime(Date.now() + EXTERNAL_HISTORY_ACTIVITY_DEBOUNCE_MS + 1); + await engine.runSyncPass(); + + expect(externalReplayCloudPrepareMock).toHaveBeenCalledTimes(1); + expect(externalReplayCloudReadBatchMock).toHaveBeenCalledTimes(3); + expect(client.uploadSessionEventWires).toHaveBeenCalledTimes(3); + expect(client.rewriteSessionEventWires).toHaveBeenCalledTimes(1); + expect(client.appendSessionEventWires).not.toHaveBeenCalled(); + expect(eventStoreMock.getPersistedEvents).not.toHaveBeenCalledWith( + "cursoride-thread-1" + ); + expect(client.rewriteSessionEventWires.mock.calls[0]?.[1].orgId).toBe( + "corg-1" + ); + expect( + client.uploadSessionEventWires.mock.calls.every( + ([, input]) => input.orgId === "corg-1" + ) + ).toBe(true); + expect( + client.rewriteSessionEventWires.mock.calls[0]?.[1].frozenSegments + ).toHaveLength(3); + }); + + it("keeps nine active-org external session spools independently bounded", async () => { + const sessionIds = Array.from( + { length: 9 }, + (_, index) => `cursoride-thread-${index + 1}` + ); + externalReplayCloudPrepareMock.mockImplementation(async (sessionId) => ({ + token: `nine-spool-${sessionId}`, + generation: `generation-${sessionId}`, + totalCount: 1, + frozenEventCount: 1, + tailEventCount: 0, + frozenChainHash: `chain-${sessionId}`, + tailHash: null, + })); + externalReplayCloudReadBatchMock.mockImplementation( + async ({ token, startEventIndex, endEventIndex }) => ({ + segments: [ + { + payloadGz: `wire-${token}`, + eventCount: 1, + segmentHash: `hash-${token}`, + wireBytes: 100, + }, + ], + startEventIndex, + nextEventIndex: endEventIndex, + startSegmentIndex: 0, + nextSegmentIndex: 1, + eof: true, + serializedBytes: 100, + }) + ); + store.set(org2CloudOrgsAtom, [ + { orgId: "corg-1", name: "Cloud Team", role: "member" }, + { orgId: "corg-2", name: "Other Team", role: "member" }, + ]); + store.set(org2CloudRepoScopesAtom, { + "corg-1": [SCOPE_KEY], + "corg-2": [SCOPE_KEY], + }); + store.set(org2CloudAccessSettingsAtom, { + "corg-1": { sessionModes: {}, sessionVisibility: {} }, + "corg-2": { sessionModes: {}, sessionVisibility: {} }, + }); + store.set(org2CloudSharingFloorAtom, { + "corg-1": "full_replay", + "corg-2": "full_replay", + }); + store.set( + sessionOrgTagsAtom, + Object.fromEntries( + sessionIds.map((sessionId) => [ + sessionId, + [cloudOrgToken("corg-1"), cloudOrgToken("corg-2")], + ]) + ) + ); + store.set( + sessionsAtom, + sessionIds.map((sessionId) => ({ + ...SESSION, + session_id: sessionId, + orgId: "personal-org", + })) + ); + + await engine.runSyncPass(); + + expect(externalReplayCloudPrepareMock).not.toHaveBeenCalled(); + + vi.setSystemTime(Date.now() + EXTERNAL_HISTORY_ACTIVITY_DEBOUNCE_MS + 1); + await engine.runSyncPass(); + + expect(externalReplayCloudPrepareMock).toHaveBeenCalledTimes(9); + expect(externalReplayCloudReadBatchMock).toHaveBeenCalledTimes(9); + expect(client.uploadSessionEventWires).toHaveBeenCalledTimes(9); + expect(client.rewriteSessionEventWires).toHaveBeenCalledTimes(9); + expect( + client.rewriteSessionEventWires.mock.calls.every( + ([, input]) => input.orgId === "corg-1" + ) + ).toBe(true); + for (const sessionId of sessionIds) { + const token = `nine-spool-${sessionId}`; + expect( + externalReplayCloudReadBatchMock.mock.calls.filter( + ([options]) => options.token === token + ) + ).toHaveLength(1); + } + + engine.stop(); + await Promise.resolve(); + await Promise.resolve(); + for (const sessionId of sessionIds) { + expect( + externalReplayCloudReleaseMock.mock.calls.filter( + ([token]) => token === `nine-spool-${sessionId}` + ) + ).toHaveLength(1); + } + }); + + it("publishes a tail-only external spool without treating the tail as frozen", async () => { + externalReplayCloudPrepareMock.mockResolvedValueOnce({ + token: "tail-only-external-spool", + generation: "g1", + totalCount: 1, + frozenEventCount: 0, + tailEventCount: 1, + frozenChainHash: + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + tailHash: "tail-hash", + }); + externalReplayCloudReadBatchMock.mockResolvedValueOnce({ + segments: [ + { + payloadGz: "tail-wire", + eventCount: 1, + segmentHash: "tail-hash", + wireBytes: 100, + }, + ], + startEventIndex: 0, + nextEventIndex: 1, + startSegmentIndex: 0, + nextSegmentIndex: 1, + eof: true, + serializedBytes: 100, + }); + store.set(sessionsAtom, [ + { ...SESSION, session_id: "cursoride-thread-1", orgId: "personal-org" }, + ]); + + await engine.runSyncPass(); + vi.setSystemTime(Date.now() + EXTERNAL_HISTORY_ACTIVITY_DEBOUNCE_MS + 1); + await engine.runSyncPass(); + + const rewrite = client.rewriteSessionEventWires.mock.calls[0]?.[1]; + expect(rewrite?.frozenSegments).toEqual([]); + expect(rewrite?.tail).toMatchObject({ + payloadGz: "tail-wire", + eventCount: 1, + segmentHash: "tail-hash", + }); + expect(rewrite?.totalCount).toBe(1); + }); + + it("re-anchors an external staged rewrite after a bounded append conflict", async () => { + const events = [makeEvent("x1"), makeEvent("x2")]; + externalReplayCloudPrepareMock.mockResolvedValueOnce({ + token: "conflict-external-spool", + generation: "g1", + totalCount: 2, + frozenEventCount: 2, + tailEventCount: 0, + frozenChainHash: "chain-2", + tailHash: null, + }); + externalReplayCloudReadBatchMock.mockImplementation( + async ({ startEventIndex, endEventIndex }) => ({ + segments: + startEventIndex < endEventIndex + ? [ + { + payloadGz: `wire-${events[startEventIndex]?.id}`, + eventCount: 1, + segmentHash: `hash-${events[startEventIndex]?.id}`, + wireBytes: 100, + }, + ] + : [], + startEventIndex, + nextEventIndex: Math.min(startEventIndex + 1, endEventIndex), + startSegmentIndex: startEventIndex, + nextSegmentIndex: startEventIndex + 1, + eof: startEventIndex + 1 >= endEventIndex, + serializedBytes: 100, + }) + ); + client.rewriteSessionEventWires.mockRejectedValueOnce(conflictError()); + client.getSessionEvents.mockResolvedValueOnce({ + epoch: 7, + frozenSeq: 0, + tailHash: null, + count: 0, + segments: [], + }); + store.set(sessionsAtom, [ + { ...SESSION, session_id: "cursoride-thread-1", orgId: "personal-org" }, + ]); + + await engine.runSyncPass(); + vi.setSystemTime(Date.now() + EXTERNAL_HISTORY_ACTIVITY_DEBOUNCE_MS + 1); + await engine.runSyncPass(); + + expect(client.getSessionEvents).toHaveBeenCalledWith( + "jwt-1", + "corg-1", + "cursoride-thread-1", + expect.objectContaining({ + boundedWirePage: true, + cursor: { direction: "backward" }, + includeTail: false, + maxSegments: 1, + maxWireBytes: 64 * 1024, + signal: expect.any(AbortSignal), + }) + ); + expect(client.rewriteSessionEventWires).toHaveBeenCalledTimes(2); + expect(client.rewriteSessionEventWires.mock.calls[1]?.[1].newEpoch).toBe(8); + expect( + store.get(org2CloudPushCursorsAtom)["corg-1:cursoride-thread-1"] + ).toMatchObject({ epoch: 8, frozenEventCount: 2, pushedCount: 2 }); + }); + + it("rewrites a new epoch when an external source shrinks", async () => { + store.set(org2CloudPushCursorsAtom, { + "corg-1:cursoride-thread-1": { + orgId: "corg-1", + sessionId: "cursoride-thread-1", + epoch: 4, + frozenSeq: 3, + pushedCount: 3, + frozenEventCount: 3, + frozenChainHash: "old-chain", + tailHash: null, + }, + }); + externalReplayCloudPrepareMock.mockResolvedValueOnce({ + token: "shrunk-external-spool", + generation: "g2", + totalCount: 2, + frozenEventCount: 2, + tailEventCount: 0, + frozenChainHash: "new-chain", + tailHash: null, + }); + externalReplayCloudReadBatchMock.mockResolvedValueOnce({ + segments: [ + { + payloadGz: "wire-new-1", + eventCount: 1, + segmentHash: "hash-new-1", + wireBytes: 100, + }, + { + payloadGz: "wire-new-2", + eventCount: 1, + segmentHash: "hash-new-2", + wireBytes: 100, + }, + ], + startEventIndex: 0, + nextEventIndex: 2, + startSegmentIndex: 0, + nextSegmentIndex: 2, + eof: true, + serializedBytes: 200, + }); + store.set(sessionsAtom, [ + { ...SESSION, session_id: "cursoride-thread-1", orgId: "personal-org" }, + ]); + + await engine.runSyncPass(); + vi.setSystemTime(Date.now() + EXTERNAL_HISTORY_ACTIVITY_DEBOUNCE_MS + 1); + await engine.runSyncPass(); + + expect(externalReplayCloudPrefixHashMock).not.toHaveBeenCalled(); + expect(client.appendSessionEventWires).not.toHaveBeenCalled(); + expect(client.rewriteSessionEventWires).toHaveBeenCalledTimes(1); + expect(client.rewriteSessionEventWires.mock.calls[0]?.[1]).toMatchObject({ + newEpoch: 5, + totalCount: 2, + frozenSegments: [ + { seq: 1, eventCount: 1 }, + { seq: 2, eventCount: 1 }, + ], + }); + expect( + store.get(org2CloudPushCursorsAtom)["corg-1:cursoride-thread-1"] + ).toMatchObject({ + epoch: 5, + frozenSeq: 2, + pushedCount: 2, + frozenEventCount: 2, + frozenChainHash: "new-chain", + }); + }); + + it("rewrites an empty epoch when an external source is cleared", async () => { + store.set(org2CloudPushCursorsAtom, { + "corg-1:cursoride-thread-1": { + orgId: "corg-1", + sessionId: "cursoride-thread-1", + epoch: 4, + frozenSeq: 3, + pushedCount: 3, + frozenEventCount: 3, + frozenChainHash: "old-chain", + tailHash: null, + }, + }); + externalReplayCloudPrepareMock.mockResolvedValueOnce({ + token: "cleared-external-spool", + generation: "g2", + totalCount: 0, + frozenEventCount: 0, + tailEventCount: 0, + frozenChainHash: + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + tailHash: null, + }); + store.set(sessionsAtom, [ + { ...SESSION, session_id: "cursoride-thread-1", orgId: "personal-org" }, + ]); + + await engine.runSyncPass(); + vi.setSystemTime(Date.now() + EXTERNAL_HISTORY_ACTIVITY_DEBOUNCE_MS + 1); + await engine.runSyncPass(); + + expect(externalReplayCloudPrefixHashMock).not.toHaveBeenCalled(); + expect(externalReplayCloudReadBatchMock).not.toHaveBeenCalled(); + expect(client.appendSessionEventWires).not.toHaveBeenCalled(); + expect(client.rewriteSessionEventWires).toHaveBeenCalledWith("jwt-1", { + orgId: "corg-1", + sessionId: "cursoride-thread-1", + newEpoch: 5, + frozenSegments: [], + tail: null, + totalCount: 0, + }); + expect( + store.get(org2CloudPushCursorsAtom)["corg-1:cursoride-thread-1"] + ).toMatchObject({ + epoch: 5, + frozenSeq: 0, + pushedCount: 0, + frozenEventCount: 0, + tailHash: null, + }); + }); + + it("stops an external multi-batch rewrite before publishing a partial epoch", async () => { + externalReplayCloudPrepareMock.mockResolvedValueOnce({ + token: "abort-external-spool", + generation: "g1", + totalCount: 3, + frozenEventCount: 3, + tailEventCount: 0, + frozenChainHash: "chain-3", + tailHash: null, + }); + externalReplayCloudReadBatchMock.mockImplementation( + async ({ startEventIndex, endEventIndex }) => ({ + segments: [ + { + payloadGz: `wire-${startEventIndex}`, + eventCount: 1, + segmentHash: `hash-${startEventIndex}`, + wireBytes: 100, + }, + ], + startEventIndex, + nextEventIndex: Math.min(startEventIndex + 1, endEventIndex), + startSegmentIndex: startEventIndex, + nextSegmentIndex: startEventIndex + 1, + eof: startEventIndex + 1 >= endEventIndex, + serializedBytes: 100, + }) + ); + client.uploadSessionEventWires.mockImplementationOnce(async () => { + engine.stop(); + return [ + { + seq: 1, + storagePath: "corg-1/cursoride-thread-1/1/1-hash-0.gz", + eventCount: 1, + segmentHash: "hash-0", + }, + ]; + }); + store.set(sessionsAtom, [ + { ...SESSION, session_id: "cursoride-thread-1", orgId: "personal-org" }, + ]); + + await engine.runSyncPass(); + vi.setSystemTime(Date.now() + EXTERNAL_HISTORY_ACTIVITY_DEBOUNCE_MS + 1); + await engine.runSyncPass(); + await Promise.resolve(); + await Promise.resolve(); + + expect(client.uploadSessionEventWires).toHaveBeenCalledTimes(1); + expect(client.rewriteSessionEventWires).not.toHaveBeenCalled(); + expect(client.appendSessionEventWires).not.toHaveBeenCalled(); + expect(externalReplayCloudReadBatchMock).toHaveBeenCalledTimes(1); + expect(externalReplayCloudReleaseMock).toHaveBeenCalledWith( + "abort-external-spool" + ); + expect( + store.get(org2CloudPushCursorsAtom)["corg-1:cursoride-thread-1"] + ).toBeUndefined(); + }); + + // --- deleteSession resurrection-hash fix ---------------------------------- +}); diff --git a/src/features/Org2Cloud/org2CloudSyncEngine.sessions.test.ts b/src/features/Org2Cloud/org2CloudSyncEngine.sessions.test.ts index 72532f3f1..4ed1b2ae5 100644 --- a/src/features/Org2Cloud/org2CloudSyncEngine.sessions.test.ts +++ b/src/features/Org2Cloud/org2CloudSyncEngine.sessions.test.ts @@ -12,12 +12,15 @@ import { createEngineFixture, engineTestDeps, eventStoreMock, + externalReplayCloudPrefixHashMock, + externalReplayCloudPrepareMock, + externalReplayCloudReadBatchMock, makeEvent, messageMock, notifySessionEvents, peekMock, primeMock, - processChunksRustMock, + resolveSecondaryReplayTargetMock, } from "./org2CloudSyncEngine.testUtils"; import type { EngineFixture } from "./org2CloudSyncEngine.testUtils"; @@ -31,7 +34,6 @@ const { Org2CloudSyncError, chatPanelSelectedCloudOrgAtom, cloudOrgToken, - getImportedHistorySourceBySessionId, org2CloudAccessSettingsAtom, org2CloudOrgsAtom, org2CloudPushCursorsAtom, @@ -61,31 +63,25 @@ describe("Org2CloudSyncEngine session publishing", () => { cleanupEngineFixture(engine); }); - it("publishes Cursor from the full source transcript, never its preview window or event cache", async () => { - const source = getImportedHistorySourceBySessionId("cursoride-thread-1"); - expect(source).toBeDefined(); - const fullChunks = [{ id: "full-cursor-chunk" }] as never; - const fullLoader = vi - .spyOn(source!, "loadFullTranscriptChunks") - .mockResolvedValue(fullChunks); - const previewLoader = vi.spyOn(source!, "loadPreviewChunks"); - const converted = [makeEvent("cursor-event")]; - processChunksRustMock.mockResolvedValueOnce(converted); - - const events = await ( - engine as unknown as { - loadPushEvents(sessionId: string): Promise; - } - ).loadPushEvents("cursoride-thread-1"); + it("publishes Cursor through the Rust spool without any legacy full-history loader", async () => { + store.set(org2CloudAccessSettingsAtom, {}); + store.set(org2CloudSharingFloorAtom, { "corg-1": "full_replay" }); + store.set(sessionsAtom, [ + { ...SESSION, session_id: "cursoride-thread-1", orgId: "personal-org" }, + ]); - expect(events).toEqual(converted); - expect(fullLoader).toHaveBeenCalledWith("cursoride-thread-1"); - expect(previewLoader).not.toHaveBeenCalled(); - expect(eventStoreMock.getPersistedEvents).not.toHaveBeenCalledWith( + await engine.runSyncPass(); + + expect(externalReplayCloudPrepareMock).not.toHaveBeenCalled(); + + vi.setSystemTime(Date.now() + EXTERNAL_HISTORY_ACTIVITY_DEBOUNCE_MS + 1); + await engine.runSyncPass(); + + expect(externalReplayCloudPrepareMock).toHaveBeenCalledTimes(1); + expect(externalReplayCloudPrepareMock).toHaveBeenCalledWith( "cursoride-thread-1" ); - expect(processChunksRustMock).toHaveBeenCalledWith( - fullChunks, + expect(eventStoreMock.getPersistedEvents).not.toHaveBeenCalledWith( "cursoride-thread-1" ); }); @@ -127,6 +123,8 @@ describe("Org2CloudSyncEngine session publishing", () => { expect(rewrite.frozenSegments).toHaveLength(1); expect(rewrite.frozenSegments[0].events).toHaveLength(1); expect(rewrite.tail).toHaveLength(1); + expect(resolveSecondaryReplayTargetMock).not.toHaveBeenCalled(); + expect(eventStoreMock.getPersistedEvents).toHaveBeenCalledWith("session-1"); const cursor = store.get(org2CloudPushCursorsAtom)["corg-1:session-1"]; expect(cursor).toMatchObject({ @@ -223,6 +221,38 @@ describe("Org2CloudSyncEngine session publishing", () => { ); }); + it("publishes a snapshot-backed native fork through the Rust spool without hydrating EventStore", async () => { + const fork = { + ...SESSION, + session_id: "agentsession-cloud-fork", + forkedFrom: { + orgId: "corg-1", + sourceSessionId: "session-source", + ownerMemberId: "user-owner", + ownerDisplayName: "Owner", + atCount: 2, + forkedAt: "2026-07-02T00:00:00.000Z", + }, + }; + resolveSecondaryReplayTargetMock.mockResolvedValueOnce({ + sourceId: "collaboration_snapshot", + sessionId: fork.session_id, + }); + store.set(sessionsAtom, [fork]); + + await engine.runSyncPass(); + + expect(resolveSecondaryReplayTargetMock).toHaveBeenCalledWith( + fork.session_id + ); + expect(externalReplayCloudPrepareMock).toHaveBeenCalledWith( + fork.session_id + ); + expect(eventStoreMock.getPersistedEvents).not.toHaveBeenCalledWith( + fork.session_id + ); + }); + it("allows an explicit tag to move a guest fork into a member org", async () => { const fork: Session = { ...SESSION, @@ -307,7 +337,7 @@ describe("Org2CloudSyncEngine session publishing", () => { expect(client.rewriteSessionEvents).toHaveBeenCalledTimes(1); }); - it("publishes a large rewrite in bounded resumable segment batches", async () => { + it("publishes a large rewrite behind one atomic epoch switch", async () => { const oversizedPayload = "x".repeat(260 * 1024); const events = Array.from( { length: SESSION_SEGMENT_UPLOAD_BATCH_SIZE + 1 }, @@ -323,17 +353,10 @@ describe("Org2CloudSyncEngine session publishing", () => { expect(client.rewriteSessionEvents).toHaveBeenCalledTimes(1); const [, rewrite] = client.rewriteSessionEvents.mock.calls[0]; - expect(rewrite.frozenSegments).toHaveLength( - SESSION_SEGMENT_UPLOAD_BATCH_SIZE - ); + expect(rewrite.frozenSegments).toHaveLength(events.length); expect(rewrite.tail).toBeNull(); - expect(rewrite.totalCount).toBe(SESSION_SEGMENT_UPLOAD_BATCH_SIZE); - - expect(client.appendSessionEvents).toHaveBeenCalledTimes(1); - const [, append] = client.appendSessionEvents.mock.calls[0]; - expect(append.expectedFrozenSeq).toBe(SESSION_SEGMENT_UPLOAD_BATCH_SIZE); - expect(append.newFrozenSegments).toHaveLength(1); - expect(append.totalCount).toBe(events.length); + expect(rewrite.totalCount).toBe(events.length); + expect(client.appendSessionEvents).not.toHaveBeenCalled(); expect( store.get(org2CloudPushCursorsAtom)["corg-1:session-1"] @@ -345,7 +368,7 @@ describe("Org2CloudSyncEngine session publishing", () => { }); }); - it("backs off a failed large upload and resumes from its committed batch", async () => { + it("backs off a failed atomic rewrite without publishing a partial cursor", async () => { const oversizedPayload = "y".repeat(260 * 1024); const events = Array.from( { length: SESSION_SEGMENT_UPLOAD_BATCH_SIZE + 2 }, @@ -356,7 +379,7 @@ describe("Org2CloudSyncEngine session publishing", () => { }) as unknown as SessionEvent ); eventStoreMock.getPersistedEvents.mockResolvedValue(events); - client.appendSessionEvents.mockRejectedValueOnce( + client.rewriteSessionEvents.mockRejectedValueOnce( new Org2CloudSyncError( "canceling statement due to statement timeout", 500 @@ -366,20 +389,15 @@ describe("Org2CloudSyncEngine session publishing", () => { await engine.runSyncPass(); expect(client.rewriteSessionEvents).toHaveBeenCalledTimes(1); - expect(client.appendSessionEvents).toHaveBeenCalledTimes(1); + expect(client.appendSessionEvents).not.toHaveBeenCalled(); expect( store.get(org2CloudPushCursorsAtom)["corg-1:session-1"] - ).toMatchObject({ - frozenSeq: SESSION_SEGMENT_UPLOAD_BATCH_SIZE, - pushedCount: SESSION_SEGMENT_UPLOAD_BATCH_SIZE, - frozenEventCount: SESSION_SEGMENT_UPLOAD_BATCH_SIZE, - tailHash: null, - }); + ).toBeUndefined(); expect(eventStoreMock.getPersistedEvents).toHaveBeenCalledTimes(1); await engine.runSyncPass(); expect(client.rewriteSessionEvents).toHaveBeenCalledTimes(1); - expect(client.appendSessionEvents).toHaveBeenCalledTimes(1); + expect(client.appendSessionEvents).not.toHaveBeenCalled(); // The retry gate runs before loadPushEvents, so the large transcript is // not reparsed or rehashed during the cooldown. expect(eventStoreMock.getPersistedEvents).toHaveBeenCalledTimes(1); @@ -387,8 +405,8 @@ describe("Org2CloudSyncEngine session publishing", () => { vi.setSystemTime(Date.now() + SESSION_PUSH_RETRY_BASE_MS + 1); await engine.runSyncPass(); - expect(client.rewriteSessionEvents).toHaveBeenCalledTimes(1); - expect(client.appendSessionEvents).toHaveBeenCalledTimes(2); + expect(client.rewriteSessionEvents).toHaveBeenCalledTimes(2); + expect(client.appendSessionEvents).not.toHaveBeenCalled(); expect( store.get(org2CloudPushCursorsAtom)["corg-1:session-1"] ).toMatchObject({ @@ -467,7 +485,13 @@ describe("Org2CloudSyncEngine session publishing", () => { "jwt-1", "corg-1", "session-1", - { afterSeq: 2_147_483_647 } + { + boundedWirePage: true, + cursor: { direction: "backward" }, + includeTail: false, + maxSegments: 1, + maxWireBytes: 64 * 1024, + } ); expect(client.rewriteSessionEvents).toHaveBeenCalledTimes(2); const [, reanchor] = client.rewriteSessionEvents.mock.calls[1]; @@ -746,10 +770,6 @@ describe("Org2CloudSyncEngine session publishing", () => { }); it("applies the admin floor to scope-matched imported history", async () => { - const source = getImportedHistorySourceBySessionId("cursoride-thread-1"); - const loadFullTranscriptChunks = vi - .spyOn(source!, "loadFullTranscriptChunks") - .mockResolvedValue([] as never); store.set(org2CloudAccessSettingsAtom, {}); store.set(org2CloudSharingFloorAtom, { "corg-1": "full_replay" }); store.set(sessionsAtom, [ @@ -759,7 +779,7 @@ describe("Org2CloudSyncEngine session publishing", () => { await engine.runSyncPass(); expect(client.upsertSessionMetadata).not.toHaveBeenCalled(); - expect(loadFullTranscriptChunks).not.toHaveBeenCalled(); + expect(externalReplayCloudPrepareMock).not.toHaveBeenCalled(); vi.setSystemTime(Date.now() + EXTERNAL_HISTORY_ACTIVITY_DEBOUNCE_MS + 1); await engine.runSyncPass(); @@ -768,14 +788,12 @@ describe("Org2CloudSyncEngine session publishing", () => { expect(client.upsertSessionMetadata.mock.calls[0][3].accessMode).toBe( "full_replay" ); - expect(loadFullTranscriptChunks).toHaveBeenCalledWith("cursoride-thread-1"); + expect(externalReplayCloudPrepareMock).toHaveBeenCalledWith( + "cursoride-thread-1" + ); }); - it("waits for a quiet window before normalizing a changing external replay", async () => { - const source = getImportedHistorySourceBySessionId("cursoride-thread-1"); - const loadFullTranscriptChunks = vi - .spyOn(source!, "loadFullTranscriptChunks") - .mockResolvedValue([] as never); + it("waits for a quiet window before preparing a changing external replay", async () => { store.set(sessionsAtom, [ { ...SESSION, session_id: "cursoride-thread-1", orgId: "personal-org" }, ]); @@ -784,23 +802,48 @@ describe("Org2CloudSyncEngine session publishing", () => { await engine.runSyncPass(); expect(client.upsertSessionMetadata).not.toHaveBeenCalled(); - expect(loadFullTranscriptChunks).not.toHaveBeenCalled(); - expect(processChunksRustMock).not.toHaveBeenCalled(); + expect(externalReplayCloudPrepareMock).not.toHaveBeenCalled(); + expect(eventStoreMock.getPersistedEvents).not.toHaveBeenCalledWith( + "cursoride-thread-1" + ); vi.setSystemTime(Date.now() + EXTERNAL_HISTORY_ACTIVITY_DEBOUNCE_MS + 1); await engine.runSyncPass(); expect(client.upsertSessionMetadata).toHaveBeenCalledTimes(1); - expect(loadFullTranscriptChunks).toHaveBeenCalledTimes(1); + expect(externalReplayCloudPrepareMock).toHaveBeenCalledTimes(1); + expect(eventStoreMock.getPersistedEvents).not.toHaveBeenCalledWith( + "cursoride-thread-1" + ); }); it("seeds unchanged external replay state from the server after restart", async () => { const sessionId = "cursoride-thread-1"; - const source = getImportedHistorySourceBySessionId(sessionId); - const loadFullTranscriptChunks = vi - .spyOn(source!, "loadFullTranscriptChunks") - .mockResolvedValue([{ id: "cursor-chunk" }] as never); - processChunksRustMock.mockResolvedValue([makeEvent("cursor-event")]); + externalReplayCloudPrepareMock.mockResolvedValue({ + token: "restart-external-spool", + generation: "g1", + totalCount: 1, + frozenEventCount: 1, + tailEventCount: 0, + frozenChainHash: "restart-chain", + tailHash: null, + }); + externalReplayCloudReadBatchMock.mockResolvedValue({ + segments: [ + { + payloadGz: "restart-wire", + eventCount: 1, + segmentHash: "restart-segment", + wireBytes: 100, + }, + ], + startEventIndex: 0, + nextEventIndex: 1, + startSegmentIndex: 0, + nextSegmentIndex: 1, + eof: true, + serializedBytes: 100, + }); store.set(sessionsAtom, [ { ...SESSION, session_id: sessionId, orgId: "personal-org" }, ]); @@ -827,10 +870,12 @@ describe("Org2CloudSyncEngine session publishing", () => { ], }); client.upsertSessionMetadata.mockClear(); - client.rewriteSessionEvents.mockClear(); - client.appendSessionEvents.mockClear(); - loadFullTranscriptChunks.mockClear(); - processChunksRustMock.mockClear(); + client.rewriteSessionEventWires.mockClear(); + client.appendSessionEventWires.mockClear(); + externalReplayCloudPrepareMock.mockClear(); + externalReplayCloudReadBatchMock.mockClear(); + externalReplayCloudPrefixHashMock.mockClear(); + eventStoreMock.getPersistedEvents.mockClear(); engine = new Org2CloudSyncEngine(client, projectsClient, bridge); engine.start(store); @@ -838,10 +883,12 @@ describe("Org2CloudSyncEngine session publishing", () => { expect(client.listOrgSessions).toHaveBeenCalledWith("jwt-1", "corg-1"); expect(client.upsertSessionMetadata).not.toHaveBeenCalled(); - expect(loadFullTranscriptChunks).not.toHaveBeenCalled(); - expect(processChunksRustMock).not.toHaveBeenCalled(); - expect(client.rewriteSessionEvents).not.toHaveBeenCalled(); - expect(client.appendSessionEvents).not.toHaveBeenCalled(); + expect(externalReplayCloudPrepareMock).not.toHaveBeenCalled(); + expect(eventStoreMock.getPersistedEvents).not.toHaveBeenCalledWith( + sessionId + ); + expect(client.rewriteSessionEventWires).not.toHaveBeenCalled(); + expect(client.appendSessionEventWires).not.toHaveBeenCalled(); store.set(sessionsAtom, [ { @@ -853,21 +900,21 @@ describe("Org2CloudSyncEngine session publishing", () => { ]); await engine.runSyncPass(); expect(client.upsertSessionMetadata).not.toHaveBeenCalled(); - expect(loadFullTranscriptChunks).not.toHaveBeenCalled(); - expect(processChunksRustMock).not.toHaveBeenCalled(); + expect(externalReplayCloudPrepareMock).not.toHaveBeenCalled(); + expect(eventStoreMock.getPersistedEvents).not.toHaveBeenCalledWith( + sessionId + ); vi.setSystemTime(Date.now() + EXTERNAL_HISTORY_ACTIVITY_DEBOUNCE_MS + 1); await engine.runSyncPass(); expect(client.upsertSessionMetadata).toHaveBeenCalledTimes(1); - expect(loadFullTranscriptChunks).toHaveBeenCalledTimes(1); - expect(processChunksRustMock).toHaveBeenCalledTimes(1); + expect(externalReplayCloudPrepareMock).toHaveBeenCalledTimes(1); + expect(eventStoreMock.getPersistedEvents).not.toHaveBeenCalledWith( + sessionId + ); }); it("the floor still lifts imported history the user explicitly shared", async () => { - const source = getImportedHistorySourceBySessionId("cursoride-thread-1"); - vi.spyOn(source!, "loadFullTranscriptChunks").mockResolvedValue( - [] as never - ); store.set(org2CloudAccessSettingsAtom, { "corg-1": { sessionModes: { "cursoride-thread-1": "metadata_only" }, @@ -890,6 +937,9 @@ describe("Org2CloudSyncEngine session publishing", () => { expect(client.upsertSessionMetadata.mock.calls[0][3].accessMode).toBe( "full_replay" ); + expect(externalReplayCloudPrepareMock).toHaveBeenCalledWith( + "cursoride-thread-1" + ); }); it("floors a tagged effective-off session to metadata_only (never 'off' on the wire, no segments)", async () => { @@ -999,8 +1049,6 @@ describe("Org2CloudSyncEngine session publishing", () => { expect(eventStoreMock.getPersistedEvents).toHaveBeenCalledTimes(1); }); - // --- deleteSession resurrection-hash fix ---------------------------------- - it("re-upserts unchanged metadata after invalidatePushedMetadataHash (untag/delete path)", async () => { await engine.runSyncPass(); expect(client.upsertSessionMetadata).toHaveBeenCalledTimes(1); diff --git a/src/features/Org2Cloud/org2CloudSyncEngine.testUtils.ts b/src/features/Org2Cloud/org2CloudSyncEngine.testUtils.ts index c76c49e99..92b565c47 100644 --- a/src/features/Org2Cloud/org2CloudSyncEngine.testUtils.ts +++ b/src/features/Org2Cloud/org2CloudSyncEngine.testUtils.ts @@ -2,6 +2,11 @@ import { vi } from "vitest"; import type { CollabOutboxPushItem } from "@src/api/http/project"; import { getImportedHistorySourceBySessionId } from "@src/api/tauri/externalHistory"; +import type { + ExternalReplayCloudBatch, + ExternalReplayCloudManifest, + ExternalReplayTarget, +} from "@src/api/tauri/externalHistory/replay"; import Message from "@src/components/Message"; import { eventStoreProxy } from "@src/engines/SessionCore/core/store/EventStoreProxy"; import type { @@ -55,11 +60,15 @@ import { org2CloudSyncEnabledAtom, } from "./org2CloudSyncAtoms"; import type { + CloudAppendSessionEventWiresInput, CloudAppendSessionEventsInput, CloudOrgScopeState, CloudOrgSessions, + CloudRewriteSessionEventWiresInput, CloudRewriteSessionEventsInput, CloudSessionEventsSnapshot, + CloudStoredSegmentWire, + CloudUploadSessionEventWiresInput, } from "./org2CloudSyncClient"; import { Org2CloudSyncError } from "./org2CloudSyncClient"; import { @@ -76,6 +85,63 @@ const { tauriEventListeners } = vi.hoisted(() => ({ tauriEventListeners: new Map void>>(), })); +const externalReplayCloudMocks = vi.hoisted(() => ({ + prepare: vi.fn( + async (sessionId: string): Promise => ({ + token: `spool-${sessionId}`, + generation: "test-generation", + totalCount: 0, + frozenEventCount: 0, + tailEventCount: 0, + frozenChainHash: + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + tailHash: null, + }) + ), + readBatch: vi.fn( + async (options: { + token: string; + startEventIndex: number; + endEventIndex: number; + startSegmentIndex?: number; + maxBytes?: number; + }) => ({ + segments: [] as ExternalReplayCloudBatch["segments"], + startEventIndex: options.startEventIndex, + nextEventIndex: options.startEventIndex, + startSegmentIndex: options.startSegmentIndex ?? options.startEventIndex, + nextSegmentIndex: options.startSegmentIndex ?? options.startEventIndex, + eof: true, + serializedBytes: 0, + }) + ), + prefixHash: vi.fn(async (options: { eventCount: number }) => ({ + eventCount: options.eventCount, + frozenChainHash: + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + })), + release: vi.fn(async (_token: string) => undefined), + resolveSecondary: vi.fn( + async (): Promise => null + ), +})); + +vi.mock("@src/api/tauri/externalHistory/replay", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("@src/api/tauri/externalHistory/replay") + >(); + return { + ...actual, + externalReplayCloudPrepareForTarget: (target: { sessionId: string }) => + externalReplayCloudMocks.prepare(target.sessionId), + externalReplayCloudReadBatch: externalReplayCloudMocks.readBatch, + externalReplayCloudPrefixHash: externalReplayCloudMocks.prefixHash, + externalReplayCloudRelease: externalReplayCloudMocks.release, + resolveSecondaryReplayTarget: externalReplayCloudMocks.resolveSecondary, + }; +}); + export function getTauriEventListeners() { return tauriEventListeners; } @@ -144,6 +210,14 @@ export const peekMock = vi.mocked(peekShareableScopeKeys); export const primeMock = vi.mocked(primeShareableScopeKey); export const resolveMatchingScopeMock = vi.mocked(resolveMatchingOrgRepoScope); export const messageMock = vi.mocked(Message); +export const externalReplayCloudPrepareMock = externalReplayCloudMocks.prepare; +export const externalReplayCloudReadBatchMock = + externalReplayCloudMocks.readBatch; +export const externalReplayCloudPrefixHashMock = + externalReplayCloudMocks.prefixHash; +export const externalReplayCloudReleaseMock = externalReplayCloudMocks.release; +export const resolveSecondaryReplayTargetMock = + externalReplayCloudMocks.resolveSecondary; /** Minimal visibility stub for the engine's browser lifecycle triggers. */ export class DocumentStub extends EventTarget { @@ -212,10 +286,30 @@ export function makeClient() { appendSessionEvents: vi.fn( async (_token: string, _input: CloudAppendSessionEventsInput) => undefined ), + appendSessionEventWires: vi.fn( + async (_token: string, _input: CloudAppendSessionEventWiresInput) => + undefined + ), rewriteSessionEvents: vi.fn( async (_token: string, _input: CloudRewriteSessionEventsInput) => undefined ), + rewriteSessionEventWires: vi.fn( + async (_token: string, _input: CloudRewriteSessionEventWiresInput) => + undefined + ), + uploadSessionEventWires: vi.fn( + async ( + _token: string, + input: CloudUploadSessionEventWiresInput + ): Promise => + input.frozenSegments.map((segment) => ({ + seq: segment.seq ?? 0, + storagePath: `${input.orgId}/${input.sessionId}/${input.epoch}/${segment.seq}-${segment.segmentHash}.gz`, + eventCount: segment.eventCount, + segmentHash: segment.segmentHash, + })) + ), getSessionEvents: vi.fn( async ( _token: string | null, diff --git a/src/features/Org2Cloud/org2CloudSyncEngine.ts b/src/features/Org2Cloud/org2CloudSyncEngine.ts index a28838988..e11817fd8 100644 --- a/src/features/Org2Cloud/org2CloudSyncEngine.ts +++ b/src/features/Org2Cloud/org2CloudSyncEngine.ts @@ -226,9 +226,10 @@ export class Org2CloudSyncEngine extends Org2CloudSyncLifecycle { protected override async syncAllOrgs( generation: number, - options: { pushSessions: boolean } + options: { pushSessions: boolean; signal: AbortSignal } ): Promise { if (options.pushSessions) this.sessionSync.beginPass(); + const { signal } = options; const store = this.store; if (!store) return; const auth = store.get(org2CloudAuthAtom); @@ -238,7 +239,7 @@ export class Org2CloudSyncEngine extends Org2CloudSyncLifecycle { this.pruneRemovedOrgState(orgs, sessionsAtPassStart); if (orgs.length === 0) return; for (const org of orgs) { - if (this.generation !== generation) return; + if (signal.aborted || this.generation !== generation) return; await this.projectsChannel.ensureProjectOrgAlias(org); } if ( @@ -330,7 +331,7 @@ export class Org2CloudSyncEngine extends Org2CloudSyncLifecycle { (gen) => this.generation === gen ); for (const session of store.get(sessionsAtom)) { - if (this.generation !== generation) return; + if (signal.aborted || this.generation !== generation) return; if (!isCloudPushCandidate(session)) continue; // A fork is a continuation inside the source collaboration boundary, // not a new ordinary repo session. Repo scopes may overlap across a @@ -562,10 +563,11 @@ export class Org2CloudSyncEngine extends Org2CloudSyncLifecycle { org.orgId, session, matchedScope, - access + access, + signal ); } catch (error) { - if (this.generation !== generation) return; + if (signal.aborted || this.generation !== generation) return; if (isCloudSyncBackoffError(error)) { this.orgBackoff.backOffOrg(org.orgId, error); break; // Stop touching this org for the rest of the run. diff --git a/src/features/Org2Cloud/org2CloudSyncLifecycle.ts b/src/features/Org2Cloud/org2CloudSyncLifecycle.ts index e7a6f43c8..5e10f7bec 100644 --- a/src/features/Org2Cloud/org2CloudSyncLifecycle.ts +++ b/src/features/Org2Cloud/org2CloudSyncLifecycle.ts @@ -54,6 +54,8 @@ export abstract class Org2CloudSyncLifecycle { private eventStoreUnsubscribe: (() => void) | null = null; private passRunning = false; private passDirty = false; + /** Cancels pass-scoped, multi-request work such as external replay uploads. */ + private activePassAbortController: AbortController | null = null; /** A full/outbound trigger that arrived while the current pass was busy. */ private nextPassPushSessions = false; /** Serialized passes actually started (test seam for pass-count budgets). */ @@ -71,7 +73,7 @@ export abstract class Org2CloudSyncLifecycle { protected abstract syncAllOrgs( generation: number, - options: { pushSessions: boolean } + options: { pushSessions: boolean; signal: AbortSignal } ): Promise; protected abstract noteSessionEventActivity(sessionId: string): void; protected abstract resetSyncState(): void; @@ -143,6 +145,8 @@ export abstract class Org2CloudSyncLifecycle { if (!this.started) return; this.started = false; this.generation += 1; + this.activePassAbortController?.abort(); + this.activePassAbortController = null; if (this.bootstrapTimer !== null) clearTimeout(this.bootstrapTimer); this.bootstrapTimer = null; if (this.activityTimer !== null) clearTimeout(this.activityTimer); @@ -192,11 +196,21 @@ export abstract class Org2CloudSyncLifecycle { this.passRunning = true; this.startedPassCount += 1; const generation = this.generation; + const abortController = new AbortController(); + this.activePassAbortController = abortController; try { - await this.syncAllOrgs(generation, { pushSessions }); + await this.syncAllOrgs(generation, { + pushSessions, + signal: abortController.signal, + }); } catch (error) { - log.warn("cloud sync pass failed:", error); + if (!abortController.signal.aborted) { + log.warn("cloud sync pass failed:", error); + } } finally { + if (this.activePassAbortController === abortController) { + this.activePassAbortController = null; + } this.passRunning = false; if (this.started && this.generation === generation && this.passDirty) { this.passDirty = false; diff --git a/src/features/Org2Cloud/useCloudSessionActions.ts b/src/features/Org2Cloud/useCloudSessionActions.ts index 25fc0d886..deb357749 100644 --- a/src/features/Org2Cloud/useCloudSessionActions.ts +++ b/src/features/Org2Cloud/useCloudSessionActions.ts @@ -6,7 +6,7 @@ * import/fork/openSession/toast/retention semantics. Replay/fork ride the * SAME backend-agnostic machinery as the self-hosted panel * (`importRemoteSession` / `forkTeammateSession`); only the segments fetch - * differs (`buildCloudSessionFetchClient`, JWT-backed). + * differs (`buildCloudSessionWirePageClient`, JWT-backed). */ import { useAtom, useSetAtom, useStore } from "jotai"; import { useCallback, useEffect, useRef, useState } from "react"; @@ -44,7 +44,7 @@ import { org2CloudAuthAtom, org2CloudAuthIdentityKey, } from "./org2CloudAuthAtom"; -import { buildCloudSessionFetchClient } from "./org2CloudBackendAdapter"; +import { buildCloudSessionWirePageClient } from "./org2CloudBackendAdapter"; import { ensureFreshSession } from "./org2CloudClient"; import { isOrg2SyncErrorCode } from "./org2CloudSyncClient"; import { useOpenCloudBilling } from "./useOpenCloudBilling"; @@ -182,7 +182,7 @@ export function useCloudSessionActions( (await resolveForkWorkspacePath(remoteSession)) ?? undefined; abortController.signal.throwIfAborted(); return importRemoteSession({ - client: buildCloudSessionFetchClient(accessToken), + client: buildCloudSessionWirePageClient(accessToken), orgId, remoteSession, sourceEndpointUrl, @@ -265,7 +265,7 @@ export function useCloudSessionActions( return "failed"; } const result = await forkTeammateSession({ - client: buildCloudSessionFetchClient(accessToken), + client: buildCloudSessionWirePageClient(accessToken), orgId, remoteSession, promptForExecution: true, diff --git a/src/features/TeamCollaboration/cloudSessionFork.ts b/src/features/TeamCollaboration/cloudSessionFork.ts index 3d861635d..9361b1dbf 100644 --- a/src/features/TeamCollaboration/cloudSessionFork.ts +++ b/src/features/TeamCollaboration/cloudSessionFork.ts @@ -1,4 +1,4 @@ -import { buildCloudSessionFetchClient } from "@src/features/Org2Cloud/org2CloudBackendAdapter"; +import { buildCloudSessionWirePageClient } from "@src/features/Org2Cloud/org2CloudBackendAdapter"; import { listOrgSessions } from "@src/features/Org2Cloud/org2CloudSyncClient"; import type { RemoteTeammateSessionMetadata } from "@src/store/collaboration/types"; @@ -28,7 +28,7 @@ export function pickCloudRemoteSession( export interface AuthenticatedCloudSessionForkDeps { listSessions: typeof listOrgSessions; - buildClient: typeof buildCloudSessionFetchClient; + buildClient: typeof buildCloudSessionWirePageClient; fork: ( options: ForkTeammateSessionOptions ) => Promise; @@ -36,7 +36,7 @@ export interface AuthenticatedCloudSessionForkDeps { const DEFAULT_DEPS: AuthenticatedCloudSessionForkDeps = { listSessions: listOrgSessions, - buildClient: buildCloudSessionFetchClient, + buildClient: buildCloudSessionWirePageClient, fork: forkTeammateSession, }; diff --git a/src/features/TeamCollaboration/engine/collabRemoteFetch.ts b/src/features/TeamCollaboration/engine/collabRemoteFetch.ts deleted file mode 100644 index 233fd1212..000000000 --- a/src/features/TeamCollaboration/engine/collabRemoteFetch.ts +++ /dev/null @@ -1,132 +0,0 @@ -/** - * Shared remote segments fetch + assembly (design §7.4). - * - * The segments-fetch capability behind both teammate-session import - * (`collabSessionImport.ts`, read-only replay copy) and fork - * (`collabSessionFork.ts`, writable relay copy): contiguity, per-segment - * content-hash proof and summary reconciliation all live here so the two - * callers cannot drift apart on validation. - */ -import type { SessionEvent } from "@src/engines/SessionCore/core/types"; -import type { RemoteTeammateSessionMetadata } from "@src/store/collaboration/types"; - -import { SegmentIntegrityError } from "../forkSnapshotIntegrity"; -import type { - CollabSyncBackendClient, - SessionEventSegmentRecord, -} from "../sync/CollabSyncBackend"; -import { computeSegmentHash } from "../sync/collabGzip"; - -/** - * The segments-fetch capability shared by `importRemoteSession` (read-only - * replay copy) and `forkSession` (writable relay copy). Both fetch the SAME - * remote history through `fetchAndAssembleSegments`; they differ only in what - * kind of local session the assembled events land in. - */ -export interface RemoteSessionFetchOptions { - client: Pick< - CollabSyncBackendClient, - "getSessionEventSegments" | "streamSessionEventSegments" - >; - orgId: string; - remoteSession: RemoteTeammateSessionMetadata; - /** - * Link-share capability (design §6.4): when set, every segments fetch - * authenticates with the token alone — the caller is typically NOT an org - * member (guest deep link). The token is the only credential. - * `remoteSession` then comes from `resolveSessionShare`, whose projection - * includes the segments summary this importer diffs against. - */ - shareToken?: string; - /** Non-secret issuing endpoint persisted with a guest capability. */ - shareEndpointUrl?: string; - /** Deployment identity used to isolate deterministic imports and cursors. */ - sourceEndpointUrl?: string; - /** Cancels fetch, decode and the durable local apply. */ - signal?: AbortSignal; -} - -export interface AssembledSegments { - events: SessionEvent[]; - epoch: number; - frozenSeq: number; - frozenCount: number; - tailHash: string | null; -} - -export function throwIfAborted(signal: AbortSignal | undefined): void { - if (signal?.aborted) { - throw signal.reason ?? new DOMException("Aborted", "AbortError"); - } -} - -export async function validateSegmentIntegrity( - segment: SessionEventSegmentRecord -): Promise { - if (segment.events.length !== segment.eventCount) { - throw new SegmentIntegrityError(segment.seq, segment.isTail, "event_count"); - } - if ((await computeSegmentHash(segment.events)) !== segment.segmentHash) { - throw new SegmentIntegrityError( - segment.seq, - segment.isTail, - "content_hash" - ); - } -} - -export async function fetchAndAssembleSegments( - options: RemoteSessionFetchOptions, - afterSeq: number, - baseFrozenEvents: SessionEvent[], - expectedEpoch: number | null -): Promise { - const { client, orgId, remoteSession, shareToken, signal } = options; - const snapshot = await client.getSessionEventSegments({ - orgId, - sessionRowId: remoteSession.id, - afterSeq, - shareToken, - signal, - }); - if (snapshot.epoch === null || snapshot.count === null) return null; - // The snapshot is authoritative over the (possibly stale) list summary; a - // mid-flight epoch change invalidates the incremental base. - if (expectedEpoch !== null && snapshot.epoch !== expectedEpoch) return null; - - // Content-level proof BEFORE assembly: contiguity and totals below are - // structural only — a payload whose decoded events disagree with its own - // eventCount/segmentHash must fail closed, not splice into local history. - for (const segment of snapshot.segments) { - await validateSegmentIntegrity(segment); - } - - const frozen: SessionEventSegmentRecord[] = snapshot.segments - .filter((segment) => !segment.isTail) - .sort((a, b) => a.seq - b.seq); - // Contiguity (design §7.4): frozen seqs must run afterSeq+1..frozenSeq - // with no gaps, and the reassembled stream must match the summary count. - let expectedSeq = afterSeq; - for (const segment of frozen) { - if (segment.seq !== expectedSeq + 1) return null; - expectedSeq = segment.seq; - } - if ((snapshot.frozenSeq ?? 0) !== expectedSeq) return null; - - const tailSegment = - snapshot.segments.find((segment) => segment.isTail) ?? null; - const tailEvents = tailSegment?.events ?? []; - const events = [ - ...baseFrozenEvents, - ...frozen.flatMap((segment) => segment.events), - ...tailEvents, - ]; - if (events.length !== snapshot.count) return null; - return { - events, - epoch: snapshot.epoch, - frozenSeq: snapshot.frozenSeq ?? 0, - frozenCount: events.length - tailEvents.length, - tailHash: tailSegment?.segmentHash ?? snapshot.tailHash, - }; -} diff --git a/src/features/TeamCollaboration/engine/collabSessionFork.ts b/src/features/TeamCollaboration/engine/collabSessionFork.ts index 063b9a13d..f72d4d3aa 100644 --- a/src/features/TeamCollaboration/engine/collabSessionFork.ts +++ b/src/features/TeamCollaboration/engine/collabSessionFork.ts @@ -2,9 +2,9 @@ * Fork & continue (design §16.11 — session relay). * * `forkSession` is the WRITABLE sibling of `importRemoteSession` - * (`collabSessionImport.ts`): backend-agnostic, sharing the exact - * fetch/assembly path (`fetchAndAssembleSegments`) and durable-write ordering, - * differing only in the kind of local session the assembled events land in. + * (`collabSessionImport.ts`): backend-agnostic, sharing the bounded wire-page + * ingest path, and differing only in the kind of local session the committed + * snapshot lands in. */ import { DISPATCH_CATEGORY } from "@src/api/tauri/session/dispatchTypes"; import type { KeyInfo } from "@src/api/types/keys"; @@ -29,9 +29,10 @@ import { ForkOperationError, ForkSnapshotIntegrityError, } from "../forkSnapshotIntegrity"; -import { rewriteEventsForImportedSnapshot } from "./collabImportIdentity"; -import type { RemoteSessionFetchOptions } from "./collabRemoteFetch"; -import { fetchAndAssembleSegments } from "./collabRemoteFetch"; +import { + type RemoteSnapshotIngestOptions, + ingestRemoteSnapshot, +} from "./collabSnapshotIngest"; /** * Fresh id for a forked session. The `agentsession-` prefix maps to the @@ -69,6 +70,8 @@ export interface ForkSessionResult { accountId?: string; agentDefinitionId?: string; modelFallback?: { inheritedModel: string; fallbackModel?: string }; + /** Rust-folded handoff context; never derived from a full renderer array. */ + handoffItems: string[]; } export interface ForkExecutionSelection { @@ -77,7 +80,10 @@ export interface ForkExecutionSelection { model: string; } -export interface ForkSessionOptions extends RemoteSessionFetchOptions { +export interface ForkSessionOptions extends Omit< + RemoteSnapshotIngestOptions, + "localSessionId" | "previous" +> { /** Explicit local credentials/model chosen by the member continuing it. */ execution?: ForkExecutionSelection; /** @@ -94,17 +100,18 @@ export interface ForkSessionOptions extends RemoteSessionFetchOptions { /** * "Fork & continue" (design §16.11): land a replay-capable teammate session's - * FULL event history as a new WRITABLE local session, so an agent can run on - * this machine, with this member's key, continuing from the teammate's - * context. NOT multi-writer — the fork is an ordinary single-writer session - * that merely records its origin in `forkedFrom`. + * authoritative event history as a new WRITABLE local session, so an agent + * can run on this machine, with this member's key, continuing from the + * teammate's context. The history is streamed as bounded opaque wire pages + * into Rust; the renderer never assembles a session-sized event array. NOT + * multi-writer — the fork is an ordinary single-writer session that merely + * records its origin in `forkedFrom`. * - * Shares the exact fetch/assembly path with `importRemoteSession` - * (`fetchAndAssembleSegments`, always a full refetch from seq 0 — a fork has - * no incremental cursor to splice onto) and mirrors its durable-write - * ordering: events are cached BEFORE the session record is persisted, so a - * failed cache write can never leave a forked record with no events (fix P7's - * ordering, same rationale as the importer). + * Shares the exact staged-ingest path with `importRemoteSession`. A fork has + * no incremental cursor, so Rust rebuilds its local snapshot from bounded + * backward pages and atomically publishes it before the session record is + * persisted. A failed ingest therefore cannot leave a runnable fork record + * with partial history. * * Unlike an import, the created session: * - gets a fresh NORMAL id (`agentsession-*`, category `rust_agent`) so it is @@ -171,51 +178,42 @@ export async function forkSession( ? { model: options.execution.model, fellBack: false } : resolveForkModel(remoteSession.model, localKeys, defaultModel); - // Full fetch from seq 0, same assembly + validation as the importer. Forks - // additionally fail closed against the list-row summary: an internally - // valid tail-only response must not materialize when the row promised a - // larger frozen history. - const assembled = await fetchAndAssembleSegments(options, 0, [], null); - const summaryMatches = - assembled !== null && - assembled.epoch === remoteSession.eventsEpoch && - assembled.frozenSeq === (remoteSession.eventsFrozenSeq ?? 0) && - assembled.events.length === remoteSession.eventsCount && - assembled.tailHash === (remoteSession.eventsTailHash ?? null); - if (!summaryMatches) { + const localSessionId = createForkedSessionId(); + const committed = await ingestRemoteSnapshot({ + client: options.client, + orgId, + remoteSession, + localSessionId, + ...(shareToken !== undefined ? { shareToken } : {}), + ...(options.signal !== undefined ? { signal: options.signal } : {}), + }); + // Forks fail closed against the list-row summary. Unlike the old path, the + // comparison is against Rust's atomically committed counters rather than a + // renderer-sized `assembled.events` array. + if ( + committed === null || + committed.epoch !== remoteSession.eventsEpoch || + committed.frozenSeq !== (remoteSession.eventsFrozenSeq ?? 0) || + committed.eventCount !== remoteSession.eventsCount || + committed.tailHash !== (remoteSession.eventsTailHash ?? null) + ) { + await eventStoreProxy + .clearPersistedHistory(localSessionId) + .catch(() => undefined); throw new ForkSnapshotIntegrityError( FORK_SNAPSHOT_ERROR_KIND.SNAPSHOT_INCOMPLETE, `Fork snapshot does not match source summary for ${remoteSession.sourceSessionId}` ); } - const localSessionId = createForkedSessionId(); - const localEvents = rewriteEventsForImportedSnapshot( - assembled.events, - localSessionId - ); const now = new Date().toISOString(); - // Durable events first, session record second (mirror importRemoteSession): - // if the cache write fails, no record must claim the fork exists. - await eventStoreProxy.set(localEvents, localSessionId); - const savedCount = await eventStoreProxy.saveToCache(localSessionId); - if (localEvents.length > 0 && savedCount <= 0) { - // Drop the just-set events again — no session record points at them, and - // a fork id is random, so (unlike the importer's deterministic id) a - // retry would not overwrite this orphan. - await eventStoreProxy.clear(localSessionId); - throw new Error( - `Failed to durably persist forked session ${remoteSession.sourceSessionId} (saveToCache returned ${savedCount})` - ); - } - const forkedFrom: SessionForkedFrom = { orgId, sourceSessionId: remoteSession.sourceSessionId, ownerMemberId: remoteSession.ownerMemberId, ownerDisplayName: remoteSession.ownerDisplayName, - atCount: localEvents.length, + atCount: committed.eventCount, forkedAt: now, // Root inheritance: forking a fork keeps pointing at the ORIGINAL // session, so the whole relay chain groups under one thread even when @@ -259,11 +257,12 @@ export async function forkSession( return { localSessionId, name, - eventCount: localEvents.length, + eventCount: committed.eventCount, repoPath, model: resolvedModel.model, accountId: options.execution?.accountId, agentDefinitionId: options.execution?.agentDefinitionId, + handoffItems: committed.handoffItems, ...(resolvedModel.fellBack && remoteSession.model ? { modelFallback: { diff --git a/src/features/TeamCollaboration/engine/collabSessionImport.ts b/src/features/TeamCollaboration/engine/collabSessionImport.ts index 524c3ed37..34214c31f 100644 --- a/src/features/TeamCollaboration/engine/collabSessionImport.ts +++ b/src/features/TeamCollaboration/engine/collabSessionImport.ts @@ -3,11 +3,10 @@ * * `importRemoteSession` is THE consolidated teammate-session import (design * §7.4 + M5 dedup) — backend-agnostic, its only backend dependency being - * `client.getSessionEventSegments` (satisfied on the managed cloud by - * `org2CloudBackendAdapter`) via `fetchAndAssembleSegments`. + * bounded raw wire pages. Rust owns decode, validation and atomic publish; + * the renderer never assembles a session-sized event array. */ import { indexOrgtrackCollaborationSession } from "@src/api/tauri/lineage"; -import { eventStoreProxy } from "@src/engines/SessionCore/core/store/EventStoreProxy"; import { createLogger } from "@src/hooks/logger"; import { sessionsAtom } from "@src/store/session/sessionAtom/atoms"; import { recordGuestImportedSession } from "@src/store/session/sessionAtom/guestImportRegistry"; @@ -18,28 +17,27 @@ import type { SessionImportedFrom, } from "@src/store/session/sessionAtom/types"; import { getInstrumentedStore } from "@src/util/core/state/instrumentedStore"; -import { resolveSessionDisplayMetadata } from "@src/util/session/sessionDisplayMetadata"; -import { namespaceCopyEventId } from "../copyEventId"; import { deriveImportedSessionId, findImportedSession, normalizeSourceEndpointUrl, - rewriteEventsForImportedSnapshot, } from "./collabImportIdentity"; -import type { - AssembledSegments, - RemoteSessionFetchOptions, -} from "./collabRemoteFetch"; import { - fetchAndAssembleSegments, - throwIfAborted, - validateSegmentIntegrity, -} from "./collabRemoteFetch"; + type RemoteSnapshotIngestOptions, + ingestRemoteSnapshot, +} from "./collabSnapshotIngest"; const log = createLogger("collabSyncEngineHelpers"); -export interface ImportRemoteSessionOptions extends RemoteSessionFetchOptions { +export interface ImportRemoteSessionOptions extends Omit< + RemoteSnapshotIngestOptions, + "localSessionId" | "previous" +> { + /** Deployment identity used to isolate deterministic local imports. */ + sourceEndpointUrl?: string; + /** Non-secret endpoint associated with a guest replay capability. */ + shareEndpointUrl?: string; /** * Invoked with the local session id BEFORE any event-store write, so the * engine can arm its self-import guard (the eventStore write re-enters the @@ -62,12 +60,6 @@ export interface ImportRemoteSessionResult { * still have been refreshed on the existing local row. */ updated: boolean; - /** - * A fresh streamed import deliberately skips the derived provenance index: - * building it currently reloads the complete history. A later no-op refresh - * may index after the replay is already durable and usable. - */ - deferIndex?: boolean; } /** @@ -76,163 +68,6 @@ export interface ImportRemoteSessionResult { */ const remoteSessionImportTails = new Map>(); -interface PersistedStreamSummary { - epoch: number; - frozenSeq: number; - frozenCount: number; - count: number; - tailHash: string | null; -} - -const STREAM_IMPORT_MAX_ATTEMPTS = 3; - -function isReplayEpochConflict(error: unknown): boolean { - return ( - error instanceof Error && - ("code" in error - ? (error as Error & { code?: unknown }).code === "ORG2_CONFLICT" - : error.message.includes("ORG2_CONFLICT")) - ); -} - -/** - * Fresh large imports never need the full replay in JS or Rust memory. Decode - * one server page, validate it, namespace it, and upsert it directly into - * SQLite. Only the initial turn window is hydrated after the final summary - * reconciles. An epoch race clears the partial rows and restarts from page 1. - */ -async function streamFreshRemoteSessionToCache( - options: ImportRemoteSessionOptions, - localSessionId: string -): Promise { - const stream = options.client.streamSessionEventSegments; - if (!stream) return null; - - for (let attempt = 0; attempt < STREAM_IMPORT_MAX_ATTEMPTS; attempt += 1) { - await eventStoreProxy.clearPersistedHistory(localSessionId); - let epoch: number | null = null; - let expectedFrozenSeq = 0; - let persistedCount = 0; - let frozenCount = 0; - let tailHash: string | null = null; - - try { - const summary = await stream( - { - orgId: options.orgId, - sessionRowId: options.remoteSession.id, - afterSeq: 0, - ...(options.shareToken !== undefined - ? { shareToken: options.shareToken } - : {}), - ...(options.signal !== undefined ? { signal: options.signal } : {}), - }, - async (page) => { - throwIfAborted(options.signal); - if (page.epoch === null || page.count === null) return; - if (epoch === null) { - epoch = page.epoch; - } else if (page.epoch !== epoch) { - throw new Error( - "ORG2_CONFLICT session events epoch changed during streamed import" - ); - } - - const frozen = page.segments - .filter((segment) => !segment.isTail) - .sort((a, b) => a.seq - b.seq); - const tails = page.segments.filter((segment) => segment.isTail); - if (tails.length > 1) { - throw new Error("Replay page contained more than one tail"); - } - for (const segment of [...frozen, ...tails]) { - await validateSegmentIntegrity(segment); - } - for (const segment of frozen) { - if (segment.seq !== expectedFrozenSeq + 1) { - throw new Error("Replay page contained a frozen-segment gap"); - } - expectedFrozenSeq = segment.seq; - } - - const tail = tails[0] ?? null; - const sourceEvents = [ - ...frozen.flatMap((segment) => segment.events), - ...(tail?.events ?? []), - ]; - const localEvents = rewriteEventsForImportedSnapshot( - sourceEvents, - localSessionId - ); - if (localEvents.length > 0) { - const savedCount = await eventStoreProxy.persistEventsBatch( - localEvents, - localSessionId - ); - if (savedCount <= 0) { - throw new Error( - `Failed to persist streamed import ${options.remoteSession.sourceSessionId}` - ); - } - } - persistedCount += localEvents.length; - frozenCount += frozen.reduce( - (count, segment) => count + segment.events.length, - 0 - ); - if (tail) tailHash = tail.segmentHash; - } - ); - - if (summary.epoch === null || summary.count === null) { - await eventStoreProxy.clearPersistedHistory(localSessionId); - return null; - } - if ( - epoch !== summary.epoch || - expectedFrozenSeq !== (summary.frozenSeq ?? 0) || - persistedCount !== summary.count - ) { - throw new Error("Streamed replay summary did not reconcile"); - } - throwIfAborted(options.signal); - const finalizedCount = - await eventStoreProxy.finalizePersistedImport(localSessionId); - if (finalizedCount !== summary.count) { - throw new Error( - `Streamed replay finalize count ${finalizedCount} did not match ${summary.count}` - ); - } - throwIfAborted(options.signal); - const loaded = await eventStoreProxy.loadInitialTurnWindow( - localSessionId, - 0 - ); - if (summary.count > 0 && loaded <= 0) { - throw new Error("Failed to hydrate streamed replay turn window"); - } - return { - epoch: summary.epoch, - frozenSeq: summary.frozenSeq ?? 0, - frozenCount, - count: persistedCount, - tailHash: tailHash ?? summary.tailHash, - }; - } catch (error) { - await eventStoreProxy.clearPersistedHistory(localSessionId); - await eventStoreProxy.clear(localSessionId).catch(() => undefined); - if ( - isReplayEpochConflict(error) && - attempt + 1 < STREAM_IMPORT_MAX_ATTEMPTS - ) { - continue; - } - throw error; - } - } - throw new Error("Streamed replay import exhausted its retry budget"); -} - function resolveImportedSourceDisplay( remoteSession: ImportRemoteSessionOptions["remoteSession"], existing: Session | undefined @@ -251,19 +86,6 @@ function resolveImportedSourceDisplay( }; } -function resolveImportedSourcePresentation( - localSessionId: string, - importedFrom: SessionImportedFrom -) { - return resolveSessionDisplayMetadata({ - kind: "local", - session: { - session_id: localSessionId, - importedFrom, - }, - }); -} - function refreshImportedSessionPresentation( existing: Session, remoteSession: ImportRemoteSessionOptions["remoteSession"] @@ -279,23 +101,9 @@ function refreshImportedSessionPresentation( const ownerAvatarUrl = remoteSession.ownerAvatarUrl ?? importedFrom.ownerAvatarUrl; const repoPath = remoteSession.repoPath ?? existing.repoPath; - const refreshedImportedFrom: SessionImportedFrom = { - ...importedFrom, - ownerMemberId: remoteSession.ownerMemberId, - ownerDisplayName: remoteSession.ownerDisplayName, - ownerAvatarUrl, - externalHistorySource, - sourceDisplay, - }; - const sourcePresentation = resolveImportedSourcePresentation( - existing.session_id, - refreshedImportedFrom - ); const unchanged = existing.name === remoteSession.title && existing.repoPath === repoPath && - existing.agentDisplayName === sourcePresentation.agentLabel && - existing.agentIconId === sourcePresentation.agentIconId && importedFrom.ownerMemberId === remoteSession.ownerMemberId && importedFrom.ownerDisplayName === remoteSession.ownerDisplayName && importedFrom.ownerAvatarUrl === ownerAvatarUrl && @@ -312,9 +120,14 @@ function refreshImportedSessionPresentation( ...existing, name: remoteSession.title, repoPath, - agentDisplayName: sourcePresentation.agentLabel, - agentIconId: sourcePresentation.agentIconId, - importedFrom: refreshedImportedFrom, + importedFrom: { + ...importedFrom, + ownerMemberId: remoteSession.ownerMemberId, + ownerDisplayName: remoteSession.ownerDisplayName, + ownerAvatarUrl, + externalHistorySource, + sourceDisplay, + }, }; upsertSession(refreshed); recordGuestImportedSession(refreshed); @@ -364,7 +177,7 @@ export async function importRemoteSession( remoteSessionImportTails.delete(key); } } - if (result && options.workspaceRepoPath && !result.deferIndex) { + if (result && options.workspaceRepoPath) { try { await indexOrgtrackCollaborationSession({ localSessionId: result.localSessionId, @@ -407,7 +220,8 @@ async function importRemoteSessionInner( remoteSession.sourceSessionId, sourceEndpointUrl ); - // Legacy (error_message) imports have no usable cursor → full refetch. + // Legacy (error_message) imports have no usable cursor and are rebuilt by + // the bounded Rust ingester. No renderer-side full-history fallback exists. const cursor = existing?.importedFrom ?? null; if ( @@ -420,215 +234,110 @@ async function importRemoteSessionInner( return { localSessionId: existing.session_id, updated: false }; } - if ( - existing && - cursor && - cursor.epoch === remoteSession.eventsEpoch && - cursor.seq === (remoteSession.eventsFrozenSeq ?? 0) && - cursor.count === remoteSession.eventsCount && - (cursor.tailHash ?? null) === (remoteSession.eventsTailHash ?? null) - ) { - // Cursor no-op — but only if the local store still HAS the events. A - // cache row can outlive its event data (restart/cleanup churn), and - // trusting the cursor then pins an unrecoverable empty replay: every - // click returns here and Reload re-reads the same empty store. Verify - // before short-circuiting; fall through to a full refetch when hollow. - const persisted = await eventStoreProxy.getPersistedEvents( - existing.session_id - ); - if (persisted.length > 0 || remoteSession.eventsCount === 0) { - refreshImportedSessionPresentation(existing, remoteSession); - return { localSessionId: existing.session_id, updated: false }; - } - } - - let assembled: AssembledSegments | null = null; - let streamed: PersistedStreamSummary | null = null; - let localSessionId = existing?.session_id; - if (!existing && options.client.streamSessionEventSegments) { - // Fresh imports are the common large-history path. Persist bounded pages - // directly instead of constructing two full event arrays in the WebView. - localSessionId = await deriveImportedSessionId( - orgId, - remoteSession.sourceSessionId, - sourceEndpointUrl - ); - onBeforeWrite?.(localSessionId); - streamed = await streamFreshRemoteSessionToCache(options, localSessionId); - if (!streamed) return null; - } else { - if ( - existing && - cursor && - cursor.epoch >= 1 && - cursor.epoch === remoteSession.eventsEpoch && - cursor.frozenCount !== undefined && - (remoteSession.eventsFrozenSeq ?? 0) >= cursor.seq - ) { - // Incremental: verify the local store still holds exactly what the - // cursor claims before splicing onto it (design §7.4 last line). - const persistedEvents = await eventStoreProxy.getPersistedEvents( - existing.session_id - ); - if (persistedEvents.length === cursor.count) { - assembled = await fetchAndAssembleSegments( - options, - cursor.seq, - persistedEvents.slice(0, cursor.frozenCount), - cursor.epoch - ); - } - } - if (!assembled) { - // Existing imports normally fetch only a delta. Epoch changes still use - // the compatibility assembler so their prior snapshot can be restored - // atomically if the replacement fails. - assembled = await fetchAndAssembleSegments(options, 0, [], null); - } - if (!assembled) { - if (!existing) return null; - refreshImportedSessionPresentation(existing, remoteSession); - return { localSessionId: existing.session_id, updated: false }; - } - // Keep the first fetch ahead of hashing the deterministic id. Besides - // shaving startup latency, this preserves the import queue's immediate - // single-flight handoff for existing backend implementations. - localSessionId ??= await deriveImportedSessionId( + // Deterministic id (not random): a retry after a failed durable write must + // reuse the same local id instead of leaking a new orphan per attempt. + const localSessionId = + existing?.session_id ?? + (await deriveImportedSessionId( orgId, remoteSession.sourceSessionId, sourceEndpointUrl - ); - onBeforeWrite?.(localSessionId); - } - - if (!localSessionId) { - throw new Error("Failed to derive an imported session id"); - } - const localEvents = assembled - ? rewriteEventsForImportedSnapshot(assembled.events, localSessionId) - : []; - const replay = streamed ?? assembled; - if (!replay) return null; - const priorPersisted = existing - ? await eventStoreProxy.getPersistedEvents(localSessionId) - : []; - let storageMutated = streamed !== null; - try { - throwIfAborted(options.signal); - const now = new Date().toISOString(); - const importedFrom: SessionImportedFrom = { - orgId, - sourceSessionId: remoteSession.sourceSessionId, - sourceEndpointUrl, - ownerMemberId: remoteSession.ownerMemberId, - ownerDisplayName: remoteSession.ownerDisplayName, - ownerAvatarUrl: remoteSession.ownerAvatarUrl, - externalHistorySource: - remoteSession.origin?.kind === "external_history" - ? remoteSession.origin.source - : existing?.importedFrom?.externalHistorySource, - sourceDisplay: resolveImportedSourceDisplay(remoteSession, existing), - epoch: replay.epoch, - seq: replay.frozenSeq, - count: streamed?.count ?? localEvents.length, - frozenCount: replay.frozenCount, - tailHash: replay.tailHash ?? undefined, - importedAt: now, - shareToken: shareToken ?? existing?.importedFrom?.shareToken, - shareEndpointUrl: - shareEndpointUrl ?? existing?.importedFrom?.shareEndpointUrl, - }; - const sourcePresentation = resolveImportedSourcePresentation( - localSessionId, - importedFrom - ); - const importedRow: Session = { - session_id: localSessionId, - status: "completed", - created_at: existing?.created_at ?? now, - updated_at: now, - completed_at: now, - name: remoteSession.title, - repoPath: remoteSession.repoPath, - category: "external_history", - // No runnable model: the imported copy's composer is a FORK ENTRY, not a - // live agent. The source model is retained under importedFrom.sourceDisplay - // for read-only presentation, while leaving this field unset makes the - // composer ask which of the viewer's OWN local models/keys the fork should - // actually use. - model: undefined, - // Opening a cloud row replaces it with this local replay in Kanban, - // sidebar, search, and workstation consumers. Keep the same source-agent - // presentation on the replacement row so that transition never renames - // the agent to an import-mechanism placeholder. - agentIconId: sourcePresentation.agentIconId, - agentDisplayName: sourcePresentation.agentLabel, - pinned: existing?.pinned ?? false, - // Ownership stamp (`Session.orgId`, distinct from `importedFrom.orgId` - // provenance — see sessionAtom/types.ts): filing the import under the - // org makes it match the sidebar org selector. Only MEMBER imports are - // stamped — the engine PullLoop and the panel replay both run in member - // context (org sync profile, no token). A share-token import is the - // GUEST path (CollabShareImportDialog, no local membership): it stays - // under Personal, i.e. no orgId (preserving any prior member stamp). - orgId: shareToken ? existing?.orgId : orgId, - importedFrom, - // Retire the legacy error_message idiom for collab imports; clears any - // leftover value on upgraded pre-M3 rows. - error_message: undefined, - }; - // A pre-namespacing import left bare rows in SQLite. Purge before the - // replacement, but keep the prior snapshot above so cancellation/error - // can restore the exact pre-import state. - const hasBareRows = priorPersisted.some( - (event) => event.id !== namespaceCopyEventId(localSessionId, event.id) - ); - if (hasBareRows) { - storageMutated = true; - await eventStoreProxy.clearPersistedHistory(localSessionId); - } - if (!streamed) { - // Durable events first, cursor/session row last. Closing the import modal - // after this write but before commit triggers the rollback below. - storageMutated = true; - await eventStoreProxy.set(localEvents, localSessionId); - const savedCount = await eventStoreProxy.saveToCache(localSessionId); - if (localEvents.length > 0 && savedCount <= 0) { - throw new Error( - `Failed to durably persist imported session ${remoteSession.sourceSessionId} (saveToCache returned ${savedCount})` - ); - } - } - throwIfAborted(options.signal); - // No await after the final abort check: the session row, guest registry - // and persisted list commit synchronously as one local critical section. - upsertSession(importedRow); - recordGuestImportedSession(importedRow); - persistSessions(store.get(sessionsAtom) as Session[]); - } catch (error) { - if (storageMutated) { - await eventStoreProxy - .clearPersistedHistory(localSessionId) - .catch((rollbackError) => - log.error("failed to clear cancelled import history", rollbackError) - ); - if (priorPersisted.length > 0) { - await eventStoreProxy.set(priorPersisted, localSessionId); - const restored = await eventStoreProxy.saveToCache(localSessionId); - if (restored <= 0) { - log.error("failed to restore prior import history", { - localSessionId, - }); + )); + const previous = + cursor && cursor.frozenCount !== undefined + ? { + epoch: cursor.epoch, + frozenSeq: cursor.seq, + count: cursor.count, + frozenCount: cursor.frozenCount, + tailHash: cursor.tailHash ?? null, } - } else { - await eventStoreProxy.clear(localSessionId).catch(() => undefined); - } - } - throw error; - } - return { + : undefined; + + // Arm the push-loop self-import guard before Rust atomically publishes any + // rows. Wire pages remain opaque gzip strings in JS throughout this call. + onBeforeWrite?.(localSessionId); + const committed = await ingestRemoteSnapshot({ + client: options.client, + orgId, + remoteSession, localSessionId, - updated: true, - ...(streamed ? { deferIndex: true } : {}), + ...(previous !== undefined ? { previous } : {}), + ...(shareToken !== undefined ? { shareToken } : {}), + ...(options.signal !== undefined ? { signal: options.signal } : {}), + }); + if (!committed) { + return existing + ? { localSessionId: existing.session_id, updated: false } + : null; + } + + const now = new Date().toISOString(); + const importedFrom: SessionImportedFrom = { + orgId, + sourceSessionId: remoteSession.sourceSessionId, + sourceEndpointUrl, + ownerMemberId: remoteSession.ownerMemberId, + ownerDisplayName: remoteSession.ownerDisplayName, + ownerAvatarUrl: remoteSession.ownerAvatarUrl, + externalHistorySource: + remoteSession.origin?.kind === "external_history" + ? remoteSession.origin.source + : existing?.importedFrom?.externalHistorySource, + sourceDisplay: resolveImportedSourceDisplay(remoteSession, existing), + epoch: committed.epoch, + seq: committed.frozenSeq, + count: committed.eventCount, + frozenCount: committed.frozenEventCount, + tailHash: committed.tailHash ?? undefined, + importedAt: now, + shareToken: shareToken ?? existing?.importedFrom?.shareToken, + shareEndpointUrl: + shareEndpointUrl ?? existing?.importedFrom?.shareEndpointUrl, + }; + const importedRow: Session = { + session_id: localSessionId, + status: "completed", + created_at: existing?.created_at ?? now, + updated_at: now, + completed_at: now, + name: remoteSession.title, + repoPath: remoteSession.repoPath, + category: "external_history", + // No runnable model: the imported copy's composer is a FORK ENTRY, not a + // live agent. The source model is retained under importedFrom.sourceDisplay + // for read-only presentation, while leaving this field unset makes the + // composer ask which of the viewer's OWN local models/keys the fork should + // actually use. + model: undefined, + agentIconId: "archive", + agentDisplayName: "Collaboration Snapshot", + pinned: existing?.pinned ?? false, + // Ownership stamp (`Session.orgId`, distinct from `importedFrom.orgId` + // provenance — see sessionAtom/types.ts): filing the import under the + // org makes it match the sidebar org selector. Only MEMBER imports are + // stamped — the engine PullLoop and the panel replay both run in member + // context (org sync profile, no token). A share-token import is the + // GUEST path (CollabShareImportDialog, no local membership): it stays + // under Personal, i.e. no orgId (preserving any prior member stamp). + orgId: shareToken ? existing?.orgId : orgId, + importedFrom, + // Retire the legacy error_message idiom for collab imports; clears any + // leftover value on upgraded pre-M3 rows. + error_message: undefined, }; + // No await after the Rust transaction: the session row, guest registry and + // persisted list commit synchronously as one local critical section. + upsertSession(importedRow); + recordGuestImportedSession(importedRow); + persistSessions(store.get(sessionsAtom) as Session[]); + + const updated = + !cursor || + cursor.epoch !== committed.epoch || + cursor.seq !== committed.frozenSeq || + cursor.count !== committed.eventCount || + cursor.frozenCount !== committed.frozenEventCount || + (cursor.tailHash ?? null) !== committed.tailHash; + return { localSessionId, updated }; } diff --git a/src/features/TeamCollaboration/engine/collabSnapshotIngest.test.ts b/src/features/TeamCollaboration/engine/collabSnapshotIngest.test.ts new file mode 100644 index 000000000..faae90d58 --- /dev/null +++ b/src/features/TeamCollaboration/engine/collabSnapshotIngest.test.ts @@ -0,0 +1,367 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { COLLAB_IDENTITY_KIND } from "@src/store/collaboration/types"; +import type { RemoteTeammateSessionMetadata } from "@src/store/collaboration/types"; + +import type { + SessionEventWirePage, + SessionEventWirePageCursor, +} from "../sync/CollabSyncBackend"; +import { + SESSION_EVENT_WIRE_MAX_PAGE_BYTES, + SESSION_EVENT_WIRE_MAX_PAGE_SEGMENTS, +} from "../sync/CollabSyncBackend"; +import { ingestRemoteSnapshot } from "./collabSnapshotIngest"; + +const ingestRpc = vi.hoisted(() => ({ + begin: vi.fn(), + getCursor: vi.fn(), + apply: vi.fn(), + commit: vi.fn(), + abort: vi.fn(), +})); +const logger = vi.hoisted(() => ({ + warn: vi.fn(), +})); + +vi.mock("@src/api/tauri/collaborationSnapshotIngest", () => ({ + collaborationSnapshotIngestBegin: ingestRpc.begin, + collaborationSnapshotIngestGetCursor: ingestRpc.getCursor, + collaborationSnapshotIngestApplyWirePage: ingestRpc.apply, + collaborationSnapshotIngestCommit: ingestRpc.commit, + collaborationSnapshotIngestAbort: ingestRpc.abort, +})); +vi.mock("@src/hooks/logger", () => ({ + createLogger: () => logger, +})); + +const HASH = "a".repeat(64); +const TOKEN = "00000000-0000-4000-8000-000000000001"; + +function makeRemote( + overrides: Partial = {} +): RemoteTeammateSessionMetadata { + return { + id: "org-1:m2:remote-1", + orgId: "org-1", + ownerMemberId: "m2", + ownerUserId: "m2", + ownerDisplayName: "Bob", + ownerIdentityKind: COLLAB_IDENTITY_KIND.HUMAN, + sourceSessionId: "remote-1", + title: "Remote session", + lastActivityAt: "2026-07-01T00:00:00.000Z", + eventsEpoch: 3, + eventsFrozenSeq: 3, + eventsCount: 4, + eventsTailHash: HASH, + ...overrides, + }; +} + +function page( + cursor: SessionEventWirePageCursor, + overrides: Partial = {} +): SessionEventWirePage { + const seq = + cursor.direction === "backward" + ? (cursor.beforeSeq ?? 3) + : cursor.afterSeq + 1; + return { + epoch: 3, + frozenSeq: 3, + tailHash: HASH, + count: 4, + segments: [ + { + seq, + payloadGz: `opaque-${seq}`, + eventCount: 1, + segmentHash: HASH, + }, + ], + tailIncluded: false, + hasMore: false, + nextCursor: null, + returnedWireBytes: 128, + ...overrides, + }; +} + +function commit(localSessionId = "imported-session-local") { + return { + localSessionId, + epoch: 3, + frozenSeq: 3, + eventCount: 4, + frozenEventCount: 3, + tailHash: HASH, + handoffItems: [], + handoffScannedBytes: 0, + handoffScannedEvents: 0, + }; +} + +describe("ingestRemoteSnapshot", () => { + beforeEach(() => { + vi.clearAllMocks(); + ingestRpc.begin.mockResolvedValue({ token: TOKEN }); + ingestRpc.getCursor.mockResolvedValue(null); + ingestRpc.apply.mockResolvedValue({ + acceptedPhysicalRows: 1, + acceptedLogicalEvents: 1, + complete: true, + }); + ingestRpc.commit.mockResolvedValue(commit()); + ingestRpc.abort.mockResolvedValue(undefined); + }); + + it("streams a cold snapshot backward in bounded opaque pages", async () => { + const olderCursor = { direction: "backward", beforeSeq: 2 } as const; + const getSessionEventWirePage = vi + .fn() + .mockResolvedValueOnce( + page( + { direction: "backward" }, + { + tailIncluded: true, + hasMore: true, + nextCursor: olderCursor, + } + ) + ) + .mockResolvedValueOnce(page(olderCursor)); + + const result = await ingestRemoteSnapshot({ + client: { getSessionEventWirePage }, + orgId: "org-1", + remoteSession: makeRemote(), + localSessionId: "imported-session-local", + }); + + expect(result).toEqual(commit()); + expect(getSessionEventWirePage).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + cursor: { direction: "backward" }, + includeTail: true, + maxSegments: SESSION_EVENT_WIRE_MAX_PAGE_SEGMENTS, + maxWireBytes: SESSION_EVENT_WIRE_MAX_PAGE_BYTES, + }) + ); + expect(getSessionEventWirePage).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ cursor: olderCursor, includeTail: false }) + ); + expect(ingestRpc.begin).toHaveBeenCalledWith( + expect.objectContaining({ replace: true }) + ); + expect(ingestRpc.apply).toHaveBeenCalledTimes(2); + expect(ingestRpc.abort).not.toHaveBeenCalled(); + }); + + it("pins a forward delta to the advertised frozen high-water mark", async () => { + const getSessionEventWirePage = vi.fn(async ({ cursor }) => page(cursor)); + const previous = { + epoch: 3, + frozenSeq: 1, + count: 2, + frozenCount: 1, + tailHash: "b".repeat(64), + }; + ingestRpc.getCursor.mockResolvedValue(previous); + + await ingestRemoteSnapshot({ + client: { getSessionEventWirePage }, + orgId: "org-1", + remoteSession: makeRemote(), + localSessionId: "imported-session-local", + previous, + }); + + const cursor = { direction: "forward", afterSeq: 1, throughSeq: 3 }; + expect(getSessionEventWirePage).toHaveBeenCalledWith( + expect.objectContaining({ cursor, includeTail: true }) + ); + expect(ingestRpc.begin).toHaveBeenCalledWith( + expect.objectContaining({ replace: false, previous }) + ); + expect(ingestRpc.apply).toHaveBeenCalledWith( + expect.objectContaining({ cursor }) + ); + }); + + it("resets with a bounded rebuild when the remote epoch changed", async () => { + const getSessionEventWirePage = vi + .fn() + .mockResolvedValueOnce( + page({ direction: "forward", afterSeq: 1 }, { epoch: 4 }) + ) + .mockResolvedValueOnce(page({ direction: "backward" }, { epoch: 4 })); + + const previous = { + epoch: 3, + frozenSeq: 1, + count: 2, + frozenCount: 1, + tailHash: null, + }; + ingestRpc.getCursor.mockResolvedValue(previous); + await ingestRemoteSnapshot({ + client: { getSessionEventWirePage }, + orgId: "org-1", + remoteSession: makeRemote({ eventsEpoch: 4 }), + localSessionId: "imported-session-local", + previous, + }); + + expect(getSessionEventWirePage).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ cursor: { direction: "backward" } }) + ); + expect(ingestRpc.begin).toHaveBeenCalledWith( + expect.objectContaining({ epoch: 4, replace: true }) + ); + }); + + it("aborts a failed stage before retrying a bounded replacement", async () => { + ingestRpc.apply.mockRejectedValueOnce(new Error("local cursor mismatch")); + const getSessionEventWirePage = vi + .fn() + .mockResolvedValueOnce(page({ direction: "forward", afterSeq: 1 })) + .mockResolvedValueOnce(page({ direction: "backward" })); + + const previous = { + epoch: 3, + frozenSeq: 1, + count: 2, + frozenCount: 1, + tailHash: null, + }; + ingestRpc.getCursor.mockResolvedValue(previous); + await ingestRemoteSnapshot({ + client: { getSessionEventWirePage }, + orgId: "org-1", + remoteSession: makeRemote(), + localSessionId: "imported-session-local", + previous, + }); + + expect(ingestRpc.abort).toHaveBeenCalledWith(TOKEN); + expect(ingestRpc.begin).toHaveBeenLastCalledWith( + expect.objectContaining({ replace: true }) + ); + expect(logger.warn).toHaveBeenCalledWith( + "bounded Cloud replay delta failed; rebuilding the authoritative snapshot", + expect.objectContaining({ + localSessionId: "imported-session-local", + remoteSessionRowId: "org-1:m2:remote-1", + previousEpoch: 3, + remoteEpoch: 3, + error: "local cursor mismatch", + }) + ); + }); + + it("propagates cancellation and never starts a fallback generation", async () => { + const controller = new AbortController(); + const getSessionEventWirePage = vi.fn(async ({ cursor }) => { + controller.abort(); + return page(cursor); + }); + + await expect( + ingestRemoteSnapshot({ + client: { getSessionEventWirePage }, + orgId: "org-1", + remoteSession: makeRemote(), + localSessionId: "imported-session-local", + signal: controller.signal, + }) + ).rejects.toSatisfy( + (error: unknown) => + error instanceof DOMException && error.name === "AbortError" + ); + expect(ingestRpc.begin).toHaveBeenCalledTimes(1); + expect(ingestRpc.apply).not.toHaveBeenCalled(); + expect(ingestRpc.abort).toHaveBeenCalledWith(TOKEN); + }); + + it("returns an intact unchanged cursor with zero Cloud wire reads", async () => { + const previous = { + epoch: 3, + frozenSeq: 3, + count: 4, + frozenCount: 3, + tailHash: HASH, + }; + ingestRpc.getCursor.mockResolvedValue(previous); + const getSessionEventWirePage = vi.fn(); + + const result = await ingestRemoteSnapshot({ + client: { getSessionEventWirePage }, + orgId: "org-1", + remoteSession: makeRemote(), + localSessionId: "imported-session-local", + previous, + }); + + expect(result).toEqual(commit()); + expect(getSessionEventWirePage).not.toHaveBeenCalled(); + expect(ingestRpc.begin).not.toHaveBeenCalled(); + expect(ingestRpc.apply).not.toHaveBeenCalled(); + }); + + it("rebuilds from bounded backward pages when the local cursor is hollow", async () => { + ingestRpc.getCursor.mockResolvedValue(null); + const getSessionEventWirePage = vi.fn(async ({ cursor }) => page(cursor)); + + await ingestRemoteSnapshot({ + client: { getSessionEventWirePage }, + orgId: "org-1", + remoteSession: makeRemote(), + localSessionId: "imported-session-local", + previous: { + epoch: 3, + frozenSeq: 3, + count: 4, + frozenCount: 3, + tailHash: HASH, + }, + }); + + expect(getSessionEventWirePage).toHaveBeenCalledWith( + expect.objectContaining({ cursor: { direction: "backward" } }) + ); + expect(ingestRpc.begin).toHaveBeenCalledWith( + expect.objectContaining({ replace: true }) + ); + }); + + it("fails atomically if a later page changes snapshot identity", async () => { + const olderCursor = { direction: "backward", beforeSeq: 2 } as const; + const getSessionEventWirePage = vi + .fn() + .mockResolvedValueOnce( + page( + { direction: "backward" }, + { + hasMore: true, + nextCursor: olderCursor, + } + ) + ) + .mockResolvedValueOnce(page(olderCursor, { count: 5 })); + + await expect( + ingestRemoteSnapshot({ + client: { getSessionEventWirePage }, + orgId: "org-1", + remoteSession: makeRemote(), + localSessionId: "imported-session-local", + }) + ).rejects.toThrow("snapshot changed while paging"); + expect(ingestRpc.commit).not.toHaveBeenCalled(); + expect(ingestRpc.abort).toHaveBeenCalledWith(TOKEN); + }); +}); diff --git a/src/features/TeamCollaboration/engine/collabSnapshotIngest.ts b/src/features/TeamCollaboration/engine/collabSnapshotIngest.ts new file mode 100644 index 000000000..772fe55b4 --- /dev/null +++ b/src/features/TeamCollaboration/engine/collabSnapshotIngest.ts @@ -0,0 +1,259 @@ +/** + * Byte-bounded Cloud snapshot import orchestration. + * + * Cloud returns opaque gzip wire rows a page at a time. The renderer forwards + * each page once to Rust; it never decodes, rewrites, or assembles a + * session-sized `SessionEvent[]`. + */ +import { + type CollaborationSnapshotCursor, + type CollaborationSnapshotIngestCommitResult, + collaborationSnapshotIngestAbort, + collaborationSnapshotIngestApplyWirePage, + collaborationSnapshotIngestBegin, + collaborationSnapshotIngestCommit, + collaborationSnapshotIngestGetCursor, +} from "@src/api/tauri/collaborationSnapshotIngest"; +import { createLogger } from "@src/hooks/logger"; +import type { RemoteTeammateSessionMetadata } from "@src/store/collaboration/types"; + +import type { + CollabSyncBackendClient, + SessionEventWirePage, + SessionEventWirePageCursor, +} from "../sync/CollabSyncBackend"; +import { + SESSION_EVENT_WIRE_MAX_PAGE_BYTES, + SESSION_EVENT_WIRE_MAX_PAGE_SEGMENTS, +} from "../sync/CollabSyncBackend"; + +const log = createLogger("collabSnapshotIngest"); + +export interface RemoteSnapshotIngestOptions { + client: Pick; + orgId: string; + remoteSession: RemoteTeammateSessionMetadata; + localSessionId: string; + previous?: CollaborationSnapshotCursor; + shareToken?: string; + signal?: AbortSignal; +} + +function throwIfAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted) { + throw signal.reason ?? new DOMException("Aborted", "AbortError"); + } +} + +function isAbortError(error: unknown): boolean { + return error instanceof DOMException && error.name === "AbortError"; +} + +function pageHasSnapshot( + page: SessionEventWirePage +): page is SessionEventWirePage & { + epoch: number; + frozenSeq: number; + count: number; +} { + return page.epoch !== null && page.frozenSeq !== null && page.count !== null; +} + +function pageMatchesPrevious( + page: SessionEventWirePage & { + epoch: number; + frozenSeq: number; + count: number; + }, + previous: CollaborationSnapshotCursor +): boolean { + return ( + page.epoch === previous.epoch && + page.frozenSeq >= previous.frozenSeq && + page.count >= previous.frozenCount + ); +} + +function cursorMatchesRemoteSummary( + cursor: CollaborationSnapshotCursor, + remoteSession: RemoteTeammateSessionMetadata +): boolean { + return ( + remoteSession.eventsEpoch === cursor.epoch && + (remoteSession.eventsFrozenSeq ?? 0) === cursor.frozenSeq && + remoteSession.eventsCount === cursor.count && + (remoteSession.eventsTailHash ?? null) === cursor.tailHash + ); +} + +function unchangedCommit( + localSessionId: string, + cursor: CollaborationSnapshotCursor +): CollaborationSnapshotIngestCommitResult { + return { + localSessionId, + epoch: cursor.epoch, + frozenSeq: cursor.frozenSeq, + eventCount: cursor.count, + frozenEventCount: cursor.frozenCount, + tailHash: cursor.tailHash, + handoffItems: [], + handoffScannedBytes: 0, + handoffScannedEvents: 0, + }; +} + +async function fetchWirePage( + options: RemoteSnapshotIngestOptions, + cursor: SessionEventWirePageCursor, + includeTail: boolean +): Promise { + throwIfAborted(options.signal); + return options.client.getSessionEventWirePage({ + orgId: options.orgId, + sessionRowId: options.remoteSession.id, + cursor, + includeTail, + maxSegments: SESSION_EVENT_WIRE_MAX_PAGE_SEGMENTS, + maxWireBytes: SESSION_EVENT_WIRE_MAX_PAGE_BYTES, + ...(options.shareToken !== undefined + ? { shareToken: options.shareToken } + : {}), + ...(options.signal !== undefined ? { signal: options.signal } : {}), + }); +} + +async function ingestPageChain( + options: RemoteSnapshotIngestOptions, + firstPage: SessionEventWirePage & { + epoch: number; + frozenSeq: number; + count: number; + }, + firstCursor: SessionEventWirePageCursor, + replace: boolean, + previous?: CollaborationSnapshotCursor +): Promise { + const { token } = await collaborationSnapshotIngestBegin({ + localSessionId: options.localSessionId, + epoch: firstPage.epoch, + expectedCount: firstPage.count, + expectedFrozenSeq: firstPage.frozenSeq, + tailHash: firstPage.tailHash, + replace, + ...(previous !== undefined ? { previous } : {}), + }); + let committed = false; + try { + let page = firstPage; + let cursor = firstCursor; + for (;;) { + throwIfAborted(options.signal); + await collaborationSnapshotIngestApplyWirePage({ + token, + epoch: page.epoch, + frozenSeq: page.frozenSeq, + count: page.count, + tailHash: page.tailHash, + cursor, + nextCursor: page.nextCursor, + tailIncluded: page.tailIncluded, + hasMore: page.hasMore, + returnedWireBytes: page.returnedWireBytes, + segments: page.segments, + }); + if (!page.hasMore) break; + const nextCursor = page.nextCursor; + if (!nextCursor) { + throw new Error("Cloud replay page hasMore without a continuation"); + } + cursor = nextCursor; + const fetchedPage = await fetchWirePage(options, cursor, false); + if ( + !pageHasSnapshot(fetchedPage) || + fetchedPage.epoch !== firstPage.epoch || + fetchedPage.frozenSeq !== firstPage.frozenSeq || + fetchedPage.count !== firstPage.count || + fetchedPage.tailHash !== firstPage.tailHash + ) { + throw new Error("Cloud replay snapshot changed while paging"); + } + page = fetchedPage; + } + throwIfAborted(options.signal); + const result = await collaborationSnapshotIngestCommit(token); + committed = true; + return result; + } finally { + if (!committed) { + await collaborationSnapshotIngestAbort(token).catch(() => undefined); + } + } +} + +async function replaceSnapshot( + options: RemoteSnapshotIngestOptions +): Promise { + const cursor: SessionEventWirePageCursor = { direction: "backward" }; + const firstPage = await fetchWirePage(options, cursor, true); + if (!pageHasSnapshot(firstPage)) return null; + return ingestPageChain(options, firstPage, cursor, true); +} + +/** + * Import one authoritative snapshot with bounded memory. + * + * A compatible local generation first attempts a forward delta. Any epoch or + * local-state mismatch retries as a byte-bounded backward rebuild; it never + * falls back to the legacy decoded full-history API. + */ +export async function ingestRemoteSnapshot( + options: RemoteSnapshotIngestOptions +): Promise { + const cursorHint = options.previous; + if (!cursorHint) return replaceSnapshot(options); + + // The renderer's importedFrom cursor is only a hint. Trust the lightweight + // Rust health query, which also proves the mapped event rows still exist. + // An unchanged healthy snapshot performs zero Cloud wire reads; a hollow + // or corrupt local snapshot is rebuilt instead of splicing onto bad state. + throwIfAborted(options.signal); + const previous = await collaborationSnapshotIngestGetCursor( + options.localSessionId + ); + throwIfAborted(options.signal); + if (!previous) return replaceSnapshot(options); + if (cursorMatchesRemoteSummary(previous, options.remoteSession)) { + return unchangedCommit(options.localSessionId, previous); + } + + const cursor: SessionEventWirePageCursor = { + direction: "forward", + afterSeq: previous.frozenSeq, + ...(options.remoteSession.eventsFrozenSeq !== undefined + ? { throughSeq: options.remoteSession.eventsFrozenSeq } + : {}), + }; + const firstPage = await fetchWirePage(options, cursor, true); + if (!pageHasSnapshot(firstPage)) return null; + if (!pageMatchesPrevious(firstPage, previous)) { + return replaceSnapshot(options); + } + + try { + return await ingestPageChain(options, firstPage, cursor, false, previous); + } catch (error) { + if (isAbortError(error) || options.signal?.aborted) throw error; + log.warn( + "bounded Cloud replay delta failed; rebuilding the authoritative snapshot", + { + localSessionId: options.localSessionId, + remoteSessionRowId: options.remoteSession.id, + previousEpoch: previous.epoch, + remoteEpoch: firstPage.epoch, + error: error instanceof Error ? error.message : String(error), + } + ); + return replaceSnapshot(options); + } +} diff --git a/src/features/TeamCollaboration/engine/collabSyncEngineHelpers.test.ts b/src/features/TeamCollaboration/engine/collabSyncEngineHelpers.test.ts index 0b30e0f4d..5fd13dac9 100644 --- a/src/features/TeamCollaboration/engine/collabSyncEngineHelpers.test.ts +++ b/src/features/TeamCollaboration/engine/collabSyncEngineHelpers.test.ts @@ -17,13 +17,7 @@ import { createInstrumentedStore } from "@src/util/core/state/instrumentedStore" import { FORK_SNAPSHOT_ERROR_KIND, ForkSnapshotIntegrityError, - SegmentIntegrityError, } from "../forkSnapshotIntegrity"; -import type { - CollabSyncBackendClient, - SessionEventSegmentsSnapshot, -} from "../sync/CollabSyncBackend"; -import { computeSegmentHash } from "../sync/collabGzip"; import { deriveImportedSessionId, forkSession, @@ -32,15 +26,18 @@ import { splitFrozenIntoSegments, } from "./collabSyncEngineHelpers"; +const ingestRemoteSnapshotMock = vi.hoisted(() => vi.fn()); + +vi.mock("./collabSnapshotIngest", () => ({ + ingestRemoteSnapshot: ingestRemoteSnapshotMock, +})); + vi.mock("@src/engines/SessionCore/core/store/EventStoreProxy", () => ({ eventStoreProxy: { subscribe: vi.fn(), getEvents: vi.fn(), getPersistedEvents: vi.fn(), set: vi.fn(), - persistEventsBatch: vi.fn(), - finalizePersistedImport: vi.fn(), - loadInitialTurnWindow: vi.fn(), saveToCache: vi.fn(), clear: vi.fn(), clearPersistedHistory: vi.fn(), @@ -56,37 +53,61 @@ const indexCollaborationSessionMock = vi.mocked( indexOrgtrackCollaborationSession ); -async function sealSnapshot( - snapshot: SessionEventSegmentsSnapshot -): Promise { - const segments = await Promise.all( - snapshot.segments.map(async (segment) => ({ - ...segment, - segmentHash: await computeSegmentHash(segment.events), - })) - ); - const tailHash = - segments.find((segment) => segment.isTail)?.segmentHash ?? - snapshot.tailHash; - return { ...snapshot, tailHash, segments }; +function makeRemote( + overrides: Partial = {} +): RemoteTeammateSessionMetadata { + return { + id: "org-1:m2:remote-1", + orgId: "org-1", + ownerMemberId: "m2", + ownerUserId: "m2", + ownerDisplayName: "Bob", + ownerIdentityKind: COLLAB_IDENTITY_KIND.HUMAN, + sourceSessionId: "remote-1", + title: "Remote session", + repoPath: "/owner/remote/repo", + lastActivityAt: "2026-07-01T00:00:00.000Z", + eventsEpoch: 3, + eventsFrozenSeq: 2, + eventsCount: 5, + eventsTailHash: "a".repeat(64), + ...overrides, + }; +} + +function makeCommit( + localSessionId: string, + overrides: Record = {} +) { + return { + localSessionId, + epoch: 3, + frozenSeq: 2, + eventCount: 5, + frozenEventCount: 4, + tailHash: "a".repeat(64), + handoffItems: ["User: investigate memory", "Assistant: bounded replay"], + handoffScannedBytes: 128, + handoffScannedEvents: 5, + ...overrides, + }; +} + +function makeWireClient() { + return { getSessionEventWirePage: vi.fn() }; } describe("isCollabConflictError (both backends' OCC rejection)", () => { - it("matches the self-hosted ORGII_CONFLICT", () => { + it("matches both backend conflict markers", () => { expect(isCollabConflictError(new Error("ORGII_CONFLICT"))).toBe(true); - // PostgREST wraps the raise message; substring match is deliberate. expect( isCollabConflictError(new Error("P0001: ORGII_CONFLICT at line 3")) ).toBe(true); - }); - - it("matches the managed-cloud ORG2_CONFLICT (cloud-parity Phase B)", () => { expect(isCollabConflictError(new Error("ORG2_CONFLICT"))).toBe(true); }); - it("rejects other errors and non-Error values", () => { + it("rejects unrelated errors and non-Error values", () => { expect(isCollabConflictError(new Error("ORG2_FORBIDDEN"))).toBe(false); - expect(isCollabConflictError(new Error("ORGII_UNAUTHORIZED"))).toBe(false); expect(isCollabConflictError("ORG2_CONFLICT")).toBe(false); expect(isCollabConflictError(undefined)).toBe(false); }); @@ -104,24 +125,14 @@ describe("splitFrozenIntoSegments 256KB packing", () => { } as unknown as SessionEvent; } - it("packs a >256KB event stream into multiple ≤256KB segments that round-trip", () => { - // ~50KB per event so a handful crosses the 256KB cap and forces >1 segment. + it("packs a large stream into ordered bounded segments that round-trip", () => { const bigPayload = "x".repeat(50 * 1024); const events = Array.from({ length: 12 }, (_unused, index) => makeEvent(`e${index}`, bigPayload) ); - const totalBytes = events.reduce( - (sum, event) => sum + JSON.stringify(event).length, - 0 - ); - expect(totalBytes).toBeGreaterThan(SEGMENT_MAX_BYTES); - const segments = splitFrozenIntoSegments(events, 1); - // More than one frozen segment was produced. expect(segments.length).toBeGreaterThan(1); - // Each segment is within the byte cap (an event's own size can be counted, - // but no segment packs beyond the cap once it holds >1 event). for (const segment of segments) { const segmentBytes = segment.events.reduce( (sum, event) => sum + JSON.stringify(event).length, @@ -129,42 +140,28 @@ describe("splitFrozenIntoSegments 256KB packing", () => { ); expect(segmentBytes).toBeLessThanOrEqual(SEGMENT_MAX_BYTES); } - // Seqs are contiguous from the requested start. expect(segments.map((segment) => segment.seq)).toEqual( - segments.map((_unused, index) => 1 + index) + segments.map((_unused, index) => index + 1) ); - // Concatenating the segments' events round-trips the full input in order. - const flattened = segments.flatMap((segment) => segment.events); - expect(flattened.map((event) => event.id)).toEqual( - events.map((event) => event.id) - ); - expect(flattened).toEqual(events); + expect(segments.flatMap((segment) => segment.events)).toEqual(events); }); - it("ships an oversized single event as its own segment (never drops it)", () => { - // A single event larger than the cap must still ship — at least one event - // per segment (design §7.3 step 3a). + it("keeps one oversized event instead of dropping it", () => { const oversized = makeEvent("huge", "y".repeat(SEGMENT_MAX_BYTES + 1_000)); const segments = splitFrozenIntoSegments([oversized], 5); expect(segments).toHaveLength(1); expect(segments[0].seq).toBe(5); - expect(segments[0].events).toHaveLength(1); expect(segments[0].events[0].id).toBe("huge"); }); - it("budgets by UTF-8 bytes, not UTF-16 length (CJK regression)", () => { - // Each CJK char is 1 UTF-16 code unit but 3 UTF-8 bytes. A length-based - // budget would pack ~3× over the wire cap for CJK-heavy transcripts. - const cjkPayload = "汉".repeat(60 * 1024); // ~180 KiB UTF-8, 60 K length + it("budgets by UTF-8 bytes rather than UTF-16 code units", () => { + const cjkPayload = "汉".repeat(60 * 1024); const events = Array.from({ length: 6 }, (_unused, index) => makeEvent(`cjk${index}`, cjkPayload) ); const encoder = new TextEncoder(); - const segments = splitFrozenIntoSegments(events, 1); - // UTF-16 budgeting would fit 4 events per segment (~240K length) and - // produce 2 segments; UTF-8 budgeting fits 1 per segment. expect(segments.length).toBeGreaterThanOrEqual(6); for (const segment of segments) { if (segment.events.length <= 1) continue; @@ -178,1151 +175,350 @@ describe("splitFrozenIntoSegments 256KB packing", () => { }); describe("deriveImportedSessionId", () => { - it("is deterministic per (endpoint, orgId, sourceSessionId) and keeps the imported-session prefix", async () => { + it("is deterministic per endpoint/org/source and isolates deployments", async () => { const first = await deriveImportedSessionId( "org-1", "remote-1", "https://cloud-a.example.com/" ); - const second = await deriveImportedSessionId( + const same = await deriveImportedSessionId( "org-1", "remote-1", "https://cloud-a.example.com" ); - const otherSession = await deriveImportedSessionId("org-1", "remote-2"); - const otherOrg = await deriveImportedSessionId("org-2", "remote-1"); const otherEndpoint = await deriveImportedSessionId( "org-1", "remote-1", "https://cloud-b.example.com" ); - expect(first).toBe(second); + expect(first).toBe(same); expect(first).toMatch(/^imported-session-[0-9a-f]{32}$/); - expect(otherSession).not.toBe(first); - expect(otherOrg).not.toBe(first); expect(otherEndpoint).not.toBe(first); }); }); -describe("importRemoteSession", () => { +describe("importRemoteSession bounded snapshot publication", () => { const store = createInstrumentedStore(); - function makeRemote( - overrides: Partial = {} - ): RemoteTeammateSessionMetadata { - return { - id: "org-1:m2:remote-1", - orgId: "org-1", - ownerMemberId: "m2", - ownerUserId: "m2", - ownerDisplayName: "Bob", - ownerIdentityKind: COLLAB_IDENTITY_KIND.HUMAN, - sourceSessionId: "remote-1", - title: "Remote session", - repoPath: "/repo/shared", - lastActivityAt: "2026-07-01T00:00:00.000Z", - eventsEpoch: 1, - eventsFrozenSeq: 1, - eventsCount: 1, - eventsTailHash: undefined, - ...overrides, - }; - } - - function makeSnapshot(): SessionEventSegmentsSnapshot { - return { - epoch: 1, - frozenSeq: 1, - tailHash: null, - count: 1, - segments: [ - { - seq: 1, - isTail: false, - events: [ - { - id: "e1", - sessionId: "remote-1", - displayStatus: "completed", - } as unknown as SessionEvent, - ], - eventCount: 1, - segmentHash: "h1", - }, - ], - }; - } - beforeEach(() => { vi.clearAllMocks(); store.set(sessionsAtom, []); localStorage.removeItem( __GUEST_IMPORT_REGISTRY_INTERNALS.GUEST_IMPORT_REGISTRY_STORAGE_KEY ); - eventStoreMock.set.mockResolvedValue(undefined); - eventStoreMock.clear.mockResolvedValue(undefined); eventStoreMock.clearPersistedHistory.mockResolvedValue(undefined); - eventStoreMock.getPersistedEvents.mockResolvedValue([]); - eventStoreMock.persistEventsBatch.mockImplementation( - async (events: SessionEvent[]) => events.length - ); - eventStoreMock.finalizePersistedImport.mockResolvedValue(3); - eventStoreMock.loadInitialTurnWindow.mockResolvedValue(1); - eventStoreMock.saveToCache.mockResolvedValue(1); indexCollaborationSessionMock.mockResolvedValue(0); + ingestRemoteSnapshotMock.mockImplementation( + async ({ localSessionId }: { localSessionId: string }) => + makeCommit(localSessionId) + ); }); - it("indexes an authorized replay against the viewer's checkout", async () => { - const client = { - getSessionEventSegments: vi.fn(async () => sealSnapshot(makeSnapshot())), - } satisfies Pick; - const remote = makeRemote(); - - const result = await importRemoteSession({ - client, - orgId: "org-1", - remoteSession: remote, - workspaceRepoPath: "/viewer/ORG2", - }); - - expect(indexCollaborationSessionMock).toHaveBeenCalledWith({ - localSessionId: result?.localSessionId, - sourceSessionId: remote.sourceSessionId, - title: remote.title, - workspacePath: "/viewer/ORG2", - sourceWorkspacePath: remote.repoPath, - orgId: "org-1", - sessionRowId: remote.id, - ownerMemberId: remote.ownerMemberId, - ownerDisplayName: remote.ownerDisplayName, - }); - }); - - it("streams a fresh replay into bounded durable batches without assembling the full history", async () => { - const pageOne = await sealSnapshot({ - epoch: 3, - frozenSeq: 2, - tailHash: null, - count: 3, - segments: [ - { - seq: 1, - isTail: false, - events: [ - { - id: "e1", - sessionId: "remote-1", - displayStatus: "completed", - } as unknown as SessionEvent, - ], - eventCount: 1, - segmentHash: "", - }, - ], - }); - const pageTwo = await sealSnapshot({ - epoch: 3, - frozenSeq: 2, - tailHash: null, - count: 3, - segments: [ - { - seq: 2, - isTail: false, - events: [ - { - id: "e2", - sessionId: "remote-1", - displayStatus: "completed", - } as unknown as SessionEvent, - ], - eventCount: 1, - segmentHash: "", - }, - { - seq: 0, - isTail: true, - events: [ - { - id: "e3", - sessionId: "remote-1", - displayStatus: "completed", - } as unknown as SessionEvent, - ], - eventCount: 1, - segmentHash: "", - }, - ], - }); - const client = { - getSessionEventSegments: vi.fn(), - streamSessionEventSegments: vi.fn( - async ( - _input: unknown, - onPage: (page: SessionEventSegmentsSnapshot) => Promise - ) => { - await onPage(pageOne); - await onPage(pageTwo); - return { - epoch: 3, - frozenSeq: 2, - count: 3, - tailHash: pageTwo.tailHash, - }; - } - ), - } satisfies Pick< - CollabSyncBackendClient, - "getSessionEventSegments" | "streamSessionEventSegments" - >; - + it("publishes a deterministic read-only row after Rust commits the snapshot", async () => { + const client = makeWireClient(); + const onBeforeWrite = vi.fn(); const result = await importRemoteSession({ client, orgId: "org-1", remoteSession: makeRemote({ - eventsEpoch: 3, - eventsFrozenSeq: 2, - eventsCount: 3, - eventsTailHash: pageTwo.tailHash ?? undefined, + cliAgentType: "codex", + agentDisplayName: "Codex App", + agentDefinitionId: "codex-app", + model: "gpt-5.6-sol", + origin: { kind: "external_history", source: "codex_app" }, }), - workspaceRepoPath: "/viewer/ORG2", + sourceEndpointUrl: "https://cloud.example.com/", + onBeforeWrite, }); - expect(result).toMatchObject({ updated: true, deferIndex: true }); - expect(client.getSessionEventSegments).not.toHaveBeenCalled(); - expect(eventStoreMock.persistEventsBatch).toHaveBeenCalledTimes(2); - expect( - eventStoreMock.persistEventsBatch.mock.calls.map(([events]) => - events.map((event) => event.id) - ) - ).toEqual([ - [expect.stringContaining("e1")], - [expect.stringContaining("e2"), expect.stringContaining("e3")], - ]); - expect(eventStoreMock.set).not.toHaveBeenCalled(); - expect(eventStoreMock.saveToCache).not.toHaveBeenCalled(); - expect(eventStoreMock.finalizePersistedImport).toHaveBeenCalledWith( - result?.localSessionId - ); - expect(eventStoreMock.loadInitialTurnWindow).toHaveBeenCalledWith( - result?.localSessionId, - 0 - ); - expect(indexCollaborationSessionMock).not.toHaveBeenCalled(); - }); - - it("rejects on a failed durable write, clears the orphan, and reuses the deterministic id on retry", async () => { - const client = { - getSessionEventSegments: vi.fn(async () => sealSnapshot(makeSnapshot())), - } satisfies Pick; - // The durable cache write fails (transient SQLite lock → swallowed → 0). - eventStoreMock.saveToCache.mockResolvedValueOnce(0); - - await expect( - importRemoteSession({ + expect(result?.updated).toBe(true); + expect(result?.localSessionId).toMatch(/^imported-session-/); + expect(onBeforeWrite).toHaveBeenCalledWith(result?.localSessionId); + expect(ingestRemoteSnapshotMock).toHaveBeenCalledWith( + expect.objectContaining({ client, - orgId: "org-1", - remoteSession: makeRemote(), + localSessionId: result?.localSessionId, }) - ).rejects.toThrow(/durably persist/); - - const expectedId = await deriveImportedSessionId("org-1", "remote-1"); - // The events landed on the deterministic id and the orphaned store - // entry was removed again (no session record points at it). - expect(eventStoreMock.set).toHaveBeenCalledTimes(1); - expect(eventStoreMock.set.mock.calls[0][1]).toBe(expectedId); - expect(eventStoreMock.clear).toHaveBeenCalledWith(expectedId); - expect(store.get(sessionsAtom)).toHaveLength(0); - - // The retry lands on the SAME id — one orphan slot, not one per cycle. - const result = await importRemoteSession({ - client, - orgId: "org-1", - remoteSession: makeRemote(), - }); - expect(result?.localSessionId).toBe(expectedId); - expect(result?.updated).toBe(true); - expect(eventStoreMock.set).toHaveBeenCalledTimes(2); - expect(eventStoreMock.set.mock.calls[1][1]).toBe(expectedId); - }); - - it("re-fetches a hollow cache: matching cursor but empty local event store", async () => { - const client = { - getSessionEventSegments: vi.fn(async () => sealSnapshot(makeSnapshot())), - } satisfies Pick; - const expectedId = await deriveImportedSessionId("org-1", "remote-1"); - store.set(sessionsAtom, [ - { - session_id: expectedId, - name: "Remote session", - importedFrom: { - orgId: "org-1", - sourceSessionId: "remote-1", - ownerMemberId: "m2", - ownerDisplayName: "Bob", - epoch: 1, - seq: 1, - count: 1, - frozenCount: 1, - tailHash: undefined, - importedAt: "2026-07-01T00:00:00.000Z", - }, - } as unknown as Session, - ]); - // Event data lost (restart/cleanup churn) while the cursor still matches. - eventStoreMock.getPersistedEvents.mockResolvedValue([]); - - const result = await importRemoteSession({ - client, - orgId: "org-1", - remoteSession: makeRemote(), - }); - - // Not the cursor no-op: the hollow cache must trigger a full refetch. - expect(result?.updated).toBe(true); - expect(result?.localSessionId).toBe(expectedId); - expect(client.getSessionEventSegments).toHaveBeenCalled(); - expect(eventStoreMock.set).toHaveBeenCalledTimes(1); - }); - - it("keeps the cursor no-op when the local event store still holds the events", async () => { - const client = { - getSessionEventSegments: vi.fn(async () => sealSnapshot(makeSnapshot())), - } satisfies Pick; - const expectedId = await deriveImportedSessionId("org-1", "remote-1"); - store.set(sessionsAtom, [ - { - session_id: expectedId, - name: "Remote session", - importedFrom: { - orgId: "org-1", - sourceSessionId: "remote-1", - ownerMemberId: "m2", - ownerDisplayName: "Bob", - epoch: 1, - seq: 1, - count: 1, - frozenCount: 1, - tailHash: undefined, - importedAt: "2026-07-01T00:00:00.000Z", - }, - } as unknown as Session, - ]); - eventStoreMock.getPersistedEvents.mockResolvedValue([ - { id: "e1" } as unknown as SessionEvent, - ]); - - const result = await importRemoteSession({ - client, - orgId: "org-1", - remoteSession: makeRemote(), + ); + const row = (store.get(sessionsAtom) as Session[])[0]; + expect(row.category).toBe("external_history"); + expect(row.orgId).toBe("org-1"); + expect(row.importedFrom).toMatchObject({ + sourceSessionId: "remote-1", + sourceEndpointUrl: "https://cloud.example.com", + externalHistorySource: "codex_app", + sourceDisplay: { + cliAgentType: "codex", + agentDisplayName: "Codex App", + agentDefinitionId: "codex-app", + model: "gpt-5.6-sol", + }, + epoch: 3, + seq: 2, + count: 5, + frozenCount: 4, }); - - expect(result?.updated).toBe(false); - expect(client.getSessionEventSegments).not.toHaveBeenCalled(); + expect(row.model).toBeUndefined(); + expect(eventStoreMock.getPersistedEvents).not.toHaveBeenCalled(); }); - it("refreshes source display metadata without refetching unchanged events", async () => { - const client = { - getSessionEventSegments: vi.fn(async () => sealSnapshot(makeSnapshot())), - } satisfies Pick; - const expectedId = await deriveImportedSessionId("org-1", "remote-1"); + it("passes the compact cursor for a delta and reports an unchanged commit", async () => { + const client = makeWireClient(); + const localSessionId = await deriveImportedSessionId( + "org-1", + "remote-1", + "https://cloud.example.com" + ); store.set(sessionsAtom, [ { - session_id: expectedId, - status: "completed", + session_id: localSessionId, created_at: "2026-07-01T00:00:00.000Z", - updated_at: "2026-07-01T00:00:00.000Z", - name: "Remote session", - model: undefined, - agentIconId: "archive", - agentDisplayName: "Collaboration Snapshot", importedFrom: { orgId: "org-1", sourceSessionId: "remote-1", + sourceEndpointUrl: "https://cloud.example.com", ownerMemberId: "m2", ownerDisplayName: "Bob", - epoch: 1, - seq: 1, - count: 1, - frozenCount: 1, - tailHash: undefined, + epoch: 3, + seq: 2, + count: 5, + frozenCount: 4, + tailHash: "a".repeat(64), importedAt: "2026-07-01T00:00:00.000Z", }, - }, - ]); - eventStoreMock.getPersistedEvents.mockResolvedValue([ - { id: "e1" } as unknown as SessionEvent, + } as Session, ]); - const result = await importRemoteSession({ - client, - orgId: "org-1", - remoteSession: makeRemote({ - cliAgentType: "codex", - agentDisplayName: "Codex App", - model: "gpt-5.6-sol", - origin: { kind: "external_history", source: "codex_app" }, - }), - }); - const record = (store.get(sessionsAtom) as Session[]).find( - (session) => session.session_id === expectedId - ); - - expect(result?.updated).toBe(false); - expect(client.getSessionEventSegments).not.toHaveBeenCalled(); - expect(record?.model).toBeUndefined(); - expect(record?.importedFrom).toMatchObject({ - externalHistorySource: "codex_app", - sourceDisplay: { - cliAgentType: "codex", - agentDisplayName: "Codex App", - model: "gpt-5.6-sol", - }, - }); - expect(record).toMatchObject({ - agentDisplayName: "Codex App", - agentIconId: "codex", - }); - }); - - it("stamps Session.orgId on a MEMBER import so the sidebar org filter matches", async () => { - const client = { - getSessionEventSegments: vi.fn(async () => sealSnapshot(makeSnapshot())), - } satisfies Pick; - - // Member context: engine PullLoop / panel replay — org profile, no token. const result = await importRemoteSession({ client, orgId: "org-1", remoteSession: makeRemote(), - }); - - const record = (store.get(sessionsAtom) as Session[]).find( - (session) => session.session_id === result!.localSessionId - )!; - // Ownership stamp (sidebar filter) AND provenance both carry the org. - expect(record.orgId).toBe("org-1"); - expect(record.importedFrom?.orgId).toBe("org-1"); - expect(record.importedFrom?.shareToken).toBeUndefined(); - }); - - it("preserves an external app source on the local replay provenance", async () => { - const client = { - getSessionEventSegments: vi.fn(async () => sealSnapshot(makeSnapshot())), - } satisfies Pick; - - const result = await importRemoteSession({ - client, - orgId: "org-1", - remoteSession: makeRemote({ - cliAgentType: "codex", - agentDisplayName: "Codex App", - agentDefinitionId: "codex-app", - model: "gpt-5.6-sol", - origin: { kind: "external_history", source: "codex_app" }, - }), - }); - const record = (store.get(sessionsAtom) as Session[]).find( - (session) => session.session_id === result!.localSessionId - ); - - expect(record?.importedFrom?.externalHistorySource).toBe("codex_app"); - expect(record?.importedFrom?.sourceDisplay).toEqual({ - cliAgentType: "codex", - agentDisplayName: "Codex App", - agentDefinitionId: "codex-app", - model: "gpt-5.6-sol", - }); - expect(record).toMatchObject({ - agentDisplayName: "Codex App", - agentIconId: "codex", - }); - expect(record?.model).toBeUndefined(); - }); - - it("keeps a named ORGII agent identity when opening creates the local replay", async () => { - const client = { - getSessionEventSegments: vi.fn(async () => sealSnapshot(makeSnapshot())), - } satisfies Pick; - - const result = await importRemoteSession({ - client, - orgId: "org-1", - remoteSession: makeRemote({ - agentDisplayName: "Agent Architect", - agentDefinitionId: "builtin:agent-architect", - model: "gpt-5.6-sol", - origin: { kind: "orgii" }, - }), - }); - const record = (store.get(sessionsAtom) as Session[]).find( - (session) => session.session_id === result!.localSessionId - ); - - expect(record).toMatchObject({ - agentDisplayName: "ORG2", - agentIconId: "orgii", - model: undefined, - importedFrom: { - sourceDisplay: { - agentDisplayName: "Agent Architect", - agentDefinitionId: "builtin:agent-architect", - model: "gpt-5.6-sol", + sourceEndpointUrl: "https://cloud.example.com", + }); + + expect(result).toEqual({ localSessionId, updated: false }); + expect(ingestRemoteSnapshotMock).toHaveBeenCalledWith( + expect.objectContaining({ + previous: { + epoch: 3, + frozenSeq: 2, + count: 5, + frozenCount: 4, + tailHash: "a".repeat(64), }, - }, - }); - }); - - it("leaves Session.orgId unset on a GUEST share-token import (stays under Personal)", async () => { - const client = { - getSessionEventSegments: vi.fn(async () => sealSnapshot(makeSnapshot())), - } satisfies Pick; - - // Guest context: CollabShareImportDialog — the share token authenticates, - // there is no local membership of org-1. - const result = await importRemoteSession({ - client, - orgId: "org-1", - remoteSession: makeRemote(), - shareToken: "share-token", - shareEndpointUrl: "https://cloud.example.com", - }); - - const record = (store.get(sessionsAtom) as Session[]).find( - (session) => session.session_id === result!.localSessionId - )!; - // No ownership stamp — the import groups under Personal in the sidebar. - expect(record.orgId).toBeUndefined(); - // Provenance still records the origin org. - expect(record.importedFrom?.orgId).toBe("org-1"); - expect(record.importedFrom?.shareToken).toBe("share-token"); - expect(record.importedFrom?.shareEndpointUrl).toBe( - "https://cloud.example.com" + }) ); + expect(eventStoreMock.getPersistedEvents).not.toHaveBeenCalled(); }); - it("guest import survives an authoritative list replace via the registry", async () => { - const client = { - getSessionEventSegments: vi.fn(async () => sealSnapshot(makeSnapshot())), - } satisfies Pick; - + it("keeps guest imports personal and restores their capability registry", async () => { const result = await importRemoteSession({ - client, + client: makeWireClient(), orgId: "org-1", remoteSession: makeRemote(), shareToken: "share-token", shareEndpointUrl: "https://cloud.example.com", }); + const row = (store.get(sessionsAtom) as Session[])[0]; + expect(row.orgId).toBeUndefined(); + expect(row.importedFrom?.shareToken).toBe("share-token"); const restored = mergeGuestImportedSessions([]).find( - (session) => session.session_id === result!.localSessionId + (session) => session.session_id === result?.localSessionId ); - expect(restored?.importedFrom?.shareToken).toBe("share-token"); expect(restored?.importedFrom?.shareEndpointUrl).toBe( "https://cloud.example.com" ); - expect(restored?.importedFrom?.orgId).toBe("org-1"); - expect(restored?.importedFrom?.sourceSessionId).toBe("remote-1"); - removeGuestImportedSession(result!.localSessionId); expect(mergeGuestImportedSessions([])).toEqual([]); }); - it("member imports never enter the guest registry", async () => { - const client = { - getSessionEventSegments: vi.fn(async () => sealSnapshot(makeSnapshot())), - } satisfies Pick; - - await importRemoteSession({ - client, + it("indexes the committed replay against the viewer-local checkout", async () => { + const remoteSession = makeRemote(); + const result = await importRemoteSession({ + client: makeWireClient(), orgId: "org-1", - remoteSession: makeRemote(), - }); - - expect(mergeGuestImportedSessions([])).toEqual([]); - }); - - it("fails closed when decoded segment content disagrees with its hash", async () => { - const snapshot = await sealSnapshot(makeSnapshot()); - const tampered = { - ...snapshot, - segments: snapshot.segments.map((segment) => ({ - ...segment, - events: [ - { - ...(segment.events[0] as unknown as Record), - id: "tampered", - } as unknown as SessionEvent, - ...segment.events.slice(1), - ], - })), - }; - const client = { - getSessionEventSegments: vi.fn(async () => tampered), - } satisfies Pick; - - await expect( - importRemoteSession({ - client, - orgId: "org-1", - remoteSession: makeRemote(), - }) - ).rejects.toSatisfy( - (error: unknown) => - error instanceof SegmentIntegrityError && - error.mismatch === "content_hash" && - error.seq === 1 && - !error.isTail - ); - expect(eventStoreMock.set).not.toHaveBeenCalled(); - }); - - it("an aborted import stops before any durable write", async () => { - const controller = new AbortController(); - const client = { - getSessionEventSegments: vi.fn( - async (input: { signal?: AbortSignal }) => { - expect(input.signal).toBe(controller.signal); - controller.abort(); - return sealSnapshot(makeSnapshot()); - } - ), - } satisfies Pick; - - await expect( - importRemoteSession({ - client, - orgId: "org-1", - remoteSession: makeRemote(), - signal: controller.signal, - }) - ).rejects.toSatisfy( - (error: unknown) => - error instanceof DOMException && error.name === "AbortError" - ); - expect(eventStoreMock.set).not.toHaveBeenCalled(); - expect(eventStoreMock.saveToCache).not.toHaveBeenCalled(); - }); - - it("rolls back durable history when cancellation arrives before the session-row commit", async () => { - const controller = new AbortController(); - const client = { - getSessionEventSegments: vi.fn(async () => sealSnapshot(makeSnapshot())), - } satisfies Pick; - eventStoreMock.saveToCache.mockImplementationOnce(async () => { - controller.abort(); - return 1; + remoteSession, + workspaceRepoPath: "/viewer/ORG2", }); - await expect( - importRemoteSession({ - client, - orgId: "org-1", - remoteSession: makeRemote(), - signal: controller.signal, - }) - ).rejects.toSatisfy( - (error: unknown) => - error instanceof DOMException && error.name === "AbortError" - ); - - const expectedId = await deriveImportedSessionId("org-1", "remote-1"); - expect(eventStoreMock.set).toHaveBeenCalledTimes(1); - expect(eventStoreMock.clearPersistedHistory).toHaveBeenCalledWith( - expectedId - ); - expect(eventStoreMock.clear).toHaveBeenCalledWith(expectedId); - expect(store.get(sessionsAtom)).toHaveLength(0); - }); - - it("keeps identically named remote sessions from different endpoints isolated", async () => { - const client = { - getSessionEventSegments: vi.fn(async () => sealSnapshot(makeSnapshot())), - } satisfies Pick; - - const cloudA = await importRemoteSession({ - client, - orgId: "org-1", - remoteSession: makeRemote(), - sourceEndpointUrl: "https://cloud-a.example.com", - }); - const cloudB = await importRemoteSession({ - client, + expect(indexCollaborationSessionMock).toHaveBeenCalledWith({ + localSessionId: result?.localSessionId, + sourceSessionId: "remote-1", + title: "Remote session", + workspacePath: "/viewer/ORG2", + sourceWorkspacePath: "/owner/remote/repo", orgId: "org-1", - remoteSession: makeRemote(), - sourceEndpointUrl: "https://cloud-b.example.com", + sessionRowId: remoteSession.id, + ownerMemberId: "m2", + ownerDisplayName: "Bob", }); - - expect(cloudA?.localSessionId).not.toBe(cloudB?.localSessionId); - const records = store.get(sessionsAtom) as Session[]; - expect(records).toHaveLength(2); - expect( - records.map((record) => record.importedFrom?.sourceEndpointUrl).sort() - ).toEqual(["https://cloud-a.example.com", "https://cloud-b.example.com"]); }); - it("fails closed when a segment's eventCount disagrees with its payload", async () => { - const snapshot = await sealSnapshot(makeSnapshot()); - const tampered = { - ...snapshot, - segments: snapshot.segments.map((segment) => ({ - ...segment, - eventCount: segment.eventCount + 1, - })), - }; - const client = { - getSessionEventSegments: vi.fn(async () => tampered), - } satisfies Pick; - - await expect( - importRemoteSession({ - client, - orgId: "org-1", - remoteSession: makeRemote(), - }) - ).rejects.toSatisfy( - (error: unknown) => - error instanceof SegmentIntegrityError && - error.mismatch === "event_count" - ); - expect(eventStoreMock.set).not.toHaveBeenCalled(); - }); - - it("preserves a guest capability during a later tokenless re-import", async () => { - const client = { - getSessionEventSegments: vi.fn(async () => sealSnapshot(makeSnapshot())), - } satisfies Pick; - const localSessionId = await deriveImportedSessionId("org-1", "remote-1"); - store.set(sessionsAtom, [ - { - session_id: localSessionId, - name: "Remote session", - importedFrom: { - orgId: "org-1", - sourceSessionId: "remote-1", - ownerMemberId: "m2", - epoch: 1, - seq: 0, - count: 0, - shareToken: "share-token", - shareEndpointUrl: "https://cloud.example.com", - }, - } as unknown as Session, - ]); - + it("does not create a row when no replay snapshot was published", async () => { const result = await importRemoteSession({ - client, + client: makeWireClient(), orgId: "org-1", - remoteSession: makeRemote(), + remoteSession: makeRemote({ + eventsEpoch: undefined, + eventsFrozenSeq: undefined, + eventsCount: undefined, + }), }); - const record = (store.get(sessionsAtom) as Session[]).find( - (session) => session.session_id === result!.localSessionId - ); - expect(record?.importedFrom?.shareToken).toBe("share-token"); - expect(record?.importedFrom?.shareEndpointUrl).toBe( - "https://cloud.example.com" - ); + + expect(result).toBeNull(); + expect(ingestRemoteSnapshotMock).not.toHaveBeenCalled(); + expect(store.get(sessionsAtom)).toEqual([]); }); - it("serializes concurrent imports without sharing a caller's promise", async () => { - let resolveFirstFetch!: (snapshot: SessionEventSegmentsSnapshot) => void; - const client = { - getSessionEventSegments: vi - .fn<() => Promise>() - .mockImplementationOnce( - () => - new Promise((resolve) => { - resolveFirstFetch = resolve; - }) - ) - .mockImplementation(async () => - sealSnapshot({ - ...makeSnapshot(), - frozenSeq: 2, - count: 2, - segments: [ - ...makeSnapshot().segments, - { - seq: 2, - isTail: false, - events: [ - { - id: "e2", - sessionId: "remote-1", - displayStatus: "completed", - } as unknown as SessionEvent, - ], - eventCount: 1, - segmentHash: "h2", - }, - ], + it("serializes concurrent imports without sharing caller cancellation", async () => { + let releaseFirst!: () => void; + ingestRemoteSnapshotMock + .mockImplementationOnce( + ({ localSessionId }: { localSessionId: string }) => + new Promise((resolve) => { + releaseFirst = () => resolve(makeCommit(localSessionId)); }) - ), - } satisfies Pick; - - // Engine PullLoop and a panel replay click race on the same session. The - // second attempt waits instead of sharing the first caller's cancellation. - const first = importRemoteSession({ - client, - orgId: "org-1", - remoteSession: makeRemote(), - }); - const second = importRemoteSession({ - client, + ) + .mockImplementation( + async ({ localSessionId }: { localSessionId: string }) => + makeCommit(localSessionId) + ); + const options = { + client: makeWireClient(), orgId: "org-1", remoteSession: makeRemote(), - }); - for (let flush = 0; flush < 5; flush += 1) await Promise.resolve(); - expect(client.getSessionEventSegments).toHaveBeenCalledTimes(1); + }; - resolveFirstFetch(await sealSnapshot(makeSnapshot())); + const first = importRemoteSession(options); + const second = importRemoteSession(options); + await vi.waitFor(() => + expect(ingestRemoteSnapshotMock).toHaveBeenCalledTimes(1) + ); + releaseFirst(); const [firstResult, secondResult] = await Promise.all([first, second]); expect(firstResult?.localSessionId).toBe(secondResult?.localSessionId); - expect(client.getSessionEventSegments).toHaveBeenCalledTimes(2); - expect(eventStoreMock.set).toHaveBeenCalledTimes(2); + expect(ingestRemoteSnapshotMock).toHaveBeenCalledTimes(2); + }); - // The in-flight entry is cleared afterwards: a later call with a newer - // remote summary fetches again instead of returning the stale promise. - const third = await importRemoteSession({ - client, - orgId: "org-1", - remoteSession: makeRemote({ eventsFrozenSeq: 2, eventsCount: 2 }), - }); - expect(client.getSessionEventSegments).toHaveBeenCalledTimes(3); - expect(third?.updated).toBe(true); + it("leaves the session list untouched when staged ingest fails", async () => { + ingestRemoteSnapshotMock.mockRejectedValueOnce(new Error("wire rejected")); + await expect( + importRemoteSession({ + client: makeWireClient(), + orgId: "org-1", + remoteSession: makeRemote(), + }) + ).rejects.toThrow("wire rejected"); + expect(store.get(sessionsAtom)).toEqual([]); }); }); -describe("forkSession (design §16.11, fork & continue)", () => { +describe("forkSession bounded snapshot publication", () => { const store = createInstrumentedStore(); - function makeRemote( - overrides: Partial = {} - ): RemoteTeammateSessionMetadata { - return { - id: "org-1:m2:remote-1", - orgId: "org-1", - ownerMemberId: "m2", - ownerUserId: "m2", - ownerDisplayName: "Bob", - ownerIdentityKind: COLLAB_IDENTITY_KIND.HUMAN, - sourceSessionId: "remote-1", - title: "Remote session", - repoPath: "/repo/shared", - lastActivityAt: "2026-07-01T00:00:00.000Z", - eventsEpoch: 1, - eventsFrozenSeq: 1, - eventsCount: 2, - eventsTailHash: undefined, - ...overrides, - }; - } - - function makeSnapshot(): SessionEventSegmentsSnapshot { - return { - epoch: 1, - frozenSeq: 1, - tailHash: null, - count: 2, - segments: [ - { - seq: 1, - isTail: false, - events: [ - { - id: "e1", - sessionId: "remote-1", - displayStatus: "completed", - } as unknown as SessionEvent, - { - id: "e2", - sessionId: "remote-1", - displayStatus: "completed", - } as unknown as SessionEvent, - ], - eventCount: 2, - segmentHash: "h1", - }, - ], - }; - } - beforeEach(() => { vi.clearAllMocks(); store.set(sessionsAtom, []); - eventStoreMock.set.mockResolvedValue(undefined); - eventStoreMock.clear.mockResolvedValue(undefined); - eventStoreMock.getPersistedEvents.mockResolvedValue([]); - eventStoreMock.saveToCache.mockResolvedValue(1); + eventStoreMock.clearPersistedHistory.mockResolvedValue(undefined); + ingestRemoteSnapshotMock.mockImplementation( + async ({ localSessionId }: { localSessionId: string }) => + makeCommit(localSessionId) + ); }); - it("preserves every frozen segment plus the mutable tail in source order", async () => { - const snapshot = await sealSnapshot({ - epoch: 3, - frozenSeq: 2, - tailHash: "tail-hash", - count: 5, - segments: [ - { - seq: 1, - isTail: false, - events: [ - { id: "turn-1-user", sessionId: "remote-1" }, - { id: "turn-1-agent", sessionId: "remote-1" }, - ] as SessionEvent[], - eventCount: 2, - segmentHash: "h1", - }, - { - seq: 2, - isTail: false, - events: [ - { id: "turn-2-user", sessionId: "remote-1" }, - { id: "turn-2-agent", sessionId: "remote-1" }, - ] as SessionEvent[], - eventCount: 2, - segmentHash: "h2", - }, - { - seq: 0, - isTail: true, - events: [ - { id: "turn-3-user", sessionId: "remote-1" }, - ] as SessionEvent[], - eventCount: 1, - segmentHash: "tail-hash", - }, - ], - }); - const client = { - getSessionEventSegments: vi.fn(async () => snapshot), - } satisfies Pick; - + it("creates a runnable native session and returns the bounded handoff", async () => { + const client = makeWireClient(); const result = await forkSession({ client, orgId: "org-1", - remoteSession: makeRemote({ - eventsEpoch: 3, - eventsFrozenSeq: 2, - eventsCount: 5, - eventsTailHash: snapshot.tailHash ?? undefined, - }), + remoteSession: makeRemote(), }); - expect(result?.eventCount).toBe(5); - const [written, forkId] = eventStoreMock.set.mock.calls[0]; - // Inherited event ids are namespaced by the fork's local session id so - // they cannot collide (PK id) with the source or a sibling import copy. - expect((written as SessionEvent[]).map((event) => event.id)).toEqual([ - `${forkId}~turn-1-user`, - `${forkId}~turn-1-agent`, - `${forkId}~turn-2-user`, - `${forkId}~turn-2-agent`, - `${forkId}~turn-3-user`, + expect(result?.localSessionId).toMatch(/^agentsession-/); + expect(result?.handoffItems).toEqual([ + "User: investigate memory", + "Assistant: bounded replay", ]); + expect(ingestRemoteSnapshotMock).toHaveBeenCalledWith( + expect.objectContaining({ + client, + localSessionId: result?.localSessionId, + }) + ); + const row = (store.get(sessionsAtom) as Session[])[0]; + expect(row.category).toBe("rust_agent"); + expect(row.importedFrom).toBeUndefined(); + expect(row.forkedFrom).toMatchObject({ + sourceSessionId: "remote-1", + rootSessionId: "remote-1", + atCount: 5, + }); + expect(eventStoreMock.getPersistedEvents).not.toHaveBeenCalled(); }); - it("fails closed when a tail-only snapshot contradicts the list summary", async () => { - const snapshot = await sealSnapshot({ - epoch: 4, - frozenSeq: 0, - tailHash: "tail-only", - count: 1, - segments: [ - { - seq: 0, - isTail: true, - events: [ - { id: "latest-only", sessionId: "remote-1" }, - ] as SessionEvent[], - eventCount: 1, - segmentHash: "tail-only", - }, - ], - }); - const client = { - getSessionEventSegments: vi.fn(async () => snapshot), - } satisfies Pick; + it("fails closed and clears an atomically committed summary mismatch", async () => { + ingestRemoteSnapshotMock.mockImplementationOnce( + async ({ localSessionId }: { localSessionId: string }) => + makeCommit(localSessionId, { eventCount: 4 }) + ); await expect( forkSession({ - client, + client: makeWireClient(), orgId: "org-1", - remoteSession: makeRemote({ - eventsEpoch: 4, - eventsFrozenSeq: 2, - eventsCount: 5, - eventsTailHash: snapshot.tailHash ?? undefined, - }), + remoteSession: makeRemote(), }) ).rejects.toSatisfy( (error: unknown) => error instanceof ForkSnapshotIntegrityError && error.kind === FORK_SNAPSHOT_ERROR_KIND.SNAPSHOT_INCOMPLETE ); - expect(eventStoreMock.set).not.toHaveBeenCalled(); - }); - - it("creates a WRITABLE session with forkedFrom provenance and persisted events", async () => { - const client = { - getSessionEventSegments: vi.fn(async () => sealSnapshot(makeSnapshot())), - } satisfies Pick; - - const result = await forkSession({ - client, - orgId: "org-1", - remoteSession: makeRemote(), - }); - - expect(result).not.toBeNull(); - // A fresh NORMAL runnable id — not the read-only import namespace. - expect(result!.localSessionId).toMatch(/^agentsession-/); - expect(result!.localSessionId).not.toMatch(/^imported-session-/); - expect(result!.eventCount).toBe(2); - - // Events were rewritten onto the fork id and durably cached. - expect(eventStoreMock.set).toHaveBeenCalledTimes(1); - const [writtenEvents, writtenId] = eventStoreMock.set.mock.calls[0]; - expect(writtenId).toBe(result!.localSessionId); - expect( - (writtenEvents as SessionEvent[]).map((event) => event.sessionId) - ).toEqual([result!.localSessionId, result!.localSessionId]); - expect(eventStoreMock.saveToCache).toHaveBeenCalledWith( - result!.localSessionId - ); - - const record = (store.get(sessionsAtom) as Session[]).find( - (session) => session.session_id === result!.localSessionId + expect(eventStoreMock.clearPersistedHistory).toHaveBeenCalledWith( + expect.stringMatching(/^agentsession-/) ); - expect(record).toBeDefined(); - // Writable, runnable, NOT a read-only replay copy. - expect(record!.category).toBe("rust_agent"); - expect(record!.importedFrom).toBeUndefined(); - expect(record!.forkedFrom).toEqual({ - orgId: "org-1", - sourceSessionId: "remote-1", - ownerMemberId: "m2", - ownerDisplayName: "Bob", - atCount: 2, - forkedAt: expect.any(String), - // Source is not itself a fork ⇒ it IS the thread root. - rootSessionId: "remote-1", - }); - expect(record!.repoPath).toBe("/repo/shared"); - expect(record!.name).toBe("⑂ Remote session"); - // Ownership stamp (member fork context): the fork files under the source - // org so the sidebar org filter lists it alongside the org's sessions. - expect(record!.orgId).toBe("org-1"); + expect(store.get(sessionsAtom)).toEqual([]); }); - it("uses the resolved LOCAL workspace over the owner's absolute path when provided", async () => { - const client = { - getSessionEventSegments: vi.fn(async () => sealSnapshot(makeSnapshot())), - } satisfies Pick; - + it("uses a viewer-local workspace and preserves the original relay root", async () => { const result = await forkSession({ - client, + client: makeWireClient(), orgId: "org-1", - remoteSession: makeRemote(), // owner's repoPath: /repo/shared - workspaceRepoPath: "/my/checkout/shared", - }); - const record = (store.get(sessionsAtom) as Session[]).find( - (session) => session.session_id === result!.localSessionId - )!; - expect(record.repoPath).toBe("/my/checkout/shared"); - expect(result!.repoPath).toBe("/my/checkout/shared"); - }); - - it("drops the owner's dead path entirely when no local checkout resolved (null override)", async () => { - const client = { - getSessionEventSegments: vi.fn(async () => sealSnapshot(makeSnapshot())), - } satisfies Pick; - - const result = await forkSession({ - client, - orgId: "org-1", - remoteSession: makeRemote(), - workspaceRepoPath: null, - }); - const record = (store.get(sessionsAtom) as Session[]).find( - (session) => session.session_id === result!.localSessionId - )!; - // Better NO workspace than the owner's path from another machine. - expect(record.repoPath).toBeUndefined(); - expect(result!.repoPath).toBeUndefined(); - }); - - it("inherits the thread root when forking a fork (relay chain)", async () => { - const client = { - getSessionEventSegments: vi.fn(async () => sealSnapshot(makeSnapshot())), - } satisfies Pick; - - const result = await forkSession({ - client, - orgId: "org-1", - // The source session is ITSELF a fork: its wire lineage points at the - // original root. The new fork must keep pointing at that root, not at - // the intermediate parent. remoteSession: makeRemote({ forkedFrom: { - sourceSessionId: "root-0", + sourceSessionId: "parent", rootSessionId: "root-0", ownerDisplayName: "Alice", }, }), + workspaceRepoPath: "/viewer/ORG2", }); - expect(result).not.toBeNull(); - const record = (store.get(sessionsAtom) as Session[]).find( - (session) => session.session_id === result!.localSessionId + const row = (store.get(sessionsAtom) as Session[]).find( + (session) => session.session_id === result?.localSessionId ); - expect(record!.forkedFrom!.sourceSessionId).toBe("remote-1"); - expect(record!.forkedFrom!.rootSessionId).toBe("root-0"); + expect(row?.repoPath).toBe("/viewer/ORG2"); + expect(row?.forkedFrom?.rootSessionId).toBe("root-0"); }); - it("is push-eligible (unlike an import): the continuation syncs back as MY session", async () => { - const client = { - getSessionEventSegments: vi.fn(async () => sealSnapshot(makeSnapshot())), - } satisfies Pick; - + it("drops an owner's unusable absolute path when no local checkout exists", async () => { const result = await forkSession({ - client, - orgId: "org-1", - remoteSession: makeRemote(), - }); - const record = (store.get(sessionsAtom) as Session[]).find( - (session) => session.session_id === result!.localSessionId - )!; - - // Push eligibility (§16.11): the engines exclude exactly - // category==='external_history' and importedFrom-bearing sessions — a - // fork has neither, so the continuation syncs back under MY identity. - expect(record.category).not.toBe("external_history"); - expect(record.importedFrom).toBeUndefined(); - - // Contrast: the read-only import of the SAME remote session carries both - // exclusion markers (echo-loop guard P6) — the fork deliberately not. - const imported = await importRemoteSession({ - client, + client: makeWireClient(), orgId: "org-1", remoteSession: makeRemote(), + workspaceRepoPath: null, }); - const importedRecord = (store.get(sessionsAtom) as Session[]).find( - (session) => session.session_id === imported!.localSessionId - )!; - expect(importedRecord.category).toBe("external_history"); - expect(importedRecord.importedFrom).toBeDefined(); + const row = (store.get(sessionsAtom) as Session[]).find( + (session) => session.session_id === result?.localSessionId + ); + expect(row?.repoPath).toBeUndefined(); + expect(result?.repoPath).toBeUndefined(); }); - it("throws a typed replay error for metadata-only sessions without fetching", async () => { - const client = { - getSessionEventSegments: vi.fn(async () => sealSnapshot(makeSnapshot())), - } satisfies Pick; - + it("rejects metadata-only sessions before opening the wire reader", async () => { await expect( forkSession({ - client, + client: makeWireClient(), orgId: "org-1", remoteSession: makeRemote({ eventsEpoch: undefined, @@ -1334,29 +530,6 @@ describe("forkSession (design §16.11, fork & continue)", () => { kind: "replay_unavailable", sourceSessionId: "remote-1", }); - expect(client.getSessionEventSegments).not.toHaveBeenCalled(); - expect(store.get(sessionsAtom)).toHaveLength(0); - }); - - it("throws on a failed durable write and leaves no session record behind", async () => { - const client = { - getSessionEventSegments: vi.fn(async () => sealSnapshot(makeSnapshot())), - } satisfies Pick; - // The durable cache write fails (swallowed error → 0 rows saved). - eventStoreMock.saveToCache.mockResolvedValueOnce(0); - - await expect( - forkSession({ - client, - orgId: "org-1", - remoteSession: makeRemote(), - }) - ).rejects.toThrow(/durably persist/); - - // The orphaned event-store entry was dropped again and no record claims - // the fork exists (events-first ordering, mirroring the importer). - const forkId = eventStoreMock.set.mock.calls[0][1]; - expect(eventStoreMock.clear).toHaveBeenCalledWith(forkId); - expect(store.get(sessionsAtom)).toHaveLength(0); + expect(ingestRemoteSnapshotMock).not.toHaveBeenCalled(); }); }); diff --git a/src/features/TeamCollaboration/engine/collabSyncEngineHelpers.ts b/src/features/TeamCollaboration/engine/collabSyncEngineHelpers.ts index f3b861eb2..dced794c1 100644 --- a/src/features/TeamCollaboration/engine/collabSyncEngineHelpers.ts +++ b/src/features/TeamCollaboration/engine/collabSyncEngineHelpers.ts @@ -4,9 +4,9 @@ * * `importRemoteSession` is THE consolidated teammate-session import (design * §7.4 + M5 dedup); `forkSession` (design §16.11) is its WRITABLE sibling. - * Both are backend-agnostic — their only backend dependency is - * `client.getSessionEventSegments`, satisfied on the managed cloud by - * `org2CloudBackendAdapter`. The segments planning helpers + * Both are backend-agnostic — their only replay dependency is the bounded + * `client.getSessionEventWirePage` capability supplied by the managed cloud. + * The segments planning helpers * (`computeFrozenEventCount` / `splitFrozenIntoSegments`) and the shared OCC * conflict matcher (`isCollabConflictError`) serve the cloud push engine and * the ProjectSyncChannel. The self-hosted engine's pull-application/push @@ -16,7 +16,7 @@ * This module is the stable public entry point; the implementation lives in: * - `collabImportIdentity.ts` — import id derivation / provenance lookup * - `collabSegmentPlanning.ts` — frozen line, segment packing, OCC matcher - * - `collabRemoteFetch.ts` — shared segments fetch + assembly/validation + * - `collabSnapshotIngest.ts` — bounded wire paging + atomic Rust publication * - `collabSessionImport.ts` — `importRemoteSession` (read-only replay copy) * - `collabSessionFork.ts` — `forkSession` (writable relay copy) */ @@ -28,7 +28,6 @@ export { parseImportedSessionMetadata, rewriteEventsForImportedSnapshot, } from "./collabImportIdentity"; -export type { RemoteSessionFetchOptions } from "./collabRemoteFetch"; export { computeFrozenEventCount, isCollabConflictError, diff --git a/src/features/TeamCollaboration/forkSession.test.ts b/src/features/TeamCollaboration/forkSession.test.ts index 7090ea36d..527bf7c01 100644 --- a/src/features/TeamCollaboration/forkSession.test.ts +++ b/src/features/TeamCollaboration/forkSession.test.ts @@ -99,6 +99,15 @@ const FORK_RESULT: ForkSessionResult = { localSessionId: "agentsession-fork-1", name: "⑂ Remote session", eventCount: 2, + handoffItems: [ + "User: please fix the login bug", + [ + "[Inherited session action]", + "Tool: edit_file", + "Input: patch auth.ts", + "Result at that time: file updated", + ].join("\n"), + ], }; function makeRemote( @@ -127,7 +136,7 @@ function makeForkOptions( overrides: Partial = {} ) { return { - client: { getSessionEventSegments: vi.fn() }, + client: { getSessionEventWirePage: vi.fn() }, orgId: "org-1", remoteSession: makeRemote(overrides), execution: { @@ -177,7 +186,6 @@ beforeEach(() => { saveSessionMock.mockResolvedValue(undefined); deleteSessionMock.mockResolvedValue(undefined); eventStoreMock.clear.mockResolvedValue(undefined); - eventStoreMock.getPersistedEvents.mockResolvedValue([]); store.set(sessionsAtom, []); store.set(reposAtom, []); store.set(org2CloudOrgsAtom, []); @@ -640,24 +648,8 @@ describe("forkTeammateSession workspaceRepoPath key-presence (agent-pickup desig }); describe("first-send handoff (LLM context continuity)", () => { - it("wraps the first send with the inherited digest and keeps the user's words as displayText", async () => { + it("wraps the first send from the bounded ingest digest without hydrating history", async () => { await forkTeammateSession(makeForkOptions()); - eventStoreMock.getPersistedEvents.mockResolvedValue([ - makeEvent({ - id: "u1", - source: "user", - actionType: "user_message", - displayText: "please fix the login bug", - }), - makeEvent({ - id: "t1", - actionType: "tool_call", - functionName: "edit_file", - displayText: "", - args: { text: "patch auth.ts" }, - result: { content: "file updated" }, - }), - ]); const handoff = await buildPendingForkHandoff( "agentsession-fork-1", @@ -673,6 +665,36 @@ describe("first-send handoff (LLM context continuity)", () => { expect(handoff!.content).toContain("Input: patch auth.ts"); expect(handoff!.content).toContain("Result at that time: file updated"); expect(handoff!.content).toContain("continue where Bob left off"); + expect(eventStoreMock.getPersistedEvents).not.toHaveBeenCalled(); + }); + + it("bounds relay storage to 80 items and 1200 characters per item", async () => { + forkSessionMock.mockResolvedValueOnce({ + ...FORK_RESULT, + handoffItems: Array.from( + { length: __FORK_RELAY_INTERNALS.MAX_HANDOFF_ITEMS + 10 }, + (_unused, index) => `${index}:${"x".repeat(1300)}` + ), + }); + + await forkTeammateSession(makeForkOptions()); + + const raw = localStorage.getItem( + __FORK_RELAY_INTERNALS.FORK_RELAY_STORAGE_KEY + ); + const registry = JSON.parse(raw ?? "{}") as Record< + string, + { handoffItems?: string[] } + >; + const items = registry["agentsession-fork-1"]?.handoffItems ?? []; + expect(items).toHaveLength(__FORK_RELAY_INTERNALS.MAX_HANDOFF_ITEMS); + expect(items[0]).toMatch(/^10:/); + expect(items.at(-1)).toMatch(/^89:/); + expect( + items.every( + (item) => item.length <= __FORK_RELAY_INTERNALS.MAX_ITEM_TEXT_LENGTH + ) + ).toBe(true); }); it("is one-shot: consumed after a successful send, durable until then", async () => { @@ -703,19 +725,12 @@ describe("first-send handoff (LLM context continuity)", () => { expect(eventStoreMock.getPersistedEvents).not.toHaveBeenCalled(); }); - it("slices to the fork point so the just-typed user event is not doubled into the digest", async () => { - await forkTeammateSession(makeForkOptions()); // atCount = 2 - eventStoreMock.getPersistedEvents.mockResolvedValue([ - makeEvent({ id: "e1", displayText: "inherited one" }), - makeEvent({ id: "e2", displayText: "inherited two" }), - // Appended by the composer before dispatch — NOT inherited history. - makeEvent({ - id: "e3", - source: "user", - actionType: "user_message", - displayText: "my brand new message", - }), - ]); + it("does not re-read composer-appended events before the first send", async () => { + forkSessionMock.mockResolvedValueOnce({ + ...FORK_RESULT, + handoffItems: ["Assistant: inherited one", "Assistant: inherited two"], + }); + await forkTeammateSession(makeForkOptions()); const handoff = await buildPendingForkHandoff( "agentsession-fork-1", @@ -723,8 +738,33 @@ describe("first-send handoff (LLM context continuity)", () => { ); expect(handoff!.content).toContain("inherited one"); expect(handoff!.content).toContain("inherited two"); - // Present once as the continuation request, not also as transcript. + // The new text is present once as the continuation request, not also as + // transcript, because the handoff never re-opens the event store. expect(handoff!.content).not.toContain("User: my brand new message"); + expect(eventStoreMock.getPersistedEvents).not.toHaveBeenCalled(); + }); + + it("keeps legacy relay entries readable without falling back to full history", async () => { + localStorage.setItem( + __FORK_RELAY_INTERNALS.FORK_RELAY_STORAGE_KEY, + JSON.stringify({ + "agentsession-legacy": { + forkedFrom: FORKED_FROM, + handoffPending: true, + }, + }) + ); + + const handoff = await buildPendingForkHandoff( + "agentsession-legacy", + "continue safely" + ); + + expect(handoff?.content).toContain( + "No usable transcript items were found." + ); + expect(handoff?.content).toContain("continue safely"); + expect(eventStoreMock.getPersistedEvents).not.toHaveBeenCalled(); }); }); diff --git a/src/features/TeamCollaboration/forkSession.ts b/src/features/TeamCollaboration/forkSession.ts index 748a3ec72..6b67bef26 100644 --- a/src/features/TeamCollaboration/forkSession.ts +++ b/src/features/TeamCollaboration/forkSession.ts @@ -23,11 +23,12 @@ * cache the fork inherited — a fork starts with an empty message table, so * without help the agent is blind to the teammate's context. There is no * Tauri command to seed `agent_messages`, so the handoff rides the FIRST - * message instead: `buildPendingForkHandoff` wraps the user's first send - * with a bounded digest of the inherited events (same technique as the - * imported-history handoff in `externalHistoryFork.ts`), while `displayText` - * keeps the user's own words in the transcript. The handoff is one-shot - * and durable across restarts (localStorage registry), consumed by + * message instead: the bounded Rust snapshot ingest returns at most 80 + * compact handoff items, which are stored with the relay marker. + * `buildPendingForkHandoff` wraps the user's first send from those items + * without re-reading the inherited event cache, while `displayText` keeps + * the user's own words in the transcript. The handoff is one-shot and + * durable across restarts (localStorage registry), consumed by * `markForkHandoffConsumed` only after the send succeeds. * * The registry doubles as durable provenance: backend list reloads rebuild @@ -68,8 +69,8 @@ import { } from "./components/ForkSessionSetupDialog"; import type { ForkExecutionSelection, + ForkSessionOptions, ForkSessionResult, - RemoteSessionFetchOptions, } from "./engine/collabSyncEngineHelpers"; import { forkSession } from "./engine/collabSyncEngineHelpers"; import { ForkOperationError } from "./forkSnapshotIntegrity"; @@ -84,7 +85,7 @@ import { withTag, } from "./sessionOrgTagsAtom"; -export type { ForkSessionResult, RemoteSessionFetchOptions }; +export type { ForkSessionResult }; // ============================================================================ // Durable fork-relay registry (provenance + one-shot handoff marker) @@ -94,6 +95,8 @@ const FORK_RELAY_STORAGE_KEY = "orgii:collabForkRelay:v1"; /** Registry size cap — evicts the oldest fork (by forkedAt) past this. */ const MAX_REGISTRY_ENTRIES = 100; +const MAX_HANDOFF_ITEMS = 80; +const MAX_ITEM_TEXT_LENGTH = 1200; const SessionForkedFromSchema = z.object({ orgId: z.string(), @@ -109,6 +112,11 @@ const ForkRelayEntrySchema = z.object({ forkedFrom: SessionForkedFromSchema, /** True until the first successful message send consumes the handoff. */ handoffPending: z.boolean(), + /** Bounded, pre-folded context produced while the remote snapshot streams. */ + handoffItems: z + .array(z.string().max(MAX_ITEM_TEXT_LENGTH)) + .max(MAX_HANDOFF_ITEMS) + .optional(), }); type ForkRelayEntry = z.output; @@ -251,7 +259,7 @@ export async function resolveForkWorkspacePath( return null; } -export interface ForkTeammateSessionOptions extends RemoteSessionFetchOptions { +export interface ForkTeammateSessionOptions extends ForkSessionOptions { /** User-initiated forks open one setup dialog before any remote fetch. */ promptForExecution?: boolean; /** Pre-resolved execution choice for headless/programmatic callers. */ @@ -551,6 +559,9 @@ export async function forkTeammateSession( remoteSession.sourceSessionId, }, handoffPending: true, + handoffItems: result.handoffItems + .slice(-MAX_HANDOFF_ITEMS) + .map(truncateText), }); if (isStoreInitialized()) { @@ -584,9 +595,6 @@ export async function forkTeammateSession( // First-send handoff (LLM context continuity) // ============================================================================ -const MAX_HANDOFF_ITEMS = 80; -const MAX_ITEM_TEXT_LENGTH = 1200; - function textValue(value: unknown): string | undefined { if (typeof value === "string") { const trimmed = value.trim(); @@ -612,7 +620,7 @@ function textValue(value: unknown): string | undefined { function truncateText(text: string): string { return text.length > MAX_ITEM_TEXT_LENGTH - ? `${text.slice(0, MAX_ITEM_TEXT_LENGTH)}…` + ? `${text.slice(0, MAX_ITEM_TEXT_LENGTH - 1)}…` : text; } @@ -647,16 +655,12 @@ function eventToHandoffItem(event: SessionEvent): string | undefined { return primary ? `Assistant: ${truncateText(primary)}` : undefined; } -/** Exported for tests; assembles the wrapped first-send content. */ -export function buildForkHandoffPrompt( - events: SessionEvent[], +function buildForkHandoffPromptFromItems( + handoffItems: readonly string[], forkedFrom: SessionForkedFrom, userText: string ): string { - const items = events - .map(eventToHandoffItem) - .filter((item): item is string => Boolean(item)) - .slice(-MAX_HANDOFF_ITEMS); + const items = handoffItems.slice(-MAX_HANDOFF_ITEMS).map(truncateText); return [ "You are taking over a teammate's shared ORGII session and continuing it as your own session.", @@ -674,6 +678,18 @@ export function buildForkHandoffPrompt( ].join("\n"); } +/** Exported for tests; assembles the wrapped first-send content. */ +export function buildForkHandoffPrompt( + events: SessionEvent[], + forkedFrom: SessionForkedFrom, + userText: string +): string { + const items = events + .map(eventToHandoffItem) + .filter((item): item is string => Boolean(item)); + return buildForkHandoffPromptFromItems(items, forkedFrom, userText); +} + export interface ForkHandoffContent { /** Wire content for the LLM: handoff digest + the user's message. */ content: string; @@ -683,10 +699,11 @@ export interface ForkHandoffContent { /** * When `sessionId` is a fork whose handoff has not been consumed yet, build - * the wrapped first-send content from the inherited events (bounded digest). - * Pure read — call `markForkHandoffConsumed` after the send SUCCEEDS so a - * failed send retries with the handoff intact. Returns null for every - * non-fork session and for forks that already relayed their context. + * the wrapped first-send content from the bounded digest saved at fork time. + * It deliberately never hydrates the inherited event cache. Pure read — call + * `markForkHandoffConsumed` after the send SUCCEEDS so a failed send retries + * with the handoff intact. Returns null for every non-fork session and for + * forks that already relayed their context. */ export async function buildPendingForkHandoff( sessionId: string, @@ -695,13 +712,12 @@ export async function buildPendingForkHandoff( const entry = readRegistry()[sessionId]; if (!entry?.handoffPending) return null; - const events = await eventStoreProxy.getPersistedEvents(sessionId); - // Slice to the fork point: by first-send time the composer may already have - // appended the new user's own message to the store — inherited history is - // exactly the first `atCount` events. - const inherited = events.slice(0, entry.forkedFrom.atCount); return { - content: buildForkHandoffPrompt(inherited, entry.forkedFrom, userText), + content: buildForkHandoffPromptFromItems( + entry.handoffItems ?? [], + entry.forkedFrom, + userText + ), displayText: userText, }; } @@ -711,7 +727,13 @@ export function markForkHandoffConsumed(sessionId: string): void { const registry = readRegistry(); const entry = registry[sessionId]; if (!entry?.handoffPending) return; - registry[sessionId] = { ...entry, handoffPending: false }; + // Once consumed, drop the digest as well: provenance remains durable while + // bounded context no longer spends localStorage quota indefinitely. + registry[sessionId] = { + ...entry, + handoffPending: false, + handoffItems: undefined, + }; writeRegistry(registry); } diff --git a/src/features/TeamCollaboration/sync/CollabSyncBackend.ts b/src/features/TeamCollaboration/sync/CollabSyncBackend.ts index 2dd7220a7..d686f631c 100644 --- a/src/features/TeamCollaboration/sync/CollabSyncBackend.ts +++ b/src/features/TeamCollaboration/sync/CollabSyncBackend.ts @@ -4,12 +4,13 @@ * * Post cloud-parity Phase E the only implementation is the managed ORG2 * Cloud plane (`org2CloudProjectsClient.createCloudProjectSyncClient` for - * the channel slice, `org2CloudBackendAdapter.buildCloudSessionFetchClient` - * for the segments read) — the self-hosted Supabase client and its ~30 RPC + * the channel slice, `org2CloudBackendAdapter.buildCloudSessionWirePageClient` + * for bounded replay reads) — the self-hosted Supabase client and its ~30 RPC * input types were deleted with the in-app self-hosted track. What remains * is exactly the surface those cloud adapters implement. */ import type { SessionEvent } from "@src/engines/SessionCore/core/types"; +import replayBudgets from "@src/shared/externalReplayBudgets.json"; import type { CollabProjectMetadataRecord, CollabWorkItemMetadataRecord, @@ -78,54 +79,85 @@ export interface SessionEventsSegmentInput { events: SessionEvent[]; } -export interface GetSessionEventSegmentsInput { +/** + * Hard transport budgets for one raw replay page. These limits apply to the + * physical cloud rows, not logical events: one Replay Attachment V2 event may + * span several rows whose intermediate `eventCount` is zero. + */ +export const SESSION_EVENT_WIRE_MAX_SEGMENT_BYTES = + replayBudgets.cloudSegmentMaxBytes; +/** + * Compatibility ceiling for one pre-Attachment-V2 inline row. New writers + * must never use this allowance; it exists only so an already-published V1 + * row can be streamed through Rust and re-indexed instead of becoming + * permanently unreadable after the 256 KiB transport cap shipped. + */ +export const SESSION_EVENT_WIRE_MAX_LEGACY_V1_SEGMENT_BYTES = + replayBudgets.cloudLegacyV1SegmentMaxBytes; +export const SESSION_EVENT_WIRE_MAX_PAGE_BYTES = + replayBudgets.cloudPageMaxBytes; +export const SESSION_EVENT_WIRE_MAX_PAGE_SEGMENTS = + replayBudgets.cloudPageMaxSegments; + +/** + * A physical-row cursor. Forward pages are used for deltas/rebuilds; + * backward pages make a cold open start at the newest frozen rows instead of + * downloading the complete history. `throughSeq` pins a multi-page forward + * read to one frozen high-water mark. + */ +export type SessionEventWirePageCursor = + | { + direction: "forward"; + afterSeq: number; + throughSeq?: number; + } + | { + direction: "backward"; + beforeSeq?: number; + }; + +export interface GetSessionEventWirePageInput { orgId: string; sessionRowId: string; - /** Return frozen segments with seq strictly greater; tail always included. */ - afterSeq?: number; + cursor: SessionEventWirePageCursor; /** - * Link-share capability (design §6.4): when set, the call authenticates - * with the token alone (member/root credentials are not sent) and can only - * read the one session the token is bound to. + * Ask the server to include the current mutable tail state. A latest-page + * cold open and a forward delta set this; older backward pages do not. */ + includeTail: boolean; + maxSegments: number; + maxWireBytes: number; shareToken?: string; - /** Cancels the fetch + decode (dialog close / attempt supersession). */ signal?: AbortSignal; } -export interface SessionEventSegmentRecord { +/** + * Opaque compressed physical row. Consumers must persist/stream these rows + * without decoding them into a renderer-sized `SessionEvent[]`. + */ +export interface SessionEventSegmentWireRecord { seq: number; - isTail: boolean; - events: SessionEvent[]; + payloadGz: string; eventCount: number; segmentHash: string; } -/** Single-statement snapshot of the summary + requested segments. */ -export interface SessionEventSegmentsSnapshot { - /** null ⇒ the owner has never pushed segments for this session. */ +/** One fail-closed, byte-bounded page from a single epoch snapshot. */ +export interface SessionEventWirePage { epoch: number | null; frozenSeq: number | null; tailHash: string | null; count: number | null; - segments: SessionEventSegmentRecord[]; + segments: SessionEventSegmentWireRecord[]; + /** True only when this response represents the requested current tail. */ + tailIncluded: boolean; + hasMore: boolean; + /** Physical-row continuation; never a logical event-count cursor. */ + nextCursor: SessionEventWirePageCursor | null; + /** Sum of compact UTF-8 JSON bytes for `segments`. */ + returnedWireBytes: number; } -export type SessionEventSegmentsSummary = Omit< - SessionEventSegmentsSnapshot, - "segments" ->; - -/** - * Optional bounded-memory replay read. Implementations invoke `onPage` in - * frozen-sequence order and include the mutable tail only on the final page. - * The returned summary describes that final page's authoritative snapshot. - */ -export type StreamSessionEventSegments = ( - input: GetSessionEventSegmentsInput, - onPage: (page: SessionEventSegmentsSnapshot) => Promise -) => Promise; - export interface CollabSyncBackendClient { upsertProjectMetadata( input: UpsertProjectMetadataInput @@ -133,9 +165,8 @@ export interface CollabSyncBackendClient { upsertWorkItem(input: UpsertWorkItemInput): Promise; deleteProjectMetadata(input: DeleteProjectMetadataInput): Promise; deleteWorkItemMetadata(input: DeleteWorkItemMetadataInput): Promise; - getSessionEventSegments( - input: GetSessionEventSegmentsInput - ): Promise; - streamSessionEventSegments?: StreamSessionEventSegments; + getSessionEventWirePage( + input: GetSessionEventWirePageInput + ): Promise; listOrgState(input: ListOrgStateInput): Promise; } diff --git a/src/features/TeamCollaboration/sync/collabGzip.ts b/src/features/TeamCollaboration/sync/collabGzip.ts index 8aa8410f1..3c8486980 100644 --- a/src/features/TeamCollaboration/sync/collabGzip.ts +++ b/src/features/TeamCollaboration/sync/collabGzip.ts @@ -40,7 +40,7 @@ async function pipeThroughStream( const BASE64_CHUNK = 0x8000; -function bytesToBase64(bytes: Uint8Array): string { +export function bytesToBase64(bytes: Uint8Array): string { let binary = ""; for (let index = 0; index < bytes.length; index += BASE64_CHUNK) { binary += String.fromCharCode( @@ -50,7 +50,7 @@ function bytesToBase64(bytes: Uint8Array): string { return btoa(binary); } -function base64ToBytes(base64: string): Uint8Array { +export function base64ToBytes(base64: string): Uint8Array { const binary = atob(base64); const bytes = new Uint8Array(binary.length); for (let index = 0; index < binary.length; index += 1) { @@ -99,6 +99,14 @@ export async function gzipBytesToBase64(bytes: Uint8Array): Promise { return bytesToBase64(await gzipBytes(bytes)); } +/** base64 → gunzip bytes without assuming the payload is JSON. */ +export async function gunzipBase64ToBytes(base64: string): Promise { + return pipeThroughStream( + base64ToBytes(base64), + new DecompressionStream("gzip") + ); +} + /** JSON → gzip → base64 (the client half of `payload_gz`). */ export async function gzipJsonToBase64(value: unknown): Promise { return gzipBytesToBase64(segmentCanonicalBytes(value)); diff --git a/src/features/TeamCollaboration/useForkImportedSession.test.ts b/src/features/TeamCollaboration/useForkImportedSession.test.ts index 59713124b..f7d46aa10 100644 --- a/src/features/TeamCollaboration/useForkImportedSession.test.ts +++ b/src/features/TeamCollaboration/useForkImportedSession.test.ts @@ -126,10 +126,11 @@ describe("executeGuestShareFork", () => { localSessionId: "agentsession-guest-fork", name: "⑂ Remote session", eventCount: 4, + handoffItems: ["User: continue from the shared session"], }; function makeDeps() { - const client = { getSessionEventSegments: vi.fn() }; + const client = { getSessionEventWirePage: vi.fn() }; const fork = vi.fn< (options: ForkTeammateSessionOptions) => Promise >(async () => result); @@ -193,10 +194,11 @@ describe("executeAuthenticatedCloudSessionFork", () => { localSessionId: "agentsession-member-fork", name: "⑂ Remote session", eventCount: 4, + handoffItems: ["User: continue from the shared session"], }; function makeDeps(rows: RemoteTeammateSessionMetadata[]) { - const client = { getSessionEventSegments: vi.fn() }; + const client = { getSessionEventWirePage: vi.fn() }; const fork = vi.fn< (options: ForkTeammateSessionOptions) => Promise >(async () => result); diff --git a/src/features/TeamCollaboration/useForkImportedSession.ts b/src/features/TeamCollaboration/useForkImportedSession.ts index 51ad1815a..a866e3848 100644 --- a/src/features/TeamCollaboration/useForkImportedSession.ts +++ b/src/features/TeamCollaboration/useForkImportedSession.ts @@ -24,7 +24,7 @@ import { commitRefreshedAuth, org2CloudAuthAtom, } from "../Org2Cloud/org2CloudAuthAtom"; -import { buildCloudSessionFetchClient } from "../Org2Cloud/org2CloudBackendAdapter"; +import { buildCloudSessionWirePageClient } from "../Org2Cloud/org2CloudBackendAdapter"; import { ensureFreshSession } from "../Org2Cloud/org2CloudClient"; import type { Org2CloudOrg } from "../Org2Cloud/org2CloudOrgsAtom"; import { org2CloudOrgsAtom } from "../Org2Cloud/org2CloudOrgsAtom"; @@ -101,7 +101,7 @@ export function resolveImportedSessionForkBackend( export interface GuestShareForkDeps { resolveShare: typeof resolveCloudSessionShare; - buildClient: typeof buildCloudSessionFetchClient; + buildClient: typeof buildCloudSessionWirePageClient; fork: ( options: ForkTeammateSessionOptions ) => Promise; @@ -109,7 +109,7 @@ export interface GuestShareForkDeps { const GUEST_SHARE_FORK_DEPS: GuestShareForkDeps = { resolveShare: resolveCloudSessionShare, - buildClient: buildCloudSessionFetchClient, + buildClient: buildCloudSessionWirePageClient, fork: forkTeammateSession, }; diff --git a/src/i18n/locales/de/sessions.json b/src/i18n/locales/de/sessions.json index e4af59daa..9954d31a7 100644 --- a/src/i18n/locales/de/sessions.json +++ b/src/i18n/locales/de/sessions.json @@ -1002,7 +1002,13 @@ "entryCount_one": "{{count}} Eintrag", "entryCount_other": "{{count}} Einträge", "copySuccess": "Rohtranskript kopiert", - "copyFailed": "Rohtranskript konnte nicht kopiert werden" + "copyFailed": "Rohtranskript konnte nicht kopiert werden", + "exportAll": "Alles exportieren", + "exportSuccess": "Rohtranskript exportiert", + "exportFailed": "Rohtranskript konnte nicht exportiert werden", + "copyTooLarge": "Für große Transkripte „Alles exportieren“ verwenden", + "readPayload": "Nutzdaten lesen", + "newerReleased": "Neuere Zeilen wurden zur Begrenzung des Speicherverbrauchs freigegeben. Aktualisieren Sie, um zur neuesten Runde zurückzukehren." }, "linkWorkItem": { "menuItem": "Mit Work Item verknüpfen…", diff --git a/src/i18n/locales/en/sessions.json b/src/i18n/locales/en/sessions.json index f332c1d54..73310fcc5 100644 --- a/src/i18n/locales/en/sessions.json +++ b/src/i18n/locales/en/sessions.json @@ -1053,7 +1053,13 @@ "entryCount_one": "{{count}} entry", "entryCount_other": "{{count}} entries", "copySuccess": "Raw transcript copied", - "copyFailed": "Could not copy the raw transcript" + "copyFailed": "Could not copy the raw transcript", + "exportAll": "Export All", + "exportSuccess": "Raw transcript exported", + "exportFailed": "Could not export the raw transcript", + "copyTooLarge": "Use Export All for large transcripts", + "readPayload": "Read payload", + "newerReleased": "Newer rows were released to keep memory bounded. Refresh to return to the latest turn." }, "linkWorkItem": { "menuItem": "Link to Work Item…", diff --git a/src/i18n/locales/es/sessions.json b/src/i18n/locales/es/sessions.json index 3115f1ee6..b23191893 100644 --- a/src/i18n/locales/es/sessions.json +++ b/src/i18n/locales/es/sessions.json @@ -1004,7 +1004,13 @@ "entryCount_one": "{{count}} entrada", "entryCount_other": "{{count}} entradas", "copySuccess": "Transcripción sin procesar copiada", - "copyFailed": "No se pudo copiar la transcripción sin procesar" + "copyFailed": "No se pudo copiar la transcripción sin procesar", + "exportAll": "Exportar todo", + "exportSuccess": "Transcripción sin procesar exportada", + "exportFailed": "No se pudo exportar la transcripción sin procesar", + "copyTooLarge": "Usa «Exportar todo» para transcripciones grandes", + "readPayload": "Leer contenido", + "newerReleased": "Se liberaron filas más recientes para limitar el uso de memoria. Actualiza para volver al turno más reciente." }, "linkWorkItem": { "menuItem": "Vincular a un Work Item…", diff --git a/src/i18n/locales/fr/sessions.json b/src/i18n/locales/fr/sessions.json index 63b617421..111187348 100644 --- a/src/i18n/locales/fr/sessions.json +++ b/src/i18n/locales/fr/sessions.json @@ -1004,7 +1004,13 @@ "entryCount_one": "{{count}} entrée", "entryCount_other": "{{count}} entrées", "copySuccess": "Transcription brute copiée", - "copyFailed": "Impossible de copier la transcription brute" + "copyFailed": "Impossible de copier la transcription brute", + "exportAll": "Tout exporter", + "exportSuccess": "Transcription brute exportée", + "exportFailed": "Impossible d’exporter la transcription brute", + "copyTooLarge": "Utilisez « Tout exporter » pour les grandes transcriptions", + "readPayload": "Lire le contenu", + "newerReleased": "Les lignes les plus récentes ont été libérées pour limiter l’utilisation de la mémoire. Actualisez pour revenir au dernier tour." }, "linkWorkItem": { "menuItem": "Lier à un Work Item…", diff --git a/src/i18n/locales/ja/sessions.json b/src/i18n/locales/ja/sessions.json index 9af60dc98..3cd2e6124 100644 --- a/src/i18n/locales/ja/sessions.json +++ b/src/i18n/locales/ja/sessions.json @@ -1003,7 +1003,13 @@ "entryCount_one": "{{count}} 件", "entryCount_other": "{{count}} 件", "copySuccess": "生トランスクリプトをコピーしました", - "copyFailed": "生トランスクリプトをコピーできませんでした" + "copyFailed": "生トランスクリプトをコピーできませんでした", + "exportAll": "すべてエクスポート", + "exportSuccess": "生トランスクリプトをエクスポートしました", + "exportFailed": "生トランスクリプトをエクスポートできませんでした", + "copyTooLarge": "大きなトランスクリプトには「すべてエクスポート」を使用してください", + "readPayload": "内容を読み込む", + "newerReleased": "メモリ使用量を抑えるため、新しい行を現在のウィンドウから解放しました。最新のターンに戻るには更新してください。" }, "linkWorkItem": { "menuItem": "Work Item にリンク…", diff --git a/src/i18n/locales/ko/sessions.json b/src/i18n/locales/ko/sessions.json index 8d721ca34..cd41a53ae 100644 --- a/src/i18n/locales/ko/sessions.json +++ b/src/i18n/locales/ko/sessions.json @@ -1003,7 +1003,13 @@ "entryCount_one": "항목 {{count}}개", "entryCount_other": "항목 {{count}}개", "copySuccess": "원시 트랜스크립트를 복사했습니다", - "copyFailed": "원시 트랜스크립트를 복사할 수 없습니다" + "copyFailed": "원시 트랜스크립트를 복사할 수 없습니다", + "exportAll": "모두 내보내기", + "exportSuccess": "원시 트랜스크립트를 내보냈습니다", + "exportFailed": "원시 트랜스크립트를 내보낼 수 없습니다", + "copyTooLarge": "큰 트랜스크립트는 ‘모두 내보내기’를 사용하세요", + "readPayload": "전체 내용 읽기", + "newerReleased": "메모리 사용량을 제한하기 위해 최신 행을 현재 창에서 해제했습니다. 최신 턴으로 돌아가려면 새로고침하세요." }, "linkWorkItem": { "menuItem": "Work Item에 연결…", diff --git a/src/i18n/locales/pl/sessions.json b/src/i18n/locales/pl/sessions.json index 08130946e..0696cc514 100644 --- a/src/i18n/locales/pl/sessions.json +++ b/src/i18n/locales/pl/sessions.json @@ -1005,7 +1005,13 @@ "entryCount_one": "{{count}} wpis", "entryCount_other": "{{count}} wpisów", "copySuccess": "Skopiowano surowy zapis", - "copyFailed": "Nie udało się skopiować surowego zapisu" + "copyFailed": "Nie udało się skopiować surowego zapisu", + "exportAll": "Eksportuj wszystko", + "exportSuccess": "Wyeksportowano surowy zapis", + "exportFailed": "Nie udało się wyeksportować surowego zapisu", + "copyTooLarge": "Dla dużych zapisów użyj opcji „Eksportuj wszystko”", + "readPayload": "Wczytaj pełną treść", + "newerReleased": "Nowsze wiersze zwolniono, aby ograniczyć użycie pamięci. Odśwież, aby wrócić do najnowszej tury." }, "linkWorkItem": { "menuItem": "Połącz z Work Item…", diff --git a/src/i18n/locales/pt/sessions.json b/src/i18n/locales/pt/sessions.json index bb16295cd..a24c688db 100644 --- a/src/i18n/locales/pt/sessions.json +++ b/src/i18n/locales/pt/sessions.json @@ -1003,7 +1003,13 @@ "entryCount_one": "{{count}} entrada", "entryCount_other": "{{count}} entradas", "copySuccess": "Transcrição bruta copiada", - "copyFailed": "Não foi possível copiar a transcrição bruta" + "copyFailed": "Não foi possível copiar a transcrição bruta", + "exportAll": "Exportar tudo", + "exportSuccess": "Transcrição bruta exportada", + "exportFailed": "Não foi possível exportar a transcrição bruta", + "copyTooLarge": "Use “Exportar tudo” para transcrições grandes", + "readPayload": "Ler conteúdo", + "newerReleased": "As linhas mais recentes foram liberadas para limitar o uso de memória. Atualize para voltar ao turno mais recente." }, "linkWorkItem": { "menuItem": "Vincular a um Work Item…", diff --git a/src/i18n/locales/ru/sessions.json b/src/i18n/locales/ru/sessions.json index 321199fc8..4552d47a0 100644 --- a/src/i18n/locales/ru/sessions.json +++ b/src/i18n/locales/ru/sessions.json @@ -1008,7 +1008,13 @@ "entryCount_one": "{{count}} запись", "entryCount_other": "{{count}} записей", "copySuccess": "Необработанная расшифровка скопирована", - "copyFailed": "Не удалось скопировать необработанную расшифровку" + "copyFailed": "Не удалось скопировать необработанную расшифровку", + "exportAll": "Экспортировать всё", + "exportSuccess": "Необработанная расшифровка экспортирована", + "exportFailed": "Не удалось экспортировать необработанную расшифровку", + "copyTooLarge": "Для больших расшифровок используйте «Экспортировать всё»", + "readPayload": "Загрузить содержимое", + "newerReleased": "Новые строки были освобождены для ограничения расхода памяти. Обновите, чтобы вернуться к последнему ходу." }, "linkWorkItem": { "menuItem": "Связать с Work Item…", diff --git a/src/i18n/locales/tr/sessions.json b/src/i18n/locales/tr/sessions.json index e28edaa37..0cff4907c 100644 --- a/src/i18n/locales/tr/sessions.json +++ b/src/i18n/locales/tr/sessions.json @@ -1004,7 +1004,13 @@ "entryCount_one": "{{count}} girdi", "entryCount_other": "{{count}} girdi", "copySuccess": "Ham döküm kopyalandı", - "copyFailed": "Ham döküm kopyalanamadı" + "copyFailed": "Ham döküm kopyalanamadı", + "exportAll": "Tümünü dışa aktar", + "exportSuccess": "Ham döküm dışa aktarıldı", + "exportFailed": "Ham döküm dışa aktarılamadı", + "copyTooLarge": "Büyük dökümler için “Tümünü dışa aktar” seçeneğini kullanın", + "readPayload": "İçeriği oku", + "newerReleased": "Bellek kullanımını sınırlamak için daha yeni satırlar serbest bırakıldı. En son tura dönmek için yenileyin." }, "linkWorkItem": { "menuItem": "Work Item'a bağla…", diff --git a/src/i18n/locales/vi/sessions.json b/src/i18n/locales/vi/sessions.json index 1e0e360ea..d139d57e7 100644 --- a/src/i18n/locales/vi/sessions.json +++ b/src/i18n/locales/vi/sessions.json @@ -1001,7 +1001,13 @@ "entryCount_one": "{{count}} mục", "entryCount_other": "{{count}} mục", "copySuccess": "Đã sao chép bản ghi thô", - "copyFailed": "Không thể sao chép bản ghi thô" + "copyFailed": "Không thể sao chép bản ghi thô", + "exportAll": "Xuất tất cả", + "exportSuccess": "Đã xuất bản ghi thô", + "exportFailed": "Không thể xuất bản ghi thô", + "copyTooLarge": "Hãy dùng “Xuất tất cả” cho bản ghi lớn", + "readPayload": "Đọc toàn bộ nội dung", + "newerReleased": "Các hàng mới hơn đã được giải phóng để giới hạn mức dùng bộ nhớ. Hãy làm mới để quay lại lượt mới nhất." }, "linkWorkItem": { "menuItem": "Liên kết với Work Item…", diff --git a/src/i18n/locales/zh-Hant/sessions.json b/src/i18n/locales/zh-Hant/sessions.json index f001981d8..eff78e0a9 100644 --- a/src/i18n/locales/zh-Hant/sessions.json +++ b/src/i18n/locales/zh-Hant/sessions.json @@ -1018,7 +1018,13 @@ "entryCount_one": "{{count}} 條記錄", "entryCount_other": "{{count}} 條記錄", "copySuccess": "已複製原始記錄", - "copyFailed": "無法複製原始記錄" + "copyFailed": "無法複製原始記錄", + "exportAll": "全部匯出", + "exportSuccess": "已匯出原始會話記錄", + "exportFailed": "無法匯出原始會話記錄", + "copyTooLarge": "會話記錄過大,請使用「全部匯出」", + "readPayload": "讀取完整內容", + "newerReleased": "為控制記憶體用量,較新的記錄已從目前視窗釋放。重新整理後可返回最新一輪。" }, "linkWorkItem": { "menuItem": "連結到工作項…", diff --git a/src/i18n/locales/zh/sessions.json b/src/i18n/locales/zh/sessions.json index 9034d7943..45de14439 100644 --- a/src/i18n/locales/zh/sessions.json +++ b/src/i18n/locales/zh/sessions.json @@ -1049,7 +1049,13 @@ "entryCount_one": "{{count}} 条记录", "entryCount_other": "{{count}} 条记录", "copySuccess": "已复制原始记录", - "copyFailed": "无法复制原始记录" + "copyFailed": "无法复制原始记录", + "exportAll": "全部导出", + "exportSuccess": "已导出原始会话记录", + "exportFailed": "无法导出原始会话记录", + "copyTooLarge": "会话记录过大,请使用“全部导出”", + "readPayload": "读取完整内容", + "newerReleased": "为控制内存占用,较新的记录已从当前窗口释放。刷新后可返回最新一轮。" }, "linkWorkItem": { "menuItem": "关联到工作项…", diff --git a/src/modules/WorkStation/Diff/SessionReplay/useSubmissionsData.test.ts b/src/modules/WorkStation/Diff/SessionReplay/useSubmissionsData.test.ts new file mode 100644 index 000000000..bcd02aac4 --- /dev/null +++ b/src/modules/WorkStation/Diff/SessionReplay/useSubmissionsData.test.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { loadSubmissionProjectionEvents } from "./useSubmissionsData"; + +const mocks = vi.hoisted(() => ({ + loadEvents: vi.fn(), + queryWindow: vi.fn(), + resolveSecondary: vi.fn(), +})); + +vi.mock("@src/engines/SessionCore/storage/cacheAdapter", () => ({ + loadEvents: mocks.loadEvents, +})); + +vi.mock("@src/api/tauri/externalHistory/replay", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("@src/api/tauri/externalHistory/replay") + >(); + return { + ...actual, + externalReplayQueryWindowForTarget: mocks.queryWindow, + resolveSecondaryReplayTarget: mocks.resolveSecondary, + }; +}); + +function boundedWindow(sessionId: string) { + return { + events: [{ id: `${sessionId}-event`, sessionId }], + }; +} + +describe("loadSubmissionProjectionEvents", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.queryWindow.mockImplementation( + ({ target }: { target: { sessionId: string } }) => + Promise.resolve(boundedWindow(target.sessionId)) + ); + mocks.resolveSecondary.mockResolvedValue(null); + mocks.loadEvents.mockResolvedValue([]); + }); + + it.each([ + "codexapp-session", + "claudecodeapp-session", + "opencodeapp-session", + "clineapp-session", + "cliagent-session", + "imported-session-snapshot", + ])("uses a bounded projection for %s", async (sessionId) => { + await expect(loadSubmissionProjectionEvents(sessionId)).resolves.toEqual( + boundedWindow(sessionId).events + ); + expect(mocks.queryWindow).toHaveBeenCalledWith( + expect.objectContaining({ + target: expect.objectContaining({ sessionId }), + limits: { + maxTurns: 10, + maxEvents: 200, + maxIpcBytes: 4 * 1024 * 1024, + }, + }) + ); + expect(mocks.resolveSecondary).not.toHaveBeenCalled(); + expect(mocks.loadEvents).not.toHaveBeenCalled(); + }); + + it("uses the verified snapshot index for a Cloud-created native fork", async () => { + const sessionId = "agentsession-cloud-fork"; + mocks.resolveSecondary.mockResolvedValue({ + sourceId: "collaboration_snapshot", + sessionId, + }); + + await expect(loadSubmissionProjectionEvents(sessionId)).resolves.toEqual( + boundedWindow(sessionId).events + ); + expect(mocks.resolveSecondary).toHaveBeenCalledWith(sessionId); + expect(mocks.queryWindow).toHaveBeenCalledWith({ + target: { sourceId: "collaboration_snapshot", sessionId }, + limits: { + maxTurns: 10, + maxEvents: 200, + maxIpcBytes: 4 * 1024 * 1024, + }, + }); + expect(mocks.loadEvents).not.toHaveBeenCalled(); + }); + + it.each(["sdeagent-native", "agentsession-native"])( + "keeps native persisted loading for %s", + async (sessionId) => { + await loadSubmissionProjectionEvents(sessionId); + expect(mocks.loadEvents).toHaveBeenCalledWith(sessionId); + expect(mocks.queryWindow).not.toHaveBeenCalled(); + } + ); +}); diff --git a/src/modules/WorkStation/Diff/SessionReplay/useSubmissionsData.ts b/src/modules/WorkStation/Diff/SessionReplay/useSubmissionsData.ts index d6083ed55..1945abd50 100644 --- a/src/modules/WorkStation/Diff/SessionReplay/useSubmissionsData.ts +++ b/src/modules/WorkStation/Diff/SessionReplay/useSubmissionsData.ts @@ -23,6 +23,11 @@ import { useEffect, useMemo, useState } from "react"; import { getGitCommitDiff, getGitCommits } from "@src/api/http/git"; import type { CommitDiffResult, GitCommitInfo } from "@src/api/http/git/types"; +import { + externalReplayQueryWindowForTarget, + resolveExternalReplayTarget, + resolveSecondaryReplayTarget, +} from "@src/api/tauri/externalHistory/replay"; import { getPRLocal } from "@src/api/tauri/github"; import { type OrgtrackSessionFinalDiff, @@ -51,6 +56,24 @@ const logger = createLogger("useSubmissionsData"); const SUBMISSION_COMMIT_RESOLVE_LIMIT = 200; +export async function loadSubmissionProjectionEvents( + sessionId: string +): Promise { + const target = + resolveExternalReplayTarget(sessionId) ?? + (await resolveSecondaryReplayTarget(sessionId)); + if (!target) return loadEvents(sessionId); + const window = await externalReplayQueryWindowForTarget({ + target, + limits: { + maxTurns: 10, + maxEvents: 200, + maxIpcBytes: 4 * 1024 * 1024, + }, + }); + return window.events; +} + interface UseSubmissionsDataParams { sessionId: string | undefined; simulatorEvents: readonly SessionEvent[]; @@ -213,13 +236,14 @@ export function useSubmissionsData({ } let cancelled = false; - void loadEvents(sessionId) + setCachedSessionEvents([]); + void loadSubmissionProjectionEvents(sessionId) .then((events) => { if (!cancelled) setCachedSessionEvents(events); }) .catch((err: unknown) => { if (!cancelled) { - logger.warn("failed to load full session events for submissions", { + logger.warn("failed to load bounded submission events", { err, sessionId, }); diff --git a/src/scaffold/GlobalSpotlight/hooks/data/useExternalRecentPaths.ts b/src/scaffold/GlobalSpotlight/hooks/data/useExternalRecentPaths.ts index 4b51f7e3f..7ce2d6f2a 100644 --- a/src/scaffold/GlobalSpotlight/hooks/data/useExternalRecentPaths.ts +++ b/src/scaffold/GlobalSpotlight/hooks/data/useExternalRecentPaths.ts @@ -1,14 +1,8 @@ import { useEffect, useMemo, useState } from "react"; import { - claudeCodeRecentPaths, - codexAppRecentPaths, - cursorCliRecentPaths, - opencodeRecentPaths, - qoderRecentPaths, - warpRecentPaths, - windsurfRecentPaths, - zcodeRecentPaths, + IMPORTED_HISTORY_SOURCES, + importedHistoryRecentPaths, } from "@src/api/tauri/externalHistory"; import type { RepoItem } from "@src/scaffold/GlobalSpotlight/types"; import { REPO_KIND } from "@src/store/repo"; @@ -83,42 +77,17 @@ export function useExternalRecentPaths({ let cancelled = false; - Promise.all([ - codexAppRecentPaths({ limit: EXTERNAL_RECENT_PATH_LIMIT }), - claudeCodeRecentPaths({ limit: EXTERNAL_RECENT_PATH_LIMIT }), - cursorCliRecentPaths({ limit: EXTERNAL_RECENT_PATH_LIMIT }), - opencodeRecentPaths({ limit: EXTERNAL_RECENT_PATH_LIMIT }), - windsurfRecentPaths({ limit: EXTERNAL_RECENT_PATH_LIMIT }), - warpRecentPaths({ limit: EXTERNAL_RECENT_PATH_LIMIT }), - zcodeRecentPaths({ limit: EXTERNAL_RECENT_PATH_LIMIT }), - qoderRecentPaths({ limit: EXTERNAL_RECENT_PATH_LIMIT }), - ]).then( - ([ - codexPaths, - claudePaths, - cursorCliPaths, - opencodePaths, - windsurfPaths, - warpPaths, - zcodePaths, - qoderPaths, - ]) => { - if (!cancelled) { - setPaths( - mergeRecentPaths([ - ...codexPaths, - ...claudePaths, - ...cursorCliPaths, - ...opencodePaths, - ...windsurfPaths, - ...warpPaths, - ...zcodePaths, - ...qoderPaths, - ]) - ); - } + Promise.all( + IMPORTED_HISTORY_SOURCES.map((source) => + importedHistoryRecentPaths(source.sourceId, { + limit: EXTERNAL_RECENT_PATH_LIMIT, + }) + ) + ).then((sourcePaths) => { + if (!cancelled) { + setPaths(mergeRecentPaths(sourcePaths.flat())); } - ); + }); return () => { cancelled = true; diff --git a/src/scaffold/NavigationSidebar/connectors/SessionImportExportModal.tsx b/src/scaffold/NavigationSidebar/connectors/SessionImportExportModal.tsx index f601c15f7..98b121889 100644 --- a/src/scaffold/NavigationSidebar/connectors/SessionImportExportModal.tsx +++ b/src/scaffold/NavigationSidebar/connectors/SessionImportExportModal.tsx @@ -7,6 +7,7 @@ import { FileJson, FolderInput, FolderOutput } from "lucide-react"; import React, { useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; +import { externalReplayStreamExportForTarget } from "@src/api/tauri/externalHistory/replay"; import Message from "@src/components/Message"; import { createLogger } from "@src/hooks/logger"; import Modal from "@src/scaffold/ModalSystem"; @@ -129,16 +130,26 @@ export function SessionImportExportModal({ const handleConfirmExport = useCallback(async () => { if (!exportDraft) return; try { - const filePath = await saveDialog({ - defaultPath: exportDraft.preview.fileName, - filters: [SESSION_JSON_FILTER], - }); - if (!filePath) return; setLoading(true); - await writeTextFile( - filePath, - stringifySessionExportFile(exportDraft.file) - ); + if (exportDraft.mode === "bounded-replay") { + const result = await externalReplayStreamExportForTarget({ + target: exportDraft.replayTarget, + suggestedFileName: exportDraft.preview.fileName, + format: "orgii_session_json", + orgiiEnvelope: exportDraft.orgiiEnvelope, + }); + if (!result) return; + } else { + const filePath = await saveDialog({ + defaultPath: exportDraft.preview.fileName, + filters: [SESSION_JSON_FILTER], + }); + if (!filePath) return; + await writeTextFile( + filePath, + stringifySessionExportFile(exportDraft.file) + ); + } Message.success(t("chat.importExport.exportSuccess")); onClose(); } catch (error) { diff --git a/src/scaffold/NavigationSidebar/connectors/sessionImportExport.test.ts b/src/scaffold/NavigationSidebar/connectors/sessionImportExport.test.ts new file mode 100644 index 000000000..b9751b63a --- /dev/null +++ b/src/scaffold/NavigationSidebar/connectors/sessionImportExport.test.ts @@ -0,0 +1,189 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + externalReplayQueryWindowForTarget, + resolveExternalReplayTarget, + resolveSecondaryReplayTarget, +} from "@src/api/tauri/externalHistory/replay"; +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; +import { loadOwnSessionInitialEvents } from "@src/engines/SessionCore/sync/sessionSyncUtils"; +import type { Session } from "@src/store/session"; + +import { buildSessionExportDraft } from "./sessionImportExport"; + +vi.mock("@src/api/tauri/externalHistory/replay", () => ({ + externalReplayQueryWindowForTarget: vi.fn(), + resolveExternalReplayTarget: vi.fn(), + resolveSecondaryReplayTarget: vi.fn(), +})); + +vi.mock("@src/engines/SessionCore/sync/sessionSyncUtils", () => ({ + loadOwnSessionInitialEvents: vi.fn(), +})); + +function session(sessionId: string, category: Session["category"]): Session { + return { + session_id: sessionId, + category, + status: "completed", + created_at: "2026-07-22T00:00:00Z", + updated_at: "2026-07-22T00:00:01Z", + name: "Replay fixture", + }; +} + +function nativeEvent(): SessionEvent { + return { + chunk_id: "event-1", + id: "event-1", + sessionId: "agentsession-native", + createdAt: "2026-07-22T00:00:00Z", + functionName: "assistant", + uiCanonical: "assistant", + actionType: "assistant_message", + args: {}, + result: { content: "done" }, + source: "assistant", + displayText: "done", + displayStatus: "completed", + displayVariant: "message", + activityStatus: "processed", + }; +} + +describe("session export draft", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(resolveSecondaryReplayTarget).mockResolvedValue(null); + }); + + it("keeps external preview bounded and leaves full JSON writing to Rust", async () => { + vi.mocked(resolveExternalReplayTarget).mockReturnValue({ + sourceId: "codex_app", + sessionId: "codexapp-large", + }); + vi.mocked(externalReplayQueryWindowForTarget).mockResolvedValue({ + cursor: { + sourceId: "codex_app", + sessionId: "codexapp-large", + generation: "g1", + revision: 1, + throughSequence: 999, + }, + events: [], + windowStartSequence: 999, + turnHeaders: [], + totalEventCount: 12_345, + totalTurnCount: 321, + hasOlder: true, + watcherAvailable: false, + stats: { + parsedBytes: 0, + parsedRows: 0, + normalizedEvents: 0, + upsertedEvents: 0, + removedEvents: 0, + ipcBytes: 0, + notReady: false, + }, + }); + + const draft = await buildSessionExportDraft( + session("codexapp-large", "external_history"), + "fallback" + ); + + expect(draft.mode).toBe("bounded-replay"); + expect(draft.preview.eventCount).toBe(12_345); + expect(externalReplayQueryWindowForTarget).toHaveBeenCalledWith({ + target: { + sourceId: "codex_app", + sessionId: "codexapp-large", + }, + limits: { + maxTurns: 1, + maxEvents: 1, + maxIpcBytes: 128 * 1024, + }, + }); + expect(loadOwnSessionInitialEvents).not.toHaveBeenCalled(); + if (draft.mode === "bounded-replay") { + expect(draft.orgiiEnvelope.session).toMatchObject({ + session_id: "codexapp-large", + }); + expect(draft.orgiiEnvelope.originalCategory).toBe("cli_agent"); + expect(draft).not.toHaveProperty("file"); + } + }); + + it("does not route a native SDE session through external replay", async () => { + vi.mocked(resolveExternalReplayTarget).mockReturnValue(null); + vi.mocked(loadOwnSessionInitialEvents).mockResolvedValue([nativeEvent()]); + + const draft = await buildSessionExportDraft( + session("agentsession-native", "rust_agent"), + "fallback" + ); + + expect(draft.mode).toBe("materialized"); + expect(externalReplayQueryWindowForTarget).not.toHaveBeenCalled(); + expect(resolveSecondaryReplayTarget).toHaveBeenCalledWith( + "agentsession-native" + ); + expect(loadOwnSessionInitialEvents).toHaveBeenCalledWith( + "agentsession-native" + ); + if (draft.mode === "materialized") { + expect(draft.file.payload.events).toHaveLength(1); + } + }); + + it("exports a snapshot-backed native fork through bounded replay", async () => { + const replayTarget = { + sourceId: "collaboration_snapshot" as const, + sessionId: "agentsession-cloud-fork", + }; + vi.mocked(resolveExternalReplayTarget).mockReturnValue(null); + vi.mocked(resolveSecondaryReplayTarget).mockResolvedValue(replayTarget); + vi.mocked(externalReplayQueryWindowForTarget).mockResolvedValue({ + cursor: { + ...replayTarget, + generation: "snapshot-g1", + revision: 7, + throughSequence: 99, + }, + events: [], + windowStartSequence: 99, + turnHeaders: [], + totalEventCount: 9_999, + totalTurnCount: 80, + hasOlder: true, + watcherAvailable: false, + stats: { + parsedBytes: 0, + parsedRows: 0, + normalizedEvents: 0, + upsertedEvents: 0, + removedEvents: 0, + ipcBytes: 0, + notReady: false, + }, + }); + + const draft = await buildSessionExportDraft( + session("agentsession-cloud-fork", "rust_agent"), + "fallback" + ); + + expect(draft.mode).toBe("bounded-replay"); + expect(externalReplayQueryWindowForTarget).toHaveBeenCalledWith({ + target: replayTarget, + limits: { + maxTurns: 1, + maxEvents: 1, + maxIpcBytes: 128 * 1024, + }, + }); + expect(loadOwnSessionInitialEvents).not.toHaveBeenCalled(); + }); +}); diff --git a/src/scaffold/NavigationSidebar/connectors/sessionImportExport.ts b/src/scaffold/NavigationSidebar/connectors/sessionImportExport.ts index ffeadaa35..0fe607132 100644 --- a/src/scaffold/NavigationSidebar/connectors/sessionImportExport.ts +++ b/src/scaffold/NavigationSidebar/connectors/sessionImportExport.ts @@ -1,18 +1,21 @@ -import { invoke as tauriInvoke } from "@tauri-apps/api/core"; import type { TFunction } from "i18next"; import { z } from "zod/v4"; -import { cursorIdeFullRefresh } from "@src/api/tauri/externalHistory"; +import { + type ExternalReplayOrgiiEnvelope, + type ExternalReplayTarget, + externalReplayQueryWindowForTarget, + resolveExternalReplayTarget, + resolveSecondaryReplayTarget, +} from "@src/api/tauri/externalHistory/replay"; import type { DispatchCategory } from "@src/api/tauri/session"; import type { SessionEvent } from "@src/engines/SessionCore/core/types"; -import { processChunksRust } from "@src/engines/SessionCore/ingestion/rustBridge"; import { cacheAdapter } from "@src/engines/SessionCore/storage/cacheAdapter"; import { loadOwnSessionInitialEvents } from "@src/engines/SessionCore/sync/sessionSyncUtils"; import { createLogger } from "@src/hooks/logger"; import type { Session } from "@src/store/session"; import { sessionsAtom, upsertSession } from "@src/store/session"; import { persistSessions } from "@src/store/session/sessionAtom/persistence"; -import type { ActivityChunk } from "@src/types/session/session"; import { getInstrumentedStore } from "@src/util/core/state/instrumentedStore"; import { isAgentSession, @@ -96,11 +99,24 @@ export interface SessionExportPreview { exportedAt: string; } -export interface SessionExportDraft { +export interface MaterializedSessionExportDraft { + mode: "materialized"; file: SessionExportFile; preview: SessionExportPreview; } +export interface BoundedReplaySessionExportDraft { + mode: "bounded-replay"; + sessionId: string; + replayTarget: ExternalReplayTarget; + orgiiEnvelope: ExternalReplayOrgiiEnvelope; + preview: SessionExportPreview; +} + +export type SessionExportDraft = + | MaterializedSessionExportDraft + | BoundedReplaySessionExportDraft; + export interface SessionImportPreview { originalSessionId: string; displayName: string; @@ -127,6 +143,9 @@ function inferCategory( explicit === "cursor_ide" ) return explicit; + if (explicit === "external_history") { + return isCursorIdeSession(sessionId) ? "cursor_ide" : "cli_agent"; + } if (isCursorIdeSession(sessionId)) return "cursor_ide"; if (isCliSession(sessionId)) return "cli_agent"; return "rust_agent"; @@ -234,18 +253,6 @@ async function loadSessionEventsForExport( session: Session ): Promise { const sessionId = session.session_id; - if (isCursorIdeSession(sessionId)) { - const refresh = await cursorIdeFullRefresh(sessionId); - return processChunksRust(refresh.chunks, sessionId); - } - - if (isCliSession(sessionId)) { - const chunks = await tauriInvoke("cli_agent_chunks", { - sessionId, - }); - return processChunksRust(chunks, sessionId); - } - if (isAgentSession(sessionId)) { return loadOwnSessionInitialEvents(sessionId); } @@ -259,10 +266,51 @@ export async function buildSessionExportDraft( session: Session, fallback: string ): Promise { - const events = await loadSessionEventsForExport(session); const exportedAt = new Date().toISOString(); const category = inferCategory(session.session_id, session.category); const fileName = buildExportFileName(session, fallback); + const previewBase = { + sessionId: session.session_id, + displayName: getSessionListDisplayName(session, fallback), + category, + fileName, + exportedAt, + }; + + const primaryReplayTarget = resolveExternalReplayTarget(session.session_id); + const replayTarget = + primaryReplayTarget ?? + (await resolveSecondaryReplayTarget(session.session_id)); + if (replayTarget) { + // Export preview reads only the compact index plus at most one event. The + // confirmation path streams the compatible envelope in Rust and never + // builds the transcript or the final JSON string in the renderer. + const window = await externalReplayQueryWindowForTarget({ + target: replayTarget, + limits: { + maxTurns: 1, + maxEvents: 1, + maxIpcBytes: 128 * 1024, + }, + }); + return { + mode: "bounded-replay", + sessionId: session.session_id, + replayTarget, + orgiiEnvelope: { + exportedAt, + session: cloneSessionForExport(session), + originalCategory: category, + specs: [], + }, + preview: { + ...previewBase, + eventCount: window.totalEventCount, + }, + }; + } + + const events = await loadSessionEventsForExport(session); const file: SessionExportFile = { format: EXPORT_FORMAT, version: EXPORT_VERSION, @@ -279,14 +327,11 @@ export async function buildSessionExportDraft( }, }; return { + mode: "materialized", file, preview: { - sessionId: session.session_id, - displayName: getSessionListDisplayName(session, fallback), - category, + ...previewBase, eventCount: events.length, - fileName, - exportedAt, }, }; } @@ -304,6 +349,11 @@ export async function buildSessionExportFile( fallback: string ): Promise { const draft = await buildSessionExportDraft(session, fallback); + if (draft.mode === "bounded-replay") { + throw new Error( + "External session exports must be streamed to a destination path" + ); + } return draft.file; } diff --git a/src/shared/externalReplayBudgets.json b/src/shared/externalReplayBudgets.json new file mode 100644 index 000000000..0d23eca81 --- /dev/null +++ b/src/shared/externalReplayBudgets.json @@ -0,0 +1,12 @@ +{ + "replayMaxTurns": 10, + "replayMaxEvents": 200, + "replayMaxIpcBytes": 4194304, + "payloadRangeMaxBytes": 262144, + "shellReplayRangeMaxBytes": 262144, + "cloudSegmentMaxBytes": 262144, + "cloudLegacyV1SegmentMaxBytes": 67108864, + "cloudPageMaxBytes": 4194304, + "cloudPageMaxSegments": 200, + "cloudAttachmentChunkBytes": 180224 +} diff --git a/src/store/session/__tests__/viewAtom.test.ts b/src/store/session/__tests__/viewAtom.test.ts index bfa5c136d..8d4627abe 100644 --- a/src/store/session/__tests__/viewAtom.test.ts +++ b/src/store/session/__tests__/viewAtom.test.ts @@ -63,6 +63,7 @@ async function loadAtoms() { jumpToSessionAtom: mod.jumpToSessionAtom, openSessionAtom: mod.openSessionAtom, closeSessionAtom: mod.closeSessionAtom, + sessionIdAtom: sessionCoreMetadata.sessionIdAtom, sessionReloadEpochMapAtom: sessionCoreMetadata.sessionReloadEpochMapAtom, loadStatusAtom: sessionCoreMetadata.loadStatusAtom, reposAtom: repoAtoms.reposAtom, @@ -428,8 +429,11 @@ describe("closeSessionAtom", () => { sessionViewAtom, activeSessionIdAtom, workstationActiveSessionIdAtom, + sessionIdAtom, } = await loadAtoms(); const store = createStore(); + const payloadRegistry = + await import("@src/engines/SessionCore/payloads/loadedPayloadRegistry"); store.set(sessionViewAtom, { activeSessionId: "to-be-closed", @@ -437,11 +441,18 @@ describe("closeSessionAtom", () => { repoPath: "/repos/x", }); store.set(activeSessionIdAtom, "to-be-closed"); + store.set(sessionIdAtom, "to-be-closed"); + payloadRegistry.markPayloadLoaded("to-be-closed:event:result", "body"); store.set(closeSessionAtom); expect(store.get(workstationActiveSessionIdAtom)).toBeNull(); expect(store.get(activeSessionIdAtom)).toBeNull(); + expect(store.get(sessionIdAtom)).toBeNull(); + expect(payloadRegistry.getLoadedPayloadStats()).toEqual({ + entries: 0, + bytes: 0, + }); const view = store.get(sessionViewAtom); expect(view.sessionName).toBeUndefined(); expect(view.repoPath).toBeUndefined(); diff --git a/src/store/session/cursorIdeTurnSummariesAtom.ts b/src/store/session/cursorIdeTurnSummariesAtom.ts deleted file mode 100644 index 2d790b6f6..000000000 --- a/src/store/session/cursorIdeTurnSummariesAtom.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { atom } from "jotai"; -import { atomFamily } from "jotai-family"; - -import type { CursorIdeTurnSummary } from "@src/api/tauri/externalHistory"; - -export const cursorIdeTurnSummariesAtomFamily = atomFamily( - (sessionId: string) => { - const sessionAtom = atom([]); - sessionAtom.debugLabel = `cursorIdeTurnSummaries(${sessionId})`; - return sessionAtom; - } -); diff --git a/src/store/session/externalReplayTurnSummariesAtom.ts b/src/store/session/externalReplayTurnSummariesAtom.ts new file mode 100644 index 000000000..32ff9114d --- /dev/null +++ b/src/store/session/externalReplayTurnSummariesAtom.ts @@ -0,0 +1,12 @@ +import { atom } from "jotai"; +import { atomFamily } from "jotai-family"; + +import type { ExternalReplayTurnSummary } from "@src/api/tauri/externalHistory"; + +export const externalReplayTurnSummariesAtomFamily = atomFamily( + (sessionId: string) => { + const sessionAtom = atom([]); + sessionAtom.debugLabel = `externalReplayTurnSummaries(${sessionId})`; + return sessionAtom; + } +); diff --git a/src/store/session/index.ts b/src/store/session/index.ts index fa8874a8d..406f1a80b 100644 --- a/src/store/session/index.ts +++ b/src/store/session/index.ts @@ -33,7 +33,7 @@ export * from "./shellProcessAtom"; export * from "./agentRegistryAtom"; export * from "./canvasPreviewAtom"; -export * from "./cursorIdeTurnSummariesAtom"; +export * from "./externalReplayTurnSummariesAtom"; export * from "./mcpProgressAtom"; export * from "./planApprovalAtom"; export * from "./runningLocationAtom"; diff --git a/src/store/session/sessionAtom/__tests__/loaders.test.ts b/src/store/session/sessionAtom/__tests__/loaders.test.ts index 7a9acc1a5..567df48e1 100644 --- a/src/store/session/sessionAtom/__tests__/loaders.test.ts +++ b/src/store/session/sessionAtom/__tests__/loaders.test.ts @@ -97,9 +97,6 @@ describe("replaceExternalHistorySourceFirstPage", () => { groupLabel: "Codex App", listable: true, replayable: true, - supportsWindowedReplay: false, - loadPreviewChunks: async () => [], - loadFullTranscriptChunks: async () => [], } as const; it("replaces only rows for the matching imported-history source", () => { diff --git a/src/store/session/sessionAtom/mutations.ts b/src/store/session/sessionAtom/mutations.ts index 7be7aacd8..f259e3062 100644 --- a/src/store/session/sessionAtom/mutations.ts +++ b/src/store/session/sessionAtom/mutations.ts @@ -28,7 +28,7 @@ * intentional escape hatch for "the user just did something, bump * the row". */ -import { cursorIdeTurnSummariesAtomFamily } from "@src/store/session/cursorIdeTurnSummariesAtom"; +import { externalReplayTurnSummariesAtomFamily } from "@src/store/session/externalReplayTurnSummariesAtom"; import { tuiModeAtom } from "@src/store/session/tuiModeAtom"; import { clearTodosForSessionAtom } from "@src/store/ui/todoAtom"; import { getInstrumentedStore } from "@src/util/core/state/instrumentedStore"; @@ -131,7 +131,7 @@ export const removeSession = (sessionId: string) => { // A removed session has no live viewers, so free its per-session caches. // Without this they accumulate one entry per session for the app lifetime — // and tuiMode additionally leaves a `orgii:tuiMode:` localStorage key. - cursorIdeTurnSummariesAtomFamily.remove(sessionId); + externalReplayTurnSummariesAtomFamily.remove(sessionId); tuiModeAtom.remove(sessionId); if (typeof localStorage !== "undefined") { localStorage.removeItem(`orgii:tuiMode:${sessionId}`); diff --git a/src/store/session/viewAtom.ts b/src/store/session/viewAtom.ts index 5323b7853..6c491b2e8 100644 --- a/src/store/session/viewAtom.ts +++ b/src/store/session/viewAtom.ts @@ -197,6 +197,10 @@ openSessionAtom.debugLabel = "openSessionAtom"; * Close current session — clears both memory and pipeline. */ export const closeSessionAtom = atom(null, (_get, set) => { + // Explicit close owns the same payload/turn/request cleanup as navigation + // away. Snapshot data still follows EventStoreProxy's existing three-minute + // grace; large on-demand payload bodies are released immediately. + set(clearSessionAtom); set(sessionViewAtom, { activeSessionId: null, sessionName: undefined, diff --git a/src/util/session/__tests__/sessionDispatch.test.ts b/src/util/session/__tests__/sessionDispatch.test.ts index 717dbb4eb..d52fa09cf 100644 --- a/src/util/session/__tests__/sessionDispatch.test.ts +++ b/src/util/session/__tests__/sessionDispatch.test.ts @@ -2,6 +2,7 @@ import { CLAUDE_CODE_HISTORY_SESSION_PREFIX, CLI_SESSION_PREFIX, CODEX_APP_SESSION_PREFIX, + COLLABORATION_SNAPSHOT_SESSION_PREFIX, HUMAN_SESSION_PREFIX, OPENCODE_HISTORY_SESSION_PREFIX, OS_AGENT_SESSION_PREFIX, @@ -16,8 +17,10 @@ import { isCliSession, isCodexAppSession, isCollaborationImportedSession, + isCollaborationSnapshotSession, isExternalHistorySession, isHumanSession, + isImportedHistorySession, isOpenCodeHistorySession, isWarpHistorySession, isWindsurfHistorySession, @@ -31,6 +34,7 @@ describe("sessionDispatch constants", () => { expect(HUMAN_SESSION_PREFIX).toBe("humansession-"); expect(CODEX_APP_SESSION_PREFIX).toBe("codexapp-"); expect(CLAUDE_CODE_HISTORY_SESSION_PREFIX).toBe("claudecodeapp-"); + expect(COLLABORATION_SNAPSHOT_SESSION_PREFIX).toBe("imported-session-"); expect(OPENCODE_HISTORY_SESSION_PREFIX).toBe("opencodeapp-"); expect(WINDSURF_HISTORY_SESSION_PREFIX).toBe("windsurfapp-"); expect(WARP_HISTORY_SESSION_PREFIX).toBe("warpapp-"); @@ -90,6 +94,7 @@ describe("getDispatchCategory", () => { expect(getDispatchCategory("opencodeapp-x")).toBe("external_history"); expect(getDispatchCategory("windsurfapp-x")).toBe("external_history"); expect(getDispatchCategory("warpapp-x")).toBe("external_history"); + expect(getDispatchCategory("imported-session-x")).toBe("external_history"); }); it("returns rust_agent for unknown id (default)", () => { @@ -100,10 +105,26 @@ describe("getDispatchCategory", () => { }); describe("external history source detection", () => { + it("recognizes ORGII-owned collaboration snapshots without widening native agents", () => { + expect( + isCollaborationSnapshotSession("imported-session-collaboration") + ).toBe(true); + expect(isImportedHistorySession("imported-session-collaboration")).toBe( + true + ); + expect(isExternalHistorySession("imported-session-collaboration")).toBe( + true + ); + expect(isCollaborationSnapshotSession("imported-session-")).toBe(false); + expect(isImportedHistorySession("imported-session-")).toBe(false); + expect(isImportedHistorySession("sdeagent-native")).toBe(false); + expect(isImportedHistorySession("osagent-native")).toBe(false); + }); + it("recognizes collaboration replay ids without routing them as provider history", () => { expect(isCollaborationImportedSession("imported-session-abc")).toBe(true); expect(isCollaborationImportedSession("codexapp-rollout-1")).toBe(false); - expect(isExternalHistorySession("imported-session-abc")).toBe(false); + expect(getExternalHistorySourceId("imported-session-abc")).toBeUndefined(); }); it("recognizes Codex App imported history sessions", () => { diff --git a/src/util/session/sessionDispatch.ts b/src/util/session/sessionDispatch.ts index 8a0cc0ee6..2b6ca9391 100644 --- a/src/util/session/sessionDispatch.ts +++ b/src/util/session/sessionDispatch.ts @@ -32,6 +32,9 @@ import type { DispatchCategory } from "@src/api/tauri/session"; // Session Prefixes Registry // ============================================ +/** Prefix for ORGII-owned, read-only collaboration snapshots. */ +export const COLLABORATION_SNAPSHOT_SESSION_PREFIX = "imported-session-"; + /** * Lifecycle hooks for IDE session types that require a persistent backend * watch (e.g. a CDP WebSocket to a running IDE renderer). Both callbacks @@ -110,6 +113,12 @@ export const SESSION_PREFIX_REGISTRY: readonly SessionPrefixConfig[] = [ variant: undefined, iconId: "terminal", }, + { + prefix: COLLABORATION_SNAPSHOT_SESSION_PREFIX, + category: "external_history", + variant: undefined, + iconId: "messages-square", + }, ...IMPORTED_HISTORY_SOURCE_DESCRIPTORS.map( (source): SessionPrefixConfig => ({ prefix: source.prefix, @@ -141,8 +150,8 @@ export const HUMAN_SESSION_PREFIX = "humansession-"; /** * Prefix for Cursor IDE history session IDs. The bare composer UUID from * Cursor's `state.vscdb` is wrapped as `${CURSOR_IDE_SESSION_PREFIX}${uuid}` - * before crossing into our system; the prefix is stripped only inside the - * `cursor_ide_chunks` Tauri command. Frontend code never sees the bare UUID. + * before crossing into our system; the bounded replay adapter resolves the + * prefixed ID internally. Frontend code never sees the bare UUID. */ export const CURSOR_IDE_SESSION_PREFIX = "cursoride-"; @@ -231,6 +240,12 @@ export function isCursorIdeSession( export function isExternalHistorySession( sessionId: string | null | undefined ): boolean { + if ( + typeof sessionId === "string" && + sessionId.startsWith(COLLABORATION_SNAPSHOT_SESSION_PREFIX) + ) { + return isCollaborationSnapshotSession(sessionId); + } const config = findPrefixConfig(sessionId); return config?.category === "external_history"; } @@ -238,7 +253,21 @@ export function isExternalHistorySession( export function isImportedHistorySession( sessionId: string | null | undefined ): boolean { - return isCursorIdeSession(sessionId) || isExternalHistorySession(sessionId); + return ( + isCollaborationSnapshotSession(sessionId) || + isCursorIdeSession(sessionId) || + isExternalHistorySession(sessionId) + ); +} + +export function isCollaborationSnapshotSession( + sessionId: string | null | undefined +): boolean { + return ( + typeof sessionId === "string" && + sessionId.startsWith(COLLABORATION_SNAPSHOT_SESSION_PREFIX) && + sessionId.length > COLLABORATION_SNAPSHOT_SESSION_PREFIX.length + ); } /** diff --git a/tests/e2e/specs/core/chat-rendering-ui.spec.mjs b/tests/e2e/specs/core/chat-rendering-ui.spec.mjs index de3f886c1..be128c3f9 100644 --- a/tests/e2e/specs/core/chat-rendering-ui.spec.mjs +++ b/tests/e2e/specs/core/chat-rendering-ui.spec.mjs @@ -47,6 +47,31 @@ async function execJS(script) { return browser.executeScript(script, []); } +async function invokeTauriCommand(command, args = {}) { + const envelope = await browser.executeAsyncScript( + ` + const cb = arguments[arguments.length - 1]; + const command = arguments[0]; + const args = arguments[1]; + const invoke = window.__TAURI_INTERNALS__?.invoke; + if (typeof invoke !== "function") { + cb({ ok: false, error: "Tauri invoke is unavailable" }); + return; + } + Promise.resolve(invoke(command, args)) + .then((result) => cb({ ok: true, result })) + .catch((error) => cb({ ok: false, error: String(error?.message || error) })); + `, + [command, args] + ); + if (envelope?.ok !== true) { + throw new Error( + `Tauri ${command} failed: ${envelope?.error ?? "unknown error"}` + ); + } + return envelope.result; +} + async function waitForFrontendReady() { const port = process.env.E2E_FRONTEND_PORT ?? "1998"; const url = `http://127.0.0.1:${port}`; @@ -105,6 +130,61 @@ async function invokeE2E(method, ...args) { ); } +async function invokeE2EDeferred(method, args, timeoutMs, label) { + const key = `__orgiiE2EDeferred_${RUN_ID}_${Date.now()}_${Math.random() + .toString(36) + .slice(2)}`; + const started = await execJS(` + const key = ${JSON.stringify(key)}; + const method = ${JSON.stringify(method)}; + const args = ${JSON.stringify(args)}; + if (!window.__e2e || typeof window.__e2e[method] !== "function") { + return { ok: false, error: "window.__e2e." + method + " not available" }; + } + const record = { state: "pending" }; + window[key] = record; + Promise.resolve(window.__e2e[method].apply(null, args)) + .then((value) => { + record.state = "fulfilled"; + record.value = value; + }) + .catch((error) => { + record.state = "rejected"; + record.error = String(error && (error.stack || error.message) || error); + }); + return { ok: true }; + `); + if (started?.ok !== true) { + throw new Error(`${label} could not start: ${started?.error ?? "unknown"}`); + } + + let envelope = null; + try { + await browser.waitUntil( + async () => { + envelope = await execJS( + `return window[${JSON.stringify(key)}] ?? null;` + ); + return ( + envelope?.state === "fulfilled" || envelope?.state === "rejected" + ); + }, + { + timeout: timeoutMs, + interval: 500, + timeoutMsg: `${label} did not finish within ${timeoutMs} ms`, + } + ); + } finally { + await execJS(`delete window[${JSON.stringify(key)}];`); + } + + if (envelope?.state === "rejected") { + throw new Error(`${label} failed: ${envelope.error ?? "unknown"}`); + } + return envelope?.value; +} + function makeUserEvent(sessionId, batchIndex) { return { id: `user-tool-ledger-${batchIndex}`, @@ -408,7 +488,10 @@ const ORDER_TEXTS = { answerB: "ORDER_ANSWER_B_after_second_thinking", }; -const OPENCODE_RELOAD_SESSION_ID = `opencodeapp-e2e-reload-${Date.now()}`; +// This fixture validates the normalized chat renderer and persisted-cache +// reload only. A native id keeps it from pretending to exercise OpenCode's +// provider database or bounded-replay adapter. +const OPENCODE_RELOAD_SESSION_ID = `sdeagent-e2e-opencode-render-reload-${Date.now()}`; const OPENCODE_RELOAD_USER_PROMPT = "启动一个(subagent),让它帮我分析当前项目里有多少个 .rs 文件,并生成一份报告。必须要用subagent,然后要让我看到过程"; const OPENCODE_RELOAD_ASSIGNMENT_PROMPT = @@ -2221,7 +2304,7 @@ async function assertOpenCodeSubagentReloadKeepsAnswerAndAssignment() { sessionId: OPENCODE_RELOAD_SESSION_ID, name: OPENCODE_RELOAD_USER_PROMPT, userInput: OPENCODE_RELOAD_USER_PROMPT, - category: "cli_agent", + category: "rust_agent", events, }); if (!seed || seed.ok !== true) { @@ -2254,7 +2337,22 @@ async function assertOpenCodeSubagentReloadKeepsAnswerAndAssignment() { await browser.refresh(); await waitForApp(); - const reopened = await invokeE2E("openSession", OPENCODE_RELOAD_SESSION_ID); + const navigation = await invokeE2E("navigateTo", "/orgii/workstation/code"); + if (!navigation || navigation.ok !== true) { + throw new Error( + `navigateTo after reload failed: ${navigation?.error ?? "unknown"}` + ); + } + // `openSession` may legitimately wait for the post-reload session surface + // for up to 20 seconds. Temporarily widen `waitForApp`'s short script timeout + // for this one async helper, then restore the fail-fast default. + let reopened; + await browser.setTimeout({ script: 30_000 }); + try { + reopened = await invokeE2E("openSession", OPENCODE_RELOAD_SESSION_ID); + } finally { + await browser.setTimeout({ script: 5_000 }); + } if (!reopened || reopened.ok !== true) { throw new Error( `openSession after reload failed: ${reopened?.error ?? "unknown"}` @@ -2799,7 +2897,7 @@ describe("Core chat rendering UI", () => { await assertDedupRenderedOnce(); }); - it("preserves OpenCode subagent assignment and assistant answer after reload", async function () { + it("preserves normalized OpenCode subagent rows after a native cache reload", async function () { if (!shouldRunScenario("opencode-subagent-reload")) { this.skip(); return; diff --git a/tests/e2e/specs/core/external-replay-ui.spec.mjs b/tests/e2e/specs/core/external-replay-ui.spec.mjs new file mode 100644 index 000000000..5d7262b28 --- /dev/null +++ b/tests/e2e/specs/core/external-replay-ui.spec.mjs @@ -0,0 +1,521 @@ +/** + * Dedicated rendered acceptance for Issue #443 bounded external replay. + * It uses the production SessionCore/Tauri path against the real Codex + * session selected by E2E_ISSUE_443_REAL_CODEX_SESSION_ID. + */ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const MOUNT_TIMEOUT_MS = 60_000; +const RUN_ID = Date.now(); +const E2E_REPO_PATH = + process.env.E2E_REPO_PATH ?? "/tmp/orgii-e2e-workspace-repo"; +const ISSUE_443_REAL_CODEX_SESSION_ID = + process.env.E2E_ISSUE_443_REAL_CODEX_SESSION_ID ?? ""; +const ISSUE_443_FIXTURE_CODEX_SESSION_ID = + process.env.E2E_ISSUE_443_FIXTURE_CODEX_SESSION_ID ?? ""; +const MIB = 1024 * 1024; +const SCENARIO_FILTER = (process.env.E2E_CHAT_RENDERING_SCENARIOS ?? "") + .split(",") + .map((value) => value.trim()) + .filter(Boolean); + +function shouldRunScenario(name) { + return SCENARIO_FILTER.length === 0 || SCENARIO_FILTER.includes(name); +} + +function scenarioWasExplicitlyRequested(name) { + return SCENARIO_FILTER.includes(name); +} + +async function execJS(script) { + return browser.executeScript(script, []); +} + +async function invokeTauriCommand(command, args = {}) { + const envelope = await browser.executeAsyncScript( + ` + const cb = arguments[arguments.length - 1]; + const command = arguments[0]; + const args = arguments[1]; + const invoke = window.__TAURI_INTERNALS__?.invoke; + if (typeof invoke !== "function") { + cb({ ok: false, error: "Tauri invoke is unavailable" }); + return; + } + Promise.resolve(invoke(command, args)) + .then((result) => cb({ ok: true, result })) + .catch((error) => cb({ ok: false, error: String(error?.message || error) })); + `, + [command, args] + ); + if (envelope?.ok !== true) { + throw new Error( + `Tauri ${command} failed: ${envelope?.error ?? "unknown error"}` + ); + } + return envelope.result; +} + +async function waitForFrontendReady() { + const port = process.env.E2E_FRONTEND_PORT ?? "1998"; + const url = `http://127.0.0.1:${port}`; + await browser.waitUntil( + async () => { + try { + const response = await fetch(url, { method: "GET" }); + return response.ok; + } catch { + return false; + } + }, + { + timeout: MOUNT_TIMEOUT_MS, + timeoutMsg: `frontend dev server never became ready at ${url}`, + } + ); +} + +async function invokeE2E(method, ...args) { + return browser.executeAsyncScript( + ` + const cb = arguments[arguments.length - 1]; + const method = arguments[0]; + const rest = Array.prototype.slice.call(arguments, 1, arguments.length - 1); + if (!window.__e2e || typeof window.__e2e[method] !== "function") { + cb({ ok: false, error: "window.__e2e." + method + " not available" }); + return; + } + Promise.resolve(window.__e2e[method].apply(null, rest)) + .then(cb) + .catch((e) => cb({ ok: false, error: String(e && e.message || e) })); + `, + [method, ...args] + ); +} + +async function invokeE2EDeferred(method, args, timeoutMs, label) { + const key = `__orgiiE2EDeferred_${RUN_ID}_${Date.now()}_${Math.random() + .toString(36) + .slice(2)}`; + const started = await execJS(` + const key = ${JSON.stringify(key)}; + const method = ${JSON.stringify(method)}; + const args = ${JSON.stringify(args)}; + if (!window.__e2e || typeof window.__e2e[method] !== "function") { + return { ok: false, error: "window.__e2e." + method + " not available" }; + } + const record = { state: "pending" }; + window[key] = record; + Promise.resolve(window.__e2e[method].apply(null, args)) + .then((value) => { + record.state = "fulfilled"; + record.value = value; + }) + .catch((error) => { + record.state = "rejected"; + record.error = String(error && (error.stack || error.message) || error); + }); + return { ok: true }; + `); + if (started?.ok !== true) { + throw new Error(`${label} could not start: ${started?.error ?? "unknown"}`); + } + + let envelope = null; + try { + await browser.waitUntil( + async () => { + envelope = await execJS( + `return window[${JSON.stringify(key)}] ?? null;` + ); + return ( + envelope?.state === "fulfilled" || envelope?.state === "rejected" + ); + }, + { + timeout: timeoutMs, + interval: 500, + timeoutMsg: `${label} did not finish within ${timeoutMs} ms`, + } + ); + } finally { + await execJS(`delete window[${JSON.stringify(key)}];`); + } + + if (envelope?.state === "rejected") { + throw new Error(`${label} failed: ${envelope.error ?? "unknown"}`); + } + return envelope?.value; +} + +async function waitForApp() { + await waitForFrontendReady(); + await browser.setTimeout({ script: 5_000 }); + await execJS(`localStorage.setItem('orgii:auth_skipped', '1'); return true;`); + await browser.waitUntil( + async () => { + try { + return await execJS( + `return document.readyState === 'complete' || document.readyState === 'interactive';` + ); + } catch { + return false; + } + }, + { + timeout: MOUNT_TIMEOUT_MS, + timeoutMsg: "app document never became script-readable", + } + ); + await browser.waitUntil( + async () => { + try { + return await execJS( + `return !!document.querySelector('[data-testid="chat-panel"]');` + ); + } catch { + return false; + } + }, + { timeout: MOUNT_TIMEOUT_MS, timeoutMsg: "chat-panel never mounted" } + ); + await browser.waitUntil( + async () => { + try { + return await execJS( + `return !!(window.__e2e && window.__e2e.seedChatEvents && window.__e2e.listAllTools);` + ); + } catch { + return false; + } + }, + { timeout: 20_000, timeoutMsg: "window.__e2e tool helpers never exposed" } + ); +} + +async function assertIssue443RealCodexSessionStaysBounded() { + if (!ISSUE_443_REAL_CODEX_SESSION_ID) { + throw new Error( + "E2E_ISSUE_443_REAL_CODEX_SESSION_ID is required for the real Codex acceptance scenario" + ); + } + + // A real user reaches an imported session after the sidebar/data-source + // scanner has indexed its source. Keep this setup on the production rescan + // command so the open/release assertions below still exercise the real + // bounded-replay adapter rather than relying on a pre-seeded test cache. + await invokeTauriCommand("external_history_rescan_source", { + source: "codex_app", + clear: false, + }); + + const memoryBefore = await invokeTauriCommand("get_app_memory_snapshot_v1"); + const baselineBytes = Number(memoryBefore?.effective_total_bytes ?? 0); + if (!(baselineBytes > 0)) { + throw new Error( + `native memory baseline is unavailable: ${JSON.stringify(memoryBefore)}` + ); + } + if (process.platform === "darwin") { + if (memoryBefore.measurement !== "native") { + throw new Error( + `#435 regression: expected native macOS memory measurement, got ${memoryBefore.measurement}` + ); + } + let vmmapBytes = 0; + for (const processRow of memoryBefore.processes ?? []) { + if (processRow.metric_kind !== "physical_footprint") { + throw new Error( + `#435 regression: PID ${processRow.pid} used ${processRow.metric_kind}` + ); + } + const { stdout } = await execFileAsync( + "/usr/bin/vmmap", + ["-summary", String(processRow.pid)], + { maxBuffer: 2 * MIB } + ); + const match = stdout.match( + /^Physical footprint:\s+([0-9.]+)\s*([KMGT]?)B?\s*$/im + ); + if (!match) { + throw new Error( + `#435 regression: vmmap omitted Physical footprint for PID ${processRow.pid}` + ); + } + const units = { "": 1, K: 1024, M: MIB, G: 1024 * MIB, T: 1024 ** 4 }; + vmmapBytes += Number(match[1]) * units[match[2].toUpperCase()]; + } + const difference = Math.abs(vmmapBytes - baselineBytes); + const tolerance = Math.max(vmmapBytes * 0.1, 50 * MIB); + if (difference > tolerance) { + throw new Error( + `#435 regression: native snapshot and vmmap differ by ${(difference / MIB).toFixed(1)} MiB (snapshot=${(baselineBytes / MIB).toFixed(1)} MiB, vmmap=${(vmmapBytes / MIB).toFixed(1)} MiB)` + ); + } + console.log( + `[issue-443-real-codex] #435 native=${(baselineBytes / MIB).toFixed(1)} MiB vmmap=${(vmmapBytes / MIB).toFixed(1)} MiB diff=${(difference / MIB).toFixed(1)} MiB` + ); + } + + // WebKit's allocator can retain several render passes before one pressure + // cycle returns pages to the OS. Warm it with five real open/release passes, + // then measure another five; a persistent leak cannot produce a low + // post-release sample in that measured tail. + const warmupCycles = 5; + const measuredCycles = 5; + const cycleCount = warmupCycles + measuredCycles; + const samples = []; + for (let cycle = 0; cycle < cycleCount; cycle += 1) { + const startedAt = Date.now(); + const opened = await invokeE2EDeferred( + "openSession", + [ISSUE_443_REAL_CODEX_SESSION_ID], + 180_000, + `real Codex open cycle ${cycle}` + ); + if (!opened || opened.ok !== true) { + throw new Error( + `real Codex open cycle ${cycle} failed: ${opened?.error ?? "unknown"}` + ); + } + + if (opened.sessionId !== ISSUE_443_REAL_CODEX_SESSION_ID) { + throw new Error( + `real Codex cycle ${cycle} opened the wrong session: ${opened.sessionId}` + ); + } + if (opened.eventCount > 200) { + throw new Error( + `real Codex cycle ${cycle} hydrated ${opened.eventCount} events; hard cap is 200` + ); + } + + const memoryOpen = await invokeTauriCommand("get_app_memory_snapshot_v1"); + const openBytes = Number(memoryOpen?.effective_total_bytes ?? 0); + const reset = await invokeE2E("resetToNewSession"); + if (!reset || reset.ok !== true) { + throw new Error( + `real Codex release cycle ${cycle} failed: ${reset?.error ?? "unknown"}` + ); + } + await browser.pause(1_000); + const memoryReleased = await invokeTauriCommand( + "get_app_memory_snapshot_v1" + ); + samples.push({ + cycle, + elapsedMs: Date.now() - startedAt, + eventCount: opened.eventCount, + openBytes, + openProcesses: (memoryOpen?.processes ?? []).map((processRow) => ({ + pid: processRow.pid, + role: processRow.role, + mib: Number( + (Number(processRow.effective_memory_bytes ?? 0) / MIB).toFixed(1) + ), + })), + releasedBytes: Number(memoryReleased?.effective_total_bytes ?? 0), + releasedProcesses: (memoryReleased?.processes ?? []).map( + (processRow) => ({ + pid: processRow.pid, + role: processRow.role, + mib: Number( + (Number(processRow.effective_memory_bytes ?? 0) / MIB).toFixed(1) + ), + }) + ), + }); + } + + const firstGrowth = Math.max(0, samples[0].openBytes - baselineBytes); + const steadyReference = samples[warmupCycles - 1].releasedBytes; + const measuredTail = samples.slice(warmupCycles); + // A one-second post-switch sample proves the foreground lifecycle released + // its owners, but WebKit may return allocator pages to macOS later. Keep the + // hard 250 MiB threshold and give the renderer one bounded idle window to + // demonstrate that the high-water mark is reclaimable rather than live. + const idleReleaseSamples = []; + for (let sampleIndex = 0; sampleIndex < 6; sampleIndex += 1) { + await browser.pause(5_000); + const memoryIdle = await invokeTauriCommand("get_app_memory_snapshot_v1"); + idleReleaseSamples.push({ + elapsedMs: (sampleIndex + 1) * 5_000, + releasedBytes: Number(memoryIdle?.effective_total_bytes ?? 0), + releasedProcesses: (memoryIdle?.processes ?? []).map((processRow) => ({ + pid: processRow.pid, + role: processRow.role, + mib: Number( + (Number(processRow.effective_memory_bytes ?? 0) / MIB).toFixed(1) + ), + })), + }); + } + const settledCandidates = [...measuredTail, ...idleReleaseSamples]; + const settledBytes = Math.min( + ...settledCandidates.map((sample) => sample.releasedBytes) + ); + const settledGrowth = Math.max(0, settledBytes - baselineBytes); + const stepGrowth = Math.max(0, settledBytes - steadyReference); + const backendMib = (sample) => + sample.releasedProcesses.find((processRow) => processRow.role === "backend") + ?.mib ?? 0; + const backendStepGrowthMib = Math.max( + 0, + Math.min(...settledCandidates.map(backendMib)) - + backendMib(samples[warmupCycles - 1]) + ); + console.log( + `[issue-443-real-codex] baseline=${(baselineBytes / MIB).toFixed(1)} MiB firstGrowth=${(firstGrowth / MIB).toFixed(1)} MiB settledGrowth=${(settledGrowth / MIB).toFixed(1)} MiB measuredStepGrowth=${(stepGrowth / MIB).toFixed(1)} MiB backendStepGrowth=${backendStepGrowthMib.toFixed(1)} MiB samples=${JSON.stringify(samples)} idleSamples=${JSON.stringify(idleReleaseSamples)}` + ); + if (firstGrowth > 400 * MIB) { + throw new Error( + `real Codex first open grew Physical Footprint by ${(firstGrowth / MIB).toFixed(1)} MiB` + ); + } + if (stepGrowth > 64 * MIB) { + throw new Error( + `five measured real Codex open/release cycles grew another ${(stepGrowth / MIB).toFixed(1)} MiB after warmup` + ); + } + if (settledGrowth > 250 * MIB) { + throw new Error( + `real Codex settled Physical Footprint remained ${(settledGrowth / MIB).toFixed(1)} MiB above baseline` + ); + } + if (backendStepGrowthMib > 16) { + throw new Error( + `five measured real Codex cycles grew backend Physical Footprint by ${backendStepGrowthMib.toFixed(1)} MiB` + ); + } +} + +async function assertFixtureCodexSessionUsesBoundedReplay() { + if (!ISSUE_443_FIXTURE_CODEX_SESSION_ID) { + throw new Error( + "The isolated external-replay fixture was not configured by the WDIO harness" + ); + } + await invokeTauriCommand("external_history_rescan_source", { + source: "codex_app", + clear: false, + }); + const beforeCounts = await execJS( + "return { ...(window.__orgiiE2ERpcCounts || {}) };" + ); + const opened = await invokeE2E( + "openSession", + ISSUE_443_FIXTURE_CODEX_SESSION_ID + ); + if (!opened || opened.ok !== true) { + throw new Error( + `fixture Codex open failed: ${opened?.error ?? "unknown error"}` + ); + } + if (opened.sessionId !== ISSUE_443_FIXTURE_CODEX_SESSION_ID) { + throw new Error(`fixture opened the wrong session: ${opened.sessionId}`); + } + if (!(opened.eventCount > 0 && opened.eventCount <= 200)) { + throw new Error( + `fixture hydrated ${opened.eventCount} events; expected 1..200` + ); + } + await browser.waitUntil( + async () => + Boolean( + await execJS( + `return document.body.innerText.includes("E2E bounded replay fixture final answer");` + ) + ), + { + timeout: 20_000, + timeoutMsg: "bounded Codex fixture answer never rendered", + } + ); + const afterOpenCounts = await execJS( + "return { ...(window.__orgiiE2ERpcCounts || {}) };" + ); + const replayOpenCalls = + Number(afterOpenCounts.external_replay_open_window ?? 0) - + Number(beforeCounts.external_replay_open_window ?? 0); + if (replayOpenCalls < 1) { + throw new Error( + "fixture session did not enter the production external_replay_open_window path" + ); + } + const reset = await invokeE2E("resetToNewSession"); + if (!reset || reset.ok !== true) { + throw new Error( + `fixture Codex release failed: ${reset?.error ?? "unknown error"}` + ); + } + await browser.waitUntil( + async () => { + const counts = await execJS( + "return { ...(window.__orgiiE2ERpcCounts || {}) };" + ); + return ( + Number(counts.external_replay_release ?? 0) > + Number(afterOpenCounts.external_replay_release ?? 0) + ); + }, + { + timeout: 10_000, + timeoutMsg: "fixture session never released its replay lease", + } + ); +} + +describe("External replay rendered UI", () => { + before(async () => { + await waitForApp(); + const repo = await invokeE2E("ensureRepoSelected", { + repoPath: E2E_REPO_PATH, + repoName: "E2E Fixture Repo", + }); + if (!repo || repo.ok !== true) { + throw new Error(`ensureRepoSelected failed: ${repo?.error ?? "unknown"}`); + } + const navigation = await invokeE2E("navigateTo", "/orgii/workstation/code"); + if (!navigation || navigation.ok !== true) { + throw new Error(`navigateTo failed: ${navigation?.error ?? "unknown"}`); + } + }); + + it("opens the isolated Codex fixture through bounded external replay", async function () { + if (!shouldRunScenario("issue-443-fixture-codex")) { + this.skip(); + return; + } + if (!ISSUE_443_FIXTURE_CODEX_SESSION_ID) { + if (scenarioWasExplicitlyRequested("issue-443-fixture-codex")) { + throw new Error( + "issue-443-fixture-codex was explicitly requested without the isolated fixture" + ); + } + this.skip(); + return; + } + + await assertFixtureCodexSessionUsesBoundedReplay(); + }); + + it("opens and releases the real #443 Codex session without full hydration or staircase growth", async function () { + if (!shouldRunScenario("issue-443-real-codex")) { + this.skip(); + return; + } + if (!ISSUE_443_REAL_CODEX_SESSION_ID) { + if (scenarioWasExplicitlyRequested("issue-443-real-codex")) { + throw new Error( + "E2E_ISSUE_443_REAL_CODEX_SESSION_ID is required when issue-443-real-codex is explicitly requested" + ); + } + this.skip(); + return; + } + + await assertIssue443RealCodexSessionStaysBounded(); + }); +}); diff --git a/tests/e2e/wdio.conf.mjs b/tests/e2e/wdio.conf.mjs index 6bf1788ed..8d178bada 100644 --- a/tests/e2e/wdio.conf.mjs +++ b/tests/e2e/wdio.conf.mjs @@ -274,6 +274,79 @@ if (orgiiHome) { resetDerivedProjectDatabaseForIsolatedRun(orgiiHome); process.env.ORGII_HOME = orgiiHome; } + +function seedBoundedExternalReplayFixture(targetHome) { + const sourceSessionId = + "rollout-2026-07-25T00-00-00-00000000-0000-4000-8000-000000000443"; + const sessionsDir = join( + targetHome, + ".codex", + "sessions", + "2026", + "07", + "25" + ); + const transcriptPath = join(sessionsDir, `${sourceSessionId}.jsonl`); + mkdirSync(sessionsDir, { recursive: true }); + const rows = [ + { + timestamp: "2026-07-25T00:00:00Z", + type: "event_msg", + payload: { + type: "user_message", + message: "E2E bounded replay fixture question", + }, + }, + { + timestamp: "2026-07-25T00:00:01Z", + type: "event_msg", + payload: { + type: "agent_message", + message: "E2E bounded replay fixture answer", + }, + }, + { + timestamp: "2026-07-25T00:00:02Z", + type: "event_msg", + payload: { + type: "user_message", + message: "E2E bounded replay fixture follow-up", + }, + }, + { + timestamp: "2026-07-25T00:00:03Z", + type: "event_msg", + payload: { + type: "agent_message", + message: "E2E bounded replay fixture final answer", + }, + }, + ]; + writeFileSync( + transcriptPath, + `${rows.map((row) => JSON.stringify(row)).join("\n")}\n`, + "utf8" + ); + process.env.E2E_ISSUE_443_FIXTURE_CODEX_SESSION_ID = `codexapp-${sourceSessionId}`; +} + +// The default rendered suite must exercise an actual external-history driver +// without scanning or mutating the developer's real CLI homes. Explicit real +// Issue 272 runs opt back into the real home with their session id. +const realIssue443SessionConfigured = Boolean( + process.env.E2E_ISSUE_443_REAL_CODEX_SESSION_ID +); +if ( + orgiiHome && + !useRealHome && + !realIssue443SessionConfigured && + !process.env.ORGII_EXTERNAL_HISTORY_HOME +) { + const externalHistoryHome = join(orgiiHome, "external-history-home"); + process.env.ORGII_EXTERNAL_HISTORY_HOME = externalHistoryHome; + seedBoundedExternalReplayFixture(externalHistoryHome); +} + function ensureBenchmarkDockerFixtureRepo() { if (process.env.ORGII_SWE_BENCH_PRO_REPO_PATH) return; const fixtureRoot = join(tmpdir(), "orgii-e2e-swe-bench-pro-fixture");