From 53243de0c0d520e4a1688cba23253eb22163ea10 Mon Sep 17 00:00:00 2001 From: ChiGao Date: Thu, 9 Jul 2026 21:04:16 +0800 Subject: [PATCH] feat(daemon): persist session artifacts across restarts (#6557) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(daemon): persist session artifact metadata * fix(daemon): address artifact restore review findings * fix(daemon): harden artifact persistence restore * fix(daemon): align artifact persistence review decisions * fix(daemon): address artifact persistence review gaps * fix(daemon): harden artifact persistence recovery * fix(daemon): align artifact ownership capability * fix(daemon): preserve marker identity during fork * fix(daemon): roll back durable replacement removals * fix(daemon): surface artifact rollback warnings * fix(daemon): surface restore warning details * fix(daemon): preserve artifact marker metadata safely * fix(daemon): sanitize fork marker metadata * fix(daemon): harden artifact restore boundaries * fix(daemon): omit orphaned sticky snapshot markers * fix(daemon): preserve artifact tombstone and rewind warnings * fix(daemon): address artifact fork review blockers --------- Co-authored-by: 秦奇 Co-authored-by: 易良 <1204183885@qq.com> Co-authored-by: qwen-code-dev-bot --- ...session-artifacts-persistence-v2-design.md | 893 +++++ .../cli/qwen-serve-routes.test.ts | 1 + packages/acp-bridge/src/bridge.test.ts | 641 +++- packages/acp-bridge/src/bridge.ts | 234 +- packages/acp-bridge/src/bridgeClient.test.ts | 6 + packages/acp-bridge/src/bridgeClient.ts | 4 +- packages/acp-bridge/src/bridgeTypes.ts | 18 +- .../acp-bridge/src/sessionArtifacts.test.ts | 2909 ++++++++++++++++- packages/acp-bridge/src/sessionArtifacts.ts | 1749 +++++++++- packages/acp-bridge/src/status.ts | 1 + .../cli/src/acp-integration/acpAgent.test.ts | 361 ++ packages/cli/src/acp-integration/acpAgent.ts | 162 +- .../session/HistoryReplayer.test.ts | 19 + .../session/HistoryReplayer.ts | 1 + packages/cli/src/serve/acp-http/dispatch.ts | 25 +- .../cli/src/serve/acp-http/transport.test.ts | 81 +- packages/cli/src/serve/acp-session-bridge.ts | 5 +- packages/cli/src/serve/capabilities.ts | 6 + packages/cli/src/serve/fast-path-settings.ts | 17 + packages/cli/src/serve/fast-path.test.ts | 4 + packages/cli/src/serve/routes/session.ts | 41 +- packages/cli/src/serve/run-qwen-serve.ts | 22 + packages/cli/src/serve/server.test.ts | 173 +- packages/cli/src/serve/server.ts | 3 + .../cli/src/serve/server/error-response.ts | 10 + .../cli/src/serve/server/serve-features.ts | 3 + packages/core/src/index.ts | 1 + .../src/services/chatRecordingService.test.ts | 88 + .../core/src/services/chatRecordingService.ts | 69 +- .../session-artifact-persistence.test.ts | 1075 ++++++ .../services/session-artifact-persistence.ts | 1044 ++++++ .../core/src/services/sessionService.test.ts | 670 ++++ packages/core/src/services/sessionService.ts | 180 +- packages/sdk-typescript/scripts/build.js | 10 +- .../src/daemon/acpRouteTable.ts | 14 +- packages/sdk-typescript/src/daemon/index.ts | 13 + packages/sdk-typescript/src/daemon/types.ts | 53 +- .../test/unit/acpRouteTable.test.ts | 21 +- 38 files changed, 10441 insertions(+), 186 deletions(-) create mode 100644 docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md 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/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 0000000000..d6754b8520 --- /dev/null +++ b/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md @@ -0,0 +1,893 @@ +# Qwen Code Daemon Session Artifacts V2 持久化设计 + +本文延续 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、mtimeMs 和 sha256 作为恢复后的完整性校验。 + +## 1. 设计结论 + +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. Workspace integrity check:workspace artifact 登记时记录 size + mtimeMs + sha256;restore / GET 时按实时文件返回 `available` / `missing` / `changed`。 + +对应 capability: + +- `session_artifacts_persistence`:支持 metadata 持久化与 session load/replay 恢复。 +- `session_artifacts_content_retention`:当前不声明;后续如果重启 content archive 设计,必须在复制/托管内容、配额、manifest 和 GC/fsck 都完成后再声明。 + +核心原则: + +- V1 的 `SessionArtifactStore` 仍是 live session 的权威内存索引。 +- 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 文件。 +- 不把 client 传入的 `source`、`clientId`、`trustedPublisher` 当授权依据。 +- 恢复时必须重新校验,不信任磁盘上的旧 metadata。 + +当前 PR 的重要收窄: + +- 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。 + +## 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。 + +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`;旧 journal 中的 `pinned` 会在 restore / fork 时降级为 metadata-only `restorable`: + +```ts +type ArtifactRetention = 'ephemeral' | 'restorable'; +``` + +含义: + +- `ephemeral`:只存在于 live store。daemon/session 消失后不恢复。 +- `restorable`:metadata 写入持久化 journal。session load/replay 后恢复为 artifact item,但不保证底层资源仍存在。 + +默认规则: + +- 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`;当前只恢复 published locator,不托管内容。 + +如果 chat recording 被禁用,metadata persistence 默认禁用,capability 不声明。 + +### 2.3 用户注册 artifact 恢复语义 + +用户手动注册的 artifact 在 V2 恢复后应该继续存在,但恢复的是“artifact metadata item”,不是无条件内容备份。 + +恢复后的结果按资源状态区分: + +- `external_url`:恢复 title、description、url、metadata。daemon 不访问远端 URL;URL 是否仍可打开由 client 点击时决定。 +- `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。 + +因此,“用户注册的 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 和审计记录约束。 + +## 3. 数据模型 + +### 3.1 Public artifact 扩展 + +V2 在 V1 response artifact 上增加 optional fields: + +```ts +interface DaemonSessionArtifact { + // V1 fields... + status: 'available' | 'missing' | 'changed'; + retention?: 'ephemeral' | 'restorable'; + persistedAt?: string; + restoreState?: 'live' | 'restored' | 'unverified' | 'blocked'; + persistenceWarning?: + | 'persistence_unavailable' + | 'metadata_only_restore' + | 'restore_validation_failed' + | 'sticky_override_active'; + metadata?: { + 'qwen.workspace.sha256'?: string; + 'qwen.workspace.mtimeMs'?: number; + [key: string]: string | number | boolean | null | undefined; + }; +} +``` + +字段说明: + +- `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 最近成功落盘时间。 +- `restoreState`:恢复来源提示;不替代 `status`。 +- `persistenceWarning`:非阻塞持久化/恢复风险,前端可用它提示“此 artifact 不会跨重启保留”等状态。当前 wire shape 是固定字符串,避免把 host 绝对路径、credential、token、内部 storage path 或 connection id 写入 response。更结构化的 `{ code, message }` 可作为后续兼容扩展。 +- `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 的关系 + +V1 `status` 继续表示当前资源是否可用: + +- `available` +- `missing` +- `changed` + +V2 只新增 `changed` 这一种 workspace integrity 状态。它表示路径仍可访问,但实时文件的 size 已变化,或 mtime 变化后 sha256 与登记时的 metadata 不一致。`blocked` 不是 `status`,只属于 `restoreState`: + +- `restored`:从持久化 metadata 恢复。 +- `unverified`:恢复了 metadata,但尚未完成 workspace/managed 校验。 +- `blocked`:恢复时发现安全边界不满足,例如 workspace path 逃逸。 +- `live`:当前进程内新产生或已刷新确认。 + +## 4. 持久化存储设计 + +### 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 中提取。 + +如果未来实测需要 sidecar,它必须作为单独设计进入,并满足两个约束: + +- 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 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`。 +- 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 的恢复。 +- 如果某个实现阶段还不能把 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` 增加: + +```ts +'session_artifact_event' | 'session_artifact_snapshot'; +``` + +Payload: + +```ts +interface SessionArtifactEventRecordPayload { + v: 2; + sessionId: string; + sequence: number; + recordedAt: string; + changes: Array<{ + action: 'created' | 'updated' | 'removed'; + artifactId: string; + artifact?: PersistedSessionArtifact; + reason?: 'explicit' | 'eviction' | 'unpin_to_ephemeral'; + }>; +} + +interface SessionArtifactSnapshotRecordPayload { + v: 2; + sessionId: string; + sequence: number; + recordedAt: string; + artifacts: PersistedSessionArtifact[]; + tombstonedIds?: string[]; + stickyEphemeralIds: string[]; +} + +type PersistedSessionArtifact = Pick< + DaemonSessionArtifact, + | 'id' + | 'kind' + | 'storage' + | 'source' + | 'status' + | 'title' + | 'description' + | 'workspacePath' + | 'managedId' + | 'url' + | 'mimeType' + | 'sizeBytes' + | 'metadata' + | 'createdAt' + | 'updatedAt' +> & { + retention: ArtifactRetention; + persistedAt: string; + clientRetained: boolean; + toolCallId?: string; + toolName?: string; + hookEventName?: string; +}; +``` + +`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` 以及 tool/hook display hints 外,不写 V1 内部字段或运行时派生字段: + +- 不写 `identityKey` +- 不写 `trustedPublisher` +- 不写绝对 `workspaceCwd` +- 不写 transport token / auth principal +- 不写 `restoreState` +- 不写 `persistenceWarning` +- 不写 `clientId` 或 live-process owner principal;`source` 只作为显示/审计 hint,不能用于授权 + +删除 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 会丢失。 + +### 4.4 Snapshot 与 tombstone 不变量 + +artifact snapshot 只用于减少 replay 的 artifact event 应用量;它不会减少 JSONL 文件本身的读取量。 + +必须满足: + +- snapshot generation 必须在同一个 artifact operation queue 中串行执行,并严格位于所有 preceding mutation 之后。 +- 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_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。 +- 如果没有任何 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: + +- 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。 + +控制策略: + +- 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 baseline advance 成功后重置 counter。若 writer 无法确认 base position 或 counter 状态,必须保守写新 snapshot;仍无法确认时降级或报错,不能无界追加。 +- 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. 写入与恢复流程 + +### 5.1 Ingest-time validation + +任何 artifact 进入 live store 和 JSONL 之前都必须做 ingest-time validation,不能只在 restore 时校验: + +- `workspacePath`:必须是相对路径;resolve/realpath 后不能逃逸当前 workspace。 +- `url`:按 storage type 校验 scheme、userinfo、secret-like query/fragment。 +- `managedId`:拒绝路径形态、`..`、绝对路径、分隔符。 +- `published`:只能由 daemon 内部 trusted publisher 或 manifest-validated path 产生,不能由 client payload 自称。 +- 旧 `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。 + +验证失败时: + +- 明确恶意或越界输入:拒绝请求。 +- 可能包含敏感 locator 但用户仍想展示 live artifact:可降级为 `ephemeral`,并写 `persistenceWarning.code = "validation_downgraded"`;不能写入 JSONL。 + +### 5.2 Artifact 写入流程 + +V1 流程: + +```text +ingest input -> normalize/validate -> upsert live store -> publish artifact_changed +``` + +V2 流程: + +```text +ingest input + -> normalize/validate + -> in SessionArtifactStore operationQueue: compute effective mutation + -> 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。 + +如果 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` artifact 被上限裁剪时,写 `reason: "eviction"` remove event,避免下次 load/replay 把已裁剪条目全部复活。 + +### 5.3 写入失败语义 + +区分两个入口: + +- 普通 tool/hook artifact:持久化失败不应让工具调用失败;artifact 仍可进入 live store,但必须先把 live store 中的 `retention` 降级为 `ephemeral`,设置 `persistenceWarning`,再发布 `artifact_changed`。 + 对会影响恢复结果的删除型 mutation,当前 PR 按原因区分: + +- `eviction`:durable remove event,保证重启后仍遵守 200 条上限。 +- 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。 + +建议 warning: + +```text +[artifacts] session= action=persist_failed artifact= reason= +[artifacts] session= action=remove_not_persisted artifact= +[artifacts] session= action=sticky_override_suppressed artifact= prior_reason=unpin_to_ephemeral +``` + +### 5.4 恢复流程 + +session load/replay 时: + +1. `SessionService.loadSession()` 读取 JSONL,并在同一轮 parse 中提取 artifact snapshot/event records。 +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。 +6. daemon bridge 在 `createSessionEntry` / restore completion 时用 snapshot 初始化 daemon 侧 `SessionArtifactStore`。 +7. `GET /session/:id/artifacts` 读取的就是这个 daemon-side store。 + +不要在 ACP child process 的 agent/session 对象里 seed `SessionArtifactStore`:生产 HTTP API 可见的 store 在 daemon-side bridge 中创建。 + +`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。 + +具体集成点必须是显式 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 恢复时校验 + +恢复时必须重新校验: + +- `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`:拒绝路径形态、`..`、绝对路径、分隔符。 +- 旧 `contentRef`:只作为 legacy journal 输入校验并 strip;PR #6259 不通过 daemon-managed manifest 解析内容,也不把旧 `contentRef` 暴露为可打开内容承诺。 +- `metadata`:重新执行 primitive-only、size limit、secret key/value 和 unsafe display payload checks。 + +恢复失败时: + +- 安全失败:保留条目但 `restoreState: "blocked"`,`status: "missing"`,不提供可打开 locator。 +- 资源缺失:`status: "missing"`。 +- 非安全型字段损坏:跳过该 artifact,并记录 warning。 + +### 5.6 Branch / fork 语义 + +现有 `/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 的 artifact id 可以安全 remap,就应保留到目标 session,即使目标 active chain 中暂时找不到对应 upsert;orphan tombstone 没有匹配 upsert 时是无害的,但丢弃它可能让后续同 id upsert 丢失 suppression。 +- `forkedFrom` 可以记录原 session id / 原 artifact id,作为审计信息,但不能参与新 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。 + +当前 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。 + +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 设计 + +### 6.1 Capability + +`GET /capabilities` 增加: + +```json +"session_artifacts_persistence" +``` + +内容保留拆分 PR 实现可用时,才同时声明: + +```json +"session_artifacts_content_retention" +``` + +当前 `/capabilities` 是 string feature list,因此不能用 `enabled: false` 表达“实现存在但当前关闭”。规则是: + +- 行为可用且当前配置启用时才声明对应 feature string。 +- chat recording 禁用、metadata persistence 禁用或 writer 不可用时,不声明 `session_artifacts_persistence`。 +- 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 + +`POST /session/:id/artifacts` 允许 optional: + +```json +{ + "title": "Report", + "kind": "html", + "storage": "workspace", + "workspacePath": "reports/run.html", + "retention": "restorable", + "clientRetained": true +} +``` + +限制: + +- client 可以请求 `ephemeral` 或 `restorable`。 +- client 不能请求 `pinned`。 +- `clientRetained` 可选,仅表示用户保留意图和排序 hint;服务端必须按 §5.1 校验来源,不能把它当授权。 + +### 6.3 Pin/save artifact + +PR #6259 不暴露 pin/save endpoint。显式内容留档、content archive、pin/save 语义如未来需要,应基于新的产品需求重新设计,不能从本文当前 metadata persistence contract 推导。 + +### 6.4 Unpin + +PR #6259 不暴露 unpin endpoint,也不会生成新的 unpin tombstone。旧 journal 里的 `reason: "unpin_to_ephemeral"` 只作为兼容输入继续 replay,避免历史记录恢复语义变化。若要从列表移除,仍使用 V1 DELETE。 + +### 6.5 Delete artifact + +V2 的 DELETE 仍保持 V1 幂等,并采用当前 PR 的 live-first 语义: + +- 先从 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。 +- PR #6259 的 DELETE 不接受 `deleteContent`,也不触发 daemon-managed content GC;旧 `contentRef` metadata 只在 restore/serialization 时被降级或移除。 + +### 6.6 Mutation responses + +PR #6259 只交付 DELETE mutation response。 + +成功: + +- DELETE:`200 OK` 返回 `{ "deleted": true, "artifactId": string, "warnings"?: [...] }`。 +- DELETE tombstone 持久化失败时仍返回 `200 OK` mutation result,并在 `warnings` 中包含持久化失败原因;当前实现使用字符串 warning,例如 `remove_not_persisted`。这表示 live delete 已生效但跨重启不保证,不能把它展示成 durable delete 成功。 + +失败: + +```json +{ + "error": { + "code": "INVALID_ARGUMENT", + "message": "retention must be ephemeral or restorable" + } +} +``` + +PR #6259 的 HTTP mapping: + +- `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 已生效但跨重启不保证。 + +更细粒度的 `INVALID_ARGUMENT`、`NOT_FOUND`、`CONFLICT`、`METADATA_QUOTA_EXCEEDED`、`QUOTA_EXCEEDED` 或 `PERSISTENCE_UNAVAILABLE` HTTP error code 是后续 API polish,不属于当前 PR 的 wire contract。 + +## 7. 安全设计 + +### 7.1 授权原则 + +不要把 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;不是 durable authorization source: + +```ts +type ArtifactPrincipal = + | { kind: 'token_holder' } + | { kind: 'client_connection'; id: string } + | { kind: 'trusted_publisher'; id: string } + | { kind: 'hook'; extensionId: string }; +``` + +授权规则: + +- list:需要 session read 权限。 +- add ephemeral/restorable:需要 session mutate 权限。 +- 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。 +- 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 Future content archive 边界 + +本节是 future content archive 蓝图,不属于 PR #6259 的实现或验收范围。 + +默认不复制: + +- external URL 内容 +- 任意 workspace 文件 +- 普通 assistant link + +未来若启用 content archive,可考虑允许的来源: + +- trusted `ArtifactTool` / publisher 生成的 `published` artifact。 +- 用户显式 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 校验通过。 +- 只允许 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。 +- `persistenceWarning.message` 即使只作为 live response 字段,也必须使用 path-free 模板或脱敏文本;不能把 host path、credential、token、content root、connection id 写入 warning。 + +后续可新增设置: + +```json +{ + "sessionArtifacts": { + "persistence": { + "enabled": true, + "defaultRetention": "restorable", + "maxLiveArtifacts": 200, + "maxPersistedMetadata": 200, + "snapshotThresholdMutations": 100, + "snapshotThresholdBytes": 262144, + "contentRetention": { + "enabled": false, + "maxArtifactBytes": 52428800, + "maxTotalBytes": 268435456, + "maxTtlDays": 365, + "ttlScanIntervalSeconds": 900 + } + } + } +} +``` + +当前 PR 不新增 operator 配置 schema;上述值以代码常量形式发布,并通过 capability 表达行为是否可用。把这些值暴露为 operator tunables 是后续增强,不能让 client 从 capability string 推断配置细节。 + +## 8. 配额、GC 与稳定性 + +### 8.1 Metadata quota + +建议默认: + +- live store 上限仍为 200。 +- persisted metadata 上限每 session 200,与 live store 对齐。 +- snapshot record 最多保留 200 个当前有效 artifacts。 + +live store 上限在当前实现中也是 restore 可见集合的上限: + +- V2 live eviction 必须优先淘汰 `ephemeral` artifact。 +- 如果必须在 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` 必须按确定性顺序裁剪并写 `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。 + +### 8.2 Content quota + +本节是后续 content-retention PR 的实现范围;PR #6259 不引入 content store quota。 + +后续拆分 PR 的建议默认: + +- 单 artifact:50 MB。 +- content store total:256 MB。 + +达到上限时: + +- 新 pin/save 返回 `QUOTA_EXCEEDED`。 +- 不自动删除仍被当前 session live artifact 引用的 pinned content。 +- fork 不继承 pinned contentRef,避免 fork 绕过 quota。 + +### 8.3 GC + +本节是后续 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。 +- `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: + +- 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 都是后续增强。future content archive 的 safety 边界应来自“不跨 session 继承 contentRef”和“只删除当前 session manifest 且当前 live refs 未引用的 content”。 + +### 8.4 Crash consistency + +要求: + +- artifact store mutation 串行。 +- JSONL journal append 失败不会破坏 live store。 +- 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 顺序异常时选择不恢复,而不是猜测。 + +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;这是允许的,future session-scoped GC 在确认 manifest 不被当前 live refs 引用后 best-effort 删除。如果第 3 步成功,restore 必须能通过 manifest 找到内容。显式 API 只有在第 3 步成功后才能返回成功。 + +### 8.5 文件读取、CPU 与 I/O 成本 + +V2 要避免把 artifact 恢复变成 session load 的新瓶颈。 + +读取路径建议: + +1. `SessionService.loadSession()` 已经读取 JSONL 时,在同一轮 parse 中提取 artifact records。 +2. 找到最新 valid `session_artifact_snapshot`,只 replay 之后的 artifact events。 +3. 没有 valid snapshot 时允许一次顺序扫描 artifact records,但不能在 load 流程里反复扫同一文件。 + +CPU 成本边界: + +- Metadata restore 只 parse JSON 和做字段校验,复杂度 O(artifact 数量 + 最新 snapshot 后事件数)。 +- `external_url` 恢复不发网络请求。 +- `workspace` load/replay 只恢复 metadata;GET/list refresh 在 TTL/batch 限制下重新 stat 单个或一批 workspace 文件,必要时才 hash,用于区分 `available` / `missing` / `changed`。 +- `managed` / `published` 恢复只查 manifest,不读取大文件内容。 +- 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 文件,不在恢复阶段读内容;登记时读取实时文件流计算 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,并通过 size + mtimeMs 避免对未变化文件重复做全量 hash。 + +### 8.6 Observability + +V2 新增的失败路径必须有 structured logs,格式沿用: + +```text +[artifacts] session= action= key=value +``` + +建议 action: + +- `persist_failed` +- `retention_downgraded` +- `restore_skipped` +- `restore_blocked` +- `remove_not_persisted` +- `eviction` +- `fork_artifact_discarded` +- `fork_incomplete` +- `snapshot_invalid` +- `sticky_override_suppressed` +- `tombstone_conflict` +- `v2_writer_version_gate_failed` + +Future checker / content archive 可以再增加 fsck、content copy、TTL、GC 相关 action;PR #6259 不产生这些日志。 + +这些日志不替代 API/SSE 中的 `persistenceWarning`,而是用于生产排障。 + +建议 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}` +- counter: `artifact_sticky_override_suppressed_total` + +导出方式沿用 daemon 现有 telemetry/metrics 机制;如果当前没有 Prometheus endpoint,至少要进入 structured telemetry sink,并能按 session/project 聚合。 + +诊断工具是后续增强,不属于 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: + +- metadata-only 模式报告 snapshot/tombstone 不一致和 restore validation failure。 +- full content 模式报告 dangling `contentRef`、manifest 缺失和 orphan content。 +- 默认只读;修复模式只能做可验证的安全动作,例如重新生成 snapshot 或标记 orphan content 等待 GC。 + +## 9. 实现方案 + +以下是同一个 V2 design phase 内的实现里程碑。工程上可以按 PR 拆开;对外以 capability 声明实际可用能力。 + +### Milestone A: 类型和 persistence service + +- 新增 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?`。 +- 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 和 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。 + +### 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 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 + +- SDK type 增加 optional fields。 +- `POST /session/:id/artifacts` 支持 `retention: "ephemeral" | "restorable"`。 +- `POST /session/:id/artifacts` 支持 `clientRetained` boolean hint,并拒绝 client 传入 daemon-only runtime fields。 +- capability gate UI。 + +### Milestone E: Future content archive + +不属于 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。 +- `/rewind` 后 abandoned branch 上的 artifact upsert/remove 不参与恢复,也不会在 fork 中复制。 +- `/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。 +- 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` 达到上限时,legacy unpin-to-ephemeral 返回错误或延后重试,且不会静默丢失旧 sticky override。 +- explicit DELETE live-first:live view 立即移除;tombstone 写入失败时 response 带 warning,测试覆盖 live removal 不被 persistence failure 阻断。 +- 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。 +- external URL 只恢复 metadata,不发网络请求。 +- secret-bearing URL query/fragment 与 metadata key/value 不写入 JSONL。 +- published local `file:` 只有 trusted manifest revalidation 通过时恢复。 +- `managedId` 在 ingest、restore 和 fork remap 时拒绝分隔符、`..`、绝对路径和路径形态;fork 不能盲目复制源 session 的 `managedId`。 +- corrupt JSONL record 被跳过且不影响其它 artifacts。 +- chat recording / persistence disabled 时不声明或不启用 metadata restore。 +- 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 / 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、clientRetained/non-clientRetained 两层排序、全部 clientRetained restorable 仍可按确定性规则裁剪。 +- clientRetained setter:Add artifact request 能设置 boolean hint;后台自动 ingest 不能伪造用户保留。 +- 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。 +- 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。 +- 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 做的事 + +- 自动抓取普通 markdown link。 +- 自动扫描 workspace 文件变更。 +- 默认复制所有 workspace artifact 内容。 +- 对 external URL 做 reachability poll。 +- 把 `clientId` 作为删除授权凭证。 +- 对重定位 workspace 做自动 path remapping。 +- 在 GET 热路径里做大量 fs/network 校验。 +- 把持久化失败变成普通 tool turn 失败。 +- 在没有测量证明需要时引入 sidecar cache。 + +## 12. 推荐发布口径 + +V2 建议作为一个完整 design phase 发布,但能力按 capability 暴露: + +- `session_artifacts_persistence` 可先发布 metadata restore。 +- `session_artifacts_content_retention` 当前不发布;future content archive 需要重新设计并独立声明 capability。 +- 默认恢复显式登记的 artifact metadata。 +- 用户手动注册的 artifact 默认 `restorable`,session load/replay 后继续出现在列表中。 +- 用户文档明确:metadata restore 恢复的是“产物索引”,不是“产物内容备份”;workspace 的 `changed` 状态只说明实时文件和登记时 size 不一致,或 mtime 变化后 hash 不一致。 + +Rollback procedure: + +- V2 records 保留在 chat JSONL 中,不在 rollback 时删除;旧 daemon 能忽略 unknown `system` subtype 时,session load 应继续工作但不恢复 artifact persistence。 +- 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。 + +这样可以讲清楚当前 V2 的完整语义:默认恢复列表,不保存内容,用 workspace size/mtime/hash 避免静默打开错误版本,同时避免对未变化文件重复做全量 hash。 diff --git a/integration-tests/cli/qwen-serve-routes.test.ts b/integration-tests/cli/qwen-serve-routes.test.ts index a48aa1bd2b..81acb10c79 100644 --- a/integration-tests/cli/qwen-serve-routes.test.ts +++ b/integration-tests/cli/qwen-serve-routes.test.ts @@ -248,6 +248,7 @@ describe('qwen serve — capabilities envelope', () => { 'session_cancel', 'session_events', 'session_artifacts', + 'session_artifacts_persistence', '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 97166e15c7..90ca191eb0 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -51,7 +51,13 @@ import type { ChannelFactory } from './channel.js'; import type { BridgeTelemetry } from './bridgeOptions.js'; import { createInMemoryChannel } from './inMemoryChannel.js'; import { EventBus, type BridgeEvent } from './eventBus.js'; -import { ApprovalMode, ShellExecutionService } from '@qwen-code/qwen-code-core'; +import { + ApprovalMode, + SESSION_ARTIFACT_PERSISTENCE_VERSION, + ShellExecutionService, + stableSessionArtifactId, + ToolNames, +} from '@qwen-code/qwen-code-core'; import { FakeAgent, type ChannelHandle, @@ -61,6 +67,7 @@ import { WS_B, SESS_A, } from './internal/testUtils.js'; +import { SessionArtifactAuthorizationError } from './sessionArtifacts.js'; function deferred(): { promise: Promise; @@ -232,9 +239,9 @@ describe('createAcpSessionBridge', () => { { title: 'Client link', source: 'client', - clientId: session.clientId, }, ]); + expect(snapshot.artifacts[0]).not.toHaveProperty('clientId'); expect(snapshot.artifacts[0]).not.toHaveProperty('toolName'); expect(snapshot.artifacts[0]).not.toHaveProperty('hookEventName'); expect(snapshot.artifacts[0]).not.toHaveProperty('toolCallId'); @@ -260,19 +267,26 @@ 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, }), - ).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({ - artifacts: [{ id: artifactId, clientId: first.clientId }], + artifacts: [{ id: artifactId }], }); + const listed = await bridge.getSessionArtifacts(first.sessionId); + expect(listed.artifacts[0]).not.toHaveProperty('clientId'); await expect( bridge.removeSessionArtifact(first.sessionId, artifactId, { @@ -286,6 +300,27 @@ describe('createAcpSessionBridge', () => { } }); + 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('uses bridge telemetry for channel/session/prompt dispatch and prompt metadata injection', async () => { const handle = makeChannel(); const operations: string[] = []; @@ -1365,7 +1400,11 @@ describe('createAcpSessionBridge', () => { lastEventId: 0, }); expect(handles[0]?.agent.loadSessionCalls).toEqual([ - { sessionId: 'persisted-1', cwd: WS_A, mcpServers: [] }, + { + sessionId: 'persisted-1', + cwd: WS_A, + mcpServers: [], + }, ]); expect(bridge.sessionCount).toBe(1); @@ -1380,6 +1419,524 @@ 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: ['skipped corrupt artifact record'], + }, + }) as LoadSessionResponse, + }).channel, + }); + + const loaded = await bridge.loadSession({ + sessionId, + workspaceCwd: WS_A, + }); + expect(loaded.state).not.toHaveProperty('artifactSnapshot'); + expect(loaded.artifactWarnings).toEqual([ + 'skipped corrupt artifact record', + ]); + + await expect( + bridge.getSessionArtifacts(loaded.sessionId), + ).resolves.toMatchObject({ + warnings: ['skipped corrupt artifact record'], + artifacts: [ + { + id: artifactId, + title: 'Restored link', + restoreState: 'restored', + }, + ], + }); + + 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.state).not.toHaveProperty('artifactSnapshot'); + 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({ + 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: 0, + artifacts: [], + tombstonedIds: [], + stickyEphemeralIds: [], + warnings: [], + }, + }; + }, + }).channel, + }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const durable = await bridge.addSessionArtifact( + session.sessionId, + { + title: 'Abandoned durable artifact', + url: 'https://example.com/later-durable', + }, + { clientId: session.clientId }, + ); + 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( + session.sessionId, + { promptId: 'prompt-1' }, + { clientId: session.clientId }, + ), + ).resolves.toMatchObject({ targetTurnIndex: 0 }); + + const artifacts = (await bridge.getSessionArtifacts(session.sessionId)) + .artifacts; + expect(artifacts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + 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(); + }); + + 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('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(); + }); + + it('restores and persists artifact snapshots returned by rewind', async () => { + const retainedUrl = 'https://example.com/retained'; + const rewoundUrl = 'https://example.com/rewound'; + const stickyUrl = 'https://example.com/rewound-sticky'; + const rewoundArtifactId = stableSessionArtifactId( + SESS_A, + `url:${rewoundUrl}`, + ); + const stickyArtifactId = stableSessionArtifactId( + SESS_A, + `url:${stickyUrl}`, + ); + 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: [stickyArtifactId], + markerArtifacts: [ + { + id: stickyArtifactId, + kind: 'link', + storage: 'external_url', + source: 'client', + status: 'available', + title: 'Sticky artifact', + url: stickyUrl, + retention: 'restorable', + clientRetained: true, + createdAt: '2026-07-04T00:00:00.000Z', + updatedAt: '2026-07-04T00:00:00.000Z', + }, + ], + warnings: [], + }, + }; + }, + }).channel, + }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + await bridge.addSessionArtifact( + session.sessionId, + { + title: 'Later artifact', + url: retainedUrl, + }, + { 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, { + 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', reason: 'eviction' }), + expect.objectContaining({ + action: 'created', + artifactId: rewoundArtifactId, + }), + ]), + ); + abort.abort(); + 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, + artifacts: [ + expect.objectContaining({ + id: rewoundArtifactId, + title: 'Rewound artifact', + }), + ], + markerArtifacts: [ + expect.objectContaining({ + id: stickyArtifactId, + title: 'Sticky artifact', + url: stickyUrl, + }), + ], + }), + ]); + expect( + (persistedSnapshots[0] as { artifacts?: unknown[] }).artifacts, + ).toHaveLength(1); + + await bridge.shutdown(); + }); + + it('surfaces rewind artifact snapshot persistence warnings', async () => { + const bridge = makeBridge({ + channelFactory: async () => + makeChannel({ + extMethodImpl: (method, params) => { + if (method === 'qwen/control/session/artifacts/persist') { + if (params['kind'] === 'snapshot') { + throw new Error('disk full'); + } + return {}; + } + expect(method).toBe('qwen/control/session/rewind'); + return { + targetTurnIndex: 0, + filesChanged: [], + filesFailed: [], + artifactSnapshot: { + v: SESSION_ARTIFACT_PERSISTENCE_VERSION, + sessionId: SESS_A, + sequence: 0, + artifacts: [], + tombstonedIds: [], + stickyEphemeralIds: [], + warnings: [], + }, + }; + }, + }).channel, + }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const abort = new AbortController(); + const rewoundEvent = (async () => { + for await (const event of bridge.subscribeEvents(session.sessionId, { + signal: abort.signal, + })) { + if (event.type === 'session_rewound') return event; + } + return undefined; + })(); + + await expect( + bridge.rewindSession( + session.sessionId, + { promptId: 'prompt-1' }, + { clientId: session.clientId }, + ), + ).resolves.toMatchObject({ + targetTurnIndex: 0, + warnings: ['artifact snapshot not persisted'], + }); + await expect(rewoundEvent).resolves.toMatchObject({ + type: 'session_rewound', + data: { warnings: ['artifact snapshot not persisted'] }, + }); + abort.abort(); + + await bridge.shutdown(); + }); + it('loads history replay from response metadata when requested', async () => { const handles: ChannelHandle[] = []; const factory: ChannelFactory = async () => { @@ -1450,6 +2007,73 @@ describe('createAcpSessionBridge', () => { await bridge.shutdown(); }); + it('restores artifacts from response-mode load replay when no snapshot is available', async () => { + const handles: ChannelHandle[] = []; + const factory: ChannelFactory = async () => { + const h = makeChannel({ + loadSessionImpl: (p) => { + expect(p._meta).toMatchObject({ + 'qwen.session.loadReplayMode': 'bulk', + }); + return { + _meta: { + 'qwen.session.loadReplay': { + v: 1, + updates: [ + { + sessionUpdate: 'tool_call_update', + toolCallId: 'call-replayed-artifact', + status: 'completed', + content: [], + _meta: { + toolName: ToolNames.ARTIFACT, + artifacts: [ + { + title: 'Replayed artifact', + url: 'https://example.com/replayed-artifact', + retention: 'restorable', + }, + ], + }, + }, + ], + }, + }, + }; + }, + }); + handles.push(h); + return h.channel; + }; + const bridge = makeBridge({ channelFactory: factory }); + + const loaded = await bridge.loadSession({ + sessionId: 'persisted-replayed-artifact', + workspaceCwd: WS_A, + historyReplay: 'response', + }); + + expect(handles[0]?.agent.loadSessionCalls[0]).toMatchObject({ + sessionId: 'persisted-replayed-artifact', + cwd: WS_A, + mcpServers: [], + _meta: { 'qwen.session.loadReplayMode': 'bulk' }, + }); + await expect( + bridge.getSessionArtifacts(loaded.sessionId), + ).resolves.toMatchObject({ + artifacts: [ + { + title: 'Replayed artifact', + url: 'https://example.com/replayed-artifact', + restoreState: 'live', + }, + ], + }); + + await bridge.shutdown(); + }); + it('bounds response-mode load replay and emits a history_truncated marker', async () => { const factory: ChannelFactory = async () => makeChannel({ @@ -1922,6 +2546,7 @@ describe('createAcpSessionBridge', () => { const first = bridge.loadSession({ sessionId: 'coalesce-replay-mode', workspaceCwd: WS_A, + historyReplay: 'stream', }); for (let i = 0; i < 50 && !releaseLoad; i++) { await new Promise((r) => setTimeout(r, 10)); @@ -12590,7 +13215,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 92ff2af52c..3d0d6fc155 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -17,11 +17,16 @@ import type { SetSessionModelResponse, SessionUpdate, } 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, + SESSION_ARTIFACT_PERSISTENCE_VERSION, TrustGateError, + normalizeSnapshotPayload, ShellExecutionService, type ShellOutputEvent, } from '@qwen-code/qwen-code-core'; @@ -123,6 +128,9 @@ import { import { PermissionForbiddenError } from './bridgeErrors.js'; import { SessionArtifactStore, + isArtifactRestoreFailureWarning, + publicArtifactsEqual, + type DaemonSessionArtifact, type SessionArtifactChange, type SessionArtifactInput, type SessionArtifactMutationResult, @@ -2680,7 +2688,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: [], @@ -2730,6 +2742,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: 'eviction', + }); + } + } + for (const artifact of after) { + const previous = beforeById.get(artifact.id); + if (!previous) { + changes.push({ + action: 'created', + artifactId: artifact.id, + artifact, + }); + continue; + } + if (!publicArtifactsEqual(previous, artifact)) { + changes.push({ + action: 'updated', + artifactId: artifact.id, + artifact, + }); + } + } + return changes; + }; + const makeClientArtifactInput = ( artifact: SessionArtifactInput, clientId: string | undefined, @@ -2745,6 +2797,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) { @@ -2753,6 +2807,34 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { return input; }; + function createSessionArtifactPersistence( + connection: ClientSideConnection, + sessionId: string, + ) { + return { + recordEvent: async (payload: unknown): Promise => { + await connection.extMethod( + SERVE_CONTROL_EXT_METHODS.sessionArtifactsPersist, + { + sessionId, + kind: 'event', + payload, + }, + ); + }, + recordSnapshot: async (payload: unknown): Promise => { + await connection.extMethod( + SERVE_CONTROL_EXT_METHODS.sessionArtifactsPersist, + { + sessionId, + kind: 'snapshot', + payload, + }, + ); + }, + }; + } + // 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 @@ -2858,6 +2940,54 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { return { lastEventId: snapshot.lastEventId, ...replayStatus }; }; + const restoredArtifactSnapshotFromState = ( + state: BridgeSessionState, + ): RebuiltSessionArtifactSnapshot | undefined => { + const candidate = state.artifactSnapshot; + const warnings: string[] = []; + const snapshot = normalizeSnapshotPayload(candidate, warnings); + if (!snapshot) return undefined; + const snapshotWarnings = + isRecord(candidate) && Array.isArray(candidate['warnings']) + ? candidate['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, + ...(snapshot.markerArtifacts + ? { markerArtifacts: snapshot.markerArtifacts } + : {}), + tombstonedIds: snapshot.tombstonedIds ?? [], + stickyEphemeralIds: snapshot.stickyEphemeralIds ?? [], + warnings: [...warnings, ...snapshotWarnings], + }; + }; + + const artifactSnapshotUnavailableReason = ( + state: BridgeSessionState, + ): string | undefined => { + const reason = state.artifactSnapshotUnavailable; + return typeof reason === 'string' && reason ? reason : undefined; + }; + + const publicRestoreState = ( + state: BridgeSessionState, + ): BridgeSessionState => { + const { + artifactSnapshot: _artifactSnapshot, + artifactSnapshotUnavailable: _artifactSnapshotUnavailable, + ...publicState + } = state; + return publicState; + }; + async function restoreSession( action: 'load' | 'resume', req: BridgeRestoreSessionRequest, @@ -3111,16 +3241,34 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { }, ); releaseAdmissionOnce(); - entry.restoreState = state; + const restoredArtifactSnapshot = restoredArtifactSnapshotFromState(state); + const publicState = publicRestoreState(state); + entry.restoreState = publicState; if (replayPartial === true) { entry.restoreReplayPartial = true; } if (replayError !== undefined) { entry.restoreReplayError = replayError; } - seedSnapshotCaches(entry, state); + seedSnapshotCaches(entry, publicState); + const artifactRestoreWarnings = await entry.artifacts.restore( + restoredArtifactSnapshot, + ); + for (const warning of artifactRestoreWarnings) { + writeStderrLine( + `[artifacts] session=${entry.sessionId} action=restore_warning warning=${JSON.stringify( + warning, + )}`, + ); + } + const artifactRestoreFailed = artifactRestoreWarnings.some((warning) => + isArtifactRestoreFailureWarning(warning), + ); if (replayUpdates.length > 0) { - await ci.client.seedSessionUpdates(entry, replayUpdates); + await ci.client.seedSessionUpdates(entry, replayUpdates, { + ingestArtifacts: + restoredArtifactSnapshot === undefined || artifactRestoreFailed, + }); ci.client.drainEarlyEvents(entry.sessionId, entry); } const clientId = registerClient(entry, req.clientId); @@ -3145,7 +3293,10 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { attached: false, clientId, createdAt: entry.createdAt, - state, + state: publicState, + ...(artifactRestoreWarnings.length > 0 + ? { artifactWarnings: artifactRestoreWarnings } + : {}), hasActivePrompt: entry.promptActive, ...replayFieldsFor(entry, action), }; @@ -4601,9 +4752,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(); }, @@ -4613,9 +4765,13 @@ 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], { + validationStrict: true, + persistenceStrict: false, + }); publishArtifactChanges(entry, result.changes, clientId); - return result; + const warnings = [...(result.warnings ?? [])]; + return warnings.length > 0 ? { ...result, warnings } : result; }, async removeSessionArtifact(sessionId, artifactId, context) { @@ -4624,7 +4780,8 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { const clientId = resolveTrustedClientId(entry, context?.clientId); const result = await entry.artifacts.remove(artifactId, { clientId }); publishArtifactChanges(entry, result.changes, clientId); - return result; + const warnings = [...(result.warnings ?? [])]; + return warnings.length > 0 ? { ...result, warnings } : result; }, listWorkspaceSessions(workspaceCwd) { @@ -5975,7 +6132,58 @@ 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, + ); + const artifactSnapshotUnavailable = artifactSnapshotUnavailableReason( + response as BridgeSessionState, + ); + const beforeArtifacts = (await entry.artifacts.list()).artifacts; + const shouldRestoreArtifactSnapshot = + artifactSnapshot !== undefined && + artifactSnapshotUnavailable === undefined; + const artifactRestoreWarnings = + artifactSnapshotUnavailable !== undefined + ? [ + `artifact snapshot rebuild unavailable during rewind: ${artifactSnapshotUnavailable}`, + ] + : shouldRestoreArtifactSnapshot + ? await entry.artifacts.restore(artifactSnapshot, { + preserveLiveEphemeral: true, + }) + : []; + const artifactRestoreFailed = artifactRestoreWarnings.some( + isArtifactRestoreFailureWarning, + ); + const shouldRecordArtifactSnapshot = + shouldRestoreArtifactSnapshot && !artifactRestoreFailed; + const artifactSnapshotWarnings = shouldRecordArtifactSnapshot + ? await entry.artifacts.recordSnapshot() + : []; + const artifactWarnings = [ + ...artifactRestoreWarnings, + ...artifactSnapshotWarnings, + ]; + for (const warning of artifactRestoreWarnings) { + writeStderrLine( + `[artifacts] session=${entry.sessionId} action=rewind_restore_warning warning=${JSON.stringify( + warning, + )}`, + ); + } + 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, + artifactReseedChanges(beforeArtifacts, afterArtifacts), + originatorClientId, + ); try { entry.events.publish({ type: 'session_rewound', @@ -5985,6 +6193,9 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { targetTurnIndex, filesChanged, filesFailed, + ...(artifactWarnings.length > 0 + ? { warnings: artifactWarnings } + : {}), }, ...(originatorClientId ? { originatorClientId } : {}), }); @@ -5997,6 +6208,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { targetTurnIndex, filesChanged, filesFailed, + ...(artifactWarnings.length > 0 ? { warnings: artifactWarnings } : {}), }; }, diff --git a/packages/acp-bridge/src/bridgeClient.test.ts b/packages/acp-bridge/src/bridgeClient.test.ts index 18d1d5a6aa..4789d58568 100644 --- a/packages/acp-bridge/src/bridgeClient.test.ts +++ b/packages/acp-bridge/src/bridgeClient.test.ts @@ -952,6 +952,7 @@ describe('BridgeClient — artifact ingress', () => { storage: 'published', url: artifactUrl, managedId: 'managed-1', + retention: 'ephemeral', }, ], }, @@ -975,6 +976,7 @@ describe('BridgeClient — artifact ingress', () => { artifact: expect.objectContaining({ title: 'Dashboard', storage: 'published', + retention: 'ephemeral', }), }), }, @@ -986,6 +988,7 @@ describe('BridgeClient — artifact ingress', () => { title: 'Dashboard', storage: 'published', managedId: 'managed-1', + retention: 'ephemeral', }, ], }); @@ -1267,6 +1270,7 @@ describe('BridgeClient — artifact ingress', () => { { title: 'Hook dashboard', url: 'https://example.com/hook-dashboard', + retention: 'ephemeral', }, ], }); @@ -1282,6 +1286,7 @@ describe('BridgeClient — artifact ingress', () => { source: 'hook', hookEventName: 'PostToolUse', title: 'Hook dashboard', + retention: 'ephemeral', }), }), }, @@ -1293,6 +1298,7 @@ describe('BridgeClient — artifact ingress', () => { source: 'hook', hookEventName: 'PostToolUse', title: 'Hook dashboard', + retention: 'ephemeral', }, ], }); diff --git a/packages/acp-bridge/src/bridgeClient.ts b/packages/acp-bridge/src/bridgeClient.ts index c55752e80f..48d7ea853c 100644 --- a/packages/acp-bridge/src/bridgeClient.ts +++ b/packages/acp-bridge/src/bridgeClient.ts @@ -105,6 +105,7 @@ function artifactPayloadFields( mimeType: artifact['mimeType'] as string | undefined, sizeBytes: artifact['sizeBytes'] as number | undefined, metadata: artifact['metadata'] as SessionArtifactInput['metadata'], + retention: artifact['retention'] as SessionArtifactInput['retention'], }; } @@ -717,6 +718,7 @@ export class BridgeClient implements Client { async seedSessionUpdates( entry: BridgeClientSessionEntry, updates: SessionUpdate[], + options: { ingestArtifacts?: boolean } = {}, ): Promise { const frames: Array> = []; const artifactBatches: Array<{ @@ -729,7 +731,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/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts index 3ddd052caa..d1c5ef39c1 100644 --- a/packages/acp-bridge/src/bridgeTypes.ts +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -62,6 +62,7 @@ export interface RewindResponse { targetTurnIndex: number; filesChanged: string[]; filesFailed: string[]; + warnings?: string[]; } export interface BridgeSpawnRequest { @@ -107,7 +108,7 @@ export interface BridgeRestoreSessionRequest { workspaceCwd: string; /** Optional echo of a daemon-issued client id for this session. */ clientId?: string; - /** Internal replay transport for `session/load`; defaults to ACP streaming. */ + /** Internal replay transport for `session/load`; defaults to bulk response. */ historyReplay?: 'stream' | 'response'; } @@ -123,11 +124,19 @@ export interface BridgeLoadReplayEnvelope { replayError?: string; } -export type BridgeSessionState = LoadSessionResponse | ResumeSessionResponse; +export type BridgeSessionState = ( + | LoadSessionResponse + | ResumeSessionResponse +) & { + artifactSnapshot?: unknown; + artifactSnapshotUnavailable?: unknown; +}; 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. */ @@ -571,7 +580,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/acp-bridge/src/sessionArtifacts.test.ts b/packages/acp-bridge/src/sessionArtifacts.test.ts index 31dae0a417..690e2e0898 100644 --- a/packages/acp-bridge/src/sessionArtifacts.test.ts +++ b/packages/acp-bridge/src/sessionArtifacts.test.ts @@ -11,9 +11,21 @@ import os from 'node:os'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; import { + SessionArtifactAuthorizationError, SessionArtifactStore, SessionArtifactValidationError, } from './sessionArtifacts.js'; +import { + stableSessionArtifactId, + type RebuiltSessionArtifactSnapshot, + type SessionArtifactEventRecordPayload, + type SessionArtifactSnapshotRecordPayload, +} from '@qwen-code/qwen-code-core'; + +vi.mock('@xterm/headless', () => ({ + Terminal: class Terminal {}, + default: { Terminal: class Terminal {} }, +})); describe('SessionArtifactStore', () => { let workspace: string; @@ -74,6 +86,139 @@ describe('SessionArtifactStore', () => { }); }); + it('keeps live artifact when durable tombstone persistence fails', async () => { + let failWrites = false; + const store = new SessionArtifactStore({ + sessionId: 's1-remove-live-first', + workspaceCwd: workspace, + persistence: { + recordEvent: async () => { + if (failWrites) { + throw new Error('disk full'); + } + }, + recordSnapshot: async () => {}, + }, + }); + const created = await store.upsertMany( + [{ title: 'Delete me', url: 'https://example.com/delete-me' }], + { strict: true }, + ); + const artifactId = created.changes[0]!.artifactId; + + failWrites = true; + await expect(store.remove(artifactId)).resolves.toMatchObject({ + changes: [], + warnings: ['artifact removal not persisted; live artifact kept'], + warningDetails: [ + { + code: 'ARTIFACT_PERSISTENCE_WRITE_FAILED', + operation: 'remove', + artifactIds: [artifactId], + durability: 'unavailable', + retryable: true, + message: 'artifact removal not persisted; live artifact kept', + }, + ], + }); + await expect(store.list()).resolves.toMatchObject({ + artifacts: [{ id: artifactId }], + }); + }); + + 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, + metadata: { + '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); + 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'); + }); + + 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('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', @@ -96,10 +241,10 @@ describe('SessionArtifactStore', () => { try { await expect( store.remove(artifactId, { clientId: 'client-b' }), - ).resolves.toMatchObject({ changes: [] }); - await expect(store.remove(artifactId)).resolves.toMatchObject({ - changes: [], - }); + ).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'); @@ -108,9 +253,11 @@ describe('SessionArtifactStore', () => { } finally { stderr.mockRestore(); } - await expect(store.list()).resolves.toMatchObject({ - artifacts: [{ id: artifactId, clientId: 'client-a' }], + const listed = await store.list(); + expect(listed).toMatchObject({ + artifacts: [{ id: artifactId }], }); + expect(listed.artifacts[0]).not.toHaveProperty('clientId'); await expect( store.remove(artifactId, { clientId: 'client-a' }), @@ -119,6 +266,323 @@ 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', + }, + ], + { strict: true }, + ), + ).rejects.toBeInstanceOf(SessionArtifactAuthorizationError); + + await expect(store.list()).resolves.toMatchObject({ + artifacts: [ + { + id: artifactId, + title: 'Client A link', + metadata: { owner: 'a' }, + retention: 'ephemeral', + }, + ], + }); + const listed = await store.list(); + expect(listed.artifacts[0]).not.toHaveProperty('clientId'); + }); + + 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', + 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.warnings).toEqual([ + `artifact ${ownedId} is owned by a different client`, + ]); + expect(result.changes).toMatchObject([ + { + action: 'created', + artifact: { title: 'Tool output' }, + }, + ]); + await expect(store.list()).resolves.toMatchObject({ + artifacts: [ + { + id: ownedId, + title: 'Client A link', + metadata: { owner: 'a' }, + }, + { + title: 'Tool output', + }, + ], + }); + const listed = await store.list(); + expect(listed.artifacts[0]).not.toHaveProperty('clientId'); + }); + + it('keeps client ids live while omitting them from durable artifact records', async () => { + const events: SessionArtifactEventRecordPayload[] = []; + const url = 'https://example.com/client'; + 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, + source: 'client', + clientId: 'client-a', + }, + ], + { strict: true }, + ); + + expect(created.changes[0]?.artifact).not.toHaveProperty('clientId'); + const listed = await store.list(); + expect(listed.artifacts[0]).not.toHaveProperty('clientId'); + await expect( + store.remove(created.changes[0]!.artifactId, { clientId: 'client-b' }), + ).rejects.toBeInstanceOf(SessionArtifactAuthorizationError); + await expect( + store.remove(created.changes[0]!.artifactId, { clientId: 'client-a' }), + ).resolves.toMatchObject({ + changes: [ + { + action: 'removed', + artifactId: created.changes[0]!.artifactId, + reason: 'explicit', + }, + ], + }); + 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', + ); + await expect( + store.upsertMany( + [ + { + title: 'Client artifact', + url, + source: 'client', + clientId: 'client-a', + retention: 'restorable', + }, + ], + { strict: true }, + ), + ).resolves.toMatchObject({ + changes: [ + { + action: 'created', + artifactId: created.changes[0]!.artifactId, + }, + ], + }); + }); + + it('drops legacy client ownership from restored 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, + persistence: { + recordEvent: async () => {}, + recordSnapshot: async () => {}, + }, + }); + + const persistedArtifact = { + 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, + } satisfies RebuiltSessionArtifactSnapshot['artifacts'][number]; + + await store.restore({ + v: 2, + sessionId, + sequence: 1, + artifacts: [persistedArtifact], + tombstonedIds: [], + stickyEphemeralIds: [], + warnings: [], + } satisfies RebuiltSessionArtifactSnapshot); + + const listed = await store.list(); + expect(listed.artifacts[0]).toMatchObject({ + id: artifactId, + }); + expect(listed.artifacts[0]).not.toHaveProperty('clientId'); + await expect( + store.remove(artifactId, { clientId: 'client-b' }), + ).resolves.toMatchObject({ + changes: [{ action: 'removed', artifactId, reason: 'explicit' }], + }); + }); + + 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('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', @@ -204,8 +668,12 @@ describe('SessionArtifactStore', () => { { trustedPublisher: true }, ); - expect(upgraded.changes).toHaveLength(1); + expect(upgraded.changes).toHaveLength(2); expect(upgraded.changes[0]).toMatchObject({ + action: 'removed', + reason: 'explicit', + }); + expect(upgraded.changes[1]).toMatchObject({ action: 'updated', artifact: { title: 'Published', @@ -213,7 +681,7 @@ describe('SessionArtifactStore', () => { managedId: 'managed-1', }, }); - expect(upgraded.changes[0]?.artifact).not.toHaveProperty('workspacePath'); + expect(upgraded.changes[1]?.artifact).not.toHaveProperty('workspacePath'); }); it('uses managedId as identity when published artifacts also include a url', async () => { @@ -320,11 +788,16 @@ describe('SessionArtifactStore', () => { { strict: true, trustedPublisher: true }, ); - const publishedId = upgraded.changes[0]?.artifact?.id; + const publishedId = upgraded.changes[1]?.artifact?.id; expect(publishedId).toBeDefined(); expect(publishedId).not.toBe(created.changes[0]?.artifactId); - expect(upgraded.changes).toHaveLength(1); + expect(upgraded.changes).toHaveLength(2); expect(upgraded.changes[0]).toMatchObject({ + action: 'removed', + reason: 'explicit', + }); + expect(upgraded.changes[0]).not.toHaveProperty('durableTombstoneRequired'); + expect(upgraded.changes[1]).toMatchObject({ action: 'updated', artifactId: publishedId, artifact: { @@ -334,7 +807,7 @@ describe('SessionArtifactStore', () => { url: artifactUrl, }, }); - expect(upgraded.changes[0]?.artifact).not.toHaveProperty('workspacePath'); + expect(upgraded.changes[1]?.artifact).not.toHaveProperty('workspacePath'); expect((await store.list()).artifacts).toMatchObject([{ id: publishedId }]); const republished = await store.upsertMany( @@ -391,7 +864,7 @@ describe('SessionArtifactStore', () => { ], { strict: true, trustedPublisher: true }, ); - const publishedId = upgraded.changes[0]?.artifactId; + const publishedId = upgraded.changes[1]?.artifactId; const repeated = await store.upsertMany( [{ title: 'Draft again', workspacePath: 'reports/dashboard.html' }], @@ -546,11 +1019,47 @@ describe('SessionArtifactStore', () => { reason: 'eviction', }), ); + expect( + overflow.changes.find( + (change) => change.artifactId === first.changes[0]?.artifactId, + ), + ).not.toHaveProperty('durableTombstoneRequired'); expect( (await store.list()).artifacts.map((artifact) => artifact.id), ).toEqual([second.changes[0]?.artifactId, overflow.changes[0]?.artifactId]); }); + 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', @@ -596,6 +1105,41 @@ describe('SessionArtifactStore', () => { ).rejects.toMatchObject({ field: 'workspacePath' }); }); + it('normalizes workspace paths against the resolved workspace cwd', async () => { + const symlinkWorkspace = `${workspace}-link`; + await fs.symlink(workspace, symlinkWorkspace, 'dir'); + try { + const store = new SessionArtifactStore({ + sessionId: 's4-symlink-workspace', + workspaceCwd: symlinkWorkspace, + }); + await fs.writeFile(path.join(workspace, 'via-link.txt'), 'hello'); + + await expect( + store.upsertMany( + [ + { + title: 'Via link', + workspacePath: `../${path.basename(workspace)}/via-link.txt`, + }, + ], + { strict: true }, + ), + ).resolves.toMatchObject({ + changes: [ + { + artifact: { + workspacePath: 'via-link.txt', + status: 'available', + }, + }, + ], + }); + } finally { + await fs.rm(symlinkWorkspace, { force: true }); + } + }); + it('accepts workspace entries whose names start with two dots', async () => { const store = new SessionArtifactStore({ sessionId: 's4-dot-prefix', @@ -772,6 +1316,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', @@ -856,6 +1437,145 @@ 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('keeps explicit ephemeral retention while coalescing implicit duplicates', async () => { + const store = new SessionArtifactStore({ + sessionId: 's5-coalesce-explicit-ephemeral', + workspaceCwd: workspace, + }); + const url = 'https://example.com/coalesce-ephemeral'; + + const result = await store.upsertMany([ + { + title: 'Ephemeral', + source: 'client', + clientId: 'client-1', + url, + retention: 'ephemeral', + }, + { + title: 'Implicit duplicate', + source: 'client', + clientId: 'client-1', + url, + metadata: { refreshed: true }, + }, + ]); + + expect(result.changes[0]?.artifact).toMatchObject({ + url, + retention: 'ephemeral', + }); + }); + + it('keeps explicit ephemeral retention across implicit updates', async () => { + const store = new SessionArtifactStore({ + sessionId: 's5-update-explicit-ephemeral', + workspaceCwd: workspace, + }); + const url = 'https://example.com/update-ephemeral'; + + await store.upsertMany([ + { + title: 'Ephemeral', + source: 'client', + clientId: 'client-1', + url, + retention: 'ephemeral', + }, + ]); + await store.upsertMany([ + { + title: 'Implicit update', + source: 'client', + clientId: 'client-1', + url, + metadata: { refreshed: true }, + }, + ]); + + await expect(store.list()).resolves.toMatchObject({ + artifacts: [{ url, retention: 'ephemeral' }], + }); + }); + + it('persists explicit durable to ephemeral updates as sticky markers', async () => { + const events: SessionArtifactEventRecordPayload[] = []; + const store = new SessionArtifactStore({ + sessionId: 's5-explicit-ephemeral-marker', + workspaceCwd: workspace, + persistence: { + recordEvent: async (payload) => { + events.push(payload); + }, + recordSnapshot: async () => {}, + }, + }); + const url = 'https://example.com/explicit-ephemeral-marker'; + + const created = await store.upsertMany([{ title: 'Durable', url }], { + strict: true, + }); + const artifactId = created.changes[0]!.artifactId; + const updated = await store.upsertMany( + [{ title: 'Durable', url, retention: 'ephemeral' }], + { strict: true }, + ); + + expect(updated.changes).toEqual([ + expect.objectContaining({ + action: 'removed', + artifactId, + reason: 'unpin_to_ephemeral', + }), + expect.objectContaining({ + action: 'updated', + artifactId, + artifact: expect.objectContaining({ retention: 'ephemeral' }), + }), + ]); + expect(updated.changes[1]?.artifact).not.toHaveProperty('persistedAt'); + expect(events.at(-1)?.changes).toEqual([ + expect.objectContaining({ + action: 'removed', + artifactId, + reason: 'unpin_to_ephemeral', + }), + ]); + }); + it('infers artifact kind from storage and workspace extensions', async () => { const store = new SessionArtifactStore({ sessionId: 's5-kind', @@ -971,6 +1691,34 @@ describe('SessionArtifactStore', () => { ).rejects.toMatchObject({ field: 'managedId' }); } + await expect( + store.upsertMany( + [ + { + title: 'Metadata key', + url: 'https://example.com/metadata-key', + metadata: { apiKey: 'not-persisted' }, + }, + ], + { strict: true }, + ), + ).rejects.toMatchObject({ field: 'metadata' }); + + await expect( + store.upsertMany( + [ + { + title: 'Metadata value', + url: 'https://example.com/metadata-value', + metadata: { + preview: 'sk-test-token-1234567890', + }, + }, + ], + { strict: true }, + ), + ).rejects.toMatchObject({ field: 'metadata' }); + await expect( store.upsertMany( [ @@ -1085,6 +1833,32 @@ describe('SessionArtifactStore', () => { ), ).rejects.toMatchObject({ field: 'metadata' }); + await expect( + store.upsertMany( + [ + { + title: 'Report', + url: 'https://example.com/metadata-secret-key', + metadata: { apiKey: 'not-persisted' }, + }, + ], + { strict: true }, + ), + ).rejects.toMatchObject({ field: 'metadata' }); + + await expect( + store.upsertMany( + [ + { + title: 'Report', + url: 'https://example.com/metadata-secret-value', + metadata: { preview: 'sk-test-token-1234567890' }, + }, + ], + { strict: true }, + ), + ).rejects.toMatchObject({ field: 'metadata' }); + await expect( store.upsertMany( [ @@ -1118,6 +1892,85 @@ 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: 'Encoded fragment token', + url: 'https://example.com/report#access%5Ftoken=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' }); + + await expect( + store.upsertMany( + [ + { + title: 'Camel token', + url: 'https://example.com/report?accessToken=abc', + }, + ], + { strict: true }, + ), + ).rejects.toMatchObject({ field: 'url' }); + + await expect( + store.upsertMany( + [ + { + title: 'Token value', + url: 'https://example.com/report?data=sk-test-token-1234567890', + }, + ], + { strict: true }, + ), + ).rejects.toMatchObject({ field: 'url' }); + + await expect( + store.upsertMany( + [ + { + title: 'Path token', + url: 'https://example.com/files/sk-test-token-1234567890/report', + }, + ], + { strict: true }, + ), + ).rejects.toMatchObject({ field: 'url' }); + }); + it('accepts line whitespace in descriptions but not titles', async () => { const store = new SessionArtifactStore({ sessionId: 's5-line-whitespace', @@ -1183,6 +2036,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', @@ -1281,7 +2165,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, @@ -1357,16 +2241,27 @@ describe('SessionArtifactStore', () => { }); it('marks a stored workspace artifact missing if it later escapes by symlink', async () => { + const persistence = { + recordEvent: vi.fn(), + recordSnapshot: vi.fn(), + }; const store = new SessionArtifactStore({ sessionId: 's7-symlink-refresh', workspaceCwd: workspace, + persistence, }); const outside = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-outside-')); try { await fs.writeFile(path.join(workspace, 'report.txt'), 'hello'); await fs.writeFile(path.join(outside, 'secret.txt'), 'secret'); await store.upsertMany( - [{ title: 'Report', workspacePath: 'report.txt' }], + [ + { + title: 'Report', + workspacePath: 'report.txt', + retention: 'restorable', + }, + ], { strict: true }, ); @@ -1385,6 +2280,38 @@ describe('SessionArtifactStore', () => { }); expect(artifact).not.toHaveProperty('workspacePath'); expect(artifact).not.toHaveProperty('sizeBytes'); + + const snapshot = ( + store as unknown as { + buildSnapshotPayload( + recordedAt: string, + sequence: number, + ): SessionArtifactSnapshotRecordPayload; + } + ).buildSnapshotPayload('2026-07-06T00:00:00.000Z', 1); + expect(snapshot.artifacts[0]).toMatchObject({ + storage: 'workspace', + status: 'missing', + workspacePath: 'report.txt', + }); + + await fs.rm(path.join(workspace, 'report.txt')); + const restored = new SessionArtifactStore({ + sessionId: 's7-symlink-refresh', + workspaceCwd: workspace, + persistence, + }); + await restored.restore({ + ...snapshot, + tombstonedIds: snapshot.tombstonedIds ?? [], + stickyEphemeralIds: snapshot.stickyEphemeralIds ?? [], + warnings: [], + }); + expect((await restored.list()).artifacts[0]).toMatchObject({ + storage: 'workspace', + status: 'missing', + workspacePath: 'report.txt', + }); } finally { vi.useRealTimers(); await fs.rm(outside, { recursive: true, force: true }); @@ -1537,6 +2464,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', @@ -1699,4 +2707,1877 @@ 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('snapshots the post-delete state for strict durable removals', async () => { + const snapshots: SessionArtifactSnapshotRecordPayload[] = []; + const store = new SessionArtifactStore({ + sessionId: 's11-delete-snapshot-state', + workspaceCwd: workspace, + persistence: { + recordEvent: async () => {}, + recordSnapshot: async (payload) => { + snapshots.push(payload); + }, + }, + }); + + const target = await store.upsertMany( + [ + { + title: 'Delete target', + url: 'https://example.com/delete-target', + }, + ], + { strict: true }, + ); + const targetId = target.changes[0]!.artifactId; + for (let index = 0; index < 48; index++) { + await store.upsertMany( + [ + { + title: `Keeper ${index}`, + url: `https://example.com/delete-keeper-${index}`, + }, + ], + { strict: true }, + ); + } + + await store.remove(targetId); + + expect(snapshots).toHaveLength(1); + expect(snapshots[0]?.tombstonedIds).toContain(targetId); + expect(snapshots[0]?.artifacts).not.toEqual( + expect.arrayContaining([expect.objectContaining({ id: targetId })]), + ); + }); + + it('keeps durable artifacts when explicit unpin persistence fails', async () => { + let failWrites = false; + const store = new SessionArtifactStore({ + sessionId: 's11-unpin-failure', + workspaceCwd: workspace, + persistence: { + recordEvent: async () => { + if (failWrites) { + throw new Error('disk full'); + } + }, + recordSnapshot: async () => {}, + }, + }); + const url = 'https://example.com/unpin-failure'; + const created = await store.upsertMany([{ title: 'Pinned durable', url }], { + strict: true, + }); + const artifactId = created.changes[0]!.artifactId; + + failWrites = true; + const stderr = vi + .spyOn(process.stderr, 'write') + .mockReturnValue(true as never); + try { + await expect( + store.upsertMany([ + { + title: 'Pinned durable', + url, + retention: 'ephemeral', + }, + ]), + ).resolves.toMatchObject({ + changes: [], + warnings: [ + 'artifact durable removal not persisted; live changes rolled back', + ], + }); + const logged = stderr.mock.calls.map((call) => String(call[0])).join(''); + expect(logged).toContain('upsert_rollback'); + expect(logged).toContain(artifactId); + expect(logged).toContain('disk full'); + } finally { + stderr.mockRestore(); + } + + await expect(store.list()).resolves.toMatchObject({ + artifacts: [ + expect.objectContaining({ + id: artifactId, + retention: 'restorable', + }), + ], + }); + }); + + it('rolls back identity-changing ephemeral replacements when tombstone persistence fails', async () => { + let failWrites = false; + const store = new SessionArtifactStore({ + sessionId: 's11-ephemeral-replacement-failure', + workspaceCwd: workspace, + persistence: { + recordEvent: async () => { + if (failWrites) { + throw new Error('disk full'); + } + }, + recordSnapshot: async () => {}, + }, + }); + const url = 'https://example.com/replaced-with-ephemeral'; + const created = await store.upsertMany([{ title: 'Durable', url }], { + strict: true, + }); + const durableId = created.changes[0]!.artifactId; + + failWrites = true; + await expect( + store.upsertMany( + [ + { + title: 'Ephemeral published replacement', + storage: 'published', + managedId: 'published-replacement', + url, + retention: 'ephemeral', + }, + ], + { trustedPublisher: true }, + ), + ).resolves.toMatchObject({ + changes: [], + warnings: [ + 'artifact durable removal not persisted; live changes rolled back', + ], + }); + + await expect(store.list()).resolves.toMatchObject({ + artifacts: [ + expect.objectContaining({ + id: durableId, + storage: 'external_url', + retention: 'restorable', + title: 'Durable', + }), + ], + }); + }); + + 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 (payload) => { + snapshotAttempts++; + if (snapshotAttempts === 1) { + throw new Error('disk full'); + } + snapshots.push(payload); + }, + }, + }); + 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); + expect(snapshots).toHaveLength(0); + + await store.upsertMany( + [ + { + title: 'Durable after failure', + url: 'https://example.com/snapshot-after-failure', + }, + ], + { 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); + expect(snapshots[0]).toMatchObject({ sequence: 101 }); + + 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); + expect(snapshots[1]).toMatchObject({ sequence: 152 }); + + const logged = stderr.mock.calls.map((call) => String(call[0])).join(''); + expect(logged).toContain('snapshot_failed'); + } finally { + stderr.mockRestore(); + } + }); + + it('keeps explicit 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); + expect(snapshots[0]?.markerArtifacts).toEqual([ + expect.objectContaining({ + id: deletedId, + url: 'https://example.com/deleted', + }), + ]); + + const rerun = await store.upsertMany([ + { title: 'Deleted', url: 'https://example.com/deleted' }, + ]); + expect(rerun.changes).toMatchObject([ + { + action: 'created', + artifactId: deletedId, + artifact: { source: 'tool' }, + }, + ]); + }); + + it('logs when tombstone LRU eviction drops the oldest id', async () => { + const sessionId = 's11-tombstone-lru-log'; + const store = new SessionArtifactStore({ + sessionId, + workspaceCwd: workspace, + persistence: { + recordEvent: async () => {}, + recordSnapshot: async () => {}, + }, + }); + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + + try { + for (let index = 0; index < 501; index++) { + const url = `https://example.com/tombstone-lru-${index}`; + const created = await store.upsertMany( + [{ title: `Deleted ${index}`, url }], + { strict: true }, + ); + await store.remove(created.changes[0]!.artifactId); + } + + const logged = stderr.mock.calls.map((call) => String(call[0])).join(''); + expect(logged).toContain('action=tombstone_evicted'); + expect(logged).toContain('limit=500'); + expect(logged).toContain( + stableSessionArtifactId( + sessionId, + 'url:https://example.com/tombstone-lru-0', + ), + ); + } finally { + stderr.mockRestore(); + } + }); + + it('allows tool reruns to supersede restored delete 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 rerun = await store.upsertMany([input]); + expect(rerun.changes).toMatchObject([ + { + action: 'created', + artifactId, + artifact: { source: 'tool' }, + }, + ]); + }); + + 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 () => { + 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('keeps live artifacts when rewind restore has no 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: durable.changes[0]?.artifactId, + title: 'Durable', + retention: 'restorable', + }, + { + id: ephemeral.changes[0]?.artifactId, + title: 'Live only', + retention: 'ephemeral', + }, + ], + }); + }); + + 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('keeps restored sticky marker metadata in snapshots', async () => { + const sessionId = 's11-sticky-non-restored-snapshot'; + const url = 'https://example.com/sticky-ephemeral'; + const stickyId = stableSessionArtifactId(sessionId, `url:${url}`); + const now = '2026-07-04T00:00:00.000Z'; + const snapshots: SessionArtifactSnapshotRecordPayload[] = []; + const store = new SessionArtifactStore({ + sessionId, + workspaceCwd: workspace, + persistence: { + recordEvent: async () => {}, + recordSnapshot: async (payload) => { + snapshots.push(payload); + }, + }, + }); + + await store.restore({ + v: 2, + sessionId, + sequence: 1, + artifacts: [], + tombstonedIds: [], + stickyEphemeralIds: [stickyId], + markerArtifacts: [ + { + id: stickyId, + kind: 'link', + storage: 'external_url', + source: 'client', + status: 'available', + title: 'Sticky', + url, + retention: 'restorable', + clientRetained: true, + createdAt: now, + updatedAt: now, + }, + ], + warnings: [], + }); + for (let index = 0; index < 50; index++) { + await store.upsertMany( + [ + { + title: `Durable ${index}`, + url: `https://example.com/sticky-snapshot-${index}`, + }, + ], + { strict: true }, + ); + } + + expect(snapshots).toHaveLength(1); + expect(snapshots[0]?.stickyEphemeralIds).toContain(stickyId); + expect(snapshots[0]?.markerArtifacts).toEqual([ + expect.objectContaining({ id: stickyId, url }), + ]); + }); + + it('does not keep unsafe restored marker metadata in snapshots', async () => { + const sessionId = 's11-unsafe-sticky-marker-snapshot'; + const url = 'https://example.com/unsafe-sticky-ephemeral'; + const stickyId = stableSessionArtifactId(sessionId, `url:${url}`); + const now = '2026-07-04T00:00:00.000Z'; + const snapshots: SessionArtifactSnapshotRecordPayload[] = []; + const store = new SessionArtifactStore({ + sessionId, + workspaceCwd: workspace, + persistence: { + recordEvent: async () => {}, + recordSnapshot: async (payload) => { + snapshots.push(payload); + }, + }, + }); + + await expect( + store.restore({ + v: 2, + sessionId, + sequence: 1, + artifacts: [], + tombstonedIds: [], + stickyEphemeralIds: [stickyId], + markerArtifacts: [ + { + id: stickyId, + kind: 'link', + storage: 'external_url', + source: 'client', + status: 'available', + title: 'Unsafe sticky', + url, + metadata: { apiKey: 'not-restored' }, + retention: 'restorable', + clientRetained: true, + createdAt: now, + updatedAt: now, + }, + ], + warnings: [], + }), + ).resolves.toEqual([ + `skipped marker artifact ${stickyId}: metadata keys must not contain secret-like names`, + ]); + for (let index = 0; index < 50; index++) { + await store.upsertMany( + [ + { + title: `Durable ${index}`, + url: `https://example.com/unsafe-sticky-snapshot-${index}`, + }, + ], + { strict: true }, + ); + } + + expect(snapshots).toHaveLength(1); + expect(snapshots[0]?.stickyEphemeralIds).not.toContain(stickyId); + expect(snapshots[0]?.markerArtifacts).toBeUndefined(); + }); + + it('omits orphaned sticky markers after live 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); + expect(snapshots[0]?.markerArtifacts).toBeUndefined(); + }); + + 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', + }, + ], + }); + + await restored.upsertMany([ + { title: 'Sticky', url: 'https://example.com/sticky-restore' }, + ]); + + await expect(restored.list()).resolves.toMatchObject({ + artifacts: [ + { + id: artifact.id, + retention: 'ephemeral', + persistenceWarning: 'sticky_override_active', + }, + ], + }); + }); + + it('warns when restored pinned artifacts are downgraded', async () => { + const sessionId = 's11-restore-pinned'; + const url = 'https://example.com/restored-pinned'; + const artifactId = stableSessionArtifactId(sessionId, `url:${url}`); + const restored = new SessionArtifactStore({ + sessionId, + workspaceCwd: workspace, + }); + + await expect( + restored.restore({ + v: 2, + sessionId, + sequence: 1, + artifacts: [ + { + id: artifactId, + kind: 'link', + storage: 'external_url', + source: 'client', + status: 'available', + title: 'Pinned', + url, + retention: 'pinned', + clientRetained: true, + createdAt: '2026-07-04T00:00:00.000Z', + updatedAt: '2026-07-04T00:00:00.000Z', + }, + ], + tombstonedIds: [], + stickyEphemeralIds: [], + warnings: [], + }), + ).resolves.toEqual([ + `pinned artifact ${artifactId} downgraded to restorable; runtime does not support pinned retention`, + ]); + + await expect(restored.list()).resolves.toMatchObject({ + artifacts: [ + { + id: artifactId, + retention: 'restorable', + }, + ], + }); + }); + + it('does not mark restored ephemeral artifacts as sticky without a sticky marker', async () => { + const sessionId = 's11-restore-ephemeral'; + const url = 'https://example.com/restored-ephemeral'; + const artifactId = stableSessionArtifactId(sessionId, `url:${url}`); + const restored = new SessionArtifactStore({ + sessionId, + workspaceCwd: workspace, + }); + + await expect( + restored.restore({ + v: 2, + sessionId, + sequence: 1, + artifacts: [ + { + id: artifactId, + kind: 'link', + storage: 'external_url', + source: 'client', + status: 'available', + title: 'Ephemeral', + url, + retention: 'ephemeral', + clientRetained: false, + createdAt: '2026-07-04T00:00:00.000Z', + updatedAt: '2026-07-04T00:00:00.000Z', + }, + ], + tombstonedIds: [], + stickyEphemeralIds: [], + warnings: [], + }), + ).resolves.toEqual([]); + + await expect(restored.list()).resolves.toMatchObject({ + artifacts: [ + { + id: artifactId, + retention: 'ephemeral', + }, + ], + }); + expect((await restored.list()).artifacts[0]).not.toHaveProperty( + 'persistenceWarning', + ); + }); + + 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.warningDetails).toEqual([ + { + code: 'ARTIFACT_PERSISTENCE_UNAVAILABLE', + operation: 'upsert', + artifactIds: [result.changes[0]!.artifactId], + durability: 'live_only', + retryable: false, + message: + '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('keeps explicit removal live when tombstone persistence fails', async () => { + let calls = 0; + const store = new SessionArtifactStore({ + sessionId: 's11-remove-live-first', + 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 }, + ); + + await expect( + store.remove(created.changes[0]!.artifactId), + ).resolves.toMatchObject({ + changes: [], + warnings: ['artifact removal not persisted; live artifact kept'], + warningDetails: [ + { + code: 'ARTIFACT_PERSISTENCE_WRITE_FAILED', + operation: 'remove', + artifactIds: [created.changes[0]?.artifactId], + durability: 'unavailable', + retryable: true, + message: 'artifact removal not persisted; live artifact kept', + }, + ], + }); + await expect(store.list()).resolves.toMatchObject({ + artifacts: [{ id: created.changes[0]?.artifactId }], + }); + }); + + it('keeps explicit removal live when persistence is unavailable', async () => { + const sessionId = 's11-remove-durable-unavailable'; + const url = 'https://example.com/remove-durable-unavailable'; + 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: 'Previously durable', + url, + retention: 'restorable', + clientRetained: true, + 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.remove(artifactId)).resolves.toMatchObject({ + changes: [], + warnings: ['artifact removal not persisted; live artifact kept'], + warningDetails: [ + { + code: 'ARTIFACT_PERSISTENCE_UNAVAILABLE', + operation: 'remove', + artifactIds: [artifactId], + durability: 'unavailable', + retryable: false, + message: 'artifact removal not persisted; live artifact kept', + }, + ], + }); + await expect(store.list()).resolves.toMatchObject({ + artifacts: [{ id: artifactId }], + }); + }); + + 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('writes an eviction tombstone when evicting a downgraded durable artifact', async () => { + const events: SessionArtifactEventRecordPayload[] = []; + let failNext = false; + const store = new SessionArtifactStore({ + sessionId: 's11-downgraded-eviction-tombstone', + workspaceCwd: workspace, + maxArtifacts: 1, + 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-eviction' }], + { strict: true }, + ); + + failNext = true; + await store.upsertMany([ + { + title: 'Durable', + url: 'https://example.com/downgraded-eviction', + metadata: { phase: 'updated' }, + }, + ]); + + await store.upsertMany( + [{ title: 'Overflow', url: 'https://example.com/overflow' }], + { strict: true }, + ); + + expect(events.at(-1)?.changes).toContainEqual( + expect.objectContaining({ + action: 'removed', + artifactId: created.changes[0]?.artifactId, + reason: 'eviction', + }), + ); + }); + + 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', + }), + ], + }); + }); + + it('does not restore artifacts with secret-like metadata', async () => { + const sessionId = 's11-restore-secret-metadata'; + const url = 'https://example.com/restore-secret-metadata'; + const artifactId = stableSessionArtifactId(sessionId, `url:${url}`); + const store = new SessionArtifactStore({ + sessionId, + workspaceCwd: workspace, + }); + + const warnings = await store.restore({ + v: 2, + sessionId, + sequence: 8, + artifacts: [ + { + id: artifactId, + kind: 'link', + storage: 'external_url', + source: 'client', + status: 'available', + title: 'Secret metadata', + url, + metadata: { authorization: 'Bearer abc123' }, + retention: 'restorable', + clientRetained: false, + createdAt: '2026-07-04T00:00:00.000Z', + updatedAt: '2026-07-04T00:00:00.000Z', + }, + ], + tombstonedIds: [], + stickyEphemeralIds: [], + warnings: [], + }); + + expect(warnings).toEqual([ + 'artifact snapshot restore failed; kept existing live artifacts', + ]); + await expect(store.list()).resolves.toMatchObject({ artifacts: [] }); + }); + + 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', + 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([ + 'artifact snapshot restore failed; kept existing live artifacts', + ]); + await expect(store.list()).resolves.toMatchObject({ + artifacts: [ + { + id: liveId, + title: 'Live', + }, + ], + }); + }); + + it('keeps live artifacts when an empty restore snapshot has warnings', async () => { + const store = new SessionArtifactStore({ + sessionId: 's11-restore-empty-warning', + workspaceCwd: workspace, + }); + const live = await store.upsertMany([ + { title: 'Live', url: 'https://example.com/live-empty-warning' }, + ]); + const liveId = live.changes[0]!.artifactId; + + const warnings = await store.restore({ + v: 2, + sessionId: 's11-restore-empty-warning', + sequence: 8, + artifacts: [], + tombstonedIds: [], + stickyEphemeralIds: [], + warnings: ['skipped malformed artifact change'], + }); + + expect(warnings).toEqual([ + 'skipped malformed artifact change', + 'artifact snapshot restore failed; kept existing live artifacts', + ]); + const listed = await store.list(); + expect(listed).toMatchObject({ + artifacts: [ + { + id: liveId, + title: 'Live', + }, + ], + }); + expect(listed.warningDetails).toEqual([ + { + code: 'ARTIFACT_WARNING', + operation: 'restore', + message: 'skipped malformed artifact change', + }, + { + code: 'ARTIFACT_RESTORE_FAILED', + operation: 'restore', + durability: 'unavailable', + retryable: true, + message: + 'artifact snapshot restore failed; kept existing live artifacts', + }, + ]); + }); + + it('keeps successfully restored artifacts when one snapshot artifact is invalid', 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, + }); + await store.upsertMany([ + { title: 'Live', url: 'https://example.com/live-partial' }, + ]); + + 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']); + await expect(store.list()).resolves.toMatchObject({ + artifacts: [ + { + id: good.id, + title: 'Good', + }, + ], + }); + }); + + it('keeps live artifacts when a normalized snapshot has completeness warnings', async () => { + const sourceEvents: SessionArtifactEventRecordPayload[] = []; + const source = new SessionArtifactStore({ + sessionId: 's11-restore-normalized-warning', + workspaceCwd: workspace, + persistence: { + recordEvent: async (payload) => { + sourceEvents.push(payload); + }, + recordSnapshot: async () => {}, + }, + }); + await source.upsertMany( + [{ title: 'Good', url: 'https://example.com/restore-normalized-good' }], + { strict: true }, + ); + const good = sourceEvents[0]!.changes[0]!.artifact!; + const store = new SessionArtifactStore({ + sessionId: 's11-restore-normalized-warning', + workspaceCwd: workspace, + }); + const live = await store.upsertMany([ + { + title: 'Live', + url: 'https://example.com/live-normalized-warning', + }, + ]); + const liveId = live.changes[0]!.artifactId; + + const warnings = await store.restore({ + v: 2, + sessionId: 's11-restore-normalized-warning', + sequence: 8, + artifacts: [good], + tombstonedIds: [], + stickyEphemeralIds: [], + warnings: ['artifact snapshot artifacts list truncated to 500 entries'], + }); + + expect(warnings).toEqual([ + 'artifact snapshot artifacts list truncated to 500 entries', + 'artifact snapshot restore partially failed; kept existing live artifacts', + ]); + await expect(store.list()).resolves.toMatchObject({ + artifacts: [ + { + id: liveId, + title: 'Live', + }, + ], + }); + }); + + it('applies rebuilt snapshots when only stale event warnings are present', async () => { + const sourceEvents: SessionArtifactEventRecordPayload[] = []; + const source = new SessionArtifactStore({ + sessionId: 's11-restore-stale-event-warning', + workspaceCwd: workspace, + persistence: { + recordEvent: async (payload) => { + sourceEvents.push(payload); + }, + recordSnapshot: async () => {}, + }, + }); + await source.upsertMany( + [{ title: 'Fresh', url: 'https://example.com/restore-stale-fresh' }], + { strict: true }, + ); + const fresh = sourceEvents[0]!.changes[0]!.artifact!; + const store = new SessionArtifactStore({ + sessionId: 's11-restore-stale-event-warning', + workspaceCwd: workspace, + }); + await store.upsertMany([ + { title: 'Live', url: 'https://example.com/restore-stale-live' }, + ]); + + const staleWarning = + 'skipped stale event sequence 1 at or before snapshot sequence 10'; + const warnings = await store.restore({ + v: 2, + sessionId: 's11-restore-stale-event-warning', + sequence: 10, + artifacts: [fresh], + tombstonedIds: [], + stickyEphemeralIds: [], + warnings: [staleWarning], + }); + + expect(warnings).toEqual([staleWarning]); + await expect(store.list()).resolves.toMatchObject({ + artifacts: [ + expect.objectContaining({ + id: fresh.id, + title: 'Fresh', + }), + ], + }); + }); + + it('applies empty rebuilt snapshots when only stale event warnings are present', async () => { + const store = new SessionArtifactStore({ + sessionId: 's11-restore-empty-stale-event-warning', + workspaceCwd: workspace, + }); + await store.upsertMany([ + { title: 'Live', url: 'https://example.com/restore-empty-stale-live' }, + ]); + + const staleWarning = + 'skipped stale event sequence 1 at or before snapshot sequence 10'; + const warnings = await store.restore({ + v: 2, + sessionId: 's11-restore-empty-stale-event-warning', + sequence: 10, + artifacts: [], + tombstonedIds: [], + stickyEphemeralIds: [], + warnings: [staleWarning], + }); + + expect(warnings).toEqual([staleWarning]); + await expect(store.list()).resolves.toMatchObject({ + artifacts: [], + }); + }); + + 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).toEqual([ + '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({ + 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('downgrades legacy pinned content refs to metadata-only restore', async () => { + const events: SessionArtifactEventRecordPayload[] = []; + const source = new SessionArtifactStore({ + sessionId: 's11-restore-legacy-pinned', + workspaceCwd: workspace, + persistence: { + recordEvent: async (payload) => { + events.push(payload); + }, + recordSnapshot: async () => {}, + }, + }); + const created = await source.upsertMany( + [{ title: 'Legacy pinned', url: 'https://example.com/legacy-pinned' }], + { strict: true }, + ); + const artifactId = created.changes[0]!.artifactId; + const persisted = { + ...events[0]!.changes[0]!.artifact!, + retention: 'pinned' as const, + contentRef: { + 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', + }, + expiresAt: '2026-08-01T00:00:00.000Z', + }; + const snapshot: RebuiltSessionArtifactSnapshot = { + v: 2, + sessionId: 's11-restore-legacy-pinned', + sequence: 2, + artifacts: [persisted], + tombstonedIds: [], + stickyEphemeralIds: [], + warnings: [], + }; + const restored = new SessionArtifactStore({ + sessionId: 's11-restore-legacy-pinned', + workspaceCwd: workspace, + }); + + await expect(restored.restore(snapshot)).resolves.toEqual([ + `pinned artifact ${artifactId} downgraded to restorable; runtime does not support pinned retention`, + ]); + + await expect(restored.list()).resolves.toMatchObject({ + artifacts: [ + expect.objectContaining({ + id: artifactId, + retention: 'restorable', + restoreState: 'restored', + status: 'available', + }), + ], + }); + const restoredArtifact = (await restored.list()).artifacts[0]; + 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 e3bc45362a..69f900b6fc 100644 --- a/packages/acp-bridge/src/sessionArtifacts.ts +++ b/packages/acp-bridge/src/sessionArtifacts.ts @@ -5,8 +5,27 @@ */ import { createHash } from 'node:crypto'; -import { 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 { + isPrototypeMetadataKey, + isReservedWorkspaceMetadataKey, + metadataBudgetBytes, + SESSION_ARTIFACT_PERSISTENCE_VERSION, + stableSessionArtifactId, + WORKSPACE_CONTENT_MTIME_MS_METADATA_KEY, + WORKSPACE_CONTENT_SHA256_METADATA_KEY, +} from '@qwen-code/qwen-code-core'; +import type { + PersistedSessionArtifact, + RebuiltSessionArtifactSnapshot, + SessionArtifactEventRecordPayload, + SessionArtifactPersistenceWarning, + SessionArtifactRestoreState, + SessionArtifactRetention, + SessionArtifactSnapshotRecordPayload, +} from '@qwen-code/qwen-code-core'; import { writeStderrLine } from './internal/stderrLine.js'; export type DaemonSessionArtifactKind = @@ -28,7 +47,11 @@ 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, @@ -37,6 +60,23 @@ 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; +const MAX_TOMBSTONED_IDS = 500; +const MAX_STICKY_EPHEMERAL_IDS = 500; +const MAX_WORKSPACE_HASH_BYTES = 100 * 1024 * 1024; +const SECRET_TOKEN_VALUE_PATTERN = + /(?:^|\s)(?:bearer\s+\S{8,}|sk-[A-Za-z0-9_-]{12,}|(?:gh[pousr]|github_pat)_[A-Za-z0-9_/-]{12,}|[a-f0-9]{40,}|[A-Za-z0-9+/]{48,}={0,2})(?:$|\s)/i; +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; @@ -53,12 +93,18 @@ export interface ToolArtifactLike { export interface SessionArtifactInput extends ToolArtifactLike { source?: DaemonSessionArtifactSource; + retention?: DaemonSessionArtifactRetention; + clientRetained?: boolean; toolCallId?: string; toolName?: string; hookEventName?: string; clientId?: string; } +type RestoreSessionArtifactInput = Omit & { + retention?: SessionArtifactRetention; +}; + export interface DaemonSessionArtifact { id: string; kind: DaemonSessionArtifactKind; @@ -73,6 +119,10 @@ export interface DaemonSessionArtifact { mimeType?: string; sizeBytes?: number; metadata?: Record; + retention: DaemonSessionArtifactRetention; + restoreState?: SessionArtifactRestoreState; + persistenceWarning?: SessionArtifactPersistenceWarning; + persistedAt?: string; clientRetained: boolean; createdAt: string; updatedAt: string; @@ -82,15 +132,23 @@ 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'; artifactId: string; artifact?: DaemonSessionArtifact; reason?: SessionArtifactRemovalReason; + durableTombstoneRequired?: boolean; } +type InternalSessionArtifactChange = SessionArtifactChange & { + removedClientId?: string; +}; + export interface SessionArtifactsEnvelope { v: 1; sessionId: string; @@ -99,12 +157,34 @@ export interface SessionArtifactsEnvelope { limits: { maxArtifacts: number; }; + warnings?: string[]; + warningDetails?: SessionArtifactWarningDetail[]; } export interface SessionArtifactMutationResult { v: 1; sessionId: string; changes: SessionArtifactChange[]; + warnings?: string[]; + warningDetails?: SessionArtifactWarningDetail[]; +} + +export interface SessionArtifactWarningDetail { + code: string; + operation: 'upsert' | 'remove' | 'restore'; + artifactIds?: string[]; + durability?: 'durable' | 'live_only' | 'unavailable'; + retryable?: boolean; + message: string; +} + +export interface SessionArtifactRestoreOptions { + preserveLiveEphemeral?: boolean; +} + +export interface SessionArtifactPersistence { + recordEvent(payload: SessionArtifactEventRecordPayload): Promise; + recordSnapshot(payload: SessionArtifactSnapshotRecordPayload): Promise; } export class SessionArtifactValidationError extends Error { @@ -119,15 +199,31 @@ 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; maxArtifacts?: number; + persistence?: SessionArtifactPersistence; } interface NormalizedArtifact extends DaemonSessionArtifact { identityKey: string; receivedSeq: number; + retentionExplicit: boolean; retentionSource: DaemonSessionArtifactSource; trustedPublisher: boolean; lastStatAt?: number; @@ -135,22 +231,44 @@ interface NormalizedArtifact extends DaemonSessionArtifact { interface StoredArtifact extends NormalizedArtifact { insertSeq: number; + durableTombstoneRequired?: boolean; + hideWorkspacePath?: boolean; +} + +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; 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 consecutiveSnapshotFailures = 0; private realWorkspaceCwdPromise?: Promise; private operationQueue: Promise = Promise.resolve(); + private readonly tombstonedIds = new Set(); + private readonly tombstonedClientIds = new Map(); + private readonly stickyEphemeralIds = new Set(); + private readonly markerArtifacts = new Map< + string, + PersistedSessionArtifact + >(); + private lastRestoreWarnings: string[] = []; + private lastRestoreWarningDetails: SessionArtifactWarningDetail[] = []; constructor(options: SessionArtifactStoreOptions) { this.sessionId = options.sessionId; this.workspaceCwd = options.workspaceCwd; this.maxArtifacts = options.maxArtifacts ?? 200; + this.persistence = options.persistence; } inputBatchLimit(): number { @@ -168,16 +286,46 @@ export class SessionArtifactStore { .map(toPublicArtifact), generatedAt: new Date().toISOString(), limits: { maxArtifacts: this.maxArtifacts }, + ...(this.lastRestoreWarnings.length > 0 + ? { warnings: [...this.lastRestoreWarnings] } + : {}), + ...(this.lastRestoreWarningDetails.length > 0 + ? { warningDetails: [...this.lastRestoreWarningDetails] } + : {}), }; }); } + 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 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[] = []; + const warningDetails: SessionArtifactWarningDetail[] = []; for (const input of inputs) { try { normalizedResults.push( @@ -188,7 +336,7 @@ export class SessionArtifactStore { ), ); } catch (error) { - if (options.strict) { + if (validationStrict) { throw error; } const message = @@ -201,47 +349,158 @@ 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 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) ?? + 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 { + this.denyCrossClientMutation('upsert', existing.id, existing, { + clientId: artifact.clientId, + }); + } catch (error) { + if (validationStrict) { + throw error; + } + if (error instanceof SessionArtifactAuthorizationError) { + warnings.push(error.message); + continue; + } + throw error; + } + const updated = mergeArtifact(existing, artifact); + if (updated.changed) { + if (updated.artifact.id !== existing.id) { + const removeChange: InternalSessionArtifactChange = { + action: 'removed', + artifactId: existing.id, + artifact: toPublicArtifact(existing), + reason: 'explicit', + durableTombstoneRequired: + existing.durableTombstoneRequired || + existing.persistedAt !== undefined, + removedClientId: existing.clientId, + }; + changes.push(removeChange); + this.artifacts.delete(existing.id); + } else if (shouldRecordEphemeralUnpin(existing, artifact)) { + changes.push({ + action: 'removed', + artifactId: existing.id, + artifact: toPublicArtifact(existing), + reason: 'unpin_to_ephemeral', + durableTombstoneRequired: true, + }); + } + this.artifacts.set(updated.artifact.id, updated.artifact); + changes.push({ + action: 'updated', + artifactId: updated.artifact.id, + artifact: toPublicArtifact(updated.artifact), + }); + } } - const updated = mergeArtifact(existing, artifact); - if (updated.changed) { - if (updated.artifact.id !== existing.id) { - this.artifacts.delete(existing.id); + const createdIds = new Set( + changes + .filter((change) => change.action === 'created') + .map((change) => change.artifactId), + ); + changes.push( + ...(await this.evictOverflow(createdIds, changes, persistenceStrict)), + ); + + const hasStrictDurableTransition = changes.some( + shouldCommitBeforeDurablePersistence, + ); + let persistenceWarnings: string[]; + if (hasStrictDurableTransition && !persistenceStrict) { + try { + persistenceWarnings = await this.persistChanges(changes, true); + } catch (error) { + this.restoreState(before); + const artifactIds = changes + .filter(shouldCommitBeforeDurablePersistence) + .map((change) => change.artifactId); + const warning = + 'artifact durable removal not persisted; live changes rolled back'; + writeStderrLine( + `[artifacts] session=${this.sessionId} action=upsert_rollback warning=${JSON.stringify( + warning, + )} artifactIds=${JSON.stringify(artifactIds)} error=${JSON.stringify( + error instanceof Error ? error.message : String(error), + )}`, + ); + return { + v: 1, + sessionId: this.sessionId, + changes: [], + warnings: [warning], + warningDetails: [ + persistenceFailureDetail({ + message: warning, + operation: 'upsert', + artifactIds, + error, + }), + ], + }; } - this.artifacts.set(updated.artifact.id, updated.artifact); - changes.push({ - action: 'updated', - artifactId: updated.artifact.id, - artifact: toPublicArtifact(updated.artifact), - }); + } else { + persistenceWarnings = await this.persistChanges( + changes, + persistenceStrict, + ); } + warnings.push(...persistenceWarnings); + warningDetails.push( + ...detailsForPersistenceWarnings( + persistenceWarnings, + changes, + 'upsert', + ), + ); + stripDurableTombstoneMarkers(changes); + } catch (error) { + if ( + validationStrict || + persistenceStrict || + error instanceof SessionArtifactAuthorizationError + ) { + this.restoreState(before); + } + throw error; } - const createdIds = new Set( - changes - .filter((change) => change.action === 'created') - .map((change) => change.artifactId), - ); - changes.push(...(await this.evictOverflow(createdIds, changes))); - - return { v: 1, sessionId: this.sessionId, changes }; + return { + v: 1, + sessionId: this.sessionId, + changes, + ...(warnings.length > 0 ? { warnings } : {}), + ...(warningDetails.length > 0 ? { warningDetails } : {}), + }; }); } @@ -257,7 +516,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 && @@ -318,32 +577,647 @@ 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: [] }; + this.denyCrossClientMutation('remove', artifactId, existing, options); + const removeChange: InternalSessionArtifactChange = { + action: 'removed', + artifactId, + artifact: toPublicArtifact(existing), + reason: 'explicit', + durableTombstoneRequired: + existing.durableTombstoneRequired || + existing.retention !== 'ephemeral' + ? true + : undefined, + removedClientId: existing.clientId, + }; + const changes: SessionArtifactChange[] = [removeChange]; + const needsDurableTombstone = + removeChange.durableTombstoneRequired === true; + if (needsDurableTombstone) { + const before = this.cloneState(); + this.artifacts.delete(artifactId); + try { + await this.persistChanges(changes, true); + } catch (error) { + this.restoreState(before); + const warning = 'artifact removal not persisted; live artifact kept'; + return { + v: 1, + sessionId: this.sessionId, + changes: [], + warnings: [warning], + warningDetails: [ + persistenceFailureDetail({ + message: warning, + operation: 'remove', + artifactIds: [artifactId], + error, + }), + ], + }; + } } - this.artifacts.delete(artifactId); + if (!needsDurableTombstone) { + this.artifacts.delete(artifactId); + } + const warnings = needsDurableTombstone + ? [] + : await this.persistChanges(changes, false); + stripDurableTombstoneMarkers(changes); + const warningDetails = detailsForPersistenceWarnings( + warnings, + changes, + 'remove', + ); return { v: 1, sessionId: this.sessionId, - changes: [ - { - action: 'removed', - artifactId, - artifact: toPublicArtifact(existing), - reason: 'explicit', - }, - ], + changes, + ...(warnings.length > 0 ? { warnings } : {}), + ...(warningDetails.length > 0 ? { warningDetails } : {}), }; }); } + async restore( + snapshot: RebuiltSessionArtifactSnapshot | undefined, + options: SessionArtifactRestoreOptions = {}, + ): Promise { + if (!snapshot) return []; + return this.enqueue(async () => { + const warnings = [...(snapshot?.warnings ?? [])]; + const baselineWarnings = [...warnings]; + const previousState = this.cloneState(); + 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.tombstonedClientIds.clear(); + this.stickyEphemeralIds.clear(); + this.markerArtifacts.clear(); + 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.slice( + -MAX_STICKY_EPHEMERAL_IDS, + )) { + this.stickyEphemeralIds.add(id); + } + const markerIds = new Set([ + ...this.tombstonedIds, + ...this.stickyEphemeralIds, + ]); + for (const artifact of snapshot.markerArtifacts ?? []) { + if (!markerIds.has(artifact.id)) continue; + const markerArtifact = await this.normalizeRestoredMarkerArtifact( + artifact, + warnings, + ); + if (markerArtifact) + this.markerArtifacts.set(artifact.id, markerArtifact); + } + } + for (const artifact of snapshot?.artifacts ?? []) { + try { + const input = persistedArtifactToInput(artifact); + if (input.retention === 'pinned') { + input.retention = 'restorable'; + warnings.push( + `pinned artifact ${artifact.id} downgraded to restorable; runtime does not support pinned retention`, + ); + } + let normalized = await this.normalizeInput( + input, + ++this.receivedSeq, + artifact.storage === 'published' && + !isFileArtifactUrl(artifact.url), + { + metadataBudget: 'persisted', + workspaceExpected: workspaceExpectedFromArtifact(artifact), + hashWorkspaceContent: false, + }, + ); + if ( + this.stickyEphemeralIds.has(normalized.id) && + normalized.retention !== 'ephemeral' + ) { + normalized = { + ...normalized, + retention: 'ephemeral', + persistenceWarning: 'sticky_override_active', + }; + } + const stickyApplied = + normalized.persistenceWarning === 'sticky_override_active'; + if (normalized.id !== artifact.id) { + warnings.push(`skipped artifact with mismatched id ${artifact.id}`); + continue; + } + const retention = normalized.retention; + let persistenceWarning: SessionArtifactPersistenceWarning | undefined; + if (stickyApplied) { + persistenceWarning = 'sticky_override_active'; + } else if (normalized.status !== 'available') { + persistenceWarning = 'metadata_only_restore'; + } + const stored: StoredArtifact = { + ...normalized, + retention, + clientRetained: artifact.clientRetained, + createdAt: artifact.createdAt, + updatedAt: artifact.updatedAt, + persistedAt: artifact.persistedAt, + status: normalized.status, + restoreState: 'restored', + persistenceWarning, + durableTombstoneRequired: + retention !== 'ephemeral' ? true : undefined, + insertSeq: ++this.insertSeq, + }; + this.artifacts.set(stored.id, stored); + restoredCount++; + } catch (error) { + warnings.push( + `skipped artifact restore: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + if (snapshot.artifacts.length > 0 && restoredCount === 0) { + this.restoreState(previousState); + const rollbackWarnings = [ + ...baselineWarnings, + `${RESTORE_FAILED_WARNING_PREFIX}; kept existing live artifacts`, + ]; + this.setLastRestoreWarnings(rollbackWarnings); + return rollbackWarnings; + } + if ( + previousState.artifacts.size > 0 && + baselineWarnings.some(isArtifactSnapshotCompletenessWarning) + ) { + this.restoreState(previousState); + const rollbackWarnings = [ + ...baselineWarnings, + restoredCount === 0 + ? `${RESTORE_FAILED_WARNING_PREFIX}; kept existing live artifacts` + : `${RESTORE_PARTIAL_FAILED_WARNING_PREFIX}; kept existing live artifacts`, + ]; + this.setLastRestoreWarnings(rollbackWarnings); + return rollbackWarnings; + } + 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'); + warnings.push(...(await this.persistChanges(evicted, false))); + } + this.setLastRestoreWarnings(warnings); + return warnings; + }); + } + + 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(); + const sequence = this.persistenceSeq + 1; + try { + await persistence.recordSnapshot( + this.buildSnapshotPayload(recordedAt, sequence), + ); + this.persistenceSeq = sequence; + 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 async normalizeRestoredMarkerArtifact( + artifact: PersistedSessionArtifact, + warnings: string[], + ): Promise { + try { + const input = persistedArtifactToInput(artifact); + if (input.retention === 'pinned') { + input.retention = 'restorable'; + warnings.push( + `pinned marker artifact ${artifact.id} downgraded to restorable; runtime does not support pinned retention`, + ); + } + const normalized = await this.normalizeInput( + input, + ++this.receivedSeq, + artifact.storage === 'published' && !isFileArtifactUrl(artifact.url), + { + metadataBudget: 'persisted', + workspaceExpected: workspaceExpectedFromArtifact(artifact), + hashWorkspaceContent: false, + }, + ); + if (normalized.id !== artifact.id) { + warnings.push( + `skipped marker artifact with mismatched id ${artifact.id}`, + ); + return undefined; + } + return toPersistedArtifact( + { + ...normalized, + clientRetained: artifact.clientRetained, + createdAt: artifact.createdAt, + updatedAt: artifact.updatedAt, + persistedAt: artifact.persistedAt, + }, + artifact.persistedAt ?? artifact.updatedAt, + ); + } catch (error) { + warnings.push( + `skipped marker artifact ${artifact.id}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + return undefined; + } + } + + private cloneState(): { + artifacts: Map; + receivedSeq: number; + insertSeq: number; + persistenceSeq: number; + durableEventsSinceSnapshot: number; + consecutiveSnapshotFailures: number; + tombstonedIds: Set; + tombstonedClientIds: Map; + stickyEphemeralIds: Set; + markerArtifacts: Map; + lastRestoreWarnings: string[]; + lastRestoreWarningDetails: SessionArtifactWarningDetail[]; + } { + return { + artifacts: new Map( + Array.from(this.artifacts.entries()).map(([id, artifact]) => [ + id, + cloneStoredArtifact(artifact), + ]), + ), + receivedSeq: this.receivedSeq, + insertSeq: this.insertSeq, + persistenceSeq: this.persistenceSeq, + durableEventsSinceSnapshot: this.durableEventsSinceSnapshot, + consecutiveSnapshotFailures: this.consecutiveSnapshotFailures, + tombstonedIds: new Set(this.tombstonedIds), + tombstonedClientIds: new Map(this.tombstonedClientIds), + stickyEphemeralIds: new Set(this.stickyEphemeralIds), + markerArtifacts: new Map(this.markerArtifacts), + lastRestoreWarnings: [...this.lastRestoreWarnings], + lastRestoreWarningDetails: [...this.lastRestoreWarningDetails], + }; + } + + private restoreState(state: { + artifacts: Map; + receivedSeq: number; + insertSeq: number; + persistenceSeq: number; + durableEventsSinceSnapshot: number; + consecutiveSnapshotFailures: number; + tombstonedIds: Set; + tombstonedClientIds: Map; + stickyEphemeralIds: Set; + markerArtifacts: Map; + lastRestoreWarnings: string[]; + lastRestoreWarningDetails: SessionArtifactWarningDetail[]; + }): void { + this.artifacts.clear(); + 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; + this.consecutiveSnapshotFailures = state.consecutiveSnapshotFailures; + this.tombstonedIds.clear(); + 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.markerArtifacts.clear(); + for (const [id, artifact] of state.markerArtifacts) { + this.markerArtifacts.set(id, artifact); + } + this.setLastRestoreWarnings(state.lastRestoreWarnings); + this.lastRestoreWarningDetails = [...state.lastRestoreWarningDetails]; + } + + 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', + ); + } + const warnings = this.downgradeDurableChanges(durableChanges); + this.applyDurableMarkers(durableChanges); + return warnings; + } + + 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; + const artifact = { + ...toPublicArtifact(stored), + persistedAt: recordedAt, + }; + change.artifact = artifact; + } + + const sequence = this.persistenceSeq + 1; + const payload: SessionArtifactEventRecordPayload = { + v: SESSION_ARTIFACT_PERSISTENCE_VERSION, + sessionId: this.sessionId, + sequence, + recordedAt, + changes: durableChanges.map((change) => + toPersistedChange(change, recordedAt), + ), + }; + + 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); + if (!stored) continue; + stored.persistedAt = recordedAt; + stored.durableTombstoneRequired = true; + change.artifact = toPublicArtifact(stored); + } + this.applyDurableMarkers(durableChanges); + await this.maybeRecordSnapshot(recordedAt); + return []; + } catch (error) { + if (strict) { + 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 sequence=${payload.sequence} artifactIds=${JSON.stringify( + artifactIds, + )} reason=${JSON.stringify(reason)}`, + ); + const warnings = this.downgradeDurableChanges(durableChanges); + this.applyDurableMarkers(durableChanges); + return warnings; + } + } + + private async maybeRecordSnapshot(recordedAt: string): Promise { + if (!this.persistence) return; + this.durableEventsSinceSnapshot++; + const snapshotThreshold = + SNAPSHOT_AFTER_DURABLE_EVENTS * + Math.min( + MAX_SNAPSHOT_BACKOFF_MULTIPLIER, + 2 ** this.consecutiveSnapshotFailures, + ); + if (this.durableEventsSinceSnapshot < snapshotThreshold) { + return; + } + const sequence = this.persistenceSeq + 1; + try { + await this.persistence.recordSnapshot( + this.buildSnapshotPayload(recordedAt, sequence), + ); + this.persistenceSeq = sequence; + 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, + sequence: number, + ): SessionArtifactSnapshotRecordPayload { + const artifacts = Array.from(this.artifacts.values()) + .filter((artifact) => artifact.retention !== 'ephemeral') + .sort((a, b) => a.insertSeq - b.insertSeq) + .map((artifact) => toPersistedArtifact(artifact, recordedAt)); + const stickyEphemeralIds = Array.from(this.stickyEphemeralIds).filter( + (id) => this.artifacts.has(id) || this.markerArtifacts.has(id), + ); + const markerArtifacts = this.buildMarkerArtifacts( + recordedAt, + stickyEphemeralIds, + ); + return { + v: SESSION_ARTIFACT_PERSISTENCE_VERSION, + sessionId: this.sessionId, + sequence, + recordedAt, + artifacts, + tombstonedIds: Array.from(this.tombstonedIds), + stickyEphemeralIds, + ...(markerArtifacts.length > 0 ? { markerArtifacts } : {}), + }; + } + + private buildMarkerArtifacts( + recordedAt: string, + stickyEphemeralIds: string[], + ): PersistedSessionArtifact[] { + const markerIds = [...this.tombstonedIds, ...stickyEphemeralIds]; + const artifacts: PersistedSessionArtifact[] = []; + const seen = new Set(); + for (const id of markerIds) { + if (seen.has(id)) continue; + seen.add(id); + const live = this.artifacts.get(id); + if (live) { + artifacts.push(toPersistedArtifact(toPublicArtifact(live), recordedAt)); + continue; + } + const markerArtifact = this.markerArtifacts.get(id); + if (markerArtifact) { + artifacts.push(markerArtifact); + } + } + return artifacts; + } + + private downgradeDurableChanges(changes: SessionArtifactChange[]): string[] { + let downgraded = false; + let removalNotPersisted = false; + 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); + 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; + } + 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 { + for (const change of changes) { + if (change.action === 'removed') { + if (change.reason === 'explicit') { + this.rememberTombstone(change); + this.stickyEphemeralIds.delete(change.artifactId); + } else if (change.reason === 'eviction') { + this.stickyEphemeralIds.delete(change.artifactId); + this.markerArtifacts.delete(change.artifactId); + } else if (change.reason === 'unpin_to_ephemeral') { + this.rememberStickyEphemeral(change); + } + continue; + } + if (change.artifact && change.artifact.retention !== 'ephemeral') { + this.tombstonedIds.delete(change.artifactId); + this.tombstonedClientIds.delete(change.artifactId); + this.stickyEphemeralIds.delete(change.artifactId); + this.markerArtifacts.delete(change.artifactId); + } + } + } + + private rememberStickyEphemeral(change: SessionArtifactChange): void { + const artifactId = change.artifactId; + this.stickyEphemeralIds.delete(artifactId); + this.stickyEphemeralIds.add(artifactId); + if (change.artifact) { + this.markerArtifacts.set( + artifactId, + toPersistedArtifact(change.artifact, change.artifact.updatedAt), + ); + } + while (this.stickyEphemeralIds.size > MAX_STICKY_EPHEMERAL_IDS) { + const oldest = this.stickyEphemeralIds.values().next().value; + if (oldest === undefined) break; + this.stickyEphemeralIds.delete(oldest); + this.markerArtifacts.delete(oldest); + } + } + + private rememberTombstone(change: SessionArtifactChange): void { + this.tombstonedIds.delete(change.artifactId); + this.tombstonedIds.add(change.artifactId); + this.tombstonedClientIds.set( + change.artifactId, + (change as InternalSessionArtifactChange).removedClientId ?? + this.artifacts.get(change.artifactId)?.clientId, + ); + if (change.artifact) { + this.markerArtifacts.set( + change.artifactId, + toPersistedArtifact(change.artifact, change.artifact.updatedAt), + ); + } + while (this.tombstonedIds.size > MAX_TOMBSTONED_IDS) { + const oldest = this.tombstonedIds.values().next().value; + if (oldest === undefined) break; + writeStderrLine( + `[artifacts] session=${this.sessionId} action=tombstone_evicted artifactId=${JSON.stringify( + oldest, + )} limit=${MAX_TOMBSTONED_IDS}`, + ); + this.tombstonedIds.delete(oldest); + this.tombstonedClientIds.delete(oldest); + this.markerArtifacts.delete(oldest); + } + } + + private setLastRestoreWarnings(warnings: readonly string[]): void { + this.lastRestoreWarnings = [...warnings]; + this.lastRestoreWarningDetails = detailsForRestoreWarnings(warnings); + } + private enqueue(operation: () => Promise): Promise { const result = this.operationQueue.then(operation, operation); this.operationQueue = result.then( @@ -353,10 +1227,39 @@ export class SessionArtifactStore { return result; } + private denyCrossClientMutation( + action: 'remove' | 'pin' | 'unpin' | 'upsert', + artifactId: string, + existing: StoredArtifact, + options?: { clientId?: string }, + ): void { + if ( + existing.source !== 'client' || + existing.clientId === undefined || + existing.clientId === options?.clientId + ) { + return; + } + writeStderrLine( + `[artifacts] session=${this.sessionId} action=${action}_denied artifactId=${artifactId} owner=${existing.clientId} requester=${options?.clientId ?? ''}`, + ); + throw new SessionArtifactAuthorizationError( + this.sessionId, + artifactId, + existing.clientId, + options?.clientId, + ); + } + private async normalizeInput( - input: SessionArtifactInput, + input: RestoreSessionArtifactInput, receivedSeq: number, trustedPublisherFromCaller: boolean, + options: { + metadataBudget?: 'user' | 'persisted'; + workspaceExpected?: WorkspaceStatusExpected; + hashWorkspaceContent?: boolean; + } = {}, ): Promise { if (!input || typeof input !== 'object') { throw new SessionArtifactValidationError('Artifact must be an object'); @@ -378,7 +1281,10 @@ export class SessionArtifactStore { const trustedPublisher = trustedPublisherFromCaller; const workspacePath = input.workspacePath - ? normalizeWorkspacePath(input.workspacePath, this.workspaceCwd) + ? normalizeWorkspacePath( + input.workspacePath, + await this.getRealWorkspaceCwdForValidation(), + ) : undefined; const managedId = normalizeManagedId(input.managedId); const rawStorage = input.storage; @@ -399,9 +1305,15 @@ export class SessionArtifactStore { trustedPublisher, }); - const metadata = normalizeMetadata(input.metadata); + const retention = normalizeRetention(input.retention, { + persistenceAvailable: this.persistence !== undefined, + }); const workspaceStatus = workspacePath - ? await this.getInitialWorkspaceStatus(workspacePath) + ? await this.getInitialWorkspaceStatus( + workspacePath, + options.workspaceExpected, + { hashContent: options.hashWorkspaceContent !== false }, + ) : undefined; if (workspaceStatus?.escaped) { throw new SessionArtifactValidationError( @@ -409,6 +1321,12 @@ export class SessionArtifactStore { 'workspacePath', ); } + const metadata = withWorkspaceContentHashMetadata( + normalizeMetadata(input.metadata, { + budget: options.metadataBudget ?? 'user', + }), + workspaceStatus, + ); const kind = normalizeKind( input.kind ?? inferKind({ storage, workspacePath, url }), ); @@ -419,12 +1337,13 @@ export class SessionArtifactStore { managedId, url, }); - const id = stableArtifactId(this.sessionId, identityKey); + const id = stableSessionArtifactId(this.sessionId, identityKey); return { id, identityKey, receivedSeq, + retentionExplicit: input.retention !== undefined, retentionSource: source, trustedPublisher, kind, @@ -439,11 +1358,22 @@ 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) + : undefined, metadata, - clientRetained: source === 'client', + retention, + restoreState: 'live', + ...(this.persistence === undefined && retention !== 'ephemeral' + ? { persistenceWarning: 'persistence_unavailable' as const } + : {}), + clientRetained: + source === 'client' && + (input.clientRetained !== undefined + ? input.clientRetained === true + : true), createdAt: now, updatedAt: now, toolCallId: normalizeString(input.toolCallId, 'toolCallId', 200, false), @@ -458,6 +1388,25 @@ export class SessionArtifactStore { }; } + private shouldSuppressTombstonedUpsert( + artifact: NormalizedArtifact, + ): boolean { + if (!this.tombstonedIds.has(artifact.id)) { + return false; + } + const tombstonedClientId = this.tombstonedClientIds.get(artifact.id); + // Tool/hook outputs are session-scoped and may be recreated by a later run + // with the same identity after an explicit deletion. + if (artifact.source !== 'client') { + return false; + } + return !( + artifact.retentionExplicit && + artifact.clientId !== undefined && + artifact.clientId === tombstonedClientId + ); + } + private async refreshWorkspaceStatuses(): Promise { const now = Date.now(); const staleWorkspaceArtifacts = Array.from(this.artifacts.values()) @@ -475,16 +1424,41 @@ export class SessionArtifactStore { ); } - private async getInitialWorkspaceStatus(workspacePath: string): Promise<{ + private applyStickyEphemeralOverride( + artifact: NormalizedArtifact, + ): NormalizedArtifact { + if ( + !this.stickyEphemeralIds.has(artifact.id) || + artifact.retentionExplicit || + artifact.retention === 'ephemeral' + ) { + return artifact; + } + return { + ...artifact, + retention: 'ephemeral', + retentionExplicit: true, + persistenceWarning: 'sticky_override_active', + }; + } + + private async getInitialWorkspaceStatus( + workspacePath: string, + expected?: WorkspaceStatusExpected, + options: { hashContent: boolean } = { hashContent: true }, + ): Promise<{ status: DaemonSessionArtifactStatus; sizeBytes?: number; + sha256?: string; + mtimeMs?: number; escaped?: boolean; }> { try { return await getWorkspaceStatus( - this.workspaceCwd, workspacePath, this.getRealWorkspaceCwd(), + expected, + options, ); } catch (error) { const reason = error instanceof Error ? error.message : String(error); @@ -504,16 +1478,23 @@ export class SessionArtifactStore { } try { const status = await getWorkspaceStatus( - this.workspaceCwd, artifact.workspacePath, this.getRealWorkspaceCwd(), + { + sizeBytes: artifact.sizeBytes, + mtimeMs: artifact.metadata?.[WORKSPACE_CONTENT_MTIME_MS_METADATA_KEY], + sha256: artifact.metadata?.[WORKSPACE_CONTENT_SHA256_METADATA_KEY], + }, ); - 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; - delete artifact.workspacePath; + artifact.hideWorkspacePath = true; } artifact.lastStatAt = options.now ?? Date.now(); } catch (error) { @@ -544,9 +1525,22 @@ export class SessionArtifactStore { return this.realWorkspaceCwdPromise; } + private async getRealWorkspaceCwdForValidation(): Promise { + try { + return await this.getRealWorkspaceCwd(); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new SessionArtifactValidationError( + `workspacePath could not be inspected: ${reason}`, + 'workspacePath', + ); + } + } + private async evictOverflow( createdIds: Set, changes: SessionArtifactChange[], + strict = false, ): Promise { const removed: SessionArtifactChange[] = []; if (this.artifacts.size <= this.maxArtifacts) { @@ -580,6 +1574,7 @@ export class SessionArtifactStore { action: 'removed', artifactId: artifact.id, artifact: toPublicArtifact(artifact), + durableTombstoneRequired: artifact.durableTombstoneRequired, reason: 'eviction', }); } @@ -587,6 +1582,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; @@ -645,7 +1650,13 @@ function mergeBatchArtifact( trustedPublisher: true, createdAt: existing.createdAt, receivedSeq: existing.receivedSeq, + retentionExplicit: existing.retentionExplicit || next.retentionExplicit, retentionSource: existing.retentionSource, + retention: mergeRetention(existing, next), + restoreState: 'live', + persistenceWarning: + existing.persistenceWarning ?? next.persistenceWarning, + persistedAt: existing.persistedAt ?? next.persistedAt, clientRetained: existing.clientRetained || next.clientRetained, lastStatAt: undefined, }; @@ -659,6 +1670,8 @@ function mergeBatchArtifact( metadata: mergeMetadata(existing, next), clientRetained: existing.clientRetained || next.clientRetained, trustedPublisher: existing.trustedPublisher || next.trustedPublisher, + retentionExplicit: existing.retentionExplicit || next.retentionExplicit, + retention: mergeRetention(existing, next), lastStatAt: next.lastStatAt ?? existing.lastStatAt, }; } @@ -697,6 +1710,12 @@ function mergeArtifact( publishedUpdate || existing.storage === 'published' ? undefined : (existing.workspacePath ?? incoming.workspacePath), + hideWorkspacePath: + publishedUpdate || + existing.storage === 'published' || + incoming.workspacePath + ? undefined + : existing.hideWorkspacePath, mimeType: publishedUpdate ? (incoming.mimeType ?? existing.mimeType) : existing.mimeType, @@ -708,6 +1727,16 @@ function mergeArtifact( existing.storage === 'published' && !publishedUpdate ? existing.metadata : mergeMetadata(existing, incoming), + retention: mergeRetention(existing, incoming), + restoreState: 'live', + persistenceWarning: + incoming.retentionExplicit && incoming.retention !== 'ephemeral' + ? incoming.persistenceWarning + : (existing.persistenceWarning ?? incoming.persistenceWarning), + persistedAt: + incoming.retentionExplicit && incoming.retention === 'ephemeral' + ? undefined + : (existing.persistedAt ?? incoming.persistedAt), source: existing.source, retentionSource: existing.retentionSource, trustedPublisher: existing.trustedPublisher || incoming.trustedPublisher, @@ -723,6 +1752,7 @@ function mergeArtifact( next.title = incoming.title; next.description = incoming.description; delete next.workspacePath; + delete next.hideWorkspacePath; } const changed = !publicArtifactsEqual( @@ -735,7 +1765,20 @@ function mergeArtifact( return { artifact: changed ? next : existing, changed }; } -function publicArtifactsEqual( +function shouldRecordEphemeralUnpin( + existing: StoredArtifact, + incoming: NormalizedArtifact, +): boolean { + return ( + incoming.retentionExplicit && + incoming.retention === 'ephemeral' && + (existing.retention !== 'ephemeral' || + existing.persistedAt !== undefined || + existing.durableTombstoneRequired === true) + ); +} + +export function publicArtifactsEqual( a: DaemonSessionArtifact, b: DaemonSessionArtifact, ): boolean { @@ -753,8 +1796,13 @@ 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 && + 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 && @@ -787,6 +1835,34 @@ function mergeSizeBytes( return existing.sizeBytes; } +function strongestRetention( + a: DaemonSessionArtifactRetention, + b: DaemonSessionArtifactRetention, +): DaemonSessionArtifactRetention { + const rank: Record = { + ephemeral: 0, + restorable: 1, + }; + return rank[b] > rank[a] ? b : a; +} + +function mergeRetention( + existing: Pick, + incoming: Pick, +): DaemonSessionArtifactRetention { + if (incoming.retentionExplicit && incoming.retention === 'ephemeral') { + return 'ephemeral'; + } + if ( + existing.retentionExplicit && + existing.retention === 'ephemeral' && + !incoming.retentionExplicit + ) { + return 'ephemeral'; + } + return strongestRetention(existing.retention, incoming.retention); +} + function mergeMetadata( existing: DaemonSessionArtifact, incoming: NormalizedArtifact, @@ -801,7 +1877,14 @@ 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 || + key === WORKSPACE_CONTENT_MTIME_MS_METADATA_KEY) && + merged[key] !== value + ) { + merged[key] = value; + changed = true; + } else if (!Object.hasOwn(merged, key)) { merged[key] = value; changed = true; } @@ -821,7 +1904,7 @@ function mergeMetadata( function isMetadataWithinLimit( metadata: Record, ): boolean { - return Buffer.byteLength(JSON.stringify(metadata), 'utf8') <= 4096; + return metadataBudgetBytes(metadata, 'persisted') <= 4096; } function countByRetentionSource( @@ -904,6 +1987,8 @@ function removePriorChange( function toPublicArtifact( artifact: StoredArtifact | DaemonSessionArtifact, ): DaemonSessionArtifact { + const hideWorkspacePath = + 'hideWorkspacePath' in artifact && artifact.hideWorkspacePath === true; const { id, kind, @@ -918,13 +2003,16 @@ function toPublicArtifact( mimeType, sizeBytes, metadata, + retention, + restoreState, + persistenceWarning, + persistedAt, clientRetained, createdAt, updatedAt, toolCallId, toolName, hookEventName, - clientId, } = artifact; return { id, @@ -934,19 +2022,249 @@ function toPublicArtifact( status, title, ...(description ? { description } : {}), - ...(workspacePath ? { workspacePath } : {}), + ...(workspacePath && !hideWorkspacePath ? { workspacePath } : {}), ...(managedId ? { managedId } : {}), ...(url ? { url } : {}), ...(mimeType ? { mimeType } : {}), ...(sizeBytes !== undefined ? { sizeBytes } : {}), ...(metadata ? { metadata } : {}), + retention, + ...(restoreState ? { restoreState } : {}), + ...(persistenceWarning ? { persistenceWarning } : {}), + ...(persistedAt ? { persistedAt } : {}), clientRetained, createdAt, updatedAt, ...(toolCallId ? { toolCallId } : {}), ...(toolName ? { toolName } : {}), ...(hookEventName ? { hookEventName } : {}), - ...(clientId ? { clientId } : {}), + }; +} + +function persistedArtifactToInput( + artifact: PersistedSessionArtifact, +): RestoreSessionArtifactInput { + 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, + }; +} + +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, +): 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 === 'changed' ? 'available' : 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.toolCallId ? { toolCallId: artifact.toolCallId } : {}), + ...(artifact.toolName ? { toolName: artifact.toolName } : {}), + ...(artifact.hookEventName + ? { hookEventName: artifact.hookEventName } + : {}), + }; +} + +function isFileArtifactUrl(raw: unknown): boolean { + if (typeof raw !== 'string') return false; + try { + return new URL(raw).protocol === 'file:'; + } catch { + return false; + } +} + +function isArtifactSnapshotCompletenessWarning(warning: string): boolean { + if (warning.startsWith('skipped stale event sequence ')) return false; + return ( + warning.startsWith('skipped ') || warning.includes(' list truncated to ') + ); +} + +function shouldCommitBeforeDurablePersistence( + change: SessionArtifactChange, +): boolean { + return ( + change.action === 'removed' && change.durableTombstoneRequired === true + ); +} + +function isDurablePersistenceChange(change: SessionArtifactChange): boolean { + if (change.action === 'removed' && change.durableTombstoneRequired === true) { + return true; + } + if (!change.artifact) return false; + return ( + change.artifact.retention !== 'ephemeral' || + change.artifact.persistenceWarning === 'persistence_unavailable' + ); +} + +function stripDurableTombstoneMarkers(changes: SessionArtifactChange[]): void { + for (const change of changes) { + delete change.durableTombstoneRequired; + delete (change as InternalSessionArtifactChange).removedClientId; + } +} + +function detailsForRestoreWarnings( + warnings: readonly string[], +): SessionArtifactWarningDetail[] { + return warnings.map((warning) => { + if (warning.startsWith(RESTORE_FAILED_WARNING_PREFIX)) { + return { + code: 'ARTIFACT_RESTORE_FAILED', + operation: 'restore', + durability: 'unavailable', + retryable: true, + message: warning, + }; + } + if (warning.startsWith(RESTORE_PARTIAL_FAILED_WARNING_PREFIX)) { + return { + code: 'ARTIFACT_RESTORE_PARTIAL_FAILED', + operation: 'restore', + durability: 'live_only', + retryable: true, + message: warning, + }; + } + return { + code: 'ARTIFACT_WARNING', + operation: 'restore', + message: warning, + }; + }); +} + +function detailsForPersistenceWarnings( + warnings: readonly string[], + changes: readonly SessionArtifactChange[], + operation: SessionArtifactWarningDetail['operation'], +): SessionArtifactWarningDetail[] { + if (warnings.length === 0) return []; + const artifactIds = changes + .filter(isDurablePersistenceChange) + .map((change) => change.artifactId); + return warnings.map((warning) => { + if ( + warning === + 'artifact persistence unavailable; durable artifacts kept ephemeral' + ) { + return { + code: 'ARTIFACT_PERSISTENCE_UNAVAILABLE', + operation, + artifactIds, + durability: 'live_only', + retryable: false, + message: warning, + }; + } + if (warning === 'artifact removal not persisted; live removal kept') { + return { + code: 'ARTIFACT_REMOVAL_NOT_PERSISTED', + operation, + artifactIds, + durability: 'live_only', + retryable: true, + message: warning, + }; + } + return { + code: 'ARTIFACT_WARNING', + operation, + artifactIds, + message: warning, + }; + }); +} + +function persistenceFailureDetail(options: { + message: string; + operation: SessionArtifactWarningDetail['operation']; + artifactIds: string[]; + error: unknown; +}): SessionArtifactWarningDetail { + const unavailable = + options.error instanceof SessionArtifactValidationError && + options.error.message === 'artifact persistence is unavailable'; + return { + code: unavailable + ? 'ARTIFACT_PERSISTENCE_UNAVAILABLE' + : 'ARTIFACT_PERSISTENCE_WRITE_FAILED', + operation: options.operation, + artifactIds: options.artifactIds, + durability: 'unavailable', + retryable: !unavailable, + message: options.message, + }; +} + +function cloneStoredArtifact(artifact: StoredArtifact): StoredArtifact { + return { + ...artifact, + ...(artifact.metadata ? { metadata: { ...artifact.metadata } } : {}), }; } @@ -1080,13 +2398,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, @@ -1252,6 +2563,12 @@ function normalizeArtifactUrl(raw: unknown, allowFile: boolean): string { 'url', ); } + if (hasSecretLikeUrlComponent(parsed)) { + throw new SessionArtifactValidationError( + 'url must not include secret-like components', + 'url', + ); + } if ( parsed.protocol !== 'http:' && parsed.protocol !== 'https:' && @@ -1267,8 +2584,68 @@ function normalizeArtifactUrl(raw: unknown, allowFile: boolean): string { return parsed.href; } +function hasSecretLikeUrlComponent(parsed: URL): boolean { + for (const [key, value] of parsed.searchParams) { + if (isSecretLikeUrlText(key) || isSecretLikeUrlValue(value)) { + return true; + } + } + for (const segment of parsed.pathname.split('/').filter(Boolean)) { + let decodedSegment = segment; + try { + decodedSegment = decodeURIComponent(segment); + } catch { + // Keep scanning the raw segment if URL parsing accepted malformed escape. + } + if (isSecretLikeUrlValue(decodedSegment)) { + return true; + } + } + const fragment = parsed.hash.slice(1); + return hasSecretLikeUrlFragment(fragment); +} + +function hasSecretLikeUrlFragment(fragment: string): boolean { + if (!fragment) return false; + const candidates = new Set([fragment]); + try { + candidates.add(decodeURIComponent(fragment)); + } catch { + // Keep scanning the raw fragment if URL parsing accepted malformed escape. + } + for (const candidate of candidates) { + if (isSecretLikeUrlText(candidate) || isSecretLikeUrlValue(candidate)) { + return true; + } + for (const [key, value] of new URLSearchParams(candidate)) { + if (isSecretLikeUrlText(key) || isSecretLikeUrlValue(value)) { + return true; + } + } + } + return false; +} + +function isSecretLikeUrlText(value: string): boolean { + const normalized = value.replace(/([a-z])([A-Z])/g, '$1-$2'); + return /(?:^|[-_.])(token|secret|password|passwd|pwd|cookie|authorization|credential|signature|sig|api[-_]?key|access[-_]?key)(?:$|[-_.=&#])/i.test( + normalized, + ); +} + +function isSecretLikeUrlValue(value: string): boolean { + return SECRET_TOKEN_VALUE_PATTERN.test(value.trim()); +} + +function isSecretLikeMetadataValue(value: string): boolean { + return /^(?:bearer\s+\S{8,}|sk-[A-Za-z0-9_-]{12,}|(?:gh[pousr]|github_pat)_[A-Za-z0-9_/-]{12,})$/i.test( + value.trim(), + ); +} + function normalizeMetadata( metadata: unknown, + options: { budget?: 'user' | 'persisted' } = {}, ): Record | undefined { if (metadata === undefined) { return undefined; @@ -1285,6 +2662,12 @@ function normalizeMetadata( } const normalized: Record = {}; for (const [key, value] of Object.entries(metadata)) { + if (isPrototypeMetadataKey(key)) { + continue; + } + if (options.budget !== 'persisted' && isReservedWorkspaceMetadataKey(key)) { + continue; + } if (!key) { throw new SessionArtifactValidationError( 'metadata keys must not be empty', @@ -1303,6 +2686,12 @@ function normalizeMetadata( 'metadata', ); } + if (isSecretLikeUrlText(key)) { + throw new SessionArtifactValidationError( + 'metadata keys must not contain secret-like names', + 'metadata', + ); + } if ( value !== null && typeof value !== 'string' && @@ -1329,12 +2718,22 @@ function normalizeMetadata( 'metadata', ); } + if ( + typeof value === 'string' && + !isReservedWorkspaceMetadataKey(key) && + isSecretLikeMetadataValue(value) + ) { + throw new SessionArtifactValidationError( + 'metadata string values must not contain secret-like tokens', + 'metadata', + ); + } normalized[key] = value; } 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', @@ -1343,6 +2742,28 @@ function normalizeMetadata( return normalized; } +function normalizeRetention( + value: unknown, + options: { persistenceAvailable: boolean }, +): DaemonSessionArtifactRetention { + if (value === undefined) { + return options.persistenceAvailable ? 'restorable' : 'ephemeral'; + } + if (value === 'ephemeral' || value === 'restorable') { + return value; + } + if (value === 'pinned') { + throw new SessionArtifactValidationError( + 'pinned retention is not supported by session_artifacts_persistence', + 'retention', + ); + } + throw new SessionArtifactValidationError( + 'retention must be ephemeral or restorable', + 'retention', + ); +} + function normalizeSizeBytes(value: unknown): number { if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { throw new SessionArtifactValidationError( @@ -1375,28 +2796,97 @@ function inferKind(input: { } async function getWorkspaceStatus( - workspaceCwd: string, workspacePath: string, realWorkspaceCwd: Promise, + expected?: WorkspaceStatusExpected, + options: { hashContent: boolean } = { hashContent: true }, ): Promise<{ status: DaemonSessionArtifactStatus; sizeBytes?: number; + sha256?: string; + mtimeMs?: number; escaped?: boolean; }> { - const absolutePath = path.resolve(workspaceCwd, workspacePath); const realWorkspace = await realWorkspaceCwd; + const absolutePath = path.resolve(realWorkspace, workspacePath); try { const realPath = await fs.realpath(absolutePath); const relative = path.relative(realWorkspace, realPath); if (!relative || isOutsidePath(relative)) { return { status: 'missing', escaped: true }; } - const stat = await fs.stat(realPath); - return { - status: 'available', - ...(stat.isFile() ? { sizeBytes: stat.size } : {}), - }; + 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; + 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, + }; + } + if (!options.hashContent) { + 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, + sha256, + }; + } + return { + status: 'available', + }; + } finally { + await handle.close(); + } } catch (error) { + if (isNoFollowSymlinkError(error)) { + return { status: 'missing', escaped: true }; + } if (!isNotFoundError(error)) { throw error; } @@ -1407,6 +2897,68 @@ 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 })) { + hash.update(chunk as Buffer); + } + return hash.digest('hex'); +} + +function withWorkspaceContentHashMetadata( + metadata: Record | undefined, + workspaceStatus: + | { + status: DaemonSessionArtifactStatus; + sha256?: string; + mtimeMs?: number; + } + | undefined, +): Record | undefined { + if (workspaceStatus?.status !== 'available' || !workspaceStatus.sha256) { + return metadata; + } + const next = { + ...(metadata ?? {}), + [WORKSPACE_CONTENT_SHA256_METADATA_KEY]: workspaceStatus.sha256, + ...(workspaceStatus.mtimeMs !== undefined + ? { [WORKSPACE_CONTENT_MTIME_MS_METADATA_KEY]: workspaceStatus.mtimeMs } + : {}), + }; + 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 ( + artifact.sizeBytes !== undefined && + status.sizeBytes !== undefined && + status.sizeBytes !== artifact.sizeBytes + ) { + return true; + } + return ( + typeof expectedSha256 === 'string' && + status.sha256 !== undefined && + status.sha256 !== expectedSha256 + ); +} + async function danglingSymlinkEscapesWorkspace( absolutePath: string, realWorkspace: string, @@ -1446,6 +2998,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/acp-bridge/src/status.ts b/packages/acp-bridge/src/status.ts index bf2480f743..dcecb87a9e 100644 --- a/packages/acp-bridge/src/status.ts +++ b/packages/acp-bridge/src/status.ts @@ -138,6 +138,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.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index 9de54cd526..7a12fc7772 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -128,6 +128,23 @@ 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: () => mockDebugLogger, registerAcpEventLoopLagGauge: vi.fn(), startEventLoopLagMonitor: vi.fn(() => ({ @@ -634,6 +651,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'; @@ -1559,6 +1577,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([]), @@ -1573,6 +1592,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), @@ -1827,6 +1849,241 @@ describe('QwenAgent MCP SSE/HTTP support', () => { return innerConfig; } + async function bootAcpAgent() { + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + 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, { + sessionId: 'session-A', + kind: 'event', + }), + ).rejects.toThrowError(/Invalid or missing artifact persist payload/); + + mockConnectionState.resolve(); + 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: { + 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/); + + 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: { + v: SESSION_ARTIFACT_PERSISTENCE_VERSION, + sessionId, + sequence: 1, + recordedAt: '2026-07-04T00:00:00.000Z', + changes: [], + }, + }), + ).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: SESSION_ARTIFACT_PERSISTENCE_VERSION, + sessionId, + sequence: 1, + recordedAt: '2026-07-04T00:00:00.000Z', + changes: [], + }; + const snapshotPayload = { + v: SESSION_ARTIFACT_PERSISTENCE_VERSION, + sessionId, + sequence: 2, + recordedAt: '2026-07-04T00:00:01.000Z', + 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('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('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, @@ -7187,6 +7444,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, @@ -7218,12 +7490,101 @@ describe('QwenAgent MCP SSE/HTTP support', () => { apiTruncateIndex: 2, filesChanged: [], filesFailed: [], + artifactSnapshot, }); mockConnectionState.resolve(); await agentPromise; }); + it('rewindSession extension method marks artifact snapshot unavailable when session reload is missing', async () => { + const sessionId = '11111111-1111-1111-1111-111111111111'; + await setupSessionMocks(sessionId); + vi.mocked(SessionService).mockImplementation( + () => + ({ + loadSession: vi.fn().mockResolvedValue(undefined), + }) as unknown as InstanceType, + ); + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + const response = await agent.extMethod('rewindSession', { + sessionId, + targetTurnIndex: 1, + cwd: '/tmp', + }); + + expect(response).toMatchObject({ + success: true, + artifactSnapshotUnavailable: 'session data unavailable after rewind', + }); + expect(response).not.toHaveProperty('artifactSnapshot'); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('rewindSession extension method returns an empty artifact snapshot when reload has no artifact records', async () => { + const sessionId = '11111111-1111-1111-1111-111111111111'; + await setupSessionMocks(sessionId); + vi.mocked(SessionService).mockImplementation( + () => + ({ + loadSession: vi.fn().mockResolvedValue({ conversation: {} }), + }) as unknown as InstanceType, + ); + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + const response = await agent.extMethod('rewindSession', { + sessionId, + targetTurnIndex: 1, + cwd: '/tmp', + }); + + expect(response).toMatchObject({ + success: true, + artifactSnapshot: { + v: SESSION_ARTIFACT_PERSISTENCE_VERSION, + sessionId, + sequence: 0, + artifacts: [], + tombstonedIds: [], + stickyEphemeralIds: [], + warnings: [], + }, + }); + expect(response).not.toHaveProperty('artifactSnapshotUnavailable'); + + mockConnectionState.resolve(); + await agentPromise; + }); + it('rewindSession extension method can skip file rewind', async () => { const sessionId = '11111111-1111-1111-1111-111111111111'; await setupSessionMocks(sessionId); diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 9ea1e59feb..cb42bf7b1b 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -66,6 +66,9 @@ import { matchesAnyServerPattern, IMAGE_CAPABILITY, registerAcpEventLoopLagGauge, + SESSION_ARTIFACT_PERSISTENCE_VERSION, + normalizeEventPayload, + normalizeSnapshotPayload, startEventLoopLagMonitor, } from '@qwen-code/qwen-code-core'; import { randomUUID } from 'node:crypto'; @@ -86,6 +89,8 @@ import type { DiscoveredMCPResource, DiscoveredMCPPrompt, WorkspaceRememberContextMode, + SessionArtifactEventRecordPayload, + SessionArtifactSnapshotRecordPayload, ChatRecord, HistoryGap, } from '@qwen-code/qwen-code-core'; @@ -330,6 +335,67 @@ function isObjectRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } +function parseSessionArtifactEventPayload( + payload: unknown, + expectedSessionId: string, +): SessionArtifactEventRecordPayload { + 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 normalized; +} + +function parseSessionArtifactSnapshotPayload( + payload: unknown, + expectedSessionId: string, +): SessionArtifactSnapshotRecordPayload { + 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 normalized; +} + +function parseSessionArtifactBasePayload( + payload: unknown, + expectedSessionId: string, +): Record { + if (!isObjectRecord(payload)) { + throw invalidArtifactPersistPayload(); + } + if ( + payload['v'] !== SESSION_ARTIFACT_PERSISTENCE_VERSION || + payload['sessionId'] !== expectedSessionId || + !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; @@ -3202,7 +3268,10 @@ class QwenAgent implements Agent { modes: modesData, models: availableModels, configOptions, - }; + ...(sessionData?.artifactSnapshot + ? { artifactSnapshot: sessionData.artifactSnapshot } + : {}), + } as LoadSessionResponse; if (!replayEnvelope) { return response; } @@ -3255,11 +3324,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; } /** @@ -6473,6 +6546,47 @@ 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']; + 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) { + throw RequestError.internalError( + undefined, + 'Chat recording service unavailable', + ); + } + if (kind === 'event') { + await recording.recordSessionArtifactEvent( + parseSessionArtifactEventPayload(payload, sessionId), + ); + } else { + await recording.recordSessionArtifactSnapshot( + parseSessionArtifactSnapshotPayload(payload, sessionId), + ); + } + return { sessionId, persisted: true, kind }; + } case SERVE_CONTROL_EXT_METHODS.sessionTitle: { const sessionId = params['sessionId']; const displayName = params['displayName']; @@ -7521,6 +7635,46 @@ class QwenAgent implements Agent { filesFailed = [`file-history-rewind: ${reason}`]; } } + let artifactSnapshot: unknown; + let artifactSnapshotUnavailable: string | undefined; + 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); + }, + ); + if (sessionData === undefined) { + artifactSnapshotUnavailable = + 'session data unavailable after rewind'; + } else if (sessionData.artifactSnapshot) { + artifactSnapshot = sessionData.artifactSnapshot; + } else { + // A successful reload with no artifact records is a valid empty + // artifact timeline, distinct from an unavailable reload. + 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}: ${ + artifactSnapshotUnavailable + }`, + ); + } return { success: true, @@ -7528,6 +7682,10 @@ class QwenAgent implements Agent { ...rewindResult, filesChanged, filesFailed, + ...(artifactSnapshot ? { artifactSnapshot } : {}), + ...(artifactSnapshotUnavailable + ? { artifactSnapshotUnavailable } + : {}), }; } case 'qwen/session/loadUpdates': { diff --git a/packages/cli/src/acp-integration/session/HistoryReplayer.test.ts b/packages/cli/src/acp-integration/session/HistoryReplayer.test.ts index 4f891c5991..e1a1eaf6ad 100644 --- a/packages/cli/src/acp-integration/session/HistoryReplayer.test.ts +++ b/packages/cli/src/acp-integration/session/HistoryReplayer.test.ts @@ -679,6 +679,25 @@ describe('HistoryReplayer', () => { }); }); + it('should replay structured artifacts from stored tool results', async () => { + const record = createToolResultRecord('read_file', 'File contents here'); + const artifacts = [ + { + title: 'Replay artifact', + url: 'https://example.com/replayed', + }, + ]; + record.toolCallResult!.artifacts = artifacts; + + await replayer.replay([record]); + + expect(sentUpdates()[0]).toMatchObject({ + _meta: { + artifacts, + }, + }); + }); + it('should emit failed status for tool results with errors', async () => { const records = [createToolResultRecord('failing_tool', undefined, true)]; diff --git a/packages/cli/src/acp-integration/session/HistoryReplayer.ts b/packages/cli/src/acp-integration/session/HistoryReplayer.ts index 223b1346d2..02c6248ba3 100644 --- a/packages/cli/src/acp-integration/session/HistoryReplayer.ts +++ b/packages/cli/src/acp-integration/session/HistoryReplayer.ts @@ -295,6 +295,7 @@ export class HistoryReplayer { success: !result?.error, message: record.message.parts, resultDisplay: result?.resultDisplay, + artifacts: result?.artifacts, // For TodoWriteTool fallback, try to extract args from the record // Note: args aren't stored in tool_result records by default args: undefined, diff --git a/packages/cli/src/serve/acp-http/dispatch.ts b/packages/cli/src/serve/acp-http/dispatch.ts index 3376cd7320..e47738501e 100644 --- a/packages/cli/src/serve/acp-http/dispatch.ts +++ b/packages/cli/src/serve/acp-http/dispatch.ts @@ -46,7 +46,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'; @@ -411,6 +414,8 @@ function pickSessionArtifactInput( mimeType, sizeBytes, metadata, + retention, + clientRetained, } = params; return { @@ -424,6 +429,8 @@ function pickSessionArtifactInput( mimeType, sizeBytes, metadata, + retention, + clientRetained, } as AddSessionArtifactInput; } @@ -533,6 +540,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, @@ -2556,7 +2574,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/acp-http/transport.test.ts b/packages/cli/src/serve/acp-http/transport.test.ts index 0ca85f291f..b6c7b80d82 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, @@ -398,6 +401,9 @@ class FakeBridge { } | undefined; lastArtifactListSessionId: string | undefined; + lastArtifactListContext: + | Parameters[1] + | undefined; lastRemovedArtifact: | { sessionId: string; @@ -405,8 +411,12 @@ class FakeBridge { context: Parameters[2]; } | undefined; - async getSessionArtifacts(sessionId: string) { + async getSessionArtifacts( + sessionId: string, + context: Parameters[1], + ) { this.lastArtifactListSessionId = sessionId; + this.lastArtifactListContext = context; return { v: 1, sessionId, @@ -6039,6 +6049,10 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { }, }); expect(bridge.lastArtifactListSessionId).toBe('sess-1'); + expect(bridge.lastArtifactListContext).toEqual({ + clientId: 'client-1', + fromLoopback: true, + }); }); it('_qwen/session/artifacts/add forwards only public artifact fields', async () => { @@ -6063,6 +6077,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', @@ -6081,6 +6097,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 @@ -6091,6 +6109,10 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { expect(artifact).not.toHaveProperty('clientId'); expect(artifact).not.toHaveProperty('toolName'); expect(artifact).not.toHaveProperty('hookEventName'); + expect(bridge.lastAddedArtifact?.context).toEqual({ + clientId: 'client-1', + fromLoopback: true, + }); }); it('_qwen/session/artifacts/add maps artifact validation errors to invalid params', async () => { @@ -6144,7 +6166,10 @@ 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', + }, }); const frames = await takeFrames(await streamRes, 2); expect(frames[1]).toMatchObject({ @@ -6163,6 +6188,10 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { expect(bridge.lastRemovedArtifact).toMatchObject({ sessionId: 'sess-1', artifactId: 'artifact-1', + context: { + clientId: 'client-1', + fromLoopback: true, + }, }); }); @@ -6193,6 +6222,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/add holds the archive gate while mutating', async () => { await withRuntimeDir(async () => { const sessionId = '550e8400-e29b-41d4-a716-446655440131'; @@ -6275,7 +6344,11 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { artifactId, context, ) => { - bridge.lastRemovedArtifact = { sessionId, artifactId, context }; + bridge.lastRemovedArtifact = { + sessionId, + artifactId, + context, + }; 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 9913c74017..8da687ab3a 100644 --- a/packages/cli/src/serve/acp-session-bridge.ts +++ b/packages/cli/src/serve/acp-session-bridge.ts @@ -130,4 +130,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/capabilities.ts b/packages/cli/src/serve/capabilities.ts index 40430854b1..b35f1ad461 100644 --- a/packages/cli/src/serve/capabilities.ts +++ b/packages/cli/src/serve/capabilities.ts @@ -45,6 +45,7 @@ export const SERVE_CAPABILITY_REGISTRY = { session_cancel: { since: 'v1' }, session_events: { since: 'v1' }, session_artifacts: { since: 'v1' }, + session_artifacts_persistence: { 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 @@ -311,6 +312,7 @@ export interface AdvertiseFeatureToggles { persistSettingAvailable?: boolean; voiceTranscriptionAvailable?: boolean; sessionShellCommandEnabled?: boolean; + sessionArtifactsPersistenceAvailable?: boolean; rateLimit?: boolean; reloadAvailable?: boolean; /** @@ -394,6 +396,10 @@ export const CONDITIONAL_SERVE_FEATURES: ReadonlyMap< 'session_shell_command', (toggles) => toggles.sessionShellCommandEnabled === true, ], + [ + 'session_artifacts_persistence', + (toggles) => toggles.sessionArtifactsPersistenceAvailable === true, + ], ['rate_limit', (toggles) => toggles.rateLimit === true], ['workspace_reload', (toggles) => toggles.reloadAvailable === true], [ diff --git a/packages/cli/src/serve/fast-path-settings.ts b/packages/cli/src/serve/fast-path-settings.ts index 48a5e43a4e..c79f217419 100644 --- a/packages/cli/src/serve/fast-path-settings.ts +++ b/packages/cli/src/serve/fast-path-settings.ts @@ -39,6 +39,7 @@ export type ServeFastPathSettings = Pick< Settings, 'advanced' | 'context' | 'env' | 'security' | 'tools' > & { + general?: Pick, 'chatRecording'>; policy?: ServeFastPathPolicyInput; }; const V2_SETTINGS_VERSION = 2; @@ -439,6 +440,19 @@ function pickFastPathSettings( out.env = pickStringRecord(env); } + const general = value['general']; + if (isPlainObject(general)) { + const chatRecording = general['chatRecording']; + if (chatRecording !== undefined && typeof chatRecording !== 'boolean') { + throw new Error( + 'Serve fast path settings general.chatRecording must be a boolean.', + ); + } + if (chatRecording !== undefined) { + out.general = { chatRecording }; + } + } + const advanced = value['advanced']; if (isPlainObject(advanced)) { const pickedAdvanced: NonNullable = {}; @@ -621,6 +635,9 @@ function mergeFastPathSettings( if (source.env) { merged.env = { ...(merged.env ?? {}), ...source.env }; } + if (source.general) { + merged.general = { ...(merged.general ?? {}), ...source.general }; + } if (source.advanced?.excludedEnvVars) { merged.advanced = { ...(merged.advanced ?? {}), diff --git a/packages/cli/src/serve/fast-path.test.ts b/packages/cli/src/serve/fast-path.test.ts index c9f8ab0d46..e6260604a2 100644 --- a/packages/cli/src/serve/fast-path.test.ts +++ b/packages/cli/src/serve/fast-path.test.ts @@ -213,6 +213,9 @@ function pickServeFastPathComparable( if (settings.env) { out.env = settings.env; } + if (settings.general?.chatRecording !== undefined) { + out.general = { chatRecording: settings.general.chatRecording }; + } if (settings.advanced?.excludedEnvVars !== undefined) { out.advanced = { ...(out.advanced ?? {}), @@ -1362,6 +1365,7 @@ describe('serve fast path environment bootstrap', () => { FAST_PATH_OVERLAP: 'workspace', }, advanced: { runtimeOutputDir: '.workspace-runtime' }, + general: { chatRecording: false }, context: { fileName: 'WORKSPACE.md', fileFiltering: { customIgnoreFiles: ['.workspace-ignore'] }, diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index 92ba0f4cc7..2a29fc2b0e 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -81,6 +81,19 @@ interface RegisterSessionRoutesDeps { languageCodes: string[]; } +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; @@ -1042,10 +1055,17 @@ export function registerSessionRoutes( '/session/:id/artifacts', withOwnerReadSession( 'GET /session/:id/artifacts', - async (_req, res, sessionId, runtime) => { + async (req, res, sessionId, runtime) => { + const clientId = parseClientIdHeader(req, res); + if (clientId === null) return; res .status(200) - .json(await runtime.bridge.getSessionArtifacts(sessionId)); + .json( + await runtime.bridge.getSessionArtifacts( + sessionId, + clientId !== undefined ? { clientId } : undefined, + ), + ); }, ), ); @@ -1058,6 +1078,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 = { @@ -1075,12 +1096,14 @@ 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, - 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; @@ -1102,6 +1125,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, @@ -1117,10 +1141,11 @@ export function registerSessionRoutes( const result = await bridge.removeSessionArtifact( sessionId, artifactId, - clientId !== undefined ? { clientId } : undefined, + { clientId }, ); 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/run-qwen-serve.ts b/packages/cli/src/serve/run-qwen-serve.ts index 3fb8a3cf7a..8249b1002b 100644 --- a/packages/cli/src/serve/run-qwen-serve.ts +++ b/packages/cli/src/serve/run-qwen-serve.ts @@ -461,6 +461,12 @@ export function extractContextFilename(value: unknown): string | undefined { return undefined; } +function sessionArtifactsPersistenceAvailableFromSettings( + settings: { general?: { chatRecording?: unknown } } | undefined, +): boolean { + return settings?.general?.chatRecording !== false; +} + /** * Per-workspace promise chain that serializes settings read-modify-write * cycles inside this process. @@ -837,6 +843,7 @@ function sessionIdleTimeoutMs(value: number | undefined): number { function currentServeFeaturesForRunQwenServe( opts: ServeOptions, sessionShellCommandEnabled: boolean, + sessionArtifactsPersistenceAvailable: boolean, ): string[] { return getAdvertisedServeFeatures(undefined, { requireAuth: opts.requireAuth === true, @@ -851,6 +858,7 @@ function currentServeFeaturesForRunQwenServe( : {}), persistSettingAvailable: true, sessionShellCommandEnabled, + sessionArtifactsPersistenceAvailable, rateLimit: opts.rateLimit === true, reloadAvailable: true, // Advertise the same WS feature flags as the runtime path (serve-features.ts) @@ -869,6 +877,7 @@ function createBootstrapCapabilities(input: { boundWorkspace: string; qwenCodeVersion?: string; sessionShellCommandEnabled: boolean; + sessionArtifactsPersistenceAvailable: boolean; permissionPolicy: PermissionPolicy | undefined; }): CapabilitiesEnvelope { return { @@ -881,6 +890,7 @@ function createBootstrapCapabilities(input: { features: currentServeFeaturesForRunQwenServe( input.opts, input.sessionShellCommandEnabled, + input.sessionArtifactsPersistenceAvailable, ), modelServices: [], workspaceCwd: input.boundWorkspace, @@ -1056,6 +1066,7 @@ function createBootstrapServeApp(input: { daemonLog: DaemonLogger; qwenCodeVersion?: string; sessionShellCommandEnabled: boolean; + sessionArtifactsPersistenceAvailable: boolean; permissionPolicy: PermissionPolicy | undefined; multiWorkspaceCapabilitiesRequireRuntime: boolean; getRuntimeError: () => string | undefined; @@ -1072,6 +1083,7 @@ function createBootstrapServeApp(input: { daemonLog, qwenCodeVersion, sessionShellCommandEnabled, + sessionArtifactsPersistenceAvailable, permissionPolicy, multiWorkspaceCapabilitiesRequireRuntime, getRuntimeError, @@ -1137,6 +1149,7 @@ function createBootstrapServeApp(input: { boundWorkspace, qwenCodeVersion, sessionShellCommandEnabled, + sessionArtifactsPersistenceAvailable, permissionPolicy, }), ); @@ -1218,6 +1231,7 @@ function createBootstrapServeApp(input: { features: currentServeFeaturesForRunQwenServe( opts, sessionShellCommandEnabled, + sessionArtifactsPersistenceAvailable, ), }, runtime: { @@ -1805,9 +1819,12 @@ export async function runQwenServe( let permissionPolicy: PermissionPolicy | undefined; let permissionConsensusQuorum: number | undefined; let bootSettings: ServeFastPathSettings | undefined; + let sessionArtifactsPersistenceAvailable = true; try { bootSettings = deps.bootSettings ?? loadServeFastPathSettings(boundWorkspace); + sessionArtifactsPersistenceAvailable = + sessionArtifactsPersistenceAvailableFromSettings(bootSettings); contextFilenameForInit = extractContextFilename( bootSettings.context?.fileName, ); @@ -3184,6 +3201,10 @@ export async function runQwenServe( persistDisabledTools: persistDisabledToolsFn, persistSetting: persistSettingFn, persistSettings: persistSettingsFn, + sessionArtifactsPersistenceAvailable: + sessionArtifactsPersistenceAvailableFromSettings( + runtimeBootSettings?.merged, + ), installAuthProvider: (req) => withSettingsLock( boundWorkspace, @@ -3261,6 +3282,7 @@ export async function runQwenServe( daemonLog, qwenCodeVersion: cliVersion, sessionShellCommandEnabled, + sessionArtifactsPersistenceAvailable, permissionPolicy, multiWorkspaceCapabilitiesRequireRuntime: workspaceInputs.length > 1, getRuntimeError: () => runtimeStartupError, diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 8e99a68922..4adfc71d16 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -66,6 +66,7 @@ import { PermissionPolicyNotImplementedError, PromptQueueFullError, RestoreInProgressError, + SessionArtifactAuthorizationError, SessionArtifactValidationError, SessionShellClientRequiredError, SessionShellDisabledError, @@ -326,7 +327,11 @@ 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'] + : [feature], + ).filter( (f) => f !== 'workspace_init' && f !== 'workspace_github_setup' && @@ -665,7 +670,10 @@ interface FakeBridge extends AcpSessionBridge { }>; listCalls: string[]; summaryCalls: string[]; - sessionArtifactsCalls: string[]; + sessionArtifactsCalls: Array<{ + sessionId: string; + context?: BridgeClientRequestContext; + }>; addSessionArtifactCalls: Array<{ sessionId: string; artifact: Parameters[1]; @@ -820,7 +828,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'] = []; @@ -947,6 +955,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', @@ -1478,9 +1487,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({ @@ -2088,6 +2100,24 @@ 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 === 'workspace_reload') { expect(predicate({ reloadAvailable: true })).toBe(true); expect(predicate({ reloadAvailable: false })).toBe(false); @@ -2521,6 +2551,7 @@ describe('createServeApp', () => { expect(res.body.features).toEqual( getAdvertisedServeFeatures(undefined, { mcpPoolActive: true, + sessionArtifactsPersistenceAvailable: true, }), ); expect(res.body.modelServices).toEqual([]); @@ -2529,6 +2560,17 @@ describe('createServeApp', () => { }); }); + it('omits artifact persistence when the durable sink is unavailable', async () => { + const app = createServeApp(baseOpts, undefined, { + sessionArtifactsPersistenceAvailable: false, + }); + const res = await request(app) + .get('/capabilities') + .set('Host', `127.0.0.1:${baseOpts.port}`); + expect(res.status).toBe(200); + expect(res.body.features).not.toContain('session_artifacts_persistence'); + }); + it('advertises workspace voice transcription when a batch ASR model is configured', async () => { const previousQwenHome = process.env['QWEN_HOME']; const tempHome = await fsp.mkdtemp( @@ -8329,7 +8371,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({ @@ -8337,7 +8381,26 @@ 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 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(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 () => { @@ -8348,11 +8411,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 () => { @@ -8369,13 +8437,31 @@ 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 }); 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({ @@ -8394,6 +8480,8 @@ describe('createServeApp', () => { artifact: { title: 'Lineage', url: 'https://example.com/lineage', + retention: 'ephemeral', + clientRetained: false, }, context: { clientId: 'client-1' }, }, @@ -8411,9 +8499,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({ @@ -8439,6 +8527,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 }); @@ -8461,6 +8562,50 @@ describe('createServeApp', () => { }, ]); }); + + 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'), + ).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' }, + }, + ]); + }); + + 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', + }); + }); }); describe('POST /session/:id/model', () => { diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index 111eb0b047..a4b9a87819 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -287,6 +287,7 @@ export interface ServeAppDeps { value: unknown; }>, ) => Promise; + sessionArtifactsPersistenceAvailable?: boolean; /** * Reverse tool channel (issue #5626, Phase 2). Shared sender registry that * bridges the daemon WS (per-connection `ClientMcpRegistrar`) and the ACP @@ -499,6 +500,8 @@ export function createServeApp( opts, boundWorkspace, persistSettingAvailable: deps.persistSetting !== undefined, + sessionArtifactsPersistenceAvailable: + deps.sessionArtifactsPersistenceAvailable !== false, // Registry injection supplies the primary workspace service through the // runtime, so it has the same reload surface as legacy deps.workspace. reloadAvailable: diff --git a/packages/cli/src/serve/server/error-response.ts b/packages/cli/src/serve/server/error-response.ts index 410b5e341d..e7848751cf 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, @@ -279,6 +280,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/cli/src/serve/server/serve-features.ts b/packages/cli/src/serve/server/serve-features.ts index 09c88e96f7..1a1c87ae96 100644 --- a/packages/cli/src/serve/server/serve-features.ts +++ b/packages/cli/src/serve/server/serve-features.ts @@ -41,6 +41,7 @@ interface CreateServeFeaturesDeps { opts: ServeOptions; boundWorkspace: string; persistSettingAvailable: boolean; + sessionArtifactsPersistenceAvailable: boolean; reloadAvailable: boolean; sessionShellCommandEnabled: boolean; multiWorkspaceSessionsEnabled: boolean; @@ -59,6 +60,7 @@ export function createServeFeatures( opts, boundWorkspace, persistSettingAvailable, + sessionArtifactsPersistenceAvailable, reloadAvailable, sessionShellCommandEnabled, multiWorkspaceSessionsEnabled, @@ -90,6 +92,7 @@ export function createServeFeatures( : {}), persistSettingAvailable, sessionShellCommandEnabled, + sessionArtifactsPersistenceAvailable, rateLimit: opts.rateLimit === true, reloadAvailable, multiWorkspaceSessionsEnabled, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index eb8b832850..f4b70f8624 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -228,6 +228,7 @@ export * from './services/visionBridge/vision-bridge-service.js'; export * from './services/visionBridge/image-part-utils.js'; export * from './services/visionBridge/image-capability.js'; export * from './services/sessionRecap.js'; +export * from './services/session-artifact-persistence.js'; export * from './services/sessionService.js'; export * from './utils/conversation-chain.js'; export * from './services/sessionTitle.js'; diff --git a/packages/core/src/services/chatRecordingService.test.ts b/packages/core/src/services/chatRecordingService.test.ts index e38deb2d55..3607a42cd7 100644 --- a/packages/core/src/services/chatRecordingService.test.ts +++ b/packages/core/src/services/chatRecordingService.test.ts @@ -1113,6 +1113,94 @@ 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 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('appends strict artifact records 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: [], + }), + ).resolves.toBeUndefined(); + expect(jsonl.writeLine).toHaveBeenCalledTimes(2); + const snapshotRecord = vi.mocked(jsonl.writeLine).mock + .calls[1][1] as ChatRecord; + expect(snapshotRecord.subtype).toBe('session_artifact_snapshot'); + }); + + 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 29b97dcfcd..48c8091f90 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; @@ -769,6 +777,39 @@ export class ChatRecordingService { this.updateTitleAnchorTracking(record); } + 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(); + } catch (error) { + debugLogger.error('Error appending record:', error); + throw error; + } + + if (updateActiveTail) { + this.lastRecordUuid = record.uuid; + } + this.writeChain = this.writeChain + .catch(() => {}) + .then(() => jsonl.writeLine(conversationFile, record)); + + try { + await this.writeChain; + this.updateTitleAnchorTracking(record); + } catch (error) { + if (updateActiveTail && this.lastRecordUuid === record.uuid) { + 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 @@ -1477,4 +1518,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, { updateActiveTail: false }); + } + + async recordSessionArtifactSnapshot( + payload: SessionArtifactSnapshotRecordPayload, + ): Promise { + const record: ChatRecord = { + ...this.createBaseRecord('system'), + type: 'system', + subtype: 'session_artifact_snapshot', + systemPayload: payload, + }; + await this.appendRecordStrict(record, { updateActiveTail: false }); + } } 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 0000000000..3813ff0c01 --- /dev/null +++ b/packages/core/src/services/session-artifact-persistence.test.ts @@ -0,0 +1,1075 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { + SESSION_ARTIFACT_PERSISTENCE_VERSION, + normalizeEventPayload, + normalizeSnapshotPayload, + rebuildSessionArtifactSnapshot, + remapSessionArtifactPayloadForFork, + stableSessionArtifactId, + type PersistedSessionArtifact, + type SessionArtifactEventRecordPayload, + type SessionArtifactSnapshotRecordPayload, +} 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('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('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'); + + 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('skips stale events at or before the latest snapshot sequence', () => { + const first = artifact('s1', 'https://example.com/first'); + const stale = artifact('s1', 'https://example.com/stale'); + + const snapshot = rebuildSessionArtifactSnapshot([ + { + type: 'system', + subtype: 'session_artifact_snapshot', + systemPayload: { + v: SESSION_ARTIFACT_PERSISTENCE_VERSION, + sessionId: 's1', + sequence: 10, + recordedAt: '2026-07-04T00:00:00.000Z', + artifacts: [first], + tombstonedIds: [], + stickyEphemeralIds: [], + }, + }, + event({ + v: SESSION_ARTIFACT_PERSISTENCE_VERSION, + sessionId: 's1', + sequence: 9, + recordedAt: '2026-07-04T00:00:01.000Z', + changes: [{ action: 'created', artifactId: stale.id, artifact: stale }], + }), + ]); + + expect(snapshot?.artifacts).toEqual([first]); + expect(snapshot?.warnings).toContain( + 'skipped stale event sequence 9 at or before snapshot sequence 10', + ); + }); + + 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('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('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('filters unsafe persisted metadata 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: 'unsafe-metadata', + artifact: artifact('s1', 'https://example.com/unsafe-metadata', { + metadata: { + safe: 'ok', + '