docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md
本文延续 PR #5895 的 V1 session artifact API,设计 V2 持久化能力。V1 设计见同目录下的 session-artifacts-daemon-api-implementation-design.md。
V2 的目标是在不破坏 V1 live session 语义的前提下,让 artifact metadata 可以在 daemon 重启、session load/replay 后恢复。当前 PR 不复制、不冻结、不托管 artifact 内容;workspace 文件只保存路径、size、mtimeMs 和 sha256 作为恢复后的完整性校验。
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。
当前能力:
available / missing / changed。对应 capability:
session_artifacts_persistence:支持 metadata 持久化与 session load/replay 恢复。session_artifacts_content_retention:当前不声明;后续如果重启 content archive 设计,必须在复制/托管内容、配额、manifest 和 GC/fsck 都完成后再声明。核心原则:
SessionArtifactStore 仍是 live session 的权威内存索引。source、clientId、trustedPublisher 当授权依据。当前 PR 的重要收窄:
session_artifacts_content_retention capability 不在 PR #6259 中交付。当前 PR 只保留对旧 pinned / contentRef journal payload 的 downgrade/strip 兼容路径,避免旧记录破坏 metadata restore。reason: "eviction" remove event;这等价于本实现的 metadata prune,不是纯 V1 live-only hiding。session_artifact_fork_marker 才能检测当前写入路径的 partial batch。若未来改成流式 fork,再引入 begin/complete marker。V2 后的行为应是:
GET /session/:id/artifacts 即可。V1 到 V2 的 live upgrade 需要单独处理:已经在内存中的 V1 live artifacts 没有 JSONL journal。V2 首次触达这些 live sessions 时,应通过 chat recording owner 提供的 artifact persistence writer 写入一条初始 session_artifact_snapshot,然后再接受新的 restorable artifact mutation。backfill 不能把 live store 原样序列化;必须对每个 artifact 重新执行 ingest validation、privacy minimization 和 retention materialization。单条 artifact 不合格时跳过或降级该条,不能让一次坏记录拖垮整个 backfill。若 writer 不可用或 backfill 整体失败,该 session 继续保持 V1 live-only 行为,并记录 structured warning;不能让用户误以为已有 live artifacts 已经可恢复。
backfill 不能逐条向 JSONL streaming 写入 artifact event。实现必须先在内存中完成校验、最小化和降级,形成完整 candidate snapshot 后,再一次性 append session_artifact_snapshot。如果 candidate 构建或 snapshot append 失败,不能留下部分 durable artifact state。当前 PR 不实现 V1 live-store backfill;如果后续补齐,应把 candidate 条目数、跳过条目数和校验失败原因写入结构化 telemetry 或 snapshot metadata,便于 fsck 和 restore warning 区分“完整但有条目被校验跳过”和“部分写入/损坏”。
新增 optional field。PR #6259 的 public mutation path 只接受 ephemeral 和 restorable;旧 journal 中的 pinned 会在 restore / fork 时降级为 metadata-only restorable:
type ArtifactRetention = 'ephemeral' | 'restorable';
含义:
ephemeral:只存在于 live store。daemon/session 消失后不恢复。restorable:metadata 写入持久化 journal。session load/replay 后恢复为 artifact item,但不保证底层资源仍存在。默认规则:
record_artifact、hook artifact:默认 restorable,但只持久化 metadata。restorable,恢复后仍出现在 artifact list 中。retention: "ephemeral";SDK 应提供明确的 ephemeral helper。published artifact:默认 restorable;当前只恢复 published locator,不托管内容。如果 chat recording 被禁用,metadata persistence 默认禁用,capability 不声明。
用户手动注册的 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 和审计记录约束。
V2 在 V1 response artifact 上增加 optional fields:
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 兜底。V1 status 继续表示当前资源是否可用:
availablemissingchangedV2 只新增 changed 这一种 workspace integrity 状态。它表示路径仍可访问,但实时文件的 size 已变化,或 mtime 变化后 sha256 与登记时的 metadata 不一致。blocked 不是 status,只属于 restoreState:
restored:从持久化 metadata 恢复。unverified:恢复了 metadata,但尚未完成 workspace/managed 校验。blocked:恢复时发现安全边界不满足,例如 workspace path 逃逸。live:当前进程内新产生或已刷新确认。V2 默认只使用 Chat JSONL system records:
session_artifact_snapshot 是 JSONL 内的恢复加速点,不是独立文件。如果未来实测需要 sidecar,它必须作为单独设计进入,并满足两个约束:
sidecar 对 V2 持久化不是 correctness requirement。当前 loadSession() 为恢复会读取完整 session JSONL 并重建对话树;artifact restore 在同一轮读取里提取 snapshot/event records 时,不会增加额外文件 I/O。因此,sidecar 在当前架构下只能节省 artifact records 的少量 parse/replay 成本,不能消除 session load 的主要读取成本。
把 sidecar 纳入当前 PR 会明显扩大实现面:
因此 V2 发布门槛保持 JSONL-only。sidecar 只在以下任一条件被 profiling 或产品需求证明后再进入独立设计:
loadSession() 不再需要读取完整 JSONL,sidecar 可以避免一次 cold-start 全量扫描。Artifact persistence records 是 chat transcript 的一部分,必须遵循现有 ChatRecord 的 parent/leaf 语义:
ChatRecordingService.appendRecord 的进程或它暴露的明确 RPC 完成。daemon-side SessionArtifactStore 可以用 operation queue 协调 live state、SSE 和 persistence request 顺序,但不能自己打开并写 chat JSONL。session_artifact_event / session_artifact_snapshot 都必须作为普通 system ChatRecord 挂到当前 conversation leaf 上,并获得正常的 uuid / parentUuid。session_artifact_* system records 视为 side-effect records:它们参与 parent/leaf 顺序和 replay,但不渲染成用户可见 conversation node。最低支持旧版本加载包含 V2 record 的 JSONL 时也必须把未知 system subtype 当作 opaque/ignored side effect,而不是让 session load 失败。/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 中等待下次重启才消失。session_artifacts_persistence capability;否则 rewind 后会出现旧 upsert 或旧 tombstone 复活的问题。这意味着 V2 不设计独立的 artifact log 文件,也不设计绕过 chat tree 的 side log。artifact persistence 的正确性来自同一条 active chat history,而不是 daemon 当前内存状态。
给 ChatRecord.subtype 增加:
'session_artifact_event' | 'session_artifact_snapshot';
Payload:
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 内部字段或运行时派生字段:
identityKeytrustedPublisherworkspaceCwdrestoreStatepersistenceWarningclientId 或 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 会丢失。
artifact snapshot 只用于减少 replay 的 artifact event 应用量;它不会减少 JSONL 文件本身的读取量。
必须满足:
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_invalid warning,继续尝试上一个 valid snapshot;不能因为一个 corrupt snapshot 丢失整个 session 的 artifact metadata。这里的 snapshot baseline advance 不会重写或删除 JSONL 里的旧 record。旧 session_artifact_snapshot、event 和 tombstone 仍保留在 append-only chat transcript 中;artifact 子系统只是在最新 snapshot payload 内前移恢复基线并重置工作集计数。
V2 不双写 sidecar,因此没有 JSONL + sidecar 的 metadata 重复存储。存储消耗分为 metadata journal 和 content retention:
控制策略:
session_artifact_snapshot,例如每 100 次 artifact mutation 或每 256 KB artifact journal 写一次。postSnapshotBase,之后每个 artifact event append 增加 postSnapshotEventBytes。预算检查使用 snapshotBytes + postSnapshotEventBytes,snapshot baseline advance 成功后重置 counter。若 writer 无法确认 base position 或 counter 状态,必须保守写新 snapshot;仍无法确认时降级或报错,不能无界追加。ephemeral 并带 persistenceWarning.code = "journal_budget_exceeded"。任何 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。验证失败时:
ephemeral,并写 persistenceWarning.code = "validation_downgraded";不能写入 JSONL。V1 流程:
ingest input -> normalize/validate -> upsert live store -> publish artifact_changed
V2 流程:
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 把已裁剪条目全部复活。区分两个入口:
普通 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:
[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
session load/replay 时:
SessionService.loadSession() 读取 JSONL,并在同一轮 parse 中提取 artifact snapshot/event records。session_artifact_snapshot 和之后的 session_artifact_event。abandoned branch 上的 artifact records 必须忽略。artifactSnapshot 回到 daemon-side bridge。createSessionEntry / restore completion 时用 snapshot 初始化 daemon 侧 SessionArtifactStore。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。
恢复时必须重新校验:
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"。现有 /branch 会复制 active JSONL record chain 并重写 sessionId。V2 artifact records 只从 active leaf chain 复制;rewind 后落在 abandoned branch 上的 artifact records 不会进入 fork。复制时必须显式处理 artifact id:
sessionId。sessionId + locator 重新计算 artifact id。forkedFrom 可以记录原 session id / 原 artifact id,作为审计信息,但不能参与新 session 的权限判断。pinned artifact metadata 时,必须降级为 restorable,并移除旧 contentRef。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。
GET /capabilities 增加:
"session_artifacts_persistence"
内容保留拆分 PR 实现可用时,才同时声明:
"session_artifacts_content_retention"
当前 /capabilities 是 string feature list,因此不能用 enabled: false 表达“实现存在但当前关闭”。规则是:
session_artifacts_persistence。session_artifacts_content_retention。PR #6259 不声明该 capability。POST /session/:id/artifacts 允许 optional:
{
"title": "Report",
"kind": "html",
"storage": "workspace",
"workspacePath": "reports/run.html",
"retention": "restorable",
"clientRetained": true
}
限制:
ephemeral 或 restorable。pinned。clientRetained 可选,仅表示用户保留意图和排序 hint;服务端必须按 §5.1 校验来源,不能把它当授权。PR #6259 不暴露 pin/save endpoint。显式内容留档、content archive、pin/save 语义如未来需要,应基于新的产品需求重新设计,不能从本文当前 metadata persistence contract 推导。
PR #6259 不暴露 unpin endpoint,也不会生成新的 unpin tombstone。旧 journal 里的 reason: "unpin_to_ephemeral" 只作为兼容输入继续 replay,避免历史记录恢复语义变化。若要从列表移除,仍使用 V1 DELETE。
V2 的 DELETE 仍保持 V1 幂等,并采用当前 PR 的 live-first 语义:
session_artifact_event remove tombstone;tombstone 成功后,metadata restore 时不再复活。deleteContent,也不触发 daemon-managed content GC;旧 contentRef metadata 只在 restore/serialization 时被降级或移除。PR #6259 只交付 DELETE mutation response。
成功:
200 OK 返回 { "deleted": true, "artifactId": string, "warnings"?: [...] }。200 OK mutation result,并在 warnings 中包含持久化失败原因;当前实现使用字符串 warning,例如 remove_not_persisted。这表示 live delete 已生效但跨重启不保证,不能把它展示成 durable delete 成功。失败:
{
"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 权限。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。
不要把 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:
type ArtifactPrincipal =
| { kind: 'token_holder' }
| { kind: 'client_connection'; id: string }
| { kind: 'trusted_publisher'; id: string }
| { kind: 'hook'; extensionId: string };
授权规则:
clientId 伪造 ownership,删除授权退化为 session-level mutate 权限并记录 ownership_unverified audit。如果未来需要真正的 session_owner,必须先设计 durable per-session capability 或 ACL,不能在本 V2 文档中隐式假设。
本节是 future content archive 蓝图,不属于 PR #6259 的实现或验收范围。
默认不复制:
未来若启用 content archive,可考虑允许的来源:
ArtifactTool / publisher 生成的 published artifact。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。file: locator 只能由该 manifest 重新生成,不能来自 client payload 或旧 JSONL 字段。内容复制必须 race-safe:
openat2(RESOLVE_NO_SYMLINKS),其它平台用可用的 no-follow/open-handle revalidation 组合。持久化前必须做最小化:
ephemeral / non-openable locator;不能原样写入 JSONL。token、password、secret、cookie、authorization 等 key/value 必须拒绝、redact,或降级为 ephemeral。persistenceWarning.message 即使只作为 live response 字段,也必须使用 path-free 模板或脱敏文本;不能把 host path、credential、token、content root、connection id 写入 warning。后续可新增设置:
{
"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 推断配置细节。
建议默认:
live store 上限在当前实现中也是 restore 可见集合的上限:
ephemeral artifact。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。
本节是后续 content-retention PR 的实现范围;PR #6259 不引入 content store quota。
后续拆分 PR 的建议默认:
达到上限时:
QUOTA_EXCEEDED。本节是后续 content-retention PR 的实现范围。GC 只处理 daemon 管理的 session-scoped managed copy:
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。GC trigger:
POST /session/:id/artifacts/gc。.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”。
要求:
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.eviction remove event so restore respects the cap.Future content archive 的写入顺序:
artifact_changed。如果第 2 步成功但第 3 步前 crash,会留下没有 journal 引用的 orphan content;这是允许的,future session-scoped GC 在确认 manifest 不被当前 live refs 引用后 best-effort 删除。如果第 3 步成功,restore 必须能通过 manifest 找到内容。显式 API 只有在第 3 步成功后才能返回成功。
V2 要避免把 artifact 恢复变成 session load 的新瓶颈。
读取路径建议:
SessionService.loadSession() 已经读取 JSONL 时,在同一轮 parse 中提取 artifact records。session_artifact_snapshot,只 replay 之后的 artifact events。CPU 成本边界:
external_url 恢复不发网络请求。workspace load/replay 只恢复 metadata;GET/list refresh 在 TTL/batch 限制下重新 stat 单个或一批 workspace 文件,必要时才 hash,用于区分 available / missing / changed。managed / published 恢复只查 manifest,不读取大文件内容。loadSession() 的 JSONL parse 阶段全量执行。GET/list refresh 先用 size + mtimeMs 做 cheap stat gate;只有 stat 显示可能同尺寸改写时才读取文件流计算 sha256。I/O 成本边界:
推荐默认:
V2 新增的失败路径必须有 structured logs,格式沿用:
[artifacts] session=<id> action=<action> key=value
建议 action:
persist_failedretention_downgradedrestore_skippedrestore_blockedremove_not_persistedevictionfork_artifact_discardedfork_incompletesnapshot_invalidsticky_override_suppressedtombstone_conflictv2_writer_version_gate_failedFuture checker / content archive 可以再增加 fsck、content copy、TTL、GC 相关 action;PR #6259 不产生这些日志。
这些日志不替代 API/SSE 中的 persistenceWarning,而是用于生产排障。
建议 metrics:
artifact_journal_append_total{result,reason}artifact_restore_total{result,restore_state}artifact_pending_tombstone_countartifact_metadata_quota_used{session}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:
contentRef、manifest 缺失和 orphan content。以下是同一个 V2 design phase 内的实现里程碑。工程上可以按 PR 拆开;对外以 capability 声明实际可用能力。
SessionService.loadSession() parse/replay 路径,负责从 active leaf chain rebuild artifact snapshot。ChatRecord.subtype 与 systemPayload union。artifactSnapshot?。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 中保留兼容。artifact_changed event shape 不变,只增加 optional fields。SessionService.loadSession() 从 active leaf chain 提取 artifact snapshot/event records,忽略 abandoned branches。onActiveLeafChanged(sessionId, artifactSnapshot),让 daemon-side store 在 operation queue 中完成 reseed/top-up。/branch 从 active chain 复制 artifact records 并 remap session id/artifact id;当前 full-file exclusive-create 写入路径不需要 fork marker。POST /session/:id/artifacts 支持 retention: "ephemeral" | "restorable"。POST /session/:id/artifacts 支持 clientRetained boolean hint,并拒绝 client 传入 daemon-only runtime fields。不属于 PR #6259。若未来有审计/留档需求,需要单独设计 daemon-managed workspace content manifest、quota、race-safe copy、hash 校验、write-queue/lease-protected GC/fsck 和 published artifact content binding。
PR #6259 当前必须覆盖:
/rewind 后 abandoned branch 上的 artifact upsert/remove 不参与恢复,也不会在 fork 中复制。/rewind 后 live store 立即与 active-chain artifact state 对齐;不会等到 daemon 重启才改变 artifact list。unpin_to_ephemeral tombstone replay 后 load 不复活 artifact。unpin_to_ephemeral 后,同一 artifact id 的隐式/default re-upsert 仍保持 live-only;显式 restorable 可以 supersede sticky override。stickyEphemeralIds 仍能让隐式/default re-upsert 保持 live-only,并产生 sticky_override_suppressed log/metric/warning。stickyEphemeralIds 达到上限时,legacy unpin-to-ephemeral 返回错误或延后重试,且不会静默丢失旧 sticky override。eviction remove event;restore 后不会超过 live cap。stickyEphemeralIds 保留 sticky state。file: 只有 trusted manifest revalidation 通过时恢复。managedId 在 ingest、restore 和 fork remap 时拒绝分隔符、..、绝对路径和路径形态;fork 不能盲目复制源 session 的 managedId。persistenceWarning 让 client 可见。pinned artifact 时降级为 restorable,不继承 contentRef。metadata["qwen.workspace.sha256"] + metadata["qwen.workspace.mtimeMs"];GET/list refresh 能区分 available、missing 和 changed,且未变化文件只走 stat 快路径。stickyEphemeralIds 保留 sticky state;JSONL 文件本身不被 artifact 子系统重写。pinned 被拒绝。enabled:false details。system subtype,不得导致 session load 崩溃;回滚后 artifact persistence 不恢复是可接受降级。如果当前最低支持版本不能保证这一点,V2 writer 必须 capability-gate 到支持 unknown system record 的版本之后。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。contentRef、manifest 缺失、orphan content 和 GC 修复策略。clientId 作为删除授权凭证。V2 建议作为一个完整 design phase 发布,但能力按 capability 暴露:
session_artifacts_persistence 可先发布 metadata restore。session_artifacts_content_retention 当前不发布;future content archive 需要重新设计并独立声明 capability。restorable,session load/replay 后继续出现在列表中。changed 状态只说明实时文件和登记时 size 不一致,或 mtime 变化后 hash 不一致。Rollback procedure:
system subtype 时,session load 应继续工作但不恢复 artifact persistence。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。session_artifacts_persistence / session_artifacts_content_retention,因为旧 daemon 不声明这些 capability。这样可以讲清楚当前 V2 的完整语义:默认恢复列表,不保存内容,用 workspace size/mtime/hash 避免静默打开错误版本,同时避免对未变化文件重复做全量 hash。