From 8d76618d989e0c8948b171c129637f3d1ae54e1f Mon Sep 17 00:00:00 2001 From: arthur-zhang Date: Sun, 26 Jul 2026 16:35:51 +0800 Subject: [PATCH 1/9] Refactor application structure and remove redundant code --- docs/design/be/e2b-sandbox-image-contract.md | 22 +- docs/design/be/filestore.md | 20 +- .../design/be/managed-agent-skills-runtime.md | 247 +++++----- docs/design/be/runtime-configuration.md | 2 +- .../be/skills-builtin-seed-and-storage.md | 2 +- internal/agents/handler.go | 42 +- internal/api/server.go | 8 +- internal/db/filestore_cleanup.go | 8 + internal/db/filestore_cleanup_sqlx_test.go | 1 + internal/db/filestore_filesystems.go | 12 +- internal/db/filestore_fixed_roots.go | 3 +- internal/db/filestore_skill_archives.go | 284 +++++++++++ internal/db/filestore_skill_archives_test.go | 88 ++++ .../00032_add_filestore_skill_archives.sql | 125 +++++ ...idate_filestore_skill_archive_checksum.sql | 21 + internal/db/skill_prewarm.go | 295 ----------- internal/deployments/handler.go | 36 +- internal/environments/rclone_filestore.go | 24 +- .../environments/rclone_filestore_test.go | 40 +- internal/environments/runner.go | 51 +- internal/filestore/service.go | 57 ++- .../filestore/service_test_support_test.go | 34 +- internal/filestore/skill_archives.go | 466 ++++++++++++++++++ internal/filestore/skill_archives_test.go | 249 ++++++++++ internal/networkpolicy/metadata_test.go | 4 +- internal/runtime/e2bruntime/runtime.go | 234 +-------- internal/runtime/e2bruntime/runtime_test.go | 132 ----- internal/skillprewarm/enqueuer.go | 57 --- internal/skillprewarm/enqueuer_test.go | 69 --- internal/skillprewarm/worker.go | 243 --------- internal/skillprewarm/worker_test.go | 287 ----------- internal/skills/archive_limits.go | 19 - internal/skills/handler.go | 66 +-- internal/skills/mount_manifest.go | 139 ------ internal/skills/mount_manifest_test.go | 124 ----- internal/skills/resolver.go | 170 +------ internal/skills/seed.go | 5 - main.go | 2 - tests/environments_runner_cloud_test.go | 89 ++-- tests/filestore_db_test.go | 27 +- tests/filestore_fixed_roots_test.go | 4 +- tests/filestore_provision_roots_test.go | 1 + tests/skill_prewarm_api_test.go | 181 ------- tests/skills_api_test.go | 9 +- tests/skills_seed_test.go | 4 +- 45 files changed, 1660 insertions(+), 2343 deletions(-) create mode 100644 internal/db/filestore_skill_archives.go create mode 100644 internal/db/filestore_skill_archives_test.go create mode 100644 internal/db/migrations/00032_add_filestore_skill_archives.sql create mode 100644 internal/db/migrations/00033_validate_filestore_skill_archive_checksum.sql delete mode 100644 internal/db/skill_prewarm.go create mode 100644 internal/filestore/skill_archives.go create mode 100644 internal/filestore/skill_archives_test.go delete mode 100644 internal/skillprewarm/enqueuer.go delete mode 100644 internal/skillprewarm/enqueuer_test.go delete mode 100644 internal/skillprewarm/worker.go delete mode 100644 internal/skillprewarm/worker_test.go delete mode 100644 internal/skills/archive_limits.go delete mode 100644 internal/skills/mount_manifest.go delete mode 100644 internal/skills/mount_manifest_test.go delete mode 100644 tests/skill_prewarm_api_test.go diff --git a/docs/design/be/e2b-sandbox-image-contract.md b/docs/design/be/e2b-sandbox-image-contract.md index 731312838..cfedc5c0f 100644 --- a/docs/design/be/e2b-sandbox-image-contract.md +++ b/docs/design/be/e2b-sandbox-image-contract.md @@ -9,9 +9,10 @@ 镜像必须满足: - `/opt/rclone/rclone-filestore` 存在、是 Linux 可执行文件,并支持 `multimount --config `。 -- Sandbox 具有 rclone-filestore 创建四个 FUSE mount 所需的设备、capability 和 mount namespace 权限。 +- Sandbox 具有 rclone-filestore 创建五个 FUSE mount 所需的设备、capability 和 mount namespace 权限。 - `/usr/local/bin/environment-manager` 与 `/opt/claude-code/bin/claude` 默认可执行;这两个路径仍可通过既有 Environment Runner 配置覆盖。 -- 运行用户可以创建 `/mnt/user-data/outputs`、`/mnt/session/uploads`、`/mnt/transcripts`、`/mnt/user-data/tool_results` 的挂载点,并能写入 `/tmp`。 +- Runner 可以创建 `/mnt/user-data/outputs`、`/mnt/session/uploads`、`/mnt/transcripts`、`/mnt/user-data/tool_results` 和 `/root/.claude/skills` 的挂载点,并能写入 `/tmp`。 +- 镜像和 Environment Manager 不能重新创建 `/root/.claude/skills` 软链,也不能依赖 `/mnt/skills` 或 `/workspace/skills` 解压目录。 `rclone-filestore` 路径固定为 `/opt/rclone/rclone-filestore`,不提供 `rclone_filestore_path` 配置。镜像若缺失该文件或没有执行权限,后台命令启动失败或 ready marker 在 `20s` 内不会出现;Runner 会把 Sandbox 标记为失败并 Kill,不会启动 Environment Manager。 @@ -22,9 +23,9 @@ | 路径 | 所有者 | 生命周期 | | --- | --- | --- | | `/tmp/rclone-mount-config.json` | Runner/rclone | E2B Files API 写入后设为 `0600`,ready 后最多重试三次删除 | -| `/tmp/rclone-mounts/ready` | rclone-filestore | 四个 mount 全部 ready 后创建 | +| `/tmp/rclone-mounts/ready` | rclone-filestore | 五个 mount 全部 ready 后创建 | -Runner 每次创建新的 Sandbox,通过 E2B 后台进程 API 启动 rclone,不在 Sandbox 中写 PID 或 exit marker。Runner 只通过 E2B Files API 每 `200ms` 探测 ready marker,最长 `20s`,不探测 rclone PID。`/tmp/rclone-mounts/ready` 必须只表示四个固定 mount 均已可用;部分就绪不能创建 marker。 +Runner 每次创建新的 Sandbox,通过 E2B 后台进程 API 启动 rclone,不在 Sandbox 中写 PID 或 exit marker。Runner 只通过 E2B Files API 每 `200ms` 探测 ready marker,最长 `20s`,不探测 rclone PID。`/tmp/rclone-mounts/ready` 必须只表示五个固定 mount 均已可用;部分就绪不能创建 marker。 ## 固定文件系统视图 @@ -35,20 +36,24 @@ ready 后镜像内必须呈现: /mnt/session/uploads # readonly /mnt/transcripts # readonly /mnt/user-data/tool_results # readonly +/root/.claude/skills # readonly virtual archive tree ``` File resource 不新增独立 FUSE mount,也不创建逐文件软链接。`mount_path` 始终解释为 `/uploads` namespace 下的路径;resource 写事务已经在当前 Session 的 `filesystem_id` 下插入借用 Files 对象的数据库 entry,rclone 只负责把这个权威 namespace 整体只读挂载到 `/mnt/session/uploads`。例如 `/uploads/workspace/data.csv` 在 Sandbox 中通过 `/mnt/session/uploads/workspace/data.csv` 访问。镜像、Runner 和 Environment Manager 都不负责下载、复制、调和或投影单个 File resource,Runner 也不再把 `type=file` resource 转发给 Environment Manager。 同一 `filesystem_id` 的 namespace 在 mount 存活期间继续由数据库维护。运行中增删 File resource 不重建 FUSE mount;现有 Sandbox 在 `/uploads` 的 `1s` metadata cache 刷新后读取到新的 namespace 状态。 +`/root/.claude/skills` 是 Filestore `/skills` 的直接 mount。Runner 启动 rclone 前会删除该路径上的遗留软链并创建目录;archive 解包视图由 Filestore 服务虚拟生成,镜像和 Environment Manager 不下载、不复制、不解压 skill zip。该 mount 使用只读 Token、`readonly=true`、`uid=0`、`gid=0`、目录权限 `0555` 和文件权限 `0444`。 + ## 启动顺序合同 ```mermaid flowchart TD R["Resolve trusted Session filesystem scope"] --> A["Create E2B Sandbox"] A --> W["Write config and chmod 0600"] - W --> B["Start fixed rclone-filestore binary"] - B --> C{"All four mounts ready?"} + W --> S["Remove legacy skill symlink and create mountpoint"] + S --> B["Start fixed rclone-filestore binary"] + B --> C{"All five mounts ready?"} C -->|"probe error / 20s timeout"| F["Fail work and kill Sandbox"] C -->|"yes"| D["Delete token config, retry up to 3 times"] D --> G["Mark Sandbox running and heartbeat Work"] @@ -70,9 +75,10 @@ File resource 与 `/uploads` entry 的一致性由 resource 写事务负责,Ru 1. `test -x /opt/rclone/rclone-filestore` 成功。 2. 用临时测试 filesystem 启动固定 multimount,ready marker 在超时内出现。 -3. `/mnt/user-data/outputs` 可写,另外三个 destination 拒绝写入。 +3. `/mnt/user-data/outputs` 可写,另外四个 destination 拒绝写入;`/root/.claude/skills//SKILL.md` 可由 Claude Code 直接发现。 4. 只通过 Files API 上传对象并给 Session 添加 File resource,不在测试侧直接写 Filestore;确认 resource 写入已创建 `/uploads/workspace/data.csv` 的数据库引用,启动后可通过 `/mnt/session/uploads/workspace/data.csv` 读取且写入失败。 5. 正常路径 ready 后 `/tmp/rclone-mount-config.json` 不存在;模拟删除失败时确认最多重试三次、记录脱敏告警且 Sandbox 继续运行。日志和进程命令行不包含 Filestore Token,outputs token 不能写入其他 source。 -6. ready 探测失败或 `20s` 内未出现 marker 均不会启动 Environment Manager,并会终止 Sandbox。 +6. 镜像中预置遗留 `/root/.claude/skills` 软链时,Runner 会在 mount 前删除它;Environment Manager 启动后不得重建软链或产生 `/workspace/skills` 解压副本。 +7. ready 探测失败或 `20s` 内未出现 marker 均不会启动 Environment Manager,并会终止 Sandbox。 Token 当前固定一小时有效且不刷新;长生命周期 Sandbox 的续签不属于此镜像合同。 diff --git a/docs/design/be/filestore.md b/docs/design/be/filestore.md index 7c299990b..09206ca05 100644 --- a/docs/design/be/filestore.md +++ b/docs/design/be/filestore.md @@ -96,9 +96,9 @@ curl -H "Authorization: Bearer ${FILESTORE_TOKEN}" http://127.0.0.1:38080/v1/fil - token 自签发起 1 小时后失效;长时间 mount 或联调需要在到期前重新签发并更新客户端凭证。 - 所有身份和策略字段都必须与当前数据库一致,且 `filesystem-id` 必须已经存在,否则服务端验签后的范围回查仍会拒绝请求。 -当前公开合同没有 filesystem 创建接口,Filestore 鉴权也不会根据其他凭证惰性建档。public Session 创建事务会自动建立唯一 filesystem,并在同一事务中建立 `/outputs`、`/uploads`、`/transcripts`、`/tool_results` 四个固定一级目录;JWT 在 sandbox 启动等受信边界按需签发,不持久化。请求改用同 workspace 的其他 filesystem、同名 filesystem 已被其他 Session 绑定,或数据库记录尚未创建时,都必须拒绝,不能改绑或泄露其存在性。每次鉴权还会回查所属 Session;Session 一旦归档、终止或删除,既有 JWT 立即失效。 +当前公开合同没有 filesystem 创建接口,Filestore 鉴权也不会根据其他凭证惰性建档。public Session 创建事务会自动建立唯一 filesystem,并在同一事务中建立 `/outputs`、`/skills`、`/uploads`、`/transcripts`、`/tool_results` 五个固定一级目录;JWT 在 sandbox 启动等受信边界按需签发,不持久化。请求改用同 workspace 的其他 filesystem、同名 filesystem 已被其他 Session 绑定,或数据库记录尚未创建时,都必须拒绝,不能改绑或泄露其存在性。每次鉴权还会回查所属 Session;Session 一旦归档、终止或删除,既有 JWT 立即失效。 -四个一级目录是 Sandbox 运行时合同,不是普通用户目录:通用 Filestore mutation 不能移动或删除固定根、覆盖固定根,也不能把目录跨固定根边界移动;同一固定根内部的普通目录移动和删除仍按既有规则执行。这样 filesystem 的目录树始终由数据库维护,同时不会在 Session 启动时重新扫描、清空或修复 `/uploads`。 +五个一级目录是 Sandbox 运行时合同,不是普通用户目录:通用 Filestore mutation 不能移动或删除固定根、覆盖固定根,也不能把目录跨固定根边界移动;同一普通固定根内部的目录移动和删除仍按既有规则执行。`/skills` 进一步保留为全树只读命名空间,其后代由 skill archive 投影虚拟生成,任何以 `/skills` 为 source 或 destination 的 mutation 都由服务层拒绝。这样 filesystem 的目录树始终由数据库维护,同时不会在 Session 启动时重新扫描、清空或修复 `/uploads`。 `filesystemId` 同时允许 tagged external ID 和 UUID。查询同时命中两列时必须优先选择精确 `external_id`,仅在 external ID 未命中时才按内部 UUID 解析;JWT scope 回查与资源层查询使用相同优先级,避免跨命名空间的非确定选择。 @@ -114,8 +114,11 @@ filesystem 的数据库 namespace 在 Session/resource 写事务完成时已经 | `/uploads` | `/mnt/session/uploads` | 只读 | 1s | | `/transcripts` | `/mnt/transcripts` | 只读 | 10s | | `/tool_results` | `/mnt/user-data/tool_results` | 只读 | 3s | +| `/skills` | `/root/.claude/skills` | 只读 | 60s | -四个挂载统一使用 `uid=999`、`gid=1000`、目录权限 `0755`、文件权限 `0644`、`vfs_cache_mode=full` 和 `vfs_cache_max_size=1G`。`/outputs` 使用读写 Token,其余三个 source 共享只读 Token;两类 Token 都绑定当前 public Session 唯一 filesystem 的 external ID,`service_url` 直接取 `code_session.sandbox_api_base_url`。 +五个挂载统一使用 `vfs_cache_mode=full` 和 `vfs_cache_max_size=1G`。前四个保留 `uid=999`、`gid=1000`、目录权限 `0755` 和文件权限 `0644`;`/skills` 使用 `uid=0`、`gid=0`、目录权限 `0555` 和文件权限 `0444`,以匹配 Claude Code 的 root discovery 路径。`/outputs` 使用读写 Token,其余四个 source 共享只读 Token;两类 Token 都绑定当前 public Session 唯一 filesystem 的 external ID,`service_url` 直接取 `code_session.sandbox_api_base_url`。 + +Runner 在启动 rclone 前执行固定 mount preparation:确保 `/root/.claude` 存在;如果 `/root/.claude/skills` 是旧版遗留软链则删除该软链;随后确保目标是目录。它不会创建新的 skill 软链,也不会复制或解压 archive。若该路径被普通文件占用,准备阶段失败并进入统一 Sandbox 清理。 Runner 先通过 E2B Files API 完整写入强类型 JSON,再将 `/tmp/rclone-mount-config.json` 权限设置为 `0600`。文件写入完成后才直接执行固定镜像命令,不使用 stdin bootstrap、临时文件或 shell trap: @@ -132,12 +135,15 @@ sequenceDiagram participant R as Environment Runner participant E as E2B Sandbox participant M as Environment Manager - A->>D: Create filesystem and four fixed roots + A->>D: Create filesystem and five fixed roots A->>D: Write resource and borrowed /uploads entry + R->>D: Resolve concrete skill versions + R->>D: Atomically replace /skills archive projections R->>E: Create Sandbox R->>D: Resolve trusted filesystem scope R->>R: Issue filesystem RW and readonly tokens R->>E: Write 0600 rclone config + R->>E: Remove legacy skill symlink and create mountpoint R->>E: Start fixed rclone binary loop Every 200ms, up to 20s R->>E: Files.Exists(/tmp/rclone-mounts/ready) @@ -187,11 +193,13 @@ Provider Sandbox 创建前的失败会停止 Environment Work,且不会创建 - `filestore_filesystems`:保存自身的内部 bigint ID、稳定 UUID、workspace 内的外部 ID;组织、工作区、public session、可选 code session 与创建 API key 均以稳定 UUID 绑定,避免租户搬迁或跨库合并时依赖源库 identity 值。 - `filestore_entries`:统一保存 file/directory、规范化绝对路径、parent path、响应元数据、hash、TTL 和不可变 S3 object reference;Session File resource 的借用 entry 另保存源 File UUID 以及不可由 HTTP metadata 设置的 ownership 列。组织、工作区、filesystem 及可选创建者引用均保存对应 UUID,不冗余保存其他表的 identity 或 filesystem external ID。 +迁移 `00032_add_filestore_skill_archives.sql` 新增 `filestore_skill_archives`。每行把一个 Session filesystem 的 `/skills/` 固定映射到一个已解析的 catalog version zip;它只保存稳定引用和对象元数据,不拥有对象、不写成员 entry,也不计入 Filestore 容量。迁移同时为历史活动 filesystem 补齐 `/skills` 根。`00033_validate_filestore_skill_archive_checksum.sql` 把 archive SHA-256 持久化约束收紧为 64 位小写十六进制。 + 迁移 `00019_add_workspace_storage_usage.sql` 新增 `workspace_storage_usage`。它按工作区分别保存 Files API 与 Filestore 的有效字节数,是配额判定的事务型投影,不是最终文件事实来源;迁移会从两类文件记录建立一次基线,后续由资源写事务按增量维护。 迁移 `00023_provision_session_filesystems.sql` 建立“同一 Session 只能拥有一个有效 filesystem”的唯一部分索引,并为历史未软删除的 Session 回填缺失记录。创建索引前会检查历史重复;发现同一 Session 存在多个有效 filesystem 时迁移直接中止,不猜测应保留哪一条。迁移 `00024_use_uuid_filestore_entry_references.sql` 进一步把 entry 的组织、工作区、filesystem 和创建者引用改为稳定 UUID;旧引用存在孤立或租户错配时同样中止迁移。迁移 `00026_validate_filestore_filesystem_reference_scopes.sql` 在最终 UUID schema 上补验 filesystem 的组织、工作区、Session、Code Session 与 API Key 归属链,弥补早期回填只核对主键和 external ID 的不足。迁移 `00027_add_filestore_entry_management.sql` 在短事务中新增内部 ownership 列及 `NOT VALID` 形状约束,`00028_validate_filestore_entry_management.sql` 再以较弱锁单独验证历史行;两列必须成对为空或成对非空。迁移 `00029_add_filestore_file_references.sql` 新增 `source_file_uuid`、活动引用索引、每个 Session resource 唯一活动 entry 约束,并为历史活动 filesystem 补齐四个固定根目录;已有同名 entry 只有在它确实是 `parent_path=/` 的普通目录时才会复用,否则迁移中止。借用 entry 允许没有 Files API 未提供的 MD5,但必须与非过期的 `session_file_resource` 管理关系双向对应。`00030_validate_filestore_file_references.sql` 再单独验证放宽后的 blob 形状和 File reference 形状两个 `NOT VALID` 约束。回滚 `00029` 时只要仍有借用引用就明确失败;四个普通目录 row 会保留,因为 schema 没有把迁移回填目录与原有用户目录做额外标记,盲目删除会破坏数据。所有这些引用都由应用维护,不增加 PostgreSQL 外键。 -根目录 `/` 是由 filesystem 合成的虚拟目录,不写 marker row 或 S3 marker object;四个固定一级目录是真实 directory entry,由 filesystem 创建事务写入,历史活动 filesystem 通过迁移补齐。文件系统和目录节点的归属都使用 `organization_uuid`、`workspace_uuid`、`filesystem_uuid` 等稳定引用;entry 的创建者审计 UUID 直接继承已经过租户链校验的 filesystem 归属,不再从 Filestore 请求构造空的内部主键 actor。请求进入数据库后仍可使用当前库内部 ID 取得工作区用量锁与 filesystem 锁,但 entry 的持久化与热查询只使用 UUID 边界。schema 不创建 PostgreSQL 外键;源 File UUID 的完整性由同事务行锁、删除守卫和 E2E 测试维护。 +根目录 `/` 是由 filesystem 合成的虚拟目录,不写 marker row 或 S3 marker object;五个固定一级目录是真实 directory entry,由 filesystem 创建事务写入,历史活动 filesystem 通过迁移补齐。`/skills` 的后代节点例外:它们由 `filestore_skill_archives` 和 zip central directory 合成,不写 `filestore_entries`。文件系统和目录节点的归属都使用 `organization_uuid`、`workspace_uuid`、`filesystem_uuid` 等稳定引用;entry 的创建者审计 UUID 直接继承已经过租户链校验的 filesystem 归属,不再从 Filestore 请求构造空的内部主键 actor。请求进入数据库后仍可使用当前库内部 ID 取得工作区用量锁与 filesystem 锁,但 entry 的持久化与热查询只使用 UUID 边界。schema 不创建 PostgreSQL 外键;源 File UUID 的完整性由同事务行锁、删除守卫和 E2E 测试维护。 文件对象 key 固定为: @@ -205,7 +213,7 @@ workspaces/{workspaceUUID}/filestores/{filesystemUUID}/blobs/{blobUUID} public Session 是 filesystem 的生命周期归属者;Code Session 只是可重建的执行实例,调度、重试或替换 Code Session 都复用同一个 filesystem。因此新建记录的 `code_session_uuid` 固定为 `NULL`,按 Session 查询 filesystem 时也不使用 Code Session 作为所有权条件。 -普通 Session 与 Deployment Session 最终都进入共享的 `insertSessionSQLXTx`。Session 行写入后,事务立即通过 `INSERT ... SELECT` 解析并保存 `organization_uuid`、`workspace_uuid`、`session_uuid` 和 `created_by_api_key_uuid`,创建四个固定一级目录,再继续写 Thread、Resources、File resource 引用 entry 与 EnvironmentWork;任一步失败都回滚整个 Session 图。 +普通 Session 与 Deployment Session 最终都进入共享的 `insertSessionSQLXTx`。Session 行写入后,事务立即通过 `INSERT ... SELECT` 解析并保存 `organization_uuid`、`workspace_uuid`、`session_uuid` 和 `created_by_api_key_uuid`,创建五个固定一级目录,再继续写 Thread、Resources、File resource 引用 entry 与 EnvironmentWork;任一步失败都回滚整个 Session 图。 filesystem external ID 的格式为 `claude_chat_<24 位 Base62>`。生成器使用 `crypto/rand`,只接受小于 248 的随机字节,再以 `% 62` 映射字符;248 是不超过 256 的最大 62 倍数,因此不会产生取模偏差。24 位 Base62 约有 143 bit 熵、约 `1.04 × 10^43` 种组合;即使生成十亿个 ID,理论碰撞概率也约为 `4.8 × 10^-26`。随机性只降低碰撞概率,数据库仍是最终裁决者: diff --git a/docs/design/be/managed-agent-skills-runtime.md b/docs/design/be/managed-agent-skills-runtime.md index 82d52b9c4..a316edc04 100644 --- a/docs/design/be/managed-agent-skills-runtime.md +++ b/docs/design/be/managed-agent-skills-runtime.md @@ -1,149 +1,136 @@ # Managed Agent Skills Runtime -## 背景 +## 背景与目标 -Managed Agents API 已经支持 `skills` 字段和 Skills API: +Managed Agents API 的 agent snapshot 只保存 `{type, skill_id, version}` 引用。custom skill 和 +built-in skill 的每个具体版本都已经是一个规范化的不可变 zip,并由 catalog version row +记录对象存储 bucket、key、大小、SHA-256 和顶层目录。 -- custom skill 上传时会被规范化为单顶层目录 zip,并存入对象存储。 -- built-in skill(`source: "anthropic"`)由管理员 seed 到 `builtin_skills` / `builtin_skill_versions`,archive 存对象存储;运行时按 DB catalog 解析,不再依赖本地 `SKILLS_BUILTIN_DIR`。 -- agent 只保存 `{type, skill_id, version}` 引用,并把它们写入 agent version / session snapshot。 +Claude Code 需要在本地 discovery 目录中看到解包后的目录树。运行时因此把 zip 内容作为 +Filestore 的虚拟只读目录直接映射到 `/root/.claude/skills`,不再建立 E2B skill volume, +也不再由 Runner 或 Environment Manager 预热、复制或解压文件。 -Claude Code 的 skill 发现边界仍然是本地文件系统目录。Managed Agents 运行时的目标不是让 Claude Code 读取 API/DB,而是给 sandbox 提供一个稳定的 `/mnt/skills` 视图,由 `environment-manager` 负责把其中的 skill archives 解压到 sandbox 内的统一目录。 +## 启动流程 -## 运行时挂载策略 +Environment Runner 在创建 cloud managed-agent Sandbox 前完成: -Environment runner 在启动云端 managed agent session 时执行以下步骤: +1. 从 Session 的 agent snapshot 严格解析 `skills[]`。 +2. `type=anthropic` 从 `builtin_skills` / `builtin_skill_versions` 解析版本;`type=custom` + 在当前 workspace 内从 `skills` / `skill_versions` 解析版本。 +3. `latest` 在启动时解析为具体 active version row。后续 catalog 的 latest 变化不会改变 + 已启动 Session 的视图。 +4. 在一只 `sqlx.Tx` 中锁定 Session filesystem 和 namespace,确保 `/skills` 固定根存在, + 并原子替换该 filesystem 的 `filestore_skill_archives` 投影集合。 +5. 创建 Sandbox 后,Runner 删除遗留的 `/root/.claude/skills` 软链(如果它确实是软链), + 创建同名目录,再启动 rclone-filestore 的五个固定 mount。 +6. `/skills` 使用只读 Filestore Token,直接挂载到 `/root/.claude/skills`。rclone ready + 后才启动 Environment Manager;Environment Manager 不再处理 skill。 -1. 从 session 的 agent snapshot 读取受信任的 MCP/Skills 输入;当 Environment 为 limited 且 `allow_mcp_servers=true` 时,严格解析 MCP HTTP server hosts,并在任何 Provider `Resolve` 之前用当前解析结果覆盖专用 `mcp_allowed_hosts` work metadata。开关关闭或网络为 unrestricted 时不读取 Session MCP 配置,直接以空数组覆盖该字段;这样所有状态转换都会清除旧值。缺少 session work 身份、畸形 snapshot(包括 JSON `null`)或非空 MCP URL 都使启动 fail-closed;其他 managed-agent metadata key 不参与网络授权。 -2. 对更新后的 environment work 调用 runtime provider `Resolve`,得到 sandbox template、网络策略和基础 env/metadata;E2B Adapter 只在 Environment 开关开启时消费上述专用 MCP host key。 -3. 从同一 agent snapshot 读取 `skills[]`。 -4. 对 `type=anthropic`,从 `builtin_skills` / `builtin_skill_versions` 读取 archive 元数据;`latest` 解析到当前 `builtin_skills.latest_version` 对应的 version row。 -5. 对 `type=custom`,按 workspace scope 从 DB 解析 `latest` 或指定版本,并读取 skill version 元数据;`latest` 通过单个 join 查询解析到当前 active version row,避免在读取 `skills.latest_version` 和读取 `skill_versions` 之间产生 torn read。 -6. 生成确定性的 `manifest.json`,其中列出每个 skill 的 source、id、resolved version、directory、filename、sha256 和 size。`requested_version` 不进入 manifest/hash,因此 `latest` 与同一个 resolved version 的显式引用会复用同一个 volume。 -7. 用完整 manifest SHA256 生成 E2B volume 名称;如果同 hash volume 已有 `.ready` 标记,且 marker 内容等于完整 manifest SHA256,则直接复用,不读取对象存储 archive。 -8. volume miss 时才按 manifest 顺序逐个读取对应 zip archive,并校验 archive 大小、checksum、解压总大小、单顶层目录和顶层 `SKILL.md`,然后写入该 volume;runner 不会把所有 archive 同时预载到内存。 -9. 把 mount 信息写入 environment work metadata,sandbox create 时用第 2 步得到的 resolution 和当前 work metadata 挂载到 `/mnt/skills`。 -10. Session File resources 已由 resource 写事务维护为对应 `filesystem_id` 下的 `/uploads` 数据库 entry;runner 不扫描、调和或复制这些文件。runner 先创建 E2B Sandbox,再签发绑定完整 filesystem 写权限的读写 Token 与只读 Token,启动固定 rclone-filestore 四挂载并等待 ready,随后最多重试三次删除临时 Token 配置。`/uploads` 整体只读挂载到 `/mnt/session/uploads`,无需逐文件投影。 -11. runner 标记 Sandbox running、发送 Environment Work heartbeat,再创建 local Code Session、写入 `environment-manager` stdin 并启动 environment-manager;启动失败会撤销刚创建的 Code Session。Environment Manager 启动成功后,Session 与 Environment Work 的 runtime metadata 在一个数据库事务中发布。不再把 skill zip 写进 sandbox 临时目录,也不在启动 shell 中解压 skill。 +每条 `filestore_skill_archives` row 对应一个具体 skill version zip,保存: -`/mnt/skills` 的约定视图: +- organization、workspace、filesystem 的稳定 UUID; +- source 和具体 skill version UUID; +- 唯一虚拟目录 `/skills/`; +- archive 的 bucket、key、size 和 SHA-256。 -```text -/mnt/skills/ - manifest.json - anthropic__xlsx__builtin__.zip - custom__skill_...__1__.zip - .ready -``` - -`environment-manager` 会在 environment 初始化前把 Claude Code 的 skill discovery 目录软链到 `/workspace/skills`,再在 Claude Code 启动前把 `/mnt/skills/*.zip` 解压到 `/workspace/skills`。`manifest.json` 和 `.ready` 当前由 runner/E2B volume 侧用于确定性缓存和冷启动复用;`.ready` 内容必须是完整 manifest SHA256。environment-manager 暂不强依赖 manifest/checksum。 - -同样的顺序也适用于 MCP 网络策略:runner 从 Session Agent Snapshot 提取 MCP HTTP server hosts,并在 Sandbox 创建前通过命名 network metadata schema 用当前 host 集合覆盖 Environment Work metadata;当前集合为空、MCP 开关关闭或网络变为 unrestricted 时都必须写入空数组,以清除任何历史或伪造值。schema helper 在保留其他 work metadata key 的同时,对类型错误、`null` 或非法 host fail-closed,不再通过 `map[string]any` / `[]any` 静默跳过坏值。该 metadata 只是受信任的解析输入,不能自行扩大 Environment 权限;仅当 Environment 配置为 `networking.type=limited` 且 `allow_mcp_servers=true` 时,E2B Adapter 才把这些 hosts 加入本次 Sandbox 的 `AllowOut`。Code Session upstream proxy 同样受该开关约束,但会在每次 CONNECT 时从 Session Agent Snapshot 现场提取 hosts;`allow_mcp_servers=false` 时,即使 metadata 或 snapshot 中存在 MCP hosts 也不得放行。E2B `AllowOut` 是 Sandbox 创建时的快照,proxy 则读取当前配置,因此 Environment 收紧对存活 Sandbox 的下一次代理 CONNECT 即时生效。 - -## 当前流程 +同一 filesystem 内,虚拟目录和 `source + skill_version_uuid` 都唯一。Snapshot 中两个 skill +若声明相同目录但不是同一具体版本,启动失败,不能让后一个静默覆盖前一个。 ```mermaid -flowchart TD - Z["Agent/deployment/skill version write"] --> ZA["Enqueue skill_prewarm job"] - ZA --> ZB["Skill prewarm worker resolves snapshots"] - ZB --> ZC["Prepare or reuse E2B skill volume"] - A["Agent snapshot contains MCP and skills refs"] --> B["Environment runner loads session snapshot"] - B --> BA["Patch switch-gated MCP hosts before Resolve"] - BA --> AA["Resolve sandbox template and network"] - AA --> C["Resolve skill refs by workspace scope"] - C --> D{"Skill source"} - D -->|"anthropic"| E["Read builtin_skill_versions metadata"] - D -->|"custom"| F["Read skill version DB metadata"] - E --> H["Build deterministic manifest.json"] - F --> H - H --> I["Compute manifest hash"] - I --> J{"E2B volume exists with .ready?"} - J -->|"yes"| K["Reuse cached skill volume without archive read"] - J -->|"no"| L["Load and validate zip archives"] - L --> LA["Write manifest.json and zip archives, then .ready"] - K --> M["Patch work metadata with managed_agent_skills_mount"] - LA --> M - M --> N["Create sandbox with precomputed network and /mnt/skills volume mount"] - N --> NA["Start rclone-filestore and wait ready"] - NA --> NB["Delete token config, retry up to 3 times"] - NB --> NC["Mark running and heartbeat"] - NC --> ND["Create local Code Session"] - ND --> O["Write environment-manager stdin"] - O --> P["Start environment-manager"] - P --> PA["Atomically publish Session and Work runtime metadata"] - PA --> Q["Symlink Claude skill dirs to /workspace/skills"] - Q --> R["environment-manager extracts /mnt/skills/*.zip to /workspace/skills"] +flowchart LR + A["Session agent snapshot"] --> B["Resolve concrete catalog versions"] + B --> C["Replace filestore_skill_archives in one transaction"] + C --> D["Filestore /skills virtual view"] + E["Immutable zip objects"] --> D + D --> F["rclone readonly mount"] + F --> G["/root/.claude/skills"] + G --> H["Claude Code discovery"] ``` -## 异步 prewarm - -Agent create/update、deployment create/update 和 custom skill version create 会 best-effort 写入 `jobs.type = 'skill_prewarm'` outbox。API 请求只做轻量 enqueue,enqueue 使用短超时 context;失败只记录日志,不影响主请求响应。 - -enqueue 的短超时属于 handler/resource 边界,而不是 `skillprewarm.Enqueuer` 的内部策略。这样 agents、deployments、skills handler 只依赖本包定义的最小接口,只有 `internal/api` 组合根依赖 `internal/skillprewarm` 具体实现。当前三处 handler 使用相同的 3 秒 best-effort 超时;如果后续需要集中配置,应抽到中性的运行时 policy/config,而不是让资源 handler 重新依赖 prewarm 实现包。 - -触发条件: - -- agent create:`skills` 非空时 enqueue snapshot。 -- agent update:仅当 `skills` 变化且更新后非空时 enqueue snapshot。 -- deployment create:`agent_snapshot.skills` 非空时 enqueue snapshot。 -- deployment update:仅当 `agent_snapshot.skills` 变化且更新后非空时 enqueue snapshot;agent 的 name、description、tools、metadata 等非 skill 变化不会触发。 -- custom skill version create:enqueue fanout。 - -`skills` 的 enqueue 判定只把非空数组视为需要预热。缺失 `skills`、`skills:null` 和 `skills:[]` 都视为无 skills,不触发 prewarm;实际 session 启动时仍由 runtime resolver 解析 snapshot,作为最终正确性兜底。 - -`skill_prewarm` job 分两类: - -- `snapshot`:包含 agent snapshot、source、source_id 和 trigger;worker 复用 runtime resolver 解析 `skills[]`,再调用同一套 `PrepareSkillMount` 创建或复用 manifest hash 对应的 E2B volume。 -- `fanout`:由 custom skill version 创建触发,分页扫描引用该 custom skill 且 version 为 `latest` 或缺省的 agent/deployment snapshot,再生成 `snapshot` jobs。payload 中包含触发的 skill version,因此同一 agent snapshot 在不同 latest version 发布后仍会重新预热。 - -prewarm 只是冷启动优化,不是正确性边界。即使 job 丢失、失败或延迟,session 启动路径仍会在 sandbox create 前执行 lazy prepare,并把最终 mount metadata 写入 environment work。 - -session 启动路径中的 skill resolution/mount prepare 是正确性边界,而不是 best-effort:如果 snapshot 引用的 skill 不存在、版本不可用、目录冲突、custom skill 对象存储不可用或 volume prepare 失败,runner 必须失败该 work,不能静默跳过单个 skill 后继续启动。这样用户不会得到一个看似成功但缺失声明能力的 Claude Code session。 - -worker 失败时沿用 jobs 表的 retry 状态机,并在 payload 中记录 `last_error` 和 `last_error_at` 供排查;达到最大尝试次数后进入 `failed`。 - -worker complete/fail 状态推进必须校验当前 worker 仍持有 job lease:`status = 'running'` 且 `locked_by` 等于本 worker。若更新 0 行,视为 lease 已被其他 worker 接管或 job 状态已变化,当前 worker 不再覆盖 job 状态。 - -## 版本与组合语义 +## 虚拟目录合同 -`version:"latest"` 在 session launch 时解析成当时的 active latest。已经启动的 Claude Code session 使用当次 manifest 对应的挂载视图,不会因为后续 skill 上传新版本而变化。 +Filestore 对外呈现的是 archive 成员,而不是 zip 文件本身: -多个 skill 组合由 manifest hash 唯一标识,因此相同 resolved skill 组合可复用同一个 volume,减少重复写入。manifest hash 不包含调用方请求的 `latest`/显式版本文本,只包含最终 resolved version 和 archive 元数据。若多个 archive 使用同一个顶层目录,runner 会在启动前失败;Claude Code 的 filesystem discovery 以目录为单位,目录冲突无法无歧义表达。 - -## 实现边界与后续优化 - -runtime resolver 返回的是 skill 元数据和懒加载 archive loader。`BuildMountManifest` 只能读取元数据,不允许触发 archive 下载;`PrepareSkillMount` 命中已有 `.ready` volume 时也必须直接返回,保持零对象存储 archive IO。只有 volume miss 写入阶段才调用 `RuntimeSkill.LoadArchive`,并再次校验 archive 大小、checksum、解压总大小和目录结构。写入阶段按 manifest 顺序逐个 load/write archive,避免多个大 zip 同时驻留在内存中。 - -启动组合根创建 runtime resolver,并通过 `RunnerDependencies` 把它作为 Runner 的必需依赖注入;Runner 不再自行用配置和 object store 组装 resolver,也不存在缺少 resolver 的半初始化构造入口。built-in 与 custom skill 的元数据都来自 DB;两者的 archive 都需要对象存储。如果 managed agent session snapshot 包含任意 skill,而 resolver 未配置 object store,runner 必须在 volume miss 的 archive load 阶段显式失败并停止该 work,不能继续创建缺失 `/mnt/skills` 的 sandbox。没有 skills 的 session 不会读取 archive,因此仍可使用未绑定 object store 的 resolver。 - -`RuntimeSkill.LoadArchive` 返回的字节归调用方只读使用;当前 runner/E2B 写入路径不会修改返回值,因此不再额外做 defensive copy。若以后新增会修改 archive bytes 的调用方,必须在该调用方本地复制,或把 loader contract 升级为显式独占所有权。 - -`RuntimeSkill.archiveLoader` 是私有字段,导出的 `RuntimeSkill` 字面量如果没有 loader,只适合测试 fake 或已经带 `Archive` 字节的场景;真实 custom/built-in skill 应由 `RuntimeResolver` 构造。后续若有外部包需要安全构造 loader,应优先增加 constructor 或 option setter,而不是直接暴露字段。 - -E2B volume 当前通过 `ListVolumes` 做 name 到 id 的映射,create 失败后会再 list 一次处理并发创建。正确性依赖完整 manifest hash volume name 和 `.ready` marker 内容校验,不依赖本地缓存。若 workspace volume 数量变大,可优先使用 SDK 的按名称查询能力;如果 SDK 暂无该能力,再在 provider 内增加短 TTL name cache,并保留 create-conflict 后重新查询的兜底路径。 - -## API 与数据模型 - -本实现不新增公开 Skills API。built-in skill catalog 的 DB schema(`builtin_skills` / `builtin_skill_versions`)由 builtin seed 功能提供;本运行时路径只消费这些表和对象存储中的 archive。 - -上传后的 custom skill zip 已经存入对象存储,agent/session snapshot 已经持久化 skill 引用;运行时只需要解析这些引用并生成 sandbox mount metadata。异步 prewarm 复用现有 `jobs` 表作为 outbox,不改变 agent、deployment、session 或 skill 的公开数据模型。 - -## Environment Manager 契约 - -Runner 只允许可解析且规范化后非空的 `github_repository.mount_path` 成为 Environment Manager 的 `environment.cwd`;同一个命名 DTO 也为 Environment Manager source 提供规范化后的 repository URL 和 mount path,避免 clone 目标与 cwd 分叉。`file`、`memory_store`、未知 resource 类型以及畸形或空的 repository payload 都不参与工作目录选择;没有合法候选时回退到 `/home/user`。存在多个 repository 时选择最早附加到 Session 的活动 repository,按 `created_at`、内部递增 `id` 依次判定,不能依赖资源列表当前的返回顺序。后续若增加显式 workdir resource,必须在同一选择器中定义高于 repository 的明确优先级。 - -`environment-manager` 需要在 Claude Code executor 启动前处理 `/mnt/skills`: +```text +/skills/ + pdf/ + SKILL.md + references/ + forms.md + xlsx/ + SKILL.md +``` -- 如果 `/mnt/skills` 不存在或没有 `*.zip`,视为无 managed agent skills。 -- 当前只扫描 `*.zip`,不读取 `manifest.json`,也不校验 `.ready`。 -- 每个 archive 仍需是单顶层目录并包含顶层 `SKILL.md`,同时拒绝路径逃逸、symlink 和解压大小超限。 -- environment 初始化前,将当前用户和 `/home/claude` 的 `.claude/skills` 目录软链到 `/workspace/skills`。 -- 解压目标为 `/workspace/skills`,因此 Claude Code 通过原 discovery 目录即可看到 mounted skills。 -- manifest/checksum 校验后置;需要时可在 environment-manager 中升级为读取 `manifest.json` 并校验 sha256、directory 和 ready marker。 +Sandbox 中同一棵树直接位于: -## 非目标 +```text +/root/.claude/skills/ + pdf/SKILL.md + xlsx/SKILL.md +``` -- 不在创建 agent 时为每个 agent 打包组合 zip。 -- 不提前安装所有 skills。 -- 不把 DB 层改成对象存储读取方。 -- 不要求 sandbox 内部知道 custom skill 的 API/DB 身份;sandbox 只看到 `/mnt/skills` 文件视图。 +`/skills` 是真实的固定一级 directory entry;每个 skill 目录及其成员是根据投影和 zip +内容合成的虚拟节点,不写入 `filestore_entries`,不复制对象,也不计入 `filestore_bytes`。 +虚拟文件 UUID 由 filesystem、source、具体 version UUID 和成员路径确定,Runner 重试不会 +改变同一节点的身份。 + +List、metadata 和 ranged read 都由 Filestore 服务实现。对 `/skills` 本身、其后代,以及 +以 `/skills` 为 source 或 destination 的任意 mutation 均返回 `403 permission_denied`。 +HTTP 只读 Token、rclone `readonly=true`、目录权限 `0555` 和文件权限 `0444` 共同构成 +Sandbox 的只读边界。 + +## archive 校验与缓存 + +Filestore 在第一次访问某个具体 archive 时按需下载并建立内存索引: + +- 压缩 archive 最大 8 MiB,并同时校验 DB size 和 SHA-256; +- 必须是有效 zip,且只有与投影目录一致的单一顶层目录; +- 顶层必须包含 `SKILL.md`; +- 拒绝绝对路径、反斜杠、NUL、空段、`.`、`..`、重复路径、文件/目录冲突和 symlink; +- 解压大小按 archive header 累加并限制为 500 MiB; +- 读取单个成员时流式解压;range offset 通过丢弃前缀实现,不把解压结果整体缓存。 + +进程内以 `bucket + key + sha256` 为 key 使用 64 MiB 有界 LRU 缓存压缩 archive 和目录索引。 +投影仍是每次请求的授权事实来源;Session 投影删除后,缓存中残留的字节无法再通过 +Filestore 路径访问。 + +## 生命周期与对象保留 + +Session filesystem 删除后沿用现有有界 cleanup job。最后一批文件和目录退休时,同一事务 +删除该 filesystem 的 `filestore_skill_archives` rows;这些 row 只是借用 catalog archive, +不会产生 Filestore 对象清理任务或容量扣减。 + +删除 custom skill/version 或用 `seed-builtin-skills --prune` 软删除 built-in catalog row 时, +不立即删除 archive 对象,也不创建通用 `object_cleanup` job。原因是已经启动的 Session +可能仍通过具体 version UUID 投影借用该对象。物理 GC 必须先确认没有任何活动投影引用, +属于独立的 reference-aware catalog GC;当前实现选择保留对象,优先保证运行中 Session +的快照稳定性。 + +## 移除的旧链路 + +本方案删除以下运行时机制: + +- `skill_prewarm` job、fanout、worker 和启动组合; +- E2B skill volume、manifest hash、`manifest.json` 和 `.ready`; +- Environment Work 中的 `managed_agent_skills_mount` metadata; +- Runner 启动时下载/校验 zip 并写 volume; +- `/mnt/skills`、`/workspace/skills` 解压目录,以及 Claude skill discovery 软链; +- Environment Manager 的 managed-agent skill 解压职责。 + +迁移 `00032_add_filestore_skill_archives.sql` 创建投影表、为历史活动 filesystem 补齐 +`/skills` 根并清除遗留的 `skill_prewarm` jobs。迁移 +`00033_validate_filestore_skill_archive_checksum.sql` 把持久化 checksum 收紧为 64 位 +小写十六进制。两张 catalog version 表仍是 archive 所有权来源,投影表不创建 PostgreSQL +外键。 + +## 验收重点 + +- resolver 只读取 DB metadata,不在 Session 启动路径下载 archive; +- `latest` 被钉住为具体 version,投影替换是全量且原子的; +- `/skills` list、recursive list、metadata 和 ranged read 返回 archive 成员; +- checksum、路径穿越、缺少 `SKILL.md` 等损坏 archive fail closed; +- 所有 `/skills` mutation 被拒绝; +- rclone 第五个 mount 直达 `/root/.claude/skills`,启动前只移除遗留软链; +- Runner 不写 legacy mount metadata,E2B runtime 不创建 skill volume; +- catalog soft delete/prune 不破坏活动 Session 投影; +- Session filesystem cleanup 会删除投影 row,但不删除借用的 catalog object。 diff --git a/docs/design/be/runtime-configuration.md b/docs/design/be/runtime-configuration.md index 860e5652c..2e30ad7e3 100644 --- a/docs/design/be/runtime-configuration.md +++ b/docs/design/be/runtime-configuration.md @@ -106,7 +106,7 @@ Docker Compose 同样只挂载一份完整 YAML,不再通过 `.env` 插值业 Cloud Session 的固定 Filestore 挂载也使用 `code_session.sandbox_api_base_url` 作为 rclone `service_url`,因此启用 Environment Runner 时该地址必须同时能从 E2B Sandbox 访问 Filestore HTTP 路由。Runner 通过 E2B Files API 每 `200ms` 探测 `/tmp/rclone-mounts/ready`,最长 `20s`;这两个值是运行时合同,不提供 YAML 配置。 -`rclone-filestore` 的路径不是配置项。E2B 镜像合同固定要求可执行文件位于 `/opt/rclone/rclone-filestore`;缺失或不可执行会使该次 Sandbox 启动失败。四个 source、destination、cache、权限、ready/config/state 路径同样属于版本化运行时合同,不能通过租户数据、Session resource 或 YAML 改写。 +`rclone-filestore` 的路径不是配置项。E2B 镜像合同固定要求可执行文件位于 `/opt/rclone/rclone-filestore`;缺失或不可执行会使该次 Sandbox 启动失败。五个 source、destination、cache、权限、ready/config/state 路径同样属于版本化运行时合同,不能通过租户数据、Session resource 或 YAML 改写。第五个 source 固定为 Filestore `/skills`,以只读方式直接挂载到 `/root/.claude/skills`;Runner 会在启动 mount 前删除该路径上的遗留软链。 默认 Docker Compose 是显式的本地开发配置:`env: dev`、`database.auto_migrate: true`,并省略 `code_session.jwt_signing_private_key_file`。Code Session 与 Filestore 各自使用 oma-server 进程级临时 Ed25519 密钥,服务重启会轮换信任并使此前签发的两类 JWT 失效。独立运行的 `cmd/filestore-token` 不会生成另一把临时密钥;手动签发必须为 CLI 与服务配置同一个持久化私钥文件,否则服务无法验证另一个进程签出的 token。生产环境中,两个签发器可读取同一份稳定的只读 Ed25519 私钥,但使用不同的 claims 与验证入口,不会互相代用。生产部署必须使用 `env: prod`、稳定的只读私钥路径并关闭自动迁移;缺少 JWT 私钥时启动边界会拒绝生产配置。 diff --git a/docs/design/be/skills-builtin-seed-and-storage.md b/docs/design/be/skills-builtin-seed-and-storage.md index 0bb0734a3..d43bf3eec 100644 --- a/docs/design/be/skills-builtin-seed-and-storage.md +++ b/docs/design/be/skills-builtin-seed-and-storage.md @@ -85,7 +85,7 @@ builtin-skills/{skill_id}/versions/{version}/{sha256}.skill - 缺省版本号由 archive 内容 sha 派生;生产导入推荐通过 `--versions` 显式指定平台版本号。 - 同一个 `skill_id + version + sha256` 重跑只刷新 catalog,不产生语义变化,并保留已存在 active version 的 `created_at`,避免版本排序被幂等重跑改变。 - 同一个 `skill_id + version` 但内容 sha 不同会返回冲突错误,管理员需要换新版本号。 -- `--prune` 软删除 DB 行,并 best-effort 删除对应对象;对象删除失败会记录日志但不阻塞 DB 软删除。 +- `--prune` 只软删除 DB row,不立即删除对应对象。已启动 Session 可能仍通过具体 version UUID 的 Filestore skill 投影借用该 archive;物理删除必须由后续 reference-aware catalog GC 在确认无活动投影后执行。 ## API 行为 diff --git a/internal/agents/handler.go b/internal/agents/handler.go index ec0a245d9..349e49026 100644 --- a/internal/agents/handler.go +++ b/internal/agents/handler.go @@ -1,7 +1,6 @@ package agents import ( - "context" "encoding/base64" "encoding/json" "errors" @@ -13,7 +12,6 @@ import ( "strings" "time" - "github.com/superduck-ai/open-managed-agents/internal/agentsnapshot" "github.com/superduck-ai/open-managed-agents/internal/auth" "github.com/superduck-ai/open-managed-agents/internal/config" "github.com/superduck-ai/open-managed-agents/internal/db" @@ -26,21 +24,15 @@ import ( ) const ( - maxAgentBodySize = 4 << 20 - skillPrewarmEnqueueTimeout = 3 * time.Second + maxAgentBodySize = 4 << 20 ) var customToolNamePattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,128}$`) type Handler struct { - cfg config.Config - db *db.DB - prewarm skillPrewarmSnapshotEnqueuer - router chi.Router -} - -type skillPrewarmSnapshotEnqueuer interface { - EnqueueSnapshot(ctx context.Context, workspaceID int64, snapshot json.RawMessage, source string, sourceID string, trigger string) error + cfg config.Config + db *db.DB + router chi.Router } type agentResponse struct { @@ -92,11 +84,7 @@ type agentReference struct { } func NewHandler(cfg config.Config, database *db.DB) *Handler { - return NewHandlerWithSkillPrewarm(cfg, database, nil) -} - -func NewHandlerWithSkillPrewarm(cfg config.Config, database *db.DB, prewarm skillPrewarmSnapshotEnqueuer) *Handler { - h := &Handler{cfg: cfg, db: database, prewarm: prewarm} + h := &Handler{cfg: cfg, db: database} router := chi.NewRouter() router.NotFound(notFound) router.MethodNotAllowed(notFound) @@ -173,7 +161,6 @@ func (h *Handler) create(w http.ResponseWriter, r *http.Request) { httpapi.WriteError(w, r, httpapi.NewError(http.StatusInternalServerError, "api_error", "Could not create agent")) return } - h.enqueueSkillPrewarm(r.Context(), principal.WorkspaceID, created, "agent_create") httpapi.WriteJSON(w, http.StatusOK, responseFromAgent(created)) } @@ -379,9 +366,6 @@ func (h *Handler) update(w http.ResponseWriter, r *http.Request, agentID string) httpapi.WriteError(w, r, httpapi.NewError(http.StatusInternalServerError, "api_error", "Could not update agent")) return } - if !agentsnapshot.SameRawJSON(current.Skills, updated.Skills) { - h.enqueueSkillPrewarm(r.Context(), principal.WorkspaceID, updated, "agent_update") - } httpapi.WriteJSON(w, http.StatusOK, responseFromAgent(updated)) } @@ -1261,22 +1245,6 @@ func responseFromAgent(agent db.Agent) agentResponse { } } -func (h *Handler) enqueueSkillPrewarm(ctx context.Context, workspaceID int64, agent db.Agent, trigger string) { - if h == nil || h.prewarm == nil || !agentsnapshot.SkillsRawHasEntries(agent.Skills) { - return - } - snapshot, err := agentsnapshot.FromAgent(agent) - if err != nil { - log.Printf("build agent skill prewarm snapshot agent_id=%s trigger=%s: %v", agent.ExternalID, trigger, err) - return - } - enqueueCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), skillPrewarmEnqueueTimeout) - defer cancel() - if err := h.prewarm.EnqueueSnapshot(enqueueCtx, workspaceID, snapshot, "agent", agent.ExternalID, trigger); err != nil { - log.Printf("enqueue agent skill prewarm agent_id=%s trigger=%s: %v", agent.ExternalID, trigger, err) - } -} - func (h *Handler) isOfficialSDKFixtureID(principal auth.Principal, agentID string) bool { return principal.APIKeyExternalID == h.cfg.SDKFixtures.APIKeyExternalID && agentID == h.cfg.SDKFixtures.AgentID } diff --git a/internal/api/server.go b/internal/api/server.go index 1ab0e77f0..d4343e730 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -29,7 +29,6 @@ import ( "github.com/superduck-ai/open-managed-agents/internal/platformauth" "github.com/superduck-ai/open-managed-agents/internal/platformsession" sessionsapi "github.com/superduck-ai/open-managed-agents/internal/sessions" - "github.com/superduck-ai/open-managed-agents/internal/skillprewarm" skillsapi "github.com/superduck-ai/open-managed-agents/internal/skills" "github.com/superduck-ai/open-managed-agents/internal/storage" vaultsapi "github.com/superduck-ai/open-managed-agents/internal/vaults" @@ -85,7 +84,6 @@ func NewServer(deps ServerDeps) *Server { platformStore = platformsession.NewMemoryStore() } codeSessionService := codesessions.NewServiceWithCredentials(deps.DB, deps.CodeSessionCredentials) - skillPrewarmEnqueuer := skillprewarm.NewEnqueuer(deps.DB) filestoreService := deps.FilestoreService if filestoreService == nil { filestoreService = filestoreapi.NewService(deps.Config, deps.DB, deps.ObjectStore) @@ -97,10 +95,10 @@ func NewServer(deps ServerDeps) *Server { platformStore: platformStore, filestoreCredentials: deps.FilestoreCredentials, admin: adminapi.NewHandler(deps.Config, deps.DB), - agents: agents.NewHandlerWithSkillPrewarm(deps.Config, deps.DB, skillPrewarmEnqueuer), + agents: agents.NewHandler(deps.Config, deps.DB), batch: batches.NewHandler(deps.Config, deps.DB, deps.ObjectStore), codeSessions: codesessions.NewHandler(deps.Config, codeSessionService), - deployments: deploymentsapi.NewHandlerWithSkillPrewarm(deps.Config, deps.DB, skillPrewarmEnqueuer), + deployments: deploymentsapi.NewHandler(deps.Config, deps.DB), deploymentRuns: deploymentsapi.NewRunsHandler(deps.Config, deps.DB), envs: environments.NewHandler(deps.Config, deps.DB), files: files.NewHandler(deps.Config, deps.DB, deps.ObjectStore), @@ -109,7 +107,7 @@ func NewServer(deps ServerDeps) *Server { messages: messagesapi.NewHandler(deps.Config), models: modelsapi.NewHandler(deps.Config.AnthropicUpstream), sessions: sessionsapi.NewHandler(deps.Config, deps.DB, codeSessionService), - skills: skillsapi.NewHandlerWithSkillPrewarm(deps.Config, deps.DB, deps.ObjectStore, skillPrewarmEnqueuer), + skills: skillsapi.NewHandler(deps.Config, deps.DB, deps.ObjectStore), vaults: vaultsapi.NewHandler(deps.Config, deps.DB), webhooks: webhooksapi.NewHandler(deps.Config.Webhook, deps.DB), } diff --git a/internal/db/filestore_cleanup.go b/internal/db/filestore_cleanup.go index 698f6799d..14550fed7 100644 --- a/internal/db/filestore_cleanup.go +++ b/internal/db/filestore_cleanup.go @@ -65,6 +65,11 @@ const ( and filesystem_uuid = :filesystem_uuid and kind = 'directory' and deleted_at is null ` + removeFilesystemSkillArchivesQuery = ` + delete from filestore_skill_archives + where workspace_uuid = :workspace_uuid + and filesystem_uuid = :filesystem_uuid + ` completeFilesystemCleanupBatchQuery = ` update jobs set status = :status, locked_by = null, locked_until = null, @@ -344,6 +349,9 @@ func (d *DB) ProcessLeasedFilestoreFilesystemCleanupJob( if _, err := namedExecContext(ctx, tx, retireFilesystemCleanupDirectoriesQuery, arguments); err != nil { return false, err } + if _, err := namedExecContext(ctx, tx, removeFilesystemSkillArchivesQuery, arguments); err != nil { + return false, err + } } status := "pending" diff --git a/internal/db/filestore_cleanup_sqlx_test.go b/internal/db/filestore_cleanup_sqlx_test.go index de4b3eaea..5b0207941 100644 --- a/internal/db/filestore_cleanup_sqlx_test.go +++ b/internal/db/filestore_cleanup_sqlx_test.go @@ -44,6 +44,7 @@ func TestFilesystemCleanupProcessingQueriesUseSQLXNamedParameters(t *testing.T) {"retire entry", retireFilesystemCleanupEntryQuery, 3}, {"files remain", filesystemCleanupFilesRemainQuery, 2}, {"retire directories", retireFilesystemCleanupDirectoriesQuery, 4}, + {"remove skill archives", removeFilesystemSkillArchivesQuery, 2}, {"complete batch", completeFilesystemCleanupBatchQuery, 6}, } diff --git a/internal/db/filestore_filesystems.go b/internal/db/filestore_filesystems.go index a8c22b742..f6955f6a6 100644 --- a/internal/db/filestore_filesystems.go +++ b/internal/db/filestore_filesystems.go @@ -262,10 +262,10 @@ func createFilestoreFilesystemWithGeneratedID( // 它先检查 external ID 和各个引用 UUID,再从数据库确认 Organization、Workspace、 // Session、可选 Code Session 和可选 API key 的归属关系。Session 和 Workspace // 必须仍然有效。校验通过后,函数会复用已有 filesystem 或创建新记录,并确保 -// /outputs、/uploads、/transcripts 和 /tool_results 四个固定根目录存在。 +// /outputs、/skills、/uploads、/transcripts 和 /tool_results 五个固定根目录存在。 // // 例如: -// - Session 尚无 filesystem:创建 filesystem 和四个根目录,返回 filesystem、 +// - Session 尚无 filesystem:创建 filesystem 和五个根目录,返回 filesystem、 // true、nil。 // - 使用相同 external ID 和 Session 重试:复用原记录并补齐可能缺失的根目录, // 返回 filesystem、false、nil。false 只表示本次没有新建 filesystem 记录。 @@ -409,17 +409,17 @@ func ensureProvisionedFilestoreRootsTx( return ensureFilestoreFixedRootsTx(ctx, tx, workspaceID, filesystem, now) } -// ensureFilestoreFixedRootsTx 在指定 filesystem 的数据库命名空间中确保四个固定根目录存在。 +// ensureFilestoreFixedRootsTx 在指定 filesystem 的数据库命名空间中确保五个固定根目录存在。 // -// 固定根目录是 /outputs、/uploads、/transcripts 和 /tool_results。rclone 会把这些 +// 固定根目录是 /outputs、/skills、/uploads、/transcripts 和 /tool_results。rclone 会把这些 // 路径挂载到 Sandbox,因此目录必须先存在于 Filestore 数据库中。每个路径都通过 // ensureFilestoreDirectoryTx 幂等处理:已有目录保持不变,缺失目录会被创建;如果 // 路径上是已过期文件,则会释放旧文件的存储归属并把该 entry 改成目录;其中由 // Filestore 拥有的对象还会进入清理队列。 // // 例如: -// - 新 filesystem 中还没有任何 entry:创建四个目录并返回 nil。 -// - 四个目录已经存在:不重复插入,直接返回 nil。 +// - 新 filesystem 中还没有任何 entry:创建五个目录并返回 nil。 +// - 五个目录已经存在:不重复插入,直接返回 nil。 // - /uploads 被一个未过期文件占用:返回 ErrFilestorePathExists,避免 rclone 把 // 文件路径当成目录挂载。 // diff --git a/internal/db/filestore_fixed_roots.go b/internal/db/filestore_fixed_roots.go index d7edc78b9..92decb06b 100644 --- a/internal/db/filestore_fixed_roots.go +++ b/internal/db/filestore_fixed_roots.go @@ -2,6 +2,7 @@ package db var filestoreFixedRootPaths = [...]string{ "/outputs", + "/skills", "/uploads", "/transcripts", "/tool_results", @@ -19,7 +20,7 @@ func filestoreFixedRootForPath(entryPath string) (string, bool) { // validateFilestoreDirectoryMoveRoots 拒绝会破坏固定顶层命名空间语义的目录移动。 // -// 它先判断源路径和目标路径是否位于 `/outputs`、`/uploads`、`/transcripts`、`/tool_results` +// 它先判断源路径和目标路径是否位于 `/outputs`、`/skills`、`/uploads`、`/transcripts`、`/tool_results` // 这些固定根之一。 // 它不允许把固定根目录本身改名或搬到别处。 // 它也不允许目录跨固定根移动、离开固定根,或从普通路径移入固定根。 diff --git a/internal/db/filestore_skill_archives.go b/internal/db/filestore_skill_archives.go new file mode 100644 index 000000000..e61fbe8a7 --- /dev/null +++ b/internal/db/filestore_skill_archives.go @@ -0,0 +1,284 @@ +package db + +import ( + "context" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "strings" + "time" + + "github.com/google/uuid" +) + +// FilestoreSkillArchive is one immutable catalog zip projected below +// /skills in a Session filesystem. +type FilestoreSkillArchive struct { + ID int64 + UUID string + ExternalID string + OrganizationUUID string + WorkspaceUUID string + FilesystemUUID string + Source string + SkillVersionUUID string + VirtualPath string + S3Bucket string + S3Key string + SizeBytes int64 + SHA256 string + CreatedAt time.Time + UpdatedAt time.Time +} + +// FilestoreSkillArchiveInput contains resolved skill-version metadata. The +// archive remains owned by the skill catalog and is never charged to or +// deleted with the Session filesystem. +type FilestoreSkillArchiveInput struct { + Source string + SkillVersionUUID string + Directory string + S3Bucket string + S3Key string + SizeBytes int64 + SHA256 string +} + +type filestoreSkillArchiveRow struct { + ID int64 `db:"id"` + UUID string `db:"uuid"` + ExternalID string `db:"external_id"` + OrganizationUUID string `db:"organization_uuid"` + WorkspaceUUID string `db:"workspace_uuid"` + FilesystemUUID string `db:"filesystem_uuid"` + Source string `db:"source"` + SkillVersionUUID string `db:"skill_version_uuid"` + VirtualPath string `db:"virtual_path"` + S3Bucket string `db:"s3_bucket"` + S3Key string `db:"s3_key"` + SizeBytes int64 `db:"size_bytes"` + SHA256 string `db:"sha256"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} + +var ( + filestoreSkillArchiveFilesystemQuery = filestoreFilesystemSelectSQL() + ` + where workspace_uuid = (select uuid from workspaces where id = :workspace_id) + and session_uuid = ( + select uuid + from sessions + where workspace_id = :workspace_id + and external_id = :session_external_id + and deleted_at is null + ) + and deleted_at is null + limit 1 + for update + ` + filestoreSkillArchiveDeleteQuery = ` + delete from filestore_skill_archives + where workspace_uuid = :workspace_uuid + and filesystem_uuid = :filesystem_uuid + ` + filestoreSkillArchiveInsertQuery = ` + insert into filestore_skill_archives ( + external_id, organization_uuid, workspace_uuid, filesystem_uuid, + source, skill_version_uuid, virtual_path, s3_bucket, s3_key, + size_bytes, sha256, created_at, updated_at + ) + values ( + concat('fsa_', replace(cast(gen_random_uuid() as text), '-', '')), + CAST(:organization_uuid AS uuid), + CAST(:workspace_uuid AS uuid), + CAST(:filesystem_uuid AS uuid), + :source, + CAST(:skill_version_uuid AS uuid), + :virtual_path, + :s3_bucket, + :s3_key, + :size_bytes, + :sha256, + :now, + :now + ) + ` + filestoreSkillArchiveListQuery = ` + select + id, + cast(uuid as text) as uuid, + external_id, + cast(organization_uuid as text) as organization_uuid, + cast(workspace_uuid as text) as workspace_uuid, + cast(filesystem_uuid as text) as filesystem_uuid, + source, + cast(skill_version_uuid as text) as skill_version_uuid, + virtual_path, + s3_bucket, + s3_key, + size_bytes, + sha256, + created_at, + updated_at + from filestore_skill_archives + where workspace_uuid = (select uuid from workspaces where id = :workspace_id) + and filesystem_uuid = ( + select uuid + from filestore_filesystems + where id = :filesystem_id + and workspace_uuid = ( + select uuid from workspaces where id = :workspace_id + ) + and deleted_at is null + ) + order by virtual_path, id + ` +) + +// ReplaceFilestoreSkillArchives atomically replaces the complete skill view for +// a public Session. Resolving "latest" happens before this call, so every row +// pins a concrete immutable version. +func (d *DB) ReplaceFilestoreSkillArchives( + ctx context.Context, + workspaceID int64, + sessionExternalID string, + archives []FilestoreSkillArchiveInput, +) error { + if d == nil || d.sql == nil { + return errors.New("database is unavailable") + } + for _, archive := range archives { + if err := validateFilestoreSkillArchiveInput(archive); err != nil { + return err + } + } + + tx, err := d.sql.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + filesystem, err := getFilestoreFilesystemSQLX(ctx, tx, filestoreSkillArchiveFilesystemQuery, map[string]any{ + "workspace_id": workspaceID, + "session_external_id": sessionExternalID, + }) + if err != nil { + return err + } + if _, err := namedExecContext(ctx, tx, provisionFilestoreNamespaceLockQuery, map[string]any{ + "filesystem_id": filesystem.ID, + }); err != nil { + return err + } + if err := ensureFilestoreFixedRootsTx(ctx, tx, workspaceID, filesystem, time.Now().UTC()); err != nil { + return err + } + if _, err := namedExecContext(ctx, tx, filestoreSkillArchiveDeleteQuery, map[string]any{ + "workspace_uuid": filesystem.WorkspaceUUID, + "filesystem_uuid": filesystem.UUID, + }); err != nil { + return err + } + + seenPaths := make(map[string]struct{}, len(archives)) + seenVersions := make(map[string]struct{}, len(archives)) + now := time.Now().UTC() + for _, archive := range archives { + virtualPath := "/skills/" + archive.Directory + versionKey := archive.Source + "\x00" + archive.SkillVersionUUID + if _, exists := seenPaths[virtualPath]; exists { + return fmt.Errorf("duplicate filestore skill path %q: %w", virtualPath, ErrDuplicate) + } + if _, exists := seenVersions[versionKey]; exists { + continue + } + seenPaths[virtualPath] = struct{}{} + seenVersions[versionKey] = struct{}{} + if _, err := namedExecContext(ctx, tx, filestoreSkillArchiveInsertQuery, map[string]any{ + "organization_uuid": filesystem.OrganizationUUID, + "workspace_uuid": filesystem.WorkspaceUUID, + "filesystem_uuid": filesystem.UUID, + "source": archive.Source, + "skill_version_uuid": archive.SkillVersionUUID, + "virtual_path": virtualPath, + "s3_bucket": archive.S3Bucket, + "s3_key": archive.S3Key, + "size_bytes": archive.SizeBytes, + "sha256": strings.ToLower(archive.SHA256), + "now": now, + }); err != nil { + return err + } + } + return tx.Commit() +} + +// ListFilestoreSkillArchives returns the complete deterministic projection set +// for virtual namespace resolution. +func (d *DB) ListFilestoreSkillArchives( + ctx context.Context, + workspaceID int64, + filesystemID int64, +) ([]FilestoreSkillArchive, error) { + var rows []filestoreSkillArchiveRow + err := namedSelectContext(ctx, d.sql, &rows, filestoreSkillArchiveListQuery, map[string]any{ + "workspace_id": workspaceID, + "filesystem_id": filesystemID, + }) + if errors.Is(err, sql.ErrNoRows) { + return []FilestoreSkillArchive{}, nil + } + if err != nil { + return nil, err + } + archives := make([]FilestoreSkillArchive, 0, len(rows)) + for _, row := range rows { + archives = append(archives, row.archive()) + } + return archives, nil +} + +func validateFilestoreSkillArchiveInput(input FilestoreSkillArchiveInput) error { + if input.Source != "anthropic" && input.Source != "custom" { + return fmt.Errorf("unsupported skill source %q", input.Source) + } + directory := strings.TrimSpace(input.Directory) + if directory == "" || strings.ContainsAny(directory, "/\\\x00") || directory == "." || directory == ".." { + return fmt.Errorf("invalid skill directory %q", input.Directory) + } + if _, err := uuid.Parse(strings.TrimSpace(input.SkillVersionUUID)); err != nil { + return fmt.Errorf("invalid skill version UUID: %w", err) + } + checksum := strings.TrimSpace(input.SHA256) + decodedChecksum, checksumErr := hex.DecodeString(checksum) + if strings.TrimSpace(input.S3Bucket) == "" || + strings.TrimSpace(input.S3Key) == "" || + input.SizeBytes <= 0 || + checksumErr != nil || + len(decodedChecksum) != 32 { + return ErrInvalidState + } + return nil +} + +func (row filestoreSkillArchiveRow) archive() FilestoreSkillArchive { + return FilestoreSkillArchive{ + ID: row.ID, + UUID: row.UUID, + ExternalID: row.ExternalID, + OrganizationUUID: row.OrganizationUUID, + WorkspaceUUID: row.WorkspaceUUID, + FilesystemUUID: row.FilesystemUUID, + Source: row.Source, + SkillVersionUUID: row.SkillVersionUUID, + VirtualPath: row.VirtualPath, + S3Bucket: row.S3Bucket, + S3Key: row.S3Key, + SizeBytes: row.SizeBytes, + SHA256: row.SHA256, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + } +} diff --git a/internal/db/filestore_skill_archives_test.go b/internal/db/filestore_skill_archives_test.go new file mode 100644 index 000000000..dcdaf5e89 --- /dev/null +++ b/internal/db/filestore_skill_archives_test.go @@ -0,0 +1,88 @@ +package db + +import ( + "strings" + "testing" + "time" +) + +func TestFilestoreSkillArchiveQueriesUseSQLXNamedParameters(t *testing.T) { + arguments := map[string]any{ + "workspace_id": int64(41), + "session_external_id": "session_41", + "organization_uuid": "00000000-0000-4000-8000-000000000041", + "workspace_uuid": "00000000-0000-4000-8000-000000000042", + "filesystem_id": int64(43), + "filesystem_uuid": "00000000-0000-4000-8000-000000000043", + "source": "custom", + "skill_version_uuid": "00000000-0000-4000-8000-000000000044", + "virtual_path": "/skills/demo", + "s3_bucket": "skills", + "s3_key": "skills/demo.zip", + "size_bytes": int64(1024), + "sha256": strings.Repeat("a", 64), + "now": time.Date(2026, time.July, 26, 12, 0, 0, 0, time.UTC), + } + tests := []struct { + name string + query string + wantArgCount int + }{ + {"filesystem", filestoreSkillArchiveFilesystemQuery, 3}, + {"delete", filestoreSkillArchiveDeleteQuery, 2}, + {"insert", filestoreSkillArchiveInsertQuery, 12}, + {"list", filestoreSkillArchiveListQuery, 3}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + query, boundArguments, err := bindNamed(postgresRebinder{}, test.query, arguments) + if err != nil { + t.Fatalf("bind named query: %v", err) + } + if strings.Contains(query, ":") { + t.Fatalf("query retains colon syntax after binding: %q", query) + } + if len(boundArguments) != test.wantArgCount { + t.Fatalf("argument count = %d, want %d", len(boundArguments), test.wantArgCount) + } + }) + } +} + +func TestValidateFilestoreSkillArchiveInput(t *testing.T) { + valid := FilestoreSkillArchiveInput{ + Source: "custom", + SkillVersionUUID: "00000000-0000-4000-8000-000000000044", + Directory: "demo", + S3Bucket: "skills", + S3Key: "skills/demo.zip", + SizeBytes: 1024, + SHA256: strings.Repeat("a", 64), + } + for _, test := range []struct { + name string + mutate func(*FilestoreSkillArchiveInput) + }{ + {"unsupported source", func(input *FilestoreSkillArchiveInput) { input.Source = "other" }}, + {"nested directory", func(input *FilestoreSkillArchiveInput) { input.Directory = "demo/nested" }}, + {"missing version", func(input *FilestoreSkillArchiveInput) { input.SkillVersionUUID = "" }}, + {"invalid version", func(input *FilestoreSkillArchiveInput) { input.SkillVersionUUID = "not-a-uuid" }}, + {"missing object key", func(input *FilestoreSkillArchiveInput) { input.S3Key = "" }}, + {"zero size", func(input *FilestoreSkillArchiveInput) { input.SizeBytes = 0 }}, + {"short checksum", func(input *FilestoreSkillArchiveInput) { input.SHA256 = "abc" }}, + {"non-hex checksum", func(input *FilestoreSkillArchiveInput) { input.SHA256 = strings.Repeat("z", 64) }}, + } { + t.Run("rejects "+test.name, func(t *testing.T) { + input := valid + test.mutate(&input) + if err := validateFilestoreSkillArchiveInput(input); err == nil { + t.Fatal("validation error = nil") + } + }) + } + t.Run("accepts valid", func(t *testing.T) { + if err := validateFilestoreSkillArchiveInput(valid); err != nil { + t.Fatalf("validation error = %v", err) + } + }) +} diff --git a/internal/db/migrations/00032_add_filestore_skill_archives.sql b/internal/db/migrations/00032_add_filestore_skill_archives.sql new file mode 100644 index 000000000..f988915b7 --- /dev/null +++ b/internal/db/migrations/00032_add_filestore_skill_archives.sql @@ -0,0 +1,125 @@ +-- +goose Up + +-- Managed Agent skills borrow immutable catalog archives. Filestore exposes +-- each archive as a virtual directory without copying its members into S3. +create table filestore_skill_archives ( + id bigint generated always as identity, + uuid uuid not null default gen_random_uuid(), + external_id text not null, + organization_uuid uuid not null, + workspace_uuid uuid not null, + filesystem_uuid uuid not null, + source text not null, + skill_version_uuid uuid not null, + virtual_path text not null, + s3_bucket text not null, + s3_key text not null, + size_bytes bigint not null, + sha256 text not null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint filestore_skill_archives_id_pk primary key (id), + constraint filestore_skill_archives_uuid_key unique (uuid), + constraint filestore_skill_archives_external_id_key unique (external_id), + constraint filestore_skill_archives_source_check check (source in ('anthropic', 'custom')), + constraint filestore_skill_archives_virtual_path_check check ( + virtual_path ~ '^/skills/[^/]+$' + and octet_length(virtual_path) <= 4096 + ), + constraint filestore_skill_archives_object_check check ( + char_length(s3_bucket) > 0 + and char_length(s3_key) > 0 + and size_bytes > 0 + and char_length(sha256) = 64 + ) +); + +create unique index filestore_skill_archives_filesystem_path_key + on filestore_skill_archives (workspace_uuid, filesystem_uuid, virtual_path); + +create unique index filestore_skill_archives_filesystem_version_key + on filestore_skill_archives (workspace_uuid, filesystem_uuid, source, skill_version_uuid); + +-- Refuse to reinterpret an existing user-controlled subtree as the reserved, +-- read-only skill namespace. +-- +goose StatementBegin +do $$ +begin + if exists ( + select 1 + from filestore_entries + where deleted_at is null + and ( + path like '/skills/%' + or ( + path = '/skills' + and ( + kind <> 'directory' + or parent_path <> '/' + or managed_by is not null + or managed_resource_uuid is not null + or source_file_uuid is not null + ) + ) + ) + ) then + raise exception 'cannot initialize reserved /skills namespace over existing entries'; + end if; +end +$$; +-- +goose StatementEnd + +insert into filestore_entries ( + uuid, + external_id, + organization_uuid, + workspace_uuid, + filesystem_uuid, + kind, + path, + parent_path, + created_by_api_key_uuid, + created_by_session_uuid, + created_by_code_session_uuid, + created_at, + updated_at +) +select + gen_random_uuid(), + concat('fse_', replace(cast(gen_random_uuid() as text), '-', '')), + fs.organization_uuid, + fs.workspace_uuid, + fs.uuid, + 'directory', + '/skills', + '/', + fs.created_by_api_key_uuid, + fs.session_uuid, + fs.code_session_uuid, + fs.created_at, + now() +from filestore_filesystems fs +where fs.deleted_at is null +on conflict (workspace_uuid, filesystem_uuid, path) + where deleted_at is null + do nothing; + +-- Old prewarm work is no longer executable after the virtual view is enabled. +delete from jobs where type = 'skill_prewarm'; + +-- +goose Down + +drop table filestore_skill_archives; + +delete from filestore_entries +where path = '/skills' + and kind = 'directory' + and deleted_at is null + and not exists ( + select 1 + from filestore_entries child + where child.workspace_uuid = filestore_entries.workspace_uuid + and child.filesystem_uuid = filestore_entries.filesystem_uuid + and child.deleted_at is null + and child.path like '/skills/%' + ); diff --git a/internal/db/migrations/00033_validate_filestore_skill_archive_checksum.sql b/internal/db/migrations/00033_validate_filestore_skill_archive_checksum.sql new file mode 100644 index 000000000..5bfc4b1cf --- /dev/null +++ b/internal/db/migrations/00033_validate_filestore_skill_archive_checksum.sql @@ -0,0 +1,21 @@ +-- +goose Up + +alter table filestore_skill_archives + drop constraint filestore_skill_archives_object_check, + add constraint filestore_skill_archives_object_check check ( + char_length(s3_bucket) > 0 + and char_length(s3_key) > 0 + and size_bytes > 0 + and sha256 ~ '^[0-9a-f]{64}$' + ); + +-- +goose Down + +alter table filestore_skill_archives + drop constraint filestore_skill_archives_object_check, + add constraint filestore_skill_archives_object_check check ( + char_length(s3_bucket) > 0 + and char_length(s3_key) > 0 + and size_bytes > 0 + and char_length(sha256) = 64 + ); diff --git a/internal/db/skill_prewarm.go b/internal/db/skill_prewarm.go deleted file mode 100644 index 9d8a8f50b..000000000 --- a/internal/db/skill_prewarm.go +++ /dev/null @@ -1,295 +0,0 @@ -package db - -import ( - "context" - "crypto/sha256" - "encoding/binary" - "encoding/hex" - "encoding/json" - "time" -) - -type SkillPrewarmJob struct { - ID int64 - ExternalID string - WorkspaceID int64 - Payload json.RawMessage - Attempts int -} - -type SkillPrewarmSnapshotJobInput struct { - WorkspaceID int64 - AgentSnapshot json.RawMessage - Source string - SourceID string - Trigger string - TriggerSkillID string - TriggerSkillVersion string -} - -type SkillPrewarmFanoutJobInput struct { - WorkspaceID int64 - SkillID string - Version string - AfterAgentID int64 - AfterDeploymentID int64 -} - -type skillPrewarmSnapshotPayload struct { - Kind string `json:"kind"` - AgentSnapshot json.RawMessage `json:"agent_snapshot"` - Source string `json:"source"` - SourceID string `json:"source_id"` - Trigger string `json:"trigger"` - TriggerSkillID string `json:"trigger_skill_id,omitempty"` - TriggerSkillVersion string `json:"trigger_skill_version,omitempty"` -} - -type skillPrewarmFanoutPayload struct { - Kind string `json:"kind"` - SkillID string `json:"skill_id"` - Version string `json:"version"` - AfterAgentID int64 `json:"after_agent_id,omitempty"` - AfterDeploymentID int64 `json:"after_deployment_id,omitempty"` -} - -func (d *DB) EnqueueSkillPrewarmSnapshotJob(ctx context.Context, input SkillPrewarmSnapshotJobInput) error { - payload, externalID, err := skillPrewarmSnapshotJobPayload(input) - if err != nil { - return err - } - _, err = d.Pool.Exec(ctx, ` - insert into jobs (external_id, workspace_id, type, status, payload) - values ($1, $2, 'skill_prewarm', 'pending', $3::jsonb) - on conflict (external_id) do nothing - `, externalID, input.WorkspaceID, jsonArg(payload)) - return err -} - -func (d *DB) EnqueueSkillPrewarmFanoutJob(ctx context.Context, input SkillPrewarmFanoutJobInput) error { - payload, externalID, err := skillPrewarmFanoutJobPayload(input) - if err != nil { - return err - } - _, err = d.Pool.Exec(ctx, ` - insert into jobs (external_id, workspace_id, type, status, payload) - values ($1, $2, 'skill_prewarm', 'pending', $3::jsonb) - on conflict (external_id) do nothing - `, externalID, input.WorkspaceID, jsonArg(payload)) - return err -} - -func (d *DB) LeaseSkillPrewarmJobs(ctx context.Context, workerID string, limit int, leaseDuration time.Duration) ([]SkillPrewarmJob, error) { - if limit <= 0 { - limit = 5 - } - if leaseDuration <= 0 { - leaseDuration = time.Minute - } - rows, err := d.Pool.Query(ctx, ` - with next_jobs as ( - select id - from jobs - where type = 'skill_prewarm' - and run_after <= now() - and ( - status in ('pending', 'retry') - or (status = 'running' and locked_until < now()) - ) - order by run_after, created_at - limit $1 - for update skip locked - ) - update jobs j - set status = 'running', - locked_by = $2, - locked_until = now() + $3::interval, - updated_at = now() - from next_jobs - where j.id = next_jobs.id - returning j.id, j.external_id, j.workspace_id, j.payload, j.attempts - `, limit, workerID, leaseDuration) - if err != nil { - return nil, err - } - defer rows.Close() - - var jobs []SkillPrewarmJob - for rows.Next() { - var job SkillPrewarmJob - var payload []byte - if err := rows.Scan(&job.ID, &job.ExternalID, &job.WorkspaceID, &payload, &job.Attempts); err != nil { - return nil, err - } - job.Payload = copyRaw(payload) - jobs = append(jobs, job) - } - return jobs, rows.Err() -} - -func (d *DB) CompleteSkillPrewarmJob(ctx context.Context, jobID int64, workerID string) error { - tag, err := d.Pool.Exec(ctx, ` - update jobs - set status = 'completed', - locked_by = null, - locked_until = null, - updated_at = now() - where id = $1 - and type = 'skill_prewarm' - and status = 'running' - and locked_by = $2 - `, jobID, workerID) - if err != nil { - return err - } - if tag.RowsAffected() == 0 { - return ErrNotFound - } - return nil -} - -func (d *DB) FailSkillPrewarmJob(ctx context.Context, jobID int64, workerID string, attempts int, reason string, retryDelay time.Duration, maxAttempts int) error { - nextAttempts := attempts + 1 - status := "retry" - if nextAttempts >= maxAttempts { - status = "failed" - } - now := time.Now().UTC() - runAfter := now.Add(retryDelay) - tag, err := d.Pool.Exec(ctx, ` - update jobs - set status = $2, - locked_by = null, - locked_until = null, - run_after = $3, - updated_at = now(), - attempts = $5, - payload = payload || jsonb_build_object( - 'last_error', $4::text, - 'last_error_at', $6::text - ) - where id = $1 - and type = 'skill_prewarm' - and status = 'running' - and locked_by = $7 - `, jobID, status, runAfter, reason, nextAttempts, now.Format(time.RFC3339Nano), workerID) - if err != nil { - return err - } - if tag.RowsAffected() == 0 { - return ErrNotFound - } - return nil -} - -func (d *DB) ListAgentsForSkillPrewarmFanout(ctx context.Context, workspaceID int64, skillID string, afterID int64, limit int) ([]Agent, bool, error) { - if limit <= 0 { - limit = 100 - } - agents, err := selectAgentsSQLX(ctx, d.sql, agentSelectSQL()+` - where workspace_id = :workspace_id - and id > :after_id - and deleted_at is null - and archived_at is null - and exists ( - select 1 - from jsonb_array_elements(coalesce(skills, CAST('[]' AS jsonb))) elem - where elem->>'type' = 'custom' - and elem->>'skill_id' = :skill_id - and coalesce(nullif(elem->>'version', ''), 'latest') = 'latest' - ) - order by id asc - limit :limit - `, map[string]any{ - "workspace_id": workspaceID, - "skill_id": skillID, - "after_id": afterID, - "limit": limit + 1, - }) - if err != nil { - return nil, false, err - } - hasMore := len(agents) > limit - if hasMore { - agents = agents[:limit] - } - return agents, hasMore, nil -} - -func (d *DB) ListDeploymentsForSkillPrewarmFanout(ctx context.Context, workspaceID int64, skillID string, afterID int64, limit int) ([]Deployment, bool, error) { - if limit <= 0 { - limit = 100 - } - rows, err := d.Pool.Query(ctx, ` - select `+deploymentColumns()+` - from deployments - where workspace_id = $1 - and id > $3 - and deleted_at is null - and archived_at is null - and status = 'active' - and exists ( - select 1 - from jsonb_array_elements(coalesce(agent_snapshot->'skills', '[]'::jsonb)) elem - where elem->>'type' = 'custom' - and elem->>'skill_id' = $2 - and coalesce(nullif(elem->>'version', ''), 'latest') = 'latest' - ) - order by id asc - limit $4 - `, workspaceID, skillID, afterID, limit+1) - if err != nil { - return nil, false, err - } - defer rows.Close() - - deployments, err := scanDeploymentRows(rows) - if err != nil { - return nil, false, err - } - hasMore := len(deployments) > limit - if hasMore { - deployments = deployments[:limit] - } - return deployments, hasMore, nil -} - -func skillPrewarmSnapshotJobPayload(input SkillPrewarmSnapshotJobInput) (json.RawMessage, string, error) { - payload, err := json.Marshal(skillPrewarmSnapshotPayload{ - Kind: "snapshot", - AgentSnapshot: copyRaw(input.AgentSnapshot), - Source: input.Source, - SourceID: input.SourceID, - Trigger: input.Trigger, - TriggerSkillID: input.TriggerSkillID, - TriggerSkillVersion: input.TriggerSkillVersion, - }) - if err != nil { - return nil, "", err - } - return payload, skillPrewarmExternalID(input.WorkspaceID, payload), nil -} - -func skillPrewarmFanoutJobPayload(input SkillPrewarmFanoutJobInput) (json.RawMessage, string, error) { - payload, err := json.Marshal(skillPrewarmFanoutPayload{ - Kind: "fanout", - SkillID: input.SkillID, - Version: input.Version, - AfterAgentID: input.AfterAgentID, - AfterDeploymentID: input.AfterDeploymentID, - }) - if err != nil { - return nil, "", err - } - return payload, skillPrewarmExternalID(input.WorkspaceID, payload), nil -} - -func skillPrewarmExternalID(workspaceID int64, payload json.RawMessage) string { - var workspace [8]byte - binary.LittleEndian.PutUint64(workspace[:], uint64(workspaceID)) - hash := sha256.New() - hash.Write([]byte("skill_prewarm:")) - hash.Write(workspace[:]) - hash.Write(payload) - return "job_skpw_" + hex.EncodeToString(hash.Sum(nil))[:40] -} diff --git a/internal/deployments/handler.go b/internal/deployments/handler.go index 66aff1f42..6b81fbb32 100644 --- a/internal/deployments/handler.go +++ b/internal/deployments/handler.go @@ -1,7 +1,6 @@ package deployments import ( - "context" "encoding/base64" "encoding/json" "errors" @@ -26,19 +25,13 @@ import ( ) const ( - maxDeploymentBodySize = 4 << 20 - skillPrewarmEnqueueTimeout = 3 * time.Second + maxDeploymentBodySize = 4 << 20 ) type Handler struct { - cfg config.Config - db *db.DB - prewarm skillPrewarmSnapshotEnqueuer - router chi.Router -} - -type skillPrewarmSnapshotEnqueuer interface { - EnqueueSnapshot(ctx context.Context, workspaceID int64, snapshot json.RawMessage, source string, sourceID string, trigger string) error + cfg config.Config + db *db.DB + router chi.Router } type RunsHandler struct { @@ -89,11 +82,7 @@ type resolvedAgent struct { } func NewHandler(cfg config.Config, database *db.DB) *Handler { - return NewHandlerWithSkillPrewarm(cfg, database, nil) -} - -func NewHandlerWithSkillPrewarm(cfg config.Config, database *db.DB, prewarm skillPrewarmSnapshotEnqueuer) *Handler { - h := &Handler{cfg: cfg, db: database, prewarm: prewarm} + h := &Handler{cfg: cfg, db: database} router := chi.NewRouter() router.NotFound(notFound) router.MethodNotAllowed(notFound) @@ -245,7 +234,6 @@ func (h *Handler) create(w http.ResponseWriter, r *http.Request) { httpapi.WriteError(w, r, httpapi.NewError(http.StatusInternalServerError, "api_error", "Could not create deployment")) return } - h.enqueueSkillPrewarm(r.Context(), principal.WorkspaceID, created.AgentSnapshot, "deployment", created.ExternalID, "deployment_create") httpapi.WriteJSON(w, http.StatusOK, responseFromDeployment(created, now)) } @@ -446,9 +434,6 @@ func (h *Handler) updateRoute(w http.ResponseWriter, r *http.Request) { writeDeploymentLoadError(w, r, err, deploymentID) return } - if !agentsnapshot.SnapshotSkillsEqual(current.AgentSnapshot, updated.AgentSnapshot) { - h.enqueueSkillPrewarm(r.Context(), principal.WorkspaceID, updated.AgentSnapshot, "deployment", updated.ExternalID, "deployment_update") - } httpapi.WriteJSON(w, http.StatusOK, responseFromDeployment(updated, time.Now().UTC())) } @@ -1621,17 +1606,6 @@ func defaultRepoMountPath(rawURL string) string { return "/workspace/" + name } -func (h *Handler) enqueueSkillPrewarm(ctx context.Context, workspaceID int64, snapshot json.RawMessage, source string, sourceID string, trigger string) { - if h == nil || h.prewarm == nil || !agentsnapshot.SnapshotHasSkills(snapshot) { - return - } - enqueueCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), skillPrewarmEnqueueTimeout) - defer cancel() - if err := h.prewarm.EnqueueSnapshot(enqueueCtx, workspaceID, snapshot, source, sourceID, trigger); err != nil { - log.Printf("enqueue deployment skill prewarm source=%s source_id=%s trigger=%s: %v", source, sourceID, trigger, err) - } -} - func cloneMap(input map[string]any) map[string]any { output := make(map[string]any, len(input)) for key, value := range input { diff --git a/internal/environments/rclone_filestore.go b/internal/environments/rclone_filestore.go index d53d5b532..d4e45da0c 100644 --- a/internal/environments/rclone_filestore.go +++ b/internal/environments/rclone_filestore.go @@ -18,6 +18,7 @@ const ( rcloneStateDirectory = "/tmp/rclone-mounts" rcloneReadyPath = rcloneStateDirectory + "/ready" rcloneUploadsDestination = "/mnt/session/uploads" + rcloneSkillsDestination = "/root/.claude/skills" rcloneReadyPollInterval = 200 * time.Millisecond rcloneReadyTimeout = 20 * time.Second rcloneCommandGraceTimeout = 5 * time.Second @@ -95,7 +96,7 @@ func filestoreTokenIdentityFromScope(scope db.FilestoreTokenScope) filestore.Tok } } -// buildRcloneMultimountConfig 把 sandbox 内几个固定挂载点映射到同一个 +// buildRcloneMultimountConfig 把 sandbox 内五个固定挂载点映射到同一个 // filestore filesystem:outputs 读写,其余目录按最小权限原则只读挂载。 func buildRcloneMultimountConfig(filesystemID, serviceURL, readWriteToken, readonlyToken string) rcloneMultimountConfig { mount := func(source, destination string, cacheSeconds float64, readonly bool, token string) rcloneMountConfig { @@ -120,6 +121,20 @@ func buildRcloneMultimountConfig(filesystemID, serviceURL, readWriteToken, reado mount("/uploads", rcloneUploadsDestination, 1, true, readonlyToken), mount("/transcripts", "/mnt/transcripts", 10, true, readonlyToken), mount("/tool_results", "/mnt/user-data/tool_results", 3, true, readonlyToken), + { + CacheDurationSeconds: 60, + AuthToken: readonlyToken, + Destination: rcloneSkillsDestination, + DirectoryPermissions: "0555", + FilePermissions: "0444", + FilesystemID: filesystemID, + GID: 0, + Readonly: true, + Source: "/skills", + UID: 0, + VFSCacheMaxSize: "1G", + VFSCacheMode: "full", + }, }, ReadyFile: rcloneReadyPath, ServiceURL: strings.TrimRight(strings.TrimSpace(serviceURL), "/"), @@ -135,6 +150,13 @@ func rcloneConfigPermissionsCommand() string { return "chmod 0600 " + shellQuote(rcloneConfigPath) } +func rcloneMountPreparationCommand() string { + claudeDirectory := "/root/.claude" + return "mkdir -p " + shellQuote(claudeDirectory) + + " && if [ -L " + shellQuote(rcloneSkillsDestination) + " ]; then rm -f " + shellQuote(rcloneSkillsDestination) + "; fi" + + " && mkdir -p " + shellQuote(rcloneSkillsDestination) +} + func rcloneConfigCleanupCommand() string { return "rm -f " + shellQuote(rcloneConfigPath) } diff --git a/internal/environments/rclone_filestore_test.go b/internal/environments/rclone_filestore_test.go index 15756fcaf..e4e873214 100644 --- a/internal/environments/rclone_filestore_test.go +++ b/internal/environments/rclone_filestore_test.go @@ -23,13 +23,13 @@ func TestBuildRcloneMultimountConfig(t *testing.T) { if got.ReadyFile != rcloneReadyPath || got.StateDir != rcloneStateDirectory || got.ServiceURL != "http://host.docker.internal:38080" { t.Fatalf("unexpected multimount envelope: %+v", got) } - if len(got.Mounts) != 4 { - t.Fatalf("mount count = %d, want 4", len(got.Mounts)) + if len(got.Mounts) != 5 { + t.Fatalf("mount count = %d, want 5", len(got.Mounts)) } - wantSources := []string{"/outputs", "/uploads", "/transcripts", "/tool_results"} - wantDestinations := []string{"/mnt/user-data/outputs", "/mnt/session/uploads", "/mnt/transcripts", "/mnt/user-data/tool_results"} - wantCaches := []float64{3600, 1, 10, 3} - for index, mount := range got.Mounts { + wantSources := []string{"/outputs", "/uploads", "/transcripts", "/tool_results", "/skills"} + wantDestinations := []string{"/mnt/user-data/outputs", "/mnt/session/uploads", "/mnt/transcripts", "/mnt/user-data/tool_results", "/root/.claude/skills"} + wantCaches := []float64{3600, 1, 10, 3, 60} + for index, mount := range got.Mounts[:4] { if mount.Source != wantSources[index] || mount.Destination != wantDestinations[index] || mount.CacheDurationSeconds != wantCaches[index] { t.Fatalf("mount %d = %+v", index, mount) } @@ -47,6 +47,13 @@ func TestBuildRcloneMultimountConfig(t *testing.T) { t.Fatalf("mount %d authority = readonly:%t token:%q", index, mount.Readonly, mount.AuthToken) } } + skills := got.Mounts[4] + if skills.Source != wantSources[4] || skills.Destination != wantDestinations[4] || + skills.CacheDurationSeconds != wantCaches[4] || !skills.Readonly || skills.AuthToken != readonly || + skills.UID != 0 || skills.GID != 0 || skills.DirectoryPermissions != "0555" || + skills.FilePermissions != "0444" { + t.Fatalf("skills mount = %+v", skills) + } } func TestRcloneReadyProbeContract(t *testing.T) { @@ -66,8 +73,9 @@ func TestRcloneCommandsKeepTokensOutOfCommandText(t *testing.T) { } start := rcloneStartCommand() permissions := rcloneConfigPermissionsCommand() + preparation := rcloneMountPreparationCommand() cleanup := rcloneConfigCleanupCommand() - if strings.Contains(start+permissions+cleanup, secret) { + if strings.Contains(start+permissions+preparation+cleanup, secret) { t.Fatal("rclone command text contains token") } if !strings.Contains(string(configPayload), secret) { @@ -86,6 +94,10 @@ func TestRcloneCommandsKeepTokensOutOfCommandText(t *testing.T) { if permissions != "chmod 0600 '/tmp/rclone-mount-config.json'" { t.Fatalf("rclone permissions command = %q", permissions) } + if !strings.Contains(preparation, "rm -f '/root/.claude/skills'") || + !strings.Contains(preparation, "mkdir -p '/root/.claude/skills'") { + t.Fatalf("rclone mount preparation does not replace the legacy symlink: %q", preparation) + } } func TestStartRcloneFilestoreFailures(t *testing.T) { @@ -110,24 +122,30 @@ func TestStartRcloneFilestoreFailures(t *testing.T) { wantError: errRcloneConfigPermissions, wantRunCalls: 2, }, + { + name: "mount preparation", + provider: &rcloneTestProvider{runErrors: []error{nil, providerFailure, nil}}, + wantError: errRcloneMountPreparation, + wantRunCalls: 3, + }, { name: "start", provider: &rcloneTestProvider{backgroundErr: providerFailure}, wantError: errRcloneProcessStart, - wantRunCalls: 2, + wantRunCalls: 3, wantLaunchCalls: 1, }, { name: "ready", provider: &rcloneTestProvider{fileExistsErr: providerFailure}, wantError: errRcloneReadiness, - wantRunCalls: 2, + wantRunCalls: 3, wantLaunchCalls: 1, }, { name: "cleanup retries without failing ready sandbox", - provider: &rcloneTestProvider{ready: true, runErrors: []error{nil, providerFailure, providerFailure, providerFailure}}, - wantRunCalls: 4, + provider: &rcloneTestProvider{ready: true, runErrors: []error{nil, nil, providerFailure, providerFailure, providerFailure}}, + wantRunCalls: 5, wantLaunchCalls: 1, }, } diff --git a/internal/environments/runner.go b/internal/environments/runner.go index 5ef3d4a3b..e4f9b45ab 100644 --- a/internal/environments/runner.go +++ b/internal/environments/runner.go @@ -24,6 +24,7 @@ import ( var ( errRcloneConfigWrite = errors.New("rclone-filestore config write failed") errRcloneConfigPermissions = errors.New("rclone-filestore config permission update failed") + errRcloneMountPreparation = errors.New("rclone-filestore mount preparation failed") errRcloneProcessStart = errors.New("rclone-filestore process start failed") errRcloneReadiness = errors.New("rclone-filestore readiness check failed") errEnvironmentManagerStart = errors.New("environment manager process start failed") @@ -400,8 +401,7 @@ func (r *Runner) prepareManagedAgentLaunch( if err != nil { return nil, err } - skillMount, err := r.prepareRuntimeSkillMount(ctx, runtimeSkills) - if err != nil { + if err := r.replaceRuntimeSkillArchives(ctx, session, runtimeSkills); err != nil { return nil, err } runtimeResources := resolveManagedAgentRuntimeResources(resources) @@ -411,15 +411,6 @@ func (r *Runner) prepareManagedAgentLaunch( if session.Title != nil { title = *session.Title } - if skillMount != nil { - nextWorkMetadata, err := patchJSONMetadata(work.Metadata, map[string]any{ - e2bruntime.SkillMountMetadataKey: skillMount, - }) - if err != nil { - return nil, err - } - work.Metadata = nextWorkMetadata - } return &managedAgentLaunchPreparation{ Session: session, InitialEvents: events, @@ -509,6 +500,10 @@ func (r *Runner) startRcloneFilestore(ctx context.Context, sandboxID string, lau _ = r.provider.RunCommand(ctx, sandboxID, rcloneConfigCleanupCommand(), rcloneCommandGraceTimeout) return logRcloneStageFailure("config_permissions", errRcloneConfigPermissions, err) } + if err := r.provider.RunCommand(ctx, sandboxID, rcloneMountPreparationCommand(), rcloneCommandGraceTimeout); err != nil { + _ = r.provider.RunCommand(ctx, sandboxID, rcloneConfigCleanupCommand(), rcloneCommandGraceTimeout) + return logRcloneStageFailure("mount_preparation", errRcloneMountPreparation, err) + } if err := r.provider.StartBackgroundCommand(ctx, sandboxID, rcloneStartCommand(), nil); err != nil { _ = r.provider.RunCommand(ctx, sandboxID, rcloneConfigCleanupCommand(), rcloneCommandGraceTimeout) return logRcloneStageFailure("process_start", errRcloneProcessStart, err) @@ -642,21 +637,33 @@ func (r *Runner) prepareManagedAgentNetworkMetadata(ctx context.Context, env db. return nil } -func (r *Runner) prepareRuntimeSkillMount(ctx context.Context, runtimeSkills []skillsapi.RuntimeSkill) (*e2bruntime.SkillMount, error) { - if len(runtimeSkills) == 0 { - return nil, nil - } - preparer, ok := r.provider.(e2bruntime.SkillMountPreparer) - if !ok { - return nil, fmt.Errorf("runtime provider cannot prepare managed agent skill mount") - } - return preparer.PrepareSkillMount(ctx, runtimeSkills) -} - func (r *Runner) resolveRuntimeSkills(ctx context.Context, session db.Session) ([]skillsapi.RuntimeSkill, error) { return r.skills.ResolveAgentSnapshot(ctx, session.WorkspaceID, session.AgentSnapshot) } +func (r *Runner) replaceRuntimeSkillArchives( + ctx context.Context, + session db.Session, + runtimeSkills []skillsapi.RuntimeSkill, +) error { + archives := make([]db.FilestoreSkillArchiveInput, 0, len(runtimeSkills)) + for _, skill := range runtimeSkills { + archives = append(archives, db.FilestoreSkillArchiveInput{ + Source: skill.Source, + SkillVersionUUID: skill.VersionUUID, + Directory: skill.Directory, + S3Bucket: skill.S3Bucket, + S3Key: skill.S3Key, + SizeBytes: skill.SizeBytes, + SHA256: skill.SHA256, + }) + } + if err := r.db.ReplaceFilestoreSkillArchives(ctx, session.WorkspaceID, session.ExternalID, archives); err != nil { + return fmt.Errorf("replace managed agent skill archive projections: %w", err) + } + return nil +} + func (r *Runner) sessionEventPayloads(ctx context.Context, session db.Session) ([]json.RawMessage, error) { var out []json.RawMessage var cursor *db.SessionEventPageCursor diff --git a/internal/filestore/service.go b/internal/filestore/service.go index d67ad8d85..31947ae76 100644 --- a/internal/filestore/service.go +++ b/internal/filestore/service.go @@ -32,6 +32,7 @@ type filestoreDatabase interface { GetFilestoreFilesystem(context.Context, int64, string) (db.FilestoreFilesystem, error) GetFilestoreEntry(context.Context, int64, int64, string) (db.FilestoreEntry, error) ListFilestoreEntriesPage(context.Context, db.ListFilestoreEntriesPageParams) (db.FilestoreEntryPage, error) + ListFilestoreSkillArchives(context.Context, int64, int64) ([]db.FilestoreSkillArchive, error) MakeFilestoreDirectory(context.Context, db.MakeFilestoreDirectoryInput) (db.FilestoreEntry, error) PutFilestoreFile(context.Context, db.PutFilestoreFileInput) (db.FilestoreMutationResult, error) CopyFilestoreFile(context.Context, db.CopyFilestoreFileInput) (db.FilestoreMutationResult, error) @@ -47,10 +48,11 @@ type filestoreDatabase interface { // Service 编排 Filestore 的鉴权上下文、元数据事务与对象存储操作。 // 数据库负责命名空间一致性,对象存储负责字节内容,两者通过持久化清理任务实现最终一致。 type Service struct { - cfg config.Config - db filestoreDatabase - store storage.ObjectStore - now func() time.Time + cfg config.Config + db filestoreDatabase + store storage.ObjectStore + now func() time.Time + skillArchives *skillArchiveCache } type readFileResult struct { @@ -61,7 +63,13 @@ type readFileResult struct { // NewService 创建 Filestore 业务服务。 func NewService(cfg config.Config, database filestoreDatabase, store storage.ObjectStore) *Service { - return &Service{cfg: cfg, db: database, store: store, now: time.Now} + return &Service{ + cfg: cfg, + db: database, + store: store, + now: time.Now, + skillArchives: newSkillArchiveCache(defaultSkillArchiveCacheBytes), + } } // ListDirectory 按路径与内部 ID 的稳定顺序列出目录,使用键集游标避免 offset 分页漂移。 @@ -84,6 +92,9 @@ func (s *Service) ListDirectory(ctx context.Context, principal Principal, reques if apiErr != nil { return listDirectoryResponse{}, apiErr } + if isSkillNamespacePath(request.Path) { + return s.listSkillDirectory(ctx, principal, filesystem, request, cursor, int(limit)) + } params := db.ListFilestoreEntriesPageParams{ WorkspaceID: principal.WorkspaceID, FilesystemID: filesystem.ID, @@ -134,6 +145,9 @@ func (s *Service) MakeDirectory(ctx context.Context, principal Principal, reques if apiErr != nil { return directoryResponse{}, apiErr } + if apiErr := rejectSkillMutation(request.Path); apiErr != nil { + return directoryResponse{}, apiErr + } entry, err := s.db.MakeFilestoreDirectory(ctx, db.MakeFilestoreDirectoryInput{ WorkspaceID: principal.WorkspaceID, FilesystemID: filesystem.ID, @@ -156,6 +170,9 @@ func (s *Service) RemoveDirectory(ctx context.Context, principal Principal, requ if apiErr != nil { return apiErr } + if apiErr := rejectSkillMutation(request.Path); apiErr != nil { + return apiErr + } _, err := s.db.RemoveFilestoreDirectory(ctx, db.RemoveFilestoreDirectoryInput{ WorkspaceID: principal.WorkspaceID, FilesystemID: filesystem.ID, @@ -182,6 +199,9 @@ func (s *Service) CreateFile(ctx context.Context, principal Principal, params cr if apiErr != nil { return fileResponse{}, apiErr } + if apiErr := rejectSkillMutation(params.Path); apiErr != nil { + return fileResponse{}, apiErr + } if apiErr := s.requireParentDirectory(ctx, principal.WorkspaceID, filesystem.ID, params.Path); apiErr != nil { return fileResponse{}, apiErr } @@ -265,6 +285,9 @@ func (s *Service) CopyFile(ctx context.Context, principal Principal, request cop if apiErr != nil { return fileResponse{}, apiErr } + if apiErr := rejectSkillMutation(request.Source, request.Destination); apiErr != nil { + return fileResponse{}, apiErr + } source, err := s.db.GetFilestoreEntry(ctx, principal.WorkspaceID, filesystem.ID, request.Source) if err != nil { return fileResponse{}, mapDatabaseError("read copy source", err) @@ -335,6 +358,9 @@ func (s *Service) MoveFile(ctx context.Context, principal Principal, request cop if apiErr != nil { return fileResponse{}, apiErr } + if apiErr := rejectSkillMutation(request.Source, request.Destination); apiErr != nil { + return fileResponse{}, apiErr + } result, err := s.db.MoveFilestoreFile(ctx, db.MoveFilestoreFileInput{ WorkspaceID: principal.WorkspaceID, FilesystemID: filesystem.ID, @@ -368,6 +394,9 @@ func (s *Service) MoveDirectory(ctx context.Context, principal Principal, reques if apiErr != nil { return directoryResponse{}, apiErr } + if apiErr := rejectSkillMutation(request.Source, request.Destination); apiErr != nil { + return directoryResponse{}, apiErr + } result, err := s.db.MoveFilestoreDirectory(ctx, db.MoveFilestoreDirectoryInput{ WorkspaceID: principal.WorkspaceID, FilesystemID: filesystem.ID, @@ -390,6 +419,9 @@ func (s *Service) ReadFile(ctx context.Context, principal Principal, request rea if apiErr != nil { return readFileResult{}, apiErr } + if strings.HasPrefix(request.Path, skillNamespacePath+"/") { + return s.readSkillFile(ctx, principal, filesystem, request) + } entry, err := s.db.GetFilestoreEntry(ctx, principal.WorkspaceID, filesystem.ID, request.Path) if err != nil { return readFileResult{}, mapDatabaseError("read file metadata", err) @@ -427,6 +459,9 @@ func (s *Service) RemoveFile(ctx context.Context, principal Principal, request p if apiErr != nil { return apiErr } + if apiErr := rejectSkillMutation(request.Path); apiErr != nil { + return apiErr + } _, err := s.db.RemoveFilestoreFile(ctx, db.RemoveFilestoreEntryInput{ WorkspaceID: principal.WorkspaceID, FilesystemID: filesystem.ID, @@ -448,6 +483,9 @@ func (s *Service) ReadMetadata(ctx context.Context, principal Principal, request if apiErr != nil { return entryPayload{}, apiErr } + if strings.HasPrefix(request.Path, skillNamespacePath+"/") { + return s.readSkillMetadata(ctx, principal, filesystem, request.Path) + } entry, err := s.db.GetFilestoreEntry(ctx, principal.WorkspaceID, filesystem.ID, request.Path) if err != nil { return entryPayload{}, mapDatabaseError("read metadata", err) @@ -459,6 +497,15 @@ func (s *Service) ReadMetadata(ctx context.Context, principal Principal, request return payload, nil } +func rejectSkillMutation(paths ...string) *apiError { + for _, value := range paths { + if isSkillNamespacePath(value) { + return permissionDenied("the /skills namespace is read-only") + } + } + return nil +} + func (s *Service) resolveFilesystem(ctx context.Context, principal Principal, filesystemID string) (db.FilestoreFilesystem, *apiError) { if principal.WorkspaceID <= 0 { return db.FilestoreFilesystem{}, &apiError{Status: http.StatusUnauthorized, Code: "unauthenticated", Message: "Invalid principal"} diff --git a/internal/filestore/service_test_support_test.go b/internal/filestore/service_test_support_test.go index 501a1342e..0b131c0ad 100644 --- a/internal/filestore/service_test_support_test.go +++ b/internal/filestore/service_test_support_test.go @@ -170,19 +170,27 @@ func assertServiceAPIError(t *testing.T, apiErr *apiError, status int, code stri } type fakeServiceDatabase struct { - getFilesystemFn func(context.Context, int64, string) (db.FilestoreFilesystem, error) - getEntryFn func(context.Context, int64, int64, string) (db.FilestoreEntry, error) - listEntriesFn func(context.Context, db.ListFilestoreEntriesPageParams) (db.FilestoreEntryPage, error) - makeDirectoryFn func(context.Context, db.MakeFilestoreDirectoryInput) (db.FilestoreEntry, error) - putFileFn func(context.Context, db.PutFilestoreFileInput) (db.FilestoreMutationResult, error) - copyFileFn func(context.Context, db.CopyFilestoreFileInput) (db.FilestoreMutationResult, error) - moveFileFn func(context.Context, db.MoveFilestoreFileInput) (db.FilestoreMutationResult, error) - moveDirectoryFn func(context.Context, db.MoveFilestoreDirectoryInput) (db.FilestoreMutationResult, error) - removeFileFn func(context.Context, db.RemoveFilestoreEntryInput) (db.FilestoreMutationResult, error) - removeDirectoryFn func(context.Context, db.RemoveFilestoreDirectoryInput) (db.FilestoreMutationResult, error) - enqueueCleanupFn func(context.Context, db.EnqueueFilestoreObjectCleanupJobInput) (db.FilestoreObjectCleanupJob, error) - attachCleanupFn func(context.Context, int64, string, string, string) error - completeCleanupFn func(context.Context, int64) error + getFilesystemFn func(context.Context, int64, string) (db.FilestoreFilesystem, error) + getEntryFn func(context.Context, int64, int64, string) (db.FilestoreEntry, error) + listEntriesFn func(context.Context, db.ListFilestoreEntriesPageParams) (db.FilestoreEntryPage, error) + listSkillArchivesFn func(context.Context, int64, int64) ([]db.FilestoreSkillArchive, error) + makeDirectoryFn func(context.Context, db.MakeFilestoreDirectoryInput) (db.FilestoreEntry, error) + putFileFn func(context.Context, db.PutFilestoreFileInput) (db.FilestoreMutationResult, error) + copyFileFn func(context.Context, db.CopyFilestoreFileInput) (db.FilestoreMutationResult, error) + moveFileFn func(context.Context, db.MoveFilestoreFileInput) (db.FilestoreMutationResult, error) + moveDirectoryFn func(context.Context, db.MoveFilestoreDirectoryInput) (db.FilestoreMutationResult, error) + removeFileFn func(context.Context, db.RemoveFilestoreEntryInput) (db.FilestoreMutationResult, error) + removeDirectoryFn func(context.Context, db.RemoveFilestoreDirectoryInput) (db.FilestoreMutationResult, error) + enqueueCleanupFn func(context.Context, db.EnqueueFilestoreObjectCleanupJobInput) (db.FilestoreObjectCleanupJob, error) + attachCleanupFn func(context.Context, int64, string, string, string) error + completeCleanupFn func(context.Context, int64) error +} + +func (f *fakeServiceDatabase) ListFilestoreSkillArchives(ctx context.Context, workspaceID, filesystemID int64) ([]db.FilestoreSkillArchive, error) { + if f.listSkillArchivesFn == nil { + panic("unexpected ListFilestoreSkillArchives call") + } + return f.listSkillArchivesFn(ctx, workspaceID, filesystemID) } func (f *fakeServiceDatabase) GetFilestoreFilesystem(ctx context.Context, workspaceID int64, externalID string) (db.FilestoreFilesystem, error) { diff --git a/internal/filestore/skill_archives.go b/internal/filestore/skill_archives.go new file mode 100644 index 000000000..feb8a3071 --- /dev/null +++ b/internal/filestore/skill_archives.go @@ -0,0 +1,466 @@ +package filestore + +import ( + "archive/zip" + "bytes" + "container/list" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "math" + "mime" + "os" + "path" + "sort" + "strings" + "sync" + "time" + + "github.com/google/uuid" + "github.com/superduck-ai/open-managed-agents/internal/db" +) + +const ( + skillNamespacePath = "/skills" + maxSkillArchiveBytes int64 = 8 * 1024 * 1024 + maxSkillUncompressedBytes uint64 = 500 * 1024 * 1024 + defaultSkillArchiveCacheBytes = 64 * 1024 * 1024 +) + +type skillArchiveNode struct { + path string + directory bool + size int64 + mediaType string + file *zip.File +} + +type loadedSkillArchive struct { + projection db.FilestoreSkillArchive + data []byte + nodes map[string]skillArchiveNode +} + +type skillArchiveCacheEntry struct { + key string + archive *loadedSkillArchive +} + +type skillArchiveCache struct { + mu sync.Mutex + maxBytes int + bytes int + entries map[string]*list.Element + order *list.List +} + +func newSkillArchiveCache(maxBytes int) *skillArchiveCache { + return &skillArchiveCache{ + maxBytes: maxBytes, + entries: make(map[string]*list.Element), + order: list.New(), + } +} + +func (c *skillArchiveCache) get(key string) (*loadedSkillArchive, bool) { + c.mu.Lock() + defer c.mu.Unlock() + element, ok := c.entries[key] + if !ok { + return nil, false + } + c.order.MoveToFront(element) + return element.Value.(skillArchiveCacheEntry).archive, true +} + +func (c *skillArchiveCache) put(key string, archive *loadedSkillArchive) { + if c == nil || archive == nil || len(archive.data) > c.maxBytes { + return + } + c.mu.Lock() + defer c.mu.Unlock() + if element, ok := c.entries[key]; ok { + c.bytes -= len(element.Value.(skillArchiveCacheEntry).archive.data) + element.Value = skillArchiveCacheEntry{key: key, archive: archive} + c.bytes += len(archive.data) + c.order.MoveToFront(element) + } else { + element := c.order.PushFront(skillArchiveCacheEntry{key: key, archive: archive}) + c.entries[key] = element + c.bytes += len(archive.data) + } + for c.bytes > c.maxBytes { + element := c.order.Back() + if element == nil { + break + } + entry := element.Value.(skillArchiveCacheEntry) + delete(c.entries, entry.key) + c.bytes -= len(entry.archive.data) + c.order.Remove(element) + } +} + +func isSkillNamespacePath(value string) bool { + return value == skillNamespacePath || strings.HasPrefix(value, skillNamespacePath+"/") +} + +func (s *Service) listSkillDirectory( + ctx context.Context, + principal Principal, + filesystem db.FilestoreFilesystem, + request listDirectoryRequest, + cursor directoryCursor, + limit int, +) (listDirectoryResponse, *apiError) { + archives, err := s.db.ListFilestoreSkillArchives(ctx, principal.WorkspaceID, filesystem.ID) + if err != nil { + return listDirectoryResponse{}, mapDatabaseError("list skill archives", err) + } + nodes := make([]skillArchiveNode, 0) + directoryExists := request.Path == skillNamespacePath + for _, projection := range archives { + if request.Path != skillNamespacePath && + request.Path != projection.VirtualPath && + !strings.HasPrefix(request.Path, projection.VirtualPath+"/") { + continue + } + archive, apiErr := s.loadSkillArchive(ctx, projection) + if apiErr != nil { + return listDirectoryResponse{}, apiErr + } + if node, ok := archive.nodes[request.Path]; ok && node.directory { + directoryExists = true + } + for _, node := range archive.nodes { + if node.path == request.Path { + continue + } + if request.Recursive { + if strings.HasPrefix(node.path, request.Path+"/") { + nodes = append(nodes, node) + } + continue + } + if path.Dir(node.path) == request.Path { + nodes = append(nodes, node) + } + } + } + if !directoryExists { + return listDirectoryResponse{}, notFound("resource does not exist") + } + sort.Slice(nodes, func(i, j int) bool { return nodes[i].path < nodes[j].path }) + if cursor.LastPath != "" { + first := sort.Search(len(nodes), func(index int) bool { + return nodes[index].path > cursor.LastPath + }) + nodes = nodes[first:] + } + hasMore := len(nodes) > limit + if hasMore { + nodes = nodes[:limit] + } + response := listDirectoryResponse{Entries: make([]entryPayload, 0, len(nodes))} + for _, node := range nodes { + response.Entries = append(response.Entries, skillNodePayload(node, filesystem.ExternalID, archives)) + } + if hasMore { + response.Cursor, err = encodeDirectoryCursor(directoryCursor{ + FilesystemID: request.FilesystemID, + Path: request.Path, + Recursive: request.Recursive, + LastPath: nodes[len(nodes)-1].path, + }) + if err != nil { + return listDirectoryResponse{}, internalError("encode directory cursor", err) + } + } + return response, nil +} + +func (s *Service) readSkillMetadata( + ctx context.Context, + principal Principal, + filesystem db.FilestoreFilesystem, + entryPath string, +) (entryPayload, *apiError) { + archive, node, apiErr := s.resolveSkillNode(ctx, principal, filesystem, entryPath) + if apiErr != nil { + return entryPayload{}, apiErr + } + return skillNodePayload(node, filesystem.ExternalID, []db.FilestoreSkillArchive{archive.projection}), nil +} + +func (s *Service) readSkillFile( + ctx context.Context, + principal Principal, + filesystem db.FilestoreFilesystem, + request readFileRequest, +) (readFileResult, *apiError) { + _, node, apiErr := s.resolveSkillNode(ctx, principal, filesystem, request.Path) + if apiErr != nil { + return readFileResult{}, apiErr + } + if node.directory || node.file == nil { + return readFileResult{}, failedPrecondition("path is not a file") + } + objectRange, responseSize, apiErr := resolveReadRange(request.Range, node.size) + if apiErr != nil { + return readFileResult{}, apiErr + } + if responseSize == 0 { + return readFileResult{ + Body: io.NopCloser(bytes.NewReader(nil)), + MediaType: node.mediaType, + }, nil + } + reader, err := node.file.Open() + if err != nil { + return readFileResult{}, internalError("open skill archive member", err) + } + offset := int64(0) + if objectRange != nil { + offset = objectRange.Offset + } + if offset > 0 { + if _, err := io.CopyN(io.Discard, reader, offset); err != nil { + reader.Close() + return readFileResult{}, internalError("seek skill archive member", err) + } + } + return readFileResult{ + Body: &limitedReadCloser{Reader: io.LimitReader(reader, responseSize), Closer: reader}, + Size: responseSize, + MediaType: node.mediaType, + }, nil +} + +func (s *Service) resolveSkillNode( + ctx context.Context, + principal Principal, + filesystem db.FilestoreFilesystem, + entryPath string, +) (*loadedSkillArchive, skillArchiveNode, *apiError) { + archives, err := s.db.ListFilestoreSkillArchives(ctx, principal.WorkspaceID, filesystem.ID) + if err != nil { + return nil, skillArchiveNode{}, mapDatabaseError("list skill archives", err) + } + for _, projection := range archives { + if entryPath != projection.VirtualPath && !strings.HasPrefix(entryPath, projection.VirtualPath+"/") { + continue + } + archive, apiErr := s.loadSkillArchive(ctx, projection) + if apiErr != nil { + return nil, skillArchiveNode{}, apiErr + } + node, ok := archive.nodes[entryPath] + if !ok { + return nil, skillArchiveNode{}, notFound("resource does not exist") + } + return archive, node, nil + } + return nil, skillArchiveNode{}, notFound("resource does not exist") +} + +func (s *Service) loadSkillArchive( + ctx context.Context, + projection db.FilestoreSkillArchive, +) (*loadedSkillArchive, *apiError) { + if s.skillArchives == nil { + return nil, internalError("load skill archive", errors.New("skill archive cache is unavailable")) + } + cacheKey := strings.Join([]string{projection.S3Bucket, projection.S3Key, projection.SHA256}, "\x00") + if archive, ok := s.skillArchives.get(cacheKey); ok { + return archive, nil + } + if s.store == nil { + return nil, internalError("load skill archive", errors.New("object store is unavailable")) + } + if projection.S3Bucket != s.store.Name() { + return nil, internalError("load skill archive", errors.New("skill archive bucket is unavailable")) + } + if projection.SizeBytes <= 0 || projection.SizeBytes > maxSkillArchiveBytes { + return nil, internalError("load skill archive", errors.New("skill archive size is invalid")) + } + object, err := s.store.Open(ctx, projection.S3Key, nil) + if err != nil { + return nil, mapBlobstoreError("load skill archive", err) + } + defer object.Body.Close() + data, err := io.ReadAll(io.LimitReader(object.Body, maxSkillArchiveBytes+1)) + if err != nil { + return nil, mapBlobstoreError("read skill archive", err) + } + if int64(len(data)) != projection.SizeBytes { + return nil, internalError("validate skill archive", errors.New("skill archive size mismatch")) + } + sum := sha256.Sum256(data) + if !strings.EqualFold(hex.EncodeToString(sum[:]), projection.SHA256) { + return nil, internalError("validate skill archive", errors.New("skill archive checksum mismatch")) + } + archive, err := indexSkillArchive(projection, data) + if err != nil { + return nil, internalError("validate skill archive", err) + } + s.skillArchives.put(cacheKey, archive) + return archive, nil +} + +func indexSkillArchive(projection db.FilestoreSkillArchive, data []byte) (*loadedSkillArchive, error) { + reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + return nil, errors.New("skill archive is not a valid zip") + } + directory := strings.TrimPrefix(projection.VirtualPath, skillNamespacePath+"/") + if directory == "" || strings.Contains(directory, "/") { + return nil, errors.New("skill archive virtual path is invalid") + } + nodes := map[string]skillArchiveNode{ + projection.VirtualPath: {path: projection.VirtualPath, directory: true}, + } + var totalUncompressed uint64 + hasSkillMD := false + for _, file := range reader.File { + cleanName, parts, err := validateSkillZipPath(file.Name) + if err != nil { + return nil, err + } + if parts[0] != directory { + return nil, fmt.Errorf("skill archive top-level directory %q does not match %q", parts[0], directory) + } + if file.Mode()&os.ModeSymlink != 0 { + return nil, fmt.Errorf("skill archive contains symlink %q", file.Name) + } + virtualPath := skillNamespacePath + "/" + strings.TrimSuffix(cleanName, "/") + if file.FileInfo().IsDir() { + if err := addSkillDirectoryNode(nodes, virtualPath); err != nil { + return nil, err + } + continue + } + if file.UncompressedSize64 > math.MaxInt64 { + return nil, fmt.Errorf("skill archive member %q is too large", file.Name) + } + next := totalUncompressed + file.UncompressedSize64 + if next < totalUncompressed || next > maxSkillUncompressedBytes { + return nil, errors.New("skill archive uncompressed size exceeds limit") + } + totalUncompressed = next + if err := addSkillParentNodes(nodes, path.Dir(virtualPath), projection.VirtualPath); err != nil { + return nil, err + } + if previous, exists := nodes[virtualPath]; exists { + if previous.directory { + return nil, fmt.Errorf("skill archive path %q is both a file and directory", file.Name) + } + return nil, fmt.Errorf("skill archive contains duplicate path %q", file.Name) + } + mediaType := mime.TypeByExtension(path.Ext(virtualPath)) + if mediaType == "" { + mediaType = "application/octet-stream" + } + nodes[virtualPath] = skillArchiveNode{ + path: virtualPath, + size: int64(file.UncompressedSize64), + mediaType: mediaType, + file: file, + } + if virtualPath == projection.VirtualPath+"/SKILL.md" { + hasSkillMD = true + } + } + if !hasSkillMD { + return nil, fmt.Errorf("%s/SKILL.md not found", directory) + } + return &loadedSkillArchive{projection: projection, data: data, nodes: nodes}, nil +} + +func validateSkillZipPath(name string) (string, []string, error) { + if name == "" || strings.Contains(name, "\\") || strings.HasPrefix(name, "/") || strings.Contains(name, "\x00") { + return "", nil, fmt.Errorf("invalid skill archive path %q", name) + } + cleanName := strings.TrimSuffix(name, "/") + if cleanName == "" { + return "", nil, fmt.Errorf("invalid skill archive path %q", name) + } + parts := strings.Split(cleanName, "/") + for _, part := range parts { + if part == "" || part == "." || part == ".." { + return "", nil, fmt.Errorf("invalid skill archive path %q", name) + } + } + return name, parts, nil +} + +func addSkillParentNodes(nodes map[string]skillArchiveNode, directoryPath, rootPath string) error { + for directoryPath != "." && directoryPath != skillNamespacePath { + if err := addSkillDirectoryNode(nodes, directoryPath); err != nil { + return err + } + if directoryPath == rootPath { + return nil + } + directoryPath = path.Dir(directoryPath) + } + return nil +} + +func addSkillDirectoryNode(nodes map[string]skillArchiveNode, directoryPath string) error { + if previous, exists := nodes[directoryPath]; exists && !previous.directory { + return fmt.Errorf("skill archive path %q is both a file and directory", directoryPath) + } + nodes[directoryPath] = skillArchiveNode{path: directoryPath, directory: true} + return nil +} + +func skillNodePayload( + node skillArchiveNode, + filesystemExternalID string, + archives []db.FilestoreSkillArchive, +) entryPayload { + createdAt := time.Unix(0, 0).UTC() + nodeIdentity := "" + for _, projection := range archives { + if node.path == projection.VirtualPath || strings.HasPrefix(node.path, projection.VirtualPath+"/") { + createdAt = projection.CreatedAt + nodeIdentity = strings.Join([]string{ + projection.FilesystemUUID, + projection.Source, + projection.SkillVersionUUID, + }, "\x00") + break + } + } + nodeUUID := uuid.NewSHA1(uuid.NameSpaceOID, []byte(nodeIdentity+"\x00"+node.path)).String() + if node.directory { + directory := directoryPayload{ + FilesystemID: filesystemExternalID, + Path: node.path, + CreatedAt: formatTimestamp(createdAt), + } + return entryPayload{Directory: &directory} + } + file := filesystemFilePayload{ + FilesystemID: filesystemExternalID, + Path: node.path, + File: filePayload{ + UUID: nodeUUID, + CreatedAt: formatTimestamp(createdAt), + Size: protoInt64(node.size), + MediaType: node.mediaType, + DetectedMimeType: node.mediaType, + EntryTaggedID: "fse_" + strings.ReplaceAll(nodeUUID, "-", ""), + FilesystemID: filesystemExternalID, + }, + } + return entryPayload{File: &file} +} + +type limitedReadCloser struct { + io.Reader + io.Closer +} diff --git a/internal/filestore/skill_archives_test.go b/internal/filestore/skill_archives_test.go new file mode 100644 index 000000000..c0693d375 --- /dev/null +++ b/internal/filestore/skill_archives_test.go @@ -0,0 +1,249 @@ +package filestore + +import ( + "archive/zip" + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "io" + "net/http" + "testing" + + "github.com/superduck-ai/open-managed-agents/internal/db" + "github.com/superduck-ai/open-managed-agents/internal/storage" +) + +func TestSkillArchiveViewRejectsInvalidArchives(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + files map[string]string + mutateHash bool + }{ + { + name: "checksum mismatch", + files: map[string]string{"demo/SKILL.md": "# Demo"}, + mutateHash: true, + }, + { + name: "missing SKILL md", + files: map[string]string{"demo/README.md": "missing"}, + }, + { + name: "path traversal", + files: map[string]string{"demo/SKILL.md": "# Demo", "demo/../secret": "no"}, + }, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + archiveBytes := buildSkillArchiveTestZip(t, test.files) + projection := skillArchiveTestProjection(archiveBytes) + if test.mutateHash { + projection.SHA256 = string(bytes.Repeat([]byte{'0'}, 64)) + } + service, _ := skillArchiveTestService(archiveBytes, projection) + _, apiErr := service.ListDirectory(context.Background(), serviceTestPrincipal(), listDirectoryRequest{ + FilesystemID: "fs_test", + Path: skillNamespacePath, + }) + assertServiceAPIError(t, apiErr, http.StatusInternalServerError, "internal") + }) + } +} + +func TestSkillArchiveNamespaceIsReadOnly(t *testing.T) { + t.Parallel() + + service := newServiceUnderTest( + filestoreTestConfig(1024, 4096, "filestore-test"), + &fakeServiceDatabase{getFilesystemFn: serviceFilesystemLookup(serviceTestFilesystem())}, + &fakeServiceBlobStore{}, + ) + principal := serviceTestPrincipal() + tests := []struct { + name string + run func() *apiError + }{ + { + name: "create file", + run: func() *apiError { + _, apiErr := service.CreateFile(context.Background(), principal, createFileParams{ + FilesystemID: "fs_test", + Path: "/skills/demo/new.txt", + MediaType: "text/plain", + }, bytes.NewReader(nil)) + return apiErr + }, + }, + { + name: "remove directory", + run: func() *apiError { + return service.RemoveDirectory(context.Background(), principal, removeDirectoryRequest{ + FilesystemID: "fs_test", + Path: "/skills/demo", + Recursive: true, + }) + }, + }, + { + name: "move into skills", + run: func() *apiError { + _, apiErr := service.MoveFile(context.Background(), principal, copyMoveFileRequest{ + FilesystemID: "fs_test", + Source: "/outputs/a.txt", + Destination: "/skills/demo/a.txt", + }) + return apiErr + }, + }, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + assertServiceAPIError(t, test.run(), http.StatusForbidden, "permission_denied") + }) + } +} + +func TestSkillArchiveViewListsMetadataAndReadsRanges(t *testing.T) { + t.Parallel() + + archiveBytes := buildSkillArchiveTestZip(t, map[string]string{ + "demo/SKILL.md": "# Demo", + "demo/docs/guide.md": "0123456789", + }) + projection := skillArchiveTestProjection(archiveBytes) + service, openCount := skillArchiveTestService(archiveBytes, projection) + ctx := context.Background() + principal := serviceTestPrincipal() + + root, apiErr := service.ListDirectory(ctx, principal, listDirectoryRequest{ + FilesystemID: "fs_test", + Path: "/skills", + }) + if apiErr != nil { + t.Fatalf("ListDirectory(/skills) error = %v", apiErr) + } + if len(root.Entries) != 1 || root.Entries[0].Directory == nil || root.Entries[0].Directory.Path != "/skills/demo" { + t.Fatalf("ListDirectory(/skills) = %#v", root) + } + + nested, apiErr := service.ListDirectory(ctx, principal, listDirectoryRequest{ + FilesystemID: "fs_test", + Path: "/skills/demo", + Recursive: true, + }) + if apiErr != nil { + t.Fatalf("ListDirectory(/skills/demo) error = %v", apiErr) + } + if len(nested.Entries) != 3 { + t.Fatalf("recursive entry count = %d, want 3: %#v", len(nested.Entries), nested) + } + + metadata, apiErr := service.ReadMetadata(ctx, principal, pathRequest{ + FilesystemID: "fs_test", + Path: "/skills/demo/docs/guide.md", + }) + if apiErr != nil { + t.Fatalf("ReadMetadata() error = %v", apiErr) + } + if metadata.File == nil || int64(metadata.File.File.Size) != 10 || metadata.File.Path != "/skills/demo/docs/guide.md" { + t.Fatalf("ReadMetadata() = %#v", metadata) + } + + read, apiErr := service.ReadFile(ctx, principal, readFileRequest{ + FilesystemID: "fs_test", + Path: "/skills/demo/docs/guide.md", + Range: &readFileRange{Offset: 3, Length: 4}, + }) + if apiErr != nil { + t.Fatalf("ReadFile() error = %v", apiErr) + } + body, err := io.ReadAll(read.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + if err := read.Body.Close(); err != nil { + t.Fatalf("close body: %v", err) + } + if string(body) != "3456" || read.Size != 4 { + t.Fatalf("ReadFile() body/size = %q/%d", body, read.Size) + } + if *openCount != 1 { + t.Fatalf("object opens = %d, want one cached load", *openCount) + } +} + +func skillArchiveTestService( + archiveBytes []byte, + projection db.FilestoreSkillArchive, +) (*Service, *int) { + filesystem := serviceTestFilesystem() + openCount := 0 + database := &fakeServiceDatabase{ + getFilesystemFn: serviceFilesystemLookup(filesystem), + listSkillArchivesFn: func(context.Context, int64, int64) ([]db.FilestoreSkillArchive, error) { + return []db.FilestoreSkillArchive{projection}, nil + }, + } + store := &fakeServiceBlobStore{ + openFn: func(_ context.Context, key string, byteRange *storage.ByteRange) (storage.Object, error) { + openCount++ + if key != projection.S3Key || byteRange != nil { + t := storage.ErrNotFound + return storage.Object{}, t + } + return storage.Object{ + Body: io.NopCloser(bytes.NewReader(archiveBytes)), + Size: int64(len(archiveBytes)), + }, nil + }, + } + return newServiceUnderTest(filestoreTestConfig(1024, 4096, "filestore-test"), database, store), &openCount +} + +func skillArchiveTestProjection(data []byte) db.FilestoreSkillArchive { + sum := sha256.Sum256(data) + return db.FilestoreSkillArchive{ + ID: 71, + UUID: "77777777-7777-4777-8777-777777777777", + ExternalID: "fsa_test", + OrganizationUUID: serviceTestPrincipal().OrganizationUUID, + WorkspaceUUID: serviceTestPrincipal().WorkspaceUUID, + FilesystemUUID: serviceTestFilesystem().UUID, + Source: "custom", + SkillVersionUUID: "88888888-8888-4888-8888-888888888888", + VirtualPath: "/skills/demo", + S3Bucket: "filestore-test", + S3Key: "skills/demo/1.zip", + SizeBytes: int64(len(data)), + SHA256: hex.EncodeToString(sum[:]), + CreatedAt: serviceTestNow, + UpdatedAt: serviceTestNow, + } +} + +func buildSkillArchiveTestZip(t *testing.T, files map[string]string) []byte { + t.Helper() + var buffer bytes.Buffer + writer := zip.NewWriter(&buffer) + for name, contents := range files { + entry, err := writer.Create(name) + if err != nil { + t.Fatalf("create zip entry: %v", err) + } + if _, err := io.WriteString(entry, contents); err != nil { + t.Fatalf("write zip entry: %v", err) + } + } + if err := writer.Close(); err != nil { + t.Fatalf("close zip: %v", err) + } + return buffer.Bytes() +} diff --git a/internal/networkpolicy/metadata_test.go b/internal/networkpolicy/metadata_test.go index 2e05b27bf..2644599d3 100644 --- a/internal/networkpolicy/metadata_test.go +++ b/internal/networkpolicy/metadata_test.go @@ -50,7 +50,7 @@ func TestParseWorkMetadataMCPAllowedHostsTreatsMissingFieldAsEmpty(t *testing.T) func TestPatchWorkMetadataMCPAllowedHostsPreservesOtherFields(t *testing.T) { patched, err := PatchWorkMetadataMCPAllowedHosts( - json.RawMessage(`{"managed_agent_skills_mount":{"volume_name":"skills"},"mcp_allowed_hosts":["stale.example"]}`), + json.RawMessage(`{"unrelated":{"value":"keep"},"mcp_allowed_hosts":["stale.example"]}`), []string{"MCP.Example.com", "mcp.example.com"}, ) if err != nil { @@ -60,7 +60,7 @@ func TestPatchWorkMetadataMCPAllowedHostsPreservesOtherFields(t *testing.T) { if err := json.Unmarshal(patched, &fields); err != nil { t.Fatalf("decode patched metadata: %v", err) } - if _, ok := fields["managed_agent_skills_mount"]; !ok { + if _, ok := fields["unrelated"]; !ok { t.Fatalf("unrelated metadata was removed: %s", patched) } hosts, err := ParseWorkMetadataMCPAllowedHosts(patched) diff --git a/internal/runtime/e2bruntime/runtime.go b/internal/runtime/e2bruntime/runtime.go index e457ebb05..f14ddba4e 100644 --- a/internal/runtime/e2bruntime/runtime.go +++ b/internal/runtime/e2bruntime/runtime.go @@ -14,7 +14,6 @@ import ( "github.com/superduck-ai/open-managed-agents/internal/config" "github.com/superduck-ai/open-managed-agents/internal/db" "github.com/superduck-ai/open-managed-agents/internal/networkpolicy" - skillsapi "github.com/superduck-ai/open-managed-agents/internal/skills" e2b "github.com/superduck-ai/e2b-go-sdk" ) @@ -22,11 +21,6 @@ import ( const ( sandboxUserDataVolumeName = "user-data" sandboxUserDataMountPath = "/mnt/user-data" - SandboxSkillsMountPath = "/mnt/skills" - SkillMountMetadataKey = "managed_agent_skills_mount" - skillMountManifestPath = "manifest.json" - skillMountReadyPath = ".ready" - skillMountVolumePrefix = "managed-agent-skills-" ) type Resolution struct { @@ -42,13 +36,6 @@ type Sandbox struct { ID string } -type SkillMount struct { - MountPath string `json:"mount_path"` - VolumeName string `json:"volume_name"` - ManifestSHA256 string `json:"manifest_sha256"` - Skills []skillsapi.MountManifestSkill `json:"skills,omitempty"` -} - type Provider interface { Create(ctx context.Context, env db.Environment, work *db.EnvironmentWork, resolution Resolution) (Sandbox, error) Kill(ctx context.Context, sandboxID string) error @@ -59,10 +46,6 @@ type Provider interface { StartBackgroundCommand(ctx context.Context, sandboxID string, command string, stdin []byte) error } -type SkillMountPreparer interface { - PrepareSkillMount(ctx context.Context, runtimeSkills []skillsapi.RuntimeSkill) (*SkillMount, error) -} - type E2BProvider struct { cfg config.E2BConfig } @@ -273,68 +256,6 @@ func (p *E2BProvider) StartBackgroundCommand(ctx context.Context, sandboxID stri return nil } -func (p *E2BProvider) PrepareSkillMount(ctx context.Context, runtimeSkills []skillsapi.RuntimeSkill) (*SkillMount, error) { - if len(runtimeSkills) == 0 { - return nil, nil - } - manifest, manifestBytes, manifestSHA256, err := skillsapi.BuildMountManifest(runtimeSkills) - if err != nil { - return nil, err - } - volumeName := skillMountVolumeName(manifestSHA256) - volume, created, err := p.connectOrCreateSkillVolume(ctx, volumeName) - if err != nil { - return nil, err - } - if !created { - ready, err := p.skillVolumeReady(ctx, volume, volumeName, manifestSHA256) - if err != nil { - return nil, err - } - if ready { - return &SkillMount{ - MountPath: SandboxSkillsMountPath, - VolumeName: volumeName, - ManifestSHA256: manifestSHA256, - Skills: manifest.Skills, - }, nil - } - } - if err := p.writeSkillVolume(ctx, volume, manifestBytes, manifestSHA256, manifest, runtimeSkills); err != nil { - return nil, err - } - return &SkillMount{ - MountPath: SandboxSkillsMountPath, - VolumeName: volumeName, - ManifestSHA256: manifestSHA256, - Skills: manifest.Skills, - }, nil -} - -func (p *E2BProvider) skillVolumeReady(ctx context.Context, volume *e2b.Volume, volumeName string, manifestSHA256 string) (bool, error) { - exists, err := volume.Exists(ctx, skillMountReadyPath, p.volumeAPIOpts()) - if err != nil { - return false, fmt.Errorf("check managed agent skill volume readiness %s: %w", volumeName, err) - } - if !exists { - return false, nil - } - value, err := volume.ReadFile(ctx, skillMountReadyPath, p.volumeReadOpts()) - if err != nil { - return false, fmt.Errorf("read managed agent skill volume ready marker %s: %w", volumeName, err) - } - var marker string - switch typed := value.(type) { - case string: - marker = typed - case []byte: - marker = string(typed) - default: - return false, fmt.Errorf("managed agent skill volume ready marker %s has unsupported type %T", volumeName, value) - } - return strings.TrimSpace(marker) == strings.TrimSpace(manifestSHA256), nil -} - func (p *E2BProvider) connect(ctx context.Context, sandboxID string) (*e2b.Sandbox, error) { sandbox, err := e2b.Connect(ctx, sandboxID, &e2b.SandboxConnectOpts{ ConnectionOpts: ConnectionOptsFromConfig(p.cfg), @@ -345,119 +266,6 @@ func (p *E2BProvider) connect(ctx context.Context, sandboxID string) (*e2b.Sandb return sandbox, nil } -func (p *E2BProvider) connectOrCreateSkillVolume(ctx context.Context, volumeName string) (*e2b.Volume, bool, error) { - opts := p.volumeConnectionOpts() - volumes, err := e2b.ListVolumes(ctx, opts) - if err != nil { - return nil, false, fmt.Errorf("list E2B volumes for managed agent skills: %w", err) - } - for _, volume := range volumes { - if volume.Name != volumeName { - continue - } - connected, err := e2b.ConnectVolume(ctx, volume.VolumeID, opts) - if err != nil { - return nil, false, fmt.Errorf("connect managed agent skill volume %s: %w", volumeName, err) - } - return connected, false, nil - } - created, err := e2b.CreateVolume(ctx, volumeName, opts) - if err == nil { - return created, true, nil - } - volumes, listErr := e2b.ListVolumes(ctx, opts) - if listErr == nil { - for _, volume := range volumes { - if volume.Name != volumeName { - continue - } - connected, connectErr := e2b.ConnectVolume(ctx, volume.VolumeID, opts) - if connectErr != nil { - return nil, false, fmt.Errorf("connect concurrently created managed agent skill volume %s: %w", volumeName, connectErr) - } - return connected, false, nil - } - } - return nil, false, fmt.Errorf("create managed agent skill volume %s: %w", volumeName, err) -} - -func (p *E2BProvider) writeSkillVolume(ctx context.Context, volume *e2b.Volume, manifestBytes []byte, manifestSHA256 string, manifest skillsapi.MountManifest, runtimeSkills []skillsapi.RuntimeSkill) error { - skillsByFilename := make(map[string]skillsapi.RuntimeSkill, len(runtimeSkills)) - for _, skill := range runtimeSkills { - skillsByFilename[skillsapi.MountArchiveFilename(skill)] = skill - } - opts := p.volumeWriteOpts() - if _, err := volume.WriteFile(ctx, skillMountManifestPath, manifestBytes, opts); err != nil { - return fmt.Errorf("write managed agent skill manifest: %w", err) - } - for _, entry := range manifest.Skills { - skill, ok := skillsByFilename[entry.Filename] - if !ok { - return fmt.Errorf("managed agent skill archive %s is missing", entry.Filename) - } - archive, err := skill.LoadArchive(ctx) - if err != nil { - return err - } - if len(archive) == 0 { - return fmt.Errorf("managed agent skill archive %s is empty", entry.Filename) - } - if _, err := volume.WriteFile(ctx, entry.Filename, archive, opts); err != nil { - return fmt.Errorf("write managed agent skill archive %s: %w", entry.Filename, err) - } - } - if _, err := volume.WriteFile(ctx, skillMountReadyPath, []byte(manifestSHA256+"\n"), opts); err != nil { - return fmt.Errorf("write managed agent skill volume ready marker: %w", err) - } - return nil -} - -func (p *E2BProvider) volumeConnectionOpts() *e2b.VolumeConnectionOpts { - requestTimeoutMs := int(p.cfg.RequestTimeout / time.Millisecond) - debug := p.cfg.Debug - return &e2b.VolumeConnectionOpts{ - ApiKey: p.cfg.APIKey, - AccessToken: p.cfg.AccessToken, - Domain: p.cfg.Domain, - ApiUrl: p.cfg.APIURL, - SandboxUrl: p.cfg.SandboxURL, - Debug: &debug, - RequestTimeoutMs: &requestTimeoutMs, - } -} - -func (p *E2BProvider) volumeAPIOpts() *e2b.VolumeApiOpts { - requestTimeoutMs := int(p.cfg.RequestTimeout / time.Millisecond) - debug := p.cfg.Debug - return &e2b.VolumeApiOpts{ - Domain: p.cfg.Domain, - Debug: &debug, - ApiUrl: p.cfg.APIURL, - RequestTimeoutMs: &requestTimeoutMs, - } -} - -func (p *E2BProvider) volumeReadOpts() *e2b.VolumeReadOpts { - apiOpts := p.volumeAPIOpts() - return &e2b.VolumeReadOpts{ - VolumeApiOpts: *apiOpts, - } -} - -func (p *E2BProvider) volumeWriteOpts() *e2b.VolumeWriteOptions { - force := true - mode := 0o644 - apiOpts := p.volumeAPIOpts() - return &e2b.VolumeWriteOptions{ - VolumeMetadataOptions: e2b.VolumeMetadataOptions{Mode: &mode}, - Force: &force, - Domain: apiOpts.Domain, - Debug: apiOpts.Debug, - ApiUrl: apiOpts.ApiUrl, - RequestTimeoutMs: apiOpts.RequestTimeoutMs, - } -} - func resolveNetwork(raw json.RawMessage, work *db.EnvironmentWork) (*e2b.SandboxNetworkOpts, bool, error) { if len(raw) == 0 { return nil, true, nil @@ -510,46 +318,8 @@ func truncateCommandOutput(value string) string { return value[:limit] + "...[truncated]" } -func (p *E2BProvider) sandboxVolumeMounts(work *db.EnvironmentWork) map[string]any { - mounts := map[string]any{ +func (p *E2BProvider) sandboxVolumeMounts(_ *db.EnvironmentWork) map[string]any { + return map[string]any{ sandboxUserDataMountPath: sandboxUserDataVolumeName, } - if skillMount, ok := skillMountFromWork(work); ok { - mountPath := strings.TrimSpace(skillMount.MountPath) - if mountPath == "" { - mountPath = SandboxSkillsMountPath - } - mounts[mountPath] = strings.TrimSpace(skillMount.VolumeName) - } - return mounts -} - -func skillMountFromWork(work *db.EnvironmentWork) (*SkillMount, bool) { - if work == nil || len(work.Metadata) == 0 || strings.TrimSpace(string(work.Metadata)) == "null" { - return nil, false - } - var metadata map[string]json.RawMessage - if err := json.Unmarshal(work.Metadata, &metadata); err != nil { - return nil, false - } - raw, ok := metadata[SkillMountMetadataKey] - if !ok || len(raw) == 0 || strings.TrimSpace(string(raw)) == "null" { - return nil, false - } - var mount SkillMount - if err := json.Unmarshal(raw, &mount); err != nil { - return nil, false - } - if strings.TrimSpace(mount.VolumeName) == "" { - return nil, false - } - return &mount, true -} - -func skillMountVolumeName(manifestSHA256 string) string { - sha := strings.TrimSpace(manifestSHA256) - if sha == "" { - sha = "unknown" - } - return skillMountVolumePrefix + sha } diff --git a/internal/runtime/e2bruntime/runtime_test.go b/internal/runtime/e2bruntime/runtime_test.go index 2ad6b6e15..f77823284 100644 --- a/internal/runtime/e2bruntime/runtime_test.go +++ b/internal/runtime/e2bruntime/runtime_test.go @@ -1,21 +1,13 @@ package e2bruntime import ( - "context" - "crypto/sha256" "encoding/json" - "fmt" - "io" - "net/http" - "net/http/httptest" "reflect" - "strings" "testing" "time" "github.com/superduck-ai/open-managed-agents/internal/config" "github.com/superduck-ai/open-managed-agents/internal/db" - skillsapi "github.com/superduck-ai/open-managed-agents/internal/skills" ) func TestConnectionOptsFromConfigMapsAllFields(t *testing.T) { @@ -66,24 +58,6 @@ func TestSandboxVolumeMountsOnlyIncludeUserData(t *testing.T) { } } -func TestSandboxVolumeMountsIncludesManagedAgentSkills(t *testing.T) { - provider := NewProvider(config.E2BConfig{}) - work := &db.EnvironmentWork{ - Metadata: json.RawMessage(`{"managed_agent_skills_mount":{"mount_path":"/mnt/skills","volume_name":"managed-agent-skills-test","manifest_sha256":"abc123"}}`), - } - - mounts := provider.sandboxVolumeMounts(work) - if got := mounts[sandboxUserDataMountPath]; got != sandboxUserDataVolumeName { - t.Fatalf("mount %s = %v, want %s", sandboxUserDataMountPath, got, sandboxUserDataVolumeName) - } - if got := mounts[SandboxSkillsMountPath]; got != "managed-agent-skills-test" { - t.Fatalf("mount %s = %v, want managed-agent-skills-test", SandboxSkillsMountPath, got) - } - if len(mounts) != 2 { - t.Fatalf("mounts = %#v, want user-data plus skills", mounts) - } -} - func TestResolveLimitedNetworkFailsClosedOnInvalidAllowedHost(t *testing.T) { provider := NewProvider(config.E2BConfig{}) _, err := provider.Resolve(db.Environment{ @@ -170,109 +144,3 @@ func TestResolveLimitedNetworkIncludesMCPHostsWhenAllowed(t *testing.T) { t.Fatalf("AllowOut = %#v, want %#v", resolution.Network.AllowOut, want) } } - -func TestSkillMountVolumeNameUsesFullManifestHash(t *testing.T) { - hash := strings.Repeat("a", 64) - got := skillMountVolumeName(hash) - want := skillMountVolumePrefix + hash - if got != want { - t.Fatalf("skillMountVolumeName = %q, want %q", got, want) - } -} - -func TestPrepareSkillMountReusesOnlyMatchingReadyMarker(t *testing.T) { - archive := []byte("skill archive") - sum := sha256.Sum256(archive) - sha := fmt.Sprintf("%x", sum[:]) - runtimeSkills := []skillsapi.RuntimeSkill{{ - Source: "custom", - SkillID: "skill_1", - Version: "1", - Directory: "skill-one", - SHA256: sha, - SizeBytes: int64(len(archive)), - Archive: archive, - }} - _, _, manifestSHA256, err := skillsapi.BuildMountManifest(runtimeSkills) - if err != nil { - t.Fatalf("BuildMountManifest: %v", err) - } - volumeName := skillMountVolumeName(manifestSHA256) - - t.Run("matching ready marker reuses without writes", func(t *testing.T) { - var writes []string - server := newFakeE2BVolumeServer(t, volumeName, manifestSHA256, &writes) - defer server.Close() - - provider := NewProvider(config.E2BConfig{ - APIKey: "e2b_0000000000000000000000000000000000000000", - APIURL: server.URL, - }) - metadataOnly := append([]skillsapi.RuntimeSkill(nil), runtimeSkills...) - metadataOnly[0].Archive = nil - mount, err := provider.PrepareSkillMount(context.Background(), metadataOnly) - if err != nil { - t.Fatalf("PrepareSkillMount: %v", err) - } - if mount.VolumeName != volumeName || mount.ManifestSHA256 != manifestSHA256 { - t.Fatalf("mount = %#v, want volume=%s manifest=%s", mount, volumeName, manifestSHA256) - } - if len(writes) != 0 { - t.Fatalf("writes = %#v, want none for matching ready marker", writes) - } - }) - - t.Run("mismatched ready marker rewrites volume", func(t *testing.T) { - var writes []string - server := newFakeE2BVolumeServer(t, volumeName, "stale-ready-marker", &writes) - defer server.Close() - - provider := NewProvider(config.E2BConfig{ - APIKey: "e2b_0000000000000000000000000000000000000000", - APIURL: server.URL, - }) - mount, err := provider.PrepareSkillMount(context.Background(), runtimeSkills) - if err != nil { - t.Fatalf("PrepareSkillMount: %v", err) - } - if mount.VolumeName != volumeName || mount.ManifestSHA256 != manifestSHA256 { - t.Fatalf("mount = %#v, want volume=%s manifest=%s", mount, volumeName, manifestSHA256) - } - if len(writes) != 3 { - t.Fatalf("writes = %#v, want manifest, archive, ready", writes) - } - if writes[len(writes)-1] != skillMountReadyPath+"="+manifestSHA256+"\n" { - t.Fatalf("ready write = %q, want manifest hash", writes[len(writes)-1]) - } - }) -} - -func newFakeE2BVolumeServer(t *testing.T, volumeName string, readyMarker string, writes *[]string) *httptest.Server { - t.Helper() - const volumeID = "vol-skills-test" - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == http.MethodGet && r.URL.Path == "/volumes": - _, _ = w.Write([]byte(`[{"volumeID":"` + volumeID + `","name":"` + volumeName + `"}]`)) - case r.Method == http.MethodGet && r.URL.Path == "/volumes/"+volumeID: - _, _ = w.Write([]byte(`{"volumeID":"` + volumeID + `","name":"` + volumeName + `","token":"volume-token"}`)) - case r.Method == http.MethodGet && r.URL.Path == "/volumecontent/"+volumeID+"/path" && r.URL.Query().Get("path") == skillMountReadyPath: - _, _ = w.Write([]byte(`{"type":"file","name":"` + skillMountReadyPath + `","path":"` + skillMountReadyPath + `","size":1}`)) - case r.Method == http.MethodGet && r.URL.Path == "/volumecontent/"+volumeID+"/file" && r.URL.Query().Get("path") == skillMountReadyPath: - _, _ = w.Write([]byte(readyMarker)) - case r.Method == http.MethodPut && r.URL.Path == "/volumecontent/"+volumeID+"/file": - path := r.URL.Query().Get("path") - data, err := io.ReadAll(r.Body) - if err != nil { - t.Errorf("read write body: %v", err) - w.WriteHeader(http.StatusInternalServerError) - return - } - *writes = append(*writes, path+"="+string(data)) - _, _ = w.Write([]byte(`{"type":"file","name":"` + path + `","path":"` + path + `","size":1}`)) - default: - t.Errorf("unexpected E2B request: %s %s", r.Method, r.URL.String()) - w.WriteHeader(http.StatusNotFound) - } - })) -} diff --git a/internal/skillprewarm/enqueuer.go b/internal/skillprewarm/enqueuer.go deleted file mode 100644 index b30059d85..000000000 --- a/internal/skillprewarm/enqueuer.go +++ /dev/null @@ -1,57 +0,0 @@ -package skillprewarm - -import ( - "context" - "encoding/json" - - "github.com/superduck-ai/open-managed-agents/internal/agentsnapshot" - "github.com/superduck-ai/open-managed-agents/internal/db" -) - -type SnapshotEnqueuer interface { - EnqueueSnapshot(ctx context.Context, workspaceID int64, snapshot json.RawMessage, source string, sourceID string, trigger string) error -} - -type FanoutEnqueuer interface { - EnqueueFanout(ctx context.Context, workspaceID int64, skillID string, version string) error -} - -type enqueueStore interface { - EnqueueSkillPrewarmSnapshotJob(ctx context.Context, input db.SkillPrewarmSnapshotJobInput) error - EnqueueSkillPrewarmFanoutJob(ctx context.Context, input db.SkillPrewarmFanoutJobInput) error -} - -type Enqueuer struct { - store enqueueStore -} - -func NewEnqueuer(database *db.DB) *Enqueuer { - if database == nil { - return &Enqueuer{} - } - return &Enqueuer{store: database} -} - -func (e *Enqueuer) EnqueueSnapshot(ctx context.Context, workspaceID int64, snapshot json.RawMessage, source string, sourceID string, trigger string) error { - if e == nil || e.store == nil || !agentsnapshot.SnapshotHasSkills(snapshot) { - return nil - } - return e.store.EnqueueSkillPrewarmSnapshotJob(ctx, db.SkillPrewarmSnapshotJobInput{ - WorkspaceID: workspaceID, - AgentSnapshot: snapshot, - Source: source, - SourceID: sourceID, - Trigger: trigger, - }) -} - -func (e *Enqueuer) EnqueueFanout(ctx context.Context, workspaceID int64, skillID string, version string) error { - if e == nil || e.store == nil || skillID == "" || version == "" { - return nil - } - return e.store.EnqueueSkillPrewarmFanoutJob(ctx, db.SkillPrewarmFanoutJobInput{ - WorkspaceID: workspaceID, - SkillID: skillID, - Version: version, - }) -} diff --git a/internal/skillprewarm/enqueuer_test.go b/internal/skillprewarm/enqueuer_test.go deleted file mode 100644 index 2faef4448..000000000 --- a/internal/skillprewarm/enqueuer_test.go +++ /dev/null @@ -1,69 +0,0 @@ -package skillprewarm - -import ( - "context" - "encoding/json" - "testing" - - "github.com/superduck-ai/open-managed-agents/internal/db" -) - -func TestEnqueuerSkipsSnapshotsWithoutSkills(t *testing.T) { - store := &fakeEnqueueStore{} - enqueuer := &Enqueuer{store: store} - - if err := enqueuer.EnqueueSnapshot(context.Background(), 1, json.RawMessage(`{"skills":[]}`), "agent", "agent_1", "agent_create"); err != nil { - t.Fatalf("EnqueueSnapshot error = %v", err) - } - if len(store.snapshots) != 0 { - t.Fatalf("snapshot jobs = %d, want 0", len(store.snapshots)) - } -} - -func TestEnqueuerEnqueuesSnapshotWithSkills(t *testing.T) { - store := &fakeEnqueueStore{} - enqueuer := &Enqueuer{store: store} - - snapshot := json.RawMessage(`{"skills":[{"type":"custom","skill_id":"skill_1","version":"latest"}]}`) - if err := enqueuer.EnqueueSnapshot(context.Background(), 7, snapshot, "agent", "agent_1", "agent_create"); err != nil { - t.Fatalf("EnqueueSnapshot error = %v", err) - } - if len(store.snapshots) != 1 { - t.Fatalf("snapshot jobs = %d, want 1", len(store.snapshots)) - } - got := store.snapshots[0] - if got.WorkspaceID != 7 || got.Source != "agent" || got.SourceID != "agent_1" || got.Trigger != "agent_create" { - t.Fatalf("snapshot input = %+v", got) - } -} - -func TestEnqueuerEnqueuesFanout(t *testing.T) { - store := &fakeEnqueueStore{} - enqueuer := &Enqueuer{store: store} - - if err := enqueuer.EnqueueFanout(context.Background(), 3, "skill_1", "20260708"); err != nil { - t.Fatalf("EnqueueFanout error = %v", err) - } - if len(store.fanouts) != 1 { - t.Fatalf("fanout jobs = %d, want 1", len(store.fanouts)) - } - got := store.fanouts[0] - if got.WorkspaceID != 3 || got.SkillID != "skill_1" || got.Version != "20260708" { - t.Fatalf("fanout input = %+v", got) - } -} - -type fakeEnqueueStore struct { - snapshots []db.SkillPrewarmSnapshotJobInput - fanouts []db.SkillPrewarmFanoutJobInput -} - -func (s *fakeEnqueueStore) EnqueueSkillPrewarmSnapshotJob(_ context.Context, input db.SkillPrewarmSnapshotJobInput) error { - s.snapshots = append(s.snapshots, input) - return nil -} - -func (s *fakeEnqueueStore) EnqueueSkillPrewarmFanoutJob(_ context.Context, input db.SkillPrewarmFanoutJobInput) error { - s.fanouts = append(s.fanouts, input) - return nil -} diff --git a/internal/skillprewarm/worker.go b/internal/skillprewarm/worker.go deleted file mode 100644 index 9d3758fe0..000000000 --- a/internal/skillprewarm/worker.go +++ /dev/null @@ -1,243 +0,0 @@ -package skillprewarm - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "log" - "os" - "time" - - "github.com/superduck-ai/open-managed-agents/internal/agentsnapshot" - "github.com/superduck-ai/open-managed-agents/internal/config" - "github.com/superduck-ai/open-managed-agents/internal/db" - "github.com/superduck-ai/open-managed-agents/internal/runtime/e2bruntime" - skillsapi "github.com/superduck-ai/open-managed-agents/internal/skills" - "github.com/superduck-ai/open-managed-agents/internal/storage" -) - -const ( - defaultPollInterval = 5 * time.Second - defaultLeaseTimeout = time.Minute - defaultJobLimit = 5 - defaultFanoutLimit = 100 - maxAttempts = 5 -) - -type JobStore interface { - LeaseSkillPrewarmJobs(ctx context.Context, workerID string, limit int, leaseDuration time.Duration) ([]db.SkillPrewarmJob, error) - CompleteSkillPrewarmJob(ctx context.Context, jobID int64, workerID string) error - FailSkillPrewarmJob(ctx context.Context, jobID int64, workerID string, attempts int, reason string, retryDelay time.Duration, maxAttempts int) error -} - -type SnapshotJobStore interface { - EnqueueSkillPrewarmSnapshotJob(ctx context.Context, input db.SkillPrewarmSnapshotJobInput) error -} - -type FanoutStore interface { - EnqueueSkillPrewarmFanoutJob(ctx context.Context, input db.SkillPrewarmFanoutJobInput) error - ListAgentsForSkillPrewarmFanout(ctx context.Context, workspaceID int64, skillID string, afterID int64, limit int) ([]db.Agent, bool, error) - ListDeploymentsForSkillPrewarmFanout(ctx context.Context, workspaceID int64, skillID string, afterID int64, limit int) ([]db.Deployment, bool, error) -} - -type RuntimeResolver interface { - ResolveAgentSnapshot(ctx context.Context, workspaceID int64, snapshot json.RawMessage) ([]skillsapi.RuntimeSkill, error) -} - -type SkillMountPreparer interface { - PrepareSkillMount(ctx context.Context, runtimeSkills []skillsapi.RuntimeSkill) (*e2bruntime.SkillMount, error) -} - -type Worker struct { - jobs JobStore - snapshots SnapshotJobStore - fanout FanoutStore - resolver RuntimeResolver - preparer SkillMountPreparer -} - -var agentSnapshotFromAgent = agentsnapshot.FromAgent - -type jobPayload struct { - Kind string `json:"kind"` - AgentSnapshot json.RawMessage `json:"agent_snapshot"` - Source string `json:"source"` - SourceID string `json:"source_id"` - Trigger string `json:"trigger"` - TriggerSkillID string `json:"trigger_skill_id"` - TriggerSkillVersion string `json:"trigger_skill_version"` - SkillID string `json:"skill_id"` - Version string `json:"version"` - AfterAgentID int64 `json:"after_agent_id"` - AfterDeploymentID int64 `json:"after_deployment_id"` -} - -func NewWorker(jobs JobStore, snapshots SnapshotJobStore, fanout FanoutStore, resolver RuntimeResolver, preparer SkillMountPreparer) *Worker { - return &Worker{jobs: jobs, snapshots: snapshots, fanout: fanout, resolver: resolver, preparer: preparer} -} - -func StartWorker(ctx context.Context, database *db.DB, objectStore storage.ObjectStore, cfg config.Config) { - if database == nil || objectStore == nil || !cfg.EnvironmentRunner.Enabled { - return - } - workerID := fmt.Sprintf("skill-prewarm-%d", os.Getpid()) - resolver := skillsapi.NewRuntimeResolver(cfg, database, objectStore) - worker := NewWorker(database, database, database, resolver, e2bruntime.NewProvider(cfg.E2B)) - go worker.loop(ctx, workerID) -} - -func (w *Worker) loop(ctx context.Context, workerID string) { - ticker := time.NewTicker(defaultPollInterval) - defer ticker.Stop() - for { - if err := w.RunOnce(ctx, workerID); err != nil { - log.Printf("skill prewarm worker=%s: %v", workerID, err) - } - select { - case <-ctx.Done(): - return - case <-ticker.C: - } - } -} - -func (w *Worker) RunOnce(ctx context.Context, workerID string) error { - if w == nil || w.jobs == nil { - return nil - } - jobs, err := w.jobs.LeaseSkillPrewarmJobs(ctx, workerID, defaultJobLimit, defaultLeaseTimeout) - if err != nil { - return err - } - var errs []error - for _, job := range jobs { - if err := w.processJob(ctx, job); err != nil { - delay := retryDelay(job.Attempts + 1) - if markErr := w.jobs.FailSkillPrewarmJob(ctx, job.ID, workerID, job.Attempts, err.Error(), delay, maxAttempts); errors.Is(markErr, db.ErrNotFound) { - continue - } else if markErr != nil { - errs = append(errs, fmt.Errorf("mark skill prewarm job %s retry: %w", job.ExternalID, markErr)) - } - errs = append(errs, fmt.Errorf("process skill prewarm job %s: %w", job.ExternalID, err)) - continue - } - if err := w.jobs.CompleteSkillPrewarmJob(ctx, job.ID, workerID); errors.Is(err, db.ErrNotFound) { - continue - } else if err != nil { - errs = append(errs, fmt.Errorf("complete skill prewarm job %s: %w", job.ExternalID, err)) - } - } - return errors.Join(errs...) -} - -func (w *Worker) processJob(ctx context.Context, job db.SkillPrewarmJob) error { - var payload jobPayload - if err := json.Unmarshal(job.Payload, &payload); err != nil { - return err - } - switch payload.Kind { - case "snapshot": - return w.processSnapshot(ctx, job.WorkspaceID, payload) - case "fanout": - return w.processFanout(ctx, job.WorkspaceID, payload) - default: - return fmt.Errorf("unknown skill prewarm job kind %q", payload.Kind) - } -} - -func (w *Worker) processSnapshot(ctx context.Context, workspaceID int64, payload jobPayload) error { - if !agentsnapshot.SnapshotHasSkills(payload.AgentSnapshot) { - return nil - } - if w.resolver == nil { - return errors.New("skill prewarm resolver is unavailable") - } - if w.preparer == nil { - return errors.New("skill mount preparer is unavailable") - } - runtimeSkills, err := w.resolver.ResolveAgentSnapshot(ctx, workspaceID, payload.AgentSnapshot) - if err != nil { - return err - } - if len(runtimeSkills) == 0 { - return nil - } - _, err = w.preparer.PrepareSkillMount(ctx, runtimeSkills) - return err -} - -func (w *Worker) processFanout(ctx context.Context, workspaceID int64, payload jobPayload) error { - if payload.SkillID == "" || payload.Version == "" { - return nil - } - if w.fanout == nil || w.snapshots == nil { - return errors.New("skill prewarm fanout store is unavailable") - } - agents, hasMoreAgents, err := w.fanout.ListAgentsForSkillPrewarmFanout(ctx, workspaceID, payload.SkillID, payload.AfterAgentID, defaultFanoutLimit) - if err != nil { - return err - } - deployments, hasMoreDeployments, err := w.fanout.ListDeploymentsForSkillPrewarmFanout(ctx, workspaceID, payload.SkillID, payload.AfterDeploymentID, defaultFanoutLimit) - if err != nil { - return err - } - - nextAfterAgentID := payload.AfterAgentID - for _, agent := range agents { - nextAfterAgentID = agent.ID - snapshot, err := agentSnapshotFromAgent(agent) - if err != nil { - log.Printf("skill prewarm fanout skip agent workspace_id=%d agent_id=%s skill_id=%s version=%s: %v", workspaceID, agent.ExternalID, payload.SkillID, payload.Version, err) - continue - } - if err := w.snapshots.EnqueueSkillPrewarmSnapshotJob(ctx, db.SkillPrewarmSnapshotJobInput{ - WorkspaceID: workspaceID, - AgentSnapshot: snapshot, - Source: "agent", - SourceID: agent.ExternalID, - Trigger: "skill_version_create", - TriggerSkillID: payload.SkillID, - TriggerSkillVersion: payload.Version, - }); err != nil { - return err - } - } - - nextAfterDeploymentID := payload.AfterDeploymentID - for _, deployment := range deployments { - nextAfterDeploymentID = deployment.ID - if err := w.snapshots.EnqueueSkillPrewarmSnapshotJob(ctx, db.SkillPrewarmSnapshotJobInput{ - WorkspaceID: workspaceID, - AgentSnapshot: deployment.AgentSnapshot, - Source: "deployment", - SourceID: deployment.ExternalID, - Trigger: "skill_version_create", - TriggerSkillID: payload.SkillID, - TriggerSkillVersion: payload.Version, - }); err != nil { - return err - } - } - - if hasMoreAgents || hasMoreDeployments { - return w.fanout.EnqueueSkillPrewarmFanoutJob(ctx, db.SkillPrewarmFanoutJobInput{ - WorkspaceID: workspaceID, - SkillID: payload.SkillID, - Version: payload.Version, - AfterAgentID: nextAfterAgentID, - AfterDeploymentID: nextAfterDeploymentID, - }) - } - return nil -} - -func retryDelay(attempts int) time.Duration { - if attempts < 1 { - attempts = 1 - } - if attempts > 5 { - attempts = 5 - } - return time.Duration(attempts*attempts) * time.Minute -} diff --git a/internal/skillprewarm/worker_test.go b/internal/skillprewarm/worker_test.go deleted file mode 100644 index eb6d1d743..000000000 --- a/internal/skillprewarm/worker_test.go +++ /dev/null @@ -1,287 +0,0 @@ -package skillprewarm - -import ( - "context" - "encoding/json" - "errors" - "testing" - "time" - - "github.com/superduck-ai/open-managed-agents/internal/db" - "github.com/superduck-ai/open-managed-agents/internal/runtime/e2bruntime" - skillsapi "github.com/superduck-ai/open-managed-agents/internal/skills" -) - -func TestWorkerSnapshotFailureRetries(t *testing.T) { - store := &fakeWorkerStore{ - jobs: []db.SkillPrewarmJob{{ - ID: 2, - ExternalID: "job_2", - WorkspaceID: 42, - Attempts: 1, - Payload: json.RawMessage(`{"kind":"snapshot","agent_snapshot":{"skills":[{"type":"custom","skill_id":"skill_1","version":"latest"}]}}`), - }}, - } - worker := NewWorker(store, store, store, &fakeResolver{err: errors.New("resolve failed")}, &fakePreparer{}) - - if err := worker.RunOnce(context.Background(), "worker_1"); err == nil { - t.Fatal("RunOnce error = nil, want failure") - } - if len(store.failed) != 1 { - t.Fatalf("failed = %+v, want one failure", store.failed) - } - if store.failed[0].id != 2 || store.failed[0].attempts != 1 || store.failed[0].maxAttempts != maxAttempts { - t.Fatalf("failure = %+v", store.failed[0]) - } - if len(store.completed) != 0 { - t.Fatalf("completed = %+v, want none", store.completed) - } -} - -func TestWorkerSnapshotPreparesMountAndCompletes(t *testing.T) { - store := &fakeWorkerStore{ - jobs: []db.SkillPrewarmJob{{ - ID: 1, - ExternalID: "job_1", - WorkspaceID: 42, - Payload: json.RawMessage(`{"kind":"snapshot","agent_snapshot":{"skills":[{"type":"custom","skill_id":"skill_1","version":"latest"}]}}`), - }}, - } - resolver := &fakeResolver{runtimeSkills: []skillsapi.RuntimeSkill{{Source: "custom", SkillID: "skill_1", Version: "1"}}} - preparer := &fakePreparer{} - worker := NewWorker(store, store, store, resolver, preparer) - - if err := worker.RunOnce(context.Background(), "worker_1"); err != nil { - t.Fatalf("RunOnce error = %v", err) - } - if len(store.completed) != 1 || store.completed[0] != 1 { - t.Fatalf("completed = %+v, want [1]", store.completed) - } - if len(store.failed) != 0 { - t.Fatalf("failed = %+v, want none", store.failed) - } - if resolver.workspaceID != 42 { - t.Fatalf("resolver workspaceID = %d, want 42", resolver.workspaceID) - } - if len(preparer.received) != 1 || preparer.received[0].SkillID != "skill_1" { - t.Fatalf("preparer received = %+v", preparer.received) - } -} - -func TestWorkerFanoutSkipsAgentSnapshotFailure(t *testing.T) { - originalSnapshotFromAgent := agentSnapshotFromAgent - agentSnapshotFromAgent = func(agent db.Agent) (json.RawMessage, error) { - if agent.ExternalID == "agent_bad" { - return nil, errors.New("snapshot failed") - } - return originalSnapshotFromAgent(agent) - } - t.Cleanup(func() { - agentSnapshotFromAgent = originalSnapshotFromAgent - }) - - store := &fakeWorkerStore{ - jobs: []db.SkillPrewarmJob{{ - ID: 5, - ExternalID: "job_5", - WorkspaceID: 42, - Payload: json.RawMessage(`{"kind":"fanout","skill_id":"skill_1","version":"20260708"}`), - }}, - agents: []db.Agent{{ - ID: 10, - ExternalID: "agent_bad", - CurrentVersion: 2, - Name: "bad agent", - Model: json.RawMessage(`{}`), - Skills: json.RawMessage(`[{"type":"custom","skill_id":"skill_1","version":"latest"}]`), - }}, - deployments: []db.Deployment{{ - ID: 20, - ExternalID: "dep_1", - AgentSnapshot: json.RawMessage(`{"skills":[{"type":"custom","skill_id":"skill_1","version":"latest"}]}`), - }}, - } - worker := NewWorker(store, store, store, &fakeResolver{}, &fakePreparer{}) - - if err := worker.RunOnce(context.Background(), "worker_1"); err != nil { - t.Fatalf("RunOnce error = %v", err) - } - if len(store.snapshotInputs) != 1 { - t.Fatalf("snapshot inputs = %+v, want deployment only", store.snapshotInputs) - } - if store.snapshotInputs[0].Source != "deployment" || store.snapshotInputs[0].SourceID != "dep_1" { - t.Fatalf("snapshot input = %+v, want deployment", store.snapshotInputs[0]) - } - if len(store.failed) != 0 { - t.Fatalf("failed = %+v, want none", store.failed) - } - if len(store.completed) != 1 || store.completed[0] != 5 { - t.Fatalf("completed = %+v, want [5]", store.completed) - } -} - -func TestWorkerFanoutEnqueuesSnapshotsAndContinuation(t *testing.T) { - store := &fakeWorkerStore{ - jobs: []db.SkillPrewarmJob{{ - ID: 3, - ExternalID: "job_3", - WorkspaceID: 42, - Payload: json.RawMessage(`{"kind":"fanout","skill_id":"skill_1","version":"20260708","after_agent_id":5,"after_deployment_id":7}`), - }}, - agents: []db.Agent{{ - ID: 10, - ExternalID: "agent_1", - CurrentVersion: 2, - Name: "agent", - Model: json.RawMessage(`{}`), - Skills: json.RawMessage(`[{"type":"custom","skill_id":"skill_1","version":"latest"}]`), - }}, - deployments: []db.Deployment{{ - ID: 20, - ExternalID: "dep_1", - AgentSnapshot: json.RawMessage(`{"skills":[{"type":"custom","skill_id":"skill_1","version":"latest"}]}`), - }}, - hasMoreAgents: true, - } - worker := NewWorker(store, store, store, &fakeResolver{}, &fakePreparer{}) - - if err := worker.RunOnce(context.Background(), "worker_1"); err != nil { - t.Fatalf("RunOnce error = %v", err) - } - if len(store.snapshotInputs) != 2 { - t.Fatalf("snapshot inputs = %+v, want 2", store.snapshotInputs) - } - for _, input := range store.snapshotInputs { - if input.Trigger != "skill_version_create" || input.TriggerSkillID != "skill_1" || input.TriggerSkillVersion != "20260708" { - t.Fatalf("snapshot input = %+v", input) - } - } - if store.snapshotInputs[0].Source != "agent" || store.snapshotInputs[0].SourceID != "agent_1" { - t.Fatalf("agent snapshot input = %+v", store.snapshotInputs[0]) - } - if store.snapshotInputs[1].Source != "deployment" || store.snapshotInputs[1].SourceID != "dep_1" { - t.Fatalf("deployment snapshot input = %+v", store.snapshotInputs[1]) - } - if len(store.fanoutInputs) != 1 { - t.Fatalf("fanout inputs = %+v, want continuation", store.fanoutInputs) - } - continuation := store.fanoutInputs[0] - if continuation.AfterAgentID != 10 || continuation.AfterDeploymentID != 20 { - t.Fatalf("continuation = %+v", continuation) - } - if len(store.completed) != 1 || store.completed[0] != 3 { - t.Fatalf("completed = %+v, want [3]", store.completed) - } -} - -func TestWorkerFanoutCompletesWhenNoMatches(t *testing.T) { - store := &fakeWorkerStore{ - jobs: []db.SkillPrewarmJob{{ - ID: 4, - ExternalID: "job_4", - WorkspaceID: 42, - Payload: json.RawMessage(`{"kind":"fanout","skill_id":"skill_1","version":"20260708"}`), - }}, - } - worker := NewWorker(store, store, store, &fakeResolver{}, &fakePreparer{}) - - if err := worker.RunOnce(context.Background(), "worker_1"); err != nil { - t.Fatalf("RunOnce error = %v", err) - } - if len(store.snapshotInputs) != 0 { - t.Fatalf("snapshot inputs = %+v, want none", store.snapshotInputs) - } - if len(store.fanoutInputs) != 0 { - t.Fatalf("fanout inputs = %+v, want no continuation", store.fanoutInputs) - } - if len(store.completed) != 1 || store.completed[0] != 4 { - t.Fatalf("completed = %+v, want [4]", store.completed) - } - if len(store.failed) != 0 { - t.Fatalf("failed = %+v, want none", store.failed) - } -} - -type fakeWorkerStore struct { - jobs []db.SkillPrewarmJob - completed []int64 - completedBy []string - failed []fakeFailure - snapshotInputs []db.SkillPrewarmSnapshotJobInput - fanoutInputs []db.SkillPrewarmFanoutJobInput - agents []db.Agent - deployments []db.Deployment - hasMoreAgents bool - hasMoreDeps bool -} - -type fakeFailure struct { - id int64 - workerID string - attempts int - reason string - delay time.Duration - maxAttempts int -} - -func (s *fakeWorkerStore) LeaseSkillPrewarmJobs(_ context.Context, _ string, _ int, _ time.Duration) ([]db.SkillPrewarmJob, error) { - jobs := s.jobs - s.jobs = nil - return jobs, nil -} - -func (s *fakeWorkerStore) CompleteSkillPrewarmJob(_ context.Context, jobID int64, workerID string) error { - s.completed = append(s.completed, jobID) - s.completedBy = append(s.completedBy, workerID) - return nil -} - -func (s *fakeWorkerStore) FailSkillPrewarmJob(_ context.Context, jobID int64, workerID string, attempts int, reason string, retryDelay time.Duration, maxAttempts int) error { - s.failed = append(s.failed, fakeFailure{id: jobID, workerID: workerID, attempts: attempts, reason: reason, delay: retryDelay, maxAttempts: maxAttempts}) - return nil -} - -func (s *fakeWorkerStore) EnqueueSkillPrewarmSnapshotJob(_ context.Context, input db.SkillPrewarmSnapshotJobInput) error { - s.snapshotInputs = append(s.snapshotInputs, input) - return nil -} - -func (s *fakeWorkerStore) EnqueueSkillPrewarmFanoutJob(_ context.Context, input db.SkillPrewarmFanoutJobInput) error { - s.fanoutInputs = append(s.fanoutInputs, input) - return nil -} - -func (s *fakeWorkerStore) ListAgentsForSkillPrewarmFanout(_ context.Context, _ int64, _ string, _ int64, _ int) ([]db.Agent, bool, error) { - return s.agents, s.hasMoreAgents, nil -} - -func (s *fakeWorkerStore) ListDeploymentsForSkillPrewarmFanout(_ context.Context, _ int64, _ string, _ int64, _ int) ([]db.Deployment, bool, error) { - return s.deployments, s.hasMoreDeps, nil -} - -type fakeResolver struct { - runtimeSkills []skillsapi.RuntimeSkill - workspaceID int64 - err error -} - -func (r *fakeResolver) ResolveAgentSnapshot(_ context.Context, workspaceID int64, _ json.RawMessage) ([]skillsapi.RuntimeSkill, error) { - r.workspaceID = workspaceID - if r.err != nil { - return nil, r.err - } - return r.runtimeSkills, nil -} - -type fakePreparer struct { - received []skillsapi.RuntimeSkill - err error -} - -func (p *fakePreparer) PrepareSkillMount(_ context.Context, runtimeSkills []skillsapi.RuntimeSkill) (*e2bruntime.SkillMount, error) { - p.received = append([]skillsapi.RuntimeSkill(nil), runtimeSkills...) - if p.err != nil { - return nil, p.err - } - return &e2bruntime.SkillMount{MountPath: e2bruntime.SandboxSkillsMountPath}, nil -} diff --git a/internal/skills/archive_limits.go b/internal/skills/archive_limits.go deleted file mode 100644 index e82c56d1e..000000000 --- a/internal/skills/archive_limits.go +++ /dev/null @@ -1,19 +0,0 @@ -package skills - -import ( - "archive/zip" - "fmt" -) - -const maxSkillArchiveUncompressedBytes uint64 = 500 * 1024 * 1024 - -func addZipFileUncompressedSize(total uint64, file *zip.File) (uint64, error) { - if file == nil { - return total, nil - } - next := total + file.UncompressedSize64 - if next < total || next > maxSkillArchiveUncompressedBytes { - return total, fmt.Errorf("skill archive uncompressed size exceeds %d bytes", maxSkillArchiveUncompressedBytes) - } - return next, nil -} diff --git a/internal/skills/handler.go b/internal/skills/handler.go index 9e3643cb5..eaad748d8 100644 --- a/internal/skills/handler.go +++ b/internal/skills/handler.go @@ -28,25 +28,19 @@ import ( ) const ( - skillsBeta = "skills-2025-10-02" - defaultSkillsLimit = 20 - maxSkillsLimit = 100 - defaultSkillVersionsLimit = 20 - maxSkillVersionsLimit = 1000 - skillArchiveContentType = "application/zip" - skillPrewarmEnqueueTimeout = 3 * time.Second + skillsBeta = "skills-2025-10-02" + defaultSkillsLimit = 20 + maxSkillsLimit = 100 + defaultSkillVersionsLimit = 20 + maxSkillVersionsLimit = 1000 + skillArchiveContentType = "application/zip" ) type Handler struct { - cfg config.Config - db *db.DB - store storage.ObjectStore - prewarm skillPrewarmFanoutEnqueuer - router chi.Router -} - -type skillPrewarmFanoutEnqueuer interface { - EnqueueFanout(ctx context.Context, workspaceID int64, skillID string, version string) error + cfg config.Config + db *db.DB + store storage.ObjectStore + router chi.Router } type skillResponse struct { @@ -81,15 +75,10 @@ type pageCursor struct { } func NewHandler(cfg config.Config, database *db.DB, store storage.ObjectStore) *Handler { - return NewHandlerWithSkillPrewarm(cfg, database, store, nil) -} - -func NewHandlerWithSkillPrewarm(cfg config.Config, database *db.DB, store storage.ObjectStore, prewarm skillPrewarmFanoutEnqueuer) *Handler { h := &Handler{ - cfg: cfg, - db: database, - store: store, - prewarm: prewarm, + cfg: cfg, + db: database, + store: store, } router := chi.NewRouter() router.NotFound(notFound) @@ -390,7 +379,7 @@ func (h *Handler) delete(w http.ResponseWriter, r *http.Request, skillID string) return } - _, versions, err := h.db.SoftDeleteSkill(r.Context(), principal.WorkspaceID, skillID) + _, _, err := h.db.SoftDeleteSkill(r.Context(), principal.WorkspaceID, skillID) if err != nil { if errors.Is(err, db.ErrNotFound) && h.isOfficialSDKFixtureSkill(principal, skillID) { httpapi.WriteJSON(w, http.StatusOK, map[string]string{"id": skillID, "type": "skill_deleted"}) @@ -404,9 +393,6 @@ func (h *Handler) delete(w http.ResponseWriter, r *http.Request, skillID string) httpapi.WriteError(w, r, httpapi.NewError(http.StatusInternalServerError, "api_error", "Could not delete skill")) return } - for _, version := range versions { - h.deleteObjectOrEnqueueCleanup(r.Context(), version) - } httpapi.WriteJSON(w, http.StatusOK, map[string]string{"id": skillID, "type": "skill_deleted"}) } @@ -493,7 +479,6 @@ func (h *Handler) createVersion(w http.ResponseWriter, r *http.Request, skillID httpapi.WriteError(w, r, httpapi.NewError(http.StatusInternalServerError, "api_error", "Could not create skill version")) return } - h.enqueueSkillPrewarmFanout(r.Context(), principal.WorkspaceID, skillID, version.Version) httpapi.WriteJSON(w, http.StatusOK, responseFromSkillVersion(version)) } @@ -665,7 +650,7 @@ func (h *Handler) deleteVersion(w http.ResponseWriter, r *http.Request, skillID, writeResolveVersionError(w, r, skillID, version, err) return } - deletedVersion, _, err := h.db.SoftDeleteSkillVersion(r.Context(), principal.WorkspaceID, skillID, resolved) + _, _, err = h.db.SoftDeleteSkillVersion(r.Context(), principal.WorkspaceID, skillID, resolved) if err != nil { if errors.Is(err, db.ErrNotFound) { httpapi.WriteError(w, r, httpapi.NewError(http.StatusNotFound, "not_found_error", "Skill version not found: "+version)) @@ -675,7 +660,6 @@ func (h *Handler) deleteVersion(w http.ResponseWriter, r *http.Request, skillID, httpapi.WriteError(w, r, httpapi.NewError(http.StatusInternalServerError, "api_error", "Could not delete skill version")) return } - h.deleteObjectOrEnqueueCleanup(r.Context(), deletedVersion) httpapi.WriteJSON(w, http.StatusOK, map[string]string{"id": resolved, "type": "skill_version_deleted"}) } @@ -791,17 +775,6 @@ func (h *Handler) downloadFixtureSkill(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write(data) } -func (h *Handler) enqueueSkillPrewarmFanout(ctx context.Context, workspaceID int64, skillID string, version string) { - if h == nil || h.prewarm == nil { - return - } - enqueueCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), skillPrewarmEnqueueTimeout) - defer cancel() - if err := h.prewarm.EnqueueFanout(enqueueCtx, workspaceID, skillID, version); err != nil { - log.Printf("enqueue skill prewarm fanout skill_id=%s version=%s: %v", skillID, version, err) - } -} - func (h *Handler) cleanupUploadedObjectAfterMetadataFailure(ctx context.Context, workspaceID int64, bucket, key, externalID string) { cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) defer cancel() @@ -813,15 +786,6 @@ func (h *Handler) cleanupUploadedObjectAfterMetadataFailure(ctx context.Context, } } -func (h *Handler) deleteObjectOrEnqueueCleanup(ctx context.Context, version db.SkillVersion) { - if err := h.store.Delete(ctx, version.S3Key, storage.DeleteOptions{}); err != nil { - log.Printf("delete skill object skill_id=%s version=%s key=%s: %v", version.SkillExternalID, version.Version, version.S3Key, err) - if enqueueErr := h.db.EnqueueObjectCleanupJob(ctx, version.WorkspaceID, version.S3Bucket, version.S3Key, version.ExternalID); enqueueErr != nil { - log.Printf("enqueue object cleanup skill_id=%s version=%s key=%s: %v", version.SkillExternalID, version.Version, version.S3Key, enqueueErr) - } - } -} - func responseFromSkill(skill db.Skill) skillResponse { return skillResponse{ ID: skill.ExternalID, diff --git a/internal/skills/mount_manifest.go b/internal/skills/mount_manifest.go deleted file mode 100644 index 11f59bb91..000000000 --- a/internal/skills/mount_manifest.go +++ /dev/null @@ -1,139 +0,0 @@ -package skills - -import ( - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "sort" - "strings" -) - -const MountManifestVersion = 1 - -type MountManifest struct { - Version int `json:"version"` - Skills []MountManifestSkill `json:"skills"` -} - -type MountManifestSkill struct { - Source string `json:"source"` - SkillID string `json:"skill_id"` - Version string `json:"version"` - Directory string `json:"directory"` - Name string `json:"name,omitempty"` - Description string `json:"description,omitempty"` - Filename string `json:"filename"` - SHA256 string `json:"sha256"` - SizeBytes int64 `json:"size_bytes"` -} - -func BuildMountManifest(runtimeSkills []RuntimeSkill) (MountManifest, []byte, string, error) { - ordered := append([]RuntimeSkill(nil), runtimeSkills...) - sort.SliceStable(ordered, func(i, j int) bool { - left := ordered[i] - right := ordered[j] - return strings.Join([]string{left.Source, left.SkillID, left.Version, left.Directory}, "\x00") < - strings.Join([]string{right.Source, right.SkillID, right.Version, right.Directory}, "\x00") - }) - - manifest := MountManifest{ - Version: MountManifestVersion, - Skills: make([]MountManifestSkill, 0, len(ordered)), - } - filenames := map[string]struct{}{} - for _, skill := range ordered { - entry, err := MountManifestEntry(skill) - if err != nil { - return MountManifest{}, nil, "", err - } - if _, ok := filenames[entry.Filename]; ok { - return MountManifest{}, nil, "", fmt.Errorf("duplicate skill mount filename %q", entry.Filename) - } - filenames[entry.Filename] = struct{}{} - manifest.Skills = append(manifest.Skills, entry) - } - - data, err := json.Marshal(manifest) - if err != nil { - return MountManifest{}, nil, "", err - } - sum := sha256.Sum256(data) - return manifest, data, hex.EncodeToString(sum[:]), nil -} - -func MountManifestEntry(skill RuntimeSkill) (MountManifestSkill, error) { - sha := strings.TrimSpace(skill.SHA256) - if len(skill.Archive) > 0 { - got := sha256Hex(skill.Archive) - if sha == "" { - sha = got - } else if got != sha { - return MountManifestSkill{}, fmt.Errorf("skill archive checksum mismatch for %s/%s@%s", skill.Source, skill.SkillID, skill.Version) - } - } - if sha == "" { - return MountManifestSkill{}, fmt.Errorf("skill checksum is required for %s/%s@%s", skill.Source, skill.SkillID, skill.Version) - } - sizeBytes := skill.SizeBytes - if sizeBytes == 0 && len(skill.Archive) > 0 { - sizeBytes = int64(len(skill.Archive)) - } - return MountManifestSkill{ - Source: strings.TrimSpace(skill.Source), - SkillID: strings.TrimSpace(skill.SkillID), - Version: strings.TrimSpace(skill.Version), - Directory: strings.TrimSpace(skill.Directory), - Name: strings.TrimSpace(skill.Name), - Description: strings.TrimSpace(skill.Description), - Filename: MountArchiveFilename(skill), - SHA256: sha, - SizeBytes: sizeBytes, - }, nil -} - -func MountArchiveFilename(skill RuntimeSkill) string { - sha := strings.TrimSpace(skill.SHA256) - if sha == "" && len(skill.Archive) > 0 { - sha = sha256Hex(skill.Archive) - } - if len(sha) > 12 { - sha = sha[:12] - } - if sha == "" { - sha = "unknown" - } - return strings.Join([]string{ - safeMountFilenamePart(skill.Source), - safeMountFilenamePart(skill.SkillID), - safeMountFilenamePart(firstNonEmpty(skill.Version, "latest")), - safeMountFilenamePart(sha), - }, "__") + ".zip" -} - -func safeMountFilenamePart(value string) string { - value = strings.TrimSpace(value) - var builder strings.Builder - for _, r := range value { - switch { - case r >= 'a' && r <= 'z': - builder.WriteRune(r) - case r >= 'A' && r <= 'Z': - builder.WriteRune(r) - case r >= '0' && r <= '9': - builder.WriteRune(r) - case r == '-' || r == '_' || r == '.': - builder.WriteRune(r) - default: - builder.WriteByte('_') - } - } - out := strings.Trim(builder.String(), "._-") - if out == "" { - return "unknown" - } - if len(out) > 80 { - return out[:80] - } - return out -} diff --git a/internal/skills/mount_manifest_test.go b/internal/skills/mount_manifest_test.go deleted file mode 100644 index 3229d642e..000000000 --- a/internal/skills/mount_manifest_test.go +++ /dev/null @@ -1,124 +0,0 @@ -package skills - -import ( - "archive/zip" - "bytes" - "context" - "errors" - "testing" -) - -func TestBuildMountManifestIsDeterministic(t *testing.T) { - first := []RuntimeSkill{ - {Source: "custom", SkillID: "skill_b", Version: "2", Directory: "b", SHA256: "bbbb", SizeBytes: 10}, - {Source: "custom", SkillID: "skill_a", Version: "1", Directory: "a", SHA256: "aaaa", SizeBytes: 20}, - } - second := []RuntimeSkill{first[1], first[0]} - - _, firstData, firstSHA, err := BuildMountManifest(first) - if err != nil { - t.Fatalf("BuildMountManifest(first) error = %v", err) - } - _, secondData, secondSHA, err := BuildMountManifest(second) - if err != nil { - t.Fatalf("BuildMountManifest(second) error = %v", err) - } - if firstSHA != secondSHA || string(firstData) != string(secondData) { - t.Fatalf("manifest is not deterministic:\n%s\n%s", firstData, secondData) - } -} - -func TestBuildMountManifestUsesResolvedVersionForCacheKey(t *testing.T) { - latest := []RuntimeSkill{{ - Source: "custom", - SkillID: "skill_a", - RequestedVersion: "latest", - Version: "20260708", - Directory: "runtime-skill", - SHA256: "aaaa", - SizeBytes: 20, - }} - pinned := []RuntimeSkill{{ - Source: "custom", - SkillID: "skill_a", - RequestedVersion: "20260708", - Version: "20260708", - Directory: "runtime-skill", - SHA256: "aaaa", - SizeBytes: 20, - }} - - _, latestData, latestSHA, err := BuildMountManifest(latest) - if err != nil { - t.Fatalf("BuildMountManifest(latest) error = %v", err) - } - _, pinnedData, pinnedSHA, err := BuildMountManifest(pinned) - if err != nil { - t.Fatalf("BuildMountManifest(pinned) error = %v", err) - } - if latestSHA != pinnedSHA || string(latestData) != string(pinnedData) { - t.Fatalf("requested version changed cache manifest:\n%s\n%s", latestData, pinnedData) - } - if bytes.Contains(latestData, []byte("requested_version")) { - t.Fatalf("cache manifest contains requested_version: %s", latestData) - } -} - -func TestBuildMountManifestDoesNotLoadArchive(t *testing.T) { - _, _, _, err := BuildMountManifest([]RuntimeSkill{{ - Source: "custom", - SkillID: "skill_a", - Version: "20260708", - Directory: "runtime-skill", - SHA256: "aaaa", - SizeBytes: 20, - archiveLoader: func(context.Context) ([]byte, error) { - return nil, errors.New("archive loader should not be called") - }, - }}) - if err != nil { - t.Fatalf("BuildMountManifest() error = %v", err) - } -} - -func TestAddZipFileUncompressedSizeRejectsOversize(t *testing.T) { - _, err := addZipFileUncompressedSize(0, &zip.File{ - FileHeader: zip.FileHeader{ - Name: "runtime-skill/huge.bin", - UncompressedSize64: maxSkillArchiveUncompressedBytes + 1, - }, - }) - if err == nil { - t.Fatal("addZipFileUncompressedSize error = nil, want oversize error") - } -} - -func TestInspectSkillArchiveAcceptsDirectoryEntries(t *testing.T) { - var buf bytes.Buffer - writer := zip.NewWriter(&buf) - dirHeader := &zip.FileHeader{Name: "runtime-skill/"} - dirHeader.SetMode(0755) - if _, err := writer.CreateHeader(dirHeader); err != nil { - t.Fatalf("CreateHeader(dir) error = %v", err) - } - fileHeader := &zip.FileHeader{Name: "runtime-skill/SKILL.md"} - fileHeader.SetMode(0644) - file, err := writer.CreateHeader(fileHeader) - if err != nil { - t.Fatalf("CreateHeader(SKILL.md) error = %v", err) - } - if _, err := file.Write([]byte("# Runtime Skill\n")); err != nil { - t.Fatalf("Write(SKILL.md) error = %v", err) - } - if err := writer.Close(); err != nil { - t.Fatalf("Close() error = %v", err) - } - - dir, skillMD, err := inspectSkillArchiveBytes(buf.Bytes()) - if err != nil { - t.Fatalf("inspectSkillArchiveBytes() error = %v", err) - } - if dir != "runtime-skill" || string(skillMD) != "# Runtime Skill\n" { - t.Fatalf("inspectSkillArchiveBytes() = %q, %q", dir, skillMD) - } -} diff --git a/internal/skills/resolver.go b/internal/skills/resolver.go index 386ba4587..a7d2d4d1b 100644 --- a/internal/skills/resolver.go +++ b/internal/skills/resolver.go @@ -1,16 +1,10 @@ package skills import ( - "archive/zip" - "bytes" "context" - "crypto/sha256" - "encoding/hex" "encoding/json" "errors" "fmt" - "io" - "os" "strings" "github.com/superduck-ai/open-managed-agents/internal/config" @@ -19,32 +13,22 @@ import ( ) type RuntimeResolver struct { - db *db.DB - store storage.ObjectStore + db *db.DB } type RuntimeSkill struct { Source string SkillID string + VersionUUID string RequestedVersion string Version string Directory string Name string Description string + S3Bucket string + S3Key string SHA256 string - Archive []byte SizeBytes int64 - archiveLoader func(context.Context) ([]byte, error) -} - -func (s RuntimeSkill) LoadArchive(ctx context.Context) ([]byte, error) { - if len(s.Archive) > 0 { - return s.Archive, nil - } - if s.archiveLoader == nil { - return nil, fmt.Errorf("skill archive loader is unavailable for %s/%s@%s", s.Source, s.SkillID, s.Version) - } - return s.archiveLoader(ctx) } type runtimeSkillRef struct { @@ -53,11 +37,8 @@ type runtimeSkillRef struct { Version string `json:"version"` } -func NewRuntimeResolver(_ config.Config, database *db.DB, store storage.ObjectStore) *RuntimeResolver { - return &RuntimeResolver{ - db: database, - store: store, - } +func NewRuntimeResolver(_ config.Config, database *db.DB, _ storage.ObjectStore) *RuntimeResolver { + return &RuntimeResolver{db: database} } func (r *RuntimeResolver) ResolveAgentSnapshot(ctx context.Context, workspaceID int64, snapshot json.RawMessage) ([]RuntimeSkill, error) { @@ -157,25 +138,24 @@ func (r *RuntimeResolver) resolveBuiltin(ctx context.Context, ref runtimeSkillRe } return RuntimeSkill{}, err } - versionRecord := record return RuntimeSkill{ Source: "anthropic", SkillID: record.SkillExternalID, + VersionUUID: record.UUID, RequestedVersion: ref.Version, Version: record.Version, Directory: record.Directory, Name: firstNonEmpty(record.Name, record.Directory, record.SkillExternalID), Description: record.Description, + S3Bucket: record.S3Bucket, + S3Key: record.S3Key, SHA256: record.SHA256, SizeBytes: record.SizeBytes, - archiveLoader: func(ctx context.Context) ([]byte, error) { - return r.loadBuiltinArchive(ctx, versionRecord) - }, }, nil } func (r *RuntimeResolver) resolveCustom(ctx context.Context, workspaceID int64, ref runtimeSkillRef) (RuntimeSkill, error) { - if r.db == nil || r.store == nil { + if r.db == nil { return RuntimeSkill{}, errors.New("custom skill resolver is unavailable") } var record db.SkillVersion @@ -194,142 +174,18 @@ func (r *RuntimeResolver) resolveCustom(ctx context.Context, workspaceID int64, } return RuntimeSkill{}, err } - versionRecord := record return RuntimeSkill{ Source: "custom", SkillID: ref.SkillID, + VersionUUID: record.UUID, RequestedVersion: ref.Version, Version: record.Version, Directory: record.Directory, Name: record.Name, Description: record.Description, + S3Bucket: record.S3Bucket, + S3Key: record.S3Key, SHA256: record.SHA256, SizeBytes: record.SizeBytes, - archiveLoader: func(ctx context.Context) ([]byte, error) { - return r.loadCustomArchive(ctx, versionRecord) - }, }, nil } - -func (r *RuntimeResolver) loadBuiltinArchive(ctx context.Context, record db.BuiltinSkillVersion) ([]byte, error) { - if r.store == nil { - return nil, errors.New("built-in skill object store is unavailable") - } - object, err := r.store.Open(ctx, record.S3Key, nil) - if err != nil { - return nil, fmt.Errorf("read built-in skill object %s@%s: %w", record.SkillExternalID, record.Version, err) - } - defer object.Body.Close() - data, err := io.ReadAll(object.Body) - if err != nil { - return nil, fmt.Errorf("read built-in skill archive %s@%s: %w", record.SkillExternalID, record.Version, err) - } - if err := validateRuntimeSkillArchive(data, "built-in skill", record.SkillExternalID, record.Version, record.Directory, record.SHA256, record.SizeBytes); err != nil { - return nil, err - } - return data, nil -} - -func (r *RuntimeResolver) loadCustomArchive(ctx context.Context, record db.SkillVersion) ([]byte, error) { - object, err := r.store.Open(ctx, record.S3Key, nil) - if err != nil { - return nil, fmt.Errorf("read custom skill object %s@%s: %w", record.SkillExternalID, record.Version, err) - } - defer object.Body.Close() - data, err := io.ReadAll(object.Body) - if err != nil { - return nil, fmt.Errorf("read custom skill archive %s@%s: %w", record.SkillExternalID, record.Version, err) - } - if err := validateRuntimeSkillArchive(data, "custom skill", record.SkillExternalID, record.Version, record.Directory, record.SHA256, record.SizeBytes); err != nil { - return nil, err - } - return data, nil -} - -func validateRuntimeSkillArchive(data []byte, label string, skillID string, version string, directory string, sha string, sizeBytes int64) error { - if int64(len(data)) != sizeBytes { - return fmt.Errorf("%s archive size mismatch %s@%s: got %d want %d", label, skillID, version, len(data), sizeBytes) - } - if got := sha256Hex(data); got != sha { - return fmt.Errorf("%s archive checksum mismatch %s@%s", label, skillID, version) - } - archiveDirectory, _, err := inspectSkillArchiveBytes(data) - if err != nil { - return fmt.Errorf("inspect %s %s@%s: %w", label, skillID, version, err) - } - if archiveDirectory != directory { - return fmt.Errorf("%s %s@%s directory changed from %q to %q", label, skillID, version, directory, archiveDirectory) - } - return nil -} - -func inspectSkillArchiveBytes(data []byte) (string, []byte, error) { - reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) - if err != nil { - return "", nil, err - } - return inspectSkillZipFiles(reader.File) -} - -func inspectSkillZipFiles(files []*zip.File) (string, []byte, error) { - var top string - var skillMD []byte - var uncompressedSize uint64 - for _, file := range files { - name := strings.ReplaceAll(file.Name, "\\", "/") - if name == "" { - continue - } - if strings.HasPrefix(name, "/") || strings.Contains(name, "\x00") { - return "", nil, fmt.Errorf("invalid zip path %q", file.Name) - } - cleanName := strings.TrimSuffix(name, "/") - if cleanName == "" { - return "", nil, fmt.Errorf("invalid zip path %q", file.Name) - } - parts := strings.Split(cleanName, "/") - if len(parts) == 0 || parts[0] == "" || parts[0] == "." || parts[0] == ".." { - return "", nil, fmt.Errorf("invalid zip top-level path %q", file.Name) - } - for _, part := range parts { - if part == "" || part == "." || part == ".." { - return "", nil, fmt.Errorf("invalid zip path %q", file.Name) - } - } - if file.Mode()&os.ModeSymlink != 0 { - return "", nil, fmt.Errorf("zip contains symlink: %s", file.Name) - } - if top == "" { - top = parts[0] - } else if top != parts[0] { - return "", nil, fmt.Errorf("multiple top-level directories: %s and %s", top, parts[0]) - } - if file.FileInfo().IsDir() { - continue - } - var err error - uncompressedSize, err = addZipFileUncompressedSize(uncompressedSize, file) - if err != nil { - return "", nil, err - } - if cleanName == top+"/SKILL.md" { - data, err := readZipFile(file, MaxSkillPackageBytes) - if err != nil { - return "", nil, err - } - skillMD = data - } - } - if top == "" { - return "", nil, errors.New("archive is empty") - } - if len(skillMD) == 0 { - return "", nil, fmt.Errorf("%s/SKILL.md not found", top) - } - return top, skillMD, nil -} - -func sha256Hex(data []byte) string { - sum := sha256.Sum256(data) - return hex.EncodeToString(sum[:]) -} diff --git a/internal/skills/seed.go b/internal/skills/seed.go index 035a38398..24e5d22b7 100644 --- a/internal/skills/seed.go +++ b/internal/skills/seed.go @@ -118,11 +118,6 @@ func SeedBuiltinSkills(ctx context.Context, database *db.DB, store storage.Objec if err != nil { return BuiltinSeedResult{}, err } - for _, version := range prunedVersions { - if err := store.Delete(ctx, version.S3Key, storage.DeleteOptions{}); err != nil { - log.Printf("seed builtin skills: delete pruned object failed for %s version %s (%s): %v", version.SkillExternalID, version.Version, version.S3Key, err) - } - } result.Pruned = len(prunedVersions) } return result, nil diff --git a/main.go b/main.go index 83e168bb3..737c5a84e 100644 --- a/main.go +++ b/main.go @@ -22,7 +22,6 @@ import ( "github.com/superduck-ai/open-managed-agents/internal/observability" "github.com/superduck-ai/open-managed-agents/internal/platformsession" "github.com/superduck-ai/open-managed-agents/internal/runtime/e2bruntime" - "github.com/superduck-ai/open-managed-agents/internal/skillprewarm" skillsapi "github.com/superduck-ai/open-managed-agents/internal/skills" "github.com/superduck-ai/open-managed-agents/internal/storage" "github.com/superduck-ai/open-managed-agents/internal/webhooks" @@ -110,7 +109,6 @@ func main() { log.Fatalf("create environment runner: %v", err) } environmentRunner.Start(ctx) - skillprewarm.StartWorker(ctx, database, objectStore, cfg) webhooks.StartWorker(ctx, database, cfg.Webhook) server := &http.Server{ diff --git a/tests/environments_runner_cloud_test.go b/tests/environments_runner_cloud_test.go index 1293c7e06..46ee901bf 100644 --- a/tests/environments_runner_cloud_test.go +++ b/tests/environments_runner_cloud_test.go @@ -258,7 +258,7 @@ func TestEnvironmentRunnerLaunchesManagedAgentCloudSession(t *testing.T) { if len(provider.rcloneLaunches) != 1 { t.Fatalf("rclone launches = %d, want 1", len(provider.rcloneLaunches)) } - if got, want := provider.operations, []string{"rclone-config-write", "rclone-config-chmod", "rclone-start", "rclone-ready", "rclone-config-cleanup", "environment-manager"}; !slices.Equal(got, want) { + if got, want := provider.operations, []string{"rclone-config-write", "rclone-config-chmod", "rclone-mount-preparation", "rclone-start", "rclone-ready", "rclone-config-cleanup", "environment-manager"}; !slices.Equal(got, want) { t.Fatalf("sandbox operation order = %#v, want %#v", got, want) } if len(provider.writes) != 1 || provider.writes[0].path != "/tmp/rclone-mount-config.json" { @@ -369,7 +369,7 @@ func TestEnvironmentRunnerKillsSandboxWhenRcloneReadyFails(t *testing.T) { if !processed { t.Fatal("runner did not process queued session work") } - if got, want := provider.operations, []string{"rclone-config-write", "rclone-config-chmod", "rclone-start", "rclone-ready", "rclone-config-cleanup"}; !slices.Equal(got, want) { + if got, want := provider.operations, []string{"rclone-config-write", "rclone-config-chmod", "rclone-mount-preparation", "rclone-start", "rclone-ready", "rclone-config-cleanup"}; !slices.Equal(got, want) { t.Fatalf("sandbox operation order = %#v, want %#v", got, want) } if len(provider.launches) != 0 { @@ -539,20 +539,17 @@ func TestEnvironmentRunnerInstallsManagedAgentCustomSkill(t *testing.T) { if len(provider.launches) != 1 { t.Fatalf("sandbox launches = %#v, want one environment-manager background process", provider.launches) } - if len(provider.skillMounts) != 1 { - t.Fatalf("skill mounts = %#v, want one prepared mount", provider.skillMounts) - } - mount := provider.skillMounts[0].mount - if mount.MountPath != e2bruntime.SandboxSkillsMountPath || mount.VolumeName == "" || mount.ManifestSHA256 == "" { - t.Fatalf("unexpected skill mount: %#v", mount) + filesystem, err := app.db.GetFilestoreFilesystemBySession(ctx, getDefaultDBIDs(t, app.db).WorkspaceID, session.ID) + if err != nil { + t.Fatalf("get session filestore: %v", err) } - if len(mount.Skills) != 1 || mount.Skills[0].Directory != "runtime-skill" { - t.Fatalf("unexpected skill mount manifest: %#v", mount.Skills) + projections, err := app.db.ListFilestoreSkillArchives(ctx, getDefaultDBIDs(t, app.db).WorkspaceID, filesystem.ID) + if err != nil { + t.Fatalf("list skill archive projections: %v", err) } - if len(provider.skillMounts[0].runtimeSkills) != 1 { - t.Fatalf("runtime skills = %#v, want one", provider.skillMounts[0].runtimeSkills) + if len(projections) != 1 || projections[0].VirtualPath != "/skills/runtime-skill" || projections[0].Source != "custom" { + t.Fatalf("skill archive projections = %#v", projections) } - assertZipContains(t, provider.skillMounts[0].runtimeSkills[0].Archive, "runtime-skill/SKILL.md") if len(provider.creates) != 1 { t.Fatalf("sandbox creates = %#v, want one", provider.creates) } @@ -560,12 +557,8 @@ func TestEnvironmentRunnerInstallsManagedAgentCustomSkill(t *testing.T) { if err := json.Unmarshal(provider.creates[0].metadata, &workMetadata); err != nil { t.Fatalf("decode work metadata: %v", err) } - rawMount, ok := workMetadata[e2bruntime.SkillMountMetadataKey].(map[string]any) - if !ok { - t.Fatalf("work metadata missing skill mount: %#v", workMetadata) - } - if rawMount["mount_path"] != e2bruntime.SandboxSkillsMountPath || rawMount["volume_name"] != mount.VolumeName { - t.Fatalf("unexpected work skill mount metadata: %#v", rawMount) + if _, exists := workMetadata["managed_agent_skills_mount"]; exists { + t.Fatalf("work metadata still contains legacy skill mount: %#v", workMetadata) } if strings.Contains(provider.launches[0].command, "installed managed agent skills") || strings.Contains(provider.launches[0].command, "$HOME/.claude/skills") { @@ -573,7 +566,7 @@ func TestEnvironmentRunnerInstallsManagedAgentCustomSkill(t *testing.T) { } } -func TestEnvironmentRunnerFailsWhenSkillResolverUnavailable(t *testing.T) { +func TestEnvironmentRunnerProjectsSkillsWithoutDownloadingArchives(t *testing.T) { ctx := context.Background() cfg, err := config.Load() @@ -628,17 +621,28 @@ func TestEnvironmentRunnerFailsWhenSkillResolverUnavailable(t *testing.T) { } defer client.Beta.Sessions.Delete(context.Background(), session.ID, anthropic.BetaSessionDeleteParams{}) - provider := &recordingRunnerProvider{sandboxID: "sandbox-should-not-start"} + provider := &recordingRunnerProvider{sandboxID: "sandbox-skill-projection-only"} runner := newManagedAgentRunner(t, app, provider, cfg, nil) processed, err := runner.RunOnce(ctx, "runner-cloud-no-resolver-test") - if err == nil || !strings.Contains(err.Error(), "custom skill resolver is unavailable") { - t.Fatalf("RunOnce error = %v, want custom resolver error", err) + if err != nil { + t.Fatalf("RunOnce error = %v", err) } if !processed { t.Fatal("runner did not process queued session work") } - if len(provider.creates) != 0 || len(provider.commands) != 0 || len(provider.launches) != 0 { - t.Fatalf("provider should not be called after missing resolver: creates=%#v commands=%#v launches=%#v", provider.creates, provider.commands, provider.launches) + if len(provider.creates) != 1 || len(provider.launches) != 1 { + t.Fatalf("provider launch counts = creates:%d launches:%d, want one each", len(provider.creates), len(provider.launches)) + } + filesystem, err := app.db.GetFilestoreFilesystemBySession(ctx, getDefaultDBIDs(t, app.db).WorkspaceID, session.ID) + if err != nil { + t.Fatalf("get session filestore: %v", err) + } + projections, err := app.db.ListFilestoreSkillArchives(ctx, getDefaultDBIDs(t, app.db).WorkspaceID, filesystem.ID) + if err != nil { + t.Fatalf("list projections: %v", err) + } + if len(projections) != 1 || projections[0].VirtualPath != "/skills/missing-resolver-skill" { + t.Fatalf("projections = %#v", projections) } } @@ -891,7 +895,6 @@ type recordingRunnerProvider struct { operations []string kills []string creates []recordedSandboxCreate - skillMounts []recordedSkillMount } type recordedSandboxResolve struct { @@ -916,11 +919,6 @@ type recordedSandboxCreate struct { resolution e2bruntime.Resolution } -type recordedSkillMount struct { - mount e2bruntime.SkillMount - runtimeSkills []skillsapi.RuntimeSkill -} - func newManagedAgentRunner( t *testing.T, app *testApp, @@ -1021,6 +1019,8 @@ func (p *recordingRunnerProvider) RunCommand(_ context.Context, sandboxID string switch { case strings.HasPrefix(command, "chmod 0600 "): operation = "rclone-config-chmod" + case strings.HasPrefix(command, "mkdir -p '/root/.claude'"): + operation = "rclone-mount-preparation" case strings.HasPrefix(command, "rm -f ") && strings.Contains(command, "rclone-mount-config.json"): operation = "rclone-config-cleanup" } @@ -1031,33 +1031,6 @@ func (p *recordingRunnerProvider) RunCommand(_ context.Context, sandboxID string return nil } -func (p *recordingRunnerProvider) PrepareSkillMount(ctx context.Context, runtimeSkills []skillsapi.RuntimeSkill) (*e2bruntime.SkillMount, error) { - manifest, _, manifestSHA256, err := skillsapi.BuildMountManifest(runtimeSkills) - if err != nil { - return nil, err - } - mount := e2bruntime.SkillMount{ - MountPath: e2bruntime.SandboxSkillsMountPath, - VolumeName: "test-managed-agent-skills-" + manifestSHA256[:12], - ManifestSHA256: manifestSHA256, - Skills: manifest.Skills, - } - copied := make([]skillsapi.RuntimeSkill, 0, len(runtimeSkills)) - for _, skill := range runtimeSkills { - archive, err := skill.LoadArchive(ctx) - if err != nil { - return nil, err - } - skill.Archive = archive - copied = append(copied, skill) - } - p.skillMounts = append(p.skillMounts, recordedSkillMount{ - mount: mount, - runtimeSkills: copied, - }) - return &mount, nil -} - func (p *recordingRunnerProvider) StartBackgroundCommand(_ context.Context, sandboxID string, command string, stdin []byte) error { if sandboxID != p.sandboxID { p.launches = append(p.launches, recordedSandboxLaunch{sandboxID: sandboxID, command: "wrong sandbox: " + command}) diff --git a/tests/filestore_db_test.go b/tests/filestore_db_test.go index 16338e860..81899e868 100644 --- a/tests/filestore_db_test.go +++ b/tests/filestore_db_test.go @@ -297,7 +297,7 @@ func TestCreateSessionProvisionsFilesystem(t *testing.T) { if err := rows.Err(); err != nil { t.Fatalf("iterate Session Filestore roots: %v", err) } - wantRootPaths := []string{"/outputs", "/tool_results", "/transcripts", "/uploads"} + wantRootPaths := []string{"/outputs", "/skills", "/tool_results", "/transcripts", "/uploads"} if !reflect.DeepEqual(rootPaths, wantRootPaths) { t.Fatalf("Session Filestore roots = %v, want %v", rootPaths, wantRootPaths) } @@ -743,6 +743,17 @@ func TestDeleteSessionQueuesBoundedFilesystemCleanup(t *testing.T) { }); err != nil { t.Fatalf("put cleanup file: %v", err) } + if err := app.db.ReplaceFilestoreSkillArchives(context.Background(), workspaceID, created.ExternalID, []db.FilestoreSkillArchiveInput{{ + Source: "custom", + SkillVersionUUID: uuid.NewString(), + Directory: "cleanup-skill", + S3Bucket: "filestore-session-delete", + S3Key: "catalog/cleanup-skill.zip", + SizeBytes: 128, + SHA256: strings.Repeat("a", 64), + }}); err != nil { + t.Fatalf("project cleanup skill: %v", err) + } var entryOrganizationUUID, entryWorkspaceUUID, entryFilesystemUUID string var entryAPIKeyUUID, entrySessionUUID string var entryCodeSessionUUID *string @@ -814,20 +825,26 @@ func TestDeleteSessionQueuesBoundedFilesystemCleanup(t *testing.T) { if err != nil || !done { t.Fatalf("process filesystem cleanup = done %v, error %v", done, err) } - var activeEntries, cleanupObjects int + var activeEntries, activeSkillArchives, cleanupObjects int if err := app.db.Pool.QueryRow(context.Background(), ` select (select count(*) from filestore_entries where filesystem_uuid = $1 and deleted_at is null), + (select count(*) from filestore_skill_archives where filesystem_uuid = $1), (select count(*) from jobs where type = 'filestore_object_cleanup' and payload->>'filesystem_uuid' = $1::text and payload->>'workspace_uuid' = $2 and not (payload ? 'filesystem_id') and payload->>'reason' = 'session_deleted') - `, filesystem.UUID, workspaceUUID).Scan(&activeEntries, &cleanupObjects); err != nil { + `, filesystem.UUID, workspaceUUID).Scan(&activeEntries, &activeSkillArchives, &cleanupObjects); err != nil { t.Fatalf("load processed cleanup state: %v", err) } - if activeEntries != 0 || cleanupObjects != 1 { - t.Fatalf("processed cleanup = active entries %d, object jobs %d; want 0, 1", activeEntries, cleanupObjects) + if activeEntries != 0 || activeSkillArchives != 0 || cleanupObjects != 1 { + t.Fatalf( + "processed cleanup = active entries %d, skill archives %d, object jobs %d; want 0, 0, 1", + activeEntries, + activeSkillArchives, + cleanupObjects, + ) } if _, err := app.db.ProcessLeasedFilestoreFilesystemCleanupJob( context.Background(), diff --git a/tests/filestore_fixed_roots_test.go b/tests/filestore_fixed_roots_test.go index 0671a0876..9fbf331e1 100644 --- a/tests/filestore_fixed_roots_test.go +++ b/tests/filestore_fixed_roots_test.go @@ -15,7 +15,7 @@ func TestFilestoreFixedRootsRejectGenericDirectoryMutation(t *testing.T) { makeFilestoreTestDirectory(t, fixture, "/outputs/cross-root") t.Run("rejects moving fixed roots", func(t *testing.T) { - for _, rootPath := range []string{"/outputs", "/uploads", "/transcripts", "/tool_results"} { + for _, rootPath := range []string{"/outputs", "/skills", "/uploads", "/transcripts", "/tool_results"} { _, err := fixture.app.db.MoveFilestoreDirectory(ctx, db.MoveFilestoreDirectoryInput{ WorkspaceID: fixture.workspaceID, FilesystemID: fixture.filesystem.ID, @@ -29,7 +29,7 @@ func TestFilestoreFixedRootsRejectGenericDirectoryMutation(t *testing.T) { }) t.Run("rejects removing fixed roots", func(t *testing.T) { - for _, rootPath := range []string{"/outputs", "/uploads", "/transcripts", "/tool_results"} { + for _, rootPath := range []string{"/outputs", "/skills", "/uploads", "/transcripts", "/tool_results"} { _, err := fixture.app.db.RemoveFilestoreDirectory(ctx, db.RemoveFilestoreDirectoryInput{ WorkspaceID: fixture.workspaceID, FilesystemID: fixture.filesystem.ID, diff --git a/tests/filestore_provision_roots_test.go b/tests/filestore_provision_roots_test.go index d26074f8e..b407f4297 100644 --- a/tests/filestore_provision_roots_test.go +++ b/tests/filestore_provision_roots_test.go @@ -276,6 +276,7 @@ func assertFixedFilestoreRoots(t *testing.T, app *testApp, filesystem db.Filesto got := filestoreRootKinds(t, app, filesystem) want := map[string]string{ "/outputs": db.FilestoreEntryKindDirectory, + "/skills": db.FilestoreEntryKindDirectory, "/uploads": db.FilestoreEntryKindDirectory, "/transcripts": db.FilestoreEntryKindDirectory, "/tool_results": db.FilestoreEntryKindDirectory, diff --git a/tests/skill_prewarm_api_test.go b/tests/skill_prewarm_api_test.go deleted file mode 100644 index 480cf8c74..000000000 --- a/tests/skill_prewarm_api_test.go +++ /dev/null @@ -1,181 +0,0 @@ -package tests - -import ( - "context" - "encoding/json" - "errors" - "net/http" - "testing" - "time" - - "github.com/superduck-ai/open-managed-agents/internal/db" -) - -func TestSkillPrewarmJobFailureStoresLastErrorAt(t *testing.T) { - app := newTestAppWithStore(t, nil, newFakeStore("skill-prewarm-failure-bucket")) - defer app.close() - - ctx := context.Background() - ids := getDefaultDBIDs(t, app.db) - clearSkillPrewarmJobs(t, app, ids.WorkspaceID) - sourceID := "agent_prewarm_failure_" + time.Now().Format("150405.000000000") - if err := app.db.EnqueueSkillPrewarmSnapshotJob(ctx, db.SkillPrewarmSnapshotJobInput{ - WorkspaceID: ids.WorkspaceID, - AgentSnapshot: json.RawMessage(`{"skills":[{"type":"custom","skill_id":"skill_missing","version":"latest"}]}`), - Source: "agent", - SourceID: sourceID, - Trigger: "test_failure", - }); err != nil { - t.Fatalf("enqueue skill prewarm snapshot job: %v", err) - } - defer app.db.Pool.Exec(ctx, `delete from jobs where type = 'skill_prewarm' and payload->>'source_id' = $1`, sourceID) - - jobs, err := app.db.LeaseSkillPrewarmJobs(ctx, "skill-prewarm-failure-test", 1, time.Minute) - if err != nil { - t.Fatalf("lease skill prewarm jobs: %v", err) - } - if len(jobs) != 1 || jobs[0].WorkspaceID != ids.WorkspaceID { - t.Fatalf("leased jobs = %+v, want one default workspace job", jobs) - } - if err := app.db.FailSkillPrewarmJob(ctx, jobs[0].ID, "skill-prewarm-failure-test", jobs[0].Attempts, "boom", time.Minute, 5); err != nil { - t.Fatalf("fail skill prewarm job: %v", err) - } - - var status, lastError, lastErrorAt string - var attempts int - if err := app.db.Pool.QueryRow(ctx, ` - select status, attempts, payload->>'last_error', payload->>'last_error_at' - from jobs - where id = $1 - `, jobs[0].ID).Scan(&status, &attempts, &lastError, &lastErrorAt); err != nil { - t.Fatalf("load failed skill prewarm job: %v", err) - } - if status != "retry" || attempts != 1 || lastError != "boom" { - t.Fatalf("failed job status=%q attempts=%d last_error=%q", status, attempts, lastError) - } - if _, err := time.Parse(time.RFC3339Nano, lastErrorAt); err != nil { - t.Fatalf("last_error_at = %q, want RFC3339Nano timestamp: %v", lastErrorAt, err) - } -} - -func TestSkillPrewarmJobStateTransitionsRequireLeaseOwner(t *testing.T) { - app := newTestAppWithStore(t, nil, newFakeStore("skill-prewarm-lease-bucket")) - defer app.close() - - ctx := context.Background() - ids := getDefaultDBIDs(t, app.db) - clearSkillPrewarmJobs(t, app, ids.WorkspaceID) - sourceID := "agent_prewarm_lease_" + time.Now().Format("150405.000000000") - if err := app.db.EnqueueSkillPrewarmSnapshotJob(ctx, db.SkillPrewarmSnapshotJobInput{ - WorkspaceID: ids.WorkspaceID, - AgentSnapshot: json.RawMessage(`{"skills":[{"type":"custom","skill_id":"skill_missing","version":"latest"}]}`), - Source: "agent", - SourceID: sourceID, - Trigger: "test_lease", - }); err != nil { - t.Fatalf("enqueue skill prewarm snapshot job: %v", err) - } - defer app.db.Pool.Exec(ctx, `delete from jobs where type = 'skill_prewarm' and payload->>'source_id' = $1`, sourceID) - - const workerID = "skill-prewarm-owner-test" - jobs, err := app.db.LeaseSkillPrewarmJobs(ctx, workerID, 1, time.Minute) - if err != nil { - t.Fatalf("lease skill prewarm jobs: %v", err) - } - if len(jobs) != 1 { - t.Fatalf("leased jobs = %+v, want one", jobs) - } - - if err := app.db.CompleteSkillPrewarmJob(ctx, jobs[0].ID, "other-worker"); !errors.Is(err, db.ErrNotFound) { - t.Fatalf("complete with wrong worker error = %v, want ErrNotFound", err) - } - if err := app.db.FailSkillPrewarmJob(ctx, jobs[0].ID, "other-worker", jobs[0].Attempts, "boom", time.Minute, 5); !errors.Is(err, db.ErrNotFound) { - t.Fatalf("fail with wrong worker error = %v, want ErrNotFound", err) - } - - var status, lockedBy string - if err := app.db.Pool.QueryRow(ctx, ` - select status, locked_by - from jobs - where id = $1 - `, jobs[0].ID).Scan(&status, &lockedBy); err != nil { - t.Fatalf("load skill prewarm job: %v", err) - } - if status != "running" || lockedBy != workerID { - t.Fatalf("job status=%q locked_by=%q, want running/%s", status, lockedBy, workerID) - } - - if err := app.db.CompleteSkillPrewarmJob(ctx, jobs[0].ID, workerID); err != nil { - t.Fatalf("complete with lease owner: %v", err) - } - if err := app.db.Pool.QueryRow(ctx, `select status from jobs where id = $1`, jobs[0].ID).Scan(&status); err != nil { - t.Fatalf("load completed skill prewarm job: %v", err) - } - if status != "completed" { - t.Fatalf("job status = %q, want completed", status) - } -} - -func TestSkillPrewarmAPIEnqueuesJobs(t *testing.T) { - app := newTestAppWithStore(t, nil, newFakeStore("skill-prewarm-api-bucket")) - defer app.close() - - skill := createSkill(t, app, "prewarm-skill") - defer deleteSkill(t, app, skill.ID) - - agent := createAgent(t, app, `{ - "model":"claude-opus-4-6", - "name":"prewarm-agent", - "skills":[{"type":"custom","skill_id":`+quoteJSON(skill.ID)+`}] - }`) - defer cleanupAgentRows(t, app.db, agent.ID) - assertSkillPrewarmJobCount(t, app, `payload->>'kind' = 'snapshot' and payload->>'source' = 'agent' and payload->>'source_id' = $1 and payload->>'trigger' = 'agent_create'`, agent.ID, 1) - - env := createEnvironment(t, app, `{"name":"prewarm-env"}`) - defer cleanupEnvironmentRows(t, app.db, env.ID) - deployment := createDeployment(t, app, minimalDeploymentBody(agent.ID, env.ID)) - defer cleanupDeploymentRows(t, app, deployment.ID) - assertSkillPrewarmJobCount(t, app, `payload->>'kind' = 'snapshot' and payload->>'source' = 'deployment' and payload->>'source_id' = $1 and payload->>'trigger' = 'deployment_create'`, deployment.ID, 1) - - updatedAgent := updateAgent(t, app, agent.ID, `{"version":1,"name":"prewarm-agent-renamed"}`, http.StatusOK) - if updatedAgent.Name != "prewarm-agent-renamed" { - t.Fatalf("updated agent name = %q, want prewarm-agent-renamed", updatedAgent.Name) - } - assertSkillPrewarmJobCount(t, app, `payload->>'kind' = 'snapshot' and payload->>'source' = 'agent' and payload->>'source_id' = $1 and payload->>'trigger' = 'agent_update'`, agent.ID, 0) - - updatedDeployment := updateDeployment(t, app, deployment.ID, `{"name":"prewarm-deployment-renamed"}`) - if updatedDeployment.Name != "prewarm-deployment-renamed" { - t.Fatalf("updated deployment name = %q, want prewarm-deployment-renamed", updatedDeployment.Name) - } - assertSkillPrewarmJobCount(t, app, `payload->>'kind' = 'snapshot' and payload->>'source' = 'deployment' and payload->>'source_id' = $1 and payload->>'trigger' = 'deployment_update'`, deployment.ID, 0) - - time.Sleep(time.Millisecond) - body, contentType := skillMultipartBody(t, "", []skillUploadFile{ - {FieldName: "files", Filename: "prewarm-skill/SKILL.md", Content: "---\nname: Prewarm Skill v2\ndescription: v2\n---\n\n# Prewarm Skill v2"}, - }) - resp := doSkillRequest(t, app, http.MethodPost, "/v1/skills/"+skill.ID+"/versions?beta=true", body, defaultTestKey, true, contentType) - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - t.Fatalf("create skill version status = %d, want 200: %s", resp.StatusCode, readAll(t, resp.Body)) - } - assertSkillPrewarmJobCount(t, app, `payload->>'kind' = 'fanout' and payload->>'skill_id' = $1`, skill.ID, 1) -} - -func assertSkillPrewarmJobCount(t *testing.T, app *testApp, predicate string, arg string, want int) { - t.Helper() - var count int - query := `select count(*) from jobs where type = 'skill_prewarm' and ` + predicate - if err := app.db.Pool.QueryRow(context.Background(), query, arg).Scan(&count); err != nil { - t.Fatalf("count skill prewarm jobs: %v", err) - } - if count != want { - t.Fatalf("skill prewarm job count = %d, want %d for %s", count, want, predicate) - } -} - -func clearSkillPrewarmJobs(t *testing.T, app *testApp, workspaceID int64) { - t.Helper() - if _, err := app.db.Pool.Exec(context.Background(), `delete from jobs where workspace_id = $1 and type = 'skill_prewarm'`, workspaceID); err != nil { - t.Fatalf("clear skill prewarm jobs: %v", err) - } -} diff --git a/tests/skills_api_test.go b/tests/skills_api_test.go index dd04f36d6..6520e708f 100644 --- a/tests/skills_api_test.go +++ b/tests/skills_api_test.go @@ -396,7 +396,7 @@ func TestSkillsAPI(t *testing.T) { } }) - t.Run("success delete object queues cleanup job", func(t *testing.T) { + t.Run("success delete retains archive for active projections", func(t *testing.T) { cleanupStore := newFakeStore("fake-bucket") cleanupStore.deleteErr = errors.New("object storage unavailable") cleanupApp := newTestAppWithStore(t, nil, cleanupStore) @@ -418,6 +418,9 @@ func TestSkillsAPI(t *testing.T) { if resp.StatusCode != http.StatusOK { t.Fatalf("delete cleanup skill status = %d, want 200: %s", resp.StatusCode, readAll(t, resp.Body)) } + if _, ok := cleanupStore.objects[objectKey]; !ok { + t.Fatalf("skill archive %s was removed while a session projection may still reference it", objectKey) + } var jobCount int if err := cleanupApp.db.Pool.QueryRow(context.Background(), ` @@ -430,8 +433,8 @@ func TestSkillsAPI(t *testing.T) { `, objectKey).Scan(&jobCount); err != nil { t.Fatalf("count cleanup jobs: %v", err) } - if jobCount != 1 { - t.Fatalf("cleanup job count = %d, want 1", jobCount) + if jobCount != 0 { + t.Fatalf("cleanup job count = %d, want 0", jobCount) } }) diff --git a/tests/skills_seed_test.go b/tests/skills_seed_test.go index 463334577..e557a221c 100644 --- a/tests/skills_seed_test.go +++ b/tests/skills_seed_test.go @@ -162,8 +162,8 @@ func TestSeedBuiltinSkillsPrune(t *testing.T) { if _, err := app.db.GetBuiltinSkill(context.Background(), pdfID); !errors.Is(err, db.ErrNotFound) { t.Fatalf("get pruned pdf err = %v, want not found", err) } - if _, ok := store.objects[pdfVersion.S3Key]; ok { - t.Fatalf("pruned pdf object %s still present in store", pdfVersion.S3Key) + if _, ok := store.objects[pdfVersion.S3Key]; !ok { + t.Fatalf("pruned pdf object %s was removed while a session projection may still reference it", pdfVersion.S3Key) } } From c6f4eaa58ab785ce9a60ad7f810c366a28c700be Mon Sep 17 00:00:00 2001 From: arthur-zhang Date: Sun, 26 Jul 2026 20:56:51 +0800 Subject: [PATCH 2/9] Delegate mount destination creation to multimount --- docs/design/be/e2b-sandbox-image-contract.md | 6 +++--- docs/design/be/filestore.md | 2 +- .../design/be/managed-agent-skills-runtime.md | 6 +++--- docs/design/be/runtime-configuration.md | 2 +- internal/environments/rclone_filestore.go | 7 ------- .../environments/rclone_filestore_test.go | 21 +++++-------------- internal/environments/runner.go | 5 ----- tests/environments_runner_cloud_test.go | 6 ++---- 8 files changed, 15 insertions(+), 40 deletions(-) diff --git a/docs/design/be/e2b-sandbox-image-contract.md b/docs/design/be/e2b-sandbox-image-contract.md index cfedc5c0f..d4043d6e1 100644 --- a/docs/design/be/e2b-sandbox-image-contract.md +++ b/docs/design/be/e2b-sandbox-image-contract.md @@ -11,7 +11,7 @@ - `/opt/rclone/rclone-filestore` 存在、是 Linux 可执行文件,并支持 `multimount --config `。 - Sandbox 具有 rclone-filestore 创建五个 FUSE mount 所需的设备、capability 和 mount namespace 权限。 - `/usr/local/bin/environment-manager` 与 `/opt/claude-code/bin/claude` 默认可执行;这两个路径仍可通过既有 Environment Runner 配置覆盖。 -- Runner 可以创建 `/mnt/user-data/outputs`、`/mnt/session/uploads`、`/mnt/transcripts`、`/mnt/user-data/tool_results` 和 `/root/.claude/skills` 的挂载点,并能写入 `/tmp`。 +- `rclone-filestore multimount` 可以创建 `/mnt/user-data/outputs`、`/mnt/session/uploads`、`/mnt/transcripts`、`/mnt/user-data/tool_results` 和 `/root/.claude/skills` 的挂载点,并能写入 `/tmp`。 - 镜像和 Environment Manager 不能重新创建 `/root/.claude/skills` 软链,也不能依赖 `/mnt/skills` 或 `/workspace/skills` 解压目录。 `rclone-filestore` 路径固定为 `/opt/rclone/rclone-filestore`,不提供 `rclone_filestore_path` 配置。镜像若缺失该文件或没有执行权限,后台命令启动失败或 ready marker 在 `20s` 内不会出现;Runner 会把 Sandbox 标记为失败并 Kill,不会启动 Environment Manager。 @@ -43,7 +43,7 @@ File resource 不新增独立 FUSE mount,也不创建逐文件软链接。`mou 同一 `filesystem_id` 的 namespace 在 mount 存活期间继续由数据库维护。运行中增删 File resource 不重建 FUSE mount;现有 Sandbox 在 `/uploads` 的 `1s` metadata cache 刷新后读取到新的 namespace 状态。 -`/root/.claude/skills` 是 Filestore `/skills` 的直接 mount。Runner 启动 rclone 前会删除该路径上的遗留软链并创建目录;archive 解包视图由 Filestore 服务虚拟生成,镜像和 Environment Manager 不下载、不复制、不解压 skill zip。该 mount 使用只读 Token、`readonly=true`、`uid=0`、`gid=0`、目录权限 `0555` 和文件权限 `0444`。 +`/root/.claude/skills` 是 Filestore `/skills` 的直接 mount。`rclone-filestore multimount` 在内部对 destination 执行 `MkdirAll`,Runner 不再单独准备挂载目录;archive 解包视图由 Filestore 服务虚拟生成,镜像和 Environment Manager 不下载、不复制、不解压 skill zip。该 mount 使用只读 Token、`readonly=true`、`uid=0`、`gid=0`、目录权限 `0555` 和文件权限 `0444`。 ## 启动顺序合同 @@ -78,7 +78,7 @@ File resource 与 `/uploads` entry 的一致性由 resource 写事务负责,Ru 3. `/mnt/user-data/outputs` 可写,另外四个 destination 拒绝写入;`/root/.claude/skills//SKILL.md` 可由 Claude Code 直接发现。 4. 只通过 Files API 上传对象并给 Session 添加 File resource,不在测试侧直接写 Filestore;确认 resource 写入已创建 `/uploads/workspace/data.csv` 的数据库引用,启动后可通过 `/mnt/session/uploads/workspace/data.csv` 读取且写入失败。 5. 正常路径 ready 后 `/tmp/rclone-mount-config.json` 不存在;模拟删除失败时确认最多重试三次、记录脱敏告警且 Sandbox 继续运行。日志和进程命令行不包含 Filestore Token,outputs token 不能写入其他 source。 -6. 镜像中预置遗留 `/root/.claude/skills` 软链时,Runner 会在 mount 前删除它;Environment Manager 启动后不得重建软链或产生 `/workspace/skills` 解压副本。 +6. 镜像不得预置 `/root/.claude/skills` 软链;`rclone-filestore multimount` 负责创建 destination,Environment Manager 启动后不得创建软链或产生 `/workspace/skills` 解压副本。 7. ready 探测失败或 `20s` 内未出现 marker 均不会启动 Environment Manager,并会终止 Sandbox。 Token 当前固定一小时有效且不刷新;长生命周期 Sandbox 的续签不属于此镜像合同。 diff --git a/docs/design/be/filestore.md b/docs/design/be/filestore.md index 09206ca05..deeb12fba 100644 --- a/docs/design/be/filestore.md +++ b/docs/design/be/filestore.md @@ -118,7 +118,7 @@ filesystem 的数据库 namespace 在 Session/resource 写事务完成时已经 五个挂载统一使用 `vfs_cache_mode=full` 和 `vfs_cache_max_size=1G`。前四个保留 `uid=999`、`gid=1000`、目录权限 `0755` 和文件权限 `0644`;`/skills` 使用 `uid=0`、`gid=0`、目录权限 `0555` 和文件权限 `0444`,以匹配 Claude Code 的 root discovery 路径。`/outputs` 使用读写 Token,其余四个 source 共享只读 Token;两类 Token 都绑定当前 public Session 唯一 filesystem 的 external ID,`service_url` 直接取 `code_session.sandbox_api_base_url`。 -Runner 在启动 rclone 前执行固定 mount preparation:确保 `/root/.claude` 存在;如果 `/root/.claude/skills` 是旧版遗留软链则删除该软链;随后确保目标是目录。它不会创建新的 skill 软链,也不会复制或解压 archive。若该路径被普通文件占用,准备阶段失败并进入统一 Sandbox 清理。 +Runner 不执行独立的 mount preparation;`rclone-filestore multimount` 在内部对每个 destination 执行 `MkdirAll`。镜像和 Environment Manager 不得创建 skill 软链,也不会复制或解压 archive;destination 无法创建时,由 multimount 启动或 ready 阶段失败并进入统一 Sandbox 清理。 Runner 先通过 E2B Files API 完整写入强类型 JSON,再将 `/tmp/rclone-mount-config.json` 权限设置为 `0600`。文件写入完成后才直接执行固定镜像命令,不使用 stdin bootstrap、临时文件或 shell trap: diff --git a/docs/design/be/managed-agent-skills-runtime.md b/docs/design/be/managed-agent-skills-runtime.md index a316edc04..340e62cb0 100644 --- a/docs/design/be/managed-agent-skills-runtime.md +++ b/docs/design/be/managed-agent-skills-runtime.md @@ -21,8 +21,8 @@ Environment Runner 在创建 cloud managed-agent Sandbox 前完成: 已启动 Session 的视图。 4. 在一只 `sqlx.Tx` 中锁定 Session filesystem 和 namespace,确保 `/skills` 固定根存在, 并原子替换该 filesystem 的 `filestore_skill_archives` 投影集合。 -5. 创建 Sandbox 后,Runner 删除遗留的 `/root/.claude/skills` 软链(如果它确实是软链), - 创建同名目录,再启动 rclone-filestore 的五个固定 mount。 +5. 创建 Sandbox 后,Runner 直接启动 rclone-filestore 的五个固定 mount;multimount + 在内部对 destination 执行 `MkdirAll`,Runner 不执行独立 mount preparation。 6. `/skills` 使用只读 Filestore Token,直接挂载到 `/root/.claude/skills`。rclone ready 后才启动 Environment Manager;Environment Manager 不再处理 skill。 @@ -130,7 +130,7 @@ Session filesystem 删除后沿用现有有界 cleanup job。最后一批文件 - `/skills` list、recursive list、metadata 和 ranged read 返回 archive 成员; - checksum、路径穿越、缺少 `SKILL.md` 等损坏 archive fail closed; - 所有 `/skills` mutation 被拒绝; -- rclone 第五个 mount 直达 `/root/.claude/skills`,启动前只移除遗留软链; +- rclone 第五个 mount 直达 `/root/.claude/skills`,destination 由 multimount 内部创建; - Runner 不写 legacy mount metadata,E2B runtime 不创建 skill volume; - catalog soft delete/prune 不破坏活动 Session 投影; - Session filesystem cleanup 会删除投影 row,但不删除借用的 catalog object。 diff --git a/docs/design/be/runtime-configuration.md b/docs/design/be/runtime-configuration.md index 2e30ad7e3..3b3883abe 100644 --- a/docs/design/be/runtime-configuration.md +++ b/docs/design/be/runtime-configuration.md @@ -106,7 +106,7 @@ Docker Compose 同样只挂载一份完整 YAML,不再通过 `.env` 插值业 Cloud Session 的固定 Filestore 挂载也使用 `code_session.sandbox_api_base_url` 作为 rclone `service_url`,因此启用 Environment Runner 时该地址必须同时能从 E2B Sandbox 访问 Filestore HTTP 路由。Runner 通过 E2B Files API 每 `200ms` 探测 `/tmp/rclone-mounts/ready`,最长 `20s`;这两个值是运行时合同,不提供 YAML 配置。 -`rclone-filestore` 的路径不是配置项。E2B 镜像合同固定要求可执行文件位于 `/opt/rclone/rclone-filestore`;缺失或不可执行会使该次 Sandbox 启动失败。五个 source、destination、cache、权限、ready/config/state 路径同样属于版本化运行时合同,不能通过租户数据、Session resource 或 YAML 改写。第五个 source 固定为 Filestore `/skills`,以只读方式直接挂载到 `/root/.claude/skills`;Runner 会在启动 mount 前删除该路径上的遗留软链。 +`rclone-filestore` 的路径不是配置项。E2B 镜像合同固定要求可执行文件位于 `/opt/rclone/rclone-filestore`;缺失或不可执行会使该次 Sandbox 启动失败。五个 source、destination、cache、权限、ready/config/state 路径同样属于版本化运行时合同,不能通过租户数据、Session resource 或 YAML 改写。第五个 source 固定为 Filestore `/skills`,以只读方式直接挂载到 `/root/.claude/skills`;destination 由 `rclone-filestore multimount` 内部创建,Runner 不执行独立准备命令。 默认 Docker Compose 是显式的本地开发配置:`env: dev`、`database.auto_migrate: true`,并省略 `code_session.jwt_signing_private_key_file`。Code Session 与 Filestore 各自使用 oma-server 进程级临时 Ed25519 密钥,服务重启会轮换信任并使此前签发的两类 JWT 失效。独立运行的 `cmd/filestore-token` 不会生成另一把临时密钥;手动签发必须为 CLI 与服务配置同一个持久化私钥文件,否则服务无法验证另一个进程签出的 token。生产环境中,两个签发器可读取同一份稳定的只读 Ed25519 私钥,但使用不同的 claims 与验证入口,不会互相代用。生产部署必须使用 `env: prod`、稳定的只读私钥路径并关闭自动迁移;缺少 JWT 私钥时启动边界会拒绝生产配置。 diff --git a/internal/environments/rclone_filestore.go b/internal/environments/rclone_filestore.go index d4e45da0c..7c3fd71f7 100644 --- a/internal/environments/rclone_filestore.go +++ b/internal/environments/rclone_filestore.go @@ -150,13 +150,6 @@ func rcloneConfigPermissionsCommand() string { return "chmod 0600 " + shellQuote(rcloneConfigPath) } -func rcloneMountPreparationCommand() string { - claudeDirectory := "/root/.claude" - return "mkdir -p " + shellQuote(claudeDirectory) + - " && if [ -L " + shellQuote(rcloneSkillsDestination) + " ]; then rm -f " + shellQuote(rcloneSkillsDestination) + "; fi" + - " && mkdir -p " + shellQuote(rcloneSkillsDestination) -} - func rcloneConfigCleanupCommand() string { return "rm -f " + shellQuote(rcloneConfigPath) } diff --git a/internal/environments/rclone_filestore_test.go b/internal/environments/rclone_filestore_test.go index e4e873214..e572ae62d 100644 --- a/internal/environments/rclone_filestore_test.go +++ b/internal/environments/rclone_filestore_test.go @@ -73,9 +73,8 @@ func TestRcloneCommandsKeepTokensOutOfCommandText(t *testing.T) { } start := rcloneStartCommand() permissions := rcloneConfigPermissionsCommand() - preparation := rcloneMountPreparationCommand() cleanup := rcloneConfigCleanupCommand() - if strings.Contains(start+permissions+preparation+cleanup, secret) { + if strings.Contains(start+permissions+cleanup, secret) { t.Fatal("rclone command text contains token") } if !strings.Contains(string(configPayload), secret) { @@ -94,10 +93,6 @@ func TestRcloneCommandsKeepTokensOutOfCommandText(t *testing.T) { if permissions != "chmod 0600 '/tmp/rclone-mount-config.json'" { t.Fatalf("rclone permissions command = %q", permissions) } - if !strings.Contains(preparation, "rm -f '/root/.claude/skills'") || - !strings.Contains(preparation, "mkdir -p '/root/.claude/skills'") { - t.Fatalf("rclone mount preparation does not replace the legacy symlink: %q", preparation) - } } func TestStartRcloneFilestoreFailures(t *testing.T) { @@ -122,30 +117,24 @@ func TestStartRcloneFilestoreFailures(t *testing.T) { wantError: errRcloneConfigPermissions, wantRunCalls: 2, }, - { - name: "mount preparation", - provider: &rcloneTestProvider{runErrors: []error{nil, providerFailure, nil}}, - wantError: errRcloneMountPreparation, - wantRunCalls: 3, - }, { name: "start", provider: &rcloneTestProvider{backgroundErr: providerFailure}, wantError: errRcloneProcessStart, - wantRunCalls: 3, + wantRunCalls: 2, wantLaunchCalls: 1, }, { name: "ready", provider: &rcloneTestProvider{fileExistsErr: providerFailure}, wantError: errRcloneReadiness, - wantRunCalls: 3, + wantRunCalls: 2, wantLaunchCalls: 1, }, { name: "cleanup retries without failing ready sandbox", - provider: &rcloneTestProvider{ready: true, runErrors: []error{nil, nil, providerFailure, providerFailure, providerFailure}}, - wantRunCalls: 5, + provider: &rcloneTestProvider{ready: true, runErrors: []error{nil, providerFailure, providerFailure, providerFailure}}, + wantRunCalls: 4, wantLaunchCalls: 1, }, } diff --git a/internal/environments/runner.go b/internal/environments/runner.go index e4f9b45ab..04d3ba1fd 100644 --- a/internal/environments/runner.go +++ b/internal/environments/runner.go @@ -24,7 +24,6 @@ import ( var ( errRcloneConfigWrite = errors.New("rclone-filestore config write failed") errRcloneConfigPermissions = errors.New("rclone-filestore config permission update failed") - errRcloneMountPreparation = errors.New("rclone-filestore mount preparation failed") errRcloneProcessStart = errors.New("rclone-filestore process start failed") errRcloneReadiness = errors.New("rclone-filestore readiness check failed") errEnvironmentManagerStart = errors.New("environment manager process start failed") @@ -500,10 +499,6 @@ func (r *Runner) startRcloneFilestore(ctx context.Context, sandboxID string, lau _ = r.provider.RunCommand(ctx, sandboxID, rcloneConfigCleanupCommand(), rcloneCommandGraceTimeout) return logRcloneStageFailure("config_permissions", errRcloneConfigPermissions, err) } - if err := r.provider.RunCommand(ctx, sandboxID, rcloneMountPreparationCommand(), rcloneCommandGraceTimeout); err != nil { - _ = r.provider.RunCommand(ctx, sandboxID, rcloneConfigCleanupCommand(), rcloneCommandGraceTimeout) - return logRcloneStageFailure("mount_preparation", errRcloneMountPreparation, err) - } if err := r.provider.StartBackgroundCommand(ctx, sandboxID, rcloneStartCommand(), nil); err != nil { _ = r.provider.RunCommand(ctx, sandboxID, rcloneConfigCleanupCommand(), rcloneCommandGraceTimeout) return logRcloneStageFailure("process_start", errRcloneProcessStart, err) diff --git a/tests/environments_runner_cloud_test.go b/tests/environments_runner_cloud_test.go index 46ee901bf..e2854129c 100644 --- a/tests/environments_runner_cloud_test.go +++ b/tests/environments_runner_cloud_test.go @@ -258,7 +258,7 @@ func TestEnvironmentRunnerLaunchesManagedAgentCloudSession(t *testing.T) { if len(provider.rcloneLaunches) != 1 { t.Fatalf("rclone launches = %d, want 1", len(provider.rcloneLaunches)) } - if got, want := provider.operations, []string{"rclone-config-write", "rclone-config-chmod", "rclone-mount-preparation", "rclone-start", "rclone-ready", "rclone-config-cleanup", "environment-manager"}; !slices.Equal(got, want) { + if got, want := provider.operations, []string{"rclone-config-write", "rclone-config-chmod", "rclone-start", "rclone-ready", "rclone-config-cleanup", "environment-manager"}; !slices.Equal(got, want) { t.Fatalf("sandbox operation order = %#v, want %#v", got, want) } if len(provider.writes) != 1 || provider.writes[0].path != "/tmp/rclone-mount-config.json" { @@ -369,7 +369,7 @@ func TestEnvironmentRunnerKillsSandboxWhenRcloneReadyFails(t *testing.T) { if !processed { t.Fatal("runner did not process queued session work") } - if got, want := provider.operations, []string{"rclone-config-write", "rclone-config-chmod", "rclone-mount-preparation", "rclone-start", "rclone-ready", "rclone-config-cleanup"}; !slices.Equal(got, want) { + if got, want := provider.operations, []string{"rclone-config-write", "rclone-config-chmod", "rclone-start", "rclone-ready", "rclone-config-cleanup"}; !slices.Equal(got, want) { t.Fatalf("sandbox operation order = %#v, want %#v", got, want) } if len(provider.launches) != 0 { @@ -1019,8 +1019,6 @@ func (p *recordingRunnerProvider) RunCommand(_ context.Context, sandboxID string switch { case strings.HasPrefix(command, "chmod 0600 "): operation = "rclone-config-chmod" - case strings.HasPrefix(command, "mkdir -p '/root/.claude'"): - operation = "rclone-mount-preparation" case strings.HasPrefix(command, "rm -f ") && strings.Contains(command, "rclone-mount-config.json"): operation = "rclone-config-cleanup" } From 9edc20b713176b4d5d0df092ba737d3e5168b858 Mon Sep 17 00:00:00 2001 From: arthur-zhang Date: Sun, 26 Jul 2026 21:06:08 +0800 Subject: [PATCH 3/9] Unify skills mount permissions with read-only mounts --- docs/design/be/e2b-sandbox-image-contract.md | 2 +- docs/design/be/filestore.md | 2 +- docs/design/be/managed-agent-skills-runtime.md | 4 ++-- internal/environments/rclone_filestore.go | 15 +-------------- internal/environments/rclone_filestore_test.go | 4 ++-- 5 files changed, 7 insertions(+), 20 deletions(-) diff --git a/docs/design/be/e2b-sandbox-image-contract.md b/docs/design/be/e2b-sandbox-image-contract.md index d4043d6e1..7574366a7 100644 --- a/docs/design/be/e2b-sandbox-image-contract.md +++ b/docs/design/be/e2b-sandbox-image-contract.md @@ -43,7 +43,7 @@ File resource 不新增独立 FUSE mount,也不创建逐文件软链接。`mou 同一 `filesystem_id` 的 namespace 在 mount 存活期间继续由数据库维护。运行中增删 File resource 不重建 FUSE mount;现有 Sandbox 在 `/uploads` 的 `1s` metadata cache 刷新后读取到新的 namespace 状态。 -`/root/.claude/skills` 是 Filestore `/skills` 的直接 mount。`rclone-filestore multimount` 在内部对 destination 执行 `MkdirAll`,Runner 不再单独准备挂载目录;archive 解包视图由 Filestore 服务虚拟生成,镜像和 Environment Manager 不下载、不复制、不解压 skill zip。该 mount 使用只读 Token、`readonly=true`、`uid=0`、`gid=0`、目录权限 `0555` 和文件权限 `0444`。 +`/root/.claude/skills` 是 Filestore `/skills` 的直接 mount。`rclone-filestore multimount` 在内部对 destination 执行 `MkdirAll`,Runner 不再单独准备挂载目录;archive 解包视图由 Filestore 服务虚拟生成,镜像和 Environment Manager 不下载、不复制、不解压 skill zip。该 mount 与其他只读 mount 一样使用只读 Token、`readonly=true`、`uid=999`、`gid=1000`、目录权限 `0755` 和文件权限 `0644`。 ## 启动顺序合同 diff --git a/docs/design/be/filestore.md b/docs/design/be/filestore.md index deeb12fba..ddbd3ac82 100644 --- a/docs/design/be/filestore.md +++ b/docs/design/be/filestore.md @@ -116,7 +116,7 @@ filesystem 的数据库 namespace 在 Session/resource 写事务完成时已经 | `/tool_results` | `/mnt/user-data/tool_results` | 只读 | 3s | | `/skills` | `/root/.claude/skills` | 只读 | 60s | -五个挂载统一使用 `vfs_cache_mode=full` 和 `vfs_cache_max_size=1G`。前四个保留 `uid=999`、`gid=1000`、目录权限 `0755` 和文件权限 `0644`;`/skills` 使用 `uid=0`、`gid=0`、目录权限 `0555` 和文件权限 `0444`,以匹配 Claude Code 的 root discovery 路径。`/outputs` 使用读写 Token,其余四个 source 共享只读 Token;两类 Token 都绑定当前 public Session 唯一 filesystem 的 external ID,`service_url` 直接取 `code_session.sandbox_api_base_url`。 +五个挂载统一使用 `vfs_cache_mode=full`、`vfs_cache_max_size=1G`、`uid=999`、`gid=1000`、目录权限 `0755` 和文件权限 `0644`。`/outputs` 使用读写 Token,其余四个 source 共享只读 Token 并设置 `readonly=true`;两类 Token 都绑定当前 public Session 唯一 filesystem 的 external ID,`service_url` 直接取 `code_session.sandbox_api_base_url`。 Runner 不执行独立的 mount preparation;`rclone-filestore multimount` 在内部对每个 destination 执行 `MkdirAll`。镜像和 Environment Manager 不得创建 skill 软链,也不会复制或解压 archive;destination 无法创建时,由 multimount 启动或 ready 阶段失败并进入统一 Sandbox 清理。 diff --git a/docs/design/be/managed-agent-skills-runtime.md b/docs/design/be/managed-agent-skills-runtime.md index 340e62cb0..43c0bd73d 100644 --- a/docs/design/be/managed-agent-skills-runtime.md +++ b/docs/design/be/managed-agent-skills-runtime.md @@ -76,8 +76,8 @@ Sandbox 中同一棵树直接位于: List、metadata 和 ranged read 都由 Filestore 服务实现。对 `/skills` 本身、其后代,以及 以 `/skills` 为 source 或 destination 的任意 mutation 均返回 `403 permission_denied`。 -HTTP 只读 Token、rclone `readonly=true`、目录权限 `0555` 和文件权限 `0444` 共同构成 -Sandbox 的只读边界。 +HTTP 只读 Token 和 rclone `readonly=true` 构成 Sandbox 的只读边界;`/skills` 与其他 +只读 mount 统一使用目录权限 `0755` 和文件权限 `0644`。 ## archive 校验与缓存 diff --git a/internal/environments/rclone_filestore.go b/internal/environments/rclone_filestore.go index 7c3fd71f7..94af8232d 100644 --- a/internal/environments/rclone_filestore.go +++ b/internal/environments/rclone_filestore.go @@ -121,20 +121,7 @@ func buildRcloneMultimountConfig(filesystemID, serviceURL, readWriteToken, reado mount("/uploads", rcloneUploadsDestination, 1, true, readonlyToken), mount("/transcripts", "/mnt/transcripts", 10, true, readonlyToken), mount("/tool_results", "/mnt/user-data/tool_results", 3, true, readonlyToken), - { - CacheDurationSeconds: 60, - AuthToken: readonlyToken, - Destination: rcloneSkillsDestination, - DirectoryPermissions: "0555", - FilePermissions: "0444", - FilesystemID: filesystemID, - GID: 0, - Readonly: true, - Source: "/skills", - UID: 0, - VFSCacheMaxSize: "1G", - VFSCacheMode: "full", - }, + mount("/skills", rcloneSkillsDestination, 60, true, readonlyToken), }, ReadyFile: rcloneReadyPath, ServiceURL: strings.TrimRight(strings.TrimSpace(serviceURL), "/"), diff --git a/internal/environments/rclone_filestore_test.go b/internal/environments/rclone_filestore_test.go index e572ae62d..bd1d3ecfc 100644 --- a/internal/environments/rclone_filestore_test.go +++ b/internal/environments/rclone_filestore_test.go @@ -50,8 +50,8 @@ func TestBuildRcloneMultimountConfig(t *testing.T) { skills := got.Mounts[4] if skills.Source != wantSources[4] || skills.Destination != wantDestinations[4] || skills.CacheDurationSeconds != wantCaches[4] || !skills.Readonly || skills.AuthToken != readonly || - skills.UID != 0 || skills.GID != 0 || skills.DirectoryPermissions != "0555" || - skills.FilePermissions != "0444" { + skills.UID != 999 || skills.GID != 1000 || skills.DirectoryPermissions != "0755" || + skills.FilePermissions != "0644" { t.Fatalf("skills mount = %+v", skills) } } From 20dbdc817b7b03c7791be84d0bb6cfbde74ed811 Mon Sep 17 00:00:00 2001 From: arthur-zhang Date: Sun, 26 Jul 2026 21:24:24 +0800 Subject: [PATCH 4/9] Document runtime skill archive replacement behavior --- internal/environments/runner.go | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/internal/environments/runner.go b/internal/environments/runner.go index 04d3ba1fd..9d58297b8 100644 --- a/internal/environments/runner.go +++ b/internal/environments/runner.go @@ -636,6 +636,27 @@ func (r *Runner) resolveRuntimeSkills(ctx context.Context, session db.Session) ( return r.skills.ResolveAgentSnapshot(ctx, session.WorkspaceID, session.AgentSnapshot) } +// replaceRuntimeSkillArchives 使用已解析的不可变 skill archive,完整替换 Managed Agent +// Session 的 /skills 投影视图。 +// +// runtimeSkills 必须已经包含具体的版本 UUID 和可信对象元数据;"latest" 会在调用本函数 +// 前解析为确定版本。本函数只保留将 zip 映射为 /skills/ 所需的字段,不下载、 +// 复制或解压 archive。 +// +// DB 操作会校验来源、目录、版本 UUID、对象大小和 SHA-256。它在同一个事务中锁定 Session +// filesystem 记录及其命名空间,确保固定根目录存在,然后删除旧投影并插入新集合。采用 +// 全量替换,是为了让已从 Agent snapshot 移除的 skill 同步消失,并避免读取方或并发的 +// 命名空间写入方看到只更新了一部分的视图。 +// +// 成功时返回 nil,确保固定根目录存在并替换投影记录;catalog 对象仍归 skill catalog +// 所有。runtimeSkills 为空时会清空投影的子目录,但保留 /skills 根目录。元数据无效、 +// Session filesystem 不存在、目录重复,或事务、加锁、写入失败时,函数返回包装后的 +// 错误,并回滚整个替换操作。 +// +// 示例: +// - [pdf@v2, sheets@v1] 成功映射为 /skills/pdf 和 /skills/sheets。 +// - [] 会删除所有 skill 投影目录,但保留 /skills。 +// - 两个 skill 同时使用目录 "pdf" 时返回错误,旧视图保持不变。 func (r *Runner) replaceRuntimeSkillArchives( ctx context.Context, session db.Session, From 2f830f39bd09be84bf4d48a639e25e0fb0b54d77 Mon Sep 17 00:00:00 2001 From: arthur-zhang Date: Sun, 26 Jul 2026 23:03:07 +0800 Subject: [PATCH 5/9] Unify skill archives with Filestore entries --- docs/design/be/filestore.md | 8 +- .../design/be/managed-agent-skills-runtime.md | 52 ++-- internal/db/filestore.go | 17 +- internal/db/filestore_archive_entries.go | 267 ++++++++++++++++ internal/db/filestore_archive_entries_test.go | 129 ++++++++ internal/db/filestore_cleanup.go | 14 +- internal/db/filestore_cleanup_sqlx_test.go | 3 +- internal/db/filestore_entries.go | 1 + internal/db/filestore_entry_methods.go | 8 +- internal/db/filestore_skill_archives.go | 284 ------------------ internal/db/filestore_skill_archives_test.go | 88 ------ .../00034_unify_filestore_skill_archives.sql | 194 ++++++++++++ ...035_validate_filestore_archive_entries.sql | 14 + internal/environments/runner.go | 19 +- internal/filestore/service.go | 2 +- .../filestore/service_test_support_test.go | 40 +-- internal/filestore/skill_archives.go | 120 +++++--- internal/filestore/skill_archives_test.go | 61 ++-- tests/environments_runner_cloud_test.go | 29 +- tests/filestore_db_test.go | 155 +++++++++- 20 files changed, 972 insertions(+), 533 deletions(-) create mode 100644 internal/db/filestore_archive_entries.go create mode 100644 internal/db/filestore_archive_entries_test.go delete mode 100644 internal/db/filestore_skill_archives.go delete mode 100644 internal/db/filestore_skill_archives_test.go create mode 100644 internal/db/migrations/00034_unify_filestore_skill_archives.sql create mode 100644 internal/db/migrations/00035_validate_filestore_archive_entries.sql diff --git a/docs/design/be/filestore.md b/docs/design/be/filestore.md index ddbd3ac82..88e70f523 100644 --- a/docs/design/be/filestore.md +++ b/docs/design/be/filestore.md @@ -138,7 +138,7 @@ sequenceDiagram A->>D: Create filesystem and five fixed roots A->>D: Write resource and borrowed /uploads entry R->>D: Resolve concrete skill versions - R->>D: Atomically replace /skills archive projections + R->>D: Atomically replace /skills archive entries R->>E: Create Sandbox R->>D: Resolve trusted filesystem scope R->>R: Issue filesystem RW and readonly tokens @@ -191,15 +191,15 @@ Provider Sandbox 创建前的失败会停止 Environment Work,且不会创建 迁移 `00018_add_filestore.sql` 新增: - `filestore_filesystems`:保存自身的内部 bigint ID、稳定 UUID、workspace 内的外部 ID;组织、工作区、public session、可选 code session 与创建 API key 均以稳定 UUID 绑定,避免租户搬迁或跨库合并时依赖源库 identity 值。 -- `filestore_entries`:统一保存 file/directory、规范化绝对路径、parent path、响应元数据、hash、TTL 和不可变 S3 object reference;Session File resource 的借用 entry 另保存源 File UUID 以及不可由 HTTP metadata 设置的 ownership 列。组织、工作区、filesystem 及可选创建者引用均保存对应 UUID,不冗余保存其他表的 identity 或 filesystem external ID。 +- `filestore_entries`:统一保存 file/directory/archive、规范化绝对路径、parent path、响应元数据、hash、TTL 和不可变 S3 object reference;Session File resource 的借用 entry 另保存源 File UUID,skill archive entry 保存具体 version UUID,两者都使用不可由 HTTP metadata 设置的 ownership 列。组织、工作区、filesystem 及可选创建者引用均保存对应 UUID,不冗余保存其他表的 identity 或 filesystem external ID。 -迁移 `00032_add_filestore_skill_archives.sql` 新增 `filestore_skill_archives`。每行把一个 Session filesystem 的 `/skills/` 固定映射到一个已解析的 catalog version zip;它只保存稳定引用和对象元数据,不拥有对象、不写成员 entry,也不计入 Filestore 容量。迁移同时为历史活动 filesystem 补齐 `/skills` 根。`00033_validate_filestore_skill_archive_checksum.sql` 把 archive SHA-256 持久化约束收紧为 64 位小写十六进制。 +迁移 `00032_add_filestore_skill_archives.sql` 曾新增独立 archive 投影表,并为历史活动 filesystem 补齐 `/skills` 根。`00034_unify_filestore_skill_archives.sql` 删除该表,把每个 `/skills/` 改为 `filestore_entries.kind=archive` 的受管 entry,并增加对象形状与具体 version UUID 唯一约束;旧投影 rows 按产品决策直接丢弃,不做数据迁移。`00035_validate_filestore_archive_entries.sql` 单独验证新增约束。archive entry 不拥有 catalog 对象、不写成员 entry,也不计入 Filestore 容量。 迁移 `00019_add_workspace_storage_usage.sql` 新增 `workspace_storage_usage`。它按工作区分别保存 Files API 与 Filestore 的有效字节数,是配额判定的事务型投影,不是最终文件事实来源;迁移会从两类文件记录建立一次基线,后续由资源写事务按增量维护。 迁移 `00023_provision_session_filesystems.sql` 建立“同一 Session 只能拥有一个有效 filesystem”的唯一部分索引,并为历史未软删除的 Session 回填缺失记录。创建索引前会检查历史重复;发现同一 Session 存在多个有效 filesystem 时迁移直接中止,不猜测应保留哪一条。迁移 `00024_use_uuid_filestore_entry_references.sql` 进一步把 entry 的组织、工作区、filesystem 和创建者引用改为稳定 UUID;旧引用存在孤立或租户错配时同样中止迁移。迁移 `00026_validate_filestore_filesystem_reference_scopes.sql` 在最终 UUID schema 上补验 filesystem 的组织、工作区、Session、Code Session 与 API Key 归属链,弥补早期回填只核对主键和 external ID 的不足。迁移 `00027_add_filestore_entry_management.sql` 在短事务中新增内部 ownership 列及 `NOT VALID` 形状约束,`00028_validate_filestore_entry_management.sql` 再以较弱锁单独验证历史行;两列必须成对为空或成对非空。迁移 `00029_add_filestore_file_references.sql` 新增 `source_file_uuid`、活动引用索引、每个 Session resource 唯一活动 entry 约束,并为历史活动 filesystem 补齐四个固定根目录;已有同名 entry 只有在它确实是 `parent_path=/` 的普通目录时才会复用,否则迁移中止。借用 entry 允许没有 Files API 未提供的 MD5,但必须与非过期的 `session_file_resource` 管理关系双向对应。`00030_validate_filestore_file_references.sql` 再单独验证放宽后的 blob 形状和 File reference 形状两个 `NOT VALID` 约束。回滚 `00029` 时只要仍有借用引用就明确失败;四个普通目录 row 会保留,因为 schema 没有把迁移回填目录与原有用户目录做额外标记,盲目删除会破坏数据。所有这些引用都由应用维护,不增加 PostgreSQL 外键。 -根目录 `/` 是由 filesystem 合成的虚拟目录,不写 marker row 或 S3 marker object;五个固定一级目录是真实 directory entry,由 filesystem 创建事务写入,历史活动 filesystem 通过迁移补齐。`/skills` 的后代节点例外:它们由 `filestore_skill_archives` 和 zip central directory 合成,不写 `filestore_entries`。文件系统和目录节点的归属都使用 `organization_uuid`、`workspace_uuid`、`filesystem_uuid` 等稳定引用;entry 的创建者审计 UUID 直接继承已经过租户链校验的 filesystem 归属,不再从 Filestore 请求构造空的内部主键 actor。请求进入数据库后仍可使用当前库内部 ID 取得工作区用量锁与 filesystem 锁,但 entry 的持久化与热查询只使用 UUID 边界。schema 不创建 PostgreSQL 外键;源 File UUID 的完整性由同事务行锁、删除守卫和 E2E 测试维护。 +根目录 `/` 是由 filesystem 合成的虚拟目录,不写 marker row 或 S3 marker object;五个固定一级目录是真实 directory entry,由 filesystem 创建事务写入,历史活动 filesystem 通过迁移补齐。每个 `/skills/` 是 archive entry;只有 zip 内部成员由 central directory 合成,不逐个写 entry。普通 entry 枚举排除 archive kind,`/skills` 专用只读视图负责把 archive 表现为目录并按需展开。文件系统和目录节点的归属都使用 `organization_uuid`、`workspace_uuid`、`filesystem_uuid` 等稳定引用;entry 的创建者审计 UUID 直接继承已经过租户链校验的 filesystem 归属,不再从 Filestore 请求构造空的内部主键 actor。请求进入数据库后仍可使用当前库内部 ID 取得工作区用量锁与 filesystem 锁,但 entry 的持久化与热查询只使用 UUID 边界。schema 不创建 PostgreSQL 外键;源 File UUID 的完整性由同事务行锁、删除守卫和 E2E 测试维护。 文件对象 key 固定为: diff --git a/docs/design/be/managed-agent-skills-runtime.md b/docs/design/be/managed-agent-skills-runtime.md index 43c0bd73d..bed86e729 100644 --- a/docs/design/be/managed-agent-skills-runtime.md +++ b/docs/design/be/managed-agent-skills-runtime.md @@ -20,26 +20,26 @@ Environment Runner 在创建 cloud managed-agent Sandbox 前完成: 3. `latest` 在启动时解析为具体 active version row。后续 catalog 的 latest 变化不会改变 已启动 Session 的视图。 4. 在一只 `sqlx.Tx` 中锁定 Session filesystem 和 namespace,确保 `/skills` 固定根存在, - 并原子替换该 filesystem 的 `filestore_skill_archives` 投影集合。 + 并原子替换该 filesystem 中 `kind=archive`、`managed_by=skill_archive` 的 entry 集合。 5. 创建 Sandbox 后,Runner 直接启动 rclone-filestore 的五个固定 mount;multimount 在内部对 destination 执行 `MkdirAll`,Runner 不执行独立 mount preparation。 6. `/skills` 使用只读 Filestore Token,直接挂载到 `/root/.claude/skills`。rclone ready 后才启动 Environment Manager;Environment Manager 不再处理 skill。 -每条 `filestore_skill_archives` row 对应一个具体 skill version zip,保存: +每条 archive entry 对应一个具体 skill version zip,保存: - organization、workspace、filesystem 的稳定 UUID; -- source 和具体 skill version UUID; -- 唯一虚拟目录 `/skills/`; +- `metadata.skill_source` 和 `managed_resource_uuid` 中的具体 skill version UUID; +- 唯一路径 `/skills/`; - archive 的 bucket、key、size 和 SHA-256。 -同一 filesystem 内,虚拟目录和 `source + skill_version_uuid` 都唯一。Snapshot 中两个 skill +同一 filesystem 内,路径和具体 skill version UUID 都唯一。Snapshot 中两个 skill 若声明相同目录但不是同一具体版本,启动失败,不能让后一个静默覆盖前一个。 ```mermaid flowchart LR A["Session agent snapshot"] --> B["Resolve concrete catalog versions"] - B --> C["Replace filestore_skill_archives in one transaction"] + B --> C["Replace kind=archive entries in one transaction"] C --> D["Filestore /skills virtual view"] E["Immutable zip objects"] --> D D --> F["rclone readonly mount"] @@ -69,16 +69,20 @@ Sandbox 中同一棵树直接位于: xlsx/SKILL.md ``` -`/skills` 是真实的固定一级 directory entry;每个 skill 目录及其成员是根据投影和 zip -内容合成的虚拟节点,不写入 `filestore_entries`,不复制对象,也不计入 `filestore_bytes`。 -虚拟文件 UUID 由 filesystem、source、具体 version UUID 和成员路径确定,Runner 重试不会 -改变同一节点的身份。 +`/skills` 是真实的固定一级 directory entry;每个 `/skills/` 是 +`filestore_entries` 中的 archive entry,成员则根据 zip central directory 合成,不逐个 +写 entry。archive entry 只借用 catalog 对象,不复制对象,也不计入 `filestore_bytes`。 +虚拟文件 UUID 由 filesystem、具体 version UUID 和成员路径确定,Runner 重试不会改变 +同一节点的身份。 List、metadata 和 ranged read 都由 Filestore 服务实现。对 `/skills` 本身、其后代,以及 以 `/skills` 为 source 或 destination 的任意 mutation 均返回 `403 permission_denied`。 HTTP 只读 Token 和 rclone `readonly=true` 构成 Sandbox 的只读边界;`/skills` 与其他 只读 mount 统一使用目录权限 `0755` 和文件权限 `0644`。 +非递归列举 `/skills` 时,Filestore 直接使用 archive entry 的 `path` 返回一级 skill +目录,不下载 archive。递归列举或访问具体 skill 子树时,才按需加载并校验对应 archive。 + ## archive 校验与缓存 Filestore 在第一次访问某个具体 archive 时按需下载并建立内存索引: @@ -90,15 +94,16 @@ Filestore 在第一次访问某个具体 archive 时按需下载并建立内存 - 解压大小按 archive header 累加并限制为 500 MiB; - 读取单个成员时流式解压;range offset 通过丢弃前缀实现,不把解压结果整体缓存。 -进程内以 `bucket + key + sha256` 为 key 使用 64 MiB 有界 LRU 缓存压缩 archive 和目录索引。 -投影仍是每次请求的授权事实来源;Session 投影删除后,缓存中残留的字节无法再通过 +进程内以 `bucket + key + sha256 + archive path` 为 key 使用 64 MiB 有界 LRU 缓存压缩 +archive 和目录索引。 +archive entry 仍是每次请求的授权事实来源;Session entry 删除后,缓存中残留的字节无法再通过 Filestore 路径访问。 ## 生命周期与对象保留 -Session filesystem 删除后沿用现有有界 cleanup job。最后一批文件和目录退休时,同一事务 -删除该 filesystem 的 `filestore_skill_archives` rows;这些 row 只是借用 catalog archive, -不会产生 Filestore 对象清理任务或容量扣减。 +Session filesystem 删除后沿用现有有界 cleanup job。最后一批普通文件退休后,同一事务 +软删除该 filesystem 的 directory 和 archive entries。archive entry 只是借用 catalog +archive,不会产生 Filestore 对象清理任务或容量扣减。 删除 custom skill/version 或用 `seed-builtin-skills --prune` 软删除 built-in catalog row 时, 不立即删除 archive 对象,也不创建通用 `object_cleanup` job。原因是已经启动的 Session @@ -117,20 +122,21 @@ Session filesystem 删除后沿用现有有界 cleanup job。最后一批文件 - `/mnt/skills`、`/workspace/skills` 解压目录,以及 Claude skill discovery 软链; - Environment Manager 的 managed-agent skill 解压职责。 -迁移 `00032_add_filestore_skill_archives.sql` 创建投影表、为历史活动 filesystem 补齐 -`/skills` 根并清除遗留的 `skill_prewarm` jobs。迁移 -`00033_validate_filestore_skill_archive_checksum.sql` 把持久化 checksum 收紧为 64 位 -小写十六进制。两张 catalog version 表仍是 archive 所有权来源,投影表不创建 PostgreSQL -外键。 +迁移 `00032_add_filestore_skill_archives.sql` 曾创建独立投影表、为历史活动 filesystem +补齐 `/skills` 根并清除遗留的 `skill_prewarm` jobs。迁移 +`00034_unify_filestore_skill_archives.sql` 把 `archive` 加入 entry kind,增加 archive +对象与 ownership 形状约束,并直接删除旧表;按产品决策,旧表历史 rows 不迁移。 +`00035_validate_filestore_archive_entries.sql` 单独验证新约束。两张 catalog version 表仍是 +archive 所有权来源,schema 不创建 PostgreSQL 外键。 ## 验收重点 - resolver 只读取 DB metadata,不在 Session 启动路径下载 archive; -- `latest` 被钉住为具体 version,投影替换是全量且原子的; +- `latest` 被钉住为具体 version,archive entry 替换是全量且原子的; - `/skills` list、recursive list、metadata 和 ranged read 返回 archive 成员; - checksum、路径穿越、缺少 `SKILL.md` 等损坏 archive fail closed; - 所有 `/skills` mutation 被拒绝; - rclone 第五个 mount 直达 `/root/.claude/skills`,destination 由 multimount 内部创建; - Runner 不写 legacy mount metadata,E2B runtime 不创建 skill volume; -- catalog soft delete/prune 不破坏活动 Session 投影; -- Session filesystem cleanup 会删除投影 row,但不删除借用的 catalog object。 +- catalog soft delete/prune 不破坏活动 Session archive entry; +- Session filesystem cleanup 会软删除 archive entry,但不删除借用的 catalog object。 diff --git a/internal/db/filestore.go b/internal/db/filestore.go index aad2f816c..10ff21b2e 100644 --- a/internal/db/filestore.go +++ b/internal/db/filestore.go @@ -11,6 +11,7 @@ import ( const ( FilestoreEntryKindFile = "file" FilestoreEntryKindDirectory = "directory" + FilestoreEntryKindArchive = "archive" filestoreMaxPathBytes = filestorepath.MaxBytes filestoreCleanupJobType = "filestore_object_cleanup" @@ -78,8 +79,8 @@ type ProvisionFilestoreFilesystemInput struct { } // FilestoreEntry 是目录树中的一个持久化节点。 -// 目录字段保持为空;文件字段指向对象存储中的一个不可变对象版本。 -// Managed 字段标识由 Session File resource 管理且借用 Files API 对象的条目。 +// 目录字段保持为空;文件与 archive 字段指向对象存储中的不可变对象版本。 +// Managed 字段标识借用其他资源对象、但投影到 Session 命名空间中的条目。 type FilestoreEntry struct { ID int64 UUID string @@ -133,6 +134,18 @@ type FilestoreFileBlob struct { ExpiresAt *time.Time } +// FilestoreSkillArchiveEntryInput 描述一个已解析的不可变 skill ZIP。 +// Source 只写入通用 metadata;对象及其生命周期仍由 skill catalog 管理。 +type FilestoreSkillArchiveEntryInput struct { + Source string + SkillVersionUUID string + Directory string + S3Bucket string + S3Key string + SizeBytes int64 + SHA256 string +} + // FilestoreEntryPageCursor 保存键集分页的最后一个 (Path, ID) 排序键。 type FilestoreEntryPageCursor struct { Path string diff --git a/internal/db/filestore_archive_entries.go b/internal/db/filestore_archive_entries.go new file mode 100644 index 000000000..3f2cdbf4b --- /dev/null +++ b/internal/db/filestore_archive_entries.go @@ -0,0 +1,267 @@ +package db + +import ( + "context" + "encoding/hex" + "errors" + "fmt" + "strings" + "time" + + "github.com/google/uuid" +) + +type normalizedFilestoreSkillArchiveEntry struct { + Source string + SkillVersionUUID string + Path string + S3Bucket string + S3Key string + SizeBytes int64 + SHA256 string +} + +var ( + filestoreSkillArchiveEntryFilesystemQuery = filestoreFilesystemSelectSQL() + ` + where workspace_uuid = (select uuid from workspaces where id = :workspace_id) + and session_uuid = ( + select uuid + from sessions + where workspace_id = :workspace_id + and external_id = :session_external_id + and deleted_at is null + ) + and deleted_at is null + limit 1 + for update + ` + filestoreSkillArchiveEntryDeleteQuery = ` + delete from filestore_entries + where workspace_uuid = :workspace_uuid + and filesystem_uuid = :filesystem_uuid + and kind = 'archive' + and managed_by = 'skill_archive' + ` + filestoreSkillArchiveEntryInsertQuery = ` + insert into filestore_entries ( + uuid, + external_id, + organization_uuid, + workspace_uuid, + filesystem_uuid, + kind, + path, + parent_path, + size_bytes, + media_type, + detected_mime_type, + metadata, + authorization_metadata, + downloadable, + sha256, + s3_bucket, + s3_key, + managed_by, + managed_resource_uuid, + created_by_api_key_uuid, + created_by_session_uuid, + created_by_code_session_uuid, + created_at, + updated_at + ) + values ( + gen_random_uuid(), + concat('fse_', replace(cast(gen_random_uuid() as text), '-', '')), + CAST(:organization_uuid AS uuid), + CAST(:workspace_uuid AS uuid), + CAST(:filesystem_uuid AS uuid), + 'archive', + :entry_path, + '/skills', + :size_bytes, + 'application/zip', + 'application/zip', + jsonb_build_object('skill_source', cast(:source as text)), + cast('{}' as jsonb), + false, + :sha256, + :s3_bucket, + :s3_key, + 'skill_archive', + CAST(:skill_version_uuid AS uuid), + CAST(:created_by_api_key_uuid AS uuid), + CAST(:created_by_session_uuid AS uuid), + CAST(:created_by_code_session_uuid AS uuid), + :now, + :now + ) + ` + filestoreSkillArchiveEntryListQuery = filestoreEntrySelectSQL() + ` + where workspace_uuid = (select uuid from workspaces where id = :workspace_id) + and filesystem_uuid = ( + select uuid + from filestore_filesystems + where id = :filesystem_id + and workspace_uuid = ( + select uuid from workspaces where id = :workspace_id + ) + and deleted_at is null + ) + and kind = 'archive' + and managed_by = 'skill_archive' + and deleted_at is null + order by path, id + ` +) + +// ReplaceFilestoreSkillArchiveEntries 原子替换一个公开 Session 的完整 +// /skills archive entry 集合。调用前必须已把 "latest" 解析为具体版本。 +func (d *DB) ReplaceFilestoreSkillArchiveEntries( + ctx context.Context, + workspaceID int64, + sessionExternalID string, + inputs []FilestoreSkillArchiveEntryInput, +) error { + if d == nil || d.sql == nil { + return errors.New("database is unavailable") + } + entries, err := normalizeFilestoreSkillArchiveEntries(inputs) + if err != nil { + return err + } + + tx, err := d.sql.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + filesystem, err := getFilestoreFilesystemSQLX(ctx, tx, filestoreSkillArchiveEntryFilesystemQuery, map[string]any{ + "workspace_id": workspaceID, + "session_external_id": sessionExternalID, + }) + if err != nil { + return err + } + if _, err := namedExecContext(ctx, tx, provisionFilestoreNamespaceLockQuery, map[string]any{ + "filesystem_id": filesystem.ID, + }); err != nil { + return err + } + now := time.Now().UTC() + if err := ensureFilestoreFixedRootsTx(ctx, tx, workspaceID, filesystem, now); err != nil { + return err + } + if _, err := namedExecContext(ctx, tx, filestoreSkillArchiveEntryDeleteQuery, map[string]any{ + "workspace_uuid": filesystem.WorkspaceUUID, + "filesystem_uuid": filesystem.UUID, + }); err != nil { + return err + } + + for _, entry := range entries { + if _, err := namedExecContext(ctx, tx, filestoreSkillArchiveEntryInsertQuery, map[string]any{ + "organization_uuid": filesystem.OrganizationUUID, + "workspace_uuid": filesystem.WorkspaceUUID, + "filesystem_uuid": filesystem.UUID, + "entry_path": entry.Path, + "source": entry.Source, + "skill_version_uuid": entry.SkillVersionUUID, + "s3_bucket": entry.S3Bucket, + "s3_key": entry.S3Key, + "size_bytes": entry.SizeBytes, + "sha256": entry.SHA256, + "created_by_api_key_uuid": filesystem.CreatedByAPIKeyUUID, + "created_by_session_uuid": filesystem.SessionUUID, + "created_by_code_session_uuid": filesystem.CodeSessionUUID, + "now": now, + }); err != nil { + return err + } + } + return tx.Commit() +} + +// ListFilestoreSkillArchiveEntries 返回一个 Session 文件系统中完整且稳定排序的 +// archive entry 集合,供只读 /skills 虚拟视图解析。 +func (d *DB) ListFilestoreSkillArchiveEntries( + ctx context.Context, + workspaceID int64, + filesystemID int64, +) ([]FilestoreEntry, error) { + var rows []filestoreEntryRow + if err := namedSelectContext(ctx, d.sql, &rows, filestoreSkillArchiveEntryListQuery, map[string]any{ + "workspace_id": workspaceID, + "filesystem_id": filesystemID, + }); err != nil { + return nil, err + } + return filestoreEntriesFromSQLXRows(rows) +} + +func normalizeFilestoreSkillArchiveEntries( + inputs []FilestoreSkillArchiveEntryInput, +) ([]normalizedFilestoreSkillArchiveEntry, error) { + entries := make([]normalizedFilestoreSkillArchiveEntry, 0, len(inputs)) + seenPaths := make(map[string]struct{}, len(inputs)) + seenVersions := make(map[string]struct{}, len(inputs)) + for _, input := range inputs { + entry, err := normalizeFilestoreSkillArchiveEntry(input) + if err != nil { + return nil, err + } + if _, exists := seenPaths[entry.Path]; exists { + return nil, fmt.Errorf("duplicate filestore skill path %q: %w", entry.Path, ErrDuplicate) + } + if _, exists := seenVersions[entry.SkillVersionUUID]; exists { + return nil, fmt.Errorf( + "duplicate filestore skill version %q: %w", + entry.SkillVersionUUID, + ErrDuplicate, + ) + } + seenPaths[entry.Path] = struct{}{} + seenVersions[entry.SkillVersionUUID] = struct{}{} + entries = append(entries, entry) + } + return entries, nil +} + +func normalizeFilestoreSkillArchiveEntry( + input FilestoreSkillArchiveEntryInput, +) (normalizedFilestoreSkillArchiveEntry, error) { + source := strings.TrimSpace(input.Source) + if source != "anthropic" && source != "custom" { + return normalizedFilestoreSkillArchiveEntry{}, fmt.Errorf("unsupported skill source %q", input.Source) + } + directory := strings.TrimSpace(input.Directory) + if directory == "" || strings.ContainsAny(directory, "/\\\x00") || directory == "." || directory == ".." { + return normalizedFilestoreSkillArchiveEntry{}, fmt.Errorf("invalid skill directory %q", input.Directory) + } + entryPath := "/skills/" + directory + if err := validateFilestorePath(entryPath); err != nil { + return normalizedFilestoreSkillArchiveEntry{}, fmt.Errorf("invalid skill directory %q: %w", input.Directory, err) + } + versionUUID := strings.TrimSpace(input.SkillVersionUUID) + if _, err := uuid.Parse(versionUUID); err != nil { + return normalizedFilestoreSkillArchiveEntry{}, fmt.Errorf("invalid skill version UUID: %w", err) + } + checksum := strings.ToLower(strings.TrimSpace(input.SHA256)) + decodedChecksum, checksumErr := hex.DecodeString(checksum) + if strings.TrimSpace(input.S3Bucket) == "" || + strings.TrimSpace(input.S3Key) == "" || + input.SizeBytes <= 0 || + checksumErr != nil || + len(decodedChecksum) != 32 { + return normalizedFilestoreSkillArchiveEntry{}, ErrInvalidState + } + return normalizedFilestoreSkillArchiveEntry{ + Source: source, + SkillVersionUUID: versionUUID, + Path: entryPath, + S3Bucket: strings.TrimSpace(input.S3Bucket), + S3Key: strings.TrimSpace(input.S3Key), + SizeBytes: input.SizeBytes, + SHA256: checksum, + }, nil +} diff --git a/internal/db/filestore_archive_entries_test.go b/internal/db/filestore_archive_entries_test.go new file mode 100644 index 000000000..d69300869 --- /dev/null +++ b/internal/db/filestore_archive_entries_test.go @@ -0,0 +1,129 @@ +package db + +import ( + "errors" + "strings" + "testing" + "time" +) + +func TestFilestoreSkillArchiveEntryQueriesUseSQLXNamedParameters(t *testing.T) { + arguments := map[string]any{ + "workspace_id": int64(41), + "session_external_id": "session_41", + "organization_uuid": "00000000-0000-4000-8000-000000000041", + "workspace_uuid": "00000000-0000-4000-8000-000000000042", + "filesystem_id": int64(43), + "filesystem_uuid": "00000000-0000-4000-8000-000000000043", + "source": "custom", + "skill_version_uuid": "00000000-0000-4000-8000-000000000044", + "entry_path": "/skills/demo", + "s3_bucket": "skills", + "s3_key": "skills/demo.zip", + "size_bytes": int64(1024), + "sha256": strings.Repeat("a", 64), + "created_by_api_key_uuid": "00000000-0000-4000-8000-000000000045", + "created_by_session_uuid": "00000000-0000-4000-8000-000000000046", + "created_by_code_session_uuid": nil, + "now": time.Date(2026, time.July, 26, 12, 0, 0, 0, time.UTC), + } + tests := []struct { + name string + query string + wantArgCount int + }{ + {"filesystem", filestoreSkillArchiveEntryFilesystemQuery, 3}, + {"delete", filestoreSkillArchiveEntryDeleteQuery, 2}, + {"insert", filestoreSkillArchiveEntryInsertQuery, 15}, + {"list", filestoreSkillArchiveEntryListQuery, 3}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + query, boundArguments, err := bindNamed(postgresRebinder{}, test.query, arguments) + if err != nil { + t.Fatalf("bind named query: %v", err) + } + if strings.Contains(query, ":") { + t.Fatalf("query retains colon syntax after binding: %q", query) + } + if len(boundArguments) != test.wantArgCount { + t.Fatalf("argument count = %d, want %d", len(boundArguments), test.wantArgCount) + } + }) + } +} + +func TestNormalizeFilestoreSkillArchiveEntries(t *testing.T) { + valid := FilestoreSkillArchiveEntryInput{ + Source: "custom", + SkillVersionUUID: "00000000-0000-4000-8000-000000000044", + Directory: "demo", + S3Bucket: "skills", + S3Key: "skills/demo.zip", + SizeBytes: 1024, + SHA256: strings.Repeat("A", 64), + } + for _, test := range []struct { + name string + mutate func(*FilestoreSkillArchiveEntryInput) + }{ + {"unsupported source", func(input *FilestoreSkillArchiveEntryInput) { input.Source = "other" }}, + {"nested directory", func(input *FilestoreSkillArchiveEntryInput) { input.Directory = "demo/nested" }}, + {"missing version", func(input *FilestoreSkillArchiveEntryInput) { input.SkillVersionUUID = "" }}, + {"invalid version", func(input *FilestoreSkillArchiveEntryInput) { input.SkillVersionUUID = "not-a-uuid" }}, + {"missing object key", func(input *FilestoreSkillArchiveEntryInput) { input.S3Key = "" }}, + {"zero size", func(input *FilestoreSkillArchiveEntryInput) { input.SizeBytes = 0 }}, + {"short checksum", func(input *FilestoreSkillArchiveEntryInput) { input.SHA256 = "abc" }}, + {"non-hex checksum", func(input *FilestoreSkillArchiveEntryInput) { input.SHA256 = strings.Repeat("z", 64) }}, + } { + t.Run("rejects "+test.name, func(t *testing.T) { + input := valid + test.mutate(&input) + if _, err := normalizeFilestoreSkillArchiveEntries([]FilestoreSkillArchiveEntryInput{input}); err == nil { + t.Fatal("validation error = nil") + } + }) + } + t.Run("rejects duplicate path", func(t *testing.T) { + second := valid + second.SkillVersionUUID = "00000000-0000-4000-8000-000000000045" + if _, err := normalizeFilestoreSkillArchiveEntries([]FilestoreSkillArchiveEntryInput{ + valid, + second, + }); !errors.Is(err, ErrDuplicate) { + t.Fatalf("error = %v, want ErrDuplicate", err) + } + }) + t.Run("rejects duplicate version", func(t *testing.T) { + second := valid + second.Directory = "other" + if _, err := normalizeFilestoreSkillArchiveEntries([]FilestoreSkillArchiveEntryInput{ + valid, + second, + }); !errors.Is(err, ErrDuplicate) { + t.Fatalf("error = %v, want ErrDuplicate", err) + } + }) + t.Run("normalizes valid entry", func(t *testing.T) { + entries, err := normalizeFilestoreSkillArchiveEntries([]FilestoreSkillArchiveEntryInput{valid}) + if err != nil { + t.Fatalf("validation error = %v", err) + } + if len(entries) != 1 || + entries[0].Path != "/skills/demo" || + entries[0].SHA256 != strings.Repeat("a", 64) { + t.Fatalf("normalized entries = %#v", entries) + } + }) +} + +func TestFilestoreArchiveEntryDoesNotOwnCatalogBytes(t *testing.T) { + sizeBytes := int64(1024) + entry := FilestoreEntry{ + Kind: FilestoreEntryKindArchive, + SizeBytes: &sizeBytes, + } + if got := entry.OwnedBytes(); got != 0 { + t.Fatalf("OwnedBytes() = %d, want 0", got) + } +} diff --git a/internal/db/filestore_cleanup.go b/internal/db/filestore_cleanup.go index 14550fed7..85ff8ee95 100644 --- a/internal/db/filestore_cleanup.go +++ b/internal/db/filestore_cleanup.go @@ -58,17 +58,12 @@ const ( and kind = 'file' and deleted_at is null ) ` - retireFilesystemCleanupDirectoriesQuery = ` + retireFilesystemCleanupNamespaceEntriesQuery = ` update filestore_entries set deleted_at = :retired_at, updated_at = :retired_at where workspace_uuid = :workspace_uuid and filesystem_uuid = :filesystem_uuid - and kind = 'directory' and deleted_at is null - ` - removeFilesystemSkillArchivesQuery = ` - delete from filestore_skill_archives - where workspace_uuid = :workspace_uuid - and filesystem_uuid = :filesystem_uuid + and kind in ('directory', 'archive') and deleted_at is null ` completeFilesystemCleanupBatchQuery = ` update jobs @@ -346,10 +341,7 @@ func (d *DB) ProcessLeasedFilestoreFilesystemCleanupJob( return false, err } if !filesRemain { - if _, err := namedExecContext(ctx, tx, retireFilesystemCleanupDirectoriesQuery, arguments); err != nil { - return false, err - } - if _, err := namedExecContext(ctx, tx, removeFilesystemSkillArchivesQuery, arguments); err != nil { + if _, err := namedExecContext(ctx, tx, retireFilesystemCleanupNamespaceEntriesQuery, arguments); err != nil { return false, err } } diff --git a/internal/db/filestore_cleanup_sqlx_test.go b/internal/db/filestore_cleanup_sqlx_test.go index 5b0207941..2c21fa8bf 100644 --- a/internal/db/filestore_cleanup_sqlx_test.go +++ b/internal/db/filestore_cleanup_sqlx_test.go @@ -43,8 +43,7 @@ func TestFilesystemCleanupProcessingQueriesUseSQLXNamedParameters(t *testing.T) {"entries", filesystemCleanupEntriesQuery, 3}, {"retire entry", retireFilesystemCleanupEntryQuery, 3}, {"files remain", filesystemCleanupFilesRemainQuery, 2}, - {"retire directories", retireFilesystemCleanupDirectoriesQuery, 4}, - {"remove skill archives", removeFilesystemSkillArchivesQuery, 2}, + {"retire namespace entries", retireFilesystemCleanupNamespaceEntriesQuery, 4}, {"complete batch", completeFilesystemCleanupBatchQuery, 6}, } diff --git a/internal/db/filestore_entries.go b/internal/db/filestore_entries.go index a1f22c791..05b64b6a6 100644 --- a/internal/db/filestore_entries.go +++ b/internal/db/filestore_entries.go @@ -61,6 +61,7 @@ func buildFilestoreEntriesPageQuery(filesystem FilestoreFilesystem, params ListF query := filestoreEntrySelectSQL() + ` where workspace_uuid = :workspace_uuid and filesystem_uuid = :filesystem_uuid + and kind <> 'archive' and deleted_at is null and (expires_at is null or expires_at > now()) ` diff --git a/internal/db/filestore_entry_methods.go b/internal/db/filestore_entry_methods.go index 0d4f9c485..7481f3fd5 100644 --- a/internal/db/filestore_entry_methods.go +++ b/internal/db/filestore_entry_methods.go @@ -9,10 +9,12 @@ func (entry *FilestoreEntry) BorrowsSourceObject() bool { } // OwnedBytes returns the bytes that this entry contributes to Filestore-owned -// storage accounting. Borrowed Files API objects remain visible in the -// namespace, but they do not consume Filestore-owned bytes. +// storage accounting. Only ordinary file entries own their objects. Files API +// references and skill archives borrow objects from their source catalogs. func (entry *FilestoreEntry) OwnedBytes() int64 { - if entry == nil || entry.BorrowsSourceObject() { + if entry == nil || + entry.Kind != FilestoreEntryKindFile || + entry.BorrowsSourceObject() { return 0 } return filestoreInt64(entry.SizeBytes) diff --git a/internal/db/filestore_skill_archives.go b/internal/db/filestore_skill_archives.go deleted file mode 100644 index e61fbe8a7..000000000 --- a/internal/db/filestore_skill_archives.go +++ /dev/null @@ -1,284 +0,0 @@ -package db - -import ( - "context" - "database/sql" - "encoding/hex" - "errors" - "fmt" - "strings" - "time" - - "github.com/google/uuid" -) - -// FilestoreSkillArchive is one immutable catalog zip projected below -// /skills in a Session filesystem. -type FilestoreSkillArchive struct { - ID int64 - UUID string - ExternalID string - OrganizationUUID string - WorkspaceUUID string - FilesystemUUID string - Source string - SkillVersionUUID string - VirtualPath string - S3Bucket string - S3Key string - SizeBytes int64 - SHA256 string - CreatedAt time.Time - UpdatedAt time.Time -} - -// FilestoreSkillArchiveInput contains resolved skill-version metadata. The -// archive remains owned by the skill catalog and is never charged to or -// deleted with the Session filesystem. -type FilestoreSkillArchiveInput struct { - Source string - SkillVersionUUID string - Directory string - S3Bucket string - S3Key string - SizeBytes int64 - SHA256 string -} - -type filestoreSkillArchiveRow struct { - ID int64 `db:"id"` - UUID string `db:"uuid"` - ExternalID string `db:"external_id"` - OrganizationUUID string `db:"organization_uuid"` - WorkspaceUUID string `db:"workspace_uuid"` - FilesystemUUID string `db:"filesystem_uuid"` - Source string `db:"source"` - SkillVersionUUID string `db:"skill_version_uuid"` - VirtualPath string `db:"virtual_path"` - S3Bucket string `db:"s3_bucket"` - S3Key string `db:"s3_key"` - SizeBytes int64 `db:"size_bytes"` - SHA256 string `db:"sha256"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` -} - -var ( - filestoreSkillArchiveFilesystemQuery = filestoreFilesystemSelectSQL() + ` - where workspace_uuid = (select uuid from workspaces where id = :workspace_id) - and session_uuid = ( - select uuid - from sessions - where workspace_id = :workspace_id - and external_id = :session_external_id - and deleted_at is null - ) - and deleted_at is null - limit 1 - for update - ` - filestoreSkillArchiveDeleteQuery = ` - delete from filestore_skill_archives - where workspace_uuid = :workspace_uuid - and filesystem_uuid = :filesystem_uuid - ` - filestoreSkillArchiveInsertQuery = ` - insert into filestore_skill_archives ( - external_id, organization_uuid, workspace_uuid, filesystem_uuid, - source, skill_version_uuid, virtual_path, s3_bucket, s3_key, - size_bytes, sha256, created_at, updated_at - ) - values ( - concat('fsa_', replace(cast(gen_random_uuid() as text), '-', '')), - CAST(:organization_uuid AS uuid), - CAST(:workspace_uuid AS uuid), - CAST(:filesystem_uuid AS uuid), - :source, - CAST(:skill_version_uuid AS uuid), - :virtual_path, - :s3_bucket, - :s3_key, - :size_bytes, - :sha256, - :now, - :now - ) - ` - filestoreSkillArchiveListQuery = ` - select - id, - cast(uuid as text) as uuid, - external_id, - cast(organization_uuid as text) as organization_uuid, - cast(workspace_uuid as text) as workspace_uuid, - cast(filesystem_uuid as text) as filesystem_uuid, - source, - cast(skill_version_uuid as text) as skill_version_uuid, - virtual_path, - s3_bucket, - s3_key, - size_bytes, - sha256, - created_at, - updated_at - from filestore_skill_archives - where workspace_uuid = (select uuid from workspaces where id = :workspace_id) - and filesystem_uuid = ( - select uuid - from filestore_filesystems - where id = :filesystem_id - and workspace_uuid = ( - select uuid from workspaces where id = :workspace_id - ) - and deleted_at is null - ) - order by virtual_path, id - ` -) - -// ReplaceFilestoreSkillArchives atomically replaces the complete skill view for -// a public Session. Resolving "latest" happens before this call, so every row -// pins a concrete immutable version. -func (d *DB) ReplaceFilestoreSkillArchives( - ctx context.Context, - workspaceID int64, - sessionExternalID string, - archives []FilestoreSkillArchiveInput, -) error { - if d == nil || d.sql == nil { - return errors.New("database is unavailable") - } - for _, archive := range archives { - if err := validateFilestoreSkillArchiveInput(archive); err != nil { - return err - } - } - - tx, err := d.sql.BeginTxx(ctx, nil) - if err != nil { - return err - } - defer tx.Rollback() - - filesystem, err := getFilestoreFilesystemSQLX(ctx, tx, filestoreSkillArchiveFilesystemQuery, map[string]any{ - "workspace_id": workspaceID, - "session_external_id": sessionExternalID, - }) - if err != nil { - return err - } - if _, err := namedExecContext(ctx, tx, provisionFilestoreNamespaceLockQuery, map[string]any{ - "filesystem_id": filesystem.ID, - }); err != nil { - return err - } - if err := ensureFilestoreFixedRootsTx(ctx, tx, workspaceID, filesystem, time.Now().UTC()); err != nil { - return err - } - if _, err := namedExecContext(ctx, tx, filestoreSkillArchiveDeleteQuery, map[string]any{ - "workspace_uuid": filesystem.WorkspaceUUID, - "filesystem_uuid": filesystem.UUID, - }); err != nil { - return err - } - - seenPaths := make(map[string]struct{}, len(archives)) - seenVersions := make(map[string]struct{}, len(archives)) - now := time.Now().UTC() - for _, archive := range archives { - virtualPath := "/skills/" + archive.Directory - versionKey := archive.Source + "\x00" + archive.SkillVersionUUID - if _, exists := seenPaths[virtualPath]; exists { - return fmt.Errorf("duplicate filestore skill path %q: %w", virtualPath, ErrDuplicate) - } - if _, exists := seenVersions[versionKey]; exists { - continue - } - seenPaths[virtualPath] = struct{}{} - seenVersions[versionKey] = struct{}{} - if _, err := namedExecContext(ctx, tx, filestoreSkillArchiveInsertQuery, map[string]any{ - "organization_uuid": filesystem.OrganizationUUID, - "workspace_uuid": filesystem.WorkspaceUUID, - "filesystem_uuid": filesystem.UUID, - "source": archive.Source, - "skill_version_uuid": archive.SkillVersionUUID, - "virtual_path": virtualPath, - "s3_bucket": archive.S3Bucket, - "s3_key": archive.S3Key, - "size_bytes": archive.SizeBytes, - "sha256": strings.ToLower(archive.SHA256), - "now": now, - }); err != nil { - return err - } - } - return tx.Commit() -} - -// ListFilestoreSkillArchives returns the complete deterministic projection set -// for virtual namespace resolution. -func (d *DB) ListFilestoreSkillArchives( - ctx context.Context, - workspaceID int64, - filesystemID int64, -) ([]FilestoreSkillArchive, error) { - var rows []filestoreSkillArchiveRow - err := namedSelectContext(ctx, d.sql, &rows, filestoreSkillArchiveListQuery, map[string]any{ - "workspace_id": workspaceID, - "filesystem_id": filesystemID, - }) - if errors.Is(err, sql.ErrNoRows) { - return []FilestoreSkillArchive{}, nil - } - if err != nil { - return nil, err - } - archives := make([]FilestoreSkillArchive, 0, len(rows)) - for _, row := range rows { - archives = append(archives, row.archive()) - } - return archives, nil -} - -func validateFilestoreSkillArchiveInput(input FilestoreSkillArchiveInput) error { - if input.Source != "anthropic" && input.Source != "custom" { - return fmt.Errorf("unsupported skill source %q", input.Source) - } - directory := strings.TrimSpace(input.Directory) - if directory == "" || strings.ContainsAny(directory, "/\\\x00") || directory == "." || directory == ".." { - return fmt.Errorf("invalid skill directory %q", input.Directory) - } - if _, err := uuid.Parse(strings.TrimSpace(input.SkillVersionUUID)); err != nil { - return fmt.Errorf("invalid skill version UUID: %w", err) - } - checksum := strings.TrimSpace(input.SHA256) - decodedChecksum, checksumErr := hex.DecodeString(checksum) - if strings.TrimSpace(input.S3Bucket) == "" || - strings.TrimSpace(input.S3Key) == "" || - input.SizeBytes <= 0 || - checksumErr != nil || - len(decodedChecksum) != 32 { - return ErrInvalidState - } - return nil -} - -func (row filestoreSkillArchiveRow) archive() FilestoreSkillArchive { - return FilestoreSkillArchive{ - ID: row.ID, - UUID: row.UUID, - ExternalID: row.ExternalID, - OrganizationUUID: row.OrganizationUUID, - WorkspaceUUID: row.WorkspaceUUID, - FilesystemUUID: row.FilesystemUUID, - Source: row.Source, - SkillVersionUUID: row.SkillVersionUUID, - VirtualPath: row.VirtualPath, - S3Bucket: row.S3Bucket, - S3Key: row.S3Key, - SizeBytes: row.SizeBytes, - SHA256: row.SHA256, - CreatedAt: row.CreatedAt, - UpdatedAt: row.UpdatedAt, - } -} diff --git a/internal/db/filestore_skill_archives_test.go b/internal/db/filestore_skill_archives_test.go deleted file mode 100644 index dcdaf5e89..000000000 --- a/internal/db/filestore_skill_archives_test.go +++ /dev/null @@ -1,88 +0,0 @@ -package db - -import ( - "strings" - "testing" - "time" -) - -func TestFilestoreSkillArchiveQueriesUseSQLXNamedParameters(t *testing.T) { - arguments := map[string]any{ - "workspace_id": int64(41), - "session_external_id": "session_41", - "organization_uuid": "00000000-0000-4000-8000-000000000041", - "workspace_uuid": "00000000-0000-4000-8000-000000000042", - "filesystem_id": int64(43), - "filesystem_uuid": "00000000-0000-4000-8000-000000000043", - "source": "custom", - "skill_version_uuid": "00000000-0000-4000-8000-000000000044", - "virtual_path": "/skills/demo", - "s3_bucket": "skills", - "s3_key": "skills/demo.zip", - "size_bytes": int64(1024), - "sha256": strings.Repeat("a", 64), - "now": time.Date(2026, time.July, 26, 12, 0, 0, 0, time.UTC), - } - tests := []struct { - name string - query string - wantArgCount int - }{ - {"filesystem", filestoreSkillArchiveFilesystemQuery, 3}, - {"delete", filestoreSkillArchiveDeleteQuery, 2}, - {"insert", filestoreSkillArchiveInsertQuery, 12}, - {"list", filestoreSkillArchiveListQuery, 3}, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - query, boundArguments, err := bindNamed(postgresRebinder{}, test.query, arguments) - if err != nil { - t.Fatalf("bind named query: %v", err) - } - if strings.Contains(query, ":") { - t.Fatalf("query retains colon syntax after binding: %q", query) - } - if len(boundArguments) != test.wantArgCount { - t.Fatalf("argument count = %d, want %d", len(boundArguments), test.wantArgCount) - } - }) - } -} - -func TestValidateFilestoreSkillArchiveInput(t *testing.T) { - valid := FilestoreSkillArchiveInput{ - Source: "custom", - SkillVersionUUID: "00000000-0000-4000-8000-000000000044", - Directory: "demo", - S3Bucket: "skills", - S3Key: "skills/demo.zip", - SizeBytes: 1024, - SHA256: strings.Repeat("a", 64), - } - for _, test := range []struct { - name string - mutate func(*FilestoreSkillArchiveInput) - }{ - {"unsupported source", func(input *FilestoreSkillArchiveInput) { input.Source = "other" }}, - {"nested directory", func(input *FilestoreSkillArchiveInput) { input.Directory = "demo/nested" }}, - {"missing version", func(input *FilestoreSkillArchiveInput) { input.SkillVersionUUID = "" }}, - {"invalid version", func(input *FilestoreSkillArchiveInput) { input.SkillVersionUUID = "not-a-uuid" }}, - {"missing object key", func(input *FilestoreSkillArchiveInput) { input.S3Key = "" }}, - {"zero size", func(input *FilestoreSkillArchiveInput) { input.SizeBytes = 0 }}, - {"short checksum", func(input *FilestoreSkillArchiveInput) { input.SHA256 = "abc" }}, - {"non-hex checksum", func(input *FilestoreSkillArchiveInput) { input.SHA256 = strings.Repeat("z", 64) }}, - } { - t.Run("rejects "+test.name, func(t *testing.T) { - input := valid - test.mutate(&input) - if err := validateFilestoreSkillArchiveInput(input); err == nil { - t.Fatal("validation error = nil") - } - }) - } - t.Run("accepts valid", func(t *testing.T) { - if err := validateFilestoreSkillArchiveInput(valid); err != nil { - t.Fatalf("validation error = %v", err) - } - }) -} diff --git a/internal/db/migrations/00034_unify_filestore_skill_archives.sql b/internal/db/migrations/00034_unify_filestore_skill_archives.sql new file mode 100644 index 000000000..3108f9e2e --- /dev/null +++ b/internal/db/migrations/00034_unify_filestore_skill_archives.sql @@ -0,0 +1,194 @@ +-- +goose Up + +-- Skill archives are Filestore namespace entries backed by immutable catalog +-- zip objects. Historical rows in the old projection table are intentionally +-- discarded instead of migrated. +drop table filestore_skill_archives; + +alter table filestore_entries + drop constraint filestore_entries_kind_check, + add constraint filestore_entries_kind_check check ( + kind in ('file', 'directory', 'archive') + ) not valid; + +alter table filestore_entries + drop constraint filestore_entries_blob_shape_check, + add constraint filestore_entries_blob_shape_check check ( + ( + kind = 'directory' + and source_file_uuid is null + and size_bytes is null + and media_type is null + and detected_mime_type is null + and md5 is null + and sha256 is null + and s3_bucket is null + and s3_key is null + and s3_etag is null + and s3_version_id is null + and expires_at is null + ) + or ( + kind = 'file' + and size_bytes is not null + and size_bytes >= 0 + and media_type is not null + and ( + source_file_uuid is not null + or ( + md5 is not null + and char_length(md5) > 0 + ) + ) + and sha256 is not null + and char_length(sha256) = 64 + and s3_bucket is not null + and char_length(s3_bucket) > 0 + and s3_key is not null + and char_length(s3_key) > 0 + ) + or ( + kind = 'archive' + and source_file_uuid is null + and size_bytes is not null + and size_bytes > 0 + and media_type = 'application/zip' + and detected_mime_type = 'application/zip' + and md5 is null + and sha256 ~ '^[0-9a-f]{64}$' + and s3_bucket is not null + and char_length(s3_bucket) > 0 + and s3_key is not null + and char_length(s3_key) > 0 + and s3_etag is null + and s3_version_id is null + and expires_at is null + ) + ) not valid; + +alter table filestore_entries + add constraint filestore_entries_archive_shape_check check ( + ( + kind = 'archive' + and path ~ '^/skills/[^/]+$' + and parent_path = '/skills' + and managed_by = 'skill_archive' + and managed_resource_uuid is not null + and downloadable = false + ) + or ( + kind <> 'archive' + and managed_by is distinct from 'skill_archive' + ) + ) not valid; + +create unique index filestore_entries_skill_archive_active_v1_key + on filestore_entries ( + workspace_uuid, + filesystem_uuid, + managed_resource_uuid + ) + where deleted_at is null + and kind = 'archive' + and managed_by = 'skill_archive'; + +-- +goose Down + +delete from filestore_entries +where kind = 'archive'; + +drop index filestore_entries_skill_archive_active_v1_key; + +alter table filestore_entries + drop constraint filestore_entries_archive_shape_check, + drop constraint filestore_entries_blob_shape_check, + drop constraint filestore_entries_kind_check; + +alter table filestore_entries + add constraint filestore_entries_kind_check check ( + kind in ('file', 'directory') + ), + add constraint filestore_entries_blob_shape_check check ( + ( + kind = 'directory' + and source_file_uuid is null + and size_bytes is null + and media_type is null + and detected_mime_type is null + and md5 is null + and sha256 is null + and s3_bucket is null + and s3_key is null + and s3_etag is null + and s3_version_id is null + and expires_at is null + ) + or ( + kind = 'file' + and size_bytes is not null + and size_bytes >= 0 + and media_type is not null + and ( + source_file_uuid is not null + or ( + md5 is not null + and char_length(md5) > 0 + ) + ) + and sha256 is not null + and char_length(sha256) = 64 + and s3_bucket is not null + and char_length(s3_bucket) > 0 + and s3_key is not null + and char_length(s3_key) > 0 + ) + ); + +create table filestore_skill_archives ( + id bigint generated always as identity, + uuid uuid not null default gen_random_uuid(), + external_id text not null, + organization_uuid uuid not null, + workspace_uuid uuid not null, + filesystem_uuid uuid not null, + source text not null, + skill_version_uuid uuid not null, + virtual_path text not null, + s3_bucket text not null, + s3_key text not null, + size_bytes bigint not null, + sha256 text not null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint filestore_skill_archives_id_pk primary key (id), + constraint filestore_skill_archives_uuid_key unique (uuid), + constraint filestore_skill_archives_external_id_key unique (external_id), + constraint filestore_skill_archives_source_check check ( + source in ('anthropic', 'custom') + ), + constraint filestore_skill_archives_virtual_path_check check ( + virtual_path ~ '^/skills/[^/]+$' + and octet_length(virtual_path) <= 4096 + ), + constraint filestore_skill_archives_object_check check ( + char_length(s3_bucket) > 0 + and char_length(s3_key) > 0 + and size_bytes > 0 + and sha256 ~ '^[0-9a-f]{64}$' + ) +); + +create unique index filestore_skill_archives_filesystem_path_key + on filestore_skill_archives ( + workspace_uuid, + filesystem_uuid, + virtual_path + ); + +create unique index filestore_skill_archives_filesystem_version_key + on filestore_skill_archives ( + workspace_uuid, + filesystem_uuid, + source, + skill_version_uuid + ); diff --git a/internal/db/migrations/00035_validate_filestore_archive_entries.sql b/internal/db/migrations/00035_validate_filestore_archive_entries.sql new file mode 100644 index 000000000..bf0e892a1 --- /dev/null +++ b/internal/db/migrations/00035_validate_filestore_archive_entries.sql @@ -0,0 +1,14 @@ +-- +goose Up + +alter table filestore_entries + validate constraint filestore_entries_kind_check; + +alter table filestore_entries + validate constraint filestore_entries_blob_shape_check; + +alter table filestore_entries + validate constraint filestore_entries_archive_shape_check; + +-- +goose Down + +select 1; diff --git a/internal/environments/runner.go b/internal/environments/runner.go index 9d58297b8..2b4130c47 100644 --- a/internal/environments/runner.go +++ b/internal/environments/runner.go @@ -637,19 +637,20 @@ func (r *Runner) resolveRuntimeSkills(ctx context.Context, session db.Session) ( } // replaceRuntimeSkillArchives 使用已解析的不可变 skill archive,完整替换 Managed Agent -// Session 的 /skills 投影视图。 +// Session 的 /skills archive entries。 // // runtimeSkills 必须已经包含具体的版本 UUID 和可信对象元数据;"latest" 会在调用本函数 // 前解析为确定版本。本函数只保留将 zip 映射为 /skills/ 所需的字段,不下载、 -// 复制或解压 archive。 +// 复制或解压 archive。每个 zip 作为 kind=archive 的受管 entry 写入 +// /skills/,来源保存在通用 metadata 中。 // // DB 操作会校验来源、目录、版本 UUID、对象大小和 SHA-256。它在同一个事务中锁定 Session -// filesystem 记录及其命名空间,确保固定根目录存在,然后删除旧投影并插入新集合。采用 +// filesystem 记录及其命名空间,确保固定根目录存在,然后删除旧 entries 并插入新集合。采用 // 全量替换,是为了让已从 Agent snapshot 移除的 skill 同步消失,并避免读取方或并发的 // 命名空间写入方看到只更新了一部分的视图。 // -// 成功时返回 nil,确保固定根目录存在并替换投影记录;catalog 对象仍归 skill catalog -// 所有。runtimeSkills 为空时会清空投影的子目录,但保留 /skills 根目录。元数据无效、 +// 成功时返回 nil,确保固定根目录存在并替换 archive entries;catalog 对象仍归 skill +// catalog 所有。runtimeSkills 为空时会清空 archive 子目录,但保留 /skills 根目录。元数据无效、 // Session filesystem 不存在、目录重复,或事务、加锁、写入失败时,函数返回包装后的 // 错误,并回滚整个替换操作。 // @@ -662,9 +663,9 @@ func (r *Runner) replaceRuntimeSkillArchives( session db.Session, runtimeSkills []skillsapi.RuntimeSkill, ) error { - archives := make([]db.FilestoreSkillArchiveInput, 0, len(runtimeSkills)) + archives := make([]db.FilestoreSkillArchiveEntryInput, 0, len(runtimeSkills)) for _, skill := range runtimeSkills { - archives = append(archives, db.FilestoreSkillArchiveInput{ + archives = append(archives, db.FilestoreSkillArchiveEntryInput{ Source: skill.Source, SkillVersionUUID: skill.VersionUUID, Directory: skill.Directory, @@ -674,8 +675,8 @@ func (r *Runner) replaceRuntimeSkillArchives( SHA256: skill.SHA256, }) } - if err := r.db.ReplaceFilestoreSkillArchives(ctx, session.WorkspaceID, session.ExternalID, archives); err != nil { - return fmt.Errorf("replace managed agent skill archive projections: %w", err) + if err := r.db.ReplaceFilestoreSkillArchiveEntries(ctx, session.WorkspaceID, session.ExternalID, archives); err != nil { + return fmt.Errorf("replace managed agent skill archive entries: %w", err) } return nil } diff --git a/internal/filestore/service.go b/internal/filestore/service.go index 31947ae76..00f8304ee 100644 --- a/internal/filestore/service.go +++ b/internal/filestore/service.go @@ -32,7 +32,7 @@ type filestoreDatabase interface { GetFilestoreFilesystem(context.Context, int64, string) (db.FilestoreFilesystem, error) GetFilestoreEntry(context.Context, int64, int64, string) (db.FilestoreEntry, error) ListFilestoreEntriesPage(context.Context, db.ListFilestoreEntriesPageParams) (db.FilestoreEntryPage, error) - ListFilestoreSkillArchives(context.Context, int64, int64) ([]db.FilestoreSkillArchive, error) + ListFilestoreSkillArchiveEntries(context.Context, int64, int64) ([]db.FilestoreEntry, error) MakeFilestoreDirectory(context.Context, db.MakeFilestoreDirectoryInput) (db.FilestoreEntry, error) PutFilestoreFile(context.Context, db.PutFilestoreFileInput) (db.FilestoreMutationResult, error) CopyFilestoreFile(context.Context, db.CopyFilestoreFileInput) (db.FilestoreMutationResult, error) diff --git a/internal/filestore/service_test_support_test.go b/internal/filestore/service_test_support_test.go index 0b131c0ad..b94aae30e 100644 --- a/internal/filestore/service_test_support_test.go +++ b/internal/filestore/service_test_support_test.go @@ -170,27 +170,27 @@ func assertServiceAPIError(t *testing.T, apiErr *apiError, status int, code stri } type fakeServiceDatabase struct { - getFilesystemFn func(context.Context, int64, string) (db.FilestoreFilesystem, error) - getEntryFn func(context.Context, int64, int64, string) (db.FilestoreEntry, error) - listEntriesFn func(context.Context, db.ListFilestoreEntriesPageParams) (db.FilestoreEntryPage, error) - listSkillArchivesFn func(context.Context, int64, int64) ([]db.FilestoreSkillArchive, error) - makeDirectoryFn func(context.Context, db.MakeFilestoreDirectoryInput) (db.FilestoreEntry, error) - putFileFn func(context.Context, db.PutFilestoreFileInput) (db.FilestoreMutationResult, error) - copyFileFn func(context.Context, db.CopyFilestoreFileInput) (db.FilestoreMutationResult, error) - moveFileFn func(context.Context, db.MoveFilestoreFileInput) (db.FilestoreMutationResult, error) - moveDirectoryFn func(context.Context, db.MoveFilestoreDirectoryInput) (db.FilestoreMutationResult, error) - removeFileFn func(context.Context, db.RemoveFilestoreEntryInput) (db.FilestoreMutationResult, error) - removeDirectoryFn func(context.Context, db.RemoveFilestoreDirectoryInput) (db.FilestoreMutationResult, error) - enqueueCleanupFn func(context.Context, db.EnqueueFilestoreObjectCleanupJobInput) (db.FilestoreObjectCleanupJob, error) - attachCleanupFn func(context.Context, int64, string, string, string) error - completeCleanupFn func(context.Context, int64) error -} - -func (f *fakeServiceDatabase) ListFilestoreSkillArchives(ctx context.Context, workspaceID, filesystemID int64) ([]db.FilestoreSkillArchive, error) { - if f.listSkillArchivesFn == nil { - panic("unexpected ListFilestoreSkillArchives call") + getFilesystemFn func(context.Context, int64, string) (db.FilestoreFilesystem, error) + getEntryFn func(context.Context, int64, int64, string) (db.FilestoreEntry, error) + listEntriesFn func(context.Context, db.ListFilestoreEntriesPageParams) (db.FilestoreEntryPage, error) + listSkillArchiveEntriesFn func(context.Context, int64, int64) ([]db.FilestoreEntry, error) + makeDirectoryFn func(context.Context, db.MakeFilestoreDirectoryInput) (db.FilestoreEntry, error) + putFileFn func(context.Context, db.PutFilestoreFileInput) (db.FilestoreMutationResult, error) + copyFileFn func(context.Context, db.CopyFilestoreFileInput) (db.FilestoreMutationResult, error) + moveFileFn func(context.Context, db.MoveFilestoreFileInput) (db.FilestoreMutationResult, error) + moveDirectoryFn func(context.Context, db.MoveFilestoreDirectoryInput) (db.FilestoreMutationResult, error) + removeFileFn func(context.Context, db.RemoveFilestoreEntryInput) (db.FilestoreMutationResult, error) + removeDirectoryFn func(context.Context, db.RemoveFilestoreDirectoryInput) (db.FilestoreMutationResult, error) + enqueueCleanupFn func(context.Context, db.EnqueueFilestoreObjectCleanupJobInput) (db.FilestoreObjectCleanupJob, error) + attachCleanupFn func(context.Context, int64, string, string, string) error + completeCleanupFn func(context.Context, int64) error +} + +func (f *fakeServiceDatabase) ListFilestoreSkillArchiveEntries(ctx context.Context, workspaceID, filesystemID int64) ([]db.FilestoreEntry, error) { + if f.listSkillArchiveEntriesFn == nil { + panic("unexpected ListFilestoreSkillArchiveEntries call") } - return f.listSkillArchivesFn(ctx, workspaceID, filesystemID) + return f.listSkillArchiveEntriesFn(ctx, workspaceID, filesystemID) } func (f *fakeServiceDatabase) GetFilestoreFilesystem(ctx context.Context, workspaceID int64, externalID string) (db.FilestoreFilesystem, error) { diff --git a/internal/filestore/skill_archives.go b/internal/filestore/skill_archives.go index feb8a3071..243ece238 100644 --- a/internal/filestore/skill_archives.go +++ b/internal/filestore/skill_archives.go @@ -39,9 +39,8 @@ type skillArchiveNode struct { } type loadedSkillArchive struct { - projection db.FilestoreSkillArchive - data []byte - nodes map[string]skillArchiveNode + data []byte + nodes map[string]skillArchiveNode } type skillArchiveCacheEntry struct { @@ -116,19 +115,27 @@ func (s *Service) listSkillDirectory( cursor directoryCursor, limit int, ) (listDirectoryResponse, *apiError) { - archives, err := s.db.ListFilestoreSkillArchives(ctx, principal.WorkspaceID, filesystem.ID) + archiveEntries, err := s.db.ListFilestoreSkillArchiveEntries(ctx, principal.WorkspaceID, filesystem.ID) if err != nil { - return listDirectoryResponse{}, mapDatabaseError("list skill archives", err) + return listDirectoryResponse{}, mapDatabaseError("list skill archive entries", err) } nodes := make([]skillArchiveNode, 0) directoryExists := request.Path == skillNamespacePath - for _, projection := range archives { + for _, archiveEntry := range archiveEntries { + // 顶层目录名已经由 archive entry 确定,无需为非递归列举下载和校验 zip。 + if request.Path == skillNamespacePath && !request.Recursive { + nodes = append(nodes, skillArchiveNode{ + path: archiveEntry.Path, + directory: true, + }) + continue + } if request.Path != skillNamespacePath && - request.Path != projection.VirtualPath && - !strings.HasPrefix(request.Path, projection.VirtualPath+"/") { + request.Path != archiveEntry.Path && + !strings.HasPrefix(request.Path, archiveEntry.Path+"/") { continue } - archive, apiErr := s.loadSkillArchive(ctx, projection) + archive, apiErr := s.loadSkillArchive(ctx, archiveEntry) if apiErr != nil { return listDirectoryResponse{}, apiErr } @@ -166,7 +173,10 @@ func (s *Service) listSkillDirectory( } response := listDirectoryResponse{Entries: make([]entryPayload, 0, len(nodes))} for _, node := range nodes { - response.Entries = append(response.Entries, skillNodePayload(node, filesystem.ExternalID, archives)) + response.Entries = append( + response.Entries, + skillNodePayload(node, filesystem.ExternalID, archiveEntries), + ) } if hasMore { response.Cursor, err = encodeDirectoryCursor(directoryCursor{ @@ -188,11 +198,11 @@ func (s *Service) readSkillMetadata( filesystem db.FilestoreFilesystem, entryPath string, ) (entryPayload, *apiError) { - archive, node, apiErr := s.resolveSkillNode(ctx, principal, filesystem, entryPath) + archiveEntry, _, node, apiErr := s.resolveSkillNode(ctx, principal, filesystem, entryPath) if apiErr != nil { return entryPayload{}, apiErr } - return skillNodePayload(node, filesystem.ExternalID, []db.FilestoreSkillArchive{archive.projection}), nil + return skillNodePayload(node, filesystem.ExternalID, []db.FilestoreEntry{archiveEntry}), nil } func (s *Service) readSkillFile( @@ -201,7 +211,7 @@ func (s *Service) readSkillFile( filesystem db.FilestoreFilesystem, request readFileRequest, ) (readFileResult, *apiError) { - _, node, apiErr := s.resolveSkillNode(ctx, principal, filesystem, request.Path) + _, _, node, apiErr := s.resolveSkillNode(ctx, principal, filesystem, request.Path) if apiErr != nil { return readFileResult{}, apiErr } @@ -244,49 +254,53 @@ func (s *Service) resolveSkillNode( principal Principal, filesystem db.FilestoreFilesystem, entryPath string, -) (*loadedSkillArchive, skillArchiveNode, *apiError) { - archives, err := s.db.ListFilestoreSkillArchives(ctx, principal.WorkspaceID, filesystem.ID) +) (db.FilestoreEntry, *loadedSkillArchive, skillArchiveNode, *apiError) { + archiveEntries, err := s.db.ListFilestoreSkillArchiveEntries(ctx, principal.WorkspaceID, filesystem.ID) if err != nil { - return nil, skillArchiveNode{}, mapDatabaseError("list skill archives", err) + return db.FilestoreEntry{}, nil, skillArchiveNode{}, mapDatabaseError("list skill archive entries", err) } - for _, projection := range archives { - if entryPath != projection.VirtualPath && !strings.HasPrefix(entryPath, projection.VirtualPath+"/") { + for _, archiveEntry := range archiveEntries { + if entryPath != archiveEntry.Path && !strings.HasPrefix(entryPath, archiveEntry.Path+"/") { continue } - archive, apiErr := s.loadSkillArchive(ctx, projection) + archive, apiErr := s.loadSkillArchive(ctx, archiveEntry) if apiErr != nil { - return nil, skillArchiveNode{}, apiErr + return db.FilestoreEntry{}, nil, skillArchiveNode{}, apiErr } node, ok := archive.nodes[entryPath] if !ok { - return nil, skillArchiveNode{}, notFound("resource does not exist") + return db.FilestoreEntry{}, nil, skillArchiveNode{}, notFound("resource does not exist") } - return archive, node, nil + return archiveEntry, archive, node, nil } - return nil, skillArchiveNode{}, notFound("resource does not exist") + return db.FilestoreEntry{}, nil, skillArchiveNode{}, notFound("resource does not exist") } func (s *Service) loadSkillArchive( ctx context.Context, - projection db.FilestoreSkillArchive, + archiveEntry db.FilestoreEntry, ) (*loadedSkillArchive, *apiError) { if s.skillArchives == nil { return nil, internalError("load skill archive", errors.New("skill archive cache is unavailable")) } - cacheKey := strings.Join([]string{projection.S3Bucket, projection.S3Key, projection.SHA256}, "\x00") + bucket, objectKey, checksum, sizeBytes, err := skillArchiveObject(archiveEntry) + if err != nil { + return nil, internalError("load skill archive", err) + } + cacheKey := strings.Join([]string{bucket, objectKey, checksum, archiveEntry.Path}, "\x00") if archive, ok := s.skillArchives.get(cacheKey); ok { return archive, nil } if s.store == nil { return nil, internalError("load skill archive", errors.New("object store is unavailable")) } - if projection.S3Bucket != s.store.Name() { + if bucket != s.store.Name() { return nil, internalError("load skill archive", errors.New("skill archive bucket is unavailable")) } - if projection.SizeBytes <= 0 || projection.SizeBytes > maxSkillArchiveBytes { + if sizeBytes > maxSkillArchiveBytes { return nil, internalError("load skill archive", errors.New("skill archive size is invalid")) } - object, err := s.store.Open(ctx, projection.S3Key, nil) + object, err := s.store.Open(ctx, objectKey, nil) if err != nil { return nil, mapBlobstoreError("load skill archive", err) } @@ -295,14 +309,14 @@ func (s *Service) loadSkillArchive( if err != nil { return nil, mapBlobstoreError("read skill archive", err) } - if int64(len(data)) != projection.SizeBytes { + if int64(len(data)) != sizeBytes { return nil, internalError("validate skill archive", errors.New("skill archive size mismatch")) } sum := sha256.Sum256(data) - if !strings.EqualFold(hex.EncodeToString(sum[:]), projection.SHA256) { + if !strings.EqualFold(hex.EncodeToString(sum[:]), checksum) { return nil, internalError("validate skill archive", errors.New("skill archive checksum mismatch")) } - archive, err := indexSkillArchive(projection, data) + archive, err := indexSkillArchive(archiveEntry, data) if err != nil { return nil, internalError("validate skill archive", err) } @@ -310,17 +324,32 @@ func (s *Service) loadSkillArchive( return archive, nil } -func indexSkillArchive(projection db.FilestoreSkillArchive, data []byte) (*loadedSkillArchive, error) { +func skillArchiveObject(entry db.FilestoreEntry) (string, string, string, int64, error) { + if entry.Kind != db.FilestoreEntryKindArchive || + entry.ManagedBy == nil || + *entry.ManagedBy != "skill_archive" || + entry.ManagedResourceUUID == nil || + entry.S3Bucket == nil || + entry.S3Key == nil || + entry.SHA256 == nil || + entry.SizeBytes == nil || + *entry.SizeBytes <= 0 { + return "", "", "", 0, errors.New("skill archive entry is invalid") + } + return *entry.S3Bucket, *entry.S3Key, *entry.SHA256, *entry.SizeBytes, nil +} + +func indexSkillArchive(archiveEntry db.FilestoreEntry, data []byte) (*loadedSkillArchive, error) { reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) if err != nil { return nil, errors.New("skill archive is not a valid zip") } - directory := strings.TrimPrefix(projection.VirtualPath, skillNamespacePath+"/") + directory := strings.TrimPrefix(archiveEntry.Path, skillNamespacePath+"/") if directory == "" || strings.Contains(directory, "/") { return nil, errors.New("skill archive virtual path is invalid") } nodes := map[string]skillArchiveNode{ - projection.VirtualPath: {path: projection.VirtualPath, directory: true}, + archiveEntry.Path: {path: archiveEntry.Path, directory: true}, } var totalUncompressed uint64 hasSkillMD := false @@ -350,7 +379,7 @@ func indexSkillArchive(projection db.FilestoreSkillArchive, data []byte) (*loade return nil, errors.New("skill archive uncompressed size exceeds limit") } totalUncompressed = next - if err := addSkillParentNodes(nodes, path.Dir(virtualPath), projection.VirtualPath); err != nil { + if err := addSkillParentNodes(nodes, path.Dir(virtualPath), archiveEntry.Path); err != nil { return nil, err } if previous, exists := nodes[virtualPath]; exists { @@ -369,14 +398,14 @@ func indexSkillArchive(projection db.FilestoreSkillArchive, data []byte) (*loade mediaType: mediaType, file: file, } - if virtualPath == projection.VirtualPath+"/SKILL.md" { + if virtualPath == archiveEntry.Path+"/SKILL.md" { hasSkillMD = true } } if !hasSkillMD { return nil, fmt.Errorf("%s/SKILL.md not found", directory) } - return &loadedSkillArchive{projection: projection, data: data, nodes: nodes}, nil + return &loadedSkillArchive{data: data, nodes: nodes}, nil } func validateSkillZipPath(name string) (string, []string, error) { @@ -420,17 +449,20 @@ func addSkillDirectoryNode(nodes map[string]skillArchiveNode, directoryPath stri func skillNodePayload( node skillArchiveNode, filesystemExternalID string, - archives []db.FilestoreSkillArchive, + archiveEntries []db.FilestoreEntry, ) entryPayload { createdAt := time.Unix(0, 0).UTC() nodeIdentity := "" - for _, projection := range archives { - if node.path == projection.VirtualPath || strings.HasPrefix(node.path, projection.VirtualPath+"/") { - createdAt = projection.CreatedAt + for _, archiveEntry := range archiveEntries { + if node.path == archiveEntry.Path || strings.HasPrefix(node.path, archiveEntry.Path+"/") { + createdAt = archiveEntry.CreatedAt + versionUUID := "" + if archiveEntry.ManagedResourceUUID != nil { + versionUUID = *archiveEntry.ManagedResourceUUID + } nodeIdentity = strings.Join([]string{ - projection.FilesystemUUID, - projection.Source, - projection.SkillVersionUUID, + archiveEntry.FilesystemUUID, + versionUUID, }, "\x00") break } diff --git a/internal/filestore/skill_archives_test.go b/internal/filestore/skill_archives_test.go index c0693d375..f89a16c0e 100644 --- a/internal/filestore/skill_archives_test.go +++ b/internal/filestore/skill_archives_test.go @@ -42,14 +42,14 @@ func TestSkillArchiveViewRejectsInvalidArchives(t *testing.T) { t.Parallel() archiveBytes := buildSkillArchiveTestZip(t, test.files) - projection := skillArchiveTestProjection(archiveBytes) + archiveEntry := skillArchiveTestEntry(archiveBytes) if test.mutateHash { - projection.SHA256 = string(bytes.Repeat([]byte{'0'}, 64)) + archiveEntry.SHA256 = serviceTestPointer(string(bytes.Repeat([]byte{'0'}, 64))) } - service, _ := skillArchiveTestService(archiveBytes, projection) + service, _ := skillArchiveTestService(archiveBytes, archiveEntry) _, apiErr := service.ListDirectory(context.Background(), serviceTestPrincipal(), listDirectoryRequest{ FilesystemID: "fs_test", - Path: skillNamespacePath, + Path: archiveEntry.Path, }) assertServiceAPIError(t, apiErr, http.StatusInternalServerError, "internal") }) @@ -118,8 +118,8 @@ func TestSkillArchiveViewListsMetadataAndReadsRanges(t *testing.T) { "demo/SKILL.md": "# Demo", "demo/docs/guide.md": "0123456789", }) - projection := skillArchiveTestProjection(archiveBytes) - service, openCount := skillArchiveTestService(archiveBytes, projection) + archiveEntry := skillArchiveTestEntry(archiveBytes) + service, openCount := skillArchiveTestService(archiveBytes, archiveEntry) ctx := context.Background() principal := serviceTestPrincipal() @@ -133,6 +133,9 @@ func TestSkillArchiveViewListsMetadataAndReadsRanges(t *testing.T) { if len(root.Entries) != 1 || root.Entries[0].Directory == nil || root.Entries[0].Directory.Path != "/skills/demo" { t.Fatalf("ListDirectory(/skills) = %#v", root) } + if *openCount != 0 { + t.Fatalf("top-level ListDirectory(/skills) object opens = %d, want 0", *openCount) + } nested, apiErr := service.ListDirectory(ctx, principal, listDirectoryRequest{ FilesystemID: "fs_test", @@ -182,20 +185,20 @@ func TestSkillArchiveViewListsMetadataAndReadsRanges(t *testing.T) { func skillArchiveTestService( archiveBytes []byte, - projection db.FilestoreSkillArchive, + archiveEntry db.FilestoreEntry, ) (*Service, *int) { filesystem := serviceTestFilesystem() openCount := 0 database := &fakeServiceDatabase{ getFilesystemFn: serviceFilesystemLookup(filesystem), - listSkillArchivesFn: func(context.Context, int64, int64) ([]db.FilestoreSkillArchive, error) { - return []db.FilestoreSkillArchive{projection}, nil + listSkillArchiveEntriesFn: func(context.Context, int64, int64) ([]db.FilestoreEntry, error) { + return []db.FilestoreEntry{archiveEntry}, nil }, } store := &fakeServiceBlobStore{ openFn: func(_ context.Context, key string, byteRange *storage.ByteRange) (storage.Object, error) { openCount++ - if key != projection.S3Key || byteRange != nil { + if key != *archiveEntry.S3Key || byteRange != nil { t := storage.ErrNotFound return storage.Object{}, t } @@ -208,24 +211,28 @@ func skillArchiveTestService( return newServiceUnderTest(filestoreTestConfig(1024, 4096, "filestore-test"), database, store), &openCount } -func skillArchiveTestProjection(data []byte) db.FilestoreSkillArchive { +func skillArchiveTestEntry(data []byte) db.FilestoreEntry { sum := sha256.Sum256(data) - return db.FilestoreSkillArchive{ - ID: 71, - UUID: "77777777-7777-4777-8777-777777777777", - ExternalID: "fsa_test", - OrganizationUUID: serviceTestPrincipal().OrganizationUUID, - WorkspaceUUID: serviceTestPrincipal().WorkspaceUUID, - FilesystemUUID: serviceTestFilesystem().UUID, - Source: "custom", - SkillVersionUUID: "88888888-8888-4888-8888-888888888888", - VirtualPath: "/skills/demo", - S3Bucket: "filestore-test", - S3Key: "skills/demo/1.zip", - SizeBytes: int64(len(data)), - SHA256: hex.EncodeToString(sum[:]), - CreatedAt: serviceTestNow, - UpdatedAt: serviceTestNow, + return db.FilestoreEntry{ + ID: 71, + UUID: "77777777-7777-4777-8777-777777777777", + ExternalID: "fse_test", + OrganizationUUID: serviceTestPrincipal().OrganizationUUID, + WorkspaceUUID: serviceTestPrincipal().WorkspaceUUID, + FilesystemUUID: serviceTestFilesystem().UUID, + Kind: db.FilestoreEntryKindArchive, + Path: "/skills/demo", + ParentPath: serviceTestPointer("/skills"), + SizeBytes: serviceTestPointer(int64(len(data))), + MediaType: serviceTestPointer("application/zip"), + DetectedMimeType: serviceTestPointer("application/zip"), + SHA256: serviceTestPointer(hex.EncodeToString(sum[:])), + S3Bucket: serviceTestPointer("filestore-test"), + S3Key: serviceTestPointer("skills/demo/1.zip"), + ManagedBy: serviceTestPointer("skill_archive"), + ManagedResourceUUID: serviceTestPointer("88888888-8888-4888-8888-888888888888"), + CreatedAt: serviceTestNow, + UpdatedAt: serviceTestNow, } } diff --git a/tests/environments_runner_cloud_test.go b/tests/environments_runner_cloud_test.go index e2854129c..88ea8d450 100644 --- a/tests/environments_runner_cloud_test.go +++ b/tests/environments_runner_cloud_test.go @@ -543,12 +543,19 @@ func TestEnvironmentRunnerInstallsManagedAgentCustomSkill(t *testing.T) { if err != nil { t.Fatalf("get session filestore: %v", err) } - projections, err := app.db.ListFilestoreSkillArchives(ctx, getDefaultDBIDs(t, app.db).WorkspaceID, filesystem.ID) + archiveEntries, err := app.db.ListFilestoreSkillArchiveEntries( + ctx, + getDefaultDBIDs(t, app.db).WorkspaceID, + filesystem.ID, + ) if err != nil { - t.Fatalf("list skill archive projections: %v", err) + t.Fatalf("list skill archive entries: %v", err) } - if len(projections) != 1 || projections[0].VirtualPath != "/skills/runtime-skill" || projections[0].Source != "custom" { - t.Fatalf("skill archive projections = %#v", projections) + if len(archiveEntries) != 1 || + archiveEntries[0].Kind != db.FilestoreEntryKindArchive || + archiveEntries[0].Path != "/skills/runtime-skill" || + string(archiveEntries[0].Metadata) != `{"skill_source": "custom"}` { + t.Fatalf("skill archive entries = %#v", archiveEntries) } if len(provider.creates) != 1 { t.Fatalf("sandbox creates = %#v, want one", provider.creates) @@ -637,12 +644,18 @@ func TestEnvironmentRunnerProjectsSkillsWithoutDownloadingArchives(t *testing.T) if err != nil { t.Fatalf("get session filestore: %v", err) } - projections, err := app.db.ListFilestoreSkillArchives(ctx, getDefaultDBIDs(t, app.db).WorkspaceID, filesystem.ID) + archiveEntries, err := app.db.ListFilestoreSkillArchiveEntries( + ctx, + getDefaultDBIDs(t, app.db).WorkspaceID, + filesystem.ID, + ) if err != nil { - t.Fatalf("list projections: %v", err) + t.Fatalf("list archive entries: %v", err) } - if len(projections) != 1 || projections[0].VirtualPath != "/skills/missing-resolver-skill" { - t.Fatalf("projections = %#v", projections) + if len(archiveEntries) != 1 || + archiveEntries[0].Kind != db.FilestoreEntryKindArchive || + archiveEntries[0].Path != "/skills/missing-resolver-skill" { + t.Fatalf("archive entries = %#v", archiveEntries) } } diff --git a/tests/filestore_db_test.go b/tests/filestore_db_test.go index 81899e868..ba236bf8b 100644 --- a/tests/filestore_db_test.go +++ b/tests/filestore_db_test.go @@ -743,7 +743,7 @@ func TestDeleteSessionQueuesBoundedFilesystemCleanup(t *testing.T) { }); err != nil { t.Fatalf("put cleanup file: %v", err) } - if err := app.db.ReplaceFilestoreSkillArchives(context.Background(), workspaceID, created.ExternalID, []db.FilestoreSkillArchiveInput{{ + if err := app.db.ReplaceFilestoreSkillArchiveEntries(context.Background(), workspaceID, created.ExternalID, []db.FilestoreSkillArchiveEntryInput{{ Source: "custom", SkillVersionUUID: uuid.NewString(), Directory: "cleanup-skill", @@ -825,24 +825,27 @@ func TestDeleteSessionQueuesBoundedFilesystemCleanup(t *testing.T) { if err != nil || !done { t.Fatalf("process filesystem cleanup = done %v, error %v", done, err) } - var activeEntries, activeSkillArchives, cleanupObjects int + var activeEntries, activeSkillArchiveEntries, cleanupObjects int if err := app.db.Pool.QueryRow(context.Background(), ` select (select count(*) from filestore_entries where filesystem_uuid = $1 and deleted_at is null), - (select count(*) from filestore_skill_archives where filesystem_uuid = $1), + (select count(*) from filestore_entries + where filesystem_uuid = $1 + and kind = 'archive' + and deleted_at is null), (select count(*) from jobs where type = 'filestore_object_cleanup' and payload->>'filesystem_uuid' = $1::text and payload->>'workspace_uuid' = $2 and not (payload ? 'filesystem_id') and payload->>'reason' = 'session_deleted') - `, filesystem.UUID, workspaceUUID).Scan(&activeEntries, &activeSkillArchives, &cleanupObjects); err != nil { + `, filesystem.UUID, workspaceUUID).Scan(&activeEntries, &activeSkillArchiveEntries, &cleanupObjects); err != nil { t.Fatalf("load processed cleanup state: %v", err) } - if activeEntries != 0 || activeSkillArchives != 0 || cleanupObjects != 1 { + if activeEntries != 0 || activeSkillArchiveEntries != 0 || cleanupObjects != 1 { t.Fatalf( - "processed cleanup = active entries %d, skill archives %d, object jobs %d; want 0, 0, 1", + "processed cleanup = active entries %d, skill archive entries %d, object jobs %d; want 0, 0, 1", activeEntries, - activeSkillArchives, + activeSkillArchiveEntries, cleanupObjects, ) } @@ -863,6 +866,144 @@ func TestDeleteSessionQueuesBoundedFilesystemCleanup(t *testing.T) { } } +func TestFilestoreSkillArchivesUseUnifiedEntries(t *testing.T) { + app := newTestAppWithStore(t, nil, newFakeStore("filestore-archive-entry")) + t.Cleanup(app.close) + organizationID, workspaceID, _, _, apiKeyID, _, _, _, _, _ := seedFilestoreLookupScope(t, app) + created, _, _, _, err := app.db.CreateSession( + context.Background(), + filestoreSessionCreateInput(organizationID, workspaceID, apiKeyID), + ) + if err != nil { + t.Fatalf("CreateSession() error = %v", err) + } + filesystem, err := app.db.GetFilestoreFilesystemBySession( + context.Background(), + workspaceID, + created.ExternalID, + ) + if err != nil { + t.Fatalf("load Session filesystem: %v", err) + } + + var legacyTable *string + if err := app.db.Pool.QueryRow( + context.Background(), + `select cast(to_regclass('filestore_skill_archives') as text)`, + ).Scan(&legacyTable); err != nil { + t.Fatalf("check legacy archive table: %v", err) + } + if legacyTable != nil { + t.Fatalf("legacy archive table still exists: %s", *legacyTable) + } + + versionUUID := uuid.NewString() + err = app.db.ReplaceFilestoreSkillArchiveEntries( + context.Background(), + workspaceID, + created.ExternalID, + []db.FilestoreSkillArchiveEntryInput{{ + Source: "custom", + SkillVersionUUID: versionUUID, + Directory: "demo", + S3Bucket: "filestore-archive-entry", + S3Key: "catalog/demo.zip", + SizeBytes: 128, + SHA256: strings.Repeat("a", 64), + }}, + ) + if err != nil { + t.Fatalf("replace archive entries: %v", err) + } + + entries, err := app.db.ListFilestoreSkillArchiveEntries( + context.Background(), + workspaceID, + filesystem.ID, + ) + if err != nil { + t.Fatalf("list archive entries: %v", err) + } + if len(entries) != 1 { + t.Fatalf("archive entry count = %d, want 1", len(entries)) + } + entry := entries[0] + var metadata struct { + SkillSource string `json:"skill_source"` + } + if err := json.Unmarshal(entry.Metadata, &metadata); err != nil { + t.Fatalf("decode archive entry metadata: %v", err) + } + if entry.Kind != db.FilestoreEntryKindArchive || + entry.Path != "/skills/demo" || + entry.ParentPath == nil || + *entry.ParentPath != "/skills" || + entry.ManagedBy == nil || + *entry.ManagedBy != "skill_archive" || + entry.ManagedResourceUUID == nil || + *entry.ManagedResourceUUID != versionUUID || + entry.S3Key == nil || + *entry.S3Key != "catalog/demo.zip" || + metadata.SkillSource != "custom" { + t.Fatalf("archive entry = %#v, metadata = %#v", entry, metadata) + } + + page, err := app.db.ListFilestoreEntriesPage(context.Background(), db.ListFilestoreEntriesPageParams{ + WorkspaceID: workspaceID, + FilesystemID: filesystem.ID, + DirectoryPath: "/", + Recursive: true, + Limit: 100, + }) + if err != nil { + t.Fatalf("list ordinary entries: %v", err) + } + for _, listedEntry := range page.Entries { + if listedEntry.Kind == db.FilestoreEntryKindArchive { + t.Fatalf("ordinary entry listing exposed archive: %#v", listedEntry) + } + } + storageBytes, err := app.db.WorkspaceStorageBytes(context.Background(), workspaceID) + if err != nil { + t.Fatalf("load workspace storage: %v", err) + } + if storageBytes != 0 { + t.Fatalf("workspace storage bytes = %d, want 0", storageBytes) + } + + if err := app.db.ReplaceFilestoreSkillArchiveEntries( + context.Background(), + workspaceID, + created.ExternalID, + nil, + ); err != nil { + t.Fatalf("clear archive entries: %v", err) + } + entries, err = app.db.ListFilestoreSkillArchiveEntries( + context.Background(), + workspaceID, + filesystem.ID, + ) + if err != nil { + t.Fatalf("list cleared archive entries: %v", err) + } + if len(entries) != 0 { + t.Fatalf("archive entries after clear = %#v", entries) + } + skillsRoot, err := app.db.GetFilestoreEntry( + context.Background(), + workspaceID, + filesystem.ID, + "/skills", + ) + if err != nil { + t.Fatalf("load /skills root: %v", err) + } + if skillsRoot.Kind != db.FilestoreEntryKindDirectory { + t.Fatalf("/skills kind = %q, want directory", skillsRoot.Kind) + } +} + func TestFilestoreFilesystemLookupPrefersExactExternalID(t *testing.T) { app := newTestAppWithStore(t, nil, newFakeStore("filestore-lookup-priority-bucket")) t.Cleanup(app.close) From dcd2122964445e5dd1f63f41744685a6cbcea2b2 Mon Sep 17 00:00:00 2001 From: arthur-zhang Date: Mon, 27 Jul 2026 09:15:04 +0800 Subject: [PATCH 6/9] Refactor filestore reads through pluggable path backends --- docs/design/be/filestore.md | 9 ++ internal/filestore/path_backend.go | 74 +++++++++++ internal/filestore/path_backend_test.go | 102 ++++++++++++++++ internal/filestore/persistent_backend.go | 116 ++++++++++++++++++ internal/filestore/service.go | 142 +++++----------------- internal/filestore/skill_archives.go | 56 ++++++--- internal/filestore/skill_archives_test.go | 95 ++++++++++++++- internal/skills/handler.go | 3 + internal/skills/seed.go | 2 + 9 files changed, 470 insertions(+), 129 deletions(-) create mode 100644 internal/filestore/path_backend.go create mode 100644 internal/filestore/path_backend_test.go create mode 100644 internal/filestore/persistent_backend.go diff --git a/docs/design/be/filestore.md b/docs/design/be/filestore.md index 88e70f523..19d9a350d 100644 --- a/docs/design/be/filestore.md +++ b/docs/design/be/filestore.md @@ -21,6 +21,8 @@ Filestore 是现有单体中的独立资源切片。handler 负责 wire contract 和流式 HTTP,service 负责校验及业务编排,`internal/db` 负责事务和租户范围内的持久化。对象读写、错误分类和版本清理统一交给绑定单个 bucket 的 `internal/storage.ObjectStore`;生产环境由共享 `storage.Client` 复用 AWS SDK 连接,再按名称派生轻量对象存储。 +Service 在完成请求校验和 filesystem 租户鉴权后,通过 `pathRouter` 把 list、file read 和 metadata read 分发给持久化 backend 或只读虚拟 backend。普通 namespace 的读取由持久化 backend 访问数据库和对象存储,`/skills` 的 archive 索引、缓存与成员读取由独立 skill backend 处理。虚拟 backend 自己声明读取匹配范围,router 则统一拒绝对其整棵 namespace 的 mutation;普通写入仍由 Service 编排既有数据库事务和对象存储操作。这样新增只读虚拟 namespace 时只需注册新的 backend,不需要在每个 Filestore API 入口增加特例。 + Filestore 还拥有独立的 `filestore.Principal`。API 中间件完成专用 JWT 验证与数据库回查后,只把资源所需的租户、account、filesystem 和策略范围映射到该类型,并通过 Filestore 私有的 context key 交给 handler。全局 `auth.Principal` 不保存 `filesystem_id`、`readonly`、`org_taints` 或 CMEK 等 Filestore 专属状态;Filestore handler/service 也不依赖全局 Principal。 ```mermaid @@ -29,6 +31,13 @@ flowchart LR AUTH["API auth boundary"] --> P["filestore.Principal"] P --> H H --> S["Filestore service"] + S --> PR["pathRouter"] + PR --> PB["persistent read backend"] + PR --> SB["skill read backend"] + PB --> D + PB --> T + SB --> D + SB --> T S --> D["PostgreSQL namespace"] S --> T["default storage.ObjectStore"] SC["shared storage.Client"] --> T diff --git a/internal/filestore/path_backend.go b/internal/filestore/path_backend.go new file mode 100644 index 000000000..c3896e3cb --- /dev/null +++ b/internal/filestore/path_backend.go @@ -0,0 +1,74 @@ +package filestore + +import ( + "context" + + "github.com/superduck-ai/open-managed-agents/internal/db" +) + +type readOperation uint8 + +const ( + readOperationListDirectory readOperation = iota + readOperationFile + readOperationMetadata +) + +// pathBackend 只抽象不同命名空间共享的读取能力。 +// 普通 Filestore 的写入仍由 Service 编排,避免把对象存储和数据库事务塞进虚拟读取接口。 +type pathBackend interface { + listDirectory( + context.Context, + Principal, + db.FilestoreFilesystem, + listDirectoryRequest, + directoryCursor, + int, + ) (listDirectoryResponse, *apiError) + readFile( + context.Context, + Principal, + db.FilestoreFilesystem, + readFileRequest, + ) (readFileResult, *apiError) + readMetadata( + context.Context, + Principal, + db.FilestoreFilesystem, + string, + ) (entryPayload, *apiError) +} + +// readOnlyPathBackend 表示覆盖普通持久化视图的只读虚拟命名空间。 +// backend 自己决定哪些读取由它处理;router 统一阻止对整棵命名空间的修改。 +type readOnlyPathBackend interface { + pathBackend + namespaceRoot() string + matchesRead(readOperation, string) bool + containsPath(string) bool +} + +type pathRouter struct { + persistent pathBackend + readOnly []readOnlyPathBackend +} + +func (r pathRouter) backendFor(operation readOperation, value string) pathBackend { + for _, backend := range r.readOnly { + if backend.matchesRead(operation, value) { + return backend + } + } + return r.persistent +} + +func (r pathRouter) authorizeMutation(paths ...string) *apiError { + for _, value := range paths { + for _, backend := range r.readOnly { + if backend.containsPath(value) { + return permissionDenied("the " + backend.namespaceRoot() + " namespace is read-only") + } + } + } + return nil +} diff --git a/internal/filestore/path_backend_test.go b/internal/filestore/path_backend_test.go new file mode 100644 index 000000000..a7bd2cfb2 --- /dev/null +++ b/internal/filestore/path_backend_test.go @@ -0,0 +1,102 @@ +package filestore + +import ( + "net/http" + "testing" +) + +func TestPathRouterRejectsReadOnlyNamespaceMutation(t *testing.T) { + t.Parallel() + + router := pathRouter{ + persistent: &persistentPathBackend{}, + readOnly: []readOnlyPathBackend{&skillArchivePathBackend{}}, + } + for _, paths := range [][]string{ + {"/skills"}, + {"/skills/demo/SKILL.md"}, + {"/outputs/a.txt", "/skills/demo/a.txt"}, + {"/skills/demo/a.txt", "/outputs/a.txt"}, + } { + apiErr := router.authorizeMutation(paths...) + assertServiceAPIError(t, apiErr, http.StatusForbidden, "permission_denied") + } + if apiErr := router.authorizeMutation("/outputs/a.txt", "/outputs/b.txt"); apiErr != nil { + t.Fatalf("authorizeMutation() error = %v, want nil", apiErr) + } +} + +func TestPathRouterSelectsReadBackend(t *testing.T) { + t.Parallel() + + persistent := &persistentPathBackend{} + skills := &skillArchivePathBackend{} + router := pathRouter{ + persistent: persistent, + readOnly: []readOnlyPathBackend{skills}, + } + tests := []struct { + name string + operation readOperation + path string + want pathBackend + }{ + { + name: "skill root list", + operation: readOperationListDirectory, + path: "/skills", + want: skills, + }, + { + name: "skill descendant list", + operation: readOperationListDirectory, + path: "/skills/demo", + want: skills, + }, + { + name: "skill descendant file", + operation: readOperationFile, + path: "/skills/demo/SKILL.md", + want: skills, + }, + { + name: "skill descendant metadata", + operation: readOperationMetadata, + path: "/skills/demo/SKILL.md", + want: skills, + }, + { + name: "skill root file stays persistent", + operation: readOperationFile, + path: "/skills", + want: persistent, + }, + { + name: "skill root metadata stays persistent", + operation: readOperationMetadata, + path: "/skills", + want: persistent, + }, + { + name: "ordinary path", + operation: readOperationMetadata, + path: "/outputs/report.txt", + want: persistent, + }, + { + name: "similar prefix is ordinary", + operation: readOperationListDirectory, + path: "/skills-old", + want: persistent, + }, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + if got := router.backendFor(test.operation, test.path); got != test.want { + t.Fatalf("backendFor(%q) = %T, want %T", test.path, got, test.want) + } + }) + } +} diff --git a/internal/filestore/persistent_backend.go b/internal/filestore/persistent_backend.go new file mode 100644 index 000000000..8e2a7a922 --- /dev/null +++ b/internal/filestore/persistent_backend.go @@ -0,0 +1,116 @@ +package filestore + +import ( + "bytes" + "context" + "errors" + "io" + + "github.com/superduck-ai/open-managed-agents/internal/db" + "github.com/superduck-ai/open-managed-agents/internal/storage" +) + +type persistentPathBackend struct { + db filestoreDatabase + store storage.ObjectStore +} + +func (b *persistentPathBackend) listDirectory( + ctx context.Context, + principal Principal, + filesystem db.FilestoreFilesystem, + request listDirectoryRequest, + cursor directoryCursor, + limit int, +) (listDirectoryResponse, *apiError) { + params := db.ListFilestoreEntriesPageParams{ + WorkspaceID: principal.WorkspaceID, + FilesystemID: filesystem.ID, + DirectoryPath: request.Path, + Recursive: request.Recursive, + Limit: limit, + } + if request.Cursor != "" { + // Path 是主排序键,ID 在路径相同的边界情形下提供稳定的决胜键。 + params.Cursor = &db.FilestoreEntryPageCursor{Path: cursor.LastPath, ID: cursor.LastID} + } + page, err := b.db.ListFilestoreEntriesPage(ctx, params) + if err != nil { + return listDirectoryResponse{}, mapDatabaseError("list directory", err) + } + entries := page.Entries + response := listDirectoryResponse{Entries: make([]entryPayload, 0, len(entries))} + for _, entry := range entries { + payload, err := payloadFromEntry(entry, filesystem.ExternalID) + if err != nil { + return listDirectoryResponse{}, internalError("encode directory entry", err) + } + response.Entries = append(response.Entries, payload) + } + if page.HasMore && len(entries) != 0 { + // 只在确有下一页时签发游标;最后一页返回空 cursor,rclone 据此停止翻页。 + last := entries[len(entries)-1] + response.Cursor, err = encodeDirectoryCursor(directoryCursor{ + FilesystemID: request.FilesystemID, + Path: request.Path, + Recursive: request.Recursive, + LastPath: last.Path, + LastID: last.ID, + }) + if err != nil { + return listDirectoryResponse{}, internalError("encode directory cursor", err) + } + } + return response, nil +} + +func (b *persistentPathBackend) readFile( + ctx context.Context, + principal Principal, + filesystem db.FilestoreFilesystem, + request readFileRequest, +) (readFileResult, *apiError) { + entry, err := b.db.GetFilestoreEntry(ctx, principal.WorkspaceID, filesystem.ID, request.Path) + if err != nil { + return readFileResult{}, mapDatabaseError("read file metadata", err) + } + if entry.Kind != db.FilestoreEntryKindFile || entry.S3Key == nil || entry.SizeBytes == nil { + return readFileResult{}, failedPrecondition("path is not a file") + } + objectRange, responseSize, apiErr := resolveReadRange(request.Range, *entry.SizeBytes) + if apiErr != nil { + return readFileResult{}, apiErr + } + mediaType := stringValue(entry.MediaType) + if responseSize == 0 { + // 空区间无需访问 S3;仍返回可关闭的空流,使 Handler 的生命周期保持统一。 + return readFileResult{Body: io.NopCloser(bytes.NewReader(nil)), MediaType: mediaType}, nil + } + object, err := b.store.Open(ctx, *entry.S3Key, objectRange) + if err != nil { + if errors.Is(err, storage.ErrNotFound) { + return readFileResult{}, internalError("read file object", errors.New("object metadata exists but blob is missing")) + } + return readFileResult{}, mapBlobstoreError("read file", err) + } + // 数据库元数据与已解析区间共同决定协议层应返回的精确字节数。 + // S3 响应可能没有 Content-Length(Object.Size 为 -1),不能让传输语义取决于该可选响应头。 + return readFileResult{Body: object.Body, Size: responseSize, MediaType: mediaType}, nil +} + +func (b *persistentPathBackend) readMetadata( + ctx context.Context, + principal Principal, + filesystem db.FilestoreFilesystem, + entryPath string, +) (entryPayload, *apiError) { + entry, err := b.db.GetFilestoreEntry(ctx, principal.WorkspaceID, filesystem.ID, entryPath) + if err != nil { + return entryPayload{}, mapDatabaseError("read metadata", err) + } + payload, err := payloadFromEntry(entry, filesystem.ExternalID) + if err != nil { + return entryPayload{}, internalError("encode metadata", err) + } + return payload, nil +} diff --git a/internal/filestore/service.go b/internal/filestore/service.go index 00f8304ee..5708baa49 100644 --- a/internal/filestore/service.go +++ b/internal/filestore/service.go @@ -1,7 +1,6 @@ package filestore import ( - "bytes" "context" "crypto/md5" "crypto/sha256" @@ -48,11 +47,11 @@ type filestoreDatabase interface { // Service 编排 Filestore 的鉴权上下文、元数据事务与对象存储操作。 // 数据库负责命名空间一致性,对象存储负责字节内容,两者通过持久化清理任务实现最终一致。 type Service struct { - cfg config.Config - db filestoreDatabase - store storage.ObjectStore - now func() time.Time - skillArchives *skillArchiveCache + cfg config.Config + db filestoreDatabase + store storage.ObjectStore + now func() time.Time + paths pathRouter } type readFileResult struct { @@ -63,12 +62,21 @@ type readFileResult struct { // NewService 创建 Filestore 业务服务。 func NewService(cfg config.Config, database filestoreDatabase, store storage.ObjectStore) *Service { + persistent := &persistentPathBackend{db: database, store: store} + skills := &skillArchivePathBackend{ + db: database, + store: store, + cache: newSkillArchiveCache(defaultSkillArchiveCacheBytes), + } return &Service{ - cfg: cfg, - db: database, - store: store, - now: time.Now, - skillArchives: newSkillArchiveCache(defaultSkillArchiveCacheBytes), + cfg: cfg, + db: database, + store: store, + now: time.Now, + paths: pathRouter{ + persistent: persistent, + readOnly: []readOnlyPathBackend{skills}, + }, } } @@ -92,48 +100,8 @@ func (s *Service) ListDirectory(ctx context.Context, principal Principal, reques if apiErr != nil { return listDirectoryResponse{}, apiErr } - if isSkillNamespacePath(request.Path) { - return s.listSkillDirectory(ctx, principal, filesystem, request, cursor, int(limit)) - } - params := db.ListFilestoreEntriesPageParams{ - WorkspaceID: principal.WorkspaceID, - FilesystemID: filesystem.ID, - DirectoryPath: request.Path, - Recursive: request.Recursive, - Limit: int(limit), - } - if request.Cursor != "" { - // Path 是主排序键,ID 在路径相同的边界情形下提供稳定的决胜键。 - params.Cursor = &db.FilestoreEntryPageCursor{Path: cursor.LastPath, ID: cursor.LastID} - } - page, err := s.db.ListFilestoreEntriesPage(ctx, params) - if err != nil { - return listDirectoryResponse{}, mapDatabaseError("list directory", err) - } - entries := page.Entries - response := listDirectoryResponse{Entries: make([]entryPayload, 0, len(entries))} - for _, entry := range entries { - payload, err := payloadFromEntry(entry, filesystem.ExternalID) - if err != nil { - return listDirectoryResponse{}, internalError("encode directory entry", err) - } - response.Entries = append(response.Entries, payload) - } - if page.HasMore && len(entries) != 0 { - // 只在确有下一页时签发游标;最后一页返回空 cursor,rclone 据此停止翻页。 - last := entries[len(entries)-1] - response.Cursor, err = encodeDirectoryCursor(directoryCursor{ - FilesystemID: request.FilesystemID, - Path: request.Path, - Recursive: request.Recursive, - LastPath: last.Path, - LastID: last.ID, - }) - if err != nil { - return listDirectoryResponse{}, internalError("encode directory cursor", err) - } - } - return response, nil + backend := s.paths.backendFor(readOperationListDirectory, request.Path) + return backend.listDirectory(ctx, principal, filesystem, request, cursor, int(limit)) } // MakeDirectory 创建目录;MakeParents 为真时在同一事务内补齐整条父目录链。 @@ -145,7 +113,7 @@ func (s *Service) MakeDirectory(ctx context.Context, principal Principal, reques if apiErr != nil { return directoryResponse{}, apiErr } - if apiErr := rejectSkillMutation(request.Path); apiErr != nil { + if apiErr := s.paths.authorizeMutation(request.Path); apiErr != nil { return directoryResponse{}, apiErr } entry, err := s.db.MakeFilestoreDirectory(ctx, db.MakeFilestoreDirectoryInput{ @@ -170,7 +138,7 @@ func (s *Service) RemoveDirectory(ctx context.Context, principal Principal, requ if apiErr != nil { return apiErr } - if apiErr := rejectSkillMutation(request.Path); apiErr != nil { + if apiErr := s.paths.authorizeMutation(request.Path); apiErr != nil { return apiErr } _, err := s.db.RemoveFilestoreDirectory(ctx, db.RemoveFilestoreDirectoryInput{ @@ -199,7 +167,7 @@ func (s *Service) CreateFile(ctx context.Context, principal Principal, params cr if apiErr != nil { return fileResponse{}, apiErr } - if apiErr := rejectSkillMutation(params.Path); apiErr != nil { + if apiErr := s.paths.authorizeMutation(params.Path); apiErr != nil { return fileResponse{}, apiErr } if apiErr := s.requireParentDirectory(ctx, principal.WorkspaceID, filesystem.ID, params.Path); apiErr != nil { @@ -285,7 +253,7 @@ func (s *Service) CopyFile(ctx context.Context, principal Principal, request cop if apiErr != nil { return fileResponse{}, apiErr } - if apiErr := rejectSkillMutation(request.Source, request.Destination); apiErr != nil { + if apiErr := s.paths.authorizeMutation(request.Source, request.Destination); apiErr != nil { return fileResponse{}, apiErr } source, err := s.db.GetFilestoreEntry(ctx, principal.WorkspaceID, filesystem.ID, request.Source) @@ -358,7 +326,7 @@ func (s *Service) MoveFile(ctx context.Context, principal Principal, request cop if apiErr != nil { return fileResponse{}, apiErr } - if apiErr := rejectSkillMutation(request.Source, request.Destination); apiErr != nil { + if apiErr := s.paths.authorizeMutation(request.Source, request.Destination); apiErr != nil { return fileResponse{}, apiErr } result, err := s.db.MoveFilestoreFile(ctx, db.MoveFilestoreFileInput{ @@ -394,7 +362,7 @@ func (s *Service) MoveDirectory(ctx context.Context, principal Principal, reques if apiErr != nil { return directoryResponse{}, apiErr } - if apiErr := rejectSkillMutation(request.Source, request.Destination); apiErr != nil { + if apiErr := s.paths.authorizeMutation(request.Source, request.Destination); apiErr != nil { return directoryResponse{}, apiErr } result, err := s.db.MoveFilestoreDirectory(ctx, db.MoveFilestoreDirectoryInput{ @@ -419,35 +387,8 @@ func (s *Service) ReadFile(ctx context.Context, principal Principal, request rea if apiErr != nil { return readFileResult{}, apiErr } - if strings.HasPrefix(request.Path, skillNamespacePath+"/") { - return s.readSkillFile(ctx, principal, filesystem, request) - } - entry, err := s.db.GetFilestoreEntry(ctx, principal.WorkspaceID, filesystem.ID, request.Path) - if err != nil { - return readFileResult{}, mapDatabaseError("read file metadata", err) - } - if entry.Kind != db.FilestoreEntryKindFile || entry.S3Key == nil || entry.SizeBytes == nil { - return readFileResult{}, failedPrecondition("path is not a file") - } - objectRange, responseSize, apiErr := resolveReadRange(request.Range, *entry.SizeBytes) - if apiErr != nil { - return readFileResult{}, apiErr - } - mediaType := stringValue(entry.MediaType) - if responseSize == 0 { - // 空区间无需访问 S3;仍返回可关闭的空流,使 Handler 的生命周期保持统一。 - return readFileResult{Body: io.NopCloser(bytes.NewReader(nil)), MediaType: mediaType}, nil - } - object, err := s.store.Open(ctx, *entry.S3Key, objectRange) - if err != nil { - if errors.Is(err, storage.ErrNotFound) { - return readFileResult{}, internalError("read file object", errors.New("object metadata exists but blob is missing")) - } - return readFileResult{}, mapBlobstoreError("read file", err) - } - // 数据库元数据与已解析区间共同决定协议层应返回的精确字节数。 - // S3 响应可能没有 Content-Length(Object.Size 为 -1),不能让传输语义取决于该可选响应头。 - return readFileResult{Body: object.Body, Size: responseSize, MediaType: mediaType}, nil + backend := s.paths.backendFor(readOperationFile, request.Path) + return backend.readFile(ctx, principal, filesystem, request) } // RemoveFile 软删除元数据并登记对象清理任务;重复删除按幂等成功处理。 @@ -459,7 +400,7 @@ func (s *Service) RemoveFile(ctx context.Context, principal Principal, request p if apiErr != nil { return apiErr } - if apiErr := rejectSkillMutation(request.Path); apiErr != nil { + if apiErr := s.paths.authorizeMutation(request.Path); apiErr != nil { return apiErr } _, err := s.db.RemoveFilestoreFile(ctx, db.RemoveFilestoreEntryInput{ @@ -483,27 +424,8 @@ func (s *Service) ReadMetadata(ctx context.Context, principal Principal, request if apiErr != nil { return entryPayload{}, apiErr } - if strings.HasPrefix(request.Path, skillNamespacePath+"/") { - return s.readSkillMetadata(ctx, principal, filesystem, request.Path) - } - entry, err := s.db.GetFilestoreEntry(ctx, principal.WorkspaceID, filesystem.ID, request.Path) - if err != nil { - return entryPayload{}, mapDatabaseError("read metadata", err) - } - payload, err := payloadFromEntry(entry, filesystem.ExternalID) - if err != nil { - return entryPayload{}, internalError("encode metadata", err) - } - return payload, nil -} - -func rejectSkillMutation(paths ...string) *apiError { - for _, value := range paths { - if isSkillNamespacePath(value) { - return permissionDenied("the /skills namespace is read-only") - } - } - return nil + backend := s.paths.backendFor(readOperationMetadata, request.Path) + return backend.readMetadata(ctx, principal, filesystem, request.Path) } func (s *Service) resolveFilesystem(ctx context.Context, principal Principal, filesystemID string) (db.FilestoreFilesystem, *apiError) { diff --git a/internal/filestore/skill_archives.go b/internal/filestore/skill_archives.go index 243ece238..0b91ee7f6 100644 --- a/internal/filestore/skill_archives.go +++ b/internal/filestore/skill_archives.go @@ -21,6 +21,7 @@ import ( "github.com/google/uuid" "github.com/superduck-ai/open-managed-agents/internal/db" + "github.com/superduck-ai/open-managed-agents/internal/storage" ) const ( @@ -56,6 +57,12 @@ type skillArchiveCache struct { order *list.List } +type skillArchivePathBackend struct { + db filestoreDatabase + store storage.ObjectStore + cache *skillArchiveCache +} + func newSkillArchiveCache(maxBytes int) *skillArchiveCache { return &skillArchiveCache{ maxBytes: maxBytes, @@ -103,11 +110,24 @@ func (c *skillArchiveCache) put(key string, archive *loadedSkillArchive) { } } -func isSkillNamespacePath(value string) bool { +func (b *skillArchivePathBackend) namespaceRoot() string { + return skillNamespacePath +} + +func (b *skillArchivePathBackend) containsPath(value string) bool { return value == skillNamespacePath || strings.HasPrefix(value, skillNamespacePath+"/") } -func (s *Service) listSkillDirectory( +func (b *skillArchivePathBackend) matchesRead(operation readOperation, value string) bool { + if strings.HasPrefix(value, skillNamespacePath+"/") { + return true + } + // /skills 本身是持久化目录:只有目录列举需要切换到虚拟 archive 视图, + // metadata 和 file read 仍按普通 entry 语义处理。 + return operation == readOperationListDirectory && value == skillNamespacePath +} + +func (b *skillArchivePathBackend) listDirectory( ctx context.Context, principal Principal, filesystem db.FilestoreFilesystem, @@ -115,7 +135,7 @@ func (s *Service) listSkillDirectory( cursor directoryCursor, limit int, ) (listDirectoryResponse, *apiError) { - archiveEntries, err := s.db.ListFilestoreSkillArchiveEntries(ctx, principal.WorkspaceID, filesystem.ID) + archiveEntries, err := b.db.ListFilestoreSkillArchiveEntries(ctx, principal.WorkspaceID, filesystem.ID) if err != nil { return listDirectoryResponse{}, mapDatabaseError("list skill archive entries", err) } @@ -135,7 +155,7 @@ func (s *Service) listSkillDirectory( !strings.HasPrefix(request.Path, archiveEntry.Path+"/") { continue } - archive, apiErr := s.loadSkillArchive(ctx, archiveEntry) + archive, apiErr := b.loadSkillArchive(ctx, archiveEntry) if apiErr != nil { return listDirectoryResponse{}, apiErr } @@ -192,26 +212,26 @@ func (s *Service) listSkillDirectory( return response, nil } -func (s *Service) readSkillMetadata( +func (b *skillArchivePathBackend) readMetadata( ctx context.Context, principal Principal, filesystem db.FilestoreFilesystem, entryPath string, ) (entryPayload, *apiError) { - archiveEntry, _, node, apiErr := s.resolveSkillNode(ctx, principal, filesystem, entryPath) + archiveEntry, _, node, apiErr := b.resolveSkillNode(ctx, principal, filesystem, entryPath) if apiErr != nil { return entryPayload{}, apiErr } return skillNodePayload(node, filesystem.ExternalID, []db.FilestoreEntry{archiveEntry}), nil } -func (s *Service) readSkillFile( +func (b *skillArchivePathBackend) readFile( ctx context.Context, principal Principal, filesystem db.FilestoreFilesystem, request readFileRequest, ) (readFileResult, *apiError) { - _, _, node, apiErr := s.resolveSkillNode(ctx, principal, filesystem, request.Path) + _, _, node, apiErr := b.resolveSkillNode(ctx, principal, filesystem, request.Path) if apiErr != nil { return readFileResult{}, apiErr } @@ -249,13 +269,13 @@ func (s *Service) readSkillFile( }, nil } -func (s *Service) resolveSkillNode( +func (b *skillArchivePathBackend) resolveSkillNode( ctx context.Context, principal Principal, filesystem db.FilestoreFilesystem, entryPath string, ) (db.FilestoreEntry, *loadedSkillArchive, skillArchiveNode, *apiError) { - archiveEntries, err := s.db.ListFilestoreSkillArchiveEntries(ctx, principal.WorkspaceID, filesystem.ID) + archiveEntries, err := b.db.ListFilestoreSkillArchiveEntries(ctx, principal.WorkspaceID, filesystem.ID) if err != nil { return db.FilestoreEntry{}, nil, skillArchiveNode{}, mapDatabaseError("list skill archive entries", err) } @@ -263,7 +283,7 @@ func (s *Service) resolveSkillNode( if entryPath != archiveEntry.Path && !strings.HasPrefix(entryPath, archiveEntry.Path+"/") { continue } - archive, apiErr := s.loadSkillArchive(ctx, archiveEntry) + archive, apiErr := b.loadSkillArchive(ctx, archiveEntry) if apiErr != nil { return db.FilestoreEntry{}, nil, skillArchiveNode{}, apiErr } @@ -276,11 +296,11 @@ func (s *Service) resolveSkillNode( return db.FilestoreEntry{}, nil, skillArchiveNode{}, notFound("resource does not exist") } -func (s *Service) loadSkillArchive( +func (b *skillArchivePathBackend) loadSkillArchive( ctx context.Context, archiveEntry db.FilestoreEntry, ) (*loadedSkillArchive, *apiError) { - if s.skillArchives == nil { + if b.cache == nil { return nil, internalError("load skill archive", errors.New("skill archive cache is unavailable")) } bucket, objectKey, checksum, sizeBytes, err := skillArchiveObject(archiveEntry) @@ -288,19 +308,19 @@ func (s *Service) loadSkillArchive( return nil, internalError("load skill archive", err) } cacheKey := strings.Join([]string{bucket, objectKey, checksum, archiveEntry.Path}, "\x00") - if archive, ok := s.skillArchives.get(cacheKey); ok { + if archive, ok := b.cache.get(cacheKey); ok { return archive, nil } - if s.store == nil { + if b.store == nil { return nil, internalError("load skill archive", errors.New("object store is unavailable")) } - if bucket != s.store.Name() { + if bucket != b.store.Name() { return nil, internalError("load skill archive", errors.New("skill archive bucket is unavailable")) } if sizeBytes > maxSkillArchiveBytes { return nil, internalError("load skill archive", errors.New("skill archive size is invalid")) } - object, err := s.store.Open(ctx, objectKey, nil) + object, err := b.store.Open(ctx, objectKey, nil) if err != nil { return nil, mapBlobstoreError("load skill archive", err) } @@ -320,7 +340,7 @@ func (s *Service) loadSkillArchive( if err != nil { return nil, internalError("validate skill archive", err) } - s.skillArchives.put(cacheKey, archive) + b.cache.put(cacheKey, archive) return archive, nil } diff --git a/internal/filestore/skill_archives_test.go b/internal/filestore/skill_archives_test.go index f89a16c0e..3409dc993 100644 --- a/internal/filestore/skill_archives_test.go +++ b/internal/filestore/skill_archives_test.go @@ -69,6 +69,16 @@ func TestSkillArchiveNamespaceIsReadOnly(t *testing.T) { name string run func() *apiError }{ + { + name: "make directory", + run: func() *apiError { + _, apiErr := service.MakeDirectory(context.Background(), principal, makeDirectoryRequest{ + FilesystemID: "fs_test", + Path: "/skills/demo/new", + }) + return apiErr + }, + }, { name: "create file", run: func() *apiError { @@ -91,7 +101,49 @@ func TestSkillArchiveNamespaceIsReadOnly(t *testing.T) { }, }, { - name: "move into skills", + name: "remove file", + run: func() *apiError { + return service.RemoveFile(context.Background(), principal, pathRequest{ + FilesystemID: "fs_test", + Path: "/skills/demo/a.txt", + }) + }, + }, + { + name: "copy from skills", + run: func() *apiError { + _, apiErr := service.CopyFile(context.Background(), principal, copyMoveFileRequest{ + FilesystemID: "fs_test", + Source: "/skills/demo/a.txt", + Destination: "/outputs/a.txt", + }) + return apiErr + }, + }, + { + name: "copy into skills", + run: func() *apiError { + _, apiErr := service.CopyFile(context.Background(), principal, copyMoveFileRequest{ + FilesystemID: "fs_test", + Source: "/outputs/a.txt", + Destination: "/skills/demo/a.txt", + }) + return apiErr + }, + }, + { + name: "move file from skills", + run: func() *apiError { + _, apiErr := service.MoveFile(context.Background(), principal, copyMoveFileRequest{ + FilesystemID: "fs_test", + Source: "/skills/demo/a.txt", + Destination: "/outputs/a.txt", + }) + return apiErr + }, + }, + { + name: "move file into skills", run: func() *apiError { _, apiErr := service.MoveFile(context.Background(), principal, copyMoveFileRequest{ FilesystemID: "fs_test", @@ -101,6 +153,28 @@ func TestSkillArchiveNamespaceIsReadOnly(t *testing.T) { return apiErr }, }, + { + name: "move directory from skills", + run: func() *apiError { + _, apiErr := service.MoveDirectory(context.Background(), principal, moveDirectoryRequest{ + FilesystemID: "fs_test", + Source: "/skills/demo", + Destination: "/outputs/demo", + }) + return apiErr + }, + }, + { + name: "move directory into skills", + run: func() *apiError { + _, apiErr := service.MoveDirectory(context.Background(), principal, moveDirectoryRequest{ + FilesystemID: "fs_test", + Source: "/outputs/demo", + Destination: "/skills/demo", + }) + return apiErr + }, + }, } for _, test := range tests { test := test @@ -137,6 +211,17 @@ func TestSkillArchiveViewListsMetadataAndReadsRanges(t *testing.T) { t.Fatalf("top-level ListDirectory(/skills) object opens = %d, want 0", *openCount) } + rootMetadata, apiErr := service.ReadMetadata(ctx, principal, pathRequest{ + FilesystemID: "fs_test", + Path: "/skills", + }) + if apiErr != nil { + t.Fatalf("ReadMetadata(/skills) error = %v", apiErr) + } + if rootMetadata.Directory == nil || rootMetadata.Directory.Path != "/skills" { + t.Fatalf("ReadMetadata(/skills) = %#v", rootMetadata) + } + nested, apiErr := service.ListDirectory(ctx, principal, listDirectoryRequest{ FilesystemID: "fs_test", Path: "/skills/demo", @@ -191,6 +276,14 @@ func skillArchiveTestService( openCount := 0 database := &fakeServiceDatabase{ getFilesystemFn: serviceFilesystemLookup(filesystem), + getEntryFn: func(_ context.Context, workspaceID, filesystemID int64, entryPath string) (db.FilestoreEntry, error) { + if workspaceID != serviceTestPrincipal().WorkspaceID || + filesystemID != filesystem.ID || + entryPath != skillNamespacePath { + return db.FilestoreEntry{}, db.ErrNotFound + } + return serviceTestDirectoryEntry(filesystem, 70, skillNamespacePath), nil + }, listSkillArchiveEntriesFn: func(context.Context, int64, int64) ([]db.FilestoreEntry, error) { return []db.FilestoreEntry{archiveEntry}, nil }, diff --git a/internal/skills/handler.go b/internal/skills/handler.go index eaad748d8..478cb31bf 100644 --- a/internal/skills/handler.go +++ b/internal/skills/handler.go @@ -364,6 +364,9 @@ func (h *Handler) deleteRoute(w http.ResponseWriter, r *http.Request) { h.delete(w, r, chi.URLParam(r, "skill_id")) } +// TODO: 将 custom skill/version 软删除产生的 archive 标记为 catalog GC candidate, +// 并仅在不存在活动 Filestore skill_archive entry 引用时由后台任务删除对象。 +// 当前必须保留 archive,以保证已经启动的 Session 仍能读取钉住的具体版本。 func (h *Handler) delete(w http.ResponseWriter, r *http.Request, skillID string) { principal, _ := auth.PrincipalFromContext(r.Context()) if err := requireWorkspaceCredential(principal); err != nil { diff --git a/internal/skills/seed.go b/internal/skills/seed.go index 24e5d22b7..03a1e79e0 100644 --- a/internal/skills/seed.go +++ b/internal/skills/seed.go @@ -114,6 +114,8 @@ func SeedBuiltinSkills(ctx context.Context, database *db.DB, store storage.Objec } if opts.Prune { + // TODO: 将 prune 的 builtin archive 纳入 reference-aware catalog GC; + // 当前只软删除 catalog row,避免破坏活动 Session 借用的对象。 prunedVersions, err := database.SoftDeleteMissingBuiltinSkills(ctx, result.Skills, now) if err != nil { return BuiltinSeedResult{}, err From 5bee93c616f30a4257c34477ee8dbb0d28b6af5b Mon Sep 17 00:00:00 2001 From: arthur-zhang Date: Mon, 27 Jul 2026 09:51:43 +0800 Subject: [PATCH 7/9] Use bounded LRU caching and singleflight for skill archives --- .../design/be/managed-agent-skills-runtime.md | 7 +- .../environments/runner_dependencies_test.go | 4 +- internal/filestore/service.go | 2 +- internal/filestore/skill_archives.go | 121 +++++++-------- internal/filestore/skill_archives_test.go | 140 ++++++++++++++++++ internal/skills/resolver.go | 4 +- main.go | 2 +- tests/environments_e2b_integration_test.go | 2 +- ...nments_full_e2b_bridge_integration_test.go | 2 +- tests/environments_runner_cloud_test.go | 20 ++- tests/sdk_go_quickstart_demo_test.go | 2 +- tests/sessions_api_test.go | 2 +- tests/skills_runtime_resolver_test.go | 2 +- 13 files changed, 217 insertions(+), 93 deletions(-) diff --git a/docs/design/be/managed-agent-skills-runtime.md b/docs/design/be/managed-agent-skills-runtime.md index bed86e729..2b994fb4f 100644 --- a/docs/design/be/managed-agent-skills-runtime.md +++ b/docs/design/be/managed-agent-skills-runtime.md @@ -94,8 +94,11 @@ Filestore 在第一次访问某个具体 archive 时按需下载并建立内存 - 解压大小按 archive header 累加并限制为 500 MiB; - 读取单个成员时流式解压;range offset 通过丢弃前缀实现,不把解压结果整体缓存。 -进程内以 `bucket + key + sha256 + archive path` 为 key 使用 64 MiB 有界 LRU 缓存压缩 -archive 和目录索引。 +进程内以 `bucket + key + sha256 + archive path` 为 key,使用最多 20 个 archive 的有界 +LRU 缓存压缩 archive 和目录索引。单个压缩 archive 最大 8 MiB,因此缓存中的压缩数据 +理论上最多为 160 MiB,另有目录索引开销。同一个 key 的并发 cache miss 通过 singleflight +合并,只有首个请求下载、校验并建立索引,等待者共享同一结果或错误;失败结果不写缓存, +后续请求可以重新加载。 archive entry 仍是每次请求的授权事实来源;Session entry 删除后,缓存中残留的字节无法再通过 Filestore 路径访问。 diff --git a/internal/environments/runner_dependencies_test.go b/internal/environments/runner_dependencies_test.go index f6e78ecc4..4b0e5f7a1 100644 --- a/internal/environments/runner_dependencies_test.go +++ b/internal/environments/runner_dependencies_test.go @@ -18,7 +18,7 @@ func TestNewRunnerRejectsMissingDependencies(t *testing.T) { DB: database, Provider: e2bruntime.NewProvider(config.E2BConfig{}), CodeSessions: codesessions.NewServiceWithCredentials(database, new(codesessions.SessionCredentials)), - Skills: skillsapi.NewRuntimeResolver(config.Config{}, database, nil), + Skills: skillsapi.NewRuntimeResolver(database), FilestoreTokens: new(filestore.TokenCredentials), } tests := []struct { @@ -75,7 +75,7 @@ func TestNewRunnerAcceptsCompleteDependencies(t *testing.T) { Provider: e2bruntime.NewProvider(config.E2BConfig{}), Config: config.Config{}, CodeSessions: codesessions.NewServiceWithCredentials(database, new(codesessions.SessionCredentials)), - Skills: skillsapi.NewRuntimeResolver(config.Config{}, database, nil), + Skills: skillsapi.NewRuntimeResolver(database), FilestoreTokens: new(filestore.TokenCredentials), }) if err != nil { diff --git a/internal/filestore/service.go b/internal/filestore/service.go index 5708baa49..79795ee5a 100644 --- a/internal/filestore/service.go +++ b/internal/filestore/service.go @@ -66,7 +66,7 @@ func NewService(cfg config.Config, database filestoreDatabase, store storage.Obj skills := &skillArchivePathBackend{ db: database, store: store, - cache: newSkillArchiveCache(defaultSkillArchiveCacheBytes), + cache: newSkillArchiveCache(defaultSkillArchiveCacheEntries), } return &Service{ cfg: cfg, diff --git a/internal/filestore/skill_archives.go b/internal/filestore/skill_archives.go index 0b91ee7f6..ecc88f73f 100644 --- a/internal/filestore/skill_archives.go +++ b/internal/filestore/skill_archives.go @@ -3,7 +3,6 @@ package filestore import ( "archive/zip" "bytes" - "container/list" "context" "crypto/sha256" "encoding/hex" @@ -16,19 +15,20 @@ import ( "path" "sort" "strings" - "sync" "time" "github.com/google/uuid" + lru "github.com/hashicorp/golang-lru/v2" "github.com/superduck-ai/open-managed-agents/internal/db" "github.com/superduck-ai/open-managed-agents/internal/storage" + "golang.org/x/sync/singleflight" ) const ( - skillNamespacePath = "/skills" - maxSkillArchiveBytes int64 = 8 * 1024 * 1024 - maxSkillUncompressedBytes uint64 = 500 * 1024 * 1024 - defaultSkillArchiveCacheBytes = 64 * 1024 * 1024 + skillNamespacePath = "/skills" + maxSkillArchiveBytes int64 = 8 * 1024 * 1024 + maxSkillUncompressedBytes uint64 = 500 * 1024 * 1024 + defaultSkillArchiveCacheEntries = 20 ) type skillArchiveNode struct { @@ -44,70 +44,19 @@ type loadedSkillArchive struct { nodes map[string]skillArchiveNode } -type skillArchiveCacheEntry struct { - key string - archive *loadedSkillArchive -} - -type skillArchiveCache struct { - mu sync.Mutex - maxBytes int - bytes int - entries map[string]*list.Element - order *list.List -} - type skillArchivePathBackend struct { - db filestoreDatabase - store storage.ObjectStore - cache *skillArchiveCache + db filestoreDatabase + store storage.ObjectStore + cache *lru.Cache[string, *loadedSkillArchive] + archiveLoads singleflight.Group } -func newSkillArchiveCache(maxBytes int) *skillArchiveCache { - return &skillArchiveCache{ - maxBytes: maxBytes, - entries: make(map[string]*list.Element), - order: list.New(), - } -} - -func (c *skillArchiveCache) get(key string) (*loadedSkillArchive, bool) { - c.mu.Lock() - defer c.mu.Unlock() - element, ok := c.entries[key] - if !ok { - return nil, false - } - c.order.MoveToFront(element) - return element.Value.(skillArchiveCacheEntry).archive, true -} - -func (c *skillArchiveCache) put(key string, archive *loadedSkillArchive) { - if c == nil || archive == nil || len(archive.data) > c.maxBytes { - return - } - c.mu.Lock() - defer c.mu.Unlock() - if element, ok := c.entries[key]; ok { - c.bytes -= len(element.Value.(skillArchiveCacheEntry).archive.data) - element.Value = skillArchiveCacheEntry{key: key, archive: archive} - c.bytes += len(archive.data) - c.order.MoveToFront(element) - } else { - element := c.order.PushFront(skillArchiveCacheEntry{key: key, archive: archive}) - c.entries[key] = element - c.bytes += len(archive.data) - } - for c.bytes > c.maxBytes { - element := c.order.Back() - if element == nil { - break - } - entry := element.Value.(skillArchiveCacheEntry) - delete(c.entries, entry.key) - c.bytes -= len(entry.archive.data) - c.order.Remove(element) +func newSkillArchiveCache(maxEntries int) *lru.Cache[string, *loadedSkillArchive] { + cache, err := lru.New[string, *loadedSkillArchive](maxEntries) + if err != nil { + panic(fmt.Sprintf("create skill archive cache: %v", err)) } + return cache } func (b *skillArchivePathBackend) namespaceRoot() string { @@ -308,9 +257,46 @@ func (b *skillArchivePathBackend) loadSkillArchive( return nil, internalError("load skill archive", err) } cacheKey := strings.Join([]string{bucket, objectKey, checksum, archiveEntry.Path}, "\x00") - if archive, ok := b.cache.get(cacheKey); ok { + if archive, ok := b.cache.Get(cacheKey); ok { + return archive, nil + } + value, loadErr, _ := b.archiveLoads.Do(cacheKey, func() (any, error) { + // 快路径 miss 后,可能已有同 key 的加载刚完成;进入 singleflight 后必须再次检查缓存。 + if archive, ok := b.cache.Get(cacheKey); ok { + return archive, nil + } + archive, apiErr := b.fetchSkillArchive(ctx, archiveEntry, bucket, objectKey, checksum, sizeBytes) + if apiErr != nil { + return nil, apiErr + } + b.cache.Add(cacheKey, archive) return archive, nil + }) + if loadErr != nil { + apiErr, ok := loadErr.(*apiError) + if !ok { + return nil, internalError("load skill archive", loadErr) + } + return nil, apiErr } + archive, ok := value.(*loadedSkillArchive) + if !ok { + return nil, internalError( + "load skill archive", + fmt.Errorf("unexpected archive load result type %T", value), + ) + } + return archive, nil +} + +func (b *skillArchivePathBackend) fetchSkillArchive( + ctx context.Context, + archiveEntry db.FilestoreEntry, + bucket string, + objectKey string, + checksum string, + sizeBytes int64, +) (*loadedSkillArchive, *apiError) { if b.store == nil { return nil, internalError("load skill archive", errors.New("object store is unavailable")) } @@ -340,7 +326,6 @@ func (b *skillArchivePathBackend) loadSkillArchive( if err != nil { return nil, internalError("validate skill archive", err) } - b.cache.put(cacheKey, archive) return archive, nil } diff --git a/internal/filestore/skill_archives_test.go b/internal/filestore/skill_archives_test.go index 3409dc993..7e4a1b364 100644 --- a/internal/filestore/skill_archives_test.go +++ b/internal/filestore/skill_archives_test.go @@ -6,9 +6,13 @@ import ( "context" "crypto/sha256" "encoding/hex" + "fmt" "io" "net/http" + "sync" + "sync/atomic" "testing" + "time" "github.com/superduck-ai/open-managed-agents/internal/db" "github.com/superduck-ai/open-managed-agents/internal/storage" @@ -56,6 +60,69 @@ func TestSkillArchiveViewRejectsInvalidArchives(t *testing.T) { } } +func TestSkillArchiveLoadRetriesAfterFailure(t *testing.T) { + t.Parallel() + + archiveBytes := buildSkillArchiveTestZip(t, map[string]string{"demo/SKILL.md": "# Demo"}) + archiveEntry := skillArchiveTestEntry(archiveBytes) + var openCount atomic.Int32 + backend := &skillArchivePathBackend{ + store: &fakeServiceBlobStore{ + openFn: func(context.Context, string, *storage.ByteRange) (storage.Object, error) { + if openCount.Add(1) == 1 { + return storage.Object{}, storage.ErrNotFound + } + return storage.Object{ + Body: io.NopCloser(bytes.NewReader(archiveBytes)), + Size: int64(len(archiveBytes)), + }, nil + }, + }, + cache: newSkillArchiveCache(defaultSkillArchiveCacheEntries), + } + + _, apiErr := backend.loadSkillArchive(context.Background(), archiveEntry) + assertServiceAPIError(t, apiErr, http.StatusNotFound, "not_found") + + archive, apiErr := backend.loadSkillArchive(context.Background(), archiveEntry) + if apiErr != nil { + t.Fatalf("loadSkillArchive() retry error = %v", apiErr) + } + if _, ok := archive.nodes["/skills/demo/SKILL.md"]; !ok { + t.Fatalf("loadSkillArchive() retry archive = %#v, want SKILL.md", archive) + } + if got := openCount.Load(); got != 2 { + t.Fatalf("object opens = %d, want 2", got) + } +} + +func TestSkillArchiveCacheKeepsTwentyMostRecentArchives(t *testing.T) { + t.Parallel() + + cache := newSkillArchiveCache(defaultSkillArchiveCacheEntries) + for index := range defaultSkillArchiveCacheEntries { + cache.Add( + fmt.Sprintf("skill-%d", index), + &loadedSkillArchive{data: []byte{byte(index)}}, + ) + } + if _, ok := cache.Get("skill-0"); !ok { + t.Fatal("cache does not contain the oldest inserted archive") + } + + cache.Add("skill-20", &loadedSkillArchive{data: []byte{20}}) + + if _, ok := cache.Peek("skill-1"); ok { + t.Fatal("cache retained the least recently used archive after exceeding 20 entries") + } + if _, ok := cache.Peek("skill-0"); !ok { + t.Fatal("cache evicted an archive refreshed before capacity was exceeded") + } + if got := cache.Len(); got != defaultSkillArchiveCacheEntries { + t.Fatalf("cache length = %d, want %d", got, defaultSkillArchiveCacheEntries) + } +} + func TestSkillArchiveNamespaceIsReadOnly(t *testing.T) { t.Parallel() @@ -185,6 +252,79 @@ func TestSkillArchiveNamespaceIsReadOnly(t *testing.T) { } } +func TestSkillArchiveConcurrentColdLoadUsesSingleObjectRead(t *testing.T) { + t.Parallel() + + archiveBytes := buildSkillArchiveTestZip(t, map[string]string{"demo/SKILL.md": "# Demo"}) + archiveEntry := skillArchiveTestEntry(archiveBytes) + firstOpen := make(chan struct{}) + secondOpen := make(chan struct{}) + releaseOpen := make(chan struct{}) + var openCount atomic.Int32 + backend := &skillArchivePathBackend{ + store: &fakeServiceBlobStore{ + openFn: func(context.Context, string, *storage.ByteRange) (storage.Object, error) { + switch openCount.Add(1) { + case 1: + close(firstOpen) + case 2: + close(secondOpen) + } + <-releaseOpen + return storage.Object{ + Body: io.NopCloser(bytes.NewReader(archiveBytes)), + Size: int64(len(archiveBytes)), + }, nil + }, + }, + cache: newSkillArchiveCache(defaultSkillArchiveCacheEntries), + } + + const callers = 16 + start := make(chan struct{}) + archives := make([]*loadedSkillArchive, callers) + apiErrors := make([]*apiError, callers) + var ready sync.WaitGroup + var complete sync.WaitGroup + ready.Add(callers) + complete.Add(callers) + for index := range callers { + go func() { + defer complete.Done() + ready.Done() + <-start + archives[index], apiErrors[index] = backend.loadSkillArchive(context.Background(), archiveEntry) + }() + } + ready.Wait() + close(start) + <-firstOpen + + duplicateOpen := false + select { + case <-secondOpen: + duplicateOpen = true + case <-time.After(100 * time.Millisecond): + } + close(releaseOpen) + complete.Wait() + + if duplicateOpen { + t.Fatalf("concurrent cold load opened the archive %d times, want 1", openCount.Load()) + } + if got := openCount.Load(); got != 1 { + t.Fatalf("object opens = %d, want 1", got) + } + for index := range callers { + if apiErrors[index] != nil { + t.Fatalf("loadSkillArchive() caller %d error = %v", index, apiErrors[index]) + } + if archives[index] != archives[0] { + t.Fatalf("loadSkillArchive() caller %d received a different archive pointer", index) + } + } +} + func TestSkillArchiveViewListsMetadataAndReadsRanges(t *testing.T) { t.Parallel() diff --git a/internal/skills/resolver.go b/internal/skills/resolver.go index a7d2d4d1b..2f22099a3 100644 --- a/internal/skills/resolver.go +++ b/internal/skills/resolver.go @@ -7,9 +7,7 @@ import ( "fmt" "strings" - "github.com/superduck-ai/open-managed-agents/internal/config" "github.com/superduck-ai/open-managed-agents/internal/db" - "github.com/superduck-ai/open-managed-agents/internal/storage" ) type RuntimeResolver struct { @@ -37,7 +35,7 @@ type runtimeSkillRef struct { Version string `json:"version"` } -func NewRuntimeResolver(_ config.Config, database *db.DB, _ storage.ObjectStore) *RuntimeResolver { +func NewRuntimeResolver(database *db.DB) *RuntimeResolver { return &RuntimeResolver{db: database} } diff --git a/main.go b/main.go index 737c5a84e..2177e9d43 100644 --- a/main.go +++ b/main.go @@ -102,7 +102,7 @@ func main() { Provider: e2bruntime.NewProvider(cfg.E2B), Config: cfg, CodeSessions: codesessions.NewServiceWithCredentials(database, codeSessionCredentials), - Skills: skillsapi.NewRuntimeResolver(cfg, database, objectStore), + Skills: skillsapi.NewRuntimeResolver(database), FilestoreTokens: filestoreCredentials, }) if err != nil { diff --git a/tests/environments_e2b_integration_test.go b/tests/environments_e2b_integration_test.go index 4c58f1c26..e91d7fea6 100644 --- a/tests/environments_e2b_integration_test.go +++ b/tests/environments_e2b_integration_test.go @@ -138,7 +138,7 @@ func TestE2BEnvironmentRunnerIntegration(t *testing.T) { Provider: provider, Config: cfg, CodeSessions: codesessions.NewServiceWithCredentials(database, credentials), - Skills: skillsapi.NewRuntimeResolver(cfg, database, objectStore), + Skills: skillsapi.NewRuntimeResolver(database), FilestoreTokens: filestoreCredentials, }) if err != nil { diff --git a/tests/environments_full_e2b_bridge_integration_test.go b/tests/environments_full_e2b_bridge_integration_test.go index e8ae64aec..edf1a1744 100644 --- a/tests/environments_full_e2b_bridge_integration_test.go +++ b/tests/environments_full_e2b_bridge_integration_test.go @@ -137,7 +137,7 @@ func TestE2BManagedAgentBridgeEnvironmentManagerIntegration(t *testing.T) { } }() - runner := newManagedAgentRunner(t, app, provider, cfg, nil) + runner := newManagedAgentRunner(t, app, provider, cfg) processed, err := runner.RunOnce(ctx, "full-e2b-bridge-test") if err != nil { t.Fatalf("run environment runner once: %v", err) diff --git a/tests/environments_runner_cloud_test.go b/tests/environments_runner_cloud_test.go index 88ea8d450..7c67055b4 100644 --- a/tests/environments_runner_cloud_test.go +++ b/tests/environments_runner_cloud_test.go @@ -18,7 +18,6 @@ import ( "github.com/superduck-ai/open-managed-agents/internal/environments" "github.com/superduck-ai/open-managed-agents/internal/runtime/e2bruntime" skillsapi "github.com/superduck-ai/open-managed-agents/internal/skills" - "github.com/superduck-ai/open-managed-agents/internal/storage" "github.com/anthropics/anthropic-sdk-go" "github.com/anthropics/anthropic-sdk-go/option" @@ -167,7 +166,7 @@ func TestEnvironmentRunnerLaunchesManagedAgentCloudSession(t *testing.T) { } }, } - runner := newManagedAgentRunner(t, app, provider, cfg, nil) + runner := newManagedAgentRunner(t, app, provider, cfg) processed, err := runner.RunOnce(ctx, "runner-cloud-test") if err != nil { t.Fatalf("run once: %v", err) @@ -358,7 +357,7 @@ func TestEnvironmentRunnerKillsSandboxWhenRcloneReadyFails(t *testing.T) { failOperation: "rclone-ready", runCommandFailure: errors.New("simulated rclone ready failure: " + providerSecretMarker), } - runner := newManagedAgentRunner(t, app, provider, cfg, nil) + runner := newManagedAgentRunner(t, app, provider, cfg) processed, err := runner.RunOnce(ctx, "runner-rclone-failure-test") if err == nil || err.Error() != "rclone-filestore readiness check failed" { t.Fatalf("RunOnce error = %v, want rclone ready failure", err) @@ -443,7 +442,7 @@ func TestEnvironmentRunnerRevokesCodeSessionWhenManagerStartFails(t *testing.T) failOperation: "environment-manager", runCommandFailure: errors.New("simulated environment-manager launch failure"), } - runner := newManagedAgentRunner(t, app, provider, cfg, nil) + runner := newManagedAgentRunner(t, app, provider, cfg) processed, err := runner.RunOnce(ctx, "runner-manager-failure-test") if err == nil || err.Error() != "environment manager process start failed" { t.Fatalf("RunOnce error = %v, want manager launch failure", err) @@ -527,7 +526,7 @@ func TestEnvironmentRunnerInstallsManagedAgentCustomSkill(t *testing.T) { defer client.Beta.Sessions.Delete(context.Background(), session.ID, anthropic.BetaSessionDeleteParams{}) provider := &recordingRunnerProvider{sandboxID: "sandbox-runner-skills"} - runner := newManagedAgentRunner(t, app, provider, cfg, store) + runner := newManagedAgentRunner(t, app, provider, cfg) processed, err := runner.RunOnce(ctx, "runner-cloud-skills-test") if err != nil { t.Fatalf("run once: %v", err) @@ -629,7 +628,7 @@ func TestEnvironmentRunnerProjectsSkillsWithoutDownloadingArchives(t *testing.T) defer client.Beta.Sessions.Delete(context.Background(), session.ID, anthropic.BetaSessionDeleteParams{}) provider := &recordingRunnerProvider{sandboxID: "sandbox-skill-projection-only"} - runner := newManagedAgentRunner(t, app, provider, cfg, nil) + runner := newManagedAgentRunner(t, app, provider, cfg) processed, err := runner.RunOnce(ctx, "runner-cloud-no-resolver-test") if err != nil { t.Fatalf("RunOnce error = %v", err) @@ -705,7 +704,7 @@ func TestEnvironmentRunnerResolvesLimitedNetworkWithManagedAgentMCPHosts(t *test defer client.Beta.Sessions.Delete(context.Background(), session.ID, anthropic.BetaSessionDeleteParams{}) provider := &recordingRunnerProvider{sandboxID: "sandbox-network-order"} - runner := newManagedAgentRunner(t, app, provider, cfg, nil) + runner := newManagedAgentRunner(t, app, provider, cfg) processed, err := runner.RunOnce(ctx, "runner-cloud-network-order-test") if err != nil { t.Fatalf("run once: %v", err) @@ -796,7 +795,7 @@ func TestEnvironmentRunnerClearsStaleMCPHosts(t *testing.T) { } provider := &recordingRunnerProvider{sandboxID: "sandbox-empty-mcp"} - runner := newManagedAgentRunner(t, app, provider, cfg, nil) + runner := newManagedAgentRunner(t, app, provider, cfg) processed, err := runner.RunOnce(ctx, "runner-cloud-empty-mcp-test") if err != nil || !processed { t.Fatalf("RunOnce() = processed %v, error %v", processed, err) @@ -877,7 +876,7 @@ func TestEnvironmentRunnerDoesNotCreateCodeSessionWhenResolveFails(t *testing.T) sandboxID: "sandbox-should-not-start", resolveErr: fmt.Errorf("network config invalid"), } - runner := newManagedAgentRunner(t, app, provider, cfg, nil) + runner := newManagedAgentRunner(t, app, provider, cfg) processed, err := runner.RunOnce(ctx, "runner-cloud-resolve-failure-test") if err == nil || !strings.Contains(err.Error(), "network config invalid") { t.Fatalf("RunOnce error = %v, want resolve error", err) @@ -937,7 +936,6 @@ func newManagedAgentRunner( app *testApp, provider e2bruntime.Provider, cfg config.Config, - skillStore storage.ObjectStore, ) *environments.Runner { t.Helper() runner, err := environments.NewRunner(environments.RunnerDependencies{ @@ -945,7 +943,7 @@ func newManagedAgentRunner( Provider: provider, Config: cfg, CodeSessions: codesessions.NewServiceWithCredentials(app.db, app.credentials), - Skills: skillsapi.NewRuntimeResolver(cfg, app.db, skillStore), + Skills: skillsapi.NewRuntimeResolver(app.db), FilestoreTokens: app.filestoreCredentials, }) if err != nil { diff --git a/tests/sdk_go_quickstart_demo_test.go b/tests/sdk_go_quickstart_demo_test.go index 454429a43..ca534f042 100644 --- a/tests/sdk_go_quickstart_demo_test.go +++ b/tests/sdk_go_quickstart_demo_test.go @@ -249,7 +249,7 @@ func quickstartRunRealSandbox(t *testing.T, ctx context.Context, app *testApp, e } }() - runner := newManagedAgentRunner(t, app, provider, app.cfg, nil) + runner := newManagedAgentRunner(t, app, provider, app.cfg) processed, err := runner.RunOnce(ctx, "quickstart-real-e2b") if err != nil { t.Fatalf("run environment runner once: %v", err) diff --git a/tests/sessions_api_test.go b/tests/sessions_api_test.go index 662f40e2b..e561cddff 100644 --- a/tests/sessions_api_test.go +++ b/tests/sessions_api_test.go @@ -3497,7 +3497,7 @@ func launchLocalCodeSession(t *testing.T, app *testApp, sessionID string) string cfg.CodeSession.SandboxAPIBaseURL = "http://sandbox-api.example.test" } provider := &recordingRunnerProvider{sandboxID: "sandbox-" + strings.TrimPrefix(sessionID, "sesn_")} - runner := newManagedAgentRunner(t, app, provider, cfg, nil) + runner := newManagedAgentRunner(t, app, provider, cfg) deadline := time.Now().Add(10 * time.Second) for { processed, err := runner.RunOnce(ctx, "sessions-code-session-test") diff --git a/tests/skills_runtime_resolver_test.go b/tests/skills_runtime_resolver_test.go index 74762083d..571489966 100644 --- a/tests/skills_runtime_resolver_test.go +++ b/tests/skills_runtime_resolver_test.go @@ -37,7 +37,7 @@ func TestRuntimeResolverResolvesCustomLatestVersion(t *testing.T) { var secondVersion skillVersionAPIResponse decodeJSON(t, resp.Body, &secondVersion) - resolver := skillsapi.NewRuntimeResolver(app.cfg, app.db, store) + resolver := skillsapi.NewRuntimeResolver(app.db) ids := getDefaultDBIDs(t, app.db) snapshot, err := json.Marshal(map[string]any{ "skills": []map[string]string{{ From e4395b0f59d96f061989dc220dac12569d8439f2 Mon Sep 17 00:00:00 2001 From: arthur-zhang Date: Mon, 27 Jul 2026 10:22:47 +0800 Subject: [PATCH 8/9] Preserve skill archive history and isolate shared loads --- .../design/be/managed-agent-skills-runtime.md | 11 +- internal/db/filestore_archive_entries.go | 9 +- internal/db/filestore_archive_entries_test.go | 2 +- internal/environments/runner.go | 6 +- internal/filestore/skill_archives.go | 53 +++- internal/filestore/skill_archives_test.go | 229 ++++++++++++++++++ tests/filestore_db_test.go | 12 + 7 files changed, 311 insertions(+), 11 deletions(-) diff --git a/docs/design/be/managed-agent-skills-runtime.md b/docs/design/be/managed-agent-skills-runtime.md index 2b994fb4f..858067d82 100644 --- a/docs/design/be/managed-agent-skills-runtime.md +++ b/docs/design/be/managed-agent-skills-runtime.md @@ -21,6 +21,7 @@ Environment Runner 在创建 cloud managed-agent Sandbox 前完成: 已启动 Session 的视图。 4. 在一只 `sqlx.Tx` 中锁定 Session filesystem 和 namespace,确保 `/skills` 固定根存在, 并原子替换该 filesystem 中 `kind=archive`、`managed_by=skill_archive` 的 entry 集合。 + 替换时旧的活动投影统一写入 `deleted_at`,不做硬删除;新的投影作为新 entry 插入。 5. 创建 Sandbox 后,Runner 直接启动 rclone-filestore 的五个固定 mount;multimount 在内部对 destination 执行 `MkdirAll`,Runner 不执行独立 mount preparation。 6. `/skills` 使用只读 Filestore Token,直接挂载到 `/root/.claude/skills`。rclone ready @@ -97,8 +98,10 @@ Filestore 在第一次访问某个具体 archive 时按需下载并建立内存 进程内以 `bucket + key + sha256 + archive path` 为 key,使用最多 20 个 archive 的有界 LRU 缓存压缩 archive 和目录索引。单个压缩 archive 最大 8 MiB,因此缓存中的压缩数据 理论上最多为 160 MiB,另有目录索引开销。同一个 key 的并发 cache miss 通过 singleflight -合并,只有首个请求下载、校验并建立索引,等待者共享同一结果或错误;失败结果不写缓存, -后续请求可以重新加载。 +合并,只有一个共享任务下载、校验并建立索引。共享任务使用独立的 30 秒服务端超时,不继承 +首个请求的取消信号,避免 leader 断开导致其他等待者一起失败;每个调用者通过自己的 context +独立等待,取消只会结束该调用者,不会终止或 Forget 仍可服务其他请求的共享任务。所有调用者 +都取消后,共享任务仍允许在超时内完成并填充缓存。失败结果不写缓存,后续请求可以重新加载。 archive entry 仍是每次请求的授权事实来源;Session entry 删除后,缓存中残留的字节无法再通过 Filestore 路径访问。 @@ -108,6 +111,10 @@ Session filesystem 删除后沿用现有有界 cleanup job。最后一批普通 软删除该 filesystem 的 directory 和 archive entries。archive entry 只是借用 catalog archive,不会产生 Filestore 对象清理任务或容量扣减。 +Runner 每次全量替换 `/skills` 投影时,会在同一事务中软删除旧的活动 archive entries +并插入新集合。这样被移除或换版的 skill 投影仍可用于审计,活动读取和唯一索引只考虑 +`deleted_at is null` 的记录。历史投影不拥有 catalog archive,也不会触发对象回收。 + 删除 custom skill/version 或用 `seed-builtin-skills --prune` 软删除 built-in catalog row 时, 不立即删除 archive 对象,也不创建通用 `object_cleanup` job。原因是已经启动的 Session 可能仍通过具体 version UUID 投影借用该对象。物理 GC 必须先确认没有任何活动投影引用, diff --git a/internal/db/filestore_archive_entries.go b/internal/db/filestore_archive_entries.go index 3f2cdbf4b..c8988aeb4 100644 --- a/internal/db/filestore_archive_entries.go +++ b/internal/db/filestore_archive_entries.go @@ -35,12 +35,14 @@ var ( limit 1 for update ` - filestoreSkillArchiveEntryDeleteQuery = ` - delete from filestore_entries + filestoreSkillArchiveEntryRetireQuery = ` + update filestore_entries + set deleted_at = :now, updated_at = :now where workspace_uuid = :workspace_uuid and filesystem_uuid = :filesystem_uuid and kind = 'archive' and managed_by = 'skill_archive' + and deleted_at is null ` filestoreSkillArchiveEntryInsertQuery = ` insert into filestore_entries ( @@ -152,9 +154,10 @@ func (d *DB) ReplaceFilestoreSkillArchiveEntries( if err := ensureFilestoreFixedRootsTx(ctx, tx, workspaceID, filesystem, now); err != nil { return err } - if _, err := namedExecContext(ctx, tx, filestoreSkillArchiveEntryDeleteQuery, map[string]any{ + if _, err := namedExecContext(ctx, tx, filestoreSkillArchiveEntryRetireQuery, map[string]any{ "workspace_uuid": filesystem.WorkspaceUUID, "filesystem_uuid": filesystem.UUID, + "now": now, }); err != nil { return err } diff --git a/internal/db/filestore_archive_entries_test.go b/internal/db/filestore_archive_entries_test.go index d69300869..ab2a7a88d 100644 --- a/internal/db/filestore_archive_entries_test.go +++ b/internal/db/filestore_archive_entries_test.go @@ -33,7 +33,7 @@ func TestFilestoreSkillArchiveEntryQueriesUseSQLXNamedParameters(t *testing.T) { wantArgCount int }{ {"filesystem", filestoreSkillArchiveEntryFilesystemQuery, 3}, - {"delete", filestoreSkillArchiveEntryDeleteQuery, 2}, + {"retire", filestoreSkillArchiveEntryRetireQuery, 4}, {"insert", filestoreSkillArchiveEntryInsertQuery, 15}, {"list", filestoreSkillArchiveEntryListQuery, 3}, } diff --git a/internal/environments/runner.go b/internal/environments/runner.go index 2b4130c47..8dd0bf922 100644 --- a/internal/environments/runner.go +++ b/internal/environments/runner.go @@ -645,9 +645,9 @@ func (r *Runner) resolveRuntimeSkills(ctx context.Context, session db.Session) ( // /skills/,来源保存在通用 metadata 中。 // // DB 操作会校验来源、目录、版本 UUID、对象大小和 SHA-256。它在同一个事务中锁定 Session -// filesystem 记录及其命名空间,确保固定根目录存在,然后删除旧 entries 并插入新集合。采用 -// 全量替换,是为了让已从 Agent snapshot 移除的 skill 同步消失,并避免读取方或并发的 -// 命名空间写入方看到只更新了一部分的视图。 +// filesystem 记录及其命名空间,确保固定根目录存在,然后软删除旧 entries 并插入新集合。 +// 采用全量替换,是为了让已从 Agent snapshot 移除的 skill 同步消失,并避免读取方或并发的 +// 命名空间写入方看到只更新了一部分的视图;软删除则保留历史投影供审计。 // // 成功时返回 nil,确保固定根目录存在并替换 archive entries;catalog 对象仍归 skill // catalog 所有。runtimeSkills 为空时会清空 archive 子目录,但保留 /skills 根目录。元数据无效、 diff --git a/internal/filestore/skill_archives.go b/internal/filestore/skill_archives.go index ecc88f73f..0d6a3ca86 100644 --- a/internal/filestore/skill_archives.go +++ b/internal/filestore/skill_archives.go @@ -11,6 +11,7 @@ import ( "io" "math" "mime" + "net/http" "os" "path" "sort" @@ -29,6 +30,7 @@ const ( maxSkillArchiveBytes int64 = 8 * 1024 * 1024 maxSkillUncompressedBytes uint64 = 500 * 1024 * 1024 defaultSkillArchiveCacheEntries = 20 + defaultSkillArchiveLoadTimeout = 30 * time.Second ) type skillArchiveNode struct { @@ -49,6 +51,7 @@ type skillArchivePathBackend struct { store storage.ObjectStore cache *lru.Cache[string, *loadedSkillArchive] archiveLoads singleflight.Group + loadTimeout time.Duration } func newSkillArchiveCache(maxEntries int) *lru.Cache[string, *loadedSkillArchive] { @@ -260,18 +263,55 @@ func (b *skillArchivePathBackend) loadSkillArchive( if archive, ok := b.cache.Get(cacheKey); ok { return archive, nil } - value, loadErr, _ := b.archiveLoads.Do(cacheKey, func() (any, error) { + // 已经取消的请求不应凭空创建一个没有等待者的后台加载;只有在注册 singleflight + // 之后发生的取消,才允许共享任务继续服务其他调用者或预热缓存。 + if err := ctx.Err(); err != nil { + return nil, skillArchiveRequestCanceled(err) + } + resultChannel := b.archiveLoads.DoChan(cacheKey, func() (any, error) { // 快路径 miss 后,可能已有同 key 的加载刚完成;进入 singleflight 后必须再次检查缓存。 if archive, ok := b.cache.Get(cacheKey); ok { return archive, nil } - archive, apiErr := b.fetchSkillArchive(ctx, archiveEntry, bucket, objectKey, checksum, sizeBytes) + + // singleflight 的共享加载不能绑定首个调用者的取消信号。否则 leader 的客户端断开时, + // S3 下载会被取消,仍在等待的其他请求也会收到同一个失败结果。WithoutCancel 保留 + // tracing 等 context values,但切断首个请求的取消和 deadline;随后再施加服务端自己的 + // 有界超时,避免所有调用者都离开后共享加载无限占用连接或 goroutine。 + loadCtx, cancel := context.WithTimeout( + context.WithoutCancel(ctx), + b.effectiveLoadTimeout(), + ) + defer cancel() + + archive, apiErr := b.fetchSkillArchive(loadCtx, archiveEntry, bucket, objectKey, checksum, sizeBytes) if apiErr != nil { return nil, apiErr } b.cache.Add(cacheKey, archive) return archive, nil }) + + // DoChan 让当前调用者可以独立响应自己的取消,而不终止共享加载。这里不能在取消时调用 + // Forget:共享任务仍可能服务其他 waiter 或填充缓存;Forget 会允许后续请求启动第二次 + // 同 key 下载,重新引入冷启动并发拉取。即使所有 waiter 都取消,共享任务也只会继续到 + // 下载完成或上面的服务端超时。 + select { + case <-ctx.Done(): + return nil, skillArchiveRequestCanceled(ctx.Err()) + case result := <-resultChannel: + return decodeSkillArchiveLoadResult(result.Val, result.Err) + } +} + +func (b *skillArchivePathBackend) effectiveLoadTimeout() time.Duration { + if b.loadTimeout > 0 { + return b.loadTimeout + } + return defaultSkillArchiveLoadTimeout +} + +func decodeSkillArchiveLoadResult(value any, loadErr error) (*loadedSkillArchive, *apiError) { if loadErr != nil { apiErr, ok := loadErr.(*apiError) if !ok { @@ -289,6 +329,15 @@ func (b *skillArchivePathBackend) loadSkillArchive( return archive, nil } +func skillArchiveRequestCanceled(cause error) *apiError { + return &apiError{ + Status: http.StatusRequestTimeout, + Code: "request_canceled", + Message: "Skill archive request was canceled", + Cause: cause, + } +} + func (b *skillArchivePathBackend) fetchSkillArchive( ctx context.Context, archiveEntry db.FilestoreEntry, diff --git a/internal/filestore/skill_archives_test.go b/internal/filestore/skill_archives_test.go index 7e4a1b364..401d038b3 100644 --- a/internal/filestore/skill_archives_test.go +++ b/internal/filestore/skill_archives_test.go @@ -6,6 +6,7 @@ import ( "context" "crypto/sha256" "encoding/hex" + "errors" "fmt" "io" "net/http" @@ -18,6 +19,17 @@ import ( "github.com/superduck-ai/open-managed-agents/internal/storage" ) +type observedDoneContext struct { + context.Context + observed chan struct{} + once sync.Once +} + +func (c *observedDoneContext) Done() <-chan struct{} { + c.once.Do(func() { close(c.observed) }) + return c.Context.Done() +} + func TestSkillArchiveViewRejectsInvalidArchives(t *testing.T) { t.Parallel() @@ -96,6 +108,223 @@ func TestSkillArchiveLoadRetriesAfterFailure(t *testing.T) { } } +func TestSkillArchiveSharedLoadTimeoutDoesNotCacheFailure(t *testing.T) { + t.Parallel() + + archiveBytes := buildSkillArchiveTestZip(t, map[string]string{"demo/SKILL.md": "# Demo"}) + archiveEntry := skillArchiveTestEntry(archiveBytes) + var openCount atomic.Int32 + backend := &skillArchivePathBackend{ + store: &fakeServiceBlobStore{ + openFn: func(ctx context.Context, _ string, _ *storage.ByteRange) (storage.Object, error) { + if openCount.Add(1) == 1 { + <-ctx.Done() + return storage.Object{}, ctx.Err() + } + return storage.Object{ + Body: io.NopCloser(bytes.NewReader(archiveBytes)), + Size: int64(len(archiveBytes)), + }, nil + }, + }, + cache: newSkillArchiveCache(defaultSkillArchiveCacheEntries), + loadTimeout: 20 * time.Millisecond, + } + + _, apiErr := backend.loadSkillArchive(context.Background(), archiveEntry) + assertServiceAPIError(t, apiErr, http.StatusServiceUnavailable, "unavailable") + if !errors.Is(apiErr, context.DeadlineExceeded) { + t.Fatalf("loadSkillArchive() timeout error = %v, want context deadline exceeded", apiErr) + } + + archive, apiErr := backend.loadSkillArchive(context.Background(), archiveEntry) + if apiErr != nil { + t.Fatalf("loadSkillArchive() retry error = %v", apiErr) + } + if _, ok := archive.nodes["/skills/demo/SKILL.md"]; !ok { + t.Fatalf("loadSkillArchive() retry archive = %#v, want SKILL.md", archive) + } + if got := openCount.Load(); got != 2 { + t.Fatalf("object opens = %d, want 2", got) + } +} + +func TestSkillArchiveAlreadyCanceledRequestDoesNotStartLoad(t *testing.T) { + t.Parallel() + + archiveBytes := buildSkillArchiveTestZip(t, map[string]string{"demo/SKILL.md": "# Demo"}) + archiveEntry := skillArchiveTestEntry(archiveBytes) + var openCount atomic.Int32 + backend := &skillArchivePathBackend{ + store: &fakeServiceBlobStore{ + openFn: func(context.Context, string, *storage.ByteRange) (storage.Object, error) { + openCount.Add(1) + return storage.Object{}, errors.New("unexpected object read") + }, + }, + cache: newSkillArchiveCache(defaultSkillArchiveCacheEntries), + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, apiErr := backend.loadSkillArchive(ctx, archiveEntry) + assertServiceAPIError(t, apiErr, http.StatusRequestTimeout, "request_canceled") + if !errors.Is(apiErr, context.Canceled) { + t.Fatalf("loadSkillArchive() error = %v, want context canceled", apiErr) + } + if got := openCount.Load(); got != 0 { + t.Fatalf("object opens = %d, want 0", got) + } +} + +func TestSkillArchiveLeaderCancellationDoesNotAbortSharedLoad(t *testing.T) { + t.Parallel() + + archiveBytes := buildSkillArchiveTestZip(t, map[string]string{"demo/SKILL.md": "# Demo"}) + archiveEntry := skillArchiveTestEntry(archiveBytes) + openStarted := make(chan struct{}) + releaseOpen := make(chan struct{}) + var openCount atomic.Int32 + backend := &skillArchivePathBackend{ + store: &fakeServiceBlobStore{ + openFn: func(ctx context.Context, _ string, _ *storage.ByteRange) (storage.Object, error) { + if openCount.Add(1) == 1 { + close(openStarted) + } + select { + case <-ctx.Done(): + return storage.Object{}, ctx.Err() + case <-releaseOpen: + return storage.Object{ + Body: io.NopCloser(bytes.NewReader(archiveBytes)), + Size: int64(len(archiveBytes)), + }, nil + } + }, + }, + cache: newSkillArchiveCache(defaultSkillArchiveCacheEntries), + } + + type loadResult struct { + archive *loadedSkillArchive + apiErr *apiError + } + leaderCtx, cancelLeader := context.WithCancel(context.Background()) + leaderResult := make(chan loadResult, 1) + go func() { + archive, apiErr := backend.loadSkillArchive(leaderCtx, archiveEntry) + leaderResult <- loadResult{archive: archive, apiErr: apiErr} + }() + <-openStarted + cancelLeader() + + select { + case result := <-leaderResult: + assertServiceAPIError(t, result.apiErr, http.StatusRequestTimeout, "request_canceled") + if !errors.Is(result.apiErr, context.Canceled) { + t.Fatalf("leader error = %v, want context canceled", result.apiErr) + } + case <-time.After(time.Second): + t.Fatal("leader did not stop waiting after its context was canceled") + } + + waiterResult := make(chan loadResult, 1) + go func() { + archive, apiErr := backend.loadSkillArchive(context.Background(), archiveEntry) + waiterResult <- loadResult{archive: archive, apiErr: apiErr} + }() + close(releaseOpen) + + select { + case result := <-waiterResult: + if result.apiErr != nil { + t.Fatalf("waiter load error = %v", result.apiErr) + } + if _, ok := result.archive.nodes["/skills/demo/SKILL.md"]; !ok { + t.Fatalf("waiter archive = %#v, want SKILL.md", result.archive) + } + case <-time.After(time.Second): + t.Fatal("waiter did not receive the shared load result") + } + if got := openCount.Load(); got != 1 { + t.Fatalf("object opens = %d, want 1", got) + } +} + +func TestSkillArchiveCanceledWaiterReturnsBeforeSharedLoadCompletes(t *testing.T) { + t.Parallel() + + archiveBytes := buildSkillArchiveTestZip(t, map[string]string{"demo/SKILL.md": "# Demo"}) + archiveEntry := skillArchiveTestEntry(archiveBytes) + openStarted := make(chan struct{}) + releaseOpen := make(chan struct{}) + var openCount atomic.Int32 + backend := &skillArchivePathBackend{ + store: &fakeServiceBlobStore{ + openFn: func(ctx context.Context, _ string, _ *storage.ByteRange) (storage.Object, error) { + if openCount.Add(1) == 1 { + close(openStarted) + } + select { + case <-ctx.Done(): + return storage.Object{}, ctx.Err() + case <-releaseOpen: + return storage.Object{ + Body: io.NopCloser(bytes.NewReader(archiveBytes)), + Size: int64(len(archiveBytes)), + }, nil + } + }, + }, + cache: newSkillArchiveCache(defaultSkillArchiveCacheEntries), + } + + leaderResult := make(chan *apiError, 1) + go func() { + _, apiErr := backend.loadSkillArchive(context.Background(), archiveEntry) + leaderResult <- apiErr + }() + <-openStarted + + waiterBaseCtx, cancelWaiter := context.WithCancel(context.Background()) + waiterCtx := &observedDoneContext{ + Context: waiterBaseCtx, + observed: make(chan struct{}), + } + waiterResult := make(chan *apiError, 1) + go func() { + _, apiErr := backend.loadSkillArchive(waiterCtx, archiveEntry) + waiterResult <- apiErr + }() + // Done() 只会在 DoChan 已经注册当前 waiter 后被 select 读取,因此这里取消可以 + // 稳定验证“已加入共享 flight 的 waiter”会独立退出,而不是测试调用前取消。 + <-waiterCtx.observed + cancelWaiter() + + select { + case apiErr := <-waiterResult: + assertServiceAPIError(t, apiErr, http.StatusRequestTimeout, "request_canceled") + if !errors.Is(apiErr, context.Canceled) { + t.Fatalf("waiter error = %v, want context canceled", apiErr) + } + case <-time.After(time.Second): + t.Fatal("canceled waiter remained blocked on the shared load") + } + if got := openCount.Load(); got != 1 { + t.Fatalf("object opens before release = %d, want 1", got) + } + + close(releaseOpen) + select { + case apiErr := <-leaderResult: + if apiErr != nil { + t.Fatalf("leader load error = %v", apiErr) + } + case <-time.After(time.Second): + t.Fatal("leader did not complete after the object read was released") + } +} + func TestSkillArchiveCacheKeepsTwentyMostRecentArchives(t *testing.T) { t.Parallel() diff --git a/tests/filestore_db_test.go b/tests/filestore_db_test.go index ba236bf8b..76c7df6ed 100644 --- a/tests/filestore_db_test.go +++ b/tests/filestore_db_test.go @@ -928,6 +928,7 @@ func TestFilestoreSkillArchivesUseUnifiedEntries(t *testing.T) { t.Fatalf("archive entry count = %d, want 1", len(entries)) } entry := entries[0] + replacedEntryID := entry.ID var metadata struct { SkillSource string `json:"skill_source"` } @@ -990,6 +991,17 @@ func TestFilestoreSkillArchivesUseUnifiedEntries(t *testing.T) { if len(entries) != 0 { t.Fatalf("archive entries after clear = %#v", entries) } + var retiredAt *time.Time + if err := app.db.Pool.QueryRow(context.Background(), ` + select deleted_at + from filestore_entries + where id = $1 + `, replacedEntryID).Scan(&retiredAt); err != nil { + t.Fatalf("load retired archive entry: %v", err) + } + if retiredAt == nil { + t.Fatal("retired archive entry deleted_at = nil") + } skillsRoot, err := app.db.GetFilestoreEntry( context.Background(), workspaceID, From 944bfdd601eca5dd5f1558833eabd69e3c684dbd Mon Sep 17 00:00:00 2001 From: arthur-zhang Date: Mon, 27 Jul 2026 10:34:36 +0800 Subject: [PATCH 9/9] Consolidate filestore archive migrations --- docs/design/be/filestore.md | 2 +- .../design/be/managed-agent-skills-runtime.md | 12 +- ...> 00032_add_filestore_archive_entries.sql} | 131 +++++++++++------- .../00032_add_filestore_skill_archives.sql | 125 ----------------- ...033_validate_filestore_archive_entries.sql | 95 +++++++++++++ ...idate_filestore_skill_archive_checksum.sql | 21 --- ...035_validate_filestore_archive_entries.sql | 14 -- 7 files changed, 181 insertions(+), 219 deletions(-) rename internal/db/migrations/{00034_unify_filestore_skill_archives.sql => 00032_add_filestore_archive_entries.sql} (55%) delete mode 100644 internal/db/migrations/00032_add_filestore_skill_archives.sql create mode 100644 internal/db/migrations/00033_validate_filestore_archive_entries.sql delete mode 100644 internal/db/migrations/00033_validate_filestore_skill_archive_checksum.sql delete mode 100644 internal/db/migrations/00035_validate_filestore_archive_entries.sql diff --git a/docs/design/be/filestore.md b/docs/design/be/filestore.md index 19d9a350d..c7fa3ca94 100644 --- a/docs/design/be/filestore.md +++ b/docs/design/be/filestore.md @@ -202,7 +202,7 @@ Provider Sandbox 创建前的失败会停止 Environment Work,且不会创建 - `filestore_filesystems`:保存自身的内部 bigint ID、稳定 UUID、workspace 内的外部 ID;组织、工作区、public session、可选 code session 与创建 API key 均以稳定 UUID 绑定,避免租户搬迁或跨库合并时依赖源库 identity 值。 - `filestore_entries`:统一保存 file/directory/archive、规范化绝对路径、parent path、响应元数据、hash、TTL 和不可变 S3 object reference;Session File resource 的借用 entry 另保存源 File UUID,skill archive entry 保存具体 version UUID,两者都使用不可由 HTTP metadata 设置的 ownership 列。组织、工作区、filesystem 及可选创建者引用均保存对应 UUID,不冗余保存其他表的 identity 或 filesystem external ID。 -迁移 `00032_add_filestore_skill_archives.sql` 曾新增独立 archive 投影表,并为历史活动 filesystem 补齐 `/skills` 根。`00034_unify_filestore_skill_archives.sql` 删除该表,把每个 `/skills/` 改为 `filestore_entries.kind=archive` 的受管 entry,并增加对象形状与具体 version UUID 唯一约束;旧投影 rows 按产品决策直接丢弃,不做数据迁移。`00035_validate_filestore_archive_entries.sql` 单独验证新增约束。archive entry 不拥有 catalog 对象、不写成员 entry,也不计入 Filestore 容量。 +迁移 `00032_add_filestore_archive_entries.sql` 直接把 `archive` 加入 `filestore_entries.kind`,增加对象形状与具体 version UUID 唯一约束,并为历史活动 filesystem 补齐 `/skills` 根;没有独立的 skill archive 投影表。`00033_validate_filestore_archive_entries.sql` 单独验证新增约束,避免在替换约束的短事务内扫描历史 rows。archive entry 不拥有 catalog 对象、不写成员 entry,也不计入 Filestore 容量。 迁移 `00019_add_workspace_storage_usage.sql` 新增 `workspace_storage_usage`。它按工作区分别保存 Files API 与 Filestore 的有效字节数,是配额判定的事务型投影,不是最终文件事实来源;迁移会从两类文件记录建立一次基线,后续由资源写事务按增量维护。 diff --git a/docs/design/be/managed-agent-skills-runtime.md b/docs/design/be/managed-agent-skills-runtime.md index 858067d82..e1fab0b04 100644 --- a/docs/design/be/managed-agent-skills-runtime.md +++ b/docs/design/be/managed-agent-skills-runtime.md @@ -132,12 +132,12 @@ Runner 每次全量替换 `/skills` 投影时,会在同一事务中软删除 - `/mnt/skills`、`/workspace/skills` 解压目录,以及 Claude skill discovery 软链; - Environment Manager 的 managed-agent skill 解压职责。 -迁移 `00032_add_filestore_skill_archives.sql` 曾创建独立投影表、为历史活动 filesystem -补齐 `/skills` 根并清除遗留的 `skill_prewarm` jobs。迁移 -`00034_unify_filestore_skill_archives.sql` 把 `archive` 加入 entry kind,增加 archive -对象与 ownership 形状约束,并直接删除旧表;按产品决策,旧表历史 rows 不迁移。 -`00035_validate_filestore_archive_entries.sql` 单独验证新约束。两张 catalog version 表仍是 -archive 所有权来源,schema 不创建 PostgreSQL 外键。 +迁移 `00032_add_filestore_archive_entries.sql` 直接把 `archive` 加入 entry kind,增加 +archive 对象与 ownership 形状约束,为历史活动 filesystem 补齐 `/skills` 根,并清除 +遗留的 `skill_prewarm` jobs;整个模型不创建独立的 skill archive 投影表。迁移 +`00033_validate_filestore_archive_entries.sql` 单独验证新约束,避免在替换约束的短事务内 +扫描历史 rows。两张 catalog version 表仍是 archive 所有权来源,schema 不创建 +PostgreSQL 外键。 ## 验收重点 diff --git a/internal/db/migrations/00034_unify_filestore_skill_archives.sql b/internal/db/migrations/00032_add_filestore_archive_entries.sql similarity index 55% rename from internal/db/migrations/00034_unify_filestore_skill_archives.sql rename to internal/db/migrations/00032_add_filestore_archive_entries.sql index 3108f9e2e..70b0957c7 100644 --- a/internal/db/migrations/00034_unify_filestore_skill_archives.sql +++ b/internal/db/migrations/00032_add_filestore_archive_entries.sql @@ -1,10 +1,37 @@ -- +goose Up --- Skill archives are Filestore namespace entries backed by immutable catalog --- zip objects. Historical rows in the old projection table are intentionally --- discarded instead of migrated. -drop table filestore_skill_archives; +-- /skills 是只读的 archive namespace。迁移必须先确认历史数据没有占用这个 +-- subtree,避免把用户可写的普通 entry 静默解释成受服务管理的 skill archive。 +-- +goose StatementBegin +do $$ +begin + if exists ( + select 1 + from filestore_entries + where deleted_at is null + and ( + path like '/skills/%' + or ( + path = '/skills' + and ( + kind <> 'directory' + or parent_path <> '/' + or managed_by is not null + or managed_resource_uuid is not null + or source_file_uuid is not null + ) + ) + ) + ) then + raise exception 'cannot initialize reserved /skills namespace over existing entries'; + end if; +end +$$; +-- +goose StatementEnd +-- archive 与 file、directory 共用 filestore_entries 生命周期和租户边界。 +-- NOT VALID 避免本次短事务在持有 AccessExclusive 锁时扫描历史 rows; +-- PostgreSQL 仍会立即校验迁移后的新写入,00033 再以较弱锁验证存量数据。 alter table filestore_entries drop constraint filestore_entries_kind_check, add constraint filestore_entries_kind_check check ( @@ -66,6 +93,8 @@ alter table filestore_entries ) ) not valid; +-- archive entry 必须位于 /skills 的直接子级,并绑定具体 catalog version。 +-- 其他 kind 不能伪装成 skill_archive,防止普通 Filestore mutation 越过只读边界。 alter table filestore_entries add constraint filestore_entries_archive_shape_check check ( ( @@ -82,6 +111,8 @@ alter table filestore_entries ) ) not valid; +-- 同一 filesystem 内,一个具体 skill version 最多只能有一条活动投影; +-- deleted_at 不为空的历史投影保留用于审计,不阻止后续重新挂载。 create unique index filestore_entries_skill_archive_active_v1_key on filestore_entries ( workspace_uuid, @@ -92,8 +123,50 @@ create unique index filestore_entries_skill_archive_active_v1_key and kind = 'archive' and managed_by = 'skill_archive'; +-- 历史 filesystem 没有 /skills 根目录。archive 成员只在读取时从 zip central +-- directory 合成,因此数据库只需要根目录和每个 archive 的一条 entry。 +insert into filestore_entries ( + uuid, + external_id, + organization_uuid, + workspace_uuid, + filesystem_uuid, + kind, + path, + parent_path, + created_by_api_key_uuid, + created_by_session_uuid, + created_by_code_session_uuid, + created_at, + updated_at +) +select + gen_random_uuid(), + concat('fse_', replace(cast(gen_random_uuid() as text), '-', '')), + fs.organization_uuid, + fs.workspace_uuid, + fs.uuid, + 'directory', + '/skills', + '/', + fs.created_by_api_key_uuid, + fs.session_uuid, + fs.code_session_uuid, + fs.created_at, + now() +from filestore_filesystems fs +where fs.deleted_at is null +on conflict (workspace_uuid, filesystem_uuid, path) + where deleted_at is null + do nothing; + +-- 虚拟只读视图启用后,旧 prewarm worker 已经移除,遗留 job 不再可执行。 +delete from jobs where type = 'skill_prewarm'; + -- +goose Down +-- kind 约束恢复前必须移除所有 archive 投影,包括已经软删除的历史 rows。 +-- 这些 rows 只借用 catalog 对象;删除投影不会删除或回收 zip。 delete from filestore_entries where kind = 'archive'; @@ -144,51 +217,5 @@ alter table filestore_entries ) ); -create table filestore_skill_archives ( - id bigint generated always as identity, - uuid uuid not null default gen_random_uuid(), - external_id text not null, - organization_uuid uuid not null, - workspace_uuid uuid not null, - filesystem_uuid uuid not null, - source text not null, - skill_version_uuid uuid not null, - virtual_path text not null, - s3_bucket text not null, - s3_key text not null, - size_bytes bigint not null, - sha256 text not null, - created_at timestamptz not null default now(), - updated_at timestamptz not null default now(), - constraint filestore_skill_archives_id_pk primary key (id), - constraint filestore_skill_archives_uuid_key unique (uuid), - constraint filestore_skill_archives_external_id_key unique (external_id), - constraint filestore_skill_archives_source_check check ( - source in ('anthropic', 'custom') - ), - constraint filestore_skill_archives_virtual_path_check check ( - virtual_path ~ '^/skills/[^/]+$' - and octet_length(virtual_path) <= 4096 - ), - constraint filestore_skill_archives_object_check check ( - char_length(s3_bucket) > 0 - and char_length(s3_key) > 0 - and size_bytes > 0 - and sha256 ~ '^[0-9a-f]{64}$' - ) -); - -create unique index filestore_skill_archives_filesystem_path_key - on filestore_skill_archives ( - workspace_uuid, - filesystem_uuid, - virtual_path - ); - -create unique index filestore_skill_archives_filesystem_version_key - on filestore_skill_archives ( - workspace_uuid, - filesystem_uuid, - source, - skill_version_uuid - ); +-- 无法区分迁移创建的 /skills 与迁移前已经存在的同名空目录,因此回滚时保留 +-- 目录 row,避免误删用户数据。旧版本服务会把它当作普通空目录。 diff --git a/internal/db/migrations/00032_add_filestore_skill_archives.sql b/internal/db/migrations/00032_add_filestore_skill_archives.sql deleted file mode 100644 index f988915b7..000000000 --- a/internal/db/migrations/00032_add_filestore_skill_archives.sql +++ /dev/null @@ -1,125 +0,0 @@ --- +goose Up - --- Managed Agent skills borrow immutable catalog archives. Filestore exposes --- each archive as a virtual directory without copying its members into S3. -create table filestore_skill_archives ( - id bigint generated always as identity, - uuid uuid not null default gen_random_uuid(), - external_id text not null, - organization_uuid uuid not null, - workspace_uuid uuid not null, - filesystem_uuid uuid not null, - source text not null, - skill_version_uuid uuid not null, - virtual_path text not null, - s3_bucket text not null, - s3_key text not null, - size_bytes bigint not null, - sha256 text not null, - created_at timestamptz not null default now(), - updated_at timestamptz not null default now(), - constraint filestore_skill_archives_id_pk primary key (id), - constraint filestore_skill_archives_uuid_key unique (uuid), - constraint filestore_skill_archives_external_id_key unique (external_id), - constraint filestore_skill_archives_source_check check (source in ('anthropic', 'custom')), - constraint filestore_skill_archives_virtual_path_check check ( - virtual_path ~ '^/skills/[^/]+$' - and octet_length(virtual_path) <= 4096 - ), - constraint filestore_skill_archives_object_check check ( - char_length(s3_bucket) > 0 - and char_length(s3_key) > 0 - and size_bytes > 0 - and char_length(sha256) = 64 - ) -); - -create unique index filestore_skill_archives_filesystem_path_key - on filestore_skill_archives (workspace_uuid, filesystem_uuid, virtual_path); - -create unique index filestore_skill_archives_filesystem_version_key - on filestore_skill_archives (workspace_uuid, filesystem_uuid, source, skill_version_uuid); - --- Refuse to reinterpret an existing user-controlled subtree as the reserved, --- read-only skill namespace. --- +goose StatementBegin -do $$ -begin - if exists ( - select 1 - from filestore_entries - where deleted_at is null - and ( - path like '/skills/%' - or ( - path = '/skills' - and ( - kind <> 'directory' - or parent_path <> '/' - or managed_by is not null - or managed_resource_uuid is not null - or source_file_uuid is not null - ) - ) - ) - ) then - raise exception 'cannot initialize reserved /skills namespace over existing entries'; - end if; -end -$$; --- +goose StatementEnd - -insert into filestore_entries ( - uuid, - external_id, - organization_uuid, - workspace_uuid, - filesystem_uuid, - kind, - path, - parent_path, - created_by_api_key_uuid, - created_by_session_uuid, - created_by_code_session_uuid, - created_at, - updated_at -) -select - gen_random_uuid(), - concat('fse_', replace(cast(gen_random_uuid() as text), '-', '')), - fs.organization_uuid, - fs.workspace_uuid, - fs.uuid, - 'directory', - '/skills', - '/', - fs.created_by_api_key_uuid, - fs.session_uuid, - fs.code_session_uuid, - fs.created_at, - now() -from filestore_filesystems fs -where fs.deleted_at is null -on conflict (workspace_uuid, filesystem_uuid, path) - where deleted_at is null - do nothing; - --- Old prewarm work is no longer executable after the virtual view is enabled. -delete from jobs where type = 'skill_prewarm'; - --- +goose Down - -drop table filestore_skill_archives; - -delete from filestore_entries -where path = '/skills' - and kind = 'directory' - and deleted_at is null - and not exists ( - select 1 - from filestore_entries child - where child.workspace_uuid = filestore_entries.workspace_uuid - and child.filesystem_uuid = filestore_entries.filesystem_uuid - and child.deleted_at is null - and child.path like '/skills/%' - ); diff --git a/internal/db/migrations/00033_validate_filestore_archive_entries.sql b/internal/db/migrations/00033_validate_filestore_archive_entries.sql new file mode 100644 index 000000000..5732ea5a7 --- /dev/null +++ b/internal/db/migrations/00033_validate_filestore_archive_entries.sql @@ -0,0 +1,95 @@ +-- +goose Up + +-- 与 00032 的约束替换事务分开,存量扫描只取得 ShareUpdateExclusive 锁。 +alter table filestore_entries + validate constraint filestore_entries_kind_check; + +alter table filestore_entries + validate constraint filestore_entries_blob_shape_check; + +alter table filestore_entries + validate constraint filestore_entries_archive_shape_check; + +-- +goose Down + +-- PostgreSQL 不能把已验证约束直接改回 NOT VALID;降级时重建三个约束, +-- 使只回退本 migration 也准确恢复到 00032 的状态。 +alter table filestore_entries + drop constraint filestore_entries_archive_shape_check, + drop constraint filestore_entries_blob_shape_check, + drop constraint filestore_entries_kind_check; + +alter table filestore_entries + add constraint filestore_entries_kind_check check ( + kind in ('file', 'directory', 'archive') + ) not valid; + +alter table filestore_entries + add constraint filestore_entries_blob_shape_check check ( + ( + kind = 'directory' + and source_file_uuid is null + and size_bytes is null + and media_type is null + and detected_mime_type is null + and md5 is null + and sha256 is null + and s3_bucket is null + and s3_key is null + and s3_etag is null + and s3_version_id is null + and expires_at is null + ) + or ( + kind = 'file' + and size_bytes is not null + and size_bytes >= 0 + and media_type is not null + and ( + source_file_uuid is not null + or ( + md5 is not null + and char_length(md5) > 0 + ) + ) + and sha256 is not null + and char_length(sha256) = 64 + and s3_bucket is not null + and char_length(s3_bucket) > 0 + and s3_key is not null + and char_length(s3_key) > 0 + ) + or ( + kind = 'archive' + and source_file_uuid is null + and size_bytes is not null + and size_bytes > 0 + and media_type = 'application/zip' + and detected_mime_type = 'application/zip' + and md5 is null + and sha256 ~ '^[0-9a-f]{64}$' + and s3_bucket is not null + and char_length(s3_bucket) > 0 + and s3_key is not null + and char_length(s3_key) > 0 + and s3_etag is null + and s3_version_id is null + and expires_at is null + ) + ) not valid; + +alter table filestore_entries + add constraint filestore_entries_archive_shape_check check ( + ( + kind = 'archive' + and path ~ '^/skills/[^/]+$' + and parent_path = '/skills' + and managed_by = 'skill_archive' + and managed_resource_uuid is not null + and downloadable = false + ) + or ( + kind <> 'archive' + and managed_by is distinct from 'skill_archive' + ) + ) not valid; diff --git a/internal/db/migrations/00033_validate_filestore_skill_archive_checksum.sql b/internal/db/migrations/00033_validate_filestore_skill_archive_checksum.sql deleted file mode 100644 index 5bfc4b1cf..000000000 --- a/internal/db/migrations/00033_validate_filestore_skill_archive_checksum.sql +++ /dev/null @@ -1,21 +0,0 @@ --- +goose Up - -alter table filestore_skill_archives - drop constraint filestore_skill_archives_object_check, - add constraint filestore_skill_archives_object_check check ( - char_length(s3_bucket) > 0 - and char_length(s3_key) > 0 - and size_bytes > 0 - and sha256 ~ '^[0-9a-f]{64}$' - ); - --- +goose Down - -alter table filestore_skill_archives - drop constraint filestore_skill_archives_object_check, - add constraint filestore_skill_archives_object_check check ( - char_length(s3_bucket) > 0 - and char_length(s3_key) > 0 - and size_bytes > 0 - and char_length(sha256) = 64 - ); diff --git a/internal/db/migrations/00035_validate_filestore_archive_entries.sql b/internal/db/migrations/00035_validate_filestore_archive_entries.sql deleted file mode 100644 index bf0e892a1..000000000 --- a/internal/db/migrations/00035_validate_filestore_archive_entries.sql +++ /dev/null @@ -1,14 +0,0 @@ --- +goose Up - -alter table filestore_entries - validate constraint filestore_entries_kind_check; - -alter table filestore_entries - validate constraint filestore_entries_blob_shape_check; - -alter table filestore_entries - validate constraint filestore_entries_archive_shape_check; - --- +goose Down - -select 1;