From 6cb224967db59257adc9298e82903883c2926017 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Fri, 3 Jul 2026 17:51:04 +0800 Subject: [PATCH 01/61] docs: add session artifacts persistence v2 design --- ...session-artifacts-persistence-v2-design.md | 671 ++++++++++++++++++ 1 file changed, 671 insertions(+) create mode 100644 docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md diff --git a/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md b/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md new file mode 100644 index 00000000000..faca6bae74b --- /dev/null +++ b/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md @@ -0,0 +1,671 @@ +# Qwen Code Daemon Session Artifacts V2 持久化设计 + +本文延续 PR #5895 的 V1 session artifact API,设计 V2 持久化能力。目标是在不破坏 V1 live session 语义的前提下,让 artifact metadata 可以在 daemon 重启、session load/replay 后恢复,同时把内容持久化、权限、配额和清理策略收窄到可审计、可回滚的范围内。 + +## 1. 设计结论 + +V2 建议作为一个完整 phase 交付,而不是拆成多个对外版本。这个 phase 内包含两层能力: + +1. Metadata restore:默认恢复 artifact 的结构化 metadata 和资源引用,不复制实际内容。 +2. Content retention:只有用户或可信 publisher 显式 `pin/save` 时,才把可控内容复制进 daemon artifact storage。 + +对应 capability: + +- `session_artifacts_persistence`:支持 metadata 持久化与 session load/replay 恢复。 +- `session_artifacts_content_retention`:支持显式内容保留、配额和 GC。它仍是同一个 V2 phase 的一部分;实现可以用 feature flag / setting 控制启用时机,但对外不再拆成单独版本。 + +核心原则: + +- V1 的 `SessionArtifactStore` 仍是 live session 的权威内存索引。 +- V2 增加持久化 journal/snapshot,用于在创建 live store 时 seed 初始状态。 +- 不把远端 URL 内容抓取到本地。 +- 不默认复制 workspace 文件。 +- 不把 client 传入的 `source`、`clientId`、`trustedPublisher` 当授权依据。 +- 恢复时必须重新校验,不信任磁盘上的旧 metadata。 + +## 2. 用户可见语义 + +### 2.1 页面刷新、切换和重启 + +V2 后的行为应是: + +- 页面刷新:和 V1 一样,只要 daemon/session 还活着,前端重新 `GET /session/:id/artifacts` 即可。 +- 切换 session:每个 live session 仍有独立 artifact store。 +- 前端实例重启:daemon 还在时可 GET 当前 live store。 +- daemon/bridge 重启:如果 session 被重新 load,V2 从持久化 metadata 恢复 artifact list。 +- 历史 load/replay:如果该 session 有 V2 persistence records,恢复 artifact list;没有则返回空 list。 + +### 2.2 retention 分层 + +新增 optional field: + +```ts +type ArtifactRetention = 'ephemeral' | 'restorable' | 'pinned'; +``` + +含义: + +- `ephemeral`:只存在于 live store。daemon/session 消失后不恢复。 +- `restorable`:metadata 写入持久化 journal。session load/replay 后恢复为 artifact item,但不保证底层资源仍存在。 +- `pinned`:metadata 持久化,并尽量保存可控内容或稳定 content reference;直到用户 unpin/delete 或 GC 策略命中。 + +建议默认: + +- Tool result、`record_artifact`、hook artifact:默认 `restorable`,但只持久化 metadata。 +- 用户在交互式前端手动注册的 Client POST artifact:默认 `restorable`,恢复后仍出现在 artifact list 中。 +- 后台/自动化 client POST:如果只是临时 UI 状态,应显式请求 `retention: "ephemeral"`;SDK 应提供明确的 ephemeral helper,避免后台调用误用默认持久化语义。 +- `published` artifact:默认 `restorable`;如果 publisher 提供可校验 managed content,则可升级为 `pinned`。 + +如果 chat recording 被禁用,metadata persistence 默认禁用,capability 不声明或只声明为 disabled 状态。 + +### 2.3 用户注册 artifact 恢复语义 + +用户手动注册的 artifact 在 V2 恢复后应该继续存在,但恢复的是“artifact metadata item”,不是无条件内容备份。 + +恢复后的结果按资源状态区分: + +- `external_url`:恢复 title、description、url、metadata。daemon 不访问远端 URL;URL 是否仍可打开由 client 点击时决定。 +- `workspace`:恢复 workspacePath 和 metadata;如果文件仍在 workspace 内且可访问,`status: "available"`;如果文件已删除、移动或 symlink 逃逸,`status: "missing"` 或 `restoreState: "blocked"`。 +- `managed`:恢复 managedId;只有 managed storage manifest 仍能解析时才 `available`。 +- `published`:恢复 published URL;只有仍满足 trusted publisher 校验时才保留 published trust。 + +因此,“用户注册的 artifact 恢复后还存在吗?”的答案是:V2 中应该存在于列表里,除非用户 DELETE、metadata 被 GC/tombstone、恢复校验发现记录损坏到无法安全展示,或 chat recording / persistence 被禁用。是否还能打开底层内容,则取决于 storage 类型和是否做了 `pin/save content`。 + +daemon 不能只凭 request payload 判断“手动”还是“后台”。实现上应由连接 principal、SDK helper 或 UI action path 标识交互式注册来源;无法确认交互意图的 client 应按显式 `retention` 处理,缺省仍接受 `restorable` 但受 session metadata quota 和审计记录约束。 + +## 3. 数据模型 + +### 3.1 Public artifact 扩展 + +V2 在 V1 response artifact 上增加 optional fields: + +```ts +interface DaemonSessionArtifact { + // V1 fields... + retention?: 'ephemeral' | 'restorable' | 'pinned'; + persistedAt?: string; + expiresAt?: string; + restoreState?: 'live' | 'restored' | 'unverified' | 'blocked'; + contentRef?: { + kind: 'managed_copy' | 'published' | 'workspace_reference'; + id?: string; + sha256?: string; + sizeBytes?: number; + }; +} +``` + +字段说明: + +- `retention`:artifact 的持久化级别。缺省按 V1 兼容处理为 live-only。 +- `persistedAt`:metadata 或 content retention 最近成功落盘时间。 +- `expiresAt`:可被 GC 的时间。`pinned` 默认不设置。 +- `restoreState`:恢复来源提示;不替代 `status`。 +- `contentRef`:仅在内容保留或可信 published storage 时出现,不暴露宿主机绝对路径。 + +### 3.2 Status 与 restoreState 的关系 + +V1 `status` 继续表示当前资源是否可用: + +- `available` +- `missing` + +V2 不建议直接把 `status` 扩成复杂状态来表达恢复来源,避免旧 client 误解。恢复后的不确定性放在 `restoreState`: + +- `restored`:从持久化 metadata 恢复。 +- `unverified`:恢复了 metadata,但尚未完成 workspace/managed 校验。 +- `blocked`:恢复时发现安全边界不满足,例如 workspace path 逃逸。 +- `live`:当前进程内新产生或已刷新确认。 + +## 4. 持久化存储设计 + +### 4.1 双存储:JSONL journal + sidecar snapshot + +建议使用两层存储: + +1. Chat JSONL system records:审计源与跨版本恢复源。 +2. Session artifact sidecar:快速加载缓存。 + +JSONL 是 source of truth;sidecar 是可删除缓存。sidecar 损坏或版本不匹配时,从 JSONL 重建。 + +### 4.2 JSONL system record + +给 `ChatRecord.subtype` 增加: + +```ts +'session_artifact_event' | 'session_artifact_snapshot'; +``` + +Payload: + +```ts +interface SessionArtifactEventRecordPayload { + v: 2; + sessionId: string; + sequence: number; + changes: Array<{ + action: 'upsert' | 'remove'; + artifactId: string; + artifact?: DaemonSessionArtifact; + reason?: 'explicit' | 'eviction' | 'restore_pruned' | 'unpin'; + }>; +} + +interface SessionArtifactSnapshotRecordPayload { + v: 2; + sessionId: string; + sequence: number; + generatedAt: string; + artifacts: DaemonSessionArtifact[]; +} +``` + +只写经过 store validation/normalization 后的 public artifact shape,不写内部字段: + +- 不写 `identityKey` +- 不写 `trustedPublisher` +- 不写绝对 `workspaceCwd` +- 不写 transport token / auth principal + +删除 artifact 必须写 tombstone change,避免历史 replay 后被旧 upsert 复活。 + +### 4.3 Sidecar snapshot + +建议路径: + +```text +~/.qwen/tmp//chats/.artifacts.json +``` + +示例: + +```json +{ + "v": 2, + "sessionId": "session-123", + "projectHash": "abc123", + "sourceChatFile": "session-123.jsonl", + "sourceSizeBytes": 20480, + "sourceMtimeMs": 1783070000000, + "sourceArtifactSequence": 42, + "generatedAt": "2026-07-03T10:00:00.000Z", + "sequence": 42, + "artifacts": [] +} +``` + +写入策略: + +- temp file + fsync + atomic rename。 +- 读取时同时校验 `sessionId`、`projectHash`、`sourceChatFile`、`sourceSizeBytes`、`sourceMtimeMs` 和 `sourceArtifactSequence`;任一不匹配都 fallback 到 JSONL。 +- JSON parse 失败时丢弃 sidecar,重建。 +- session archive/unarchive 时与 chat JSONL 一起移动。 +- session delete 时一起删除。 + +`sequence` 表示 sidecar snapshot 自身对应的 artifact 序号;`sourceArtifactSequence` 表示源 JSONL 中最新 artifact record 序号。正常情况下两者相同;保留两个字段可以让 sidecar 重建或压缩后仍能校验缓存来源。 + +### 4.4 为什么不用 sidecar 作为唯一存储 + +只用 sidecar 会缺少审计链,也容易在 fork/branch/session copy 时漏掉 artifact 状态。JSONL system record 与现有 session 历史系统一致,天然支持: + +- append-only crash safety +- branch/fork 复制 active record chain +- resume/load 时统一读取 +- 版本迁移时 tolerant reader + +sidecar 只解决性能,不承载协议正确性。 + +### 4.5 JSONL + sidecar 双存储的空间消耗 + +双存储会重复保存“当前 artifact snapshot metadata”,但重复的是小型 metadata,不是 artifact 内容。空间消耗应按 metadata 与 content retention 分开看: + +- Metadata 单条通常约 0.5 KB - 2 KB,取决于 title、description、url 和 metadata 大小。 +- 每 session 500 条 persisted metadata 时,当前 snapshot 约 250 KB - 1 MB。 +- sidecar 保存一份当前 snapshot,因此额外增加约 250 KB - 1 MB。 +- JSONL journal 还会保存增量事件和 tombstone;如果不做 snapshot compaction,长会话可能继续增长。 +- content retention 才是主要空间来源,例如单 artifact 50 MB、单 session 200 MB、单 project 1 GB。 + +建议控制策略: + +- artifact event journal 达到固定阈值后写 `session_artifact_snapshot`,例如每 100 次 artifact mutation 或每 256 KB artifact journal 写一次。 +- load 时只需要读取最新 snapshot 之后的 artifact events。 +- sidecar 只保留最新当前态,不保存历史事件。 +- 不把 content bytes 写进 JSONL 或 sidecar;content 只进入 daemon managed artifact storage。 +- metadata 超过每 session 上限时写 `restore_pruned` tombstone,避免历史 replay 复活被裁剪条目。 + +在这些限制下,典型 session 的 artifact metadata 持久化开销应保持在 MB 级以内;真正需要关注的容量风险是 pinned content storage,而不是 JSONL + sidecar metadata。 + +## 5. 写入与恢复流程 + +### 5.1 Artifact 写入流程 + +V1 流程: + +```text +ingest input -> normalize/validate -> upsert live store -> publish artifact_changed +``` + +V2 流程: + +```text +ingest input + -> normalize/validate + -> apply live-store mutation in operationQueue + -> for restorable/pinned changes: append artifact journal + -> update sidecar snapshot best-effort + -> publish artifact_changed +``` + +持久化必须在同一个 store operation queue 内串行执行,避免 live store、journal、SSE 顺序错乱。 + +### 5.2 写入失败语义 + +区分两个入口: + +- 普通 tool/hook artifact:持久化失败不应让工具调用失败;artifact 仍可进入 live store,但必须先把 live store 中的 `retention` 降级为 `ephemeral`,再发布 `artifact_changed`,并记录 structured warning。 +- 显式 `pin/save` API:持久化失败必须返回错误,不能假装已经保存。 + +建议 warning: + +```text +[artifacts] session= action=persist_failed artifact= reason= +``` + +### 5.3 恢复流程 + +session load/replay 时: + +1. `SessionService.loadSession()` 读取 JSONL。 +2. 提取 active branch 中的 `session_artifact_snapshot` 和之后的 `session_artifact_event`。 +3. 重建 artifact snapshot,应用 tombstone。 +4. 对每个 artifact 重新执行 V2 restore validation。 +5. 把恢复结果放入 `ResumedSessionData.artifactSnapshot`。 +6. `AcpAgent.createAndStoreSession()` 创建 `Session` 后,用 snapshot 初始化 `SessionArtifactStore`。 +7. client 初次 GET 时看到恢复后的 artifact list。 + +如果 sidecar 可用且版本、projectHash、sessionId、source file metadata 和 artifact sequence 都匹配,可直接用 sidecar;否则 fallback 到 JSONL replay。 + +### 5.4 恢复时校验 + +恢复时必须重新校验: + +- `workspacePath`:仍必须是相对路径,realpath/stat 后不能逃逸当前 workspace。 +- `url`:重新校验 scheme,只允许 `http:` / `https:`;拒绝 username/password credential。 +- `managedId`:拒绝路径形态、`..`、绝对路径、分隔符。 +- `published`:必须能映射到受信任 publisher manifest,不能只因为历史里写了 `storage: "published"` 就恢复 trust。 +- `metadata`:重新执行 primitive-only、size limit 和 unsafe display payload checks。 + +恢复失败时: + +- 安全失败:保留条目但 `restoreState: "blocked"`,`status: "missing"`,不提供可打开 locator。 +- 资源缺失:`status: "missing"`。 +- 非安全型字段损坏:跳过该 artifact,并记录 warning。 + +### 5.5 Branch / fork 语义 + +现有 `/branch` 会复制 active JSONL record chain 并重写 `sessionId`。V2 artifact records 也会跟随复制,因此必须显式处理 artifact id: + +- 同一个资源在新 session 中应得到新 artifact id,因为 V1 identity 包含 `sessionId`。 +- fork 写入目标 session 时,应根据目标 `sessionId + locator` 重新计算 artifact id。 +- tombstone 也要按目标 session 的新 id 重写,不能保留源 session 的 artifact id。 +- `forkedFrom` 可以记录原 session id / 原 artifact id,作为审计信息,但不能参与新 session 的权限判断。 +- `pinned` content 可以共享底层 contentRef,但必须用引用计数或 manifest 引用表管理 GC,避免删除一个 session 时误删另一个 fork 仍在引用的内容。 + +fork remap 是发布门槛:如果某条路径不能安全重写 artifact id 和 tombstone,就必须在 fork 时丢弃 artifact persistence records,不能把源 session 的 artifact id 原样带入新 session。 + +## 6. API 设计 + +### 6.1 Capability + +`GET /capabilities` 增加: + +```json +"session_artifacts_persistence" +``` + +内容保留实现可用时,同时声明: + +```json +"session_artifacts_content_retention" +``` + +可选 details: + +```json +{ + "session_artifacts_persistence": { + "metadata": true, + "defaultRetention": "restorable", + "maxRestorableArtifacts": 500 + }, + "session_artifacts_content_retention": { + "enabled": true, + "maxPinnedArtifactBytes": 52428800, + "maxPinnedSessionBytes": 209715200 + } +} +``` + +`enabled` 表示当前配置下是否允许使用 content retention。若实现已具备但用户设置关闭,details 可以返回 `enabled: false`;如果现有 capability 结构只支持 string feature,则只有 `pin/save content`、quota、hash、manifest 和 GC 都可用且当前启用时,才声明 `session_artifacts_content_retention`。 + +### 6.2 Add artifact + +`POST /session/:id/artifacts` 允许 optional: + +```json +{ + "title": "Report", + "kind": "html", + "storage": "workspace", + "workspacePath": "reports/run.html", + "retention": "restorable" +} +``` + +限制: + +- client 不能请求 `pinned`,只能请求 `restorable`。 +- `pinned` 必须走 pin/save API,因为可能涉及内容复制、配额、确认和失败回滚。 + +### 6.3 Pin/save artifact + +新增: + +```http +POST /session/:id/artifacts/:artifactId/pin +Content-Type: application/json +``` + +Body: + +```json +{ + "mode": "metadata" +} +``` + +或在 content retention capability 开启后: + +```json +{ + "mode": "content", + "ttlDays": 90 +} +``` + +语义: + +- `mode: "metadata"`:升级为 `restorable`。 +- `mode: "content"`:复制或绑定可控内容,升级为 `pinned`。 +- 成功返回 mutation result,并发布 `artifact_changed` / `updated`。 +- 失败返回明确错误,不改变 artifact retention。 + +### 6.4 Unpin + +```http +DELETE /session/:id/artifacts/:artifactId/pin +``` + +语义: + +- `pinned` 降级为 `restorable` 或 `ephemeral`,由请求参数决定。 +- 默认只删除 content retention,不删除 metadata。 +- 若要从列表移除,仍使用 V1 DELETE。 + +### 6.5 Delete artifact + +V2 的 DELETE 仍保持 V1 幂等,但要追加 tombstone: + +- 从 live store 移除。 +- 写 `session_artifact_event` remove tombstone。 +- metadata restore 时不再复活。 +- 默认不立即同步删除 managed/pinned content,但会释放该 artifact 对 contentRef 的引用;GC 默认清理无其它 session 引用的 daemon-managed content。 + +可选 body 或独立 endpoint 支持内容删除: + +```json +{ + "deleteContent": true +} +``` + +`deleteContent` 表示请求立即删除可删除内容,必须经过更严格授权或用户确认;共享 contentRef 只能在引用计数归零后删除。 + +## 7. 安全设计 + +### 7.1 授权原则 + +不要把 public `clientId` 当授权边界。V2 应引入 daemon 内部 mutation principal: + +```ts +type ArtifactPrincipal = + | { kind: 'session_owner' } + | { kind: 'client_connection'; id: string } + | { kind: 'trusted_publisher'; id: string } + | { kind: 'hook'; extensionId: string }; +``` + +授权规则: + +- list:需要 session read 权限。 +- add restorable:需要 session mutate 权限。 +- pin/save content:需要 session owner 或用户确认。 +- delete metadata:需要 session mutate 权限;如果要保留 per-client ownership,则必须用内部 principal,不用 payload clientId。 +- delete content:需要 session owner 或创建该 contentRef 的 principal。 + +### 7.2 持久化内容边界 + +默认不复制: + +- external URL 内容 +- 任意 workspace 文件 +- 普通 assistant link + +允许内容 retention 的来源: + +- trusted `ArtifactTool` / publisher 生成的 `published` artifact。 +- 用户显式 pin 的 workspace artifact,且文件在 workspace 内、类型/大小可控。 +- client 上传或登记的 managed artifact,前提是通过 daemon API 接收并校验。 + +内容复制要求: + +- workspace containment 校验通过。 +- 文件大小低于默认 cap,例如 50 MB。 +- 保存 sha256、size、mimeType。 +- 打开/下载前重新校验 hash。 +- 不 follow symlink escape。 + +### 7.3 隐私和敏感信息 + +持久化前要做最小化: + +- 不保存 host 绝对路径。 +- 不保存 URL username/password。 +- metadata 仍限制 4 KB。 +- title/description/metadata 继续执行 unsafe display payload checks。 +- 对看起来像 secret 的 metadata key 可拒绝或 redacted,例如 `token`、`password`、`secret`。 + +建议新增设置: + +```json +{ + "sessionArtifacts": { + "persistence": { + "enabled": true, + "defaultRetention": "restorable", + "contentRetention": { + "enabled": false, + "maxArtifactBytes": 52428800, + "maxTotalBytes": 1073741824 + } + } + } +} +``` + +## 8. 配额、GC 与稳定性 + +### 8.1 Metadata quota + +建议默认: + +- live store 上限仍为 200。 +- persisted metadata 上限每 session 500。 +- snapshot record 最多保留 500 个当前有效 artifacts。 + +超过 persisted metadata 上限: + +- `ephemeral` 本来不写 journal,不计入 persisted metadata quota,只受 live store 上限约束。 +- `restorable` 可按最旧未 pinned 裁剪,并写 `restore_pruned` tombstone。 +- `pinned` 不因 metadata quota 被裁剪,但可被 global content quota 约束。 + +### 8.2 Content quota + +建议默认: + +- 单 artifact:50 MB。 +- 单 session pinned content:200 MB。 +- 单 project pinned content:1 GB。 + +达到上限时: + +- 新 pin/save 返回 `QUOTA_EXCEEDED`。 +- 不自动删除 pinned content,除非用户设置 TTL 或 explicit GC policy。 + +### 8.3 GC + +GC 只处理 daemon 管理的 content storage 和过期 metadata cache: + +- 删除已被 tombstone 且无其它 session 引用的 managed copy。 +- 删除超过 `expiresAt` 的 non-pinned content。 +- 清理 orphan sidecar。 +- session delete 时删除 sidecar,并对 pinned content 做引用计数递减;默认删除无其它 session 引用的 content。只有用户显式开启“保留已 pin 内容”或导出到全局 artifact library 时,才在 session 删除后继续保留。 + +GC 必须 best-effort,不阻塞 prompt/tool flow。 + +### 8.4 Crash consistency + +要求: + +- artifact store mutation 串行。 +- JSONL journal append 失败不会破坏 live store。 +- explicit `pin/save metadata` 必须等待 journal 落盘;sidecar 仍是 best-effort cache。 +- explicit `pin/save content` 必须等待 content manifest 和 journal 都落盘;sidecar 仍是 best-effort cache。 +- sidecar 原子写。 +- reader 容忍半截 JSONL 和 corrupt sidecar。 + +### 8.5 文件读取、CPU 与 I/O 成本 + +V2 要避免把 artifact 恢复变成 session load 的新瓶颈。 + +读取路径建议: + +1. 优先读 sidecar:一次小 JSON 文件读取和 parse,成本约 O(当前 artifact 数量)。 +2. sidecar 缺失、损坏、版本不匹配或 source metadata 不匹配时,fallback 到 JSONL。 +3. JSONL fallback 时在已有 session JSONL 读取过程中找到最新 `session_artifact_snapshot`,只 replay 之后的 artifact events;没有独立索引时允许一次顺序扫描,但不能在 load 流程里反复扫同一文件。 +4. 如果现有 `SessionService.loadSession()` 已经完整读取 JSONL,可在同一轮 parse 中顺手提取 artifact records,避免二次扫文件。 + +CPU 成本边界: + +- Metadata restore 只 parse JSON 和做字段校验,复杂度 O(artifact 数量 + 最新 snapshot 后事件数)。 +- `external_url` 恢复不发网络请求。 +- `workspace` 恢复只做 path normalization、realpath/stat 等元数据检查,不读取文件内容。 +- `managed` / `published` 恢复只查 manifest,不读取大文件内容。 +- content hash 校验只在显式 `pin/save content` 或打开 retained content 时触发,不在每次 session load 时全量 hash。 + +I/O 成本边界: + +- 每次 session load 最多读 sidecar 一次;fallback 才读 JSONL artifact records。 +- workspace 状态校验复用 V1 的 TTL/batch 策略,不在 GET 热路径对所有 artifact 做无限制 stat。 +- 对大 workspace 文件,不在恢复阶段读内容;只有用户显式 pin/save 时才读取并 hash。 + +推荐默认: + +- sidecar artifact snapshot 上限 500 条。 +- workspace status restore batch size 20,与 V1 保持一致。 +- artifact journal snapshot 阈值 100 mutations 或 256 KB。 +- content hash 在 pin/save 时同步完成;恢复时 lazy verify 或后台 best-effort verify。 + +这样 session load 的常规成本是读取一个小 sidecar;只有 sidecar 不可用时才退化为 JSONL replay,而 replay 也被 snapshot 阈值限制在较小范围内。 + +## 9. 实现方案 + +以下是同一个 V2 phase 内的实现里程碑。工程上可以按 PR 拆开,但对外发布口径仍是一项完整的持久化能力。 + +### Milestone A: 类型和 persistence service + +- 新增 `ArtifactPersistenceService`,职责: + - append event/snapshot record + - write sidecar atomically + - rebuild from JSONL + - restore validation +- 扩展 `ChatRecord.subtype` 与 `systemPayload` union。 +- 增加 `ResumedSessionData.artifactSnapshot?`。 + +### Milestone B: store 集成 + +- `SessionArtifactStore` 支持 seed artifacts。 +- `upsertMany()` 根据 `retention` 决定是否调用 persistence。 +- `remove()` 写 tombstone。 +- 保持 V1 `artifact_changed` event shape 不变,只增加 optional fields。 + +### Milestone C: load/replay 集成 + +- `SessionService.loadSession()` 提取 artifact snapshot。 +- `AcpAgent.createAndStoreSession()` 创建 session 后 seed store。 +- replay 历史时同 identity artifact 不重复创建。 + +### Milestone D: REST/SDK + +- SDK type 增加 optional fields。 +- `POST /session/:id/artifacts` 支持 `retention: "restorable"`。 +- 新增 `pinArtifact()` / `unpinArtifact()` SDK 方法。 +- capability gate UI。 + +### Milestone E: content retention + +- 增加 daemon managed artifact storage manifest。 +- 实现 pin content、hash 校验、quota、GC。 +- 支持 published artifact 绑定 trusted contentRef。 + +## 10. 测试计划 + +必须覆盖: + +- metadata journal append 后 daemon restart/load 恢复 artifact list。 +- DELETE tombstone 后 load 不复活 artifact。 +- sidecar source metadata 过期时 fallback 到 JSONL,不能读取陈旧 snapshot。 +- workspace artifact 恢复时文件存在/缺失/ symlink escape 三种状态。 +- external URL 只恢复 metadata,不发网络请求。 +- corrupt sidecar fallback 到 JSONL。 +- corrupt JSONL record 被跳过且不影响其它 artifacts。 +- chat recording / persistence disabled 时不声明或不启用 metadata restore。 +- pin/save 显式写失败时返回错误。 +- tool artifact 持久化失败时降级为 live-only,不影响 tool turn。 +- branch/fork 时 artifact records 的 sessionId/id 处理。 +- archive/unarchive/delete session 时 sidecar、content refcount 和 GC 行为。 +- SDK 旧 client 忽略 optional fields 后仍能展示 V1 artifacts。 + +## 11. 不建议在 V2 做的事 + +- 自动抓取普通 markdown link。 +- 自动扫描 workspace 文件变更。 +- 默认复制所有 workspace artifact 内容。 +- 对 external URL 做 reachability poll。 +- 把 `clientId` 作为删除或 pin/save 的授权凭证。 +- 在 GET 热路径里做大量 fs/network 校验。 +- 把持久化失败变成普通 tool turn 失败。 + +## 12. 推荐发布口径 + +V2 建议作为一个完整 phase 发布: + +- capability:`session_artifacts_persistence` +- capability:`session_artifacts_content_retention` +- 默认恢复显式登记的 artifact metadata。 +- 用户手动注册的 artifact 默认 `restorable`,session load/replay 后继续出现在列表中。 +- 显式 `pin/save` 才做 content retention。 +- quota、hash、manifest、GC 必须和 `pin/save` 同期具备,不能只暴露保存入口。 +- 用户文档明确:metadata restore 恢复的是“产物索引”,不是“产物内容备份”;content retention 才是受配额和 GC 约束的内容保存。 + +这样可以一次性讲清楚 V2 的完整语义:默认恢复列表,显式保存内容,所有长期存储都受权限、配额、hash 和 GC 约束。 From 9b3a00ca1e137a401926a6d903606eb682fbfafb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Sat, 4 Jul 2026 18:15:56 +0800 Subject: [PATCH 02/61] docs: address session artifacts persistence review --- ...session-artifacts-persistence-v2-design.md | 348 ++++++++++-------- 1 file changed, 200 insertions(+), 148 deletions(-) diff --git a/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md b/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md index faca6bae74b..0a772f25ee3 100644 --- a/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md +++ b/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md @@ -1,23 +1,28 @@ # Qwen Code Daemon Session Artifacts V2 持久化设计 -本文延续 PR #5895 的 V1 session artifact API,设计 V2 持久化能力。目标是在不破坏 V1 live session 语义的前提下,让 artifact metadata 可以在 daemon 重启、session load/replay 后恢复,同时把内容持久化、权限、配额和清理策略收窄到可审计、可回滚的范围内。 +本文延续 PR #5895 的 V1 session artifact API,设计 V2 持久化能力。V1 设计见同目录下的 [session-artifacts-daemon-api-implementation-design.md](./session-artifacts-daemon-api-implementation-design.md)。 + +V2 的目标是在不破坏 V1 live session 语义的前提下,让 artifact metadata 可以在 daemon 重启、session load/replay 后恢复,同时把内容持久化、权限、配额和清理策略收窄到可审计、可回滚的范围内。 ## 1. 设计结论 -V2 建议作为一个完整 phase 交付,而不是拆成多个对外版本。这个 phase 内包含两层能力: +V2 是一个完整设计 phase,但对外能力仍按 capability gate 暴露。实现可以先交付 metadata restore,再在 quota、hash、manifest 和 GC 都具备后声明 content retention capability;client 不应依赖“V2”这个阶段名推断功能,而应读取 capability。 + +两层能力: 1. Metadata restore:默认恢复 artifact 的结构化 metadata 和资源引用,不复制实际内容。 -2. Content retention:只有用户或可信 publisher 显式 `pin/save` 时,才把可控内容复制进 daemon artifact storage。 +2. Content retention:只有用户或可信 publisher 显式 `pin/save` 时,才把可控内容复制或绑定进 daemon-managed artifact storage。 对应 capability: - `session_artifacts_persistence`:支持 metadata 持久化与 session load/replay 恢复。 -- `session_artifacts_content_retention`:支持显式内容保留、配额和 GC。它仍是同一个 V2 phase 的一部分;实现可以用 feature flag / setting 控制启用时机,但对外不再拆成单独版本。 +- `session_artifacts_content_retention`:支持显式内容保留、配额、hash、manifest 和 GC。只有这一整套能力可用且当前配置启用时才声明。 核心原则: - V1 的 `SessionArtifactStore` 仍是 live session 的权威内存索引。 -- V2 增加持久化 journal/snapshot,用于在创建 live store 时 seed 初始状态。 +- V2 增加 JSONL artifact journal/snapshot,用于在 daemon 侧创建 live store 时 seed 初始状态。 +- V2 默认 JSONL-only。sidecar cache 不进入 V2 发布门槛;只有实测 session load 成本不可接受时,才另行设计可删除缓存。 - 不把远端 URL 内容抓取到本地。 - 不默认复制 workspace 文件。 - 不把 client 传入的 `source`、`clientId`、`trustedPublisher` 当授权依据。 @@ -35,6 +40,8 @@ V2 后的行为应是: - daemon/bridge 重启:如果 session 被重新 load,V2 从持久化 metadata 恢复 artifact list。 - 历史 load/replay:如果该 session 有 V2 persistence records,恢复 artifact list;没有则返回空 list。 +V1 到 V2 的 live upgrade 需要单独处理:已经在内存中的 V1 live artifacts 没有 JSONL journal。V2 daemon 首次触达这些 live sessions 时,应在 daemon-side bridge 从当前 live store 写入一条初始 `session_artifact_snapshot`,然后再接受新的 restorable/pinned artifact mutation。若 backfill 失败,该 session 继续保持 V1 live-only 行为,并记录 structured warning;不能让用户误以为已有 live artifacts 已经可恢复。 + ### 2.2 retention 分层 新增 optional field: @@ -47,14 +54,14 @@ type ArtifactRetention = 'ephemeral' | 'restorable' | 'pinned'; - `ephemeral`:只存在于 live store。daemon/session 消失后不恢复。 - `restorable`:metadata 写入持久化 journal。session load/replay 后恢复为 artifact item,但不保证底层资源仍存在。 -- `pinned`:metadata 持久化,并尽量保存可控内容或稳定 content reference;直到用户 unpin/delete 或 GC 策略命中。 +- `pinned`:metadata 持久化,并保存可控内容或稳定 content reference;直到用户 unpin/delete、TTL 到期或 GC 策略命中。 -建议默认: +默认规则: - Tool result、`record_artifact`、hook artifact:默认 `restorable`,但只持久化 metadata。 - 用户在交互式前端手动注册的 Client POST artifact:默认 `restorable`,恢复后仍出现在 artifact list 中。 -- 后台/自动化 client POST:如果只是临时 UI 状态,应显式请求 `retention: "ephemeral"`;SDK 应提供明确的 ephemeral helper,避免后台调用误用默认持久化语义。 -- `published` artifact:默认 `restorable`;如果 publisher 提供可校验 managed content,则可升级为 `pinned`。 +- 后台/自动化 client POST:如果只是临时 UI 状态,应显式请求 `retention: "ephemeral"`;SDK 应提供明确的 ephemeral helper。 +- `published` artifact:默认 `restorable`;如果 publisher 提供可校验 daemon-managed content,则可升级为 `pinned`。 如果 chat recording 被禁用,metadata persistence 默认禁用,capability 不声明或只声明为 disabled 状态。 @@ -67,11 +74,11 @@ type ArtifactRetention = 'ephemeral' | 'restorable' | 'pinned'; - `external_url`:恢复 title、description、url、metadata。daemon 不访问远端 URL;URL 是否仍可打开由 client 点击时决定。 - `workspace`:恢复 workspacePath 和 metadata;如果文件仍在 workspace 内且可访问,`status: "available"`;如果文件已删除、移动或 symlink 逃逸,`status: "missing"` 或 `restoreState: "blocked"`。 - `managed`:恢复 managedId;只有 managed storage manifest 仍能解析时才 `available`。 -- `published`:恢复 published URL;只有仍满足 trusted publisher 校验时才保留 published trust。 +- `published`:恢复 published locator;只有仍满足 trusted publisher manifest 校验时才保留 published trust。 因此,“用户注册的 artifact 恢复后还存在吗?”的答案是:V2 中应该存在于列表里,除非用户 DELETE、metadata 被 GC/tombstone、恢复校验发现记录损坏到无法安全展示,或 chat recording / persistence 被禁用。是否还能打开底层内容,则取决于 storage 类型和是否做了 `pin/save content`。 -daemon 不能只凭 request payload 判断“手动”还是“后台”。实现上应由连接 principal、SDK helper 或 UI action path 标识交互式注册来源;无法确认交互意图的 client 应按显式 `retention` 处理,缺省仍接受 `restorable` 但受 session metadata quota 和审计记录约束。 +daemon 不能只凭 request payload 判断“手动”还是“后台”。实现上应由连接 principal、SDK helper 或 UI action path 标识交互式注册来源;无法确认交互意图的 client 应按显式 `retention` 处理,缺省仍接受 `restorable`,但受 session metadata quota 和审计记录约束。 ## 3. 数据模型 @@ -86,6 +93,10 @@ interface DaemonSessionArtifact { persistedAt?: string; expiresAt?: string; restoreState?: 'live' | 'restored' | 'unverified' | 'blocked'; + persistenceWarning?: { + code: 'persist_failed' | 'restore_pruned' | 'content_unavailable'; + message: string; + }; contentRef?: { kind: 'managed_copy' | 'published' | 'workspace_reference'; id?: string; @@ -97,10 +108,11 @@ interface DaemonSessionArtifact { 字段说明: -- `retention`:artifact 的持久化级别。缺省按 V1 兼容处理为 live-only。 +- `retention`:artifact 的持久化级别。解析顺序为:请求体显式值优先;系统内部 artifact 按 daemon policy;client POST 未指定时使用用户配置的 `defaultRetention`;无配置时回退为 `restorable`。只有 persistence capability 未声明或读取 V1-era 记录时,才按 V1 兼容的 live-only 处理。V2 writer 写 journal 时必须 materialize `retention`,不能依赖 optional 缺省。 - `persistedAt`:metadata 或 content retention 最近成功落盘时间。 -- `expiresAt`:可被 GC 的时间。`pinned` 默认不设置。 +- `expiresAt`:可被 GC 的时间。`pinned` 默认不设置,但 `ttlDays` pin 会转换成绝对 `expiresAt`。 - `restoreState`:恢复来源提示;不替代 `status`。 +- `persistenceWarning`:非阻塞持久化/恢复风险,前端可用它提示“此 artifact 不会跨重启保留”等状态。 - `contentRef`:仅在内容保留或可信 published storage 时出现,不暴露宿主机绝对路径。 ### 3.2 Status 与 restoreState 的关系 @@ -110,7 +122,7 @@ V1 `status` 继续表示当前资源是否可用: - `available` - `missing` -V2 不建议直接把 `status` 扩成复杂状态来表达恢复来源,避免旧 client 误解。恢复后的不确定性放在 `restoreState`: +V2 不把 `status` 扩成复杂状态,避免旧 client 误解。`blocked` 不是 `status`,只属于 `restoreState`: - `restored`:从持久化 metadata 恢复。 - `unverified`:恢复了 metadata,但尚未完成 workspace/managed 校验。 @@ -119,14 +131,18 @@ V2 不建议直接把 `status` 扩成复杂状态来表达恢复来源,避免 ## 4. 持久化存储设计 -### 4.1 双存储:JSONL journal + sidecar snapshot +### 4.1 JSONL-only source of truth + +V2 默认只使用 Chat JSONL system records: -建议使用两层存储: +1. JSONL journal 是审计源、恢复源和跨版本迁移源。 +2. `session_artifact_snapshot` 是 JSONL 内的恢复加速点,不是独立文件。 +3. 不在 V2 中引入 sidecar cache。sidecar 会增加路径同步、陈旧校验、archive/unarchive/delete 联动、orphan GC 和缓存信任问题;当前 session load 已经读取 JSONL,artifact records 可以在同一轮 parse 中提取。 -1. Chat JSONL system records:审计源与跨版本恢复源。 -2. Session artifact sidecar:快速加载缓存。 +如果未来实测需要 sidecar,它必须作为单独设计进入,并满足两个约束: -JSONL 是 source of truth;sidecar 是可删除缓存。sidecar 损坏或版本不匹配时,从 JSONL 重建。 +- sidecar 只能是可删除缓存,不能承载协议正确性。 +- 即使 sidecar 命中,也必须对每个 artifact 执行恢复校验,不能绕过 JSONL restore validation。 ### 4.2 JSONL system record @@ -147,7 +163,7 @@ interface SessionArtifactEventRecordPayload { action: 'upsert' | 'remove'; artifactId: string; artifact?: DaemonSessionArtifact; - reason?: 'explicit' | 'eviction' | 'restore_pruned' | 'unpin'; + reason?: 'explicit' | 'eviction' | 'restore_pruned' | 'unpin_to_ephemeral'; }>; } @@ -157,9 +173,12 @@ interface SessionArtifactSnapshotRecordPayload { sequence: number; generatedAt: string; artifacts: DaemonSessionArtifact[]; + tombstonedIds: string[]; } ``` +`sequence` 必须来源于 JSONL record ordering,例如 line/order index 或 ChatRecord append order,不能使用 daemon 进程内会在重启后归零的 counter。读取时以 JSONL 顺序为准;`sequence` 仅用于检测异常和生成 snapshot。 + 只写经过 store validation/normalization 后的 public artifact shape,不写内部字段: - 不写 `identityKey` @@ -167,77 +186,54 @@ interface SessionArtifactSnapshotRecordPayload { - 不写绝对 `workspaceCwd` - 不写 transport token / auth principal -删除 artifact 必须写 tombstone change,避免历史 replay 后被旧 upsert 复活。 +删除 artifact 或 unpin 到 `ephemeral` 必须写 tombstone change,避免历史 replay 后被旧 upsert 复活。 -### 4.3 Sidecar snapshot +### 4.3 Snapshot 与 tombstone 不变量 -建议路径: +artifact snapshot 只用于减少 replay 的 artifact event 应用量;它不会减少 JSONL 文件本身的读取量。 -```text -~/.qwen/tmp//chats/.artifacts.json -``` +必须满足: -示例: +- snapshot generation 必须在同一个 artifact operation queue 中串行执行,并严格位于所有 preceding mutation 之后。 +- snapshot 不能包含任何 preceding tombstone 对应的 artifact id。 +- snapshot 必须携带 `tombstonedIds`,用于防止旧 upsert 在 snapshot 之后被错误复活。 +- load 时选择最新 valid snapshot,然后只应用该 snapshot 之后的 artifact events。 +- 如果 snapshot 解析失败或 tombstone 状态不一致,丢弃 artifact persistence records 并记录 warning;不能恢复不可信状态。 -```json -{ - "v": 2, - "sessionId": "session-123", - "projectHash": "abc123", - "sourceChatFile": "session-123.jsonl", - "sourceSizeBytes": 20480, - "sourceMtimeMs": 1783070000000, - "sourceArtifactSequence": 42, - "generatedAt": "2026-07-03T10:00:00.000Z", - "sequence": 42, - "artifacts": [] -} -``` - -写入策略: - -- temp file + fsync + atomic rename。 -- 读取时同时校验 `sessionId`、`projectHash`、`sourceChatFile`、`sourceSizeBytes`、`sourceMtimeMs` 和 `sourceArtifactSequence`;任一不匹配都 fallback 到 JSONL。 -- JSON parse 失败时丢弃 sidecar,重建。 -- session archive/unarchive 时与 chat JSONL 一起移动。 -- session delete 时一起删除。 +### 4.4 存储消耗 -`sequence` 表示 sidecar snapshot 自身对应的 artifact 序号;`sourceArtifactSequence` 表示源 JSONL 中最新 artifact record 序号。正常情况下两者相同;保留两个字段可以让 sidecar 重建或压缩后仍能校验缓存来源。 +V2 不双写 sidecar,因此没有 JSONL + sidecar 的 metadata 重复存储。存储消耗分为 metadata journal 和 content retention: -### 4.4 为什么不用 sidecar 作为唯一存储 - -只用 sidecar 会缺少审计链,也容易在 fork/branch/session copy 时漏掉 artifact 状态。JSONL system record 与现有 session 历史系统一致,天然支持: - -- append-only crash safety -- branch/fork 复制 active record chain -- resume/load 时统一读取 -- 版本迁移时 tolerant reader +- Metadata 单条通常约 0.5 KB - 2 KB,取决于 title、description、url 和 metadata 大小。 +- 每 session 有效 persisted metadata 上限默认与 live store 对齐为 200 条,单个 snapshot 约 100 KB - 400 KB。 +- JSONL journal 会保存增量事件、snapshot 和 tombstone;append-only chat transcript 本身会增长。 +- content retention 才是主要空间来源,例如单 artifact 50 MB、单 session 200 MB、单 project 1 GB。 -sidecar 只解决性能,不承载协议正确性。 +控制策略: -### 4.5 JSONL + sidecar 双存储的空间消耗 +- artifact event journal 达到固定阈值后写 `session_artifact_snapshot`,例如每 100 次 artifact mutation 或每 256 KB artifact journal 写一次。 +- artifact persistence records 跟随 chat transcript 生命周期;不做独立文件 GC。 +- 每 session 增加 artifact journal byte budget,例如 4 MB。超过后不再写新的 restorable metadata,普通 artifact 降级为 `ephemeral` 并带 `persistenceWarning`;用户显式 pin/save 仍返回明确错误而不是静默降级。 +- 不把 content bytes 写进 JSONL;content 只进入 daemon-managed artifact storage。 -双存储会重复保存“当前 artifact snapshot metadata”,但重复的是小型 metadata,不是 artifact 内容。空间消耗应按 metadata 与 content retention 分开看: +## 5. 写入与恢复流程 -- Metadata 单条通常约 0.5 KB - 2 KB,取决于 title、description、url 和 metadata 大小。 -- 每 session 500 条 persisted metadata 时,当前 snapshot 约 250 KB - 1 MB。 -- sidecar 保存一份当前 snapshot,因此额外增加约 250 KB - 1 MB。 -- JSONL journal 还会保存增量事件和 tombstone;如果不做 snapshot compaction,长会话可能继续增长。 -- content retention 才是主要空间来源,例如单 artifact 50 MB、单 session 200 MB、单 project 1 GB。 +### 5.1 Ingest-time validation -建议控制策略: +任何 artifact 进入 live store 和 JSONL 之前都必须做 ingest-time validation,不能只在 restore 时校验: -- artifact event journal 达到固定阈值后写 `session_artifact_snapshot`,例如每 100 次 artifact mutation 或每 256 KB artifact journal 写一次。 -- load 时只需要读取最新 snapshot 之后的 artifact events。 -- sidecar 只保留最新当前态,不保存历史事件。 -- 不把 content bytes 写进 JSONL 或 sidecar;content 只进入 daemon managed artifact storage。 -- metadata 超过每 session 上限时写 `restore_pruned` tombstone,避免历史 replay 复活被裁剪条目。 +- `workspacePath`:必须是相对路径;resolve/realpath 后不能逃逸当前 workspace。 +- `url`:按 storage type 校验 scheme、userinfo、secret-like query/fragment。 +- `managedId`:拒绝路径形态、`..`、绝对路径、分隔符。 +- `published`:只能由 daemon 内部 trusted publisher 或 manifest-validated path 产生,不能由 client payload 自称。 +- `metadata`:执行 primitive-only、size limit、secret key/value 和 unsafe display payload checks。 -在这些限制下,典型 session 的 artifact metadata 持久化开销应保持在 MB 级以内;真正需要关注的容量风险是 pinned content storage,而不是 JSONL + sidecar metadata。 +验证失败时: -## 5. 写入与恢复流程 +- 明确恶意或越界输入:拒绝请求。 +- 可能包含敏感 locator 但用户仍想展示 live artifact:可降级为 `ephemeral`,并写 `persistenceWarning`;不能写入 JSONL。 -### 5.1 Artifact 写入流程 +### 5.2 Artifact 写入流程 V1 流程: @@ -252,17 +248,16 @@ ingest input -> normalize/validate -> apply live-store mutation in operationQueue -> for restorable/pinned changes: append artifact journal - -> update sidecar snapshot best-effort - -> publish artifact_changed + -> publish artifact_changed with effective retention/warning fields ``` 持久化必须在同一个 store operation queue 内串行执行,避免 live store、journal、SSE 顺序错乱。 -### 5.2 写入失败语义 +### 5.3 写入失败语义 区分两个入口: -- 普通 tool/hook artifact:持久化失败不应让工具调用失败;artifact 仍可进入 live store,但必须先把 live store 中的 `retention` 降级为 `ephemeral`,再发布 `artifact_changed`,并记录 structured warning。 +- 普通 tool/hook artifact:持久化失败不应让工具调用失败;artifact 仍可进入 live store,但必须先把 live store 中的 `retention` 降级为 `ephemeral`,设置 `persistenceWarning`,再发布 `artifact_changed`。 - 显式 `pin/save` API:持久化失败必须返回错误,不能假装已经保存。 建议 warning: @@ -271,29 +266,29 @@ ingest input [artifacts] session= action=persist_failed artifact= reason= ``` -### 5.3 恢复流程 +### 5.4 恢复流程 session load/replay 时: -1. `SessionService.loadSession()` 读取 JSONL。 -2. 提取 active branch 中的 `session_artifact_snapshot` 和之后的 `session_artifact_event`。 +1. `SessionService.loadSession()` 读取 JSONL,并在同一轮 parse 中提取 artifact snapshot/event records。 +2. 提取 active branch 中最新 valid `session_artifact_snapshot` 和之后的 `session_artifact_event`。 3. 重建 artifact snapshot,应用 tombstone。 4. 对每个 artifact 重新执行 V2 restore validation。 -5. 把恢复结果放入 `ResumedSessionData.artifactSnapshot`。 -6. `AcpAgent.createAndStoreSession()` 创建 `Session` 后,用 snapshot 初始化 `SessionArtifactStore`。 -7. client 初次 GET 时看到恢复后的 artifact list。 +5. load result 携带 `artifactSnapshot` 回到 daemon-side bridge。 +6. daemon bridge 在 `createSessionEntry` / restore completion 时用 snapshot 初始化 daemon 侧 `SessionArtifactStore`。 +7. `GET /session/:id/artifacts` 读取的就是这个 daemon-side store。 -如果 sidecar 可用且版本、projectHash、sessionId、source file metadata 和 artifact sequence 都匹配,可直接用 sidecar;否则 fallback 到 JSONL replay。 +不要在 ACP child process 的 agent/session 对象里 seed `SessionArtifactStore`:生产 HTTP API 可见的 store 在 daemon-side bridge 中创建。 -### 5.4 恢复时校验 +### 5.5 恢复时校验 恢复时必须重新校验: - `workspacePath`:仍必须是相对路径,realpath/stat 后不能逃逸当前 workspace。 -- `url`:重新校验 scheme,只允许 `http:` / `https:`;拒绝 username/password credential。 +- `external_url`:只允许 `http:` / `https:`;拒绝 username/password credential;secret-like query/fragment 必须 redacted、降级为 non-openable locator,或整条 artifact 降级/阻断。 +- `published`:可以恢复 `file:` locator,但只能在 trusted publisher manifest 重新校验通过、且目标属于 daemon-managed published storage 时允许。普通 `external_url` 永远不能通过 `file:`。 - `managedId`:拒绝路径形态、`..`、绝对路径、分隔符。 -- `published`:必须能映射到受信任 publisher manifest,不能只因为历史里写了 `storage: "published"` 就恢复 trust。 -- `metadata`:重新执行 primitive-only、size limit 和 unsafe display payload checks。 +- `metadata`:重新执行 primitive-only、size limit、secret key/value 和 unsafe display payload checks。 恢复失败时: @@ -301,15 +296,15 @@ session load/replay 时: - 资源缺失:`status: "missing"`。 - 非安全型字段损坏:跳过该 artifact,并记录 warning。 -### 5.5 Branch / fork 语义 +### 5.6 Branch / fork 语义 现有 `/branch` 会复制 active JSONL record chain 并重写 `sessionId`。V2 artifact records 也会跟随复制,因此必须显式处理 artifact id: - 同一个资源在新 session 中应得到新 artifact id,因为 V1 identity 包含 `sessionId`。 - fork 写入目标 session 时,应根据目标 `sessionId + locator` 重新计算 artifact id。 -- tombstone 也要按目标 session 的新 id 重写,不能保留源 session 的 artifact id。 +- tombstone 也要按目标 session 的新 id 重写。若源 tombstone 找不到目标 session 中对应 upsert,丢弃该 orphan tombstone。 - `forkedFrom` 可以记录原 session id / 原 artifact id,作为审计信息,但不能参与新 session 的权限判断。 -- `pinned` content 可以共享底层 contentRef,但必须用引用计数或 manifest 引用表管理 GC,避免删除一个 session 时误删另一个 fork 仍在引用的内容。 +- fork 继承 artifact metadata 时,`pinned` 必须降级为 `restorable`。fork 不继承 pinned contentRef,用户需要在 forked session 中显式 re-pin,避免通过 fork 绕过 session/project quota。 fork remap 是发布门槛:如果某条路径不能安全重写 artifact id 和 tombstone,就必须在 fork 时丢弃 artifact persistence records,不能把源 session 的 artifact id 原样带入新 session。 @@ -336,7 +331,7 @@ fork remap 是发布门槛:如果某条路径不能安全重写 artifact id "session_artifacts_persistence": { "metadata": true, "defaultRetention": "restorable", - "maxRestorableArtifacts": 500 + "maxRestorableArtifacts": 200 }, "session_artifacts_content_retention": { "enabled": true, @@ -364,7 +359,8 @@ fork remap 是发布门槛:如果某条路径不能安全重写 artifact id 限制: -- client 不能请求 `pinned`,只能请求 `restorable`。 +- client 可以请求 `ephemeral` 或 `restorable`。 +- client 不能请求 `pinned`。 - `pinned` 必须走 pin/save API,因为可能涉及内容复制、配额、确认和失败回滚。 ### 6.3 Pin/save artifact @@ -397,6 +393,7 @@ Body: - `mode: "metadata"`:升级为 `restorable`。 - `mode: "content"`:复制或绑定可控内容,升级为 `pinned`。 +- `ttlDays` 由 daemon 在 pin 时转换为绝对 `expiresAt = now + ttlDays`。 - 成功返回 mutation result,并发布 `artifact_changed` / `updated`。 - 失败返回明确错误,不改变 artifact retention。 @@ -404,12 +401,22 @@ Body: ```http DELETE /session/:id/artifacts/:artifactId/pin +Content-Type: application/json +``` + +Body: + +```json +{ + "retention": "restorable" +} ``` 语义: -- `pinned` 降级为 `restorable` 或 `ephemeral`,由请求参数决定。 -- 默认只删除 content retention,不删除 metadata。 +- `retention` 可为 `restorable` 或 `ephemeral`,默认 `restorable`。 +- `restorable`:删除 content retention,保留 metadata restore,并写 upsert event。 +- `ephemeral`:删除 content retention,live store 中保留 artifact,但写 remove tombstone 或等价 restore-skip 记录,确保下次 load 不复活。 - 若要从列表移除,仍使用 V1 DELETE。 ### 6.5 Delete artifact @@ -429,17 +436,19 @@ V2 的 DELETE 仍保持 V1 幂等,但要追加 tombstone: } ``` -`deleteContent` 表示请求立即删除可删除内容,必须经过更严格授权或用户确认;共享 contentRef 只能在引用计数归零后删除。 +`deleteContent` 表示请求立即删除可删除内容,必须经过更严格授权;共享 contentRef 只能在引用计数归零后删除。 ## 7. 安全设计 ### 7.1 授权原则 -不要把 public `clientId` 当授权边界。V2 应引入 daemon 内部 mutation principal: +不要把 public `clientId` 当授权边界。V2 的实际 HTTP 信任边界仍是 daemon bearer token + route-level read/mutate permission;在现有 auth 模型下,`session_owner` 不能被安全 mint 或跨 daemon restart 持久化。因此 V2 不引入强于 token-holder 的 owner tier。 + +内部 principal 只用于审计、默认策略和防止 payload spoofing: ```ts type ArtifactPrincipal = - | { kind: 'session_owner' } + | { kind: 'token_holder' } | { kind: 'client_connection'; id: string } | { kind: 'trusted_publisher'; id: string } | { kind: 'hook'; extensionId: string }; @@ -448,10 +457,12 @@ type ArtifactPrincipal = 授权规则: - list:需要 session read 权限。 -- add restorable:需要 session mutate 权限。 -- pin/save content:需要 session owner 或用户确认。 -- delete metadata:需要 session mutate 权限;如果要保留 per-client ownership,则必须用内部 principal,不用 payload clientId。 -- delete content:需要 session owner 或创建该 contentRef 的 principal。 +- add ephemeral/restorable:需要 session mutate 权限。 +- pin/save content:需要 session mutate 权限,并且必须是显式 REST/SDK call;“用户确认”在 V2 中不表示 agent permission-vote,而是 UI 或 headless client 主动调用 pin/save endpoint。 +- delete metadata:需要 session mutate 权限;如果要保留 per-client ownership,只能用内部 principal 审计,不能用 payload clientId 授权。 +- delete content:需要 session mutate 权限;若 contentRef 被多个 session 引用,只能递减引用计数,不能强删共享内容。 + +如果未来需要真正的 `session_owner`,必须先设计 durable per-session capability 或 ACL,不能在本 V2 文档中隐式假设。 ### 7.2 持久化内容边界 @@ -461,29 +472,33 @@ type ArtifactPrincipal = - 任意 workspace 文件 - 普通 assistant link -允许内容 retention 的来源: +允许 content retention 的来源: - trusted `ArtifactTool` / publisher 生成的 `published` artifact。 - 用户显式 pin 的 workspace artifact,且文件在 workspace 内、类型/大小可控。 - client 上传或登记的 managed artifact,前提是通过 daemon API 接收并校验。 -内容复制要求: +内容复制必须 race-safe: - workspace containment 校验通过。 -- 文件大小低于默认 cap,例如 50 MB。 -- 保存 sha256、size、mimeType。 -- 打开/下载前重新校验 hash。 -- 不 follow symlink escape。 +- 只允许 regular file;拒绝目录、FIFO、device、socket 和其它特殊文件。 +- 打开文件时使用 no-follow 语义;Linux 可用 `openat2(RESOLVE_NO_SYMLINKS)`,其它平台用可用的 no-follow/open-handle revalidation 组合。 +- 打开后对 file handle 执行 fstat/revalidate,确认仍是 regular file、仍在 workspace containment 内。 +- 拒绝 link count 异常的 hardlink,除非后续有明确 allowlist。 +- 读取时按 stream 强制 max bytes,不能先信任 stat size。 +- hash exactly the bytes copied,并保存 sha256、size、mimeType。 +- 打开/下载 retained content 前重新校验 manifest/hash。 ### 7.3 隐私和敏感信息 -持久化前要做最小化: +持久化前必须做最小化: - 不保存 host 绝对路径。 - 不保存 URL username/password。 +- external URL 的 secret-like query/fragment 必须拒绝、redact,或将 artifact 降级为 `ephemeral` / non-openable locator;不能原样写入 JSONL。 +- metadata 使用 allowlist 或 secret-key denylist;`token`、`password`、`secret`、`cookie`、`authorization` 等 key/value 必须拒绝、redact,或降级为 `ephemeral`。 - metadata 仍限制 4 KB。 - title/description/metadata 继续执行 unsafe display payload checks。 -- 对看起来像 secret 的 metadata key 可拒绝或 redacted,例如 `token`、`password`、`secret`。 建议新增设置: @@ -510,14 +525,16 @@ type ArtifactPrincipal = 建议默认: - live store 上限仍为 200。 -- persisted metadata 上限每 session 500。 -- snapshot record 最多保留 500 个当前有效 artifacts。 +- persisted metadata 上限每 session 200,与 live store 对齐。 +- snapshot record 最多保留 200 个当前有效 artifacts。 超过 persisted metadata 上限: - `ephemeral` 本来不写 journal,不计入 persisted metadata quota,只受 live store 上限约束。 -- `restorable` 可按最旧未 pinned 裁剪,并写 `restore_pruned` tombstone。 -- `pinned` 不因 metadata quota 被裁剪,但可被 global content quota 约束。 +- `restorable` 按确定性顺序裁剪,例如最旧未 pinned,并写 `restore_pruned` tombstone。 +- `pinned` metadata 不因 metadata quota 被裁剪,但 content retention 仍受 content quota 约束。 + +restore seed 不能超过 live store 上限;若历史里有效 persisted artifact 超过当前 live cap,必须按同一确定性规则 prune,并把 pruned ids 写入 tombstone,保证首次 GET 与后续 replay 一致。 ### 8.2 Content quota @@ -531,6 +548,7 @@ type ArtifactPrincipal = - 新 pin/save 返回 `QUOTA_EXCEEDED`。 - 不自动删除 pinned content,除非用户设置 TTL 或 explicit GC policy。 +- fork 不继承 pinned contentRef,避免 fork 绕过 quota。 ### 8.3 GC @@ -538,8 +556,8 @@ GC 只处理 daemon 管理的 content storage 和过期 metadata cache: - 删除已被 tombstone 且无其它 session 引用的 managed copy。 - 删除超过 `expiresAt` 的 non-pinned content。 -- 清理 orphan sidecar。 -- session delete 时删除 sidecar,并对 pinned content 做引用计数递减;默认删除无其它 session 引用的 content。只有用户显式开启“保留已 pin 内容”或导出到全局 artifact library 时,才在 session 删除后继续保留。 +- 删除超过 `expiresAt` 的 pinned content,并把对应 artifact 降级为 `restorable` 或 `missing`。 +- session delete 时递减 pinned content refcount;默认删除无其它 session 引用的 content。只有用户显式导出到全局 artifact library 时,才在 session 删除后继续保留。 GC 必须 best-effort,不阻塞 prompt/tool flow。 @@ -549,10 +567,10 @@ GC 必须 best-effort,不阻塞 prompt/tool flow。 - artifact store mutation 串行。 - JSONL journal append 失败不会破坏 live store。 -- explicit `pin/save metadata` 必须等待 journal 落盘;sidecar 仍是 best-effort cache。 -- explicit `pin/save content` 必须等待 content manifest 和 journal 都落盘;sidecar 仍是 best-effort cache。 -- sidecar 原子写。 -- reader 容忍半截 JSONL 和 corrupt sidecar。 +- explicit `pin/save metadata` 必须等待 journal 落盘。 +- explicit `pin/save content` 必须等待 content manifest 和 journal 都落盘。 +- reader 容忍半截 JSONL 和 corrupt artifact record。 +- tombstone / snapshot 顺序异常时选择不恢复,而不是猜测。 ### 8.5 文件读取、CPU 与 I/O 成本 @@ -560,10 +578,9 @@ V2 要避免把 artifact 恢复变成 session load 的新瓶颈。 读取路径建议: -1. 优先读 sidecar:一次小 JSON 文件读取和 parse,成本约 O(当前 artifact 数量)。 -2. sidecar 缺失、损坏、版本不匹配或 source metadata 不匹配时,fallback 到 JSONL。 -3. JSONL fallback 时在已有 session JSONL 读取过程中找到最新 `session_artifact_snapshot`,只 replay 之后的 artifact events;没有独立索引时允许一次顺序扫描,但不能在 load 流程里反复扫同一文件。 -4. 如果现有 `SessionService.loadSession()` 已经完整读取 JSONL,可在同一轮 parse 中顺手提取 artifact records,避免二次扫文件。 +1. `SessionService.loadSession()` 已经读取 JSONL 时,在同一轮 parse 中提取 artifact records。 +2. 找到最新 valid `session_artifact_snapshot`,只 replay 之后的 artifact events。 +3. 没有 valid snapshot 时允许一次顺序扫描 artifact records,但不能在 load 流程里反复扫同一文件。 CPU 成本边界: @@ -575,57 +592,80 @@ CPU 成本边界: I/O 成本边界: -- 每次 session load 最多读 sidecar 一次;fallback 才读 JSONL artifact records。 +- V2 不额外读 sidecar 文件。 - workspace 状态校验复用 V1 的 TTL/batch 策略,不在 GET 热路径对所有 artifact 做无限制 stat。 - 对大 workspace 文件,不在恢复阶段读内容;只有用户显式 pin/save 时才读取并 hash。 推荐默认: -- sidecar artifact snapshot 上限 500 条。 +- artifact snapshot 上限 200 条。 - workspace status restore batch size 20,与 V1 保持一致。 - artifact journal snapshot 阈值 100 mutations 或 256 KB。 - content hash 在 pin/save 时同步完成;恢复时 lazy verify 或后台 best-effort verify。 -这样 session load 的常规成本是读取一个小 sidecar;只有 sidecar 不可用时才退化为 JSONL replay,而 replay 也被 snapshot 阈值限制在较小范围内。 +### 8.6 Observability + +V2 新增的失败路径必须有 structured logs,格式沿用: + +```text +[artifacts] session= action= key=value +``` + +建议 action: + +- `persist_failed` +- `retention_downgraded` +- `restore_skipped` +- `restore_blocked` +- `restore_pruned` +- `fork_artifact_discarded` +- `gc_content_deleted` +- `gc_refcount_updated` +- `snapshot_invalid` +- `tombstone_conflict` +- `content_copy_rejected` + +这些日志不替代 API/SSE 中的 `persistenceWarning`,而是用于生产排障。 ## 9. 实现方案 -以下是同一个 V2 phase 内的实现里程碑。工程上可以按 PR 拆开,但对外发布口径仍是一项完整的持久化能力。 +以下是同一个 V2 design phase 内的实现里程碑。工程上可以按 PR 拆开;对外以 capability 声明实际可用能力。 ### Milestone A: 类型和 persistence service - 新增 `ArtifactPersistenceService`,职责: - append event/snapshot record - - write sidecar atomically - rebuild from JSONL - restore validation + - snapshot/tombstone consistency checks - 扩展 `ChatRecord.subtype` 与 `systemPayload` union。 -- 增加 `ResumedSessionData.artifactSnapshot?`。 +- 增加 load result 中的 `artifactSnapshot?`。 -### Milestone B: store 集成 +### Milestone B: daemon-side store 集成 +- daemon bridge `createSessionEntry` 支持 seed artifacts。 - `SessionArtifactStore` 支持 seed artifacts。 -- `upsertMany()` 根据 `retention` 决定是否调用 persistence。 +- `upsertMany()` 根据 effective `retention` 决定是否调用 persistence。 - `remove()` 写 tombstone。 - 保持 V1 `artifact_changed` event shape 不变,只增加 optional fields。 ### Milestone C: load/replay 集成 - `SessionService.loadSession()` 提取 artifact snapshot。 -- `AcpAgent.createAndStoreSession()` 创建 session 后 seed store。 +- load result 把 snapshot 交给 daemon bridge,而不是在 ACP child process 中 seed store。 - replay 历史时同 identity artifact 不重复创建。 ### Milestone D: REST/SDK - SDK type 增加 optional fields。 -- `POST /session/:id/artifacts` 支持 `retention: "restorable"`。 +- `POST /session/:id/artifacts` 支持 `retention: "ephemeral" | "restorable"`。 - 新增 `pinArtifact()` / `unpinArtifact()` SDK 方法。 - capability gate UI。 ### Milestone E: content retention -- 增加 daemon managed artifact storage manifest。 -- 实现 pin content、hash 校验、quota、GC。 +- 增加 daemon-managed artifact storage manifest。 +- 实现 race-safe content copy、hash 校验、quota、GC。 - 支持 published artifact 绑定 trusted contentRef。 ## 10. 测试计划 @@ -633,17 +673,29 @@ I/O 成本边界: 必须覆盖: - metadata journal append 后 daemon restart/load 恢复 artifact list。 +- V1 live session 升级到 V2 时 backfill snapshot 成功/失败两种路径。 - DELETE tombstone 后 load 不复活 artifact。 -- sidecar source metadata 过期时 fallback 到 JSONL,不能读取陈旧 snapshot。 -- workspace artifact 恢复时文件存在/缺失/ symlink escape 三种状态。 +- unpin 到 `ephemeral` 后 load 不复活 artifact。 +- workspace artifact ingest 和 restore 时文件存在/缺失/symlink escape 三种状态。 +- pin/save content 时拒绝 symlink、special file、oversized stream、hardlink 异常和 TOCTOU swap。 - external URL 只恢复 metadata,不发网络请求。 -- corrupt sidecar fallback 到 JSONL。 +- secret-bearing URL query/fragment 与 metadata key/value 不写入 JSONL。 +- published local `file:` 只有 trusted manifest revalidation 通过时恢复。 - corrupt JSONL record 被跳过且不影响其它 artifacts。 - chat recording / persistence disabled 时不声明或不启用 metadata restore。 - pin/save 显式写失败时返回错误。 -- tool artifact 持久化失败时降级为 live-only,不影响 tool turn。 +- tool artifact 持久化失败时降级为 live-only,并通过 `persistenceWarning` 让 client 可见。 - branch/fork 时 artifact records 的 sessionId/id 处理。 -- archive/unarchive/delete session 时 sidecar、content refcount 和 GC 行为。 +- fork 继承 pinned artifact 时降级为 restorable,不继承 contentRef。 +- orphan tombstone 在 fork remap 时被丢弃。 +- restore seed 与 concurrent POST 串行,不丢写、不重复。 +- quota 边界:200 条、201 条 prune、pinned metadata 处理。 +- GC:tombstoned content 有/无跨 session 引用、TTL 过期、session delete refcount decrement。 +- authorization:token-holder/principal 审计路径允许和拒绝情况。 +- partial writes:journal 成功但 warning、content manifest 成功但 journal 失败。 +- JSONL snapshot compaction:threshold 触发、tombstonedIds 保留、post-snapshot replay 有界。 +- retention defaults:tool artifact 无显式 retention、client POST `pinned` 被拒绝。 +- replay idempotency:同一 session history replay 两次不会重复 artifact。 - SDK 旧 client 忽略 optional fields 后仍能展示 V1 artifacts。 ## 11. 不建议在 V2 做的事 @@ -655,17 +707,17 @@ I/O 成本边界: - 把 `clientId` 作为删除或 pin/save 的授权凭证。 - 在 GET 热路径里做大量 fs/network 校验。 - 把持久化失败变成普通 tool turn 失败。 +- 在没有测量证明需要时引入 sidecar cache。 ## 12. 推荐发布口径 -V2 建议作为一个完整 phase 发布: +V2 建议作为一个完整 design phase 发布,但能力按 capability 暴露: -- capability:`session_artifacts_persistence` -- capability:`session_artifacts_content_retention` +- `session_artifacts_persistence` 可先发布 metadata restore。 +- `session_artifacts_content_retention` 只有 quota、hash、manifest、race-safe copy 和 GC 都具备时才声明。 - 默认恢复显式登记的 artifact metadata。 - 用户手动注册的 artifact 默认 `restorable`,session load/replay 后继续出现在列表中。 - 显式 `pin/save` 才做 content retention。 -- quota、hash、manifest、GC 必须和 `pin/save` 同期具备,不能只暴露保存入口。 - 用户文档明确:metadata restore 恢复的是“产物索引”,不是“产物内容备份”;content retention 才是受配额和 GC 约束的内容保存。 -这样可以一次性讲清楚 V2 的完整语义:默认恢复列表,显式保存内容,所有长期存储都受权限、配额、hash 和 GC 约束。 +这样可以讲清楚 V2 的完整语义:默认恢复列表,显式保存内容,所有长期存储都受权限、配额、hash 和 GC 约束。 From c376e1ff8b1b15e41c289d6695f7cf370ddbcf62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Sat, 4 Jul 2026 18:17:47 +0800 Subject: [PATCH 03/61] docs: tighten artifact persistence restore semantics --- .../session-artifacts-persistence-v2-design.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md b/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md index 0a772f25ee3..1fd3c443851 100644 --- a/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md +++ b/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md @@ -226,6 +226,7 @@ V2 不双写 sidecar,因此没有 JSONL + sidecar 的 metadata 重复存储。 - `url`:按 storage type 校验 scheme、userinfo、secret-like query/fragment。 - `managedId`:拒绝路径形态、`..`、绝对路径、分隔符。 - `published`:只能由 daemon 内部 trusted publisher 或 manifest-validated path 产生,不能由 client payload 自称。 +- `contentRef`:如果存在,必须验证 `kind`/`id` shape,拒绝分隔符、绝对路径和 `..`,并只能通过 daemon-managed manifest 解析。 - `metadata`:执行 primitive-only、size limit、secret key/value 和 unsafe display payload checks。 验证失败时: @@ -260,6 +261,12 @@ ingest input - 普通 tool/hook artifact:持久化失败不应让工具调用失败;artifact 仍可进入 live store,但必须先把 live store 中的 `retention` 降级为 `ephemeral`,设置 `persistenceWarning`,再发布 `artifact_changed`。 - 显式 `pin/save` API:持久化失败必须返回错误,不能假装已经保存。 +对会影响恢复结果的删除型 mutation,必须 durable-first 或 rollback: + +- DELETE、eviction、unpin-to-ephemeral、`restore_pruned` 都必须先成功写入 tombstone,再从 live store 移除或发布变化。 +- 如果 tombstone append 失败,保持 live store 不变;显式 API 返回错误,后台 eviction/prune 记录 structured warning 并稍后重试。 +- 不能先从 live store 删除再尝试写 tombstone,否则下一次 replay 可能复活用户已经删除或 quota 已裁剪的 artifact。 + 建议 warning: ```text @@ -288,6 +295,7 @@ session load/replay 时: - `external_url`:只允许 `http:` / `https:`;拒绝 username/password credential;secret-like query/fragment 必须 redacted、降级为 non-openable locator,或整条 artifact 降级/阻断。 - `published`:可以恢复 `file:` locator,但只能在 trusted publisher manifest 重新校验通过、且目标属于 daemon-managed published storage 时允许。普通 `external_url` 永远不能通过 `file:`。 - `managedId`:拒绝路径形态、`..`、绝对路径、分隔符。 +- `contentRef`:验证 `kind`/`id` shape,拒绝分隔符、绝对路径和 `..`;只能通过 daemon-managed manifest 在当前 session/artifact scope 内解析;暴露前必须校验 stored size/hash metadata。 - `metadata`:重新执行 primitive-only、size limit、secret key/value 和 unsafe display payload checks。 恢复失败时: @@ -676,11 +684,13 @@ V2 新增的失败路径必须有 structured logs,格式沿用: - V1 live session 升级到 V2 时 backfill snapshot 成功/失败两种路径。 - DELETE tombstone 后 load 不复活 artifact。 - unpin 到 `ephemeral` 后 load 不复活 artifact。 +- DELETE、eviction、unpin-to-ephemeral、`restore_pruned` tombstone append 失败时不会先改变 live state。 - workspace artifact ingest 和 restore 时文件存在/缺失/symlink escape 三种状态。 - pin/save content 时拒绝 symlink、special file、oversized stream、hardlink 异常和 TOCTOU swap。 - external URL 只恢复 metadata,不发网络请求。 - secret-bearing URL query/fragment 与 metadata key/value 不写入 JSONL。 - published local `file:` 只有 trusted manifest revalidation 通过时恢复。 +- stale/tampered `contentRef` 无法绕过 daemon-managed manifest、size 和 hash 校验。 - corrupt JSONL record 被跳过且不影响其它 artifacts。 - chat recording / persistence disabled 时不声明或不启用 metadata restore。 - pin/save 显式写失败时返回错误。 From 1f089ddeb4936c3fd4bb3d4adf50c8688a7ab6f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Sat, 4 Jul 2026 18:55:03 +0800 Subject: [PATCH 04/61] docs: address artifact persistence follow-up review --- ...session-artifacts-persistence-v2-design.md | 63 +++++++++++++++---- 1 file changed, 51 insertions(+), 12 deletions(-) diff --git a/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md b/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md index 1fd3c443851..f532cd61f36 100644 --- a/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md +++ b/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md @@ -94,7 +94,13 @@ interface DaemonSessionArtifact { expiresAt?: string; restoreState?: 'live' | 'restored' | 'unverified' | 'blocked'; persistenceWarning?: { - code: 'persist_failed' | 'restore_pruned' | 'content_unavailable'; + code: + | 'persist_failed' + | 'journal_budget_exceeded' + | 'metadata_quota_exceeded' + | 'validation_downgraded' + | 'restore_pruned' + | 'content_unavailable'; message: string; }; contentRef?: { @@ -112,7 +118,7 @@ interface DaemonSessionArtifact { - `persistedAt`:metadata 或 content retention 最近成功落盘时间。 - `expiresAt`:可被 GC 的时间。`pinned` 默认不设置,但 `ttlDays` pin 会转换成绝对 `expiresAt`。 - `restoreState`:恢复来源提示;不替代 `status`。 -- `persistenceWarning`:非阻塞持久化/恢复风险,前端可用它提示“此 artifact 不会跨重启保留”等状态。 +- `persistenceWarning`:非阻塞持久化/恢复风险,前端可用它提示“此 artifact 不会跨重启保留”等状态。warning code 必须覆盖实际降级原因,不能把 quota、budget、validation 和内容缺失都压成同一个模糊错误。 - `contentRef`:仅在内容保留或可信 published storage 时出现,不暴露宿主机绝对路径。 ### 3.2 Status 与 restoreState 的关系 @@ -197,8 +203,9 @@ artifact snapshot 只用于减少 replay 的 artifact event 应用量;它不 - snapshot generation 必须在同一个 artifact operation queue 中串行执行,并严格位于所有 preceding mutation 之后。 - snapshot 不能包含任何 preceding tombstone 对应的 artifact id。 - snapshot 必须携带 `tombstonedIds`,用于防止旧 upsert 在 snapshot 之后被错误复活。 -- load 时选择最新 valid snapshot,然后只应用该 snapshot 之后的 artifact events。 -- 如果 snapshot 解析失败或 tombstone 状态不一致,丢弃 artifact persistence records 并记录 warning;不能恢复不可信状态。 +- load 时从新到旧选择最新 valid snapshot,然后只应用该 snapshot 之后的 artifact events。 +- 如果最新 snapshot 解析失败,记录 `snapshot_invalid` warning,继续尝试上一个 valid snapshot;不能因为一个 corrupt snapshot 丢失整个 session 的 artifact metadata。 +- 如果没有任何 valid snapshot,允许对 active JSONL 分支做一次顺序 artifact event replay。isolated corrupt artifact record 应跳过并记录 warning;只有 branch ordering、record envelope 或 tombstone 状态已经无法建立可信顺序时,才丢弃该 session 的 artifact persistence records。 ### 4.4 存储消耗 @@ -213,7 +220,8 @@ V2 不双写 sidecar,因此没有 JSONL + sidecar 的 metadata 重复存储。 - artifact event journal 达到固定阈值后写 `session_artifact_snapshot`,例如每 100 次 artifact mutation 或每 256 KB artifact journal 写一次。 - artifact persistence records 跟随 chat transcript 生命周期;不做独立文件 GC。 -- 每 session 增加 artifact journal byte budget,例如 4 MB。超过后不再写新的 restorable metadata,普通 artifact 降级为 `ephemeral` 并带 `persistenceWarning`;用户显式 pin/save 仍返回明确错误而不是静默降级。 +- 每 session 增加 artifact journal working-set byte budget,例如 4 MB。该 budget 衡量恢复必须读取和应用的 artifact 工作集,也就是最新 valid snapshot 加其后的 artifact events;不能把 chat transcript 中已经被 snapshot 覆盖的旧 artifact records 计入 budget,否则 append-only JSONL 会变成不可恢复的一次性上限。 +- budget 接近上限时先尝试写新 snapshot。若最新 snapshot 加 post-snapshot events 仍超过 budget,则不再写新的 restorable metadata,普通 artifact 降级为 `ephemeral` 并带 `persistenceWarning.code = "journal_budget_exceeded"`;用户显式 pin/save 仍返回明确错误而不是静默降级。 - 不把 content bytes 写进 JSONL;content 只进入 daemon-managed artifact storage。 ## 5. 写入与恢复流程 @@ -232,7 +240,7 @@ V2 不双写 sidecar,因此没有 JSONL + sidecar 的 metadata 重复存储。 验证失败时: - 明确恶意或越界输入:拒绝请求。 -- 可能包含敏感 locator 但用户仍想展示 live artifact:可降级为 `ephemeral`,并写 `persistenceWarning`;不能写入 JSONL。 +- 可能包含敏感 locator 但用户仍想展示 live artifact:可降级为 `ephemeral`,并写 `persistenceWarning.code = "validation_downgraded"`;不能写入 JSONL。 ### 5.2 Artifact 写入流程 @@ -404,6 +412,7 @@ Body: - `ttlDays` 由 daemon 在 pin 时转换为绝对 `expiresAt = now + ttlDays`。 - 成功返回 mutation result,并发布 `artifact_changed` / `updated`。 - 失败返回明确错误,不改变 artifact retention。 +- 对已经 `pinned` 的 artifact,V2 pin API 默认是幂等 no-op:返回当前 artifact,不重新复制内容、不重新计算 hash、不延长 TTL。若未来需要刷新内容或延长 TTL,应设计显式 refresh/extend API;不能让重试请求隐式改变已保存内容或无限延长保留期。 ### 6.4 Unpin @@ -444,7 +453,7 @@ V2 的 DELETE 仍保持 V1 幂等,但要追加 tombstone: } ``` -`deleteContent` 表示请求立即删除可删除内容,必须经过更严格授权;共享 contentRef 只能在引用计数归零后删除。 +`deleteContent` 表示请求立即删除可删除内容,必须经过更严格授权;共享 contentRef 只能在后台引用集合确认没有其它 session 仍引用后删除。 ## 7. 安全设计 @@ -486,6 +495,13 @@ type ArtifactPrincipal = - 用户显式 pin 的 workspace artifact,且文件在 workspace 内、类型/大小可控。 - client 上传或登记的 managed artifact,前提是通过 daemon API 接收并校验。 +daemon-managed artifact storage 必须有明确 root: + +- `managed_copy` content root 位于 daemon 数据目录下的 artifact content 区域,例如 `/artifacts/content/`。 +- `published` file root 位于 daemon 数据目录下的 published artifact 区域,例如 `/artifacts/published/`,或位于配置声明的等价 daemon-owned root;root id 必须写入 publisher manifest。 +- JSONL 里不能保存可直接信任的宿主机绝对路径。restore 时只能读取 manifest 中的 root id 和相对 locator,resolve/realpath 后必须仍位于对应 root 内,并拒绝 symlink/path escape。 +- trusted publisher manifest 至少记录 publisher id、artifact id、storage root id、relative path 或 content id、sha256、sizeBytes 和 createdAt。`file:` locator 只能由该 manifest 重新生成,不能来自 client payload 或旧 JSONL 字段。 + 内容复制必须 race-safe: - workspace containment 校验通过。 @@ -541,6 +557,10 @@ type ArtifactPrincipal = - `ephemeral` 本来不写 journal,不计入 persisted metadata quota,只受 live store 上限约束。 - `restorable` 按确定性顺序裁剪,例如最旧未 pinned,并写 `restore_pruned` tombstone。 - `pinned` metadata 不因 metadata quota 被裁剪,但 content retention 仍受 content quota 约束。 +- 如果 200 个 persisted slots 全部被 `pinned` 占用,没有可裁剪的 `restorable` candidate: + - 自动/tool artifact 降级为 live-only `ephemeral`,带 `persistenceWarning.code = "metadata_quota_exceeded"`。 + - 显式请求 restorable/pin/save 的 API 返回 `METADATA_QUOTA_EXCEEDED`,提示用户删除、unpin 或让 pinned TTL 到期。 + - daemon 不能为了接收新 artifact 自动裁剪 pinned metadata;这会违背用户显式保存语义。 restore seed 不能超过 live store 上限;若历史里有效 persisted artifact 超过当前 live cap,必须按同一确定性规则 prune,并把 pruned ids 写入 tombstone,保证首次 GET 与后续 replay 一致。 @@ -565,7 +585,15 @@ GC 只处理 daemon 管理的 content storage 和过期 metadata cache: - 删除已被 tombstone 且无其它 session 引用的 managed copy。 - 删除超过 `expiresAt` 的 non-pinned content。 - 删除超过 `expiresAt` 的 pinned content,并把对应 artifact 降级为 `restorable` 或 `missing`。 -- session delete 时递减 pinned content refcount;默认删除无其它 session 引用的 content。只有用户显式导出到全局 artifact library 时,才在 session 删除后继续保留。 +- session delete 后该 session 的 artifact journal 不再贡献 content 引用;默认删除无其它 session 引用的 content。只有用户显式导出到全局 artifact library 时,才在 session 删除后继续保留。 + +GC trigger: + +- daemon startup 后延迟触发一次 project-scoped sweep。 +- session delete、artifact delete、unpin、TTL 到期检查或 content quota pressure 后 enqueue sweep。 +- 周期性 timer 可作为兜底,例如每小时一次;实现必须用单实例 lease/lock 避免并发 sweep。 + +V2 不把 mutable reference-count table 当 source of truth。content reference set 应从 project 内 session artifact journals 的最新 valid snapshot/events 派生;content manifest 只保存 content metadata 和可选的 lastKnownReferenceCount/cache。GC sweep 在后台按 project 扫描并重建引用集合,crash 后下一次 sweep 重新计算。引用未知或扫描失败时必须保守保留 content,不能删除。 GC 必须 best-effort,不阻塞 prompt/tool flow。 @@ -580,6 +608,15 @@ GC 必须 best-effort,不阻塞 prompt/tool flow。 - reader 容忍半截 JSONL 和 corrupt artifact record。 - tombstone / snapshot 顺序异常时选择不恢复,而不是猜测。 +`pin/save content` 写入顺序: + +1. 复制内容到 staging path,hash exactly copied bytes,并 fsync bytes。 +2. atomically move 到 daemon-managed content root,写入并 fsync content manifest。 +3. append artifact journal event,引用该 contentRef,并 fsync JSONL。 +4. 更新 live store 并发布 `artifact_changed`。 + +如果第 2 步成功但第 3 步前 crash,会留下没有 journal 引用的 orphan content;这是允许的,GC 在 grace period 后删除。如果第 3 步成功,restore 必须能通过 manifest 找到内容。显式 API 只有在第 3 步成功后才能返回成功。 + ### 8.5 文件读取、CPU 与 I/O 成本 V2 要避免把 artifact 恢复变成 session load 的新瓶颈。 @@ -628,7 +665,7 @@ V2 新增的失败路径必须有 structured logs,格式沿用: - `restore_pruned` - `fork_artifact_discarded` - `gc_content_deleted` -- `gc_refcount_updated` +- `gc_reference_rebuilt` - `snapshot_invalid` - `tombstone_conflict` - `content_copy_rejected` @@ -672,7 +709,7 @@ V2 新增的失败路径必须有 structured logs,格式沿用: ### Milestone E: content retention -- 增加 daemon-managed artifact storage manifest。 +- 增加 daemon-managed artifact storage manifest 和 project-scoped GC sweep。 - 实现 race-safe content copy、hash 校验、quota、GC。 - 支持 published artifact 绑定 trusted contentRef。 @@ -700,10 +737,12 @@ V2 新增的失败路径必须有 structured logs,格式沿用: - orphan tombstone 在 fork remap 时被丢弃。 - restore seed 与 concurrent POST 串行,不丢写、不重复。 - quota 边界:200 条、201 条 prune、pinned metadata 处理。 -- GC:tombstoned content 有/无跨 session 引用、TTL 过期、session delete refcount decrement。 +- GC:tombstoned content 有/无跨 session 引用、TTL 过期、session delete 后引用重建、orphan content grace cleanup。 - authorization:token-holder/principal 审计路径允许和拒绝情况。 -- partial writes:journal 成功但 warning、content manifest 成功但 journal 失败。 +- partial writes:journal 成功但 warning、content manifest 成功但 journal 失败、journal 成功后 restore 能找到 content。 - JSONL snapshot compaction:threshold 触发、tombstonedIds 保留、post-snapshot replay 有界。 +- corrupt latest snapshot fallback:回退到较旧 valid snapshot 或一次顺序 artifact replay。 +- repin idempotency:已 pinned artifact 的重复 pin 不刷新内容、不延长 TTL。 - retention defaults:tool artifact 无显式 retention、client POST `pinned` 被拒绝。 - replay idempotency:同一 session history replay 两次不会重复 artifact。 - SDK 旧 client 忽略 optional fields 后仍能展示 V1 artifacts。 From caaa225ba10f9e6d79c582c2bdbda22ee463baec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Sat, 4 Jul 2026 19:31:48 +0800 Subject: [PATCH 05/61] docs: clarify artifact sidecar cache tradeoff --- .../session-artifacts-persistence-v2-design.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md b/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md index f532cd61f36..3ed824b2043 100644 --- a/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md +++ b/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md @@ -150,6 +150,23 @@ V2 默认只使用 Chat JSONL system records: - sidecar 只能是可删除缓存,不能承载协议正确性。 - 即使 sidecar 命中,也必须对每个 artifact 执行恢复校验,不能绕过 JSONL restore validation。 +sidecar 对 V2 持久化不是 correctness requirement。当前 `loadSession()` 为恢复会读取完整 session JSONL 并重建对话树;artifact restore 在同一轮读取里提取 snapshot/event records 时,不会增加额外文件 I/O。因此,sidecar 在当前架构下只能节省 artifact records 的少量 parse/replay 成本,不能消除 session load 的主要读取成本。 + +把 sidecar 纳入当前 PR 会明显扩大实现面: + +- JSONL 与 sidecar 的双写顺序、fsync 和 crash recovery。 +- stale/corrupt sidecar 的校验、失效和 fallback。 +- archive/unarchive/delete/fork/remap 时 sidecar 生命周期同步。 +- sidecar 是否可信、是否可能绕过 restore validation 的安全边界。 +- orphan sidecar/cache cleanup 和额外测试矩阵。 + +因此 V2 发布门槛保持 JSONL-only。sidecar 只在以下任一条件被 profiling 或产品需求证明后再进入独立设计: + +- `loadSession()` 不再需要读取完整 JSONL,sidecar 可以避免一次 cold-start 全量扫描。 +- artifact list 需要在不 load session history 的场景下冷启动展示。 +- 实测 artifact restore,而不是对话历史重建,成为 session load 的主耗时。 +- 需要跨 session/project 的 artifact 搜索或全局索引。 + ### 4.2 JSONL system record 给 `ChatRecord.subtype` 增加: From e90c6c4f26af0d7053a1e15ae64d999a4c543828 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Sat, 4 Jul 2026 19:34:41 +0800 Subject: [PATCH 06/61] docs: tighten artifact persistence edge cases --- ...session-artifacts-persistence-v2-design.md | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md b/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md index 3ed824b2043..3727134f4f3 100644 --- a/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md +++ b/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md @@ -316,7 +316,7 @@ session load/replay 时: 恢复时必须重新校验: -- `workspacePath`:仍必须是相对路径,realpath/stat 后不能逃逸当前 workspace。 +- `workspacePath`:仍必须是相对路径,按 restore 时的 workspace root 重新 resolve/realpath/stat,不能逃逸当前 workspace。workspace 重定位后,如果相同相对路径仍存在则可恢复为 `available`;如果文件缺失或新 workspace layout 不一致,则恢复为 `missing`。V2 不做自动 path remapping。 - `external_url`:只允许 `http:` / `https:`;拒绝 username/password credential;secret-like query/fragment 必须 redacted、降级为 non-openable locator,或整条 artifact 降级/阻断。 - `published`:可以恢复 `file:` locator,但只能在 trusted publisher manifest 重新校验通过、且目标属于 daemon-managed published storage 时允许。普通 `external_url` 永远不能通过 `file:`。 - `managedId`:拒绝路径形态、`..`、绝对路径、分隔符。 @@ -426,7 +426,7 @@ Body: - `mode: "metadata"`:升级为 `restorable`。 - `mode: "content"`:复制或绑定可控内容,升级为 `pinned`。 -- `ttlDays` 由 daemon 在 pin 时转换为绝对 `expiresAt = now + ttlDays`。 +- `ttlDays` 由 daemon 在 pin 时转换为绝对 `expiresAt = now + ttlDays`。如果提供,必须是正整数,并受默认最大值 365 天约束;超过上限返回 `INVALID_ARGUMENT`。未提供时才表示无固定过期时间。 - 成功返回 mutation result,并发布 `artifact_changed` / `updated`。 - 失败返回明确错误,不改变 artifact retention。 - 对已经 `pinned` 的 artifact,V2 pin API 默认是幂等 no-op:返回当前 artifact,不重新复制内容、不重新计算 hash、不延长 TTL。若未来需要刷新内容或延长 TTL,应设计显式 refresh/extend API;不能让重试请求隐式改变已保存内容或无限延长保留期。 @@ -450,7 +450,7 @@ Body: - `retention` 可为 `restorable` 或 `ephemeral`,默认 `restorable`。 - `restorable`:删除 content retention,保留 metadata restore,并写 upsert event。 -- `ephemeral`:删除 content retention,live store 中保留 artifact,但写 remove tombstone 或等价 restore-skip 记录,确保下次 load 不复活。 +- `ephemeral`:删除 content retention,live store 中保留 artifact,但写 remove tombstone,确保下次 load 不复活。V2 不定义单独的 restore-skip journal event;跳过被 tombstone 覆盖的旧 upsert 是 replay 实现细节。 - 若要从列表移除,仍使用 V1 DELETE。 ### 6.5 Delete artifact @@ -493,8 +493,8 @@ type ArtifactPrincipal = - list:需要 session read 权限。 - add ephemeral/restorable:需要 session mutate 权限。 - pin/save content:需要 session mutate 权限,并且必须是显式 REST/SDK call;“用户确认”在 V2 中不表示 agent permission-vote,而是 UI 或 headless client 主动调用 pin/save endpoint。 -- delete metadata:需要 session mutate 权限;如果要保留 per-client ownership,只能用内部 principal 审计,不能用 payload clientId 授权。 -- delete content:需要 session mutate 权限;若 contentRef 被多个 session 引用,只能递减引用计数,不能强删共享内容。 +- delete metadata:需要 session mutate 权限。对于仍在 live store 中、且存在 daemon-resolved creator principal 的 artifact,必须保留 V1 same-principal delete guard;跨 principal 删除只能通过显式 override/admin policy,并写审计日志。restore 后如果没有可验证的 durable creator principal,不能从 public `clientId` 伪造 ownership,只能退化为 session-level mutate 权限并记录 `ownership_unverified` audit。 +- delete content:需要 session mutate 权限、`session_artifacts_content_retention` capability 启用、显式 REST/SDK call,以及 creator-principal match 或显式 override/admin policy;background session/hook 不能直接发起 `deleteContent`。若 contentRef 被多个 session 引用,只能释放当前 artifact 引用,不能强删共享内容。 如果未来需要真正的 `session_owner`,必须先设计 durable per-session capability 或 ACL,不能在本 V2 文档中隐式假设。 @@ -572,7 +572,7 @@ daemon-managed artifact storage 必须有明确 root: 超过 persisted metadata 上限: - `ephemeral` 本来不写 journal,不计入 persisted metadata quota,只受 live store 上限约束。 -- `restorable` 按确定性顺序裁剪,例如最旧未 pinned,并写 `restore_pruned` tombstone。 +- `restorable` 必须按确定性顺序裁剪并写 `restore_pruned` tombstone:先裁剪未 pinned 的 `restorable` artifact;同级内按 `persistedAt` 升序,缺失时按 journal sequence 升序,最后按 `artifactId` 字典序打破平局。 - `pinned` metadata 不因 metadata quota 被裁剪,但 content retention 仍受 content quota 约束。 - 如果 200 个 persisted slots 全部被 `pinned` 占用,没有可裁剪的 `restorable` candidate: - 自动/tool artifact 降级为 live-only `ephemeral`,带 `persistenceWarning.code = "metadata_quota_exceeded"`。 @@ -612,6 +612,8 @@ GC trigger: V2 不把 mutable reference-count table 当 source of truth。content reference set 应从 project 内 session artifact journals 的最新 valid snapshot/events 派生;content manifest 只保存 content metadata 和可选的 lastKnownReferenceCount/cache。GC sweep 在后台按 project 扫描并重建引用集合,crash 后下一次 sweep 重新计算。引用未知或扫描失败时必须保守保留 content,不能删除。 +orphan content 删除必须有 grace period。默认 grace period 为 1 小时,从 GC sweep 首次确认某个 contentRef 没有任何 journal 引用并在 manifest/cache 中写入 `orphanedAt` 开始计算;下一次 sweep 只有在仍无引用且已超过 grace period 时才删除。新的 journal 引用会清除 `orphanedAt`。该值可以配置,但不能短于一次正常 pin/save 操作和 journal flush 的最长预期窗口。 + GC 必须 best-effort,不阻塞 prompt/tool flow。 ### 8.4 Crash consistency @@ -689,6 +691,12 @@ V2 新增的失败路径必须有 structured logs,格式沿用: 这些日志不替代 API/SSE 中的 `persistenceWarning`,而是用于生产排障。 +诊断工具是 content retention 发布门槛之一。实现必须提供 CLI 或 daemon-internal API,例如 `qwen artifact fsck`,用于 dry-run 扫描 project/session artifact journals、content manifests 和 daemon-managed storage: + +- 报告 dangling `contentRef`、manifest 缺失、orphan content、snapshot/tombstone 不一致和 restore validation failure。 +- 默认只读;修复模式只能做可验证的安全动作,例如重新生成 snapshot、清除 stale cache marker、标记 orphan content 等待 GC。不能在没有引用集合确认和 grace period 的情况下直接删除内容。 +- 输出结构化结果并计数,至少包含 `fsck_dangling_content_ref`、`fsck_orphan_content`、`fsck_snapshot_invalid`;持续出现的 dangling contentRef 或 snapshot invalid 应触发告警。 + ## 9. 实现方案 以下是同一个 V2 design phase 内的实现里程碑。工程上可以按 PR 拆开;对外以 capability 声明实际可用能力。 @@ -740,6 +748,7 @@ V2 新增的失败路径必须有 structured logs,格式沿用: - unpin 到 `ephemeral` 后 load 不复活 artifact。 - DELETE、eviction、unpin-to-ephemeral、`restore_pruned` tombstone append 失败时不会先改变 live state。 - workspace artifact ingest 和 restore 时文件存在/缺失/symlink escape 三种状态。 +- workspace root 重定位:相同相对路径存在时恢复为 available;缺失或 layout 不一致时恢复为 missing;不做 path remap。 - pin/save content 时拒绝 symlink、special file、oversized stream、hardlink 异常和 TOCTOU swap。 - external URL 只恢复 metadata,不发网络请求。 - secret-bearing URL query/fragment 与 metadata key/value 不写入 JSONL。 @@ -756,6 +765,7 @@ V2 新增的失败路径必须有 structured logs,格式沿用: - quota 边界:200 条、201 条 prune、pinned metadata 处理。 - GC:tombstoned content 有/无跨 session 引用、TTL 过期、session delete 后引用重建、orphan content grace cleanup。 - authorization:token-holder/principal 审计路径允许和拒绝情况。 +- V1 live same-principal delete guard 保留、cross-principal delete audit、restored artifact ownership_unverified fallback。 - partial writes:journal 成功但 warning、content manifest 成功但 journal 失败、journal 成功后 restore 能找到 content。 - JSONL snapshot compaction:threshold 触发、tombstonedIds 保留、post-snapshot replay 有界。 - corrupt latest snapshot fallback:回退到较旧 valid snapshot 或一次顺序 artifact replay。 @@ -763,6 +773,8 @@ V2 新增的失败路径必须有 structured logs,格式沿用: - retention defaults:tool artifact 无显式 retention、client POST `pinned` 被拒绝。 - replay idempotency:同一 session history replay 两次不会重复 artifact。 - SDK 旧 client 忽略 optional fields 后仍能展示 V1 artifacts。 +- V2 -> V1 rollback compatibility:旧 daemon 必须能解析或忽略 unknown `system` subtype,不得导致 session load 崩溃;回滚后 artifact persistence 不恢复是可接受降级。如果当前最低支持版本不能保证这一点,V2 writer 必须 capability-gate 到支持 unknown system record 的版本之后。 +- artifact fsck dry-run:dangling contentRef、orphan content、snapshot invalid 和 repair-safe actions。 ## 11. 不建议在 V2 做的事 @@ -771,6 +783,7 @@ V2 新增的失败路径必须有 structured logs,格式沿用: - 默认复制所有 workspace artifact 内容。 - 对 external URL 做 reachability poll。 - 把 `clientId` 作为删除或 pin/save 的授权凭证。 +- 对重定位 workspace 做自动 path remapping。 - 在 GET 热路径里做大量 fs/network 校验。 - 把持久化失败变成普通 tool turn 失败。 - 在没有测量证明需要时引入 sidecar cache。 From 1c4137f94724182cf3fff11a3c7ed2839f6dbf76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Sat, 4 Jul 2026 20:13:26 +0800 Subject: [PATCH 07/61] docs: tighten artifact persistence design semantics --- ...session-artifacts-persistence-v2-design.md | 168 +++++++++++------- 1 file changed, 102 insertions(+), 66 deletions(-) diff --git a/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md b/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md index 3727134f4f3..6a5035faddc 100644 --- a/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md +++ b/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md @@ -21,7 +21,7 @@ V2 是一个完整设计 phase,但对外能力仍按 capability gate 暴露。 核心原则: - V1 的 `SessionArtifactStore` 仍是 live session 的权威内存索引。 -- V2 增加 JSONL artifact journal/snapshot,用于在 daemon 侧创建 live store 时 seed 初始状态。 +- V2 增加 JSONL artifact journal/snapshot,用于在 daemon 侧创建 live store 时 seed 初始状态;JSONL append 必须由当前拥有 chat recording 的 core/ACP child 路径完成,daemon-side store 不能直接写 transcript。 - V2 默认 JSONL-only。sidecar cache 不进入 V2 发布门槛;只有实测 session load 成本不可接受时,才另行设计可删除缓存。 - 不把远端 URL 内容抓取到本地。 - 不默认复制 workspace 文件。 @@ -40,7 +40,7 @@ V2 后的行为应是: - daemon/bridge 重启:如果 session 被重新 load,V2 从持久化 metadata 恢复 artifact list。 - 历史 load/replay:如果该 session 有 V2 persistence records,恢复 artifact list;没有则返回空 list。 -V1 到 V2 的 live upgrade 需要单独处理:已经在内存中的 V1 live artifacts 没有 JSONL journal。V2 daemon 首次触达这些 live sessions 时,应在 daemon-side bridge 从当前 live store 写入一条初始 `session_artifact_snapshot`,然后再接受新的 restorable/pinned artifact mutation。若 backfill 失败,该 session 继续保持 V1 live-only 行为,并记录 structured warning;不能让用户误以为已有 live artifacts 已经可恢复。 +V1 到 V2 的 live upgrade 需要单独处理:已经在内存中的 V1 live artifacts 没有 JSONL journal。V2 首次触达这些 live sessions 时,应通过 chat recording owner 提供的 artifact persistence writer 写入一条初始 `session_artifact_snapshot`,然后再接受新的 restorable/pinned artifact mutation。backfill 不能把 live store 原样序列化;必须对每个 artifact 重新执行 ingest validation、privacy minimization 和 `retention` materialization。单条 artifact 不合格时跳过或降级该条,不能让一次坏记录拖垮整个 backfill。若 writer 不可用或 backfill 整体失败,该 session 继续保持 V1 live-only 行为,并记录 structured warning;不能让用户误以为已有 live artifacts 已经可恢复。 ### 2.2 retention 分层 @@ -63,7 +63,7 @@ type ArtifactRetention = 'ephemeral' | 'restorable' | 'pinned'; - 后台/自动化 client POST:如果只是临时 UI 状态,应显式请求 `retention: "ephemeral"`;SDK 应提供明确的 ephemeral helper。 - `published` artifact:默认 `restorable`;如果 publisher 提供可校验 daemon-managed content,则可升级为 `pinned`。 -如果 chat recording 被禁用,metadata persistence 默认禁用,capability 不声明或只声明为 disabled 状态。 +如果 chat recording 被禁用,metadata persistence 默认禁用,capability 不声明。 ### 2.3 用户注册 artifact 恢复语义 @@ -167,7 +167,19 @@ sidecar 对 V2 持久化不是 correctness requirement。当前 `loadSession()` - 实测 artifact restore,而不是对话历史重建,成为 session load 的主耗时。 - 需要跨 session/project 的 artifact 搜索或全局索引。 -### 4.2 JSONL system record +### 4.2 JSONL writer ownership and branch model + +Artifact persistence records 是 chat transcript 的一部分,必须遵循现有 `ChatRecord` 的 parent/leaf 语义: + +- JSONL append 只能通过拥有 `ChatRecordingService.appendRecord` 的进程或它暴露的明确 RPC 完成。daemon-side `SessionArtifactStore` 可以用 operation queue 协调 live state、SSE 和 persistence request 顺序,但不能自己打开并写 chat JSONL。 +- 每条 `session_artifact_event` / `session_artifact_snapshot` 都必须作为普通 system `ChatRecord` 挂到当前 conversation leaf 上,并获得正常的 `uuid` / `parentUuid`。 +- session load/replay 只应用 active leaf chain 中的 artifact records。被 `/rewind` 丢到 abandoned branch 的 artifact upsert/remove 不再影响当前 artifact list。 +- fork/branch 只复制 active chain 中的 artifact records;off-chain records 不参与目标 session 的恢复。 +- 如果某个实现阶段还不能把 artifact system records 接到 active leaf chain,就不能声明 `session_artifacts_persistence` capability;否则 rewind 后会出现旧 upsert 或旧 tombstone 复活的问题。 + +这意味着 V2 不设计独立的 artifact log 文件,也不设计绕过 chat tree 的 side log。artifact persistence 的正确性来自同一条 active chat history,而不是 daemon 当前内存状态。 + +### 4.3 JSONL system record 给 `ChatRecord.subtype` 增加: @@ -185,8 +197,12 @@ interface SessionArtifactEventRecordPayload { changes: Array<{ action: 'upsert' | 'remove'; artifactId: string; - artifact?: DaemonSessionArtifact; - reason?: 'explicit' | 'eviction' | 'restore_pruned' | 'unpin_to_ephemeral'; + artifact?: PersistedSessionArtifact; + reason?: + | 'explicit' + | 'quota_pruned' + | 'restore_pruned' + | 'unpin_to_ephemeral'; }>; } @@ -195,36 +211,42 @@ interface SessionArtifactSnapshotRecordPayload { sessionId: string; sequence: number; generatedAt: string; - artifacts: DaemonSessionArtifact[]; + artifacts: PersistedSessionArtifact[]; tombstonedIds: string[]; } + +interface PersistedSessionArtifact extends DaemonSessionArtifact { + clientRetained?: boolean; + createdSequence: number; +} ``` -`sequence` 必须来源于 JSONL record ordering,例如 line/order index 或 ChatRecord append order,不能使用 daemon 进程内会在重启后归零的 counter。读取时以 JSONL 顺序为准;`sequence` 仅用于检测异常和生成 snapshot。 +`sequence` / `createdSequence` 必须来源于 JSONL record ordering,例如 line/order index 或 ChatRecord append order,不能使用 daemon 进程内会在重启后归零的 counter。读取时以 JSONL 顺序为准;字段仅用于检测异常、生成 snapshot、恢复稳定排序和 quota prune tie-break。 -只写经过 store validation/normalization 后的 public artifact shape,不写内部字段: +只写经过 store validation/normalization 后的最小化 artifact shape。除 `clientRetained` 和 `createdSequence` 这两个恢复语义字段外,不写 V1 内部字段: - 不写 `identityKey` - 不写 `trustedPublisher` - 不写绝对 `workspaceCwd` - 不写 transport token / auth principal -删除 artifact 或 unpin 到 `ephemeral` 必须写 tombstone change,避免历史 replay 后被旧 upsert 复活。 +删除 artifact 或 unpin 到 `ephemeral` 必须写 tombstone change,避免历史 replay 后被旧 upsert 复活。tombstone 不是永久禁止同一 id 再出现:它只覆盖自己之前的 upsert,直到之后出现更高 sequence 的显式 upsert。`reason: "unpin_to_ephemeral"` 是 sticky override:后续同一 artifact id 的隐式/default upsert 仍按 live-only 处理,只有用户或可信 client 显式请求 `retention: "restorable"` 或 pin/save 才能写入 superseding upsert。 -### 4.3 Snapshot 与 tombstone 不变量 +### 4.4 Snapshot 与 tombstone 不变量 artifact snapshot 只用于减少 replay 的 artifact event 应用量;它不会减少 JSONL 文件本身的读取量。 必须满足: - snapshot generation 必须在同一个 artifact operation queue 中串行执行,并严格位于所有 preceding mutation 之后。 -- snapshot 不能包含任何 preceding tombstone 对应的 artifact id。 -- snapshot 必须携带 `tombstonedIds`,用于防止旧 upsert 在 snapshot 之后被错误复活。 +- snapshot 是 authoritative current state:它只包含 snapshot 生成时仍有效的 artifacts。 +- `tombstonedIds` 只记录 snapshot 之后仍需要覆盖旧 upsert 的 tombstones;被 snapshot 覆盖的旧 tombstones 必须 compaction 掉,避免数组随历史无限增长。 +- snapshot 可以包含曾经被 tombstone 的 artifact id,前提是该 tombstone 已被更高 sequence 的显式 upsert supersede。 - load 时从新到旧选择最新 valid snapshot,然后只应用该 snapshot 之后的 artifact events。 - 如果最新 snapshot 解析失败,记录 `snapshot_invalid` warning,继续尝试上一个 valid snapshot;不能因为一个 corrupt snapshot 丢失整个 session 的 artifact metadata。 -- 如果没有任何 valid snapshot,允许对 active JSONL 分支做一次顺序 artifact event replay。isolated corrupt artifact record 应跳过并记录 warning;只有 branch ordering、record envelope 或 tombstone 状态已经无法建立可信顺序时,才丢弃该 session 的 artifact persistence records。 +- 如果没有任何 valid snapshot,允许对 active JSONL leaf chain 做一次顺序 artifact event replay。isolated corrupt artifact record 应跳过并记录 warning;只有 branch ordering、record envelope 或 tombstone 状态已经无法建立可信顺序时,才丢弃该 session 的 artifact persistence records。 -### 4.4 存储消耗 +### 4.5 存储消耗 V2 不双写 sidecar,因此没有 JSONL + sidecar 的 metadata 重复存储。存储消耗分为 metadata journal 和 content retention: @@ -272,12 +294,19 @@ V2 流程: ```text ingest input -> normalize/validate - -> apply live-store mutation in operationQueue - -> for restorable/pinned changes: append artifact journal + -> in SessionArtifactStore operationQueue: compute effective mutation + -> for restorable/pinned changes: request chat-recording writer append + artifact journal/snapshot on the active leaf chain + -> apply live-store mutation -> publish artifact_changed with effective retention/warning fields ``` -持久化必须在同一个 store operation queue 内串行执行,避免 live store、journal、SSE 顺序错乱。 +`SessionArtifactStore` 的 operation queue 负责串行化同一 session 的 live mutation、persistence request 和 SSE 顺序;真正的 JSONL append 仍由 chat recording owner 完成。普通 tool/hook artifact 如果 persistence writer 不可用,可以降级为 live-only `ephemeral` 后进入 live store;显式 `pin/save` 不能降级,必须返回错误。 + +live store eviction 和 persisted metadata prune 必须分开: + +- live store 上限只影响当前内存 view,不写 durable tombstone。V2 live eviction 必须 retention-aware,优先丢弃 ephemeral/unretained item;不能因为 V1 live cap 就让 `restorable` / `pinned` metadata 永久丢失。 +- persisted metadata quota prune 才写 `quota_pruned` / `restore_pruned` remove change,并且必须和触发它的新 upsert 在同一次 durable journal append 中完成。append 成功后再更新 live store;append 失败时不裁剪已持久化 metadata。 ### 5.3 写入失败语义 @@ -286,16 +315,17 @@ ingest input - 普通 tool/hook artifact:持久化失败不应让工具调用失败;artifact 仍可进入 live store,但必须先把 live store 中的 `retention` 降级为 `ephemeral`,设置 `persistenceWarning`,再发布 `artifact_changed`。 - 显式 `pin/save` API:持久化失败必须返回错误,不能假装已经保存。 -对会影响恢复结果的删除型 mutation,必须 durable-first 或 rollback: +对会影响恢复结果的删除型 mutation,按原因区分: -- DELETE、eviction、unpin-to-ephemeral、`restore_pruned` 都必须先成功写入 tombstone,再从 live store 移除或发布变化。 -- 如果 tombstone append 失败,保持 live store 不变;显式 API 返回错误,后台 eviction/prune 记录 structured warning 并稍后重试。 -- 不能先从 live store 删除再尝试写 tombstone,否则下一次 replay 可能复活用户已经删除或 quota 已裁剪的 artifact。 +- V1/当前 live cap eviction:只影响 live view,不写 tombstone,不改变持久化 metadata。 +- `quota_pruned` / `restore_pruned` / unpin-to-`ephemeral`:必须 durable-first 或 rollback。tombstone append 失败时保持 persisted state 不变;后台 prune 记录 structured warning 并稍后重试,显式 unpin API 返回错误。 +- 显式 DELETE:必须先从 live store 移除,避免磁盘满或 transcript 写失败时阻止用户隐藏敏感 artifact。随后 best-effort 写 tombstone;如果 append 失败,daemon 在当前进程内保留 pending tombstone 并重试,同时通过 API warning / structured log 表明“本次删除尚未 durable,daemon crash 后可能从历史恢复”。不能把这种状态报告成已持久删除。 建议 warning: ```text [artifacts] session= action=persist_failed artifact= reason= +[artifacts] session= action=delete_not_durable artifact= ``` ### 5.4 恢复流程 @@ -303,7 +333,7 @@ ingest input session load/replay 时: 1. `SessionService.loadSession()` 读取 JSONL,并在同一轮 parse 中提取 artifact snapshot/event records。 -2. 提取 active branch 中最新 valid `session_artifact_snapshot` 和之后的 `session_artifact_event`。 +2. 基于 active leaf chain 提取最新 valid `session_artifact_snapshot` 和之后的 `session_artifact_event`。abandoned branch 上的 artifact records 必须忽略。 3. 重建 artifact snapshot,应用 tombstone。 4. 对每个 artifact 重新执行 V2 restore validation。 5. load result 携带 `artifactSnapshot` 回到 daemon-side bridge。 @@ -312,6 +342,8 @@ session load/replay 时: 不要在 ACP child process 的 agent/session 对象里 seed `SessionArtifactStore`:生产 HTTP API 可见的 store 在 daemon-side bridge 中创建。 +`loadSession()` 必须是 read-only:它不能在解析过程中写 `restore_pruned` tombstone,也不能直接触发 content GC。若 restore 后发现当前 live cap 或 policy 比历史更严格,daemon-side store 在创建完成、persistence writer 可用后,再通过正常 operation queue 写 `restore_pruned` tombstone;writer 不可用时只在 live view 中隐藏超限 item,并记录 warning,下一次 load 仍可能重新看到这些待裁剪记录。 + ### 5.5 恢复时校验 恢复时必须重新校验: @@ -331,15 +363,15 @@ session load/replay 时: ### 5.6 Branch / fork 语义 -现有 `/branch` 会复制 active JSONL record chain 并重写 `sessionId`。V2 artifact records 也会跟随复制,因此必须显式处理 artifact id: +现有 `/branch` 会复制 active JSONL record chain 并重写 `sessionId`。V2 artifact records 只从 active leaf chain 复制;rewind 后落在 abandoned branch 上的 artifact records 不会进入 fork。复制时必须显式处理 artifact id: - 同一个资源在新 session 中应得到新 artifact id,因为 V1 identity 包含 `sessionId`。 - fork 写入目标 session 时,应根据目标 `sessionId + locator` 重新计算 artifact id。 -- tombstone 也要按目标 session 的新 id 重写。若源 tombstone 找不到目标 session 中对应 upsert,丢弃该 orphan tombstone。 +- tombstone 也要按目标 session 的新 id 重写。若源 tombstone 找不到目标 active chain 中对应 upsert,丢弃该 orphan tombstone。 - `forkedFrom` 可以记录原 session id / 原 artifact id,作为审计信息,但不能参与新 session 的权限判断。 - fork 继承 artifact metadata 时,`pinned` 必须降级为 `restorable`。fork 不继承 pinned contentRef,用户需要在 forked session 中显式 re-pin,避免通过 fork 绕过 session/project quota。 -fork remap 是发布门槛:如果某条路径不能安全重写 artifact id 和 tombstone,就必须在 fork 时丢弃 artifact persistence records,不能把源 session 的 artifact id 原样带入新 session。 +fork remap 是发布门槛:如果某条路径不能安全重写 artifact id 和 tombstone,就必须在 fork 时丢弃 artifact persistence records,不能把源 session 的 artifact id 原样带入新 session。若现有 fork 实现有类似 `file_history_snapshot` 的 top-up 机制,artifact 也只能从 active-chain replay result 生成 top-up,不能从 daemon 当前 live store 原样补写,否则会把 rewind 后不再属于历史的 artifact 带入新 session。 ## 6. API 设计 @@ -357,24 +389,12 @@ fork remap 是发布门槛:如果某条路径不能安全重写 artifact id "session_artifacts_content_retention" ``` -可选 details: +当前 `/capabilities` 是 string feature list,因此不能用 `enabled: false` 表达“实现存在但当前关闭”。规则是: -```json -{ - "session_artifacts_persistence": { - "metadata": true, - "defaultRetention": "restorable", - "maxRestorableArtifacts": 200 - }, - "session_artifacts_content_retention": { - "enabled": true, - "maxPinnedArtifactBytes": 52428800, - "maxPinnedSessionBytes": 209715200 - } -} -``` - -`enabled` 表示当前配置下是否允许使用 content retention。若实现已具备但用户设置关闭,details 可以返回 `enabled: false`;如果现有 capability 结构只支持 string feature,则只有 `pin/save content`、quota、hash、manifest 和 GC 都可用且当前启用时,才声明 `session_artifacts_content_retention`。 +- 行为可用且当前配置启用时才声明对应 feature string。 +- chat recording 禁用、metadata persistence 禁用或 writer 不可用时,不声明 `session_artifacts_persistence`。 +- content retention 的 `pin/save content`、quota、hash、manifest 和 GC 都可用且当前启用时,才声明 `session_artifacts_content_retention`。 +- 如果 client 需要读取 limits/default retention,应另设计 config endpoint 或 SDK config query;不要把结构化 details 混入现有 string-only capability contract。 ### 6.2 Add artifact @@ -450,7 +470,7 @@ Body: - `retention` 可为 `restorable` 或 `ephemeral`,默认 `restorable`。 - `restorable`:删除 content retention,保留 metadata restore,并写 upsert event。 -- `ephemeral`:删除 content retention,live store 中保留 artifact,但写 remove tombstone,确保下次 load 不复活。V2 不定义单独的 restore-skip journal event;跳过被 tombstone 覆盖的旧 upsert 是 replay 实现细节。 +- `ephemeral`:删除 content retention,live store 中保留 artifact,但写 remove tombstone,确保下次 load 不复活。该 tombstone 对同一 artifact id 是 sticky override:后续隐式/default upsert 仍保持 live-only,直到显式 `retention: "restorable"` 或 pin/save 写入 superseding upsert。 - 若要从列表移除,仍使用 V1 DELETE。 ### 6.5 Delete artifact @@ -458,8 +478,9 @@ Body: V2 的 DELETE 仍保持 V1 幂等,但要追加 tombstone: - 从 live store 移除。 -- 写 `session_artifact_event` remove tombstone。 -- metadata restore 时不再复活。 +- best-effort 写 `session_artifact_event` remove tombstone。 +- tombstone 成功后,metadata restore 时不再复活。 +- tombstone 失败时,当前 live view 仍删除成功,但 response/log 必须暴露 `delete_not_durable` warning;daemon 在当前进程内继续重试 pending tombstone。若 daemon crash 前仍未落盘,历史 load 可能恢复该 artifact。 - 默认不立即同步删除 managed/pinned content,但会释放该 artifact 对 contentRef 的引用;GC 默认清理无其它 session 引用的 daemon-managed content。 可选 body 或独立 endpoint 支持内容删除: @@ -478,7 +499,7 @@ V2 的 DELETE 仍保持 V1 幂等,但要追加 tombstone: 不要把 public `clientId` 当授权边界。V2 的实际 HTTP 信任边界仍是 daemon bearer token + route-level read/mutate permission;在现有 auth 模型下,`session_owner` 不能被安全 mint 或跨 daemon restart 持久化。因此 V2 不引入强于 token-holder 的 owner tier。 -内部 principal 只用于审计、默认策略和防止 payload spoofing: +内部 principal 只用于审计、默认策略和防止 payload spoofing;不是 durable authorization source: ```ts type ArtifactPrincipal = @@ -493,8 +514,8 @@ type ArtifactPrincipal = - list:需要 session read 权限。 - add ephemeral/restorable:需要 session mutate 权限。 - pin/save content:需要 session mutate 权限,并且必须是显式 REST/SDK call;“用户确认”在 V2 中不表示 agent permission-vote,而是 UI 或 headless client 主动调用 pin/save endpoint。 -- delete metadata:需要 session mutate 权限。对于仍在 live store 中、且存在 daemon-resolved creator principal 的 artifact,必须保留 V1 same-principal delete guard;跨 principal 删除只能通过显式 override/admin policy,并写审计日志。restore 后如果没有可验证的 durable creator principal,不能从 public `clientId` 伪造 ownership,只能退化为 session-level mutate 权限并记录 `ownership_unverified` audit。 -- delete content:需要 session mutate 权限、`session_artifacts_content_retention` capability 启用、显式 REST/SDK call,以及 creator-principal match 或显式 override/admin policy;background session/hook 不能直接发起 `deleteContent`。若 contentRef 被多个 session 引用,只能释放当前 artifact 引用,不能强删共享内容。 +- delete metadata:需要 session mutate 权限。V1 same-principal delete guard 只能作为 live-process UX guard 和 audit hint;它依赖当前连接上下文,不能跨 daemon restart 证明 artifact owner。restore 后不能从 public `clientId` 伪造 ownership,删除授权退化为 session-level mutate 权限并记录 `ownership_unverified` audit。 +- delete content:需要 session mutate 权限、`session_artifacts_content_retention` capability 启用、显式 REST/SDK call,以及当前进程可验证的 creator-principal match 或显式 override/admin policy;background session/hook 不能直接发起 `deleteContent`。restore 后如果没有 durable owner proof,默认只能释放当前 artifact 引用并交给 GC,不能立即强删共享 content。若 contentRef 被多个 session 引用,也只能释放当前 artifact 引用,不能强删共享内容。 如果未来需要真正的 `session_owner`,必须先设计 durable per-session capability 或 ACL,不能在本 V2 文档中隐式假设。 @@ -569,17 +590,23 @@ daemon-managed artifact storage 必须有明确 root: - persisted metadata 上限每 session 200,与 live store 对齐。 - snapshot record 最多保留 200 个当前有效 artifacts。 +live store 上限是内存展示约束,不是 durable deletion policy: + +- V2 live eviction 必须优先淘汰 `ephemeral` artifact。 +- 如果必须在 restorable artifacts 中选择 live view,应按 `clientRetained`、`retention`、`createdSequence` 和 `artifactId` 做确定性排序,只隐藏当前 live view 中优先级最低的 item;不能写 tombstone。 +- `clientRetained` 是用户保留意图,必须进入 `PersistedSessionArtifact`,用于 restore 后稳定排序和 live cap 选择。V1 的进程内 `insertSeq` 不直接持久化,但必须转换成 durable `createdSequence`。 + 超过 persisted metadata 上限: - `ephemeral` 本来不写 journal,不计入 persisted metadata quota,只受 live store 上限约束。 -- `restorable` 必须按确定性顺序裁剪并写 `restore_pruned` tombstone:先裁剪未 pinned 的 `restorable` artifact;同级内按 `persistedAt` 升序,缺失时按 journal sequence 升序,最后按 `artifactId` 字典序打破平局。 +- `restorable` 必须按确定性顺序裁剪并写 `quota_pruned` tombstone:先裁剪未 `clientRetained` 的 `restorable` artifact;同级内按 `persistedAt` 升序,缺失时按 `createdSequence` 升序,最后按 `artifactId` 字典序打破平局。 - `pinned` metadata 不因 metadata quota 被裁剪,但 content retention 仍受 content quota 约束。 - 如果 200 个 persisted slots 全部被 `pinned` 占用,没有可裁剪的 `restorable` candidate: - 自动/tool artifact 降级为 live-only `ephemeral`,带 `persistenceWarning.code = "metadata_quota_exceeded"`。 - 显式请求 restorable/pin/save 的 API 返回 `METADATA_QUOTA_EXCEEDED`,提示用户删除、unpin 或让 pinned TTL 到期。 - daemon 不能为了接收新 artifact 自动裁剪 pinned metadata;这会违背用户显式保存语义。 -restore seed 不能超过 live store 上限;若历史里有效 persisted artifact 超过当前 live cap,必须按同一确定性规则 prune,并把 pruned ids 写入 tombstone,保证首次 GET 与后续 replay 一致。 +restore seed 不能超过 live store 上限;若历史里有效 persisted artifact 超过当前 live cap,daemon 可以按同一确定性规则只 seed 可见 subset,并对隐藏 item 标记 warning/metrics。只有在 metadata quota policy 确认这些 item 不应再恢复时,才通过 daemon-side operation queue 写 `restore_pruned` tombstone;`loadSession()` 本身不能写 durable prune。 ### 8.2 Content quota @@ -624,6 +651,8 @@ GC 必须 best-effort,不阻塞 prompt/tool flow。 - JSONL journal append 失败不会破坏 live store。 - explicit `pin/save metadata` 必须等待 journal 落盘。 - explicit `pin/save content` 必须等待 content manifest 和 journal 都落盘。 +- explicit DELETE 的 live removal 不等待 journal;tombstone append 失败时必须暴露 `delete_not_durable` 并在当前进程内重试。 +- live cap eviction 不写 tombstone;metadata quota prune 必须 durable-first。 - reader 容忍半截 JSONL 和 corrupt artifact record。 - tombstone / snapshot 顺序异常时选择不恢复,而不是猜测。 @@ -682,6 +711,8 @@ V2 新增的失败路径必须有 structured logs,格式沿用: - `restore_skipped` - `restore_blocked` - `restore_pruned` +- `delete_not_durable` +- `quota_pruned` - `fork_artifact_discarded` - `gc_content_deleted` - `gc_reference_rebuilt` @@ -703,11 +734,10 @@ V2 新增的失败路径必须有 structured logs,格式沿用: ### Milestone A: 类型和 persistence service -- 新增 `ArtifactPersistenceService`,职责: - - append event/snapshot record - - rebuild from JSONL - - restore validation - - snapshot/tombstone consistency checks +- 新增 artifact persistence reader/writer: + - writer 位于 chat recording owner 一侧,或者由该侧暴露明确 RPC;它负责 append event/snapshot record 到 active leaf chain。 + - reader 位于 `SessionService.loadSession()` parse/replay 路径,负责从 active leaf chain rebuild artifact snapshot。 + - 共享 restore validation、snapshot/tombstone consistency checks 和 persisted shape normalization。 - 扩展 `ChatRecord.subtype` 与 `systemPayload` union。 - 增加 load result 中的 `artifactSnapshot?`。 @@ -715,14 +745,15 @@ V2 新增的失败路径必须有 structured logs,格式沿用: - daemon bridge `createSessionEntry` 支持 seed artifacts。 - `SessionArtifactStore` 支持 seed artifacts。 -- `upsertMany()` 根据 effective `retention` 决定是否调用 persistence。 -- `remove()` 写 tombstone。 +- `upsertMany()` 在 operation queue 中计算 effective `retention`、quota prune 和 live view,再通过 writer append durable records。 +- `remove()` 区分 explicit DELETE、unpin-to-ephemeral、quota prune 和 live eviction;只有前三者写 tombstone,explicit DELETE 允许 live-first + pending tombstone retry。 - 保持 V1 `artifact_changed` event shape 不变,只增加 optional fields。 ### Milestone C: load/replay 集成 -- `SessionService.loadSession()` 提取 artifact snapshot。 +- `SessionService.loadSession()` 从 active leaf chain 提取 artifact snapshot/event records,忽略 abandoned branches。 - load result 把 snapshot 交给 daemon bridge,而不是在 ACP child process 中 seed store。 +- restore-prune 写入只能在 daemon-side store 创建并且 writer 可用后执行;load parse 过程保持 read-only。 - replay 历史时同 identity artifact 不重复创建。 ### Milestone D: REST/SDK @@ -743,10 +774,14 @@ V2 新增的失败路径必须有 structured logs,格式沿用: 必须覆盖: - metadata journal append 后 daemon restart/load 恢复 artifact list。 -- V1 live session 升级到 V2 时 backfill snapshot 成功/失败两种路径。 +- artifact journal append 通过 chat recording owner 写入 active leaf chain;daemon-side store 不能直接写 JSONL。 +- `/rewind` 后 abandoned branch 上的 artifact upsert/remove 不参与恢复,也不会在 fork 中复制。 +- V1 live session 升级到 V2 时 backfill snapshot 成功/失败两种路径;backfill 对单条 artifact 执行 validation/minimization,坏记录只影响自身。 - DELETE tombstone 后 load 不复活 artifact。 - unpin 到 `ephemeral` 后 load 不复活 artifact。 -- DELETE、eviction、unpin-to-ephemeral、`restore_pruned` tombstone append 失败时不会先改变 live state。 +- unpin 到 `ephemeral` 后,同一 artifact id 的隐式/default re-upsert 仍保持 live-only;显式 restorable/pin 可以 supersede sticky override。 +- explicit DELETE 在 tombstone append 失败时仍先从 live view 移除,并暴露 `delete_not_durable`;daemon 重启前 tombstone retry 成功后 load 不复活。 +- live cap eviction 不写 tombstone;quota prune、unpin-to-ephemeral、`restore_pruned` tombstone append 失败时不会改变 persisted metadata state。 - workspace artifact ingest 和 restore 时文件存在/缺失/symlink escape 三种状态。 - workspace root 重定位:相同相对路径存在时恢复为 available;缺失或 layout 不一致时恢复为 missing;不做 path remap。 - pin/save content 时拒绝 symlink、special file、oversized stream、hardlink 异常和 TOCTOU swap。 @@ -758,19 +793,20 @@ V2 新增的失败路径必须有 structured logs,格式沿用: - chat recording / persistence disabled 时不声明或不启用 metadata restore。 - pin/save 显式写失败时返回错误。 - tool artifact 持久化失败时降级为 live-only,并通过 `persistenceWarning` 让 client 可见。 -- branch/fork 时 artifact records 的 sessionId/id 处理。 +- branch/fork 时 artifact records 的 sessionId/id 处理,且只使用 active-chain replay result。 - fork 继承 pinned artifact 时降级为 restorable,不继承 contentRef。 - orphan tombstone 在 fork remap 时被丢弃。 - restore seed 与 concurrent POST 串行,不丢写、不重复。 -- quota 边界:200 条、201 条 prune、pinned metadata 处理。 +- quota 边界:200 条、201 条 prune、pinned metadata、clientRetained 和 createdSequence 排序处理。 - GC:tombstoned content 有/无跨 session 引用、TTL 过期、session delete 后引用重建、orphan content grace cleanup。 -- authorization:token-holder/principal 审计路径允许和拒绝情况。 -- V1 live same-principal delete guard 保留、cross-principal delete audit、restored artifact ownership_unverified fallback。 +- authorization:token-holder/principal 审计路径允许和拒绝情况;V1 live same-principal guard 仅作为 live UX/audit hint,不作为 durable security boundary。 +- restored artifact ownership_unverified fallback;deleteContent 在无 durable owner proof 时只释放引用,不立即强删共享 content。 - partial writes:journal 成功但 warning、content manifest 成功但 journal 失败、journal 成功后 restore 能找到 content。 -- JSONL snapshot compaction:threshold 触发、tombstonedIds 保留、post-snapshot replay 有界。 +- JSONL snapshot compaction:threshold 触发、post-snapshot replay 有界、snapshot 之前 tombstones 被 compact、superseded tombstone 允许同 id 重新出现。 - corrupt latest snapshot fallback:回退到较旧 valid snapshot 或一次顺序 artifact replay。 - repin idempotency:已 pinned artifact 的重复 pin 不刷新内容、不延长 TTL。 - retention defaults:tool artifact 无显式 retention、client POST `pinned` 被拒绝。 +- capability:string list 只在行为当前可用时声明;不依赖 `enabled:false` details。 - replay idempotency:同一 session history replay 两次不会重复 artifact。 - SDK 旧 client 忽略 optional fields 后仍能展示 V1 artifacts。 - V2 -> V1 rollback compatibility:旧 daemon 必须能解析或忽略 unknown `system` subtype,不得导致 session load 崩溃;回滚后 artifact persistence 不恢复是可接受降级。如果当前最低支持版本不能保证这一点,V2 writer 必须 capability-gate 到支持 unknown system record 的版本之后。 From f550fcba871628dafa6c9cdadb4f79b57bf2d3d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Sat, 4 Jul 2026 20:14:55 +0800 Subject: [PATCH 08/61] docs: clarify artifact rewind live-state semantics --- .../session-artifacts-persistence-v2-design.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md b/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md index 6a5035faddc..1a4f373420f 100644 --- a/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md +++ b/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md @@ -174,6 +174,7 @@ Artifact persistence records 是 chat transcript 的一部分,必须遵循现 - JSONL append 只能通过拥有 `ChatRecordingService.appendRecord` 的进程或它暴露的明确 RPC 完成。daemon-side `SessionArtifactStore` 可以用 operation queue 协调 live state、SSE 和 persistence request 顺序,但不能自己打开并写 chat JSONL。 - 每条 `session_artifact_event` / `session_artifact_snapshot` 都必须作为普通 system `ChatRecord` 挂到当前 conversation leaf 上,并获得正常的 `uuid` / `parentUuid`。 - session load/replay 只应用 active leaf chain 中的 artifact records。被 `/rewind` 丢到 abandoned branch 的 artifact upsert/remove 不再影响当前 artifact list。 +- `/rewind` 或任何 leaf switch 发生时,daemon-side live `SessionArtifactStore` 必须重新对齐新的 active-chain artifact state:要么从 active-chain replay result reseed,要么在 rewind 操作中向 surviving chain 写一条当前 artifact snapshot top-up。V2 默认采用 branch-scoped 语义;off-branch mutation 不应继续留在 live flat map 中等待下次重启才消失。 - fork/branch 只复制 active chain 中的 artifact records;off-chain records 不参与目标 session 的恢复。 - 如果某个实现阶段还不能把 artifact system records 接到 active leaf chain,就不能声明 `session_artifacts_persistence` capability;否则 rewind 后会出现旧 upsert 或旧 tombstone 复活的问题。 @@ -344,6 +345,8 @@ session load/replay 时: `loadSession()` 必须是 read-only:它不能在解析过程中写 `restore_pruned` tombstone,也不能直接触发 content GC。若 restore 后发现当前 live cap 或 policy 比历史更严格,daemon-side store 在创建完成、persistence writer 可用后,再通过正常 operation queue 写 `restore_pruned` tombstone;writer 不可用时只在 live view 中隐藏超限 item,并记录 warning,下一次 load 仍可能重新看到这些待裁剪记录。 +rewind/replay 中的 live store 处理必须和 load 一致:一旦 active leaf 改变,flat live store 不能继续保留 off-branch artifact mutation。若当前实现没有 active-chain replay result 可直接 reseed,必须在 rewind 完成时写入 artifact snapshot top-up,否则不能启用 persistence capability。 + ### 5.5 恢复时校验 恢复时必须重新校验: @@ -754,6 +757,7 @@ V2 新增的失败路径必须有 structured logs,格式沿用: - `SessionService.loadSession()` 从 active leaf chain 提取 artifact snapshot/event records,忽略 abandoned branches。 - load result 把 snapshot 交给 daemon bridge,而不是在 ACP child process 中 seed store。 - restore-prune 写入只能在 daemon-side store 创建并且 writer 可用后执行;load parse 过程保持 read-only。 +- rewind/leaf switch 后,daemon-side live store 重新对齐 active-chain replay result,或通过 artifact snapshot top-up 固化 surviving chain 的当前状态。 - replay 历史时同 identity artifact 不重复创建。 ### Milestone D: REST/SDK @@ -776,6 +780,7 @@ V2 新增的失败路径必须有 structured logs,格式沿用: - metadata journal append 后 daemon restart/load 恢复 artifact list。 - artifact journal append 通过 chat recording owner 写入 active leaf chain;daemon-side store 不能直接写 JSONL。 - `/rewind` 后 abandoned branch 上的 artifact upsert/remove 不参与恢复,也不会在 fork 中复制。 +- `/rewind` 后 live store 立即与 active-chain artifact state 对齐;不会等到 daemon 重启才改变 artifact list。 - V1 live session 升级到 V2 时 backfill snapshot 成功/失败两种路径;backfill 对单条 artifact 执行 validation/minimization,坏记录只影响自身。 - DELETE tombstone 后 load 不复活 artifact。 - unpin 到 `ephemeral` 后 load 不复活 artifact。 From 95281cd86d6fc85cff44fc3205805a31c648da58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Sat, 4 Jul 2026 21:04:59 +0800 Subject: [PATCH 09/61] docs: address artifact persistence review gaps --- ...session-artifacts-persistence-v2-design.md | 112 +++++++++++++++--- 1 file changed, 96 insertions(+), 16 deletions(-) diff --git a/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md b/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md index 1a4f373420f..cd86c8a4eeb 100644 --- a/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md +++ b/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md @@ -100,7 +100,8 @@ interface DaemonSessionArtifact { | 'metadata_quota_exceeded' | 'validation_downgraded' | 'restore_pruned' - | 'content_unavailable'; + | 'content_unavailable' + | 'delete_not_durable'; message: string; }; contentRef?: { @@ -118,7 +119,7 @@ interface DaemonSessionArtifact { - `persistedAt`:metadata 或 content retention 最近成功落盘时间。 - `expiresAt`:可被 GC 的时间。`pinned` 默认不设置,但 `ttlDays` pin 会转换成绝对 `expiresAt`。 - `restoreState`:恢复来源提示;不替代 `status`。 -- `persistenceWarning`:非阻塞持久化/恢复风险,前端可用它提示“此 artifact 不会跨重启保留”等状态。warning code 必须覆盖实际降级原因,不能把 quota、budget、validation 和内容缺失都压成同一个模糊错误。 +- `persistenceWarning`:非阻塞持久化/恢复风险,前端可用它提示“此 artifact 不会跨重启保留”等状态。warning code 必须覆盖实际降级原因,不能把 quota、budget、validation 和内容缺失都压成同一个模糊错误。`message` 必须来自 path-free 的固定模板或经过脱敏,不能包含 host 绝对路径、credential、token、内部 storage path 或 connection id。 - `contentRef`:仅在内容保留或可信 published storage 时出现,不暴露宿主机绝对路径。 ### 3.2 Status 与 restoreState 的关系 @@ -203,7 +204,8 @@ interface SessionArtifactEventRecordPayload { | 'explicit' | 'quota_pruned' | 'restore_pruned' - | 'unpin_to_ephemeral'; + | 'unpin_to_ephemeral' + | 'ttl_expired'; }>; } @@ -216,20 +218,25 @@ interface SessionArtifactSnapshotRecordPayload { tombstonedIds: string[]; } -interface PersistedSessionArtifact extends DaemonSessionArtifact { +type PersistedSessionArtifact = Omit< + DaemonSessionArtifact, + 'restoreState' | 'persistenceWarning' +> & { clientRetained?: boolean; createdSequence: number; -} +}; ``` `sequence` / `createdSequence` 必须来源于 JSONL record ordering,例如 line/order index 或 ChatRecord append order,不能使用 daemon 进程内会在重启后归零的 counter。读取时以 JSONL 顺序为准;字段仅用于检测异常、生成 snapshot、恢复稳定排序和 quota prune tie-break。 -只写经过 store validation/normalization 后的最小化 artifact shape。除 `clientRetained` 和 `createdSequence` 这两个恢复语义字段外,不写 V1 内部字段: +只写经过 store validation/normalization 后的最小化 artifact shape。除 `clientRetained` 和 `createdSequence` 这两个恢复语义字段外,不写 V1 内部字段或运行时派生字段: - 不写 `identityKey` - 不写 `trustedPublisher` - 不写绝对 `workspaceCwd` - 不写 transport token / auth principal +- 不写 `restoreState` +- 不写 `persistenceWarning` 删除 artifact 或 unpin 到 `ephemeral` 必须写 tombstone change,避免历史 replay 后被旧 upsert 复活。tombstone 不是永久禁止同一 id 再出现:它只覆盖自己之前的 upsert,直到之后出现更高 sequence 的显式 upsert。`reason: "unpin_to_ephemeral"` 是 sticky override:后续同一 artifact id 的隐式/default upsert 仍按 live-only 处理,只有用户或可信 client 显式请求 `retention: "restorable"` 或 pin/save 才能写入 superseding upsert。 @@ -261,6 +268,7 @@ V2 不双写 sidecar,因此没有 JSONL + sidecar 的 metadata 重复存储。 - artifact event journal 达到固定阈值后写 `session_artifact_snapshot`,例如每 100 次 artifact mutation 或每 256 KB artifact journal 写一次。 - artifact persistence records 跟随 chat transcript 生命周期;不做独立文件 GC。 - 每 session 增加 artifact journal working-set byte budget,例如 4 MB。该 budget 衡量恢复必须读取和应用的 artifact 工作集,也就是最新 valid snapshot 加其后的 artifact events;不能把 chat transcript 中已经被 snapshot 覆盖的旧 artifact records 计入 budget,否则 append-only JSONL 会变成不可恢复的一次性上限。 +- writer 必须显式跟踪 working-set bytes:每次写 snapshot 后记录该 snapshot 的 artifact byte size、JSONL append position 或 line index 作为 `postSnapshotBase`,之后每个 artifact event append 增加 `postSnapshotEventBytes`。预算检查使用 `snapshotBytes + postSnapshotEventBytes`,snapshot compaction 成功后重置 counter。若 writer 无法确认 base position 或 counter 状态,必须保守写新 snapshot;仍无法确认时降级或报错,不能无界追加。 - budget 接近上限时先尝试写新 snapshot。若最新 snapshot 加 post-snapshot events 仍超过 budget,则不再写新的 restorable metadata,普通 artifact 降级为 `ephemeral` 并带 `persistenceWarning.code = "journal_budget_exceeded"`;用户显式 pin/save 仍返回明确错误而不是静默降级。 - 不把 content bytes 写进 JSONL;content 只进入 daemon-managed artifact storage。 @@ -275,6 +283,9 @@ V2 不双写 sidecar,因此没有 JSONL + sidecar 的 metadata 重复存储。 - `managedId`:拒绝路径形态、`..`、绝对路径、分隔符。 - `published`:只能由 daemon 内部 trusted publisher 或 manifest-validated path 产生,不能由 client payload 自称。 - `contentRef`:如果存在,必须验证 `kind`/`id` shape,拒绝分隔符、绝对路径和 `..`,并只能通过 daemon-managed manifest 解析。 +- `expiresAt`:daemon-only 字段;client payload 中出现时必须拒绝或 strip。只有 pin/save 的 `ttlDays` 可以由 daemon 转换成 `expiresAt`。 +- `restoreState` / `persistenceWarning`:runtime-only response 字段;client payload 中出现时必须拒绝或 strip,不能写入 persisted artifact。 +- `clientRetained`:只能是 boolean,表示用户保留意图和稳定排序 hint,不是授权信号。只有显式 REST/SDK/UI action 可以设置;后台自动 ingest 不能伪造为用户保留。 - `metadata`:执行 primitive-only、size limit、secret key/value 和 unsafe display payload checks。 验证失败时: @@ -320,7 +331,7 @@ live store eviction 和 persisted metadata prune 必须分开: - V1/当前 live cap eviction:只影响 live view,不写 tombstone,不改变持久化 metadata。 - `quota_pruned` / `restore_pruned` / unpin-to-`ephemeral`:必须 durable-first 或 rollback。tombstone append 失败时保持 persisted state 不变;后台 prune 记录 structured warning 并稍后重试,显式 unpin API 返回错误。 -- 显式 DELETE:必须先从 live store 移除,避免磁盘满或 transcript 写失败时阻止用户隐藏敏感 artifact。随后 best-effort 写 tombstone;如果 append 失败,daemon 在当前进程内保留 pending tombstone 并重试,同时通过 API warning / structured log 表明“本次删除尚未 durable,daemon crash 后可能从历史恢复”。不能把这种状态报告成已持久删除。 +- 显式 DELETE:必须先从 live store 移除,避免磁盘满或 transcript 写失败时阻止用户隐藏敏感 artifact。随后 best-effort 写 tombstone;如果 append 失败,daemon 在当前进程内保留 pending tombstone 并重试,同时通过 API warning / structured log 表明“本次删除尚未 durable,daemon crash 后可能从历史恢复”。不能把这种状态报告成已持久删除。retry 必须有边界:默认指数退避从 1s 开始、上限 60s,总重试窗口 10 分钟或 10 次尝试,以先到者为准;全部失败后停止自动重试,保留 live warning / metric,要求用户在 storage 恢复后重新发起 DELETE。V2 不引入单独的 durable pending-delete queue,因为这会变成第二套持久化 source of truth。 建议 warning: @@ -347,6 +358,8 @@ session load/replay 时: rewind/replay 中的 live store 处理必须和 load 一致:一旦 active leaf 改变,flat live store 不能继续保留 off-branch artifact mutation。若当前实现没有 active-chain replay result 可直接 reseed,必须在 rewind 完成时写入 artifact snapshot top-up,否则不能启用 persistence capability。 +具体集成点必须是显式 hook,而不是靠下一次 GET 懒修复。建议由 rewind/leaf-switch 实现调用 daemon bridge 的 `onActiveLeafChanged(sessionId, artifactSnapshot)`,或在现有 session load/replay result 中携带同等事件;artifact store 收到后在同一 session operation queue 中 reseed 或写 top-up snapshot。 + ### 5.5 恢复时校验 恢复时必须重新校验: @@ -376,6 +389,8 @@ rewind/replay 中的 live store 处理必须和 load 一致:一旦 active leaf fork remap 是发布门槛:如果某条路径不能安全重写 artifact id 和 tombstone,就必须在 fork 时丢弃 artifact persistence records,不能把源 session 的 artifact id 原样带入新 session。若现有 fork 实现有类似 `file_history_snapshot` 的 top-up 机制,artifact 也只能从 active-chain replay result 生成 top-up,不能从 daemon 当前 live store 原样补写,否则会把 rewind 后不再属于历史的 artifact 带入新 session。 +metadata 的 fork amplification 在 V2 中作为有界 trade-off 接受:fork 需要 session mutate 权限,每个 fork 仍受 200 条 persisted metadata 上限,metadata 单条较小,且不会继承 pinned content bytes。V2 不引入 project-level metadata quota;实现必须记录 forked artifact count metric/log,若实际滥用再引入 project-level cap。content retention 仍受 session/project content quota 约束。 + ## 6. API 设计 ### 6.1 Capability @@ -409,7 +424,8 @@ fork remap 是发布门槛:如果某条路径不能安全重写 artifact id "kind": "html", "storage": "workspace", "workspacePath": "reports/run.html", - "retention": "restorable" + "retention": "restorable", + "clientRetained": true } ``` @@ -417,6 +433,7 @@ fork remap 是发布门槛:如果某条路径不能安全重写 artifact id - client 可以请求 `ephemeral` 或 `restorable`。 - client 不能请求 `pinned`。 +- `clientRetained` 可选,仅表示用户保留意图和排序 hint;服务端必须按 §5.1 校验来源,不能把它当授权。 - `pinned` 必须走 pin/save API,因为可能涉及内容复制、配额、确认和失败回滚。 ### 6.3 Pin/save artifact @@ -441,7 +458,8 @@ Body: ```json { "mode": "content", - "ttlDays": 90 + "ttlDays": 90, + "clientRetained": true } ``` @@ -449,8 +467,10 @@ Body: - `mode: "metadata"`:升级为 `restorable`。 - `mode: "content"`:复制或绑定可控内容,升级为 `pinned`。 -- `ttlDays` 由 daemon 在 pin 时转换为绝对 `expiresAt = now + ttlDays`。如果提供,必须是正整数,并受默认最大值 365 天约束;超过上限返回 `INVALID_ARGUMENT`。未提供时才表示无固定过期时间。 -- 成功返回 mutation result,并发布 `artifact_changed` / `updated`。 +- pin 一个仍为 `ephemeral` 的 live artifact 时,必须创建新的 journal upsert event;它不是修改既有 persisted record。 +- pin/save 是显式保留意图,成功后应把 `clientRetained` materialize 为 `true`,除非 request 明确设置为 `false`。 +- `ttlDays` 只允许和 `mode: "content"` 一起使用,由 daemon 在 pin 时转换为绝对 `expiresAt = now + ttlDays`。如果提供,必须是正整数,并受默认最大值 365 天约束;超过上限返回 `INVALID_ARGUMENT`。`mode: "metadata"` 携带 `ttlDays` 返回 `INVALID_ARGUMENT`,不能静默忽略。 +- 成功按 §6.6 返回更新后的 artifact,并发布 `artifact_changed` / `updated`。 - 失败返回明确错误,不改变 artifact retention。 - 对已经 `pinned` 的 artifact,V2 pin API 默认是幂等 no-op:返回当前 artifact,不重新复制内容、不重新计算 hash、不延长 TTL。若未来需要刷新内容或延长 TTL,应设计显式 refresh/extend API;不能让重试请求隐式改变已保存内容或无限延长保留期。 @@ -472,7 +492,7 @@ Body: 语义: - `retention` 可为 `restorable` 或 `ephemeral`,默认 `restorable`。 -- `restorable`:删除 content retention,保留 metadata restore,并写 upsert event。 +- `restorable`:删除 content retention,保留 metadata restore,并写 upsert event。该 upsert payload 必须移除 `contentRef` 和 `expiresAt`,避免恢复时引用已可被 GC 的旧 content。 - `ephemeral`:删除 content retention,live store 中保留 artifact,但写 remove tombstone,确保下次 load 不复活。该 tombstone 对同一 artifact id 是 sticky override:后续隐式/default upsert 仍保持 live-only,直到显式 `retention: "restorable"` 或 pin/save 写入 superseding upsert。 - 若要从列表移除,仍使用 V1 DELETE。 @@ -494,7 +514,36 @@ V2 的 DELETE 仍保持 V1 幂等,但要追加 tombstone: } ``` -`deleteContent` 表示请求立即删除可删除内容,必须经过更严格授权;共享 contentRef 只能在后台引用集合确认没有其它 session 仍引用后删除。 +`deleteContent` 表示请求立即删除可删除内容,授权要求见 §7.1 的 delete content 规则:必须是显式 REST/SDK call、有 session mutate 权限、content retention capability 启用,并满足可验证 creator-principal match 或 admin override。共享 contentRef 只能在后台引用集合确认没有其它 session 仍引用后删除。 + +### 6.6 Mutation responses + +Pin、unpin 和 delete 必须使用一致的 mutation response,避免 client 猜测状态: + +成功: + +- `200 OK`:返回更新后的 `DaemonSessionArtifact`,或 DELETE 后返回 `{ "deleted": true, "artifactId": string, "warnings"?: [...] }`。 +- `202 Accepted`:仅用于 DELETE live view 已移除但 tombstone 尚未 durable 的情况,body 必须包含 `warnings[].code = "delete_not_durable"`。 + +失败: + +```json +{ + "error": { + "code": "INVALID_ARGUMENT", + "message": "ttlDays is only valid with content pinning" + } +} +``` + +建议状态码: + +- `400 INVALID_ARGUMENT`:非法 body、`ttlDays` 与 `mode` 不匹配、client 请求 `pinned`。 +- `403 FORBIDDEN`:缺少 session mutate 权限或 `deleteContent` 授权不足。 +- `404 NOT_FOUND`:artifact 不存在;DELETE 仍可保持幂等成功,但 pin/unpin 应返回 404。 +- `409 CONFLICT`:当前 artifact 状态不能完成请求,例如 content source 已不可用。 +- `429 QUOTA_EXCEEDED`:metadata/content quota 阻止显式 pin/save。 +- `503 PERSISTENCE_UNAVAILABLE`:writer 或 content storage 不可用,显式 pin/save/unpin 不能 durable 完成。 ## 7. 安全设计 @@ -564,6 +613,7 @@ daemon-managed artifact storage 必须有明确 root: - metadata 使用 allowlist 或 secret-key denylist;`token`、`password`、`secret`、`cookie`、`authorization` 等 key/value 必须拒绝、redact,或降级为 `ephemeral`。 - metadata 仍限制 4 KB。 - title/description/metadata 继续执行 unsafe display payload checks。 +- `persistenceWarning.message` 即使只作为 live response 字段,也必须使用 path-free 模板或脱敏文本;不能把 host path、credential、token、content root、connection id 写入 warning。 建议新增设置: @@ -631,7 +681,7 @@ GC 只处理 daemon 管理的 content storage 和过期 metadata cache: - 删除已被 tombstone 且无其它 session 引用的 managed copy。 - 删除超过 `expiresAt` 的 non-pinned content。 -- 删除超过 `expiresAt` 的 pinned content,并把对应 artifact 降级为 `restorable` 或 `missing`。 +- 删除超过 `expiresAt` 的 pinned content,并把对应 artifact 降级为 `restorable` 或 `missing`。该降级必须先写 durable artifact event:`action: "upsert"`、`reason: "ttl_expired"`,payload 移除 `contentRef` 和 `expiresAt`;append 成功后才能更新 live store 并让 content 进入可删除集合。append 失败时保守保留 content。 - session delete 后该 session 的 artifact journal 不再贡献 content 引用;默认删除无其它 session 引用的 content。只有用户显式导出到全局 artifact library 时,才在 session 删除后继续保留。 GC trigger: @@ -654,7 +704,7 @@ GC 必须 best-effort,不阻塞 prompt/tool flow。 - JSONL journal append 失败不会破坏 live store。 - explicit `pin/save metadata` 必须等待 journal 落盘。 - explicit `pin/save content` 必须等待 content manifest 和 journal 都落盘。 -- explicit DELETE 的 live removal 不等待 journal;tombstone append 失败时必须暴露 `delete_not_durable` 并在当前进程内重试。 +- explicit DELETE 的 live removal 不等待 journal;tombstone append 失败时必须暴露 `delete_not_durable` 并按 §5.3 的 bounded retry 在当前进程内重试。 - live cap eviction 不写 tombstone;metadata quota prune 必须 durable-first。 - reader 容忍半截 JSONL 和 corrupt artifact record。 - tombstone / snapshot 顺序异常时选择不恢复,而不是猜测。 @@ -722,10 +772,24 @@ V2 新增的失败路径必须有 structured logs,格式沿用: - `snapshot_invalid` - `tombstone_conflict` - `content_copy_rejected` +- `metadata_fsck_failed` 这些日志不替代 API/SSE 中的 `persistenceWarning`,而是用于生产排障。 -诊断工具是 content retention 发布门槛之一。实现必须提供 CLI 或 daemon-internal API,例如 `qwen artifact fsck`,用于 dry-run 扫描 project/session artifact journals、content manifests 和 daemon-managed storage: +建议 metrics: + +- counter: `artifact_journal_append_total{result,reason}` +- counter: `artifact_restore_total{result,restore_state}` +- gauge: `artifact_pending_tombstone_count` +- gauge: `artifact_metadata_quota_used{session}` +- gauge: `artifact_content_quota_used_bytes{scope}` +- histogram: `artifact_gc_sweep_duration_seconds` +- counter: `artifact_gc_deleted_content_total{reason}` +- counter: `artifact_fsck_findings_total{kind}` + +导出方式沿用 daemon 现有 telemetry/metrics 机制;如果当前没有 Prometheus endpoint,至少要进入 structured telemetry sink,并能按 session/project 聚合。 + +诊断工具分两层。metadata-only `fsck` 是 metadata restore 发布门槛,必须在 content retention 前可用,用于扫描 artifact journal/snapshot/tombstone 与 restore validation failure。full `fsck` 是 content retention 发布门槛,额外扫描 content manifests 和 daemon-managed storage。实现必须提供 CLI 或 daemon-internal API,例如 `qwen artifact fsck`,用于 dry-run 扫描 project/session artifact journals、content manifests 和 daemon-managed storage: - 报告 dangling `contentRef`、manifest 缺失、orphan content、snapshot/tombstone 不一致和 restore validation failure。 - 默认只读;修复模式只能做可验证的安全动作,例如重新生成 snapshot、清除 stale cache marker、标记 orphan content 等待 GC。不能在没有引用集合确认和 grace period 的情况下直接删除内容。 @@ -743,6 +807,7 @@ V2 新增的失败路径必须有 structured logs,格式沿用: - 共享 restore validation、snapshot/tombstone consistency checks 和 persisted shape normalization。 - 扩展 `ChatRecord.subtype` 与 `systemPayload` union。 - 增加 load result 中的 `artifactSnapshot?`。 +- 增加 metadata-only `fsck` / checker,可 dry-run 检测 corrupt artifact records、snapshot/tombstone 不一致和 restore validation failure。 ### Milestone B: daemon-side store 集成 @@ -750,6 +815,7 @@ V2 新增的失败路径必须有 structured logs,格式沿用: - `SessionArtifactStore` 支持 seed artifacts。 - `upsertMany()` 在 operation queue 中计算 effective `retention`、quota prune 和 live view,再通过 writer append durable records。 - `remove()` 区分 explicit DELETE、unpin-to-ephemeral、quota prune 和 live eviction;只有前三者写 tombstone,explicit DELETE 允许 live-first + pending tombstone retry。 +- V1 live session 首次启用 V2 时执行 backfill snapshot;backfill 逐条 validation/minimization/materialization,坏记录跳过或降级,writer 不可用时保持 V1 live-only 并记录 warning。 - 保持 V1 `artifact_changed` event shape 不变,只增加 optional fields。 ### Milestone C: load/replay 集成 @@ -758,12 +824,14 @@ V2 新增的失败路径必须有 structured logs,格式沿用: - load result 把 snapshot 交给 daemon bridge,而不是在 ACP child process 中 seed store。 - restore-prune 写入只能在 daemon-side store 创建并且 writer 可用后执行;load parse 过程保持 read-only。 - rewind/leaf switch 后,daemon-side live store 重新对齐 active-chain replay result,或通过 artifact snapshot top-up 固化 surviving chain 的当前状态。 +- rewind/leaf-switch 必须调用明确 hook,例如 `onActiveLeafChanged(sessionId, artifactSnapshot)`,让 daemon-side store 在 operation queue 中完成 reseed/top-up。 - replay 历史时同 identity artifact 不重复创建。 ### Milestone D: REST/SDK - SDK type 增加 optional fields。 - `POST /session/:id/artifacts` 支持 `retention: "ephemeral" | "restorable"`。 +- `POST /session/:id/artifacts` 支持 `clientRetained` boolean hint,并拒绝 client 传入 daemon-only runtime fields。 - 新增 `pinArtifact()` / `unpinArtifact()` SDK 方法。 - capability gate UI。 @@ -803,7 +871,10 @@ V2 新增的失败路径必须有 structured logs,格式沿用: - orphan tombstone 在 fork remap 时被丢弃。 - restore seed 与 concurrent POST 串行,不丢写、不重复。 - quota 边界:200 条、201 条 prune、pinned metadata、clientRetained 和 createdSequence 排序处理。 +- clientRetained setter:Add artifact request 和 pin/save request 都能设置 boolean hint;后台自动 ingest 不能伪造用户保留。 - GC:tombstoned content 有/无跨 session 引用、TTL 过期、session delete 后引用重建、orphan content grace cleanup。 +- GC concurrency:并发 sweep trigger 被 lease 阻止;持有 lease 的 sweep crash 后 lease 到期可恢复;daemon shutdown 中断 sweep 后下次启动继续保守扫描。 +- TTL 过期导致 pinned 降级时写 `ttl_expired` event,payload 移除 `contentRef` / `expiresAt`,append 失败时不删除 content。 - authorization:token-holder/principal 审计路径允许和拒绝情况;V1 live same-principal guard 仅作为 live UX/audit hint,不作为 durable security boundary。 - restored artifact ownership_unverified fallback;deleteContent 在无 durable owner proof 时只释放引用,不立即强删共享 content。 - partial writes:journal 成功但 warning、content manifest 成功但 journal 失败、journal 成功后 restore 能找到 content。 @@ -816,6 +887,8 @@ V2 新增的失败路径必须有 structured logs,格式沿用: - SDK 旧 client 忽略 optional fields 后仍能展示 V1 artifacts。 - V2 -> V1 rollback compatibility:旧 daemon 必须能解析或忽略 unknown `system` subtype,不得导致 session load 崩溃;回滚后 artifact persistence 不恢复是可接受降级。如果当前最低支持版本不能保证这一点,V2 writer 必须 capability-gate 到支持 unknown system record 的版本之后。 - artifact fsck dry-run:dangling contentRef、orphan content、snapshot invalid 和 repair-safe actions。 +- metadata-only fsck dry-run:corrupt record、snapshot fallback、orphan tombstone、restore validation failure。 +- API response contract:pin/unpin/delete success body、`delete_not_durable` warning、400/403/404/409/429/503 error mapping。 ## 11. 不建议在 V2 做的事 @@ -840,4 +913,11 @@ V2 建议作为一个完整 design phase 发布,但能力按 capability 暴露 - 显式 `pin/save` 才做 content retention。 - 用户文档明确:metadata restore 恢复的是“产物索引”,不是“产物内容备份”;content retention 才是受配额和 GC 约束的内容保存。 +Rollback procedure: + +- V2 records 保留在 chat JSONL 中,不在 rollback 时删除;旧 daemon 能忽略 unknown `system` subtype 时,session load 应继续工作但不恢复 artifact persistence。 +- daemon-managed content storage 不由旧 daemon 读取;rollback 后 pinned content 只是未被引用的 retained bytes。再次升级到 V2 后可恢复引用;若决定永久回滚,管理员运行 full `fsck --cleanup-orphans` dry-run/confirm 流程清理。 +- 如果当前最低支持旧版本不能安全忽略 V2 system records,writer 必须 capability-gate 到安全版本之后,或者在升级前提供 migration guard,阻止写入 V2 records。 +- rollback 后 client 不能依赖 `session_artifacts_persistence` / `session_artifacts_content_retention`,因为旧 daemon 不声明这些 capability。 + 这样可以讲清楚 V2 的完整语义:默认恢复列表,显式保存内容,所有长期存储都受权限、配额、hash 和 GC 约束。 From 95ac88b54e168f241a43575533744c2ea0ba4770 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Sat, 4 Jul 2026 21:47:36 +0800 Subject: [PATCH 10/61] docs: address artifact persistence review follow-up --- ...session-artifacts-persistence-v2-design.md | 117 +++++++++++++++--- 1 file changed, 101 insertions(+), 16 deletions(-) diff --git a/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md b/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md index cd86c8a4eeb..58c07d91c8a 100644 --- a/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md +++ b/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md @@ -42,6 +42,8 @@ V2 后的行为应是: V1 到 V2 的 live upgrade 需要单独处理:已经在内存中的 V1 live artifacts 没有 JSONL journal。V2 首次触达这些 live sessions 时,应通过 chat recording owner 提供的 artifact persistence writer 写入一条初始 `session_artifact_snapshot`,然后再接受新的 restorable/pinned artifact mutation。backfill 不能把 live store 原样序列化;必须对每个 artifact 重新执行 ingest validation、privacy minimization 和 `retention` materialization。单条 artifact 不合格时跳过或降级该条,不能让一次坏记录拖垮整个 backfill。若 writer 不可用或 backfill 整体失败,该 session 继续保持 V1 live-only 行为,并记录 structured warning;不能让用户误以为已有 live artifacts 已经可恢复。 +backfill 不能逐条向 JSONL streaming 写入 artifact event。实现必须先在内存中完成校验、最小化和降级,形成完整 candidate snapshot 后,再一次性 append `session_artifact_snapshot`。如果 candidate 构建或 snapshot append 失败,不能留下部分 durable artifact state。backfill snapshot 应记录 `expectedArtifactCount`,便于 fsck 和 restore warning 区分“完整但有条目被校验跳过”和“部分写入/损坏”。 + ### 2.2 retention 分层 新增 optional field: @@ -101,7 +103,9 @@ interface DaemonSessionArtifact { | 'validation_downgraded' | 'restore_pruned' | 'content_unavailable' - | 'delete_not_durable'; + | 'delete_not_durable' + | 'content_delete_preserved' + | 'sticky_override_active'; message: string; }; contentRef?: { @@ -186,7 +190,9 @@ Artifact persistence records 是 chat transcript 的一部分,必须遵循现 给 `ChatRecord.subtype` 增加: ```ts -'session_artifact_event' | 'session_artifact_snapshot'; +'session_artifact_event' | + 'session_artifact_snapshot' | + 'session_artifact_fork_marker'; ``` Payload: @@ -216,12 +222,41 @@ interface SessionArtifactSnapshotRecordPayload { generatedAt: string; artifacts: PersistedSessionArtifact[]; tombstonedIds: string[]; + stickyEphemeralIds: string[]; + expectedArtifactCount?: number; } -type PersistedSessionArtifact = Omit< +interface SessionArtifactForkMarkerRecordPayload { + v: 2; + sessionId: string; + forkId: string; + phase: 'begin' | 'complete'; + expectedArtifactCount: number; + writtenArtifactCount?: number; +} + +type PersistedSessionArtifact = Pick< DaemonSessionArtifact, - 'restoreState' | 'persistenceWarning' + | 'id' + | 'kind' + | 'storage' + | 'source' + | 'status' + | 'title' + | 'description' + | 'workspacePath' + | 'managedId' + | 'url' + | 'mimeType' + | 'sizeBytes' + | 'metadata' + | 'createdAt' + | 'updatedAt' > & { + retention: ArtifactRetention; + persistedAt: string; + expiresAt?: string; + contentRef?: DaemonSessionArtifact['contentRef']; clientRetained?: boolean; createdSequence: number; }; @@ -229,6 +264,8 @@ type PersistedSessionArtifact = Omit< `sequence` / `createdSequence` 必须来源于 JSONL record ordering,例如 line/order index 或 ChatRecord append order,不能使用 daemon 进程内会在重启后归零的 counter。读取时以 JSONL 顺序为准;字段仅用于检测异常、生成 snapshot、恢复稳定排序和 quota prune tie-break。 +`PersistedSessionArtifact` 必须是正向 allowlist(显式 `Pick` 或独立 interface),不能用 `Omit` 负向排除。未来如果 `DaemonSessionArtifact` 增加新的 runtime-only 字段,编译时断言应要求维护者显式决定是否进入 persisted allowlist,避免 schema 污染。 + 只写经过 store validation/normalization 后的最小化 artifact shape。除 `clientRetained` 和 `createdSequence` 这两个恢复语义字段外,不写 V1 内部字段或运行时派生字段: - 不写 `identityKey` @@ -237,9 +274,12 @@ type PersistedSessionArtifact = Omit< - 不写 transport token / auth principal - 不写 `restoreState` - 不写 `persistenceWarning` +- 不写 `clientId` 或 live-process owner principal;`source` 只作为显示/审计 hint,不能用于授权 删除 artifact 或 unpin 到 `ephemeral` 必须写 tombstone change,避免历史 replay 后被旧 upsert 复活。tombstone 不是永久禁止同一 id 再出现:它只覆盖自己之前的 upsert,直到之后出现更高 sequence 的显式 upsert。`reason: "unpin_to_ephemeral"` 是 sticky override:后续同一 artifact id 的隐式/default upsert 仍按 live-only 处理,只有用户或可信 client 显式请求 `retention: "restorable"` 或 pin/save 才能写入 superseding upsert。 +sticky override 不能只存在于历史 tombstone event 中。snapshot writer 必须把尚未被显式 supersede 的 `unpin_to_ephemeral` 状态写入 `stickyEphemeralIds`;restore reader 先恢复 snapshot 中的 sticky set,再应用 snapshot 之后的 upsert/remove。否则 snapshot baseline advance 后旧 tombstone 不再需要 replay,sticky override 会丢失。 + ### 4.4 Snapshot 与 tombstone 不变量 artifact snapshot 只用于减少 replay 的 artifact event 应用量;它不会减少 JSONL 文件本身的读取量。 @@ -248,12 +288,15 @@ artifact snapshot 只用于减少 replay 的 artifact event 应用量;它不 - snapshot generation 必须在同一个 artifact operation queue 中串行执行,并严格位于所有 preceding mutation 之后。 - snapshot 是 authoritative current state:它只包含 snapshot 生成时仍有效的 artifacts。 -- `tombstonedIds` 只记录 snapshot 之后仍需要覆盖旧 upsert 的 tombstones;被 snapshot 覆盖的旧 tombstones 必须 compaction 掉,避免数组随历史无限增长。 +- `tombstonedIds` 只记录 snapshot 之后仍需要覆盖旧 upsert 的 tombstones;被 snapshot 覆盖的旧 tombstones 不再进入新 snapshot payload,避免数组随历史无限增长。 +- `stickyEphemeralIds` 记录当前仍处于 sticky ephemeral override 的 artifact id,即使对应旧 tombstone 已经不需要 replay,也必须保留该 override 状态。 - snapshot 可以包含曾经被 tombstone 的 artifact id,前提是该 tombstone 已被更高 sequence 的显式 upsert supersede。 - load 时从新到旧选择最新 valid snapshot,然后只应用该 snapshot 之后的 artifact events。 - 如果最新 snapshot 解析失败,记录 `snapshot_invalid` warning,继续尝试上一个 valid snapshot;不能因为一个 corrupt snapshot 丢失整个 session 的 artifact metadata。 - 如果没有任何 valid snapshot,允许对 active JSONL leaf chain 做一次顺序 artifact event replay。isolated corrupt artifact record 应跳过并记录 warning;只有 branch ordering、record envelope 或 tombstone 状态已经无法建立可信顺序时,才丢弃该 session 的 artifact persistence records。 +这里的 snapshot baseline advance 不会重写或删除 JSONL 里的旧 record。旧 `session_artifact_snapshot`、event 和 tombstone 仍保留在 append-only chat transcript 中;artifact 子系统只是在最新 snapshot payload 内前移恢复基线并重置工作集计数。 + ### 4.5 存储消耗 V2 不双写 sidecar,因此没有 JSONL + sidecar 的 metadata 重复存储。存储消耗分为 metadata journal 和 content retention: @@ -268,7 +311,7 @@ V2 不双写 sidecar,因此没有 JSONL + sidecar 的 metadata 重复存储。 - artifact event journal 达到固定阈值后写 `session_artifact_snapshot`,例如每 100 次 artifact mutation 或每 256 KB artifact journal 写一次。 - artifact persistence records 跟随 chat transcript 生命周期;不做独立文件 GC。 - 每 session 增加 artifact journal working-set byte budget,例如 4 MB。该 budget 衡量恢复必须读取和应用的 artifact 工作集,也就是最新 valid snapshot 加其后的 artifact events;不能把 chat transcript 中已经被 snapshot 覆盖的旧 artifact records 计入 budget,否则 append-only JSONL 会变成不可恢复的一次性上限。 -- writer 必须显式跟踪 working-set bytes:每次写 snapshot 后记录该 snapshot 的 artifact byte size、JSONL append position 或 line index 作为 `postSnapshotBase`,之后每个 artifact event append 增加 `postSnapshotEventBytes`。预算检查使用 `snapshotBytes + postSnapshotEventBytes`,snapshot compaction 成功后重置 counter。若 writer 无法确认 base position 或 counter 状态,必须保守写新 snapshot;仍无法确认时降级或报错,不能无界追加。 +- writer 必须显式跟踪 working-set bytes:每次写 snapshot 后记录该 snapshot 的 artifact byte size、JSONL append position 或 line index 作为 `postSnapshotBase`,之后每个 artifact event append 增加 `postSnapshotEventBytes`。预算检查使用 `snapshotBytes + postSnapshotEventBytes`,snapshot baseline advance 成功后重置 counter。若 writer 无法确认 base position 或 counter 状态,必须保守写新 snapshot;仍无法确认时降级或报错,不能无界追加。 - budget 接近上限时先尝试写新 snapshot。若最新 snapshot 加 post-snapshot events 仍超过 budget,则不再写新的 restorable metadata,普通 artifact 降级为 `ephemeral` 并带 `persistenceWarning.code = "journal_budget_exceeded"`;用户显式 pin/save 仍返回明确错误而不是静默降级。 - 不把 content bytes 写进 JSONL;content 只进入 daemon-managed artifact storage。 @@ -315,6 +358,8 @@ ingest input `SessionArtifactStore` 的 operation queue 负责串行化同一 session 的 live mutation、persistence request 和 SSE 顺序;真正的 JSONL append 仍由 chat recording owner 完成。普通 tool/hook artifact 如果 persistence writer 不可用,可以降级为 live-only `ephemeral` 后进入 live store;显式 `pin/save` 不能降级,必须返回错误。 +如果 sticky ephemeral override 抑制了隐式/default upsert 的持久化,live artifact 必须带 `persistenceWarning.code = "sticky_override_active"`,并记录 structured log `action=sticky_override_suppressed` 和 counter metric。否则排障时会看到合法 upsert input 却找不到对应 durable record。 + live store eviction 和 persisted metadata prune 必须分开: - live store 上限只影响当前内存 view,不写 durable tombstone。V2 live eviction 必须 retention-aware,优先丢弃 ephemeral/unretained item;不能因为 V1 live cap 就让 `restorable` / `pinned` metadata 永久丢失。 @@ -332,12 +377,15 @@ live store eviction 和 persisted metadata prune 必须分开: - V1/当前 live cap eviction:只影响 live view,不写 tombstone,不改变持久化 metadata。 - `quota_pruned` / `restore_pruned` / unpin-to-`ephemeral`:必须 durable-first 或 rollback。tombstone append 失败时保持 persisted state 不变;后台 prune 记录 structured warning 并稍后重试,显式 unpin API 返回错误。 - 显式 DELETE:必须先从 live store 移除,避免磁盘满或 transcript 写失败时阻止用户隐藏敏感 artifact。随后 best-effort 写 tombstone;如果 append 失败,daemon 在当前进程内保留 pending tombstone 并重试,同时通过 API warning / structured log 表明“本次删除尚未 durable,daemon crash 后可能从历史恢复”。不能把这种状态报告成已持久删除。retry 必须有边界:默认指数退避从 1s 开始、上限 60s,总重试窗口 10 分钟或 10 次尝试,以先到者为准;全部失败后停止自动重试,保留 live warning / metric,要求用户在 storage 恢复后重新发起 DELETE。V2 不引入单独的 durable pending-delete queue,因为这会变成第二套持久化 source of truth。 +- 显式 DELETE 携带 `deleteContent: true` 时,content 删除必须等待 tombstone durable 后才能执行。live view 仍可先移除,但如果 tombstone append 失败,daemon 不得删除 managed/pinned content,response 必须同时暴露 `delete_not_durable` 和 `content_delete_preserved` warning。tombstone durable 后,content 删除仍需通过引用集合确认和 §7.1 授权;共享 contentRef 只能释放当前 artifact 引用,不能强删。 建议 warning: ```text [artifacts] session= action=persist_failed artifact= reason= [artifacts] session= action=delete_not_durable artifact= +[artifacts] session= action=content_delete_preserved artifact= reason=tombstone_not_durable +[artifacts] session= action=sticky_override_suppressed artifact= prior_reason=unpin_to_ephemeral ``` ### 5.4 恢复流程 @@ -383,12 +431,14 @@ rewind/replay 中的 live store 处理必须和 load 一致:一旦 active leaf - 同一个资源在新 session 中应得到新 artifact id,因为 V1 identity 包含 `sessionId`。 - fork 写入目标 session 时,应根据目标 `sessionId + locator` 重新计算 artifact id。 -- tombstone 也要按目标 session 的新 id 重写。若源 tombstone 找不到目标 active chain 中对应 upsert,丢弃该 orphan tombstone。 +- tombstone 也要按目标 session 的新 id 重写。只要 tombstone 的 artifact id 可以安全 remap,就应保留到目标 session,即使目标 active chain 中暂时找不到对应 upsert;orphan tombstone 没有匹配 upsert 时是无害的,但丢弃它可能让后续同 id upsert 丢失 suppression。 - `forkedFrom` 可以记录原 session id / 原 artifact id,作为审计信息,但不能参与新 session 的权限判断。 - fork 继承 artifact metadata 时,`pinned` 必须降级为 `restorable`。fork 不继承 pinned contentRef,用户需要在 forked session 中显式 re-pin,避免通过 fork 绕过 session/project quota。 fork remap 是发布门槛:如果某条路径不能安全重写 artifact id 和 tombstone,就必须在 fork 时丢弃 artifact persistence records,不能把源 session 的 artifact id 原样带入新 session。若现有 fork 实现有类似 `file_history_snapshot` 的 top-up 机制,artifact 也只能从 active-chain replay result 生成 top-up,不能从 daemon 当前 live store 原样补写,否则会把 rewind 后不再属于历史的 artifact 带入新 session。 +fork 写入目标 session 必须可检测部分失败。remap 开始前向目标 active chain 写 `session_artifact_fork_marker`,`phase: "begin"`,并带 `expectedArtifactCount`;所有 remapped artifact records 写完后再写 `phase: "complete"` 和 `writtenArtifactCount`。session load 如果看到 begin 但没有 complete,或 count 不匹配,必须记录 `fork_incomplete`,并把本次 fork 写入的 artifacts 标记为 `restoreState: "unverified"` 或丢弃该 fork batch;不能把部分 fork 当完整恢复结果。 + metadata 的 fork amplification 在 V2 中作为有界 trade-off 接受:fork 需要 session mutate 权限,每个 fork 仍受 200 条 persisted metadata 上限,metadata 单条较小,且不会继承 pinned content bytes。V2 不引入 project-level metadata quota;实现必须记录 forked artifact count metric/log,若实际滥用再引入 project-level cap。content retention 仍受 session/project content quota 约束。 ## 6. API 设计 @@ -516,14 +566,18 @@ V2 的 DELETE 仍保持 V1 幂等,但要追加 tombstone: `deleteContent` 表示请求立即删除可删除内容,授权要求见 §7.1 的 delete content 规则:必须是显式 REST/SDK call、有 session mutate 权限、content retention capability 启用,并满足可验证 creator-principal match 或 admin override。共享 contentRef 只能在后台引用集合确认没有其它 session 仍引用后删除。 +`deleteContent: true` 不改变默认 DELETE 的 live-first UX,但会改变不可逆 content 删除的顺序:content bytes 只有在 tombstone durable 且引用集合确认无其它 session 仍引用后才能删除。如果 tombstone 未 durable,API 只能报告当前 live view 已删除、content preserved,不能报告 content delete 成功。 + ### 6.6 Mutation responses Pin、unpin 和 delete 必须使用一致的 mutation response,避免 client 猜测状态: 成功: -- `200 OK`:返回更新后的 `DaemonSessionArtifact`,或 DELETE 后返回 `{ "deleted": true, "artifactId": string, "warnings"?: [...] }`。 -- `202 Accepted`:仅用于 DELETE live view 已移除但 tombstone 尚未 durable 的情况,body 必须包含 `warnings[].code = "delete_not_durable"`。 +- Pin:`200 OK` 返回更新后的 `DaemonSessionArtifact`。 +- Unpin:`200 OK` 返回更新后的 `DaemonSessionArtifact`;`retention: "restorable"` 时 `contentRef`/`expiresAt` 已移除,`retention: "ephemeral"` 时 response 可以返回当前 live-only artifact,并带 `persistenceWarning.code = "sticky_override_active"`。 +- DELETE:`200 OK` 返回 `{ "deleted": true, "artifactId": string, "warnings"?: [...] }`。 +- `202 Accepted`:仅用于 DELETE live view 已移除但 tombstone 尚未 durable 的情况,body 必须包含 `warnings[].code = "delete_not_durable"`。如果 request 包含 `deleteContent: true`,还必须包含 `warnings[].code = "content_delete_preserved"`,说明 content 未删除且仍等待 durable tombstone / GC 引用确认。 失败: @@ -542,7 +596,8 @@ Pin、unpin 和 delete 必须使用一致的 mutation response,避免 client - `403 FORBIDDEN`:缺少 session mutate 权限或 `deleteContent` 授权不足。 - `404 NOT_FOUND`:artifact 不存在;DELETE 仍可保持幂等成功,但 pin/unpin 应返回 404。 - `409 CONFLICT`:当前 artifact 状态不能完成请求,例如 content source 已不可用。 -- `429 QUOTA_EXCEEDED`:metadata/content quota 阻止显式 pin/save。 +- `429 METADATA_QUOTA_EXCEEDED`:metadata slots 已满且没有可裁剪 candidate,阻止显式 restorable/pin/save。 +- `429 QUOTA_EXCEEDED`:content quota 阻止显式 `mode: "content"` pin/save。 - `503 PERSISTENCE_UNAVAILABLE`:writer 或 content storage 不可用,显式 pin/save/unpin 不能 durable 完成。 ## 7. 安全设计 @@ -623,16 +678,27 @@ daemon-managed artifact storage 必须有明确 root: "persistence": { "enabled": true, "defaultRetention": "restorable", + "maxLiveArtifacts": 200, + "maxPersistedMetadata": 200, + "journalBudgetBytes": 4194304, + "snapshotThresholdMutations": 100, + "snapshotThresholdBytes": 262144, "contentRetention": { "enabled": false, "maxArtifactBytes": 52428800, - "maxTotalBytes": 1073741824 + "maxTotalBytes": 1073741824, + "maxTtlDays": 365, + "ttlScanIntervalSeconds": 900, + "gcSweepIntervalSeconds": 3600, + "gcGracePeriodSeconds": 3600 } } } } ``` +这些字段是 operator tunables;如果实现选择硬编码某些值,也必须在配置 schema/文档中明确标注为不可配置常量。文档其它 section 引用的默认值应统一来自这里,避免实现方猜测哪些值可以调整。 + ## 8. 配额、GC 与稳定性 ### 8.1 Metadata quota @@ -652,7 +718,7 @@ live store 上限是内存展示约束,不是 durable deletion policy: 超过 persisted metadata 上限: - `ephemeral` 本来不写 journal,不计入 persisted metadata quota,只受 live store 上限约束。 -- `restorable` 必须按确定性顺序裁剪并写 `quota_pruned` tombstone:先裁剪未 `clientRetained` 的 `restorable` artifact;同级内按 `persistedAt` 升序,缺失时按 `createdSequence` 升序,最后按 `artifactId` 字典序打破平局。 +- `restorable` 必须按确定性顺序裁剪并写 `quota_pruned` tombstone:先裁剪未 `clientRetained` 的 `restorable` artifact;如果仍无空间,再裁剪 `clientRetained` 的 `restorable` artifact。每一层内按 `persistedAt` 升序,缺失时按 `createdSequence` 升序,最后按 `artifactId` 字典序打破平局。`clientRetained` 是排序保护,不是绝对保护;用户需要真正保护内容时应使用 pin/save。 - `pinned` metadata 不因 metadata quota 被裁剪,但 content retention 仍受 content quota 约束。 - 如果 200 个 persisted slots 全部被 `pinned` 占用,没有可裁剪的 `restorable` candidate: - 自动/tool artifact 降级为 live-only `ephemeral`,带 `persistenceWarning.code = "metadata_quota_exceeded"`。 @@ -689,9 +755,12 @@ GC trigger: - daemon startup 后延迟触发一次 project-scoped sweep。 - session delete、artifact delete、unpin、TTL 到期检查或 content quota pressure 后 enqueue sweep。 - 周期性 timer 可作为兜底,例如每小时一次;实现必须用单实例 lease/lock 避免并发 sweep。 +- TTL 到期检测必须有独立、可观测路径:session load 后对该 session 做一次 lightweight TTL scan;后台 timer 默认每 15 分钟扫描一次 pinned `expiresAt` index;project GC sweep 作为兜底。每次 TTL scan 即使没有过期项也要记录 `ttl_scan_checked`,并导出 expired pending bytes,避免配额耗尽时根因不可见。 V2 不把 mutable reference-count table 当 source of truth。content reference set 应从 project 内 session artifact journals 的最新 valid snapshot/events 派生;content manifest 只保存 content metadata 和可选的 lastKnownReferenceCount/cache。GC sweep 在后台按 project 扫描并重建引用集合,crash 后下一次 sweep 重新计算。引用未知或扫描失败时必须保守保留 content,不能删除。 +每次 GC sweep 必须跟踪 per-session scan status,例如 `{ sessionId, status: 'scanned' | 'corrupt' | 'timeout', lastScannedAt }`。只要任一相关 session 不是 `scanned`,GC 就不能删除可能只由未扫描 session 引用的 content;这些 content 必须保守保留到下一次完整扫描。corrupt journal 不能通过“跳过该 session”变成静默数据丢失。记录 action `gc_incomplete_scan`,并导出 `artifact_gc_sessions_unscanned`。 + orphan content 删除必须有 grace period。默认 grace period 为 1 小时,从 GC sweep 首次确认某个 contentRef 没有任何 journal 引用并在 manifest/cache 中写入 `orphanedAt` 开始计算;下一次 sweep 只有在仍无引用且已超过 grace period 时才删除。新的 journal 引用会清除 `orphanedAt`。该值可以配置,但不能短于一次正常 pin/save 操作和 journal flush 的最长预期窗口。 GC 必须 best-effort,不阻塞 prompt/tool flow。 @@ -705,6 +774,7 @@ GC 必须 best-effort,不阻塞 prompt/tool flow。 - explicit `pin/save metadata` 必须等待 journal 落盘。 - explicit `pin/save content` 必须等待 content manifest 和 journal 都落盘。 - explicit DELETE 的 live removal 不等待 journal;tombstone append 失败时必须暴露 `delete_not_durable` 并按 §5.3 的 bounded retry 在当前进程内重试。 +- explicit DELETE with `deleteContent: true` 的 content 删除必须在 tombstone durable 之后执行;tombstone append 失败时 content 必须保留,并暴露 `content_delete_preserved`。 - live cap eviction 不写 tombstone;metadata quota prune 必须 durable-first。 - reader 容忍半截 JSONL 和 corrupt artifact record。 - tombstone / snapshot 顺序异常时选择不恢复,而不是猜测。 @@ -767,12 +837,17 @@ V2 新增的失败路径必须有 structured logs,格式沿用: - `delete_not_durable` - `quota_pruned` - `fork_artifact_discarded` +- `fork_incomplete` - `gc_content_deleted` +- `gc_incomplete_scan` - `gc_reference_rebuilt` - `snapshot_invalid` +- `sticky_override_suppressed` +- `ttl_scan_checked` - `tombstone_conflict` - `content_copy_rejected` - `metadata_fsck_failed` +- `v2_writer_version_gate_failed` 这些日志不替代 API/SSE 中的 `persistenceWarning`,而是用于生产排障。 @@ -783,6 +858,9 @@ V2 新增的失败路径必须有 structured logs,格式沿用: - gauge: `artifact_pending_tombstone_count` - gauge: `artifact_metadata_quota_used{session}` - gauge: `artifact_content_quota_used_bytes{scope}` +- gauge: `artifact_gc_sessions_unscanned` +- gauge: `artifact_content_expired_pending_bytes` +- counter: `artifact_sticky_override_suppressed_total` - histogram: `artifact_gc_sweep_duration_seconds` - counter: `artifact_gc_deleted_content_total{reason}` - counter: `artifact_fsck_findings_total{kind}` @@ -853,7 +931,9 @@ V2 新增的失败路径必须有 structured logs,格式沿用: - DELETE tombstone 后 load 不复活 artifact。 - unpin 到 `ephemeral` 后 load 不复活 artifact。 - unpin 到 `ephemeral` 后,同一 artifact id 的隐式/default re-upsert 仍保持 live-only;显式 restorable/pin 可以 supersede sticky override。 +- snapshot baseline advance 后 `stickyEphemeralIds` 仍能让隐式/default re-upsert 保持 live-only,并产生 `sticky_override_suppressed` log/metric/warning。 - explicit DELETE 在 tombstone append 失败时仍先从 live view 移除,并暴露 `delete_not_durable`;daemon 重启前 tombstone retry 成功后 load 不复活。 +- `deleteContent: true` 在 tombstone append 失败时不删除 content,response 同时包含 `delete_not_durable` 和 `content_delete_preserved`。 - live cap eviction 不写 tombstone;quota prune、unpin-to-ephemeral、`restore_pruned` tombstone append 失败时不会改变 persisted metadata state。 - workspace artifact ingest 和 restore 时文件存在/缺失/symlink escape 三种状态。 - workspace root 重定位:相同相对路径存在时恢复为 available;缺失或 layout 不一致时恢复为 missing;不做 path remap。 @@ -867,18 +947,21 @@ V2 新增的失败路径必须有 structured logs,格式沿用: - pin/save 显式写失败时返回错误。 - tool artifact 持久化失败时降级为 live-only,并通过 `persistenceWarning` 让 client 可见。 - branch/fork 时 artifact records 的 sessionId/id 处理,且只使用 active-chain replay result。 +- fork begin/complete marker:partial fork crash、count mismatch 和 successful fork 三种路径。 - fork 继承 pinned artifact 时降级为 restorable,不继承 contentRef。 -- orphan tombstone 在 fork remap 时被丢弃。 +- orphan tombstone 在 fork remap 时被保留并安全 remap;无法安全 remap 的 tombstone 才丢弃。 - restore seed 与 concurrent POST 串行,不丢写、不重复。 -- quota 边界:200 条、201 条 prune、pinned metadata、clientRetained 和 createdSequence 排序处理。 +- quota 边界:200 条、201 条 prune、pinned metadata、clientRetained/non-clientRetained 两层排序、全部 clientRetained restorable 仍可按确定性规则裁剪。 - clientRetained setter:Add artifact request 和 pin/save request 都能设置 boolean hint;后台自动 ingest 不能伪造用户保留。 - GC:tombstoned content 有/无跨 session 引用、TTL 过期、session delete 后引用重建、orphan content grace cleanup。 +- GC incomplete scan:某 session journal corrupt/timeout 时不删除可能仅由该 session 引用的 content,并记录 `gc_incomplete_scan`。 - GC concurrency:并发 sweep trigger 被 lease 阻止;持有 lease 的 sweep crash 后 lease 到期可恢复;daemon shutdown 中断 sweep 后下次启动继续保守扫描。 +- TTL scan:session load 和周期 timer 都能发现过期 pinned content,记录 `ttl_scan_checked` 和 `artifact_content_expired_pending_bytes`。 - TTL 过期导致 pinned 降级时写 `ttl_expired` event,payload 移除 `contentRef` / `expiresAt`,append 失败时不删除 content。 - authorization:token-holder/principal 审计路径允许和拒绝情况;V1 live same-principal guard 仅作为 live UX/audit hint,不作为 durable security boundary。 - restored artifact ownership_unverified fallback;deleteContent 在无 durable owner proof 时只释放引用,不立即强删共享 content。 - partial writes:journal 成功但 warning、content manifest 成功但 journal 失败、journal 成功后 restore 能找到 content。 -- JSONL snapshot compaction:threshold 触发、post-snapshot replay 有界、snapshot 之前 tombstones 被 compact、superseded tombstone 允许同 id 重新出现。 +- JSONL snapshot baseline advance:threshold 触发、post-snapshot replay 有界、snapshot payload 不再携带已被覆盖的 tombstones、superseded tombstone 允许同 id 重新出现、`stickyEphemeralIds` 保留 sticky state;JSONL 文件本身不被 artifact 子系统重写。 - corrupt latest snapshot fallback:回退到较旧 valid snapshot 或一次顺序 artifact replay。 - repin idempotency:已 pinned artifact 的重复 pin 不刷新内容、不延长 TTL。 - retention defaults:tool artifact 无显式 retention、client POST `pinned` 被拒绝。 @@ -886,9 +969,10 @@ V2 新增的失败路径必须有 structured logs,格式沿用: - replay idempotency:同一 session history replay 两次不会重复 artifact。 - SDK 旧 client 忽略 optional fields 后仍能展示 V1 artifacts。 - V2 -> V1 rollback compatibility:旧 daemon 必须能解析或忽略 unknown `system` subtype,不得导致 session load 崩溃;回滚后 artifact persistence 不恢复是可接受降级。如果当前最低支持版本不能保证这一点,V2 writer 必须 capability-gate 到支持 unknown system record 的版本之后。 +- rollback preflight:最低支持旧 daemon 版本加载包含 V2 event/snapshot/fork marker 的 JSONL,writer version gate 失败时拒绝写入并记录 `v2_writer_version_gate_failed`。 - artifact fsck dry-run:dangling contentRef、orphan content、snapshot invalid 和 repair-safe actions。 - metadata-only fsck dry-run:corrupt record、snapshot fallback、orphan tombstone、restore validation failure。 -- API response contract:pin/unpin/delete success body、`delete_not_durable` warning、400/403/404/409/429/503 error mapping。 +- API response contract:pin/unpin/delete success body、`METADATA_QUOTA_EXCEEDED` 与 `QUOTA_EXCEEDED` error code、`delete_not_durable` / `content_delete_preserved` warning、400/403/404/409/429/503 error mapping。 ## 11. 不建议在 V2 做的事 @@ -918,6 +1002,7 @@ Rollback procedure: - V2 records 保留在 chat JSONL 中,不在 rollback 时删除;旧 daemon 能忽略 unknown `system` subtype 时,session load 应继续工作但不恢复 artifact persistence。 - daemon-managed content storage 不由旧 daemon 读取;rollback 后 pinned content 只是未被引用的 retained bytes。再次升级到 V2 后可恢复引用;若决定永久回滚,管理员运行 full `fsck --cleanup-orphans` dry-run/confirm 流程清理。 - 如果当前最低支持旧版本不能安全忽略 V2 system records,writer 必须 capability-gate 到安全版本之后,或者在升级前提供 migration guard,阻止写入 V2 records。 +- 发布前 CI 必须用最低支持旧 daemon 版本加载包含 `session_artifact_event`、`session_artifact_snapshot` 和 `session_artifact_fork_marker` 的 JSONL,断言 session load 成功且 unknown subtype 被忽略。V2 writer 首次初始化前也要检查版本/feature gate;失败时拒绝写 V2 records,记录 `v2_writer_version_gate_failed`,保持 V1 行为。 - rollback 后 client 不能依赖 `session_artifacts_persistence` / `session_artifacts_content_retention`,因为旧 daemon 不声明这些 capability。 这样可以讲清楚 V2 的完整语义:默认恢复列表,显式保存内容,所有长期存储都受权限、配额、hash 和 GC 约束。 From 9cba32b064514f3206e0c19f54557058b7965620 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Sat, 4 Jul 2026 23:12:48 +0800 Subject: [PATCH 11/61] feat(daemon): persist session artifacts --- packages/acp-bridge/src/bridge.ts | 121 +++- packages/acp-bridge/src/bridgeTypes.ts | 20 + .../src/sessionArtifactContentStore.ts | 315 +++++++++ .../acp-bridge/src/sessionArtifacts.test.ts | 360 ++++++++++ packages/acp-bridge/src/sessionArtifacts.ts | 665 ++++++++++++++++-- packages/acp-bridge/src/status.ts | 1 + packages/cli/src/acp-integration/acpAgent.ts | 48 +- packages/cli/src/serve/acp-http/dispatch.ts | 69 ++ packages/cli/src/serve/acp-http/index.ts | 1 + .../cli/src/serve/acp-http/transport.test.ts | 174 +++++ packages/cli/src/serve/capabilities.ts | 2 + packages/cli/src/serve/routes/session.ts | 111 +++ packages/cli/src/serve/server.test.ts | 170 ++++- packages/core/src/index.ts | 1 + .../core/src/services/chatRecordingService.ts | 61 +- .../session-artifact-persistence.test.ts | 172 +++++ .../services/session-artifact-persistence.ts | 594 ++++++++++++++++ packages/core/src/services/sessionService.ts | 51 +- packages/sdk-typescript/scripts/build.js | 2 +- .../sdk-typescript/src/daemon/DaemonClient.ts | 57 ++ .../src/daemon/DaemonSessionClient.ts | 33 + .../src/daemon/acpRouteTable.ts | 42 ++ packages/sdk-typescript/src/daemon/types.ts | 61 +- .../test/unit/DaemonClient.test.ts | 68 ++ .../test/unit/DaemonSessionClient.test.ts | 47 ++ .../test/unit/acpRouteTable.test.ts | 36 + 26 files changed, 3207 insertions(+), 75 deletions(-) create mode 100644 packages/acp-bridge/src/sessionArtifactContentStore.ts create mode 100644 packages/core/src/services/session-artifact-persistence.test.ts create mode 100644 packages/core/src/services/session-artifact-persistence.ts diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index 2c9d2121ee1..68a64fd1ac3 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -16,7 +16,10 @@ import type { SetSessionModelRequest, SetSessionModelResponse, } from '@agentclientprotocol/sdk'; -import type { ApprovalMode } from '@qwen-code/qwen-code-core'; +import type { + ApprovalMode, + RebuiltSessionArtifactSnapshot, +} from '@qwen-code/qwen-code-core'; import { DAEMON_TRACEPARENT_META_KEY, DAEMON_TRACESTATE_META_KEY, @@ -106,6 +109,7 @@ import { type SessionArtifactInput, type SessionArtifactMutationResult, } from './sessionArtifacts.js'; +import { SessionArtifactContentStore } from './sessionArtifactContentStore.js'; const NOOP_BRIDGE_TELEMETRY: BridgeTelemetry = { captureContext: () => undefined, @@ -1163,6 +1167,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { // daemon. Cleared in the `finally` of the creator. let inFlightChannelSpawn: Promise | undefined; const byId = new Map(); + const artifactContentStore = new SessionArtifactContentStore(); const toSessionSummary = (entry: SessionEntry): BridgeSessionSummary => ({ sessionId: entry.sessionId, workspaceCwd: entry.workspaceCwd, @@ -2265,7 +2270,11 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { channel: ci.channel, connection: ci.connection, events, - artifacts: new SessionArtifactStore({ sessionId, workspaceCwd }), + artifacts: new SessionArtifactStore({ + sessionId, + workspaceCwd, + persistence: createSessionArtifactPersistence(ci.connection, sessionId), + }), promptQueue: Promise.resolve(), pendingPromptCount: 0, pendingPromptList: [], @@ -2322,6 +2331,8 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { mimeType: artifact.mimeType, sizeBytes: artifact.sizeBytes, metadata: artifact.metadata, + retention: artifact.retention, + clientRetained: artifact.clientRetained, source: 'client', }; if (clientId) { @@ -2330,6 +2341,42 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { return input; }; + function createSessionArtifactPersistence( + connection: ClientSideConnection, + sessionId: string, + ) { + return { + recordEvent: async (payload: unknown): Promise => { + await withTimeout( + connection.extMethod( + SERVE_CONTROL_EXT_METHODS.sessionArtifactsPersist, + { + sessionId, + kind: 'event', + payload, + }, + ), + PERSIST_TIMEOUT_MS, + 'sessionArtifactPersist(event)', + ); + }, + recordSnapshot: async (payload: unknown): Promise => { + await withTimeout( + connection.extMethod( + SERVE_CONTROL_EXT_METHODS.sessionArtifactsPersist, + { + sessionId, + kind: 'snapshot', + payload, + }, + ), + PERSIST_TIMEOUT_MS, + 'sessionArtifactPersist(snapshot)', + ); + }, + }; + } + // A5: seed the snapshot caches from the agent's session-create response // (`newSession` / `loadSession` / `resumeSession` all return `models` + // `modes`). Without this the caches stay unset until the first change, so a @@ -2416,6 +2463,23 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { return { lastEventId: snapshot.lastEventId }; }; + const restoredArtifactSnapshotFromState = ( + state: BridgeSessionState, + ): RebuiltSessionArtifactSnapshot | undefined => { + const candidate = (state as { artifactSnapshot?: unknown }) + .artifactSnapshot; + if ( + candidate && + typeof candidate === 'object' && + !Array.isArray(candidate) && + (candidate as { v?: unknown }).v === 2 && + Array.isArray((candidate as { artifacts?: unknown }).artifacts) + ) { + return candidate as RebuiltSessionArtifactSnapshot; + } + return undefined; + }; + async function restoreSession( action: 'load' | 'resume', req: BridgeRestoreSessionRequest, @@ -2629,6 +2693,16 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { ); entry.restoreState = state; seedSnapshotCaches(entry, state); + const artifactRestoreWarnings = await entry.artifacts.restore( + restoredArtifactSnapshotFromState(state), + ); + for (const warning of artifactRestoreWarnings) { + writeStderrLine( + `[artifacts] session=${entry.sessionId} action=restore_warning warning=${JSON.stringify( + warning, + )}`, + ); + } const clientId = registerClient(entry, req.clientId); // Fold synchronous coalesce reservations into the new entry's // `attachCount`. By this point all coalescers that beat us must @@ -4038,6 +4112,49 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { return result; }, + async pinSessionArtifact(sessionId, artifactId, context) { + const entry = byId.get(sessionId); + if (!entry) throw new SessionNotFoundError(sessionId); + const clientId = resolveTrustedClientId(entry, context?.clientId); + const artifact = await entry.artifacts.get(artifactId); + if (!artifact) { + return { v: 1, sessionId, changes: [] }; + } + const contentRef = await artifactContentStore.pinWorkspaceFile( + sessionId, + artifact, + entry.workspaceCwd, + ); + const result = await entry.artifacts.pin(artifactId, contentRef); + publishArtifactChanges(entry, result.changes, clientId); + return result; + }, + + async unpinSessionArtifact(sessionId, artifactId, context) { + const entry = byId.get(sessionId); + if (!entry) throw new SessionNotFoundError(sessionId); + const clientId = resolveTrustedClientId(entry, context?.clientId); + const result = await entry.artifacts.unpin(artifactId); + publishArtifactChanges(entry, result.changes, clientId); + return result; + }, + + async fsckSessionArtifacts(sessionId) { + const entry = byId.get(sessionId); + if (!entry) throw new SessionNotFoundError(sessionId); + return artifactContentStore.fsck(await entry.artifacts.contentRefs()); + }, + + async gcSessionArtifacts(sessionId) { + const entry = byId.get(sessionId); + if (!entry) throw new SessionNotFoundError(sessionId); + const refs = await entry.artifacts.contentRefs(); + return artifactContentStore.gc( + sessionId, + new Set(refs.map((ref) => ref.contentId)), + ); + }, + listWorkspaceSessions(workspaceCwd) { if (!path.isAbsolute(workspaceCwd)) return []; const key = diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts index 992c5eb64d3..7455aa0b3e7 100644 --- a/packages/acp-bridge/src/bridgeTypes.ts +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -22,6 +22,10 @@ import type { SessionArtifactMutationResult, SessionArtifactsEnvelope, } from './sessionArtifacts.js'; +import type { + SessionArtifactFsckResult, + SessionArtifactGcResult, +} from './sessionArtifactContentStore.js'; import type { ServeSessionContextStatus, ServeSessionHooksStatus, @@ -526,6 +530,22 @@ export interface AcpSessionBridge { context?: BridgeClientRequestContext, ): Promise; + pinSessionArtifact( + sessionId: string, + artifactId: string, + context?: BridgeClientRequestContext, + ): Promise; + + unpinSessionArtifact( + sessionId: string, + artifactId: string, + context?: BridgeClientRequestContext, + ): Promise; + + fsckSessionArtifacts(sessionId: string): Promise; + + gcSessionArtifacts(sessionId: string): Promise; + /** * Cast a vote on a pending `permission_request` (first-responder wins). */ diff --git a/packages/acp-bridge/src/sessionArtifactContentStore.ts b/packages/acp-bridge/src/sessionArtifactContentStore.ts new file mode 100644 index 00000000000..fec839dcc51 --- /dev/null +++ b/packages/acp-bridge/src/sessionArtifactContentStore.ts @@ -0,0 +1,315 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createHash } from 'node:crypto'; +import fsSync from 'node:fs'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import type { SessionArtifactContentRef } from '@qwen-code/qwen-code-core'; +import type { DaemonSessionArtifact } from './sessionArtifacts.js'; +import { SessionArtifactValidationError } from './sessionArtifacts.js'; + +const CONTENT_FORMAT_VERSION = 1; +const MAX_PINNED_FILE_BYTES = 50 * 1024 * 1024; +const MAX_CONTENT_STORE_BYTES = 256 * 1024 * 1024; + +interface ContentManifest { + v: typeof CONTENT_FORMAT_VERSION; + contentId: string; + sessionId: string; + artifactId: string; + workspacePath: string; + sha256: string; + sizeBytes: number; + createdAt: string; +} + +export interface SessionArtifactFsckResult { + checked: number; + missing: string[]; + hashMismatches: string[]; +} + +export interface SessionArtifactGcResult { + removed: string[]; + retained: string[]; +} + +export class SessionArtifactContentStore { + private readonly rootDir: string; + + constructor(rootDir = defaultContentRoot()) { + this.rootDir = rootDir; + } + + async pinWorkspaceFile( + sessionId: string, + artifact: DaemonSessionArtifact, + workspaceCwd: string, + ): Promise { + if (artifact.storage !== 'workspace' || !artifact.workspacePath) { + return undefined; + } + const source = await resolveWorkspaceFile( + workspaceCwd, + artifact.workspacePath, + ).catch((error: unknown) => { + if (error instanceof SessionArtifactValidationError) { + throw error; + } + throw new SessionArtifactValidationError( + `workspacePath could not be inspected: ${ + error instanceof Error ? error.message : String(error) + }`, + 'artifactId', + ); + }); + const sourceStat = await fs.stat(source); + if (!sourceStat.isFile()) { + throw new SessionArtifactValidationError( + 'Only regular workspace files can be pinned with content retention', + 'artifactId', + ); + } + if (sourceStat.size > MAX_PINNED_FILE_BYTES) { + throw new SessionArtifactValidationError( + `Pinned artifact content exceeds ${MAX_PINNED_FILE_BYTES} bytes`, + 'artifactId', + ); + } + const usedBytes = await this.usedBytes(); + if (usedBytes + sourceStat.size > MAX_CONTENT_STORE_BYTES) { + throw new SessionArtifactValidationError( + 'Artifact content quota exceeded', + 'artifactId', + ); + } + + const tmpDir = path.join(this.rootDir, '.tmp'); + await fs.mkdir(tmpDir, { recursive: true, mode: 0o700 }); + const tmpPath = path.join( + tmpDir, + `${process.pid}-${Date.now()}-${artifact.id}.bin`, + ); + await fs.copyFile(source, tmpPath); + const { sha256, sizeBytes } = await hashFile(tmpPath); + const contentId = `${sha256}-${stableContentSuffix(sessionId, artifact.id)}`; + const contentDir = path.join(this.rootDir, contentId); + const dataPath = path.join(contentDir, 'content'); + await fs.mkdir(contentDir, { recursive: true, mode: 0o700 }); + if (await exists(dataPath)) { + await fs.rm(tmpPath, { force: true }); + } else { + await fs.rename(tmpPath, dataPath); + } + const createdAt = new Date().toISOString(); + const manifest: ContentManifest = { + v: CONTENT_FORMAT_VERSION, + contentId, + sessionId, + artifactId: artifact.id, + workspacePath: artifact.workspacePath, + sha256, + sizeBytes, + createdAt, + }; + await fs.writeFile( + path.join(contentDir, 'manifest.json'), + `${JSON.stringify(manifest)}\n`, + { mode: 0o600 }, + ); + return { + kind: 'managed_copy', + contentId, + sha256, + sizeBytes, + createdAt, + }; + } + + async fsck( + contentRefs: readonly SessionArtifactContentRef[], + ): Promise { + const missing: string[] = []; + const hashMismatches: string[] = []; + for (const ref of contentRefs) { + const dataPath = path.join(this.rootDir, ref.contentId, 'content'); + try { + const { sha256 } = await hashFile(dataPath); + if (sha256 !== ref.sha256) { + hashMismatches.push(ref.contentId); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + missing.push(ref.contentId); + continue; + } + throw error; + } + } + return { checked: contentRefs.length, missing, hashMismatches }; + } + + async gc( + sessionId: string, + referencedContentIds: ReadonlySet, + ): Promise { + const removed: string[] = []; + const retained: string[] = []; + let entries: string[]; + try { + entries = await fs.readdir(this.rootDir); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { removed, retained }; + } + throw error; + } + for (const entry of entries) { + if (entry === '.tmp') continue; + const fullPath = path.join(this.rootDir, entry); + if (referencedContentIds.has(entry)) { + retained.push(entry); + continue; + } + let manifest: ContentManifest; + try { + manifest = await readManifest(path.join(fullPath, 'manifest.json')); + } catch { + retained.push(entry); + continue; + } + if (manifest.sessionId !== sessionId) { + retained.push(entry); + continue; + } + await fs.rm(fullPath, { recursive: true, force: true }); + removed.push(entry); + } + return { removed, retained }; + } + + private async usedBytes(): Promise { + let total = 0; + let entries: string[]; + try { + entries = await fs.readdir(this.rootDir); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return 0; + } + throw error; + } + for (const entry of entries) { + if (entry === '.tmp') continue; + try { + const manifest = await readManifest( + path.join(this.rootDir, entry, 'manifest.json'), + ); + total += manifest.sizeBytes; + } catch { + // Malformed manifests are handled by fsck/GC; ignore them for quota. + } + } + return total; + } +} + +function defaultContentRoot(): string { + return path.join(getGlobalQwenDir(), 'session-artifacts', 'content'); +} + +function getGlobalQwenDir(): string { + const envDir = process.env['QWEN_HOME']; + if (envDir) { + return resolveUserPath(envDir); + } + const homeDir = os.homedir(); + if (!homeDir) { + return path.join(os.tmpdir(), '.qwen'); + } + return path.join(homeDir, '.qwen'); +} + +function resolveUserPath(dir: string): string { + let resolved = dir; + if ( + resolved === '~' || + resolved.startsWith('~/') || + resolved.startsWith('~\\') + ) { + const relativeSegments = + resolved === '~' + ? [] + : resolved + .slice(2) + .split(/[/\\]+/) + .filter(Boolean); + resolved = path.join(os.homedir(), ...relativeSegments); + } + return path.isAbsolute(resolved) ? resolved : path.resolve(resolved); +} + +function stableContentSuffix(sessionId: string, artifactId: string): string { + return createHash('sha256') + .update(`${sessionId}:${artifactId}`) + .digest('hex') + .slice(0, 16); +} + +async function resolveWorkspaceFile( + workspaceCwd: string, + workspacePath: string, +): Promise { + const realWorkspace = await fs.realpath(workspaceCwd); + const candidate = path.resolve(realWorkspace, workspacePath); + const realCandidate = await fs.realpath(candidate); + const relative = path.relative(realWorkspace, realCandidate); + if (relative.startsWith('..') || path.isAbsolute(relative)) { + throw new SessionArtifactValidationError( + 'workspacePath must stay inside the workspace', + 'workspacePath', + ); + } + return realCandidate; +} + +function hashFile( + filePath: string, +): Promise<{ sha256: string; sizeBytes: number }> { + return new Promise((resolve, reject) => { + const hash = createHash('sha256'); + let sizeBytes = 0; + const stream = fsSync.createReadStream(filePath); + stream.on('data', (chunk: string | Buffer) => { + const buffer = typeof chunk === 'string' ? Buffer.from(chunk) : chunk; + sizeBytes += buffer.length; + hash.update(buffer); + }); + stream.on('error', reject); + stream.on('end', () => { + resolve({ sha256: hash.digest('hex'), sizeBytes }); + }); + }); +} + +async function readManifest(filePath: string): Promise { + const body = await fs.readFile(filePath, 'utf8'); + return JSON.parse(body) as ContentManifest; +} + +async function exists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return false; + } + throw error; + } +} diff --git a/packages/acp-bridge/src/sessionArtifacts.test.ts b/packages/acp-bridge/src/sessionArtifacts.test.ts index 31dae0a4178..1513c62db51 100644 --- a/packages/acp-bridge/src/sessionArtifacts.test.ts +++ b/packages/acp-bridge/src/sessionArtifacts.test.ts @@ -14,6 +14,12 @@ import { SessionArtifactStore, SessionArtifactValidationError, } from './sessionArtifacts.js'; +import { SessionArtifactContentStore } from './sessionArtifactContentStore.js'; +import type { + RebuiltSessionArtifactSnapshot, + SessionArtifactEventRecordPayload, + SessionArtifactSnapshotRecordPayload, +} from '@qwen-code/qwen-code-core'; describe('SessionArtifactStore', () => { let workspace: string; @@ -1699,4 +1705,358 @@ describe('SessionArtifactStore', () => { }), ); }); + + it('records durable artifact events through the persistence hook', async () => { + const events: SessionArtifactEventRecordPayload[] = []; + const store = new SessionArtifactStore({ + sessionId: 's11-persist', + workspaceCwd: workspace, + persistence: { + recordEvent: async (payload) => { + events.push(payload); + }, + recordSnapshot: async () => {}, + }, + }); + + const created = await store.upsertMany( + [{ title: 'Durable', url: 'https://example.com/durable' }], + { strict: true }, + ); + + expect(created.changes[0]?.artifact).toMatchObject({ + retention: 'restorable', + restoreState: 'live', + }); + expect(created.changes[0]?.artifact?.persistedAt).toBeDefined(); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + sessionId: 's11-persist', + sequence: 1, + changes: [ + { + action: 'created', + artifact: expect.objectContaining({ + title: 'Durable', + retention: 'restorable', + }), + }, + ], + }); + + const artifactId = created.changes[0]!.artifactId; + await store.remove(artifactId); + + expect(events).toHaveLength(2); + expect(events[1]).toMatchObject({ + sequence: 2, + changes: [ + { + action: 'removed', + artifactId, + reason: 'explicit', + }, + ], + }); + }); + + it('records periodic snapshots after durable artifact events', async () => { + const events: SessionArtifactEventRecordPayload[] = []; + const snapshots: SessionArtifactSnapshotRecordPayload[] = []; + const store = new SessionArtifactStore({ + sessionId: 's11-snapshot', + workspaceCwd: workspace, + persistence: { + recordEvent: async (payload) => { + events.push(payload); + }, + recordSnapshot: async (payload) => { + snapshots.push(payload); + }, + }, + }); + + for (let index = 0; index < 50; index++) { + await store.upsertMany( + [ + { + title: `Durable ${index}`, + url: `https://example.com/durable-${index}`, + }, + ], + { strict: true }, + ); + } + + expect(events).toHaveLength(50); + expect(snapshots).toHaveLength(1); + expect(snapshots[0]).toMatchObject({ + sessionId: 's11-snapshot', + sequence: 51, + artifacts: expect.arrayContaining([ + expect.objectContaining({ + title: 'Durable 0', + retention: 'restorable', + }), + expect.objectContaining({ + title: 'Durable 49', + retention: 'restorable', + }), + ]), + }); + expect(snapshots[0]?.artifacts).toHaveLength(50); + + await store.upsertMany( + [{ title: 'Durable 50', url: 'https://example.com/durable-50' }], + { strict: true }, + ); + + expect(events).toHaveLength(51); + expect(snapshots).toHaveLength(1); + expect(events[50]).toMatchObject({ sequence: 52 }); + }); + + it('downgrades non-strict durable artifacts when persistence is unavailable', async () => { + const store = new SessionArtifactStore({ + sessionId: 's11-unavailable', + workspaceCwd: workspace, + }); + + const result = await store.upsertMany([ + { + title: 'Requested durable', + url: 'https://example.com/durable', + retention: 'restorable', + }, + ]); + + expect(result.warnings).toEqual([ + 'artifact persistence unavailable; durable artifacts kept ephemeral', + ]); + expect(result.changes[0]?.artifact).toMatchObject({ + retention: 'ephemeral', + persistenceWarning: 'persistence_unavailable', + }); + await expect(store.list()).resolves.toMatchObject({ + artifacts: [ + expect.objectContaining({ + retention: 'ephemeral', + persistenceWarning: 'persistence_unavailable', + }), + ], + }); + }); + + it('rolls back strict mutations when persistence fails', async () => { + const store = new SessionArtifactStore({ + sessionId: 's11-rollback', + workspaceCwd: workspace, + persistence: { + recordEvent: async () => { + throw new Error('disk full'); + }, + recordSnapshot: async () => {}, + }, + }); + + await expect( + store.upsertMany( + [{ title: 'Rollback', url: 'https://example.com/rollback' }], + { strict: true }, + ), + ).rejects.toThrow('disk full'); + await expect(store.list()).resolves.toMatchObject({ artifacts: [] }); + }); + + it('restores rebuilt durable artifacts as metadata-only restored entries', async () => { + const events: SessionArtifactEventRecordPayload[] = []; + const source = new SessionArtifactStore({ + sessionId: 's11-restore', + workspaceCwd: workspace, + persistence: { + recordEvent: async (payload) => { + events.push(payload); + }, + recordSnapshot: async () => {}, + }, + }); + await source.upsertMany( + [{ title: 'Restored', url: 'https://example.com/restored' }], + { strict: true }, + ); + const persisted = events[0]!.changes[0]!.artifact!; + const snapshot: RebuiltSessionArtifactSnapshot = { + v: 2, + sessionId: 's11-restore', + sequence: 1, + artifacts: [persisted], + tombstonedIds: [], + stickyEphemeralIds: [], + warnings: [], + }; + + const restored = new SessionArtifactStore({ + sessionId: 's11-restore', + workspaceCwd: workspace, + persistence: { + recordEvent: async () => { + throw new Error('restore must not write records'); + }, + recordSnapshot: async () => {}, + }, + }); + + await expect(restored.restore(snapshot)).resolves.toEqual([]); + await expect(restored.list()).resolves.toMatchObject({ + artifacts: [ + expect.objectContaining({ + id: persisted.id, + title: 'Restored', + retention: 'restorable', + restoreState: 'restored', + persistenceWarning: 'metadata_only_restore', + }), + ], + }); + }); +}); + +describe('SessionArtifactContentStore', () => { + let workspace: string; + let contentRoot: string; + + beforeEach(async () => { + workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-artifacts-')); + contentRoot = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-artifact-content-'), + ); + }); + + afterEach(async () => { + await fs.rm(workspace, { recursive: true, force: true }); + await fs.rm(contentRoot, { recursive: true, force: true }); + }); + + async function workspaceArtifact(name: string, body: string) { + await fs.mkdir(path.join(workspace, 'reports'), { recursive: true }); + await fs.writeFile(path.join(workspace, 'reports', name), body); + const store = new SessionArtifactStore({ + sessionId: 'content-session', + workspaceCwd: workspace, + }); + await store.upsertMany( + [{ title: name, workspacePath: `reports/${name}` }], + { strict: true }, + ); + return (await store.list()).artifacts[0]!; + } + + it('copies pinned workspace content with a path-safe content id', async () => { + const contentStore = new SessionArtifactContentStore(contentRoot); + const artifact = await workspaceArtifact('report.txt', 'hello'); + + const ref = await contentStore.pinWorkspaceFile( + 'session/with/slash', + artifact, + workspace, + ); + + expect(ref).toMatchObject({ + kind: 'managed_copy', + sha256: createHash('sha256').update('hello').digest('hex'), + sizeBytes: 5, + }); + expect(ref!.contentId).toMatch(/^[a-f0-9]{64}-[a-f0-9]{16}$/); + await expect( + fs.readFile(path.join(contentRoot, ref!.contentId, 'content'), 'utf8'), + ).resolves.toBe('hello'); + await expect(contentStore.fsck([ref!])).resolves.toEqual({ + checked: 1, + missing: [], + hashMismatches: [], + }); + }); + + it('reuses an existing retained content copy for repeated pins', async () => { + const contentStore = new SessionArtifactContentStore(contentRoot); + const artifact = await workspaceArtifact('repeat.txt', 'repeat'); + + const first = (await contentStore.pinWorkspaceFile( + 'content-session', + artifact, + workspace, + ))!; + const second = (await contentStore.pinWorkspaceFile( + 'content-session', + artifact, + workspace, + ))!; + + expect(second.contentId).toBe(first.contentId); + await expect(contentStore.fsck([second])).resolves.toEqual({ + checked: 1, + missing: [], + hashMismatches: [], + }); + }); + + it('reports missing and hash-mismatched retained content', async () => { + const contentStore = new SessionArtifactContentStore(contentRoot); + const artifact = await workspaceArtifact('report.txt', 'hello'); + const ref = (await contentStore.pinWorkspaceFile( + 'content-session', + artifact, + workspace, + ))!; + + await fs.writeFile(path.join(contentRoot, ref.contentId, 'content'), 'bad'); + await expect(contentStore.fsck([ref])).resolves.toMatchObject({ + checked: 1, + hashMismatches: [ref.contentId], + }); + + await fs.rm(path.join(contentRoot, ref.contentId, 'content')); + await expect(contentStore.fsck([ref])).resolves.toMatchObject({ + checked: 1, + missing: [ref.contentId], + }); + }); + + it('garbage-collects only unreferenced content for the requested session', async () => { + const contentStore = new SessionArtifactContentStore(contentRoot); + const kept = (await contentStore.pinWorkspaceFile( + 'content-session', + await workspaceArtifact('kept.txt', 'kept'), + workspace, + ))!; + const orphaned = (await contentStore.pinWorkspaceFile( + 'content-session', + await workspaceArtifact('orphaned.txt', 'orphaned'), + workspace, + ))!; + const otherSession = (await contentStore.pinWorkspaceFile( + 'other-session', + await workspaceArtifact('other.txt', 'other'), + workspace, + ))!; + + await expect( + contentStore.gc('content-session', new Set([kept.contentId])), + ).resolves.toEqual({ + removed: [orphaned.contentId], + retained: expect.arrayContaining([ + kept.contentId, + otherSession.contentId, + ]), + }); + await expect( + fs.access(path.join(contentRoot, orphaned.contentId)), + ).rejects.toMatchObject({ code: 'ENOENT' }); + await expect( + fs.access(path.join(contentRoot, kept.contentId)), + ).resolves.toBeUndefined(); + await expect( + fs.access(path.join(contentRoot, otherSession.contentId)), + ).resolves.toBeUndefined(); + }); }); diff --git a/packages/acp-bridge/src/sessionArtifacts.ts b/packages/acp-bridge/src/sessionArtifacts.ts index e3bc45362af..d93762ae217 100644 --- a/packages/acp-bridge/src/sessionArtifacts.ts +++ b/packages/acp-bridge/src/sessionArtifacts.ts @@ -7,8 +7,30 @@ import { createHash } from 'node:crypto'; import { promises as fs } from 'node:fs'; import path from 'node:path'; +import type { + PersistedSessionArtifact, + RebuiltSessionArtifactSnapshot, + SessionArtifactContentRef, + SessionArtifactEventRecordPayload, + SessionArtifactPersistenceWarning, + SessionArtifactRestoreState, + SessionArtifactRetention, + SessionArtifactSnapshotRecordPayload, +} from '@qwen-code/qwen-code-core'; import { writeStderrLine } from './internal/stderrLine.js'; +const SESSION_ARTIFACT_PERSISTENCE_VERSION = 2 as const; + +function stableSessionArtifactId( + sessionId: string, + identityKey: string, +): string { + return createHash('sha256') + .update(`${sessionId}:${identityKey}`) + .digest('hex') + .slice(0, 16); +} + export type DaemonSessionArtifactKind = | 'file' | 'link' @@ -37,6 +59,7 @@ const SOURCE_RESERVATIONS: Record = { }; const WORKSPACE_STATUS_REFRESH_TTL_MS = 5_000; const WORKSPACE_STATUS_REFRESH_BATCH_SIZE = 20; +const SNAPSHOT_AFTER_DURABLE_EVENTS = 50; export interface ToolArtifactLike { kind?: DaemonSessionArtifactKind; @@ -53,6 +76,8 @@ export interface ToolArtifactLike { export interface SessionArtifactInput extends ToolArtifactLike { source?: DaemonSessionArtifactSource; + retention?: SessionArtifactRetention; + clientRetained?: boolean; toolCallId?: string; toolName?: string; hookEventName?: string; @@ -73,6 +98,12 @@ export interface DaemonSessionArtifact { mimeType?: string; sizeBytes?: number; metadata?: Record; + retention: SessionArtifactRetention; + restoreState?: SessionArtifactRestoreState; + persistenceWarning?: SessionArtifactPersistenceWarning; + contentRef?: SessionArtifactContentRef; + persistedAt?: string; + expiresAt?: string; clientRetained: boolean; createdAt: string; updatedAt: string; @@ -82,7 +113,10 @@ export interface DaemonSessionArtifact { clientId?: string; } -export type SessionArtifactRemovalReason = 'eviction' | 'explicit'; +export type SessionArtifactRemovalReason = + | 'eviction' + | 'explicit' + | 'unpin_to_ephemeral'; export interface SessionArtifactChange { action: 'created' | 'updated' | 'removed'; @@ -105,6 +139,12 @@ export interface SessionArtifactMutationResult { v: 1; sessionId: string; changes: SessionArtifactChange[]; + warnings?: string[]; +} + +export interface SessionArtifactPersistence { + recordEvent(payload: SessionArtifactEventRecordPayload): Promise; + recordSnapshot(payload: SessionArtifactSnapshotRecordPayload): Promise; } export class SessionArtifactValidationError extends Error { @@ -123,6 +163,7 @@ interface SessionArtifactStoreOptions { sessionId: string; workspaceCwd: string; maxArtifacts?: number; + persistence?: SessionArtifactPersistence; } interface NormalizedArtifact extends DaemonSessionArtifact { @@ -141,9 +182,12 @@ export class SessionArtifactStore { private readonly sessionId: string; private readonly workspaceCwd: string; private readonly maxArtifacts: number; + private readonly persistence?: SessionArtifactPersistence; private readonly artifacts = new Map(); private receivedSeq = 0; private insertSeq = 0; + private persistenceSeq = 0; + private durableEventsSinceSnapshot = 0; private realWorkspaceCwdPromise?: Promise; private operationQueue: Promise = Promise.resolve(); @@ -151,6 +195,7 @@ export class SessionArtifactStore { this.sessionId = options.sessionId; this.workspaceCwd = options.workspaceCwd; this.maxArtifacts = options.maxArtifacts ?? 200; + this.persistence = options.persistence; } inputBatchLimit(): number { @@ -172,12 +217,36 @@ export class SessionArtifactStore { }); } + async get(artifactId: string): Promise { + return this.enqueue(async () => { + const artifact = this.artifacts.get(artifactId); + if (!artifact) return undefined; + if ( + artifact.workspacePath && + shouldRefreshWorkspaceStatus(artifact, Date.now()) + ) { + await this.refreshWorkspaceStatus(artifact, { onError: 'missing' }); + } + return toPublicArtifact(artifact); + }); + } + + async contentRefs(): Promise { + return this.enqueue(async () => + Array.from(this.artifacts.values()) + .map((artifact) => artifact.contentRef) + .filter((ref): ref is SessionArtifactContentRef => ref !== undefined), + ); + } + async upsertMany( inputs: SessionArtifactInput[], options: { strict?: boolean; trustedPublisher?: boolean } = {}, ): Promise { return this.enqueue(async () => { + const before = this.cloneState(); const normalizedResults: NormalizedArtifact[] = []; + const warnings: string[] = []; for (const input of inputs) { try { normalizedResults.push( @@ -201,47 +270,61 @@ export class SessionArtifactStore { } } const changes: SessionArtifactChange[] = []; - for (const artifact of coalesceByIdentity(normalizedResults)) { - const existing = - this.artifacts.get(artifact.id) ?? - this.findPublishedUpgradeTarget(artifact) ?? - this.findPublishedWorkspaceTarget(artifact); - if (!existing) { - const stored: StoredArtifact = { - ...artifact, - insertSeq: ++this.insertSeq, - }; - this.artifacts.set(stored.id, stored); - changes.push({ - action: 'created', - artifactId: stored.id, - artifact: toPublicArtifact(stored), - }); - continue; - } + try { + for (const artifact of coalesceByIdentity(normalizedResults)) { + const existing = + this.artifacts.get(artifact.id) ?? + this.findPublishedUpgradeTarget(artifact) ?? + this.findPublishedWorkspaceTarget(artifact); + if (!existing) { + const stored: StoredArtifact = { + ...artifact, + insertSeq: ++this.insertSeq, + }; + this.artifacts.set(stored.id, stored); + changes.push({ + action: 'created', + artifactId: stored.id, + artifact: toPublicArtifact(stored), + }); + continue; + } - const updated = mergeArtifact(existing, artifact); - if (updated.changed) { - if (updated.artifact.id !== existing.id) { - this.artifacts.delete(existing.id); + const updated = mergeArtifact(existing, artifact); + if (updated.changed) { + if (updated.artifact.id !== existing.id) { + this.artifacts.delete(existing.id); + } + this.artifacts.set(updated.artifact.id, updated.artifact); + changes.push({ + action: 'updated', + artifactId: updated.artifact.id, + artifact: toPublicArtifact(updated.artifact), + }); } - this.artifacts.set(updated.artifact.id, updated.artifact); - changes.push({ - action: 'updated', - artifactId: updated.artifact.id, - artifact: toPublicArtifact(updated.artifact), - }); } - } - const createdIds = new Set( - changes - .filter((change) => change.action === 'created') - .map((change) => change.artifactId), - ); - changes.push(...(await this.evictOverflow(createdIds, changes))); + const createdIds = new Set( + changes + .filter((change) => change.action === 'created') + .map((change) => change.artifactId), + ); + changes.push(...(await this.evictOverflow(createdIds, changes))); + + warnings.push(...(await this.persistChanges(changes, options.strict))); + } catch (error) { + if (options.strict) { + this.restoreState(before); + } + throw error; + } - return { v: 1, sessionId: this.sessionId, changes }; + return { + v: 1, + sessionId: this.sessionId, + changes, + ...(warnings.length > 0 ? { warnings } : {}), + }; }); } @@ -257,7 +340,7 @@ export class SessionArtifactStore { return undefined; } const byUrl = this.artifacts.get( - stableArtifactId(this.sessionId, `url:${artifact.url}`), + stableSessionArtifactId(this.sessionId, `url:${artifact.url}`), ); if ( byUrl && @@ -311,6 +394,7 @@ export class SessionArtifactStore { options?: { clientId?: string }, ): Promise { return this.enqueue(async () => { + const before = this.cloneState(); const existing = this.artifacts.get(artifactId); if (!existing) { return { v: 1, sessionId: this.sessionId, changes: [] }; @@ -329,21 +413,303 @@ export class SessionArtifactStore { return { v: 1, sessionId: this.sessionId, changes: [] }; } this.artifacts.delete(artifactId); - return { - v: 1, - sessionId: this.sessionId, - changes: [ - { - action: 'removed', - artifactId, - artifact: toPublicArtifact(existing), - reason: 'explicit', - }, - ], + const changes: SessionArtifactChange[] = [ + { + action: 'removed', + artifactId, + artifact: toPublicArtifact(existing), + reason: 'explicit', + }, + ]; + try { + const warnings = await this.persistChanges(changes, true); + return { + v: 1, + sessionId: this.sessionId, + changes, + ...(warnings.length > 0 ? { warnings } : {}), + }; + } catch (error) { + this.restoreState(before); + throw error; + } + }); + } + + async pin( + artifactId: string, + contentRef?: SessionArtifactContentRef, + ): Promise { + return this.enqueue(async () => { + const before = this.cloneState(); + const existing = this.artifacts.get(artifactId); + if (!existing) { + return { v: 1, sessionId: this.sessionId, changes: [] }; + } + const updated: StoredArtifact = { + ...existing, + retention: 'pinned', + clientRetained: true, + restoreState: 'live', + updatedAt: new Date().toISOString(), + ...(contentRef + ? { contentRef } + : { persistenceWarning: 'metadata_only_restore' as const }), + }; + if (contentRef) { + delete updated.persistenceWarning; + } + this.artifacts.set(artifactId, updated); + const changes: SessionArtifactChange[] = [ + { + action: 'updated', + artifactId, + artifact: toPublicArtifact(updated), + }, + ]; + try { + const warnings = await this.persistChanges(changes, true); + return { + v: 1, + sessionId: this.sessionId, + changes, + ...(warnings.length > 0 ? { warnings } : {}), + }; + } catch (error) { + this.restoreState(before); + throw error; + } + }); + } + + async unpin(artifactId: string): Promise { + return this.enqueue(async () => { + const before = this.cloneState(); + const existing = this.artifacts.get(artifactId); + if (!existing) { + return { v: 1, sessionId: this.sessionId, changes: [] }; + } + const updated: StoredArtifact = { + ...existing, + retention: 'restorable', + restoreState: 'live', + persistenceWarning: 'metadata_only_restore', + updatedAt: new Date().toISOString(), }; + delete updated.contentRef; + delete updated.expiresAt; + this.artifacts.set(artifactId, updated); + const changes: SessionArtifactChange[] = [ + { + action: 'updated', + artifactId, + artifact: toPublicArtifact(updated), + }, + ]; + try { + const warnings = await this.persistChanges(changes, true); + return { + v: 1, + sessionId: this.sessionId, + changes, + ...(warnings.length > 0 ? { warnings } : {}), + }; + } catch (error) { + this.restoreState(before); + throw error; + } }); } + async restore( + snapshot: RebuiltSessionArtifactSnapshot | undefined, + ): Promise { + if (!snapshot) return []; + return this.enqueue(async () => { + const warnings = [...snapshot.warnings]; + this.artifacts.clear(); + this.insertSeq = 0; + this.persistenceSeq = snapshot.sequence; + for (const artifact of snapshot.artifacts) { + try { + const input = persistedArtifactToInput(artifact); + if (input.retention === 'pinned') { + input.retention = 'restorable'; + } + const normalized = await this.normalizeInput( + input, + ++this.receivedSeq, + artifact.storage === 'published', + ); + if (normalized.id !== artifact.id) { + warnings.push(`skipped artifact with mismatched id ${artifact.id}`); + continue; + } + const stored: StoredArtifact = { + ...normalized, + retention: artifact.retention, + clientRetained: artifact.clientRetained, + createdAt: artifact.createdAt, + updatedAt: artifact.updatedAt, + persistedAt: artifact.persistedAt, + expiresAt: artifact.expiresAt, + restoreState: 'restored', + persistenceWarning: artifact.contentRef + ? artifact.persistenceWarning + : (artifact.persistenceWarning ?? 'metadata_only_restore'), + contentRef: artifact.contentRef, + insertSeq: ++this.insertSeq, + }; + this.artifacts.set(stored.id, stored); + } catch (error) { + warnings.push( + `skipped artifact restore: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + return warnings; + }); + } + + private cloneState(): { + artifacts: Map; + insertSeq: number; + persistenceSeq: number; + durableEventsSinceSnapshot: number; + } { + return { + artifacts: new Map( + Array.from(this.artifacts.entries()).map(([id, artifact]) => [ + id, + cloneStoredArtifact(artifact), + ]), + ), + insertSeq: this.insertSeq, + persistenceSeq: this.persistenceSeq, + durableEventsSinceSnapshot: this.durableEventsSinceSnapshot, + }; + } + + private restoreState(state: { + artifacts: Map; + insertSeq: number; + persistenceSeq: number; + durableEventsSinceSnapshot: number; + }): void { + this.artifacts.clear(); + for (const [id, artifact] of state.artifacts) { + this.artifacts.set(id, cloneStoredArtifact(artifact)); + } + this.insertSeq = state.insertSeq; + this.persistenceSeq = state.persistenceSeq; + this.durableEventsSinceSnapshot = state.durableEventsSinceSnapshot; + } + + private async persistChanges( + changes: SessionArtifactChange[], + strict = false, + ): Promise { + const durableChanges = changes.filter(isDurablePersistenceChange); + if (durableChanges.length === 0) { + return []; + } + if (!this.persistence) { + if (strict) { + throw new SessionArtifactValidationError( + 'artifact persistence is unavailable', + 'retention', + ); + } + return this.downgradeDurableChanges(durableChanges); + } + + const recordedAt = new Date().toISOString(); + for (const change of durableChanges) { + if (change.action === 'removed') continue; + const stored = this.artifacts.get(change.artifactId); + if (!stored) continue; + stored.persistedAt = recordedAt; + delete stored.persistenceWarning; + change.artifact = toPublicArtifact(stored); + } + + const payload: SessionArtifactEventRecordPayload = { + v: SESSION_ARTIFACT_PERSISTENCE_VERSION, + sessionId: this.sessionId, + sequence: ++this.persistenceSeq, + recordedAt, + changes: durableChanges.map((change) => + toPersistedChange(change, recordedAt), + ), + }; + + try { + await this.persistence.recordEvent(payload); + await this.maybeRecordSnapshot(recordedAt); + return []; + } catch (error) { + if (strict) { + throw error; + } + const reason = error instanceof Error ? error.message : String(error); + writeStderrLine( + `[artifacts] session=${this.sessionId} action=persist_failed reason=${JSON.stringify( + reason, + )}`, + ); + return this.downgradeDurableChanges(durableChanges); + } + } + + private async maybeRecordSnapshot(recordedAt: string): Promise { + if (!this.persistence) return; + this.durableEventsSinceSnapshot++; + if (this.durableEventsSinceSnapshot < SNAPSHOT_AFTER_DURABLE_EVENTS) { + return; + } + const artifacts = Array.from(this.artifacts.values()) + .filter((artifact) => artifact.retention !== 'ephemeral') + .sort((a, b) => a.insertSeq - b.insertSeq) + .map((artifact) => + toPersistedArtifact(toPublicArtifact(artifact), recordedAt), + ); + const payload: SessionArtifactSnapshotRecordPayload = { + v: SESSION_ARTIFACT_PERSISTENCE_VERSION, + sessionId: this.sessionId, + sequence: ++this.persistenceSeq, + recordedAt, + artifacts, + }; + try { + await this.persistence.recordSnapshot(payload); + this.durableEventsSinceSnapshot = 0; + } catch (error) { + writeStderrLine( + `[artifacts] session=${this.sessionId} action=snapshot_failed reason=${JSON.stringify( + error instanceof Error ? error.message : String(error), + )}`, + ); + } + } + + private downgradeDurableChanges(changes: SessionArtifactChange[]): string[] { + for (const change of changes) { + if (change.action === 'removed') continue; + const stored = this.artifacts.get(change.artifactId); + if (!stored) continue; + stored.retention = 'ephemeral'; + stored.persistenceWarning = 'persistence_unavailable'; + delete stored.persistedAt; + delete stored.contentRef; + change.artifact = toPublicArtifact(stored); + } + return [ + 'artifact persistence unavailable; durable artifacts kept ephemeral', + ]; + } + private enqueue(operation: () => Promise): Promise { const result = this.operationQueue.then(operation, operation); this.operationQueue = result.then( @@ -400,6 +766,10 @@ export class SessionArtifactStore { }); const metadata = normalizeMetadata(input.metadata); + const retention = normalizeRetention(input.retention, { + persistenceAvailable: this.persistence !== undefined, + strictPinnedAllowed: false, + }); const workspaceStatus = workspacePath ? await this.getInitialWorkspaceStatus(workspacePath) : undefined; @@ -419,7 +789,7 @@ export class SessionArtifactStore { managedId, url, }); - const id = stableArtifactId(this.sessionId, identityKey); + const id = stableSessionArtifactId(this.sessionId, identityKey); return { id, @@ -443,7 +813,15 @@ export class SessionArtifactStore { ? normalizeSizeBytes(input.sizeBytes) : workspaceStatus?.sizeBytes, metadata, - clientRetained: source === 'client', + retention, + restoreState: 'live', + ...(this.persistence === undefined && retention !== 'ephemeral' + ? { persistenceWarning: 'persistence_unavailable' as const } + : {}), + clientRetained: + input.clientRetained !== undefined + ? input.clientRetained === true + : source === 'client', createdAt: now, updatedAt: now, toolCallId: normalizeString(input.toolCallId, 'toolCallId', 200, false), @@ -646,6 +1024,13 @@ function mergeBatchArtifact( createdAt: existing.createdAt, receivedSeq: existing.receivedSeq, retentionSource: existing.retentionSource, + retention: strongestRetention(existing.retention, next.retention), + restoreState: 'live', + persistenceWarning: + existing.persistenceWarning ?? next.persistenceWarning, + contentRef: existing.contentRef ?? next.contentRef, + persistedAt: existing.persistedAt ?? next.persistedAt, + expiresAt: existing.expiresAt ?? next.expiresAt, clientRetained: existing.clientRetained || next.clientRetained, lastStatAt: undefined, }; @@ -708,6 +1093,13 @@ function mergeArtifact( existing.storage === 'published' && !publishedUpdate ? existing.metadata : mergeMetadata(existing, incoming), + retention: strongestRetention(existing.retention, incoming.retention), + restoreState: 'live', + persistenceWarning: + existing.persistenceWarning ?? incoming.persistenceWarning, + contentRef: existing.contentRef ?? incoming.contentRef, + persistedAt: existing.persistedAt ?? incoming.persistedAt, + expiresAt: existing.expiresAt ?? incoming.expiresAt, source: existing.source, retentionSource: existing.retentionSource, trustedPublisher: existing.trustedPublisher || incoming.trustedPublisher, @@ -753,6 +1145,12 @@ function publicArtifactsEqual( a.mimeType === b.mimeType && a.sizeBytes === b.sizeBytes && metadataEqual(a.metadata, b.metadata) && + a.retention === b.retention && + a.restoreState === b.restoreState && + a.persistenceWarning === b.persistenceWarning && + contentRefEqual(a.contentRef, b.contentRef) && + a.persistedAt === b.persistedAt && + a.expiresAt === b.expiresAt && a.clientRetained === b.clientRetained && a.createdAt === b.createdAt && a.toolCallId === b.toolCallId && @@ -762,6 +1160,21 @@ function publicArtifactsEqual( ); } +function contentRefEqual( + a: SessionArtifactContentRef | undefined, + b: SessionArtifactContentRef | undefined, +): boolean { + if (a === b) return true; + if (!a || !b) return false; + return ( + a.kind === b.kind && + a.contentId === b.contentId && + a.sha256 === b.sha256 && + a.sizeBytes === b.sizeBytes && + a.createdAt === b.createdAt + ); +} + function metadataEqual( a: Record | undefined, b: Record | undefined, @@ -787,6 +1200,18 @@ function mergeSizeBytes( return existing.sizeBytes; } +function strongestRetention( + a: SessionArtifactRetention, + b: SessionArtifactRetention, +): SessionArtifactRetention { + const rank: Record = { + ephemeral: 0, + restorable: 1, + pinned: 2, + }; + return rank[b] > rank[a] ? b : a; +} + function mergeMetadata( existing: DaemonSessionArtifact, incoming: NormalizedArtifact, @@ -918,6 +1343,12 @@ function toPublicArtifact( mimeType, sizeBytes, metadata, + retention, + restoreState, + persistenceWarning, + contentRef, + persistedAt, + expiresAt, clientRetained, createdAt, updatedAt, @@ -940,6 +1371,12 @@ function toPublicArtifact( ...(mimeType ? { mimeType } : {}), ...(sizeBytes !== undefined ? { sizeBytes } : {}), ...(metadata ? { metadata } : {}), + retention, + ...(restoreState ? { restoreState } : {}), + ...(persistenceWarning ? { persistenceWarning } : {}), + ...(contentRef ? { contentRef } : {}), + ...(persistedAt ? { persistedAt } : {}), + ...(expiresAt ? { expiresAt } : {}), clientRetained, createdAt, updatedAt, @@ -950,6 +1387,100 @@ function toPublicArtifact( }; } +function persistedArtifactToInput( + artifact: PersistedSessionArtifact, +): SessionArtifactInput { + return { + title: artifact.title, + kind: artifact.kind, + storage: artifact.storage, + description: artifact.description, + workspacePath: artifact.workspacePath, + managedId: artifact.managedId, + url: artifact.url, + mimeType: artifact.mimeType, + sizeBytes: artifact.sizeBytes, + metadata: artifact.metadata, + source: artifact.source, + retention: artifact.retention, + clientRetained: artifact.clientRetained, + toolCallId: artifact.toolCallId, + toolName: artifact.toolName, + hookEventName: artifact.hookEventName, + clientId: artifact.clientId, + }; +} + +function toPersistedChange( + change: SessionArtifactChange, + recordedAt: string, +): SessionArtifactEventRecordPayload['changes'][number] { + return { + action: change.action, + artifactId: change.artifactId, + ...(change.artifact + ? { artifact: toPersistedArtifact(change.artifact, recordedAt) } + : {}), + ...(change.reason ? { reason: change.reason } : {}), + }; +} + +function toPersistedArtifact( + artifact: DaemonSessionArtifact, + recordedAt: string, +): PersistedSessionArtifact { + return { + id: artifact.id, + kind: artifact.kind, + storage: artifact.storage, + source: artifact.source, + status: artifact.status, + title: artifact.title, + ...(artifact.description ? { description: artifact.description } : {}), + ...(artifact.workspacePath + ? { workspacePath: artifact.workspacePath } + : {}), + ...(artifact.managedId ? { managedId: artifact.managedId } : {}), + ...(artifact.url ? { url: artifact.url } : {}), + ...(artifact.mimeType ? { mimeType: artifact.mimeType } : {}), + ...(artifact.sizeBytes !== undefined + ? { sizeBytes: artifact.sizeBytes } + : {}), + ...(artifact.metadata ? { metadata: artifact.metadata } : {}), + retention: artifact.retention, + clientRetained: artifact.clientRetained, + createdAt: artifact.createdAt, + updatedAt: artifact.updatedAt, + persistedAt: artifact.persistedAt ?? recordedAt, + ...(artifact.expiresAt ? { expiresAt: artifact.expiresAt } : {}), + ...(artifact.restoreState ? { restoreState: artifact.restoreState } : {}), + ...(artifact.persistenceWarning + ? { persistenceWarning: artifact.persistenceWarning } + : {}), + ...(artifact.contentRef ? { contentRef: artifact.contentRef } : {}), + ...(artifact.toolCallId ? { toolCallId: artifact.toolCallId } : {}), + ...(artifact.toolName ? { toolName: artifact.toolName } : {}), + ...(artifact.hookEventName + ? { hookEventName: artifact.hookEventName } + : {}), + ...(artifact.clientId ? { clientId: artifact.clientId } : {}), + }; +} + +function isDurablePersistenceChange(change: SessionArtifactChange): boolean { + if (change.reason === 'eviction') return false; + if (!change.artifact) return false; + return change.artifact.retention !== 'ephemeral'; +} + +function cloneStoredArtifact(artifact: StoredArtifact): StoredArtifact { + return { + ...artifact, + ...(artifact.metadata ? { metadata: { ...artifact.metadata } } : {}), + ...(artifact.contentRef ? { contentRef: { ...artifact.contentRef } } : {}), + }; +} + function inferStorage( requested: unknown, locators: { @@ -1080,13 +1611,6 @@ function buildIdentityKey(input: { ); } -function stableArtifactId(sessionId: string, identityKey: string): string { - return createHash('sha256') - .update(`${sessionId}:${identityKey}`) - .digest('hex') - .slice(0, 16); -} - function managedIdForWorkspacePath( workspaceCwd: string, workspacePath: string, @@ -1343,6 +1867,31 @@ function normalizeMetadata( return normalized; } +function normalizeRetention( + value: unknown, + options: { persistenceAvailable: boolean; strictPinnedAllowed: boolean }, +): SessionArtifactRetention { + if (value === undefined) { + return options.persistenceAvailable ? 'restorable' : 'ephemeral'; + } + if (value === 'ephemeral' || value === 'restorable') { + return value; + } + if (value === 'pinned' && options.strictPinnedAllowed) { + return 'pinned'; + } + if (value === 'pinned') { + throw new SessionArtifactValidationError( + 'pinned retention requires the pin API', + 'retention', + ); + } + throw new SessionArtifactValidationError( + 'retention must be ephemeral, restorable, or pinned', + 'retention', + ); +} + function normalizeSizeBytes(value: unknown): number { if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { throw new SessionArtifactValidationError( diff --git a/packages/acp-bridge/src/status.ts b/packages/acp-bridge/src/status.ts index c76da2c4351..cc6ff3e52a9 100644 --- a/packages/acp-bridge/src/status.ts +++ b/packages/acp-bridge/src/status.ts @@ -135,6 +135,7 @@ export const SERVE_CONTROL_EXT_METHODS = { sessionRewind: 'qwen/control/session/rewind', sessionContinue: 'qwen/control/session/continue', sessionTitle: 'qwen/control/session/title', + sessionArtifactsPersist: 'qwen/control/session/artifacts/persist', workspaceMcpRestart: 'qwen/control/workspace/mcp/restart', workspaceMcpManage: 'qwen/control/workspace/mcp/manage', workspaceAgentGenerate: 'qwen/control/workspace/agents/generate', diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 53d86a19910..ff2c565367f 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -82,6 +82,8 @@ import type { DiscoveredMCPResource, DiscoveredMCPPrompt, WorkspaceRememberContextMode, + SessionArtifactEventRecordPayload, + SessionArtifactSnapshotRecordPayload, } from '@qwen-code/qwen-code-core'; import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js'; import { @@ -2886,7 +2888,10 @@ class QwenAgent implements Agent { modes: modesData, models: availableModels, configOptions, - }; + ...(sessionData?.artifactSnapshot + ? { artifactSnapshot: sessionData.artifactSnapshot } + : {}), + } as LoadSessionResponse; } async unstable_resumeSession( @@ -2925,11 +2930,15 @@ class QwenAgent implements Agent { const availableModels = this.buildAvailableModels(config); const configOptions = this.buildConfigOptions(config); + const sessionData = config.getResumedSessionData(); return { modes: modesData, models: availableModels, configOptions, - }; + ...(sessionData?.artifactSnapshot + ? { artifactSnapshot: sessionData.artifactSnapshot } + : {}), + } as ResumeSessionResponse; } /** @@ -5848,6 +5857,41 @@ class QwenAgent implements Agent { AbortSignal.timeout(5 * 60_000), )) as unknown as Record; } + case SERVE_CONTROL_EXT_METHODS.sessionArtifactsPersist: { + const sessionId = params['sessionId']; + if (typeof sessionId !== 'string' || sessionId.length === 0) { + throw RequestError.invalidParams( + undefined, + 'Invalid or missing sessionId', + ); + } + const kind = params['kind']; + if (kind !== 'event' && kind !== 'snapshot') { + throw RequestError.invalidParams( + undefined, + 'Invalid or missing artifact persist kind', + ); + } + const payload = params['payload']; + const session = this.sessionOrThrow(sessionId); + const recording = session.getConfig().getChatRecordingService(); + if (!recording) { + throw RequestError.internalError( + undefined, + 'Chat recording service unavailable', + ); + } + if (kind === 'event') { + await recording.recordSessionArtifactEvent( + payload as SessionArtifactEventRecordPayload, + ); + } else { + await recording.recordSessionArtifactSnapshot( + payload as SessionArtifactSnapshotRecordPayload, + ); + } + return { sessionId, persisted: true, kind }; + } case SERVE_CONTROL_EXT_METHODS.sessionTitle: { const sessionId = params['sessionId']; const displayName = params['displayName']; diff --git a/packages/cli/src/serve/acp-http/dispatch.ts b/packages/cli/src/serve/acp-http/dispatch.ts index 2f4a2f82338..db3ee02a275 100644 --- a/packages/cli/src/serve/acp-http/dispatch.ts +++ b/packages/cli/src/serve/acp-http/dispatch.ts @@ -166,6 +166,10 @@ const ALL_QWEN_VENDOR_METHODS: readonly string[] = [ `${QWEN_METHOD_NS}session/lsp`, `${QWEN_METHOD_NS}session/artifacts`, `${QWEN_METHOD_NS}session/artifacts/add`, + `${QWEN_METHOD_NS}session/artifacts/pin`, + `${QWEN_METHOD_NS}session/artifacts/unpin`, + `${QWEN_METHOD_NS}session/artifacts/fsck`, + `${QWEN_METHOD_NS}session/artifacts/gc`, `${QWEN_METHOD_NS}session/artifacts/remove`, // Wave 1: memory `${QWEN_METHOD_NS}workspace/memory`, @@ -390,6 +394,8 @@ function pickSessionArtifactInput( mimeType, sizeBytes, metadata, + retention, + clientRetained, } = params; return { @@ -403,6 +409,8 @@ function pickSessionArtifactInput( mimeType, sizeBytes, metadata, + retention, + clientRetained, } as AddSessionArtifactInput; } @@ -2356,6 +2364,67 @@ export class AcpDispatcher { return; } + case `${QWEN_METHOD_NS}session/artifacts/pin`: { + const sessionId = String(params['sessionId'] ?? ''); + await this.withMutableOwned(conn, sessionId, id, async () => { + const artifactId = String(params['artifactId'] ?? ''); + if (!artifactId) { + if (id !== undefined) { + conn.sendConn( + error(id, RPC.INVALID_PARAMS, '`artifactId` is required'), + ); + } + return; + } + const result = await this.bridge.pinSessionArtifact( + sessionId, + artifactId, + this.sessionCtx(conn, sessionId, loopback), + ); + this.replyConn(conn, id, result as unknown); + }); + return; + } + + case `${QWEN_METHOD_NS}session/artifacts/unpin`: { + const sessionId = String(params['sessionId'] ?? ''); + await this.withMutableOwned(conn, sessionId, id, async () => { + const artifactId = String(params['artifactId'] ?? ''); + if (!artifactId) { + if (id !== undefined) { + conn.sendConn( + error(id, RPC.INVALID_PARAMS, '`artifactId` is required'), + ); + } + return; + } + const result = await this.bridge.unpinSessionArtifact( + sessionId, + artifactId, + this.sessionCtx(conn, sessionId, loopback), + ); + this.replyConn(conn, id, result as unknown); + }); + return; + } + + case `${QWEN_METHOD_NS}session/artifacts/fsck`: { + const sessionId = String(params['sessionId'] ?? ''); + if (!this.requireOwned(conn, sessionId, id)) return; + const result = await this.bridge.fsckSessionArtifacts(sessionId); + this.replyConn(conn, id, result as unknown); + return; + } + + case `${QWEN_METHOD_NS}session/artifacts/gc`: { + const sessionId = String(params['sessionId'] ?? ''); + await this.withMutableOwned(conn, sessionId, id, async () => { + const result = await this.bridge.gcSessionArtifacts(sessionId); + this.replyConn(conn, id, result as unknown); + }); + return; + } + case `${QWEN_METHOD_NS}session/artifacts/remove`: { const sessionId = String(params['sessionId'] ?? ''); await this.withMutableOwned(conn, sessionId, id, async () => { diff --git a/packages/cli/src/serve/acp-http/index.ts b/packages/cli/src/serve/acp-http/index.ts index ce5656e0dda..a0b278fff91 100644 --- a/packages/cli/src/serve/acp-http/index.ts +++ b/packages/cli/src/serve/acp-http/index.ts @@ -189,6 +189,7 @@ const WS_READ_METHODS = new Set([ '_qwen/session/tasks', '_qwen/session/lsp', '_qwen/session/artifacts', + '_qwen/session/artifacts/fsck', '_qwen/workspace/mcp', '_qwen/workspace/skills', '_qwen/workspace/providers', diff --git a/packages/cli/src/serve/acp-http/transport.test.ts b/packages/cli/src/serve/acp-http/transport.test.ts index 4053d98f529..61398a462e3 100644 --- a/packages/cli/src/serve/acp-http/transport.test.ts +++ b/packages/cli/src/serve/acp-http/transport.test.ts @@ -369,6 +369,22 @@ class FakeBridge { context: Parameters[2]; } | undefined; + lastPinnedArtifact: + | { + sessionId: string; + artifactId: string; + context: Parameters[2]; + } + | undefined; + lastUnpinnedArtifact: + | { + sessionId: string; + artifactId: string; + context: Parameters[2]; + } + | undefined; + lastFsckSessionId: string | undefined; + lastGcSessionId: string | undefined; async getSessionArtifacts(sessionId: string) { this.lastArtifactListSessionId = sessionId; return { @@ -399,6 +415,42 @@ class FakeBridge { changes: [{ action: 'removed' as const, artifactId, reason: 'explicit' }], }; } + async pinSessionArtifact( + sessionId: string, + artifactId: string, + context: Parameters[2], + ) { + this.lastPinnedArtifact = { sessionId, artifactId, context }; + return { + v: 1, + sessionId, + changes: [{ action: 'updated' as const, artifactId }], + }; + } + async unpinSessionArtifact( + sessionId: string, + artifactId: string, + context: Parameters[2], + ) { + this.lastUnpinnedArtifact = { sessionId, artifactId, context }; + return { + v: 1, + sessionId, + changes: [{ action: 'updated' as const, artifactId }], + }; + } + async fsckSessionArtifacts(sessionId: string) { + this.lastFsckSessionId = sessionId; + return { + checked: 0, + missing: [] as string[], + hashMismatches: [] as string[], + }; + } + async gcSessionArtifacts(sessionId: string) { + this.lastGcSessionId = sessionId; + return { removed: [] as string[], retained: [] as string[] }; + } async getWorkspaceToolsStatus() { return { v: 1, tools: [] }; } @@ -5546,6 +5598,8 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { storage: 'external_url', url: 'https://example.test/lineage', metadata: { table: 'fact_orders' }, + retention: 'ephemeral', + clientRetained: false, source: 'tool', trustedPublisher: true, clientId: 'forged-client', @@ -5564,6 +5618,8 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { storage: 'external_url', url: 'https://example.test/lineage', metadata: { table: 'fact_orders' }, + retention: 'ephemeral', + clientRetained: false, }); const artifact = bridge.lastAddedArtifact?.artifact as | Record @@ -5676,6 +5732,124 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { expect(bridge.lastRemovedArtifact).toBeUndefined(); }); + it('_qwen/session/artifacts/pin forwards artifact id', async () => { + const connId = await initialize(); + const streamRes = openStream(connId); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 99, + method: 'session/new', + params: {}, + }); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 61, + method: '_qwen/session/artifacts/pin', + params: { sessionId: 'sess-1', artifactId: 'artifact-1' }, + }); + const frames = await takeFrames(await streamRes, 2); + expect(frames[1]).toMatchObject({ + result: { + v: 1, + sessionId: 'sess-1', + changes: [{ action: 'updated', artifactId: 'artifact-1' }], + }, + }); + expect(bridge.lastPinnedArtifact).toMatchObject({ + sessionId: 'sess-1', + artifactId: 'artifact-1', + }); + }); + + it('_qwen/session/artifacts/unpin forwards artifact id', async () => { + const connId = await initialize(); + const streamRes = openStream(connId); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 99, + method: 'session/new', + params: {}, + }); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 62, + method: '_qwen/session/artifacts/unpin', + params: { sessionId: 'sess-1', artifactId: 'artifact-1' }, + }); + const frames = await takeFrames(await streamRes, 2); + expect(frames[1]).toMatchObject({ + result: { + v: 1, + sessionId: 'sess-1', + changes: [{ action: 'updated', artifactId: 'artifact-1' }], + }, + }); + expect(bridge.lastUnpinnedArtifact).toMatchObject({ + sessionId: 'sess-1', + artifactId: 'artifact-1', + }); + }); + + it('_qwen/session/artifacts/fsck returns integrity status', async () => { + bridge.fsckSessionArtifacts = async (sessionId) => { + bridge.lastFsckSessionId = sessionId; + return { checked: 1, missing: ['missing'], hashMismatches: [] }; + }; + const connId = await initialize(); + const streamRes = openStream(connId); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 99, + method: 'session/new', + params: {}, + }); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 63, + method: '_qwen/session/artifacts/fsck', + params: { sessionId: 'sess-1' }, + }); + const frames = await takeFrames(await streamRes, 2); + expect(frames[1]).toMatchObject({ + result: { checked: 1, missing: ['missing'], hashMismatches: [] }, + }); + expect(bridge.lastFsckSessionId).toBe('sess-1'); + }); + + it('_qwen/session/artifacts/gc returns cleanup result', async () => { + bridge.gcSessionArtifacts = async (sessionId) => { + bridge.lastGcSessionId = sessionId; + return { removed: ['old'], retained: ['kept'] }; + }; + const connId = await initialize(); + const streamRes = openStream(connId); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 99, + method: 'session/new', + params: {}, + }); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 64, + method: '_qwen/session/artifacts/gc', + params: { sessionId: 'sess-1' }, + }); + const frames = await takeFrames(await streamRes, 2); + expect(frames[1]).toMatchObject({ + result: { removed: ['old'], retained: ['kept'] }, + }); + expect(bridge.lastGcSessionId).toBe('sess-1'); + }); + it('_qwen/session/artifacts/add holds the archive gate while mutating', async () => { await withRuntimeDir(async () => { const sessionId = '550e8400-e29b-41d4-a716-446655440131'; diff --git a/packages/cli/src/serve/capabilities.ts b/packages/cli/src/serve/capabilities.ts index 986ba1d0143..e2b0c1e21bc 100644 --- a/packages/cli/src/serve/capabilities.ts +++ b/packages/cli/src/serve/capabilities.ts @@ -45,6 +45,8 @@ export const SERVE_CAPABILITY_REGISTRY = { session_cancel: { since: 'v1' }, session_events: { since: 'v1' }, session_artifacts: { since: 'v1' }, + session_artifacts_persistence: { since: 'v1' }, + session_artifacts_content_retention: { since: 'v1' }, // Daemon emits `slow_client_warning` synthetic frames at 75% queue // fill and honors `?maxQueued=N` (range [16, 2048]) on // `GET /session/:id/events`. Old daemons silently lack both — SDK diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index 79a66889d4e..71bd82ac5ce 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -561,6 +561,10 @@ export function registerSessionRoutes( mimeType: body['mimeType'] as SessionArtifactInput['mimeType'], sizeBytes: body['sizeBytes'] as SessionArtifactInput['sizeBytes'], metadata: body['metadata'] as SessionArtifactInput['metadata'], + retention: body['retention'] as SessionArtifactInput['retention'], + clientRetained: body[ + 'clientRetained' + ] as SessionArtifactInput['clientRetained'], }; const result = await bridge.addSessionArtifact( sessionId, @@ -579,6 +583,113 @@ export function registerSessionRoutes( ), ); + app.post( + '/session/:id/artifacts/:artifactId/pin', + mutate({ strict: true }), + withMutableSession( + 'POST /session/:id/artifacts/:artifactId/pin', + async (req, res, sessionId) => { + const artifactId = req.params['artifactId']; + const clientId = parseClientIdHeader(req, res); + if (clientId === null) return; + if (!artifactId) { + res.status(400).json({ + v: 1, + error: { + code: 'VALIDATION_FAILED', + message: '`artifactId` route parameter is required', + field: 'artifactId', + }, + }); + return; + } + try { + const result = await bridge.pinSessionArtifact( + sessionId, + artifactId, + clientId !== undefined ? { clientId } : undefined, + ); + res.status(200).json(result); + } catch (err) { + if (sendArtifactValidationError(res, err)) return; + sendBridgeError(res, err, { + route: 'POST /session/:id/artifacts/:artifactId/pin', + sessionId, + }); + } + }, + ), + ); + + app.delete( + '/session/:id/artifacts/:artifactId/pin', + mutate({ strict: true }), + withMutableSession( + 'DELETE /session/:id/artifacts/:artifactId/pin', + async (req, res, sessionId) => { + const artifactId = req.params['artifactId']; + const clientId = parseClientIdHeader(req, res); + if (clientId === null) return; + if (!artifactId) { + res.status(400).json({ + v: 1, + error: { + code: 'VALIDATION_FAILED', + message: '`artifactId` route parameter is required', + field: 'artifactId', + }, + }); + return; + } + try { + const result = await bridge.unpinSessionArtifact( + sessionId, + artifactId, + clientId !== undefined ? { clientId } : undefined, + ); + res.status(200).json(result); + } catch (err) { + if (sendArtifactValidationError(res, err)) return; + sendBridgeError(res, err, { + route: 'DELETE /session/:id/artifacts/:artifactId/pin', + sessionId, + }); + } + }, + ), + ); + + app.get('/session/:id/artifacts/fsck', async (req, res) => { + const sessionId = requireSessionId(req, res); + if (sessionId === null) return; + try { + res.status(200).json(await bridge.fsckSessionArtifacts(sessionId)); + } catch (err) { + sendBridgeError(res, err, { + route: 'GET /session/:id/artifacts/fsck', + sessionId, + }); + } + }); + + app.post( + '/session/:id/artifacts/gc', + mutate({ strict: true }), + withMutableSession( + 'POST /session/:id/artifacts/gc', + async (_req, res, sessionId) => { + try { + res.status(200).json(await bridge.gcSessionArtifacts(sessionId)); + } catch (err) { + sendBridgeError(res, err, { + route: 'POST /session/:id/artifacts/gc', + sessionId, + }); + } + }, + ), + ); + app.delete( '/session/:id/artifacts/:artifactId', mutate({ strict: true }), diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 6f463bb97e5..d81c54dd9ad 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -187,6 +187,8 @@ const EXPECTED_STAGE1_FEATURES = [ 'session_cancel', 'session_events', 'session_artifacts', + 'session_artifacts_persistence', + 'session_artifacts_content_retention', 'slow_client_warning', 'typed_event_schema', 'session_set_model', @@ -408,6 +410,10 @@ interface FakeBridgeOpts { getSessionArtifactsImpl?: AcpSessionBridge['getSessionArtifacts']; addSessionArtifactImpl?: AcpSessionBridge['addSessionArtifact']; removeSessionArtifactImpl?: AcpSessionBridge['removeSessionArtifact']; + pinSessionArtifactImpl?: AcpSessionBridge['pinSessionArtifact']; + unpinSessionArtifactImpl?: AcpSessionBridge['unpinSessionArtifact']; + fsckSessionArtifactsImpl?: AcpSessionBridge['fsckSessionArtifacts']; + gcSessionArtifactsImpl?: AcpSessionBridge['gcSessionArtifacts']; workspaceMcpImpl?: () => Promise; workspaceMcpToolsImpl?: ( serverName: string, @@ -621,6 +627,18 @@ interface FakeBridge extends AcpSessionBridge { artifactId: string; context?: BridgeClientRequestContext; }>; + pinSessionArtifactCalls: Array<{ + sessionId: string; + artifactId: string; + context?: BridgeClientRequestContext; + }>; + unpinSessionArtifactCalls: Array<{ + sessionId: string; + artifactId: string; + context?: BridgeClientRequestContext; + }>; + fsckSessionArtifactsCalls: string[]; + gcSessionArtifactsCalls: string[]; workspaceMcpCalls: number; workspaceMcpToolsCalls: string[]; workspaceMcpResourcesCalls: string[]; @@ -767,6 +785,10 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { const addSessionArtifactCalls: FakeBridge['addSessionArtifactCalls'] = []; const removeSessionArtifactCalls: FakeBridge['removeSessionArtifactCalls'] = []; + const pinSessionArtifactCalls: FakeBridge['pinSessionArtifactCalls'] = []; + const unpinSessionArtifactCalls: FakeBridge['unpinSessionArtifactCalls'] = []; + const fsckSessionArtifactsCalls: string[] = []; + const gcSessionArtifactsCalls: string[] = []; let workspaceMcpCalls = 0; const workspaceMcpToolsCalls: string[] = []; const workspaceMcpResourcesCalls: string[] = []; @@ -872,6 +894,7 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { ...(artifact.workspacePath ? { workspacePath: artifact.workspacePath } : {}), + retention: artifact.retention ?? 'ephemeral', clientRetained: true, createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', @@ -896,6 +919,26 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { }, ], })); + const pinSessionArtifactImpl = + opts.pinSessionArtifactImpl ?? + ((sessionId, artifactId) => ({ + v: 1 as const, + sessionId, + changes: [{ action: 'updated' as const, artifactId }], + })); + const unpinSessionArtifactImpl = + opts.unpinSessionArtifactImpl ?? + ((sessionId, artifactId) => ({ + v: 1 as const, + sessionId, + changes: [{ action: 'updated' as const, artifactId }], + })); + const fsckSessionArtifactsImpl = + opts.fsckSessionArtifactsImpl ?? + (async () => ({ checked: 0, missing: [], hashMismatches: [] })); + const gcSessionArtifactsImpl = + opts.gcSessionArtifactsImpl ?? + (async () => ({ removed: [], retained: [] })); const workspaceMcpImpl = opts.workspaceMcpImpl ?? (async () => ({ @@ -1249,6 +1292,10 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { sessionArtifactsCalls, addSessionArtifactCalls, removeSessionArtifactCalls, + pinSessionArtifactCalls, + unpinSessionArtifactCalls, + fsckSessionArtifactsCalls, + gcSessionArtifactsCalls, workspaceMcpToolsCalls, workspaceMcpResourcesCalls, extensionEvents, @@ -1418,6 +1465,30 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { }); return removeSessionArtifactImpl(sessionId, artifactId, context); }, + async pinSessionArtifact(sessionId, artifactId, context) { + pinSessionArtifactCalls.push({ + sessionId, + artifactId, + ...(context ? { context } : {}), + }); + return pinSessionArtifactImpl(sessionId, artifactId, context); + }, + async unpinSessionArtifact(sessionId, artifactId, context) { + unpinSessionArtifactCalls.push({ + sessionId, + artifactId, + ...(context ? { context } : {}), + }); + return unpinSessionArtifactImpl(sessionId, artifactId, context); + }, + async fsckSessionArtifacts(sessionId) { + fsckSessionArtifactsCalls.push(sessionId); + return fsckSessionArtifactsImpl(sessionId); + }, + async gcSessionArtifacts(sessionId) { + gcSessionArtifactsCalls.push(sessionId); + return gcSessionArtifactsImpl(sessionId); + }, async getWorkspaceMcpStatus() { workspaceMcpCalls += 1; return workspaceMcpImpl(); @@ -7086,7 +7157,12 @@ describe('createServeApp', () => { const res = await auth(request(app).post('/session/session-A/artifacts')) .set('X-Qwen-Client-Id', 'client-1') - .send({ title: 'Lineage', url: 'https://example.com/lineage' }); + .send({ + title: 'Lineage', + url: 'https://example.com/lineage', + retention: 'ephemeral', + clientRetained: false, + }); expect(res.status).toBe(200); expect(res.body).toMatchObject({ @@ -7105,6 +7181,8 @@ describe('createServeApp', () => { artifact: { title: 'Lineage', url: 'https://example.com/lineage', + retention: 'ephemeral', + clientRetained: false, }, context: { clientId: 'client-1' }, }, @@ -7172,6 +7250,96 @@ describe('createServeApp', () => { }, ]); }); + + it('POST /session/:id/artifacts/:artifactId/pin forwards mutation auth context', async () => { + const bridge = fakeBridge(); + const app = createServeApp(tokenOpts, undefined, { bridge }); + + const res = await auth( + request(app).post('/session/session-A/artifacts/artifact-1/pin'), + ).set('X-Qwen-Client-Id', 'client-1'); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + v: 1, + sessionId: 'session-A', + changes: [{ action: 'updated', artifactId: 'artifact-1' }], + }); + expect(bridge.pinSessionArtifactCalls).toEqual([ + { + sessionId: 'session-A', + artifactId: 'artifact-1', + context: { clientId: 'client-1' }, + }, + ]); + }); + + it('DELETE /session/:id/artifacts/:artifactId/pin forwards mutation auth context', async () => { + const bridge = fakeBridge(); + const app = createServeApp(tokenOpts, undefined, { bridge }); + + const res = await auth( + request(app).delete('/session/session-A/artifacts/artifact-1/pin'), + ).set('X-Qwen-Client-Id', 'client-1'); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + v: 1, + sessionId: 'session-A', + changes: [{ action: 'updated', artifactId: 'artifact-1' }], + }); + expect(bridge.unpinSessionArtifactCalls).toEqual([ + { + sessionId: 'session-A', + artifactId: 'artifact-1', + context: { clientId: 'client-1' }, + }, + ]); + }); + + it('GET /session/:id/artifacts/fsck returns content integrity status', async () => { + const bridge = fakeBridge({ + fsckSessionArtifactsImpl: async () => ({ + checked: 2, + missing: ['missing-content'], + hashMismatches: ['bad-hash'], + }), + }); + const app = createServeApp(tokenOpts, undefined, { bridge }); + + const res = await auth( + request(app).get('/session/session-A/artifacts/fsck'), + ); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + checked: 2, + missing: ['missing-content'], + hashMismatches: ['bad-hash'], + }); + expect(bridge.fsckSessionArtifactsCalls).toEqual(['session-A']); + }); + + it('POST /session/:id/artifacts/gc returns content cleanup result', async () => { + const bridge = fakeBridge({ + gcSessionArtifactsImpl: async () => ({ + removed: ['orphaned'], + retained: ['kept'], + }), + }); + const app = createServeApp(tokenOpts, undefined, { bridge }); + + const res = await auth( + request(app).post('/session/session-A/artifacts/gc'), + ); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + removed: ['orphaned'], + retained: ['kept'], + }); + expect(bridge.gcSessionArtifactsCalls).toEqual(['session-A']); + }); }); describe('POST /session/:id/model', () => { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d666bf71baf..84bb3124817 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -216,6 +216,7 @@ export * from './services/gitWorktreeService.js'; export * from './services/visionBridge/vision-bridge-service.js'; export * from './services/visionBridge/image-part-utils.js'; export * from './services/sessionRecap.js'; +export * from './services/session-artifact-persistence.js'; export * from './services/sessionService.js'; export * from './services/sessionTitle.js'; export * from './services/sleepInhibitor.js'; diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index 39c1c8a7910..878ec03d005 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -34,6 +34,10 @@ import type { SerializedFileHistorySnapshot, } from './fileHistoryService.js'; import { serializeSnapshot } from './fileHistoryService.js'; +import type { + SessionArtifactEventRecordPayload, + SessionArtifactSnapshotRecordPayload, +} from './session-artifact-persistence.js'; const debugLogger = createDebugLogger('CHAT_RECORDING'); @@ -251,7 +255,9 @@ export interface ChatRecord { | 'rewind' | 'agent_bootstrap' | 'agent_launch_prompt' - | 'file_history_snapshot'; + | 'file_history_snapshot' + | 'session_artifact_event' + | 'session_artifact_snapshot'; /** Working directory at time of message */ cwd: string; /** CLI version for compatibility tracking */ @@ -298,7 +304,9 @@ export interface ChatRecord { | NotificationRecordPayload | RewindRecordPayload | AgentBootstrapRecordPayload - | FileHistorySnapshotRecordPayload; + | FileHistorySnapshotRecordPayload + | SessionArtifactEventRecordPayload + | SessionArtifactSnapshotRecordPayload; /** Background subagent that produced this record (e.g. "explore-7f3c"). */ agentId?: string; @@ -765,6 +773,31 @@ export class ChatRecordingService { this.updateTitleAnchorTracking(record); } + private async appendRecordStrict(record: ChatRecord): Promise { + const previousLastRecordUuid = this.lastRecordUuid; + let conversationFile: string; + try { + conversationFile = this.ensureConversationFile(); + } catch (error) { + debugLogger.error('Error appending record:', error); + throw error; + } + + this.lastRecordUuid = record.uuid; + this.writeChain = this.writeChain + .catch(() => {}) + .then(() => jsonl.writeLine(conversationFile, record)); + + try { + await this.writeChain; + this.updateTitleAnchorTracking(record); + } catch (error) { + this.lastRecordUuid = previousLastRecordUuid; + debugLogger.error('Error appending record (async):', error); + throw error; + } + } + /** * Maintain the "title is always in the tail window" invariant by * counting bytes appended since the last `custom_title` record and @@ -1471,4 +1504,28 @@ export class ChatRecordingService { debugLogger.error('Error saving file history snapshot batch:', error); } } + + async recordSessionArtifactEvent( + payload: SessionArtifactEventRecordPayload, + ): Promise { + const record: ChatRecord = { + ...this.createBaseRecord('system'), + type: 'system', + subtype: 'session_artifact_event', + systemPayload: payload, + }; + await this.appendRecordStrict(record); + } + + async recordSessionArtifactSnapshot( + payload: SessionArtifactSnapshotRecordPayload, + ): Promise { + const record: ChatRecord = { + ...this.createBaseRecord('system'), + type: 'system', + subtype: 'session_artifact_snapshot', + systemPayload: payload, + }; + await this.appendRecordStrict(record); + } } diff --git a/packages/core/src/services/session-artifact-persistence.test.ts b/packages/core/src/services/session-artifact-persistence.test.ts new file mode 100644 index 00000000000..6099d1b913d --- /dev/null +++ b/packages/core/src/services/session-artifact-persistence.test.ts @@ -0,0 +1,172 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { + SESSION_ARTIFACT_PERSISTENCE_VERSION, + rebuildSessionArtifactSnapshot, + remapSessionArtifactPayloadForFork, + stableSessionArtifactId, + type PersistedSessionArtifact, + type SessionArtifactEventRecordPayload, +} from './session-artifact-persistence.js'; + +function artifact( + sessionId: string, + url: string, + overrides: Partial = {}, +): PersistedSessionArtifact { + const now = '2026-07-04T00:00:00.000Z'; + return { + id: stableSessionArtifactId(sessionId, `url:${url}`), + kind: 'link', + storage: 'external_url', + source: 'client', + status: 'available', + title: 'Report', + url, + retention: 'restorable', + clientRetained: true, + createdAt: now, + updatedAt: now, + persistedAt: now, + ...overrides, + }; +} + +function event(payload: SessionArtifactEventRecordPayload): { + type: 'system'; + subtype: 'session_artifact_event'; + systemPayload: SessionArtifactEventRecordPayload; +} { + return { + type: 'system', + subtype: 'session_artifact_event', + systemPayload: payload, + }; +} + +describe('session artifact persistence records', () => { + it('rebuilds durable artifacts and explicit tombstones from event records', () => { + const first = artifact('s1', 'https://example.com/first'); + const second = artifact('s1', 'https://example.com/second'); + + const snapshot = rebuildSessionArtifactSnapshot([ + event({ + v: SESSION_ARTIFACT_PERSISTENCE_VERSION, + sessionId: 's1', + sequence: 1, + recordedAt: '2026-07-04T00:00:00.000Z', + changes: [ + { action: 'created', artifactId: first.id, artifact: first }, + { + action: 'created', + artifactId: second.id, + artifact: { ...second, retention: 'ephemeral' }, + }, + ], + }), + event({ + v: SESSION_ARTIFACT_PERSISTENCE_VERSION, + sessionId: 's1', + sequence: 2, + recordedAt: '2026-07-04T00:00:01.000Z', + changes: [ + { + action: 'removed', + artifactId: first.id, + artifact: first, + reason: 'explicit', + }, + ], + }), + ]); + + expect(snapshot).toMatchObject({ + v: SESSION_ARTIFACT_PERSISTENCE_VERSION, + sessionId: 's1', + sequence: 2, + artifacts: [], + tombstonedIds: [first.id], + stickyEphemeralIds: [], + warnings: [], + }); + }); + + it('lets snapshot records replace earlier event state', () => { + const first = artifact('s1', 'https://example.com/first'); + const second = artifact('s1', 'https://example.com/second'); + + const snapshot = rebuildSessionArtifactSnapshot([ + event({ + v: SESSION_ARTIFACT_PERSISTENCE_VERSION, + sessionId: 's1', + sequence: 1, + recordedAt: '2026-07-04T00:00:00.000Z', + changes: [{ action: 'created', artifactId: first.id, artifact: first }], + }), + { + type: 'system', + subtype: 'session_artifact_snapshot', + systemPayload: { + v: SESSION_ARTIFACT_PERSISTENCE_VERSION, + sessionId: 's1', + sequence: 3, + recordedAt: '2026-07-04T00:00:02.000Z', + artifacts: [second], + tombstonedIds: [first.id], + stickyEphemeralIds: [], + }, + }, + ]); + + expect(snapshot?.artifacts).toEqual([second]); + expect(snapshot?.tombstonedIds).toEqual([first.id]); + expect(snapshot?.sequence).toBe(3); + }); + + it('remaps forked payloads to the new session without carrying pinned content', () => { + const source = artifact('source-session', 'https://example.com/report', { + retention: 'pinned', + contentRef: { + kind: 'managed_copy', + contentId: 'content-1', + sha256: 'a'.repeat(64), + sizeBytes: 12, + createdAt: '2026-07-04T00:00:00.000Z', + }, + expiresAt: '2026-08-01T00:00:00.000Z', + }); + + const remapped = remapSessionArtifactPayloadForFork( + { + v: SESSION_ARTIFACT_PERSISTENCE_VERSION, + sessionId: 'source-session', + sequence: 5, + recordedAt: '2026-07-04T00:00:00.000Z', + changes: [ + { action: 'updated', artifactId: source.id, artifact: source }, + ], + }, + 'source-session', + 'forked-session', + ) as SessionArtifactEventRecordPayload; + + const forked = remapped.changes[0]?.artifact; + expect(remapped.sessionId).toBe('forked-session'); + expect(forked).toMatchObject({ + id: stableSessionArtifactId( + 'forked-session', + 'url:https://example.com/report', + ), + retention: 'restorable', + restoreState: 'restored', + persistenceWarning: 'metadata_only_restore', + }); + expect(forked).not.toHaveProperty('contentRef'); + expect(forked).not.toHaveProperty('expiresAt'); + }); +}); diff --git a/packages/core/src/services/session-artifact-persistence.ts b/packages/core/src/services/session-artifact-persistence.ts new file mode 100644 index 00000000000..55bf7f4e549 --- /dev/null +++ b/packages/core/src/services/session-artifact-persistence.ts @@ -0,0 +1,594 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createHash } from 'node:crypto'; + +export const SESSION_ARTIFACT_PERSISTENCE_VERSION = 2 as const; + +export type SessionArtifactRetention = 'ephemeral' | 'restorable' | 'pinned'; + +export type SessionArtifactRestoreState = + | 'live' + | 'restored' + | 'unverified' + | 'blocked'; + +export type SessionArtifactPersistenceWarning = + | 'persistence_unavailable' + | 'content_missing' + | 'content_expired' + | 'content_hash_mismatch' + | 'metadata_only_restore' + | 'restore_validation_failed'; + +export type PersistedSessionArtifactKind = + | 'file' + | 'link' + | 'html' + | 'image' + | 'video' + | 'audio' + | 'pdf' + | 'notebook' + | 'other'; + +export type PersistedSessionArtifactStorage = + | 'workspace' + | 'external_url' + | 'managed' + | 'published'; + +export type PersistedSessionArtifactSource = 'tool' | 'hook' | 'client'; + +export type PersistedSessionArtifactStatus = 'available' | 'missing'; + +export interface SessionArtifactContentRef { + kind: 'managed_copy'; + contentId: string; + sha256: string; + sizeBytes: number; + createdAt: string; +} + +export interface PersistedSessionArtifact { + id: string; + kind: PersistedSessionArtifactKind; + storage: PersistedSessionArtifactStorage; + source: PersistedSessionArtifactSource; + status: PersistedSessionArtifactStatus; + title: string; + description?: string; + workspacePath?: string; + managedId?: string; + url?: string; + mimeType?: string; + sizeBytes?: number; + metadata?: Record; + retention: SessionArtifactRetention; + clientRetained: boolean; + createdAt: string; + updatedAt: string; + persistedAt?: string; + expiresAt?: string; + restoreState?: SessionArtifactRestoreState; + persistenceWarning?: SessionArtifactPersistenceWarning; + contentRef?: SessionArtifactContentRef; + toolCallId?: string; + toolName?: string; + hookEventName?: string; + clientId?: string; +} + +export type SessionArtifactPersistedChangeAction = + | 'created' + | 'updated' + | 'removed'; + +export type SessionArtifactPersistedRemovalReason = + | 'explicit' + | 'eviction' + | 'unpin_to_ephemeral'; + +export interface SessionArtifactPersistedChange { + action: SessionArtifactPersistedChangeAction; + artifactId: string; + artifact?: PersistedSessionArtifact; + reason?: SessionArtifactPersistedRemovalReason; +} + +export interface SessionArtifactEventRecordPayload { + v: typeof SESSION_ARTIFACT_PERSISTENCE_VERSION; + sessionId: string; + sequence: number; + recordedAt: string; + changes: SessionArtifactPersistedChange[]; +} + +export interface SessionArtifactSnapshotRecordPayload { + v: typeof SESSION_ARTIFACT_PERSISTENCE_VERSION; + sessionId: string; + sequence: number; + recordedAt: string; + artifacts: PersistedSessionArtifact[]; + tombstonedIds?: string[]; + stickyEphemeralIds?: string[]; +} + +export interface RebuiltSessionArtifactSnapshot { + v: typeof SESSION_ARTIFACT_PERSISTENCE_VERSION; + sessionId: string; + sequence: number; + artifacts: PersistedSessionArtifact[]; + tombstonedIds: string[]; + stickyEphemeralIds: string[]; + warnings: string[]; +} + +export interface SessionArtifactChatRecordLike { + type?: unknown; + subtype?: unknown; + sessionId?: unknown; + systemPayload?: unknown; +} + +const ARTIFACT_RECORD_SUBTYPES = new Set([ + 'session_artifact_event', + 'session_artifact_snapshot', +]); + +export function isSessionArtifactRecord( + record: SessionArtifactChatRecordLike, +): boolean { + return ( + record.type === 'system' && + typeof record.subtype === 'string' && + ARTIFACT_RECORD_SUBTYPES.has(record.subtype) + ); +} + +export function stableSessionArtifactId( + sessionId: string, + identityKey: string, +): string { + return createHash('sha256') + .update(`${sessionId}:${identityKey}`) + .digest('hex') + .slice(0, 16); +} + +export function sessionArtifactIdentityKey( + artifact: Pick< + PersistedSessionArtifact, + 'workspacePath' | 'managedId' | 'url' + >, +): string | undefined { + if (artifact.workspacePath) return `workspace:${artifact.workspacePath}`; + if (artifact.managedId) return `managed:${artifact.managedId}`; + if (artifact.url) return `url:${artifact.url}`; + return undefined; +} + +export function rebuildSessionArtifactSnapshot( + records: readonly SessionArtifactChatRecordLike[], + fallbackSessionId?: string, +): RebuiltSessionArtifactSnapshot | undefined { + const artifacts = new Map(); + const tombstonedIds = new Set(); + const stickyEphemeralIds = new Set(); + const warnings: string[] = []; + let sequence = 0; + let sessionId = fallbackSessionId; + let sawRecord = false; + + for (const record of records) { + if (!isSessionArtifactRecord(record)) continue; + if (record.subtype === 'session_artifact_snapshot') { + const payload = normalizeSnapshotPayload(record.systemPayload, warnings); + if (!payload) continue; + sawRecord = true; + sessionId = payload.sessionId; + sequence = Math.max(sequence, payload.sequence); + artifacts.clear(); + tombstonedIds.clear(); + stickyEphemeralIds.clear(); + for (const id of payload.tombstonedIds ?? []) tombstonedIds.add(id); + for (const id of payload.stickyEphemeralIds ?? []) { + stickyEphemeralIds.add(id); + } + for (const artifact of payload.artifacts) { + if (artifact.retention === 'ephemeral') continue; + artifacts.set(artifact.id, artifact); + } + continue; + } + + const payload = normalizeEventPayload(record.systemPayload, warnings); + if (!payload) continue; + sawRecord = true; + sessionId = payload.sessionId; + sequence = Math.max(sequence, payload.sequence); + for (const change of payload.changes) { + if (change.action === 'removed') { + artifacts.delete(change.artifactId); + if (change.reason === 'explicit') { + tombstonedIds.add(change.artifactId); + } + if (change.reason === 'unpin_to_ephemeral') { + stickyEphemeralIds.add(change.artifactId); + } + continue; + } + if (!change.artifact || change.artifact.retention === 'ephemeral') { + continue; + } + artifacts.set(change.artifact.id, change.artifact); + tombstonedIds.delete(change.artifact.id); + stickyEphemeralIds.delete(change.artifact.id); + } + } + + if (!sawRecord || !sessionId) { + return undefined; + } + + return { + v: SESSION_ARTIFACT_PERSISTENCE_VERSION, + sessionId, + sequence, + artifacts: Array.from(artifacts.values()), + tombstonedIds: Array.from(tombstonedIds), + stickyEphemeralIds: Array.from(stickyEphemeralIds), + warnings, + }; +} + +export function remapSessionArtifactPayloadForFork( + payload: unknown, + sourceSessionId: string, + newSessionId: string, +): unknown { + const snapshot = normalizeSnapshotPayload(payload, []); + if (snapshot) { + return { + ...snapshot, + sessionId: newSessionId, + artifacts: snapshot.artifacts.map((artifact) => + remapSessionArtifactForFork(artifact, sourceSessionId, newSessionId), + ), + tombstonedIds: undefined, + stickyEphemeralIds: undefined, + } satisfies SessionArtifactSnapshotRecordPayload; + } + + const event = normalizeEventPayload(payload, []); + if (!event) return payload; + return { + ...event, + sessionId: newSessionId, + changes: event.changes + .map((change): SessionArtifactPersistedChange | undefined => { + if (change.artifact) { + const artifact = remapSessionArtifactForFork( + change.artifact, + sourceSessionId, + newSessionId, + ); + return { + ...change, + artifactId: artifact.id, + artifact, + }; + } + if (change.action === 'removed') return undefined; + return change; + }) + .filter((change) => change !== undefined), + } satisfies SessionArtifactEventRecordPayload; +} + +function remapSessionArtifactForFork( + artifact: PersistedSessionArtifact, + sourceSessionId: string, + newSessionId: string, +): PersistedSessionArtifact { + const identityKey = sessionArtifactIdentityKey(artifact); + const id = identityKey + ? stableSessionArtifactId(newSessionId, identityKey) + : stableSessionArtifactId( + newSessionId, + `fork:${sourceSessionId}:${artifact.id}`, + ); + const next: PersistedSessionArtifact = { + ...artifact, + id, + retention: + artifact.retention === 'pinned' ? 'restorable' : artifact.retention, + restoreState: 'restored', + persistenceWarning: 'metadata_only_restore', + }; + delete next.contentRef; + delete next.expiresAt; + return next; +} + +function normalizeSnapshotPayload( + value: unknown, + warnings: string[], +): SessionArtifactSnapshotRecordPayload | undefined { + if (!isRecord(value) || value['v'] !== SESSION_ARTIFACT_PERSISTENCE_VERSION) { + return undefined; + } + if (!Array.isArray(value['artifacts'])) return undefined; + const sessionId = getString(value, 'sessionId'); + if (!sessionId) return undefined; + const artifacts = value['artifacts'] + .map((artifact) => normalizePersistedArtifact(artifact, warnings)) + .filter((artifact) => artifact !== undefined); + return { + v: SESSION_ARTIFACT_PERSISTENCE_VERSION, + sessionId, + sequence: getNonNegativeInteger(value, 'sequence') ?? 0, + recordedAt: getString(value, 'recordedAt') ?? new Date(0).toISOString(), + artifacts, + tombstonedIds: getStringArray(value, 'tombstonedIds'), + stickyEphemeralIds: getStringArray(value, 'stickyEphemeralIds'), + }; +} + +function normalizeEventPayload( + value: unknown, + warnings: string[], +): SessionArtifactEventRecordPayload | undefined { + if (!isRecord(value) || value['v'] !== SESSION_ARTIFACT_PERSISTENCE_VERSION) { + return undefined; + } + if (!Array.isArray(value['changes'])) return undefined; + const sessionId = getString(value, 'sessionId'); + if (!sessionId) return undefined; + return { + v: SESSION_ARTIFACT_PERSISTENCE_VERSION, + sessionId, + sequence: getNonNegativeInteger(value, 'sequence') ?? 0, + recordedAt: getString(value, 'recordedAt') ?? new Date(0).toISOString(), + changes: value['changes'] + .map((change) => normalizePersistedChange(change, warnings)) + .filter((change) => change !== undefined), + }; +} + +function normalizePersistedChange( + value: unknown, + warnings: string[], +): SessionArtifactPersistedChange | undefined { + if (!isRecord(value)) return undefined; + const action = value['action']; + if (action !== 'created' && action !== 'updated' && action !== 'removed') { + return undefined; + } + const artifact = normalizePersistedArtifact(value['artifact'], warnings); + const artifactId = getString(value, 'artifactId') ?? artifact?.id; + if (!artifactId) return undefined; + const reason = value['reason']; + return { + action, + artifactId, + ...(artifact ? { artifact } : {}), + ...(reason === 'explicit' || + reason === 'eviction' || + reason === 'unpin_to_ephemeral' + ? { reason } + : {}), + }; +} + +function normalizePersistedArtifact( + value: unknown, + warnings: string[], +): PersistedSessionArtifact | undefined { + if (!isRecord(value)) return undefined; + const id = getString(value, 'id'); + const title = getString(value, 'title'); + if (!id || !title) { + warnings.push('skipped artifact without id/title'); + return undefined; + } + + const kind = normalizeLiteral(value['kind'], [ + 'file', + 'link', + 'html', + 'image', + 'video', + 'audio', + 'pdf', + 'notebook', + 'other', + ]); + const storage = normalizeLiteral( + value['storage'], + ['workspace', 'external_url', 'managed', 'published'], + ); + const source = normalizeLiteral( + value['source'], + ['tool', 'hook', 'client'], + ); + const status = + normalizeLiteral(value['status'], [ + 'available', + 'missing', + ]) ?? 'missing'; + const retention = + normalizeLiteral(value['retention'], [ + 'ephemeral', + 'restorable', + 'pinned', + ]) ?? 'restorable'; + if (!kind || !storage || !source) { + warnings.push(`skipped malformed artifact ${id}`); + return undefined; + } + + const metadata = normalizeMetadata(value['metadata']); + return { + id, + kind, + storage, + source, + status, + title, + ...(getString(value, 'description') + ? { description: getString(value, 'description') } + : {}), + ...(getString(value, 'workspacePath') + ? { workspacePath: getString(value, 'workspacePath') } + : {}), + ...(getString(value, 'managedId') + ? { managedId: getString(value, 'managedId') } + : {}), + ...(getString(value, 'url') ? { url: getString(value, 'url') } : {}), + ...(getString(value, 'mimeType') + ? { mimeType: getString(value, 'mimeType') } + : {}), + ...(getNonNegativeInteger(value, 'sizeBytes') !== undefined + ? { sizeBytes: getNonNegativeInteger(value, 'sizeBytes') } + : {}), + ...(metadata ? { metadata } : {}), + retention, + clientRetained: value['clientRetained'] === true, + createdAt: getString(value, 'createdAt') ?? new Date(0).toISOString(), + updatedAt: getString(value, 'updatedAt') ?? new Date(0).toISOString(), + ...(getString(value, 'persistedAt') + ? { persistedAt: getString(value, 'persistedAt') } + : {}), + ...(getString(value, 'expiresAt') + ? { expiresAt: getString(value, 'expiresAt') } + : {}), + ...(normalizeLiteral(value['restoreState'], [ + 'live', + 'restored', + 'unverified', + 'blocked', + ]) + ? { + restoreState: normalizeLiteral( + value['restoreState'], + ['live', 'restored', 'unverified', 'blocked'], + ), + } + : {}), + ...(normalizeLiteral( + value['persistenceWarning'], + [ + 'persistence_unavailable', + 'content_missing', + 'content_expired', + 'content_hash_mismatch', + 'metadata_only_restore', + 'restore_validation_failed', + ], + ) + ? { + persistenceWarning: value[ + 'persistenceWarning' + ] as SessionArtifactPersistenceWarning, + } + : {}), + ...(normalizeContentRef(value['contentRef']) + ? { contentRef: normalizeContentRef(value['contentRef']) } + : {}), + ...(getString(value, 'toolCallId') + ? { toolCallId: getString(value, 'toolCallId') } + : {}), + ...(getString(value, 'toolName') + ? { toolName: getString(value, 'toolName') } + : {}), + ...(getString(value, 'hookEventName') + ? { hookEventName: getString(value, 'hookEventName') } + : {}), + ...(getString(value, 'clientId') + ? { clientId: getString(value, 'clientId') } + : {}), + }; +} + +function normalizeContentRef( + value: unknown, +): SessionArtifactContentRef | undefined { + if (!isRecord(value) || value['kind'] !== 'managed_copy') return undefined; + const contentId = getString(value, 'contentId'); + const sha256 = getString(value, 'sha256'); + const sizeBytes = getNonNegativeInteger(value, 'sizeBytes'); + const createdAt = getString(value, 'createdAt'); + if (!contentId || !sha256 || sizeBytes === undefined || !createdAt) { + return undefined; + } + return { kind: 'managed_copy', contentId, sha256, sizeBytes, createdAt }; +} + +function normalizeMetadata( + value: unknown, +): Record | undefined { + if (!isRecord(value)) return undefined; + const normalized: Record = {}; + for (const [key, item] of Object.entries(value)) { + if ( + item === null || + typeof item === 'string' || + typeof item === 'number' || + typeof item === 'boolean' + ) { + normalized[key] = item; + } + } + if (Object.keys(normalized).length === 0) return undefined; + if (Buffer.byteLength(JSON.stringify(normalized), 'utf8') > 4096) { + return undefined; + } + return normalized; +} + +function normalizeLiteral( + value: unknown, + allowed: readonly T[], +): T | undefined { + return typeof value === 'string' && allowed.includes(value as T) + ? (value as T) + : undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function getString( + record: Record, + key: string, +): string | undefined { + const value = record[key]; + return typeof value === 'string' ? value : undefined; +} + +function getStringArray( + record: Record, + key: string, +): string[] | undefined { + const value = record[key]; + if (!Array.isArray(value)) return undefined; + const items = value.filter( + (item): item is string => typeof item === 'string', + ); + return items.length > 0 ? items : undefined; +} + +function getNonNegativeInteger( + record: Record, + key: string, +): number | undefined { + const value = record[key]; + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 + ? value + : undefined; +} diff --git a/packages/core/src/services/sessionService.ts b/packages/core/src/services/sessionService.ts index d9404b750ac..d720a17350c 100644 --- a/packages/core/src/services/sessionService.ts +++ b/packages/core/src/services/sessionService.ts @@ -35,6 +35,11 @@ import { readLastJsonStringFieldsSync, } from '../utils/sessionStorageUtils.js'; import { getUsageOutputTokenCountForPromptEstimate } from './tokenEstimation.js'; +import { + rebuildSessionArtifactSnapshot, + remapSessionArtifactPayloadForFork, + type RebuiltSessionArtifactSnapshot, +} from './session-artifact-persistence.js'; const debugLogger = createDebugLogger('SESSION'); @@ -178,6 +183,8 @@ export interface ResumedSessionData { lastCompletedUuid: string | null; /** Deserialized file history snapshots for resume (enables /rewind across sessions) */ fileHistorySnapshots?: FileHistorySnapshot[]; + /** Persisted session artifact metadata reconstructed from JSONL records. */ + artifactSnapshot?: RebuiltSessionArtifactSnapshot; } /** @@ -1014,6 +1021,10 @@ export class SessionService { fileHistorySnapshots.length > MAX_SNAPSHOTS ? fileHistorySnapshots.slice(-MAX_SNAPSHOTS) : fileHistorySnapshots; + const artifactSnapshot = rebuildSessionArtifactSnapshot( + messages, + firstRecord.sessionId, + ); return { conversation, @@ -1021,6 +1032,7 @@ export class SessionService { lastCompletedUuid: lastMessage.uuid, fileHistorySnapshots: cappedSnapshots.length > 0 ? cappedSnapshots : undefined, + ...(artifactSnapshot ? { artifactSnapshot } : {}), }; } @@ -1373,14 +1385,11 @@ export class SessionService { // message. let prevUuid: string | null = null; const forked: ChatRecord[] = sourceRecords.map((record) => { - const systemPayload = - record.type === 'system' && record.subtype === 'file_history_snapshot' - ? remapFileHistorySnapshotPayload( - record.systemPayload, - sourceSessionId, - newSessionId, - ) - : record.systemPayload; + const systemPayload = remapSystemPayloadForFork( + record, + sourceSessionId, + newSessionId, + ); const next: ChatRecord = { ...record, sessionId: newSessionId, @@ -1866,6 +1875,32 @@ function remapFileHistorySnapshotPayload( } } +function remapSystemPayloadForFork( + record: ChatRecord, + sourceSessionId: string, + newSessionId: string, +): ChatRecord['systemPayload'] { + if (record.type !== 'system') return record.systemPayload; + if (record.subtype === 'file_history_snapshot') { + return remapFileHistorySnapshotPayload( + record.systemPayload, + sourceSessionId, + newSessionId, + ); + } + if ( + record.subtype === 'session_artifact_event' || + record.subtype === 'session_artifact_snapshot' + ) { + return remapSessionArtifactPayloadForFork( + record.systemPayload, + sourceSessionId, + newSessionId, + ) as ChatRecord['systemPayload']; + } + return record.systemPayload; +} + function collectFileHistorySnapshotPromptIds( records: ChatRecord[], ): Set { diff --git a/packages/sdk-typescript/scripts/build.js b/packages/sdk-typescript/scripts/build.js index 1256d7091a2..af28194b494 100755 --- a/packages/sdk-typescript/scripts/build.js +++ b/packages/sdk-typescript/scripts/build.js @@ -41,7 +41,7 @@ const rootDir = join(__dirname, '..'); // workspace remember (managed memory client methods + event validation). // Bumped from 133KB to 135KB after merging both surfaces plus session artifact // APIs and event validation. -const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 135 * 1024; +const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 136 * 1024; // The opt-in `daemon/transports` browser bundle legitimately ships the concrete // ACP transports (AcpHttpTransport/AcpWsTransport/AutoReconnect + negotiate), so // it's larger than the default barrel — but still budgeted so a future PR can't diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts index 11edbf14be7..ba09f571053 100644 --- a/packages/sdk-typescript/src/daemon/DaemonClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -91,6 +91,8 @@ import type { DaemonSessionArtifactInput, DaemonSessionArtifactMutationResult, DaemonSessionArtifactsEnvelope, + DaemonSessionArtifactFsckResult, + DaemonSessionArtifactGcResult, DaemonRewindSnapshotInfo, DaemonRewindResult, ForkSessionRequest, @@ -3005,6 +3007,61 @@ export class DaemonClient { ); } + async pinSessionArtifact( + sessionId: string, + artifactId: string, + clientId?: string, + ): Promise { + return await this.jsonRequest( + `/session/${encodeURIComponent(sessionId)}/artifacts/${encodeURIComponent(artifactId)}/pin`, + 'POST /session/:id/artifacts/:artifactId/pin', + { + method: 'POST', + clientId, + }, + ); + } + + async unpinSessionArtifact( + sessionId: string, + artifactId: string, + clientId?: string, + ): Promise { + return await this.jsonRequest( + `/session/${encodeURIComponent(sessionId)}/artifacts/${encodeURIComponent(artifactId)}/pin`, + 'DELETE /session/:id/artifacts/:artifactId/pin', + { + method: 'DELETE', + clientId, + }, + ); + } + + async fsckSessionArtifacts( + sessionId: string, + clientId?: string, + ): Promise { + return await this.jsonRequest( + `/session/${encodeURIComponent(sessionId)}/artifacts/fsck`, + 'GET /session/:id/artifacts/fsck', + { clientId }, + ); + } + + async gcSessionArtifacts( + sessionId: string, + clientId?: string, + ): Promise { + return await this.jsonRequest( + `/session/${encodeURIComponent(sessionId)}/artifacts/gc`, + 'POST /session/:id/artifacts/gc', + { + method: 'POST', + clientId, + }, + ); + } + // -- Session metadata ---------------------------------------------------- /** diff --git a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts index b8f317f2977..1068ca423d3 100644 --- a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts @@ -31,6 +31,8 @@ import type { DaemonSessionRecapResult, DaemonShellCommandResult, DaemonSessionArtifactInput, + DaemonSessionArtifactFsckResult, + DaemonSessionArtifactGcResult, DaemonSessionArtifactMutationResult, DaemonSessionArtifactsEnvelope, DaemonSessionState, @@ -437,6 +439,37 @@ export class DaemonSessionClient { ); } + async pinArtifact( + artifactId: string, + ): Promise { + return await this.client.pinSessionArtifact( + this.sessionId, + artifactId, + this.clientId, + ); + } + + async unpinArtifact( + artifactId: string, + ): Promise { + return await this.client.unpinSessionArtifact( + this.sessionId, + artifactId, + this.clientId, + ); + } + + async fsckArtifacts(): Promise { + return await this.client.fsckSessionArtifacts( + this.sessionId, + this.clientId, + ); + } + + async gcArtifacts(): Promise { + return await this.client.gcSessionArtifacts(this.sessionId, this.clientId); + } + async setModel(modelId: string): Promise { return await this.client.setSessionModel( this.sessionId, diff --git a/packages/sdk-typescript/src/daemon/acpRouteTable.ts b/packages/sdk-typescript/src/daemon/acpRouteTable.ts index 2fe8ccc2854..267d2e5c88d 100644 --- a/packages/sdk-typescript/src/daemon/acpRouteTable.ts +++ b/packages/sdk-typescript/src/daemon/acpRouteTable.ts @@ -264,6 +264,48 @@ export const ROUTE_TABLE: readonly RouteEntry[] = [ }), }, }, + // POST /session/:id/artifacts/:artifactId/pin → _qwen/session/artifacts/pin + { + httpMethod: 'POST', + pattern: /^\/session\/([^/]+)\/artifacts\/([^/]+)\/pin$/, + mapping: { + method: '_qwen/session/artifacts/pin', + extractParams: (segs) => ({ + sessionId: segs[0], + artifactId: segs[1], + }), + }, + }, + // DELETE /session/:id/artifacts/:artifactId/pin → _qwen/session/artifacts/unpin + { + httpMethod: 'DELETE', + pattern: /^\/session\/([^/]+)\/artifacts\/([^/]+)\/pin$/, + mapping: { + method: '_qwen/session/artifacts/unpin', + extractParams: (segs) => ({ + sessionId: segs[0], + artifactId: segs[1], + }), + }, + }, + // GET /session/:id/artifacts/fsck → _qwen/session/artifacts/fsck + { + httpMethod: 'GET', + pattern: /^\/session\/([^/]+)\/artifacts\/fsck$/, + mapping: { + method: '_qwen/session/artifacts/fsck', + extractParams: (segs) => ({ sessionId: segs[0] }), + }, + }, + // POST /session/:id/artifacts/gc → _qwen/session/artifacts/gc + { + httpMethod: 'POST', + pattern: /^\/session\/([^/]+)\/artifacts\/gc$/, + mapping: { + method: '_qwen/session/artifacts/gc', + extractParams: (segs) => ({ sessionId: segs[0] }), + }, + }, // DELETE /session/:id/artifacts/:artifactId → _qwen/session/artifacts/remove { httpMethod: 'DELETE', diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index 0fad4728a15..701a6390276 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -271,6 +271,42 @@ export type KnownDaemonSessionArtifactStatus = 'available' | 'missing'; export type DaemonSessionArtifactStatus = OpenStringUnion; +export type KnownDaemonSessionArtifactRetention = + | 'ephemeral' + | 'restorable' + | 'pinned'; + +export type DaemonSessionArtifactRetention = + OpenStringUnion; + +export type KnownDaemonSessionArtifactRestoreState = + | 'live' + | 'restored' + | 'unverified' + | 'blocked'; + +export type DaemonSessionArtifactRestoreState = + OpenStringUnion; + +export type KnownDaemonSessionArtifactPersistenceWarning = + | 'persistence_unavailable' + | 'content_missing' + | 'content_expired' + | 'content_hash_mismatch' + | 'metadata_only_restore' + | 'restore_validation_failed'; + +export type DaemonSessionArtifactPersistenceWarning = + OpenStringUnion; + +export interface DaemonSessionArtifactContentRef { + kind: 'managed_copy'; + contentId: string; + sha256: string; + sizeBytes: number; + createdAt: string; +} + export interface DaemonSessionArtifactInput { kind?: KnownDaemonSessionArtifactKind; storage?: Exclude; @@ -282,6 +318,8 @@ export interface DaemonSessionArtifactInput { mimeType?: string; sizeBytes?: number; metadata?: Record; + retention?: Exclude; + clientRetained?: boolean; } export interface DaemonSessionArtifact { @@ -298,6 +336,12 @@ export interface DaemonSessionArtifact { mimeType?: string; sizeBytes?: number; metadata?: Record; + retention: DaemonSessionArtifactRetention; + restoreState?: DaemonSessionArtifactRestoreState; + persistenceWarning?: DaemonSessionArtifactPersistenceWarning; + contentRef?: DaemonSessionArtifactContentRef; + persistedAt?: string; + expiresAt?: string; clientRetained: boolean; createdAt: string; updatedAt: string; @@ -314,7 +358,10 @@ export type KnownDaemonSessionArtifactChangeAction = export type DaemonSessionArtifactChangeAction = OpenStringUnion; -export type KnownDaemonSessionArtifactRemovalReason = 'eviction' | 'explicit'; +export type KnownDaemonSessionArtifactRemovalReason = + | 'eviction' + | 'explicit' + | 'unpin_to_ephemeral'; export type DaemonSessionArtifactRemovalReason = OpenStringUnion; @@ -339,6 +386,18 @@ export interface DaemonSessionArtifactMutationResult { v: 1; sessionId: string; changes: DaemonSessionArtifactChange[]; + warnings?: string[]; +} + +export interface DaemonSessionArtifactFsckResult { + checked: number; + missing: string[]; + hashMismatches: string[]; +} + +export interface DaemonSessionArtifactGcResult { + removed: string[]; + retained: string[]; } export type DaemonStatus = diff --git a/packages/sdk-typescript/test/unit/DaemonClient.test.ts b/packages/sdk-typescript/test/unit/DaemonClient.test.ts index 840919ef3a4..991c11d7253 100644 --- a/packages/sdk-typescript/test/unit/DaemonClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonClient.test.ts @@ -269,6 +269,74 @@ describe('DaemonClient', () => { body: null, }); }); + + it('pins and unpins session artifacts with encoded ids', async () => { + const result = { + v: 1 as const, + sessionId: 'session/1', + changes: [{ action: 'updated' as const, artifactId: 'artifact/1' }], + }; + const { fetch, calls } = recordingFetch(() => jsonResponse(200, result)); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + + await expect( + client.pinSessionArtifact('session/1', 'artifact/1', 'client-1'), + ).resolves.toEqual(result); + await expect( + client.unpinSessionArtifact('session/1', 'artifact/1', 'client-1'), + ).resolves.toEqual(result); + + expect(calls[0]).toMatchObject({ + url: 'http://daemon/session/session%2F1/artifacts/artifact%2F1/pin', + method: 'POST', + headers: { 'x-qwen-client-id': 'client-1' }, + body: null, + }); + expect(calls[1]).toMatchObject({ + url: 'http://daemon/session/session%2F1/artifacts/artifact%2F1/pin', + method: 'DELETE', + headers: { 'x-qwen-client-id': 'client-1' }, + body: null, + }); + }); + + it('runs fsck and gc for session artifact content', async () => { + const replies = [ + { checked: 1, missing: ['missing'], hashMismatches: [] }, + { removed: ['old'], retained: ['kept'] }, + ]; + const { fetch, calls } = recordingFetch(() => + jsonResponse(200, replies.shift()), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + + await expect( + client.fsckSessionArtifacts('session/1', 'client-1'), + ).resolves.toEqual({ + checked: 1, + missing: ['missing'], + hashMismatches: [], + }); + await expect( + client.gcSessionArtifacts('session/1', 'client-1'), + ).resolves.toEqual({ + removed: ['old'], + retained: ['kept'], + }); + + expect(calls[0]).toMatchObject({ + url: 'http://daemon/session/session%2F1/artifacts/fsck', + method: 'GET', + headers: { 'x-qwen-client-id': 'client-1' }, + body: null, + }); + expect(calls[1]).toMatchObject({ + url: 'http://daemon/session/session%2F1/artifacts/gc', + method: 'POST', + headers: { 'x-qwen-client-id': 'client-1' }, + body: null, + }); + }); }); describe('workspace file helpers', () => { diff --git a/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts b/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts index f873846eafe..ce31c1d0f75 100644 --- a/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts @@ -472,6 +472,34 @@ describe('DaemonSessionClient', () => { ) { return jsonResponse(200, mutationResult); } + if ( + req.method === 'POST' && + req.url === 'http://daemon/session/s-1/artifacts/artifact-1/pin' + ) { + return jsonResponse(200, mutationResult); + } + if ( + req.method === 'DELETE' && + req.url === 'http://daemon/session/s-1/artifacts/artifact-1/pin' + ) { + return jsonResponse(200, mutationResult); + } + if ( + req.method === 'GET' && + req.url === 'http://daemon/session/s-1/artifacts/fsck' + ) { + return jsonResponse(200, { + checked: 1, + missing: [], + hashMismatches: [], + }); + } + if ( + req.method === 'POST' && + req.url === 'http://daemon/session/s-1/artifacts/gc' + ) { + return jsonResponse(200, { removed: [], retained: [] }); + } return jsonResponse(500, { error: `unexpected ${req.method} ${req.url}`, }); @@ -497,11 +525,30 @@ describe('DaemonSessionClient', () => { await expect(session.removeArtifact('artifact-1')).resolves.toEqual( mutationResult, ); + await expect(session.pinArtifact('artifact-1')).resolves.toEqual( + mutationResult, + ); + await expect(session.unpinArtifact('artifact-1')).resolves.toEqual( + mutationResult, + ); + await expect(session.fsckArtifacts()).resolves.toEqual({ + checked: 1, + missing: [], + hashMismatches: [], + }); + await expect(session.gcArtifacts()).resolves.toEqual({ + removed: [], + retained: [], + }); expect(calls.map((call) => call.headers['x-qwen-client-id'])).toEqual([ 'client-1', 'client-1', 'client-1', + 'client-1', + 'client-1', + 'client-1', + 'client-1', ]); expect(calls[1]?.body).toBe( JSON.stringify({ diff --git a/packages/sdk-typescript/test/unit/acpRouteTable.test.ts b/packages/sdk-typescript/test/unit/acpRouteTable.test.ts index a5eced81bd6..42aa6c87ee0 100644 --- a/packages/sdk-typescript/test/unit/acpRouteTable.test.ts +++ b/packages/sdk-typescript/test/unit/acpRouteTable.test.ts @@ -270,6 +270,42 @@ describe('acpRouteTable – matchRoute', () => { ).toEqual({ sessionId: 's8', artifactId: 'art 1' }); }); + it('POST /session/:id/artifacts/:artifactId/pin maps to _qwen/session/artifacts/pin', () => { + const result = matchRoute('/session/s8/artifacts/art%201/pin', 'POST'); + expect(result).not.toBeNull(); + expect(result!.mapping.method).toBe('_qwen/session/artifacts/pin'); + expect( + result!.mapping.extractParams(result!.segments, undefined, 'POST'), + ).toEqual({ sessionId: 's8', artifactId: 'art 1' }); + }); + + it('DELETE /session/:id/artifacts/:artifactId/pin maps to _qwen/session/artifacts/unpin', () => { + const result = matchRoute('/session/s8/artifacts/art%201/pin', 'DELETE'); + expect(result).not.toBeNull(); + expect(result!.mapping.method).toBe('_qwen/session/artifacts/unpin'); + expect( + result!.mapping.extractParams(result!.segments, undefined, 'DELETE'), + ).toEqual({ sessionId: 's8', artifactId: 'art 1' }); + }); + + it('GET /session/:id/artifacts/fsck maps to _qwen/session/artifacts/fsck', () => { + const result = matchRoute('/session/s8/artifacts/fsck', 'GET'); + expect(result).not.toBeNull(); + expect(result!.mapping.method).toBe('_qwen/session/artifacts/fsck'); + expect( + result!.mapping.extractParams(result!.segments, undefined, 'GET'), + ).toEqual({ sessionId: 's8' }); + }); + + it('POST /session/:id/artifacts/gc maps to _qwen/session/artifacts/gc', () => { + const result = matchRoute('/session/s8/artifacts/gc', 'POST'); + expect(result).not.toBeNull(); + expect(result!.mapping.method).toBe('_qwen/session/artifacts/gc'); + expect( + result!.mapping.extractParams(result!.segments, undefined, 'POST'), + ).toEqual({ sessionId: 's8' }); + }); + it('POST /session/:id/recap maps to _qwen/session/recap', () => { const result = matchRoute('/session/s9/recap', 'POST'); expect(result).not.toBeNull(); From e05ffac42cca030416dd62adcdad5b041a0c0982 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Sat, 4 Jul 2026 23:31:24 +0800 Subject: [PATCH 12/61] docs(daemon): address artifact persistence review --- ...session-artifacts-persistence-v2-design.md | 53 ++++++++++++------- 1 file changed, 33 insertions(+), 20 deletions(-) diff --git a/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md b/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md index 58c07d91c8a..96579e7da13 100644 --- a/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md +++ b/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md @@ -119,11 +119,11 @@ interface DaemonSessionArtifact { 字段说明: -- `retention`:artifact 的持久化级别。解析顺序为:请求体显式值优先;系统内部 artifact 按 daemon policy;client POST 未指定时使用用户配置的 `defaultRetention`;无配置时回退为 `restorable`。只有 persistence capability 未声明或读取 V1-era 记录时,才按 V1 兼容的 live-only 处理。V2 writer 写 journal 时必须 materialize `retention`,不能依赖 optional 缺省。 +- `retention`:artifact 的持久化级别。解析顺序为:请求体显式值优先;系统内部 artifact 按 §2.2 的 daemon 默认策略;client POST 未指定时使用用户配置的 `defaultRetention`;无配置时回退为 `restorable`。只有 persistence capability 未声明或读取 V1-era 记录时,才按 V1 兼容的 live-only 处理。V2 writer 写 journal 时必须 materialize `retention`,不能依赖 optional 缺省。 - `persistedAt`:metadata 或 content retention 最近成功落盘时间。 - `expiresAt`:可被 GC 的时间。`pinned` 默认不设置,但 `ttlDays` pin 会转换成绝对 `expiresAt`。 - `restoreState`:恢复来源提示;不替代 `status`。 -- `persistenceWarning`:非阻塞持久化/恢复风险,前端可用它提示“此 artifact 不会跨重启保留”等状态。warning code 必须覆盖实际降级原因,不能把 quota、budget、validation 和内容缺失都压成同一个模糊错误。`message` 必须来自 path-free 的固定模板或经过脱敏,不能包含 host 绝对路径、credential、token、内部 storage path 或 connection id。 +- `persistenceWarning`:非阻塞持久化/恢复风险,前端可用它提示“此 artifact 不会跨重启保留”等状态。warning code 必须覆盖实际降级原因,不能把 quota、budget、validation 和内容缺失都压成同一个模糊错误。`content_unavailable` 专用于 metadata 已恢复但 `contentRef` 的 manifest、storage 或 hash 校验不可用的情况;此时 artifact 仍可展示为 missing/non-openable,不能假装内容已保存。`message` 必须来自 path-free 的固定模板或经过脱敏,不能包含 host 绝对路径、credential、token、内部 storage path 或 connection id。 - `contentRef`:仅在内容保留或可信 published storage 时出现,不暴露宿主机绝对路径。 ### 3.2 Status 与 restoreState 的关系 @@ -178,6 +178,7 @@ Artifact persistence records 是 chat transcript 的一部分,必须遵循现 - JSONL append 只能通过拥有 `ChatRecordingService.appendRecord` 的进程或它暴露的明确 RPC 完成。daemon-side `SessionArtifactStore` 可以用 operation queue 协调 live state、SSE 和 persistence request 顺序,但不能自己打开并写 chat JSONL。 - 每条 `session_artifact_event` / `session_artifact_snapshot` 都必须作为普通 system `ChatRecord` 挂到当前 conversation leaf 上,并获得正常的 `uuid` / `parentUuid`。 +- chat tree builder 和 renderer 必须把 `session_artifact_*` system records 视为 side-effect records:它们参与 parent/leaf 顺序和 replay,但不渲染成用户可见 conversation node。最低支持旧版本加载包含 V2 record 的 JSONL 时也必须把未知 system subtype 当作 opaque/ignored side effect,而不是让 session load 失败。 - session load/replay 只应用 active leaf chain 中的 artifact records。被 `/rewind` 丢到 abandoned branch 的 artifact upsert/remove 不再影响当前 artifact list。 - `/rewind` 或任何 leaf switch 发生时,daemon-side live `SessionArtifactStore` 必须重新对齐新的 active-chain artifact state:要么从 active-chain replay result reseed,要么在 rewind 操作中向 surviving chain 写一条当前 artifact snapshot top-up。V2 默认采用 branch-scoped 语义;off-branch mutation 不应继续留在 live flat map 中等待下次重启才消失。 - fork/branch 只复制 active chain 中的 artifact records;off-chain records 不参与目标 session 的恢复。 @@ -276,7 +277,7 @@ type PersistedSessionArtifact = Pick< - 不写 `persistenceWarning` - 不写 `clientId` 或 live-process owner principal;`source` 只作为显示/审计 hint,不能用于授权 -删除 artifact 或 unpin 到 `ephemeral` 必须写 tombstone change,避免历史 replay 后被旧 upsert 复活。tombstone 不是永久禁止同一 id 再出现:它只覆盖自己之前的 upsert,直到之后出现更高 sequence 的显式 upsert。`reason: "unpin_to_ephemeral"` 是 sticky override:后续同一 artifact id 的隐式/default upsert 仍按 live-only 处理,只有用户或可信 client 显式请求 `retention: "restorable"` 或 pin/save 才能写入 superseding upsert。 +删除 artifact 或 unpin 到 `ephemeral` 必须写 tombstone change,避免历史 replay 后被旧 upsert 复活。tombstone 不是永久禁止同一 id 再出现:它只覆盖自己之前的 upsert,直到之后出现更高 sequence 的显式 upsert。`reason: "unpin_to_ephemeral"` 是 sticky override:后续同一 artifact id 的隐式/default upsert 仍按 live-only 处理,只有显式 upsert 才能 supersede。这里的“显式”只包括 UI action path、SDK `pin/save` helper、或经过认证的 REST/ACP mutate route 中显式传入 `retention: "restorable"` 的请求;tool/hook/background/default retention、restore backfill 和隐式 re-ingest 都不能 supersede sticky override。 sticky override 不能只存在于历史 tombstone event 中。snapshot writer 必须把尚未被显式 supersede 的 `unpin_to_ephemeral` 状态写入 `stickyEphemeralIds`;restore reader 先恢复 snapshot 中的 sticky set,再应用 snapshot 之后的 upsert/remove。否则 snapshot baseline advance 后旧 tombstone 不再需要 replay,sticky override 会丢失。 @@ -290,6 +291,7 @@ artifact snapshot 只用于减少 replay 的 artifact event 应用量;它不 - snapshot 是 authoritative current state:它只包含 snapshot 生成时仍有效的 artifacts。 - `tombstonedIds` 只记录 snapshot 之后仍需要覆盖旧 upsert 的 tombstones;被 snapshot 覆盖的旧 tombstones 不再进入新 snapshot payload,避免数组随历史无限增长。 - `stickyEphemeralIds` 记录当前仍处于 sticky ephemeral override 的 artifact id,即使对应旧 tombstone 已经不需要 replay,也必须保留该 override 状态。 +- `stickyEphemeralIds` 必须有界,默认和 persisted metadata 上限共享同一 `maxPersistedMetadata` 数量级,并计入 artifact journal working-set budget。unpin 到 `ephemeral` 若会超过 sticky set 上限,显式 API 必须返回错误并保持原 durable state;后台/restore prune 必须记录 warning 后稍后重试,不能静默增长、随机裁剪旧 sticky override,或让隐式 upsert 恢复持久化。 - snapshot 可以包含曾经被 tombstone 的 artifact id,前提是该 tombstone 已被更高 sequence 的显式 upsert supersede。 - load 时从新到旧选择最新 valid snapshot,然后只应用该 snapshot 之后的 artifact events。 - 如果最新 snapshot 解析失败,记录 `snapshot_invalid` warning,继续尝试上一个 valid snapshot;不能因为一个 corrupt snapshot 丢失整个 session 的 artifact metadata。 @@ -376,14 +378,14 @@ live store eviction 和 persisted metadata prune 必须分开: - V1/当前 live cap eviction:只影响 live view,不写 tombstone,不改变持久化 metadata。 - `quota_pruned` / `restore_pruned` / unpin-to-`ephemeral`:必须 durable-first 或 rollback。tombstone append 失败时保持 persisted state 不变;后台 prune 记录 structured warning 并稍后重试,显式 unpin API 返回错误。 -- 显式 DELETE:必须先从 live store 移除,避免磁盘满或 transcript 写失败时阻止用户隐藏敏感 artifact。随后 best-effort 写 tombstone;如果 append 失败,daemon 在当前进程内保留 pending tombstone 并重试,同时通过 API warning / structured log 表明“本次删除尚未 durable,daemon crash 后可能从历史恢复”。不能把这种状态报告成已持久删除。retry 必须有边界:默认指数退避从 1s 开始、上限 60s,总重试窗口 10 分钟或 10 次尝试,以先到者为准;全部失败后停止自动重试,保留 live warning / metric,要求用户在 storage 恢复后重新发起 DELETE。V2 不引入单独的 durable pending-delete queue,因为这会变成第二套持久化 source of truth。 -- 显式 DELETE 携带 `deleteContent: true` 时,content 删除必须等待 tombstone durable 后才能执行。live view 仍可先移除,但如果 tombstone append 失败,daemon 不得删除 managed/pinned content,response 必须同时暴露 `delete_not_durable` 和 `content_delete_preserved` warning。tombstone durable 后,content 删除仍需通过引用集合确认和 §7.1 授权;共享 contentRef 只能释放当前 artifact 引用,不能强删。 +- 显式 DELETE:默认必须 durable-first。先 append remove tombstone 并 fsync;成功后再从 live store 移除并发布删除事件。append 失败时返回 `PERSISTENCE_UNAVAILABLE` 或明确错误,artifact 继续留在 `GET /artifacts` 结果中,不能报告成已删除。若实现为了敏感 UX 选择 live-first,必须保留一个用户可见的 pending-delete item 或 session-level warning,并允许同一 artifact id 的幂等 DELETE 继续补写 tombstone;没有这种可见重试路径时不得 live-first。 +- 显式 DELETE 携带 `deleteContent: true` 时,content 删除必须等待 tombstone durable 后才能执行。默认 durable-first 失败时不得删除 managed/pinned content,response 必须包含 `content_delete_preserved` warning。tombstone durable 后,content 删除仍需通过引用集合确认和 §7.1 授权;共享 contentRef 只能释放当前 artifact 引用,不能强删。只有可见 pending-delete/live-first 实现才允许同时暴露 `delete_not_durable` 与 `content_delete_preserved`。 建议 warning: ```text [artifacts] session= action=persist_failed artifact= reason= -[artifacts] session= action=delete_not_durable artifact= +[artifacts] session= action=delete_not_durable artifact= mode=visible_pending_delete [artifacts] session= action=content_delete_preserved artifact= reason=tombstone_not_durable [artifacts] session= action=sticky_override_suppressed artifact= prior_reason=unpin_to_ephemeral ``` @@ -434,10 +436,14 @@ rewind/replay 中的 live store 处理必须和 load 一致:一旦 active leaf - tombstone 也要按目标 session 的新 id 重写。只要 tombstone 的 artifact id 可以安全 remap,就应保留到目标 session,即使目标 active chain 中暂时找不到对应 upsert;orphan tombstone 没有匹配 upsert 时是无害的,但丢弃它可能让后续同 id upsert 丢失 suppression。 - `forkedFrom` 可以记录原 session id / 原 artifact id,作为审计信息,但不能参与新 session 的权限判断。 - fork 继承 artifact metadata 时,`pinned` 必须降级为 `restorable`。fork 不继承 pinned contentRef,用户需要在 forked session 中显式 re-pin,避免通过 fork 绕过 session/project quota。 +- fork copy 必须重新执行 ingest/restore validation、privacy minimization 和 redaction。workspace / url / metadata / contentRef 中无法在目标 session 安全表达的 locator 必须降级、strip 或丢弃,不能因为源 session 曾经通过校验就直接复制。 +- `managedId` 不能从源 session 盲目复制。目标 session 中若能从目标 workspace / daemon-managed manifest 派生新的 `managedId`,必须重新计算;不能安全派生时必须移除 `managedId` 或丢弃该 artifact metadata。 fork remap 是发布门槛:如果某条路径不能安全重写 artifact id 和 tombstone,就必须在 fork 时丢弃 artifact persistence records,不能把源 session 的 artifact id 原样带入新 session。若现有 fork 实现有类似 `file_history_snapshot` 的 top-up 机制,artifact 也只能从 active-chain replay result 生成 top-up,不能从 daemon 当前 live store 原样补写,否则会把 rewind 后不再属于历史的 artifact 带入新 session。 -fork 写入目标 session 必须可检测部分失败。remap 开始前向目标 active chain 写 `session_artifact_fork_marker`,`phase: "begin"`,并带 `expectedArtifactCount`;所有 remapped artifact records 写完后再写 `phase: "complete"` 和 `writtenArtifactCount`。session load 如果看到 begin 但没有 complete,或 count 不匹配,必须记录 `fork_incomplete`,并把本次 fork 写入的 artifacts 标记为 `restoreState: "unverified"` 或丢弃该 fork batch;不能把部分 fork 当完整恢复结果。 +fork 写入目标 session 必须可检测部分失败。remap 开始前向目标 active chain 写 `session_artifact_fork_marker`,`phase: "begin"`,并带 `expectedArtifactCount`;所有 remapped artifact records 写完后再写 `phase: "complete"` 和 `writtenArtifactCount`。session load 如果看到 begin 但没有 complete,或 count 不匹配,必须记录 `fork_incomplete`,并丢弃该 fork batch;不能把部分 fork 标记为 `restoreState: "unverified"` 后继续展示,也不能把部分 fork 当完整恢复结果。 + +fork 的 rewind 语义是 branch-scoped:目标 session 只复制当前 active chain 的结果。如果用户 rewind 到显式 DELETE 之前再 fork,那个 DELETE tombstone 本来就不在 active chain 中,artifact 在新 branch 中重新出现是预期的历史分支行为。若产品需要“全局不可 rewind 删除”或隐私擦除语义,应作为单独的 policy 设计,不能混入 V2 默认 branch model。 metadata 的 fork amplification 在 V2 中作为有界 trade-off 接受:fork 需要 session mutate 权限,每个 fork 仍受 200 条 persisted metadata 上限,metadata 单条较小,且不会继承 pinned content bytes。V2 不引入 project-level metadata quota;实现必须记录 forked artifact count metric/log,若实际滥用再引入 project-level cap。content retention 仍受 session/project content quota 约束。 @@ -543,17 +549,17 @@ Body: - `retention` 可为 `restorable` 或 `ephemeral`,默认 `restorable`。 - `restorable`:删除 content retention,保留 metadata restore,并写 upsert event。该 upsert payload 必须移除 `contentRef` 和 `expiresAt`,避免恢复时引用已可被 GC 的旧 content。 -- `ephemeral`:删除 content retention,live store 中保留 artifact,但写 remove tombstone,确保下次 load 不复活。该 tombstone 对同一 artifact id 是 sticky override:后续隐式/default upsert 仍保持 live-only,直到显式 `retention: "restorable"` 或 pin/save 写入 superseding upsert。 +- `ephemeral`:删除 content retention,live store 中保留 artifact,但写 remove tombstone,确保下次 load 不复活。该 tombstone 对同一 artifact id 是 sticky override:后续隐式/default upsert 仍保持 live-only,直到 §4.3 定义的显式 `retention: "restorable"` 或 pin/save 写入 superseding upsert。 - 若要从列表移除,仍使用 V1 DELETE。 ### 6.5 Delete artifact V2 的 DELETE 仍保持 V1 幂等,但要追加 tombstone: -- 从 live store 移除。 -- best-effort 写 `session_artifact_event` remove tombstone。 -- tombstone 成功后,metadata restore 时不再复活。 -- tombstone 失败时,当前 live view 仍删除成功,但 response/log 必须暴露 `delete_not_durable` warning;daemon 在当前进程内继续重试 pending tombstone。若 daemon crash 前仍未落盘,历史 load 可能恢复该 artifact。 +- 默认先写并 fsync `session_artifact_event` remove tombstone。 +- tombstone 成功后,再从 live store 移除;metadata restore 时不再复活。 +- tombstone 失败时,返回明确错误,live view 保持 artifact 可见,用户可在 storage 恢复后重试 DELETE。 +- DELETE 对已经有 durable tombstone 的 artifact 保持幂等成功。若实现采用 §5.3 允许的 live-first pending-delete UX,后续同 id DELETE 必须继续尝试补写 tombstone;若既没有 live artifact 也没有 durable/pending tombstone,DELETE 可作为幂等 no-op 成功。 - 默认不立即同步删除 managed/pinned content,但会释放该 artifact 对 contentRef 的引用;GC 默认清理无其它 session 引用的 daemon-managed content。 可选 body 或独立 endpoint 支持内容删除: @@ -566,7 +572,7 @@ V2 的 DELETE 仍保持 V1 幂等,但要追加 tombstone: `deleteContent` 表示请求立即删除可删除内容,授权要求见 §7.1 的 delete content 规则:必须是显式 REST/SDK call、有 session mutate 权限、content retention capability 启用,并满足可验证 creator-principal match 或 admin override。共享 contentRef 只能在后台引用集合确认没有其它 session 仍引用后删除。 -`deleteContent: true` 不改变默认 DELETE 的 live-first UX,但会改变不可逆 content 删除的顺序:content bytes 只有在 tombstone durable 且引用集合确认无其它 session 仍引用后才能删除。如果 tombstone 未 durable,API 只能报告当前 live view 已删除、content preserved,不能报告 content delete 成功。 +`deleteContent: true` 不改变默认 DELETE 的 durable-first 顺序:content bytes 只有在 tombstone durable 且引用集合确认无其它 session 仍引用后才能删除。如果 tombstone 未 durable,API 必须返回错误并报告 content preserved,不能报告 content delete 成功。 ### 6.6 Mutation responses @@ -577,7 +583,7 @@ Pin、unpin 和 delete 必须使用一致的 mutation response,避免 client - Pin:`200 OK` 返回更新后的 `DaemonSessionArtifact`。 - Unpin:`200 OK` 返回更新后的 `DaemonSessionArtifact`;`retention: "restorable"` 时 `contentRef`/`expiresAt` 已移除,`retention: "ephemeral"` 时 response 可以返回当前 live-only artifact,并带 `persistenceWarning.code = "sticky_override_active"`。 - DELETE:`200 OK` 返回 `{ "deleted": true, "artifactId": string, "warnings"?: [...] }`。 -- `202 Accepted`:仅用于 DELETE live view 已移除但 tombstone 尚未 durable 的情况,body 必须包含 `warnings[].code = "delete_not_durable"`。如果 request 包含 `deleteContent: true`,还必须包含 `warnings[].code = "content_delete_preserved"`,说明 content 未删除且仍等待 durable tombstone / GC 引用确认。 +- `202 Accepted`:仅用于实现了 §5.3 可见 pending-delete/live-first UX 的情况,body 必须包含 `warnings[].code = "delete_not_durable"`。如果 request 包含 `deleteContent: true`,还必须包含 `warnings[].code = "content_delete_preserved"`,说明 content 未删除且仍等待 durable tombstone / GC 引用确认。默认 durable-first 实现不返回 202;tombstone append 失败返回错误并保持 artifact 可见。 失败: @@ -598,7 +604,7 @@ Pin、unpin 和 delete 必须使用一致的 mutation response,避免 client - `409 CONFLICT`:当前 artifact 状态不能完成请求,例如 content source 已不可用。 - `429 METADATA_QUOTA_EXCEEDED`:metadata slots 已满且没有可裁剪 candidate,阻止显式 restorable/pin/save。 - `429 QUOTA_EXCEEDED`:content quota 阻止显式 `mode: "content"` pin/save。 -- `503 PERSISTENCE_UNAVAILABLE`:writer 或 content storage 不可用,显式 pin/save/unpin 不能 durable 完成。 +- `503 PERSISTENCE_UNAVAILABLE`:writer 或 content storage 不可用,显式 pin/save/unpin/delete 不能 durable 完成。 ## 7. 安全设计 @@ -748,6 +754,7 @@ GC 只处理 daemon 管理的 content storage 和过期 metadata cache: - 删除已被 tombstone 且无其它 session 引用的 managed copy。 - 删除超过 `expiresAt` 的 non-pinned content。 - 删除超过 `expiresAt` 的 pinned content,并把对应 artifact 降级为 `restorable` 或 `missing`。该降级必须先写 durable artifact event:`action: "upsert"`、`reason: "ttl_expired"`,payload 移除 `contentRef` 和 `expiresAt`;append 成功后才能更新 live store 并让 content 进入可删除集合。append 失败时保守保留 content。 +- startup/timer/project GC 不能绕过 session writer。触发 TTL 降级或 metadata 变更前,GC 必须通过 load/attach session recording context 获取同一个 artifact persistence writer,并使用相同的锁、operation queue 和 durability 规则写入 `ttl_expired` event。writer 不可用、session 无法 attach、或 journal 不可写时,只能标记 expired pending metric/warning 并保留 content;不能先删 content,也不能写第二套 offline source of truth。 - session delete 后该 session 的 artifact journal 不再贡献 content 引用;默认删除无其它 session 引用的 content。只有用户显式导出到全局 artifact library 时,才在 session 删除后继续保留。 GC trigger: @@ -773,7 +780,7 @@ GC 必须 best-effort,不阻塞 prompt/tool flow。 - JSONL journal append 失败不会破坏 live store。 - explicit `pin/save metadata` 必须等待 journal 落盘。 - explicit `pin/save content` 必须等待 content manifest 和 journal 都落盘。 -- explicit DELETE 的 live removal 不等待 journal;tombstone append 失败时必须暴露 `delete_not_durable` 并按 §5.3 的 bounded retry 在当前进程内重试。 +- explicit DELETE 默认 durable-first:tombstone append 成功后才移除 live view。只有实现了 §5.3 可见 pending-delete/live-first UX 时,live removal 才能先于 journal,并且必须暴露 `delete_not_durable`、允许同 id retry、且不能报告为 durable delete。 - explicit DELETE with `deleteContent: true` 的 content 删除必须在 tombstone durable 之后执行;tombstone append 失败时 content 必须保留,并暴露 `content_delete_preserved`。 - live cap eviction 不写 tombstone;metadata quota prune 必须 durable-first。 - reader 容忍半截 JSONL 和 corrupt artifact record。 @@ -892,7 +899,7 @@ V2 新增的失败路径必须有 structured logs,格式沿用: - daemon bridge `createSessionEntry` 支持 seed artifacts。 - `SessionArtifactStore` 支持 seed artifacts。 - `upsertMany()` 在 operation queue 中计算 effective `retention`、quota prune 和 live view,再通过 writer append durable records。 -- `remove()` 区分 explicit DELETE、unpin-to-ephemeral、quota prune 和 live eviction;只有前三者写 tombstone,explicit DELETE 允许 live-first + pending tombstone retry。 +- `remove()` 区分 explicit DELETE、unpin-to-ephemeral、quota prune 和 live eviction;只有前三者写 tombstone。explicit DELETE 默认 durable-first;只有同时实现可见 pending-delete warning 和同 id retry 时,才允许 live-first。 - V1 live session 首次启用 V2 时执行 backfill snapshot;backfill 逐条 validation/minimization/materialization,坏记录跳过或降级,writer 不可用时保持 V1 live-only 并记录 warning。 - 保持 V1 `artifact_changed` event shape 不变,只增加 optional fields。 @@ -932,9 +939,12 @@ V2 新增的失败路径必须有 structured logs,格式沿用: - unpin 到 `ephemeral` 后 load 不复活 artifact。 - unpin 到 `ephemeral` 后,同一 artifact id 的隐式/default re-upsert 仍保持 live-only;显式 restorable/pin 可以 supersede sticky override。 - snapshot baseline advance 后 `stickyEphemeralIds` 仍能让隐式/default re-upsert 保持 live-only,并产生 `sticky_override_suppressed` log/metric/warning。 -- explicit DELETE 在 tombstone append 失败时仍先从 live view 移除,并暴露 `delete_not_durable`;daemon 重启前 tombstone retry 成功后 load 不复活。 -- `deleteContent: true` 在 tombstone append 失败时不删除 content,response 同时包含 `delete_not_durable` 和 `content_delete_preserved`。 +- `stickyEphemeralIds` 达到上限时,unpin-to-ephemeral 返回错误或延后重试,且不会静默丢失旧 sticky override。 +- explicit DELETE 默认 durable-first:tombstone append 失败时 live view 仍保留 artifact,并返回 `PERSISTENCE_UNAVAILABLE`;append 成功后 load 不复活。 +- 可选 live-first pending-delete 实现若存在,必须在 tombstone append 失败时保留可见 pending warning、允许同 id DELETE retry,并暴露 `delete_not_durable`。 +- `deleteContent: true` 在 tombstone append 失败时不删除 content,default durable-first 返回错误并包含 `content_delete_preserved`;可选 pending-delete 路径才同时包含 `delete_not_durable`。 - live cap eviction 不写 tombstone;quota prune、unpin-to-ephemeral、`restore_pruned` tombstone append 失败时不会改变 persisted metadata state。 +- journal working-set budget:snapshot bytes + post-snapshot event bytes 超限时触发 snapshot advance、自动 artifact 降级或显式 API 错误,不会无界追加 JSONL 工作集。 - workspace artifact ingest 和 restore 时文件存在/缺失/symlink escape 三种状态。 - workspace root 重定位:相同相对路径存在时恢复为 available;缺失或 layout 不一致时恢复为 missing;不做 path remap。 - pin/save content 时拒绝 symlink、special file、oversized stream、hardlink 异常和 TOCTOU swap。 @@ -942,14 +952,16 @@ V2 新增的失败路径必须有 structured logs,格式沿用: - secret-bearing URL query/fragment 与 metadata key/value 不写入 JSONL。 - published local `file:` 只有 trusted manifest revalidation 通过时恢复。 - stale/tampered `contentRef` 无法绕过 daemon-managed manifest、size 和 hash 校验。 +- `managedId` 在 ingest、restore 和 fork remap 时拒绝分隔符、`..`、绝对路径和路径形态;fork 不能盲目复制源 session 的 `managedId`。 - corrupt JSONL record 被跳过且不影响其它 artifacts。 - chat recording / persistence disabled 时不声明或不启用 metadata restore。 - pin/save 显式写失败时返回错误。 - tool artifact 持久化失败时降级为 live-only,并通过 `persistenceWarning` 让 client 可见。 - branch/fork 时 artifact records 的 sessionId/id 处理,且只使用 active-chain replay result。 -- fork begin/complete marker:partial fork crash、count mismatch 和 successful fork 三种路径。 +- fork begin/complete marker:partial fork crash、count mismatch 和 successful fork 三种路径;不完整或 count mismatch 的 fork batch 必须丢弃。 - fork 继承 pinned artifact 时降级为 restorable,不继承 contentRef。 - orphan tombstone 在 fork remap 时被保留并安全 remap;无法安全 remap 的 tombstone 才丢弃。 +- fork remap 重新执行 validation、privacy minimization 和 redaction;unsafe locator 被 strip、降级或丢弃。 - restore seed 与 concurrent POST 串行,不丢写、不重复。 - quota 边界:200 条、201 条 prune、pinned metadata、clientRetained/non-clientRetained 两层排序、全部 clientRetained restorable 仍可按确定性规则裁剪。 - clientRetained setter:Add artifact request 和 pin/save request 都能设置 boolean hint;后台自动 ingest 不能伪造用户保留。 @@ -958,6 +970,7 @@ V2 新增的失败路径必须有 structured logs,格式沿用: - GC concurrency:并发 sweep trigger 被 lease 阻止;持有 lease 的 sweep crash 后 lease 到期可恢复;daemon shutdown 中断 sweep 后下次启动继续保守扫描。 - TTL scan:session load 和周期 timer 都能发现过期 pinned content,记录 `ttl_scan_checked` 和 `artifact_content_expired_pending_bytes`。 - TTL 过期导致 pinned 降级时写 `ttl_expired` event,payload 移除 `contentRef` / `expiresAt`,append 失败时不删除 content。 +- startup/timer/project GC 在 writer 不可用或 session attach 失败时只记录 expired pending 并保留 content;获取 writer 后才写 `ttl_expired` event 和进入删除集合。 - authorization:token-holder/principal 审计路径允许和拒绝情况;V1 live same-principal guard 仅作为 live UX/audit hint,不作为 durable security boundary。 - restored artifact ownership_unverified fallback;deleteContent 在无 durable owner proof 时只释放引用,不立即强删共享 content。 - partial writes:journal 成功但 warning、content manifest 成功但 journal 失败、journal 成功后 restore 能找到 content。 From c9a33fd72391cb9991d06f6308d112b51280cf35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Sun, 5 Jul 2026 00:18:49 +0800 Subject: [PATCH 13/61] test(integration): update artifact capability baseline --- integration-tests/cli/qwen-serve-routes.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/integration-tests/cli/qwen-serve-routes.test.ts b/integration-tests/cli/qwen-serve-routes.test.ts index bed25c036df..fdb29cc10b7 100644 --- a/integration-tests/cli/qwen-serve-routes.test.ts +++ b/integration-tests/cli/qwen-serve-routes.test.ts @@ -248,6 +248,8 @@ describe('qwen serve — capabilities envelope', () => { 'session_cancel', 'session_events', 'session_artifacts', + 'session_artifacts_persistence', + 'session_artifacts_content_retention', 'slow_client_warning', 'typed_event_schema', 'session_set_model', From cb646c303b4192f21f2c14a31287ac7e287d159c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Sun, 5 Jul 2026 00:34:22 +0800 Subject: [PATCH 14/61] fix(daemon): harden artifact content retention --- packages/acp-bridge/src/bridge.ts | 7 + .../src/sessionArtifactContentStore.ts | 127 +++++++++++------- .../acp-bridge/src/sessionArtifacts.test.ts | 118 ++++++++++++++++ .../session-artifact-persistence.test.ts | 44 ++++++ .../services/session-artifact-persistence.ts | 111 ++++++--------- 5 files changed, 293 insertions(+), 114 deletions(-) diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index 3b67a71071a..0cdf470e308 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -4235,6 +4235,13 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { if (!entry) throw new SessionNotFoundError(sessionId); const clientId = resolveTrustedClientId(entry, context?.clientId); const result = await entry.artifacts.unpin(artifactId); + if (result.changes.length > 0) { + const refs = await entry.artifacts.contentRefs(); + await artifactContentStore.gc( + sessionId, + new Set(refs.map((ref) => ref.contentId)), + ); + } publishArtifactChanges(entry, result.changes, clientId); return result; }, diff --git a/packages/acp-bridge/src/sessionArtifactContentStore.ts b/packages/acp-bridge/src/sessionArtifactContentStore.ts index fec839dcc51..c065be039f7 100644 --- a/packages/acp-bridge/src/sessionArtifactContentStore.ts +++ b/packages/acp-bridge/src/sessionArtifactContentStore.ts @@ -41,6 +41,7 @@ export interface SessionArtifactGcResult { export class SessionArtifactContentStore { private readonly rootDir: string; + private writeQueue: Promise = Promise.resolve(); constructor(rootDir = defaultContentRoot()) { this.rootDir = rootDir; @@ -54,9 +55,10 @@ export class SessionArtifactContentStore { if (artifact.storage !== 'workspace' || !artifact.workspacePath) { return undefined; } + const workspacePath = artifact.workspacePath; const source = await resolveWorkspaceFile( workspaceCwd, - artifact.workspacePath, + workspacePath, ).catch((error: unknown) => { if (error instanceof SessionArtifactValidationError) { throw error; @@ -81,54 +83,76 @@ export class SessionArtifactContentStore { 'artifactId', ); } - const usedBytes = await this.usedBytes(); - if (usedBytes + sourceStat.size > MAX_CONTENT_STORE_BYTES) { - throw new SessionArtifactValidationError( - 'Artifact content quota exceeded', - 'artifactId', + + return this.enqueueWrite(async () => { + const tmpDir = path.join(this.rootDir, '.tmp'); + await fs.mkdir(tmpDir, { recursive: true, mode: 0o700 }); + let tmpPath: string | undefined = path.join( + tmpDir, + `${process.pid}-${Date.now()}-${artifact.id}.bin`, ); - } + try { + await fs.copyFile(source, tmpPath); + const { sha256, sizeBytes } = await hashFile(tmpPath); + if (sizeBytes > MAX_PINNED_FILE_BYTES) { + throw new SessionArtifactValidationError( + `Pinned artifact content exceeds ${MAX_PINNED_FILE_BYTES} bytes`, + 'artifactId', + ); + } - const tmpDir = path.join(this.rootDir, '.tmp'); - await fs.mkdir(tmpDir, { recursive: true, mode: 0o700 }); - const tmpPath = path.join( - tmpDir, - `${process.pid}-${Date.now()}-${artifact.id}.bin`, - ); - await fs.copyFile(source, tmpPath); - const { sha256, sizeBytes } = await hashFile(tmpPath); - const contentId = `${sha256}-${stableContentSuffix(sessionId, artifact.id)}`; - const contentDir = path.join(this.rootDir, contentId); - const dataPath = path.join(contentDir, 'content'); - await fs.mkdir(contentDir, { recursive: true, mode: 0o700 }); - if (await exists(dataPath)) { - await fs.rm(tmpPath, { force: true }); - } else { - await fs.rename(tmpPath, dataPath); - } - const createdAt = new Date().toISOString(); - const manifest: ContentManifest = { - v: CONTENT_FORMAT_VERSION, - contentId, - sessionId, - artifactId: artifact.id, - workspacePath: artifact.workspacePath, - sha256, - sizeBytes, - createdAt, - }; - await fs.writeFile( - path.join(contentDir, 'manifest.json'), - `${JSON.stringify(manifest)}\n`, - { mode: 0o600 }, - ); - return { - kind: 'managed_copy', - contentId, - sha256, - sizeBytes, - createdAt, - }; + const contentId = `${sha256}-${stableContentSuffix( + sessionId, + artifact.id, + )}`; + const contentDir = path.join(this.rootDir, contentId); + const dataPath = path.join(contentDir, 'content'); + if (await exists(dataPath)) { + await fs.rm(tmpPath, { force: true }); + tmpPath = undefined; + } else { + const usedBytes = await this.usedBytes(); + if (usedBytes + sizeBytes > MAX_CONTENT_STORE_BYTES) { + throw new SessionArtifactValidationError( + 'Artifact content quota exceeded', + 'artifactId', + ); + } + await fs.mkdir(contentDir, { recursive: true, mode: 0o700 }); + await fs.rename(tmpPath, dataPath); + tmpPath = undefined; + } + + const createdAt = new Date().toISOString(); + const manifest: ContentManifest = { + v: CONTENT_FORMAT_VERSION, + contentId, + sessionId, + artifactId: artifact.id, + workspacePath, + sha256, + sizeBytes, + createdAt, + }; + await fs.writeFile( + path.join(contentDir, 'manifest.json'), + `${JSON.stringify(manifest)}\n`, + { mode: 0o600 }, + ); + return { + kind: 'managed_copy', + contentId, + sha256, + sizeBytes, + createdAt, + }; + } catch (error) { + if (tmpPath) { + await fs.rm(tmpPath, { force: true }).catch(() => {}); + } + throw error; + } + }); } async fsck( @@ -217,6 +241,15 @@ export class SessionArtifactContentStore { } return total; } + + private enqueueWrite(operation: () => Promise): Promise { + const run = this.writeQueue.catch(() => {}).then(operation); + this.writeQueue = run.then( + () => undefined, + () => undefined, + ); + return run; + } } function defaultContentRoot(): string { diff --git a/packages/acp-bridge/src/sessionArtifacts.test.ts b/packages/acp-bridge/src/sessionArtifacts.test.ts index 1513c62db51..843e817f157 100644 --- a/packages/acp-bridge/src/sessionArtifacts.test.ts +++ b/packages/acp-bridge/src/sessionArtifacts.test.ts @@ -125,6 +125,35 @@ describe('SessionArtifactStore', () => { }); }); + it('returns pinned content refs for gc and fsck callers', async () => { + const store = new SessionArtifactStore({ + sessionId: 's1-content-refs', + workspaceCwd: workspace, + persistence: { + recordEvent: async () => {}, + recordSnapshot: async () => {}, + }, + }); + const created = await store.upsertMany( + [{ title: 'Report', url: 'https://example.com/report' }], + { strict: true }, + ); + const artifactId = created.changes[0]!.artifactId; + const contentRef = { + kind: 'managed_copy' as const, + contentId: 'content-ref-1', + sha256: 'a'.repeat(64), + sizeBytes: 12, + createdAt: '2026-07-04T00:00:00.000Z', + }; + + await store.pin(artifactId, contentRef); + await expect(store.contentRefs()).resolves.toEqual([contentRef]); + + await store.unpin(artifactId); + await expect(store.contentRefs()).resolves.toEqual([]); + }); + it('serializes concurrent store operations', async () => { const store = new SessionArtifactStore({ sessionId: 's1-queue', @@ -1951,6 +1980,23 @@ describe('SessionArtifactContentStore', () => { return (await store.list()).artifacts[0]!; } + async function writeQuotaManifest(contentId: string, sizeBytes: number) { + await fs.mkdir(path.join(contentRoot, contentId), { recursive: true }); + await fs.writeFile( + path.join(contentRoot, contentId, 'manifest.json'), + `${JSON.stringify({ + v: 1, + contentId, + sessionId: 'content-session', + artifactId: contentId, + workspacePath: `reports/${contentId}.txt`, + sha256: '0'.repeat(64), + sizeBytes, + createdAt: '2026-07-04T00:00:00.000Z', + })}\n`, + ); + } + it('copies pinned workspace content with a path-safe content id', async () => { const contentStore = new SessionArtifactContentStore(contentRoot); const artifact = await workspaceArtifact('report.txt', 'hello'); @@ -2000,6 +2046,78 @@ describe('SessionArtifactContentStore', () => { }); }); + it('reuses repeated pins even when the store is otherwise near quota', async () => { + const contentStore = new SessionArtifactContentStore(contentRoot); + const artifact = await workspaceArtifact('repeat-near-quota.txt', 'repeat'); + const first = (await contentStore.pinWorkspaceFile( + 'content-session', + artifact, + workspace, + ))!; + await writeQuotaManifest('quota-filler', 256 * 1024 * 1024); + + const second = (await contentStore.pinWorkspaceFile( + 'content-session', + artifact, + workspace, + ))!; + + expect(second.contentId).toBe(first.contentId); + }); + + it('rejects files over the per-artifact content limit before copying', async () => { + const contentStore = new SessionArtifactContentStore(contentRoot); + await fs.mkdir(path.join(workspace, 'reports'), { recursive: true }); + const oversizedPath = path.join(workspace, 'reports', 'oversized.bin'); + await fs.writeFile(oversizedPath, ''); + await fs.truncate(oversizedPath, 50 * 1024 * 1024 + 1); + const store = new SessionArtifactStore({ + sessionId: 'content-session', + workspaceCwd: workspace, + }); + await store.upsertMany( + [{ title: 'oversized.bin', workspacePath: 'reports/oversized.bin' }], + { strict: true }, + ); + const artifact = (await store.list()).artifacts[0]!; + + await expect( + contentStore.pinWorkspaceFile('content-session', artifact, workspace), + ).rejects.toThrow(SessionArtifactValidationError); + }); + + it('rejects new content over total quota and cleans up temporary files', async () => { + const contentStore = new SessionArtifactContentStore(contentRoot); + const artifact = await workspaceArtifact('over-quota.txt', 'new-content'); + await writeQuotaManifest('quota-filler', 256 * 1024 * 1024); + + await expect( + contentStore.pinWorkspaceFile('content-session', artifact, workspace), + ).rejects.toThrow(SessionArtifactValidationError); + await expect(fs.readdir(path.join(contentRoot, '.tmp'))).resolves.toEqual( + [], + ); + }); + + it('serializes quota checks across concurrent pins', async () => { + const contentStore = new SessionArtifactContentStore(contentRoot); + const first = await workspaceArtifact('concurrent-a.txt', 'abc'); + const second = await workspaceArtifact('concurrent-b.txt', 'def'); + await writeQuotaManifest('quota-filler', 256 * 1024 * 1024 - 3); + + const results = await Promise.allSettled([ + contentStore.pinWorkspaceFile('content-session', first, workspace), + contentStore.pinWorkspaceFile('content-session', second, workspace), + ]); + + expect( + results.filter((result) => result.status === 'fulfilled'), + ).toHaveLength(1); + expect( + results.filter((result) => result.status === 'rejected'), + ).toHaveLength(1); + }); + it('reports missing and hash-mismatched retained content', async () => { const contentStore = new SessionArtifactContentStore(contentRoot); const artifact = await workspaceArtifact('report.txt', 'hello'); diff --git a/packages/core/src/services/session-artifact-persistence.test.ts b/packages/core/src/services/session-artifact-persistence.test.ts index 6099d1b913d..8e6d0a75421 100644 --- a/packages/core/src/services/session-artifact-persistence.test.ts +++ b/packages/core/src/services/session-artifact-persistence.test.ts @@ -12,6 +12,7 @@ import { stableSessionArtifactId, type PersistedSessionArtifact, type SessionArtifactEventRecordPayload, + type SessionArtifactSnapshotRecordPayload, } from './session-artifact-persistence.js'; function artifact( @@ -169,4 +170,47 @@ describe('session artifact persistence records', () => { expect(forked).not.toHaveProperty('contentRef'); expect(forked).not.toHaveProperty('expiresAt'); }); + + it('remaps forked snapshot payloads and clears inherited tombstone state', () => { + const source = artifact('source-session', 'https://example.com/snapshot', { + retention: 'pinned', + contentRef: { + kind: 'managed_copy', + contentId: 'content-1', + sha256: 'a'.repeat(64), + sizeBytes: 12, + createdAt: '2026-07-04T00:00:00.000Z', + }, + expiresAt: '2026-08-01T00:00:00.000Z', + }); + + const remapped = remapSessionArtifactPayloadForFork( + { + v: SESSION_ARTIFACT_PERSISTENCE_VERSION, + sessionId: 'source-session', + sequence: 7, + recordedAt: '2026-07-04T00:00:00.000Z', + artifacts: [source], + tombstonedIds: ['deleted-in-source'], + stickyEphemeralIds: ['ephemeral-in-source'], + }, + 'source-session', + 'forked-session', + ) as SessionArtifactSnapshotRecordPayload; + + expect(remapped.sessionId).toBe('forked-session'); + expect(remapped.tombstonedIds).toBeUndefined(); + expect(remapped.stickyEphemeralIds).toBeUndefined(); + expect(remapped.artifacts[0]).toMatchObject({ + id: stableSessionArtifactId( + 'forked-session', + 'url:https://example.com/snapshot', + ), + retention: 'restorable', + restoreState: 'restored', + persistenceWarning: 'metadata_only_restore', + }); + expect(remapped.artifacts[0]).not.toHaveProperty('contentRef'); + expect(remapped.artifacts[0]).not.toHaveProperty('expiresAt'); + }); }); diff --git a/packages/core/src/services/session-artifact-persistence.ts b/packages/core/src/services/session-artifact-persistence.ts index 55bf7f4e549..8ecb0970ce0 100644 --- a/packages/core/src/services/session-artifact-persistence.ts +++ b/packages/core/src/services/session-artifact-persistence.ts @@ -432,6 +432,35 @@ function normalizePersistedArtifact( } const metadata = normalizeMetadata(value['metadata']); + const description = getString(value, 'description'); + const workspacePath = getString(value, 'workspacePath'); + const managedId = getString(value, 'managedId'); + const url = getString(value, 'url'); + const mimeType = getString(value, 'mimeType'); + const sizeBytes = getNonNegativeInteger(value, 'sizeBytes'); + const persistedAt = getString(value, 'persistedAt'); + const expiresAt = getString(value, 'expiresAt'); + const restoreState = normalizeLiteral( + value['restoreState'], + ['live', 'restored', 'unverified', 'blocked'], + ); + const persistenceWarning = + normalizeLiteral( + value['persistenceWarning'], + [ + 'persistence_unavailable', + 'content_missing', + 'content_expired', + 'content_hash_mismatch', + 'metadata_only_restore', + 'restore_validation_failed', + ], + ); + const contentRef = normalizeContentRef(value['contentRef']); + const toolCallId = getString(value, 'toolCallId'); + const toolName = getString(value, 'toolName'); + const hookEventName = getString(value, 'hookEventName'); + const clientId = getString(value, 'clientId'); return { id, kind, @@ -439,78 +468,26 @@ function normalizePersistedArtifact( source, status, title, - ...(getString(value, 'description') - ? { description: getString(value, 'description') } - : {}), - ...(getString(value, 'workspacePath') - ? { workspacePath: getString(value, 'workspacePath') } - : {}), - ...(getString(value, 'managedId') - ? { managedId: getString(value, 'managedId') } - : {}), - ...(getString(value, 'url') ? { url: getString(value, 'url') } : {}), - ...(getString(value, 'mimeType') - ? { mimeType: getString(value, 'mimeType') } - : {}), - ...(getNonNegativeInteger(value, 'sizeBytes') !== undefined - ? { sizeBytes: getNonNegativeInteger(value, 'sizeBytes') } - : {}), + ...(description ? { description } : {}), + ...(workspacePath ? { workspacePath } : {}), + ...(managedId ? { managedId } : {}), + ...(url ? { url } : {}), + ...(mimeType ? { mimeType } : {}), + ...(sizeBytes !== undefined ? { sizeBytes } : {}), ...(metadata ? { metadata } : {}), retention, clientRetained: value['clientRetained'] === true, createdAt: getString(value, 'createdAt') ?? new Date(0).toISOString(), updatedAt: getString(value, 'updatedAt') ?? new Date(0).toISOString(), - ...(getString(value, 'persistedAt') - ? { persistedAt: getString(value, 'persistedAt') } - : {}), - ...(getString(value, 'expiresAt') - ? { expiresAt: getString(value, 'expiresAt') } - : {}), - ...(normalizeLiteral(value['restoreState'], [ - 'live', - 'restored', - 'unverified', - 'blocked', - ]) - ? { - restoreState: normalizeLiteral( - value['restoreState'], - ['live', 'restored', 'unverified', 'blocked'], - ), - } - : {}), - ...(normalizeLiteral( - value['persistenceWarning'], - [ - 'persistence_unavailable', - 'content_missing', - 'content_expired', - 'content_hash_mismatch', - 'metadata_only_restore', - 'restore_validation_failed', - ], - ) - ? { - persistenceWarning: value[ - 'persistenceWarning' - ] as SessionArtifactPersistenceWarning, - } - : {}), - ...(normalizeContentRef(value['contentRef']) - ? { contentRef: normalizeContentRef(value['contentRef']) } - : {}), - ...(getString(value, 'toolCallId') - ? { toolCallId: getString(value, 'toolCallId') } - : {}), - ...(getString(value, 'toolName') - ? { toolName: getString(value, 'toolName') } - : {}), - ...(getString(value, 'hookEventName') - ? { hookEventName: getString(value, 'hookEventName') } - : {}), - ...(getString(value, 'clientId') - ? { clientId: getString(value, 'clientId') } - : {}), + ...(persistedAt ? { persistedAt } : {}), + ...(expiresAt ? { expiresAt } : {}), + ...(restoreState ? { restoreState } : {}), + ...(persistenceWarning ? { persistenceWarning } : {}), + ...(contentRef ? { contentRef } : {}), + ...(toolCallId ? { toolCallId } : {}), + ...(toolName ? { toolName } : {}), + ...(hookEventName ? { hookEventName } : {}), + ...(clientId ? { clientId } : {}), }; } From 07282a2483b5ebd13d3063c4e067980f6ce977d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Sun, 5 Jul 2026 00:49:28 +0800 Subject: [PATCH 15/61] fix(daemon): close artifact persistence safety gaps --- packages/acp-bridge/src/bridge.ts | 9 + .../src/sessionArtifactContentStore.ts | 84 +++++- .../acp-bridge/src/sessionArtifacts.test.ts | 259 +++++++++++++++++- packages/acp-bridge/src/sessionArtifacts.ts | 54 +++- .../session-artifact-persistence.test.ts | 27 ++ .../services/session-artifact-persistence.ts | 10 +- 6 files changed, 431 insertions(+), 12 deletions(-) diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index 0cdf470e308..d41d14b9b47 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -2923,6 +2923,15 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { activePromptCounter--; touchActivity(); } + try { + await artifactContentStore.gc(sessionId, new Set()); + } catch (error) { + writeStderrLine( + `qwen serve: session artifact GC failed during close for ${JSON.stringify( + sessionId, + )}: ${error instanceof Error ? error.message : String(error)}`, + ); + } byId.delete(sessionId); telemetry.metrics?.sessionLifecycle('close'); // Tombstone the closed sessionId so any late `extNotification` diff --git a/packages/acp-bridge/src/sessionArtifactContentStore.ts b/packages/acp-bridge/src/sessionArtifactContentStore.ts index c065be039f7..f0914f472e6 100644 --- a/packages/acp-bridge/src/sessionArtifactContentStore.ts +++ b/packages/acp-bridge/src/sessionArtifactContentStore.ts @@ -16,6 +16,7 @@ import { SessionArtifactValidationError } from './sessionArtifacts.js'; const CONTENT_FORMAT_VERSION = 1; const MAX_PINNED_FILE_BYTES = 50 * 1024 * 1024; const MAX_CONTENT_STORE_BYTES = 256 * 1024 * 1024; +const CONTENT_ID_PATTERN = /^[0-9a-f]{64}-[0-9a-f]{16}$/; interface ContentManifest { v: typeof CONTENT_FORMAT_VERSION; @@ -134,11 +135,7 @@ export class SessionArtifactContentStore { sizeBytes, createdAt, }; - await fs.writeFile( - path.join(contentDir, 'manifest.json'), - `${JSON.stringify(manifest)}\n`, - { mode: 0o600 }, - ); + await writeManifestAtomic(contentDir, manifest); return { kind: 'managed_copy', contentId, @@ -161,6 +158,10 @@ export class SessionArtifactContentStore { const missing: string[] = []; const hashMismatches: string[] = []; for (const ref of contentRefs) { + if (!isValidContentId(ref.contentId)) { + missing.push(ref.contentId); + continue; + } const dataPath = path.join(this.rootDir, ref.contentId, 'content'); try { const { sha256 } = await hashFile(dataPath); @@ -194,7 +195,10 @@ export class SessionArtifactContentStore { throw error; } for (const entry of entries) { - if (entry === '.tmp') continue; + if (entry === '.tmp') { + await cleanTmpDir(path.join(this.rootDir, entry)); + continue; + } const fullPath = path.join(this.rootDir, entry); if (referencedContentIds.has(entry)) { retained.push(entry); @@ -236,7 +240,14 @@ export class SessionArtifactContentStore { ); total += manifest.sizeBytes; } catch { - // Malformed manifests are handled by fsck/GC; ignore them for quota. + try { + const stat = await fs.stat(path.join(this.rootDir, entry, 'content')); + if (stat.isFile()) { + total += stat.size; + } + } catch { + // Malformed manifests are handled by fsck/GC. + } } } return total; @@ -332,7 +343,64 @@ function hashFile( async function readManifest(filePath: string): Promise { const body = await fs.readFile(filePath, 'utf8'); - return JSON.parse(body) as ContentManifest; + const parsed = JSON.parse(body) as Partial; + if ( + parsed.v !== CONTENT_FORMAT_VERSION || + !parsed.contentId || + !isValidContentId(parsed.contentId) || + typeof parsed.sessionId !== 'string' || + typeof parsed.artifactId !== 'string' || + typeof parsed.workspacePath !== 'string' || + typeof parsed.sha256 !== 'string' || + !/^[0-9a-f]{64}$/.test(parsed.sha256) || + typeof parsed.sizeBytes !== 'number' || + !Number.isSafeInteger(parsed.sizeBytes) || + parsed.sizeBytes < 0 || + typeof parsed.createdAt !== 'string' + ) { + throw new Error('Invalid artifact content manifest'); + } + return parsed as ContentManifest; +} + +async function writeManifestAtomic( + contentDir: string, + manifest: ContentManifest, +): Promise { + const tmpPath = path.join( + contentDir, + `.manifest-${process.pid}-${Date.now()}.json.tmp`, + ); + try { + await fs.writeFile(tmpPath, `${JSON.stringify(manifest)}\n`, { + mode: 0o600, + }); + await fs.rename(tmpPath, path.join(contentDir, 'manifest.json')); + } catch (error) { + await fs.rm(tmpPath, { force: true }).catch(() => undefined); + throw error; + } +} + +async function cleanTmpDir(tmpDir: string): Promise { + let entries: string[]; + try { + entries = await fs.readdir(tmpDir); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return; + } + throw error; + } + await Promise.all( + entries.map((entry) => + fs.rm(path.join(tmpDir, entry), { recursive: true, force: true }), + ), + ); +} + +function isValidContentId(contentId: string): boolean { + return CONTENT_ID_PATTERN.test(contentId); } async function exists(filePath: string): Promise { diff --git a/packages/acp-bridge/src/sessionArtifacts.test.ts b/packages/acp-bridge/src/sessionArtifacts.test.ts index 843e817f157..66022c92bf5 100644 --- a/packages/acp-bridge/src/sessionArtifacts.test.ts +++ b/packages/acp-bridge/src/sessionArtifacts.test.ts @@ -154,6 +154,119 @@ describe('SessionArtifactStore', () => { await expect(store.contentRefs()).resolves.toEqual([]); }); + it('pins and unpins artifact retention state', async () => { + const events: SessionArtifactEventRecordPayload[] = []; + const store = new SessionArtifactStore({ + sessionId: 's1-pin', + workspaceCwd: workspace, + persistence: { + recordEvent: async (payload) => { + events.push(payload); + }, + recordSnapshot: async () => {}, + }, + }); + const created = await store.upsertMany( + [{ title: 'Report', url: 'https://example.com/report' }], + { strict: true }, + ); + const artifactId = created.changes[0]!.artifactId; + const contentRef = { + kind: 'managed_copy' as const, + contentId: `${'a'.repeat(64)}-${'b'.repeat(16)}`, + sha256: 'a'.repeat(64), + sizeBytes: 12, + createdAt: '2026-07-04T00:00:00.000Z', + }; + + await expect(store.pin('missing', contentRef)).resolves.toMatchObject({ + changes: [], + }); + + const pinned = await store.pin(artifactId, contentRef); + expect(pinned.changes[0]?.artifact).toMatchObject({ + retention: 'pinned', + clientRetained: true, + contentRef, + }); + expect(pinned.changes[0]?.artifact).not.toHaveProperty( + 'persistenceWarning', + ); + + const unpinned = await store.unpin(artifactId); + expect(unpinned.changes[0]?.artifact).toMatchObject({ + retention: 'restorable', + persistenceWarning: 'metadata_only_restore', + }); + expect(unpinned.changes[0]?.artifact).not.toHaveProperty('contentRef'); + expect(events.map((event) => event.sequence)).toEqual([1, 2, 3]); + }); + + it('marks metadata-only pin when no content ref is available', async () => { + const store = new SessionArtifactStore({ + sessionId: 's1-pin-metadata', + workspaceCwd: workspace, + persistence: { + recordEvent: async () => {}, + recordSnapshot: async () => {}, + }, + }); + const created = await store.upsertMany( + [{ title: 'Report', url: 'https://example.com/report' }], + { strict: true }, + ); + const artifactId = created.changes[0]!.artifactId; + + await expect(store.pin(artifactId)).resolves.toMatchObject({ + changes: [ + { + artifact: { + retention: 'pinned', + persistenceWarning: 'metadata_only_restore', + }, + }, + ], + }); + }); + + it('rolls back pin mutations when persistence fails', async () => { + let fail = false; + const store = new SessionArtifactStore({ + sessionId: 's1-pin-rollback', + workspaceCwd: workspace, + persistence: { + recordEvent: async () => { + if (fail) throw new Error('persist failed'); + }, + recordSnapshot: async () => {}, + }, + }); + const created = await store.upsertMany( + [{ title: 'Report', url: 'https://example.com/report' }], + { strict: true }, + ); + const artifactId = created.changes[0]!.artifactId; + fail = true; + + await expect( + store.pin(artifactId, { + kind: 'managed_copy', + contentId: `${'a'.repeat(64)}-${'b'.repeat(16)}`, + sha256: 'a'.repeat(64), + sizeBytes: 12, + createdAt: '2026-07-04T00:00:00.000Z', + }), + ).rejects.toThrow('persist failed'); + await expect(store.list()).resolves.toMatchObject({ + artifacts: [ + { + id: artifactId, + retention: 'restorable', + }, + ], + }); + }); + it('serializes concurrent store operations', async () => { const store = new SessionArtifactStore({ sessionId: 's1-queue', @@ -586,6 +699,34 @@ describe('SessionArtifactStore', () => { ).toEqual([second.changes[0]?.artifactId, overflow.changes[0]?.artifactId]); }); + it('does not evict existing pinned artifacts as a fallback candidate', async () => { + const store = new SessionArtifactStore({ + sessionId: 's3-pinned-eviction', + workspaceCwd: workspace, + maxArtifacts: 2, + persistence: { + recordEvent: async () => {}, + recordSnapshot: async () => {}, + }, + }); + const created = await store.upsertMany( + [ + { title: 'Pinned', url: 'https://example.com/pinned' }, + { title: 'Ordinary', url: 'https://example.com/ordinary' }, + ], + { strict: true }, + ); + await store.pin(created.changes[0]!.artifactId); + + await store.upsertMany([{ title: 'New', url: 'https://example.com/new' }], { + strict: true, + }); + + expect( + (await store.list()).artifacts.map((artifact) => artifact.title), + ).toEqual(['Pinned', 'New']); + }); + it('drops newest artifacts created in the same batch when no older eviction candidate exists', async () => { const store = new SessionArtifactStore({ sessionId: 's3-same-batch-overflow', @@ -1845,6 +1986,46 @@ describe('SessionArtifactStore', () => { expect(events[50]).toMatchObject({ sequence: 52 }); }); + it('records durable tombstones in periodic snapshots', async () => { + const snapshots: SessionArtifactSnapshotRecordPayload[] = []; + const store = new SessionArtifactStore({ + sessionId: 's11-tombstone-snapshot', + workspaceCwd: workspace, + persistence: { + recordEvent: async () => {}, + recordSnapshot: async (payload) => { + snapshots.push(payload); + }, + }, + }); + const created = await store.upsertMany( + [{ title: 'Deleted', url: 'https://example.com/deleted' }], + { strict: true }, + ); + const deletedId = created.changes[0]!.artifactId; + await store.remove(deletedId); + for (let index = 0; index < 48; index++) { + await store.upsertMany( + [ + { + title: `Durable ${index}`, + url: `https://example.com/tombstone-${index}`, + }, + ], + { strict: true }, + ); + } + + expect(snapshots).toHaveLength(1); + expect(snapshots[0]).toMatchObject({ + tombstonedIds: [deletedId], + stickyEphemeralIds: [], + }); + expect( + snapshots[0]?.artifacts.some((artifact) => artifact.id === deletedId), + ).toBe(false); + }); + it('downgrades non-strict durable artifacts when persistence is unavailable', async () => { const store = new SessionArtifactStore({ sessionId: 's11-unavailable', @@ -1980,7 +2161,17 @@ describe('SessionArtifactContentStore', () => { return (await store.list()).artifacts[0]!; } - async function writeQuotaManifest(contentId: string, sizeBytes: number) { + function fakeContentId(label: string): string { + return `${createHash('sha256').update(label).digest('hex')}-${createHash( + 'sha256', + ) + .update(`suffix:${label}`) + .digest('hex') + .slice(0, 16)}`; + } + + async function writeQuotaManifest(label: string, sizeBytes: number) { + const contentId = fakeContentId(label); await fs.mkdir(path.join(contentRoot, contentId), { recursive: true }); await fs.writeFile( path.join(contentRoot, contentId, 'manifest.json'), @@ -1995,6 +2186,7 @@ describe('SessionArtifactContentStore', () => { createdAt: '2026-07-04T00:00:00.000Z', })}\n`, ); + return contentId; } it('copies pinned workspace content with a path-safe content id', async () => { @@ -2086,6 +2278,42 @@ describe('SessionArtifactContentStore', () => { ).rejects.toThrow(SessionArtifactValidationError); }); + it('rejects non-regular workspace files for content retention', async () => { + const contentStore = new SessionArtifactContentStore(contentRoot); + await fs.mkdir(path.join(workspace, 'reports'), { recursive: true }); + const artifact = { + ...(await workspaceArtifact('regular.txt', 'regular')), + workspacePath: 'reports', + }; + + await expect( + contentStore.pinWorkspaceFile('content-session', artifact, workspace), + ).rejects.toThrow(SessionArtifactValidationError); + }); + + it('rejects workspace symlinks that escape before copying content', async () => { + const contentStore = new SessionArtifactContentStore(contentRoot); + const outside = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-outside-')); + try { + await fs.writeFile(path.join(outside, 'secret.txt'), 'secret'); + await fs.mkdir(path.join(workspace, 'reports'), { recursive: true }); + await fs.symlink( + path.join(outside, 'secret.txt'), + path.join(workspace, 'reports', 'link.txt'), + ); + const artifact = { + ...(await workspaceArtifact('regular.txt', 'regular')), + workspacePath: 'reports/link.txt', + }; + + await expect( + contentStore.pinWorkspaceFile('content-session', artifact, workspace), + ).rejects.toThrow(SessionArtifactValidationError); + } finally { + await fs.rm(outside, { recursive: true, force: true }); + } + }); + it('rejects new content over total quota and cleans up temporary files', async () => { const contentStore = new SessionArtifactContentStore(contentRoot); const artifact = await workspaceArtifact('over-quota.txt', 'new-content'); @@ -2099,6 +2327,35 @@ describe('SessionArtifactContentStore', () => { ); }); + it('counts malformed manifest content files against total quota', async () => { + const contentStore = new SessionArtifactContentStore(contentRoot); + const artifact = await workspaceArtifact('malformed-quota.txt', 'data'); + const contentId = fakeContentId('malformed-quota'); + const contentDir = path.join(contentRoot, contentId); + await fs.mkdir(contentDir, { recursive: true }); + await fs.writeFile( + path.join(contentDir, 'manifest.json'), + '{"sizeBytes":"bad"}\n', + ); + await fs.writeFile(path.join(contentDir, 'content'), ''); + await fs.truncate(path.join(contentDir, 'content'), 256 * 1024 * 1024); + + await expect( + contentStore.pinWorkspaceFile('content-session', artifact, workspace), + ).rejects.toThrow(SessionArtifactValidationError); + }); + + it('cleans stale temporary content files during gc', async () => { + const contentStore = new SessionArtifactContentStore(contentRoot); + const tmpDir = path.join(contentRoot, '.tmp'); + await fs.mkdir(tmpDir, { recursive: true }); + await fs.writeFile(path.join(tmpDir, 'stale.bin'), 'stale'); + + await contentStore.gc('content-session', new Set()); + + await expect(fs.readdir(tmpDir)).resolves.toEqual([]); + }); + it('serializes quota checks across concurrent pins', async () => { const contentStore = new SessionArtifactContentStore(contentRoot); const first = await workspaceArtifact('concurrent-a.txt', 'abc'); diff --git a/packages/acp-bridge/src/sessionArtifacts.ts b/packages/acp-bridge/src/sessionArtifacts.ts index d93762ae217..283cb4e4f7a 100644 --- a/packages/acp-bridge/src/sessionArtifacts.ts +++ b/packages/acp-bridge/src/sessionArtifacts.ts @@ -190,6 +190,8 @@ export class SessionArtifactStore { private durableEventsSinceSnapshot = 0; private realWorkspaceCwdPromise?: Promise; private operationQueue: Promise = Promise.resolve(); + private readonly tombstonedIds = new Set(); + private readonly stickyEphemeralIds = new Set(); constructor(options: SessionArtifactStoreOptions) { this.sessionId = options.sessionId; @@ -528,8 +530,16 @@ export class SessionArtifactStore { return this.enqueue(async () => { const warnings = [...snapshot.warnings]; this.artifacts.clear(); + this.tombstonedIds.clear(); + this.stickyEphemeralIds.clear(); this.insertSeq = 0; this.persistenceSeq = snapshot.sequence; + for (const id of snapshot.tombstonedIds) { + this.tombstonedIds.add(id); + } + for (const id of snapshot.stickyEphemeralIds) { + this.stickyEphemeralIds.add(id); + } for (const artifact of snapshot.artifacts) { try { const input = persistedArtifactToInput(artifact); @@ -578,6 +588,8 @@ export class SessionArtifactStore { insertSeq: number; persistenceSeq: number; durableEventsSinceSnapshot: number; + tombstonedIds: Set; + stickyEphemeralIds: Set; } { return { artifacts: new Map( @@ -589,6 +601,8 @@ export class SessionArtifactStore { insertSeq: this.insertSeq, persistenceSeq: this.persistenceSeq, durableEventsSinceSnapshot: this.durableEventsSinceSnapshot, + tombstonedIds: new Set(this.tombstonedIds), + stickyEphemeralIds: new Set(this.stickyEphemeralIds), }; } @@ -597,6 +611,8 @@ export class SessionArtifactStore { insertSeq: number; persistenceSeq: number; durableEventsSinceSnapshot: number; + tombstonedIds: Set; + stickyEphemeralIds: Set; }): void { this.artifacts.clear(); for (const [id, artifact] of state.artifacts) { @@ -605,6 +621,14 @@ export class SessionArtifactStore { this.insertSeq = state.insertSeq; this.persistenceSeq = state.persistenceSeq; this.durableEventsSinceSnapshot = state.durableEventsSinceSnapshot; + this.tombstonedIds.clear(); + for (const id of state.tombstonedIds) { + this.tombstonedIds.add(id); + } + this.stickyEphemeralIds.clear(); + for (const id of state.stickyEphemeralIds) { + this.stickyEphemeralIds.add(id); + } } private async persistChanges( @@ -631,7 +655,12 @@ export class SessionArtifactStore { const stored = this.artifacts.get(change.artifactId); if (!stored) continue; stored.persistedAt = recordedAt; - delete stored.persistenceWarning; + if ( + stored.contentRef || + stored.persistenceWarning !== 'metadata_only_restore' + ) { + delete stored.persistenceWarning; + } change.artifact = toPublicArtifact(stored); } @@ -647,6 +676,7 @@ export class SessionArtifactStore { try { await this.persistence.recordEvent(payload); + this.applyDurableMarkers(durableChanges); await this.maybeRecordSnapshot(recordedAt); return []; } catch (error) { @@ -681,6 +711,8 @@ export class SessionArtifactStore { sequence: ++this.persistenceSeq, recordedAt, artifacts, + tombstonedIds: Array.from(this.tombstonedIds), + stickyEphemeralIds: Array.from(this.stickyEphemeralIds), }; try { await this.persistence.recordSnapshot(payload); @@ -710,6 +742,24 @@ export class SessionArtifactStore { ]; } + private applyDurableMarkers(changes: readonly SessionArtifactChange[]): void { + for (const change of changes) { + if (change.action === 'removed') { + if (change.reason === 'explicit') { + this.tombstonedIds.add(change.artifactId); + this.stickyEphemeralIds.delete(change.artifactId); + } else if (change.reason === 'unpin_to_ephemeral') { + this.stickyEphemeralIds.add(change.artifactId); + } + continue; + } + if (change.artifact && change.artifact.retention !== 'ephemeral') { + this.tombstonedIds.delete(change.artifactId); + this.stickyEphemeralIds.delete(change.artifactId); + } + } + } + private enqueue(operation: () => Promise): Promise { const result = this.operationQueue.then(operation, operation); this.operationQueue = result.then( @@ -1290,7 +1340,7 @@ function selectEvictionCandidate( SOURCE_RESERVATIONS[artifact.retentionSource], ) ?? oldest(candidates, (artifact) => !artifact.clientRetained) ?? - oldest(candidates) + oldest(candidates, (artifact) => artifact.retention !== 'pinned') ); } diff --git a/packages/core/src/services/session-artifact-persistence.test.ts b/packages/core/src/services/session-artifact-persistence.test.ts index 8e6d0a75421..989b78bae07 100644 --- a/packages/core/src/services/session-artifact-persistence.test.ts +++ b/packages/core/src/services/session-artifact-persistence.test.ts @@ -129,6 +129,33 @@ describe('session artifact persistence records', () => { expect(snapshot?.sequence).toBe(3); }); + it('drops malformed content refs during restore normalization', () => { + const pinned = artifact('s1', 'https://example.com/pinned', { + retention: 'pinned', + contentRef: { + kind: 'managed_copy', + contentId: '../../escape', + sha256: 'a'.repeat(64), + sizeBytes: 12, + createdAt: '2026-07-04T00:00:00.000Z', + }, + }); + + const snapshot = rebuildSessionArtifactSnapshot([ + event({ + v: SESSION_ARTIFACT_PERSISTENCE_VERSION, + sessionId: 's1', + sequence: 1, + recordedAt: '2026-07-04T00:00:00.000Z', + changes: [ + { action: 'created', artifactId: pinned.id, artifact: pinned }, + ], + }), + ]); + + expect(snapshot?.artifacts[0]).not.toHaveProperty('contentRef'); + }); + it('remaps forked payloads to the new session without carrying pinned content', () => { const source = artifact('source-session', 'https://example.com/report', { retention: 'pinned', diff --git a/packages/core/src/services/session-artifact-persistence.ts b/packages/core/src/services/session-artifact-persistence.ts index 8ecb0970ce0..272129cad8d 100644 --- a/packages/core/src/services/session-artifact-persistence.ts +++ b/packages/core/src/services/session-artifact-persistence.ts @@ -7,6 +7,7 @@ import { createHash } from 'node:crypto'; export const SESSION_ARTIFACT_PERSISTENCE_VERSION = 2 as const; +const CONTENT_ID_PATTERN = /^[0-9a-f]{64}-[0-9a-f]{16}$/; export type SessionArtifactRetention = 'ephemeral' | 'restorable' | 'pinned'; @@ -499,7 +500,14 @@ function normalizeContentRef( const sha256 = getString(value, 'sha256'); const sizeBytes = getNonNegativeInteger(value, 'sizeBytes'); const createdAt = getString(value, 'createdAt'); - if (!contentId || !sha256 || sizeBytes === undefined || !createdAt) { + if ( + !contentId || + !CONTENT_ID_PATTERN.test(contentId) || + !sha256 || + !/^[0-9a-f]{64}$/.test(sha256) || + sizeBytes === undefined || + !createdAt + ) { return undefined; } return { kind: 'managed_copy', contentId, sha256, sizeBytes, createdAt }; From 6f8a852e646662da3f272f705a5eb5d4516c842c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Sun, 5 Jul 2026 02:26:16 +0800 Subject: [PATCH 16/61] feat(daemon): persist session artifacts across restarts --- packages/acp-bridge/src/bridge.ts | 186 ++++++++- packages/acp-bridge/src/bridgeTypes.ts | 17 + .../src/sessionArtifactContentStore.ts | 212 ++++++++-- .../acp-bridge/src/sessionArtifacts.test.ts | 373 +++++++++++++++++- packages/acp-bridge/src/sessionArtifacts.ts | 214 ++++++++-- packages/cli/src/serve/acp-http/dispatch.ts | 44 ++- .../cli/src/serve/acp-http/transport.test.ts | 43 +- packages/cli/src/serve/acp-session-bridge.ts | 3 + packages/cli/src/serve/capabilities.ts | 10 + packages/cli/src/serve/routes/session.ts | 113 ++++++ packages/cli/src/serve/run-qwen-serve.ts | 2 + packages/cli/src/serve/server.test.ts | 167 +++++++- .../cli/src/serve/server/serve-features.ts | 2 + .../session-artifact-persistence.test.ts | 96 ++++- .../services/session-artifact-persistence.ts | 28 +- .../sdk-typescript/src/daemon/DaemonClient.ts | 9 + .../src/daemon/DaemonSessionClient.ts | 9 + packages/sdk-typescript/src/daemon/index.ts | 3 + packages/sdk-typescript/src/daemon/types.ts | 17 +- .../test/unit/DaemonClient.test.ts | 48 +++ 20 files changed, 1465 insertions(+), 131 deletions(-) diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index d41d14b9b47..381fd90e9a5 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -95,6 +95,9 @@ import type { BridgeWorkspaceMemoryForgetMatch, BridgeWorkspaceMemoryRememberRequest, BridgeWorkspaceMemoryRememberResult, + SessionArtifactPinRequest, + SessionArtifactRemoveRequest, + SessionArtifactUnpinRequest, } from './bridgeTypes.js'; import type { BridgeOptions, BridgeTelemetry } from './bridgeOptions.js'; import { MCP_RESTART_SERVER_DEADLINE_MS } from './mcpTimeouts.js'; @@ -110,6 +113,7 @@ import { import { PermissionForbiddenError } from './bridgeErrors.js'; import { SessionArtifactStore, + SessionArtifactValidationError, type SessionArtifactChange, type SessionArtifactInput, type SessionArtifactMutationResult, @@ -853,6 +857,8 @@ function extractPromptText( const DEFAULT_INIT_TIMEOUT_MS = 10_000; const PERSIST_TIMEOUT_MS = 5_000; +const ARTIFACT_TTL_DAY_MS = 24 * 60 * 60 * 1000; +const ARTIFACT_MAX_TTL_DAYS = 365; const MCP_RESTART_TIMEOUT_MS = 300_000; const WORKSPACE_MEMORY_REMEMBER_TIMEOUT_MS = 300_000; const MCP_OAUTH_TIMEOUT_MS = 600_000; @@ -2441,6 +2447,102 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { return input; }; + const artifactExpiresAt = ( + options: SessionArtifactPinRequest | undefined, + mode: 'metadata' | 'content', + ): string | undefined => { + if (options?.ttlDays === undefined) return undefined; + if (mode !== 'content') { + throw new SessionArtifactValidationError( + 'ttlDays is only valid with content pinning', + 'ttlDays', + ); + } + if (!Number.isSafeInteger(options.ttlDays) || options.ttlDays <= 0) { + throw new SessionArtifactValidationError( + 'ttlDays must be a positive safe integer', + 'ttlDays', + ); + } + if (options.ttlDays > ARTIFACT_MAX_TTL_DAYS) { + throw new SessionArtifactValidationError( + `ttlDays must be at most ${ARTIFACT_MAX_TTL_DAYS}`, + 'ttlDays', + ); + } + const expiresAtMs = Date.now() + options.ttlDays * ARTIFACT_TTL_DAY_MS; + if (!Number.isSafeInteger(expiresAtMs)) { + throw new SessionArtifactValidationError( + 'ttlDays is too large', + 'ttlDays', + ); + } + return new Date(expiresAtMs).toISOString(); + }; + + const artifactPinMode = ( + options: SessionArtifactPinRequest | undefined, + ): 'metadata' | 'content' => { + const mode = options?.mode ?? 'content'; + if (mode !== 'metadata' && mode !== 'content') { + throw new SessionArtifactValidationError( + 'mode must be "metadata" or "content"', + 'mode', + ); + } + return mode; + }; + + const artifactRemoveOptions = ( + options: SessionArtifactRemoveRequest | undefined, + ): SessionArtifactRemoveRequest => { + if (options?.deleteContent === undefined) return {}; + if (typeof options.deleteContent !== 'boolean') { + throw new SessionArtifactValidationError( + 'deleteContent must be a boolean', + 'deleteContent', + ); + } + return { deleteContent: options.deleteContent }; + }; + + const artifactClientRetained = ( + options: SessionArtifactPinRequest | undefined, + ): boolean | undefined => { + if (options?.clientRetained === undefined) return undefined; + if (typeof options.clientRetained !== 'boolean') { + throw new SessionArtifactValidationError( + 'clientRetained must be a boolean', + 'clientRetained', + ); + } + return options.clientRetained; + }; + + const artifactUnpinOptions = ( + options: SessionArtifactUnpinRequest | undefined, + ): SessionArtifactUnpinRequest => { + if (options?.retention === undefined) return {}; + if ( + options.retention !== 'ephemeral' && + options.retention !== 'restorable' + ) { + throw new SessionArtifactValidationError( + 'retention must be "ephemeral" or "restorable"', + 'retention', + ); + } + return { retention: options.retention }; + }; + + const gcArtifactContent = async (entry: SessionEntry): Promise => { + const refs = await entry.artifacts.contentRefs(); + await artifactContentStore.gc( + entry.sessionId, + new Set(refs.map((ref) => ref.contentId)), + ); + }; + function createSessionArtifactPersistence( connection: ClientSideConnection, sessionId: string, @@ -2795,6 +2897,16 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { seedSnapshotCaches(entry, state); const artifactRestoreWarnings = await entry.artifacts.restore( restoredArtifactSnapshotFromState(state), + { + verifyContentRef: (artifact) => + artifact.contentRef + ? artifactContentStore.verifyContentRef( + entry.sessionId, + artifact.id, + artifact.contentRef, + ) + : Promise.resolve(undefined), + }, ); for (const warning of artifactRestoreWarnings) { writeStderrLine( @@ -4198,6 +4310,11 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { async getSessionArtifacts(sessionId) { const entry = byId.get(sessionId); if (!entry) throw new SessionNotFoundError(sessionId); + const pruned = await entry.artifacts.pruneExpiredPins(); + if (pruned.changes.length > 0) { + publishArtifactChanges(entry, pruned.changes); + await gcArtifactContent(entry); + } return entry.artifacts.list(); }, @@ -4212,16 +4329,34 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { return result; }, - async removeSessionArtifact(sessionId, artifactId, context) { + async removeSessionArtifact(sessionId, artifactId, context, options) { const entry = byId.get(sessionId); if (!entry) throw new SessionNotFoundError(sessionId); const clientId = resolveTrustedClientId(entry, context?.clientId); + const artifact = await entry.artifacts.get(artifactId); const result = await entry.artifacts.remove(artifactId, { clientId }); + const removeOptions = artifactRemoveOptions(options); + const warnings = [...(result.warnings ?? [])]; + if (removeOptions.deleteContent === true && result.changes.length > 0) { + if ( + artifact?.source === 'client' && + artifact.clientId !== undefined && + artifact.clientId === clientId + ) { + try { + await gcArtifactContent(entry); + } catch { + warnings.push('content_delete_preserved'); + } + } else { + warnings.push('content_delete_preserved'); + } + } publishArtifactChanges(entry, result.changes, clientId); - return result; + return warnings.length > 0 ? { ...result, warnings } : result; }, - async pinSessionArtifact(sessionId, artifactId, context) { + async pinSessionArtifact(sessionId, artifactId, context, options) { const entry = byId.get(sessionId); if (!entry) throw new SessionNotFoundError(sessionId); const clientId = resolveTrustedClientId(entry, context?.clientId); @@ -4229,27 +4364,46 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { if (!artifact) { return { v: 1, sessionId, changes: [] }; } - const contentRef = await artifactContentStore.pinWorkspaceFile( - sessionId, - artifact, - entry.workspaceCwd, - ); - const result = await entry.artifacts.pin(artifactId, contentRef); + const mode = artifactPinMode(options); + if (artifact.retention === 'pinned') { + const result = await entry.artifacts.pin(artifactId); + publishArtifactChanges(entry, result.changes, clientId); + return result; + } + const contentRef = + mode === 'metadata' + ? undefined + : await artifactContentStore.pinWorkspaceFile( + sessionId, + artifact, + entry.workspaceCwd, + ); + if (mode === 'content' && !contentRef) { + throw new SessionArtifactValidationError( + 'artifact content is not available for retention', + 'artifactId', + ); + } + const result = await entry.artifacts.pin(artifactId, { + retention: mode === 'metadata' ? 'restorable' : 'pinned', + contentRef, + expiresAt: artifactExpiresAt(options, mode), + clientRetained: artifactClientRetained(options), + }); publishArtifactChanges(entry, result.changes, clientId); return result; }, - async unpinSessionArtifact(sessionId, artifactId, context) { + async unpinSessionArtifact(sessionId, artifactId, context, options) { const entry = byId.get(sessionId); if (!entry) throw new SessionNotFoundError(sessionId); const clientId = resolveTrustedClientId(entry, context?.clientId); - const result = await entry.artifacts.unpin(artifactId); + const result = await entry.artifacts.unpin( + artifactId, + artifactUnpinOptions(options), + ); if (result.changes.length > 0) { - const refs = await entry.artifacts.contentRefs(); - await artifactContentStore.gc( - sessionId, - new Set(refs.map((ref) => ref.contentId)), - ); + await gcArtifactContent(entry); } publishArtifactChanges(entry, result.changes, clientId); return result; diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts index 49441bbeacf..4b572c51993 100644 --- a/packages/acp-bridge/src/bridgeTypes.ts +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -60,6 +60,20 @@ export interface RewindResponse { filesFailed: string[]; } +export interface SessionArtifactPinRequest { + mode?: 'metadata' | 'content'; + ttlDays?: number; + clientRetained?: boolean; +} + +export interface SessionArtifactRemoveRequest { + deleteContent?: boolean; +} + +export interface SessionArtifactUnpinRequest { + retention?: 'ephemeral' | 'restorable'; +} + export interface BridgeSpawnRequest { /** Absolute path to the workspace root the child inherits as cwd. */ workspaceCwd: string; @@ -555,18 +569,21 @@ export interface AcpSessionBridge { sessionId: string, artifactId: string, context?: BridgeClientRequestContext, + options?: SessionArtifactRemoveRequest, ): Promise; pinSessionArtifact( sessionId: string, artifactId: string, context?: BridgeClientRequestContext, + options?: SessionArtifactPinRequest, ): Promise; unpinSessionArtifact( sessionId: string, artifactId: string, context?: BridgeClientRequestContext, + options?: SessionArtifactUnpinRequest, ): Promise; fsckSessionArtifacts(sessionId: string): Promise; diff --git a/packages/acp-bridge/src/sessionArtifactContentStore.ts b/packages/acp-bridge/src/sessionArtifactContentStore.ts index f0914f472e6..b1ad6a9dbda 100644 --- a/packages/acp-bridge/src/sessionArtifactContentStore.ts +++ b/packages/acp-bridge/src/sessionArtifactContentStore.ts @@ -7,9 +7,11 @@ import { createHash } from 'node:crypto'; import fsSync from 'node:fs'; import { promises as fs } from 'node:fs'; +import type { FileHandle } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import type { SessionArtifactContentRef } from '@qwen-code/qwen-code-core'; +import type { SessionArtifactPersistenceWarning } from '@qwen-code/qwen-code-core'; import type { DaemonSessionArtifact } from './sessionArtifacts.js'; import { SessionArtifactValidationError } from './sessionArtifacts.js'; @@ -71,20 +73,6 @@ export class SessionArtifactContentStore { 'artifactId', ); }); - const sourceStat = await fs.stat(source); - if (!sourceStat.isFile()) { - throw new SessionArtifactValidationError( - 'Only regular workspace files can be pinned with content retention', - 'artifactId', - ); - } - if (sourceStat.size > MAX_PINNED_FILE_BYTES) { - throw new SessionArtifactValidationError( - `Pinned artifact content exceeds ${MAX_PINNED_FILE_BYTES} bytes`, - 'artifactId', - ); - } - return this.enqueueWrite(async () => { const tmpDir = path.join(this.rootDir, '.tmp'); await fs.mkdir(tmpDir, { recursive: true, mode: 0o700 }); @@ -92,15 +80,13 @@ export class SessionArtifactContentStore { tmpDir, `${process.pid}-${Date.now()}-${artifact.id}.bin`, ); + let sourceHandle: FileHandle | undefined; try { - await fs.copyFile(source, tmpPath); - const { sha256, sizeBytes } = await hashFile(tmpPath); - if (sizeBytes > MAX_PINNED_FILE_BYTES) { - throw new SessionArtifactValidationError( - `Pinned artifact content exceeds ${MAX_PINNED_FILE_BYTES} bytes`, - 'artifactId', - ); - } + sourceHandle = await openRegularWorkspaceFile(source); + const { sha256, sizeBytes } = await copyOpenFileToTemp( + sourceHandle, + tmpPath, + ); const contentId = `${sha256}-${stableContentSuffix( sessionId, @@ -148,6 +134,8 @@ export class SessionArtifactContentStore { await fs.rm(tmpPath, { force: true }).catch(() => {}); } throw error; + } finally { + await sourceHandle?.close().catch(() => undefined); } }); } @@ -179,6 +167,53 @@ export class SessionArtifactContentStore { return { checked: contentRefs.length, missing, hashMismatches }; } + async verifyContentRef( + sessionId: string, + artifactId: string, + ref: SessionArtifactContentRef, + ): Promise { + if ( + ref.kind !== 'managed_copy' || + !isValidContentId(ref.contentId) || + !/^[0-9a-f]{64}$/.test(ref.sha256) || + !Number.isSafeInteger(ref.sizeBytes) || + ref.sizeBytes < 0 + ) { + return 'restore_validation_failed'; + } + const contentDir = path.join(this.rootDir, ref.contentId); + let manifest: ContentManifest; + try { + manifest = await readManifest(path.join(contentDir, 'manifest.json')); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return 'content_missing'; + } + return 'restore_validation_failed'; + } + if ( + manifest.sessionId !== sessionId || + manifest.artifactId !== artifactId || + manifest.contentId !== ref.contentId || + manifest.sha256 !== ref.sha256 || + manifest.sizeBytes !== ref.sizeBytes + ) { + return 'restore_validation_failed'; + } + try { + const stat = await fs.stat(path.join(contentDir, 'content')); + if (!stat.isFile() || stat.size !== ref.sizeBytes) { + return 'content_hash_mismatch'; + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return 'content_missing'; + } + throw error; + } + return undefined; + } + async gc( sessionId: string, referencedContentIds: ReadonlySet, @@ -322,6 +357,129 @@ async function resolveWorkspaceFile( return realCandidate; } +async function openRegularWorkspaceFile(filePath: string): Promise { + const sourceStat = await fs.stat(filePath); + if (!sourceStat.isFile()) { + throw new SessionArtifactValidationError( + 'Only regular workspace files can be pinned with content retention', + 'artifactId', + ); + } + if (sourceStat.nlink > 1) { + throw new SessionArtifactValidationError( + 'Hardlinked workspace files cannot be pinned with content retention', + 'artifactId', + ); + } + if (sourceStat.size > MAX_PINNED_FILE_BYTES) { + throw new SessionArtifactValidationError( + `Pinned artifact content exceeds ${MAX_PINNED_FILE_BYTES} bytes`, + 'artifactId', + ); + } + let handle: FileHandle; + try { + handle = await fs.open( + filePath, + fsSync.constants.O_RDONLY | noFollowFlag(), + ); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ELOOP') { + throw new SessionArtifactValidationError( + 'workspacePath must not resolve through a symlink while pinning content', + 'workspacePath', + ); + } + throw error; + } + try { + const handleStat = await handle.stat(); + if (!handleStat.isFile()) { + throw new SessionArtifactValidationError( + 'Only regular workspace files can be pinned with content retention', + 'artifactId', + ); + } + if (!sameFile(sourceStat, handleStat)) { + throw new SessionArtifactValidationError( + 'workspacePath changed while pinning content', + 'workspacePath', + ); + } + if (handleStat.nlink > 1) { + throw new SessionArtifactValidationError( + 'Hardlinked workspace files cannot be pinned with content retention', + 'artifactId', + ); + } + if (handleStat.size > MAX_PINNED_FILE_BYTES) { + throw new SessionArtifactValidationError( + `Pinned artifact content exceeds ${MAX_PINNED_FILE_BYTES} bytes`, + 'artifactId', + ); + } + return handle; + } catch (error) { + await handle.close().catch(() => undefined); + throw error; + } +} + +async function copyOpenFileToTemp( + handle: FileHandle, + tmpPath: string, +): Promise<{ sha256: string; sizeBytes: number }> { + const writer = await fs.open(tmpPath, 'w', 0o600); + const hash = createHash('sha256'); + const buffer = Buffer.allocUnsafe(64 * 1024); + let sizeBytes = 0; + let position = 0; + try { + while (true) { + const { bytesRead } = await handle.read( + buffer, + 0, + buffer.length, + position, + ); + if (bytesRead === 0) break; + position += bytesRead; + sizeBytes += bytesRead; + if (sizeBytes > MAX_PINNED_FILE_BYTES) { + throw new SessionArtifactValidationError( + `Pinned artifact content exceeds ${MAX_PINNED_FILE_BYTES} bytes`, + 'artifactId', + ); + } + const chunk = buffer.subarray(0, bytesRead); + hash.update(chunk); + await writer.write(chunk); + } + await writer.sync(); + } finally { + await writer.close().catch(() => undefined); + } + return { sha256: hash.digest('hex'), sizeBytes }; +} + +function noFollowFlag(): number { + return typeof fsSync.constants.O_NOFOLLOW === 'number' + ? fsSync.constants.O_NOFOLLOW + : 0; +} + +function sameFile(before: fsSync.Stats, after: fsSync.Stats): boolean { + if ( + before.dev !== 0 || + before.ino !== 0 || + after.dev !== 0 || + after.ino !== 0 + ) { + return before.dev === after.dev && before.ino === after.ino; + } + return before.size === after.size && before.mtimeMs === after.mtimeMs; +} + function hashFile( filePath: string, ): Promise<{ sha256: string; sizeBytes: number }> { @@ -371,12 +529,16 @@ async function writeManifestAtomic( contentDir, `.manifest-${process.pid}-${Date.now()}.json.tmp`, ); + let handle: FileHandle | undefined; try { - await fs.writeFile(tmpPath, `${JSON.stringify(manifest)}\n`, { - mode: 0o600, - }); + handle = await fs.open(tmpPath, 'w', 0o600); + await handle.writeFile(`${JSON.stringify(manifest)}\n`); + await handle.sync(); + await handle.close(); + handle = undefined; await fs.rename(tmpPath, path.join(contentDir, 'manifest.json')); } catch (error) { + await handle?.close().catch(() => undefined); await fs.rm(tmpPath, { force: true }).catch(() => undefined); throw error; } diff --git a/packages/acp-bridge/src/sessionArtifacts.test.ts b/packages/acp-bridge/src/sessionArtifacts.test.ts index 66022c92bf5..50a1db7f054 100644 --- a/packages/acp-bridge/src/sessionArtifacts.test.ts +++ b/packages/acp-bridge/src/sessionArtifacts.test.ts @@ -125,6 +125,41 @@ describe('SessionArtifactStore', () => { }); }); + it('does not write live client ids into durable artifact records', async () => { + const events: SessionArtifactEventRecordPayload[] = []; + const store = new SessionArtifactStore({ + sessionId: 's1-client-id-durable', + workspaceCwd: workspace, + persistence: { + recordEvent: async (payload) => { + events.push(payload); + }, + recordSnapshot: async () => {}, + }, + }); + + const created = await store.upsertMany( + [ + { + title: 'Client artifact', + url: 'https://example.com/client', + source: 'client', + clientId: 'client-a', + }, + ], + { strict: true }, + ); + + expect(created.changes[0]?.artifact).toMatchObject({ + clientId: 'client-a', + }); + expect(events[0]?.changes[0]?.artifact).not.toHaveProperty('clientId'); + expect(events[0]?.changes[0]?.artifact).not.toHaveProperty('restoreState'); + expect(events[0]?.changes[0]?.artifact).not.toHaveProperty( + 'persistenceWarning', + ); + }); + it('returns pinned content refs for gc and fsck callers', async () => { const store = new SessionArtifactStore({ sessionId: 's1-content-refs', @@ -147,7 +182,7 @@ describe('SessionArtifactStore', () => { createdAt: '2026-07-04T00:00:00.000Z', }; - await store.pin(artifactId, contentRef); + await store.pin(artifactId, { contentRef }); await expect(store.contentRefs()).resolves.toEqual([contentRef]); await store.unpin(artifactId); @@ -179,11 +214,11 @@ describe('SessionArtifactStore', () => { createdAt: '2026-07-04T00:00:00.000Z', }; - await expect(store.pin('missing', contentRef)).resolves.toMatchObject({ + await expect(store.pin('missing', { contentRef })).resolves.toMatchObject({ changes: [], }); - const pinned = await store.pin(artifactId, contentRef); + const pinned = await store.pin(artifactId, { contentRef }); expect(pinned.changes[0]?.artifact).toMatchObject({ retention: 'pinned', clientRetained: true, @@ -202,7 +237,134 @@ describe('SessionArtifactStore', () => { expect(events.map((event) => event.sequence)).toEqual([1, 2, 3]); }); - it('marks metadata-only pin when no content ref is available', async () => { + it('unpins to live-only state with a durable sticky tombstone', async () => { + const events: SessionArtifactEventRecordPayload[] = []; + const store = new SessionArtifactStore({ + sessionId: 's1-unpin-ephemeral', + workspaceCwd: workspace, + persistence: { + recordEvent: async (payload) => { + events.push(payload); + }, + recordSnapshot: async () => {}, + }, + }); + const created = await store.upsertMany( + [{ title: 'Report', url: 'https://example.com/report' }], + { strict: true }, + ); + const artifactId = created.changes[0]!.artifactId; + const contentRef = { + kind: 'managed_copy' as const, + contentId: `${'a'.repeat(64)}-${'b'.repeat(16)}`, + sha256: 'a'.repeat(64), + sizeBytes: 12, + createdAt: '2026-07-04T00:00:00.000Z', + }; + + await store.pin(artifactId, { contentRef }); + const unpinned = await store.unpin(artifactId, { + retention: 'ephemeral', + }); + + expect(unpinned.changes[0]?.artifact).toMatchObject({ + id: artifactId, + retention: 'ephemeral', + persistenceWarning: 'sticky_override_active', + }); + expect(unpinned.changes[0]?.artifact).not.toHaveProperty('contentRef'); + expect(events[2]?.changes).toEqual([ + expect.objectContaining({ + action: 'removed', + artifactId, + reason: 'unpin_to_ephemeral', + }), + ]); + + await store.upsertMany([ + { title: 'Report', url: 'https://example.com/report' }, + ]); + await expect(store.list()).resolves.toMatchObject({ + artifacts: [ + expect.objectContaining({ + id: artifactId, + retention: 'ephemeral', + persistenceWarning: 'sticky_override_active', + }), + ], + }); + + await store.upsertMany( + [ + { + title: 'Report', + url: 'https://example.com/report', + retention: 'restorable', + }, + ], + { strict: true }, + ); + await expect(store.list()).resolves.toMatchObject({ + artifacts: [ + expect.objectContaining({ + id: artifactId, + retention: 'restorable', + }), + ], + }); + expect((await store.list()).artifacts[0]).not.toHaveProperty( + 'persistenceWarning', + ); + }); + + it('downgrades expired pinned content before exposing artifacts', async () => { + const events: SessionArtifactEventRecordPayload[] = []; + const store = new SessionArtifactStore({ + sessionId: 's1-expired-pin', + workspaceCwd: workspace, + persistence: { + recordEvent: async (payload) => { + events.push(payload); + }, + recordSnapshot: async () => {}, + }, + }); + const created = await store.upsertMany( + [{ title: 'Report', url: 'https://example.com/report' }], + { strict: true }, + ); + const artifactId = created.changes[0]!.artifactId; + await store.pin(artifactId, { + contentRef: { + kind: 'managed_copy', + contentId: `${'c'.repeat(64)}-${'d'.repeat(16)}`, + sha256: 'c'.repeat(64), + sizeBytes: 12, + createdAt: '2026-07-04T00:00:00.000Z', + }, + expiresAt: '2026-07-04T00:00:00.000Z', + }); + + const pruned = await store.pruneExpiredPins( + new Date('2026-07-05T00:00:00.000Z'), + ); + + expect(pruned.changes[0]?.artifact).toMatchObject({ + id: artifactId, + retention: 'restorable', + persistenceWarning: 'content_expired', + }); + expect(pruned.changes[0]?.artifact).not.toHaveProperty('contentRef'); + expect(pruned.changes[0]?.artifact).not.toHaveProperty('expiresAt'); + expect(events[2]?.changes[0]?.artifact).toMatchObject({ + retention: 'restorable', + }); + expect(events[2]?.changes[0]?.artifact).not.toHaveProperty( + 'persistenceWarning', + ); + }); + + it('keeps metadata-only pin restorable when no content ref is available', async () => { const store = new SessionArtifactStore({ sessionId: 's1-pin-metadata', workspaceCwd: workspace, @@ -221,7 +383,7 @@ describe('SessionArtifactStore', () => { changes: [ { artifact: { - retention: 'pinned', + retention: 'restorable', persistenceWarning: 'metadata_only_restore', }, }, @@ -229,6 +391,58 @@ describe('SessionArtifactStore', () => { }); }); + it('does not refresh an already pinned artifact on repeated pin', async () => { + const events: SessionArtifactEventRecordPayload[] = []; + const store = new SessionArtifactStore({ + sessionId: 's1-pin-idempotent', + workspaceCwd: workspace, + persistence: { + recordEvent: async (payload) => { + events.push(payload); + }, + recordSnapshot: async () => {}, + }, + }); + const created = await store.upsertMany( + [{ title: 'Report', url: 'https://example.com/report' }], + { strict: true }, + ); + const artifactId = created.changes[0]!.artifactId; + const contentRef = { + kind: 'managed_copy' as const, + contentId: `${'a'.repeat(64)}-${'b'.repeat(16)}`, + sha256: 'a'.repeat(64), + sizeBytes: 12, + createdAt: '2026-07-04T00:00:00.000Z', + }; + await store.pin(artifactId, { + contentRef, + expiresAt: '2026-08-01T00:00:00.000Z', + }); + + await expect( + store.pin(artifactId, { + contentRef: { + ...contentRef, + contentId: `${'c'.repeat(64)}-${'d'.repeat(16)}`, + sha256: 'c'.repeat(64), + }, + expiresAt: '2026-09-01T00:00:00.000Z', + }), + ).resolves.toMatchObject({ changes: [] }); + await expect(store.list()).resolves.toMatchObject({ + artifacts: [ + { + id: artifactId, + retention: 'pinned', + contentRef, + expiresAt: '2026-08-01T00:00:00.000Z', + }, + ], + }); + expect(events).toHaveLength(2); + }); + it('rolls back pin mutations when persistence fails', async () => { let fail = false; const store = new SessionArtifactStore({ @@ -250,11 +464,13 @@ describe('SessionArtifactStore', () => { await expect( store.pin(artifactId, { - kind: 'managed_copy', - contentId: `${'a'.repeat(64)}-${'b'.repeat(16)}`, - sha256: 'a'.repeat(64), - sizeBytes: 12, - createdAt: '2026-07-04T00:00:00.000Z', + contentRef: { + kind: 'managed_copy', + contentId: `${'a'.repeat(64)}-${'b'.repeat(16)}`, + sha256: 'a'.repeat(64), + sizeBytes: 12, + createdAt: '2026-07-04T00:00:00.000Z', + }, }), ).rejects.toThrow('persist failed'); await expect(store.list()).resolves.toMatchObject({ @@ -716,7 +932,15 @@ describe('SessionArtifactStore', () => { ], { strict: true }, ); - await store.pin(created.changes[0]!.artifactId); + await store.pin(created.changes[0]!.artifactId, { + contentRef: { + kind: 'managed_copy', + contentId: `${'a'.repeat(64)}-${'b'.repeat(16)}`, + sha256: 'a'.repeat(64), + sizeBytes: 12, + createdAt: '2026-07-04T00:00:00.000Z', + }, + }); await store.upsertMany([{ title: 'New', url: 'https://example.com/new' }], { strict: true, @@ -2129,6 +2353,69 @@ describe('SessionArtifactStore', () => { ], }); }); + + it('strips invalid retained content refs during restore', async () => { + const events: SessionArtifactEventRecordPayload[] = []; + const source = new SessionArtifactStore({ + sessionId: 's11-restore-content-ref', + workspaceCwd: workspace, + persistence: { + recordEvent: async (payload) => { + events.push(payload); + }, + recordSnapshot: async () => {}, + }, + }); + const created = await source.upsertMany( + [{ title: 'Pinned', url: 'https://example.com/pinned' }], + { strict: true }, + ); + const artifactId = created.changes[0]!.artifactId; + await source.pin(artifactId, { + contentRef: { + kind: 'managed_copy', + contentId: `${'e'.repeat(64)}-${'f'.repeat(16)}`, + sha256: 'e'.repeat(64), + sizeBytes: 12, + createdAt: '2026-07-04T00:00:00.000Z', + }, + }); + const persisted = events[1]!.changes[0]!.artifact!; + const snapshot: RebuiltSessionArtifactSnapshot = { + v: 2, + sessionId: 's11-restore-content-ref', + sequence: 2, + artifacts: [persisted], + tombstonedIds: [], + stickyEphemeralIds: [], + warnings: [], + }; + const restored = new SessionArtifactStore({ + sessionId: 's11-restore-content-ref', + workspaceCwd: workspace, + }); + + await expect( + restored.restore(snapshot, { + verifyContentRef: async () => 'content_hash_mismatch', + }), + ).resolves.toEqual([]); + + await expect(restored.list()).resolves.toMatchObject({ + artifacts: [ + expect.objectContaining({ + id: artifactId, + retention: 'restorable', + restoreState: 'restored', + status: 'missing', + persistenceWarning: 'content_hash_mismatch', + }), + ], + }); + expect((await restored.list()).artifacts[0]).not.toHaveProperty( + 'contentRef', + ); + }); }); describe('SessionArtifactContentStore', () => { @@ -2314,6 +2601,31 @@ describe('SessionArtifactContentStore', () => { } }); + it('rejects hardlinked workspace files for content retention', async () => { + const contentStore = new SessionArtifactContentStore(contentRoot); + await fs.mkdir(path.join(workspace, 'reports'), { recursive: true }); + const originalPath = path.join(workspace, 'reports', 'original.txt'); + const hardlinkPath = path.join(workspace, 'reports', 'hardlink.txt'); + await fs.writeFile(originalPath, 'hardlinked'); + try { + await fs.link(originalPath, hardlinkPath); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'EPERM' || code === 'EACCES' || code === 'EXDEV') { + return; + } + throw error; + } + const artifact = { + ...(await workspaceArtifact('regular.txt', 'regular')), + workspacePath: 'reports/hardlink.txt', + }; + + await expect( + contentStore.pinWorkspaceFile('content-session', artifact, workspace), + ).rejects.toThrow(SessionArtifactValidationError); + }); + it('rejects new content over total quota and cleans up temporary files', async () => { const contentStore = new SessionArtifactContentStore(contentRoot); const artifact = await workspaceArtifact('over-quota.txt', 'new-content'); @@ -2397,6 +2709,45 @@ describe('SessionArtifactContentStore', () => { }); }); + it('validates retained content refs against manifest and content size', async () => { + const contentStore = new SessionArtifactContentStore(contentRoot); + const artifact = await workspaceArtifact('verify.txt', 'hello'); + const ref = (await contentStore.pinWorkspaceFile( + 'content-session', + artifact, + workspace, + ))!; + + await expect( + contentStore.verifyContentRef('content-session', artifact.id, ref), + ).resolves.toBeUndefined(); + + await expect( + contentStore.verifyContentRef('other-session', artifact.id, ref), + ).resolves.toBe('restore_validation_failed'); + + await fs.writeFile( + path.join(contentRoot, ref.contentId, 'content'), + 'HELLO', + ); + await expect( + contentStore.verifyContentRef('content-session', artifact.id, ref), + ).resolves.toBeUndefined(); + await expect(contentStore.fsck([ref])).resolves.toMatchObject({ + hashMismatches: [ref.contentId], + }); + + await fs.writeFile(path.join(contentRoot, ref.contentId, 'content'), 'bad'); + await expect( + contentStore.verifyContentRef('content-session', artifact.id, ref), + ).resolves.toBe('content_hash_mismatch'); + + await fs.rm(path.join(contentRoot, ref.contentId, 'content')); + await expect( + contentStore.verifyContentRef('content-session', artifact.id, ref), + ).resolves.toBe('content_missing'); + }); + it('garbage-collects only unreferenced content for the requested session', async () => { const contentStore = new SessionArtifactContentStore(contentRoot); const kept = (await contentStore.pinWorkspaceFile( diff --git a/packages/acp-bridge/src/sessionArtifacts.ts b/packages/acp-bridge/src/sessionArtifacts.ts index 283cb4e4f7a..75f4c33f7a9 100644 --- a/packages/acp-bridge/src/sessionArtifacts.ts +++ b/packages/acp-bridge/src/sessionArtifacts.ts @@ -142,6 +142,23 @@ export interface SessionArtifactMutationResult { warnings?: string[]; } +export interface SessionArtifactPinOptions { + retention?: Extract; + contentRef?: SessionArtifactContentRef; + expiresAt?: string; + clientRetained?: boolean; +} + +export interface SessionArtifactUnpinOptions { + retention?: Extract; +} + +export interface SessionArtifactRestoreOptions { + verifyContentRef?: ( + artifact: PersistedSessionArtifact, + ) => Promise; +} + export interface SessionArtifactPersistence { recordEvent(payload: SessionArtifactEventRecordPayload): Promise; recordSnapshot(payload: SessionArtifactSnapshotRecordPayload): Promise; @@ -169,6 +186,7 @@ interface SessionArtifactStoreOptions { interface NormalizedArtifact extends DaemonSessionArtifact { identityKey: string; receivedSeq: number; + retentionExplicit: boolean; retentionSource: DaemonSessionArtifactSource; trustedPublisher: boolean; lastStatAt?: number; @@ -273,7 +291,8 @@ export class SessionArtifactStore { } const changes: SessionArtifactChange[] = []; try { - for (const artifact of coalesceByIdentity(normalizedResults)) { + for (const normalized of coalesceByIdentity(normalizedResults)) { + const artifact = this.applyStickyEphemeralOverride(normalized); const existing = this.artifacts.get(artifact.id) ?? this.findPublishedUpgradeTarget(artifact) ?? @@ -440,7 +459,7 @@ export class SessionArtifactStore { async pin( artifactId: string, - contentRef?: SessionArtifactContentRef, + options: SessionArtifactPinOptions = {}, ): Promise { return this.enqueue(async () => { const before = this.cloneState(); @@ -448,18 +467,31 @@ export class SessionArtifactStore { if (!existing) { return { v: 1, sessionId: this.sessionId, changes: [] }; } + if (existing.retention === 'pinned') { + return { v: 1, sessionId: this.sessionId, changes: [] }; + } + const targetRetention = + options.retention ?? (options.contentRef ? 'pinned' : 'restorable'); + const { contentRef, expiresAt } = options; const updated: StoredArtifact = { ...existing, - retention: 'pinned', - clientRetained: true, + retention: targetRetention, + clientRetained: options.clientRetained ?? true, restoreState: 'live', updatedAt: new Date().toISOString(), - ...(contentRef - ? { contentRef } - : { persistenceWarning: 'metadata_only_restore' as const }), }; - if (contentRef) { + if (targetRetention === 'pinned' && contentRef) { + updated.contentRef = contentRef; delete updated.persistenceWarning; + if (expiresAt) { + updated.expiresAt = expiresAt; + } else { + delete updated.expiresAt; + } + } else { + updated.persistenceWarning = 'metadata_only_restore'; + delete updated.contentRef; + delete updated.expiresAt; } this.artifacts.set(artifactId, updated); const changes: SessionArtifactChange[] = [ @@ -484,23 +516,32 @@ export class SessionArtifactStore { }); } - async unpin(artifactId: string): Promise { + async unpin( + artifactId: string, + options: SessionArtifactUnpinOptions = {}, + ): Promise { return this.enqueue(async () => { const before = this.cloneState(); const existing = this.artifacts.get(artifactId); if (!existing) { return { v: 1, sessionId: this.sessionId, changes: [] }; } + const targetRetention = options.retention ?? 'restorable'; const updated: StoredArtifact = { ...existing, - retention: 'restorable', + retention: targetRetention, restoreState: 'live', - persistenceWarning: 'metadata_only_restore', + persistenceWarning: + targetRetention === 'ephemeral' + ? 'sticky_override_active' + : 'metadata_only_restore', updatedAt: new Date().toISOString(), }; delete updated.contentRef; delete updated.expiresAt; - this.artifacts.set(artifactId, updated); + if (targetRetention === 'ephemeral') { + delete updated.persistedAt; + } const changes: SessionArtifactChange[] = [ { action: 'updated', @@ -508,8 +549,25 @@ export class SessionArtifactStore { artifact: toPublicArtifact(updated), }, ]; + const durableChanges = + targetRetention === 'ephemeral' + ? [ + { + action: 'removed' as const, + artifactId, + artifact: toPublicArtifact(existing), + reason: 'unpin_to_ephemeral' as const, + }, + ] + : changes; + if (targetRetention !== 'ephemeral') { + this.artifacts.set(artifactId, updated); + } try { - const warnings = await this.persistChanges(changes, true); + const warnings = await this.persistChanges(durableChanges, true); + if (targetRetention === 'ephemeral') { + this.artifacts.set(artifactId, updated); + } return { v: 1, sessionId: this.sessionId, @@ -523,8 +581,69 @@ export class SessionArtifactStore { }); } + async pruneExpiredPins( + now = new Date(), + ): Promise { + return this.enqueue(async () => { + const before = this.cloneState(); + const changes: SessionArtifactChange[] = []; + for (const [artifactId, existing] of this.artifacts) { + if ( + existing.retention !== 'pinned' || + !existing.expiresAt || + Date.parse(existing.expiresAt) > now.getTime() + ) { + continue; + } + const updated: StoredArtifact = { + ...existing, + retention: 'restorable', + restoreState: 'live', + persistenceWarning: 'content_expired', + updatedAt: now.toISOString(), + }; + delete updated.contentRef; + delete updated.expiresAt; + this.artifacts.set(artifactId, updated); + changes.push({ + action: 'updated', + artifactId, + artifact: toPublicArtifact(updated), + }); + } + if (changes.length === 0) { + return { v: 1, sessionId: this.sessionId, changes: [] }; + } + try { + const warnings = await this.persistChanges(changes, true); + return { + v: 1, + sessionId: this.sessionId, + changes, + ...(warnings.length > 0 ? { warnings } : {}), + }; + } catch (error) { + this.restoreState(before); + writeStderrLine( + `[artifacts] session=${this.sessionId} action=ttl_prune_failed reason=${JSON.stringify( + error instanceof Error ? error.message : String(error), + )}`, + ); + return { + v: 1, + sessionId: this.sessionId, + changes: [], + warnings: [ + 'expired pinned artifacts retained because persistence failed', + ], + }; + } + }); + } + async restore( snapshot: RebuiltSessionArtifactSnapshot | undefined, + options: SessionArtifactRestoreOptions = {}, ): Promise { if (!snapshot) return []; return this.enqueue(async () => { @@ -555,19 +674,41 @@ export class SessionArtifactStore { warnings.push(`skipped artifact with mismatched id ${artifact.id}`); continue; } + let contentRef = artifact.contentRef; + let expiresAt = artifact.expiresAt; + let retention = artifact.retention; + let status = normalized.status; + let persistenceWarning: + | SessionArtifactPersistenceWarning + | undefined = contentRef ? undefined : 'metadata_only_restore'; + if (expiresAt && Date.parse(expiresAt) <= Date.now()) { + contentRef = undefined; + expiresAt = undefined; + retention = 'restorable'; + status = 'missing'; + persistenceWarning = 'content_expired'; + } else if (contentRef && options.verifyContentRef) { + const warning = await options.verifyContentRef(artifact); + if (warning) { + contentRef = undefined; + expiresAt = undefined; + retention = 'restorable'; + status = 'missing'; + persistenceWarning = warning; + } + } const stored: StoredArtifact = { ...normalized, - retention: artifact.retention, + retention, clientRetained: artifact.clientRetained, createdAt: artifact.createdAt, updatedAt: artifact.updatedAt, persistedAt: artifact.persistedAt, - expiresAt: artifact.expiresAt, + expiresAt, + status, restoreState: 'restored', - persistenceWarning: artifact.contentRef - ? artifact.persistenceWarning - : (artifact.persistenceWarning ?? 'metadata_only_restore'), - contentRef: artifact.contentRef, + persistenceWarning, + contentRef, insertSeq: ++this.insertSeq, }; this.artifacts.set(stored.id, stored); @@ -655,10 +796,7 @@ export class SessionArtifactStore { const stored = this.artifacts.get(change.artifactId); if (!stored) continue; stored.persistedAt = recordedAt; - if ( - stored.contentRef || - stored.persistenceWarning !== 'metadata_only_restore' - ) { + if (stored.contentRef) { delete stored.persistenceWarning; } change.artifact = toPublicArtifact(stored); @@ -845,6 +983,7 @@ export class SessionArtifactStore { id, identityKey, receivedSeq, + retentionExplicit: input.retention !== undefined, retentionSource: source, trustedPublisher, kind, @@ -903,6 +1042,23 @@ export class SessionArtifactStore { ); } + private applyStickyEphemeralOverride( + artifact: NormalizedArtifact, + ): NormalizedArtifact { + if ( + !this.stickyEphemeralIds.has(artifact.id) || + artifact.retentionExplicit || + artifact.retention === 'ephemeral' + ) { + return artifact; + } + return { + ...artifact, + retention: 'ephemeral', + persistenceWarning: 'sticky_override_active', + }; + } + private async getInitialWorkspaceStatus(workspacePath: string): Promise<{ status: DaemonSessionArtifactStatus; sizeBytes?: number; @@ -1073,6 +1229,7 @@ function mergeBatchArtifact( trustedPublisher: true, createdAt: existing.createdAt, receivedSeq: existing.receivedSeq, + retentionExplicit: existing.retentionExplicit || next.retentionExplicit, retentionSource: existing.retentionSource, retention: strongestRetention(existing.retention, next.retention), restoreState: 'live', @@ -1094,6 +1251,7 @@ function mergeBatchArtifact( metadata: mergeMetadata(existing, next), clientRetained: existing.clientRetained || next.clientRetained, trustedPublisher: existing.trustedPublisher || next.trustedPublisher, + retentionExplicit: existing.retentionExplicit || next.retentionExplicit, lastStatAt: next.lastStatAt ?? existing.lastStatAt, }; } @@ -1146,7 +1304,9 @@ function mergeArtifact( retention: strongestRetention(existing.retention, incoming.retention), restoreState: 'live', persistenceWarning: - existing.persistenceWarning ?? incoming.persistenceWarning, + incoming.retentionExplicit && incoming.retention !== 'ephemeral' + ? incoming.persistenceWarning + : (existing.persistenceWarning ?? incoming.persistenceWarning), contentRef: existing.contentRef ?? incoming.contentRef, persistedAt: existing.persistedAt ?? incoming.persistedAt, expiresAt: existing.expiresAt ?? incoming.expiresAt, @@ -1457,7 +1617,6 @@ function persistedArtifactToInput( toolCallId: artifact.toolCallId, toolName: artifact.toolName, hookEventName: artifact.hookEventName, - clientId: artifact.clientId, }; } @@ -1503,17 +1662,12 @@ function toPersistedArtifact( updatedAt: artifact.updatedAt, persistedAt: artifact.persistedAt ?? recordedAt, ...(artifact.expiresAt ? { expiresAt: artifact.expiresAt } : {}), - ...(artifact.restoreState ? { restoreState: artifact.restoreState } : {}), - ...(artifact.persistenceWarning - ? { persistenceWarning: artifact.persistenceWarning } - : {}), ...(artifact.contentRef ? { contentRef: artifact.contentRef } : {}), ...(artifact.toolCallId ? { toolCallId: artifact.toolCallId } : {}), ...(artifact.toolName ? { toolName: artifact.toolName } : {}), ...(artifact.hookEventName ? { hookEventName: artifact.hookEventName } : {}), - ...(artifact.clientId ? { clientId: artifact.clientId } : {}), }; } diff --git a/packages/cli/src/serve/acp-http/dispatch.ts b/packages/cli/src/serve/acp-http/dispatch.ts index f1dde106ce0..84fed3c01a9 100644 --- a/packages/cli/src/serve/acp-http/dispatch.ts +++ b/packages/cli/src/serve/acp-http/dispatch.ts @@ -36,7 +36,12 @@ import { UnsupportedDeviceFlowProviderError, UpstreamDeviceFlowError, } from '../auth/device-flow.js'; -import type { HttpAcpBridge } from '@qwen-code/acp-bridge/bridgeTypes'; +import type { + HttpAcpBridge, + SessionArtifactPinRequest, + SessionArtifactRemoveRequest, + SessionArtifactUnpinRequest, +} from '@qwen-code/acp-bridge/bridgeTypes'; import type { BridgeEvent } from '@qwen-code/acp-bridge/eventBus'; import { SessionShellClientRequiredError, @@ -426,6 +431,40 @@ function pickSessionArtifactInput( } as AddSessionArtifactInput; } +function pickSessionArtifactPinRequest( + params: Record, +): SessionArtifactPinRequest { + const request: SessionArtifactPinRequest = {}; + if (params['mode'] !== undefined) { + request.mode = params['mode'] as SessionArtifactPinRequest['mode']; + } + if (params['ttlDays'] !== undefined) { + request.ttlDays = params['ttlDays'] as number; + } + if (params['clientRetained'] !== undefined) { + request.clientRetained = params['clientRetained'] as boolean; + } + return request; +} + +function pickSessionArtifactUnpinRequest( + params: Record, +): SessionArtifactUnpinRequest { + const retention = params['retention']; + return retention === undefined + ? {} + : { retention: retention as SessionArtifactUnpinRequest['retention'] }; +} + +function pickSessionArtifactRemoveRequest( + params: Record, +): SessionArtifactRemoveRequest { + const deleteContent = params['deleteContent']; + return deleteContent === undefined + ? {} + : { deleteContent: deleteContent as boolean }; +} + /** * Map a thrown error to a JSON-RPC error code + a client-safe message. * Param-validation errors are echoed (they describe the client's own bad @@ -2393,6 +2432,7 @@ export class AcpDispatcher { sessionId, artifactId, this.sessionCtx(conn, sessionId, loopback), + pickSessionArtifactPinRequest(params), ); this.replyConn(conn, id, result as unknown); }); @@ -2415,6 +2455,7 @@ export class AcpDispatcher { sessionId, artifactId, this.sessionCtx(conn, sessionId, loopback), + pickSessionArtifactUnpinRequest(params), ); this.replyConn(conn, id, result as unknown); }); @@ -2454,6 +2495,7 @@ export class AcpDispatcher { sessionId, artifactId, this.sessionCtx(conn, sessionId, loopback), + pickSessionArtifactRemoveRequest(params), ); this.replyConn(conn, id, result as unknown); }); diff --git a/packages/cli/src/serve/acp-http/transport.test.ts b/packages/cli/src/serve/acp-http/transport.test.ts index 32937907746..c9e3509feb3 100644 --- a/packages/cli/src/serve/acp-http/transport.test.ts +++ b/packages/cli/src/serve/acp-http/transport.test.ts @@ -367,6 +367,7 @@ class FakeBridge { sessionId: string; artifactId: string; context: Parameters[2]; + options: Parameters[3]; } | undefined; lastPinnedArtifact: @@ -374,6 +375,7 @@ class FakeBridge { sessionId: string; artifactId: string; context: Parameters[2]; + options: Parameters[3]; } | undefined; lastUnpinnedArtifact: @@ -381,6 +383,7 @@ class FakeBridge { sessionId: string; artifactId: string; context: Parameters[2]; + options: Parameters[3]; } | undefined; lastFsckSessionId: string | undefined; @@ -407,8 +410,9 @@ class FakeBridge { sessionId: string, artifactId: string, context: Parameters[2], + options?: Parameters[3], ) { - this.lastRemovedArtifact = { sessionId, artifactId, context }; + this.lastRemovedArtifact = { sessionId, artifactId, context, options }; return { v: 1, sessionId, @@ -419,8 +423,9 @@ class FakeBridge { sessionId: string, artifactId: string, context: Parameters[2], + options?: Parameters[3], ) { - this.lastPinnedArtifact = { sessionId, artifactId, context }; + this.lastPinnedArtifact = { sessionId, artifactId, context, options }; return { v: 1, sessionId, @@ -431,8 +436,9 @@ class FakeBridge { sessionId: string, artifactId: string, context: Parameters[2], + options?: Parameters[3], ) { - this.lastUnpinnedArtifact = { sessionId, artifactId, context }; + this.lastUnpinnedArtifact = { sessionId, artifactId, context, options }; return { v: 1, sessionId, @@ -5715,7 +5721,11 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { jsonrpc: '2.0', id: 59, method: '_qwen/session/artifacts/remove', - params: { sessionId: 'sess-1', artifactId: 'artifact-1' }, + params: { + sessionId: 'sess-1', + artifactId: 'artifact-1', + deleteContent: true, + }, }); const frames = await takeFrames(await streamRes, 2); expect(frames[1]).toMatchObject({ @@ -5734,6 +5744,7 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { expect(bridge.lastRemovedArtifact).toMatchObject({ sessionId: 'sess-1', artifactId: 'artifact-1', + options: { deleteContent: true }, }); }); @@ -5779,7 +5790,13 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { jsonrpc: '2.0', id: 61, method: '_qwen/session/artifacts/pin', - params: { sessionId: 'sess-1', artifactId: 'artifact-1' }, + params: { + sessionId: 'sess-1', + artifactId: 'artifact-1', + mode: 'content', + ttlDays: 7, + clientRetained: false, + }, }); const frames = await takeFrames(await streamRes, 2); expect(frames[1]).toMatchObject({ @@ -5792,6 +5809,7 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { expect(bridge.lastPinnedArtifact).toMatchObject({ sessionId: 'sess-1', artifactId: 'artifact-1', + options: { mode: 'content', ttlDays: 7, clientRetained: false }, }); }); @@ -5810,7 +5828,11 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { jsonrpc: '2.0', id: 62, method: '_qwen/session/artifacts/unpin', - params: { sessionId: 'sess-1', artifactId: 'artifact-1' }, + params: { + sessionId: 'sess-1', + artifactId: 'artifact-1', + retention: 'ephemeral', + }, }); const frames = await takeFrames(await streamRes, 2); expect(frames[1]).toMatchObject({ @@ -5823,6 +5845,7 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { expect(bridge.lastUnpinnedArtifact).toMatchObject({ sessionId: 'sess-1', artifactId: 'artifact-1', + options: { retention: 'ephemeral' }, }); }); @@ -5963,8 +5986,14 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { sessionId, artifactId, context, + options, ) => { - bridge.lastRemovedArtifact = { sessionId, artifactId, context }; + bridge.lastRemovedArtifact = { + sessionId, + artifactId, + context, + options, + }; removeStarted(); await removeReleasedPromise; return { diff --git a/packages/cli/src/serve/acp-session-bridge.ts b/packages/cli/src/serve/acp-session-bridge.ts index 6cce4cb36e9..465bc02050a 100644 --- a/packages/cli/src/serve/acp-session-bridge.ts +++ b/packages/cli/src/serve/acp-session-bridge.ts @@ -85,6 +85,9 @@ export type { BridgeDaemonStatusSnapshot, AcpSessionBridge, HttpAcpBridge, + SessionArtifactPinRequest, + SessionArtifactRemoveRequest, + SessionArtifactUnpinRequest, } from '@qwen-code/acp-bridge/bridgeTypes'; export { diff --git a/packages/cli/src/serve/capabilities.ts b/packages/cli/src/serve/capabilities.ts index 5b5c6f107b5..9b76da39c3d 100644 --- a/packages/cli/src/serve/capabilities.ts +++ b/packages/cli/src/serve/capabilities.ts @@ -303,6 +303,8 @@ export interface AdvertiseFeatureToggles { persistSettingAvailable?: boolean; voiceTranscriptionAvailable?: boolean; sessionShellCommandEnabled?: boolean; + sessionArtifactsPersistenceAvailable?: boolean; + sessionArtifactsContentRetentionAvailable?: boolean; rateLimit?: boolean; reloadAvailable?: boolean; /** @@ -380,6 +382,14 @@ export const CONDITIONAL_SERVE_FEATURES: ReadonlyMap< 'session_shell_command', (toggles) => toggles.sessionShellCommandEnabled === true, ], + [ + 'session_artifacts_persistence', + (toggles) => toggles.sessionArtifactsPersistenceAvailable === true, + ], + [ + 'session_artifacts_content_retention', + (toggles) => toggles.sessionArtifactsContentRetentionAvailable === true, + ], ['rate_limit', (toggles) => toggles.rateLimit === true], ['workspace_reload', (toggles) => toggles.reloadAvailable === true], ['client_mcp_over_ws', (toggles) => toggles.clientMcpOverWsEnabled === true], diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index 8a5910e2368..4c2d2e7d381 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -25,6 +25,9 @@ import { SessionShellClientRequiredError, SessionShellDisabledError, type AcpSessionBridge, + type SessionArtifactPinRequest, + type SessionArtifactRemoveRequest, + type SessionArtifactUnpinRequest, } from '../acp-session-bridge.js'; import type { DaemonLogger } from '../daemon-logger.js'; import type { SendBridgeError } from '../server/error-response.js'; @@ -70,6 +73,8 @@ interface RegisterSessionRoutesDeps { languageCodes: string[]; } +const SESSION_ARTIFACT_MAX_TTL_DAYS = 365; + function sendArtifactValidationError(res: Response, err: unknown): boolean { if (!(err instanceof SessionArtifactValidationError)) { return false; @@ -85,6 +90,108 @@ function sendArtifactValidationError(res: Response, err: unknown): boolean { return true; } +function parseArtifactPinRequest(req: Request): SessionArtifactPinRequest { + const body = safeBody(req); + const mode = body['mode']; + const ttlDays = body['ttlDays']; + const clientRetained = body['clientRetained']; + const options: SessionArtifactPinRequest = {}; + if (mode !== undefined) { + if (mode !== 'metadata' && mode !== 'content') { + throw new SessionArtifactValidationError( + 'mode must be "metadata" or "content"', + 'mode', + ); + } + options.mode = mode; + } + if (ttlDays !== undefined) { + if (mode === 'metadata') { + throw new SessionArtifactValidationError( + 'ttlDays is only valid with content pinning', + 'ttlDays', + ); + } + if ( + typeof ttlDays !== 'number' || + !Number.isSafeInteger(ttlDays) || + ttlDays <= 0 + ) { + throw new SessionArtifactValidationError( + 'ttlDays must be a positive safe integer', + 'ttlDays', + ); + } + if (ttlDays > SESSION_ARTIFACT_MAX_TTL_DAYS) { + throw new SessionArtifactValidationError( + `ttlDays must be at most ${SESSION_ARTIFACT_MAX_TTL_DAYS}`, + 'ttlDays', + ); + } + options.ttlDays = ttlDays; + } + if (clientRetained !== undefined) { + if (typeof clientRetained !== 'boolean') { + throw new SessionArtifactValidationError( + 'clientRetained must be a boolean', + 'clientRetained', + ); + } + options.clientRetained = clientRetained; + } + return options; +} + +function parseArtifactRemoveRequest( + req: Request, +): SessionArtifactRemoveRequest { + const body = safeBody(req); + const deleteContent = body['deleteContent']; + if (deleteContent === undefined) { + return {}; + } + if (typeof deleteContent !== 'boolean') { + throw new SessionArtifactValidationError( + 'deleteContent must be a boolean', + 'deleteContent', + ); + } + return deleteContent ? { deleteContent } : {}; +} + +function parseArtifactUnpinRequest(req: Request): SessionArtifactUnpinRequest { + const body = safeBody(req); + const retention = body['retention']; + if (retention === undefined) { + return {}; + } + if (retention !== 'ephemeral' && retention !== 'restorable') { + throw new SessionArtifactValidationError( + 'retention must be "ephemeral" or "restorable"', + 'retention', + ); + } + return { retention }; +} + +function nonEmptyArtifactPinRequest( + options: SessionArtifactPinRequest, +): SessionArtifactPinRequest | undefined { + return Object.keys(options).length === 0 ? undefined : options; +} + +function nonEmptyArtifactRemoveRequest( + options: SessionArtifactRemoveRequest, +): SessionArtifactRemoveRequest | undefined { + return Object.keys(options).length === 0 ? undefined : options; +} + +function nonEmptyArtifactUnpinRequest( + options: SessionArtifactUnpinRequest, +): SessionArtifactUnpinRequest | undefined { + return Object.keys(options).length === 0 ? undefined : options; +} + export function registerSessionRoutes( app: Application, deps: RegisterSessionRoutesDeps, @@ -652,10 +759,12 @@ export function registerSessionRoutes( return; } try { + const options = parseArtifactPinRequest(req); const result = await bridge.pinSessionArtifact( sessionId, artifactId, clientId !== undefined ? { clientId } : undefined, + nonEmptyArtifactPinRequest(options), ); res.status(200).json(result); } catch (err) { @@ -690,10 +799,12 @@ export function registerSessionRoutes( return; } try { + const options = parseArtifactUnpinRequest(req); const result = await bridge.unpinSessionArtifact( sessionId, artifactId, clientId !== undefined ? { clientId } : undefined, + nonEmptyArtifactUnpinRequest(options), ); res.status(200).json(result); } catch (err) { @@ -759,10 +870,12 @@ export function registerSessionRoutes( return; } try { + const options = parseArtifactRemoveRequest(req); const result = await bridge.removeSessionArtifact( sessionId, artifactId, clientId !== undefined ? { clientId } : undefined, + nonEmptyArtifactRemoveRequest(options), ); res.status(200).json(result); } catch (err) { diff --git a/packages/cli/src/serve/run-qwen-serve.ts b/packages/cli/src/serve/run-qwen-serve.ts index 697e342988c..1dc30a48350 100644 --- a/packages/cli/src/serve/run-qwen-serve.ts +++ b/packages/cli/src/serve/run-qwen-serve.ts @@ -765,6 +765,8 @@ function currentServeFeaturesForRunQwenServe( : {}), persistSettingAvailable: true, sessionShellCommandEnabled, + sessionArtifactsPersistenceAvailable: true, + sessionArtifactsContentRetentionAvailable: true, rateLimit: opts.rateLimit === true, reloadAvailable: true, // Advertise the same WS feature flags as the runtime path (serve-features.ts) diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 56cc5ba117a..207e79dc9c1 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -187,8 +187,6 @@ const EXPECTED_STAGE1_FEATURES = [ 'session_cancel', 'session_events', 'session_artifacts', - 'session_artifacts_persistence', - 'session_artifacts_content_retention', 'slow_client_warning', 'typed_event_schema', 'session_set_model', @@ -293,7 +291,15 @@ const EXPECTED_REGISTERED_FEATURES = [ // All four conditional tags filtered from the stage1 baseline so // they appear here in their registry-declaration order, not the // stage1 order. - ...EXPECTED_STAGE1_FEATURES.filter( + ...EXPECTED_STAGE1_FEATURES.flatMap((feature) => + feature === 'session_artifacts' + ? [ + feature, + 'session_artifacts_persistence', + 'session_artifacts_content_retention', + ] + : [feature], + ).filter( (f) => f !== 'workspace_init' && f !== 'workspace_github_setup' && @@ -643,16 +649,19 @@ interface FakeBridge extends AcpSessionBridge { sessionId: string; artifactId: string; context?: BridgeClientRequestContext; + options?: Parameters[3]; }>; pinSessionArtifactCalls: Array<{ sessionId: string; artifactId: string; context?: BridgeClientRequestContext; + options?: Parameters[3]; }>; unpinSessionArtifactCalls: Array<{ sessionId: string; artifactId: string; context?: BridgeClientRequestContext; + options?: Parameters[3]; }>; fsckSessionArtifactsCalls: string[]; gcSessionArtifactsCalls: string[]; @@ -1497,29 +1506,32 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { }); return addSessionArtifactImpl(sessionId, artifact, context); }, - async removeSessionArtifact(sessionId, artifactId, context) { + async removeSessionArtifact(sessionId, artifactId, context, options) { removeSessionArtifactCalls.push({ sessionId, artifactId, ...(context ? { context } : {}), + ...(options ? { options } : {}), }); - return removeSessionArtifactImpl(sessionId, artifactId, context); + return removeSessionArtifactImpl(sessionId, artifactId, context, options); }, - async pinSessionArtifact(sessionId, artifactId, context) { + async pinSessionArtifact(sessionId, artifactId, context, options) { pinSessionArtifactCalls.push({ sessionId, artifactId, ...(context ? { context } : {}), + ...(options ? { options } : {}), }); - return pinSessionArtifactImpl(sessionId, artifactId, context); + return pinSessionArtifactImpl(sessionId, artifactId, context, options); }, - async unpinSessionArtifact(sessionId, artifactId, context) { + async unpinSessionArtifact(sessionId, artifactId, context, options) { unpinSessionArtifactCalls.push({ sessionId, artifactId, ...(context ? { context } : {}), + ...(options ? { options } : {}), }); - return unpinSessionArtifactImpl(sessionId, artifactId, context); + return unpinSessionArtifactImpl(sessionId, artifactId, context, options); }, async fsckSessionArtifacts(sessionId) { fsckSessionArtifactsCalls.push(sessionId); @@ -2099,6 +2111,42 @@ describe('createServeApp', () => { ); continue; } + if (feature === 'session_artifacts_persistence') { + expect( + predicate({ sessionArtifactsPersistenceAvailable: true }), + ).toBe(true); + expect( + predicate({ sessionArtifactsPersistenceAvailable: false }), + ).toBe(false); + expect(predicate({})).toBe(false); + expect( + getAdvertisedServeFeatures(undefined, { + sessionArtifactsPersistenceAvailable: true, + }), + ).toContain(feature); + expect(getAdvertisedServeFeatures(undefined, {})).not.toContain( + feature, + ); + continue; + } + if (feature === 'session_artifacts_content_retention') { + expect( + predicate({ sessionArtifactsContentRetentionAvailable: true }), + ).toBe(true); + expect( + predicate({ sessionArtifactsContentRetentionAvailable: false }), + ).toBe(false); + expect(predicate({})).toBe(false); + expect( + getAdvertisedServeFeatures(undefined, { + sessionArtifactsContentRetentionAvailable: true, + }), + ).toContain(feature); + expect(getAdvertisedServeFeatures(undefined, {})).not.toContain( + feature, + ); + continue; + } if (feature === 'workspace_reload') { expect(predicate({ reloadAvailable: true })).toBe(true); expect(predicate({ reloadAvailable: false })).toBe(false); @@ -2500,6 +2548,8 @@ describe('createServeApp', () => { expect(res.body.features).toEqual( getAdvertisedServeFeatures(undefined, { mcpPoolActive: true, + sessionArtifactsPersistenceAvailable: true, + sessionArtifactsContentRetentionAvailable: true, }), ); expect(res.body.modelServices).toEqual([]); @@ -7299,6 +7349,27 @@ describe('createServeApp', () => { ]); }); + it('DELETE /session/:id/artifacts/:artifactId forwards content deletion option', async () => { + const bridge = fakeBridge(); + const app = createServeApp(tokenOpts, undefined, { bridge }); + + const res = await auth( + request(app) + .delete('/session/session-A/artifacts/artifact-1') + .send({ deleteContent: true }), + ).set('X-Qwen-Client-Id', 'client-1'); + + expect(res.status).toBe(200); + expect(bridge.removeSessionArtifactCalls).toEqual([ + { + sessionId: 'session-A', + artifactId: 'artifact-1', + context: { clientId: 'client-1' }, + options: { deleteContent: true }, + }, + ]); + }); + it('POST /session/:id/artifacts/:artifactId/pin forwards mutation auth context', async () => { const bridge = fakeBridge(); const app = createServeApp(tokenOpts, undefined, { bridge }); @@ -7322,6 +7393,63 @@ describe('createServeApp', () => { ]); }); + it('POST /session/:id/artifacts/:artifactId/pin forwards retention options', async () => { + const bridge = fakeBridge(); + const app = createServeApp(tokenOpts, undefined, { bridge }); + + const res = await auth( + request(app) + .post('/session/session-A/artifacts/artifact-1/pin') + .send({ mode: 'content', ttlDays: 7, clientRetained: false }), + ).set('X-Qwen-Client-Id', 'client-1'); + + expect(res.status).toBe(200); + expect(bridge.pinSessionArtifactCalls).toEqual([ + { + sessionId: 'session-A', + artifactId: 'artifact-1', + context: { clientId: 'client-1' }, + options: { mode: 'content', ttlDays: 7, clientRetained: false }, + }, + ]); + }); + + it('POST /session/:id/artifacts/:artifactId/pin rejects ttlDays for metadata pinning', async () => { + const bridge = fakeBridge(); + const app = createServeApp(tokenOpts, undefined, { bridge }); + + const res = await auth( + request(app) + .post('/session/session-A/artifacts/artifact-1/pin') + .send({ mode: 'metadata', ttlDays: 7 }), + ).set('X-Qwen-Client-Id', 'client-1'); + + expect(res.status).toBe(400); + expect(res.body.error).toMatchObject({ + code: 'VALIDATION_FAILED', + field: 'ttlDays', + }); + expect(bridge.pinSessionArtifactCalls).toHaveLength(0); + }); + + it('POST /session/:id/artifacts/:artifactId/pin rejects ttlDays above the maximum', async () => { + const bridge = fakeBridge(); + const app = createServeApp(tokenOpts, undefined, { bridge }); + + const res = await auth( + request(app) + .post('/session/session-A/artifacts/artifact-1/pin') + .send({ mode: 'content', ttlDays: 366 }), + ).set('X-Qwen-Client-Id', 'client-1'); + + expect(res.status).toBe(400); + expect(res.body.error).toMatchObject({ + code: 'VALIDATION_FAILED', + field: 'ttlDays', + }); + expect(bridge.pinSessionArtifactCalls).toHaveLength(0); + }); + it('DELETE /session/:id/artifacts/:artifactId/pin forwards mutation auth context', async () => { const bridge = fakeBridge(); const app = createServeApp(tokenOpts, undefined, { bridge }); @@ -7345,6 +7473,27 @@ describe('createServeApp', () => { ]); }); + it('DELETE /session/:id/artifacts/:artifactId/pin forwards target retention', async () => { + const bridge = fakeBridge(); + const app = createServeApp(tokenOpts, undefined, { bridge }); + + const res = await auth( + request(app) + .delete('/session/session-A/artifacts/artifact-1/pin') + .send({ retention: 'ephemeral' }), + ).set('X-Qwen-Client-Id', 'client-1'); + + expect(res.status).toBe(200); + expect(bridge.unpinSessionArtifactCalls).toEqual([ + { + sessionId: 'session-A', + artifactId: 'artifact-1', + context: { clientId: 'client-1' }, + options: { retention: 'ephemeral' }, + }, + ]); + }); + it('GET /session/:id/artifacts/fsck returns content integrity status', async () => { const bridge = fakeBridge({ fsckSessionArtifactsImpl: async () => ({ diff --git a/packages/cli/src/serve/server/serve-features.ts b/packages/cli/src/serve/server/serve-features.ts index a804f1b22cd..fa0ebb14345 100644 --- a/packages/cli/src/serve/server/serve-features.ts +++ b/packages/cli/src/serve/server/serve-features.ts @@ -78,6 +78,8 @@ export function createServeFeatures( : {}), persistSettingAvailable, sessionShellCommandEnabled, + sessionArtifactsPersistenceAvailable: true, + sessionArtifactsContentRetentionAvailable: true, rateLimit: opts.rateLimit === true, reloadAvailable, clientMcpOverWsEnabled: opts.clientMcpOverWs === true, diff --git a/packages/core/src/services/session-artifact-persistence.test.ts b/packages/core/src/services/session-artifact-persistence.test.ts index 989b78bae07..be12dcb0461 100644 --- a/packages/core/src/services/session-artifact-persistence.test.ts +++ b/packages/core/src/services/session-artifact-persistence.test.ts @@ -156,6 +156,47 @@ describe('session artifact persistence records', () => { expect(snapshot?.artifacts[0]).not.toHaveProperty('contentRef'); }); + it('drops runtime warning fields during restore normalization', () => { + const restored = artifact('s1', 'https://example.com/sticky', { + persistenceWarning: 'sticky_override_active', + } as Partial); + + const snapshot = rebuildSessionArtifactSnapshot([ + event({ + v: SESSION_ARTIFACT_PERSISTENCE_VERSION, + sessionId: 's1', + sequence: 1, + recordedAt: '2026-07-04T00:00:00.000Z', + changes: [ + { action: 'created', artifactId: restored.id, artifact: restored }, + ], + }), + ]); + + expect(snapshot?.artifacts[0]).not.toHaveProperty('persistenceWarning'); + }); + + it('drops persisted client ids during restore normalization', () => { + const restored = { + ...artifact('s1', 'https://example.com/client-owned'), + clientId: 'client-a', + } as PersistedSessionArtifact; + + const snapshot = rebuildSessionArtifactSnapshot([ + event({ + v: SESSION_ARTIFACT_PERSISTENCE_VERSION, + sessionId: 's1', + sequence: 1, + recordedAt: '2026-07-04T00:00:00.000Z', + changes: [ + { action: 'created', artifactId: restored.id, artifact: restored }, + ], + }), + ]); + + expect(snapshot?.artifacts[0]).not.toHaveProperty('clientId'); + }); + it('remaps forked payloads to the new session without carrying pinned content', () => { const source = artifact('source-session', 'https://example.com/report', { retention: 'pinned', @@ -191,14 +232,59 @@ describe('session artifact persistence records', () => { 'url:https://example.com/report', ), retention: 'restorable', - restoreState: 'restored', - persistenceWarning: 'metadata_only_restore', }); expect(forked).not.toHaveProperty('contentRef'); expect(forked).not.toHaveProperty('expiresAt'); + expect(forked).not.toHaveProperty('restoreState'); + expect(forked).not.toHaveProperty('persistenceWarning'); + }); + + it('remaps forked tombstone changes when artifact metadata is present', () => { + const source = artifact('source-session', 'https://example.com/deleted'); + + const remapped = remapSessionArtifactPayloadForFork( + { + v: SESSION_ARTIFACT_PERSISTENCE_VERSION, + sessionId: 'source-session', + sequence: 6, + recordedAt: '2026-07-04T00:00:00.000Z', + changes: [ + { + action: 'removed', + artifactId: source.id, + artifact: source, + reason: 'unpin_to_ephemeral', + }, + ], + }, + 'source-session', + 'forked-session', + ) as SessionArtifactEventRecordPayload; + + expect(remapped.changes).toEqual([ + expect.objectContaining({ + action: 'removed', + artifactId: stableSessionArtifactId( + 'forked-session', + 'url:https://example.com/deleted', + ), + reason: 'unpin_to_ephemeral', + }), + ]); + expect(remapped.changes[0]?.artifact).toMatchObject({ + id: stableSessionArtifactId( + 'forked-session', + 'url:https://example.com/deleted', + ), + retention: 'restorable', + }); + expect(remapped.changes[0]?.artifact).not.toHaveProperty('restoreState'); + expect(remapped.changes[0]?.artifact).not.toHaveProperty( + 'persistenceWarning', + ); }); - it('remaps forked snapshot payloads and clears inherited tombstone state', () => { + it('remaps forked snapshot payloads and drops bare tombstone state', () => { const source = artifact('source-session', 'https://example.com/snapshot', { retention: 'pinned', contentRef: { @@ -234,10 +320,10 @@ describe('session artifact persistence records', () => { 'url:https://example.com/snapshot', ), retention: 'restorable', - restoreState: 'restored', - persistenceWarning: 'metadata_only_restore', }); expect(remapped.artifacts[0]).not.toHaveProperty('contentRef'); expect(remapped.artifacts[0]).not.toHaveProperty('expiresAt'); + expect(remapped.artifacts[0]).not.toHaveProperty('restoreState'); + expect(remapped.artifacts[0]).not.toHaveProperty('persistenceWarning'); }); }); diff --git a/packages/core/src/services/session-artifact-persistence.ts b/packages/core/src/services/session-artifact-persistence.ts index 272129cad8d..f3f1bb39cb5 100644 --- a/packages/core/src/services/session-artifact-persistence.ts +++ b/packages/core/src/services/session-artifact-persistence.ts @@ -23,7 +23,8 @@ export type SessionArtifactPersistenceWarning = | 'content_expired' | 'content_hash_mismatch' | 'metadata_only_restore' - | 'restore_validation_failed'; + | 'restore_validation_failed' + | 'sticky_override_active'; export type PersistedSessionArtifactKind = | 'file' @@ -74,13 +75,10 @@ export interface PersistedSessionArtifact { updatedAt: string; persistedAt?: string; expiresAt?: string; - restoreState?: SessionArtifactRestoreState; - persistenceWarning?: SessionArtifactPersistenceWarning; contentRef?: SessionArtifactContentRef; toolCallId?: string; toolName?: string; hookEventName?: string; - clientId?: string; } export type SessionArtifactPersistedChangeAction = @@ -307,8 +305,6 @@ function remapSessionArtifactForFork( id, retention: artifact.retention === 'pinned' ? 'restorable' : artifact.retention, - restoreState: 'restored', - persistenceWarning: 'metadata_only_restore', }; delete next.contentRef; delete next.expiresAt; @@ -441,27 +437,10 @@ function normalizePersistedArtifact( const sizeBytes = getNonNegativeInteger(value, 'sizeBytes'); const persistedAt = getString(value, 'persistedAt'); const expiresAt = getString(value, 'expiresAt'); - const restoreState = normalizeLiteral( - value['restoreState'], - ['live', 'restored', 'unverified', 'blocked'], - ); - const persistenceWarning = - normalizeLiteral( - value['persistenceWarning'], - [ - 'persistence_unavailable', - 'content_missing', - 'content_expired', - 'content_hash_mismatch', - 'metadata_only_restore', - 'restore_validation_failed', - ], - ); const contentRef = normalizeContentRef(value['contentRef']); const toolCallId = getString(value, 'toolCallId'); const toolName = getString(value, 'toolName'); const hookEventName = getString(value, 'hookEventName'); - const clientId = getString(value, 'clientId'); return { id, kind, @@ -482,13 +461,10 @@ function normalizePersistedArtifact( updatedAt: getString(value, 'updatedAt') ?? new Date(0).toISOString(), ...(persistedAt ? { persistedAt } : {}), ...(expiresAt ? { expiresAt } : {}), - ...(restoreState ? { restoreState } : {}), - ...(persistenceWarning ? { persistenceWarning } : {}), ...(contentRef ? { contentRef } : {}), ...(toolCallId ? { toolCallId } : {}), ...(toolName ? { toolName } : {}), ...(hookEventName ? { hookEventName } : {}), - ...(clientId ? { clientId } : {}), }; } diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts index f9a11ee846b..be5fb44af5d 100644 --- a/packages/sdk-typescript/src/daemon/DaemonClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -98,6 +98,9 @@ import type { DaemonToolToggleResult, DaemonSessionArtifactInput, DaemonSessionArtifactMutationResult, + DaemonSessionArtifactPinOptions, + DaemonSessionArtifactRemoveOptions, + DaemonSessionArtifactUnpinOptions, DaemonSessionArtifactsEnvelope, DaemonSessionArtifactFsckResult, DaemonSessionArtifactGcResult, @@ -3108,6 +3111,7 @@ export class DaemonClient { sessionId: string, artifactId: string, clientId?: string, + options?: DaemonSessionArtifactRemoveOptions, ): Promise { return await this.jsonRequest( `/session/${encodeURIComponent(sessionId)}/artifacts/${encodeURIComponent(artifactId)}`, @@ -3115,6 +3119,7 @@ export class DaemonClient { { method: 'DELETE', clientId, + ...(options !== undefined ? { body: options } : {}), }, ); } @@ -3123,6 +3128,7 @@ export class DaemonClient { sessionId: string, artifactId: string, clientId?: string, + options?: DaemonSessionArtifactPinOptions, ): Promise { return await this.jsonRequest( `/session/${encodeURIComponent(sessionId)}/artifacts/${encodeURIComponent(artifactId)}/pin`, @@ -3130,6 +3136,7 @@ export class DaemonClient { { method: 'POST', clientId, + ...(options !== undefined ? { body: options } : {}), }, ); } @@ -3138,6 +3145,7 @@ export class DaemonClient { sessionId: string, artifactId: string, clientId?: string, + options?: DaemonSessionArtifactUnpinOptions, ): Promise { return await this.jsonRequest( `/session/${encodeURIComponent(sessionId)}/artifacts/${encodeURIComponent(artifactId)}/pin`, @@ -3145,6 +3153,7 @@ export class DaemonClient { { method: 'DELETE', clientId, + ...(options !== undefined ? { body: options } : {}), }, ); } diff --git a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts index 1068ca423d3..fa2313f0449 100644 --- a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts @@ -34,6 +34,9 @@ import type { DaemonSessionArtifactFsckResult, DaemonSessionArtifactGcResult, DaemonSessionArtifactMutationResult, + DaemonSessionArtifactPinOptions, + DaemonSessionArtifactRemoveOptions, + DaemonSessionArtifactUnpinOptions, DaemonSessionArtifactsEnvelope, DaemonSessionState, DaemonSession, @@ -431,31 +434,37 @@ export class DaemonSessionClient { async removeArtifact( artifactId: string, + options?: DaemonSessionArtifactRemoveOptions, ): Promise { return await this.client.removeSessionArtifact( this.sessionId, artifactId, this.clientId, + options, ); } async pinArtifact( artifactId: string, + options?: DaemonSessionArtifactPinOptions, ): Promise { return await this.client.pinSessionArtifact( this.sessionId, artifactId, this.clientId, + options, ); } async unpinArtifact( artifactId: string, + options?: DaemonSessionArtifactUnpinOptions, ): Promise { return await this.client.unpinSessionArtifact( this.sessionId, artifactId, this.clientId, + options, ); } diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts index 2cc109e60ba..cda2b4ac0a4 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -523,7 +523,10 @@ export type { DaemonSessionArtifactInput, DaemonSessionArtifactKind, DaemonSessionArtifactMutationResult, + DaemonSessionArtifactPinOptions, + DaemonSessionArtifactRemoveOptions, DaemonSessionArtifactRemovalReason, + DaemonSessionArtifactUnpinOptions, DaemonSessionArtifactsEnvelope, DaemonSessionArtifactSource, DaemonSessionArtifactStatus, diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index 360ed350fdd..050b0884b0d 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -474,7 +474,8 @@ export type KnownDaemonSessionArtifactPersistenceWarning = | 'content_expired' | 'content_hash_mismatch' | 'metadata_only_restore' - | 'restore_validation_failed'; + | 'restore_validation_failed' + | 'sticky_override_active'; export type DaemonSessionArtifactPersistenceWarning = OpenStringUnion; @@ -569,6 +570,20 @@ export interface DaemonSessionArtifactMutationResult { warnings?: string[]; } +export interface DaemonSessionArtifactPinOptions { + mode?: 'metadata' | 'content'; + ttlDays?: number; + clientRetained?: boolean; +} + +export interface DaemonSessionArtifactRemoveOptions { + deleteContent?: boolean; +} + +export interface DaemonSessionArtifactUnpinOptions { + retention?: 'ephemeral' | 'restorable'; +} + export interface DaemonSessionArtifactFsckResult { checked: number; missing: string[]; diff --git a/packages/sdk-typescript/test/unit/DaemonClient.test.ts b/packages/sdk-typescript/test/unit/DaemonClient.test.ts index 0300ffb28b4..c0eed03da4b 100644 --- a/packages/sdk-typescript/test/unit/DaemonClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonClient.test.ts @@ -305,6 +305,11 @@ describe('DaemonClient', () => { await expect( client.removeSessionArtifact('session/1', 'artifact/1', 'client-1'), ).resolves.toEqual(result); + await expect( + client.removeSessionArtifact('session/1', 'artifact/1', 'client-1', { + deleteContent: true, + }), + ).resolves.toEqual(result); expect(calls[0]).toMatchObject({ url: 'http://daemon/session/session%2F1/artifacts/artifact%2F1', method: 'DELETE', @@ -313,6 +318,15 @@ describe('DaemonClient', () => { }, body: null, }); + expect(calls[1]).toMatchObject({ + url: 'http://daemon/session/session%2F1/artifacts/artifact%2F1', + method: 'DELETE', + headers: { + 'content-type': 'application/json', + 'x-qwen-client-id': 'client-1', + }, + body: JSON.stringify({ deleteContent: true }), + }); }); it('pins and unpins session artifacts with encoded ids', async () => { @@ -330,6 +344,18 @@ describe('DaemonClient', () => { await expect( client.unpinSessionArtifact('session/1', 'artifact/1', 'client-1'), ).resolves.toEqual(result); + await expect( + client.pinSessionArtifact('session/1', 'artifact/1', 'client-1', { + mode: 'content', + ttlDays: 7, + clientRetained: false, + }), + ).resolves.toEqual(result); + await expect( + client.unpinSessionArtifact('session/1', 'artifact/1', 'client-1', { + retention: 'ephemeral', + }), + ).resolves.toEqual(result); expect(calls[0]).toMatchObject({ url: 'http://daemon/session/session%2F1/artifacts/artifact%2F1/pin', @@ -343,6 +369,28 @@ describe('DaemonClient', () => { headers: { 'x-qwen-client-id': 'client-1' }, body: null, }); + expect(calls[2]).toMatchObject({ + url: 'http://daemon/session/session%2F1/artifacts/artifact%2F1/pin', + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-qwen-client-id': 'client-1', + }, + body: JSON.stringify({ + mode: 'content', + ttlDays: 7, + clientRetained: false, + }), + }); + expect(calls[3]).toMatchObject({ + url: 'http://daemon/session/session%2F1/artifacts/artifact%2F1/pin', + method: 'DELETE', + headers: { + 'content-type': 'application/json', + 'x-qwen-client-id': 'client-1', + }, + body: JSON.stringify({ retention: 'ephemeral' }), + }); }); it('runs fsck and gc for session artifact content', async () => { From 79978079d30d0ca167ad8c8c5dd2ef872f449bc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Sun, 5 Jul 2026 02:45:47 +0800 Subject: [PATCH 17/61] fix(daemon): close artifact persistence audit gaps --- packages/acp-bridge/src/bridge.test.ts | 4 +- packages/acp-bridge/src/bridge.ts | 174 +++++++++++++---- .../src/sessionArtifactContentStore.ts | 77 ++++---- .../acp-bridge/src/sessionArtifacts.test.ts | 183 ++++++++++++++++++ packages/acp-bridge/src/sessionArtifacts.ts | 62 ++++-- packages/cli/src/acp-integration/acpAgent.ts | 21 ++ .../src/services/chatRecordingService.test.ts | 30 +++ .../core/src/services/chatRecordingService.ts | 4 +- 8 files changed, 462 insertions(+), 93 deletions(-) diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index bf257a5f1f8..3e74ed7e3af 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -11759,7 +11759,9 @@ describe('session idle reaper', () => { // No detach — simulates client crash. Reaper catches all 3. await vi.advanceTimersByTimeAsync(4_000); - expect(bridge.sessionCount).toBe(0); + await vi.waitFor(() => { + expect(bridge.sessionCount).toBe(0); + }); await bridge.shutdown(); } finally { diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index 381fd90e9a5..a848ff4fa85 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -19,6 +19,7 @@ import type { import type { ApprovalMode, RebuiltSessionArtifactSnapshot, + SessionArtifactContentRef, } from '@qwen-code/qwen-code-core'; import { DAEMON_TRACEPARENT_META_KEY, @@ -114,6 +115,7 @@ import { PermissionForbiddenError } from './bridgeErrors.js'; import { SessionArtifactStore, SessionArtifactValidationError, + type DaemonSessionArtifact, type SessionArtifactChange, type SessionArtifactInput, type SessionArtifactMutationResult, @@ -2422,6 +2424,46 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { } }; + const artifactReseedChanges = ( + before: readonly DaemonSessionArtifact[], + after: readonly DaemonSessionArtifact[], + ): SessionArtifactChange[] => { + const beforeById = new Map( + before.map((artifact) => [artifact.id, artifact]), + ); + const afterById = new Map(after.map((artifact) => [artifact.id, artifact])); + const changes: SessionArtifactChange[] = []; + for (const artifact of before) { + if (!afterById.has(artifact.id)) { + changes.push({ + action: 'removed', + artifactId: artifact.id, + artifact, + reason: 'explicit', + }); + } + } + for (const artifact of after) { + const previous = beforeById.get(artifact.id); + if (!previous) { + changes.push({ + action: 'created', + artifactId: artifact.id, + artifact, + }); + continue; + } + if (JSON.stringify(previous) !== JSON.stringify(artifact)) { + changes.push({ + action: 'updated', + artifactId: artifact.id, + artifact, + }); + } + } + return changes; + }; + const makeClientArtifactInput = ( artifact: SessionArtifactInput, clientId: string | undefined, @@ -3035,8 +3077,14 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { activePromptCounter--; touchActivity(); } + byId.delete(sessionId); + telemetry.metrics?.sessionLifecycle('close'); + // Tombstone the closed sessionId so any late `extNotification` + // from the (now-defunct) child can't seed the early-event buffer + // and leak into a future load/resume of the same persisted id. + ci?.client.markSessionClosed(sessionId); try { - await artifactContentStore.gc(sessionId, new Set()); + await gcArtifactContent(entry); } catch (error) { writeStderrLine( `qwen serve: session artifact GC failed during close for ${JSON.stringify( @@ -3044,12 +3092,6 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { )}: ${error instanceof Error ? error.message : String(error)}`, ); } - byId.delete(sessionId); - telemetry.metrics?.sessionLifecycle('close'); - // Tombstone the closed sessionId so any late `extNotification` - // from the (now-defunct) child can't seed the early-event buffer - // and leak into a future load/resume of the same persisted id. - ci?.client.markSessionClosed(sessionId); try { entry.events.publish({ type: 'session_closed', @@ -4324,7 +4366,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { const clientId = resolveTrustedClientId(entry, context?.clientId); const input = makeClientArtifactInput(artifact, clientId); const result: SessionArtifactMutationResult = - await entry.artifacts.upsertMany([input], { strict: true }); + await entry.artifacts.upsertMany([input], { strict: false }); publishArtifactChanges(entry, result.changes, clientId); return result; }, @@ -4337,19 +4379,13 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { const result = await entry.artifacts.remove(artifactId, { clientId }); const removeOptions = artifactRemoveOptions(options); const warnings = [...(result.warnings ?? [])]; - if (removeOptions.deleteContent === true && result.changes.length > 0) { - if ( - artifact?.source === 'client' && - artifact.clientId !== undefined && - artifact.clientId === clientId - ) { - try { - await gcArtifactContent(entry); - } catch { + if (result.changes.length > 0) { + try { + await gcArtifactContent(entry); + } catch { + if (removeOptions.deleteContent === true || artifact?.contentRef) { warnings.push('content_delete_preserved'); } - } else { - warnings.push('content_delete_preserved'); } } publishArtifactChanges(entry, result.changes, clientId); @@ -4370,28 +4406,49 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { publishArtifactChanges(entry, result.changes, clientId); return result; } - const contentRef = - mode === 'metadata' - ? undefined - : await artifactContentStore.pinWorkspaceFile( - sessionId, - artifact, - entry.workspaceCwd, + let contentRef: SessionArtifactContentRef | undefined; + try { + contentRef = + mode === 'metadata' + ? undefined + : await artifactContentStore.pinWorkspaceFile( + sessionId, + artifact, + entry.workspaceCwd, + ); + if (mode === 'content' && !contentRef) { + throw new SessionArtifactValidationError( + 'artifact content is not available for retention', + 'artifactId', + ); + } + const result = await entry.artifacts.pin(artifactId, { + retention: mode === 'metadata' ? 'restorable' : 'pinned', + contentRef, + expiresAt: artifactExpiresAt(options, mode), + clientRetained: artifactClientRetained(options), + }); + if (contentRef) { + artifactContentStore.releaseContentRef(contentRef); + } + if (contentRef && result.changes.length === 0) { + await gcArtifactContent(entry).catch(() => undefined); + if (!(await entry.artifacts.get(artifactId))) { + throw new SessionArtifactValidationError( + 'artifact no longer exists', + 'artifactId', ); - if (mode === 'content' && !contentRef) { - throw new SessionArtifactValidationError( - 'artifact content is not available for retention', - 'artifactId', - ); + } + } + publishArtifactChanges(entry, result.changes, clientId); + return result; + } catch (error) { + if (contentRef) { + artifactContentStore.releaseContentRef(contentRef); + await gcArtifactContent(entry).catch(() => undefined); + } + throw error; } - const result = await entry.artifacts.pin(artifactId, { - retention: mode === 'metadata' ? 'restorable' : 'pinned', - contentRef, - expiresAt: artifactExpiresAt(options, mode), - clientRetained: artifactClientRetained(options), - }); - publishArtifactChanges(entry, result.changes, clientId); - return result; }, async unpinSessionArtifact(sessionId, artifactId, context, options) { @@ -5773,6 +5830,45 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { const targetTurnIndex = (response['targetTurnIndex'] as number) ?? 0; const filesChanged = (response['filesChanged'] as string[]) ?? []; const filesFailed = (response['filesFailed'] as string[]) ?? []; + const artifactSnapshot = restoredArtifactSnapshotFromState( + response as BridgeSessionState, + ); + if (artifactSnapshot) { + const beforeArtifacts = (await entry.artifacts.list()).artifacts; + const artifactRestoreWarnings = await entry.artifacts.restore( + artifactSnapshot, + { + verifyContentRef: (artifact) => + artifact.contentRef + ? artifactContentStore.verifyContentRef( + entry.sessionId, + artifact.id, + artifact.contentRef, + ) + : Promise.resolve(undefined), + }, + ); + for (const warning of artifactRestoreWarnings) { + writeStderrLine( + `[artifacts] session=${entry.sessionId} action=rewind_restore_warning warning=${JSON.stringify( + warning, + )}`, + ); + } + const afterArtifacts = (await entry.artifacts.list()).artifacts; + publishArtifactChanges( + entry, + artifactReseedChanges(beforeArtifacts, afterArtifacts), + originatorClientId, + ); + await gcArtifactContent(entry).catch((error) => { + writeStderrLine( + `qwen serve: session artifact GC failed during rewind for ${JSON.stringify( + sessionId, + )}: ${error instanceof Error ? error.message : String(error)}`, + ); + }); + } try { entry.events.publish({ diff --git a/packages/acp-bridge/src/sessionArtifactContentStore.ts b/packages/acp-bridge/src/sessionArtifactContentStore.ts index b1ad6a9dbda..6bbaa879a57 100644 --- a/packages/acp-bridge/src/sessionArtifactContentStore.ts +++ b/packages/acp-bridge/src/sessionArtifactContentStore.ts @@ -45,6 +45,7 @@ export interface SessionArtifactGcResult { export class SessionArtifactContentStore { private readonly rootDir: string; private writeQueue: Promise = Promise.resolve(); + private readonly leasedContentIds = new Set(); constructor(rootDir = defaultContentRoot()) { this.rootDir = rootDir; @@ -122,6 +123,7 @@ export class SessionArtifactContentStore { createdAt, }; await writeManifestAtomic(contentDir, manifest); + this.leasedContentIds.add(contentId); return { kind: 'managed_copy', contentId, @@ -140,6 +142,10 @@ export class SessionArtifactContentStore { }); } + releaseContentRef(ref: SessionArtifactContentRef): void { + this.leasedContentIds.delete(ref.contentId); + } + async fsck( contentRefs: readonly SessionArtifactContentRef[], ): Promise { @@ -218,42 +224,47 @@ export class SessionArtifactContentStore { sessionId: string, referencedContentIds: ReadonlySet, ): Promise { - const removed: string[] = []; - const retained: string[] = []; - let entries: string[]; - try { - entries = await fs.readdir(this.rootDir); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return { removed, retained }; - } - throw error; - } - for (const entry of entries) { - if (entry === '.tmp') { - await cleanTmpDir(path.join(this.rootDir, entry)); - continue; - } - const fullPath = path.join(this.rootDir, entry); - if (referencedContentIds.has(entry)) { - retained.push(entry); - continue; - } - let manifest: ContentManifest; + return this.enqueueWrite(async () => { + const removed: string[] = []; + const retained: string[] = []; + let entries: string[]; try { - manifest = await readManifest(path.join(fullPath, 'manifest.json')); - } catch { - retained.push(entry); - continue; + entries = await fs.readdir(this.rootDir); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { removed, retained }; + } + throw error; } - if (manifest.sessionId !== sessionId) { - retained.push(entry); - continue; + for (const entry of entries) { + if (entry === '.tmp') { + await cleanTmpDir(path.join(this.rootDir, entry)); + continue; + } + const fullPath = path.join(this.rootDir, entry); + if ( + referencedContentIds.has(entry) || + this.leasedContentIds.has(entry) + ) { + retained.push(entry); + continue; + } + let manifest: ContentManifest; + try { + manifest = await readManifest(path.join(fullPath, 'manifest.json')); + } catch { + retained.push(entry); + continue; + } + if (manifest.sessionId !== sessionId) { + retained.push(entry); + continue; + } + await fs.rm(fullPath, { recursive: true, force: true }); + removed.push(entry); } - await fs.rm(fullPath, { recursive: true, force: true }); - removed.push(entry); - } - return { removed, retained }; + return { removed, retained }; + }); } private async usedBytes(): Promise { diff --git a/packages/acp-bridge/src/sessionArtifacts.test.ts b/packages/acp-bridge/src/sessionArtifacts.test.ts index 50a1db7f054..56d79fe802d 100644 --- a/packages/acp-bridge/src/sessionArtifacts.test.ts +++ b/packages/acp-bridge/src/sessionArtifacts.test.ts @@ -951,6 +951,71 @@ describe('SessionArtifactStore', () => { ).toEqual(['Pinned', 'New']); }); + it('rejects strict overflow when every live artifact is pinned', async () => { + const store = new SessionArtifactStore({ + sessionId: 's3-all-pinned', + workspaceCwd: workspace, + maxArtifacts: 1, + persistence: { + recordEvent: async () => {}, + recordSnapshot: async () => {}, + }, + }); + const created = await store.upsertMany( + [{ title: 'Pinned', url: 'https://example.com/pinned' }], + { strict: true }, + ); + await store.pin(created.changes[0]!.artifactId, { + contentRef: { + kind: 'managed_copy', + contentId: `${'a'.repeat(64)}-${'b'.repeat(16)}`, + sha256: 'a'.repeat(64), + sizeBytes: 12, + createdAt: '2026-07-04T00:00:00.000Z', + }, + }); + + await expect( + store.upsertMany([{ title: 'New', url: 'https://example.com/new' }], { + strict: true, + }), + ).rejects.toThrow(SessionArtifactValidationError); + await expect(store.list()).resolves.toMatchObject({ + artifacts: [{ title: 'Pinned', retention: 'pinned' }], + }); + }); + + it('writes eviction removals to durable persistence', async () => { + const events: SessionArtifactEventRecordPayload[] = []; + const store = new SessionArtifactStore({ + sessionId: 's3-durable-eviction', + workspaceCwd: workspace, + maxArtifacts: 1, + persistence: { + recordEvent: async (payload) => { + events.push(payload); + }, + recordSnapshot: async () => {}, + }, + }); + const first = await store.upsertMany( + [{ title: 'First', url: 'https://example.com/first' }], + { strict: true }, + ); + await store.upsertMany( + [{ title: 'Second', url: 'https://example.com/second' }], + { strict: true }, + ); + + expect(events.at(-1)?.changes).toContainEqual( + expect.objectContaining({ + action: 'removed', + artifactId: first.changes[0]?.artifactId, + reason: 'eviction', + }), + ); + }); + it('drops newest artifacts created in the same batch when no older eviction candidate exists', async () => { const store = new SessionArtifactStore({ sessionId: 's3-same-batch-overflow', @@ -2302,6 +2367,41 @@ describe('SessionArtifactStore', () => { await expect(store.list()).resolves.toMatchObject({ artifacts: [] }); }); + it('removes live artifacts even when explicit tombstone persistence fails', async () => { + let calls = 0; + const store = new SessionArtifactStore({ + sessionId: 's11-remove-best-effort', + workspaceCwd: workspace, + persistence: { + recordEvent: async () => { + calls++; + if (calls > 1) { + throw new Error('disk full'); + } + }, + recordSnapshot: async () => {}, + }, + }); + const created = await store.upsertMany( + [{ title: 'Sensitive', url: 'https://example.com/sensitive' }], + { strict: true }, + ); + + const removed = await store.remove(created.changes[0]!.artifactId); + + expect(removed).toMatchObject({ + changes: [ + { + action: 'removed', + artifactId: created.changes[0]?.artifactId, + reason: 'explicit', + }, + ], + warnings: ['artifact removal not persisted; live removal kept'], + }); + await expect(store.list()).resolves.toMatchObject({ artifacts: [] }); + }); + it('restores rebuilt durable artifacts as metadata-only restored entries', async () => { const events: SessionArtifactEventRecordPayload[] = []; const source = new SessionArtifactStore({ @@ -2354,6 +2454,63 @@ describe('SessionArtifactStore', () => { }); }); + it('prunes over-limit restored artifacts and records eviction tombstones', async () => { + const sourceEvents: SessionArtifactEventRecordPayload[] = []; + const source = new SessionArtifactStore({ + sessionId: 's11-restore-prune', + workspaceCwd: workspace, + persistence: { + recordEvent: async (payload) => { + sourceEvents.push(payload); + }, + recordSnapshot: async () => {}, + }, + }); + await source.upsertMany( + [ + { title: 'One', url: 'https://example.com/one' }, + { title: 'Two', url: 'https://example.com/two' }, + ], + { strict: true }, + ); + const prunedEvents: SessionArtifactEventRecordPayload[] = []; + const restored = new SessionArtifactStore({ + sessionId: 's11-restore-prune', + workspaceCwd: workspace, + maxArtifacts: 1, + persistence: { + recordEvent: async (payload) => { + prunedEvents.push(payload); + }, + recordSnapshot: async () => {}, + }, + }); + const snapshot: RebuiltSessionArtifactSnapshot = { + v: 2, + sessionId: 's11-restore-prune', + sequence: 1, + artifacts: sourceEvents[0]!.changes.map((change) => change.artifact!), + tombstonedIds: [], + stickyEphemeralIds: [], + warnings: [], + }; + + await expect(restored.restore(snapshot)).resolves.toContain( + 'restored artifact list pruned to live limit', + ); + + await expect(restored.list()).resolves.toMatchObject({ + artifacts: [{ title: 'Two' }], + }); + expect(prunedEvents[0]?.changes).toContainEqual( + expect.objectContaining({ + action: 'removed', + artifactId: sourceEvents[0]?.changes[0]?.artifactId, + reason: 'eviction', + }), + ); + }); + it('strips invalid retained content refs during restore', async () => { const events: SessionArtifactEventRecordPayload[] = []; const source = new SessionArtifactStore({ @@ -2668,6 +2825,29 @@ describe('SessionArtifactContentStore', () => { await expect(fs.readdir(tmpDir)).resolves.toEqual([]); }); + it('retains leased content during gc until the pin flow releases it', async () => { + const contentStore = new SessionArtifactContentStore(contentRoot); + const ref = (await contentStore.pinWorkspaceFile( + 'content-session', + await workspaceArtifact('leased.txt', 'leased'), + workspace, + ))!; + + await expect( + contentStore.gc('content-session', new Set()), + ).resolves.toMatchObject({ + removed: [], + retained: expect.arrayContaining([ref.contentId]), + }); + + contentStore.releaseContentRef(ref); + await expect( + contentStore.gc('content-session', new Set()), + ).resolves.toMatchObject({ + removed: [ref.contentId], + }); + }); + it('serializes quota checks across concurrent pins', async () => { const contentStore = new SessionArtifactContentStore(contentRoot); const first = await workspaceArtifact('concurrent-a.txt', 'abc'); @@ -2765,6 +2945,9 @@ describe('SessionArtifactContentStore', () => { await workspaceArtifact('other.txt', 'other'), workspace, ))!; + contentStore.releaseContentRef(kept); + contentStore.releaseContentRef(orphaned); + contentStore.releaseContentRef(otherSession); await expect( contentStore.gc('content-session', new Set([kept.contentId])), diff --git a/packages/acp-bridge/src/sessionArtifacts.ts b/packages/acp-bridge/src/sessionArtifacts.ts index 75f4c33f7a9..79f459ac6a1 100644 --- a/packages/acp-bridge/src/sessionArtifacts.ts +++ b/packages/acp-bridge/src/sessionArtifacts.ts @@ -330,7 +330,9 @@ export class SessionArtifactStore { .filter((change) => change.action === 'created') .map((change) => change.artifactId), ); - changes.push(...(await this.evictOverflow(createdIds, changes))); + changes.push( + ...(await this.evictOverflow(createdIds, changes, options.strict)), + ); warnings.push(...(await this.persistChanges(changes, options.strict))); } catch (error) { @@ -415,7 +417,6 @@ export class SessionArtifactStore { options?: { clientId?: string }, ): Promise { return this.enqueue(async () => { - const before = this.cloneState(); const existing = this.artifacts.get(artifactId); if (!existing) { return { v: 1, sessionId: this.sessionId, changes: [] }; @@ -442,18 +443,13 @@ export class SessionArtifactStore { reason: 'explicit', }, ]; - try { - const warnings = await this.persistChanges(changes, true); - return { - v: 1, - sessionId: this.sessionId, - changes, - ...(warnings.length > 0 ? { warnings } : {}), - }; - } catch (error) { - this.restoreState(before); - throw error; - } + const warnings = await this.persistChanges(changes, false); + return { + v: 1, + sessionId: this.sessionId, + changes, + ...(warnings.length > 0 ? { warnings } : {}), + }; }); } @@ -720,6 +716,11 @@ export class SessionArtifactStore { ); } } + const evicted = await this.evictOverflow(new Set(), []); + if (evicted.length > 0) { + warnings.push('restored artifact list pruned to live limit'); + warnings.push(...(await this.persistChanges(evicted, false))); + } return warnings; }); } @@ -865,8 +866,13 @@ export class SessionArtifactStore { } private downgradeDurableChanges(changes: SessionArtifactChange[]): string[] { + let downgraded = false; + let removalNotPersisted = false; for (const change of changes) { - if (change.action === 'removed') continue; + if (change.action === 'removed') { + removalNotPersisted = true; + continue; + } const stored = this.artifacts.get(change.artifactId); if (!stored) continue; stored.retention = 'ephemeral'; @@ -874,10 +880,18 @@ export class SessionArtifactStore { delete stored.persistedAt; delete stored.contentRef; change.artifact = toPublicArtifact(stored); + downgraded = true; } - return [ - 'artifact persistence unavailable; durable artifacts kept ephemeral', - ]; + const warnings: string[] = []; + if (downgraded) { + warnings.push( + 'artifact persistence unavailable; durable artifacts kept ephemeral', + ); + } + if (removalNotPersisted) { + warnings.push('artifact removal not persisted; live removal kept'); + } + return warnings; } private applyDurableMarkers(changes: readonly SessionArtifactChange[]): void { @@ -1131,6 +1145,7 @@ export class SessionArtifactStore { private async evictOverflow( createdIds: Set, changes: SessionArtifactChange[], + strict = false, ): Promise { const removed: SessionArtifactChange[] = []; if (this.artifacts.size <= this.maxArtifacts) { @@ -1171,6 +1186,16 @@ export class SessionArtifactStore { const overflowCreated = Array.from(this.artifacts.values()) .filter((artifact) => createdInThisBatch.has(artifact.id)) .sort((a, b) => b.receivedSeq - a.receivedSeq); + if ( + strict && + overflowCreated.length > 0 && + this.artifacts.size > this.maxArtifacts + ) { + throw new SessionArtifactValidationError( + 'artifact store is full; no eviction candidate is available', + 'artifactId', + ); + } for (const artifact of overflowCreated) { if (this.artifacts.size <= this.maxArtifacts) { break; @@ -1672,7 +1697,6 @@ function toPersistedArtifact( } function isDurablePersistenceChange(change: SessionArtifactChange): boolean { - if (change.reason === 'eviction') return false; if (!change.artifact) return false; return change.artifact.retention !== 'ephemeral'; } diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index ec7cbe5b3db..6170b7ec54c 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -7115,6 +7115,26 @@ class QwenAgent implements Agent { filesFailed = [`file-history-rewind: ${reason}`]; } } + let artifactSnapshot: unknown; + try { + await session.getConfig().getChatRecordingService()?.flush(); + const cwd = session.getConfig().getProjectRoot(); + const sessionData = await runWithAcpRuntimeOutputDir( + this.settings, + cwd, + async () => { + const sessionService = new SessionService(cwd); + return sessionService.loadSession(sessionId); + }, + ); + artifactSnapshot = sessionData?.artifactSnapshot; + } catch (err) { + debugLogger.warn( + `[ACP] Failed to rebuild artifact snapshot after rewind for session=${sessionId}: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } return { success: true, @@ -7122,6 +7142,7 @@ class QwenAgent implements Agent { ...rewindResult, filesChanged, filesFailed, + ...(artifactSnapshot ? { artifactSnapshot } : {}), }; } case 'qwen/session/loadUpdates': { diff --git a/packages/core/src/services/chatRecordingService.test.ts b/packages/core/src/services/chatRecordingService.test.ts index e38deb2d55e..e7ae04042f8 100644 --- a/packages/core/src/services/chatRecordingService.test.ts +++ b/packages/core/src/services/chatRecordingService.test.ts @@ -1113,6 +1113,36 @@ describe('ChatRecordingService', () => { } }); + it('does not rewind the shared parent pointer when strict artifact writes race with normal appends', async () => { + let rejectStrict!: (error: Error) => void; + const strictWrite = new Promise((_resolve, reject) => { + rejectStrict = reject; + }); + vi.mocked(jsonl.writeLine) + .mockImplementationOnce(() => strictWrite) + .mockResolvedValue(undefined); + + const strict = chatRecordingService.recordSessionArtifactEvent({ + v: 2, + sessionId: 'test-session-id', + sequence: 1, + recordedAt: '2026-07-04T00:00:00.000Z', + changes: [], + }); + chatRecordingService.recordUserMessage([{ text: 'after strict write' }]); + rejectStrict(new Error('disk full')); + + await expect(strict).rejects.toThrow('disk full'); + await chatRecordingService.flush(); + + chatRecordingService.recordUserMessage([{ text: 'next message' }]); + await chatRecordingService.flush(); + + const second = vi.mocked(jsonl.writeLine).mock.calls[1][1] as ChatRecord; + const third = vi.mocked(jsonl.writeLine).mock.calls[2][1] as ChatRecord; + expect(third.parentUuid).toBe(second.uuid); + }); + // appendRecord can throw SYNCHRONOUSLY before returning a promise // (e.g. ensureConversationFile fails because the conversation // file can't be created). Without rollback in the outer catch, diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index 878ec03d005..fd035028649 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -792,7 +792,9 @@ export class ChatRecordingService { await this.writeChain; this.updateTitleAnchorTracking(record); } catch (error) { - this.lastRecordUuid = previousLastRecordUuid; + if (this.lastRecordUuid === record.uuid) { + this.lastRecordUuid = previousLastRecordUuid; + } debugLogger.error('Error appending record (async):', error); throw error; } From fab9a3d6922aedcce52be998a0daea0b711b8a70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Sun, 5 Jul 2026 03:06:24 +0800 Subject: [PATCH 18/61] fix(daemon): align artifact persistence release scope --- ...session-artifacts-persistence-v2-design.md | 252 ++++++++---------- .../acp-bridge/src/sessionArtifacts.test.ts | 45 +++- packages/acp-bridge/src/sessionArtifacts.ts | 13 +- 3 files changed, 167 insertions(+), 143 deletions(-) diff --git a/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md b/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md index 96579e7da13..621a163ed0c 100644 --- a/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md +++ b/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md @@ -6,7 +6,7 @@ V2 的目标是在不破坏 V1 live session 语义的前提下,让 artifact me ## 1. 设计结论 -V2 是一个完整设计 phase,但对外能力仍按 capability gate 暴露。实现可以先交付 metadata restore,再在 quota、hash、manifest 和 GC 都具备后声明 content retention capability;client 不应依赖“V2”这个阶段名推断功能,而应读取 capability。 +V2 是一个完整设计 phase,但对外能力仍按 capability gate 暴露。PR #6259 的实现范围是:metadata restore、显式 workspace content pin、session-scoped managed copy、hash/manifest 校验、硬编码 quota、TTL 降级、session-scoped GC/fsck,以及 load/resume/rewind/fork 的 active-chain 恢复。client 不应依赖“V2”这个阶段名推断功能,而应读取 capability。 两层能力: @@ -16,7 +16,7 @@ V2 是一个完整设计 phase,但对外能力仍按 capability gate 暴露。 对应 capability: - `session_artifacts_persistence`:支持 metadata 持久化与 session load/replay 恢复。 -- `session_artifacts_content_retention`:支持显式内容保留、配额、hash、manifest 和 GC。只有这一整套能力可用且当前配置启用时才声明。 +- `session_artifacts_content_retention`:支持显式 workspace 内容保留、配额、hash、manifest 和 session-scoped GC/fsck。当前实现不声明或承诺 project-wide cross-session GC、global artifact library、published file-root restore 或 sidecar cache。 核心原则: @@ -28,6 +28,13 @@ V2 是一个完整设计 phase,但对外能力仍按 capability gate 暴露。 - 不把 client 传入的 `source`、`clientId`、`trustedPublisher` 当授权依据。 - 恢复时必须重新校验,不信任磁盘上的旧 metadata。 +当前 PR 的重要收窄: + +- Content retention 只保存用户显式 pin 的 workspace regular file managed copy。`contentId` 包含 session/artifact 维度,fork 不继承 `contentRef`,因此当前 GC 可以按 session 引用集保守清理;project-wide 引用重建、orphan grace marker 和跨 session shared contentRef 是后续增强。 +- 当前 live view 与 persisted metadata 使用同一个 200 条可见集合。为了避免重启后 over-restore,超过上限时的 durable/restorable eviction 会写入 `reason: "eviction"` remove event;这等价于本实现的 metadata prune,不是纯 V1 live-only hiding。 +- 显式 DELETE 当前采用 live-first:先从 live store 移除,tombstone 写入失败时返回 warning。这样可优先隐藏敏感项;失败窗口内 daemon 重启仍可能从旧 journal 恢复该 artifact,client 应把 warning 作为“删除未 durable”的信号。 +- Fork 当前通过一次性 exclusive-create 写入目标 JSONL 文件;不会逐条 streaming fork artifact records,因此不需要 `session_artifact_fork_marker` 才能检测当前写入路径的 partial batch。若未来改成流式 fork,再引入 begin/complete marker。 + ## 2. 用户可见语义 ### 2.1 页面刷新、切换和重启 @@ -42,7 +49,7 @@ V2 后的行为应是: V1 到 V2 的 live upgrade 需要单独处理:已经在内存中的 V1 live artifacts 没有 JSONL journal。V2 首次触达这些 live sessions 时,应通过 chat recording owner 提供的 artifact persistence writer 写入一条初始 `session_artifact_snapshot`,然后再接受新的 restorable/pinned artifact mutation。backfill 不能把 live store 原样序列化;必须对每个 artifact 重新执行 ingest validation、privacy minimization 和 `retention` materialization。单条 artifact 不合格时跳过或降级该条,不能让一次坏记录拖垮整个 backfill。若 writer 不可用或 backfill 整体失败,该 session 继续保持 V1 live-only 行为,并记录 structured warning;不能让用户误以为已有 live artifacts 已经可恢复。 -backfill 不能逐条向 JSONL streaming 写入 artifact event。实现必须先在内存中完成校验、最小化和降级,形成完整 candidate snapshot 后,再一次性 append `session_artifact_snapshot`。如果 candidate 构建或 snapshot append 失败,不能留下部分 durable artifact state。backfill snapshot 应记录 `expectedArtifactCount`,便于 fsck 和 restore warning 区分“完整但有条目被校验跳过”和“部分写入/损坏”。 +backfill 不能逐条向 JSONL streaming 写入 artifact event。实现必须先在内存中完成校验、最小化和降级,形成完整 candidate snapshot 后,再一次性 append `session_artifact_snapshot`。如果 candidate 构建或 snapshot append 失败,不能留下部分 durable artifact state。当前 PR 不实现 V1 live-store backfill;如果后续补齐,应把 candidate 条目数、跳过条目数和校验失败原因写入结构化 telemetry 或 snapshot metadata,便于 fsck 和 restore warning 区分“完整但有条目被校验跳过”和“部分写入/损坏”。 ### 2.2 retention 分层 @@ -95,24 +102,20 @@ interface DaemonSessionArtifact { persistedAt?: string; expiresAt?: string; restoreState?: 'live' | 'restored' | 'unverified' | 'blocked'; - persistenceWarning?: { - code: - | 'persist_failed' - | 'journal_budget_exceeded' - | 'metadata_quota_exceeded' - | 'validation_downgraded' - | 'restore_pruned' - | 'content_unavailable' - | 'delete_not_durable' - | 'content_delete_preserved' - | 'sticky_override_active'; - message: string; - }; + persistenceWarning?: + | 'persistence_unavailable' + | 'content_missing' + | 'content_expired' + | 'content_hash_mismatch' + | 'metadata_only_restore' + | 'restore_validation_failed' + | 'sticky_override_active'; contentRef?: { - kind: 'managed_copy' | 'published' | 'workspace_reference'; - id?: string; - sha256?: string; - sizeBytes?: number; + kind: 'managed_copy'; + contentId: string; + sha256: string; + sizeBytes: number; + createdAt: string; }; } ``` @@ -123,8 +126,8 @@ interface DaemonSessionArtifact { - `persistedAt`:metadata 或 content retention 最近成功落盘时间。 - `expiresAt`:可被 GC 的时间。`pinned` 默认不设置,但 `ttlDays` pin 会转换成绝对 `expiresAt`。 - `restoreState`:恢复来源提示;不替代 `status`。 -- `persistenceWarning`:非阻塞持久化/恢复风险,前端可用它提示“此 artifact 不会跨重启保留”等状态。warning code 必须覆盖实际降级原因,不能把 quota、budget、validation 和内容缺失都压成同一个模糊错误。`content_unavailable` 专用于 metadata 已恢复但 `contentRef` 的 manifest、storage 或 hash 校验不可用的情况;此时 artifact 仍可展示为 missing/non-openable,不能假装内容已保存。`message` 必须来自 path-free 的固定模板或经过脱敏,不能包含 host 绝对路径、credential、token、内部 storage path 或 connection id。 -- `contentRef`:仅在内容保留或可信 published storage 时出现,不暴露宿主机绝对路径。 +- `persistenceWarning`:非阻塞持久化/恢复风险,前端可用它提示“此 artifact 不会跨重启保留”等状态。当前 wire shape 是固定字符串,避免把 host 绝对路径、credential、token、内部 storage path 或 connection id 写入 response。更结构化的 `{ code, message }` 可作为后续兼容扩展。 +- `contentRef`:当前只在 `mode: "content"` pin workspace regular file 成功后出现,不暴露宿主机绝对路径。published / workspace-reference contentRef 是后续增强,不属于本 PR 发布承诺。 ### 3.2 Status 与 restoreState 的关系 @@ -191,9 +194,7 @@ Artifact persistence records 是 chat transcript 的一部分,必须遵循现 给 `ChatRecord.subtype` 增加: ```ts -'session_artifact_event' | - 'session_artifact_snapshot' | - 'session_artifact_fork_marker'; +'session_artifact_event' | 'session_artifact_snapshot'; ``` Payload: @@ -203,16 +204,12 @@ interface SessionArtifactEventRecordPayload { v: 2; sessionId: string; sequence: number; + recordedAt: string; changes: Array<{ - action: 'upsert' | 'remove'; + action: 'created' | 'updated' | 'removed'; artifactId: string; artifact?: PersistedSessionArtifact; - reason?: - | 'explicit' - | 'quota_pruned' - | 'restore_pruned' - | 'unpin_to_ephemeral' - | 'ttl_expired'; + reason?: 'explicit' | 'eviction' | 'unpin_to_ephemeral'; }>; } @@ -220,20 +217,10 @@ interface SessionArtifactSnapshotRecordPayload { v: 2; sessionId: string; sequence: number; - generatedAt: string; + recordedAt: string; artifacts: PersistedSessionArtifact[]; - tombstonedIds: string[]; + tombstonedIds?: string[]; stickyEphemeralIds: string[]; - expectedArtifactCount?: number; -} - -interface SessionArtifactForkMarkerRecordPayload { - v: 2; - sessionId: string; - forkId: string; - phase: 'begin' | 'complete'; - expectedArtifactCount: number; - writtenArtifactCount?: number; } type PersistedSessionArtifact = Pick< @@ -258,16 +245,18 @@ type PersistedSessionArtifact = Pick< persistedAt: string; expiresAt?: string; contentRef?: DaemonSessionArtifact['contentRef']; - clientRetained?: boolean; - createdSequence: number; + clientRetained: boolean; + toolCallId?: string; + toolName?: string; + hookEventName?: string; }; ``` -`sequence` / `createdSequence` 必须来源于 JSONL record ordering,例如 line/order index 或 ChatRecord append order,不能使用 daemon 进程内会在重启后归零的 counter。读取时以 JSONL 顺序为准;字段仅用于检测异常、生成 snapshot、恢复稳定排序和 quota prune tie-break。 +`sequence` 是每个 session artifact store 内的 durable mutation counter,用于 snapshot/event 排序和异常诊断。恢复时仍以 active JSONL chain 顺序为准;`sequence` 不作为跨 session 授权或全局 ordering source。 `PersistedSessionArtifact` 必须是正向 allowlist(显式 `Pick` 或独立 interface),不能用 `Omit` 负向排除。未来如果 `DaemonSessionArtifact` 增加新的 runtime-only 字段,编译时断言应要求维护者显式决定是否进入 persisted allowlist,避免 schema 污染。 -只写经过 store validation/normalization 后的最小化 artifact shape。除 `clientRetained` 和 `createdSequence` 这两个恢复语义字段外,不写 V1 内部字段或运行时派生字段: +只写经过 store validation/normalization 后的最小化 artifact shape。除 `clientRetained` 以及 tool/hook display hints 外,不写 V1 内部字段或运行时派生字段: - 不写 `identityKey` - 不写 `trustedPublisher` @@ -362,10 +351,11 @@ ingest input 如果 sticky ephemeral override 抑制了隐式/default upsert 的持久化,live artifact 必须带 `persistenceWarning.code = "sticky_override_active"`,并记录 structured log `action=sticky_override_suppressed` 和 counter metric。否则排障时会看到合法 upsert input 却找不到对应 durable record。 -live store eviction 和 persisted metadata prune 必须分开: +当前 PR 没有隐藏的 paged persisted metadata 视图;live list 就是恢复后暴露给 client 的 metadata 集合。因此上限处理采用一个收窄策略: -- live store 上限只影响当前内存 view,不写 durable tombstone。V2 live eviction 必须 retention-aware,优先丢弃 ephemeral/unretained item;不能因为 V1 live cap 就让 `restorable` / `pinned` metadata 永久丢失。 -- persisted metadata quota prune 才写 `quota_pruned` / `restore_pruned` remove change,并且必须和触发它的新 upsert 在同一次 durable journal append 中完成。append 成功后再更新 live store;append 失败时不裁剪已持久化 metadata。 +- `ephemeral` artifact 可以只从 live view 丢弃,不写 journal。 +- `restorable` / `pinned` artifact 被上限裁剪时,写 `reason: "eviction"` remove event,避免下次 load/replay 把已裁剪条目全部复活。 +- 如果 live list 全部是 `pinned` 且没有可裁剪项,显式/strict mutation 返回 validation error;非 strict 自动写入可以降级或丢弃新 artifact,并返回/记录 warning。 ### 5.3 写入失败语义 @@ -374,19 +364,18 @@ live store eviction 和 persisted metadata prune 必须分开: - 普通 tool/hook artifact:持久化失败不应让工具调用失败;artifact 仍可进入 live store,但必须先把 live store 中的 `retention` 降级为 `ephemeral`,设置 `persistenceWarning`,再发布 `artifact_changed`。 - 显式 `pin/save` API:持久化失败必须返回错误,不能假装已经保存。 -对会影响恢复结果的删除型 mutation,按原因区分: +对会影响恢复结果的删除型 mutation,当前 PR 按原因区分: -- V1/当前 live cap eviction:只影响 live view,不写 tombstone,不改变持久化 metadata。 -- `quota_pruned` / `restore_pruned` / unpin-to-`ephemeral`:必须 durable-first 或 rollback。tombstone append 失败时保持 persisted state 不变;后台 prune 记录 structured warning 并稍后重试,显式 unpin API 返回错误。 -- 显式 DELETE:默认必须 durable-first。先 append remove tombstone 并 fsync;成功后再从 live store 移除并发布删除事件。append 失败时返回 `PERSISTENCE_UNAVAILABLE` 或明确错误,artifact 继续留在 `GET /artifacts` 结果中,不能报告成已删除。若实现为了敏感 UX 选择 live-first,必须保留一个用户可见的 pending-delete item 或 session-level warning,并允许同一 artifact id 的幂等 DELETE 继续补写 tombstone;没有这种可见重试路径时不得 live-first。 -- 显式 DELETE 携带 `deleteContent: true` 时,content 删除必须等待 tombstone durable 后才能执行。默认 durable-first 失败时不得删除 managed/pinned content,response 必须包含 `content_delete_preserved` warning。tombstone durable 后,content 删除仍需通过引用集合确认和 §7.1 授权;共享 contentRef 只能释放当前 artifact 引用,不能强删。只有可见 pending-delete/live-first 实现才允许同时暴露 `delete_not_durable` 与 `content_delete_preserved`。 +- `eviction`:durable remove event,保证重启后仍遵守 200 条上限。 +- unpin-to-`ephemeral`:durable remove event,并把 id 写入 bounded `stickyEphemeralIds`;后续隐式/default upsert 会保持 live-only,直到显式 `retention: "restorable"` 或 pin/save supersede。 +- 显式 DELETE:live-first。先从 live store 移除并发布删除事件,再 best-effort 写 explicit remove tombstone。tombstone 写入失败时 response 返回 warning(当前为字符串 warning),表示删除没有 durable;如果 daemon 在补写成功前重启,旧 journal 仍可能恢复该 artifact。 +- 显式 DELETE 携带 `deleteContent: true` 时,content GC 只在 live removal 后按当前引用集合 best-effort 执行。若 tombstone 或 content GC 有风险,response 包含 warning;当前实现不会在未确认引用集合时强删其它 session 的 content,因为 contentRef 不跨 session 继承。 建议 warning: ```text [artifacts] session= action=persist_failed artifact= reason= -[artifacts] session= action=delete_not_durable artifact= mode=visible_pending_delete -[artifacts] session= action=content_delete_preserved artifact= reason=tombstone_not_durable +[artifacts] session= action=remove_not_persisted artifact= [artifacts] session= action=sticky_override_suppressed artifact= prior_reason=unpin_to_ephemeral ``` @@ -404,7 +393,7 @@ session load/replay 时: 不要在 ACP child process 的 agent/session 对象里 seed `SessionArtifactStore`:生产 HTTP API 可见的 store 在 daemon-side bridge 中创建。 -`loadSession()` 必须是 read-only:它不能在解析过程中写 `restore_pruned` tombstone,也不能直接触发 content GC。若 restore 后发现当前 live cap 或 policy 比历史更严格,daemon-side store 在创建完成、persistence writer 可用后,再通过正常 operation queue 写 `restore_pruned` tombstone;writer 不可用时只在 live view 中隐藏超限 item,并记录 warning,下一次 load 仍可能重新看到这些待裁剪记录。 +`loadSession()` 必须是 read-only:它不能在解析过程中写 tombstone,也不能直接触发 content GC。若 restore 后发现当前 live cap 或 policy 比历史更严格,daemon-side store 在创建完成、persistence writer 可用后,再通过正常 operation queue 写 `eviction` remove event;writer 不可用时只在 live view 中隐藏超限 item,并记录 warning,下一次 load 仍可能重新看到这些待裁剪记录。 rewind/replay 中的 live store 处理必须和 load 一致:一旦 active leaf 改变,flat live store 不能继续保留 off-branch artifact mutation。若当前实现没有 active-chain replay result 可直接 reseed,必须在 rewind 完成时写入 artifact snapshot top-up,否则不能启用 persistence capability。 @@ -441,7 +430,7 @@ rewind/replay 中的 live store 处理必须和 load 一致:一旦 active leaf fork remap 是发布门槛:如果某条路径不能安全重写 artifact id 和 tombstone,就必须在 fork 时丢弃 artifact persistence records,不能把源 session 的 artifact id 原样带入新 session。若现有 fork 实现有类似 `file_history_snapshot` 的 top-up 机制,artifact 也只能从 active-chain replay result 生成 top-up,不能从 daemon 当前 live store 原样补写,否则会把 rewind 后不再属于历史的 artifact 带入新 session。 -fork 写入目标 session 必须可检测部分失败。remap 开始前向目标 active chain 写 `session_artifact_fork_marker`,`phase: "begin"`,并带 `expectedArtifactCount`;所有 remapped artifact records 写完后再写 `phase: "complete"` 和 `writtenArtifactCount`。session load 如果看到 begin 但没有 complete,或 count 不匹配,必须记录 `fork_incomplete`,并丢弃该 fork batch;不能把部分 fork 标记为 `restoreState: "unverified"` 后继续展示,也不能把部分 fork 当完整恢复结果。 +当前 fork 实现不是逐条 append,而是先从 source active chain 生成完整目标 record 列表,再用 exclusive-create 写入目标 JSONL 文件;写入失败时目标 session 文件不会被当作成功 fork 使用。因此当前 PR 不写 `session_artifact_fork_marker`。如果未来 fork 改为 streaming append 或跨进程批量复制,再引入 begin/complete marker、count 校验和 `fork_incomplete` 恢复规则。 fork 的 rewind 语义是 branch-scoped:目标 session 只复制当前 active chain 的结果。如果用户 rewind 到显式 DELETE 之前再 fork,那个 DELETE tombstone 本来就不在 active chain 中,artifact 在新 branch 中重新出现是预期的历史分支行为。若产品需要“全局不可 rewind 删除”或隐私擦除语义,应作为单独的 policy 设计,不能混入 V2 默认 branch model。 @@ -467,7 +456,7 @@ metadata 的 fork amplification 在 V2 中作为有界 trade-off 接受:fork - 行为可用且当前配置启用时才声明对应 feature string。 - chat recording 禁用、metadata persistence 禁用或 writer 不可用时,不声明 `session_artifacts_persistence`。 -- content retention 的 `pin/save content`、quota、hash、manifest 和 GC 都可用且当前启用时,才声明 `session_artifacts_content_retention`。 +- content retention 的显式 workspace `pin/save content`、quota、hash、manifest、session-scoped GC/fsck 都可用时,才声明 `session_artifacts_content_retention`。当前 feature string 是 daemon build-level 能力;单个 session 如果 chat recording writer 不可用,mutation 会降级或返回明确错误。 - 如果 client 需要读取 limits/default retention,应另设计 config endpoint 或 SDK config query;不要把结构化 details 混入现有 string-only capability contract。 ### 6.2 Add artifact @@ -554,13 +543,13 @@ Body: ### 6.5 Delete artifact -V2 的 DELETE 仍保持 V1 幂等,但要追加 tombstone: +V2 的 DELETE 仍保持 V1 幂等,并采用当前 PR 的 live-first 语义: -- 默认先写并 fsync `session_artifact_event` remove tombstone。 -- tombstone 成功后,再从 live store 移除;metadata restore 时不再复活。 -- tombstone 失败时,返回明确错误,live view 保持 artifact 可见,用户可在 storage 恢复后重试 DELETE。 -- DELETE 对已经有 durable tombstone 的 artifact 保持幂等成功。若实现采用 §5.3 允许的 live-first pending-delete UX,后续同 id DELETE 必须继续尝试补写 tombstone;若既没有 live artifact 也没有 durable/pending tombstone,DELETE 可作为幂等 no-op 成功。 -- 默认不立即同步删除 managed/pinned content,但会释放该 artifact 对 contentRef 的引用;GC 默认清理无其它 session 引用的 daemon-managed content。 +- 先从 live store 移除 artifact,保持用户可见删除即时生效。 +- 随后 best-effort append `session_artifact_event` remove tombstone;tombstone 成功后,metadata restore 时不再复活。 +- tombstone 失败时,返回成功 mutation result 但附带 warning;当前 daemon 生命周期内该 artifact 已被删除,但如果 daemon 在 tombstone 持久化前重启,旧 durable artifact 仍可能恢复。用户或上层 UI 可以在 storage 恢复后重试 DELETE。 +- DELETE 对不存在的 artifact 保持幂等成功;如果已有 durable tombstone,重复 DELETE 不需要再写同一 tombstone。 +- 默认不立即同步删除 managed/pinned content,但会释放该 artifact 对 contentRef 的 live 引用;session-scoped GC 清理当前 session manifest 中已无 live artifact 引用的 daemon-managed content。 可选 body 或独立 endpoint 支持内容删除: @@ -572,7 +561,7 @@ V2 的 DELETE 仍保持 V1 幂等,但要追加 tombstone: `deleteContent` 表示请求立即删除可删除内容,授权要求见 §7.1 的 delete content 规则:必须是显式 REST/SDK call、有 session mutate 权限、content retention capability 启用,并满足可验证 creator-principal match 或 admin override。共享 contentRef 只能在后台引用集合确认没有其它 session 仍引用后删除。 -`deleteContent: true` 不改变默认 DELETE 的 durable-first 顺序:content bytes 只有在 tombstone durable 且引用集合确认无其它 session 仍引用后才能删除。如果 tombstone 未 durable,API 必须返回错误并报告 content preserved,不能报告 content delete 成功。 +`deleteContent: true` 在当前 PR 中仍只表达“释放当前 artifact 对 content 的引用并触发 best-effort GC”。content bytes 只有在 GC 确认当前 session manifest 中没有 live artifact 引用后才会删除;如果 tombstone 未 durable,API 仍不能声称跨重启删除已经完成,只能通过 warning 表达持久化风险。跨 session 引用确认和强制立即删除是后续增强。 ### 6.6 Mutation responses @@ -583,7 +572,7 @@ Pin、unpin 和 delete 必须使用一致的 mutation response,避免 client - Pin:`200 OK` 返回更新后的 `DaemonSessionArtifact`。 - Unpin:`200 OK` 返回更新后的 `DaemonSessionArtifact`;`retention: "restorable"` 时 `contentRef`/`expiresAt` 已移除,`retention: "ephemeral"` 时 response 可以返回当前 live-only artifact,并带 `persistenceWarning.code = "sticky_override_active"`。 - DELETE:`200 OK` 返回 `{ "deleted": true, "artifactId": string, "warnings"?: [...] }`。 -- `202 Accepted`:仅用于实现了 §5.3 可见 pending-delete/live-first UX 的情况,body 必须包含 `warnings[].code = "delete_not_durable"`。如果 request 包含 `deleteContent: true`,还必须包含 `warnings[].code = "content_delete_preserved"`,说明 content 未删除且仍等待 durable tombstone / GC 引用确认。默认 durable-first 实现不返回 202;tombstone append 失败返回错误并保持 artifact 可见。 +- DELETE tombstone 持久化失败时仍返回 `200 OK` mutation result,并在 `warnings` 中包含持久化失败原因;当前实现使用字符串 warning,例如 `remove_not_persisted`。这表示 live delete 已生效但跨重启不保证,不能把它展示成 durable delete 成功。 失败: @@ -596,15 +585,14 @@ Pin、unpin 和 delete 必须使用一致的 mutation response,避免 client } ``` -建议状态码: +当前 PR 的 HTTP mapping: -- `400 INVALID_ARGUMENT`:非法 body、`ttlDays` 与 `mode` 不匹配、client 请求 `pinned`。 +- `400 VALIDATION_FAILED`:非法 body、`ttlDays` 与 `mode` 不匹配、client 请求 `pinned`、artifact 不存在、metadata/content quota 已满且没有可裁剪 candidate,或 writer/content storage 不可用但 mutation 必须严格 durable 完成。 - `403 FORBIDDEN`:缺少 session mutate 权限或 `deleteContent` 授权不足。 -- `404 NOT_FOUND`:artifact 不存在;DELETE 仍可保持幂等成功,但 pin/unpin 应返回 404。 -- `409 CONFLICT`:当前 artifact 状态不能完成请求,例如 content source 已不可用。 -- `429 METADATA_QUOTA_EXCEEDED`:metadata slots 已满且没有可裁剪 candidate,阻止显式 restorable/pin/save。 -- `429 QUOTA_EXCEEDED`:content quota 阻止显式 `mode: "content"` pin/save。 -- `503 PERSISTENCE_UNAVAILABLE`:writer 或 content storage 不可用,显式 pin/save/unpin/delete 不能 durable 完成。 +- DELETE 保持幂等;不存在的 artifact 返回空 mutation result 而不是错误。 +- DELETE tombstone 持久化失败返回 `200 OK` + warning,因为当前 live delete 已生效但跨重启不保证。 + +更细粒度的 `INVALID_ARGUMENT`、`NOT_FOUND`、`CONFLICT`、`METADATA_QUOTA_EXCEEDED`、`QUOTA_EXCEEDED` 或 `PERSISTENCE_UNAVAILABLE` HTTP error code 是后续 API polish,不属于当前 PR 的 wire contract。 ## 7. 安全设计 @@ -676,7 +664,7 @@ daemon-managed artifact storage 必须有明确 root: - title/description/metadata 继续执行 unsafe display payload checks。 - `persistenceWarning.message` 即使只作为 live response 字段,也必须使用 path-free 模板或脱敏文本;不能把 host path、credential、token、content root、connection id 写入 warning。 -建议新增设置: +后续可新增设置: ```json { @@ -686,24 +674,21 @@ daemon-managed artifact storage 必须有明确 root: "defaultRetention": "restorable", "maxLiveArtifacts": 200, "maxPersistedMetadata": 200, - "journalBudgetBytes": 4194304, "snapshotThresholdMutations": 100, "snapshotThresholdBytes": 262144, "contentRetention": { "enabled": false, "maxArtifactBytes": 52428800, - "maxTotalBytes": 1073741824, + "maxTotalBytes": 268435456, "maxTtlDays": 365, - "ttlScanIntervalSeconds": 900, - "gcSweepIntervalSeconds": 3600, - "gcGracePeriodSeconds": 3600 + "ttlScanIntervalSeconds": 900 } } } } ``` -这些字段是 operator tunables;如果实现选择硬编码某些值,也必须在配置 schema/文档中明确标注为不可配置常量。文档其它 section 引用的默认值应统一来自这里,避免实现方猜测哪些值可以调整。 +当前 PR 不新增 operator 配置 schema;上述值以代码常量形式发布,并通过 capability 表达行为是否可用。把这些值暴露为 operator tunables 是后续增强,不能让 client 从 capability string 推断配置细节。 ## 8. 配额、GC 与稳定性 @@ -715,62 +700,53 @@ daemon-managed artifact storage 必须有明确 root: - persisted metadata 上限每 session 200,与 live store 对齐。 - snapshot record 最多保留 200 个当前有效 artifacts。 -live store 上限是内存展示约束,不是 durable deletion policy: +live store 上限在当前实现中也是 restore 可见集合的上限: - V2 live eviction 必须优先淘汰 `ephemeral` artifact。 -- 如果必须在 restorable artifacts 中选择 live view,应按 `clientRetained`、`retention`、`createdSequence` 和 `artifactId` 做确定性排序,只隐藏当前 live view 中优先级最低的 item;不能写 tombstone。 -- `clientRetained` 是用户保留意图,必须进入 `PersistedSessionArtifact`,用于 restore 后稳定排序和 live cap 选择。V1 的进程内 `insertSeq` 不直接持久化,但必须转换成 durable `createdSequence`。 +- 如果必须在 durable artifacts 中选择 live view,当前实现按 source reservation、source、status、retention、clientRetained 和 insertion order 做确定性选择。 +- durable artifact 被 live cap 淘汰时,当前实现会写 `reason: "eviction"` 的 remove event,确保下一次 restore 不反复复活已被 daemon 淘汰的 item。 +- `clientRetained` 是用户保留意图,进入 `PersistedSessionArtifact`,用于 restore 后稳定排序和 live cap 选择;它是排序保护,不是绝对保护。 超过 persisted metadata 上限: - `ephemeral` 本来不写 journal,不计入 persisted metadata quota,只受 live store 上限约束。 -- `restorable` 必须按确定性顺序裁剪并写 `quota_pruned` tombstone:先裁剪未 `clientRetained` 的 `restorable` artifact;如果仍无空间,再裁剪 `clientRetained` 的 `restorable` artifact。每一层内按 `persistedAt` 升序,缺失时按 `createdSequence` 升序,最后按 `artifactId` 字典序打破平局。`clientRetained` 是排序保护,不是绝对保护;用户需要真正保护内容时应使用 pin/save。 +- `restorable` 必须按确定性顺序裁剪并写 `eviction` remove event:先裁剪未 `clientRetained` 的 `restorable` artifact;如果仍无空间,再裁剪 `clientRetained` 的 `restorable` artifact。`clientRetained` 是排序保护,不是绝对保护;用户需要真正保护内容时应使用 pin/save。 - `pinned` metadata 不因 metadata quota 被裁剪,但 content retention 仍受 content quota 约束。 - 如果 200 个 persisted slots 全部被 `pinned` 占用,没有可裁剪的 `restorable` candidate: - 自动/tool artifact 降级为 live-only `ephemeral`,带 `persistenceWarning.code = "metadata_quota_exceeded"`。 - 显式请求 restorable/pin/save 的 API 返回 `METADATA_QUOTA_EXCEEDED`,提示用户删除、unpin 或让 pinned TTL 到期。 - daemon 不能为了接收新 artifact 自动裁剪 pinned metadata;这会违背用户显式保存语义。 -restore seed 不能超过 live store 上限;若历史里有效 persisted artifact 超过当前 live cap,daemon 可以按同一确定性规则只 seed 可见 subset,并对隐藏 item 标记 warning/metrics。只有在 metadata quota policy 确认这些 item 不应再恢复时,才通过 daemon-side operation queue 写 `restore_pruned` tombstone;`loadSession()` 本身不能写 durable prune。 +restore seed 不能超过 live store 上限;若历史里有效 persisted artifact 超过当前 live cap,daemon-side store 按同一确定性规则 seed 可见 subset,并通过 operation queue 为被裁剪的 durable item 写 `eviction` remove event。`loadSession()` parse 过程本身保持 read-only,不能直接写 durable prune。 ### 8.2 Content quota -建议默认: +当前 PR 的硬编码默认: - 单 artifact:50 MB。 -- 单 session pinned content:200 MB。 -- 单 project pinned content:1 GB。 +- content store total:256 MB。 达到上限时: - 新 pin/save 返回 `QUOTA_EXCEEDED`。 -- 不自动删除 pinned content,除非用户设置 TTL 或 explicit GC policy。 +- 不自动删除仍被当前 session live artifact 引用的 pinned content。 - fork 不继承 pinned contentRef,避免 fork 绕过 quota。 ### 8.3 GC -GC 只处理 daemon 管理的 content storage 和过期 metadata cache: +当前 PR 的 GC 只处理 daemon 管理的 session-scoped managed copy: -- 删除已被 tombstone 且无其它 session 引用的 managed copy。 -- 删除超过 `expiresAt` 的 non-pinned content。 -- 删除超过 `expiresAt` 的 pinned content,并把对应 artifact 降级为 `restorable` 或 `missing`。该降级必须先写 durable artifact event:`action: "upsert"`、`reason: "ttl_expired"`,payload 移除 `contentRef` 和 `expiresAt`;append 成功后才能更新 live store 并让 content 进入可删除集合。append 失败时保守保留 content。 -- startup/timer/project GC 不能绕过 session writer。触发 TTL 降级或 metadata 变更前,GC 必须通过 load/attach session recording context 获取同一个 artifact persistence writer,并使用相同的锁、operation queue 和 durability 规则写入 `ttl_expired` event。writer 不可用、session 无法 attach、或 journal 不可写时,只能标记 expired pending metric/warning 并保留 content;不能先删 content,也不能写第二套 offline source of truth。 -- session delete 后该 session 的 artifact journal 不再贡献 content 引用;默认删除无其它 session 引用的 content。只有用户显式导出到全局 artifact library 时,才在 session 删除后继续保留。 +- content manifest 保存 `sessionId` 和 `artifactId`;GC 只删除 manifest 属于当前 session 且不在当前 live `contentRefs()` 引用集合中的 content。 +- `pinWorkspaceFile()`、GC、tmp cleanup 通过同一个 write queue 串行化,并用 in-flight lease 避免并发 pin/GC 删除刚复制但尚未 journal 的 content。 +- `expiresAt` 到期通过 `GET /artifacts` 前的 lightweight prune 把 pinned artifact 降级为 `restorable`,移除 `contentRef` 后再触发 GC。 +- close / explicit delete / unpin / explicit GC endpoint 都会 best-effort sweep;GC 失败不阻塞 prompt/tool flow。 GC trigger: -- daemon startup 后延迟触发一次 project-scoped sweep。 -- session delete、artifact delete、unpin、TTL 到期检查或 content quota pressure 后 enqueue sweep。 -- 周期性 timer 可作为兜底,例如每小时一次;实现必须用单实例 lease/lock 避免并发 sweep。 -- TTL 到期检测必须有独立、可观测路径:session load 后对该 session 做一次 lightweight TTL scan;后台 timer 默认每 15 分钟扫描一次 pinned `expiresAt` index;project GC sweep 作为兜底。每次 TTL scan 即使没有过期项也要记录 `ttl_scan_checked`,并导出 expired pending bytes,避免配额耗尽时根因不可见。 - -V2 不把 mutable reference-count table 当 source of truth。content reference set 应从 project 内 session artifact journals 的最新 valid snapshot/events 派生;content manifest 只保存 content metadata 和可选的 lastKnownReferenceCount/cache。GC sweep 在后台按 project 扫描并重建引用集合,crash 后下一次 sweep 重新计算。引用未知或扫描失败时必须保守保留 content,不能删除。 - -每次 GC sweep 必须跟踪 per-session scan status,例如 `{ sessionId, status: 'scanned' | 'corrupt' | 'timeout', lastScannedAt }`。只要任一相关 session 不是 `scanned`,GC 就不能删除可能只由未扫描 session 引用的 content;这些 content 必须保守保留到下一次完整扫描。corrupt journal 不能通过“跳过该 session”变成静默数据丢失。记录 action `gc_incomplete_scan`,并导出 `artifact_gc_sessions_unscanned`。 - -orphan content 删除必须有 grace period。默认 grace period 为 1 小时,从 GC sweep 首次确认某个 contentRef 没有任何 journal 引用并在 manifest/cache 中写入 `orphanedAt` 开始计算;下一次 sweep 只有在仍无引用且已超过 grace period 时才删除。新的 journal 引用会清除 `orphanedAt`。该值可以配置,但不能短于一次正常 pin/save 操作和 journal flush 的最长预期窗口。 +- artifact delete、unpin、TTL 到期检查、session close 或 explicit `POST /session/:id/artifacts/gc`。 +- stale `.tmp` entries are cleaned during GC. -GC 必须 best-effort,不阻塞 prompt/tool flow。 +Project-scoped reference rebuild、incomplete-scan tracking、orphan grace period 和 global artifact library 都是后续增强。当前实现的 safety 边界来自“不跨 session 继承 contentRef”和“只删除当前 session manifest 且当前 live refs 未引用的 content”。 ### 8.4 Crash consistency @@ -780,9 +756,9 @@ GC 必须 best-effort,不阻塞 prompt/tool flow。 - JSONL journal append 失败不会破坏 live store。 - explicit `pin/save metadata` 必须等待 journal 落盘。 - explicit `pin/save content` 必须等待 content manifest 和 journal 都落盘。 -- explicit DELETE 默认 durable-first:tombstone append 成功后才移除 live view。只有实现了 §5.3 可见 pending-delete/live-first UX 时,live removal 才能先于 journal,并且必须暴露 `delete_not_durable`、允许同 id retry、且不能报告为 durable delete。 -- explicit DELETE with `deleteContent: true` 的 content 删除必须在 tombstone durable 之后执行;tombstone append 失败时 content 必须保留,并暴露 `content_delete_preserved`。 -- live cap eviction 不写 tombstone;metadata quota prune 必须 durable-first。 +- explicit DELETE live-first:live store removal must not be blocked by journal failure; response warning tells clients when the tombstone was not durable. +- explicit DELETE with `deleteContent: true` runs best-effort session-scoped content GC after live removal; content delete warnings must be surfaced. +- live cap eviction for durable artifacts writes an `eviction` remove event so restore respects the cap. - reader 容忍半截 JSONL 和 corrupt artifact record。 - tombstone / snapshot 顺序异常时选择不恢复,而不是猜测。 @@ -793,7 +769,7 @@ GC 必须 best-effort,不阻塞 prompt/tool flow。 3. append artifact journal event,引用该 contentRef,并 fsync JSONL。 4. 更新 live store 并发布 `artifact_changed`。 -如果第 2 步成功但第 3 步前 crash,会留下没有 journal 引用的 orphan content;这是允许的,GC 在 grace period 后删除。如果第 3 步成功,restore 必须能通过 manifest 找到内容。显式 API 只有在第 3 步成功后才能返回成功。 +如果第 2 步成功但第 3 步前 crash,会留下没有 journal 引用的 orphan content;这是允许的,当前 session-scoped GC 在确认 manifest 不被当前 live refs 引用后 best-effort 删除。如果第 3 步成功,restore 必须能通过 manifest 找到内容。显式 API 只有在第 3 步成功后才能返回成功。 ### 8.5 文件读取、CPU 与 I/O 成本 @@ -840,9 +816,8 @@ V2 新增的失败路径必须有 structured logs,格式沿用: - `retention_downgraded` - `restore_skipped` - `restore_blocked` -- `restore_pruned` -- `delete_not_durable` -- `quota_pruned` +- `remove_not_persisted` +- `eviction` - `fork_artifact_discarded` - `fork_incomplete` - `gc_content_deleted` @@ -877,7 +852,7 @@ V2 新增的失败路径必须有 structured logs,格式沿用: 诊断工具分两层。metadata-only `fsck` 是 metadata restore 发布门槛,必须在 content retention 前可用,用于扫描 artifact journal/snapshot/tombstone 与 restore validation failure。full `fsck` 是 content retention 发布门槛,额外扫描 content manifests 和 daemon-managed storage。实现必须提供 CLI 或 daemon-internal API,例如 `qwen artifact fsck`,用于 dry-run 扫描 project/session artifact journals、content manifests 和 daemon-managed storage: - 报告 dangling `contentRef`、manifest 缺失、orphan content、snapshot/tombstone 不一致和 restore validation failure。 -- 默认只读;修复模式只能做可验证的安全动作,例如重新生成 snapshot、清除 stale cache marker、标记 orphan content 等待 GC。不能在没有引用集合确认和 grace period 的情况下直接删除内容。 +- 默认只读;修复模式只能做可验证的安全动作,例如重新生成 snapshot、清除 stale cache marker、标记 orphan content 等待 GC。当前 PR 的自动删除只限于当前 session manifest 且当前 live refs 未引用的 managed content;跨 session 引用重建和延迟删除策略是后续增强。 - 输出结构化结果并计数,至少包含 `fsck_dangling_content_ref`、`fsck_orphan_content`、`fsck_snapshot_invalid`;持续出现的 dangling contentRef 或 snapshot invalid 应触发告警。 ## 9. 实现方案 @@ -899,18 +874,19 @@ V2 新增的失败路径必须有 structured logs,格式沿用: - daemon bridge `createSessionEntry` 支持 seed artifacts。 - `SessionArtifactStore` 支持 seed artifacts。 - `upsertMany()` 在 operation queue 中计算 effective `retention`、quota prune 和 live view,再通过 writer append durable records。 -- `remove()` 区分 explicit DELETE、unpin-to-ephemeral、quota prune 和 live eviction;只有前三者写 tombstone。explicit DELETE 默认 durable-first;只有同时实现可见 pending-delete warning 和同 id retry 时,才允许 live-first。 -- V1 live session 首次启用 V2 时执行 backfill snapshot;backfill 逐条 validation/minimization/materialization,坏记录跳过或降级,writer 不可用时保持 V1 live-only 并记录 warning。 +- `remove()` 区分 explicit DELETE、unpin-to-ephemeral 和 eviction;explicit DELETE live-first 并 best-effort 写 tombstone,unpin-to-ephemeral 和 durable eviction 写 journal。 +- V1 live session 首次启用 V2 的 backfill snapshot 不在当前 PR 实现范围内;当前实现从新写入的 V2 journal/snapshot 恢复。 - 保持 V1 `artifact_changed` event shape 不变,只增加 optional fields。 ### Milestone C: load/replay 集成 - `SessionService.loadSession()` 从 active leaf chain 提取 artifact snapshot/event records,忽略 abandoned branches。 - load result 把 snapshot 交给 daemon bridge,而不是在 ACP child process 中 seed store。 -- restore-prune 写入只能在 daemon-side store 创建并且 writer 可用后执行;load parse 过程保持 read-only。 +- restore over-cap prune 写入只能在 daemon-side store 创建并且 writer 可用后执行;load parse 过程保持 read-only。 - rewind/leaf switch 后,daemon-side live store 重新对齐 active-chain replay result,或通过 artifact snapshot top-up 固化 surviving chain 的当前状态。 - rewind/leaf-switch 必须调用明确 hook,例如 `onActiveLeafChanged(sessionId, artifactSnapshot)`,让 daemon-side store 在 operation queue 中完成 reseed/top-up。 - replay 历史时同 identity artifact 不重复创建。 +- `/branch` 从 active chain 复制 artifact records 并 remap session id/artifact id;当前 full-file exclusive-create 写入路径不需要 fork marker。 ### Milestone D: REST/SDK @@ -922,9 +898,9 @@ V2 新增的失败路径必须有 structured logs,格式沿用: ### Milestone E: content retention -- 增加 daemon-managed artifact storage manifest 和 project-scoped GC sweep。 -- 实现 race-safe content copy、hash 校验、quota、GC。 -- 支持 published artifact 绑定 trusted contentRef。 +- 增加 daemon-managed workspace content manifest 和 session-scoped GC/fsck。 +- 实现 race-safe content copy、hash 校验、quota、write-queue/lease-protected GC。 +- published artifact 绑定 trusted contentRef 是后续增强。 ## 10. 测试计划 @@ -934,17 +910,16 @@ V2 新增的失败路径必须有 structured logs,格式沿用: - artifact journal append 通过 chat recording owner 写入 active leaf chain;daemon-side store 不能直接写 JSONL。 - `/rewind` 后 abandoned branch 上的 artifact upsert/remove 不参与恢复,也不会在 fork 中复制。 - `/rewind` 后 live store 立即与 active-chain artifact state 对齐;不会等到 daemon 重启才改变 artifact list。 -- V1 live session 升级到 V2 时 backfill snapshot 成功/失败两种路径;backfill 对单条 artifact 执行 validation/minimization,坏记录只影响自身。 +- V1 live session 升级到 V2 时的 backfill snapshot 是后续增强;当前 PR 测试应确认未写入 V2 journal 的旧 live artifacts 不被误报为可恢复。 - DELETE tombstone 后 load 不复活 artifact。 - unpin 到 `ephemeral` 后 load 不复活 artifact。 - unpin 到 `ephemeral` 后,同一 artifact id 的隐式/default re-upsert 仍保持 live-only;显式 restorable/pin 可以 supersede sticky override。 - snapshot baseline advance 后 `stickyEphemeralIds` 仍能让隐式/default re-upsert 保持 live-only,并产生 `sticky_override_suppressed` log/metric/warning。 - `stickyEphemeralIds` 达到上限时,unpin-to-ephemeral 返回错误或延后重试,且不会静默丢失旧 sticky override。 -- explicit DELETE 默认 durable-first:tombstone append 失败时 live view 仍保留 artifact,并返回 `PERSISTENCE_UNAVAILABLE`;append 成功后 load 不复活。 -- 可选 live-first pending-delete 实现若存在,必须在 tombstone append 失败时保留可见 pending warning、允许同 id DELETE retry,并暴露 `delete_not_durable`。 -- `deleteContent: true` 在 tombstone append 失败时不删除 content,default durable-first 返回错误并包含 `content_delete_preserved`;可选 pending-delete 路径才同时包含 `delete_not_durable`。 -- live cap eviction 不写 tombstone;quota prune、unpin-to-ephemeral、`restore_pruned` tombstone append 失败时不会改变 persisted metadata state。 -- journal working-set budget:snapshot bytes + post-snapshot event bytes 超限时触发 snapshot advance、自动 artifact 降级或显式 API 错误,不会无界追加 JSONL 工作集。 +- explicit DELETE live-first:live view 立即移除;tombstone 写入失败时 response 带 warning,测试覆盖 live removal 不被 persistence failure 阻断。 +- `deleteContent: true` 在 tombstone/content GC 有风险时暴露 `content_delete_preserved` warning。 +- durable artifact eviction 写 `eviction` remove event;restore 后不会超过 live cap。 +- snapshot baseline advance:periodic snapshot 压缩当前 artifact list,explicit tombstone 在 snapshot 成功后不再无界增长,`stickyEphemeralIds` 保留 sticky state。 - workspace artifact ingest 和 restore 时文件存在/缺失/symlink escape 三种状态。 - workspace root 重定位:相同相对路径存在时恢复为 available;缺失或 layout 不一致时恢复为 missing;不做 path remap。 - pin/save content 时拒绝 symlink、special file、oversized stream、hardlink 异常和 TOCTOU swap。 @@ -958,23 +933,20 @@ V2 新增的失败路径必须有 structured logs,格式沿用: - pin/save 显式写失败时返回错误。 - tool artifact 持久化失败时降级为 live-only,并通过 `persistenceWarning` 让 client 可见。 - branch/fork 时 artifact records 的 sessionId/id 处理,且只使用 active-chain replay result。 -- fork begin/complete marker:partial fork crash、count mismatch 和 successful fork 三种路径;不完整或 count mismatch 的 fork batch 必须丢弃。 +- fork full-file write:active-chain remap 后 exclusive-create 写入目标 JSONL,失败不产生成功 fork;如果未来改为 streaming fork,再补 begin/complete marker 测试。 - fork 继承 pinned artifact 时降级为 restorable,不继承 contentRef。 - orphan tombstone 在 fork remap 时被保留并安全 remap;无法安全 remap 的 tombstone 才丢弃。 - fork remap 重新执行 validation、privacy minimization 和 redaction;unsafe locator 被 strip、降级或丢弃。 - restore seed 与 concurrent POST 串行,不丢写、不重复。 - quota 边界:200 条、201 条 prune、pinned metadata、clientRetained/non-clientRetained 两层排序、全部 clientRetained restorable 仍可按确定性规则裁剪。 - clientRetained setter:Add artifact request 和 pin/save request 都能设置 boolean hint;后台自动 ingest 不能伪造用户保留。 -- GC:tombstoned content 有/无跨 session 引用、TTL 过期、session delete 后引用重建、orphan content grace cleanup。 -- GC incomplete scan:某 session journal corrupt/timeout 时不删除可能仅由该 session 引用的 content,并记录 `gc_incomplete_scan`。 -- GC concurrency:并发 sweep trigger 被 lease 阻止;持有 lease 的 sweep crash 后 lease 到期可恢复;daemon shutdown 中断 sweep 后下次启动继续保守扫描。 -- TTL scan:session load 和周期 timer 都能发现过期 pinned content,记录 `ttl_scan_checked` 和 `artifact_content_expired_pending_bytes`。 -- TTL 过期导致 pinned 降级时写 `ttl_expired` event,payload 移除 `contentRef` / `expiresAt`,append 失败时不删除 content。 -- startup/timer/project GC 在 writer 不可用或 session attach 失败时只记录 expired pending 并保留 content;获取 writer 后才写 `ttl_expired` event 和进入删除集合。 +- GC:unpin、delete、close、explicit GC endpoint 都只删除当前 session manifest 且当前 live refs 未引用的 content。 +- GC concurrency:并发 pin/GC 通过 content-store write queue 和 leased content ids 串行/保护。 +- TTL scan:`GET /artifacts` 会降级过期 pinned content 并触发 best-effort session-scoped GC。 - authorization:token-holder/principal 审计路径允许和拒绝情况;V1 live same-principal guard 仅作为 live UX/audit hint,不作为 durable security boundary。 - restored artifact ownership_unverified fallback;deleteContent 在无 durable owner proof 时只释放引用,不立即强删共享 content。 - partial writes:journal 成功但 warning、content manifest 成功但 journal 失败、journal 成功后 restore 能找到 content。 -- JSONL snapshot baseline advance:threshold 触发、post-snapshot replay 有界、snapshot payload 不再携带已被覆盖的 tombstones、superseded tombstone 允许同 id 重新出现、`stickyEphemeralIds` 保留 sticky state;JSONL 文件本身不被 artifact 子系统重写。 +- JSONL snapshot baseline advance:threshold 触发、post-snapshot replay 有界、snapshot payload 不再携带已被覆盖的 explicit tombstones、superseded sticky tombstone 允许显式同 id 重新出现、`stickyEphemeralIds` 保留 sticky state;JSONL 文件本身不被 artifact 子系统重写。 - corrupt latest snapshot fallback:回退到较旧 valid snapshot 或一次顺序 artifact replay。 - repin idempotency:已 pinned artifact 的重复 pin 不刷新内容、不延长 TTL。 - retention defaults:tool artifact 无显式 retention、client POST `pinned` 被拒绝。 @@ -982,10 +954,10 @@ V2 新增的失败路径必须有 structured logs,格式沿用: - replay idempotency:同一 session history replay 两次不会重复 artifact。 - SDK 旧 client 忽略 optional fields 后仍能展示 V1 artifacts。 - V2 -> V1 rollback compatibility:旧 daemon 必须能解析或忽略 unknown `system` subtype,不得导致 session load 崩溃;回滚后 artifact persistence 不恢复是可接受降级。如果当前最低支持版本不能保证这一点,V2 writer 必须 capability-gate 到支持 unknown system record 的版本之后。 -- rollback preflight:最低支持旧 daemon 版本加载包含 V2 event/snapshot/fork marker 的 JSONL,writer version gate 失败时拒绝写入并记录 `v2_writer_version_gate_failed`。 +- rollback preflight:最低支持旧 daemon 版本加载包含 V2 event/snapshot 的 JSONL;如果未来加入 fork marker,再扩展 rollback fixture。 - artifact fsck dry-run:dangling contentRef、orphan content、snapshot invalid 和 repair-safe actions。 - metadata-only fsck dry-run:corrupt record、snapshot fallback、orphan tombstone、restore validation failure。 -- API response contract:pin/unpin/delete success body、`METADATA_QUOTA_EXCEEDED` 与 `QUOTA_EXCEEDED` error code、`delete_not_durable` / `content_delete_preserved` warning、400/403/404/409/429/503 error mapping。 +- API response contract:pin/unpin/delete success body、metadata/content quota validation failure、`remove_not_persisted` / `content_delete_preserved` / `persistence_unavailable` / `sticky_override_active` / `content_expired` warning、current 400/403/200+warning mapping。 ## 11. 不建议在 V2 做的事 @@ -1015,7 +987,7 @@ Rollback procedure: - V2 records 保留在 chat JSONL 中,不在 rollback 时删除;旧 daemon 能忽略 unknown `system` subtype 时,session load 应继续工作但不恢复 artifact persistence。 - daemon-managed content storage 不由旧 daemon 读取;rollback 后 pinned content 只是未被引用的 retained bytes。再次升级到 V2 后可恢复引用;若决定永久回滚,管理员运行 full `fsck --cleanup-orphans` dry-run/confirm 流程清理。 - 如果当前最低支持旧版本不能安全忽略 V2 system records,writer 必须 capability-gate 到安全版本之后,或者在升级前提供 migration guard,阻止写入 V2 records。 -- 发布前 CI 必须用最低支持旧 daemon 版本加载包含 `session_artifact_event`、`session_artifact_snapshot` 和 `session_artifact_fork_marker` 的 JSONL,断言 session load 成功且 unknown subtype 被忽略。V2 writer 首次初始化前也要检查版本/feature gate;失败时拒绝写 V2 records,记录 `v2_writer_version_gate_failed`,保持 V1 行为。 +- 发布前 CI 必须用最低支持旧 daemon 版本加载包含 `session_artifact_event` 和 `session_artifact_snapshot` 的 JSONL,断言 session load 成功且 unknown subtype 被忽略。V2 writer 首次初始化前也要检查版本/feature gate;失败时拒绝写 V2 records,记录 `v2_writer_version_gate_failed`,保持 V1 行为。如果未来加入 fork marker,再把该 subtype 纳入 rollback fixture。 - rollback 后 client 不能依赖 `session_artifacts_persistence` / `session_artifacts_content_retention`,因为旧 daemon 不声明这些 capability。 这样可以讲清楚 V2 的完整语义:默认恢复列表,显式保存内容,所有长期存储都受权限、配额、hash 和 GC 约束。 diff --git a/packages/acp-bridge/src/sessionArtifacts.test.ts b/packages/acp-bridge/src/sessionArtifacts.test.ts index 56d79fe802d..b2381b79018 100644 --- a/packages/acp-bridge/src/sessionArtifacts.test.ts +++ b/packages/acp-bridge/src/sessionArtifacts.test.ts @@ -317,6 +317,47 @@ describe('SessionArtifactStore', () => { ); }); + it('bounds sticky ephemeral overrides by the artifact limit', async () => { + const store = new SessionArtifactStore({ + sessionId: 's1-sticky-cap', + workspaceCwd: workspace, + maxArtifacts: 1, + persistence: { + recordEvent: async () => {}, + recordSnapshot: async () => {}, + }, + }); + + const first = await store.upsertMany( + [{ title: 'First', url: 'https://example.com/first' }], + { strict: true }, + ); + await store.unpin(first.changes[0]!.artifactId, { + retention: 'ephemeral', + }); + + const second = await store.upsertMany( + [{ title: 'Second', url: 'https://example.com/second' }], + { strict: true }, + ); + const secondId = second.changes[0]!.artifactId; + + await expect( + store.unpin(secondId, { retention: 'ephemeral' }), + ).rejects.toMatchObject({ + field: 'retention', + message: 'sticky ephemeral artifact limit exceeded', + }); + await expect(store.list()).resolves.toMatchObject({ + artifacts: [ + expect.objectContaining({ + id: secondId, + retention: 'restorable', + }), + ], + }); + }); + it('downgrades expired pinned content before exposing artifacts', async () => { const events: SessionArtifactEventRecordPayload[] = []; const store = new SessionArtifactStore({ @@ -2275,7 +2316,7 @@ describe('SessionArtifactStore', () => { expect(events[50]).toMatchObject({ sequence: 52 }); }); - it('records durable tombstones in periodic snapshots', async () => { + it('compacts explicit tombstones out of periodic snapshots', async () => { const snapshots: SessionArtifactSnapshotRecordPayload[] = []; const store = new SessionArtifactStore({ sessionId: 's11-tombstone-snapshot', @@ -2307,7 +2348,7 @@ describe('SessionArtifactStore', () => { expect(snapshots).toHaveLength(1); expect(snapshots[0]).toMatchObject({ - tombstonedIds: [deletedId], + tombstonedIds: [], stickyEphemeralIds: [], }); expect( diff --git a/packages/acp-bridge/src/sessionArtifacts.ts b/packages/acp-bridge/src/sessionArtifacts.ts index 79f459ac6a1..be398b33bbb 100644 --- a/packages/acp-bridge/src/sessionArtifacts.ts +++ b/packages/acp-bridge/src/sessionArtifacts.ts @@ -523,6 +523,16 @@ export class SessionArtifactStore { return { v: 1, sessionId: this.sessionId, changes: [] }; } const targetRetention = options.retention ?? 'restorable'; + if ( + targetRetention === 'ephemeral' && + !this.stickyEphemeralIds.has(artifactId) && + this.stickyEphemeralIds.size >= this.maxArtifacts + ) { + throw new SessionArtifactValidationError( + 'sticky ephemeral artifact limit exceeded', + 'retention', + ); + } const updated: StoredArtifact = { ...existing, retention: targetRetention, @@ -850,12 +860,13 @@ export class SessionArtifactStore { sequence: ++this.persistenceSeq, recordedAt, artifacts, - tombstonedIds: Array.from(this.tombstonedIds), + tombstonedIds: [], stickyEphemeralIds: Array.from(this.stickyEphemeralIds), }; try { await this.persistence.recordSnapshot(payload); this.durableEventsSinceSnapshot = 0; + this.tombstonedIds.clear(); } catch (error) { writeStderrLine( `[artifacts] session=${this.sessionId} action=snapshot_failed reason=${JSON.stringify( From 6baf8ba93eb2aebc9d50ced12520d2a2bb6450f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Sun, 5 Jul 2026 05:36:36 +0800 Subject: [PATCH 19/61] fix(daemon): harden artifact persistence review paths --- packages/acp-bridge/src/bridge.test.ts | 216 +++++++++++++++++- packages/acp-bridge/src/bridge.ts | 51 ++++- .../src/sessionArtifactContentStore.ts | 26 ++- .../acp-bridge/src/sessionArtifacts.test.ts | 21 ++ packages/acp-bridge/src/sessionArtifacts.ts | 38 +-- packages/sdk-typescript/src/daemon/types.ts | 1 + 6 files changed, 327 insertions(+), 26 deletions(-) diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index 3e74ed7e3af..4f1c4e9f9c6 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -45,7 +45,12 @@ import type { ChannelFactory } from './channel.js'; import type { BridgeTelemetry } from './bridgeOptions.js'; import { createInMemoryChannel } from './inMemoryChannel.js'; import type { BridgeEvent } from './eventBus.js'; -import { ApprovalMode, ShellExecutionService } from '@qwen-code/qwen-code-core'; +import { + ApprovalMode, + SESSION_ARTIFACT_PERSISTENCE_VERSION, + ShellExecutionService, + stableSessionArtifactId, +} from '@qwen-code/qwen-code-core'; import { FakeAgent, type ChannelHandle, @@ -55,6 +60,7 @@ import { WS_B, SESS_A, } from './internal/testUtils.js'; +import { SessionArtifactContentStore } from './sessionArtifactContentStore.js'; function deferred(): { promise: Promise; @@ -183,6 +189,161 @@ describe('createAcpSessionBridge', () => { } }); + it('validates artifact remove options before changing live state', async () => { + const bridge = makeBridge({ + channelFactory: async () => makeChannel().channel, + }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + try { + const created = await bridge.addSessionArtifact( + session.sessionId, + { + title: 'Client link', + url: 'https://example.com/client', + }, + { clientId: session.clientId }, + ); + const artifactId = created.changes[0]!.artifactId; + + await expect( + bridge.removeSessionArtifact( + session.sessionId, + artifactId, + { clientId: session.clientId }, + { deleteContent: 'yes' } as unknown as { deleteContent: boolean }, + ), + ).rejects.toThrow(/deleteContent must be a boolean/); + + await expect( + bridge.getSessionArtifacts(session.sessionId), + ).resolves.toMatchObject({ + artifacts: [{ id: artifactId }], + }); + } finally { + await bridge.shutdown(); + } + }); + + it('returns artifact snapshots when best-effort GET content GC fails', async () => { + const previousQwenHome = process.env['QWEN_HOME']; + const tempHome = await fsp.mkdtemp(path.join(os.tmpdir(), 'qwen-home-')); + const workspace = await fsp.mkdtemp( + path.join(os.tmpdir(), 'qwen-artifact-workspace-'), + ); + process.env['QWEN_HOME'] = tempHome; + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-07-04T00:00:00.000Z')); + const gcSpy = vi + .spyOn(SessionArtifactContentStore.prototype, 'gc') + .mockRejectedValueOnce(new Error('disk unavailable')); + const bridge = makeBridge({ + boundWorkspace: workspace, + channelFactory: async () => makeChannel().channel, + }); + try { + await fsp.mkdir(path.join(workspace, 'reports'), { recursive: true }); + await fsp.writeFile(path.join(workspace, 'reports', 'report.txt'), 'hi'); + const session = await bridge.spawnOrAttach({ workspaceCwd: workspace }); + const created = await bridge.addSessionArtifact( + session.sessionId, + { + title: 'Report', + workspacePath: 'reports/report.txt', + }, + { clientId: session.clientId }, + ); + const artifactId = created.changes[0]!.artifactId; + await bridge.pinSessionArtifact( + session.sessionId, + artifactId, + { clientId: session.clientId }, + { mode: 'content', ttlDays: 1 }, + ); + + vi.setSystemTime(new Date('2026-07-06T00:00:00.000Z')); + await expect( + bridge.getSessionArtifacts(session.sessionId), + ).resolves.toMatchObject({ + artifacts: [ + { + id: artifactId, + retention: 'restorable', + persistenceWarning: 'content_expired', + }, + ], + warnings: ['artifact content GC failed; stale content retained'], + }); + expect(gcSpy).toHaveBeenCalledTimes(1); + } finally { + gcSpy.mockRestore(); + vi.useRealTimers(); + await bridge.shutdown(); + if (previousQwenHome === undefined) { + delete process.env['QWEN_HOME']; + } else { + process.env['QWEN_HOME'] = previousQwenHome; + } + await fsp.rm(tempHome, { recursive: true, force: true }); + await fsp.rm(workspace, { recursive: true, force: true }); + } + }); + + it('keeps unpin successful when best-effort content GC fails', async () => { + const previousQwenHome = process.env['QWEN_HOME']; + const tempHome = await fsp.mkdtemp(path.join(os.tmpdir(), 'qwen-home-')); + const workspace = await fsp.mkdtemp( + path.join(os.tmpdir(), 'qwen-artifact-workspace-'), + ); + process.env['QWEN_HOME'] = tempHome; + const bridge = makeBridge({ + boundWorkspace: workspace, + channelFactory: async () => makeChannel().channel, + }); + const gcSpy = vi + .spyOn(SessionArtifactContentStore.prototype, 'gc') + .mockRejectedValueOnce(new Error('disk unavailable')); + try { + await fsp.mkdir(path.join(workspace, 'reports'), { recursive: true }); + await fsp.writeFile(path.join(workspace, 'reports', 'report.txt'), 'hi'); + const session = await bridge.spawnOrAttach({ workspaceCwd: workspace }); + const created = await bridge.addSessionArtifact( + session.sessionId, + { + title: 'Report', + workspacePath: 'reports/report.txt', + }, + { clientId: session.clientId }, + ); + const artifactId = created.changes[0]!.artifactId; + await bridge.pinSessionArtifact( + session.sessionId, + artifactId, + { clientId: session.clientId }, + { mode: 'content' }, + ); + + await expect( + bridge.unpinSessionArtifact(session.sessionId, artifactId, { + clientId: session.clientId, + }), + ).resolves.toMatchObject({ + changes: [{ action: 'updated', artifactId }], + warnings: ['content_delete_preserved'], + }); + expect(gcSpy).toHaveBeenCalledTimes(1); + } finally { + gcSpy.mockRestore(); + await bridge.shutdown(); + if (previousQwenHome === undefined) { + delete process.env['QWEN_HOME']; + } else { + process.env['QWEN_HOME'] = previousQwenHome; + } + await fsp.rm(tempHome, { recursive: true, force: true }); + await fsp.rm(workspace, { recursive: true, force: true }); + } + }); + it('uses bridge telemetry for channel/session/prompt dispatch and prompt metadata injection', async () => { const handle = makeChannel(); const operations: string[] = []; @@ -1240,6 +1401,59 @@ describe('createAcpSessionBridge', () => { await bridge.shutdown(); }); + it('restores artifact snapshots that omit marker arrays after fork remap', async () => { + const sessionId = 'persisted-artifacts'; + const artifactUrl = 'https://example.com/restored'; + const artifactId = stableSessionArtifactId(sessionId, `url:${artifactUrl}`); + const bridge = makeBridge({ + channelFactory: async () => + makeChannel({ + loadSessionImpl: () => + ({ + artifactSnapshot: { + v: SESSION_ARTIFACT_PERSISTENCE_VERSION, + sessionId, + sequence: 7, + artifacts: [ + { + id: artifactId, + kind: 'link', + storage: 'external_url', + source: 'client', + status: 'available', + title: 'Restored link', + url: artifactUrl, + retention: 'restorable', + clientRetained: true, + createdAt: '2026-07-04T00:00:00.000Z', + updatedAt: '2026-07-04T00:00:00.000Z', + }, + ], + warnings: [], + }, + }) as LoadSessionResponse, + }).channel, + }); + + const loaded = await bridge.loadSession({ + sessionId, + workspaceCwd: WS_A, + }); + + await expect( + bridge.getSessionArtifacts(loaded.sessionId), + ).resolves.toMatchObject({ + artifacts: [ + { + id: artifactId, + title: 'Restored link', + restoreState: 'restored', + }, + ], + }); + await bridge.shutdown(); + }); + it('buffers load replay events until the restored session is registered', async () => { let capturedConn: AgentSideConnection | undefined; const factory: ChannelFactory = async () => { diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index a848ff4fa85..4fc264366f2 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -24,6 +24,7 @@ import type { import { DAEMON_TRACEPARENT_META_KEY, DAEMON_TRACESTATE_META_KEY, + SESSION_ARTIFACT_PERSISTENCE_VERSION, TrustGateError, ShellExecutionService, type ShellOutputEvent, @@ -2716,10 +2717,25 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { candidate && typeof candidate === 'object' && !Array.isArray(candidate) && - (candidate as { v?: unknown }).v === 2 && + (candidate as { v?: unknown }).v === + SESSION_ARTIFACT_PERSISTENCE_VERSION && Array.isArray((candidate as { artifacts?: unknown }).artifacts) ) { - return candidate as RebuiltSessionArtifactSnapshot; + const snapshot = candidate as Partial; + return { + v: SESSION_ARTIFACT_PERSISTENCE_VERSION, + sessionId: + typeof snapshot.sessionId === 'string' ? snapshot.sessionId : '', + sequence: typeof snapshot.sequence === 'number' ? snapshot.sequence : 0, + artifacts: snapshot.artifacts ?? [], + tombstonedIds: Array.isArray(snapshot.tombstonedIds) + ? snapshot.tombstonedIds + : [], + stickyEphemeralIds: Array.isArray(snapshot.stickyEphemeralIds) + ? snapshot.stickyEphemeralIds + : [], + warnings: Array.isArray(snapshot.warnings) ? snapshot.warnings : [], + }; } return undefined; }; @@ -4353,11 +4369,22 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { const entry = byId.get(sessionId); if (!entry) throw new SessionNotFoundError(sessionId); const pruned = await entry.artifacts.pruneExpiredPins(); + const warnings = [...(pruned.warnings ?? [])]; if (pruned.changes.length > 0) { publishArtifactChanges(entry, pruned.changes); - await gcArtifactContent(entry); + try { + await gcArtifactContent(entry); + } catch (error) { + warnings.push('artifact content GC failed; stale content retained'); + writeStderrLine( + `[artifacts] session=${entry.sessionId} action=get_gc_failed reason=${JSON.stringify( + error instanceof Error ? error.message : String(error), + )}`, + ); + } } - return entry.artifacts.list(); + const snapshot = await entry.artifacts.list(); + return warnings.length > 0 ? { ...snapshot, warnings } : snapshot; }, async addSessionArtifact(sessionId, artifact, context) { @@ -4375,9 +4402,9 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { const entry = byId.get(sessionId); if (!entry) throw new SessionNotFoundError(sessionId); const clientId = resolveTrustedClientId(entry, context?.clientId); + const removeOptions = artifactRemoveOptions(options); const artifact = await entry.artifacts.get(artifactId); const result = await entry.artifacts.remove(artifactId, { clientId }); - const removeOptions = artifactRemoveOptions(options); const warnings = [...(result.warnings ?? [])]; if (result.changes.length > 0) { try { @@ -4459,11 +4486,21 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { artifactId, artifactUnpinOptions(options), ); + const warnings = [...(result.warnings ?? [])]; if (result.changes.length > 0) { - await gcArtifactContent(entry); + try { + await gcArtifactContent(entry); + } catch (error) { + warnings.push('content_delete_preserved'); + writeStderrLine( + `[artifacts] session=${entry.sessionId} action=unpin_gc_failed reason=${JSON.stringify( + error instanceof Error ? error.message : String(error), + )}`, + ); + } } publishArtifactChanges(entry, result.changes, clientId); - return result; + return warnings.length > 0 ? { ...result, warnings } : result; }, async fsckSessionArtifacts(sessionId) { diff --git a/packages/acp-bridge/src/sessionArtifactContentStore.ts b/packages/acp-bridge/src/sessionArtifactContentStore.ts index 6bbaa879a57..32ba58e45ce 100644 --- a/packages/acp-bridge/src/sessionArtifactContentStore.ts +++ b/packages/acp-bridge/src/sessionArtifactContentStore.ts @@ -46,6 +46,7 @@ export class SessionArtifactContentStore { private readonly rootDir: string; private writeQueue: Promise = Promise.resolve(); private readonly leasedContentIds = new Set(); + private cachedTotalBytes: number | undefined; constructor(rootDir = defaultContentRoot()) { this.rootDir = rootDir; @@ -82,6 +83,7 @@ export class SessionArtifactContentStore { `${process.pid}-${Date.now()}-${artifact.id}.bin`, ); let sourceHandle: FileHandle | undefined; + let addedSizeBytes = 0; try { sourceHandle = await openRegularWorkspaceFile(source); const { sha256, sizeBytes } = await copyOpenFileToTemp( @@ -99,7 +101,7 @@ export class SessionArtifactContentStore { await fs.rm(tmpPath, { force: true }); tmpPath = undefined; } else { - const usedBytes = await this.usedBytes(); + const usedBytes = await this.getUsedBytes(); if (usedBytes + sizeBytes > MAX_CONTENT_STORE_BYTES) { throw new SessionArtifactValidationError( 'Artifact content quota exceeded', @@ -109,6 +111,7 @@ export class SessionArtifactContentStore { await fs.mkdir(contentDir, { recursive: true, mode: 0o700 }); await fs.rename(tmpPath, dataPath); tmpPath = undefined; + addedSizeBytes = sizeBytes; } const createdAt = new Date().toISOString(); @@ -123,6 +126,9 @@ export class SessionArtifactContentStore { createdAt, }; await writeManifestAtomic(contentDir, manifest); + if (addedSizeBytes > 0 && this.cachedTotalBytes !== undefined) { + this.cachedTotalBytes += addedSizeBytes; + } this.leasedContentIds.add(contentId); return { kind: 'managed_copy', @@ -132,6 +138,9 @@ export class SessionArtifactContentStore { createdAt, }; } catch (error) { + if (addedSizeBytes > 0) { + this.cachedTotalBytes = undefined; + } if (tmpPath) { await fs.rm(tmpPath, { force: true }).catch(() => {}); } @@ -261,13 +270,26 @@ export class SessionArtifactContentStore { continue; } await fs.rm(fullPath, { recursive: true, force: true }); + if (this.cachedTotalBytes !== undefined) { + this.cachedTotalBytes = Math.max( + 0, + this.cachedTotalBytes - manifest.sizeBytes, + ); + } removed.push(entry); } return { removed, retained }; }); } - private async usedBytes(): Promise { + private async getUsedBytes(): Promise { + if (this.cachedTotalBytes === undefined) { + this.cachedTotalBytes = await this.scanUsedBytes(); + } + return this.cachedTotalBytes; + } + + private async scanUsedBytes(): Promise { let total = 0; let entries: string[]; try { diff --git a/packages/acp-bridge/src/sessionArtifacts.test.ts b/packages/acp-bridge/src/sessionArtifacts.test.ts index b2381b79018..c8fad005ee1 100644 --- a/packages/acp-bridge/src/sessionArtifacts.test.ts +++ b/packages/acp-bridge/src/sessionArtifacts.test.ts @@ -2628,6 +2628,7 @@ describe('SessionArtifactContentStore', () => { }); afterEach(async () => { + vi.restoreAllMocks(); await fs.rm(workspace, { recursive: true, force: true }); await fs.rm(contentRoot, { recursive: true, force: true }); }); @@ -2742,6 +2743,26 @@ describe('SessionArtifactContentStore', () => { expect(second.contentId).toBe(first.contentId); }); + it('caches total content bytes after the first quota scan', async () => { + const contentStore = new SessionArtifactContentStore(contentRoot); + const first = await workspaceArtifact('cache-a.txt', 'a'); + const second = await workspaceArtifact('cache-b.txt', 'b'); + const readdirSpy = vi.spyOn(fs, 'readdir'); + + await contentStore.pinWorkspaceFile('content-session', first, workspace); + const rootScansAfterFirst = readdirSpy.mock.calls.filter( + ([dir]) => String(dir) === contentRoot, + ).length; + + await contentStore.pinWorkspaceFile('content-session', second, workspace); + const rootScansAfterSecond = readdirSpy.mock.calls.filter( + ([dir]) => String(dir) === contentRoot, + ).length; + + expect(rootScansAfterFirst).toBe(1); + expect(rootScansAfterSecond).toBe(rootScansAfterFirst); + }); + it('rejects files over the per-artifact content limit before copying', async () => { const contentStore = new SessionArtifactContentStore(contentRoot); await fs.mkdir(path.join(workspace, 'reports'), { recursive: true }); diff --git a/packages/acp-bridge/src/sessionArtifacts.ts b/packages/acp-bridge/src/sessionArtifacts.ts index be398b33bbb..b29f5c90e63 100644 --- a/packages/acp-bridge/src/sessionArtifacts.ts +++ b/packages/acp-bridge/src/sessionArtifacts.ts @@ -7,6 +7,10 @@ import { createHash } from 'node:crypto'; import { promises as fs } from 'node:fs'; import path from 'node:path'; +import { + SESSION_ARTIFACT_PERSISTENCE_VERSION, + stableSessionArtifactId, +} from '@qwen-code/qwen-code-core'; import type { PersistedSessionArtifact, RebuiltSessionArtifactSnapshot, @@ -19,18 +23,6 @@ import type { } from '@qwen-code/qwen-code-core'; import { writeStderrLine } from './internal/stderrLine.js'; -const SESSION_ARTIFACT_PERSISTENCE_VERSION = 2 as const; - -function stableSessionArtifactId( - sessionId: string, - identityKey: string, -): string { - return createHash('sha256') - .update(`${sessionId}:${identityKey}`) - .digest('hex') - .slice(0, 16); -} - export type DaemonSessionArtifactKind = | 'file' | 'link' @@ -133,6 +125,7 @@ export interface SessionArtifactsEnvelope { limits: { maxArtifacts: number; }; + warnings?: string[]; } export interface SessionArtifactMutationResult { @@ -806,11 +799,14 @@ export class SessionArtifactStore { if (change.action === 'removed') continue; const stored = this.artifacts.get(change.artifactId); if (!stored) continue; - stored.persistedAt = recordedAt; - if (stored.contentRef) { - delete stored.persistenceWarning; + const artifact = { + ...toPublicArtifact(stored), + persistedAt: recordedAt, + }; + if (artifact.contentRef) { + delete artifact.persistenceWarning; } - change.artifact = toPublicArtifact(stored); + change.artifact = artifact; } const payload: SessionArtifactEventRecordPayload = { @@ -825,6 +821,16 @@ export class SessionArtifactStore { try { await this.persistence.recordEvent(payload); + for (const change of durableChanges) { + if (change.action === 'removed') continue; + const stored = this.artifacts.get(change.artifactId); + if (!stored) continue; + stored.persistedAt = recordedAt; + if (stored.contentRef) { + delete stored.persistenceWarning; + } + change.artifact = toPublicArtifact(stored); + } this.applyDurableMarkers(durableChanges); await this.maybeRecordSnapshot(recordedAt); return []; diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index 050b0884b0d..2c8c7f16350 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -561,6 +561,7 @@ export interface DaemonSessionArtifactsEnvelope { limits: { maxArtifacts: number; }; + warnings?: string[]; } export interface DaemonSessionArtifactMutationResult { From b728e355fc4d69fd3d93280df32d5e11ca3074af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Sun, 5 Jul 2026 05:59:29 +0800 Subject: [PATCH 20/61] fix(daemon): address artifact persistence review follow-ups --- packages/acp-bridge/src/bridge.test.ts | 154 +++++++++++++++++- packages/acp-bridge/src/bridge.ts | 106 ++++++++---- .../src/sessionArtifactContentStore.ts | 9 +- .../acp-bridge/src/sessionArtifacts.test.ts | 102 +++++++++++- packages/acp-bridge/src/sessionArtifacts.ts | 50 ++++-- .../src/daemon/acpRouteTable.ts | 9 +- .../test/unit/acpRouteTable.test.ts | 32 +++- 7 files changed, 399 insertions(+), 63 deletions(-) diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index 61758e3ba9f..8d3d65e8e28 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -224,7 +224,28 @@ describe('createAcpSessionBridge', () => { } }); - it('returns artifact snapshots when best-effort GET content GC fails', async () => { + it('rejects invalid client artifact records instead of dropping them', async () => { + const bridge = makeBridge({ + channelFactory: async () => makeChannel().channel, + }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + try { + await expect( + bridge.addSessionArtifact( + session.sessionId, + { + title: 'x'.repeat(201), + url: 'https://example.com/client', + }, + { clientId: session.clientId }, + ), + ).rejects.toThrow(/title/); + } finally { + await bridge.shutdown(); + } + }); + + it('does not prune or GC expired pins during artifact GET', async () => { const previousQwenHome = process.env['QWEN_HOME']; const tempHome = await fsp.mkdtemp(path.join(os.tmpdir(), 'qwen-home-')); const workspace = await fsp.mkdtemp( @@ -267,13 +288,86 @@ describe('createAcpSessionBridge', () => { artifacts: [ { id: artifactId, - retention: 'restorable', - persistenceWarning: 'content_expired', + retention: 'pinned', }, ], + }); + expect(gcSpy).not.toHaveBeenCalled(); + } finally { + gcSpy.mockRestore(); + vi.useRealTimers(); + await bridge.shutdown(); + if (previousQwenHome === undefined) { + delete process.env['QWEN_HOME']; + } else { + process.env['QWEN_HOME'] = previousQwenHome; + } + await fsp.rm(tempHome, { recursive: true, force: true }); + await fsp.rm(workspace, { recursive: true, force: true }); + } + }); + + it('prunes expired pins during artifact mutations and reports GC warnings', async () => { + const previousQwenHome = process.env['QWEN_HOME']; + const tempHome = await fsp.mkdtemp(path.join(os.tmpdir(), 'qwen-home-')); + const workspace = await fsp.mkdtemp( + path.join(os.tmpdir(), 'qwen-artifact-workspace-'), + ); + process.env['QWEN_HOME'] = tempHome; + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-07-04T00:00:00.000Z')); + const gcSpy = vi + .spyOn(SessionArtifactContentStore.prototype, 'gc') + .mockRejectedValueOnce(new Error('disk unavailable')); + const bridge = makeBridge({ + boundWorkspace: workspace, + channelFactory: async () => makeChannel().channel, + }); + try { + await fsp.mkdir(path.join(workspace, 'reports'), { recursive: true }); + await fsp.writeFile(path.join(workspace, 'reports', 'report.txt'), 'hi'); + const session = await bridge.spawnOrAttach({ workspaceCwd: workspace }); + const created = await bridge.addSessionArtifact( + session.sessionId, + { + title: 'Report', + workspacePath: 'reports/report.txt', + }, + { clientId: session.clientId }, + ); + const artifactId = created.changes[0]!.artifactId; + await bridge.pinSessionArtifact( + session.sessionId, + artifactId, + { clientId: session.clientId }, + { mode: 'content', ttlDays: 1 }, + ); + + vi.setSystemTime(new Date('2026-07-06T00:00:00.000Z')); + await expect( + bridge.addSessionArtifact( + session.sessionId, + { + title: 'New link', + url: 'https://example.com/new-link', + }, + { clientId: session.clientId }, + ), + ).resolves.toMatchObject({ warnings: ['artifact content GC failed; stale content retained'], }); expect(gcSpy).toHaveBeenCalledTimes(1); + await expect( + bridge.getSessionArtifacts(session.sessionId), + ).resolves.toMatchObject({ + artifacts: expect.arrayContaining([ + expect.objectContaining({ + id: artifactId, + retention: 'restorable', + persistenceWarning: 'content_expired', + }), + ]), + }); } finally { gcSpy.mockRestore(); vi.useRealTimers(); @@ -288,6 +382,60 @@ describe('createAcpSessionBridge', () => { } }); + it('warns when pin options are ignored for an already pinned artifact', async () => { + const previousQwenHome = process.env['QWEN_HOME']; + const tempHome = await fsp.mkdtemp(path.join(os.tmpdir(), 'qwen-home-')); + const workspace = await fsp.mkdtemp( + path.join(os.tmpdir(), 'qwen-artifact-workspace-'), + ); + process.env['QWEN_HOME'] = tempHome; + const bridge = makeBridge({ + boundWorkspace: workspace, + channelFactory: async () => makeChannel().channel, + }); + try { + await fsp.mkdir(path.join(workspace, 'reports'), { recursive: true }); + await fsp.writeFile(path.join(workspace, 'reports', 'report.txt'), 'hi'); + const session = await bridge.spawnOrAttach({ workspaceCwd: workspace }); + const created = await bridge.addSessionArtifact( + session.sessionId, + { + title: 'Report', + workspacePath: 'reports/report.txt', + }, + { clientId: session.clientId }, + ); + const artifactId = created.changes[0]!.artifactId; + await bridge.pinSessionArtifact( + session.sessionId, + artifactId, + { clientId: session.clientId }, + { mode: 'content' }, + ); + + await expect( + bridge.pinSessionArtifact( + session.sessionId, + artifactId, + { clientId: session.clientId }, + { mode: 'metadata' }, + ), + ).resolves.toMatchObject({ + changes: [], + warnings: ['artifact already pinned; pin options ignored'], + }); + } finally { + await bridge.shutdown(); + if (previousQwenHome === undefined) { + delete process.env['QWEN_HOME']; + } else { + process.env['QWEN_HOME'] = previousQwenHome; + } + await fsp.rm(tempHome, { recursive: true, force: true }); + await fsp.rm(workspace, { recursive: true, force: true }); + } + }); + it('keeps unpin successful when best-effort content GC fails', async () => { const previousQwenHome = process.env['QWEN_HOME']; const tempHome = await fsp.mkdtemp(path.join(os.tmpdir(), 'qwen-home-')); diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index 2dbd0cb6c4b..f2037b6feff 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -123,6 +123,7 @@ import { PermissionForbiddenError } from './bridgeErrors.js'; import { SessionArtifactStore, SessionArtifactValidationError, + publicArtifactsEqual, type DaemonSessionArtifact, type SessionArtifactChange, type SessionArtifactInput, @@ -2575,7 +2576,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { }); continue; } - if (JSON.stringify(previous) !== JSON.stringify(artifact)) { + if (!publicArtifactsEqual(previous, artifact)) { changes.push({ action: 'updated', artifactId: artifact.id, @@ -2707,6 +2708,28 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { ); }; + const pruneExpiredArtifactPins = async ( + entry: SessionEntry, + originatorClientId?: string, + ): Promise => { + const pruned = await entry.artifacts.pruneExpiredPins(); + const warnings = [...(pruned.warnings ?? [])]; + if (pruned.changes.length > 0) { + publishArtifactChanges(entry, pruned.changes, originatorClientId); + try { + await gcArtifactContent(entry); + } catch (error) { + warnings.push('artifact content GC failed; stale content retained'); + writeStderrLine( + `[artifacts] session=${entry.sessionId} action=ttl_prune_gc_failed reason=${JSON.stringify( + error instanceof Error ? error.message : String(error), + )}`, + ); + } + } + return warnings; + }; + function createSessionArtifactPersistence( connection: ClientSideConnection, sessionId: string, @@ -4562,23 +4585,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { async getSessionArtifacts(sessionId) { const entry = byId.get(sessionId); if (!entry) throw new SessionNotFoundError(sessionId); - const pruned = await entry.artifacts.pruneExpiredPins(); - const warnings = [...(pruned.warnings ?? [])]; - if (pruned.changes.length > 0) { - publishArtifactChanges(entry, pruned.changes); - try { - await gcArtifactContent(entry); - } catch (error) { - warnings.push('artifact content GC failed; stale content retained'); - writeStderrLine( - `[artifacts] session=${entry.sessionId} action=get_gc_failed reason=${JSON.stringify( - error instanceof Error ? error.message : String(error), - )}`, - ); - } - } - const snapshot = await entry.artifacts.list(); - return warnings.length > 0 ? { ...snapshot, warnings } : snapshot; + return entry.artifacts.list(); }, async addSessionArtifact(sessionId, artifact, context) { @@ -4587,9 +4594,16 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { const clientId = resolveTrustedClientId(entry, context?.clientId); const input = makeClientArtifactInput(artifact, clientId); const result: SessionArtifactMutationResult = - await entry.artifacts.upsertMany([input], { strict: false }); + await entry.artifacts.upsertMany([input], { + validationStrict: true, + persistenceStrict: false, + }); publishArtifactChanges(entry, result.changes, clientId); - return result; + const warnings = [ + ...(result.warnings ?? []), + ...(await pruneExpiredArtifactPins(entry, clientId)), + ]; + return warnings.length > 0 ? { ...result, warnings } : result; }, async removeSessionArtifact(sessionId, artifactId, context, options) { @@ -4598,13 +4612,17 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { const clientId = resolveTrustedClientId(entry, context?.clientId); const removeOptions = artifactRemoveOptions(options); const artifact = await entry.artifacts.get(artifactId); + if (!artifact) { + return { v: 1, sessionId, changes: [] }; + } const result = await entry.artifacts.remove(artifactId, { clientId }); const warnings = [...(result.warnings ?? [])]; - if (result.changes.length > 0) { + if (result.changes.length > 0 && removeOptions.deleteContent !== false) { + warnings.push(...(await pruneExpiredArtifactPins(entry, clientId))); try { await gcArtifactContent(entry); } catch { - if (removeOptions.deleteContent === true || artifact?.contentRef) { + if (removeOptions.deleteContent === true || artifact.contentRef) { warnings.push('content_delete_preserved'); } } @@ -4617,15 +4635,30 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { const entry = byId.get(sessionId); if (!entry) throw new SessionNotFoundError(sessionId); const clientId = resolveTrustedClientId(entry, context?.clientId); + const mode = artifactPinMode(options); + const clientRetained = artifactClientRetained(options); + const expiresAt = artifactExpiresAt(options, mode); const artifact = await entry.artifacts.get(artifactId); if (!artifact) { return { v: 1, sessionId, changes: [] }; } - const mode = artifactPinMode(options); - if (artifact.retention === 'pinned') { - const result = await entry.artifacts.pin(artifactId); + const pruneWarnings = await pruneExpiredArtifactPins(entry, clientId); + const currentArtifact = await entry.artifacts.get(artifactId); + if (!currentArtifact) { + return { v: 1, sessionId, changes: [] }; + } + if (currentArtifact.retention === 'pinned') { + const result = + options === undefined + ? await entry.artifacts.pin(artifactId) + : await entry.artifacts.pin(artifactId, { + retention: mode === 'metadata' ? 'restorable' : 'pinned', + expiresAt, + clientRetained, + }); + const warnings = [...pruneWarnings, ...(result.warnings ?? [])]; publishArtifactChanges(entry, result.changes, clientId); - return result; + return warnings.length > 0 ? { ...result, warnings } : result; } let contentRef: SessionArtifactContentRef | undefined; try { @@ -4634,7 +4667,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { ? undefined : await artifactContentStore.pinWorkspaceFile( sessionId, - artifact, + currentArtifact, entry.workspaceCwd, ); if (mode === 'content' && !contentRef) { @@ -4646,8 +4679,8 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { const result = await entry.artifacts.pin(artifactId, { retention: mode === 'metadata' ? 'restorable' : 'pinned', contentRef, - expiresAt: artifactExpiresAt(options, mode), - clientRetained: artifactClientRetained(options), + expiresAt, + clientRetained, }); if (contentRef) { artifactContentStore.releaseContentRef(contentRef); @@ -4662,7 +4695,8 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { } } publishArtifactChanges(entry, result.changes, clientId); - return result; + const warnings = [...pruneWarnings, ...(result.warnings ?? [])]; + return warnings.length > 0 ? { ...result, warnings } : result; } catch (error) { if (contentRef) { artifactContentStore.releaseContentRef(contentRef); @@ -4676,11 +4710,13 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { const entry = byId.get(sessionId); if (!entry) throw new SessionNotFoundError(sessionId); const clientId = resolveTrustedClientId(entry, context?.clientId); - const result = await entry.artifacts.unpin( - artifactId, - artifactUnpinOptions(options), - ); - const warnings = [...(result.warnings ?? [])]; + const unpinOptions = artifactUnpinOptions(options); + if (!(await entry.artifacts.get(artifactId))) { + return { v: 1, sessionId, changes: [] }; + } + const pruneWarnings = await pruneExpiredArtifactPins(entry, clientId); + const result = await entry.artifacts.unpin(artifactId, unpinOptions); + const warnings = [...pruneWarnings, ...(result.warnings ?? [])]; if (result.changes.length > 0) { try { await gcArtifactContent(entry); diff --git a/packages/acp-bridge/src/sessionArtifactContentStore.ts b/packages/acp-bridge/src/sessionArtifactContentStore.ts index 32ba58e45ce..382cdeccd63 100644 --- a/packages/acp-bridge/src/sessionArtifactContentStore.ts +++ b/packages/acp-bridge/src/sessionArtifactContentStore.ts @@ -216,8 +216,13 @@ export class SessionArtifactContentStore { return 'restore_validation_failed'; } try { - const stat = await fs.stat(path.join(contentDir, 'content')); - if (!stat.isFile() || stat.size !== ref.sizeBytes) { + const contentPath = path.join(contentDir, 'content'); + const stat = await fs.stat(contentPath); + if (!stat.isFile()) { + return 'content_hash_mismatch'; + } + const { sha256, sizeBytes } = await hashFile(contentPath); + if (sha256 !== ref.sha256 || sizeBytes !== ref.sizeBytes) { return 'content_hash_mismatch'; } } catch (error) { diff --git a/packages/acp-bridge/src/sessionArtifacts.test.ts b/packages/acp-bridge/src/sessionArtifacts.test.ts index c8fad005ee1..cacb4641ee3 100644 --- a/packages/acp-bridge/src/sessionArtifacts.test.ts +++ b/packages/acp-bridge/src/sessionArtifacts.test.ts @@ -2356,6 +2356,53 @@ describe('SessionArtifactStore', () => { ).toBe(false); }); + it('snapshots unpin-to-ephemeral state after applying the live update', async () => { + const snapshots: SessionArtifactSnapshotRecordPayload[] = []; + const store = new SessionArtifactStore({ + sessionId: 's11-unpin-snapshot', + workspaceCwd: workspace, + persistence: { + recordEvent: async () => {}, + recordSnapshot: async (payload) => { + snapshots.push(payload); + }, + }, + }); + const created = await store.upsertMany( + [{ title: 'Pinned', url: 'https://example.com/pinned' }], + { strict: true }, + ); + const artifactId = created.changes[0]!.artifactId; + await store.pin(artifactId, { + contentRef: { + kind: 'managed_copy', + contentId: `${'a'.repeat(64)}-${'b'.repeat(16)}`, + sha256: 'a'.repeat(64), + sizeBytes: 5, + createdAt: '2026-07-04T00:00:00.000Z', + }, + }); + for (let index = 0; index < 47; index++) { + await store.upsertMany( + [ + { + title: `Durable ${index}`, + url: `https://example.com/unpin-${index}`, + }, + ], + { strict: true }, + ); + } + + await store.unpin(artifactId, { retention: 'ephemeral' }); + + expect(snapshots).toHaveLength(1); + expect(snapshots[0]?.stickyEphemeralIds).toContain(artifactId); + expect( + snapshots[0]?.artifacts.some((artifact) => artifact.id === artifactId), + ).toBe(false); + }); + it('downgrades non-strict durable artifacts when persistence is unavailable', async () => { const store = new SessionArtifactStore({ sessionId: 's11-unavailable', @@ -2408,6 +2455,59 @@ describe('SessionArtifactStore', () => { await expect(store.list()).resolves.toMatchObject({ artifacts: [] }); }); + it('keeps expired pins visible with a warning when TTL prune persistence fails', async () => { + let rejectPrune = false; + const store = new SessionArtifactStore({ + sessionId: 's11-prune-fail', + workspaceCwd: workspace, + persistence: { + recordEvent: async (payload) => { + if ( + rejectPrune && + payload.changes.some((change) => change.action === 'updated') + ) { + throw new Error('disk full'); + } + }, + recordSnapshot: async () => {}, + }, + }); + const created = await store.upsertMany( + [{ title: 'Pinned', url: 'https://example.com/pinned' }], + { strict: true }, + ); + const artifactId = created.changes[0]!.artifactId; + await store.pin(artifactId, { + expiresAt: '2026-07-04T00:00:00.000Z', + contentRef: { + kind: 'managed_copy', + contentId: `${'c'.repeat(64)}-${'d'.repeat(16)}`, + sha256: 'c'.repeat(64), + sizeBytes: 5, + createdAt: '2026-07-03T00:00:00.000Z', + }, + }); + + rejectPrune = true; + await expect( + store.pruneExpiredPins(new Date('2026-07-05T00:00:00.000Z')), + ).resolves.toMatchObject({ + changes: [], + warnings: [ + 'expired pinned artifacts retained because persistence failed', + ], + }); + await expect(store.list()).resolves.toMatchObject({ + artifacts: [ + expect.objectContaining({ + id: artifactId, + retention: 'pinned', + persistenceWarning: 'persistence_unavailable', + }), + ], + }); + }); + it('removes live artifacts even when explicit tombstone persistence fails', async () => { let calls = 0; const store = new SessionArtifactStore({ @@ -2974,7 +3074,7 @@ describe('SessionArtifactContentStore', () => { ); await expect( contentStore.verifyContentRef('content-session', artifact.id, ref), - ).resolves.toBeUndefined(); + ).resolves.toBe('content_hash_mismatch'); await expect(contentStore.fsck([ref])).resolves.toMatchObject({ hashMismatches: [ref.contentId], }); diff --git a/packages/acp-bridge/src/sessionArtifacts.ts b/packages/acp-bridge/src/sessionArtifacts.ts index b29f5c90e63..e1d6c1a04a9 100644 --- a/packages/acp-bridge/src/sessionArtifacts.ts +++ b/packages/acp-bridge/src/sessionArtifacts.ts @@ -254,9 +254,16 @@ export class SessionArtifactStore { async upsertMany( inputs: SessionArtifactInput[], - options: { strict?: boolean; trustedPublisher?: boolean } = {}, + options: { + strict?: boolean; + validationStrict?: boolean; + persistenceStrict?: boolean; + trustedPublisher?: boolean; + } = {}, ): Promise { return this.enqueue(async () => { + const validationStrict = options.validationStrict ?? options.strict; + const persistenceStrict = options.persistenceStrict ?? options.strict; const before = this.cloneState(); const normalizedResults: NormalizedArtifact[] = []; const warnings: string[] = []; @@ -270,7 +277,7 @@ export class SessionArtifactStore { ), ); } catch (error) { - if (options.strict) { + if (validationStrict) { throw error; } const message = @@ -324,12 +331,14 @@ export class SessionArtifactStore { .map((change) => change.artifactId), ); changes.push( - ...(await this.evictOverflow(createdIds, changes, options.strict)), + ...(await this.evictOverflow(createdIds, changes, persistenceStrict)), ); - warnings.push(...(await this.persistChanges(changes, options.strict))); + warnings.push( + ...(await this.persistChanges(changes, persistenceStrict)), + ); } catch (error) { - if (options.strict) { + if (validationStrict || persistenceStrict) { this.restoreState(before); } throw error; @@ -457,7 +466,16 @@ export class SessionArtifactStore { return { v: 1, sessionId: this.sessionId, changes: [] }; } if (existing.retention === 'pinned') { - return { v: 1, sessionId: this.sessionId, changes: [] }; + const warnings = + Object.keys(options).length > 0 + ? ['artifact already pinned; pin options ignored'] + : undefined; + return { + v: 1, + sessionId: this.sessionId, + changes: [], + ...(warnings ? { warnings } : {}), + }; } const targetRetention = options.retention ?? (options.contentRef ? 'pinned' : 'restorable'); @@ -559,14 +577,9 @@ export class SessionArtifactStore { }, ] : changes; - if (targetRetention !== 'ephemeral') { - this.artifacts.set(artifactId, updated); - } + this.artifacts.set(artifactId, updated); try { const warnings = await this.persistChanges(durableChanges, true); - if (targetRetention === 'ephemeral') { - this.artifacts.set(artifactId, updated); - } return { v: 1, sessionId: this.sessionId, @@ -586,6 +599,7 @@ export class SessionArtifactStore { return this.enqueue(async () => { const before = this.cloneState(); const changes: SessionArtifactChange[] = []; + const expiredIds: string[] = []; for (const [artifactId, existing] of this.artifacts) { if ( existing.retention !== 'pinned' || @@ -594,6 +608,7 @@ export class SessionArtifactStore { ) { continue; } + expiredIds.push(artifactId); const updated: StoredArtifact = { ...existing, retention: 'restorable', @@ -623,6 +638,15 @@ export class SessionArtifactStore { }; } catch (error) { this.restoreState(before); + for (const artifactId of expiredIds) { + const restored = this.artifacts.get(artifactId); + if (restored?.retention === 'pinned') { + this.artifacts.set(artifactId, { + ...restored, + persistenceWarning: 'persistence_unavailable', + }); + } + } writeStderrLine( `[artifacts] session=${this.sessionId} action=ttl_prune_failed reason=${JSON.stringify( error instanceof Error ? error.message : String(error), @@ -1379,7 +1403,7 @@ function mergeArtifact( return { artifact: changed ? next : existing, changed }; } -function publicArtifactsEqual( +export function publicArtifactsEqual( a: DaemonSessionArtifact, b: DaemonSessionArtifact, ): boolean { diff --git a/packages/sdk-typescript/src/daemon/acpRouteTable.ts b/packages/sdk-typescript/src/daemon/acpRouteTable.ts index 5eafb5f64f2..f21df094865 100644 --- a/packages/sdk-typescript/src/daemon/acpRouteTable.ts +++ b/packages/sdk-typescript/src/daemon/acpRouteTable.ts @@ -270,7 +270,8 @@ export const ROUTE_TABLE: readonly RouteEntry[] = [ pattern: /^\/session\/([^/]+)\/artifacts\/([^/]+)\/pin$/, mapping: { method: '_qwen/session/artifacts/pin', - extractParams: (segs) => ({ + extractParams: (segs, body) => ({ + ...(isRecord(body) ? body : {}), sessionId: segs[0], artifactId: segs[1], }), @@ -282,7 +283,8 @@ export const ROUTE_TABLE: readonly RouteEntry[] = [ pattern: /^\/session\/([^/]+)\/artifacts\/([^/]+)\/pin$/, mapping: { method: '_qwen/session/artifacts/unpin', - extractParams: (segs) => ({ + extractParams: (segs, body) => ({ + ...(isRecord(body) ? body : {}), sessionId: segs[0], artifactId: segs[1], }), @@ -312,7 +314,8 @@ export const ROUTE_TABLE: readonly RouteEntry[] = [ pattern: /^\/session\/([^/]+)\/artifacts\/([^/]+)$/, mapping: { method: '_qwen/session/artifacts/remove', - extractParams: (segs) => ({ + extractParams: (segs, body) => ({ + ...(isRecord(body) ? body : {}), sessionId: segs[0], artifactId: segs[1], }), diff --git a/packages/sdk-typescript/test/unit/acpRouteTable.test.ts b/packages/sdk-typescript/test/unit/acpRouteTable.test.ts index ab2fc1f6eec..83db1dc27e1 100644 --- a/packages/sdk-typescript/test/unit/acpRouteTable.test.ts +++ b/packages/sdk-typescript/test/unit/acpRouteTable.test.ts @@ -266,8 +266,16 @@ describe('acpRouteTable – matchRoute', () => { expect(result).not.toBeNull(); expect(result!.mapping.method).toBe('_qwen/session/artifacts/remove'); expect( - result!.mapping.extractParams(result!.segments, undefined, 'DELETE'), - ).toEqual({ sessionId: 's8', artifactId: 'art 1' }); + result!.mapping.extractParams( + result!.segments, + { deleteContent: false }, + 'DELETE', + ), + ).toEqual({ + sessionId: 's8', + artifactId: 'art 1', + deleteContent: false, + }); }); it('POST /session/:id/artifacts/:artifactId/pin maps to _qwen/session/artifacts/pin', () => { @@ -275,8 +283,12 @@ describe('acpRouteTable – matchRoute', () => { expect(result).not.toBeNull(); expect(result!.mapping.method).toBe('_qwen/session/artifacts/pin'); expect( - result!.mapping.extractParams(result!.segments, undefined, 'POST'), - ).toEqual({ sessionId: 's8', artifactId: 'art 1' }); + result!.mapping.extractParams( + result!.segments, + { mode: 'metadata' }, + 'POST', + ), + ).toEqual({ sessionId: 's8', artifactId: 'art 1', mode: 'metadata' }); }); it('DELETE /session/:id/artifacts/:artifactId/pin maps to _qwen/session/artifacts/unpin', () => { @@ -284,8 +296,16 @@ describe('acpRouteTable – matchRoute', () => { expect(result).not.toBeNull(); expect(result!.mapping.method).toBe('_qwen/session/artifacts/unpin'); expect( - result!.mapping.extractParams(result!.segments, undefined, 'DELETE'), - ).toEqual({ sessionId: 's8', artifactId: 'art 1' }); + result!.mapping.extractParams( + result!.segments, + { retention: 'ephemeral' }, + 'DELETE', + ), + ).toEqual({ + sessionId: 's8', + artifactId: 'art 1', + retention: 'ephemeral', + }); }); it('GET /session/:id/artifacts/fsck maps to _qwen/session/artifacts/fsck', () => { From 95cd5f66a3e9f2f3a52067b5aaba2b6321390726 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Sun, 5 Jul 2026 07:46:34 +0800 Subject: [PATCH 21/61] fix(daemon): address artifact persistence review --- ...session-artifacts-persistence-v2-design.md | 4 +- packages/acp-bridge/src/bridge.test.ts | 84 ++++++++- packages/acp-bridge/src/bridge.ts | 39 +++-- packages/acp-bridge/src/bridgeTypes.ts | 5 +- .../acp-bridge/src/sessionArtifacts.test.ts | 161 ++++++++++++++++-- packages/acp-bridge/src/sessionArtifacts.ts | 98 ++++++++--- packages/cli/src/serve/acp-http/dispatch.ts | 5 +- .../cli/src/serve/acp-http/transport.test.ts | 16 +- packages/cli/src/serve/routes/session.ts | 11 +- packages/cli/src/serve/server.test.ts | 24 ++- 10 files changed, 384 insertions(+), 63 deletions(-) diff --git a/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md b/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md index 621a163ed0c..5bc6bed3b22 100644 --- a/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md +++ b/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md @@ -517,7 +517,7 @@ Body: - `ttlDays` 只允许和 `mode: "content"` 一起使用,由 daemon 在 pin 时转换为绝对 `expiresAt = now + ttlDays`。如果提供,必须是正整数,并受默认最大值 365 天约束;超过上限返回 `INVALID_ARGUMENT`。`mode: "metadata"` 携带 `ttlDays` 返回 `INVALID_ARGUMENT`,不能静默忽略。 - 成功按 §6.6 返回更新后的 artifact,并发布 `artifact_changed` / `updated`。 - 失败返回明确错误,不改变 artifact retention。 -- 对已经 `pinned` 的 artifact,V2 pin API 默认是幂等 no-op:返回当前 artifact,不重新复制内容、不重新计算 hash、不延长 TTL。若未来需要刷新内容或延长 TTL,应设计显式 refresh/extend API;不能让重试请求隐式改变已保存内容或无限延长保留期。 +- 对已经 `pinned` 的 artifact,空 body / 无显式选项的重复 pin 是幂等 no-op。带显式选项的重复 pin 视为用户更新操作:`mode: "metadata"` 可降级为 `restorable`,显式 `mode: "content"` 会刷新 managed content copy/hash,`ttlDays` 会更新 `expiresAt`,`clientRetained` 会更新保留 hint。调用方不应把带选项的 pin 当作无副作用重试。 ### 6.4 Unpin @@ -948,7 +948,7 @@ V2 新增的失败路径必须有 structured logs,格式沿用: - partial writes:journal 成功但 warning、content manifest 成功但 journal 失败、journal 成功后 restore 能找到 content。 - JSONL snapshot baseline advance:threshold 触发、post-snapshot replay 有界、snapshot payload 不再携带已被覆盖的 explicit tombstones、superseded sticky tombstone 允许显式同 id 重新出现、`stickyEphemeralIds` 保留 sticky state;JSONL 文件本身不被 artifact 子系统重写。 - corrupt latest snapshot fallback:回退到较旧 valid snapshot 或一次顺序 artifact replay。 -- repin idempotency:已 pinned artifact 的重复 pin 不刷新内容、不延长 TTL。 +- repin idempotency:已 pinned artifact 的空重复 pin 不刷新内容、不延长 TTL;显式 `mode` / `ttlDays` / `clientRetained` 会按 §6.3 更新对应状态。 - retention defaults:tool artifact 无显式 retention、client POST `pinned` 被拒绝。 - capability:string list 只在行为当前可用时声明;不依赖 `enabled:false` details。 - replay idempotency:同一 session history replay 两次不会重复 artifact。 diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index 8d3d65e8e28..2cb40735e7e 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -382,7 +382,7 @@ describe('createAcpSessionBridge', () => { } }); - it('warns when pin options are ignored for an already pinned artifact', async () => { + it('applies metadata pin options to an already pinned artifact', async () => { const previousQwenHome = process.env['QWEN_HOME']; const tempHome = await fsp.mkdtemp(path.join(os.tmpdir(), 'qwen-home-')); const workspace = await fsp.mkdtemp( @@ -421,8 +421,16 @@ describe('createAcpSessionBridge', () => { { mode: 'metadata' }, ), ).resolves.toMatchObject({ - changes: [], - warnings: ['artifact already pinned; pin options ignored'], + changes: [ + { + action: 'updated', + artifactId, + artifact: { + retention: 'restorable', + persistenceWarning: 'metadata_only_restore', + }, + }, + ], }); } finally { await bridge.shutdown(); @@ -436,6 +444,71 @@ describe('createAcpSessionBridge', () => { } }); + it('refreshes retained content on explicit content re-pin', async () => { + const previousQwenHome = process.env['QWEN_HOME']; + const tempHome = await fsp.mkdtemp(path.join(os.tmpdir(), 'qwen-home-')); + const workspace = await fsp.mkdtemp( + path.join(os.tmpdir(), 'qwen-artifact-workspace-'), + ); + process.env['QWEN_HOME'] = tempHome; + const bridge = makeBridge({ + boundWorkspace: workspace, + channelFactory: async () => makeChannel().channel, + }); + try { + await fsp.mkdir(path.join(workspace, 'reports'), { recursive: true }); + await fsp.writeFile( + path.join(workspace, 'reports', 'report.txt'), + 'first', + ); + const session = await bridge.spawnOrAttach({ workspaceCwd: workspace }); + const created = await bridge.addSessionArtifact( + session.sessionId, + { + title: 'Report', + workspacePath: 'reports/report.txt', + }, + { clientId: session.clientId }, + ); + const artifactId = created.changes[0]!.artifactId; + const firstPin = await bridge.pinSessionArtifact( + session.sessionId, + artifactId, + { clientId: session.clientId }, + { mode: 'content' }, + ); + const firstContentId = + firstPin.changes[0]?.artifact?.contentRef?.contentId; + + await fsp.writeFile( + path.join(workspace, 'reports', 'report.txt'), + 'second', + ); + const secondPin = await bridge.pinSessionArtifact( + session.sessionId, + artifactId, + { clientId: session.clientId }, + { mode: 'content' }, + ); + + expect( + secondPin.changes[0]?.artifact?.contentRef?.contentId, + ).toBeTruthy(); + expect(secondPin.changes[0]?.artifact?.contentRef?.contentId).not.toBe( + firstContentId, + ); + } finally { + await bridge.shutdown(); + if (previousQwenHome === undefined) { + delete process.env['QWEN_HOME']; + } else { + process.env['QWEN_HOME'] = previousQwenHome; + } + await fsp.rm(tempHome, { recursive: true, force: true }); + await fsp.rm(workspace, { recursive: true, force: true }); + } + }); + it('keeps unpin successful when best-effort content GC fails', async () => { const previousQwenHome = process.env['QWEN_HOME']; const tempHome = await fsp.mkdtemp(path.join(os.tmpdir(), 'qwen-home-')); @@ -7318,6 +7391,11 @@ describe('createAcpSessionBridge', () => { { clientId: 'client-not-issued' }, ), ).rejects.toBeInstanceOf(InvalidClientIdError); + await expect( + bridge.fsckSessionArtifacts(session.sessionId, { + clientId: 'client-not-issued', + }), + ).rejects.toBeInstanceOf(InvalidClientIdError); await bridge.shutdown(); }); diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index f2037b6feff..d3f7bdfdce5 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -4635,9 +4635,13 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { const entry = byId.get(sessionId); if (!entry) throw new SessionNotFoundError(sessionId); const clientId = resolveTrustedClientId(entry, context?.clientId); - const mode = artifactPinMode(options); - const clientRetained = artifactClientRetained(options); - const expiresAt = artifactExpiresAt(options, mode); + const requestOptions = + options !== undefined && Object.keys(options).length > 0 + ? options + : undefined; + const mode = artifactPinMode(requestOptions); + const clientRetained = artifactClientRetained(requestOptions); + const expiresAt = artifactExpiresAt(requestOptions, mode); const artifact = await entry.artifacts.get(artifactId); if (!artifact) { return { v: 1, sessionId, changes: [] }; @@ -4647,14 +4651,18 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { if (!currentArtifact) { return { v: 1, sessionId, changes: [] }; } - if (currentArtifact.retention === 'pinned') { + const refreshPinnedContent = + currentArtifact.retention === 'pinned' && + requestOptions?.mode === 'content'; + if (currentArtifact.retention === 'pinned' && !refreshPinnedContent) { const result = - options === undefined - ? await entry.artifacts.pin(artifactId) + requestOptions === undefined + ? await entry.artifacts.pin(artifactId, { clientId }) : await entry.artifacts.pin(artifactId, { retention: mode === 'metadata' ? 'restorable' : 'pinned', - expiresAt, - clientRetained, + ...(expiresAt !== undefined ? { expiresAt } : {}), + ...(clientRetained !== undefined ? { clientRetained } : {}), + clientId, }); const warnings = [...pruneWarnings, ...(result.warnings ?? [])]; publishArtifactChanges(entry, result.changes, clientId); @@ -4678,9 +4686,10 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { } const result = await entry.artifacts.pin(artifactId, { retention: mode === 'metadata' ? 'restorable' : 'pinned', - contentRef, - expiresAt, - clientRetained, + ...(contentRef ? { contentRef } : {}), + ...(expiresAt !== undefined ? { expiresAt } : {}), + ...(clientRetained !== undefined ? { clientRetained } : {}), + clientId, }); if (contentRef) { artifactContentStore.releaseContentRef(contentRef); @@ -4715,7 +4724,10 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { return { v: 1, sessionId, changes: [] }; } const pruneWarnings = await pruneExpiredArtifactPins(entry, clientId); - const result = await entry.artifacts.unpin(artifactId, unpinOptions); + const result = await entry.artifacts.unpin(artifactId, { + ...unpinOptions, + clientId, + }); const warnings = [...pruneWarnings, ...(result.warnings ?? [])]; if (result.changes.length > 0) { try { @@ -4733,9 +4745,10 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { return warnings.length > 0 ? { ...result, warnings } : result; }, - async fsckSessionArtifacts(sessionId) { + async fsckSessionArtifacts(sessionId, context) { const entry = byId.get(sessionId); if (!entry) throw new SessionNotFoundError(sessionId); + resolveTrustedClientId(entry, context?.clientId); return artifactContentStore.fsck(await entry.artifacts.contentRefs()); }, diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts index eeabd567c52..73d8302ce56 100644 --- a/packages/acp-bridge/src/bridgeTypes.ts +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -617,7 +617,10 @@ export interface AcpSessionBridge { options?: SessionArtifactUnpinRequest, ): Promise; - fsckSessionArtifacts(sessionId: string): Promise; + fsckSessionArtifacts( + sessionId: string, + context?: BridgeClientRequestContext, + ): Promise; gcSessionArtifacts(sessionId: string): Promise; diff --git a/packages/acp-bridge/src/sessionArtifacts.test.ts b/packages/acp-bridge/src/sessionArtifacts.test.ts index cacb4641ee3..5ebf1ba4153 100644 --- a/packages/acp-bridge/src/sessionArtifacts.test.ts +++ b/packages/acp-bridge/src/sessionArtifacts.test.ts @@ -125,6 +125,73 @@ describe('SessionArtifactStore', () => { }); }); + it('prevents one client from pinning or unpinning another client artifact', async () => { + const store = new SessionArtifactStore({ + sessionId: 's1-client-owner-pin', + workspaceCwd: workspace, + persistence: { + recordEvent: async () => {}, + recordSnapshot: async () => {}, + }, + }); + const created = await store.upsertMany([ + { + title: 'Client link', + source: 'client', + clientId: 'client-a', + url: 'https://example.com/client-a-pin', + }, + ]); + const artifactId = created.changes[0]!.artifactId; + const contentRef = { + kind: 'managed_copy' as const, + contentId: `${'a'.repeat(64)}-${'b'.repeat(16)}`, + sha256: 'a'.repeat(64), + sizeBytes: 12, + createdAt: '2026-07-04T00:00:00.000Z', + }; + + const stderr = vi + .spyOn(process.stderr, 'write') + .mockReturnValue(true as never); + try { + await expect( + store.pin(artifactId, { clientId: 'client-b', contentRef }), + ).resolves.toMatchObject({ changes: [] }); + await expect( + store.pin(artifactId, { clientId: 'client-a', contentRef }), + ).resolves.toMatchObject({ + changes: [{ action: 'updated', artifactId }], + }); + await expect( + store.unpin(artifactId, { clientId: 'client-b' }), + ).resolves.toMatchObject({ changes: [] }); + + const logged = stderr.mock.calls.map((call) => String(call[0])).join(''); + expect(logged).toContain('pin_denied'); + expect(logged).toContain('unpin_denied'); + expect(logged).toContain('client-a'); + expect(logged).toContain('client-b'); + } finally { + stderr.mockRestore(); + } + + await expect(store.list()).resolves.toMatchObject({ + artifacts: [ + expect.objectContaining({ + id: artifactId, + retention: 'pinned', + clientId: 'client-a', + }), + ], + }); + await expect( + store.unpin(artifactId, { clientId: 'client-a' }), + ).resolves.toMatchObject({ + changes: [{ action: 'updated', artifactId }], + }); + }); + it('does not write live client ids into durable artifact records', async () => { const events: SessionArtifactEventRecordPayload[] = []; const store = new SessionArtifactStore({ @@ -432,7 +499,7 @@ describe('SessionArtifactStore', () => { }); }); - it('does not refresh an already pinned artifact on repeated pin', async () => { + it('updates an already pinned artifact when pin options change', async () => { const events: SessionArtifactEventRecordPayload[] = []; const store = new SessionArtifactStore({ sessionId: 's1-pin-idempotent', @@ -461,27 +528,46 @@ describe('SessionArtifactStore', () => { expiresAt: '2026-08-01T00:00:00.000Z', }); + const nextContentRef = { + ...contentRef, + contentId: `${'c'.repeat(64)}-${'d'.repeat(16)}`, + sha256: 'c'.repeat(64), + }; await expect( store.pin(artifactId, { - contentRef: { - ...contentRef, - contentId: `${'c'.repeat(64)}-${'d'.repeat(16)}`, - sha256: 'c'.repeat(64), - }, + contentRef: nextContentRef, expiresAt: '2026-09-01T00:00:00.000Z', + clientRetained: false, }), - ).resolves.toMatchObject({ changes: [] }); + ).resolves.toMatchObject({ + changes: [ + { + action: 'updated', + artifactId, + artifact: { + retention: 'pinned', + contentRef: nextContentRef, + expiresAt: '2026-09-01T00:00:00.000Z', + clientRetained: false, + }, + }, + ], + }); await expect(store.list()).resolves.toMatchObject({ artifacts: [ { id: artifactId, retention: 'pinned', - contentRef, - expiresAt: '2026-08-01T00:00:00.000Z', + contentRef: nextContentRef, + expiresAt: '2026-09-01T00:00:00.000Z', + clientRetained: false, }, ], }); - expect(events).toHaveLength(2); + await expect(store.pin(artifactId)).resolves.toMatchObject({ + changes: [], + }); + expect(events).toHaveLength(3); }); it('rolls back pin mutations when persistence fails', async () => { @@ -2403,6 +2489,61 @@ describe('SessionArtifactStore', () => { ).toBe(false); }); + it('drops stale sticky markers when durable eviction removes an artifact', async () => { + const sourceEvents: SessionArtifactEventRecordPayload[] = []; + const source = new SessionArtifactStore({ + sessionId: 's11-eviction-sticky', + workspaceCwd: workspace, + persistence: { + recordEvent: async (payload) => { + sourceEvents.push(payload); + }, + recordSnapshot: async () => {}, + }, + }); + await source.upsertMany( + [{ title: 'Sticky', url: 'https://example.com/sticky' }], + { strict: true }, + ); + const evictedArtifact = sourceEvents[0]!.changes[0]!.artifact!; + const snapshots: SessionArtifactSnapshotRecordPayload[] = []; + const restored = new SessionArtifactStore({ + sessionId: 's11-eviction-sticky', + workspaceCwd: workspace, + maxArtifacts: 1, + persistence: { + recordEvent: async () => {}, + recordSnapshot: async (payload) => { + snapshots.push(payload); + }, + }, + }); + await restored.restore({ + v: 2, + sessionId: 's11-eviction-sticky', + sequence: 1, + artifacts: [evictedArtifact], + tombstonedIds: [], + stickyEphemeralIds: [evictedArtifact.id], + warnings: [], + }); + + for (let index = 0; index < 50; index++) { + await restored.upsertMany( + [ + { + title: `Replacement ${index}`, + url: `https://example.com/replacement-${index}`, + }, + ], + { strict: true }, + ); + } + + expect(snapshots).toHaveLength(1); + expect(snapshots[0]?.stickyEphemeralIds).not.toContain(evictedArtifact.id); + }); + it('downgrades non-strict durable artifacts when persistence is unavailable', async () => { const store = new SessionArtifactStore({ sessionId: 's11-unavailable', diff --git a/packages/acp-bridge/src/sessionArtifacts.ts b/packages/acp-bridge/src/sessionArtifacts.ts index e1d6c1a04a9..63206cecbb0 100644 --- a/packages/acp-bridge/src/sessionArtifacts.ts +++ b/packages/acp-bridge/src/sessionArtifacts.ts @@ -426,16 +426,13 @@ export class SessionArtifactStore { // Client-created artifacts with an owner require the same client id. // Tool/hook artifacts are session-scoped outputs and may be removed by // any caller that already passed session mutation auth. - if ( - existing.source === 'client' && - existing.clientId !== undefined && - existing.clientId !== options?.clientId - ) { - writeStderrLine( - `[artifacts] session=${this.sessionId} action=remove_denied artifactId=${artifactId} owner=${existing.clientId} requester=${options?.clientId ?? ''}`, - ); - return { v: 1, sessionId: this.sessionId, changes: [] }; - } + const denied = this.denyCrossClientMutation( + 'remove', + artifactId, + existing, + options, + ); + if (denied) return denied; this.artifacts.delete(artifactId); const changes: SessionArtifactChange[] = [ { @@ -457,7 +454,7 @@ export class SessionArtifactStore { async pin( artifactId: string, - options: SessionArtifactPinOptions = {}, + options: SessionArtifactPinOptions & { clientId?: string } = {}, ): Promise { return this.enqueue(async () => { const before = this.cloneState(); @@ -465,33 +462,51 @@ export class SessionArtifactStore { if (!existing) { return { v: 1, sessionId: this.sessionId, changes: [] }; } - if (existing.retention === 'pinned') { - const warnings = - Object.keys(options).length > 0 - ? ['artifact already pinned; pin options ignored'] - : undefined; + const denied = this.denyCrossClientMutation( + 'pin', + artifactId, + existing, + options, + ); + if (denied) return denied; + if (existing.retention === 'pinned' && !hasPinOptions(options)) { return { v: 1, sessionId: this.sessionId, changes: [], - ...(warnings ? { warnings } : {}), }; } const targetRetention = - options.retention ?? (options.contentRef ? 'pinned' : 'restorable'); - const { contentRef, expiresAt } = options; + options.retention ?? + (options.contentRef + ? 'pinned' + : existing.retention === 'pinned' + ? 'pinned' + : 'restorable'); + const contentRef = + targetRetention === 'pinned' + ? (options.contentRef ?? existing.contentRef) + : undefined; const updated: StoredArtifact = { ...existing, retention: targetRetention, - clientRetained: options.clientRetained ?? true, + clientRetained: + options.clientRetained ?? + (existing.retention === 'pinned' ? existing.clientRetained : true), restoreState: 'live', updatedAt: new Date().toISOString(), }; if (targetRetention === 'pinned' && contentRef) { updated.contentRef = contentRef; delete updated.persistenceWarning; - if (expiresAt) { - updated.expiresAt = expiresAt; + if (Object.hasOwn(options, 'expiresAt')) { + if (options.expiresAt) { + updated.expiresAt = options.expiresAt; + } else { + delete updated.expiresAt; + } + } else if (existing.expiresAt) { + updated.expiresAt = existing.expiresAt; } else { delete updated.expiresAt; } @@ -525,7 +540,7 @@ export class SessionArtifactStore { async unpin( artifactId: string, - options: SessionArtifactUnpinOptions = {}, + options: SessionArtifactUnpinOptions & { clientId?: string } = {}, ): Promise { return this.enqueue(async () => { const before = this.cloneState(); @@ -533,6 +548,13 @@ export class SessionArtifactStore { if (!existing) { return { v: 1, sessionId: this.sessionId, changes: [] }; } + const denied = this.denyCrossClientMutation( + 'unpin', + artifactId, + existing, + options, + ); + if (denied) return denied; const targetRetention = options.retention ?? 'restorable'; if ( targetRetention === 'ephemeral' && @@ -941,6 +963,8 @@ export class SessionArtifactStore { if (change.reason === 'explicit') { this.tombstonedIds.add(change.artifactId); this.stickyEphemeralIds.delete(change.artifactId); + } else if (change.reason === 'eviction') { + this.stickyEphemeralIds.delete(change.artifactId); } else if (change.reason === 'unpin_to_ephemeral') { this.stickyEphemeralIds.add(change.artifactId); } @@ -962,6 +986,25 @@ export class SessionArtifactStore { return result; } + private denyCrossClientMutation( + action: 'remove' | 'pin' | 'unpin', + artifactId: string, + existing: StoredArtifact, + options?: { clientId?: string }, + ): SessionArtifactMutationResult | undefined { + if ( + existing.source !== 'client' || + existing.clientId === undefined || + existing.clientId === options?.clientId + ) { + return undefined; + } + writeStderrLine( + `[artifacts] session=${this.sessionId} action=${action}_denied artifactId=${artifactId} owner=${existing.clientId} requester=${options?.clientId ?? ''}`, + ); + return { v: 1, sessionId: this.sessionId, changes: [] }; + } + private async normalizeInput( input: SessionArtifactInput, receivedSeq: number, @@ -1742,6 +1785,15 @@ function isDurablePersistenceChange(change: SessionArtifactChange): boolean { return change.artifact.retention !== 'ephemeral'; } +function hasPinOptions(options: SessionArtifactPinOptions): boolean { + return ( + options.retention !== undefined || + options.contentRef !== undefined || + Object.hasOwn(options, 'expiresAt') || + options.clientRetained !== undefined + ); +} + function cloneStoredArtifact(artifact: StoredArtifact): StoredArtifact { return { ...artifact, diff --git a/packages/cli/src/serve/acp-http/dispatch.ts b/packages/cli/src/serve/acp-http/dispatch.ts index cfe4ce369a1..51f7e795e15 100644 --- a/packages/cli/src/serve/acp-http/dispatch.ts +++ b/packages/cli/src/serve/acp-http/dispatch.ts @@ -2496,7 +2496,10 @@ export class AcpDispatcher { case `${QWEN_METHOD_NS}session/artifacts/fsck`: { const sessionId = String(params['sessionId'] ?? ''); if (!this.requireOwned(conn, sessionId, id)) return; - const result = await this.bridge.fsckSessionArtifacts(sessionId); + const result = await this.bridge.fsckSessionArtifacts( + sessionId, + this.sessionCtx(conn, sessionId, loopback), + ); this.replyConn(conn, id, result as unknown); return; } diff --git a/packages/cli/src/serve/acp-http/transport.test.ts b/packages/cli/src/serve/acp-http/transport.test.ts index d7678440d3b..73ca1ba4a14 100644 --- a/packages/cli/src/serve/acp-http/transport.test.ts +++ b/packages/cli/src/serve/acp-http/transport.test.ts @@ -410,6 +410,9 @@ class FakeBridge { } | undefined; lastFsckSessionId: string | undefined; + lastFsckSessionContext: + | Parameters[1] + | undefined; lastGcSessionId: string | undefined; async getSessionArtifacts(sessionId: string) { this.lastArtifactListSessionId = sessionId; @@ -468,8 +471,12 @@ class FakeBridge { changes: [{ action: 'updated' as const, artifactId }], }; } - async fsckSessionArtifacts(sessionId: string) { + async fsckSessionArtifacts( + sessionId: string, + context?: Parameters[1], + ) { this.lastFsckSessionId = sessionId; + this.lastFsckSessionContext = context; return { checked: 0, missing: [] as string[], @@ -6219,8 +6226,9 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { }); it('_qwen/session/artifacts/fsck returns integrity status', async () => { - bridge.fsckSessionArtifacts = async (sessionId) => { + bridge.fsckSessionArtifacts = async (sessionId, context) => { bridge.lastFsckSessionId = sessionId; + bridge.lastFsckSessionContext = context; return { checked: 1, missing: ['missing'], hashMismatches: [] }; }; const connId = await initialize(); @@ -6244,6 +6252,10 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { result: { checked: 1, missing: ['missing'], hashMismatches: [] }, }); expect(bridge.lastFsckSessionId).toBe('sess-1'); + expect(bridge.lastFsckSessionContext).toEqual({ + clientId: 'client-1', + fromLoopback: true, + }); }); it('_qwen/session/artifacts/gc returns cleanup result', async () => { diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index 00b8b015a09..2d3118550ae 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -822,8 +822,17 @@ export function registerSessionRoutes( app.get('/session/:id/artifacts/fsck', async (req, res) => { const sessionId = requireSessionId(req, res); if (sessionId === null) return; + const clientId = parseClientIdHeader(req, res); + if (clientId === null) return; try { - res.status(200).json(await bridge.fsckSessionArtifacts(sessionId)); + res + .status(200) + .json( + await bridge.fsckSessionArtifacts( + sessionId, + clientId !== undefined ? { clientId } : undefined, + ), + ); } catch (err) { sendBridgeError(res, err, { route: 'GET /session/:id/artifacts/fsck', diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index c8e072fc5ca..3367bcafd72 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -663,7 +663,10 @@ interface FakeBridge extends AcpSessionBridge { context?: BridgeClientRequestContext; options?: Parameters[3]; }>; - fsckSessionArtifactsCalls: string[]; + fsckSessionArtifactsCalls: Array<{ + sessionId: string; + context?: BridgeClientRequestContext; + }>; gcSessionArtifactsCalls: string[]; workspaceMcpCalls: number; workspaceMcpToolsCalls: string[]; @@ -815,7 +818,7 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { []; const pinSessionArtifactCalls: FakeBridge['pinSessionArtifactCalls'] = []; const unpinSessionArtifactCalls: FakeBridge['unpinSessionArtifactCalls'] = []; - const fsckSessionArtifactsCalls: string[] = []; + const fsckSessionArtifactsCalls: FakeBridge['fsckSessionArtifactsCalls'] = []; const gcSessionArtifactsCalls: string[] = []; let workspaceMcpCalls = 0; const workspaceMcpToolsCalls: string[] = []; @@ -1533,9 +1536,12 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { }); return unpinSessionArtifactImpl(sessionId, artifactId, context, options); }, - async fsckSessionArtifacts(sessionId) { - fsckSessionArtifactsCalls.push(sessionId); - return fsckSessionArtifactsImpl(sessionId); + async fsckSessionArtifacts(sessionId, context) { + fsckSessionArtifactsCalls.push({ + sessionId, + ...(context ? { context } : {}), + }); + return fsckSessionArtifactsImpl(sessionId, context); }, async gcSessionArtifacts(sessionId) { gcSessionArtifactsCalls.push(sessionId); @@ -7544,7 +7550,9 @@ describe('createServeApp', () => { const app = createServeApp(tokenOpts, undefined, { bridge }); const res = await auth( - request(app).get('/session/session-A/artifacts/fsck'), + request(app) + .get('/session/session-A/artifacts/fsck') + .set('X-Qwen-Client-Id', 'client-1'), ); expect(res.status).toBe(200); @@ -7553,7 +7561,9 @@ describe('createServeApp', () => { missing: ['missing-content'], hashMismatches: ['bad-hash'], }); - expect(bridge.fsckSessionArtifactsCalls).toEqual(['session-A']); + expect(bridge.fsckSessionArtifactsCalls).toEqual([ + { sessionId: 'session-A', context: { clientId: 'client-1' } }, + ]); }); it('POST /session/:id/artifacts/gc returns content cleanup result', async () => { From 50f25d78bc6bcbb3f5c0a05ef2197e7c10b76840 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Sun, 5 Jul 2026 09:18:46 +0800 Subject: [PATCH 22/61] fix(daemon): address artifact persistence review follow-ups --- packages/acp-bridge/src/bridge.test.ts | 40 +++++++++ packages/acp-bridge/src/bridge.ts | 74 ++++++++------- .../src/sessionArtifactContentStore.ts | 8 +- .../acp-bridge/src/sessionArtifacts.test.ts | 89 ++++++++++++++++++- packages/acp-bridge/src/sessionArtifacts.ts | 10 ++- packages/cli/src/serve/routes/session.ts | 2 +- packages/cli/src/serve/server.test.ts | 21 +++++ 7 files changed, 203 insertions(+), 41 deletions(-) diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index 2cb40735e7e..018d8fcb0a1 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -1676,6 +1676,46 @@ describe('createAcpSessionBridge', () => { await bridge.shutdown(); }); + it('clears live artifacts when rewind returns no artifact snapshot', async () => { + const bridge = makeBridge({ + channelFactory: async () => + makeChannel({ + extMethodImpl: (method) => { + expect(method).toBe('qwen/control/session/rewind'); + return { + targetTurnIndex: 0, + filesChanged: [], + filesFailed: [], + }; + }, + }).channel, + }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const created = await bridge.addSessionArtifact( + session.sessionId, + { + title: 'Later artifact', + url: 'https://example.com/later', + }, + { clientId: session.clientId }, + ); + expect(created.changes).toHaveLength(1); + + await expect( + bridge.rewindSession( + session.sessionId, + { promptId: 'prompt-1' }, + { clientId: session.clientId }, + ), + ).resolves.toMatchObject({ targetTurnIndex: 0 }); + + await expect( + bridge.getSessionArtifacts(session.sessionId), + ).resolves.toMatchObject({ artifacts: [] }); + + await bridge.shutdown(); + }); + it('loads history replay from response metadata when requested', async () => { const handles: ChannelHandle[] = []; const factory: ChannelFactory = async () => { diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index d3f7bdfdce5..5b86763ba77 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -6113,42 +6113,48 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { const artifactSnapshot = restoredArtifactSnapshotFromState( response as BridgeSessionState, ); - if (artifactSnapshot) { - const beforeArtifacts = (await entry.artifacts.list()).artifacts; - const artifactRestoreWarnings = await entry.artifacts.restore( - artifactSnapshot, - { - verifyContentRef: (artifact) => - artifact.contentRef - ? artifactContentStore.verifyContentRef( - entry.sessionId, - artifact.id, - artifact.contentRef, - ) - : Promise.resolve(undefined), - }, - ); - for (const warning of artifactRestoreWarnings) { - writeStderrLine( - `[artifacts] session=${entry.sessionId} action=rewind_restore_warning warning=${JSON.stringify( - warning, - )}`, - ); - } - const afterArtifacts = (await entry.artifacts.list()).artifacts; - publishArtifactChanges( - entry, - artifactReseedChanges(beforeArtifacts, afterArtifacts), - originatorClientId, + const beforeArtifacts = (await entry.artifacts.list()).artifacts; + const artifactRestoreWarnings = await entry.artifacts.restore( + artifactSnapshot ?? { + v: SESSION_ARTIFACT_PERSISTENCE_VERSION, + sessionId: entry.sessionId, + sequence: 0, + artifacts: [], + tombstonedIds: [], + stickyEphemeralIds: [], + warnings: [], + }, + { + verifyContentRef: (artifact) => + artifact.contentRef + ? artifactContentStore.verifyContentRef( + entry.sessionId, + artifact.id, + artifact.contentRef, + ) + : Promise.resolve(undefined), + }, + ); + for (const warning of artifactRestoreWarnings) { + writeStderrLine( + `[artifacts] session=${entry.sessionId} action=rewind_restore_warning warning=${JSON.stringify( + warning, + )}`, ); - await gcArtifactContent(entry).catch((error) => { - writeStderrLine( - `qwen serve: session artifact GC failed during rewind for ${JSON.stringify( - sessionId, - )}: ${error instanceof Error ? error.message : String(error)}`, - ); - }); } + const afterArtifacts = (await entry.artifacts.list()).artifacts; + publishArtifactChanges( + entry, + artifactReseedChanges(beforeArtifacts, afterArtifacts), + originatorClientId, + ); + await gcArtifactContent(entry).catch((error) => { + writeStderrLine( + `qwen serve: session artifact GC failed during rewind for ${JSON.stringify( + sessionId, + )}: ${error instanceof Error ? error.message : String(error)}`, + ); + }); try { entry.events.publish({ diff --git a/packages/acp-bridge/src/sessionArtifactContentStore.ts b/packages/acp-bridge/src/sessionArtifactContentStore.ts index 382cdeccd63..1bee2077efc 100644 --- a/packages/acp-bridge/src/sessionArtifactContentStore.ts +++ b/packages/acp-bridge/src/sessionArtifactContentStore.ts @@ -12,6 +12,7 @@ import os from 'node:os'; import path from 'node:path'; import type { SessionArtifactContentRef } from '@qwen-code/qwen-code-core'; import type { SessionArtifactPersistenceWarning } from '@qwen-code/qwen-code-core'; +import { writeStderrLine } from './internal/stderrLine.js'; import type { DaemonSessionArtifact } from './sessionArtifacts.js'; import { SessionArtifactValidationError } from './sessionArtifacts.js'; @@ -266,7 +267,12 @@ export class SessionArtifactContentStore { let manifest: ContentManifest; try { manifest = await readManifest(path.join(fullPath, 'manifest.json')); - } catch { + } catch (error) { + writeStderrLine( + `[artifacts] action=content_gc_manifest_read_failed contentId=${entry} reason=${JSON.stringify( + error instanceof Error ? error.message : String(error), + )}`, + ); retained.push(entry); continue; } diff --git a/packages/acp-bridge/src/sessionArtifacts.test.ts b/packages/acp-bridge/src/sessionArtifacts.test.ts index 5ebf1ba4153..209f095da61 100644 --- a/packages/acp-bridge/src/sessionArtifacts.test.ts +++ b/packages/acp-bridge/src/sessionArtifacts.test.ts @@ -102,9 +102,13 @@ describe('SessionArtifactStore', () => { try { await expect( store.remove(artifactId, { clientId: 'client-b' }), - ).resolves.toMatchObject({ changes: [] }); + ).resolves.toMatchObject({ + changes: [], + warnings: [`artifact ${artifactId} is owned by a different client`], + }); await expect(store.remove(artifactId)).resolves.toMatchObject({ changes: [], + warnings: [`artifact ${artifactId} is owned by a different client`], }); const logged = stderr.mock.calls.map((call) => String(call[0])).join(''); expect(logged).toContain('remove_denied'); @@ -157,7 +161,10 @@ describe('SessionArtifactStore', () => { try { await expect( store.pin(artifactId, { clientId: 'client-b', contentRef }), - ).resolves.toMatchObject({ changes: [] }); + ).resolves.toMatchObject({ + changes: [], + warnings: [`artifact ${artifactId} is owned by a different client`], + }); await expect( store.pin(artifactId, { clientId: 'client-a', contentRef }), ).resolves.toMatchObject({ @@ -165,7 +172,10 @@ describe('SessionArtifactStore', () => { }); await expect( store.unpin(artifactId, { clientId: 'client-b' }), - ).resolves.toMatchObject({ changes: [] }); + ).resolves.toMatchObject({ + changes: [], + warnings: [`artifact ${artifactId} is owned by a different client`], + }); const logged = stderr.mock.calls.map((call) => String(call[0])).join(''); expect(logged).toContain('pin_denied'); @@ -2402,6 +2412,54 @@ describe('SessionArtifactStore', () => { expect(events[50]).toMatchObject({ sequence: 52 }); }); + it('does not retry snapshots on every event after a snapshot write failure', async () => { + let snapshotAttempts = 0; + const store = new SessionArtifactStore({ + sessionId: 's11-snapshot-failure', + workspaceCwd: workspace, + persistence: { + recordEvent: async () => {}, + recordSnapshot: async () => { + snapshotAttempts++; + throw new Error('disk full'); + }, + }, + }); + const stderr = vi + .spyOn(process.stderr, 'write') + .mockReturnValue(true as never); + + try { + for (let index = 0; index < 50; index++) { + await store.upsertMany( + [ + { + title: `Durable ${index}`, + url: `https://example.com/snapshot-failure-${index}`, + }, + ], + { strict: true }, + ); + } + expect(snapshotAttempts).toBe(1); + + await store.upsertMany( + [ + { + title: 'Durable after failure', + url: 'https://example.com/snapshot-after-failure', + }, + ], + { strict: true }, + ); + expect(snapshotAttempts).toBe(1); + const logged = stderr.mock.calls.map((call) => String(call[0])).join(''); + expect(logged).toContain('snapshot_failed'); + } finally { + stderr.mockRestore(); + } + }); + it('compacts explicit tombstones out of periodic snapshots', async () => { const snapshots: SessionArtifactSnapshotRecordPayload[] = []; const store = new SessionArtifactStore({ @@ -3128,6 +3186,31 @@ describe('SessionArtifactContentStore', () => { await expect(fs.readdir(tmpDir)).resolves.toEqual([]); }); + it('logs and retains content when gc cannot read its manifest', async () => { + const contentStore = new SessionArtifactContentStore(contentRoot); + const contentId = fakeContentId('bad-manifest'); + const contentDir = path.join(contentRoot, contentId); + await fs.mkdir(contentDir, { recursive: true }); + await fs.writeFile(path.join(contentDir, 'manifest.json'), '{bad json'); + const stderr = vi + .spyOn(process.stderr, 'write') + .mockReturnValue(true as never); + + try { + await expect( + contentStore.gc('content-session', new Set()), + ).resolves.toEqual({ + removed: [], + retained: [contentId], + }); + const logged = stderr.mock.calls.map((call) => String(call[0])).join(''); + expect(logged).toContain('content_gc_manifest_read_failed'); + expect(logged).toContain(contentId); + } finally { + stderr.mockRestore(); + } + }); + it('retains leased content during gc until the pin flow releases it', async () => { const contentStore = new SessionArtifactContentStore(contentRoot); const ref = (await contentStore.pinWorkspaceFile( diff --git a/packages/acp-bridge/src/sessionArtifacts.ts b/packages/acp-bridge/src/sessionArtifacts.ts index 63206cecbb0..03148ae4e9b 100644 --- a/packages/acp-bridge/src/sessionArtifacts.ts +++ b/packages/acp-bridge/src/sessionArtifacts.ts @@ -917,7 +917,6 @@ export class SessionArtifactStore { }; try { await this.persistence.recordSnapshot(payload); - this.durableEventsSinceSnapshot = 0; this.tombstonedIds.clear(); } catch (error) { writeStderrLine( @@ -925,6 +924,8 @@ export class SessionArtifactStore { error instanceof Error ? error.message : String(error), )}`, ); + } finally { + this.durableEventsSinceSnapshot = 0; } } @@ -1002,7 +1003,12 @@ export class SessionArtifactStore { writeStderrLine( `[artifacts] session=${this.sessionId} action=${action}_denied artifactId=${artifactId} owner=${existing.clientId} requester=${options?.clientId ?? ''}`, ); - return { v: 1, sessionId: this.sessionId, changes: [] }; + return { + v: 1, + sessionId: this.sessionId, + changes: [], + warnings: [`artifact ${artifactId} is owned by a different client`], + }; } private async normalizeInput( diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index 2d3118550ae..7e9d792f6e7 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -156,7 +156,7 @@ function parseArtifactRemoveRequest( 'deleteContent', ); } - return deleteContent ? { deleteContent } : {}; + return { deleteContent }; } function parseArtifactUnpinRequest(req: Request): SessionArtifactUnpinRequest { diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 3367bcafd72..8748b92e75e 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -7415,6 +7415,27 @@ describe('createServeApp', () => { ]); }); + it('DELETE /session/:id/artifacts/:artifactId forwards explicit content retention option', async () => { + const bridge = fakeBridge(); + const app = createServeApp(tokenOpts, undefined, { bridge }); + + const res = await auth( + request(app) + .delete('/session/session-A/artifacts/artifact-1') + .send({ deleteContent: false }), + ).set('X-Qwen-Client-Id', 'client-1'); + + expect(res.status).toBe(200); + expect(bridge.removeSessionArtifactCalls).toEqual([ + { + sessionId: 'session-A', + artifactId: 'artifact-1', + context: { clientId: 'client-1' }, + options: { deleteContent: false }, + }, + ]); + }); + it('POST /session/:id/artifacts/:artifactId/pin forwards mutation auth context', async () => { const bridge = fakeBridge(); const app = createServeApp(tokenOpts, undefined, { bridge }); From 073daae066793f89884bf20349827fe0689b48d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Sun, 5 Jul 2026 09:32:06 +0800 Subject: [PATCH 23/61] fix(daemon): close artifact review edge cases --- .../acp-bridge/src/sessionArtifacts.test.ts | 137 ++++++++++++++++++ packages/acp-bridge/src/sessionArtifacts.ts | 9 +- 2 files changed, 143 insertions(+), 3 deletions(-) diff --git a/packages/acp-bridge/src/sessionArtifacts.test.ts b/packages/acp-bridge/src/sessionArtifacts.test.ts index 209f095da61..4662ae4e8dd 100644 --- a/packages/acp-bridge/src/sessionArtifacts.test.ts +++ b/packages/acp-bridge/src/sessionArtifacts.test.ts @@ -309,6 +309,7 @@ describe('SessionArtifactStore', () => { expect(unpinned.changes[0]?.artifact).toMatchObject({ retention: 'restorable', persistenceWarning: 'metadata_only_restore', + clientRetained: false, }); expect(unpinned.changes[0]?.artifact).not.toHaveProperty('contentRef'); expect(events.map((event) => event.sequence)).toEqual([1, 2, 3]); @@ -580,6 +581,46 @@ describe('SessionArtifactStore', () => { expect(events).toHaveLength(3); }); + it('clears stale pin expiration when repinning without a ttl', async () => { + const store = new SessionArtifactStore({ + sessionId: 's1-pin-clear-expiration', + workspaceCwd: workspace, + persistence: { + recordEvent: async () => {}, + recordSnapshot: async () => {}, + }, + }); + const created = await store.upsertMany( + [{ title: 'Report', url: 'https://example.com/report' }], + { strict: true }, + ); + const artifactId = created.changes[0]!.artifactId; + const contentRef = { + kind: 'managed_copy' as const, + contentId: `${'a'.repeat(64)}-${'b'.repeat(16)}`, + sha256: 'a'.repeat(64), + sizeBytes: 12, + createdAt: '2026-07-04T00:00:00.000Z', + }; + await store.pin(artifactId, { + contentRef, + expiresAt: '2026-08-01T00:00:00.000Z', + }); + + const repinned = await store.pin(artifactId, { + contentRef: { + ...contentRef, + contentId: `${'c'.repeat(64)}-${'d'.repeat(16)}`, + sha256: 'c'.repeat(64), + }, + }); + + expect(repinned.changes[0]?.artifact).toMatchObject({ + retention: 'pinned', + }); + expect(repinned.changes[0]?.artifact).not.toHaveProperty('expiresAt'); + }); + it('rolls back pin mutations when persistence fails', async () => { let fail = false; const store = new SessionArtifactStore({ @@ -620,6 +661,43 @@ describe('SessionArtifactStore', () => { }); }); + it('rolls back received sequence when strict upsert persistence fails', async () => { + let fail = false; + const store = new SessionArtifactStore({ + sessionId: 's1-upsert-sequence-rollback', + workspaceCwd: workspace, + persistence: { + recordEvent: async () => { + if (fail) throw new Error('persist failed'); + }, + recordSnapshot: async () => {}, + }, + }); + const sequenceState = store as unknown as { receivedSeq: number }; + + await store.upsertMany( + [{ title: 'First', url: 'https://example.com/first' }], + { strict: true }, + ); + expect(sequenceState.receivedSeq).toBe(1); + + fail = true; + await expect( + store.upsertMany( + [{ title: 'Second', url: 'https://example.com/second' }], + { strict: true }, + ), + ).rejects.toThrow('persist failed'); + expect(sequenceState.receivedSeq).toBe(1); + + fail = false; + await store.upsertMany( + [{ title: 'Third', url: 'https://example.com/third' }], + { strict: true }, + ); + expect(sequenceState.receivedSeq).toBe(2); + }); + it('serializes concurrent store operations', async () => { const store = new SessionArtifactStore({ sessionId: 's1-queue', @@ -2913,6 +2991,65 @@ describe('SessionArtifactStore', () => { 'contentRef', ); }); + + it('downgrades restored pinned records to restorable even when content is valid', async () => { + const events: SessionArtifactEventRecordPayload[] = []; + const source = new SessionArtifactStore({ + sessionId: 's11-restore-valid-pinned', + workspaceCwd: workspace, + persistence: { + recordEvent: async (payload) => { + events.push(payload); + }, + recordSnapshot: async () => {}, + }, + }); + const created = await source.upsertMany( + [{ title: 'Pinned', url: 'https://example.com/pinned' }], + { strict: true }, + ); + const artifactId = created.changes[0]!.artifactId; + await source.pin(artifactId, { + contentRef: { + kind: 'managed_copy', + contentId: `${'e'.repeat(64)}-${'f'.repeat(16)}`, + sha256: 'e'.repeat(64), + sizeBytes: 12, + createdAt: '2026-07-04T00:00:00.000Z', + }, + }); + const persisted = events[1]!.changes[0]!.artifact!; + const restored = new SessionArtifactStore({ + sessionId: 's11-restore-valid-pinned', + workspaceCwd: workspace, + }); + + await expect( + restored.restore( + { + v: 2, + sessionId: 's11-restore-valid-pinned', + sequence: 2, + artifacts: [persisted], + tombstonedIds: [], + stickyEphemeralIds: [], + warnings: [], + }, + { verifyContentRef: async () => undefined }, + ), + ).resolves.toEqual([]); + + await expect(restored.list()).resolves.toMatchObject({ + artifacts: [ + expect.objectContaining({ + id: artifactId, + retention: 'restorable', + restoreState: 'restored', + contentRef: persisted.contentRef, + }), + ], + }); + }); }); describe('SessionArtifactContentStore', () => { diff --git a/packages/acp-bridge/src/sessionArtifacts.ts b/packages/acp-bridge/src/sessionArtifacts.ts index 03148ae4e9b..9da5e226144 100644 --- a/packages/acp-bridge/src/sessionArtifacts.ts +++ b/packages/acp-bridge/src/sessionArtifacts.ts @@ -505,8 +505,6 @@ export class SessionArtifactStore { } else { delete updated.expiresAt; } - } else if (existing.expiresAt) { - updated.expiresAt = existing.expiresAt; } else { delete updated.expiresAt; } @@ -574,6 +572,7 @@ export class SessionArtifactStore { targetRetention === 'ephemeral' ? 'sticky_override_active' : 'metadata_only_restore', + clientRetained: false, updatedAt: new Date().toISOString(), }; delete updated.contentRef; @@ -721,7 +720,7 @@ export class SessionArtifactStore { } let contentRef = artifact.contentRef; let expiresAt = artifact.expiresAt; - let retention = artifact.retention; + let retention = normalized.retention; let status = normalized.status; let persistenceWarning: | SessionArtifactPersistenceWarning @@ -776,6 +775,7 @@ export class SessionArtifactStore { private cloneState(): { artifacts: Map; + receivedSeq: number; insertSeq: number; persistenceSeq: number; durableEventsSinceSnapshot: number; @@ -789,6 +789,7 @@ export class SessionArtifactStore { cloneStoredArtifact(artifact), ]), ), + receivedSeq: this.receivedSeq, insertSeq: this.insertSeq, persistenceSeq: this.persistenceSeq, durableEventsSinceSnapshot: this.durableEventsSinceSnapshot, @@ -799,6 +800,7 @@ export class SessionArtifactStore { private restoreState(state: { artifacts: Map; + receivedSeq: number; insertSeq: number; persistenceSeq: number; durableEventsSinceSnapshot: number; @@ -809,6 +811,7 @@ export class SessionArtifactStore { for (const [id, artifact] of state.artifacts) { this.artifacts.set(id, cloneStoredArtifact(artifact)); } + this.receivedSeq = state.receivedSeq; this.insertSeq = state.insertSeq; this.persistenceSeq = state.persistenceSeq; this.durableEventsSinceSnapshot = state.durableEventsSinceSnapshot; From 242163463a61a1c691ef81c0e880ebf075e8afbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Sun, 5 Jul 2026 10:01:52 +0800 Subject: [PATCH 24/61] fix(daemon): harden artifact restore consistency --- packages/acp-bridge/src/bridge.test.ts | 10 ++- packages/acp-bridge/src/bridge.ts | 10 +-- .../acp-bridge/src/sessionArtifacts.test.ts | 65 +++++++++++++++---- packages/acp-bridge/src/sessionArtifacts.ts | 50 +++++++++++--- 4 files changed, 103 insertions(+), 32 deletions(-) diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index 018d8fcb0a1..d151e75c98a 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -1676,7 +1676,7 @@ describe('createAcpSessionBridge', () => { await bridge.shutdown(); }); - it('clears live artifacts when rewind returns no artifact snapshot', async () => { + it('keeps live artifacts when rewind returns no artifact snapshot', async () => { const bridge = makeBridge({ channelFactory: async () => makeChannel({ @@ -1711,7 +1711,13 @@ describe('createAcpSessionBridge', () => { await expect( bridge.getSessionArtifacts(session.sessionId), - ).resolves.toMatchObject({ artifacts: [] }); + ).resolves.toMatchObject({ + artifacts: [ + expect.objectContaining({ + title: 'Later artifact', + }), + ], + }); await bridge.shutdown(); }); diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index 5b86763ba77..9197bf537cd 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -6115,15 +6115,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { ); const beforeArtifacts = (await entry.artifacts.list()).artifacts; const artifactRestoreWarnings = await entry.artifacts.restore( - artifactSnapshot ?? { - v: SESSION_ARTIFACT_PERSISTENCE_VERSION, - sessionId: entry.sessionId, - sequence: 0, - artifacts: [], - tombstonedIds: [], - stickyEphemeralIds: [], - warnings: [], - }, + artifactSnapshot, { verifyContentRef: (artifact) => artifact.contentRef diff --git a/packages/acp-bridge/src/sessionArtifacts.test.ts b/packages/acp-bridge/src/sessionArtifacts.test.ts index 4662ae4e8dd..8a8a0c12de7 100644 --- a/packages/acp-bridge/src/sessionArtifacts.test.ts +++ b/packages/acp-bridge/src/sessionArtifacts.test.ts @@ -2680,6 +2680,51 @@ describe('SessionArtifactStore', () => { expect(snapshots[0]?.stickyEphemeralIds).not.toContain(evictedArtifact.id); }); + it('applies sticky ephemeral markers while restoring durable artifacts', async () => { + const sourceEvents: SessionArtifactEventRecordPayload[] = []; + const source = new SessionArtifactStore({ + sessionId: 's11-restore-sticky', + workspaceCwd: workspace, + persistence: { + recordEvent: async (payload) => { + sourceEvents.push(payload); + }, + recordSnapshot: async () => {}, + }, + }); + await source.upsertMany( + [{ title: 'Sticky', url: 'https://example.com/sticky-restore' }], + { strict: true }, + ); + const artifact = sourceEvents[0]!.changes[0]!.artifact!; + const restored = new SessionArtifactStore({ + sessionId: 's11-restore-sticky', + workspaceCwd: workspace, + }); + + await expect( + restored.restore({ + v: 2, + sessionId: 's11-restore-sticky', + sequence: 1, + artifacts: [artifact], + tombstonedIds: [], + stickyEphemeralIds: [artifact.id], + warnings: [], + }), + ).resolves.toEqual([]); + + await expect(restored.list()).resolves.toMatchObject({ + artifacts: [ + { + id: artifact.id, + retention: 'ephemeral', + persistenceWarning: 'sticky_override_active', + }, + ], + }); + }); + it('downgrades non-strict durable artifacts when persistence is unavailable', async () => { const store = new SessionArtifactStore({ sessionId: 's11-unavailable', @@ -2785,10 +2830,10 @@ describe('SessionArtifactStore', () => { }); }); - it('removes live artifacts even when explicit tombstone persistence fails', async () => { + it('rolls back live removal when explicit tombstone persistence fails', async () => { let calls = 0; const store = new SessionArtifactStore({ - sessionId: 's11-remove-best-effort', + sessionId: 's11-remove-rollback', workspaceCwd: workspace, persistence: { recordEvent: async () => { @@ -2805,19 +2850,17 @@ describe('SessionArtifactStore', () => { { strict: true }, ); - const removed = await store.remove(created.changes[0]!.artifactId); - - expect(removed).toMatchObject({ - changes: [ + await expect(store.remove(created.changes[0]!.artifactId)).rejects.toThrow( + 'disk full', + ); + await expect(store.list()).resolves.toMatchObject({ + artifacts: [ { - action: 'removed', - artifactId: created.changes[0]?.artifactId, - reason: 'explicit', + id: created.changes[0]?.artifactId, + title: 'Sensitive', }, ], - warnings: ['artifact removal not persisted; live removal kept'], }); - await expect(store.list()).resolves.toMatchObject({ artifacts: [] }); }); it('restores rebuilt durable artifacts as metadata-only restored entries', async () => { diff --git a/packages/acp-bridge/src/sessionArtifacts.ts b/packages/acp-bridge/src/sessionArtifacts.ts index 9da5e226144..92eb969521c 100644 --- a/packages/acp-bridge/src/sessionArtifacts.ts +++ b/packages/acp-bridge/src/sessionArtifacts.ts @@ -419,6 +419,7 @@ export class SessionArtifactStore { options?: { clientId?: string }, ): Promise { return this.enqueue(async () => { + const before = this.cloneState(); const existing = this.artifacts.get(artifactId); if (!existing) { return { v: 1, sessionId: this.sessionId, changes: [] }; @@ -442,13 +443,21 @@ export class SessionArtifactStore { reason: 'explicit', }, ]; - const warnings = await this.persistChanges(changes, false); - return { - v: 1, - sessionId: this.sessionId, - changes, - ...(warnings.length > 0 ? { warnings } : {}), - }; + try { + const warnings = await this.persistChanges( + changes, + this.persistence !== undefined, + ); + return { + v: 1, + sessionId: this.sessionId, + changes, + ...(warnings.length > 0 ? { warnings } : {}), + }; + } catch (error) { + this.restoreState(before); + throw error; + } }); } @@ -709,11 +718,21 @@ export class SessionArtifactStore { if (input.retention === 'pinned') { input.retention = 'restorable'; } - const normalized = await this.normalizeInput( + let normalized = await this.normalizeInput( input, ++this.receivedSeq, artifact.storage === 'published', ); + if ( + this.stickyEphemeralIds.has(normalized.id) && + normalized.retention !== 'ephemeral' + ) { + normalized = { + ...normalized, + retention: 'ephemeral', + persistenceWarning: 'sticky_override_active', + }; + } if (normalized.id !== artifact.id) { warnings.push(`skipped artifact with mismatched id ${artifact.id}`); continue; @@ -725,7 +744,11 @@ export class SessionArtifactStore { let persistenceWarning: | SessionArtifactPersistenceWarning | undefined = contentRef ? undefined : 'metadata_only_restore'; - if (expiresAt && Date.parse(expiresAt) <= Date.now()) { + if (retention === 'ephemeral') { + contentRef = undefined; + expiresAt = undefined; + persistenceWarning = 'sticky_override_active'; + } else if (expiresAt && Date.parse(expiresAt) <= Date.now()) { contentRef = undefined; expiresAt = undefined; retention = 'restorable'; @@ -909,6 +932,9 @@ export class SessionArtifactStore { .map((artifact) => toPersistedArtifact(toPublicArtifact(artifact), recordedAt), ); + const stickyEphemeralIds = Array.from(this.stickyEphemeralIds).filter( + (id) => this.artifacts.has(id), + ); const payload: SessionArtifactSnapshotRecordPayload = { v: SESSION_ARTIFACT_PERSISTENCE_VERSION, sessionId: this.sessionId, @@ -916,11 +942,15 @@ export class SessionArtifactStore { recordedAt, artifacts, tombstonedIds: [], - stickyEphemeralIds: Array.from(this.stickyEphemeralIds), + stickyEphemeralIds, }; try { await this.persistence.recordSnapshot(payload); this.tombstonedIds.clear(); + this.stickyEphemeralIds.clear(); + for (const id of stickyEphemeralIds) { + this.stickyEphemeralIds.add(id); + } } catch (error) { writeStderrLine( `[artifacts] session=${this.sessionId} action=snapshot_failed reason=${JSON.stringify( From 7d60ad9e3694509fb5b06985bf8be2d52c8609cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Sun, 5 Jul 2026 12:33:14 +0800 Subject: [PATCH 25/61] fix(daemon): log artifact remove gc failures --- packages/acp-bridge/src/bridge.ts | 7 +++- .../session-artifact-persistence.test.ts | 40 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index b693a6fff0c..cff51f56e79 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -4731,10 +4731,15 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { warnings.push(...(await pruneExpiredArtifactPins(entry, clientId))); try { await gcArtifactContent(entry); - } catch { + } catch (error) { if (removeOptions.deleteContent === true || artifact.contentRef) { warnings.push('content_delete_preserved'); } + writeStderrLine( + `[artifacts] session=${entry.sessionId} action=remove_gc_failed reason=${JSON.stringify( + error instanceof Error ? error.message : String(error), + )}`, + ); } } publishArtifactChanges(entry, result.changes, clientId); diff --git a/packages/core/src/services/session-artifact-persistence.test.ts b/packages/core/src/services/session-artifact-persistence.test.ts index be12dcb0461..e852b82e2e9 100644 --- a/packages/core/src/services/session-artifact-persistence.test.ts +++ b/packages/core/src/services/session-artifact-persistence.test.ts @@ -97,6 +97,46 @@ describe('session artifact persistence records', () => { }); }); + it('rebuilds sticky ephemeral ids from unpin tombstones', () => { + const pinned = artifact('s1', 'https://example.com/sticky', { + retention: 'pinned', + }); + + const snapshot = rebuildSessionArtifactSnapshot([ + event({ + v: SESSION_ARTIFACT_PERSISTENCE_VERSION, + sessionId: 's1', + sequence: 1, + recordedAt: '2026-07-04T00:00:00.000Z', + changes: [ + { action: 'created', artifactId: pinned.id, artifact: pinned }, + ], + }), + event({ + v: SESSION_ARTIFACT_PERSISTENCE_VERSION, + sessionId: 's1', + sequence: 2, + recordedAt: '2026-07-04T00:00:01.000Z', + changes: [ + { + action: 'removed', + artifactId: pinned.id, + artifact: pinned, + reason: 'unpin_to_ephemeral', + }, + ], + }), + ]); + + expect(snapshot).toMatchObject({ + sequence: 2, + artifacts: [], + tombstonedIds: [], + stickyEphemeralIds: [pinned.id], + warnings: [], + }); + }); + it('lets snapshot records replace earlier event state', () => { const first = artifact('s1', 'https://example.com/first'); const second = artifact('s1', 'https://example.com/second'); From 7d58413204ab3bb571c99753ee4843d581df49ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Sun, 5 Jul 2026 16:26:16 +0800 Subject: [PATCH 26/61] fix(daemon): harden session artifact maintenance paths --- packages/acp-bridge/src/bridge.test.ts | 5 +- packages/acp-bridge/src/bridge.ts | 7 +- packages/acp-bridge/src/bridgeTypes.ts | 5 +- .../src/sessionArtifactContentStore.ts | 9 +- .../acp-bridge/src/sessionArtifacts.test.ts | 124 +++++++++++++++--- packages/acp-bridge/src/sessionArtifacts.ts | 70 ++++++---- .../cli/src/acp-integration/acpAgent.test.ts | 24 ++++ packages/cli/src/acp-integration/acpAgent.ts | 6 + packages/cli/src/serve/acp-http/dispatch.ts | 5 +- .../cli/src/serve/acp-http/transport.test.ts | 16 ++- packages/cli/src/serve/acp-session-bridge.ts | 5 +- packages/cli/src/serve/routes/session.ts | 14 +- packages/cli/src/serve/server.test.ts | 73 ++++++++++- .../cli/src/serve/server/error-response.ts | 10 ++ packages/sdk-typescript/src/daemon/index.ts | 16 +++ 15 files changed, 317 insertions(+), 72 deletions(-) diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index c19ea0f003d..b8244a22078 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -62,6 +62,7 @@ import { SESS_A, } from './internal/testUtils.js'; import { SessionArtifactContentStore } from './sessionArtifactContentStore.js'; +import { SessionArtifactAuthorizationError } from './sessionArtifacts.js'; function deferred(): { promise: Promise; @@ -168,10 +169,10 @@ describe('createAcpSessionBridge', () => { bridge.removeSessionArtifact(first.sessionId, artifactId, { clientId: second.clientId, }), - ).resolves.toMatchObject({ changes: [] }); + ).rejects.toBeInstanceOf(SessionArtifactAuthorizationError); await expect( bridge.removeSessionArtifact(first.sessionId, artifactId), - ).resolves.toMatchObject({ changes: [] }); + ).rejects.toBeInstanceOf(SessionArtifactAuthorizationError); await expect( bridge.getSessionArtifacts(first.sessionId), ).resolves.toMatchObject({ diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index cff51f56e79..43392a4693b 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -4757,10 +4757,6 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { const mode = artifactPinMode(requestOptions); const clientRetained = artifactClientRetained(requestOptions); const expiresAt = artifactExpiresAt(requestOptions, mode); - const artifact = await entry.artifacts.get(artifactId); - if (!artifact) { - return { v: 1, sessionId, changes: [] }; - } const pruneWarnings = await pruneExpiredArtifactPins(entry, clientId); const currentArtifact = await entry.artifacts.get(artifactId); if (!currentArtifact) { @@ -4867,9 +4863,10 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { return artifactContentStore.fsck(await entry.artifacts.contentRefs()); }, - async gcSessionArtifacts(sessionId) { + async gcSessionArtifacts(sessionId, context) { const entry = byId.get(sessionId); if (!entry) throw new SessionNotFoundError(sessionId); + resolveTrustedClientId(entry, context?.clientId); const refs = await entry.artifacts.contentRefs(); return artifactContentStore.gc( sessionId, diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts index 8a0f1889ce3..19873e28140 100644 --- a/packages/acp-bridge/src/bridgeTypes.ts +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -625,7 +625,10 @@ export interface AcpSessionBridge { context?: BridgeClientRequestContext, ): Promise; - gcSessionArtifacts(sessionId: string): Promise; + gcSessionArtifacts( + sessionId: string, + context?: BridgeClientRequestContext, + ): Promise; /** * Cast a vote on a pending `permission_request` (first-responder wins). diff --git a/packages/acp-bridge/src/sessionArtifactContentStore.ts b/packages/acp-bridge/src/sessionArtifactContentStore.ts index 1bee2077efc..f77edaf3f65 100644 --- a/packages/acp-bridge/src/sessionArtifactContentStore.ts +++ b/packages/acp-bridge/src/sessionArtifactContentStore.ts @@ -172,12 +172,9 @@ export class SessionArtifactContentStore { if (sha256 !== ref.sha256) { hashMismatches.push(ref.contentId); } - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - missing.push(ref.contentId); - continue; - } - throw error; + } catch { + missing.push(ref.contentId); + continue; } } return { checked: contentRefs.length, missing, hashMismatches }; diff --git a/packages/acp-bridge/src/sessionArtifacts.test.ts b/packages/acp-bridge/src/sessionArtifacts.test.ts index 8a8a0c12de7..a49f12109b8 100644 --- a/packages/acp-bridge/src/sessionArtifacts.test.ts +++ b/packages/acp-bridge/src/sessionArtifacts.test.ts @@ -11,6 +11,7 @@ import os from 'node:os'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; import { + SessionArtifactAuthorizationError, SessionArtifactStore, SessionArtifactValidationError, } from './sessionArtifacts.js'; @@ -102,14 +103,10 @@ describe('SessionArtifactStore', () => { try { await expect( store.remove(artifactId, { clientId: 'client-b' }), - ).resolves.toMatchObject({ - changes: [], - warnings: [`artifact ${artifactId} is owned by a different client`], - }); - await expect(store.remove(artifactId)).resolves.toMatchObject({ - changes: [], - warnings: [`artifact ${artifactId} is owned by a different client`], - }); + ).rejects.toBeInstanceOf(SessionArtifactAuthorizationError); + await expect(store.remove(artifactId)).rejects.toBeInstanceOf( + SessionArtifactAuthorizationError, + ); const logged = stderr.mock.calls.map((call) => String(call[0])).join(''); expect(logged).toContain('remove_denied'); expect(logged).toContain('client-a'); @@ -161,10 +158,7 @@ describe('SessionArtifactStore', () => { try { await expect( store.pin(artifactId, { clientId: 'client-b', contentRef }), - ).resolves.toMatchObject({ - changes: [], - warnings: [`artifact ${artifactId} is owned by a different client`], - }); + ).rejects.toBeInstanceOf(SessionArtifactAuthorizationError); await expect( store.pin(artifactId, { clientId: 'client-a', contentRef }), ).resolves.toMatchObject({ @@ -172,10 +166,7 @@ describe('SessionArtifactStore', () => { }); await expect( store.unpin(artifactId, { clientId: 'client-b' }), - ).resolves.toMatchObject({ - changes: [], - warnings: [`artifact ${artifactId} is owned by a different client`], - }); + ).rejects.toBeInstanceOf(SessionArtifactAuthorizationError); const logged = stderr.mock.calls.map((call) => String(call[0])).join(''); expect(logged).toContain('pin_denied'); @@ -2578,6 +2569,88 @@ describe('SessionArtifactStore', () => { ).toBe(false); }); + it('suppresses implicit upserts for restored tombstones', async () => { + const sessionId = 's11-restored-tombstone'; + const input = { + title: 'Old tool result', + url: 'https://example.com/tombstoned', + }; + const seed = new SessionArtifactStore({ + sessionId, + workspaceCwd: workspace, + }); + const artifactId = (await seed.upsertMany([input])).changes[0]!.artifactId; + const store = new SessionArtifactStore({ + sessionId, + workspaceCwd: workspace, + }); + + await store.restore({ + v: 2, + sessionId, + sequence: 1, + artifacts: [], + tombstonedIds: [artifactId], + stickyEphemeralIds: [], + warnings: [], + }); + const suppressed = await store.upsertMany([input]); + expect(suppressed.changes).toEqual([]); + + const explicitClient = await store.upsertMany([ + { ...input, source: 'client' }, + ]); + expect(explicitClient.changes).toMatchObject([{ action: 'created' }]); + }); + + it('resets durable event snapshot cadence after restore', async () => { + const snapshots: SessionArtifactSnapshotRecordPayload[] = []; + const store = new SessionArtifactStore({ + sessionId: 's11-restore-snapshot-cadence', + workspaceCwd: workspace, + persistence: { + recordEvent: async () => {}, + recordSnapshot: async (payload) => { + snapshots.push(payload); + }, + }, + }); + + for (let index = 0; index < 49; index++) { + await store.upsertMany( + [ + { + title: `Before restore ${index}`, + url: `https://example.com/before-restore-${index}`, + }, + ], + { strict: true }, + ); + } + expect(snapshots).toHaveLength(0); + + await store.restore({ + v: 2, + sessionId: 's11-restore-snapshot-cadence', + sequence: 100, + artifacts: [], + tombstonedIds: [], + stickyEphemeralIds: [], + warnings: [], + }); + await store.upsertMany( + [ + { + title: 'After restore', + url: 'https://example.com/after-restore', + }, + ], + { strict: true }, + ); + + expect(snapshots).toHaveLength(0); + }); + it('snapshots unpin-to-ephemeral state after applying the live update', async () => { const snapshots: SessionArtifactSnapshotRecordPayload[] = []; const store = new SessionArtifactStore({ @@ -3180,6 +3253,25 @@ describe('SessionArtifactContentStore', () => { }); }); + it('treats fsck read races as missing content', async () => { + const contentStore = new SessionArtifactContentStore(contentRoot); + const artifact = await workspaceArtifact('race.txt', 'race'); + const ref = (await contentStore.pinWorkspaceFile( + 'content-session', + artifact, + workspace, + ))!; + const contentPath = path.join(contentRoot, ref.contentId, 'content'); + await fs.rm(contentPath); + await fs.mkdir(contentPath); + + await expect(contentStore.fsck([ref])).resolves.toEqual({ + checked: 1, + missing: [ref.contentId], + hashMismatches: [], + }); + }); + it('reuses an existing retained content copy for repeated pins', async () => { const contentStore = new SessionArtifactContentStore(contentRoot); const artifact = await workspaceArtifact('repeat.txt', 'repeat'); diff --git a/packages/acp-bridge/src/sessionArtifacts.ts b/packages/acp-bridge/src/sessionArtifacts.ts index 92eb969521c..3d14c85096d 100644 --- a/packages/acp-bridge/src/sessionArtifacts.ts +++ b/packages/acp-bridge/src/sessionArtifacts.ts @@ -169,6 +169,20 @@ export class SessionArtifactValidationError extends Error { } } +export class SessionArtifactAuthorizationError extends Error { + readonly code = 'SESSION_ARTIFACT_FORBIDDEN'; + + constructor( + readonly sessionId: string, + readonly artifactId: string, + readonly ownerClientId: string, + readonly requesterClientId?: string, + ) { + super(`artifact ${artifactId} is owned by a different client`); + this.name = 'SessionArtifactAuthorizationError'; + } +} + interface SessionArtifactStoreOptions { sessionId: string; workspaceCwd: string; @@ -293,6 +307,12 @@ export class SessionArtifactStore { try { for (const normalized of coalesceByIdentity(normalizedResults)) { const artifact = this.applyStickyEphemeralOverride(normalized); + if (this.shouldSuppressTombstonedUpsert(artifact)) { + writeStderrLine( + `[artifacts] session=${this.sessionId} action=tombstone_replay_suppressed artifactId=${artifact.id}`, + ); + continue; + } const existing = this.artifacts.get(artifact.id) ?? this.findPublishedUpgradeTarget(artifact) ?? @@ -427,13 +447,7 @@ export class SessionArtifactStore { // Client-created artifacts with an owner require the same client id. // Tool/hook artifacts are session-scoped outputs and may be removed by // any caller that already passed session mutation auth. - const denied = this.denyCrossClientMutation( - 'remove', - artifactId, - existing, - options, - ); - if (denied) return denied; + this.denyCrossClientMutation('remove', artifactId, existing, options); this.artifacts.delete(artifactId); const changes: SessionArtifactChange[] = [ { @@ -471,13 +485,7 @@ export class SessionArtifactStore { if (!existing) { return { v: 1, sessionId: this.sessionId, changes: [] }; } - const denied = this.denyCrossClientMutation( - 'pin', - artifactId, - existing, - options, - ); - if (denied) return denied; + this.denyCrossClientMutation('pin', artifactId, existing, options); if (existing.retention === 'pinned' && !hasPinOptions(options)) { return { v: 1, @@ -555,13 +563,7 @@ export class SessionArtifactStore { if (!existing) { return { v: 1, sessionId: this.sessionId, changes: [] }; } - const denied = this.denyCrossClientMutation( - 'unpin', - artifactId, - existing, - options, - ); - if (denied) return denied; + this.denyCrossClientMutation('unpin', artifactId, existing, options); const targetRetention = options.retention ?? 'restorable'; if ( targetRetention === 'ephemeral' && @@ -706,6 +708,7 @@ export class SessionArtifactStore { this.stickyEphemeralIds.clear(); this.insertSeq = 0; this.persistenceSeq = snapshot.sequence; + this.durableEventsSinceSnapshot = 0; for (const id of snapshot.tombstonedIds) { this.tombstonedIds.add(id); } @@ -1025,23 +1028,23 @@ export class SessionArtifactStore { artifactId: string, existing: StoredArtifact, options?: { clientId?: string }, - ): SessionArtifactMutationResult | undefined { + ): void { if ( existing.source !== 'client' || existing.clientId === undefined || existing.clientId === options?.clientId ) { - return undefined; + return; } writeStderrLine( `[artifacts] session=${this.sessionId} action=${action}_denied artifactId=${artifactId} owner=${existing.clientId} requester=${options?.clientId ?? ''}`, ); - return { - v: 1, - sessionId: this.sessionId, - changes: [], - warnings: [`artifact ${artifactId} is owned by a different client`], - }; + throw new SessionArtifactAuthorizationError( + this.sessionId, + artifactId, + existing.clientId, + options?.clientId, + ); } private async normalizeInput( @@ -1162,6 +1165,15 @@ export class SessionArtifactStore { }; } + private shouldSuppressTombstonedUpsert( + artifact: NormalizedArtifact, + ): boolean { + if (!this.tombstonedIds.has(artifact.id)) { + return false; + } + return artifact.source !== 'client' && !artifact.retentionExplicit; + } + private async refreshWorkspaceStatuses(): Promise { const now = Date.now(); const staleWorkspaceArtifacts = Array.from(this.artifacts.values()) diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index 031b69cc394..7357a833244 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -1765,6 +1765,30 @@ describe('QwenAgent MCP SSE/HTTP support', () => { return innerConfig; } + it('sessionArtifactsPersist rejects a missing payload', async () => { + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionArtifactsPersist, { + sessionId: 'session-A', + kind: 'event', + }), + ).rejects.toThrowError(/Invalid or missing artifact persist payload/); + + mockConnectionState.resolve(); + await agentPromise; + }); + it('status ext methods expose workspace snapshots without secrets', async () => { vi.mocked(getMCPDiscoveryState).mockReturnValue( MCPDiscoveryState.COMPLETED, diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 3869b318954..adbcd8ba7f4 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -6318,6 +6318,12 @@ class QwenAgent implements Agent { ); } const payload = params['payload']; + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + throw RequestError.invalidParams( + undefined, + 'Invalid or missing artifact persist payload', + ); + } const session = this.sessionOrThrow(sessionId); const recording = session.getConfig().getChatRecordingService(); if (!recording) { diff --git a/packages/cli/src/serve/acp-http/dispatch.ts b/packages/cli/src/serve/acp-http/dispatch.ts index 7dbd82d6e45..44f844c3b7c 100644 --- a/packages/cli/src/serve/acp-http/dispatch.ts +++ b/packages/cli/src/serve/acp-http/dispatch.ts @@ -2644,7 +2644,10 @@ export class AcpDispatcher { case `${QWEN_METHOD_NS}session/artifacts/gc`: { const sessionId = String(params['sessionId'] ?? ''); await this.withMutableOwned(conn, sessionId, id, async () => { - const result = await this.bridge.gcSessionArtifacts(sessionId); + const result = await this.bridge.gcSessionArtifacts( + sessionId, + this.sessionCtx(conn, sessionId, loopback), + ); this.replyConn(conn, id, result as unknown); }); return; diff --git a/packages/cli/src/serve/acp-http/transport.test.ts b/packages/cli/src/serve/acp-http/transport.test.ts index 48c6236abc7..fe51ff67bf3 100644 --- a/packages/cli/src/serve/acp-http/transport.test.ts +++ b/packages/cli/src/serve/acp-http/transport.test.ts @@ -425,6 +425,9 @@ class FakeBridge { | Parameters[1] | undefined; lastGcSessionId: string | undefined; + lastGcSessionContext: + | Parameters[1] + | undefined; async getSessionArtifacts(sessionId: string) { this.lastArtifactListSessionId = sessionId; return { @@ -494,8 +497,12 @@ class FakeBridge { hashMismatches: [] as string[], }; } - async gcSessionArtifacts(sessionId: string) { + async gcSessionArtifacts( + sessionId: string, + context?: Parameters[1], + ) { this.lastGcSessionId = sessionId; + this.lastGcSessionContext = context; return { removed: [] as string[], retained: [] as string[] }; } async getWorkspaceToolsStatus() { @@ -6294,8 +6301,9 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { }); it('_qwen/session/artifacts/gc returns cleanup result', async () => { - bridge.gcSessionArtifacts = async (sessionId) => { + bridge.gcSessionArtifacts = async (sessionId, context) => { bridge.lastGcSessionId = sessionId; + bridge.lastGcSessionContext = context; return { removed: ['old'], retained: ['kept'] }; }; const connId = await initialize(); @@ -6319,6 +6327,10 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { result: { removed: ['old'], retained: ['kept'] }, }); expect(bridge.lastGcSessionId).toBe('sess-1'); + expect(bridge.lastGcSessionContext).toEqual({ + clientId: 'client-1', + fromLoopback: true, + }); }); it('_qwen/session/artifacts/add holds the archive gate while mutating', async () => { diff --git a/packages/cli/src/serve/acp-session-bridge.ts b/packages/cli/src/serve/acp-session-bridge.ts index 465bc02050a..d0873d437b9 100644 --- a/packages/cli/src/serve/acp-session-bridge.ts +++ b/packages/cli/src/serve/acp-session-bridge.ts @@ -127,4 +127,7 @@ export { canonicalizeWorkspace, } from '@qwen-code/acp-bridge/workspacePaths'; -export { SessionArtifactValidationError } from '@qwen-code/acp-bridge/sessionArtifacts'; +export { + SessionArtifactAuthorizationError, + SessionArtifactValidationError, +} from '@qwen-code/acp-bridge/sessionArtifacts'; diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index 00f84bfc68a..61a64751fbe 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -893,9 +893,18 @@ export function registerSessionRoutes( mutate({ strict: true }), withMutableSession( 'POST /session/:id/artifacts/gc', - async (_req, res, sessionId) => { + async (req, res, sessionId) => { + const clientId = parseClientIdHeader(req, res); + if (clientId === null) return; try { - res.status(200).json(await bridge.gcSessionArtifacts(sessionId)); + res + .status(200) + .json( + await bridge.gcSessionArtifacts( + sessionId, + clientId !== undefined ? { clientId } : undefined, + ), + ); } catch (err) { sendBridgeError(res, err, { route: 'POST /session/:id/artifacts/gc', @@ -936,6 +945,7 @@ export function registerSessionRoutes( ); res.status(200).json(result); } catch (err) { + if (sendArtifactValidationError(res, err)) return; sendBridgeError(res, err, { route: 'DELETE /session/:id/artifacts/:artifactId', sessionId, diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 47a5db2c49c..803b782618f 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -65,6 +65,7 @@ import { PermissionPolicyNotImplementedError, PromptQueueFullError, RestoreInProgressError, + SessionArtifactAuthorizationError, SessionArtifactValidationError, SessionShellClientRequiredError, SessionShellDisabledError, @@ -669,7 +670,10 @@ interface FakeBridge extends AcpSessionBridge { sessionId: string; context?: BridgeClientRequestContext; }>; - gcSessionArtifactsCalls: string[]; + gcSessionArtifactsCalls: Array<{ + sessionId: string; + context?: BridgeClientRequestContext; + }>; workspaceMcpCalls: number; workspaceMcpToolsCalls: string[]; workspaceMcpResourcesCalls: string[]; @@ -821,7 +825,7 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { const pinSessionArtifactCalls: FakeBridge['pinSessionArtifactCalls'] = []; const unpinSessionArtifactCalls: FakeBridge['unpinSessionArtifactCalls'] = []; const fsckSessionArtifactsCalls: FakeBridge['fsckSessionArtifactsCalls'] = []; - const gcSessionArtifactsCalls: string[] = []; + const gcSessionArtifactsCalls: FakeBridge['gcSessionArtifactsCalls'] = []; let workspaceMcpCalls = 0; const workspaceMcpToolsCalls: string[] = []; const workspaceMcpResourcesCalls: string[] = []; @@ -1545,9 +1549,12 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { }); return fsckSessionArtifactsImpl(sessionId, context); }, - async gcSessionArtifacts(sessionId) { - gcSessionArtifactsCalls.push(sessionId); - return gcSessionArtifactsImpl(sessionId); + async gcSessionArtifacts(sessionId, context) { + gcSessionArtifactsCalls.push({ + sessionId, + ...(context ? { context } : {}), + }); + return gcSessionArtifactsImpl(sessionId, context); }, async getWorkspaceMcpStatus() { workspaceMcpCalls += 1; @@ -7936,6 +7943,54 @@ describe('createServeApp', () => { ]); }); + it('DELETE /session/:id/artifacts/:artifactId maps artifact validation errors', async () => { + const bridge = fakeBridge(); + const app = createServeApp(tokenOpts, undefined, { bridge }); + + const res = await auth( + request(app) + .delete('/session/session-A/artifacts/artifact-1') + .send({ deleteContent: 'yes' }), + ).set('X-Qwen-Client-Id', 'client-1'); + + expect(res.status).toBe(400); + expect(res.body).toEqual({ + v: 1, + error: { + code: 'VALIDATION_FAILED', + message: 'deleteContent must be a boolean', + field: 'deleteContent', + }, + }); + expect(bridge.removeSessionArtifactCalls).toEqual([]); + }); + + it('DELETE /session/:id/artifacts/:artifactId maps artifact authorization errors', async () => { + const bridge = fakeBridge({ + removeSessionArtifactImpl: async () => { + throw new SessionArtifactAuthorizationError( + 'session-A', + 'artifact-1', + 'client-a', + 'client-b', + ); + }, + }); + const app = createServeApp(tokenOpts, undefined, { bridge }); + + const res = await auth( + request(app).delete('/session/session-A/artifacts/artifact-1'), + ).set('X-Qwen-Client-Id', 'client-b'); + + expect(res.status).toBe(403); + expect(res.body).toEqual({ + error: 'artifact artifact-1 is owned by a different client', + code: 'session_artifact_forbidden', + sessionId: 'session-A', + artifactId: 'artifact-1', + }); + }); + it('POST /session/:id/artifacts/:artifactId/pin forwards mutation auth context', async () => { const bridge = fakeBridge(); const app = createServeApp(tokenOpts, undefined, { bridge }); @@ -8097,7 +8152,9 @@ describe('createServeApp', () => { const app = createServeApp(tokenOpts, undefined, { bridge }); const res = await auth( - request(app).post('/session/session-A/artifacts/gc'), + request(app) + .post('/session/session-A/artifacts/gc') + .set('X-Qwen-Client-Id', 'client-1'), ); expect(res.status).toBe(200); @@ -8105,7 +8162,9 @@ describe('createServeApp', () => { removed: ['orphaned'], retained: ['kept'], }); - expect(bridge.gcSessionArtifactsCalls).toEqual(['session-A']); + expect(bridge.gcSessionArtifactsCalls).toEqual([ + { sessionId: 'session-A', context: { clientId: 'client-1' } }, + ]); }); }); diff --git a/packages/cli/src/serve/server/error-response.ts b/packages/cli/src/serve/server/error-response.ts index 6c8112e3e5b..b224217a213 100644 --- a/packages/cli/src/serve/server/error-response.ts +++ b/packages/cli/src/serve/server/error-response.ts @@ -27,6 +27,7 @@ import { PermissionPolicyNotImplementedError, PromptQueueFullError, RestoreInProgressError, + SessionArtifactAuthorizationError, SessionArchivedError, SessionArchivingError, SessionBusyError, @@ -278,6 +279,15 @@ export function sendBridgeError( }); return; } + if (err instanceof SessionArtifactAuthorizationError) { + res.status(403).json({ + error: err.message, + code: 'session_artifact_forbidden', + sessionId: err.sessionId, + artifactId: err.artifactId, + }); + return; + } if (err instanceof SessionShellDisabledError) { res.status(403).json({ error: err.message, diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts index f8db7689e03..3138c3a8d90 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -530,14 +530,30 @@ export type { PromptTextContent, SetModelResult, SetSessionLanguageResult, + KnownDaemonSessionArtifactChangeAction, + KnownDaemonSessionArtifactKind, + KnownDaemonSessionArtifactPersistenceWarning, + KnownDaemonSessionArtifactRemovalReason, + KnownDaemonSessionArtifactRestoreState, + KnownDaemonSessionArtifactRetention, + KnownDaemonSessionArtifactSource, + KnownDaemonSessionArtifactStatus, + KnownDaemonSessionArtifactStorage, DaemonSessionArtifact, DaemonSessionArtifactChange, + DaemonSessionArtifactChangeAction, + DaemonSessionArtifactContentRef, + DaemonSessionArtifactFsckResult, + DaemonSessionArtifactGcResult, DaemonSessionArtifactInput, DaemonSessionArtifactKind, DaemonSessionArtifactMutationResult, DaemonSessionArtifactPinOptions, + DaemonSessionArtifactPersistenceWarning, DaemonSessionArtifactRemoveOptions, DaemonSessionArtifactRemovalReason, + DaemonSessionArtifactRestoreState, + DaemonSessionArtifactRetention, DaemonSessionArtifactUnpinOptions, DaemonSessionArtifactsEnvelope, DaemonSessionArtifactSource, From 673bed0ac37c7fd283eb9467d73ac8cff4f212f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Sun, 5 Jul 2026 18:28:04 +0800 Subject: [PATCH 27/61] fix(daemon): require client context for artifact listing --- packages/acp-bridge/src/bridge.test.ts | 5 +++ packages/acp-bridge/src/bridge.ts | 3 +- packages/acp-bridge/src/bridgeTypes.ts | 5 ++- packages/cli/src/serve/acp-http/dispatch.ts | 5 ++- packages/cli/src/serve/routes/session.ts | 14 ++++++- packages/cli/src/serve/server.test.ts | 44 ++++++++++++++++----- 6 files changed, 63 insertions(+), 13 deletions(-) diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index b8244a22078..8a879ad8409 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -165,6 +165,11 @@ describe('createAcpSessionBridge', () => { ); const artifactId = created.changes[0]!.artifactId; + await expect( + bridge.getSessionArtifacts(first.sessionId, { + clientId: 'forged-client', + }), + ).rejects.toBeInstanceOf(InvalidClientIdError); await expect( bridge.removeSessionArtifact(first.sessionId, artifactId, { clientId: second.clientId, diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index 43392a4693b..c8c1e788a15 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -4692,9 +4692,10 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { return { displayName: entry.displayName }; }, - async getSessionArtifacts(sessionId) { + async getSessionArtifacts(sessionId, context) { const entry = byId.get(sessionId); if (!entry) throw new SessionNotFoundError(sessionId); + resolveTrustedClientId(entry, context?.clientId); return entry.artifacts.list(); }, diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts index 19873e28140..d47ea2fd5db 100644 --- a/packages/acp-bridge/src/bridgeTypes.ts +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -582,7 +582,10 @@ export interface AcpSessionBridge { * List the structured artifacts registered for a live session. Throws * `SessionNotFoundError` when the id is unknown. */ - getSessionArtifacts(sessionId: string): Promise; + getSessionArtifacts( + sessionId: string, + context?: BridgeClientRequestContext, + ): Promise; /** * Register a client-supplied artifact for the session. Client artifacts use diff --git a/packages/cli/src/serve/acp-http/dispatch.ts b/packages/cli/src/serve/acp-http/dispatch.ts index 44f844c3b7c..01ccaa493e2 100644 --- a/packages/cli/src/serve/acp-http/dispatch.ts +++ b/packages/cli/src/serve/acp-http/dispatch.ts @@ -2566,7 +2566,10 @@ export class AcpDispatcher { case `${QWEN_METHOD_NS}session/artifacts`: { const sessionId = String(params['sessionId'] ?? ''); if (!this.requireOwned(conn, sessionId, id)) return; - const result = await this.bridge.getSessionArtifacts(sessionId); + const result = await this.bridge.getSessionArtifacts( + sessionId, + this.sessionCtx(conn, sessionId, loopback), + ); this.replyConn(conn, id, result as unknown); return; } diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index 61a64751fbe..05fd9047c87 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -729,8 +729,20 @@ export function registerSessionRoutes( app.get('/session/:id/artifacts', async (req, res) => { const sessionId = requireSessionId(req, res); if (sessionId === null) return; + const clientId = parseClientIdHeader(req, res); + if (clientId === null) return; + if (clientId === undefined) { + res.status(403).json({ + error: 'Session artifact listing requires a session-bound client id', + code: 'client_id_required', + errorKind: 'client_id_required', + }); + return; + } try { - res.status(200).json(await bridge.getSessionArtifacts(sessionId)); + res + .status(200) + .json(await bridge.getSessionArtifacts(sessionId, { clientId })); } catch (err) { sendBridgeError(res, err, { route: 'GET /session/:id/artifacts', diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 803b782618f..2459c0de85d 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -642,7 +642,10 @@ interface FakeBridge extends AcpSessionBridge { }>; listCalls: string[]; summaryCalls: string[]; - sessionArtifactsCalls: string[]; + sessionArtifactsCalls: Array<{ + sessionId: string; + context?: BridgeClientRequestContext; + }>; addSessionArtifactCalls: Array<{ sessionId: string; artifact: Parameters[1]; @@ -818,7 +821,7 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { const sessionPermissionVotes: FakeBridge['sessionPermissionVotes'] = []; const listCalls: string[] = []; const summaryCalls: string[] = []; - const sessionArtifactsCalls: string[] = []; + const sessionArtifactsCalls: FakeBridge['sessionArtifactsCalls'] = []; const addSessionArtifactCalls: FakeBridge['addSessionArtifactCalls'] = []; const removeSessionArtifactCalls: FakeBridge['removeSessionArtifactCalls'] = []; @@ -1503,9 +1506,12 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { summaryCalls.push(sessionId); return summaryImpl(sessionId); }, - async getSessionArtifacts(sessionId) { - sessionArtifactsCalls.push(sessionId); - return getSessionArtifactsImpl(sessionId); + async getSessionArtifacts(sessionId, context) { + sessionArtifactsCalls.push({ + sessionId, + ...(context ? { context } : {}), + }); + return getSessionArtifactsImpl(sessionId, context); }, async addSessionArtifact(sessionId, artifact, context) { addSessionArtifactCalls.push({ @@ -7761,7 +7767,9 @@ describe('createServeApp', () => { }); const app = createServeApp(tokenOpts, undefined, { bridge }); - const res = await auth(request(app).get('/session/session-A/artifacts')); + const res = await auth( + request(app).get('/session/session-A/artifacts'), + ).set('X-Qwen-Client-Id', 'client-1'); expect(res.status).toBe(200); expect(res.body).toMatchObject({ @@ -7769,7 +7777,20 @@ describe('createServeApp', () => { sessionId: 'session-A', artifacts: [{ id: 'artifact-1', title: 'Lineage' }], }); - expect(bridge.sessionArtifactsCalls).toEqual(['session-A']); + expect(bridge.sessionArtifactsCalls).toEqual([ + { sessionId: 'session-A', context: { clientId: 'client-1' } }, + ]); + }); + + it('GET /session/:id/artifacts requires a client id', async () => { + const bridge = fakeBridge(); + const app = createServeApp(tokenOpts, undefined, { bridge }); + + const res = await auth(request(app).get('/session/session-A/artifacts')); + + expect(res.status).toBe(403); + expect(res.body.code).toBe('client_id_required'); + expect(bridge.sessionArtifactsCalls).toEqual([]); }); it('GET /session/:id/artifacts returns 404 for an unknown session', async () => { @@ -7780,11 +7801,16 @@ describe('createServeApp', () => { }); const app = createServeApp(tokenOpts, undefined, { bridge }); - const res = await auth(request(app).get('/session/ghost/artifacts')); + const res = await auth(request(app).get('/session/ghost/artifacts')).set( + 'X-Qwen-Client-Id', + 'client-1', + ); expect(res.status).toBe(404); expect(res.body.sessionId).toBe('ghost'); - expect(bridge.sessionArtifactsCalls).toEqual(['ghost']); + expect(bridge.sessionArtifactsCalls).toEqual([ + { sessionId: 'ghost', context: { clientId: 'client-1' } }, + ]); }); it('POST /session/:id/artifacts requires strict mutation auth', async () => { From 315492ec1e17669bd3f698d9ddff7005c9ac4d1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Sun, 5 Jul 2026 18:42:08 +0800 Subject: [PATCH 28/61] fix(daemon): map artifact authorization rpc errors --- packages/cli/src/serve/acp-http/dispatch.ts | 16 ++++++- .../cli/src/serve/acp-http/transport.test.ts | 45 ++++++++++++++++++- 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/serve/acp-http/dispatch.ts b/packages/cli/src/serve/acp-http/dispatch.ts index 01ccaa493e2..868819e43cd 100644 --- a/packages/cli/src/serve/acp-http/dispatch.ts +++ b/packages/cli/src/serve/acp-http/dispatch.ts @@ -50,7 +50,10 @@ import { SessionShellDisabledError, WorkspaceMismatchError, } from '@qwen-code/acp-bridge/bridgeErrors'; -import { SessionArtifactValidationError } from '@qwen-code/acp-bridge/sessionArtifacts'; +import { + SessionArtifactAuthorizationError, + SessionArtifactValidationError, +} from '@qwen-code/acp-bridge/sessionArtifacts'; import { canonicalizeWorkspace } from '@qwen-code/acp-bridge/workspacePaths'; import { writeStderrLine } from '../../utils/stdioHelpers.js'; import { MAX_WORKSPACE_PATH_LENGTH } from '../fs/paths.js'; @@ -579,6 +582,17 @@ function toRpcError(err: unknown): { data: { errorKind: 'client_id_required' }, }; } + if (err instanceof SessionArtifactAuthorizationError) { + return { + code: RPC.INVALID_REQUEST, + message: err.message, + data: { + errorKind: 'artifact_forbidden', + sessionId: err.sessionId, + artifactId: err.artifactId, + }, + }; + } if (err instanceof SessionArtifactValidationError) { return { code: RPC.INVALID_PARAMS, diff --git a/packages/cli/src/serve/acp-http/transport.test.ts b/packages/cli/src/serve/acp-http/transport.test.ts index fe51ff67bf3..f3ec262189a 100644 --- a/packages/cli/src/serve/acp-http/transport.test.ts +++ b/packages/cli/src/serve/acp-http/transport.test.ts @@ -20,7 +20,10 @@ import type { BridgeEvent, SessionReplaySnapshot, } from '@qwen-code/acp-bridge/eventBus'; -import { SessionArtifactValidationError } from '@qwen-code/acp-bridge/sessionArtifacts'; +import { + SessionArtifactAuthorizationError, + SessionArtifactValidationError, +} from '@qwen-code/acp-bridge/sessionArtifacts'; import { CancelSentinelCollisionError, InvalidClientIdError, @@ -6193,6 +6196,46 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { expect(bridge.lastRemovedArtifact).toBeUndefined(); }); + it('_qwen/session/artifacts/remove maps artifact authorization errors', async () => { + bridge.removeSessionArtifact = async () => { + throw new SessionArtifactAuthorizationError( + 'sess-1', + 'artifact-1', + 'client-owner', + 'client-other', + ); + }; + const connId = await initialize(); + const streamRes = openStream(connId); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 99, + method: 'session/new', + params: {}, + }); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 61, + method: '_qwen/session/artifacts/remove', + params: { sessionId: 'sess-1', artifactId: 'artifact-1' }, + }); + + const frames = await takeFrames(await streamRes, 2); + expect(frames[1]).toMatchObject({ + error: { + code: -32600, + message: 'artifact artifact-1 is owned by a different client', + data: { + errorKind: 'artifact_forbidden', + sessionId: 'sess-1', + artifactId: 'artifact-1', + }, + }, + }); + }); + it('_qwen/session/artifacts/pin forwards artifact id', async () => { const connId = await initialize(); const streamRes = openStream(connId); From feb4c54557fb799997235c30849d1800a5ebe2b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Sun, 5 Jul 2026 18:48:23 +0800 Subject: [PATCH 29/61] fix(daemon): harden artifact retention cleanup --- packages/acp-bridge/src/bridge.test.ts | 2 + packages/acp-bridge/src/bridge.ts | 12 ++++ .../acp-bridge/src/sessionArtifacts.test.ts | 68 +++++++++++++++++++ packages/acp-bridge/src/sessionArtifacts.ts | 18 +++-- 4 files changed, 93 insertions(+), 7 deletions(-) diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index 8a879ad8409..2adc42a6c0e 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -419,6 +419,7 @@ describe('createAcpSessionBridge', () => { { clientId: session.clientId }, { mode: 'content' }, ); + const gcSpy = vi.spyOn(SessionArtifactContentStore.prototype, 'gc'); await expect( bridge.pinSessionArtifact( @@ -439,6 +440,7 @@ describe('createAcpSessionBridge', () => { }, ], }); + expect(gcSpy).toHaveBeenCalledTimes(1); } finally { await bridge.shutdown(); if (previousQwenHome === undefined) { diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index c8c1e788a15..0e492988e78 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -4777,6 +4777,18 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { clientId, }); const warnings = [...pruneWarnings, ...(result.warnings ?? [])]; + if (requestOptions?.mode === 'metadata' && result.changes.length > 0) { + try { + await gcArtifactContent(entry); + } catch (error) { + warnings.push('content_delete_preserved'); + writeStderrLine( + `[artifacts] session=${entry.sessionId} action=pin_metadata_gc_failed reason=${JSON.stringify( + error instanceof Error ? error.message : String(error), + )}`, + ); + } + } publishArtifactChanges(entry, result.changes, clientId); return warnings.length > 0 ? { ...result, warnings } : result; } diff --git a/packages/acp-bridge/src/sessionArtifacts.test.ts b/packages/acp-bridge/src/sessionArtifacts.test.ts index a49f12109b8..e14e844c9f1 100644 --- a/packages/acp-bridge/src/sessionArtifacts.test.ts +++ b/packages/acp-bridge/src/sessionArtifacts.test.ts @@ -3108,6 +3108,74 @@ describe('SessionArtifactStore', () => { ); }); + it('strips retained content when restored expiresAt is invalid', async () => { + const events: SessionArtifactEventRecordPayload[] = []; + const source = new SessionArtifactStore({ + sessionId: 's11-restore-invalid-expires', + workspaceCwd: workspace, + persistence: { + recordEvent: async (payload) => { + events.push(payload); + }, + recordSnapshot: async () => {}, + }, + }); + const created = await source.upsertMany( + [{ title: 'Pinned', url: 'https://example.com/pinned' }], + { strict: true }, + ); + const artifactId = created.changes[0]!.artifactId; + await source.pin(artifactId, { + contentRef: { + kind: 'managed_copy', + contentId: `${'e'.repeat(64)}-${'f'.repeat(16)}`, + sha256: 'e'.repeat(64), + sizeBytes: 12, + createdAt: '2026-07-04T00:00:00.000Z', + }, + }); + const persisted = { + ...events[1]!.changes[0]!.artifact!, + expiresAt: 'not-a-date', + }; + const restored = new SessionArtifactStore({ + sessionId: 's11-restore-invalid-expires', + workspaceCwd: workspace, + }); + const verifyContentRef = vi.fn(async () => undefined); + + await expect( + restored.restore( + { + v: 2, + sessionId: 's11-restore-invalid-expires', + sequence: 2, + artifacts: [persisted], + tombstonedIds: [], + stickyEphemeralIds: [], + warnings: [], + }, + { verifyContentRef }, + ), + ).resolves.toEqual([]); + + expect(verifyContentRef).not.toHaveBeenCalled(); + await expect(restored.list()).resolves.toMatchObject({ + artifacts: [ + expect.objectContaining({ + id: artifactId, + retention: 'restorable', + restoreState: 'restored', + status: 'missing', + persistenceWarning: 'content_expired', + }), + ], + }); + const restoredArtifact = (await restored.list()).artifacts[0]; + expect(restoredArtifact).not.toHaveProperty('contentRef'); + expect(restoredArtifact).not.toHaveProperty('expiresAt'); + }); + it('downgrades restored pinned records to restorable even when content is valid', async () => { const events: SessionArtifactEventRecordPayload[] = []; const source = new SessionArtifactStore({ diff --git a/packages/acp-bridge/src/sessionArtifacts.ts b/packages/acp-bridge/src/sessionArtifacts.ts index 3d14c85096d..7deeb81896d 100644 --- a/packages/acp-bridge/src/sessionArtifacts.ts +++ b/packages/acp-bridge/src/sessionArtifacts.ts @@ -751,13 +751,17 @@ export class SessionArtifactStore { contentRef = undefined; expiresAt = undefined; persistenceWarning = 'sticky_override_active'; - } else if (expiresAt && Date.parse(expiresAt) <= Date.now()) { - contentRef = undefined; - expiresAt = undefined; - retention = 'restorable'; - status = 'missing'; - persistenceWarning = 'content_expired'; - } else if (contentRef && options.verifyContentRef) { + } else if (expiresAt) { + const expiresAtMs = Date.parse(expiresAt); + if (!Number.isFinite(expiresAtMs) || expiresAtMs <= Date.now()) { + contentRef = undefined; + expiresAt = undefined; + retention = 'restorable'; + status = 'missing'; + persistenceWarning = 'content_expired'; + } + } + if (contentRef && options.verifyContentRef) { const warning = await options.verifyContentRef(artifact); if (warning) { contentRef = undefined; From 47e15e2cbece77c8891614c8cd6497b46ce07fa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Sun, 5 Jul 2026 20:15:38 +0800 Subject: [PATCH 30/61] test(web-shell): align sidebar render helper usage --- .../components/sidebar/WebShellSidebar.test.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.test.tsx b/packages/web-shell/client/components/sidebar/WebShellSidebar.test.tsx index 58af5efffec..47b341ee2e3 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.test.tsx +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.test.tsx @@ -280,7 +280,7 @@ describe('WebShellSidebar — session organization', () => { mockWorkspaceActions.listSessionGroups.mockReturnValue( new Promise(() => undefined), ); - const container = renderSidebar(false); + const { container } = renderSidebar(false); expect(mockUseSessions).toHaveBeenCalledWith({ autoLoad: true, pageSize: 1000, @@ -484,7 +484,7 @@ describe('WebShellSidebar — session organization', () => { }), ]; - const container = renderSidebar(false); + const { container } = renderSidebar(false); await act(async () => { await Promise.resolve(); }); @@ -535,7 +535,7 @@ describe('WebShellSidebar — session organization', () => { }), ]; - const container = renderSidebar(false); + const { container } = renderSidebar(false); await act(async () => { await Promise.resolve(); }); @@ -588,7 +588,7 @@ describe('WebShellSidebar — session organization', () => { }), ]; - const container = renderSidebar(false); + const { container } = renderSidebar(false); await act(async () => { await Promise.resolve(); }); @@ -630,7 +630,7 @@ describe('WebShellSidebar — session organization', () => { ); mockActive.sessions = [makeSession('session-a'), makeSession('session-b')]; - const container = renderSidebar(false); + const { container } = renderSidebar(false); await act(async () => { await Promise.resolve(); }); @@ -683,7 +683,7 @@ describe('WebShellSidebar — session organization', () => { mockActive.sessions = [makeSession('session-a')]; const onNewSession = vi.fn(); - const container = renderSidebar(false, { onNewSession }); + const { container } = renderSidebar(false, { onNewSession }); await act(async () => { await Promise.resolve(); }); @@ -732,7 +732,7 @@ describe('WebShellSidebar — session organization', () => { mockActive.sessions = [makeSession('session-a')]; const onError = vi.fn(); - const container = renderSidebar(false, { onError }); + const { container } = renderSidebar(false, { onError }); await act(async () => { await Promise.resolve(); }); From 4b6c7f5a40db457bc81a36343bd499e9053a1c77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Sun, 5 Jul 2026 20:43:14 +0800 Subject: [PATCH 31/61] fix(daemon): harden artifact gc and snapshot retries --- packages/acp-bridge/src/bridge.ts | 26 +++++--- .../src/sessionArtifactContentStore.ts | 42 +++++++++++-- .../acp-bridge/src/sessionArtifacts.test.ts | 61 ++++++++++++++++++- packages/acp-bridge/src/sessionArtifacts.ts | 23 ++++++- packages/cli/src/serve/routes/session.ts | 44 ++++++------- packages/cli/src/serve/server.test.ts | 52 ++++++++++++++++ 6 files changed, 206 insertions(+), 42 deletions(-) diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index 0e492988e78..8fe62c3d0c9 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -2791,10 +2791,15 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { const gcArtifactContent = async (entry: SessionEntry): Promise => { const refs = await entry.artifacts.contentRefs(); - await artifactContentStore.gc( - entry.sessionId, - new Set(refs.map((ref) => ref.contentId)), - ); + const releaseRefs = artifactContentStore.leaseContentRefs(refs); + try { + await artifactContentStore.gc( + entry.sessionId, + new Set(refs.map((ref) => ref.contentId)), + ); + } finally { + releaseRefs(); + } }; const pruneExpiredArtifactPins = async ( @@ -4881,10 +4886,15 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { if (!entry) throw new SessionNotFoundError(sessionId); resolveTrustedClientId(entry, context?.clientId); const refs = await entry.artifacts.contentRefs(); - return artifactContentStore.gc( - sessionId, - new Set(refs.map((ref) => ref.contentId)), - ); + const releaseRefs = artifactContentStore.leaseContentRefs(refs); + try { + return await artifactContentStore.gc( + sessionId, + new Set(refs.map((ref) => ref.contentId)), + ); + } finally { + releaseRefs(); + } }, listWorkspaceSessions(workspaceCwd) { diff --git a/packages/acp-bridge/src/sessionArtifactContentStore.ts b/packages/acp-bridge/src/sessionArtifactContentStore.ts index f77edaf3f65..99e7c70e034 100644 --- a/packages/acp-bridge/src/sessionArtifactContentStore.ts +++ b/packages/acp-bridge/src/sessionArtifactContentStore.ts @@ -46,7 +46,7 @@ export interface SessionArtifactGcResult { export class SessionArtifactContentStore { private readonly rootDir: string; private writeQueue: Promise = Promise.resolve(); - private readonly leasedContentIds = new Set(); + private readonly leasedContentIds = new Map(); private cachedTotalBytes: number | undefined; constructor(rootDir = defaultContentRoot()) { @@ -130,7 +130,7 @@ export class SessionArtifactContentStore { if (addedSizeBytes > 0 && this.cachedTotalBytes !== undefined) { this.cachedTotalBytes += addedSizeBytes; } - this.leasedContentIds.add(contentId); + this.leaseContentId(contentId); return { kind: 'managed_copy', contentId, @@ -153,7 +153,20 @@ export class SessionArtifactContentStore { } releaseContentRef(ref: SessionArtifactContentRef): void { - this.leasedContentIds.delete(ref.contentId); + this.releaseContentId(ref.contentId); + } + + leaseContentRefs(refs: readonly SessionArtifactContentRef[]): () => void { + const leased: string[] = []; + for (const ref of refs) { + this.leaseContentId(ref.contentId); + leased.push(ref.contentId); + } + return () => { + for (const contentId of leased) { + this.releaseContentId(contentId); + } + }; } async fsck( @@ -337,6 +350,23 @@ export class SessionArtifactContentStore { ); return run; } + + private leaseContentId(contentId: string): void { + this.leasedContentIds.set( + contentId, + (this.leasedContentIds.get(contentId) ?? 0) + 1, + ); + } + + private releaseContentId(contentId: string): void { + const count = this.leasedContentIds.get(contentId); + if (count === undefined) return; + if (count <= 1) { + this.leasedContentIds.delete(contentId); + return; + } + this.leasedContentIds.set(contentId, count - 1); + } } function defaultContentRoot(): string { @@ -511,9 +541,9 @@ function noFollowFlag(): number { function sameFile(before: fsSync.Stats, after: fsSync.Stats): boolean { if ( - before.dev !== 0 || - before.ino !== 0 || - after.dev !== 0 || + before.dev !== 0 && + before.ino !== 0 && + after.dev !== 0 && after.ino !== 0 ) { return before.dev === after.dev && before.ino === after.ino; diff --git a/packages/acp-bridge/src/sessionArtifacts.test.ts b/packages/acp-bridge/src/sessionArtifacts.test.ts index e14e844c9f1..d6fb63b1520 100644 --- a/packages/acp-bridge/src/sessionArtifacts.test.ts +++ b/packages/acp-bridge/src/sessionArtifacts.test.ts @@ -2481,16 +2481,20 @@ describe('SessionArtifactStore', () => { expect(events[50]).toMatchObject({ sequence: 52 }); }); - it('does not retry snapshots on every event after a snapshot write failure', async () => { + it('backs off snapshot retries after a write failure and resets after success', async () => { let snapshotAttempts = 0; + const snapshots: SessionArtifactSnapshotRecordPayload[] = []; const store = new SessionArtifactStore({ sessionId: 's11-snapshot-failure', workspaceCwd: workspace, persistence: { recordEvent: async () => {}, - recordSnapshot: async () => { + recordSnapshot: async (payload) => { snapshotAttempts++; - throw new Error('disk full'); + if (snapshotAttempts === 1) { + throw new Error('disk full'); + } + snapshots.push(payload); }, }, }); @@ -2511,6 +2515,7 @@ describe('SessionArtifactStore', () => { ); } expect(snapshotAttempts).toBe(1); + expect(snapshots).toHaveLength(0); await store.upsertMany( [ @@ -2522,6 +2527,47 @@ describe('SessionArtifactStore', () => { { strict: true }, ); expect(snapshotAttempts).toBe(1); + expect(snapshots).toHaveLength(0); + + for (let index = 51; index < 100; index++) { + await store.upsertMany( + [ + { + title: `Durable retry ${index}`, + url: `https://example.com/snapshot-retry-${index}`, + }, + ], + { strict: true }, + ); + } + expect(snapshotAttempts).toBe(2); + expect(snapshots).toHaveLength(1); + + for (let index = 100; index < 149; index++) { + await store.upsertMany( + [ + { + title: `Durable reset ${index}`, + url: `https://example.com/snapshot-reset-${index}`, + }, + ], + { strict: true }, + ); + } + expect(snapshotAttempts).toBe(2); + + await store.upsertMany( + [ + { + title: 'Durable after reset', + url: 'https://example.com/snapshot-after-reset', + }, + ], + { strict: true }, + ); + expect(snapshotAttempts).toBe(3); + expect(snapshots).toHaveLength(2); + const logged = stderr.mock.calls.map((call) => String(call[0])).join(''); expect(logged).toContain('snapshot_failed'); } finally { @@ -3566,7 +3612,16 @@ describe('SessionArtifactContentStore', () => { retained: expect.arrayContaining([ref.contentId]), }); + const releaseGcLease = contentStore.leaseContentRefs([ref]); contentStore.releaseContentRef(ref); + await expect( + contentStore.gc('content-session', new Set()), + ).resolves.toMatchObject({ + removed: [], + retained: expect.arrayContaining([ref.contentId]), + }); + + releaseGcLease(); await expect( contentStore.gc('content-session', new Set()), ).resolves.toMatchObject({ diff --git a/packages/acp-bridge/src/sessionArtifacts.ts b/packages/acp-bridge/src/sessionArtifacts.ts index 7deeb81896d..c682accc608 100644 --- a/packages/acp-bridge/src/sessionArtifacts.ts +++ b/packages/acp-bridge/src/sessionArtifacts.ts @@ -52,6 +52,7 @@ const SOURCE_RESERVATIONS: Record = { const WORKSPACE_STATUS_REFRESH_TTL_MS = 5_000; const WORKSPACE_STATUS_REFRESH_BATCH_SIZE = 20; const SNAPSHOT_AFTER_DURABLE_EVENTS = 50; +const MAX_SNAPSHOT_BACKOFF_MULTIPLIER = 4; export interface ToolArtifactLike { kind?: DaemonSessionArtifactKind; @@ -213,6 +214,7 @@ export class SessionArtifactStore { private insertSeq = 0; private persistenceSeq = 0; private durableEventsSinceSnapshot = 0; + private consecutiveSnapshotFailures = 0; private realWorkspaceCwdPromise?: Promise; private operationQueue: Promise = Promise.resolve(); private readonly tombstonedIds = new Set(); @@ -709,6 +711,7 @@ export class SessionArtifactStore { this.insertSeq = 0; this.persistenceSeq = snapshot.sequence; this.durableEventsSinceSnapshot = 0; + this.consecutiveSnapshotFailures = 0; for (const id of snapshot.tombstonedIds) { this.tombstonedIds.add(id); } @@ -809,6 +812,7 @@ export class SessionArtifactStore { insertSeq: number; persistenceSeq: number; durableEventsSinceSnapshot: number; + consecutiveSnapshotFailures: number; tombstonedIds: Set; stickyEphemeralIds: Set; } { @@ -823,6 +827,7 @@ export class SessionArtifactStore { insertSeq: this.insertSeq, persistenceSeq: this.persistenceSeq, durableEventsSinceSnapshot: this.durableEventsSinceSnapshot, + consecutiveSnapshotFailures: this.consecutiveSnapshotFailures, tombstonedIds: new Set(this.tombstonedIds), stickyEphemeralIds: new Set(this.stickyEphemeralIds), }; @@ -834,6 +839,7 @@ export class SessionArtifactStore { insertSeq: number; persistenceSeq: number; durableEventsSinceSnapshot: number; + consecutiveSnapshotFailures: number; tombstonedIds: Set; stickyEphemeralIds: Set; }): void { @@ -845,6 +851,7 @@ export class SessionArtifactStore { this.insertSeq = state.insertSeq; this.persistenceSeq = state.persistenceSeq; this.durableEventsSinceSnapshot = state.durableEventsSinceSnapshot; + this.consecutiveSnapshotFailures = state.consecutiveSnapshotFailures; this.tombstonedIds.clear(); for (const id of state.tombstonedIds) { this.tombstonedIds.add(id); @@ -930,7 +937,13 @@ export class SessionArtifactStore { private async maybeRecordSnapshot(recordedAt: string): Promise { if (!this.persistence) return; this.durableEventsSinceSnapshot++; - if (this.durableEventsSinceSnapshot < SNAPSHOT_AFTER_DURABLE_EVENTS) { + const snapshotThreshold = + SNAPSHOT_AFTER_DURABLE_EVENTS * + Math.min( + MAX_SNAPSHOT_BACKOFF_MULTIPLIER, + 2 ** this.consecutiveSnapshotFailures, + ); + if (this.durableEventsSinceSnapshot < snapshotThreshold) { return; } const artifacts = Array.from(this.artifacts.values()) @@ -958,14 +971,18 @@ export class SessionArtifactStore { for (const id of stickyEphemeralIds) { this.stickyEphemeralIds.add(id); } + this.durableEventsSinceSnapshot = 0; + this.consecutiveSnapshotFailures = 0; } catch (error) { + this.consecutiveSnapshotFailures = Math.min( + this.consecutiveSnapshotFailures + 1, + Math.log2(MAX_SNAPSHOT_BACKOFF_MULTIPLIER), + ); writeStderrLine( `[artifacts] session=${this.sessionId} action=snapshot_failed reason=${JSON.stringify( error instanceof Error ? error.message : String(error), )}`, ); - } finally { - this.durableEventsSinceSnapshot = 0; } } diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index 05fd9047c87..06d23bb86f9 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -78,6 +78,19 @@ interface RegisterSessionRoutesDeps { const SESSION_ARTIFACT_MAX_TTL_DAYS = 365; +function requireSessionArtifactClientId( + clientId: string | undefined, + res: Response, +): clientId is string { + if (clientId !== undefined) return true; + res.status(403).json({ + error: 'Session artifact access requires a session-bound client id', + code: 'client_id_required', + errorKind: 'client_id_required', + }); + return false; +} + function sendArtifactValidationError(res: Response, err: unknown): boolean { if (!(err instanceof SessionArtifactValidationError)) { return false; @@ -731,14 +744,7 @@ export function registerSessionRoutes( if (sessionId === null) return; const clientId = parseClientIdHeader(req, res); if (clientId === null) return; - if (clientId === undefined) { - res.status(403).json({ - error: 'Session artifact listing requires a session-bound client id', - code: 'client_id_required', - errorKind: 'client_id_required', - }); - return; - } + if (!requireSessionArtifactClientId(clientId, res)) return; try { res .status(200) @@ -807,6 +813,7 @@ export function registerSessionRoutes( const artifactId = req.params['artifactId']; const clientId = parseClientIdHeader(req, res); if (clientId === null) return; + if (!requireSessionArtifactClientId(clientId, res)) return; if (!artifactId) { res.status(400).json({ v: 1, @@ -823,7 +830,7 @@ export function registerSessionRoutes( const result = await bridge.pinSessionArtifact( sessionId, artifactId, - clientId !== undefined ? { clientId } : undefined, + { clientId }, nonEmptyArtifactPinRequest(options), ); res.status(200).json(result); @@ -847,6 +854,7 @@ export function registerSessionRoutes( const artifactId = req.params['artifactId']; const clientId = parseClientIdHeader(req, res); if (clientId === null) return; + if (!requireSessionArtifactClientId(clientId, res)) return; if (!artifactId) { res.status(400).json({ v: 1, @@ -863,7 +871,7 @@ export function registerSessionRoutes( const result = await bridge.unpinSessionArtifact( sessionId, artifactId, - clientId !== undefined ? { clientId } : undefined, + { clientId }, nonEmptyArtifactUnpinRequest(options), ); res.status(200).json(result); @@ -883,15 +891,11 @@ export function registerSessionRoutes( if (sessionId === null) return; const clientId = parseClientIdHeader(req, res); if (clientId === null) return; + if (!requireSessionArtifactClientId(clientId, res)) return; try { res .status(200) - .json( - await bridge.fsckSessionArtifacts( - sessionId, - clientId !== undefined ? { clientId } : undefined, - ), - ); + .json(await bridge.fsckSessionArtifacts(sessionId, { clientId })); } catch (err) { sendBridgeError(res, err, { route: 'GET /session/:id/artifacts/fsck', @@ -908,15 +912,11 @@ export function registerSessionRoutes( async (req, res, sessionId) => { const clientId = parseClientIdHeader(req, res); if (clientId === null) return; + if (!requireSessionArtifactClientId(clientId, res)) return; try { res .status(200) - .json( - await bridge.gcSessionArtifacts( - sessionId, - clientId !== undefined ? { clientId } : undefined, - ), - ); + .json(await bridge.gcSessionArtifacts(sessionId, { clientId })); } catch (err) { sendBridgeError(res, err, { route: 'POST /session/:id/artifacts/gc', diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 2459c0de85d..26ec3dc897a 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -7793,6 +7793,32 @@ describe('createServeApp', () => { expect(bridge.sessionArtifactsCalls).toEqual([]); }); + it('POST /session/:id/artifacts/:artifactId/pin requires a client id', async () => { + const bridge = fakeBridge(); + const app = createServeApp(tokenOpts, undefined, { bridge }); + + const res = await auth( + request(app).post('/session/session-A/artifacts/artifact-1/pin'), + ); + + expect(res.status).toBe(403); + expect(res.body.code).toBe('client_id_required'); + expect(bridge.pinSessionArtifactCalls).toEqual([]); + }); + + it('DELETE /session/:id/artifacts/:artifactId/pin requires a client id', async () => { + const bridge = fakeBridge(); + const app = createServeApp(tokenOpts, undefined, { bridge }); + + const res = await auth( + request(app).delete('/session/session-A/artifacts/artifact-1/pin'), + ); + + expect(res.status).toBe(403); + expect(res.body.code).toBe('client_id_required'); + expect(bridge.unpinSessionArtifactCalls).toEqual([]); + }); + it('GET /session/:id/artifacts returns 404 for an unknown session', async () => { const bridge = fakeBridge({ getSessionArtifactsImpl: async (sessionId) => { @@ -8168,6 +8194,19 @@ describe('createServeApp', () => { ]); }); + it('GET /session/:id/artifacts/fsck requires a client id', async () => { + const bridge = fakeBridge(); + const app = createServeApp(tokenOpts, undefined, { bridge }); + + const res = await auth( + request(app).get('/session/session-A/artifacts/fsck'), + ); + + expect(res.status).toBe(403); + expect(res.body.code).toBe('client_id_required'); + expect(bridge.fsckSessionArtifactsCalls).toEqual([]); + }); + it('POST /session/:id/artifacts/gc returns content cleanup result', async () => { const bridge = fakeBridge({ gcSessionArtifactsImpl: async () => ({ @@ -8192,6 +8231,19 @@ describe('createServeApp', () => { { sessionId: 'session-A', context: { clientId: 'client-1' } }, ]); }); + + it('POST /session/:id/artifacts/gc requires a client id', async () => { + const bridge = fakeBridge(); + const app = createServeApp(tokenOpts, undefined, { bridge }); + + const res = await auth( + request(app).post('/session/session-A/artifacts/gc'), + ); + + expect(res.status).toBe(403); + expect(res.body.code).toBe('client_id_required'); + expect(bridge.gcSessionArtifactsCalls).toEqual([]); + }); }); describe('POST /session/:id/model', () => { From ae551e7d85d68ea0ae1fbd64fca48dd82a71dbd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Sun, 5 Jul 2026 22:49:25 +0800 Subject: [PATCH 32/61] fix(daemon): harden artifact mutation ownership --- packages/acp-bridge/src/bridge.test.ts | 2 ++ packages/acp-bridge/src/bridge.ts | 2 +- packages/cli/src/serve/routes/session.ts | 12 ++++----- packages/cli/src/serve/server.test.ts | 32 +++++++++++++++++++++--- 4 files changed, 38 insertions(+), 10 deletions(-) diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index 2adc42a6c0e..2acde1e5c56 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -488,6 +488,7 @@ describe('createAcpSessionBridge', () => { ); const firstContentId = firstPin.changes[0]?.artifact?.contentRef?.contentId; + const gcSpy = vi.spyOn(SessionArtifactContentStore.prototype, 'gc'); await fsp.writeFile( path.join(workspace, 'reports', 'report.txt'), @@ -506,6 +507,7 @@ describe('createAcpSessionBridge', () => { expect(secondPin.changes[0]?.artifact?.contentRef?.contentId).not.toBe( firstContentId, ); + expect(gcSpy).toHaveBeenCalledTimes(1); } finally { await bridge.shutdown(); if (previousQwenHome === undefined) { diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index 8fe62c3d0c9..fb1761a4b7a 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -4822,9 +4822,9 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { }); if (contentRef) { artifactContentStore.releaseContentRef(contentRef); + await gcArtifactContent(entry).catch(() => undefined); } if (contentRef && result.changes.length === 0) { - await gcArtifactContent(entry).catch(() => undefined); if (!(await entry.artifacts.get(artifactId))) { throw new SessionArtifactValidationError( 'artifact no longer exists', diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index 06d23bb86f9..bfca3428b09 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -765,6 +765,7 @@ export function registerSessionRoutes( async (req, res, sessionId) => { const clientId = parseClientIdHeader(req, res); if (clientId === null) return; + if (!requireSessionArtifactClientId(clientId, res)) return; try { const body = safeBody(req); const artifact: SessionArtifactInput = { @@ -787,11 +788,9 @@ export function registerSessionRoutes( 'clientRetained' ] as SessionArtifactInput['clientRetained'], }; - const result = await bridge.addSessionArtifact( - sessionId, - artifact, - clientId !== undefined ? { clientId } : undefined, - ); + const result = await bridge.addSessionArtifact(sessionId, artifact, { + clientId, + }); res.status(200).json(result); } catch (err) { if (sendArtifactValidationError(res, err)) return; @@ -936,6 +935,7 @@ export function registerSessionRoutes( const artifactId = req.params['artifactId']; const clientId = parseClientIdHeader(req, res); if (clientId === null) return; + if (!requireSessionArtifactClientId(clientId, res)) return; if (!artifactId) { res.status(400).json({ v: 1, @@ -952,7 +952,7 @@ export function registerSessionRoutes( const result = await bridge.removeSessionArtifact( sessionId, artifactId, - clientId !== undefined ? { clientId } : undefined, + { clientId }, nonEmptyArtifactRemoveRequest(options), ); res.status(200).json(result); diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 8b4e21de1ca..8127e41b54c 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -7853,6 +7853,19 @@ describe('createServeApp', () => { expect(bridge.addSessionArtifactCalls).toHaveLength(0); }); + it('POST /session/:id/artifacts requires a client id', async () => { + const bridge = fakeBridge(); + const app = createServeApp(tokenOpts, undefined, { bridge }); + + const res = await auth( + request(app).post('/session/session-A/artifacts'), + ).send({ title: 'Lineage', url: 'https://example.com/lineage' }); + + expect(res.status).toBe(403); + expect(res.body.code).toBe('client_id_required'); + expect(bridge.addSessionArtifactCalls).toEqual([]); + }); + it('POST /session/:id/artifacts forwards body and client context', async () => { const bridge = fakeBridge(); const app = createServeApp(tokenOpts, undefined, { bridge }); @@ -7902,9 +7915,9 @@ describe('createServeApp', () => { }); const app = createServeApp(tokenOpts, undefined, { bridge }); - const res = await auth( - request(app).post('/session/session-A/artifacts'), - ).send({ title: 'Bad link', url: 'javascript:alert(1)' }); + const res = await auth(request(app).post('/session/session-A/artifacts')) + .set('X-Qwen-Client-Id', 'client-1') + .send({ title: 'Bad link', url: 'javascript:alert(1)' }); expect(res.status).toBe(400); expect(res.body).toEqual({ @@ -7930,6 +7943,19 @@ describe('createServeApp', () => { expect(bridge.removeSessionArtifactCalls).toHaveLength(0); }); + it('DELETE /session/:id/artifacts/:artifactId requires a client id', async () => { + const bridge = fakeBridge(); + const app = createServeApp(tokenOpts, undefined, { bridge }); + + const res = await auth( + request(app).delete('/session/session-A/artifacts/artifact-1'), + ); + + expect(res.status).toBe(403); + expect(res.body.code).toBe('client_id_required'); + expect(bridge.removeSessionArtifactCalls).toEqual([]); + }); + it('DELETE /session/:id/artifacts/:artifactId is idempotent for missing artifacts', async () => { const bridge = fakeBridge(); const app = createServeApp(tokenOpts, undefined, { bridge }); From 729dff74d5023c033f3b5861ccff492bc91b226d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Sun, 5 Jul 2026 23:29:19 +0800 Subject: [PATCH 33/61] refactor(daemon): split artifact content retention --- ...session-artifacts-persistence-v2-design.md | 70 +- .../cli/qwen-serve-routes.test.ts | 1 - packages/acp-bridge/src/bridge.test.ts | 365 ---------- packages/acp-bridge/src/bridge.ts | 345 +--------- packages/acp-bridge/src/bridgeTypes.ts | 43 -- .../src/sessionArtifactContentStore.ts | 649 ------------------ .../acp-bridge/src/sessionArtifacts.test.ts | 470 ------------- packages/cli/src/serve/acp-http/dispatch.ts | 115 +--- packages/cli/src/serve/acp-http/index.ts | 1 - .../cli/src/serve/acp-http/transport.test.ts | 218 +----- packages/cli/src/serve/acp-session-bridge.ts | 3 - packages/cli/src/serve/capabilities.ts | 6 - packages/cli/src/serve/routes/session.ts | 232 ------- packages/cli/src/serve/run-qwen-serve.ts | 1 - packages/cli/src/serve/server.test.ts | 393 +---------- .../cli/src/serve/server/serve-features.ts | 1 - .../sdk-typescript/src/daemon/DaemonClient.ts | 66 -- .../src/daemon/DaemonSessionClient.ts | 42 -- .../src/daemon/acpRouteTable.ts | 44 -- packages/sdk-typescript/src/daemon/index.ts | 6 - packages/sdk-typescript/src/daemon/types.ts | 45 +- .../test/unit/DaemonClient.test.ts | 116 ---- .../test/unit/DaemonSessionClient.test.ts | 47 -- .../test/unit/acpRouteTable.test.ts | 55 +- 24 files changed, 47 insertions(+), 3287 deletions(-) delete mode 100644 packages/acp-bridge/src/sessionArtifactContentStore.ts diff --git a/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md b/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md index 5bc6bed3b22..8d74d02ed8c 100644 --- a/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md +++ b/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md @@ -6,7 +6,7 @@ V2 的目标是在不破坏 V1 live session 语义的前提下,让 artifact me ## 1. 设计结论 -V2 是一个完整设计 phase,但对外能力仍按 capability gate 暴露。PR #6259 的实现范围是:metadata restore、显式 workspace content pin、session-scoped managed copy、hash/manifest 校验、硬编码 quota、TTL 降级、session-scoped GC/fsck,以及 load/resume/rewind/fork 的 active-chain 恢复。client 不应依赖“V2”这个阶段名推断功能,而应读取 capability。 +V2 是一个完整设计 phase,但对外能力仍按 capability gate 暴露。PR #6259 的实现范围收敛为 metadata restore、artifact JSONL journal/snapshot/rebuild/fork remap、daemon restart/load/replay 后恢复 artifact metadata,以及 REST/ACP/SDK 的 metadata persistence 暴露。content retention(workspace content pin、session-scoped managed copy、hash/manifest、quota、TTL、session-scoped GC/fsck)已拆出到后续 PR。client 不应依赖“V2”这个阶段名推断功能,而应读取 capability。 两层能力: @@ -16,7 +16,7 @@ V2 是一个完整设计 phase,但对外能力仍按 capability gate 暴露。 对应 capability: - `session_artifacts_persistence`:支持 metadata 持久化与 session load/replay 恢复。 -- `session_artifacts_content_retention`:支持显式 workspace 内容保留、配额、hash、manifest 和 session-scoped GC/fsck。当前实现不声明或承诺 project-wide cross-session GC、global artifact library、published file-root restore 或 sidecar cache。 +- `session_artifacts_content_retention`:后续 content-retention PR 才声明;它支持显式 workspace 内容保留、配额、hash、manifest 和 session-scoped GC/fsck。即使后续声明,也不承诺 project-wide cross-session GC、global artifact library、published file-root restore 或 sidecar cache。 核心原则: @@ -30,7 +30,7 @@ V2 是一个完整设计 phase,但对外能力仍按 capability gate 暴露。 当前 PR 的重要收窄: -- Content retention 只保存用户显式 pin 的 workspace regular file managed copy。`contentId` 包含 session/artifact 维度,fork 不继承 `contentRef`,因此当前 GC 可以按 session 引用集保守清理;project-wide 引用重建、orphan grace marker 和跨 session shared contentRef 是后续增强。 +- Content retention public API、managed content store、pin/unpin、deleteContent、quota/hash/manifest/fsck/gc 和 `session_artifacts_content_retention` capability 不在 PR #6259 中交付;这些安全面拆到后续 PR。当前 PR 只保留对旧 `pinned` / `contentRef` journal payload 的 downgrade/strip 兼容路径,避免旧记录破坏 metadata restore。 - 当前 live view 与 persisted metadata 使用同一个 200 条可见集合。为了避免重启后 over-restore,超过上限时的 durable/restorable eviction 会写入 `reason: "eviction"` remove event;这等价于本实现的 metadata prune,不是纯 V1 live-only hiding。 - 显式 DELETE 当前采用 live-first:先从 live store 移除,tombstone 写入失败时返回 warning。这样可优先隐藏敏感项;失败窗口内 daemon 重启仍可能从旧 journal 恢复该 artifact,client 应把 warning 作为“删除未 durable”的信号。 - Fork 当前通过一次性 exclusive-create 写入目标 JSONL 文件;不会逐条 streaming fork artifact records,因此不需要 `session_artifact_fork_marker` 才能检测当前写入路径的 partial batch。若未来改成流式 fork,再引入 begin/complete marker。 @@ -369,7 +369,7 @@ ingest input - `eviction`:durable remove event,保证重启后仍遵守 200 条上限。 - unpin-to-`ephemeral`:durable remove event,并把 id 写入 bounded `stickyEphemeralIds`;后续隐式/default upsert 会保持 live-only,直到显式 `retention: "restorable"` 或 pin/save supersede。 - 显式 DELETE:live-first。先从 live store 移除并发布删除事件,再 best-effort 写 explicit remove tombstone。tombstone 写入失败时 response 返回 warning(当前为字符串 warning),表示删除没有 durable;如果 daemon 在补写成功前重启,旧 journal 仍可能恢复该 artifact。 -- 显式 DELETE 携带 `deleteContent: true` 时,content GC 只在 live removal 后按当前引用集合 best-effort 执行。若 tombstone 或 content GC 有风险,response 包含 warning;当前实现不会在未确认引用集合时强删其它 session 的 content,因为 contentRef 不跨 session 继承。 +- `deleteContent: true` 不属于 PR #6259 的 public API。content-retention follow-up 才会定义 content GC 与 warning contract;当前 PR 的显式 DELETE 只处理 metadata tombstone 和 live removal。 建议 warning: @@ -446,7 +446,7 @@ metadata 的 fork amplification 在 V2 中作为有界 trade-off 接受:fork "session_artifacts_persistence" ``` -内容保留实现可用时,同时声明: +内容保留拆分 PR 实现可用时,才同时声明: ```json "session_artifacts_content_retention" @@ -456,7 +456,7 @@ metadata 的 fork amplification 在 V2 中作为有界 trade-off 接受:fork - 行为可用且当前配置启用时才声明对应 feature string。 - chat recording 禁用、metadata persistence 禁用或 writer 不可用时,不声明 `session_artifacts_persistence`。 -- content retention 的显式 workspace `pin/save content`、quota、hash、manifest、session-scoped GC/fsck 都可用时,才声明 `session_artifacts_content_retention`。当前 feature string 是 daemon build-level 能力;单个 session 如果 chat recording writer 不可用,mutation 会降级或返回明确错误。 +- content retention 的显式 workspace `pin/save content`、quota、hash、manifest、session-scoped GC/fsck 都可用时,才声明 `session_artifacts_content_retention`。PR #6259 不声明该 capability;后续拆分 PR 必须保持 feature string 是 daemon build-level 能力。 - 如果 client 需要读取 limits/default retention,应另设计 config endpoint 或 SDK config query;不要把结构化 details 混入现有 string-only capability contract。 ### 6.2 Add artifact @@ -483,6 +483,8 @@ metadata 的 fork amplification 在 V2 中作为有界 trade-off 接受:fork ### 6.3 Pin/save artifact +本节是后续 content-retention PR 的 API 设计,不属于 PR #6259 的 wire contract。PR #6259 只允许 add/list/get/delete metadata artifacts,不暴露 pin/unpin endpoint。 + 新增: ```http @@ -521,6 +523,8 @@ Body: ### 6.4 Unpin +本节是后续 content-retention PR 的 API 设计,不属于 PR #6259 的 wire contract。 + ```http DELETE /session/:id/artifacts/:artifactId/pin Content-Type: application/json @@ -549,23 +553,11 @@ V2 的 DELETE 仍保持 V1 幂等,并采用当前 PR 的 live-first 语义: - 随后 best-effort append `session_artifact_event` remove tombstone;tombstone 成功后,metadata restore 时不再复活。 - tombstone 失败时,返回成功 mutation result 但附带 warning;当前 daemon 生命周期内该 artifact 已被删除,但如果 daemon 在 tombstone 持久化前重启,旧 durable artifact 仍可能恢复。用户或上层 UI 可以在 storage 恢复后重试 DELETE。 - DELETE 对不存在的 artifact 保持幂等成功;如果已有 durable tombstone,重复 DELETE 不需要再写同一 tombstone。 -- 默认不立即同步删除 managed/pinned content,但会释放该 artifact 对 contentRef 的 live 引用;session-scoped GC 清理当前 session manifest 中已无 live artifact 引用的 daemon-managed content。 - -可选 body 或独立 endpoint 支持内容删除: - -```json -{ - "deleteContent": true -} -``` - -`deleteContent` 表示请求立即删除可删除内容,授权要求见 §7.1 的 delete content 规则:必须是显式 REST/SDK call、有 session mutate 权限、content retention capability 启用,并满足可验证 creator-principal match 或 admin override。共享 contentRef 只能在后台引用集合确认没有其它 session 仍引用后删除。 - -`deleteContent: true` 在当前 PR 中仍只表达“释放当前 artifact 对 content 的引用并触发 best-effort GC”。content bytes 只有在 GC 确认当前 session manifest 中没有 live artifact 引用后才会删除;如果 tombstone 未 durable,API 仍不能声称跨重启删除已经完成,只能通过 warning 表达持久化风险。跨 session 引用确认和强制立即删除是后续增强。 +- PR #6259 的 DELETE 不接受 `deleteContent`,也不触发 daemon-managed content GC;旧 `contentRef` metadata 只在 restore/serialization 时被降级或移除。内容删除与 GC 由后续 content-retention PR 定义。 ### 6.6 Mutation responses -Pin、unpin 和 delete 必须使用一致的 mutation response,避免 client 猜测状态: +Pin、unpin 和 delete 必须使用一致的 mutation response,避免 client 猜测状态。PR #6259 只交付 DELETE response;Pin/Unpin response 是后续 content-retention PR 的 contract。 成功: @@ -585,10 +577,10 @@ Pin、unpin 和 delete 必须使用一致的 mutation response,避免 client } ``` -当前 PR 的 HTTP mapping: +PR #6259 的 HTTP mapping: -- `400 VALIDATION_FAILED`:非法 body、`ttlDays` 与 `mode` 不匹配、client 请求 `pinned`、artifact 不存在、metadata/content quota 已满且没有可裁剪 candidate,或 writer/content storage 不可用但 mutation 必须严格 durable 完成。 -- `403 FORBIDDEN`:缺少 session mutate 权限或 `deleteContent` 授权不足。 +- `400 VALIDATION_FAILED`:非法 body、client 请求 `pinned`、artifact 不存在、metadata quota 已满且没有可裁剪 candidate,或 writer 不可用但 mutation 必须严格 durable 完成。 +- `403 FORBIDDEN`:缺少 session mutate 权限。 - DELETE 保持幂等;不存在的 artifact 返回空 mutation result 而不是错误。 - DELETE tombstone 持久化失败返回 `200 OK` + warning,因为当前 live delete 已生效但跨重启不保证。 @@ -616,7 +608,7 @@ type ArtifactPrincipal = - add ephemeral/restorable:需要 session mutate 权限。 - pin/save content:需要 session mutate 权限,并且必须是显式 REST/SDK call;“用户确认”在 V2 中不表示 agent permission-vote,而是 UI 或 headless client 主动调用 pin/save endpoint。 - delete metadata:需要 session mutate 权限。V1 same-principal delete guard 只能作为 live-process UX guard 和 audit hint;它依赖当前连接上下文,不能跨 daemon restart 证明 artifact owner。restore 后不能从 public `clientId` 伪造 ownership,删除授权退化为 session-level mutate 权限并记录 `ownership_unverified` audit。 -- delete content:需要 session mutate 权限、`session_artifacts_content_retention` capability 启用、显式 REST/SDK call,以及当前进程可验证的 creator-principal match 或显式 override/admin policy;background session/hook 不能直接发起 `deleteContent`。restore 后如果没有 durable owner proof,默认只能释放当前 artifact 引用并交给 GC,不能立即强删共享 content。若 contentRef 被多个 session 引用,也只能释放当前 artifact 引用,不能强删共享内容。 +- delete content:后续 content-retention PR 才启用;需要 session mutate 权限、`session_artifacts_content_retention` capability 启用、显式 REST/SDK call,以及当前进程可验证的 creator-principal match 或显式 override/admin policy;background session/hook 不能直接发起 `deleteContent`。restore 后如果没有 durable owner proof,默认只能释放当前 artifact 引用并交给 GC,不能立即强删共享 content。若 contentRef 被多个 session 引用,也只能释放当前 artifact 引用,不能强删共享内容。 如果未来需要真正的 `session_owner`,必须先设计 durable per-session capability 或 ACL,不能在本 V2 文档中隐式假设。 @@ -721,7 +713,9 @@ restore seed 不能超过 live store 上限;若历史里有效 persisted artif ### 8.2 Content quota -当前 PR 的硬编码默认: +本节是后续 content-retention PR 的实现范围;PR #6259 不引入 content store quota。 + +后续拆分 PR 的建议默认: - 单 artifact:50 MB。 - content store total:256 MB。 @@ -734,7 +728,7 @@ restore seed 不能超过 live store 上限;若历史里有效 persisted artif ### 8.3 GC -当前 PR 的 GC 只处理 daemon 管理的 session-scoped managed copy: +本节是后续 content-retention PR 的实现范围。GC 只处理 daemon 管理的 session-scoped managed copy: - content manifest 保存 `sessionId` 和 `artifactId`;GC 只删除 manifest 属于当前 session 且不在当前 live `contentRefs()` 引用集合中的 content。 - `pinWorkspaceFile()`、GC、tmp cleanup 通过同一个 write queue 串行化,并用 in-flight lease 避免并发 pin/GC 删除刚复制但尚未 journal 的 content。 @@ -757,7 +751,7 @@ Project-scoped reference rebuild、incomplete-scan tracking、orphan grace perio - explicit `pin/save metadata` 必须等待 journal 落盘。 - explicit `pin/save content` 必须等待 content manifest 和 journal 都落盘。 - explicit DELETE live-first:live store removal must not be blocked by journal failure; response warning tells clients when the tombstone was not durable. -- explicit DELETE with `deleteContent: true` runs best-effort session-scoped content GC after live removal; content delete warnings must be surfaced. +- explicit DELETE with `deleteContent: true` is only available in the content-retention follow-up; that PR must run best-effort session-scoped content GC after live removal and surface content delete warnings. - live cap eviction for durable artifacts writes an `eviction` remove event so restore respects the cap. - reader 容忍半截 JSONL 和 corrupt artifact record。 - tombstone / snapshot 顺序异常时选择不恢复,而不是猜测。 @@ -917,16 +911,16 @@ V2 新增的失败路径必须有 structured logs,格式沿用: - snapshot baseline advance 后 `stickyEphemeralIds` 仍能让隐式/default re-upsert 保持 live-only,并产生 `sticky_override_suppressed` log/metric/warning。 - `stickyEphemeralIds` 达到上限时,unpin-to-ephemeral 返回错误或延后重试,且不会静默丢失旧 sticky override。 - explicit DELETE live-first:live view 立即移除;tombstone 写入失败时 response 带 warning,测试覆盖 live removal 不被 persistence failure 阻断。 -- `deleteContent: true` 在 tombstone/content GC 有风险时暴露 `content_delete_preserved` warning。 +- content-retention follow-up 覆盖 `deleteContent: true` 在 tombstone/content GC 有风险时暴露 `content_delete_preserved` warning。 - durable artifact eviction 写 `eviction` remove event;restore 后不会超过 live cap。 - snapshot baseline advance:periodic snapshot 压缩当前 artifact list,explicit tombstone 在 snapshot 成功后不再无界增长,`stickyEphemeralIds` 保留 sticky state。 - workspace artifact ingest 和 restore 时文件存在/缺失/symlink escape 三种状态。 - workspace root 重定位:相同相对路径存在时恢复为 available;缺失或 layout 不一致时恢复为 missing;不做 path remap。 -- pin/save content 时拒绝 symlink、special file、oversized stream、hardlink 异常和 TOCTOU swap。 +- content-retention follow-up 覆盖 pin/save content 时拒绝 symlink、special file、oversized stream、hardlink 异常和 TOCTOU swap。 - external URL 只恢复 metadata,不发网络请求。 - secret-bearing URL query/fragment 与 metadata key/value 不写入 JSONL。 - published local `file:` 只有 trusted manifest revalidation 通过时恢复。 -- stale/tampered `contentRef` 无法绕过 daemon-managed manifest、size 和 hash 校验。 +- content-retention follow-up 覆盖 stale/tampered `contentRef` 无法绕过 daemon-managed manifest、size 和 hash 校验。 - `managedId` 在 ingest、restore 和 fork remap 时拒绝分隔符、`..`、绝对路径和路径形态;fork 不能盲目复制源 session 的 `managedId`。 - corrupt JSONL record 被跳过且不影响其它 artifacts。 - chat recording / persistence disabled 时不声明或不启用 metadata restore。 @@ -940,12 +934,12 @@ V2 新增的失败路径必须有 structured logs,格式沿用: - restore seed 与 concurrent POST 串行,不丢写、不重复。 - quota 边界:200 条、201 条 prune、pinned metadata、clientRetained/non-clientRetained 两层排序、全部 clientRetained restorable 仍可按确定性规则裁剪。 - clientRetained setter:Add artifact request 和 pin/save request 都能设置 boolean hint;后台自动 ingest 不能伪造用户保留。 -- GC:unpin、delete、close、explicit GC endpoint 都只删除当前 session manifest 且当前 live refs 未引用的 content。 -- GC concurrency:并发 pin/GC 通过 content-store write queue 和 leased content ids 串行/保护。 -- TTL scan:`GET /artifacts` 会降级过期 pinned content 并触发 best-effort session-scoped GC。 +- content-retention follow-up 覆盖 GC:unpin、delete、close、explicit GC endpoint 都只删除当前 session manifest 且当前 live refs 未引用的 content。 +- content-retention follow-up 覆盖 GC concurrency:并发 pin/GC 通过 content-store write queue 和 leased content ids 串行/保护。 +- content-retention follow-up 覆盖 TTL scan:`GET /artifacts` 会降级过期 pinned content 并触发 best-effort session-scoped GC。 - authorization:token-holder/principal 审计路径允许和拒绝情况;V1 live same-principal guard 仅作为 live UX/audit hint,不作为 durable security boundary。 -- restored artifact ownership_unverified fallback;deleteContent 在无 durable owner proof 时只释放引用,不立即强删共享 content。 -- partial writes:journal 成功但 warning、content manifest 成功但 journal 失败、journal 成功后 restore 能找到 content。 +- content-retention follow-up 覆盖 restored artifact ownership_unverified fallback;deleteContent 在无 durable owner proof 时只释放引用,不立即强删共享 content。 +- content-retention follow-up 覆盖 partial writes:journal 成功但 warning、content manifest 成功但 journal 失败、journal 成功后 restore 能找到 content。 - JSONL snapshot baseline advance:threshold 触发、post-snapshot replay 有界、snapshot payload 不再携带已被覆盖的 explicit tombstones、superseded sticky tombstone 允许显式同 id 重新出现、`stickyEphemeralIds` 保留 sticky state;JSONL 文件本身不被 artifact 子系统重写。 - corrupt latest snapshot fallback:回退到较旧 valid snapshot 或一次顺序 artifact replay。 - repin idempotency:已 pinned artifact 的空重复 pin 不刷新内容、不延长 TTL;显式 `mode` / `ttlDays` / `clientRetained` 会按 §6.3 更新对应状态。 @@ -955,9 +949,9 @@ V2 新增的失败路径必须有 structured logs,格式沿用: - SDK 旧 client 忽略 optional fields 后仍能展示 V1 artifacts。 - V2 -> V1 rollback compatibility:旧 daemon 必须能解析或忽略 unknown `system` subtype,不得导致 session load 崩溃;回滚后 artifact persistence 不恢复是可接受降级。如果当前最低支持版本不能保证这一点,V2 writer 必须 capability-gate 到支持 unknown system record 的版本之后。 - rollback preflight:最低支持旧 daemon 版本加载包含 V2 event/snapshot 的 JSONL;如果未来加入 fork marker,再扩展 rollback fixture。 -- artifact fsck dry-run:dangling contentRef、orphan content、snapshot invalid 和 repair-safe actions。 +- content-retention follow-up 覆盖 artifact fsck dry-run:dangling contentRef、orphan content、snapshot invalid 和 repair-safe actions。 - metadata-only fsck dry-run:corrupt record、snapshot fallback、orphan tombstone、restore validation failure。 -- API response contract:pin/unpin/delete success body、metadata/content quota validation failure、`remove_not_persisted` / `content_delete_preserved` / `persistence_unavailable` / `sticky_override_active` / `content_expired` warning、current 400/403/200+warning mapping。 +- PR #6259 覆盖 metadata API response contract:delete success body、metadata quota validation failure、`remove_not_persisted` / `persistence_unavailable` / `sticky_override_active` warning、current 400/403/200+warning mapping。content-retention follow-up 覆盖 pin/unpin、content quota、`content_delete_preserved` / `content_expired` warning。 ## 11. 不建议在 V2 做的事 @@ -985,7 +979,7 @@ V2 建议作为一个完整 design phase 发布,但能力按 capability 暴露 Rollback procedure: - V2 records 保留在 chat JSONL 中,不在 rollback 时删除;旧 daemon 能忽略 unknown `system` subtype 时,session load 应继续工作但不恢复 artifact persistence。 -- daemon-managed content storage 不由旧 daemon 读取;rollback 后 pinned content 只是未被引用的 retained bytes。再次升级到 V2 后可恢复引用;若决定永久回滚,管理员运行 full `fsck --cleanup-orphans` dry-run/confirm 流程清理。 +- daemon-managed content storage 不属于 PR #6259;后续 content-retention PR 需要单独定义 rollback 后 retained bytes 的清理流程。 - 如果当前最低支持旧版本不能安全忽略 V2 system records,writer 必须 capability-gate 到安全版本之后,或者在升级前提供 migration guard,阻止写入 V2 records。 - 发布前 CI 必须用最低支持旧 daemon 版本加载包含 `session_artifact_event` 和 `session_artifact_snapshot` 的 JSONL,断言 session load 成功且 unknown subtype 被忽略。V2 writer 首次初始化前也要检查版本/feature gate;失败时拒绝写 V2 records,记录 `v2_writer_version_gate_failed`,保持 V1 行为。如果未来加入 fork marker,再把该 subtype 纳入 rollback fixture。 - rollback 后 client 不能依赖 `session_artifacts_persistence` / `session_artifacts_content_retention`,因为旧 daemon 不声明这些 capability。 diff --git a/integration-tests/cli/qwen-serve-routes.test.ts b/integration-tests/cli/qwen-serve-routes.test.ts index f4f66b38f4e..3de8c3a14f7 100644 --- a/integration-tests/cli/qwen-serve-routes.test.ts +++ b/integration-tests/cli/qwen-serve-routes.test.ts @@ -249,7 +249,6 @@ describe('qwen serve — capabilities envelope', () => { 'session_events', 'session_artifacts', 'session_artifacts_persistence', - 'session_artifacts_content_retention', 'slow_client_warning', 'typed_event_schema', 'session_set_model', diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index 2acde1e5c56..7636feaefb1 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -61,7 +61,6 @@ import { WS_B, SESS_A, } from './internal/testUtils.js'; -import { SessionArtifactContentStore } from './sessionArtifactContentStore.js'; import { SessionArtifactAuthorizationError } from './sessionArtifacts.js'; function deferred(): { @@ -196,41 +195,6 @@ describe('createAcpSessionBridge', () => { } }); - it('validates artifact remove options before changing live state', async () => { - const bridge = makeBridge({ - channelFactory: async () => makeChannel().channel, - }); - const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); - try { - const created = await bridge.addSessionArtifact( - session.sessionId, - { - title: 'Client link', - url: 'https://example.com/client', - }, - { clientId: session.clientId }, - ); - const artifactId = created.changes[0]!.artifactId; - - await expect( - bridge.removeSessionArtifact( - session.sessionId, - artifactId, - { clientId: session.clientId }, - { deleteContent: 'yes' } as unknown as { deleteContent: boolean }, - ), - ).rejects.toThrow(/deleteContent must be a boolean/); - - await expect( - bridge.getSessionArtifacts(session.sessionId), - ).resolves.toMatchObject({ - artifacts: [{ id: artifactId }], - }); - } finally { - await bridge.shutdown(); - } - }); - it('rejects invalid client artifact records instead of dropping them', async () => { const bridge = makeBridge({ channelFactory: async () => makeChannel().channel, @@ -252,330 +216,6 @@ describe('createAcpSessionBridge', () => { } }); - it('does not prune or GC expired pins during artifact GET', async () => { - const previousQwenHome = process.env['QWEN_HOME']; - const tempHome = await fsp.mkdtemp(path.join(os.tmpdir(), 'qwen-home-')); - const workspace = await fsp.mkdtemp( - path.join(os.tmpdir(), 'qwen-artifact-workspace-'), - ); - process.env['QWEN_HOME'] = tempHome; - vi.useFakeTimers(); - vi.setSystemTime(new Date('2026-07-04T00:00:00.000Z')); - const gcSpy = vi - .spyOn(SessionArtifactContentStore.prototype, 'gc') - .mockRejectedValueOnce(new Error('disk unavailable')); - const bridge = makeBridge({ - boundWorkspace: workspace, - channelFactory: async () => makeChannel().channel, - }); - try { - await fsp.mkdir(path.join(workspace, 'reports'), { recursive: true }); - await fsp.writeFile(path.join(workspace, 'reports', 'report.txt'), 'hi'); - const session = await bridge.spawnOrAttach({ workspaceCwd: workspace }); - const created = await bridge.addSessionArtifact( - session.sessionId, - { - title: 'Report', - workspacePath: 'reports/report.txt', - }, - { clientId: session.clientId }, - ); - const artifactId = created.changes[0]!.artifactId; - await bridge.pinSessionArtifact( - session.sessionId, - artifactId, - { clientId: session.clientId }, - { mode: 'content', ttlDays: 1 }, - ); - - vi.setSystemTime(new Date('2026-07-06T00:00:00.000Z')); - await expect( - bridge.getSessionArtifacts(session.sessionId), - ).resolves.toMatchObject({ - artifacts: [ - { - id: artifactId, - retention: 'pinned', - }, - ], - }); - expect(gcSpy).not.toHaveBeenCalled(); - } finally { - gcSpy.mockRestore(); - vi.useRealTimers(); - await bridge.shutdown(); - if (previousQwenHome === undefined) { - delete process.env['QWEN_HOME']; - } else { - process.env['QWEN_HOME'] = previousQwenHome; - } - await fsp.rm(tempHome, { recursive: true, force: true }); - await fsp.rm(workspace, { recursive: true, force: true }); - } - }); - - it('prunes expired pins during artifact mutations and reports GC warnings', async () => { - const previousQwenHome = process.env['QWEN_HOME']; - const tempHome = await fsp.mkdtemp(path.join(os.tmpdir(), 'qwen-home-')); - const workspace = await fsp.mkdtemp( - path.join(os.tmpdir(), 'qwen-artifact-workspace-'), - ); - process.env['QWEN_HOME'] = tempHome; - vi.useFakeTimers(); - vi.setSystemTime(new Date('2026-07-04T00:00:00.000Z')); - const gcSpy = vi - .spyOn(SessionArtifactContentStore.prototype, 'gc') - .mockRejectedValueOnce(new Error('disk unavailable')); - const bridge = makeBridge({ - boundWorkspace: workspace, - channelFactory: async () => makeChannel().channel, - }); - try { - await fsp.mkdir(path.join(workspace, 'reports'), { recursive: true }); - await fsp.writeFile(path.join(workspace, 'reports', 'report.txt'), 'hi'); - const session = await bridge.spawnOrAttach({ workspaceCwd: workspace }); - const created = await bridge.addSessionArtifact( - session.sessionId, - { - title: 'Report', - workspacePath: 'reports/report.txt', - }, - { clientId: session.clientId }, - ); - const artifactId = created.changes[0]!.artifactId; - await bridge.pinSessionArtifact( - session.sessionId, - artifactId, - { clientId: session.clientId }, - { mode: 'content', ttlDays: 1 }, - ); - - vi.setSystemTime(new Date('2026-07-06T00:00:00.000Z')); - await expect( - bridge.addSessionArtifact( - session.sessionId, - { - title: 'New link', - url: 'https://example.com/new-link', - }, - { clientId: session.clientId }, - ), - ).resolves.toMatchObject({ - warnings: ['artifact content GC failed; stale content retained'], - }); - expect(gcSpy).toHaveBeenCalledTimes(1); - await expect( - bridge.getSessionArtifacts(session.sessionId), - ).resolves.toMatchObject({ - artifacts: expect.arrayContaining([ - expect.objectContaining({ - id: artifactId, - retention: 'restorable', - persistenceWarning: 'content_expired', - }), - ]), - }); - } finally { - gcSpy.mockRestore(); - vi.useRealTimers(); - await bridge.shutdown(); - if (previousQwenHome === undefined) { - delete process.env['QWEN_HOME']; - } else { - process.env['QWEN_HOME'] = previousQwenHome; - } - await fsp.rm(tempHome, { recursive: true, force: true }); - await fsp.rm(workspace, { recursive: true, force: true }); - } - }); - - it('applies metadata pin options to an already pinned artifact', async () => { - const previousQwenHome = process.env['QWEN_HOME']; - const tempHome = await fsp.mkdtemp(path.join(os.tmpdir(), 'qwen-home-')); - const workspace = await fsp.mkdtemp( - path.join(os.tmpdir(), 'qwen-artifact-workspace-'), - ); - process.env['QWEN_HOME'] = tempHome; - const bridge = makeBridge({ - boundWorkspace: workspace, - channelFactory: async () => makeChannel().channel, - }); - try { - await fsp.mkdir(path.join(workspace, 'reports'), { recursive: true }); - await fsp.writeFile(path.join(workspace, 'reports', 'report.txt'), 'hi'); - const session = await bridge.spawnOrAttach({ workspaceCwd: workspace }); - const created = await bridge.addSessionArtifact( - session.sessionId, - { - title: 'Report', - workspacePath: 'reports/report.txt', - }, - { clientId: session.clientId }, - ); - const artifactId = created.changes[0]!.artifactId; - await bridge.pinSessionArtifact( - session.sessionId, - artifactId, - { clientId: session.clientId }, - { mode: 'content' }, - ); - const gcSpy = vi.spyOn(SessionArtifactContentStore.prototype, 'gc'); - - await expect( - bridge.pinSessionArtifact( - session.sessionId, - artifactId, - { clientId: session.clientId }, - { mode: 'metadata' }, - ), - ).resolves.toMatchObject({ - changes: [ - { - action: 'updated', - artifactId, - artifact: { - retention: 'restorable', - persistenceWarning: 'metadata_only_restore', - }, - }, - ], - }); - expect(gcSpy).toHaveBeenCalledTimes(1); - } finally { - await bridge.shutdown(); - if (previousQwenHome === undefined) { - delete process.env['QWEN_HOME']; - } else { - process.env['QWEN_HOME'] = previousQwenHome; - } - await fsp.rm(tempHome, { recursive: true, force: true }); - await fsp.rm(workspace, { recursive: true, force: true }); - } - }); - - it('refreshes retained content on explicit content re-pin', async () => { - const previousQwenHome = process.env['QWEN_HOME']; - const tempHome = await fsp.mkdtemp(path.join(os.tmpdir(), 'qwen-home-')); - const workspace = await fsp.mkdtemp( - path.join(os.tmpdir(), 'qwen-artifact-workspace-'), - ); - process.env['QWEN_HOME'] = tempHome; - const bridge = makeBridge({ - boundWorkspace: workspace, - channelFactory: async () => makeChannel().channel, - }); - try { - await fsp.mkdir(path.join(workspace, 'reports'), { recursive: true }); - await fsp.writeFile( - path.join(workspace, 'reports', 'report.txt'), - 'first', - ); - const session = await bridge.spawnOrAttach({ workspaceCwd: workspace }); - const created = await bridge.addSessionArtifact( - session.sessionId, - { - title: 'Report', - workspacePath: 'reports/report.txt', - }, - { clientId: session.clientId }, - ); - const artifactId = created.changes[0]!.artifactId; - const firstPin = await bridge.pinSessionArtifact( - session.sessionId, - artifactId, - { clientId: session.clientId }, - { mode: 'content' }, - ); - const firstContentId = - firstPin.changes[0]?.artifact?.contentRef?.contentId; - const gcSpy = vi.spyOn(SessionArtifactContentStore.prototype, 'gc'); - - await fsp.writeFile( - path.join(workspace, 'reports', 'report.txt'), - 'second', - ); - const secondPin = await bridge.pinSessionArtifact( - session.sessionId, - artifactId, - { clientId: session.clientId }, - { mode: 'content' }, - ); - - expect( - secondPin.changes[0]?.artifact?.contentRef?.contentId, - ).toBeTruthy(); - expect(secondPin.changes[0]?.artifact?.contentRef?.contentId).not.toBe( - firstContentId, - ); - expect(gcSpy).toHaveBeenCalledTimes(1); - } finally { - await bridge.shutdown(); - if (previousQwenHome === undefined) { - delete process.env['QWEN_HOME']; - } else { - process.env['QWEN_HOME'] = previousQwenHome; - } - await fsp.rm(tempHome, { recursive: true, force: true }); - await fsp.rm(workspace, { recursive: true, force: true }); - } - }); - - it('keeps unpin successful when best-effort content GC fails', async () => { - const previousQwenHome = process.env['QWEN_HOME']; - const tempHome = await fsp.mkdtemp(path.join(os.tmpdir(), 'qwen-home-')); - const workspace = await fsp.mkdtemp( - path.join(os.tmpdir(), 'qwen-artifact-workspace-'), - ); - process.env['QWEN_HOME'] = tempHome; - const bridge = makeBridge({ - boundWorkspace: workspace, - channelFactory: async () => makeChannel().channel, - }); - const gcSpy = vi - .spyOn(SessionArtifactContentStore.prototype, 'gc') - .mockRejectedValueOnce(new Error('disk unavailable')); - try { - await fsp.mkdir(path.join(workspace, 'reports'), { recursive: true }); - await fsp.writeFile(path.join(workspace, 'reports', 'report.txt'), 'hi'); - const session = await bridge.spawnOrAttach({ workspaceCwd: workspace }); - const created = await bridge.addSessionArtifact( - session.sessionId, - { - title: 'Report', - workspacePath: 'reports/report.txt', - }, - { clientId: session.clientId }, - ); - const artifactId = created.changes[0]!.artifactId; - await bridge.pinSessionArtifact( - session.sessionId, - artifactId, - { clientId: session.clientId }, - { mode: 'content' }, - ); - - await expect( - bridge.unpinSessionArtifact(session.sessionId, artifactId, { - clientId: session.clientId, - }), - ).resolves.toMatchObject({ - changes: [{ action: 'updated', artifactId }], - warnings: ['content_delete_preserved'], - }); - expect(gcSpy).toHaveBeenCalledTimes(1); - } finally { - gcSpy.mockRestore(); - await bridge.shutdown(); - if (previousQwenHome === undefined) { - delete process.env['QWEN_HOME']; - } else { - process.env['QWEN_HOME'] = previousQwenHome; - } - await fsp.rm(tempHome, { recursive: true, force: true }); - await fsp.rm(workspace, { recursive: true, force: true }); - } - }); - it('uses bridge telemetry for channel/session/prompt dispatch and prompt metadata injection', async () => { const handle = makeChannel(); const operations: string[] = []; @@ -7448,11 +7088,6 @@ describe('createAcpSessionBridge', () => { { clientId: 'client-not-issued' }, ), ).rejects.toBeInstanceOf(InvalidClientIdError); - await expect( - bridge.fsckSessionArtifacts(session.sessionId, { - clientId: 'client-not-issued', - }), - ).rejects.toBeInstanceOf(InvalidClientIdError); await bridge.shutdown(); }); diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index fb1761a4b7a..ff7a28d2585 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -20,7 +20,6 @@ import type { import type { ApprovalMode, RebuiltSessionArtifactSnapshot, - SessionArtifactContentRef, } from '@qwen-code/qwen-code-core'; import { DAEMON_TRACEPARENT_META_KEY, @@ -104,9 +103,6 @@ import type { BridgeWorkspaceMemoryForgetMatch, BridgeWorkspaceMemoryRememberRequest, BridgeWorkspaceMemoryRememberResult, - SessionArtifactPinRequest, - SessionArtifactRemoveRequest, - SessionArtifactUnpinRequest, } from './bridgeTypes.js'; import type { BridgeOptions, BridgeTelemetry } from './bridgeOptions.js'; import { MCP_RESTART_SERVER_DEADLINE_MS } from './mcpTimeouts.js'; @@ -122,14 +118,12 @@ import { import { PermissionForbiddenError } from './bridgeErrors.js'; import { SessionArtifactStore, - SessionArtifactValidationError, publicArtifactsEqual, type DaemonSessionArtifact, type SessionArtifactChange, type SessionArtifactInput, type SessionArtifactMutationResult, } from './sessionArtifacts.js'; -import { SessionArtifactContentStore } from './sessionArtifactContentStore.js'; const NOOP_BRIDGE_TELEMETRY: BridgeTelemetry = { captureContext: () => undefined, @@ -987,8 +981,6 @@ function extractPromptText( const DEFAULT_INIT_TIMEOUT_MS = 10_000; const PERSIST_TIMEOUT_MS = 5_000; -const ARTIFACT_TTL_DAY_MS = 24 * 60 * 60 * 1000; -const ARTIFACT_MAX_TTL_DAYS = 365; const MCP_RESTART_TIMEOUT_MS = 300_000; const WORKSPACE_MEMORY_REMEMBER_TIMEOUT_MS = 300_000; const MCP_OAUTH_TIMEOUT_MS = 600_000; @@ -1403,7 +1395,6 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { // daemon. Cleared in the `finally` of the creator. let inFlightChannelSpawn: Promise | undefined; const byId = new Map(); - const artifactContentStore = new SessionArtifactContentStore(); const toSessionSummary = (entry: SessionEntry): BridgeSessionSummary => ({ sessionId: entry.sessionId, workspaceCwd: entry.workspaceCwd, @@ -2701,129 +2692,6 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { return input; }; - const artifactExpiresAt = ( - options: SessionArtifactPinRequest | undefined, - mode: 'metadata' | 'content', - ): string | undefined => { - if (options?.ttlDays === undefined) return undefined; - if (mode !== 'content') { - throw new SessionArtifactValidationError( - 'ttlDays is only valid with content pinning', - 'ttlDays', - ); - } - if (!Number.isSafeInteger(options.ttlDays) || options.ttlDays <= 0) { - throw new SessionArtifactValidationError( - 'ttlDays must be a positive safe integer', - 'ttlDays', - ); - } - if (options.ttlDays > ARTIFACT_MAX_TTL_DAYS) { - throw new SessionArtifactValidationError( - `ttlDays must be at most ${ARTIFACT_MAX_TTL_DAYS}`, - 'ttlDays', - ); - } - const expiresAtMs = Date.now() + options.ttlDays * ARTIFACT_TTL_DAY_MS; - if (!Number.isSafeInteger(expiresAtMs)) { - throw new SessionArtifactValidationError( - 'ttlDays is too large', - 'ttlDays', - ); - } - return new Date(expiresAtMs).toISOString(); - }; - - const artifactPinMode = ( - options: SessionArtifactPinRequest | undefined, - ): 'metadata' | 'content' => { - const mode = options?.mode ?? 'content'; - if (mode !== 'metadata' && mode !== 'content') { - throw new SessionArtifactValidationError( - 'mode must be "metadata" or "content"', - 'mode', - ); - } - return mode; - }; - - const artifactRemoveOptions = ( - options: SessionArtifactRemoveRequest | undefined, - ): SessionArtifactRemoveRequest => { - if (options?.deleteContent === undefined) return {}; - if (typeof options.deleteContent !== 'boolean') { - throw new SessionArtifactValidationError( - 'deleteContent must be a boolean', - 'deleteContent', - ); - } - return { deleteContent: options.deleteContent }; - }; - - const artifactClientRetained = ( - options: SessionArtifactPinRequest | undefined, - ): boolean | undefined => { - if (options?.clientRetained === undefined) return undefined; - if (typeof options.clientRetained !== 'boolean') { - throw new SessionArtifactValidationError( - 'clientRetained must be a boolean', - 'clientRetained', - ); - } - return options.clientRetained; - }; - - const artifactUnpinOptions = ( - options: SessionArtifactUnpinRequest | undefined, - ): SessionArtifactUnpinRequest => { - if (options?.retention === undefined) return {}; - if ( - options.retention !== 'ephemeral' && - options.retention !== 'restorable' - ) { - throw new SessionArtifactValidationError( - 'retention must be "ephemeral" or "restorable"', - 'retention', - ); - } - return { retention: options.retention }; - }; - - const gcArtifactContent = async (entry: SessionEntry): Promise => { - const refs = await entry.artifacts.contentRefs(); - const releaseRefs = artifactContentStore.leaseContentRefs(refs); - try { - await artifactContentStore.gc( - entry.sessionId, - new Set(refs.map((ref) => ref.contentId)), - ); - } finally { - releaseRefs(); - } - }; - - const pruneExpiredArtifactPins = async ( - entry: SessionEntry, - originatorClientId?: string, - ): Promise => { - const pruned = await entry.artifacts.pruneExpiredPins(); - const warnings = [...(pruned.warnings ?? [])]; - if (pruned.changes.length > 0) { - publishArtifactChanges(entry, pruned.changes, originatorClientId); - try { - await gcArtifactContent(entry); - } catch (error) { - warnings.push('artifact content GC failed; stale content retained'); - writeStderrLine( - `[artifacts] session=${entry.sessionId} action=ttl_prune_gc_failed reason=${JSON.stringify( - error instanceof Error ? error.message : String(error), - )}`, - ); - } - } - return warnings; - }; - function createSessionArtifactPersistence( connection: ClientSideConnection, sessionId: string, @@ -3241,16 +3109,6 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { seedSnapshotCaches(entry, state); const artifactRestoreWarnings = await entry.artifacts.restore( restoredArtifactSnapshotFromState(state), - { - verifyContentRef: (artifact) => - artifact.contentRef - ? artifactContentStore.verifyContentRef( - entry.sessionId, - artifact.id, - artifact.contentRef, - ) - : Promise.resolve(undefined), - }, ); for (const warning of artifactRestoreWarnings) { writeStderrLine( @@ -3404,15 +3262,6 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { // from the (now-defunct) child can't seed the early-event buffer // and leak into a future load/resume of the same persisted id. ci?.client.markSessionClosed(sessionId); - try { - await gcArtifactContent(entry); - } catch (error) { - writeStderrLine( - `qwen serve: session artifact GC failed during close for ${JSON.stringify( - sessionId, - )}: ${error instanceof Error ? error.message : String(error)}`, - ); - } try { entry.events.publish({ type: 'session_closed', @@ -4715,188 +4564,23 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { persistenceStrict: false, }); publishArtifactChanges(entry, result.changes, clientId); - const warnings = [ - ...(result.warnings ?? []), - ...(await pruneExpiredArtifactPins(entry, clientId)), - ]; - return warnings.length > 0 ? { ...result, warnings } : result; - }, - - async removeSessionArtifact(sessionId, artifactId, context, options) { - const entry = byId.get(sessionId); - if (!entry) throw new SessionNotFoundError(sessionId); - const clientId = resolveTrustedClientId(entry, context?.clientId); - const removeOptions = artifactRemoveOptions(options); - const artifact = await entry.artifacts.get(artifactId); - if (!artifact) { - return { v: 1, sessionId, changes: [] }; - } - const result = await entry.artifacts.remove(artifactId, { clientId }); const warnings = [...(result.warnings ?? [])]; - if (result.changes.length > 0 && removeOptions.deleteContent !== false) { - warnings.push(...(await pruneExpiredArtifactPins(entry, clientId))); - try { - await gcArtifactContent(entry); - } catch (error) { - if (removeOptions.deleteContent === true || artifact.contentRef) { - warnings.push('content_delete_preserved'); - } - writeStderrLine( - `[artifacts] session=${entry.sessionId} action=remove_gc_failed reason=${JSON.stringify( - error instanceof Error ? error.message : String(error), - )}`, - ); - } - } - publishArtifactChanges(entry, result.changes, clientId); return warnings.length > 0 ? { ...result, warnings } : result; }, - async pinSessionArtifact(sessionId, artifactId, context, options) { + async removeSessionArtifact(sessionId, artifactId, context) { const entry = byId.get(sessionId); if (!entry) throw new SessionNotFoundError(sessionId); const clientId = resolveTrustedClientId(entry, context?.clientId); - const requestOptions = - options !== undefined && Object.keys(options).length > 0 - ? options - : undefined; - const mode = artifactPinMode(requestOptions); - const clientRetained = artifactClientRetained(requestOptions); - const expiresAt = artifactExpiresAt(requestOptions, mode); - const pruneWarnings = await pruneExpiredArtifactPins(entry, clientId); - const currentArtifact = await entry.artifacts.get(artifactId); - if (!currentArtifact) { - return { v: 1, sessionId, changes: [] }; - } - const refreshPinnedContent = - currentArtifact.retention === 'pinned' && - requestOptions?.mode === 'content'; - if (currentArtifact.retention === 'pinned' && !refreshPinnedContent) { - const result = - requestOptions === undefined - ? await entry.artifacts.pin(artifactId, { clientId }) - : await entry.artifacts.pin(artifactId, { - retention: mode === 'metadata' ? 'restorable' : 'pinned', - ...(expiresAt !== undefined ? { expiresAt } : {}), - ...(clientRetained !== undefined ? { clientRetained } : {}), - clientId, - }); - const warnings = [...pruneWarnings, ...(result.warnings ?? [])]; - if (requestOptions?.mode === 'metadata' && result.changes.length > 0) { - try { - await gcArtifactContent(entry); - } catch (error) { - warnings.push('content_delete_preserved'); - writeStderrLine( - `[artifacts] session=${entry.sessionId} action=pin_metadata_gc_failed reason=${JSON.stringify( - error instanceof Error ? error.message : String(error), - )}`, - ); - } - } - publishArtifactChanges(entry, result.changes, clientId); - return warnings.length > 0 ? { ...result, warnings } : result; - } - let contentRef: SessionArtifactContentRef | undefined; - try { - contentRef = - mode === 'metadata' - ? undefined - : await artifactContentStore.pinWorkspaceFile( - sessionId, - currentArtifact, - entry.workspaceCwd, - ); - if (mode === 'content' && !contentRef) { - throw new SessionArtifactValidationError( - 'artifact content is not available for retention', - 'artifactId', - ); - } - const result = await entry.artifacts.pin(artifactId, { - retention: mode === 'metadata' ? 'restorable' : 'pinned', - ...(contentRef ? { contentRef } : {}), - ...(expiresAt !== undefined ? { expiresAt } : {}), - ...(clientRetained !== undefined ? { clientRetained } : {}), - clientId, - }); - if (contentRef) { - artifactContentStore.releaseContentRef(contentRef); - await gcArtifactContent(entry).catch(() => undefined); - } - if (contentRef && result.changes.length === 0) { - if (!(await entry.artifacts.get(artifactId))) { - throw new SessionArtifactValidationError( - 'artifact no longer exists', - 'artifactId', - ); - } - } - publishArtifactChanges(entry, result.changes, clientId); - const warnings = [...pruneWarnings, ...(result.warnings ?? [])]; - return warnings.length > 0 ? { ...result, warnings } : result; - } catch (error) { - if (contentRef) { - artifactContentStore.releaseContentRef(contentRef); - await gcArtifactContent(entry).catch(() => undefined); - } - throw error; - } - }, - - async unpinSessionArtifact(sessionId, artifactId, context, options) { - const entry = byId.get(sessionId); - if (!entry) throw new SessionNotFoundError(sessionId); - const clientId = resolveTrustedClientId(entry, context?.clientId); - const unpinOptions = artifactUnpinOptions(options); if (!(await entry.artifacts.get(artifactId))) { return { v: 1, sessionId, changes: [] }; } - const pruneWarnings = await pruneExpiredArtifactPins(entry, clientId); - const result = await entry.artifacts.unpin(artifactId, { - ...unpinOptions, - clientId, - }); - const warnings = [...pruneWarnings, ...(result.warnings ?? [])]; - if (result.changes.length > 0) { - try { - await gcArtifactContent(entry); - } catch (error) { - warnings.push('content_delete_preserved'); - writeStderrLine( - `[artifacts] session=${entry.sessionId} action=unpin_gc_failed reason=${JSON.stringify( - error instanceof Error ? error.message : String(error), - )}`, - ); - } - } + const result = await entry.artifacts.remove(artifactId, { clientId }); publishArtifactChanges(entry, result.changes, clientId); + const warnings = [...(result.warnings ?? [])]; return warnings.length > 0 ? { ...result, warnings } : result; }, - async fsckSessionArtifacts(sessionId, context) { - const entry = byId.get(sessionId); - if (!entry) throw new SessionNotFoundError(sessionId); - resolveTrustedClientId(entry, context?.clientId); - return artifactContentStore.fsck(await entry.artifacts.contentRefs()); - }, - - async gcSessionArtifacts(sessionId, context) { - const entry = byId.get(sessionId); - if (!entry) throw new SessionNotFoundError(sessionId); - resolveTrustedClientId(entry, context?.clientId); - const refs = await entry.artifacts.contentRefs(); - const releaseRefs = artifactContentStore.leaseContentRefs(refs); - try { - return await artifactContentStore.gc( - sessionId, - new Set(refs.map((ref) => ref.contentId)), - ); - } finally { - releaseRefs(); - } - }, - listWorkspaceSessions(workspaceCwd) { if (!path.isAbsolute(workspaceCwd)) return []; const key = @@ -6249,19 +5933,8 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { response as BridgeSessionState, ); const beforeArtifacts = (await entry.artifacts.list()).artifacts; - const artifactRestoreWarnings = await entry.artifacts.restore( - artifactSnapshot, - { - verifyContentRef: (artifact) => - artifact.contentRef - ? artifactContentStore.verifyContentRef( - entry.sessionId, - artifact.id, - artifact.contentRef, - ) - : Promise.resolve(undefined), - }, - ); + const artifactRestoreWarnings = + await entry.artifacts.restore(artifactSnapshot); for (const warning of artifactRestoreWarnings) { writeStderrLine( `[artifacts] session=${entry.sessionId} action=rewind_restore_warning warning=${JSON.stringify( @@ -6275,14 +5948,6 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { artifactReseedChanges(beforeArtifacts, afterArtifacts), originatorClientId, ); - await gcArtifactContent(entry).catch((error) => { - writeStderrLine( - `qwen serve: session artifact GC failed during rewind for ${JSON.stringify( - sessionId, - )}: ${error instanceof Error ? error.message : String(error)}`, - ); - }); - try { entry.events.publish({ type: 'session_rewound', diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts index d47ea2fd5db..44ec764cf93 100644 --- a/packages/acp-bridge/src/bridgeTypes.ts +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -27,10 +27,6 @@ import type { SessionArtifactMutationResult, SessionArtifactsEnvelope, } from './sessionArtifacts.js'; -import type { - SessionArtifactFsckResult, - SessionArtifactGcResult, -} from './sessionArtifactContentStore.js'; import type { ServeSessionContextStatus, ServeSessionHooksStatus, @@ -65,20 +61,6 @@ export interface RewindResponse { filesFailed: string[]; } -export interface SessionArtifactPinRequest { - mode?: 'metadata' | 'content'; - ttlDays?: number; - clientRetained?: boolean; -} - -export interface SessionArtifactRemoveRequest { - deleteContent?: boolean; -} - -export interface SessionArtifactUnpinRequest { - retention?: 'ephemeral' | 'restorable'; -} - export interface BridgeSpawnRequest { /** Absolute path to the workspace root the child inherits as cwd. */ workspaceCwd: string; @@ -606,33 +588,8 @@ export interface AcpSessionBridge { sessionId: string, artifactId: string, context?: BridgeClientRequestContext, - options?: SessionArtifactRemoveRequest, - ): Promise; - - pinSessionArtifact( - sessionId: string, - artifactId: string, - context?: BridgeClientRequestContext, - options?: SessionArtifactPinRequest, ): Promise; - unpinSessionArtifact( - sessionId: string, - artifactId: string, - context?: BridgeClientRequestContext, - options?: SessionArtifactUnpinRequest, - ): Promise; - - fsckSessionArtifacts( - sessionId: string, - context?: BridgeClientRequestContext, - ): Promise; - - gcSessionArtifacts( - sessionId: string, - context?: BridgeClientRequestContext, - ): Promise; - /** * Cast a vote on a pending `permission_request` (first-responder wins). */ diff --git a/packages/acp-bridge/src/sessionArtifactContentStore.ts b/packages/acp-bridge/src/sessionArtifactContentStore.ts deleted file mode 100644 index 99e7c70e034..00000000000 --- a/packages/acp-bridge/src/sessionArtifactContentStore.ts +++ /dev/null @@ -1,649 +0,0 @@ -/** - * @license - * Copyright 2026 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import { createHash } from 'node:crypto'; -import fsSync from 'node:fs'; -import { promises as fs } from 'node:fs'; -import type { FileHandle } from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import type { SessionArtifactContentRef } from '@qwen-code/qwen-code-core'; -import type { SessionArtifactPersistenceWarning } from '@qwen-code/qwen-code-core'; -import { writeStderrLine } from './internal/stderrLine.js'; -import type { DaemonSessionArtifact } from './sessionArtifacts.js'; -import { SessionArtifactValidationError } from './sessionArtifacts.js'; - -const CONTENT_FORMAT_VERSION = 1; -const MAX_PINNED_FILE_BYTES = 50 * 1024 * 1024; -const MAX_CONTENT_STORE_BYTES = 256 * 1024 * 1024; -const CONTENT_ID_PATTERN = /^[0-9a-f]{64}-[0-9a-f]{16}$/; - -interface ContentManifest { - v: typeof CONTENT_FORMAT_VERSION; - contentId: string; - sessionId: string; - artifactId: string; - workspacePath: string; - sha256: string; - sizeBytes: number; - createdAt: string; -} - -export interface SessionArtifactFsckResult { - checked: number; - missing: string[]; - hashMismatches: string[]; -} - -export interface SessionArtifactGcResult { - removed: string[]; - retained: string[]; -} - -export class SessionArtifactContentStore { - private readonly rootDir: string; - private writeQueue: Promise = Promise.resolve(); - private readonly leasedContentIds = new Map(); - private cachedTotalBytes: number | undefined; - - constructor(rootDir = defaultContentRoot()) { - this.rootDir = rootDir; - } - - async pinWorkspaceFile( - sessionId: string, - artifact: DaemonSessionArtifact, - workspaceCwd: string, - ): Promise { - if (artifact.storage !== 'workspace' || !artifact.workspacePath) { - return undefined; - } - const workspacePath = artifact.workspacePath; - const source = await resolveWorkspaceFile( - workspaceCwd, - workspacePath, - ).catch((error: unknown) => { - if (error instanceof SessionArtifactValidationError) { - throw error; - } - throw new SessionArtifactValidationError( - `workspacePath could not be inspected: ${ - error instanceof Error ? error.message : String(error) - }`, - 'artifactId', - ); - }); - return this.enqueueWrite(async () => { - const tmpDir = path.join(this.rootDir, '.tmp'); - await fs.mkdir(tmpDir, { recursive: true, mode: 0o700 }); - let tmpPath: string | undefined = path.join( - tmpDir, - `${process.pid}-${Date.now()}-${artifact.id}.bin`, - ); - let sourceHandle: FileHandle | undefined; - let addedSizeBytes = 0; - try { - sourceHandle = await openRegularWorkspaceFile(source); - const { sha256, sizeBytes } = await copyOpenFileToTemp( - sourceHandle, - tmpPath, - ); - - const contentId = `${sha256}-${stableContentSuffix( - sessionId, - artifact.id, - )}`; - const contentDir = path.join(this.rootDir, contentId); - const dataPath = path.join(contentDir, 'content'); - if (await exists(dataPath)) { - await fs.rm(tmpPath, { force: true }); - tmpPath = undefined; - } else { - const usedBytes = await this.getUsedBytes(); - if (usedBytes + sizeBytes > MAX_CONTENT_STORE_BYTES) { - throw new SessionArtifactValidationError( - 'Artifact content quota exceeded', - 'artifactId', - ); - } - await fs.mkdir(contentDir, { recursive: true, mode: 0o700 }); - await fs.rename(tmpPath, dataPath); - tmpPath = undefined; - addedSizeBytes = sizeBytes; - } - - const createdAt = new Date().toISOString(); - const manifest: ContentManifest = { - v: CONTENT_FORMAT_VERSION, - contentId, - sessionId, - artifactId: artifact.id, - workspacePath, - sha256, - sizeBytes, - createdAt, - }; - await writeManifestAtomic(contentDir, manifest); - if (addedSizeBytes > 0 && this.cachedTotalBytes !== undefined) { - this.cachedTotalBytes += addedSizeBytes; - } - this.leaseContentId(contentId); - return { - kind: 'managed_copy', - contentId, - sha256, - sizeBytes, - createdAt, - }; - } catch (error) { - if (addedSizeBytes > 0) { - this.cachedTotalBytes = undefined; - } - if (tmpPath) { - await fs.rm(tmpPath, { force: true }).catch(() => {}); - } - throw error; - } finally { - await sourceHandle?.close().catch(() => undefined); - } - }); - } - - releaseContentRef(ref: SessionArtifactContentRef): void { - this.releaseContentId(ref.contentId); - } - - leaseContentRefs(refs: readonly SessionArtifactContentRef[]): () => void { - const leased: string[] = []; - for (const ref of refs) { - this.leaseContentId(ref.contentId); - leased.push(ref.contentId); - } - return () => { - for (const contentId of leased) { - this.releaseContentId(contentId); - } - }; - } - - async fsck( - contentRefs: readonly SessionArtifactContentRef[], - ): Promise { - const missing: string[] = []; - const hashMismatches: string[] = []; - for (const ref of contentRefs) { - if (!isValidContentId(ref.contentId)) { - missing.push(ref.contentId); - continue; - } - const dataPath = path.join(this.rootDir, ref.contentId, 'content'); - try { - const { sha256 } = await hashFile(dataPath); - if (sha256 !== ref.sha256) { - hashMismatches.push(ref.contentId); - } - } catch { - missing.push(ref.contentId); - continue; - } - } - return { checked: contentRefs.length, missing, hashMismatches }; - } - - async verifyContentRef( - sessionId: string, - artifactId: string, - ref: SessionArtifactContentRef, - ): Promise { - if ( - ref.kind !== 'managed_copy' || - !isValidContentId(ref.contentId) || - !/^[0-9a-f]{64}$/.test(ref.sha256) || - !Number.isSafeInteger(ref.sizeBytes) || - ref.sizeBytes < 0 - ) { - return 'restore_validation_failed'; - } - const contentDir = path.join(this.rootDir, ref.contentId); - let manifest: ContentManifest; - try { - manifest = await readManifest(path.join(contentDir, 'manifest.json')); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return 'content_missing'; - } - return 'restore_validation_failed'; - } - if ( - manifest.sessionId !== sessionId || - manifest.artifactId !== artifactId || - manifest.contentId !== ref.contentId || - manifest.sha256 !== ref.sha256 || - manifest.sizeBytes !== ref.sizeBytes - ) { - return 'restore_validation_failed'; - } - try { - const contentPath = path.join(contentDir, 'content'); - const stat = await fs.stat(contentPath); - if (!stat.isFile()) { - return 'content_hash_mismatch'; - } - const { sha256, sizeBytes } = await hashFile(contentPath); - if (sha256 !== ref.sha256 || sizeBytes !== ref.sizeBytes) { - return 'content_hash_mismatch'; - } - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return 'content_missing'; - } - throw error; - } - return undefined; - } - - async gc( - sessionId: string, - referencedContentIds: ReadonlySet, - ): Promise { - return this.enqueueWrite(async () => { - const removed: string[] = []; - const retained: string[] = []; - let entries: string[]; - try { - entries = await fs.readdir(this.rootDir); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return { removed, retained }; - } - throw error; - } - for (const entry of entries) { - if (entry === '.tmp') { - await cleanTmpDir(path.join(this.rootDir, entry)); - continue; - } - const fullPath = path.join(this.rootDir, entry); - if ( - referencedContentIds.has(entry) || - this.leasedContentIds.has(entry) - ) { - retained.push(entry); - continue; - } - let manifest: ContentManifest; - try { - manifest = await readManifest(path.join(fullPath, 'manifest.json')); - } catch (error) { - writeStderrLine( - `[artifacts] action=content_gc_manifest_read_failed contentId=${entry} reason=${JSON.stringify( - error instanceof Error ? error.message : String(error), - )}`, - ); - retained.push(entry); - continue; - } - if (manifest.sessionId !== sessionId) { - retained.push(entry); - continue; - } - await fs.rm(fullPath, { recursive: true, force: true }); - if (this.cachedTotalBytes !== undefined) { - this.cachedTotalBytes = Math.max( - 0, - this.cachedTotalBytes - manifest.sizeBytes, - ); - } - removed.push(entry); - } - return { removed, retained }; - }); - } - - private async getUsedBytes(): Promise { - if (this.cachedTotalBytes === undefined) { - this.cachedTotalBytes = await this.scanUsedBytes(); - } - return this.cachedTotalBytes; - } - - private async scanUsedBytes(): Promise { - let total = 0; - let entries: string[]; - try { - entries = await fs.readdir(this.rootDir); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return 0; - } - throw error; - } - for (const entry of entries) { - if (entry === '.tmp') continue; - try { - const manifest = await readManifest( - path.join(this.rootDir, entry, 'manifest.json'), - ); - total += manifest.sizeBytes; - } catch { - try { - const stat = await fs.stat(path.join(this.rootDir, entry, 'content')); - if (stat.isFile()) { - total += stat.size; - } - } catch { - // Malformed manifests are handled by fsck/GC. - } - } - } - return total; - } - - private enqueueWrite(operation: () => Promise): Promise { - const run = this.writeQueue.catch(() => {}).then(operation); - this.writeQueue = run.then( - () => undefined, - () => undefined, - ); - return run; - } - - private leaseContentId(contentId: string): void { - this.leasedContentIds.set( - contentId, - (this.leasedContentIds.get(contentId) ?? 0) + 1, - ); - } - - private releaseContentId(contentId: string): void { - const count = this.leasedContentIds.get(contentId); - if (count === undefined) return; - if (count <= 1) { - this.leasedContentIds.delete(contentId); - return; - } - this.leasedContentIds.set(contentId, count - 1); - } -} - -function defaultContentRoot(): string { - return path.join(getGlobalQwenDir(), 'session-artifacts', 'content'); -} - -function getGlobalQwenDir(): string { - const envDir = process.env['QWEN_HOME']; - if (envDir) { - return resolveUserPath(envDir); - } - const homeDir = os.homedir(); - if (!homeDir) { - return path.join(os.tmpdir(), '.qwen'); - } - return path.join(homeDir, '.qwen'); -} - -function resolveUserPath(dir: string): string { - let resolved = dir; - if ( - resolved === '~' || - resolved.startsWith('~/') || - resolved.startsWith('~\\') - ) { - const relativeSegments = - resolved === '~' - ? [] - : resolved - .slice(2) - .split(/[/\\]+/) - .filter(Boolean); - resolved = path.join(os.homedir(), ...relativeSegments); - } - return path.isAbsolute(resolved) ? resolved : path.resolve(resolved); -} - -function stableContentSuffix(sessionId: string, artifactId: string): string { - return createHash('sha256') - .update(`${sessionId}:${artifactId}`) - .digest('hex') - .slice(0, 16); -} - -async function resolveWorkspaceFile( - workspaceCwd: string, - workspacePath: string, -): Promise { - const realWorkspace = await fs.realpath(workspaceCwd); - const candidate = path.resolve(realWorkspace, workspacePath); - const realCandidate = await fs.realpath(candidate); - const relative = path.relative(realWorkspace, realCandidate); - if (relative.startsWith('..') || path.isAbsolute(relative)) { - throw new SessionArtifactValidationError( - 'workspacePath must stay inside the workspace', - 'workspacePath', - ); - } - return realCandidate; -} - -async function openRegularWorkspaceFile(filePath: string): Promise { - const sourceStat = await fs.stat(filePath); - if (!sourceStat.isFile()) { - throw new SessionArtifactValidationError( - 'Only regular workspace files can be pinned with content retention', - 'artifactId', - ); - } - if (sourceStat.nlink > 1) { - throw new SessionArtifactValidationError( - 'Hardlinked workspace files cannot be pinned with content retention', - 'artifactId', - ); - } - if (sourceStat.size > MAX_PINNED_FILE_BYTES) { - throw new SessionArtifactValidationError( - `Pinned artifact content exceeds ${MAX_PINNED_FILE_BYTES} bytes`, - 'artifactId', - ); - } - let handle: FileHandle; - try { - handle = await fs.open( - filePath, - fsSync.constants.O_RDONLY | noFollowFlag(), - ); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ELOOP') { - throw new SessionArtifactValidationError( - 'workspacePath must not resolve through a symlink while pinning content', - 'workspacePath', - ); - } - throw error; - } - try { - const handleStat = await handle.stat(); - if (!handleStat.isFile()) { - throw new SessionArtifactValidationError( - 'Only regular workspace files can be pinned with content retention', - 'artifactId', - ); - } - if (!sameFile(sourceStat, handleStat)) { - throw new SessionArtifactValidationError( - 'workspacePath changed while pinning content', - 'workspacePath', - ); - } - if (handleStat.nlink > 1) { - throw new SessionArtifactValidationError( - 'Hardlinked workspace files cannot be pinned with content retention', - 'artifactId', - ); - } - if (handleStat.size > MAX_PINNED_FILE_BYTES) { - throw new SessionArtifactValidationError( - `Pinned artifact content exceeds ${MAX_PINNED_FILE_BYTES} bytes`, - 'artifactId', - ); - } - return handle; - } catch (error) { - await handle.close().catch(() => undefined); - throw error; - } -} - -async function copyOpenFileToTemp( - handle: FileHandle, - tmpPath: string, -): Promise<{ sha256: string; sizeBytes: number }> { - const writer = await fs.open(tmpPath, 'w', 0o600); - const hash = createHash('sha256'); - const buffer = Buffer.allocUnsafe(64 * 1024); - let sizeBytes = 0; - let position = 0; - try { - while (true) { - const { bytesRead } = await handle.read( - buffer, - 0, - buffer.length, - position, - ); - if (bytesRead === 0) break; - position += bytesRead; - sizeBytes += bytesRead; - if (sizeBytes > MAX_PINNED_FILE_BYTES) { - throw new SessionArtifactValidationError( - `Pinned artifact content exceeds ${MAX_PINNED_FILE_BYTES} bytes`, - 'artifactId', - ); - } - const chunk = buffer.subarray(0, bytesRead); - hash.update(chunk); - await writer.write(chunk); - } - await writer.sync(); - } finally { - await writer.close().catch(() => undefined); - } - return { sha256: hash.digest('hex'), sizeBytes }; -} - -function noFollowFlag(): number { - return typeof fsSync.constants.O_NOFOLLOW === 'number' - ? fsSync.constants.O_NOFOLLOW - : 0; -} - -function sameFile(before: fsSync.Stats, after: fsSync.Stats): boolean { - if ( - before.dev !== 0 && - before.ino !== 0 && - after.dev !== 0 && - after.ino !== 0 - ) { - return before.dev === after.dev && before.ino === after.ino; - } - return before.size === after.size && before.mtimeMs === after.mtimeMs; -} - -function hashFile( - filePath: string, -): Promise<{ sha256: string; sizeBytes: number }> { - return new Promise((resolve, reject) => { - const hash = createHash('sha256'); - let sizeBytes = 0; - const stream = fsSync.createReadStream(filePath); - stream.on('data', (chunk: string | Buffer) => { - const buffer = typeof chunk === 'string' ? Buffer.from(chunk) : chunk; - sizeBytes += buffer.length; - hash.update(buffer); - }); - stream.on('error', reject); - stream.on('end', () => { - resolve({ sha256: hash.digest('hex'), sizeBytes }); - }); - }); -} - -async function readManifest(filePath: string): Promise { - const body = await fs.readFile(filePath, 'utf8'); - const parsed = JSON.parse(body) as Partial; - if ( - parsed.v !== CONTENT_FORMAT_VERSION || - !parsed.contentId || - !isValidContentId(parsed.contentId) || - typeof parsed.sessionId !== 'string' || - typeof parsed.artifactId !== 'string' || - typeof parsed.workspacePath !== 'string' || - typeof parsed.sha256 !== 'string' || - !/^[0-9a-f]{64}$/.test(parsed.sha256) || - typeof parsed.sizeBytes !== 'number' || - !Number.isSafeInteger(parsed.sizeBytes) || - parsed.sizeBytes < 0 || - typeof parsed.createdAt !== 'string' - ) { - throw new Error('Invalid artifact content manifest'); - } - return parsed as ContentManifest; -} - -async function writeManifestAtomic( - contentDir: string, - manifest: ContentManifest, -): Promise { - const tmpPath = path.join( - contentDir, - `.manifest-${process.pid}-${Date.now()}.json.tmp`, - ); - let handle: FileHandle | undefined; - try { - handle = await fs.open(tmpPath, 'w', 0o600); - await handle.writeFile(`${JSON.stringify(manifest)}\n`); - await handle.sync(); - await handle.close(); - handle = undefined; - await fs.rename(tmpPath, path.join(contentDir, 'manifest.json')); - } catch (error) { - await handle?.close().catch(() => undefined); - await fs.rm(tmpPath, { force: true }).catch(() => undefined); - throw error; - } -} - -async function cleanTmpDir(tmpDir: string): Promise { - let entries: string[]; - try { - entries = await fs.readdir(tmpDir); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return; - } - throw error; - } - await Promise.all( - entries.map((entry) => - fs.rm(path.join(tmpDir, entry), { recursive: true, force: true }), - ), - ); -} - -function isValidContentId(contentId: string): boolean { - return CONTENT_ID_PATTERN.test(contentId); -} - -async function exists(filePath: string): Promise { - try { - await fs.access(filePath); - return true; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return false; - } - throw error; - } -} diff --git a/packages/acp-bridge/src/sessionArtifacts.test.ts b/packages/acp-bridge/src/sessionArtifacts.test.ts index d6fb63b1520..fe391038c45 100644 --- a/packages/acp-bridge/src/sessionArtifacts.test.ts +++ b/packages/acp-bridge/src/sessionArtifacts.test.ts @@ -15,7 +15,6 @@ import { SessionArtifactStore, SessionArtifactValidationError, } from './sessionArtifacts.js'; -import { SessionArtifactContentStore } from './sessionArtifactContentStore.js'; import type { RebuiltSessionArtifactSnapshot, SessionArtifactEventRecordPayload, @@ -3281,472 +3280,3 @@ describe('SessionArtifactStore', () => { }); }); }); - -describe('SessionArtifactContentStore', () => { - let workspace: string; - let contentRoot: string; - - beforeEach(async () => { - workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-artifacts-')); - contentRoot = await fs.mkdtemp( - path.join(os.tmpdir(), 'qwen-artifact-content-'), - ); - }); - - afterEach(async () => { - vi.restoreAllMocks(); - await fs.rm(workspace, { recursive: true, force: true }); - await fs.rm(contentRoot, { recursive: true, force: true }); - }); - - async function workspaceArtifact(name: string, body: string) { - await fs.mkdir(path.join(workspace, 'reports'), { recursive: true }); - await fs.writeFile(path.join(workspace, 'reports', name), body); - const store = new SessionArtifactStore({ - sessionId: 'content-session', - workspaceCwd: workspace, - }); - await store.upsertMany( - [{ title: name, workspacePath: `reports/${name}` }], - { strict: true }, - ); - return (await store.list()).artifacts[0]!; - } - - function fakeContentId(label: string): string { - return `${createHash('sha256').update(label).digest('hex')}-${createHash( - 'sha256', - ) - .update(`suffix:${label}`) - .digest('hex') - .slice(0, 16)}`; - } - - async function writeQuotaManifest(label: string, sizeBytes: number) { - const contentId = fakeContentId(label); - await fs.mkdir(path.join(contentRoot, contentId), { recursive: true }); - await fs.writeFile( - path.join(contentRoot, contentId, 'manifest.json'), - `${JSON.stringify({ - v: 1, - contentId, - sessionId: 'content-session', - artifactId: contentId, - workspacePath: `reports/${contentId}.txt`, - sha256: '0'.repeat(64), - sizeBytes, - createdAt: '2026-07-04T00:00:00.000Z', - })}\n`, - ); - return contentId; - } - - it('copies pinned workspace content with a path-safe content id', async () => { - const contentStore = new SessionArtifactContentStore(contentRoot); - const artifact = await workspaceArtifact('report.txt', 'hello'); - - const ref = await contentStore.pinWorkspaceFile( - 'session/with/slash', - artifact, - workspace, - ); - - expect(ref).toMatchObject({ - kind: 'managed_copy', - sha256: createHash('sha256').update('hello').digest('hex'), - sizeBytes: 5, - }); - expect(ref!.contentId).toMatch(/^[a-f0-9]{64}-[a-f0-9]{16}$/); - await expect( - fs.readFile(path.join(contentRoot, ref!.contentId, 'content'), 'utf8'), - ).resolves.toBe('hello'); - await expect(contentStore.fsck([ref!])).resolves.toEqual({ - checked: 1, - missing: [], - hashMismatches: [], - }); - }); - - it('treats fsck read races as missing content', async () => { - const contentStore = new SessionArtifactContentStore(contentRoot); - const artifact = await workspaceArtifact('race.txt', 'race'); - const ref = (await contentStore.pinWorkspaceFile( - 'content-session', - artifact, - workspace, - ))!; - const contentPath = path.join(contentRoot, ref.contentId, 'content'); - await fs.rm(contentPath); - await fs.mkdir(contentPath); - - await expect(contentStore.fsck([ref])).resolves.toEqual({ - checked: 1, - missing: [ref.contentId], - hashMismatches: [], - }); - }); - - it('reuses an existing retained content copy for repeated pins', async () => { - const contentStore = new SessionArtifactContentStore(contentRoot); - const artifact = await workspaceArtifact('repeat.txt', 'repeat'); - - const first = (await contentStore.pinWorkspaceFile( - 'content-session', - artifact, - workspace, - ))!; - const second = (await contentStore.pinWorkspaceFile( - 'content-session', - artifact, - workspace, - ))!; - - expect(second.contentId).toBe(first.contentId); - await expect(contentStore.fsck([second])).resolves.toEqual({ - checked: 1, - missing: [], - hashMismatches: [], - }); - }); - - it('reuses repeated pins even when the store is otherwise near quota', async () => { - const contentStore = new SessionArtifactContentStore(contentRoot); - const artifact = await workspaceArtifact('repeat-near-quota.txt', 'repeat'); - const first = (await contentStore.pinWorkspaceFile( - 'content-session', - artifact, - workspace, - ))!; - await writeQuotaManifest('quota-filler', 256 * 1024 * 1024); - - const second = (await contentStore.pinWorkspaceFile( - 'content-session', - artifact, - workspace, - ))!; - - expect(second.contentId).toBe(first.contentId); - }); - - it('caches total content bytes after the first quota scan', async () => { - const contentStore = new SessionArtifactContentStore(contentRoot); - const first = await workspaceArtifact('cache-a.txt', 'a'); - const second = await workspaceArtifact('cache-b.txt', 'b'); - const readdirSpy = vi.spyOn(fs, 'readdir'); - - await contentStore.pinWorkspaceFile('content-session', first, workspace); - const rootScansAfterFirst = readdirSpy.mock.calls.filter( - ([dir]) => String(dir) === contentRoot, - ).length; - - await contentStore.pinWorkspaceFile('content-session', second, workspace); - const rootScansAfterSecond = readdirSpy.mock.calls.filter( - ([dir]) => String(dir) === contentRoot, - ).length; - - expect(rootScansAfterFirst).toBe(1); - expect(rootScansAfterSecond).toBe(rootScansAfterFirst); - }); - - it('rejects files over the per-artifact content limit before copying', async () => { - const contentStore = new SessionArtifactContentStore(contentRoot); - await fs.mkdir(path.join(workspace, 'reports'), { recursive: true }); - const oversizedPath = path.join(workspace, 'reports', 'oversized.bin'); - await fs.writeFile(oversizedPath, ''); - await fs.truncate(oversizedPath, 50 * 1024 * 1024 + 1); - const store = new SessionArtifactStore({ - sessionId: 'content-session', - workspaceCwd: workspace, - }); - await store.upsertMany( - [{ title: 'oversized.bin', workspacePath: 'reports/oversized.bin' }], - { strict: true }, - ); - const artifact = (await store.list()).artifacts[0]!; - - await expect( - contentStore.pinWorkspaceFile('content-session', artifact, workspace), - ).rejects.toThrow(SessionArtifactValidationError); - }); - - it('rejects non-regular workspace files for content retention', async () => { - const contentStore = new SessionArtifactContentStore(contentRoot); - await fs.mkdir(path.join(workspace, 'reports'), { recursive: true }); - const artifact = { - ...(await workspaceArtifact('regular.txt', 'regular')), - workspacePath: 'reports', - }; - - await expect( - contentStore.pinWorkspaceFile('content-session', artifact, workspace), - ).rejects.toThrow(SessionArtifactValidationError); - }); - - it('rejects workspace symlinks that escape before copying content', async () => { - const contentStore = new SessionArtifactContentStore(contentRoot); - const outside = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-outside-')); - try { - await fs.writeFile(path.join(outside, 'secret.txt'), 'secret'); - await fs.mkdir(path.join(workspace, 'reports'), { recursive: true }); - await fs.symlink( - path.join(outside, 'secret.txt'), - path.join(workspace, 'reports', 'link.txt'), - ); - const artifact = { - ...(await workspaceArtifact('regular.txt', 'regular')), - workspacePath: 'reports/link.txt', - }; - - await expect( - contentStore.pinWorkspaceFile('content-session', artifact, workspace), - ).rejects.toThrow(SessionArtifactValidationError); - } finally { - await fs.rm(outside, { recursive: true, force: true }); - } - }); - - it('rejects hardlinked workspace files for content retention', async () => { - const contentStore = new SessionArtifactContentStore(contentRoot); - await fs.mkdir(path.join(workspace, 'reports'), { recursive: true }); - const originalPath = path.join(workspace, 'reports', 'original.txt'); - const hardlinkPath = path.join(workspace, 'reports', 'hardlink.txt'); - await fs.writeFile(originalPath, 'hardlinked'); - try { - await fs.link(originalPath, hardlinkPath); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === 'EPERM' || code === 'EACCES' || code === 'EXDEV') { - return; - } - throw error; - } - const artifact = { - ...(await workspaceArtifact('regular.txt', 'regular')), - workspacePath: 'reports/hardlink.txt', - }; - - await expect( - contentStore.pinWorkspaceFile('content-session', artifact, workspace), - ).rejects.toThrow(SessionArtifactValidationError); - }); - - it('rejects new content over total quota and cleans up temporary files', async () => { - const contentStore = new SessionArtifactContentStore(contentRoot); - const artifact = await workspaceArtifact('over-quota.txt', 'new-content'); - await writeQuotaManifest('quota-filler', 256 * 1024 * 1024); - - await expect( - contentStore.pinWorkspaceFile('content-session', artifact, workspace), - ).rejects.toThrow(SessionArtifactValidationError); - await expect(fs.readdir(path.join(contentRoot, '.tmp'))).resolves.toEqual( - [], - ); - }); - - it('counts malformed manifest content files against total quota', async () => { - const contentStore = new SessionArtifactContentStore(contentRoot); - const artifact = await workspaceArtifact('malformed-quota.txt', 'data'); - const contentId = fakeContentId('malformed-quota'); - const contentDir = path.join(contentRoot, contentId); - await fs.mkdir(contentDir, { recursive: true }); - await fs.writeFile( - path.join(contentDir, 'manifest.json'), - '{"sizeBytes":"bad"}\n', - ); - await fs.writeFile(path.join(contentDir, 'content'), ''); - await fs.truncate(path.join(contentDir, 'content'), 256 * 1024 * 1024); - - await expect( - contentStore.pinWorkspaceFile('content-session', artifact, workspace), - ).rejects.toThrow(SessionArtifactValidationError); - }); - - it('cleans stale temporary content files during gc', async () => { - const contentStore = new SessionArtifactContentStore(contentRoot); - const tmpDir = path.join(contentRoot, '.tmp'); - await fs.mkdir(tmpDir, { recursive: true }); - await fs.writeFile(path.join(tmpDir, 'stale.bin'), 'stale'); - - await contentStore.gc('content-session', new Set()); - - await expect(fs.readdir(tmpDir)).resolves.toEqual([]); - }); - - it('logs and retains content when gc cannot read its manifest', async () => { - const contentStore = new SessionArtifactContentStore(contentRoot); - const contentId = fakeContentId('bad-manifest'); - const contentDir = path.join(contentRoot, contentId); - await fs.mkdir(contentDir, { recursive: true }); - await fs.writeFile(path.join(contentDir, 'manifest.json'), '{bad json'); - const stderr = vi - .spyOn(process.stderr, 'write') - .mockReturnValue(true as never); - - try { - await expect( - contentStore.gc('content-session', new Set()), - ).resolves.toEqual({ - removed: [], - retained: [contentId], - }); - const logged = stderr.mock.calls.map((call) => String(call[0])).join(''); - expect(logged).toContain('content_gc_manifest_read_failed'); - expect(logged).toContain(contentId); - } finally { - stderr.mockRestore(); - } - }); - - it('retains leased content during gc until the pin flow releases it', async () => { - const contentStore = new SessionArtifactContentStore(contentRoot); - const ref = (await contentStore.pinWorkspaceFile( - 'content-session', - await workspaceArtifact('leased.txt', 'leased'), - workspace, - ))!; - - await expect( - contentStore.gc('content-session', new Set()), - ).resolves.toMatchObject({ - removed: [], - retained: expect.arrayContaining([ref.contentId]), - }); - - const releaseGcLease = contentStore.leaseContentRefs([ref]); - contentStore.releaseContentRef(ref); - await expect( - contentStore.gc('content-session', new Set()), - ).resolves.toMatchObject({ - removed: [], - retained: expect.arrayContaining([ref.contentId]), - }); - - releaseGcLease(); - await expect( - contentStore.gc('content-session', new Set()), - ).resolves.toMatchObject({ - removed: [ref.contentId], - }); - }); - - it('serializes quota checks across concurrent pins', async () => { - const contentStore = new SessionArtifactContentStore(contentRoot); - const first = await workspaceArtifact('concurrent-a.txt', 'abc'); - const second = await workspaceArtifact('concurrent-b.txt', 'def'); - await writeQuotaManifest('quota-filler', 256 * 1024 * 1024 - 3); - - const results = await Promise.allSettled([ - contentStore.pinWorkspaceFile('content-session', first, workspace), - contentStore.pinWorkspaceFile('content-session', second, workspace), - ]); - - expect( - results.filter((result) => result.status === 'fulfilled'), - ).toHaveLength(1); - expect( - results.filter((result) => result.status === 'rejected'), - ).toHaveLength(1); - }); - - it('reports missing and hash-mismatched retained content', async () => { - const contentStore = new SessionArtifactContentStore(contentRoot); - const artifact = await workspaceArtifact('report.txt', 'hello'); - const ref = (await contentStore.pinWorkspaceFile( - 'content-session', - artifact, - workspace, - ))!; - - await fs.writeFile(path.join(contentRoot, ref.contentId, 'content'), 'bad'); - await expect(contentStore.fsck([ref])).resolves.toMatchObject({ - checked: 1, - hashMismatches: [ref.contentId], - }); - - await fs.rm(path.join(contentRoot, ref.contentId, 'content')); - await expect(contentStore.fsck([ref])).resolves.toMatchObject({ - checked: 1, - missing: [ref.contentId], - }); - }); - - it('validates retained content refs against manifest and content size', async () => { - const contentStore = new SessionArtifactContentStore(contentRoot); - const artifact = await workspaceArtifact('verify.txt', 'hello'); - const ref = (await contentStore.pinWorkspaceFile( - 'content-session', - artifact, - workspace, - ))!; - - await expect( - contentStore.verifyContentRef('content-session', artifact.id, ref), - ).resolves.toBeUndefined(); - - await expect( - contentStore.verifyContentRef('other-session', artifact.id, ref), - ).resolves.toBe('restore_validation_failed'); - - await fs.writeFile( - path.join(contentRoot, ref.contentId, 'content'), - 'HELLO', - ); - await expect( - contentStore.verifyContentRef('content-session', artifact.id, ref), - ).resolves.toBe('content_hash_mismatch'); - await expect(contentStore.fsck([ref])).resolves.toMatchObject({ - hashMismatches: [ref.contentId], - }); - - await fs.writeFile(path.join(contentRoot, ref.contentId, 'content'), 'bad'); - await expect( - contentStore.verifyContentRef('content-session', artifact.id, ref), - ).resolves.toBe('content_hash_mismatch'); - - await fs.rm(path.join(contentRoot, ref.contentId, 'content')); - await expect( - contentStore.verifyContentRef('content-session', artifact.id, ref), - ).resolves.toBe('content_missing'); - }); - - it('garbage-collects only unreferenced content for the requested session', async () => { - const contentStore = new SessionArtifactContentStore(contentRoot); - const kept = (await contentStore.pinWorkspaceFile( - 'content-session', - await workspaceArtifact('kept.txt', 'kept'), - workspace, - ))!; - const orphaned = (await contentStore.pinWorkspaceFile( - 'content-session', - await workspaceArtifact('orphaned.txt', 'orphaned'), - workspace, - ))!; - const otherSession = (await contentStore.pinWorkspaceFile( - 'other-session', - await workspaceArtifact('other.txt', 'other'), - workspace, - ))!; - contentStore.releaseContentRef(kept); - contentStore.releaseContentRef(orphaned); - contentStore.releaseContentRef(otherSession); - - await expect( - contentStore.gc('content-session', new Set([kept.contentId])), - ).resolves.toEqual({ - removed: [orphaned.contentId], - retained: expect.arrayContaining([ - kept.contentId, - otherSession.contentId, - ]), - }); - await expect( - fs.access(path.join(contentRoot, orphaned.contentId)), - ).rejects.toMatchObject({ code: 'ENOENT' }); - await expect( - fs.access(path.join(contentRoot, kept.contentId)), - ).resolves.toBeUndefined(); - await expect( - fs.access(path.join(contentRoot, otherSession.contentId)), - ).resolves.toBeUndefined(); - }); -}); diff --git a/packages/cli/src/serve/acp-http/dispatch.ts b/packages/cli/src/serve/acp-http/dispatch.ts index 868819e43cd..6c26e1c85b3 100644 --- a/packages/cli/src/serve/acp-http/dispatch.ts +++ b/packages/cli/src/serve/acp-http/dispatch.ts @@ -38,12 +38,7 @@ import { UnsupportedDeviceFlowProviderError, UpstreamDeviceFlowError, } from '../auth/device-flow.js'; -import type { - HttpAcpBridge, - SessionArtifactPinRequest, - SessionArtifactRemoveRequest, - SessionArtifactUnpinRequest, -} from '@qwen-code/acp-bridge/bridgeTypes'; +import type { HttpAcpBridge } from '@qwen-code/acp-bridge/bridgeTypes'; import type { BridgeEvent } from '@qwen-code/acp-bridge/eventBus'; import { SessionShellClientRequiredError, @@ -190,10 +185,6 @@ const ALL_QWEN_VENDOR_METHODS: readonly string[] = [ `${QWEN_METHOD_NS}session/lsp`, `${QWEN_METHOD_NS}session/artifacts`, `${QWEN_METHOD_NS}session/artifacts/add`, - `${QWEN_METHOD_NS}session/artifacts/pin`, - `${QWEN_METHOD_NS}session/artifacts/unpin`, - `${QWEN_METHOD_NS}session/artifacts/fsck`, - `${QWEN_METHOD_NS}session/artifacts/gc`, `${QWEN_METHOD_NS}session/artifacts/remove`, // Wave 1: memory `${QWEN_METHOD_NS}workspace/memory`, @@ -442,40 +433,6 @@ function pickSessionArtifactInput( } as AddSessionArtifactInput; } -function pickSessionArtifactPinRequest( - params: Record, -): SessionArtifactPinRequest { - const request: SessionArtifactPinRequest = {}; - if (params['mode'] !== undefined) { - request.mode = params['mode'] as SessionArtifactPinRequest['mode']; - } - if (params['ttlDays'] !== undefined) { - request.ttlDays = params['ttlDays'] as number; - } - if (params['clientRetained'] !== undefined) { - request.clientRetained = params['clientRetained'] as boolean; - } - return request; -} - -function pickSessionArtifactUnpinRequest( - params: Record, -): SessionArtifactUnpinRequest { - const retention = params['retention']; - return retention === undefined - ? {} - : { retention: retention as SessionArtifactUnpinRequest['retention'] }; -} - -function pickSessionArtifactRemoveRequest( - params: Record, -): SessionArtifactRemoveRequest { - const deleteContent = params['deleteContent']; - return deleteContent === undefined - ? {} - : { deleteContent: deleteContent as boolean }; -} - /** * Map a thrown error to a JSON-RPC error code + a client-safe message. * Param-validation errors are echoed (they describe the client's own bad @@ -2601,75 +2558,6 @@ export class AcpDispatcher { return; } - case `${QWEN_METHOD_NS}session/artifacts/pin`: { - const sessionId = String(params['sessionId'] ?? ''); - await this.withMutableOwned(conn, sessionId, id, async () => { - const artifactId = String(params['artifactId'] ?? ''); - if (!artifactId) { - if (id !== undefined) { - conn.sendConn( - error(id, RPC.INVALID_PARAMS, '`artifactId` is required'), - ); - } - return; - } - const result = await this.bridge.pinSessionArtifact( - sessionId, - artifactId, - this.sessionCtx(conn, sessionId, loopback), - pickSessionArtifactPinRequest(params), - ); - this.replyConn(conn, id, result as unknown); - }); - return; - } - - case `${QWEN_METHOD_NS}session/artifacts/unpin`: { - const sessionId = String(params['sessionId'] ?? ''); - await this.withMutableOwned(conn, sessionId, id, async () => { - const artifactId = String(params['artifactId'] ?? ''); - if (!artifactId) { - if (id !== undefined) { - conn.sendConn( - error(id, RPC.INVALID_PARAMS, '`artifactId` is required'), - ); - } - return; - } - const result = await this.bridge.unpinSessionArtifact( - sessionId, - artifactId, - this.sessionCtx(conn, sessionId, loopback), - pickSessionArtifactUnpinRequest(params), - ); - this.replyConn(conn, id, result as unknown); - }); - return; - } - - case `${QWEN_METHOD_NS}session/artifacts/fsck`: { - const sessionId = String(params['sessionId'] ?? ''); - if (!this.requireOwned(conn, sessionId, id)) return; - const result = await this.bridge.fsckSessionArtifacts( - sessionId, - this.sessionCtx(conn, sessionId, loopback), - ); - this.replyConn(conn, id, result as unknown); - return; - } - - case `${QWEN_METHOD_NS}session/artifacts/gc`: { - const sessionId = String(params['sessionId'] ?? ''); - await this.withMutableOwned(conn, sessionId, id, async () => { - const result = await this.bridge.gcSessionArtifacts( - sessionId, - this.sessionCtx(conn, sessionId, loopback), - ); - this.replyConn(conn, id, result as unknown); - }); - return; - } - case `${QWEN_METHOD_NS}session/artifacts/remove`: { const sessionId = String(params['sessionId'] ?? ''); await this.withMutableOwned(conn, sessionId, id, async () => { @@ -2686,7 +2574,6 @@ export class AcpDispatcher { sessionId, artifactId, this.sessionCtx(conn, sessionId, loopback), - pickSessionArtifactRemoveRequest(params), ); this.replyConn(conn, id, result as unknown); }); diff --git a/packages/cli/src/serve/acp-http/index.ts b/packages/cli/src/serve/acp-http/index.ts index cde84714597..1d81c1d5260 100644 --- a/packages/cli/src/serve/acp-http/index.ts +++ b/packages/cli/src/serve/acp-http/index.ts @@ -189,7 +189,6 @@ const WS_READ_METHODS = new Set([ '_qwen/session/tasks', '_qwen/session/lsp', '_qwen/session/artifacts', - '_qwen/session/artifacts/fsck', '_qwen/workspace/mcp', '_qwen/workspace/skills', '_qwen/workspace/providers', diff --git a/packages/cli/src/serve/acp-http/transport.test.ts b/packages/cli/src/serve/acp-http/transport.test.ts index f3ec262189a..c92acf8daf5 100644 --- a/packages/cli/src/serve/acp-http/transport.test.ts +++ b/packages/cli/src/serve/acp-http/transport.test.ts @@ -404,33 +404,8 @@ class FakeBridge { sessionId: string; artifactId: string; context: Parameters[2]; - options: Parameters[3]; } | undefined; - lastPinnedArtifact: - | { - sessionId: string; - artifactId: string; - context: Parameters[2]; - options: Parameters[3]; - } - | undefined; - lastUnpinnedArtifact: - | { - sessionId: string; - artifactId: string; - context: Parameters[2]; - options: Parameters[3]; - } - | undefined; - lastFsckSessionId: string | undefined; - lastFsckSessionContext: - | Parameters[1] - | undefined; - lastGcSessionId: string | undefined; - lastGcSessionContext: - | Parameters[1] - | undefined; async getSessionArtifacts(sessionId: string) { this.lastArtifactListSessionId = sessionId; return { @@ -453,61 +428,14 @@ class FakeBridge { sessionId: string, artifactId: string, context: Parameters[2], - options?: Parameters[3], ) { - this.lastRemovedArtifact = { sessionId, artifactId, context, options }; + this.lastRemovedArtifact = { sessionId, artifactId, context }; return { v: 1, sessionId, changes: [{ action: 'removed' as const, artifactId, reason: 'explicit' }], }; } - async pinSessionArtifact( - sessionId: string, - artifactId: string, - context: Parameters[2], - options?: Parameters[3], - ) { - this.lastPinnedArtifact = { sessionId, artifactId, context, options }; - return { - v: 1, - sessionId, - changes: [{ action: 'updated' as const, artifactId }], - }; - } - async unpinSessionArtifact( - sessionId: string, - artifactId: string, - context: Parameters[2], - options?: Parameters[3], - ) { - this.lastUnpinnedArtifact = { sessionId, artifactId, context, options }; - return { - v: 1, - sessionId, - changes: [{ action: 'updated' as const, artifactId }], - }; - } - async fsckSessionArtifacts( - sessionId: string, - context?: Parameters[1], - ) { - this.lastFsckSessionId = sessionId; - this.lastFsckSessionContext = context; - return { - checked: 0, - missing: [] as string[], - hashMismatches: [] as string[], - }; - } - async gcSessionArtifacts( - sessionId: string, - context?: Parameters[1], - ) { - this.lastGcSessionId = sessionId; - this.lastGcSessionContext = context; - return { removed: [] as string[], retained: [] as string[] }; - } async getWorkspaceToolsStatus() { return { v: 1, tools: [] }; } @@ -6145,7 +6073,6 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { params: { sessionId: 'sess-1', artifactId: 'artifact-1', - deleteContent: true, }, }); const frames = await takeFrames(await streamRes, 2); @@ -6165,7 +6092,6 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { expect(bridge.lastRemovedArtifact).toMatchObject({ sessionId: 'sess-1', artifactId: 'artifact-1', - options: { deleteContent: true }, }); }); @@ -6236,146 +6162,6 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { }); }); - it('_qwen/session/artifacts/pin forwards artifact id', async () => { - const connId = await initialize(); - const streamRes = openStream(connId); - await new Promise((r) => setTimeout(r, 30)); - await post(connId, { - jsonrpc: '2.0', - id: 99, - method: 'session/new', - params: {}, - }); - await new Promise((r) => setTimeout(r, 30)); - await post(connId, { - jsonrpc: '2.0', - id: 61, - method: '_qwen/session/artifacts/pin', - params: { - sessionId: 'sess-1', - artifactId: 'artifact-1', - mode: 'content', - ttlDays: 7, - clientRetained: false, - }, - }); - const frames = await takeFrames(await streamRes, 2); - expect(frames[1]).toMatchObject({ - result: { - v: 1, - sessionId: 'sess-1', - changes: [{ action: 'updated', artifactId: 'artifact-1' }], - }, - }); - expect(bridge.lastPinnedArtifact).toMatchObject({ - sessionId: 'sess-1', - artifactId: 'artifact-1', - options: { mode: 'content', ttlDays: 7, clientRetained: false }, - }); - }); - - it('_qwen/session/artifacts/unpin forwards artifact id', async () => { - const connId = await initialize(); - const streamRes = openStream(connId); - await new Promise((r) => setTimeout(r, 30)); - await post(connId, { - jsonrpc: '2.0', - id: 99, - method: 'session/new', - params: {}, - }); - await new Promise((r) => setTimeout(r, 30)); - await post(connId, { - jsonrpc: '2.0', - id: 62, - method: '_qwen/session/artifacts/unpin', - params: { - sessionId: 'sess-1', - artifactId: 'artifact-1', - retention: 'ephemeral', - }, - }); - const frames = await takeFrames(await streamRes, 2); - expect(frames[1]).toMatchObject({ - result: { - v: 1, - sessionId: 'sess-1', - changes: [{ action: 'updated', artifactId: 'artifact-1' }], - }, - }); - expect(bridge.lastUnpinnedArtifact).toMatchObject({ - sessionId: 'sess-1', - artifactId: 'artifact-1', - options: { retention: 'ephemeral' }, - }); - }); - - it('_qwen/session/artifacts/fsck returns integrity status', async () => { - bridge.fsckSessionArtifacts = async (sessionId, context) => { - bridge.lastFsckSessionId = sessionId; - bridge.lastFsckSessionContext = context; - return { checked: 1, missing: ['missing'], hashMismatches: [] }; - }; - const connId = await initialize(); - const streamRes = openStream(connId); - await new Promise((r) => setTimeout(r, 30)); - await post(connId, { - jsonrpc: '2.0', - id: 99, - method: 'session/new', - params: {}, - }); - await new Promise((r) => setTimeout(r, 30)); - await post(connId, { - jsonrpc: '2.0', - id: 63, - method: '_qwen/session/artifacts/fsck', - params: { sessionId: 'sess-1' }, - }); - const frames = await takeFrames(await streamRes, 2); - expect(frames[1]).toMatchObject({ - result: { checked: 1, missing: ['missing'], hashMismatches: [] }, - }); - expect(bridge.lastFsckSessionId).toBe('sess-1'); - expect(bridge.lastFsckSessionContext).toEqual({ - clientId: 'client-1', - fromLoopback: true, - }); - }); - - it('_qwen/session/artifacts/gc returns cleanup result', async () => { - bridge.gcSessionArtifacts = async (sessionId, context) => { - bridge.lastGcSessionId = sessionId; - bridge.lastGcSessionContext = context; - return { removed: ['old'], retained: ['kept'] }; - }; - const connId = await initialize(); - const streamRes = openStream(connId); - await new Promise((r) => setTimeout(r, 30)); - await post(connId, { - jsonrpc: '2.0', - id: 99, - method: 'session/new', - params: {}, - }); - await new Promise((r) => setTimeout(r, 30)); - await post(connId, { - jsonrpc: '2.0', - id: 64, - method: '_qwen/session/artifacts/gc', - params: { sessionId: 'sess-1' }, - }); - const frames = await takeFrames(await streamRes, 2); - expect(frames[1]).toMatchObject({ - result: { removed: ['old'], retained: ['kept'] }, - }); - expect(bridge.lastGcSessionId).toBe('sess-1'); - expect(bridge.lastGcSessionContext).toEqual({ - clientId: 'client-1', - fromLoopback: true, - }); - }); - it('_qwen/session/artifacts/add holds the archive gate while mutating', async () => { await withRuntimeDir(async () => { const sessionId = '550e8400-e29b-41d4-a716-446655440131'; @@ -6457,13 +6243,11 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { sessionId, artifactId, context, - options, ) => { bridge.lastRemovedArtifact = { sessionId, artifactId, context, - options, }; removeStarted(); await removeReleasedPromise; diff --git a/packages/cli/src/serve/acp-session-bridge.ts b/packages/cli/src/serve/acp-session-bridge.ts index d0873d437b9..ace689254c5 100644 --- a/packages/cli/src/serve/acp-session-bridge.ts +++ b/packages/cli/src/serve/acp-session-bridge.ts @@ -85,9 +85,6 @@ export type { BridgeDaemonStatusSnapshot, AcpSessionBridge, HttpAcpBridge, - SessionArtifactPinRequest, - SessionArtifactRemoveRequest, - SessionArtifactUnpinRequest, } from '@qwen-code/acp-bridge/bridgeTypes'; export { diff --git a/packages/cli/src/serve/capabilities.ts b/packages/cli/src/serve/capabilities.ts index f71d1fe7126..cfaf71afd86 100644 --- a/packages/cli/src/serve/capabilities.ts +++ b/packages/cli/src/serve/capabilities.ts @@ -46,7 +46,6 @@ export const SERVE_CAPABILITY_REGISTRY = { session_events: { since: 'v1' }, session_artifacts: { since: 'v1' }, session_artifacts_persistence: { since: 'v1' }, - session_artifacts_content_retention: { since: 'v1' }, // Daemon emits `slow_client_warning` synthetic frames at 75% queue // fill and honors `?maxQueued=N` (range [16, 2048]) on // `GET /session/:id/events`. Old daemons silently lack both — SDK @@ -305,7 +304,6 @@ export interface AdvertiseFeatureToggles { voiceTranscriptionAvailable?: boolean; sessionShellCommandEnabled?: boolean; sessionArtifactsPersistenceAvailable?: boolean; - sessionArtifactsContentRetentionAvailable?: boolean; rateLimit?: boolean; reloadAvailable?: boolean; /** @@ -387,10 +385,6 @@ export const CONDITIONAL_SERVE_FEATURES: ReadonlyMap< 'session_artifacts_persistence', (toggles) => toggles.sessionArtifactsPersistenceAvailable === true, ], - [ - 'session_artifacts_content_retention', - (toggles) => toggles.sessionArtifactsContentRetentionAvailable === true, - ], ['rate_limit', (toggles) => toggles.rateLimit === true], ['workspace_reload', (toggles) => toggles.reloadAvailable === true], ['client_mcp_over_ws', (toggles) => toggles.clientMcpOverWsEnabled === true], diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index bfca3428b09..52a606a9f8a 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -27,9 +27,6 @@ import { SessionShellClientRequiredError, SessionShellDisabledError, type AcpSessionBridge, - type SessionArtifactPinRequest, - type SessionArtifactRemoveRequest, - type SessionArtifactUnpinRequest, } from '../acp-session-bridge.js'; import type { DaemonLogger } from '../daemon-logger.js'; import type { SendBridgeError } from '../server/error-response.js'; @@ -76,8 +73,6 @@ interface RegisterSessionRoutesDeps { languageCodes: string[]; } -const SESSION_ARTIFACT_MAX_TTL_DAYS = 365; - function requireSessionArtifactClientId( clientId: string | undefined, res: Response, @@ -106,108 +101,6 @@ function sendArtifactValidationError(res: Response, err: unknown): boolean { return true; } -function parseArtifactPinRequest(req: Request): SessionArtifactPinRequest { - const body = safeBody(req); - const mode = body['mode']; - const ttlDays = body['ttlDays']; - const clientRetained = body['clientRetained']; - const options: SessionArtifactPinRequest = {}; - if (mode !== undefined) { - if (mode !== 'metadata' && mode !== 'content') { - throw new SessionArtifactValidationError( - 'mode must be "metadata" or "content"', - 'mode', - ); - } - options.mode = mode; - } - if (ttlDays !== undefined) { - if (mode === 'metadata') { - throw new SessionArtifactValidationError( - 'ttlDays is only valid with content pinning', - 'ttlDays', - ); - } - if ( - typeof ttlDays !== 'number' || - !Number.isSafeInteger(ttlDays) || - ttlDays <= 0 - ) { - throw new SessionArtifactValidationError( - 'ttlDays must be a positive safe integer', - 'ttlDays', - ); - } - if (ttlDays > SESSION_ARTIFACT_MAX_TTL_DAYS) { - throw new SessionArtifactValidationError( - `ttlDays must be at most ${SESSION_ARTIFACT_MAX_TTL_DAYS}`, - 'ttlDays', - ); - } - options.ttlDays = ttlDays; - } - if (clientRetained !== undefined) { - if (typeof clientRetained !== 'boolean') { - throw new SessionArtifactValidationError( - 'clientRetained must be a boolean', - 'clientRetained', - ); - } - options.clientRetained = clientRetained; - } - return options; -} - -function parseArtifactRemoveRequest( - req: Request, -): SessionArtifactRemoveRequest { - const body = safeBody(req); - const deleteContent = body['deleteContent']; - if (deleteContent === undefined) { - return {}; - } - if (typeof deleteContent !== 'boolean') { - throw new SessionArtifactValidationError( - 'deleteContent must be a boolean', - 'deleteContent', - ); - } - return { deleteContent }; -} - -function parseArtifactUnpinRequest(req: Request): SessionArtifactUnpinRequest { - const body = safeBody(req); - const retention = body['retention']; - if (retention === undefined) { - return {}; - } - if (retention !== 'ephemeral' && retention !== 'restorable') { - throw new SessionArtifactValidationError( - 'retention must be "ephemeral" or "restorable"', - 'retention', - ); - } - return { retention }; -} - -function nonEmptyArtifactPinRequest( - options: SessionArtifactPinRequest, -): SessionArtifactPinRequest | undefined { - return Object.keys(options).length === 0 ? undefined : options; -} - -function nonEmptyArtifactRemoveRequest( - options: SessionArtifactRemoveRequest, -): SessionArtifactRemoveRequest | undefined { - return Object.keys(options).length === 0 ? undefined : options; -} - -function nonEmptyArtifactUnpinRequest( - options: SessionArtifactUnpinRequest, -): SessionArtifactUnpinRequest | undefined { - return Object.keys(options).length === 0 ? undefined : options; -} - function sendSessionOrganizationError(res: Response, err: unknown): boolean { if (!(err instanceof SessionOrganizationError)) { return false; @@ -803,129 +696,6 @@ export function registerSessionRoutes( ), ); - app.post( - '/session/:id/artifacts/:artifactId/pin', - mutate({ strict: true }), - withMutableSession( - 'POST /session/:id/artifacts/:artifactId/pin', - async (req, res, sessionId) => { - const artifactId = req.params['artifactId']; - const clientId = parseClientIdHeader(req, res); - if (clientId === null) return; - if (!requireSessionArtifactClientId(clientId, res)) return; - if (!artifactId) { - res.status(400).json({ - v: 1, - error: { - code: 'VALIDATION_FAILED', - message: '`artifactId` route parameter is required', - field: 'artifactId', - }, - }); - return; - } - try { - const options = parseArtifactPinRequest(req); - const result = await bridge.pinSessionArtifact( - sessionId, - artifactId, - { clientId }, - nonEmptyArtifactPinRequest(options), - ); - res.status(200).json(result); - } catch (err) { - if (sendArtifactValidationError(res, err)) return; - sendBridgeError(res, err, { - route: 'POST /session/:id/artifacts/:artifactId/pin', - sessionId, - }); - } - }, - ), - ); - - app.delete( - '/session/:id/artifacts/:artifactId/pin', - mutate({ strict: true }), - withMutableSession( - 'DELETE /session/:id/artifacts/:artifactId/pin', - async (req, res, sessionId) => { - const artifactId = req.params['artifactId']; - const clientId = parseClientIdHeader(req, res); - if (clientId === null) return; - if (!requireSessionArtifactClientId(clientId, res)) return; - if (!artifactId) { - res.status(400).json({ - v: 1, - error: { - code: 'VALIDATION_FAILED', - message: '`artifactId` route parameter is required', - field: 'artifactId', - }, - }); - return; - } - try { - const options = parseArtifactUnpinRequest(req); - const result = await bridge.unpinSessionArtifact( - sessionId, - artifactId, - { clientId }, - nonEmptyArtifactUnpinRequest(options), - ); - res.status(200).json(result); - } catch (err) { - if (sendArtifactValidationError(res, err)) return; - sendBridgeError(res, err, { - route: 'DELETE /session/:id/artifacts/:artifactId/pin', - sessionId, - }); - } - }, - ), - ); - - app.get('/session/:id/artifacts/fsck', async (req, res) => { - const sessionId = requireSessionId(req, res); - if (sessionId === null) return; - const clientId = parseClientIdHeader(req, res); - if (clientId === null) return; - if (!requireSessionArtifactClientId(clientId, res)) return; - try { - res - .status(200) - .json(await bridge.fsckSessionArtifacts(sessionId, { clientId })); - } catch (err) { - sendBridgeError(res, err, { - route: 'GET /session/:id/artifacts/fsck', - sessionId, - }); - } - }); - - app.post( - '/session/:id/artifacts/gc', - mutate({ strict: true }), - withMutableSession( - 'POST /session/:id/artifacts/gc', - async (req, res, sessionId) => { - const clientId = parseClientIdHeader(req, res); - if (clientId === null) return; - if (!requireSessionArtifactClientId(clientId, res)) return; - try { - res - .status(200) - .json(await bridge.gcSessionArtifacts(sessionId, { clientId })); - } catch (err) { - sendBridgeError(res, err, { - route: 'POST /session/:id/artifacts/gc', - sessionId, - }); - } - }, - ), - ); - app.delete( '/session/:id/artifacts/:artifactId', mutate({ strict: true }), @@ -948,12 +718,10 @@ export function registerSessionRoutes( return; } try { - const options = parseArtifactRemoveRequest(req); const result = await bridge.removeSessionArtifact( sessionId, artifactId, { clientId }, - nonEmptyArtifactRemoveRequest(options), ); res.status(200).json(result); } catch (err) { diff --git a/packages/cli/src/serve/run-qwen-serve.ts b/packages/cli/src/serve/run-qwen-serve.ts index 0513f81b158..9d9633d5b12 100644 --- a/packages/cli/src/serve/run-qwen-serve.ts +++ b/packages/cli/src/serve/run-qwen-serve.ts @@ -788,7 +788,6 @@ function currentServeFeaturesForRunQwenServe( persistSettingAvailable: true, sessionShellCommandEnabled, sessionArtifactsPersistenceAvailable: true, - sessionArtifactsContentRetentionAvailable: true, rateLimit: opts.rateLimit === true, reloadAvailable: true, // Advertise the same WS feature flags as the runtime path (serve-features.ts) diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 8127e41b54c..6c745435e9b 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -296,11 +296,7 @@ const EXPECTED_REGISTERED_FEATURES = [ // stage1 order. ...EXPECTED_STAGE1_FEATURES.flatMap((feature) => feature === 'session_artifacts' - ? [ - feature, - 'session_artifacts_persistence', - 'session_artifacts_content_retention', - ] + ? [feature, 'session_artifacts_persistence'] : [feature], ).filter( (f) => @@ -422,10 +418,6 @@ interface FakeBridgeOpts { getSessionArtifactsImpl?: AcpSessionBridge['getSessionArtifacts']; addSessionArtifactImpl?: AcpSessionBridge['addSessionArtifact']; removeSessionArtifactImpl?: AcpSessionBridge['removeSessionArtifact']; - pinSessionArtifactImpl?: AcpSessionBridge['pinSessionArtifact']; - unpinSessionArtifactImpl?: AcpSessionBridge['unpinSessionArtifact']; - fsckSessionArtifactsImpl?: AcpSessionBridge['fsckSessionArtifacts']; - gcSessionArtifactsImpl?: AcpSessionBridge['gcSessionArtifacts']; workspaceMcpImpl?: () => Promise; workspaceMcpToolsImpl?: ( serverName: string, @@ -655,27 +647,6 @@ interface FakeBridge extends AcpSessionBridge { sessionId: string; artifactId: string; context?: BridgeClientRequestContext; - options?: Parameters[3]; - }>; - pinSessionArtifactCalls: Array<{ - sessionId: string; - artifactId: string; - context?: BridgeClientRequestContext; - options?: Parameters[3]; - }>; - unpinSessionArtifactCalls: Array<{ - sessionId: string; - artifactId: string; - context?: BridgeClientRequestContext; - options?: Parameters[3]; - }>; - fsckSessionArtifactsCalls: Array<{ - sessionId: string; - context?: BridgeClientRequestContext; - }>; - gcSessionArtifactsCalls: Array<{ - sessionId: string; - context?: BridgeClientRequestContext; }>; workspaceMcpCalls: number; workspaceMcpToolsCalls: string[]; @@ -825,10 +796,6 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { const addSessionArtifactCalls: FakeBridge['addSessionArtifactCalls'] = []; const removeSessionArtifactCalls: FakeBridge['removeSessionArtifactCalls'] = []; - const pinSessionArtifactCalls: FakeBridge['pinSessionArtifactCalls'] = []; - const unpinSessionArtifactCalls: FakeBridge['unpinSessionArtifactCalls'] = []; - const fsckSessionArtifactsCalls: FakeBridge['fsckSessionArtifactsCalls'] = []; - const gcSessionArtifactsCalls: FakeBridge['gcSessionArtifactsCalls'] = []; let workspaceMcpCalls = 0; const workspaceMcpToolsCalls: string[] = []; const workspaceMcpResourcesCalls: string[] = []; @@ -976,26 +943,6 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { }, ], })); - const pinSessionArtifactImpl = - opts.pinSessionArtifactImpl ?? - ((sessionId, artifactId) => ({ - v: 1 as const, - sessionId, - changes: [{ action: 'updated' as const, artifactId }], - })); - const unpinSessionArtifactImpl = - opts.unpinSessionArtifactImpl ?? - ((sessionId, artifactId) => ({ - v: 1 as const, - sessionId, - changes: [{ action: 'updated' as const, artifactId }], - })); - const fsckSessionArtifactsImpl = - opts.fsckSessionArtifactsImpl ?? - (async () => ({ checked: 0, missing: [], hashMismatches: [] })); - const gcSessionArtifactsImpl = - opts.gcSessionArtifactsImpl ?? - (async () => ({ removed: [], retained: [] })); const workspaceMcpImpl = opts.workspaceMcpImpl ?? (async () => ({ @@ -1349,10 +1296,6 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { sessionArtifactsCalls, addSessionArtifactCalls, removeSessionArtifactCalls, - pinSessionArtifactCalls, - unpinSessionArtifactCalls, - fsckSessionArtifactsCalls, - gcSessionArtifactsCalls, workspaceMcpToolsCalls, workspaceMcpResourcesCalls, extensionEvents, @@ -1521,46 +1464,13 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { }); return addSessionArtifactImpl(sessionId, artifact, context); }, - async removeSessionArtifact(sessionId, artifactId, context, options) { + async removeSessionArtifact(sessionId, artifactId, context) { removeSessionArtifactCalls.push({ sessionId, artifactId, ...(context ? { context } : {}), - ...(options ? { options } : {}), - }); - return removeSessionArtifactImpl(sessionId, artifactId, context, options); - }, - async pinSessionArtifact(sessionId, artifactId, context, options) { - pinSessionArtifactCalls.push({ - sessionId, - artifactId, - ...(context ? { context } : {}), - ...(options ? { options } : {}), - }); - return pinSessionArtifactImpl(sessionId, artifactId, context, options); - }, - async unpinSessionArtifact(sessionId, artifactId, context, options) { - unpinSessionArtifactCalls.push({ - sessionId, - artifactId, - ...(context ? { context } : {}), - ...(options ? { options } : {}), - }); - return unpinSessionArtifactImpl(sessionId, artifactId, context, options); - }, - async fsckSessionArtifacts(sessionId, context) { - fsckSessionArtifactsCalls.push({ - sessionId, - ...(context ? { context } : {}), - }); - return fsckSessionArtifactsImpl(sessionId, context); - }, - async gcSessionArtifacts(sessionId, context) { - gcSessionArtifactsCalls.push({ - sessionId, - ...(context ? { context } : {}), }); - return gcSessionArtifactsImpl(sessionId, context); + return removeSessionArtifactImpl(sessionId, artifactId, context); }, async getWorkspaceMcpStatus() { workspaceMcpCalls += 1; @@ -2150,24 +2060,6 @@ describe('createServeApp', () => { ); continue; } - if (feature === 'session_artifacts_content_retention') { - expect( - predicate({ sessionArtifactsContentRetentionAvailable: true }), - ).toBe(true); - expect( - predicate({ sessionArtifactsContentRetentionAvailable: false }), - ).toBe(false); - expect(predicate({})).toBe(false); - expect( - getAdvertisedServeFeatures(undefined, { - sessionArtifactsContentRetentionAvailable: true, - }), - ).toContain(feature); - expect(getAdvertisedServeFeatures(undefined, {})).not.toContain( - feature, - ); - continue; - } if (feature === 'workspace_reload') { expect(predicate({ reloadAvailable: true })).toBe(true); expect(predicate({ reloadAvailable: false })).toBe(false); @@ -2570,7 +2462,6 @@ describe('createServeApp', () => { getAdvertisedServeFeatures(undefined, { mcpPoolActive: true, sessionArtifactsPersistenceAvailable: true, - sessionArtifactsContentRetentionAvailable: true, }), ); expect(res.body.modelServices).toEqual([]); @@ -7793,32 +7684,6 @@ describe('createServeApp', () => { expect(bridge.sessionArtifactsCalls).toEqual([]); }); - it('POST /session/:id/artifacts/:artifactId/pin requires a client id', async () => { - const bridge = fakeBridge(); - const app = createServeApp(tokenOpts, undefined, { bridge }); - - const res = await auth( - request(app).post('/session/session-A/artifacts/artifact-1/pin'), - ); - - expect(res.status).toBe(403); - expect(res.body.code).toBe('client_id_required'); - expect(bridge.pinSessionArtifactCalls).toEqual([]); - }); - - it('DELETE /session/:id/artifacts/:artifactId/pin requires a client id', async () => { - const bridge = fakeBridge(); - const app = createServeApp(tokenOpts, undefined, { bridge }); - - const res = await auth( - request(app).delete('/session/session-A/artifacts/artifact-1/pin'), - ); - - expect(res.status).toBe(403); - expect(res.body.code).toBe('client_id_required'); - expect(bridge.unpinSessionArtifactCalls).toEqual([]); - }); - it('GET /session/:id/artifacts returns 404 for an unknown session', async () => { const bridge = fakeBridge({ getSessionArtifactsImpl: async (sessionId) => { @@ -7979,35 +7844,12 @@ describe('createServeApp', () => { ]); }); - it('DELETE /session/:id/artifacts/:artifactId forwards content deletion option', async () => { + it('DELETE /session/:id/artifacts/:artifactId forwards client context', async () => { const bridge = fakeBridge(); const app = createServeApp(tokenOpts, undefined, { bridge }); const res = await auth( - request(app) - .delete('/session/session-A/artifacts/artifact-1') - .send({ deleteContent: true }), - ).set('X-Qwen-Client-Id', 'client-1'); - - expect(res.status).toBe(200); - expect(bridge.removeSessionArtifactCalls).toEqual([ - { - sessionId: 'session-A', - artifactId: 'artifact-1', - context: { clientId: 'client-1' }, - options: { deleteContent: true }, - }, - ]); - }); - - it('DELETE /session/:id/artifacts/:artifactId forwards explicit content retention option', async () => { - const bridge = fakeBridge(); - const app = createServeApp(tokenOpts, undefined, { bridge }); - - const res = await auth( - request(app) - .delete('/session/session-A/artifacts/artifact-1') - .send({ deleteContent: false }), + request(app).delete('/session/session-A/artifacts/artifact-1'), ).set('X-Qwen-Client-Id', 'client-1'); expect(res.status).toBe(200); @@ -8016,33 +7858,10 @@ describe('createServeApp', () => { sessionId: 'session-A', artifactId: 'artifact-1', context: { clientId: 'client-1' }, - options: { deleteContent: false }, }, ]); }); - it('DELETE /session/:id/artifacts/:artifactId maps artifact validation errors', async () => { - const bridge = fakeBridge(); - const app = createServeApp(tokenOpts, undefined, { bridge }); - - const res = await auth( - request(app) - .delete('/session/session-A/artifacts/artifact-1') - .send({ deleteContent: 'yes' }), - ).set('X-Qwen-Client-Id', 'client-1'); - - expect(res.status).toBe(400); - expect(res.body).toEqual({ - v: 1, - error: { - code: 'VALIDATION_FAILED', - message: 'deleteContent must be a boolean', - field: 'deleteContent', - }, - }); - expect(bridge.removeSessionArtifactCalls).toEqual([]); - }); - it('DELETE /session/:id/artifacts/:artifactId maps artifact authorization errors', async () => { const bridge = fakeBridge({ removeSessionArtifactImpl: async () => { @@ -8068,208 +7887,6 @@ describe('createServeApp', () => { artifactId: 'artifact-1', }); }); - - it('POST /session/:id/artifacts/:artifactId/pin forwards mutation auth context', async () => { - const bridge = fakeBridge(); - const app = createServeApp(tokenOpts, undefined, { bridge }); - - const res = await auth( - request(app).post('/session/session-A/artifacts/artifact-1/pin'), - ).set('X-Qwen-Client-Id', 'client-1'); - - expect(res.status).toBe(200); - expect(res.body).toMatchObject({ - v: 1, - sessionId: 'session-A', - changes: [{ action: 'updated', artifactId: 'artifact-1' }], - }); - expect(bridge.pinSessionArtifactCalls).toEqual([ - { - sessionId: 'session-A', - artifactId: 'artifact-1', - context: { clientId: 'client-1' }, - }, - ]); - }); - - it('POST /session/:id/artifacts/:artifactId/pin forwards retention options', async () => { - const bridge = fakeBridge(); - const app = createServeApp(tokenOpts, undefined, { bridge }); - - const res = await auth( - request(app) - .post('/session/session-A/artifacts/artifact-1/pin') - .send({ mode: 'content', ttlDays: 7, clientRetained: false }), - ).set('X-Qwen-Client-Id', 'client-1'); - - expect(res.status).toBe(200); - expect(bridge.pinSessionArtifactCalls).toEqual([ - { - sessionId: 'session-A', - artifactId: 'artifact-1', - context: { clientId: 'client-1' }, - options: { mode: 'content', ttlDays: 7, clientRetained: false }, - }, - ]); - }); - - it('POST /session/:id/artifacts/:artifactId/pin rejects ttlDays for metadata pinning', async () => { - const bridge = fakeBridge(); - const app = createServeApp(tokenOpts, undefined, { bridge }); - - const res = await auth( - request(app) - .post('/session/session-A/artifacts/artifact-1/pin') - .send({ mode: 'metadata', ttlDays: 7 }), - ).set('X-Qwen-Client-Id', 'client-1'); - - expect(res.status).toBe(400); - expect(res.body.error).toMatchObject({ - code: 'VALIDATION_FAILED', - field: 'ttlDays', - }); - expect(bridge.pinSessionArtifactCalls).toHaveLength(0); - }); - - it('POST /session/:id/artifacts/:artifactId/pin rejects ttlDays above the maximum', async () => { - const bridge = fakeBridge(); - const app = createServeApp(tokenOpts, undefined, { bridge }); - - const res = await auth( - request(app) - .post('/session/session-A/artifacts/artifact-1/pin') - .send({ mode: 'content', ttlDays: 366 }), - ).set('X-Qwen-Client-Id', 'client-1'); - - expect(res.status).toBe(400); - expect(res.body.error).toMatchObject({ - code: 'VALIDATION_FAILED', - field: 'ttlDays', - }); - expect(bridge.pinSessionArtifactCalls).toHaveLength(0); - }); - - it('DELETE /session/:id/artifacts/:artifactId/pin forwards mutation auth context', async () => { - const bridge = fakeBridge(); - const app = createServeApp(tokenOpts, undefined, { bridge }); - - const res = await auth( - request(app).delete('/session/session-A/artifacts/artifact-1/pin'), - ).set('X-Qwen-Client-Id', 'client-1'); - - expect(res.status).toBe(200); - expect(res.body).toMatchObject({ - v: 1, - sessionId: 'session-A', - changes: [{ action: 'updated', artifactId: 'artifact-1' }], - }); - expect(bridge.unpinSessionArtifactCalls).toEqual([ - { - sessionId: 'session-A', - artifactId: 'artifact-1', - context: { clientId: 'client-1' }, - }, - ]); - }); - - it('DELETE /session/:id/artifacts/:artifactId/pin forwards target retention', async () => { - const bridge = fakeBridge(); - const app = createServeApp(tokenOpts, undefined, { bridge }); - - const res = await auth( - request(app) - .delete('/session/session-A/artifacts/artifact-1/pin') - .send({ retention: 'ephemeral' }), - ).set('X-Qwen-Client-Id', 'client-1'); - - expect(res.status).toBe(200); - expect(bridge.unpinSessionArtifactCalls).toEqual([ - { - sessionId: 'session-A', - artifactId: 'artifact-1', - context: { clientId: 'client-1' }, - options: { retention: 'ephemeral' }, - }, - ]); - }); - - it('GET /session/:id/artifacts/fsck returns content integrity status', async () => { - const bridge = fakeBridge({ - fsckSessionArtifactsImpl: async () => ({ - checked: 2, - missing: ['missing-content'], - hashMismatches: ['bad-hash'], - }), - }); - const app = createServeApp(tokenOpts, undefined, { bridge }); - - const res = await auth( - request(app) - .get('/session/session-A/artifacts/fsck') - .set('X-Qwen-Client-Id', 'client-1'), - ); - - expect(res.status).toBe(200); - expect(res.body).toEqual({ - checked: 2, - missing: ['missing-content'], - hashMismatches: ['bad-hash'], - }); - expect(bridge.fsckSessionArtifactsCalls).toEqual([ - { sessionId: 'session-A', context: { clientId: 'client-1' } }, - ]); - }); - - it('GET /session/:id/artifacts/fsck requires a client id', async () => { - const bridge = fakeBridge(); - const app = createServeApp(tokenOpts, undefined, { bridge }); - - const res = await auth( - request(app).get('/session/session-A/artifacts/fsck'), - ); - - expect(res.status).toBe(403); - expect(res.body.code).toBe('client_id_required'); - expect(bridge.fsckSessionArtifactsCalls).toEqual([]); - }); - - it('POST /session/:id/artifacts/gc returns content cleanup result', async () => { - const bridge = fakeBridge({ - gcSessionArtifactsImpl: async () => ({ - removed: ['orphaned'], - retained: ['kept'], - }), - }); - const app = createServeApp(tokenOpts, undefined, { bridge }); - - const res = await auth( - request(app) - .post('/session/session-A/artifacts/gc') - .set('X-Qwen-Client-Id', 'client-1'), - ); - - expect(res.status).toBe(200); - expect(res.body).toEqual({ - removed: ['orphaned'], - retained: ['kept'], - }); - expect(bridge.gcSessionArtifactsCalls).toEqual([ - { sessionId: 'session-A', context: { clientId: 'client-1' } }, - ]); - }); - - it('POST /session/:id/artifacts/gc requires a client id', async () => { - const bridge = fakeBridge(); - const app = createServeApp(tokenOpts, undefined, { bridge }); - - const res = await auth( - request(app).post('/session/session-A/artifacts/gc'), - ); - - expect(res.status).toBe(403); - expect(res.body.code).toBe('client_id_required'); - expect(bridge.gcSessionArtifactsCalls).toEqual([]); - }); }); describe('POST /session/:id/model', () => { diff --git a/packages/cli/src/serve/server/serve-features.ts b/packages/cli/src/serve/server/serve-features.ts index fa0ebb14345..30e1acdde77 100644 --- a/packages/cli/src/serve/server/serve-features.ts +++ b/packages/cli/src/serve/server/serve-features.ts @@ -79,7 +79,6 @@ export function createServeFeatures( persistSettingAvailable, sessionShellCommandEnabled, sessionArtifactsPersistenceAvailable: true, - sessionArtifactsContentRetentionAvailable: true, rateLimit: opts.rateLimit === true, reloadAvailable, clientMcpOverWsEnabled: opts.clientMcpOverWs === true, diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts index 9f677d2a9cd..bc353457ab1 100644 --- a/packages/sdk-typescript/src/daemon/DaemonClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -106,12 +106,7 @@ import type { DaemonToolToggleResult, DaemonSessionArtifactInput, DaemonSessionArtifactMutationResult, - DaemonSessionArtifactPinOptions, - DaemonSessionArtifactRemoveOptions, - DaemonSessionArtifactUnpinOptions, DaemonSessionArtifactsEnvelope, - DaemonSessionArtifactFsckResult, - DaemonSessionArtifactGcResult, DaemonRewindSnapshotInfo, DaemonRewindResult, ForkSessionRequest, @@ -3189,7 +3184,6 @@ export class DaemonClient { sessionId: string, artifactId: string, clientId?: string, - options?: DaemonSessionArtifactRemoveOptions, ): Promise { return await this.jsonRequest( `/session/${urlEncode(sessionId)}/artifacts/${urlEncode(artifactId)}`, @@ -3197,66 +3191,6 @@ export class DaemonClient { { method: 'DELETE', clientId, - ...(options !== undefined ? { body: options } : {}), - }, - ); - } - - async pinSessionArtifact( - sessionId: string, - artifactId: string, - clientId?: string, - options?: DaemonSessionArtifactPinOptions, - ): Promise { - return await this.jsonRequest( - `/session/${encodeURIComponent(sessionId)}/artifacts/${encodeURIComponent(artifactId)}/pin`, - 'POST /session/:id/artifacts/:artifactId/pin', - { - method: 'POST', - clientId, - ...(options !== undefined ? { body: options } : {}), - }, - ); - } - - async unpinSessionArtifact( - sessionId: string, - artifactId: string, - clientId?: string, - options?: DaemonSessionArtifactUnpinOptions, - ): Promise { - return await this.jsonRequest( - `/session/${encodeURIComponent(sessionId)}/artifacts/${encodeURIComponent(artifactId)}/pin`, - 'DELETE /session/:id/artifacts/:artifactId/pin', - { - method: 'DELETE', - clientId, - ...(options !== undefined ? { body: options } : {}), - }, - ); - } - - async fsckSessionArtifacts( - sessionId: string, - clientId?: string, - ): Promise { - return await this.jsonRequest( - `/session/${encodeURIComponent(sessionId)}/artifacts/fsck`, - 'GET /session/:id/artifacts/fsck', - { clientId }, - ); - } - - async gcSessionArtifacts( - sessionId: string, - clientId?: string, - ): Promise { - return await this.jsonRequest( - `/session/${encodeURIComponent(sessionId)}/artifacts/gc`, - 'POST /session/:id/artifacts/gc', - { - method: 'POST', - clientId, }, ); } diff --git a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts index fa2313f0449..b8f317f2977 100644 --- a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts @@ -31,12 +31,7 @@ import type { DaemonSessionRecapResult, DaemonShellCommandResult, DaemonSessionArtifactInput, - DaemonSessionArtifactFsckResult, - DaemonSessionArtifactGcResult, DaemonSessionArtifactMutationResult, - DaemonSessionArtifactPinOptions, - DaemonSessionArtifactRemoveOptions, - DaemonSessionArtifactUnpinOptions, DaemonSessionArtifactsEnvelope, DaemonSessionState, DaemonSession, @@ -434,51 +429,14 @@ export class DaemonSessionClient { async removeArtifact( artifactId: string, - options?: DaemonSessionArtifactRemoveOptions, ): Promise { return await this.client.removeSessionArtifact( this.sessionId, artifactId, this.clientId, - options, ); } - async pinArtifact( - artifactId: string, - options?: DaemonSessionArtifactPinOptions, - ): Promise { - return await this.client.pinSessionArtifact( - this.sessionId, - artifactId, - this.clientId, - options, - ); - } - - async unpinArtifact( - artifactId: string, - options?: DaemonSessionArtifactUnpinOptions, - ): Promise { - return await this.client.unpinSessionArtifact( - this.sessionId, - artifactId, - this.clientId, - options, - ); - } - - async fsckArtifacts(): Promise { - return await this.client.fsckSessionArtifacts( - this.sessionId, - this.clientId, - ); - } - - async gcArtifacts(): Promise { - return await this.client.gcSessionArtifacts(this.sessionId, this.clientId); - } - async setModel(modelId: string): Promise { return await this.client.setSessionModel( this.sessionId, diff --git a/packages/sdk-typescript/src/daemon/acpRouteTable.ts b/packages/sdk-typescript/src/daemon/acpRouteTable.ts index bee1d13ea4f..5a20294c2b8 100644 --- a/packages/sdk-typescript/src/daemon/acpRouteTable.ts +++ b/packages/sdk-typescript/src/daemon/acpRouteTable.ts @@ -280,50 +280,6 @@ export const ROUTE_TABLE: readonly RouteEntry[] = [ }), }, }, - // POST /session/:id/artifacts/:artifactId/pin → _qwen/session/artifacts/pin - { - httpMethod: 'POST', - pattern: /^\/session\/([^/]+)\/artifacts\/([^/]+)\/pin$/, - mapping: { - method: '_qwen/session/artifacts/pin', - extractParams: (segs, body) => ({ - ...(isRecord(body) ? body : {}), - sessionId: segs[0], - artifactId: segs[1], - }), - }, - }, - // DELETE /session/:id/artifacts/:artifactId/pin → _qwen/session/artifacts/unpin - { - httpMethod: 'DELETE', - pattern: /^\/session\/([^/]+)\/artifacts\/([^/]+)\/pin$/, - mapping: { - method: '_qwen/session/artifacts/unpin', - extractParams: (segs, body) => ({ - ...(isRecord(body) ? body : {}), - sessionId: segs[0], - artifactId: segs[1], - }), - }, - }, - // GET /session/:id/artifacts/fsck → _qwen/session/artifacts/fsck - { - httpMethod: 'GET', - pattern: /^\/session\/([^/]+)\/artifacts\/fsck$/, - mapping: { - method: '_qwen/session/artifacts/fsck', - extractParams: (segs) => ({ sessionId: segs[0] }), - }, - }, - // POST /session/:id/artifacts/gc → _qwen/session/artifacts/gc - { - httpMethod: 'POST', - pattern: /^\/session\/([^/]+)\/artifacts\/gc$/, - mapping: { - method: '_qwen/session/artifacts/gc', - extractParams: (segs) => ({ sessionId: segs[0] }), - }, - }, // DELETE /session/:id/artifacts/:artifactId → _qwen/session/artifacts/remove { httpMethod: 'DELETE', diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts index 3138c3a8d90..a6d75768fdd 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -542,19 +542,13 @@ export type { DaemonSessionArtifact, DaemonSessionArtifactChange, DaemonSessionArtifactChangeAction, - DaemonSessionArtifactContentRef, - DaemonSessionArtifactFsckResult, - DaemonSessionArtifactGcResult, DaemonSessionArtifactInput, DaemonSessionArtifactKind, DaemonSessionArtifactMutationResult, - DaemonSessionArtifactPinOptions, DaemonSessionArtifactPersistenceWarning, - DaemonSessionArtifactRemoveOptions, DaemonSessionArtifactRemovalReason, DaemonSessionArtifactRestoreState, DaemonSessionArtifactRetention, - DaemonSessionArtifactUnpinOptions, DaemonSessionArtifactsEnvelope, DaemonSessionArtifactSource, DaemonSessionArtifactStatus, diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index 61663c2a63f..3a076b3b9cb 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -623,10 +623,7 @@ export type KnownDaemonSessionArtifactStatus = 'available' | 'missing'; export type DaemonSessionArtifactStatus = OpenStringUnion; -export type KnownDaemonSessionArtifactRetention = - | 'ephemeral' - | 'restorable' - | 'pinned'; +export type KnownDaemonSessionArtifactRetention = 'ephemeral' | 'restorable'; export type DaemonSessionArtifactRetention = OpenStringUnion; @@ -642,9 +639,6 @@ export type DaemonSessionArtifactRestoreState = export type KnownDaemonSessionArtifactPersistenceWarning = | 'persistence_unavailable' - | 'content_missing' - | 'content_expired' - | 'content_hash_mismatch' | 'metadata_only_restore' | 'restore_validation_failed' | 'sticky_override_active'; @@ -652,14 +646,6 @@ export type KnownDaemonSessionArtifactPersistenceWarning = export type DaemonSessionArtifactPersistenceWarning = OpenStringUnion; -export interface DaemonSessionArtifactContentRef { - kind: 'managed_copy'; - contentId: string; - sha256: string; - sizeBytes: number; - createdAt: string; -} - export interface DaemonSessionArtifactInput { kind?: KnownDaemonSessionArtifactKind; storage?: Exclude; @@ -671,7 +657,7 @@ export interface DaemonSessionArtifactInput { mimeType?: string; sizeBytes?: number; metadata?: Record; - retention?: Exclude; + retention?: KnownDaemonSessionArtifactRetention; clientRetained?: boolean; } @@ -692,9 +678,7 @@ export interface DaemonSessionArtifact { retention: DaemonSessionArtifactRetention; restoreState?: DaemonSessionArtifactRestoreState; persistenceWarning?: DaemonSessionArtifactPersistenceWarning; - contentRef?: DaemonSessionArtifactContentRef; persistedAt?: string; - expiresAt?: string; clientRetained: boolean; createdAt: string; updatedAt: string; @@ -743,31 +727,6 @@ export interface DaemonSessionArtifactMutationResult { warnings?: string[]; } -export interface DaemonSessionArtifactPinOptions { - mode?: 'metadata' | 'content'; - ttlDays?: number; - clientRetained?: boolean; -} - -export interface DaemonSessionArtifactRemoveOptions { - deleteContent?: boolean; -} - -export interface DaemonSessionArtifactUnpinOptions { - retention?: 'ephemeral' | 'restorable'; -} - -export interface DaemonSessionArtifactFsckResult { - checked: number; - missing: string[]; - hashMismatches: string[]; -} - -export interface DaemonSessionArtifactGcResult { - removed: string[]; - retained: string[]; -} - export type DaemonStatus = | 'ok' | 'warning' diff --git a/packages/sdk-typescript/test/unit/DaemonClient.test.ts b/packages/sdk-typescript/test/unit/DaemonClient.test.ts index 42690e2a0ce..f99cfea9e1e 100644 --- a/packages/sdk-typescript/test/unit/DaemonClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonClient.test.ts @@ -305,11 +305,6 @@ describe('DaemonClient', () => { await expect( client.removeSessionArtifact('session/1', 'artifact/1', 'client-1'), ).resolves.toEqual(result); - await expect( - client.removeSessionArtifact('session/1', 'artifact/1', 'client-1', { - deleteContent: true, - }), - ).resolves.toEqual(result); expect(calls[0]).toMatchObject({ url: 'http://daemon/session/session%2F1/artifacts/artifact%2F1', method: 'DELETE', @@ -318,117 +313,6 @@ describe('DaemonClient', () => { }, body: null, }); - expect(calls[1]).toMatchObject({ - url: 'http://daemon/session/session%2F1/artifacts/artifact%2F1', - method: 'DELETE', - headers: { - 'content-type': 'application/json', - 'x-qwen-client-id': 'client-1', - }, - body: JSON.stringify({ deleteContent: true }), - }); - }); - - it('pins and unpins session artifacts with encoded ids', async () => { - const result = { - v: 1 as const, - sessionId: 'session/1', - changes: [{ action: 'updated' as const, artifactId: 'artifact/1' }], - }; - const { fetch, calls } = recordingFetch(() => jsonResponse(200, result)); - const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); - - await expect( - client.pinSessionArtifact('session/1', 'artifact/1', 'client-1'), - ).resolves.toEqual(result); - await expect( - client.unpinSessionArtifact('session/1', 'artifact/1', 'client-1'), - ).resolves.toEqual(result); - await expect( - client.pinSessionArtifact('session/1', 'artifact/1', 'client-1', { - mode: 'content', - ttlDays: 7, - clientRetained: false, - }), - ).resolves.toEqual(result); - await expect( - client.unpinSessionArtifact('session/1', 'artifact/1', 'client-1', { - retention: 'ephemeral', - }), - ).resolves.toEqual(result); - - expect(calls[0]).toMatchObject({ - url: 'http://daemon/session/session%2F1/artifacts/artifact%2F1/pin', - method: 'POST', - headers: { 'x-qwen-client-id': 'client-1' }, - body: null, - }); - expect(calls[1]).toMatchObject({ - url: 'http://daemon/session/session%2F1/artifacts/artifact%2F1/pin', - method: 'DELETE', - headers: { 'x-qwen-client-id': 'client-1' }, - body: null, - }); - expect(calls[2]).toMatchObject({ - url: 'http://daemon/session/session%2F1/artifacts/artifact%2F1/pin', - method: 'POST', - headers: { - 'content-type': 'application/json', - 'x-qwen-client-id': 'client-1', - }, - body: JSON.stringify({ - mode: 'content', - ttlDays: 7, - clientRetained: false, - }), - }); - expect(calls[3]).toMatchObject({ - url: 'http://daemon/session/session%2F1/artifacts/artifact%2F1/pin', - method: 'DELETE', - headers: { - 'content-type': 'application/json', - 'x-qwen-client-id': 'client-1', - }, - body: JSON.stringify({ retention: 'ephemeral' }), - }); - }); - - it('runs fsck and gc for session artifact content', async () => { - const replies = [ - { checked: 1, missing: ['missing'], hashMismatches: [] }, - { removed: ['old'], retained: ['kept'] }, - ]; - const { fetch, calls } = recordingFetch(() => - jsonResponse(200, replies.shift()), - ); - const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); - - await expect( - client.fsckSessionArtifacts('session/1', 'client-1'), - ).resolves.toEqual({ - checked: 1, - missing: ['missing'], - hashMismatches: [], - }); - await expect( - client.gcSessionArtifacts('session/1', 'client-1'), - ).resolves.toEqual({ - removed: ['old'], - retained: ['kept'], - }); - - expect(calls[0]).toMatchObject({ - url: 'http://daemon/session/session%2F1/artifacts/fsck', - method: 'GET', - headers: { 'x-qwen-client-id': 'client-1' }, - body: null, - }); - expect(calls[1]).toMatchObject({ - url: 'http://daemon/session/session%2F1/artifacts/gc', - method: 'POST', - headers: { 'x-qwen-client-id': 'client-1' }, - body: null, - }); }); }); diff --git a/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts b/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts index ce31c1d0f75..f873846eafe 100644 --- a/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts @@ -472,34 +472,6 @@ describe('DaemonSessionClient', () => { ) { return jsonResponse(200, mutationResult); } - if ( - req.method === 'POST' && - req.url === 'http://daemon/session/s-1/artifacts/artifact-1/pin' - ) { - return jsonResponse(200, mutationResult); - } - if ( - req.method === 'DELETE' && - req.url === 'http://daemon/session/s-1/artifacts/artifact-1/pin' - ) { - return jsonResponse(200, mutationResult); - } - if ( - req.method === 'GET' && - req.url === 'http://daemon/session/s-1/artifacts/fsck' - ) { - return jsonResponse(200, { - checked: 1, - missing: [], - hashMismatches: [], - }); - } - if ( - req.method === 'POST' && - req.url === 'http://daemon/session/s-1/artifacts/gc' - ) { - return jsonResponse(200, { removed: [], retained: [] }); - } return jsonResponse(500, { error: `unexpected ${req.method} ${req.url}`, }); @@ -525,30 +497,11 @@ describe('DaemonSessionClient', () => { await expect(session.removeArtifact('artifact-1')).resolves.toEqual( mutationResult, ); - await expect(session.pinArtifact('artifact-1')).resolves.toEqual( - mutationResult, - ); - await expect(session.unpinArtifact('artifact-1')).resolves.toEqual( - mutationResult, - ); - await expect(session.fsckArtifacts()).resolves.toEqual({ - checked: 1, - missing: [], - hashMismatches: [], - }); - await expect(session.gcArtifacts()).resolves.toEqual({ - removed: [], - retained: [], - }); expect(calls.map((call) => call.headers['x-qwen-client-id'])).toEqual([ 'client-1', 'client-1', 'client-1', - 'client-1', - 'client-1', - 'client-1', - 'client-1', ]); expect(calls[1]?.body).toBe( JSON.stringify({ diff --git a/packages/sdk-typescript/test/unit/acpRouteTable.test.ts b/packages/sdk-typescript/test/unit/acpRouteTable.test.ts index 926c890c2c8..15bdb8f4c2f 100644 --- a/packages/sdk-typescript/test/unit/acpRouteTable.test.ts +++ b/packages/sdk-typescript/test/unit/acpRouteTable.test.ts @@ -358,66 +358,13 @@ describe('acpRouteTable – matchRoute', () => { expect(result).not.toBeNull(); expect(result!.mapping.method).toBe('_qwen/session/artifacts/remove'); expect( - result!.mapping.extractParams( - result!.segments, - { deleteContent: false }, - 'DELETE', - ), + result!.mapping.extractParams(result!.segments, undefined, 'DELETE'), ).toEqual({ sessionId: 's8', artifactId: 'art 1', - deleteContent: false, }); }); - it('POST /session/:id/artifacts/:artifactId/pin maps to _qwen/session/artifacts/pin', () => { - const result = matchRoute('/session/s8/artifacts/art%201/pin', 'POST'); - expect(result).not.toBeNull(); - expect(result!.mapping.method).toBe('_qwen/session/artifacts/pin'); - expect( - result!.mapping.extractParams( - result!.segments, - { mode: 'metadata' }, - 'POST', - ), - ).toEqual({ sessionId: 's8', artifactId: 'art 1', mode: 'metadata' }); - }); - - it('DELETE /session/:id/artifacts/:artifactId/pin maps to _qwen/session/artifacts/unpin', () => { - const result = matchRoute('/session/s8/artifacts/art%201/pin', 'DELETE'); - expect(result).not.toBeNull(); - expect(result!.mapping.method).toBe('_qwen/session/artifacts/unpin'); - expect( - result!.mapping.extractParams( - result!.segments, - { retention: 'ephemeral' }, - 'DELETE', - ), - ).toEqual({ - sessionId: 's8', - artifactId: 'art 1', - retention: 'ephemeral', - }); - }); - - it('GET /session/:id/artifacts/fsck maps to _qwen/session/artifacts/fsck', () => { - const result = matchRoute('/session/s8/artifacts/fsck', 'GET'); - expect(result).not.toBeNull(); - expect(result!.mapping.method).toBe('_qwen/session/artifacts/fsck'); - expect( - result!.mapping.extractParams(result!.segments, undefined, 'GET'), - ).toEqual({ sessionId: 's8' }); - }); - - it('POST /session/:id/artifacts/gc maps to _qwen/session/artifacts/gc', () => { - const result = matchRoute('/session/s8/artifacts/gc', 'POST'); - expect(result).not.toBeNull(); - expect(result!.mapping.method).toBe('_qwen/session/artifacts/gc'); - expect( - result!.mapping.extractParams(result!.segments, undefined, 'POST'), - ).toEqual({ sessionId: 's8' }); - }); - it('POST /session/:id/recap maps to _qwen/session/recap', () => { const result = matchRoute('/session/s9/recap', 'POST'); expect(result).not.toBeNull(); From c8b7aa6380b3b8c74b6e2df05bc71c80172fc99a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Mon, 6 Jul 2026 00:34:00 +0800 Subject: [PATCH 34/61] fix(daemon): persist restored artifact snapshots --- packages/acp-bridge/src/bridge.test.ts | 118 ++++++++++++++++++ packages/acp-bridge/src/bridge.ts | 11 ++ .../acp-bridge/src/sessionArtifacts.test.ts | 9 +- packages/acp-bridge/src/sessionArtifacts.ts | 71 +++++++---- .../session-artifact-persistence.test.ts | 47 +++++++ .../services/session-artifact-persistence.ts | 16 ++- 6 files changed, 246 insertions(+), 26 deletions(-) diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index 7636feaefb1..66a467d5549 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -1373,6 +1373,124 @@ describe('createAcpSessionBridge', () => { await bridge.shutdown(); }); + it('restores and persists artifact snapshots returned by rewind', async () => { + const retainedUrl = 'https://example.com/retained'; + const rewoundUrl = 'https://example.com/rewound'; + const rewoundArtifactId = stableSessionArtifactId( + SESS_A, + `url:${rewoundUrl}`, + ); + const persistedSnapshots: unknown[] = []; + const bridge = makeBridge({ + channelFactory: async () => + makeChannel({ + extMethodImpl: (method, params) => { + if (method === 'qwen/control/session/artifacts/persist') { + if (params['kind'] === 'snapshot') { + persistedSnapshots.push(params['payload']); + } + return {}; + } + expect(method).toBe('qwen/control/session/rewind'); + return { + targetTurnIndex: 0, + filesChanged: [], + filesFailed: [], + artifactSnapshot: { + v: SESSION_ARTIFACT_PERSISTENCE_VERSION, + sessionId: SESS_A, + sequence: 8, + artifacts: [ + { + id: rewoundArtifactId, + kind: 'link', + storage: 'external_url', + source: 'client', + status: 'available', + title: 'Rewound artifact', + url: rewoundUrl, + retention: 'restorable', + clientRetained: true, + createdAt: '2026-07-04T00:00:00.000Z', + updatedAt: '2026-07-04T00:00:00.000Z', + }, + ], + tombstonedIds: [], + stickyEphemeralIds: [], + warnings: [], + }, + }; + }, + }).channel, + }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + await bridge.addSessionArtifact( + session.sessionId, + { + title: 'Later artifact', + url: retainedUrl, + }, + { clientId: session.clientId }, + ); + + const abort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: abort.signal, + }); + const artifactChanges = (async () => { + const changes: unknown[] = []; + for await (const event of iter) { + if (event.type !== 'artifact_changed') continue; + changes.push((event.data as { change?: unknown }).change); + if (changes.length === 2) return changes; + } + return changes; + })(); + + await expect( + bridge.rewindSession( + session.sessionId, + { promptId: 'prompt-1' }, + { clientId: session.clientId }, + ), + ).resolves.toMatchObject({ targetTurnIndex: 0 }); + + await expect(artifactChanges).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ action: 'removed' }), + expect.objectContaining({ + action: 'created', + artifactId: rewoundArtifactId, + }), + ]), + ); + abort.abort(); + await expect( + bridge.getSessionArtifacts(session.sessionId), + ).resolves.toMatchObject({ + artifacts: [ + { + id: rewoundArtifactId, + title: 'Rewound artifact', + restoreState: 'restored', + }, + ], + }); + expect(persistedSnapshots).toEqual([ + expect.objectContaining({ + sessionId: session.sessionId, + artifacts: [ + expect.objectContaining({ + id: rewoundArtifactId, + title: 'Rewound artifact', + }), + ], + }), + ]); + + await bridge.shutdown(); + }); + it('loads history replay from response metadata when requested', async () => { const handles: ChannelHandle[] = []; const factory: ChannelFactory = async () => { diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index ff7a28d2585..3b0760f5c07 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -5935,6 +5935,10 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { const beforeArtifacts = (await entry.artifacts.list()).artifacts; const artifactRestoreWarnings = await entry.artifacts.restore(artifactSnapshot); + const artifactSnapshotWarnings = + artifactSnapshot === undefined + ? [] + : await entry.artifacts.recordSnapshot(); for (const warning of artifactRestoreWarnings) { writeStderrLine( `[artifacts] session=${entry.sessionId} action=rewind_restore_warning warning=${JSON.stringify( @@ -5942,6 +5946,13 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { )}`, ); } + for (const warning of artifactSnapshotWarnings) { + writeStderrLine( + `[artifacts] session=${entry.sessionId} action=rewind_snapshot_warning warning=${JSON.stringify( + warning, + )}`, + ); + } const afterArtifacts = (await entry.artifacts.list()).artifacts; publishArtifactChanges( entry, diff --git a/packages/acp-bridge/src/sessionArtifacts.test.ts b/packages/acp-bridge/src/sessionArtifacts.test.ts index fe391038c45..b2f68b4acf7 100644 --- a/packages/acp-bridge/src/sessionArtifacts.test.ts +++ b/packages/acp-bridge/src/sessionArtifacts.test.ts @@ -2574,7 +2574,7 @@ describe('SessionArtifactStore', () => { } }); - it('compacts explicit tombstones out of periodic snapshots', async () => { + it('keeps explicit tombstones in periodic snapshots', async () => { const snapshots: SessionArtifactSnapshotRecordPayload[] = []; const store = new SessionArtifactStore({ sessionId: 's11-tombstone-snapshot', @@ -2606,12 +2606,17 @@ describe('SessionArtifactStore', () => { expect(snapshots).toHaveLength(1); expect(snapshots[0]).toMatchObject({ - tombstonedIds: [], + tombstonedIds: [deletedId], stickyEphemeralIds: [], }); expect( snapshots[0]?.artifacts.some((artifact) => artifact.id === deletedId), ).toBe(false); + + const suppressed = await store.upsertMany([ + { title: 'Deleted', url: 'https://example.com/deleted' }, + ]); + expect(suppressed.changes).toEqual([]); }); it('suppresses implicit upserts for restored tombstones', async () => { diff --git a/packages/acp-bridge/src/sessionArtifacts.ts b/packages/acp-bridge/src/sessionArtifacts.ts index c682accc608..53e7968925f 100644 --- a/packages/acp-bridge/src/sessionArtifacts.ts +++ b/packages/acp-bridge/src/sessionArtifacts.ts @@ -806,6 +806,31 @@ export class SessionArtifactStore { }); } + async recordSnapshot(): Promise { + const persistence = this.persistence; + if (!persistence) { + return [ + 'artifact persistence unavailable; restored artifacts not snapshotted', + ]; + } + return this.enqueue(async () => { + const recordedAt = new Date().toISOString(); + try { + await persistence.recordSnapshot(this.buildSnapshotPayload(recordedAt)); + this.durableEventsSinceSnapshot = 0; + this.consecutiveSnapshotFailures = 0; + return []; + } catch (error) { + writeStderrLine( + `[artifacts] session=${this.sessionId} action=snapshot_failed reason=${JSON.stringify( + error instanceof Error ? error.message : String(error), + )}`, + ); + return ['artifact snapshot not persisted']; + } + }); + } + private cloneState(): { artifacts: Map; receivedSeq: number; @@ -946,6 +971,28 @@ export class SessionArtifactStore { if (this.durableEventsSinceSnapshot < snapshotThreshold) { return; } + try { + await this.persistence.recordSnapshot( + this.buildSnapshotPayload(recordedAt), + ); + this.durableEventsSinceSnapshot = 0; + this.consecutiveSnapshotFailures = 0; + } catch (error) { + this.consecutiveSnapshotFailures = Math.min( + this.consecutiveSnapshotFailures + 1, + Math.log2(MAX_SNAPSHOT_BACKOFF_MULTIPLIER), + ); + writeStderrLine( + `[artifacts] session=${this.sessionId} action=snapshot_failed reason=${JSON.stringify( + error instanceof Error ? error.message : String(error), + )}`, + ); + } + } + + private buildSnapshotPayload( + recordedAt: string, + ): SessionArtifactSnapshotRecordPayload { const artifacts = Array.from(this.artifacts.values()) .filter((artifact) => artifact.retention !== 'ephemeral') .sort((a, b) => a.insertSeq - b.insertSeq) @@ -955,35 +1002,15 @@ export class SessionArtifactStore { const stickyEphemeralIds = Array.from(this.stickyEphemeralIds).filter( (id) => this.artifacts.has(id), ); - const payload: SessionArtifactSnapshotRecordPayload = { + return { v: SESSION_ARTIFACT_PERSISTENCE_VERSION, sessionId: this.sessionId, sequence: ++this.persistenceSeq, recordedAt, artifacts, - tombstonedIds: [], + tombstonedIds: Array.from(this.tombstonedIds), stickyEphemeralIds, }; - try { - await this.persistence.recordSnapshot(payload); - this.tombstonedIds.clear(); - this.stickyEphemeralIds.clear(); - for (const id of stickyEphemeralIds) { - this.stickyEphemeralIds.add(id); - } - this.durableEventsSinceSnapshot = 0; - this.consecutiveSnapshotFailures = 0; - } catch (error) { - this.consecutiveSnapshotFailures = Math.min( - this.consecutiveSnapshotFailures + 1, - Math.log2(MAX_SNAPSHOT_BACKOFF_MULTIPLIER), - ); - writeStderrLine( - `[artifacts] session=${this.sessionId} action=snapshot_failed reason=${JSON.stringify( - error instanceof Error ? error.message : String(error), - )}`, - ); - } } private downgradeDurableChanges(changes: SessionArtifactChange[]): string[] { diff --git a/packages/core/src/services/session-artifact-persistence.test.ts b/packages/core/src/services/session-artifact-persistence.test.ts index e852b82e2e9..76183e97770 100644 --- a/packages/core/src/services/session-artifact-persistence.test.ts +++ b/packages/core/src/services/session-artifact-persistence.test.ts @@ -169,6 +169,53 @@ describe('session artifact persistence records', () => { expect(snapshot?.sequence).toBe(3); }); + it('warns when artifact records use an unsupported version', () => { + const restored = rebuildSessionArtifactSnapshot([ + { + type: 'system', + subtype: 'session_artifact_snapshot', + systemPayload: { + v: SESSION_ARTIFACT_PERSISTENCE_VERSION + 1, + sessionId: 's1', + sequence: 1, + recordedAt: '2026-07-04T00:00:00.000Z', + artifacts: [], + tombstonedIds: [], + stickyEphemeralIds: [], + }, + }, + { + type: 'system', + subtype: 'session_artifact_event', + systemPayload: { + v: SESSION_ARTIFACT_PERSISTENCE_VERSION + 1, + sessionId: 's1', + sequence: 2, + recordedAt: '2026-07-04T00:00:01.000Z', + changes: [], + }, + }, + { + type: 'system', + subtype: 'session_artifact_snapshot', + systemPayload: { + v: SESSION_ARTIFACT_PERSISTENCE_VERSION, + sessionId: 's1', + sequence: 3, + recordedAt: '2026-07-04T00:00:02.000Z', + artifacts: [], + tombstonedIds: [], + stickyEphemeralIds: [], + }, + }, + ]); + + expect(restored?.warnings).toEqual([ + `skipped v${SESSION_ARTIFACT_PERSISTENCE_VERSION + 1} snapshot record (expected v${SESSION_ARTIFACT_PERSISTENCE_VERSION})`, + `skipped v${SESSION_ARTIFACT_PERSISTENCE_VERSION + 1} event record (expected v${SESSION_ARTIFACT_PERSISTENCE_VERSION})`, + ]); + }); + it('drops malformed content refs during restore normalization', () => { const pinned = artifact('s1', 'https://example.com/pinned', { retention: 'pinned', diff --git a/packages/core/src/services/session-artifact-persistence.ts b/packages/core/src/services/session-artifact-persistence.ts index f3f1bb39cb5..e1656c93bf9 100644 --- a/packages/core/src/services/session-artifact-persistence.ts +++ b/packages/core/src/services/session-artifact-persistence.ts @@ -315,7 +315,13 @@ function normalizeSnapshotPayload( value: unknown, warnings: string[], ): SessionArtifactSnapshotRecordPayload | undefined { - if (!isRecord(value) || value['v'] !== SESSION_ARTIFACT_PERSISTENCE_VERSION) { + if (!isRecord(value)) { + return undefined; + } + if (value['v'] !== SESSION_ARTIFACT_PERSISTENCE_VERSION) { + warnings.push( + `skipped v${String(value['v'])} snapshot record (expected v${SESSION_ARTIFACT_PERSISTENCE_VERSION})`, + ); return undefined; } if (!Array.isArray(value['artifacts'])) return undefined; @@ -339,7 +345,13 @@ function normalizeEventPayload( value: unknown, warnings: string[], ): SessionArtifactEventRecordPayload | undefined { - if (!isRecord(value) || value['v'] !== SESSION_ARTIFACT_PERSISTENCE_VERSION) { + if (!isRecord(value)) { + return undefined; + } + if (value['v'] !== SESSION_ARTIFACT_PERSISTENCE_VERSION) { + warnings.push( + `skipped v${String(value['v'])} event record (expected v${SESSION_ARTIFACT_PERSISTENCE_VERSION})`, + ); return undefined; } if (!Array.isArray(value['changes'])) return undefined; From c144fa6b3d8e115a209a61caf823b984fcc5399d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Mon, 6 Jul 2026 00:37:37 +0800 Subject: [PATCH 35/61] fix(daemon): keep artifacts on failed restore --- .../acp-bridge/src/sessionArtifacts.test.ts | 48 +++++++++++++++++++ packages/acp-bridge/src/sessionArtifacts.ts | 15 ++++++ 2 files changed, 63 insertions(+) diff --git a/packages/acp-bridge/src/sessionArtifacts.test.ts b/packages/acp-bridge/src/sessionArtifacts.test.ts index b2f68b4acf7..2fe8fc15d68 100644 --- a/packages/acp-bridge/src/sessionArtifacts.test.ts +++ b/packages/acp-bridge/src/sessionArtifacts.test.ts @@ -3038,6 +3038,54 @@ describe('SessionArtifactStore', () => { }); }); + it('keeps live artifacts when a non-empty restore snapshot fully fails', async () => { + const store = new SessionArtifactStore({ + sessionId: 's11-restore-fail-closed', + workspaceCwd: workspace, + }); + const live = await store.upsertMany([ + { title: 'Live', url: 'https://example.com/live' }, + ]); + const liveId = live.changes[0]!.artifactId; + + const warnings = await store.restore({ + v: 2, + sessionId: 's11-restore-fail-closed', + sequence: 8, + artifacts: [ + { + id: 'bad-id', + kind: 'link', + storage: 'external_url', + source: 'client', + status: 'available', + title: 'Bad', + url: 'https://example.com/bad', + retention: 'restorable', + clientRetained: false, + createdAt: '2026-07-04T00:00:00.000Z', + updatedAt: '2026-07-04T00:00:00.000Z', + }, + ], + tombstonedIds: [], + stickyEphemeralIds: [], + warnings: [], + }); + + expect(warnings).toEqual([ + 'skipped artifact with mismatched id bad-id', + 'artifact snapshot restore failed; kept existing live artifacts', + ]); + await expect(store.list()).resolves.toMatchObject({ + artifacts: [ + { + id: liveId, + title: 'Live', + }, + ], + }); + }); + it('prunes over-limit restored artifacts and records eviction tombstones', async () => { const sourceEvents: SessionArtifactEventRecordPayload[] = []; const source = new SessionArtifactStore({ diff --git a/packages/acp-bridge/src/sessionArtifacts.ts b/packages/acp-bridge/src/sessionArtifacts.ts index 53e7968925f..5333f46b301 100644 --- a/packages/acp-bridge/src/sessionArtifacts.ts +++ b/packages/acp-bridge/src/sessionArtifacts.ts @@ -705,6 +705,9 @@ export class SessionArtifactStore { if (!snapshot) return []; return this.enqueue(async () => { const warnings = [...snapshot.warnings]; + const previousState = this.cloneState(); + const warningCountBeforeRestore = warnings.length; + let restoredCount = 0; this.artifacts.clear(); this.tombstonedIds.clear(); this.stickyEphemeralIds.clear(); @@ -789,6 +792,7 @@ export class SessionArtifactStore { insertSeq: ++this.insertSeq, }; this.artifacts.set(stored.id, stored); + restoredCount++; } catch (error) { warnings.push( `skipped artifact restore: ${ @@ -797,6 +801,17 @@ export class SessionArtifactStore { ); } } + if ( + snapshot.artifacts.length > 0 && + restoredCount === 0 && + warnings.length > warningCountBeforeRestore + ) { + this.restoreState(previousState); + warnings.push( + 'artifact snapshot restore failed; kept existing live artifacts', + ); + return warnings; + } const evicted = await this.evictOverflow(new Set(), []); if (evicted.length > 0) { warnings.push('restored artifact list pruned to live limit'); From e24885f36a65ec519704f35aac931b3e591a1e7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Mon, 6 Jul 2026 01:41:01 +0800 Subject: [PATCH 36/61] fix(daemon): harden artifact restore metadata --- .../acp-bridge/src/sessionArtifacts.test.ts | 52 ++++++++++++++++++- packages/acp-bridge/src/sessionArtifacts.ts | 14 +++-- .../session-artifact-persistence.test.ts | 28 ++++++++++ .../services/session-artifact-persistence.ts | 5 +- 4 files changed, 93 insertions(+), 6 deletions(-) diff --git a/packages/acp-bridge/src/sessionArtifacts.test.ts b/packages/acp-bridge/src/sessionArtifacts.test.ts index 2fe8fc15d68..3b84777da8b 100644 --- a/packages/acp-bridge/src/sessionArtifacts.test.ts +++ b/packages/acp-bridge/src/sessionArtifacts.test.ts @@ -571,7 +571,7 @@ describe('SessionArtifactStore', () => { expect(events).toHaveLength(3); }); - it('clears stale pin expiration when repinning without a ttl', async () => { + it('preserves pin expiration when refreshing content without a ttl', async () => { const store = new SessionArtifactStore({ sessionId: 's1-pin-clear-expiration', workspaceCwd: workspace, @@ -607,8 +607,8 @@ describe('SessionArtifactStore', () => { expect(repinned.changes[0]?.artifact).toMatchObject({ retention: 'pinned', + expiresAt: '2026-08-01T00:00:00.000Z', }); - expect(repinned.changes[0]?.artifact).not.toHaveProperty('expiresAt'); }); it('rolls back pin mutations when persistence fails', async () => { @@ -3086,6 +3086,54 @@ describe('SessionArtifactStore', () => { }); }); + it('does not trust persisted published file urls during restore', async () => { + const store = new SessionArtifactStore({ + sessionId: 's11-restore-published-file', + workspaceCwd: workspace, + }); + const live = await store.upsertMany([ + { title: 'Live', url: 'https://example.com/live' }, + ]); + const liveId = live.changes[0]!.artifactId; + + const warnings = await store.restore({ + v: 2, + sessionId: 's11-restore-published-file', + sequence: 8, + artifacts: [ + { + id: 'tampered-published-file', + kind: 'link', + storage: 'published', + source: 'client', + status: 'available', + title: 'Tampered', + url: 'file:///tmp/secret.html', + retention: 'restorable', + clientRetained: false, + createdAt: '2026-07-04T00:00:00.000Z', + updatedAt: '2026-07-04T00:00:00.000Z', + }, + ], + tombstonedIds: [], + stickyEphemeralIds: [], + warnings: [], + }); + + expect(warnings[0]).toContain('url must use http or https'); + expect(warnings).toContain( + 'artifact snapshot restore failed; kept existing live artifacts', + ); + await expect(store.list()).resolves.toMatchObject({ + artifacts: [ + { + id: liveId, + title: 'Live', + }, + ], + }); + }); + it('prunes over-limit restored artifacts and records eviction tombstones', async () => { const sourceEvents: SessionArtifactEventRecordPayload[] = []; const source = new SessionArtifactStore({ diff --git a/packages/acp-bridge/src/sessionArtifacts.ts b/packages/acp-bridge/src/sessionArtifacts.ts index 5333f46b301..15050139d89 100644 --- a/packages/acp-bridge/src/sessionArtifacts.ts +++ b/packages/acp-bridge/src/sessionArtifacts.ts @@ -524,8 +524,6 @@ export class SessionArtifactStore { } else { delete updated.expiresAt; } - } else { - delete updated.expiresAt; } } else { updated.persistenceWarning = 'metadata_only_restore'; @@ -730,7 +728,8 @@ export class SessionArtifactStore { let normalized = await this.normalizeInput( input, ++this.receivedSeq, - artifact.storage === 'published', + artifact.storage === 'published' && + !isFileArtifactUrl(artifact.url), ); if ( this.stickyEphemeralIds.has(normalized.id) && @@ -1894,6 +1893,15 @@ function toPersistedArtifact( }; } +function isFileArtifactUrl(raw: unknown): boolean { + if (typeof raw !== 'string') return false; + try { + return new URL(raw).protocol === 'file:'; + } catch { + return false; + } +} + function isDurablePersistenceChange(change: SessionArtifactChange): boolean { if (!change.artifact) return false; return change.artifact.retention !== 'ephemeral'; diff --git a/packages/core/src/services/session-artifact-persistence.test.ts b/packages/core/src/services/session-artifact-persistence.test.ts index 76183e97770..53024f9bdf2 100644 --- a/packages/core/src/services/session-artifact-persistence.test.ts +++ b/packages/core/src/services/session-artifact-persistence.test.ts @@ -216,6 +216,34 @@ describe('session artifact persistence records', () => { ]); }); + it('warns when oversized metadata is stripped during restore', () => { + const restored = rebuildSessionArtifactSnapshot([ + event({ + v: SESSION_ARTIFACT_PERSISTENCE_VERSION, + sessionId: 's1', + sequence: 1, + recordedAt: '2026-07-04T00:00:00.000Z', + changes: [ + { + action: 'created', + artifactId: 'oversized-metadata', + artifact: artifact('s1', 'https://example.com/metadata', { + metadata: { blob: 'x'.repeat(5000) }, + }), + }, + ], + }), + ]); + + expect(restored?.warnings).toEqual([ + `skipped oversized metadata for artifact ${stableSessionArtifactId( + 's1', + 'url:https://example.com/metadata', + )}`, + ]); + expect(restored?.artifacts[0]).not.toHaveProperty('metadata'); + }); + it('drops malformed content refs during restore normalization', () => { const pinned = artifact('s1', 'https://example.com/pinned', { retention: 'pinned', diff --git a/packages/core/src/services/session-artifact-persistence.ts b/packages/core/src/services/session-artifact-persistence.ts index e1656c93bf9..941574103ab 100644 --- a/packages/core/src/services/session-artifact-persistence.ts +++ b/packages/core/src/services/session-artifact-persistence.ts @@ -440,7 +440,7 @@ function normalizePersistedArtifact( return undefined; } - const metadata = normalizeMetadata(value['metadata']); + const metadata = normalizeMetadata(value['metadata'], warnings, id); const description = getString(value, 'description'); const workspacePath = getString(value, 'workspacePath'); const managedId = getString(value, 'managedId'); @@ -503,6 +503,8 @@ function normalizeContentRef( function normalizeMetadata( value: unknown, + warnings: string[], + artifactId: string, ): Record | undefined { if (!isRecord(value)) return undefined; const normalized: Record = {}; @@ -518,6 +520,7 @@ function normalizeMetadata( } if (Object.keys(normalized).length === 0) return undefined; if (Buffer.byteLength(JSON.stringify(normalized), 'utf8') > 4096) { + warnings.push(`skipped oversized metadata for artifact ${artifactId}`); return undefined; } return normalized; From 2a54a77c81207232c3c79aeef3e3a4f3126b11b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Mon, 6 Jul 2026 01:54:31 +0800 Subject: [PATCH 37/61] fix(daemon): cover artifact get and batch retention --- .../acp-bridge/src/sessionArtifacts.test.ts | 60 +++++++++++++++++++ packages/acp-bridge/src/sessionArtifacts.ts | 1 + 2 files changed, 61 insertions(+) diff --git a/packages/acp-bridge/src/sessionArtifacts.test.ts b/packages/acp-bridge/src/sessionArtifacts.test.ts index 3b84777da8b..89affc63325 100644 --- a/packages/acp-bridge/src/sessionArtifacts.test.ts +++ b/packages/acp-bridge/src/sessionArtifacts.test.ts @@ -80,6 +80,33 @@ describe('SessionArtifactStore', () => { }); }); + it('gets artifacts and refreshes stale workspace status', async () => { + const store = new SessionArtifactStore({ + sessionId: 's1-get', + workspaceCwd: workspace, + }); + await fs.writeFile(path.join(workspace, 'report.txt'), 'hello'); + const created = await store.upsertMany([ + { title: 'Report', workspacePath: 'report.txt' }, + ]); + const artifactId = created.changes[0]!.artifactId; + + await expect(store.get(artifactId)).resolves.toMatchObject({ + id: artifactId, + title: 'Report', + status: 'available', + sizeBytes: 5, + }); + await expect(store.get('missing')).resolves.toBeUndefined(); + + await fs.rm(path.join(workspace, 'report.txt')); + vi.useFakeTimers(); + vi.setSystemTime(new Date(Date.now() + 6_000)); + const missing = await store.get(artifactId); + expect(missing).toMatchObject({ id: artifactId, status: 'missing' }); + expect(missing).not.toHaveProperty('sizeBytes'); + }); + it('prevents one client from removing another client retained artifact', async () => { const store = new SessionArtifactStore({ sessionId: 's1-client-owner', @@ -1526,6 +1553,39 @@ describe('SessionArtifactStore', () => { }); }); + it('keeps strongest retention when coalescing duplicate identities', async () => { + const store = new SessionArtifactStore({ + sessionId: 's5-coalesce-retention', + workspaceCwd: workspace, + persistence: { + recordEvent: async () => {}, + recordSnapshot: async () => {}, + }, + }); + + const result = await store.upsertMany( + [ + { + title: 'Ephemeral', + url: 'https://example.com/retention', + retention: 'ephemeral', + }, + { + title: 'Restorable', + url: 'https://example.com/retention', + retention: 'restorable', + }, + ], + { strict: true }, + ); + + expect(result.changes).toHaveLength(1); + expect(result.changes[0]?.artifact).toMatchObject({ + title: 'Ephemeral', + retention: 'restorable', + }); + }); + it('infers artifact kind from storage and workspace extensions', async () => { const store = new SessionArtifactStore({ sessionId: 's5-kind', diff --git a/packages/acp-bridge/src/sessionArtifacts.ts b/packages/acp-bridge/src/sessionArtifacts.ts index 15050139d89..e7a53253723 100644 --- a/packages/acp-bridge/src/sessionArtifacts.ts +++ b/packages/acp-bridge/src/sessionArtifacts.ts @@ -1474,6 +1474,7 @@ function mergeBatchArtifact( clientRetained: existing.clientRetained || next.clientRetained, trustedPublisher: existing.trustedPublisher || next.trustedPublisher, retentionExplicit: existing.retentionExplicit || next.retentionExplicit, + retention: strongestRetention(existing.retention, next.retention), lastStatAt: next.lastStatAt ?? existing.lastStatAt, }; } From 44e2d3dd57e1de5034e81407b9fb667fc8ae8eeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Mon, 6 Jul 2026 03:12:44 +0800 Subject: [PATCH 38/61] fix(daemon): tighten artifact marker rebuild --- .../acp-bridge/src/sessionArtifacts.test.ts | 12 ++--- packages/acp-bridge/src/sessionArtifacts.ts | 18 +++----- .../session-artifact-persistence.test.ts | 45 +++++++++++++++++++ .../services/session-artifact-persistence.ts | 4 ++ 4 files changed, 58 insertions(+), 21 deletions(-) diff --git a/packages/acp-bridge/src/sessionArtifacts.test.ts b/packages/acp-bridge/src/sessionArtifacts.test.ts index 89affc63325..2c81cb0d6ad 100644 --- a/packages/acp-bridge/src/sessionArtifacts.test.ts +++ b/packages/acp-bridge/src/sessionArtifacts.test.ts @@ -412,7 +412,7 @@ describe('SessionArtifactStore', () => { ); }); - it('bounds sticky ephemeral overrides by the artifact limit', async () => { + it('allows sticky ephemeral overrides within the artifact limit', async () => { const store = new SessionArtifactStore({ sessionId: 's1-sticky-cap', workspaceCwd: workspace, @@ -437,17 +437,13 @@ describe('SessionArtifactStore', () => { ); const secondId = second.changes[0]!.artifactId; - await expect( - store.unpin(secondId, { retention: 'ephemeral' }), - ).rejects.toMatchObject({ - field: 'retention', - message: 'sticky ephemeral artifact limit exceeded', - }); + await store.unpin(secondId, { retention: 'ephemeral' }); await expect(store.list()).resolves.toMatchObject({ artifacts: [ expect.objectContaining({ id: secondId, - retention: 'restorable', + retention: 'ephemeral', + persistenceWarning: 'sticky_override_active', }), ], }); diff --git a/packages/acp-bridge/src/sessionArtifacts.ts b/packages/acp-bridge/src/sessionArtifacts.ts index e7a53253723..d5d0aa1e998 100644 --- a/packages/acp-bridge/src/sessionArtifacts.ts +++ b/packages/acp-bridge/src/sessionArtifacts.ts @@ -441,7 +441,6 @@ export class SessionArtifactStore { options?: { clientId?: string }, ): Promise { return this.enqueue(async () => { - const before = this.cloneState(); const existing = this.artifacts.get(artifactId); if (!existing) { return { v: 1, sessionId: this.sessionId, changes: [] }; @@ -450,6 +449,7 @@ export class SessionArtifactStore { // Tool/hook artifacts are session-scoped outputs and may be removed by // any caller that already passed session mutation auth. this.denyCrossClientMutation('remove', artifactId, existing, options); + const before = this.cloneState(); this.artifacts.delete(artifactId); const changes: SessionArtifactChange[] = [ { @@ -482,7 +482,6 @@ export class SessionArtifactStore { options: SessionArtifactPinOptions & { clientId?: string } = {}, ): Promise { return this.enqueue(async () => { - const before = this.cloneState(); const existing = this.artifacts.get(artifactId); if (!existing) { return { v: 1, sessionId: this.sessionId, changes: [] }; @@ -495,6 +494,7 @@ export class SessionArtifactStore { changes: [], }; } + const before = this.cloneState(); const targetRetention = options.retention ?? (options.contentRef @@ -558,23 +558,13 @@ export class SessionArtifactStore { options: SessionArtifactUnpinOptions & { clientId?: string } = {}, ): Promise { return this.enqueue(async () => { - const before = this.cloneState(); const existing = this.artifacts.get(artifactId); if (!existing) { return { v: 1, sessionId: this.sessionId, changes: [] }; } this.denyCrossClientMutation('unpin', artifactId, existing, options); const targetRetention = options.retention ?? 'restorable'; - if ( - targetRetention === 'ephemeral' && - !this.stickyEphemeralIds.has(artifactId) && - this.stickyEphemeralIds.size >= this.maxArtifacts - ) { - throw new SessionArtifactValidationError( - 'sticky ephemeral artifact limit exceeded', - 'retention', - ); - } + const before = this.cloneState(); const updated: StoredArtifact = { ...existing, retention: targetRetention, @@ -598,6 +588,8 @@ export class SessionArtifactStore { artifact: toPublicArtifact(updated), }, ]; + // Journal records the durable side effect while the API returns the live + // in-memory state that remains available as ephemeral. const durableChanges = targetRetention === 'ephemeral' ? [ diff --git a/packages/core/src/services/session-artifact-persistence.test.ts b/packages/core/src/services/session-artifact-persistence.test.ts index 53024f9bdf2..1b8bc8f0892 100644 --- a/packages/core/src/services/session-artifact-persistence.test.ts +++ b/packages/core/src/services/session-artifact-persistence.test.ts @@ -137,6 +137,51 @@ describe('session artifact persistence records', () => { }); }); + it('clears sticky ephemeral ids when rebuild sees an eviction tombstone', () => { + const pinned = artifact('s1', 'https://example.com/sticky', { + retention: 'pinned', + }); + + const snapshot = rebuildSessionArtifactSnapshot([ + event({ + v: SESSION_ARTIFACT_PERSISTENCE_VERSION, + sessionId: 's1', + sequence: 1, + recordedAt: '2026-07-04T00:00:00.000Z', + changes: [ + { + action: 'removed', + artifactId: pinned.id, + artifact: pinned, + reason: 'unpin_to_ephemeral', + }, + ], + }), + event({ + v: SESSION_ARTIFACT_PERSISTENCE_VERSION, + sessionId: 's1', + sequence: 2, + recordedAt: '2026-07-04T00:00:01.000Z', + changes: [ + { + action: 'removed', + artifactId: pinned.id, + artifact: pinned, + reason: 'eviction', + }, + ], + }), + ]); + + expect(snapshot).toMatchObject({ + sequence: 2, + artifacts: [], + tombstonedIds: [], + stickyEphemeralIds: [], + warnings: [], + }); + }); + it('lets snapshot records replace earlier event state', () => { const first = artifact('s1', 'https://example.com/first'); const second = artifact('s1', 'https://example.com/second'); diff --git a/packages/core/src/services/session-artifact-persistence.ts b/packages/core/src/services/session-artifact-persistence.ts index 941574103ab..38f29583afa 100644 --- a/packages/core/src/services/session-artifact-persistence.ts +++ b/packages/core/src/services/session-artifact-persistence.ts @@ -214,6 +214,10 @@ export function rebuildSessionArtifactSnapshot( artifacts.delete(change.artifactId); if (change.reason === 'explicit') { tombstonedIds.add(change.artifactId); + stickyEphemeralIds.delete(change.artifactId); + } + if (change.reason === 'eviction') { + stickyEphemeralIds.delete(change.artifactId); } if (change.reason === 'unpin_to_ephemeral') { stickyEphemeralIds.add(change.artifactId); From 492d09e28109456e870dc573395fba5db49ca197 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Mon, 6 Jul 2026 10:51:12 +0800 Subject: [PATCH 39/61] fix(daemon): reconcile persisted session artifacts --- ...session-artifacts-persistence-v2-design.md | 5 +- packages/acp-bridge/src/bridge.test.ts | 89 ++++++++++++++----- packages/acp-bridge/src/bridge.ts | 16 ++-- .../acp-bridge/src/sessionArtifacts.test.ts | 62 ++++++++++--- packages/acp-bridge/src/sessionArtifacts.ts | 71 +++++++++------ .../src/services/chatRecordingService.test.ts | 26 ++++++ .../core/src/services/chatRecordingService.ts | 16 ++-- 7 files changed, 212 insertions(+), 73 deletions(-) diff --git a/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md b/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md index 8d74d02ed8c..56f60d7e910 100644 --- a/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md +++ b/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md @@ -31,6 +31,7 @@ V2 是一个完整设计 phase,但对外能力仍按 capability gate 暴露。 当前 PR 的重要收窄: - Content retention public API、managed content store、pin/unpin、deleteContent、quota/hash/manifest/fsck/gc 和 `session_artifacts_content_retention` capability 不在 PR #6259 中交付;这些安全面拆到后续 PR。当前 PR 只保留对旧 `pinned` / `contentRef` journal payload 的 downgrade/strip 兼容路径,避免旧记录破坏 metadata restore。 +- 下文保留的 pin/save、content quota、managed content GC/fsck 细节是后续 content-retention PR 的安全边界说明,不是 PR #6259 的 wire contract 或验收项;除非小节明确标注为 PR #6259 HTTP mapping / metadata behavior,否则实现不得在 #6259 中暴露这些 API 或 capability。 - 当前 live view 与 persisted metadata 使用同一个 200 条可见集合。为了避免重启后 over-restore,超过上限时的 durable/restorable eviction 会写入 `reason: "eviction"` remove event;这等价于本实现的 metadata prune,不是纯 V1 live-only hiding。 - 显式 DELETE 当前采用 live-first:先从 live store 移除,tombstone 写入失败时返回 warning。这样可优先隐藏敏感项;失败窗口内 daemon 重启仍可能从旧 journal 恢复该 artifact,client 应把 warning 作为“删除未 durable”的信号。 - Fork 当前通过一次性 exclusive-create 写入目标 JSONL 文件;不会逐条 streaming fork artifact records,因此不需要 `session_artifact_fork_marker` 才能检测当前写入路径的 partial batch。若未来改成流式 fork,再引入 begin/complete marker。 @@ -53,7 +54,7 @@ backfill 不能逐条向 JSONL streaming 写入 artifact event。实现必须先 ### 2.2 retention 分层 -新增 optional field: +新增 optional field。PR #6259 的 public mutation path 只接受 `ephemeral` 和 `restorable`;`pinned` 只作为旧 journal 兼容字段和后续 content-retention 设计目标出现,当前 restore 会把旧 `pinned` 记录降级为 metadata-only `restorable`: ```ts type ArtifactRetention = 'ephemeral' | 'restorable' | 'pinned'; @@ -127,7 +128,7 @@ interface DaemonSessionArtifact { - `expiresAt`:可被 GC 的时间。`pinned` 默认不设置,但 `ttlDays` pin 会转换成绝对 `expiresAt`。 - `restoreState`:恢复来源提示;不替代 `status`。 - `persistenceWarning`:非阻塞持久化/恢复风险,前端可用它提示“此 artifact 不会跨重启保留”等状态。当前 wire shape 是固定字符串,避免把 host 绝对路径、credential、token、内部 storage path 或 connection id 写入 response。更结构化的 `{ code, message }` 可作为后续兼容扩展。 -- `contentRef`:当前只在 `mode: "content"` pin workspace regular file 成功后出现,不暴露宿主机绝对路径。published / workspace-reference contentRef 是后续增强,不属于本 PR 发布承诺。 +- `contentRef`:PR #6259 不产生新的 `contentRef`。该字段只为读取旧 journal payload 和后续 content-retention PR 的 `mode: "content"` pin 预留;当前 metadata restore 会校验、strip 或 downgrade 旧 `contentRef`,不会把它作为可打开内容承诺暴露。 ### 3.2 Status 与 restoreState 的关系 diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index 66a467d5549..9aea82a95fb 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -1327,11 +1327,18 @@ describe('createAcpSessionBridge', () => { await bridge.shutdown(); }); - it('keeps live artifacts when rewind returns no artifact snapshot', async () => { + it('clears durable artifacts but keeps live ephemerals when rewind returns no artifact snapshot', async () => { + const persistedSnapshots: unknown[] = []; const bridge = makeBridge({ channelFactory: async () => makeChannel({ - extMethodImpl: (method) => { + extMethodImpl: (method, params) => { + if (method === 'qwen/control/session/artifacts/persist') { + if (params['kind'] === 'snapshot') { + persistedSnapshots.push(params['payload']); + } + return {}; + } expect(method).toBe('qwen/control/session/rewind'); return { targetTurnIndex: 0, @@ -1342,15 +1349,25 @@ describe('createAcpSessionBridge', () => { }).channel, }); const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); - const created = await bridge.addSessionArtifact( + const durable = await bridge.addSessionArtifact( session.sessionId, { - title: 'Later artifact', - url: 'https://example.com/later', + title: 'Abandoned durable artifact', + url: 'https://example.com/later-durable', }, { clientId: session.clientId }, ); - expect(created.changes).toHaveLength(1); + const ephemeral = await bridge.addSessionArtifact( + session.sessionId, + { + title: 'Live ephemeral artifact', + url: 'https://example.com/later-ephemeral', + retention: 'ephemeral', + }, + { clientId: session.clientId }, + ); + expect(durable.changes).toHaveLength(1); + expect(ephemeral.changes).toHaveLength(1); await expect( bridge.rewindSession( @@ -1360,15 +1377,28 @@ describe('createAcpSessionBridge', () => { ), ).resolves.toMatchObject({ targetTurnIndex: 0 }); - await expect( - bridge.getSessionArtifacts(session.sessionId), - ).resolves.toMatchObject({ - artifacts: [ + const artifacts = (await bridge.getSessionArtifacts(session.sessionId)) + .artifacts; + expect(artifacts).toEqual( + expect.arrayContaining([ expect.objectContaining({ - title: 'Later artifact', + title: 'Live ephemeral artifact', + retention: 'ephemeral', }), - ], - }); + ]), + ); + expect( + artifacts.some( + (artifact) => artifact.id === durable.changes[0]?.artifactId, + ), + ).toBe(false); + expect(persistedSnapshots).toEqual([ + expect.objectContaining({ + sessionId: session.sessionId, + artifacts: [], + tombstonedIds: [], + }), + ]); await bridge.shutdown(); }); @@ -1432,6 +1462,15 @@ describe('createAcpSessionBridge', () => { }, { clientId: session.clientId }, ); + const ephemeral = await bridge.addSessionArtifact( + session.sessionId, + { + title: 'Live ephemeral artifact', + url: 'https://example.com/live-ephemeral-during-rewind', + retention: 'ephemeral', + }, + { clientId: session.clientId }, + ); const abort = new AbortController(); const iter = bridge.subscribeEvents(session.sessionId, { @@ -1465,17 +1504,22 @@ describe('createAcpSessionBridge', () => { ]), ); abort.abort(); - await expect( - bridge.getSessionArtifacts(session.sessionId), - ).resolves.toMatchObject({ - artifacts: [ - { + const artifacts = (await bridge.getSessionArtifacts(session.sessionId)) + .artifacts; + expect(artifacts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: rewoundArtifactId, title: 'Rewound artifact', restoreState: 'restored', - }, - ], - }); + }), + expect.objectContaining({ + id: ephemeral.changes[0]?.artifactId, + title: 'Live ephemeral artifact', + retention: 'ephemeral', + }), + ]), + ); expect(persistedSnapshots).toEqual([ expect.objectContaining({ sessionId: session.sessionId, @@ -1487,6 +1531,9 @@ describe('createAcpSessionBridge', () => { ], }), ]); + expect( + (persistedSnapshots[0] as { artifacts?: unknown[] }).artifacts, + ).toHaveLength(1); await bridge.shutdown(); }); diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index 3b0760f5c07..5e1797a039d 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -5933,12 +5933,16 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { response as BridgeSessionState, ); const beforeArtifacts = (await entry.artifacts.list()).artifacts; - const artifactRestoreWarnings = - await entry.artifacts.restore(artifactSnapshot); - const artifactSnapshotWarnings = - artifactSnapshot === undefined - ? [] - : await entry.artifacts.recordSnapshot(); + const shouldRecordArtifactSnapshot = + artifactSnapshot !== undefined || + beforeArtifacts.some((artifact) => artifact.retention !== 'ephemeral'); + const artifactRestoreWarnings = await entry.artifacts.restore( + artifactSnapshot, + { preserveLiveEphemeral: true }, + ); + const artifactSnapshotWarnings = shouldRecordArtifactSnapshot + ? await entry.artifacts.recordSnapshot() + : []; for (const warning of artifactRestoreWarnings) { writeStderrLine( `[artifacts] session=${entry.sessionId} action=rewind_restore_warning warning=${JSON.stringify( diff --git a/packages/acp-bridge/src/sessionArtifacts.test.ts b/packages/acp-bridge/src/sessionArtifacts.test.ts index 2c81cb0d6ad..f3a3476408f 100644 --- a/packages/acp-bridge/src/sessionArtifacts.test.ts +++ b/packages/acp-bridge/src/sessionArtifacts.test.ts @@ -2709,6 +2709,45 @@ describe('SessionArtifactStore', () => { expect(explicitClient.changes).toMatchObject([{ action: 'created' }]); }); + it('clears durable artifacts but preserves live ephemerals for rewind without a snapshot', async () => { + const store = new SessionArtifactStore({ + sessionId: 's11-restore-empty-rewind', + workspaceCwd: workspace, + persistence: { + recordEvent: async () => {}, + recordSnapshot: async () => {}, + }, + }); + + const durable = await store.upsertMany( + [{ title: 'Durable', url: 'https://example.com/durable-rewind' }], + { strict: true }, + ); + const ephemeral = await store.upsertMany([ + { + title: 'Live only', + url: 'https://example.com/live-only-rewind', + retention: 'ephemeral', + }, + ]); + + await expect( + store.restore(undefined, { preserveLiveEphemeral: true }), + ).resolves.toEqual([]); + await expect(store.list()).resolves.toMatchObject({ + artifacts: [ + { + id: ephemeral.changes[0]?.artifactId, + title: 'Live only', + retention: 'ephemeral', + }, + ], + }); + await expect(store.get(durable.changes[0]!.artifactId)).resolves.toBe( + undefined, + ); + }); + it('resets durable event snapshot cadence after restore', async () => { const snapshots: SessionArtifactSnapshotRecordPayload[] = []; const store = new SessionArtifactStore({ @@ -3009,10 +3048,10 @@ describe('SessionArtifactStore', () => { }); }); - it('rolls back live removal when explicit tombstone persistence fails', async () => { + it('keeps live removal when explicit tombstone persistence fails', async () => { let calls = 0; const store = new SessionArtifactStore({ - sessionId: 's11-remove-rollback', + sessionId: 's11-remove-live-first', workspaceCwd: workspace, persistence: { recordEvent: async () => { @@ -3029,16 +3068,19 @@ describe('SessionArtifactStore', () => { { strict: true }, ); - await expect(store.remove(created.changes[0]!.artifactId)).rejects.toThrow( - 'disk full', + await expect(store.remove(created.changes[0]!.artifactId)).resolves.toEqual( + expect.objectContaining({ + changes: [ + expect.objectContaining({ + action: 'removed', + artifactId: created.changes[0]?.artifactId, + }), + ], + warnings: ['artifact removal not persisted; live removal kept'], + }), ); await expect(store.list()).resolves.toMatchObject({ - artifacts: [ - { - id: created.changes[0]?.artifactId, - title: 'Sensitive', - }, - ], + artifacts: [], }); }); diff --git a/packages/acp-bridge/src/sessionArtifacts.ts b/packages/acp-bridge/src/sessionArtifacts.ts index d5d0aa1e998..5945ff899d8 100644 --- a/packages/acp-bridge/src/sessionArtifacts.ts +++ b/packages/acp-bridge/src/sessionArtifacts.ts @@ -151,6 +151,7 @@ export interface SessionArtifactRestoreOptions { verifyContentRef?: ( artifact: PersistedSessionArtifact, ) => Promise; + preserveLiveEphemeral?: boolean; } export interface SessionArtifactPersistence { @@ -260,6 +261,8 @@ export class SessionArtifactStore { }); } + // Content-retention helpers are intentionally not exposed by #6259. + // The stacked #6346 PR wires these to pin/unpin/content-GC surfaces. async contentRefs(): Promise { return this.enqueue(async () => Array.from(this.artifacts.values()) @@ -449,7 +452,6 @@ export class SessionArtifactStore { // Tool/hook artifacts are session-scoped outputs and may be removed by // any caller that already passed session mutation auth. this.denyCrossClientMutation('remove', artifactId, existing, options); - const before = this.cloneState(); this.artifacts.delete(artifactId); const changes: SessionArtifactChange[] = [ { @@ -459,21 +461,13 @@ export class SessionArtifactStore { reason: 'explicit', }, ]; - try { - const warnings = await this.persistChanges( - changes, - this.persistence !== undefined, - ); - return { - v: 1, - sessionId: this.sessionId, - changes, - ...(warnings.length > 0 ? { warnings } : {}), - }; - } catch (error) { - this.restoreState(before); - throw error; - } + const warnings = await this.persistChanges(changes, false); + return { + v: 1, + sessionId: this.sessionId, + changes, + ...(warnings.length > 0 ? { warnings } : {}), + }; }); } @@ -692,26 +686,33 @@ export class SessionArtifactStore { snapshot: RebuiltSessionArtifactSnapshot | undefined, options: SessionArtifactRestoreOptions = {}, ): Promise { - if (!snapshot) return []; + if (!snapshot && !options.preserveLiveEphemeral) return []; return this.enqueue(async () => { - const warnings = [...snapshot.warnings]; + const warnings = [...(snapshot?.warnings ?? [])]; const previousState = this.cloneState(); const warningCountBeforeRestore = warnings.length; + const preservedLiveEphemeralArtifacts = options.preserveLiveEphemeral + ? Array.from(this.artifacts.values()) + .filter((artifact) => artifact.retention === 'ephemeral') + .map(cloneStoredArtifact) + : []; let restoredCount = 0; this.artifacts.clear(); this.tombstonedIds.clear(); this.stickyEphemeralIds.clear(); - this.insertSeq = 0; - this.persistenceSeq = snapshot.sequence; - this.durableEventsSinceSnapshot = 0; - this.consecutiveSnapshotFailures = 0; - for (const id of snapshot.tombstonedIds) { - this.tombstonedIds.add(id); - } - for (const id of snapshot.stickyEphemeralIds) { - this.stickyEphemeralIds.add(id); + if (snapshot) { + this.insertSeq = 0; + this.persistenceSeq = snapshot.sequence; + this.durableEventsSinceSnapshot = 0; + this.consecutiveSnapshotFailures = 0; + for (const id of snapshot.tombstonedIds) { + this.tombstonedIds.add(id); + } + for (const id of snapshot.stickyEphemeralIds) { + this.stickyEphemeralIds.add(id); + } } - for (const artifact of snapshot.artifacts) { + for (const artifact of snapshot?.artifacts ?? []) { try { const input = persistedArtifactToInput(artifact); if (input.retention === 'pinned') { @@ -793,7 +794,7 @@ export class SessionArtifactStore { } } if ( - snapshot.artifacts.length > 0 && + (snapshot?.artifacts.length ?? 0) > 0 && restoredCount === 0 && warnings.length > warningCountBeforeRestore ) { @@ -803,6 +804,18 @@ export class SessionArtifactStore { ); return warnings; } + for (const artifact of preservedLiveEphemeralArtifacts) { + if ( + this.artifacts.has(artifact.id) || + this.tombstonedIds.has(artifact.id) + ) { + continue; + } + this.artifacts.set(artifact.id, { + ...artifact, + insertSeq: ++this.insertSeq, + }); + } const evicted = await this.evictOverflow(new Set(), []); if (evicted.length > 0) { warnings.push('restored artifact list pruned to live limit'); diff --git a/packages/core/src/services/chatRecordingService.test.ts b/packages/core/src/services/chatRecordingService.test.ts index e7ae04042f8..be4a8693925 100644 --- a/packages/core/src/services/chatRecordingService.test.ts +++ b/packages/core/src/services/chatRecordingService.test.ts @@ -1138,11 +1138,37 @@ describe('ChatRecordingService', () => { chatRecordingService.recordUserMessage([{ text: 'next message' }]); await chatRecordingService.flush(); + const first = vi.mocked(jsonl.writeLine).mock.calls[0][1] as ChatRecord; const second = vi.mocked(jsonl.writeLine).mock.calls[1][1] as ChatRecord; const third = vi.mocked(jsonl.writeLine).mock.calls[2][1] as ChatRecord; + expect(second.parentUuid).not.toBe(first.uuid); expect(third.parentUuid).toBe(second.uuid); }); + it('keeps artifact journal records out of the active conversation chain', async () => { + chatRecordingService.recordUserMessage([{ text: 'before artifact' }]); + await chatRecordingService.flush(); + + await chatRecordingService.recordSessionArtifactEvent({ + v: 2, + sessionId: 'test-session-id', + sequence: 1, + recordedAt: '2026-07-04T00:00:00.000Z', + changes: [], + }); + + chatRecordingService.recordUserMessage([{ text: 'after artifact' }]); + await chatRecordingService.flush(); + + const before = vi.mocked(jsonl.writeLine).mock.calls[0][1] as ChatRecord; + const artifact = vi.mocked(jsonl.writeLine).mock + .calls[1][1] as ChatRecord; + const after = vi.mocked(jsonl.writeLine).mock.calls[2][1] as ChatRecord; + expect(artifact.parentUuid).toBe(before.uuid); + expect(after.parentUuid).toBe(before.uuid); + expect(after.parentUuid).not.toBe(artifact.uuid); + }); + // appendRecord can throw SYNCHRONOUSLY before returning a promise // (e.g. ensureConversationFile fails because the conversation // file can't be created). Without rollback in the outer catch, diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index fd035028649..ef05ab5f4cb 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -773,8 +773,12 @@ export class ChatRecordingService { this.updateTitleAnchorTracking(record); } - private async appendRecordStrict(record: ChatRecord): Promise { + private async appendRecordStrict( + record: ChatRecord, + options?: { updateActiveTail?: boolean }, + ): Promise { const previousLastRecordUuid = this.lastRecordUuid; + const updateActiveTail = options?.updateActiveTail !== false; let conversationFile: string; try { conversationFile = this.ensureConversationFile(); @@ -783,7 +787,9 @@ export class ChatRecordingService { throw error; } - this.lastRecordUuid = record.uuid; + if (updateActiveTail) { + this.lastRecordUuid = record.uuid; + } this.writeChain = this.writeChain .catch(() => {}) .then(() => jsonl.writeLine(conversationFile, record)); @@ -792,7 +798,7 @@ export class ChatRecordingService { await this.writeChain; this.updateTitleAnchorTracking(record); } catch (error) { - if (this.lastRecordUuid === record.uuid) { + if (updateActiveTail && this.lastRecordUuid === record.uuid) { this.lastRecordUuid = previousLastRecordUuid; } debugLogger.error('Error appending record (async):', error); @@ -1516,7 +1522,7 @@ export class ChatRecordingService { subtype: 'session_artifact_event', systemPayload: payload, }; - await this.appendRecordStrict(record); + await this.appendRecordStrict(record, { updateActiveTail: false }); } async recordSessionArtifactSnapshot( @@ -1528,6 +1534,6 @@ export class ChatRecordingService { subtype: 'session_artifact_snapshot', systemPayload: payload, }; - await this.appendRecordStrict(record); + await this.appendRecordStrict(record, { updateActiveTail: false }); } } From 0357fb83234fd7a233cea5c43403364514459375 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Mon, 6 Jul 2026 11:31:40 +0800 Subject: [PATCH 40/61] fix(daemon): scope artifact persistence to metadata --- ...session-artifacts-persistence-v2-design.md | 266 ++---- .../acp-bridge/src/sessionArtifacts.test.ts | 877 +----------------- packages/acp-bridge/src/sessionArtifacts.ts | 450 +++------ .../services/session-artifact-persistence.ts | 6 +- packages/sdk-typescript/src/daemon/types.ts | 5 +- 5 files changed, 258 insertions(+), 1346 deletions(-) diff --git a/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md b/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md index 56f60d7e910..93d237ccbd5 100644 --- a/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md +++ b/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md @@ -2,21 +2,21 @@ 本文延续 PR #5895 的 V1 session artifact API,设计 V2 持久化能力。V1 设计见同目录下的 [session-artifacts-daemon-api-implementation-design.md](./session-artifacts-daemon-api-implementation-design.md)。 -V2 的目标是在不破坏 V1 live session 语义的前提下,让 artifact metadata 可以在 daemon 重启、session load/replay 后恢复,同时把内容持久化、权限、配额和清理策略收窄到可审计、可回滚的范围内。 +V2 的目标是在不破坏 V1 live session 语义的前提下,让 artifact metadata 可以在 daemon 重启、session load/replay 后恢复。当前 PR 不复制、不冻结、不托管 artifact 内容;workspace 文件只保存路径、size 和 sha256 作为恢复后的完整性校验。 ## 1. 设计结论 -V2 是一个完整设计 phase,但对外能力仍按 capability gate 暴露。PR #6259 的实现范围收敛为 metadata restore、artifact JSONL journal/snapshot/rebuild/fork remap、daemon restart/load/replay 后恢复 artifact metadata,以及 REST/ACP/SDK 的 metadata persistence 暴露。content retention(workspace content pin、session-scoped managed copy、hash/manifest、quota、TTL、session-scoped GC/fsck)已拆出到后续 PR。client 不应依赖“V2”这个阶段名推断功能,而应读取 capability。 +V2 是一个 metadata persistence phase。PR #6259 的实现范围收敛为 metadata restore、artifact JSONL journal/snapshot/rebuild/fork remap、daemon restart/load/replay 后恢复 artifact metadata,以及 REST/ACP/SDK 的 metadata persistence 暴露。content retention(workspace content pin、session-scoped managed copy、manifest、quota、TTL、session-scoped GC/fsck)不在当前 scope;若未来有真实审计/留档需求,应作为新的 content archive 设计重新评审。client 不应依赖“V2”这个阶段名推断功能,而应读取 capability。 -两层能力: +当前能力: 1. Metadata restore:默认恢复 artifact 的结构化 metadata 和资源引用,不复制实际内容。 -2. Content retention:只有用户或可信 publisher 显式 `pin/save` 时,才把可控内容复制或绑定进 daemon-managed artifact storage。 +2. Workspace integrity check:workspace artifact 登记时记录 size + sha256;restore / GET 时按实时文件返回 `available` / `missing` / `changed`。 对应 capability: - `session_artifacts_persistence`:支持 metadata 持久化与 session load/replay 恢复。 -- `session_artifacts_content_retention`:后续 content-retention PR 才声明;它支持显式 workspace 内容保留、配额、hash、manifest 和 session-scoped GC/fsck。即使后续声明,也不承诺 project-wide cross-session GC、global artifact library、published file-root restore 或 sidecar cache。 +- `session_artifacts_content_retention`:当前不声明;后续如果重启 content archive 设计,必须在复制/托管内容、配额、manifest 和 GC/fsck 都完成后再声明。 核心原则: @@ -30,8 +30,8 @@ V2 是一个完整设计 phase,但对外能力仍按 capability gate 暴露。 当前 PR 的重要收窄: -- Content retention public API、managed content store、pin/unpin、deleteContent、quota/hash/manifest/fsck/gc 和 `session_artifacts_content_retention` capability 不在 PR #6259 中交付;这些安全面拆到后续 PR。当前 PR 只保留对旧 `pinned` / `contentRef` journal payload 的 downgrade/strip 兼容路径,避免旧记录破坏 metadata restore。 -- 下文保留的 pin/save、content quota、managed content GC/fsck 细节是后续 content-retention PR 的安全边界说明,不是 PR #6259 的 wire contract 或验收项;除非小节明确标注为 PR #6259 HTTP mapping / metadata behavior,否则实现不得在 #6259 中暴露这些 API 或 capability。 +- Content retention public API、managed content store、pin/unpin、deleteContent、quota/manifest/fsck/gc 和 `session_artifacts_content_retention` capability 不在 PR #6259 中交付。当前 PR 只保留对旧 `pinned` / `contentRef` journal payload 的 downgrade/strip 兼容路径,避免旧记录破坏 metadata restore。 +- 下文保留的 pin/save、content quota、managed content GC/fsck 细节是 future content archive 蓝图,不是 PR #6259 的 wire contract 或验收项;除非小节明确标注为 PR #6259 HTTP mapping / metadata behavior,否则实现不得在 #6259 中暴露这些 API 或 capability。 - 当前 live view 与 persisted metadata 使用同一个 200 条可见集合。为了避免重启后 over-restore,超过上限时的 durable/restorable eviction 会写入 `reason: "eviction"` remove event;这等价于本实现的 metadata prune,不是纯 V1 live-only hiding。 - 显式 DELETE 当前采用 live-first:先从 live store 移除,tombstone 写入失败时返回 warning。这样可优先隐藏敏感项;失败窗口内 daemon 重启仍可能从旧 journal 恢复该 artifact,client 应把 warning 作为“删除未 durable”的信号。 - Fork 当前通过一次性 exclusive-create 写入目标 JSONL 文件;不会逐条 streaming fork artifact records,因此不需要 `session_artifact_fork_marker` 才能检测当前写入路径的 partial batch。若未来改成流式 fork,再引入 begin/complete marker。 @@ -48,30 +48,29 @@ V2 后的行为应是: - daemon/bridge 重启:如果 session 被重新 load,V2 从持久化 metadata 恢复 artifact list。 - 历史 load/replay:如果该 session 有 V2 persistence records,恢复 artifact list;没有则返回空 list。 -V1 到 V2 的 live upgrade 需要单独处理:已经在内存中的 V1 live artifacts 没有 JSONL journal。V2 首次触达这些 live sessions 时,应通过 chat recording owner 提供的 artifact persistence writer 写入一条初始 `session_artifact_snapshot`,然后再接受新的 restorable/pinned artifact mutation。backfill 不能把 live store 原样序列化;必须对每个 artifact 重新执行 ingest validation、privacy minimization 和 `retention` materialization。单条 artifact 不合格时跳过或降级该条,不能让一次坏记录拖垮整个 backfill。若 writer 不可用或 backfill 整体失败,该 session 继续保持 V1 live-only 行为,并记录 structured warning;不能让用户误以为已有 live artifacts 已经可恢复。 +V1 到 V2 的 live upgrade 需要单独处理:已经在内存中的 V1 live artifacts 没有 JSONL journal。V2 首次触达这些 live sessions 时,应通过 chat recording owner 提供的 artifact persistence writer 写入一条初始 `session_artifact_snapshot`,然后再接受新的 restorable artifact mutation。backfill 不能把 live store 原样序列化;必须对每个 artifact 重新执行 ingest validation、privacy minimization 和 `retention` materialization。单条 artifact 不合格时跳过或降级该条,不能让一次坏记录拖垮整个 backfill。若 writer 不可用或 backfill 整体失败,该 session 继续保持 V1 live-only 行为,并记录 structured warning;不能让用户误以为已有 live artifacts 已经可恢复。 backfill 不能逐条向 JSONL streaming 写入 artifact event。实现必须先在内存中完成校验、最小化和降级,形成完整 candidate snapshot 后,再一次性 append `session_artifact_snapshot`。如果 candidate 构建或 snapshot append 失败,不能留下部分 durable artifact state。当前 PR 不实现 V1 live-store backfill;如果后续补齐,应把 candidate 条目数、跳过条目数和校验失败原因写入结构化 telemetry 或 snapshot metadata,便于 fsck 和 restore warning 区分“完整但有条目被校验跳过”和“部分写入/损坏”。 ### 2.2 retention 分层 -新增 optional field。PR #6259 的 public mutation path 只接受 `ephemeral` 和 `restorable`;`pinned` 只作为旧 journal 兼容字段和后续 content-retention 设计目标出现,当前 restore 会把旧 `pinned` 记录降级为 metadata-only `restorable`: +新增 optional field。PR #6259 的 public mutation path 只接受 `ephemeral` 和 `restorable`;旧 journal 中的 `pinned` 会在 restore / fork 时降级为 metadata-only `restorable`: ```ts -type ArtifactRetention = 'ephemeral' | 'restorable' | 'pinned'; +type ArtifactRetention = 'ephemeral' | 'restorable'; ``` 含义: - `ephemeral`:只存在于 live store。daemon/session 消失后不恢复。 - `restorable`:metadata 写入持久化 journal。session load/replay 后恢复为 artifact item,但不保证底层资源仍存在。 -- `pinned`:metadata 持久化,并保存可控内容或稳定 content reference;直到用户 unpin/delete、TTL 到期或 GC 策略命中。 默认规则: - Tool result、`record_artifact`、hook artifact:默认 `restorable`,但只持久化 metadata。 - 用户在交互式前端手动注册的 Client POST artifact:默认 `restorable`,恢复后仍出现在 artifact list 中。 - 后台/自动化 client POST:如果只是临时 UI 状态,应显式请求 `retention: "ephemeral"`;SDK 应提供明确的 ephemeral helper。 -- `published` artifact:默认 `restorable`;如果 publisher 提供可校验 daemon-managed content,则可升级为 `pinned`。 +- `published` artifact:默认 `restorable`;当前只恢复 published locator,不托管内容。 如果 chat recording 被禁用,metadata persistence 默认禁用,capability 不声明。 @@ -82,11 +81,11 @@ type ArtifactRetention = 'ephemeral' | 'restorable' | 'pinned'; 恢复后的结果按资源状态区分: - `external_url`:恢复 title、description、url、metadata。daemon 不访问远端 URL;URL 是否仍可打开由 client 点击时决定。 -- `workspace`:恢复 workspacePath 和 metadata;如果文件仍在 workspace 内且可访问,`status: "available"`;如果文件已删除、移动或 symlink 逃逸,`status: "missing"` 或 `restoreState: "blocked"`。 +- `workspace`:恢复 workspacePath 和 metadata;如果文件仍在 workspace 内且 size + sha256 与登记时一致,`status: "available"`;如果文件已删除、移动或 symlink 逃逸,`status: "missing"`;如果文件仍在但 size 或 sha256 与登记时不同,`status: "changed"`。 - `managed`:恢复 managedId;只有 managed storage manifest 仍能解析时才 `available`。 - `published`:恢复 published locator;只有仍满足 trusted publisher manifest 校验时才保留 published trust。 -因此,“用户注册的 artifact 恢复后还存在吗?”的答案是:V2 中应该存在于列表里,除非用户 DELETE、metadata 被 GC/tombstone、恢复校验发现记录损坏到无法安全展示,或 chat recording / persistence 被禁用。是否还能打开底层内容,则取决于 storage 类型和是否做了 `pin/save content`。 +因此,“用户注册的 artifact 恢复后还存在吗?”的答案是:V2 中应该存在于列表里,除非用户 DELETE、metadata 被 GC/tombstone、恢复校验发现记录损坏到无法安全展示,或 chat recording / persistence 被禁用。是否还能打开底层内容,则取决于 storage 类型和实时资源状态;workspace 文件不会被 daemon 备份,`changed` 用来避免静默打开错误版本。 daemon 不能只凭 request payload 判断“手动”还是“后台”。实现上应由连接 principal、SDK helper 或 UI action path 标识交互式注册来源;无法确认交互意图的 client 应按显式 `retention` 处理,缺省仍接受 `restorable`,但受 session metadata quota 和审计记录约束。 @@ -99,24 +98,18 @@ V2 在 V1 response artifact 上增加 optional fields: ```ts interface DaemonSessionArtifact { // V1 fields... - retention?: 'ephemeral' | 'restorable' | 'pinned'; + status: 'available' | 'missing' | 'changed'; + retention?: 'ephemeral' | 'restorable'; persistedAt?: string; - expiresAt?: string; restoreState?: 'live' | 'restored' | 'unverified' | 'blocked'; persistenceWarning?: | 'persistence_unavailable' - | 'content_missing' - | 'content_expired' - | 'content_hash_mismatch' | 'metadata_only_restore' | 'restore_validation_failed' | 'sticky_override_active'; - contentRef?: { - kind: 'managed_copy'; - contentId: string; - sha256: string; - sizeBytes: number; - createdAt: string; + metadata?: { + 'qwen.workspace.sha256'?: string; + [key: string]: string | number | boolean | null | undefined; }; } ``` @@ -124,11 +117,10 @@ interface DaemonSessionArtifact { 字段说明: - `retention`:artifact 的持久化级别。解析顺序为:请求体显式值优先;系统内部 artifact 按 §2.2 的 daemon 默认策略;client POST 未指定时使用用户配置的 `defaultRetention`;无配置时回退为 `restorable`。只有 persistence capability 未声明或读取 V1-era 记录时,才按 V1 兼容的 live-only 处理。V2 writer 写 journal 时必须 materialize `retention`,不能依赖 optional 缺省。 -- `persistedAt`:metadata 或 content retention 最近成功落盘时间。 -- `expiresAt`:可被 GC 的时间。`pinned` 默认不设置,但 `ttlDays` pin 会转换成绝对 `expiresAt`。 +- `persistedAt`:metadata 最近成功落盘时间。 - `restoreState`:恢复来源提示;不替代 `status`。 - `persistenceWarning`:非阻塞持久化/恢复风险,前端可用它提示“此 artifact 不会跨重启保留”等状态。当前 wire shape 是固定字符串,避免把 host 绝对路径、credential、token、内部 storage path 或 connection id 写入 response。更结构化的 `{ code, message }` 可作为后续兼容扩展。 -- `contentRef`:PR #6259 不产生新的 `contentRef`。该字段只为读取旧 journal payload 和后续 content-retention PR 的 `mode: "content"` pin 预留;当前 metadata restore 会校验、strip 或 downgrade 旧 `contentRef`,不会把它作为可打开内容承诺暴露。 +- `status: "changed"`:仅用于 workspace artifact。daemon 在登记时写入 `metadata["qwen.workspace.sha256"]` 和 `sizeBytes`;GET/list/restore 后 refresh 时重新 stat/hash 当前文件,如果文件存在但 size 或 sha256 不一致,则返回 `changed`。 ### 3.2 Status 与 restoreState 的关系 @@ -136,8 +128,9 @@ V1 `status` 继续表示当前资源是否可用: - `available` - `missing` +- `changed` -V2 不把 `status` 扩成复杂状态,避免旧 client 误解。`blocked` 不是 `status`,只属于 `restoreState`: +V2 只新增 `changed` 这一种 workspace integrity 状态。它表示路径仍可访问,但实时文件的 size 或 sha256 与登记时的 metadata 不一致。`blocked` 不是 `status`,只属于 `restoreState`: - `restored`:从持久化 metadata 恢复。 - `unverified`:恢复了 metadata,但尚未完成 workspace/managed 校验。 @@ -244,8 +237,6 @@ type PersistedSessionArtifact = Pick< > & { retention: ArtifactRetention; persistedAt: string; - expiresAt?: string; - contentRef?: DaemonSessionArtifact['contentRef']; clientRetained: boolean; toolCallId?: string; toolName?: string; @@ -267,7 +258,7 @@ type PersistedSessionArtifact = Pick< - 不写 `persistenceWarning` - 不写 `clientId` 或 live-process owner principal;`source` 只作为显示/审计 hint,不能用于授权 -删除 artifact 或 unpin 到 `ephemeral` 必须写 tombstone change,避免历史 replay 后被旧 upsert 复活。tombstone 不是永久禁止同一 id 再出现:它只覆盖自己之前的 upsert,直到之后出现更高 sequence 的显式 upsert。`reason: "unpin_to_ephemeral"` 是 sticky override:后续同一 artifact id 的隐式/default upsert 仍按 live-only 处理,只有显式 upsert 才能 supersede。这里的“显式”只包括 UI action path、SDK `pin/save` helper、或经过认证的 REST/ACP mutate route 中显式传入 `retention: "restorable"` 的请求;tool/hook/background/default retention、restore backfill 和隐式 re-ingest 都不能 supersede sticky override。 +删除 artifact 必须写 tombstone change,避免历史 replay 后被旧 upsert 复活。tombstone 不是永久禁止同一 id 再出现:它只覆盖自己之前的 upsert,直到之后出现更高 sequence 的显式 upsert。旧 journal 中的 `reason: "unpin_to_ephemeral"` 继续作为 sticky override 兼容:后续同一 artifact id 的隐式/default upsert 仍按 live-only 处理,只有经过认证的 REST/ACP mutate route 中显式传入 `retention: "restorable"` 的请求才能 supersede;tool/hook/background/default retention、restore backfill 和隐式 re-ingest 都不能 supersede sticky override。 sticky override 不能只存在于历史 tombstone event 中。snapshot writer 必须把尚未被显式 supersede 的 `unpin_to_ephemeral` 状态写入 `stickyEphemeralIds`;restore reader 先恢复 snapshot 中的 sticky set,再应用 snapshot 之后的 upsert/remove。否则 snapshot baseline advance 后旧 tombstone 不再需要 replay,sticky override 会丢失。 @@ -281,7 +272,7 @@ artifact snapshot 只用于减少 replay 的 artifact event 应用量;它不 - snapshot 是 authoritative current state:它只包含 snapshot 生成时仍有效的 artifacts。 - `tombstonedIds` 只记录 snapshot 之后仍需要覆盖旧 upsert 的 tombstones;被 snapshot 覆盖的旧 tombstones 不再进入新 snapshot payload,避免数组随历史无限增长。 - `stickyEphemeralIds` 记录当前仍处于 sticky ephemeral override 的 artifact id,即使对应旧 tombstone 已经不需要 replay,也必须保留该 override 状态。 -- `stickyEphemeralIds` 必须有界,默认和 persisted metadata 上限共享同一 `maxPersistedMetadata` 数量级,并计入 artifact journal working-set budget。unpin 到 `ephemeral` 若会超过 sticky set 上限,显式 API 必须返回错误并保持原 durable state;后台/restore prune 必须记录 warning 后稍后重试,不能静默增长、随机裁剪旧 sticky override,或让隐式 upsert 恢复持久化。 +- `stickyEphemeralIds` 必须有界,默认和 persisted metadata 上限共享同一 `maxPersistedMetadata` 数量级,并计入 artifact journal working-set budget。旧 `unpin_to_ephemeral` journal replay 若会超过 sticky set 上限,restore/prune 必须记录 warning 后稍后重试,不能静默增长、随机裁剪旧 sticky override,或让隐式 upsert 恢复持久化。 - snapshot 可以包含曾经被 tombstone 的 artifact id,前提是该 tombstone 已被更高 sequence 的显式 upsert supersede。 - load 时从新到旧选择最新 valid snapshot,然后只应用该 snapshot 之后的 artifact events。 - 如果最新 snapshot 解析失败,记录 `snapshot_invalid` warning,继续尝试上一个 valid snapshot;不能因为一个 corrupt snapshot 丢失整个 session 的 artifact metadata。 @@ -304,8 +295,8 @@ V2 不双写 sidecar,因此没有 JSONL + sidecar 的 metadata 重复存储。 - artifact persistence records 跟随 chat transcript 生命周期;不做独立文件 GC。 - 每 session 增加 artifact journal working-set byte budget,例如 4 MB。该 budget 衡量恢复必须读取和应用的 artifact 工作集,也就是最新 valid snapshot 加其后的 artifact events;不能把 chat transcript 中已经被 snapshot 覆盖的旧 artifact records 计入 budget,否则 append-only JSONL 会变成不可恢复的一次性上限。 - writer 必须显式跟踪 working-set bytes:每次写 snapshot 后记录该 snapshot 的 artifact byte size、JSONL append position 或 line index 作为 `postSnapshotBase`,之后每个 artifact event append 增加 `postSnapshotEventBytes`。预算检查使用 `snapshotBytes + postSnapshotEventBytes`,snapshot baseline advance 成功后重置 counter。若 writer 无法确认 base position 或 counter 状态,必须保守写新 snapshot;仍无法确认时降级或报错,不能无界追加。 -- budget 接近上限时先尝试写新 snapshot。若最新 snapshot 加 post-snapshot events 仍超过 budget,则不再写新的 restorable metadata,普通 artifact 降级为 `ephemeral` 并带 `persistenceWarning.code = "journal_budget_exceeded"`;用户显式 pin/save 仍返回明确错误而不是静默降级。 -- 不把 content bytes 写进 JSONL;content 只进入 daemon-managed artifact storage。 +- budget 接近上限时先尝试写新 snapshot。若最新 snapshot 加 post-snapshot events 仍超过 budget,则不再写新的 restorable metadata,普通 artifact 降级为 `ephemeral` 并带 `persistenceWarning.code = "journal_budget_exceeded"`。 +- 不把 content bytes 写进 JSONL;PR #6259 也不写 daemon-managed artifact content storage。 ## 5. 写入与恢复流程 @@ -317,8 +308,7 @@ V2 不双写 sidecar,因此没有 JSONL + sidecar 的 metadata 重复存储。 - `url`:按 storage type 校验 scheme、userinfo、secret-like query/fragment。 - `managedId`:拒绝路径形态、`..`、绝对路径、分隔符。 - `published`:只能由 daemon 内部 trusted publisher 或 manifest-validated path 产生,不能由 client payload 自称。 -- `contentRef`:如果存在,必须验证 `kind`/`id` shape,拒绝分隔符、绝对路径和 `..`,并只能通过 daemon-managed manifest 解析。 -- `expiresAt`:daemon-only 字段;client payload 中出现时必须拒绝或 strip。只有 pin/save 的 `ttlDays` 可以由 daemon 转换成 `expiresAt`。 +- 旧 `contentRef` / `expiresAt`:只作为 legacy journal 输入兼容;client payload 中出现时必须拒绝或 strip,当前 PR 不能生成新的字段。 - `restoreState` / `persistenceWarning`:runtime-only response 字段;client payload 中出现时必须拒绝或 strip,不能写入 persisted artifact。 - `clientRetained`:只能是 boolean,表示用户保留意图和稳定排序 hint,不是授权信号。只有显式 REST/SDK/UI action 可以设置;后台自动 ingest 不能伪造为用户保留。 - `metadata`:执行 primitive-only、size limit、secret key/value 和 unsafe display payload checks。 @@ -342,33 +332,30 @@ V2 流程: ingest input -> normalize/validate -> in SessionArtifactStore operationQueue: compute effective mutation - -> for restorable/pinned changes: request chat-recording writer append + -> for restorable changes: request chat-recording writer append artifact journal/snapshot on the active leaf chain -> apply live-store mutation -> publish artifact_changed with effective retention/warning fields ``` -`SessionArtifactStore` 的 operation queue 负责串行化同一 session 的 live mutation、persistence request 和 SSE 顺序;真正的 JSONL append 仍由 chat recording owner 完成。普通 tool/hook artifact 如果 persistence writer 不可用,可以降级为 live-only `ephemeral` 后进入 live store;显式 `pin/save` 不能降级,必须返回错误。 +`SessionArtifactStore` 的 operation queue 负责串行化同一 session 的 live mutation、persistence request 和 SSE 顺序;真正的 JSONL append 仍由 chat recording owner 完成。普通 tool/hook artifact 如果 persistence writer 不可用,可以降级为 live-only `ephemeral` 后进入 live store。 如果 sticky ephemeral override 抑制了隐式/default upsert 的持久化,live artifact 必须带 `persistenceWarning.code = "sticky_override_active"`,并记录 structured log `action=sticky_override_suppressed` 和 counter metric。否则排障时会看到合法 upsert input 却找不到对应 durable record。 当前 PR 没有隐藏的 paged persisted metadata 视图;live list 就是恢复后暴露给 client 的 metadata 集合。因此上限处理采用一个收窄策略: - `ephemeral` artifact 可以只从 live view 丢弃,不写 journal。 -- `restorable` / `pinned` artifact 被上限裁剪时,写 `reason: "eviction"` remove event,避免下次 load/replay 把已裁剪条目全部复活。 -- 如果 live list 全部是 `pinned` 且没有可裁剪项,显式/strict mutation 返回 validation error;非 strict 自动写入可以降级或丢弃新 artifact,并返回/记录 warning。 +- `restorable` artifact 被上限裁剪时,写 `reason: "eviction"` remove event,避免下次 load/replay 把已裁剪条目全部复活。 ### 5.3 写入失败语义 区分两个入口: - 普通 tool/hook artifact:持久化失败不应让工具调用失败;artifact 仍可进入 live store,但必须先把 live store 中的 `retention` 降级为 `ephemeral`,设置 `persistenceWarning`,再发布 `artifact_changed`。 -- 显式 `pin/save` API:持久化失败必须返回错误,不能假装已经保存。 - -对会影响恢复结果的删除型 mutation,当前 PR 按原因区分: + 对会影响恢复结果的删除型 mutation,当前 PR 按原因区分: - `eviction`:durable remove event,保证重启后仍遵守 200 条上限。 -- unpin-to-`ephemeral`:durable remove event,并把 id 写入 bounded `stickyEphemeralIds`;后续隐式/default upsert 会保持 live-only,直到显式 `retention: "restorable"` 或 pin/save supersede。 +- legacy unpin-to-`ephemeral`:读取旧 journal 时继续识别 durable remove event,并把 id 写入 bounded `stickyEphemeralIds`;后续隐式/default upsert 会保持 live-only,直到显式 `retention: "restorable"` supersede。 - 显式 DELETE:live-first。先从 live store 移除并发布删除事件,再 best-effort 写 explicit remove tombstone。tombstone 写入失败时 response 返回 warning(当前为字符串 warning),表示删除没有 durable;如果 daemon 在补写成功前重启,旧 journal 仍可能恢复该 artifact。 - `deleteContent: true` 不属于 PR #6259 的 public API。content-retention follow-up 才会定义 content GC 与 warning contract;当前 PR 的显式 DELETE 只处理 metadata tombstone 和 live removal。 @@ -408,7 +395,7 @@ rewind/replay 中的 live store 处理必须和 load 一致:一旦 active leaf - `external_url`:只允许 `http:` / `https:`;拒绝 username/password credential;secret-like query/fragment 必须 redacted、降级为 non-openable locator,或整条 artifact 降级/阻断。 - `published`:可以恢复 `file:` locator,但只能在 trusted publisher manifest 重新校验通过、且目标属于 daemon-managed published storage 时允许。普通 `external_url` 永远不能通过 `file:`。 - `managedId`:拒绝路径形态、`..`、绝对路径、分隔符。 -- `contentRef`:验证 `kind`/`id` shape,拒绝分隔符、绝对路径和 `..`;只能通过 daemon-managed manifest 在当前 session/artifact scope 内解析;暴露前必须校验 stored size/hash metadata。 +- 旧 `contentRef`:只作为 legacy journal 输入校验并 strip;PR #6259 不通过 daemon-managed manifest 解析内容,也不把旧 `contentRef` 暴露为可打开内容承诺。 - `metadata`:重新执行 primitive-only、size limit、secret key/value 和 unsafe display payload checks。 恢复失败时: @@ -425,8 +412,8 @@ rewind/replay 中的 live store 处理必须和 load 一致:一旦 active leaf - fork 写入目标 session 时,应根据目标 `sessionId + locator` 重新计算 artifact id。 - tombstone 也要按目标 session 的新 id 重写。只要 tombstone 的 artifact id 可以安全 remap,就应保留到目标 session,即使目标 active chain 中暂时找不到对应 upsert;orphan tombstone 没有匹配 upsert 时是无害的,但丢弃它可能让后续同 id upsert 丢失 suppression。 - `forkedFrom` 可以记录原 session id / 原 artifact id,作为审计信息,但不能参与新 session 的权限判断。 -- fork 继承 artifact metadata 时,`pinned` 必须降级为 `restorable`。fork 不继承 pinned contentRef,用户需要在 forked session 中显式 re-pin,避免通过 fork 绕过 session/project quota。 -- fork copy 必须重新执行 ingest/restore validation、privacy minimization 和 redaction。workspace / url / metadata / contentRef 中无法在目标 session 安全表达的 locator 必须降级、strip 或丢弃,不能因为源 session 曾经通过校验就直接复制。 +- fork 继承旧 `pinned` artifact metadata 时,必须降级为 `restorable`,并移除旧 `contentRef`。 +- fork copy 必须重新执行 ingest/restore validation、privacy minimization 和 redaction。workspace / url / metadata 中无法在目标 session 安全表达的 locator 必须降级、strip 或丢弃,不能因为源 session 曾经通过校验就直接复制。 - `managedId` 不能从源 session 盲目复制。目标 session 中若能从目标 workspace / daemon-managed manifest 派生新的 `managedId`,必须重新计算;不能安全派生时必须移除 `managedId` 或丢弃该 artifact metadata。 fork remap 是发布门槛:如果某条路径不能安全重写 artifact id 和 tombstone,就必须在 fork 时丢弃 artifact persistence records,不能把源 session 的 artifact id 原样带入新 session。若现有 fork 实现有类似 `file_history_snapshot` 的 top-up 机制,artifact 也只能从 active-chain replay result 生成 top-up,不能从 daemon 当前 live store 原样补写,否则会把 rewind 后不再属于历史的 artifact 带入新 session。 @@ -435,7 +422,7 @@ fork remap 是发布门槛:如果某条路径不能安全重写 artifact id fork 的 rewind 语义是 branch-scoped:目标 session 只复制当前 active chain 的结果。如果用户 rewind 到显式 DELETE 之前再 fork,那个 DELETE tombstone 本来就不在 active chain 中,artifact 在新 branch 中重新出现是预期的历史分支行为。若产品需要“全局不可 rewind 删除”或隐私擦除语义,应作为单独的 policy 设计,不能混入 V2 默认 branch model。 -metadata 的 fork amplification 在 V2 中作为有界 trade-off 接受:fork 需要 session mutate 权限,每个 fork 仍受 200 条 persisted metadata 上限,metadata 单条较小,且不会继承 pinned content bytes。V2 不引入 project-level metadata quota;实现必须记录 forked artifact count metric/log,若实际滥用再引入 project-level cap。content retention 仍受 session/project content quota 约束。 +metadata 的 fork amplification 在 V2 中作为有界 trade-off 接受:fork 需要 session mutate 权限,每个 fork 仍受 200 条 persisted metadata 上限,metadata 单条较小,且不会继承 content bytes。V2 不引入 project-level metadata quota;实现必须记录 forked artifact count metric/log,若实际滥用再引入 project-level cap。 ## 6. API 设计 @@ -457,7 +444,7 @@ metadata 的 fork amplification 在 V2 中作为有界 trade-off 接受:fork - 行为可用且当前配置启用时才声明对应 feature string。 - chat recording 禁用、metadata persistence 禁用或 writer 不可用时,不声明 `session_artifacts_persistence`。 -- content retention 的显式 workspace `pin/save content`、quota、hash、manifest、session-scoped GC/fsck 都可用时,才声明 `session_artifacts_content_retention`。PR #6259 不声明该 capability;后续拆分 PR 必须保持 feature string 是 daemon build-level 能力。 +- future content archive 的显式 workspace content 保存、quota、manifest、session-scoped GC/fsck 都可用时,才声明 `session_artifacts_content_retention`。PR #6259 不声明该 capability。 - 如果 client 需要读取 limits/default retention,应另设计 config endpoint 或 SDK config query;不要把结构化 details 混入现有 string-only capability contract。 ### 6.2 Add artifact @@ -480,71 +467,14 @@ metadata 的 fork amplification 在 V2 中作为有界 trade-off 接受:fork - client 可以请求 `ephemeral` 或 `restorable`。 - client 不能请求 `pinned`。 - `clientRetained` 可选,仅表示用户保留意图和排序 hint;服务端必须按 §5.1 校验来源,不能把它当授权。 -- `pinned` 必须走 pin/save API,因为可能涉及内容复制、配额、确认和失败回滚。 ### 6.3 Pin/save artifact -本节是后续 content-retention PR 的 API 设计,不属于 PR #6259 的 wire contract。PR #6259 只允许 add/list/get/delete metadata artifacts,不暴露 pin/unpin endpoint。 - -新增: - -```http -POST /session/:id/artifacts/:artifactId/pin -Content-Type: application/json -``` - -Body: - -```json -{ - "mode": "metadata" -} -``` - -或在 content retention capability 开启后: - -```json -{ - "mode": "content", - "ttlDays": 90, - "clientRetained": true -} -``` - -语义: - -- `mode: "metadata"`:升级为 `restorable`。 -- `mode: "content"`:复制或绑定可控内容,升级为 `pinned`。 -- pin 一个仍为 `ephemeral` 的 live artifact 时,必须创建新的 journal upsert event;它不是修改既有 persisted record。 -- pin/save 是显式保留意图,成功后应把 `clientRetained` materialize 为 `true`,除非 request 明确设置为 `false`。 -- `ttlDays` 只允许和 `mode: "content"` 一起使用,由 daemon 在 pin 时转换为绝对 `expiresAt = now + ttlDays`。如果提供,必须是正整数,并受默认最大值 365 天约束;超过上限返回 `INVALID_ARGUMENT`。`mode: "metadata"` 携带 `ttlDays` 返回 `INVALID_ARGUMENT`,不能静默忽略。 -- 成功按 §6.6 返回更新后的 artifact,并发布 `artifact_changed` / `updated`。 -- 失败返回明确错误,不改变 artifact retention。 -- 对已经 `pinned` 的 artifact,空 body / 无显式选项的重复 pin 是幂等 no-op。带显式选项的重复 pin 视为用户更新操作:`mode: "metadata"` 可降级为 `restorable`,显式 `mode: "content"` 会刷新 managed content copy/hash,`ttlDays` 会更新 `expiresAt`,`clientRetained` 会更新保留 hint。调用方不应把带选项的 pin 当作无副作用重试。 +PR #6259 不暴露 pin/save endpoint。显式内容留档、content archive、pin/save 语义如未来需要,应基于新的产品需求重新设计,不能从本文当前 metadata persistence contract 推导。 ### 6.4 Unpin -本节是后续 content-retention PR 的 API 设计,不属于 PR #6259 的 wire contract。 - -```http -DELETE /session/:id/artifacts/:artifactId/pin -Content-Type: application/json -``` - -Body: - -```json -{ - "retention": "restorable" -} -``` - -语义: - -- `retention` 可为 `restorable` 或 `ephemeral`,默认 `restorable`。 -- `restorable`:删除 content retention,保留 metadata restore,并写 upsert event。该 upsert payload 必须移除 `contentRef` 和 `expiresAt`,避免恢复时引用已可被 GC 的旧 content。 -- `ephemeral`:删除 content retention,live store 中保留 artifact,但写 remove tombstone,确保下次 load 不复活。该 tombstone 对同一 artifact id 是 sticky override:后续隐式/default upsert 仍保持 live-only,直到 §4.3 定义的显式 `retention: "restorable"` 或 pin/save 写入 superseding upsert。 -- 若要从列表移除,仍使用 V1 DELETE。 +PR #6259 不暴露 unpin endpoint,也不会生成新的 unpin tombstone。旧 journal 里的 `reason: "unpin_to_ephemeral"` 只作为兼容输入继续 replay,避免历史记录恢复语义变化。若要从列表移除,仍使用 V1 DELETE。 ### 6.5 Delete artifact @@ -554,16 +484,14 @@ V2 的 DELETE 仍保持 V1 幂等,并采用当前 PR 的 live-first 语义: - 随后 best-effort append `session_artifact_event` remove tombstone;tombstone 成功后,metadata restore 时不再复活。 - tombstone 失败时,返回成功 mutation result 但附带 warning;当前 daemon 生命周期内该 artifact 已被删除,但如果 daemon 在 tombstone 持久化前重启,旧 durable artifact 仍可能恢复。用户或上层 UI 可以在 storage 恢复后重试 DELETE。 - DELETE 对不存在的 artifact 保持幂等成功;如果已有 durable tombstone,重复 DELETE 不需要再写同一 tombstone。 -- PR #6259 的 DELETE 不接受 `deleteContent`,也不触发 daemon-managed content GC;旧 `contentRef` metadata 只在 restore/serialization 时被降级或移除。内容删除与 GC 由后续 content-retention PR 定义。 +- PR #6259 的 DELETE 不接受 `deleteContent`,也不触发 daemon-managed content GC;旧 `contentRef` metadata 只在 restore/serialization 时被降级或移除。 ### 6.6 Mutation responses -Pin、unpin 和 delete 必须使用一致的 mutation response,避免 client 猜测状态。PR #6259 只交付 DELETE response;Pin/Unpin response 是后续 content-retention PR 的 contract。 +PR #6259 只交付 DELETE mutation response。 成功: -- Pin:`200 OK` 返回更新后的 `DaemonSessionArtifact`。 -- Unpin:`200 OK` 返回更新后的 `DaemonSessionArtifact`;`retention: "restorable"` 时 `contentRef`/`expiresAt` 已移除,`retention: "ephemeral"` 时 response 可以返回当前 live-only artifact,并带 `persistenceWarning.code = "sticky_override_active"`。 - DELETE:`200 OK` 返回 `{ "deleted": true, "artifactId": string, "warnings"?: [...] }`。 - DELETE tombstone 持久化失败时仍返回 `200 OK` mutation result,并在 `warnings` 中包含持久化失败原因;当前实现使用字符串 warning,例如 `remove_not_persisted`。这表示 live delete 已生效但跨重启不保证,不能把它展示成 durable delete 成功。 @@ -573,7 +501,7 @@ Pin、unpin 和 delete 必须使用一致的 mutation response,避免 client { "error": { "code": "INVALID_ARGUMENT", - "message": "ttlDays is only valid with content pinning" + "message": "retention must be ephemeral or restorable" } } ``` @@ -607,13 +535,14 @@ type ArtifactPrincipal = - list:需要 session read 权限。 - add ephemeral/restorable:需要 session mutate 权限。 -- pin/save content:需要 session mutate 权限,并且必须是显式 REST/SDK call;“用户确认”在 V2 中不表示 agent permission-vote,而是 UI 或 headless client 主动调用 pin/save endpoint。 - delete metadata:需要 session mutate 权限。V1 same-principal delete guard 只能作为 live-process UX guard 和 audit hint;它依赖当前连接上下文,不能跨 daemon restart 证明 artifact owner。restore 后不能从 public `clientId` 伪造 ownership,删除授权退化为 session-level mutate 权限并记录 `ownership_unverified` audit。 -- delete content:后续 content-retention PR 才启用;需要 session mutate 权限、`session_artifacts_content_retention` capability 启用、显式 REST/SDK call,以及当前进程可验证的 creator-principal match 或显式 override/admin policy;background session/hook 不能直接发起 `deleteContent`。restore 后如果没有 durable owner proof,默认只能释放当前 artifact 引用并交给 GC,不能立即强删共享 content。若 contentRef 被多个 session 引用,也只能释放当前 artifact 引用,不能强删共享内容。 +- content archive / delete content:当前 PR 不启用。未来若重启 content archive,需要 session mutate 权限、独立 capability、显式 REST/SDK call,以及当前进程可验证的 creator-principal match 或显式 override/admin policy;background session/hook 不能直接发起内容删除。 如果未来需要真正的 `session_owner`,必须先设计 durable per-session capability 或 ACL,不能在本 V2 文档中隐式假设。 -### 7.2 持久化内容边界 +### 7.2 Future content archive 边界 + +本节是 future content archive 蓝图,不属于 PR #6259 的实现或验收范围。 默认不复制: @@ -621,7 +550,7 @@ type ArtifactPrincipal = - 任意 workspace 文件 - 普通 assistant link -允许 content retention 的来源: +未来若启用 content archive,可考虑允许的来源: - trusted `ArtifactTool` / publisher 生成的 `published` artifact。 - 用户显式 pin 的 workspace artifact,且文件在 workspace 内、类型/大小可控。 @@ -703,12 +632,7 @@ live store 上限在当前实现中也是 restore 可见集合的上限: 超过 persisted metadata 上限: - `ephemeral` 本来不写 journal,不计入 persisted metadata quota,只受 live store 上限约束。 -- `restorable` 必须按确定性顺序裁剪并写 `eviction` remove event:先裁剪未 `clientRetained` 的 `restorable` artifact;如果仍无空间,再裁剪 `clientRetained` 的 `restorable` artifact。`clientRetained` 是排序保护,不是绝对保护;用户需要真正保护内容时应使用 pin/save。 -- `pinned` metadata 不因 metadata quota 被裁剪,但 content retention 仍受 content quota 约束。 -- 如果 200 个 persisted slots 全部被 `pinned` 占用,没有可裁剪的 `restorable` candidate: - - 自动/tool artifact 降级为 live-only `ephemeral`,带 `persistenceWarning.code = "metadata_quota_exceeded"`。 - - 显式请求 restorable/pin/save 的 API 返回 `METADATA_QUOTA_EXCEEDED`,提示用户删除、unpin 或让 pinned TTL 到期。 - - daemon 不能为了接收新 artifact 自动裁剪 pinned metadata;这会违背用户显式保存语义。 +- `restorable` 必须按确定性顺序裁剪并写 `eviction` remove event:先裁剪未 `clientRetained` 的 `restorable` artifact;如果仍无空间,再裁剪 `clientRetained` 的 `restorable` artifact。`clientRetained` 是排序保护,不是绝对保护。 restore seed 不能超过 live store 上限;若历史里有效 persisted artifact 超过当前 live cap,daemon-side store 按同一确定性规则 seed 可见 subset,并通过 operation queue 为被裁剪的 durable item 写 `eviction` remove event。`loadSession()` parse 过程本身保持 read-only,不能直接写 durable prune。 @@ -741,7 +665,7 @@ GC trigger: - artifact delete、unpin、TTL 到期检查、session close 或 explicit `POST /session/:id/artifacts/gc`。 - stale `.tmp` entries are cleaned during GC. -Project-scoped reference rebuild、incomplete-scan tracking、orphan grace period 和 global artifact library 都是后续增强。当前实现的 safety 边界来自“不跨 session 继承 contentRef”和“只删除当前 session manifest 且当前 live refs 未引用的 content”。 +Project-scoped reference rebuild、incomplete-scan tracking、orphan grace period 和 global artifact library 都是后续增强。future content archive 的 safety 边界应来自“不跨 session 继承 contentRef”和“只删除当前 session manifest 且当前 live refs 未引用的 content”。 ### 8.4 Crash consistency @@ -749,22 +673,20 @@ Project-scoped reference rebuild、incomplete-scan tracking、orphan grace perio - artifact store mutation 串行。 - JSONL journal append 失败不会破坏 live store。 -- explicit `pin/save metadata` 必须等待 journal 落盘。 -- explicit `pin/save content` 必须等待 content manifest 和 journal 都落盘。 - explicit DELETE live-first:live store removal must not be blocked by journal failure; response warning tells clients when the tombstone was not durable. - explicit DELETE with `deleteContent: true` is only available in the content-retention follow-up; that PR must run best-effort session-scoped content GC after live removal and surface content delete warnings. - live cap eviction for durable artifacts writes an `eviction` remove event so restore respects the cap. - reader 容忍半截 JSONL 和 corrupt artifact record。 - tombstone / snapshot 顺序异常时选择不恢复,而不是猜测。 -`pin/save content` 写入顺序: +Future content archive 的写入顺序: 1. 复制内容到 staging path,hash exactly copied bytes,并 fsync bytes。 2. atomically move 到 daemon-managed content root,写入并 fsync content manifest。 3. append artifact journal event,引用该 contentRef,并 fsync JSONL。 4. 更新 live store 并发布 `artifact_changed`。 -如果第 2 步成功但第 3 步前 crash,会留下没有 journal 引用的 orphan content;这是允许的,当前 session-scoped GC 在确认 manifest 不被当前 live refs 引用后 best-effort 删除。如果第 3 步成功,restore 必须能通过 manifest 找到内容。显式 API 只有在第 3 步成功后才能返回成功。 +如果第 2 步成功但第 3 步前 crash,会留下没有 journal 引用的 orphan content;这是允许的,future session-scoped GC 在确认 manifest 不被当前 live refs 引用后 best-effort 删除。如果第 3 步成功,restore 必须能通过 manifest 找到内容。显式 API 只有在第 3 步成功后才能返回成功。 ### 8.5 文件读取、CPU 与 I/O 成本 @@ -780,22 +702,22 @@ CPU 成本边界: - Metadata restore 只 parse JSON 和做字段校验,复杂度 O(artifact 数量 + 最新 snapshot 后事件数)。 - `external_url` 恢复不发网络请求。 -- `workspace` 恢复只做 path normalization、realpath/stat 等元数据检查,不读取文件内容。 +- `workspace` load/replay 只恢复 metadata;GET/list refresh 在 TTL/batch 限制下重新 stat/hash 单个或一批 workspace 文件,用于区分 `available` / `missing` / `changed`。 - `managed` / `published` 恢复只查 manifest,不读取大文件内容。 -- content hash 校验只在显式 `pin/save content` 或打开 retained content 时触发,不在每次 session load 时全量 hash。 +- workspace content hash 不在 `loadSession()` 的 JSONL parse 阶段全量执行。 I/O 成本边界: - V2 不额外读 sidecar 文件。 - workspace 状态校验复用 V1 的 TTL/batch 策略,不在 GET 热路径对所有 artifact 做无限制 stat。 -- 对大 workspace 文件,不在恢复阶段读内容;只有用户显式 pin/save 时才读取并 hash。 +- 对大 workspace 文件,不在恢复阶段读内容;登记和后续 refresh 读取实时文件流计算 sha256,不复制到 daemon-managed storage。 推荐默认: - artifact snapshot 上限 200 条。 - workspace status restore batch size 20,与 V1 保持一致。 - artifact journal snapshot 阈值 100 mutations 或 256 KB。 -- content hash 在 pin/save 时同步完成;恢复时 lazy verify 或后台 best-effort verify。 +- workspace sha256 在登记时同步完成;恢复后的状态校验按 TTL/batch lazy refresh。 ### 8.6 Observability @@ -815,17 +737,13 @@ V2 新增的失败路径必须有 structured logs,格式沿用: - `eviction` - `fork_artifact_discarded` - `fork_incomplete` -- `gc_content_deleted` -- `gc_incomplete_scan` -- `gc_reference_rebuilt` - `snapshot_invalid` - `sticky_override_suppressed` -- `ttl_scan_checked` - `tombstone_conflict` -- `content_copy_rejected` -- `metadata_fsck_failed` - `v2_writer_version_gate_failed` +Future checker / content archive 可以再增加 fsck、content copy、TTL、GC 相关 action;PR #6259 不产生这些日志。 + 这些日志不替代 API/SSE 中的 `persistenceWarning`,而是用于生产排障。 建议 metrics: @@ -834,21 +752,15 @@ V2 新增的失败路径必须有 structured logs,格式沿用: - counter: `artifact_restore_total{result,restore_state}` - gauge: `artifact_pending_tombstone_count` - gauge: `artifact_metadata_quota_used{session}` -- gauge: `artifact_content_quota_used_bytes{scope}` -- gauge: `artifact_gc_sessions_unscanned` -- gauge: `artifact_content_expired_pending_bytes` - counter: `artifact_sticky_override_suppressed_total` -- histogram: `artifact_gc_sweep_duration_seconds` -- counter: `artifact_gc_deleted_content_total{reason}` -- counter: `artifact_fsck_findings_total{kind}` 导出方式沿用 daemon 现有 telemetry/metrics 机制;如果当前没有 Prometheus endpoint,至少要进入 structured telemetry sink,并能按 session/project 聚合。 -诊断工具分两层。metadata-only `fsck` 是 metadata restore 发布门槛,必须在 content retention 前可用,用于扫描 artifact journal/snapshot/tombstone 与 restore validation failure。full `fsck` 是 content retention 发布门槛,额外扫描 content manifests 和 daemon-managed storage。实现必须提供 CLI 或 daemon-internal API,例如 `qwen artifact fsck`,用于 dry-run 扫描 project/session artifact journals、content manifests 和 daemon-managed storage: +诊断工具是后续增强,不属于 PR #6259 的 wire contract。metadata-only checker 可扫描 artifact journal/snapshot/tombstone 与 restore validation failure;full content checker 则等 future content archive 重新设计后,再扫描 content manifests 和 daemon-managed storage。未来 CLI 或 daemon-internal API(例如 `qwen artifact fsck`)应支持 dry-run: -- 报告 dangling `contentRef`、manifest 缺失、orphan content、snapshot/tombstone 不一致和 restore validation failure。 -- 默认只读;修复模式只能做可验证的安全动作,例如重新生成 snapshot、清除 stale cache marker、标记 orphan content 等待 GC。当前 PR 的自动删除只限于当前 session manifest 且当前 live refs 未引用的 managed content;跨 session 引用重建和延迟删除策略是后续增强。 -- 输出结构化结果并计数,至少包含 `fsck_dangling_content_ref`、`fsck_orphan_content`、`fsck_snapshot_invalid`;持续出现的 dangling contentRef 或 snapshot invalid 应触发告警。 +- metadata-only 模式报告 snapshot/tombstone 不一致和 restore validation failure。 +- full content 模式报告 dangling `contentRef`、manifest 缺失和 orphan content。 +- 默认只读;修复模式只能做可验证的安全动作,例如重新生成 snapshot 或标记 orphan content 等待 GC。 ## 9. 实现方案 @@ -862,14 +774,14 @@ V2 新增的失败路径必须有 structured logs,格式沿用: - 共享 restore validation、snapshot/tombstone consistency checks 和 persisted shape normalization。 - 扩展 `ChatRecord.subtype` 与 `systemPayload` union。 - 增加 load result 中的 `artifactSnapshot?`。 -- 增加 metadata-only `fsck` / checker,可 dry-run 检测 corrupt artifact records、snapshot/tombstone 不一致和 restore validation failure。 +- metadata-only checker 是后续增强,可 dry-run 检测 corrupt artifact records、snapshot/tombstone 不一致和 restore validation failure。 ### Milestone B: daemon-side store 集成 - daemon bridge `createSessionEntry` 支持 seed artifacts。 - `SessionArtifactStore` 支持 seed artifacts。 - `upsertMany()` 在 operation queue 中计算 effective `retention`、quota prune 和 live view,再通过 writer append durable records。 -- `remove()` 区分 explicit DELETE、unpin-to-ephemeral 和 eviction;explicit DELETE live-first 并 best-effort 写 tombstone,unpin-to-ephemeral 和 durable eviction 写 journal。 +- `remove()` 区分 explicit DELETE 和 eviction;explicit DELETE live-first 并 best-effort 写 tombstone,durable eviction 写 journal。旧 `unpin_to_ephemeral` 只在 journal replay / snapshot sticky state 中保留兼容。 - V1 live session 首次启用 V2 的 backfill snapshot 不在当前 PR 实现范围内;当前实现从新写入的 V2 journal/snapshot 恢复。 - 保持 V1 `artifact_changed` event shape 不变,只增加 optional fields。 @@ -888,18 +800,15 @@ V2 新增的失败路径必须有 structured logs,格式沿用: - SDK type 增加 optional fields。 - `POST /session/:id/artifacts` 支持 `retention: "ephemeral" | "restorable"`。 - `POST /session/:id/artifacts` 支持 `clientRetained` boolean hint,并拒绝 client 传入 daemon-only runtime fields。 -- 新增 `pinArtifact()` / `unpinArtifact()` SDK 方法。 - capability gate UI。 -### Milestone E: content retention +### Milestone E: Future content archive -- 增加 daemon-managed workspace content manifest 和 session-scoped GC/fsck。 -- 实现 race-safe content copy、hash 校验、quota、write-queue/lease-protected GC。 -- published artifact 绑定 trusted contentRef 是后续增强。 +不属于 PR #6259。若未来有审计/留档需求,需要单独设计 daemon-managed workspace content manifest、quota、race-safe copy、hash 校验、write-queue/lease-protected GC/fsck 和 published artifact content binding。 ## 10. 测试计划 -必须覆盖: +PR #6259 当前必须覆盖: - metadata journal append 后 daemon restart/load 恢复 artifact list。 - artifact journal append 通过 chat recording owner 写入 active leaf chain;daemon-side store 不能直接写 JSONL。 @@ -907,52 +816,48 @@ V2 新增的失败路径必须有 structured logs,格式沿用: - `/rewind` 后 live store 立即与 active-chain artifact state 对齐;不会等到 daemon 重启才改变 artifact list。 - V1 live session 升级到 V2 时的 backfill snapshot 是后续增强;当前 PR 测试应确认未写入 V2 journal 的旧 live artifacts 不被误报为可恢复。 - DELETE tombstone 后 load 不复活 artifact。 -- unpin 到 `ephemeral` 后 load 不复活 artifact。 -- unpin 到 `ephemeral` 后,同一 artifact id 的隐式/default re-upsert 仍保持 live-only;显式 restorable/pin 可以 supersede sticky override。 +- legacy `unpin_to_ephemeral` tombstone replay 后 load 不复活 artifact。 +- legacy `unpin_to_ephemeral` 后,同一 artifact id 的隐式/default re-upsert 仍保持 live-only;显式 `restorable` 可以 supersede sticky override。 - snapshot baseline advance 后 `stickyEphemeralIds` 仍能让隐式/default re-upsert 保持 live-only,并产生 `sticky_override_suppressed` log/metric/warning。 -- `stickyEphemeralIds` 达到上限时,unpin-to-ephemeral 返回错误或延后重试,且不会静默丢失旧 sticky override。 +- `stickyEphemeralIds` 达到上限时,legacy unpin-to-ephemeral 返回错误或延后重试,且不会静默丢失旧 sticky override。 - explicit DELETE live-first:live view 立即移除;tombstone 写入失败时 response 带 warning,测试覆盖 live removal 不被 persistence failure 阻断。 -- content-retention follow-up 覆盖 `deleteContent: true` 在 tombstone/content GC 有风险时暴露 `content_delete_preserved` warning。 - durable artifact eviction 写 `eviction` remove event;restore 后不会超过 live cap。 - snapshot baseline advance:periodic snapshot 压缩当前 artifact list,explicit tombstone 在 snapshot 成功后不再无界增长,`stickyEphemeralIds` 保留 sticky state。 - workspace artifact ingest 和 restore 时文件存在/缺失/symlink escape 三种状态。 - workspace root 重定位:相同相对路径存在时恢复为 available;缺失或 layout 不一致时恢复为 missing;不做 path remap。 -- content-retention follow-up 覆盖 pin/save content 时拒绝 symlink、special file、oversized stream、hardlink 异常和 TOCTOU swap。 - external URL 只恢复 metadata,不发网络请求。 - secret-bearing URL query/fragment 与 metadata key/value 不写入 JSONL。 - published local `file:` 只有 trusted manifest revalidation 通过时恢复。 -- content-retention follow-up 覆盖 stale/tampered `contentRef` 无法绕过 daemon-managed manifest、size 和 hash 校验。 - `managedId` 在 ingest、restore 和 fork remap 时拒绝分隔符、`..`、绝对路径和路径形态;fork 不能盲目复制源 session 的 `managedId`。 - corrupt JSONL record 被跳过且不影响其它 artifacts。 - chat recording / persistence disabled 时不声明或不启用 metadata restore。 -- pin/save 显式写失败时返回错误。 - tool artifact 持久化失败时降级为 live-only,并通过 `persistenceWarning` 让 client 可见。 - branch/fork 时 artifact records 的 sessionId/id 处理,且只使用 active-chain replay result。 - fork full-file write:active-chain remap 后 exclusive-create 写入目标 JSONL,失败不产生成功 fork;如果未来改为 streaming fork,再补 begin/complete marker 测试。 -- fork 继承 pinned artifact 时降级为 restorable,不继承 contentRef。 +- fork / restore 读取旧 `pinned` artifact 时降级为 restorable,不继承 contentRef。 - orphan tombstone 在 fork remap 时被保留并安全 remap;无法安全 remap 的 tombstone 才丢弃。 - fork remap 重新执行 validation、privacy minimization 和 redaction;unsafe locator 被 strip、降级或丢弃。 - restore seed 与 concurrent POST 串行,不丢写、不重复。 -- quota 边界:200 条、201 条 prune、pinned metadata、clientRetained/non-clientRetained 两层排序、全部 clientRetained restorable 仍可按确定性规则裁剪。 -- clientRetained setter:Add artifact request 和 pin/save request 都能设置 boolean hint;后台自动 ingest 不能伪造用户保留。 -- content-retention follow-up 覆盖 GC:unpin、delete、close、explicit GC endpoint 都只删除当前 session manifest 且当前 live refs 未引用的 content。 -- content-retention follow-up 覆盖 GC concurrency:并发 pin/GC 通过 content-store write queue 和 leased content ids 串行/保护。 -- content-retention follow-up 覆盖 TTL scan:`GET /artifacts` 会降级过期 pinned content 并触发 best-effort session-scoped GC。 +- quota 边界:200 条、201 条 prune、clientRetained/non-clientRetained 两层排序、全部 clientRetained restorable 仍可按确定性规则裁剪。 +- clientRetained setter:Add artifact request 能设置 boolean hint;后台自动 ingest 不能伪造用户保留。 +- workspace 三态:登记时写入 size + `metadata["qwen.workspace.sha256"]`;GET/list refresh 能区分 `available`、`missing` 和 `changed`。 - authorization:token-holder/principal 审计路径允许和拒绝情况;V1 live same-principal guard 仅作为 live UX/audit hint,不作为 durable security boundary。 -- content-retention follow-up 覆盖 restored artifact ownership_unverified fallback;deleteContent 在无 durable owner proof 时只释放引用,不立即强删共享 content。 -- content-retention follow-up 覆盖 partial writes:journal 成功但 warning、content manifest 成功但 journal 失败、journal 成功后 restore 能找到 content。 - JSONL snapshot baseline advance:threshold 触发、post-snapshot replay 有界、snapshot payload 不再携带已被覆盖的 explicit tombstones、superseded sticky tombstone 允许显式同 id 重新出现、`stickyEphemeralIds` 保留 sticky state;JSONL 文件本身不被 artifact 子系统重写。 - corrupt latest snapshot fallback:回退到较旧 valid snapshot 或一次顺序 artifact replay。 -- repin idempotency:已 pinned artifact 的空重复 pin 不刷新内容、不延长 TTL;显式 `mode` / `ttlDays` / `clientRetained` 会按 §6.3 更新对应状态。 - retention defaults:tool artifact 无显式 retention、client POST `pinned` 被拒绝。 - capability:string list 只在行为当前可用时声明;不依赖 `enabled:false` details。 - replay idempotency:同一 session history replay 两次不会重复 artifact。 - SDK 旧 client 忽略 optional fields 后仍能展示 V1 artifacts。 - V2 -> V1 rollback compatibility:旧 daemon 必须能解析或忽略 unknown `system` subtype,不得导致 session load 崩溃;回滚后 artifact persistence 不恢复是可接受降级。如果当前最低支持版本不能保证这一点,V2 writer 必须 capability-gate 到支持 unknown system record 的版本之后。 - rollback preflight:最低支持旧 daemon 版本加载包含 V2 event/snapshot 的 JSONL;如果未来加入 fork marker,再扩展 rollback fixture。 -- content-retention follow-up 覆盖 artifact fsck dry-run:dangling contentRef、orphan content、snapshot invalid 和 repair-safe actions。 -- metadata-only fsck dry-run:corrupt record、snapshot fallback、orphan tombstone、restore validation failure。 -- PR #6259 覆盖 metadata API response contract:delete success body、metadata quota validation failure、`remove_not_persisted` / `persistence_unavailable` / `sticky_override_active` warning、current 400/403/200+warning mapping。content-retention follow-up 覆盖 pin/unpin、content quota、`content_delete_preserved` / `content_expired` warning。 +- PR #6259 覆盖 metadata API response contract:delete success body、metadata quota validation failure、`remove_not_persisted` / `persistence_unavailable` warning、current 400/403/200+warning mapping。 + +Future content archive / checker 另行覆盖: + +- `deleteContent: true` 在 tombstone/content GC 有风险时暴露 `content_delete_preserved` warning。 +- pin/save content 时拒绝 symlink、special file、oversized stream、hardlink 异常和 TOCTOU swap。 +- metadata-only checker dry-run:corrupt record、snapshot fallback、orphan tombstone、restore validation failure。 +- full content checker dry-run:dangling `contentRef`、manifest 缺失、orphan content 和 GC 修复策略。 ## 11. 不建议在 V2 做的事 @@ -960,7 +865,7 @@ V2 新增的失败路径必须有 structured logs,格式沿用: - 自动扫描 workspace 文件变更。 - 默认复制所有 workspace artifact 内容。 - 对 external URL 做 reachability poll。 -- 把 `clientId` 作为删除或 pin/save 的授权凭证。 +- 把 `clientId` 作为删除授权凭证。 - 对重定位 workspace 做自动 path remapping。 - 在 GET 热路径里做大量 fs/network 校验。 - 把持久化失败变成普通 tool turn 失败。 @@ -971,11 +876,10 @@ V2 新增的失败路径必须有 structured logs,格式沿用: V2 建议作为一个完整 design phase 发布,但能力按 capability 暴露: - `session_artifacts_persistence` 可先发布 metadata restore。 -- `session_artifacts_content_retention` 只有 quota、hash、manifest、race-safe copy 和 GC 都具备时才声明。 +- `session_artifacts_content_retention` 当前不发布;future content archive 需要重新设计并独立声明 capability。 - 默认恢复显式登记的 artifact metadata。 - 用户手动注册的 artifact 默认 `restorable`,session load/replay 后继续出现在列表中。 -- 显式 `pin/save` 才做 content retention。 -- 用户文档明确:metadata restore 恢复的是“产物索引”,不是“产物内容备份”;content retention 才是受配额和 GC 约束的内容保存。 +- 用户文档明确:metadata restore 恢复的是“产物索引”,不是“产物内容备份”;workspace 的 `changed` 状态只说明实时文件和登记时 hash/size 不一致。 Rollback procedure: @@ -985,4 +889,4 @@ Rollback procedure: - 发布前 CI 必须用最低支持旧 daemon 版本加载包含 `session_artifact_event` 和 `session_artifact_snapshot` 的 JSONL,断言 session load 成功且 unknown subtype 被忽略。V2 writer 首次初始化前也要检查版本/feature gate;失败时拒绝写 V2 records,记录 `v2_writer_version_gate_failed`,保持 V1 行为。如果未来加入 fork marker,再把该 subtype 纳入 rollback fixture。 - rollback 后 client 不能依赖 `session_artifacts_persistence` / `session_artifacts_content_retention`,因为旧 daemon 不声明这些 capability。 -这样可以讲清楚 V2 的完整语义:默认恢复列表,显式保存内容,所有长期存储都受权限、配额、hash 和 GC 约束。 +这样可以讲清楚当前 V2 的完整语义:默认恢复列表,不保存内容,用 workspace size/hash 避免静默打开错误版本。 diff --git a/packages/acp-bridge/src/sessionArtifacts.test.ts b/packages/acp-bridge/src/sessionArtifacts.test.ts index f3a3476408f..03ff7f58068 100644 --- a/packages/acp-bridge/src/sessionArtifacts.test.ts +++ b/packages/acp-bridge/src/sessionArtifacts.test.ts @@ -96,12 +96,23 @@ describe('SessionArtifactStore', () => { title: 'Report', status: 'available', sizeBytes: 5, + metadata: { + 'qwen.workspace.sha256': createHash('sha256') + .update('hello') + .digest('hex'), + }, }); await expect(store.get('missing')).resolves.toBeUndefined(); - await fs.rm(path.join(workspace, 'report.txt')); + await fs.writeFile(path.join(workspace, 'report.txt'), 'HELLO'); vi.useFakeTimers(); vi.setSystemTime(new Date(Date.now() + 6_000)); + const changed = await store.get(artifactId); + expect(changed).toMatchObject({ id: artifactId, status: 'changed' }); + expect(changed).toMatchObject({ sizeBytes: 5 }); + + await fs.rm(path.join(workspace, 'report.txt')); + vi.setSystemTime(new Date(Date.now() + 6_000)); const missing = await store.get(artifactId); expect(missing).toMatchObject({ id: artifactId, status: 'missing' }); expect(missing).not.toHaveProperty('sizeBytes'); @@ -145,533 +156,46 @@ describe('SessionArtifactStore', () => { artifacts: [{ id: artifactId, clientId: 'client-a' }], }); - await expect( - store.remove(artifactId, { clientId: 'client-a' }), - ).resolves.toMatchObject({ - changes: [{ action: 'removed', artifactId, reason: 'explicit' }], - }); - }); - - it('prevents one client from pinning or unpinning another client artifact', async () => { - const store = new SessionArtifactStore({ - sessionId: 's1-client-owner-pin', - workspaceCwd: workspace, - persistence: { - recordEvent: async () => {}, - recordSnapshot: async () => {}, - }, - }); - const created = await store.upsertMany([ - { - title: 'Client link', - source: 'client', - clientId: 'client-a', - url: 'https://example.com/client-a-pin', - }, - ]); - const artifactId = created.changes[0]!.artifactId; - const contentRef = { - kind: 'managed_copy' as const, - contentId: `${'a'.repeat(64)}-${'b'.repeat(16)}`, - sha256: 'a'.repeat(64), - sizeBytes: 12, - createdAt: '2026-07-04T00:00:00.000Z', - }; - - const stderr = vi - .spyOn(process.stderr, 'write') - .mockReturnValue(true as never); - try { - await expect( - store.pin(artifactId, { clientId: 'client-b', contentRef }), - ).rejects.toBeInstanceOf(SessionArtifactAuthorizationError); - await expect( - store.pin(artifactId, { clientId: 'client-a', contentRef }), - ).resolves.toMatchObject({ - changes: [{ action: 'updated', artifactId }], - }); - await expect( - store.unpin(artifactId, { clientId: 'client-b' }), - ).rejects.toBeInstanceOf(SessionArtifactAuthorizationError); - - const logged = stderr.mock.calls.map((call) => String(call[0])).join(''); - expect(logged).toContain('pin_denied'); - expect(logged).toContain('unpin_denied'); - expect(logged).toContain('client-a'); - expect(logged).toContain('client-b'); - } finally { - stderr.mockRestore(); - } - - await expect(store.list()).resolves.toMatchObject({ - artifacts: [ - expect.objectContaining({ - id: artifactId, - retention: 'pinned', - clientId: 'client-a', - }), - ], - }); - await expect( - store.unpin(artifactId, { clientId: 'client-a' }), - ).resolves.toMatchObject({ - changes: [{ action: 'updated', artifactId }], - }); - }); - - it('does not write live client ids into durable artifact records', async () => { - const events: SessionArtifactEventRecordPayload[] = []; - const store = new SessionArtifactStore({ - sessionId: 's1-client-id-durable', - workspaceCwd: workspace, - persistence: { - recordEvent: async (payload) => { - events.push(payload); - }, - recordSnapshot: async () => {}, - }, - }); - - const created = await store.upsertMany( - [ - { - title: 'Client artifact', - url: 'https://example.com/client', - source: 'client', - clientId: 'client-a', - }, - ], - { strict: true }, - ); - - expect(created.changes[0]?.artifact).toMatchObject({ - clientId: 'client-a', - }); - expect(events[0]?.changes[0]?.artifact).not.toHaveProperty('clientId'); - expect(events[0]?.changes[0]?.artifact).not.toHaveProperty('restoreState'); - expect(events[0]?.changes[0]?.artifact).not.toHaveProperty( - 'persistenceWarning', - ); - }); - - it('returns pinned content refs for gc and fsck callers', async () => { - const store = new SessionArtifactStore({ - sessionId: 's1-content-refs', - workspaceCwd: workspace, - persistence: { - recordEvent: async () => {}, - recordSnapshot: async () => {}, - }, - }); - const created = await store.upsertMany( - [{ title: 'Report', url: 'https://example.com/report' }], - { strict: true }, - ); - const artifactId = created.changes[0]!.artifactId; - const contentRef = { - kind: 'managed_copy' as const, - contentId: 'content-ref-1', - sha256: 'a'.repeat(64), - sizeBytes: 12, - createdAt: '2026-07-04T00:00:00.000Z', - }; - - await store.pin(artifactId, { contentRef }); - await expect(store.contentRefs()).resolves.toEqual([contentRef]); - - await store.unpin(artifactId); - await expect(store.contentRefs()).resolves.toEqual([]); - }); - - it('pins and unpins artifact retention state', async () => { - const events: SessionArtifactEventRecordPayload[] = []; - const store = new SessionArtifactStore({ - sessionId: 's1-pin', - workspaceCwd: workspace, - persistence: { - recordEvent: async (payload) => { - events.push(payload); - }, - recordSnapshot: async () => {}, - }, - }); - const created = await store.upsertMany( - [{ title: 'Report', url: 'https://example.com/report' }], - { strict: true }, - ); - const artifactId = created.changes[0]!.artifactId; - const contentRef = { - kind: 'managed_copy' as const, - contentId: `${'a'.repeat(64)}-${'b'.repeat(16)}`, - sha256: 'a'.repeat(64), - sizeBytes: 12, - createdAt: '2026-07-04T00:00:00.000Z', - }; - - await expect(store.pin('missing', { contentRef })).resolves.toMatchObject({ - changes: [], - }); - - const pinned = await store.pin(artifactId, { contentRef }); - expect(pinned.changes[0]?.artifact).toMatchObject({ - retention: 'pinned', - clientRetained: true, - contentRef, - }); - expect(pinned.changes[0]?.artifact).not.toHaveProperty( - 'persistenceWarning', - ); - - const unpinned = await store.unpin(artifactId); - expect(unpinned.changes[0]?.artifact).toMatchObject({ - retention: 'restorable', - persistenceWarning: 'metadata_only_restore', - clientRetained: false, - }); - expect(unpinned.changes[0]?.artifact).not.toHaveProperty('contentRef'); - expect(events.map((event) => event.sequence)).toEqual([1, 2, 3]); - }); - - it('unpins to live-only state with a durable sticky tombstone', async () => { - const events: SessionArtifactEventRecordPayload[] = []; - const store = new SessionArtifactStore({ - sessionId: 's1-unpin-ephemeral', - workspaceCwd: workspace, - persistence: { - recordEvent: async (payload) => { - events.push(payload); - }, - recordSnapshot: async () => {}, - }, - }); - const created = await store.upsertMany( - [{ title: 'Report', url: 'https://example.com/report' }], - { strict: true }, - ); - const artifactId = created.changes[0]!.artifactId; - const contentRef = { - kind: 'managed_copy' as const, - contentId: `${'a'.repeat(64)}-${'b'.repeat(16)}`, - sha256: 'a'.repeat(64), - sizeBytes: 12, - createdAt: '2026-07-04T00:00:00.000Z', - }; - - await store.pin(artifactId, { contentRef }); - const unpinned = await store.unpin(artifactId, { - retention: 'ephemeral', - }); - - expect(unpinned.changes[0]?.artifact).toMatchObject({ - id: artifactId, - retention: 'ephemeral', - persistenceWarning: 'sticky_override_active', - }); - expect(unpinned.changes[0]?.artifact).not.toHaveProperty('contentRef'); - expect(events[2]?.changes).toEqual([ - expect.objectContaining({ - action: 'removed', - artifactId, - reason: 'unpin_to_ephemeral', - }), - ]); - - await store.upsertMany([ - { title: 'Report', url: 'https://example.com/report' }, - ]); - await expect(store.list()).resolves.toMatchObject({ - artifacts: [ - expect.objectContaining({ - id: artifactId, - retention: 'ephemeral', - persistenceWarning: 'sticky_override_active', - }), - ], - }); - - await store.upsertMany( - [ - { - title: 'Report', - url: 'https://example.com/report', - retention: 'restorable', - }, - ], - { strict: true }, - ); - await expect(store.list()).resolves.toMatchObject({ - artifacts: [ - expect.objectContaining({ - id: artifactId, - retention: 'restorable', - }), - ], - }); - expect((await store.list()).artifacts[0]).not.toHaveProperty( - 'persistenceWarning', - ); - }); - - it('allows sticky ephemeral overrides within the artifact limit', async () => { - const store = new SessionArtifactStore({ - sessionId: 's1-sticky-cap', - workspaceCwd: workspace, - maxArtifacts: 1, - persistence: { - recordEvent: async () => {}, - recordSnapshot: async () => {}, - }, - }); - - const first = await store.upsertMany( - [{ title: 'First', url: 'https://example.com/first' }], - { strict: true }, - ); - await store.unpin(first.changes[0]!.artifactId, { - retention: 'ephemeral', - }); - - const second = await store.upsertMany( - [{ title: 'Second', url: 'https://example.com/second' }], - { strict: true }, - ); - const secondId = second.changes[0]!.artifactId; - - await store.unpin(secondId, { retention: 'ephemeral' }); - await expect(store.list()).resolves.toMatchObject({ - artifacts: [ - expect.objectContaining({ - id: secondId, - retention: 'ephemeral', - persistenceWarning: 'sticky_override_active', - }), - ], - }); - }); - - it('downgrades expired pinned content before exposing artifacts', async () => { - const events: SessionArtifactEventRecordPayload[] = []; - const store = new SessionArtifactStore({ - sessionId: 's1-expired-pin', - workspaceCwd: workspace, - persistence: { - recordEvent: async (payload) => { - events.push(payload); - }, - recordSnapshot: async () => {}, - }, - }); - const created = await store.upsertMany( - [{ title: 'Report', url: 'https://example.com/report' }], - { strict: true }, - ); - const artifactId = created.changes[0]!.artifactId; - await store.pin(artifactId, { - contentRef: { - kind: 'managed_copy', - contentId: `${'c'.repeat(64)}-${'d'.repeat(16)}`, - sha256: 'c'.repeat(64), - sizeBytes: 12, - createdAt: '2026-07-04T00:00:00.000Z', - }, - expiresAt: '2026-07-04T00:00:00.000Z', - }); - - const pruned = await store.pruneExpiredPins( - new Date('2026-07-05T00:00:00.000Z'), - ); - - expect(pruned.changes[0]?.artifact).toMatchObject({ - id: artifactId, - retention: 'restorable', - persistenceWarning: 'content_expired', - }); - expect(pruned.changes[0]?.artifact).not.toHaveProperty('contentRef'); - expect(pruned.changes[0]?.artifact).not.toHaveProperty('expiresAt'); - expect(events[2]?.changes[0]?.artifact).toMatchObject({ - retention: 'restorable', - }); - expect(events[2]?.changes[0]?.artifact).not.toHaveProperty( - 'persistenceWarning', - ); - }); - - it('keeps metadata-only pin restorable when no content ref is available', async () => { - const store = new SessionArtifactStore({ - sessionId: 's1-pin-metadata', - workspaceCwd: workspace, - persistence: { - recordEvent: async () => {}, - recordSnapshot: async () => {}, - }, - }); - const created = await store.upsertMany( - [{ title: 'Report', url: 'https://example.com/report' }], - { strict: true }, - ); - const artifactId = created.changes[0]!.artifactId; - - await expect(store.pin(artifactId)).resolves.toMatchObject({ - changes: [ - { - artifact: { - retention: 'restorable', - persistenceWarning: 'metadata_only_restore', - }, - }, - ], - }); - }); - - it('updates an already pinned artifact when pin options change', async () => { - const events: SessionArtifactEventRecordPayload[] = []; - const store = new SessionArtifactStore({ - sessionId: 's1-pin-idempotent', - workspaceCwd: workspace, - persistence: { - recordEvent: async (payload) => { - events.push(payload); - }, - recordSnapshot: async () => {}, - }, - }); - const created = await store.upsertMany( - [{ title: 'Report', url: 'https://example.com/report' }], - { strict: true }, - ); - const artifactId = created.changes[0]!.artifactId; - const contentRef = { - kind: 'managed_copy' as const, - contentId: `${'a'.repeat(64)}-${'b'.repeat(16)}`, - sha256: 'a'.repeat(64), - sizeBytes: 12, - createdAt: '2026-07-04T00:00:00.000Z', - }; - await store.pin(artifactId, { - contentRef, - expiresAt: '2026-08-01T00:00:00.000Z', - }); - - const nextContentRef = { - ...contentRef, - contentId: `${'c'.repeat(64)}-${'d'.repeat(16)}`, - sha256: 'c'.repeat(64), - }; - await expect( - store.pin(artifactId, { - contentRef: nextContentRef, - expiresAt: '2026-09-01T00:00:00.000Z', - clientRetained: false, - }), - ).resolves.toMatchObject({ - changes: [ - { - action: 'updated', - artifactId, - artifact: { - retention: 'pinned', - contentRef: nextContentRef, - expiresAt: '2026-09-01T00:00:00.000Z', - clientRetained: false, - }, - }, - ], - }); - await expect(store.list()).resolves.toMatchObject({ - artifacts: [ - { - id: artifactId, - retention: 'pinned', - contentRef: nextContentRef, - expiresAt: '2026-09-01T00:00:00.000Z', - clientRetained: false, - }, - ], - }); - await expect(store.pin(artifactId)).resolves.toMatchObject({ - changes: [], - }); - expect(events).toHaveLength(3); - }); - - it('preserves pin expiration when refreshing content without a ttl', async () => { - const store = new SessionArtifactStore({ - sessionId: 's1-pin-clear-expiration', - workspaceCwd: workspace, - persistence: { - recordEvent: async () => {}, - recordSnapshot: async () => {}, - }, - }); - const created = await store.upsertMany( - [{ title: 'Report', url: 'https://example.com/report' }], - { strict: true }, - ); - const artifactId = created.changes[0]!.artifactId; - const contentRef = { - kind: 'managed_copy' as const, - contentId: `${'a'.repeat(64)}-${'b'.repeat(16)}`, - sha256: 'a'.repeat(64), - sizeBytes: 12, - createdAt: '2026-07-04T00:00:00.000Z', - }; - await store.pin(artifactId, { - contentRef, - expiresAt: '2026-08-01T00:00:00.000Z', - }); - - const repinned = await store.pin(artifactId, { - contentRef: { - ...contentRef, - contentId: `${'c'.repeat(64)}-${'d'.repeat(16)}`, - sha256: 'c'.repeat(64), - }, - }); - - expect(repinned.changes[0]?.artifact).toMatchObject({ - retention: 'pinned', - expiresAt: '2026-08-01T00:00:00.000Z', + await expect( + store.remove(artifactId, { clientId: 'client-a' }), + ).resolves.toMatchObject({ + changes: [{ action: 'removed', artifactId, reason: 'explicit' }], }); }); - it('rolls back pin mutations when persistence fails', async () => { - let fail = false; + it('does not write live client ids into durable artifact records', async () => { + const events: SessionArtifactEventRecordPayload[] = []; const store = new SessionArtifactStore({ - sessionId: 's1-pin-rollback', + sessionId: 's1-client-id-durable', workspaceCwd: workspace, persistence: { - recordEvent: async () => { - if (fail) throw new Error('persist failed'); + recordEvent: async (payload) => { + events.push(payload); }, recordSnapshot: async () => {}, }, }); - const created = await store.upsertMany( - [{ title: 'Report', url: 'https://example.com/report' }], - { strict: true }, - ); - const artifactId = created.changes[0]!.artifactId; - fail = true; - await expect( - store.pin(artifactId, { - contentRef: { - kind: 'managed_copy', - contentId: `${'a'.repeat(64)}-${'b'.repeat(16)}`, - sha256: 'a'.repeat(64), - sizeBytes: 12, - createdAt: '2026-07-04T00:00:00.000Z', - }, - }), - ).rejects.toThrow('persist failed'); - await expect(store.list()).resolves.toMatchObject({ - artifacts: [ + const created = await store.upsertMany( + [ { - id: artifactId, - retention: 'restorable', + title: 'Client artifact', + url: 'https://example.com/client', + source: 'client', + clientId: 'client-a', }, ], + { strict: true }, + ); + + expect(created.changes[0]?.artifact).toMatchObject({ + clientId: 'client-a', }); + expect(events[0]?.changes[0]?.artifact).not.toHaveProperty('clientId'); + expect(events[0]?.changes[0]?.artifact).not.toHaveProperty('restoreState'); + expect(events[0]?.changes[0]?.artifact).not.toHaveProperty( + 'persistenceWarning', + ); }); it('rolls back received sequence when strict upsert persistence fails', async () => { @@ -1143,76 +667,6 @@ describe('SessionArtifactStore', () => { ).toEqual([second.changes[0]?.artifactId, overflow.changes[0]?.artifactId]); }); - it('does not evict existing pinned artifacts as a fallback candidate', async () => { - const store = new SessionArtifactStore({ - sessionId: 's3-pinned-eviction', - workspaceCwd: workspace, - maxArtifacts: 2, - persistence: { - recordEvent: async () => {}, - recordSnapshot: async () => {}, - }, - }); - const created = await store.upsertMany( - [ - { title: 'Pinned', url: 'https://example.com/pinned' }, - { title: 'Ordinary', url: 'https://example.com/ordinary' }, - ], - { strict: true }, - ); - await store.pin(created.changes[0]!.artifactId, { - contentRef: { - kind: 'managed_copy', - contentId: `${'a'.repeat(64)}-${'b'.repeat(16)}`, - sha256: 'a'.repeat(64), - sizeBytes: 12, - createdAt: '2026-07-04T00:00:00.000Z', - }, - }); - - await store.upsertMany([{ title: 'New', url: 'https://example.com/new' }], { - strict: true, - }); - - expect( - (await store.list()).artifacts.map((artifact) => artifact.title), - ).toEqual(['Pinned', 'New']); - }); - - it('rejects strict overflow when every live artifact is pinned', async () => { - const store = new SessionArtifactStore({ - sessionId: 's3-all-pinned', - workspaceCwd: workspace, - maxArtifacts: 1, - persistence: { - recordEvent: async () => {}, - recordSnapshot: async () => {}, - }, - }); - const created = await store.upsertMany( - [{ title: 'Pinned', url: 'https://example.com/pinned' }], - { strict: true }, - ); - await store.pin(created.changes[0]!.artifactId, { - contentRef: { - kind: 'managed_copy', - contentId: `${'a'.repeat(64)}-${'b'.repeat(16)}`, - sha256: 'a'.repeat(64), - sizeBytes: 12, - createdAt: '2026-07-04T00:00:00.000Z', - }, - }); - - await expect( - store.upsertMany([{ title: 'New', url: 'https://example.com/new' }], { - strict: true, - }), - ).rejects.toThrow(SessionArtifactValidationError); - await expect(store.list()).resolves.toMatchObject({ - artifacts: [{ title: 'Pinned', retention: 'pinned' }], - }); - }); - it('writes eviction removals to durable persistence', async () => { const events: SessionArtifactEventRecordPayload[] = []; const store = new SessionArtifactStore({ @@ -2796,53 +2250,6 @@ describe('SessionArtifactStore', () => { expect(snapshots).toHaveLength(0); }); - it('snapshots unpin-to-ephemeral state after applying the live update', async () => { - const snapshots: SessionArtifactSnapshotRecordPayload[] = []; - const store = new SessionArtifactStore({ - sessionId: 's11-unpin-snapshot', - workspaceCwd: workspace, - persistence: { - recordEvent: async () => {}, - recordSnapshot: async (payload) => { - snapshots.push(payload); - }, - }, - }); - const created = await store.upsertMany( - [{ title: 'Pinned', url: 'https://example.com/pinned' }], - { strict: true }, - ); - const artifactId = created.changes[0]!.artifactId; - await store.pin(artifactId, { - contentRef: { - kind: 'managed_copy', - contentId: `${'a'.repeat(64)}-${'b'.repeat(16)}`, - sha256: 'a'.repeat(64), - sizeBytes: 5, - createdAt: '2026-07-04T00:00:00.000Z', - }, - }); - for (let index = 0; index < 47; index++) { - await store.upsertMany( - [ - { - title: `Durable ${index}`, - url: `https://example.com/unpin-${index}`, - }, - ], - { strict: true }, - ); - } - - await store.unpin(artifactId, { retention: 'ephemeral' }); - - expect(snapshots).toHaveLength(1); - expect(snapshots[0]?.stickyEphemeralIds).toContain(artifactId); - expect( - snapshots[0]?.artifacts.some((artifact) => artifact.id === artifactId), - ).toBe(false); - }); - it('drops stale sticky markers when durable eviction removes an artifact', async () => { const sourceEvents: SessionArtifactEventRecordPayload[] = []; const source = new SessionArtifactStore({ @@ -2995,59 +2402,6 @@ describe('SessionArtifactStore', () => { await expect(store.list()).resolves.toMatchObject({ artifacts: [] }); }); - it('keeps expired pins visible with a warning when TTL prune persistence fails', async () => { - let rejectPrune = false; - const store = new SessionArtifactStore({ - sessionId: 's11-prune-fail', - workspaceCwd: workspace, - persistence: { - recordEvent: async (payload) => { - if ( - rejectPrune && - payload.changes.some((change) => change.action === 'updated') - ) { - throw new Error('disk full'); - } - }, - recordSnapshot: async () => {}, - }, - }); - const created = await store.upsertMany( - [{ title: 'Pinned', url: 'https://example.com/pinned' }], - { strict: true }, - ); - const artifactId = created.changes[0]!.artifactId; - await store.pin(artifactId, { - expiresAt: '2026-07-04T00:00:00.000Z', - contentRef: { - kind: 'managed_copy', - contentId: `${'c'.repeat(64)}-${'d'.repeat(16)}`, - sha256: 'c'.repeat(64), - sizeBytes: 5, - createdAt: '2026-07-03T00:00:00.000Z', - }, - }); - - rejectPrune = true; - await expect( - store.pruneExpiredPins(new Date('2026-07-05T00:00:00.000Z')), - ).resolves.toMatchObject({ - changes: [], - warnings: [ - 'expired pinned artifacts retained because persistence failed', - ], - }); - await expect(store.list()).resolves.toMatchObject({ - artifacts: [ - expect.objectContaining({ - id: artifactId, - retention: 'pinned', - persistenceWarning: 'persistence_unavailable', - }), - ], - }); - }); - it('keeps live removal when explicit tombstone persistence fails', async () => { let calls = 0; const store = new SessionArtifactStore({ @@ -3289,10 +2643,10 @@ describe('SessionArtifactStore', () => { ); }); - it('strips invalid retained content refs during restore', async () => { + it('downgrades legacy pinned content refs to metadata-only restore', async () => { const events: SessionArtifactEventRecordPayload[] = []; const source = new SessionArtifactStore({ - sessionId: 's11-restore-content-ref', + sessionId: 's11-restore-legacy-pinned', workspaceCwd: workspace, persistence: { recordEvent: async (payload) => { @@ -3302,23 +2656,25 @@ describe('SessionArtifactStore', () => { }, }); const created = await source.upsertMany( - [{ title: 'Pinned', url: 'https://example.com/pinned' }], + [{ title: 'Legacy pinned', url: 'https://example.com/legacy-pinned' }], { strict: true }, ); const artifactId = created.changes[0]!.artifactId; - await source.pin(artifactId, { + const persisted = { + ...events[0]!.changes[0]!.artifact!, + retention: 'pinned' as const, contentRef: { - kind: 'managed_copy', + kind: 'managed_copy' as const, contentId: `${'e'.repeat(64)}-${'f'.repeat(16)}`, sha256: 'e'.repeat(64), sizeBytes: 12, createdAt: '2026-07-04T00:00:00.000Z', }, - }); - const persisted = events[1]!.changes[0]!.artifact!; + expiresAt: '2026-08-01T00:00:00.000Z', + }; const snapshot: RebuiltSessionArtifactSnapshot = { v: 2, - sessionId: 's11-restore-content-ref', + sessionId: 's11-restore-legacy-pinned', sequence: 2, artifacts: [persisted], tombstonedIds: [], @@ -3326,92 +2682,20 @@ describe('SessionArtifactStore', () => { warnings: [], }; const restored = new SessionArtifactStore({ - sessionId: 's11-restore-content-ref', - workspaceCwd: workspace, - }); - - await expect( - restored.restore(snapshot, { - verifyContentRef: async () => 'content_hash_mismatch', - }), - ).resolves.toEqual([]); - - await expect(restored.list()).resolves.toMatchObject({ - artifacts: [ - expect.objectContaining({ - id: artifactId, - retention: 'restorable', - restoreState: 'restored', - status: 'missing', - persistenceWarning: 'content_hash_mismatch', - }), - ], - }); - expect((await restored.list()).artifacts[0]).not.toHaveProperty( - 'contentRef', - ); - }); - - it('strips retained content when restored expiresAt is invalid', async () => { - const events: SessionArtifactEventRecordPayload[] = []; - const source = new SessionArtifactStore({ - sessionId: 's11-restore-invalid-expires', - workspaceCwd: workspace, - persistence: { - recordEvent: async (payload) => { - events.push(payload); - }, - recordSnapshot: async () => {}, - }, - }); - const created = await source.upsertMany( - [{ title: 'Pinned', url: 'https://example.com/pinned' }], - { strict: true }, - ); - const artifactId = created.changes[0]!.artifactId; - await source.pin(artifactId, { - contentRef: { - kind: 'managed_copy', - contentId: `${'e'.repeat(64)}-${'f'.repeat(16)}`, - sha256: 'e'.repeat(64), - sizeBytes: 12, - createdAt: '2026-07-04T00:00:00.000Z', - }, - }); - const persisted = { - ...events[1]!.changes[0]!.artifact!, - expiresAt: 'not-a-date', - }; - const restored = new SessionArtifactStore({ - sessionId: 's11-restore-invalid-expires', + sessionId: 's11-restore-legacy-pinned', workspaceCwd: workspace, }); - const verifyContentRef = vi.fn(async () => undefined); - await expect( - restored.restore( - { - v: 2, - sessionId: 's11-restore-invalid-expires', - sequence: 2, - artifacts: [persisted], - tombstonedIds: [], - stickyEphemeralIds: [], - warnings: [], - }, - { verifyContentRef }, - ), - ).resolves.toEqual([]); + await expect(restored.restore(snapshot)).resolves.toEqual([]); - expect(verifyContentRef).not.toHaveBeenCalled(); await expect(restored.list()).resolves.toMatchObject({ artifacts: [ expect.objectContaining({ id: artifactId, retention: 'restorable', restoreState: 'restored', - status: 'missing', - persistenceWarning: 'content_expired', + status: 'available', + persistenceWarning: 'metadata_only_restore', }), ], }); @@ -3419,63 +2703,4 @@ describe('SessionArtifactStore', () => { expect(restoredArtifact).not.toHaveProperty('contentRef'); expect(restoredArtifact).not.toHaveProperty('expiresAt'); }); - - it('downgrades restored pinned records to restorable even when content is valid', async () => { - const events: SessionArtifactEventRecordPayload[] = []; - const source = new SessionArtifactStore({ - sessionId: 's11-restore-valid-pinned', - workspaceCwd: workspace, - persistence: { - recordEvent: async (payload) => { - events.push(payload); - }, - recordSnapshot: async () => {}, - }, - }); - const created = await source.upsertMany( - [{ title: 'Pinned', url: 'https://example.com/pinned' }], - { strict: true }, - ); - const artifactId = created.changes[0]!.artifactId; - await source.pin(artifactId, { - contentRef: { - kind: 'managed_copy', - contentId: `${'e'.repeat(64)}-${'f'.repeat(16)}`, - sha256: 'e'.repeat(64), - sizeBytes: 12, - createdAt: '2026-07-04T00:00:00.000Z', - }, - }); - const persisted = events[1]!.changes[0]!.artifact!; - const restored = new SessionArtifactStore({ - sessionId: 's11-restore-valid-pinned', - workspaceCwd: workspace, - }); - - await expect( - restored.restore( - { - v: 2, - sessionId: 's11-restore-valid-pinned', - sequence: 2, - artifacts: [persisted], - tombstonedIds: [], - stickyEphemeralIds: [], - warnings: [], - }, - { verifyContentRef: async () => undefined }, - ), - ).resolves.toEqual([]); - - await expect(restored.list()).resolves.toMatchObject({ - artifacts: [ - expect.objectContaining({ - id: artifactId, - retention: 'restorable', - restoreState: 'restored', - contentRef: persisted.contentRef, - }), - ], - }); - }); }); diff --git a/packages/acp-bridge/src/sessionArtifacts.ts b/packages/acp-bridge/src/sessionArtifacts.ts index 5945ff899d8..8ab1bbf8ec9 100644 --- a/packages/acp-bridge/src/sessionArtifacts.ts +++ b/packages/acp-bridge/src/sessionArtifacts.ts @@ -5,7 +5,7 @@ */ import { createHash } from 'node:crypto'; -import { promises as fs } from 'node:fs'; +import { createReadStream, promises as fs } from 'node:fs'; import path from 'node:path'; import { SESSION_ARTIFACT_PERSISTENCE_VERSION, @@ -14,7 +14,6 @@ import { import type { PersistedSessionArtifact, RebuiltSessionArtifactSnapshot, - SessionArtifactContentRef, SessionArtifactEventRecordPayload, SessionArtifactPersistenceWarning, SessionArtifactRestoreState, @@ -42,13 +41,18 @@ export type DaemonSessionArtifactStorage = export type DaemonSessionArtifactSource = 'tool' | 'hook' | 'client'; -export type DaemonSessionArtifactStatus = 'available' | 'missing'; +export type DaemonSessionArtifactStatus = 'available' | 'missing' | 'changed'; +export type DaemonSessionArtifactRetention = Exclude< + SessionArtifactRetention, + 'pinned' +>; const SOURCE_RESERVATIONS: Record = { tool: 100, client: 50, hook: 50, }; +const WORKSPACE_CONTENT_SHA256_METADATA_KEY = 'qwen.workspace.sha256'; const WORKSPACE_STATUS_REFRESH_TTL_MS = 5_000; const WORKSPACE_STATUS_REFRESH_BATCH_SIZE = 20; const SNAPSHOT_AFTER_DURABLE_EVENTS = 50; @@ -69,7 +73,7 @@ export interface ToolArtifactLike { export interface SessionArtifactInput extends ToolArtifactLike { source?: DaemonSessionArtifactSource; - retention?: SessionArtifactRetention; + retention?: DaemonSessionArtifactRetention; clientRetained?: boolean; toolCallId?: string; toolName?: string; @@ -77,6 +81,10 @@ export interface SessionArtifactInput extends ToolArtifactLike { clientId?: string; } +type LegacySessionArtifactInput = Omit & { + retention?: SessionArtifactRetention; +}; + export interface DaemonSessionArtifact { id: string; kind: DaemonSessionArtifactKind; @@ -91,12 +99,10 @@ export interface DaemonSessionArtifact { mimeType?: string; sizeBytes?: number; metadata?: Record; - retention: SessionArtifactRetention; + retention: DaemonSessionArtifactRetention; restoreState?: SessionArtifactRestoreState; persistenceWarning?: SessionArtifactPersistenceWarning; - contentRef?: SessionArtifactContentRef; persistedAt?: string; - expiresAt?: string; clientRetained: boolean; createdAt: string; updatedAt: string; @@ -136,21 +142,7 @@ export interface SessionArtifactMutationResult { warnings?: string[]; } -export interface SessionArtifactPinOptions { - retention?: Extract; - contentRef?: SessionArtifactContentRef; - expiresAt?: string; - clientRetained?: boolean; -} - -export interface SessionArtifactUnpinOptions { - retention?: Extract; -} - export interface SessionArtifactRestoreOptions { - verifyContentRef?: ( - artifact: PersistedSessionArtifact, - ) => Promise; preserveLiveEphemeral?: boolean; } @@ -261,16 +253,6 @@ export class SessionArtifactStore { }); } - // Content-retention helpers are intentionally not exposed by #6259. - // The stacked #6346 PR wires these to pin/unpin/content-GC surfaces. - async contentRefs(): Promise { - return this.enqueue(async () => - Array.from(this.artifacts.values()) - .map((artifact) => artifact.contentRef) - .filter((ref): ref is SessionArtifactContentRef => ref !== undefined), - ); - } - async upsertMany( inputs: SessionArtifactInput[], options: { @@ -471,217 +453,6 @@ export class SessionArtifactStore { }); } - async pin( - artifactId: string, - options: SessionArtifactPinOptions & { clientId?: string } = {}, - ): Promise { - return this.enqueue(async () => { - const existing = this.artifacts.get(artifactId); - if (!existing) { - return { v: 1, sessionId: this.sessionId, changes: [] }; - } - this.denyCrossClientMutation('pin', artifactId, existing, options); - if (existing.retention === 'pinned' && !hasPinOptions(options)) { - return { - v: 1, - sessionId: this.sessionId, - changes: [], - }; - } - const before = this.cloneState(); - const targetRetention = - options.retention ?? - (options.contentRef - ? 'pinned' - : existing.retention === 'pinned' - ? 'pinned' - : 'restorable'); - const contentRef = - targetRetention === 'pinned' - ? (options.contentRef ?? existing.contentRef) - : undefined; - const updated: StoredArtifact = { - ...existing, - retention: targetRetention, - clientRetained: - options.clientRetained ?? - (existing.retention === 'pinned' ? existing.clientRetained : true), - restoreState: 'live', - updatedAt: new Date().toISOString(), - }; - if (targetRetention === 'pinned' && contentRef) { - updated.contentRef = contentRef; - delete updated.persistenceWarning; - if (Object.hasOwn(options, 'expiresAt')) { - if (options.expiresAt) { - updated.expiresAt = options.expiresAt; - } else { - delete updated.expiresAt; - } - } - } else { - updated.persistenceWarning = 'metadata_only_restore'; - delete updated.contentRef; - delete updated.expiresAt; - } - this.artifacts.set(artifactId, updated); - const changes: SessionArtifactChange[] = [ - { - action: 'updated', - artifactId, - artifact: toPublicArtifact(updated), - }, - ]; - try { - const warnings = await this.persistChanges(changes, true); - return { - v: 1, - sessionId: this.sessionId, - changes, - ...(warnings.length > 0 ? { warnings } : {}), - }; - } catch (error) { - this.restoreState(before); - throw error; - } - }); - } - - async unpin( - artifactId: string, - options: SessionArtifactUnpinOptions & { clientId?: string } = {}, - ): Promise { - return this.enqueue(async () => { - const existing = this.artifacts.get(artifactId); - if (!existing) { - return { v: 1, sessionId: this.sessionId, changes: [] }; - } - this.denyCrossClientMutation('unpin', artifactId, existing, options); - const targetRetention = options.retention ?? 'restorable'; - const before = this.cloneState(); - const updated: StoredArtifact = { - ...existing, - retention: targetRetention, - restoreState: 'live', - persistenceWarning: - targetRetention === 'ephemeral' - ? 'sticky_override_active' - : 'metadata_only_restore', - clientRetained: false, - updatedAt: new Date().toISOString(), - }; - delete updated.contentRef; - delete updated.expiresAt; - if (targetRetention === 'ephemeral') { - delete updated.persistedAt; - } - const changes: SessionArtifactChange[] = [ - { - action: 'updated', - artifactId, - artifact: toPublicArtifact(updated), - }, - ]; - // Journal records the durable side effect while the API returns the live - // in-memory state that remains available as ephemeral. - const durableChanges = - targetRetention === 'ephemeral' - ? [ - { - action: 'removed' as const, - artifactId, - artifact: toPublicArtifact(existing), - reason: 'unpin_to_ephemeral' as const, - }, - ] - : changes; - this.artifacts.set(artifactId, updated); - try { - const warnings = await this.persistChanges(durableChanges, true); - return { - v: 1, - sessionId: this.sessionId, - changes, - ...(warnings.length > 0 ? { warnings } : {}), - }; - } catch (error) { - this.restoreState(before); - throw error; - } - }); - } - - async pruneExpiredPins( - now = new Date(), - ): Promise { - return this.enqueue(async () => { - const before = this.cloneState(); - const changes: SessionArtifactChange[] = []; - const expiredIds: string[] = []; - for (const [artifactId, existing] of this.artifacts) { - if ( - existing.retention !== 'pinned' || - !existing.expiresAt || - Date.parse(existing.expiresAt) > now.getTime() - ) { - continue; - } - expiredIds.push(artifactId); - const updated: StoredArtifact = { - ...existing, - retention: 'restorable', - restoreState: 'live', - persistenceWarning: 'content_expired', - updatedAt: now.toISOString(), - }; - delete updated.contentRef; - delete updated.expiresAt; - this.artifacts.set(artifactId, updated); - changes.push({ - action: 'updated', - artifactId, - artifact: toPublicArtifact(updated), - }); - } - if (changes.length === 0) { - return { v: 1, sessionId: this.sessionId, changes: [] }; - } - try { - const warnings = await this.persistChanges(changes, true); - return { - v: 1, - sessionId: this.sessionId, - changes, - ...(warnings.length > 0 ? { warnings } : {}), - }; - } catch (error) { - this.restoreState(before); - for (const artifactId of expiredIds) { - const restored = this.artifacts.get(artifactId); - if (restored?.retention === 'pinned') { - this.artifacts.set(artifactId, { - ...restored, - persistenceWarning: 'persistence_unavailable', - }); - } - } - writeStderrLine( - `[artifacts] session=${this.sessionId} action=ttl_prune_failed reason=${JSON.stringify( - error instanceof Error ? error.message : String(error), - )}`, - ); - return { - v: 1, - sessionId: this.sessionId, - changes: [], - warnings: [ - 'expired pinned artifacts retained because persistence failed', - ], - }; - } - }); - } - async restore( snapshot: RebuiltSessionArtifactSnapshot | undefined, options: SessionArtifactRestoreOptions = {}, @@ -738,36 +509,12 @@ export class SessionArtifactStore { warnings.push(`skipped artifact with mismatched id ${artifact.id}`); continue; } - let contentRef = artifact.contentRef; - let expiresAt = artifact.expiresAt; - let retention = normalized.retention; - let status = normalized.status; + const retention = normalized.retention; let persistenceWarning: | SessionArtifactPersistenceWarning - | undefined = contentRef ? undefined : 'metadata_only_restore'; + | undefined = 'metadata_only_restore'; if (retention === 'ephemeral') { - contentRef = undefined; - expiresAt = undefined; persistenceWarning = 'sticky_override_active'; - } else if (expiresAt) { - const expiresAtMs = Date.parse(expiresAt); - if (!Number.isFinite(expiresAtMs) || expiresAtMs <= Date.now()) { - contentRef = undefined; - expiresAt = undefined; - retention = 'restorable'; - status = 'missing'; - persistenceWarning = 'content_expired'; - } - } - if (contentRef && options.verifyContentRef) { - const warning = await options.verifyContentRef(artifact); - if (warning) { - contentRef = undefined; - expiresAt = undefined; - retention = 'restorable'; - status = 'missing'; - persistenceWarning = warning; - } } const stored: StoredArtifact = { ...normalized, @@ -776,11 +523,9 @@ export class SessionArtifactStore { createdAt: artifact.createdAt, updatedAt: artifact.updatedAt, persistedAt: artifact.persistedAt, - expiresAt, - status, + status: normalized.status, restoreState: 'restored', persistenceWarning, - contentRef, insertSeq: ++this.insertSeq, }; this.artifacts.set(stored.id, stored); @@ -933,9 +678,6 @@ export class SessionArtifactStore { ...toPublicArtifact(stored), persistedAt: recordedAt, }; - if (artifact.contentRef) { - delete artifact.persistenceWarning; - } change.artifact = artifact; } @@ -956,9 +698,6 @@ export class SessionArtifactStore { const stored = this.artifacts.get(change.artifactId); if (!stored) continue; stored.persistedAt = recordedAt; - if (stored.contentRef) { - delete stored.persistenceWarning; - } change.artifact = toPublicArtifact(stored); } this.applyDurableMarkers(durableChanges); @@ -1045,7 +784,6 @@ export class SessionArtifactStore { stored.retention = 'ephemeral'; stored.persistenceWarning = 'persistence_unavailable'; delete stored.persistedAt; - delete stored.contentRef; change.artifact = toPublicArtifact(stored); downgraded = true; } @@ -1115,7 +853,7 @@ export class SessionArtifactStore { } private async normalizeInput( - input: SessionArtifactInput, + input: LegacySessionArtifactInput, receivedSeq: number, trustedPublisherFromCaller: boolean, ): Promise { @@ -1160,10 +898,8 @@ export class SessionArtifactStore { trustedPublisher, }); - const metadata = normalizeMetadata(input.metadata); const retention = normalizeRetention(input.retention, { persistenceAvailable: this.persistence !== undefined, - strictPinnedAllowed: false, }); const workspaceStatus = workspacePath ? await this.getInitialWorkspaceStatus(workspacePath) @@ -1174,6 +910,10 @@ export class SessionArtifactStore { 'workspacePath', ); } + const metadata = withWorkspaceContentHashMetadata( + normalizeMetadata(input.metadata), + workspaceStatus, + ); const kind = normalizeKind( input.kind ?? inferKind({ storage, workspacePath, url }), ); @@ -1278,6 +1018,7 @@ export class SessionArtifactStore { private async getInitialWorkspaceStatus(workspacePath: string): Promise<{ status: DaemonSessionArtifactStatus; sizeBytes?: number; + sha256?: string; escaped?: boolean; }> { try { @@ -1308,8 +1049,11 @@ export class SessionArtifactStore { artifact.workspacePath, this.getRealWorkspaceCwd(), ); - artifact.status = status.status; - artifact.sizeBytes = status.sizeBytes; + const changed = isWorkspaceContentChanged(artifact, status); + artifact.status = changed ? 'changed' : status.status; + if (!changed) { + artifact.sizeBytes = status.sizeBytes; + } if (status.escaped) { artifact.status = 'missing'; artifact.sizeBytes = undefined; @@ -1462,9 +1206,7 @@ function mergeBatchArtifact( restoreState: 'live', persistenceWarning: existing.persistenceWarning ?? next.persistenceWarning, - contentRef: existing.contentRef ?? next.contentRef, persistedAt: existing.persistedAt ?? next.persistedAt, - expiresAt: existing.expiresAt ?? next.expiresAt, clientRetained: existing.clientRetained || next.clientRetained, lastStatAt: undefined, }; @@ -1535,9 +1277,7 @@ function mergeArtifact( incoming.retentionExplicit && incoming.retention !== 'ephemeral' ? incoming.persistenceWarning : (existing.persistenceWarning ?? incoming.persistenceWarning), - contentRef: existing.contentRef ?? incoming.contentRef, persistedAt: existing.persistedAt ?? incoming.persistedAt, - expiresAt: existing.expiresAt ?? incoming.expiresAt, source: existing.source, retentionSource: existing.retentionSource, trustedPublisher: existing.trustedPublisher || incoming.trustedPublisher, @@ -1586,9 +1326,7 @@ export function publicArtifactsEqual( a.retention === b.retention && a.restoreState === b.restoreState && a.persistenceWarning === b.persistenceWarning && - contentRefEqual(a.contentRef, b.contentRef) && a.persistedAt === b.persistedAt && - a.expiresAt === b.expiresAt && a.clientRetained === b.clientRetained && a.createdAt === b.createdAt && a.toolCallId === b.toolCallId && @@ -1598,21 +1336,6 @@ export function publicArtifactsEqual( ); } -function contentRefEqual( - a: SessionArtifactContentRef | undefined, - b: SessionArtifactContentRef | undefined, -): boolean { - if (a === b) return true; - if (!a || !b) return false; - return ( - a.kind === b.kind && - a.contentId === b.contentId && - a.sha256 === b.sha256 && - a.sizeBytes === b.sizeBytes && - a.createdAt === b.createdAt - ); -} - function metadataEqual( a: Record | undefined, b: Record | undefined, @@ -1639,13 +1362,12 @@ function mergeSizeBytes( } function strongestRetention( - a: SessionArtifactRetention, - b: SessionArtifactRetention, -): SessionArtifactRetention { - const rank: Record = { + a: DaemonSessionArtifactRetention, + b: DaemonSessionArtifactRetention, +): DaemonSessionArtifactRetention { + const rank: Record = { ephemeral: 0, restorable: 1, - pinned: 2, }; return rank[b] > rank[a] ? b : a; } @@ -1664,7 +1386,13 @@ function mergeMetadata( const merged = { ...(existing.metadata ?? {}) }; let changed = false; for (const [key, value] of Object.entries(incoming.metadata)) { - if (!Object.hasOwn(merged, key)) { + if ( + key === WORKSPACE_CONTENT_SHA256_METADATA_KEY && + merged[key] !== value + ) { + merged[key] = value; + changed = true; + } else if (!Object.hasOwn(merged, key)) { merged[key] = value; changed = true; } @@ -1728,7 +1456,7 @@ function selectEvictionCandidate( SOURCE_RESERVATIONS[artifact.retentionSource], ) ?? oldest(candidates, (artifact) => !artifact.clientRetained) ?? - oldest(candidates, (artifact) => artifact.retention !== 'pinned') + oldest(candidates) ); } @@ -1784,9 +1512,7 @@ function toPublicArtifact( retention, restoreState, persistenceWarning, - contentRef, persistedAt, - expiresAt, clientRetained, createdAt, updatedAt, @@ -1812,9 +1538,7 @@ function toPublicArtifact( retention, ...(restoreState ? { restoreState } : {}), ...(persistenceWarning ? { persistenceWarning } : {}), - ...(contentRef ? { contentRef } : {}), ...(persistedAt ? { persistedAt } : {}), - ...(expiresAt ? { expiresAt } : {}), clientRetained, createdAt, updatedAt, @@ -1827,7 +1551,7 @@ function toPublicArtifact( function persistedArtifactToInput( artifact: PersistedSessionArtifact, -): SessionArtifactInput { +): LegacySessionArtifactInput { return { title: artifact.title, kind: artifact.kind, @@ -1871,7 +1595,7 @@ function toPersistedArtifact( kind: artifact.kind, storage: artifact.storage, source: artifact.source, - status: artifact.status, + status: artifact.status === 'changed' ? 'available' : artifact.status, title: artifact.title, ...(artifact.description ? { description: artifact.description } : {}), ...(artifact.workspacePath @@ -1889,8 +1613,6 @@ function toPersistedArtifact( createdAt: artifact.createdAt, updatedAt: artifact.updatedAt, persistedAt: artifact.persistedAt ?? recordedAt, - ...(artifact.expiresAt ? { expiresAt: artifact.expiresAt } : {}), - ...(artifact.contentRef ? { contentRef: artifact.contentRef } : {}), ...(artifact.toolCallId ? { toolCallId: artifact.toolCallId } : {}), ...(artifact.toolName ? { toolName: artifact.toolName } : {}), ...(artifact.hookEventName @@ -1913,20 +1635,10 @@ function isDurablePersistenceChange(change: SessionArtifactChange): boolean { return change.artifact.retention !== 'ephemeral'; } -function hasPinOptions(options: SessionArtifactPinOptions): boolean { - return ( - options.retention !== undefined || - options.contentRef !== undefined || - Object.hasOwn(options, 'expiresAt') || - options.clientRetained !== undefined - ); -} - function cloneStoredArtifact(artifact: StoredArtifact): StoredArtifact { return { ...artifact, ...(artifact.metadata ? { metadata: { ...artifact.metadata } } : {}), - ...(artifact.contentRef ? { contentRef: { ...artifact.contentRef } } : {}), }; } @@ -2318,25 +2030,22 @@ function normalizeMetadata( function normalizeRetention( value: unknown, - options: { persistenceAvailable: boolean; strictPinnedAllowed: boolean }, -): SessionArtifactRetention { + options: { persistenceAvailable: boolean }, +): DaemonSessionArtifactRetention { if (value === undefined) { return options.persistenceAvailable ? 'restorable' : 'ephemeral'; } if (value === 'ephemeral' || value === 'restorable') { return value; } - if (value === 'pinned' && options.strictPinnedAllowed) { - return 'pinned'; - } if (value === 'pinned') { throw new SessionArtifactValidationError( - 'pinned retention requires the pin API', + 'pinned retention is not supported by session_artifacts_persistence', 'retention', ); } throw new SessionArtifactValidationError( - 'retention must be ephemeral, restorable, or pinned', + 'retention must be ephemeral or restorable', 'retention', ); } @@ -2379,6 +2088,7 @@ async function getWorkspaceStatus( ): Promise<{ status: DaemonSessionArtifactStatus; sizeBytes?: number; + sha256?: string; escaped?: boolean; }> { const absolutePath = path.resolve(workspaceCwd, workspacePath); @@ -2390,9 +2100,15 @@ async function getWorkspaceStatus( return { status: 'missing', escaped: true }; } const stat = await fs.stat(realPath); + if (stat.isFile()) { + return { + status: 'available', + sizeBytes: stat.size, + sha256: await hashFile(realPath), + }; + } return { status: 'available', - ...(stat.isFile() ? { sizeBytes: stat.size } : {}), }; } catch (error) { if (!isNotFoundError(error)) { @@ -2405,6 +2121,66 @@ async function getWorkspaceStatus( } } +async function hashFile(absolutePath: string): Promise { + const hash = createHash('sha256'); + for await (const chunk of createReadStream(absolutePath)) { + hash.update(chunk as Buffer); + } + return hash.digest('hex'); +} + +function withWorkspaceContentHashMetadata( + metadata: Record | undefined, + workspaceStatus: + | { + status: DaemonSessionArtifactStatus; + sha256?: string; + } + | undefined, +): Record | undefined { + if (workspaceStatus?.status !== 'available' || !workspaceStatus.sha256) { + return metadata; + } + const next = { + ...(metadata ?? {}), + [WORKSPACE_CONTENT_SHA256_METADATA_KEY]: workspaceStatus.sha256, + }; + if (Buffer.byteLength(JSON.stringify(next), 'utf8') > 4096) { + throw new SessionArtifactValidationError( + 'metadata must be 4096 bytes or fewer', + 'metadata', + ); + } + return next; +} + +function isWorkspaceContentChanged( + artifact: StoredArtifact, + status: { + status: DaemonSessionArtifactStatus; + sizeBytes?: number; + sha256?: string; + }, +): boolean { + if (status.status !== 'available') { + return false; + } + const expectedSha256 = + artifact.metadata?.[WORKSPACE_CONTENT_SHA256_METADATA_KEY]; + if ( + typeof expectedSha256 === 'string' && + status.sha256 && + status.sha256 !== expectedSha256 + ) { + return true; + } + return ( + artifact.sizeBytes !== undefined && + status.sizeBytes !== undefined && + status.sizeBytes !== artifact.sizeBytes + ); +} + async function danglingSymlinkEscapesWorkspace( absolutePath: string, realWorkspace: string, diff --git a/packages/core/src/services/session-artifact-persistence.ts b/packages/core/src/services/session-artifact-persistence.ts index 38f29583afa..9a0f71939b1 100644 --- a/packages/core/src/services/session-artifact-persistence.ts +++ b/packages/core/src/services/session-artifact-persistence.ts @@ -45,7 +45,10 @@ export type PersistedSessionArtifactStorage = export type PersistedSessionArtifactSource = 'tool' | 'hook' | 'client'; -export type PersistedSessionArtifactStatus = 'available' | 'missing'; +export type PersistedSessionArtifactStatus = + | 'available' + | 'missing' + | 'changed'; export interface SessionArtifactContentRef { kind: 'managed_copy'; @@ -432,6 +435,7 @@ function normalizePersistedArtifact( normalizeLiteral(value['status'], [ 'available', 'missing', + 'changed', ]) ?? 'missing'; const retention = normalizeLiteral(value['retention'], [ diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index 3a076b3b9cb..5e69b6eef54 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -618,7 +618,10 @@ export type KnownDaemonSessionArtifactSource = 'tool' | 'hook' | 'client'; export type DaemonSessionArtifactSource = OpenStringUnion; -export type KnownDaemonSessionArtifactStatus = 'available' | 'missing'; +export type KnownDaemonSessionArtifactStatus = + | 'available' + | 'missing' + | 'changed'; export type DaemonSessionArtifactStatus = OpenStringUnion; From 6d98d6f4ace0b3b311ebfe9ce432f9ef858075d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Mon, 6 Jul 2026 11:40:35 +0800 Subject: [PATCH 41/61] fix(core): restore side-channel artifact records --- .../core/src/services/sessionService.test.ts | 130 ++++++++++++++++++ packages/core/src/services/sessionService.ts | 46 ++++++- 2 files changed, 174 insertions(+), 2 deletions(-) diff --git a/packages/core/src/services/sessionService.test.ts b/packages/core/src/services/sessionService.test.ts index fc8d7deea73..e1a7a03c54b 100644 --- a/packages/core/src/services/sessionService.test.ts +++ b/packages/core/src/services/sessionService.test.ts @@ -25,6 +25,10 @@ import { getResumeTokenCounts, type ConversationRecord, } from './sessionService.js'; +import { + SESSION_ARTIFACT_PERSISTENCE_VERSION, + stableSessionArtifactId, +} from './session-artifact-persistence.js'; import { SessionOrganizationService } from './session-organization-service.js'; import { CompressionStatus } from '../core/turn.js'; import type { ChatRecord } from './chatRecordingService.js'; @@ -468,6 +472,69 @@ describe('SessionService', () => { expect(loaded?.lastCompletedUuid).toBe('b2'); }); + it('loads artifact side records attached to the active branch', async () => { + const now = Date.now(); + statSyncSpy.mockReturnValue({ + mtimeMs: now, + isFile: () => true, + } as fs.Stats); + const artifactId = stableSessionArtifactId( + sessionIdB, + 'url:https://example.com/report', + ); + const artifactRecord: ChatRecord = { + ...recordB1, + uuid: 'artifact-1', + parentUuid: 'b1', + type: 'system', + subtype: 'session_artifact_event', + message: undefined, + systemPayload: { + v: SESSION_ARTIFACT_PERSISTENCE_VERSION, + sessionId: sessionIdB, + sequence: 1, + recordedAt: '2026-07-06T00:00:00.000Z', + changes: [ + { + action: 'created', + artifactId, + artifact: { + id: artifactId, + kind: 'link', + storage: 'external_url', + source: 'client', + status: 'available', + title: 'Report', + url: 'https://example.com/report', + retention: 'restorable', + clientRetained: true, + createdAt: '2026-07-06T00:00:00.000Z', + updatedAt: '2026-07-06T00:00:00.000Z', + persistedAt: '2026-07-06T00:00:00.000Z', + }, + }, + ], + }, + }; + vi.mocked(jsonl.read).mockResolvedValue([ + recordB1, + artifactRecord, + recordB2, + ]); + + const loaded = await sessionService.loadSession(sessionIdB); + + expect( + loaded?.conversation.messages.map((record) => record.uuid), + ).toEqual(['b1', 'b2']); + expect(loaded?.artifactSnapshot?.artifacts).toEqual([ + expect.objectContaining({ + id: artifactId, + title: 'Report', + }), + ]); + }); + it('keeps the latest file history snapshot for a prompt id', async () => { const now = Date.now(); statSyncSpy.mockReturnValue({ @@ -2511,6 +2578,69 @@ describe('SessionService', () => { expect(srcLines.every((r) => !r.forkedFrom)).toBe(true); }); + it('copies artifact side records from the active branch', async () => { + const oldId = '71717171-7171-7171-7171-717171717171'; + const newId = '81818181-8181-8181-8181-818181818181'; + const { file, lines } = seedSession(oldId); + const oldArtifactId = stableSessionArtifactId( + oldId, + 'url:https://example.com/forked', + ); + const artifactRecord = { + uuid: 'artifact-1', + parentUuid: 'u1', + sessionId: oldId, + type: 'system', + subtype: 'session_artifact_event', + timestamp: '2026-04-22T00:00:00.500Z', + cwd, + version: 'test', + systemPayload: { + v: SESSION_ARTIFACT_PERSISTENCE_VERSION, + sessionId: oldId, + sequence: 1, + recordedAt: '2026-04-22T00:00:00.500Z', + changes: [ + { + action: 'created', + artifactId: oldArtifactId, + artifact: { + id: oldArtifactId, + kind: 'link', + storage: 'external_url', + source: 'client', + status: 'available', + title: 'Forked artifact', + url: 'https://example.com/forked', + retention: 'restorable', + clientRetained: true, + createdAt: '2026-04-22T00:00:00.500Z', + updatedAt: '2026-04-22T00:00:00.500Z', + persistedAt: '2026-04-22T00:00:00.500Z', + }, + }, + ], + }, + }; + fs.writeFileSync( + file, + [lines[0], artifactRecord, lines[1]] + .map((line) => JSON.stringify(line)) + .join('\n') + '\n', + ); + + const result = await service.forkSession(oldId, newId); + const loaded = await service.loadSession(newId); + + expect(result.copiedCount).toBe(3); + expect(loaded?.artifactSnapshot?.artifacts).toEqual([ + expect.objectContaining({ + id: stableSessionArtifactId(newId, 'url:https://example.com/forked'), + title: 'Forked artifact', + }), + ]); + }); + it('preserves file history snapshots on the forked session', async () => { const oldId = '31313131-3131-3131-3131-313131313131'; const newId = '41414141-4141-4141-4141-414141414141'; diff --git a/packages/core/src/services/sessionService.ts b/packages/core/src/services/sessionService.ts index 1bac312bb64..77547f59179 100644 --- a/packages/core/src/services/sessionService.ts +++ b/packages/core/src/services/sessionService.ts @@ -1051,8 +1051,12 @@ export class SessionService { fileHistorySnapshots.length > MAX_SNAPSHOTS ? fileHistorySnapshots.slice(-MAX_SNAPSHOTS) : fileHistorySnapshots; - const artifactSnapshot = rebuildSessionArtifactSnapshot( + const activeBranchRecords = includeActiveSideArtifactRecords( + records, messages, + ); + const artifactSnapshot = rebuildSessionArtifactSnapshot( + activeBranchRecords, firstRecord.sessionId, ); @@ -1415,7 +1419,10 @@ export class SessionService { // Copy only the active branch. Rewind leaves old records in the JSONL as // abandoned parentUuid branches; copying raw records would resurrect them. - const sourceRecords = this.reconstructHistory(records); + const sourceRecords = includeActiveSideArtifactRecords( + records, + this.reconstructHistory(records), + ); if (sourceRecords.length === 0) { throw new Error(`Source session not found or empty: ${sourceSessionId}`); } @@ -1941,6 +1948,41 @@ function remapSystemPayloadForFork( return record.systemPayload; } +function isSessionArtifactRecord(record: ChatRecord): boolean { + return ( + record.type === 'system' && + (record.subtype === 'session_artifact_event' || + record.subtype === 'session_artifact_snapshot') + ); +} + +function includeActiveSideArtifactRecords( + records: ChatRecord[], + activeRecords: ChatRecord[], +): ChatRecord[] { + const activeByUuid = new Map( + activeRecords.map((record) => [record.uuid, record]), + ); + const activeUuids = new Set(activeByUuid.keys()); + const selected: ChatRecord[] = []; + for (const record of records) { + const activeRecord = activeByUuid.get(record.uuid); + if (activeRecord) { + selected.push(activeRecord); + activeByUuid.delete(record.uuid); + continue; + } + if ( + isSessionArtifactRecord(record) && + record.parentUuid !== null && + activeUuids.has(record.parentUuid) + ) { + selected.push(record); + } + } + return selected; +} + function collectFileHistorySnapshotPromptIds( records: ChatRecord[], ): Set { From 37fb6ccce30592875561a8fbe9125373ba204f60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Mon, 6 Jul 2026 11:49:44 +0800 Subject: [PATCH 42/61] perf(daemon): avoid repeated workspace artifact hashing --- ...session-artifacts-persistence-v2-design.md | 25 ++++++------ .../acp-bridge/src/sessionArtifacts.test.ts | 6 +++ packages/acp-bridge/src/sessionArtifacts.ts | 39 +++++++++++++++---- 3 files changed, 50 insertions(+), 20 deletions(-) diff --git a/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md b/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md index 93d237ccbd5..d6754b85200 100644 --- a/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md +++ b/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md @@ -2,7 +2,7 @@ 本文延续 PR #5895 的 V1 session artifact API,设计 V2 持久化能力。V1 设计见同目录下的 [session-artifacts-daemon-api-implementation-design.md](./session-artifacts-daemon-api-implementation-design.md)。 -V2 的目标是在不破坏 V1 live session 语义的前提下,让 artifact metadata 可以在 daemon 重启、session load/replay 后恢复。当前 PR 不复制、不冻结、不托管 artifact 内容;workspace 文件只保存路径、size 和 sha256 作为恢复后的完整性校验。 +V2 的目标是在不破坏 V1 live session 语义的前提下,让 artifact metadata 可以在 daemon 重启、session load/replay 后恢复。当前 PR 不复制、不冻结、不托管 artifact 内容;workspace 文件只保存路径、size、mtimeMs 和 sha256 作为恢复后的完整性校验。 ## 1. 设计结论 @@ -11,7 +11,7 @@ V2 是一个 metadata persistence phase。PR #6259 的实现范围收敛为 meta 当前能力: 1. Metadata restore:默认恢复 artifact 的结构化 metadata 和资源引用,不复制实际内容。 -2. Workspace integrity check:workspace artifact 登记时记录 size + sha256;restore / GET 时按实时文件返回 `available` / `missing` / `changed`。 +2. Workspace integrity check:workspace artifact 登记时记录 size + mtimeMs + sha256;restore / GET 时按实时文件返回 `available` / `missing` / `changed`。 对应 capability: @@ -81,7 +81,7 @@ type ArtifactRetention = 'ephemeral' | 'restorable'; 恢复后的结果按资源状态区分: - `external_url`:恢复 title、description、url、metadata。daemon 不访问远端 URL;URL 是否仍可打开由 client 点击时决定。 -- `workspace`:恢复 workspacePath 和 metadata;如果文件仍在 workspace 内且 size + sha256 与登记时一致,`status: "available"`;如果文件已删除、移动或 symlink 逃逸,`status: "missing"`;如果文件仍在但 size 或 sha256 与登记时不同,`status: "changed"`。 +- `workspace`:恢复 workspacePath 和 metadata;如果文件仍在 workspace 内且 size + mtimeMs 未变,或 mtime 变化后 sha256 仍与登记时一致,`status: "available"`;如果文件已删除、移动或 symlink 逃逸,`status: "missing"`;如果文件仍在但 size 或 sha256 与登记时不同,`status: "changed"`。 - `managed`:恢复 managedId;只有 managed storage manifest 仍能解析时才 `available`。 - `published`:恢复 published locator;只有仍满足 trusted publisher manifest 校验时才保留 published trust。 @@ -109,6 +109,7 @@ interface DaemonSessionArtifact { | 'sticky_override_active'; metadata?: { 'qwen.workspace.sha256'?: string; + 'qwen.workspace.mtimeMs'?: number; [key: string]: string | number | boolean | null | undefined; }; } @@ -120,7 +121,7 @@ interface DaemonSessionArtifact { - `persistedAt`:metadata 最近成功落盘时间。 - `restoreState`:恢复来源提示;不替代 `status`。 - `persistenceWarning`:非阻塞持久化/恢复风险,前端可用它提示“此 artifact 不会跨重启保留”等状态。当前 wire shape 是固定字符串,避免把 host 绝对路径、credential、token、内部 storage path 或 connection id 写入 response。更结构化的 `{ code, message }` 可作为后续兼容扩展。 -- `status: "changed"`:仅用于 workspace artifact。daemon 在登记时写入 `metadata["qwen.workspace.sha256"]` 和 `sizeBytes`;GET/list/restore 后 refresh 时重新 stat/hash 当前文件,如果文件存在但 size 或 sha256 不一致,则返回 `changed`。 +- `status: "changed"`:仅用于 workspace artifact。daemon 在登记时写入 `sizeBytes`、`metadata["qwen.workspace.sha256"]` 和 `metadata["qwen.workspace.mtimeMs"]`;GET/list/restore 后 refresh 先 stat 当前文件,size 变化直接返回 `changed`,size/mtime 均未变化则不重读文件,只有 mtime 变化但 size 相同时才重新计算 sha256 兜底。 ### 3.2 Status 与 restoreState 的关系 @@ -130,7 +131,7 @@ V1 `status` 继续表示当前资源是否可用: - `missing` - `changed` -V2 只新增 `changed` 这一种 workspace integrity 状态。它表示路径仍可访问,但实时文件的 size 或 sha256 与登记时的 metadata 不一致。`blocked` 不是 `status`,只属于 `restoreState`: +V2 只新增 `changed` 这一种 workspace integrity 状态。它表示路径仍可访问,但实时文件的 size 已变化,或 mtime 变化后 sha256 与登记时的 metadata 不一致。`blocked` 不是 `status`,只属于 `restoreState`: - `restored`:从持久化 metadata 恢复。 - `unverified`:恢复了 metadata,但尚未完成 workspace/managed 校验。 @@ -702,22 +703,22 @@ CPU 成本边界: - Metadata restore 只 parse JSON 和做字段校验,复杂度 O(artifact 数量 + 最新 snapshot 后事件数)。 - `external_url` 恢复不发网络请求。 -- `workspace` load/replay 只恢复 metadata;GET/list refresh 在 TTL/batch 限制下重新 stat/hash 单个或一批 workspace 文件,用于区分 `available` / `missing` / `changed`。 +- `workspace` load/replay 只恢复 metadata;GET/list refresh 在 TTL/batch 限制下重新 stat 单个或一批 workspace 文件,必要时才 hash,用于区分 `available` / `missing` / `changed`。 - `managed` / `published` 恢复只查 manifest,不读取大文件内容。 -- workspace content hash 不在 `loadSession()` 的 JSONL parse 阶段全量执行。 +- workspace content hash 不在 `loadSession()` 的 JSONL parse 阶段全量执行。GET/list refresh 先用 size + mtimeMs 做 cheap stat gate;只有 stat 显示可能同尺寸改写时才读取文件流计算 sha256。 I/O 成本边界: - V2 不额外读 sidecar 文件。 - workspace 状态校验复用 V1 的 TTL/batch 策略,不在 GET 热路径对所有 artifact 做无限制 stat。 -- 对大 workspace 文件,不在恢复阶段读内容;登记和后续 refresh 读取实时文件流计算 sha256,不复制到 daemon-managed storage。 +- 对大 workspace 文件,不在恢复阶段读内容;登记时读取实时文件流计算 sha256,后续 refresh 只有 size/mtimeMs 显示可能变更时才重新读取文件流,不复制到 daemon-managed storage。 推荐默认: - artifact snapshot 上限 200 条。 - workspace status restore batch size 20,与 V1 保持一致。 - artifact journal snapshot 阈值 100 mutations 或 256 KB。 -- workspace sha256 在登记时同步完成;恢复后的状态校验按 TTL/batch lazy refresh。 +- workspace sha256 在登记时同步完成;恢复后的状态校验按 TTL/batch lazy refresh,并通过 size + mtimeMs 避免对未变化文件重复做全量 hash。 ### 8.6 Observability @@ -840,7 +841,7 @@ PR #6259 当前必须覆盖: - restore seed 与 concurrent POST 串行,不丢写、不重复。 - quota 边界:200 条、201 条 prune、clientRetained/non-clientRetained 两层排序、全部 clientRetained restorable 仍可按确定性规则裁剪。 - clientRetained setter:Add artifact request 能设置 boolean hint;后台自动 ingest 不能伪造用户保留。 -- workspace 三态:登记时写入 size + `metadata["qwen.workspace.sha256"]`;GET/list refresh 能区分 `available`、`missing` 和 `changed`。 +- workspace 三态:登记时写入 size + `metadata["qwen.workspace.sha256"]` + `metadata["qwen.workspace.mtimeMs"]`;GET/list refresh 能区分 `available`、`missing` 和 `changed`,且未变化文件只走 stat 快路径。 - authorization:token-holder/principal 审计路径允许和拒绝情况;V1 live same-principal guard 仅作为 live UX/audit hint,不作为 durable security boundary。 - JSONL snapshot baseline advance:threshold 触发、post-snapshot replay 有界、snapshot payload 不再携带已被覆盖的 explicit tombstones、superseded sticky tombstone 允许显式同 id 重新出现、`stickyEphemeralIds` 保留 sticky state;JSONL 文件本身不被 artifact 子系统重写。 - corrupt latest snapshot fallback:回退到较旧 valid snapshot 或一次顺序 artifact replay。 @@ -879,7 +880,7 @@ V2 建议作为一个完整 design phase 发布,但能力按 capability 暴露 - `session_artifacts_content_retention` 当前不发布;future content archive 需要重新设计并独立声明 capability。 - 默认恢复显式登记的 artifact metadata。 - 用户手动注册的 artifact 默认 `restorable`,session load/replay 后继续出现在列表中。 -- 用户文档明确:metadata restore 恢复的是“产物索引”,不是“产物内容备份”;workspace 的 `changed` 状态只说明实时文件和登记时 hash/size 不一致。 +- 用户文档明确:metadata restore 恢复的是“产物索引”,不是“产物内容备份”;workspace 的 `changed` 状态只说明实时文件和登记时 size 不一致,或 mtime 变化后 hash 不一致。 Rollback procedure: @@ -889,4 +890,4 @@ Rollback procedure: - 发布前 CI 必须用最低支持旧 daemon 版本加载包含 `session_artifact_event` 和 `session_artifact_snapshot` 的 JSONL,断言 session load 成功且 unknown subtype 被忽略。V2 writer 首次初始化前也要检查版本/feature gate;失败时拒绝写 V2 records,记录 `v2_writer_version_gate_failed`,保持 V1 行为。如果未来加入 fork marker,再把该 subtype 纳入 rollback fixture。 - rollback 后 client 不能依赖 `session_artifacts_persistence` / `session_artifacts_content_retention`,因为旧 daemon 不声明这些 capability。 -这样可以讲清楚当前 V2 的完整语义:默认恢复列表,不保存内容,用 workspace size/hash 避免静默打开错误版本。 +这样可以讲清楚当前 V2 的完整语义:默认恢复列表,不保存内容,用 workspace size/mtime/hash 避免静默打开错误版本,同时避免对未变化文件重复做全量 hash。 diff --git a/packages/acp-bridge/src/sessionArtifacts.test.ts b/packages/acp-bridge/src/sessionArtifacts.test.ts index 03ff7f58068..da24fb63cc2 100644 --- a/packages/acp-bridge/src/sessionArtifacts.test.ts +++ b/packages/acp-bridge/src/sessionArtifacts.test.ts @@ -100,11 +100,17 @@ describe('SessionArtifactStore', () => { 'qwen.workspace.sha256': createHash('sha256') .update('hello') .digest('hex'), + 'qwen.workspace.mtimeMs': expect.any(Number), }, }); await expect(store.get('missing')).resolves.toBeUndefined(); await fs.writeFile(path.join(workspace, 'report.txt'), 'HELLO'); + await fs.utimes( + path.join(workspace, 'report.txt'), + new Date('2026-07-06T00:00:00.000Z'), + new Date('2026-07-06T00:00:00.000Z'), + ); vi.useFakeTimers(); vi.setSystemTime(new Date(Date.now() + 6_000)); const changed = await store.get(artifactId); diff --git a/packages/acp-bridge/src/sessionArtifacts.ts b/packages/acp-bridge/src/sessionArtifacts.ts index 8ab1bbf8ec9..fe0a0ea4064 100644 --- a/packages/acp-bridge/src/sessionArtifacts.ts +++ b/packages/acp-bridge/src/sessionArtifacts.ts @@ -53,6 +53,7 @@ const SOURCE_RESERVATIONS: Record = { hook: 50, }; const WORKSPACE_CONTENT_SHA256_METADATA_KEY = 'qwen.workspace.sha256'; +const WORKSPACE_CONTENT_MTIME_MS_METADATA_KEY = 'qwen.workspace.mtimeMs'; const WORKSPACE_STATUS_REFRESH_TTL_MS = 5_000; const WORKSPACE_STATUS_REFRESH_BATCH_SIZE = 20; const SNAPSHOT_AFTER_DURABLE_EVENTS = 50; @@ -1019,6 +1020,7 @@ export class SessionArtifactStore { status: DaemonSessionArtifactStatus; sizeBytes?: number; sha256?: string; + mtimeMs?: number; escaped?: boolean; }> { try { @@ -1048,6 +1050,10 @@ export class SessionArtifactStore { this.workspaceCwd, artifact.workspacePath, this.getRealWorkspaceCwd(), + { + sizeBytes: artifact.sizeBytes, + mtimeMs: artifact.metadata?.[WORKSPACE_CONTENT_MTIME_MS_METADATA_KEY], + }, ); const changed = isWorkspaceContentChanged(artifact, status); artifact.status = changed ? 'changed' : status.status; @@ -1387,7 +1393,8 @@ function mergeMetadata( let changed = false; for (const [key, value] of Object.entries(incoming.metadata)) { if ( - key === WORKSPACE_CONTENT_SHA256_METADATA_KEY && + (key === WORKSPACE_CONTENT_SHA256_METADATA_KEY || + key === WORKSPACE_CONTENT_MTIME_MS_METADATA_KEY) && merged[key] !== value ) { merged[key] = value; @@ -2085,10 +2092,15 @@ async function getWorkspaceStatus( workspaceCwd: string, workspacePath: string, realWorkspaceCwd: Promise, + expected?: { + sizeBytes?: number; + mtimeMs?: string | number | boolean | null; + }, ): Promise<{ status: DaemonSessionArtifactStatus; sizeBytes?: number; sha256?: string; + mtimeMs?: number; escaped?: boolean; }> { const absolutePath = path.resolve(workspaceCwd, workspacePath); @@ -2101,10 +2113,17 @@ async function getWorkspaceStatus( } const stat = await fs.stat(realPath); if (stat.isFile()) { + const unchanged = + expected?.sizeBytes === stat.size && expected.mtimeMs === stat.mtimeMs; + const sizeChanged = + expected?.sizeBytes !== undefined && expected.sizeBytes !== stat.size; return { status: 'available', sizeBytes: stat.size, - sha256: await hashFile(realPath), + mtimeMs: stat.mtimeMs, + ...(unchanged || sizeChanged + ? {} + : { sha256: await hashFile(realPath) }), }; } return { @@ -2135,6 +2154,7 @@ function withWorkspaceContentHashMetadata( | { status: DaemonSessionArtifactStatus; sha256?: string; + mtimeMs?: number; } | undefined, ): Record | undefined { @@ -2144,6 +2164,9 @@ function withWorkspaceContentHashMetadata( const next = { ...(metadata ?? {}), [WORKSPACE_CONTENT_SHA256_METADATA_KEY]: workspaceStatus.sha256, + ...(workspaceStatus.mtimeMs !== undefined + ? { [WORKSPACE_CONTENT_MTIME_MS_METADATA_KEY]: workspaceStatus.mtimeMs } + : {}), }; if (Buffer.byteLength(JSON.stringify(next), 'utf8') > 4096) { throw new SessionArtifactValidationError( @@ -2168,16 +2191,16 @@ function isWorkspaceContentChanged( const expectedSha256 = artifact.metadata?.[WORKSPACE_CONTENT_SHA256_METADATA_KEY]; if ( - typeof expectedSha256 === 'string' && - status.sha256 && - status.sha256 !== expectedSha256 + artifact.sizeBytes !== undefined && + status.sizeBytes !== undefined && + status.sizeBytes !== artifact.sizeBytes ) { return true; } return ( - artifact.sizeBytes !== undefined && - status.sizeBytes !== undefined && - status.sizeBytes !== artifact.sizeBytes + typeof expectedSha256 === 'string' && + status.sha256 !== undefined && + status.sha256 !== expectedSha256 ); } From 703b3a97c7cb2f17884e2ece2556424195813e72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Mon, 6 Jul 2026 13:03:11 +0800 Subject: [PATCH 43/61] fix(daemon): address artifact persistence review gaps --- packages/acp-bridge/src/bridge.test.ts | 6 +- packages/acp-bridge/src/bridge.ts | 39 +++++----- packages/acp-bridge/src/bridgeTypes.ts | 2 + .../acp-bridge/src/sessionArtifacts.test.ts | 73 ++++++++++++++++++- packages/acp-bridge/src/sessionArtifacts.ts | 71 +++++++++++++++--- .../session-artifact-persistence.test.ts | 40 ++++++++++ .../services/session-artifact-persistence.ts | 17 ++++- packages/sdk-typescript/src/daemon/types.ts | 1 + 8 files changed, 210 insertions(+), 39 deletions(-) diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index 9aea82a95fb..a31defc635f 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -1301,7 +1301,7 @@ describe('createAcpSessionBridge', () => { updatedAt: '2026-07-04T00:00:00.000Z', }, ], - warnings: [], + warnings: ['skipped corrupt artifact record'], }, }) as LoadSessionResponse, }).channel, @@ -1311,10 +1311,14 @@ describe('createAcpSessionBridge', () => { sessionId, workspaceCwd: WS_A, }); + expect(loaded.artifactWarnings).toEqual([ + 'skipped corrupt artifact record', + ]); await expect( bridge.getSessionArtifacts(loaded.sessionId), ).resolves.toMatchObject({ + warnings: ['skipped corrupt artifact record'], artifacts: [ { id: artifactId, diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index 5e1797a039d..a79bed3aa72 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -2698,31 +2698,23 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { ) { return { recordEvent: async (payload: unknown): Promise => { - await withTimeout( - connection.extMethod( - SERVE_CONTROL_EXT_METHODS.sessionArtifactsPersist, - { - sessionId, - kind: 'event', - payload, - }, - ), - PERSIST_TIMEOUT_MS, - 'sessionArtifactPersist(event)', + await connection.extMethod( + SERVE_CONTROL_EXT_METHODS.sessionArtifactsPersist, + { + sessionId, + kind: 'event', + payload, + }, ); }, recordSnapshot: async (payload: unknown): Promise => { - await withTimeout( - connection.extMethod( - SERVE_CONTROL_EXT_METHODS.sessionArtifactsPersist, - { - sessionId, - kind: 'snapshot', - payload, - }, - ), - PERSIST_TIMEOUT_MS, - 'sessionArtifactPersist(snapshot)', + await connection.extMethod( + SERVE_CONTROL_EXT_METHODS.sessionArtifactsPersist, + { + sessionId, + kind: 'snapshot', + payload, + }, ); }, }; @@ -3144,6 +3136,9 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { clientId, createdAt: entry.createdAt, state, + ...(artifactRestoreWarnings.length > 0 + ? { artifactWarnings: artifactRestoreWarnings } + : {}), hasActivePrompt: entry.promptActive, ...replayFieldsFor(entry, action), }; diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts index 44ec764cf93..e8e79c368c1 100644 --- a/packages/acp-bridge/src/bridgeTypes.ts +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -125,6 +125,8 @@ export type BridgeSessionState = LoadSessionResponse | ResumeSessionResponse; export interface BridgeRestoredSession extends BridgeSession { /** ACP state returned by `session/load` / `session/resume`. */ state: BridgeSessionState; + /** Artifact restore warnings surfaced during session load/resume. */ + artifactWarnings?: string[]; /** True when response-mode history replay aborted after emitting a prefix. */ partial?: true; /** Agent-provided replay failure detail when `partial` is true. */ diff --git a/packages/acp-bridge/src/sessionArtifacts.test.ts b/packages/acp-bridge/src/sessionArtifacts.test.ts index da24fb63cc2..ae97ab85f47 100644 --- a/packages/acp-bridge/src/sessionArtifacts.test.ts +++ b/packages/acp-bridge/src/sessionArtifacts.test.ts @@ -169,6 +169,49 @@ describe('SessionArtifactStore', () => { }); }); + it('prevents one client from upserting another client retained artifact', async () => { + const store = new SessionArtifactStore({ + sessionId: 's1-client-upsert-owner', + workspaceCwd: workspace, + }); + + const created = await store.upsertMany([ + { + title: 'Client A link', + source: 'client', + clientId: 'client-a', + url: 'https://example.com/client-owned', + metadata: { owner: 'a' }, + }, + ]); + const artifactId = created.changes[0]!.artifactId; + + await expect( + store.upsertMany([ + { + title: 'Client B rewrite', + source: 'client', + clientId: 'client-b', + url: 'https://example.com/client-owned', + metadata: { owner: 'b' }, + retention: 'restorable', + }, + ]), + ).rejects.toBeInstanceOf(SessionArtifactAuthorizationError); + + await expect(store.list()).resolves.toMatchObject({ + artifacts: [ + { + id: artifactId, + title: 'Client A link', + clientId: 'client-a', + metadata: { owner: 'a' }, + retention: 'ephemeral', + }, + ], + }); + }); + it('does not write live client ids into durable artifact records', async () => { const events: SessionArtifactEventRecordPayload[] = []; const store = new SessionArtifactStore({ @@ -2164,9 +2207,35 @@ describe('SessionArtifactStore', () => { expect(suppressed.changes).toEqual([]); const explicitClient = await store.upsertMany([ - { ...input, source: 'client' }, + { + ...input, + source: 'client', + clientId: 'client-a', + retention: 'restorable', + }, ]); - expect(explicitClient.changes).toMatchObject([{ action: 'created' }]); + expect(explicitClient.changes).toEqual([]); + }); + + it('keeps restore warnings visible on the artifact list', async () => { + const store = new SessionArtifactStore({ + sessionId: 's11-restore-warnings', + workspaceCwd: workspace, + }); + + await store.restore({ + v: 2, + sessionId: 's11-restore-warnings', + sequence: 1, + artifacts: [], + tombstonedIds: [], + stickyEphemeralIds: [], + warnings: ['skipped corrupt artifact record'], + }); + + await expect(store.list()).resolves.toMatchObject({ + warnings: ['skipped corrupt artifact record'], + }); }); it('clears durable artifacts but preserves live ephemerals for rewind without a snapshot', async () => { diff --git a/packages/acp-bridge/src/sessionArtifacts.ts b/packages/acp-bridge/src/sessionArtifacts.ts index fe0a0ea4064..d02907d8e18 100644 --- a/packages/acp-bridge/src/sessionArtifacts.ts +++ b/packages/acp-bridge/src/sessionArtifacts.ts @@ -58,6 +58,7 @@ const WORKSPACE_STATUS_REFRESH_TTL_MS = 5_000; const WORKSPACE_STATUS_REFRESH_BATCH_SIZE = 20; const SNAPSHOT_AFTER_DURABLE_EVENTS = 50; const MAX_SNAPSHOT_BACKOFF_MULTIPLIER = 4; +const MAX_TOMBSTONED_IDS = 500; export interface ToolArtifactLike { kind?: DaemonSessionArtifactKind; @@ -82,7 +83,7 @@ export interface SessionArtifactInput extends ToolArtifactLike { clientId?: string; } -type LegacySessionArtifactInput = Omit & { +type RestoreSessionArtifactInput = Omit & { retention?: SessionArtifactRetention; }; @@ -212,7 +213,9 @@ export class SessionArtifactStore { private realWorkspaceCwdPromise?: Promise; private operationQueue: Promise = Promise.resolve(); private readonly tombstonedIds = new Set(); + private readonly tombstonedClientIds = new Map(); private readonly stickyEphemeralIds = new Set(); + private lastRestoreWarnings: string[] = []; constructor(options: SessionArtifactStoreOptions) { this.sessionId = options.sessionId; @@ -236,6 +239,9 @@ export class SessionArtifactStore { .map(toPublicArtifact), generatedAt: new Date().toISOString(), limits: { maxArtifacts: this.maxArtifacts }, + ...(this.lastRestoreWarnings.length > 0 + ? { warnings: [...this.lastRestoreWarnings] } + : {}), }; }); } @@ -319,6 +325,9 @@ export class SessionArtifactStore { continue; } + this.denyCrossClientMutation('upsert', existing.id, existing, { + clientId: artifact.clientId, + }); const updated = mergeArtifact(existing, artifact); if (updated.changed) { if (updated.artifact.id !== existing.id) { @@ -346,7 +355,11 @@ export class SessionArtifactStore { ...(await this.persistChanges(changes, persistenceStrict)), ); } catch (error) { - if (validationStrict || persistenceStrict) { + if ( + validationStrict || + persistenceStrict || + error instanceof SessionArtifactAuthorizationError + ) { this.restoreState(before); } throw error; @@ -471,6 +484,7 @@ export class SessionArtifactStore { let restoredCount = 0; this.artifacts.clear(); this.tombstonedIds.clear(); + this.tombstonedClientIds.clear(); this.stickyEphemeralIds.clear(); if (snapshot) { this.insertSeq = 0; @@ -548,6 +562,7 @@ export class SessionArtifactStore { warnings.push( 'artifact snapshot restore failed; kept existing live artifacts', ); + this.setLastRestoreWarnings(warnings); return warnings; } for (const artifact of preservedLiveEphemeralArtifacts) { @@ -567,6 +582,7 @@ export class SessionArtifactStore { warnings.push('restored artifact list pruned to live limit'); warnings.push(...(await this.persistChanges(evicted, false))); } + this.setLastRestoreWarnings(warnings); return warnings; }); } @@ -604,7 +620,9 @@ export class SessionArtifactStore { durableEventsSinceSnapshot: number; consecutiveSnapshotFailures: number; tombstonedIds: Set; + tombstonedClientIds: Map; stickyEphemeralIds: Set; + lastRestoreWarnings: string[]; } { return { artifacts: new Map( @@ -619,7 +637,9 @@ export class SessionArtifactStore { durableEventsSinceSnapshot: this.durableEventsSinceSnapshot, consecutiveSnapshotFailures: this.consecutiveSnapshotFailures, tombstonedIds: new Set(this.tombstonedIds), + tombstonedClientIds: new Map(this.tombstonedClientIds), stickyEphemeralIds: new Set(this.stickyEphemeralIds), + lastRestoreWarnings: [...this.lastRestoreWarnings], }; } @@ -631,7 +651,9 @@ export class SessionArtifactStore { durableEventsSinceSnapshot: number; consecutiveSnapshotFailures: number; tombstonedIds: Set; + tombstonedClientIds: Map; stickyEphemeralIds: Set; + lastRestoreWarnings: string[]; }): void { this.artifacts.clear(); for (const [id, artifact] of state.artifacts) { @@ -646,10 +668,15 @@ export class SessionArtifactStore { for (const id of state.tombstonedIds) { this.tombstonedIds.add(id); } + this.tombstonedClientIds.clear(); + for (const [id, clientId] of state.tombstonedClientIds) { + this.tombstonedClientIds.set(id, clientId); + } this.stickyEphemeralIds.clear(); for (const id of state.stickyEphemeralIds) { this.stickyEphemeralIds.add(id); } + this.setLastRestoreWarnings(state.lastRestoreWarnings); } private async persistChanges( @@ -709,10 +736,11 @@ export class SessionArtifactStore { throw error; } const reason = error instanceof Error ? error.message : String(error); + const artifactIds = durableChanges.map((change) => change.artifactId); writeStderrLine( - `[artifacts] session=${this.sessionId} action=persist_failed reason=${JSON.stringify( - reason, - )}`, + `[artifacts] session=${this.sessionId} action=persist_failed sequence=${payload.sequence} artifactIds=${JSON.stringify( + artifactIds, + )} reason=${JSON.stringify(reason)}`, ); return this.downgradeDurableChanges(durableChanges); } @@ -804,7 +832,7 @@ export class SessionArtifactStore { for (const change of changes) { if (change.action === 'removed') { if (change.reason === 'explicit') { - this.tombstonedIds.add(change.artifactId); + this.rememberTombstone(change); this.stickyEphemeralIds.delete(change.artifactId); } else if (change.reason === 'eviction') { this.stickyEphemeralIds.delete(change.artifactId); @@ -815,11 +843,28 @@ export class SessionArtifactStore { } if (change.artifact && change.artifact.retention !== 'ephemeral') { this.tombstonedIds.delete(change.artifactId); + this.tombstonedClientIds.delete(change.artifactId); this.stickyEphemeralIds.delete(change.artifactId); } } } + private rememberTombstone(change: SessionArtifactChange): void { + this.tombstonedIds.delete(change.artifactId); + this.tombstonedIds.add(change.artifactId); + this.tombstonedClientIds.set(change.artifactId, change.artifact?.clientId); + while (this.tombstonedIds.size > MAX_TOMBSTONED_IDS) { + const oldest = this.tombstonedIds.values().next().value; + if (oldest === undefined) break; + this.tombstonedIds.delete(oldest); + this.tombstonedClientIds.delete(oldest); + } + } + + private setLastRestoreWarnings(warnings: readonly string[]): void { + this.lastRestoreWarnings = [...warnings]; + } + private enqueue(operation: () => Promise): Promise { const result = this.operationQueue.then(operation, operation); this.operationQueue = result.then( @@ -830,7 +875,7 @@ export class SessionArtifactStore { } private denyCrossClientMutation( - action: 'remove' | 'pin' | 'unpin', + action: 'remove' | 'pin' | 'unpin' | 'upsert', artifactId: string, existing: StoredArtifact, options?: { clientId?: string }, @@ -854,7 +899,7 @@ export class SessionArtifactStore { } private async normalizeInput( - input: LegacySessionArtifactInput, + input: RestoreSessionArtifactInput, receivedSeq: number, trustedPublisherFromCaller: boolean, ): Promise { @@ -979,7 +1024,13 @@ export class SessionArtifactStore { if (!this.tombstonedIds.has(artifact.id)) { return false; } - return artifact.source !== 'client' && !artifact.retentionExplicit; + const tombstonedClientId = this.tombstonedClientIds.get(artifact.id); + return !( + artifact.source === 'client' && + artifact.retentionExplicit && + artifact.clientId !== undefined && + artifact.clientId === tombstonedClientId + ); } private async refreshWorkspaceStatuses(): Promise { @@ -1558,7 +1609,7 @@ function toPublicArtifact( function persistedArtifactToInput( artifact: PersistedSessionArtifact, -): LegacySessionArtifactInput { +): RestoreSessionArtifactInput { return { title: artifact.title, kind: artifact.kind, diff --git a/packages/core/src/services/session-artifact-persistence.test.ts b/packages/core/src/services/session-artifact-persistence.test.ts index 1b8bc8f0892..b5292759e83 100644 --- a/packages/core/src/services/session-artifact-persistence.test.ts +++ b/packages/core/src/services/session-artifact-persistence.test.ts @@ -444,6 +444,46 @@ describe('session artifact persistence records', () => { ); }); + it('remaps forked tombstone changes that omit artifact metadata', () => { + const source = artifact('source-session', 'https://example.com/deleted'); + + const remapped = remapSessionArtifactPayloadForFork( + { + v: SESSION_ARTIFACT_PERSISTENCE_VERSION, + sessionId: 'source-session', + sequence: 7, + recordedAt: '2026-07-04T00:00:00.000Z', + changes: [ + { action: 'created', artifactId: source.id, artifact: source }, + { + action: 'removed', + artifactId: source.id, + reason: 'explicit', + }, + ], + }, + 'source-session', + 'forked-session', + ) as SessionArtifactEventRecordPayload; + + const forkedId = stableSessionArtifactId( + 'forked-session', + 'url:https://example.com/deleted', + ); + expect(remapped.changes).toMatchObject([ + { + action: 'created', + artifactId: forkedId, + artifact: { id: forkedId }, + }, + { + action: 'removed', + artifactId: forkedId, + reason: 'explicit', + }, + ]); + }); + it('remaps forked snapshot payloads and drops bare tombstone state', () => { const source = artifact('source-session', 'https://example.com/snapshot', { retention: 'pinned', diff --git a/packages/core/src/services/session-artifact-persistence.ts b/packages/core/src/services/session-artifact-persistence.ts index 9a0f71939b1..8803623eba4 100644 --- a/packages/core/src/services/session-artifact-persistence.ts +++ b/packages/core/src/services/session-artifact-persistence.ts @@ -19,9 +19,6 @@ export type SessionArtifactRestoreState = export type SessionArtifactPersistenceWarning = | 'persistence_unavailable' - | 'content_missing' - | 'content_expired' - | 'content_hash_mismatch' | 'metadata_only_restore' | 'restore_validation_failed' | 'sticky_override_active'; @@ -271,6 +268,7 @@ export function remapSessionArtifactPayloadForFork( const event = normalizeEventPayload(payload, []); if (!event) return payload; + const remappedArtifactIds = new Map(); return { ...event, sessionId: newSessionId, @@ -282,13 +280,24 @@ export function remapSessionArtifactPayloadForFork( sourceSessionId, newSessionId, ); + remappedArtifactIds.set(change.artifactId, artifact.id); return { ...change, artifactId: artifact.id, artifact, }; } - if (change.action === 'removed') return undefined; + if (change.action === 'removed') { + return { + ...change, + artifactId: + remappedArtifactIds.get(change.artifactId) ?? + stableSessionArtifactId( + newSessionId, + `fork:${sourceSessionId}:${change.artifactId}`, + ), + }; + } return change; }) .filter((change) => change !== undefined), diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index 5e69b6eef54..f63f55ded12 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -446,6 +446,7 @@ export interface DaemonSessionState { /** Returned from `POST /session/:id/load` and `POST /session/:id/resume`. */ export interface DaemonRestoredSession extends DaemonSession { state: DaemonSessionState; + artifactWarnings?: string[]; /** Compacted events for completed turns (load only). */ compactedReplay?: DaemonEvent[]; /** Raw events since last turn boundary — current incomplete turn (load only). */ From 77375fb5b230589e1e42478f574d821e87e00481 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Mon, 6 Jul 2026 13:15:54 +0800 Subject: [PATCH 44/61] fix(daemon): preserve artifact client ownership --- .../acp-bridge/src/sessionArtifacts.test.ts | 63 +++++++++++++++++-- packages/acp-bridge/src/sessionArtifacts.ts | 2 + packages/cli/src/serve/routes/session.ts | 8 ++- .../session-artifact-persistence.test.ts | 4 +- .../services/session-artifact-persistence.ts | 3 + 5 files changed, 70 insertions(+), 10 deletions(-) diff --git a/packages/acp-bridge/src/sessionArtifacts.test.ts b/packages/acp-bridge/src/sessionArtifacts.test.ts index ae97ab85f47..3052b6ef87a 100644 --- a/packages/acp-bridge/src/sessionArtifacts.test.ts +++ b/packages/acp-bridge/src/sessionArtifacts.test.ts @@ -15,10 +15,11 @@ import { SessionArtifactStore, SessionArtifactValidationError, } from './sessionArtifacts.js'; -import type { - RebuiltSessionArtifactSnapshot, - SessionArtifactEventRecordPayload, - SessionArtifactSnapshotRecordPayload, +import { + stableSessionArtifactId, + type RebuiltSessionArtifactSnapshot, + type SessionArtifactEventRecordPayload, + type SessionArtifactSnapshotRecordPayload, } from '@qwen-code/qwen-code-core'; describe('SessionArtifactStore', () => { @@ -212,7 +213,7 @@ describe('SessionArtifactStore', () => { }); }); - it('does not write live client ids into durable artifact records', async () => { + it('writes client ids into durable artifact records', async () => { const events: SessionArtifactEventRecordPayload[] = []; const store = new SessionArtifactStore({ sessionId: 's1-client-id-durable', @@ -240,13 +241,63 @@ describe('SessionArtifactStore', () => { expect(created.changes[0]?.artifact).toMatchObject({ clientId: 'client-a', }); - expect(events[0]?.changes[0]?.artifact).not.toHaveProperty('clientId'); + expect(events[0]?.changes[0]?.artifact).toMatchObject({ + clientId: 'client-a', + }); expect(events[0]?.changes[0]?.artifact).not.toHaveProperty('restoreState'); expect(events[0]?.changes[0]?.artifact).not.toHaveProperty( 'persistenceWarning', ); }); + it('restores client ownership from durable artifact records', async () => { + const owner = 'client-a'; + const sessionId = 's1-restored-client-owner'; + const url = 'https://example.com/owned-restored-artifact'; + const artifactId = stableSessionArtifactId(sessionId, `url:${url}`); + const store = new SessionArtifactStore({ + sessionId, + workspaceCwd: workspace, + }); + + await store.restore({ + v: 2, + sessionId, + sequence: 1, + artifacts: [ + { + id: artifactId, + kind: 'link', + storage: 'external_url', + source: 'client', + status: 'available', + title: 'Owned restored artifact', + url, + retention: 'restorable', + clientRetained: true, + createdAt: '2026-07-04T00:00:00.000Z', + updatedAt: '2026-07-04T00:00:00.000Z', + clientId: owner, + }, + ], + tombstonedIds: [], + stickyEphemeralIds: [], + warnings: [], + } satisfies RebuiltSessionArtifactSnapshot); + + await expect(store.list()).resolves.toMatchObject({ + artifacts: [{ id: artifactId, clientId: owner }], + }); + await expect( + store.remove(artifactId, { clientId: 'client-b' }), + ).rejects.toBeInstanceOf(SessionArtifactAuthorizationError); + await expect( + store.remove(artifactId, { clientId: owner }), + ).resolves.toMatchObject({ + changes: [{ action: 'removed', artifactId }], + }); + }); + it('rolls back received sequence when strict upsert persistence fails', async () => { let fail = false; const store = new SessionArtifactStore({ diff --git a/packages/acp-bridge/src/sessionArtifacts.ts b/packages/acp-bridge/src/sessionArtifacts.ts index d02907d8e18..234f95fd80e 100644 --- a/packages/acp-bridge/src/sessionArtifacts.ts +++ b/packages/acp-bridge/src/sessionArtifacts.ts @@ -1627,6 +1627,7 @@ function persistedArtifactToInput( toolCallId: artifact.toolCallId, toolName: artifact.toolName, hookEventName: artifact.hookEventName, + clientId: artifact.clientId, }; } @@ -1676,6 +1677,7 @@ function toPersistedArtifact( ...(artifact.hookEventName ? { hookEventName: artifact.hookEventName } : {}), + ...(artifact.clientId ? { clientId: artifact.clientId } : {}), }; } diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index 52a606a9f8a..153804ddf97 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -637,11 +637,15 @@ export function registerSessionRoutes( if (sessionId === null) return; const clientId = parseClientIdHeader(req, res); if (clientId === null) return; - if (!requireSessionArtifactClientId(clientId, res)) return; try { res .status(200) - .json(await bridge.getSessionArtifacts(sessionId, { clientId })); + .json( + await bridge.getSessionArtifacts( + sessionId, + clientId !== undefined ? { clientId } : undefined, + ), + ); } catch (err) { sendBridgeError(res, err, { route: 'GET /session/:id/artifacts', diff --git a/packages/core/src/services/session-artifact-persistence.test.ts b/packages/core/src/services/session-artifact-persistence.test.ts index b5292759e83..565b332a277 100644 --- a/packages/core/src/services/session-artifact-persistence.test.ts +++ b/packages/core/src/services/session-artifact-persistence.test.ts @@ -336,7 +336,7 @@ describe('session artifact persistence records', () => { expect(snapshot?.artifacts[0]).not.toHaveProperty('persistenceWarning'); }); - it('drops persisted client ids during restore normalization', () => { + it('preserves persisted client ids during restore normalization', () => { const restored = { ...artifact('s1', 'https://example.com/client-owned'), clientId: 'client-a', @@ -354,7 +354,7 @@ describe('session artifact persistence records', () => { }), ]); - expect(snapshot?.artifacts[0]).not.toHaveProperty('clientId'); + expect(snapshot?.artifacts[0]).toMatchObject({ clientId: 'client-a' }); }); it('remaps forked payloads to the new session without carrying pinned content', () => { diff --git a/packages/core/src/services/session-artifact-persistence.ts b/packages/core/src/services/session-artifact-persistence.ts index 8803623eba4..e92d0839456 100644 --- a/packages/core/src/services/session-artifact-persistence.ts +++ b/packages/core/src/services/session-artifact-persistence.ts @@ -79,6 +79,7 @@ export interface PersistedSessionArtifact { toolCallId?: string; toolName?: string; hookEventName?: string; + clientId?: string; } export type SessionArtifactPersistedChangeAction = @@ -470,6 +471,7 @@ function normalizePersistedArtifact( const toolCallId = getString(value, 'toolCallId'); const toolName = getString(value, 'toolName'); const hookEventName = getString(value, 'hookEventName'); + const clientId = getString(value, 'clientId'); return { id, kind, @@ -494,6 +496,7 @@ function normalizePersistedArtifact( ...(toolCallId ? { toolCallId } : {}), ...(toolName ? { toolName } : {}), ...(hookEventName ? { hookEventName } : {}), + ...(clientId ? { clientId } : {}), }; } From 812c0634ef21082a3e039d516c7fe882b8f1f23d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Mon, 6 Jul 2026 13:27:45 +0800 Subject: [PATCH 45/61] fix(daemon): close artifact persistence review gaps --- packages/acp-bridge/src/bridge.test.ts | 68 ++++++++++++++++++- packages/acp-bridge/src/bridge.ts | 30 +++++--- .../acp-bridge/src/sessionArtifacts.test.ts | 34 ++++++++++ packages/acp-bridge/src/sessionArtifacts.ts | 10 ++- packages/cli/src/acp-integration/acpAgent.ts | 19 +++++- .../core/src/services/sessionService.test.ts | 64 +++++++++++++++++ packages/core/src/services/sessionService.ts | 12 +++- 7 files changed, 218 insertions(+), 19 deletions(-) diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index a31defc635f..588567bc77b 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -1331,7 +1331,7 @@ describe('createAcpSessionBridge', () => { await bridge.shutdown(); }); - it('clears durable artifacts but keeps live ephemerals when rewind returns no artifact snapshot', async () => { + it('clears durable artifacts but keeps live ephemerals when rewind returns an empty artifact snapshot', async () => { const persistedSnapshots: unknown[] = []; const bridge = makeBridge({ channelFactory: async () => @@ -1348,6 +1348,15 @@ describe('createAcpSessionBridge', () => { targetTurnIndex: 0, filesChanged: [], filesFailed: [], + artifactSnapshot: { + v: SESSION_ARTIFACT_PERSISTENCE_VERSION, + sessionId: SESS_A, + sequence: 0, + artifacts: [], + tombstonedIds: [], + stickyEphemeralIds: [], + warnings: [], + }, }; }, }).channel, @@ -1407,6 +1416,63 @@ describe('createAcpSessionBridge', () => { await bridge.shutdown(); }); + it('keeps durable artifacts when rewind cannot rebuild an artifact snapshot', async () => { + const persistedSnapshots: unknown[] = []; + const bridge = makeBridge({ + channelFactory: async () => + makeChannel({ + extMethodImpl: (method, params) => { + if (method === 'qwen/control/session/artifacts/persist') { + if (params['kind'] === 'snapshot') { + persistedSnapshots.push(params['payload']); + } + return {}; + } + expect(method).toBe('qwen/control/session/rewind'); + return { + targetTurnIndex: 0, + filesChanged: [], + filesFailed: [], + artifactSnapshotUnavailable: 'artifact journal read failed', + }; + }, + }).channel, + }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const durable = await bridge.addSessionArtifact( + session.sessionId, + { + title: 'Durable artifact', + url: 'https://example.com/durable-after-failed-rebuild', + }, + { clientId: session.clientId }, + ); + + await expect( + bridge.rewindSession( + session.sessionId, + { promptId: 'prompt-1' }, + { clientId: session.clientId }, + ), + ).resolves.toMatchObject({ targetTurnIndex: 0 }); + + await expect( + bridge.getSessionArtifacts(session.sessionId), + ).resolves.toEqual( + expect.objectContaining({ + artifacts: [ + expect.objectContaining({ + id: durable.changes[0]?.artifactId, + title: 'Durable artifact', + }), + ], + }), + ); + expect(persistedSnapshots).toEqual([]); + + await bridge.shutdown(); + }); + it('restores and persists artifact snapshots returned by rewind', async () => { const retainedUrl = 'https://example.com/retained'; const rewoundUrl = 'https://example.com/rewound'; diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index a79bed3aa72..06e7c4e09dd 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -2857,6 +2857,14 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { return undefined; }; + const artifactSnapshotUnavailableReason = ( + state: BridgeSessionState, + ): string | undefined => { + const reason = (state as { artifactSnapshotUnavailable?: unknown }) + .artifactSnapshotUnavailable; + return typeof reason === 'string' && reason ? reason : undefined; + }; + async function restoreSession( action: 'load' | 'resume', req: BridgeRestoreSessionRequest, @@ -4567,9 +4575,6 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { const entry = byId.get(sessionId); if (!entry) throw new SessionNotFoundError(sessionId); const clientId = resolveTrustedClientId(entry, context?.clientId); - if (!(await entry.artifacts.get(artifactId))) { - return { v: 1, sessionId, changes: [] }; - } const result = await entry.artifacts.remove(artifactId, { clientId }); publishArtifactChanges(entry, result.changes, clientId); const warnings = [...(result.warnings ?? [])]; @@ -5927,14 +5932,21 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { const artifactSnapshot = restoredArtifactSnapshotFromState( response as BridgeSessionState, ); + const artifactSnapshotUnavailable = artifactSnapshotUnavailableReason( + response as BridgeSessionState, + ); const beforeArtifacts = (await entry.artifacts.list()).artifacts; const shouldRecordArtifactSnapshot = - artifactSnapshot !== undefined || - beforeArtifacts.some((artifact) => artifact.retention !== 'ephemeral'); - const artifactRestoreWarnings = await entry.artifacts.restore( - artifactSnapshot, - { preserveLiveEphemeral: true }, - ); + artifactSnapshot !== undefined && + artifactSnapshotUnavailable === undefined; + const artifactRestoreWarnings = + artifactSnapshotUnavailable !== undefined + ? [ + `artifact snapshot rebuild unavailable during rewind: ${artifactSnapshotUnavailable}`, + ] + : await entry.artifacts.restore(artifactSnapshot, { + preserveLiveEphemeral: true, + }); const artifactSnapshotWarnings = shouldRecordArtifactSnapshot ? await entry.artifacts.recordSnapshot() : []; diff --git a/packages/acp-bridge/src/sessionArtifacts.test.ts b/packages/acp-bridge/src/sessionArtifacts.test.ts index 3052b6ef87a..073319b8a31 100644 --- a/packages/acp-bridge/src/sessionArtifacts.test.ts +++ b/packages/acp-bridge/src/sessionArtifacts.test.ts @@ -125,6 +125,33 @@ describe('SessionArtifactStore', () => { expect(missing).not.toHaveProperty('sizeBytes'); }); + it('does not count injected workspace hash metadata against the user metadata limit', async () => { + const store = new SessionArtifactStore({ + sessionId: 's1-workspace-metadata-budget', + workspaceCwd: workspace, + }); + await fs.writeFile(path.join(workspace, 'budget.txt'), 'budget'); + const metadata = { payload: 'x'.repeat(4096) }; + while (Buffer.byteLength(JSON.stringify(metadata), 'utf8') > 4096) { + metadata.payload = metadata.payload.slice(0, -1); + } + + const created = await store.upsertMany( + [{ title: 'Budget', workspacePath: 'budget.txt', metadata }], + { strict: true }, + ); + + expect(created.changes[0]?.artifact).toMatchObject({ + metadata: { + payload: metadata.payload, + 'qwen.workspace.sha256': createHash('sha256') + .update('budget') + .digest('hex'), + 'qwen.workspace.mtimeMs': expect.any(Number), + }, + }); + }); + it('prevents one client from removing another client retained artifact', async () => { const store = new SessionArtifactStore({ sessionId: 's1-client-owner', @@ -2562,6 +2589,13 @@ describe('SessionArtifactStore', () => { await expect(store.list()).resolves.toMatchObject({ artifacts: [], }); + const replay = await store.upsertMany([ + { title: 'Sensitive', url: 'https://example.com/sensitive' }, + ]); + expect(replay.changes).toEqual([]); + await expect(store.list()).resolves.toMatchObject({ + artifacts: [], + }); }); it('restores rebuilt durable artifacts as metadata-only restored entries', async () => { diff --git a/packages/acp-bridge/src/sessionArtifacts.ts b/packages/acp-bridge/src/sessionArtifacts.ts index 234f95fd80e..a0b13680dc6 100644 --- a/packages/acp-bridge/src/sessionArtifacts.ts +++ b/packages/acp-bridge/src/sessionArtifacts.ts @@ -806,6 +806,10 @@ export class SessionArtifactStore { for (const change of changes) { if (change.action === 'removed') { removalNotPersisted = true; + if (change.reason === 'explicit') { + this.rememberTombstone(change); + this.stickyEphemeralIds.delete(change.artifactId); + } continue; } const stored = this.artifacts.get(change.artifactId); @@ -2221,12 +2225,6 @@ function withWorkspaceContentHashMetadata( ? { [WORKSPACE_CONTENT_MTIME_MS_METADATA_KEY]: workspaceStatus.mtimeMs } : {}), }; - if (Buffer.byteLength(JSON.stringify(next), 'utf8') > 4096) { - throw new SessionArtifactValidationError( - 'metadata must be 4096 bytes or fewer', - 'metadata', - ); - } return next; } diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 9b25f49e248..35c2cf43e6c 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -66,6 +66,7 @@ import { matchesAnyServerPattern, IMAGE_CAPABILITY, registerAcpEventLoopLagGauge, + SESSION_ARTIFACT_PERSISTENCE_VERSION, startEventLoopLagMonitor, } from '@qwen-code/qwen-code-core'; import { randomUUID } from 'node:crypto'; @@ -7381,6 +7382,7 @@ class QwenAgent implements Agent { } } let artifactSnapshot: unknown; + let artifactSnapshotUnavailable: string | undefined; try { await session.getConfig().getChatRecordingService()?.flush(); const cwd = session.getConfig().getProjectRoot(); @@ -7392,11 +7394,21 @@ class QwenAgent implements Agent { return sessionService.loadSession(sessionId); }, ); - artifactSnapshot = sessionData?.artifactSnapshot; + artifactSnapshot = sessionData?.artifactSnapshot ?? { + v: SESSION_ARTIFACT_PERSISTENCE_VERSION, + sessionId, + sequence: 0, + artifacts: [], + tombstonedIds: [], + stickyEphemeralIds: [], + warnings: [], + }; } catch (err) { + artifactSnapshotUnavailable = + err instanceof Error ? err.message : String(err); debugLogger.warn( `[ACP] Failed to rebuild artifact snapshot after rewind for session=${sessionId}: ${ - err instanceof Error ? err.message : String(err) + artifactSnapshotUnavailable }`, ); } @@ -7408,6 +7420,9 @@ class QwenAgent implements Agent { filesChanged, filesFailed, ...(artifactSnapshot ? { artifactSnapshot } : {}), + ...(artifactSnapshotUnavailable + ? { artifactSnapshotUnavailable } + : {}), }; } case 'qwen/session/loadUpdates': { diff --git a/packages/core/src/services/sessionService.test.ts b/packages/core/src/services/sessionService.test.ts index e1a7a03c54b..3e520d04e1e 100644 --- a/packages/core/src/services/sessionService.test.ts +++ b/packages/core/src/services/sessionService.test.ts @@ -535,6 +535,70 @@ describe('SessionService', () => { ]); }); + it('does not treat trailing artifact side records as the conversation leaf', async () => { + const now = Date.now(); + statSyncSpy.mockReturnValue({ + mtimeMs: now, + isFile: () => true, + } as fs.Stats); + const artifactId = stableSessionArtifactId( + sessionIdB, + 'url:https://example.com/trailing-report', + ); + const artifactRecord: ChatRecord = { + ...recordB2, + uuid: 'artifact-tail', + parentUuid: 'b2', + type: 'system', + subtype: 'session_artifact_event', + message: undefined, + systemPayload: { + v: SESSION_ARTIFACT_PERSISTENCE_VERSION, + sessionId: sessionIdB, + sequence: 1, + recordedAt: '2026-07-06T00:00:00.000Z', + changes: [ + { + action: 'created', + artifactId, + artifact: { + id: artifactId, + kind: 'link', + storage: 'external_url', + source: 'client', + status: 'available', + title: 'Trailing report', + url: 'https://example.com/trailing-report', + retention: 'restorable', + clientRetained: true, + createdAt: '2026-07-06T00:00:00.000Z', + updatedAt: '2026-07-06T00:00:00.000Z', + persistedAt: '2026-07-06T00:00:00.000Z', + }, + }, + ], + }, + }; + vi.mocked(jsonl.read).mockResolvedValue([ + recordB1, + recordB2, + artifactRecord, + ]); + + const loaded = await sessionService.loadSession(sessionIdB); + + expect( + loaded?.conversation.messages.map((record) => record.uuid), + ).toEqual(['b1', 'b2']); + expect(loaded?.lastCompletedUuid).toBe('b2'); + expect(loaded?.artifactSnapshot?.artifacts).toEqual([ + expect.objectContaining({ + id: artifactId, + title: 'Trailing report', + }), + ]); + }); + it('keeps the latest file history snapshot for a prompt id', async () => { const now = Date.now(); statSyncSpy.mockReturnValue({ diff --git a/packages/core/src/services/sessionService.ts b/packages/core/src/services/sessionService.ts index 77547f59179..52864984f20 100644 --- a/packages/core/src/services/sessionService.ts +++ b/packages/core/src/services/sessionService.ts @@ -945,7 +945,7 @@ export class SessionService { } let currentUuid: string | null = - leafUuid ?? records[records.length - 1].uuid; + leafUuid ?? this.lastConversationRecordUuid(records); const uuidChain: string[] = []; const visited = new Set(); @@ -969,6 +969,16 @@ export class SessionService { return messages; } + private lastConversationRecordUuid(records: ChatRecord[]): string | null { + for (let index = records.length - 1; index >= 0; index--) { + const record = records[index]; + if (record && !isSessionArtifactRecord(record)) { + return record.uuid; + } + } + return null; + } + /** * Loads a session by its session ID. * Reconstructs the full conversation from tree-structured records. From 7a356a174726609d7a0f831e0c756904bf9b7f27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Mon, 6 Jul 2026 14:10:21 +0800 Subject: [PATCH 46/61] test(cli): align artifact persistence CI expectations --- .../cli/src/acp-integration/acpAgent.test.ts | 20 +++++++++++++++++++ packages/cli/src/serve/server.test.ts | 14 +++++++++---- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index ceb2ddb2b2c..43a8c702e76 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -1587,6 +1587,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { reloadModelProvidersConfig: vi.fn(), refreshAuth: vi.fn().mockResolvedValue(undefined), getModel: vi.fn().mockReturnValue('m'), + getProjectRoot: vi.fn().mockReturnValue('/tmp'), getTargetDir: vi.fn().mockReturnValue('/tmp'), getContentGeneratorConfig: vi.fn().mockReturnValue({}), getAvailableModels: vi.fn().mockReturnValue([]), @@ -1601,6 +1602,9 @@ describe('QwenAgent MCP SSE/HTTP support', () => { waitForMcpReady: vi.fn().mockResolvedValue(undefined), }), getFileSystemService: vi.fn().mockReturnValue(undefined), + getChatRecordingService: vi.fn().mockReturnValue({ + flush: vi.fn().mockResolvedValue(undefined), + }), setFileSystemService: vi.fn(), getHookSystem: vi.fn().mockReturnValue(undefined), getDisableAllHooks: vi.fn().mockReturnValue(true), @@ -6406,6 +6410,21 @@ describe('QwenAgent MCP SSE/HTTP support', () => { it('rewindSession extension method rewinds the active session', async () => { const sessionId = '11111111-1111-1111-1111-111111111111'; await setupSessionMocks(sessionId); + const artifactSnapshot = { + v: 1, + sessionId, + sequence: 0, + artifacts: [], + tombstonedIds: [], + stickyEphemeralIds: [], + warnings: [], + }; + vi.mocked(SessionService).mockImplementation( + () => + ({ + loadSession: vi.fn().mockResolvedValue({ artifactSnapshot }), + }) as unknown as InstanceType, + ); const agentPromise = runAcpAgent( mockConfig, @@ -6437,6 +6456,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { apiTruncateIndex: 2, filesChanged: [], filesFailed: [], + artifactSnapshot, }); mockConnectionState.resolve(); diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 6c745435e9b..b8927b62ed2 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -7673,15 +7673,21 @@ describe('createServeApp', () => { ]); }); - it('GET /session/:id/artifacts requires a client id', async () => { + it('GET /session/:id/artifacts allows missing client id', async () => { const bridge = fakeBridge(); const app = createServeApp(tokenOpts, undefined, { bridge }); const res = await auth(request(app).get('/session/session-A/artifacts')); - expect(res.status).toBe(403); - expect(res.body.code).toBe('client_id_required'); - expect(bridge.sessionArtifactsCalls).toEqual([]); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + v: 1, + sessionId: 'session-A', + artifacts: [], + }); + expect(bridge.sessionArtifactsCalls).toEqual([ + { sessionId: 'session-A' }, + ]); }); it('GET /session/:id/artifacts returns 404 for an unknown session', async () => { From 306ae986d564764bb70f52109c79f41837897023 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Mon, 6 Jul 2026 14:17:43 +0800 Subject: [PATCH 47/61] test(cli): cover artifact persist handler --- .../cli/src/acp-integration/acpAgent.test.ts | 122 +++++++++++++++++- 1 file changed, 121 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index 43a8c702e76..cd0e6a55d55 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -1769,7 +1769,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { return innerConfig; } - it('sessionArtifactsPersist rejects a missing payload', async () => { + async function bootAcpAgent() { const agentPromise = runAcpAgent( mockConfig, makeSessionSettings(), @@ -1781,6 +1781,40 @@ describe('QwenAgent MCP SSE/HTTP support', () => { return mockConnectionState.promise; }, }) as AgentLike; + return { agent, agentPromise }; + } + + it('sessionArtifactsPersist rejects a missing session id', async () => { + const { agent, agentPromise } = await bootAcpAgent(); + + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionArtifactsPersist, { + kind: 'event', + payload: {}, + }), + ).rejects.toThrowError(/Invalid or missing sessionId/); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('sessionArtifactsPersist rejects an invalid kind', async () => { + const { agent, agentPromise } = await bootAcpAgent(); + + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionArtifactsPersist, { + sessionId: 'session-A', + kind: 'other', + payload: {}, + }), + ).rejects.toThrowError(/Invalid or missing artifact persist kind/); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('sessionArtifactsPersist rejects a missing payload', async () => { + const { agent, agentPromise } = await bootAcpAgent(); await expect( agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionArtifactsPersist, { @@ -1793,6 +1827,92 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await agentPromise; }); + it('sessionArtifactsPersist rejects a missing live session', async () => { + const { agent, agentPromise } = await bootAcpAgent(); + + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionArtifactsPersist, { + sessionId: 'session-A', + kind: 'event', + payload: {}, + }), + ).rejects.toThrowError(/Session not found for id: session-A/); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('sessionArtifactsPersist rejects when chat recording is unavailable', async () => { + const sessionId = 'session-A'; + const innerConfig = await setupSessionMocks(sessionId); + innerConfig.getChatRecordingService = vi.fn().mockReturnValue(undefined); + const { agent, agentPromise } = await bootAcpAgent(); + + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionArtifactsPersist, { + sessionId, + kind: 'event', + payload: {}, + }), + ).rejects.toThrowError(/Chat recording service unavailable/); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('sessionArtifactsPersist records artifact events and snapshots', async () => { + const sessionId = 'session-A'; + const recording = { + flush: vi.fn().mockResolvedValue(undefined), + recordSessionArtifactEvent: vi.fn().mockResolvedValue(undefined), + recordSessionArtifactSnapshot: vi.fn().mockResolvedValue(undefined), + }; + const innerConfig = await setupSessionMocks(sessionId); + innerConfig.getChatRecordingService = vi.fn().mockReturnValue(recording); + const { agent, agentPromise } = await bootAcpAgent(); + const eventPayload = { + v: 1, + sessionId, + sequence: 1, + changes: [], + }; + const snapshotPayload = { + v: 1, + sessionId, + sequence: 2, + artifacts: [], + tombstonedIds: [], + stickyEphemeralIds: [], + }; + + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionArtifactsPersist, { + sessionId, + kind: 'event', + payload: eventPayload, + }), + ).resolves.toEqual({ sessionId, persisted: true, kind: 'event' }); + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionArtifactsPersist, { + sessionId, + kind: 'snapshot', + payload: snapshotPayload, + }), + ).resolves.toEqual({ sessionId, persisted: true, kind: 'snapshot' }); + + expect(recording.recordSessionArtifactEvent).toHaveBeenCalledWith( + eventPayload, + ); + expect(recording.recordSessionArtifactSnapshot).toHaveBeenCalledWith( + snapshotPayload, + ); + + mockConnectionState.resolve(); + await agentPromise; + }); + it('status ext methods expose workspace snapshots without secrets', async () => { vi.mocked(getMCPDiscoveryState).mockReturnValue( MCPDiscoveryState.COMPLETED, From 736da6f96a86a74fe33ff6822692d69d25c30409 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Mon, 6 Jul 2026 14:23:16 +0800 Subject: [PATCH 48/61] fix(daemon): filter artifact metadata prototype keys --- .../acp-bridge/src/sessionArtifacts.test.ts | 31 ++++++++++++++++ packages/acp-bridge/src/sessionArtifacts.ts | 7 ++++ .../session-artifact-persistence.test.ts | 35 +++++++++++++++++++ .../services/session-artifact-persistence.ts | 5 +++ 4 files changed, 78 insertions(+) diff --git a/packages/acp-bridge/src/sessionArtifacts.test.ts b/packages/acp-bridge/src/sessionArtifacts.test.ts index 073319b8a31..41d0b8f3b19 100644 --- a/packages/acp-bridge/src/sessionArtifacts.test.ts +++ b/packages/acp-bridge/src/sessionArtifacts.test.ts @@ -1490,6 +1490,37 @@ describe('SessionArtifactStore', () => { expect(repeated.changes).toEqual([]); }); + it('filters prototype metadata keys without changing object prototype', async () => { + const store = new SessionArtifactStore({ + sessionId: 's5-prototype-metadata', + workspaceCwd: workspace, + }); + const metadata = JSON.parse( + '{"__proto__":null,"constructor":"blocked","prototype":"blocked","safe":"ok"}', + ) as Record; + + const created = await store.upsertMany([ + { + title: 'Link', + url: 'https://example.com/prototype-metadata', + metadata, + }, + ]); + const normalized = created.changes[0]?.artifact?.metadata; + + expect(normalized).toEqual({ safe: 'ok' }); + expect(Object.getPrototypeOf(normalized)).toBe(Object.prototype); + expect(Object.prototype.hasOwnProperty.call(normalized, '__proto__')).toBe( + false, + ); + expect( + Object.prototype.hasOwnProperty.call(normalized, 'constructor'), + ).toBe(false); + expect(Object.prototype.hasOwnProperty.call(normalized, 'prototype')).toBe( + false, + ); + }); + it('rejects non-finite metadata numbers', async () => { const store = new SessionArtifactStore({ sessionId: 's5-finite-metadata', diff --git a/packages/acp-bridge/src/sessionArtifacts.ts b/packages/acp-bridge/src/sessionArtifacts.ts index a0b13680dc6..ebd561959fa 100644 --- a/packages/acp-bridge/src/sessionArtifacts.ts +++ b/packages/acp-bridge/src/sessionArtifacts.ts @@ -2034,6 +2034,9 @@ function normalizeMetadata( } const normalized: Record = {}; for (const [key, value] of Object.entries(metadata)) { + if (isPrototypeMetadataKey(key)) { + continue; + } if (!key) { throw new SessionArtifactValidationError( 'metadata keys must not be empty', @@ -2092,6 +2095,10 @@ function normalizeMetadata( return normalized; } +function isPrototypeMetadataKey(key: string): boolean { + return key === '__proto__' || key === 'constructor' || key === 'prototype'; +} + function normalizeRetention( value: unknown, options: { persistenceAvailable: boolean }, diff --git a/packages/core/src/services/session-artifact-persistence.test.ts b/packages/core/src/services/session-artifact-persistence.test.ts index 565b332a277..2956ccb7a36 100644 --- a/packages/core/src/services/session-artifact-persistence.test.ts +++ b/packages/core/src/services/session-artifact-persistence.test.ts @@ -289,6 +289,41 @@ describe('session artifact persistence records', () => { expect(restored?.artifacts[0]).not.toHaveProperty('metadata'); }); + it('filters prototype metadata keys during restore normalization', () => { + const restored = rebuildSessionArtifactSnapshot([ + event({ + v: SESSION_ARTIFACT_PERSISTENCE_VERSION, + sessionId: 's1', + sequence: 1, + recordedAt: '2026-07-04T00:00:00.000Z', + changes: [ + { + action: 'created', + artifactId: 'prototype-metadata', + artifact: artifact('s1', 'https://example.com/prototype', { + metadata: JSON.parse( + '{"__proto__":null,"constructor":"blocked","prototype":"blocked","safe":"ok"}', + ) as Record, + }), + }, + ], + }), + ]); + const metadata = restored?.artifacts[0]?.metadata; + + expect(metadata).toEqual({ safe: 'ok' }); + expect(Object.getPrototypeOf(metadata)).toBe(Object.prototype); + expect(Object.prototype.hasOwnProperty.call(metadata, '__proto__')).toBe( + false, + ); + expect(Object.prototype.hasOwnProperty.call(metadata, 'constructor')).toBe( + false, + ); + expect(Object.prototype.hasOwnProperty.call(metadata, 'prototype')).toBe( + false, + ); + }); + it('drops malformed content refs during restore normalization', () => { const pinned = artifact('s1', 'https://example.com/pinned', { retention: 'pinned', diff --git a/packages/core/src/services/session-artifact-persistence.ts b/packages/core/src/services/session-artifact-persistence.ts index e92d0839456..56051eab447 100644 --- a/packages/core/src/services/session-artifact-persistence.ts +++ b/packages/core/src/services/session-artifact-persistence.ts @@ -529,6 +529,7 @@ function normalizeMetadata( if (!isRecord(value)) return undefined; const normalized: Record = {}; for (const [key, item] of Object.entries(value)) { + if (isPrototypeMetadataKey(key)) continue; if ( item === null || typeof item === 'string' || @@ -546,6 +547,10 @@ function normalizeMetadata( return normalized; } +function isPrototypeMetadataKey(key: string): boolean { + return key === '__proto__' || key === 'constructor' || key === 'prototype'; +} + function normalizeLiteral( value: unknown, allowed: readonly T[], From a1e83315830a9a76a9ab8b1b953d44babb766ac8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Mon, 6 Jul 2026 14:34:42 +0800 Subject: [PATCH 49/61] fix(daemon): restore artifact workspace baselines --- .../acp-bridge/src/sessionArtifacts.test.ts | 144 ++++++++++++++++-- packages/acp-bridge/src/sessionArtifacts.ts | 127 +++++++++++++-- .../session-artifact-persistence.test.ts | 35 +++++ .../services/session-artifact-persistence.ts | 28 +++- .../core/src/services/sessionService.test.ts | 16 ++ packages/core/src/services/sessionService.ts | 16 +- 6 files changed, 330 insertions(+), 36 deletions(-) diff --git a/packages/acp-bridge/src/sessionArtifacts.test.ts b/packages/acp-bridge/src/sessionArtifacts.test.ts index 41d0b8f3b19..64586084543 100644 --- a/packages/acp-bridge/src/sessionArtifacts.test.ts +++ b/packages/acp-bridge/src/sessionArtifacts.test.ts @@ -215,16 +215,19 @@ describe('SessionArtifactStore', () => { const artifactId = created.changes[0]!.artifactId; await expect( - store.upsertMany([ - { - title: 'Client B rewrite', - source: 'client', - clientId: 'client-b', - url: 'https://example.com/client-owned', - metadata: { owner: 'b' }, - retention: 'restorable', - }, - ]), + store.upsertMany( + [ + { + title: 'Client B rewrite', + source: 'client', + clientId: 'client-b', + url: 'https://example.com/client-owned', + metadata: { owner: 'b' }, + retention: 'restorable', + }, + ], + { strict: true }, + ), ).rejects.toBeInstanceOf(SessionArtifactAuthorizationError); await expect(store.list()).resolves.toMatchObject({ @@ -240,6 +243,59 @@ describe('SessionArtifactStore', () => { }); }); + it('skips cross-client upsert conflicts without dropping the batch', async () => { + const store = new SessionArtifactStore({ + sessionId: 's1-client-upsert-owner-batch', + workspaceCwd: workspace, + }); + + const owned = await store.upsertMany([ + { + title: 'Client A link', + source: 'client', + clientId: 'client-a', + url: 'https://example.com/client-owned-batch', + metadata: { owner: 'a' }, + }, + ]); + const ownedId = owned.changes[0]!.artifactId; + + const result = await store.upsertMany([ + { + title: 'Client B rewrite', + source: 'client', + clientId: 'client-b', + url: 'https://example.com/client-owned-batch', + metadata: { owner: 'b' }, + retention: 'restorable', + }, + { + title: 'Tool output', + url: 'https://example.com/tool-output', + }, + ]); + + expect(result.changes).toMatchObject([ + { + action: 'created', + artifact: { title: 'Tool output' }, + }, + ]); + await expect(store.list()).resolves.toMatchObject({ + artifacts: [ + { + id: ownedId, + title: 'Client A link', + clientId: 'client-a', + metadata: { owner: 'a' }, + }, + { + title: 'Tool output', + }, + ], + }); + }); + it('writes client ids into durable artifact records', async () => { const events: SessionArtifactEventRecordPayload[] = []; const store = new SessionArtifactStore({ @@ -2894,4 +2950,72 @@ describe('SessionArtifactStore', () => { expect(restoredArtifact).not.toHaveProperty('contentRef'); expect(restoredArtifact).not.toHaveProperty('expiresAt'); }); + + it('restores workspace metadata near the user budget without replacing the persisted hash', async () => { + const sessionId = 's11-restore-workspace-baseline'; + const workspacePath = 'baseline.txt'; + await fs.writeFile(path.join(workspace, workspacePath), 'HELLO'); + const persistedSha = createHash('sha256').update('hello').digest('hex'); + const metadata = { + payload: 'x'.repeat(4096), + 'qwen.workspace.sha256': persistedSha, + 'qwen.workspace.mtimeMs': 0, + }; + while ( + Buffer.byteLength(JSON.stringify({ payload: metadata.payload }), 'utf8') > + 4096 + ) { + metadata.payload = metadata.payload.slice(0, -1); + } + const artifactId = stableSessionArtifactId( + sessionId, + `workspace:${workspacePath}`, + ); + const store = new SessionArtifactStore({ + sessionId, + workspaceCwd: workspace, + }); + + await expect( + store.restore({ + v: 2, + sessionId, + sequence: 1, + artifacts: [ + { + id: artifactId, + kind: 'file', + storage: 'workspace', + source: 'tool', + status: 'available', + title: 'Baseline', + workspacePath, + sizeBytes: 5, + metadata, + retention: 'restorable', + clientRetained: false, + createdAt: '2026-07-04T00:00:00.000Z', + updatedAt: '2026-07-04T00:00:00.000Z', + }, + ], + tombstonedIds: [], + stickyEphemeralIds: [], + warnings: [], + }), + ).resolves.toEqual([]); + + await expect(store.list()).resolves.toMatchObject({ + artifacts: [ + { + id: artifactId, + status: 'changed', + metadata: { + payload: metadata.payload, + 'qwen.workspace.sha256': persistedSha, + 'qwen.workspace.mtimeMs': 0, + }, + }, + ], + }); + }); }); diff --git a/packages/acp-bridge/src/sessionArtifacts.ts b/packages/acp-bridge/src/sessionArtifacts.ts index ebd561959fa..9a58fbe148c 100644 --- a/packages/acp-bridge/src/sessionArtifacts.ts +++ b/packages/acp-bridge/src/sessionArtifacts.ts @@ -199,6 +199,12 @@ interface StoredArtifact extends NormalizedArtifact { insertSeq: number; } +interface WorkspaceStatusExpected { + sizeBytes?: number; + mtimeMs?: string | number | boolean | null; + sha256?: string | number | boolean | null; +} + export class SessionArtifactStore { private readonly sessionId: string; private readonly workspaceCwd: string; @@ -325,9 +331,19 @@ export class SessionArtifactStore { continue; } - this.denyCrossClientMutation('upsert', existing.id, existing, { - clientId: artifact.clientId, - }); + try { + this.denyCrossClientMutation('upsert', existing.id, existing, { + clientId: artifact.clientId, + }); + } catch (error) { + if (validationStrict) { + throw error; + } + if (error instanceof SessionArtifactAuthorizationError) { + continue; + } + throw error; + } const updated = mergeArtifact(existing, artifact); if (updated.changed) { if (updated.artifact.id !== existing.id) { @@ -509,6 +525,10 @@ export class SessionArtifactStore { ++this.receivedSeq, artifact.storage === 'published' && !isFileArtifactUrl(artifact.url), + { + metadataBudget: 'persisted', + workspaceExpected: workspaceExpectedFromArtifact(artifact), + }, ); if ( this.stickyEphemeralIds.has(normalized.id) && @@ -906,6 +926,10 @@ export class SessionArtifactStore { input: RestoreSessionArtifactInput, receivedSeq: number, trustedPublisherFromCaller: boolean, + options: { + metadataBudget?: 'user' | 'persisted'; + workspaceExpected?: WorkspaceStatusExpected; + } = {}, ): Promise { if (!input || typeof input !== 'object') { throw new SessionArtifactValidationError('Artifact must be an object'); @@ -952,7 +976,10 @@ export class SessionArtifactStore { persistenceAvailable: this.persistence !== undefined, }); const workspaceStatus = workspacePath - ? await this.getInitialWorkspaceStatus(workspacePath) + ? await this.getInitialWorkspaceStatus( + workspacePath, + options.workspaceExpected, + ) : undefined; if (workspaceStatus?.escaped) { throw new SessionArtifactValidationError( @@ -961,7 +988,9 @@ export class SessionArtifactStore { ); } const metadata = withWorkspaceContentHashMetadata( - normalizeMetadata(input.metadata), + normalizeMetadata(input.metadata, { + budget: options.metadataBudget ?? 'user', + }), workspaceStatus, ); const kind = normalizeKind( @@ -1071,7 +1100,10 @@ export class SessionArtifactStore { }; } - private async getInitialWorkspaceStatus(workspacePath: string): Promise<{ + private async getInitialWorkspaceStatus( + workspacePath: string, + expected?: WorkspaceStatusExpected, + ): Promise<{ status: DaemonSessionArtifactStatus; sizeBytes?: number; sha256?: string; @@ -1083,6 +1115,7 @@ export class SessionArtifactStore { this.workspaceCwd, workspacePath, this.getRealWorkspaceCwd(), + expected, ); } catch (error) { const reason = error instanceof Error ? error.message : String(error); @@ -1108,6 +1141,7 @@ export class SessionArtifactStore { { sizeBytes: artifact.sizeBytes, mtimeMs: artifact.metadata?.[WORKSPACE_CONTENT_MTIME_MS_METADATA_KEY], + sha256: artifact.metadata?.[WORKSPACE_CONTENT_SHA256_METADATA_KEY], }, ); const changed = isWorkspaceContentChanged(artifact, status); @@ -1635,6 +1669,19 @@ function persistedArtifactToInput( }; } +function workspaceExpectedFromArtifact( + artifact: PersistedSessionArtifact, +): WorkspaceStatusExpected | undefined { + if (!artifact.workspacePath) { + return undefined; + } + return { + sizeBytes: artifact.sizeBytes, + mtimeMs: artifact.metadata?.[WORKSPACE_CONTENT_MTIME_MS_METADATA_KEY], + sha256: artifact.metadata?.[WORKSPACE_CONTENT_SHA256_METADATA_KEY], + }; +} + function toPersistedChange( change: SessionArtifactChange, recordedAt: string, @@ -2018,6 +2065,7 @@ function normalizeArtifactUrl(raw: unknown, allowFile: boolean): string { function normalizeMetadata( metadata: unknown, + options: { budget?: 'user' | 'persisted' } = {}, ): Record | undefined { if (metadata === undefined) { return undefined; @@ -2086,7 +2134,7 @@ function normalizeMetadata( if (Object.keys(normalized).length === 0) { return undefined; } - if (Buffer.byteLength(JSON.stringify(normalized), 'utf8') > 4096) { + if (metadataBudgetBytes(normalized, options.budget ?? 'user') > 4096) { throw new SessionArtifactValidationError( 'metadata must be 4096 bytes or fewer', 'metadata', @@ -2099,6 +2147,34 @@ function isPrototypeMetadataKey(key: string): boolean { return key === '__proto__' || key === 'constructor' || key === 'prototype'; } +function metadataBudgetBytes( + metadata: Record, + budget: 'user' | 'persisted', +): number { + if (budget === 'user') { + return Buffer.byteLength(JSON.stringify(metadata), 'utf8'); + } + const userMetadata = Object.fromEntries( + Object.entries(metadata).filter( + ([key, value]) => !isWorkspaceContentMetadataEntry(key, value), + ), + ); + return Buffer.byteLength(JSON.stringify(userMetadata), 'utf8'); +} + +function isWorkspaceContentMetadataEntry( + key: string, + value: string | number | boolean | null, +): boolean { + if (key === WORKSPACE_CONTENT_SHA256_METADATA_KEY) { + return typeof value === 'string' && /^[0-9a-f]{64}$/.test(value); + } + if (key === WORKSPACE_CONTENT_MTIME_MS_METADATA_KEY) { + return typeof value === 'number' && Number.isFinite(value); + } + return false; +} + function normalizeRetention( value: unknown, options: { persistenceAvailable: boolean }, @@ -2156,10 +2232,7 @@ async function getWorkspaceStatus( workspaceCwd: string, workspacePath: string, realWorkspaceCwd: Promise, - expected?: { - sizeBytes?: number; - mtimeMs?: string | number | boolean | null; - }, + expected?: WorkspaceStatusExpected, ): Promise<{ status: DaemonSessionArtifactStatus; sizeBytes?: number; @@ -2177,17 +2250,41 @@ async function getWorkspaceStatus( } const stat = await fs.stat(realPath); if (stat.isFile()) { + const expectedMtimeMs = + typeof expected?.mtimeMs === 'number' ? expected.mtimeMs : undefined; + const expectedSha256 = + typeof expected?.sha256 === 'string' ? expected.sha256 : undefined; const unchanged = - expected?.sizeBytes === stat.size && expected.mtimeMs === stat.mtimeMs; + expected?.sizeBytes === stat.size && expectedMtimeMs === stat.mtimeMs; const sizeChanged = expected?.sizeBytes !== undefined && expected.sizeBytes !== stat.size; + if (sizeChanged) { + return { + status: 'changed', + sizeBytes: stat.size, + mtimeMs: stat.mtimeMs, + }; + } + if (unchanged) { + return { + status: 'available', + sizeBytes: stat.size, + mtimeMs: stat.mtimeMs, + }; + } + const sha256 = await hashFile(realPath); + if (expectedSha256 && sha256 !== expectedSha256) { + return { + status: 'changed', + sizeBytes: stat.size, + mtimeMs: stat.mtimeMs, + }; + } return { status: 'available', sizeBytes: stat.size, mtimeMs: stat.mtimeMs, - ...(unchanged || sizeChanged - ? {} - : { sha256: await hashFile(realPath) }), + sha256, }; } return { diff --git a/packages/core/src/services/session-artifact-persistence.test.ts b/packages/core/src/services/session-artifact-persistence.test.ts index 2956ccb7a36..51682a92bc3 100644 --- a/packages/core/src/services/session-artifact-persistence.test.ts +++ b/packages/core/src/services/session-artifact-persistence.test.ts @@ -392,6 +392,41 @@ describe('session artifact persistence records', () => { expect(snapshot?.artifacts[0]).toMatchObject({ clientId: 'client-a' }); }); + it('preserves near-limit user metadata with workspace hash metadata', () => { + const metadata = { + payload: 'x'.repeat(4096), + 'qwen.workspace.sha256': 'a'.repeat(64), + 'qwen.workspace.mtimeMs': 123, + }; + while ( + Buffer.byteLength(JSON.stringify({ payload: metadata.payload }), 'utf8') > + 4096 + ) { + metadata.payload = metadata.payload.slice(0, -1); + } + const restored = artifact('s1', 'https://example.com/workspace-budget', { + storage: 'workspace', + workspacePath: 'budget.txt', + url: undefined, + sizeBytes: 6, + metadata, + }); + + const snapshot = rebuildSessionArtifactSnapshot([ + event({ + v: SESSION_ARTIFACT_PERSISTENCE_VERSION, + sessionId: 's1', + sequence: 1, + recordedAt: '2026-07-04T00:00:00.000Z', + changes: [ + { action: 'created', artifactId: restored.id, artifact: restored }, + ], + }), + ]); + + expect(snapshot?.artifacts[0]?.metadata).toMatchObject(metadata); + }); + it('remaps forked payloads to the new session without carrying pinned content', () => { const source = artifact('source-session', 'https://example.com/report', { retention: 'pinned', diff --git a/packages/core/src/services/session-artifact-persistence.ts b/packages/core/src/services/session-artifact-persistence.ts index 56051eab447..482a5ce7c02 100644 --- a/packages/core/src/services/session-artifact-persistence.ts +++ b/packages/core/src/services/session-artifact-persistence.ts @@ -8,6 +8,8 @@ import { createHash } from 'node:crypto'; export const SESSION_ARTIFACT_PERSISTENCE_VERSION = 2 as const; const CONTENT_ID_PATTERN = /^[0-9a-f]{64}-[0-9a-f]{16}$/; +const WORKSPACE_CONTENT_SHA256_METADATA_KEY = 'qwen.workspace.sha256'; +const WORKSPACE_CONTENT_MTIME_MS_METADATA_KEY = 'qwen.workspace.mtimeMs'; export type SessionArtifactRetention = 'ephemeral' | 'restorable' | 'pinned'; @@ -540,7 +542,7 @@ function normalizeMetadata( } } if (Object.keys(normalized).length === 0) return undefined; - if (Buffer.byteLength(JSON.stringify(normalized), 'utf8') > 4096) { + if (metadataBudgetBytes(normalized) > 4096) { warnings.push(`skipped oversized metadata for artifact ${artifactId}`); return undefined; } @@ -551,6 +553,30 @@ function isPrototypeMetadataKey(key: string): boolean { return key === '__proto__' || key === 'constructor' || key === 'prototype'; } +function metadataBudgetBytes( + metadata: Record, +): number { + const userMetadata = Object.fromEntries( + Object.entries(metadata).filter( + ([key, value]) => !isWorkspaceContentMetadataEntry(key, value), + ), + ); + return Buffer.byteLength(JSON.stringify(userMetadata), 'utf8'); +} + +function isWorkspaceContentMetadataEntry( + key: string, + value: string | number | boolean | null, +): boolean { + if (key === WORKSPACE_CONTENT_SHA256_METADATA_KEY) { + return typeof value === 'string' && /^[0-9a-f]{64}$/.test(value); + } + if (key === WORKSPACE_CONTENT_MTIME_MS_METADATA_KEY) { + return typeof value === 'number' && Number.isFinite(value); + } + return false; +} + function normalizeLiteral( value: unknown, allowed: readonly T[], diff --git a/packages/core/src/services/sessionService.test.ts b/packages/core/src/services/sessionService.test.ts index 3e520d04e1e..12e150ddd52 100644 --- a/packages/core/src/services/sessionService.test.ts +++ b/packages/core/src/services/sessionService.test.ts @@ -2695,8 +2695,24 @@ describe('SessionService', () => { const result = await service.forkSession(oldId, newId); const loaded = await service.loadSession(newId); + const forkedLines = fs + .readFileSync(result.filePath, 'utf8') + .trim() + .split('\n') + .map((line) => JSON.parse(line)); expect(result.copiedCount).toBe(3); + expect( + loaded?.conversation.messages.map((record) => record.uuid), + ).toEqual(['u1', 'u2']); + expect( + forkedLines.find((record) => record.uuid === 'artifact-1'), + ).toMatchObject({ + parentUuid: 'u1', + }); + expect(forkedLines.find((record) => record.uuid === 'u2')).toMatchObject({ + parentUuid: 'u1', + }); expect(loaded?.artifactSnapshot?.artifacts).toEqual([ expect.objectContaining({ id: stableSessionArtifactId(newId, 'url:https://example.com/forked'), diff --git a/packages/core/src/services/sessionService.ts b/packages/core/src/services/sessionService.ts index 52864984f20..76f433cbbab 100644 --- a/packages/core/src/services/sessionService.ts +++ b/packages/core/src/services/sessionService.ts @@ -36,6 +36,7 @@ import { } from '../utils/sessionStorageUtils.js'; import { getUsageOutputTokenCountForPromptEstimate } from './tokenEstimation.js'; import { + isSessionArtifactRecord, rebuildSessionArtifactSnapshot, remapSessionArtifactPayloadForFork, type RebuiltSessionArtifactSnapshot, @@ -1442,6 +1443,7 @@ export class SessionService { // message. let prevUuid: string | null = null; const forked: ChatRecord[] = sourceRecords.map((record) => { + const isArtifactRecord = isSessionArtifactRecord(record); const systemPayload = remapSystemPayloadForFork( record, sourceSessionId, @@ -1452,13 +1454,15 @@ export class SessionService { sessionId: newSessionId, cwd: this.projectRoot, systemPayload, - parentUuid: prevUuid, + parentUuid: isArtifactRecord ? record.parentUuid : prevUuid, forkedFrom: { sessionId: sourceSessionId, messageUuid: record.uuid, }, }; - prevUuid = record.uuid; + if (!isArtifactRecord) { + prevUuid = record.uuid; + } return next; }); @@ -1958,14 +1962,6 @@ function remapSystemPayloadForFork( return record.systemPayload; } -function isSessionArtifactRecord(record: ChatRecord): boolean { - return ( - record.type === 'system' && - (record.subtype === 'session_artifact_event' || - record.subtype === 'session_artifact_snapshot') - ); -} - function includeActiveSideArtifactRecords( records: ChatRecord[], activeRecords: ChatRecord[], From 054725c466b37dfc9e0fed8b01dd18db2ee2ca23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Mon, 6 Jul 2026 14:34:42 +0800 Subject: [PATCH 50/61] fix(daemon): restore artifact workspace baselines --- .../acp-bridge/src/sessionArtifacts.test.ts | 144 ++++++++++++++++-- packages/acp-bridge/src/sessionArtifacts.ts | 135 +++++++++++++--- .../session-artifact-persistence.test.ts | 35 +++++ .../services/session-artifact-persistence.ts | 28 +++- .../core/src/services/sessionService.test.ts | 16 ++ packages/core/src/services/sessionService.ts | 16 +- 6 files changed, 335 insertions(+), 39 deletions(-) diff --git a/packages/acp-bridge/src/sessionArtifacts.test.ts b/packages/acp-bridge/src/sessionArtifacts.test.ts index 41d0b8f3b19..64586084543 100644 --- a/packages/acp-bridge/src/sessionArtifacts.test.ts +++ b/packages/acp-bridge/src/sessionArtifacts.test.ts @@ -215,16 +215,19 @@ describe('SessionArtifactStore', () => { const artifactId = created.changes[0]!.artifactId; await expect( - store.upsertMany([ - { - title: 'Client B rewrite', - source: 'client', - clientId: 'client-b', - url: 'https://example.com/client-owned', - metadata: { owner: 'b' }, - retention: 'restorable', - }, - ]), + store.upsertMany( + [ + { + title: 'Client B rewrite', + source: 'client', + clientId: 'client-b', + url: 'https://example.com/client-owned', + metadata: { owner: 'b' }, + retention: 'restorable', + }, + ], + { strict: true }, + ), ).rejects.toBeInstanceOf(SessionArtifactAuthorizationError); await expect(store.list()).resolves.toMatchObject({ @@ -240,6 +243,59 @@ describe('SessionArtifactStore', () => { }); }); + it('skips cross-client upsert conflicts without dropping the batch', async () => { + const store = new SessionArtifactStore({ + sessionId: 's1-client-upsert-owner-batch', + workspaceCwd: workspace, + }); + + const owned = await store.upsertMany([ + { + title: 'Client A link', + source: 'client', + clientId: 'client-a', + url: 'https://example.com/client-owned-batch', + metadata: { owner: 'a' }, + }, + ]); + const ownedId = owned.changes[0]!.artifactId; + + const result = await store.upsertMany([ + { + title: 'Client B rewrite', + source: 'client', + clientId: 'client-b', + url: 'https://example.com/client-owned-batch', + metadata: { owner: 'b' }, + retention: 'restorable', + }, + { + title: 'Tool output', + url: 'https://example.com/tool-output', + }, + ]); + + expect(result.changes).toMatchObject([ + { + action: 'created', + artifact: { title: 'Tool output' }, + }, + ]); + await expect(store.list()).resolves.toMatchObject({ + artifacts: [ + { + id: ownedId, + title: 'Client A link', + clientId: 'client-a', + metadata: { owner: 'a' }, + }, + { + title: 'Tool output', + }, + ], + }); + }); + it('writes client ids into durable artifact records', async () => { const events: SessionArtifactEventRecordPayload[] = []; const store = new SessionArtifactStore({ @@ -2894,4 +2950,72 @@ describe('SessionArtifactStore', () => { expect(restoredArtifact).not.toHaveProperty('contentRef'); expect(restoredArtifact).not.toHaveProperty('expiresAt'); }); + + it('restores workspace metadata near the user budget without replacing the persisted hash', async () => { + const sessionId = 's11-restore-workspace-baseline'; + const workspacePath = 'baseline.txt'; + await fs.writeFile(path.join(workspace, workspacePath), 'HELLO'); + const persistedSha = createHash('sha256').update('hello').digest('hex'); + const metadata = { + payload: 'x'.repeat(4096), + 'qwen.workspace.sha256': persistedSha, + 'qwen.workspace.mtimeMs': 0, + }; + while ( + Buffer.byteLength(JSON.stringify({ payload: metadata.payload }), 'utf8') > + 4096 + ) { + metadata.payload = metadata.payload.slice(0, -1); + } + const artifactId = stableSessionArtifactId( + sessionId, + `workspace:${workspacePath}`, + ); + const store = new SessionArtifactStore({ + sessionId, + workspaceCwd: workspace, + }); + + await expect( + store.restore({ + v: 2, + sessionId, + sequence: 1, + artifacts: [ + { + id: artifactId, + kind: 'file', + storage: 'workspace', + source: 'tool', + status: 'available', + title: 'Baseline', + workspacePath, + sizeBytes: 5, + metadata, + retention: 'restorable', + clientRetained: false, + createdAt: '2026-07-04T00:00:00.000Z', + updatedAt: '2026-07-04T00:00:00.000Z', + }, + ], + tombstonedIds: [], + stickyEphemeralIds: [], + warnings: [], + }), + ).resolves.toEqual([]); + + await expect(store.list()).resolves.toMatchObject({ + artifacts: [ + { + id: artifactId, + status: 'changed', + metadata: { + payload: metadata.payload, + 'qwen.workspace.sha256': persistedSha, + 'qwen.workspace.mtimeMs': 0, + }, + }, + ], + }); + }); }); diff --git a/packages/acp-bridge/src/sessionArtifacts.ts b/packages/acp-bridge/src/sessionArtifacts.ts index ebd561959fa..8c70eaea0d7 100644 --- a/packages/acp-bridge/src/sessionArtifacts.ts +++ b/packages/acp-bridge/src/sessionArtifacts.ts @@ -199,6 +199,12 @@ interface StoredArtifact extends NormalizedArtifact { insertSeq: number; } +interface WorkspaceStatusExpected { + sizeBytes?: number; + mtimeMs?: string | number | boolean | null; + sha256?: string | number | boolean | null; +} + export class SessionArtifactStore { private readonly sessionId: string; private readonly workspaceCwd: string; @@ -325,9 +331,19 @@ export class SessionArtifactStore { continue; } - this.denyCrossClientMutation('upsert', existing.id, existing, { - clientId: artifact.clientId, - }); + try { + this.denyCrossClientMutation('upsert', existing.id, existing, { + clientId: artifact.clientId, + }); + } catch (error) { + if (validationStrict) { + throw error; + } + if (error instanceof SessionArtifactAuthorizationError) { + continue; + } + throw error; + } const updated = mergeArtifact(existing, artifact); if (updated.changed) { if (updated.artifact.id !== existing.id) { @@ -509,6 +525,10 @@ export class SessionArtifactStore { ++this.receivedSeq, artifact.storage === 'published' && !isFileArtifactUrl(artifact.url), + { + metadataBudget: 'persisted', + workspaceExpected: workspaceExpectedFromArtifact(artifact), + }, ); if ( this.stickyEphemeralIds.has(normalized.id) && @@ -906,6 +926,10 @@ export class SessionArtifactStore { input: RestoreSessionArtifactInput, receivedSeq: number, trustedPublisherFromCaller: boolean, + options: { + metadataBudget?: 'user' | 'persisted'; + workspaceExpected?: WorkspaceStatusExpected; + } = {}, ): Promise { if (!input || typeof input !== 'object') { throw new SessionArtifactValidationError('Artifact must be an object'); @@ -952,7 +976,10 @@ export class SessionArtifactStore { persistenceAvailable: this.persistence !== undefined, }); const workspaceStatus = workspacePath - ? await this.getInitialWorkspaceStatus(workspacePath) + ? await this.getInitialWorkspaceStatus( + workspacePath, + options.workspaceExpected, + ) : undefined; if (workspaceStatus?.escaped) { throw new SessionArtifactValidationError( @@ -961,7 +988,9 @@ export class SessionArtifactStore { ); } const metadata = withWorkspaceContentHashMetadata( - normalizeMetadata(input.metadata), + normalizeMetadata(input.metadata, { + budget: options.metadataBudget ?? 'user', + }), workspaceStatus, ); const kind = normalizeKind( @@ -995,9 +1024,11 @@ export class SessionArtifactStore { url, mimeType: normalizeString(input.mimeType, 'mimeType', 120, false), sizeBytes: - input.sizeBytes !== undefined - ? normalizeSizeBytes(input.sizeBytes) - : workspaceStatus?.sizeBytes, + workspaceStatus?.sizeBytes !== undefined + ? workspaceStatus.sizeBytes + : input.sizeBytes !== undefined + ? normalizeSizeBytes(input.sizeBytes) + : workspaceStatus?.sizeBytes, metadata, retention, restoreState: 'live', @@ -1071,7 +1102,10 @@ export class SessionArtifactStore { }; } - private async getInitialWorkspaceStatus(workspacePath: string): Promise<{ + private async getInitialWorkspaceStatus( + workspacePath: string, + expected?: WorkspaceStatusExpected, + ): Promise<{ status: DaemonSessionArtifactStatus; sizeBytes?: number; sha256?: string; @@ -1083,6 +1117,7 @@ export class SessionArtifactStore { this.workspaceCwd, workspacePath, this.getRealWorkspaceCwd(), + expected, ); } catch (error) { const reason = error instanceof Error ? error.message : String(error); @@ -1108,6 +1143,7 @@ export class SessionArtifactStore { { sizeBytes: artifact.sizeBytes, mtimeMs: artifact.metadata?.[WORKSPACE_CONTENT_MTIME_MS_METADATA_KEY], + sha256: artifact.metadata?.[WORKSPACE_CONTENT_SHA256_METADATA_KEY], }, ); const changed = isWorkspaceContentChanged(artifact, status); @@ -1635,6 +1671,19 @@ function persistedArtifactToInput( }; } +function workspaceExpectedFromArtifact( + artifact: PersistedSessionArtifact, +): WorkspaceStatusExpected | undefined { + if (!artifact.workspacePath) { + return undefined; + } + return { + sizeBytes: artifact.sizeBytes, + mtimeMs: artifact.metadata?.[WORKSPACE_CONTENT_MTIME_MS_METADATA_KEY], + sha256: artifact.metadata?.[WORKSPACE_CONTENT_SHA256_METADATA_KEY], + }; +} + function toPersistedChange( change: SessionArtifactChange, recordedAt: string, @@ -2018,6 +2067,7 @@ function normalizeArtifactUrl(raw: unknown, allowFile: boolean): string { function normalizeMetadata( metadata: unknown, + options: { budget?: 'user' | 'persisted' } = {}, ): Record | undefined { if (metadata === undefined) { return undefined; @@ -2086,7 +2136,7 @@ function normalizeMetadata( if (Object.keys(normalized).length === 0) { return undefined; } - if (Buffer.byteLength(JSON.stringify(normalized), 'utf8') > 4096) { + if (metadataBudgetBytes(normalized, options.budget ?? 'user') > 4096) { throw new SessionArtifactValidationError( 'metadata must be 4096 bytes or fewer', 'metadata', @@ -2099,6 +2149,34 @@ function isPrototypeMetadataKey(key: string): boolean { return key === '__proto__' || key === 'constructor' || key === 'prototype'; } +function metadataBudgetBytes( + metadata: Record, + budget: 'user' | 'persisted', +): number { + if (budget === 'user') { + return Buffer.byteLength(JSON.stringify(metadata), 'utf8'); + } + const userMetadata = Object.fromEntries( + Object.entries(metadata).filter( + ([key, value]) => !isWorkspaceContentMetadataEntry(key, value), + ), + ); + return Buffer.byteLength(JSON.stringify(userMetadata), 'utf8'); +} + +function isWorkspaceContentMetadataEntry( + key: string, + value: string | number | boolean | null, +): boolean { + if (key === WORKSPACE_CONTENT_SHA256_METADATA_KEY) { + return typeof value === 'string' && /^[0-9a-f]{64}$/.test(value); + } + if (key === WORKSPACE_CONTENT_MTIME_MS_METADATA_KEY) { + return typeof value === 'number' && Number.isFinite(value); + } + return false; +} + function normalizeRetention( value: unknown, options: { persistenceAvailable: boolean }, @@ -2156,10 +2234,7 @@ async function getWorkspaceStatus( workspaceCwd: string, workspacePath: string, realWorkspaceCwd: Promise, - expected?: { - sizeBytes?: number; - mtimeMs?: string | number | boolean | null; - }, + expected?: WorkspaceStatusExpected, ): Promise<{ status: DaemonSessionArtifactStatus; sizeBytes?: number; @@ -2177,17 +2252,41 @@ async function getWorkspaceStatus( } const stat = await fs.stat(realPath); if (stat.isFile()) { + const expectedMtimeMs = + typeof expected?.mtimeMs === 'number' ? expected.mtimeMs : undefined; + const expectedSha256 = + typeof expected?.sha256 === 'string' ? expected.sha256 : undefined; const unchanged = - expected?.sizeBytes === stat.size && expected.mtimeMs === stat.mtimeMs; + expected?.sizeBytes === stat.size && expectedMtimeMs === stat.mtimeMs; const sizeChanged = expected?.sizeBytes !== undefined && expected.sizeBytes !== stat.size; + if (sizeChanged) { + return { + status: 'changed', + sizeBytes: stat.size, + mtimeMs: stat.mtimeMs, + }; + } + if (unchanged) { + return { + status: 'available', + sizeBytes: stat.size, + mtimeMs: stat.mtimeMs, + }; + } + const sha256 = await hashFile(realPath); + if (expectedSha256 && sha256 !== expectedSha256) { + return { + status: 'changed', + sizeBytes: stat.size, + mtimeMs: stat.mtimeMs, + }; + } return { status: 'available', sizeBytes: stat.size, mtimeMs: stat.mtimeMs, - ...(unchanged || sizeChanged - ? {} - : { sha256: await hashFile(realPath) }), + sha256, }; } return { diff --git a/packages/core/src/services/session-artifact-persistence.test.ts b/packages/core/src/services/session-artifact-persistence.test.ts index 2956ccb7a36..51682a92bc3 100644 --- a/packages/core/src/services/session-artifact-persistence.test.ts +++ b/packages/core/src/services/session-artifact-persistence.test.ts @@ -392,6 +392,41 @@ describe('session artifact persistence records', () => { expect(snapshot?.artifacts[0]).toMatchObject({ clientId: 'client-a' }); }); + it('preserves near-limit user metadata with workspace hash metadata', () => { + const metadata = { + payload: 'x'.repeat(4096), + 'qwen.workspace.sha256': 'a'.repeat(64), + 'qwen.workspace.mtimeMs': 123, + }; + while ( + Buffer.byteLength(JSON.stringify({ payload: metadata.payload }), 'utf8') > + 4096 + ) { + metadata.payload = metadata.payload.slice(0, -1); + } + const restored = artifact('s1', 'https://example.com/workspace-budget', { + storage: 'workspace', + workspacePath: 'budget.txt', + url: undefined, + sizeBytes: 6, + metadata, + }); + + const snapshot = rebuildSessionArtifactSnapshot([ + event({ + v: SESSION_ARTIFACT_PERSISTENCE_VERSION, + sessionId: 's1', + sequence: 1, + recordedAt: '2026-07-04T00:00:00.000Z', + changes: [ + { action: 'created', artifactId: restored.id, artifact: restored }, + ], + }), + ]); + + expect(snapshot?.artifacts[0]?.metadata).toMatchObject(metadata); + }); + it('remaps forked payloads to the new session without carrying pinned content', () => { const source = artifact('source-session', 'https://example.com/report', { retention: 'pinned', diff --git a/packages/core/src/services/session-artifact-persistence.ts b/packages/core/src/services/session-artifact-persistence.ts index 56051eab447..482a5ce7c02 100644 --- a/packages/core/src/services/session-artifact-persistence.ts +++ b/packages/core/src/services/session-artifact-persistence.ts @@ -8,6 +8,8 @@ import { createHash } from 'node:crypto'; export const SESSION_ARTIFACT_PERSISTENCE_VERSION = 2 as const; const CONTENT_ID_PATTERN = /^[0-9a-f]{64}-[0-9a-f]{16}$/; +const WORKSPACE_CONTENT_SHA256_METADATA_KEY = 'qwen.workspace.sha256'; +const WORKSPACE_CONTENT_MTIME_MS_METADATA_KEY = 'qwen.workspace.mtimeMs'; export type SessionArtifactRetention = 'ephemeral' | 'restorable' | 'pinned'; @@ -540,7 +542,7 @@ function normalizeMetadata( } } if (Object.keys(normalized).length === 0) return undefined; - if (Buffer.byteLength(JSON.stringify(normalized), 'utf8') > 4096) { + if (metadataBudgetBytes(normalized) > 4096) { warnings.push(`skipped oversized metadata for artifact ${artifactId}`); return undefined; } @@ -551,6 +553,30 @@ function isPrototypeMetadataKey(key: string): boolean { return key === '__proto__' || key === 'constructor' || key === 'prototype'; } +function metadataBudgetBytes( + metadata: Record, +): number { + const userMetadata = Object.fromEntries( + Object.entries(metadata).filter( + ([key, value]) => !isWorkspaceContentMetadataEntry(key, value), + ), + ); + return Buffer.byteLength(JSON.stringify(userMetadata), 'utf8'); +} + +function isWorkspaceContentMetadataEntry( + key: string, + value: string | number | boolean | null, +): boolean { + if (key === WORKSPACE_CONTENT_SHA256_METADATA_KEY) { + return typeof value === 'string' && /^[0-9a-f]{64}$/.test(value); + } + if (key === WORKSPACE_CONTENT_MTIME_MS_METADATA_KEY) { + return typeof value === 'number' && Number.isFinite(value); + } + return false; +} + function normalizeLiteral( value: unknown, allowed: readonly T[], diff --git a/packages/core/src/services/sessionService.test.ts b/packages/core/src/services/sessionService.test.ts index 3e520d04e1e..12e150ddd52 100644 --- a/packages/core/src/services/sessionService.test.ts +++ b/packages/core/src/services/sessionService.test.ts @@ -2695,8 +2695,24 @@ describe('SessionService', () => { const result = await service.forkSession(oldId, newId); const loaded = await service.loadSession(newId); + const forkedLines = fs + .readFileSync(result.filePath, 'utf8') + .trim() + .split('\n') + .map((line) => JSON.parse(line)); expect(result.copiedCount).toBe(3); + expect( + loaded?.conversation.messages.map((record) => record.uuid), + ).toEqual(['u1', 'u2']); + expect( + forkedLines.find((record) => record.uuid === 'artifact-1'), + ).toMatchObject({ + parentUuid: 'u1', + }); + expect(forkedLines.find((record) => record.uuid === 'u2')).toMatchObject({ + parentUuid: 'u1', + }); expect(loaded?.artifactSnapshot?.artifacts).toEqual([ expect.objectContaining({ id: stableSessionArtifactId(newId, 'url:https://example.com/forked'), diff --git a/packages/core/src/services/sessionService.ts b/packages/core/src/services/sessionService.ts index 52864984f20..76f433cbbab 100644 --- a/packages/core/src/services/sessionService.ts +++ b/packages/core/src/services/sessionService.ts @@ -36,6 +36,7 @@ import { } from '../utils/sessionStorageUtils.js'; import { getUsageOutputTokenCountForPromptEstimate } from './tokenEstimation.js'; import { + isSessionArtifactRecord, rebuildSessionArtifactSnapshot, remapSessionArtifactPayloadForFork, type RebuiltSessionArtifactSnapshot, @@ -1442,6 +1443,7 @@ export class SessionService { // message. let prevUuid: string | null = null; const forked: ChatRecord[] = sourceRecords.map((record) => { + const isArtifactRecord = isSessionArtifactRecord(record); const systemPayload = remapSystemPayloadForFork( record, sourceSessionId, @@ -1452,13 +1454,15 @@ export class SessionService { sessionId: newSessionId, cwd: this.projectRoot, systemPayload, - parentUuid: prevUuid, + parentUuid: isArtifactRecord ? record.parentUuid : prevUuid, forkedFrom: { sessionId: sourceSessionId, messageUuid: record.uuid, }, }; - prevUuid = record.uuid; + if (!isArtifactRecord) { + prevUuid = record.uuid; + } return next; }); @@ -1958,14 +1962,6 @@ function remapSystemPayloadForFork( return record.systemPayload; } -function isSessionArtifactRecord(record: ChatRecord): boolean { - return ( - record.type === 'system' && - (record.subtype === 'session_artifact_event' || - record.subtype === 'session_artifact_snapshot') - ); -} - function includeActiveSideArtifactRecords( records: ChatRecord[], activeRecords: ChatRecord[], From 95a54e354faa1911e0db17dfbb8bbe5af28088f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Mon, 6 Jul 2026 15:01:46 +0800 Subject: [PATCH 51/61] fix(daemon): preserve workspace metadata on merge --- .../acp-bridge/src/sessionArtifacts.test.ts | 37 +++++++++++++++++++ packages/acp-bridge/src/sessionArtifacts.ts | 2 +- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/packages/acp-bridge/src/sessionArtifacts.test.ts b/packages/acp-bridge/src/sessionArtifacts.test.ts index 64586084543..b201cac74d6 100644 --- a/packages/acp-bridge/src/sessionArtifacts.test.ts +++ b/packages/acp-bridge/src/sessionArtifacts.test.ts @@ -1102,6 +1102,43 @@ describe('SessionArtifactStore', () => { } }); + it('does not count injected workspace hash metadata against the merge limit', async () => { + const store = new SessionArtifactStore({ + sessionId: 's5-workspace-metadata-merge-budget', + workspaceCwd: workspace, + }); + const workspacePath = 'merge-budget.txt'; + const metadata = { payload: 'x'.repeat(4096) }; + while (Buffer.byteLength(JSON.stringify(metadata), 'utf8') > 4096) { + metadata.payload = metadata.payload.slice(0, -1); + } + await fs.writeFile(path.join(workspace, workspacePath), 'before'); + const oldSha = createHash('sha256').update('before').digest('hex'); + + await store.upsertMany([{ title: 'Budget', workspacePath, metadata }], { + strict: true, + }); + await fs.writeFile(path.join(workspace, workspacePath), 'after'); + const newSha = createHash('sha256').update('after').digest('hex'); + + const updated = await store.upsertMany( + [{ title: 'Budget update', workspacePath }], + { strict: true }, + ); + + expect(updated.changes[0]?.artifact).toMatchObject({ + metadata: { + payload: metadata.payload, + 'qwen.workspace.sha256': newSha, + 'qwen.workspace.mtimeMs': expect.any(Number), + }, + status: 'available', + }); + expect(updated.changes[0]?.artifact?.metadata).not.toMatchObject({ + 'qwen.workspace.sha256': oldSha, + }); + }); + it('does not merge client metadata into a published tool artifact', async () => { const store = new SessionArtifactStore({ sessionId: 's5-published-metadata-source', diff --git a/packages/acp-bridge/src/sessionArtifacts.ts b/packages/acp-bridge/src/sessionArtifacts.ts index 8c70eaea0d7..ef33e66446a 100644 --- a/packages/acp-bridge/src/sessionArtifacts.ts +++ b/packages/acp-bridge/src/sessionArtifacts.ts @@ -1510,7 +1510,7 @@ function mergeMetadata( function isMetadataWithinLimit( metadata: Record, ): boolean { - return Buffer.byteLength(JSON.stringify(metadata), 'utf8') <= 4096; + return metadataBudgetBytes(metadata, 'persisted') <= 4096; } function countByRetentionSource( From 717534af09c460788715bcda9ff0200df781ba1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Mon, 6 Jul 2026 15:41:40 +0800 Subject: [PATCH 52/61] fix(daemon): harden artifact rewind restore --- packages/acp-bridge/src/bridge.test.ts | 66 ++++++++++++ packages/acp-bridge/src/bridge.ts | 18 +++- .../acp-bridge/src/sessionArtifacts.test.ts | 72 ++++++++++++- packages/acp-bridge/src/sessionArtifacts.ts | 10 +- .../cli/src/acp-integration/acpAgent.test.ts | 69 +++++++++++- packages/cli/src/acp-integration/acpAgent.ts | 51 ++++++++- .../session-artifact-persistence.test.ts | 53 ++++++++++ .../services/session-artifact-persistence.ts | 15 ++- .../core/src/services/sessionService.test.ts | 100 ++++++++++++++++++ packages/core/src/services/sessionService.ts | 4 + 10 files changed, 436 insertions(+), 22 deletions(-) diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index 588567bc77b..14824a92d5d 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -1468,6 +1468,72 @@ describe('createAcpSessionBridge', () => { ], }), ); + expect(persistedSnapshots).toEqual([ + expect.objectContaining({ + sessionId: session.sessionId, + artifacts: [ + expect.objectContaining({ + id: durable.changes[0]?.artifactId, + title: 'Durable artifact', + }), + ], + }), + ]); + + await bridge.shutdown(); + }); + + it('keeps durable artifacts when rewind omits artifact snapshot metadata', async () => { + const persistedSnapshots: unknown[] = []; + const bridge = makeBridge({ + channelFactory: async () => + makeChannel({ + extMethodImpl: (method, params) => { + if (method === 'qwen/control/session/artifacts/persist') { + if (params['kind'] === 'snapshot') { + persistedSnapshots.push(params['payload']); + } + return {}; + } + expect(method).toBe('qwen/control/session/rewind'); + return { + targetTurnIndex: 0, + filesChanged: [], + filesFailed: [], + }; + }, + }).channel, + }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const durable = await bridge.addSessionArtifact( + session.sessionId, + { + title: 'Version-skew durable artifact', + url: 'https://example.com/durable-version-skew', + }, + { clientId: session.clientId }, + ); + + await expect( + bridge.rewindSession( + session.sessionId, + { promptId: 'prompt-1' }, + { clientId: session.clientId }, + ), + ).resolves.toMatchObject({ targetTurnIndex: 0 }); + + await expect( + bridge.getSessionArtifacts(session.sessionId), + ).resolves.toEqual( + expect.objectContaining({ + artifacts: [ + expect.objectContaining({ + id: durable.changes[0]?.artifactId, + title: 'Version-skew durable artifact', + }), + ], + }), + ); expect(persistedSnapshots).toEqual([]); await bridge.shutdown(); diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index 06e7c4e09dd..a4a50a9e5ba 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -5936,7 +5936,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { response as BridgeSessionState, ); const beforeArtifacts = (await entry.artifacts.list()).artifacts; - const shouldRecordArtifactSnapshot = + const shouldRestoreArtifactSnapshot = artifactSnapshot !== undefined && artifactSnapshotUnavailable === undefined; const artifactRestoreWarnings = @@ -5944,9 +5944,19 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { ? [ `artifact snapshot rebuild unavailable during rewind: ${artifactSnapshotUnavailable}`, ] - : await entry.artifacts.restore(artifactSnapshot, { - preserveLiveEphemeral: true, - }); + : shouldRestoreArtifactSnapshot + ? await entry.artifacts.restore(artifactSnapshot, { + preserveLiveEphemeral: true, + }) + : []; + const artifactRestoreFailed = artifactRestoreWarnings.some( + (warning) => + warning.includes('artifact snapshot restore failed') || + warning.includes('artifact snapshot restore partially failed'), + ); + const shouldRecordArtifactSnapshot = + artifactSnapshotUnavailable !== undefined || + (shouldRestoreArtifactSnapshot && !artifactRestoreFailed); const artifactSnapshotWarnings = shouldRecordArtifactSnapshot ? await entry.artifacts.recordSnapshot() : []; diff --git a/packages/acp-bridge/src/sessionArtifacts.test.ts b/packages/acp-bridge/src/sessionArtifacts.test.ts index b201cac74d6..54f95bcf1e9 100644 --- a/packages/acp-bridge/src/sessionArtifacts.test.ts +++ b/packages/acp-bridge/src/sessionArtifacts.test.ts @@ -22,6 +22,11 @@ import { type SessionArtifactSnapshotRecordPayload, } from '@qwen-code/qwen-code-core'; +vi.mock('@xterm/headless', () => ({ + Terminal: class Terminal {}, + default: { Terminal: class Terminal {} }, +})); + describe('SessionArtifactStore', () => { let workspace: string; @@ -2440,7 +2445,7 @@ describe('SessionArtifactStore', () => { }); }); - it('clears durable artifacts but preserves live ephemerals for rewind without a snapshot', async () => { + it('keeps live artifacts when rewind restore has no snapshot', async () => { const store = new SessionArtifactStore({ sessionId: 's11-restore-empty-rewind', workspaceCwd: workspace, @@ -2467,6 +2472,11 @@ describe('SessionArtifactStore', () => { ).resolves.toEqual([]); await expect(store.list()).resolves.toMatchObject({ artifacts: [ + { + id: durable.changes[0]?.artifactId, + title: 'Durable', + retention: 'restorable', + }, { id: ephemeral.changes[0]?.artifactId, title: 'Live only', @@ -2474,9 +2484,6 @@ describe('SessionArtifactStore', () => { }, ], }); - await expect(store.get(durable.changes[0]!.artifactId)).resolves.toBe( - undefined, - ); }); it('resets durable event snapshot cadence after restore', async () => { @@ -2822,6 +2829,63 @@ describe('SessionArtifactStore', () => { }); }); + it('keeps live artifacts when a non-empty restore snapshot partially fails', async () => { + const sourceEvents: SessionArtifactEventRecordPayload[] = []; + const source = new SessionArtifactStore({ + sessionId: 's11-restore-partial', + workspaceCwd: workspace, + persistence: { + recordEvent: async (payload) => { + sourceEvents.push(payload); + }, + recordSnapshot: async () => {}, + }, + }); + await source.upsertMany( + [ + { title: 'Good', url: 'https://example.com/restore-good' }, + { title: 'Bad', url: 'https://example.com/restore-bad' }, + ], + { strict: true }, + ); + const good = sourceEvents[0]!.changes[0]!.artifact!; + const bad = { + ...sourceEvents[0]!.changes[1]!.artifact!, + id: 'bad-id', + }; + const store = new SessionArtifactStore({ + sessionId: 's11-restore-partial', + workspaceCwd: workspace, + }); + const live = await store.upsertMany([ + { title: 'Live', url: 'https://example.com/live-partial' }, + ]); + const liveId = live.changes[0]!.artifactId; + + const warnings = await store.restore({ + v: 2, + sessionId: 's11-restore-partial', + sequence: 8, + artifacts: [good, bad], + tombstonedIds: [], + stickyEphemeralIds: [], + warnings: [], + }); + + expect(warnings).toEqual([ + 'skipped artifact with mismatched id bad-id', + 'artifact snapshot restore partially failed; restored 1/2 artifacts; kept existing live artifacts', + ]); + await expect(store.list()).resolves.toMatchObject({ + artifacts: [ + { + id: liveId, + title: 'Live', + }, + ], + }); + }); + it('does not trust persisted published file urls during restore', async () => { const store = new SessionArtifactStore({ sessionId: 's11-restore-published-file', diff --git a/packages/acp-bridge/src/sessionArtifacts.ts b/packages/acp-bridge/src/sessionArtifacts.ts index ef33e66446a..d1e90a34594 100644 --- a/packages/acp-bridge/src/sessionArtifacts.ts +++ b/packages/acp-bridge/src/sessionArtifacts.ts @@ -487,7 +487,7 @@ export class SessionArtifactStore { snapshot: RebuiltSessionArtifactSnapshot | undefined, options: SessionArtifactRestoreOptions = {}, ): Promise { - if (!snapshot && !options.preserveLiveEphemeral) return []; + if (!snapshot) return []; return this.enqueue(async () => { const warnings = [...(snapshot?.warnings ?? [])]; const previousState = this.cloneState(); @@ -574,13 +574,15 @@ export class SessionArtifactStore { } } if ( - (snapshot?.artifacts.length ?? 0) > 0 && - restoredCount === 0 && + snapshot.artifacts.length > 0 && + restoredCount < snapshot.artifacts.length && warnings.length > warningCountBeforeRestore ) { this.restoreState(previousState); warnings.push( - 'artifact snapshot restore failed; kept existing live artifacts', + restoredCount === 0 + ? 'artifact snapshot restore failed; kept existing live artifacts' + : `artifact snapshot restore partially failed; restored ${restoredCount}/${snapshot.artifacts.length} artifacts; kept existing live artifacts`, ); this.setLastRestoreWarnings(warnings); return warnings; diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index cd0e6a55d55..77a065df172 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -119,6 +119,7 @@ vi.mock('node:stream', async (importOriginal) => { // Mock core dependencies vi.mock('@qwen-code/qwen-code-core', () => ({ + SESSION_ARTIFACT_PERSISTENCE_VERSION: 2, createDebugLogger: () => ({ debug: vi.fn(), error: vi.fn(), @@ -629,6 +630,7 @@ import { unregisterGoalHook, startEventLoopLagMonitor, registerAcpEventLoopLagGauge, + SESSION_ARTIFACT_PERSISTENCE_VERSION, } from '@qwen-code/qwen-code-core'; import type { McpServer } from '@agentclientprotocol/sdk'; import { AgentSideConnection } from '@agentclientprotocol/sdk'; @@ -1834,7 +1836,13 @@ describe('QwenAgent MCP SSE/HTTP support', () => { agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionArtifactsPersist, { sessionId: 'session-A', kind: 'event', - payload: {}, + payload: { + v: SESSION_ARTIFACT_PERSISTENCE_VERSION, + sessionId: 'session-A', + sequence: 1, + recordedAt: '2026-07-04T00:00:00.000Z', + changes: [], + }, }), ).rejects.toThrowError(/Session not found for id: session-A/); @@ -1853,7 +1861,13 @@ describe('QwenAgent MCP SSE/HTTP support', () => { agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionArtifactsPersist, { sessionId, kind: 'event', - payload: {}, + payload: { + v: SESSION_ARTIFACT_PERSISTENCE_VERSION, + sessionId, + sequence: 1, + recordedAt: '2026-07-04T00:00:00.000Z', + changes: [], + }, }), ).rejects.toThrowError(/Chat recording service unavailable/); @@ -1872,15 +1886,17 @@ describe('QwenAgent MCP SSE/HTTP support', () => { innerConfig.getChatRecordingService = vi.fn().mockReturnValue(recording); const { agent, agentPromise } = await bootAcpAgent(); const eventPayload = { - v: 1, + v: SESSION_ARTIFACT_PERSISTENCE_VERSION, sessionId, sequence: 1, + recordedAt: '2026-07-04T00:00:00.000Z', changes: [], }; const snapshotPayload = { - v: 1, + v: SESSION_ARTIFACT_PERSISTENCE_VERSION, sessionId, sequence: 2, + recordedAt: '2026-07-04T00:00:01.000Z', artifacts: [], tombstonedIds: [], stickyEphemeralIds: [], @@ -1913,6 +1929,51 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await agentPromise; }); + it('sessionArtifactsPersist rejects malformed event and snapshot payloads', async () => { + const sessionId = 'session-A'; + const recording = { + flush: vi.fn().mockResolvedValue(undefined), + recordSessionArtifactEvent: vi.fn().mockResolvedValue(undefined), + recordSessionArtifactSnapshot: vi.fn().mockResolvedValue(undefined), + }; + const innerConfig = await setupSessionMocks(sessionId); + innerConfig.getChatRecordingService = vi.fn().mockReturnValue(recording); + const { agent, agentPromise } = await bootAcpAgent(); + + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionArtifactsPersist, { + sessionId, + kind: 'event', + payload: { + v: SESSION_ARTIFACT_PERSISTENCE_VERSION, + sessionId, + sequence: 1, + changes: [], + }, + }), + ).rejects.toThrowError(/Invalid or missing artifact persist payload/); + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionArtifactsPersist, { + sessionId, + kind: 'snapshot', + payload: { + v: SESSION_ARTIFACT_PERSISTENCE_VERSION, + sessionId, + sequence: 2, + recordedAt: '2026-07-04T00:00:01.000Z', + changes: [], + }, + }), + ).rejects.toThrowError(/Invalid or missing artifact persist payload/); + + expect(recording.recordSessionArtifactEvent).not.toHaveBeenCalled(); + expect(recording.recordSessionArtifactSnapshot).not.toHaveBeenCalled(); + + mockConnectionState.resolve(); + await agentPromise; + }); + it('status ext methods expose workspace snapshots without secrets', async () => { vi.mocked(getMCPDiscoveryState).mockReturnValue( MCPDiscoveryState.COMPLETED, diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 35c2cf43e6c..88db56ae722 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -276,6 +276,53 @@ function isObjectRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } +function parseSessionArtifactEventPayload( + payload: unknown, +): SessionArtifactEventRecordPayload { + const record = parseSessionArtifactBasePayload(payload); + if (!Array.isArray(record['changes'])) { + throw invalidArtifactPersistPayload(); + } + return record as unknown as SessionArtifactEventRecordPayload; +} + +function parseSessionArtifactSnapshotPayload( + payload: unknown, +): SessionArtifactSnapshotRecordPayload { + const record = parseSessionArtifactBasePayload(payload); + if (!Array.isArray(record['artifacts'])) { + throw invalidArtifactPersistPayload(); + } + return record as unknown as SessionArtifactSnapshotRecordPayload; +} + +function parseSessionArtifactBasePayload( + payload: unknown, +): Record { + if (!isObjectRecord(payload)) { + throw invalidArtifactPersistPayload(); + } + if ( + payload['v'] !== SESSION_ARTIFACT_PERSISTENCE_VERSION || + typeof payload['sessionId'] !== 'string' || + payload['sessionId'].length === 0 || + !Number.isSafeInteger(payload['sequence']) || + (payload['sequence'] as number) < 0 || + typeof payload['recordedAt'] !== 'string' || + payload['recordedAt'].length === 0 + ) { + throw invalidArtifactPersistPayload(); + } + return payload; +} + +function invalidArtifactPersistPayload(): Error { + return RequestError.invalidParams( + undefined, + 'Invalid or missing artifact persist payload', + ); +} + function isBulkLoadReplayRequest(params: LoadSessionRequest): boolean { const meta = isObjectRecord(params._meta) ? params._meta : undefined; return meta?.[LOAD_REPLAY_MODE_META_KEY] === LOAD_REPLAY_BULK_MODE; @@ -6340,11 +6387,11 @@ class QwenAgent implements Agent { } if (kind === 'event') { await recording.recordSessionArtifactEvent( - payload as SessionArtifactEventRecordPayload, + parseSessionArtifactEventPayload(payload), ); } else { await recording.recordSessionArtifactSnapshot( - payload as SessionArtifactSnapshotRecordPayload, + parseSessionArtifactSnapshotPayload(payload), ); } return { sessionId, persisted: true, kind }; diff --git a/packages/core/src/services/session-artifact-persistence.test.ts b/packages/core/src/services/session-artifact-persistence.test.ts index 51682a92bc3..18598082f68 100644 --- a/packages/core/src/services/session-artifact-persistence.test.ts +++ b/packages/core/src/services/session-artifact-persistence.test.ts @@ -554,6 +554,59 @@ describe('session artifact persistence records', () => { ]); }); + it('reuses remapped ids across separate forked event payloads', () => { + const source = artifact( + 'source-session', + 'https://example.com/deleted-later', + ); + const remappedIds = new Map(); + const forkedId = stableSessionArtifactId( + 'forked-session', + 'url:https://example.com/deleted-later', + ); + + remapSessionArtifactPayloadForFork( + { + v: SESSION_ARTIFACT_PERSISTENCE_VERSION, + sessionId: 'source-session', + sequence: 7, + recordedAt: '2026-07-04T00:00:00.000Z', + changes: [ + { action: 'created', artifactId: source.id, artifact: source }, + ], + }, + 'source-session', + 'forked-session', + remappedIds, + ); + const remappedRemove = remapSessionArtifactPayloadForFork( + { + v: SESSION_ARTIFACT_PERSISTENCE_VERSION, + sessionId: 'source-session', + sequence: 8, + recordedAt: '2026-07-04T00:00:01.000Z', + changes: [ + { + action: 'removed', + artifactId: source.id, + reason: 'explicit', + }, + ], + }, + 'source-session', + 'forked-session', + remappedIds, + ) as SessionArtifactEventRecordPayload; + + expect(remappedRemove.changes).toEqual([ + { + action: 'removed', + artifactId: forkedId, + reason: 'explicit', + }, + ]); + }); + it('remaps forked snapshot payloads and drops bare tombstone state', () => { const source = artifact('source-session', 'https://example.com/snapshot', { retention: 'pinned', diff --git a/packages/core/src/services/session-artifact-persistence.ts b/packages/core/src/services/session-artifact-persistence.ts index 482a5ce7c02..40228f15a61 100644 --- a/packages/core/src/services/session-artifact-persistence.ts +++ b/packages/core/src/services/session-artifact-persistence.ts @@ -255,15 +255,23 @@ export function remapSessionArtifactPayloadForFork( payload: unknown, sourceSessionId: string, newSessionId: string, + remappedArtifactIds = new Map(), ): unknown { const snapshot = normalizeSnapshotPayload(payload, []); if (snapshot) { + const artifacts = snapshot.artifacts.map((artifact) => { + const remapped = remapSessionArtifactForFork( + artifact, + sourceSessionId, + newSessionId, + ); + remappedArtifactIds.set(artifact.id, remapped.id); + return remapped; + }); return { ...snapshot, sessionId: newSessionId, - artifacts: snapshot.artifacts.map((artifact) => - remapSessionArtifactForFork(artifact, sourceSessionId, newSessionId), - ), + artifacts, tombstonedIds: undefined, stickyEphemeralIds: undefined, } satisfies SessionArtifactSnapshotRecordPayload; @@ -271,7 +279,6 @@ export function remapSessionArtifactPayloadForFork( const event = normalizeEventPayload(payload, []); if (!event) return payload; - const remappedArtifactIds = new Map(); return { ...event, sessionId: newSessionId, diff --git a/packages/core/src/services/sessionService.test.ts b/packages/core/src/services/sessionService.test.ts index 12e150ddd52..e9b9065708a 100644 --- a/packages/core/src/services/sessionService.test.ts +++ b/packages/core/src/services/sessionService.test.ts @@ -2721,6 +2721,106 @@ describe('SessionService', () => { ]); }); + it('does not resurrect artifacts removed by later side records when forking', async () => { + const oldId = '72727272-7272-7272-7272-727272727272'; + const newId = '82828282-8282-8282-8282-828282828282'; + const { file, lines } = seedSession(oldId); + const url = 'https://example.com/forked-then-removed'; + const oldArtifactId = stableSessionArtifactId(oldId, `url:${url}`); + const forkedArtifactId = stableSessionArtifactId(newId, `url:${url}`); + const createRecord = { + uuid: 'artifact-create', + parentUuid: 'u1', + sessionId: oldId, + type: 'system', + subtype: 'session_artifact_event', + timestamp: '2026-04-22T00:00:00.500Z', + cwd, + version: 'test', + systemPayload: { + v: SESSION_ARTIFACT_PERSISTENCE_VERSION, + sessionId: oldId, + sequence: 1, + recordedAt: '2026-04-22T00:00:00.500Z', + changes: [ + { + action: 'created', + artifactId: oldArtifactId, + artifact: { + id: oldArtifactId, + kind: 'link', + storage: 'external_url', + source: 'client', + status: 'available', + title: 'Forked artifact', + url, + retention: 'restorable', + clientRetained: true, + createdAt: '2026-04-22T00:00:00.500Z', + updatedAt: '2026-04-22T00:00:00.500Z', + persistedAt: '2026-04-22T00:00:00.500Z', + }, + }, + ], + }, + }; + const removeRecord = { + uuid: 'artifact-remove', + parentUuid: 'u1', + sessionId: oldId, + type: 'system', + subtype: 'session_artifact_event', + timestamp: '2026-04-22T00:00:00.750Z', + cwd, + version: 'test', + systemPayload: { + v: SESSION_ARTIFACT_PERSISTENCE_VERSION, + sessionId: oldId, + sequence: 2, + recordedAt: '2026-04-22T00:00:00.750Z', + changes: [ + { + action: 'removed', + artifactId: oldArtifactId, + reason: 'explicit', + }, + ], + }, + }; + fs.writeFileSync( + file, + [lines[0], createRecord, removeRecord, lines[1]] + .map((line) => JSON.stringify(line)) + .join('\n') + '\n', + ); + + const result = await service.forkSession(oldId, newId); + const loaded = await service.loadSession(newId); + const forkedLines = fs + .readFileSync(result.filePath, 'utf8') + .trim() + .split('\n') + .map((line) => JSON.parse(line)); + const forkedRemovePayload = forkedLines.find( + (record) => record.uuid === 'artifact-remove', + )?.systemPayload; + + expect(result.copiedCount).toBe(4); + expect(loaded?.artifactSnapshot?.artifacts).toEqual([]); + expect(loaded?.artifactSnapshot?.tombstonedIds).toContain( + forkedArtifactId, + ); + expect(forkedRemovePayload).toMatchObject({ + changes: [ + { + action: 'removed', + artifactId: forkedArtifactId, + reason: 'explicit', + }, + ], + }); + }); + it('preserves file history snapshots on the forked session', async () => { const oldId = '31313131-3131-3131-3131-313131313131'; const newId = '41414141-4141-4141-4141-414141414141'; diff --git a/packages/core/src/services/sessionService.ts b/packages/core/src/services/sessionService.ts index 76f433cbbab..1d4ae38fff2 100644 --- a/packages/core/src/services/sessionService.ts +++ b/packages/core/src/services/sessionService.ts @@ -1442,12 +1442,14 @@ export class SessionService { // clean linear descendant. `forkedFrom` captures the origin of each // message. let prevUuid: string | null = null; + const remappedArtifactIds = new Map(); const forked: ChatRecord[] = sourceRecords.map((record) => { const isArtifactRecord = isSessionArtifactRecord(record); const systemPayload = remapSystemPayloadForFork( record, sourceSessionId, newSessionId, + remappedArtifactIds, ); const next: ChatRecord = { ...record, @@ -1940,6 +1942,7 @@ function remapSystemPayloadForFork( record: ChatRecord, sourceSessionId: string, newSessionId: string, + remappedArtifactIds: Map, ): ChatRecord['systemPayload'] { if (record.type !== 'system') return record.systemPayload; if (record.subtype === 'file_history_snapshot') { @@ -1957,6 +1960,7 @@ function remapSystemPayloadForFork( record.systemPayload, sourceSessionId, newSessionId, + remappedArtifactIds, ) as ChatRecord['systemPayload']; } return record.systemPayload; From 9e27e830551302b50dff83352871b03b1f3b47ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Mon, 6 Jul 2026 15:54:57 +0800 Subject: [PATCH 53/61] fix(daemon): label rewind artifact removals --- packages/acp-bridge/src/bridge.test.ts | 2 +- packages/acp-bridge/src/bridge.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index 14824a92d5d..accffc92da4 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -1632,7 +1632,7 @@ describe('createAcpSessionBridge', () => { await expect(artifactChanges).resolves.toEqual( expect.arrayContaining([ - expect.objectContaining({ action: 'removed' }), + expect.objectContaining({ action: 'removed', reason: 'eviction' }), expect.objectContaining({ action: 'created', artifactId: rewoundArtifactId, diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index a4a50a9e5ba..7894540a251 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -2642,7 +2642,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { action: 'removed', artifactId: artifact.id, artifact, - reason: 'explicit', + reason: 'eviction', }); } } From d7db531c4bf0a6ed7239f98d5f64835fc139fed9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Mon, 6 Jul 2026 19:32:10 +0800 Subject: [PATCH 54/61] fix(daemon): harden session artifact restore --- packages/acp-bridge/src/bridge.ts | 10 +- packages/acp-bridge/src/bridgeClient.ts | 3 +- .../acp-bridge/src/sessionArtifacts.test.ts | 24 +++- packages/acp-bridge/src/sessionArtifacts.ts | 121 ++++++++++------ .../cli/src/acp-integration/acpAgent.test.ts | 48 +++++++ packages/cli/src/acp-integration/acpAgent.ts | 36 +++-- .../session-artifact-persistence.test.ts | 58 ++++++++ .../services/session-artifact-persistence.ts | 135 ++++++++++++++---- packages/core/src/services/sessionService.ts | 19 ++- 9 files changed, 360 insertions(+), 94 deletions(-) diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index 7894540a251..f6e319c79f9 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -3107,8 +3107,9 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { entry.restoreReplayError = replayError; } seedSnapshotCaches(entry, state); + const restoredArtifactSnapshot = restoredArtifactSnapshotFromState(state); const artifactRestoreWarnings = await entry.artifacts.restore( - restoredArtifactSnapshotFromState(state), + restoredArtifactSnapshot, ); for (const warning of artifactRestoreWarnings) { writeStderrLine( @@ -3118,7 +3119,9 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { ); } if (replayUpdates.length > 0) { - await ci.client.seedSessionUpdates(entry, replayUpdates); + await ci.client.seedSessionUpdates(entry, replayUpdates, { + ingestArtifacts: restoredArtifactSnapshot === undefined, + }); ci.client.drainEarlyEvents(entry.sessionId, entry); } const clientId = registerClient(entry, req.clientId); @@ -5955,8 +5958,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { warning.includes('artifact snapshot restore partially failed'), ); const shouldRecordArtifactSnapshot = - artifactSnapshotUnavailable !== undefined || - (shouldRestoreArtifactSnapshot && !artifactRestoreFailed); + shouldRestoreArtifactSnapshot && !artifactRestoreFailed; const artifactSnapshotWarnings = shouldRecordArtifactSnapshot ? await entry.artifacts.recordSnapshot() : []; diff --git a/packages/acp-bridge/src/bridgeClient.ts b/packages/acp-bridge/src/bridgeClient.ts index 7dbc1e732d2..edaa3665fcc 100644 --- a/packages/acp-bridge/src/bridgeClient.ts +++ b/packages/acp-bridge/src/bridgeClient.ts @@ -701,6 +701,7 @@ export class BridgeClient implements Client { async seedSessionUpdates( entry: BridgeClientSessionEntry, updates: SessionUpdate[], + options: { ingestArtifacts?: boolean } = {}, ): Promise { const frames: Array> = []; const artifactBatches: Array<{ @@ -713,7 +714,7 @@ export class BridgeClient implements Client { entry, ); frames.push(...prepared.frames); - if (prepared.artifacts.length > 0) { + if (options.ingestArtifacts !== false && prepared.artifacts.length > 0) { artifactBatches.push({ artifacts: prepared.artifacts, trustedPublisher: prepared.trustedPublisher, diff --git a/packages/acp-bridge/src/sessionArtifacts.test.ts b/packages/acp-bridge/src/sessionArtifacts.test.ts index 54f95bcf1e9..f43a96b7840 100644 --- a/packages/acp-bridge/src/sessionArtifacts.test.ts +++ b/packages/acp-bridge/src/sessionArtifacts.test.ts @@ -157,6 +157,28 @@ describe('SessionArtifactStore', () => { }); }); + it('strips user-supplied reserved workspace metadata when the file is missing', async () => { + const store = new SessionArtifactStore({ + sessionId: 's1-reserved-metadata', + workspaceCwd: workspace, + }); + + const result = await store.upsertMany([ + { + title: 'Missing workspace artifact', + workspacePath: 'missing.txt', + metadata: { + 'qwen.workspace.sha256': 'a'.repeat(64), + 'qwen.workspace.mtimeMs': 123, + keep: true, + }, + }, + ]); + + expect(result.changes[0]?.artifact?.metadata).toEqual({ keep: true }); + expect(result.changes[0]?.artifact?.status).toBe('missing'); + }); + it('prevents one client from removing another client retained artifact', async () => { const store = new SessionArtifactStore({ sessionId: 's1-client-owner', @@ -2775,7 +2797,6 @@ describe('SessionArtifactStore', () => { title: 'Restored', retention: 'restorable', restoreState: 'restored', - persistenceWarning: 'metadata_only_restore', }), ], }); @@ -3043,7 +3064,6 @@ describe('SessionArtifactStore', () => { retention: 'restorable', restoreState: 'restored', status: 'available', - persistenceWarning: 'metadata_only_restore', }), ], }); diff --git a/packages/acp-bridge/src/sessionArtifacts.ts b/packages/acp-bridge/src/sessionArtifacts.ts index d1e90a34594..2a31ea9512b 100644 --- a/packages/acp-bridge/src/sessionArtifacts.ts +++ b/packages/acp-bridge/src/sessionArtifacts.ts @@ -5,7 +5,8 @@ */ import { createHash } from 'node:crypto'; -import { createReadStream, promises as fs } from 'node:fs'; +import { constants as fsConstants, promises as fs } from 'node:fs'; +import type { FileHandle } from 'node:fs/promises'; import path from 'node:path'; import { SESSION_ARTIFACT_PERSISTENCE_VERSION, @@ -59,6 +60,8 @@ const WORKSPACE_STATUS_REFRESH_BATCH_SIZE = 20; const SNAPSHOT_AFTER_DURABLE_EVENTS = 50; const MAX_SNAPSHOT_BACKOFF_MULTIPLIER = 4; const MAX_TOMBSTONED_IDS = 500; +const MAX_STICKY_EPHEMERAL_IDS = 500; +const MAX_WORKSPACE_HASH_BYTES = 100 * 1024 * 1024; export interface ToolArtifactLike { kind?: DaemonSessionArtifactKind; @@ -510,7 +513,9 @@ export class SessionArtifactStore { for (const id of snapshot.tombstonedIds) { this.tombstonedIds.add(id); } - for (const id of snapshot.stickyEphemeralIds) { + for (const id of snapshot.stickyEphemeralIds.slice( + -MAX_STICKY_EPHEMERAL_IDS, + )) { this.stickyEphemeralIds.add(id); } } @@ -545,11 +550,11 @@ export class SessionArtifactStore { continue; } const retention = normalized.retention; - let persistenceWarning: - | SessionArtifactPersistenceWarning - | undefined = 'metadata_only_restore'; + let persistenceWarning: SessionArtifactPersistenceWarning | undefined; if (retention === 'ephemeral') { persistenceWarning = 'sticky_override_active'; + } else if (normalized.status !== 'available') { + persistenceWarning = 'metadata_only_restore'; } const stored: StoredArtifact = { ...normalized, @@ -1030,7 +1035,7 @@ export class SessionArtifactStore { ? workspaceStatus.sizeBytes : input.sizeBytes !== undefined ? normalizeSizeBytes(input.sizeBytes) - : workspaceStatus?.sizeBytes, + : undefined, metadata, retention, restoreState: 'live', @@ -1428,6 +1433,7 @@ export function publicArtifactsEqual( a.persistedAt === b.persistedAt && a.clientRetained === b.clientRetained && a.createdAt === b.createdAt && + a.updatedAt === b.updatedAt && a.toolCallId === b.toolCallId && a.toolName === b.toolName && a.hookEventName === b.hookEventName && @@ -2089,6 +2095,9 @@ function normalizeMetadata( if (isPrototypeMetadataKey(key)) { continue; } + if (options.budget !== 'persisted' && isReservedWorkspaceMetadataKey(key)) { + continue; + } if (!key) { throw new SessionArtifactValidationError( 'metadata keys must not be empty', @@ -2151,6 +2160,13 @@ function isPrototypeMetadataKey(key: string): boolean { return key === '__proto__' || key === 'constructor' || key === 'prototype'; } +function isReservedWorkspaceMetadataKey(key: string): boolean { + return ( + key === WORKSPACE_CONTENT_SHA256_METADATA_KEY || + key === WORKSPACE_CONTENT_MTIME_MS_METADATA_KEY + ); +} + function metadataBudgetBytes( metadata: Record, budget: 'user' | 'persisted', @@ -2252,49 +2268,67 @@ async function getWorkspaceStatus( if (!relative || isOutsidePath(relative)) { return { status: 'missing', escaped: true }; } - const stat = await fs.stat(realPath); - if (stat.isFile()) { - const expectedMtimeMs = - typeof expected?.mtimeMs === 'number' ? expected.mtimeMs : undefined; - const expectedSha256 = - typeof expected?.sha256 === 'string' ? expected.sha256 : undefined; - const unchanged = - expected?.sizeBytes === stat.size && expectedMtimeMs === stat.mtimeMs; - const sizeChanged = - expected?.sizeBytes !== undefined && expected.sizeBytes !== stat.size; - if (sizeChanged) { - return { - status: 'changed', - sizeBytes: stat.size, - mtimeMs: stat.mtimeMs, - }; - } - if (unchanged) { + const handle = await fs.open( + realPath, + fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW, + ); + try { + const stat = await handle.stat(); + if (stat.isFile()) { + const expectedMtimeMs = + typeof expected?.mtimeMs === 'number' ? expected.mtimeMs : undefined; + const expectedSha256 = + typeof expected?.sha256 === 'string' ? expected.sha256 : undefined; + const unchanged = + expected?.sizeBytes === stat.size && expectedMtimeMs === stat.mtimeMs; + const sizeChanged = + expected?.sizeBytes !== undefined && expected.sizeBytes !== stat.size; + if (sizeChanged) { + return { + status: 'changed', + sizeBytes: stat.size, + mtimeMs: stat.mtimeMs, + }; + } + if (unchanged) { + return { + status: 'available', + sizeBytes: stat.size, + mtimeMs: stat.mtimeMs, + }; + } + if (stat.size > MAX_WORKSPACE_HASH_BYTES) { + return { + status: expectedSha256 ? 'changed' : 'available', + sizeBytes: stat.size, + mtimeMs: stat.mtimeMs, + }; + } + const sha256 = await hashFile(handle); + if (expectedSha256 && sha256 !== expectedSha256) { + return { + status: 'changed', + sizeBytes: stat.size, + mtimeMs: stat.mtimeMs, + }; + } return { status: 'available', sizeBytes: stat.size, mtimeMs: stat.mtimeMs, - }; - } - const sha256 = await hashFile(realPath); - if (expectedSha256 && sha256 !== expectedSha256) { - return { - status: 'changed', - sizeBytes: stat.size, - mtimeMs: stat.mtimeMs, + sha256, }; } return { status: 'available', - sizeBytes: stat.size, - mtimeMs: stat.mtimeMs, - sha256, }; + } finally { + await handle.close(); } - return { - status: 'available', - }; } catch (error) { + if (isNoFollowSymlinkError(error)) { + return { status: 'missing', escaped: true }; + } if (!isNotFoundError(error)) { throw error; } @@ -2305,9 +2339,9 @@ async function getWorkspaceStatus( } } -async function hashFile(absolutePath: string): Promise { +async function hashFile(handle: FileHandle): Promise { const hash = createHash('sha256'); - for await (const chunk of createReadStream(absolutePath)) { + for await (const chunk of handle.createReadStream({ start: 0 })) { hash.update(chunk as Buffer); } return hash.digest('hex'); @@ -2402,6 +2436,13 @@ function isNotFoundError(error: unknown): boolean { return code === 'ENOENT' || code === 'ENOTDIR'; } +function isNoFollowSymlinkError(error: unknown): boolean { + if (typeof error !== 'object' || error === null || !('code' in error)) { + return false; + } + return (error as { code?: unknown }).code === 'ELOOP'; +} + function isOutsidePath(relative: string): boolean { return ( relative === '..' || diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index 77a065df172..85c377d27e3 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -120,6 +120,22 @@ vi.mock('node:stream', async (importOriginal) => { // Mock core dependencies vi.mock('@qwen-code/qwen-code-core', () => ({ SESSION_ARTIFACT_PERSISTENCE_VERSION: 2, + normalizeEventPayload: vi.fn((payload: unknown) => + typeof payload === 'object' && + payload !== null && + !Array.isArray(payload) && + Array.isArray((payload as { changes?: unknown }).changes) + ? payload + : undefined, + ), + normalizeSnapshotPayload: vi.fn((payload: unknown) => + typeof payload === 'object' && + payload !== null && + !Array.isArray(payload) && + Array.isArray((payload as { artifacts?: unknown }).artifacts) + ? payload + : undefined, + ), createDebugLogger: () => ({ debug: vi.fn(), error: vi.fn(), @@ -1974,6 +1990,38 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await agentPromise; }); + it('sessionArtifactsPersist rejects payloads for a different session', async () => { + const sessionId = 'session-A'; + const recording = { + flush: vi.fn().mockResolvedValue(undefined), + recordSessionArtifactEvent: vi.fn().mockResolvedValue(undefined), + recordSessionArtifactSnapshot: vi.fn().mockResolvedValue(undefined), + }; + const innerConfig = await setupSessionMocks(sessionId); + innerConfig.getChatRecordingService = vi.fn().mockReturnValue(recording); + const { agent, agentPromise } = await bootAcpAgent(); + + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionArtifactsPersist, { + sessionId, + kind: 'event', + payload: { + v: SESSION_ARTIFACT_PERSISTENCE_VERSION, + sessionId: 'session-B', + sequence: 1, + recordedAt: '2026-07-04T00:00:00.000Z', + changes: [], + }, + }), + ).rejects.toThrowError(/Invalid or missing artifact persist payload/); + + expect(recording.recordSessionArtifactEvent).not.toHaveBeenCalled(); + + mockConnectionState.resolve(); + await agentPromise; + }); + it('status ext methods expose workspace snapshots without secrets', async () => { vi.mocked(getMCPDiscoveryState).mockReturnValue( MCPDiscoveryState.COMPLETED, diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 88db56ae722..ba38151879b 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -67,6 +67,8 @@ import { IMAGE_CAPABILITY, registerAcpEventLoopLagGauge, SESSION_ARTIFACT_PERSISTENCE_VERSION, + normalizeEventPayload, + normalizeSnapshotPayload, startEventLoopLagMonitor, } from '@qwen-code/qwen-code-core'; import { randomUUID } from 'node:crypto'; @@ -278,34 +280,48 @@ function isObjectRecord(value: unknown): value is Record { function parseSessionArtifactEventPayload( payload: unknown, + expectedSessionId: string, ): SessionArtifactEventRecordPayload { - const record = parseSessionArtifactBasePayload(payload); - if (!Array.isArray(record['changes'])) { + const record = parseSessionArtifactBasePayload(payload, expectedSessionId); + const warnings: string[] = []; + const normalized = normalizeEventPayload(record, warnings); + if ( + !normalized || + warnings.length > 0 || + normalized.changes.length !== (record['changes'] as unknown[]).length + ) { throw invalidArtifactPersistPayload(); } - return record as unknown as SessionArtifactEventRecordPayload; + return normalized; } function parseSessionArtifactSnapshotPayload( payload: unknown, + expectedSessionId: string, ): SessionArtifactSnapshotRecordPayload { - const record = parseSessionArtifactBasePayload(payload); - if (!Array.isArray(record['artifacts'])) { + const record = parseSessionArtifactBasePayload(payload, expectedSessionId); + const warnings: string[] = []; + const normalized = normalizeSnapshotPayload(record, warnings); + if ( + !normalized || + warnings.length > 0 || + normalized.artifacts.length !== (record['artifacts'] as unknown[]).length + ) { throw invalidArtifactPersistPayload(); } - return record as unknown as SessionArtifactSnapshotRecordPayload; + return normalized; } function parseSessionArtifactBasePayload( payload: unknown, + expectedSessionId: string, ): Record { if (!isObjectRecord(payload)) { throw invalidArtifactPersistPayload(); } if ( payload['v'] !== SESSION_ARTIFACT_PERSISTENCE_VERSION || - typeof payload['sessionId'] !== 'string' || - payload['sessionId'].length === 0 || + payload['sessionId'] !== expectedSessionId || !Number.isSafeInteger(payload['sequence']) || (payload['sequence'] as number) < 0 || typeof payload['recordedAt'] !== 'string' || @@ -6387,11 +6403,11 @@ class QwenAgent implements Agent { } if (kind === 'event') { await recording.recordSessionArtifactEvent( - parseSessionArtifactEventPayload(payload), + parseSessionArtifactEventPayload(payload, sessionId), ); } else { await recording.recordSessionArtifactSnapshot( - parseSessionArtifactSnapshotPayload(payload), + parseSessionArtifactSnapshotPayload(payload, sessionId), ); } return { sessionId, persisted: true, kind }; diff --git a/packages/core/src/services/session-artifact-persistence.test.ts b/packages/core/src/services/session-artifact-persistence.test.ts index 18598082f68..b293bde1cde 100644 --- a/packages/core/src/services/session-artifact-persistence.test.ts +++ b/packages/core/src/services/session-artifact-persistence.test.ts @@ -7,6 +7,7 @@ import { describe, expect, it } from 'vitest'; import { SESSION_ARTIFACT_PERSISTENCE_VERSION, + normalizeSnapshotPayload, rebuildSessionArtifactSnapshot, remapSessionArtifactPayloadForFork, stableSessionArtifactId, @@ -649,4 +650,61 @@ describe('session artifact persistence records', () => { expect(remapped.artifacts[0]).not.toHaveProperty('restoreState'); expect(remapped.artifacts[0]).not.toHaveProperty('persistenceWarning'); }); + + it('normalizes inbound snapshot payloads with bounded artifacts and sticky ids', () => { + const warnings: string[] = []; + const artifacts = Array.from({ length: 501 }, (_, index) => + artifact('session-A', `https://example.com/${index}`), + ); + const snapshot = normalizeSnapshotPayload( + { + v: SESSION_ARTIFACT_PERSISTENCE_VERSION, + sessionId: 'session-A', + sequence: 1, + recordedAt: '2026-07-04T00:00:00.000Z', + artifacts, + stickyEphemeralIds: Array.from( + { length: 501 }, + (_, index) => `sticky-${index}`, + ), + }, + warnings, + ); + + expect(snapshot?.artifacts).toHaveLength(500); + expect(snapshot?.stickyEphemeralIds).toHaveLength(500); + expect(snapshot?.stickyEphemeralIds?.[0]).toBe('sticky-1'); + expect(warnings).toContain('snapshot artifact list truncated to 500'); + }); + + it('drops unsafe persisted metadata and overlong string fields', () => { + const warnings: string[] = []; + const normalized = normalizeSnapshotPayload( + { + v: SESSION_ARTIFACT_PERSISTENCE_VERSION, + sessionId: 'session-A', + sequence: 1, + recordedAt: '2026-07-04T00:00:00.000Z', + artifacts: [ + { + ...artifact('session-A', 'https://example.com/metadata'), + title: 'x'.repeat(201), + }, + { + ...artifact('session-A', 'https://example.com/metadata-2'), + metadata: { + 'qwen.workspace.sha256': 'not-a-sha', + 'qwen.workspace.mtimeMs': '123', + keep: true, + }, + }, + ], + }, + warnings, + ); + + expect(normalized?.artifacts).toHaveLength(1); + expect(normalized?.artifacts[0]?.metadata).toEqual({ keep: true }); + expect(warnings).toContain('skipped artifact without id/title'); + }); }); diff --git a/packages/core/src/services/session-artifact-persistence.ts b/packages/core/src/services/session-artifact-persistence.ts index 40228f15a61..bdad607ee24 100644 --- a/packages/core/src/services/session-artifact-persistence.ts +++ b/packages/core/src/services/session-artifact-persistence.ts @@ -10,6 +10,16 @@ export const SESSION_ARTIFACT_PERSISTENCE_VERSION = 2 as const; const CONTENT_ID_PATTERN = /^[0-9a-f]{64}-[0-9a-f]{16}$/; const WORKSPACE_CONTENT_SHA256_METADATA_KEY = 'qwen.workspace.sha256'; const WORKSPACE_CONTENT_MTIME_MS_METADATA_KEY = 'qwen.workspace.mtimeMs'; +const MAX_PERSISTED_ARTIFACTS = 500; +const MAX_PERSISTED_IDS = 500; +const MAX_PERSISTED_ID_CHARS = 200; +const MAX_PERSISTED_TITLE_CHARS = 200; +const MAX_PERSISTED_DESCRIPTION_CHARS = 1000; +const MAX_PERSISTED_PATH_CHARS = 500; +const MAX_PERSISTED_URL_CHARS = 2048; +const MAX_PERSISTED_MIME_CHARS = 120; +const MAX_PERSISTED_FIELD_CHARS = 200; +const MAX_PERSISTED_TIMESTAMP_CHARS = 64; export type SessionArtifactRetention = 'ephemeral' | 'restorable' | 'pinned'; @@ -337,7 +347,7 @@ function remapSessionArtifactForFork( return next; } -function normalizeSnapshotPayload( +export function normalizeSnapshotPayload( value: unknown, warnings: string[], ): SessionArtifactSnapshotRecordPayload | undefined { @@ -353,21 +363,34 @@ function normalizeSnapshotPayload( if (!Array.isArray(value['artifacts'])) return undefined; const sessionId = getString(value, 'sessionId'); if (!sessionId) return undefined; - const artifacts = value['artifacts'] + const rawArtifacts = value['artifacts']; + if (rawArtifacts.length > MAX_PERSISTED_ARTIFACTS) { + warnings.push( + `snapshot artifact list truncated to ${MAX_PERSISTED_ARTIFACTS}`, + ); + } + const artifacts = rawArtifacts + .slice(0, MAX_PERSISTED_ARTIFACTS) .map((artifact) => normalizePersistedArtifact(artifact, warnings)) .filter((artifact) => artifact !== undefined); return { v: SESSION_ARTIFACT_PERSISTENCE_VERSION, sessionId, sequence: getNonNegativeInteger(value, 'sequence') ?? 0, - recordedAt: getString(value, 'recordedAt') ?? new Date(0).toISOString(), + recordedAt: + getString(value, 'recordedAt', MAX_PERSISTED_TIMESTAMP_CHARS) ?? + new Date(0).toISOString(), artifacts, - tombstonedIds: getStringArray(value, 'tombstonedIds'), - stickyEphemeralIds: getStringArray(value, 'stickyEphemeralIds'), + tombstonedIds: getStringArray(value, 'tombstonedIds', MAX_PERSISTED_IDS), + stickyEphemeralIds: getStringArray( + value, + 'stickyEphemeralIds', + MAX_PERSISTED_IDS, + ), }; } -function normalizeEventPayload( +export function normalizeEventPayload( value: unknown, warnings: string[], ): SessionArtifactEventRecordPayload | undefined { @@ -381,13 +404,15 @@ function normalizeEventPayload( return undefined; } if (!Array.isArray(value['changes'])) return undefined; - const sessionId = getString(value, 'sessionId'); + const sessionId = getString(value, 'sessionId', MAX_PERSISTED_ID_CHARS); if (!sessionId) return undefined; return { v: SESSION_ARTIFACT_PERSISTENCE_VERSION, sessionId, sequence: getNonNegativeInteger(value, 'sequence') ?? 0, - recordedAt: getString(value, 'recordedAt') ?? new Date(0).toISOString(), + recordedAt: + getString(value, 'recordedAt', MAX_PERSISTED_TIMESTAMP_CHARS) ?? + new Date(0).toISOString(), changes: value['changes'] .map((change) => normalizePersistedChange(change, warnings)) .filter((change) => change !== undefined), @@ -404,7 +429,8 @@ function normalizePersistedChange( return undefined; } const artifact = normalizePersistedArtifact(value['artifact'], warnings); - const artifactId = getString(value, 'artifactId') ?? artifact?.id; + const artifactId = + getString(value, 'artifactId', MAX_PERSISTED_ID_CHARS) ?? artifact?.id; if (!artifactId) return undefined; const reason = value['reason']; return { @@ -424,8 +450,8 @@ function normalizePersistedArtifact( warnings: string[], ): PersistedSessionArtifact | undefined { if (!isRecord(value)) return undefined; - const id = getString(value, 'id'); - const title = getString(value, 'title'); + const id = getString(value, 'id', MAX_PERSISTED_ID_CHARS); + const title = getString(value, 'title', MAX_PERSISTED_TITLE_CHARS); if (!id || !title) { warnings.push('skipped artifact without id/title'); return undefined; @@ -468,19 +494,39 @@ function normalizePersistedArtifact( } const metadata = normalizeMetadata(value['metadata'], warnings, id); - const description = getString(value, 'description'); - const workspacePath = getString(value, 'workspacePath'); - const managedId = getString(value, 'managedId'); - const url = getString(value, 'url'); - const mimeType = getString(value, 'mimeType'); + const description = getString( + value, + 'description', + MAX_PERSISTED_DESCRIPTION_CHARS, + ); + const workspacePath = getString( + value, + 'workspacePath', + MAX_PERSISTED_PATH_CHARS, + ); + const managedId = getString(value, 'managedId', MAX_PERSISTED_FIELD_CHARS); + const url = getString(value, 'url', MAX_PERSISTED_URL_CHARS); + const mimeType = getString(value, 'mimeType', MAX_PERSISTED_MIME_CHARS); const sizeBytes = getNonNegativeInteger(value, 'sizeBytes'); - const persistedAt = getString(value, 'persistedAt'); - const expiresAt = getString(value, 'expiresAt'); + const persistedAt = getString( + value, + 'persistedAt', + MAX_PERSISTED_TIMESTAMP_CHARS, + ); + const expiresAt = getString( + value, + 'expiresAt', + MAX_PERSISTED_TIMESTAMP_CHARS, + ); const contentRef = normalizeContentRef(value['contentRef']); - const toolCallId = getString(value, 'toolCallId'); - const toolName = getString(value, 'toolName'); - const hookEventName = getString(value, 'hookEventName'); - const clientId = getString(value, 'clientId'); + const toolCallId = getString(value, 'toolCallId', MAX_PERSISTED_FIELD_CHARS); + const toolName = getString(value, 'toolName', MAX_PERSISTED_FIELD_CHARS); + const hookEventName = getString( + value, + 'hookEventName', + MAX_PERSISTED_FIELD_CHARS, + ); + const clientId = getString(value, 'clientId', MAX_PERSISTED_FIELD_CHARS); return { id, kind, @@ -497,8 +543,12 @@ function normalizePersistedArtifact( ...(metadata ? { metadata } : {}), retention, clientRetained: value['clientRetained'] === true, - createdAt: getString(value, 'createdAt') ?? new Date(0).toISOString(), - updatedAt: getString(value, 'updatedAt') ?? new Date(0).toISOString(), + createdAt: + getString(value, 'createdAt', MAX_PERSISTED_TIMESTAMP_CHARS) ?? + new Date(0).toISOString(), + updatedAt: + getString(value, 'updatedAt', MAX_PERSISTED_TIMESTAMP_CHARS) ?? + new Date(0).toISOString(), ...(persistedAt ? { persistedAt } : {}), ...(expiresAt ? { expiresAt } : {}), ...(contentRef ? { contentRef } : {}), @@ -513,10 +563,14 @@ function normalizeContentRef( value: unknown, ): SessionArtifactContentRef | undefined { if (!isRecord(value) || value['kind'] !== 'managed_copy') return undefined; - const contentId = getString(value, 'contentId'); - const sha256 = getString(value, 'sha256'); + const contentId = getString(value, 'contentId', MAX_PERSISTED_FIELD_CHARS); + const sha256 = getString(value, 'sha256', 64); const sizeBytes = getNonNegativeInteger(value, 'sizeBytes'); - const createdAt = getString(value, 'createdAt'); + const createdAt = getString( + value, + 'createdAt', + MAX_PERSISTED_TIMESTAMP_CHARS, + ); if ( !contentId || !CONTENT_ID_PATTERN.test(contentId) || @@ -539,12 +593,19 @@ function normalizeMetadata( const normalized: Record = {}; for (const [key, item] of Object.entries(value)) { if (isPrototypeMetadataKey(key)) continue; + if (key.length > 120) continue; if ( item === null || typeof item === 'string' || typeof item === 'number' || typeof item === 'boolean' ) { + if ( + isReservedWorkspaceMetadataKey(key) && + !isWorkspaceContentMetadataEntry(key, item) + ) { + continue; + } normalized[key] = item; } } @@ -560,6 +621,13 @@ function isPrototypeMetadataKey(key: string): boolean { return key === '__proto__' || key === 'constructor' || key === 'prototype'; } +function isReservedWorkspaceMetadataKey(key: string): boolean { + return ( + key === WORKSPACE_CONTENT_SHA256_METADATA_KEY || + key === WORKSPACE_CONTENT_MTIME_MS_METADATA_KEY + ); +} + function metadataBudgetBytes( metadata: Record, ): number { @@ -600,20 +668,25 @@ function isRecord(value: unknown): value is Record { function getString( record: Record, key: string, + maxLength = MAX_PERSISTED_FIELD_CHARS, ): string | undefined { const value = record[key]; - return typeof value === 'string' ? value : undefined; + if (typeof value !== 'string' || value.length > maxLength) { + return undefined; + } + return value; } function getStringArray( record: Record, key: string, + maxItems = MAX_PERSISTED_IDS, ): string[] | undefined { const value = record[key]; if (!Array.isArray(value)) return undefined; - const items = value.filter( - (item): item is string => typeof item === 'string', - ); + const items = value + .filter((item): item is string => typeof item === 'string') + .slice(-maxItems); return items.length > 0 ? items : undefined; } diff --git a/packages/core/src/services/sessionService.ts b/packages/core/src/services/sessionService.ts index 1d4ae38fff2..df83bdcb10e 100644 --- a/packages/core/src/services/sessionService.ts +++ b/packages/core/src/services/sessionService.ts @@ -1974,19 +1974,26 @@ function includeActiveSideArtifactRecords( activeRecords.map((record) => [record.uuid, record]), ); const activeUuids = new Set(activeByUuid.keys()); + const firstActiveUuid = activeRecords[0]?.uuid; + const firstActiveIndex = + firstActiveUuid === undefined + ? -1 + : records.findIndex((record) => record.uuid === firstActiveUuid); const selected: ChatRecord[] = []; - for (const record of records) { + for (let index = 0; index < records.length; index++) { + const record = records[index]!; const activeRecord = activeByUuid.get(record.uuid); if (activeRecord) { selected.push(activeRecord); activeByUuid.delete(record.uuid); continue; } - if ( - isSessionArtifactRecord(record) && - record.parentUuid !== null && - activeUuids.has(record.parentUuid) - ) { + if (!isSessionArtifactRecord(record)) { + continue; + } + if (record.parentUuid !== null && activeUuids.has(record.parentUuid)) { + selected.push(record); + } else if (record.parentUuid === null && index < firstActiveIndex) { selected.push(record); } } From d389f0af189e92f0a874321b6d2edf2ff619e786 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Mon, 6 Jul 2026 19:35:20 +0800 Subject: [PATCH 55/61] fix(daemon): stabilize artifact restore failure checks --- packages/acp-bridge/src/bridge.ts | 5 ++--- packages/acp-bridge/src/sessionArtifacts.ts | 14 ++++++++++++-- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index f6e319c79f9..25ce05941ad 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -118,6 +118,7 @@ import { import { PermissionForbiddenError } from './bridgeErrors.js'; import { SessionArtifactStore, + isArtifactRestoreFailureWarning, publicArtifactsEqual, type DaemonSessionArtifact, type SessionArtifactChange, @@ -5953,9 +5954,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { }) : []; const artifactRestoreFailed = artifactRestoreWarnings.some( - (warning) => - warning.includes('artifact snapshot restore failed') || - warning.includes('artifact snapshot restore partially failed'), + isArtifactRestoreFailureWarning, ); const shouldRecordArtifactSnapshot = shouldRestoreArtifactSnapshot && !artifactRestoreFailed; diff --git a/packages/acp-bridge/src/sessionArtifacts.ts b/packages/acp-bridge/src/sessionArtifacts.ts index 2a31ea9512b..bff34a729e6 100644 --- a/packages/acp-bridge/src/sessionArtifacts.ts +++ b/packages/acp-bridge/src/sessionArtifacts.ts @@ -62,6 +62,16 @@ const MAX_SNAPSHOT_BACKOFF_MULTIPLIER = 4; const MAX_TOMBSTONED_IDS = 500; const MAX_STICKY_EPHEMERAL_IDS = 500; const MAX_WORKSPACE_HASH_BYTES = 100 * 1024 * 1024; +const RESTORE_FAILED_WARNING_PREFIX = 'artifact snapshot restore failed'; +const RESTORE_PARTIAL_FAILED_WARNING_PREFIX = + 'artifact snapshot restore partially failed'; + +export function isArtifactRestoreFailureWarning(warning: string): boolean { + return ( + warning.startsWith(RESTORE_FAILED_WARNING_PREFIX) || + warning.startsWith(RESTORE_PARTIAL_FAILED_WARNING_PREFIX) + ); +} export interface ToolArtifactLike { kind?: DaemonSessionArtifactKind; @@ -586,8 +596,8 @@ export class SessionArtifactStore { this.restoreState(previousState); warnings.push( restoredCount === 0 - ? 'artifact snapshot restore failed; kept existing live artifacts' - : `artifact snapshot restore partially failed; restored ${restoredCount}/${snapshot.artifacts.length} artifacts; kept existing live artifacts`, + ? `${RESTORE_FAILED_WARNING_PREFIX}; kept existing live artifacts` + : `${RESTORE_PARTIAL_FAILED_WARNING_PREFIX}; restored ${restoredCount}/${snapshot.artifacts.length} artifacts; kept existing live artifacts`, ); this.setLastRestoreWarnings(warnings); return warnings; From 44186583d56e8de188f4d16bf4cd9b323e5858ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Mon, 6 Jul 2026 19:40:57 +0800 Subject: [PATCH 56/61] fix(daemon): preserve tombstones for downgraded artifacts --- .../acp-bridge/src/sessionArtifacts.test.ts | 46 +++++++++++++++++++ packages/acp-bridge/src/sessionArtifacts.ts | 36 +++++++++++---- 2 files changed, 73 insertions(+), 9 deletions(-) diff --git a/packages/acp-bridge/src/sessionArtifacts.test.ts b/packages/acp-bridge/src/sessionArtifacts.test.ts index f43a96b7840..c22c26af981 100644 --- a/packages/acp-bridge/src/sessionArtifacts.test.ts +++ b/packages/acp-bridge/src/sessionArtifacts.test.ts @@ -2751,6 +2751,52 @@ describe('SessionArtifactStore', () => { }); }); + it('writes a tombstone when deleting a downgraded durable artifact', async () => { + const events: SessionArtifactEventRecordPayload[] = []; + let failNext = false; + const store = new SessionArtifactStore({ + sessionId: 's11-downgraded-tombstone', + workspaceCwd: workspace, + persistence: { + recordEvent: async (payload) => { + if (failNext) { + failNext = false; + throw new Error('disk full'); + } + events.push(payload); + }, + recordSnapshot: async () => {}, + }, + }); + const created = await store.upsertMany( + [{ title: 'Durable', url: 'https://example.com/downgraded' }], + { strict: true }, + ); + + failNext = true; + const downgraded = await store.upsertMany([ + { + title: 'Durable', + url: 'https://example.com/downgraded', + metadata: { phase: 'updated' }, + }, + ]); + expect(downgraded.changes[0]?.artifact).toMatchObject({ + retention: 'ephemeral', + persistenceWarning: 'persistence_unavailable', + }); + + await store.remove(created.changes[0]!.artifactId); + + expect(events.at(-1)?.changes).toEqual([ + expect.objectContaining({ + action: 'removed', + artifactId: created.changes[0]?.artifactId, + reason: 'explicit', + }), + ]); + }); + it('restores rebuilt durable artifacts as metadata-only restored entries', async () => { const events: SessionArtifactEventRecordPayload[] = []; const source = new SessionArtifactStore({ diff --git a/packages/acp-bridge/src/sessionArtifacts.ts b/packages/acp-bridge/src/sessionArtifacts.ts index bff34a729e6..3f51274458e 100644 --- a/packages/acp-bridge/src/sessionArtifacts.ts +++ b/packages/acp-bridge/src/sessionArtifacts.ts @@ -210,6 +210,7 @@ interface NormalizedArtifact extends DaemonSessionArtifact { interface StoredArtifact extends NormalizedArtifact { insertSeq: number; + durableTombstoneRequired?: boolean; } interface WorkspaceStatusExpected { @@ -478,15 +479,22 @@ export class SessionArtifactStore { // any caller that already passed session mutation auth. this.denyCrossClientMutation('remove', artifactId, existing, options); this.artifacts.delete(artifactId); - const changes: SessionArtifactChange[] = [ - { - action: 'removed', - artifactId, - artifact: toPublicArtifact(existing), - reason: 'explicit', - }, - ]; + const removeChange: SessionArtifactChange & { + durableTombstoneRequired?: boolean; + } = { + action: 'removed', + artifactId, + artifact: toPublicArtifact(existing), + reason: 'explicit', + durableTombstoneRequired: + existing.durableTombstoneRequired || + existing.retention !== 'ephemeral' + ? true + : undefined, + }; + const changes: SessionArtifactChange[] = [removeChange]; const warnings = await this.persistChanges(changes, false); + delete removeChange.durableTombstoneRequired; return { v: 1, sessionId: this.sessionId, @@ -576,6 +584,8 @@ export class SessionArtifactStore { status: normalized.status, restoreState: 'restored', persistenceWarning, + durableTombstoneRequired: + retention !== 'ephemeral' ? true : undefined, insertSeq: ++this.insertSeq, }; this.artifacts.set(stored.id, stored); @@ -763,6 +773,7 @@ export class SessionArtifactStore { const stored = this.artifacts.get(change.artifactId); if (!stored) continue; stored.persistedAt = recordedAt; + stored.durableTombstoneRequired = true; change.artifact = toPublicArtifact(stored); } this.applyDurableMarkers(durableChanges); @@ -853,6 +864,8 @@ export class SessionArtifactStore { if (!stored) continue; stored.retention = 'ephemeral'; stored.persistenceWarning = 'persistence_unavailable'; + stored.durableTombstoneRequired = + stored.durableTombstoneRequired || stored.persistedAt !== undefined; delete stored.persistedAt; change.artifact = toPublicArtifact(stored); downgraded = true; @@ -1763,7 +1776,12 @@ function isFileArtifactUrl(raw: unknown): boolean { function isDurablePersistenceChange(change: SessionArtifactChange): boolean { if (!change.artifact) return false; - return change.artifact.retention !== 'ephemeral'; + return ( + change.artifact.retention !== 'ephemeral' || + (change.action === 'removed' && + (change as { durableTombstoneRequired?: boolean }) + .durableTombstoneRequired === true) + ); } function cloneStoredArtifact(artifact: StoredArtifact): StoredArtifact { From 8efb85edd446495926af2c9f0ad02d9dc9740083 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Tue, 7 Jul 2026 00:36:38 +0800 Subject: [PATCH 57/61] fix(daemon): snapshot artifacts on rewind rebuild failure --- packages/acp-bridge/src/bridge.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index 25ce05941ad..602293dbe50 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -5957,7 +5957,8 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { isArtifactRestoreFailureWarning, ); const shouldRecordArtifactSnapshot = - shouldRestoreArtifactSnapshot && !artifactRestoreFailed; + artifactSnapshotUnavailable !== undefined || + (shouldRestoreArtifactSnapshot && !artifactRestoreFailed); const artifactSnapshotWarnings = shouldRecordArtifactSnapshot ? await entry.artifacts.recordSnapshot() : []; From 83bc7b56428e6a53712824a47ed85e81a76f11f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Tue, 7 Jul 2026 01:15:49 +0800 Subject: [PATCH 58/61] fix(daemon): harden artifact journal replay --- .../acp-bridge/src/sessionArtifacts.test.ts | 28 ++++-------- packages/acp-bridge/src/sessionArtifacts.ts | 24 ++++++++-- .../session-artifact-persistence.test.ts | 45 +++++++++++++++++++ .../services/session-artifact-persistence.ts | 13 +++++- 4 files changed, 86 insertions(+), 24 deletions(-) diff --git a/packages/acp-bridge/src/sessionArtifacts.test.ts b/packages/acp-bridge/src/sessionArtifacts.test.ts index c22c26af981..8de77529ac0 100644 --- a/packages/acp-bridge/src/sessionArtifacts.test.ts +++ b/packages/acp-bridge/src/sessionArtifacts.test.ts @@ -2708,7 +2708,7 @@ describe('SessionArtifactStore', () => { await expect(store.list()).resolves.toMatchObject({ artifacts: [] }); }); - it('keeps live removal when explicit tombstone persistence fails', async () => { + it('rolls back explicit removal when tombstone persistence fails', async () => { let calls = 0; const store = new SessionArtifactStore({ sessionId: 's11-remove-live-first', @@ -2728,26 +2728,16 @@ describe('SessionArtifactStore', () => { { strict: true }, ); - await expect(store.remove(created.changes[0]!.artifactId)).resolves.toEqual( - expect.objectContaining({ - changes: [ - expect.objectContaining({ - action: 'removed', - artifactId: created.changes[0]?.artifactId, - }), - ], - warnings: ['artifact removal not persisted; live removal kept'], - }), + await expect(store.remove(created.changes[0]!.artifactId)).rejects.toThrow( + 'disk full', ); await expect(store.list()).resolves.toMatchObject({ - artifacts: [], - }); - const replay = await store.upsertMany([ - { title: 'Sensitive', url: 'https://example.com/sensitive' }, - ]); - expect(replay.changes).toEqual([]); - await expect(store.list()).resolves.toMatchObject({ - artifacts: [], + artifacts: [ + expect.objectContaining({ + id: created.changes[0]?.artifactId, + title: 'Sensitive', + }), + ], }); }); diff --git a/packages/acp-bridge/src/sessionArtifacts.ts b/packages/acp-bridge/src/sessionArtifacts.ts index 3f51274458e..14695c61cd5 100644 --- a/packages/acp-bridge/src/sessionArtifacts.ts +++ b/packages/acp-bridge/src/sessionArtifacts.ts @@ -5,7 +5,7 @@ */ import { createHash } from 'node:crypto'; -import { constants as fsConstants, promises as fs } from 'node:fs'; +import { constants as fsConstants, promises as fs, type Stats } from 'node:fs'; import type { FileHandle } from 'node:fs/promises'; import path from 'node:path'; import { @@ -478,6 +478,7 @@ export class SessionArtifactStore { // Tool/hook artifacts are session-scoped outputs and may be removed by // any caller that already passed session mutation auth. this.denyCrossClientMutation('remove', artifactId, existing, options); + const before = this.cloneState(); this.artifacts.delete(artifactId); const removeChange: SessionArtifactChange & { durableTombstoneRequired?: boolean; @@ -493,8 +494,17 @@ export class SessionArtifactStore { : undefined, }; const changes: SessionArtifactChange[] = [removeChange]; - const warnings = await this.persistChanges(changes, false); - delete removeChange.durableTombstoneRequired; + let warnings: string[]; + try { + warnings = await this.persistChanges( + changes, + this.persistence !== undefined, + ); + delete removeChange.durableTombstoneRequired; + } catch (error) { + this.restoreState(before); + throw error; + } return { v: 1, sessionId: this.sessionId, @@ -2296,12 +2306,16 @@ async function getWorkspaceStatus( if (!relative || isOutsidePath(relative)) { return { status: 'missing', escaped: true }; } + const preOpenStat = await fs.lstat(realPath); const handle = await fs.open( realPath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW, ); try { const stat = await handle.stat(); + if (!isSameFile(preOpenStat, stat)) { + return { status: 'missing', escaped: true }; + } if (stat.isFile()) { const expectedMtimeMs = typeof expected?.mtimeMs === 'number' ? expected.mtimeMs : undefined; @@ -2367,6 +2381,10 @@ async function getWorkspaceStatus( } } +function isSameFile(before: Stats, after: Stats): boolean { + return before.dev === after.dev && before.ino === after.ino; +} + async function hashFile(handle: FileHandle): Promise { const hash = createHash('sha256'); for await (const chunk of handle.createReadStream({ start: 0 })) { diff --git a/packages/core/src/services/session-artifact-persistence.test.ts b/packages/core/src/services/session-artifact-persistence.test.ts index b293bde1cde..c468442105d 100644 --- a/packages/core/src/services/session-artifact-persistence.test.ts +++ b/packages/core/src/services/session-artifact-persistence.test.ts @@ -7,6 +7,7 @@ import { describe, expect, it } from 'vitest'; import { SESSION_ARTIFACT_PERSISTENCE_VERSION, + normalizeEventPayload, normalizeSnapshotPayload, rebuildSessionArtifactSnapshot, remapSessionArtifactPayloadForFork, @@ -677,6 +678,50 @@ describe('session artifact persistence records', () => { expect(warnings).toContain('snapshot artifact list truncated to 500'); }); + it('normalizes inbound event payloads with bounded changes', () => { + const warnings: string[] = []; + const changes = Array.from({ length: 501 }, (_, index) => { + const item = artifact('session-A', `https://example.com/event-${index}`); + return { + action: 'created' as const, + artifactId: item.id, + artifact: item, + }; + }); + + const normalized = normalizeEventPayload( + { + v: SESSION_ARTIFACT_PERSISTENCE_VERSION, + sessionId: 'session-A', + sequence: 1, + recordedAt: '2026-07-04T00:00:00.000Z', + changes, + }, + warnings, + ); + + expect(normalized?.changes).toHaveLength(500); + expect(warnings).toContain('event change list truncated to 500'); + }); + + it('drops overlong persisted string array items', () => { + const snapshot = normalizeSnapshotPayload( + { + v: SESSION_ARTIFACT_PERSISTENCE_VERSION, + sessionId: 'session-A', + sequence: 1, + recordedAt: '2026-07-04T00:00:00.000Z', + artifacts: [], + tombstonedIds: ['deleted', 'x'.repeat(201)], + stickyEphemeralIds: ['sticky', 'y'.repeat(201)], + }, + [], + ); + + expect(snapshot?.tombstonedIds).toEqual(['deleted']); + expect(snapshot?.stickyEphemeralIds).toEqual(['sticky']); + }); + it('drops unsafe persisted metadata and overlong string fields', () => { const warnings: string[] = []; const normalized = normalizeSnapshotPayload( diff --git a/packages/core/src/services/session-artifact-persistence.ts b/packages/core/src/services/session-artifact-persistence.ts index bdad607ee24..da8c6f770c2 100644 --- a/packages/core/src/services/session-artifact-persistence.ts +++ b/packages/core/src/services/session-artifact-persistence.ts @@ -406,6 +406,10 @@ export function normalizeEventPayload( if (!Array.isArray(value['changes'])) return undefined; const sessionId = getString(value, 'sessionId', MAX_PERSISTED_ID_CHARS); if (!sessionId) return undefined; + const rawChanges = value['changes']; + if (rawChanges.length > MAX_PERSISTED_ARTIFACTS) { + warnings.push(`event change list truncated to ${MAX_PERSISTED_ARTIFACTS}`); + } return { v: SESSION_ARTIFACT_PERSISTENCE_VERSION, sessionId, @@ -413,7 +417,8 @@ export function normalizeEventPayload( recordedAt: getString(value, 'recordedAt', MAX_PERSISTED_TIMESTAMP_CHARS) ?? new Date(0).toISOString(), - changes: value['changes'] + changes: rawChanges + .slice(0, MAX_PERSISTED_ARTIFACTS) .map((change) => normalizePersistedChange(change, warnings)) .filter((change) => change !== undefined), }; @@ -681,11 +686,15 @@ function getStringArray( record: Record, key: string, maxItems = MAX_PERSISTED_IDS, + maxLength = MAX_PERSISTED_ID_CHARS, ): string[] | undefined { const value = record[key]; if (!Array.isArray(value)) return undefined; const items = value - .filter((item): item is string => typeof item === 'string') + .filter( + (item): item is string => + typeof item === 'string' && item.length <= maxLength, + ) .slice(-maxItems); return items.length > 0 ? items : undefined; } From 502e05457b6be5020453c7ced4797daabd5f442a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Tue, 7 Jul 2026 01:41:15 +0800 Subject: [PATCH 59/61] fix(daemon): harden artifact snapshot restore --- packages/acp-bridge/src/bridge.test.ts | 39 +++++++++++ packages/acp-bridge/src/bridge.ts | 48 +++++++------- .../acp-bridge/src/sessionArtifacts.test.ts | 64 +++++++++++++++++-- packages/acp-bridge/src/sessionArtifacts.ts | 26 ++++++-- 4 files changed, 141 insertions(+), 36 deletions(-) diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index accffc92da4..c7bb2d13e27 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -1331,6 +1331,45 @@ describe('createAcpSessionBridge', () => { await bridge.shutdown(); }); + it('normalizes artifact snapshots returned by session restore', async () => { + const sessionId = 'persisted-artifact-normalize'; + const bridge = makeBridge({ + channelFactory: async () => + makeChannel({ + loadSessionImpl: () => + ({ + artifactSnapshot: { + v: SESSION_ARTIFACT_PERSISTENCE_VERSION, + sessionId, + sequence: 7, + artifacts: [{ malformed: true }], + tombstonedIds: ['x'.repeat(201)], + stickyEphemeralIds: ['y'.repeat(201)], + warnings: ['kept warning', 'z'.repeat(1001), 123], + }, + }) as LoadSessionResponse, + }).channel, + }); + + const loaded = await bridge.loadSession({ + sessionId, + workspaceCwd: WS_A, + }); + + expect(loaded.artifactWarnings).toEqual([ + 'skipped artifact without id/title', + 'kept warning', + ]); + await expect( + bridge.getSessionArtifacts(loaded.sessionId), + ).resolves.toMatchObject({ + warnings: ['skipped artifact without id/title', 'kept warning'], + artifacts: [], + }); + + await bridge.shutdown(); + }); + it('clears durable artifacts but keeps live ephemerals when rewind returns an empty artifact snapshot', async () => { const persistedSnapshots: unknown[] = []; const bridge = makeBridge({ diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index 602293dbe50..d47c627bd60 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -26,6 +26,7 @@ import { DAEMON_TRACESTATE_META_KEY, SESSION_ARTIFACT_PERSISTENCE_VERSION, TrustGateError, + normalizeSnapshotPayload, ShellExecutionService, type ShellOutputEvent, } from '@qwen-code/qwen-code-core'; @@ -2831,31 +2832,28 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { ): RebuiltSessionArtifactSnapshot | undefined => { const candidate = (state as { artifactSnapshot?: unknown }) .artifactSnapshot; - if ( - candidate && - typeof candidate === 'object' && - !Array.isArray(candidate) && - (candidate as { v?: unknown }).v === - SESSION_ARTIFACT_PERSISTENCE_VERSION && - Array.isArray((candidate as { artifacts?: unknown }).artifacts) - ) { - const snapshot = candidate as Partial; - return { - v: SESSION_ARTIFACT_PERSISTENCE_VERSION, - sessionId: - typeof snapshot.sessionId === 'string' ? snapshot.sessionId : '', - sequence: typeof snapshot.sequence === 'number' ? snapshot.sequence : 0, - artifacts: snapshot.artifacts ?? [], - tombstonedIds: Array.isArray(snapshot.tombstonedIds) - ? snapshot.tombstonedIds - : [], - stickyEphemeralIds: Array.isArray(snapshot.stickyEphemeralIds) - ? snapshot.stickyEphemeralIds - : [], - warnings: Array.isArray(snapshot.warnings) ? snapshot.warnings : [], - }; - } - return undefined; + const warnings: string[] = []; + const snapshot = normalizeSnapshotPayload(candidate, warnings); + if (!snapshot) return undefined; + const snapshotWarnings = Array.isArray( + (candidate as { warnings?: unknown }).warnings, + ) + ? (candidate as { warnings: unknown[] }).warnings + .filter( + (warning): warning is string => + typeof warning === 'string' && warning.length <= 1000, + ) + .slice(-500) + : []; + return { + v: SESSION_ARTIFACT_PERSISTENCE_VERSION, + sessionId: snapshot.sessionId, + sequence: snapshot.sequence, + artifacts: snapshot.artifacts, + tombstonedIds: snapshot.tombstonedIds ?? [], + stickyEphemeralIds: snapshot.stickyEphemeralIds ?? [], + warnings: [...warnings, ...snapshotWarnings], + }; }; const artifactSnapshotUnavailableReason = ( diff --git a/packages/acp-bridge/src/sessionArtifacts.test.ts b/packages/acp-bridge/src/sessionArtifacts.test.ts index 8de77529ac0..46ef756bb3a 100644 --- a/packages/acp-bridge/src/sessionArtifacts.test.ts +++ b/packages/acp-bridge/src/sessionArtifacts.test.ts @@ -2838,6 +2838,63 @@ describe('SessionArtifactStore', () => { }); }); + it('restores workspace artifacts with stat-only content checks', async () => { + const workspacePath = 'restore-stat-only.txt'; + const content = 'same content'; + await fs.writeFile(path.join(workspace, workspacePath), content); + const stat = await fs.stat(path.join(workspace, workspacePath)); + const id = stableSessionArtifactId( + 's11-restore-workspace-stat-only', + `workspace:${workspacePath}`, + ); + const store = new SessionArtifactStore({ + sessionId: 's11-restore-workspace-stat-only', + workspaceCwd: workspace, + }); + + await store.restore({ + v: 2, + sessionId: 's11-restore-workspace-stat-only', + sequence: 1, + artifacts: [ + { + id, + kind: 'file', + storage: 'workspace', + source: 'tool', + status: 'available', + title: 'Restored workspace file', + workspacePath, + sizeBytes: stat.size, + metadata: { + 'qwen.workspace.sha256': createHash('sha256') + .update(content) + .digest('hex'), + 'qwen.workspace.mtimeMs': stat.mtimeMs - 1, + }, + retention: 'restorable', + clientRetained: false, + createdAt: '2026-07-04T00:00:00.000Z', + updatedAt: '2026-07-04T00:00:00.000Z', + persistedAt: '2026-07-04T00:00:00.000Z', + }, + ], + tombstonedIds: [], + stickyEphemeralIds: [], + warnings: [], + }); + + await expect(store.list()).resolves.toMatchObject({ + artifacts: [ + { + id, + status: 'changed', + persistenceWarning: 'metadata_only_restore', + }, + ], + }); + }); + it('keeps live artifacts when a non-empty restore snapshot fully fails', async () => { const store = new SessionArtifactStore({ sessionId: 's11-restore-fail-closed', @@ -2873,7 +2930,6 @@ describe('SessionArtifactStore', () => { }); expect(warnings).toEqual([ - 'skipped artifact with mismatched id bad-id', 'artifact snapshot restore failed; kept existing live artifacts', ]); await expect(store.list()).resolves.toMatchObject({ @@ -2930,7 +2986,6 @@ describe('SessionArtifactStore', () => { }); expect(warnings).toEqual([ - 'skipped artifact with mismatched id bad-id', 'artifact snapshot restore partially failed; restored 1/2 artifacts; kept existing live artifacts', ]); await expect(store.list()).resolves.toMatchObject({ @@ -2977,10 +3032,9 @@ describe('SessionArtifactStore', () => { warnings: [], }); - expect(warnings[0]).toContain('url must use http or https'); - expect(warnings).toContain( + expect(warnings).toEqual([ 'artifact snapshot restore failed; kept existing live artifacts', - ); + ]); await expect(store.list()).resolves.toMatchObject({ artifacts: [ { diff --git a/packages/acp-bridge/src/sessionArtifacts.ts b/packages/acp-bridge/src/sessionArtifacts.ts index 14695c61cd5..1300a53b82d 100644 --- a/packages/acp-bridge/src/sessionArtifacts.ts +++ b/packages/acp-bridge/src/sessionArtifacts.ts @@ -521,8 +521,8 @@ export class SessionArtifactStore { if (!snapshot) return []; return this.enqueue(async () => { const warnings = [...(snapshot?.warnings ?? [])]; + const baselineWarnings = [...warnings]; const previousState = this.cloneState(); - const warningCountBeforeRestore = warnings.length; const preservedLiveEphemeralArtifacts = options.preserveLiveEphemeral ? Array.from(this.artifacts.values()) .filter((artifact) => artifact.retention === 'ephemeral') @@ -561,6 +561,7 @@ export class SessionArtifactStore { { metadataBudget: 'persisted', workspaceExpected: workspaceExpectedFromArtifact(artifact), + hashWorkspaceContent: false, }, ); if ( @@ -611,16 +612,17 @@ export class SessionArtifactStore { if ( snapshot.artifacts.length > 0 && restoredCount < snapshot.artifacts.length && - warnings.length > warningCountBeforeRestore + warnings.length > baselineWarnings.length ) { this.restoreState(previousState); - warnings.push( + const rollbackWarnings = [ + ...baselineWarnings, restoredCount === 0 ? `${RESTORE_FAILED_WARNING_PREFIX}; kept existing live artifacts` : `${RESTORE_PARTIAL_FAILED_WARNING_PREFIX}; restored ${restoredCount}/${snapshot.artifacts.length} artifacts; kept existing live artifacts`, - ); - this.setLastRestoreWarnings(warnings); - return warnings; + ]; + this.setLastRestoreWarnings(rollbackWarnings); + return rollbackWarnings; } for (const artifact of preservedLiveEphemeralArtifacts) { if ( @@ -969,6 +971,7 @@ export class SessionArtifactStore { options: { metadataBudget?: 'user' | 'persisted'; workspaceExpected?: WorkspaceStatusExpected; + hashWorkspaceContent?: boolean; } = {}, ): Promise { if (!input || typeof input !== 'object') { @@ -1019,6 +1022,7 @@ export class SessionArtifactStore { ? await this.getInitialWorkspaceStatus( workspacePath, options.workspaceExpected, + { hashContent: options.hashWorkspaceContent !== false }, ) : undefined; if (workspaceStatus?.escaped) { @@ -1145,6 +1149,7 @@ export class SessionArtifactStore { private async getInitialWorkspaceStatus( workspacePath: string, expected?: WorkspaceStatusExpected, + options: { hashContent: boolean } = { hashContent: true }, ): Promise<{ status: DaemonSessionArtifactStatus; sizeBytes?: number; @@ -1158,6 +1163,7 @@ export class SessionArtifactStore { workspacePath, this.getRealWorkspaceCwd(), expected, + options, ); } catch (error) { const reason = error instanceof Error ? error.message : String(error); @@ -2291,6 +2297,7 @@ async function getWorkspaceStatus( workspacePath: string, realWorkspaceCwd: Promise, expected?: WorkspaceStatusExpected, + options: { hashContent: boolean } = { hashContent: true }, ): Promise<{ status: DaemonSessionArtifactStatus; sizeBytes?: number; @@ -2346,6 +2353,13 @@ async function getWorkspaceStatus( mtimeMs: stat.mtimeMs, }; } + if (!options.hashContent) { + return { + status: expectedSha256 ? 'changed' : 'available', + sizeBytes: stat.size, + mtimeMs: stat.mtimeMs, + }; + } const sha256 = await hashFile(handle); if (expectedSha256 && sha256 !== expectedSha256) { return { From 5d3345ad8e683c99f04ecacbeca945231caa8a63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Tue, 7 Jul 2026 04:46:24 +0800 Subject: [PATCH 60/61] fix(daemon): harden artifact persistence edges --- .../acp-bridge/src/sessionArtifacts.test.ts | 114 +++++++++++++++++- packages/acp-bridge/src/sessionArtifacts.ts | 34 +++++- .../src/services/chatRecordingService.test.ts | 29 +++++ .../core/src/services/chatRecordingService.ts | 6 +- 4 files changed, 178 insertions(+), 5 deletions(-) diff --git a/packages/acp-bridge/src/sessionArtifacts.test.ts b/packages/acp-bridge/src/sessionArtifacts.test.ts index 46ef756bb3a..00c3061ebbc 100644 --- a/packages/acp-bridge/src/sessionArtifacts.test.ts +++ b/packages/acp-bridge/src/sessionArtifacts.test.ts @@ -1545,6 +1545,37 @@ describe('SessionArtifactStore', () => { ).rejects.toMatchObject({ field: 'url' }); }); + it('rejects external urls with secret-like query or fragment', async () => { + const store = new SessionArtifactStore({ + sessionId: 's5-url-secrets', + workspaceCwd: workspace, + }); + + await expect( + store.upsertMany( + [ + { + title: 'Signed link', + url: 'https://example.com/report?access_token=abc', + }, + ], + { strict: true }, + ), + ).rejects.toMatchObject({ field: 'url' }); + + await expect( + store.upsertMany( + [ + { + title: 'Fragment token', + url: 'https://example.com/report#access_token=abc', + }, + ], + { strict: true }, + ), + ).rejects.toMatchObject({ field: 'url' }); + }); + it('accepts line whitespace in descriptions but not titles', async () => { const store = new SessionArtifactStore({ sessionId: 's5-line-whitespace', @@ -1739,7 +1770,7 @@ describe('SessionArtifactStore', () => { ).rejects.toMatchObject({ field: 'workspacePath' }); }); - it('rejects dangling symlinks that point outside the workspace', async () => { + it('rejects absolute dangling symlinks that point outside the workspace', async () => { const store = new SessionArtifactStore({ sessionId: 's6-dangling-symlink', workspaceCwd: workspace, @@ -1995,6 +2026,87 @@ describe('SessionArtifactStore', () => { } }); + it('marks workspace artifacts missing when the file is swapped before open', async () => { + const store = new SessionArtifactStore({ + sessionId: 's7-open-swap', + workspaceCwd: workspace, + }); + const target = path.join(workspace, 'swap.txt'); + const replacement = path.join(workspace, 'swap-replacement.txt'); + await fs.writeFile(target, 'before'); + const created = await store.upsertMany([ + { title: 'Swap', workspacePath: 'swap.txt' }, + ]); + const artifactId = created.changes[0]!.artifactId; + const realTarget = await fs.realpath(target); + + vi.useFakeTimers(); + vi.setSystemTime(new Date(Date.now() + 6_000)); + const originalLstat = fs.lstat.bind(fs); + let swapped = false; + const lstatSpy = vi.spyOn(fs, 'lstat').mockImplementation(async (entry) => { + const stat = await originalLstat(entry); + if (!swapped && String(entry) === realTarget) { + swapped = true; + await fs.writeFile(replacement, 'after'); + await fs.rename(replacement, target); + } + return stat; + }); + + try { + const artifact = await store.get(artifactId); + expect(artifact).toMatchObject({ id: artifactId, status: 'missing' }); + expect(artifact).not.toHaveProperty('workspacePath'); + } finally { + lstatSpy.mockRestore(); + vi.useRealTimers(); + } + }); + + it('rejects workspace paths that become symlinks before open', async () => { + const store = new SessionArtifactStore({ + sessionId: 's7-open-nofollow', + workspaceCwd: workspace, + }); + await fs.writeFile(path.join(workspace, 'loop.txt'), 'content'); + const openSpy = vi.spyOn(fs, 'open').mockImplementation(async (entry) => { + if (String(entry).endsWith('loop.txt')) { + throw Object.assign(new Error('too many symbolic links'), { + code: 'ELOOP', + }); + } + throw new Error('unexpected open'); + }); + + try { + await expect( + store.upsertMany([{ title: 'Loop', workspacePath: 'loop.txt' }], { + strict: true, + }), + ).rejects.toMatchObject({ field: 'workspacePath' }); + } finally { + openSpy.mockRestore(); + } + }); + + it('rejects relative dangling symlinks that point outside the workspace', async () => { + const store = new SessionArtifactStore({ + sessionId: 's7-dangling-symlink', + workspaceCwd: workspace, + }); + await fs.symlink( + '../outside-missing.txt', + path.join(workspace, 'dangling'), + ); + + await expect( + store.upsertMany([{ title: 'Dangling', workspacePath: 'dangling' }], { + strict: true, + }), + ).rejects.toMatchObject({ field: 'workspacePath' }); + }); + it('uses cached workspace status while the refresh ttl is fresh', async () => { const store = new SessionArtifactStore({ sessionId: 's7-status-cache', diff --git a/packages/acp-bridge/src/sessionArtifacts.ts b/packages/acp-bridge/src/sessionArtifacts.ts index 1300a53b82d..bba954f6dbf 100644 --- a/packages/acp-bridge/src/sessionArtifacts.ts +++ b/packages/acp-bridge/src/sessionArtifacts.ts @@ -903,7 +903,7 @@ export class SessionArtifactStore { } else if (change.reason === 'eviction') { this.stickyEphemeralIds.delete(change.artifactId); } else if (change.reason === 'unpin_to_ephemeral') { - this.stickyEphemeralIds.add(change.artifactId); + this.rememberStickyEphemeral(change.artifactId); } continue; } @@ -915,6 +915,16 @@ export class SessionArtifactStore { } } + private rememberStickyEphemeral(artifactId: string): void { + this.stickyEphemeralIds.delete(artifactId); + this.stickyEphemeralIds.add(artifactId); + while (this.stickyEphemeralIds.size > MAX_STICKY_EPHEMERAL_IDS) { + const oldest = this.stickyEphemeralIds.values().next().value; + if (oldest === undefined) break; + this.stickyEphemeralIds.delete(oldest); + } + } + private rememberTombstone(change: SessionArtifactChange): void { this.tombstonedIds.delete(change.artifactId); this.tombstonedIds.add(change.artifactId); @@ -2102,6 +2112,12 @@ function normalizeArtifactUrl(raw: unknown, allowFile: boolean): string { 'url', ); } + if (hasSecretLikeUrlComponent(parsed)) { + throw new SessionArtifactValidationError( + 'url must not include secret-like query or fragment', + 'url', + ); + } if ( parsed.protocol !== 'http:' && parsed.protocol !== 'https:' && @@ -2117,6 +2133,22 @@ function normalizeArtifactUrl(raw: unknown, allowFile: boolean): string { return parsed.href; } +function hasSecretLikeUrlComponent(parsed: URL): boolean { + for (const key of parsed.searchParams.keys()) { + if (isSecretLikeUrlText(key)) { + return true; + } + } + const fragment = parsed.hash.slice(1); + return fragment !== '' && isSecretLikeUrlText(fragment); +} + +function isSecretLikeUrlText(value: string): boolean { + return /(?:^|[-_.])(token|secret|password|passwd|pwd|cookie|authorization|credential|signature|sig|api[-_]?key|access[-_]?key)(?:$|[-_.=&#])/i.test( + value, + ); +} + function normalizeMetadata( metadata: unknown, options: { budget?: 'user' | 'persisted' } = {}, diff --git a/packages/core/src/services/chatRecordingService.test.ts b/packages/core/src/services/chatRecordingService.test.ts index be4a8693925..01147bf0a97 100644 --- a/packages/core/src/services/chatRecordingService.test.ts +++ b/packages/core/src/services/chatRecordingService.test.ts @@ -1145,6 +1145,35 @@ describe('ChatRecordingService', () => { expect(third.parentUuid).toBe(second.uuid); }); + it('does not append a strict artifact record after a previous strict write failed', async () => { + vi.mocked(jsonl.writeLine) + .mockRejectedValueOnce(new Error('corrupt journal')) + .mockResolvedValue(undefined); + + await expect( + chatRecordingService.recordSessionArtifactEvent({ + v: 2, + sessionId: 'test-session-id', + sequence: 1, + recordedAt: '2026-07-04T00:00:00.000Z', + changes: [], + }), + ).rejects.toThrow('corrupt journal'); + + await expect( + chatRecordingService.recordSessionArtifactSnapshot({ + v: 2, + sessionId: 'test-session-id', + sequence: 2, + recordedAt: '2026-07-04T00:00:01.000Z', + artifacts: [], + tombstonedIds: [], + stickyEphemeralIds: [], + }), + ).rejects.toThrow('corrupt journal'); + expect(jsonl.writeLine).toHaveBeenCalledTimes(1); + }); + it('keeps artifact journal records out of the active conversation chain', async () => { chatRecordingService.recordUserMessage([{ text: 'before artifact' }]); await chatRecordingService.flush(); diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index ef05ab5f4cb..b80e9e2e3f1 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -790,9 +790,9 @@ export class ChatRecordingService { if (updateActiveTail) { this.lastRecordUuid = record.uuid; } - this.writeChain = this.writeChain - .catch(() => {}) - .then(() => jsonl.writeLine(conversationFile, record)); + this.writeChain = this.writeChain.then(() => + jsonl.writeLine(conversationFile, record), + ); try { await this.writeChain; From d37e8d2df37a694368feb0d1f764e69e684d5d6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Tue, 7 Jul 2026 05:48:52 +0800 Subject: [PATCH 61/61] fix(daemon): close artifact persistence review gaps --- .../acp-bridge/src/sessionArtifacts.test.ts | 113 ++++++++++++++++-- packages/acp-bridge/src/sessionArtifacts.ts | 26 ++-- .../src/daemon/acpRouteTable.ts | 15 ++- .../test/unit/acpRouteTable.test.ts | 16 +++ 4 files changed, 146 insertions(+), 24 deletions(-) diff --git a/packages/acp-bridge/src/sessionArtifacts.test.ts b/packages/acp-bridge/src/sessionArtifacts.test.ts index 00c3061ebbc..d0fce59acb6 100644 --- a/packages/acp-bridge/src/sessionArtifacts.test.ts +++ b/packages/acp-bridge/src/sessionArtifacts.test.ts @@ -270,6 +270,27 @@ describe('SessionArtifactStore', () => { }); }); + it('ignores explicit client retention flags from non-client sources', async () => { + const store = new SessionArtifactStore({ + sessionId: 's1-client-retained-source', + workspaceCwd: workspace, + }); + + const created = await store.upsertMany([ + { + title: 'Tool retained', + source: 'tool', + clientRetained: true, + url: 'https://example.com/tool-retained', + }, + ]); + + expect(created.changes[0]?.artifact).toMatchObject({ + source: 'tool', + clientRetained: false, + }); + }); + it('skips cross-client upsert conflicts without dropping the batch', async () => { const store = new SessionArtifactStore({ sessionId: 's1-client-upsert-owner-batch', @@ -445,6 +466,39 @@ describe('SessionArtifactStore', () => { expect(sequenceState.receivedSeq).toBe(2); }); + it('does not consume persistence sequence when strict event writes fail', async () => { + let fail = false; + const events: SessionArtifactEventRecordPayload[] = []; + const store = new SessionArtifactStore({ + sessionId: 's1-persistence-sequence-rollback', + workspaceCwd: workspace, + persistence: { + recordEvent: async (payload) => { + if (fail) throw new Error('persist failed'); + events.push(payload); + }, + recordSnapshot: async () => {}, + }, + }); + + fail = true; + await expect( + store.upsertMany( + [{ title: 'First', url: 'https://example.com/sequence-first' }], + { strict: true }, + ), + ).rejects.toThrow('persist failed'); + + fail = false; + await store.upsertMany( + [{ title: 'Second', url: 'https://example.com/sequence-second' }], + { strict: true }, + ); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ sequence: 1 }); + }); + it('serializes concurrent store operations', async () => { const store = new SessionArtifactStore({ sessionId: 's1-queue', @@ -2441,6 +2495,7 @@ describe('SessionArtifactStore', () => { } expect(snapshotAttempts).toBe(2); expect(snapshots).toHaveLength(1); + expect(snapshots[0]).toMatchObject({ sequence: 101 }); for (let index = 100; index < 149; index++) { await store.upsertMany( @@ -2466,6 +2521,7 @@ describe('SessionArtifactStore', () => { ); expect(snapshotAttempts).toBe(3); expect(snapshots).toHaveLength(2); + expect(snapshots[1]).toMatchObject({ sequence: 152 }); const logged = stderr.mock.calls.map((call) => String(call[0])).join(''); expect(logged).toContain('snapshot_failed'); @@ -2513,13 +2569,19 @@ describe('SessionArtifactStore', () => { snapshots[0]?.artifacts.some((artifact) => artifact.id === deletedId), ).toBe(false); - const suppressed = await store.upsertMany([ + const rerun = await store.upsertMany([ { title: 'Deleted', url: 'https://example.com/deleted' }, ]); - expect(suppressed.changes).toEqual([]); + expect(rerun.changes).toMatchObject([ + { + action: 'created', + artifactId: deletedId, + artifact: { source: 'tool' }, + }, + ]); }); - it('suppresses implicit upserts for restored tombstones', async () => { + it('allows tool reruns to supersede restored delete tombstones', async () => { const sessionId = 's11-restored-tombstone'; const input = { title: 'Old tool result', @@ -2544,18 +2606,45 @@ describe('SessionArtifactStore', () => { stickyEphemeralIds: [], warnings: [], }); - const suppressed = await store.upsertMany([input]); - expect(suppressed.changes).toEqual([]); - - const explicitClient = await store.upsertMany([ + const rerun = await store.upsertMany([input]); + expect(rerun.changes).toMatchObject([ { - ...input, - source: 'client', - clientId: 'client-a', - retention: 'restorable', + action: 'created', + artifactId, + artifact: { source: 'tool' }, }, ]); - expect(explicitClient.changes).toEqual([]); + }); + + it('keeps stale client upserts suppressed by restored tombstones', async () => { + const sessionId = 's11-restored-client-tombstone'; + const input = { + title: 'Old client result', + source: 'client' as const, + clientId: 'client-a', + url: 'https://example.com/tombstoned-client', + }; + const seed = new SessionArtifactStore({ + sessionId, + workspaceCwd: workspace, + }); + const artifactId = (await seed.upsertMany([input])).changes[0]!.artifactId; + const store = new SessionArtifactStore({ + sessionId, + workspaceCwd: workspace, + }); + + await store.restore({ + v: 2, + sessionId, + sequence: 1, + artifacts: [], + tombstonedIds: [artifactId], + stickyEphemeralIds: [], + warnings: [], + }); + const suppressed = await store.upsertMany([input]); + expect(suppressed.changes).toEqual([]); }); it('keeps restore warnings visible on the artifact list', async () => { diff --git a/packages/acp-bridge/src/sessionArtifacts.ts b/packages/acp-bridge/src/sessionArtifacts.ts index bba954f6dbf..24d0ed201cd 100644 --- a/packages/acp-bridge/src/sessionArtifacts.ts +++ b/packages/acp-bridge/src/sessionArtifacts.ts @@ -655,8 +655,12 @@ export class SessionArtifactStore { } return this.enqueue(async () => { const recordedAt = new Date().toISOString(); + const sequence = this.persistenceSeq + 1; try { - await persistence.recordSnapshot(this.buildSnapshotPayload(recordedAt)); + await persistence.recordSnapshot( + this.buildSnapshotPayload(recordedAt, sequence), + ); + this.persistenceSeq = sequence; this.durableEventsSinceSnapshot = 0; this.consecutiveSnapshotFailures = 0; return []; @@ -768,10 +772,11 @@ export class SessionArtifactStore { change.artifact = artifact; } + const sequence = this.persistenceSeq + 1; const payload: SessionArtifactEventRecordPayload = { v: SESSION_ARTIFACT_PERSISTENCE_VERSION, sessionId: this.sessionId, - sequence: ++this.persistenceSeq, + sequence, recordedAt, changes: durableChanges.map((change) => toPersistedChange(change, recordedAt), @@ -780,6 +785,7 @@ export class SessionArtifactStore { try { await this.persistence.recordEvent(payload); + this.persistenceSeq = sequence; for (const change of durableChanges) { if (change.action === 'removed') continue; const stored = this.artifacts.get(change.artifactId); @@ -818,10 +824,12 @@ export class SessionArtifactStore { if (this.durableEventsSinceSnapshot < snapshotThreshold) { return; } + const sequence = this.persistenceSeq + 1; try { await this.persistence.recordSnapshot( - this.buildSnapshotPayload(recordedAt), + this.buildSnapshotPayload(recordedAt, sequence), ); + this.persistenceSeq = sequence; this.durableEventsSinceSnapshot = 0; this.consecutiveSnapshotFailures = 0; } catch (error) { @@ -839,6 +847,7 @@ export class SessionArtifactStore { private buildSnapshotPayload( recordedAt: string, + sequence: number, ): SessionArtifactSnapshotRecordPayload { const artifacts = Array.from(this.artifacts.values()) .filter((artifact) => artifact.retention !== 'ephemeral') @@ -852,7 +861,7 @@ export class SessionArtifactStore { return { v: SESSION_ARTIFACT_PERSISTENCE_VERSION, sessionId: this.sessionId, - sequence: ++this.persistenceSeq, + sequence, recordedAt, artifacts, tombstonedIds: Array.from(this.tombstonedIds), @@ -1090,9 +1099,10 @@ export class SessionArtifactStore { ? { persistenceWarning: 'persistence_unavailable' as const } : {}), clientRetained: - input.clientRetained !== undefined + source === 'client' && + (input.clientRetained !== undefined ? input.clientRetained === true - : source === 'client', + : true), createdAt: now, updatedAt: now, toolCallId: normalizeString(input.toolCallId, 'toolCallId', 200, false), @@ -1114,8 +1124,10 @@ export class SessionArtifactStore { return false; } const tombstonedClientId = this.tombstonedClientIds.get(artifact.id); + if (artifact.source !== 'client') { + return false; + } return !( - artifact.source === 'client' && artifact.retentionExplicit && artifact.clientId !== undefined && artifact.clientId === tombstonedClientId diff --git a/packages/sdk-typescript/src/daemon/acpRouteTable.ts b/packages/sdk-typescript/src/daemon/acpRouteTable.ts index 5a20294c2b8..36370739b09 100644 --- a/packages/sdk-typescript/src/daemon/acpRouteTable.ts +++ b/packages/sdk-typescript/src/daemon/acpRouteTable.ts @@ -286,11 +286,16 @@ export const ROUTE_TABLE: readonly RouteEntry[] = [ pattern: /^\/session\/([^/]+)\/artifacts\/([^/]+)$/, mapping: { method: '_qwen/session/artifacts/remove', - extractParams: (segs, body) => ({ - ...(isRecord(body) ? body : {}), - sessionId: segs[0], - artifactId: segs[1], - }), + extractParams: (segs, body) => { + const record = isRecord(body) ? body : {}; + return { + sessionId: segs[0], + artifactId: segs[1], + ...(typeof record.clientId === 'string' + ? { clientId: record.clientId } + : {}), + }; + }, }, }, // POST /session/:id/recap → _qwen/session/recap diff --git a/packages/sdk-typescript/test/unit/acpRouteTable.test.ts b/packages/sdk-typescript/test/unit/acpRouteTable.test.ts index 15bdb8f4c2f..3b4cbb0910c 100644 --- a/packages/sdk-typescript/test/unit/acpRouteTable.test.ts +++ b/packages/sdk-typescript/test/unit/acpRouteTable.test.ts @@ -363,6 +363,22 @@ describe('acpRouteTable – matchRoute', () => { sessionId: 's8', artifactId: 'art 1', }); + expect( + result!.mapping.extractParams( + result!.segments, + { + clientId: 'client-a', + sessionId: 'body-session', + artifactId: 'body-artifact', + deleteContent: true, + }, + 'DELETE', + ), + ).toEqual({ + sessionId: 's8', + artifactId: 'art 1', + clientId: 'client-a', + }); }); it('POST /session/:id/recap maps to _qwen/session/recap', () => {