Skip to content
Merged
Show file tree
Hide file tree
Changes from 32 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
58b69bc
feat: add session file catalog projection
pxguan Jul 27, 2026
509120f
fix: clean up session file projections
pxguan Jul 27, 2026
9fff28c
Merge remote-tracking branch 'origin/main' into codex/session-file-ca…
pxguan Jul 27, 2026
4fbf68a
fix: refresh session outputs on file listing
pxguan Jul 27, 2026
1099fb3
Merge remote-tracking branch 'origin/main' into codex/session-file-ca…
pxguan Jul 27, 2026
801f471
fix: address session file projection review feedback
pxguan Jul 27, 2026
afa6a7d
fix: materialize session outputs on write
pxguan Jul 28, 2026
eb5232b
fix: harden session projection cleanup
pxguan Jul 28, 2026
3e089a7
feat: unify session resources and files
pxguan Jul 29, 2026
53bc24a
Merge origin/main into session resource unification
pxguan Jul 29, 2026
64337a0
fix: harden unified session file lifecycle
pxguan Jul 29, 2026
ea28d2b
docs: remove obsolete session file research
pxguan Jul 29, 2026
0d2587d
fix: address session resource review feedback
pxguan Jul 29, 2026
0af03a5
fix: ignore retired filestore filesystems
pxguan Jul 30, 2026
68e4e73
refactor: rename SessionNamespaceNode to SessionResourceFile
pxguan Jul 30, 2026
501d3f2
Keep expired Filestore paths reserved until TTL cleanup
arthur-zhang Jul 30, 2026
a7ff352
refactor: generate session resource and file identities in applicatio…
pxguan Jul 30, 2026
a5445a9
Merge remote-tracking branch 'origin/codex/issue-184-session-resource…
pxguan Jul 30, 2026
a6a361d
refactor: snapshot session skills as files
pxguan Jul 30, 2026
6bb70eb
refactor: use stable UUIDs for session resources
pxguan Jul 30, 2026
862759d
fix: avoid duplicate skill snapshot files
pxguan Jul 30, 2026
87fa1cb
Merge origin/main into session-resource-file-unification
pxguan Jul 31, 2026
1f6e134
brother18@qq.com
arthur-zhang Jul 31, 2026
3ff98c8
Merge remote-tracking branch 'origin/main' into codex/issue-184-sessi…
pxguan Jul 31, 2026
ca6194b
fix(db): 修复 main 合并后的文件资源逻辑
pxguan Jul 31, 2026
d9cc7ce
Merge updated main history
arthur-zhang Jul 31, 2026
f24fcb6
fix(db): 对齐 Session 与清理任务 UUID 边界
pxguan Aug 1, 2026
53e6b35
Merge remote-tracking branch 'origin/main' into codex/issue-184-sessi…
arthur-zhang Aug 3, 2026
d84f2a0
Merge remote-tracking branch 'origin/main' into codex/issue-184-sessi…
arthur-zhang Aug 3, 2026
1ffc250
Merge remote-tracking branch 'origin/main' into codex/issue-184-sessi…
arthur-zhang Aug 4, 2026
b5f0b91
Refactor managed agent workflows and simplify implementation
arthur-zhang Aug 4, 2026
7d06f14
Merge remote-tracking branch 'origin/codex/issue-184-session-resource…
arthur-zhang Aug 4, 2026
d895da6
feat(sessions): 支持相对挂载路径输入
pxguan Aug 5, 2026
54472b1
移除 session resource 文件的 TTL 到期清理逻辑
arthur-zhang Aug 5, 2026
2a69fa4
Merge remote-tracking branch 'origin/main' into codex/issue-184-sessi…
arthur-zhang Aug 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/design/be/database-identifier-references.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,11 @@ UUID,参数映射只区分 `dbUUID(value)` 和 `dbNullableUUID(pointer)` 两
`uuid.UUID`/`uuid.NullUUID`。自定义参数类型不实现 `driver.Valuer`,PostgreSQL driver
只接收标准 typed UUID,校验错误也在 SQL 绑定前返回。

yourbatis Mapper 直接声明业务字段,不引入自定义 UUID 参数结构。生成 SQL 保留参数字段名,
executor 通过 `BoundSQL.Values()` 将字段值交给 `database/sql`;PostgreSQL 根据 UUID 列的
比较或写入上下文推断字符串参数类型。需要在发送查询前校验 UUID 的业务入口仍应显式调用
`parseDBUUID`,不要根据 Mapper 字段名隐式转换参数。

sqlx 行结构同样使用 `uuid.UUID`/`uuid.NullUUID` 扫描 `uuid` 列,只有映射到 HTTP、SDK、
事件或其他文本协议 DTO 时才调用 `String()`。因此普通查询不得通过
`CAST(:..._uuid AS uuid)` 修复字符串输入,也不得通过 `CAST(uuid AS text) AS uuid`
Expand Down
167 changes: 90 additions & 77 deletions docs/design/be/filestore.md

Large diffs are not rendered by default.

68 changes: 34 additions & 34 deletions docs/design/be/managed-agent-skills-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,28 +19,29 @@ Environment Runner 在创建 cloud managed-agent Sandbox 前完成:
在当前 workspace 内从 `skills` / `skill_versions` 解析版本。
3. `latest` 在启动时解析为具体 active version row。后续 catalog 的 latest 变化不会改变
已启动 Session 的视图。
4. 在一只 `sqlx.Tx` 中锁定 Session filesystem 和 namespace,确保 `/skills` 固定根存在,
并原子替换该 filesystem 中 `kind=archive`、`managed_by=skill_archive` 的 entry 集合。
替换时旧的活动投影统一写入 `deleted_at`,不做硬删除;新的投影作为新 entry 插入。
4. 在一只 `sqlx.Tx` 中通过生成的 Session Resource/File Mapper 锁定 filesystem 和 namespace,确保 `/skills` 固定根存在,
并原子替换该 Session 中 `resource_type=skill_archive` 的内部 Resource 集合。
替换时旧的 Resource 与 File 快照统一写入 `deleted_at`;每个新 Resource 通过 `file_uuid`
指向一条固化解析结果的 ZIP File。
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。

每条 archive entry 对应一个具体 skill version zip,保存
每条 Skill Archive Resource 对应一份解析后的 skill zip File 快照

- organization、workspace、filesystem 的稳定 UUID;
- `metadata.skill_source` 和 `managed_resource_uuid` 中的具体 skill version UUID
- 唯一路径 `/skills/<directory>`
- archive 的 bucket、key、size 和 SHA-256
- Resource 保存 organization、workspace、Session identity、唯一路径 `/skills/<directory>`
和通用 `file_uuid`
- File 保存独立 UUID/`file_` identity、ZIP filename、bucket、key、size 与 SHA-256
- 具体 Skill Version UUID 只存在于 Resolver 的瞬时解析结果,不写入 Resource 或 File

同一 filesystem 内,路径和具体 skill version UUID 都唯一。Snapshot 中两个 skill
若声明相同目录但不是同一具体版本,启动失败,不能让后一个静默覆盖前一个
同一 Session 内路径唯一。Snapshot 中两个 Skill 声明相同目录时启动失败,不能让后一个
静默覆盖前一个;同一个具体版本也不能被重复投影

```mermaid
flowchart LR
A["Session agent snapshot"] --> B["Resolve concrete catalog versions"]
B --> C["Replace kind=archive entries in one transaction"]
A["Session agent snapshot"] --> B["Resolve concrete catalog contents"]
B --> C["Create File snapshots and skill_archive Resources"]
C --> D["Filestore /skills virtual view"]
E["Immutable zip objects"] --> D
D --> F["rclone readonly mount"]
Expand Down Expand Up @@ -70,18 +71,17 @@ Sandbox 中同一棵树直接位于:
xlsx/SKILL.md
```

`/skills` 是真实的固定一级 directory entry;每个 `/skills/<directory>` 是
`filestore_entries` 中的 archive entry,成员则根据 zip central directory 合成,不逐个
写 entry。archive entry 只借用 catalog 对象,不复制对象,也不计入 `filestore_bytes`。
虚拟文件 UUID 由 filesystem、具体 version UUID 和成员路径确定,Runner 重试不会改变
同一节点的身份。
`/skills` 是内部 directory Resource;每个 `/skills/<directory>` 是通过 `file_uuid` 引用
ZIP File 快照的 `skill_archive` Resource。成员根据 zip central directory 动态合成,不逐个
持久化,也不生成虚假 UUID 或 `fse_` external ID。Skill File 不复制物理对象,不进入 Files
Catalog,也不计入 `filestore_bytes`。

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
非递归列举 `/skills` 时,Filestore 直接使用 Skill Archive Resource 的 `path` 返回一级 skill
目录,不下载 archive。递归列举或访问具体 skill 子树时,才按需加载并校验对应 archive。

## archive 校验与缓存
Expand All @@ -102,22 +102,23 @@ LRU 缓存压缩 archive 和目录索引。单个压缩 archive 最大 8 MiB,
首个请求的取消信号,避免 leader 断开导致其他等待者一起失败;每个调用者通过自己的 context
独立等待,取消只会结束该调用者,不会终止或 Forget 仍可服务其他请求的共享任务。所有调用者
都取消后,共享任务仍允许在超时内完成并填充缓存。失败结果不写缓存,后续请求可以重新加载。
archive entry 仍是每次请求的授权事实来源;Session entry 删除后,缓存中残留的字节无法再通过
Skill Archive Resource 仍是每次请求的授权事实来源;Resource 删除后,缓存中残留的字节无法再通过
Filestore 路径访问。

## 生命周期与对象保留

Session filesystem 删除后沿用现有有界 cleanup job。最后一批普通文件退休后,同一事务
软删除该 filesystem 的 directory 和 archive entries。archive entry 只是借用 catalog
archive,不会产生 Filestore 对象清理任务或容量扣减。
Session filesystem 删除后沿用现有有界 cleanup job。最后一批 Owned File 退休后,同一事务
软删除该 Session 的内部 directory、Skill Archive Resources 及其 File 快照。Skill File 借用
catalog 中的不可变对象,不会产生 Filestore 对象清理任务或容量扣减。

Runner 每次全量替换 `/skills` 投影时,会在同一事务中软删除旧的活动 archive entries
并插入新集合。这样被移除或换版的 skill 投影仍可用于审计,活动读取和唯一索引只考虑
`deleted_at is null` 的记录。历史投影不拥有 catalog archive,也不会触发对象回收。
Runner 每次全量替换 `/skills` Resources 时,会在同一事务中软删除旧 Resource/File 集合并
插入新集合。活动读取只考虑 `deleted_at is null` 的记录;历史 Skill File 不拥有 catalog
archive,也不会触发对象回收。

删除 custom skill/version 或用 `seed-builtin-skills --prune` 软删除 built-in catalog row 时,
不立即删除 archive 对象,也不创建通用 `object_cleanup` job。原因是已经启动的 Session
可能仍通过具体 version UUID 投影借用该对象。物理 GC 必须先确认没有任何活动投影引用,
可能仍通过 File 快照中的 bucket、key 与 SHA-256 读取该对象。读路径不再查询 catalog version,
也不把 catalog 列表可见性当成 Session 快照可见性。物理 GC 必须先确认没有任何活动 Resource 引用,
属于独立的 reference-aware catalog GC;当前实现选择保留对象,优先保证运行中 Session
的快照稳定性。

Expand All @@ -132,21 +133,20 @@ Runner 每次全量替换 `/skills` 投影时,会在同一事务中软删除
- `/mnt/skills`、`/workspace/skills` 解压目录,以及 Claude skill discovery 软链;
- Environment Manager 的 managed-agent skill 解压职责。

迁移 `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 不创建
迁移 `00047_unify_session_resources_and_files.sql` 把活动的旧 Archive 节点转换为
`resource_type='skill_archive'` 的内部 Resource。迁移 `00048_snapshot_session_skills.sql`
随后从 catalog version 创建 ZIP File 快照、回填 Resource 的通用 `file_uuid`,并删除 Skill
Version UUID。Resource + File 成为 Session 内唯一的 Skill 快照事实;schema 不创建
PostgreSQL 外键。

## 验收重点

- resolver 只读取 DB metadata,不在 Session 启动路径下载 archive;
- `latest` 被钉住为具体 version,archive entry 替换是全量且原子的
- `latest` 在启动时解析成具体内容,Skill File + Resource 快照替换是全量且原子的
- `/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 archive entry
- Session filesystem cleanup 会软删除 archive entry,但不删除借用的 catalog object。
- catalog soft delete/prune 不破坏活动 Session Skill File,读取路径不 JOIN version 表
- Session filesystem cleanup 会软删除 Skill Archive Resource 与 File 快照,但不删除 catalog object。
2 changes: 1 addition & 1 deletion docs/design/be/yourbatis-admin-api-keys-prototype.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ Yourbatis 生成 PostgreSQL 位置参数和固定 `rows.Scan`。若要正式采
2. 评审并记录生成 Mapper 代码的受控例外及其静态安全保证。

Yourbatis runtime 与生成器均由 `go.mod` 固定到已发布的
`github.com/superduck-ai/yourbatis v0.1.0`:应用依赖不再使用本地 module replacement,
`github.com/superduck-ai/yourbatis v0.1.1`:应用依赖不再使用本地 module replacement,
`sqlmapgen` 通过 Go `tool` 指令声明,并由 `go:generate` 使用 `go tool sqlmapgen` 调用。
后续升级只需更新 `go.mod` 中的模块版本,运行时和生成器会保持一致。

Expand Down
2 changes: 1 addition & 1 deletion internal/db/AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# 数据库标识符引用

- 表自身继续使用 `id bigint generated always as identity` 作为当前数据库内的内部主键,并使用唯一 `uuid` 作为稳定业务标识。
- 需要在整库恢复、租户搬迁、跨库合并或部分数据导入后保持含义的持久化跨表引用,使用被引用资源的 `*_uuid`,不要保存另一张表可重新生成的 identity 主键;例如 `filestore_filesystems` 与 `filestore_entries` 的租户、filesystem、会话和创建者归属都使用对应 UUID。
- 需要在整库恢复、租户搬迁、跨库合并或部分数据导入后保持含义的持久化跨表引用,使用被引用资源的 `*_uuid`,不要保存另一张表可重新生成的 identity 主键;例如 `filestore_filesystems` 的租户、会话和创建者归属,以及 `session_resources.file_uuid` 都使用对应 UUID。Session Skill 由独立 File 快照承载,不保存 catalog version 引用
- `external_id` 服务于对外 API 兼容,不替代内部稳定 UUID;只有确实需要回显或兼容外部协议时才冗余保存。
- 尽量避免在表中引用外部表的 id 时使用 bigint 形式的 id,优先使用 uuid 类型的 id
- 把既有 bigint 引用迁移为 UUID 时,必须先通过源表回填并验证每一条引用均可解析;发现孤立引用应让 migration 失败,不能静默写成 `NULL`。
Expand Down
3 changes: 3 additions & 0 deletions internal/db/admin_requests.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,9 @@ func (d *DB) ListAdminRequests(ctx context.Context, orgUUID string, requestType
limit,
)
if err != nil {
if postgresErr, ok := postgresError(err); ok && postgresErr.Code == "42P01" {
return []platform.AdminRequest{}, nil
}
return nil, err
}
return requests, nil
Expand Down
192 changes: 192 additions & 0 deletions internal/db/file_mapper.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
package db

import (
"context"
"database/sql"
"errors"
"time"

"github.com/google/uuid"
)

//go:generate go tool sqlmapgen -dir $PWD -mapper FileMapper -sql ./file_mapper.xml -out ./file_mapper.sqlmap.gen.go -dialect postgres

type FileMapper interface {
InsertFile(ctx context.Context, params fileMapperRecordParams) error
GetFile(ctx context.Context, workspaceUUID, fileExternalID string) (fileRecordRow, error)
GetFileByUUID(ctx context.Context, workspaceUUID, fileUUID string) (fileRecordRow, error)
GetFileByUUIDInOrganization(ctx context.Context, organizationUUID, fileUUID string) (fileRecordRow, error)
ListFiles(ctx context.Context, params fileMapperListParams) ([]fileRecordRow, error)
ListSessionFiles(ctx context.Context, params fileMapperListParams) ([]fileRecordRow, error)
FindPageCursor(ctx context.Context, params fileMapperListParams) (filePageCursorRow, bool, error)
FindSessionPageCursor(ctx context.Context, params fileMapperListParams) (filePageCursorRow, bool, error)
ListFilesPage(ctx context.Context, params fileMapperListParams) ([]fileRecordRow, error)
ListSessionFilesPage(ctx context.Context, params fileMapperListParams) ([]fileRecordRow, error)
GetFileForDelete(ctx context.Context, workspaceUUID, fileUUID string) (fileRecordRow, error)
GetFileForShare(ctx context.Context, workspaceUUID, fileExternalID string) (fileRecordRow, error)
HasActiveReference(ctx context.Context, workspaceUUID, fileUUID string) (bool, error)
SoftDeleteFile(ctx context.Context, workspaceUUID, fileUUID string) error
UpdateOwnedFile(ctx context.Context, params sessionResourceFileWriteParams) error
RetireOwnedFile(ctx context.Context, params sessionResourceRetireParams) error
RetireOwnedFilesInSubtree(ctx context.Context, params sessionResourceSubtreeParams) error
RetireSkillArchiveFiles(ctx context.Context, params sessionSkillArchiveRetireParams) error
InsertSkillArchiveFile(ctx context.Context, params sessionSkillArchiveInsertParams) error

EnqueueObjectCleanupJob(ctx context.Context, workspaceUUID string, payload []byte) error
LeaseObjectCleanupJobs(ctx context.Context, workerID string, limit int) ([]objectCleanupJobRow, error)
CompleteObjectCleanupJob(ctx context.Context, jobUUID string) error
FailObjectCleanupJob(ctx context.Context, params objectCleanupJobFailureParams) error
}

type fileMapperRecordParams struct {
FileUUID string
FileExternalID string
WorkspaceUUID string
Filename string
MimeType string
SizeBytes int64
SHA256 string
S3Bucket string
S3Key string
Downloadable bool
ScopeType *string
ScopeID *string
CreatedByAPIKeyUUID string
CreatedAt time.Time
}

type fileMapperListParams struct {
WorkspaceUUID string
ScopeID string
CursorExternalID string
CursorUUID string
CursorCreatedAt time.Time
Limit int
SessionScope bool
HasScope bool
HasCursor bool
Before bool
}

type objectCleanupJobFailureParams struct {
JobUUID string
Status string
RunAfter time.Time
Attempts int
Reason string
}

type fileRecordRow struct {
UUID uuid.UUID `db:"uuid"`
ExternalID string `db:"external_id"`
WorkspaceUUID uuid.UUID `db:"workspace_uuid"`
Filename string `db:"filename"`
MimeType string `db:"mime_type"`
SizeBytes int64 `db:"size_bytes"`
SHA256 string `db:"sha256"`
S3Bucket string `db:"s3_bucket"`
S3Key string `db:"s3_key"`
Downloadable bool `db:"downloadable"`
ScopeType *string `db:"scope_type"`
ScopeID *string `db:"scope_id"`
CreatedByAPIKeyUUID uuid.UUID `db:"created_by_api_key_uuid"`
CreatedAt time.Time `db:"created_at"`
}

type filePageCursorRow struct {
UUID uuid.UUID `db:"uuid"`
CreatedAt time.Time `db:"created_at"`
}

type objectCleanupJobRow struct {
UUID uuid.UUID `db:"uuid"`
ExternalID string `db:"external_id"`
WorkspaceUUID uuid.UUID `db:"workspace_uuid"`
Bucket string `db:"bucket"`
Key string `db:"object_key"`
FileExternalID string `db:"file_external_id"`
Attempts int `db:"attempts"`
}

func fileMapperRecordParameters(file FileRecord) fileMapperRecordParams {
return fileMapperRecordParams{
FileUUID: file.UUID,
FileExternalID: file.ExternalID,
WorkspaceUUID: file.WorkspaceUUID,
Filename: file.Filename,
MimeType: file.MimeType,
SizeBytes: file.SizeBytes,
SHA256: file.SHA256,
S3Bucket: file.S3Bucket,
S3Key: file.S3Key,
Downloadable: file.Downloadable,
ScopeType: file.ScopeType,
ScopeID: file.ScopeID,
CreatedByAPIKeyUUID: file.CreatedByAPIKeyUUID,
CreatedAt: file.CreatedAt,
}
}

func newFileMapperListParams(workspaceUUID string, scopeID string) fileMapperListParams {
return fileMapperListParams{
WorkspaceUUID: workspaceUUID,
ScopeID: scopeID,
SessionScope: isSessionFilesScope(scopeID),
HasScope: scopeID != "",
}
}

func fileRecordFromMapperRow(row fileRecordRow, err error) (FileRecord, error) {
if errors.Is(err, sql.ErrNoRows) {
return FileRecord{}, ErrNotFound
}
if err != nil {
return FileRecord{}, err
}
return row.record(), nil
}

func fileRecordsFromMapperRows(rows []fileRecordRow, err error) ([]FileRecord, error) {
if err != nil {
return nil, err
}
if rows == nil {
return nil, nil
}
files := make([]FileRecord, 0, len(rows))
for _, row := range rows {
files = append(files, row.record())
}
return files, nil
}

func (r fileRecordRow) record() FileRecord {
return FileRecord{
UUID: r.UUID.String(),
ExternalID: r.ExternalID,
WorkspaceUUID: r.WorkspaceUUID.String(),
Filename: r.Filename,
MimeType: r.MimeType,
SizeBytes: r.SizeBytes,
SHA256: r.SHA256,
S3Bucket: r.S3Bucket,
S3Key: r.S3Key,
Downloadable: r.Downloadable,
ScopeType: r.ScopeType,
ScopeID: r.ScopeID,
CreatedByAPIKeyUUID: r.CreatedByAPIKeyUUID.String(),
CreatedAt: r.CreatedAt,
}
}

func (r objectCleanupJobRow) job() ObjectCleanupJob {
return ObjectCleanupJob{
UUID: r.UUID.String(),
ExternalID: r.ExternalID,
WorkspaceUUID: r.WorkspaceUUID.String(),
Bucket: r.Bucket,
Key: r.Key,
FileExternalID: r.FileExternalID,
Attempts: r.Attempts,
}
}
Loading
Loading