# 4-27 Resource Lifecycle Command Cutover v1

> 状态：done
> 归档说明（2026-05-21）：核心 `tree.resource.*` 生命周期命令 cutover 已完成；双浏览器 no-refresh、restore reveal/focus 等尾项已由 `4-34`、`4-28` 继续跟踪。
> 更新时间：2026-05-15
> 范围：`tree.resource.archive / restore / purge / rename` 正式命令面，以及 `/api/media/*`、mindmap、table 资源垃圾箱兼容入口的收口边界。

## 1. 本阶段目标

- 普通附件 file asset 的删除、恢复、永久删除、重命名必须具备正式 `tree.resource.*` 命令。
- `/api/media/batch` 与 `/api/media/purge` 可以继续作为历史 URL，但只能作为兼容 alias，内部必须生成正式 command plan 并记录 command artifact。
- mindmap / table 旧 URL 可以保留为兼容 route，但 delete / restore / purge 必须迁移到同一 `tree.resource.*` 命令 payload，不再扩写分散 route 语义。

## 2. 已落地范围

- `bridge-runtime` 新增 file asset 生命周期命令计划：
  - `tree.resource.archive -> mediaAssets:patchById`
  - `tree.resource.restore -> mediaAssets:patchById`
  - `tree.resource.purge -> mediaAssets:purgeById`
  - `tree.resource.rename -> mediaAssets:patchById`
- `storage-convex-bridge` 为上述命令提供历史 / cloud / compat source 的 Convex 映射；默认 local-first 资源生命周期不再依赖 Convex functions。
- `wolai-frontend` 的 `/api/media/batch`：
  - `delete` 改为 `tree.resource.archive`
  - `restore` 改为 `tree.resource.restore`
  - `rename` 改为 `tree.resource.rename`
- `wolai-frontend` 的 `/api/media/purge` 改为 `tree.resource.purge`。
- `mnote-web` 的 Rust 3000 `/api/media/batch` 与 `/api/media/purge` 改为通过 runtime command 执行，旧 URL 仅作为兼容 alias。
- `mnote-web` 的 Rust 3000 `/api/mindmap/{docId}/{mindmapId}` DELETE / PATCH restore / PATCH purge 改为通过 `tree.resource.archive / restore / purge` 执行，旧 URL 仅作为兼容 alias。
- `mnote-web` 的 Rust 3000 `/api/tables/{tableId}` DELETE、`/api/tables/restore`、`/api/tables/purge` 改为通过 `tree.resource.archive / restore / purge` 执行，旧 URL 仅作为兼容 alias。
- tree shell command event schema 已补资源生命周期事件。

## 3. 未完成边界

- mindmap 与 table 的 DELETE / restore / purge 已进入 `tree.resource.*` runtime command；但旧 URL 仍保留为兼容入口，前端 Sidebar 仍按资源类型分流到这些旧 URL。
- `tree.resource.rename` 当前只覆盖普通附件 file asset；mindmap rename 暂不支持，table rename 需要结合 `tables:update` 与 UI 标题语义另行收口。
- `empty-trash` 仍是 workspace 级批量兼容合同：`mediaAssets.emptyTrashByWorkspace`、`mindmaps.emptyTrashByWorkspace`、`tables.emptyTrashByWorkspace`，暂不展开为逐资源 `tree.resource.purge`。
- 双浏览器 no-refresh、垃圾箱恢复后的 reveal/focus、跨资源 projection delta 细粒度更新仍由后续阶段覆盖。

## 4. 验收

- 普通附件通过 `/api/media/batch delete/restore/rename` 或 `/api/media/purge` 操作时，测试中应观察到正式命令名 `tree.resource.archive/restore/rename/purge`。
- Mindmap 通过 `/api/mindmap/{docId}/{mindmapId}` DELETE / PATCH restore / PATCH purge 操作时，响应必须携带 `canonicalCommand=tree.resource.archive/restore/purge` 与 `resourceKind=mindmap`。
- Table 通过 `/api/tables/{tableId}` DELETE、`/api/tables/restore`、`/api/tables/purge` 操作时，响应必须携带 `canonicalCommand=tree.resource.archive/restore/purge` 与 `resourceKind=table`。
- Rust runtime plan 必须包含 `domainEventPlan` 与 `streamDeltaHint`。
- Rust 3000 兼容 route 不再直接写 `mediaAssets:patchById / purgeById`，而是先进入 runtime command。
- 现有垃圾箱 route 回归保持通过。

## 5. 验证命令

```bash
cd /mnt/Data1T/mnote/rust
cargo test -p bridge-runtime tree_resource_lifecycle_plans_cover_file_asset_commands
cargo test -p bridge-runtime tree_resource_lifecycle_plans_cover_mindmap_and_table_commands
cargo test -p storage-convex-bridge tree_resource_lifecycle_commands_have_convex_mapping
cargo test -p mnote-web trash
cargo test -p mnote-web runtime_command_event_schema_covers_tree_and_resource_command_channels
```

```bash
cd /mnt/Data1T/mnote/wolai-frontend
pnpm test src/app/api/media/purge/route.test.ts src/app/api/media/batch/route.test.ts src/lib/documents/rust-runtime.test.ts
```

## 6. 执行记录

- 2026-05-15：先补 `mnote-web` 路由测试断言 mindmap / table DELETE、restore、purge 响应必须携带 `canonicalCommand=tree.resource.archive/restore/purge` 与对应 `resourceKind`，测试红灯确认旧路由仍只返回 legacy mutation 结果。
- 2026-05-15：`mnote-web` mindmap / table 兼容 route 已改为构造 `tree.resource.archive/restore/purge` runtime command，并通过 `execute_runtime_command_via_convex_with_artifacts` 执行；旧 URL 保留。
- 2026-05-15：新增 `bridge-runtime` 合同测试 `tree_resource_lifecycle_plans_cover_mindmap_and_table_commands`，断言 mindmap 映射到 `mindmaps:softDelete/restore/purge`，table 映射到 `tables:remove/restore/purge`，并携带 `resourceLifecyclePlan`、`domainEventHint` 与 stream delta hint。
- 2026-05-15 验证：`cargo test --manifest-path rust/Cargo.toml -p bridge-runtime tree_resource_lifecycle_plans_cover_mindmap_and_table_commands -- --nocapture` 通过，1 项通过。
- 2026-05-15 验证：`cargo test --manifest-path rust/Cargo.toml -p mnote-web trash_routes -- --nocapture --test-threads=1` 通过，3 项通过。
- 2026-05-15 验证：`cargo test --manifest-path rust/Cargo.toml -p storage-convex-bridge tree_resource_lifecycle_commands_have_convex_mapping -- --nocapture` 通过，1 项通过。

## 7. 剩余边界

- `tree.resource.rename` 仍只覆盖普通附件 file asset；mindmap rename 继续明确不支持，table rename 待结合 `/api/tables/{tableId}` PATCH 与表格标题语义单独收口。
- `empty-trash` 仍是 workspace 级批量合同，不把它伪装成逐资源 `tree.resource.purge`。
- 前端 Sidebar 仍按类型调用旧 URL；这是兼容 alias，不是新增长期业务语义。后续如要统一客户端命令面，应在 `resource-command-client` 引入 mindmap/table payload，并保留旧 URL 适配层。

## 8. 子阶段：local_folder file_path resource lifecycle

### 8.1 目标与范围

本子阶段只覆盖 `local_folder` workspace 中由本地文件系统提供的非 md 资源文件，典型对象包括图片、PDF、音视频、Office 文件、压缩包和其他附件型二进制文件。Markdown 页面本体、目录节点、远端 media asset、mindmap、table 不在本子阶段内。

最小目标是把本地非 md 资源文件的 delete / restore / purge 纳入 `tree.resource.*` 正式命令面，同时保留本地文件路径作为用户可理解的 identity 输入。实现不要求立即接入云端文件删除，也不要求新增复杂版本库；只要求命令可审计、垃圾箱可索引、冲突可判定、恢复行为可预测。

### 8.2 最小设计

- 资源归属：`resourceKind=local_file`，`resourceScope=local_folder`。
- 资源定位：命令 payload 必须携带 `workspaceId`、`rootId`、`filePath`、`resourceKind`；`filePath` 使用相对 `local_folder` 根目录的规范化 UTF-8 路径，不允许绝对路径、空路径、`..` 逃逸或平台分隔符混用。
- md 排除：扩展名判定为 Markdown 的文件继续走页面/树节点生命周期，不进入 `local_file` 资源生命周期。
- delete：`tree.resource.archive` 不直接物理删除文件，先把源文件移动到本工作区受控 trash 存储区，并写入 trash index。
- restore：`tree.resource.restore` 只从 trash index 中恢复已归档资源，默认恢复到原 `filePath`；若原路径冲突，按冲突策略处理。
- purge：`tree.resource.purge` 只允许删除 trash index 中已归档资源对应的 trash 文件和索引记录；不允许对当前活跃文件路径直接 purge。
- 事件：三类命令都产出 `domainEventPlan` 与 `streamDeltaHint`，至少能通知资源列表、附件引用状态、垃圾箱视图刷新。

### 8.3 命令 identity

`local_folder` 文件的稳定命令 identity 分两层：

- 用户输入 identity：`workspaceId + rootId + normalizedFilePath`。这是兼容 UI、外部变更监听和命令 payload 的最小输入。
- 归档后 identity：`trashEntryId`。第一次 archive 成功后生成并写入 trash index，restore / purge 必须优先使用 `trashEntryId`，同时校验其记录的原始 `filePath`、`workspaceId`、`rootId` 与命令 payload 一致。

`resourceId` 不直接等同于裸 `filePath`。对活跃文件可派生为 `local_file:{workspaceId}:{rootId}:{pathHash}`，但该值只作为事件和 projection 的稳定 key；实际文件操作仍以规范化路径和 trash index 记录为准，避免路径重命名、大小写差异或 Unicode 归一化导致误删。

### 8.4 Trash index

每个 `local_folder` root 维护一个受控 trash index，最小字段如下：

- `trashEntryId`
- `workspaceId`
- `rootId`
- `resourceKind=local_file`
- `originalFilePath`
- `trashedFilePath`
- `fileSize`
- `contentHash`，可延后异步补齐，但 purge 前若存在则用于防误删校验
- `archivedAt`
- `archivedByCommandId`
- `restoreStatus`
- `purgedAt`
- `purgedByCommandId`

trash index 是 restore / purge 的唯一可信入口。目录扫描只能用于发现活跃文件或 orphan trash 文件，不能绕过 index 直接恢复或永久删除。

### 8.5 冲突策略

- archive 时源文件不存在：命令返回 `not_found`，不生成新的 trash entry；若 index 已存在同一路径的活跃归档记录，返回该 `trashEntryId` 并标记为幂等归档结果。
- archive 时目标 trash 路径已存在：生成新的 `trashEntryId` 与唯一 `trashedFilePath`，不覆盖旧 trash 文件。
- restore 时原路径不存在：直接恢复到 `originalFilePath`，并把 index 状态改为 restored。
- restore 时原路径已存在且 contentHash 相同：视为幂等恢复，index 状态改为 restored，不覆盖活跃文件。
- restore 时原路径已存在且 contentHash 不同或无法判定：不得覆盖活跃文件；返回 `conflict=file_path_occupied`，UI 可选择恢复为带后缀副本或让用户改名，但默认命令不自动改名。
- purge 时 trash 文件不存在但 index 仍为 archived：允许把 index 标记为 purged，并记录 `missing_trash_file`，用于容忍外部手动清理。
- purge 时 index 状态不是 archived：返回幂等结果，不删除任何活跃文件。
- 任一命令发现 `filePath` 逃逸 root、大小写归一化后指向不同文件、或 symlink 指向 root 外部：返回 `invalid_path`，不移动、不删除。

### 8.6 验收标准

- 本地非 md 资源文件 delete 只能通过 `tree.resource.archive` 进入 trash，不允许直接 unlink 活跃文件。
- restore / purge 必须优先基于 `trashEntryId` 与 trash index 执行；仅传 `filePath` 时只能定位唯一 archived entry，不能模糊匹配多条记录。
- trash index 中能追踪原路径、trash 路径、归档命令、恢复/永久删除命令和当前状态。
- restore 冲突时默认不覆盖用户已有文件，并返回可被 UI 展示的结构化冲突原因。
- purge 只能作用于 archived trash entry，不能对 active `filePath` 执行永久删除。
- Markdown 文件不进入本子阶段；测试中应验证 `.md` / `.markdown` 路径被拒绝或转交页面生命周期。
- 命令事件必须包含 `resourceKind=local_file`、`resourceScope=local_folder`、`filePath`、`trashEntryId`（archive 成功后）和 `canonicalCommand=tree.resource.archive/restore/purge`。
- 外部变更监听与本命令并发时，以文件系统当前状态和 trash index 状态共同判定；不得因 stale projection 覆盖真实文件系统变化。

### 8.7 执行记录

- 2026-05-15：先补 `tree_command_local_folder_asset_trash_restore_and_purge_use_trash_index` 红灯，确认 `local:asset:docs/photo.png` delete 原本掉到 Markdown 删除路径。
- 2026-05-15：`mnote-web` local folder executor 已把 `delete / restore / purge` 分流到 entry 级处理；`local:asset:*` / `local:node:*` raw file 通过 `tree.resource.archive/restore/purge` 进入 `.mnote/trash` 与 `trash-index.json`，Markdown 页面继续走原页面生命周期。
- 2026-05-15：Rust 3000 主壳 filetree delete plan 已对 `sourceKind=local_folder && rowKind=asset` 使用 rowId 作为本地资源 identity，Delete / Backspace 走 `/api/tree/commands`，不走 `/api/media/batch`。
- 2026-05-15：新增 `scripts/task437-local-folder-asset-trash-lifecycle-smoke.js`，真实浏览器覆盖 local asset Delete 进入本地回收站、restore 回原路径、purge 清理 trash 文件与索引；结果文件 `tmp/task437-local-folder-asset-trash-lifecycle-smoke/result.json` 记录 `navigationEvents=[]`。

### 8.8 验证命令

```bash
cargo test --manifest-path rust/Cargo.toml -p mnote-web tree_command_local_folder_asset_trash_restore_and_purge_use_trash_index -- --nocapture
cargo test --manifest-path rust/Cargo.toml -p mnote-web tree_command_local_folder_create_rename_copy_trash_restore_and_purge_use_same_endpoint -- --nocapture
cargo test --manifest-path rust/Cargo.toml -p mnote-web sidebar_filetree_delete_keys_support_mixed_doc_and_asset_selection -- --nocapture
MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3427 node scripts/task437-local-folder-asset-trash-lifecycle-smoke.js
```
