diff --git a/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md b/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md index 8d74d02ed8c..5bc6bed3b22 100644 --- a/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md +++ b/docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md @@ -6,7 +6,7 @@ V2 的目标是在不破坏 V1 live session 语义的前提下,让 artifact me ## 1. 设计结论 -V2 是一个完整设计 phase,但对外能力仍按 capability gate 暴露。PR #6259 的实现范围收敛为 metadata restore、artifact JSONL journal/snapshot/rebuild/fork remap、daemon restart/load/replay 后恢复 artifact metadata,以及 REST/ACP/SDK 的 metadata persistence 暴露。content retention(workspace content pin、session-scoped managed copy、hash/manifest、quota、TTL、session-scoped GC/fsck)已拆出到后续 PR。client 不应依赖“V2”这个阶段名推断功能,而应读取 capability。 +V2 是一个完整设计 phase,但对外能力仍按 capability gate 暴露。PR #6259 的实现范围是:metadata restore、显式 workspace content pin、session-scoped managed copy、hash/manifest 校验、硬编码 quota、TTL 降级、session-scoped GC/fsck,以及 load/resume/rewind/fork 的 active-chain 恢复。client 不应依赖“V2”这个阶段名推断功能,而应读取 capability。 两层能力: @@ -16,7 +16,7 @@ V2 是一个完整设计 phase,但对外能力仍按 capability gate 暴露。 对应 capability: - `session_artifacts_persistence`:支持 metadata 持久化与 session load/replay 恢复。 -- `session_artifacts_content_retention`:后续 content-retention PR 才声明;它支持显式 workspace 内容保留、配额、hash、manifest 和 session-scoped GC/fsck。即使后续声明,也不承诺 project-wide cross-session GC、global artifact library、published file-root restore 或 sidecar cache。 +- `session_artifacts_content_retention`:支持显式 workspace 内容保留、配额、hash、manifest 和 session-scoped GC/fsck。当前实现不声明或承诺 project-wide cross-session GC、global artifact library、published file-root restore 或 sidecar cache。 核心原则: @@ -30,7 +30,7 @@ V2 是一个完整设计 phase,但对外能力仍按 capability gate 暴露。 当前 PR 的重要收窄: -- Content retention public API、managed content store、pin/unpin、deleteContent、quota/hash/manifest/fsck/gc 和 `session_artifacts_content_retention` capability 不在 PR #6259 中交付;这些安全面拆到后续 PR。当前 PR 只保留对旧 `pinned` / `contentRef` journal payload 的 downgrade/strip 兼容路径,避免旧记录破坏 metadata restore。 +- Content retention 只保存用户显式 pin 的 workspace regular file managed copy。`contentId` 包含 session/artifact 维度,fork 不继承 `contentRef`,因此当前 GC 可以按 session 引用集保守清理;project-wide 引用重建、orphan grace marker 和跨 session shared contentRef 是后续增强。 - 当前 live view 与 persisted metadata 使用同一个 200 条可见集合。为了避免重启后 over-restore,超过上限时的 durable/restorable eviction 会写入 `reason: "eviction"` remove event;这等价于本实现的 metadata prune,不是纯 V1 live-only hiding。 - 显式 DELETE 当前采用 live-first:先从 live store 移除,tombstone 写入失败时返回 warning。这样可优先隐藏敏感项;失败窗口内 daemon 重启仍可能从旧 journal 恢复该 artifact,client 应把 warning 作为“删除未 durable”的信号。 - Fork 当前通过一次性 exclusive-create 写入目标 JSONL 文件;不会逐条 streaming fork artifact records,因此不需要 `session_artifact_fork_marker` 才能检测当前写入路径的 partial batch。若未来改成流式 fork,再引入 begin/complete marker。 @@ -369,7 +369,7 @@ ingest input - `eviction`:durable remove event,保证重启后仍遵守 200 条上限。 - unpin-to-`ephemeral`:durable remove event,并把 id 写入 bounded `stickyEphemeralIds`;后续隐式/default upsert 会保持 live-only,直到显式 `retention: "restorable"` 或 pin/save supersede。 - 显式 DELETE:live-first。先从 live store 移除并发布删除事件,再 best-effort 写 explicit remove tombstone。tombstone 写入失败时 response 返回 warning(当前为字符串 warning),表示删除没有 durable;如果 daemon 在补写成功前重启,旧 journal 仍可能恢复该 artifact。 -- `deleteContent: true` 不属于 PR #6259 的 public API。content-retention follow-up 才会定义 content GC 与 warning contract;当前 PR 的显式 DELETE 只处理 metadata tombstone 和 live removal。 +- 显式 DELETE 携带 `deleteContent: true` 时,content GC 只在 live removal 后按当前引用集合 best-effort 执行。若 tombstone 或 content GC 有风险,response 包含 warning;当前实现不会在未确认引用集合时强删其它 session 的 content,因为 contentRef 不跨 session 继承。 建议 warning: @@ -446,7 +446,7 @@ metadata 的 fork amplification 在 V2 中作为有界 trade-off 接受:fork "session_artifacts_persistence" ``` -内容保留拆分 PR 实现可用时,才同时声明: +内容保留实现可用时,同时声明: ```json "session_artifacts_content_retention" @@ -456,7 +456,7 @@ metadata 的 fork amplification 在 V2 中作为有界 trade-off 接受:fork - 行为可用且当前配置启用时才声明对应 feature string。 - chat recording 禁用、metadata persistence 禁用或 writer 不可用时,不声明 `session_artifacts_persistence`。 -- content retention 的显式 workspace `pin/save content`、quota、hash、manifest、session-scoped GC/fsck 都可用时,才声明 `session_artifacts_content_retention`。PR #6259 不声明该 capability;后续拆分 PR 必须保持 feature string 是 daemon build-level 能力。 +- content retention 的显式 workspace `pin/save content`、quota、hash、manifest、session-scoped GC/fsck 都可用时,才声明 `session_artifacts_content_retention`。当前 feature string 是 daemon build-level 能力;单个 session 如果 chat recording writer 不可用,mutation 会降级或返回明确错误。 - 如果 client 需要读取 limits/default retention,应另设计 config endpoint 或 SDK config query;不要把结构化 details 混入现有 string-only capability contract。 ### 6.2 Add artifact @@ -483,8 +483,6 @@ metadata 的 fork amplification 在 V2 中作为有界 trade-off 接受:fork ### 6.3 Pin/save artifact -本节是后续 content-retention PR 的 API 设计,不属于 PR #6259 的 wire contract。PR #6259 只允许 add/list/get/delete metadata artifacts,不暴露 pin/unpin endpoint。 - 新增: ```http @@ -523,8 +521,6 @@ Body: ### 6.4 Unpin -本节是后续 content-retention PR 的 API 设计,不属于 PR #6259 的 wire contract。 - ```http DELETE /session/:id/artifacts/:artifactId/pin Content-Type: application/json @@ -553,11 +549,23 @@ V2 的 DELETE 仍保持 V1 幂等,并采用当前 PR 的 live-first 语义: - 随后 best-effort append `session_artifact_event` remove tombstone;tombstone 成功后,metadata restore 时不再复活。 - tombstone 失败时,返回成功 mutation result 但附带 warning;当前 daemon 生命周期内该 artifact 已被删除,但如果 daemon 在 tombstone 持久化前重启,旧 durable artifact 仍可能恢复。用户或上层 UI 可以在 storage 恢复后重试 DELETE。 - DELETE 对不存在的 artifact 保持幂等成功;如果已有 durable tombstone,重复 DELETE 不需要再写同一 tombstone。 -- PR #6259 的 DELETE 不接受 `deleteContent`,也不触发 daemon-managed content GC;旧 `contentRef` metadata 只在 restore/serialization 时被降级或移除。内容删除与 GC 由后续 content-retention PR 定义。 +- 默认不立即同步删除 managed/pinned content,但会释放该 artifact 对 contentRef 的 live 引用;session-scoped GC 清理当前 session manifest 中已无 live artifact 引用的 daemon-managed content。 + +可选 body 或独立 endpoint 支持内容删除: + +```json +{ + "deleteContent": true +} +``` + +`deleteContent` 表示请求立即删除可删除内容,授权要求见 §7.1 的 delete content 规则:必须是显式 REST/SDK call、有 session mutate 权限、content retention capability 启用,并满足可验证 creator-principal match 或 admin override。共享 contentRef 只能在后台引用集合确认没有其它 session 仍引用后删除。 + +`deleteContent: true` 在当前 PR 中仍只表达“释放当前 artifact 对 content 的引用并触发 best-effort GC”。content bytes 只有在 GC 确认当前 session manifest 中没有 live artifact 引用后才会删除;如果 tombstone 未 durable,API 仍不能声称跨重启删除已经完成,只能通过 warning 表达持久化风险。跨 session 引用确认和强制立即删除是后续增强。 ### 6.6 Mutation responses -Pin、unpin 和 delete 必须使用一致的 mutation response,避免 client 猜测状态。PR #6259 只交付 DELETE response;Pin/Unpin response 是后续 content-retention PR 的 contract。 +Pin、unpin 和 delete 必须使用一致的 mutation response,避免 client 猜测状态: 成功: @@ -577,10 +585,10 @@ Pin、unpin 和 delete 必须使用一致的 mutation response,避免 client } ``` -PR #6259 的 HTTP mapping: +当前 PR 的 HTTP mapping: -- `400 VALIDATION_FAILED`:非法 body、client 请求 `pinned`、artifact 不存在、metadata quota 已满且没有可裁剪 candidate,或 writer 不可用但 mutation 必须严格 durable 完成。 -- `403 FORBIDDEN`:缺少 session mutate 权限。 +- `400 VALIDATION_FAILED`:非法 body、`ttlDays` 与 `mode` 不匹配、client 请求 `pinned`、artifact 不存在、metadata/content quota 已满且没有可裁剪 candidate,或 writer/content storage 不可用但 mutation 必须严格 durable 完成。 +- `403 FORBIDDEN`:缺少 session mutate 权限或 `deleteContent` 授权不足。 - DELETE 保持幂等;不存在的 artifact 返回空 mutation result 而不是错误。 - DELETE tombstone 持久化失败返回 `200 OK` + warning,因为当前 live delete 已生效但跨重启不保证。 @@ -608,7 +616,7 @@ type ArtifactPrincipal = - add ephemeral/restorable:需要 session mutate 权限。 - pin/save content:需要 session mutate 权限,并且必须是显式 REST/SDK call;“用户确认”在 V2 中不表示 agent permission-vote,而是 UI 或 headless client 主动调用 pin/save endpoint。 - delete metadata:需要 session mutate 权限。V1 same-principal delete guard 只能作为 live-process UX guard 和 audit hint;它依赖当前连接上下文,不能跨 daemon restart 证明 artifact owner。restore 后不能从 public `clientId` 伪造 ownership,删除授权退化为 session-level mutate 权限并记录 `ownership_unverified` audit。 -- delete content:后续 content-retention PR 才启用;需要 session mutate 权限、`session_artifacts_content_retention` capability 启用、显式 REST/SDK call,以及当前进程可验证的 creator-principal match 或显式 override/admin policy;background session/hook 不能直接发起 `deleteContent`。restore 后如果没有 durable owner proof,默认只能释放当前 artifact 引用并交给 GC,不能立即强删共享 content。若 contentRef 被多个 session 引用,也只能释放当前 artifact 引用,不能强删共享内容。 +- delete content:需要 session mutate 权限、`session_artifacts_content_retention` capability 启用、显式 REST/SDK call,以及当前进程可验证的 creator-principal match 或显式 override/admin policy;background session/hook 不能直接发起 `deleteContent`。restore 后如果没有 durable owner proof,默认只能释放当前 artifact 引用并交给 GC,不能立即强删共享 content。若 contentRef 被多个 session 引用,也只能释放当前 artifact 引用,不能强删共享内容。 如果未来需要真正的 `session_owner`,必须先设计 durable per-session capability 或 ACL,不能在本 V2 文档中隐式假设。 @@ -713,9 +721,7 @@ restore seed 不能超过 live store 上限;若历史里有效 persisted artif ### 8.2 Content quota -本节是后续 content-retention PR 的实现范围;PR #6259 不引入 content store quota。 - -后续拆分 PR 的建议默认: +当前 PR 的硬编码默认: - 单 artifact:50 MB。 - content store total:256 MB。 @@ -728,7 +734,7 @@ restore seed 不能超过 live store 上限;若历史里有效 persisted artif ### 8.3 GC -本节是后续 content-retention PR 的实现范围。GC 只处理 daemon 管理的 session-scoped managed copy: +当前 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。 @@ -751,7 +757,7 @@ Project-scoped reference rebuild、incomplete-scan tracking、orphan grace perio - explicit `pin/save metadata` 必须等待 journal 落盘。 - explicit `pin/save content` 必须等待 content manifest 和 journal 都落盘。 - explicit DELETE live-first:live store removal must not be blocked by journal failure; response warning tells clients when the tombstone was not durable. -- explicit DELETE with `deleteContent: true` 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. +- explicit DELETE with `deleteContent: true` runs best-effort session-scoped content GC after live removal; content delete warnings must be surfaced. - live cap eviction for durable artifacts writes an `eviction` remove event so restore respects the cap. - reader 容忍半截 JSONL 和 corrupt artifact record。 - tombstone / snapshot 顺序异常时选择不恢复,而不是猜测。 @@ -911,16 +917,16 @@ V2 新增的失败路径必须有 structured logs,格式沿用: - snapshot baseline advance 后 `stickyEphemeralIds` 仍能让隐式/default re-upsert 保持 live-only,并产生 `sticky_override_suppressed` log/metric/warning。 - `stickyEphemeralIds` 达到上限时,unpin-to-ephemeral 返回错误或延后重试,且不会静默丢失旧 sticky override。 - explicit DELETE live-first:live view 立即移除;tombstone 写入失败时 response 带 warning,测试覆盖 live removal 不被 persistence failure 阻断。 -- content-retention follow-up 覆盖 `deleteContent: true` 在 tombstone/content GC 有风险时暴露 `content_delete_preserved` warning。 +- `deleteContent: true` 在 tombstone/content GC 有风险时暴露 `content_delete_preserved` warning。 - durable artifact eviction 写 `eviction` remove event;restore 后不会超过 live cap。 - snapshot baseline advance:periodic snapshot 压缩当前 artifact list,explicit tombstone 在 snapshot 成功后不再无界增长,`stickyEphemeralIds` 保留 sticky state。 - workspace artifact ingest 和 restore 时文件存在/缺失/symlink escape 三种状态。 - workspace root 重定位:相同相对路径存在时恢复为 available;缺失或 layout 不一致时恢复为 missing;不做 path remap。 -- content-retention follow-up 覆盖 pin/save content 时拒绝 symlink、special file、oversized stream、hardlink 异常和 TOCTOU swap。 +- pin/save content 时拒绝 symlink、special file、oversized stream、hardlink 异常和 TOCTOU swap。 - external URL 只恢复 metadata,不发网络请求。 - secret-bearing URL query/fragment 与 metadata key/value 不写入 JSONL。 - published local `file:` 只有 trusted manifest revalidation 通过时恢复。 -- content-retention follow-up 覆盖 stale/tampered `contentRef` 无法绕过 daemon-managed manifest、size 和 hash 校验。 +- stale/tampered `contentRef` 无法绕过 daemon-managed manifest、size 和 hash 校验。 - `managedId` 在 ingest、restore 和 fork remap 时拒绝分隔符、`..`、绝对路径和路径形态;fork 不能盲目复制源 session 的 `managedId`。 - corrupt JSONL record 被跳过且不影响其它 artifacts。 - chat recording / persistence disabled 时不声明或不启用 metadata restore。 @@ -934,12 +940,12 @@ V2 新增的失败路径必须有 structured logs,格式沿用: - restore seed 与 concurrent POST 串行,不丢写、不重复。 - quota 边界:200 条、201 条 prune、pinned metadata、clientRetained/non-clientRetained 两层排序、全部 clientRetained restorable 仍可按确定性规则裁剪。 - clientRetained setter:Add artifact request 和 pin/save request 都能设置 boolean hint;后台自动 ingest 不能伪造用户保留。 -- content-retention follow-up 覆盖 GC:unpin、delete、close、explicit GC endpoint 都只删除当前 session manifest 且当前 live refs 未引用的 content。 -- content-retention follow-up 覆盖 GC concurrency:并发 pin/GC 通过 content-store write queue 和 leased content ids 串行/保护。 -- content-retention follow-up 覆盖 TTL scan:`GET /artifacts` 会降级过期 pinned content 并触发 best-effort session-scoped GC。 +- GC:unpin、delete、close、explicit GC endpoint 都只删除当前 session manifest 且当前 live refs 未引用的 content。 +- GC concurrency:并发 pin/GC 通过 content-store write queue 和 leased content ids 串行/保护。 +- TTL scan:`GET /artifacts` 会降级过期 pinned content 并触发 best-effort session-scoped GC。 - authorization:token-holder/principal 审计路径允许和拒绝情况;V1 live same-principal guard 仅作为 live UX/audit hint,不作为 durable security boundary。 -- content-retention follow-up 覆盖 restored artifact ownership_unverified fallback;deleteContent 在无 durable owner proof 时只释放引用,不立即强删共享 content。 -- content-retention follow-up 覆盖 partial writes:journal 成功但 warning、content manifest 成功但 journal 失败、journal 成功后 restore 能找到 content。 +- restored artifact ownership_unverified fallback;deleteContent 在无 durable owner proof 时只释放引用,不立即强删共享 content。 +- partial writes:journal 成功但 warning、content manifest 成功但 journal 失败、journal 成功后 restore 能找到 content。 - JSONL snapshot baseline advance:threshold 触发、post-snapshot replay 有界、snapshot payload 不再携带已被覆盖的 explicit tombstones、superseded sticky tombstone 允许显式同 id 重新出现、`stickyEphemeralIds` 保留 sticky state;JSONL 文件本身不被 artifact 子系统重写。 - corrupt latest snapshot fallback:回退到较旧 valid snapshot 或一次顺序 artifact replay。 - repin idempotency:已 pinned artifact 的空重复 pin 不刷新内容、不延长 TTL;显式 `mode` / `ttlDays` / `clientRetained` 会按 §6.3 更新对应状态。 @@ -949,9 +955,9 @@ V2 新增的失败路径必须有 structured logs,格式沿用: - SDK 旧 client 忽略 optional fields 后仍能展示 V1 artifacts。 - V2 -> V1 rollback compatibility:旧 daemon 必须能解析或忽略 unknown `system` subtype,不得导致 session load 崩溃;回滚后 artifact persistence 不恢复是可接受降级。如果当前最低支持版本不能保证这一点,V2 writer 必须 capability-gate 到支持 unknown system record 的版本之后。 - rollback preflight:最低支持旧 daemon 版本加载包含 V2 event/snapshot 的 JSONL;如果未来加入 fork marker,再扩展 rollback fixture。 -- content-retention follow-up 覆盖 artifact fsck dry-run:dangling contentRef、orphan content、snapshot invalid 和 repair-safe actions。 +- artifact fsck dry-run:dangling contentRef、orphan content、snapshot invalid 和 repair-safe actions。 - metadata-only fsck dry-run:corrupt record、snapshot fallback、orphan tombstone、restore validation failure。 -- PR #6259 覆盖 metadata API response contract:delete success body、metadata quota validation failure、`remove_not_persisted` / `persistence_unavailable` / `sticky_override_active` warning、current 400/403/200+warning mapping。content-retention follow-up 覆盖 pin/unpin、content quota、`content_delete_preserved` / `content_expired` warning。 +- API response contract:pin/unpin/delete success body、metadata/content quota validation failure、`remove_not_persisted` / `content_delete_preserved` / `persistence_unavailable` / `sticky_override_active` / `content_expired` warning、current 400/403/200+warning mapping。 ## 11. 不建议在 V2 做的事 @@ -979,7 +985,7 @@ V2 建议作为一个完整 design phase 发布,但能力按 capability 暴露 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 的清理流程。 +- daemon-managed content storage 不由旧 daemon 读取;rollback 后 pinned content 只是未被引用的 retained bytes。再次升级到 V2 后可恢复引用;若决定永久回滚,管理员运行 full `fsck --cleanup-orphans` dry-run/confirm 流程清理。 - 如果当前最低支持旧版本不能安全忽略 V2 system records,writer 必须 capability-gate 到安全版本之后,或者在升级前提供 migration guard,阻止写入 V2 records。 - 发布前 CI 必须用最低支持旧 daemon 版本加载包含 `session_artifact_event` 和 `session_artifact_snapshot` 的 JSONL,断言 session load 成功且 unknown subtype 被忽略。V2 writer 首次初始化前也要检查版本/feature gate;失败时拒绝写 V2 records,记录 `v2_writer_version_gate_failed`,保持 V1 行为。如果未来加入 fork marker,再把该 subtype 纳入 rollback fixture。 - rollback 后 client 不能依赖 `session_artifacts_persistence` / `session_artifacts_content_retention`,因为旧 daemon 不声明这些 capability。 diff --git a/integration-tests/cli/qwen-serve-routes.test.ts b/integration-tests/cli/qwen-serve-routes.test.ts index 3de8c3a14f7..f4f66b38f4e 100644 --- a/integration-tests/cli/qwen-serve-routes.test.ts +++ b/integration-tests/cli/qwen-serve-routes.test.ts @@ -249,6 +249,7 @@ describe('qwen serve — capabilities envelope', () => { 'session_events', 'session_artifacts', 'session_artifacts_persistence', + 'session_artifacts_content_retention', 'slow_client_warning', 'typed_event_schema', 'session_set_model', diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index 66a467d5549..1f50cb6ade9 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -61,7 +61,11 @@ import { WS_B, SESS_A, } from './internal/testUtils.js'; -import { SessionArtifactAuthorizationError } from './sessionArtifacts.js'; +import { SessionArtifactContentStore } from './sessionArtifactContentStore.js'; +import { + SessionArtifactAuthorizationError, + SessionArtifactStore, +} from './sessionArtifacts.js'; function deferred(): { promise: Promise; @@ -112,107 +116,962 @@ describe('createAcpSessionBridge', () => { ); }); - it('sanitizes client artifact provenance fields', async () => { + it('sanitizes client artifact provenance fields', async () => { + const bridge = makeBridge({ + channelFactory: async () => makeChannel().channel, + }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + try { + await bridge.addSessionArtifact( + session.sessionId, + { + title: 'Client link', + url: 'https://example.com/client', + toolName: 'artifact', + hookEventName: 'PostToolUse', + toolCallId: 'call-forged', + clientId: 'forged-client', + }, + { clientId: session.clientId }, + ); + + const snapshot = await bridge.getSessionArtifacts(session.sessionId); + expect(snapshot.artifacts).toMatchObject([ + { + title: 'Client link', + source: 'client', + clientId: session.clientId, + }, + ]); + expect(snapshot.artifacts[0]).not.toHaveProperty('toolName'); + expect(snapshot.artifacts[0]).not.toHaveProperty('hookEventName'); + expect(snapshot.artifacts[0]).not.toHaveProperty('toolCallId'); + } finally { + await bridge.shutdown(); + } + }); + + it('keeps client artifacts owned by the issuing client', async () => { + const bridge = makeBridge({ + channelFactory: async () => makeChannel().channel, + }); + const first = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const second = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + try { + const created = await bridge.addSessionArtifact( + first.sessionId, + { + title: 'Client link', + url: 'https://example.com/client', + }, + { clientId: first.clientId }, + ); + 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, + }), + ).rejects.toBeInstanceOf(SessionArtifactAuthorizationError); + await expect( + bridge.removeSessionArtifact(first.sessionId, artifactId), + ).rejects.toBeInstanceOf(SessionArtifactAuthorizationError); + await expect( + bridge.getSessionArtifacts(first.sessionId), + ).resolves.toMatchObject({ + artifacts: [{ id: artifactId, clientId: first.clientId }], + }); + + await expect( + bridge.removeSessionArtifact(first.sessionId, artifactId, { + clientId: first.clientId, + }), + ).resolves.toMatchObject({ + changes: [{ action: 'removed', artifactId, reason: 'explicit' }], + }); + } finally { + await bridge.shutdown(); + } + }); + + it('validates artifact remove options before changing live state', async () => { + const bridge = makeBridge({ + channelFactory: async () => makeChannel().channel, + }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + try { + const created = await bridge.addSessionArtifact( + session.sessionId, + { + title: 'Client link', + url: 'https://example.com/client', + }, + { clientId: session.clientId }, + ); + const artifactId = created.changes[0]!.artifactId; + + await expect( + bridge.removeSessionArtifact( + session.sessionId, + artifactId, + { clientId: session.clientId }, + { deleteContent: 'yes' } as unknown as { deleteContent: boolean }, + ), + ).rejects.toThrow(/deleteContent must be a boolean/); + + await expect( + bridge.getSessionArtifacts(session.sessionId), + ).resolves.toMatchObject({ + artifacts: [{ id: artifactId }], + }); + } finally { + await bridge.shutdown(); + } + }); + + it('rejects invalid client artifact records instead of dropping them', async () => { + const bridge = makeBridge({ + channelFactory: async () => makeChannel().channel, + }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + try { + await expect( + bridge.addSessionArtifact( + session.sessionId, + { + title: 'x'.repeat(201), + url: 'https://example.com/client', + }, + { clientId: session.clientId }, + ), + ).rejects.toThrow(/title/); + } finally { + await bridge.shutdown(); + } + }); + + it('does not prune or GC expired pins during artifact GET', async () => { + const previousQwenHome = process.env['QWEN_HOME']; + const tempHome = await fsp.mkdtemp(path.join(os.tmpdir(), 'qwen-home-')); + const workspace = await fsp.mkdtemp( + path.join(os.tmpdir(), 'qwen-artifact-workspace-'), + ); + process.env['QWEN_HOME'] = tempHome; + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-07-04T00:00:00.000Z')); + const gcSpy = vi.spyOn(SessionArtifactContentStore.prototype, 'gc'); + const bridge = makeBridge({ + boundWorkspace: workspace, + channelFactory: async () => makeChannel().channel, + }); + try { + await fsp.mkdir(path.join(workspace, 'reports'), { recursive: true }); + await fsp.writeFile(path.join(workspace, 'reports', 'report.txt'), 'hi'); + const session = await bridge.spawnOrAttach({ workspaceCwd: workspace }); + const created = await bridge.addSessionArtifact( + session.sessionId, + { + title: 'Report', + workspacePath: 'reports/report.txt', + }, + { clientId: session.clientId }, + ); + const artifactId = created.changes[0]!.artifactId; + await bridge.pinSessionArtifact( + session.sessionId, + artifactId, + { clientId: session.clientId }, + { mode: 'content', ttlDays: 1 }, + ); + gcSpy.mockClear(); + + vi.setSystemTime(new Date('2026-07-06T00:00:00.000Z')); + await expect( + bridge.getSessionArtifacts(session.sessionId), + ).resolves.toMatchObject({ + artifacts: [ + { + id: artifactId, + retention: 'pinned', + }, + ], + }); + expect(gcSpy).not.toHaveBeenCalled(); + } finally { + gcSpy.mockRestore(); + vi.useRealTimers(); + await bridge.shutdown(); + if (previousQwenHome === undefined) { + delete process.env['QWEN_HOME']; + } else { + process.env['QWEN_HOME'] = previousQwenHome; + } + await fsp.rm(tempHome, { recursive: true, force: true }); + await fsp.rm(workspace, { recursive: true, force: true }); + } + }); + + it('prunes expired pins during artifact mutations and reports GC warnings', async () => { + const previousQwenHome = process.env['QWEN_HOME']; + const tempHome = await fsp.mkdtemp(path.join(os.tmpdir(), 'qwen-home-')); + const workspace = await fsp.mkdtemp( + path.join(os.tmpdir(), 'qwen-artifact-workspace-'), + ); + process.env['QWEN_HOME'] = tempHome; + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-07-04T00:00:00.000Z')); + const gcSpy = vi.spyOn(SessionArtifactContentStore.prototype, 'gc'); + const bridge = makeBridge({ + boundWorkspace: workspace, + channelFactory: async () => makeChannel().channel, + }); + try { + await fsp.mkdir(path.join(workspace, 'reports'), { recursive: true }); + await fsp.writeFile(path.join(workspace, 'reports', 'report.txt'), 'hi'); + const session = await bridge.spawnOrAttach({ workspaceCwd: workspace }); + const created = await bridge.addSessionArtifact( + session.sessionId, + { + title: 'Report', + workspacePath: 'reports/report.txt', + }, + { clientId: session.clientId }, + ); + const artifactId = created.changes[0]!.artifactId; + await bridge.pinSessionArtifact( + session.sessionId, + artifactId, + { clientId: session.clientId }, + { mode: 'content', ttlDays: 1 }, + ); + gcSpy.mockClear(); + gcSpy.mockRejectedValueOnce(new Error('disk unavailable')); + + vi.setSystemTime(new Date('2026-07-06T00:00:00.000Z')); + await expect( + bridge.addSessionArtifact( + session.sessionId, + { + title: 'New link', + url: 'https://example.com/new-link', + }, + { clientId: session.clientId }, + ), + ).resolves.toMatchObject({ + warnings: ['artifact content GC failed; stale content retained'], + }); + expect(gcSpy).toHaveBeenCalledTimes(1); + await expect( + bridge.getSessionArtifacts(session.sessionId), + ).resolves.toMatchObject({ + artifacts: expect.arrayContaining([ + expect.objectContaining({ + id: artifactId, + retention: 'restorable', + persistenceWarning: 'content_expired', + }), + ]), + }); + } finally { + gcSpy.mockRestore(); + vi.useRealTimers(); + await bridge.shutdown(); + if (previousQwenHome === undefined) { + delete process.env['QWEN_HOME']; + } else { + process.env['QWEN_HOME'] = previousQwenHome; + } + await fsp.rm(tempHome, { recursive: true, force: true }); + await fsp.rm(workspace, { recursive: true, force: true }); + } + }); + + it('prunes expired pins during remove with deleteContent false without GC', async () => { + const previousQwenHome = process.env['QWEN_HOME']; + const tempHome = await fsp.mkdtemp(path.join(os.tmpdir(), 'qwen-home-')); + const workspace = await fsp.mkdtemp( + path.join(os.tmpdir(), 'qwen-artifact-workspace-'), + ); + process.env['QWEN_HOME'] = tempHome; + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-07-04T00:00:00.000Z')); + const gcSpy = vi.spyOn(SessionArtifactContentStore.prototype, 'gc'); + const bridge = makeBridge({ + boundWorkspace: workspace, + channelFactory: async () => makeChannel().channel, + }); + try { + await fsp.mkdir(path.join(workspace, 'reports'), { recursive: true }); + await fsp.writeFile(path.join(workspace, 'reports', 'report.txt'), 'hi'); + const session = await bridge.spawnOrAttach({ workspaceCwd: workspace }); + const pinned = await bridge.addSessionArtifact( + session.sessionId, + { + title: 'Report', + workspacePath: 'reports/report.txt', + }, + { clientId: session.clientId }, + ); + const pinnedArtifactId = pinned.changes[0]!.artifactId; + const removed = await bridge.addSessionArtifact( + session.sessionId, + { + title: 'Later link', + url: 'https://example.com/later', + }, + { clientId: session.clientId }, + ); + const removedArtifactId = removed.changes[0]!.artifactId; + await bridge.pinSessionArtifact( + session.sessionId, + pinnedArtifactId, + { clientId: session.clientId }, + { mode: 'content', ttlDays: 1 }, + ); + gcSpy.mockClear(); + + vi.setSystemTime(new Date('2026-07-06T00:00:00.000Z')); + await expect( + bridge.removeSessionArtifact( + session.sessionId, + removedArtifactId, + { clientId: session.clientId }, + { deleteContent: false }, + ), + ).resolves.toMatchObject({ + changes: [{ action: 'removed', artifactId: removedArtifactId }], + }); + expect(gcSpy).not.toHaveBeenCalled(); + await expect( + bridge.getSessionArtifacts(session.sessionId), + ).resolves.toMatchObject({ + artifacts: expect.arrayContaining([ + expect.objectContaining({ + id: pinnedArtifactId, + retention: 'restorable', + persistenceWarning: 'content_expired', + }), + ]), + }); + } finally { + gcSpy.mockRestore(); + vi.useRealTimers(); + await bridge.shutdown(); + if (previousQwenHome === undefined) { + delete process.env['QWEN_HOME']; + } else { + process.env['QWEN_HOME'] = previousQwenHome; + } + await fsp.rm(tempHome, { recursive: true, force: true }); + await fsp.rm(workspace, { recursive: true, force: true }); + } + }); + + it('re-reads an artifact after pruning before re-pinning expired content', async () => { + const previousQwenHome = process.env['QWEN_HOME']; + const tempHome = await fsp.mkdtemp(path.join(os.tmpdir(), 'qwen-home-')); + const workspace = await fsp.mkdtemp( + path.join(os.tmpdir(), 'qwen-artifact-workspace-'), + ); + process.env['QWEN_HOME'] = tempHome; + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-07-04T00:00:00.000Z')); + const copySpy = vi.spyOn( + SessionArtifactContentStore.prototype, + 'pinWorkspaceFile', + ); + const bridge = makeBridge({ + boundWorkspace: workspace, + channelFactory: async () => makeChannel().channel, + }); + try { + await fsp.mkdir(path.join(workspace, 'reports'), { recursive: true }); + await fsp.writeFile(path.join(workspace, 'reports', 'report.txt'), 'hi'); + const session = await bridge.spawnOrAttach({ workspaceCwd: workspace }); + const created = await bridge.addSessionArtifact( + session.sessionId, + { + title: 'Report', + workspacePath: 'reports/report.txt', + }, + { clientId: session.clientId }, + ); + const artifactId = created.changes[0]!.artifactId; + await bridge.pinSessionArtifact( + session.sessionId, + artifactId, + { clientId: session.clientId }, + { mode: 'content', ttlDays: 1 }, + ); + copySpy.mockClear(); + + vi.setSystemTime(new Date('2026-07-06T00:00:00.000Z')); + await expect( + bridge.pinSessionArtifact(session.sessionId, artifactId, { + clientId: session.clientId, + }), + ).resolves.toMatchObject({ + changes: [ + { + action: 'updated', + artifactId, + artifact: { + retention: 'pinned', + contentRef: expect.any(Object), + }, + }, + ], + }); + expect(copySpy).toHaveBeenCalledTimes(1); + } finally { + copySpy.mockRestore(); + vi.useRealTimers(); + await bridge.shutdown(); + if (previousQwenHome === undefined) { + delete process.env['QWEN_HOME']; + } else { + process.env['QWEN_HOME'] = previousQwenHome; + } + await fsp.rm(tempHome, { recursive: true, force: true }); + await fsp.rm(workspace, { recursive: true, force: true }); + } + }); + + it('keeps added artifacts committed when expired pin pruning fails', async () => { + const pruneSpy = vi.spyOn( + SessionArtifactStore.prototype, + 'pruneExpiredPins', + ); + pruneSpy.mockRejectedValueOnce(new Error('prune failed')); + const bridge = makeBridge({ + channelFactory: async () => makeChannel().channel, + }); + try { + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const result = await bridge.addSessionArtifact( + session.sessionId, + { + title: 'New link', + url: 'https://example.com/new-link', + }, + { clientId: session.clientId }, + ); + + expect(result).toMatchObject({ + changes: [{ action: 'created' }], + warnings: ['expired_artifact_content_retained'], + }); + await expect( + bridge.getSessionArtifacts(session.sessionId), + ).resolves.toMatchObject({ + artifacts: [expect.objectContaining({ title: 'New link' })], + }); + } finally { + pruneSpy.mockRestore(); + await bridge.shutdown(); + } + }); + + it('keeps pin successful when expired pin pruning fails', async () => { + const bridge = makeBridge({ + channelFactory: async () => makeChannel().channel, + }); + const pruneSpy = vi.spyOn( + SessionArtifactStore.prototype, + 'pruneExpiredPins', + ); + try { + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const created = await bridge.addSessionArtifact( + session.sessionId, + { + title: 'New link', + url: 'https://example.com/new-link', + }, + { clientId: session.clientId }, + ); + const artifactId = created.changes[0]!.artifactId; + pruneSpy.mockRejectedValueOnce(new Error('prune failed')); + + await expect( + bridge.pinSessionArtifact( + session.sessionId, + artifactId, + { clientId: session.clientId }, + { mode: 'metadata' }, + ), + ).resolves.toMatchObject({ + changes: [{ action: 'updated', artifactId }], + warnings: ['expired_artifact_content_retained'], + }); + } finally { + pruneSpy.mockRestore(); + await bridge.shutdown(); + } + }); + + it('keeps unpin successful when expired pin pruning fails', async () => { + const bridge = makeBridge({ + channelFactory: async () => makeChannel().channel, + }); + const pruneSpy = vi.spyOn( + SessionArtifactStore.prototype, + 'pruneExpiredPins', + ); + try { + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const created = await bridge.addSessionArtifact( + session.sessionId, + { + title: 'New link', + url: 'https://example.com/new-link', + }, + { clientId: session.clientId }, + ); + const artifactId = created.changes[0]!.artifactId; + await bridge.pinSessionArtifact( + session.sessionId, + artifactId, + { clientId: session.clientId }, + { mode: 'metadata' }, + ); + pruneSpy.mockRejectedValueOnce(new Error('prune failed')); + + await expect( + bridge.unpinSessionArtifact( + session.sessionId, + artifactId, + { clientId: session.clientId }, + undefined, + ), + ).resolves.toMatchObject({ + changes: [{ action: 'updated', artifactId }], + warnings: ['expired_artifact_content_retained'], + }); + } finally { + pruneSpy.mockRestore(); + await bridge.shutdown(); + } + }); + + it('applies metadata pin options to an already pinned artifact', async () => { + const previousQwenHome = process.env['QWEN_HOME']; + const tempHome = await fsp.mkdtemp(path.join(os.tmpdir(), 'qwen-home-')); + const workspace = await fsp.mkdtemp( + path.join(os.tmpdir(), 'qwen-artifact-workspace-'), + ); + process.env['QWEN_HOME'] = tempHome; + const bridge = makeBridge({ + boundWorkspace: workspace, + channelFactory: async () => makeChannel().channel, + }); + try { + await fsp.mkdir(path.join(workspace, 'reports'), { recursive: true }); + await fsp.writeFile(path.join(workspace, 'reports', 'report.txt'), 'hi'); + const session = await bridge.spawnOrAttach({ workspaceCwd: workspace }); + const created = await bridge.addSessionArtifact( + session.sessionId, + { + title: 'Report', + workspacePath: 'reports/report.txt', + }, + { clientId: session.clientId }, + ); + const artifactId = created.changes[0]!.artifactId; + await bridge.pinSessionArtifact( + session.sessionId, + artifactId, + { clientId: session.clientId }, + { mode: 'content' }, + ); + const gcSpy = vi.spyOn(SessionArtifactContentStore.prototype, 'gc'); + + await expect( + bridge.pinSessionArtifact( + session.sessionId, + artifactId, + { clientId: session.clientId }, + { mode: 'metadata' }, + ), + ).resolves.toMatchObject({ + changes: [ + { + action: 'updated', + artifactId, + artifact: { + retention: 'restorable', + persistenceWarning: 'metadata_only_restore', + }, + }, + ], + }); + expect(gcSpy).toHaveBeenCalledTimes(1); + } finally { + await bridge.shutdown(); + if (previousQwenHome === undefined) { + delete process.env['QWEN_HOME']; + } else { + process.env['QWEN_HOME'] = previousQwenHome; + } + await fsp.rm(tempHome, { recursive: true, force: true }); + await fsp.rm(workspace, { recursive: true, force: true }); + } + }); + + it('checks artifact ownership before copying content on pin', async () => { + const previousQwenHome = process.env['QWEN_HOME']; + const tempHome = await fsp.mkdtemp(path.join(os.tmpdir(), 'qwen-home-')); + const workspace = await fsp.mkdtemp( + path.join(os.tmpdir(), 'qwen-artifact-workspace-'), + ); + process.env['QWEN_HOME'] = tempHome; + const copySpy = vi.spyOn( + SessionArtifactContentStore.prototype, + 'pinWorkspaceFile', + ); + const bridge = makeBridge({ + boundWorkspace: workspace, + channelFactory: async () => makeChannel().channel, + }); + try { + await fsp.mkdir(path.join(workspace, 'reports'), { recursive: true }); + await fsp.writeFile(path.join(workspace, 'reports', 'report.txt'), 'hi'); + const session = await bridge.spawnOrAttach({ workspaceCwd: workspace }); + const created = await bridge.addSessionArtifact( + session.sessionId, + { + title: 'Report', + workspacePath: 'reports/report.txt', + }, + { clientId: session.clientId }, + ); + const artifactId = created.changes[0]!.artifactId; + + await expect( + bridge.pinSessionArtifact(session.sessionId, artifactId, undefined, { + mode: 'content', + }), + ).rejects.toBeInstanceOf(SessionArtifactAuthorizationError); + expect(copySpy).not.toHaveBeenCalled(); + } finally { + copySpy.mockRestore(); + await bridge.shutdown(); + if (previousQwenHome === undefined) { + delete process.env['QWEN_HOME']; + } else { + process.env['QWEN_HOME'] = previousQwenHome; + } + await fsp.rm(tempHome, { recursive: true, force: true }); + await fsp.rm(workspace, { recursive: true, force: true }); + } + }); + + it('refreshes retained content on explicit content re-pin', async () => { + const previousQwenHome = process.env['QWEN_HOME']; + const tempHome = await fsp.mkdtemp(path.join(os.tmpdir(), 'qwen-home-')); + const workspace = await fsp.mkdtemp( + path.join(os.tmpdir(), 'qwen-artifact-workspace-'), + ); + process.env['QWEN_HOME'] = tempHome; + const bridge = makeBridge({ + boundWorkspace: workspace, + channelFactory: async () => makeChannel().channel, + }); + try { + await fsp.mkdir(path.join(workspace, 'reports'), { recursive: true }); + await fsp.writeFile( + path.join(workspace, 'reports', 'report.txt'), + 'first', + ); + const session = await bridge.spawnOrAttach({ workspaceCwd: workspace }); + const created = await bridge.addSessionArtifact( + session.sessionId, + { + title: 'Report', + workspacePath: 'reports/report.txt', + }, + { clientId: session.clientId }, + ); + const artifactId = created.changes[0]!.artifactId; + const firstPin = await bridge.pinSessionArtifact( + session.sessionId, + artifactId, + { clientId: session.clientId }, + { mode: 'content' }, + ); + const firstContentId = + firstPin.changes[0]?.artifact?.contentRef?.contentId; + const gcSpy = vi.spyOn(SessionArtifactContentStore.prototype, 'gc'); + + await fsp.writeFile( + path.join(workspace, 'reports', 'report.txt'), + 'second', + ); + const secondPin = await bridge.pinSessionArtifact( + session.sessionId, + artifactId, + { clientId: session.clientId }, + { mode: 'content' }, + ); + + expect( + secondPin.changes[0]?.artifact?.contentRef?.contentId, + ).toBeTruthy(); + expect(secondPin.changes[0]?.artifact?.contentRef?.contentId).not.toBe( + firstContentId, + ); + expect(gcSpy).toHaveBeenCalledTimes(1); + } finally { + await bridge.shutdown(); + if (previousQwenHome === undefined) { + delete process.env['QWEN_HOME']; + } else { + process.env['QWEN_HOME'] = previousQwenHome; + } + await fsp.rm(tempHome, { recursive: true, force: true }); + await fsp.rm(workspace, { recursive: true, force: true }); + } + }); + + it('leases reused retained content before re-pinning an existing content artifact', async () => { + const previousQwenHome = process.env['QWEN_HOME']; + const tempHome = await fsp.mkdtemp(path.join(os.tmpdir(), 'qwen-home-')); + const workspace = await fsp.mkdtemp( + path.join(os.tmpdir(), 'qwen-artifact-workspace-'), + ); + process.env['QWEN_HOME'] = tempHome; + const bridge = makeBridge({ + boundWorkspace: workspace, + channelFactory: async () => makeChannel().channel, + }); + const leaseSpy = vi.spyOn( + SessionArtifactContentStore.prototype, + 'leaseContentRefs', + ); + const copySpy = vi.spyOn( + SessionArtifactContentStore.prototype, + 'pinWorkspaceFile', + ); + try { + await fsp.mkdir(path.join(workspace, 'reports'), { recursive: true }); + await fsp.writeFile(path.join(workspace, 'reports', 'report.txt'), 'hi'); + const session = await bridge.spawnOrAttach({ workspaceCwd: workspace }); + const created = await bridge.addSessionArtifact( + session.sessionId, + { + title: 'Report', + workspacePath: 'reports/report.txt', + }, + { clientId: session.clientId }, + ); + const artifactId = created.changes[0]!.artifactId; + const firstPin = await bridge.pinSessionArtifact( + session.sessionId, + artifactId, + { clientId: session.clientId }, + { mode: 'content' }, + ); + const contentRef = firstPin.changes[0]?.artifact?.contentRef; + expect(contentRef).toBeDefined(); + if (!contentRef) { + throw new Error('expected content ref'); + } + leaseSpy.mockClear(); + copySpy.mockClear(); + + await expect( + bridge.pinSessionArtifact(session.sessionId, artifactId, { + clientId: session.clientId, + }), + ).resolves.toMatchObject({ changes: [] }); + + expect(copySpy).not.toHaveBeenCalled(); + expect(leaseSpy).toHaveBeenCalledTimes(1); + expect(leaseSpy.mock.calls[0]?.[0]).toEqual([contentRef]); + } finally { + leaseSpy.mockRestore(); + copySpy.mockRestore(); + await bridge.shutdown(); + if (previousQwenHome === undefined) { + delete process.env['QWEN_HOME']; + } else { + process.env['QWEN_HOME'] = previousQwenHome; + } + await fsp.rm(tempHome, { recursive: true, force: true }); + await fsp.rm(workspace, { recursive: true, force: true }); + } + }); + + it('runs content GC when killing a session', async () => { + const previousQwenHome = process.env['QWEN_HOME']; + const tempHome = await fsp.mkdtemp(path.join(os.tmpdir(), 'qwen-home-')); + const workspace = await fsp.mkdtemp( + path.join(os.tmpdir(), 'qwen-artifact-workspace-'), + ); + process.env['QWEN_HOME'] = tempHome; const bridge = makeBridge({ + boundWorkspace: workspace, channelFactory: async () => makeChannel().channel, }); - const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const gcSpy = vi.spyOn(SessionArtifactContentStore.prototype, 'gc'); try { - await bridge.addSessionArtifact( + await fsp.mkdir(path.join(workspace, 'reports'), { recursive: true }); + await fsp.writeFile(path.join(workspace, 'reports', 'report.txt'), 'hi'); + const session = await bridge.spawnOrAttach({ workspaceCwd: workspace }); + const created = await bridge.addSessionArtifact( session.sessionId, { - title: 'Client link', - url: 'https://example.com/client', - toolName: 'artifact', - hookEventName: 'PostToolUse', - toolCallId: 'call-forged', - clientId: 'forged-client', + title: 'Report', + workspacePath: 'reports/report.txt', }, { clientId: session.clientId }, ); + const artifactId = created.changes[0]!.artifactId; + const firstPin = await bridge.pinSessionArtifact( + session.sessionId, + artifactId, + { clientId: session.clientId }, + { mode: 'content' }, + ); + const contentRef = firstPin.changes[0]?.artifact?.contentRef; + expect(contentRef).toBeDefined(); + if (!contentRef) { + throw new Error('expected content ref'); + } + gcSpy.mockClear(); - const snapshot = await bridge.getSessionArtifacts(session.sessionId); - expect(snapshot.artifacts).toMatchObject([ - { - title: 'Client link', - source: 'client', - clientId: session.clientId, - }, - ]); - expect(snapshot.artifacts[0]).not.toHaveProperty('toolName'); - expect(snapshot.artifacts[0]).not.toHaveProperty('hookEventName'); - expect(snapshot.artifacts[0]).not.toHaveProperty('toolCallId'); + await bridge.killSession(session.sessionId); + + expect(gcSpy).toHaveBeenCalledTimes(1); + expect(gcSpy.mock.calls[0]?.[0]).toBe(session.sessionId); + expect(gcSpy.mock.calls[0]?.[1]).toEqual(new Set([contentRef.contentId])); } finally { + gcSpy.mockRestore(); await bridge.shutdown(); + if (previousQwenHome === undefined) { + delete process.env['QWEN_HOME']; + } else { + process.env['QWEN_HOME'] = previousQwenHome; + } + await fsp.rm(tempHome, { recursive: true, force: true }); + await fsp.rm(workspace, { recursive: true, force: true }); } }); - it('keeps client artifacts owned by the issuing client', async () => { + it('runs content GC when closing a session', async () => { + const previousQwenHome = process.env['QWEN_HOME']; + const tempHome = await fsp.mkdtemp(path.join(os.tmpdir(), 'qwen-home-')); + const workspace = await fsp.mkdtemp( + path.join(os.tmpdir(), 'qwen-artifact-workspace-'), + ); + process.env['QWEN_HOME'] = tempHome; const bridge = makeBridge({ + boundWorkspace: workspace, channelFactory: async () => makeChannel().channel, }); - const first = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); - const second = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const gcSpy = vi.spyOn(SessionArtifactContentStore.prototype, 'gc'); try { + await fsp.mkdir(path.join(workspace, 'reports'), { recursive: true }); + await fsp.writeFile(path.join(workspace, 'reports', 'report.txt'), 'hi'); + const session = await bridge.spawnOrAttach({ workspaceCwd: workspace }); const created = await bridge.addSessionArtifact( - first.sessionId, + session.sessionId, { - title: 'Client link', - url: 'https://example.com/client', + title: 'Report', + workspacePath: 'reports/report.txt', }, - { clientId: first.clientId }, + { clientId: session.clientId }, ); const artifactId = created.changes[0]!.artifactId; + const firstPin = await bridge.pinSessionArtifact( + session.sessionId, + artifactId, + { clientId: session.clientId }, + { mode: 'content' }, + ); + const contentRef = firstPin.changes[0]?.artifact?.contentRef; + expect(contentRef).toBeDefined(); + if (!contentRef) { + throw new Error('expected content ref'); + } + gcSpy.mockClear(); - await expect( - bridge.getSessionArtifacts(first.sessionId, { - clientId: 'forged-client', - }), - ).rejects.toBeInstanceOf(InvalidClientIdError); - await expect( - bridge.removeSessionArtifact(first.sessionId, artifactId, { - clientId: second.clientId, - }), - ).rejects.toBeInstanceOf(SessionArtifactAuthorizationError); - await expect( - bridge.removeSessionArtifact(first.sessionId, artifactId), - ).rejects.toBeInstanceOf(SessionArtifactAuthorizationError); - await expect( - bridge.getSessionArtifacts(first.sessionId), - ).resolves.toMatchObject({ - artifacts: [{ id: artifactId, clientId: first.clientId }], - }); + await bridge.closeSession(session.sessionId); - await expect( - bridge.removeSessionArtifact(first.sessionId, artifactId, { - clientId: first.clientId, - }), - ).resolves.toMatchObject({ - changes: [{ action: 'removed', artifactId, reason: 'explicit' }], - }); + expect(gcSpy).toHaveBeenCalledTimes(1); + expect(gcSpy.mock.calls[0]?.[0]).toBe(session.sessionId); + expect(gcSpy.mock.calls[0]?.[1]).toEqual(new Set([contentRef.contentId])); } finally { + gcSpy.mockRestore(); await bridge.shutdown(); + if (previousQwenHome === undefined) { + delete process.env['QWEN_HOME']; + } else { + process.env['QWEN_HOME'] = previousQwenHome; + } + await fsp.rm(tempHome, { recursive: true, force: true }); + await fsp.rm(workspace, { recursive: true, force: true }); } }); - it('rejects invalid client artifact records instead of dropping them', async () => { + it('keeps unpin successful when best-effort content GC fails', async () => { + const previousQwenHome = process.env['QWEN_HOME']; + const tempHome = await fsp.mkdtemp(path.join(os.tmpdir(), 'qwen-home-')); + const workspace = await fsp.mkdtemp( + path.join(os.tmpdir(), 'qwen-artifact-workspace-'), + ); + process.env['QWEN_HOME'] = tempHome; const bridge = makeBridge({ + boundWorkspace: workspace, channelFactory: async () => makeChannel().channel, }); - const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const gcSpy = vi.spyOn(SessionArtifactContentStore.prototype, 'gc'); try { + await fsp.mkdir(path.join(workspace, 'reports'), { recursive: true }); + await fsp.writeFile(path.join(workspace, 'reports', 'report.txt'), 'hi'); + const session = await bridge.spawnOrAttach({ workspaceCwd: workspace }); + const created = await bridge.addSessionArtifact( + session.sessionId, + { + title: 'Report', + workspacePath: 'reports/report.txt', + }, + { clientId: session.clientId }, + ); + const artifactId = created.changes[0]!.artifactId; + await bridge.pinSessionArtifact( + session.sessionId, + artifactId, + { clientId: session.clientId }, + { mode: 'content' }, + ); + gcSpy.mockClear(); + gcSpy.mockRejectedValueOnce(new Error('disk unavailable')); + await expect( - bridge.addSessionArtifact( - session.sessionId, - { - title: 'x'.repeat(201), - url: 'https://example.com/client', - }, - { clientId: session.clientId }, - ), - ).rejects.toThrow(/title/); + bridge.unpinSessionArtifact(session.sessionId, artifactId, { + clientId: session.clientId, + }), + ).resolves.toMatchObject({ + changes: [{ action: 'updated', artifactId }], + warnings: ['content_delete_preserved'], + }); + expect(gcSpy).toHaveBeenCalledTimes(1); } finally { + gcSpy.mockRestore(); await bridge.shutdown(); + if (previousQwenHome === undefined) { + delete process.env['QWEN_HOME']; + } else { + process.env['QWEN_HOME'] = previousQwenHome; + } + await fsp.rm(tempHome, { recursive: true, force: true }); + await fsp.rm(workspace, { recursive: true, force: true }); } }); @@ -1327,6 +2186,199 @@ describe('createAcpSessionBridge', () => { await bridge.shutdown(); }); + it('does not run broad content GC for restore metadata warnings', async () => { + const sessionId = 'persisted-artifact-warning'; + const artifactUrl = 'https://example.com/restored-warning'; + const gcSpy = vi.spyOn(SessionArtifactContentStore.prototype, 'gc'); + const bridge = makeBridge({ + channelFactory: async () => + makeChannel({ + loadSessionImpl: () => + ({ + artifactSnapshot: { + v: SESSION_ARTIFACT_PERSISTENCE_VERSION, + sessionId, + sequence: 1, + artifacts: [ + { + id: 'mismatched-artifact-id', + kind: 'link', + storage: 'external_url', + source: 'client', + status: 'available', + title: 'Restored link', + url: artifactUrl, + retention: 'restorable', + clientRetained: true, + createdAt: '2026-07-04T00:00:00.000Z', + updatedAt: '2026-07-04T00:00:00.000Z', + }, + ], + warnings: [], + }, + }) as LoadSessionResponse, + }).channel, + }); + + try { + await bridge.loadSession({ + sessionId, + workspaceCwd: WS_A, + }); + + expect(gcSpy).not.toHaveBeenCalled(); + } finally { + gcSpy.mockRestore(); + await bridge.shutdown(); + } + }); + + it('runs content GC after restore drops invalid content refs', async () => { + const sessionId = 'persisted-artifact-content-missing'; + const artifactUrl = 'https://example.com/restored-content'; + const artifactId = stableSessionArtifactId(sessionId, `url:${artifactUrl}`); + const gcSpy = vi.spyOn(SessionArtifactContentStore.prototype, 'gc'); + const bridge = makeBridge({ + channelFactory: async () => + makeChannel({ + loadSessionImpl: () => + ({ + artifactSnapshot: { + v: SESSION_ARTIFACT_PERSISTENCE_VERSION, + sessionId, + sequence: 1, + artifacts: [ + { + id: artifactId, + kind: 'link', + storage: 'external_url', + source: 'client', + status: 'available', + title: 'Restored link', + url: artifactUrl, + retention: 'pinned', + clientRetained: true, + contentRef: { + kind: 'managed_copy', + contentId: `${'0'.repeat(64)}-${'1'.repeat(16)}`, + sha256: '0'.repeat(64), + sizeBytes: 1, + }, + createdAt: '2026-07-04T00:00:00.000Z', + updatedAt: '2026-07-04T00:00:00.000Z', + }, + ], + warnings: [], + }, + }) as LoadSessionResponse, + }).channel, + }); + + try { + const loaded = await bridge.loadSession({ + sessionId, + workspaceCwd: WS_A, + }); + + expect(gcSpy).toHaveBeenCalledTimes(1); + expect(gcSpy.mock.calls[0]?.[0]).toBe(loaded.sessionId); + await expect( + bridge.getSessionArtifacts(loaded.sessionId), + ).resolves.toMatchObject({ + artifacts: [ + { + id: artifactId, + retention: 'restorable', + persistenceWarning: 'content_missing', + }, + ], + }); + } finally { + gcSpy.mockRestore(); + await bridge.shutdown(); + } + }); + + it('runs content GC when metadata pin drops restored retained content', async () => { + const sessionId = 'persisted-artifact-content-metadata-pin'; + const artifactUrl = 'https://example.com/restored-content-metadata'; + const artifactId = stableSessionArtifactId(sessionId, `url:${artifactUrl}`); + const contentRef = { + kind: 'managed_copy' as const, + contentId: `${'2'.repeat(64)}-${'3'.repeat(16)}`, + sha256: '2'.repeat(64), + sizeBytes: 1, + }; + const verifySpy = vi + .spyOn(SessionArtifactContentStore.prototype, 'verifyContentRef') + .mockResolvedValue(undefined); + const gcSpy = vi.spyOn(SessionArtifactContentStore.prototype, 'gc'); + const bridge = makeBridge({ + channelFactory: async () => + makeChannel({ + loadSessionImpl: () => + ({ + artifactSnapshot: { + v: SESSION_ARTIFACT_PERSISTENCE_VERSION, + sessionId, + sequence: 1, + artifacts: [ + { + id: artifactId, + kind: 'link', + storage: 'external_url', + source: 'client', + status: 'available', + title: 'Restored link', + url: artifactUrl, + retention: 'pinned', + clientRetained: true, + contentRef, + createdAt: '2026-07-04T00:00:00.000Z', + updatedAt: '2026-07-04T00:00:00.000Z', + }, + ], + warnings: [], + }, + }) as LoadSessionResponse, + }).channel, + }); + + try { + const loaded = await bridge.loadSession({ + sessionId, + workspaceCwd: WS_A, + }); + gcSpy.mockClear(); + + await expect( + bridge.pinSessionArtifact( + loaded.sessionId, + artifactId, + { clientId: loaded.clientId }, + { mode: 'metadata' }, + ), + ).resolves.toMatchObject({ + changes: [ + { + artifactId, + artifact: { + retention: 'restorable', + persistenceWarning: 'metadata_only_restore', + }, + }, + ], + }); + expect(gcSpy).toHaveBeenCalledTimes(1); + expect(gcSpy.mock.calls[0]?.[0]).toBe(loaded.sessionId); + expect(gcSpy.mock.calls[0]?.[1]).toEqual(new Set()); + } finally { + verifySpy.mockRestore(); + gcSpy.mockRestore(); + await bridge.shutdown(); + } + }); + it('keeps live artifacts when rewind returns no artifact snapshot', async () => { const bridge = makeBridge({ channelFactory: async () => @@ -1381,6 +2433,7 @@ describe('createAcpSessionBridge', () => { `url:${rewoundUrl}`, ); const persistedSnapshots: unknown[] = []; + const gcSpy = vi.spyOn(SessionArtifactContentStore.prototype, 'gc'); const bridge = makeBridge({ channelFactory: async () => makeChannel({ @@ -1487,7 +2540,10 @@ describe('createAcpSessionBridge', () => { ], }), ]); + expect(gcSpy).toHaveBeenCalledTimes(1); + expect(gcSpy.mock.calls[0]?.[0]).toBe(session.sessionId); + gcSpy.mockRestore(); await bridge.shutdown(); }); @@ -7206,6 +8262,11 @@ describe('createAcpSessionBridge', () => { { clientId: 'client-not-issued' }, ), ).rejects.toBeInstanceOf(InvalidClientIdError); + await expect( + bridge.fsckSessionArtifacts(session.sessionId, { + clientId: 'client-not-issued', + }), + ).rejects.toBeInstanceOf(InvalidClientIdError); await bridge.shutdown(); }); diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index 3b0760f5c07..a3dbb968030 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -20,6 +20,7 @@ import type { import type { ApprovalMode, RebuiltSessionArtifactSnapshot, + SessionArtifactContentRef, } from '@qwen-code/qwen-code-core'; import { DAEMON_TRACEPARENT_META_KEY, @@ -103,6 +104,9 @@ import type { BridgeWorkspaceMemoryForgetMatch, BridgeWorkspaceMemoryRememberRequest, BridgeWorkspaceMemoryRememberResult, + SessionArtifactPinRequest, + SessionArtifactRemoveRequest, + SessionArtifactUnpinRequest, } from './bridgeTypes.js'; import type { BridgeOptions, BridgeTelemetry } from './bridgeOptions.js'; import { MCP_RESTART_SERVER_DEADLINE_MS } from './mcpTimeouts.js'; @@ -118,12 +122,17 @@ import { import { PermissionForbiddenError } from './bridgeErrors.js'; import { SessionArtifactStore, + SessionArtifactValidationError, publicArtifactsEqual, type DaemonSessionArtifact, type SessionArtifactChange, type SessionArtifactInput, type SessionArtifactMutationResult, } from './sessionArtifacts.js'; +import { + SessionArtifactContentStore, + type SessionArtifactGcResult, +} from './sessionArtifactContentStore.js'; const NOOP_BRIDGE_TELEMETRY: BridgeTelemetry = { captureContext: () => undefined, @@ -981,6 +990,8 @@ function extractPromptText( const DEFAULT_INIT_TIMEOUT_MS = 10_000; const PERSIST_TIMEOUT_MS = 5_000; +const ARTIFACT_TTL_DAY_MS = 24 * 60 * 60 * 1000; +const ARTIFACT_MAX_TTL_DAYS = 365; const MCP_RESTART_TIMEOUT_MS = 300_000; const WORKSPACE_MEMORY_REMEMBER_TIMEOUT_MS = 300_000; const MCP_OAUTH_TIMEOUT_MS = 600_000; @@ -1395,6 +1406,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { // daemon. Cleared in the `finally` of the creator. let inFlightChannelSpawn: Promise | undefined; const byId = new Map(); + const artifactContentStore = new SessionArtifactContentStore(); const toSessionSummary = (entry: SessionEntry): BridgeSessionSummary => ({ sessionId: entry.sessionId, workspaceCwd: entry.workspaceCwd, @@ -2692,6 +2704,168 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { return input; }; + const artifactExpiresAt = ( + options: SessionArtifactPinRequest | undefined, + mode: 'metadata' | 'content', + ): string | undefined => { + if (options?.ttlDays === undefined) return undefined; + if (mode !== 'content') { + throw new SessionArtifactValidationError( + 'ttlDays is only valid with content pinning', + 'ttlDays', + ); + } + if (!Number.isSafeInteger(options.ttlDays) || options.ttlDays <= 0) { + throw new SessionArtifactValidationError( + 'ttlDays must be a positive safe integer', + 'ttlDays', + ); + } + if (options.ttlDays > ARTIFACT_MAX_TTL_DAYS) { + throw new SessionArtifactValidationError( + `ttlDays must be at most ${ARTIFACT_MAX_TTL_DAYS}`, + 'ttlDays', + ); + } + const expiresAtMs = Date.now() + options.ttlDays * ARTIFACT_TTL_DAY_MS; + if (!Number.isSafeInteger(expiresAtMs)) { + throw new SessionArtifactValidationError( + 'ttlDays is too large', + 'ttlDays', + ); + } + return new Date(expiresAtMs).toISOString(); + }; + + const artifactPinMode = ( + options: SessionArtifactPinRequest | undefined, + ): 'metadata' | 'content' => { + const mode = options?.mode ?? 'content'; + if (mode !== 'metadata' && mode !== 'content') { + throw new SessionArtifactValidationError( + 'mode must be "metadata" or "content"', + 'mode', + ); + } + return mode; + }; + + const artifactRemoveOptions = ( + options: SessionArtifactRemoveRequest | undefined, + ): SessionArtifactRemoveRequest => { + if (options?.deleteContent === undefined) return {}; + if (typeof options.deleteContent !== 'boolean') { + throw new SessionArtifactValidationError( + 'deleteContent must be a boolean', + 'deleteContent', + ); + } + return { deleteContent: options.deleteContent }; + }; + + const artifactClientRetained = ( + options: SessionArtifactPinRequest | undefined, + ): boolean | undefined => { + if (options?.clientRetained === undefined) return undefined; + if (typeof options.clientRetained !== 'boolean') { + throw new SessionArtifactValidationError( + 'clientRetained must be a boolean', + 'clientRetained', + ); + } + return options.clientRetained; + }; + + const artifactUnpinOptions = ( + options: SessionArtifactUnpinRequest | undefined, + ): SessionArtifactUnpinRequest => { + if (options?.retention === undefined) return {}; + if ( + options.retention !== 'ephemeral' && + options.retention !== 'restorable' + ) { + throw new SessionArtifactValidationError( + 'retention must be "ephemeral" or "restorable"', + 'retention', + ); + } + return { retention: options.retention }; + }; + + const gcArtifactContent = async ( + entry: SessionEntry, + ): Promise => + entry.artifacts.withContentRefsLocked(async (refs) => { + const releaseRefs = artifactContentStore.leaseContentRefs(refs); + try { + return await artifactContentStore.gc( + entry.sessionId, + new Set(refs.map((ref) => ref.contentId)), + ); + } finally { + releaseRefs(); + } + }); + + const pruneExpiredArtifactPins = async ( + entry: SessionEntry, + originatorClientId?: string, + options: { deleteContent?: boolean } = {}, + ): Promise<{ warnings: string[]; gcRan: boolean }> => { + const pruned = await entry.artifacts.pruneExpiredPins(); + const warnings = [...(pruned.warnings ?? [])]; + let gcRan = false; + if (pruned.changes.length > 0) { + publishArtifactChanges(entry, pruned.changes, originatorClientId); + } + if (pruned.changes.length > 0 && options.deleteContent !== false) { + try { + await gcArtifactContent(entry); + gcRan = true; + } catch (error) { + warnings.push('artifact content GC failed; stale content retained'); + writeStderrLine( + `[artifacts] session=${entry.sessionId} action=ttl_prune_gc_failed reason=${JSON.stringify( + error instanceof Error ? error.message : String(error), + )}`, + ); + } + } + return { warnings, gcRan }; + }; + + const pruneExpiredArtifactPinsBestEffort = async ( + entry: SessionEntry, + originatorClientId: string | undefined, + action: string, + options: { deleteContent?: boolean } = {}, + ): Promise<{ warnings: string[]; gcRan: boolean }> => { + try { + return await pruneExpiredArtifactPins(entry, originatorClientId, options); + } catch (error) { + writeStderrLine( + `[artifacts] session=${entry.sessionId} action=${action} reason=${JSON.stringify( + error instanceof Error ? error.message : String(error), + )}`, + ); + return { + warnings: ['expired_artifact_content_retained'], + gcRan: false, + }; + } + }; + + const hasDroppedContentRefs = (artifacts: readonly DaemonSessionArtifact[]) => + artifacts.some((artifact) => + [ + 'content_missing', + 'content_expired', + 'content_hash_mismatch', + 'restore_validation_failed', + 'sticky_override_active', + ].includes(artifact.persistenceWarning ?? ''), + ); + function createSessionArtifactPersistence( connection: ClientSideConnection, sessionId: string, @@ -3109,6 +3283,16 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { seedSnapshotCaches(entry, state); const artifactRestoreWarnings = await entry.artifacts.restore( restoredArtifactSnapshotFromState(state), + { + verifyContentRef: (artifact) => + artifact.contentRef + ? artifactContentStore.verifyContentRef( + entry.sessionId, + artifact.id, + artifact.contentRef, + ) + : Promise.resolve(undefined), + }, ); for (const warning of artifactRestoreWarnings) { writeStderrLine( @@ -3117,6 +3301,16 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { )}`, ); } + const restoredArtifacts = await entry.artifacts.list(); + if (hasDroppedContentRefs(restoredArtifacts.artifacts)) { + await gcArtifactContent(entry).catch((error) => { + writeStderrLine( + `qwen serve: session artifact GC failed during restore for ${JSON.stringify( + req.sessionId, + )}: ${error instanceof Error ? error.message : String(error)}`, + ); + }); + } if (replayUpdates.length > 0) { await ci.client.seedSessionUpdates(entry, replayUpdates); ci.client.drainEarlyEvents(entry.sessionId, entry); @@ -3262,6 +3456,15 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { // from the (now-defunct) child can't seed the early-event buffer // and leak into a future load/resume of the same persisted id. ci?.client.markSessionClosed(sessionId); + try { + await gcArtifactContent(entry); + } catch (error) { + writeStderrLine( + `qwen serve: session artifact GC failed during close for ${JSON.stringify( + sessionId, + )}: ${error instanceof Error ? error.message : String(error)}`, + ); + } try { entry.events.publish({ type: 'session_closed', @@ -4565,22 +4768,264 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { }); publishArtifactChanges(entry, result.changes, clientId); const warnings = [...(result.warnings ?? [])]; + const pruneResult = await pruneExpiredArtifactPinsBestEffort( + entry, + clientId, + 'add_prune_failed', + ); + warnings.push(...pruneResult.warnings); return warnings.length > 0 ? { ...result, warnings } : result; }, - async removeSessionArtifact(sessionId, artifactId, context) { + async removeSessionArtifact(sessionId, artifactId, context, options) { const entry = byId.get(sessionId); if (!entry) throw new SessionNotFoundError(sessionId); const clientId = resolveTrustedClientId(entry, context?.clientId); - if (!(await entry.artifacts.get(artifactId))) { + const removeOptions = artifactRemoveOptions(options); + const artifact = await entry.artifacts.get(artifactId); + if (!artifact) { return { v: 1, sessionId, changes: [] }; } const result = await entry.artifacts.remove(artifactId, { clientId }); publishArtifactChanges(entry, result.changes, clientId); const warnings = [...(result.warnings ?? [])]; + if (result.changes.length > 0) { + const pruneResult = await pruneExpiredArtifactPinsBestEffort( + entry, + clientId, + 'remove_prune_failed', + { deleteContent: removeOptions.deleteContent }, + ); + warnings.push(...pruneResult.warnings); + if (removeOptions.deleteContent !== false && !pruneResult.gcRan) { + try { + await gcArtifactContent(entry); + } catch (error) { + if (removeOptions.deleteContent === true || artifact.contentRef) { + warnings.push('content_delete_preserved'); + } + writeStderrLine( + `[artifacts] session=${entry.sessionId} action=remove_gc_failed reason=${JSON.stringify( + error instanceof Error ? error.message : String(error), + )}`, + ); + } + } + } + return warnings.length > 0 ? { ...result, warnings } : result; + }, + + async pinSessionArtifact(sessionId, artifactId, context, options) { + const entry = byId.get(sessionId); + if (!entry) throw new SessionNotFoundError(sessionId); + const clientId = resolveTrustedClientId(entry, context?.clientId); + const requestOptions = + options !== undefined && Object.keys(options).length > 0 + ? options + : undefined; + const mode = artifactPinMode(requestOptions); + const clientRetained = artifactClientRetained(requestOptions); + const expiresAt = artifactExpiresAt(requestOptions, mode); + const currentArtifact = await entry.artifacts.getForPin(artifactId, { + clientId, + }); + if (!currentArtifact) { + return { v: 1, sessionId, changes: [] }; + } + const pruneResult = await pruneExpiredArtifactPinsBestEffort( + entry, + clientId, + 'pin_prune_failed', + ); + const pruneWarnings = pruneResult.warnings; + const prunedArtifact = await entry.artifacts.getForPin(artifactId, { + clientId, + }); + if (!prunedArtifact) { + const result: SessionArtifactMutationResult = { + v: 1, + sessionId, + changes: [], + }; + return pruneWarnings.length > 0 + ? { ...result, warnings: pruneWarnings } + : result; + } + const refreshPinnedContent = + prunedArtifact.retention === 'pinned' && + requestOptions?.mode === 'content'; + const reusableContentRef = + mode === 'content' && !refreshPinnedContent + ? prunedArtifact.contentRef + : undefined; + const releaseReusableContentRef = reusableContentRef + ? artifactContentStore.leaseContentRefs([reusableContentRef]) + : undefined; + try { + if (prunedArtifact.retention === 'pinned' && !refreshPinnedContent) { + const result = + requestOptions === undefined + ? await entry.artifacts.pin(artifactId, { clientId }) + : await entry.artifacts.pin(artifactId, { + retention: mode === 'metadata' ? 'restorable' : 'pinned', + ...(expiresAt !== undefined ? { expiresAt } : {}), + ...(clientRetained !== undefined ? { clientRetained } : {}), + clientId, + }); + const warnings = [...pruneWarnings, ...(result.warnings ?? [])]; + if ( + requestOptions?.mode === 'metadata' && + result.changes.length > 0 + ) { + try { + await gcArtifactContent(entry); + } catch (error) { + warnings.push('content_delete_preserved'); + writeStderrLine( + `[artifacts] session=${entry.sessionId} action=pin_metadata_gc_failed reason=${JSON.stringify( + error instanceof Error ? error.message : String(error), + )}`, + ); + } + } + publishArtifactChanges(entry, result.changes, clientId); + return warnings.length > 0 ? { ...result, warnings } : result; + } + let contentRef: SessionArtifactContentRef | undefined; + let copiedContentRef = false; + try { + if (mode === 'content') { + if (reusableContentRef) { + contentRef = reusableContentRef; + } else { + contentRef = await artifactContentStore.pinWorkspaceFile( + sessionId, + prunedArtifact, + entry.workspaceCwd, + ); + copiedContentRef = contentRef !== undefined; + } + } + if (mode === 'content' && !contentRef) { + throw new SessionArtifactValidationError( + 'artifact content is not available for retention', + 'artifactId', + ); + } + const result = await entry.artifacts.pin(artifactId, { + retention: mode === 'metadata' ? 'restorable' : 'pinned', + ...(contentRef ? { contentRef } : {}), + ...(expiresAt !== undefined ? { expiresAt } : {}), + ...(clientRetained !== undefined ? { clientRetained } : {}), + clientId, + }); + const warnings = [...pruneWarnings, ...(result.warnings ?? [])]; + if (mode === 'metadata' && result.changes.length > 0) { + try { + await gcArtifactContent(entry); + } catch (error) { + warnings.push('content_delete_preserved'); + writeStderrLine( + `[artifacts] session=${entry.sessionId} action=pin_metadata_gc_failed reason=${JSON.stringify( + error instanceof Error ? error.message : String(error), + )}`, + ); + } + } + if (copiedContentRef && contentRef) { + artifactContentStore.releaseContentRef(contentRef); + await gcArtifactContent(entry).catch((error: unknown) => { + writeStderrLine( + `[artifacts] session=${entry.sessionId} action=pin_content_gc_failed reason=${JSON.stringify( + error instanceof Error ? error.message : String(error), + )}`, + ); + }); + } + if (contentRef && result.changes.length === 0) { + if (!(await entry.artifacts.get(artifactId))) { + throw new SessionArtifactValidationError( + 'artifact no longer exists', + 'artifactId', + ); + } + } + publishArtifactChanges(entry, result.changes, clientId); + return warnings.length > 0 ? { ...result, warnings } : result; + } catch (error) { + if (copiedContentRef && contentRef) { + artifactContentStore.releaseContentRef(contentRef); + await gcArtifactContent(entry).catch((error: unknown) => { + writeStderrLine( + `[artifacts] session=${entry.sessionId} action=pin_content_rollback_gc_failed reason=${JSON.stringify( + error instanceof Error ? error.message : String(error), + )}`, + ); + }); + } + throw error; + } + } finally { + releaseReusableContentRef?.(); + } + }, + + async unpinSessionArtifact(sessionId, artifactId, context, options) { + const entry = byId.get(sessionId); + if (!entry) throw new SessionNotFoundError(sessionId); + const clientId = resolveTrustedClientId(entry, context?.clientId); + const unpinOptions = artifactUnpinOptions(options); + if (!(await entry.artifacts.get(artifactId))) { + return { v: 1, sessionId, changes: [] }; + } + const pruneResult = await pruneExpiredArtifactPinsBestEffort( + entry, + clientId, + 'unpin_prune_failed', + ); + const pruneWarnings = pruneResult.warnings; + const result = await entry.artifacts.unpin(artifactId, { + ...unpinOptions, + clientId, + }); + const warnings = [...pruneWarnings, ...(result.warnings ?? [])]; + if (result.changes.length > 0) { + try { + await gcArtifactContent(entry); + } catch (error) { + warnings.push('content_delete_preserved'); + writeStderrLine( + `[artifacts] session=${entry.sessionId} action=unpin_gc_failed reason=${JSON.stringify( + error instanceof Error ? error.message : String(error), + )}`, + ); + } + } + publishArtifactChanges(entry, result.changes, clientId); return warnings.length > 0 ? { ...result, warnings } : result; }, + async fsckSessionArtifacts(sessionId, context) { + const entry = byId.get(sessionId); + if (!entry) throw new SessionNotFoundError(sessionId); + resolveTrustedClientId(entry, context?.clientId); + return entry.artifacts.withContentRefsLocked(async (refs) => { + const releaseRefs = artifactContentStore.leaseContentRefs(refs); + try { + return await artifactContentStore.fsck(refs); + } finally { + releaseRefs(); + } + }); + }, + + async gcSessionArtifacts(sessionId, context) { + const entry = byId.get(sessionId); + if (!entry) throw new SessionNotFoundError(sessionId); + resolveTrustedClientId(entry, context?.clientId); + return gcArtifactContent(entry); + }, + listWorkspaceSessions(workspaceCwd) { if (!path.isAbsolute(workspaceCwd)) return []; const key = @@ -5933,8 +6378,19 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { response as BridgeSessionState, ); const beforeArtifacts = (await entry.artifacts.list()).artifacts; - const artifactRestoreWarnings = - await entry.artifacts.restore(artifactSnapshot); + const artifactRestoreWarnings = await entry.artifacts.restore( + artifactSnapshot, + { + verifyContentRef: (artifact) => + artifact.contentRef + ? artifactContentStore.verifyContentRef( + entry.sessionId, + artifact.id, + artifact.contentRef, + ) + : Promise.resolve(undefined), + }, + ); const artifactSnapshotWarnings = artifactSnapshot === undefined ? [] @@ -5959,6 +6415,14 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { artifactReseedChanges(beforeArtifacts, afterArtifacts), originatorClientId, ); + await gcArtifactContent(entry).catch((error) => { + writeStderrLine( + `qwen serve: session artifact GC failed during rewind for ${JSON.stringify( + sessionId, + )}: ${error instanceof Error ? error.message : String(error)}`, + ); + }); + try { entry.events.publish({ type: 'session_rewound', @@ -6197,6 +6661,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { // channelInfo. Keep `channelInfoForEntry(entry)` until a // deterministic overlap test lands. const ci = channelInfoForEntry(entry); + let killEmptyChannelAfterCleanup = false; if (!ci) { // Same diagnostic as `closeSession` — when the entry's channel // is already gone, the cleanup below short-circuits silently. @@ -6207,6 +6672,10 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { } if (ci && ci.channel === entry.channel) { ci.sessionIds.delete(sessionId); + if (resolvedChannelIdleTimeoutMs() <= 0 && hasNoChannelWork(ci)) { + ci.isDying = true; + killEmptyChannelAfterCleanup = true; + } } await notifyAgentSessionClose(entry, ci, 'killSession'); // Tombstone the killed sessionId so any in-flight @@ -6214,6 +6683,15 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { // seed the early-event buffer for a subsequent load/resume of // the same persisted id. ci?.client.markSessionClosed(sessionId); + try { + await gcArtifactContent(entry); + } catch (error) { + writeStderrLine( + `qwen serve: session artifact GC failed during kill for ${JSON.stringify( + sessionId, + )}: ${error instanceof Error ? error.message : String(error)}`, + ); + } // Publish `session_died` BEFORE closing the bus. After the eager // `byId.delete` above, the channel.exited handler's // `byId.get(...)` returns undefined so the automatic publish @@ -6236,7 +6714,11 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { // SIGTERM the restore mid-flight and 500 the caller for a // failure orthogonal to their request. if (ci && hasNoChannelWork(ci)) { - await reapPendingEmptyChannel(ci); + if (killEmptyChannelAfterCleanup) { + await killChannelWithLog(ci, `killSession "${sessionId}"`); + } else { + await reapPendingEmptyChannel(ci); + } if (!ci.isDying) { await startIdleTimer(ci, `killSession "${sessionId}"`); } diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts index 44ec764cf93..d47ea2fd5db 100644 --- a/packages/acp-bridge/src/bridgeTypes.ts +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -27,6 +27,10 @@ import type { SessionArtifactMutationResult, SessionArtifactsEnvelope, } from './sessionArtifacts.js'; +import type { + SessionArtifactFsckResult, + SessionArtifactGcResult, +} from './sessionArtifactContentStore.js'; import type { ServeSessionContextStatus, ServeSessionHooksStatus, @@ -61,6 +65,20 @@ export interface RewindResponse { filesFailed: string[]; } +export interface SessionArtifactPinRequest { + mode?: 'metadata' | 'content'; + ttlDays?: number; + clientRetained?: boolean; +} + +export interface SessionArtifactRemoveRequest { + deleteContent?: boolean; +} + +export interface SessionArtifactUnpinRequest { + retention?: 'ephemeral' | 'restorable'; +} + export interface BridgeSpawnRequest { /** Absolute path to the workspace root the child inherits as cwd. */ workspaceCwd: string; @@ -588,8 +606,33 @@ export interface AcpSessionBridge { sessionId: string, artifactId: string, context?: BridgeClientRequestContext, + options?: SessionArtifactRemoveRequest, + ): Promise; + + pinSessionArtifact( + sessionId: string, + artifactId: string, + context?: BridgeClientRequestContext, + options?: SessionArtifactPinRequest, ): Promise; + unpinSessionArtifact( + sessionId: string, + artifactId: string, + context?: BridgeClientRequestContext, + options?: SessionArtifactUnpinRequest, + ): Promise; + + fsckSessionArtifacts( + sessionId: string, + context?: BridgeClientRequestContext, + ): Promise; + + gcSessionArtifacts( + sessionId: string, + context?: BridgeClientRequestContext, + ): Promise; + /** * Cast a vote on a pending `permission_request` (first-responder wins). */ diff --git a/packages/acp-bridge/src/sessionArtifactContentStore.ts b/packages/acp-bridge/src/sessionArtifactContentStore.ts new file mode 100644 index 00000000000..922c38b6c44 --- /dev/null +++ b/packages/acp-bridge/src/sessionArtifactContentStore.ts @@ -0,0 +1,756 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createHash } from 'node:crypto'; +import fsSync from 'node:fs'; +import { promises as fs } from 'node:fs'; +import type { FileHandle } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import type { SessionArtifactContentRef } from '@qwen-code/qwen-code-core'; +import type { SessionArtifactPersistenceWarning } from '@qwen-code/qwen-code-core'; +import { writeStderrLine } from './internal/stderrLine.js'; +import type { DaemonSessionArtifact } from './sessionArtifacts.js'; +import { SessionArtifactValidationError } from './sessionArtifacts.js'; + +const CONTENT_FORMAT_VERSION = 1; +const MAX_PINNED_FILE_BYTES = 50 * 1024 * 1024; +const MAX_CONTENT_STORE_BYTES = 256 * 1024 * 1024; +const MAX_CONTENT_MANIFEST_BYTES = 4 * 1024; +const CONTENT_ID_PATTERN = /^[0-9a-f]{64}-[0-9a-f]{16}$/; + +interface ContentManifest { + v: typeof CONTENT_FORMAT_VERSION; + contentId: string; + sessionId: string; + artifactId: string; + workspacePath: string; + sha256: string; + sizeBytes: number; + createdAt: string; +} + +interface ResolvedWorkspaceFile { + realWorkspace: string; + realPath: string; + stat: fsSync.Stats; +} + +export interface SessionArtifactFsckResult { + checked: number; + missing: string[]; + hashMismatches: string[]; +} + +export interface SessionArtifactGcResult { + removed: string[]; + retained: string[]; +} + +export class SessionArtifactContentStore { + private readonly rootDir: string; + private writeQueue: Promise = Promise.resolve(); + private readonly leasedContentIds = new Map(); + private cachedTotalBytes: number | undefined; + + constructor(rootDir = defaultContentRoot()) { + this.rootDir = rootDir; + } + + async pinWorkspaceFile( + sessionId: string, + artifact: DaemonSessionArtifact, + workspaceCwd: string, + ): Promise { + if (artifact.storage !== 'workspace' || !artifact.workspacePath) { + return undefined; + } + const workspacePath = artifact.workspacePath; + const source = await resolveWorkspaceFile( + workspaceCwd, + workspacePath, + ).catch((error: unknown) => { + if (error instanceof SessionArtifactValidationError) { + throw error; + } + throw new SessionArtifactValidationError( + `workspacePath could not be inspected: ${ + error instanceof Error ? error.message : String(error) + }`, + 'artifactId', + ); + }); + return this.enqueueWrite(async () => { + const tmpDir = path.join(this.rootDir, '.tmp'); + await fs.mkdir(tmpDir, { recursive: true, mode: 0o700 }); + let tmpPath: string | undefined = path.join( + tmpDir, + `${process.pid}-${Date.now()}-${artifact.id}.bin`, + ); + let sourceHandle: FileHandle | undefined; + let addedSizeBytes = 0; + let contentDir: string | undefined; + try { + sourceHandle = await openRegularWorkspaceFile(source); + const { sha256, sizeBytes } = await copyOpenFileToTemp( + sourceHandle, + tmpPath, + ); + + const contentId = `${sha256}-${stableContentSuffix( + sessionId, + artifact.id, + )}`; + contentDir = path.join(this.rootDir, contentId); + const dataPath = path.join(contentDir, 'content'); + if (await exists(dataPath)) { + if (!(await contentFileMatches(dataPath, sha256, sizeBytes))) { + throw new SessionArtifactValidationError( + 'Existing retained artifact content failed verification', + 'artifactId', + ); + } + await fs.rm(tmpPath, { force: true }); + tmpPath = undefined; + } else { + const usedBytes = await this.getUsedBytes(); + if (usedBytes + sizeBytes > MAX_CONTENT_STORE_BYTES) { + throw new SessionArtifactValidationError( + `Artifact content quota exceeded (usedBytes=${usedBytes}, requestedBytes=${sizeBytes}, limitBytes=${MAX_CONTENT_STORE_BYTES})`, + 'artifactId', + ); + } + await fs.mkdir(contentDir, { recursive: true, mode: 0o700 }); + await fs.rename(tmpPath, dataPath); + tmpPath = undefined; + addedSizeBytes = sizeBytes; + } + + const createdAt = new Date().toISOString(); + const manifest: ContentManifest = { + v: CONTENT_FORMAT_VERSION, + contentId, + sessionId, + artifactId: artifact.id, + workspacePath, + sha256, + sizeBytes, + createdAt, + }; + await writeManifestAtomic(contentDir, manifest); + if (addedSizeBytes > 0 && this.cachedTotalBytes !== undefined) { + this.cachedTotalBytes += addedSizeBytes; + } + this.leaseContentId(contentId); + return { + kind: 'managed_copy', + contentId, + sha256, + sizeBytes, + createdAt, + }; + } catch (error) { + if (addedSizeBytes > 0) { + if (contentDir) { + await fs + .rm(contentDir, { recursive: true, force: true }) + .catch(() => {}); + } + this.cachedTotalBytes = undefined; + } + if (tmpPath) { + await fs.rm(tmpPath, { force: true }).catch(() => {}); + } + throw error; + } finally { + await sourceHandle?.close().catch(() => undefined); + } + }); + } + + releaseContentRef(ref: SessionArtifactContentRef): void { + this.releaseContentId(ref.contentId); + } + + leaseContentRefs(refs: readonly SessionArtifactContentRef[]): () => void { + const leased: string[] = []; + for (const ref of refs) { + this.leaseContentId(ref.contentId); + leased.push(ref.contentId); + } + return () => { + for (const contentId of leased) { + this.releaseContentId(contentId); + } + }; + } + + async fsck( + contentRefs: readonly SessionArtifactContentRef[], + ): Promise { + const missing: string[] = []; + const hashMismatches: string[] = []; + for (const ref of contentRefs) { + if (!isValidContentId(ref.contentId)) { + missing.push(ref.contentId); + continue; + } + const dataPath = path.join(this.rootDir, ref.contentId, 'content'); + try { + const { sha256 } = await hashFile(dataPath); + if (sha256 !== ref.sha256) { + hashMismatches.push(ref.contentId); + } + } catch { + missing.push(ref.contentId); + continue; + } + } + const result = { checked: contentRefs.length, missing, hashMismatches }; + writeStderrLine( + `[artifacts] action=fsck checked=${result.checked} missing=${result.missing.length} hashMismatches=${result.hashMismatches.length}`, + ); + return result; + } + + async verifyContentRef( + sessionId: string, + artifactId: string, + ref: SessionArtifactContentRef, + ): Promise { + if ( + ref.kind !== 'managed_copy' || + !isValidContentId(ref.contentId) || + !/^[0-9a-f]{64}$/.test(ref.sha256) || + !Number.isSafeInteger(ref.sizeBytes) || + ref.sizeBytes < 0 + ) { + return 'restore_validation_failed'; + } + const contentDir = path.join(this.rootDir, ref.contentId); + let manifest: ContentManifest; + try { + manifest = await readManifest(path.join(contentDir, 'manifest.json')); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return 'content_missing'; + } + return 'restore_validation_failed'; + } + if ( + manifest.sessionId !== sessionId || + manifest.artifactId !== artifactId || + manifest.contentId !== ref.contentId || + manifest.sha256 !== ref.sha256 || + manifest.sizeBytes !== ref.sizeBytes + ) { + return 'restore_validation_failed'; + } + try { + const contentPath = path.join(contentDir, 'content'); + const stat = await fs.lstat(contentPath); + if (!stat.isFile()) { + return 'content_hash_mismatch'; + } + const { sha256, sizeBytes } = await hashFile(contentPath); + if (sha256 !== ref.sha256 || sizeBytes !== ref.sizeBytes) { + return 'content_hash_mismatch'; + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return 'content_missing'; + } + throw error; + } + return undefined; + } + + async gc( + sessionId: string, + referencedContentIds: ReadonlySet, + ): Promise { + return this.enqueueWrite(async () => { + const removed: string[] = []; + const retained: string[] = []; + const failedRemovals: string[] = []; + let entries: string[]; + try { + entries = await fs.readdir(this.rootDir); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { removed, retained }; + } + throw error; + } + for (const entry of entries) { + if (entry === '.tmp') { + await cleanTmpDir(path.join(this.rootDir, entry)); + continue; + } + const fullPath = path.join(this.rootDir, entry); + if ( + referencedContentIds.has(entry) || + this.leasedContentIds.has(entry) + ) { + retained.push(entry); + continue; + } + let manifest: ContentManifest; + try { + manifest = await readManifest(path.join(fullPath, 'manifest.json')); + } catch (error) { + writeStderrLine( + `[artifacts] action=content_gc_manifest_read_failed contentId=${entry} reason=${JSON.stringify( + error instanceof Error ? error.message : String(error), + )}`, + ); + retained.push(entry); + continue; + } + if (manifest.sessionId !== sessionId) { + retained.push(entry); + continue; + } + try { + await fs.rm(fullPath, { recursive: true, force: true }); + } catch (error) { + writeStderrLine( + `[artifacts] action=content_gc_remove_failed contentId=${entry} reason=${JSON.stringify( + error instanceof Error ? error.message : String(error), + )}`, + ); + retained.push(entry); + failedRemovals.push(entry); + continue; + } + if (this.cachedTotalBytes !== undefined) { + this.cachedTotalBytes = Math.max( + 0, + this.cachedTotalBytes - manifest.sizeBytes, + ); + } + removed.push(entry); + } + if (failedRemovals.length > 0) { + throw new Error( + `artifact content GC failed to remove ${failedRemovals.length} entr${ + failedRemovals.length === 1 ? 'y' : 'ies' + }`, + ); + } + return { removed, retained }; + }); + } + + private async getUsedBytes(): Promise { + if (this.cachedTotalBytes === undefined) { + this.cachedTotalBytes = await this.scanUsedBytes(); + } + return this.cachedTotalBytes; + } + + private async scanUsedBytes(): Promise { + let total = 0; + let entries: string[]; + try { + entries = await fs.readdir(this.rootDir); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return 0; + } + throw error; + } + for (const entry of entries) { + if (entry === '.tmp') continue; + try { + const manifest = await readManifest( + path.join(this.rootDir, entry, 'manifest.json'), + ); + total += manifest.sizeBytes; + } catch { + try { + const stat = await fs.stat(path.join(this.rootDir, entry, 'content')); + if (stat.isFile()) { + total += stat.size; + } + } catch { + // Malformed manifests are handled by fsck/GC. + } + } + } + return total; + } + + private enqueueWrite(operation: () => Promise): Promise { + const run = this.writeQueue.catch(() => {}).then(operation); + this.writeQueue = run.then( + () => undefined, + () => undefined, + ); + return run; + } + + private leaseContentId(contentId: string): void { + this.leasedContentIds.set( + contentId, + (this.leasedContentIds.get(contentId) ?? 0) + 1, + ); + } + + private releaseContentId(contentId: string): void { + const count = this.leasedContentIds.get(contentId); + if (count === undefined) return; + if (count <= 1) { + this.leasedContentIds.delete(contentId); + return; + } + this.leasedContentIds.set(contentId, count - 1); + } +} + +function defaultContentRoot(): string { + return path.join(getGlobalQwenDir(), 'session-artifacts', 'content'); +} + +function getGlobalQwenDir(): string { + const envDir = process.env['QWEN_HOME']; + if (envDir) { + return resolveUserPath(envDir); + } + const homeDir = os.homedir(); + if (!homeDir) { + return path.join(os.tmpdir(), '.qwen'); + } + return path.join(homeDir, '.qwen'); +} + +function resolveUserPath(dir: string): string { + let resolved = dir; + if ( + resolved === '~' || + resolved.startsWith('~/') || + resolved.startsWith('~\\') + ) { + const relativeSegments = + resolved === '~' + ? [] + : resolved + .slice(2) + .split(/[/\\]+/) + .filter(Boolean); + resolved = path.join(os.homedir(), ...relativeSegments); + } + return path.isAbsolute(resolved) ? resolved : path.resolve(resolved); +} + +function stableContentSuffix(sessionId: string, artifactId: string): string { + return createHash('sha256') + .update(`${sessionId}:${artifactId}`) + .digest('hex') + .slice(0, 16); +} + +async function resolveWorkspaceFile( + workspaceCwd: string, + workspacePath: string, +): Promise { + const realWorkspace = await fs.realpath(workspaceCwd); + const candidate = path.resolve(realWorkspace, workspacePath); + const realCandidate = await fs.realpath(candidate); + validateWorkspaceContained(realWorkspace, realCandidate); + return { + realWorkspace, + realPath: realCandidate, + stat: await fs.stat(realCandidate), + }; +} + +async function openRegularWorkspaceFile( + source: ResolvedWorkspaceFile, +): Promise { + const sourceStat = source.stat; + if (!sourceStat.isFile()) { + throw new SessionArtifactValidationError( + 'Only regular workspace files can be pinned with content retention', + 'workspacePath', + ); + } + if (sourceStat.nlink > 1) { + throw new SessionArtifactValidationError( + 'Hardlinked workspace files cannot be pinned with content retention', + 'workspacePath', + ); + } + if (sourceStat.size > MAX_PINNED_FILE_BYTES) { + throw new SessionArtifactValidationError( + `Pinned artifact content exceeds ${MAX_PINNED_FILE_BYTES} bytes`, + 'workspacePath', + ); + } + let handle: FileHandle; + try { + handle = await fs.open( + source.realPath, + fsSync.constants.O_RDONLY | noFollowFlag(), + ); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ELOOP') { + throw new SessionArtifactValidationError( + 'workspacePath must not resolve through a symlink while pinning content', + 'workspacePath', + ); + } + throw error; + } + try { + const handleStat = await handle.stat(); + const realAfterOpen = await fs.realpath(source.realPath); + validateWorkspaceContained(source.realWorkspace, realAfterOpen); + if (realAfterOpen !== source.realPath) { + throw new SessionArtifactValidationError( + 'workspacePath changed while pinning content', + 'workspacePath', + ); + } + const currentStat = await fs.stat(source.realPath); + if (!handleStat.isFile()) { + throw new SessionArtifactValidationError( + 'Only regular workspace files can be pinned with content retention', + 'artifactId', + ); + } + if ( + !sameFile(sourceStat, handleStat) || + !sameFile(currentStat, handleStat) + ) { + throw new SessionArtifactValidationError( + 'workspacePath changed while pinning content', + 'workspacePath', + ); + } + if (handleStat.nlink > 1) { + throw new SessionArtifactValidationError( + 'Hardlinked workspace files cannot be pinned with content retention', + 'artifactId', + ); + } + if (handleStat.size > MAX_PINNED_FILE_BYTES) { + throw new SessionArtifactValidationError( + `Pinned artifact content exceeds ${MAX_PINNED_FILE_BYTES} bytes`, + 'artifactId', + ); + } + return handle; + } catch (error) { + await handle.close().catch(() => undefined); + throw error; + } +} + +function validateWorkspaceContained( + realWorkspace: string, + realPath: string, +): void { + const relative = path.relative(realWorkspace, realPath); + if (relative.startsWith('..') || path.isAbsolute(relative)) { + throw new SessionArtifactValidationError( + 'workspacePath must stay inside the workspace', + 'workspacePath', + ); + } +} + +async function copyOpenFileToTemp( + handle: FileHandle, + tmpPath: string, +): Promise<{ sha256: string; sizeBytes: number }> { + const writer = await fs.open(tmpPath, 'w', 0o600); + const hash = createHash('sha256'); + const buffer = Buffer.allocUnsafe(64 * 1024); + let sizeBytes = 0; + let position = 0; + try { + while (true) { + const { bytesRead } = await handle.read( + buffer, + 0, + buffer.length, + position, + ); + if (bytesRead === 0) break; + position += bytesRead; + sizeBytes += bytesRead; + if (sizeBytes > MAX_PINNED_FILE_BYTES) { + throw new SessionArtifactValidationError( + `Pinned artifact content exceeds ${MAX_PINNED_FILE_BYTES} bytes`, + 'artifactId', + ); + } + const chunk = buffer.subarray(0, bytesRead); + hash.update(chunk); + await writer.write(chunk); + } + await writer.sync(); + } finally { + await writer.close().catch(() => undefined); + } + return { sha256: hash.digest('hex'), sizeBytes }; +} + +function noFollowFlag(): number { + return typeof fsSync.constants.O_NOFOLLOW === 'number' + ? fsSync.constants.O_NOFOLLOW + : 0; +} + +function sameFile(before: fsSync.Stats, after: fsSync.Stats): boolean { + if ( + before.dev !== 0 && + before.ino !== 0 && + after.dev !== 0 && + after.ino !== 0 + ) { + return before.dev === after.dev && before.ino === after.ino; + } + return before.size === after.size && before.mtimeMs === after.mtimeMs; +} + +async function hashFile( + filePath: string, +): Promise<{ sha256: string; sizeBytes: number }> { + const fileStat = await fs.lstat(filePath); + if (!fileStat.isFile()) { + throw new Error('Retained artifact content must be a regular file'); + } + const handle = await fs.open( + filePath, + fsSync.constants.O_RDONLY | noFollowFlag(), + ); + const hash = createHash('sha256'); + const buffer = Buffer.allocUnsafe(64 * 1024); + let sizeBytes = 0; + let position = 0; + try { + const stat = await handle.stat(); + if (!stat.isFile()) { + throw new Error('Retained artifact content must be a regular file'); + } + while (true) { + const { bytesRead } = await handle.read( + buffer, + 0, + buffer.length, + position, + ); + if (bytesRead === 0) break; + position += bytesRead; + sizeBytes += bytesRead; + hash.update(buffer.subarray(0, bytesRead)); + } + return { sha256: hash.digest('hex'), sizeBytes }; + } finally { + await handle.close().catch(() => undefined); + } +} + +async function contentFileMatches( + filePath: string, + sha256: string, + sizeBytes: number, +): Promise { + const existing = await hashFile(filePath); + return existing.sha256 === sha256 && existing.sizeBytes === sizeBytes; +} + +async function readManifest(filePath: string): Promise { + const stat = await fs.lstat(filePath); + if (!stat.isFile() || stat.size > MAX_CONTENT_MANIFEST_BYTES) { + throw new Error('Invalid artifact content manifest'); + } + const body = await fs.readFile(filePath, 'utf8'); + const parsed = JSON.parse(body) as Partial; + if ( + parsed.v !== CONTENT_FORMAT_VERSION || + !parsed.contentId || + !isValidContentId(parsed.contentId) || + typeof parsed.sessionId !== 'string' || + typeof parsed.artifactId !== 'string' || + typeof parsed.workspacePath !== 'string' || + typeof parsed.sha256 !== 'string' || + !/^[0-9a-f]{64}$/.test(parsed.sha256) || + typeof parsed.sizeBytes !== 'number' || + !Number.isSafeInteger(parsed.sizeBytes) || + parsed.sizeBytes < 0 || + typeof parsed.createdAt !== 'string' + ) { + throw new Error('Invalid artifact content manifest'); + } + return parsed as ContentManifest; +} + +async function writeManifestAtomic( + contentDir: string, + manifest: ContentManifest, +): Promise { + const tmpPath = path.join( + contentDir, + `.manifest-${process.pid}-${Date.now()}.json.tmp`, + ); + let handle: FileHandle | undefined; + try { + handle = await fs.open(tmpPath, 'w', 0o600); + await handle.writeFile(`${JSON.stringify(manifest)}\n`); + await handle.sync(); + await handle.close(); + handle = undefined; + await fs.rename(tmpPath, path.join(contentDir, 'manifest.json')); + } catch (error) { + await handle?.close().catch(() => undefined); + await fs.rm(tmpPath, { force: true }).catch(() => undefined); + throw error; + } +} + +async function cleanTmpDir(tmpDir: string): Promise { + let entries: string[]; + try { + entries = await fs.readdir(tmpDir); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return; + } + throw error; + } + for (const entry of entries) { + try { + await fs.rm(path.join(tmpDir, entry), { recursive: true, force: true }); + } catch (error) { + writeStderrLine( + `[artifacts] action=content_tmp_cleanup_failed entry=${JSON.stringify( + entry, + )} reason=${JSON.stringify( + error instanceof Error ? error.message : String(error), + )}`, + ); + } + } +} + +function isValidContentId(contentId: string): boolean { + return CONTENT_ID_PATTERN.test(contentId); +} + +async function exists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return false; + } + throw error; + } +} diff --git a/packages/acp-bridge/src/sessionArtifacts.test.ts b/packages/acp-bridge/src/sessionArtifacts.test.ts index 2c81cb0d6ad..96d0a00296a 100644 --- a/packages/acp-bridge/src/sessionArtifacts.test.ts +++ b/packages/acp-bridge/src/sessionArtifacts.test.ts @@ -15,6 +15,7 @@ import { SessionArtifactStore, SessionArtifactValidationError, } from './sessionArtifacts.js'; +import { SessionArtifactContentStore } from './sessionArtifactContentStore.js'; import type { RebuiltSessionArtifactSnapshot, SessionArtifactEventRecordPayload, @@ -283,6 +284,63 @@ describe('SessionArtifactStore', () => { await expect(store.contentRefs()).resolves.toEqual([]); }); + it('keeps artifact mutations queued while content refs are locked', async () => { + const store = new SessionArtifactStore({ + sessionId: 's1-content-refs-lock', + workspaceCwd: workspace, + persistence: { + recordEvent: async () => {}, + recordSnapshot: async () => {}, + }, + }); + const created = await store.upsertMany( + [ + { title: 'First', url: 'https://example.com/first' }, + { title: 'Second', url: 'https://example.com/second' }, + ], + { strict: true }, + ); + const firstId = created.changes[0]!.artifactId; + const secondId = created.changes[1]!.artifactId; + const firstRef = { + kind: 'managed_copy' as const, + contentId: `${'a'.repeat(64)}-${'b'.repeat(16)}`, + sha256: 'a'.repeat(64), + sizeBytes: 12, + createdAt: '2026-07-04T00:00:00.000Z', + }; + const secondRef = { + kind: 'managed_copy' as const, + contentId: `${'c'.repeat(64)}-${'d'.repeat(16)}`, + sha256: 'c'.repeat(64), + sizeBytes: 34, + createdAt: '2026-07-04T00:00:01.000Z', + }; + + await store.pin(firstId, { contentRef: firstRef }); + let pinSettled = false; + let pinPromise: Promise | undefined; + const refs = await store.withContentRefsLocked(async (lockedRefs) => { + pinPromise = store + .pin(secondId, { contentRef: secondRef }) + .then((result) => { + pinSettled = true; + return result; + }); + await Promise.resolve(); + expect(pinSettled).toBe(false); + return lockedRefs; + }); + + expect(refs).toEqual([firstRef]); + expect(pinPromise).toBeDefined(); + await expect(pinPromise!).resolves.toMatchObject({ + changes: [{ action: 'updated', artifactId: secondId }], + }); + expect(pinSettled).toBe(true); + await expect(store.contentRefs()).resolves.toEqual([firstRef, secondRef]); + }); + it('pins and unpins artifact retention state', async () => { const events: SessionArtifactEventRecordPayload[] = []; const store = new SessionArtifactStore({ @@ -2995,7 +3053,7 @@ describe('SessionArtifactStore', () => { ).resolves.toMatchObject({ changes: [], warnings: [ - 'expired pinned artifacts retained because persistence failed', + 'expired artifact content retained because persistence failed', ], }); await expect(store.list()).resolves.toMatchObject({ @@ -3436,4 +3494,649 @@ describe('SessionArtifactStore', () => { ], }); }); + + it('expires restored retained content after its ttl', async () => { + const events: SessionArtifactEventRecordPayload[] = []; + const source = new SessionArtifactStore({ + sessionId: 's11-restore-expired-content', + workspaceCwd: workspace, + persistence: { + recordEvent: async (payload) => { + events.push(payload); + }, + recordSnapshot: async () => {}, + }, + }); + const created = await source.upsertMany( + [{ title: 'Pinned', url: 'https://example.com/pinned' }], + { strict: true }, + ); + const artifactId = created.changes[0]!.artifactId; + await source.pin(artifactId, { + expiresAt: '2999-07-06T00:00:00.000Z', + contentRef: { + kind: 'managed_copy', + contentId: `${'e'.repeat(64)}-${'f'.repeat(16)}`, + sha256: 'e'.repeat(64), + sizeBytes: 12, + createdAt: '2026-07-04T00:00:00.000Z', + }, + }); + const persisted = events[1]!.changes[0]!.artifact!; + const restored = new SessionArtifactStore({ + sessionId: 's11-restore-expired-content', + workspaceCwd: workspace, + persistence: { + recordEvent: async () => {}, + recordSnapshot: async () => {}, + }, + }); + await restored.restore( + { + v: 2, + sessionId: 's11-restore-expired-content', + sequence: 2, + artifacts: [persisted], + tombstonedIds: [], + stickyEphemeralIds: [], + warnings: [], + }, + { verifyContentRef: async () => undefined }, + ); + + const pruned = await restored.pruneExpiredPins( + new Date('2999-07-07T00:00:00.000Z'), + ); + + expect(pruned.changes[0]?.artifact).toMatchObject({ + id: artifactId, + retention: 'restorable', + persistenceWarning: 'content_expired', + }); + expect(pruned.changes[0]?.artifact).not.toHaveProperty('contentRef'); + expect(pruned.changes[0]?.artifact).not.toHaveProperty('expiresAt'); + }); +}); + +describe('SessionArtifactContentStore', () => { + let workspace: string; + let contentRoot: string; + + beforeEach(async () => { + workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-artifacts-')); + contentRoot = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-artifact-content-'), + ); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await fs.rm(workspace, { recursive: true, force: true }); + await fs.rm(contentRoot, { recursive: true, force: true }); + }); + + async function workspaceArtifact(name: string, body: string) { + await fs.mkdir(path.join(workspace, 'reports'), { recursive: true }); + await fs.writeFile(path.join(workspace, 'reports', name), body); + const store = new SessionArtifactStore({ + sessionId: 'content-session', + workspaceCwd: workspace, + }); + await store.upsertMany( + [{ title: name, workspacePath: `reports/${name}` }], + { strict: true }, + ); + return (await store.list()).artifacts[0]!; + } + + function fakeContentId(label: string): string { + return `${createHash('sha256').update(label).digest('hex')}-${createHash( + 'sha256', + ) + .update(`suffix:${label}`) + .digest('hex') + .slice(0, 16)}`; + } + + async function writeQuotaManifest(label: string, sizeBytes: number) { + const contentId = fakeContentId(label); + await fs.mkdir(path.join(contentRoot, contentId), { recursive: true }); + await fs.writeFile( + path.join(contentRoot, contentId, 'manifest.json'), + `${JSON.stringify({ + v: 1, + contentId, + sessionId: 'content-session', + artifactId: contentId, + workspacePath: `reports/${contentId}.txt`, + sha256: '0'.repeat(64), + sizeBytes, + createdAt: '2026-07-04T00:00:00.000Z', + })}\n`, + ); + return contentId; + } + + it('copies pinned workspace content with a path-safe content id', async () => { + const contentStore = new SessionArtifactContentStore(contentRoot); + const artifact = await workspaceArtifact('report.txt', 'hello'); + + const ref = await contentStore.pinWorkspaceFile( + 'session/with/slash', + artifact, + workspace, + ); + + expect(ref).toMatchObject({ + kind: 'managed_copy', + sha256: createHash('sha256').update('hello').digest('hex'), + sizeBytes: 5, + }); + expect(ref!.contentId).toMatch(/^[a-f0-9]{64}-[a-f0-9]{16}$/); + await expect( + fs.readFile(path.join(contentRoot, ref!.contentId, 'content'), 'utf8'), + ).resolves.toBe('hello'); + await expect(contentStore.fsck([ref!])).resolves.toEqual({ + checked: 1, + missing: [], + hashMismatches: [], + }); + }); + + it('treats fsck read races as missing content', async () => { + const contentStore = new SessionArtifactContentStore(contentRoot); + const artifact = await workspaceArtifact('race.txt', 'race'); + const ref = (await contentStore.pinWorkspaceFile( + 'content-session', + artifact, + workspace, + ))!; + const contentPath = path.join(contentRoot, ref.contentId, 'content'); + await fs.rm(contentPath); + await fs.mkdir(contentPath); + + await expect(contentStore.fsck([ref])).resolves.toEqual({ + checked: 1, + missing: [ref.contentId], + hashMismatches: [], + }); + }); + + it('reuses an existing retained content copy for repeated pins', async () => { + const contentStore = new SessionArtifactContentStore(contentRoot); + const artifact = await workspaceArtifact('repeat.txt', 'repeat'); + + const first = (await contentStore.pinWorkspaceFile( + 'content-session', + artifact, + workspace, + ))!; + const second = (await contentStore.pinWorkspaceFile( + 'content-session', + artifact, + workspace, + ))!; + + expect(second.contentId).toBe(first.contentId); + await expect(contentStore.fsck([second])).resolves.toEqual({ + checked: 1, + missing: [], + hashMismatches: [], + }); + }); + + it('rejects corrupted retained content before reusing a copy', async () => { + const contentStore = new SessionArtifactContentStore(contentRoot); + const artifact = await workspaceArtifact('repeat-corrupt.txt', 'repeat'); + const first = (await contentStore.pinWorkspaceFile( + 'content-session', + artifact, + workspace, + ))!; + await fs.writeFile( + path.join(contentRoot, first.contentId, 'content'), + 'corrupt', + ); + + await expect( + contentStore.pinWorkspaceFile('content-session', artifact, workspace), + ).rejects.toBeInstanceOf(SessionArtifactValidationError); + }); + + it('reuses repeated pins even when the store is otherwise near quota', async () => { + const contentStore = new SessionArtifactContentStore(contentRoot); + const artifact = await workspaceArtifact('repeat-near-quota.txt', 'repeat'); + const first = (await contentStore.pinWorkspaceFile( + 'content-session', + artifact, + workspace, + ))!; + await writeQuotaManifest('quota-filler', 256 * 1024 * 1024); + + const second = (await contentStore.pinWorkspaceFile( + 'content-session', + artifact, + workspace, + ))!; + + expect(second.contentId).toBe(first.contentId); + }); + + it('caches total content bytes after the first quota scan', async () => { + const contentStore = new SessionArtifactContentStore(contentRoot); + const first = await workspaceArtifact('cache-a.txt', 'a'); + const second = await workspaceArtifact('cache-b.txt', 'b'); + const readdirSpy = vi.spyOn(fs, 'readdir'); + + await contentStore.pinWorkspaceFile('content-session', first, workspace); + const rootScansAfterFirst = readdirSpy.mock.calls.filter( + ([dir]) => String(dir) === contentRoot, + ).length; + + await contentStore.pinWorkspaceFile('content-session', second, workspace); + const rootScansAfterSecond = readdirSpy.mock.calls.filter( + ([dir]) => String(dir) === contentRoot, + ).length; + + expect(rootScansAfterFirst).toBe(1); + expect(rootScansAfterSecond).toBe(rootScansAfterFirst); + }); + + it('rejects files over the per-artifact content limit before copying', async () => { + const contentStore = new SessionArtifactContentStore(contentRoot); + await fs.mkdir(path.join(workspace, 'reports'), { recursive: true }); + const oversizedPath = path.join(workspace, 'reports', 'oversized.bin'); + await fs.writeFile(oversizedPath, ''); + await fs.truncate(oversizedPath, 50 * 1024 * 1024 + 1); + const store = new SessionArtifactStore({ + sessionId: 'content-session', + workspaceCwd: workspace, + }); + await store.upsertMany( + [{ title: 'oversized.bin', workspacePath: 'reports/oversized.bin' }], + { strict: true }, + ); + const artifact = (await store.list()).artifacts[0]!; + + await expect( + contentStore.pinWorkspaceFile('content-session', artifact, workspace), + ).rejects.toThrow(SessionArtifactValidationError); + }); + + it('rejects non-regular workspace files for content retention', async () => { + const contentStore = new SessionArtifactContentStore(contentRoot); + await fs.mkdir(path.join(workspace, 'reports'), { recursive: true }); + const artifact = { + ...(await workspaceArtifact('regular.txt', 'regular')), + workspacePath: 'reports', + }; + + await expect( + contentStore.pinWorkspaceFile('content-session', artifact, workspace), + ).rejects.toThrow(SessionArtifactValidationError); + }); + + it('rejects workspace symlinks that escape before copying content', async () => { + const contentStore = new SessionArtifactContentStore(contentRoot); + const outside = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-outside-')); + try { + await fs.writeFile(path.join(outside, 'secret.txt'), 'secret'); + await fs.mkdir(path.join(workspace, 'reports'), { recursive: true }); + await fs.symlink( + path.join(outside, 'secret.txt'), + path.join(workspace, 'reports', 'link.txt'), + ); + const artifact = { + ...(await workspaceArtifact('regular.txt', 'regular')), + workspacePath: 'reports/link.txt', + }; + + await expect( + contentStore.pinWorkspaceFile('content-session', artifact, workspace), + ).rejects.toThrow(SessionArtifactValidationError); + } finally { + await fs.rm(outside, { recursive: true, force: true }); + } + }); + + it('rejects hardlinked workspace files for content retention', async () => { + const contentStore = new SessionArtifactContentStore(contentRoot); + await fs.mkdir(path.join(workspace, 'reports'), { recursive: true }); + const originalPath = path.join(workspace, 'reports', 'original.txt'); + const hardlinkPath = path.join(workspace, 'reports', 'hardlink.txt'); + await fs.writeFile(originalPath, 'hardlinked'); + try { + await fs.link(originalPath, hardlinkPath); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'EPERM' || code === 'EACCES' || code === 'EXDEV') { + return; + } + throw error; + } + const artifact = { + ...(await workspaceArtifact('regular.txt', 'regular')), + workspacePath: 'reports/hardlink.txt', + }; + + await expect( + contentStore.pinWorkspaceFile('content-session', artifact, workspace), + ).rejects.toThrow(SessionArtifactValidationError); + }); + + it('rejects new content over total quota and cleans up temporary files', async () => { + const contentStore = new SessionArtifactContentStore(contentRoot); + const artifact = await workspaceArtifact('over-quota.txt', 'new-content'); + await writeQuotaManifest('quota-filler', 256 * 1024 * 1024); + + await expect( + contentStore.pinWorkspaceFile('content-session', artifact, workspace), + ).rejects.toThrow( + /Artifact content quota exceeded .*usedBytes=.*requestedBytes=.*limitBytes=/, + ); + await expect(fs.readdir(path.join(contentRoot, '.tmp'))).resolves.toEqual( + [], + ); + }); + + it('counts malformed manifest content files against total quota', async () => { + const contentStore = new SessionArtifactContentStore(contentRoot); + const artifact = await workspaceArtifact('malformed-quota.txt', 'data'); + const contentId = fakeContentId('malformed-quota'); + const contentDir = path.join(contentRoot, contentId); + await fs.mkdir(contentDir, { recursive: true }); + await fs.writeFile( + path.join(contentDir, 'manifest.json'), + '{"sizeBytes":"bad"}\n', + ); + await fs.writeFile(path.join(contentDir, 'content'), ''); + await fs.truncate(path.join(contentDir, 'content'), 256 * 1024 * 1024); + + await expect( + contentStore.pinWorkspaceFile('content-session', artifact, workspace), + ).rejects.toThrow(SessionArtifactValidationError); + }); + + it('cleans stale temporary content files during gc', async () => { + const contentStore = new SessionArtifactContentStore(contentRoot); + const tmpDir = path.join(contentRoot, '.tmp'); + await fs.mkdir(tmpDir, { recursive: true }); + await fs.writeFile(path.join(tmpDir, 'stale.bin'), 'stale'); + + await contentStore.gc('content-session', new Set()); + + await expect(fs.readdir(tmpDir)).resolves.toEqual([]); + }); + + it('continues cleaning temporary content files after an rm failure', async () => { + const contentStore = new SessionArtifactContentStore(contentRoot); + const tmpDir = path.join(contentRoot, '.tmp'); + await fs.mkdir(tmpDir, { recursive: true }); + await fs.writeFile(path.join(tmpDir, 'stuck.bin'), 'stuck'); + await fs.writeFile(path.join(tmpDir, 'gone.bin'), 'gone'); + const originalRm = fs.rm.bind(fs); + vi.spyOn(fs, 'rm').mockImplementation((async (target, options) => { + if (path.basename(String(target)) === 'stuck.bin') { + throw new Error('busy'); + } + return originalRm(target, options); + }) as typeof fs.rm); + const stderr = vi + .spyOn(process.stderr, 'write') + .mockReturnValue(true as never); + + await expect( + contentStore.gc('content-session', new Set()), + ).resolves.toEqual({ + removed: [], + retained: [], + }); + await expect(fs.readdir(tmpDir)).resolves.toEqual(['stuck.bin']); + const logged = stderr.mock.calls.map((call) => String(call[0])).join(''); + expect(logged).toContain('content_tmp_cleanup_failed'); + expect(logged).toContain('stuck.bin'); + }); + + it('logs and retains content when gc cannot read its manifest', async () => { + const contentStore = new SessionArtifactContentStore(contentRoot); + const contentId = fakeContentId('bad-manifest'); + const contentDir = path.join(contentRoot, contentId); + await fs.mkdir(contentDir, { recursive: true }); + await fs.writeFile(path.join(contentDir, 'manifest.json'), '{bad json'); + const stderr = vi + .spyOn(process.stderr, 'write') + .mockReturnValue(true as never); + + try { + await expect( + contentStore.gc('content-session', new Set()), + ).resolves.toEqual({ + removed: [], + retained: [contentId], + }); + const logged = stderr.mock.calls.map((call) => String(call[0])).join(''); + expect(logged).toContain('content_gc_manifest_read_failed'); + expect(logged).toContain(contentId); + } finally { + stderr.mockRestore(); + } + }); + + it('logs and retains content with oversized manifests during gc', async () => { + const contentStore = new SessionArtifactContentStore(contentRoot); + const contentId = fakeContentId('huge-manifest'); + const contentDir = path.join(contentRoot, contentId); + await fs.mkdir(contentDir, { recursive: true }); + await fs.writeFile( + path.join(contentDir, 'manifest.json'), + 'x'.repeat(5000), + ); + await fs.writeFile(path.join(contentDir, 'content'), 'data'); + const stderr = vi + .spyOn(process.stderr, 'write') + .mockReturnValue(true as never); + + try { + await expect( + contentStore.gc('content-session', new Set()), + ).resolves.toEqual({ + removed: [], + retained: [contentId], + }); + const logged = stderr.mock.calls.map((call) => String(call[0])).join(''); + expect(logged).toContain('content_gc_manifest_read_failed'); + expect(logged).toContain(contentId); + } finally { + stderr.mockRestore(); + } + }); + + it('throws after scanning when content removal fails during gc', async () => { + const contentStore = new SessionArtifactContentStore(contentRoot); + const ref = (await contentStore.pinWorkspaceFile( + 'content-session', + await workspaceArtifact('remove-failure.txt', 'data'), + workspace, + ))!; + contentStore.releaseContentRef(ref); + const originalRm = fs.rm.bind(fs); + vi.spyOn(fs, 'rm').mockImplementation((async (target, options) => { + if (String(target).includes(ref.contentId)) { + throw new Error('busy'); + } + return originalRm(target, options); + }) as typeof fs.rm); + const stderr = vi + .spyOn(process.stderr, 'write') + .mockReturnValue(true as never); + + await expect(contentStore.gc('content-session', new Set())).rejects.toThrow( + 'artifact content GC failed to remove 1 entry', + ); + const logged = stderr.mock.calls.map((call) => String(call[0])).join(''); + expect(logged).toContain('content_gc_remove_failed'); + expect(logged).toContain(ref.contentId); + }); + + it('retains leased content during gc until the pin flow releases it', async () => { + const contentStore = new SessionArtifactContentStore(contentRoot); + const ref = (await contentStore.pinWorkspaceFile( + 'content-session', + await workspaceArtifact('leased.txt', 'leased'), + workspace, + ))!; + + await expect( + contentStore.gc('content-session', new Set()), + ).resolves.toMatchObject({ + removed: [], + retained: expect.arrayContaining([ref.contentId]), + }); + + const releaseGcLease = contentStore.leaseContentRefs([ref]); + contentStore.releaseContentRef(ref); + await expect( + contentStore.gc('content-session', new Set()), + ).resolves.toMatchObject({ + removed: [], + retained: expect.arrayContaining([ref.contentId]), + }); + + releaseGcLease(); + await expect( + contentStore.gc('content-session', new Set()), + ).resolves.toMatchObject({ + removed: [ref.contentId], + }); + }); + + it('serializes quota checks across concurrent pins', async () => { + const contentStore = new SessionArtifactContentStore(contentRoot); + const first = await workspaceArtifact('concurrent-a.txt', 'abc'); + const second = await workspaceArtifact('concurrent-b.txt', 'def'); + await writeQuotaManifest('quota-filler', 256 * 1024 * 1024 - 3); + + const results = await Promise.allSettled([ + contentStore.pinWorkspaceFile('content-session', first, workspace), + contentStore.pinWorkspaceFile('content-session', second, workspace), + ]); + + expect( + results.filter((result) => result.status === 'fulfilled'), + ).toHaveLength(1); + expect( + results.filter((result) => result.status === 'rejected'), + ).toHaveLength(1); + }); + + it('reports missing and hash-mismatched retained content', async () => { + const contentStore = new SessionArtifactContentStore(contentRoot); + const artifact = await workspaceArtifact('report.txt', 'hello'); + const ref = (await contentStore.pinWorkspaceFile( + 'content-session', + artifact, + workspace, + ))!; + + await fs.writeFile(path.join(contentRoot, ref.contentId, 'content'), 'bad'); + await expect(contentStore.fsck([ref])).resolves.toMatchObject({ + checked: 1, + hashMismatches: [ref.contentId], + }); + + await fs.rm(path.join(contentRoot, ref.contentId, 'content')); + await expect(contentStore.fsck([ref])).resolves.toMatchObject({ + checked: 1, + missing: [ref.contentId], + }); + }); + + it('validates retained content refs against manifest and content size', async () => { + const contentStore = new SessionArtifactContentStore(contentRoot); + const artifact = await workspaceArtifact('verify.txt', 'hello'); + const ref = (await contentStore.pinWorkspaceFile( + 'content-session', + artifact, + workspace, + ))!; + + await expect( + contentStore.verifyContentRef('content-session', artifact.id, ref), + ).resolves.toBeUndefined(); + + await expect( + contentStore.verifyContentRef('other-session', artifact.id, ref), + ).resolves.toBe('restore_validation_failed'); + + await fs.writeFile( + path.join(contentRoot, ref.contentId, 'content'), + 'HELLO', + ); + await expect( + contentStore.verifyContentRef('content-session', artifact.id, ref), + ).resolves.toBe('content_hash_mismatch'); + await expect(contentStore.fsck([ref])).resolves.toMatchObject({ + hashMismatches: [ref.contentId], + }); + + await fs.writeFile(path.join(contentRoot, ref.contentId, 'content'), 'bad'); + await expect( + contentStore.verifyContentRef('content-session', artifact.id, ref), + ).resolves.toBe('content_hash_mismatch'); + + await fs.rm(path.join(contentRoot, ref.contentId, 'content')); + await fs.symlink( + path.join(workspace, 'reports', 'verify.txt'), + path.join(contentRoot, ref.contentId, 'content'), + ); + await expect( + contentStore.verifyContentRef('content-session', artifact.id, ref), + ).resolves.toBe('content_hash_mismatch'); + + await fs.rm(path.join(contentRoot, ref.contentId, 'content')); + await expect( + contentStore.verifyContentRef('content-session', artifact.id, ref), + ).resolves.toBe('content_missing'); + }); + + it('garbage-collects only unreferenced content for the requested session', async () => { + const contentStore = new SessionArtifactContentStore(contentRoot); + const kept = (await contentStore.pinWorkspaceFile( + 'content-session', + await workspaceArtifact('kept.txt', 'kept'), + workspace, + ))!; + const orphaned = (await contentStore.pinWorkspaceFile( + 'content-session', + await workspaceArtifact('orphaned.txt', 'orphaned'), + workspace, + ))!; + const otherSession = (await contentStore.pinWorkspaceFile( + 'other-session', + await workspaceArtifact('other.txt', 'other'), + workspace, + ))!; + contentStore.releaseContentRef(kept); + contentStore.releaseContentRef(orphaned); + contentStore.releaseContentRef(otherSession); + + await expect( + contentStore.gc('content-session', new Set([kept.contentId])), + ).resolves.toEqual({ + removed: [orphaned.contentId], + retained: expect.arrayContaining([ + kept.contentId, + otherSession.contentId, + ]), + }); + await expect( + fs.access(path.join(contentRoot, orphaned.contentId)), + ).rejects.toMatchObject({ code: 'ENOENT' }); + await expect( + fs.access(path.join(contentRoot, kept.contentId)), + ).resolves.toBeUndefined(); + await expect( + fs.access(path.join(contentRoot, otherSession.contentId)), + ).resolves.toBeUndefined(); + }); }); diff --git a/packages/acp-bridge/src/sessionArtifacts.ts b/packages/acp-bridge/src/sessionArtifacts.ts index d5d0aa1e998..e5c914cc4d1 100644 --- a/packages/acp-bridge/src/sessionArtifacts.ts +++ b/packages/acp-bridge/src/sessionArtifacts.ts @@ -260,12 +260,32 @@ export class SessionArtifactStore { }); } + async getForPin( + artifactId: string, + options: { clientId?: string } = {}, + ): Promise { + return this.enqueue(async () => { + const artifact = this.artifacts.get(artifactId); + if (!artifact) return undefined; + this.denyCrossClientMutation('pin', artifactId, artifact, options); + if ( + artifact.workspacePath && + shouldRefreshWorkspaceStatus(artifact, Date.now()) + ) { + await this.refreshWorkspaceStatus(artifact, { onError: 'missing' }); + } + return toPublicArtifact(artifact); + }); + } + async contentRefs(): Promise { - return this.enqueue(async () => - Array.from(this.artifacts.values()) - .map((artifact) => artifact.contentRef) - .filter((ref): ref is SessionArtifactContentRef => ref !== undefined), - ); + return this.enqueue(async () => this.currentContentRefs()); + } + + async withContentRefsLocked( + operation: (refs: SessionArtifactContentRef[]) => Promise, + ): Promise { + return this.enqueue(async () => operation(this.currentContentRefs())); } async upsertMany( @@ -625,10 +645,14 @@ export class SessionArtifactStore { const changes: SessionArtifactChange[] = []; const expiredIds: string[] = []; for (const [artifactId, existing] of this.artifacts) { + const expiresAtMs = existing.expiresAt + ? Date.parse(existing.expiresAt) + : Number.NaN; if ( - existing.retention !== 'pinned' || + !existing.contentRef || !existing.expiresAt || - Date.parse(existing.expiresAt) > now.getTime() + !Number.isFinite(expiresAtMs) || + expiresAtMs > now.getTime() ) { continue; } @@ -664,7 +688,7 @@ export class SessionArtifactStore { this.restoreState(before); for (const artifactId of expiredIds) { const restored = this.artifacts.get(artifactId); - if (restored?.retention === 'pinned') { + if (restored?.contentRef) { this.artifacts.set(artifactId, { ...restored, persistenceWarning: 'persistence_unavailable', @@ -681,7 +705,7 @@ export class SessionArtifactStore { sessionId: this.sessionId, changes: [], warnings: [ - 'expired pinned artifacts retained because persistence failed', + 'expired artifact content retained because persistence failed', ], }; } @@ -1077,6 +1101,12 @@ export class SessionArtifactStore { return result; } + private currentContentRefs(): SessionArtifactContentRef[] { + return Array.from(this.artifacts.values()) + .map((artifact) => artifact.contentRef) + .filter((ref): ref is SessionArtifactContentRef => ref !== undefined); + } + private denyCrossClientMutation( action: 'remove' | 'pin' | 'unpin', artifactId: string, diff --git a/packages/cli/src/serve/acp-http/dispatch.ts b/packages/cli/src/serve/acp-http/dispatch.ts index 6c26e1c85b3..a615a2dfe8e 100644 --- a/packages/cli/src/serve/acp-http/dispatch.ts +++ b/packages/cli/src/serve/acp-http/dispatch.ts @@ -38,7 +38,12 @@ import { UnsupportedDeviceFlowProviderError, UpstreamDeviceFlowError, } from '../auth/device-flow.js'; -import type { HttpAcpBridge } from '@qwen-code/acp-bridge/bridgeTypes'; +import type { + HttpAcpBridge, + SessionArtifactPinRequest, + SessionArtifactRemoveRequest, + SessionArtifactUnpinRequest, +} from '@qwen-code/acp-bridge/bridgeTypes'; import type { BridgeEvent } from '@qwen-code/acp-bridge/eventBus'; import { SessionShellClientRequiredError, @@ -185,6 +190,10 @@ const ALL_QWEN_VENDOR_METHODS: readonly string[] = [ `${QWEN_METHOD_NS}session/lsp`, `${QWEN_METHOD_NS}session/artifacts`, `${QWEN_METHOD_NS}session/artifacts/add`, + `${QWEN_METHOD_NS}session/artifacts/pin`, + `${QWEN_METHOD_NS}session/artifacts/unpin`, + `${QWEN_METHOD_NS}session/artifacts/fsck`, + `${QWEN_METHOD_NS}session/artifacts/gc`, `${QWEN_METHOD_NS}session/artifacts/remove`, // Wave 1: memory `${QWEN_METHOD_NS}workspace/memory`, @@ -259,6 +268,7 @@ const MAX_NAME_LENGTH = 256; const DEFAULT_FILE_GLOB_MAX_RESULTS = 5000; const MAX_FILE_GLOB_MAX_RESULTS = 50_000; const MAX_FILE_LINE_LIMIT = 2000; +const SESSION_ARTIFACT_MAX_TTL_DAYS = 365; class AcpParamError extends Error {} @@ -433,6 +443,68 @@ function pickSessionArtifactInput( } as AddSessionArtifactInput; } +function pickSessionArtifactPinRequest( + params: Record, +): SessionArtifactPinRequest { + const request: SessionArtifactPinRequest = {}; + if (params['mode'] !== undefined) { + const mode = params['mode']; + if (mode !== 'metadata' && mode !== 'content') { + throw new AcpParamError('`mode` must be "metadata" or "content"'); + } + request.mode = mode; + } + if (params['ttlDays'] !== undefined) { + const ttlDays = params['ttlDays']; + if (request.mode === 'metadata') { + throw new AcpParamError('`ttlDays` is only valid with content pinning'); + } + if ( + typeof ttlDays !== 'number' || + !Number.isSafeInteger(ttlDays) || + ttlDays <= 0 + ) { + throw new AcpParamError('`ttlDays` must be a positive safe integer'); + } + if (ttlDays > SESSION_ARTIFACT_MAX_TTL_DAYS) { + throw new AcpParamError( + `\`ttlDays\` must be at most ${SESSION_ARTIFACT_MAX_TTL_DAYS}`, + ); + } + request.ttlDays = ttlDays; + } + if (params['clientRetained'] !== undefined) { + const clientRetained = params['clientRetained']; + if (typeof clientRetained !== 'boolean') { + throw new AcpParamError('`clientRetained` must be a boolean'); + } + request.clientRetained = clientRetained; + } + return request; +} + +function pickSessionArtifactUnpinRequest( + params: Record, +): SessionArtifactUnpinRequest { + const retention = params['retention']; + if (retention === undefined) return {}; + if (retention !== 'ephemeral' && retention !== 'restorable') { + throw new AcpParamError('`retention` must be "ephemeral" or "restorable"'); + } + return { retention }; +} + +function pickSessionArtifactRemoveRequest( + params: Record, +): SessionArtifactRemoveRequest { + const deleteContent = params['deleteContent']; + if (deleteContent === undefined) return {}; + if (typeof deleteContent !== 'boolean') { + throw new AcpParamError('`deleteContent` must be a boolean'); + } + return { deleteContent }; +} + /** * Map a thrown error to a JSON-RPC error code + a client-safe message. * Param-validation errors are echoed (they describe the client's own bad @@ -2558,6 +2630,75 @@ export class AcpDispatcher { return; } + case `${QWEN_METHOD_NS}session/artifacts/pin`: { + const sessionId = String(params['sessionId'] ?? ''); + await this.withMutableOwned(conn, sessionId, id, async () => { + const artifactId = String(params['artifactId'] ?? ''); + if (!artifactId) { + if (id !== undefined) { + conn.sendConn( + error(id, RPC.INVALID_PARAMS, '`artifactId` is required'), + ); + } + return; + } + const result = await this.bridge.pinSessionArtifact( + sessionId, + artifactId, + this.sessionCtx(conn, sessionId, loopback), + pickSessionArtifactPinRequest(params), + ); + this.replyConn(conn, id, result as unknown); + }); + return; + } + + case `${QWEN_METHOD_NS}session/artifacts/unpin`: { + const sessionId = String(params['sessionId'] ?? ''); + await this.withMutableOwned(conn, sessionId, id, async () => { + const artifactId = String(params['artifactId'] ?? ''); + if (!artifactId) { + if (id !== undefined) { + conn.sendConn( + error(id, RPC.INVALID_PARAMS, '`artifactId` is required'), + ); + } + return; + } + const result = await this.bridge.unpinSessionArtifact( + sessionId, + artifactId, + this.sessionCtx(conn, sessionId, loopback), + pickSessionArtifactUnpinRequest(params), + ); + this.replyConn(conn, id, result as unknown); + }); + return; + } + + case `${QWEN_METHOD_NS}session/artifacts/fsck`: { + const sessionId = String(params['sessionId'] ?? ''); + if (!this.requireOwned(conn, sessionId, id)) return; + const result = await this.bridge.fsckSessionArtifacts( + sessionId, + this.sessionCtx(conn, sessionId, loopback), + ); + this.replyConn(conn, id, result as unknown); + return; + } + + case `${QWEN_METHOD_NS}session/artifacts/gc`: { + const sessionId = String(params['sessionId'] ?? ''); + await this.withMutableOwned(conn, sessionId, id, async () => { + const result = await this.bridge.gcSessionArtifacts( + sessionId, + this.sessionCtx(conn, sessionId, loopback), + ); + this.replyConn(conn, id, result as unknown); + }); + return; + } + case `${QWEN_METHOD_NS}session/artifacts/remove`: { const sessionId = String(params['sessionId'] ?? ''); await this.withMutableOwned(conn, sessionId, id, async () => { @@ -2574,6 +2715,7 @@ export class AcpDispatcher { sessionId, artifactId, this.sessionCtx(conn, sessionId, loopback), + pickSessionArtifactRemoveRequest(params), ); this.replyConn(conn, id, result as unknown); }); diff --git a/packages/cli/src/serve/acp-http/index.ts b/packages/cli/src/serve/acp-http/index.ts index 1d81c1d5260..cde84714597 100644 --- a/packages/cli/src/serve/acp-http/index.ts +++ b/packages/cli/src/serve/acp-http/index.ts @@ -189,6 +189,7 @@ const WS_READ_METHODS = new Set([ '_qwen/session/tasks', '_qwen/session/lsp', '_qwen/session/artifacts', + '_qwen/session/artifacts/fsck', '_qwen/workspace/mcp', '_qwen/workspace/skills', '_qwen/workspace/providers', diff --git a/packages/cli/src/serve/acp-http/transport.test.ts b/packages/cli/src/serve/acp-http/transport.test.ts index c92acf8daf5..1aeadd5bc44 100644 --- a/packages/cli/src/serve/acp-http/transport.test.ts +++ b/packages/cli/src/serve/acp-http/transport.test.ts @@ -404,8 +404,33 @@ class FakeBridge { sessionId: string; artifactId: string; context: Parameters[2]; + options: Parameters[3]; } | undefined; + lastPinnedArtifact: + | { + sessionId: string; + artifactId: string; + context: Parameters[2]; + options: Parameters[3]; + } + | undefined; + lastUnpinnedArtifact: + | { + sessionId: string; + artifactId: string; + context: Parameters[2]; + options: Parameters[3]; + } + | undefined; + lastFsckSessionId: string | undefined; + lastFsckSessionContext: + | Parameters[1] + | undefined; + lastGcSessionId: string | undefined; + lastGcSessionContext: + | Parameters[1] + | undefined; async getSessionArtifacts(sessionId: string) { this.lastArtifactListSessionId = sessionId; return { @@ -428,14 +453,61 @@ class FakeBridge { sessionId: string, artifactId: string, context: Parameters[2], + options?: Parameters[3], ) { - this.lastRemovedArtifact = { sessionId, artifactId, context }; + this.lastRemovedArtifact = { sessionId, artifactId, context, options }; return { v: 1, sessionId, changes: [{ action: 'removed' as const, artifactId, reason: 'explicit' }], }; } + async pinSessionArtifact( + sessionId: string, + artifactId: string, + context: Parameters[2], + options?: Parameters[3], + ) { + this.lastPinnedArtifact = { sessionId, artifactId, context, options }; + return { + v: 1, + sessionId, + changes: [{ action: 'updated' as const, artifactId }], + }; + } + async unpinSessionArtifact( + sessionId: string, + artifactId: string, + context: Parameters[2], + options?: Parameters[3], + ) { + this.lastUnpinnedArtifact = { sessionId, artifactId, context, options }; + return { + v: 1, + sessionId, + changes: [{ action: 'updated' as const, artifactId }], + }; + } + async fsckSessionArtifacts( + sessionId: string, + context?: Parameters[1], + ) { + this.lastFsckSessionId = sessionId; + this.lastFsckSessionContext = context; + return { + checked: 0, + missing: [] as string[], + hashMismatches: [] as string[], + }; + } + async gcSessionArtifacts( + sessionId: string, + context?: Parameters[1], + ) { + this.lastGcSessionId = sessionId; + this.lastGcSessionContext = context; + return { removed: [] as string[], retained: [] as string[] }; + } async getWorkspaceToolsStatus() { return { v: 1, tools: [] }; } @@ -6073,6 +6145,7 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { params: { sessionId: 'sess-1', artifactId: 'artifact-1', + deleteContent: true, }, }); const frames = await takeFrames(await streamRes, 2); @@ -6092,6 +6165,7 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { expect(bridge.lastRemovedArtifact).toMatchObject({ sessionId: 'sess-1', artifactId: 'artifact-1', + options: { deleteContent: true }, }); }); @@ -6122,6 +6196,37 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { expect(bridge.lastRemovedArtifact).toBeUndefined(); }); + it('_qwen/session/artifacts/remove rejects invalid deleteContent', async () => { + const connId = await initialize(); + const streamRes = openStream(connId); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 99, + method: 'session/new', + params: {}, + }); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 60, + method: '_qwen/session/artifacts/remove', + params: { + sessionId: 'sess-1', + artifactId: 'artifact-1', + deleteContent: 'yes', + }, + }); + const frames = await takeFrames(await streamRes, 2); + expect(frames[1]).toMatchObject({ + error: { + code: -32602, + message: '`deleteContent` must be a boolean', + }, + }); + expect(bridge.lastRemovedArtifact).toBeUndefined(); + }); + it('_qwen/session/artifacts/remove maps artifact authorization errors', async () => { bridge.removeSessionArtifact = async () => { throw new SessionArtifactAuthorizationError( @@ -6162,6 +6267,260 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { }); }); + it('_qwen/session/artifacts/pin forwards artifact id', async () => { + const connId = await initialize(); + const streamRes = openStream(connId); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 99, + method: 'session/new', + params: {}, + }); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 61, + method: '_qwen/session/artifacts/pin', + params: { + sessionId: 'sess-1', + artifactId: 'artifact-1', + mode: 'content', + ttlDays: 7, + clientRetained: false, + }, + }); + const frames = await takeFrames(await streamRes, 2); + expect(frames[1]).toMatchObject({ + result: { + v: 1, + sessionId: 'sess-1', + changes: [{ action: 'updated', artifactId: 'artifact-1' }], + }, + }); + expect(bridge.lastPinnedArtifact).toMatchObject({ + sessionId: 'sess-1', + artifactId: 'artifact-1', + options: { mode: 'content', ttlDays: 7, clientRetained: false }, + }); + }); + + it('_qwen/session/artifacts/pin rejects invalid retention options', async () => { + const connId = await initialize(); + const streamRes = openStream(connId); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 99, + method: 'session/new', + params: {}, + }); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 61, + method: '_qwen/session/artifacts/pin', + params: { + sessionId: 'sess-1', + artifactId: 'artifact-1', + mode: 'forever', + ttlDays: '7', + clientRetained: 'false', + }, + }); + const frames = await takeFrames(await streamRes, 2); + expect(frames[1]).toMatchObject({ + error: { + code: -32602, + message: '`mode` must be "metadata" or "content"', + }, + }); + expect(bridge.lastPinnedArtifact).toBeUndefined(); + }); + + it.each([ + [ + 'metadata ttl', + { mode: 'metadata', ttlDays: 7 }, + '`ttlDays` is only valid with content pinning', + ], + [ + 'zero ttl', + { mode: 'content', ttlDays: 0 }, + '`ttlDays` must be a positive safe integer', + ], + [ + 'oversized ttl', + { mode: 'content', ttlDays: 366 }, + '`ttlDays` must be at most 365', + ], + ])( + '_qwen/session/artifacts/pin rejects %s', + async (_label, options, message) => { + const connId = await initialize(); + const streamRes = openStream(connId); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 99, + method: 'session/new', + params: {}, + }); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 61, + method: '_qwen/session/artifacts/pin', + params: { + sessionId: 'sess-1', + artifactId: 'artifact-1', + ...options, + }, + }); + const frames = await takeFrames(await streamRes, 2); + expect(frames[1]).toMatchObject({ + error: { + code: -32602, + message, + }, + }); + expect(bridge.lastPinnedArtifact).toBeUndefined(); + }, + ); + + it('_qwen/session/artifacts/unpin forwards artifact id', async () => { + const connId = await initialize(); + const streamRes = openStream(connId); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 99, + method: 'session/new', + params: {}, + }); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 62, + method: '_qwen/session/artifacts/unpin', + params: { + sessionId: 'sess-1', + artifactId: 'artifact-1', + retention: 'ephemeral', + }, + }); + const frames = await takeFrames(await streamRes, 2); + expect(frames[1]).toMatchObject({ + result: { + v: 1, + sessionId: 'sess-1', + changes: [{ action: 'updated', artifactId: 'artifact-1' }], + }, + }); + expect(bridge.lastUnpinnedArtifact).toMatchObject({ + sessionId: 'sess-1', + artifactId: 'artifact-1', + options: { retention: 'ephemeral' }, + }); + }); + + it('_qwen/session/artifacts/unpin rejects invalid retention', async () => { + const connId = await initialize(); + const streamRes = openStream(connId); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 99, + method: 'session/new', + params: {}, + }); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 62, + method: '_qwen/session/artifacts/unpin', + params: { + sessionId: 'sess-1', + artifactId: 'artifact-1', + retention: 'pinned', + }, + }); + const frames = await takeFrames(await streamRes, 2); + expect(frames[1]).toMatchObject({ + error: { + code: -32602, + message: '`retention` must be "ephemeral" or "restorable"', + }, + }); + expect(bridge.lastUnpinnedArtifact).toBeUndefined(); + }); + + it('_qwen/session/artifacts/fsck returns integrity status', async () => { + bridge.fsckSessionArtifacts = async (sessionId, context) => { + bridge.lastFsckSessionId = sessionId; + bridge.lastFsckSessionContext = context; + return { checked: 1, missing: ['missing'], hashMismatches: [] }; + }; + const connId = await initialize(); + const streamRes = openStream(connId); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 99, + method: 'session/new', + params: {}, + }); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 63, + method: '_qwen/session/artifacts/fsck', + params: { sessionId: 'sess-1' }, + }); + const frames = await takeFrames(await streamRes, 2); + expect(frames[1]).toMatchObject({ + result: { checked: 1, missing: ['missing'], hashMismatches: [] }, + }); + expect(bridge.lastFsckSessionId).toBe('sess-1'); + expect(bridge.lastFsckSessionContext).toEqual({ + clientId: 'client-1', + fromLoopback: true, + }); + }); + + it('_qwen/session/artifacts/gc returns cleanup result', async () => { + bridge.gcSessionArtifacts = async (sessionId, context) => { + bridge.lastGcSessionId = sessionId; + bridge.lastGcSessionContext = context; + return { removed: ['old'], retained: ['kept'] }; + }; + const connId = await initialize(); + const streamRes = openStream(connId); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 99, + method: 'session/new', + params: {}, + }); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 64, + method: '_qwen/session/artifacts/gc', + params: { sessionId: 'sess-1' }, + }); + const frames = await takeFrames(await streamRes, 2); + expect(frames[1]).toMatchObject({ + result: { removed: ['old'], retained: ['kept'] }, + }); + expect(bridge.lastGcSessionId).toBe('sess-1'); + expect(bridge.lastGcSessionContext).toEqual({ + clientId: 'client-1', + fromLoopback: true, + }); + }); + it('_qwen/session/artifacts/add holds the archive gate while mutating', async () => { await withRuntimeDir(async () => { const sessionId = '550e8400-e29b-41d4-a716-446655440131'; @@ -6243,11 +6602,13 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { sessionId, artifactId, context, + options, ) => { bridge.lastRemovedArtifact = { sessionId, artifactId, context, + options, }; removeStarted(); await removeReleasedPromise; diff --git a/packages/cli/src/serve/acp-session-bridge.ts b/packages/cli/src/serve/acp-session-bridge.ts index ace689254c5..d0873d437b9 100644 --- a/packages/cli/src/serve/acp-session-bridge.ts +++ b/packages/cli/src/serve/acp-session-bridge.ts @@ -85,6 +85,9 @@ export type { BridgeDaemonStatusSnapshot, AcpSessionBridge, HttpAcpBridge, + SessionArtifactPinRequest, + SessionArtifactRemoveRequest, + SessionArtifactUnpinRequest, } from '@qwen-code/acp-bridge/bridgeTypes'; export { diff --git a/packages/cli/src/serve/capabilities.ts b/packages/cli/src/serve/capabilities.ts index cfaf71afd86..f71d1fe7126 100644 --- a/packages/cli/src/serve/capabilities.ts +++ b/packages/cli/src/serve/capabilities.ts @@ -46,6 +46,7 @@ export const SERVE_CAPABILITY_REGISTRY = { session_events: { since: 'v1' }, session_artifacts: { since: 'v1' }, session_artifacts_persistence: { since: 'v1' }, + session_artifacts_content_retention: { since: 'v1' }, // Daemon emits `slow_client_warning` synthetic frames at 75% queue // fill and honors `?maxQueued=N` (range [16, 2048]) on // `GET /session/:id/events`. Old daemons silently lack both — SDK @@ -304,6 +305,7 @@ export interface AdvertiseFeatureToggles { voiceTranscriptionAvailable?: boolean; sessionShellCommandEnabled?: boolean; sessionArtifactsPersistenceAvailable?: boolean; + sessionArtifactsContentRetentionAvailable?: boolean; rateLimit?: boolean; reloadAvailable?: boolean; /** @@ -385,6 +387,10 @@ export const CONDITIONAL_SERVE_FEATURES: ReadonlyMap< 'session_artifacts_persistence', (toggles) => toggles.sessionArtifactsPersistenceAvailable === true, ], + [ + 'session_artifacts_content_retention', + (toggles) => toggles.sessionArtifactsContentRetentionAvailable === true, + ], ['rate_limit', (toggles) => toggles.rateLimit === true], ['workspace_reload', (toggles) => toggles.reloadAvailable === true], ['client_mcp_over_ws', (toggles) => toggles.clientMcpOverWsEnabled === true], diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index 52a606a9f8a..bfca3428b09 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -27,6 +27,9 @@ import { SessionShellClientRequiredError, SessionShellDisabledError, type AcpSessionBridge, + type SessionArtifactPinRequest, + type SessionArtifactRemoveRequest, + type SessionArtifactUnpinRequest, } from '../acp-session-bridge.js'; import type { DaemonLogger } from '../daemon-logger.js'; import type { SendBridgeError } from '../server/error-response.js'; @@ -73,6 +76,8 @@ interface RegisterSessionRoutesDeps { languageCodes: string[]; } +const SESSION_ARTIFACT_MAX_TTL_DAYS = 365; + function requireSessionArtifactClientId( clientId: string | undefined, res: Response, @@ -101,6 +106,108 @@ function sendArtifactValidationError(res: Response, err: unknown): boolean { return true; } +function parseArtifactPinRequest(req: Request): SessionArtifactPinRequest { + const body = safeBody(req); + const mode = body['mode']; + const ttlDays = body['ttlDays']; + const clientRetained = body['clientRetained']; + const options: SessionArtifactPinRequest = {}; + if (mode !== undefined) { + if (mode !== 'metadata' && mode !== 'content') { + throw new SessionArtifactValidationError( + 'mode must be "metadata" or "content"', + 'mode', + ); + } + options.mode = mode; + } + if (ttlDays !== undefined) { + if (mode === 'metadata') { + throw new SessionArtifactValidationError( + 'ttlDays is only valid with content pinning', + 'ttlDays', + ); + } + if ( + typeof ttlDays !== 'number' || + !Number.isSafeInteger(ttlDays) || + ttlDays <= 0 + ) { + throw new SessionArtifactValidationError( + 'ttlDays must be a positive safe integer', + 'ttlDays', + ); + } + if (ttlDays > SESSION_ARTIFACT_MAX_TTL_DAYS) { + throw new SessionArtifactValidationError( + `ttlDays must be at most ${SESSION_ARTIFACT_MAX_TTL_DAYS}`, + 'ttlDays', + ); + } + options.ttlDays = ttlDays; + } + if (clientRetained !== undefined) { + if (typeof clientRetained !== 'boolean') { + throw new SessionArtifactValidationError( + 'clientRetained must be a boolean', + 'clientRetained', + ); + } + options.clientRetained = clientRetained; + } + return options; +} + +function parseArtifactRemoveRequest( + req: Request, +): SessionArtifactRemoveRequest { + const body = safeBody(req); + const deleteContent = body['deleteContent']; + if (deleteContent === undefined) { + return {}; + } + if (typeof deleteContent !== 'boolean') { + throw new SessionArtifactValidationError( + 'deleteContent must be a boolean', + 'deleteContent', + ); + } + return { deleteContent }; +} + +function parseArtifactUnpinRequest(req: Request): SessionArtifactUnpinRequest { + const body = safeBody(req); + const retention = body['retention']; + if (retention === undefined) { + return {}; + } + if (retention !== 'ephemeral' && retention !== 'restorable') { + throw new SessionArtifactValidationError( + 'retention must be "ephemeral" or "restorable"', + 'retention', + ); + } + return { retention }; +} + +function nonEmptyArtifactPinRequest( + options: SessionArtifactPinRequest, +): SessionArtifactPinRequest | undefined { + return Object.keys(options).length === 0 ? undefined : options; +} + +function nonEmptyArtifactRemoveRequest( + options: SessionArtifactRemoveRequest, +): SessionArtifactRemoveRequest | undefined { + return Object.keys(options).length === 0 ? undefined : options; +} + +function nonEmptyArtifactUnpinRequest( + options: SessionArtifactUnpinRequest, +): SessionArtifactUnpinRequest | undefined { + return Object.keys(options).length === 0 ? undefined : options; +} + function sendSessionOrganizationError(res: Response, err: unknown): boolean { if (!(err instanceof SessionOrganizationError)) { return false; @@ -696,6 +803,129 @@ export function registerSessionRoutes( ), ); + app.post( + '/session/:id/artifacts/:artifactId/pin', + mutate({ strict: true }), + withMutableSession( + 'POST /session/:id/artifacts/:artifactId/pin', + async (req, res, sessionId) => { + const artifactId = req.params['artifactId']; + const clientId = parseClientIdHeader(req, res); + if (clientId === null) return; + if (!requireSessionArtifactClientId(clientId, res)) return; + if (!artifactId) { + res.status(400).json({ + v: 1, + error: { + code: 'VALIDATION_FAILED', + message: '`artifactId` route parameter is required', + field: 'artifactId', + }, + }); + return; + } + try { + const options = parseArtifactPinRequest(req); + const result = await bridge.pinSessionArtifact( + sessionId, + artifactId, + { clientId }, + nonEmptyArtifactPinRequest(options), + ); + res.status(200).json(result); + } catch (err) { + if (sendArtifactValidationError(res, err)) return; + sendBridgeError(res, err, { + route: 'POST /session/:id/artifacts/:artifactId/pin', + sessionId, + }); + } + }, + ), + ); + + app.delete( + '/session/:id/artifacts/:artifactId/pin', + mutate({ strict: true }), + withMutableSession( + 'DELETE /session/:id/artifacts/:artifactId/pin', + async (req, res, sessionId) => { + const artifactId = req.params['artifactId']; + const clientId = parseClientIdHeader(req, res); + if (clientId === null) return; + if (!requireSessionArtifactClientId(clientId, res)) return; + if (!artifactId) { + res.status(400).json({ + v: 1, + error: { + code: 'VALIDATION_FAILED', + message: '`artifactId` route parameter is required', + field: 'artifactId', + }, + }); + return; + } + try { + const options = parseArtifactUnpinRequest(req); + const result = await bridge.unpinSessionArtifact( + sessionId, + artifactId, + { clientId }, + nonEmptyArtifactUnpinRequest(options), + ); + res.status(200).json(result); + } catch (err) { + if (sendArtifactValidationError(res, err)) return; + sendBridgeError(res, err, { + route: 'DELETE /session/:id/artifacts/:artifactId/pin', + sessionId, + }); + } + }, + ), + ); + + app.get('/session/:id/artifacts/fsck', async (req, res) => { + const sessionId = requireSessionId(req, res); + if (sessionId === null) return; + const clientId = parseClientIdHeader(req, res); + if (clientId === null) return; + if (!requireSessionArtifactClientId(clientId, res)) return; + try { + res + .status(200) + .json(await bridge.fsckSessionArtifacts(sessionId, { clientId })); + } catch (err) { + sendBridgeError(res, err, { + route: 'GET /session/:id/artifacts/fsck', + sessionId, + }); + } + }); + + app.post( + '/session/:id/artifacts/gc', + mutate({ strict: true }), + withMutableSession( + 'POST /session/:id/artifacts/gc', + async (req, res, sessionId) => { + const clientId = parseClientIdHeader(req, res); + if (clientId === null) return; + if (!requireSessionArtifactClientId(clientId, res)) return; + try { + res + .status(200) + .json(await bridge.gcSessionArtifacts(sessionId, { clientId })); + } catch (err) { + sendBridgeError(res, err, { + route: 'POST /session/:id/artifacts/gc', + sessionId, + }); + } + }, + ), + ); + app.delete( '/session/:id/artifacts/:artifactId', mutate({ strict: true }), @@ -718,10 +948,12 @@ export function registerSessionRoutes( return; } try { + const options = parseArtifactRemoveRequest(req); const result = await bridge.removeSessionArtifact( sessionId, artifactId, { clientId }, + nonEmptyArtifactRemoveRequest(options), ); res.status(200).json(result); } catch (err) { diff --git a/packages/cli/src/serve/run-qwen-serve.ts b/packages/cli/src/serve/run-qwen-serve.ts index 43847dfb2aa..c611890cc4d 100644 --- a/packages/cli/src/serve/run-qwen-serve.ts +++ b/packages/cli/src/serve/run-qwen-serve.ts @@ -806,6 +806,7 @@ function currentServeFeaturesForRunQwenServe( persistSettingAvailable: true, sessionShellCommandEnabled, sessionArtifactsPersistenceAvailable: true, + sessionArtifactsContentRetentionAvailable: true, rateLimit: opts.rateLimit === true, reloadAvailable: true, // Advertise the same WS feature flags as the runtime path (serve-features.ts) diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 6c745435e9b..8127e41b54c 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -296,7 +296,11 @@ const EXPECTED_REGISTERED_FEATURES = [ // stage1 order. ...EXPECTED_STAGE1_FEATURES.flatMap((feature) => feature === 'session_artifacts' - ? [feature, 'session_artifacts_persistence'] + ? [ + feature, + 'session_artifacts_persistence', + 'session_artifacts_content_retention', + ] : [feature], ).filter( (f) => @@ -418,6 +422,10 @@ interface FakeBridgeOpts { getSessionArtifactsImpl?: AcpSessionBridge['getSessionArtifacts']; addSessionArtifactImpl?: AcpSessionBridge['addSessionArtifact']; removeSessionArtifactImpl?: AcpSessionBridge['removeSessionArtifact']; + pinSessionArtifactImpl?: AcpSessionBridge['pinSessionArtifact']; + unpinSessionArtifactImpl?: AcpSessionBridge['unpinSessionArtifact']; + fsckSessionArtifactsImpl?: AcpSessionBridge['fsckSessionArtifacts']; + gcSessionArtifactsImpl?: AcpSessionBridge['gcSessionArtifacts']; workspaceMcpImpl?: () => Promise; workspaceMcpToolsImpl?: ( serverName: string, @@ -647,6 +655,27 @@ interface FakeBridge extends AcpSessionBridge { sessionId: string; artifactId: string; context?: BridgeClientRequestContext; + options?: Parameters[3]; + }>; + pinSessionArtifactCalls: Array<{ + sessionId: string; + artifactId: string; + context?: BridgeClientRequestContext; + options?: Parameters[3]; + }>; + unpinSessionArtifactCalls: Array<{ + sessionId: string; + artifactId: string; + context?: BridgeClientRequestContext; + options?: Parameters[3]; + }>; + fsckSessionArtifactsCalls: Array<{ + sessionId: string; + context?: BridgeClientRequestContext; + }>; + gcSessionArtifactsCalls: Array<{ + sessionId: string; + context?: BridgeClientRequestContext; }>; workspaceMcpCalls: number; workspaceMcpToolsCalls: string[]; @@ -796,6 +825,10 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { const addSessionArtifactCalls: FakeBridge['addSessionArtifactCalls'] = []; const removeSessionArtifactCalls: FakeBridge['removeSessionArtifactCalls'] = []; + const pinSessionArtifactCalls: FakeBridge['pinSessionArtifactCalls'] = []; + const unpinSessionArtifactCalls: FakeBridge['unpinSessionArtifactCalls'] = []; + const fsckSessionArtifactsCalls: FakeBridge['fsckSessionArtifactsCalls'] = []; + const gcSessionArtifactsCalls: FakeBridge['gcSessionArtifactsCalls'] = []; let workspaceMcpCalls = 0; const workspaceMcpToolsCalls: string[] = []; const workspaceMcpResourcesCalls: string[] = []; @@ -943,6 +976,26 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { }, ], })); + const pinSessionArtifactImpl = + opts.pinSessionArtifactImpl ?? + ((sessionId, artifactId) => ({ + v: 1 as const, + sessionId, + changes: [{ action: 'updated' as const, artifactId }], + })); + const unpinSessionArtifactImpl = + opts.unpinSessionArtifactImpl ?? + ((sessionId, artifactId) => ({ + v: 1 as const, + sessionId, + changes: [{ action: 'updated' as const, artifactId }], + })); + const fsckSessionArtifactsImpl = + opts.fsckSessionArtifactsImpl ?? + (async () => ({ checked: 0, missing: [], hashMismatches: [] })); + const gcSessionArtifactsImpl = + opts.gcSessionArtifactsImpl ?? + (async () => ({ removed: [], retained: [] })); const workspaceMcpImpl = opts.workspaceMcpImpl ?? (async () => ({ @@ -1296,6 +1349,10 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { sessionArtifactsCalls, addSessionArtifactCalls, removeSessionArtifactCalls, + pinSessionArtifactCalls, + unpinSessionArtifactCalls, + fsckSessionArtifactsCalls, + gcSessionArtifactsCalls, workspaceMcpToolsCalls, workspaceMcpResourcesCalls, extensionEvents, @@ -1464,13 +1521,46 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { }); return addSessionArtifactImpl(sessionId, artifact, context); }, - async removeSessionArtifact(sessionId, artifactId, context) { + async removeSessionArtifact(sessionId, artifactId, context, options) { removeSessionArtifactCalls.push({ sessionId, artifactId, ...(context ? { context } : {}), + ...(options ? { options } : {}), + }); + return removeSessionArtifactImpl(sessionId, artifactId, context, options); + }, + async pinSessionArtifact(sessionId, artifactId, context, options) { + pinSessionArtifactCalls.push({ + sessionId, + artifactId, + ...(context ? { context } : {}), + ...(options ? { options } : {}), + }); + return pinSessionArtifactImpl(sessionId, artifactId, context, options); + }, + async unpinSessionArtifact(sessionId, artifactId, context, options) { + unpinSessionArtifactCalls.push({ + sessionId, + artifactId, + ...(context ? { context } : {}), + ...(options ? { options } : {}), + }); + return unpinSessionArtifactImpl(sessionId, artifactId, context, options); + }, + async fsckSessionArtifacts(sessionId, context) { + fsckSessionArtifactsCalls.push({ + sessionId, + ...(context ? { context } : {}), + }); + return fsckSessionArtifactsImpl(sessionId, context); + }, + async gcSessionArtifacts(sessionId, context) { + gcSessionArtifactsCalls.push({ + sessionId, + ...(context ? { context } : {}), }); - return removeSessionArtifactImpl(sessionId, artifactId, context); + return gcSessionArtifactsImpl(sessionId, context); }, async getWorkspaceMcpStatus() { workspaceMcpCalls += 1; @@ -2060,6 +2150,24 @@ describe('createServeApp', () => { ); continue; } + if (feature === 'session_artifacts_content_retention') { + expect( + predicate({ sessionArtifactsContentRetentionAvailable: true }), + ).toBe(true); + expect( + predicate({ sessionArtifactsContentRetentionAvailable: false }), + ).toBe(false); + expect(predicate({})).toBe(false); + expect( + getAdvertisedServeFeatures(undefined, { + sessionArtifactsContentRetentionAvailable: true, + }), + ).toContain(feature); + expect(getAdvertisedServeFeatures(undefined, {})).not.toContain( + feature, + ); + continue; + } if (feature === 'workspace_reload') { expect(predicate({ reloadAvailable: true })).toBe(true); expect(predicate({ reloadAvailable: false })).toBe(false); @@ -2462,6 +2570,7 @@ describe('createServeApp', () => { getAdvertisedServeFeatures(undefined, { mcpPoolActive: true, sessionArtifactsPersistenceAvailable: true, + sessionArtifactsContentRetentionAvailable: true, }), ); expect(res.body.modelServices).toEqual([]); @@ -7684,6 +7793,32 @@ describe('createServeApp', () => { expect(bridge.sessionArtifactsCalls).toEqual([]); }); + it('POST /session/:id/artifacts/:artifactId/pin requires a client id', async () => { + const bridge = fakeBridge(); + const app = createServeApp(tokenOpts, undefined, { bridge }); + + const res = await auth( + request(app).post('/session/session-A/artifacts/artifact-1/pin'), + ); + + expect(res.status).toBe(403); + expect(res.body.code).toBe('client_id_required'); + expect(bridge.pinSessionArtifactCalls).toEqual([]); + }); + + it('DELETE /session/:id/artifacts/:artifactId/pin requires a client id', async () => { + const bridge = fakeBridge(); + const app = createServeApp(tokenOpts, undefined, { bridge }); + + const res = await auth( + request(app).delete('/session/session-A/artifacts/artifact-1/pin'), + ); + + expect(res.status).toBe(403); + expect(res.body.code).toBe('client_id_required'); + expect(bridge.unpinSessionArtifactCalls).toEqual([]); + }); + it('GET /session/:id/artifacts returns 404 for an unknown session', async () => { const bridge = fakeBridge({ getSessionArtifactsImpl: async (sessionId) => { @@ -7844,12 +7979,14 @@ describe('createServeApp', () => { ]); }); - it('DELETE /session/:id/artifacts/:artifactId forwards client context', async () => { + it('DELETE /session/:id/artifacts/:artifactId forwards content deletion option', async () => { const bridge = fakeBridge(); const app = createServeApp(tokenOpts, undefined, { bridge }); const res = await auth( - request(app).delete('/session/session-A/artifacts/artifact-1'), + request(app) + .delete('/session/session-A/artifacts/artifact-1') + .send({ deleteContent: true }), ).set('X-Qwen-Client-Id', 'client-1'); expect(res.status).toBe(200); @@ -7858,10 +7995,54 @@ describe('createServeApp', () => { sessionId: 'session-A', artifactId: 'artifact-1', context: { clientId: 'client-1' }, + options: { deleteContent: true }, }, ]); }); + it('DELETE /session/:id/artifacts/:artifactId forwards explicit content retention option', async () => { + const bridge = fakeBridge(); + const app = createServeApp(tokenOpts, undefined, { bridge }); + + const res = await auth( + request(app) + .delete('/session/session-A/artifacts/artifact-1') + .send({ deleteContent: false }), + ).set('X-Qwen-Client-Id', 'client-1'); + + expect(res.status).toBe(200); + expect(bridge.removeSessionArtifactCalls).toEqual([ + { + sessionId: 'session-A', + artifactId: 'artifact-1', + context: { clientId: 'client-1' }, + options: { deleteContent: false }, + }, + ]); + }); + + it('DELETE /session/:id/artifacts/:artifactId maps artifact validation errors', async () => { + const bridge = fakeBridge(); + const app = createServeApp(tokenOpts, undefined, { bridge }); + + const res = await auth( + request(app) + .delete('/session/session-A/artifacts/artifact-1') + .send({ deleteContent: 'yes' }), + ).set('X-Qwen-Client-Id', 'client-1'); + + expect(res.status).toBe(400); + expect(res.body).toEqual({ + v: 1, + error: { + code: 'VALIDATION_FAILED', + message: 'deleteContent must be a boolean', + field: 'deleteContent', + }, + }); + expect(bridge.removeSessionArtifactCalls).toEqual([]); + }); + it('DELETE /session/:id/artifacts/:artifactId maps artifact authorization errors', async () => { const bridge = fakeBridge({ removeSessionArtifactImpl: async () => { @@ -7887,6 +8068,208 @@ describe('createServeApp', () => { artifactId: 'artifact-1', }); }); + + it('POST /session/:id/artifacts/:artifactId/pin forwards mutation auth context', async () => { + const bridge = fakeBridge(); + const app = createServeApp(tokenOpts, undefined, { bridge }); + + const res = await auth( + request(app).post('/session/session-A/artifacts/artifact-1/pin'), + ).set('X-Qwen-Client-Id', 'client-1'); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + v: 1, + sessionId: 'session-A', + changes: [{ action: 'updated', artifactId: 'artifact-1' }], + }); + expect(bridge.pinSessionArtifactCalls).toEqual([ + { + sessionId: 'session-A', + artifactId: 'artifact-1', + context: { clientId: 'client-1' }, + }, + ]); + }); + + it('POST /session/:id/artifacts/:artifactId/pin forwards retention options', async () => { + const bridge = fakeBridge(); + const app = createServeApp(tokenOpts, undefined, { bridge }); + + const res = await auth( + request(app) + .post('/session/session-A/artifacts/artifact-1/pin') + .send({ mode: 'content', ttlDays: 7, clientRetained: false }), + ).set('X-Qwen-Client-Id', 'client-1'); + + expect(res.status).toBe(200); + expect(bridge.pinSessionArtifactCalls).toEqual([ + { + sessionId: 'session-A', + artifactId: 'artifact-1', + context: { clientId: 'client-1' }, + options: { mode: 'content', ttlDays: 7, clientRetained: false }, + }, + ]); + }); + + it('POST /session/:id/artifacts/:artifactId/pin rejects ttlDays for metadata pinning', async () => { + const bridge = fakeBridge(); + const app = createServeApp(tokenOpts, undefined, { bridge }); + + const res = await auth( + request(app) + .post('/session/session-A/artifacts/artifact-1/pin') + .send({ mode: 'metadata', ttlDays: 7 }), + ).set('X-Qwen-Client-Id', 'client-1'); + + expect(res.status).toBe(400); + expect(res.body.error).toMatchObject({ + code: 'VALIDATION_FAILED', + field: 'ttlDays', + }); + expect(bridge.pinSessionArtifactCalls).toHaveLength(0); + }); + + it('POST /session/:id/artifacts/:artifactId/pin rejects ttlDays above the maximum', async () => { + const bridge = fakeBridge(); + const app = createServeApp(tokenOpts, undefined, { bridge }); + + const res = await auth( + request(app) + .post('/session/session-A/artifacts/artifact-1/pin') + .send({ mode: 'content', ttlDays: 366 }), + ).set('X-Qwen-Client-Id', 'client-1'); + + expect(res.status).toBe(400); + expect(res.body.error).toMatchObject({ + code: 'VALIDATION_FAILED', + field: 'ttlDays', + }); + expect(bridge.pinSessionArtifactCalls).toHaveLength(0); + }); + + it('DELETE /session/:id/artifacts/:artifactId/pin forwards mutation auth context', async () => { + const bridge = fakeBridge(); + const app = createServeApp(tokenOpts, undefined, { bridge }); + + const res = await auth( + request(app).delete('/session/session-A/artifacts/artifact-1/pin'), + ).set('X-Qwen-Client-Id', 'client-1'); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + v: 1, + sessionId: 'session-A', + changes: [{ action: 'updated', artifactId: 'artifact-1' }], + }); + expect(bridge.unpinSessionArtifactCalls).toEqual([ + { + sessionId: 'session-A', + artifactId: 'artifact-1', + context: { clientId: 'client-1' }, + }, + ]); + }); + + it('DELETE /session/:id/artifacts/:artifactId/pin forwards target retention', async () => { + const bridge = fakeBridge(); + const app = createServeApp(tokenOpts, undefined, { bridge }); + + const res = await auth( + request(app) + .delete('/session/session-A/artifacts/artifact-1/pin') + .send({ retention: 'ephemeral' }), + ).set('X-Qwen-Client-Id', 'client-1'); + + expect(res.status).toBe(200); + expect(bridge.unpinSessionArtifactCalls).toEqual([ + { + sessionId: 'session-A', + artifactId: 'artifact-1', + context: { clientId: 'client-1' }, + options: { retention: 'ephemeral' }, + }, + ]); + }); + + it('GET /session/:id/artifacts/fsck returns content integrity status', async () => { + const bridge = fakeBridge({ + fsckSessionArtifactsImpl: async () => ({ + checked: 2, + missing: ['missing-content'], + hashMismatches: ['bad-hash'], + }), + }); + const app = createServeApp(tokenOpts, undefined, { bridge }); + + const res = await auth( + request(app) + .get('/session/session-A/artifacts/fsck') + .set('X-Qwen-Client-Id', 'client-1'), + ); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + checked: 2, + missing: ['missing-content'], + hashMismatches: ['bad-hash'], + }); + expect(bridge.fsckSessionArtifactsCalls).toEqual([ + { sessionId: 'session-A', context: { clientId: 'client-1' } }, + ]); + }); + + it('GET /session/:id/artifacts/fsck requires a client id', async () => { + const bridge = fakeBridge(); + const app = createServeApp(tokenOpts, undefined, { bridge }); + + const res = await auth( + request(app).get('/session/session-A/artifacts/fsck'), + ); + + expect(res.status).toBe(403); + expect(res.body.code).toBe('client_id_required'); + expect(bridge.fsckSessionArtifactsCalls).toEqual([]); + }); + + it('POST /session/:id/artifacts/gc returns content cleanup result', async () => { + const bridge = fakeBridge({ + gcSessionArtifactsImpl: async () => ({ + removed: ['orphaned'], + retained: ['kept'], + }), + }); + const app = createServeApp(tokenOpts, undefined, { bridge }); + + const res = await auth( + request(app) + .post('/session/session-A/artifacts/gc') + .set('X-Qwen-Client-Id', 'client-1'), + ); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + removed: ['orphaned'], + retained: ['kept'], + }); + expect(bridge.gcSessionArtifactsCalls).toEqual([ + { sessionId: 'session-A', context: { clientId: 'client-1' } }, + ]); + }); + + it('POST /session/:id/artifacts/gc requires a client id', async () => { + const bridge = fakeBridge(); + const app = createServeApp(tokenOpts, undefined, { bridge }); + + const res = await auth( + request(app).post('/session/session-A/artifacts/gc'), + ); + + expect(res.status).toBe(403); + expect(res.body.code).toBe('client_id_required'); + expect(bridge.gcSessionArtifactsCalls).toEqual([]); + }); }); describe('POST /session/:id/model', () => { diff --git a/packages/cli/src/serve/server/serve-features.ts b/packages/cli/src/serve/server/serve-features.ts index 30e1acdde77..fa0ebb14345 100644 --- a/packages/cli/src/serve/server/serve-features.ts +++ b/packages/cli/src/serve/server/serve-features.ts @@ -79,6 +79,7 @@ export function createServeFeatures( persistSettingAvailable, sessionShellCommandEnabled, sessionArtifactsPersistenceAvailable: true, + sessionArtifactsContentRetentionAvailable: true, rateLimit: opts.rateLimit === true, reloadAvailable, clientMcpOverWsEnabled: opts.clientMcpOverWs === true, diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts index bc353457ab1..05f28faf8a3 100644 --- a/packages/sdk-typescript/src/daemon/DaemonClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -106,7 +106,12 @@ import type { DaemonToolToggleResult, DaemonSessionArtifactInput, DaemonSessionArtifactMutationResult, + DaemonSessionArtifactPinOptions, + DaemonSessionArtifactRemoveOptions, + DaemonSessionArtifactUnpinOptions, DaemonSessionArtifactsEnvelope, + DaemonSessionArtifactFsckResult, + DaemonSessionArtifactGcResult, DaemonRewindSnapshotInfo, DaemonRewindResult, ForkSessionRequest, @@ -3184,6 +3189,7 @@ export class DaemonClient { sessionId: string, artifactId: string, clientId?: string, + options?: DaemonSessionArtifactRemoveOptions, ): Promise { return await this.jsonRequest( `/session/${urlEncode(sessionId)}/artifacts/${urlEncode(artifactId)}`, @@ -3191,6 +3197,66 @@ export class DaemonClient { { method: 'DELETE', clientId, + ...(options !== undefined ? { body: options } : {}), + }, + ); + } + + async pinSessionArtifact( + sessionId: string, + artifactId: string, + clientId?: string, + options?: DaemonSessionArtifactPinOptions, + ): Promise { + return await this.jsonRequest( + `/session/${urlEncode(sessionId)}/artifacts/${urlEncode(artifactId)}/pin`, + 'POST /session/:id/artifacts/:artifactId/pin', + { + method: 'POST', + clientId, + ...(options !== undefined ? { body: options } : {}), + }, + ); + } + + async unpinSessionArtifact( + sessionId: string, + artifactId: string, + clientId?: string, + options?: DaemonSessionArtifactUnpinOptions, + ): Promise { + return await this.jsonRequest( + `/session/${urlEncode(sessionId)}/artifacts/${urlEncode(artifactId)}/pin`, + 'DELETE /session/:id/artifacts/:artifactId/pin', + { + method: 'DELETE', + clientId, + ...(options !== undefined ? { body: options } : {}), + }, + ); + } + + async fsckSessionArtifacts( + sessionId: string, + clientId?: string, + ): Promise { + return await this.jsonRequest( + `/session/${urlEncode(sessionId)}/artifacts/fsck`, + 'GET /session/:id/artifacts/fsck', + { clientId }, + ); + } + + async gcSessionArtifacts( + sessionId: string, + clientId?: string, + ): Promise { + return await this.jsonRequest( + `/session/${urlEncode(sessionId)}/artifacts/gc`, + 'POST /session/:id/artifacts/gc', + { + method: 'POST', + clientId, }, ); } diff --git a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts index b8f317f2977..fa2313f0449 100644 --- a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts @@ -31,7 +31,12 @@ import type { DaemonSessionRecapResult, DaemonShellCommandResult, DaemonSessionArtifactInput, + DaemonSessionArtifactFsckResult, + DaemonSessionArtifactGcResult, DaemonSessionArtifactMutationResult, + DaemonSessionArtifactPinOptions, + DaemonSessionArtifactRemoveOptions, + DaemonSessionArtifactUnpinOptions, DaemonSessionArtifactsEnvelope, DaemonSessionState, DaemonSession, @@ -429,14 +434,51 @@ export class DaemonSessionClient { async removeArtifact( artifactId: string, + options?: DaemonSessionArtifactRemoveOptions, ): Promise { return await this.client.removeSessionArtifact( this.sessionId, artifactId, this.clientId, + options, ); } + async pinArtifact( + artifactId: string, + options?: DaemonSessionArtifactPinOptions, + ): Promise { + return await this.client.pinSessionArtifact( + this.sessionId, + artifactId, + this.clientId, + options, + ); + } + + async unpinArtifact( + artifactId: string, + options?: DaemonSessionArtifactUnpinOptions, + ): Promise { + return await this.client.unpinSessionArtifact( + this.sessionId, + artifactId, + this.clientId, + options, + ); + } + + async fsckArtifacts(): Promise { + return await this.client.fsckSessionArtifacts( + this.sessionId, + this.clientId, + ); + } + + async gcArtifacts(): Promise { + return await this.client.gcSessionArtifacts(this.sessionId, this.clientId); + } + async setModel(modelId: string): Promise { return await this.client.setSessionModel( this.sessionId, diff --git a/packages/sdk-typescript/src/daemon/acpRouteTable.ts b/packages/sdk-typescript/src/daemon/acpRouteTable.ts index 5a20294c2b8..bee1d13ea4f 100644 --- a/packages/sdk-typescript/src/daemon/acpRouteTable.ts +++ b/packages/sdk-typescript/src/daemon/acpRouteTable.ts @@ -280,6 +280,50 @@ export const ROUTE_TABLE: readonly RouteEntry[] = [ }), }, }, + // POST /session/:id/artifacts/:artifactId/pin → _qwen/session/artifacts/pin + { + httpMethod: 'POST', + pattern: /^\/session\/([^/]+)\/artifacts\/([^/]+)\/pin$/, + mapping: { + method: '_qwen/session/artifacts/pin', + extractParams: (segs, body) => ({ + ...(isRecord(body) ? body : {}), + sessionId: segs[0], + artifactId: segs[1], + }), + }, + }, + // DELETE /session/:id/artifacts/:artifactId/pin → _qwen/session/artifacts/unpin + { + httpMethod: 'DELETE', + pattern: /^\/session\/([^/]+)\/artifacts\/([^/]+)\/pin$/, + mapping: { + method: '_qwen/session/artifacts/unpin', + extractParams: (segs, body) => ({ + ...(isRecord(body) ? body : {}), + sessionId: segs[0], + artifactId: segs[1], + }), + }, + }, + // GET /session/:id/artifacts/fsck → _qwen/session/artifacts/fsck + { + httpMethod: 'GET', + pattern: /^\/session\/([^/]+)\/artifacts\/fsck$/, + mapping: { + method: '_qwen/session/artifacts/fsck', + extractParams: (segs) => ({ sessionId: segs[0] }), + }, + }, + // POST /session/:id/artifacts/gc → _qwen/session/artifacts/gc + { + httpMethod: 'POST', + pattern: /^\/session\/([^/]+)\/artifacts\/gc$/, + mapping: { + method: '_qwen/session/artifacts/gc', + extractParams: (segs) => ({ sessionId: segs[0] }), + }, + }, // DELETE /session/:id/artifacts/:artifactId → _qwen/session/artifacts/remove { httpMethod: 'DELETE', diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts index a6d75768fdd..3138c3a8d90 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -542,13 +542,19 @@ export type { DaemonSessionArtifact, DaemonSessionArtifactChange, DaemonSessionArtifactChangeAction, + DaemonSessionArtifactContentRef, + DaemonSessionArtifactFsckResult, + DaemonSessionArtifactGcResult, DaemonSessionArtifactInput, DaemonSessionArtifactKind, DaemonSessionArtifactMutationResult, + DaemonSessionArtifactPinOptions, DaemonSessionArtifactPersistenceWarning, + DaemonSessionArtifactRemoveOptions, DaemonSessionArtifactRemovalReason, DaemonSessionArtifactRestoreState, DaemonSessionArtifactRetention, + DaemonSessionArtifactUnpinOptions, DaemonSessionArtifactsEnvelope, DaemonSessionArtifactSource, DaemonSessionArtifactStatus, diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index 3a076b3b9cb..61663c2a63f 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -623,7 +623,10 @@ export type KnownDaemonSessionArtifactStatus = 'available' | 'missing'; export type DaemonSessionArtifactStatus = OpenStringUnion; -export type KnownDaemonSessionArtifactRetention = 'ephemeral' | 'restorable'; +export type KnownDaemonSessionArtifactRetention = + | 'ephemeral' + | 'restorable' + | 'pinned'; export type DaemonSessionArtifactRetention = OpenStringUnion; @@ -639,6 +642,9 @@ export type DaemonSessionArtifactRestoreState = export type KnownDaemonSessionArtifactPersistenceWarning = | 'persistence_unavailable' + | 'content_missing' + | 'content_expired' + | 'content_hash_mismatch' | 'metadata_only_restore' | 'restore_validation_failed' | 'sticky_override_active'; @@ -646,6 +652,14 @@ export type KnownDaemonSessionArtifactPersistenceWarning = export type DaemonSessionArtifactPersistenceWarning = OpenStringUnion; +export interface DaemonSessionArtifactContentRef { + kind: 'managed_copy'; + contentId: string; + sha256: string; + sizeBytes: number; + createdAt: string; +} + export interface DaemonSessionArtifactInput { kind?: KnownDaemonSessionArtifactKind; storage?: Exclude; @@ -657,7 +671,7 @@ export interface DaemonSessionArtifactInput { mimeType?: string; sizeBytes?: number; metadata?: Record; - retention?: KnownDaemonSessionArtifactRetention; + retention?: Exclude; clientRetained?: boolean; } @@ -678,7 +692,9 @@ export interface DaemonSessionArtifact { retention: DaemonSessionArtifactRetention; restoreState?: DaemonSessionArtifactRestoreState; persistenceWarning?: DaemonSessionArtifactPersistenceWarning; + contentRef?: DaemonSessionArtifactContentRef; persistedAt?: string; + expiresAt?: string; clientRetained: boolean; createdAt: string; updatedAt: string; @@ -727,6 +743,31 @@ export interface DaemonSessionArtifactMutationResult { warnings?: string[]; } +export interface DaemonSessionArtifactPinOptions { + mode?: 'metadata' | 'content'; + ttlDays?: number; + clientRetained?: boolean; +} + +export interface DaemonSessionArtifactRemoveOptions { + deleteContent?: boolean; +} + +export interface DaemonSessionArtifactUnpinOptions { + retention?: 'ephemeral' | 'restorable'; +} + +export interface DaemonSessionArtifactFsckResult { + checked: number; + missing: string[]; + hashMismatches: string[]; +} + +export interface DaemonSessionArtifactGcResult { + removed: string[]; + retained: string[]; +} + export type DaemonStatus = | 'ok' | 'warning' diff --git a/packages/sdk-typescript/test/unit/DaemonClient.test.ts b/packages/sdk-typescript/test/unit/DaemonClient.test.ts index f99cfea9e1e..42690e2a0ce 100644 --- a/packages/sdk-typescript/test/unit/DaemonClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonClient.test.ts @@ -305,6 +305,11 @@ describe('DaemonClient', () => { await expect( client.removeSessionArtifact('session/1', 'artifact/1', 'client-1'), ).resolves.toEqual(result); + await expect( + client.removeSessionArtifact('session/1', 'artifact/1', 'client-1', { + deleteContent: true, + }), + ).resolves.toEqual(result); expect(calls[0]).toMatchObject({ url: 'http://daemon/session/session%2F1/artifacts/artifact%2F1', method: 'DELETE', @@ -313,6 +318,117 @@ describe('DaemonClient', () => { }, body: null, }); + expect(calls[1]).toMatchObject({ + url: 'http://daemon/session/session%2F1/artifacts/artifact%2F1', + method: 'DELETE', + headers: { + 'content-type': 'application/json', + 'x-qwen-client-id': 'client-1', + }, + body: JSON.stringify({ deleteContent: true }), + }); + }); + + it('pins and unpins session artifacts with encoded ids', async () => { + const result = { + v: 1 as const, + sessionId: 'session/1', + changes: [{ action: 'updated' as const, artifactId: 'artifact/1' }], + }; + const { fetch, calls } = recordingFetch(() => jsonResponse(200, result)); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + + await expect( + client.pinSessionArtifact('session/1', 'artifact/1', 'client-1'), + ).resolves.toEqual(result); + await expect( + client.unpinSessionArtifact('session/1', 'artifact/1', 'client-1'), + ).resolves.toEqual(result); + await expect( + client.pinSessionArtifact('session/1', 'artifact/1', 'client-1', { + mode: 'content', + ttlDays: 7, + clientRetained: false, + }), + ).resolves.toEqual(result); + await expect( + client.unpinSessionArtifact('session/1', 'artifact/1', 'client-1', { + retention: 'ephemeral', + }), + ).resolves.toEqual(result); + + expect(calls[0]).toMatchObject({ + url: 'http://daemon/session/session%2F1/artifacts/artifact%2F1/pin', + method: 'POST', + headers: { 'x-qwen-client-id': 'client-1' }, + body: null, + }); + expect(calls[1]).toMatchObject({ + url: 'http://daemon/session/session%2F1/artifacts/artifact%2F1/pin', + method: 'DELETE', + headers: { 'x-qwen-client-id': 'client-1' }, + body: null, + }); + expect(calls[2]).toMatchObject({ + url: 'http://daemon/session/session%2F1/artifacts/artifact%2F1/pin', + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-qwen-client-id': 'client-1', + }, + body: JSON.stringify({ + mode: 'content', + ttlDays: 7, + clientRetained: false, + }), + }); + expect(calls[3]).toMatchObject({ + url: 'http://daemon/session/session%2F1/artifacts/artifact%2F1/pin', + method: 'DELETE', + headers: { + 'content-type': 'application/json', + 'x-qwen-client-id': 'client-1', + }, + body: JSON.stringify({ retention: 'ephemeral' }), + }); + }); + + it('runs fsck and gc for session artifact content', async () => { + const replies = [ + { checked: 1, missing: ['missing'], hashMismatches: [] }, + { removed: ['old'], retained: ['kept'] }, + ]; + const { fetch, calls } = recordingFetch(() => + jsonResponse(200, replies.shift()), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + + await expect( + client.fsckSessionArtifacts('session/1', 'client-1'), + ).resolves.toEqual({ + checked: 1, + missing: ['missing'], + hashMismatches: [], + }); + await expect( + client.gcSessionArtifacts('session/1', 'client-1'), + ).resolves.toEqual({ + removed: ['old'], + retained: ['kept'], + }); + + expect(calls[0]).toMatchObject({ + url: 'http://daemon/session/session%2F1/artifacts/fsck', + method: 'GET', + headers: { 'x-qwen-client-id': 'client-1' }, + body: null, + }); + expect(calls[1]).toMatchObject({ + url: 'http://daemon/session/session%2F1/artifacts/gc', + method: 'POST', + headers: { 'x-qwen-client-id': 'client-1' }, + body: null, + }); }); }); diff --git a/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts b/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts index f873846eafe..ce31c1d0f75 100644 --- a/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts @@ -472,6 +472,34 @@ describe('DaemonSessionClient', () => { ) { return jsonResponse(200, mutationResult); } + if ( + req.method === 'POST' && + req.url === 'http://daemon/session/s-1/artifacts/artifact-1/pin' + ) { + return jsonResponse(200, mutationResult); + } + if ( + req.method === 'DELETE' && + req.url === 'http://daemon/session/s-1/artifacts/artifact-1/pin' + ) { + return jsonResponse(200, mutationResult); + } + if ( + req.method === 'GET' && + req.url === 'http://daemon/session/s-1/artifacts/fsck' + ) { + return jsonResponse(200, { + checked: 1, + missing: [], + hashMismatches: [], + }); + } + if ( + req.method === 'POST' && + req.url === 'http://daemon/session/s-1/artifacts/gc' + ) { + return jsonResponse(200, { removed: [], retained: [] }); + } return jsonResponse(500, { error: `unexpected ${req.method} ${req.url}`, }); @@ -497,11 +525,30 @@ describe('DaemonSessionClient', () => { await expect(session.removeArtifact('artifact-1')).resolves.toEqual( mutationResult, ); + await expect(session.pinArtifact('artifact-1')).resolves.toEqual( + mutationResult, + ); + await expect(session.unpinArtifact('artifact-1')).resolves.toEqual( + mutationResult, + ); + await expect(session.fsckArtifacts()).resolves.toEqual({ + checked: 1, + missing: [], + hashMismatches: [], + }); + await expect(session.gcArtifacts()).resolves.toEqual({ + removed: [], + retained: [], + }); expect(calls.map((call) => call.headers['x-qwen-client-id'])).toEqual([ 'client-1', 'client-1', 'client-1', + 'client-1', + 'client-1', + 'client-1', + 'client-1', ]); expect(calls[1]?.body).toBe( JSON.stringify({ diff --git a/packages/sdk-typescript/test/unit/acpRouteTable.test.ts b/packages/sdk-typescript/test/unit/acpRouteTable.test.ts index 15bdb8f4c2f..926c890c2c8 100644 --- a/packages/sdk-typescript/test/unit/acpRouteTable.test.ts +++ b/packages/sdk-typescript/test/unit/acpRouteTable.test.ts @@ -358,13 +358,66 @@ describe('acpRouteTable – matchRoute', () => { expect(result).not.toBeNull(); expect(result!.mapping.method).toBe('_qwen/session/artifacts/remove'); expect( - result!.mapping.extractParams(result!.segments, undefined, 'DELETE'), + result!.mapping.extractParams( + result!.segments, + { deleteContent: false }, + 'DELETE', + ), ).toEqual({ sessionId: 's8', artifactId: 'art 1', + deleteContent: false, }); }); + it('POST /session/:id/artifacts/:artifactId/pin maps to _qwen/session/artifacts/pin', () => { + const result = matchRoute('/session/s8/artifacts/art%201/pin', 'POST'); + expect(result).not.toBeNull(); + expect(result!.mapping.method).toBe('_qwen/session/artifacts/pin'); + expect( + result!.mapping.extractParams( + result!.segments, + { mode: 'metadata' }, + 'POST', + ), + ).toEqual({ sessionId: 's8', artifactId: 'art 1', mode: 'metadata' }); + }); + + it('DELETE /session/:id/artifacts/:artifactId/pin maps to _qwen/session/artifacts/unpin', () => { + const result = matchRoute('/session/s8/artifacts/art%201/pin', 'DELETE'); + expect(result).not.toBeNull(); + expect(result!.mapping.method).toBe('_qwen/session/artifacts/unpin'); + expect( + result!.mapping.extractParams( + result!.segments, + { retention: 'ephemeral' }, + 'DELETE', + ), + ).toEqual({ + sessionId: 's8', + artifactId: 'art 1', + retention: 'ephemeral', + }); + }); + + it('GET /session/:id/artifacts/fsck maps to _qwen/session/artifacts/fsck', () => { + const result = matchRoute('/session/s8/artifacts/fsck', 'GET'); + expect(result).not.toBeNull(); + expect(result!.mapping.method).toBe('_qwen/session/artifacts/fsck'); + expect( + result!.mapping.extractParams(result!.segments, undefined, 'GET'), + ).toEqual({ sessionId: 's8' }); + }); + + it('POST /session/:id/artifacts/gc maps to _qwen/session/artifacts/gc', () => { + const result = matchRoute('/session/s8/artifacts/gc', 'POST'); + expect(result).not.toBeNull(); + expect(result!.mapping.method).toBe('_qwen/session/artifacts/gc'); + expect( + result!.mapping.extractParams(result!.segments, undefined, 'POST'), + ).toEqual({ sessionId: 's8' }); + }); + it('POST /session/:id/recap maps to _qwen/session/recap', () => { const result = matchRoute('/session/s9/recap', 'POST'); expect(result).not.toBeNull();