9.7 KiB
9.7 KiB
Repository Guide
Architecture
frontend— React Router v8 SPA; production bundle built innginx/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 and the former separate Storage API container are intentionally 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_runsandoutbox_events, then best‑effort 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/migrationsshare one.venv. Barepython/pytest/alembicresolves to system Python and allfrom 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 -quv run --package backend alembic upgrade head/downgrade -1uv run python -m compileall common/src backend/src runtime/src schedule/srcuv 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 supportIF EXISTS/IF NOT EXISTSonDROP INDEX/CREATE INDEX, even though Alembic exposes the flag.
Platform auth / soft-delete (/api/v1/platform/employees)
- Reuse
system_admin_context+_*_adminshelpers inbackend/src/backend/platform.py. Self-protection and the last-admin guard forUsers.platform_role_idmirror 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_adminlast-admin first, thenleaves_admin_pool, then self-demotion. Reversing the order lets test mocks bypass the count helper. - 404 vs 409 on already-soft-deleted:
delete_platform_employeereturns a single 404 "用户不存在" for bothuser is Noneanduser.is_deleted != 0. This intentionally differs fromDELETE /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"](notOptional). Add a separate endpoint if clearing is needed — never loosen the Literal. - DELETE cascade covers
WorkspaceMembersonly — writesis_deleted=1, deleted_at=nowonWorkspaceMembersrows for the user. Does not touchWorkspaces.
Backend test mocks (SQLAlchemy 2.0 / pytest-asyncio)
compile(literal_binds=True)uppercases keywords."from roles" in textmisses the table reference; matchtext.lower()or test for"roles.role_id"/"roles.role_code"directly.Depends-style helpers are awaited._load_role_by_codeand_count_active_system_adminsneedasync defmocks — synclambdaraisesTypeError: object int can't be used in 'await' expression.- Mock response ordering matters for re-reads.
update_platform_employeereadscurrent_rolebefore write thenresponse_roleafter. A scalar mock returning a fixed value will return the pre-write role in the response — track call order or look up byuser.platform_role_idpost-write. - Mock users must declare every attribute the handler writes.
Users.deleted_atis not in column defaults;SimpleNamespace(user_id=...)raisesAttributeErrorontarget.deleted_at = now. Setuser.deleted_at = Noneexplicitly.
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.tsxwas a barrel that side-effect-importedadmin.css+dashboard.css. When route files started importingDashboardPage/SystemAdminPagedirectly, the CSS disappeared silently. Fix: each page component imports its own CSS at the top —DashboardPageneeds bothadmin.css(.dashboard-page,.dashboard-hero,.dashboard-metrics,.dashboard-actions) anddashboard.css(.dashboard-grid).UserManagementPage/ProjectManagementPage/SystemAdminPageonly needadmin.css. Don't put CSS imports in route files; let the components own their styles.SchedulePagealready follows this pattern withschedule.css. - Module-level zustand store +
bindApi(api)for auth-dependent APIs. Keep a module-level_apiref; exposebindScriptWorkspaceApi(api); layout calls it in render body (notuseEffect([api])). Actions read_apiinternally — no api param on every call. Pair the render-body bind with a separate empty-depsuseEffect(() => () => 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. WithuseEffect([api]), the parent's cleanup wipes_apitonullbefore child effects (e.g.ScriptsPage'suseEffect([workspaceId])callingload()) run, producing thescript workspace API 未绑定race whenevercurrentWorkspace.workspace_idchanges. Render-body binding runs synchronously during the parent's render, which happens before the child's render and effects, so_apiis always current by the time child code touches the store.
- Why render body, not
- Lifecycle hooks belong in the layout, not in route components. Heartbeats (active edit session + cached sessions), the 10-min cleanup timer, and
beforeunloadlock-release must mount at the layout level — navigating to/schedulesotherwise unmounts them and cached locks expire. Pattern: store exposestickHeartbeats()/tickCleanup()/releaseActiveOnUnload(); the layout hook just owns thesetIntervalandaddEventListener. - Route components with their own internal state must remount on workspace/user change.
SchedulesPage/SystemAdminPage/UserManagementPage/ProjectManagementPagekeepuseStatefor fetched data (schedules,artifacts,selectedSchedule, employees, projects). WhencurrentWorkspaceoruserchanges, theapireference updates but the cached state does not — the UI shows the previous workspace's data. Pattern: route wrappers setkey={\${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'ssetEditSessionaction.- zustand
StateCreatorenforces declared action signatures. DeclaringloadLatestVersion: () => Promise<void>while accidentally returning a cleanup function from the implementation makestscreject 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 useroute("*", ...)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 tinyRootIndex.tsxthat renders<Navigate to="/workbench" replace />rather than reusingDashboardRoute.tsx. - An unmatched nested child leaves
<Outlet />blank → white screen inside the layout. Always cover/either withindex(...)or by letting the parent layout<Navigate>on a location check./loginis the only top-level path that escapes this trap. pnpm typecheckrunsreact-router typegen && tsc. Type errors from the generated+types/...files surface here too. New route files must be registered inroutes.tsfirst.
Frontend coupling
UserManagementPage.tsxandapi.tsstill call/api/v1/admin/employees. Don't migrate them in the same change as a/api/v1/platform/employeesaddition — the contract surface is intentionally duplicated.