hrBP_2ehBt1?`~ypvg_Ot4x1V+43P@Ve8>qd)9NX_jWdLo`Zfy
zoeam9)@Dpym{4m@+LNxXBPjPKA7{3a&H+~xQvr>C_A;7=JrfK~$M2pCh>|xLz>W6SCs4qC|#V`)#
z)0C|?$o>jzh<|-cpfK7osU{Xp5PG4-K+L2G=)c3f&}H&M3wo7TlO_UJjQ-Oq&_
zjAc9=nNIYz{c3zxOiS5UfcE1}8#iI4@uy;$Q7>}u`j+OU0N<*Ezx$k{x_27+{s2Eg
z`^=rhtIzCm!_UcJ?Db~Lh-=_))PT3{Q0{Mwdq;0>ZL%l3+;B&4!&xm#%HYAK|;b456Iv&&f$VQHf`
z>$*K9w8T+paVwc7fLfMlhQ4)*zL_SG{~v4QR;IuX-(oRtYAhWOlh`NLoX0k$RUYMi
z2Y!bqpdN}wz8q`-%>&Le@q|jFw92ErW-hma-le?S
z-@OZt2EEUm4wLsuEMkt4zlyy29_3S50JAcQHTtgTC{P~%-mvCTzrjXOc|{}N`Cz`W
zSj7CrXfa7lcsU0J(0uSX6G`54t^7}+OLM0n(|g4waOQ}bd3%!XLh?NX9|8G_|06Ie
zD5F1)w5I~!et7lA{G^;uf7aqT`KE&2qx9|~O;s6t!gb`+zVLJyT2T)l*8l(j
literal 0
HcmV?d00001
diff --git a/.pnpm-store/v11/projects/2dad80fd1dced2194639b53db6e470b3/react-router.config.ts b/.pnpm-store/v11/projects/2dad80fd1dced2194639b53db6e470b3/react-router.config.ts
new file mode 100644
index 0000000..b8b143a
--- /dev/null
+++ b/.pnpm-store/v11/projects/2dad80fd1dced2194639b53db6e470b3/react-router.config.ts
@@ -0,0 +1,7 @@
+import type { Config } from "@react-router/dev/config";
+
+export default {
+ // Config options...
+ // Server-side render by default, to enable SPA mode set this to `false`
+ ssr: false,
+} satisfies Config;
diff --git a/.pnpm-store/v11/projects/2dad80fd1dced2194639b53db6e470b3/tsconfig.json b/.pnpm-store/v11/projects/2dad80fd1dced2194639b53db6e470b3/tsconfig.json
new file mode 100644
index 0000000..cbe49c7
--- /dev/null
+++ b/.pnpm-store/v11/projects/2dad80fd1dced2194639b53db6e470b3/tsconfig.json
@@ -0,0 +1,26 @@
+{
+ "include": [
+ "**/*",
+ "**/.server/**/*",
+ "**/.client/**/*",
+ ".react-router/types/**/*"
+ ],
+ "compilerOptions": {
+ "lib": ["DOM", "DOM.Iterable", "ES2022"],
+ "types": ["node", "vite/client"],
+ "target": "ES2022",
+ "module": "ES2022",
+ "moduleResolution": "bundler",
+ "jsx": "react-jsx",
+ "rootDirs": [".", "./.react-router/types"],
+ "paths": {
+ "~/*": ["./app/*"]
+ },
+ "esModuleInterop": true,
+ "verbatimModuleSyntax": true,
+ "noEmit": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "strict": true
+ }
+}
diff --git a/.pnpm-store/v11/projects/2dad80fd1dced2194639b53db6e470b3/vite.config.ts b/.pnpm-store/v11/projects/2dad80fd1dced2194639b53db6e470b3/vite.config.ts
new file mode 100644
index 0000000..cd65060
--- /dev/null
+++ b/.pnpm-store/v11/projects/2dad80fd1dced2194639b53db6e470b3/vite.config.ts
@@ -0,0 +1,21 @@
+import { reactRouter } from "@react-router/dev/vite";
+import { defineConfig } from "vite";
+
+export default defineConfig({
+ plugins: [reactRouter()],
+ server: {
+ host: "0.0.0.0",
+ port: 5173,
+ proxy: {
+ "/api": {
+ target: process.env.VITE_GATEWAY_URL || "http://127.0.0.1:8081",
+ changeOrigin: true,
+ },
+ "/jupyter": {
+ target: process.env.VITE_GATEWAY_URL || "http://127.0.0.1:8081",
+ changeOrigin: true,
+ ws: true,
+ },
+ },
+ },
+});
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
new file mode 100644
index 0000000..633687e
--- /dev/null
+++ b/ARCHITECTURE.md
@@ -0,0 +1,39 @@
+# 简化系统架构
+
+```text
+Browser
+ |
+ v
+Nginx Gateway (静态 React Router SPA + /api + /jupyter 代理)
+ |-----------------------------|
+ | /api/v1 | /jupyter/
+ v v
+FastAPI Backend Shared Jupyter Server
+ | ^
+ | Runtime HTTP | Runtime 管理会话/票据
+ v |
+Runtime Manager ---------------|
+ |
+ +------ MySQL(编辑租约、运行实例)
+
+FastAPI Backend
+ | 1. 写 schedule_runs + outbox_events
+ | 2. 尝试 HTTP 立即推送
+ v
+Schedule Executor(APScheduler)
+ |-- MySQL APSchedulerJobStore
+ |-- MySQL Outbox 轮询兜底
+ |-- DAG 节点执行与重试
+ |-- RustFS 日志/结果
+ +-- Backend 内部 Storage API
+```
+
+## 关键简化
+
+1. 删除 Redis 服务、Redis Streams 和 Redis 文件锁。
+2. 调度定义、运行记录、Outbox、Inbox、Cron JobStore 都由 MySQL 保存。
+3. 立即运行采用 Backend -> Schedule Executor 内部 HTTP 推送;推送失败由 MySQL Outbox 轮询兜底。
+4. Schedule Executor 自带 APScheduler,负责 Cron 触发和 DAG 执行。
+5. 文件编辑锁改为 MySQL 租约,Runtime 单副本运行。
+6. Jupyter 使用一个共享容器,工作区目录通过 Volume 挂载同步。
+7. 前端改为 React Router SPA,并按 feature / route / service / component 分层。
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..32cb110
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,64 @@
+# Repository Guide
+
+## Current architecture
+
+- `frontend`: React Router SPA. Production files are built in `nginx/Dockerfile`.
+- `backend`: public FastAPI API and internal RustFS storage API in one process.
+- `runtime`: shared Jupyter lifecycle, MySQL edit leases and short-lived in-memory access tickets.
+- `schedule`: APScheduler, MySQL JobStore, MySQL Outbox polling and DAG execution.
+- `common`: SQLAlchemy models, database/session helpers, IDs and object-store helpers.
+- `migrations`: Alembic schema and seed migrations.
+- `nginx`: static frontend, `/api/` proxy and authenticated `/jupyter/` proxy.
+
+Redis and the former separate Storage API container are intentionally removed.
+
+## Commands
+
+From the repository root:
+
+```bash
+# Local Python workspace
+uv sync --all-packages
+
+# Static Python check
+python -m compileall common/src backend/src runtime/src schedule/src
+
+# Database migration
+uv run --package backend alembic upgrade head
+
+# Full Docker stack
+cp .env.example .env
+docker compose config
+docker compose up -d --build
+```
+
+Frontend development:
+
+```bash
+cd frontend
+pnpm install
+pnpm dev
+pnpm typecheck
+pnpm build
+```
+
+## Service rules
+
+- Browser traffic enters through Gateway only.
+- Frontend API calls use same-origin `/api/v1/...` paths.
+- Backend writes `schedule_runs` and `outbox_events`, then performs best-effort HTTP dispatch to Schedule Executor.
+- Schedule Executor always polls pending MySQL Outbox rows, so HTTP dispatch failure does not lose a task.
+- Cron jobs are persisted by APScheduler in MySQL table `apscheduler_jobs`.
+- Runtime must stay single-replica while file leases and Jupyter tickets use the simplified implementation.
+- Never expose the internal Jupyter token to the browser.
+- Never delete Docker volumes when preserving MySQL or RustFS data is required.
+
+## Main entrypoints
+
+```text
+frontend/app/routes/platform.tsx
+backend/src/backend/main.py
+runtime/src/runtime/main.py
+schedule/src/schedule/main.py
+nginx/default.conf.template
+```
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..c8f71cd
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,22 @@
+.PHONY: sync backend runtime schedule migrate up down
+
+sync:
+ uv sync --all-packages
+
+backend:
+ uv run --package backend uvicorn backend.main:app --host 0.0.0.0 --port 8010 --reload
+
+runtime:
+ uv run --package runtime uvicorn runtime.main:app --host 0.0.0.0 --port 8012 --reload
+
+schedule:
+ uv run --package schedule uvicorn schedule.main:app --host 0.0.0.0 --port 8013 --reload
+
+migrate:
+ uv run --package backend alembic upgrade head
+
+up:
+ docker compose up -d --build
+
+down:
+ docker compose down
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..85dbcc9
--- /dev/null
+++ b/README.md
@@ -0,0 +1,69 @@
+# Model Platform Refactored
+
+本项目是简化后的模型实验开发平台:React Router 前端、FastAPI Backend、
+Runtime、Schedule Executor、MySQL、RustFS、共享 Jupyter 与 Nginx Gateway。
+Redis 已移除。
+
+## 目录
+
+```text
+frontend/app/
+ components/ 通用组件
+ features/platform/ 平台主框架与脚本工作台
+ features/schedules/ 调度画布
+ features/admin/ 工作台与系统管理
+ routes/ React Router 路由
+ services/ API 调用与 DTO
+backend/ 核心业务 API
+runtime/ Jupyter 与文件编辑租约
+schedule/ APScheduler + DAG Executor
+common/ 数据库模型和公共能力
+nginx/ 前端静态站点与统一反向代理
+```
+
+## 简化后的调用链
+
+```text
+Browser -> Nginx Gateway -> FastAPI Backend -> MySQL / RustFS
+ -> Runtime -> shared Jupyter
+Backend --HTTP push-----> Schedule Executor
+Schedule Executor --poll MySQL Outbox / APScheduler--> execute DAG
+```
+
+## 启动
+
+Windows PowerShell:
+
+```powershell
+Copy-Item .env.example .env
+docker compose config
+docker compose up -d --build
+docker compose ps
+```
+
+Git Bash/Linux:
+
+```bash
+cp .env.example .env
+docker compose config
+docker compose up -d --build
+docker compose ps
+```
+
+访问:`http://127.0.0.1:8081`。
+
+查看日志:
+
+```bash
+docker compose logs -f backend runtime schedule gateway
+```
+
+数据库迁移由 `migrate` 一次性容器在 Backend 启动前自动执行。
+
+## Windows 快速脚本
+
+```powershell
+.\scripts\start.ps1
+.\scripts\logs.ps1
+.\scripts\stop.ps1
+```
diff --git a/REFACTOR_NOTES.md b/REFACTOR_NOTES.md
new file mode 100644
index 0000000..4b17b93
--- /dev/null
+++ b/REFACTOR_NOTES.md
@@ -0,0 +1,56 @@
+# 本次重构说明
+
+## 已完成
+
+### 前端
+
+- 将原 `frontend/src` 页面迁入 React Router SPA 结构;
+- 主入口改为 `app/routes/platform.tsx`;
+- 拆分为 `components / features / routes / services / styles`;
+- 保留脚本管理、目录管理、Jupyter 编辑、版本发布、调度画布、运行记录和系统管理功能;
+- 将原 Hash 页面切换改为 React Router 路径:`/`、`/scripts`、`/schedules`、`/system`;
+- Nginx 使用 SPA fallback,刷新子路径不会 404。
+
+### 后端
+
+- 合并 Platform API 和 Storage API 到同一个 `backend` 进程;
+- 删除 Redis 容器、依赖、Streams 消费和锁实现;
+- 文件锁改为 MySQL `edit_sessions` 租约;
+- Jupyter 短期票据由单 Runtime 进程内存保存;
+- Schedule Executor 内置 APScheduler,并使用 MySQL `apscheduler_jobs`;
+- Backend 创建运行和 Outbox 后,通过内部 HTTP 尝试立即推送;
+- Schedule Executor 轮询 MySQL Outbox 作为失败兜底;
+- 增加迁移 `20260730_0004`,兼容旧数据库的 `redis_lock_key -> lock_key`。
+
+### Docker
+
+最终容器:
+
+```text
+mysql
+rustfs
+jupyter
+migrate(一次性)
+backend
+runtime
+schedule
+gateway
+```
+
+## 已执行检查
+
+- Python 全项目 `compileall` 通过;
+- SQLAlchemy 模型导入通过,共加载 26 张表;
+- Alembic `upgrade head --sql` 离线生成通过;
+- `docker-compose.yml` YAML 解析通过;
+- 前端 feature、route 和 root 文件 TypeScript 静态检查通过。
+
+## 未在当前沙箱执行
+
+当前执行环境没有 Docker 命令,并且无法连接 npm/PyPI,因此没有在沙箱内完成:
+
+- `docker compose up --build`;
+- 正式 `pnpm install && pnpm build`;
+- Python 依赖在线安装后的集成测试。
+
+请在安装了 Docker Desktop且网络可访问依赖仓库的 Windows 电脑上执行根目录 README 中的启动命令。
diff --git a/alembic.ini b/alembic.ini
new file mode 100644
index 0000000..c1c4732
--- /dev/null
+++ b/alembic.ini
@@ -0,0 +1,41 @@
+[alembic]
+script_location = %(here)s/migrations
+prepend_sys_path = .
+path_separator = os
+
+# 真实连接串必须通过 DATABASE_URL 注入,禁止在仓库内保存数据库密码。
+sqlalchemy.url = driver://user:pass@localhost/dbname
+
+[loggers]
+keys = root,sqlalchemy,alembic
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = WARN
+handlers = console
+qualname =
+
+[logger_sqlalchemy]
+level = WARN
+handlers =
+qualname = sqlalchemy.engine
+
+[logger_alembic]
+level = INFO
+handlers =
+qualname = alembic
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatter_generic]
+format = %(levelname)-5.5s [%(name)s] %(message)s
+datefmt = %H:%M:%S
diff --git a/backend/.gitignore b/backend/.gitignore
new file mode 100644
index 0000000..505a3b1
--- /dev/null
+++ b/backend/.gitignore
@@ -0,0 +1,10 @@
+# Python-generated files
+__pycache__/
+*.py[oc]
+build/
+dist/
+wheels/
+*.egg-info
+
+# Virtual environments
+.venv
diff --git a/backend/.python-version b/backend/.python-version
new file mode 100644
index 0000000..e4fba21
--- /dev/null
+++ b/backend/.python-version
@@ -0,0 +1 @@
+3.12
diff --git a/backend/Dockerfile b/backend/Dockerfile
new file mode 100644
index 0000000..a76c832
--- /dev/null
+++ b/backend/Dockerfile
@@ -0,0 +1,13 @@
+FROM python:3.12-slim-bookworm
+
+ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PYTHONPATH=/app
+WORKDIR /app
+COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
+COPY common ./common
+COPY backend ./backend
+COPY alembic.ini ./
+COPY migrations ./migrations
+RUN uv pip install --system ./common ./backend
+
+EXPOSE 8000
+CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"]
diff --git a/backend/README.md b/backend/README.md
new file mode 100644
index 0000000..eea5476
--- /dev/null
+++ b/backend/README.md
@@ -0,0 +1,5 @@
+# Backend
+
+统一 FastAPI 管理服务。包含用户、Workspace、脚本、稳定版本、调度定义、
+立即运行以及 RustFS 对象接口。原 `platform_api` 与 `storage_api` 已在此
+模块合并,外部 REST 契约保持不变。
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
new file mode 100644
index 0000000..f8a6b83
--- /dev/null
+++ b/backend/pyproject.toml
@@ -0,0 +1,23 @@
+[project]
+name = "backend"
+version = "0.2.0"
+requires-python = ">=3.12"
+dependencies = [
+ "common",
+ "fastapi==0.116.1",
+ "uvicorn[standard]==0.35.0",
+ "httpx==0.28.1",
+ "croniter==6.2.4",
+ "alembic==1.18.5",
+ "cryptography==49.0.0",
+]
+
+[tool.uv.sources]
+common = { path = "../common" }
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.build.targets.wheel]
+packages = ["src/backend"]
diff --git a/backend/src/backend/__init__.py b/backend/src/backend/__init__.py
new file mode 100644
index 0000000..d90a9a5
--- /dev/null
+++ b/backend/src/backend/__init__.py
@@ -0,0 +1 @@
+"""Platform API application."""
diff --git a/backend/src/backend/admin.py b/backend/src/backend/admin.py
new file mode 100644
index 0000000..d911bd7
--- /dev/null
+++ b/backend/src/backend/admin.py
@@ -0,0 +1,218 @@
+from __future__ import annotations
+
+from typing import Any, Literal
+
+from fastapi import APIRouter, Depends, HTTPException, status
+from pydantic import BaseModel, ConfigDict, Field
+from sqlalchemy import delete, func, or_, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from common.db.models import Roles, Users, WorkspaceMembers
+from common.ids import new_ulid
+from backend.dependencies import (
+ RequestContext,
+ database_session,
+ request_context,
+)
+
+
+router = APIRouter(prefix="/api/v1/admin", tags=["admin"])
+
+
+class EmployeeCreate(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+
+ username: str = Field(min_length=2, max_length=64)
+ display_name: str = Field(min_length=1, max_length=100)
+ email: str | None = Field(default=None, max_length=255)
+ role_code: Literal["admin", "developer"] = "developer"
+
+
+class EmployeeUpdate(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+
+ display_name: str | None = Field(default=None, min_length=1, max_length=100)
+ email: str | None = Field(default=None, max_length=255)
+ role_code: Literal["admin", "developer"] | None = None
+ status: Literal["active", "disabled", "locked"] | None = None
+
+
+def require_admin(context: RequestContext) -> None:
+ if not context.is_admin:
+ raise HTTPException(status.HTTP_403_FORBIDDEN, "仅管理员可以管理员工")
+
+
+def employee_payload(user: Users, role: Roles) -> dict[str, Any]:
+ return {
+ "user_id": user.user_id,
+ "username": user.username,
+ "display_name": user.display_name,
+ "email": user.email,
+ "status": user.status,
+ "role_code": role.role_code,
+ "role_name": role.role_name,
+ "created_at": user.created_at.isoformat(),
+ }
+
+
+async def member_row(
+ user_id: str,
+ context: RequestContext,
+ session: AsyncSession,
+) -> tuple[Users, WorkspaceMembers, Roles]:
+ row = (
+ await session.execute(
+ select(Users, WorkspaceMembers, Roles)
+ .join(
+ WorkspaceMembers,
+ WorkspaceMembers.user_id == Users.user_id,
+ )
+ .join(Roles, Roles.role_id == WorkspaceMembers.role_id)
+ .where(
+ WorkspaceMembers.workspace_id == context.workspace.workspace_id,
+ Users.user_id == user_id,
+ )
+ )
+ ).first()
+ if row is None:
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "员工不存在")
+ return row
+
+
+@router.get("/employees")
+async def list_employees(
+ context: RequestContext = Depends(request_context),
+ session: AsyncSession = Depends(database_session),
+) -> dict[str, Any]:
+ rows = (
+ await session.execute(
+ select(Users, Roles)
+ .join(
+ WorkspaceMembers,
+ WorkspaceMembers.user_id == Users.user_id,
+ )
+ .join(Roles, Roles.role_id == WorkspaceMembers.role_id)
+ .where(
+ WorkspaceMembers.workspace_id == context.workspace.workspace_id,
+ WorkspaceMembers.member_status == "active",
+ )
+ .order_by(Users.created_at, Users.user_id)
+ )
+ ).all()
+ return {
+ "request_id": context.request_id,
+ "data": [employee_payload(user, role) for user, role in rows],
+ "meta": {"count": len(rows), "can_manage": context.is_admin},
+ }
+
+
+@router.post("/employees", status_code=status.HTTP_201_CREATED)
+async def create_employee(
+ payload: EmployeeCreate,
+ context: RequestContext = Depends(request_context),
+ session: AsyncSession = Depends(database_session),
+) -> dict[str, Any]:
+ require_admin(context)
+ username = payload.username.strip()
+ display_name = payload.display_name.strip()
+ duplicate_conditions = [Users.username == username]
+ if payload.email:
+ duplicate_conditions.append(Users.email == payload.email.strip())
+ duplicate = await session.scalar(
+ select(Users.user_id).where(or_(*duplicate_conditions))
+ )
+ if duplicate is not None:
+ raise HTTPException(status.HTTP_409_CONFLICT, "用户名或邮箱已存在")
+ role = await session.scalar(
+ select(Roles).where(Roles.role_code == payload.role_code)
+ )
+ if role is None:
+ raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "角色不存在")
+ user = Users(
+ user_id=new_ulid(),
+ username=username,
+ display_name=display_name,
+ email=payload.email.strip() if payload.email else None,
+ password_hash="demo-login-disabled",
+ status="active",
+ )
+ session.add(user)
+ session.add(
+ WorkspaceMembers(
+ workspace_id=context.workspace.workspace_id,
+ user_id=user.user_id,
+ role_id=role.role_id,
+ member_status="active",
+ )
+ )
+ await session.flush()
+ await session.refresh(user)
+ return {
+ "request_id": context.request_id,
+ "data": employee_payload(user, role),
+ "meta": {},
+ }
+
+
+@router.patch("/employees/{user_id}")
+async def update_employee(
+ user_id: str,
+ payload: EmployeeUpdate,
+ context: RequestContext = Depends(request_context),
+ session: AsyncSession = Depends(database_session),
+) -> dict[str, Any]:
+ require_admin(context)
+ user, membership, role = await member_row(user_id, context, session)
+ if payload.display_name is not None:
+ user.display_name = payload.display_name.strip()
+ if payload.email is not None:
+ user.email = payload.email.strip() or None
+ if payload.status is not None:
+ user.status = payload.status
+ if payload.role_code is not None and payload.role_code != role.role_code:
+ next_role = await session.scalar(
+ select(Roles).where(Roles.role_code == payload.role_code)
+ )
+ if next_role is None:
+ raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "角色不存在")
+ membership.role_id = next_role.role_id
+ role = next_role
+ await session.flush()
+ await session.refresh(user)
+ return {
+ "request_id": context.request_id,
+ "data": employee_payload(user, role),
+ "meta": {},
+ }
+
+
+@router.delete("/employees/{user_id}")
+async def delete_employee(
+ user_id: str,
+ context: RequestContext = Depends(request_context),
+ session: AsyncSession = Depends(database_session),
+) -> dict[str, Any]:
+ require_admin(context)
+ if user_id == context.user.user_id:
+ raise HTTPException(status.HTTP_409_CONFLICT, "不能删除当前登录员工")
+ user, _, role = await member_row(user_id, context, session)
+ if role.role_code == "admin":
+ raise HTTPException(status.HTTP_409_CONFLICT, "管理员账号不能删除")
+ await session.execute(
+ delete(WorkspaceMembers).where(
+ WorkspaceMembers.workspace_id == context.workspace.workspace_id,
+ WorkspaceMembers.user_id == user_id,
+ )
+ )
+ memberships = await session.scalar(
+ select(func.count())
+ .select_from(WorkspaceMembers)
+ .where(WorkspaceMembers.user_id == user_id)
+ )
+ if memberships == 0:
+ user.status = "disabled"
+ return {
+ "request_id": context.request_id,
+ "data": {"user_id": user_id, "deleted": True},
+ "meta": {},
+ }
diff --git a/backend/src/backend/dependencies.py b/backend/src/backend/dependencies.py
new file mode 100644
index 0000000..f646a77
--- /dev/null
+++ b/backend/src/backend/dependencies.py
@@ -0,0 +1,79 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import AsyncIterator
+
+from fastapi import Header, HTTPException, Request, status
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from common.db.models import (
+ Roles,
+ Users,
+ WorkspaceMembers,
+ Workspaces,
+)
+from common.ids import new_ulid
+
+
+@dataclass(frozen=True)
+class RequestContext:
+ request_id: str
+ user: Users
+ workspace: Workspaces
+ role: Roles
+
+ @property
+ def is_admin(self) -> bool:
+ return self.role.role_code == "admin"
+
+
+async def database_session(request: Request) -> AsyncIterator[AsyncSession]:
+ async with request.app.state.session_factory() as session:
+ try:
+ yield session
+ await session.commit()
+ except Exception:
+ await session.rollback()
+ raise
+
+
+async def request_context(
+ request: Request,
+ x_user_id: str = Header(alias="X-User-ID"),
+ x_workspace_id: str = Header(alias="X-Workspace-ID"),
+ x_request_id: str | None = Header(default=None, alias="X-Request-ID"),
+) -> RequestContext:
+ async with request.app.state.session_factory() as session:
+ statement = (
+ select(Users, Workspaces, Roles)
+ .join(
+ WorkspaceMembers,
+ WorkspaceMembers.user_id == Users.user_id,
+ )
+ .join(
+ Workspaces,
+ Workspaces.workspace_id == WorkspaceMembers.workspace_id,
+ )
+ .join(Roles, Roles.role_id == WorkspaceMembers.role_id)
+ .where(
+ Users.user_id == x_user_id,
+ Users.status == "active",
+ WorkspaceMembers.workspace_id == x_workspace_id,
+ WorkspaceMembers.member_status == "active",
+ Workspaces.status == "active",
+ )
+ )
+ row = (await session.execute(statement)).one_or_none()
+ if row is None:
+ raise HTTPException(
+ status.HTTP_403_FORBIDDEN,
+ "active workspace membership is required",
+ )
+ user, workspace, role = row
+ return RequestContext(
+ request_id=x_request_id or new_ulid(),
+ user=user,
+ workspace=workspace,
+ role=role,
+ )
diff --git a/backend/src/backend/file_locks.py b/backend/src/backend/file_locks.py
new file mode 100644
index 0000000..7a82cb3
--- /dev/null
+++ b/backend/src/backend/file_locks.py
@@ -0,0 +1,110 @@
+from __future__ import annotations
+
+from typing import Any, Awaitable, Callable
+
+from fastapi import APIRouter, Depends, Request, status
+from fastapi.responses import JSONResponse
+
+from backend.dependencies import (
+ RequestContext,
+ request_context,
+)
+from backend.runtime_client import RuntimeClientError
+from backend.schemas import FileLockTokenRequest
+
+
+router = APIRouter(tags=["file-locks"])
+
+
+async def runtime_response(
+ context: RequestContext,
+ operation: Callable[[], Awaitable[dict[str, Any]]],
+ *,
+ success_status: int = status.HTTP_200_OK,
+) -> JSONResponse:
+ try:
+ data = await operation()
+ except RuntimeClientError as exc:
+ error = exc.detail
+ if not isinstance(error, dict) or "code" not in error:
+ error = {
+ "code": "RUNTIME_REQUEST_FAILED",
+ "message": str(error),
+ "retryable": exc.status_code >= 500,
+ "details": {},
+ }
+ return JSONResponse(
+ status_code=exc.status_code,
+ content={"request_id": context.request_id, "error": error},
+ )
+ return JSONResponse(
+ status_code=success_status,
+ content={
+ "request_id": context.request_id,
+ "data": data,
+ "meta": {},
+ },
+ )
+
+
+@router.post(
+ "/api/v1/files/{storage_object_id}/lock",
+ status_code=status.HTTP_201_CREATED,
+)
+async def acquire_file_lock(
+ storage_object_id: str,
+ request: Request,
+ context: RequestContext = Depends(request_context),
+) -> JSONResponse:
+ return await runtime_response(
+ context,
+ lambda: request.app.state.runtime_client.acquire_file_lock(
+ {
+ "workspace_id": context.workspace.workspace_id,
+ "storage_object_id": storage_object_id,
+ "user_id": context.user.user_id,
+ "request_id": context.request_id,
+ }
+ ),
+ success_status=status.HTTP_201_CREATED,
+ )
+
+
+@router.post("/api/v1/file-locks/{edit_session_id}/heartbeat")
+async def heartbeat_file_lock(
+ edit_session_id: str,
+ payload: FileLockTokenRequest,
+ request: Request,
+ context: RequestContext = Depends(request_context),
+) -> JSONResponse:
+ return await runtime_response(
+ context,
+ lambda: request.app.state.runtime_client.heartbeat_file_lock(
+ edit_session_id,
+ {
+ "workspace_id": context.workspace.workspace_id,
+ "user_id": context.user.user_id,
+ "lock_token": payload.lock_token,
+ },
+ ),
+ )
+
+
+@router.delete("/api/v1/file-locks/{edit_session_id}")
+async def release_file_lock(
+ edit_session_id: str,
+ payload: FileLockTokenRequest,
+ request: Request,
+ context: RequestContext = Depends(request_context),
+) -> JSONResponse:
+ return await runtime_response(
+ context,
+ lambda: request.app.state.runtime_client.release_file_lock(
+ edit_session_id,
+ {
+ "workspace_id": context.workspace.workspace_id,
+ "user_id": context.user.user_id,
+ "lock_token": payload.lock_token,
+ },
+ ),
+ )
diff --git a/backend/src/backend/jupyter.py b/backend/src/backend/jupyter.py
new file mode 100644
index 0000000..bf68028
--- /dev/null
+++ b/backend/src/backend/jupyter.py
@@ -0,0 +1,84 @@
+from __future__ import annotations
+
+import os
+
+from fastapi import APIRouter, Depends, Request, status
+from fastapi.responses import JSONResponse
+
+from backend.dependencies import (
+ RequestContext,
+ request_context,
+)
+from backend.runtime_client import RuntimeClientError
+from backend.schemas import (
+ CreateJupyterAccessTicketRequest,
+)
+
+
+router = APIRouter(tags=["jupyter"])
+
+
+def cookie_secure() -> bool:
+ return os.getenv("COOKIE_SECURE", "false").strip().lower() in {
+ "1",
+ "true",
+ "yes",
+ "on",
+ }
+
+
+@router.post(
+ "/api/v1/jupyter/access-tickets",
+ status_code=status.HTTP_201_CREATED,
+)
+async def create_jupyter_access_ticket(
+ payload: CreateJupyterAccessTicketRequest,
+ request: Request,
+ context: RequestContext = Depends(request_context),
+) -> JSONResponse:
+ try:
+ ticket_data = (
+ await request.app.state.runtime_client
+ .create_jupyter_access_ticket(
+ payload.edit_session_id,
+ {
+ "workspace_id": context.workspace.workspace_id,
+ "user_id": context.user.user_id,
+ "lock_token": payload.lock_token,
+ },
+ )
+ )
+ except RuntimeClientError as exc:
+ error = exc.detail
+ if not isinstance(error, dict) or "code" not in error:
+ error = {
+ "code": "JUPYTER_TICKET_FAILED",
+ "message": str(error),
+ "retryable": exc.status_code >= 500,
+ "details": {},
+ }
+ return JSONResponse(
+ status_code=exc.status_code,
+ content={"request_id": context.request_id, "error": error},
+ )
+
+ raw_ticket = ticket_data.pop("ticket")
+ max_age = int(ticket_data.pop("expires_in_seconds"))
+ response = JSONResponse(
+ status_code=status.HTTP_201_CREATED,
+ content={
+ "request_id": context.request_id,
+ "data": ticket_data,
+ "meta": {},
+ },
+ )
+ response.set_cookie(
+ key="jupyter_access",
+ value=raw_ticket,
+ max_age=max_age,
+ path="/jupyter/",
+ secure=cookie_secure(),
+ httponly=True,
+ samesite="lax",
+ )
+ return response
diff --git a/backend/src/backend/main.py b/backend/src/backend/main.py
new file mode 100644
index 0000000..976b952
--- /dev/null
+++ b/backend/src/backend/main.py
@@ -0,0 +1,108 @@
+from __future__ import annotations
+
+import asyncio
+import os
+from contextlib import asynccontextmanager
+from pathlib import Path
+from typing import Any, AsyncIterator
+
+import httpx
+from fastapi.routing import APIRoute
+
+from common.db import create_database_engine, create_session_factory
+from common.service_app import create_service_app
+from common.storage import RustFSObjectStore
+from backend.admin import router as admin_router
+from backend.file_locks import router as file_locks_router
+from backend.jupyter import router as jupyter_router
+from backend.resources import router as resources_router
+from backend.runtime_client import RuntimeClient
+from backend.schedule_runs import router as schedule_runs_router
+from backend.schedule_client import ScheduleExecutorClient
+from backend.schedules import router as schedules_router
+from backend.scripts import router as scripts_router
+from backend.storage_api import app as storage_app
+from backend.storage_client import StorageClient
+
+
+@asynccontextmanager
+async def lifespan(app: Any) -> AsyncIterator[None]:
+ engine = create_database_engine(os.environ["DATABASE_URL"])
+ app.state.session_factory = create_session_factory(engine)
+ workspace_root = Path(
+ os.getenv("WORKSPACE_ROOT", "/workspace/workspaces")
+ )
+ workspace_root.mkdir(parents=True, exist_ok=True)
+
+ # Storage API is now part of the backend process. Platform routers keep
+ # their existing client contract, but calls are dispatched in-process.
+ app.state.object_store = RustFSObjectStore(
+ internal_endpoint=os.getenv(
+ "RUSTFS_INTERNAL_ENDPOINT",
+ "http://rustfs:9000",
+ ),
+ public_endpoint=os.getenv(
+ "RUSTFS_PUBLIC_ENDPOINT",
+ "http://localhost:9000",
+ ),
+ access_key=os.environ["RUSTFS_ACCESS_KEY"],
+ secret_key=os.environ["RUSTFS_SECRET_KEY"],
+ )
+ app.state.default_bucket = os.getenv(
+ "RUSTFS_DEFAULT_BUCKET",
+ "model-platform",
+ )
+ await asyncio.to_thread(
+ app.state.object_store.ensure_bucket,
+ app.state.default_bucket,
+ )
+ storage_http_client = httpx.AsyncClient(
+ transport=httpx.ASGITransport(app=app),
+ base_url="http://backend.internal",
+ timeout=httpx.Timeout(30.0),
+ )
+ app.state.storage_client = StorageClient(
+ storage_http_client,
+ os.environ["INTERNAL_SERVICE_TOKEN"],
+ )
+ runtime_http_client = httpx.AsyncClient(
+ base_url=os.getenv("RUNTIME_API_URL", "http://runtime:8000"),
+ timeout=httpx.Timeout(30.0),
+ )
+ app.state.runtime_client = RuntimeClient(
+ runtime_http_client,
+ os.environ["INTERNAL_SERVICE_TOKEN"],
+ )
+ schedule_http_client = httpx.AsyncClient(
+ base_url=os.getenv("SCHEDULE_API_URL", "http://schedule:8000"),
+ timeout=httpx.Timeout(10.0),
+ )
+ app.state.schedule_client = ScheduleExecutorClient(
+ schedule_http_client,
+ os.environ["INTERNAL_SERVICE_TOKEN"],
+ )
+ try:
+ yield
+ finally:
+ await runtime_http_client.aclose()
+ await schedule_http_client.aclose()
+ await storage_http_client.aclose()
+ await engine.dispose()
+
+
+app = create_service_app(
+ os.getenv("SERVICE_NAME", "backend"),
+ lifespan=lifespan,
+)
+app.include_router(file_locks_router)
+app.include_router(jupyter_router)
+app.include_router(resources_router)
+app.include_router(schedule_runs_router)
+app.include_router(schedules_router)
+app.include_router(scripts_router)
+app.include_router(admin_router)
+
+# Reuse the proven storage endpoints without running another FastAPI service.
+for route in storage_app.routes:
+ if isinstance(route, APIRoute) and route.path.startswith("/internal/"):
+ app.router.routes.append(route)
diff --git a/backend/src/backend/resources.py b/backend/src/backend/resources.py
new file mode 100644
index 0000000..14a4d7b
--- /dev/null
+++ b/backend/src/backend/resources.py
@@ -0,0 +1,293 @@
+from __future__ import annotations
+
+from datetime import UTC, datetime
+from typing import Any
+
+from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, status
+from sqlalchemy import or_, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from common.db.models import DataResources, StorageObjects
+from common.ids import new_ulid
+from backend.dependencies import (
+ RequestContext,
+ database_session,
+ request_context,
+)
+from backend.schemas import (
+ CompleteResourceUploadRequest,
+ CreateResourceUploadRequest,
+ DownloadUrlRequest,
+)
+
+router = APIRouter(prefix="/api/v1/data-resources", tags=["data-resources"])
+
+
+def resource_payload(
+ resource: DataResources,
+ storage_object: StorageObjects,
+) -> dict[str, Any]:
+ return {
+ "resource_id": resource.resource_id,
+ "workspace_id": resource.workspace_id,
+ "storage_object_id": resource.storage_object_id,
+ "owner_user_id": resource.owner_user_id,
+ "resource_name": resource.resource_name,
+ "description": resource.description,
+ "visibility": resource.visibility,
+ "status": resource.status,
+ "created_at": resource.created_at.isoformat(),
+ "updated_at": resource.updated_at.isoformat(),
+ "file": {
+ "file_name": storage_object.file_name,
+ "file_extension": storage_object.file_extension,
+ "mime_type": storage_object.mime_type,
+ "size_bytes": storage_object.size_bytes,
+ "content_hash": storage_object.content_hash,
+ "object_status": storage_object.object_status,
+ },
+ }
+
+
+def can_view(resource: DataResources, context: RequestContext) -> bool:
+ return (
+ resource.owner_user_id == context.user.user_id
+ or resource.visibility in {"workspace", "public"}
+ or context.is_admin
+ )
+
+
+@router.post("/uploads", status_code=status.HTTP_201_CREATED)
+async def create_resource_upload(
+ payload: CreateResourceUploadRequest,
+ request: Request,
+ context: RequestContext = Depends(request_context),
+ idempotency_key: str = Header(
+ min_length=8,
+ max_length=128,
+ alias="Idempotency-Key",
+ ),
+) -> dict[str, Any]:
+ data = await request.app.state.storage_client.create_upload(
+ {
+ "workspace_id": context.workspace.workspace_id,
+ "user_id": context.user.user_id,
+ "usage_type": "data_resource",
+ "file_name": payload.file_name,
+ "content_type": payload.content_type,
+ "expected_size_bytes": payload.expected_size_bytes,
+ "expected_hash": payload.expected_hash,
+ "idempotency_key": idempotency_key,
+ "url_scope": "public",
+ }
+ )
+ return {"request_id": context.request_id, "data": data, "meta": {}}
+
+
+@router.post("/uploads/{upload_id}/complete")
+async def complete_resource_upload(
+ upload_id: str,
+ payload: CompleteResourceUploadRequest,
+ request: Request,
+ context: RequestContext = Depends(request_context),
+ session: AsyncSession = Depends(database_session),
+) -> dict[str, Any]:
+ storage_data = await request.app.state.storage_client.complete_upload(
+ upload_id,
+ {
+ "usage_type": "data_resource",
+ "visibility": payload.visibility,
+ "is_immutable": False,
+ },
+ )
+ if storage_data["workspace_id"] != context.workspace.workspace_id:
+ raise HTTPException(
+ status.HTTP_403_FORBIDDEN,
+ "upload belongs to another workspace",
+ )
+ if storage_data["owner_user_id"] != context.user.user_id:
+ raise HTTPException(
+ status.HTTP_403_FORBIDDEN,
+ "upload belongs to another user",
+ )
+ existing = await session.scalar(
+ select(DataResources).where(
+ DataResources.storage_object_id
+ == storage_data["storage_object_id"]
+ )
+ )
+ reused = existing is not None
+ if existing is None:
+ existing = DataResources(
+ resource_id=new_ulid(),
+ workspace_id=context.workspace.workspace_id,
+ storage_object_id=storage_data["storage_object_id"],
+ owner_user_id=context.user.user_id,
+ resource_name=payload.resource_name,
+ description=payload.description,
+ visibility=payload.visibility,
+ status="active",
+ )
+ session.add(existing)
+ await session.flush()
+ await session.refresh(existing)
+ storage_object = await session.get(
+ StorageObjects,
+ existing.storage_object_id,
+ )
+ if storage_object is None:
+ raise HTTPException(
+ status.HTTP_409_CONFLICT,
+ "storage object metadata is missing",
+ )
+ return {
+ "request_id": context.request_id,
+ "data": resource_payload(existing, storage_object),
+ "meta": {"reused": reused},
+ }
+
+
+@router.get("")
+async def list_resources(
+ context: RequestContext = Depends(request_context),
+ session: AsyncSession = Depends(database_session),
+ visibility: str | None = Query(default=None),
+ keyword: str | None = Query(default=None, max_length=100),
+) -> dict[str, Any]:
+ statement = (
+ select(DataResources, StorageObjects)
+ .join(
+ StorageObjects,
+ StorageObjects.storage_object_id
+ == DataResources.storage_object_id,
+ )
+ .where(
+ DataResources.workspace_id == context.workspace.workspace_id,
+ DataResources.status == "active",
+ StorageObjects.object_status == "available",
+ )
+ .order_by(DataResources.updated_at.desc())
+ )
+ if not context.is_admin:
+ statement = statement.where(
+ or_(
+ DataResources.owner_user_id == context.user.user_id,
+ DataResources.visibility.in_(["workspace", "public"]),
+ )
+ )
+ if visibility:
+ if visibility not in {"private", "workspace", "public"}:
+ raise HTTPException(
+ status.HTTP_422_UNPROCESSABLE_ENTITY,
+ "invalid visibility",
+ )
+ statement = statement.where(DataResources.visibility == visibility)
+ if keyword:
+ statement = statement.where(
+ DataResources.resource_name.like(f"%{keyword.strip()}%")
+ )
+ rows = (await session.execute(statement)).all()
+ return {
+ "request_id": context.request_id,
+ "data": [
+ resource_payload(resource, storage_object)
+ for resource, storage_object in rows
+ ],
+ "meta": {"count": len(rows)},
+ }
+
+
+async def get_visible_resource(
+ resource_id: str,
+ context: RequestContext,
+ session: AsyncSession,
+) -> tuple[DataResources, StorageObjects]:
+ row = (
+ await session.execute(
+ select(DataResources, StorageObjects)
+ .join(
+ StorageObjects,
+ StorageObjects.storage_object_id
+ == DataResources.storage_object_id,
+ )
+ .where(
+ DataResources.resource_id == resource_id,
+ DataResources.workspace_id
+ == context.workspace.workspace_id,
+ DataResources.status == "active",
+ )
+ )
+ ).one_or_none()
+ if row is None or not can_view(row[0], context):
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "resource not found")
+ return row
+
+
+@router.get("/{resource_id}")
+async def get_resource(
+ resource_id: str,
+ context: RequestContext = Depends(request_context),
+ session: AsyncSession = Depends(database_session),
+) -> dict[str, Any]:
+ resource, storage_object = await get_visible_resource(
+ resource_id,
+ context,
+ session,
+ )
+ return {
+ "request_id": context.request_id,
+ "data": resource_payload(resource, storage_object),
+ "meta": {},
+ }
+
+
+@router.post("/{resource_id}/download-url")
+async def resource_download_url(
+ resource_id: str,
+ payload: DownloadUrlRequest,
+ request: Request,
+ context: RequestContext = Depends(request_context),
+ session: AsyncSession = Depends(database_session),
+) -> dict[str, Any]:
+ resource, _ = await get_visible_resource(
+ resource_id,
+ context,
+ session,
+ )
+ data = await request.app.state.storage_client.create_download_url(
+ resource.storage_object_id,
+ payload.expires_seconds,
+ )
+ return {"request_id": context.request_id, "data": data, "meta": {}}
+
+
+@router.delete("/{resource_id}")
+async def delete_resource(
+ resource_id: str,
+ request: Request,
+ context: RequestContext = Depends(request_context),
+ session: AsyncSession = Depends(database_session),
+) -> dict[str, Any]:
+ resource, _ = await get_visible_resource(
+ resource_id,
+ context,
+ session,
+ )
+ if (
+ resource.owner_user_id != context.user.user_id
+ and not context.is_admin
+ ):
+ raise HTTPException(
+ status.HTTP_403_FORBIDDEN,
+ "resource can only be deleted by its owner or an administrator",
+ )
+ await request.app.state.storage_client.delete_object(
+ resource.storage_object_id
+ )
+ resource.status = "deleted"
+ resource.deleted_at = datetime.now(UTC).replace(tzinfo=None)
+ return {
+ "request_id": context.request_id,
+ "data": {"resource_id": resource_id, "status": "deleted"},
+ "meta": {},
+ }
diff --git a/backend/src/backend/runtime_client.py b/backend/src/backend/runtime_client.py
new file mode 100644
index 0000000..e843b30
--- /dev/null
+++ b/backend/src/backend/runtime_client.py
@@ -0,0 +1,104 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any
+
+import httpx
+
+
+@dataclass(frozen=True)
+class RuntimeClientError(Exception):
+ status_code: int
+ detail: Any
+
+
+class RuntimeClient:
+ def __init__(
+ self,
+ client: httpx.AsyncClient,
+ service_token: str,
+ ) -> None:
+ self.client = client
+ self.headers = {"X-Service-Token": service_token}
+
+ async def _request(
+ self,
+ method: str,
+ path: str,
+ payload: dict[str, Any],
+ ) -> dict[str, Any]:
+ try:
+ response = await self.client.request(
+ method,
+ path,
+ json=payload,
+ headers=self.headers,
+ )
+ except httpx.RequestError as exc:
+ raise RuntimeClientError(
+ 503,
+ {
+ "code": "RUNTIME_UNAVAILABLE",
+ "message": "Runtime Manager 暂时不可用",
+ "retryable": True,
+ "details": {},
+ },
+ ) from exc
+ if response.is_error:
+ try:
+ detail = response.json().get("detail", response.text)
+ except ValueError:
+ detail = response.text
+ raise RuntimeClientError(response.status_code, detail)
+ return response.json()
+
+ async def acquire_file_lock(
+ self,
+ payload: dict[str, Any],
+ ) -> dict[str, Any]:
+ return (
+ await self._request(
+ "POST",
+ "/internal/v1/file-locks/acquire",
+ payload,
+ )
+ )["data"]
+
+ async def heartbeat_file_lock(
+ self,
+ edit_session_id: str,
+ payload: dict[str, Any],
+ ) -> dict[str, Any]:
+ return (
+ await self._request(
+ "POST",
+ f"/internal/v1/file-locks/{edit_session_id}/heartbeat",
+ payload,
+ )
+ )["data"]
+
+ async def release_file_lock(
+ self,
+ edit_session_id: str,
+ payload: dict[str, Any],
+ ) -> dict[str, Any]:
+ return (
+ await self._request(
+ "DELETE",
+ f"/internal/v1/file-locks/{edit_session_id}",
+ payload,
+ )
+ )["data"]
+
+ async def create_jupyter_access_ticket(
+ self,
+ edit_session_id: str,
+ payload: dict[str, Any],
+ ) -> dict[str, Any]:
+ return (
+ await self._request(
+ "POST",
+ f"/internal/v1/jupyter/access-tickets/{edit_session_id}",
+ payload,
+ )
+ )["data"]
diff --git a/backend/src/backend/schedule_client.py b/backend/src/backend/schedule_client.py
new file mode 100644
index 0000000..ba804e5
--- /dev/null
+++ b/backend/src/backend/schedule_client.py
@@ -0,0 +1,43 @@
+from __future__ import annotations
+
+import logging
+
+import httpx
+
+
+LOGGER = logging.getLogger(__name__)
+
+
+class ScheduleExecutorClient:
+ """Best-effort HTTP notification for immediate run dispatch.
+
+ MySQL remains the source of truth. If this notification fails, the
+ executor's database polling loop will still pick up the pending Outbox row.
+ """
+
+ def __init__(self, client: httpx.AsyncClient, service_token: str) -> None:
+ self.client = client
+ self.headers = {"X-Service-Token": service_token}
+
+ async def dispatch_run(self, run_id: str) -> bool:
+ try:
+ response = await self.client.post(
+ f"/internal/v1/runs/{run_id}/dispatch",
+ headers=self.headers,
+ )
+ except httpx.RequestError:
+ LOGGER.warning(
+ "schedule executor notification failed for run %s",
+ run_id,
+ exc_info=True,
+ )
+ return False
+ if response.is_error:
+ LOGGER.warning(
+ "schedule executor rejected run %s: %s %s",
+ run_id,
+ response.status_code,
+ response.text[:500],
+ )
+ return False
+ return True
diff --git a/backend/src/backend/schedule_runs.py b/backend/src/backend/schedule_runs.py
new file mode 100644
index 0000000..d75de5d
--- /dev/null
+++ b/backend/src/backend/schedule_runs.py
@@ -0,0 +1,348 @@
+from __future__ import annotations
+
+import hashlib
+from datetime import UTC, datetime
+from typing import Any, Literal
+
+from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, status
+from pydantic import Field
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from common.db.models import (
+ ScheduleNodeRuns,
+ ScheduleRuns,
+)
+from common.eventing import add_outbox_event, utcnow
+from common.ids import new_ulid
+from backend.dependencies import (
+ RequestContext,
+ database_session,
+ request_context,
+)
+from backend.schedule_schemas import StrictModel
+from backend.schedules import (
+ graph_rows,
+ schedule_row,
+ validate_dag,
+)
+
+
+router = APIRouter(tags=["schedule-runs"])
+RunStatus = Literal[
+ "queued",
+ "running",
+ "succeeded",
+ "failed",
+ "cancelled",
+ "timed_out",
+]
+
+
+class RunScheduleRequest(StrictModel):
+ reason: str = Field(default="manual_run", min_length=1, max_length=255)
+
+
+def _iso(value: datetime | None) -> str | None:
+ if value is None:
+ return None
+ if value.tzinfo is None:
+ value = value.replace(tzinfo=UTC)
+ return value.astimezone(UTC).isoformat()
+
+
+def _normalized_idempotency_key(
+ workspace_id: str,
+ schedule_id: str,
+ value: str,
+) -> str:
+ normalized = value.strip()
+ if len(normalized) < 8:
+ raise HTTPException(
+ status.HTTP_422_UNPROCESSABLE_ENTITY,
+ "Idempotency-Key must contain at least 8 characters",
+ )
+ digest = hashlib.sha256(
+ f"{workspace_id}:{schedule_id}:{normalized}".encode("utf-8")
+ ).hexdigest()
+ return f"run:v1:{digest}"
+
+
+def _arguments(value: dict[str, Any] | None) -> list[str]:
+ payload = value or {}
+ raw = payload.get("_args")
+ result = [str(item) for item in raw] if isinstance(raw, list) else []
+ for key, item in payload.items():
+ if key == "_args":
+ continue
+ option = f"--{key.replace('_', '-')}"
+ if item is True:
+ result.append(option)
+ elif item is False or item is None:
+ continue
+ elif isinstance(item, list):
+ for list_item in item:
+ result.extend((option, str(list_item)))
+ elif isinstance(item, (str, int, float)):
+ result.extend((option, str(item)))
+ else:
+ raise HTTPException(
+ status.HTTP_422_UNPROCESSABLE_ENTITY,
+ f"node argument {key!r} must be a scalar or list",
+ )
+ return result
+
+
+def run_summary(item: ScheduleRuns) -> dict[str, Any]:
+ return {
+ "run_id": item.run_id,
+ "schedule_id": item.schedule_id,
+ "workspace_id": item.workspace_id,
+ "workflow_version": item.workflow_version,
+ "trigger_type": item.trigger_type,
+ "run_status": item.run_status,
+ "state_version": item.state_version,
+ "queued_at": _iso(item.queued_at),
+ "started_at": _iso(item.started_at),
+ "finished_at": _iso(item.finished_at),
+ "duration_ms": item.duration_ms,
+ "error_code": item.error_code,
+ "error_message": item.error_message,
+ "logs_object_id": item.logs_object_id,
+ "result_object_id": item.result_object_id,
+ }
+
+
+def node_run_payload(item: ScheduleNodeRuns) -> dict[str, Any]:
+ return {
+ "node_run_id": item.node_run_id,
+ "run_id": item.run_id,
+ "node_id": item.node_id,
+ "versions_id": item.versions_id,
+ "attempt_no": item.attempt_no,
+ "node_status": item.node_status,
+ "state_version": item.state_version,
+ "started_at": _iso(item.started_at),
+ "finished_at": _iso(item.finished_at),
+ "duration_ms": item.duration_ms,
+ "exit_code": item.exit_code,
+ "message": item.message,
+ "logs_object_id": item.logs_object_id,
+ "result_object_id": item.result_object_id,
+ }
+
+
+async def run_detail(
+ item: ScheduleRuns,
+ session: AsyncSession,
+) -> dict[str, Any]:
+ node_runs = list(
+ (
+ await session.scalars(
+ select(ScheduleNodeRuns)
+ .where(ScheduleNodeRuns.run_id == item.run_id)
+ .order_by(
+ ScheduleNodeRuns.created_at,
+ ScheduleNodeRuns.attempt_no,
+ )
+ )
+ ).all()
+ )
+ return {
+ **run_summary(item),
+ "node_runs": [node_run_payload(node_run) for node_run in node_runs],
+ }
+
+
+async def _visible_run(
+ run_id: str,
+ context: RequestContext,
+ session: AsyncSession,
+) -> ScheduleRuns:
+ item = await session.scalar(
+ select(ScheduleRuns).where(
+ ScheduleRuns.run_id == run_id,
+ ScheduleRuns.workspace_id == context.workspace.workspace_id,
+ )
+ )
+ if item is None:
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "schedule run not found")
+ return item
+
+
+@router.post(
+ "/api/v1/schedules/{schedule_id}/run",
+ status_code=status.HTTP_202_ACCEPTED,
+)
+async def run_schedule_now(
+ schedule_id: str,
+ request: Request,
+ payload: RunScheduleRequest | None = None,
+ idempotency_key: str = Header(alias="Idempotency-Key"),
+ context: RequestContext = Depends(request_context),
+ session: AsyncSession = Depends(database_session),
+) -> dict[str, Any]:
+ reason = payload.reason if payload is not None else "manual_run"
+ key = _normalized_idempotency_key(
+ context.workspace.workspace_id,
+ schedule_id,
+ idempotency_key,
+ )
+ existing = await session.scalar(
+ select(ScheduleRuns).where(ScheduleRuns.idempotency_key == key)
+ )
+ if existing is not None:
+ if (
+ existing.workspace_id != context.workspace.workspace_id
+ or existing.schedule_id != schedule_id
+ ):
+ raise HTTPException(
+ status.HTTP_409_CONFLICT,
+ "Idempotency-Key belongs to another schedule run",
+ )
+ return {
+ "request_id": context.request_id,
+ "data": await run_detail(existing, session),
+ "meta": {"reused": True},
+ }
+
+ schedule = await schedule_row(
+ schedule_id,
+ context,
+ session,
+ for_update=True,
+ )
+ node_rows, edges = await graph_rows(schedule_id, session)
+ nodes = [row[0] for row in node_rows]
+ validation = validate_dag(nodes, edges)
+ if not validation["valid"] or not nodes:
+ raise HTTPException(
+ status.HTTP_409_CONFLICT,
+ detail={
+ "code": "SCHEDULE_DAG_INVALID",
+ "message": "schedule must contain a valid non-empty DAG",
+ "errors": validation["errors"],
+ },
+ )
+ if len(nodes) > 100 or len(edges) > 500:
+ raise HTTPException(
+ status.HTTP_409_CONFLICT,
+ "schedule exceeds the v1 execution size limit",
+ )
+
+ snapshot = {
+ "schedule_name": schedule.schedule_name,
+ "workflow_version": schedule.workflow_version,
+ "max_concurrency": schedule.max_concurrency,
+ "failure_policy": schedule.failure_policy,
+ "nodes": [
+ {
+ "node_id": node.node_id,
+ "node_key": node.node_key,
+ "versions_id": version.versions_id,
+ "script_type": script.script_type,
+ "artifact_object_id": version.artifact_object_id,
+ "artifact_path": version.artifact_path,
+ "timeout_seconds": node.timeout_seconds,
+ "retry_count": node.retry_count,
+ "retry_interval_sec": node.retry_interval_sec,
+ "arguments": _arguments(node.arguments_json),
+ }
+ for node, version, script in node_rows
+ ],
+ "edges": [
+ {
+ "source_node_id": edge.source_node_id,
+ "target_node_id": edge.target_node_id,
+ }
+ for edge in edges
+ ],
+ }
+ now = utcnow()
+ run = ScheduleRuns(
+ run_id=new_ulid(),
+ schedule_id=schedule.schedule_id,
+ workspace_id=schedule.workspace_id,
+ workflow_version=schedule.workflow_version,
+ trigger_type="cron" if reason == "cron" else "manual",
+ idempotency_key=key,
+ run_status="queued",
+ state_version=0,
+ schedule_snapshot=snapshot,
+ queued_at=now,
+ triggered_by=context.user.user_id,
+ )
+ session.add(run)
+ schedule.last_run_at = now
+ await add_outbox_event(
+ session,
+ event_type="schedule.run.requested",
+ producer="platform-api",
+ trace_id=context.request_id,
+ aggregate_type="schedule_run",
+ aggregate_id=run.run_id,
+ idempotency_key=key,
+ payload={
+ "workspace_id": run.workspace_id,
+ "schedule_id": run.schedule_id,
+ "run_id": run.run_id,
+ "workflow_version": run.workflow_version,
+ "trigger_type": run.trigger_type,
+ "triggered_by": run.triggered_by,
+ "schedule_snapshot": snapshot,
+ },
+ )
+ await session.flush()
+ # Commit before the HTTP push so the executor can read the Outbox row.
+ # The executor also polls MySQL, so a failed push does not lose the run.
+ await session.commit()
+ await request.app.state.schedule_client.dispatch_run(run.run_id)
+ await session.refresh(run)
+ return {
+ "request_id": context.request_id,
+ "data": await run_detail(run, session),
+ "meta": {"reused": False},
+ }
+
+
+@router.get("/api/v1/schedule-runs")
+async def list_schedule_runs(
+ schedule_id: str | None = Query(default=None),
+ run_status: RunStatus | None = Query(default=None, alias="status"),
+ limit: int = Query(default=50, ge=1, le=200),
+ context: RequestContext = Depends(request_context),
+ session: AsyncSession = Depends(database_session),
+) -> dict[str, Any]:
+ statement = select(ScheduleRuns).where(
+ ScheduleRuns.workspace_id == context.workspace.workspace_id,
+ )
+ if schedule_id:
+ statement = statement.where(ScheduleRuns.schedule_id == schedule_id)
+ if run_status:
+ statement = statement.where(ScheduleRuns.run_status == run_status)
+ items = list(
+ (
+ await session.scalars(
+ statement.order_by(ScheduleRuns.queued_at.desc()).limit(limit)
+ )
+ ).all()
+ )
+ return {
+ "request_id": context.request_id,
+ "data": [run_summary(item) for item in items],
+ "meta": {"count": len(items)},
+ }
+
+
+@router.get("/api/v1/schedule-runs/{run_id}")
+async def get_schedule_run(
+ run_id: str,
+ context: RequestContext = Depends(request_context),
+ session: AsyncSession = Depends(database_session),
+) -> dict[str, Any]:
+ item = await _visible_run(run_id, context, session)
+ return {
+ "request_id": context.request_id,
+ "data": await run_detail(item, session),
+ "meta": {},
+ }
diff --git a/backend/src/backend/schedule_schemas.py b/backend/src/backend/schedule_schemas.py
new file mode 100644
index 0000000..8d658bb
--- /dev/null
+++ b/backend/src/backend/schedule_schemas.py
@@ -0,0 +1,214 @@
+from __future__ import annotations
+
+from datetime import datetime
+from typing import Any, Literal
+
+from pydantic import Field, field_validator, model_validator
+
+from backend.schemas import StrictModel
+
+
+TriggerType = Literal["manual", "cron", "api"]
+FailurePolicy = Literal["stop", "continue"]
+
+
+def _required_text(value: str) -> str:
+ normalized = value.strip()
+ if not normalized:
+ raise ValueError("value must not be blank")
+ return normalized
+
+
+class CreateScheduleRequest(StrictModel):
+ schedule_name: str = Field(min_length=1, max_length=255)
+ description: str | None = Field(default=None, max_length=1000)
+ trigger_type: TriggerType = "cron"
+ cron_expression: str | None = Field(default=None, max_length=128)
+ timezone: str = Field(default="Asia/Shanghai", min_length=1, max_length=64)
+ enabled: bool = False
+ max_concurrency: int = Field(default=1, ge=1, le=64)
+ failure_policy: FailurePolicy = "stop"
+
+ @field_validator("schedule_name", "timezone")
+ @classmethod
+ def validate_required_text(cls, value: str) -> str:
+ return _required_text(value)
+
+ @field_validator("description", "cron_expression")
+ @classmethod
+ def normalize_optional_text(cls, value: str | None) -> str | None:
+ if value is None:
+ return None
+ normalized = value.strip()
+ return normalized or None
+
+ @model_validator(mode="after")
+ def validate_trigger(self) -> "CreateScheduleRequest":
+ if self.trigger_type == "cron" and not self.cron_expression:
+ raise ValueError("cron_expression is required for cron schedules")
+ if self.trigger_type != "cron" and self.cron_expression:
+ raise ValueError(
+ "cron_expression is only allowed for cron schedules"
+ )
+ return self
+
+
+class UpdateScheduleRequest(StrictModel):
+ workflow_version: int = Field(ge=1)
+ schedule_name: str | None = Field(default=None, min_length=1, max_length=255)
+ description: str | None = Field(default=None, max_length=1000)
+ trigger_type: TriggerType | None = None
+ cron_expression: str | None = Field(default=None, max_length=128)
+ timezone: str | None = Field(default=None, min_length=1, max_length=64)
+ enabled: bool | None = None
+ max_concurrency: int | None = Field(default=None, ge=1, le=64)
+ failure_policy: FailurePolicy | None = None
+
+ @field_validator("schedule_name", "timezone")
+ @classmethod
+ def validate_optional_required_text(
+ cls,
+ value: str | None,
+ ) -> str | None:
+ return _required_text(value) if value is not None else None
+
+ @field_validator("description", "cron_expression")
+ @classmethod
+ def normalize_optional_text(cls, value: str | None) -> str | None:
+ if value is None:
+ return None
+ normalized = value.strip()
+ return normalized or None
+
+ @model_validator(mode="after")
+ def require_change(self) -> "UpdateScheduleRequest":
+ if self.model_fields_set == {"workflow_version"}:
+ raise ValueError("at least one schedule field must be updated")
+ for field in {
+ "schedule_name",
+ "trigger_type",
+ "timezone",
+ "enabled",
+ "max_concurrency",
+ "failure_policy",
+ }:
+ if field in self.model_fields_set and getattr(self, field) is None:
+ raise ValueError(f"{field} cannot be null")
+ return self
+
+
+class WorkflowVersionRequest(StrictModel):
+ workflow_version: int = Field(ge=1)
+
+
+class CreateScheduleNodeRequest(StrictModel):
+ workflow_version: int = Field(ge=1)
+ node_key: str = Field(
+ min_length=1,
+ max_length=64,
+ pattern=r"^[A-Za-z][A-Za-z0-9_-]*$",
+ )
+ node_name: str = Field(min_length=1, max_length=255)
+ versions_id: str = Field(min_length=26, max_length=26)
+ timeout_seconds: int = Field(default=600, ge=1, le=86_400)
+ retry_count: int = Field(default=0, ge=0, le=10)
+ retry_interval_sec: int = Field(default=5, ge=0, le=3600)
+ position_x: float = Field(default=0, ge=-100_000, le=100_000)
+ position_y: float = Field(default=0, ge=-100_000, le=100_000)
+ arguments_json: dict[str, Any] = Field(default_factory=dict, max_length=100)
+ env_refs_json: dict[str, str] = Field(default_factory=dict, max_length=100)
+
+ @field_validator("node_key", "node_name")
+ @classmethod
+ def validate_required_text(cls, value: str) -> str:
+ return _required_text(value)
+
+
+class UpdateScheduleNodeRequest(StrictModel):
+ workflow_version: int = Field(ge=1)
+ node_name: str | None = Field(default=None, min_length=1, max_length=255)
+ versions_id: str | None = Field(default=None, min_length=26, max_length=26)
+ timeout_seconds: int | None = Field(default=None, ge=1, le=86_400)
+ retry_count: int | None = Field(default=None, ge=0, le=10)
+ retry_interval_sec: int | None = Field(default=None, ge=0, le=3600)
+ position_x: float | None = Field(
+ default=None,
+ ge=-100_000,
+ le=100_000,
+ )
+ position_y: float | None = Field(
+ default=None,
+ ge=-100_000,
+ le=100_000,
+ )
+ arguments_json: dict[str, Any] | None = Field(
+ default=None,
+ max_length=100,
+ )
+ env_refs_json: dict[str, str] | None = Field(
+ default=None,
+ max_length=100,
+ )
+
+ @field_validator("node_name")
+ @classmethod
+ def validate_optional_required_text(
+ cls,
+ value: str | None,
+ ) -> str | None:
+ return _required_text(value) if value is not None else None
+
+ @model_validator(mode="after")
+ def require_change(self) -> "UpdateScheduleNodeRequest":
+ if self.model_fields_set == {"workflow_version"}:
+ raise ValueError("at least one node field must be updated")
+ for field in self.model_fields_set - {"workflow_version"}:
+ if getattr(self, field) is None:
+ raise ValueError(f"{field} cannot be null")
+ return self
+
+
+class CreateScheduleEdgeRequest(StrictModel):
+ workflow_version: int = Field(ge=1)
+ source_node_id: str = Field(min_length=26, max_length=26)
+ target_node_id: str = Field(min_length=26, max_length=26)
+ condition_expr: str | None = Field(default=None, max_length=1000)
+
+ @field_validator("condition_expr")
+ @classmethod
+ def normalize_condition(cls, value: str | None) -> str | None:
+ if value is None:
+ return None
+ normalized = value.strip()
+ return normalized or None
+
+ @model_validator(mode="after")
+ def reject_self_edge(self) -> "CreateScheduleEdgeRequest":
+ if self.source_node_id == self.target_node_id:
+ raise ValueError("an edge cannot connect a node to itself")
+ return self
+
+
+class UpdateScheduleEdgeRequest(StrictModel):
+ workflow_version: int = Field(ge=1)
+ condition_expr: str | None = Field(default=None, max_length=1000)
+
+ @field_validator("condition_expr")
+ @classmethod
+ def normalize_condition(cls, value: str | None) -> str | None:
+ if value is None:
+ return None
+ normalized = value.strip()
+ return normalized or None
+
+
+class CronPreviewRequest(StrictModel):
+ cron_expression: str = Field(min_length=1, max_length=128)
+ timezone: str = Field(default="Asia/Shanghai", min_length=1, max_length=64)
+ count: int = Field(default=5, ge=1, le=20)
+ base_time: datetime | None = None
+
+ @field_validator("cron_expression", "timezone")
+ @classmethod
+ def validate_required_text(cls, value: str) -> str:
+ return _required_text(value)
diff --git a/backend/src/backend/schedules.py b/backend/src/backend/schedules.py
new file mode 100644
index 0000000..59f8925
--- /dev/null
+++ b/backend/src/backend/schedules.py
@@ -0,0 +1,1076 @@
+from __future__ import annotations
+
+import heapq
+from datetime import UTC, datetime
+from decimal import Decimal
+from typing import Any
+from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
+
+from croniter import CroniterBadCronError, croniter
+from fastapi import APIRouter, Depends, HTTPException, Query, status
+from sqlalchemy import delete, func, or_, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from common.db.models import (
+ ScheduleEdges,
+ ScheduleNodeRuns,
+ ScheduleNodes,
+ Schedules,
+ Scripts,
+ Versions,
+)
+from common.ids import new_ulid
+from backend.dependencies import (
+ RequestContext,
+ database_session,
+ request_context,
+)
+from backend.schedule_schemas import (
+ CreateScheduleEdgeRequest,
+ CreateScheduleNodeRequest,
+ CreateScheduleRequest,
+ CronPreviewRequest,
+ UpdateScheduleEdgeRequest,
+ UpdateScheduleNodeRequest,
+ UpdateScheduleRequest,
+ WorkflowVersionRequest,
+)
+
+
+router = APIRouter(tags=["schedules"])
+
+
+def _iso(value: datetime | None) -> str | None:
+ if value is None:
+ return None
+ if value.tzinfo is None:
+ value = value.replace(tzinfo=UTC)
+ return value.astimezone(UTC).isoformat()
+
+
+def _mysql_utc(value: datetime) -> datetime:
+ if value.tzinfo is None:
+ value = value.replace(tzinfo=UTC)
+ return value.astimezone(UTC).replace(tzinfo=None)
+
+
+def _timezone(value: str) -> ZoneInfo:
+ try:
+ return ZoneInfo(value)
+ except ZoneInfoNotFoundError as exc:
+ raise HTTPException(
+ status.HTTP_422_UNPROCESSABLE_ENTITY,
+ f"unknown timezone: {value}",
+ ) from exc
+
+
+def cron_preview(
+ expression: str,
+ timezone_name: str,
+ *,
+ count: int,
+ base_time: datetime | None = None,
+) -> dict[str, Any]:
+ if len(expression.split()) != 5:
+ raise HTTPException(
+ status.HTTP_422_UNPROCESSABLE_ENTITY,
+ "cron_expression must contain exactly five fields: "
+ "minute hour day month weekday",
+ )
+ timezone = _timezone(timezone_name)
+ if base_time is None:
+ local_base = datetime.now(timezone)
+ elif base_time.tzinfo is None:
+ local_base = base_time.replace(tzinfo=timezone)
+ else:
+ local_base = base_time.astimezone(timezone)
+ try:
+ if not croniter.is_valid(expression, strict=True):
+ raise CroniterBadCronError(expression)
+ iterator = croniter(expression, local_base)
+ occurrences = [
+ iterator.get_next(datetime)
+ for _ in range(count)
+ ]
+ except (CroniterBadCronError, ValueError, OverflowError) as exc:
+ raise HTTPException(
+ status.HTTP_422_UNPROCESSABLE_ENTITY,
+ "invalid cron_expression",
+ ) from exc
+ return {
+ "cron_expression": expression,
+ "timezone": timezone_name,
+ "base_time": local_base.isoformat(),
+ "occurrences": [
+ {
+ "local_time": occurrence.isoformat(),
+ "utc_time": occurrence.astimezone(UTC).isoformat(),
+ }
+ for occurrence in occurrences
+ ],
+ }
+
+
+def schedule_payload(
+ item: Schedules,
+ *,
+ node_count: int = 0,
+ edge_count: int = 0,
+) -> dict[str, Any]:
+ return {
+ "schedule_id": item.schedule_id,
+ "workspace_id": item.workspace_id,
+ "schedule_name": item.schedule_name,
+ "description": item.description,
+ "trigger_type": item.trigger_type,
+ "cron_expression": item.cron_expression,
+ "timezone": item.timezone,
+ "enabled": bool(item.enabled),
+ "workflow_version": item.workflow_version,
+ "max_concurrency": item.max_concurrency,
+ "failure_policy": item.failure_policy,
+ "last_run_at": _iso(item.last_run_at),
+ "next_run_at": _iso(item.next_run_at),
+ "created_by": item.created_by,
+ "updated_by": item.updated_by,
+ "created_at": _iso(item.created_at),
+ "updated_at": _iso(item.updated_at),
+ "node_count": node_count,
+ "edge_count": edge_count,
+ }
+
+
+def node_payload(
+ item: ScheduleNodes,
+ version: Versions,
+ script: Scripts,
+) -> dict[str, Any]:
+ return {
+ "node_id": item.node_id,
+ "schedule_id": item.schedule_id,
+ "node_key": item.node_key,
+ "node_name": item.node_name,
+ "versions_id": item.versions_id,
+ "timeout_seconds": item.timeout_seconds,
+ "retry_count": item.retry_count,
+ "retry_interval_sec": item.retry_interval_sec,
+ "position_x": float(item.position_x),
+ "position_y": float(item.position_y),
+ "arguments_json": item.arguments_json or {},
+ "env_refs_json": item.env_refs_json or {},
+ "created_at": _iso(item.created_at),
+ "updated_at": _iso(item.updated_at),
+ "version": {
+ "versions_id": version.versions_id,
+ "version_label": version.version_label,
+ "script_id": version.script_id,
+ "script_name": script.script_name,
+ "script_type": script.script_type,
+ "content_hash": version.content_hash,
+ "created_at": _iso(version.created_at),
+ },
+ }
+
+
+def edge_payload(item: ScheduleEdges) -> dict[str, Any]:
+ return {
+ "edge_id": item.edge_id,
+ "schedule_id": item.schedule_id,
+ "source_node_id": item.source_node_id,
+ "target_node_id": item.target_node_id,
+ "condition_expr": item.condition_expr,
+ "created_at": _iso(item.created_at),
+ }
+
+
+def validate_dag(
+ nodes: list[ScheduleNodes],
+ edges: list[ScheduleEdges],
+) -> dict[str, Any]:
+ node_by_id = {item.node_id: item for item in nodes}
+ indegree = {item.node_id: 0 for item in nodes}
+ outgoing: dict[str, set[str]] = {
+ item.node_id: set()
+ for item in nodes
+ }
+ errors: list[dict[str, Any]] = []
+ seen_edges: set[tuple[str, str]] = set()
+
+ if not nodes:
+ errors.append(
+ {
+ "code": "DAG_EMPTY",
+ "message": "schedule must contain at least one node",
+ }
+ )
+
+ for edge in edges:
+ if (
+ edge.source_node_id not in node_by_id
+ or edge.target_node_id not in node_by_id
+ ):
+ errors.append(
+ {
+ "code": "DAG_EDGE_NODE_MISSING",
+ "message": "edge references a node outside the schedule",
+ "edge_id": edge.edge_id,
+ }
+ )
+ continue
+ pair = (edge.source_node_id, edge.target_node_id)
+ if edge.source_node_id == edge.target_node_id:
+ errors.append(
+ {
+ "code": "DAG_SELF_EDGE",
+ "message": "a node cannot depend on itself",
+ "edge_id": edge.edge_id,
+ }
+ )
+ continue
+ if pair in seen_edges:
+ errors.append(
+ {
+ "code": "DAG_DUPLICATE_EDGE",
+ "message": "duplicate directed edge",
+ "edge_id": edge.edge_id,
+ }
+ )
+ continue
+ seen_edges.add(pair)
+ outgoing[edge.source_node_id].add(edge.target_node_id)
+ indegree[edge.target_node_id] += 1
+
+ root_ids = sorted(
+ (node_id for node_id, degree in indegree.items() if degree == 0),
+ key=lambda node_id: node_by_id[node_id].node_key,
+ )
+ leaf_ids = sorted(
+ (node_id for node_id, targets in outgoing.items() if not targets),
+ key=lambda node_id: node_by_id[node_id].node_key,
+ )
+ queue = [
+ (node_by_id[node_id].node_key, node_id)
+ for node_id in root_ids
+ ]
+ heapq.heapify(queue)
+ remaining_indegree = dict(indegree)
+ ordered_ids: list[str] = []
+ while queue:
+ _, node_id = heapq.heappop(queue)
+ ordered_ids.append(node_id)
+ for target_id in sorted(
+ outgoing[node_id],
+ key=lambda value: node_by_id[value].node_key,
+ ):
+ remaining_indegree[target_id] -= 1
+ if remaining_indegree[target_id] == 0:
+ heapq.heappush(
+ queue,
+ (node_by_id[target_id].node_key, target_id),
+ )
+
+ if len(ordered_ids) != len(nodes):
+ cycle_node_ids = sorted(
+ (
+ node_id
+ for node_id, degree in remaining_indegree.items()
+ if degree > 0
+ ),
+ key=lambda node_id: node_by_id[node_id].node_key,
+ )
+ errors.append(
+ {
+ "code": "DAG_CYCLE",
+ "message": "schedule graph contains a directed cycle",
+ "node_ids": cycle_node_ids,
+ }
+ )
+
+ return {
+ "valid": not errors,
+ "node_count": len(nodes),
+ "edge_count": len(edges),
+ "root_node_ids": root_ids,
+ "leaf_node_ids": leaf_ids,
+ "topological_order": ordered_ids,
+ "errors": errors,
+ }
+
+
+async def schedule_row(
+ schedule_id: str,
+ context: RequestContext,
+ session: AsyncSession,
+ *,
+ for_update: bool = False,
+) -> Schedules:
+ statement = select(Schedules).where(
+ Schedules.schedule_id == schedule_id,
+ Schedules.workspace_id == context.workspace.workspace_id,
+ Schedules.deleted_at.is_(None),
+ )
+ if for_update:
+ statement = statement.with_for_update()
+ item = await session.scalar(statement)
+ if item is None:
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "schedule not found")
+ return item
+
+
+def require_workflow_version(item: Schedules, expected: int) -> None:
+ if item.workflow_version != expected:
+ raise HTTPException(
+ status.HTTP_412_PRECONDITION_FAILED,
+ detail={
+ "code": "WORKFLOW_VERSION_CONFLICT",
+ "message": "schedule was modified by another request",
+ "expected": expected,
+ "current": item.workflow_version,
+ },
+ )
+
+
+async def graph_rows(
+ schedule_id: str,
+ session: AsyncSession,
+) -> tuple[
+ list[tuple[ScheduleNodes, Versions, Scripts]],
+ list[ScheduleEdges],
+]:
+ node_rows = (
+ await session.execute(
+ select(ScheduleNodes, Versions, Scripts)
+ .join(
+ Versions,
+ Versions.versions_id == ScheduleNodes.versions_id,
+ )
+ .join(Scripts, Scripts.script_id == Versions.script_id)
+ .where(ScheduleNodes.schedule_id == schedule_id)
+ .order_by(ScheduleNodes.created_at, ScheduleNodes.node_key)
+ )
+ ).all()
+ edges = list(
+ (
+ await session.scalars(
+ select(ScheduleEdges)
+ .where(ScheduleEdges.schedule_id == schedule_id)
+ .order_by(ScheduleEdges.created_at, ScheduleEdges.edge_id)
+ )
+ ).all()
+ )
+ return list(node_rows), edges
+
+
+async def detail_payload(
+ item: Schedules,
+ session: AsyncSession,
+) -> dict[str, Any]:
+ node_rows, edges = await graph_rows(item.schedule_id, session)
+ nodes = [row[0] for row in node_rows]
+ return {
+ **schedule_payload(
+ item,
+ node_count=len(nodes),
+ edge_count=len(edges),
+ ),
+ "nodes": [
+ node_payload(node, version, script)
+ for node, version, script in node_rows
+ ],
+ "edges": [edge_payload(edge) for edge in edges],
+ "dag_validation": validate_dag(nodes, edges),
+ }
+
+
+async def accessible_version(
+ versions_id: str,
+ context: RequestContext,
+ session: AsyncSession,
+) -> tuple[Versions, Scripts]:
+ row = (
+ await session.execute(
+ select(Versions, Scripts)
+ .join(Scripts, Scripts.script_id == Versions.script_id)
+ .where(
+ Versions.versions_id == versions_id,
+ Versions.workspace_id == context.workspace.workspace_id,
+ )
+ )
+ ).one_or_none()
+ if row is None:
+ raise HTTPException(
+ status.HTTP_404_NOT_FOUND,
+ "stable version not found",
+ )
+ version, script = row
+ if not (
+ context.is_admin
+ or version.created_by == context.user.user_id
+ or version.visibility in {"workspace", "public"}
+ ):
+ raise HTTPException(
+ status.HTTP_403_FORBIDDEN,
+ "stable version is not visible to this user",
+ )
+ return version, script
+
+
+def _apply_next_run(item: Schedules) -> None:
+ _timezone(item.timezone)
+ if item.trigger_type != "cron":
+ if item.cron_expression:
+ raise HTTPException(
+ status.HTTP_422_UNPROCESSABLE_ENTITY,
+ "cron_expression is only allowed for cron schedules",
+ )
+ item.cron_expression = None
+ item.next_run_at = None
+ return
+ if not item.cron_expression:
+ raise HTTPException(
+ status.HTTP_422_UNPROCESSABLE_ENTITY,
+ "cron_expression is required for cron schedules",
+ )
+ preview = cron_preview(
+ item.cron_expression,
+ item.timezone,
+ count=1,
+ )
+ item.next_run_at = (
+ _mysql_utc(
+ datetime.fromisoformat(preview["occurrences"][0]["utc_time"])
+ )
+ if item.enabled
+ else None
+ )
+
+
+async def _require_valid_when_enabled(
+ item: Schedules,
+ session: AsyncSession,
+) -> None:
+ if not item.enabled:
+ return
+ node_rows, edges = await graph_rows(item.schedule_id, session)
+ validation = validate_dag([row[0] for row in node_rows], edges)
+ if not validation["valid"]:
+ raise HTTPException(
+ status.HTTP_409_CONFLICT,
+ detail={
+ "code": "DAG_INVALID",
+ "message": "an enabled schedule must contain a valid DAG",
+ "validation": validation,
+ },
+ )
+
+
+@router.post("/api/v1/cron/preview")
+async def preview_cron(
+ payload: CronPreviewRequest,
+ context: RequestContext = Depends(request_context),
+) -> dict[str, Any]:
+ return {
+ "request_id": context.request_id,
+ "data": cron_preview(
+ payload.cron_expression,
+ payload.timezone,
+ count=payload.count,
+ base_time=payload.base_time,
+ ),
+ "meta": {},
+ }
+
+
+@router.get("/api/v1/schedule-artifacts")
+async def list_schedule_artifacts(
+ limit: int = Query(default=100, ge=1, le=500),
+ context: RequestContext = Depends(request_context),
+ session: AsyncSession = Depends(database_session),
+) -> dict[str, Any]:
+ rows = (
+ await session.execute(
+ select(Versions, Scripts)
+ .join(Scripts, Scripts.script_id == Versions.script_id)
+ .where(
+ Versions.workspace_id == context.workspace.workspace_id,
+ Versions.schedule_hidden_at.is_(None),
+ )
+ .order_by(Versions.created_at.desc())
+ .limit(limit)
+ )
+ ).all()
+ visible = [
+ (version, script)
+ for version, script in rows
+ if (
+ context.is_admin
+ or version.created_by == context.user.user_id
+ or version.visibility in {"workspace", "public"}
+ )
+ ]
+ data = [
+ {
+ "versions_id": version.versions_id,
+ "version_label": version.version_label,
+ "script_id": script.script_id,
+ "script_name": script.script_name,
+ "script_type": script.script_type,
+ "content_hash": version.content_hash,
+ "file_size_bytes": version.file_size_bytes,
+ "visibility": version.visibility,
+ "created_by": version.created_by,
+ "created_at": _iso(version.created_at),
+ }
+ for version, script in visible
+ ]
+ return {
+ "request_id": context.request_id,
+ "data": data,
+ "meta": {"count": len(data)},
+ }
+
+
+@router.get("/api/v1/schedules")
+async def list_schedules(
+ context: RequestContext = Depends(request_context),
+ session: AsyncSession = Depends(database_session),
+) -> dict[str, Any]:
+ items = list(
+ (
+ await session.scalars(
+ select(Schedules)
+ .where(
+ Schedules.workspace_id == context.workspace.workspace_id,
+ Schedules.deleted_at.is_(None),
+ )
+ .order_by(Schedules.updated_at.desc())
+ )
+ ).all()
+ )
+ ids = [item.schedule_id for item in items]
+ node_counts: dict[str, int] = {}
+ edge_counts: dict[str, int] = {}
+ if ids:
+ node_counts = {
+ schedule_id: int(count)
+ for schedule_id, count in (
+ await session.execute(
+ select(
+ ScheduleNodes.schedule_id,
+ func.count(ScheduleNodes.node_id),
+ )
+ .where(ScheduleNodes.schedule_id.in_(ids))
+ .group_by(ScheduleNodes.schedule_id)
+ )
+ ).all()
+ }
+ edge_counts = {
+ schedule_id: int(count)
+ for schedule_id, count in (
+ await session.execute(
+ select(
+ ScheduleEdges.schedule_id,
+ func.count(ScheduleEdges.edge_id),
+ )
+ .where(ScheduleEdges.schedule_id.in_(ids))
+ .group_by(ScheduleEdges.schedule_id)
+ )
+ ).all()
+ }
+ return {
+ "request_id": context.request_id,
+ "data": [
+ schedule_payload(
+ item,
+ node_count=node_counts.get(item.schedule_id, 0),
+ edge_count=edge_counts.get(item.schedule_id, 0),
+ )
+ for item in items
+ ],
+ "meta": {"count": len(items)},
+ }
+
+
+@router.post(
+ "/api/v1/schedules",
+ status_code=status.HTTP_201_CREATED,
+)
+async def create_schedule(
+ payload: CreateScheduleRequest,
+ context: RequestContext = Depends(request_context),
+ session: AsyncSession = Depends(database_session),
+) -> dict[str, Any]:
+ duplicate = await session.scalar(
+ select(Schedules).where(
+ Schedules.workspace_id == context.workspace.workspace_id,
+ Schedules.schedule_name == payload.schedule_name,
+ Schedules.deleted_at.is_(None),
+ )
+ )
+ if duplicate is not None:
+ raise HTTPException(
+ status.HTTP_409_CONFLICT,
+ "an active schedule with the same name already exists",
+ )
+ if payload.enabled:
+ raise HTTPException(
+ status.HTTP_409_CONFLICT,
+ "a new empty schedule cannot be enabled",
+ )
+ item = Schedules(
+ schedule_id=new_ulid(),
+ workspace_id=context.workspace.workspace_id,
+ schedule_name=payload.schedule_name,
+ description=payload.description,
+ trigger_type=payload.trigger_type,
+ cron_expression=payload.cron_expression,
+ timezone=payload.timezone,
+ enabled=0,
+ workflow_version=1,
+ max_concurrency=payload.max_concurrency,
+ failure_policy=payload.failure_policy,
+ created_by=context.user.user_id,
+ updated_by=context.user.user_id,
+ )
+ _apply_next_run(item)
+ session.add(item)
+ await session.flush()
+ await session.refresh(item)
+ return {
+ "request_id": context.request_id,
+ "data": await detail_payload(item, session),
+ "meta": {},
+ }
+
+
+@router.get("/api/v1/schedules/{schedule_id}")
+async def get_schedule(
+ schedule_id: str,
+ context: RequestContext = Depends(request_context),
+ session: AsyncSession = Depends(database_session),
+) -> dict[str, Any]:
+ item = await schedule_row(schedule_id, context, session)
+ return {
+ "request_id": context.request_id,
+ "data": await detail_payload(item, session),
+ "meta": {},
+ }
+
+
+@router.put("/api/v1/schedules/{schedule_id}")
+@router.patch("/api/v1/schedules/{schedule_id}")
+async def update_schedule(
+ schedule_id: str,
+ payload: UpdateScheduleRequest,
+ context: RequestContext = Depends(request_context),
+ session: AsyncSession = Depends(database_session),
+) -> dict[str, Any]:
+ item = await schedule_row(
+ schedule_id,
+ context,
+ session,
+ for_update=True,
+ )
+ require_workflow_version(item, payload.workflow_version)
+ mutable_fields = {
+ "schedule_name",
+ "description",
+ "trigger_type",
+ "cron_expression",
+ "timezone",
+ "enabled",
+ "max_concurrency",
+ "failure_policy",
+ }
+ if (
+ "schedule_name" in payload.model_fields_set
+ and payload.schedule_name != item.schedule_name
+ ):
+ duplicate = await session.scalar(
+ select(Schedules).where(
+ Schedules.workspace_id == context.workspace.workspace_id,
+ Schedules.schedule_name == payload.schedule_name,
+ Schedules.schedule_id != schedule_id,
+ Schedules.deleted_at.is_(None),
+ )
+ )
+ if duplicate is not None:
+ raise HTTPException(
+ status.HTTP_409_CONFLICT,
+ "an active schedule with the same name already exists",
+ )
+ for field in mutable_fields & payload.model_fields_set:
+ value = getattr(payload, field)
+ setattr(item, field, int(value) if field == "enabled" else value)
+ if (
+ "trigger_type" in payload.model_fields_set
+ and item.trigger_type != "cron"
+ and "cron_expression" not in payload.model_fields_set
+ ):
+ item.cron_expression = None
+ _apply_next_run(item)
+ await _require_valid_when_enabled(item, session)
+ item.updated_by = context.user.user_id
+ item.workflow_version += 1
+ await session.flush()
+ await session.refresh(item)
+ return {
+ "request_id": context.request_id,
+ "data": await detail_payload(item, session),
+ "meta": {},
+ }
+
+
+@router.delete("/api/v1/schedules/{schedule_id}")
+async def delete_schedule(
+ schedule_id: str,
+ payload: WorkflowVersionRequest,
+ context: RequestContext = Depends(request_context),
+ session: AsyncSession = Depends(database_session),
+) -> dict[str, Any]:
+ item = await schedule_row(
+ schedule_id,
+ context,
+ session,
+ for_update=True,
+ )
+ require_workflow_version(item, payload.workflow_version)
+ item.enabled = 0
+ item.next_run_at = None
+ item.deleted_at = _mysql_utc(datetime.now(UTC))
+ item.updated_by = context.user.user_id
+ item.workflow_version += 1
+ await session.flush()
+ return {
+ "request_id": context.request_id,
+ "data": {
+ "schedule_id": item.schedule_id,
+ "deleted": True,
+ "workflow_version": item.workflow_version,
+ },
+ "meta": {},
+ }
+
+
+@router.post(
+ "/api/v1/schedules/{schedule_id}/nodes",
+ status_code=status.HTTP_201_CREATED,
+)
+async def create_schedule_node(
+ schedule_id: str,
+ payload: CreateScheduleNodeRequest,
+ context: RequestContext = Depends(request_context),
+ session: AsyncSession = Depends(database_session),
+) -> dict[str, Any]:
+ schedule = await schedule_row(
+ schedule_id,
+ context,
+ session,
+ for_update=True,
+ )
+ require_workflow_version(schedule, payload.workflow_version)
+ duplicate = await session.scalar(
+ select(ScheduleNodes).where(
+ ScheduleNodes.schedule_id == schedule_id,
+ ScheduleNodes.node_key == payload.node_key,
+ )
+ )
+ if duplicate is not None:
+ raise HTTPException(
+ status.HTTP_409_CONFLICT,
+ "node_key already exists in this schedule",
+ )
+ await accessible_version(payload.versions_id, context, session)
+ node = ScheduleNodes(
+ node_id=new_ulid(),
+ schedule_id=schedule_id,
+ node_key=payload.node_key,
+ node_name=payload.node_name,
+ versions_id=payload.versions_id,
+ timeout_seconds=payload.timeout_seconds,
+ retry_count=payload.retry_count,
+ retry_interval_sec=payload.retry_interval_sec,
+ position_x=Decimal(str(payload.position_x)),
+ position_y=Decimal(str(payload.position_y)),
+ arguments_json=payload.arguments_json,
+ env_refs_json=payload.env_refs_json,
+ )
+ session.add(node)
+ schedule.updated_by = context.user.user_id
+ schedule.workflow_version += 1
+ await session.flush()
+ return {
+ "request_id": context.request_id,
+ "data": await detail_payload(schedule, session),
+ "meta": {"created_node_id": node.node_id},
+ }
+
+
+@router.put("/api/v1/schedules/{schedule_id}/nodes/{node_id}")
+async def update_schedule_node(
+ schedule_id: str,
+ node_id: str,
+ payload: UpdateScheduleNodeRequest,
+ context: RequestContext = Depends(request_context),
+ session: AsyncSession = Depends(database_session),
+) -> dict[str, Any]:
+ schedule = await schedule_row(
+ schedule_id,
+ context,
+ session,
+ for_update=True,
+ )
+ require_workflow_version(schedule, payload.workflow_version)
+ node = await session.scalar(
+ select(ScheduleNodes).where(
+ ScheduleNodes.node_id == node_id,
+ ScheduleNodes.schedule_id == schedule_id,
+ )
+ )
+ if node is None:
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "schedule node not found")
+ if (
+ "versions_id" in payload.model_fields_set
+ and payload.versions_id is not None
+ ):
+ await accessible_version(payload.versions_id, context, session)
+ mutable_fields = {
+ "node_name",
+ "versions_id",
+ "timeout_seconds",
+ "retry_count",
+ "retry_interval_sec",
+ "position_x",
+ "position_y",
+ "arguments_json",
+ "env_refs_json",
+ }
+ for field in mutable_fields & payload.model_fields_set:
+ value = getattr(payload, field)
+ if field in {"position_x", "position_y"} and value is not None:
+ value = Decimal(str(value))
+ setattr(node, field, value)
+ schedule.updated_by = context.user.user_id
+ schedule.workflow_version += 1
+ await session.flush()
+ return {
+ "request_id": context.request_id,
+ "data": await detail_payload(schedule, session),
+ "meta": {"updated_node_id": node.node_id},
+ }
+
+
+@router.delete("/api/v1/schedules/{schedule_id}/nodes/{node_id}")
+async def delete_schedule_node(
+ schedule_id: str,
+ node_id: str,
+ payload: WorkflowVersionRequest,
+ context: RequestContext = Depends(request_context),
+ session: AsyncSession = Depends(database_session),
+) -> dict[str, Any]:
+ schedule = await schedule_row(
+ schedule_id,
+ context,
+ session,
+ for_update=True,
+ )
+ require_workflow_version(schedule, payload.workflow_version)
+ node = await session.scalar(
+ select(ScheduleNodes).where(
+ ScheduleNodes.node_id == node_id,
+ ScheduleNodes.schedule_id == schedule_id,
+ )
+ )
+ if node is None:
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "schedule node not found")
+ has_runs = await session.scalar(
+ select(func.count())
+ .select_from(ScheduleNodeRuns)
+ .where(ScheduleNodeRuns.node_id == node_id)
+ )
+ if int(has_runs or 0):
+ raise HTTPException(
+ status.HTTP_409_CONFLICT,
+ "a node with execution history cannot be deleted",
+ )
+ await session.execute(
+ delete(ScheduleEdges).where(
+ ScheduleEdges.schedule_id == schedule_id,
+ or_(
+ ScheduleEdges.source_node_id == node_id,
+ ScheduleEdges.target_node_id == node_id,
+ ),
+ )
+ )
+ await session.delete(node)
+ schedule.updated_by = context.user.user_id
+ schedule.workflow_version += 1
+ await session.flush()
+ return {
+ "request_id": context.request_id,
+ "data": await detail_payload(schedule, session),
+ "meta": {"deleted_node_id": node_id},
+ }
+
+
+@router.post(
+ "/api/v1/schedules/{schedule_id}/edges",
+ status_code=status.HTTP_201_CREATED,
+)
+async def create_schedule_edge(
+ schedule_id: str,
+ payload: CreateScheduleEdgeRequest,
+ context: RequestContext = Depends(request_context),
+ session: AsyncSession = Depends(database_session),
+) -> dict[str, Any]:
+ schedule = await schedule_row(
+ schedule_id,
+ context,
+ session,
+ for_update=True,
+ )
+ require_workflow_version(schedule, payload.workflow_version)
+ node_ids = set(
+ (
+ await session.scalars(
+ select(ScheduleNodes.node_id).where(
+ ScheduleNodes.schedule_id == schedule_id,
+ ScheduleNodes.node_id.in_(
+ [payload.source_node_id, payload.target_node_id]
+ ),
+ )
+ )
+ ).all()
+ )
+ if node_ids != {payload.source_node_id, payload.target_node_id}:
+ raise HTTPException(
+ status.HTTP_422_UNPROCESSABLE_ENTITY,
+ "both edge nodes must belong to the schedule",
+ )
+ duplicate = await session.scalar(
+ select(ScheduleEdges).where(
+ ScheduleEdges.schedule_id == schedule_id,
+ ScheduleEdges.source_node_id == payload.source_node_id,
+ ScheduleEdges.target_node_id == payload.target_node_id,
+ )
+ )
+ if duplicate is not None:
+ raise HTTPException(
+ status.HTTP_409_CONFLICT,
+ "directed edge already exists",
+ )
+ edge = ScheduleEdges(
+ edge_id=new_ulid(),
+ schedule_id=schedule_id,
+ source_node_id=payload.source_node_id,
+ target_node_id=payload.target_node_id,
+ condition_expr=payload.condition_expr,
+ )
+ session.add(edge)
+ await session.flush()
+ node_rows, edges = await graph_rows(schedule_id, session)
+ validation = validate_dag([row[0] for row in node_rows], edges)
+ if not validation["valid"]:
+ raise HTTPException(
+ status.HTTP_409_CONFLICT,
+ detail={
+ "code": "DAG_INVALID",
+ "message": "edge would make the schedule graph invalid",
+ "validation": validation,
+ },
+ )
+ schedule.updated_by = context.user.user_id
+ schedule.workflow_version += 1
+ await session.flush()
+ return {
+ "request_id": context.request_id,
+ "data": await detail_payload(schedule, session),
+ "meta": {"created_edge_id": edge.edge_id},
+ }
+
+
+@router.put("/api/v1/schedules/{schedule_id}/edges/{edge_id}")
+async def update_schedule_edge(
+ schedule_id: str,
+ edge_id: str,
+ payload: UpdateScheduleEdgeRequest,
+ context: RequestContext = Depends(request_context),
+ session: AsyncSession = Depends(database_session),
+) -> dict[str, Any]:
+ schedule = await schedule_row(
+ schedule_id,
+ context,
+ session,
+ for_update=True,
+ )
+ require_workflow_version(schedule, payload.workflow_version)
+ edge = await session.scalar(
+ select(ScheduleEdges).where(
+ ScheduleEdges.edge_id == edge_id,
+ ScheduleEdges.schedule_id == schedule_id,
+ )
+ )
+ if edge is None:
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "schedule edge not found")
+ edge.condition_expr = payload.condition_expr
+ schedule.updated_by = context.user.user_id
+ schedule.workflow_version += 1
+ await session.flush()
+ return {
+ "request_id": context.request_id,
+ "data": await detail_payload(schedule, session),
+ "meta": {"updated_edge_id": edge.edge_id},
+ }
+
+
+@router.delete("/api/v1/schedules/{schedule_id}/edges/{edge_id}")
+async def delete_schedule_edge(
+ schedule_id: str,
+ edge_id: str,
+ payload: WorkflowVersionRequest,
+ context: RequestContext = Depends(request_context),
+ session: AsyncSession = Depends(database_session),
+) -> dict[str, Any]:
+ schedule = await schedule_row(
+ schedule_id,
+ context,
+ session,
+ for_update=True,
+ )
+ require_workflow_version(schedule, payload.workflow_version)
+ edge = await session.scalar(
+ select(ScheduleEdges).where(
+ ScheduleEdges.edge_id == edge_id,
+ ScheduleEdges.schedule_id == schedule_id,
+ )
+ )
+ if edge is None:
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "schedule edge not found")
+ await session.delete(edge)
+ schedule.updated_by = context.user.user_id
+ schedule.workflow_version += 1
+ await session.flush()
+ return {
+ "request_id": context.request_id,
+ "data": await detail_payload(schedule, session),
+ "meta": {"deleted_edge_id": edge_id},
+ }
+
+
+@router.post("/api/v1/schedules/{schedule_id}/validate")
+async def validate_schedule(
+ schedule_id: str,
+ context: RequestContext = Depends(request_context),
+ session: AsyncSession = Depends(database_session),
+) -> dict[str, Any]:
+ item = await schedule_row(schedule_id, context, session)
+ node_rows, edges = await graph_rows(schedule_id, session)
+ return {
+ "request_id": context.request_id,
+ "data": {
+ "schedule_id": item.schedule_id,
+ "workflow_version": item.workflow_version,
+ **validate_dag([row[0] for row in node_rows], edges),
+ },
+ "meta": {},
+ }
diff --git a/backend/src/backend/schemas.py b/backend/src/backend/schemas.py
new file mode 100644
index 0000000..33c9c01
--- /dev/null
+++ b/backend/src/backend/schemas.py
@@ -0,0 +1,72 @@
+from __future__ import annotations
+
+from typing import Literal
+
+from pydantic import BaseModel, ConfigDict, Field, field_validator
+
+
+class StrictModel(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+
+
+class CreateResourceUploadRequest(StrictModel):
+ file_name: str = Field(min_length=1, max_length=255)
+ content_type: str = Field(min_length=1, max_length=255)
+ expected_size_bytes: int = Field(ge=0, le=100 * 1024 * 1024)
+ expected_hash: str | None = Field(default=None, min_length=64, max_length=64)
+
+ @field_validator("expected_hash")
+ @classmethod
+ def validate_hash(cls, value: str | None) -> str | None:
+ if value is None:
+ return None
+ normalized = value.lower()
+ if any(character not in "0123456789abcdef" for character in normalized):
+ raise ValueError("expected_hash must be SHA-256 hex")
+ return normalized
+
+
+class CompleteResourceUploadRequest(StrictModel):
+ resource_name: str = Field(min_length=1, max_length=255)
+ description: str | None = Field(default=None, max_length=1000)
+ visibility: Literal["private", "workspace", "public"] = "private"
+
+
+class CreateScriptRequest(StrictModel):
+ script_name: str = Field(min_length=1, max_length=255)
+ script_type: Literal["python", "notebook"]
+ content: str = Field(max_length=10 * 1024 * 1024)
+ visibility: Literal["private", "workspace", "public"] = "private"
+ parent_path: str | None = Field(default=None, max_length=1024)
+
+
+class CreateWorkspaceDirectoryRequest(StrictModel):
+ directory_name: str = Field(min_length=1, max_length=255)
+ parent_path: str = Field(default="", max_length=1024)
+
+
+class UpdateScriptRequest(StrictModel):
+ content: str = Field(max_length=10 * 1024 * 1024)
+
+
+class PublishVersionRequest(StrictModel):
+ source_object_id: str | None = Field(
+ default=None,
+ min_length=26,
+ max_length=26,
+ )
+ release_note: str | None = Field(default=None, max_length=1000)
+ visibility: Literal["private", "workspace", "public"] = "workspace"
+
+
+class DownloadUrlRequest(StrictModel):
+ expires_seconds: int = Field(default=300, ge=30, le=3600)
+
+
+class FileLockTokenRequest(StrictModel):
+ lock_token: str = Field(min_length=32, max_length=256)
+
+
+class CreateJupyterAccessTicketRequest(StrictModel):
+ edit_session_id: str = Field(min_length=26, max_length=26)
+ lock_token: str = Field(min_length=32, max_length=256)
diff --git a/backend/src/backend/scripts.py b/backend/src/backend/scripts.py
new file mode 100644
index 0000000..54f1a11
--- /dev/null
+++ b/backend/src/backend/scripts.py
@@ -0,0 +1,925 @@
+from __future__ import annotations
+
+import hashlib
+import json
+import mimetypes
+import os
+import shutil
+import tempfile
+from datetime import UTC, datetime
+from pathlib import Path, PurePosixPath
+from typing import Any
+
+from fastapi import (
+ APIRouter,
+ Depends,
+ Header,
+ HTTPException,
+ Query,
+ Request,
+ status,
+)
+from sqlalchemy import func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from common.db.models import (
+ EditSessions,
+ Scripts,
+ StorageObjects,
+ Versions,
+)
+from common.ids import new_ulid
+from backend.dependencies import (
+ RequestContext,
+ database_session,
+ request_context,
+)
+from backend.schemas import (
+ CreateScriptRequest,
+ CreateWorkspaceDirectoryRequest,
+ DownloadUrlRequest,
+ PublishVersionRequest,
+ UpdateScriptRequest,
+)
+
+router = APIRouter(tags=["scripts"])
+
+
+def normalize_user_path(value: str, *, allow_empty: bool = True) -> str:
+ normalized = value.replace("\\", "/").strip().strip("/")
+ if not normalized and allow_empty:
+ return ""
+ pure_path = PurePosixPath(normalized)
+ if (
+ pure_path.is_absolute()
+ or not pure_path.parts
+ or any(
+ part in {"", ".", ".."} or any(ord(char) < 32 for char in part)
+ for part in pure_path.parts
+ )
+ ):
+ raise HTTPException(
+ status.HTTP_422_UNPROCESSABLE_ENTITY,
+ "invalid workspace path",
+ )
+ return pure_path.as_posix()
+
+
+def safe_directory_name(value: str) -> str:
+ name = value.strip()
+ if (
+ not name
+ or name in {".", ".."}
+ or name.startswith(".")
+ or "/" in name
+ or "\\" in name
+ or any(ord(char) < 32 for char in name)
+ ):
+ raise HTTPException(
+ status.HTTP_422_UNPROCESSABLE_ENTITY,
+ "invalid directory_name",
+ )
+ return name
+
+
+def user_relative_path(context: RequestContext, child_path: str = "") -> str:
+ base = f"users/{context.user.username}"
+ normalized = normalize_user_path(child_path)
+ return f"{base}/{normalized}" if normalized else base
+
+
+def safe_script_name(value: str, script_type: str) -> str:
+ name = value.replace("\\", "/").rsplit("/", 1)[-1].strip()
+ if not name or name in {".", ".."} or any(ord(char) < 32 for char in name):
+ raise HTTPException(
+ status.HTTP_422_UNPROCESSABLE_ENTITY,
+ "invalid script_name",
+ )
+ expected_suffix = ".py" if script_type == "python" else ".ipynb"
+ if not name.lower().endswith(expected_suffix):
+ name += expected_suffix
+ return name
+
+
+def validate_script_content(content: str, script_type: str) -> bytes:
+ encoded = content.encode("utf-8")
+ if len(encoded) > 10 * 1024 * 1024:
+ raise HTTPException(
+ status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
+ "script exceeds 10 MiB",
+ )
+ if script_type == "notebook":
+ try:
+ notebook = json.loads(content)
+ except json.JSONDecodeError as exc:
+ raise HTTPException(
+ status.HTTP_422_UNPROCESSABLE_ENTITY,
+ "notebook content must be valid JSON",
+ ) from exc
+ if not isinstance(notebook, dict) or not isinstance(
+ notebook.get("cells"),
+ list,
+ ):
+ raise HTTPException(
+ status.HTTP_422_UNPROCESSABLE_ENTITY,
+ "notebook must contain a cells array",
+ )
+ return encoded
+
+
+def workspace_target(
+ context: RequestContext,
+ relative_path: str,
+) -> Path:
+ root = Path(
+ os.getenv("WORKSPACE_ROOT", "/workspace/workspaces")
+ ).resolve()
+ scoped_root = (root / context.workspace.workspace_code).resolve()
+ pure_path = PurePosixPath(relative_path)
+ target = (scoped_root / Path(*pure_path.parts)).resolve()
+ if scoped_root not in target.parents:
+ raise HTTPException(
+ status.HTTP_422_UNPROCESSABLE_ENTITY,
+ "script path escapes workspace root",
+ )
+ return target
+
+
+def apply_workspace_permissions(path: Path, mode: int) -> None:
+ os.chmod(path, mode)
+ if os.name != "nt":
+ shared_gid = int(os.getenv("WORKSPACE_SHARED_GID", "100"))
+ os.chown(path, -1, shared_gid)
+
+
+def atomic_write(target: Path, content: bytes) -> None:
+ target.parent.mkdir(parents=True, exist_ok=True)
+ apply_workspace_permissions(target.parent, 0o2770)
+ descriptor, temporary_name = tempfile.mkstemp(
+ prefix=f".{target.name}.",
+ suffix=".tmp",
+ dir=target.parent,
+ )
+ try:
+ with os.fdopen(descriptor, "wb") as handle:
+ handle.write(content)
+ handle.flush()
+ os.fsync(handle.fileno())
+ os.replace(temporary_name, target)
+ apply_workspace_permissions(target, 0o660)
+ finally:
+ if os.path.exists(temporary_name):
+ os.unlink(temporary_name)
+
+
+def script_payload(
+ script: Scripts,
+ storage_object: StorageObjects | dict[str, Any],
+) -> dict[str, Any]:
+ if isinstance(storage_object, dict):
+ relative_path = storage_object.get("relative_path")
+ content_hash = storage_object.get("content_hash")
+ size_bytes = storage_object.get("size_bytes", 0)
+ else:
+ relative_path = storage_object.relative_path
+ content_hash = storage_object.content_hash
+ size_bytes = storage_object.size_bytes
+ return {
+ "script_id": script.script_id,
+ "workspace_id": script.workspace_id,
+ "current_object_id": script.current_object_id,
+ "owner_user_id": script.owner_user_id,
+ "script_name": script.script_name,
+ "script_type": script.script_type,
+ "visibility": script.visibility,
+ "status": script.status,
+ "relative_path": relative_path,
+ "content_hash": content_hash,
+ "size_bytes": size_bytes,
+ "created_at": script.created_at.isoformat(),
+ "updated_at": script.updated_at.isoformat(),
+ }
+
+
+def version_payload(version: Versions) -> dict[str, Any]:
+ return {
+ "versions_id": version.versions_id,
+ "workspace_id": version.workspace_id,
+ "script_id": version.script_id,
+ "source_object_id": version.source_object_id,
+ "artifact_object_id": version.artifact_object_id,
+ "version_no": version.version_no,
+ "version_label": version.version_label,
+ "source_path": version.source_path,
+ "artifact_path": version.artifact_path,
+ "content_hash": version.content_hash,
+ "file_size_bytes": version.file_size_bytes,
+ "visibility": version.visibility,
+ "release_note": version.release_note,
+ "created_by": version.created_by,
+ "created_at": version.created_at.isoformat(),
+ }
+
+
+async def get_script_row(
+ script_id: str,
+ context: RequestContext,
+ session: AsyncSession,
+ *,
+ for_update: bool = False,
+) -> tuple[Scripts, StorageObjects]:
+ if for_update:
+ script = await session.scalar(
+ select(Scripts)
+ .where(
+ Scripts.script_id == script_id,
+ Scripts.workspace_id == context.workspace.workspace_id,
+ Scripts.status == "active",
+ )
+ .with_for_update()
+ )
+ if script is None:
+ raise HTTPException(
+ status.HTTP_404_NOT_FOUND,
+ "script not found",
+ )
+ storage_object = await session.get(
+ StorageObjects,
+ script.current_object_id,
+ )
+ if storage_object is None:
+ raise HTTPException(
+ status.HTTP_409_CONFLICT,
+ "script working-copy metadata is missing",
+ )
+ row = (script, storage_object)
+ else:
+ statement = (
+ select(Scripts, StorageObjects)
+ .join(
+ StorageObjects,
+ StorageObjects.storage_object_id
+ == Scripts.current_object_id,
+ )
+ .where(
+ Scripts.script_id == script_id,
+ Scripts.workspace_id == context.workspace.workspace_id,
+ Scripts.status == "active",
+ )
+ )
+ row = (await session.execute(statement)).one_or_none()
+ if row is None:
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "script not found")
+ script, storage_object = row
+ return script, storage_object
+
+
+async def require_no_active_edit_session(
+ session: AsyncSession,
+ storage_object_ids: list[str],
+) -> None:
+ if not storage_object_ids:
+ return
+ active_session = await session.scalar(
+ select(EditSessions).where(
+ EditSessions.storage_object_id.in_(storage_object_ids),
+ EditSessions.session_status == "active",
+ EditSessions.expires_at > datetime.now(UTC).replace(tzinfo=None),
+ )
+ )
+ if active_session is not None:
+ raise HTTPException(
+ status.HTTP_409_CONFLICT,
+ "file is being edited; end the editing session before deletion",
+ )
+
+
+async def create_script_record(
+ *,
+ name: str,
+ script_type: str,
+ content: bytes,
+ visibility: str,
+ parent_path: str | None,
+ request: Request,
+ context: RequestContext,
+ session: AsyncSession,
+) -> tuple[Scripts, dict[str, Any]]:
+ folder = (
+ "scripts" if script_type == "python" else "notebooks"
+ ) if parent_path is None else normalize_user_path(parent_path)
+ child_path = f"{folder}/{name}" if folder else name
+ relative_path = user_relative_path(context, child_path)
+ target = workspace_target(context, relative_path)
+
+ existing_script = await session.scalar(
+ select(Scripts)
+ .join(
+ StorageObjects,
+ StorageObjects.storage_object_id == Scripts.current_object_id,
+ )
+ .where(
+ Scripts.workspace_id == context.workspace.workspace_id,
+ StorageObjects.relative_path == relative_path,
+ Scripts.status == "active",
+ )
+ )
+ if existing_script is not None or target.exists():
+ raise HTTPException(
+ status.HTTP_409_CONFLICT,
+ "a file with the same path already exists",
+ )
+
+ atomic_write(target, content)
+ storage_data = await request.app.state.storage_client.register_workspace_object(
+ {
+ "workspace_id": context.workspace.workspace_id,
+ "user_id": context.user.user_id,
+ "relative_path": relative_path,
+ "usage_type": "working_copy",
+ "visibility": visibility,
+ }
+ )
+ object_id = storage_data["storage_object_id"]
+ script = await session.scalar(
+ select(Scripts).where(Scripts.current_object_id == object_id)
+ )
+ now = datetime.now(UTC).replace(tzinfo=None)
+ if script is None:
+ script = Scripts(
+ script_id=new_ulid(),
+ workspace_id=context.workspace.workspace_id,
+ current_object_id=object_id,
+ owner_user_id=context.user.user_id,
+ script_name=name,
+ script_type=script_type,
+ visibility=visibility,
+ status="active",
+ )
+ session.add(script)
+ else:
+ script.owner_user_id = context.user.user_id
+ script.script_name = name
+ script.script_type = script_type
+ script.visibility = visibility
+ script.status = "active"
+ script.deleted_at = None
+ script.updated_at = now
+ await session.flush()
+ await session.refresh(script)
+ return script, storage_data
+
+
+@router.post("/api/v1/scripts", status_code=status.HTTP_201_CREATED)
+async def create_script(
+ payload: CreateScriptRequest,
+ request: Request,
+ context: RequestContext = Depends(request_context),
+ session: AsyncSession = Depends(database_session),
+) -> dict[str, Any]:
+ name = safe_script_name(payload.script_name, payload.script_type)
+ content = validate_script_content(payload.content, payload.script_type)
+ script, storage_data = await create_script_record(
+ name=name,
+ script_type=payload.script_type,
+ content=content,
+ visibility=payload.visibility,
+ parent_path=payload.parent_path,
+ request=request,
+ context=context,
+ session=session,
+ )
+ return {
+ "request_id": context.request_id,
+ "data": script_payload(script, storage_data),
+ "meta": {},
+ }
+
+
+@router.post(
+ "/api/v1/scripts/upload",
+ status_code=status.HTTP_201_CREATED,
+)
+async def upload_script(
+ request: Request,
+ file_name: str = Query(min_length=1, max_length=255),
+ parent_path: str = Query(default="", max_length=1024),
+ visibility: str = Query(
+ default="workspace",
+ pattern="^(private|workspace|public)$",
+ ),
+ context: RequestContext = Depends(request_context),
+ session: AsyncSession = Depends(database_session),
+) -> dict[str, Any]:
+ suffix = Path(file_name).suffix.lower()
+ if suffix not in {".py", ".ipynb"}:
+ raise HTTPException(
+ status.HTTP_422_UNPROCESSABLE_ENTITY,
+ "only .py and .ipynb files can be uploaded",
+ )
+ script_type = "notebook" if suffix == ".ipynb" else "python"
+ name = safe_script_name(file_name, script_type)
+ body = await request.body()
+ if len(body) > 10 * 1024 * 1024:
+ raise HTTPException(
+ status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
+ "script exceeds 10 MiB",
+ )
+ try:
+ text = body.decode("utf-8")
+ except UnicodeDecodeError as exc:
+ raise HTTPException(
+ status.HTTP_422_UNPROCESSABLE_ENTITY,
+ "script must use UTF-8 encoding",
+ ) from exc
+ content = validate_script_content(text, script_type)
+ script, storage_data = await create_script_record(
+ name=name,
+ script_type=script_type,
+ content=content,
+ visibility=visibility,
+ parent_path=parent_path,
+ request=request,
+ context=context,
+ session=session,
+ )
+ return {
+ "request_id": context.request_id,
+ "data": script_payload(script, storage_data),
+ "meta": {},
+ }
+
+
+@router.get("/api/v1/workspace-tree")
+async def get_workspace_tree(
+ context: RequestContext = Depends(request_context),
+) -> dict[str, Any]:
+ root = workspace_target(context, user_relative_path(context))
+ root.mkdir(parents=True, exist_ok=True)
+ apply_workspace_permissions(root, 0o2770)
+ directories: list[dict[str, str]] = []
+ for current, names, _files in os.walk(root, followlinks=False):
+ names[:] = sorted(
+ name
+ for name in names
+ if not name.startswith(".")
+ and not (Path(current) / name).is_symlink()
+ )
+ current_path = Path(current)
+ if current_path == root:
+ continue
+ relative = current_path.relative_to(root).as_posix()
+ parent = PurePosixPath(relative).parent.as_posix()
+ directories.append(
+ {
+ "path": relative,
+ "name": current_path.name,
+ "parent_path": "" if parent == "." else parent,
+ }
+ )
+ return {
+ "request_id": context.request_id,
+ "data": {"directories": directories},
+ "meta": {"directory_count": len(directories)},
+ }
+
+
+@router.post(
+ "/api/v1/workspace-directories",
+ status_code=status.HTTP_201_CREATED,
+)
+async def create_workspace_directory(
+ payload: CreateWorkspaceDirectoryRequest,
+ context: RequestContext = Depends(request_context),
+) -> dict[str, Any]:
+ name = safe_directory_name(payload.directory_name)
+ parent = normalize_user_path(payload.parent_path)
+ child_path = f"{parent}/{name}" if parent else name
+ target = workspace_target(context, user_relative_path(context, child_path))
+ parent_target = target.parent
+ user_root = workspace_target(context, user_relative_path(context))
+ if (
+ not parent_target.is_dir()
+ or (
+ parent_target != user_root
+ and user_root not in parent_target.parents
+ )
+ ):
+ raise HTTPException(
+ status.HTTP_404_NOT_FOUND,
+ "parent directory not found",
+ )
+ if target.exists():
+ raise HTTPException(
+ status.HTTP_409_CONFLICT,
+ "a file or directory with the same path already exists",
+ )
+ target.mkdir(parents=False)
+ apply_workspace_permissions(target, 0o2770)
+ return {
+ "request_id": context.request_id,
+ "data": {
+ "path": child_path,
+ "name": name,
+ "parent_path": parent,
+ },
+ "meta": {},
+ }
+
+
+@router.delete("/api/v1/workspace-directories")
+async def delete_workspace_directory(
+ request: Request,
+ path: str = Query(min_length=1, max_length=1024),
+ context: RequestContext = Depends(request_context),
+ session: AsyncSession = Depends(database_session),
+) -> dict[str, Any]:
+ directory_path = normalize_user_path(path, allow_empty=False)
+ relative_prefix = user_relative_path(context, directory_path)
+ target = workspace_target(context, relative_prefix)
+ if not target.is_dir() or target.is_symlink():
+ raise HTTPException(
+ status.HTTP_404_NOT_FOUND,
+ "directory not found",
+ )
+
+ rows = (
+ await session.execute(
+ select(Scripts, StorageObjects)
+ .join(
+ StorageObjects,
+ StorageObjects.storage_object_id == Scripts.current_object_id,
+ )
+ .where(
+ Scripts.workspace_id == context.workspace.workspace_id,
+ Scripts.owner_user_id == context.user.user_id,
+ Scripts.status == "active",
+ StorageObjects.relative_path.startswith(
+ f"{relative_prefix}/"
+ ),
+ )
+ )
+ ).all()
+ await require_no_active_edit_session(
+ session,
+ [script.current_object_id for script, _storage in rows],
+ )
+ for script, _storage in rows:
+ await request.app.state.storage_client.delete_object(
+ script.current_object_id
+ )
+ script.status = "deleted"
+ script.deleted_at = datetime.now(UTC).replace(tzinfo=None)
+ shutil.rmtree(target)
+ return {
+ "request_id": context.request_id,
+ "data": {
+ "path": directory_path,
+ "status": "deleted",
+ "deleted_scripts": len(rows),
+ "versions_preserved": True,
+ },
+ "meta": {},
+ }
+
+
+@router.get("/api/v1/scripts")
+async def list_scripts(
+ context: RequestContext = Depends(request_context),
+ session: AsyncSession = Depends(database_session),
+) -> dict[str, Any]:
+ statement = (
+ select(Scripts, StorageObjects)
+ .join(
+ StorageObjects,
+ StorageObjects.storage_object_id == Scripts.current_object_id,
+ )
+ .where(
+ Scripts.workspace_id == context.workspace.workspace_id,
+ Scripts.status == "active",
+ )
+ .order_by(Scripts.updated_at.desc())
+ )
+ rows = (await session.execute(statement)).all()
+ return {
+ "request_id": context.request_id,
+ "data": [
+ script_payload(script, storage_object)
+ for script, storage_object in rows
+ ],
+ "meta": {"count": len(rows)},
+ }
+
+
+@router.get("/api/v1/scripts/{script_id}")
+async def get_script(
+ script_id: str,
+ context: RequestContext = Depends(request_context),
+ session: AsyncSession = Depends(database_session),
+) -> dict[str, Any]:
+ script, storage_object = await get_script_row(
+ script_id,
+ context,
+ session,
+ )
+ return {
+ "request_id": context.request_id,
+ "data": script_payload(script, storage_object),
+ "meta": {},
+ }
+
+
+@router.put("/api/v1/scripts/{script_id}")
+async def update_script(
+ script_id: str,
+ payload: UpdateScriptRequest,
+ request: Request,
+ context: RequestContext = Depends(request_context),
+ session: AsyncSession = Depends(database_session),
+) -> dict[str, Any]:
+ script, storage_object = await get_script_row(
+ script_id,
+ context,
+ session,
+ for_update=True,
+ )
+ if (
+ script.owner_user_id != context.user.user_id
+ and not context.is_admin
+ ):
+ raise HTTPException(
+ status.HTTP_403_FORBIDDEN,
+ "script can only be changed by its owner or an administrator",
+ )
+ content = validate_script_content(payload.content, script.script_type)
+ if not storage_object.relative_path:
+ raise HTTPException(
+ status.HTTP_409_CONFLICT,
+ "script has no workspace path",
+ )
+ target = workspace_target(context, storage_object.relative_path)
+ atomic_write(target, content)
+ storage_data = await request.app.state.storage_client.register_workspace_object(
+ {
+ "workspace_id": context.workspace.workspace_id,
+ "user_id": script.owner_user_id,
+ "relative_path": storage_object.relative_path,
+ "usage_type": "working_copy",
+ "visibility": script.visibility,
+ }
+ )
+ storage_object.content_hash = storage_data["content_hash"]
+ storage_object.size_bytes = storage_data["size_bytes"]
+ script.updated_at = datetime.now(UTC).replace(tzinfo=None)
+ return {
+ "request_id": context.request_id,
+ "data": script_payload(script, storage_object),
+ "meta": {},
+ }
+
+
+@router.delete("/api/v1/scripts/{script_id}")
+async def delete_script(
+ script_id: str,
+ request: Request,
+ context: RequestContext = Depends(request_context),
+ session: AsyncSession = Depends(database_session),
+) -> dict[str, Any]:
+ script, storage_object = await get_script_row(
+ script_id,
+ context,
+ session,
+ for_update=True,
+ )
+ if (
+ script.owner_user_id != context.user.user_id
+ and not context.is_admin
+ ):
+ raise HTTPException(
+ status.HTTP_403_FORBIDDEN,
+ "script can only be deleted by its owner or an administrator",
+ )
+ await require_no_active_edit_session(
+ session,
+ [script.current_object_id],
+ )
+ if storage_object.relative_path:
+ target = workspace_target(context, storage_object.relative_path)
+ if target.is_file():
+ target.unlink()
+ await request.app.state.storage_client.delete_object(
+ script.current_object_id
+ )
+ script.status = "deleted"
+ script.deleted_at = datetime.now(UTC).replace(tzinfo=None)
+ return {
+ "request_id": context.request_id,
+ "data": {
+ "script_id": script.script_id,
+ "status": script.status,
+ "versions_preserved": True,
+ },
+ "meta": {},
+ }
+
+
+@router.post(
+ "/api/v1/scripts/{script_id}/versions",
+ status_code=status.HTTP_201_CREATED,
+)
+async def publish_version(
+ script_id: str,
+ payload: PublishVersionRequest,
+ request: Request,
+ context: RequestContext = Depends(request_context),
+ session: AsyncSession = Depends(database_session),
+) -> dict[str, Any]:
+ script, source_object = await get_script_row(
+ script_id,
+ context,
+ session,
+ for_update=True,
+ )
+ if (
+ script.owner_user_id != context.user.user_id
+ and not context.is_admin
+ ):
+ raise HTTPException(
+ status.HTTP_403_FORBIDDEN,
+ "script can only be published by its owner or an administrator",
+ )
+ if (
+ payload.source_object_id
+ and payload.source_object_id != script.current_object_id
+ ):
+ raise HTTPException(
+ status.HTTP_412_PRECONDITION_FAILED,
+ "source_object_id is not the current working copy",
+ )
+ if not source_object.relative_path:
+ raise HTTPException(
+ status.HTTP_409_CONFLICT,
+ "script has no workspace path",
+ )
+ target = workspace_target(context, source_object.relative_path)
+ if not target.is_file():
+ raise HTTPException(
+ status.HTTP_409_CONFLICT,
+ "script working copy is missing",
+ )
+ content = target.read_bytes()
+ content_hash = hashlib.sha256(content).hexdigest()
+ existing = await session.scalar(
+ select(Versions).where(
+ Versions.script_id == script.script_id,
+ Versions.content_hash == content_hash,
+ )
+ )
+ if existing is not None:
+ return {
+ "request_id": context.request_id,
+ "data": version_payload(existing),
+ "meta": {"reused": True},
+ }
+ content_type = (
+ mimetypes.guess_type(script.script_name)[0]
+ or "application/octet-stream"
+ )
+ artifact = await request.app.state.storage_client.create_server_object(
+ workspace_id=context.workspace.workspace_id,
+ user_id=context.user.user_id,
+ usage_type="version_artifact",
+ file_name=script.script_name,
+ content_type=content_type,
+ content=content,
+ visibility=payload.visibility,
+ is_immutable=True,
+ idempotency_key=f"version:{script.script_id}:{content_hash}",
+ )
+ current_max = await session.scalar(
+ select(func.max(Versions.version_no)).where(
+ Versions.script_id == script.script_id
+ )
+ )
+ version_no = int(current_max or 0) + 1
+ version = Versions(
+ versions_id=new_ulid(),
+ workspace_id=context.workspace.workspace_id,
+ script_id=script.script_id,
+ source_object_id=script.current_object_id,
+ artifact_object_id=artifact["storage_object_id"],
+ version_no=version_no,
+ version_label=f"v{version_no}.0",
+ source_path=source_object.relative_path,
+ artifact_path=artifact["storage_uri"],
+ content_hash=content_hash,
+ file_size_bytes=len(content),
+ visibility=payload.visibility,
+ release_note=payload.release_note,
+ created_by=context.user.user_id,
+ )
+ session.add(version)
+ await session.flush()
+ await session.refresh(version)
+ return {
+ "request_id": context.request_id,
+ "data": version_payload(version),
+ "meta": {"reused": False},
+ }
+
+
+@router.get("/api/v1/scripts/{script_id}/versions")
+async def list_versions(
+ script_id: str,
+ context: RequestContext = Depends(request_context),
+ session: AsyncSession = Depends(database_session),
+) -> dict[str, Any]:
+ await get_script_row(script_id, context, session)
+ versions = (
+ await session.scalars(
+ select(Versions)
+ .where(Versions.script_id == script_id)
+ .order_by(Versions.version_no.desc())
+ )
+ ).all()
+ return {
+ "request_id": context.request_id,
+ "data": [version_payload(version) for version in versions],
+ "meta": {"count": len(versions)},
+ }
+
+
+@router.get("/api/v1/versions/{versions_id}")
+async def get_version(
+ versions_id: str,
+ context: RequestContext = Depends(request_context),
+ session: AsyncSession = Depends(database_session),
+) -> dict[str, Any]:
+ version = await session.get(Versions, versions_id)
+ if (
+ version is None
+ or version.workspace_id != context.workspace.workspace_id
+ ):
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "version not found")
+ return {
+ "request_id": context.request_id,
+ "data": version_payload(version),
+ "meta": {},
+ }
+
+
+@router.delete("/api/v1/versions/{versions_id}")
+async def delete_version(
+ versions_id: str,
+ context: RequestContext = Depends(request_context),
+ session: AsyncSession = Depends(database_session),
+) -> dict[str, Any]:
+ version = await session.get(Versions, versions_id)
+ if (
+ version is None
+ or version.workspace_id != context.workspace.workspace_id
+ ):
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "稳定版本不存在")
+ if (
+ version.created_by != context.user.user_id
+ and not context.is_admin
+ ):
+ raise HTTPException(
+ status.HTTP_403_FORBIDDEN,
+ "仅稳定版本发布者或管理员可以删除",
+ )
+
+ version.schedule_hidden_at = datetime.now(UTC).replace(tzinfo=None)
+ await session.flush()
+ return {
+ "request_id": context.request_id,
+ "data": {
+ "versions_id": versions_id,
+ "deleted": True,
+ "artifact_preserved": True,
+ },
+ "meta": {
+ "message": "已从调度稳定版本列表移除;稳定版本和历史记录保持不变",
+ },
+ }
+
+
+@router.post("/api/v1/versions/{versions_id}/download-url")
+async def version_download_url(
+ versions_id: str,
+ payload: DownloadUrlRequest,
+ request: Request,
+ context: RequestContext = Depends(request_context),
+ session: AsyncSession = Depends(database_session),
+) -> dict[str, Any]:
+ version = await session.get(Versions, versions_id)
+ if (
+ version is None
+ or version.workspace_id != context.workspace.workspace_id
+ ):
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "version not found")
+ data = await request.app.state.storage_client.create_download_url(
+ version.artifact_object_id,
+ payload.expires_seconds,
+ )
+ return {"request_id": context.request_id, "data": data, "meta": {}}
diff --git a/backend/src/backend/storage_api.py b/backend/src/backend/storage_api.py
new file mode 100644
index 0000000..bfd0ffc
--- /dev/null
+++ b/backend/src/backend/storage_api.py
@@ -0,0 +1,681 @@
+from __future__ import annotations
+
+import asyncio
+import base64
+import binascii
+import hashlib
+import mimetypes
+import os
+import secrets
+from contextlib import asynccontextmanager
+from datetime import UTC, datetime, timedelta
+from pathlib import Path, PurePosixPath
+from typing import Any, AsyncIterator
+
+from fastapi import Depends, Header, HTTPException, Request, status
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from common.db import create_database_engine, create_session_factory
+from common.db.models import (
+ StorageObjects,
+ UploadSessions,
+ Users,
+ WorkspaceMembers,
+ Workspaces,
+)
+from common.ids import new_ulid
+from common.service_app import create_service_app
+from common.storage import RustFSObjectStore
+from backend.storage_schemas import (
+ CompleteUploadRequest,
+ CreateUploadRequest,
+ DownloadUrlRequest,
+ RegisterWorkspaceObjectRequest,
+ ServerObjectRequest,
+)
+
+
+def utcnow() -> datetime:
+ return datetime.now(UTC).replace(tzinfo=None)
+
+
+def hash_bytes(value: str) -> bytes:
+ return hashlib.sha256(value.encode("utf-8")).digest()
+
+
+def normalized_idempotency_key(
+ workspace_id: str,
+ user_id: str,
+ value: str,
+) -> str:
+ digest = hashlib.sha256(
+ f"{workspace_id}:{user_id}:{value}".encode("utf-8")
+ ).hexdigest()
+ return f"v1:{digest}"
+
+
+def safe_file_name(value: str) -> str:
+ name = value.replace("\\", "/").rsplit("/", 1)[-1].strip()
+ if not name or name in {".", ".."} or any(ord(char) < 32 for char in name):
+ raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "invalid file_name")
+ return name
+
+
+def storage_payload(item: StorageObjects) -> dict[str, Any]:
+ return {
+ "storage_object_id": item.storage_object_id,
+ "workspace_id": item.workspace_id,
+ "owner_user_id": item.owner_user_id,
+ "storage_backend": item.storage_backend,
+ "usage_type": item.usage_type,
+ "storage_uri": item.storage_uri,
+ "relative_path": item.relative_path,
+ "file_name": item.file_name,
+ "file_extension": item.file_extension,
+ "mime_type": item.mime_type,
+ "size_bytes": item.size_bytes,
+ "content_hash": item.content_hash,
+ "visibility": item.visibility,
+ "is_immutable": bool(item.is_immutable),
+ "object_status": item.object_status,
+ "created_at": item.created_at.isoformat(),
+ "updated_at": item.updated_at.isoformat(),
+ }
+
+
+@asynccontextmanager
+async def lifespan(app: Any) -> AsyncIterator[None]:
+ database_url = os.environ["DATABASE_URL"]
+ engine = create_database_engine(database_url)
+ app.state.session_factory = create_session_factory(engine)
+ app.state.object_store = RustFSObjectStore(
+ internal_endpoint=os.getenv(
+ "RUSTFS_INTERNAL_ENDPOINT",
+ "http://rustfs:9000",
+ ),
+ public_endpoint=os.getenv(
+ "RUSTFS_PUBLIC_ENDPOINT",
+ "http://localhost:9000",
+ ),
+ access_key=os.environ["RUSTFS_ACCESS_KEY"],
+ secret_key=os.environ["RUSTFS_SECRET_KEY"],
+ )
+ app.state.default_bucket = os.getenv(
+ "RUSTFS_DEFAULT_BUCKET",
+ "model-platform",
+ )
+ await asyncio.to_thread(
+ app.state.object_store.ensure_bucket,
+ app.state.default_bucket,
+ )
+ try:
+ yield
+ finally:
+ await engine.dispose()
+
+
+app = create_service_app(
+ os.getenv("SERVICE_NAME", "storage-api"),
+ lifespan=lifespan,
+)
+
+
+async def database_session(request: Request) -> AsyncIterator[AsyncSession]:
+ async with request.app.state.session_factory() as session:
+ try:
+ yield session
+ await session.commit()
+ except Exception:
+ await session.rollback()
+ raise
+
+
+def verify_internal_service(
+ x_service_token: str = Header(alias="X-Service-Token"),
+) -> None:
+ expected = os.environ.get("INTERNAL_SERVICE_TOKEN", "")
+ if not expected or not secrets.compare_digest(expected, x_service_token):
+ raise HTTPException(
+ status.HTTP_401_UNAUTHORIZED,
+ "invalid internal service identity",
+ )
+
+
+async def require_workspace_member(
+ session: AsyncSession,
+ workspace_id: str,
+ user_id: str,
+) -> Workspaces:
+ statement = (
+ select(Workspaces)
+ .join(
+ WorkspaceMembers,
+ WorkspaceMembers.workspace_id == Workspaces.workspace_id,
+ )
+ .join(Users, Users.user_id == WorkspaceMembers.user_id)
+ .where(
+ Workspaces.workspace_id == workspace_id,
+ Workspaces.status == "active",
+ WorkspaceMembers.user_id == user_id,
+ WorkspaceMembers.member_status == "active",
+ Users.status == "active",
+ )
+ )
+ workspace = await session.scalar(statement)
+ if workspace is None:
+ raise HTTPException(
+ status.HTTP_403_FORBIDDEN,
+ "user is not an active workspace member",
+ )
+ return workspace
+
+
+async def create_upload_record(
+ payload: CreateUploadRequest,
+ session: AsyncSession,
+ request: Request,
+) -> dict[str, Any]:
+ workspace = await require_workspace_member(
+ session,
+ payload.workspace_id,
+ payload.user_id,
+ )
+ stored_key = normalized_idempotency_key(
+ payload.workspace_id,
+ payload.user_id,
+ payload.idempotency_key,
+ )
+ existing = await session.scalar(
+ select(UploadSessions).where(
+ UploadSessions.idempotency_key == stored_key
+ )
+ )
+ if existing is not None:
+ if (
+ existing.workspace_id != payload.workspace_id
+ or existing.user_id != payload.user_id
+ or existing.expected_size_bytes != payload.expected_size_bytes
+ or existing.expected_hash != payload.expected_hash
+ or existing.content_type != payload.content_type
+ ):
+ raise HTTPException(
+ status.HTTP_409_CONFLICT,
+ "idempotency key was used with different upload metadata",
+ )
+ upload = existing
+ else:
+ upload_id = new_ulid()
+ file_name = safe_file_name(payload.file_name)
+ bucket_name = (
+ workspace.artifact_bucket or request.app.state.default_bucket
+ )
+ object_key = (
+ f"workspaces/{payload.workspace_id}/"
+ f"{payload.usage_type}/{upload_id}/{file_name}"
+ )
+ upload = UploadSessions(
+ upload_id=upload_id,
+ workspace_id=payload.workspace_id,
+ user_id=payload.user_id,
+ idempotency_key=stored_key,
+ bucket_name=bucket_name,
+ object_key=object_key,
+ object_key_hash=hash_bytes(object_key),
+ upload_status="created",
+ expires_at=utcnow() + timedelta(minutes=15),
+ expected_size_bytes=payload.expected_size_bytes,
+ expected_hash=payload.expected_hash,
+ content_type=payload.content_type,
+ )
+ session.add(upload)
+ await session.flush()
+
+ if upload.upload_status == "completed" and upload.storage_object_id:
+ storage_object = await session.get(
+ StorageObjects,
+ upload.storage_object_id,
+ )
+ return {
+ "upload_id": upload.upload_id,
+ "status": upload.upload_status,
+ "storage_object": (
+ storage_payload(storage_object) if storage_object else None
+ ),
+ }
+ if upload.upload_status not in {"created", "uploading"}:
+ raise HTTPException(
+ status.HTTP_409_CONFLICT,
+ f"upload cannot continue from status {upload.upload_status}",
+ )
+
+ url, headers = request.app.state.object_store.presign_put(
+ bucket_name=upload.bucket_name,
+ object_key=upload.object_key,
+ content_type=upload.content_type or "application/octet-stream",
+ expected_hash=upload.expected_hash,
+ expires_seconds=900,
+ public=payload.url_scope == "public",
+ )
+ return {
+ "upload_id": upload.upload_id,
+ "status": upload.upload_status,
+ "method": "PUT",
+ "presigned_url": url,
+ "required_headers": headers,
+ "expires_at": upload.expires_at.isoformat(),
+ }
+
+
+async def complete_upload_record(
+ upload_id: str,
+ payload: CompleteUploadRequest,
+ session: AsyncSession,
+ request: Request,
+) -> StorageObjects:
+ upload = await session.scalar(
+ select(UploadSessions)
+ .where(UploadSessions.upload_id == upload_id)
+ .with_for_update()
+ )
+ if upload is None:
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "upload not found")
+ if upload.upload_status == "completed" and upload.storage_object_id:
+ item = await session.get(StorageObjects, upload.storage_object_id)
+ if item is None:
+ raise HTTPException(
+ status.HTTP_409_CONFLICT,
+ "completed upload has no storage object",
+ )
+ return item
+ if upload.upload_status not in {"created", "uploading"}:
+ raise HTTPException(
+ status.HTTP_409_CONFLICT,
+ f"upload cannot be completed from status {upload.upload_status}",
+ )
+ if upload.expires_at < utcnow():
+ upload.upload_status = "expired"
+ raise HTTPException(status.HTTP_409_CONFLICT, "upload expired")
+
+ try:
+ head = await asyncio.to_thread(
+ request.app.state.object_store.head,
+ bucket_name=upload.bucket_name,
+ object_key=upload.object_key,
+ )
+ except Exception as exc:
+ raise HTTPException(
+ status.HTTP_409_CONFLICT,
+ "uploaded object is not available",
+ ) from exc
+
+ actual_size = int(head.get("ContentLength", 0))
+ if (
+ upload.expected_size_bytes is not None
+ and actual_size != upload.expected_size_bytes
+ ):
+ upload.upload_status = "failed"
+ raise HTTPException(
+ status.HTTP_409_CONFLICT,
+ "uploaded object size does not match expected_size_bytes",
+ )
+ actual_content_type = str(
+ head.get("ContentType") or "application/octet-stream"
+ )
+ if upload.content_type and actual_content_type != upload.content_type:
+ upload.upload_status = "failed"
+ raise HTTPException(
+ status.HTTP_409_CONFLICT,
+ "uploaded object content type does not match",
+ )
+ metadata = {
+ str(key).lower(): str(value).lower()
+ for key, value in dict(head.get("Metadata") or {}).items()
+ }
+ actual_hash = metadata.get("sha256")
+ if not actual_hash:
+ actual_hash = await asyncio.to_thread(
+ request.app.state.object_store.sha256,
+ bucket_name=upload.bucket_name,
+ object_key=upload.object_key,
+ )
+ if upload.expected_hash and actual_hash != upload.expected_hash:
+ upload.upload_status = "failed"
+ raise HTTPException(
+ status.HTTP_409_CONFLICT,
+ "uploaded object hash does not match expected_hash",
+ )
+
+ file_name = upload.object_key.rsplit("/", 1)[-1]
+ item = StorageObjects(
+ storage_object_id=new_ulid(),
+ workspace_id=upload.workspace_id,
+ owner_user_id=upload.user_id,
+ object_type="file",
+ usage_type=payload.usage_type,
+ storage_backend="rustfs",
+ bucket_name=upload.bucket_name,
+ object_key=upload.object_key,
+ object_key_hash=upload.object_key_hash,
+ storage_uri=f"s3://{upload.bucket_name}/{upload.object_key}",
+ file_name=file_name,
+ file_extension=Path(file_name).suffix.lower() or None,
+ mime_type=actual_content_type,
+ size_bytes=actual_size,
+ content_hash=actual_hash,
+ object_etag=str(head.get("ETag", "")).strip('"') or None,
+ visibility=payload.visibility,
+ is_immutable=int(payload.is_immutable),
+ object_status="available",
+ created_by=upload.user_id,
+ )
+ session.add(item)
+ await session.flush()
+ await session.refresh(item)
+ upload.storage_object_id = item.storage_object_id
+ upload.upload_status = "completed"
+ upload.completed_at = utcnow()
+ return item
+
+
+@app.post("/internal/v1/uploads", dependencies=[Depends(verify_internal_service)])
+async def create_upload(
+ payload: CreateUploadRequest,
+ request: Request,
+ session: AsyncSession = Depends(database_session),
+) -> dict[str, Any]:
+ return {
+ "data": await create_upload_record(payload, session, request),
+ }
+
+
+@app.post(
+ "/internal/v1/uploads/{upload_id}/complete",
+ dependencies=[Depends(verify_internal_service)],
+)
+async def complete_upload(
+ upload_id: str,
+ payload: CompleteUploadRequest,
+ request: Request,
+ session: AsyncSession = Depends(database_session),
+) -> dict[str, Any]:
+ item = await complete_upload_record(
+ upload_id,
+ payload,
+ session,
+ request,
+ )
+ return {"data": storage_payload(item)}
+
+
+@app.post(
+ "/internal/v1/uploads/{upload_id}/abort",
+ dependencies=[Depends(verify_internal_service)],
+)
+async def abort_upload(
+ upload_id: str,
+ request: Request,
+ session: AsyncSession = Depends(database_session),
+) -> dict[str, Any]:
+ upload = await session.scalar(
+ select(UploadSessions)
+ .where(UploadSessions.upload_id == upload_id)
+ .with_for_update()
+ )
+ if upload is None:
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "upload not found")
+ if upload.upload_status == "completed":
+ raise HTTPException(
+ status.HTTP_409_CONFLICT,
+ "completed upload cannot be aborted",
+ )
+ if upload.upload_status != "aborted":
+ await asyncio.to_thread(
+ request.app.state.object_store.delete,
+ bucket_name=upload.bucket_name,
+ object_key=upload.object_key,
+ )
+ upload.upload_status = "aborted"
+ return {"data": {"upload_id": upload_id, "status": "aborted"}}
+
+
+@app.post(
+ "/internal/v1/objects",
+ dependencies=[Depends(verify_internal_service)],
+)
+async def create_server_object(
+ payload: ServerObjectRequest,
+ request: Request,
+ session: AsyncSession = Depends(database_session),
+) -> dict[str, Any]:
+ try:
+ content = base64.b64decode(payload.content_base64, validate=True)
+ except (binascii.Error, ValueError) as exc:
+ raise HTTPException(
+ status.HTTP_422_UNPROCESSABLE_ENTITY,
+ "content_base64 is invalid",
+ ) from exc
+ if len(content) > 100 * 1024 * 1024:
+ raise HTTPException(
+ status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
+ "object exceeds 100 MiB server-side upload limit",
+ )
+ content_hash = hashlib.sha256(content).hexdigest()
+ upload_result = await create_upload_record(
+ CreateUploadRequest(
+ workspace_id=payload.workspace_id,
+ user_id=payload.user_id,
+ usage_type=payload.usage_type,
+ file_name=payload.file_name,
+ content_type=payload.content_type,
+ expected_size_bytes=len(content),
+ expected_hash=content_hash,
+ idempotency_key=payload.idempotency_key,
+ url_scope="internal",
+ ),
+ session,
+ request,
+ )
+ if upload_result.get("status") == "completed":
+ return {"data": upload_result["storage_object"], "meta": {"reused": True}}
+
+ upload = await session.get(UploadSessions, upload_result["upload_id"])
+ if upload is None:
+ raise HTTPException(
+ status.HTTP_500_INTERNAL_SERVER_ERROR,
+ "upload record disappeared",
+ )
+ await asyncio.to_thread(
+ request.app.state.object_store.put_bytes,
+ bucket_name=upload.bucket_name,
+ object_key=upload.object_key,
+ content=content,
+ content_type=payload.content_type,
+ content_hash=content_hash,
+ )
+ item = await complete_upload_record(
+ upload.upload_id,
+ CompleteUploadRequest(
+ usage_type=payload.usage_type,
+ visibility=payload.visibility,
+ is_immutable=payload.is_immutable,
+ ),
+ session,
+ request,
+ )
+ return {"data": storage_payload(item), "meta": {"reused": False}}
+
+
+@app.post(
+ "/internal/v1/workspace-objects",
+ dependencies=[Depends(verify_internal_service)],
+)
+async def register_workspace_object(
+ payload: RegisterWorkspaceObjectRequest,
+ session: AsyncSession = Depends(database_session),
+) -> dict[str, Any]:
+ workspace = await require_workspace_member(
+ session,
+ payload.workspace_id,
+ payload.user_id,
+ )
+ pure_path = PurePosixPath(payload.relative_path.replace("\\", "/"))
+ if (
+ pure_path.is_absolute()
+ or not pure_path.parts
+ or any(part in {"", ".", ".."} for part in pure_path.parts)
+ ):
+ raise HTTPException(
+ status.HTTP_422_UNPROCESSABLE_ENTITY,
+ "invalid workspace relative_path",
+ )
+ relative_path = pure_path.as_posix()
+ workspace_root = Path(
+ os.getenv("WORKSPACE_ROOT", "/workspace/workspaces")
+ ).resolve()
+ scoped_root = (workspace_root / workspace.workspace_code).resolve()
+ target = (scoped_root / Path(*pure_path.parts)).resolve()
+ if scoped_root != target and scoped_root not in target.parents:
+ raise HTTPException(
+ status.HTTP_422_UNPROCESSABLE_ENTITY,
+ "workspace path escapes its root",
+ )
+ if not target.is_file():
+ raise HTTPException(
+ status.HTTP_404_NOT_FOUND,
+ "workspace file does not exist",
+ )
+ content = await asyncio.to_thread(target.read_bytes)
+ content_hash = hashlib.sha256(content).hexdigest()
+ stat_result = target.stat()
+ path_digest = hash_bytes(relative_path)
+ item = await session.scalar(
+ select(StorageObjects).where(
+ StorageObjects.workspace_id == payload.workspace_id,
+ StorageObjects.storage_backend == "workspace_fs",
+ StorageObjects.path_hash == path_digest,
+ )
+ )
+ reused = item is not None
+ if item is None:
+ item = StorageObjects(
+ storage_object_id=new_ulid(),
+ workspace_id=payload.workspace_id,
+ owner_user_id=payload.user_id,
+ object_type="file",
+ usage_type=payload.usage_type,
+ storage_backend="workspace_fs",
+ relative_path=relative_path,
+ path_hash=path_digest,
+ storage_uri=target.as_uri(),
+ file_name=target.name,
+ visibility=payload.visibility,
+ is_immutable=0,
+ object_status="available",
+ created_by=payload.user_id,
+ )
+ session.add(item)
+ elif item.is_immutable:
+ raise HTTPException(
+ status.HTTP_409_CONFLICT,
+ "immutable workspace object cannot be updated",
+ )
+ elif item.owner_user_id != payload.user_id:
+ raise HTTPException(
+ status.HTTP_403_FORBIDDEN,
+ "workspace object belongs to another user",
+ )
+ item.usage_type = payload.usage_type
+ item.file_name = target.name
+ item.file_extension = target.suffix.lower() or None
+ item.mime_type = (
+ mimetypes.guess_type(target.name)[0] or "application/octet-stream"
+ )
+ item.size_bytes = stat_result.st_size
+ item.content_hash = content_hash
+ item.visibility = payload.visibility
+ item.object_status = "available"
+ await session.flush()
+ await session.refresh(item)
+ return {"data": storage_payload(item), "meta": {"reused": reused}}
+
+
+@app.post(
+ "/internal/v1/objects/{storage_object_id}/download-url",
+ dependencies=[Depends(verify_internal_service)],
+)
+async def create_download_url(
+ storage_object_id: str,
+ payload: DownloadUrlRequest,
+ request: Request,
+ session: AsyncSession = Depends(database_session),
+) -> dict[str, Any]:
+ item = await session.get(StorageObjects, storage_object_id)
+ if item is None or item.object_status != "available":
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "object not found")
+ if (
+ item.storage_backend != "rustfs"
+ or not item.bucket_name
+ or not item.object_key
+ ):
+ raise HTTPException(
+ status.HTTP_409_CONFLICT,
+ "object does not support a presigned URL",
+ )
+ url = request.app.state.object_store.presign_get(
+ bucket_name=item.bucket_name,
+ object_key=item.object_key,
+ file_name=item.file_name,
+ expires_seconds=payload.expires_seconds,
+ )
+ return {
+ "data": {
+ "storage_object_id": item.storage_object_id,
+ "presigned_url": url,
+ "method": "GET",
+ "expires_in_seconds": payload.expires_seconds,
+ }
+ }
+
+
+@app.delete(
+ "/internal/v1/objects/{storage_object_id}",
+ dependencies=[Depends(verify_internal_service)],
+)
+async def delete_object(
+ storage_object_id: str,
+ request: Request,
+ session: AsyncSession = Depends(database_session),
+) -> dict[str, Any]:
+ item = await session.scalar(
+ select(StorageObjects)
+ .where(StorageObjects.storage_object_id == storage_object_id)
+ .with_for_update()
+ )
+ if item is None:
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "object not found")
+ if item.is_immutable:
+ raise HTTPException(
+ status.HTTP_409_CONFLICT,
+ "immutable object cannot be deleted",
+ )
+ if item.object_status != "deleted":
+ if item.storage_backend == "rustfs" and item.bucket_name and item.object_key:
+ await asyncio.to_thread(
+ request.app.state.object_store.delete,
+ bucket_name=item.bucket_name,
+ object_key=item.object_key,
+ )
+ item.object_status = "deleted"
+ item.deleted_at = utcnow()
+ return {
+ "data": {
+ "storage_object_id": storage_object_id,
+ "object_status": item.object_status,
+ }
+ }
+
+
+@app.get("/internal/health/storage")
+async def internal_health() -> dict[str, str]:
+ return {"status": "ready", "service": "storage-api"}
diff --git a/backend/src/backend/storage_client.py b/backend/src/backend/storage_client.py
new file mode 100644
index 0000000..8a57826
--- /dev/null
+++ b/backend/src/backend/storage_client.py
@@ -0,0 +1,116 @@
+from __future__ import annotations
+
+import base64
+from typing import Any
+
+import httpx
+from fastapi import HTTPException
+
+
+class StorageClient:
+ def __init__(
+ self,
+ client: httpx.AsyncClient,
+ service_token: str,
+ ) -> None:
+ self.client = client
+ self.headers = {"X-Service-Token": service_token}
+
+ async def _request(
+ self,
+ method: str,
+ path: str,
+ *,
+ payload: dict[str, Any] | None = None,
+ ) -> dict[str, Any]:
+ response = await self.client.request(
+ method,
+ path,
+ json=payload,
+ headers=self.headers,
+ )
+ if response.is_error:
+ try:
+ detail = response.json().get("detail", response.text)
+ except ValueError:
+ detail = response.text
+ raise HTTPException(response.status_code, detail)
+ return response.json()
+
+ async def create_upload(
+ self,
+ payload: dict[str, Any],
+ ) -> dict[str, Any]:
+ return (await self._request(
+ "POST",
+ "/internal/v1/uploads",
+ payload=payload,
+ ))["data"]
+
+ async def complete_upload(
+ self,
+ upload_id: str,
+ payload: dict[str, Any],
+ ) -> dict[str, Any]:
+ return (await self._request(
+ "POST",
+ f"/internal/v1/uploads/{upload_id}/complete",
+ payload=payload,
+ ))["data"]
+
+ async def register_workspace_object(
+ self,
+ payload: dict[str, Any],
+ ) -> dict[str, Any]:
+ return (await self._request(
+ "POST",
+ "/internal/v1/workspace-objects",
+ payload=payload,
+ ))["data"]
+
+ async def create_server_object(
+ self,
+ *,
+ workspace_id: str,
+ user_id: str,
+ usage_type: str,
+ file_name: str,
+ content_type: str,
+ content: bytes,
+ visibility: str,
+ is_immutable: bool,
+ idempotency_key: str,
+ ) -> dict[str, Any]:
+ result = await self._request(
+ "POST",
+ "/internal/v1/objects",
+ payload={
+ "workspace_id": workspace_id,
+ "user_id": user_id,
+ "usage_type": usage_type,
+ "file_name": file_name,
+ "content_type": content_type,
+ "content_base64": base64.b64encode(content).decode("ascii"),
+ "visibility": visibility,
+ "is_immutable": is_immutable,
+ "idempotency_key": idempotency_key,
+ },
+ )
+ return result["data"]
+
+ async def create_download_url(
+ self,
+ storage_object_id: str,
+ expires_seconds: int,
+ ) -> dict[str, Any]:
+ return (await self._request(
+ "POST",
+ f"/internal/v1/objects/{storage_object_id}/download-url",
+ payload={"expires_seconds": expires_seconds},
+ ))["data"]
+
+ async def delete_object(self, storage_object_id: str) -> dict[str, Any]:
+ return (await self._request(
+ "DELETE",
+ f"/internal/v1/objects/{storage_object_id}",
+ ))["data"]
diff --git a/backend/src/backend/storage_schemas.py b/backend/src/backend/storage_schemas.py
new file mode 100644
index 0000000..0e5f36f
--- /dev/null
+++ b/backend/src/backend/storage_schemas.py
@@ -0,0 +1,79 @@
+from __future__ import annotations
+
+from typing import Literal
+
+from pydantic import BaseModel, ConfigDict, Field, field_validator
+
+
+class StrictModel(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+
+
+class CreateUploadRequest(StrictModel):
+ workspace_id: str = Field(min_length=26, max_length=26)
+ user_id: str = Field(min_length=26, max_length=26)
+ usage_type: Literal[
+ "data_resource",
+ "version_artifact",
+ "snapshot",
+ "run_log",
+ "run_result",
+ ]
+ file_name: str = Field(min_length=1, max_length=255)
+ content_type: str = Field(min_length=1, max_length=255)
+ expected_size_bytes: int = Field(ge=0, le=100 * 1024 * 1024)
+ expected_hash: str | None = Field(default=None, min_length=64, max_length=64)
+ idempotency_key: str = Field(min_length=8, max_length=128)
+ url_scope: Literal["public", "internal"] = "public"
+
+ @field_validator("expected_hash")
+ @classmethod
+ def validate_hash(cls, value: str | None) -> str | None:
+ if value is None:
+ return None
+ normalized = value.lower()
+ if any(character not in "0123456789abcdef" for character in normalized):
+ raise ValueError("expected_hash must be lowercase SHA-256 hex")
+ return normalized
+
+
+class CompleteUploadRequest(StrictModel):
+ usage_type: Literal[
+ "data_resource",
+ "version_artifact",
+ "snapshot",
+ "run_log",
+ "run_result",
+ ]
+ visibility: Literal["private", "workspace", "public"] = "private"
+ is_immutable: bool = False
+
+
+class ServerObjectRequest(StrictModel):
+ workspace_id: str = Field(min_length=26, max_length=26)
+ user_id: str = Field(min_length=26, max_length=26)
+ usage_type: Literal[
+ "data_resource",
+ "version_artifact",
+ "snapshot",
+ "run_log",
+ "run_result",
+ ]
+ file_name: str = Field(min_length=1, max_length=255)
+ content_type: str = Field(min_length=1, max_length=255)
+ content_base64: str = Field(min_length=1)
+ visibility: Literal["private", "workspace", "public"] = "private"
+ is_immutable: bool = False
+ idempotency_key: str = Field(min_length=8, max_length=128)
+
+
+class RegisterWorkspaceObjectRequest(StrictModel):
+ workspace_id: str = Field(min_length=26, max_length=26)
+ user_id: str = Field(min_length=26, max_length=26)
+ relative_path: str = Field(min_length=1, max_length=1024)
+ usage_type: Literal["working_copy", "public_script"]
+ visibility: Literal["private", "workspace", "public"] = "private"
+
+
+class DownloadUrlRequest(StrictModel):
+ expires_seconds: int = Field(default=300, ge=30, le=3600)
diff --git a/common/.gitignore b/common/.gitignore
new file mode 100644
index 0000000..505a3b1
--- /dev/null
+++ b/common/.gitignore
@@ -0,0 +1,10 @@
+# Python-generated files
+__pycache__/
+*.py[oc]
+build/
+dist/
+wheels/
+*.egg-info
+
+# Virtual environments
+.venv
diff --git a/common/.python-version b/common/.python-version
new file mode 100644
index 0000000..e4fba21
--- /dev/null
+++ b/common/.python-version
@@ -0,0 +1 @@
+3.12
diff --git a/common/README.md b/common/README.md
new file mode 100644
index 0000000..261a432
--- /dev/null
+++ b/common/README.md
@@ -0,0 +1,5 @@
+# Common
+
+后端公共配置、标识、错误模型、日志和基础工具目录。
+
+业务模块不得在本目录外重复定义公共 DTO 或错误码。
diff --git a/common/alembic.ini b/common/alembic.ini
new file mode 100644
index 0000000..52820cc
--- /dev/null
+++ b/common/alembic.ini
@@ -0,0 +1,149 @@
+# A generic, single database configuration.
+
+[alembic]
+# path to migration scripts.
+# this is typically a path given in POSIX (e.g. forward slashes)
+# format, relative to the token %(here)s which refers to the location of this
+# ini file
+script_location = %(here)s/src/common/migrations
+
+# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
+# Uncomment the line below if you want the files to be prepended with date and time
+# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
+# for all available tokens
+# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
+# Or organize into date-based subdirectories (requires recursive_version_locations = true)
+# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
+
+# sys.path path, will be prepended to sys.path if present.
+# defaults to the current working directory. for multiple paths, the path separator
+# is defined by "path_separator" below.
+prepend_sys_path = .
+
+
+# timezone to use when rendering the date within the migration file
+# as well as the filename.
+# If specified, requires the tzdata library which can be installed by adding
+# `alembic[tz]` to the pip requirements.
+# string value is passed to ZoneInfo()
+# leave blank for localtime
+# timezone =
+
+# max length of characters to apply to the "slug" field
+# truncate_slug_length = 40
+
+# set to 'true' to run the environment during
+# the 'revision' command, regardless of autogenerate
+# revision_environment = false
+
+# set to 'true' to allow .pyc and .pyo files without
+# a source .py file to be detected as revisions in the
+# versions/ directory
+# sourceless = false
+
+# version location specification; This defaults
+# to /versions. When using multiple version
+# directories, initial revisions must be specified with --version-path.
+# The path separator used here should be the separator specified by "path_separator"
+# below.
+# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
+
+# path_separator; This indicates what character is used to split lists of file
+# paths, including version_locations and prepend_sys_path within configparser
+# files such as alembic.ini.
+# The default rendered in new alembic.ini files is "os", which uses os.pathsep
+# to provide os-dependent path splitting.
+#
+# Note that in order to support legacy alembic.ini files, this default does NOT
+# take place if path_separator is not present in alembic.ini. If this
+# option is omitted entirely, fallback logic is as follows:
+#
+# 1. Parsing of the version_locations option falls back to using the legacy
+# "version_path_separator" key, which if absent then falls back to the legacy
+# behavior of splitting on spaces and/or commas.
+# 2. Parsing of the prepend_sys_path option falls back to the legacy
+# behavior of splitting on spaces, commas, or colons.
+#
+# Valid values for path_separator are:
+#
+# path_separator = :
+# path_separator = ;
+# path_separator = space
+# path_separator = newline
+#
+# Use os.pathsep. Default configuration used for new projects.
+path_separator = os
+
+# set to 'true' to search source files recursively
+# in each "version_locations" directory
+# new in Alembic version 1.10
+# recursive_version_locations = false
+
+# the output encoding used when revision files
+# are written from script.py.mako
+# output_encoding = utf-8
+
+# database URL. This is consumed by the user-maintained env.py script only.
+# other means of configuring database URLs may be customized within the env.py
+# file.
+sqlalchemy.url = driver://user:pass@localhost/dbname
+
+
+[post_write_hooks]
+# post_write_hooks defines scripts or Python functions that are run
+# on newly generated revision scripts. See the documentation for further
+# detail and examples
+
+# format using "black" - use the console_scripts runner, against the "black" entrypoint
+# hooks = black
+# black.type = console_scripts
+# black.entrypoint = black
+# black.options = -l 79 REVISION_SCRIPT_FILENAME
+
+# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
+# hooks = ruff
+# ruff.type = module
+# ruff.module = ruff
+# ruff.options = check --fix REVISION_SCRIPT_FILENAME
+
+# Alternatively, use the exec runner to execute a binary found on your PATH
+# hooks = ruff
+# ruff.type = exec
+# ruff.executable = ruff
+# ruff.options = check --fix REVISION_SCRIPT_FILENAME
+
+# Logging configuration. This is also consumed by the user-maintained
+# env.py script only.
+[loggers]
+keys = root,sqlalchemy,alembic
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = WARNING
+handlers = console
+qualname =
+
+[logger_sqlalchemy]
+level = WARNING
+handlers =
+qualname = sqlalchemy.engine
+
+[logger_alembic]
+level = INFO
+handlers =
+qualname = alembic
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatter_generic]
+format = %(levelname)-5.5s [%(name)s] %(message)s
+datefmt = %H:%M:%S
diff --git a/common/pyproject.toml b/common/pyproject.toml
new file mode 100644
index 0000000..a79133e
--- /dev/null
+++ b/common/pyproject.toml
@@ -0,0 +1,17 @@
+[project]
+name = "common"
+version = "0.2.0"
+requires-python = ">=3.12"
+dependencies = [
+ "SQLAlchemy==2.0.51",
+ "asyncmy==0.2.11",
+ "boto3>=1.34,<2",
+ "fastapi==0.116.1",
+]
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.build.targets.wheel]
+packages = ["src/common"]
diff --git a/common/src/common/__init__.py b/common/src/common/__init__.py
new file mode 100644
index 0000000..ab78a93
--- /dev/null
+++ b/common/src/common/__init__.py
@@ -0,0 +1 @@
+"""Shared backend building blocks."""
diff --git a/common/src/common/db/__init__.py b/common/src/common/db/__init__.py
new file mode 100644
index 0000000..10b0d03
--- /dev/null
+++ b/common/src/common/db/__init__.py
@@ -0,0 +1,17 @@
+"""Shared SQLAlchemy database models and infrastructure."""
+
+from common.db.models import Base
+from common.db.session import (
+ AsyncSessionFactory,
+ create_database_engine,
+ create_session_factory,
+ session_scope,
+)
+
+__all__ = [
+ "AsyncSessionFactory",
+ "Base",
+ "create_database_engine",
+ "create_session_factory",
+ "session_scope",
+]
diff --git a/common/src/common/db/base.py b/common/src/common/db/base.py
new file mode 100644
index 0000000..3f203ac
--- /dev/null
+++ b/common/src/common/db/base.py
@@ -0,0 +1,14 @@
+# coding=utf-8
+"""
+@Time :2026/7/29
+@Author :tao.chen
+"""
+from sqlalchemy.orm import DeclarativeBase
+
+class Base(DeclarativeBase):
+ pass
+
+# 导入所有实体模型,确保 Base.metadata 能收集到所有表
+# from common.db.models.notebook import NotebookModel
+# from common.db.models.workspace import WorkspaceModel
+# from common.db.models.job import JobRunModel
\ No newline at end of file
diff --git a/common/src/common/db/models.py b/common/src/common/db/models.py
new file mode 100644
index 0000000..f3aa8af
--- /dev/null
+++ b/common/src/common/db/models.py
@@ -0,0 +1,878 @@
+from typing import Optional
+import datetime
+import decimal
+
+from sqlalchemy import BINARY, BigInteger, CHAR, DECIMAL, Double, ForeignKeyConstraint, Index, Integer, JSON, String, Text, text
+from sqlalchemy.dialects.mysql import BIGINT, DATETIME, INTEGER, SMALLINT, TINYINT
+from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
+
+class Base(DeclarativeBase):
+ pass
+
+
+class ConsumerInbox(Base):
+ __tablename__ = 'consumer_inbox'
+ __table_args__ = (
+ Index('idx_consumer_inbox_status', 'consumer_name', 'process_status', 'created_at'),
+ {'comment': '消费者幂等 Inbox,防止数据库事件重复处理'}
+ )
+
+ consumer_name: Mapped[str] = mapped_column(String(128), primary_key=True)
+ event_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
+ process_status: Mapped[str] = mapped_column(String(16), nullable=False, server_default=text("'processing'"), comment='processing/succeeded/failed')
+ created_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3)'))
+ message_id: Mapped[Optional[str]] = mapped_column(String(128), comment='数据库事件处理批次标识')
+ processed_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
+ error_message: Mapped[Optional[str]] = mapped_column(String(2000))
+
+
+class OutboxEvents(Base):
+ __tablename__ = 'outbox_events'
+ __table_args__ = (
+ Index('idx_outbox_aggregate', 'aggregate_type', 'aggregate_id', 'created_at'),
+ Index('idx_outbox_idempotency', 'idempotency_key'),
+ Index('idx_outbox_pending', 'event_status', 'available_at', 'created_at'),
+ {'comment': '事务 Outbox;由 Schedule Executor 直接轮询处理'}
+ )
+
+ event_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
+ aggregate_type: Mapped[str] = mapped_column(String(64), nullable=False)
+ aggregate_id: Mapped[str] = mapped_column(String(128), nullable=False)
+ event_type: Mapped[str] = mapped_column(String(128), nullable=False)
+ schema_version: Mapped[int] = mapped_column(SMALLINT, nullable=False, server_default=text("1"))
+ payload_json: Mapped[dict] = mapped_column(JSON, nullable=False)
+ event_status: Mapped[str] = mapped_column(String(16), nullable=False, server_default=text("'pending'"), comment='pending/published/failed')
+ available_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3)'))
+ retry_count: Mapped[int] = mapped_column(INTEGER, nullable=False, server_default=text("0"))
+ created_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3)'))
+ trace_id: Mapped[Optional[str]] = mapped_column(String(64))
+ idempotency_key: Mapped[Optional[str]] = mapped_column(String(128))
+ published_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
+ last_error: Mapped[Optional[str]] = mapped_column(String(2000))
+
+
+class Permissions(Base):
+ __tablename__ = 'permissions'
+ __table_args__ = (
+ Index('idx_permissions_module', 'module_code'),
+ Index('uk_permissions_code', 'permission_code', unique=True),
+ {'comment': '权限点'}
+ )
+
+ permission_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
+ permission_code: Mapped[str] = mapped_column(String(128), nullable=False)
+ permission_name: Mapped[str] = mapped_column(String(100), nullable=False)
+ module_code: Mapped[str] = mapped_column(String(64), nullable=False)
+ created_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3)'))
+ description: Mapped[Optional[str]] = mapped_column(String(500))
+
+ role_permissions: Mapped[list['RolePermissions']] = relationship('RolePermissions', back_populates='permission')
+
+
+class Roles(Base):
+ __tablename__ = 'roles'
+ __table_args__ = (
+ Index('uk_roles_code', 'role_code', unique=True),
+ {'comment': '角色'}
+ )
+
+ role_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
+ role_code: Mapped[str] = mapped_column(String(64), nullable=False)
+ role_name: Mapped[str] = mapped_column(String(100), nullable=False)
+ role_scope: Mapped[str] = mapped_column(String(16), nullable=False, comment='platform/workspace')
+ is_builtin: Mapped[int] = mapped_column(TINYINT(1), nullable=False, server_default=text("0"))
+ created_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3)'))
+ updated_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)'))
+ description: Mapped[Optional[str]] = mapped_column(String(500))
+
+ role_permissions: Mapped[list['RolePermissions']] = relationship('RolePermissions', back_populates='role')
+ users: Mapped[list['Users']] = relationship('Users', back_populates='platform_role')
+ workspace_members: Mapped[list['WorkspaceMembers']] = relationship('WorkspaceMembers', back_populates='role')
+
+
+class RolePermissions(Base):
+ __tablename__ = 'role_permissions'
+ __table_args__ = (
+ ForeignKeyConstraint(['permission_id'], ['permissions.permission_id'], ondelete='CASCADE', name='fk_role_permissions_permission'),
+ ForeignKeyConstraint(['role_id'], ['roles.role_id'], ondelete='CASCADE', name='fk_role_permissions_role'),
+ Index('fk_role_permissions_permission', 'permission_id'),
+ {'comment': '角色权限'}
+ )
+
+ role_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
+ permission_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
+ created_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3)'))
+
+ permission: Mapped['Permissions'] = relationship('Permissions', back_populates='role_permissions')
+ role: Mapped['Roles'] = relationship('Roles', back_populates='role_permissions')
+
+
+class Users(Base):
+ __tablename__ = 'users'
+ __table_args__ = (
+ ForeignKeyConstraint(['platform_role_id'], ['roles.role_id'], ondelete='SET NULL', name='fk_users_platform_role'),
+ Index('fk_users_platform_role', 'platform_role_id'),
+ Index('idx_users_status', 'status'),
+ Index('uk_users_email', 'email', unique=True),
+ Index('uk_users_username', 'username', unique=True),
+ {'comment': '平台用户'}
+ )
+
+ user_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
+ username: Mapped[str] = mapped_column(String(64), nullable=False)
+ display_name: Mapped[str] = mapped_column(String(100), nullable=False)
+ password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
+ status: Mapped[str] = mapped_column(String(16), nullable=False, server_default=text("'active'"), comment='active/disabled/locked')
+ created_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3)'))
+ updated_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)'))
+ email: Mapped[Optional[str]] = mapped_column(String(255))
+ platform_role_id: Mapped[Optional[str]] = mapped_column(CHAR(26))
+ avatar_uri: Mapped[Optional[str]] = mapped_column(String(1000))
+ last_login_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
+ deleted_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
+
+ platform_role: Mapped[Optional['Roles']] = relationship('Roles', back_populates='users')
+ workspaces: Mapped[list['Workspaces']] = relationship('Workspaces', back_populates='users')
+ audit_logs: Mapped[list['AuditLogs']] = relationship('AuditLogs', back_populates='actor_user')
+ runtime_instances: Mapped[list['RuntimeInstances']] = relationship('RuntimeInstances', foreign_keys='[RuntimeInstances.owner_user_id]', back_populates='owner_user')
+ runtime_instances_: Mapped[list['RuntimeInstances']] = relationship('RuntimeInstances', foreign_keys='[RuntimeInstances.started_by]', back_populates='users')
+ schedules: Mapped[list['Schedules']] = relationship('Schedules', foreign_keys='[Schedules.created_by]', back_populates='users')
+ schedules_: Mapped[list['Schedules']] = relationship('Schedules', foreign_keys='[Schedules.updated_by]', back_populates='users_')
+ storage_objects: Mapped[list['StorageObjects']] = relationship('StorageObjects', foreign_keys='[StorageObjects.created_by]', back_populates='users')
+ storage_objects_: Mapped[list['StorageObjects']] = relationship('StorageObjects', foreign_keys='[StorageObjects.owner_user_id]', back_populates='owner_user')
+ workspace_members: Mapped[list['WorkspaceMembers']] = relationship('WorkspaceMembers', back_populates='user')
+ data_resources: Mapped[list['DataResources']] = relationship('DataResources', back_populates='owner_user')
+ edit_sessions: Mapped[list['EditSessions']] = relationship('EditSessions', back_populates='user')
+ schedule_runs: Mapped[list['ScheduleRuns']] = relationship('ScheduleRuns', back_populates='users')
+ scripts: Mapped[list['Scripts']] = relationship('Scripts', back_populates='owner_user')
+ upload_sessions: Mapped[list['UploadSessions']] = relationship('UploadSessions', back_populates='user')
+ workspace_operations: Mapped[list['WorkspaceOperations']] = relationship('WorkspaceOperations', back_populates='users')
+ notebook_snapshots: Mapped[list['NotebookSnapshots']] = relationship('NotebookSnapshots', back_populates='users')
+ versions: Mapped[list['Versions']] = relationship('Versions', back_populates='users')
+ experiments: Mapped[list['Experiments']] = relationship('Experiments', back_populates='owner_user')
+
+
+class Workspaces(Base):
+ __tablename__ = 'workspaces'
+ __table_args__ = (
+ ForeignKeyConstraint(['created_by'], ['users.user_id'], ondelete='RESTRICT', name='fk_workspaces_created_by'),
+ Index('fk_workspaces_created_by', 'created_by'),
+ Index('idx_workspaces_status', 'status'),
+ Index('uk_workspaces_code', 'workspace_code', unique=True),
+ {'comment': 'Workspace'}
+ )
+
+ workspace_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
+ workspace_code: Mapped[str] = mapped_column(String(64), nullable=False)
+ workspace_name: Mapped[str] = mapped_column(String(150), nullable=False)
+ active_root_uri: Mapped[str] = mapped_column(String(1500), nullable=False, comment='活动工作区,建议 NFS/PVC/file URI')
+ quota_bytes: Mapped[int] = mapped_column(BIGINT, nullable=False, server_default=text("0"), comment='0 表示不限额')
+ used_bytes: Mapped[int] = mapped_column(BIGINT, nullable=False, server_default=text("0"))
+ status: Mapped[str] = mapped_column(String(24), nullable=False, server_default=text("'active'"), comment='creating/active/suspended/deleting/deleted')
+ created_by: Mapped[str] = mapped_column(CHAR(26), nullable=False)
+ created_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3)'))
+ updated_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)'))
+ description: Mapped[Optional[str]] = mapped_column(String(1000))
+ artifact_bucket: Mapped[Optional[str]] = mapped_column(String(128), comment='RustFS bucket')
+ artifact_prefix: Mapped[Optional[str]] = mapped_column(String(512), comment='RustFS object key prefix')
+ deleted_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
+
+ users: Mapped['Users'] = relationship('Users', back_populates='workspaces')
+ audit_logs: Mapped[list['AuditLogs']] = relationship('AuditLogs', back_populates='workspace')
+ runtime_instances: Mapped[list['RuntimeInstances']] = relationship('RuntimeInstances', back_populates='workspace')
+ schedules: Mapped[list['Schedules']] = relationship('Schedules', back_populates='workspace')
+ storage_objects: Mapped[list['StorageObjects']] = relationship('StorageObjects', back_populates='workspace')
+ workspace_members: Mapped[list['WorkspaceMembers']] = relationship('WorkspaceMembers', back_populates='workspace')
+ data_resources: Mapped[list['DataResources']] = relationship('DataResources', back_populates='workspace')
+ edit_sessions: Mapped[list['EditSessions']] = relationship('EditSessions', back_populates='workspace')
+ schedule_runs: Mapped[list['ScheduleRuns']] = relationship('ScheduleRuns', back_populates='workspace')
+ scripts: Mapped[list['Scripts']] = relationship('Scripts', back_populates='workspace')
+ upload_sessions: Mapped[list['UploadSessions']] = relationship('UploadSessions', back_populates='workspace')
+ workspace_operations: Mapped[list['WorkspaceOperations']] = relationship('WorkspaceOperations', back_populates='workspace')
+ notebook_snapshots: Mapped[list['NotebookSnapshots']] = relationship('NotebookSnapshots', back_populates='workspace')
+ versions: Mapped[list['Versions']] = relationship('Versions', back_populates='workspace')
+ experiments: Mapped[list['Experiments']] = relationship('Experiments', back_populates='workspace')
+
+
+class AuditLogs(Base):
+ __tablename__ = 'audit_logs'
+ __table_args__ = (
+ ForeignKeyConstraint(['actor_user_id'], ['users.user_id'], ondelete='SET NULL', name='fk_audit_actor'),
+ ForeignKeyConstraint(['workspace_id'], ['workspaces.workspace_id'], ondelete='SET NULL', name='fk_audit_workspace'),
+ Index('idx_audit_action_time', 'action_code', 'created_at'),
+ Index('idx_audit_actor_time', 'actor_user_id', 'created_at'),
+ Index('idx_audit_workspace_time', 'workspace_id', 'created_at'),
+ {'comment': '操作审计日志'}
+ )
+
+ audit_id: Mapped[int] = mapped_column(BIGINT, primary_key=True)
+ action_code: Mapped[str] = mapped_column(String(128), nullable=False)
+ target_type: Mapped[str] = mapped_column(String(64), nullable=False)
+ operation_status: Mapped[str] = mapped_column(String(16), nullable=False, server_default=text("'success'"))
+ created_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3)'))
+ workspace_id: Mapped[Optional[str]] = mapped_column(CHAR(26))
+ actor_user_id: Mapped[Optional[str]] = mapped_column(CHAR(26))
+ target_id: Mapped[Optional[str]] = mapped_column(String(128))
+ client_ip: Mapped[Optional[str]] = mapped_column(String(45))
+ user_agent: Mapped[Optional[str]] = mapped_column(String(1000))
+ detail_json: Mapped[Optional[dict]] = mapped_column(JSON)
+
+ actor_user: Mapped[Optional['Users']] = relationship('Users', back_populates='audit_logs')
+ workspace: Mapped[Optional['Workspaces']] = relationship('Workspaces', back_populates='audit_logs')
+
+
+class RuntimeInstances(Base):
+ __tablename__ = 'runtime_instances'
+ __table_args__ = (
+ ForeignKeyConstraint(['owner_user_id'], ['users.user_id'], ondelete='SET NULL', name='fk_runtime_owner'),
+ ForeignKeyConstraint(['started_by'], ['users.user_id'], ondelete='RESTRICT', name='fk_runtime_started_by'),
+ ForeignKeyConstraint(['workspace_id'], ['workspaces.workspace_id'], ondelete='RESTRICT', name='fk_runtime_workspace'),
+ Index('fk_runtime_started_by', 'started_by'),
+ Index('idx_runtime_lease', 'actual_state', 'lease_expires_at'),
+ Index('idx_runtime_owner_state', 'owner_user_id', 'actual_state'),
+ Index('idx_runtime_workspace_state', 'workspace_id', 'runtime_type', 'actual_state'),
+ {'comment': 'Jupyter/未来 VS Code、OpenCode Runtime 实例'}
+ )
+
+ runtime_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
+ workspace_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
+ runtime_type: Mapped[str] = mapped_column(String(24), nullable=False, server_default=text("'jupyter'"))
+ runtime_provider: Mapped[str] = mapped_column(String(24), nullable=False, comment='process/docker/kubernetes')
+ proxy_base_path: Mapped[str] = mapped_column(String(512), nullable=False)
+ desired_state: Mapped[str] = mapped_column(String(16), nullable=False, server_default=text("'running'"))
+ actual_state: Mapped[str] = mapped_column(String(24), nullable=False, server_default=text("'provisioning'"), comment='provisioning/starting/running/unhealthy/stopping/stopped/failed')
+ state_version: Mapped[int] = mapped_column(INTEGER, nullable=False, server_default=text("0"), comment='乐观锁版本')
+ started_by: Mapped[str] = mapped_column(CHAR(26), nullable=False)
+ created_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3)'))
+ updated_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)'))
+ owner_user_id: Mapped[Optional[str]] = mapped_column(CHAR(26), comment='为空表示 Workspace 级 Runtime')
+ runtime_ref: Mapped[Optional[str]] = mapped_column(String(255), comment='PID/container ID/pod UID')
+ host_node: Mapped[Optional[str]] = mapped_column(String(255))
+ internal_url: Mapped[Optional[str]] = mapped_column(String(1000))
+ started_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
+ last_heartbeat_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
+ lease_expires_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
+ stopped_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
+ error_message: Mapped[Optional[str]] = mapped_column(Text)
+
+ owner_user: Mapped[Optional['Users']] = relationship('Users', foreign_keys=[owner_user_id], back_populates='runtime_instances')
+ users: Mapped['Users'] = relationship('Users', foreign_keys=[started_by], back_populates='runtime_instances_')
+ workspace: Mapped['Workspaces'] = relationship('Workspaces', back_populates='runtime_instances')
+ edit_sessions: Mapped[list['EditSessions']] = relationship('EditSessions', back_populates='runtime')
+ workspace_operations: Mapped[list['WorkspaceOperations']] = relationship('WorkspaceOperations', back_populates='runtime')
+
+
+class Schedules(Base):
+ __tablename__ = 'schedules'
+ __table_args__ = (
+ ForeignKeyConstraint(['created_by'], ['users.user_id'], ondelete='RESTRICT', name='fk_schedules_created_by'),
+ ForeignKeyConstraint(['updated_by'], ['users.user_id'], ondelete='RESTRICT', name='fk_schedules_updated_by'),
+ ForeignKeyConstraint(['workspace_id'], ['workspaces.workspace_id'], ondelete='RESTRICT', name='fk_schedules_workspace'),
+ Index('fk_schedules_created_by', 'created_by'),
+ Index('fk_schedules_updated_by', 'updated_by'),
+ Index('idx_schedules_due', 'enabled', 'next_run_at'),
+ Index('idx_schedules_workspace', 'workspace_id', 'enabled', 'updated_at'),
+ {'comment': '调度方案'}
+ )
+
+ schedule_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
+ workspace_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
+ schedule_name: Mapped[str] = mapped_column(String(255), nullable=False)
+ trigger_type: Mapped[str] = mapped_column(String(16), nullable=False, server_default=text("'cron'"), comment='manual/cron/api')
+ timezone: Mapped[str] = mapped_column(String(64), nullable=False, server_default=text("'Asia/Shanghai'"))
+ enabled: Mapped[int] = mapped_column(TINYINT(1), nullable=False, server_default=text("0"))
+ workflow_version: Mapped[int] = mapped_column(INTEGER, nullable=False, server_default=text("1"))
+ max_concurrency: Mapped[int] = mapped_column(INTEGER, nullable=False, server_default=text("1"))
+ failure_policy: Mapped[str] = mapped_column(String(24), nullable=False, server_default=text("'stop'"), comment='stop/continue')
+ created_by: Mapped[str] = mapped_column(CHAR(26), nullable=False)
+ updated_by: Mapped[str] = mapped_column(CHAR(26), nullable=False)
+ created_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3)'))
+ updated_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)'))
+ description: Mapped[Optional[str]] = mapped_column(String(1000))
+ cron_expression: Mapped[Optional[str]] = mapped_column(String(128))
+ last_run_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
+ next_run_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
+ deleted_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
+
+ users: Mapped['Users'] = relationship('Users', foreign_keys=[created_by], back_populates='schedules')
+ users_: Mapped['Users'] = relationship('Users', foreign_keys=[updated_by], back_populates='schedules_')
+ workspace: Mapped['Workspaces'] = relationship('Workspaces', back_populates='schedules')
+ schedule_runs: Mapped[list['ScheduleRuns']] = relationship('ScheduleRuns', back_populates='schedule')
+ schedule_nodes: Mapped[list['ScheduleNodes']] = relationship('ScheduleNodes', back_populates='schedule')
+ schedule_edges: Mapped[list['ScheduleEdges']] = relationship('ScheduleEdges', back_populates='schedule')
+
+
+class StorageObjects(Base):
+ __tablename__ = 'storage_objects'
+ __table_args__ = (
+ ForeignKeyConstraint(['created_by'], ['users.user_id'], ondelete='RESTRICT', name='fk_storage_created_by'),
+ ForeignKeyConstraint(['owner_user_id'], ['users.user_id'], ondelete='SET NULL', name='fk_storage_owner'),
+ ForeignKeyConstraint(['parent_object_id'], ['storage_objects.storage_object_id'], ondelete='SET NULL', name='fk_storage_parent'),
+ ForeignKeyConstraint(['workspace_id'], ['workspaces.workspace_id'], ondelete='RESTRICT', name='fk_storage_workspace'),
+ Index('fk_storage_created_by', 'created_by'),
+ Index('idx_storage_content_hash', 'content_hash'),
+ Index('idx_storage_owner', 'owner_user_id', 'object_status'),
+ Index('idx_storage_parent', 'parent_object_id'),
+ Index('idx_storage_workspace_usage', 'workspace_id', 'usage_type', 'object_status'),
+ Index('uk_storage_bucket_key', 'storage_backend', 'bucket_name', 'object_key_hash', unique=True),
+ Index('uk_storage_workspace_path', 'workspace_id', 'storage_backend', 'path_hash', unique=True),
+ {'comment': 'Workspace 文件和 RustFS 对象的统一元数据'}
+ )
+
+ storage_object_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
+ workspace_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
+ object_type: Mapped[str] = mapped_column(String(16), nullable=False, comment='file/directory')
+ usage_type: Mapped[str] = mapped_column(String(32), nullable=False, comment='working_copy/public_script/data_resource/version_artifact/snapshot/run_log/run_result')
+ storage_backend: Mapped[str] = mapped_column(String(16), nullable=False, comment='workspace_fs/rustfs')
+ storage_uri: Mapped[str] = mapped_column(String(1500), nullable=False)
+ file_name: Mapped[str] = mapped_column(String(255), nullable=False)
+ size_bytes: Mapped[int] = mapped_column(BIGINT, nullable=False, server_default=text("0"))
+ visibility: Mapped[str] = mapped_column(String(16), nullable=False, server_default=text("'private'"), comment='private/workspace/public')
+ is_immutable: Mapped[int] = mapped_column(TINYINT(1), nullable=False, server_default=text("0"))
+ object_status: Mapped[str] = mapped_column(String(24), nullable=False, server_default=text("'available'"), comment='uploading/available/deleting/deleted/failed')
+ created_by: Mapped[str] = mapped_column(CHAR(26), nullable=False)
+ created_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3)'))
+ updated_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)'))
+ owner_user_id: Mapped[Optional[str]] = mapped_column(CHAR(26))
+ parent_object_id: Mapped[Optional[str]] = mapped_column(CHAR(26))
+ relative_path: Mapped[Optional[str]] = mapped_column(String(1024), comment='Workspace 相对路径')
+ path_hash: Mapped[Optional[bytes]] = mapped_column(BINARY(32), comment='SHA-256(relative_path),由应用写入')
+ bucket_name: Mapped[Optional[str]] = mapped_column(String(128))
+ object_key: Mapped[Optional[str]] = mapped_column(String(1024))
+ object_key_hash: Mapped[Optional[bytes]] = mapped_column(BINARY(32), comment='SHA-256(object_key),由应用写入')
+ file_extension: Mapped[Optional[str]] = mapped_column(String(32))
+ mime_type: Mapped[Optional[str]] = mapped_column(String(255))
+ content_hash: Mapped[Optional[str]] = mapped_column(CHAR(64), comment='SHA-256 hex')
+ object_etag: Mapped[Optional[str]] = mapped_column(String(255))
+ deleted_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
+
+ users: Mapped['Users'] = relationship('Users', foreign_keys=[created_by], back_populates='storage_objects')
+ owner_user: Mapped[Optional['Users']] = relationship('Users', foreign_keys=[owner_user_id], back_populates='storage_objects_')
+ parent_object: Mapped[Optional['StorageObjects']] = relationship('StorageObjects', remote_side=[storage_object_id], back_populates='parent_object_reverse')
+ parent_object_reverse: Mapped[list['StorageObjects']] = relationship('StorageObjects', remote_side=[parent_object_id], back_populates='parent_object')
+ workspace: Mapped['Workspaces'] = relationship('Workspaces', back_populates='storage_objects')
+ data_resources: Mapped[list['DataResources']] = relationship('DataResources', back_populates='storage_object')
+ edit_sessions: Mapped[list['EditSessions']] = relationship('EditSessions', back_populates='storage_object')
+ schedule_runs: Mapped[list['ScheduleRuns']] = relationship('ScheduleRuns', foreign_keys='[ScheduleRuns.logs_object_id]', back_populates='logs_object')
+ schedule_runs_: Mapped[list['ScheduleRuns']] = relationship('ScheduleRuns', foreign_keys='[ScheduleRuns.result_object_id]', back_populates='result_object')
+ scripts: Mapped[list['Scripts']] = relationship('Scripts', back_populates='current_object')
+ upload_sessions: Mapped[list['UploadSessions']] = relationship('UploadSessions', back_populates='storage_object')
+ notebook_snapshots: Mapped[list['NotebookSnapshots']] = relationship('NotebookSnapshots', foreign_keys='[NotebookSnapshots.artifact_object_id]', back_populates='artifact_object')
+ notebook_snapshots_: Mapped[list['NotebookSnapshots']] = relationship('NotebookSnapshots', foreign_keys='[NotebookSnapshots.source_object_id]', back_populates='source_object')
+ versions: Mapped[list['Versions']] = relationship('Versions', foreign_keys='[Versions.artifact_object_id]', back_populates='artifact_object')
+ versions_: Mapped[list['Versions']] = relationship('Versions', foreign_keys='[Versions.source_object_id]', back_populates='source_object')
+ experiments: Mapped[list['Experiments']] = relationship('Experiments', foreign_keys='[Experiments.logs_object_id]', back_populates='logs_object')
+ experiments_: Mapped[list['Experiments']] = relationship('Experiments', foreign_keys='[Experiments.result_object_id]', back_populates='result_object')
+ schedule_node_runs: Mapped[list['ScheduleNodeRuns']] = relationship('ScheduleNodeRuns', foreign_keys='[ScheduleNodeRuns.logs_object_id]', back_populates='logs_object')
+ schedule_node_runs_: Mapped[list['ScheduleNodeRuns']] = relationship('ScheduleNodeRuns', foreign_keys='[ScheduleNodeRuns.result_object_id]', back_populates='result_object')
+
+
+class WorkspaceMembers(Base):
+ __tablename__ = 'workspace_members'
+ __table_args__ = (
+ ForeignKeyConstraint(['role_id'], ['roles.role_id'], ondelete='RESTRICT', name='fk_workspace_members_role'),
+ ForeignKeyConstraint(['user_id'], ['users.user_id'], ondelete='RESTRICT', name='fk_workspace_members_user'),
+ ForeignKeyConstraint(['workspace_id'], ['workspaces.workspace_id'], ondelete='CASCADE', name='fk_workspace_members_workspace'),
+ Index('idx_workspace_members_role', 'role_id'),
+ Index('idx_workspace_members_user', 'user_id', 'member_status'),
+ {'comment': 'Workspace 成员与角色'}
+ )
+
+ workspace_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
+ user_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
+ role_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
+ member_status: Mapped[str] = mapped_column(String(16), nullable=False, server_default=text("'active'"))
+ joined_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3)'))
+ updated_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)'))
+
+ role: Mapped['Roles'] = relationship('Roles', back_populates='workspace_members')
+ user: Mapped['Users'] = relationship('Users', back_populates='workspace_members')
+ workspace: Mapped['Workspaces'] = relationship('Workspaces', back_populates='workspace_members')
+
+
+class DataResources(Base):
+ __tablename__ = 'data_resources'
+ __table_args__ = (
+ ForeignKeyConstraint(['owner_user_id'], ['users.user_id'], ondelete='RESTRICT', name='fk_data_resources_owner'),
+ ForeignKeyConstraint(['storage_object_id'], ['storage_objects.storage_object_id'], ondelete='RESTRICT', name='fk_data_resources_object'),
+ ForeignKeyConstraint(['workspace_id'], ['workspaces.workspace_id'], ondelete='RESTRICT', name='fk_data_resources_workspace'),
+ Index('idx_data_resources_owner', 'owner_user_id', 'status'),
+ Index('idx_data_resources_workspace', 'workspace_id', 'visibility', 'status'),
+ Index('uk_data_resources_object', 'storage_object_id', unique=True),
+ {'comment': '数据资源'}
+ )
+
+ resource_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
+ workspace_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
+ storage_object_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
+ owner_user_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
+ resource_name: Mapped[str] = mapped_column(String(255), nullable=False)
+ visibility: Mapped[str] = mapped_column(String(16), nullable=False, server_default=text("'private'"))
+ status: Mapped[str] = mapped_column(String(16), nullable=False, server_default=text("'active'"))
+ created_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3)'))
+ updated_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)'))
+ description: Mapped[Optional[str]] = mapped_column(String(1000))
+ schema_json: Mapped[Optional[dict]] = mapped_column(JSON, comment='字段结构、行数等可选元数据')
+ deleted_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
+
+ owner_user: Mapped['Users'] = relationship('Users', back_populates='data_resources')
+ storage_object: Mapped['StorageObjects'] = relationship('StorageObjects', back_populates='data_resources')
+ workspace: Mapped['Workspaces'] = relationship('Workspaces', back_populates='data_resources')
+ experiment_resources: Mapped[list['ExperimentResources']] = relationship('ExperimentResources', back_populates='resource')
+
+
+class EditSessions(Base):
+ __tablename__ = 'edit_sessions'
+ __table_args__ = (
+ ForeignKeyConstraint(['runtime_id'], ['runtime_instances.runtime_id'], ondelete='SET NULL', name='fk_edit_sessions_runtime'),
+ ForeignKeyConstraint(['storage_object_id'], ['storage_objects.storage_object_id'], ondelete='RESTRICT', name='fk_edit_sessions_object'),
+ ForeignKeyConstraint(['user_id'], ['users.user_id'], ondelete='RESTRICT', name='fk_edit_sessions_user'),
+ ForeignKeyConstraint(['workspace_id'], ['workspaces.workspace_id'], ondelete='RESTRICT', name='fk_edit_sessions_workspace'),
+ Index('fk_edit_sessions_workspace', 'workspace_id'),
+ Index('idx_edit_sessions_object', 'storage_object_id', 'session_status', 'expires_at'),
+ Index('idx_edit_sessions_runtime', 'runtime_id', 'session_status'),
+ Index('idx_edit_sessions_user', 'user_id', 'session_status'),
+ {'comment': '编辑会话与数据库租约;MySQL 为锁状态权威'}
+ )
+
+ edit_session_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
+ workspace_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
+ storage_object_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
+ user_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
+ lock_key: Mapped[str] = mapped_column(String(512), nullable=False)
+ lock_token_hash: Mapped[bytes] = mapped_column(BINARY(32), nullable=False, comment='不保存原始 token')
+ session_status: Mapped[str] = mapped_column(String(16), nullable=False, server_default=text("'active'"), comment='active/closed/expired/failed')
+ started_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3)'))
+ last_heartbeat_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3)'))
+ expires_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False)
+ runtime_id: Mapped[Optional[str]] = mapped_column(CHAR(26))
+ jupyter_session_id: Mapped[Optional[str]] = mapped_column(String(255))
+ ended_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
+ end_reason: Mapped[Optional[str]] = mapped_column(String(64))
+
+ runtime: Mapped[Optional['RuntimeInstances']] = relationship('RuntimeInstances', back_populates='edit_sessions')
+ storage_object: Mapped['StorageObjects'] = relationship('StorageObjects', back_populates='edit_sessions')
+ user: Mapped['Users'] = relationship('Users', back_populates='edit_sessions')
+ workspace: Mapped['Workspaces'] = relationship('Workspaces', back_populates='edit_sessions')
+
+
+class ScheduleRuns(Base):
+ __tablename__ = 'schedule_runs'
+ __table_args__ = (
+ ForeignKeyConstraint(['logs_object_id'], ['storage_objects.storage_object_id'], ondelete='SET NULL', name='fk_schedule_runs_logs'),
+ ForeignKeyConstraint(['result_object_id'], ['storage_objects.storage_object_id'], ondelete='SET NULL', name='fk_schedule_runs_result'),
+ ForeignKeyConstraint(['schedule_id'], ['schedules.schedule_id'], ondelete='RESTRICT', name='fk_schedule_runs_schedule'),
+ ForeignKeyConstraint(['triggered_by'], ['users.user_id'], ondelete='SET NULL', name='fk_schedule_runs_user'),
+ ForeignKeyConstraint(['workspace_id'], ['workspaces.workspace_id'], ondelete='RESTRICT', name='fk_schedule_runs_workspace'),
+ Index('fk_schedule_runs_logs', 'logs_object_id'),
+ Index('fk_schedule_runs_result', 'result_object_id'),
+ Index('fk_schedule_runs_user', 'triggered_by'),
+ Index('idx_schedule_runs_schedule', 'schedule_id', 'created_at'),
+ Index('idx_schedule_runs_status', 'run_status', 'queued_at'),
+ Index('idx_schedule_runs_workspace_status', 'workspace_id', 'run_status', 'queued_at'),
+ Index('uk_schedule_runs_idempotency', 'idempotency_key', unique=True),
+ {'comment': '调度运行'}
+ )
+
+ run_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
+ schedule_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
+ workspace_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
+ workflow_version: Mapped[int] = mapped_column(INTEGER, nullable=False)
+ trigger_type: Mapped[str] = mapped_column(String(16), nullable=False, comment='manual/cron/api/retry')
+ idempotency_key: Mapped[str] = mapped_column(String(128), nullable=False)
+ run_status: Mapped[str] = mapped_column(String(24), nullable=False, server_default=text("'queued'"), comment='queued/running/succeeded/failed/cancelled/timed_out')
+ state_version: Mapped[int] = mapped_column(INTEGER, nullable=False, server_default=text("0"), comment='乐观锁版本')
+ schedule_snapshot: Mapped[dict] = mapped_column(JSON, nullable=False, comment='执行时 DAG 快照')
+ queued_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3)'))
+ created_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3)'))
+ triggered_by: Mapped[Optional[str]] = mapped_column(CHAR(26))
+ started_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
+ finished_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
+ duration_ms: Mapped[Optional[int]] = mapped_column(BIGINT)
+ error_code: Mapped[Optional[str]] = mapped_column(String(64))
+ error_message: Mapped[Optional[str]] = mapped_column(Text)
+ logs_object_id: Mapped[Optional[str]] = mapped_column(CHAR(26))
+ result_object_id: Mapped[Optional[str]] = mapped_column(CHAR(26))
+
+ logs_object: Mapped[Optional['StorageObjects']] = relationship('StorageObjects', foreign_keys=[logs_object_id], back_populates='schedule_runs')
+ result_object: Mapped[Optional['StorageObjects']] = relationship('StorageObjects', foreign_keys=[result_object_id], back_populates='schedule_runs_')
+ schedule: Mapped['Schedules'] = relationship('Schedules', back_populates='schedule_runs')
+ users: Mapped[Optional['Users']] = relationship('Users', back_populates='schedule_runs')
+ workspace: Mapped['Workspaces'] = relationship('Workspaces', back_populates='schedule_runs')
+ experiments: Mapped[list['Experiments']] = relationship('Experiments', back_populates='schedule_run')
+ schedule_node_runs: Mapped[list['ScheduleNodeRuns']] = relationship('ScheduleNodeRuns', back_populates='run')
+
+
+class Scripts(Base):
+ __tablename__ = 'scripts'
+ __table_args__ = (
+ ForeignKeyConstraint(['current_object_id'], ['storage_objects.storage_object_id'], ondelete='RESTRICT', name='fk_scripts_current_object'),
+ ForeignKeyConstraint(['owner_user_id'], ['users.user_id'], ondelete='RESTRICT', name='fk_scripts_owner'),
+ ForeignKeyConstraint(['workspace_id'], ['workspaces.workspace_id'], ondelete='RESTRICT', name='fk_scripts_workspace'),
+ Index('idx_scripts_owner', 'owner_user_id', 'status'),
+ Index('idx_scripts_workspace', 'workspace_id', 'script_type', 'visibility', 'status'),
+ Index('uk_scripts_current_object', 'current_object_id', unique=True),
+ {'comment': '可执行 Python/Notebook 脚本'}
+ )
+
+ script_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
+ workspace_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
+ current_object_id: Mapped[str] = mapped_column(CHAR(26), nullable=False, comment='当前工作副本')
+ owner_user_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
+ script_name: Mapped[str] = mapped_column(String(255), nullable=False)
+ script_type: Mapped[str] = mapped_column(String(16), nullable=False, comment='python/notebook')
+ visibility: Mapped[str] = mapped_column(String(16), nullable=False, server_default=text("'private'"))
+ status: Mapped[str] = mapped_column(String(16), nullable=False, server_default=text("'active'"))
+ created_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3)'))
+ updated_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)'))
+ deleted_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
+
+ current_object: Mapped['StorageObjects'] = relationship('StorageObjects', back_populates='scripts')
+ owner_user: Mapped['Users'] = relationship('Users', back_populates='scripts')
+ workspace: Mapped['Workspaces'] = relationship('Workspaces', back_populates='scripts')
+ notebook_snapshots: Mapped[list['NotebookSnapshots']] = relationship('NotebookSnapshots', back_populates='script')
+ versions: Mapped[list['Versions']] = relationship('Versions', back_populates='script')
+ experiments: Mapped[list['Experiments']] = relationship('Experiments', back_populates='script')
+
+
+class UploadSessions(Base):
+ __tablename__ = 'upload_sessions'
+ __table_args__ = (
+ ForeignKeyConstraint(['storage_object_id'], ['storage_objects.storage_object_id'], ondelete='SET NULL', name='fk_upload_sessions_storage_object'),
+ ForeignKeyConstraint(['user_id'], ['users.user_id'], ondelete='RESTRICT', name='fk_upload_sessions_user'),
+ ForeignKeyConstraint(['workspace_id'], ['workspaces.workspace_id'], ondelete='RESTRICT', name='fk_upload_sessions_workspace'),
+ Index('fk_upload_sessions_storage_object', 'storage_object_id'),
+ Index('fk_upload_sessions_user', 'user_id'),
+ Index('idx_upload_sessions_expiry', 'upload_status', 'expires_at'),
+ Index('idx_upload_sessions_object_key', 'bucket_name', 'object_key_hash'),
+ Index('idx_upload_sessions_workspace', 'workspace_id', 'user_id', 'created_at'),
+ Index('uk_upload_sessions_idempotency', 'idempotency_key', unique=True),
+ {'comment': 'RustFS 预签名上传会话;URL 本身不持久化'}
+ )
+
+ upload_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
+ workspace_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
+ user_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
+ idempotency_key: Mapped[str] = mapped_column(String(128), nullable=False)
+ bucket_name: Mapped[str] = mapped_column(String(128), nullable=False)
+ object_key: Mapped[str] = mapped_column(String(1024), nullable=False)
+ object_key_hash: Mapped[bytes] = mapped_column(BINARY(32), nullable=False)
+ upload_status: Mapped[str] = mapped_column(String(24), nullable=False, server_default=text("'created'"), comment='created/uploading/completed/expired/aborted/failed')
+ expires_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False)
+ created_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3)'))
+ updated_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)'))
+ multipart_upload_id: Mapped[Optional[str]] = mapped_column(String(255))
+ expected_size_bytes: Mapped[Optional[int]] = mapped_column(BIGINT)
+ expected_hash: Mapped[Optional[str]] = mapped_column(CHAR(64))
+ content_type: Mapped[Optional[str]] = mapped_column(String(255))
+ storage_object_id: Mapped[Optional[str]] = mapped_column(CHAR(26))
+ completed_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
+
+ storage_object: Mapped[Optional['StorageObjects']] = relationship('StorageObjects', back_populates='upload_sessions')
+ user: Mapped['Users'] = relationship('Users', back_populates='upload_sessions')
+ workspace: Mapped['Workspaces'] = relationship('Workspaces', back_populates='upload_sessions')
+
+
+class WorkspaceOperations(Base):
+ __tablename__ = 'workspace_operations'
+ __table_args__ = (
+ ForeignKeyConstraint(['requested_by'], ['users.user_id'], ondelete='RESTRICT', name='fk_workspace_operations_user'),
+ ForeignKeyConstraint(['runtime_id'], ['runtime_instances.runtime_id'], ondelete='SET NULL', name='fk_workspace_operations_runtime'),
+ ForeignKeyConstraint(['workspace_id'], ['workspaces.workspace_id'], ondelete='RESTRICT', name='fk_workspace_operations_workspace'),
+ Index('fk_workspace_operations_user', 'requested_by'),
+ Index('idx_workspace_operations_runtime', 'runtime_id', 'created_at'),
+ Index('idx_workspace_operations_workspace', 'workspace_id', 'operation_status', 'created_at'),
+ Index('uk_workspace_operations_request', 'request_id', unique=True),
+ {'comment': '无状态 Backend 的 Workspace/Jupyter 异步操作记录'}
+ )
+
+ operation_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
+ workspace_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
+ operation_type: Mapped[str] = mapped_column(String(24), nullable=False, comment='open/close/mount/unmount/start/stop/restart/recycle')
+ operation_status: Mapped[str] = mapped_column(String(24), nullable=False, server_default=text("'pending'"), comment='pending/running/succeeded/failed/cancelled')
+ state_version: Mapped[int] = mapped_column(INTEGER, nullable=False, server_default=text("0"), comment='乐观锁版本')
+ requested_by: Mapped[str] = mapped_column(CHAR(26), nullable=False)
+ created_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3)'))
+ runtime_id: Mapped[Optional[str]] = mapped_column(CHAR(26))
+ request_id: Mapped[Optional[str]] = mapped_column(String(128))
+ started_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
+ finished_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
+ error_code: Mapped[Optional[str]] = mapped_column(String(64))
+ error_message: Mapped[Optional[str]] = mapped_column(Text)
+
+ users: Mapped['Users'] = relationship('Users', back_populates='workspace_operations')
+ runtime: Mapped[Optional['RuntimeInstances']] = relationship('RuntimeInstances', back_populates='workspace_operations')
+ workspace: Mapped['Workspaces'] = relationship('Workspaces', back_populates='workspace_operations')
+
+
+class NotebookSnapshots(Base):
+ __tablename__ = 'notebook_snapshots'
+ __table_args__ = (
+ ForeignKeyConstraint(['artifact_object_id'], ['storage_objects.storage_object_id'], ondelete='RESTRICT', name='fk_snapshots_artifact_object'),
+ ForeignKeyConstraint(['created_by'], ['users.user_id'], ondelete='RESTRICT', name='fk_snapshots_created_by'),
+ ForeignKeyConstraint(['script_id'], ['scripts.script_id'], ondelete='RESTRICT', name='fk_snapshots_script'),
+ ForeignKeyConstraint(['source_object_id'], ['storage_objects.storage_object_id'], ondelete='RESTRICT', name='fk_snapshots_source_object'),
+ ForeignKeyConstraint(['workspace_id'], ['workspaces.workspace_id'], ondelete='RESTRICT', name='fk_snapshots_workspace'),
+ Index('fk_snapshots_created_by', 'created_by'),
+ Index('fk_snapshots_source_object', 'source_object_id'),
+ Index('idx_snapshots_workspace_created', 'workspace_id', 'created_at'),
+ Index('uk_snapshots_artifact', 'artifact_object_id', unique=True),
+ Index('uk_snapshots_script_hash', 'script_id', 'content_hash', unique=True),
+ {'comment': 'Notebook 开发快照,append-only'}
+ )
+
+ snapshot_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
+ workspace_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
+ script_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
+ source_object_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
+ artifact_object_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
+ snapshot_name: Mapped[str] = mapped_column(String(255), nullable=False)
+ content_hash: Mapped[str] = mapped_column(CHAR(64), nullable=False)
+ outputs_stripped: Mapped[int] = mapped_column(TINYINT(1), nullable=False, server_default=text("1"))
+ created_by: Mapped[str] = mapped_column(CHAR(26), nullable=False)
+ created_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3)'))
+ description: Mapped[Optional[str]] = mapped_column(String(1000))
+
+ artifact_object: Mapped['StorageObjects'] = relationship('StorageObjects', foreign_keys=[artifact_object_id], back_populates='notebook_snapshots')
+ users: Mapped['Users'] = relationship('Users', back_populates='notebook_snapshots')
+ script: Mapped['Scripts'] = relationship('Scripts', back_populates='notebook_snapshots')
+ source_object: Mapped['StorageObjects'] = relationship('StorageObjects', foreign_keys=[source_object_id], back_populates='notebook_snapshots_')
+ workspace: Mapped['Workspaces'] = relationship('Workspaces', back_populates='notebook_snapshots')
+
+
+class Versions(Base):
+ __tablename__ = 'versions'
+ __table_args__ = (
+ ForeignKeyConstraint(['artifact_object_id'], ['storage_objects.storage_object_id'], ondelete='RESTRICT', name='fk_versions_artifact_object'),
+ ForeignKeyConstraint(['created_by'], ['users.user_id'], ondelete='RESTRICT', name='fk_versions_created_by'),
+ ForeignKeyConstraint(['script_id'], ['scripts.script_id'], ondelete='RESTRICT', name='fk_versions_script'),
+ ForeignKeyConstraint(['source_object_id'], ['storage_objects.storage_object_id'], ondelete='RESTRICT', name='fk_versions_source_object'),
+ ForeignKeyConstraint(['workspace_id'], ['workspaces.workspace_id'], ondelete='RESTRICT', name='fk_versions_workspace'),
+ Index('fk_versions_source_object', 'source_object_id'),
+ Index('idx_versions_creator', 'created_by', 'created_at'),
+ Index('idx_versions_workspace_created', 'workspace_id', 'created_at'),
+ Index('uk_versions_artifact', 'artifact_object_id', unique=True),
+ Index('uk_versions_script_hash', 'script_id', 'content_hash', unique=True),
+ Index('uk_versions_script_no', 'script_id', 'version_no', unique=True),
+ {'comment': '不可变稳定版本;调度节点必须引用 versions_id'}
+ )
+
+ versions_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True, comment='稳定版本唯一 ID')
+ workspace_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
+ script_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
+ source_object_id: Mapped[str] = mapped_column(CHAR(26), nullable=False, comment='发布时的源对象')
+ artifact_object_id: Mapped[str] = mapped_column(CHAR(26), nullable=False, comment='RustFS 不可变版本制品')
+ version_no: Mapped[int] = mapped_column(INTEGER, nullable=False)
+ version_label: Mapped[str] = mapped_column(String(32), nullable=False, comment='例如 v1.0')
+ source_path: Mapped[str] = mapped_column(String(1024), nullable=False, comment='发布时路径快照')
+ artifact_path: Mapped[str] = mapped_column(String(1500), nullable=False)
+ content_hash: Mapped[str] = mapped_column(CHAR(64), nullable=False)
+ file_size_bytes: Mapped[int] = mapped_column(BIGINT, nullable=False, server_default=text("0"))
+ visibility: Mapped[str] = mapped_column(String(16), nullable=False, server_default=text("'private'"))
+ created_by: Mapped[str] = mapped_column(CHAR(26), nullable=False)
+ created_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3)'))
+ release_note: Mapped[Optional[str]] = mapped_column(String(1000))
+ schedule_hidden_at: Mapped[Optional[datetime.datetime]] = mapped_column(
+ DATETIME(fsp=3),
+ comment='从调度稳定版本列表移除的时间;不影响版本和运行历史',
+ )
+
+ artifact_object: Mapped['StorageObjects'] = relationship('StorageObjects', foreign_keys=[artifact_object_id], back_populates='versions')
+ users: Mapped['Users'] = relationship('Users', back_populates='versions')
+ script: Mapped['Scripts'] = relationship('Scripts', back_populates='versions')
+ source_object: Mapped['StorageObjects'] = relationship('StorageObjects', foreign_keys=[source_object_id], back_populates='versions_')
+ workspace: Mapped['Workspaces'] = relationship('Workspaces', back_populates='versions')
+ experiments: Mapped[list['Experiments']] = relationship('Experiments', back_populates='versions')
+ schedule_nodes: Mapped[list['ScheduleNodes']] = relationship('ScheduleNodes', back_populates='versions')
+ schedule_node_runs: Mapped[list['ScheduleNodeRuns']] = relationship('ScheduleNodeRuns', back_populates='versions')
+
+
+class Experiments(Base):
+ __tablename__ = 'experiments'
+ __table_args__ = (
+ ForeignKeyConstraint(['logs_object_id'], ['storage_objects.storage_object_id'], ondelete='SET NULL', name='fk_experiments_logs'),
+ ForeignKeyConstraint(['owner_user_id'], ['users.user_id'], ondelete='RESTRICT', name='fk_experiments_owner'),
+ ForeignKeyConstraint(['parent_experiment_id'], ['experiments.experiment_id'], ondelete='SET NULL', name='fk_experiments_parent'),
+ ForeignKeyConstraint(['result_object_id'], ['storage_objects.storage_object_id'], ondelete='SET NULL', name='fk_experiments_result'),
+ ForeignKeyConstraint(['schedule_run_id'], ['schedule_runs.run_id'], ondelete='SET NULL', name='fk_experiments_schedule_run'),
+ ForeignKeyConstraint(['script_id'], ['scripts.script_id'], ondelete='SET NULL', name='fk_experiments_script'),
+ ForeignKeyConstraint(['versions_id'], ['versions.versions_id'], ondelete='SET NULL', name='fk_experiments_version'),
+ ForeignKeyConstraint(['workspace_id'], ['workspaces.workspace_id'], ondelete='RESTRICT', name='fk_experiments_workspace'),
+ Index('fk_experiments_logs', 'logs_object_id'),
+ Index('fk_experiments_parent', 'parent_experiment_id'),
+ Index('fk_experiments_result', 'result_object_id'),
+ Index('fk_experiments_script', 'script_id'),
+ Index('idx_experiments_owner', 'owner_user_id', 'created_at'),
+ Index('idx_experiments_schedule_run', 'schedule_run_id'),
+ Index('idx_experiments_version', 'versions_id'),
+ Index('idx_experiments_workspace', 'workspace_id', 'experiment_status', 'created_at'),
+ {'comment': '实验记录'}
+ )
+
+ experiment_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
+ workspace_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
+ owner_user_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
+ experiment_name: Mapped[str] = mapped_column(String(255), nullable=False)
+ source_type: Mapped[str] = mapped_column(String(24), nullable=False, comment='python/notebook/schedule/rerun')
+ experiment_status: Mapped[str] = mapped_column(String(24), nullable=False, server_default=text("'queued'"))
+ state_version: Mapped[int] = mapped_column(INTEGER, nullable=False, server_default=text("0"), comment='乐观锁版本')
+ created_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3)'))
+ script_id: Mapped[Optional[str]] = mapped_column(CHAR(26))
+ versions_id: Mapped[Optional[str]] = mapped_column(CHAR(26), comment='工作副本运行时可为空')
+ schedule_run_id: Mapped[Optional[str]] = mapped_column(CHAR(26))
+ parent_experiment_id: Mapped[Optional[str]] = mapped_column(CHAR(26))
+ parameters_json: Mapped[Optional[dict]] = mapped_column(JSON)
+ environment_json: Mapped[Optional[dict]] = mapped_column(JSON)
+ result_summary: Mapped[Optional[str]] = mapped_column(String(2000))
+ logs_object_id: Mapped[Optional[str]] = mapped_column(CHAR(26))
+ result_object_id: Mapped[Optional[str]] = mapped_column(CHAR(26))
+ started_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
+ finished_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
+ duration_ms: Mapped[Optional[int]] = mapped_column(BIGINT)
+ deleted_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
+
+ logs_object: Mapped[Optional['StorageObjects']] = relationship('StorageObjects', foreign_keys=[logs_object_id], back_populates='experiments')
+ owner_user: Mapped['Users'] = relationship('Users', back_populates='experiments')
+ parent_experiment: Mapped[Optional['Experiments']] = relationship('Experiments', remote_side=[experiment_id], back_populates='parent_experiment_reverse')
+ parent_experiment_reverse: Mapped[list['Experiments']] = relationship('Experiments', remote_side=[parent_experiment_id], back_populates='parent_experiment')
+ result_object: Mapped[Optional['StorageObjects']] = relationship('StorageObjects', foreign_keys=[result_object_id], back_populates='experiments_')
+ schedule_run: Mapped[Optional['ScheduleRuns']] = relationship('ScheduleRuns', back_populates='experiments')
+ script: Mapped[Optional['Scripts']] = relationship('Scripts', back_populates='experiments')
+ versions: Mapped[Optional['Versions']] = relationship('Versions', back_populates='experiments')
+ workspace: Mapped['Workspaces'] = relationship('Workspaces', back_populates='experiments')
+ experiment_metrics: Mapped[list['ExperimentMetrics']] = relationship('ExperimentMetrics', back_populates='experiment')
+ experiment_resources: Mapped[list['ExperimentResources']] = relationship('ExperimentResources', back_populates='experiment')
+
+
+class ScheduleNodes(Base):
+ __tablename__ = 'schedule_nodes'
+ __table_args__ = (
+ ForeignKeyConstraint(['schedule_id'], ['schedules.schedule_id'], ondelete='CASCADE', name='fk_schedule_nodes_schedule'),
+ ForeignKeyConstraint(['versions_id'], ['versions.versions_id'], ondelete='RESTRICT', name='fk_schedule_nodes_version'),
+ Index('idx_schedule_nodes_version', 'versions_id'),
+ Index('uk_schedule_nodes_key', 'schedule_id', 'node_key', unique=True),
+ {'comment': 'DAG 节点,必须引用稳定版本'}
+ )
+
+ node_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
+ schedule_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
+ node_key: Mapped[str] = mapped_column(String(64), nullable=False, comment='画布内稳定标识')
+ node_name: Mapped[str] = mapped_column(String(255), nullable=False)
+ versions_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
+ timeout_seconds: Mapped[int] = mapped_column(INTEGER, nullable=False, server_default=text("600"))
+ retry_count: Mapped[int] = mapped_column(INTEGER, nullable=False, server_default=text("0"))
+ retry_interval_sec: Mapped[int] = mapped_column(INTEGER, nullable=False, server_default=text("5"))
+ position_x: Mapped[decimal.Decimal] = mapped_column(DECIMAL(10, 2), nullable=False, server_default=text("0.00"))
+ position_y: Mapped[decimal.Decimal] = mapped_column(DECIMAL(10, 2), nullable=False, server_default=text("0.00"))
+ created_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3)'))
+ updated_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)'))
+ arguments_json: Mapped[Optional[dict]] = mapped_column(JSON)
+ env_refs_json: Mapped[Optional[dict]] = mapped_column(JSON, comment='只存密钥引用,不存明文密钥')
+
+ schedule: Mapped['Schedules'] = relationship('Schedules', back_populates='schedule_nodes')
+ versions: Mapped['Versions'] = relationship('Versions', back_populates='schedule_nodes')
+ schedule_edges: Mapped[list['ScheduleEdges']] = relationship('ScheduleEdges', foreign_keys='[ScheduleEdges.source_node_id]', back_populates='source_node')
+ schedule_edges_: Mapped[list['ScheduleEdges']] = relationship('ScheduleEdges', foreign_keys='[ScheduleEdges.target_node_id]', back_populates='target_node')
+ schedule_node_runs: Mapped[list['ScheduleNodeRuns']] = relationship('ScheduleNodeRuns', back_populates='node')
+
+
+class ExperimentMetrics(Base):
+ __tablename__ = 'experiment_metrics'
+ __table_args__ = (
+ ForeignKeyConstraint(['experiment_id'], ['experiments.experiment_id'], ondelete='CASCADE', name='fk_experiment_metrics_experiment'),
+ Index('idx_experiment_metrics_lookup', 'experiment_id', 'metric_name', 'step_no'),
+ {'comment': '实验指标,支持筛选和曲线'}
+ )
+
+ metric_id: Mapped[int] = mapped_column(BIGINT, primary_key=True)
+ experiment_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
+ metric_name: Mapped[str] = mapped_column(String(128), nullable=False)
+ recorded_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3)'))
+ metric_value: Mapped[Optional[decimal.Decimal]] = mapped_column(Double(asdecimal=True))
+ metric_text: Mapped[Optional[str]] = mapped_column(String(1000))
+ step_no: Mapped[Optional[int]] = mapped_column(BigInteger)
+
+ experiment: Mapped['Experiments'] = relationship('Experiments', back_populates='experiment_metrics')
+
+
+class ExperimentResources(Base):
+ __tablename__ = 'experiment_resources'
+ __table_args__ = (
+ ForeignKeyConstraint(['experiment_id'], ['experiments.experiment_id'], ondelete='CASCADE', name='fk_experiment_resources_experiment'),
+ ForeignKeyConstraint(['resource_id'], ['data_resources.resource_id'], ondelete='RESTRICT', name='fk_experiment_resources_resource'),
+ Index('fk_experiment_resources_resource', 'resource_id'),
+ {'comment': '实验与数据资源'}
+ )
+
+ experiment_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
+ resource_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
+ resource_role: Mapped[str] = mapped_column(String(16), primary_key=True, server_default=text("'input'"), comment='input/output')
+ created_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3)'))
+
+ experiment: Mapped['Experiments'] = relationship('Experiments', back_populates='experiment_resources')
+ resource: Mapped['DataResources'] = relationship('DataResources', back_populates='experiment_resources')
+
+
+class ScheduleEdges(Base):
+ __tablename__ = 'schedule_edges'
+ __table_args__ = (
+ ForeignKeyConstraint(['schedule_id'], ['schedules.schedule_id'], ondelete='CASCADE', name='fk_schedule_edges_schedule'),
+ ForeignKeyConstraint(['source_node_id'], ['schedule_nodes.node_id'], ondelete='CASCADE', name='fk_schedule_edges_source'),
+ ForeignKeyConstraint(['target_node_id'], ['schedule_nodes.node_id'], ondelete='CASCADE', name='fk_schedule_edges_target'),
+ Index('fk_schedule_edges_source', 'source_node_id'),
+ Index('idx_schedule_edges_target', 'target_node_id'),
+ Index('uk_schedule_edges_pair', 'schedule_id', 'source_node_id', 'target_node_id', unique=True),
+ {'comment': 'DAG 有向边'}
+ )
+
+ edge_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
+ schedule_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
+ source_node_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
+ target_node_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
+ created_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3)'))
+ condition_expr: Mapped[Optional[str]] = mapped_column(String(1000))
+
+ schedule: Mapped['Schedules'] = relationship('Schedules', back_populates='schedule_edges')
+ source_node: Mapped['ScheduleNodes'] = relationship('ScheduleNodes', foreign_keys=[source_node_id], back_populates='schedule_edges')
+ target_node: Mapped['ScheduleNodes'] = relationship('ScheduleNodes', foreign_keys=[target_node_id], back_populates='schedule_edges_')
+
+
+class ScheduleNodeRuns(Base):
+ __tablename__ = 'schedule_node_runs'
+ __table_args__ = (
+ ForeignKeyConstraint(['logs_object_id'], ['storage_objects.storage_object_id'], ondelete='SET NULL', name='fk_node_runs_logs'),
+ ForeignKeyConstraint(['node_id'], ['schedule_nodes.node_id'], ondelete='RESTRICT', name='fk_node_runs_node'),
+ ForeignKeyConstraint(['result_object_id'], ['storage_objects.storage_object_id'], ondelete='SET NULL', name='fk_node_runs_result'),
+ ForeignKeyConstraint(['run_id'], ['schedule_runs.run_id'], ondelete='CASCADE', name='fk_node_runs_run'),
+ ForeignKeyConstraint(['versions_id'], ['versions.versions_id'], ondelete='RESTRICT', name='fk_node_runs_version'),
+ Index('fk_node_runs_logs', 'logs_object_id'),
+ Index('fk_node_runs_node', 'node_id'),
+ Index('fk_node_runs_result', 'result_object_id'),
+ Index('idx_node_runs_status', 'run_id', 'node_status'),
+ Index('idx_node_runs_version', 'versions_id'),
+ Index('uk_node_runs_attempt', 'run_id', 'node_id', 'attempt_no', unique=True),
+ {'comment': '调度节点运行与重试'}
+ )
+
+ node_run_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
+ run_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
+ node_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
+ versions_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
+ attempt_no: Mapped[int] = mapped_column(INTEGER, nullable=False, server_default=text("1"))
+ node_status: Mapped[str] = mapped_column(String(24), nullable=False, server_default=text("'queued'"), comment='queued/running/succeeded/failed/skipped/cancelled/timed_out')
+ state_version: Mapped[int] = mapped_column(INTEGER, nullable=False, server_default=text("0"), comment='乐观锁版本')
+ created_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3)'))
+ started_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
+ finished_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
+ duration_ms: Mapped[Optional[int]] = mapped_column(BIGINT)
+ exit_code: Mapped[Optional[int]] = mapped_column(Integer)
+ message: Mapped[Optional[str]] = mapped_column(String(2000))
+ metrics_json: Mapped[Optional[dict]] = mapped_column(JSON)
+ logs_object_id: Mapped[Optional[str]] = mapped_column(CHAR(26))
+ result_object_id: Mapped[Optional[str]] = mapped_column(CHAR(26))
+
+ logs_object: Mapped[Optional['StorageObjects']] = relationship('StorageObjects', foreign_keys=[logs_object_id], back_populates='schedule_node_runs')
+ node: Mapped['ScheduleNodes'] = relationship('ScheduleNodes', back_populates='schedule_node_runs')
+ result_object: Mapped[Optional['StorageObjects']] = relationship('StorageObjects', foreign_keys=[result_object_id], back_populates='schedule_node_runs_')
+ run: Mapped['ScheduleRuns'] = relationship('ScheduleRuns', back_populates='schedule_node_runs')
+ versions: Mapped['Versions'] = relationship('Versions', back_populates='schedule_node_runs')
diff --git a/common/src/common/db/session.py b/common/src/common/db/session.py
new file mode 100644
index 0000000..65ed3ca
--- /dev/null
+++ b/common/src/common/db/session.py
@@ -0,0 +1,51 @@
+from __future__ import annotations
+
+from collections.abc import AsyncIterator
+from contextlib import asynccontextmanager
+
+from sqlalchemy.ext.asyncio import (
+ AsyncEngine,
+ AsyncSession,
+ async_sessionmaker,
+ create_async_engine,
+)
+
+AsyncSessionFactory = async_sessionmaker[AsyncSession]
+
+
+def create_database_engine(
+ database_url: str,
+ *,
+ echo: bool = False,
+ pool_pre_ping: bool = True,
+) -> AsyncEngine:
+ """Create an async SQLAlchemy engine without storing global connection state."""
+ return create_async_engine(
+ database_url,
+ echo=echo,
+ pool_pre_ping=pool_pre_ping,
+ )
+
+
+def create_session_factory(engine: AsyncEngine) -> AsyncSessionFactory:
+ """Create the shared async session factory used by FastAPI dependencies."""
+ return async_sessionmaker(
+ bind=engine,
+ class_=AsyncSession,
+ expire_on_commit=False,
+ autoflush=False,
+ )
+
+
+@asynccontextmanager
+async def session_scope(
+ factory: AsyncSessionFactory,
+) -> AsyncIterator[AsyncSession]:
+ """Commit a unit of work or roll it back when an exception is raised."""
+ async with factory() as session:
+ try:
+ yield session
+ await session.commit()
+ except Exception:
+ await session.rollback()
+ raise
diff --git a/common/src/common/eventing.py b/common/src/common/eventing.py
new file mode 100644
index 0000000..7035679
--- /dev/null
+++ b/common/src/common/eventing.py
@@ -0,0 +1,71 @@
+from __future__ import annotations
+
+from datetime import UTC, datetime
+from typing import Any
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from common.db.models import OutboxEvents
+from common.ids import new_ulid
+
+
+SUPPORTED_EVENT_TYPES = {
+ "schedule.run.requested",
+ "job.node.execute",
+ "job.node.finished",
+}
+
+
+def utcnow() -> datetime:
+ return datetime.now(UTC).replace(tzinfo=None)
+
+
+def event_time(value: datetime | None = None) -> str:
+ item = value or utcnow()
+ if item.tzinfo is None:
+ item = item.replace(tzinfo=UTC)
+ return item.astimezone(UTC).isoformat().replace("+00:00", "Z")
+
+
+async def add_outbox_event(
+ session: AsyncSession,
+ *,
+ event_type: str,
+ producer: str,
+ trace_id: str,
+ aggregate_type: str,
+ aggregate_id: str,
+ idempotency_key: str,
+ payload: dict[str, Any],
+ available_at: datetime | None = None,
+) -> OutboxEvents:
+ if event_type not in SUPPORTED_EVENT_TYPES:
+ raise ValueError(f"unsupported event type: {event_type}")
+ event_id = new_ulid()
+ envelope = {
+ "event_id": event_id,
+ "event_type": event_type,
+ "schema_version": 1,
+ "occurred_at": event_time(),
+ "producer": producer,
+ "trace_id": trace_id,
+ "aggregate_type": aggregate_type,
+ "aggregate_id": aggregate_id,
+ "idempotency_key": idempotency_key,
+ "payload": payload,
+ }
+ item = OutboxEvents(
+ event_id=event_id,
+ aggregate_type=aggregate_type,
+ aggregate_id=aggregate_id,
+ event_type=event_type,
+ schema_version=1,
+ payload_json=envelope,
+ event_status="pending",
+ available_at=available_at or utcnow(),
+ retry_count=0,
+ trace_id=trace_id,
+ idempotency_key=idempotency_key,
+ )
+ session.add(item)
+ return item
diff --git a/common/src/common/ids.py b/common/src/common/ids.py
new file mode 100644
index 0000000..f738c8a
--- /dev/null
+++ b/common/src/common/ids.py
@@ -0,0 +1,17 @@
+from __future__ import annotations
+
+import secrets
+import time
+
+_CROCKFORD32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
+
+
+def new_ulid() -> str:
+ """Return a lexicographically sortable 26-character ULID."""
+ timestamp_ms = int(time.time_ns() // 1_000_000)
+ value = (timestamp_ms << 80) | secrets.randbits(80)
+ encoded = ["0"] * 26
+ for index in range(25, -1, -1):
+ encoded[index] = _CROCKFORD32[value & 31]
+ value >>= 5
+ return "".join(encoded)
diff --git a/common/src/common/migrations/README b/common/src/common/migrations/README
new file mode 100644
index 0000000..98e4f9c
--- /dev/null
+++ b/common/src/common/migrations/README
@@ -0,0 +1 @@
+Generic single-database configuration.
\ No newline at end of file
diff --git a/common/src/common/migrations/env.py b/common/src/common/migrations/env.py
new file mode 100644
index 0000000..3f8cba8
--- /dev/null
+++ b/common/src/common/migrations/env.py
@@ -0,0 +1,78 @@
+from logging.config import fileConfig
+
+from sqlalchemy import engine_from_config
+from sqlalchemy import pool
+
+from alembic import context
+from common.db.base import Base
+# this is the Alembic Config object, which provides
+# access to the values within the .ini file in use.
+config = context.config
+
+# Interpret the config file for Python logging.
+# This line sets up loggers basically.
+if config.config_file_name is not None:
+ fileConfig(config.config_file_name)
+
+# add your model's MetaData object here
+# for 'autogenerate' support
+# from myapp import mymodel
+# target_metadata = mymodel.Base.metadata
+target_metadata = Base.metadata
+
+# other values from the config, defined by the needs of env.py,
+# can be acquired:
+# my_important_option = config.get_main_option("my_important_option")
+# ... etc.
+
+
+def run_migrations_offline() -> None:
+ """Run migrations in 'offline' mode.
+
+ This configures the context with just a URL
+ and not an Engine, though an Engine is acceptable
+ here as well. By skipping the Engine creation
+ we don't even need a DBAPI to be available.
+
+ Calls to context.execute() here emit the given string to the
+ script output.
+
+ """
+ url = config.get_main_option("sqlalchemy.url")
+ context.configure(
+ url=url,
+ target_metadata=target_metadata,
+ literal_binds=True,
+ dialect_opts={"paramstyle": "named"},
+ )
+
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+def run_migrations_online() -> None:
+ """Run migrations in 'online' mode.
+
+ In this scenario we need to create an Engine
+ and associate a connection with the context.
+
+ """
+ connectable = engine_from_config(
+ config.get_section(config.config_ini_section, {}),
+ prefix="sqlalchemy.",
+ poolclass=pool.NullPool,
+ )
+
+ with connectable.connect() as connection:
+ context.configure(
+ connection=connection, target_metadata=target_metadata
+ )
+
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+if context.is_offline_mode():
+ run_migrations_offline()
+else:
+ run_migrations_online()
diff --git a/common/src/common/migrations/script.py.mako b/common/src/common/migrations/script.py.mako
new file mode 100644
index 0000000..1101630
--- /dev/null
+++ b/common/src/common/migrations/script.py.mako
@@ -0,0 +1,28 @@
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+Create Date: ${create_date}
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+${imports if imports else ""}
+
+# revision identifiers, used by Alembic.
+revision: str = ${repr(up_revision)}
+down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
+branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
+depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
+
+
+def upgrade() -> None:
+ """Upgrade schema."""
+ ${upgrades if upgrades else "pass"}
+
+
+def downgrade() -> None:
+ """Downgrade schema."""
+ ${downgrades if downgrades else "pass"}
diff --git a/common/src/common/service_app.py b/common/src/common/service_app.py
new file mode 100644
index 0000000..bebb15c
--- /dev/null
+++ b/common/src/common/service_app.py
@@ -0,0 +1,79 @@
+from __future__ import annotations
+
+import asyncio
+import os
+from datetime import UTC, datetime
+from typing import Any, Callable
+
+from fastapi import FastAPI, Response, status
+
+
+def _target_list() -> list[str]:
+ value = os.getenv("READINESS_TARGETS", "")
+ return [item.strip() for item in value.split(",") if item.strip()]
+
+
+async def _check_tcp_target(target: str) -> dict[str, Any]:
+ host, port_text = target.rsplit(":", 1)
+ try:
+ reader, writer = await asyncio.wait_for(
+ asyncio.open_connection(host, int(port_text)),
+ timeout=1.5,
+ )
+ writer.close()
+ await writer.wait_closed()
+ return {"target": target, "status": "ok"}
+ except (OSError, TimeoutError, ValueError) as exc:
+ return {
+ "target": target,
+ "status": "error",
+ "detail": type(exc).__name__,
+ }
+
+
+def create_service_app(
+ service_name: str,
+ *,
+ lifespan: Callable[..., Any] | None = None,
+) -> FastAPI:
+ app = FastAPI(
+ title=f"{service_name} service",
+ version="0.1.0",
+ docs_url="/docs",
+ redoc_url=None,
+ lifespan=lifespan,
+ )
+
+ def base_payload(state: str) -> dict[str, str]:
+ return {
+ "status": state,
+ "service": service_name,
+ "timestamp": datetime.now(UTC).isoformat(),
+ }
+
+ @app.get("/")
+ async def root() -> dict[str, str]:
+ return base_payload("running")
+
+ @app.get("/health/live")
+ async def live() -> dict[str, str]:
+ return base_payload("ok")
+
+ @app.get("/api/v1/health")
+ async def public_health() -> dict[str, str]:
+ return base_payload("ok")
+
+ @app.get("/health/ready")
+ async def ready(response: Response) -> dict[str, Any]:
+ checks = await asyncio.gather(
+ *(_check_tcp_target(target) for target in _target_list())
+ )
+ ready_state = all(check["status"] == "ok" for check in checks)
+ if not ready_state:
+ response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
+ return {
+ **base_payload("ready" if ready_state else "not_ready"),
+ "checks": checks,
+ }
+
+ return app
diff --git a/common/src/common/storage/__init__.py b/common/src/common/storage/__init__.py
new file mode 100644
index 0000000..417f62a
--- /dev/null
+++ b/common/src/common/storage/__init__.py
@@ -0,0 +1,3 @@
+from common.storage.rustfs import RustFSObjectStore
+
+__all__ = ["RustFSObjectStore"]
diff --git a/common/src/common/storage/rustfs.py b/common/src/common/storage/rustfs.py
new file mode 100644
index 0000000..88bc51c
--- /dev/null
+++ b/common/src/common/storage/rustfs.py
@@ -0,0 +1,126 @@
+from __future__ import annotations
+
+import hashlib
+from typing import Any, BinaryIO
+
+import boto3
+from botocore.client import Config
+from botocore.exceptions import ClientError
+
+
+class RustFSObjectStore:
+ def __init__(
+ self,
+ *,
+ internal_endpoint: str,
+ public_endpoint: str,
+ access_key: str,
+ secret_key: str,
+ ) -> None:
+ common = {
+ "aws_access_key_id": access_key,
+ "aws_secret_access_key": secret_key,
+ "region_name": "us-east-1",
+ "config": Config(
+ signature_version="s3v4",
+ s3={"addressing_style": "path"},
+ ),
+ }
+ self.internal = boto3.client(
+ "s3",
+ endpoint_url=internal_endpoint.rstrip("/"),
+ **common,
+ )
+ self.public = boto3.client(
+ "s3",
+ endpoint_url=public_endpoint.rstrip("/"),
+ **common,
+ )
+
+ def ensure_bucket(self, bucket_name: str) -> None:
+ try:
+ self.internal.head_bucket(Bucket=bucket_name)
+ except ClientError as exc:
+ code = str(exc.response.get("Error", {}).get("Code", ""))
+ if code not in {"404", "NoSuchBucket", "NotFound"}:
+ raise
+ self.internal.create_bucket(Bucket=bucket_name)
+
+ def presign_put(
+ self,
+ *,
+ bucket_name: str,
+ object_key: str,
+ content_type: str,
+ expected_hash: str | None,
+ expires_seconds: int,
+ public: bool,
+ ) -> tuple[str, dict[str, str]]:
+ params: dict[str, Any] = {
+ "Bucket": bucket_name,
+ "Key": object_key,
+ "ContentType": content_type,
+ }
+ headers = {"Content-Type": content_type}
+ if expected_hash:
+ params["Metadata"] = {"sha256": expected_hash}
+ headers["x-amz-meta-sha256"] = expected_hash
+ client = self.public if public else self.internal
+ url = client.generate_presigned_url(
+ "put_object",
+ Params=params,
+ ExpiresIn=expires_seconds,
+ )
+ return url, headers
+
+ def presign_get(
+ self,
+ *,
+ bucket_name: str,
+ object_key: str,
+ file_name: str,
+ expires_seconds: int,
+ ) -> str:
+ return self.public.generate_presigned_url(
+ "get_object",
+ Params={
+ "Bucket": bucket_name,
+ "Key": object_key,
+ "ResponseContentDisposition": (
+ f'attachment; filename="{file_name.encode("ascii", "ignore").decode() or "download"}"'
+ ),
+ },
+ ExpiresIn=expires_seconds,
+ )
+
+ def put_bytes(
+ self,
+ *,
+ bucket_name: str,
+ object_key: str,
+ content: bytes,
+ content_type: str,
+ content_hash: str,
+ ) -> None:
+ self.internal.put_object(
+ Bucket=bucket_name,
+ Key=object_key,
+ Body=content,
+ ContentType=content_type,
+ Metadata={"sha256": content_hash},
+ )
+
+ def head(self, *, bucket_name: str, object_key: str) -> dict[str, Any]:
+ return self.internal.head_object(Bucket=bucket_name, Key=object_key)
+
+ def sha256(self, *, bucket_name: str, object_key: str) -> str:
+ response = self.internal.get_object(Bucket=bucket_name, Key=object_key)
+ body: BinaryIO = response["Body"]
+ digest = hashlib.sha256()
+ while chunk := body.read(1024 * 1024):
+ digest.update(chunk)
+ body.close()
+ return digest.hexdigest()
+
+ def delete(self, *, bucket_name: str, object_key: str) -> None:
+ self.internal.delete_object(Bucket=bucket_name, Key=object_key)
diff --git a/common/src/common/utils.py b/common/src/common/utils.py
new file mode 100644
index 0000000..3c4e086
--- /dev/null
+++ b/common/src/common/utils.py
@@ -0,0 +1,7 @@
+# coding=utf-8
+"""
+@Time :2026/7/27
+@Author :tao.chen
+"""
+def hello_world():
+ return 'Hello World!'
\ No newline at end of file
diff --git a/contracts/README.md b/contracts/README.md
new file mode 100644
index 0000000..002f4b4
--- /dev/null
+++ b/contracts/README.md
@@ -0,0 +1,15 @@
+# Contracts
+
+模块接口契约公共目录:
+
+```text
+openapi/ HTTP OpenAPI 3 契约
+events/ MySQL Outbox 内部事件 JSON Schema
+runtime/ Runtime Adapter 契约
+locks/ Notebook 编辑锁契约
+```
+
+各服务实现必须以本目录中的版本化契约为准。
+
+快速 Demo 的跨模块边界、状态枚举和事件路由统一见
+`demo-core-v1.md`。
diff --git a/contracts/__init__.py b/contracts/__init__.py
new file mode 100644
index 0000000..3992080
--- /dev/null
+++ b/contracts/__init__.py
@@ -0,0 +1 @@
+"""Executable cross-service contracts."""
diff --git a/contracts/demo-core-v1.md b/contracts/demo-core-v1.md
new file mode 100644
index 0000000..da788ff
--- /dev/null
+++ b/contracts/demo-core-v1.md
@@ -0,0 +1,56 @@
+# 快速 Demo 核心契约 V1
+
+冻结日期:2026-07-27
+状态:`frozen-target`
+
+## 1. 契约边界
+
+Demo 公共 HTTP API 由两部分共同组成:
+
+1. `openapi/platform-api-v1.yaml`:已经运行的脚本、稳定版本详情、数据资源和文件编辑锁接口。
+2. `openapi/demo-core-extension-v1.yaml`:第 15 小步冻结、后续小步实现的上下文、Jupyter 票据、稳定版本列表、调度和运行查询接口。
+
+内部 Storage 与 Runtime 接口继续分别以
+`openapi/storage-api-internal-v1.yaml` 和
+`openapi/runtime-api-internal-v1.yaml` 为准。
+
+## 2. 不可变约束
+
+- 主标识使用 ULID;调度节点只引用不可变的 `versions_id`。
+- 当前认证上下文由 `X-User-ID`、`X-Workspace-ID` 提供;以后换成 JWT 时不得改变业务 DTO。
+- 修改调度方案必须提交 `workflow_version`,冲突返回 `412`。
+- 创建调度、立即运行、启停和取消使用 `Idempotency-Key`。
+- `schedule_runs.schedule_snapshot` 固化本次执行 DAG,后续编辑不影响已创建的运行。
+- 浏览器不能拿到 Jupyter 内部 Token,只能获得短期访问 Cookie。
+
+## 3. 状态值
+
+- 调度运行:`queued / running / succeeded / failed / cancelled / timed_out`
+- 节点运行:`queued / running / succeeded / failed / skipped / cancelled / timed_out`
+- 失败策略:`stop / continue`
+- 触发类型:`manual / cron / api / retry`
+
+## 4. MySQL Outbox 与 HTTP 推送
+
+| 事件 | 写入方 | 处理方 |
+|---|---|---|
+| `schedule.run.requested` | Backend / Cron Dispatcher | Schedule Executor |
+| `job.node.execute` | Schedule Executor | Schedule Executor Worker |
+| `job.node.finished` | Schedule Executor Worker | Schedule Executor |
+
+业务事务先写 `outbox_events`。Backend 对“立即运行”执行一次内部 HTTP 推送,
+Executor 同时轮询 MySQL 作为兜底;`consumer_inbox` 防止同一事件重复执行。
+
+## 5. 模块所有权
+
+| 模块 | 拥有的数据与职责 |
+|---|---|
+| Platform API | 脚本、稳定版本、调度定义、运行查询、Outbox 写入 |
+| Runtime Manager | Workspace/Jupyter 生命周期与内部运行态 |
+| Nginx | 统一入口、Jupyter HTTP/WebSocket 代理 |
+| Schedule Orchestrator | 消费调度请求、解析 DAG、推进节点状态 |
+| Job Worker | 读取稳定版本、执行节点、保存日志/结果、报告终态 |
+| Storage Service | RustFS 对象元数据与预签名访问 |
+
+模块间不得复制定义 DTO、状态枚举、错误结构或事件字段;契约变更必须先
+升级本目录中的版本化文件。
diff --git a/contracts/events/README.md b/contracts/events/README.md
new file mode 100644
index 0000000..4e0482a
--- /dev/null
+++ b/contracts/events/README.md
@@ -0,0 +1,11 @@
+# Database Event Schemas
+
+内部事件以 JSON Schema Draft 2020-12 定义:
+
+- `event-envelope-v1.json`:公共事件信封;
+- `schedule-run-requested-v1.json`:请求启动一次调度运行;
+- `job-node-execute-v1.json`:请求执行一个稳定版本节点;
+- `job-node-finished-v1.json`:节点终态。
+
+事件先随业务事务写入 MySQL `outbox_events`,Schedule Executor 直接轮询处理;
+`consumer_inbox` 提供幂等保护。字段和状态值不得在生产者或消费者中另行定义。
diff --git a/contracts/events/event-envelope-v1.json b/contracts/events/event-envelope-v1.json
new file mode 100644
index 0000000..7484242
--- /dev/null
+++ b/contracts/events/event-envelope-v1.json
@@ -0,0 +1,72 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "event-envelope-v1.json",
+ "title": "Model Platform Event Envelope V1",
+ "description": "MySQL Outbox 中所有内部业务事件共用的不可变信封。",
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "event_id",
+ "event_type",
+ "schema_version",
+ "occurred_at",
+ "producer",
+ "trace_id",
+ "aggregate_type",
+ "aggregate_id",
+ "idempotency_key",
+ "payload"
+ ],
+ "properties": {
+ "event_id": {
+ "$ref": "#/$defs/ulid"
+ },
+ "event_type": {
+ "type": "string",
+ "pattern": "^[a-z][a-z0-9]*(\\.[a-z][a-z0-9_]*)+$"
+ },
+ "schema_version": {
+ "const": 1
+ },
+ "occurred_at": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "producer": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 64
+ },
+ "trace_id": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 64
+ },
+ "aggregate_type": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 64
+ },
+ "aggregate_id": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 128
+ },
+ "idempotency_key": {
+ "type": "string",
+ "minLength": 8,
+ "maxLength": 128
+ },
+ "payload": {
+ "type": "object"
+ }
+ },
+ "$defs": {
+ "ulid": {
+ "type": "string",
+ "minLength": 26,
+ "maxLength": 26,
+ "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$"
+ }
+ }
+}
diff --git a/contracts/events/job-node-execute-v1.json b/contracts/events/job-node-execute-v1.json
new file mode 100644
index 0000000..423ad25
--- /dev/null
+++ b/contracts/events/job-node-execute-v1.json
@@ -0,0 +1,125 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "job-node-execute-v1.json",
+ "title": "Job Node Execute V1",
+ "description": "调度编排器请求 Job Worker 执行一个稳定版本节点。",
+ "allOf": [
+ {
+ "$ref": "event-envelope-v1.json"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "event_type": {
+ "const": "job.node.execute"
+ },
+ "aggregate_type": {
+ "const": "schedule_node_run"
+ },
+ "payload": {
+ "$ref": "job-node-execute-v1.json#/$defs/payload"
+ }
+ }
+ }
+ ],
+ "$defs": {
+ "ulid": {
+ "type": "string",
+ "minLength": 26,
+ "maxLength": 26,
+ "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$"
+ },
+ "payload": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "workspace_id",
+ "run_id",
+ "node_run_id",
+ "node_id",
+ "versions_id",
+ "attempt_no",
+ "script_type",
+ "artifact_object_id",
+ "artifact_path",
+ "timeout_seconds",
+ "arguments"
+ ],
+ "properties": {
+ "workspace_id": {
+ "$ref": "job-node-execute-v1.json#/$defs/ulid"
+ },
+ "run_id": {
+ "$ref": "job-node-execute-v1.json#/$defs/ulid"
+ },
+ "node_run_id": {
+ "$ref": "job-node-execute-v1.json#/$defs/ulid"
+ },
+ "node_id": {
+ "$ref": "job-node-execute-v1.json#/$defs/ulid"
+ },
+ "versions_id": {
+ "$ref": "job-node-execute-v1.json#/$defs/ulid"
+ },
+ "attempt_no": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 11
+ },
+ "script_type": {
+ "enum": [
+ "python",
+ "notebook"
+ ]
+ },
+ "artifact_object_id": {
+ "$ref": "job-node-execute-v1.json#/$defs/ulid"
+ },
+ "artifact_path": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 1024
+ },
+ "timeout_seconds": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 86400
+ },
+ "arguments": {
+ "type": "array",
+ "maxItems": 100,
+ "items": {
+ "type": "string",
+ "maxLength": 1000
+ }
+ }
+ }
+ }
+ },
+ "examples": [
+ {
+ "event_id": "01K123456789ABCDEFGHJKMNPZ",
+ "event_type": "job.node.execute",
+ "schema_version": 1,
+ "occurred_at": "2026-07-27T08:00:01Z",
+ "producer": "schedule-orchestrator",
+ "trace_id": "req-demo-0001",
+ "aggregate_type": "schedule_node_run",
+ "aggregate_id": "01K123456789ABCDEFGHJKMNQ0",
+ "idempotency_key": "01K123456789ABCDEFGHJKMNQ0:1",
+ "payload": {
+ "workspace_id": "01K123456789ABCDEFGHJKMNPS",
+ "run_id": "01K123456789ABCDEFGHJKMNPR",
+ "node_run_id": "01K123456789ABCDEFGHJKMNQ0",
+ "node_id": "01K123456789ABCDEFGHJKMNPW",
+ "versions_id": "01K123456789ABCDEFGHJKMNPX",
+ "attempt_no": 1,
+ "script_type": "python",
+ "artifact_object_id": "01K123456789ABCDEFGHJKMNPY",
+ "artifact_path": "versions/prepare/v1.py",
+ "timeout_seconds": 600,
+ "arguments": []
+ }
+ }
+ ]
+}
diff --git a/contracts/events/job-node-finished-v1.json b/contracts/events/job-node-finished-v1.json
new file mode 100644
index 0000000..4a925cc
--- /dev/null
+++ b/contracts/events/job-node-finished-v1.json
@@ -0,0 +1,162 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "job-node-finished-v1.json",
+ "title": "Job Node Finished V1",
+ "description": "Job Worker 报告一次节点执行的终态,调度编排器据此推进 DAG。",
+ "allOf": [
+ {
+ "$ref": "event-envelope-v1.json"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "event_type": {
+ "const": "job.node.finished"
+ },
+ "aggregate_type": {
+ "const": "schedule_node_run"
+ },
+ "payload": {
+ "$ref": "job-node-finished-v1.json#/$defs/payload"
+ }
+ }
+ }
+ ],
+ "$defs": {
+ "ulid": {
+ "type": "string",
+ "minLength": 26,
+ "maxLength": 26,
+ "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$"
+ },
+ "nullableUlid": {
+ "oneOf": [
+ {
+ "$ref": "job-node-finished-v1.json#/$defs/ulid"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "payload": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "workspace_id",
+ "run_id",
+ "node_run_id",
+ "node_id",
+ "versions_id",
+ "attempt_no",
+ "node_status",
+ "exit_code",
+ "started_at",
+ "finished_at",
+ "duration_ms",
+ "logs_object_id",
+ "result_object_id",
+ "error_code",
+ "error_message"
+ ],
+ "properties": {
+ "workspace_id": {
+ "$ref": "job-node-finished-v1.json#/$defs/ulid"
+ },
+ "run_id": {
+ "$ref": "job-node-finished-v1.json#/$defs/ulid"
+ },
+ "node_run_id": {
+ "$ref": "job-node-finished-v1.json#/$defs/ulid"
+ },
+ "node_id": {
+ "$ref": "job-node-finished-v1.json#/$defs/ulid"
+ },
+ "versions_id": {
+ "$ref": "job-node-finished-v1.json#/$defs/ulid"
+ },
+ "attempt_no": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 11
+ },
+ "node_status": {
+ "enum": [
+ "succeeded",
+ "failed",
+ "cancelled",
+ "timed_out"
+ ]
+ },
+ "exit_code": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
+ "started_at": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "finished_at": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "duration_ms": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "logs_object_id": {
+ "$ref": "job-node-finished-v1.json#/$defs/nullableUlid"
+ },
+ "result_object_id": {
+ "$ref": "job-node-finished-v1.json#/$defs/nullableUlid"
+ },
+ "error_code": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "maxLength": 64
+ },
+ "error_message": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "maxLength": 2000
+ }
+ }
+ }
+ },
+ "examples": [
+ {
+ "event_id": "01K123456789ABCDEFGHJKMNQ1",
+ "event_type": "job.node.finished",
+ "schema_version": 1,
+ "occurred_at": "2026-07-27T08:00:03Z",
+ "producer": "job-worker",
+ "trace_id": "req-demo-0001",
+ "aggregate_type": "schedule_node_run",
+ "aggregate_id": "01K123456789ABCDEFGHJKMNQ0",
+ "idempotency_key": "01K123456789ABCDEFGHJKMNQ0:1:finished",
+ "payload": {
+ "workspace_id": "01K123456789ABCDEFGHJKMNPS",
+ "run_id": "01K123456789ABCDEFGHJKMNPR",
+ "node_run_id": "01K123456789ABCDEFGHJKMNQ0",
+ "node_id": "01K123456789ABCDEFGHJKMNPW",
+ "versions_id": "01K123456789ABCDEFGHJKMNPX",
+ "attempt_no": 1,
+ "node_status": "succeeded",
+ "exit_code": 0,
+ "started_at": "2026-07-27T08:00:01Z",
+ "finished_at": "2026-07-27T08:00:03Z",
+ "duration_ms": 2000,
+ "logs_object_id": null,
+ "result_object_id": null,
+ "error_code": null,
+ "error_message": null
+ }
+ }
+ ]
+}
diff --git a/contracts/events/schedule-run-requested-v1.json b/contracts/events/schedule-run-requested-v1.json
new file mode 100644
index 0000000..e8603e3
--- /dev/null
+++ b/contracts/events/schedule-run-requested-v1.json
@@ -0,0 +1,255 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "schedule-run-requested-v1.json",
+ "title": "Schedule Run Requested V1",
+ "description": "Platform API 或 Cron Dispatcher 请求调度编排器启动一次固化 DAG。",
+ "allOf": [
+ {
+ "$ref": "event-envelope-v1.json"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "event_type": {
+ "const": "schedule.run.requested"
+ },
+ "aggregate_type": {
+ "const": "schedule_run"
+ },
+ "payload": {
+ "$ref": "schedule-run-requested-v1.json#/$defs/payload"
+ }
+ }
+ }
+ ],
+ "$defs": {
+ "ulid": {
+ "type": "string",
+ "minLength": 26,
+ "maxLength": 26,
+ "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$"
+ },
+ "node": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "node_id",
+ "node_key",
+ "versions_id",
+ "script_type",
+ "artifact_object_id",
+ "artifact_path",
+ "timeout_seconds",
+ "retry_count",
+ "retry_interval_sec",
+ "arguments"
+ ],
+ "properties": {
+ "node_id": {
+ "$ref": "schedule-run-requested-v1.json#/$defs/ulid"
+ },
+ "node_key": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 64
+ },
+ "versions_id": {
+ "$ref": "schedule-run-requested-v1.json#/$defs/ulid"
+ },
+ "script_type": {
+ "enum": [
+ "python",
+ "notebook"
+ ]
+ },
+ "artifact_object_id": {
+ "$ref": "schedule-run-requested-v1.json#/$defs/ulid"
+ },
+ "artifact_path": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 1024
+ },
+ "timeout_seconds": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 86400
+ },
+ "retry_count": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 10
+ },
+ "retry_interval_sec": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 3600
+ },
+ "arguments": {
+ "type": "array",
+ "maxItems": 100,
+ "items": {
+ "type": "string",
+ "maxLength": 1000
+ }
+ }
+ }
+ },
+ "edge": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "source_node_id",
+ "target_node_id"
+ ],
+ "properties": {
+ "source_node_id": {
+ "$ref": "schedule-run-requested-v1.json#/$defs/ulid"
+ },
+ "target_node_id": {
+ "$ref": "schedule-run-requested-v1.json#/$defs/ulid"
+ }
+ }
+ },
+ "snapshot": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "schedule_name",
+ "workflow_version",
+ "max_concurrency",
+ "failure_policy",
+ "nodes",
+ "edges"
+ ],
+ "properties": {
+ "schedule_name": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 255
+ },
+ "workflow_version": {
+ "type": "integer",
+ "minimum": 1
+ },
+ "max_concurrency": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 100
+ },
+ "failure_policy": {
+ "enum": [
+ "stop",
+ "continue"
+ ]
+ },
+ "nodes": {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 100,
+ "items": {
+ "$ref": "schedule-run-requested-v1.json#/$defs/node"
+ }
+ },
+ "edges": {
+ "type": "array",
+ "maxItems": 500,
+ "items": {
+ "$ref": "schedule-run-requested-v1.json#/$defs/edge"
+ }
+ }
+ }
+ },
+ "payload": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "workspace_id",
+ "schedule_id",
+ "run_id",
+ "workflow_version",
+ "trigger_type",
+ "triggered_by",
+ "schedule_snapshot"
+ ],
+ "properties": {
+ "workspace_id": {
+ "$ref": "schedule-run-requested-v1.json#/$defs/ulid"
+ },
+ "schedule_id": {
+ "$ref": "schedule-run-requested-v1.json#/$defs/ulid"
+ },
+ "run_id": {
+ "$ref": "schedule-run-requested-v1.json#/$defs/ulid"
+ },
+ "workflow_version": {
+ "type": "integer",
+ "minimum": 1
+ },
+ "trigger_type": {
+ "enum": [
+ "manual",
+ "cron",
+ "api",
+ "retry"
+ ]
+ },
+ "triggered_by": {
+ "oneOf": [
+ {
+ "$ref": "schedule-run-requested-v1.json#/$defs/ulid"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "schedule_snapshot": {
+ "$ref": "schedule-run-requested-v1.json#/$defs/snapshot"
+ }
+ }
+ }
+ },
+ "examples": [
+ {
+ "event_id": "01K123456789ABCDEFGHJKMNPQ",
+ "event_type": "schedule.run.requested",
+ "schema_version": 1,
+ "occurred_at": "2026-07-27T08:00:00Z",
+ "producer": "platform-api",
+ "trace_id": "req-demo-0001",
+ "aggregate_type": "schedule_run",
+ "aggregate_id": "01K123456789ABCDEFGHJKMNPR",
+ "idempotency_key": "run-demo-0001",
+ "payload": {
+ "workspace_id": "01K123456789ABCDEFGHJKMNPS",
+ "schedule_id": "01K123456789ABCDEFGHJKMNPT",
+ "run_id": "01K123456789ABCDEFGHJKMNPR",
+ "workflow_version": 1,
+ "trigger_type": "manual",
+ "triggered_by": "01K123456789ABCDEFGHJKMNPV",
+ "schedule_snapshot": {
+ "schedule_name": "每日模型演示",
+ "workflow_version": 1,
+ "max_concurrency": 1,
+ "failure_policy": "stop",
+ "nodes": [
+ {
+ "node_id": "01K123456789ABCDEFGHJKMNPW",
+ "node_key": "prepare",
+ "versions_id": "01K123456789ABCDEFGHJKMNPX",
+ "script_type": "python",
+ "artifact_object_id": "01K123456789ABCDEFGHJKMNPY",
+ "artifact_path": "versions/prepare/v1.py",
+ "timeout_seconds": 600,
+ "retry_count": 0,
+ "retry_interval_sec": 5,
+ "arguments": []
+ }
+ ],
+ "edges": []
+ }
+ }
+ }
+ ]
+}
diff --git a/contracts/locks/README.md b/contracts/locks/README.md
new file mode 100644
index 0000000..3578db1
--- /dev/null
+++ b/contracts/locks/README.md
@@ -0,0 +1,3 @@
+# File Edit Lock
+
+当前版本见 [file-edit-lock-v1.md](file-edit-lock-v1.md)。MySQL 租约是实时锁权威。
diff --git a/contracts/locks/file-edit-lock-v1.md b/contracts/locks/file-edit-lock-v1.md
new file mode 100644
index 0000000..f955c61
--- /dev/null
+++ b/contracts/locks/file-edit-lock-v1.md
@@ -0,0 +1,38 @@
+# 文件编辑锁契约 v1
+
+## 公共接口
+
+```text
+POST /api/v1/files/{storage_object_id}/lock
+POST /api/v1/file-locks/{edit_session_id}/heartbeat
+DELETE /api/v1/file-locks/{edit_session_id}
+```
+
+三个接口均要求 `X-User-ID` 和 `X-Workspace-ID`。加锁成功返回一次性原始
+`lock_token`;心跳和释放请求体均为:
+
+```json
+{"lock_token": "raw-token-returned-by-acquire"}
+```
+
+原始 token 只由客户端持有,数据库只保存 SHA-256 摘要。
+
+## MySQL 租约
+
+`edit_sessions` 是实时锁权威:
+
+- `session_status=active` 且 `expires_at > now()` 表示锁有效;
+- 同一 `storage_object_id` 同时只能存在一个有效编辑会话;
+- 心跳更新 `last_heartbeat_at` 与 `expires_at`;
+- 主动释放将状态改为 `closed`;
+- 后台清理将超时租约改为 `expired`。
+
+加锁、心跳和释放都在数据库事务中校验 `edit_session_id + lock_token_hash`。
+部署时 Runtime 保持单副本;若扩展到多副本,应为加锁查询增加数据库行锁或唯一
+租约表约束。
+
+## 状态与错误
+
+- 冲突返回 HTTP 409、错误码 `FILE_LOCK_CONFLICT`,并包含当前编辑者和租约到期时间;
+- 错误 token 返回 HTTP 403,且不得续期或释放现有锁;
+- 浏览器建议每 15 秒心跳,默认租约为 45 秒。
diff --git a/contracts/openapi/README.md b/contracts/openapi/README.md
new file mode 100644
index 0000000..cfc99f3
--- /dev/null
+++ b/contracts/openapi/README.md
@@ -0,0 +1,11 @@
+# OpenAPI 契约
+
+- `platform-api-v1.yaml`:浏览器访问的公共业务接口。
+- `demo-core-extension-v1.yaml`:第 15 小步冻结的 Demo 目标扩展接口;
+ 与 `platform-api-v1.yaml` 合并后构成完整公共 API v1。
+- `storage-api-internal-v1.yaml`:Storage Service 内部接口。
+- `runtime-api-internal-v1.yaml`:Runtime Manager 内部接口。
+
+现有服务契约由对应 FastAPI 应用生成;目标扩展契约先冻结、后实现,不能
+标记为已上线。所有文件均纳入结构校验。公共接口统一使用 `/api/v1`,
+内部服务接口统一使用 `/internal/v1`。
diff --git a/contracts/openapi/demo-core-extension-v1.yaml b/contracts/openapi/demo-core-extension-v1.yaml
new file mode 100644
index 0000000..d81930e
--- /dev/null
+++ b/contracts/openapi/demo-core-extension-v1.yaml
@@ -0,0 +1,1093 @@
+openapi: 3.1.0
+info:
+ title: Model Platform Demo Core Extension
+ version: 1.0.0
+ description: |
+ 第 15 小步冻结的快速 Demo 目标契约。此文件只描述尚待实现的公共接口;
+ 已实现的脚本、稳定版本详情、文件锁和数据资源接口继续以
+ platform-api-v1.yaml 为准。两个文件共同组成 Demo 公共 API v1。
+ 当前身份仍使用 X-User-ID 与 X-Workspace-ID,后续替换 JWT 时不改变业务 DTO。
+x-implementation-status: frozen-target
+paths:
+ /api/v1/session-context:
+ get:
+ tags: [context]
+ operationId: getSessionContext
+ summary: 获取当前用户、Workspace 和可切换上下文
+ parameters:
+ - $ref: '#/components/parameters/XUserId'
+ - $ref: '#/components/parameters/XWorkspaceId'
+ - $ref: '#/components/parameters/XRequestId'
+ responses:
+ '200':
+ description: 当前演示上下文
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/SessionContextResponse'
+ '403':
+ $ref: '#/components/responses/ErrorResponse'
+
+ /api/v1/jupyter/access-tickets:
+ post:
+ tags: [jupyter]
+ operationId: createJupyterAccessTicket
+ x-implementation-status: implemented-step16
+ summary: 为已加锁编辑会话签发短期 Jupyter 访问票据
+ description: |
+ 必须校验 edit_session_id、用户、Workspace 和 lock_token。
+ 成功响应同时设置 HttpOnly、SameSite=Lax、Path=/jupyter/ 的
+ jupyter_access Cookie,浏览器不得获得 Jupyter 内部服务 token。
+ parameters:
+ - $ref: '#/components/parameters/XUserId'
+ - $ref: '#/components/parameters/XWorkspaceId'
+ - $ref: '#/components/parameters/XRequestId'
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/CreateJupyterAccessTicketRequest'
+ responses:
+ '201':
+ description: 访问票据已签发
+ headers:
+ Set-Cookie:
+ schema:
+ type: string
+ description: jupyter_access HttpOnly Cookie
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/JupyterAccessTicketResponse'
+ '403':
+ $ref: '#/components/responses/ErrorResponse'
+ '409':
+ $ref: '#/components/responses/ErrorResponse'
+
+ /api/v1/versions:
+ get:
+ tags: [versions]
+ operationId: listStableVersions
+ summary: 获取调度画布可用的稳定版本
+ parameters:
+ - $ref: '#/components/parameters/XUserId'
+ - $ref: '#/components/parameters/XWorkspaceId'
+ - $ref: '#/components/parameters/XRequestId'
+ - name: keyword
+ in: query
+ required: false
+ schema:
+ type: string
+ maxLength: 100
+ - name: script_type
+ in: query
+ required: false
+ schema:
+ type: string
+ enum: [python, notebook]
+ - name: limit
+ in: query
+ required: false
+ schema:
+ type: integer
+ minimum: 1
+ maximum: 200
+ default: 100
+ responses:
+ '200':
+ description: 当前 Workspace 可见稳定版本
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/StableVersionListResponse'
+ '403':
+ $ref: '#/components/responses/ErrorResponse'
+
+ /api/v1/schedules:
+ get:
+ tags: [schedules]
+ operationId: listSchedules
+ summary: 查询调度方案
+ parameters:
+ - $ref: '#/components/parameters/XUserId'
+ - $ref: '#/components/parameters/XWorkspaceId'
+ - $ref: '#/components/parameters/XRequestId'
+ - name: keyword
+ in: query
+ required: false
+ schema:
+ type: string
+ maxLength: 100
+ - name: enabled
+ in: query
+ required: false
+ schema:
+ type: boolean
+ responses:
+ '200':
+ description: 调度方案列表
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ScheduleListResponse'
+ '403':
+ $ref: '#/components/responses/ErrorResponse'
+ post:
+ tags: [schedules]
+ operationId: createSchedule
+ summary: 创建调度方案及完整 DAG
+ parameters:
+ - $ref: '#/components/parameters/XUserId'
+ - $ref: '#/components/parameters/XWorkspaceId'
+ - $ref: '#/components/parameters/XRequestId'
+ - $ref: '#/components/parameters/IdempotencyKey'
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/CreateScheduleRequest'
+ responses:
+ '201':
+ description: 调度方案已创建
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ScheduleResponse'
+ '409':
+ $ref: '#/components/responses/ErrorResponse'
+ '422':
+ $ref: '#/components/responses/ErrorResponse'
+
+ /api/v1/schedules/preview:
+ post:
+ tags: [schedules]
+ operationId: previewScheduleCron
+ summary: 校验 Cron 并预览未来执行时间
+ parameters:
+ - $ref: '#/components/parameters/XUserId'
+ - $ref: '#/components/parameters/XWorkspaceId'
+ - $ref: '#/components/parameters/XRequestId'
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/CronPreviewRequest'
+ responses:
+ '200':
+ description: Cron 有效
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/CronPreviewResponse'
+ '422':
+ $ref: '#/components/responses/ErrorResponse'
+
+ /api/v1/schedules/{schedule_id}:
+ parameters:
+ - $ref: '#/components/parameters/ScheduleId'
+ get:
+ tags: [schedules]
+ operationId: getSchedule
+ summary: 获取调度方案及完整 DAG
+ parameters:
+ - $ref: '#/components/parameters/XUserId'
+ - $ref: '#/components/parameters/XWorkspaceId'
+ - $ref: '#/components/parameters/XRequestId'
+ responses:
+ '200':
+ description: 调度方案详情
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ScheduleResponse'
+ '404':
+ $ref: '#/components/responses/ErrorResponse'
+ put:
+ tags: [schedules]
+ operationId: updateSchedule
+ summary: 使用 workflow_version 乐观锁替换调度方案和 DAG
+ parameters:
+ - $ref: '#/components/parameters/XUserId'
+ - $ref: '#/components/parameters/XWorkspaceId'
+ - $ref: '#/components/parameters/XRequestId'
+ - $ref: '#/components/parameters/IdempotencyKey'
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/UpdateScheduleRequest'
+ responses:
+ '200':
+ description: 调度方案已更新,workflow_version 已递增
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ScheduleResponse'
+ '404':
+ $ref: '#/components/responses/ErrorResponse'
+ '409':
+ $ref: '#/components/responses/ErrorResponse'
+ '412':
+ $ref: '#/components/responses/ErrorResponse'
+ '422':
+ $ref: '#/components/responses/ErrorResponse'
+ delete:
+ tags: [schedules]
+ operationId: deleteSchedule
+ summary: 软删除调度方案并保留历史运行
+ parameters:
+ - $ref: '#/components/parameters/XUserId'
+ - $ref: '#/components/parameters/XWorkspaceId'
+ - $ref: '#/components/parameters/XRequestId'
+ responses:
+ '200':
+ description: 调度方案已删除
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ScheduleResponse'
+ '404':
+ $ref: '#/components/responses/ErrorResponse'
+
+ /api/v1/schedules/{schedule_id}/run:
+ post:
+ tags: [schedule-runs]
+ operationId: runScheduleNow
+ summary: 为当前 workflow_version 创建一次立即运行
+ parameters:
+ - $ref: '#/components/parameters/ScheduleId'
+ - $ref: '#/components/parameters/XUserId'
+ - $ref: '#/components/parameters/XWorkspaceId'
+ - $ref: '#/components/parameters/XRequestId'
+ - $ref: '#/components/parameters/IdempotencyKey'
+ requestBody:
+ required: false
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/RunScheduleRequest'
+ responses:
+ '202':
+ description: 运行事件已进入 Transactional Outbox
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ScheduleRunResponse'
+ '404':
+ $ref: '#/components/responses/ErrorResponse'
+ '409':
+ $ref: '#/components/responses/ErrorResponse'
+
+ /api/v1/schedules/{schedule_id}/enable:
+ post:
+ tags: [schedules]
+ operationId: enableSchedule
+ summary: 启用 Cron 调度
+ parameters:
+ - $ref: '#/components/parameters/ScheduleId'
+ - $ref: '#/components/parameters/XUserId'
+ - $ref: '#/components/parameters/XWorkspaceId'
+ - $ref: '#/components/parameters/XRequestId'
+ - $ref: '#/components/parameters/IdempotencyKey'
+ responses:
+ '200':
+ description: 调度已启用
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ScheduleResponse'
+ '404':
+ $ref: '#/components/responses/ErrorResponse'
+ '409':
+ $ref: '#/components/responses/ErrorResponse'
+
+ /api/v1/schedules/{schedule_id}/disable:
+ post:
+ tags: [schedules]
+ operationId: disableSchedule
+ summary: 暂停 Cron 调度
+ parameters:
+ - $ref: '#/components/parameters/ScheduleId'
+ - $ref: '#/components/parameters/XUserId'
+ - $ref: '#/components/parameters/XWorkspaceId'
+ - $ref: '#/components/parameters/XRequestId'
+ - $ref: '#/components/parameters/IdempotencyKey'
+ responses:
+ '200':
+ description: 调度已暂停
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ScheduleResponse'
+ '404':
+ $ref: '#/components/responses/ErrorResponse'
+
+ /api/v1/schedule-runs:
+ get:
+ tags: [schedule-runs]
+ operationId: listScheduleRuns
+ summary: 查询最近调度运行
+ parameters:
+ - $ref: '#/components/parameters/XUserId'
+ - $ref: '#/components/parameters/XWorkspaceId'
+ - $ref: '#/components/parameters/XRequestId'
+ - name: schedule_id
+ in: query
+ required: false
+ schema:
+ $ref: '#/components/schemas/Ulid'
+ - name: status
+ in: query
+ required: false
+ schema:
+ $ref: '#/components/schemas/RunStatus'
+ - name: limit
+ in: query
+ required: false
+ schema:
+ type: integer
+ minimum: 1
+ maximum: 200
+ default: 50
+ responses:
+ '200':
+ description: 最近运行列表
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ScheduleRunListResponse'
+ '403':
+ $ref: '#/components/responses/ErrorResponse'
+
+ /api/v1/schedule-runs/{run_id}:
+ get:
+ tags: [schedule-runs]
+ operationId: getScheduleRun
+ summary: 获取运行及各节点状态
+ parameters:
+ - $ref: '#/components/parameters/RunId'
+ - $ref: '#/components/parameters/XUserId'
+ - $ref: '#/components/parameters/XWorkspaceId'
+ - $ref: '#/components/parameters/XRequestId'
+ responses:
+ '200':
+ description: 调度运行详情
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ScheduleRunResponse'
+ '404':
+ $ref: '#/components/responses/ErrorResponse'
+
+ /api/v1/schedule-runs/{run_id}/cancel:
+ post:
+ tags: [schedule-runs]
+ operationId: cancelScheduleRun
+ summary: 请求取消尚未结束的调度运行
+ parameters:
+ - $ref: '#/components/parameters/RunId'
+ - $ref: '#/components/parameters/XUserId'
+ - $ref: '#/components/parameters/XWorkspaceId'
+ - $ref: '#/components/parameters/XRequestId'
+ - $ref: '#/components/parameters/IdempotencyKey'
+ responses:
+ '202':
+ description: 取消请求已接受
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ScheduleRunResponse'
+ '404':
+ $ref: '#/components/responses/ErrorResponse'
+ '409':
+ $ref: '#/components/responses/ErrorResponse'
+
+components:
+ parameters:
+ XUserId:
+ name: X-User-ID
+ in: header
+ required: true
+ schema:
+ $ref: '#/components/schemas/Ulid'
+ XWorkspaceId:
+ name: X-Workspace-ID
+ in: header
+ required: true
+ schema:
+ $ref: '#/components/schemas/Ulid'
+ XRequestId:
+ name: X-Request-ID
+ in: header
+ required: false
+ schema:
+ type: string
+ minLength: 1
+ maxLength: 64
+ IdempotencyKey:
+ name: Idempotency-Key
+ in: header
+ required: true
+ schema:
+ type: string
+ minLength: 8
+ maxLength: 128
+ ScheduleId:
+ name: schedule_id
+ in: path
+ required: true
+ schema:
+ $ref: '#/components/schemas/Ulid'
+ RunId:
+ name: run_id
+ in: path
+ required: true
+ schema:
+ $ref: '#/components/schemas/Ulid'
+
+ responses:
+ ErrorResponse:
+ description: 标准错误响应
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorEnvelope'
+
+ schemas:
+ Ulid:
+ type: string
+ minLength: 26
+ maxLength: 26
+ pattern: '^[0-9A-HJKMNP-TV-Z]{26}$'
+
+ UtcDateTime:
+ type: string
+ format: date-time
+
+ RunStatus:
+ type: string
+ enum: [queued, running, succeeded, failed, cancelled, timed_out]
+
+ NodeRunStatus:
+ type: string
+ enum: [queued, running, succeeded, failed, skipped, cancelled, timed_out]
+
+ ErrorEnvelope:
+ type: object
+ additionalProperties: false
+ required: [request_id, error]
+ properties:
+ request_id:
+ type: string
+ error:
+ type: object
+ additionalProperties: false
+ required: [code, message, retryable, details]
+ properties:
+ code:
+ type: string
+ pattern: '^[A-Z][A-Z0-9_]+$'
+ message:
+ type: string
+ retryable:
+ type: boolean
+ details:
+ type: object
+
+ UserSummary:
+ type: object
+ additionalProperties: false
+ required: [user_id, username, display_name, role_code]
+ properties:
+ user_id:
+ $ref: '#/components/schemas/Ulid'
+ username:
+ type: string
+ display_name:
+ type: string
+ role_code:
+ type: string
+
+ WorkspaceSummary:
+ type: object
+ additionalProperties: false
+ required: [workspace_id, workspace_code, workspace_name]
+ properties:
+ workspace_id:
+ $ref: '#/components/schemas/Ulid'
+ workspace_code:
+ type: string
+ workspace_name:
+ type: string
+
+ SessionContextResponse:
+ type: object
+ additionalProperties: false
+ required: [request_id, data, meta]
+ properties:
+ request_id:
+ type: string
+ data:
+ type: object
+ additionalProperties: false
+ required: [current_user, current_workspace, workspaces, workspace_members]
+ properties:
+ current_user:
+ $ref: '#/components/schemas/UserSummary'
+ current_workspace:
+ $ref: '#/components/schemas/WorkspaceSummary'
+ workspaces:
+ type: array
+ items:
+ $ref: '#/components/schemas/WorkspaceSummary'
+ workspace_members:
+ type: array
+ items:
+ $ref: '#/components/schemas/UserSummary'
+ meta:
+ type: object
+
+ CreateJupyterAccessTicketRequest:
+ type: object
+ additionalProperties: false
+ required: [edit_session_id, lock_token]
+ properties:
+ edit_session_id:
+ $ref: '#/components/schemas/Ulid'
+ lock_token:
+ type: string
+ minLength: 32
+ maxLength: 256
+
+ JupyterAccessTicketResponse:
+ type: object
+ additionalProperties: false
+ required: [request_id, data, meta]
+ properties:
+ request_id:
+ type: string
+ data:
+ type: object
+ additionalProperties: false
+ required: [edit_session_id, jupyter_url, expires_at]
+ properties:
+ edit_session_id:
+ $ref: '#/components/schemas/Ulid'
+ jupyter_url:
+ type: string
+ pattern: '^/jupyter/'
+ expires_at:
+ $ref: '#/components/schemas/UtcDateTime'
+ meta:
+ type: object
+
+ StableVersionSummary:
+ type: object
+ additionalProperties: false
+ required:
+ - versions_id
+ - script_id
+ - script_name
+ - script_type
+ - version_label
+ - artifact_object_id
+ - source_path
+ - visibility
+ - created_by
+ - created_at
+ properties:
+ versions_id:
+ $ref: '#/components/schemas/Ulid'
+ script_id:
+ $ref: '#/components/schemas/Ulid'
+ script_name:
+ type: string
+ script_type:
+ type: string
+ enum: [python, notebook]
+ version_label:
+ type: string
+ artifact_object_id:
+ $ref: '#/components/schemas/Ulid'
+ source_path:
+ type: string
+ visibility:
+ type: string
+ enum: [private, workspace, public]
+ created_by:
+ $ref: '#/components/schemas/Ulid'
+ created_at:
+ $ref: '#/components/schemas/UtcDateTime'
+
+ StableVersionListResponse:
+ type: object
+ additionalProperties: false
+ required: [request_id, data, meta]
+ properties:
+ request_id:
+ type: string
+ data:
+ type: array
+ items:
+ $ref: '#/components/schemas/StableVersionSummary'
+ meta:
+ type: object
+ required: [count]
+ properties:
+ count:
+ type: integer
+ minimum: 0
+
+ ScheduleNodeInput:
+ type: object
+ required:
+ - node_key
+ - node_name
+ - versions_id
+ - timeout_seconds
+ - retry_count
+ - retry_interval_sec
+ - position_x
+ - position_y
+ - arguments
+ properties:
+ node_key:
+ type: string
+ minLength: 1
+ maxLength: 64
+ pattern: '^[A-Za-z0-9_-]+$'
+ node_name:
+ type: string
+ minLength: 1
+ maxLength: 255
+ versions_id:
+ $ref: '#/components/schemas/Ulid'
+ timeout_seconds:
+ type: integer
+ minimum: 1
+ maximum: 86400
+ default: 600
+ retry_count:
+ type: integer
+ minimum: 0
+ maximum: 10
+ default: 0
+ retry_interval_sec:
+ type: integer
+ minimum: 0
+ maximum: 3600
+ default: 5
+ position_x:
+ type: number
+ position_y:
+ type: number
+ arguments:
+ type: array
+ maxItems: 100
+ items:
+ type: string
+ maxLength: 1000
+
+ ScheduleEdgeInput:
+ type: object
+ required: [source_node_key, target_node_key]
+ properties:
+ source_node_key:
+ type: string
+ minLength: 1
+ maxLength: 64
+ target_node_key:
+ type: string
+ minLength: 1
+ maxLength: 64
+
+ ScheduleDefinitionBase:
+ type: object
+ required:
+ - schedule_name
+ - trigger_type
+ - timezone
+ - max_concurrency
+ - failure_policy
+ - nodes
+ - edges
+ properties:
+ schedule_name:
+ type: string
+ minLength: 1
+ maxLength: 255
+ description:
+ type: [string, 'null']
+ maxLength: 1000
+ trigger_type:
+ type: string
+ enum: [manual, cron, api]
+ default: cron
+ cron_expression:
+ type: [string, 'null']
+ maxLength: 128
+ timezone:
+ type: string
+ minLength: 1
+ maxLength: 64
+ default: Asia/Shanghai
+ max_concurrency:
+ type: integer
+ minimum: 1
+ maximum: 100
+ default: 1
+ failure_policy:
+ type: string
+ enum: [stop, continue]
+ default: stop
+ nodes:
+ type: array
+ minItems: 1
+ maxItems: 100
+ items:
+ $ref: '#/components/schemas/ScheduleNodeInput'
+ edges:
+ type: array
+ maxItems: 500
+ items:
+ $ref: '#/components/schemas/ScheduleEdgeInput'
+
+ CreateScheduleRequest:
+ unevaluatedProperties: false
+ allOf:
+ - $ref: '#/components/schemas/ScheduleDefinitionBase'
+
+ UpdateScheduleRequest:
+ unevaluatedProperties: false
+ allOf:
+ - $ref: '#/components/schemas/ScheduleDefinitionBase'
+ - type: object
+ required: [workflow_version]
+ properties:
+ workflow_version:
+ type: integer
+ minimum: 1
+
+ ScheduleNode:
+ unevaluatedProperties: false
+ allOf:
+ - $ref: '#/components/schemas/ScheduleNodeInput'
+ - type: object
+ required: [node_id]
+ properties:
+ node_id:
+ $ref: '#/components/schemas/Ulid'
+
+ ScheduleEdge:
+ unevaluatedProperties: false
+ allOf:
+ - $ref: '#/components/schemas/ScheduleEdgeInput'
+ - type: object
+ required: [edge_id, source_node_id, target_node_id]
+ properties:
+ edge_id:
+ $ref: '#/components/schemas/Ulid'
+ source_node_id:
+ $ref: '#/components/schemas/Ulid'
+ target_node_id:
+ $ref: '#/components/schemas/Ulid'
+
+ Schedule:
+ type: object
+ additionalProperties: false
+ required:
+ - schedule_id
+ - workspace_id
+ - schedule_name
+ - trigger_type
+ - timezone
+ - enabled
+ - workflow_version
+ - max_concurrency
+ - failure_policy
+ - nodes
+ - edges
+ - created_by
+ - updated_by
+ - created_at
+ - updated_at
+ properties:
+ schedule_id:
+ $ref: '#/components/schemas/Ulid'
+ workspace_id:
+ $ref: '#/components/schemas/Ulid'
+ schedule_name:
+ type: string
+ description:
+ type: [string, 'null']
+ trigger_type:
+ type: string
+ enum: [manual, cron, api]
+ cron_expression:
+ type: [string, 'null']
+ timezone:
+ type: string
+ enabled:
+ type: boolean
+ workflow_version:
+ type: integer
+ minimum: 1
+ max_concurrency:
+ type: integer
+ failure_policy:
+ type: string
+ enum: [stop, continue]
+ next_run_at:
+ type: [string, 'null']
+ format: date-time
+ last_run_at:
+ type: [string, 'null']
+ format: date-time
+ nodes:
+ type: array
+ items:
+ $ref: '#/components/schemas/ScheduleNode'
+ edges:
+ type: array
+ items:
+ $ref: '#/components/schemas/ScheduleEdge'
+ created_by:
+ $ref: '#/components/schemas/Ulid'
+ updated_by:
+ $ref: '#/components/schemas/Ulid'
+ created_at:
+ $ref: '#/components/schemas/UtcDateTime'
+ updated_at:
+ $ref: '#/components/schemas/UtcDateTime'
+
+ ScheduleResponse:
+ type: object
+ additionalProperties: false
+ required: [request_id, data, meta]
+ properties:
+ request_id:
+ type: string
+ data:
+ $ref: '#/components/schemas/Schedule'
+ meta:
+ type: object
+
+ ScheduleListResponse:
+ type: object
+ additionalProperties: false
+ required: [request_id, data, meta]
+ properties:
+ request_id:
+ type: string
+ data:
+ type: array
+ items:
+ $ref: '#/components/schemas/Schedule'
+ meta:
+ type: object
+ required: [count]
+ properties:
+ count:
+ type: integer
+ minimum: 0
+
+ CronPreviewRequest:
+ type: object
+ additionalProperties: false
+ required: [cron_expression, timezone]
+ properties:
+ cron_expression:
+ type: string
+ minLength: 9
+ maxLength: 128
+ timezone:
+ type: string
+ minLength: 1
+ maxLength: 64
+ default: Asia/Shanghai
+ count:
+ type: integer
+ minimum: 1
+ maximum: 20
+ default: 5
+
+ CronPreviewResponse:
+ type: object
+ additionalProperties: false
+ required: [request_id, data, meta]
+ properties:
+ request_id:
+ type: string
+ data:
+ type: object
+ additionalProperties: false
+ required: [cron_expression, timezone, next_runs]
+ properties:
+ cron_expression:
+ type: string
+ timezone:
+ type: string
+ next_runs:
+ type: array
+ items:
+ $ref: '#/components/schemas/UtcDateTime'
+ meta:
+ type: object
+
+ RunScheduleRequest:
+ type: object
+ additionalProperties: false
+ properties:
+ reason:
+ type: string
+ maxLength: 255
+ default: manual_run
+
+ ScheduleRunSummary:
+ type: object
+ required:
+ - run_id
+ - schedule_id
+ - workspace_id
+ - workflow_version
+ - trigger_type
+ - run_status
+ - state_version
+ - queued_at
+ properties:
+ run_id:
+ $ref: '#/components/schemas/Ulid'
+ schedule_id:
+ $ref: '#/components/schemas/Ulid'
+ workspace_id:
+ $ref: '#/components/schemas/Ulid'
+ workflow_version:
+ type: integer
+ trigger_type:
+ type: string
+ enum: [manual, cron, api, retry]
+ run_status:
+ $ref: '#/components/schemas/RunStatus'
+ state_version:
+ type: integer
+ minimum: 0
+ queued_at:
+ $ref: '#/components/schemas/UtcDateTime'
+ started_at:
+ type: [string, 'null']
+ format: date-time
+ finished_at:
+ type: [string, 'null']
+ format: date-time
+ duration_ms:
+ type: [integer, 'null']
+ minimum: 0
+ error_code:
+ type: [string, 'null']
+ error_message:
+ type: [string, 'null']
+ logs_object_id:
+ oneOf:
+ - $ref: '#/components/schemas/Ulid'
+ - type: 'null'
+ result_object_id:
+ oneOf:
+ - $ref: '#/components/schemas/Ulid'
+ - type: 'null'
+
+ ScheduleNodeRun:
+ type: object
+ additionalProperties: false
+ required:
+ - node_run_id
+ - run_id
+ - node_id
+ - versions_id
+ - attempt_no
+ - node_status
+ - state_version
+ properties:
+ node_run_id:
+ $ref: '#/components/schemas/Ulid'
+ run_id:
+ $ref: '#/components/schemas/Ulid'
+ node_id:
+ $ref: '#/components/schemas/Ulid'
+ versions_id:
+ $ref: '#/components/schemas/Ulid'
+ attempt_no:
+ type: integer
+ minimum: 1
+ node_status:
+ $ref: '#/components/schemas/NodeRunStatus'
+ state_version:
+ type: integer
+ minimum: 0
+ started_at:
+ type: [string, 'null']
+ format: date-time
+ finished_at:
+ type: [string, 'null']
+ format: date-time
+ duration_ms:
+ type: [integer, 'null']
+ minimum: 0
+ exit_code:
+ type: [integer, 'null']
+ message:
+ type: [string, 'null']
+ logs_object_id:
+ oneOf:
+ - $ref: '#/components/schemas/Ulid'
+ - type: 'null'
+ result_object_id:
+ oneOf:
+ - $ref: '#/components/schemas/Ulid'
+ - type: 'null'
+
+ ScheduleRunDetail:
+ unevaluatedProperties: false
+ allOf:
+ - $ref: '#/components/schemas/ScheduleRunSummary'
+ - type: object
+ required: [node_runs]
+ properties:
+ node_runs:
+ type: array
+ items:
+ $ref: '#/components/schemas/ScheduleNodeRun'
+
+ ScheduleRunResponse:
+ type: object
+ additionalProperties: false
+ required: [request_id, data, meta]
+ properties:
+ request_id:
+ type: string
+ data:
+ $ref: '#/components/schemas/ScheduleRunDetail'
+ meta:
+ type: object
+
+ ScheduleRunListResponse:
+ type: object
+ additionalProperties: false
+ required: [request_id, data, meta]
+ properties:
+ request_id:
+ type: string
+ data:
+ type: array
+ items:
+ $ref: '#/components/schemas/ScheduleRunSummary'
+ meta:
+ type: object
+ required: [count]
+ properties:
+ count:
+ type: integer
+ minimum: 0
diff --git a/contracts/openapi/platform-api-v1.yaml b/contracts/openapi/platform-api-v1.yaml
new file mode 100644
index 0000000..3cb378d
--- /dev/null
+++ b/contracts/openapi/platform-api-v1.yaml
@@ -0,0 +1,3080 @@
+openapi: 3.1.0
+info:
+ title: platform-api service
+ version: 0.1.0
+paths:
+ /:
+ get:
+ summary: Root
+ operationId: root__get
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ additionalProperties:
+ type: string
+ type: object
+ title: Response Root Get
+ /health/live:
+ get:
+ summary: Live
+ operationId: live_health_live_get
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ additionalProperties:
+ type: string
+ type: object
+ title: Response Live Health Live Get
+ /api/v1/health:
+ get:
+ summary: Public Health
+ operationId: public_health_api_v1_health_get
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ additionalProperties:
+ type: string
+ type: object
+ title: Response Public Health Api V1 Health Get
+ /health/ready:
+ get:
+ summary: Ready
+ operationId: ready_health_ready_get
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ additionalProperties: true
+ type: object
+ title: Response Ready Health Ready Get
+ /api/v1/files/{storage_object_id}/lock:
+ post:
+ tags:
+ - file-locks
+ summary: Acquire File Lock
+ operationId: acquire_file_lock_api_v1_files__storage_object_id__lock_post
+ parameters:
+ - name: storage_object_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Storage Object Id
+ - name: X-User-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-User-Id
+ - name: X-Workspace-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-Workspace-Id
+ - name: X-Request-ID
+ in: header
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: X-Request-Id
+ responses:
+ '201':
+ description: Successful Response
+ content:
+ application/json:
+ schema: {}
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ /api/v1/file-locks/{edit_session_id}/heartbeat:
+ post:
+ tags:
+ - file-locks
+ summary: Heartbeat File Lock
+ operationId: heartbeat_file_lock_api_v1_file_locks__edit_session_id__heartbeat_post
+ parameters:
+ - name: edit_session_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Edit Session Id
+ - name: X-User-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-User-Id
+ - name: X-Workspace-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-Workspace-Id
+ - name: X-Request-ID
+ in: header
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: X-Request-Id
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/FileLockTokenRequest'
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema: {}
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ /api/v1/file-locks/{edit_session_id}:
+ delete:
+ tags:
+ - file-locks
+ summary: Release File Lock
+ operationId: release_file_lock_api_v1_file_locks__edit_session_id__delete
+ parameters:
+ - name: edit_session_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Edit Session Id
+ - name: X-User-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-User-Id
+ - name: X-Workspace-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-Workspace-Id
+ - name: X-Request-ID
+ in: header
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: X-Request-Id
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/FileLockTokenRequest'
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema: {}
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ /api/v1/jupyter/access-tickets:
+ post:
+ tags:
+ - jupyter
+ summary: Create Jupyter Access Ticket
+ operationId: create_jupyter_access_ticket_api_v1_jupyter_access_tickets_post
+ parameters:
+ - name: X-User-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-User-Id
+ - name: X-Workspace-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-Workspace-Id
+ - name: X-Request-ID
+ in: header
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: X-Request-Id
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/CreateJupyterAccessTicketRequest'
+ responses:
+ '201':
+ description: Successful Response
+ content:
+ application/json:
+ schema: {}
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ /api/v1/data-resources/uploads:
+ post:
+ tags:
+ - data-resources
+ summary: Create Resource Upload
+ operationId: create_resource_upload_api_v1_data_resources_uploads_post
+ parameters:
+ - name: Idempotency-Key
+ in: header
+ required: true
+ schema:
+ type: string
+ minLength: 8
+ maxLength: 128
+ title: Idempotency-Key
+ - name: X-User-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-User-Id
+ - name: X-Workspace-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-Workspace-Id
+ - name: X-Request-ID
+ in: header
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: X-Request-Id
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/CreateResourceUploadRequest'
+ responses:
+ '201':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ title: Response Create Resource Upload Api V1 Data Resources Uploads Post
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ /api/v1/data-resources/uploads/{upload_id}/complete:
+ post:
+ tags:
+ - data-resources
+ summary: Complete Resource Upload
+ operationId: complete_resource_upload_api_v1_data_resources_uploads__upload_id__complete_post
+ parameters:
+ - name: upload_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Upload Id
+ - name: X-User-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-User-Id
+ - name: X-Workspace-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-Workspace-Id
+ - name: X-Request-ID
+ in: header
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: X-Request-Id
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/CompleteResourceUploadRequest'
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ title: Response Complete Resource Upload Api V1 Data Resources Uploads Upload Id Complete
+ Post
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ /api/v1/data-resources:
+ get:
+ tags:
+ - data-resources
+ summary: List Resources
+ operationId: list_resources_api_v1_data_resources_get
+ parameters:
+ - name: visibility
+ in: query
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: Visibility
+ - name: keyword
+ in: query
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ maxLength: 100
+ - type: 'null'
+ title: Keyword
+ - name: X-User-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-User-Id
+ - name: X-Workspace-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-Workspace-Id
+ - name: X-Request-ID
+ in: header
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: X-Request-Id
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ title: Response List Resources Api V1 Data Resources Get
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ /api/v1/data-resources/{resource_id}:
+ get:
+ tags:
+ - data-resources
+ summary: Get Resource
+ operationId: get_resource_api_v1_data_resources__resource_id__get
+ parameters:
+ - name: resource_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Resource Id
+ - name: X-User-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-User-Id
+ - name: X-Workspace-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-Workspace-Id
+ - name: X-Request-ID
+ in: header
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: X-Request-Id
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ title: Response Get Resource Api V1 Data Resources Resource Id Get
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ delete:
+ tags:
+ - data-resources
+ summary: Delete Resource
+ operationId: delete_resource_api_v1_data_resources__resource_id__delete
+ parameters:
+ - name: resource_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Resource Id
+ - name: X-User-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-User-Id
+ - name: X-Workspace-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-Workspace-Id
+ - name: X-Request-ID
+ in: header
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: X-Request-Id
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ title: Response Delete Resource Api V1 Data Resources Resource Id Delete
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ /api/v1/data-resources/{resource_id}/download-url:
+ post:
+ tags:
+ - data-resources
+ summary: Resource Download Url
+ operationId: resource_download_url_api_v1_data_resources__resource_id__download_url_post
+ parameters:
+ - name: resource_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Resource Id
+ - name: X-User-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-User-Id
+ - name: X-Workspace-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-Workspace-Id
+ - name: X-Request-ID
+ in: header
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: X-Request-Id
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/DownloadUrlRequest'
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ title: Response Resource Download Url Api V1 Data Resources Resource Id Download Url
+ Post
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ /api/v1/schedules/{schedule_id}/run:
+ post:
+ tags:
+ - schedule-runs
+ summary: Run Schedule Now
+ operationId: run_schedule_now_api_v1_schedules__schedule_id__run_post
+ parameters:
+ - name: schedule_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Schedule Id
+ - name: Idempotency-Key
+ in: header
+ required: true
+ schema:
+ type: string
+ title: Idempotency-Key
+ - name: X-User-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-User-Id
+ - name: X-Workspace-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-Workspace-Id
+ - name: X-Request-ID
+ in: header
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: X-Request-Id
+ requestBody:
+ content:
+ application/json:
+ schema:
+ anyOf:
+ - $ref: '#/components/schemas/RunScheduleRequest'
+ - type: 'null'
+ title: Payload
+ responses:
+ '202':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ title: Response Run Schedule Now Api V1 Schedules Schedule Id Run Post
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ /api/v1/schedule-runs:
+ get:
+ tags:
+ - schedule-runs
+ summary: List Schedule Runs
+ operationId: list_schedule_runs_api_v1_schedule_runs_get
+ parameters:
+ - name: schedule_id
+ in: query
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: Schedule Id
+ - name: status
+ in: query
+ required: false
+ schema:
+ anyOf:
+ - enum:
+ - queued
+ - running
+ - succeeded
+ - failed
+ - cancelled
+ - timed_out
+ type: string
+ - type: 'null'
+ title: Status
+ - name: limit
+ in: query
+ required: false
+ schema:
+ type: integer
+ maximum: 200
+ minimum: 1
+ default: 50
+ title: Limit
+ - name: X-User-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-User-Id
+ - name: X-Workspace-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-Workspace-Id
+ - name: X-Request-ID
+ in: header
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: X-Request-Id
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ title: Response List Schedule Runs Api V1 Schedule Runs Get
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ /api/v1/schedule-runs/{run_id}:
+ get:
+ tags:
+ - schedule-runs
+ summary: Get Schedule Run
+ operationId: get_schedule_run_api_v1_schedule_runs__run_id__get
+ parameters:
+ - name: run_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Run Id
+ - name: X-User-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-User-Id
+ - name: X-Workspace-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-Workspace-Id
+ - name: X-Request-ID
+ in: header
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: X-Request-Id
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ title: Response Get Schedule Run Api V1 Schedule Runs Run Id Get
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ /api/v1/cron/preview:
+ post:
+ tags:
+ - schedules
+ summary: Preview Cron
+ operationId: preview_cron_api_v1_cron_preview_post
+ parameters:
+ - name: X-User-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-User-Id
+ - name: X-Workspace-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-Workspace-Id
+ - name: X-Request-ID
+ in: header
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: X-Request-Id
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/CronPreviewRequest'
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ title: Response Preview Cron Api V1 Cron Preview Post
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ /api/v1/schedule-artifacts:
+ get:
+ tags:
+ - schedules
+ summary: List Schedule Artifacts
+ operationId: list_schedule_artifacts_api_v1_schedule_artifacts_get
+ parameters:
+ - name: limit
+ in: query
+ required: false
+ schema:
+ type: integer
+ maximum: 500
+ minimum: 1
+ default: 100
+ title: Limit
+ - name: X-User-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-User-Id
+ - name: X-Workspace-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-Workspace-Id
+ - name: X-Request-ID
+ in: header
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: X-Request-Id
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ title: Response List Schedule Artifacts Api V1 Schedule Artifacts Get
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ /api/v1/schedules:
+ get:
+ tags:
+ - schedules
+ summary: List Schedules
+ operationId: list_schedules_api_v1_schedules_get
+ parameters:
+ - name: X-User-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-User-Id
+ - name: X-Workspace-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-Workspace-Id
+ - name: X-Request-ID
+ in: header
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: X-Request-Id
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ title: Response List Schedules Api V1 Schedules Get
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ post:
+ tags:
+ - schedules
+ summary: Create Schedule
+ operationId: create_schedule_api_v1_schedules_post
+ parameters:
+ - name: X-User-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-User-Id
+ - name: X-Workspace-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-Workspace-Id
+ - name: X-Request-ID
+ in: header
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: X-Request-Id
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/CreateScheduleRequest'
+ responses:
+ '201':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ title: Response Create Schedule Api V1 Schedules Post
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ /api/v1/schedules/{schedule_id}:
+ get:
+ tags:
+ - schedules
+ summary: Get Schedule
+ operationId: get_schedule_api_v1_schedules__schedule_id__get
+ parameters:
+ - name: schedule_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Schedule Id
+ - name: X-User-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-User-Id
+ - name: X-Workspace-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-Workspace-Id
+ - name: X-Request-ID
+ in: header
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: X-Request-Id
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ title: Response Get Schedule Api V1 Schedules Schedule Id Get
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ patch:
+ tags:
+ - schedules
+ summary: Update Schedule
+ operationId: update_schedule_api_v1_schedules__schedule_id__patch
+ parameters:
+ - name: schedule_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Schedule Id
+ - name: X-User-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-User-Id
+ - name: X-Workspace-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-Workspace-Id
+ - name: X-Request-ID
+ in: header
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: X-Request-Id
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/UpdateScheduleRequest'
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ title: Response Update Schedule Api V1 Schedules Schedule Id Patch
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ put:
+ tags:
+ - schedules
+ summary: Update Schedule
+ operationId: update_schedule_api_v1_schedules__schedule_id__put
+ parameters:
+ - name: schedule_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Schedule Id
+ - name: X-User-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-User-Id
+ - name: X-Workspace-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-Workspace-Id
+ - name: X-Request-ID
+ in: header
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: X-Request-Id
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/UpdateScheduleRequest'
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ title: Response Update Schedule Api V1 Schedules Schedule Id Put
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ delete:
+ tags:
+ - schedules
+ summary: Delete Schedule
+ operationId: delete_schedule_api_v1_schedules__schedule_id__delete
+ parameters:
+ - name: schedule_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Schedule Id
+ - name: X-User-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-User-Id
+ - name: X-Workspace-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-Workspace-Id
+ - name: X-Request-ID
+ in: header
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: X-Request-Id
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/WorkflowVersionRequest'
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ title: Response Delete Schedule Api V1 Schedules Schedule Id Delete
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ /api/v1/schedules/{schedule_id}/nodes:
+ post:
+ tags:
+ - schedules
+ summary: Create Schedule Node
+ operationId: create_schedule_node_api_v1_schedules__schedule_id__nodes_post
+ parameters:
+ - name: schedule_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Schedule Id
+ - name: X-User-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-User-Id
+ - name: X-Workspace-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-Workspace-Id
+ - name: X-Request-ID
+ in: header
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: X-Request-Id
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/CreateScheduleNodeRequest'
+ responses:
+ '201':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ title: Response Create Schedule Node Api V1 Schedules Schedule Id Nodes Post
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ /api/v1/schedules/{schedule_id}/nodes/{node_id}:
+ put:
+ tags:
+ - schedules
+ summary: Update Schedule Node
+ operationId: update_schedule_node_api_v1_schedules__schedule_id__nodes__node_id__put
+ parameters:
+ - name: schedule_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Schedule Id
+ - name: node_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Node Id
+ - name: X-User-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-User-Id
+ - name: X-Workspace-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-Workspace-Id
+ - name: X-Request-ID
+ in: header
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: X-Request-Id
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/UpdateScheduleNodeRequest'
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ title: Response Update Schedule Node Api V1 Schedules Schedule Id Nodes Node Id Put
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ delete:
+ tags:
+ - schedules
+ summary: Delete Schedule Node
+ operationId: delete_schedule_node_api_v1_schedules__schedule_id__nodes__node_id__delete
+ parameters:
+ - name: schedule_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Schedule Id
+ - name: node_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Node Id
+ - name: X-User-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-User-Id
+ - name: X-Workspace-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-Workspace-Id
+ - name: X-Request-ID
+ in: header
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: X-Request-Id
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/WorkflowVersionRequest'
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ title: Response Delete Schedule Node Api V1 Schedules Schedule Id Nodes Node Id Delete
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ /api/v1/schedules/{schedule_id}/edges:
+ post:
+ tags:
+ - schedules
+ summary: Create Schedule Edge
+ operationId: create_schedule_edge_api_v1_schedules__schedule_id__edges_post
+ parameters:
+ - name: schedule_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Schedule Id
+ - name: X-User-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-User-Id
+ - name: X-Workspace-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-Workspace-Id
+ - name: X-Request-ID
+ in: header
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: X-Request-Id
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/CreateScheduleEdgeRequest'
+ responses:
+ '201':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ title: Response Create Schedule Edge Api V1 Schedules Schedule Id Edges Post
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ /api/v1/schedules/{schedule_id}/edges/{edge_id}:
+ put:
+ tags:
+ - schedules
+ summary: Update Schedule Edge
+ operationId: update_schedule_edge_api_v1_schedules__schedule_id__edges__edge_id__put
+ parameters:
+ - name: schedule_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Schedule Id
+ - name: edge_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Edge Id
+ - name: X-User-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-User-Id
+ - name: X-Workspace-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-Workspace-Id
+ - name: X-Request-ID
+ in: header
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: X-Request-Id
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/UpdateScheduleEdgeRequest'
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ title: Response Update Schedule Edge Api V1 Schedules Schedule Id Edges Edge Id Put
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ delete:
+ tags:
+ - schedules
+ summary: Delete Schedule Edge
+ operationId: delete_schedule_edge_api_v1_schedules__schedule_id__edges__edge_id__delete
+ parameters:
+ - name: schedule_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Schedule Id
+ - name: edge_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Edge Id
+ - name: X-User-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-User-Id
+ - name: X-Workspace-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-Workspace-Id
+ - name: X-Request-ID
+ in: header
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: X-Request-Id
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/WorkflowVersionRequest'
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ title: Response Delete Schedule Edge Api V1 Schedules Schedule Id Edges Edge Id Delete
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ /api/v1/schedules/{schedule_id}/validate:
+ post:
+ tags:
+ - schedules
+ summary: Validate Schedule
+ operationId: validate_schedule_api_v1_schedules__schedule_id__validate_post
+ parameters:
+ - name: schedule_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Schedule Id
+ - name: X-User-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-User-Id
+ - name: X-Workspace-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-Workspace-Id
+ - name: X-Request-ID
+ in: header
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: X-Request-Id
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ title: Response Validate Schedule Api V1 Schedules Schedule Id Validate Post
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ /api/v1/scripts:
+ post:
+ tags:
+ - scripts
+ summary: Create Script
+ operationId: create_script_api_v1_scripts_post
+ parameters:
+ - name: X-User-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-User-Id
+ - name: X-Workspace-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-Workspace-Id
+ - name: X-Request-ID
+ in: header
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: X-Request-Id
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/CreateScriptRequest'
+ responses:
+ '201':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ title: Response Create Script Api V1 Scripts Post
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ get:
+ tags:
+ - scripts
+ summary: List Scripts
+ operationId: list_scripts_api_v1_scripts_get
+ parameters:
+ - name: X-User-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-User-Id
+ - name: X-Workspace-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-Workspace-Id
+ - name: X-Request-ID
+ in: header
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: X-Request-Id
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ title: Response List Scripts Api V1 Scripts Get
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ /api/v1/scripts/upload:
+ post:
+ tags:
+ - scripts
+ summary: Upload Script
+ operationId: upload_script_api_v1_scripts_upload_post
+ parameters:
+ - name: file_name
+ in: query
+ required: true
+ schema:
+ type: string
+ minLength: 1
+ maxLength: 255
+ title: File Name
+ - name: parent_path
+ in: query
+ required: false
+ schema:
+ type: string
+ maxLength: 1024
+ default: ''
+ title: Parent Path
+ - name: visibility
+ in: query
+ required: false
+ schema:
+ type: string
+ pattern: ^(private|workspace|public)$
+ default: workspace
+ title: Visibility
+ - name: X-User-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-User-Id
+ - name: X-Workspace-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-Workspace-Id
+ - name: X-Request-ID
+ in: header
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: X-Request-Id
+ responses:
+ '201':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ title: Response Upload Script Api V1 Scripts Upload Post
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ /api/v1/workspace-tree:
+ get:
+ tags:
+ - scripts
+ summary: Get Workspace Tree
+ operationId: get_workspace_tree_api_v1_workspace_tree_get
+ parameters:
+ - name: X-User-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-User-Id
+ - name: X-Workspace-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-Workspace-Id
+ - name: X-Request-ID
+ in: header
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: X-Request-Id
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ title: Response Get Workspace Tree Api V1 Workspace Tree Get
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ /api/v1/workspace-directories:
+ post:
+ tags:
+ - scripts
+ summary: Create Workspace Directory
+ operationId: create_workspace_directory_api_v1_workspace_directories_post
+ parameters:
+ - name: X-User-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-User-Id
+ - name: X-Workspace-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-Workspace-Id
+ - name: X-Request-ID
+ in: header
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: X-Request-Id
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/CreateWorkspaceDirectoryRequest'
+ responses:
+ '201':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ title: Response Create Workspace Directory Api V1 Workspace Directories Post
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ delete:
+ tags:
+ - scripts
+ summary: Delete Workspace Directory
+ operationId: delete_workspace_directory_api_v1_workspace_directories_delete
+ parameters:
+ - name: path
+ in: query
+ required: true
+ schema:
+ type: string
+ minLength: 1
+ maxLength: 1024
+ title: Path
+ - name: X-User-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-User-Id
+ - name: X-Workspace-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-Workspace-Id
+ - name: X-Request-ID
+ in: header
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: X-Request-Id
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ title: Response Delete Workspace Directory Api V1 Workspace Directories Delete
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ /api/v1/scripts/{script_id}:
+ get:
+ tags:
+ - scripts
+ summary: Get Script
+ operationId: get_script_api_v1_scripts__script_id__get
+ parameters:
+ - name: script_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Script Id
+ - name: X-User-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-User-Id
+ - name: X-Workspace-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-Workspace-Id
+ - name: X-Request-ID
+ in: header
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: X-Request-Id
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ title: Response Get Script Api V1 Scripts Script Id Get
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ put:
+ tags:
+ - scripts
+ summary: Update Script
+ operationId: update_script_api_v1_scripts__script_id__put
+ parameters:
+ - name: script_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Script Id
+ - name: X-User-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-User-Id
+ - name: X-Workspace-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-Workspace-Id
+ - name: X-Request-ID
+ in: header
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: X-Request-Id
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/UpdateScriptRequest'
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ title: Response Update Script Api V1 Scripts Script Id Put
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ delete:
+ tags:
+ - scripts
+ summary: Delete Script
+ operationId: delete_script_api_v1_scripts__script_id__delete
+ parameters:
+ - name: script_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Script Id
+ - name: X-User-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-User-Id
+ - name: X-Workspace-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-Workspace-Id
+ - name: X-Request-ID
+ in: header
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: X-Request-Id
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ title: Response Delete Script Api V1 Scripts Script Id Delete
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ /api/v1/scripts/{script_id}/versions:
+ post:
+ tags:
+ - scripts
+ summary: Publish Version
+ operationId: publish_version_api_v1_scripts__script_id__versions_post
+ parameters:
+ - name: script_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Script Id
+ - name: X-User-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-User-Id
+ - name: X-Workspace-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-Workspace-Id
+ - name: X-Request-ID
+ in: header
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: X-Request-Id
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PublishVersionRequest'
+ responses:
+ '201':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ title: Response Publish Version Api V1 Scripts Script Id Versions Post
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ get:
+ tags:
+ - scripts
+ summary: List Versions
+ operationId: list_versions_api_v1_scripts__script_id__versions_get
+ parameters:
+ - name: script_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Script Id
+ - name: X-User-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-User-Id
+ - name: X-Workspace-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-Workspace-Id
+ - name: X-Request-ID
+ in: header
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: X-Request-Id
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ title: Response List Versions Api V1 Scripts Script Id Versions Get
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ /api/v1/versions/{versions_id}:
+ get:
+ tags:
+ - scripts
+ summary: Get Version
+ operationId: get_version_api_v1_versions__versions_id__get
+ parameters:
+ - name: versions_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Versions Id
+ - name: X-User-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-User-Id
+ - name: X-Workspace-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-Workspace-Id
+ - name: X-Request-ID
+ in: header
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: X-Request-Id
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ title: Response Get Version Api V1 Versions Versions Id Get
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ delete:
+ tags:
+ - scripts
+ summary: Delete Version
+ operationId: delete_version_api_v1_versions__versions_id__delete
+ parameters:
+ - name: versions_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Versions Id
+ - name: X-User-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-User-Id
+ - name: X-Workspace-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-Workspace-Id
+ - name: X-Request-ID
+ in: header
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: X-Request-Id
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ title: Response Delete Version Api V1 Versions Versions Id Delete
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ /api/v1/versions/{versions_id}/download-url:
+ post:
+ tags:
+ - scripts
+ summary: Version Download Url
+ operationId: version_download_url_api_v1_versions__versions_id__download_url_post
+ parameters:
+ - name: versions_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Versions Id
+ - name: X-User-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-User-Id
+ - name: X-Workspace-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-Workspace-Id
+ - name: X-Request-ID
+ in: header
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: X-Request-Id
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/DownloadUrlRequest'
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ title: Response Version Download Url Api V1 Versions Versions Id Download Url Post
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ /api/v1/admin/employees:
+ get:
+ tags:
+ - admin
+ summary: List Employees
+ operationId: list_employees_api_v1_admin_employees_get
+ parameters:
+ - name: X-User-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-User-Id
+ - name: X-Workspace-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-Workspace-Id
+ - name: X-Request-ID
+ in: header
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: X-Request-Id
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ title: Response List Employees Api V1 Admin Employees Get
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ post:
+ tags:
+ - admin
+ summary: Create Employee
+ operationId: create_employee_api_v1_admin_employees_post
+ parameters:
+ - name: X-User-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-User-Id
+ - name: X-Workspace-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-Workspace-Id
+ - name: X-Request-ID
+ in: header
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: X-Request-Id
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/EmployeeCreate'
+ responses:
+ '201':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ title: Response Create Employee Api V1 Admin Employees Post
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ /api/v1/admin/employees/{user_id}:
+ patch:
+ tags:
+ - admin
+ summary: Update Employee
+ operationId: update_employee_api_v1_admin_employees__user_id__patch
+ parameters:
+ - name: user_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: User Id
+ - name: X-User-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-User-Id
+ - name: X-Workspace-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-Workspace-Id
+ - name: X-Request-ID
+ in: header
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: X-Request-Id
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/EmployeeUpdate'
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ title: Response Update Employee Api V1 Admin Employees User Id Patch
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ delete:
+ tags:
+ - admin
+ summary: Delete Employee
+ operationId: delete_employee_api_v1_admin_employees__user_id__delete
+ parameters:
+ - name: user_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: User Id
+ - name: X-User-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-User-Id
+ - name: X-Workspace-ID
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-Workspace-Id
+ - name: X-Request-ID
+ in: header
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: X-Request-Id
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ title: Response Delete Employee Api V1 Admin Employees User Id Delete
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+components:
+ schemas:
+ CompleteResourceUploadRequest:
+ properties:
+ resource_name:
+ type: string
+ maxLength: 255
+ minLength: 1
+ title: Resource Name
+ description:
+ anyOf:
+ - type: string
+ maxLength: 1000
+ - type: 'null'
+ title: Description
+ visibility:
+ type: string
+ enum:
+ - private
+ - workspace
+ - public
+ title: Visibility
+ default: private
+ additionalProperties: false
+ type: object
+ required:
+ - resource_name
+ title: CompleteResourceUploadRequest
+ CreateJupyterAccessTicketRequest:
+ properties:
+ edit_session_id:
+ type: string
+ maxLength: 26
+ minLength: 26
+ title: Edit Session Id
+ lock_token:
+ type: string
+ maxLength: 256
+ minLength: 32
+ title: Lock Token
+ additionalProperties: false
+ type: object
+ required:
+ - edit_session_id
+ - lock_token
+ title: CreateJupyterAccessTicketRequest
+ CreateResourceUploadRequest:
+ properties:
+ file_name:
+ type: string
+ maxLength: 255
+ minLength: 1
+ title: File Name
+ content_type:
+ type: string
+ maxLength: 255
+ minLength: 1
+ title: Content Type
+ expected_size_bytes:
+ type: integer
+ maximum: 104857600.0
+ minimum: 0.0
+ title: Expected Size Bytes
+ expected_hash:
+ anyOf:
+ - type: string
+ maxLength: 64
+ minLength: 64
+ - type: 'null'
+ title: Expected Hash
+ additionalProperties: false
+ type: object
+ required:
+ - file_name
+ - content_type
+ - expected_size_bytes
+ title: CreateResourceUploadRequest
+ CreateScheduleEdgeRequest:
+ properties:
+ workflow_version:
+ type: integer
+ minimum: 1.0
+ title: Workflow Version
+ source_node_id:
+ type: string
+ maxLength: 26
+ minLength: 26
+ title: Source Node Id
+ target_node_id:
+ type: string
+ maxLength: 26
+ minLength: 26
+ title: Target Node Id
+ condition_expr:
+ anyOf:
+ - type: string
+ maxLength: 1000
+ - type: 'null'
+ title: Condition Expr
+ additionalProperties: false
+ type: object
+ required:
+ - workflow_version
+ - source_node_id
+ - target_node_id
+ title: CreateScheduleEdgeRequest
+ CreateScheduleNodeRequest:
+ properties:
+ workflow_version:
+ type: integer
+ minimum: 1.0
+ title: Workflow Version
+ node_key:
+ type: string
+ maxLength: 64
+ minLength: 1
+ pattern: ^[A-Za-z][A-Za-z0-9_-]*$
+ title: Node Key
+ node_name:
+ type: string
+ maxLength: 255
+ minLength: 1
+ title: Node Name
+ versions_id:
+ type: string
+ maxLength: 26
+ minLength: 26
+ title: Versions Id
+ timeout_seconds:
+ type: integer
+ maximum: 86400.0
+ minimum: 1.0
+ title: Timeout Seconds
+ default: 600
+ retry_count:
+ type: integer
+ maximum: 10.0
+ minimum: 0.0
+ title: Retry Count
+ default: 0
+ retry_interval_sec:
+ type: integer
+ maximum: 3600.0
+ minimum: 0.0
+ title: Retry Interval Sec
+ default: 5
+ position_x:
+ type: number
+ maximum: 100000.0
+ minimum: -100000.0
+ title: Position X
+ default: 0
+ position_y:
+ type: number
+ maximum: 100000.0
+ minimum: -100000.0
+ title: Position Y
+ default: 0
+ arguments_json:
+ additionalProperties: true
+ type: object
+ maxProperties: 100
+ title: Arguments Json
+ env_refs_json:
+ additionalProperties:
+ type: string
+ type: object
+ maxProperties: 100
+ title: Env Refs Json
+ additionalProperties: false
+ type: object
+ required:
+ - workflow_version
+ - node_key
+ - node_name
+ - versions_id
+ title: CreateScheduleNodeRequest
+ CreateScheduleRequest:
+ properties:
+ schedule_name:
+ type: string
+ maxLength: 255
+ minLength: 1
+ title: Schedule Name
+ description:
+ anyOf:
+ - type: string
+ maxLength: 1000
+ - type: 'null'
+ title: Description
+ trigger_type:
+ type: string
+ enum:
+ - manual
+ - cron
+ - api
+ title: Trigger Type
+ default: cron
+ cron_expression:
+ anyOf:
+ - type: string
+ maxLength: 128
+ - type: 'null'
+ title: Cron Expression
+ timezone:
+ type: string
+ maxLength: 64
+ minLength: 1
+ title: Timezone
+ default: Asia/Shanghai
+ enabled:
+ type: boolean
+ title: Enabled
+ default: false
+ max_concurrency:
+ type: integer
+ maximum: 64.0
+ minimum: 1.0
+ title: Max Concurrency
+ default: 1
+ failure_policy:
+ type: string
+ enum:
+ - stop
+ - continue
+ title: Failure Policy
+ default: stop
+ additionalProperties: false
+ type: object
+ required:
+ - schedule_name
+ title: CreateScheduleRequest
+ CreateScriptRequest:
+ properties:
+ script_name:
+ type: string
+ maxLength: 255
+ minLength: 1
+ title: Script Name
+ script_type:
+ type: string
+ enum:
+ - python
+ - notebook
+ title: Script Type
+ content:
+ type: string
+ maxLength: 10485760
+ title: Content
+ visibility:
+ type: string
+ enum:
+ - private
+ - workspace
+ - public
+ title: Visibility
+ default: private
+ parent_path:
+ anyOf:
+ - type: string
+ maxLength: 1024
+ - type: 'null'
+ title: Parent Path
+ additionalProperties: false
+ type: object
+ required:
+ - script_name
+ - script_type
+ - content
+ title: CreateScriptRequest
+ CreateWorkspaceDirectoryRequest:
+ properties:
+ directory_name:
+ type: string
+ maxLength: 255
+ minLength: 1
+ title: Directory Name
+ parent_path:
+ type: string
+ maxLength: 1024
+ title: Parent Path
+ default: ''
+ additionalProperties: false
+ type: object
+ required:
+ - directory_name
+ title: CreateWorkspaceDirectoryRequest
+ CronPreviewRequest:
+ properties:
+ cron_expression:
+ type: string
+ maxLength: 128
+ minLength: 1
+ title: Cron Expression
+ timezone:
+ type: string
+ maxLength: 64
+ minLength: 1
+ title: Timezone
+ default: Asia/Shanghai
+ count:
+ type: integer
+ maximum: 20.0
+ minimum: 1.0
+ title: Count
+ default: 5
+ base_time:
+ anyOf:
+ - type: string
+ format: date-time
+ - type: 'null'
+ title: Base Time
+ additionalProperties: false
+ type: object
+ required:
+ - cron_expression
+ title: CronPreviewRequest
+ DownloadUrlRequest:
+ properties:
+ expires_seconds:
+ type: integer
+ maximum: 3600.0
+ minimum: 30.0
+ title: Expires Seconds
+ default: 300
+ additionalProperties: false
+ type: object
+ title: DownloadUrlRequest
+ EmployeeCreate:
+ properties:
+ username:
+ type: string
+ maxLength: 64
+ minLength: 2
+ title: Username
+ display_name:
+ type: string
+ maxLength: 100
+ minLength: 1
+ title: Display Name
+ email:
+ anyOf:
+ - type: string
+ maxLength: 255
+ - type: 'null'
+ title: Email
+ role_code:
+ type: string
+ enum:
+ - admin
+ - developer
+ title: Role Code
+ default: developer
+ additionalProperties: false
+ type: object
+ required:
+ - username
+ - display_name
+ title: EmployeeCreate
+ EmployeeUpdate:
+ properties:
+ display_name:
+ anyOf:
+ - type: string
+ maxLength: 100
+ minLength: 1
+ - type: 'null'
+ title: Display Name
+ email:
+ anyOf:
+ - type: string
+ maxLength: 255
+ - type: 'null'
+ title: Email
+ role_code:
+ anyOf:
+ - type: string
+ enum:
+ - admin
+ - developer
+ - type: 'null'
+ title: Role Code
+ status:
+ anyOf:
+ - type: string
+ enum:
+ - active
+ - disabled
+ - locked
+ - type: 'null'
+ title: Status
+ additionalProperties: false
+ type: object
+ title: EmployeeUpdate
+ FileLockTokenRequest:
+ properties:
+ lock_token:
+ type: string
+ maxLength: 256
+ minLength: 32
+ title: Lock Token
+ additionalProperties: false
+ type: object
+ required:
+ - lock_token
+ title: FileLockTokenRequest
+ HTTPValidationError:
+ properties:
+ detail:
+ items:
+ $ref: '#/components/schemas/ValidationError'
+ type: array
+ title: Detail
+ type: object
+ title: HTTPValidationError
+ PublishVersionRequest:
+ properties:
+ source_object_id:
+ anyOf:
+ - type: string
+ maxLength: 26
+ minLength: 26
+ - type: 'null'
+ title: Source Object Id
+ release_note:
+ anyOf:
+ - type: string
+ maxLength: 1000
+ - type: 'null'
+ title: Release Note
+ visibility:
+ type: string
+ enum:
+ - private
+ - workspace
+ - public
+ title: Visibility
+ default: workspace
+ additionalProperties: false
+ type: object
+ title: PublishVersionRequest
+ RunScheduleRequest:
+ properties:
+ reason:
+ type: string
+ maxLength: 255
+ minLength: 1
+ title: Reason
+ default: manual_run
+ additionalProperties: false
+ type: object
+ title: RunScheduleRequest
+ UpdateScheduleEdgeRequest:
+ properties:
+ workflow_version:
+ type: integer
+ minimum: 1.0
+ title: Workflow Version
+ condition_expr:
+ anyOf:
+ - type: string
+ maxLength: 1000
+ - type: 'null'
+ title: Condition Expr
+ additionalProperties: false
+ type: object
+ required:
+ - workflow_version
+ title: UpdateScheduleEdgeRequest
+ UpdateScheduleNodeRequest:
+ properties:
+ workflow_version:
+ type: integer
+ minimum: 1.0
+ title: Workflow Version
+ node_name:
+ anyOf:
+ - type: string
+ maxLength: 255
+ minLength: 1
+ - type: 'null'
+ title: Node Name
+ versions_id:
+ anyOf:
+ - type: string
+ maxLength: 26
+ minLength: 26
+ - type: 'null'
+ title: Versions Id
+ timeout_seconds:
+ anyOf:
+ - type: integer
+ maximum: 86400.0
+ minimum: 1.0
+ - type: 'null'
+ title: Timeout Seconds
+ retry_count:
+ anyOf:
+ - type: integer
+ maximum: 10.0
+ minimum: 0.0
+ - type: 'null'
+ title: Retry Count
+ retry_interval_sec:
+ anyOf:
+ - type: integer
+ maximum: 3600.0
+ minimum: 0.0
+ - type: 'null'
+ title: Retry Interval Sec
+ position_x:
+ anyOf:
+ - type: number
+ maximum: 100000.0
+ minimum: -100000.0
+ - type: 'null'
+ title: Position X
+ position_y:
+ anyOf:
+ - type: number
+ maximum: 100000.0
+ minimum: -100000.0
+ - type: 'null'
+ title: Position Y
+ arguments_json:
+ anyOf:
+ - additionalProperties: true
+ type: object
+ maxProperties: 100
+ - type: 'null'
+ title: Arguments Json
+ env_refs_json:
+ anyOf:
+ - additionalProperties:
+ type: string
+ type: object
+ maxProperties: 100
+ - type: 'null'
+ title: Env Refs Json
+ additionalProperties: false
+ type: object
+ required:
+ - workflow_version
+ title: UpdateScheduleNodeRequest
+ UpdateScheduleRequest:
+ properties:
+ workflow_version:
+ type: integer
+ minimum: 1.0
+ title: Workflow Version
+ schedule_name:
+ anyOf:
+ - type: string
+ maxLength: 255
+ minLength: 1
+ - type: 'null'
+ title: Schedule Name
+ description:
+ anyOf:
+ - type: string
+ maxLength: 1000
+ - type: 'null'
+ title: Description
+ trigger_type:
+ anyOf:
+ - type: string
+ enum:
+ - manual
+ - cron
+ - api
+ - type: 'null'
+ title: Trigger Type
+ cron_expression:
+ anyOf:
+ - type: string
+ maxLength: 128
+ - type: 'null'
+ title: Cron Expression
+ timezone:
+ anyOf:
+ - type: string
+ maxLength: 64
+ minLength: 1
+ - type: 'null'
+ title: Timezone
+ enabled:
+ anyOf:
+ - type: boolean
+ - type: 'null'
+ title: Enabled
+ max_concurrency:
+ anyOf:
+ - type: integer
+ maximum: 64.0
+ minimum: 1.0
+ - type: 'null'
+ title: Max Concurrency
+ failure_policy:
+ anyOf:
+ - type: string
+ enum:
+ - stop
+ - continue
+ - type: 'null'
+ title: Failure Policy
+ additionalProperties: false
+ type: object
+ required:
+ - workflow_version
+ title: UpdateScheduleRequest
+ UpdateScriptRequest:
+ properties:
+ content:
+ type: string
+ maxLength: 10485760
+ title: Content
+ additionalProperties: false
+ type: object
+ required:
+ - content
+ title: UpdateScriptRequest
+ ValidationError:
+ properties:
+ loc:
+ items:
+ anyOf:
+ - type: string
+ - type: integer
+ type: array
+ title: Location
+ msg:
+ type: string
+ title: Message
+ type:
+ type: string
+ title: Error Type
+ type: object
+ required:
+ - loc
+ - msg
+ - type
+ title: ValidationError
+ WorkflowVersionRequest:
+ properties:
+ workflow_version:
+ type: integer
+ minimum: 1.0
+ title: Workflow Version
+ additionalProperties: false
+ type: object
+ required:
+ - workflow_version
+ title: WorkflowVersionRequest
diff --git a/contracts/openapi/runtime-api-internal-v1.yaml b/contracts/openapi/runtime-api-internal-v1.yaml
new file mode 100644
index 0000000..85b7727
--- /dev/null
+++ b/contracts/openapi/runtime-api-internal-v1.yaml
@@ -0,0 +1,699 @@
+openapi: 3.1.0
+info:
+ title: runtime-manager service
+ version: 0.1.0
+paths:
+ /:
+ get:
+ summary: Root
+ operationId: root__get
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ additionalProperties:
+ type: string
+ type: object
+ title: Response Root Get
+ /health/live:
+ get:
+ summary: Live
+ operationId: live_health_live_get
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ additionalProperties:
+ type: string
+ type: object
+ title: Response Live Health Live Get
+ /api/v1/health:
+ get:
+ summary: Public Health
+ operationId: public_health_api_v1_health_get
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ additionalProperties:
+ type: string
+ type: object
+ title: Response Public Health Api V1 Health Get
+ /health/ready:
+ get:
+ summary: Ready
+ operationId: ready_health_ready_get
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ additionalProperties: true
+ type: object
+ title: Response Ready Health Ready Get
+ /internal/v1/file-locks/acquire:
+ post:
+ summary: Acquire File Lock
+ operationId: acquire_file_lock_internal_v1_file_locks_acquire_post
+ parameters:
+ - name: X-Service-Token
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-Service-Token
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/AcquireFileLockRequest'
+ responses:
+ '201':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ title: Response Acquire File Lock Internal V1 File Locks Acquire Post
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ /internal/v1/file-locks/{edit_session_id}/heartbeat:
+ post:
+ summary: Heartbeat File Lock
+ operationId: heartbeat_file_lock_internal_v1_file_locks__edit_session_id__heartbeat_post
+ parameters:
+ - name: edit_session_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Edit Session Id
+ - name: X-Service-Token
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-Service-Token
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/FileLockTokenRequest'
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ title: Response Heartbeat File Lock Internal V1 File Locks Edit Session Id Heartbeat
+ Post
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ /internal/v1/file-locks/{edit_session_id}:
+ delete:
+ summary: Release File Lock
+ operationId: release_file_lock_internal_v1_file_locks__edit_session_id__delete
+ parameters:
+ - name: edit_session_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Edit Session Id
+ - name: X-Service-Token
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-Service-Token
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/FileLockTokenRequest'
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ title: Response Release File Lock Internal V1 File Locks Edit Session Id Delete
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ /internal/v1/jupyter/access-tickets/{edit_session_id}:
+ post:
+ summary: Create Jupyter Access Ticket
+ operationId: create_jupyter_access_ticket_internal_v1_jupyter_access_tickets__edit_session_id__post
+ parameters:
+ - name: edit_session_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Edit Session Id
+ - name: X-Service-Token
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-Service-Token
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/FileLockTokenRequest'
+ responses:
+ '201':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ title: Response Create Jupyter Access Ticket Internal V1 Jupyter Access Tickets Edit
+ Session Id Post
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ /internal/v1/jupyter/authorize:
+ get:
+ summary: Authorize Jupyter Proxy
+ operationId: authorize_jupyter_proxy_internal_v1_jupyter_authorize_get
+ parameters:
+ - name: X-Original-URI
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-Original-Uri
+ - name: X-Service-Token
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-Service-Token
+ - name: jupyter_access
+ in: cookie
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: Jupyter Access
+ responses:
+ '204':
+ description: Successful Response
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ /internal/v1/runtimes/ensure:
+ post:
+ summary: Ensure Runtime
+ operationId: ensure_runtime_internal_v1_runtimes_ensure_post
+ parameters:
+ - name: X-Service-Token
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-Service-Token
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/EnsureRuntimeApiRequest'
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ title: Response Ensure Runtime Internal V1 Runtimes Ensure Post
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ /internal/v1/runtimes/{runtime_id}:
+ get:
+ summary: Get Runtime
+ operationId: get_runtime_internal_v1_runtimes__runtime_id__get
+ parameters:
+ - name: runtime_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Runtime Id
+ - name: workspace_id
+ in: query
+ required: true
+ schema:
+ type: string
+ minLength: 26
+ maxLength: 26
+ title: Workspace Id
+ - name: user_id
+ in: query
+ required: true
+ schema:
+ type: string
+ minLength: 26
+ maxLength: 26
+ title: User Id
+ - name: X-Service-Token
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-Service-Token
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ title: Response Get Runtime Internal V1 Runtimes Runtime Id Get
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ delete:
+ summary: Stop Runtime
+ operationId: stop_runtime_internal_v1_runtimes__runtime_id__delete
+ parameters:
+ - name: runtime_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Runtime Id
+ - name: X-Service-Token
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-Service-Token
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/StopRuntimeApiRequest'
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ title: Response Stop Runtime Internal V1 Runtimes Runtime Id Delete
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ /internal/v1/runtimes/{runtime_id}/restart:
+ post:
+ summary: Restart Runtime
+ operationId: restart_runtime_internal_v1_runtimes__runtime_id__restart_post
+ parameters:
+ - name: runtime_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Runtime Id
+ - name: X-Service-Token
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-Service-Token
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/RuntimeIdentityRequest'
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ title: Response Restart Runtime Internal V1 Runtimes Runtime Id Restart Post
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ /internal/v1/runtimes/{runtime_id}/sessions:
+ post:
+ summary: Create Runtime Session
+ operationId: create_runtime_session_internal_v1_runtimes__runtime_id__sessions_post
+ parameters:
+ - name: runtime_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Runtime Id
+ - name: X-Service-Token
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-Service-Token
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/CreateRuntimeSessionApiRequest'
+ responses:
+ '201':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ title: Response Create Runtime Session Internal V1 Runtimes Runtime Id Sessions Post
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ /internal/v1/runtimes/{runtime_id}/sessions/{session_id}:
+ delete:
+ summary: Terminate Runtime Session
+ operationId: terminate_runtime_session_internal_v1_runtimes__runtime_id__sessions__session_id__delete
+ parameters:
+ - name: runtime_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Runtime Id
+ - name: session_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Session Id
+ - name: X-Service-Token
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-Service-Token
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/RuntimeIdentityRequest'
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ title: Response Terminate Runtime Session Internal V1 Runtimes Runtime Id Sessions Session
+ Id Delete
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ /internal/health/runtime:
+ get:
+ summary: Internal Health
+ operationId: internal_health_internal_health_runtime_get
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ additionalProperties:
+ type: string
+ type: object
+ title: Response Internal Health Internal Health Runtime Get
+components:
+ schemas:
+ AcquireFileLockRequest:
+ properties:
+ workspace_id:
+ type: string
+ maxLength: 26
+ minLength: 26
+ title: Workspace Id
+ storage_object_id:
+ type: string
+ maxLength: 26
+ minLength: 26
+ title: Storage Object Id
+ user_id:
+ type: string
+ maxLength: 26
+ minLength: 26
+ title: User Id
+ request_id:
+ anyOf:
+ - type: string
+ maxLength: 64
+ minLength: 1
+ - type: 'null'
+ title: Request Id
+ additionalProperties: false
+ type: object
+ required:
+ - workspace_id
+ - storage_object_id
+ - user_id
+ title: AcquireFileLockRequest
+ CreateRuntimeSessionApiRequest:
+ properties:
+ workspace_id:
+ type: string
+ maxLength: 26
+ minLength: 26
+ title: Workspace Id
+ user_id:
+ type: string
+ maxLength: 26
+ minLength: 26
+ title: User Id
+ request_id:
+ anyOf:
+ - type: string
+ maxLength: 64
+ minLength: 1
+ - type: 'null'
+ title: Request Id
+ storage_object_id:
+ type: string
+ maxLength: 26
+ minLength: 26
+ title: Storage Object Id
+ relative_path:
+ type: string
+ maxLength: 1024
+ minLength: 1
+ title: Relative Path
+ additionalProperties: false
+ type: object
+ required:
+ - workspace_id
+ - user_id
+ - storage_object_id
+ - relative_path
+ title: CreateRuntimeSessionApiRequest
+ EnsureRuntimeApiRequest:
+ properties:
+ workspace_id:
+ type: string
+ maxLength: 26
+ minLength: 26
+ title: Workspace Id
+ user_id:
+ type: string
+ maxLength: 26
+ minLength: 26
+ title: User Id
+ request_id:
+ anyOf:
+ - type: string
+ maxLength: 64
+ minLength: 1
+ - type: 'null'
+ title: Request Id
+ additionalProperties: false
+ type: object
+ required:
+ - workspace_id
+ - user_id
+ title: EnsureRuntimeApiRequest
+ FileLockTokenRequest:
+ properties:
+ workspace_id:
+ type: string
+ maxLength: 26
+ minLength: 26
+ title: Workspace Id
+ user_id:
+ type: string
+ maxLength: 26
+ minLength: 26
+ title: User Id
+ lock_token:
+ type: string
+ maxLength: 256
+ minLength: 32
+ title: Lock Token
+ additionalProperties: false
+ type: object
+ required:
+ - workspace_id
+ - user_id
+ - lock_token
+ title: FileLockTokenRequest
+ HTTPValidationError:
+ properties:
+ detail:
+ items:
+ $ref: '#/components/schemas/ValidationError'
+ type: array
+ title: Detail
+ type: object
+ title: HTTPValidationError
+ RuntimeIdentityRequest:
+ properties:
+ workspace_id:
+ type: string
+ maxLength: 26
+ minLength: 26
+ title: Workspace Id
+ user_id:
+ type: string
+ maxLength: 26
+ minLength: 26
+ title: User Id
+ request_id:
+ anyOf:
+ - type: string
+ maxLength: 64
+ minLength: 1
+ - type: 'null'
+ title: Request Id
+ additionalProperties: false
+ type: object
+ required:
+ - workspace_id
+ - user_id
+ title: RuntimeIdentityRequest
+ StopRuntimeApiRequest:
+ properties:
+ workspace_id:
+ type: string
+ maxLength: 26
+ minLength: 26
+ title: Workspace Id
+ user_id:
+ type: string
+ maxLength: 26
+ minLength: 26
+ title: User Id
+ request_id:
+ anyOf:
+ - type: string
+ maxLength: 64
+ minLength: 1
+ - type: 'null'
+ title: Request Id
+ reason:
+ type: string
+ maxLength: 64
+ minLength: 1
+ title: Reason
+ default: client_request
+ additionalProperties: false
+ type: object
+ required:
+ - workspace_id
+ - user_id
+ title: StopRuntimeApiRequest
+ ValidationError:
+ properties:
+ loc:
+ items:
+ anyOf:
+ - type: string
+ - type: integer
+ type: array
+ title: Location
+ msg:
+ type: string
+ title: Message
+ type:
+ type: string
+ title: Error Type
+ type: object
+ required:
+ - loc
+ - msg
+ - type
+ title: ValidationError
diff --git a/contracts/openapi/storage-api-internal-v1.yaml b/contracts/openapi/storage-api-internal-v1.yaml
new file mode 100644
index 0000000..17c3f0f
--- /dev/null
+++ b/contracts/openapi/storage-api-internal-v1.yaml
@@ -0,0 +1,550 @@
+openapi: 3.1.0
+info:
+ title: storage-api service
+ version: 0.1.0
+paths:
+ /:
+ get:
+ summary: Root
+ operationId: root__get
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ additionalProperties:
+ type: string
+ type: object
+ title: Response Root Get
+ /health/live:
+ get:
+ summary: Live
+ operationId: live_health_live_get
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ additionalProperties:
+ type: string
+ type: object
+ title: Response Live Health Live Get
+ /api/v1/health:
+ get:
+ summary: Public Health
+ operationId: public_health_api_v1_health_get
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ additionalProperties:
+ type: string
+ type: object
+ title: Response Public Health Api V1 Health Get
+ /health/ready:
+ get:
+ summary: Ready
+ operationId: ready_health_ready_get
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ additionalProperties: true
+ type: object
+ title: Response Ready Health Ready Get
+ /internal/v1/uploads:
+ post:
+ summary: Create Upload
+ operationId: create_upload_internal_v1_uploads_post
+ parameters:
+ - name: X-Service-Token
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-Service-Token
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/CreateUploadRequest'
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ title: Response Create Upload Internal V1 Uploads Post
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ /internal/v1/uploads/{upload_id}/complete:
+ post:
+ summary: Complete Upload
+ operationId: complete_upload_internal_v1_uploads__upload_id__complete_post
+ parameters:
+ - name: upload_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Upload Id
+ - name: X-Service-Token
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-Service-Token
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/CompleteUploadRequest'
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ title: Response Complete Upload Internal V1 Uploads Upload Id Complete Post
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ /internal/v1/uploads/{upload_id}/abort:
+ post:
+ summary: Abort Upload
+ operationId: abort_upload_internal_v1_uploads__upload_id__abort_post
+ parameters:
+ - name: upload_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Upload Id
+ - name: X-Service-Token
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-Service-Token
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ title: Response Abort Upload Internal V1 Uploads Upload Id Abort Post
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ /internal/v1/objects:
+ post:
+ summary: Create Server Object
+ operationId: create_server_object_internal_v1_objects_post
+ parameters:
+ - name: X-Service-Token
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-Service-Token
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ServerObjectRequest'
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ title: Response Create Server Object Internal V1 Objects Post
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ /internal/v1/workspace-objects:
+ post:
+ summary: Register Workspace Object
+ operationId: register_workspace_object_internal_v1_workspace_objects_post
+ parameters:
+ - name: X-Service-Token
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-Service-Token
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/RegisterWorkspaceObjectRequest'
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ title: Response Register Workspace Object Internal V1 Workspace Objects Post
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ /internal/v1/objects/{storage_object_id}/download-url:
+ post:
+ summary: Create Download Url
+ operationId: create_download_url_internal_v1_objects__storage_object_id__download_url_post
+ parameters:
+ - name: storage_object_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Storage Object Id
+ - name: X-Service-Token
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-Service-Token
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/DownloadUrlRequest'
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ title: Response Create Download Url Internal V1 Objects Storage Object Id Download Url
+ Post
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ /internal/v1/objects/{storage_object_id}:
+ delete:
+ summary: Delete Object
+ operationId: delete_object_internal_v1_objects__storage_object_id__delete
+ parameters:
+ - name: storage_object_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Storage Object Id
+ - name: X-Service-Token
+ in: header
+ required: true
+ schema:
+ type: string
+ title: X-Service-Token
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: true
+ title: Response Delete Object Internal V1 Objects Storage Object Id Delete
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ /internal/health/storage:
+ get:
+ summary: Internal Health
+ operationId: internal_health_internal_health_storage_get
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ additionalProperties:
+ type: string
+ type: object
+ title: Response Internal Health Internal Health Storage Get
+components:
+ schemas:
+ CompleteUploadRequest:
+ properties:
+ usage_type:
+ type: string
+ enum:
+ - data_resource
+ - version_artifact
+ - snapshot
+ - run_log
+ - run_result
+ title: Usage Type
+ visibility:
+ type: string
+ enum:
+ - private
+ - workspace
+ - public
+ title: Visibility
+ default: private
+ is_immutable:
+ type: boolean
+ title: Is Immutable
+ default: false
+ additionalProperties: false
+ type: object
+ required:
+ - usage_type
+ title: CompleteUploadRequest
+ CreateUploadRequest:
+ properties:
+ workspace_id:
+ type: string
+ maxLength: 26
+ minLength: 26
+ title: Workspace Id
+ user_id:
+ type: string
+ maxLength: 26
+ minLength: 26
+ title: User Id
+ usage_type:
+ type: string
+ enum:
+ - data_resource
+ - version_artifact
+ - snapshot
+ - run_log
+ - run_result
+ title: Usage Type
+ file_name:
+ type: string
+ maxLength: 255
+ minLength: 1
+ title: File Name
+ content_type:
+ type: string
+ maxLength: 255
+ minLength: 1
+ title: Content Type
+ expected_size_bytes:
+ type: integer
+ maximum: 104857600.0
+ minimum: 0.0
+ title: Expected Size Bytes
+ expected_hash:
+ anyOf:
+ - type: string
+ maxLength: 64
+ minLength: 64
+ - type: 'null'
+ title: Expected Hash
+ idempotency_key:
+ type: string
+ maxLength: 128
+ minLength: 8
+ title: Idempotency Key
+ url_scope:
+ type: string
+ enum:
+ - public
+ - internal
+ title: Url Scope
+ default: public
+ additionalProperties: false
+ type: object
+ required:
+ - workspace_id
+ - user_id
+ - usage_type
+ - file_name
+ - content_type
+ - expected_size_bytes
+ - idempotency_key
+ title: CreateUploadRequest
+ DownloadUrlRequest:
+ properties:
+ expires_seconds:
+ type: integer
+ maximum: 3600.0
+ minimum: 30.0
+ title: Expires Seconds
+ default: 300
+ additionalProperties: false
+ type: object
+ title: DownloadUrlRequest
+ HTTPValidationError:
+ properties:
+ detail:
+ items:
+ $ref: '#/components/schemas/ValidationError'
+ type: array
+ title: Detail
+ type: object
+ title: HTTPValidationError
+ RegisterWorkspaceObjectRequest:
+ properties:
+ workspace_id:
+ type: string
+ maxLength: 26
+ minLength: 26
+ title: Workspace Id
+ user_id:
+ type: string
+ maxLength: 26
+ minLength: 26
+ title: User Id
+ relative_path:
+ type: string
+ maxLength: 1024
+ minLength: 1
+ title: Relative Path
+ usage_type:
+ type: string
+ enum:
+ - working_copy
+ - public_script
+ title: Usage Type
+ visibility:
+ type: string
+ enum:
+ - private
+ - workspace
+ - public
+ title: Visibility
+ default: private
+ additionalProperties: false
+ type: object
+ required:
+ - workspace_id
+ - user_id
+ - relative_path
+ - usage_type
+ title: RegisterWorkspaceObjectRequest
+ ServerObjectRequest:
+ properties:
+ workspace_id:
+ type: string
+ maxLength: 26
+ minLength: 26
+ title: Workspace Id
+ user_id:
+ type: string
+ maxLength: 26
+ minLength: 26
+ title: User Id
+ usage_type:
+ type: string
+ enum:
+ - data_resource
+ - version_artifact
+ - snapshot
+ - run_log
+ - run_result
+ title: Usage Type
+ file_name:
+ type: string
+ maxLength: 255
+ minLength: 1
+ title: File Name
+ content_type:
+ type: string
+ maxLength: 255
+ minLength: 1
+ title: Content Type
+ content_base64:
+ type: string
+ minLength: 1
+ title: Content Base64
+ visibility:
+ type: string
+ enum:
+ - private
+ - workspace
+ - public
+ title: Visibility
+ default: private
+ is_immutable:
+ type: boolean
+ title: Is Immutable
+ default: false
+ idempotency_key:
+ type: string
+ maxLength: 128
+ minLength: 8
+ title: Idempotency Key
+ additionalProperties: false
+ type: object
+ required:
+ - workspace_id
+ - user_id
+ - usage_type
+ - file_name
+ - content_type
+ - content_base64
+ - idempotency_key
+ title: ServerObjectRequest
+ ValidationError:
+ properties:
+ loc:
+ items:
+ anyOf:
+ - type: string
+ - type: integer
+ type: array
+ title: Location
+ msg:
+ type: string
+ title: Message
+ type:
+ type: string
+ title: Error Type
+ type: object
+ required:
+ - loc
+ - msg
+ - type
+ title: ValidationError
diff --git a/contracts/runtime/README.md b/contracts/runtime/README.md
new file mode 100644
index 0000000..4670133
--- /dev/null
+++ b/contracts/runtime/README.md
@@ -0,0 +1,26 @@
+# Runtime Adapter 契约
+
+`runtime_adapter.py` 是 Runtime Manager 与具体运行环境之间的可执行契约。
+
+当前 Compose 环境使用 `SharedJupyterAdapter`。它把共享 Jupyter Server 作为
+首期 Provider,并在 MySQL 中为每个 `Workspace` 建立并复用一个逻辑
+Runtime。每个打开的 Notebook 在该 Runtime 中建立独立 Jupyter Session,
+每个 Session 绑定自己的 Kernel。
+后续 Docker/Kubernetes Provider 必须实现同一 Protocol,公共锁接口和
+`runtime_instances` 状态模型保持不变。
+
+核心约束:
+
+- `ensure_running` 对同一作用域必须幂等;
+- Runtime 的唯一复用作用域是 Workspace,不能按用户重复创建;
+- Runtime 的期望状态、实际状态和租约写入 MySQL;
+- Notebook 对应独立 Jupyter Session/Kernel,其创建和终止只能由 Runtime
+ Manager 调用;
+- Provider 不能依赖 Platform API 的进程内状态;
+- `proxy_base_path` 与 Jupyter 内部 `base_url` 必须一致。
+- Provider 调用必须携带内部服务凭据,不能通过关闭 XSRF 检查规避认证;
+- Workspace 的目录、文件权限必须同时满足 Platform 原子写入和 Jupyter
+ 读写,当前 Compose 环境使用共享 Unix 组完成。
+
+Jupyter 浏览器代理、访问票据和 WebSocket 约束见
+`jupyter-proxy-v1.md`。
diff --git a/contracts/runtime/__init__.py b/contracts/runtime/__init__.py
new file mode 100644
index 0000000..22ae10c
--- /dev/null
+++ b/contracts/runtime/__init__.py
@@ -0,0 +1 @@
+"""Runtime adapter contract package."""
diff --git a/contracts/runtime/jupyter-proxy-v1.md b/contracts/runtime/jupyter-proxy-v1.md
new file mode 100644
index 0000000..bb58546
--- /dev/null
+++ b/contracts/runtime/jupyter-proxy-v1.md
@@ -0,0 +1,65 @@
+# Jupyter 代理与访问票据契约 V1
+
+## 1. 浏览器访问流程
+
+1. 前端携带用户、Workspace、`edit_session_id` 和 `lock_token` 调用
+ `POST /api/v1/jupyter/access-tickets`。
+2. Platform API 校验编辑锁、Runtime 与 Jupyter Session 的归属及状态。
+3. 成功后返回 `/jupyter/...` 地址,并设置短期
+ `jupyter_access` Cookie。
+4. 前端在当前平台页面的编辑区内嵌同源 `/jupyter/...` 页面,不新开浏览器
+ 标签页。
+5. 内嵌页面经 Nginx 访问 Jupyter HTTP/WebSocket;Nginx 先执行内部鉴权
+ 子请求,再把内部 Token 注入上游请求。
+
+一个 Workspace 只建立或复用一个 Jupyter Runtime/Server;同一 Server
+中的每个 Notebook 使用独立 Jupyter Session 和独立 Kernel。
+
+浏览器响应、URL、JavaScript 和日志中都不得出现 Jupyter 内部 Token。
+
+## 2. 访问票据
+
+- 票据绑定:`user_id`、`workspace_id`、`edit_session_id`、
+ `runtime_id`、`jupyter_session_id`。
+- 默认有效期:60 秒;最长不超过 5 分钟。
+- Cookie:`HttpOnly; SameSite=Lax; Path=/jupyter/`,生产环境必须增加
+ `Secure`。
+- 票据只授权当前 Workspace 的 Jupyter 路径,不能跨 Workspace 使用。
+- Jupyter 页面必须与平台使用同一站点入口,并允许同源 iframe 嵌入。
+
+## 3. Nginx 内部鉴权接口
+
+```http
+GET /internal/v1/jupyter/authorize
+Cookie: jupyter_access=
+X-Original-URI: /jupyter/...
+X-Request-ID:
+```
+
+该接口只允许 Nginx 在内部网络调用:
+
+- `204`:票据有效,响应头提供上游地址和内部认证信息。
+- `401`:票据缺失、伪造或过期。
+- `403`:用户、Workspace、Session 不匹配,或编辑锁/Runtime 已失效。
+
+成功响应头:
+
+```http
+X-Jupyter-Upstream: http://jupyter:8888
+X-Jupyter-Authorization: token
+X-Workspace-ID:
+```
+
+Nginx 必须删除客户端传入的 `Authorization`,使用内部鉴权结果重建上游
+认证头。
+
+## 4. WebSocket 约束
+
+- `/jupyter/api/kernels/*/channels` 与其他 WebSocket 路径使用 HTTP/1.1。
+- 转发 `Upgrade`、`Connection`、`Host`、`Origin` 和请求 ID。
+- 关闭代理缓冲,读超时不小于 3600 秒。
+- HTTP 和 WebSocket 使用同一票据校验规则。
+- 连接关闭只断开交互连接,不自动停止 Workspace Runtime。
+- Compose Demo 使用最长 5 分钟票据;前端必须继续发送编辑锁心跳,并在
+ 票据到期前重新签发。
+- Jupyter Lab HTML 中出现的内部 Token 必须由 Nginx 在返回浏览器前清除。
diff --git a/contracts/runtime/runtime_adapter.py b/contracts/runtime/runtime_adapter.py
new file mode 100644
index 0000000..478dae0
--- /dev/null
+++ b/contracts/runtime/runtime_adapter.py
@@ -0,0 +1,70 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from datetime import datetime
+from typing import Protocol
+
+
+@dataclass(frozen=True)
+class EnsureRuntimeRequest:
+ runtime_id: str
+ workspace_id: str
+ workspace_code: str
+ owner_user_id: str
+
+
+@dataclass(frozen=True)
+class RuntimeEndpoint:
+ runtime_id: str
+ runtime_type: str
+ provider: str
+ runtime_ref: str
+ internal_url: str
+ proxy_base_path: str
+
+
+@dataclass(frozen=True)
+class RuntimeHealth:
+ runtime_id: str
+ healthy: bool
+ checked_at: datetime
+ detail: str | None = None
+
+
+@dataclass(frozen=True)
+class CreateSessionRequest:
+ runtime_id: str
+ workspace_code: str
+ relative_path: str
+
+
+@dataclass(frozen=True)
+class RuntimeSession:
+ runtime_id: str
+ session_id: str
+ jupyter_url: str
+ reused: bool
+
+
+class RuntimeAdapter(Protocol):
+ async def ensure_running(
+ self,
+ request: EnsureRuntimeRequest,
+ ) -> RuntimeEndpoint: ...
+
+ async def stop(self, runtime_id: str, reason: str) -> None: ...
+
+ async def restart(self, runtime_id: str) -> RuntimeEndpoint: ...
+
+ async def health(self, runtime_id: str) -> RuntimeHealth: ...
+
+ async def create_session(
+ self,
+ request: CreateSessionRequest,
+ ) -> RuntimeSession: ...
+
+ async def terminate_session(
+ self,
+ runtime_id: str,
+ session_id: str,
+ ) -> None: ...
diff --git a/contracts/schedules/schedule-definition-api-v1.md b/contracts/schedules/schedule-definition-api-v1.md
new file mode 100644
index 0000000..4393816
--- /dev/null
+++ b/contracts/schedules/schedule-definition-api-v1.md
@@ -0,0 +1,122 @@
+# 调度定义 API 契约 V1
+
+冻结日期:2026-07-28
+状态:`implemented`
+
+## 1. 范围
+
+本契约只覆盖调度定义,不触发任务执行:
+
+- 调度方案增删改查;
+- 稳定版本制品列表;
+- 节点和连线增删改;
+- 五段 Cron 校验与未来时间预览;
+- DAG 完整性和有向无环校验。
+
+立即运行、Cron 自动触发和 Executor 执行已经由独立 Schedule 服务承接。
+
+## 2. 请求上下文
+
+所有接口都要求:
+
+| Header | 含义 |
+|---|---|
+| `X-User-ID` | 当前用户 |
+| `X-Workspace-ID` | 当前 Workspace |
+| `X-Request-ID` | 请求追踪 ID;可省略,由服务端生成 |
+
+服务端只返回当前 Workspace 的调度。节点引用的 `versions_id` 必须属于当前
+Workspace,且当前用户有权读取。
+
+## 3. HTTP 接口
+
+| 方法 | 路径 | 作用 |
+|---|---|---|
+| `GET` | `/api/v1/schedule-artifacts` | 查询可加入调度的稳定版本 |
+| `POST` | `/api/v1/cron/preview` | 校验 Cron 并预览未来时间 |
+| `GET` | `/api/v1/schedules` | 查询调度列表 |
+| `POST` | `/api/v1/schedules` | 新建调度 |
+| `GET` | `/api/v1/schedules/{schedule_id}` | 查询调度及完整 DAG |
+| `PUT/PATCH` | `/api/v1/schedules/{schedule_id}` | 修改调度基本信息 |
+| `DELETE` | `/api/v1/schedules/{schedule_id}` | 软删除调度 |
+| `POST` | `/api/v1/schedules/{schedule_id}/nodes` | 新建节点 |
+| `PUT` | `/api/v1/schedules/{schedule_id}/nodes/{node_id}` | 修改节点 |
+| `DELETE` | `/api/v1/schedules/{schedule_id}/nodes/{node_id}` | 删除节点及关联连线 |
+| `POST` | `/api/v1/schedules/{schedule_id}/edges` | 新建有向连线 |
+| `PUT` | `/api/v1/schedules/{schedule_id}/edges/{edge_id}` | 修改连线条件 |
+| `DELETE` | `/api/v1/schedules/{schedule_id}/edges/{edge_id}` | 删除连线 |
+| `POST` | `/api/v1/schedules/{schedule_id}/validate` | 校验当前 DAG |
+
+成功响应统一为:
+
+```json
+{
+ "request_id": "01...",
+ "data": {},
+ "meta": {}
+}
+```
+
+## 4. 并发修改契约
+
+新建调度时 `workflow_version=1`。每次修改调度、节点或连线都必须在请求体中
+提交当前 `workflow_version`,成功后版本号加一。
+
+版本不一致时返回 `412 Precondition Failed`:
+
+```json
+{
+ "detail": {
+ "code": "WORKFLOW_VERSION_CONFLICT",
+ "message": "schedule was modified by another request",
+ "expected": 3,
+ "current": 4
+ }
+}
+```
+
+前端收到 `412` 后必须重新读取调度,不得用旧画布直接覆盖。
+
+## 5. 调度与 Cron 约束
+
+- `trigger_type`:`manual / cron / api`;
+- `failure_policy`:`stop / continue`;
+- Cron 固定为五段:`minute hour day month weekday`;
+- 时区使用 IANA 名称,例如 `Asia/Shanghai`;
+- `cron` 类型必须提供 `cron_expression`,其他类型不得提供;
+- 新建的空调度不能直接启用;
+- 只有 DAG 校验通过的调度才能设为 `enabled=true`;
+- `next_run_at` 在数据库和接口中按 UTC 保存和返回。
+
+Cron 预览请求示例:
+
+```json
+{
+ "cron_expression": "*/5 * * * *",
+ "timezone": "Asia/Shanghai",
+ "count": 5,
+ "base_time": "2026-07-28T08:00:00+08:00"
+}
+```
+
+## 6. 节点与 DAG 约束
+
+- 节点必须引用不可变稳定版本 `versions_id`,不能引用工作副本;
+- 同一个调度内 `node_key` 唯一;
+- 节点保存超时、重试、位置、参数和环境引用;
+- 连线的起点、终点必须属于同一调度;
+- 不允许自环、重复连线和有向环;
+- 删除节点会同时删除与该节点相连的边;
+- 已有运行历史的节点不能物理删除;
+- 校验结果返回根节点、叶节点、拓扑顺序和错误列表。
+
+## 7. 典型状态码
+
+| 状态码 | 场景 |
+|---|---|
+| `201` | 调度、节点或连线创建成功 |
+| `404` | 调度、稳定版本、节点或连线不存在/不可见 |
+| `409` | 名称冲突、重复连线、DAG 成环或无效 DAG 启用 |
+| `412` | `workflow_version` 已过期 |
+| `422` | 请求字段、Cron、时区或节点归属不合法 |
+
diff --git a/deploy/data/workspaces/model-dev/users/admin-zhang/.ipynb_checkpoints/111test1-checkpoint.ipynb b/deploy/data/workspaces/model-dev/users/admin-zhang/.ipynb_checkpoints/111test1-checkpoint.ipynb
new file mode 100644
index 0000000..06b3181
--- /dev/null
+++ b/deploy/data/workspaces/model-dev/users/admin-zhang/.ipynb_checkpoints/111test1-checkpoint.ipynb
@@ -0,0 +1,67 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "5f4293c4",
+ "metadata": {},
+ "source": [
+ "# 新建模型实验\\n在这里开始数据探索与模型构建。"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "694d9330",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Hello, Model Platform!\n"
+ ]
+ }
+ ],
+ "source": [
+ "print('Hello, Model Platform!')"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "6c70739e-0bd2-413d-bb0e-68ed8951d4bb",
+ "metadata": {},
+ "outputs": [],
+ "source": []
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "a1574172-4d3c-40cd-860e-f0f0b4822c49",
+ "metadata": {},
+ "outputs": [],
+ "source": []
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3 (ipykernel)",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.13.11"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/deploy/data/workspaces/model-dev/users/admin-zhang/.ipynb_checkpoints/222test2-checkpoint.py b/deploy/data/workspaces/model-dev/users/admin-zhang/.ipynb_checkpoints/222test2-checkpoint.py
new file mode 100644
index 0000000..2dc3e1b
--- /dev/null
+++ b/deploy/data/workspaces/model-dev/users/admin-zhang/.ipynb_checkpoints/222test2-checkpoint.py
@@ -0,0 +1,9 @@
+"""模型实验开发平台构建脚本。"""
+
+
+def main() -> None:
+ print("Hello, Model Platform!")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/deploy/data/workspaces/model-dev/users/admin-zhang/.ipynb_checkpoints/test2-checkpoint.py b/deploy/data/workspaces/model-dev/users/admin-zhang/.ipynb_checkpoints/test2-checkpoint.py
new file mode 100644
index 0000000..2dc3e1b
--- /dev/null
+++ b/deploy/data/workspaces/model-dev/users/admin-zhang/.ipynb_checkpoints/test2-checkpoint.py
@@ -0,0 +1,9 @@
+"""模型实验开发平台构建脚本。"""
+
+
+def main() -> None:
+ print("Hello, Model Platform!")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/deploy/data/workspaces/model-dev/users/admin-zhang/111test1.ipynb b/deploy/data/workspaces/model-dev/users/admin-zhang/111test1.ipynb
new file mode 100644
index 0000000..08007ca
--- /dev/null
+++ b/deploy/data/workspaces/model-dev/users/admin-zhang/111test1.ipynb
@@ -0,0 +1,89 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "5f4293c4",
+ "metadata": {},
+ "source": [
+ "# 新建模型实验\\n在这里开始数据探索与模型构建。"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "694d9330",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Hello, Model Platform!\n"
+ ]
+ }
+ ],
+ "source": [
+ "print('Hello, Model Platform!')"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "6c70739e-0bd2-413d-bb0e-68ed8951d4bb",
+ "metadata": {},
+ "outputs": [],
+ "source": []
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "id": "a1574172-4d3c-40cd-860e-f0f0b4822c49",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ "'2026-07-30 10:38:49.218085'"
+ ]
+ },
+ "execution_count": 3,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "from datetime import datetime\n",
+ "str(datetime.now())"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "2d521122-0ee9-4f39-b0a2-6d3a3b004916",
+ "metadata": {},
+ "outputs": [],
+ "source": []
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3 (ipykernel)",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.13.11"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/deploy/data/workspaces/model-dev/users/admin-zhang/222test2.py b/deploy/data/workspaces/model-dev/users/admin-zhang/222test2.py
new file mode 100644
index 0000000..2dc3e1b
--- /dev/null
+++ b/deploy/data/workspaces/model-dev/users/admin-zhang/222test2.py
@@ -0,0 +1,9 @@
+"""模型实验开发平台构建脚本。"""
+
+
+def main() -> None:
+ print("Hello, Model Platform!")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/deploy/data/workspaces/model-dev/users/admin-zhang/notebooks/.ipynb_checkpoints/test-checkpoint.ipynb b/deploy/data/workspaces/model-dev/users/admin-zhang/notebooks/.ipynb_checkpoints/test-checkpoint.ipynb
new file mode 100644
index 0000000..bcf5520
--- /dev/null
+++ b/deploy/data/workspaces/model-dev/users/admin-zhang/notebooks/.ipynb_checkpoints/test-checkpoint.ipynb
@@ -0,0 +1,43 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "e139eae2",
+ "metadata": {},
+ "source": [
+ "# 新建模型实验\\n在这里开始数据探索与模型构建。"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "7a78bce5",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "print('Hello, Model Platform!')"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3 (ipykernel)",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.13.11"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/deploy/data/workspaces/model-dev/users/admin-zhang/notebooks/NotebookDemo.ipynb b/deploy/data/workspaces/model-dev/users/admin-zhang/notebooks/NotebookDemo.ipynb
new file mode 100644
index 0000000..7cd3200
--- /dev/null
+++ b/deploy/data/workspaces/model-dev/users/admin-zhang/notebooks/NotebookDemo.ipynb
@@ -0,0 +1,67 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "initial_id",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": ["hello world\n"]
+ }
+ ],
+ "source": ["print(\"hello world\")"]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "979c21d2489e134d",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": ["hello world12323\n"]
+ }
+ ],
+ "source": ["print(\"hello world12323\")"]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "id": "e068092a-3b51-4399-9c64-c44d58f4973c",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": ["Python 3.12.13\n"]
+ }
+ ],
+ "source": ["!python --version"]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "1f512d9e-92f7-423e-9176-40fec30dd79c",
+ "metadata": {},
+ "outputs": [],
+ "source": []
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3 (ipykernel)",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "name": "python",
+ "version": "3.12.13"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/deploy/data/workspaces/model-dev/users/admin-zhang/notebooks/test.ipynb b/deploy/data/workspaces/model-dev/users/admin-zhang/notebooks/test.ipynb
new file mode 100644
index 0000000..4fc54c1
--- /dev/null
+++ b/deploy/data/workspaces/model-dev/users/admin-zhang/notebooks/test.ipynb
@@ -0,0 +1,59 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "e139eae2",
+ "metadata": {},
+ "source": [
+ "# 新建模型实验\\n在这里开始数据探索与模型构建。"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "7a78bce5",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "111\n"
+ ]
+ }
+ ],
+ "source": [
+ "print('111')"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "42d2764b-5699-4374-afe9-aecda6d2d1cd",
+ "metadata": {},
+ "outputs": [],
+ "source": []
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3 (ipykernel)",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.13.11"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/deploy/data/workspaces/model-dev/users/admin-zhang/test2.py b/deploy/data/workspaces/model-dev/users/admin-zhang/test2.py
new file mode 100644
index 0000000..2dc3e1b
--- /dev/null
+++ b/deploy/data/workspaces/model-dev/users/admin-zhang/test2.py
@@ -0,0 +1,9 @@
+"""模型实验开发平台构建脚本。"""
+
+
+def main() -> None:
+ print("Hello, Model Platform!")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/deploy/demo/NotebookDemo.ipynb b/deploy/demo/NotebookDemo.ipynb
new file mode 100644
index 0000000..7cd3200
--- /dev/null
+++ b/deploy/demo/NotebookDemo.ipynb
@@ -0,0 +1,67 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "initial_id",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": ["hello world\n"]
+ }
+ ],
+ "source": ["print(\"hello world\")"]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "979c21d2489e134d",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": ["hello world12323\n"]
+ }
+ ],
+ "source": ["print(\"hello world12323\")"]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "id": "e068092a-3b51-4399-9c64-c44d58f4973c",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": ["Python 3.12.13\n"]
+ }
+ ],
+ "source": ["!python --version"]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "1f512d9e-92f7-423e-9176-40fec30dd79c",
+ "metadata": {},
+ "outputs": [],
+ "source": []
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3 (ipykernel)",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "name": "python",
+ "version": "3.12.13"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..f5a6d4b
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,224 @@
+name: ${COMPOSE_PROJECT_NAME:-model-platform-new}
+
+x-app-environment: &app-environment
+ DATABASE_URL: mysql+asyncmy://${MYSQL_USER:-model_platform}:${MYSQL_PASSWORD:-model_platform}@mysql:3306/${MYSQL_DATABASE:-model_platform}?charset=utf8mb4
+ INTERNAL_SERVICE_TOKEN: ${INTERNAL_SERVICE_TOKEN:-local-internal-token}
+ JUPYTER_TOKEN: ${JUPYTER_TOKEN:-local-jupyter-token}
+ WORKSPACE_ROOT: /workspace/workspaces
+
+services:
+ mysql:
+ image: mysql:8.0.36
+ restart: unless-stopped
+ environment:
+ MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-model_platform_root}
+ MYSQL_DATABASE: ${MYSQL_DATABASE:-model_platform}
+ MYSQL_USER: ${MYSQL_USER:-model_platform}
+ MYSQL_PASSWORD: ${MYSQL_PASSWORD:-model_platform}
+ command:
+ - --character-set-server=utf8mb4
+ - --collation-server=utf8mb4_0900_ai_ci
+ - --default-time-zone=+08:00
+ ports:
+ - "${MYSQL_PORT:-3308}:3306"
+ volumes:
+ - mysql_data:/var/lib/mysql
+ healthcheck:
+ test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u root -p$$MYSQL_ROOT_PASSWORD --silent"]
+ interval: 10s
+ timeout: 5s
+ retries: 15
+ start_period: 20s
+
+ rustfs:
+ image: ${RUSTFS_IMAGE:-rustfs/rustfs:latest}
+ restart: unless-stopped
+ environment:
+ RUSTFS_ADDRESS: ":9000"
+ RUSTFS_CONSOLE_ENABLE: "true"
+ RUSTFS_CONSOLE_ADDRESS: ":9001"
+ RUSTFS_ACCESS_KEY: ${RUSTFS_ACCESS_KEY:-modelplatform}
+ RUSTFS_SECRET_KEY: ${RUSTFS_SECRET_KEY:-modelplatformsecret}
+ command: ["/data"]
+ ports:
+ - "${RUSTFS_API_PORT:-9010}:9000"
+ - "${RUSTFS_CONSOLE_PORT:-9011}:9001"
+ volumes:
+ - rustfs_data:/data
+
+ jupyter:
+ image: ${JUPYTER_IMAGE:-quay.io/jupyter/base-notebook:2025-12-31}
+ restart: unless-stopped
+ environment:
+ JUPYTER_TOKEN: ${JUPYTER_TOKEN:-local-jupyter-token}
+ command:
+ - start-notebook.py
+ - --ServerApp.base_url=/jupyter/
+ - --ServerApp.root_dir=/home/jovyan/work
+ - --ServerApp.ip=0.0.0.0
+ - --ServerApp.allow_remote_access=True
+ - --IdentityProvider.token=${JUPYTER_TOKEN:-local-jupyter-token}
+ - --PasswordIdentityProvider.hashed_password=
+ volumes:
+ - ./deploy/data/workspaces:/home/jovyan/work
+ expose:
+ - "8888"
+ healthcheck:
+ test:
+ - CMD
+ - python
+ - -c
+ - "import os, urllib.request; r=urllib.request.Request('http://127.0.0.1:8888/jupyter/api/status', headers={'Authorization':'Bearer '+os.environ['JUPYTER_TOKEN']}); urllib.request.urlopen(r, timeout=3)"
+ interval: 10s
+ timeout: 5s
+ retries: 15
+ start_period: 30s
+
+ migrate:
+ build:
+ context: .
+ dockerfile: backend/Dockerfile
+ environment:
+ <<: *app-environment
+ command: ["alembic", "upgrade", "head"]
+ depends_on:
+ mysql:
+ condition: service_healthy
+ restart: "no"
+
+ backend:
+ build:
+ context: .
+ dockerfile: backend/Dockerfile
+ restart: unless-stopped
+ environment:
+ <<: *app-environment
+ SERVICE_NAME: backend
+ READINESS_TARGETS: mysql:3306,rustfs:9000
+ RUNTIME_API_URL: http://runtime:8000
+ SCHEDULE_API_URL: http://schedule:8000
+ RUSTFS_INTERNAL_ENDPOINT: http://rustfs:9000
+ RUSTFS_PUBLIC_ENDPOINT: http://localhost:${RUSTFS_API_PORT:-9010}
+ RUSTFS_ACCESS_KEY: ${RUSTFS_ACCESS_KEY:-modelplatform}
+ RUSTFS_SECRET_KEY: ${RUSTFS_SECRET_KEY:-modelplatformsecret}
+ RUSTFS_DEFAULT_BUCKET: model-platform
+ healthcheck:
+ test:
+ - CMD
+ - python
+ - -c
+ - "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health/ready', timeout=3)"
+ interval: 10s
+ timeout: 5s
+ retries: 10
+ start_period: 10s
+ volumes:
+ - ./deploy/data/workspaces:/workspace/workspaces
+ ports:
+ - "${BACKEND_PORT:-8010}:8000"
+ depends_on:
+ migrate:
+ condition: service_completed_successfully
+ mysql:
+ condition: service_healthy
+ rustfs:
+ condition: service_started
+
+ runtime:
+ build:
+ context: .
+ dockerfile: runtime/Dockerfile
+ restart: unless-stopped
+ environment:
+ <<: *app-environment
+ SERVICE_NAME: runtime-manager
+ READINESS_TARGETS: mysql:3306,jupyter:8888
+ FILE_LOCK_ENABLED: "false"
+ FILE_LOCK_TTL_MS: "45000"
+ JUPYTER_INTERNAL_URL: http://jupyter:8888/jupyter/
+ JUPYTER_PROXY_BASE_PATH: /jupyter/
+ JUPYTER_TICKET_TTL_SECONDS: "300"
+ RUNTIME_LEASE_SECONDS: "1800"
+ healthcheck:
+ test:
+ - CMD
+ - python
+ - -c
+ - "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health/ready', timeout=3)"
+ interval: 10s
+ timeout: 5s
+ retries: 10
+ start_period: 10s
+ ports:
+ - "${RUNTIME_PORT:-8012}:8000"
+ depends_on:
+ migrate:
+ condition: service_completed_successfully
+ mysql:
+ condition: service_healthy
+ jupyter:
+ condition: service_healthy
+
+ schedule:
+ build:
+ context: .
+ dockerfile: schedule/Dockerfile
+ restart: unless-stopped
+ environment:
+ <<: *app-environment
+ SERVICE_NAME: schedule-executor
+ READINESS_TARGETS: mysql:3306,rustfs:9000,backend:8000
+ RUSTFS_INTERNAL_ENDPOINT: http://rustfs:9000
+ RUSTFS_ACCESS_KEY: ${RUSTFS_ACCESS_KEY:-modelplatform}
+ RUSTFS_SECRET_KEY: ${RUSTFS_SECRET_KEY:-modelplatformsecret}
+ BACKEND_API_URL: http://backend:8000
+ healthcheck:
+ test:
+ - CMD
+ - python
+ - -c
+ - "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health/ready', timeout=3)"
+ interval: 10s
+ timeout: 5s
+ retries: 10
+ start_period: 10s
+ volumes:
+ - ./deploy/data/workspaces:/workspace/workspaces
+ ports:
+ - "${SCHEDULE_PORT:-8013}:8000"
+ depends_on:
+ mysql:
+ condition: service_healthy
+ rustfs:
+ condition: service_started
+ backend:
+ condition: service_healthy
+
+ gateway:
+ build:
+ context: .
+ dockerfile: nginx/Dockerfile
+ restart: unless-stopped
+ environment:
+ INTERNAL_SERVICE_TOKEN: ${INTERNAL_SERVICE_TOKEN:-local-internal-token}
+ ports:
+ - "${GATEWAY_PORT:-8081}:80"
+ depends_on:
+ backend:
+ condition: service_healthy
+ runtime:
+ condition: service_healthy
+ schedule:
+ condition: service_healthy
+ jupyter:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/health >/dev/null 2>&1"]
+ interval: 10s
+ timeout: 5s
+ retries: 10
+ start_period: 5s
+
+volumes:
+ mysql_data:
+ rustfs_data:
diff --git a/frontend/.dockerignore b/frontend/.dockerignore
new file mode 100644
index 0000000..9b8d514
--- /dev/null
+++ b/frontend/.dockerignore
@@ -0,0 +1,4 @@
+.react-router
+build
+node_modules
+README.md
\ No newline at end of file
diff --git a/frontend/.gitignore b/frontend/.gitignore
new file mode 100644
index 0000000..039ee62
--- /dev/null
+++ b/frontend/.gitignore
@@ -0,0 +1,7 @@
+.DS_Store
+.env
+/node_modules/
+
+# React Router
+/.react-router/
+/build/
diff --git a/frontend/README.md b/frontend/README.md
new file mode 100644
index 0000000..a3844c1
--- /dev/null
+++ b/frontend/README.md
@@ -0,0 +1,22 @@
+# Frontend
+
+React Router SPA,已将原前端按功能拆分到 `app/`:
+
+```text
+app/components/ 通用图标组件
+app/features/platform/ 主框架、脚本工作区和 Jupyter 编辑
+app/features/schedules/ DAG 调度页面
+app/features/admin/ 工作台与系统管理
+app/routes/ React Router 路由入口
+app/services/ API 请求、DTO 和演示上下文
+app/styles/ 分功能样式
+```
+
+本地开发:
+
+```bash
+pnpm install
+pnpm dev
+```
+
+生产构建由根目录 `nginx/Dockerfile` 完成,构建结果复制到 Nginx 静态目录。
diff --git a/frontend/app/app.css b/frontend/app/app.css
new file mode 100644
index 0000000..928fa83
--- /dev/null
+++ b/frontend/app/app.css
@@ -0,0 +1,11 @@
+:root {
+ font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
+ color: #1f354b;
+ background: #f3f6f9;
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+}
+
+* { box-sizing: border-box; }
+html, body { margin: 0; min-width: 1024px; min-height: 100%; }
+button, input, select, textarea { font: inherit; }
diff --git a/frontend/app/components/Icon.tsx b/frontend/app/components/Icon.tsx
new file mode 100644
index 0000000..ec50beb
--- /dev/null
+++ b/frontend/app/components/Icon.tsx
@@ -0,0 +1,183 @@
+type IconName =
+ | "brand"
+ | "home"
+ | "script"
+ | "schedule"
+ | "experiment"
+ | "database"
+ | "settings"
+ | "chevron"
+ | "search"
+ | "plus"
+ | "upload"
+ | "folder"
+ | "notebook"
+ | "python"
+ | "refresh"
+ | "close"
+ | "check"
+ | "info"
+ | "workspace"
+ | "menu"
+ | "external"
+ | "release"
+ | "play";
+
+type IconProps = {
+ name: IconName;
+ size?: number;
+ strokeWidth?: number;
+};
+
+export default function Icon({
+ name,
+ size = 18,
+ strokeWidth = 1.8,
+}: IconProps) {
+ const common = {
+ width: size,
+ height: size,
+ viewBox: "0 0 24 24",
+ fill: "none",
+ xmlns: "http://www.w3.org/2000/svg",
+ "aria-hidden": true,
+ };
+
+ if (name === "brand") {
+ return (
+
+ );
+ }
+
+ const paths: Record, React.ReactNode> = {
+ home: (
+ <>
+
+
+ >
+ ),
+ script: (
+ <>
+
+
+ >
+ ),
+ schedule: (
+ <>
+
+
+ >
+ ),
+ experiment: (
+ <>
+
+
+ >
+ ),
+ database: (
+ <>
+
+
+
+ >
+ ),
+ settings: (
+ <>
+
+
+ >
+ ),
+ chevron: ,
+ search: (
+ <>
+
+
+ >
+ ),
+ plus: ,
+ upload: (
+ <>
+
+
+ >
+ ),
+ folder: (
+
+ ),
+ notebook: (
+ <>
+
+
+ >
+ ),
+ python: (
+ <>
+
+
+
+
+ >
+ ),
+ refresh: (
+ <>
+
+
+ >
+ ),
+ close: ,
+ check: ,
+ info: (
+ <>
+
+
+ >
+ ),
+ workspace: (
+ <>
+
+
+ >
+ ),
+ menu: (
+ <>
+
+ >
+ ),
+ external: (
+ <>
+
+
+ >
+ ),
+ release: (
+ <>
+
+
+ >
+ ),
+ play: ,
+ };
+
+ return (
+
+ );
+}
diff --git a/frontend/app/features/admin/AdminPages.tsx b/frontend/app/features/admin/AdminPages.tsx
new file mode 100644
index 0000000..1f7ad1d
--- /dev/null
+++ b/frontend/app/features/admin/AdminPages.tsx
@@ -0,0 +1,256 @@
+import { FormEvent, useEffect, useState } from "react";
+
+import {
+ ApiRequestError,
+ createEmployee,
+ deleteEmployee,
+ demoContext,
+ listEmployees,
+ updateEmployee,
+ type Employee,
+} from "../../services/api";
+import Icon from "../../components/Icon";
+import "../../styles/admin.css";
+import "../../styles/dashboard.css";
+
+
+type Notice = {
+ tone: "success" | "error" | "info";
+ message: string;
+};
+
+export function DashboardPage({
+ scriptCount,
+ online,
+ onNavigate,
+}: {
+ scriptCount: number;
+ online: boolean;
+ onNavigate: (page: "scripts" | "schedules" | "system") => void;
+}) {
+ return (
+
+
+
+
MODEL DEVELOPMENT PLATFORM
+
下午好,{demoContext.userName}
+
当前位于 {demoContext.workspaceName},可以继续构建脚本或配置调度。
+
+
{online ? "服务正常" : "服务连接中"}
+
+
+
{scriptCount}工作副本
+
2Workspace
+
4平台员工
+
{online ? "正常" : "检查中"}平台状态
+
+
+
+
+
+
+
+
+
+
+ {[38, 55, 44, 73, 61, 86, 78].map((value, index) => (
+
+ {Math.round(value / 7)}
+
+ {["周一", "周二", "周三", "周四", "周五", "周六", "今天"][index]}
+
+ ))}
+
+
+
+
+
+
+
{scriptCount}全部脚本
+
+ Notebook{Math.max(1, Math.round(scriptCount * .67))} 个 · 67%
+ Python{Math.max(0, scriptCount - Math.round(scriptCount * .67))} 个 · 33%
+ 稳定版本3 个已发布
+
+
+
+
+
+
+
操作内容执行人状态时间
+ {[
+ ["数据探索.ipynb 发布稳定版本 v3.0", "张三", "成功", "16:42"],
+ ["每日模型训练流程完成调度运行", "Scheduler", "成功", "15:25"],
+ ["批量预测.py 更新工作副本", "王五", "已同步", "14:18"],
+ ["风险验证流程完成 DAG 校验", "李四", "成功", "11:06"],
+ ].map((row) => (
+
+ {row[0]}
+ {row[1]}{row[2]}{row[3]}
+
+ ))}
+
+
+
+
+ );
+}
+
+
+const EMPTY_FORM = {
+ username: "",
+ display_name: "",
+ email: "",
+ role_code: "developer" as "admin" | "developer",
+ status: "active" as "active" | "disabled" | "locked",
+};
+
+export function SystemAdminPage({
+ onNotify,
+ onConnectionChange,
+}: {
+ onNotify: (notice: Notice) => void;
+ onConnectionChange: (online: boolean) => void;
+}) {
+ const [employees, setEmployees] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [editing, setEditing] = useState(null);
+ const [dialogOpen, setDialogOpen] = useState(false);
+ const [form, setForm] = useState(EMPTY_FORM);
+ const canManage = demoContext.roleCode === "admin";
+
+ const load = async (): Promise => {
+ setLoading(true);
+ try {
+ setEmployees(await listEmployees());
+ onConnectionChange(true);
+ } catch (error) {
+ onConnectionChange(false);
+ onNotify({
+ tone: "error",
+ message: error instanceof Error ? error.message : "员工列表加载失败",
+ });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ void load();
+ }, []);
+
+ const openCreate = (): void => {
+ setEditing(null);
+ setForm(EMPTY_FORM);
+ setDialogOpen(true);
+ };
+
+ const openEdit = (employee: Employee): void => {
+ setEditing(employee);
+ setForm({
+ username: employee.username,
+ display_name: employee.display_name,
+ email: employee.email ?? "",
+ role_code: employee.role_code,
+ status: employee.status,
+ });
+ setDialogOpen(true);
+ };
+
+ const submit = async (event: FormEvent): Promise => {
+ event.preventDefault();
+ if (!form.display_name.trim() || (!editing && !form.username.trim())) return;
+ setSaving(true);
+ try {
+ if (editing) {
+ const updated = await updateEmployee(editing.user_id, {
+ display_name: form.display_name.trim(),
+ email: form.email.trim() || null,
+ role_code: form.role_code,
+ status: form.status,
+ });
+ setEmployees((current) => current.map(
+ (item) => item.user_id === updated.user_id ? updated : item,
+ ));
+ } else {
+ const created = await createEmployee({
+ username: form.username.trim(),
+ display_name: form.display_name.trim(),
+ email: form.email.trim() || null,
+ role_code: form.role_code,
+ });
+ setEmployees((current) => [...current, created]);
+ }
+ setDialogOpen(false);
+ onNotify({ tone: "success", message: editing ? "员工信息已更新" : "员工已添加" });
+ } catch (error) {
+ onNotify({
+ tone: "error",
+ message: error instanceof ApiRequestError ? error.message : "保存员工失败",
+ });
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const remove = async (employee: Employee): Promise => {
+ if (!window.confirm(`确定从当前 Workspace 删除员工“${employee.display_name}”吗?`)) return;
+ try {
+ await deleteEmployee(employee.user_id);
+ setEmployees((current) => current.filter((item) => item.user_id !== employee.user_id));
+ onNotify({ tone: "success", message: "员工已删除" });
+ } catch (error) {
+ onNotify({
+ tone: "error",
+ message: error instanceof Error ? error.message : "删除员工失败",
+ });
+ }
+ };
+
+ return (
+
+
+ {!canManage && 当前为开发人员,只能查看员工列表。
}
+
+
员工账号角色状态操作
+ {loading ?
正在加载员工…
: employees.map((employee) => {
+ const isProtectedAdmin = employee.role_code === "admin";
+ return (
+
+ {employee.display_name.slice(0, 1)}{employee.display_name}{employee.email ?? "未设置邮箱"}
+ {employee.username}
+ {employee.role_name}
+ {employee.status === "active" ? "正常" : employee.status === "disabled" ? "已停用" : "已锁定"}
+
+
+
+
+
+ );
+ })}
+
+
+ {dialogOpen && (
+
+
+ EMPLOYEE
{editing ? "编辑员工" : "添加员工"}
+
+
+
+ )}
+
+ );
+}
diff --git a/frontend/app/features/platform/ModelPlatformApp.tsx b/frontend/app/features/platform/ModelPlatformApp.tsx
new file mode 100644
index 0000000..73a3b9c
--- /dev/null
+++ b/frontend/app/features/platform/ModelPlatformApp.tsx
@@ -0,0 +1,1965 @@
+import {
+ type ChangeEvent,
+ FormEvent,
+ type MouseEvent as ReactMouseEvent,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import { useLocation, useNavigate } from "react-router";
+
+import {
+ acquireFileLock,
+ createWorkspaceDirectory,
+ createScript,
+ createJupyterAccessTicket,
+ deleteScript,
+ deleteWorkspaceDirectory,
+ demoContext,
+ demoUsers,
+ demoWorkspaces,
+ heartbeatFileLock,
+ listScripts,
+ listScriptVersions,
+ listWorkspaceDirectories,
+ publishScriptVersion,
+ releaseFileLock,
+ releaseFileLockOnUnload,
+ setDemoContext,
+ uploadScript,
+ type ActiveEditSession,
+ type ScriptItem,
+ type ScriptType,
+ type StableVersion,
+ type Visibility,
+ type WorkspaceDirectory,
+} from "../../services/api";
+import Icon from "../../components/Icon";
+import SchedulePage from "../schedules/SchedulePage";
+import { DashboardPage, SystemAdminPage } from "../admin/AdminPages";
+import "../../styles/platform.css";
+
+
+type NewScriptForm = {
+ name: string;
+ scriptType: ScriptType;
+ visibility: Visibility;
+ parentPath: string;
+};
+
+type ContextMenuState = {
+ x: number;
+ y: number;
+ kind: "root" | "directory" | "file";
+ path: string;
+ script?: ScriptItem;
+};
+
+type ToastState = {
+ tone: "success" | "error" | "info";
+ message: string;
+};
+
+const navigation = [
+ { label: "工作台", icon: "home" as const, page: "home" as const },
+ { label: "构建脚本", icon: "script" as const, page: "scripts" as const },
+ { label: "调度配置", icon: "schedule" as const, page: "schedules" as const },
+ { label: "系统管理", icon: "settings" as const, page: "system" as const },
+];
+
+type ActivePage = "home" | "scripts" | "schedules" | "system";
+
+function pageFromPath(pathname: string): ActivePage {
+ const page = pathname.replace(/^\/+|\/+$/g, "");
+ return ["scripts", "schedules", "system"].includes(page)
+ ? page as ActivePage
+ : "home";
+}
+
+function pathForPage(page: ActivePage): string {
+ return page === "home" ? "/workbench" : `/${page}`;
+}
+
+const initialForm: NewScriptForm = {
+ name: "",
+ scriptType: "notebook",
+ visibility: "workspace",
+ parentPath: "",
+};
+
+function formatTime(value: string) {
+ return new Intl.DateTimeFormat("zh-CN", {
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ hour12: false,
+ }).format(new Date(value));
+}
+
+function formatBytes(value: number) {
+ if (value < 1024) return `${value} B`;
+ return `${(value / 1024).toFixed(1)} KB`;
+}
+
+function shortHash(value: string) {
+ return value ? `${value.slice(0, 8)}…${value.slice(-6)}` : "—";
+}
+
+function scriptIcon(item: ScriptItem) {
+ return item.script_type === "notebook" ? "notebook" : "python";
+}
+
+function ownedScriptPath(item: ScriptItem) {
+ return item.relative_path.replaceAll("\\", "/").split("/").slice(2).join("/");
+}
+
+function parentOf(path: string) {
+ const parts = path.split("/");
+ parts.pop();
+ return parts.join("/");
+}
+
+function inferredDirectories(items: ScriptItem[]): WorkspaceDirectory[] {
+ const result = new Map();
+ for (const item of items) {
+ const parts = parentOf(ownedScriptPath(item)).split("/").filter(Boolean);
+ let parentPath = "";
+ for (const name of parts) {
+ const path = parentPath ? `${parentPath}/${name}` : name;
+ result.set(path, { path, name, parent_path: parentPath });
+ parentPath = path;
+ }
+ }
+ return [...result.values()];
+}
+
+function mergeDirectories(
+ left: WorkspaceDirectory[],
+ right: WorkspaceDirectory[],
+): WorkspaceDirectory[] {
+ return [...new Map(
+ [...left, ...right].map((item) => [item.path, item]),
+ ).values()];
+}
+
+function confineJupyterFrame(frame: HTMLIFrameElement): void {
+ try {
+ const document = frame.contentDocument;
+ if (!document?.documentElement) return;
+ const keepInside = (): void => {
+ document.querySelectorAll("button,[role='button']").forEach(
+ (element) => {
+ const label = `${element.getAttribute("aria-label") ?? ""} ${
+ element.getAttribute("title") ?? ""
+ } ${element.textContent ?? ""}`.trim();
+ if (/^open in\b/i.test(label) || /\bopen in\.\.\./i.test(label)) {
+ element.style.setProperty("display", "none", "important");
+ }
+ },
+ );
+ document.querySelectorAll("a[target]").forEach((link) => {
+ if (["_blank", "_top", "_parent"].includes(link.target)) {
+ link.target = "_self";
+ }
+ });
+ };
+ keepInside();
+ new MutationObserver(keepInside).observe(document.documentElement, {
+ childList: true,
+ subtree: true,
+ });
+ document.addEventListener("click", (event) => {
+ const target = event.target as HTMLElement | null;
+ const link = target?.closest?.("a") as HTMLAnchorElement | null;
+ if (link && ["_blank", "_top", "_parent"].includes(link.target)) {
+ link.target = "_self";
+ }
+ }, true);
+ } catch {
+ // The iframe remains sandboxed even if its document is not yet accessible.
+ }
+}
+
+export default function ModelPlatformApp() {
+ const location = useLocation();
+ const navigate = useNavigate();
+ const activePage = pageFromPath(location.pathname);
+ const [scripts, setScripts] = useState([]);
+ const [directories, setDirectories] = useState([]);
+ const [selectedId, setSelectedId] = useState(null);
+ const [keyword, setKeyword] = useState("");
+ const [loading, setLoading] = useState(true);
+ const [refreshing, setRefreshing] = useState(false);
+ const [apiOnline, setApiOnline] = useState(false);
+ const [createOpen, setCreateOpen] = useState(false);
+ const [creating, setCreating] = useState(false);
+ const [form, setForm] = useState(initialForm);
+ const [folderDialog, setFolderDialog] = useState<{
+ open: boolean;
+ parentPath: string;
+ name: string;
+ busy: boolean;
+ }>({ open: false, parentPath: "", name: "", busy: false });
+ const [contextMenu, setContextMenu] = useState(null);
+ const [workspaceMenuOpen, setWorkspaceMenuOpen] = useState(false);
+ const [userMenuOpen, setUserMenuOpen] = useState(false);
+ const [uploadParentPath, setUploadParentPath] = useState("");
+ const [uploading, setUploading] = useState(false);
+ const uploadInputRef = useRef(null);
+ const [toast, setToast] = useState(null);
+ const [editSession, setEditSession] = useState(null);
+ const editSessionRef = useRef(null);
+ const selectedIdRef = useRef(null);
+ const editorOpenRequestRef = useRef(0);
+ const editorOpeningRef = useRef(false);
+ const [embeddedJupyterUrl, setEmbeddedJupyterUrl] =
+ useState(null);
+ const [editBusy, setEditBusy] = useState(false);
+ const [editorOpenError, setEditorOpenError] = useState<{
+ scriptId: string;
+ message: string;
+ } | null>(null);
+ const [versions, setVersions] = useState([]);
+ const [versionsLoading, setVersionsLoading] = useState(false);
+ const [publishTarget, setPublishTarget] = useState(null);
+ const [releaseNote, setReleaseNote] = useState("");
+ const [publishVisibility, setPublishVisibility] =
+ useState("workspace");
+ const [publishing, setPublishing] = useState(false);
+ const [publishedVersion, setPublishedVersion] =
+ useState(null);
+
+ const load = async (silent = false) => {
+ if (!silent) setLoading(true);
+ setRefreshing(silent);
+ try {
+ const [items, folderItems] = await Promise.all([
+ listScripts(),
+ listWorkspaceDirectories(),
+ ]);
+ setScripts(items);
+ setDirectories(folderItems);
+ setApiOnline(true);
+ setSelectedId((current) => {
+ if (current && items.some((item) => item.script_id === current)) {
+ selectedIdRef.current = current;
+ return current;
+ }
+ const nextSelectedId = (
+ items.find((item) => item.script_type === "notebook")?.script_id
+ ?? items[0]?.script_id
+ ?? null
+ );
+ selectedIdRef.current = nextSelectedId;
+ return nextSelectedId;
+ });
+ } catch (error) {
+ setApiOnline(false);
+ setToast({
+ tone: "error",
+ message: error instanceof Error ? error.message : "脚本列表加载失败",
+ });
+ } finally {
+ setLoading(false);
+ setRefreshing(false);
+ }
+ };
+
+ useEffect(() => {
+ void load();
+ }, []);
+
+ useEffect(() => {
+ if (!toast) return;
+ const timer = window.setTimeout(() => setToast(null), 3200);
+ return () => window.clearTimeout(timer);
+ }, [toast]);
+
+ useEffect(() => {
+ if (!contextMenu) return;
+ const close = () => setContextMenu(null);
+ const closeOnEscape = (event: KeyboardEvent) => {
+ if (event.key === "Escape") close();
+ };
+ window.addEventListener("pointerdown", close);
+ window.addEventListener("blur", close);
+ window.addEventListener("resize", close);
+ window.addEventListener("scroll", close, true);
+ window.addEventListener("keydown", closeOnEscape);
+ return () => {
+ window.removeEventListener("pointerdown", close);
+ window.removeEventListener("blur", close);
+ window.removeEventListener("resize", close);
+ window.removeEventListener("scroll", close, true);
+ window.removeEventListener("keydown", closeOnEscape);
+ };
+ }, [contextMenu]);
+
+ useEffect(() => {
+ editSessionRef.current = editSession;
+ }, [editSession]);
+
+ useEffect(() => {
+ if (!selectedId) {
+ setVersions([]);
+ return;
+ }
+ let ignore = false;
+ setVersionsLoading(true);
+ void listScriptVersions(selectedId)
+ .then((items) => {
+ if (!ignore) setVersions(items);
+ })
+ .catch((error) => {
+ if (!ignore) {
+ setToast({
+ tone: "error",
+ message: error instanceof Error ? error.message : "版本列表加载失败",
+ });
+ }
+ })
+ .finally(() => {
+ if (!ignore) setVersionsLoading(false);
+ });
+ return () => {
+ ignore = true;
+ };
+ }, [selectedId]);
+
+ useEffect(() => {
+ if (!editSession) return;
+ const intervalSeconds = Math.max(
+ 5,
+ editSession.heartbeat_interval_seconds || 15,
+ );
+ let heartbeatRunning = false;
+ const timer = window.setInterval(() => {
+ if (heartbeatRunning) return;
+ const current = editSessionRef.current;
+ if (!current || current.edit_session_id !== editSession.edit_session_id) {
+ return;
+ }
+ heartbeatRunning = true;
+ void heartbeatFileLock(current)
+ .then((updated) => {
+ setEditSession((active) => active
+ && active.edit_session_id === updated.edit_session_id
+ ? {
+ ...active,
+ session_status: updated.session_status,
+ expires_at: updated.expires_at,
+ }
+ : active);
+ })
+ .catch((error) => {
+ setEditSession(null);
+ setEmbeddedJupyterUrl(null);
+ setToast({
+ tone: "error",
+ message: `编辑锁心跳已中断:${
+ error instanceof Error ? error.message : "请重新打开文件"
+ }`,
+ });
+ })
+ .finally(() => {
+ heartbeatRunning = false;
+ });
+ }, intervalSeconds * 1000);
+ return () => window.clearInterval(timer);
+ }, [editSession?.edit_session_id, editSession?.heartbeat_interval_seconds]);
+
+ useEffect(() => {
+ if (!editSession?.ticket_expires_at) return;
+ const expiresAt = new Date(editSession.ticket_expires_at).getTime();
+ const renewAfter = Math.max(15_000, expiresAt - Date.now() - 60_000);
+ const timer = window.setTimeout(() => {
+ const current = editSessionRef.current;
+ if (!current || current.edit_session_id !== editSession.edit_session_id) {
+ return;
+ }
+ void createJupyterAccessTicket(current)
+ .then((ticket) => {
+ setEditSession((active) => active
+ && active.edit_session_id === ticket.edit_session_id
+ ? { ...active, ticket_expires_at: ticket.expires_at }
+ : active);
+ })
+ .catch((error) => {
+ setToast({
+ tone: "error",
+ message: `Jupyter 访问票据续签失败:${
+ error instanceof Error ? error.message : "请重新打开文件"
+ }`,
+ });
+ });
+ }, renewAfter);
+ return () => window.clearTimeout(timer);
+ }, [editSession?.edit_session_id, editSession?.ticket_expires_at]);
+
+ useEffect(() => {
+ if (!editSession) return;
+ const handleUnload = () => {
+ const current = editSessionRef.current;
+ if (current) releaseFileLockOnUnload(current);
+ };
+ window.addEventListener("beforeunload", handleUnload);
+ return () => window.removeEventListener("beforeunload", handleUnload);
+ }, [editSession?.edit_session_id]);
+
+ const filteredScripts = useMemo(() => {
+ const normalized = keyword.trim().toLocaleLowerCase();
+ if (!normalized) return scripts;
+ return scripts.filter((item) =>
+ item.script_name.toLocaleLowerCase().includes(normalized),
+ );
+ }, [keyword, scripts]);
+
+ const memberScriptGroups = [...demoUsers]
+ .sort((left, right) => (
+ Number(right.userId === demoContext.userId)
+ - Number(left.userId === demoContext.userId)
+ ))
+ .map((user) => {
+ const memberScripts = filteredScripts.filter(
+ (item) => item.owner_user_id === user.userId,
+ );
+ const inferred = inferredDirectories(memberScripts);
+ return {
+ user,
+ scripts: memberScripts,
+ directories: user.userId === demoContext.userId
+ ? mergeDirectories(directories, inferred)
+ : inferred,
+ };
+ });
+ const selected = scripts.find((item) => item.script_id === selectedId) ?? null;
+
+ const selectScript = (scriptId: string | null) => {
+ if (selectedIdRef.current !== scriptId) {
+ editorOpenRequestRef.current += 1;
+ setEditorOpenError(null);
+ }
+ selectedIdRef.current = scriptId;
+ setSelectedId(scriptId);
+ };
+
+ const openScriptEditor = async (
+ script: ScriptItem,
+ showToast = true,
+ ) => {
+ if (editorOpeningRef.current) return;
+ editorOpeningRef.current = true;
+ const requestId = editorOpenRequestRef.current + 1;
+ editorOpenRequestRef.current = requestId;
+ const requestIsCurrent = () =>
+ editorOpenRequestRef.current === requestId
+ && selectedIdRef.current === script.script_id;
+ const clearSessionIfActive = (session: ActiveEditSession) => {
+ if (
+ editSessionRef.current?.edit_session_id === session.edit_session_id
+ ) {
+ setEmbeddedJupyterUrl(null);
+ setEditSession(null);
+ editSessionRef.current = null;
+ }
+ };
+
+ setEditBusy(true);
+ setEditorOpenError((current) =>
+ current?.scriptId === script.script_id ? null : current);
+ let active = editSessionRef.current;
+ let newlyAcquired = false;
+ try {
+ if (active && active.script_id !== script.script_id) {
+ await releaseFileLock(active);
+ setEmbeddedJupyterUrl(null);
+ setEditSession(null);
+ editSessionRef.current = null;
+ active = null;
+ }
+ if (!requestIsCurrent()) return;
+
+ if (!active) {
+ active = await acquireFileLock(script);
+ newlyAcquired = true;
+ }
+ if (!requestIsCurrent()) {
+ if (active) {
+ await releaseFileLock(active);
+ clearSessionIfActive(active);
+ }
+ return;
+ }
+
+ const ticket = await createJupyterAccessTicket(active);
+ if (!requestIsCurrent()) {
+ await releaseFileLock(active);
+ clearSessionIfActive(active);
+ return;
+ }
+
+ const readySession = {
+ ...active,
+ ticket_expires_at: ticket.expires_at,
+ };
+ setEditSession(readySession);
+ editSessionRef.current = readySession;
+ setEmbeddedJupyterUrl(ticket.jupyter_url);
+ if (showToast) {
+ setToast({
+ tone: "success",
+ message: `${script.script_name} 已打开,Jupyter 已嵌入当前工作区`,
+ });
+ }
+ } catch (error) {
+ if (newlyAcquired && active) {
+ try {
+ await releaseFileLock(active);
+ } catch {
+ // The database lease is the final safety net if compensation cannot reach Runtime.
+ }
+ setEditSession(null);
+ editSessionRef.current = null;
+ }
+ setEmbeddedJupyterUrl(null);
+ if (requestIsCurrent()) {
+ const message = error instanceof Error ? error.message : "打开编辑器失败";
+ setEditorOpenError({ scriptId: script.script_id, message });
+ if (showToast) {
+ setToast({ tone: "error", message });
+ }
+ }
+ } finally {
+ editorOpeningRef.current = false;
+ setEditBusy(false);
+ }
+ };
+
+ useEffect(() => {
+ if (
+ activePage !== "scripts"
+ || !selected
+ || selected.script_type !== "notebook"
+ || editBusy
+ || editorOpenError?.scriptId === selected.script_id
+ || (
+ editSession?.script_id === selected.script_id
+ && embeddedJupyterUrl
+ )
+ ) {
+ return;
+ }
+
+ void openScriptEditor(selected, false);
+ }, [
+ activePage,
+ editBusy,
+ editSession?.script_id,
+ editorOpenError?.scriptId,
+ embeddedJupyterUrl,
+ selected?.script_id,
+ selected?.script_type,
+ ]);
+
+ const endEditing = async (closeTab = false, showToast = true) => {
+ editorOpenRequestRef.current += 1;
+ setEditorOpenError(null);
+ const active = editSessionRef.current;
+ if (!active) {
+ if (closeTab) selectScript(null);
+ return;
+ }
+ setEditBusy(true);
+ try {
+ await releaseFileLock(active);
+ setEmbeddedJupyterUrl(null);
+ setEditSession(null);
+ editSessionRef.current = null;
+ if (closeTab) selectScript(null);
+ if (showToast) {
+ setToast({
+ tone: "success",
+ message: `${active.script_name} 的编辑锁已释放`,
+ });
+ }
+ } catch (error) {
+ setToast({
+ tone: "error",
+ message: error instanceof Error ? error.message : "释放编辑锁失败",
+ });
+ } finally {
+ setEditBusy(false);
+ }
+ };
+
+ useEffect(() => {
+ const active = editSessionRef.current;
+ if (
+ !active
+ || !selected
+ || selected.script_type === "notebook"
+ || selected.script_id === active.script_id
+ || editBusy
+ ) {
+ return;
+ }
+ void endEditing(false, false);
+ }, [editBusy, selected?.script_id, selected?.script_type]);
+
+ useEffect(() => {
+ if (
+ activePage === "scripts"
+ || editBusy
+ || (!editSessionRef.current && !editorOpeningRef.current)
+ ) {
+ return;
+ }
+ void endEditing(true, false);
+ }, [activePage, editBusy]);
+
+ const openPublishDialog = (script: ScriptItem) => {
+ setPublishTarget(script);
+ setReleaseNote("");
+ setPublishVisibility(
+ script.visibility === "private" ? "private" : "workspace",
+ );
+ };
+
+ const submitPublish = async (event: FormEvent) => {
+ event.preventDefault();
+ if (!publishTarget) return;
+ setPublishing(true);
+ try {
+ const version = await publishScriptVersion({
+ script: publishTarget,
+ releaseNote,
+ visibility: publishVisibility,
+ });
+ setVersions((items) => [
+ version,
+ ...items.filter((item) => item.versions_id !== version.versions_id),
+ ]);
+ setPublishTarget(null);
+ setPublishedVersion(version);
+ setToast({
+ tone: "success",
+ message: `${version.version_label} 稳定版本发布成功`,
+ });
+ } catch (error) {
+ setToast({
+ tone: "error",
+ message: error instanceof Error ? error.message : "稳定版本发布失败",
+ });
+ } finally {
+ setPublishing(false);
+ }
+ };
+
+ const submitCreate = async (event: FormEvent) => {
+ event.preventDefault();
+ if (!form.name.trim()) return;
+ setCreating(true);
+ try {
+ const created = await createScript(form);
+ setScripts((items) => [created, ...items]);
+ selectScript(created.script_id);
+ setCreateOpen(false);
+ setForm(initialForm);
+ setToast({
+ tone: "success",
+ message: `${created.script_name} 已创建`,
+ });
+ } catch (error) {
+ setToast({
+ tone: "error",
+ message: error instanceof Error ? error.message : "创建失败",
+ });
+ } finally {
+ setCreating(false);
+ }
+ };
+
+ const openCreateDialog = (
+ parentPath = "",
+ scriptType: ScriptType = "notebook",
+ ) => {
+ setContextMenu(null);
+ setForm({
+ ...initialForm,
+ parentPath,
+ scriptType,
+ });
+ setCreateOpen(true);
+ };
+
+ const openFolderDialog = (parentPath = "") => {
+ setContextMenu(null);
+ setFolderDialog({
+ open: true,
+ parentPath,
+ name: "",
+ busy: false,
+ });
+ };
+
+ const chooseUpload = (parentPath = "") => {
+ setContextMenu(null);
+ setUploadParentPath(parentPath);
+ uploadInputRef.current?.click();
+ };
+
+ const handleUpload = async (event: ChangeEvent) => {
+ const files = Array.from(event.target.files ?? []);
+ event.target.value = "";
+ if (files.length === 0) return;
+ setUploading(true);
+ let lastCreated: ScriptItem | null = null;
+ try {
+ for (const file of files) {
+ lastCreated = await uploadScript(file, uploadParentPath);
+ }
+ await load(true);
+ if (lastCreated) selectScript(lastCreated.script_id);
+ setToast({
+ tone: "success",
+ message: `${files.length} 个文件已上传到${
+ uploadParentPath ? ` ${uploadParentPath}` : "当前目录"
+ }`,
+ });
+ } catch (error) {
+ await load(true);
+ setToast({
+ tone: "error",
+ message: error instanceof Error ? error.message : "文件上传失败",
+ });
+ } finally {
+ setUploading(false);
+ }
+ };
+
+ const submitFolder = async (event: FormEvent) => {
+ event.preventDefault();
+ if (!folderDialog.name.trim()) return;
+ setFolderDialog((current) => ({ ...current, busy: true }));
+ try {
+ await createWorkspaceDirectory(
+ folderDialog.name.trim(),
+ folderDialog.parentPath,
+ );
+ await load(true);
+ setFolderDialog({
+ open: false,
+ parentPath: "",
+ name: "",
+ busy: false,
+ });
+ setToast({
+ tone: "success",
+ message: `${folderDialog.name.trim()} 文件夹已创建`,
+ });
+ } catch (error) {
+ setFolderDialog((current) => ({ ...current, busy: false }));
+ setToast({
+ tone: "error",
+ message: error instanceof Error ? error.message : "文件夹创建失败",
+ });
+ }
+ };
+
+ const removeScript = async (script: ScriptItem) => {
+ setContextMenu(null);
+ if (!window.confirm(`确定删除文件“${script.script_name}”吗?稳定版本会保留。`)) {
+ return;
+ }
+ if (editSessionRef.current?.script_id === script.script_id) {
+ await endEditing(false, false);
+ if (editSessionRef.current?.script_id === script.script_id) return;
+ }
+ try {
+ await deleteScript(script.script_id);
+ if (selectedIdRef.current === script.script_id) selectScript(null);
+ await load(true);
+ setToast({
+ tone: "success",
+ message: `${script.script_name} 已删除`,
+ });
+ } catch (error) {
+ setToast({
+ tone: "error",
+ message: error instanceof Error ? error.message : "文件删除失败",
+ });
+ }
+ };
+
+ const removeDirectory = async (path: string) => {
+ setContextMenu(null);
+ if (!window.confirm(`确定递归删除文件夹“${path}”及其内容吗?稳定版本会保留。`)) {
+ return;
+ }
+ const activeScript = scripts.find(
+ (item) => item.script_id === editSessionRef.current?.script_id,
+ );
+ if (
+ activeScript
+ && (
+ ownedScriptPath(activeScript) === path
+ || ownedScriptPath(activeScript).startsWith(`${path}/`)
+ )
+ ) {
+ await endEditing(false, false);
+ if (editSessionRef.current?.script_id === activeScript.script_id) return;
+ }
+ try {
+ const result = await deleteWorkspaceDirectory(path);
+ const selectedScript = scripts.find(
+ (item) => item.script_id === selectedIdRef.current,
+ );
+ if (
+ selectedScript
+ && ownedScriptPath(selectedScript).startsWith(`${path}/`)
+ ) {
+ selectScript(null);
+ }
+ await load(true);
+ setToast({
+ tone: "success",
+ message: `${path} 已删除(含 ${result.deleted_scripts} 个脚本)`,
+ });
+ } catch (error) {
+ setToast({
+ tone: "error",
+ message: error instanceof Error ? error.message : "文件夹删除失败",
+ });
+ }
+ };
+
+ const showContextMenu = (
+ event: ReactMouseEvent,
+ target: Omit,
+ ) => {
+ event.preventDefault();
+ event.stopPropagation();
+ const width = 188;
+ const height = target.kind === "file" ? 92 : 190;
+ setContextMenu({
+ ...target,
+ x: Math.min(event.clientX, window.innerWidth - width - 8),
+ y: Math.min(event.clientY, window.innerHeight - height - 8),
+ });
+ };
+
+ return (
+
+
+
+
+
+
+ {activePage === "scripts" ? (
+
+
+
+
+ {selected ? (
+ void openScriptEditor(selected)}
+ onEndEditing={() => void endEditing()}
+ onClose={() => {
+ if (
+ editSessionRef.current?.script_id === selected.script_id
+ ) {
+ void endEditing(true);
+ } else {
+ selectScript(null);
+ }
+ }}
+ onPublish={() => openPublishDialog(selected)}
+ onInfo={setToast}
+ />
+ ) : (
+
+
+
+
+
构建脚本工作台
+
创建你的第一个模型脚本
+
+ 通过 Notebook 完成数据探索,或使用 Python
+ 脚本构建可调度的处理任务。
+
+
+
+ )}
+
+
+ ) : activePage === "schedules" ? (
+
+ ) : activePage === "system" ? (
+
+ ) : (
+ {
+ navigate(pathForPage(page));
+ }}
+ />
+ )}
+
+
+ {createOpen && (
+
+
+
+
+ 工作副本
+
新建构建脚本
+
+
+
+
+
+
+ )}
+
+ {folderDialog.open && (
+
+
+
+
+ WORKSPACE
+
新建文件夹
+
+
+
+
+
+
+ )}
+
+ {contextMenu && (
+
event.stopPropagation()}
+ >
+ {contextMenu.kind === "file" && contextMenu.script ? (
+ <>
+
+
+ >
+ ) : (
+ <>
+
+
+
+
+ {contextMenu.kind === "directory" && (
+ <>
+
+
+ >
+ )}
+ >
+ )}
+
+ )}
+
+ {publishTarget && (
+
+
+
+
+ 不可变制品
+
发布稳定版本
+
+
+
+
+
+
+ )}
+
+ {publishedVersion && (
+
+
+
+
+
+ STABLE VERSION
+ 稳定版本发布成功
+
+ {publishedVersion.version_label} 已成为不可变制品,
+ 后续调度将通过 versions_id 引用它。
+
+
+ versions_id
+ {publishedVersion.versions_id}
+
+
+
+
+
+ )}
+
+ {toast && (
+
+
+
+
+ {toast.message}
+
+ )}
+
+ );
+}
+
+function WorkspaceTreeGroup({
+ title,
+ scripts,
+ directories,
+ selectedId,
+ onSelect,
+ onContextMenu,
+ readOnly = false,
+}: {
+ title: string;
+ scripts: ScriptItem[];
+ directories: WorkspaceDirectory[];
+ selectedId: string | null;
+ onSelect: (id: string) => void;
+ onContextMenu?: (
+ event: ReactMouseEvent,
+ target: Omit,
+ ) => void;
+ readOnly?: boolean;
+}) {
+ const [open, setOpen] = useState(true);
+ return (
+
+
+ {open && (
+
+
+ {scripts.length === 0 && directories.length === 0 && (
+
+ {readOnly ? "暂无文件" : "右键此目录即可新建或上传"}
+
+ )}
+
+ )}
+
+ );
+}
+
+function WorkspaceTreeItems({
+ path,
+ depth,
+ scripts,
+ directories,
+ selectedId,
+ onSelect,
+ onContextMenu,
+}: {
+ path: string;
+ depth: number;
+ scripts: ScriptItem[];
+ directories: WorkspaceDirectory[];
+ selectedId: string | null;
+ onSelect: (id: string) => void;
+ onContextMenu?: (
+ event: ReactMouseEvent,
+ target: Omit,
+ ) => void;
+}) {
+ const childDirectories = directories.filter(
+ (item) => item.parent_path === path,
+ );
+ const childScripts = scripts.filter(
+ (item) => parentOf(ownedScriptPath(item)) === path,
+ );
+ return (
+ <>
+ {childDirectories.map((directory) => (
+
+ ))}
+ {childScripts.map((item) => (
+
+ ))}
+ >
+ );
+}
+
+function DirectoryBranch({
+ directory,
+ depth,
+ scripts,
+ directories,
+ selectedId,
+ onSelect,
+ onContextMenu,
+}: {
+ directory: WorkspaceDirectory;
+ depth: number;
+ scripts: ScriptItem[];
+ directories: WorkspaceDirectory[];
+ selectedId: string | null;
+ onSelect: (id: string) => void;
+ onContextMenu?: (
+ event: ReactMouseEvent,
+ target: Omit,
+ ) => void;
+}) {
+ const [open, setOpen] = useState(true);
+ return (
+
+
+ {open && (
+
+ )}
+
+ );
+}
+
+function ScriptWorkspace({
+ script,
+ editSession,
+ jupyterUrl,
+ editBusy,
+ openError,
+ latestVersion,
+ versionsLoading,
+ onOpenEditor,
+ onEndEditing,
+ onClose,
+ onPublish,
+ onInfo,
+}: {
+ script: ScriptItem;
+ editSession: ActiveEditSession | null;
+ jupyterUrl: string | null;
+ editBusy: boolean;
+ openError: string | null;
+ latestVersion: StableVersion | null;
+ versionsLoading: boolean;
+ onOpenEditor: () => void;
+ onEndEditing: () => void;
+ onClose: () => void;
+ onPublish: () => void;
+ onInfo: (toast: ToastState) => void;
+}) {
+ const isNotebook = script.script_type === "notebook";
+ const isEditing = editSession?.session_status === "active";
+ return (
+ <>
+
+
+
+
+
+ {script.script_name}
+
+
+
+
+
+
+
+
+
+
+ 工作副本
+
+ {script.script_name}
+
+
+ {isEditing ? (
+
+ ) : (
+
+ )}
+
+
+
+ {isEditing
+ ? isNotebook
+ ? "Demo 无锁模式 · Kernel 已连接"
+ : "Demo 无锁模式 · 编辑中"
+ : latestVersion
+ ? `最新 ${latestVersion.version_label}`
+ : "工作副本已就绪"}
+
+
+
+
+
+ {isEditing && jupyterUrl ? (
+
+
+
+
+ Workspace Jupyter Server
+
+
+ {isNotebook ? "Notebook Session · Python 3 Kernel" : "Jupyter 文本编辑器"}
+
+
+ Runtime {editSession.runtime_id.slice(-8)}
+
+
+
+ ) : isNotebook ? (
+
+
+ {openError
+ ?
+ : }
+
+
+ {openError ? "Notebook 打开失败" : "正在打开 Notebook"}
+
+
+ {openError
+ ? openError
+ : "正在获取编辑锁并连接 Workspace Jupyter Server…"}
+
+ {openError && (
+
+ )}
+
+ ) : (
+
+
+
+
+ PYTHON SCRIPT
+
+
{script.script_name}
+
{script.relative_path}
+
+
+
+
+
+
+ 脚本类型
+ Python
+
+
+ 可见范围
+
+ {script.visibility === "workspace"
+ ? "Workspace"
+ : script.visibility === "public" ? "公开" : "私有"}
+
+
+
+ 文件大小
+ {formatBytes(script.size_bytes)}
+
+
+ 最近更新
+ {formatTime(script.updated_at)}
+
+
+
+
+
+
+
+
+
+
+
Python 预览
+
{isEditing ? "Jupyter 中可编辑" : "平台只读预览"}
+
+
+
+
+
+
+
+ {isEditing ? "Demo 无锁编辑会话已连接" : "工作副本已同步"}
+
+ SHA-256 {shortHash(script.content_hash)}
+
+ 稳定版本
+ {versionsLoading
+ ? "加载中"
+ : latestVersion
+ ? `${latestVersion.version_label} · ${latestVersion.versions_id}`
+ : "尚未发布"}
+
+
+
+ )}
+
+ >
+ );
+}
+
+function PythonPreview() {
+ return (
+
+
1
2
3
4
5
6
7
8
9
+
+ """模型实验开发平台构建脚本。"""
+ {"\n\n"}def main() -> None:
+ {"\n"} print("Hello, Model Platform!")
+ {"\n\n\n"}if __name__ == "__main__":
+ {"\n"} main()
+
+
+ );
+}
diff --git a/frontend/app/features/schedules/SchedulePage.tsx b/frontend/app/features/schedules/SchedulePage.tsx
new file mode 100644
index 0000000..ff07ba0
--- /dev/null
+++ b/frontend/app/features/schedules/SchedulePage.tsx
@@ -0,0 +1,1951 @@
+import {
+ DragEvent,
+ FormEvent,
+ MouseEvent as ReactMouseEvent,
+ PointerEvent as ReactPointerEvent,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+
+import {
+ ApiRequestError,
+ createSchedule,
+ createScheduleEdge,
+ createScheduleNode,
+ deleteSchedule,
+ deleteScheduleEdge,
+ deleteScheduleNode,
+ hideScheduleArtifact,
+ getSchedule,
+ listScheduleArtifacts,
+ listScheduleRuns,
+ listSchedules,
+ previewCron,
+ runScheduleNow,
+ updateSchedule,
+ updateScheduleNode,
+ validateSchedule,
+ type CronPreview,
+ type Schedule,
+ type ScheduleArtifact,
+ type ScheduleEdge,
+ type ScheduleNode,
+ type ScheduleRunSummary,
+} from "../../services/api";
+import Icon from "../../components/Icon";
+import "../../styles/schedule.css";
+
+
+type Notice = {
+ tone: "success" | "error" | "info";
+ message: string;
+};
+
+type ScheduleForm = {
+ scheduleName: string;
+ description: string;
+ triggerType: "manual" | "cron" | "api";
+ cronExpression: string;
+ timezone: string;
+ enabled: boolean;
+ maxConcurrency: string;
+ failurePolicy: "stop" | "continue";
+};
+
+type NodeForm = {
+ nodeName: string;
+ timeoutSeconds: string;
+ retryCount: string;
+ retryIntervalSec: string;
+ argumentsJson: string;
+ envRefsJson: string;
+};
+
+type DragState = {
+ nodeId: string;
+ pointerId: number;
+ startClientX: number;
+ startClientY: number;
+ originX: number;
+ originY: number;
+ moved: boolean;
+};
+
+type NodePositionDraft = {
+ position_x: number;
+ position_y: number;
+};
+
+type ScheduleContextMenu =
+ | { kind: "schedule-list"; x: number; y: number }
+ | { kind: "schedule"; x: number; y: number; schedule: Schedule }
+ | { kind: "artifact"; x: number; y: number; artifact: ScheduleArtifact }
+ | { kind: "node"; x: number; y: number; node: ScheduleNode }
+ | { kind: "edge"; x: number; y: number; edge: ScheduleEdge };
+
+type ScheduleContextMenuTarget =
+ | { kind: "schedule-list" }
+ | { kind: "schedule"; schedule: Schedule }
+ | { kind: "artifact"; artifact: ScheduleArtifact }
+ | { kind: "node"; node: ScheduleNode }
+ | { kind: "edge"; edge: ScheduleEdge };
+
+const EMPTY_SCHEDULE_FORM: ScheduleForm = {
+ scheduleName: "",
+ description: "",
+ triggerType: "manual",
+ cronExpression: "0 9 * * *",
+ timezone: "Asia/Shanghai",
+ enabled: false,
+ maxConcurrency: "1",
+ failurePolicy: "stop",
+};
+
+const EMPTY_NODE_FORM: NodeForm = {
+ nodeName: "",
+ timeoutSeconds: "600",
+ retryCount: "0",
+ retryIntervalSec: "5",
+ argumentsJson: "{}",
+ envRefsJson: "{}",
+};
+
+const CANVAS_WIDTH = 1400;
+const CANVAS_HEIGHT = 860;
+const NODE_WIDTH = 218;
+const NODE_HEIGHT = 104;
+const ARTIFACT_MIME = "application/x-model-platform-version";
+
+
+function formatTime(value: string | null): string {
+ if (!value) return "尚未执行";
+ return new Intl.DateTimeFormat("zh-CN", {
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ hour12: false,
+ }).format(new Date(value));
+}
+
+function shortHash(value: string): string {
+ return value ? `${value.slice(0, 7)}…${value.slice(-5)}` : "—";
+}
+
+const RUN_STATUS_LABELS: Record = {
+ queued: "排队中",
+ running: "运行中",
+ succeeded: "成功",
+ failed: "失败",
+ cancelled: "已取消",
+ timed_out: "已超时",
+};
+
+function formatDuration(value: number | null): string {
+ if (value === null) return "—";
+ if (value < 1000) return `${value} ms`;
+ if (value < 60_000) return `${(value / 1000).toFixed(1)} s`;
+ return `${Math.floor(value / 60_000)}m ${Math.round((value % 60_000) / 1000)}s`;
+}
+
+function parseObject(text: string, label: string): Record {
+ let value: unknown;
+ try {
+ value = JSON.parse(text);
+ } catch {
+ throw new Error(`${label}必须是合法 JSON`);
+ }
+ if (!value || Array.isArray(value) || typeof value !== "object") {
+ throw new Error(`${label}必须是 JSON 对象`);
+ }
+ return value as Record;
+}
+
+function scheduleToForm(schedule: Schedule): ScheduleForm {
+ return {
+ scheduleName: schedule.schedule_name,
+ description: schedule.description ?? "",
+ triggerType: schedule.trigger_type,
+ cronExpression: schedule.cron_expression ?? "0 9 * * *",
+ timezone: schedule.timezone,
+ enabled: schedule.enabled,
+ maxConcurrency: String(schedule.max_concurrency),
+ failurePolicy: schedule.failure_policy,
+ };
+}
+
+function nodeToForm(node: ScheduleNode): NodeForm {
+ return {
+ nodeName: node.node_name,
+ timeoutSeconds: String(node.timeout_seconds),
+ retryCount: String(node.retry_count),
+ retryIntervalSec: String(node.retry_interval_sec),
+ argumentsJson: JSON.stringify(node.arguments_json ?? {}, null, 2),
+ envRefsJson: JSON.stringify(node.env_refs_json ?? {}, null, 2),
+ };
+}
+
+function artifactNodeKey(
+ artifact: ScheduleArtifact,
+ schedule: Schedule,
+): string {
+ const ascii = artifact.script_name
+ .replace(/\.[^.]+$/, "")
+ .replace(/[^A-Za-z0-9_-]+/g, "_")
+ .replace(/^([^A-Za-z])/, "n_$1")
+ .replace(/^_+|_+$/g, "")
+ .slice(0, 48);
+ const base = ascii || `node_${artifact.versions_id.slice(-6).toLowerCase()}`;
+ const existing = new Set(schedule.nodes.map((item) => item.node_key));
+ if (!existing.has(base)) return base;
+ let index = 2;
+ while (existing.has(`${base}_${index}`)) index += 1;
+ return `${base}_${index}`.slice(0, 64);
+}
+
+function edgePath(
+ source: ScheduleNode,
+ target: ScheduleNode,
+): string {
+ const x1 = source.position_x + NODE_WIDTH;
+ const y1 = source.position_y + NODE_HEIGHT / 2;
+ const x2 = target.position_x;
+ const y2 = target.position_y + NODE_HEIGHT / 2;
+ const curve = Math.max(70, Math.abs(x2 - x1) * 0.45);
+ return `M ${x1} ${y1} C ${x1 + curve} ${y1}, ${x2 - curve} ${y2}, ${x2} ${y2}`;
+}
+
+
+export default function SchedulePage({
+ onNotify,
+ onConnectionChange,
+}: {
+ onNotify: (notice: Notice) => void;
+ onConnectionChange: (online: boolean) => void;
+}) {
+ const [schedules, setSchedules] = useState([]);
+ const [artifacts, setArtifacts] = useState([]);
+ const [schedule, setSchedule] = useState(null);
+ const scheduleRef = useRef(null);
+ const [selectedNodeId, setSelectedNodeId] = useState(null);
+ const [selectedEdgeId, setSelectedEdgeId] = useState(null);
+ const [linkSourceId, setLinkSourceId] = useState(null);
+ const [scheduleKeyword, setScheduleKeyword] = useState("");
+ const [artifactKeyword, setArtifactKeyword] = useState("");
+ const [loading, setLoading] = useState(true);
+ const [busy, setBusy] = useState(null);
+ const [createDialogOpen, setCreateDialogOpen] = useState(false);
+ const [newScheduleName, setNewScheduleName] = useState("");
+ const [contextMenu, setContextMenu] =
+ useState(null);
+ const [scheduleForm, setScheduleForm] =
+ useState(EMPTY_SCHEDULE_FORM);
+ const [nodeForm, setNodeForm] = useState(EMPTY_NODE_FORM);
+ const [cronResult, setCronResult] = useState(null);
+ const [runs, setRuns] = useState([]);
+ const [runsLoading, setRunsLoading] = useState(false);
+ const canvasRef = useRef(null);
+ const dragRef = useRef(null);
+ const positionDraftsRef = useRef>({});
+ const [positionDraftCount, setPositionDraftCount] = useState(0);
+
+ const selectedNode = schedule?.nodes.find(
+ (item) => item.node_id === selectedNodeId,
+ ) ?? null;
+ const selectedEdge = schedule?.edges.find(
+ (item) => item.edge_id === selectedEdgeId,
+ ) ?? null;
+
+ useEffect(() => {
+ scheduleRef.current = schedule;
+ }, [schedule]);
+
+ useEffect(() => {
+ if (schedule) setScheduleForm(scheduleToForm(schedule));
+ }, [schedule?.schedule_id, schedule?.workflow_version]);
+
+ useEffect(() => {
+ setNodeForm(selectedNode ? nodeToForm(selectedNode) : EMPTY_NODE_FORM);
+ }, [selectedNode?.node_id, selectedNode?.updated_at]);
+
+ useEffect(() => {
+ if (!contextMenu) return undefined;
+ const close = (): void => setContextMenu(null);
+ const onKeyDown = (event: KeyboardEvent): void => {
+ if (event.key === "Escape") close();
+ };
+ window.addEventListener("pointerdown", close);
+ window.addEventListener("blur", close);
+ window.addEventListener("resize", close);
+ window.addEventListener("scroll", close, true);
+ window.addEventListener("keydown", onKeyDown);
+ return () => {
+ window.removeEventListener("pointerdown", close);
+ window.removeEventListener("blur", close);
+ window.removeEventListener("resize", close);
+ window.removeEventListener("scroll", close, true);
+ window.removeEventListener("keydown", onKeyDown);
+ };
+ }, [contextMenu]);
+
+ const clearPositionDrafts = (): void => {
+ positionDraftsRef.current = {};
+ setPositionDraftCount(0);
+ };
+
+ const openContextMenu = (
+ event: ReactMouseEvent,
+ target: ScheduleContextMenuTarget,
+ ): void => {
+ event.preventDefault();
+ event.stopPropagation();
+ const menuWidth = 176;
+ const menuHeight = target.kind === "schedule" ? 132 : 48;
+ setContextMenu({
+ ...target,
+ x: Math.min(event.clientX, window.innerWidth - menuWidth - 8),
+ y: Math.min(event.clientY, window.innerHeight - menuHeight - 8),
+ } as ScheduleContextMenu);
+ };
+
+ const applyPositionDrafts = (serverSchedule: Schedule): Schedule => {
+ const drafts = positionDraftsRef.current;
+ if (Object.keys(drafts).length === 0) return serverSchedule;
+ return {
+ ...serverSchedule,
+ nodes: serverSchedule.nodes.map((node) => {
+ const draft = drafts[node.node_id];
+ return draft ? { ...node, ...draft } : node;
+ }),
+ };
+ };
+
+ const refreshRuns = async (
+ scheduleId: string,
+ showLoading = false,
+ ): Promise => {
+ if (showLoading) setRunsLoading(true);
+ try {
+ const items = await listScheduleRuns({
+ scheduleId,
+ limit: 20,
+ });
+ if (scheduleRef.current?.schedule_id === scheduleId) {
+ setRuns(items);
+ }
+ onConnectionChange(true);
+ } catch (error) {
+ if (showLoading) {
+ await handleError(error, "运行记录加载失败");
+ }
+ } finally {
+ if (showLoading) setRunsLoading(false);
+ }
+ };
+
+ const refreshLists = async (
+ preferredScheduleId?: string | null,
+ ): Promise => {
+ const [scheduleItems, artifactItems] = await Promise.all([
+ listSchedules(),
+ listScheduleArtifacts(),
+ ]);
+ setSchedules(scheduleItems);
+ setArtifacts(artifactItems);
+ const targetId = preferredScheduleId
+ ?? scheduleRef.current?.schedule_id
+ ?? scheduleItems[0]?.schedule_id
+ ?? null;
+ if (!targetId) {
+ setSchedule(null);
+ return;
+ }
+ const detail = await getSchedule(targetId);
+ setSchedule(applyPositionDrafts(detail));
+ };
+
+ useEffect(() => {
+ let cancelled = false;
+ setLoading(true);
+ Promise.all([listSchedules(), listScheduleArtifacts()])
+ .then(async ([scheduleItems, artifactItems]) => {
+ if (cancelled) return;
+ setSchedules(scheduleItems);
+ setArtifacts(artifactItems);
+ if (scheduleItems[0]) {
+ const detail = await getSchedule(scheduleItems[0].schedule_id);
+ if (!cancelled) setSchedule(applyPositionDrafts(detail));
+ }
+ onConnectionChange(true);
+ })
+ .catch((error: unknown) => {
+ if (cancelled) return;
+ onConnectionChange(false);
+ onNotify({
+ tone: "error",
+ message: error instanceof Error ? error.message : "调度数据加载失败",
+ });
+ })
+ .finally(() => {
+ if (!cancelled) setLoading(false);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ useEffect(() => {
+ const scheduleId = schedule?.schedule_id;
+ if (!scheduleId) {
+ setRuns([]);
+ return;
+ }
+ let cancelled = false;
+ setRunsLoading(true);
+ listScheduleRuns({ scheduleId, limit: 20 })
+ .then((items) => {
+ if (!cancelled) setRuns(items);
+ })
+ .catch((error: unknown) => {
+ if (!cancelled) {
+ onNotify({
+ tone: "error",
+ message: error instanceof Error ? error.message : "运行记录加载失败",
+ });
+ }
+ })
+ .finally(() => {
+ if (!cancelled) setRunsLoading(false);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [schedule?.schedule_id]);
+
+ const hasActiveRuns = runs.some(
+ (item) => item.run_status === "queued" || item.run_status === "running",
+ );
+
+ useEffect(() => {
+ const scheduleId = schedule?.schedule_id;
+ if (!scheduleId || !hasActiveRuns) return;
+ const timer = window.setInterval(() => {
+ void refreshRuns(scheduleId);
+ }, 1500);
+ return () => window.clearInterval(timer);
+ }, [schedule?.schedule_id, hasActiveRuns]);
+
+ const handleError = async (
+ error: unknown,
+ fallback: string,
+ ): Promise => {
+ if (error instanceof ApiRequestError && error.status === 412) {
+ const currentId = scheduleRef.current?.schedule_id;
+ if (currentId) {
+ withSuppressedError(() => refreshLists(currentId));
+ }
+ onNotify({
+ tone: "error",
+ message: "调度已被其他操作更新,已重新加载最新版本",
+ });
+ return;
+ }
+ onNotify({
+ tone: "error",
+ message: error instanceof Error ? error.message : fallback,
+ });
+ };
+
+ const withMutation = async (
+ label: string,
+ action: () => Promise,
+ successMessage: string,
+ ): Promise => {
+ if (busy) return null;
+ setBusy(label);
+ try {
+ const serverUpdated = await action();
+ const validNodeIds = new Set(
+ serverUpdated.nodes.map((node) => node.node_id),
+ );
+ positionDraftsRef.current = Object.fromEntries(
+ Object.entries(positionDraftsRef.current).filter(([nodeId]) => (
+ validNodeIds.has(nodeId)
+ )),
+ );
+ setPositionDraftCount(Object.keys(positionDraftsRef.current).length);
+ const updated = applyPositionDrafts(serverUpdated);
+ setSchedule(updated);
+ setSchedules((current) => {
+ const summary = { ...updated, nodes: [], edges: [] };
+ const index = current.findIndex(
+ (item) => item.schedule_id === updated.schedule_id,
+ );
+ if (index < 0) return [summary, ...current];
+ return current.map((item) => (
+ item.schedule_id === updated.schedule_id ? summary : item
+ ));
+ });
+ onNotify({ tone: "success", message: successMessage });
+ return updated;
+ } catch (error) {
+ await handleError(error, `${successMessage}失败`);
+ return null;
+ } finally {
+ setBusy(null);
+ }
+ };
+
+ const chooseSchedule = async (scheduleId: string): Promise => {
+ if (scheduleId === schedule?.schedule_id || busy) return;
+ setBusy("load-schedule");
+ clearPositionDrafts();
+ setSelectedNodeId(null);
+ setSelectedEdgeId(null);
+ setLinkSourceId(null);
+ setCronResult(null);
+ try {
+ setSchedule(await getSchedule(scheduleId));
+ onConnectionChange(true);
+ } catch (error) {
+ await handleError(error, "调度详情加载失败");
+ } finally {
+ setBusy(null);
+ }
+ };
+
+ const openCreateScheduleDialog = (): void => {
+ if (busy) return;
+ setContextMenu(null);
+ setNewScheduleName(`新建调度 ${schedules.length + 1}`);
+ setCreateDialogOpen(true);
+ };
+
+ const addSchedule = async (event: FormEvent): Promise => {
+ event.preventDefault();
+ const scheduleName = newScheduleName.trim();
+ if (busy || !scheduleName) return;
+ setBusy("create-schedule");
+ try {
+ const created = await createSchedule({
+ schedule_name: scheduleName,
+ description: "在画布中拖入稳定版本并配置执行顺序",
+ trigger_type: "manual",
+ timezone: "Asia/Shanghai",
+ enabled: false,
+ });
+ setSchedules((current) => [created, ...current]);
+ setSchedule(created);
+ clearPositionDrafts();
+ setSelectedNodeId(null);
+ setCreateDialogOpen(false);
+ setNewScheduleName("");
+ onNotify({ tone: "success", message: "调度方案已创建" });
+ } catch (error) {
+ await handleError(error, "创建调度失败");
+ } finally {
+ setBusy(null);
+ }
+ };
+
+ const removeSchedule = async (target?: Schedule): Promise => {
+ const selectedSchedule = target ?? schedule;
+ if (!selectedSchedule || busy) return;
+ setContextMenu(null);
+ if (!window.confirm(`确定删除调度“${selectedSchedule.schedule_name}”吗?`)) return;
+ setBusy("delete-schedule");
+ try {
+ await deleteSchedule(
+ selectedSchedule.schedule_id,
+ selectedSchedule.workflow_version,
+ );
+ const remaining = schedules.filter(
+ (item) => item.schedule_id !== selectedSchedule.schedule_id,
+ );
+ setSchedules(remaining);
+ if (schedule?.schedule_id === selectedSchedule.schedule_id) {
+ setSchedule(null);
+ clearPositionDrafts();
+ setSelectedNodeId(null);
+ setSelectedEdgeId(null);
+ if (remaining[0]) {
+ setSchedule(await getSchedule(remaining[0].schedule_id));
+ }
+ }
+ onNotify({ tone: "success", message: "调度方案已删除" });
+ } catch (error) {
+ await handleError(error, "删除调度失败");
+ } finally {
+ setBusy(null);
+ }
+ };
+
+ const renameSchedule = async (target: Schedule): Promise => {
+ if (busy) return;
+ setContextMenu(null);
+ const scheduleName = window.prompt("请输入新的调度方案名称", target.schedule_name)?.trim();
+ if (!scheduleName || scheduleName === target.schedule_name) return;
+ setBusy("rename-schedule");
+ try {
+ const updated = await updateSchedule(target.schedule_id, {
+ workflow_version: target.workflow_version,
+ schedule_name: scheduleName,
+ });
+ setSchedules((current) => current.map((item) => (
+ item.schedule_id === updated.schedule_id
+ ? { ...updated, nodes: [], edges: [] }
+ : item
+ )));
+ if (schedule?.schedule_id === updated.schedule_id) setSchedule(updated);
+ onNotify({ tone: "success", message: "调度方案已改名" });
+ } catch (error) {
+ await handleError(error, "调度方案改名失败");
+ } finally {
+ setBusy(null);
+ }
+ };
+
+ const removeArtifact = async (
+ artifact: ScheduleArtifact,
+ ): Promise => {
+ if (busy) return;
+ setContextMenu(null);
+ if (
+ !window.confirm(
+ `确定将“${artifact.script_name} ${artifact.version_label}”移出调度列表吗?\n`
+ + "稳定版本本身和历史运行记录不会被删除。",
+ )
+ ) return;
+ setBusy("delete-artifact");
+ try {
+ await hideScheduleArtifact(artifact.versions_id);
+ setArtifacts((current) => current.filter(
+ (item) => item.versions_id !== artifact.versions_id,
+ ));
+ onNotify({
+ tone: "success",
+ message: "已移出调度列表,稳定版本和历史记录保持不变",
+ });
+ } catch (error) {
+ await handleError(error, "移出调度列表失败");
+ } finally {
+ setBusy(null);
+ }
+ };
+
+ const saveSchedule = async (): Promise => {
+ if (!schedule || busy) return;
+ const maxConcurrency = Number(scheduleForm.maxConcurrency);
+ if (!scheduleForm.scheduleName.trim()) {
+ onNotify({ tone: "error", message: "调度名称不能为空" });
+ return;
+ }
+ if (!Number.isInteger(maxConcurrency) || maxConcurrency < 1) {
+ onNotify({ tone: "error", message: "最大并发数必须是正整数" });
+ return;
+ }
+ setBusy("save-schedule");
+ let updated = scheduleRef.current ?? schedule;
+ try {
+ for (const [nodeId, position] of Object.entries(
+ positionDraftsRef.current,
+ )) {
+ updated = await updateScheduleNode(updated.schedule_id, nodeId, {
+ workflow_version: updated.workflow_version,
+ position_x: position.position_x,
+ position_y: position.position_y,
+ });
+ }
+ updated = await updateSchedule(updated.schedule_id, {
+ workflow_version: updated.workflow_version,
+ schedule_name: scheduleForm.scheduleName.trim(),
+ description: scheduleForm.description.trim() || null,
+ trigger_type: scheduleForm.triggerType,
+ cron_expression: scheduleForm.triggerType === "cron"
+ ? scheduleForm.cronExpression.trim()
+ : null,
+ timezone: scheduleForm.timezone.trim(),
+ enabled: scheduleForm.enabled,
+ max_concurrency: maxConcurrency,
+ failure_policy: scheduleForm.failurePolicy,
+ });
+ clearPositionDrafts();
+ setSchedule(updated);
+ setSchedules((current) => current.map((item) => (
+ item.schedule_id === updated.schedule_id
+ ? { ...updated, nodes: [], edges: [] }
+ : item
+ )));
+ onNotify({ tone: "success", message: "调度配置已保存" });
+ } catch (error) {
+ const localDraft = applyPositionDrafts(updated);
+ scheduleRef.current = localDraft;
+ setSchedule(localDraft);
+ await handleError(error, "调度配置保存失败");
+ } finally {
+ setBusy(null);
+ }
+ };
+
+ const runCronPreview = async (): Promise => {
+ if (scheduleForm.triggerType !== "cron") return;
+ if (busy) return;
+ setBusy("cron-preview");
+ try {
+ const result = await previewCron({
+ cron_expression: scheduleForm.cronExpression.trim(),
+ timezone: scheduleForm.timezone.trim(),
+ count: 5,
+ });
+ setCronResult(result);
+ onNotify({ tone: "success", message: "Cron 表达式校验通过" });
+ } catch (error) {
+ setCronResult(null);
+ await handleError(error, "Cron 预览失败");
+ } finally {
+ setBusy(null);
+ }
+ };
+
+ const runNow = async (): Promise => {
+ if (!schedule || busy) return;
+ if (positionDraftCount > 0) {
+ onNotify({
+ tone: "info",
+ message: "还有节点位置未保存,请先点击“保存配置”再运行",
+ });
+ return;
+ }
+ if (!schedule.dag_validation.valid || schedule.nodes.length === 0) {
+ onNotify({
+ tone: "error",
+ message: "当前调度必须包含有效的非空 DAG 才能运行",
+ });
+ return;
+ }
+ setBusy("run-now");
+ try {
+ const created = await runScheduleNow(schedule.schedule_id);
+ setRuns((current) => [
+ created,
+ ...current.filter((item) => item.run_id !== created.run_id),
+ ].slice(0, 20));
+ setSchedule((current) => (
+ current ? { ...current, last_run_at: created.queued_at } : current
+ ));
+ setSchedules((current) => current.map((item) => (
+ item.schedule_id === schedule.schedule_id
+ ? { ...item, last_run_at: created.queued_at }
+ : item
+ )));
+ onNotify({
+ tone: "success",
+ message: `运行 ${created.run_id.slice(-8)} 已进入队列`,
+ });
+ } catch (error) {
+ await handleError(error, "立即运行失败");
+ } finally {
+ setBusy(null);
+ }
+ };
+
+ const addArtifactAt = async (
+ artifact: ScheduleArtifact,
+ positionX: number,
+ positionY: number,
+ ): Promise => {
+ if (!schedule) {
+ onNotify({ tone: "info", message: "请先新建或选择一个调度方案" });
+ return;
+ }
+ const nodeKey = artifactNodeKey(artifact, schedule);
+ const updated = await withMutation(
+ "add-node",
+ () => createScheduleNode(schedule.schedule_id, {
+ workflow_version: schedule.workflow_version,
+ node_key: nodeKey,
+ node_name: artifact.script_name,
+ versions_id: artifact.versions_id,
+ timeout_seconds: 600,
+ retry_count: 0,
+ retry_interval_sec: 5,
+ position_x: Math.max(20, Math.round(positionX)),
+ position_y: Math.max(20, Math.round(positionY)),
+ arguments_json: {},
+ env_refs_json: {},
+ }),
+ `${artifact.script_name} 已加入画布`,
+ );
+ if (updated) {
+ const created = updated.nodes.find((item) => item.node_key === nodeKey);
+ setSelectedNodeId(created?.node_id ?? null);
+ }
+ };
+
+ const onArtifactDragStart = (
+ event: DragEvent,
+ artifact: ScheduleArtifact,
+ ): void => {
+ event.dataTransfer.effectAllowed = "copy";
+ event.dataTransfer.setData(ARTIFACT_MIME, artifact.versions_id);
+ event.dataTransfer.setData("text/plain", artifact.versions_id);
+ };
+
+ const onCanvasDrop = (event: DragEvent): void => {
+ event.preventDefault();
+ const versionsId = event.dataTransfer.getData(ARTIFACT_MIME)
+ || event.dataTransfer.getData("text/plain");
+ const artifact = artifacts.find((item) => item.versions_id === versionsId);
+ if (!artifact || !canvasRef.current) return;
+ const rect = canvasRef.current.getBoundingClientRect();
+ const positionX = event.clientX - rect.left
+ + canvasRef.current.scrollLeft - NODE_WIDTH / 2;
+ const positionY = event.clientY - rect.top
+ + canvasRef.current.scrollTop - NODE_HEIGHT / 2;
+ void addArtifactAt(artifact, positionX, positionY);
+ };
+
+ const startNodeDrag = (
+ event: ReactPointerEvent,
+ node: ScheduleNode,
+ ): void => {
+ if (event.button !== 0 || busy || linkSourceId) return;
+ const target = event.target as HTMLElement;
+ if (target.closest("button")) return;
+ event.currentTarget.setPointerCapture(event.pointerId);
+ dragRef.current = {
+ nodeId: node.node_id,
+ pointerId: event.pointerId,
+ startClientX: event.clientX,
+ startClientY: event.clientY,
+ originX: node.position_x,
+ originY: node.position_y,
+ moved: false,
+ };
+ setSelectedNodeId(node.node_id);
+ setSelectedEdgeId(null);
+ };
+
+ const moveNode = (
+ event: ReactPointerEvent,
+ ): void => {
+ const drag = dragRef.current;
+ if (!drag || drag.pointerId !== event.pointerId) return;
+ const deltaX = event.clientX - drag.startClientX;
+ const deltaY = event.clientY - drag.startClientY;
+ if (Math.abs(deltaX) + Math.abs(deltaY) > 3) drag.moved = true;
+ setSchedule((current) => {
+ if (!current) return current;
+ const next = {
+ ...current,
+ nodes: current.nodes.map((item) => (
+ item.node_id === drag.nodeId
+ ? {
+ ...item,
+ position_x: Math.max(10, drag.originX + deltaX),
+ position_y: Math.max(10, drag.originY + deltaY),
+ }
+ : item
+ )),
+ };
+ scheduleRef.current = next;
+ return next;
+ });
+ };
+
+ const finishNodeDrag = (
+ event: ReactPointerEvent,
+ ): void => {
+ const drag = dragRef.current;
+ if (!drag || drag.pointerId !== event.pointerId) return;
+ dragRef.current = null;
+ if (!drag.moved) return;
+ const current = scheduleRef.current;
+ const node = current?.nodes.find((item) => item.node_id === drag.nodeId);
+ if (!current || !node) return;
+ const position = {
+ position_x: Math.round(node.position_x),
+ position_y: Math.round(node.position_y),
+ };
+ positionDraftsRef.current = {
+ ...positionDraftsRef.current,
+ [node.node_id]: position,
+ };
+ setPositionDraftCount(Object.keys(positionDraftsRef.current).length);
+ setSchedule((value) => {
+ if (!value) return value;
+ const next = {
+ ...value,
+ nodes: value.nodes.map((item) => (
+ item.node_id === node.node_id ? { ...item, ...position } : item
+ )),
+ };
+ scheduleRef.current = next;
+ return next;
+ });
+ };
+
+ const connectTo = async (targetNodeId: string): Promise => {
+ if (!schedule || !linkSourceId || busy) return;
+ if (linkSourceId === targetNodeId) {
+ setLinkSourceId(null);
+ onNotify({ tone: "info", message: "已取消连线" });
+ return;
+ }
+ const sourceId = linkSourceId;
+ setLinkSourceId(null);
+ await withMutation(
+ "create-edge",
+ () => createScheduleEdge(schedule.schedule_id, {
+ workflow_version: schedule.workflow_version,
+ source_node_id: sourceId,
+ target_node_id: targetNodeId,
+ }),
+ "节点连线已创建",
+ );
+ };
+
+ const saveNode = async (): Promise => {
+ if (!schedule || !selectedNode) return;
+ try {
+ const timeoutSeconds = Number(nodeForm.timeoutSeconds);
+ const retryCount = Number(nodeForm.retryCount);
+ const retryIntervalSec = Number(nodeForm.retryIntervalSec);
+ if (!nodeForm.nodeName.trim()) throw new Error("节点名称不能为空");
+ if (!Number.isInteger(timeoutSeconds) || timeoutSeconds < 1) {
+ throw new Error("超时时间必须是正整数");
+ }
+ if (!Number.isInteger(retryCount) || retryCount < 0) {
+ throw new Error("重试次数必须是非负整数");
+ }
+ if (!Number.isInteger(retryIntervalSec) || retryIntervalSec < 0) {
+ throw new Error("重试间隔必须是非负整数");
+ }
+ const argumentsJson = parseObject(nodeForm.argumentsJson, "运行参数");
+ const rawEnv = parseObject(nodeForm.envRefsJson, "环境引用");
+ const envRefsJson = Object.fromEntries(
+ Object.entries(rawEnv).map(([key, value]) => {
+ if (typeof value !== "string") {
+ throw new Error("环境引用的值必须是字符串");
+ }
+ return [key, value];
+ }),
+ );
+ await withMutation(
+ "save-node",
+ () => updateScheduleNode(schedule.schedule_id, selectedNode.node_id, {
+ workflow_version: schedule.workflow_version,
+ node_name: nodeForm.nodeName.trim(),
+ timeout_seconds: timeoutSeconds,
+ retry_count: retryCount,
+ retry_interval_sec: retryIntervalSec,
+ arguments_json: argumentsJson,
+ env_refs_json: envRefsJson,
+ }),
+ "节点配置已保存",
+ );
+ } catch (error) {
+ await handleError(error, "节点配置保存失败");
+ }
+ };
+
+ const removeNode = async (target?: ScheduleNode): Promise => {
+ const node = target ?? selectedNode;
+ if (!schedule || !node || busy) return;
+ setContextMenu(null);
+ if (!window.confirm(`确定删除节点“${node.node_name}”吗?`)) return;
+ const updated = await withMutation(
+ "delete-node",
+ () => deleteScheduleNode(
+ schedule.schedule_id,
+ node.node_id,
+ schedule.workflow_version,
+ ),
+ "节点已删除",
+ );
+ if (updated && selectedNodeId === node.node_id) setSelectedNodeId(null);
+ };
+
+ const removeEdge = async (target?: ScheduleEdge): Promise => {
+ const edge = target ?? selectedEdge;
+ if (!schedule || !edge || busy) return;
+ setContextMenu(null);
+ const updated = await withMutation(
+ "delete-edge",
+ () => deleteScheduleEdge(
+ schedule.schedule_id,
+ edge.edge_id,
+ schedule.workflow_version,
+ ),
+ "连线已删除",
+ );
+ if (updated && selectedEdgeId === edge.edge_id) setSelectedEdgeId(null);
+ };
+
+ const checkDag = async (): Promise => {
+ if (!schedule || busy) return;
+ setBusy("validate");
+ try {
+ const result = await validateSchedule(schedule.schedule_id);
+ setSchedule((current) => current
+ ? { ...current, dag_validation: result }
+ : current);
+ onNotify({
+ tone: result.valid ? "success" : "error",
+ message: result.valid
+ ? `DAG 校验通过,共 ${result.node_count} 个节点`
+ : result.errors.map((item) => item.message).join(";"),
+ });
+ } catch (error) {
+ await handleError(error, "DAG 校验失败");
+ } finally {
+ setBusy(null);
+ }
+ };
+
+ const filteredSchedules = useMemo(() => {
+ const keyword = scheduleKeyword.trim().toLowerCase();
+ return keyword
+ ? schedules.filter((item) => item.schedule_name.toLowerCase().includes(keyword))
+ : schedules;
+ }, [schedules, scheduleKeyword]);
+
+ const filteredArtifacts = useMemo(() => {
+ const keyword = artifactKeyword.trim().toLowerCase();
+ return keyword
+ ? artifacts.filter((item) => (
+ item.script_name.toLowerCase().includes(keyword)
+ || item.version_label.toLowerCase().includes(keyword)
+ ))
+ : artifacts;
+ }, [artifacts, artifactKeyword]);
+
+ return (
+
+
+
+ 图形化调度
+
+ {schedule
+ ? `版本 ${schedule.workflow_version} · ${schedule.dag_validation.valid ? "DAG 有效" : "待校验"}${
+ positionDraftCount > 0
+ ? ` · ${positionDraftCount} 个节点位置待保存`
+ : ""
+ }`
+ : "创建调度后开始编排"}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 流程画布
+
+ 拖入稳定版本;点击节点右侧圆点,再点击目标左侧圆点完成连线
+
+
+ {linkSourceId && (
+
+ )}
+
+ {
+ event.preventDefault();
+ event.dataTransfer.dropEffect = "copy";
+ }}
+ onDrop={onCanvasDrop}
+ onClick={(event) => {
+ if (event.target === event.currentTarget) {
+ setSelectedNodeId(null);
+ setSelectedEdgeId(null);
+ }
+ }}
+ >
+
+ {schedule && (
+
+ )}
+
+ {schedule?.nodes.map((node) => (
+
startNodeDrag(event, node)}
+ onPointerMove={moveNode}
+ onPointerUp={finishNodeDrag}
+ onPointerCancel={finishNodeDrag}
+ onContextMenu={(event) => openContextMenu(event, {
+ kind: "node",
+ node,
+ })}
+ >
+
+ ))}
+
+ {!schedule ? (
+
+
+
还没有选中调度方案
+
点击“新建”创建一个调度,然后拖入稳定版本脚本。
+
+ 新建调度
+
+
+ ) : schedule.nodes.length === 0 ? (
+
+
+
从稳定版本开始编排
+
把左侧脚本卡片拖到这里,或双击卡片快速加入。
+
+ ) : null}
+
+
+
+
+
+
+
+
+ {contextMenu && (
+ event.stopPropagation()}
+ >
+ {contextMenu.kind === "schedule-list" && (
+
+
+ 新建调度方案
+
+ )}
+ {contextMenu.kind === "schedule" && (
+ <>
+
+
+ 新建调度方案
+
+ void renameSchedule(contextMenu.schedule)}
+ >
+
+ 改名
+
+ void removeSchedule(contextMenu.schedule)}
+ >
+
+ 删除调度方案
+
+ >
+ )}
+ {contextMenu.kind === "artifact" && (
+ void removeArtifact(contextMenu.artifact)}
+ >
+
+ 移出调度列表
+
+ )}
+ {contextMenu.kind === "node" && (
+ void removeNode(contextMenu.node)}
+ >
+
+ 删除画布节点
+
+ )}
+ {contextMenu.kind === "edge" && (
+ void removeEdge(contextMenu.edge)}
+ >
+
+ 删除画布连线
+
+ )}
+
+ )}
+
+ {createDialogOpen && (
+
+
+
+
+ SCHEDULE
+
新建调度方案
+
+
setCreateDialogOpen(false)}
+ >
+
+
+
+
+
+
+ )}
+
+ {busy && (
+
+
+ {busy === "run-now" ? "正在创建运行…" : "正在同步调度配置…"}
+
+ )}
+
+ );
+}
+
+
+function RunHistory({
+ runs,
+ loading,
+ disabled,
+ onRefresh,
+}: {
+ runs: ScheduleRunSummary[];
+ loading: boolean;
+ disabled: boolean;
+ onRefresh: () => void;
+}) {
+ return (
+
+
+
+ 运行记录
+ {runs.length}
+
+
+
+
+
+
+ {loading && runs.length === 0 ? (
+
正在加载运行记录…
+ ) : runs.length === 0 ? (
+
点击右上角“立即运行”后,这里会显示状态和耗时。
+ ) : (
+ runs.map((run) => (
+
+
+
+
{RUN_STATUS_LABELS[run.run_status]}
+
+ {formatTime(run.queued_at)} · {formatDuration(run.duration_ms)}
+
+ {run.error_message &&
{run.error_message}
}
+
+ {run.run_id.slice(-8)}
+
+ ))
+ )}
+
+
+ );
+}
+
+
+function ScheduleInspector({
+ schedule,
+ form,
+ cronResult,
+ busy,
+ onChange,
+ onPreview,
+ onSave,
+}: {
+ schedule: Schedule | null;
+ form: ScheduleForm;
+ cronResult: CronPreview | null;
+ busy: boolean;
+ onChange: (form: ScheduleForm) => void;
+ onPreview: () => void;
+ onSave: () => void;
+}) {
+ if (!schedule) {
+ return (
+
+
+
调度属性
+
选中调度方案后可配置触发方式、Cron 和执行策略。
+
+ );
+ }
+ return (
+
+
+
+
+
+
+
+
+ 保存调度属性
+
+
+ );
+}
+
+
+function NodeInspector({
+ node,
+ form,
+ busy,
+ onChange,
+ onSave,
+ onDelete,
+}: {
+ node: ScheduleNode;
+ form: NodeForm;
+ busy: boolean;
+ onChange: (form: NodeForm) => void;
+ onSave: () => void;
+ onDelete: () => void;
+}) {
+ return (
+
+
+
+
+
+
+ 节点配置
+ {node.node_key}
+
+
+
+ 稳定版本
+
+ {node.version.script_name}
+ {node.version.version_label}
+ versions_id: {node.versions_id}
+ SHA-256: {shortHash(node.version.content_hash)}
+
+
+
+
+ 运行参数
+
+
+
+
+
+ 保存节点
+
+
+ 删除节点
+
+
+
+ );
+}
+
+
+function withSuppressedError(action: () => Promise): void {
+ void action().catch(() => undefined);
+}
diff --git a/frontend/app/root.tsx b/frontend/app/root.tsx
new file mode 100644
index 0000000..6cce2dc
--- /dev/null
+++ b/frontend/app/root.tsx
@@ -0,0 +1,75 @@
+import {
+ isRouteErrorResponse,
+ Links,
+ Meta,
+ Outlet,
+ Scripts,
+ ScrollRestoration,
+} from "react-router";
+
+import type { Route } from "./+types/root";
+import "./app.css";
+
+export const links: Route.LinksFunction = () => [
+ { rel: "preconnect", href: "https://fonts.googleapis.com" },
+ {
+ rel: "preconnect",
+ href: "https://fonts.gstatic.com",
+ crossOrigin: "anonymous",
+ },
+ {
+ rel: "stylesheet",
+ href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap",
+ },
+];
+
+export function Layout({ children }: { children: React.ReactNode }) {
+ return (
+
+
+
+
+
+
+
+
+ {children}
+
+
+
+
+ );
+}
+
+export default function App() {
+ return ;
+}
+
+export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
+ let message = "页面加载失败";
+ let details = "系统发生了未预期的错误。";
+ let stack: string | undefined;
+
+ if (isRouteErrorResponse(error)) {
+ message = error.status === 404 ? "404" : "请求失败";
+ details =
+ error.status === 404
+ ? "没有找到请求的页面。"
+ : error.statusText || details;
+ } else if (import.meta.env.DEV && error && error instanceof Error) {
+ details = error.message;
+ stack = error.stack;
+ }
+
+ return (
+
+ {message}
+ {details}
+ {stack && (
+
+ {stack}
+
+ )}
+
+ );
+}
diff --git a/frontend/app/routes.ts b/frontend/app/routes.ts
new file mode 100644
index 0000000..87d802e
--- /dev/null
+++ b/frontend/app/routes.ts
@@ -0,0 +1,3 @@
+import { type RouteConfig, route } from "@react-router/dev/routes";
+
+export default [route("*", "routes/platform.tsx")] satisfies RouteConfig;
diff --git a/frontend/app/routes/platform.tsx b/frontend/app/routes/platform.tsx
new file mode 100644
index 0000000..6f9b64c
--- /dev/null
+++ b/frontend/app/routes/platform.tsx
@@ -0,0 +1,13 @@
+import type { Route } from "./+types/platform";
+import ModelPlatformApp from "../features/platform/ModelPlatformApp";
+
+export function meta({}: Route.MetaArgs) {
+ return [
+ { title: "模型实验开发平台" },
+ { name: "description", content: "模型实验、脚本版本与 DAG 调度平台" },
+ ];
+}
+
+export default function PlatformRoute() {
+ return ;
+}
diff --git a/frontend/app/services/api.ts b/frontend/app/services/api.ts
new file mode 100644
index 0000000..766b150
--- /dev/null
+++ b/frontend/app/services/api.ts
@@ -0,0 +1,853 @@
+export type DemoUser = {
+ userId: string;
+ userName: string;
+ username: string;
+ roleCode: "admin" | "developer";
+ roleName: string;
+};
+
+export type DemoWorkspace = {
+ workspaceId: string;
+ workspaceName: string;
+};
+
+export const demoUsers: DemoUser[] = [
+ { userId: "0000000000RF6FG1SDBXG59S13", userName: "张三", username: "admin-zhang", roleCode: "admin", roleName: "管理员" },
+ { userId: "0000000000H2QYCGPCWQM1JSGS", userName: "李四", username: "admin-li", roleCode: "admin", roleName: "管理员" },
+ { userId: "0000000000RWG40ESZPGJT629J", userName: "王五", username: "dev-wang", roleCode: "developer", roleName: "开发人员" },
+ { userId: "00000000004CQV7WASJA6N6FW4", userName: "赵六", username: "dev-zhao", roleCode: "developer", roleName: "开发人员" },
+];
+
+export const demoWorkspaces: DemoWorkspace[] = [
+ { workspaceId: "00000000000BM630VT9ARVFZPC", workspaceName: "模型开发 Workspace" },
+ { workspaceId: "0000000000AE0NC0V5T424KK86", workspaceName: "风险验证 Workspace" },
+];
+
+function readStoredContext(): Partial<{
+ userId: string;
+ workspaceId: string;
+}> {
+ if (typeof window === "undefined") return {};
+ try {
+ return JSON.parse(
+ window.localStorage.getItem("model-platform-demo-context") ?? "{}",
+ ) as Partial<{ userId: string; workspaceId: string }>;
+ } catch {
+ return {};
+ }
+}
+
+const storedContext = readStoredContext();
+const initialUser = demoUsers.find((item) => item.userId === storedContext.userId)
+ ?? demoUsers[0];
+const initialWorkspace = demoWorkspaces.find(
+ (item) => item.workspaceId === storedContext.workspaceId,
+) ?? demoWorkspaces[0];
+
+export const demoContext = {
+ ...initialUser,
+ ...initialWorkspace,
+};
+
+export function setDemoContext(input: {
+ user?: DemoUser;
+ workspace?: DemoWorkspace;
+}): void {
+ if (input.user) Object.assign(demoContext, input.user);
+ if (input.workspace) Object.assign(demoContext, input.workspace);
+ if (typeof window !== "undefined") {
+ window.localStorage.setItem("model-platform-demo-context", JSON.stringify({
+ userId: demoContext.userId,
+ workspaceId: demoContext.workspaceId,
+ }));
+ }
+}
+
+export type ScriptType = "python" | "notebook";
+export type Visibility = "private" | "workspace" | "public";
+
+export type Employee = {
+ user_id: string;
+ username: string;
+ display_name: string;
+ email: string | null;
+ status: "active" | "disabled" | "locked";
+ role_code: "admin" | "developer";
+ role_name: string;
+ created_at: string;
+};
+
+export type ScriptItem = {
+ script_id: string;
+ workspace_id: string;
+ current_object_id: string;
+ owner_user_id: string;
+ script_name: string;
+ script_type: ScriptType;
+ visibility: Visibility;
+ status: string;
+ relative_path: string;
+ content_hash: string;
+ size_bytes: number;
+ created_at: string;
+ updated_at: string;
+};
+
+export type WorkspaceDirectory = {
+ path: string;
+ name: string;
+ parent_path: string;
+};
+
+type ApiEnvelope = {
+ request_id: string;
+ data: T;
+ meta: Record;
+};
+
+type ApiErrorEnvelope = {
+ detail?: string | {
+ code?: string;
+ message?: string;
+ };
+ error?: {
+ code?: string;
+ message?: string;
+ details?: {
+ editor_name?: string;
+ lease_expires_at?: string;
+ };
+ };
+};
+
+export class ApiRequestError extends Error {
+ readonly status: number;
+ readonly code?: string;
+
+ constructor(message: string, status: number, code?: string) {
+ super(message);
+ this.name = "ApiRequestError";
+ this.status = status;
+ this.code = code;
+ }
+}
+
+async function apiRequest(
+ path: string,
+ init: RequestInit = {},
+): Promise {
+ const response = await fetch(path, {
+ ...init,
+ credentials: "same-origin",
+ headers: {
+ "X-User-ID": demoContext.userId,
+ "X-Workspace-ID": demoContext.workspaceId,
+ "X-Request-ID": crypto.randomUUID().replaceAll("-", ""),
+ ...(init.body ? { "Content-Type": "application/json" } : {}),
+ ...init.headers,
+ },
+ });
+
+ const payload = (await response.json().catch(() => ({}))) as
+ | ApiEnvelope
+ | ApiErrorEnvelope;
+ if (!response.ok) {
+ const error = payload as ApiErrorEnvelope;
+ const detailMessage = typeof error.detail === "string"
+ ? error.detail
+ : error.detail?.message;
+ const editor = error.error?.details?.editor_name;
+ throw new ApiRequestError(
+ (editor ? `${error.error?.message ?? "文件正在编辑"}(${editor})` : undefined)
+ ?? error.error?.message
+ ?? detailMessage
+ ?? `请求失败(HTTP ${response.status})`,
+ response.status,
+ typeof error.detail === "object"
+ ? error.detail?.code
+ : error.error?.code,
+ );
+ }
+ return (payload as ApiEnvelope).data;
+}
+
+export async function listScripts(): Promise {
+ return apiRequest("/api/v1/scripts");
+}
+
+function initialContent(scriptType: ScriptType): string {
+ if (scriptType === "python") {
+ return [
+ '"""模型实验开发平台构建脚本。"""',
+ "",
+ "",
+ "def main() -> None:",
+ ' print("Hello, Model Platform!")',
+ "",
+ "",
+ 'if __name__ == "__main__":',
+ " main()",
+ "",
+ ].join("\n");
+ }
+
+ return JSON.stringify(
+ {
+ cells: [
+ {
+ cell_type: "markdown",
+ metadata: {},
+ source: ["# 新建模型实验\\n", "在这里开始数据探索与模型构建。"],
+ },
+ {
+ cell_type: "code",
+ execution_count: null,
+ metadata: {},
+ outputs: [],
+ source: ["print('Hello, Model Platform!')\\n"],
+ },
+ ],
+ metadata: {
+ kernelspec: {
+ display_name: "Python 3",
+ language: "python",
+ name: "python3",
+ },
+ language_info: {
+ name: "python",
+ version: "3.12",
+ },
+ },
+ nbformat: 4,
+ nbformat_minor: 5,
+ },
+ null,
+ 2,
+ );
+}
+
+export async function createScript(input: {
+ name: string;
+ scriptType: ScriptType;
+ visibility: Visibility;
+ parentPath?: string | null;
+}): Promise {
+ return apiRequest("/api/v1/scripts", {
+ method: "POST",
+ body: JSON.stringify({
+ script_name: input.name.trim(),
+ script_type: input.scriptType,
+ visibility: input.visibility,
+ content: initialContent(input.scriptType),
+ parent_path: input.parentPath,
+ }),
+ });
+}
+
+export async function uploadScript(
+ file: File,
+ parentPath = "",
+ visibility: Visibility = "workspace",
+): Promise {
+ const parameters = new URLSearchParams({
+ file_name: file.name,
+ parent_path: parentPath,
+ visibility,
+ });
+ return apiRequest(
+ `/api/v1/scripts/upload?${parameters.toString()}`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/octet-stream" },
+ body: file,
+ },
+ );
+}
+
+export async function deleteScript(
+ scriptId: string,
+): Promise<{ script_id: string; status: string; versions_preserved: boolean }> {
+ return apiRequest(`/api/v1/scripts/${scriptId}`, { method: "DELETE" });
+}
+
+export async function listWorkspaceDirectories(): Promise<
+ WorkspaceDirectory[]
+> {
+ const data = await apiRequest<{ directories: WorkspaceDirectory[] }>(
+ "/api/v1/workspace-tree",
+ );
+ return data.directories;
+}
+
+export async function createWorkspaceDirectory(
+ directoryName: string,
+ parentPath = "",
+): Promise {
+ return apiRequest("/api/v1/workspace-directories", {
+ method: "POST",
+ body: JSON.stringify({
+ directory_name: directoryName,
+ parent_path: parentPath,
+ }),
+ });
+}
+
+export async function deleteWorkspaceDirectory(
+ path: string,
+): Promise<{
+ path: string;
+ status: string;
+ deleted_scripts: number;
+ versions_preserved: boolean;
+}> {
+ const parameters = new URLSearchParams({ path });
+ return apiRequest(`/api/v1/workspace-directories?${parameters.toString()}`, {
+ method: "DELETE",
+ });
+}
+
+export type FileLockSession = {
+ edit_session_id: string;
+ workspace_id: string;
+ storage_object_id: string;
+ user_id: string;
+ session_status: "active" | "closed" | "expired";
+ lease_seconds: number;
+ heartbeat_interval_seconds: number;
+ expires_at: string;
+ runtime_id: string;
+ jupyter_session_id: string;
+ jupyter_url?: string;
+ relative_path?: string;
+ lock_token?: string;
+};
+
+export type ActiveEditSession = FileLockSession & {
+ script_id: string;
+ script_name: string;
+ lock_token: string;
+ ticket_expires_at?: string;
+};
+
+export type JupyterAccessTicket = {
+ edit_session_id: string;
+ jupyter_url: string;
+ expires_at: string;
+};
+
+export type StableVersion = {
+ versions_id: string;
+ workspace_id: string;
+ script_id: string;
+ source_object_id: string;
+ artifact_object_id: string;
+ version_no: number;
+ version_label: string;
+ source_path: string;
+ artifact_path: string;
+ content_hash: string;
+ file_size_bytes: number;
+ visibility: Visibility;
+ release_note: string | null;
+ created_by: string;
+ created_at: string;
+};
+
+export async function acquireFileLock(
+ script: ScriptItem,
+): Promise {
+ const session = await apiRequest(
+ `/api/v1/files/${script.current_object_id}/lock`,
+ { method: "POST" },
+ );
+ if (!session.lock_token) {
+ throw new Error("加锁成功响应缺少 lock_token");
+ }
+ return {
+ ...session,
+ script_id: script.script_id,
+ script_name: script.script_name,
+ lock_token: session.lock_token,
+ };
+}
+
+export async function heartbeatFileLock(
+ session: ActiveEditSession,
+): Promise {
+ return apiRequest(
+ `/api/v1/file-locks/${session.edit_session_id}/heartbeat`,
+ {
+ method: "POST",
+ body: JSON.stringify({ lock_token: session.lock_token }),
+ },
+ );
+}
+
+export async function releaseFileLock(
+ session: ActiveEditSession,
+): Promise {
+ return apiRequest