diff --git a/HANDOVER.md b/HANDOVER.md new file mode 100644 index 0000000..d7520f9 --- /dev/null +++ b/HANDOVER.md @@ -0,0 +1,242 @@ +# Handover — 近期变更与待办 + +面向接手运维 / 二次开发的人。记录最近几次 commit 的动机、改动范围、未尽事项, +以及「线上有旧数据时怎么过渡」。详细架构见 `ARCHITECTURE.md`;本仓库的开发规约 +见 `DEVELOP.md`。 + +--- + +## 1. 最近 5 个 commit(按时间倒序) + +### `139f2c0` — fix: delete error(trash 路径统一 + 删除后 DB 字段更新) + +**背景**:删除文件统一改为移动到 trash 桶后,存在三个连锁 bug: + +1. **local 模式 trash 路径双重 `data`**:`trash_key = f"{item.bucket_name}/{item.object_key}"`。 + local 模式下 `bucket_name` 是绝对路径(如 `/data/workspace`),最终落到 + `data/trash/data/workspace/{ws_id}/{user_id}/...`。 +2. **s3 模式桶名 / 路径名错位**:`.env` 之前是 `S3_WORKSPACE_BUCKET=workspaces`(复数), + 代码 default / `PURPOSE_BUCKETS` / `USAGE_TYPE_TO_PURPOSE` 全部是单数 `workspace`。 + 跨环境部署产生 `KeyError: 'workspaces'`(软删时 `object_stores[item.bucket_name]` 抛错)。 +3. **软删后 `storage_objects` 路径字段未更新**:只翻 `trash_key` / `object_status` / + `deleted_at` / `is_deleted`,`bucket_name` / `object_key` / `storage_uri` 仍指向源, + 与物理位置不一致,`purge_trash_object` 必须读旧 `trash_key` 才能定位 trash 文件。 + +**改动**(5 个文件,44+/24-): + +| 文件 | 改动 | +|---|---| +| `backend/src/backend/services/storage.py` | `trash_key = f"{source_purpose}/{item.object_key}"`;软删成功后更新 `bucket_name` / `object_key` / `storage_uri` 指向 trash 位置 | +| `backend/src/backend/storage_api.py` | `restore_object` 用 `item.object_key` 直接定位 trash,按 `/` 拆 `source_purpose`,写回源桶,更新 DB 字段指回 source;`purge_trash_object` 用 `(actual_bucket_name("trash"), item.object_key)` 删除,加 `bucket_name != trash` 的 409 防御 | +| `common/src/common/storage/factory.py` | 引入 `USAGE_TYPE_TO_PURPOSE` 字典(替代 `storage_api.py` 的本地定义) | +| `common/src/common/storage/__init__.py` | 导出 `USAGE_TYPE_TO_PURPOSE` | +| `.env.example` | `S3_WORKSPACE_BUCKET=workspaces` → `workspace`、`S3_VERSION_BUCKET=versions` → `version`、`S3_RUN_LOG_BUCKET=run-logs` → `run-log`(统一单数) | + +**注意点**: + +- `workspaces_root()` 函数名 / `WORKSPACES_ROOT` 环境变量名不动(这是 path 概念,不是 bucket 名)。 +- `Workspaces` 数据库表名 / `auth.py` 的 `"workspaces"` JSON key(API 响应)不动(这些不是桶名)。 +- `visibility="workspace"` 是 ACL Literal,不动。 + +### `2e070fa` — fix: pre check(前端 script 创建/上传前置校验) + +`CreateScriptModal.tsx`(+25 行)— 文件名 / workspace_id / 类型前置校验。 +`ScriptsPage.tsx`(+1)/ `scriptWorkspaceStore.ts`(+19/-10)— store action 增加 +`precheck` 步骤,提前拦截无效请求。 + +### `5c5dec9` — fix: allows_same_name_different_parent + +允许同名 script 出现在不同父目录下。`scripts.py`(+32/-?)、`services/storage.py`、 +`storage_api.py` 微调;`test_scripts.py` 新增 57 行回归测试。 + +### `5d49ff5` — fix: file upload error(大重写) + +修复 file upload 路径上一连串 schema / API / 上传流程的不一致。改动面最大 +(14 个文件,872+/604-): + +- 新增索引 / 调整唯一约束(去掉了 scripts 和 data_resources 的旧 unique index) +- 把多个小 migration 合并进 `e1f2a3b4c5d6_rebuild_baseline.py` 一份大 baseline +- `resources.py` / `scripts.py` 上传流程对齐 +- `test_resources.py` / `test_scripts.py` 大量回归 +- `API.md` 增加 3 行 + +### `c65d6dc` — fix: build error + +`backend/Dockerfile` 单行修复(镜像构建报错)。 + +--- + +## 2. Trash 修复详解(commit `139f2c0`) + +### 2.1 修复后的语义 + +- **trash_key**:`f"{source_purpose}/{item.object_key}"`。`source_purpose` 来自 + `USAGE_TYPE_TO_PURPOSE[item.usage_type]`,与 `item.bucket_name` 解耦 — + 避免 local 模式绝对路径泄漏 + 命名错位风险。 +- **软删后 `storage_objects` 行的 7 个字段同步**: + + | 字段 | 软删前 | 软删后 | + |---|---|---| + | `bucket_name` | 源桶 | trash 桶 | + | `object_key` | 源 key | trash key | + | `trash_key` | NULL | trash key | + | `storage_uri` | 源 URI | trash URI(`build_storage_uri` 重建) | + | `object_status` | `available` | `deleted` | + | `is_deleted` | `0` | `1` | + | `deleted_at` | NULL | `` | + +- **物理文件落点**: + + | 模式 | 源文件(被删) | trash 文件(新落) | + |---|---|---| + | S3 | `s3:///` | `s3://trash//` | + | local | `//` | `/trash//` | + + `source_purpose` ∈ `{workspace, version, run_log}`。 + +### 2.2 `restore_object` 反向流程 + +```python +data = await object_stores[trash_bucket].get(item.object_key) # 从 trash 读 +source_purpose, _, source_key = item.object_key.partition("/") # 拆 source_purpose +target_bucket = actual_bucket_name(source_purpose) # 还原目标桶 +await object_stores[target_bucket].put(source_key, data) # 写回源 + +item.bucket_name = target_bucket +item.object_key = source_key +item.storage_uri = build_storage_uri(target_bucket, source_key) +# trash_key 保留不动 → 未来 reaper 可据此清理 trash 里的 orphan 副本 +item.object_status = "available" +item.deleted_at = None +``` + +**trash 文件不会被 restore 主动删除**(保留现有安全语义:restore 失败时 +数据仍在 trash 里有兜底)。未实现的 reaper 未来可扫 +`object_status='available' AND trash_key IS NOT NULL` 清理 orphan。 + +### 2.3 `purge_trash_object` 防御 + +加 409 防御:`if item.bucket_name != actual_bucket_name("trash"): raise 409`。 +避免误删源数据(用 `item.object_key` 直接删 trash 文件,不再读旧 `trash_key`)。 + +### 2.4 USAGE_TYPE_TO_PURPOSE 提升到 common 层 + +`backend/storage_api.py` 与 `backend/services/storage.py` 都依赖这个映射。 +提到 `common/storage/factory.py` 后两边都 `from common.storage import USAGE_TYPE_TO_PURPOSE`, +避免互相 import 循环。 + +--- + +## 3. 线上数据兼容(重要) + +修复后**新行为对 DB 里已有的旧行不感知**。如果部署环境之前是 +`S3_WORKSPACE_BUCKET=workspaces`(复数)且已上传过文件, +`storage_objects.bucket_name` 列里会有复数残留。新代码 default 是单数, +`object_stores` dict key 也是单数,访问旧行仍会 `KeyError: 'workspaces'`。 + +**部署新代码前必须先跑一次数据修复 SQL**(先 SELECT 看数量,确认是同一个 deployment): + +```sql +-- 1. 先看现状(按 backend 区分) +SELECT storage_backend, bucket_name, COUNT(*) +FROM storage_objects +WHERE object_status = 'available' +GROUP BY storage_backend, bucket_name +ORDER BY storage_backend, bucket_name; + +-- 2a. S3 部署:把复数残留修正成单数 +UPDATE storage_objects +SET bucket_name = 'workspace' +WHERE bucket_name = 'workspaces' + AND object_status = 'available'; + +-- 2b. local 部署:把绝对路径里的复数残留修正成单数 +UPDATE storage_objects +SET bucket_name = '/data/workspace' +WHERE bucket_name = '/data/workspaces' + AND object_status = 'available'; +``` + +> **只改 `available` 行**;`deleted` 行已经在 trash 里、bucket_name 会被新代码改写,无需处理。 +> 如果同时改 `version` / `run_log` 桶残留,按相同模式跑。 + +--- + +## 4. 本地验收清单 + +```bash +# 1. 语法 +uv run python -m compileall common/src backend/src runtime/src schedule/src + +# 2. 测试 +uv run --package backend pytest backend/tests -q +# 期望:30 passed + +# 3. 命名一致性 +grep -rn '"workspaces"\|"versions"\|"run-logs"' \ + --include='*.py' --include='*.yml' --include='*.env*' \ + backend/src common/src docker-compose.yml .env .env.example 2>/dev/null +# 期望:除 `__tablename__ = "workspaces"`(DB 表名)和 `auth.py` 的 JSON key 外,零结果 + +# 4. 单复数对齐 +grep -n 'S3_WORKSPACE_BUCKET\|S3_VERSION_BUCKET\|S3_RUN_LOG_BUCKET\|S3_TRASH_BUCKET' \ + .env.example +# 期望:全部 = 单数(workspace / version / run-log / trash) +``` + +--- + +## 5. 回归测试覆盖 + +| 场景 | 触发方式 | 期望 | +|---|---|---| +| 同名 script 不同父目录 | `test_soft_delete_object_sets_is_deleted` 旁边的新测试 | 两条 row 同名共存 | +| 软删 → 物理文件落到 trash 桶的 `source_purpose/{key}` | 手工:上传 → 删 → `mc ls s3/trash/` | 文件在;源桶里已删 | +| 软删后 `storage_objects` 7 字段同步 | 软删后 `SELECT * FROM storage_objects WHERE storage_object_id=...` | 见 §2.1 表格 | +| `restore_object` 把 trash 文件写回源桶 | 软删 → restore → 查源桶 | 源桶恢复;trash 文件保留(orphan) | +| `purge_trash_object` 拒绝误删源数据 | 手工:把 `bucket_name` 改成源桶后调 purge | 409 | + +--- + +## 6. 监控项(建议加) + +1. `storage_objects` 行 `bucket_name` 与 `actual_bucket_name(purpose)` 不一致的比例 + (按 `usage_type` 分组 COUNT)。生产环境应长期趋近于 0。 +2. `object_status='deleted'` 但 `bucket_name != actual_bucket_name('trash')` 的行数 — + 应为 0(软删失败回滚残留)。 +3. trash 桶目录大小 vs `object_status='deleted'` 行数(孤儿文件比例)。 + 没实现 reaper 前这个会单调上升。 + +--- + +## 7. 已知未实现项 + +- **Trash reaper**:`s3_trash_retention_days` 已声明,但代码没有扫表清理。 + `restore_object` 故意保留 trash 文件作 orphan,等 reaper 兜底。 + 第一次实现建议在 `schedule/src/schedule/` 加一个 tick job: + ```sql + SELECT storage_object_id FROM storage_objects + WHERE deleted_at < UTC_TIMESTAMP() - INTERVAL :retention_days DAY + AND object_status = 'deleted' + LIMIT 100; + ``` + 然后调 `/internal/v1/admin/trash/purge`。 +- **跨 backend trash 兼容**:当前只在 `item.storage_backend == settings.storage_backend` + 的行上做物理移动。异构 backend 的旧行(少)软删时只翻 DB 状态,trash 文件实际没移动。 + 远程 8.153.151.51:8890 应该都是同一 backend,目前无影响。 +- **历史 trash_key 兼容读**:新代码用 `item.object_key` 读 trash,**不**回退到 + `item.trash_key`。如果迁移前有按旧 `trash_key = f"{bucket_name}/{object_key}"` + 写入的已删行,`object_key` 还是源 key,restore 会找不到 trash 文件(404)。 + 处理:在 `restore_object` 加 fallback — 先按 `item.object_key` 试,失败再按 `item.trash_key` + 试一次(兼容窗口期)。建议在 reaper 上线后删除 fallback。 + +--- + +## 8. 仍未合并到 develop 的本地改动 + +```text +M frontend/vite.config.ts (4+/2-) +?? data/ (untracked — 本地 docker volume 残留,可忽略) +``` + +`frontend/vite.config.ts` 的改动不在本次 trash 修复范围内,等下次 commit 一起提。 \ No newline at end of file diff --git a/README.md b/README.md index f955d7f..a9fb334 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,26 @@ # Model Platform -A self-hosted **Jupyter-based model development platform** that combines -an interactive workspace, a DAG scheduler, an object-storage-backed artifact -store, and per-workspace runtime isolation — all behind a single Nginx -gateway. +自托管的 **Jupyter 模型开发平台**:交互式 workspace、DAG 调度、对象存储工件、 +按 workspace 隔离的运行时 — 全部经一个 Nginx 网关对外。 -> Stack: React Router SPA · FastAPI · APScheduler · MySQL · S3-compatible -> storage (or local filesystem via `STORAGE_BACKEND=local`) · shared -> Jupyter · FUSE mount via rclone (s3 mode only) -> Single ingress (Nginx :80); all other services are Docker-internal. +> 技术栈:React Router SPA · FastAPI · APScheduler · MySQL · S3 兼容存储 +> (或本地文件系统,`STORAGE_BACKEND=local`)· 共享 Jupyter · rclone FUSE 挂载(仅 s3 模式) +> 单一入口(Nginx :80);其他服务只在 Docker 内网互通。 -## What it does +## 它做什么 -| Capability | Where | +| 能力 | 位置 | |---|---| -| Workspace-scoped notebook editing with row-level lock | `backend/jupyter.py` + `scripts.is_locked` | -| Authenticated Jupyter routing (browser never sees the runtime token) | `nginx/default.conf` + `auth_request` + `backend/jupyter.py` | -| Object storage for notebooks / scripts / versions / run logs (s3 / local toggle) | `common/storage/` + `backend/scripts.py` | -| DAG-style scheduling: nodes, edges, cron, manual trigger, retries, snapshots | `backend/schedules.py` + `backend/schedule_runs.py` + `schedule/` (5 modules) | -| DAG execution via MySQL Outbox (no Redis, no in-process queues) | `schedule/orchestrator.py` + `schedule/worker.py` | -| Per-workspace Jupyter sub-process pool with asyncio locks | `runtime/process.py` | -| rclone FUSE mount of the workspace bucket into the runtime (s3 mode) | `runtime/mount.py` | -| MySQL-only persistence (26 tables, soft-delete, no foreign keys) | `common/db/models/` | +| workspace 内 notebook 编辑,行级锁 | `backend/jupyter.py` + `scripts.is_locked` | +| Jupyter 鉴权路由(浏览器永远拿不到 runtime token) | `nginx/default.conf` + `auth_request` + `backend/jupyter.py` | +| notebook / script / version / run_log 的对象存储(s3 / local 二选一) | `common/storage/` + `backend/scripts.py` | +| DAG 调度:节点、边、cron、手动触发、重试、快照 | `backend/schedules.py` + `backend/schedule_runs.py` + `schedule/`(5 个模块) | +| DAG 执行走 MySQL Outbox(无 Redis,无进程内队列) | `schedule/orchestrator.py` + `schedule/worker.py` | +| 每个 workspace 一个 Jupyter 子进程池,配 asyncio 锁 | `runtime/process.py` | +| runtime 内 rclone FUSE 把 workspace 桶挂上来(s3 模式) | `runtime/mount.py` | +| 仅 MySQL 持久化(26 张表,软删除,无外键) | `common/db/models/` | -## Architecture at a glance +## 架构一览 ``` ┌────────────────────┐ @@ -66,69 +63,67 @@ gateway. └───────────────────────────────┘ ``` -Object storage is selectable via `STORAGE_BACKEND` (s3 | local). In s3 mode -the 4 purpose-named buckets (`workspaces` / `versions` / `run-logs` / `trash`) -are S3 buckets; in local mode they're subdirectories of `LOCAL_STORAGE_BASE_DIR`, -shared via the `local-storage` Docker volume. See `DEVELOP.md` §Storage. +对象存储通过 `STORAGE_BACKEND`(s3 | local)二选一。s3 模式下 4 个 purpose 命名桶 +(`workspace` / `version` / `run-log` / `trash`)是独立的 S3 bucket;local 模式下 +是 `LOCAL_STORAGE_BASE_DIR` 的子目录,通过 Docker volume `local-storage` 共享。 +详见 `DEVELOP.md` §存储。 -Detailed design lives in `ARCHITECTURE.md`. Implementation deviations and -recent refactors are recorded in `HANDOVER.md`. +详细设计见 `ARCHITECTURE.md`。实现的偏离和近期重构记录在 `HANDOVER.md`。 -## Repository layout +## 目录结构 ```text frontend/ React Router SPA -backend/ FastAPI: public API + internal storage API -runtime/ Jupyter subprocess manager + rclone FUSE -schedule/ DAG scheduler (5 modules: context/scheduler/ - orchestrator/worker/service) -common/ Settings, SQLAlchemy models, storage SDK, - outbox events, jobstore -migrations/ Alembic baseline + per-feature revisions -nginx/ (concept only — see "Container" below) -scripts/ nginx-entrypoint.sh (template renderer) -docker-compose.yml 4 services — web / backend / runtime / schedule -default.conf Nginx template (mounted, rendered at start) -.env.example All env vars consumed by common.config.Settings +backend/ FastAPI:公开 API + 内部存储 API +runtime/ Jupyter 子进程管理 + rclone FUSE +schedule/ DAG 调度器(5 模块:context/scheduler/ + orchestrator/worker/service) +common/ 配置、SQLAlchemy 模型、存储 SDK、 + outbox 事件、jobstore +migrations/ Alembic 基线 + 各特性 migration +nginx/ (仅概念 — 见下方「容器」一节) +scripts/ nginx-entrypoint.sh(模板渲染) +docker-compose.yml 4 服务 — web / backend / runtime / schedule +default.conf Nginx 模板(挂载,启动时渲染) +.env.example common.config.Settings 消费的所有环境变量 ``` -## Containers +## 容器 -| Service | Image | Exposed | Purpose | +| 服务 | 镜像 | 暴露 | 用途 | |---|---|---|---| -| `web` | `nginx:alpine` | host `:8888` → `:80` | SPA, `/api/` reverse-proxy, `/jupyter/{ws}/` auth_request proxy, `/storage/` S3 passthrough (s3 mode only) | -| `backend` | `Dockerfile` | internal only | DAG CRUD, script CRUD, schedule triggers, `/api/v1/auth/jupyter`, `/internal/v1/*` storage control plane | -| `runtime` | `Dockerfile` | internal only | Per-workspace Jupyter sub-process pool, rclone FUSE mount of `workspaces` bucket (s3 mode) | -| `schedule` | `Dockerfile` | internal only | Cron tick + DAG execution via MySQL Outbox polling | +| `web` | `nginx:alpine` | 宿主机 `:8888` → `:80` | SPA、`/api/` 反向代理、`/jupyter/{ws}/` auth_request 代理、`/storage/` S3 直通(仅 s3 模式) | +| `backend` | `Dockerfile` | 仅内网 | DAG CRUD、script CRUD、schedule 触发、`/api/v1/auth/jupyter`、`/internal/v1/*` 存储控制面 | +| `runtime` | `Dockerfile` | 仅内网 | 每个 workspace 一个 Jupyter 子进程池、rclone FUSE 挂载 `workspace` 桶(s3 模式) | +| `schedule` | `Dockerfile` | 仅内网 | cron tick + DAG 执行(轮询 MySQL Outbox) | -The architecture **deliberately has only one host port** (the gateway); -all other services are on the Docker internal network. This is enforced in -`docker-compose.yml` — no `ports:` on backend / runtime / schedule. +架构**故意只暴露一个宿主机端口**(网关);其他服务都在 Docker 内网。 +这一点在 `docker-compose.yml` 里强制执行 — backend / runtime / schedule 都没有 `ports:`。 -## Quick start +## 快速启动 ```bash cp .env.example .env -# Edit .env — at minimum change MYSQL password and (in s3 mode) S3 credentials. +# 编辑 .env — 至少改 MYSQL 密码,以及(s3 模式下)S3 凭据。 -# Static check +# 静态检查 uv sync --all-packages uv run --frozen --package backend python -m compileall -q backend/src common/src uv run --frozen --package schedule python -m compileall -q schedule/src uv run --frozen --package runtime python -m compileall -q runtime/src -# Apply schema +# 应用 schema uv run --frozen --package backend alembic upgrade head -# Bring up the stack -docker compose config # validate +# 启动整套服务 +docker compose config # 校验 docker compose up -d --build docker compose ps ``` -Visit `http://localhost:8888`. +访问 `http://localhost:8888`。 -### Logs +### 日志 ```bash docker compose logs -f backend @@ -136,47 +131,43 @@ docker compose logs -f schedule docker compose logs -f runtime ``` -### Tear down (keeps MySQL + S3 / local-storage volumes) +### 停服(保留 MySQL + S3 / local-storage 数据卷) ```bash docker compose down ``` -### Wipe data +### 抹数据 ```bash docker compose down -v ``` -## Configuration +## 配置 -All environment variables are declared once in `common/src/common/config.py` -as a pydantic-settings `Settings` class, with a `@lru_cache` singleton. -Adding a new env var: +所有环境变量在 `common/src/common/config.py` 里用 pydantic-settings 的 `Settings` +类一次性声明,外面套一层 `@lru_cache` 单例。新增环境变量: -1. Add the field to `Settings` in `common/src/common/config.py` (with a - sensible default so dev-env "just works"). -2. Add the line to `.env.example` with a comment. -3. Use `settings.` at the call site. Never `os.environ["..."]`. +1. 在 `common/src/common/config.py` 的 `Settings` 里加字段(带合理 default,使 dev 启动不需要设) +2. 在 `.env.example` 加一行带注释 +3. 调用点用 `settings.`,永远不要用 `os.environ["..."]` -See `DEVELOP.md` for the full list of variables and their meanings. +完整环境变量列表和含义见 `DEVELOP.md`。 -## Storage layout +## 存储布局 -Four purpose-named buckets. The mapping from `StorageObjects.usage_type` -to bucket is decided in **one place** (`backend/storage_api.py:resolve_bucket`): +4 个 purpose 命名桶。从 `StorageObjects.usage_type` 到桶的映射由 +**单一入口**(`backend/storage_api.py:resolve_bucket`)决定: -| `usage_type` | Bucket (env var) | Default name | +| `usage_type` | 桶(环境变量) | 默认名 | |---|---|---| -| `working_copy`, `public_script`, `data_resource`, `snapshot` | `S3_WORKSPACE_BUCKET` | `workspaces` | -| `version_artifact` | `S3_VERSION_BUCKET` | `versions` | -| `run_log`, `run_result` | `S3_RUN_LOG_BUCKET` | `run-logs` | -| (soft-delete target) | `S3_TRASH_BUCKET` | `trash` | +| `working_copy`、`public_script`、`data_resource`、`snapshot` | `S3_WORKSPACE_BUCKET` | `workspace` | +| `version_artifact` | `S3_VERSION_BUCKET` | `version` | +| `run_log`、`run_result` | `S3_RUN_LOG_BUCKET` | `run-log` | +| (软删除目标) | `S3_TRASH_BUCKET` | `trash` | -In `STORAGE_BACKEND=s3` mode these are 4 separate S3 buckets. In -`STORAGE_BACKEND=local` mode they are 4 subdirectories under -`LOCAL_STORAGE_BASE_DIR` (default `/data`), so the layout above -becomes: +`STORAGE_BACKEND=s3` 模式下是 4 个独立 S3 桶。`STORAGE_BACKEND=local` 模式下 +是 `LOCAL_STORAGE_BASE_DIR`(默认 `/data`)下的 4 个子目录: ``` /data/ @@ -186,36 +177,31 @@ becomes: └── trash/ # S3_TRASH_BUCKET ``` -A workspace's `artifact_bucket` column (when non-null) overrides the -default for that workspace, regardless of `usage_type` — useful for -isolating a paying customer to their own bucket. +某个 workspace 的 `artifact_bucket` 列(非 NULL 时)覆盖该 workspace 的默认桶, +无视 `usage_type` — 适合把付费客户隔离到专属桶。 -The object key is a flat two-level path — `workspace_id` and a server- -issued `ulid` for the object: +对象 key 是两层扁平路径 — `workspace_id` 加服务端签发的 `ulid`: ``` //{.} ``` -The file name, extension, content type, and logical path live in the -`StorageObjects` and `Scripts` rows, not in the object key, so the -storage can be re-organised without a database rewrite. +文件名、扩展名、MIME、逻辑路径都放在 `StorageObjects` 和 `Scripts` 行里,不进 +object key — 重新组织存储不需要重写数据库。 -Backend code never writes to the container's local filesystem (except -in `STORAGE_BACKEND=local` mode, where the shared `local-storage` volume -is the canonical store). Schedule Executor stages node artifacts in -`tempfile.TemporaryDirectory()` (auto-cleaned). Only the `runtime` -container keeps a host volume — required by the rclone FUSE mount in -s3 mode, and a no-op pass-through in local mode. +Backend 代码从不写容器本地文件系统(`STORAGE_BACKEND=local` 模式除外,那里共享 +`local-storage` volume 就是规范存储)。Schedule Executor 在 `tempfile.TemporaryDirectory()` +里暂存节点工件(自动清理)。只有 `runtime` 容器保留宿主 volume — s3 模式下 rclone FUSE +挂载需要;local 模式下是 no-op 透传。 -## Documentation +## 文档 -- `README.md` (this file) — quick orientation -- `ARCHITECTURE.md` — design diagrams + simplification history -- `HANDOVER.md` — implementation deviations, recent refactors, pending work -- `DEVELOP.md` — developer guide (env vars, code conventions, common tasks) -- `CLAUDE.md` — agent-facing conventions for the repo +- `README.md`(本文)— 快速导读 +- `ARCHITECTURE.md` — 设计图 + 简化历史 +- `HANDOVER.md` — 实现偏离、近期重构、待办事项 +- `DEVELOP.md` — 开发指南(环境变量、代码规约、常用操作) +- `CLAUDE.md` — agent 面向的本仓库规约 -## License +## 许可 -Internal. +内部。 \ No newline at end of file