18 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 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_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.
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 3118694 → 7476c27, 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_runneris a stable external contract. The worker launches the notebook subprocess withsys.executable, "-m", "schedule.notebook_runner". That-mstring must never change — soschedule/notebook_runner.pysurvives as a 6-line shim re-exportingmainfromschedule.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.pypatched"schedule.orchestrator.session_scope"andtest_worker.pypatched"schedule.service.build_object_store";worker.pyalso had afrom 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 oldschedule/execution.pysilently dead code (the package wins import resolution), so move-then-delete, don't just copy.gitusually 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
ExecutionResultfrom the flatschedule/execution.pyintodomain/execution.pyand 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 raisesFrozenInstanceError, andrepr()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.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 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 implicitanythrough the slice body. WritingStateCreator<any, [], [], SliceState & SliceActions>to giveget()cross-slice access makes every nested call (get().schedules.filter((item) => ...)) fail with TS7006 —eslint-disable @typescript-eslint/no-explicit-anydoes NOT save you, because TS still infersany. Fix: declare a combinedSchedulesStore = State & Actionstype in the root composition file (useSchedulesStore.ts), then each sliceimport type { SchedulesStore } from "./useSchedulesStore"and usesStateCreator<SchedulesStore, [], [], SliceState & SliceActions>. TypeScript accepts the circularimport typebecause 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) takeset: (partial: Partial<SchedulesStore> | ((s: SchedulesStore) => Partial<SchedulesStore>)) => voidandget: () => SchedulesStoredirectly.SchedulesStorealready enumerates everything; no constraint to extend. - Inner-closure helpers become standalone functions. Monolithic
create((set, get) => { async function withMutation(...) { ... } })capturesset/getimplicitly. When splitting the store you must reify these as exported functions inhelpers.tstaking(set, get)arguments. This forces signatures to spell out exactly which fields they touch — which is what makes theSchedulesStore-typed approach pay off. - "De-duplicate" requires value comparison, not just name matching. Plan item "make
constants.tsre-exportEMPTY_SCHEDULE_FORMfromstate/types" looks like obvious dedup, but the values differ:Consolidating silently changes// constants.ts (utils.ts imports this) cronExpression: "0 9 * * *", // state/types (canvasSlice initial state) cronExpression: "",utils.ts's runtime defaults. Per "never break userspace", leaveconstants.tsalone. 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/artifactKeywordfilter 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 incanvasSlice. Decision rule: "which layout component writes this field?" not "which data does this field filter?". positionDraftsMap stays at module scope inhelpers.ts. It must NOT move intoCanvasSliceState. 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 everyclearAllPositionDrafts()call site and decide whether each should fire on reset.reset()mirrors originalset({ ...initial, loading: true })semantics. The legacy reset wipes ALL slice fields including user-editedscheduleForm/nodeForm(becauseinitial.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.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.
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 零变化 — 不要顺手"清理"。