mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
feat(daemon): persist session artifacts across restarts (#6557)
* feat(daemon): persist session artifact metadata * fix(daemon): address artifact restore review findings * fix(daemon): harden artifact persistence restore * fix(daemon): align artifact persistence review decisions * fix(daemon): address artifact persistence review gaps * fix(daemon): harden artifact persistence recovery * fix(daemon): align artifact ownership capability * fix(daemon): preserve marker identity during fork * fix(daemon): roll back durable replacement removals * fix(daemon): surface artifact rollback warnings * fix(daemon): surface restore warning details * fix(daemon): preserve artifact marker metadata safely * fix(daemon): sanitize fork marker metadata * fix(daemon): harden artifact restore boundaries * fix(daemon): omit orphaned sticky snapshot markers * fix(daemon): preserve artifact tombstone and rewind warnings * fix(daemon): address artifact fork review blockers --------- Co-authored-by: 秦奇 <gary.gq@alibaba-inc.com> Co-authored-by: 易良 <1204183885@qq.com> Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
This commit is contained in:
parent
bb96ac4fe5
commit
53243de0c0
38 changed files with 10441 additions and 186 deletions
|
|
@ -0,0 +1,893 @@
|
|||
# Qwen Code Daemon Session Artifacts V2 持久化设计
|
||||
|
||||
本文延续 PR #5895 的 V1 session artifact API,设计 V2 持久化能力。V1 设计见同目录下的 [session-artifacts-daemon-api-implementation-design.md](./session-artifacts-daemon-api-implementation-design.md)。
|
||||
|
||||
V2 的目标是在不破坏 V1 live session 语义的前提下,让 artifact metadata 可以在 daemon 重启、session load/replay 后恢复。当前 PR 不复制、不冻结、不托管 artifact 内容;workspace 文件只保存路径、size、mtimeMs 和 sha256 作为恢复后的完整性校验。
|
||||
|
||||
## 1. 设计结论
|
||||
|
||||
V2 是一个 metadata persistence phase。PR #6259 的实现范围收敛为 metadata restore、artifact JSONL journal/snapshot/rebuild/fork remap、daemon restart/load/replay 后恢复 artifact metadata,以及 REST/ACP/SDK 的 metadata persistence 暴露。content retention(workspace content pin、session-scoped managed copy、manifest、quota、TTL、session-scoped GC/fsck)不在当前 scope;若未来有真实审计/留档需求,应作为新的 content archive 设计重新评审。client 不应依赖“V2”这个阶段名推断功能,而应读取 capability。
|
||||
|
||||
当前能力:
|
||||
|
||||
1. Metadata restore:默认恢复 artifact 的结构化 metadata 和资源引用,不复制实际内容。
|
||||
2. Workspace integrity check:workspace artifact 登记时记录 size + mtimeMs + sha256;restore / GET 时按实时文件返回 `available` / `missing` / `changed`。
|
||||
|
||||
对应 capability:
|
||||
|
||||
- `session_artifacts_persistence`:支持 metadata 持久化与 session load/replay 恢复。
|
||||
- `session_artifacts_content_retention`:当前不声明;后续如果重启 content archive 设计,必须在复制/托管内容、配额、manifest 和 GC/fsck 都完成后再声明。
|
||||
|
||||
核心原则:
|
||||
|
||||
- V1 的 `SessionArtifactStore` 仍是 live session 的权威内存索引。
|
||||
- V2 增加 JSONL artifact journal/snapshot,用于在 daemon 侧创建 live store 时 seed 初始状态;JSONL append 必须由当前拥有 chat recording 的 core/ACP child 路径完成,daemon-side store 不能直接写 transcript。
|
||||
- V2 默认 JSONL-only。sidecar cache 不进入 V2 发布门槛;只有实测 session load 成本不可接受时,才另行设计可删除缓存。
|
||||
- 不把远端 URL 内容抓取到本地。
|
||||
- 不默认复制 workspace 文件。
|
||||
- 不把 client 传入的 `source`、`clientId`、`trustedPublisher` 当授权依据。
|
||||
- 恢复时必须重新校验,不信任磁盘上的旧 metadata。
|
||||
|
||||
当前 PR 的重要收窄:
|
||||
|
||||
- Content retention public API、managed content store、pin/unpin、deleteContent、quota/manifest/fsck/gc 和 `session_artifacts_content_retention` capability 不在 PR #6259 中交付。当前 PR 只保留对旧 `pinned` / `contentRef` journal payload 的 downgrade/strip 兼容路径,避免旧记录破坏 metadata restore。
|
||||
- 下文保留的 pin/save、content quota、managed content GC/fsck 细节是 future content archive 蓝图,不是 PR #6259 的 wire contract 或验收项;除非小节明确标注为 PR #6259 HTTP mapping / metadata behavior,否则实现不得在 #6259 中暴露这些 API 或 capability。
|
||||
- 当前 live view 与 persisted metadata 使用同一个 200 条可见集合。为了避免重启后 over-restore,超过上限时的 durable/restorable eviction 会写入 `reason: "eviction"` remove event;这等价于本实现的 metadata prune,不是纯 V1 live-only hiding。
|
||||
- 显式 DELETE 当前采用 live-first:先从 live store 移除,tombstone 写入失败时返回 warning。这样可优先隐藏敏感项;失败窗口内 daemon 重启仍可能从旧 journal 恢复该 artifact,client 应把 warning 作为“删除未 durable”的信号。
|
||||
- Fork 当前通过一次性 exclusive-create 写入目标 JSONL 文件;不会逐条 streaming fork artifact records,因此不需要 `session_artifact_fork_marker` 才能检测当前写入路径的 partial batch。若未来改成流式 fork,再引入 begin/complete marker。
|
||||
|
||||
## 2. 用户可见语义
|
||||
|
||||
### 2.1 页面刷新、切换和重启
|
||||
|
||||
V2 后的行为应是:
|
||||
|
||||
- 页面刷新:和 V1 一样,只要 daemon/session 还活着,前端重新 `GET /session/:id/artifacts` 即可。
|
||||
- 切换 session:每个 live session 仍有独立 artifact store。
|
||||
- 前端实例重启:daemon 还在时可 GET 当前 live store。
|
||||
- daemon/bridge 重启:如果 session 被重新 load,V2 从持久化 metadata 恢复 artifact list。
|
||||
- 历史 load/replay:如果该 session 有 V2 persistence records,恢复 artifact list;没有则返回空 list。
|
||||
|
||||
V1 到 V2 的 live upgrade 需要单独处理:已经在内存中的 V1 live artifacts 没有 JSONL journal。V2 首次触达这些 live sessions 时,应通过 chat recording owner 提供的 artifact persistence writer 写入一条初始 `session_artifact_snapshot`,然后再接受新的 restorable artifact mutation。backfill 不能把 live store 原样序列化;必须对每个 artifact 重新执行 ingest validation、privacy minimization 和 `retention` materialization。单条 artifact 不合格时跳过或降级该条,不能让一次坏记录拖垮整个 backfill。若 writer 不可用或 backfill 整体失败,该 session 继续保持 V1 live-only 行为,并记录 structured warning;不能让用户误以为已有 live artifacts 已经可恢复。
|
||||
|
||||
backfill 不能逐条向 JSONL streaming 写入 artifact event。实现必须先在内存中完成校验、最小化和降级,形成完整 candidate snapshot 后,再一次性 append `session_artifact_snapshot`。如果 candidate 构建或 snapshot append 失败,不能留下部分 durable artifact state。当前 PR 不实现 V1 live-store backfill;如果后续补齐,应把 candidate 条目数、跳过条目数和校验失败原因写入结构化 telemetry 或 snapshot metadata,便于 fsck 和 restore warning 区分“完整但有条目被校验跳过”和“部分写入/损坏”。
|
||||
|
||||
### 2.2 retention 分层
|
||||
|
||||
新增 optional field。PR #6259 的 public mutation path 只接受 `ephemeral` 和 `restorable`;旧 journal 中的 `pinned` 会在 restore / fork 时降级为 metadata-only `restorable`:
|
||||
|
||||
```ts
|
||||
type ArtifactRetention = 'ephemeral' | 'restorable';
|
||||
```
|
||||
|
||||
含义:
|
||||
|
||||
- `ephemeral`:只存在于 live store。daemon/session 消失后不恢复。
|
||||
- `restorable`:metadata 写入持久化 journal。session load/replay 后恢复为 artifact item,但不保证底层资源仍存在。
|
||||
|
||||
默认规则:
|
||||
|
||||
- Tool result、`record_artifact`、hook artifact:默认 `restorable`,但只持久化 metadata。
|
||||
- 用户在交互式前端手动注册的 Client POST artifact:默认 `restorable`,恢复后仍出现在 artifact list 中。
|
||||
- 后台/自动化 client POST:如果只是临时 UI 状态,应显式请求 `retention: "ephemeral"`;SDK 应提供明确的 ephemeral helper。
|
||||
- `published` artifact:默认 `restorable`;当前只恢复 published locator,不托管内容。
|
||||
|
||||
如果 chat recording 被禁用,metadata persistence 默认禁用,capability 不声明。
|
||||
|
||||
### 2.3 用户注册 artifact 恢复语义
|
||||
|
||||
用户手动注册的 artifact 在 V2 恢复后应该继续存在,但恢复的是“artifact metadata item”,不是无条件内容备份。
|
||||
|
||||
恢复后的结果按资源状态区分:
|
||||
|
||||
- `external_url`:恢复 title、description、url、metadata。daemon 不访问远端 URL;URL 是否仍可打开由 client 点击时决定。
|
||||
- `workspace`:恢复 workspacePath 和 metadata;如果文件仍在 workspace 内且 size + mtimeMs 未变,或 mtime 变化后 sha256 仍与登记时一致,`status: "available"`;如果文件已删除、移动或 symlink 逃逸,`status: "missing"`;如果文件仍在但 size 或 sha256 与登记时不同,`status: "changed"`。
|
||||
- `managed`:恢复 managedId;只有 managed storage manifest 仍能解析时才 `available`。
|
||||
- `published`:恢复 published locator;只有仍满足 trusted publisher manifest 校验时才保留 published trust。
|
||||
|
||||
因此,“用户注册的 artifact 恢复后还存在吗?”的答案是:V2 中应该存在于列表里,除非用户 DELETE、metadata 被 GC/tombstone、恢复校验发现记录损坏到无法安全展示,或 chat recording / persistence 被禁用。是否还能打开底层内容,则取决于 storage 类型和实时资源状态;workspace 文件不会被 daemon 备份,`changed` 用来避免静默打开错误版本。
|
||||
|
||||
daemon 不能只凭 request payload 判断“手动”还是“后台”。实现上应由连接 principal、SDK helper 或 UI action path 标识交互式注册来源;无法确认交互意图的 client 应按显式 `retention` 处理,缺省仍接受 `restorable`,但受 session metadata quota 和审计记录约束。
|
||||
|
||||
## 3. 数据模型
|
||||
|
||||
### 3.1 Public artifact 扩展
|
||||
|
||||
V2 在 V1 response artifact 上增加 optional fields:
|
||||
|
||||
```ts
|
||||
interface DaemonSessionArtifact {
|
||||
// V1 fields...
|
||||
status: 'available' | 'missing' | 'changed';
|
||||
retention?: 'ephemeral' | 'restorable';
|
||||
persistedAt?: string;
|
||||
restoreState?: 'live' | 'restored' | 'unverified' | 'blocked';
|
||||
persistenceWarning?:
|
||||
| 'persistence_unavailable'
|
||||
| 'metadata_only_restore'
|
||||
| 'restore_validation_failed'
|
||||
| 'sticky_override_active';
|
||||
metadata?: {
|
||||
'qwen.workspace.sha256'?: string;
|
||||
'qwen.workspace.mtimeMs'?: number;
|
||||
[key: string]: string | number | boolean | null | undefined;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
||||
- `retention`:artifact 的持久化级别。解析顺序为:请求体显式值优先;系统内部 artifact 按 §2.2 的 daemon 默认策略;client POST 未指定时使用用户配置的 `defaultRetention`;无配置时回退为 `restorable`。只有 persistence capability 未声明或读取 V1-era 记录时,才按 V1 兼容的 live-only 处理。V2 writer 写 journal 时必须 materialize `retention`,不能依赖 optional 缺省。
|
||||
- `persistedAt`:metadata 最近成功落盘时间。
|
||||
- `restoreState`:恢复来源提示;不替代 `status`。
|
||||
- `persistenceWarning`:非阻塞持久化/恢复风险,前端可用它提示“此 artifact 不会跨重启保留”等状态。当前 wire shape 是固定字符串,避免把 host 绝对路径、credential、token、内部 storage path 或 connection id 写入 response。更结构化的 `{ code, message }` 可作为后续兼容扩展。
|
||||
- `status: "changed"`:仅用于 workspace artifact。daemon 在登记时写入 `sizeBytes`、`metadata["qwen.workspace.sha256"]` 和 `metadata["qwen.workspace.mtimeMs"]`;GET/list/restore 后 refresh 先 stat 当前文件,size 变化直接返回 `changed`,size/mtime 均未变化则不重读文件,只有 mtime 变化但 size 相同时才重新计算 sha256 兜底。
|
||||
|
||||
### 3.2 Status 与 restoreState 的关系
|
||||
|
||||
V1 `status` 继续表示当前资源是否可用:
|
||||
|
||||
- `available`
|
||||
- `missing`
|
||||
- `changed`
|
||||
|
||||
V2 只新增 `changed` 这一种 workspace integrity 状态。它表示路径仍可访问,但实时文件的 size 已变化,或 mtime 变化后 sha256 与登记时的 metadata 不一致。`blocked` 不是 `status`,只属于 `restoreState`:
|
||||
|
||||
- `restored`:从持久化 metadata 恢复。
|
||||
- `unverified`:恢复了 metadata,但尚未完成 workspace/managed 校验。
|
||||
- `blocked`:恢复时发现安全边界不满足,例如 workspace path 逃逸。
|
||||
- `live`:当前进程内新产生或已刷新确认。
|
||||
|
||||
## 4. 持久化存储设计
|
||||
|
||||
### 4.1 JSONL-only source of truth
|
||||
|
||||
V2 默认只使用 Chat JSONL system records:
|
||||
|
||||
1. JSONL journal 是审计源、恢复源和跨版本迁移源。
|
||||
2. `session_artifact_snapshot` 是 JSONL 内的恢复加速点,不是独立文件。
|
||||
3. 不在 V2 中引入 sidecar cache。sidecar 会增加路径同步、陈旧校验、archive/unarchive/delete 联动、orphan GC 和缓存信任问题;当前 session load 已经读取 JSONL,artifact records 可以在同一轮 parse 中提取。
|
||||
|
||||
如果未来实测需要 sidecar,它必须作为单独设计进入,并满足两个约束:
|
||||
|
||||
- sidecar 只能是可删除缓存,不能承载协议正确性。
|
||||
- 即使 sidecar 命中,也必须对每个 artifact 执行恢复校验,不能绕过 JSONL restore validation。
|
||||
|
||||
sidecar 对 V2 持久化不是 correctness requirement。当前 `loadSession()` 为恢复会读取完整 session JSONL 并重建对话树;artifact restore 在同一轮读取里提取 snapshot/event records 时,不会增加额外文件 I/O。因此,sidecar 在当前架构下只能节省 artifact records 的少量 parse/replay 成本,不能消除 session load 的主要读取成本。
|
||||
|
||||
把 sidecar 纳入当前 PR 会明显扩大实现面:
|
||||
|
||||
- JSONL 与 sidecar 的双写顺序、fsync 和 crash recovery。
|
||||
- stale/corrupt sidecar 的校验、失效和 fallback。
|
||||
- archive/unarchive/delete/fork/remap 时 sidecar 生命周期同步。
|
||||
- sidecar 是否可信、是否可能绕过 restore validation 的安全边界。
|
||||
- orphan sidecar/cache cleanup 和额外测试矩阵。
|
||||
|
||||
因此 V2 发布门槛保持 JSONL-only。sidecar 只在以下任一条件被 profiling 或产品需求证明后再进入独立设计:
|
||||
|
||||
- `loadSession()` 不再需要读取完整 JSONL,sidecar 可以避免一次 cold-start 全量扫描。
|
||||
- artifact list 需要在不 load session history 的场景下冷启动展示。
|
||||
- 实测 artifact restore,而不是对话历史重建,成为 session load 的主耗时。
|
||||
- 需要跨 session/project 的 artifact 搜索或全局索引。
|
||||
|
||||
### 4.2 JSONL writer ownership and branch model
|
||||
|
||||
Artifact persistence records 是 chat transcript 的一部分,必须遵循现有 `ChatRecord` 的 parent/leaf 语义:
|
||||
|
||||
- JSONL append 只能通过拥有 `ChatRecordingService.appendRecord` 的进程或它暴露的明确 RPC 完成。daemon-side `SessionArtifactStore` 可以用 operation queue 协调 live state、SSE 和 persistence request 顺序,但不能自己打开并写 chat JSONL。
|
||||
- 每条 `session_artifact_event` / `session_artifact_snapshot` 都必须作为普通 system `ChatRecord` 挂到当前 conversation leaf 上,并获得正常的 `uuid` / `parentUuid`。
|
||||
- chat tree builder 和 renderer 必须把 `session_artifact_*` system records 视为 side-effect records:它们参与 parent/leaf 顺序和 replay,但不渲染成用户可见 conversation node。最低支持旧版本加载包含 V2 record 的 JSONL 时也必须把未知 system subtype 当作 opaque/ignored side effect,而不是让 session load 失败。
|
||||
- session load/replay 只应用 active leaf chain 中的 artifact records。被 `/rewind` 丢到 abandoned branch 的 artifact upsert/remove 不再影响当前 artifact list。
|
||||
- `/rewind` 或任何 leaf switch 发生时,daemon-side live `SessionArtifactStore` 必须重新对齐新的 active-chain artifact state:要么从 active-chain replay result reseed,要么在 rewind 操作中向 surviving chain 写一条当前 artifact snapshot top-up。V2 默认采用 branch-scoped 语义;off-branch mutation 不应继续留在 live flat map 中等待下次重启才消失。
|
||||
- fork/branch 只复制 active chain 中的 artifact records;off-chain records 不参与目标 session 的恢复。
|
||||
- 如果某个实现阶段还不能把 artifact system records 接到 active leaf chain,就不能声明 `session_artifacts_persistence` capability;否则 rewind 后会出现旧 upsert 或旧 tombstone 复活的问题。
|
||||
|
||||
这意味着 V2 不设计独立的 artifact log 文件,也不设计绕过 chat tree 的 side log。artifact persistence 的正确性来自同一条 active chat history,而不是 daemon 当前内存状态。
|
||||
|
||||
### 4.3 JSONL system record
|
||||
|
||||
给 `ChatRecord.subtype` 增加:
|
||||
|
||||
```ts
|
||||
'session_artifact_event' | 'session_artifact_snapshot';
|
||||
```
|
||||
|
||||
Payload:
|
||||
|
||||
```ts
|
||||
interface SessionArtifactEventRecordPayload {
|
||||
v: 2;
|
||||
sessionId: string;
|
||||
sequence: number;
|
||||
recordedAt: string;
|
||||
changes: Array<{
|
||||
action: 'created' | 'updated' | 'removed';
|
||||
artifactId: string;
|
||||
artifact?: PersistedSessionArtifact;
|
||||
reason?: 'explicit' | 'eviction' | 'unpin_to_ephemeral';
|
||||
}>;
|
||||
}
|
||||
|
||||
interface SessionArtifactSnapshotRecordPayload {
|
||||
v: 2;
|
||||
sessionId: string;
|
||||
sequence: number;
|
||||
recordedAt: string;
|
||||
artifacts: PersistedSessionArtifact[];
|
||||
tombstonedIds?: string[];
|
||||
stickyEphemeralIds: string[];
|
||||
}
|
||||
|
||||
type PersistedSessionArtifact = Pick<
|
||||
DaemonSessionArtifact,
|
||||
| 'id'
|
||||
| 'kind'
|
||||
| 'storage'
|
||||
| 'source'
|
||||
| 'status'
|
||||
| 'title'
|
||||
| 'description'
|
||||
| 'workspacePath'
|
||||
| 'managedId'
|
||||
| 'url'
|
||||
| 'mimeType'
|
||||
| 'sizeBytes'
|
||||
| 'metadata'
|
||||
| 'createdAt'
|
||||
| 'updatedAt'
|
||||
> & {
|
||||
retention: ArtifactRetention;
|
||||
persistedAt: string;
|
||||
clientRetained: boolean;
|
||||
toolCallId?: string;
|
||||
toolName?: string;
|
||||
hookEventName?: string;
|
||||
};
|
||||
```
|
||||
|
||||
`sequence` 是每个 session artifact store 内的 durable mutation counter,用于 snapshot/event 排序和异常诊断。恢复时仍以 active JSONL chain 顺序为准;`sequence` 不作为跨 session 授权或全局 ordering source。
|
||||
|
||||
`PersistedSessionArtifact` 必须是正向 allowlist(显式 `Pick` 或独立 interface),不能用 `Omit<DaemonSessionArtifact, ...>` 负向排除。未来如果 `DaemonSessionArtifact` 增加新的 runtime-only 字段,编译时断言应要求维护者显式决定是否进入 persisted allowlist,避免 schema 污染。
|
||||
|
||||
只写经过 store validation/normalization 后的最小化 artifact shape。除 `clientRetained` 以及 tool/hook display hints 外,不写 V1 内部字段或运行时派生字段:
|
||||
|
||||
- 不写 `identityKey`
|
||||
- 不写 `trustedPublisher`
|
||||
- 不写绝对 `workspaceCwd`
|
||||
- 不写 transport token / auth principal
|
||||
- 不写 `restoreState`
|
||||
- 不写 `persistenceWarning`
|
||||
- 不写 `clientId` 或 live-process owner principal;`source` 只作为显示/审计 hint,不能用于授权
|
||||
|
||||
删除 artifact 必须写 tombstone change,避免历史 replay 后被旧 upsert 复活。tombstone 不是永久禁止同一 id 再出现:它只覆盖自己之前的 upsert,直到之后出现更高 sequence 的显式 upsert。旧 journal 中的 `reason: "unpin_to_ephemeral"` 继续作为 sticky override 兼容:后续同一 artifact id 的隐式/default upsert 仍按 live-only 处理,只有经过认证的 REST/ACP mutate route 中显式传入 `retention: "restorable"` 的请求才能 supersede;tool/hook/background/default retention、restore backfill 和隐式 re-ingest 都不能 supersede sticky override。
|
||||
|
||||
sticky override 不能只存在于历史 tombstone event 中。snapshot writer 必须把尚未被显式 supersede 的 `unpin_to_ephemeral` 状态写入 `stickyEphemeralIds`;restore reader 先恢复 snapshot 中的 sticky set,再应用 snapshot 之后的 upsert/remove。否则 snapshot baseline advance 后旧 tombstone 不再需要 replay,sticky override 会丢失。
|
||||
|
||||
### 4.4 Snapshot 与 tombstone 不变量
|
||||
|
||||
artifact snapshot 只用于减少 replay 的 artifact event 应用量;它不会减少 JSONL 文件本身的读取量。
|
||||
|
||||
必须满足:
|
||||
|
||||
- snapshot generation 必须在同一个 artifact operation queue 中串行执行,并严格位于所有 preceding mutation 之后。
|
||||
- snapshot 是 authoritative current state:它只包含 snapshot 生成时仍有效的 artifacts。
|
||||
- `tombstonedIds` 只记录 snapshot 之后仍需要覆盖旧 upsert 的 tombstones;被 snapshot 覆盖的旧 tombstones 不再进入新 snapshot payload,避免数组随历史无限增长。
|
||||
- `stickyEphemeralIds` 记录当前仍处于 sticky ephemeral override 的 artifact id,即使对应旧 tombstone 已经不需要 replay,也必须保留该 override 状态。
|
||||
- `stickyEphemeralIds` 必须有界,默认和 persisted metadata 上限共享同一 `maxPersistedMetadata` 数量级,并计入 artifact journal working-set budget。旧 `unpin_to_ephemeral` journal replay 若会超过 sticky set 上限,restore/prune 必须记录 warning 后稍后重试,不能静默增长、随机裁剪旧 sticky override,或让隐式 upsert 恢复持久化。
|
||||
- snapshot 可以包含曾经被 tombstone 的 artifact id,前提是该 tombstone 已被更高 sequence 的显式 upsert supersede。
|
||||
- load 时从新到旧选择最新 valid snapshot,然后只应用该 snapshot 之后的 artifact events。
|
||||
- 如果最新 snapshot 解析失败,记录 `snapshot_invalid` warning,继续尝试上一个 valid snapshot;不能因为一个 corrupt snapshot 丢失整个 session 的 artifact metadata。
|
||||
- 如果没有任何 valid snapshot,允许对 active JSONL leaf chain 做一次顺序 artifact event replay。isolated corrupt artifact record 应跳过并记录 warning;只有 branch ordering、record envelope 或 tombstone 状态已经无法建立可信顺序时,才丢弃该 session 的 artifact persistence records。
|
||||
|
||||
这里的 snapshot baseline advance 不会重写或删除 JSONL 里的旧 record。旧 `session_artifact_snapshot`、event 和 tombstone 仍保留在 append-only chat transcript 中;artifact 子系统只是在最新 snapshot payload 内前移恢复基线并重置工作集计数。
|
||||
|
||||
### 4.5 存储消耗
|
||||
|
||||
V2 不双写 sidecar,因此没有 JSONL + sidecar 的 metadata 重复存储。存储消耗分为 metadata journal 和 content retention:
|
||||
|
||||
- Metadata 单条通常约 0.5 KB - 2 KB,取决于 title、description、url 和 metadata 大小。
|
||||
- 每 session 有效 persisted metadata 上限默认与 live store 对齐为 200 条,单个 snapshot 约 100 KB - 400 KB。
|
||||
- JSONL journal 会保存增量事件、snapshot 和 tombstone;append-only chat transcript 本身会增长。
|
||||
- content retention 才是主要空间来源,例如单 artifact 50 MB、单 session 200 MB、单 project 1 GB。
|
||||
|
||||
控制策略:
|
||||
|
||||
- artifact event journal 达到固定阈值后写 `session_artifact_snapshot`,例如每 100 次 artifact mutation 或每 256 KB artifact journal 写一次。
|
||||
- artifact persistence records 跟随 chat transcript 生命周期;不做独立文件 GC。
|
||||
- 每 session 增加 artifact journal working-set byte budget,例如 4 MB。该 budget 衡量恢复必须读取和应用的 artifact 工作集,也就是最新 valid snapshot 加其后的 artifact events;不能把 chat transcript 中已经被 snapshot 覆盖的旧 artifact records 计入 budget,否则 append-only JSONL 会变成不可恢复的一次性上限。
|
||||
- writer 必须显式跟踪 working-set bytes:每次写 snapshot 后记录该 snapshot 的 artifact byte size、JSONL append position 或 line index 作为 `postSnapshotBase`,之后每个 artifact event append 增加 `postSnapshotEventBytes`。预算检查使用 `snapshotBytes + postSnapshotEventBytes`,snapshot baseline advance 成功后重置 counter。若 writer 无法确认 base position 或 counter 状态,必须保守写新 snapshot;仍无法确认时降级或报错,不能无界追加。
|
||||
- budget 接近上限时先尝试写新 snapshot。若最新 snapshot 加 post-snapshot events 仍超过 budget,则不再写新的 restorable metadata,普通 artifact 降级为 `ephemeral` 并带 `persistenceWarning.code = "journal_budget_exceeded"`。
|
||||
- 不把 content bytes 写进 JSONL;PR #6259 也不写 daemon-managed artifact content storage。
|
||||
|
||||
## 5. 写入与恢复流程
|
||||
|
||||
### 5.1 Ingest-time validation
|
||||
|
||||
任何 artifact 进入 live store 和 JSONL 之前都必须做 ingest-time validation,不能只在 restore 时校验:
|
||||
|
||||
- `workspacePath`:必须是相对路径;resolve/realpath 后不能逃逸当前 workspace。
|
||||
- `url`:按 storage type 校验 scheme、userinfo、secret-like query/fragment。
|
||||
- `managedId`:拒绝路径形态、`..`、绝对路径、分隔符。
|
||||
- `published`:只能由 daemon 内部 trusted publisher 或 manifest-validated path 产生,不能由 client payload 自称。
|
||||
- 旧 `contentRef` / `expiresAt`:只作为 legacy journal 输入兼容;client payload 中出现时必须拒绝或 strip,当前 PR 不能生成新的字段。
|
||||
- `restoreState` / `persistenceWarning`:runtime-only response 字段;client payload 中出现时必须拒绝或 strip,不能写入 persisted artifact。
|
||||
- `clientRetained`:只能是 boolean,表示用户保留意图和稳定排序 hint,不是授权信号。只有显式 REST/SDK/UI action 可以设置;后台自动 ingest 不能伪造为用户保留。
|
||||
- `metadata`:执行 primitive-only、size limit、secret key/value 和 unsafe display payload checks。
|
||||
|
||||
验证失败时:
|
||||
|
||||
- 明确恶意或越界输入:拒绝请求。
|
||||
- 可能包含敏感 locator 但用户仍想展示 live artifact:可降级为 `ephemeral`,并写 `persistenceWarning.code = "validation_downgraded"`;不能写入 JSONL。
|
||||
|
||||
### 5.2 Artifact 写入流程
|
||||
|
||||
V1 流程:
|
||||
|
||||
```text
|
||||
ingest input -> normalize/validate -> upsert live store -> publish artifact_changed
|
||||
```
|
||||
|
||||
V2 流程:
|
||||
|
||||
```text
|
||||
ingest input
|
||||
-> normalize/validate
|
||||
-> in SessionArtifactStore operationQueue: compute effective mutation
|
||||
-> for restorable changes: request chat-recording writer append
|
||||
artifact journal/snapshot on the active leaf chain
|
||||
-> apply live-store mutation
|
||||
-> publish artifact_changed with effective retention/warning fields
|
||||
```
|
||||
|
||||
`SessionArtifactStore` 的 operation queue 负责串行化同一 session 的 live mutation、persistence request 和 SSE 顺序;真正的 JSONL append 仍由 chat recording owner 完成。普通 tool/hook artifact 如果 persistence writer 不可用,可以降级为 live-only `ephemeral` 后进入 live store。
|
||||
|
||||
如果 sticky ephemeral override 抑制了隐式/default upsert 的持久化,live artifact 必须带 `persistenceWarning.code = "sticky_override_active"`,并记录 structured log `action=sticky_override_suppressed` 和 counter metric。否则排障时会看到合法 upsert input 却找不到对应 durable record。
|
||||
|
||||
当前 PR 没有隐藏的 paged persisted metadata 视图;live list 就是恢复后暴露给 client 的 metadata 集合。因此上限处理采用一个收窄策略:
|
||||
|
||||
- `ephemeral` artifact 可以只从 live view 丢弃,不写 journal。
|
||||
- `restorable` artifact 被上限裁剪时,写 `reason: "eviction"` remove event,避免下次 load/replay 把已裁剪条目全部复活。
|
||||
|
||||
### 5.3 写入失败语义
|
||||
|
||||
区分两个入口:
|
||||
|
||||
- 普通 tool/hook artifact:持久化失败不应让工具调用失败;artifact 仍可进入 live store,但必须先把 live store 中的 `retention` 降级为 `ephemeral`,设置 `persistenceWarning`,再发布 `artifact_changed`。
|
||||
对会影响恢复结果的删除型 mutation,当前 PR 按原因区分:
|
||||
|
||||
- `eviction`:durable remove event,保证重启后仍遵守 200 条上限。
|
||||
- legacy unpin-to-`ephemeral`:读取旧 journal 时继续识别 durable remove event,并把 id 写入 bounded `stickyEphemeralIds`;后续隐式/default upsert 会保持 live-only,直到显式 `retention: "restorable"` supersede。
|
||||
- 显式 DELETE:live-first。先从 live store 移除并发布删除事件,再 best-effort 写 explicit remove tombstone。tombstone 写入失败时 response 返回 warning(当前为字符串 warning),表示删除没有 durable;如果 daemon 在补写成功前重启,旧 journal 仍可能恢复该 artifact。
|
||||
- `deleteContent: true` 不属于 PR #6259 的 public API。content-retention follow-up 才会定义 content GC 与 warning contract;当前 PR 的显式 DELETE 只处理 metadata tombstone 和 live removal。
|
||||
|
||||
建议 warning:
|
||||
|
||||
```text
|
||||
[artifacts] session=<id> action=persist_failed artifact=<id> reason=<code>
|
||||
[artifacts] session=<id> action=remove_not_persisted artifact=<id>
|
||||
[artifacts] session=<id> action=sticky_override_suppressed artifact=<id> prior_reason=unpin_to_ephemeral
|
||||
```
|
||||
|
||||
### 5.4 恢复流程
|
||||
|
||||
session load/replay 时:
|
||||
|
||||
1. `SessionService.loadSession()` 读取 JSONL,并在同一轮 parse 中提取 artifact snapshot/event records。
|
||||
2. 基于 active leaf chain 提取最新 valid `session_artifact_snapshot` 和之后的 `session_artifact_event`。abandoned branch 上的 artifact records 必须忽略。
|
||||
3. 重建 artifact snapshot,应用 tombstone。
|
||||
4. 对每个 artifact 重新执行 V2 restore validation。
|
||||
5. load result 携带 `artifactSnapshot` 回到 daemon-side bridge。
|
||||
6. daemon bridge 在 `createSessionEntry` / restore completion 时用 snapshot 初始化 daemon 侧 `SessionArtifactStore`。
|
||||
7. `GET /session/:id/artifacts` 读取的就是这个 daemon-side store。
|
||||
|
||||
不要在 ACP child process 的 agent/session 对象里 seed `SessionArtifactStore`:生产 HTTP API 可见的 store 在 daemon-side bridge 中创建。
|
||||
|
||||
`loadSession()` 必须是 read-only:它不能在解析过程中写 tombstone,也不能直接触发 content GC。若 restore 后发现当前 live cap 或 policy 比历史更严格,daemon-side store 在创建完成、persistence writer 可用后,再通过正常 operation queue 写 `eviction` remove event;writer 不可用时只在 live view 中隐藏超限 item,并记录 warning,下一次 load 仍可能重新看到这些待裁剪记录。
|
||||
|
||||
rewind/replay 中的 live store 处理必须和 load 一致:一旦 active leaf 改变,flat live store 不能继续保留 off-branch artifact mutation。若当前实现没有 active-chain replay result 可直接 reseed,必须在 rewind 完成时写入 artifact snapshot top-up,否则不能启用 persistence capability。
|
||||
|
||||
具体集成点必须是显式 hook,而不是靠下一次 GET 懒修复。建议由 rewind/leaf-switch 实现调用 daemon bridge 的 `onActiveLeafChanged(sessionId, artifactSnapshot)`,或在现有 session load/replay result 中携带同等事件;artifact store 收到后在同一 session operation queue 中 reseed 或写 top-up snapshot。
|
||||
|
||||
### 5.5 恢复时校验
|
||||
|
||||
恢复时必须重新校验:
|
||||
|
||||
- `workspacePath`:仍必须是相对路径,按 restore 时的 workspace root 重新 resolve/realpath/stat,不能逃逸当前 workspace。workspace 重定位后,如果相同相对路径仍存在则可恢复为 `available`;如果文件缺失或新 workspace layout 不一致,则恢复为 `missing`。V2 不做自动 path remapping。
|
||||
- `external_url`:只允许 `http:` / `https:`;拒绝 username/password credential;secret-like query/fragment 必须 redacted、降级为 non-openable locator,或整条 artifact 降级/阻断。
|
||||
- `published`:可以恢复 `file:` locator,但只能在 trusted publisher manifest 重新校验通过、且目标属于 daemon-managed published storage 时允许。普通 `external_url` 永远不能通过 `file:`。
|
||||
- `managedId`:拒绝路径形态、`..`、绝对路径、分隔符。
|
||||
- 旧 `contentRef`:只作为 legacy journal 输入校验并 strip;PR #6259 不通过 daemon-managed manifest 解析内容,也不把旧 `contentRef` 暴露为可打开内容承诺。
|
||||
- `metadata`:重新执行 primitive-only、size limit、secret key/value 和 unsafe display payload checks。
|
||||
|
||||
恢复失败时:
|
||||
|
||||
- 安全失败:保留条目但 `restoreState: "blocked"`,`status: "missing"`,不提供可打开 locator。
|
||||
- 资源缺失:`status: "missing"`。
|
||||
- 非安全型字段损坏:跳过该 artifact,并记录 warning。
|
||||
|
||||
### 5.6 Branch / fork 语义
|
||||
|
||||
现有 `/branch` 会复制 active JSONL record chain 并重写 `sessionId`。V2 artifact records 只从 active leaf chain 复制;rewind 后落在 abandoned branch 上的 artifact records 不会进入 fork。复制时必须显式处理 artifact id:
|
||||
|
||||
- 同一个资源在新 session 中应得到新 artifact id,因为 V1 identity 包含 `sessionId`。
|
||||
- fork 写入目标 session 时,应根据目标 `sessionId + locator` 重新计算 artifact id。
|
||||
- tombstone 也要按目标 session 的新 id 重写。只要 tombstone 的 artifact id 可以安全 remap,就应保留到目标 session,即使目标 active chain 中暂时找不到对应 upsert;orphan tombstone 没有匹配 upsert 时是无害的,但丢弃它可能让后续同 id upsert 丢失 suppression。
|
||||
- `forkedFrom` 可以记录原 session id / 原 artifact id,作为审计信息,但不能参与新 session 的权限判断。
|
||||
- fork 继承旧 `pinned` artifact metadata 时,必须降级为 `restorable`,并移除旧 `contentRef`。
|
||||
- fork copy 必须重新执行 ingest/restore validation、privacy minimization 和 redaction。workspace / url / metadata 中无法在目标 session 安全表达的 locator 必须降级、strip 或丢弃,不能因为源 session 曾经通过校验就直接复制。
|
||||
- `managedId` 不能从源 session 盲目复制。目标 session 中若能从目标 workspace / daemon-managed manifest 派生新的 `managedId`,必须重新计算;不能安全派生时必须移除 `managedId` 或丢弃该 artifact metadata。
|
||||
|
||||
fork remap 是发布门槛:如果某条路径不能安全重写 artifact id 和 tombstone,就必须在 fork 时丢弃 artifact persistence records,不能把源 session 的 artifact id 原样带入新 session。若现有 fork 实现有类似 `file_history_snapshot` 的 top-up 机制,artifact 也只能从 active-chain replay result 生成 top-up,不能从 daemon 当前 live store 原样补写,否则会把 rewind 后不再属于历史的 artifact 带入新 session。
|
||||
|
||||
当前 fork 实现不是逐条 append,而是先从 source active chain 生成完整目标 record 列表,再用 exclusive-create 写入目标 JSONL 文件;写入失败时目标 session 文件不会被当作成功 fork 使用。因此当前 PR 不写 `session_artifact_fork_marker`。如果未来 fork 改为 streaming append 或跨进程批量复制,再引入 begin/complete marker、count 校验和 `fork_incomplete` 恢复规则。
|
||||
|
||||
fork 的 rewind 语义是 branch-scoped:目标 session 只复制当前 active chain 的结果。如果用户 rewind 到显式 DELETE 之前再 fork,那个 DELETE tombstone 本来就不在 active chain 中,artifact 在新 branch 中重新出现是预期的历史分支行为。若产品需要“全局不可 rewind 删除”或隐私擦除语义,应作为单独的 policy 设计,不能混入 V2 默认 branch model。
|
||||
|
||||
metadata 的 fork amplification 在 V2 中作为有界 trade-off 接受:fork 需要 session mutate 权限,每个 fork 仍受 200 条 persisted metadata 上限,metadata 单条较小,且不会继承 content bytes。V2 不引入 project-level metadata quota;实现必须记录 forked artifact count metric/log,若实际滥用再引入 project-level cap。
|
||||
|
||||
## 6. API 设计
|
||||
|
||||
### 6.1 Capability
|
||||
|
||||
`GET /capabilities` 增加:
|
||||
|
||||
```json
|
||||
"session_artifacts_persistence"
|
||||
```
|
||||
|
||||
内容保留拆分 PR 实现可用时,才同时声明:
|
||||
|
||||
```json
|
||||
"session_artifacts_content_retention"
|
||||
```
|
||||
|
||||
当前 `/capabilities` 是 string feature list,因此不能用 `enabled: false` 表达“实现存在但当前关闭”。规则是:
|
||||
|
||||
- 行为可用且当前配置启用时才声明对应 feature string。
|
||||
- chat recording 禁用、metadata persistence 禁用或 writer 不可用时,不声明 `session_artifacts_persistence`。
|
||||
- future content archive 的显式 workspace content 保存、quota、manifest、session-scoped GC/fsck 都可用时,才声明 `session_artifacts_content_retention`。PR #6259 不声明该 capability。
|
||||
- 如果 client 需要读取 limits/default retention,应另设计 config endpoint 或 SDK config query;不要把结构化 details 混入现有 string-only capability contract。
|
||||
|
||||
### 6.2 Add artifact
|
||||
|
||||
`POST /session/:id/artifacts` 允许 optional:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Report",
|
||||
"kind": "html",
|
||||
"storage": "workspace",
|
||||
"workspacePath": "reports/run.html",
|
||||
"retention": "restorable",
|
||||
"clientRetained": true
|
||||
}
|
||||
```
|
||||
|
||||
限制:
|
||||
|
||||
- client 可以请求 `ephemeral` 或 `restorable`。
|
||||
- client 不能请求 `pinned`。
|
||||
- `clientRetained` 可选,仅表示用户保留意图和排序 hint;服务端必须按 §5.1 校验来源,不能把它当授权。
|
||||
|
||||
### 6.3 Pin/save artifact
|
||||
|
||||
PR #6259 不暴露 pin/save endpoint。显式内容留档、content archive、pin/save 语义如未来需要,应基于新的产品需求重新设计,不能从本文当前 metadata persistence contract 推导。
|
||||
|
||||
### 6.4 Unpin
|
||||
|
||||
PR #6259 不暴露 unpin endpoint,也不会生成新的 unpin tombstone。旧 journal 里的 `reason: "unpin_to_ephemeral"` 只作为兼容输入继续 replay,避免历史记录恢复语义变化。若要从列表移除,仍使用 V1 DELETE。
|
||||
|
||||
### 6.5 Delete artifact
|
||||
|
||||
V2 的 DELETE 仍保持 V1 幂等,并采用当前 PR 的 live-first 语义:
|
||||
|
||||
- 先从 live store 移除 artifact,保持用户可见删除即时生效。
|
||||
- 随后 best-effort append `session_artifact_event` remove tombstone;tombstone 成功后,metadata restore 时不再复活。
|
||||
- tombstone 失败时,返回成功 mutation result 但附带 warning;当前 daemon 生命周期内该 artifact 已被删除,但如果 daemon 在 tombstone 持久化前重启,旧 durable artifact 仍可能恢复。用户或上层 UI 可以在 storage 恢复后重试 DELETE。
|
||||
- DELETE 对不存在的 artifact 保持幂等成功;如果已有 durable tombstone,重复 DELETE 不需要再写同一 tombstone。
|
||||
- PR #6259 的 DELETE 不接受 `deleteContent`,也不触发 daemon-managed content GC;旧 `contentRef` metadata 只在 restore/serialization 时被降级或移除。
|
||||
|
||||
### 6.6 Mutation responses
|
||||
|
||||
PR #6259 只交付 DELETE mutation response。
|
||||
|
||||
成功:
|
||||
|
||||
- DELETE:`200 OK` 返回 `{ "deleted": true, "artifactId": string, "warnings"?: [...] }`。
|
||||
- DELETE tombstone 持久化失败时仍返回 `200 OK` mutation result,并在 `warnings` 中包含持久化失败原因;当前实现使用字符串 warning,例如 `remove_not_persisted`。这表示 live delete 已生效但跨重启不保证,不能把它展示成 durable delete 成功。
|
||||
|
||||
失败:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "INVALID_ARGUMENT",
|
||||
"message": "retention must be ephemeral or restorable"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
PR #6259 的 HTTP mapping:
|
||||
|
||||
- `400 VALIDATION_FAILED`:非法 body、client 请求 `pinned`、artifact 不存在、metadata quota 已满且没有可裁剪 candidate,或 writer 不可用但 mutation 必须严格 durable 完成。
|
||||
- `403 FORBIDDEN`:缺少 session mutate 权限。
|
||||
- DELETE 保持幂等;不存在的 artifact 返回空 mutation result 而不是错误。
|
||||
- DELETE tombstone 持久化失败返回 `200 OK` + warning,因为当前 live delete 已生效但跨重启不保证。
|
||||
|
||||
更细粒度的 `INVALID_ARGUMENT`、`NOT_FOUND`、`CONFLICT`、`METADATA_QUOTA_EXCEEDED`、`QUOTA_EXCEEDED` 或 `PERSISTENCE_UNAVAILABLE` HTTP error code 是后续 API polish,不属于当前 PR 的 wire contract。
|
||||
|
||||
## 7. 安全设计
|
||||
|
||||
### 7.1 授权原则
|
||||
|
||||
不要把 public `clientId` 当授权边界。V2 的实际 HTTP 信任边界仍是 daemon bearer token + route-level read/mutate permission;在现有 auth 模型下,`session_owner` 不能被安全 mint 或跨 daemon restart 持久化。因此 V2 不引入强于 token-holder 的 owner tier。
|
||||
|
||||
内部 principal 只用于审计、默认策略和防止 payload spoofing;不是 durable authorization source:
|
||||
|
||||
```ts
|
||||
type ArtifactPrincipal =
|
||||
| { kind: 'token_holder' }
|
||||
| { kind: 'client_connection'; id: string }
|
||||
| { kind: 'trusted_publisher'; id: string }
|
||||
| { kind: 'hook'; extensionId: string };
|
||||
```
|
||||
|
||||
授权规则:
|
||||
|
||||
- list:需要 session read 权限。
|
||||
- add ephemeral/restorable:需要 session mutate 权限。
|
||||
- delete metadata:需要 session mutate 权限。V1 same-principal delete guard 只能作为 live-process UX guard 和 audit hint;它依赖当前连接上下文,不能跨 daemon restart 证明 artifact owner。restore 后不能从 public `clientId` 伪造 ownership,删除授权退化为 session-level mutate 权限并记录 `ownership_unverified` audit。
|
||||
- content archive / delete content:当前 PR 不启用。未来若重启 content archive,需要 session mutate 权限、独立 capability、显式 REST/SDK call,以及当前进程可验证的 creator-principal match 或显式 override/admin policy;background session/hook 不能直接发起内容删除。
|
||||
|
||||
如果未来需要真正的 `session_owner`,必须先设计 durable per-session capability 或 ACL,不能在本 V2 文档中隐式假设。
|
||||
|
||||
### 7.2 Future content archive 边界
|
||||
|
||||
本节是 future content archive 蓝图,不属于 PR #6259 的实现或验收范围。
|
||||
|
||||
默认不复制:
|
||||
|
||||
- external URL 内容
|
||||
- 任意 workspace 文件
|
||||
- 普通 assistant link
|
||||
|
||||
未来若启用 content archive,可考虑允许的来源:
|
||||
|
||||
- trusted `ArtifactTool` / publisher 生成的 `published` artifact。
|
||||
- 用户显式 pin 的 workspace artifact,且文件在 workspace 内、类型/大小可控。
|
||||
- client 上传或登记的 managed artifact,前提是通过 daemon API 接收并校验。
|
||||
|
||||
daemon-managed artifact storage 必须有明确 root:
|
||||
|
||||
- `managed_copy` content root 位于 daemon 数据目录下的 artifact content 区域,例如 `<daemonDataDir>/artifacts/content/`。
|
||||
- `published` file root 位于 daemon 数据目录下的 published artifact 区域,例如 `<daemonDataDir>/artifacts/published/`,或位于配置声明的等价 daemon-owned root;root id 必须写入 publisher manifest。
|
||||
- JSONL 里不能保存可直接信任的宿主机绝对路径。restore 时只能读取 manifest 中的 root id 和相对 locator,resolve/realpath 后必须仍位于对应 root 内,并拒绝 symlink/path escape。
|
||||
- trusted publisher manifest 至少记录 publisher id、artifact id、storage root id、relative path 或 content id、sha256、sizeBytes 和 createdAt。`file:` locator 只能由该 manifest 重新生成,不能来自 client payload 或旧 JSONL 字段。
|
||||
|
||||
内容复制必须 race-safe:
|
||||
|
||||
- workspace containment 校验通过。
|
||||
- 只允许 regular file;拒绝目录、FIFO、device、socket 和其它特殊文件。
|
||||
- 打开文件时使用 no-follow 语义;Linux 可用 `openat2(RESOLVE_NO_SYMLINKS)`,其它平台用可用的 no-follow/open-handle revalidation 组合。
|
||||
- 打开后对 file handle 执行 fstat/revalidate,确认仍是 regular file、仍在 workspace containment 内。
|
||||
- 拒绝 link count 异常的 hardlink,除非后续有明确 allowlist。
|
||||
- 读取时按 stream 强制 max bytes,不能先信任 stat size。
|
||||
- hash exactly the bytes copied,并保存 sha256、size、mimeType。
|
||||
- 打开/下载 retained content 前重新校验 manifest/hash。
|
||||
|
||||
### 7.3 隐私和敏感信息
|
||||
|
||||
持久化前必须做最小化:
|
||||
|
||||
- 不保存 host 绝对路径。
|
||||
- 不保存 URL username/password。
|
||||
- external URL 的 secret-like query/fragment 必须拒绝、redact,或将 artifact 降级为 `ephemeral` / non-openable locator;不能原样写入 JSONL。
|
||||
- metadata 使用 allowlist 或 secret-key denylist;`token`、`password`、`secret`、`cookie`、`authorization` 等 key/value 必须拒绝、redact,或降级为 `ephemeral`。
|
||||
- metadata 仍限制 4 KB。
|
||||
- title/description/metadata 继续执行 unsafe display payload checks。
|
||||
- `persistenceWarning.message` 即使只作为 live response 字段,也必须使用 path-free 模板或脱敏文本;不能把 host path、credential、token、content root、connection id 写入 warning。
|
||||
|
||||
后续可新增设置:
|
||||
|
||||
```json
|
||||
{
|
||||
"sessionArtifacts": {
|
||||
"persistence": {
|
||||
"enabled": true,
|
||||
"defaultRetention": "restorable",
|
||||
"maxLiveArtifacts": 200,
|
||||
"maxPersistedMetadata": 200,
|
||||
"snapshotThresholdMutations": 100,
|
||||
"snapshotThresholdBytes": 262144,
|
||||
"contentRetention": {
|
||||
"enabled": false,
|
||||
"maxArtifactBytes": 52428800,
|
||||
"maxTotalBytes": 268435456,
|
||||
"maxTtlDays": 365,
|
||||
"ttlScanIntervalSeconds": 900
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
当前 PR 不新增 operator 配置 schema;上述值以代码常量形式发布,并通过 capability 表达行为是否可用。把这些值暴露为 operator tunables 是后续增强,不能让 client 从 capability string 推断配置细节。
|
||||
|
||||
## 8. 配额、GC 与稳定性
|
||||
|
||||
### 8.1 Metadata quota
|
||||
|
||||
建议默认:
|
||||
|
||||
- live store 上限仍为 200。
|
||||
- persisted metadata 上限每 session 200,与 live store 对齐。
|
||||
- snapshot record 最多保留 200 个当前有效 artifacts。
|
||||
|
||||
live store 上限在当前实现中也是 restore 可见集合的上限:
|
||||
|
||||
- V2 live eviction 必须优先淘汰 `ephemeral` artifact。
|
||||
- 如果必须在 durable artifacts 中选择 live view,当前实现按 source reservation、source、status、retention、clientRetained 和 insertion order 做确定性选择。
|
||||
- durable artifact 被 live cap 淘汰时,当前实现会写 `reason: "eviction"` 的 remove event,确保下一次 restore 不反复复活已被 daemon 淘汰的 item。
|
||||
- `clientRetained` 是用户保留意图,进入 `PersistedSessionArtifact`,用于 restore 后稳定排序和 live cap 选择;它是排序保护,不是绝对保护。
|
||||
|
||||
超过 persisted metadata 上限:
|
||||
|
||||
- `ephemeral` 本来不写 journal,不计入 persisted metadata quota,只受 live store 上限约束。
|
||||
- `restorable` 必须按确定性顺序裁剪并写 `eviction` remove event:先裁剪未 `clientRetained` 的 `restorable` artifact;如果仍无空间,再裁剪 `clientRetained` 的 `restorable` artifact。`clientRetained` 是排序保护,不是绝对保护。
|
||||
|
||||
restore seed 不能超过 live store 上限;若历史里有效 persisted artifact 超过当前 live cap,daemon-side store 按同一确定性规则 seed 可见 subset,并通过 operation queue 为被裁剪的 durable item 写 `eviction` remove event。`loadSession()` parse 过程本身保持 read-only,不能直接写 durable prune。
|
||||
|
||||
### 8.2 Content quota
|
||||
|
||||
本节是后续 content-retention PR 的实现范围;PR #6259 不引入 content store quota。
|
||||
|
||||
后续拆分 PR 的建议默认:
|
||||
|
||||
- 单 artifact:50 MB。
|
||||
- content store total:256 MB。
|
||||
|
||||
达到上限时:
|
||||
|
||||
- 新 pin/save 返回 `QUOTA_EXCEEDED`。
|
||||
- 不自动删除仍被当前 session live artifact 引用的 pinned content。
|
||||
- fork 不继承 pinned contentRef,避免 fork 绕过 quota。
|
||||
|
||||
### 8.3 GC
|
||||
|
||||
本节是后续 content-retention PR 的实现范围。GC 只处理 daemon 管理的 session-scoped managed copy:
|
||||
|
||||
- content manifest 保存 `sessionId` 和 `artifactId`;GC 只删除 manifest 属于当前 session 且不在当前 live `contentRefs()` 引用集合中的 content。
|
||||
- `pinWorkspaceFile()`、GC、tmp cleanup 通过同一个 write queue 串行化,并用 in-flight lease 避免并发 pin/GC 删除刚复制但尚未 journal 的 content。
|
||||
- `expiresAt` 到期通过 `GET /artifacts` 前的 lightweight prune 把 pinned artifact 降级为 `restorable`,移除 `contentRef` 后再触发 GC。
|
||||
- close / explicit delete / unpin / explicit GC endpoint 都会 best-effort sweep;GC 失败不阻塞 prompt/tool flow。
|
||||
|
||||
GC trigger:
|
||||
|
||||
- artifact delete、unpin、TTL 到期检查、session close 或 explicit `POST /session/:id/artifacts/gc`。
|
||||
- stale `.tmp` entries are cleaned during GC.
|
||||
|
||||
Project-scoped reference rebuild、incomplete-scan tracking、orphan grace period 和 global artifact library 都是后续增强。future content archive 的 safety 边界应来自“不跨 session 继承 contentRef”和“只删除当前 session manifest 且当前 live refs 未引用的 content”。
|
||||
|
||||
### 8.4 Crash consistency
|
||||
|
||||
要求:
|
||||
|
||||
- artifact store mutation 串行。
|
||||
- JSONL journal append 失败不会破坏 live store。
|
||||
- explicit DELETE live-first:live store removal must not be blocked by journal failure; response warning tells clients when the tombstone was not durable.
|
||||
- explicit DELETE with `deleteContent: true` is only available in the content-retention follow-up; that PR must run best-effort session-scoped content GC after live removal and surface content delete warnings.
|
||||
- live cap eviction for durable artifacts writes an `eviction` remove event so restore respects the cap.
|
||||
- reader 容忍半截 JSONL 和 corrupt artifact record。
|
||||
- tombstone / snapshot 顺序异常时选择不恢复,而不是猜测。
|
||||
|
||||
Future content archive 的写入顺序:
|
||||
|
||||
1. 复制内容到 staging path,hash exactly copied bytes,并 fsync bytes。
|
||||
2. atomically move 到 daemon-managed content root,写入并 fsync content manifest。
|
||||
3. append artifact journal event,引用该 contentRef,并 fsync JSONL。
|
||||
4. 更新 live store 并发布 `artifact_changed`。
|
||||
|
||||
如果第 2 步成功但第 3 步前 crash,会留下没有 journal 引用的 orphan content;这是允许的,future session-scoped GC 在确认 manifest 不被当前 live refs 引用后 best-effort 删除。如果第 3 步成功,restore 必须能通过 manifest 找到内容。显式 API 只有在第 3 步成功后才能返回成功。
|
||||
|
||||
### 8.5 文件读取、CPU 与 I/O 成本
|
||||
|
||||
V2 要避免把 artifact 恢复变成 session load 的新瓶颈。
|
||||
|
||||
读取路径建议:
|
||||
|
||||
1. `SessionService.loadSession()` 已经读取 JSONL 时,在同一轮 parse 中提取 artifact records。
|
||||
2. 找到最新 valid `session_artifact_snapshot`,只 replay 之后的 artifact events。
|
||||
3. 没有 valid snapshot 时允许一次顺序扫描 artifact records,但不能在 load 流程里反复扫同一文件。
|
||||
|
||||
CPU 成本边界:
|
||||
|
||||
- Metadata restore 只 parse JSON 和做字段校验,复杂度 O(artifact 数量 + 最新 snapshot 后事件数)。
|
||||
- `external_url` 恢复不发网络请求。
|
||||
- `workspace` load/replay 只恢复 metadata;GET/list refresh 在 TTL/batch 限制下重新 stat 单个或一批 workspace 文件,必要时才 hash,用于区分 `available` / `missing` / `changed`。
|
||||
- `managed` / `published` 恢复只查 manifest,不读取大文件内容。
|
||||
- workspace content hash 不在 `loadSession()` 的 JSONL parse 阶段全量执行。GET/list refresh 先用 size + mtimeMs 做 cheap stat gate;只有 stat 显示可能同尺寸改写时才读取文件流计算 sha256。
|
||||
|
||||
I/O 成本边界:
|
||||
|
||||
- V2 不额外读 sidecar 文件。
|
||||
- workspace 状态校验复用 V1 的 TTL/batch 策略,不在 GET 热路径对所有 artifact 做无限制 stat。
|
||||
- 对大 workspace 文件,不在恢复阶段读内容;登记时读取实时文件流计算 sha256,后续 refresh 只有 size/mtimeMs 显示可能变更时才重新读取文件流,不复制到 daemon-managed storage。
|
||||
|
||||
推荐默认:
|
||||
|
||||
- artifact snapshot 上限 200 条。
|
||||
- workspace status restore batch size 20,与 V1 保持一致。
|
||||
- artifact journal snapshot 阈值 100 mutations 或 256 KB。
|
||||
- workspace sha256 在登记时同步完成;恢复后的状态校验按 TTL/batch lazy refresh,并通过 size + mtimeMs 避免对未变化文件重复做全量 hash。
|
||||
|
||||
### 8.6 Observability
|
||||
|
||||
V2 新增的失败路径必须有 structured logs,格式沿用:
|
||||
|
||||
```text
|
||||
[artifacts] session=<id> action=<action> key=value
|
||||
```
|
||||
|
||||
建议 action:
|
||||
|
||||
- `persist_failed`
|
||||
- `retention_downgraded`
|
||||
- `restore_skipped`
|
||||
- `restore_blocked`
|
||||
- `remove_not_persisted`
|
||||
- `eviction`
|
||||
- `fork_artifact_discarded`
|
||||
- `fork_incomplete`
|
||||
- `snapshot_invalid`
|
||||
- `sticky_override_suppressed`
|
||||
- `tombstone_conflict`
|
||||
- `v2_writer_version_gate_failed`
|
||||
|
||||
Future checker / content archive 可以再增加 fsck、content copy、TTL、GC 相关 action;PR #6259 不产生这些日志。
|
||||
|
||||
这些日志不替代 API/SSE 中的 `persistenceWarning`,而是用于生产排障。
|
||||
|
||||
建议 metrics:
|
||||
|
||||
- counter: `artifact_journal_append_total{result,reason}`
|
||||
- counter: `artifact_restore_total{result,restore_state}`
|
||||
- gauge: `artifact_pending_tombstone_count`
|
||||
- gauge: `artifact_metadata_quota_used{session}`
|
||||
- counter: `artifact_sticky_override_suppressed_total`
|
||||
|
||||
导出方式沿用 daemon 现有 telemetry/metrics 机制;如果当前没有 Prometheus endpoint,至少要进入 structured telemetry sink,并能按 session/project 聚合。
|
||||
|
||||
诊断工具是后续增强,不属于 PR #6259 的 wire contract。metadata-only checker 可扫描 artifact journal/snapshot/tombstone 与 restore validation failure;full content checker 则等 future content archive 重新设计后,再扫描 content manifests 和 daemon-managed storage。未来 CLI 或 daemon-internal API(例如 `qwen artifact fsck`)应支持 dry-run:
|
||||
|
||||
- metadata-only 模式报告 snapshot/tombstone 不一致和 restore validation failure。
|
||||
- full content 模式报告 dangling `contentRef`、manifest 缺失和 orphan content。
|
||||
- 默认只读;修复模式只能做可验证的安全动作,例如重新生成 snapshot 或标记 orphan content 等待 GC。
|
||||
|
||||
## 9. 实现方案
|
||||
|
||||
以下是同一个 V2 design phase 内的实现里程碑。工程上可以按 PR 拆开;对外以 capability 声明实际可用能力。
|
||||
|
||||
### Milestone A: 类型和 persistence service
|
||||
|
||||
- 新增 artifact persistence reader/writer:
|
||||
- writer 位于 chat recording owner 一侧,或者由该侧暴露明确 RPC;它负责 append event/snapshot record 到 active leaf chain。
|
||||
- reader 位于 `SessionService.loadSession()` parse/replay 路径,负责从 active leaf chain rebuild artifact snapshot。
|
||||
- 共享 restore validation、snapshot/tombstone consistency checks 和 persisted shape normalization。
|
||||
- 扩展 `ChatRecord.subtype` 与 `systemPayload` union。
|
||||
- 增加 load result 中的 `artifactSnapshot?`。
|
||||
- metadata-only checker 是后续增强,可 dry-run 检测 corrupt artifact records、snapshot/tombstone 不一致和 restore validation failure。
|
||||
|
||||
### Milestone B: daemon-side store 集成
|
||||
|
||||
- daemon bridge `createSessionEntry` 支持 seed artifacts。
|
||||
- `SessionArtifactStore` 支持 seed artifacts。
|
||||
- `upsertMany()` 在 operation queue 中计算 effective `retention`、quota prune 和 live view,再通过 writer append durable records。
|
||||
- `remove()` 区分 explicit DELETE 和 eviction;explicit DELETE live-first 并 best-effort 写 tombstone,durable eviction 写 journal。旧 `unpin_to_ephemeral` 只在 journal replay / snapshot sticky state 中保留兼容。
|
||||
- V1 live session 首次启用 V2 的 backfill snapshot 不在当前 PR 实现范围内;当前实现从新写入的 V2 journal/snapshot 恢复。
|
||||
- 保持 V1 `artifact_changed` event shape 不变,只增加 optional fields。
|
||||
|
||||
### Milestone C: load/replay 集成
|
||||
|
||||
- `SessionService.loadSession()` 从 active leaf chain 提取 artifact snapshot/event records,忽略 abandoned branches。
|
||||
- load result 把 snapshot 交给 daemon bridge,而不是在 ACP child process 中 seed store。
|
||||
- restore over-cap prune 写入只能在 daemon-side store 创建并且 writer 可用后执行;load parse 过程保持 read-only。
|
||||
- rewind/leaf switch 后,daemon-side live store 重新对齐 active-chain replay result,或通过 artifact snapshot top-up 固化 surviving chain 的当前状态。
|
||||
- rewind/leaf-switch 必须调用明确 hook,例如 `onActiveLeafChanged(sessionId, artifactSnapshot)`,让 daemon-side store 在 operation queue 中完成 reseed/top-up。
|
||||
- replay 历史时同 identity artifact 不重复创建。
|
||||
- `/branch` 从 active chain 复制 artifact records 并 remap session id/artifact id;当前 full-file exclusive-create 写入路径不需要 fork marker。
|
||||
|
||||
### Milestone D: REST/SDK
|
||||
|
||||
- SDK type 增加 optional fields。
|
||||
- `POST /session/:id/artifacts` 支持 `retention: "ephemeral" | "restorable"`。
|
||||
- `POST /session/:id/artifacts` 支持 `clientRetained` boolean hint,并拒绝 client 传入 daemon-only runtime fields。
|
||||
- capability gate UI。
|
||||
|
||||
### Milestone E: Future content archive
|
||||
|
||||
不属于 PR #6259。若未来有审计/留档需求,需要单独设计 daemon-managed workspace content manifest、quota、race-safe copy、hash 校验、write-queue/lease-protected GC/fsck 和 published artifact content binding。
|
||||
|
||||
## 10. 测试计划
|
||||
|
||||
PR #6259 当前必须覆盖:
|
||||
|
||||
- metadata journal append 后 daemon restart/load 恢复 artifact list。
|
||||
- artifact journal append 通过 chat recording owner 写入 active leaf chain;daemon-side store 不能直接写 JSONL。
|
||||
- `/rewind` 后 abandoned branch 上的 artifact upsert/remove 不参与恢复,也不会在 fork 中复制。
|
||||
- `/rewind` 后 live store 立即与 active-chain artifact state 对齐;不会等到 daemon 重启才改变 artifact list。
|
||||
- V1 live session 升级到 V2 时的 backfill snapshot 是后续增强;当前 PR 测试应确认未写入 V2 journal 的旧 live artifacts 不被误报为可恢复。
|
||||
- DELETE tombstone 后 load 不复活 artifact。
|
||||
- legacy `unpin_to_ephemeral` tombstone replay 后 load 不复活 artifact。
|
||||
- legacy `unpin_to_ephemeral` 后,同一 artifact id 的隐式/default re-upsert 仍保持 live-only;显式 `restorable` 可以 supersede sticky override。
|
||||
- snapshot baseline advance 后 `stickyEphemeralIds` 仍能让隐式/default re-upsert 保持 live-only,并产生 `sticky_override_suppressed` log/metric/warning。
|
||||
- `stickyEphemeralIds` 达到上限时,legacy unpin-to-ephemeral 返回错误或延后重试,且不会静默丢失旧 sticky override。
|
||||
- explicit DELETE live-first:live view 立即移除;tombstone 写入失败时 response 带 warning,测试覆盖 live removal 不被 persistence failure 阻断。
|
||||
- durable artifact eviction 写 `eviction` remove event;restore 后不会超过 live cap。
|
||||
- snapshot baseline advance:periodic snapshot 压缩当前 artifact list,explicit tombstone 在 snapshot 成功后不再无界增长,`stickyEphemeralIds` 保留 sticky state。
|
||||
- workspace artifact ingest 和 restore 时文件存在/缺失/symlink escape 三种状态。
|
||||
- workspace root 重定位:相同相对路径存在时恢复为 available;缺失或 layout 不一致时恢复为 missing;不做 path remap。
|
||||
- external URL 只恢复 metadata,不发网络请求。
|
||||
- secret-bearing URL query/fragment 与 metadata key/value 不写入 JSONL。
|
||||
- published local `file:` 只有 trusted manifest revalidation 通过时恢复。
|
||||
- `managedId` 在 ingest、restore 和 fork remap 时拒绝分隔符、`..`、绝对路径和路径形态;fork 不能盲目复制源 session 的 `managedId`。
|
||||
- corrupt JSONL record 被跳过且不影响其它 artifacts。
|
||||
- chat recording / persistence disabled 时不声明或不启用 metadata restore。
|
||||
- tool artifact 持久化失败时降级为 live-only,并通过 `persistenceWarning` 让 client 可见。
|
||||
- branch/fork 时 artifact records 的 sessionId/id 处理,且只使用 active-chain replay result。
|
||||
- fork full-file write:active-chain remap 后 exclusive-create 写入目标 JSONL,失败不产生成功 fork;如果未来改为 streaming fork,再补 begin/complete marker 测试。
|
||||
- fork / restore 读取旧 `pinned` artifact 时降级为 restorable,不继承 contentRef。
|
||||
- orphan tombstone 在 fork remap 时被保留并安全 remap;无法安全 remap 的 tombstone 才丢弃。
|
||||
- fork remap 重新执行 validation、privacy minimization 和 redaction;unsafe locator 被 strip、降级或丢弃。
|
||||
- restore seed 与 concurrent POST 串行,不丢写、不重复。
|
||||
- quota 边界:200 条、201 条 prune、clientRetained/non-clientRetained 两层排序、全部 clientRetained restorable 仍可按确定性规则裁剪。
|
||||
- clientRetained setter:Add artifact request 能设置 boolean hint;后台自动 ingest 不能伪造用户保留。
|
||||
- workspace 三态:登记时写入 size + `metadata["qwen.workspace.sha256"]` + `metadata["qwen.workspace.mtimeMs"]`;GET/list refresh 能区分 `available`、`missing` 和 `changed`,且未变化文件只走 stat 快路径。
|
||||
- authorization:token-holder/principal 审计路径允许和拒绝情况;V1 live same-principal guard 仅作为 live UX/audit hint,不作为 durable security boundary。
|
||||
- JSONL snapshot baseline advance:threshold 触发、post-snapshot replay 有界、snapshot payload 不再携带已被覆盖的 explicit tombstones、superseded sticky tombstone 允许显式同 id 重新出现、`stickyEphemeralIds` 保留 sticky state;JSONL 文件本身不被 artifact 子系统重写。
|
||||
- corrupt latest snapshot fallback:回退到较旧 valid snapshot 或一次顺序 artifact replay。
|
||||
- retention defaults:tool artifact 无显式 retention、client POST `pinned` 被拒绝。
|
||||
- capability:string list 只在行为当前可用时声明;不依赖 `enabled:false` details。
|
||||
- replay idempotency:同一 session history replay 两次不会重复 artifact。
|
||||
- SDK 旧 client 忽略 optional fields 后仍能展示 V1 artifacts。
|
||||
- V2 -> V1 rollback compatibility:旧 daemon 必须能解析或忽略 unknown `system` subtype,不得导致 session load 崩溃;回滚后 artifact persistence 不恢复是可接受降级。如果当前最低支持版本不能保证这一点,V2 writer 必须 capability-gate 到支持 unknown system record 的版本之后。
|
||||
- rollback preflight:最低支持旧 daemon 版本加载包含 V2 event/snapshot 的 JSONL;如果未来加入 fork marker,再扩展 rollback fixture。
|
||||
- PR #6259 覆盖 metadata API response contract:delete success body、metadata quota validation failure、`remove_not_persisted` / `persistence_unavailable` warning、current 400/403/200+warning mapping。
|
||||
|
||||
Future content archive / checker 另行覆盖:
|
||||
|
||||
- `deleteContent: true` 在 tombstone/content GC 有风险时暴露 `content_delete_preserved` warning。
|
||||
- pin/save content 时拒绝 symlink、special file、oversized stream、hardlink 异常和 TOCTOU swap。
|
||||
- metadata-only checker dry-run:corrupt record、snapshot fallback、orphan tombstone、restore validation failure。
|
||||
- full content checker dry-run:dangling `contentRef`、manifest 缺失、orphan content 和 GC 修复策略。
|
||||
|
||||
## 11. 不建议在 V2 做的事
|
||||
|
||||
- 自动抓取普通 markdown link。
|
||||
- 自动扫描 workspace 文件变更。
|
||||
- 默认复制所有 workspace artifact 内容。
|
||||
- 对 external URL 做 reachability poll。
|
||||
- 把 `clientId` 作为删除授权凭证。
|
||||
- 对重定位 workspace 做自动 path remapping。
|
||||
- 在 GET 热路径里做大量 fs/network 校验。
|
||||
- 把持久化失败变成普通 tool turn 失败。
|
||||
- 在没有测量证明需要时引入 sidecar cache。
|
||||
|
||||
## 12. 推荐发布口径
|
||||
|
||||
V2 建议作为一个完整 design phase 发布,但能力按 capability 暴露:
|
||||
|
||||
- `session_artifacts_persistence` 可先发布 metadata restore。
|
||||
- `session_artifacts_content_retention` 当前不发布;future content archive 需要重新设计并独立声明 capability。
|
||||
- 默认恢复显式登记的 artifact metadata。
|
||||
- 用户手动注册的 artifact 默认 `restorable`,session load/replay 后继续出现在列表中。
|
||||
- 用户文档明确:metadata restore 恢复的是“产物索引”,不是“产物内容备份”;workspace 的 `changed` 状态只说明实时文件和登记时 size 不一致,或 mtime 变化后 hash 不一致。
|
||||
|
||||
Rollback procedure:
|
||||
|
||||
- V2 records 保留在 chat JSONL 中,不在 rollback 时删除;旧 daemon 能忽略 unknown `system` subtype 时,session load 应继续工作但不恢复 artifact persistence。
|
||||
- daemon-managed content storage 不属于 PR #6259;后续 content-retention PR 需要单独定义 rollback 后 retained bytes 的清理流程。
|
||||
- 如果当前最低支持旧版本不能安全忽略 V2 system records,writer 必须 capability-gate 到安全版本之后,或者在升级前提供 migration guard,阻止写入 V2 records。
|
||||
- 发布前 CI 必须用最低支持旧 daemon 版本加载包含 `session_artifact_event` 和 `session_artifact_snapshot` 的 JSONL,断言 session load 成功且 unknown subtype 被忽略。V2 writer 首次初始化前也要检查版本/feature gate;失败时拒绝写 V2 records,记录 `v2_writer_version_gate_failed`,保持 V1 行为。如果未来加入 fork marker,再把该 subtype 纳入 rollback fixture。
|
||||
- rollback 后 client 不能依赖 `session_artifacts_persistence` / `session_artifacts_content_retention`,因为旧 daemon 不声明这些 capability。
|
||||
|
||||
这样可以讲清楚当前 V2 的完整语义:默认恢复列表,不保存内容,用 workspace size/mtime/hash 避免静默打开错误版本,同时避免对未变化文件重复做全量 hash。
|
||||
|
|
@ -248,6 +248,7 @@ describe('qwen serve — capabilities envelope', () => {
|
|||
'session_cancel',
|
||||
'session_events',
|
||||
'session_artifacts',
|
||||
'session_artifacts_persistence',
|
||||
'slow_client_warning',
|
||||
'typed_event_schema',
|
||||
'session_set_model',
|
||||
|
|
|
|||
|
|
@ -51,7 +51,13 @@ import type { ChannelFactory } from './channel.js';
|
|||
import type { BridgeTelemetry } from './bridgeOptions.js';
|
||||
import { createInMemoryChannel } from './inMemoryChannel.js';
|
||||
import { EventBus, type BridgeEvent } from './eventBus.js';
|
||||
import { ApprovalMode, ShellExecutionService } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
ApprovalMode,
|
||||
SESSION_ARTIFACT_PERSISTENCE_VERSION,
|
||||
ShellExecutionService,
|
||||
stableSessionArtifactId,
|
||||
ToolNames,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
FakeAgent,
|
||||
type ChannelHandle,
|
||||
|
|
@ -61,6 +67,7 @@ import {
|
|||
WS_B,
|
||||
SESS_A,
|
||||
} from './internal/testUtils.js';
|
||||
import { SessionArtifactAuthorizationError } from './sessionArtifacts.js';
|
||||
|
||||
function deferred<T>(): {
|
||||
promise: Promise<T>;
|
||||
|
|
@ -232,9 +239,9 @@ describe('createAcpSessionBridge', () => {
|
|||
{
|
||||
title: 'Client link',
|
||||
source: 'client',
|
||||
clientId: session.clientId,
|
||||
},
|
||||
]);
|
||||
expect(snapshot.artifacts[0]).not.toHaveProperty('clientId');
|
||||
expect(snapshot.artifacts[0]).not.toHaveProperty('toolName');
|
||||
expect(snapshot.artifacts[0]).not.toHaveProperty('hookEventName');
|
||||
expect(snapshot.artifacts[0]).not.toHaveProperty('toolCallId');
|
||||
|
|
@ -260,19 +267,26 @@ describe('createAcpSessionBridge', () => {
|
|||
);
|
||||
const artifactId = created.changes[0]!.artifactId;
|
||||
|
||||
await expect(
|
||||
bridge.getSessionArtifacts(first.sessionId, {
|
||||
clientId: 'forged-client',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(InvalidClientIdError);
|
||||
await expect(
|
||||
bridge.removeSessionArtifact(first.sessionId, artifactId, {
|
||||
clientId: second.clientId,
|
||||
}),
|
||||
).resolves.toMatchObject({ changes: [] });
|
||||
).rejects.toBeInstanceOf(SessionArtifactAuthorizationError);
|
||||
await expect(
|
||||
bridge.removeSessionArtifact(first.sessionId, artifactId),
|
||||
).resolves.toMatchObject({ changes: [] });
|
||||
).rejects.toBeInstanceOf(SessionArtifactAuthorizationError);
|
||||
await expect(
|
||||
bridge.getSessionArtifacts(first.sessionId),
|
||||
).resolves.toMatchObject({
|
||||
artifacts: [{ id: artifactId, clientId: first.clientId }],
|
||||
artifacts: [{ id: artifactId }],
|
||||
});
|
||||
const listed = await bridge.getSessionArtifacts(first.sessionId);
|
||||
expect(listed.artifacts[0]).not.toHaveProperty('clientId');
|
||||
|
||||
await expect(
|
||||
bridge.removeSessionArtifact(first.sessionId, artifactId, {
|
||||
|
|
@ -286,6 +300,27 @@ describe('createAcpSessionBridge', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('rejects invalid client artifact records instead of dropping them', async () => {
|
||||
const bridge = makeBridge({
|
||||
channelFactory: async () => makeChannel().channel,
|
||||
});
|
||||
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
|
||||
try {
|
||||
await expect(
|
||||
bridge.addSessionArtifact(
|
||||
session.sessionId,
|
||||
{
|
||||
title: 'x'.repeat(201),
|
||||
url: 'https://example.com/client',
|
||||
},
|
||||
{ clientId: session.clientId },
|
||||
),
|
||||
).rejects.toThrow(/title/);
|
||||
} finally {
|
||||
await bridge.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
it('uses bridge telemetry for channel/session/prompt dispatch and prompt metadata injection', async () => {
|
||||
const handle = makeChannel();
|
||||
const operations: string[] = [];
|
||||
|
|
@ -1365,7 +1400,11 @@ describe('createAcpSessionBridge', () => {
|
|||
lastEventId: 0,
|
||||
});
|
||||
expect(handles[0]?.agent.loadSessionCalls).toEqual([
|
||||
{ sessionId: 'persisted-1', cwd: WS_A, mcpServers: [] },
|
||||
{
|
||||
sessionId: 'persisted-1',
|
||||
cwd: WS_A,
|
||||
mcpServers: [],
|
||||
},
|
||||
]);
|
||||
expect(bridge.sessionCount).toBe(1);
|
||||
|
||||
|
|
@ -1380,6 +1419,524 @@ describe('createAcpSessionBridge', () => {
|
|||
await bridge.shutdown();
|
||||
});
|
||||
|
||||
it('restores artifact snapshots that omit marker arrays after fork remap', async () => {
|
||||
const sessionId = 'persisted-artifacts';
|
||||
const artifactUrl = 'https://example.com/restored';
|
||||
const artifactId = stableSessionArtifactId(sessionId, `url:${artifactUrl}`);
|
||||
const bridge = makeBridge({
|
||||
channelFactory: async () =>
|
||||
makeChannel({
|
||||
loadSessionImpl: () =>
|
||||
({
|
||||
artifactSnapshot: {
|
||||
v: SESSION_ARTIFACT_PERSISTENCE_VERSION,
|
||||
sessionId,
|
||||
sequence: 7,
|
||||
artifacts: [
|
||||
{
|
||||
id: artifactId,
|
||||
kind: 'link',
|
||||
storage: 'external_url',
|
||||
source: 'client',
|
||||
status: 'available',
|
||||
title: 'Restored link',
|
||||
url: artifactUrl,
|
||||
retention: 'restorable',
|
||||
clientRetained: true,
|
||||
createdAt: '2026-07-04T00:00:00.000Z',
|
||||
updatedAt: '2026-07-04T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
warnings: ['skipped corrupt artifact record'],
|
||||
},
|
||||
}) as LoadSessionResponse,
|
||||
}).channel,
|
||||
});
|
||||
|
||||
const loaded = await bridge.loadSession({
|
||||
sessionId,
|
||||
workspaceCwd: WS_A,
|
||||
});
|
||||
expect(loaded.state).not.toHaveProperty('artifactSnapshot');
|
||||
expect(loaded.artifactWarnings).toEqual([
|
||||
'skipped corrupt artifact record',
|
||||
]);
|
||||
|
||||
await expect(
|
||||
bridge.getSessionArtifacts(loaded.sessionId),
|
||||
).resolves.toMatchObject({
|
||||
warnings: ['skipped corrupt artifact record'],
|
||||
artifacts: [
|
||||
{
|
||||
id: artifactId,
|
||||
title: 'Restored link',
|
||||
restoreState: 'restored',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await bridge.shutdown();
|
||||
});
|
||||
|
||||
it('normalizes artifact snapshots returned by session restore', async () => {
|
||||
const sessionId = 'persisted-artifact-normalize';
|
||||
const bridge = makeBridge({
|
||||
channelFactory: async () =>
|
||||
makeChannel({
|
||||
loadSessionImpl: () =>
|
||||
({
|
||||
artifactSnapshot: {
|
||||
v: SESSION_ARTIFACT_PERSISTENCE_VERSION,
|
||||
sessionId,
|
||||
sequence: 7,
|
||||
artifacts: [{ malformed: true }],
|
||||
tombstonedIds: ['x'.repeat(201)],
|
||||
stickyEphemeralIds: ['y'.repeat(201)],
|
||||
warnings: ['kept warning', 'z'.repeat(1001), 123],
|
||||
},
|
||||
}) as LoadSessionResponse,
|
||||
}).channel,
|
||||
});
|
||||
|
||||
const loaded = await bridge.loadSession({
|
||||
sessionId,
|
||||
workspaceCwd: WS_A,
|
||||
});
|
||||
|
||||
expect(loaded.state).not.toHaveProperty('artifactSnapshot');
|
||||
expect(loaded.artifactWarnings).toEqual([
|
||||
'skipped artifact without id/title',
|
||||
'kept warning',
|
||||
]);
|
||||
await expect(
|
||||
bridge.getSessionArtifacts(loaded.sessionId),
|
||||
).resolves.toMatchObject({
|
||||
warnings: ['skipped artifact without id/title', 'kept warning'],
|
||||
artifacts: [],
|
||||
});
|
||||
|
||||
await bridge.shutdown();
|
||||
});
|
||||
|
||||
it('clears durable artifacts but keeps live ephemerals when rewind returns an empty artifact snapshot', async () => {
|
||||
const persistedSnapshots: unknown[] = [];
|
||||
const bridge = makeBridge({
|
||||
channelFactory: async () =>
|
||||
makeChannel({
|
||||
extMethodImpl: (method, params) => {
|
||||
if (method === 'qwen/control/session/artifacts/persist') {
|
||||
if (params['kind'] === 'snapshot') {
|
||||
persistedSnapshots.push(params['payload']);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
expect(method).toBe('qwen/control/session/rewind');
|
||||
return {
|
||||
targetTurnIndex: 0,
|
||||
filesChanged: [],
|
||||
filesFailed: [],
|
||||
artifactSnapshot: {
|
||||
v: SESSION_ARTIFACT_PERSISTENCE_VERSION,
|
||||
sessionId: SESS_A,
|
||||
sequence: 0,
|
||||
artifacts: [],
|
||||
tombstonedIds: [],
|
||||
stickyEphemeralIds: [],
|
||||
warnings: [],
|
||||
},
|
||||
};
|
||||
},
|
||||
}).channel,
|
||||
});
|
||||
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
|
||||
const durable = await bridge.addSessionArtifact(
|
||||
session.sessionId,
|
||||
{
|
||||
title: 'Abandoned durable artifact',
|
||||
url: 'https://example.com/later-durable',
|
||||
},
|
||||
{ clientId: session.clientId },
|
||||
);
|
||||
const ephemeral = await bridge.addSessionArtifact(
|
||||
session.sessionId,
|
||||
{
|
||||
title: 'Live ephemeral artifact',
|
||||
url: 'https://example.com/later-ephemeral',
|
||||
retention: 'ephemeral',
|
||||
},
|
||||
{ clientId: session.clientId },
|
||||
);
|
||||
expect(durable.changes).toHaveLength(1);
|
||||
expect(ephemeral.changes).toHaveLength(1);
|
||||
|
||||
await expect(
|
||||
bridge.rewindSession(
|
||||
session.sessionId,
|
||||
{ promptId: 'prompt-1' },
|
||||
{ clientId: session.clientId },
|
||||
),
|
||||
).resolves.toMatchObject({ targetTurnIndex: 0 });
|
||||
|
||||
const artifacts = (await bridge.getSessionArtifacts(session.sessionId))
|
||||
.artifacts;
|
||||
expect(artifacts).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
title: 'Live ephemeral artifact',
|
||||
retention: 'ephemeral',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
artifacts.some(
|
||||
(artifact) => artifact.id === durable.changes[0]?.artifactId,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(persistedSnapshots).toEqual([
|
||||
expect.objectContaining({
|
||||
sessionId: session.sessionId,
|
||||
artifacts: [],
|
||||
tombstonedIds: [],
|
||||
}),
|
||||
]);
|
||||
|
||||
await bridge.shutdown();
|
||||
});
|
||||
|
||||
it('keeps durable artifacts when rewind cannot rebuild an artifact snapshot', async () => {
|
||||
const persistedSnapshots: unknown[] = [];
|
||||
const bridge = makeBridge({
|
||||
channelFactory: async () =>
|
||||
makeChannel({
|
||||
extMethodImpl: (method, params) => {
|
||||
if (method === 'qwen/control/session/artifacts/persist') {
|
||||
if (params['kind'] === 'snapshot') {
|
||||
persistedSnapshots.push(params['payload']);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
expect(method).toBe('qwen/control/session/rewind');
|
||||
return {
|
||||
targetTurnIndex: 0,
|
||||
filesChanged: [],
|
||||
filesFailed: [],
|
||||
artifactSnapshotUnavailable: 'artifact journal read failed',
|
||||
};
|
||||
},
|
||||
}).channel,
|
||||
});
|
||||
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
|
||||
const durable = await bridge.addSessionArtifact(
|
||||
session.sessionId,
|
||||
{
|
||||
title: 'Durable artifact',
|
||||
url: 'https://example.com/durable-after-failed-rebuild',
|
||||
},
|
||||
{ clientId: session.clientId },
|
||||
);
|
||||
|
||||
await expect(
|
||||
bridge.rewindSession(
|
||||
session.sessionId,
|
||||
{ promptId: 'prompt-1' },
|
||||
{ clientId: session.clientId },
|
||||
),
|
||||
).resolves.toMatchObject({ targetTurnIndex: 0 });
|
||||
|
||||
await expect(
|
||||
bridge.getSessionArtifacts(session.sessionId),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
artifacts: [
|
||||
expect.objectContaining({
|
||||
id: durable.changes[0]?.artifactId,
|
||||
title: 'Durable artifact',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(persistedSnapshots).toEqual([]);
|
||||
|
||||
await bridge.shutdown();
|
||||
});
|
||||
|
||||
it('keeps durable artifacts when rewind omits artifact snapshot metadata', async () => {
|
||||
const persistedSnapshots: unknown[] = [];
|
||||
const bridge = makeBridge({
|
||||
channelFactory: async () =>
|
||||
makeChannel({
|
||||
extMethodImpl: (method, params) => {
|
||||
if (method === 'qwen/control/session/artifacts/persist') {
|
||||
if (params['kind'] === 'snapshot') {
|
||||
persistedSnapshots.push(params['payload']);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
expect(method).toBe('qwen/control/session/rewind');
|
||||
return {
|
||||
targetTurnIndex: 0,
|
||||
filesChanged: [],
|
||||
filesFailed: [],
|
||||
};
|
||||
},
|
||||
}).channel,
|
||||
});
|
||||
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
|
||||
const durable = await bridge.addSessionArtifact(
|
||||
session.sessionId,
|
||||
{
|
||||
title: 'Version-skew durable artifact',
|
||||
url: 'https://example.com/durable-version-skew',
|
||||
},
|
||||
{ clientId: session.clientId },
|
||||
);
|
||||
|
||||
await expect(
|
||||
bridge.rewindSession(
|
||||
session.sessionId,
|
||||
{ promptId: 'prompt-1' },
|
||||
{ clientId: session.clientId },
|
||||
),
|
||||
).resolves.toMatchObject({ targetTurnIndex: 0 });
|
||||
|
||||
await expect(
|
||||
bridge.getSessionArtifacts(session.sessionId),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
artifacts: [
|
||||
expect.objectContaining({
|
||||
id: durable.changes[0]?.artifactId,
|
||||
title: 'Version-skew durable artifact',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(persistedSnapshots).toEqual([]);
|
||||
|
||||
await bridge.shutdown();
|
||||
});
|
||||
|
||||
it('restores and persists artifact snapshots returned by rewind', async () => {
|
||||
const retainedUrl = 'https://example.com/retained';
|
||||
const rewoundUrl = 'https://example.com/rewound';
|
||||
const stickyUrl = 'https://example.com/rewound-sticky';
|
||||
const rewoundArtifactId = stableSessionArtifactId(
|
||||
SESS_A,
|
||||
`url:${rewoundUrl}`,
|
||||
);
|
||||
const stickyArtifactId = stableSessionArtifactId(
|
||||
SESS_A,
|
||||
`url:${stickyUrl}`,
|
||||
);
|
||||
const persistedSnapshots: unknown[] = [];
|
||||
const bridge = makeBridge({
|
||||
channelFactory: async () =>
|
||||
makeChannel({
|
||||
extMethodImpl: (method, params) => {
|
||||
if (method === 'qwen/control/session/artifacts/persist') {
|
||||
if (params['kind'] === 'snapshot') {
|
||||
persistedSnapshots.push(params['payload']);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
expect(method).toBe('qwen/control/session/rewind');
|
||||
return {
|
||||
targetTurnIndex: 0,
|
||||
filesChanged: [],
|
||||
filesFailed: [],
|
||||
artifactSnapshot: {
|
||||
v: SESSION_ARTIFACT_PERSISTENCE_VERSION,
|
||||
sessionId: SESS_A,
|
||||
sequence: 8,
|
||||
artifacts: [
|
||||
{
|
||||
id: rewoundArtifactId,
|
||||
kind: 'link',
|
||||
storage: 'external_url',
|
||||
source: 'client',
|
||||
status: 'available',
|
||||
title: 'Rewound artifact',
|
||||
url: rewoundUrl,
|
||||
retention: 'restorable',
|
||||
clientRetained: true,
|
||||
createdAt: '2026-07-04T00:00:00.000Z',
|
||||
updatedAt: '2026-07-04T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
tombstonedIds: [],
|
||||
stickyEphemeralIds: [stickyArtifactId],
|
||||
markerArtifacts: [
|
||||
{
|
||||
id: stickyArtifactId,
|
||||
kind: 'link',
|
||||
storage: 'external_url',
|
||||
source: 'client',
|
||||
status: 'available',
|
||||
title: 'Sticky artifact',
|
||||
url: stickyUrl,
|
||||
retention: 'restorable',
|
||||
clientRetained: true,
|
||||
createdAt: '2026-07-04T00:00:00.000Z',
|
||||
updatedAt: '2026-07-04T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
},
|
||||
};
|
||||
},
|
||||
}).channel,
|
||||
});
|
||||
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
|
||||
await bridge.addSessionArtifact(
|
||||
session.sessionId,
|
||||
{
|
||||
title: 'Later artifact',
|
||||
url: retainedUrl,
|
||||
},
|
||||
{ clientId: session.clientId },
|
||||
);
|
||||
const ephemeral = await bridge.addSessionArtifact(
|
||||
session.sessionId,
|
||||
{
|
||||
title: 'Live ephemeral artifact',
|
||||
url: 'https://example.com/live-ephemeral-during-rewind',
|
||||
retention: 'ephemeral',
|
||||
},
|
||||
{ clientId: session.clientId },
|
||||
);
|
||||
|
||||
const abort = new AbortController();
|
||||
const iter = bridge.subscribeEvents(session.sessionId, {
|
||||
signal: abort.signal,
|
||||
});
|
||||
const artifactChanges = (async () => {
|
||||
const changes: unknown[] = [];
|
||||
for await (const event of iter) {
|
||||
if (event.type !== 'artifact_changed') continue;
|
||||
changes.push((event.data as { change?: unknown }).change);
|
||||
if (changes.length === 2) return changes;
|
||||
}
|
||||
return changes;
|
||||
})();
|
||||
|
||||
await expect(
|
||||
bridge.rewindSession(
|
||||
session.sessionId,
|
||||
{ promptId: 'prompt-1' },
|
||||
{ clientId: session.clientId },
|
||||
),
|
||||
).resolves.toMatchObject({ targetTurnIndex: 0 });
|
||||
|
||||
await expect(artifactChanges).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ action: 'removed', reason: 'eviction' }),
|
||||
expect.objectContaining({
|
||||
action: 'created',
|
||||
artifactId: rewoundArtifactId,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
abort.abort();
|
||||
const artifacts = (await bridge.getSessionArtifacts(session.sessionId))
|
||||
.artifacts;
|
||||
expect(artifacts).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: rewoundArtifactId,
|
||||
title: 'Rewound artifact',
|
||||
restoreState: 'restored',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: ephemeral.changes[0]?.artifactId,
|
||||
title: 'Live ephemeral artifact',
|
||||
retention: 'ephemeral',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(persistedSnapshots).toEqual([
|
||||
expect.objectContaining({
|
||||
sessionId: session.sessionId,
|
||||
artifacts: [
|
||||
expect.objectContaining({
|
||||
id: rewoundArtifactId,
|
||||
title: 'Rewound artifact',
|
||||
}),
|
||||
],
|
||||
markerArtifacts: [
|
||||
expect.objectContaining({
|
||||
id: stickyArtifactId,
|
||||
title: 'Sticky artifact',
|
||||
url: stickyUrl,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
expect(
|
||||
(persistedSnapshots[0] as { artifacts?: unknown[] }).artifacts,
|
||||
).toHaveLength(1);
|
||||
|
||||
await bridge.shutdown();
|
||||
});
|
||||
|
||||
it('surfaces rewind artifact snapshot persistence warnings', async () => {
|
||||
const bridge = makeBridge({
|
||||
channelFactory: async () =>
|
||||
makeChannel({
|
||||
extMethodImpl: (method, params) => {
|
||||
if (method === 'qwen/control/session/artifacts/persist') {
|
||||
if (params['kind'] === 'snapshot') {
|
||||
throw new Error('disk full');
|
||||
}
|
||||
return {};
|
||||
}
|
||||
expect(method).toBe('qwen/control/session/rewind');
|
||||
return {
|
||||
targetTurnIndex: 0,
|
||||
filesChanged: [],
|
||||
filesFailed: [],
|
||||
artifactSnapshot: {
|
||||
v: SESSION_ARTIFACT_PERSISTENCE_VERSION,
|
||||
sessionId: SESS_A,
|
||||
sequence: 0,
|
||||
artifacts: [],
|
||||
tombstonedIds: [],
|
||||
stickyEphemeralIds: [],
|
||||
warnings: [],
|
||||
},
|
||||
};
|
||||
},
|
||||
}).channel,
|
||||
});
|
||||
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
|
||||
const abort = new AbortController();
|
||||
const rewoundEvent = (async () => {
|
||||
for await (const event of bridge.subscribeEvents(session.sessionId, {
|
||||
signal: abort.signal,
|
||||
})) {
|
||||
if (event.type === 'session_rewound') return event;
|
||||
}
|
||||
return undefined;
|
||||
})();
|
||||
|
||||
await expect(
|
||||
bridge.rewindSession(
|
||||
session.sessionId,
|
||||
{ promptId: 'prompt-1' },
|
||||
{ clientId: session.clientId },
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
targetTurnIndex: 0,
|
||||
warnings: ['artifact snapshot not persisted'],
|
||||
});
|
||||
await expect(rewoundEvent).resolves.toMatchObject({
|
||||
type: 'session_rewound',
|
||||
data: { warnings: ['artifact snapshot not persisted'] },
|
||||
});
|
||||
abort.abort();
|
||||
|
||||
await bridge.shutdown();
|
||||
});
|
||||
|
||||
it('loads history replay from response metadata when requested', async () => {
|
||||
const handles: ChannelHandle[] = [];
|
||||
const factory: ChannelFactory = async () => {
|
||||
|
|
@ -1450,6 +2007,73 @@ describe('createAcpSessionBridge', () => {
|
|||
await bridge.shutdown();
|
||||
});
|
||||
|
||||
it('restores artifacts from response-mode load replay when no snapshot is available', async () => {
|
||||
const handles: ChannelHandle[] = [];
|
||||
const factory: ChannelFactory = async () => {
|
||||
const h = makeChannel({
|
||||
loadSessionImpl: (p) => {
|
||||
expect(p._meta).toMatchObject({
|
||||
'qwen.session.loadReplayMode': 'bulk',
|
||||
});
|
||||
return {
|
||||
_meta: {
|
||||
'qwen.session.loadReplay': {
|
||||
v: 1,
|
||||
updates: [
|
||||
{
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: 'call-replayed-artifact',
|
||||
status: 'completed',
|
||||
content: [],
|
||||
_meta: {
|
||||
toolName: ToolNames.ARTIFACT,
|
||||
artifacts: [
|
||||
{
|
||||
title: 'Replayed artifact',
|
||||
url: 'https://example.com/replayed-artifact',
|
||||
retention: 'restorable',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
handles.push(h);
|
||||
return h.channel;
|
||||
};
|
||||
const bridge = makeBridge({ channelFactory: factory });
|
||||
|
||||
const loaded = await bridge.loadSession({
|
||||
sessionId: 'persisted-replayed-artifact',
|
||||
workspaceCwd: WS_A,
|
||||
historyReplay: 'response',
|
||||
});
|
||||
|
||||
expect(handles[0]?.agent.loadSessionCalls[0]).toMatchObject({
|
||||
sessionId: 'persisted-replayed-artifact',
|
||||
cwd: WS_A,
|
||||
mcpServers: [],
|
||||
_meta: { 'qwen.session.loadReplayMode': 'bulk' },
|
||||
});
|
||||
await expect(
|
||||
bridge.getSessionArtifacts(loaded.sessionId),
|
||||
).resolves.toMatchObject({
|
||||
artifacts: [
|
||||
{
|
||||
title: 'Replayed artifact',
|
||||
url: 'https://example.com/replayed-artifact',
|
||||
restoreState: 'live',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await bridge.shutdown();
|
||||
});
|
||||
|
||||
it('bounds response-mode load replay and emits a history_truncated marker', async () => {
|
||||
const factory: ChannelFactory = async () =>
|
||||
makeChannel({
|
||||
|
|
@ -1922,6 +2546,7 @@ describe('createAcpSessionBridge', () => {
|
|||
const first = bridge.loadSession({
|
||||
sessionId: 'coalesce-replay-mode',
|
||||
workspaceCwd: WS_A,
|
||||
historyReplay: 'stream',
|
||||
});
|
||||
for (let i = 0; i < 50 && !releaseLoad; i++) {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
|
@ -12590,7 +13215,9 @@ describe('session idle reaper', () => {
|
|||
|
||||
// No detach — simulates client crash. Reaper catches all 3.
|
||||
await vi.advanceTimersByTimeAsync(4_000);
|
||||
expect(bridge.sessionCount).toBe(0);
|
||||
await vi.waitFor(() => {
|
||||
expect(bridge.sessionCount).toBe(0);
|
||||
});
|
||||
|
||||
await bridge.shutdown();
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -17,11 +17,16 @@ import type {
|
|||
SetSessionModelResponse,
|
||||
SessionUpdate,
|
||||
} from '@agentclientprotocol/sdk';
|
||||
import type { ApprovalMode } from '@qwen-code/qwen-code-core';
|
||||
import type {
|
||||
ApprovalMode,
|
||||
RebuiltSessionArtifactSnapshot,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
DAEMON_TRACEPARENT_META_KEY,
|
||||
DAEMON_TRACESTATE_META_KEY,
|
||||
SESSION_ARTIFACT_PERSISTENCE_VERSION,
|
||||
TrustGateError,
|
||||
normalizeSnapshotPayload,
|
||||
ShellExecutionService,
|
||||
type ShellOutputEvent,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
|
@ -123,6 +128,9 @@ import {
|
|||
import { PermissionForbiddenError } from './bridgeErrors.js';
|
||||
import {
|
||||
SessionArtifactStore,
|
||||
isArtifactRestoreFailureWarning,
|
||||
publicArtifactsEqual,
|
||||
type DaemonSessionArtifact,
|
||||
type SessionArtifactChange,
|
||||
type SessionArtifactInput,
|
||||
type SessionArtifactMutationResult,
|
||||
|
|
@ -2680,7 +2688,11 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
|||
channel: ci.channel,
|
||||
connection: ci.connection,
|
||||
events,
|
||||
artifacts: new SessionArtifactStore({ sessionId, workspaceCwd }),
|
||||
artifacts: new SessionArtifactStore({
|
||||
sessionId,
|
||||
workspaceCwd,
|
||||
persistence: createSessionArtifactPersistence(ci.connection, sessionId),
|
||||
}),
|
||||
promptQueue: Promise.resolve(),
|
||||
pendingPromptCount: 0,
|
||||
pendingPromptList: [],
|
||||
|
|
@ -2730,6 +2742,46 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
|||
}
|
||||
};
|
||||
|
||||
const artifactReseedChanges = (
|
||||
before: readonly DaemonSessionArtifact[],
|
||||
after: readonly DaemonSessionArtifact[],
|
||||
): SessionArtifactChange[] => {
|
||||
const beforeById = new Map(
|
||||
before.map((artifact) => [artifact.id, artifact]),
|
||||
);
|
||||
const afterById = new Map(after.map((artifact) => [artifact.id, artifact]));
|
||||
const changes: SessionArtifactChange[] = [];
|
||||
for (const artifact of before) {
|
||||
if (!afterById.has(artifact.id)) {
|
||||
changes.push({
|
||||
action: 'removed',
|
||||
artifactId: artifact.id,
|
||||
artifact,
|
||||
reason: 'eviction',
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const artifact of after) {
|
||||
const previous = beforeById.get(artifact.id);
|
||||
if (!previous) {
|
||||
changes.push({
|
||||
action: 'created',
|
||||
artifactId: artifact.id,
|
||||
artifact,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!publicArtifactsEqual(previous, artifact)) {
|
||||
changes.push({
|
||||
action: 'updated',
|
||||
artifactId: artifact.id,
|
||||
artifact,
|
||||
});
|
||||
}
|
||||
}
|
||||
return changes;
|
||||
};
|
||||
|
||||
const makeClientArtifactInput = (
|
||||
artifact: SessionArtifactInput,
|
||||
clientId: string | undefined,
|
||||
|
|
@ -2745,6 +2797,8 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
|||
mimeType: artifact.mimeType,
|
||||
sizeBytes: artifact.sizeBytes,
|
||||
metadata: artifact.metadata,
|
||||
retention: artifact.retention,
|
||||
clientRetained: artifact.clientRetained,
|
||||
source: 'client',
|
||||
};
|
||||
if (clientId) {
|
||||
|
|
@ -2753,6 +2807,34 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
|||
return input;
|
||||
};
|
||||
|
||||
function createSessionArtifactPersistence(
|
||||
connection: ClientSideConnection,
|
||||
sessionId: string,
|
||||
) {
|
||||
return {
|
||||
recordEvent: async (payload: unknown): Promise<void> => {
|
||||
await connection.extMethod(
|
||||
SERVE_CONTROL_EXT_METHODS.sessionArtifactsPersist,
|
||||
{
|
||||
sessionId,
|
||||
kind: 'event',
|
||||
payload,
|
||||
},
|
||||
);
|
||||
},
|
||||
recordSnapshot: async (payload: unknown): Promise<void> => {
|
||||
await connection.extMethod(
|
||||
SERVE_CONTROL_EXT_METHODS.sessionArtifactsPersist,
|
||||
{
|
||||
sessionId,
|
||||
kind: 'snapshot',
|
||||
payload,
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// A5: seed the snapshot caches from the agent's session-create response
|
||||
// (`newSession` / `loadSession` / `resumeSession` all return `models` +
|
||||
// `modes`). Without this the caches stay unset until the first change, so a
|
||||
|
|
@ -2858,6 +2940,54 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
|||
return { lastEventId: snapshot.lastEventId, ...replayStatus };
|
||||
};
|
||||
|
||||
const restoredArtifactSnapshotFromState = (
|
||||
state: BridgeSessionState,
|
||||
): RebuiltSessionArtifactSnapshot | undefined => {
|
||||
const candidate = state.artifactSnapshot;
|
||||
const warnings: string[] = [];
|
||||
const snapshot = normalizeSnapshotPayload(candidate, warnings);
|
||||
if (!snapshot) return undefined;
|
||||
const snapshotWarnings =
|
||||
isRecord(candidate) && Array.isArray(candidate['warnings'])
|
||||
? candidate['warnings']
|
||||
.filter(
|
||||
(warning): warning is string =>
|
||||
typeof warning === 'string' && warning.length <= 1000,
|
||||
)
|
||||
.slice(-500)
|
||||
: [];
|
||||
return {
|
||||
v: SESSION_ARTIFACT_PERSISTENCE_VERSION,
|
||||
sessionId: snapshot.sessionId,
|
||||
sequence: snapshot.sequence,
|
||||
artifacts: snapshot.artifacts,
|
||||
...(snapshot.markerArtifacts
|
||||
? { markerArtifacts: snapshot.markerArtifacts }
|
||||
: {}),
|
||||
tombstonedIds: snapshot.tombstonedIds ?? [],
|
||||
stickyEphemeralIds: snapshot.stickyEphemeralIds ?? [],
|
||||
warnings: [...warnings, ...snapshotWarnings],
|
||||
};
|
||||
};
|
||||
|
||||
const artifactSnapshotUnavailableReason = (
|
||||
state: BridgeSessionState,
|
||||
): string | undefined => {
|
||||
const reason = state.artifactSnapshotUnavailable;
|
||||
return typeof reason === 'string' && reason ? reason : undefined;
|
||||
};
|
||||
|
||||
const publicRestoreState = (
|
||||
state: BridgeSessionState,
|
||||
): BridgeSessionState => {
|
||||
const {
|
||||
artifactSnapshot: _artifactSnapshot,
|
||||
artifactSnapshotUnavailable: _artifactSnapshotUnavailable,
|
||||
...publicState
|
||||
} = state;
|
||||
return publicState;
|
||||
};
|
||||
|
||||
async function restoreSession(
|
||||
action: 'load' | 'resume',
|
||||
req: BridgeRestoreSessionRequest,
|
||||
|
|
@ -3111,16 +3241,34 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
|||
},
|
||||
);
|
||||
releaseAdmissionOnce();
|
||||
entry.restoreState = state;
|
||||
const restoredArtifactSnapshot = restoredArtifactSnapshotFromState(state);
|
||||
const publicState = publicRestoreState(state);
|
||||
entry.restoreState = publicState;
|
||||
if (replayPartial === true) {
|
||||
entry.restoreReplayPartial = true;
|
||||
}
|
||||
if (replayError !== undefined) {
|
||||
entry.restoreReplayError = replayError;
|
||||
}
|
||||
seedSnapshotCaches(entry, state);
|
||||
seedSnapshotCaches(entry, publicState);
|
||||
const artifactRestoreWarnings = await entry.artifacts.restore(
|
||||
restoredArtifactSnapshot,
|
||||
);
|
||||
for (const warning of artifactRestoreWarnings) {
|
||||
writeStderrLine(
|
||||
`[artifacts] session=${entry.sessionId} action=restore_warning warning=${JSON.stringify(
|
||||
warning,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
const artifactRestoreFailed = artifactRestoreWarnings.some((warning) =>
|
||||
isArtifactRestoreFailureWarning(warning),
|
||||
);
|
||||
if (replayUpdates.length > 0) {
|
||||
await ci.client.seedSessionUpdates(entry, replayUpdates);
|
||||
await ci.client.seedSessionUpdates(entry, replayUpdates, {
|
||||
ingestArtifacts:
|
||||
restoredArtifactSnapshot === undefined || artifactRestoreFailed,
|
||||
});
|
||||
ci.client.drainEarlyEvents(entry.sessionId, entry);
|
||||
}
|
||||
const clientId = registerClient(entry, req.clientId);
|
||||
|
|
@ -3145,7 +3293,10 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
|||
attached: false,
|
||||
clientId,
|
||||
createdAt: entry.createdAt,
|
||||
state,
|
||||
state: publicState,
|
||||
...(artifactRestoreWarnings.length > 0
|
||||
? { artifactWarnings: artifactRestoreWarnings }
|
||||
: {}),
|
||||
hasActivePrompt: entry.promptActive,
|
||||
...replayFieldsFor(entry, action),
|
||||
};
|
||||
|
|
@ -4601,9 +4752,10 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
|||
return { displayName: entry.displayName };
|
||||
},
|
||||
|
||||
async getSessionArtifacts(sessionId) {
|
||||
async getSessionArtifacts(sessionId, context) {
|
||||
const entry = byId.get(sessionId);
|
||||
if (!entry) throw new SessionNotFoundError(sessionId);
|
||||
resolveTrustedClientId(entry, context?.clientId);
|
||||
return entry.artifacts.list();
|
||||
},
|
||||
|
||||
|
|
@ -4613,9 +4765,13 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
|||
const clientId = resolveTrustedClientId(entry, context?.clientId);
|
||||
const input = makeClientArtifactInput(artifact, clientId);
|
||||
const result: SessionArtifactMutationResult =
|
||||
await entry.artifacts.upsertMany([input], { strict: true });
|
||||
await entry.artifacts.upsertMany([input], {
|
||||
validationStrict: true,
|
||||
persistenceStrict: false,
|
||||
});
|
||||
publishArtifactChanges(entry, result.changes, clientId);
|
||||
return result;
|
||||
const warnings = [...(result.warnings ?? [])];
|
||||
return warnings.length > 0 ? { ...result, warnings } : result;
|
||||
},
|
||||
|
||||
async removeSessionArtifact(sessionId, artifactId, context) {
|
||||
|
|
@ -4624,7 +4780,8 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
|||
const clientId = resolveTrustedClientId(entry, context?.clientId);
|
||||
const result = await entry.artifacts.remove(artifactId, { clientId });
|
||||
publishArtifactChanges(entry, result.changes, clientId);
|
||||
return result;
|
||||
const warnings = [...(result.warnings ?? [])];
|
||||
return warnings.length > 0 ? { ...result, warnings } : result;
|
||||
},
|
||||
|
||||
listWorkspaceSessions(workspaceCwd) {
|
||||
|
|
@ -5975,7 +6132,58 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
|||
const targetTurnIndex = (response['targetTurnIndex'] as number) ?? 0;
|
||||
const filesChanged = (response['filesChanged'] as string[]) ?? [];
|
||||
const filesFailed = (response['filesFailed'] as string[]) ?? [];
|
||||
|
||||
const artifactSnapshot = restoredArtifactSnapshotFromState(
|
||||
response as BridgeSessionState,
|
||||
);
|
||||
const artifactSnapshotUnavailable = artifactSnapshotUnavailableReason(
|
||||
response as BridgeSessionState,
|
||||
);
|
||||
const beforeArtifacts = (await entry.artifacts.list()).artifacts;
|
||||
const shouldRestoreArtifactSnapshot =
|
||||
artifactSnapshot !== undefined &&
|
||||
artifactSnapshotUnavailable === undefined;
|
||||
const artifactRestoreWarnings =
|
||||
artifactSnapshotUnavailable !== undefined
|
||||
? [
|
||||
`artifact snapshot rebuild unavailable during rewind: ${artifactSnapshotUnavailable}`,
|
||||
]
|
||||
: shouldRestoreArtifactSnapshot
|
||||
? await entry.artifacts.restore(artifactSnapshot, {
|
||||
preserveLiveEphemeral: true,
|
||||
})
|
||||
: [];
|
||||
const artifactRestoreFailed = artifactRestoreWarnings.some(
|
||||
isArtifactRestoreFailureWarning,
|
||||
);
|
||||
const shouldRecordArtifactSnapshot =
|
||||
shouldRestoreArtifactSnapshot && !artifactRestoreFailed;
|
||||
const artifactSnapshotWarnings = shouldRecordArtifactSnapshot
|
||||
? await entry.artifacts.recordSnapshot()
|
||||
: [];
|
||||
const artifactWarnings = [
|
||||
...artifactRestoreWarnings,
|
||||
...artifactSnapshotWarnings,
|
||||
];
|
||||
for (const warning of artifactRestoreWarnings) {
|
||||
writeStderrLine(
|
||||
`[artifacts] session=${entry.sessionId} action=rewind_restore_warning warning=${JSON.stringify(
|
||||
warning,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
for (const warning of artifactSnapshotWarnings) {
|
||||
writeStderrLine(
|
||||
`[artifacts] session=${entry.sessionId} action=rewind_snapshot_warning warning=${JSON.stringify(
|
||||
warning,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
const afterArtifacts = (await entry.artifacts.list()).artifacts;
|
||||
publishArtifactChanges(
|
||||
entry,
|
||||
artifactReseedChanges(beforeArtifacts, afterArtifacts),
|
||||
originatorClientId,
|
||||
);
|
||||
try {
|
||||
entry.events.publish({
|
||||
type: 'session_rewound',
|
||||
|
|
@ -5985,6 +6193,9 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
|||
targetTurnIndex,
|
||||
filesChanged,
|
||||
filesFailed,
|
||||
...(artifactWarnings.length > 0
|
||||
? { warnings: artifactWarnings }
|
||||
: {}),
|
||||
},
|
||||
...(originatorClientId ? { originatorClientId } : {}),
|
||||
});
|
||||
|
|
@ -5997,6 +6208,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
|||
targetTurnIndex,
|
||||
filesChanged,
|
||||
filesFailed,
|
||||
...(artifactWarnings.length > 0 ? { warnings: artifactWarnings } : {}),
|
||||
};
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -952,6 +952,7 @@ describe('BridgeClient — artifact ingress', () => {
|
|||
storage: 'published',
|
||||
url: artifactUrl,
|
||||
managedId: 'managed-1',
|
||||
retention: 'ephemeral',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -975,6 +976,7 @@ describe('BridgeClient — artifact ingress', () => {
|
|||
artifact: expect.objectContaining({
|
||||
title: 'Dashboard',
|
||||
storage: 'published',
|
||||
retention: 'ephemeral',
|
||||
}),
|
||||
}),
|
||||
},
|
||||
|
|
@ -986,6 +988,7 @@ describe('BridgeClient — artifact ingress', () => {
|
|||
title: 'Dashboard',
|
||||
storage: 'published',
|
||||
managedId: 'managed-1',
|
||||
retention: 'ephemeral',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
@ -1267,6 +1270,7 @@ describe('BridgeClient — artifact ingress', () => {
|
|||
{
|
||||
title: 'Hook dashboard',
|
||||
url: 'https://example.com/hook-dashboard',
|
||||
retention: 'ephemeral',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
@ -1282,6 +1286,7 @@ describe('BridgeClient — artifact ingress', () => {
|
|||
source: 'hook',
|
||||
hookEventName: 'PostToolUse',
|
||||
title: 'Hook dashboard',
|
||||
retention: 'ephemeral',
|
||||
}),
|
||||
}),
|
||||
},
|
||||
|
|
@ -1293,6 +1298,7 @@ describe('BridgeClient — artifact ingress', () => {
|
|||
source: 'hook',
|
||||
hookEventName: 'PostToolUse',
|
||||
title: 'Hook dashboard',
|
||||
retention: 'ephemeral',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -105,6 +105,7 @@ function artifactPayloadFields(
|
|||
mimeType: artifact['mimeType'] as string | undefined,
|
||||
sizeBytes: artifact['sizeBytes'] as number | undefined,
|
||||
metadata: artifact['metadata'] as SessionArtifactInput['metadata'],
|
||||
retention: artifact['retention'] as SessionArtifactInput['retention'],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -717,6 +718,7 @@ export class BridgeClient implements Client {
|
|||
async seedSessionUpdates(
|
||||
entry: BridgeClientSessionEntry,
|
||||
updates: SessionUpdate[],
|
||||
options: { ingestArtifacts?: boolean } = {},
|
||||
): Promise<void> {
|
||||
const frames: Array<Omit<BridgeEvent, 'id' | 'v'>> = [];
|
||||
const artifactBatches: Array<{
|
||||
|
|
@ -729,7 +731,7 @@ export class BridgeClient implements Client {
|
|||
entry,
|
||||
);
|
||||
frames.push(...prepared.frames);
|
||||
if (prepared.artifacts.length > 0) {
|
||||
if (options.ingestArtifacts !== false && prepared.artifacts.length > 0) {
|
||||
artifactBatches.push({
|
||||
artifacts: prepared.artifacts,
|
||||
trustedPublisher: prepared.trustedPublisher,
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ export interface RewindResponse {
|
|||
targetTurnIndex: number;
|
||||
filesChanged: string[];
|
||||
filesFailed: string[];
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
export interface BridgeSpawnRequest {
|
||||
|
|
@ -107,7 +108,7 @@ export interface BridgeRestoreSessionRequest {
|
|||
workspaceCwd: string;
|
||||
/** Optional echo of a daemon-issued client id for this session. */
|
||||
clientId?: string;
|
||||
/** Internal replay transport for `session/load`; defaults to ACP streaming. */
|
||||
/** Internal replay transport for `session/load`; defaults to bulk response. */
|
||||
historyReplay?: 'stream' | 'response';
|
||||
}
|
||||
|
||||
|
|
@ -123,11 +124,19 @@ export interface BridgeLoadReplayEnvelope {
|
|||
replayError?: string;
|
||||
}
|
||||
|
||||
export type BridgeSessionState = LoadSessionResponse | ResumeSessionResponse;
|
||||
export type BridgeSessionState = (
|
||||
| LoadSessionResponse
|
||||
| ResumeSessionResponse
|
||||
) & {
|
||||
artifactSnapshot?: unknown;
|
||||
artifactSnapshotUnavailable?: unknown;
|
||||
};
|
||||
|
||||
export interface BridgeRestoredSession extends BridgeSession {
|
||||
/** ACP state returned by `session/load` / `session/resume`. */
|
||||
state: BridgeSessionState;
|
||||
/** Artifact restore warnings surfaced during session load/resume. */
|
||||
artifactWarnings?: string[];
|
||||
/** True when response-mode history replay aborted after emitting a prefix. */
|
||||
partial?: true;
|
||||
/** Agent-provided replay failure detail when `partial` is true. */
|
||||
|
|
@ -571,7 +580,10 @@ export interface AcpSessionBridge {
|
|||
* List the structured artifacts registered for a live session. Throws
|
||||
* `SessionNotFoundError` when the id is unknown.
|
||||
*/
|
||||
getSessionArtifacts(sessionId: string): Promise<SessionArtifactsEnvelope>;
|
||||
getSessionArtifacts(
|
||||
sessionId: string,
|
||||
context?: BridgeClientRequestContext,
|
||||
): Promise<SessionArtifactsEnvelope>;
|
||||
|
||||
/**
|
||||
* Register a client-supplied artifact for the session. Client artifacts use
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -138,6 +138,7 @@ export const SERVE_CONTROL_EXT_METHODS = {
|
|||
sessionRewind: 'qwen/control/session/rewind',
|
||||
sessionContinue: 'qwen/control/session/continue',
|
||||
sessionTitle: 'qwen/control/session/title',
|
||||
sessionArtifactsPersist: 'qwen/control/session/artifacts/persist',
|
||||
workspaceMcpRestart: 'qwen/control/workspace/mcp/restart',
|
||||
workspaceMcpManage: 'qwen/control/workspace/mcp/manage',
|
||||
workspaceAgentGenerate: 'qwen/control/workspace/agents/generate',
|
||||
|
|
|
|||
|
|
@ -128,6 +128,23 @@ vi.mock('node:stream', async (importOriginal) => {
|
|||
|
||||
// Mock core dependencies
|
||||
vi.mock('@qwen-code/qwen-code-core', () => ({
|
||||
SESSION_ARTIFACT_PERSISTENCE_VERSION: 2,
|
||||
normalizeEventPayload: vi.fn((payload: unknown) =>
|
||||
typeof payload === 'object' &&
|
||||
payload !== null &&
|
||||
!Array.isArray(payload) &&
|
||||
Array.isArray((payload as { changes?: unknown }).changes)
|
||||
? payload
|
||||
: undefined,
|
||||
),
|
||||
normalizeSnapshotPayload: vi.fn((payload: unknown) =>
|
||||
typeof payload === 'object' &&
|
||||
payload !== null &&
|
||||
!Array.isArray(payload) &&
|
||||
Array.isArray((payload as { artifacts?: unknown }).artifacts)
|
||||
? payload
|
||||
: undefined,
|
||||
),
|
||||
createDebugLogger: () => mockDebugLogger,
|
||||
registerAcpEventLoopLagGauge: vi.fn(),
|
||||
startEventLoopLagMonitor: vi.fn(() => ({
|
||||
|
|
@ -634,6 +651,7 @@ import {
|
|||
unregisterGoalHook,
|
||||
startEventLoopLagMonitor,
|
||||
registerAcpEventLoopLagGauge,
|
||||
SESSION_ARTIFACT_PERSISTENCE_VERSION,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type { McpServer } from '@agentclientprotocol/sdk';
|
||||
import { AgentSideConnection } from '@agentclientprotocol/sdk';
|
||||
|
|
@ -1559,6 +1577,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
|
|||
reloadModelProvidersConfig: vi.fn(),
|
||||
refreshAuth: vi.fn().mockResolvedValue(undefined),
|
||||
getModel: vi.fn().mockReturnValue('m'),
|
||||
getProjectRoot: vi.fn().mockReturnValue('/tmp'),
|
||||
getTargetDir: vi.fn().mockReturnValue('/tmp'),
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue({}),
|
||||
getAvailableModels: vi.fn().mockReturnValue([]),
|
||||
|
|
@ -1573,6 +1592,9 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
|
|||
waitForMcpReady: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
getFileSystemService: vi.fn().mockReturnValue(undefined),
|
||||
getChatRecordingService: vi.fn().mockReturnValue({
|
||||
flush: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
setFileSystemService: vi.fn(),
|
||||
getHookSystem: vi.fn().mockReturnValue(undefined),
|
||||
getDisableAllHooks: vi.fn().mockReturnValue(true),
|
||||
|
|
@ -1827,6 +1849,241 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
|
|||
return innerConfig;
|
||||
}
|
||||
|
||||
async function bootAcpAgent() {
|
||||
const agentPromise = runAcpAgent(
|
||||
mockConfig,
|
||||
makeSessionSettings(),
|
||||
mockArgv,
|
||||
);
|
||||
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
|
||||
const agent = capturedAgentFactory!({
|
||||
get closed() {
|
||||
return mockConnectionState.promise;
|
||||
},
|
||||
}) as AgentLike;
|
||||
return { agent, agentPromise };
|
||||
}
|
||||
|
||||
it('sessionArtifactsPersist rejects a missing session id', async () => {
|
||||
const { agent, agentPromise } = await bootAcpAgent();
|
||||
|
||||
await expect(
|
||||
agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionArtifactsPersist, {
|
||||
kind: 'event',
|
||||
payload: {},
|
||||
}),
|
||||
).rejects.toThrowError(/Invalid or missing sessionId/);
|
||||
|
||||
mockConnectionState.resolve();
|
||||
await agentPromise;
|
||||
});
|
||||
|
||||
it('sessionArtifactsPersist rejects an invalid kind', async () => {
|
||||
const { agent, agentPromise } = await bootAcpAgent();
|
||||
|
||||
await expect(
|
||||
agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionArtifactsPersist, {
|
||||
sessionId: 'session-A',
|
||||
kind: 'other',
|
||||
payload: {},
|
||||
}),
|
||||
).rejects.toThrowError(/Invalid or missing artifact persist kind/);
|
||||
|
||||
mockConnectionState.resolve();
|
||||
await agentPromise;
|
||||
});
|
||||
|
||||
it('sessionArtifactsPersist rejects a missing payload', async () => {
|
||||
const { agent, agentPromise } = await bootAcpAgent();
|
||||
|
||||
await expect(
|
||||
agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionArtifactsPersist, {
|
||||
sessionId: 'session-A',
|
||||
kind: 'event',
|
||||
}),
|
||||
).rejects.toThrowError(/Invalid or missing artifact persist payload/);
|
||||
|
||||
mockConnectionState.resolve();
|
||||
await agentPromise;
|
||||
});
|
||||
|
||||
it('sessionArtifactsPersist rejects a missing live session', async () => {
|
||||
const { agent, agentPromise } = await bootAcpAgent();
|
||||
|
||||
await expect(
|
||||
agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionArtifactsPersist, {
|
||||
sessionId: 'session-A',
|
||||
kind: 'event',
|
||||
payload: {
|
||||
v: SESSION_ARTIFACT_PERSISTENCE_VERSION,
|
||||
sessionId: 'session-A',
|
||||
sequence: 1,
|
||||
recordedAt: '2026-07-04T00:00:00.000Z',
|
||||
changes: [],
|
||||
},
|
||||
}),
|
||||
).rejects.toThrowError(/Session not found for id: session-A/);
|
||||
|
||||
mockConnectionState.resolve();
|
||||
await agentPromise;
|
||||
});
|
||||
|
||||
it('sessionArtifactsPersist rejects when chat recording is unavailable', async () => {
|
||||
const sessionId = 'session-A';
|
||||
const innerConfig = await setupSessionMocks(sessionId);
|
||||
innerConfig.getChatRecordingService = vi.fn().mockReturnValue(undefined);
|
||||
const { agent, agentPromise } = await bootAcpAgent();
|
||||
|
||||
await agent.newSession({ cwd: '/tmp', mcpServers: [] });
|
||||
await expect(
|
||||
agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionArtifactsPersist, {
|
||||
sessionId,
|
||||
kind: 'event',
|
||||
payload: {
|
||||
v: SESSION_ARTIFACT_PERSISTENCE_VERSION,
|
||||
sessionId,
|
||||
sequence: 1,
|
||||
recordedAt: '2026-07-04T00:00:00.000Z',
|
||||
changes: [],
|
||||
},
|
||||
}),
|
||||
).rejects.toThrowError(/Chat recording service unavailable/);
|
||||
|
||||
mockConnectionState.resolve();
|
||||
await agentPromise;
|
||||
});
|
||||
|
||||
it('sessionArtifactsPersist records artifact events and snapshots', async () => {
|
||||
const sessionId = 'session-A';
|
||||
const recording = {
|
||||
flush: vi.fn().mockResolvedValue(undefined),
|
||||
recordSessionArtifactEvent: vi.fn().mockResolvedValue(undefined),
|
||||
recordSessionArtifactSnapshot: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const innerConfig = await setupSessionMocks(sessionId);
|
||||
innerConfig.getChatRecordingService = vi.fn().mockReturnValue(recording);
|
||||
const { agent, agentPromise } = await bootAcpAgent();
|
||||
const eventPayload = {
|
||||
v: SESSION_ARTIFACT_PERSISTENCE_VERSION,
|
||||
sessionId,
|
||||
sequence: 1,
|
||||
recordedAt: '2026-07-04T00:00:00.000Z',
|
||||
changes: [],
|
||||
};
|
||||
const snapshotPayload = {
|
||||
v: SESSION_ARTIFACT_PERSISTENCE_VERSION,
|
||||
sessionId,
|
||||
sequence: 2,
|
||||
recordedAt: '2026-07-04T00:00:01.000Z',
|
||||
artifacts: [],
|
||||
tombstonedIds: [],
|
||||
stickyEphemeralIds: [],
|
||||
};
|
||||
|
||||
await agent.newSession({ cwd: '/tmp', mcpServers: [] });
|
||||
await expect(
|
||||
agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionArtifactsPersist, {
|
||||
sessionId,
|
||||
kind: 'event',
|
||||
payload: eventPayload,
|
||||
}),
|
||||
).resolves.toEqual({ sessionId, persisted: true, kind: 'event' });
|
||||
await expect(
|
||||
agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionArtifactsPersist, {
|
||||
sessionId,
|
||||
kind: 'snapshot',
|
||||
payload: snapshotPayload,
|
||||
}),
|
||||
).resolves.toEqual({ sessionId, persisted: true, kind: 'snapshot' });
|
||||
|
||||
expect(recording.recordSessionArtifactEvent).toHaveBeenCalledWith(
|
||||
eventPayload,
|
||||
);
|
||||
expect(recording.recordSessionArtifactSnapshot).toHaveBeenCalledWith(
|
||||
snapshotPayload,
|
||||
);
|
||||
|
||||
mockConnectionState.resolve();
|
||||
await agentPromise;
|
||||
});
|
||||
|
||||
it('sessionArtifactsPersist rejects malformed event and snapshot payloads', async () => {
|
||||
const sessionId = 'session-A';
|
||||
const recording = {
|
||||
flush: vi.fn().mockResolvedValue(undefined),
|
||||
recordSessionArtifactEvent: vi.fn().mockResolvedValue(undefined),
|
||||
recordSessionArtifactSnapshot: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const innerConfig = await setupSessionMocks(sessionId);
|
||||
innerConfig.getChatRecordingService = vi.fn().mockReturnValue(recording);
|
||||
const { agent, agentPromise } = await bootAcpAgent();
|
||||
|
||||
await agent.newSession({ cwd: '/tmp', mcpServers: [] });
|
||||
await expect(
|
||||
agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionArtifactsPersist, {
|
||||
sessionId,
|
||||
kind: 'event',
|
||||
payload: {
|
||||
v: SESSION_ARTIFACT_PERSISTENCE_VERSION,
|
||||
sessionId,
|
||||
sequence: 1,
|
||||
changes: [],
|
||||
},
|
||||
}),
|
||||
).rejects.toThrowError(/Invalid or missing artifact persist payload/);
|
||||
await expect(
|
||||
agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionArtifactsPersist, {
|
||||
sessionId,
|
||||
kind: 'snapshot',
|
||||
payload: {
|
||||
v: SESSION_ARTIFACT_PERSISTENCE_VERSION,
|
||||
sessionId,
|
||||
sequence: 2,
|
||||
recordedAt: '2026-07-04T00:00:01.000Z',
|
||||
changes: [],
|
||||
},
|
||||
}),
|
||||
).rejects.toThrowError(/Invalid or missing artifact persist payload/);
|
||||
|
||||
expect(recording.recordSessionArtifactEvent).not.toHaveBeenCalled();
|
||||
expect(recording.recordSessionArtifactSnapshot).not.toHaveBeenCalled();
|
||||
|
||||
mockConnectionState.resolve();
|
||||
await agentPromise;
|
||||
});
|
||||
|
||||
it('sessionArtifactsPersist rejects payloads for a different session', async () => {
|
||||
const sessionId = 'session-A';
|
||||
const recording = {
|
||||
flush: vi.fn().mockResolvedValue(undefined),
|
||||
recordSessionArtifactEvent: vi.fn().mockResolvedValue(undefined),
|
||||
recordSessionArtifactSnapshot: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const innerConfig = await setupSessionMocks(sessionId);
|
||||
innerConfig.getChatRecordingService = vi.fn().mockReturnValue(recording);
|
||||
const { agent, agentPromise } = await bootAcpAgent();
|
||||
|
||||
await agent.newSession({ cwd: '/tmp', mcpServers: [] });
|
||||
await expect(
|
||||
agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionArtifactsPersist, {
|
||||
sessionId,
|
||||
kind: 'event',
|
||||
payload: {
|
||||
v: SESSION_ARTIFACT_PERSISTENCE_VERSION,
|
||||
sessionId: 'session-B',
|
||||
sequence: 1,
|
||||
recordedAt: '2026-07-04T00:00:00.000Z',
|
||||
changes: [],
|
||||
},
|
||||
}),
|
||||
).rejects.toThrowError(/Invalid or missing artifact persist payload/);
|
||||
|
||||
expect(recording.recordSessionArtifactEvent).not.toHaveBeenCalled();
|
||||
|
||||
mockConnectionState.resolve();
|
||||
await agentPromise;
|
||||
});
|
||||
|
||||
it('status ext methods expose workspace snapshots without secrets', async () => {
|
||||
vi.mocked(getMCPDiscoveryState).mockReturnValue(
|
||||
MCPDiscoveryState.COMPLETED,
|
||||
|
|
@ -7187,6 +7444,21 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
|
|||
it('rewindSession extension method rewinds the active session', async () => {
|
||||
const sessionId = '11111111-1111-1111-1111-111111111111';
|
||||
await setupSessionMocks(sessionId);
|
||||
const artifactSnapshot = {
|
||||
v: 1,
|
||||
sessionId,
|
||||
sequence: 0,
|
||||
artifacts: [],
|
||||
tombstonedIds: [],
|
||||
stickyEphemeralIds: [],
|
||||
warnings: [],
|
||||
};
|
||||
vi.mocked(SessionService).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
loadSession: vi.fn().mockResolvedValue({ artifactSnapshot }),
|
||||
}) as unknown as InstanceType<typeof SessionService>,
|
||||
);
|
||||
|
||||
const agentPromise = runAcpAgent(
|
||||
mockConfig,
|
||||
|
|
@ -7218,12 +7490,101 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
|
|||
apiTruncateIndex: 2,
|
||||
filesChanged: [],
|
||||
filesFailed: [],
|
||||
artifactSnapshot,
|
||||
});
|
||||
|
||||
mockConnectionState.resolve();
|
||||
await agentPromise;
|
||||
});
|
||||
|
||||
it('rewindSession extension method marks artifact snapshot unavailable when session reload is missing', async () => {
|
||||
const sessionId = '11111111-1111-1111-1111-111111111111';
|
||||
await setupSessionMocks(sessionId);
|
||||
vi.mocked(SessionService).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
loadSession: vi.fn().mockResolvedValue(undefined),
|
||||
}) as unknown as InstanceType<typeof SessionService>,
|
||||
);
|
||||
|
||||
const agentPromise = runAcpAgent(
|
||||
mockConfig,
|
||||
makeSessionSettings(),
|
||||
mockArgv,
|
||||
);
|
||||
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
|
||||
|
||||
const agent = capturedAgentFactory!({
|
||||
get closed() {
|
||||
return mockConnectionState.promise;
|
||||
},
|
||||
}) as AgentLike;
|
||||
|
||||
await agent.newSession({ cwd: '/tmp', mcpServers: [] });
|
||||
const response = await agent.extMethod('rewindSession', {
|
||||
sessionId,
|
||||
targetTurnIndex: 1,
|
||||
cwd: '/tmp',
|
||||
});
|
||||
|
||||
expect(response).toMatchObject({
|
||||
success: true,
|
||||
artifactSnapshotUnavailable: 'session data unavailable after rewind',
|
||||
});
|
||||
expect(response).not.toHaveProperty('artifactSnapshot');
|
||||
|
||||
mockConnectionState.resolve();
|
||||
await agentPromise;
|
||||
});
|
||||
|
||||
it('rewindSession extension method returns an empty artifact snapshot when reload has no artifact records', async () => {
|
||||
const sessionId = '11111111-1111-1111-1111-111111111111';
|
||||
await setupSessionMocks(sessionId);
|
||||
vi.mocked(SessionService).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
loadSession: vi.fn().mockResolvedValue({ conversation: {} }),
|
||||
}) as unknown as InstanceType<typeof SessionService>,
|
||||
);
|
||||
|
||||
const agentPromise = runAcpAgent(
|
||||
mockConfig,
|
||||
makeSessionSettings(),
|
||||
mockArgv,
|
||||
);
|
||||
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
|
||||
|
||||
const agent = capturedAgentFactory!({
|
||||
get closed() {
|
||||
return mockConnectionState.promise;
|
||||
},
|
||||
}) as AgentLike;
|
||||
|
||||
await agent.newSession({ cwd: '/tmp', mcpServers: [] });
|
||||
const response = await agent.extMethod('rewindSession', {
|
||||
sessionId,
|
||||
targetTurnIndex: 1,
|
||||
cwd: '/tmp',
|
||||
});
|
||||
|
||||
expect(response).toMatchObject({
|
||||
success: true,
|
||||
artifactSnapshot: {
|
||||
v: SESSION_ARTIFACT_PERSISTENCE_VERSION,
|
||||
sessionId,
|
||||
sequence: 0,
|
||||
artifacts: [],
|
||||
tombstonedIds: [],
|
||||
stickyEphemeralIds: [],
|
||||
warnings: [],
|
||||
},
|
||||
});
|
||||
expect(response).not.toHaveProperty('artifactSnapshotUnavailable');
|
||||
|
||||
mockConnectionState.resolve();
|
||||
await agentPromise;
|
||||
});
|
||||
|
||||
it('rewindSession extension method can skip file rewind', async () => {
|
||||
const sessionId = '11111111-1111-1111-1111-111111111111';
|
||||
await setupSessionMocks(sessionId);
|
||||
|
|
|
|||
|
|
@ -66,6 +66,9 @@ import {
|
|||
matchesAnyServerPattern,
|
||||
IMAGE_CAPABILITY,
|
||||
registerAcpEventLoopLagGauge,
|
||||
SESSION_ARTIFACT_PERSISTENCE_VERSION,
|
||||
normalizeEventPayload,
|
||||
normalizeSnapshotPayload,
|
||||
startEventLoopLagMonitor,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
|
@ -86,6 +89,8 @@ import type {
|
|||
DiscoveredMCPResource,
|
||||
DiscoveredMCPPrompt,
|
||||
WorkspaceRememberContextMode,
|
||||
SessionArtifactEventRecordPayload,
|
||||
SessionArtifactSnapshotRecordPayload,
|
||||
ChatRecord,
|
||||
HistoryGap,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
|
@ -330,6 +335,67 @@ function isObjectRecord(value: unknown): value is Record<string, unknown> {
|
|||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function parseSessionArtifactEventPayload(
|
||||
payload: unknown,
|
||||
expectedSessionId: string,
|
||||
): SessionArtifactEventRecordPayload {
|
||||
const record = parseSessionArtifactBasePayload(payload, expectedSessionId);
|
||||
const warnings: string[] = [];
|
||||
const normalized = normalizeEventPayload(record, warnings);
|
||||
if (
|
||||
!normalized ||
|
||||
warnings.length > 0 ||
|
||||
normalized.changes.length !== (record['changes'] as unknown[]).length
|
||||
) {
|
||||
throw invalidArtifactPersistPayload();
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function parseSessionArtifactSnapshotPayload(
|
||||
payload: unknown,
|
||||
expectedSessionId: string,
|
||||
): SessionArtifactSnapshotRecordPayload {
|
||||
const record = parseSessionArtifactBasePayload(payload, expectedSessionId);
|
||||
const warnings: string[] = [];
|
||||
const normalized = normalizeSnapshotPayload(record, warnings);
|
||||
if (
|
||||
!normalized ||
|
||||
warnings.length > 0 ||
|
||||
normalized.artifacts.length !== (record['artifacts'] as unknown[]).length
|
||||
) {
|
||||
throw invalidArtifactPersistPayload();
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function parseSessionArtifactBasePayload(
|
||||
payload: unknown,
|
||||
expectedSessionId: string,
|
||||
): Record<string, unknown> {
|
||||
if (!isObjectRecord(payload)) {
|
||||
throw invalidArtifactPersistPayload();
|
||||
}
|
||||
if (
|
||||
payload['v'] !== SESSION_ARTIFACT_PERSISTENCE_VERSION ||
|
||||
payload['sessionId'] !== expectedSessionId ||
|
||||
!Number.isSafeInteger(payload['sequence']) ||
|
||||
(payload['sequence'] as number) < 0 ||
|
||||
typeof payload['recordedAt'] !== 'string' ||
|
||||
payload['recordedAt'].length === 0
|
||||
) {
|
||||
throw invalidArtifactPersistPayload();
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function invalidArtifactPersistPayload(): Error {
|
||||
return RequestError.invalidParams(
|
||||
undefined,
|
||||
'Invalid or missing artifact persist payload',
|
||||
);
|
||||
}
|
||||
|
||||
function isBulkLoadReplayRequest(params: LoadSessionRequest): boolean {
|
||||
const meta = isObjectRecord(params._meta) ? params._meta : undefined;
|
||||
return meta?.[LOAD_REPLAY_MODE_META_KEY] === LOAD_REPLAY_BULK_MODE;
|
||||
|
|
@ -3202,7 +3268,10 @@ class QwenAgent implements Agent {
|
|||
modes: modesData,
|
||||
models: availableModels,
|
||||
configOptions,
|
||||
};
|
||||
...(sessionData?.artifactSnapshot
|
||||
? { artifactSnapshot: sessionData.artifactSnapshot }
|
||||
: {}),
|
||||
} as LoadSessionResponse;
|
||||
if (!replayEnvelope) {
|
||||
return response;
|
||||
}
|
||||
|
|
@ -3255,11 +3324,15 @@ class QwenAgent implements Agent {
|
|||
const availableModels = this.buildAvailableModels(config);
|
||||
const configOptions = this.buildConfigOptions(config);
|
||||
|
||||
const sessionData = config.getResumedSessionData();
|
||||
return {
|
||||
modes: modesData,
|
||||
models: availableModels,
|
||||
configOptions,
|
||||
};
|
||||
...(sessionData?.artifactSnapshot
|
||||
? { artifactSnapshot: sessionData.artifactSnapshot }
|
||||
: {}),
|
||||
} as ResumeSessionResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -6473,6 +6546,47 @@ class QwenAgent implements Agent {
|
|||
AbortSignal.timeout(5 * 60_000),
|
||||
)) as unknown as Record<string, unknown>;
|
||||
}
|
||||
case SERVE_CONTROL_EXT_METHODS.sessionArtifactsPersist: {
|
||||
const sessionId = params['sessionId'];
|
||||
if (typeof sessionId !== 'string' || sessionId.length === 0) {
|
||||
throw RequestError.invalidParams(
|
||||
undefined,
|
||||
'Invalid or missing sessionId',
|
||||
);
|
||||
}
|
||||
const kind = params['kind'];
|
||||
if (kind !== 'event' && kind !== 'snapshot') {
|
||||
throw RequestError.invalidParams(
|
||||
undefined,
|
||||
'Invalid or missing artifact persist kind',
|
||||
);
|
||||
}
|
||||
const payload = params['payload'];
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
||||
throw RequestError.invalidParams(
|
||||
undefined,
|
||||
'Invalid or missing artifact persist payload',
|
||||
);
|
||||
}
|
||||
const session = this.sessionOrThrow(sessionId);
|
||||
const recording = session.getConfig().getChatRecordingService();
|
||||
if (!recording) {
|
||||
throw RequestError.internalError(
|
||||
undefined,
|
||||
'Chat recording service unavailable',
|
||||
);
|
||||
}
|
||||
if (kind === 'event') {
|
||||
await recording.recordSessionArtifactEvent(
|
||||
parseSessionArtifactEventPayload(payload, sessionId),
|
||||
);
|
||||
} else {
|
||||
await recording.recordSessionArtifactSnapshot(
|
||||
parseSessionArtifactSnapshotPayload(payload, sessionId),
|
||||
);
|
||||
}
|
||||
return { sessionId, persisted: true, kind };
|
||||
}
|
||||
case SERVE_CONTROL_EXT_METHODS.sessionTitle: {
|
||||
const sessionId = params['sessionId'];
|
||||
const displayName = params['displayName'];
|
||||
|
|
@ -7521,6 +7635,46 @@ class QwenAgent implements Agent {
|
|||
filesFailed = [`file-history-rewind: ${reason}`];
|
||||
}
|
||||
}
|
||||
let artifactSnapshot: unknown;
|
||||
let artifactSnapshotUnavailable: string | undefined;
|
||||
try {
|
||||
await session.getConfig().getChatRecordingService()?.flush();
|
||||
const cwd = session.getConfig().getProjectRoot();
|
||||
const sessionData = await runWithAcpRuntimeOutputDir(
|
||||
this.settings,
|
||||
cwd,
|
||||
async () => {
|
||||
const sessionService = new SessionService(cwd);
|
||||
return sessionService.loadSession(sessionId);
|
||||
},
|
||||
);
|
||||
if (sessionData === undefined) {
|
||||
artifactSnapshotUnavailable =
|
||||
'session data unavailable after rewind';
|
||||
} else if (sessionData.artifactSnapshot) {
|
||||
artifactSnapshot = sessionData.artifactSnapshot;
|
||||
} else {
|
||||
// A successful reload with no artifact records is a valid empty
|
||||
// artifact timeline, distinct from an unavailable reload.
|
||||
artifactSnapshot = {
|
||||
v: SESSION_ARTIFACT_PERSISTENCE_VERSION,
|
||||
sessionId,
|
||||
sequence: 0,
|
||||
artifacts: [],
|
||||
tombstonedIds: [],
|
||||
stickyEphemeralIds: [],
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
artifactSnapshotUnavailable =
|
||||
err instanceof Error ? err.message : String(err);
|
||||
debugLogger.warn(
|
||||
`[ACP] Failed to rebuild artifact snapshot after rewind for session=${sessionId}: ${
|
||||
artifactSnapshotUnavailable
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
|
@ -7528,6 +7682,10 @@ class QwenAgent implements Agent {
|
|||
...rewindResult,
|
||||
filesChanged,
|
||||
filesFailed,
|
||||
...(artifactSnapshot ? { artifactSnapshot } : {}),
|
||||
...(artifactSnapshotUnavailable
|
||||
? { artifactSnapshotUnavailable }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
case 'qwen/session/loadUpdates': {
|
||||
|
|
|
|||
|
|
@ -679,6 +679,25 @@ describe('HistoryReplayer', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('should replay structured artifacts from stored tool results', async () => {
|
||||
const record = createToolResultRecord('read_file', 'File contents here');
|
||||
const artifacts = [
|
||||
{
|
||||
title: 'Replay artifact',
|
||||
url: 'https://example.com/replayed',
|
||||
},
|
||||
];
|
||||
record.toolCallResult!.artifacts = artifacts;
|
||||
|
||||
await replayer.replay([record]);
|
||||
|
||||
expect(sentUpdates()[0]).toMatchObject({
|
||||
_meta: {
|
||||
artifacts,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should emit failed status for tool results with errors', async () => {
|
||||
const records = [createToolResultRecord('failing_tool', undefined, true)];
|
||||
|
||||
|
|
|
|||
|
|
@ -295,6 +295,7 @@ export class HistoryReplayer {
|
|||
success: !result?.error,
|
||||
message: record.message.parts,
|
||||
resultDisplay: result?.resultDisplay,
|
||||
artifacts: result?.artifacts,
|
||||
// For TodoWriteTool fallback, try to extract args from the record
|
||||
// Note: args aren't stored in tool_result records by default
|
||||
args: undefined,
|
||||
|
|
|
|||
|
|
@ -46,7 +46,10 @@ import {
|
|||
SessionShellDisabledError,
|
||||
WorkspaceMismatchError,
|
||||
} from '@qwen-code/acp-bridge/bridgeErrors';
|
||||
import { SessionArtifactValidationError } from '@qwen-code/acp-bridge/sessionArtifacts';
|
||||
import {
|
||||
SessionArtifactAuthorizationError,
|
||||
SessionArtifactValidationError,
|
||||
} from '@qwen-code/acp-bridge/sessionArtifacts';
|
||||
import { canonicalizeWorkspace } from '@qwen-code/acp-bridge/workspacePaths';
|
||||
import { writeStderrLine } from '../../utils/stdioHelpers.js';
|
||||
import { MAX_WORKSPACE_PATH_LENGTH } from '../fs/paths.js';
|
||||
|
|
@ -411,6 +414,8 @@ function pickSessionArtifactInput(
|
|||
mimeType,
|
||||
sizeBytes,
|
||||
metadata,
|
||||
retention,
|
||||
clientRetained,
|
||||
} = params;
|
||||
|
||||
return {
|
||||
|
|
@ -424,6 +429,8 @@ function pickSessionArtifactInput(
|
|||
mimeType,
|
||||
sizeBytes,
|
||||
metadata,
|
||||
retention,
|
||||
clientRetained,
|
||||
} as AddSessionArtifactInput;
|
||||
}
|
||||
|
||||
|
|
@ -533,6 +540,17 @@ function toRpcError(err: unknown): {
|
|||
data: { errorKind: 'client_id_required' },
|
||||
};
|
||||
}
|
||||
if (err instanceof SessionArtifactAuthorizationError) {
|
||||
return {
|
||||
code: RPC.INVALID_REQUEST,
|
||||
message: err.message,
|
||||
data: {
|
||||
errorKind: 'artifact_forbidden',
|
||||
sessionId: err.sessionId,
|
||||
artifactId: err.artifactId,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (err instanceof SessionArtifactValidationError) {
|
||||
return {
|
||||
code: RPC.INVALID_PARAMS,
|
||||
|
|
@ -2556,7 +2574,10 @@ export class AcpDispatcher {
|
|||
case `${QWEN_METHOD_NS}session/artifacts`: {
|
||||
const sessionId = String(params['sessionId'] ?? '');
|
||||
if (!this.requireOwned(conn, sessionId, id)) return;
|
||||
const result = await this.bridge.getSessionArtifacts(sessionId);
|
||||
const result = await this.bridge.getSessionArtifacts(
|
||||
sessionId,
|
||||
this.sessionCtx(conn, sessionId, loopback),
|
||||
);
|
||||
this.replyConn(conn, id, result as unknown);
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,10 @@ import type {
|
|||
BridgeEvent,
|
||||
SessionReplaySnapshot,
|
||||
} from '@qwen-code/acp-bridge/eventBus';
|
||||
import { SessionArtifactValidationError } from '@qwen-code/acp-bridge/sessionArtifacts';
|
||||
import {
|
||||
SessionArtifactAuthorizationError,
|
||||
SessionArtifactValidationError,
|
||||
} from '@qwen-code/acp-bridge/sessionArtifacts';
|
||||
import {
|
||||
CancelSentinelCollisionError,
|
||||
InvalidClientIdError,
|
||||
|
|
@ -398,6 +401,9 @@ class FakeBridge {
|
|||
}
|
||||
| undefined;
|
||||
lastArtifactListSessionId: string | undefined;
|
||||
lastArtifactListContext:
|
||||
| Parameters<HttpAcpBridge['getSessionArtifacts']>[1]
|
||||
| undefined;
|
||||
lastRemovedArtifact:
|
||||
| {
|
||||
sessionId: string;
|
||||
|
|
@ -405,8 +411,12 @@ class FakeBridge {
|
|||
context: Parameters<HttpAcpBridge['removeSessionArtifact']>[2];
|
||||
}
|
||||
| undefined;
|
||||
async getSessionArtifacts(sessionId: string) {
|
||||
async getSessionArtifacts(
|
||||
sessionId: string,
|
||||
context: Parameters<HttpAcpBridge['getSessionArtifacts']>[1],
|
||||
) {
|
||||
this.lastArtifactListSessionId = sessionId;
|
||||
this.lastArtifactListContext = context;
|
||||
return {
|
||||
v: 1,
|
||||
sessionId,
|
||||
|
|
@ -6039,6 +6049,10 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
|
|||
},
|
||||
});
|
||||
expect(bridge.lastArtifactListSessionId).toBe('sess-1');
|
||||
expect(bridge.lastArtifactListContext).toEqual({
|
||||
clientId: 'client-1',
|
||||
fromLoopback: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('_qwen/session/artifacts/add forwards only public artifact fields', async () => {
|
||||
|
|
@ -6063,6 +6077,8 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
|
|||
storage: 'external_url',
|
||||
url: 'https://example.test/lineage',
|
||||
metadata: { table: 'fact_orders' },
|
||||
retention: 'ephemeral',
|
||||
clientRetained: false,
|
||||
source: 'tool',
|
||||
trustedPublisher: true,
|
||||
clientId: 'forged-client',
|
||||
|
|
@ -6081,6 +6097,8 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
|
|||
storage: 'external_url',
|
||||
url: 'https://example.test/lineage',
|
||||
metadata: { table: 'fact_orders' },
|
||||
retention: 'ephemeral',
|
||||
clientRetained: false,
|
||||
});
|
||||
const artifact = bridge.lastAddedArtifact?.artifact as
|
||||
| Record<string, unknown>
|
||||
|
|
@ -6091,6 +6109,10 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
|
|||
expect(artifact).not.toHaveProperty('clientId');
|
||||
expect(artifact).not.toHaveProperty('toolName');
|
||||
expect(artifact).not.toHaveProperty('hookEventName');
|
||||
expect(bridge.lastAddedArtifact?.context).toEqual({
|
||||
clientId: 'client-1',
|
||||
fromLoopback: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('_qwen/session/artifacts/add maps artifact validation errors to invalid params', async () => {
|
||||
|
|
@ -6144,7 +6166,10 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
|
|||
jsonrpc: '2.0',
|
||||
id: 59,
|
||||
method: '_qwen/session/artifacts/remove',
|
||||
params: { sessionId: 'sess-1', artifactId: 'artifact-1' },
|
||||
params: {
|
||||
sessionId: 'sess-1',
|
||||
artifactId: 'artifact-1',
|
||||
},
|
||||
});
|
||||
const frames = await takeFrames(await streamRes, 2);
|
||||
expect(frames[1]).toMatchObject({
|
||||
|
|
@ -6163,6 +6188,10 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
|
|||
expect(bridge.lastRemovedArtifact).toMatchObject({
|
||||
sessionId: 'sess-1',
|
||||
artifactId: 'artifact-1',
|
||||
context: {
|
||||
clientId: 'client-1',
|
||||
fromLoopback: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -6193,6 +6222,46 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
|
|||
expect(bridge.lastRemovedArtifact).toBeUndefined();
|
||||
});
|
||||
|
||||
it('_qwen/session/artifacts/remove maps artifact authorization errors', async () => {
|
||||
bridge.removeSessionArtifact = async () => {
|
||||
throw new SessionArtifactAuthorizationError(
|
||||
'sess-1',
|
||||
'artifact-1',
|
||||
'client-owner',
|
||||
'client-other',
|
||||
);
|
||||
};
|
||||
const connId = await initialize();
|
||||
const streamRes = openStream(connId);
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
await post(connId, {
|
||||
jsonrpc: '2.0',
|
||||
id: 99,
|
||||
method: 'session/new',
|
||||
params: {},
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
await post(connId, {
|
||||
jsonrpc: '2.0',
|
||||
id: 61,
|
||||
method: '_qwen/session/artifacts/remove',
|
||||
params: { sessionId: 'sess-1', artifactId: 'artifact-1' },
|
||||
});
|
||||
|
||||
const frames = await takeFrames(await streamRes, 2);
|
||||
expect(frames[1]).toMatchObject({
|
||||
error: {
|
||||
code: -32600,
|
||||
message: 'artifact artifact-1 is owned by a different client',
|
||||
data: {
|
||||
errorKind: 'artifact_forbidden',
|
||||
sessionId: 'sess-1',
|
||||
artifactId: 'artifact-1',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('_qwen/session/artifacts/add holds the archive gate while mutating', async () => {
|
||||
await withRuntimeDir(async () => {
|
||||
const sessionId = '550e8400-e29b-41d4-a716-446655440131';
|
||||
|
|
@ -6275,7 +6344,11 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
|
|||
artifactId,
|
||||
context,
|
||||
) => {
|
||||
bridge.lastRemovedArtifact = { sessionId, artifactId, context };
|
||||
bridge.lastRemovedArtifact = {
|
||||
sessionId,
|
||||
artifactId,
|
||||
context,
|
||||
};
|
||||
removeStarted();
|
||||
await removeReleasedPromise;
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -130,4 +130,7 @@ export {
|
|||
canonicalizeWorkspace,
|
||||
} from '@qwen-code/acp-bridge/workspacePaths';
|
||||
|
||||
export { SessionArtifactValidationError } from '@qwen-code/acp-bridge/sessionArtifacts';
|
||||
export {
|
||||
SessionArtifactAuthorizationError,
|
||||
SessionArtifactValidationError,
|
||||
} from '@qwen-code/acp-bridge/sessionArtifacts';
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ export const SERVE_CAPABILITY_REGISTRY = {
|
|||
session_cancel: { since: 'v1' },
|
||||
session_events: { since: 'v1' },
|
||||
session_artifacts: { since: 'v1' },
|
||||
session_artifacts_persistence: { since: 'v1' },
|
||||
// Daemon emits `slow_client_warning` synthetic frames at 75% queue
|
||||
// fill and honors `?maxQueued=N` (range [16, 2048]) on
|
||||
// `GET /session/:id/events`. Old daemons silently lack both — SDK
|
||||
|
|
@ -311,6 +312,7 @@ export interface AdvertiseFeatureToggles {
|
|||
persistSettingAvailable?: boolean;
|
||||
voiceTranscriptionAvailable?: boolean;
|
||||
sessionShellCommandEnabled?: boolean;
|
||||
sessionArtifactsPersistenceAvailable?: boolean;
|
||||
rateLimit?: boolean;
|
||||
reloadAvailable?: boolean;
|
||||
/**
|
||||
|
|
@ -394,6 +396,10 @@ export const CONDITIONAL_SERVE_FEATURES: ReadonlyMap<
|
|||
'session_shell_command',
|
||||
(toggles) => toggles.sessionShellCommandEnabled === true,
|
||||
],
|
||||
[
|
||||
'session_artifacts_persistence',
|
||||
(toggles) => toggles.sessionArtifactsPersistenceAvailable === true,
|
||||
],
|
||||
['rate_limit', (toggles) => toggles.rateLimit === true],
|
||||
['workspace_reload', (toggles) => toggles.reloadAvailable === true],
|
||||
[
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ export type ServeFastPathSettings = Pick<
|
|||
Settings,
|
||||
'advanced' | 'context' | 'env' | 'security' | 'tools'
|
||||
> & {
|
||||
general?: Pick<NonNullable<Settings['general']>, 'chatRecording'>;
|
||||
policy?: ServeFastPathPolicyInput;
|
||||
};
|
||||
const V2_SETTINGS_VERSION = 2;
|
||||
|
|
@ -439,6 +440,19 @@ function pickFastPathSettings(
|
|||
out.env = pickStringRecord(env);
|
||||
}
|
||||
|
||||
const general = value['general'];
|
||||
if (isPlainObject(general)) {
|
||||
const chatRecording = general['chatRecording'];
|
||||
if (chatRecording !== undefined && typeof chatRecording !== 'boolean') {
|
||||
throw new Error(
|
||||
'Serve fast path settings general.chatRecording must be a boolean.',
|
||||
);
|
||||
}
|
||||
if (chatRecording !== undefined) {
|
||||
out.general = { chatRecording };
|
||||
}
|
||||
}
|
||||
|
||||
const advanced = value['advanced'];
|
||||
if (isPlainObject(advanced)) {
|
||||
const pickedAdvanced: NonNullable<ServeFastPathSettings['advanced']> = {};
|
||||
|
|
@ -621,6 +635,9 @@ function mergeFastPathSettings(
|
|||
if (source.env) {
|
||||
merged.env = { ...(merged.env ?? {}), ...source.env };
|
||||
}
|
||||
if (source.general) {
|
||||
merged.general = { ...(merged.general ?? {}), ...source.general };
|
||||
}
|
||||
if (source.advanced?.excludedEnvVars) {
|
||||
merged.advanced = {
|
||||
...(merged.advanced ?? {}),
|
||||
|
|
|
|||
|
|
@ -213,6 +213,9 @@ function pickServeFastPathComparable(
|
|||
if (settings.env) {
|
||||
out.env = settings.env;
|
||||
}
|
||||
if (settings.general?.chatRecording !== undefined) {
|
||||
out.general = { chatRecording: settings.general.chatRecording };
|
||||
}
|
||||
if (settings.advanced?.excludedEnvVars !== undefined) {
|
||||
out.advanced = {
|
||||
...(out.advanced ?? {}),
|
||||
|
|
@ -1362,6 +1365,7 @@ describe('serve fast path environment bootstrap', () => {
|
|||
FAST_PATH_OVERLAP: 'workspace',
|
||||
},
|
||||
advanced: { runtimeOutputDir: '.workspace-runtime' },
|
||||
general: { chatRecording: false },
|
||||
context: {
|
||||
fileName: 'WORKSPACE.md',
|
||||
fileFiltering: { customIgnoreFiles: ['.workspace-ignore'] },
|
||||
|
|
|
|||
|
|
@ -81,6 +81,19 @@ interface RegisterSessionRoutesDeps {
|
|||
languageCodes: string[];
|
||||
}
|
||||
|
||||
function requireSessionArtifactClientId(
|
||||
clientId: string | undefined,
|
||||
res: Response,
|
||||
): clientId is string {
|
||||
if (clientId !== undefined) return true;
|
||||
res.status(403).json({
|
||||
error: 'Session artifact access requires a session-bound client id',
|
||||
code: 'client_id_required',
|
||||
errorKind: 'client_id_required',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
function sendArtifactValidationError(res: Response, err: unknown): boolean {
|
||||
if (!(err instanceof SessionArtifactValidationError)) {
|
||||
return false;
|
||||
|
|
@ -1042,10 +1055,17 @@ export function registerSessionRoutes(
|
|||
'/session/:id/artifacts',
|
||||
withOwnerReadSession(
|
||||
'GET /session/:id/artifacts',
|
||||
async (_req, res, sessionId, runtime) => {
|
||||
async (req, res, sessionId, runtime) => {
|
||||
const clientId = parseClientIdHeader(req, res);
|
||||
if (clientId === null) return;
|
||||
res
|
||||
.status(200)
|
||||
.json(await runtime.bridge.getSessionArtifacts(sessionId));
|
||||
.json(
|
||||
await runtime.bridge.getSessionArtifacts(
|
||||
sessionId,
|
||||
clientId !== undefined ? { clientId } : undefined,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
@ -1058,6 +1078,7 @@ export function registerSessionRoutes(
|
|||
async (req, res, sessionId) => {
|
||||
const clientId = parseClientIdHeader(req, res);
|
||||
if (clientId === null) return;
|
||||
if (!requireSessionArtifactClientId(clientId, res)) return;
|
||||
try {
|
||||
const body = safeBody(req);
|
||||
const artifact: SessionArtifactInput = {
|
||||
|
|
@ -1075,12 +1096,14 @@ export function registerSessionRoutes(
|
|||
mimeType: body['mimeType'] as SessionArtifactInput['mimeType'],
|
||||
sizeBytes: body['sizeBytes'] as SessionArtifactInput['sizeBytes'],
|
||||
metadata: body['metadata'] as SessionArtifactInput['metadata'],
|
||||
retention: body['retention'] as SessionArtifactInput['retention'],
|
||||
clientRetained: body[
|
||||
'clientRetained'
|
||||
] as SessionArtifactInput['clientRetained'],
|
||||
};
|
||||
const result = await bridge.addSessionArtifact(
|
||||
sessionId,
|
||||
artifact,
|
||||
clientId !== undefined ? { clientId } : undefined,
|
||||
);
|
||||
const result = await bridge.addSessionArtifact(sessionId, artifact, {
|
||||
clientId,
|
||||
});
|
||||
res.status(200).json(result);
|
||||
} catch (err) {
|
||||
if (sendArtifactValidationError(res, err)) return;
|
||||
|
|
@ -1102,6 +1125,7 @@ export function registerSessionRoutes(
|
|||
const artifactId = req.params['artifactId'];
|
||||
const clientId = parseClientIdHeader(req, res);
|
||||
if (clientId === null) return;
|
||||
if (!requireSessionArtifactClientId(clientId, res)) return;
|
||||
if (!artifactId) {
|
||||
res.status(400).json({
|
||||
v: 1,
|
||||
|
|
@ -1117,10 +1141,11 @@ export function registerSessionRoutes(
|
|||
const result = await bridge.removeSessionArtifact(
|
||||
sessionId,
|
||||
artifactId,
|
||||
clientId !== undefined ? { clientId } : undefined,
|
||||
{ clientId },
|
||||
);
|
||||
res.status(200).json(result);
|
||||
} catch (err) {
|
||||
if (sendArtifactValidationError(res, err)) return;
|
||||
sendBridgeError(res, err, {
|
||||
route: 'DELETE /session/:id/artifacts/:artifactId',
|
||||
sessionId,
|
||||
|
|
|
|||
|
|
@ -461,6 +461,12 @@ export function extractContextFilename(value: unknown): string | undefined {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
function sessionArtifactsPersistenceAvailableFromSettings(
|
||||
settings: { general?: { chatRecording?: unknown } } | undefined,
|
||||
): boolean {
|
||||
return settings?.general?.chatRecording !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-workspace promise chain that serializes settings read-modify-write
|
||||
* cycles inside this process.
|
||||
|
|
@ -837,6 +843,7 @@ function sessionIdleTimeoutMs(value: number | undefined): number {
|
|||
function currentServeFeaturesForRunQwenServe(
|
||||
opts: ServeOptions,
|
||||
sessionShellCommandEnabled: boolean,
|
||||
sessionArtifactsPersistenceAvailable: boolean,
|
||||
): string[] {
|
||||
return getAdvertisedServeFeatures(undefined, {
|
||||
requireAuth: opts.requireAuth === true,
|
||||
|
|
@ -851,6 +858,7 @@ function currentServeFeaturesForRunQwenServe(
|
|||
: {}),
|
||||
persistSettingAvailable: true,
|
||||
sessionShellCommandEnabled,
|
||||
sessionArtifactsPersistenceAvailable,
|
||||
rateLimit: opts.rateLimit === true,
|
||||
reloadAvailable: true,
|
||||
// Advertise the same WS feature flags as the runtime path (serve-features.ts)
|
||||
|
|
@ -869,6 +877,7 @@ function createBootstrapCapabilities(input: {
|
|||
boundWorkspace: string;
|
||||
qwenCodeVersion?: string;
|
||||
sessionShellCommandEnabled: boolean;
|
||||
sessionArtifactsPersistenceAvailable: boolean;
|
||||
permissionPolicy: PermissionPolicy | undefined;
|
||||
}): CapabilitiesEnvelope {
|
||||
return {
|
||||
|
|
@ -881,6 +890,7 @@ function createBootstrapCapabilities(input: {
|
|||
features: currentServeFeaturesForRunQwenServe(
|
||||
input.opts,
|
||||
input.sessionShellCommandEnabled,
|
||||
input.sessionArtifactsPersistenceAvailable,
|
||||
),
|
||||
modelServices: [],
|
||||
workspaceCwd: input.boundWorkspace,
|
||||
|
|
@ -1056,6 +1066,7 @@ function createBootstrapServeApp(input: {
|
|||
daemonLog: DaemonLogger;
|
||||
qwenCodeVersion?: string;
|
||||
sessionShellCommandEnabled: boolean;
|
||||
sessionArtifactsPersistenceAvailable: boolean;
|
||||
permissionPolicy: PermissionPolicy | undefined;
|
||||
multiWorkspaceCapabilitiesRequireRuntime: boolean;
|
||||
getRuntimeError: () => string | undefined;
|
||||
|
|
@ -1072,6 +1083,7 @@ function createBootstrapServeApp(input: {
|
|||
daemonLog,
|
||||
qwenCodeVersion,
|
||||
sessionShellCommandEnabled,
|
||||
sessionArtifactsPersistenceAvailable,
|
||||
permissionPolicy,
|
||||
multiWorkspaceCapabilitiesRequireRuntime,
|
||||
getRuntimeError,
|
||||
|
|
@ -1137,6 +1149,7 @@ function createBootstrapServeApp(input: {
|
|||
boundWorkspace,
|
||||
qwenCodeVersion,
|
||||
sessionShellCommandEnabled,
|
||||
sessionArtifactsPersistenceAvailable,
|
||||
permissionPolicy,
|
||||
}),
|
||||
);
|
||||
|
|
@ -1218,6 +1231,7 @@ function createBootstrapServeApp(input: {
|
|||
features: currentServeFeaturesForRunQwenServe(
|
||||
opts,
|
||||
sessionShellCommandEnabled,
|
||||
sessionArtifactsPersistenceAvailable,
|
||||
),
|
||||
},
|
||||
runtime: {
|
||||
|
|
@ -1805,9 +1819,12 @@ export async function runQwenServe(
|
|||
let permissionPolicy: PermissionPolicy | undefined;
|
||||
let permissionConsensusQuorum: number | undefined;
|
||||
let bootSettings: ServeFastPathSettings | undefined;
|
||||
let sessionArtifactsPersistenceAvailable = true;
|
||||
try {
|
||||
bootSettings =
|
||||
deps.bootSettings ?? loadServeFastPathSettings(boundWorkspace);
|
||||
sessionArtifactsPersistenceAvailable =
|
||||
sessionArtifactsPersistenceAvailableFromSettings(bootSettings);
|
||||
contextFilenameForInit = extractContextFilename(
|
||||
bootSettings.context?.fileName,
|
||||
);
|
||||
|
|
@ -3184,6 +3201,10 @@ export async function runQwenServe(
|
|||
persistDisabledTools: persistDisabledToolsFn,
|
||||
persistSetting: persistSettingFn,
|
||||
persistSettings: persistSettingsFn,
|
||||
sessionArtifactsPersistenceAvailable:
|
||||
sessionArtifactsPersistenceAvailableFromSettings(
|
||||
runtimeBootSettings?.merged,
|
||||
),
|
||||
installAuthProvider: (req) =>
|
||||
withSettingsLock(
|
||||
boundWorkspace,
|
||||
|
|
@ -3261,6 +3282,7 @@ export async function runQwenServe(
|
|||
daemonLog,
|
||||
qwenCodeVersion: cliVersion,
|
||||
sessionShellCommandEnabled,
|
||||
sessionArtifactsPersistenceAvailable,
|
||||
permissionPolicy,
|
||||
multiWorkspaceCapabilitiesRequireRuntime: workspaceInputs.length > 1,
|
||||
getRuntimeError: () => runtimeStartupError,
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ import {
|
|||
PermissionPolicyNotImplementedError,
|
||||
PromptQueueFullError,
|
||||
RestoreInProgressError,
|
||||
SessionArtifactAuthorizationError,
|
||||
SessionArtifactValidationError,
|
||||
SessionShellClientRequiredError,
|
||||
SessionShellDisabledError,
|
||||
|
|
@ -326,7 +327,11 @@ const EXPECTED_REGISTERED_FEATURES = [
|
|||
// All four conditional tags filtered from the stage1 baseline so
|
||||
// they appear here in their registry-declaration order, not the
|
||||
// stage1 order.
|
||||
...EXPECTED_STAGE1_FEATURES.filter(
|
||||
...EXPECTED_STAGE1_FEATURES.flatMap((feature) =>
|
||||
feature === 'session_artifacts'
|
||||
? [feature, 'session_artifacts_persistence']
|
||||
: [feature],
|
||||
).filter(
|
||||
(f) =>
|
||||
f !== 'workspace_init' &&
|
||||
f !== 'workspace_github_setup' &&
|
||||
|
|
@ -665,7 +670,10 @@ interface FakeBridge extends AcpSessionBridge {
|
|||
}>;
|
||||
listCalls: string[];
|
||||
summaryCalls: string[];
|
||||
sessionArtifactsCalls: string[];
|
||||
sessionArtifactsCalls: Array<{
|
||||
sessionId: string;
|
||||
context?: BridgeClientRequestContext;
|
||||
}>;
|
||||
addSessionArtifactCalls: Array<{
|
||||
sessionId: string;
|
||||
artifact: Parameters<AcpSessionBridge['addSessionArtifact']>[1];
|
||||
|
|
@ -820,7 +828,7 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge {
|
|||
const sessionPermissionVotes: FakeBridge['sessionPermissionVotes'] = [];
|
||||
const listCalls: string[] = [];
|
||||
const summaryCalls: string[] = [];
|
||||
const sessionArtifactsCalls: string[] = [];
|
||||
const sessionArtifactsCalls: FakeBridge['sessionArtifactsCalls'] = [];
|
||||
const addSessionArtifactCalls: FakeBridge['addSessionArtifactCalls'] = [];
|
||||
const removeSessionArtifactCalls: FakeBridge['removeSessionArtifactCalls'] =
|
||||
[];
|
||||
|
|
@ -947,6 +955,7 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge {
|
|||
...(artifact.workspacePath
|
||||
? { workspacePath: artifact.workspacePath }
|
||||
: {}),
|
||||
retention: artifact.retention ?? 'ephemeral',
|
||||
clientRetained: true,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
|
|
@ -1478,9 +1487,12 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge {
|
|||
summaryCalls.push(sessionId);
|
||||
return summaryImpl(sessionId);
|
||||
},
|
||||
async getSessionArtifacts(sessionId) {
|
||||
sessionArtifactsCalls.push(sessionId);
|
||||
return getSessionArtifactsImpl(sessionId);
|
||||
async getSessionArtifacts(sessionId, context) {
|
||||
sessionArtifactsCalls.push({
|
||||
sessionId,
|
||||
...(context ? { context } : {}),
|
||||
});
|
||||
return getSessionArtifactsImpl(sessionId, context);
|
||||
},
|
||||
async addSessionArtifact(sessionId, artifact, context) {
|
||||
addSessionArtifactCalls.push({
|
||||
|
|
@ -2088,6 +2100,24 @@ describe('createServeApp', () => {
|
|||
);
|
||||
continue;
|
||||
}
|
||||
if (feature === 'session_artifacts_persistence') {
|
||||
expect(
|
||||
predicate({ sessionArtifactsPersistenceAvailable: true }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
predicate({ sessionArtifactsPersistenceAvailable: false }),
|
||||
).toBe(false);
|
||||
expect(predicate({})).toBe(false);
|
||||
expect(
|
||||
getAdvertisedServeFeatures(undefined, {
|
||||
sessionArtifactsPersistenceAvailable: true,
|
||||
}),
|
||||
).toContain(feature);
|
||||
expect(getAdvertisedServeFeatures(undefined, {})).not.toContain(
|
||||
feature,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (feature === 'workspace_reload') {
|
||||
expect(predicate({ reloadAvailable: true })).toBe(true);
|
||||
expect(predicate({ reloadAvailable: false })).toBe(false);
|
||||
|
|
@ -2521,6 +2551,7 @@ describe('createServeApp', () => {
|
|||
expect(res.body.features).toEqual(
|
||||
getAdvertisedServeFeatures(undefined, {
|
||||
mcpPoolActive: true,
|
||||
sessionArtifactsPersistenceAvailable: true,
|
||||
}),
|
||||
);
|
||||
expect(res.body.modelServices).toEqual([]);
|
||||
|
|
@ -2529,6 +2560,17 @@ describe('createServeApp', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('omits artifact persistence when the durable sink is unavailable', async () => {
|
||||
const app = createServeApp(baseOpts, undefined, {
|
||||
sessionArtifactsPersistenceAvailable: false,
|
||||
});
|
||||
const res = await request(app)
|
||||
.get('/capabilities')
|
||||
.set('Host', `127.0.0.1:${baseOpts.port}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.features).not.toContain('session_artifacts_persistence');
|
||||
});
|
||||
|
||||
it('advertises workspace voice transcription when a batch ASR model is configured', async () => {
|
||||
const previousQwenHome = process.env['QWEN_HOME'];
|
||||
const tempHome = await fsp.mkdtemp(
|
||||
|
|
@ -8329,7 +8371,9 @@ describe('createServeApp', () => {
|
|||
});
|
||||
const app = createServeApp(tokenOpts, undefined, { bridge });
|
||||
|
||||
const res = await auth(request(app).get('/session/session-A/artifacts'));
|
||||
const res = await auth(
|
||||
request(app).get('/session/session-A/artifacts'),
|
||||
).set('X-Qwen-Client-Id', 'client-1');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({
|
||||
|
|
@ -8337,7 +8381,26 @@ describe('createServeApp', () => {
|
|||
sessionId: 'session-A',
|
||||
artifacts: [{ id: 'artifact-1', title: 'Lineage' }],
|
||||
});
|
||||
expect(bridge.sessionArtifactsCalls).toEqual(['session-A']);
|
||||
expect(bridge.sessionArtifactsCalls).toEqual([
|
||||
{ sessionId: 'session-A', context: { clientId: 'client-1' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('GET /session/:id/artifacts allows missing client id', async () => {
|
||||
const bridge = fakeBridge();
|
||||
const app = createServeApp(tokenOpts, undefined, { bridge });
|
||||
|
||||
const res = await auth(request(app).get('/session/session-A/artifacts'));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({
|
||||
v: 1,
|
||||
sessionId: 'session-A',
|
||||
artifacts: [],
|
||||
});
|
||||
expect(bridge.sessionArtifactsCalls).toEqual([
|
||||
{ sessionId: 'session-A' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('GET /session/:id/artifacts returns 404 for an unknown session', async () => {
|
||||
|
|
@ -8348,11 +8411,16 @@ describe('createServeApp', () => {
|
|||
});
|
||||
const app = createServeApp(tokenOpts, undefined, { bridge });
|
||||
|
||||
const res = await auth(request(app).get('/session/ghost/artifacts'));
|
||||
const res = await auth(request(app).get('/session/ghost/artifacts')).set(
|
||||
'X-Qwen-Client-Id',
|
||||
'client-1',
|
||||
);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.sessionId).toBe('ghost');
|
||||
expect(bridge.sessionArtifactsCalls).toEqual(['ghost']);
|
||||
expect(bridge.sessionArtifactsCalls).toEqual([
|
||||
{ sessionId: 'ghost', context: { clientId: 'client-1' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('POST /session/:id/artifacts requires strict mutation auth', async () => {
|
||||
|
|
@ -8369,13 +8437,31 @@ describe('createServeApp', () => {
|
|||
expect(bridge.addSessionArtifactCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('POST /session/:id/artifacts requires a client id', async () => {
|
||||
const bridge = fakeBridge();
|
||||
const app = createServeApp(tokenOpts, undefined, { bridge });
|
||||
|
||||
const res = await auth(
|
||||
request(app).post('/session/session-A/artifacts'),
|
||||
).send({ title: 'Lineage', url: 'https://example.com/lineage' });
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('client_id_required');
|
||||
expect(bridge.addSessionArtifactCalls).toEqual([]);
|
||||
});
|
||||
|
||||
it('POST /session/:id/artifacts forwards body and client context', async () => {
|
||||
const bridge = fakeBridge();
|
||||
const app = createServeApp(tokenOpts, undefined, { bridge });
|
||||
|
||||
const res = await auth(request(app).post('/session/session-A/artifacts'))
|
||||
.set('X-Qwen-Client-Id', 'client-1')
|
||||
.send({ title: 'Lineage', url: 'https://example.com/lineage' });
|
||||
.send({
|
||||
title: 'Lineage',
|
||||
url: 'https://example.com/lineage',
|
||||
retention: 'ephemeral',
|
||||
clientRetained: false,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({
|
||||
|
|
@ -8394,6 +8480,8 @@ describe('createServeApp', () => {
|
|||
artifact: {
|
||||
title: 'Lineage',
|
||||
url: 'https://example.com/lineage',
|
||||
retention: 'ephemeral',
|
||||
clientRetained: false,
|
||||
},
|
||||
context: { clientId: 'client-1' },
|
||||
},
|
||||
|
|
@ -8411,9 +8499,9 @@ describe('createServeApp', () => {
|
|||
});
|
||||
const app = createServeApp(tokenOpts, undefined, { bridge });
|
||||
|
||||
const res = await auth(
|
||||
request(app).post('/session/session-A/artifacts'),
|
||||
).send({ title: 'Bad link', url: 'javascript:alert(1)' });
|
||||
const res = await auth(request(app).post('/session/session-A/artifacts'))
|
||||
.set('X-Qwen-Client-Id', 'client-1')
|
||||
.send({ title: 'Bad link', url: 'javascript:alert(1)' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toEqual({
|
||||
|
|
@ -8439,6 +8527,19 @@ describe('createServeApp', () => {
|
|||
expect(bridge.removeSessionArtifactCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('DELETE /session/:id/artifacts/:artifactId requires a client id', async () => {
|
||||
const bridge = fakeBridge();
|
||||
const app = createServeApp(tokenOpts, undefined, { bridge });
|
||||
|
||||
const res = await auth(
|
||||
request(app).delete('/session/session-A/artifacts/artifact-1'),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('client_id_required');
|
||||
expect(bridge.removeSessionArtifactCalls).toEqual([]);
|
||||
});
|
||||
|
||||
it('DELETE /session/:id/artifacts/:artifactId is idempotent for missing artifacts', async () => {
|
||||
const bridge = fakeBridge();
|
||||
const app = createServeApp(tokenOpts, undefined, { bridge });
|
||||
|
|
@ -8461,6 +8562,50 @@ describe('createServeApp', () => {
|
|||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('DELETE /session/:id/artifacts/:artifactId forwards client context', async () => {
|
||||
const bridge = fakeBridge();
|
||||
const app = createServeApp(tokenOpts, undefined, { bridge });
|
||||
|
||||
const res = await auth(
|
||||
request(app).delete('/session/session-A/artifacts/artifact-1'),
|
||||
).set('X-Qwen-Client-Id', 'client-1');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(bridge.removeSessionArtifactCalls).toEqual([
|
||||
{
|
||||
sessionId: 'session-A',
|
||||
artifactId: 'artifact-1',
|
||||
context: { clientId: 'client-1' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('DELETE /session/:id/artifacts/:artifactId maps artifact authorization errors', async () => {
|
||||
const bridge = fakeBridge({
|
||||
removeSessionArtifactImpl: async () => {
|
||||
throw new SessionArtifactAuthorizationError(
|
||||
'session-A',
|
||||
'artifact-1',
|
||||
'client-a',
|
||||
'client-b',
|
||||
);
|
||||
},
|
||||
});
|
||||
const app = createServeApp(tokenOpts, undefined, { bridge });
|
||||
|
||||
const res = await auth(
|
||||
request(app).delete('/session/session-A/artifacts/artifact-1'),
|
||||
).set('X-Qwen-Client-Id', 'client-b');
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body).toEqual({
|
||||
error: 'artifact artifact-1 is owned by a different client',
|
||||
code: 'session_artifact_forbidden',
|
||||
sessionId: 'session-A',
|
||||
artifactId: 'artifact-1',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /session/:id/model', () => {
|
||||
|
|
|
|||
|
|
@ -287,6 +287,7 @@ export interface ServeAppDeps {
|
|||
value: unknown;
|
||||
}>,
|
||||
) => Promise<void>;
|
||||
sessionArtifactsPersistenceAvailable?: boolean;
|
||||
/**
|
||||
* Reverse tool channel (issue #5626, Phase 2). Shared sender registry that
|
||||
* bridges the daemon WS (per-connection `ClientMcpRegistrar`) and the ACP
|
||||
|
|
@ -499,6 +500,8 @@ export function createServeApp(
|
|||
opts,
|
||||
boundWorkspace,
|
||||
persistSettingAvailable: deps.persistSetting !== undefined,
|
||||
sessionArtifactsPersistenceAvailable:
|
||||
deps.sessionArtifactsPersistenceAvailable !== false,
|
||||
// Registry injection supplies the primary workspace service through the
|
||||
// runtime, so it has the same reload surface as legacy deps.workspace.
|
||||
reloadAvailable:
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import {
|
|||
PermissionPolicyNotImplementedError,
|
||||
PromptQueueFullError,
|
||||
RestoreInProgressError,
|
||||
SessionArtifactAuthorizationError,
|
||||
SessionArchivedError,
|
||||
SessionArchivingError,
|
||||
SessionBusyError,
|
||||
|
|
@ -279,6 +280,15 @@ export function sendBridgeError(
|
|||
});
|
||||
return;
|
||||
}
|
||||
if (err instanceof SessionArtifactAuthorizationError) {
|
||||
res.status(403).json({
|
||||
error: err.message,
|
||||
code: 'session_artifact_forbidden',
|
||||
sessionId: err.sessionId,
|
||||
artifactId: err.artifactId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (err instanceof SessionShellDisabledError) {
|
||||
res.status(403).json({
|
||||
error: err.message,
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ interface CreateServeFeaturesDeps {
|
|||
opts: ServeOptions;
|
||||
boundWorkspace: string;
|
||||
persistSettingAvailable: boolean;
|
||||
sessionArtifactsPersistenceAvailable: boolean;
|
||||
reloadAvailable: boolean;
|
||||
sessionShellCommandEnabled: boolean;
|
||||
multiWorkspaceSessionsEnabled: boolean;
|
||||
|
|
@ -59,6 +60,7 @@ export function createServeFeatures(
|
|||
opts,
|
||||
boundWorkspace,
|
||||
persistSettingAvailable,
|
||||
sessionArtifactsPersistenceAvailable,
|
||||
reloadAvailable,
|
||||
sessionShellCommandEnabled,
|
||||
multiWorkspaceSessionsEnabled,
|
||||
|
|
@ -90,6 +92,7 @@ export function createServeFeatures(
|
|||
: {}),
|
||||
persistSettingAvailable,
|
||||
sessionShellCommandEnabled,
|
||||
sessionArtifactsPersistenceAvailable,
|
||||
rateLimit: opts.rateLimit === true,
|
||||
reloadAvailable,
|
||||
multiWorkspaceSessionsEnabled,
|
||||
|
|
|
|||
|
|
@ -228,6 +228,7 @@ export * from './services/visionBridge/vision-bridge-service.js';
|
|||
export * from './services/visionBridge/image-part-utils.js';
|
||||
export * from './services/visionBridge/image-capability.js';
|
||||
export * from './services/sessionRecap.js';
|
||||
export * from './services/session-artifact-persistence.js';
|
||||
export * from './services/sessionService.js';
|
||||
export * from './utils/conversation-chain.js';
|
||||
export * from './services/sessionTitle.js';
|
||||
|
|
|
|||
|
|
@ -1113,6 +1113,94 @@ describe('ChatRecordingService', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('does not rewind the shared parent pointer when strict artifact writes race with normal appends', async () => {
|
||||
let rejectStrict!: (error: Error) => void;
|
||||
const strictWrite = new Promise<void>((_resolve, reject) => {
|
||||
rejectStrict = reject;
|
||||
});
|
||||
vi.mocked(jsonl.writeLine)
|
||||
.mockImplementationOnce(() => strictWrite)
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
const strict = chatRecordingService.recordSessionArtifactEvent({
|
||||
v: 2,
|
||||
sessionId: 'test-session-id',
|
||||
sequence: 1,
|
||||
recordedAt: '2026-07-04T00:00:00.000Z',
|
||||
changes: [],
|
||||
});
|
||||
chatRecordingService.recordUserMessage([{ text: 'after strict write' }]);
|
||||
rejectStrict(new Error('disk full'));
|
||||
|
||||
await expect(strict).rejects.toThrow('disk full');
|
||||
await chatRecordingService.flush();
|
||||
|
||||
chatRecordingService.recordUserMessage([{ text: 'next message' }]);
|
||||
await chatRecordingService.flush();
|
||||
|
||||
const first = vi.mocked(jsonl.writeLine).mock.calls[0][1] as ChatRecord;
|
||||
const second = vi.mocked(jsonl.writeLine).mock.calls[1][1] as ChatRecord;
|
||||
const third = vi.mocked(jsonl.writeLine).mock.calls[2][1] as ChatRecord;
|
||||
expect(second.parentUuid).not.toBe(first.uuid);
|
||||
expect(third.parentUuid).toBe(second.uuid);
|
||||
});
|
||||
|
||||
it('appends strict artifact records after a previous strict write failed', async () => {
|
||||
vi.mocked(jsonl.writeLine)
|
||||
.mockRejectedValueOnce(new Error('corrupt journal'))
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
await expect(
|
||||
chatRecordingService.recordSessionArtifactEvent({
|
||||
v: 2,
|
||||
sessionId: 'test-session-id',
|
||||
sequence: 1,
|
||||
recordedAt: '2026-07-04T00:00:00.000Z',
|
||||
changes: [],
|
||||
}),
|
||||
).rejects.toThrow('corrupt journal');
|
||||
|
||||
await expect(
|
||||
chatRecordingService.recordSessionArtifactSnapshot({
|
||||
v: 2,
|
||||
sessionId: 'test-session-id',
|
||||
sequence: 2,
|
||||
recordedAt: '2026-07-04T00:00:01.000Z',
|
||||
artifacts: [],
|
||||
tombstonedIds: [],
|
||||
stickyEphemeralIds: [],
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
expect(jsonl.writeLine).toHaveBeenCalledTimes(2);
|
||||
const snapshotRecord = vi.mocked(jsonl.writeLine).mock
|
||||
.calls[1][1] as ChatRecord;
|
||||
expect(snapshotRecord.subtype).toBe('session_artifact_snapshot');
|
||||
});
|
||||
|
||||
it('keeps artifact journal records out of the active conversation chain', async () => {
|
||||
chatRecordingService.recordUserMessage([{ text: 'before artifact' }]);
|
||||
await chatRecordingService.flush();
|
||||
|
||||
await chatRecordingService.recordSessionArtifactEvent({
|
||||
v: 2,
|
||||
sessionId: 'test-session-id',
|
||||
sequence: 1,
|
||||
recordedAt: '2026-07-04T00:00:00.000Z',
|
||||
changes: [],
|
||||
});
|
||||
|
||||
chatRecordingService.recordUserMessage([{ text: 'after artifact' }]);
|
||||
await chatRecordingService.flush();
|
||||
|
||||
const before = vi.mocked(jsonl.writeLine).mock.calls[0][1] as ChatRecord;
|
||||
const artifact = vi.mocked(jsonl.writeLine).mock
|
||||
.calls[1][1] as ChatRecord;
|
||||
const after = vi.mocked(jsonl.writeLine).mock.calls[2][1] as ChatRecord;
|
||||
expect(artifact.parentUuid).toBe(before.uuid);
|
||||
expect(after.parentUuid).toBe(before.uuid);
|
||||
expect(after.parentUuid).not.toBe(artifact.uuid);
|
||||
});
|
||||
|
||||
// appendRecord can throw SYNCHRONOUSLY before returning a promise
|
||||
// (e.g. ensureConversationFile fails because the conversation
|
||||
// file can't be created). Without rollback in the outer catch,
|
||||
|
|
|
|||
|
|
@ -34,6 +34,10 @@ import type {
|
|||
SerializedFileHistorySnapshot,
|
||||
} from './fileHistoryService.js';
|
||||
import { serializeSnapshot } from './fileHistoryService.js';
|
||||
import type {
|
||||
SessionArtifactEventRecordPayload,
|
||||
SessionArtifactSnapshotRecordPayload,
|
||||
} from './session-artifact-persistence.js';
|
||||
|
||||
const debugLogger = createDebugLogger('CHAT_RECORDING');
|
||||
|
||||
|
|
@ -251,7 +255,9 @@ export interface ChatRecord {
|
|||
| 'rewind'
|
||||
| 'agent_bootstrap'
|
||||
| 'agent_launch_prompt'
|
||||
| 'file_history_snapshot';
|
||||
| 'file_history_snapshot'
|
||||
| 'session_artifact_event'
|
||||
| 'session_artifact_snapshot';
|
||||
/** Working directory at time of message */
|
||||
cwd: string;
|
||||
/** CLI version for compatibility tracking */
|
||||
|
|
@ -298,7 +304,9 @@ export interface ChatRecord {
|
|||
| NotificationRecordPayload
|
||||
| RewindRecordPayload
|
||||
| AgentBootstrapRecordPayload
|
||||
| FileHistorySnapshotRecordPayload;
|
||||
| FileHistorySnapshotRecordPayload
|
||||
| SessionArtifactEventRecordPayload
|
||||
| SessionArtifactSnapshotRecordPayload;
|
||||
|
||||
/** Background subagent that produced this record (e.g. "explore-7f3c"). */
|
||||
agentId?: string;
|
||||
|
|
@ -769,6 +777,39 @@ export class ChatRecordingService {
|
|||
this.updateTitleAnchorTracking(record);
|
||||
}
|
||||
|
||||
private async appendRecordStrict(
|
||||
record: ChatRecord,
|
||||
options?: { updateActiveTail?: boolean },
|
||||
): Promise<void> {
|
||||
const previousLastRecordUuid = this.lastRecordUuid;
|
||||
const updateActiveTail = options?.updateActiveTail !== false;
|
||||
let conversationFile: string;
|
||||
try {
|
||||
conversationFile = this.ensureConversationFile();
|
||||
} catch (error) {
|
||||
debugLogger.error('Error appending record:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (updateActiveTail) {
|
||||
this.lastRecordUuid = record.uuid;
|
||||
}
|
||||
this.writeChain = this.writeChain
|
||||
.catch(() => {})
|
||||
.then(() => jsonl.writeLine(conversationFile, record));
|
||||
|
||||
try {
|
||||
await this.writeChain;
|
||||
this.updateTitleAnchorTracking(record);
|
||||
} catch (error) {
|
||||
if (updateActiveTail && this.lastRecordUuid === record.uuid) {
|
||||
this.lastRecordUuid = previousLastRecordUuid;
|
||||
}
|
||||
debugLogger.error('Error appending record (async):', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maintain the "title is always in the tail window" invariant by
|
||||
* counting bytes appended since the last `custom_title` record and
|
||||
|
|
@ -1477,4 +1518,28 @@ export class ChatRecordingService {
|
|||
debugLogger.error('Error saving file history snapshot batch:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async recordSessionArtifactEvent(
|
||||
payload: SessionArtifactEventRecordPayload,
|
||||
): Promise<void> {
|
||||
const record: ChatRecord = {
|
||||
...this.createBaseRecord('system'),
|
||||
type: 'system',
|
||||
subtype: 'session_artifact_event',
|
||||
systemPayload: payload,
|
||||
};
|
||||
await this.appendRecordStrict(record, { updateActiveTail: false });
|
||||
}
|
||||
|
||||
async recordSessionArtifactSnapshot(
|
||||
payload: SessionArtifactSnapshotRecordPayload,
|
||||
): Promise<void> {
|
||||
const record: ChatRecord = {
|
||||
...this.createBaseRecord('system'),
|
||||
type: 'system',
|
||||
subtype: 'session_artifact_snapshot',
|
||||
systemPayload: payload,
|
||||
};
|
||||
await this.appendRecordStrict(record, { updateActiveTail: false });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1075
packages/core/src/services/session-artifact-persistence.test.ts
Normal file
1075
packages/core/src/services/session-artifact-persistence.test.ts
Normal file
File diff suppressed because it is too large
Load diff
1044
packages/core/src/services/session-artifact-persistence.ts
Normal file
1044
packages/core/src/services/session-artifact-persistence.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -25,6 +25,10 @@ import {
|
|||
getResumeTokenCounts,
|
||||
type ConversationRecord,
|
||||
} from './sessionService.js';
|
||||
import {
|
||||
SESSION_ARTIFACT_PERSISTENCE_VERSION,
|
||||
stableSessionArtifactId,
|
||||
} from './session-artifact-persistence.js';
|
||||
import { SessionOrganizationService } from './session-organization-service.js';
|
||||
import { CompressionStatus } from '../core/turn.js';
|
||||
import type { ChatRecord } from './chatRecordingService.js';
|
||||
|
|
@ -468,6 +472,354 @@ describe('SessionService', () => {
|
|||
expect(loaded?.lastCompletedUuid).toBe('b2');
|
||||
});
|
||||
|
||||
it('loads artifact side records attached to the active branch', async () => {
|
||||
const now = Date.now();
|
||||
statSyncSpy.mockReturnValue({
|
||||
mtimeMs: now,
|
||||
isFile: () => true,
|
||||
} as fs.Stats);
|
||||
const artifactId = stableSessionArtifactId(
|
||||
sessionIdB,
|
||||
'url:https://example.com/report',
|
||||
);
|
||||
const artifactRecord: ChatRecord = {
|
||||
...recordB1,
|
||||
uuid: 'artifact-1',
|
||||
parentUuid: 'b1',
|
||||
type: 'system',
|
||||
subtype: 'session_artifact_event',
|
||||
message: undefined,
|
||||
systemPayload: {
|
||||
v: SESSION_ARTIFACT_PERSISTENCE_VERSION,
|
||||
sessionId: sessionIdB,
|
||||
sequence: 1,
|
||||
recordedAt: '2026-07-06T00:00:00.000Z',
|
||||
changes: [
|
||||
{
|
||||
action: 'created',
|
||||
artifactId,
|
||||
artifact: {
|
||||
id: artifactId,
|
||||
kind: 'link',
|
||||
storage: 'external_url',
|
||||
source: 'client',
|
||||
status: 'available',
|
||||
title: 'Report',
|
||||
url: 'https://example.com/report',
|
||||
retention: 'restorable',
|
||||
clientRetained: true,
|
||||
createdAt: '2026-07-06T00:00:00.000Z',
|
||||
updatedAt: '2026-07-06T00:00:00.000Z',
|
||||
persistedAt: '2026-07-06T00:00:00.000Z',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
vi.mocked(jsonl.read).mockResolvedValue([
|
||||
recordB1,
|
||||
artifactRecord,
|
||||
recordB2,
|
||||
]);
|
||||
|
||||
const loaded = await sessionService.loadSession(sessionIdB);
|
||||
|
||||
expect(
|
||||
loaded?.conversation.messages.map((record) => record.uuid),
|
||||
).toEqual(['b1', 'b2']);
|
||||
expect(loaded?.artifactSnapshot?.artifacts).toEqual([
|
||||
expect.objectContaining({
|
||||
id: artifactId,
|
||||
title: 'Report',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('loads artifact side records after a tail-neutral title reanchor', async () => {
|
||||
const now = Date.now();
|
||||
statSyncSpy.mockReturnValue({
|
||||
mtimeMs: now,
|
||||
isFile: () => true,
|
||||
} as fs.Stats);
|
||||
const artifactId = stableSessionArtifactId(
|
||||
sessionIdB,
|
||||
'url:https://example.com/reanchored-report',
|
||||
);
|
||||
const titleRecord: ChatRecord = {
|
||||
...recordB1,
|
||||
uuid: 'title-reanchor',
|
||||
parentUuid: 'b1',
|
||||
type: 'system',
|
||||
subtype: 'custom_title',
|
||||
message: undefined,
|
||||
systemPayload: {
|
||||
customTitle: 'Reanchored title',
|
||||
titleSource: 'auto',
|
||||
},
|
||||
};
|
||||
const artifactRecord: ChatRecord = {
|
||||
...recordB1,
|
||||
uuid: 'artifact-after-title',
|
||||
parentUuid: 'b1',
|
||||
type: 'system',
|
||||
subtype: 'session_artifact_event',
|
||||
message: undefined,
|
||||
systemPayload: {
|
||||
v: SESSION_ARTIFACT_PERSISTENCE_VERSION,
|
||||
sessionId: sessionIdB,
|
||||
sequence: 1,
|
||||
recordedAt: '2026-07-06T00:00:00.000Z',
|
||||
changes: [
|
||||
{
|
||||
action: 'created',
|
||||
artifactId,
|
||||
artifact: {
|
||||
id: artifactId,
|
||||
kind: 'link',
|
||||
storage: 'external_url',
|
||||
source: 'client',
|
||||
status: 'available',
|
||||
title: 'Reanchored report',
|
||||
url: 'https://example.com/reanchored-report',
|
||||
retention: 'restorable',
|
||||
clientRetained: true,
|
||||
createdAt: '2026-07-06T00:00:00.000Z',
|
||||
updatedAt: '2026-07-06T00:00:00.000Z',
|
||||
persistedAt: '2026-07-06T00:00:00.000Z',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
vi.mocked(jsonl.read).mockResolvedValue([
|
||||
recordB1,
|
||||
titleRecord,
|
||||
artifactRecord,
|
||||
recordB2,
|
||||
]);
|
||||
|
||||
const loaded = await sessionService.loadSession(sessionIdB);
|
||||
|
||||
expect(
|
||||
loaded?.conversation.messages.map((record) => record.uuid),
|
||||
).toEqual(['b1', 'b2']);
|
||||
expect(loaded?.artifactSnapshot?.artifacts).toEqual([
|
||||
expect.objectContaining({
|
||||
id: artifactId,
|
||||
title: 'Reanchored report',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('loads chained artifact side records attached to the active branch', async () => {
|
||||
const now = Date.now();
|
||||
statSyncSpy.mockReturnValue({
|
||||
mtimeMs: now,
|
||||
isFile: () => true,
|
||||
} as fs.Stats);
|
||||
const artifactId = stableSessionArtifactId(
|
||||
sessionIdB,
|
||||
'url:https://example.com/chained-report',
|
||||
);
|
||||
const createRecord: ChatRecord = {
|
||||
...recordB1,
|
||||
uuid: 'artifact-create',
|
||||
parentUuid: 'b1',
|
||||
type: 'system',
|
||||
subtype: 'session_artifact_event',
|
||||
message: undefined,
|
||||
systemPayload: {
|
||||
v: SESSION_ARTIFACT_PERSISTENCE_VERSION,
|
||||
sessionId: sessionIdB,
|
||||
sequence: 1,
|
||||
recordedAt: '2026-07-06T00:00:00.000Z',
|
||||
changes: [
|
||||
{
|
||||
action: 'created',
|
||||
artifactId,
|
||||
artifact: {
|
||||
id: artifactId,
|
||||
kind: 'link',
|
||||
storage: 'external_url',
|
||||
source: 'client',
|
||||
status: 'available',
|
||||
title: 'Chained report',
|
||||
url: 'https://example.com/chained-report',
|
||||
retention: 'restorable',
|
||||
clientRetained: true,
|
||||
createdAt: '2026-07-06T00:00:00.000Z',
|
||||
updatedAt: '2026-07-06T00:00:00.000Z',
|
||||
persistedAt: '2026-07-06T00:00:00.000Z',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const removeRecord: ChatRecord = {
|
||||
...recordB1,
|
||||
uuid: 'artifact-remove',
|
||||
parentUuid: 'artifact-create',
|
||||
type: 'system',
|
||||
subtype: 'session_artifact_event',
|
||||
message: undefined,
|
||||
systemPayload: {
|
||||
v: SESSION_ARTIFACT_PERSISTENCE_VERSION,
|
||||
sessionId: sessionIdB,
|
||||
sequence: 2,
|
||||
recordedAt: '2026-07-06T00:00:01.000Z',
|
||||
changes: [
|
||||
{
|
||||
action: 'removed',
|
||||
artifactId,
|
||||
reason: 'explicit',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
vi.mocked(jsonl.read).mockResolvedValue([
|
||||
recordB1,
|
||||
createRecord,
|
||||
removeRecord,
|
||||
recordB2,
|
||||
]);
|
||||
|
||||
const loaded = await sessionService.loadSession(sessionIdB);
|
||||
|
||||
expect(
|
||||
loaded?.conversation.messages.map((record) => record.uuid),
|
||||
).toEqual(['b1', 'b2']);
|
||||
expect(loaded?.artifactSnapshot?.artifacts).toEqual([]);
|
||||
expect(loaded?.artifactSnapshot?.tombstonedIds).toContain(artifactId);
|
||||
});
|
||||
|
||||
it('does not load artifact side records from abandoned branches', async () => {
|
||||
const now = Date.now();
|
||||
statSyncSpy.mockReturnValue({
|
||||
mtimeMs: now,
|
||||
isFile: () => true,
|
||||
} as fs.Stats);
|
||||
const artifactId = stableSessionArtifactId(
|
||||
sessionIdB,
|
||||
'url:https://example.com/abandoned-report',
|
||||
);
|
||||
const artifactRecord: ChatRecord = {
|
||||
...recordB1,
|
||||
uuid: 'artifact-abandoned',
|
||||
parentUuid: 'b1',
|
||||
type: 'system',
|
||||
subtype: 'session_artifact_event',
|
||||
message: undefined,
|
||||
systemPayload: {
|
||||
v: SESSION_ARTIFACT_PERSISTENCE_VERSION,
|
||||
sessionId: sessionIdB,
|
||||
sequence: 1,
|
||||
recordedAt: '2026-07-06T00:00:00.000Z',
|
||||
changes: [
|
||||
{
|
||||
action: 'created',
|
||||
artifactId,
|
||||
artifact: {
|
||||
id: artifactId,
|
||||
kind: 'link',
|
||||
storage: 'external_url',
|
||||
source: 'client',
|
||||
status: 'available',
|
||||
title: 'Abandoned report',
|
||||
url: 'https://example.com/abandoned-report',
|
||||
retention: 'restorable',
|
||||
clientRetained: true,
|
||||
createdAt: '2026-07-06T00:00:00.000Z',
|
||||
updatedAt: '2026-07-06T00:00:00.000Z',
|
||||
persistedAt: '2026-07-06T00:00:00.000Z',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const abandonedChild: ChatRecord = {
|
||||
...recordB2,
|
||||
uuid: 'abandoned-child',
|
||||
parentUuid: 'b1',
|
||||
};
|
||||
vi.mocked(jsonl.read).mockResolvedValue([
|
||||
recordB1,
|
||||
artifactRecord,
|
||||
abandonedChild,
|
||||
recordB2,
|
||||
]);
|
||||
|
||||
const loaded = await sessionService.loadSession(sessionIdB);
|
||||
|
||||
expect(
|
||||
loaded?.conversation.messages.map((record) => record.uuid),
|
||||
).toEqual(['b1', 'b2']);
|
||||
expect(loaded?.artifactSnapshot).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not treat trailing artifact side records as the conversation leaf', async () => {
|
||||
const now = Date.now();
|
||||
statSyncSpy.mockReturnValue({
|
||||
mtimeMs: now,
|
||||
isFile: () => true,
|
||||
} as fs.Stats);
|
||||
const artifactId = stableSessionArtifactId(
|
||||
sessionIdB,
|
||||
'url:https://example.com/trailing-report',
|
||||
);
|
||||
const artifactRecord: ChatRecord = {
|
||||
...recordB2,
|
||||
uuid: 'artifact-tail',
|
||||
parentUuid: 'b2',
|
||||
type: 'system',
|
||||
subtype: 'session_artifact_event',
|
||||
message: undefined,
|
||||
systemPayload: {
|
||||
v: SESSION_ARTIFACT_PERSISTENCE_VERSION,
|
||||
sessionId: sessionIdB,
|
||||
sequence: 1,
|
||||
recordedAt: '2026-07-06T00:00:00.000Z',
|
||||
changes: [
|
||||
{
|
||||
action: 'created',
|
||||
artifactId,
|
||||
artifact: {
|
||||
id: artifactId,
|
||||
kind: 'link',
|
||||
storage: 'external_url',
|
||||
source: 'client',
|
||||
status: 'available',
|
||||
title: 'Trailing report',
|
||||
url: 'https://example.com/trailing-report',
|
||||
retention: 'restorable',
|
||||
clientRetained: true,
|
||||
createdAt: '2026-07-06T00:00:00.000Z',
|
||||
updatedAt: '2026-07-06T00:00:00.000Z',
|
||||
persistedAt: '2026-07-06T00:00:00.000Z',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
vi.mocked(jsonl.read).mockResolvedValue([
|
||||
recordB1,
|
||||
recordB2,
|
||||
artifactRecord,
|
||||
]);
|
||||
|
||||
const loaded = await sessionService.loadSession(sessionIdB);
|
||||
|
||||
expect(
|
||||
loaded?.conversation.messages.map((record) => record.uuid),
|
||||
).toEqual(['b1', 'b2']);
|
||||
expect(loaded?.lastCompletedUuid).toBe('b2');
|
||||
expect(loaded?.artifactSnapshot?.artifacts).toEqual([
|
||||
expect.objectContaining({
|
||||
id: artifactId,
|
||||
title: 'Trailing report',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the latest file history snapshot for a prompt id', async () => {
|
||||
const now = Date.now();
|
||||
statSyncSpy.mockReturnValue({
|
||||
|
|
@ -2511,6 +2863,324 @@ describe('SessionService', () => {
|
|||
expect(srcLines.every((r) => !r.forkedFrom)).toBe(true);
|
||||
});
|
||||
|
||||
it('copies artifact side records from the active branch', async () => {
|
||||
const oldId = '71717171-7171-7171-7171-717171717171';
|
||||
const newId = '81818181-8181-8181-8181-818181818181';
|
||||
const { file, lines } = seedSession(oldId);
|
||||
const oldArtifactId = stableSessionArtifactId(
|
||||
oldId,
|
||||
'url:https://example.com/forked',
|
||||
);
|
||||
const artifactRecord = {
|
||||
uuid: 'artifact-1',
|
||||
parentUuid: 'u1',
|
||||
sessionId: oldId,
|
||||
type: 'system',
|
||||
subtype: 'session_artifact_event',
|
||||
timestamp: '2026-04-22T00:00:00.500Z',
|
||||
cwd,
|
||||
version: 'test',
|
||||
systemPayload: {
|
||||
v: SESSION_ARTIFACT_PERSISTENCE_VERSION,
|
||||
sessionId: oldId,
|
||||
sequence: 1,
|
||||
recordedAt: '2026-04-22T00:00:00.500Z',
|
||||
changes: [
|
||||
{
|
||||
action: 'created',
|
||||
artifactId: oldArtifactId,
|
||||
artifact: {
|
||||
id: oldArtifactId,
|
||||
kind: 'link',
|
||||
storage: 'external_url',
|
||||
source: 'client',
|
||||
status: 'available',
|
||||
title: 'Forked artifact',
|
||||
url: 'https://example.com/forked',
|
||||
retention: 'restorable',
|
||||
clientRetained: true,
|
||||
createdAt: '2026-04-22T00:00:00.500Z',
|
||||
updatedAt: '2026-04-22T00:00:00.500Z',
|
||||
persistedAt: '2026-04-22T00:00:00.500Z',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
[lines[0], artifactRecord, lines[1]]
|
||||
.map((line) => JSON.stringify(line))
|
||||
.join('\n') + '\n',
|
||||
);
|
||||
|
||||
const result = await service.forkSession(oldId, newId);
|
||||
const loaded = await service.loadSession(newId);
|
||||
const forkedLines = fs
|
||||
.readFileSync(result.filePath, 'utf8')
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((line) => JSON.parse(line));
|
||||
|
||||
expect(result.copiedCount).toBe(3);
|
||||
expect(
|
||||
loaded?.conversation.messages.map((record) => record.uuid),
|
||||
).toEqual(['u1', 'u2']);
|
||||
expect(
|
||||
forkedLines.find((record) => record.uuid === 'artifact-1'),
|
||||
).toMatchObject({
|
||||
parentUuid: 'u1',
|
||||
});
|
||||
expect(forkedLines.find((record) => record.uuid === 'u2')).toMatchObject({
|
||||
parentUuid: 'u1',
|
||||
});
|
||||
expect(loaded?.artifactSnapshot?.artifacts).toEqual([
|
||||
expect.objectContaining({
|
||||
id: stableSessionArtifactId(newId, 'url:https://example.com/forked'),
|
||||
title: 'Forked artifact',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not copy artifact side records from abandoned branches', async () => {
|
||||
const oldId = '74747474-7474-7474-7474-747474747474';
|
||||
const newId = '84848484-8484-8484-8484-848484848484';
|
||||
const { file, lines } = seedSession(oldId);
|
||||
const oldArtifactId = stableSessionArtifactId(
|
||||
oldId,
|
||||
'url:https://example.com/abandoned-forked',
|
||||
);
|
||||
const artifactRecord = {
|
||||
uuid: 'artifact-abandoned',
|
||||
parentUuid: 'u1',
|
||||
sessionId: oldId,
|
||||
type: 'system',
|
||||
subtype: 'session_artifact_event',
|
||||
timestamp: '2026-04-22T00:00:00.500Z',
|
||||
cwd,
|
||||
version: 'test',
|
||||
systemPayload: {
|
||||
v: SESSION_ARTIFACT_PERSISTENCE_VERSION,
|
||||
sessionId: oldId,
|
||||
sequence: 1,
|
||||
recordedAt: '2026-04-22T00:00:00.500Z',
|
||||
changes: [
|
||||
{
|
||||
action: 'created',
|
||||
artifactId: oldArtifactId,
|
||||
artifact: {
|
||||
id: oldArtifactId,
|
||||
kind: 'link',
|
||||
storage: 'external_url',
|
||||
source: 'client',
|
||||
status: 'available',
|
||||
title: 'Abandoned forked artifact',
|
||||
url: 'https://example.com/abandoned-forked',
|
||||
retention: 'restorable',
|
||||
clientRetained: true,
|
||||
createdAt: '2026-04-22T00:00:00.500Z',
|
||||
updatedAt: '2026-04-22T00:00:00.500Z',
|
||||
persistedAt: '2026-04-22T00:00:00.500Z',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const abandonedChild = {
|
||||
...lines[1],
|
||||
uuid: 'abandoned-child',
|
||||
parentUuid: 'u1',
|
||||
};
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
[lines[0], artifactRecord, abandonedChild, lines[1]]
|
||||
.map((line) => JSON.stringify(line))
|
||||
.join('\n') + '\n',
|
||||
);
|
||||
|
||||
const result = await service.forkSession(oldId, newId);
|
||||
const loaded = await service.loadSession(newId);
|
||||
const forkedLines = fs
|
||||
.readFileSync(result.filePath, 'utf8')
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((line) => JSON.parse(line));
|
||||
|
||||
expect(result.copiedCount).toBe(2);
|
||||
expect(
|
||||
forkedLines.some((record) => record.uuid === 'artifact-abandoned'),
|
||||
).toBe(false);
|
||||
expect(
|
||||
loaded?.conversation.messages.map((record) => record.uuid),
|
||||
).toEqual(['u1', 'u2']);
|
||||
expect(loaded?.artifactSnapshot).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not treat trailing artifact side records as the fork leaf', async () => {
|
||||
const oldId = '73737373-7373-7373-7373-737373737373';
|
||||
const newId = '83838383-8383-8383-8383-838383838383';
|
||||
const { file, lines } = seedSession(oldId);
|
||||
const url = 'https://example.com/trailing-forked';
|
||||
const oldArtifactId = stableSessionArtifactId(oldId, `url:${url}`);
|
||||
const artifactRecord = {
|
||||
uuid: 'artifact-tail',
|
||||
parentUuid: 'u2',
|
||||
sessionId: oldId,
|
||||
type: 'system',
|
||||
subtype: 'session_artifact_event',
|
||||
timestamp: '2026-04-22T00:00:01.500Z',
|
||||
cwd,
|
||||
version: 'test',
|
||||
systemPayload: {
|
||||
v: SESSION_ARTIFACT_PERSISTENCE_VERSION,
|
||||
sessionId: oldId,
|
||||
sequence: 1,
|
||||
recordedAt: '2026-04-22T00:00:01.500Z',
|
||||
changes: [
|
||||
{
|
||||
action: 'created',
|
||||
artifactId: oldArtifactId,
|
||||
artifact: {
|
||||
id: oldArtifactId,
|
||||
kind: 'link',
|
||||
storage: 'external_url',
|
||||
source: 'client',
|
||||
status: 'available',
|
||||
title: 'Trailing forked artifact',
|
||||
url,
|
||||
retention: 'restorable',
|
||||
clientRetained: true,
|
||||
createdAt: '2026-04-22T00:00:01.500Z',
|
||||
updatedAt: '2026-04-22T00:00:01.500Z',
|
||||
persistedAt: '2026-04-22T00:00:01.500Z',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
[...lines, artifactRecord]
|
||||
.map((line) => JSON.stringify(line))
|
||||
.join('\n') + '\n',
|
||||
);
|
||||
|
||||
const result = await service.forkSession(oldId, newId);
|
||||
const loaded = await service.loadSession(newId);
|
||||
|
||||
expect(result.copiedCount).toBe(3);
|
||||
expect(
|
||||
loaded?.conversation.messages.map((record) => record.uuid),
|
||||
).toEqual(['u1', 'u2']);
|
||||
expect(loaded?.lastCompletedUuid).toBe('u2');
|
||||
expect(loaded?.artifactSnapshot?.artifacts).toEqual([
|
||||
expect.objectContaining({
|
||||
id: stableSessionArtifactId(newId, `url:${url}`),
|
||||
title: 'Trailing forked artifact',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not resurrect artifacts removed by later side records when forking', async () => {
|
||||
const oldId = '72727272-7272-7272-7272-727272727272';
|
||||
const newId = '82828282-8282-8282-8282-828282828282';
|
||||
const { file, lines } = seedSession(oldId);
|
||||
const url = 'https://example.com/forked-then-removed';
|
||||
const oldArtifactId = stableSessionArtifactId(oldId, `url:${url}`);
|
||||
const forkedArtifactId = stableSessionArtifactId(newId, `url:${url}`);
|
||||
const createRecord = {
|
||||
uuid: 'artifact-create',
|
||||
parentUuid: 'u1',
|
||||
sessionId: oldId,
|
||||
type: 'system',
|
||||
subtype: 'session_artifact_event',
|
||||
timestamp: '2026-04-22T00:00:00.500Z',
|
||||
cwd,
|
||||
version: 'test',
|
||||
systemPayload: {
|
||||
v: SESSION_ARTIFACT_PERSISTENCE_VERSION,
|
||||
sessionId: oldId,
|
||||
sequence: 1,
|
||||
recordedAt: '2026-04-22T00:00:00.500Z',
|
||||
changes: [
|
||||
{
|
||||
action: 'created',
|
||||
artifactId: oldArtifactId,
|
||||
artifact: {
|
||||
id: oldArtifactId,
|
||||
kind: 'link',
|
||||
storage: 'external_url',
|
||||
source: 'client',
|
||||
status: 'available',
|
||||
title: 'Forked artifact',
|
||||
url,
|
||||
retention: 'restorable',
|
||||
clientRetained: true,
|
||||
createdAt: '2026-04-22T00:00:00.500Z',
|
||||
updatedAt: '2026-04-22T00:00:00.500Z',
|
||||
persistedAt: '2026-04-22T00:00:00.500Z',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const removeRecord = {
|
||||
uuid: 'artifact-remove',
|
||||
parentUuid: 'u1',
|
||||
sessionId: oldId,
|
||||
type: 'system',
|
||||
subtype: 'session_artifact_event',
|
||||
timestamp: '2026-04-22T00:00:00.750Z',
|
||||
cwd,
|
||||
version: 'test',
|
||||
systemPayload: {
|
||||
v: SESSION_ARTIFACT_PERSISTENCE_VERSION,
|
||||
sessionId: oldId,
|
||||
sequence: 2,
|
||||
recordedAt: '2026-04-22T00:00:00.750Z',
|
||||
changes: [
|
||||
{
|
||||
action: 'removed',
|
||||
artifactId: oldArtifactId,
|
||||
reason: 'explicit',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
[lines[0], createRecord, removeRecord, lines[1]]
|
||||
.map((line) => JSON.stringify(line))
|
||||
.join('\n') + '\n',
|
||||
);
|
||||
|
||||
const result = await service.forkSession(oldId, newId);
|
||||
const loaded = await service.loadSession(newId);
|
||||
const forkedLines = fs
|
||||
.readFileSync(result.filePath, 'utf8')
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((line) => JSON.parse(line));
|
||||
const forkedRemovePayload = forkedLines.find(
|
||||
(record) => record.uuid === 'artifact-remove',
|
||||
)?.systemPayload;
|
||||
|
||||
expect(result.copiedCount).toBe(4);
|
||||
expect(loaded?.artifactSnapshot?.artifacts).toEqual([]);
|
||||
expect(loaded?.artifactSnapshot?.tombstonedIds).toContain(
|
||||
forkedArtifactId,
|
||||
);
|
||||
expect(forkedRemovePayload).toMatchObject({
|
||||
changes: [
|
||||
{
|
||||
action: 'removed',
|
||||
artifactId: forkedArtifactId,
|
||||
reason: 'explicit',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves file history snapshots on the forked session', async () => {
|
||||
const oldId = '31313131-3131-3131-3131-313131313131';
|
||||
const newId = '41414141-4141-4141-4141-414141414141';
|
||||
|
|
|
|||
|
|
@ -39,6 +39,12 @@ import {
|
|||
readLastJsonStringFieldsSync,
|
||||
} from '../utils/sessionStorageUtils.js';
|
||||
import { getUsageOutputTokenCountForPromptEstimate } from './tokenEstimation.js';
|
||||
import {
|
||||
isSessionArtifactRecord,
|
||||
rebuildSessionArtifactSnapshot,
|
||||
remapSessionArtifactPayloadForFork,
|
||||
type RebuiltSessionArtifactSnapshot,
|
||||
} from './session-artifact-persistence.js';
|
||||
import { SessionOrganizationService } from './session-organization-service.js';
|
||||
|
||||
const debugLogger = createDebugLogger('SESSION');
|
||||
|
|
@ -183,6 +189,8 @@ export interface ResumedSessionData {
|
|||
lastCompletedUuid: string | null;
|
||||
/** Deserialized file history snapshots for resume (enables /rewind across sessions) */
|
||||
fileHistorySnapshots?: FileHistorySnapshot[];
|
||||
/** Persisted session artifact metadata reconstructed from JSONL records. */
|
||||
artifactSnapshot?: RebuiltSessionArtifactSnapshot;
|
||||
/**
|
||||
* Breaks in the persisted parentUuid chain that were detected during
|
||||
* reconstruction (an earlier segment of history was physically lost and could
|
||||
|
|
@ -976,6 +984,16 @@ export class SessionService {
|
|||
return { messages, gaps };
|
||||
}
|
||||
|
||||
private lastConversationRecordUuid(records: ChatRecord[]): string | null {
|
||||
for (let index = records.length - 1; index >= 0; index--) {
|
||||
const record = records[index];
|
||||
if (record && !isSessionArtifactRecord(record)) {
|
||||
return record.uuid;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a session by its session ID.
|
||||
* Reconstructs the full conversation from tree-structured records.
|
||||
|
|
@ -1007,7 +1025,13 @@ export class SessionService {
|
|||
}
|
||||
|
||||
// Reconstruct linear history
|
||||
const leafUuid = this.lastConversationRecordUuid(records);
|
||||
if (!leafUuid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { messages, gaps } = this.reconstructHistory(records, {
|
||||
leafUuid,
|
||||
detectGaps: true,
|
||||
});
|
||||
if (messages.length === 0) {
|
||||
|
|
@ -1073,6 +1097,14 @@ export class SessionService {
|
|||
fileHistorySnapshots.length > MAX_SNAPSHOTS
|
||||
? fileHistorySnapshots.slice(-MAX_SNAPSHOTS)
|
||||
: fileHistorySnapshots;
|
||||
const activeBranchRecords = includeActiveSideArtifactRecords(
|
||||
records,
|
||||
messages,
|
||||
);
|
||||
const artifactSnapshot = rebuildSessionArtifactSnapshot(
|
||||
activeBranchRecords,
|
||||
firstRecord.sessionId,
|
||||
);
|
||||
|
||||
return {
|
||||
conversation,
|
||||
|
|
@ -1080,6 +1112,7 @@ export class SessionService {
|
|||
lastCompletedUuid: lastMessage.uuid,
|
||||
fileHistorySnapshots:
|
||||
cappedSnapshots.length > 0 ? cappedSnapshots : undefined,
|
||||
...(artifactSnapshot ? { artifactSnapshot } : {}),
|
||||
historyGaps: gaps.length > 0 ? gaps : undefined,
|
||||
};
|
||||
}
|
||||
|
|
@ -1433,8 +1466,17 @@ export class SessionService {
|
|||
|
||||
// Copy only the active branch. Rewind leaves old records in the JSONL as
|
||||
// abandoned parentUuid branches; copying raw records would resurrect them.
|
||||
// detectGaps stays off here so a fork copies exactly the active branch.
|
||||
const { messages: sourceRecords } = this.reconstructHistory(records);
|
||||
const leafUuid = this.lastConversationRecordUuid(records);
|
||||
if (!leafUuid) {
|
||||
throw new Error(`Source session not found or empty: ${sourceSessionId}`);
|
||||
}
|
||||
const { messages: activeMessages } = this.reconstructHistory(records, {
|
||||
leafUuid,
|
||||
});
|
||||
const sourceRecords = includeActiveSideArtifactRecords(
|
||||
records,
|
||||
activeMessages,
|
||||
);
|
||||
if (sourceRecords.length === 0) {
|
||||
throw new Error(`Source session not found or empty: ${sourceSessionId}`);
|
||||
}
|
||||
|
|
@ -1443,27 +1485,29 @@ export class SessionService {
|
|||
// clean linear descendant. `forkedFrom` captures the origin of each
|
||||
// message.
|
||||
let prevUuid: string | null = null;
|
||||
const remappedArtifactIds = new Map<string, string>();
|
||||
const forked: ChatRecord[] = sourceRecords.map((record) => {
|
||||
const systemPayload =
|
||||
record.type === 'system' && record.subtype === 'file_history_snapshot'
|
||||
? remapFileHistorySnapshotPayload(
|
||||
record.systemPayload,
|
||||
sourceSessionId,
|
||||
newSessionId,
|
||||
)
|
||||
: record.systemPayload;
|
||||
const isArtifactRecord = isSessionArtifactRecord(record);
|
||||
const systemPayload = remapSystemPayloadForFork(
|
||||
record,
|
||||
sourceSessionId,
|
||||
newSessionId,
|
||||
remappedArtifactIds,
|
||||
);
|
||||
const next: ChatRecord = {
|
||||
...record,
|
||||
sessionId: newSessionId,
|
||||
cwd: this.projectRoot,
|
||||
systemPayload,
|
||||
parentUuid: prevUuid,
|
||||
parentUuid: isArtifactRecord ? record.parentUuid : prevUuid,
|
||||
forkedFrom: {
|
||||
sessionId: sourceSessionId,
|
||||
messageUuid: record.uuid,
|
||||
},
|
||||
};
|
||||
prevUuid = record.uuid;
|
||||
if (!isArtifactRecord) {
|
||||
prevUuid = record.uuid;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
|
|
@ -1937,6 +1981,118 @@ function remapFileHistorySnapshotPayload(
|
|||
}
|
||||
}
|
||||
|
||||
function remapSystemPayloadForFork(
|
||||
record: ChatRecord,
|
||||
sourceSessionId: string,
|
||||
newSessionId: string,
|
||||
remappedArtifactIds: Map<string, string>,
|
||||
): ChatRecord['systemPayload'] {
|
||||
if (record.type !== 'system') return record.systemPayload;
|
||||
if (record.subtype === 'file_history_snapshot') {
|
||||
return remapFileHistorySnapshotPayload(
|
||||
record.systemPayload,
|
||||
sourceSessionId,
|
||||
newSessionId,
|
||||
);
|
||||
}
|
||||
if (
|
||||
record.subtype === 'session_artifact_event' ||
|
||||
record.subtype === 'session_artifact_snapshot'
|
||||
) {
|
||||
return remapSessionArtifactPayloadForFork(
|
||||
record.systemPayload,
|
||||
sourceSessionId,
|
||||
newSessionId,
|
||||
remappedArtifactIds,
|
||||
) as ChatRecord['systemPayload'];
|
||||
}
|
||||
return record.systemPayload;
|
||||
}
|
||||
|
||||
function includeActiveSideArtifactRecords(
|
||||
records: ChatRecord[],
|
||||
activeRecords: ChatRecord[],
|
||||
): ChatRecord[] {
|
||||
const activeByUuid = new Map(
|
||||
activeRecords.map((record) => [record.uuid, record]),
|
||||
);
|
||||
const activeUuids = new Set(activeByUuid.keys());
|
||||
const firstActiveUuid = activeRecords[0]?.uuid;
|
||||
const firstActiveIndex =
|
||||
firstActiveUuid === undefined
|
||||
? -1
|
||||
: records.findIndex((record) => record.uuid === firstActiveUuid);
|
||||
const nextActiveUuidByIndex = new Map<number, string>();
|
||||
const nextBlockingUuidByIndex = new Map<number, string>();
|
||||
let nextActiveUuid: string | undefined;
|
||||
let nextBlockingUuid: string | undefined;
|
||||
for (let index = records.length - 1; index >= 0; index--) {
|
||||
if (nextActiveUuid !== undefined) {
|
||||
nextActiveUuidByIndex.set(index, nextActiveUuid);
|
||||
}
|
||||
if (nextBlockingUuid !== undefined) {
|
||||
nextBlockingUuidByIndex.set(index, nextBlockingUuid);
|
||||
}
|
||||
if (activeUuids.has(records[index]!.uuid)) {
|
||||
nextActiveUuid = records[index]!.uuid;
|
||||
nextBlockingUuid = undefined;
|
||||
} else if (
|
||||
!isSessionArtifactRecord(records[index]!) &&
|
||||
!isTailNeutralSideRecord(records[index]!)
|
||||
) {
|
||||
nextBlockingUuid = records[index]!.uuid;
|
||||
}
|
||||
}
|
||||
const selected: ChatRecord[] = [];
|
||||
const includedSideArtifactUuids = new Set<string>();
|
||||
let previousActiveUuid: string | undefined;
|
||||
for (let index = 0; index < records.length; index++) {
|
||||
const record = records[index]!;
|
||||
const activeRecord = activeByUuid.get(record.uuid);
|
||||
if (activeRecord) {
|
||||
selected.push(activeRecord);
|
||||
activeByUuid.delete(record.uuid);
|
||||
previousActiveUuid = record.uuid;
|
||||
continue;
|
||||
}
|
||||
if (!isSessionArtifactRecord(record)) {
|
||||
continue;
|
||||
}
|
||||
const nextUuid = nextActiveUuidByIndex.get(index);
|
||||
const hasBlockingRecordBeforeNextActive =
|
||||
nextBlockingUuidByIndex.has(index);
|
||||
const isInActiveSegment =
|
||||
!hasBlockingRecordBeforeNextActive &&
|
||||
(nextUuid !== undefined
|
||||
? activeUuids.has(nextUuid)
|
||||
: previousActiveUuid !== undefined &&
|
||||
activeUuids.has(previousActiveUuid));
|
||||
if (
|
||||
record.parentUuid !== null &&
|
||||
(activeUuids.has(record.parentUuid) ||
|
||||
includedSideArtifactUuids.has(record.parentUuid)) &&
|
||||
isInActiveSegment &&
|
||||
(record.parentUuid === previousActiveUuid ||
|
||||
includedSideArtifactUuids.has(record.parentUuid))
|
||||
) {
|
||||
selected.push(record);
|
||||
includedSideArtifactUuids.add(record.uuid);
|
||||
} else if (
|
||||
record.parentUuid === null &&
|
||||
index < firstActiveIndex &&
|
||||
isInActiveSegment
|
||||
) {
|
||||
selected.push(record);
|
||||
includedSideArtifactUuids.add(record.uuid);
|
||||
}
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
function isTailNeutralSideRecord(record: ChatRecord): boolean {
|
||||
return record.type === 'system' && record.subtype === 'custom_title';
|
||||
}
|
||||
|
||||
function collectFileHistorySnapshotPromptIds(
|
||||
records: ChatRecord[],
|
||||
): Set<string> {
|
||||
|
|
|
|||
|
|
@ -41,11 +41,15 @@ const rootDir = join(__dirname, '..');
|
|||
// workspace remember (managed memory client methods + event validation).
|
||||
// Bumped from 133KB to 136KB after merging session artifacts plus sessionless
|
||||
// workspace memory forget/dream APIs and event validation.
|
||||
// Bumped from 136KB to 137KB for EventBus byte-backlog telemetry validation.
|
||||
// Bumped from 137KB to 138KB for history_truncated event validation and
|
||||
// Bumped from 136KB to 138KB for persistent session artifact APIs after
|
||||
// merging the upstream daemon SDK surface.
|
||||
// Bumped from 138KB to 139KB for EventBus byte-backlog telemetry validation.
|
||||
// Bumped from 139KB to 140KB for history_truncated event validation and
|
||||
// transcript status projection.
|
||||
// Bumped from 138KB to 139KB for workspace ACP status/preheat APIs.
|
||||
const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 139 * 1024;
|
||||
// Bumped from 139KB to 141KB after merging main (ACP status/preheat) into
|
||||
// the history_truncated/transcript-status branch.
|
||||
const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 141 * 1024;
|
||||
// The opt-in `daemon/transports` browser bundle legitimately ships the concrete
|
||||
// ACP transports (AcpHttpTransport/AcpWsTransport/AutoReconnect + negotiate), so
|
||||
// it's larger than the default barrel — but still budgeted so a future PR can't
|
||||
|
|
|
|||
|
|
@ -286,10 +286,16 @@ export const ROUTE_TABLE: readonly RouteEntry[] = [
|
|||
pattern: /^\/session\/([^/]+)\/artifacts\/([^/]+)$/,
|
||||
mapping: {
|
||||
method: '_qwen/session/artifacts/remove',
|
||||
extractParams: (segs) => ({
|
||||
sessionId: segs[0],
|
||||
artifactId: segs[1],
|
||||
}),
|
||||
extractParams: (segs, body) => {
|
||||
const record = isRecord(body) ? body : {};
|
||||
return {
|
||||
sessionId: segs[0],
|
||||
artifactId: segs[1],
|
||||
...(typeof record.clientId === 'string'
|
||||
? { clientId: record.clientId }
|
||||
: {}),
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
// POST /session/:id/recap → _qwen/session/recap
|
||||
|
|
|
|||
|
|
@ -542,12 +542,25 @@ export type {
|
|||
PromptTextContent,
|
||||
SetModelResult,
|
||||
SetSessionLanguageResult,
|
||||
KnownDaemonSessionArtifactChangeAction,
|
||||
KnownDaemonSessionArtifactKind,
|
||||
KnownDaemonSessionArtifactPersistenceWarning,
|
||||
KnownDaemonSessionArtifactRemovalReason,
|
||||
KnownDaemonSessionArtifactRestoreState,
|
||||
KnownDaemonSessionArtifactRetention,
|
||||
KnownDaemonSessionArtifactSource,
|
||||
KnownDaemonSessionArtifactStatus,
|
||||
KnownDaemonSessionArtifactStorage,
|
||||
DaemonSessionArtifact,
|
||||
DaemonSessionArtifactChange,
|
||||
DaemonSessionArtifactChangeAction,
|
||||
DaemonSessionArtifactInput,
|
||||
DaemonSessionArtifactKind,
|
||||
DaemonSessionArtifactMutationResult,
|
||||
DaemonSessionArtifactPersistenceWarning,
|
||||
DaemonSessionArtifactRemovalReason,
|
||||
DaemonSessionArtifactRestoreState,
|
||||
DaemonSessionArtifactRetention,
|
||||
DaemonSessionArtifactsEnvelope,
|
||||
DaemonSessionArtifactSource,
|
||||
DaemonSessionArtifactStatus,
|
||||
|
|
|
|||
|
|
@ -465,6 +465,7 @@ export interface DaemonSessionState {
|
|||
/** Returned from `POST /session/:id/load` and `POST /session/:id/resume`. */
|
||||
export interface DaemonRestoredSession extends DaemonSession {
|
||||
state: DaemonSessionState;
|
||||
artifactWarnings?: string[];
|
||||
/** Compacted events for completed turns (load only). */
|
||||
compactedReplay?: DaemonEvent[];
|
||||
/** Raw events since last turn boundary — current incomplete turn (load only). */
|
||||
|
|
@ -641,11 +642,37 @@ export type KnownDaemonSessionArtifactSource = 'tool' | 'hook' | 'client';
|
|||
export type DaemonSessionArtifactSource =
|
||||
OpenStringUnion<KnownDaemonSessionArtifactSource>;
|
||||
|
||||
export type KnownDaemonSessionArtifactStatus = 'available' | 'missing';
|
||||
export type KnownDaemonSessionArtifactStatus =
|
||||
| 'available'
|
||||
| 'missing'
|
||||
| 'changed';
|
||||
|
||||
export type DaemonSessionArtifactStatus =
|
||||
OpenStringUnion<KnownDaemonSessionArtifactStatus>;
|
||||
|
||||
export type KnownDaemonSessionArtifactRetention = 'ephemeral' | 'restorable';
|
||||
|
||||
export type DaemonSessionArtifactRetention =
|
||||
OpenStringUnion<KnownDaemonSessionArtifactRetention>;
|
||||
|
||||
export type KnownDaemonSessionArtifactRestoreState =
|
||||
| 'live'
|
||||
| 'restored'
|
||||
| 'unverified'
|
||||
| 'blocked';
|
||||
|
||||
export type DaemonSessionArtifactRestoreState =
|
||||
OpenStringUnion<KnownDaemonSessionArtifactRestoreState>;
|
||||
|
||||
export type KnownDaemonSessionArtifactPersistenceWarning =
|
||||
| 'persistence_unavailable'
|
||||
| 'metadata_only_restore'
|
||||
| 'restore_validation_failed'
|
||||
| 'sticky_override_active';
|
||||
|
||||
export type DaemonSessionArtifactPersistenceWarning =
|
||||
OpenStringUnion<KnownDaemonSessionArtifactPersistenceWarning>;
|
||||
|
||||
export interface DaemonSessionArtifactInput {
|
||||
kind?: KnownDaemonSessionArtifactKind;
|
||||
storage?: Exclude<KnownDaemonSessionArtifactStorage, 'published'>;
|
||||
|
|
@ -657,6 +684,8 @@ export interface DaemonSessionArtifactInput {
|
|||
mimeType?: string;
|
||||
sizeBytes?: number;
|
||||
metadata?: Record<string, string | number | boolean | null>;
|
||||
retention?: KnownDaemonSessionArtifactRetention;
|
||||
clientRetained?: boolean;
|
||||
}
|
||||
|
||||
export interface DaemonSessionArtifact {
|
||||
|
|
@ -673,6 +702,10 @@ export interface DaemonSessionArtifact {
|
|||
mimeType?: string;
|
||||
sizeBytes?: number;
|
||||
metadata?: Record<string, string | number | boolean | null>;
|
||||
retention: DaemonSessionArtifactRetention;
|
||||
restoreState?: DaemonSessionArtifactRestoreState;
|
||||
persistenceWarning?: DaemonSessionArtifactPersistenceWarning;
|
||||
persistedAt?: string;
|
||||
clientRetained: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
|
|
@ -689,7 +722,10 @@ export type KnownDaemonSessionArtifactChangeAction =
|
|||
export type DaemonSessionArtifactChangeAction =
|
||||
OpenStringUnion<KnownDaemonSessionArtifactChangeAction>;
|
||||
|
||||
export type KnownDaemonSessionArtifactRemovalReason = 'eviction' | 'explicit';
|
||||
export type KnownDaemonSessionArtifactRemovalReason =
|
||||
| 'eviction'
|
||||
| 'explicit'
|
||||
| 'unpin_to_ephemeral';
|
||||
export type DaemonSessionArtifactRemovalReason =
|
||||
OpenStringUnion<KnownDaemonSessionArtifactRemovalReason>;
|
||||
|
||||
|
|
@ -708,12 +744,25 @@ export interface DaemonSessionArtifactsEnvelope {
|
|||
limits: {
|
||||
maxArtifacts: number;
|
||||
};
|
||||
warnings?: string[];
|
||||
warningDetails?: DaemonSessionArtifactWarningDetail[];
|
||||
}
|
||||
|
||||
export interface DaemonSessionArtifactMutationResult {
|
||||
v: 1;
|
||||
sessionId: string;
|
||||
changes: DaemonSessionArtifactChange[];
|
||||
warnings?: string[];
|
||||
warningDetails?: DaemonSessionArtifactWarningDetail[];
|
||||
}
|
||||
|
||||
export interface DaemonSessionArtifactWarningDetail {
|
||||
code: string;
|
||||
operation: 'upsert' | 'remove' | 'restore' | (string & {});
|
||||
artifactIds?: string[];
|
||||
durability?: 'durable' | 'live_only' | 'unavailable' | (string & {});
|
||||
retryable?: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type DaemonStatus =
|
||||
|
|
|
|||
|
|
@ -359,7 +359,26 @@ describe('acpRouteTable – matchRoute', () => {
|
|||
expect(result!.mapping.method).toBe('_qwen/session/artifacts/remove');
|
||||
expect(
|
||||
result!.mapping.extractParams(result!.segments, undefined, 'DELETE'),
|
||||
).toEqual({ sessionId: 's8', artifactId: 'art 1' });
|
||||
).toEqual({
|
||||
sessionId: 's8',
|
||||
artifactId: 'art 1',
|
||||
});
|
||||
expect(
|
||||
result!.mapping.extractParams(
|
||||
result!.segments,
|
||||
{
|
||||
clientId: 'client-a',
|
||||
sessionId: 'body-session',
|
||||
artifactId: 'body-artifact',
|
||||
deleteContent: true,
|
||||
},
|
||||
'DELETE',
|
||||
),
|
||||
).toEqual({
|
||||
sessionId: 's8',
|
||||
artifactId: 'art 1',
|
||||
clientId: 'client-a',
|
||||
});
|
||||
});
|
||||
|
||||
it('POST /session/:id/recap maps to _qwen/session/recap', () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue