Files

18 KiB
Raw Permalink Blame History

Repository Guide

Architecture

  • frontend — React Router v8 SPA; production bundle built in nginx/Dockerfile.
  • backend — public FastAPI API + internal S3 storage API in one process.
  • runtime — Jupyter lifecycle, MySQL edit leases, short-lived in-memory access tickets.
  • schedule — APScheduler, MySQL JobStore, MySQL Outbox polling and DAG execution.
  • common — SQLAlchemy models, database/session helpers, IDs, object-store helpers.
  • migrations — Alembic schema + seed migrations.
  • nginx — static frontend, /api/ proxy, authenticated /jupyter/ proxy.

Redis is reintroduced only for the A-card operations query cache and event Streams. MySQL Outbox remains authoritative, and Redis outages must not block core APIs. The former separate Storage API container remains removed.

Commands

From repo root:

uv sync --all-packages
uv run python -m compileall common/src backend/src runtime/src schedule/src
uv run --package backend alembic upgrade head
uv run --package backend pytest backend/tests -q

cp .env.example .env docker compose config docker compose up -d --build


Frontend:

```bash
cd frontend
pnpm install
pnpm dev
pnpm typecheck   # runs `react-router typegen && tsc`
pnpm build

Service rules

  • Browser traffic enters through Gateway only; frontend uses same-origin /api/v1/....
  • Backend writes schedule_runs and outbox_events, then besteffort HTTP-dispatches to Schedule Executor. The executor always polls pending MySQL Outbox rows, so dispatch failure does not lose a task.
  • Cron jobs persisted in MySQL 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 storage data.

Engineering notes

Hard-won lessons. Read the relevant bullet before touching the named area.

Python runtime — always go through uv run

  • Repo is a uv workspace; common / backend / runtime / schedule / migrations share one .venv. Bare python / pytest / alembic resolves to system Python and all from backend.X import ... / from common.X import ... fail with ModuleNotFoundError, or worse: an out-of-date venv silently runs stale code.
  • Always prefix with uv run [--package <pkg>] <cmd>:
    • uv run --package backend pytest backend/tests -q
    • uv run --package backend alembic upgrade head / downgrade -1
    • uv run python -m compileall common/src backend/src runtime/src schedule/src
    • uv run python -c "from backend.foo import bar" for one-shot inspection
  • Migration files use plain op.drop_index / op.create_index — MySQL 8.0 does not support IF EXISTS / IF NOT EXISTS on DROP INDEX / CREATE INDEX, even though Alembic exposes the flag.

Platform auth / soft-delete (/api/v1/platform/employees)

  • Reuse system_admin_context + _*_admins helpers in backend/src/backend/platform.py. Self-protection and the last-admin guard for Users.platform_role_id mirror the workspace pattern. _count_active_system_admins(session, exclude_user_id=...) is already there; do not reinvent the count in the handler.
  • PATCH guard order is load-bearing. Always check is_current_system_admin last-admin first, then leaves_admin_pool, then self-demotion. Reversing the order lets test mocks bypass the count helper.
  • 404 vs 409 on already-soft-deleted: delete_platform_employee returns a single 404 "用户不存在" for both user is None and user.is_deleted != 0. This intentionally differs from DELETE /workspaces/{id} which returns 409 for already-disabled. Document both in API.md.
  • PATCH cannot null-out platform_role_id. PlatformEmployeeUpdate.role_code: Literal["admin","developer"] (not Optional). Add a separate endpoint if clearing is needed — never loosen the Literal.
  • DELETE cascade covers WorkspaceMembers only — writes is_deleted=1, deleted_at=now on WorkspaceMembers rows for the user. Does not touch Workspaces.

Backend test mocks (SQLAlchemy 2.0 / pytest-asyncio)

  • compile(literal_binds=True) uppercases keywords. "from roles" in text misses the table reference; match text.lower() or test for "roles.role_id" / "roles.role_code" directly.
  • Depends-style helpers are awaited. _load_role_by_code and _count_active_system_admins need async def mocks — sync lambda raises TypeError: object int can't be used in 'await' expression.
  • Mock response ordering matters for re-reads. update_platform_employee reads current_role before write then response_role after. A scalar mock returning a fixed value will return the pre-write role in the response — track call order or look up by user.platform_role_id post-write.
  • Mock users must declare every attribute the handler writes. Users.deleted_at is not in column defaults; SimpleNamespace(user_id=...) raises AttributeError on target.deleted_at = now. Set user.deleted_at = None explicitly.

Schedule service layering (domain / scheduling / application / execution / infrastructure)

Lessons from the zero-behavior-change refactor that split the flat 11-file schedule/src/schedule/ into five subpackages (commits 31186947476c27, with a follow-up git mv in stage 8 placing the orchestrator under application/). Public class names (SchedulerService, CronScheduler, DispatchOrchestrator, NodeExecutor, SchedulerStorageClient) and schedule/pyproject.toml are unchanged.

  • python -m schedule.notebook_runner is a stable external contract. The worker launches the notebook subprocess with sys.executable, "-m", "schedule.notebook_runner". That -m string must never change — so schedule/notebook_runner.py survives as a 6-line shim re-exporting main from schedule.execution.runners.notebook. Don't "clean up" the shim.
  • Mock patch() string targets and in-function lazy imports are invisible to import-line greps. test_janitor.py patched "schedule.orchestrator.session_scope" and test_worker.py patched "schedule.service.build_object_store"; worker.py also had a from schedule.service import ... inside a function body. When files move, these silently no-op against the old path — and hard-crash once the old file is deleted. After any move, grep the whole tree for the old module path (including tests) and rewrite every hit, not just top-level imports.
  • A new package dir shadows a same-named flat module. Creating schedule/execution/ makes the old schedule/execution.py silently dead code (the package wins import resolution), so move-then-delete, don't just copy. git usually detects these as renames, which keeps the diff reviewable.
  • Docstring references survive file deletion. After removing flat files, :class:\schedule.worker.NodeExecutor``-style text can linger in docstrings and render as broken links. Grep for the old module name one more time at cleanup and rewrite comment-only refs too.
  • Don't silently upgrade dataclass-ness during a "structural only" refactor. Stage 1 moved ExecutionResult from the flat schedule/execution.py into domain/execution.py and decorated it with @dataclass(frozen=True) along the way. Pre-refactor it was a plain class. Review (2026-08-21) caught that this changes three things at once: identity-== becomes value-==, mutation raises FrozenInstanceError, and repr() becomes structured. No caller in the repo mutates or compares these objects, so the only externally visible change is log format — but it is not "zero behavior change." If you want strict behavioral equivalence during a structural move, copy the class definition verbatim and document any intentional semantic tightening.

Frontend state + routing (zustand + React Router v8)

Lessons from splitting frontend/app/features/platform/ModelPlatformApp.tsx (1057 → 121 lines) into zustand stores + nested routes.

  • Barrel-file CSS imports vanish on refactor. features/admin/AdminPages.tsx was a barrel that side-effect-imported admin.css + dashboard.css. When route files started importing DashboardPage / SystemAdminPage directly, the CSS disappeared silently. Fix: each page component imports its own CSS at the top — DashboardPage needs both admin.css (.dashboard-page, .dashboard-hero, .dashboard-metrics, .dashboard-actions) and dashboard.css (.dashboard-grid). UserManagementPage / ProjectManagementPage / SystemAdminPage only need admin.css. Don't put CSS imports in route files; let the components own their styles. SchedulePage already follows this pattern with schedule.css.
  • Module-level zustand store + bindApi(api) for auth-dependent APIs. Keep a module-level _api ref; expose bindScriptWorkspaceApi(api); layout calls it in render body (not useEffect([api])). Actions read _api internally — no api param on every call. Pair the render-body bind with a separate empty-deps useEffect(() => () => bindScriptWorkspaceApi(null), []) for unmount cleanup only.
    • Why render body, not useEffect([api]): React effect order on deps change is parent cleanup → child effect → parent effect. With useEffect([api]), the parent's cleanup wipes _api to null before child effects (e.g. ScriptsPage's useEffect([workspaceId]) calling load()) run, producing the script workspace API 未绑定 race whenever currentWorkspace.workspace_id changes. Render-body binding runs synchronously during the parent's render, which happens before the child's render and effects, so _api is always current by the time child code touches the store.
  • Lifecycle hooks belong in the layout, not in route components. Heartbeats (active edit session + cached sessions), the 10-min cleanup timer, and beforeunload lock-release must mount at the layout level — navigating to /schedules otherwise unmounts them and cached locks expire. Pattern: store exposes tickHeartbeats() / tickCleanup() / releaseActiveOnUnload(); the layout hook just owns the setInterval and addEventListener.
  • Route components with their own internal state must remount on workspace/user change. SchedulesPage / SystemAdminPage / UserManagementPage / ProjectManagementPage keep useState for fetched data (schedules, artifacts, selectedSchedule, employees, projects). When currentWorkspace or user changes, the api reference updates but the cached state does not — the UI shows the previous workspace's data. Pattern: route wrappers set key={\${user?.user_id ?? "anon"}-${currentWorkspace?.workspace_id ?? "none"}`}on the page component to force React to unmount and remount, resetting all internal state and re-runninguseEffectdata fetches.ScriptsPagedoesn't need this — its store-backed state is reset viascriptWorkspaceStore.reset()` on workspace change.
  • { current: T | null } module-level handle for non-subscribing consumers. Sidebar reads "is there an active edit session?" without subscribing to the store — expose a plain { current: ... } object at module scope and update it synchronously inside the store's setEditSession action.
  • zustand StateCreator enforces declared action signatures. Declaring loadLatestVersion: () => Promise<void> while accidentally returning a cleanup function from the implementation makes tsc reject the whole store with TS2345. Match the declared type exactly.
  • Nested routes in React Router v8. Use route("", "layout.tsx", [route("x", "x.tsx"), ...]) from @react-router/dev/routes. URLs stay flat; the layout renders <Outlet />. Don't use route("*", ...) as a wildcard — it skips the nested children config.
  • Route ids are derived from file paths. The same file cannot be referenced by two route entries (index("X.tsx") + route("y", "X.tsx")duplicate route id). To make / redirect to /workbench, create a tiny RootIndex.tsx that renders <Navigate to="/workbench" replace /> rather than reusing DashboardRoute.tsx.
  • An unmatched nested child leaves <Outlet /> blank → white screen inside the layout. Always cover / either with index(...) or by letting the parent layout <Navigate> on a location check. /login is the only top-level path that escapes this trap.
  • pnpm typecheck runs react-router typegen && tsc. Type errors from the generated +types/... files surface here too. New route files must be registered in routes.ts first.

Frontend store slice split (zustand)

Lessons from splitting frontend/app/features/schedules/state/schedulesStore.ts (1251 lines, 40KB) into 8 files: types.ts, helpers.ts, canvasSlice.ts, listSlice.ts, dialogSlice.ts, runsSlice.ts, logsSlice.ts, useSchedulesStore.ts. Public exports (useSchedulesStore, bindSchedulesApi) and selector protocol unchanged.

  • StateCreator<any, ...> cascades implicit any through the slice body. Writing StateCreator<any, [], [], SliceState & SliceActions> to give get() cross-slice access makes every nested call (get().schedules.filter((item) => ...)) fail with TS7006 — eslint-disable @typescript-eslint/no-explicit-any does NOT save you, because TS still infers any. Fix: declare a combined SchedulesStore = State & Actions type in the root composition file (useSchedulesStore.ts), then each slice import type { SchedulesStore } from "./useSchedulesStore" and uses StateCreator<SchedulesStore, [], [], SliceState & SliceActions>. TypeScript accepts the circular import type because it erases at build time. get() now returns a fully typed snapshot and .map((item) => ...) infers correctly.
  • Don't write <S extends ListFieldSlice & { positionDraftCount: number; busy: string | null; refreshLists: ... }> constraints on cross-slice helpers. Every helper needs to know about every other slice's fields, and the constraint chain keeps growing. Better: have helpers (withMutation, applyServerUpdatedSchedule, handleError) take set: (partial: Partial<SchedulesStore> | ((s: SchedulesStore) => Partial<SchedulesStore>)) => void and get: () => SchedulesStore directly. SchedulesStore already enumerates everything; no constraint to extend.
  • Inner-closure helpers become standalone functions. Monolithic create((set, get) => { async function withMutation(...) { ... } }) captures set/get implicitly. When splitting the store you must reify these as exported functions in helpers.ts taking (set, get) arguments. This forces signatures to spell out exactly which fields they touch — which is what makes the SchedulesStore-typed approach pay off.
  • "De-duplicate" requires value comparison, not just name matching. Plan item "make constants.ts re-export EMPTY_SCHEDULE_FORM from state/types" looks like obvious dedup, but the values differ:
    // constants.ts (utils.ts imports this)
    cronExpression: "0 9 * * *",
    // state/types (canvasSlice initial state)
    cronExpression: "",
    
    Consolidating silently changes utils.ts's runtime defaults. Per "never break userspace", leave constants.ts alone. Rule: before any dedup, grep both call sites and diff the actual values, not just the symbol names.
  • Cross-slice field ownership belongs to layout, not data. scheduleKeyword / artifactKeyword filter inputs look like listSlice state because they filter schedules, but they're bound to the left-panel UI and updated by the canvas layout component — they belong in canvasSlice. Decision rule: "which layout component writes this field?" not "which data does this field filter?".
  • positionDrafts Map stays at module scope in helpers.ts. It must NOT move into CanvasSliceState. The original behavior — reset() does NOT clear drag-in-progress drafts — is a feature; users expect their unsaved drag to survive reset. If you move it into slice state, audit every clearAllPositionDrafts() call site and decide whether each should fire on reset.
  • reset() mirrors original set({ ...initial, loading: true }) semantics. The legacy reset wipes ALL slice fields including user-edited scheduleForm / nodeForm (because initial.scheduleForm = EMPTY_SCHEDULE_FORM). Any "preserve user input in reset()" change silently diverges from original behavior. If you want to preserve form values, do it as an intentional new feature with its own API, not a side-effect of refactoring.

Frontend coupling

  • UserManagementPage.tsx and api.ts still call /api/v1/admin/employees. Don't migrate them in the same change as a /api/v1/platform/employees addition — the contract surface is intentionally duplicated.

File size: 500 lines hard cap

  • 每个文件最多 500 行 — 超过时主动拆分,不要等 review 才动。
  • 常见拆分维度:
    • 后端 — 按资源 / 端点分组:例如 api/platform.py (1600 行 / 20 endpoint) → api/platform/{__init__,_deps,employees,roles,workspaces}.py。原文件改为 thin shim,只 re-export 公共符号,保持 from backend.api.platform import router 等既有 import path 不变。
    • 后端 — 按层级:参考 schedule/ 已有的 domain/ / application/ / infrastructure/ 分层。
    • 前端 — 按职责:types.ts / helpers.ts / slices/ / useXStore.ts(参考 useSchedulesStore 拆 8-slice 的纪律)。
    • 前端 — 页面 vs 路由 wrapper:Page 组件持有 useState,route 文件只做 <Page key={...} /> 重挂载,二者不要混在一起。
  • 拆分前先列调用面(grep "from <old_path>"),任何外部 import 路径必须仍然可用 — 用 re-export 或 shim 兜底,不要让调用方被迫改。
  • 拆分后每个新文件 ≤ 500 行是硬约束,验证方式:wc -l <file> 或 CI 脚本。
  • 拆分本身是纯结构调整,endpoint 行为 / URL / 响应 schema 零变化 — 不要顺手"清理"。