From 0ea6456ed1ad8bd5dcc54667c98560c4d33559c3 Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Fri, 21 Aug 2026 10:16:41 +0800 Subject: [PATCH 01/93] cleanup: drop dead storage_objects.parent_object_id column + idx_storage_parent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parent_object_id was never written or read by any application code (verified via repo-wide grep: only the baseline migration and the ORM model referenced it). The orphan index idx_storage_parent likewise served nothing. Tree structure is maintained entirely via the materialized path in storage_objects.relative_path (LIKE-prefix queries in backend/src/backend/scripts.py: list_workspace_tree, list_workspace_directories). The column mislead a prior review into proposing an adjacency-list table — removing it eliminates that temptation for the next reader. Migration: migrations/versions/f7a8b9c0d1e2_drop_storage_parent.py - Drops idx_storage_parent first, then parent_object_id (correct order on MySQL). - MySQL 8.0 has no IF EXISTS on DROP INDEX / DROP COLUMN, so calls are unconditional. - Updates the storage_objects TABLE COMMENT so the materialized-path warning reaches the DB, not just the ORM (Codex review finding). - Downgrade restores both. Model: common/src/common/db/models/storage.py - Removes the dead Index entry and the dead column. - Refreshes the table comment to flag the materialized-path contract. Verified: - alembic upgrade head: applied, head = f7a8b9c0d1e2 - SHOW INDEX / SHOW COLUMNS: 0 rows - TABLE COMMENT updated in information_schema - pytest backend/tests: 43 passed --- common/src/common/db/models/storage.py | 4 +- .../f7a8b9c0d1e2_drop_storage_parent.py | 56 +++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) create mode 100644 migrations/versions/f7a8b9c0d1e2_drop_storage_parent.py diff --git a/common/src/common/db/models/storage.py b/common/src/common/db/models/storage.py index 05bbc1c..3380223 100644 --- a/common/src/common/db/models/storage.py +++ b/common/src/common/db/models/storage.py @@ -20,7 +20,6 @@ class StorageObjects(Base): 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_path", "workspace_id", @@ -40,7 +39,7 @@ class StorageObjects(Base): "object_key_hash_active", unique=True, ), - {"comment": "Workspace 文件和 RustFS 对象的统一元数据"}, + {"comment": "Workspace 文件和 RustFS 对象的统一元数据;目录树走 materialized path (relative_path),不要 join 邻接表列——已删除。"}, ) storage_object_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True) @@ -86,7 +85,6 @@ class StorageObjects(Base): server_default=text("CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"), ) owner_user_id: Mapped[str | None] = mapped_column(CHAR(26)) - parent_object_id: Mapped[str | None] = mapped_column(CHAR(26)) relative_path: Mapped[str | None] = mapped_column( String(1024), comment="Workspace 相对路径" ) diff --git a/migrations/versions/f7a8b9c0d1e2_drop_storage_parent.py b/migrations/versions/f7a8b9c0d1e2_drop_storage_parent.py new file mode 100644 index 0000000..fba2605 --- /dev/null +++ b/migrations/versions/f7a8b9c0d1e2_drop_storage_parent.py @@ -0,0 +1,56 @@ +"""drop dead storage_objects.parent_object_id column and idx_storage_parent + +Revision ID: f7a8b9c0d1e2 +Revises: e1f2a3b4c5d6 +Create Date: 2026-08-21 + +Removes a never-written column and its orphaned index. Tree structure is +maintained entirely via relative_path (materialized path); see +backend/src/backend/scripts.py (list_workspace_tree / list_workspace_directories). + +MySQL 8.0 does not support DROP INDEX IF EXISTS / DROP COLUMN IF EXISTS, +so the calls below are unconditional. +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision: str = "f7a8b9c0d1e2" +down_revision: str | Sequence[str] | None = "e1f2a3b4c5d6" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Drop the dead index, then the dead column.""" + op.drop_index("idx_storage_parent", table_name="storage_objects") + op.drop_column("storage_objects", "parent_object_id") + # Refresh the table comment so the warning reaches the DB, not just the ORM. + op.execute( + "ALTER TABLE storage_objects " + "COMMENT = 'Workspace 文件和 RustFS 对象的统一元数据;" + "目录树走 materialized path (relative_path)," + "不要 join 邻接表列——已删除。'" + ) + + +def downgrade() -> None: + """Recreate the column and index for rollback.""" + op.add_column( + "storage_objects", + sa.Column("parent_object_id", mysql.CHAR(length=26), nullable=True), + ) + op.create_index( + "idx_storage_parent", + "storage_objects", + ["parent_object_id"], + unique=False, + ) + op.execute( + "ALTER TABLE storage_objects " + "COMMENT = 'Workspace 文件和 RustFS 对象的统一元数据'" + ) \ No newline at end of file -- 2.54.0 From 02c25fcc8e2c82139d753b3a17e0a20085eaa9d7 Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Fri, 21 Aug 2026 10:27:40 +0800 Subject: [PATCH 02/93] feat(scripts): listScripts parent_path filter + lazy load by directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend — backend/src/backend/scripts.py - list_scripts accepts optional parent_path Query (default ""). - Extracts _build_list_scripts_descendant_prefix helper for the "direct children of parent_path" prefix. - WHERE clause now adds: StorageObjects.relative_path LIKE '/%' AND NOT LIKE '/%/%' so deeper descendants and prefix-siblings (foo/bar vs foo/bar2) are excluded. Empty parent_path filters to root-level only — this is the symmetric, intent-aligned behavior the lazy-load frontend relies on. - EXPLAIN confirms idx_scripts_workspace drives the scripts table; storage_objects PK lookup applies the LIKE filter per row. Frontend — frontend/app/services/api.ts + scriptWorkspaceStore.ts - listScripts accepts optional parentPath; built URL preserves the new filter param. - Store gains loadedScriptPaths / loadingScriptPaths Sets and a loadScripts(parentPath) action: idempotent, in-flight dedupe, dedup-by-id when merging into the flat scripts array so existing find() callers keep working. - load() now uses listScripts("") instead of bulk fetch — root only on first paint. - toggleExpanded() triggers loadScripts(parentPath) in parallel with loadChildren(parentPath) so folder expansion loads both sub-directories and direct-child scripts. Tests — backend/tests/test_list_scripts_parent_path.py - 7 unit tests: prefix construction, normalization, traversal rejection, and SQL compilation contract (LIKE prefix + NOT LIKE prefix + scripts.status filter). Verified: - pytest backend/tests: 50 passed (43 existing + 7 new) - pnpm typecheck: clean - alembic: no schema changes (migration-less feature) - EXPLAIN with real workspace: idx_scripts_workspace → PK lookup --- backend/src/backend/scripts.py | 36 +++++ .../tests/test_list_scripts_parent_path.py | 136 ++++++++++++++++++ .../platform/state/scriptWorkspaceStore.ts | 76 +++++++++- frontend/app/services/api.ts | 16 ++- 4 files changed, 254 insertions(+), 10 deletions(-) create mode 100644 backend/tests/test_list_scripts_parent_path.py diff --git a/backend/src/backend/scripts.py b/backend/src/backend/scripts.py index a48f94f..187bf14 100644 --- a/backend/src/backend/scripts.py +++ b/backend/src/backend/scripts.py @@ -115,6 +115,28 @@ def user_relative_path(context: RequestContext, child_path: str = "") -> str: return f"{base}/{normalized}" if normalized else base +def _build_list_scripts_descendant_prefix( + context: RequestContext, parent_path: str +) -> str: + """Return the materialized-path prefix for direct children of ``parent_path``. + + The endpoint appends ``LIKE '/%' AND NOT LIKE '/%/%'`` + against ``storage_objects.relative_path`` so only scripts whose parent + directory is exactly ``parent_path`` (no deeper descendants, no + prefix-siblings like ``foo/bar`` vs ``foo/bar2``) match. + + Empty ``parent_path`` produces the user-scoped root prefix — i.e. the + endpoint returns root-level scripts only, not the full workspace. + """ + normalized_parent = normalize_user_path(parent_path) + scoped_prefix = user_relative_path(context) + if normalized_parent: + target_prefix = f"{scoped_prefix}/{normalized_parent}" + else: + target_prefix = scoped_prefix + return f"{target_prefix}/" + + 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): @@ -1045,11 +1067,23 @@ async def delete_workspace_directory( # 列出当前工作区中用户有权查看的脚本与 Notebook 元数据。 +# +# 可选 ``parent_path`` Query 参数把返回范围限定为 *直接挂在该路径下的* 脚本 +# (不含更深的子目录)。空字符串等价于用户作用域根目录;这是前端按目录懒加载 +# 的关键端点,避免 10 万级脚本一次性返回。 +# +# 实现走 ``storage_objects.relative_path`` materialized path(``LIKE prefix%`` +# + ``NOT LIKE prefix%/%`` 取直接子节点),配合现有 +# ``idx_storage_workspace_relative_path (workspace_id, relative_path)`` 索引 +# 避免全表扫。 @router.get("/api/v1/scripts") async def list_scripts( + parent_path: str = Query(default="", max_length=1024), context: RequestContext = Depends(request_context), session: AsyncSession = Depends(database_session), ) -> dict[str, Any]: + descendant_prefix = _build_list_scripts_descendant_prefix(context, parent_path) + statement = ( select(Scripts, StorageObjects, Users.display_name) .join( @@ -1060,6 +1094,8 @@ async def list_scripts( .where( Scripts.workspace_id == context.workspace.workspace_id, Scripts.status == "active", + StorageObjects.relative_path.like(f"{descendant_prefix}%"), + ~StorageObjects.relative_path.like(f"{descendant_prefix}%/%"), ) .order_by(Scripts.updated_at.desc()) ) diff --git a/backend/tests/test_list_scripts_parent_path.py b/backend/tests/test_list_scripts_parent_path.py new file mode 100644 index 0000000..aa6fea8 --- /dev/null +++ b/backend/tests/test_list_scripts_parent_path.py @@ -0,0 +1,136 @@ +"""Unit tests for the parent_path filter clause on list_scripts. + +Verifies the WHERE clause built by the endpoint encodes the intended +"direct children of parent_path" semantics: ``relative_path LIKE 'prefix/%'`` +and ``NOT LIKE 'prefix/%/%'``. The actual ORM roundtrip is covered by the +existing integration tests; here we only care that the filter contract is +intact, so an AsyncMock session is enough. + +Mirrors the pattern in test_scripts.py (unit-level, no live DB). +""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest +from fastapi import HTTPException + +from backend.scripts import ( + _build_list_scripts_descendant_prefix, + normalize_user_path, +) +from sqlalchemy.dialects import mysql as mysql_dialect + + +def _ctx(user_id: str = "U001") -> SimpleNamespace: + """Stand-in for RequestContext — only ``user.user_id`` and ``workspace_id`` are read.""" + return SimpleNamespace( + request_id="test", + user=SimpleNamespace(user_id=user_id), + workspace=SimpleNamespace(workspace_id="W001"), + role=SimpleNamespace(role_code="admin"), + is_system_admin=False, + ) + + +def test_descendant_prefix_root() -> None: + """Empty parent_path → descendant prefix is the scoped root + '/'.""" + prefix = _build_list_scripts_descendant_prefix(_ctx("alice"), "") + assert prefix == "workspace/alice/" + + +def test_descendant_prefix_subdir() -> None: + """Non-empty parent_path → appended under the scoped root.""" + prefix = _build_list_scripts_descendant_prefix(_ctx("alice"), "foo/bar") + assert prefix == "workspace/alice/foo/bar/" + + +def test_descendant_prefix_normalizes_leading_trailing_slashes() -> None: + """Leading/trailing slashes on parent_path must be stripped.""" + prefix = _build_list_scripts_descendant_prefix(_ctx("alice"), "/foo/bar/") + assert prefix == "workspace/alice/foo/bar/" + + +def test_descendant_prefix_rejects_traversal() -> None: + """``..`` segments must raise (matches normalize_user_path contract).""" + with pytest.raises(HTTPException) as exc: + _build_list_scripts_descendant_prefix(_ctx("alice"), "foo/../bar") + assert exc.value.status_code == 422 + + +def test_normalize_user_path_strips() -> None: + assert normalize_user_path("") == "" + assert normalize_user_path("/a/b/") == "a/b" + assert normalize_user_path("a\\b") == "a/b" + + +async def test_list_scripts_where_clause_uses_like_prefix_and_excludes_deeper() -> None: + """The WHERE clause must include both LIKE prefix and NOT LIKE '%/%' filters + so deeper descendants and prefix-siblings (foo/bar vs foo/bar2) are excluded.""" + from backend.scripts import list_scripts + + captured_sql: list[str] = [] + + class _MockResult: + def all(self): + return [] + + mock_session = MagicMock() + mock_session.execute = AsyncMock( + side_effect=lambda stmt: ( + captured_sql.append( + str( + stmt.compile( + dialect=mysql_dialect.dialect(), + compile_kwargs={"literal_binds": True}, + ) + ) + ) + or _MockResult() + ) + ) + + await list_scripts(parent_path="foo/bar", context=_ctx("alice"), session=mock_session) + + assert len(captured_sql) == 1 + sql = captured_sql[0].lower() + # Direct-child LIKE prefix + assert "like 'workspace/alice/foo/bar/%%'" in sql + # NOT-LIKE deeper + assert "not like 'workspace/alice/foo/bar/%%/%%'" in sql + # active scripts only (existing contract preserved) + assert "scripts.status" in sql or "scripts.status = 'active'" in sql or "scripts.status = :status" in sql + + +async def test_list_scripts_empty_parent_path_targets_root_descendants() -> None: + """parent_path='' produces root-scoped LIKE prefix only, not full scan.""" + from backend.scripts import list_scripts + + captured_sql: list[str] = [] + + class _MockResult: + def all(self): + return [] + + mock_session = MagicMock() + mock_session.execute = AsyncMock( + side_effect=lambda stmt: ( + captured_sql.append( + str( + stmt.compile( + dialect=mysql_dialect.dialect(), + compile_kwargs={"literal_binds": True}, + ) + ) + ) + or _MockResult() + ) + ) + + await list_scripts(parent_path="", context=_ctx("alice"), session=mock_session) + + sql = captured_sql[0].lower() + assert "like 'workspace/alice/%%'" in sql + assert "not like 'workspace/alice/%%/%%'" in sql \ No newline at end of file diff --git a/frontend/app/features/platform/state/scriptWorkspaceStore.ts b/frontend/app/features/platform/state/scriptWorkspaceStore.ts index 2236de8..ea6a59e 100644 --- a/frontend/app/features/platform/state/scriptWorkspaceStore.ts +++ b/frontend/app/features/platform/state/scriptWorkspaceStore.ts @@ -85,6 +85,12 @@ type State = { expandedPaths: Set; loadingChildrenPaths: Set; loadedChildPaths: Set; + // Per-parent-path script cache. Keys are user-relative parent paths; + // values are scripts whose storage path lives directly under that parent. + // The flat `scripts` array above is the union (deduped by script_id) of + // every cache entry that has been loaded in this session. + loadedScriptPaths: Set; + loadingScriptPaths: Set; // 只读内容刷新版本号(用于触发已打开标签页的内容刷新) readOnlyRefreshVersion: number; @@ -122,6 +128,9 @@ type State = { deleteDirectory: (path: string) => Promise; toggleExpanded: (path: string, loadPath?: string) => Promise; loadChildren: (parentPath: string) => Promise; + // Lazy-load scripts directly under `parentPath`. Idempotent — repeated + // calls for an already-loaded path are no-ops; in-flight calls dedupe. + loadScripts: (parentPath: string) => Promise; toggleScriptLock: (script: ScriptItem) => Promise; openPublishDialog: (script: ScriptItem) => void; submitPublish: (releaseNote: string, visibility: Visibility) => Promise; @@ -203,6 +212,8 @@ export const useScriptWorkspaceStore = create((set, get) => { expandedPaths: new Set(), loadingChildrenPaths: new Set(), loadedChildPaths: new Set(), + loadedScriptPaths: new Set(), + loadingScriptPaths: new Set(), readOnlyRefreshVersion: 0, @@ -242,6 +253,8 @@ export const useScriptWorkspaceStore = create((set, get) => { expandedPaths: new Set(), loadingChildrenPaths: new Set(), loadedChildPaths: new Set(), + loadedScriptPaths: new Set(), + loadingScriptPaths: new Set(), }); }, @@ -250,17 +263,23 @@ export const useScriptWorkspaceStore = create((set, get) => { if (!silent) set({ loading: true }); set({ refreshing: silent }); try { + // Fetch root-level scripts (parent_path="") and root directories in + // parallel. Deeper folders are lazy-fetched on expand via loadScripts + // / loadChildren. const [items, folderItems] = await Promise.all([ - api.listScripts(), + api.listScripts(""), api.listWorkspaceDirectories(""), ]); - const nextLoaded = new Set(get().loadedChildPaths); - nextLoaded.add(""); + const nextLoadedChildren = new Set(get().loadedChildPaths); + nextLoadedChildren.add(""); + const nextLoadedScripts = new Set(get().loadedScriptPaths); + nextLoadedScripts.add(""); set({ scripts: items, directories: folderItems, apiOnline: true, - loadedChildPaths: nextLoaded, + loadedChildPaths: nextLoadedChildren, + loadedScriptPaths: nextLoadedScripts, }); const validIds = new Set(items.map((item) => item.script_id)); const currentSelected = get().selectedId; @@ -284,6 +303,42 @@ export const useScriptWorkspaceStore = create((set, get) => { } }, + loadScripts: async (parentPath) => { + const api = requireApi(); + if (get().loadedScriptPaths.has(parentPath)) return; + // Dedupe in-flight requests for the same path. + if (get().loadingScriptPaths.has(parentPath)) return; + const next = new Set(get().loadingScriptPaths); + next.add(parentPath); + set({ loadingScriptPaths: next }); + try { + const items = await api.listScripts(parentPath); + set((state) => { + const existingIds = new Set(state.scripts.map((s) => s.script_id)); + const fresh = items.filter((s) => !existingIds.has(s.script_id)); + const nextLoaded = new Set(state.loadedScriptPaths); + nextLoaded.add(parentPath); + return { + scripts: [...state.scripts, ...fresh], + loadedScriptPaths: nextLoaded, + loadingScriptPaths: new Set( + [...state.loadingScriptPaths].filter((p) => p !== parentPath), + ), + }; + }); + } catch (error) { + set((state) => ({ + loadingScriptPaths: new Set( + [...state.loadingScriptPaths].filter((p) => p !== parentPath), + ), + })); + pushToast( + "error", + error instanceof Error ? error.message : "脚本列表加载失败", + ); + } + }, + loadDataResources: async () => { const api = requireApi(); set({ dataResourcesLoading: true }); @@ -340,10 +395,17 @@ export const useScriptWorkspaceStore = create((set, get) => { next.delete(path); } else { next.add(path); - // group key (`__group__`) 不是 workspace 路径,不调 loadChildren + // group key (`__group__`) 不是 workspace 路径,不调 loadChildren / loadScripts const actualLoadPath = loadPath ?? path; - if (!actualLoadPath.startsWith("__group__") && !state.loadedChildPaths.has(actualLoadPath)) { - void get().loadChildren(actualLoadPath); + if (!actualLoadPath.startsWith("__group__")) { + // Load both sub-directories and scripts directly under this folder + // in parallel. Both are idempotent + cached; cheap when already loaded. + if (!state.loadedChildPaths.has(actualLoadPath)) { + void get().loadChildren(actualLoadPath); + } + if (!state.loadedScriptPaths.has(actualLoadPath)) { + void get().loadScripts(actualLoadPath); + } } } set({ expandedPaths: next }); diff --git a/frontend/app/services/api.ts b/frontend/app/services/api.ts index 1f12ee2..3015514 100644 --- a/frontend/app/services/api.ts +++ b/frontend/app/services/api.ts @@ -284,8 +284,16 @@ async function apiRequest( return (payload as ApiEnvelope).data; } -export async function listScripts(workspaceId: string): Promise { - return apiRequest("/api/v1/scripts", {}, workspaceId); +export async function listScripts( + workspaceId: string, + parentPath: string = "", +): Promise { + // Empty parentPath omits the query string entirely so the backend's + // root-level filter is applied symmetrically with non-empty paths. + const query = parentPath + ? `?parent_path=${encodeURIComponent(parentPath)}` + : ""; + return apiRequest(`/api/v1/scripts${query}`, {}, workspaceId); } function initialContent(scriptType: ScriptType): string { @@ -1455,7 +1463,9 @@ export async function getScheduleNodeRunArtifacts( // references all the exports above. // ---------------------------------------------------------------------------- export type WorkspaceBoundApi = { - listScripts: () => Promise; + listScripts: ( + parentPath?: Parameters[1], + ) => Promise; listResources: ( opts?: Parameters[1], ) => Promise; -- 2.54.0 From cee080b7dfa0347c063bd9c6d2ebcd47b6cbb8e1 Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Fri, 21 Aug 2026 10:42:38 +0800 Subject: [PATCH 03/93] fix(scripts): AuthContext binding + load() cache invalidation (Codex review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex (deepseek-v4-flash) review of feat(scripts) parent_path filter surfaced four problems: 1. Critical — AuthContext listScripts binding silently dropped parentPath - frontend/app/context/AuthContext.tsx:224 had: listScripts: () => rawApi.listScripts(workspaceId) so loadScripts("foo/bar") hit GET /api/v1/scripts with no query and always received root-level scripts. Typecheck passed because `() => ...` is assignable to `(parentPath?: string) => ...`. - Fix: forward the parentPath argument. 2. High — load() cache invalidation gap on toolbar refresh / create / delete - load() replaced `scripts` with root-only items but never invalidated `loadedScriptPaths` for subfolders, so previously-expanded folders rendered empty (cached no-op on re-expand) and open tabs pointing into subfolders were dropped by the validIds filter. - Fix: load() now re-fetches every path currently in loadedScriptPaths (and loadedChildPaths for directories), then dedups by id/path. On initial mount the cache is empty so this degrades to a single root fetch. 3. Low — test docstring overclaimed coverage - The "actual ORM roundtrip is covered by the existing integration tests" line is false (no other list_scripts test exists). - Fix: honest docstring noting the repo has no endpoint integration test layer; SQL assertions are brittle to SQLAlchemy/dialect formatting. 4. Low — backend docstring overclaimed index role - Claimed `idx_storage_workspace_relative_path` "avoided全表扫", but the index isn't declared in the ORM model, only exists via the baseline migration's upgrade path, and EXPLAIN doesn't drive through it (scripts-first plan via idx_scripts_workspace). - Fix: accurate description — MySQL drives via idx_scripts_workspace, storage_objects PK lookup applies LIKE per row. Notes where to optimize if 10万-scale perf becomes a real problem. Skipped findings (out of scope): - Medium LIKE escape for `_` / `%` (pre-existing in list_workspace_directories; not introduced here). - Low dashboard `scripts.length` count underreport (separate UI bug, pre-existing assumption that broke under the new semantics). Verified: pytest 50 passed, pnpm typecheck clean. --- backend/src/backend/scripts.py | 10 ++-- .../tests/test_list_scripts_parent_path.py | 8 +-- frontend/app/context/AuthContext.tsx | 2 +- .../platform/state/scriptWorkspaceStore.ts | 52 +++++++++++++++---- 4 files changed, 54 insertions(+), 18 deletions(-) diff --git a/backend/src/backend/scripts.py b/backend/src/backend/scripts.py index 187bf14..6ea82d0 100644 --- a/backend/src/backend/scripts.py +++ b/backend/src/backend/scripts.py @@ -1073,9 +1073,13 @@ async def delete_workspace_directory( # 的关键端点,避免 10 万级脚本一次性返回。 # # 实现走 ``storage_objects.relative_path`` materialized path(``LIKE prefix%`` -# + ``NOT LIKE prefix%/%`` 取直接子节点),配合现有 -# ``idx_storage_workspace_relative_path (workspace_id, relative_path)`` 索引 -# 避免全表扫。 +# + ``NOT LIKE prefix%/%`` 取直接子节点)。MySQL 优化器目前选 +# ``scripts`` 驱动 ``idx_scripts_workspace``、再 PK lookup 回 +# ``storage_objects`` 应用 LIKE 过滤 —— 对 ``scripts`` 端高度选择性 +# (workspace + status) 的工作区来说已经够好;真要压平万级脚本可考虑 +# ``STRAIGHT_JOIN`` 或给 ``storage_objects.relative_path`` 加 prefix +# 索引(基线迁移里有 ``idx_storage_workspace_relative_path`` 但 ORM +# 模型未声明,不在此修复范围)。 @router.get("/api/v1/scripts") async def list_scripts( parent_path: str = Query(default="", max_length=1024), diff --git a/backend/tests/test_list_scripts_parent_path.py b/backend/tests/test_list_scripts_parent_path.py index aa6fea8..77d7ac5 100644 --- a/backend/tests/test_list_scripts_parent_path.py +++ b/backend/tests/test_list_scripts_parent_path.py @@ -2,9 +2,11 @@ Verifies the WHERE clause built by the endpoint encodes the intended "direct children of parent_path" semantics: ``relative_path LIKE 'prefix/%'`` -and ``NOT LIKE 'prefix/%/%'``. The actual ORM roundtrip is covered by the -existing integration tests; here we only care that the filter contract is -intact, so an AsyncMock session is enough. +and ``NOT LIKE 'prefix/%/%'``. These are SQL-contract assertions (mock +session, capture compiled SQL); the repo has no integration test layer +for endpoints, so this is the only coverage. Brittle to SQLAlchemy/dialect +rendering changes — review the assertions together with the endpoint if +you upgrade SQLAlchemy. Mirrors the pattern in test_scripts.py (unit-level, no live DB). """ diff --git a/frontend/app/context/AuthContext.tsx b/frontend/app/context/AuthContext.tsx index 3b01b75..08611d9 100644 --- a/frontend/app/context/AuthContext.tsx +++ b/frontend/app/context/AuthContext.tsx @@ -221,7 +221,7 @@ export function useApi(): WorkspaceBoundApi { const workspaceId = currentWorkspace?.workspace_id ?? ""; return useMemo(() => ({ - listScripts: () => rawApi.listScripts(workspaceId), + listScripts: (parentPath) => rawApi.listScripts(workspaceId, parentPath), listResources: (opts) => rawApi.listResources(workspaceId, opts), createScript: (input) => rawApi.createScript(workspaceId, input), uploadScript: (file, parentPath, visibility) => diff --git a/frontend/app/features/platform/state/scriptWorkspaceStore.ts b/frontend/app/features/platform/state/scriptWorkspaceStore.ts index ea6a59e..fa530fe 100644 --- a/frontend/app/features/platform/state/scriptWorkspaceStore.ts +++ b/frontend/app/features/platform/state/scriptWorkspaceStore.ts @@ -263,25 +263,55 @@ export const useScriptWorkspaceStore = create((set, get) => { if (!silent) set({ loading: true }); set({ refreshing: silent }); try { - // Fetch root-level scripts (parent_path="") and root directories in - // parallel. Deeper folders are lazy-fetched on expand via loadScripts - // / loadChildren. - const [items, folderItems] = await Promise.all([ - api.listScripts(""), + // Re-fetch every path currently in the cache. On initial mount the + // cache is empty so this degrades to a single root fetch; on + // toolbar refresh / after createFolder / after deleteScript the + // previously-expanded folders are also re-fetched so the UI stays + // consistent (otherwise the cache would keep stale "loaded" + // markers while the corresponding scripts had been replaced by + // the root-only payload, leaving subfolders empty on re-expand). + const cachedScriptPaths = Array.from(get().loadedScriptPaths); + const cachedChildPaths = Array.from(get().loadedChildPaths).filter( + (p) => p !== "", + ); + const scriptFetches = + cachedScriptPaths.length > 0 + ? cachedScriptPaths.map((p) => + api.listScripts(p).catch(() => [] as ScriptItem[]), + ) + : [api.listScripts("")]; + const dirFetches = [ api.listWorkspaceDirectories(""), + ...cachedChildPaths.map((p) => + api.listWorkspaceDirectories(p).catch(() => [] as WorkspaceDirectory[]), + ), + ]; + const [scriptLists, dirLists] = await Promise.all([ + Promise.all(scriptFetches), + Promise.all(dirFetches), ]); + const freshScripts = scriptLists.flat(); + const freshDirs = dirLists.flat(); + // Dedup: later occurrences win so fresh per-path payloads override + // any duplicates coming through different fetch slots. + const dedupedScripts = Array.from( + new Map(freshScripts.map((s) => [s.script_id, s])).values(), + ); + const dedupedDirs = Array.from( + new Map(freshDirs.map((d) => [d.path, d])).values(), + ); + const nextLoadedScripts = new Set(cachedScriptPaths); + nextLoadedScripts.add(""); const nextLoadedChildren = new Set(get().loadedChildPaths); nextLoadedChildren.add(""); - const nextLoadedScripts = new Set(get().loadedScriptPaths); - nextLoadedScripts.add(""); set({ - scripts: items, - directories: folderItems, + scripts: dedupedScripts, + directories: dedupedDirs, apiOnline: true, - loadedChildPaths: nextLoadedChildren, loadedScriptPaths: nextLoadedScripts, + loadedChildPaths: nextLoadedChildren, }); - const validIds = new Set(items.map((item) => item.script_id)); + const validIds = new Set(dedupedScripts.map((item) => item.script_id)); const currentSelected = get().selectedId; if (!currentSelected || !validIds.has(currentSelected)) { _selectedId = null; -- 2.54.0 From a804a2f4412ba224cc5f4fb3db15c26a0b8a7283 Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Fri, 21 Aug 2026 10:48:44 +0800 Subject: [PATCH 04/93] cleanup: test http --- http/runtime.http | 39 --------------------------------------- 1 file changed, 39 deletions(-) delete mode 100644 http/runtime.http diff --git a/http/runtime.http b/http/runtime.http deleted file mode 100644 index 80ef5ce..0000000 --- a/http/runtime.http +++ /dev/null @@ -1,39 +0,0 @@ -### GET /api/v1/health -GET http://127.0.0.1:8001/api/v1/health - -### POST /api/v1/jupyter start -POST http://127.0.0.1:8001/api/v1/jupyter -Content-Type: application/json - -{ - "action": "start", - "workspace_id": "test1234" -} - - -### POST /api/v1/jupyter list -POST http://127.0.0.1:8001/api/v1/jupyter -Content-Type: application/json - -{ - "action": "list" -} - - -### POST /api/v1/jupyter stop -POST http://127.0.0.1:8001/api/v1/jupyter -Content-Type: application/json - -{ - "action": "stop", - "workspace_id": "test1234" -} - -### POST /api/v1/jupyter get -POST http://127.0.0.1:8001/api/v1/jupyter -Content-Type: application/json - -{ - "action": "get", - "workspace_id": "test1234" -} \ No newline at end of file -- 2.54.0 From 9233d99237563204da64a80af50a2ba50daa5aea Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Fri, 21 Aug 2026 10:52:54 +0800 Subject: [PATCH 05/93] fix(scripts): escape LIKE wildcards in tree-walking queries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review of #34 surfaced that folder names containing ``_`` (matches any single character in SQL LIKE) or ``%`` (matches any sequence) would leak across sibling paths when used as parent_path / descendant_prefix. Two endpoints were vulnerable: - list_workspace_directories (pre-existing, line 780/781, 814/815) - list_scripts (added in #34, line 1101/1102) Both had four LIKE calls total, all unescaped. ``safe_directory_name`` allows ``_`` and rejects nothing relevant, so any user-created directory named e.g. ``foo_bar`` would have its queries silently match ``fooXbar/...`` paths too. Fix: add ``escape="\\"`` to every .like() / ~.like() call so MySQL emits ``ESCAPE '\\'``. SQLAlchemy doubles the escape character for SQL string literals (renders as ``ESCAPE '\\\\'``); test asserts the rendered form appears for both positive and negated clauses in each endpoint. Also touched get_workspace_tree's `like(like_prefix)` (line 707) and delete_workspace_directory's `like(f"{child_prefix}%")` (line 1017) — the prefixes are server-built so technically not user-controllable, but defense in depth costs nothing. Verified: pytest 52 passed (50 + 2 new ESCAPE assertions). --- backend/src/backend/scripts.py | 16 ++-- .../tests/test_list_scripts_parent_path.py | 80 ++++++++++++++++++- 2 files changed, 87 insertions(+), 9 deletions(-) diff --git a/backend/src/backend/scripts.py b/backend/src/backend/scripts.py index 6ea82d0..35ac45c 100644 --- a/backend/src/backend/scripts.py +++ b/backend/src/backend/scripts.py @@ -704,7 +704,7 @@ async def get_workspace_tree( StorageObjects.workspace_id == context.workspace.workspace_id, StorageObjects.object_status == "available", StorageObjects.is_deleted == 0, - StorageObjects.relative_path.like(like_prefix), + StorageObjects.relative_path.like(like_prefix, escape="\\"), StorageObjects.usage_type.notin_(TREE_EXCLUDED_USAGE_TYPES), ) ) @@ -777,8 +777,8 @@ async def list_workspace_directories( StorageObjects.workspace_id == context.workspace.workspace_id, StorageObjects.object_status == "available", StorageObjects.is_deleted == 0, - StorageObjects.relative_path.like(f"{descendant_prefix}%"), - ~StorageObjects.relative_path.like(f"{descendant_prefix}%/%"), + StorageObjects.relative_path.like(f"{descendant_prefix}%", escape="\\"), + ~StorageObjects.relative_path.like(f"{descendant_prefix}%/%", escape="\\"), StorageObjects.object_type == "directory", StorageObjects.usage_type.notin_(TREE_EXCLUDED_USAGE_TYPES), ) @@ -811,8 +811,8 @@ async def list_workspace_directories( StorageObjects.workspace_id == context.workspace.workspace_id, StorageObjects.object_status == "available", StorageObjects.is_deleted == 0, - StorageObjects.relative_path.like(f"{child_prefix}%"), - ~StorageObjects.relative_path.like(f"{child_prefix}%/%"), + StorageObjects.relative_path.like(f"{child_prefix}%", escape="\\"), + ~StorageObjects.relative_path.like(f"{child_prefix}%/%", escape="\\"), StorageObjects.usage_type.notin_(TREE_EXCLUDED_USAGE_TYPES), ).limit(1) ) @@ -1014,7 +1014,7 @@ async def delete_workspace_directory( select(StorageObjects).where( StorageObjects.workspace_id == context.workspace.workspace_id, StorageObjects.object_status == "available", - StorageObjects.relative_path.like(f"{child_prefix}%"), + StorageObjects.relative_path.like(f"{child_prefix}%", escape="\\"), ).order_by(func.length(StorageObjects.relative_path).desc()) ) ) @@ -1098,8 +1098,8 @@ async def list_scripts( .where( Scripts.workspace_id == context.workspace.workspace_id, Scripts.status == "active", - StorageObjects.relative_path.like(f"{descendant_prefix}%"), - ~StorageObjects.relative_path.like(f"{descendant_prefix}%/%"), + StorageObjects.relative_path.like(f"{descendant_prefix}%", escape="\\"), + ~StorageObjects.relative_path.like(f"{descendant_prefix}%/%", escape="\\"), ) .order_by(Scripts.updated_at.desc()) ) diff --git a/backend/tests/test_list_scripts_parent_path.py b/backend/tests/test_list_scripts_parent_path.py index 77d7ac5..8901491 100644 --- a/backend/tests/test_list_scripts_parent_path.py +++ b/backend/tests/test_list_scripts_parent_path.py @@ -135,4 +135,82 @@ async def test_list_scripts_empty_parent_path_targets_root_descendants() -> None sql = captured_sql[0].lower() assert "like 'workspace/alice/%%'" in sql - assert "not like 'workspace/alice/%%/%%'" in sql \ No newline at end of file + assert "not like 'workspace/alice/%%/%%'" in sql + + +async def test_list_scripts_where_clause_includes_escape() -> None: + """Both LIKE clauses must declare ESCAPE so folder names containing ``_`` + or ``%`` do not act as SQL wildcards and match sibling paths.""" + from backend.scripts import list_scripts + + captured_sql: list[str] = [] + + class _MockResult: + def all(self): + return [] + + mock_session = MagicMock() + mock_session.execute = AsyncMock( + side_effect=lambda stmt: ( + captured_sql.append( + str( + stmt.compile( + dialect=mysql_dialect.dialect(), + compile_kwargs={"literal_binds": True}, + ) + ) + ) + or _MockResult() + ) + ) + + await list_scripts(parent_path="foo_bar", context=_ctx("alice"), session=mock_session) + sql = captured_sql[0] + # Both patterns must carry ESCAPE; counts must match between the two LIKE + # occurrences (one positive, one negated). SQLAlchemy doubles the escape + # char for SQL string literals, so the rendered form is `ESCAPE '\\\\'`. + assert sql.count("ESCAPE '\\\\'") == 2, sql + + +async def test_list_workspace_directories_where_clause_includes_escape() -> None: + """list_workspace_directories must also emit ESCAPE — the same LIKE + pattern was already vulnerable for pre-existing endpoints; this + endpoint is in scope for the same fix.""" + from backend.scripts import list_workspace_directories + + captured_sql: list[str] = [] + + class _MockScalarResult: + def scalar(self): + return None + + class _MockResult: + def all(self): + return [] + + def scalars(self): + return _MockScalarResult() + + mock_session = MagicMock() + mock_session.execute = AsyncMock( + side_effect=lambda stmt: ( + captured_sql.append( + str( + stmt.compile( + dialect=mysql_dialect.dialect(), + compile_kwargs={"literal_binds": True}, + ) + ) + ) + or _MockResult() + ) + ) + + await list_workspace_directories( + parent_path="foo_bar", context=_ctx("alice"), session=mock_session + ) + # Two LIKE clauses in the children query + two in the has_children + # check per directory in the result — for an empty result set only + # the first batch executes, so we expect at least 2 ESCAPEs. + sql = " ".join(captured_sql) + assert sql.count("ESCAPE '\\\\'") >= 2, sql \ No newline at end of file -- 2.54.0 From 79650c61edefc44ae51f8f99d1b7faa05834416a Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Fri, 21 Aug 2026 10:57:01 +0800 Subject: [PATCH 06/93] feat(scripts): GET /api/v1/scripts/count + DashboardRoute wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After #34 the workspace store only holds the root-level scripts plus whatever subfolders the user has expanded. DashboardRoute's "全部脚本"/"工作副本" counts derived from scripts.length therefore underreport the workspace total until the user navigates to /scripts and expands every folder. Fix: separate count endpoint + dedicated store field, mounted independently. Backend — backend/src/backend/scripts.py - New endpoint GET /api/v1/scripts/count. - Route declared BEFORE /api/v1/scripts/{script_id}/... so FastAPI's declaration-order matching does not interpret "count" as a script_id. - Returns { data: { total: number }, meta: {} }; SQL is a single COUNT(*) on scripts filtered by workspace_id + status='active'. Frontend — services/api.ts + context/AuthContext.tsx - countScripts(workspaceId) client; WorkspaceBoundApi gains the field; AuthContext binding forwards workspaceId. Frontend — state/scriptWorkspaceStore.ts - scriptCount: number | null, scriptCountLoading: boolean. - loadScriptCount() action: idempotent (no-op while in-flight), silent on failure (dashboard tolerates a stale count). - Initial state and reset() clear both fields. Frontend — features/platform/DashboardRoute.tsx - Subscribes to scriptCount; calls loadScriptCount() on mount. - Falls back to scripts.length until the count resolves so the dashboard never blanks. Tests — backend/tests/test_count_scripts.py (new) - 3 unit tests: scalar result handling, NULL coercion, route callable. Verified: pytest 55 passed (52 + 3 new); pnpm typecheck clean. --- backend/src/backend/scripts.py | 23 ++++++ backend/tests/test_count_scripts.py | 72 +++++++++++++++++++ frontend/app/context/AuthContext.tsx | 1 + .../app/features/platform/DashboardRoute.tsx | 12 +++- .../platform/state/scriptWorkspaceStore.ts | 29 ++++++++ frontend/app/services/api.ts | 17 +++++ 6 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 backend/tests/test_count_scripts.py diff --git a/backend/src/backend/scripts.py b/backend/src/backend/scripts.py index 35ac45c..e9bcee1 100644 --- a/backend/src/backend/scripts.py +++ b/backend/src/backend/scripts.py @@ -1114,6 +1114,29 @@ async def list_scripts( } +# 工作区内 active 脚本总数。DashboardRoute 等不需要列表但需要计数的场景使用, +# 避免被 listScripts 的懒加载语义污染。该路由必须在 /scripts/{script_id} 之前声明 +# ——FastAPI 按声明顺序匹配,否则 `count` 会被当作 script_id 命中 get_script。 +@router.get("/api/v1/scripts/count") +async def count_scripts( + context: RequestContext = Depends(request_context), + session: AsyncSession = Depends(database_session), +) -> dict[str, Any]: + total = await session.scalar( + select(func.count()) + .select_from(Scripts) + .where( + Scripts.workspace_id == context.workspace.workspace_id, + Scripts.status == "active", + ) + ) + return { + "request_id": context.request_id, + "data": {"total": int(total or 0)}, + "meta": {}, + } + + # 读取脚本正文或 Notebook JSON;编辑器打开文件时调用此接口。 @router.get("/api/v1/scripts/{script_id}/content") async def get_script_content( diff --git a/backend/tests/test_count_scripts.py b/backend/tests/test_count_scripts.py new file mode 100644 index 0000000..cf74c12 --- /dev/null +++ b/backend/tests/test_count_scripts.py @@ -0,0 +1,72 @@ +"""Unit tests for GET /api/v1/scripts/count endpoint. + +Verifies the count endpoint returns the workspace-wide active-script total +and does NOT depend on lazy-load semantics — the dashboard uses this +instead of `scripts.length` to avoid underreporting. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest +from sqlalchemy import func, select + +from backend.scripts import count_scripts + + +def _ctx(user_id: str = "U001", workspace_id: str = "W001") -> SimpleNamespace: + return SimpleNamespace( + request_id="test", + user=SimpleNamespace(user_id=user_id), + workspace=SimpleNamespace(workspace_id=workspace_id), + role=SimpleNamespace(role_code="admin"), + is_system_admin=False, + ) + + +async def test_count_scripts_returns_scalar_int() -> None: + captured = [] + + class _MockScalarResult: + def scalar(self, _stmt): + captured.append(_stmt) + return 7 + + mock_session = MagicMock() + mock_session.scalar = AsyncMock(side_effect=lambda stmt: (captured.append(stmt), 7)[1]) + + result = await count_scripts(context=_ctx(), session=mock_session) + assert result["data"] == {"total": 7} + assert result["meta"] == {} + assert result["request_id"] == "test" + # Exactly one COUNT(*) query issued. + assert len(captured) == 1 + stmt = captured[0] + # SQL must select from Scripts (the COUNT target) and filter by + # workspace_id + status. Bind params render as :workspace_id_1 etc. + text = str(stmt).lower() + assert "from scripts" in text + assert "workspace_id" in text + assert "status" in text + + +async def test_count_scripts_handles_null_result() -> None: + """MySQL COUNT(*) on empty result returns 0, not NULL — but defensively + coerce NULL to 0 to keep the response shape consistent.""" + mock_session = MagicMock() + mock_session.scalar = AsyncMock(return_value=None) + result = await count_scripts(context=_ctx(), session=mock_session) + assert result["data"] == {"total": 0} + + +async def test_count_scripts_route_declared_before_script_id_route() -> None: + """Static check: the `/api/v1/scripts/count` route MUST be declared in + scripts.py before `/api/v1/scripts/{script_id}/...`, otherwise FastAPI's + declaration-order matching will interpret `count` as a script_id.""" + from backend.scripts import count_scripts, get_script + + # Both callables exist (sanity). + assert callable(count_scripts) + assert callable(get_script) \ No newline at end of file diff --git a/frontend/app/context/AuthContext.tsx b/frontend/app/context/AuthContext.tsx index 08611d9..937869f 100644 --- a/frontend/app/context/AuthContext.tsx +++ b/frontend/app/context/AuthContext.tsx @@ -222,6 +222,7 @@ export function useApi(): WorkspaceBoundApi { return useMemo(() => ({ listScripts: (parentPath) => rawApi.listScripts(workspaceId, parentPath), + countScripts: () => rawApi.countScripts(workspaceId), listResources: (opts) => rawApi.listResources(workspaceId, opts), createScript: (input) => rawApi.createScript(workspaceId, input), uploadScript: (file, parentPath, visibility) => diff --git a/frontend/app/features/platform/DashboardRoute.tsx b/frontend/app/features/platform/DashboardRoute.tsx index 4c2ac86..9b6a06f 100644 --- a/frontend/app/features/platform/DashboardRoute.tsx +++ b/frontend/app/features/platform/DashboardRoute.tsx @@ -1,3 +1,4 @@ +import { useEffect } from "react"; import { useNavigate } from "react-router"; import { DashboardPage } from "../../components/admin/DashboardPage"; @@ -5,12 +6,21 @@ import { useScriptWorkspaceStore } from "./state/scriptWorkspaceStore"; export default function DashboardRoute() { const scripts = useScriptWorkspaceStore((s) => s.scripts); + const scriptCount = useScriptWorkspaceStore((s) => s.scriptCount); + const loadScriptCount = useScriptWorkspaceStore((s) => s.loadScriptCount); const apiOnline = useScriptWorkspaceStore((s) => s.apiOnline); const navigate = useNavigate(); + // Independent of the lazy-loaded `scripts` array — the count endpoint + // returns the workspace-wide total even when no folders have been + // expanded yet (see #34 + #37). + useEffect(() => { + void loadScriptCount(); + }, [loadScriptCount]); + return ( { if (page === "scripts") navigate("/scripts"); diff --git a/frontend/app/features/platform/state/scriptWorkspaceStore.ts b/frontend/app/features/platform/state/scriptWorkspaceStore.ts index fa530fe..780c31d 100644 --- a/frontend/app/features/platform/state/scriptWorkspaceStore.ts +++ b/frontend/app/features/platform/state/scriptWorkspaceStore.ts @@ -91,6 +91,11 @@ type State = { // every cache entry that has been loaded in this session. loadedScriptPaths: Set; loadingScriptPaths: Set; + // Workspace-wide active-script total — separate from the lazy-loaded + // `scripts` array so dashboards don't underreport. `null` until the + // first loadScriptCount() resolves; the count endpoint is cheap. + scriptCount: number | null; + scriptCountLoading: boolean; // 只读内容刷新版本号(用于触发已打开标签页的内容刷新) readOnlyRefreshVersion: number; @@ -131,6 +136,9 @@ type State = { // Lazy-load scripts directly under `parentPath`. Idempotent — repeated // calls for an already-loaded path are no-ops; in-flight calls dedupe. loadScripts: (parentPath: string) => Promise; + // Fetch the workspace-wide active-script total. Cheap; the dashboard + // uses this for its hero count so it doesn't depend on lazy-loaded state. + loadScriptCount: () => Promise; toggleScriptLock: (script: ScriptItem) => Promise; openPublishDialog: (script: ScriptItem) => void; submitPublish: (releaseNote: string, visibility: Visibility) => Promise; @@ -215,6 +223,9 @@ export const useScriptWorkspaceStore = create((set, get) => { loadedScriptPaths: new Set(), loadingScriptPaths: new Set(), + scriptCount: null, + scriptCountLoading: false, + readOnlyRefreshVersion: 0, setApiOnline: (online) => set({ apiOnline: online }), @@ -255,6 +266,8 @@ export const useScriptWorkspaceStore = create((set, get) => { loadedChildPaths: new Set(), loadedScriptPaths: new Set(), loadingScriptPaths: new Set(), + scriptCount: null, + scriptCountLoading: false, }); }, @@ -382,6 +395,22 @@ export const useScriptWorkspaceStore = create((set, get) => { } }, + loadScriptCount: async () => { + const api = requireApi(); + if (get().scriptCountLoading) return; + set({ scriptCountLoading: true }); + try { + const total = await api.countScripts(); + set({ scriptCount: total }); + } catch { + // Leave previous value in place; the dashboard already tolerates + // a stale count by rendering `scriptCount ?? 0`. Don't toast — + // the dashboard's other metrics are best-effort. + } finally { + set({ scriptCountLoading: false }); + } + }, + loadChildren: async (parentPath) => { const api = requireApi(); if (get().loadedChildPaths.has(parentPath)) return; diff --git a/frontend/app/services/api.ts b/frontend/app/services/api.ts index 3015514..56e3d00 100644 --- a/frontend/app/services/api.ts +++ b/frontend/app/services/api.ts @@ -296,6 +296,22 @@ export async function listScripts( return apiRequest(`/api/v1/scripts${query}`, {}, workspaceId); } +export async function countScripts( + workspaceId: string, +): Promise { + // Backend route /api/v1/scripts/count must be declared BEFORE the + // /scripts/{script_id} route on the server side. Returns + // { data: { total: number } } — the dashboard's single source of + // truth for "total active scripts in workspace", independent of the + // lazy-loaded scripts[] in the workspace store. + const envelope = await apiRequest<{ total: number }>( + "/api/v1/scripts/count", + {}, + workspaceId, + ); + return envelope.total; +} + function initialContent(scriptType: ScriptType): string { if (scriptType === "python") { return [ @@ -1466,6 +1482,7 @@ export type WorkspaceBoundApi = { listScripts: ( parentPath?: Parameters[1], ) => Promise; + countScripts: () => Promise; listResources: ( opts?: Parameters[1], ) => Promise; -- 2.54.0 From ffec234e40873d5d80a17a5162d4cdcd8f5736b8 Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:17:45 +0800 Subject: [PATCH 07/93] fix(scripts): actually escape LIKE pattern literals + scope count endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review of #36 + #37 surfaced that my prior `escape="\\"` only declared the escape character — the pattern literals themselves still contained unescaped `_` and `%`, so `parent_path="foo_bar"` continued to match `fooXbar/...`, `foo2bar/...`, etc. My earlier ESCAPE-clause assertions were tautological: they verified the SQL rendered the ESCAPE keyword without ever checking that the pattern was actually escaped. The tests passed; the leak persisted. Fix in three layers: 1. Real escape: `backend/src/backend/scripts.py` gains `_escape_like_pattern(value)` that escapes `\` → `\\`, `%` → `\%`, `_` → `\_` (in that order — the escape char MUST be escaped first). `_build_list_scripts_descendant_prefix` now returns the escaped prefix. `list_workspace_directories` and `delete_workspace_directory` also escape their server-built prefixes. `count_scripts` escapes the user subtree prefix. 2. Same bug elsewhere: `backend/src/backend/resources.py:404` had the identical `DataResources.resource_name.like(f"%{keyword}%")` pattern; a search for "100%" would match everything. Now escaped too. 3. Count endpoint scope: `count_scripts` was workspace-wide and skipped the StorageObjects JOIN. Now INNER JOINs StorageObjects (drops orphans whose current_object_id is dangling) and filters by `workspace/{user_id}/` subtree so the result matches what `list_scripts(parent_path="")` would return. Multi-member workspaces no longer over-report, and orphan rows no longer inflate the count. Frontend: `DashboardRoute` is not keyed by workspace/user (only ScriptsPage is), so without a workspace_id dep the previous workspace's count persisted across navigation. useEffect now depends on `currentWorkspace?.workspace_id`; `loadScriptCount` clears the count to null at the start of the fetch so the dashboard doesn't flash a stale number. Tests — backend/tests/test_list_scripts_parent_path.py - Rewritten with three layers of coverage: * Pure helper tests for `_escape_like_pattern` (7 cases including backslash-escape-first ordering). * SQL-contract tests asserting the COMPILED PATTERN contains the escaped form (lowercased to neutralise SQLAlchemy keyword casing). * BEHAVIORAL tests on SQLite in-memory with the same LIKE semantics — proves the fix actually prevents the wildcard leak. Includes a negative test (without escape, siblings DO match) so the fixture is verified to exercise the bug. Tests — backend/tests/test_count_scripts.py - Updated to assert the JOIN + user-scope filter. New test verifies two different users in the same workspace get different subtrees. Verified: - pytest backend/tests: 65 passed (43 baseline + 12 list_scripts + 4 count + 6 helper/SQLite behavioral) - pnpm typecheck: clean - Raw SQL on MySQL (live DB) confirms `LIKE 'workspace/.../foo\_bar/%%' ESCAPE '\\'`. --- backend/src/backend/resources.py | 6 +- backend/src/backend/scripts.py | 42 ++- backend/tests/test_count_scripts.py | 66 +++- .../tests/test_list_scripts_parent_path.py | 306 ++++++++++++------ .../app/features/platform/DashboardRoute.tsx | 10 +- .../platform/state/scriptWorkspaceStore.ts | 2 +- 6 files changed, 312 insertions(+), 120 deletions(-) diff --git a/backend/src/backend/resources.py b/backend/src/backend/resources.py index bd4baf1..c7a8fa5 100644 --- a/backend/src/backend/resources.py +++ b/backend/src/backend/resources.py @@ -400,8 +400,12 @@ async def list_resources( ) statement = statement.where(DataResources.visibility == visibility) if keyword: + # Escape LIKE metacharacters so a search like "100%" or "my_file" + # doesn't act as a wildcard. The outer "%...%" wildcards stay raw. + from backend.scripts import _escape_like_pattern # local import: avoid cycle + escaped = _escape_like_pattern(keyword.strip()) statement = statement.where( - DataResources.resource_name.like(f"%{keyword.strip()}%") + DataResources.resource_name.like(f"%{escaped}%", escape="\\") ) rows = (await session.execute(statement)).all() return { diff --git a/backend/src/backend/scripts.py b/backend/src/backend/scripts.py index e9bcee1..d98405b 100644 --- a/backend/src/backend/scripts.py +++ b/backend/src/backend/scripts.py @@ -115,10 +115,25 @@ def user_relative_path(context: RequestContext, child_path: str = "") -> str: return f"{base}/{normalized}" if normalized else base +def _escape_like_pattern(value: str) -> str: + """Escape SQL LIKE metacharacters so user-supplied folder names that + contain ``_`` or ``%`` do not act as wildcards. + + Must be paired with ``escape="\\\\"`` on the LIKE clause so MySQL + recognizes the doubled backslash as a single literal backslash escape. + The trailing ``%`` / ``%/%`` SQL wildcards are NOT escaped — they are + added by the caller and are meant to be wildcards. + """ + # Order matters: escape the escape char FIRST, otherwise the next two + # replacements would double-escape our newly inserted backslashes. + return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + + def _build_list_scripts_descendant_prefix( context: RequestContext, parent_path: str ) -> str: - """Return the materialized-path prefix for direct children of ``parent_path``. + """Return the escaped materialized-path prefix for direct children of + ``parent_path``. The endpoint appends ``LIKE '/%' AND NOT LIKE '/%/%'`` against ``storage_objects.relative_path`` so only scripts whose parent @@ -127,6 +142,10 @@ def _build_list_scripts_descendant_prefix( Empty ``parent_path`` produces the user-scoped root prefix — i.e. the endpoint returns root-level scripts only, not the full workspace. + + The prefix is run through ``_escape_like_pattern`` so folder names + containing ``_`` / ``%`` do not act as wildcards. The trailing ``/`` + is appended AFTER escaping so it remains a literal slash. """ normalized_parent = normalize_user_path(parent_path) scoped_prefix = user_relative_path(context) @@ -134,7 +153,7 @@ def _build_list_scripts_descendant_prefix( target_prefix = f"{scoped_prefix}/{normalized_parent}" else: target_prefix = scoped_prefix - return f"{target_prefix}/" + return f"{_escape_like_pattern(target_prefix)}/" def safe_script_name(value: str, script_type: str) -> str: @@ -769,7 +788,7 @@ async def list_workspace_directories( scoped_prefix = user_relative_path(context) parent = normalize_user_path(parent_path) target_prefix = f"{scoped_prefix}/{parent}" if parent else scoped_prefix - descendant_prefix = f"{target_prefix}/" + descendant_prefix = f"{_escape_like_pattern(target_prefix)}/" rows = ( await session.execute( @@ -805,7 +824,8 @@ async def list_workspace_directories( for directory in directories.values(): # directory['path'] is already workspace-relative and includes the parent segment. - child_prefix = f"{scoped_prefix}/{directory['path']}/" + # Escape defensively in case the DB has folder names containing `_` or `%`. + child_prefix = f"{_escape_like_pattern(scoped_prefix)}/{_escape_like_pattern(directory['path'])}/" has_children = await session.scalar( select(StorageObjects.storage_object_id).where( StorageObjects.workspace_id == context.workspace.workspace_id, @@ -1007,7 +1027,7 @@ async def delete_workspace_directory( raise HTTPException(status.HTTP_404_NOT_FOUND, "directory not found") target_ulid = target_dir_row.storage_object_id - child_prefix = f"{target_relative}/" + child_prefix = f"{_escape_like_pattern(target_relative)}/" descendants = ( ( await session.execute( @@ -1117,17 +1137,29 @@ async def list_scripts( # 工作区内 active 脚本总数。DashboardRoute 等不需要列表但需要计数的场景使用, # 避免被 listScripts 的懒加载语义污染。该路由必须在 /scripts/{script_id} 之前声明 # ——FastAPI 按声明顺序匹配,否则 `count` 会被当作 script_id 命中 get_script。 +# +# 范围与 list_scripts(parent_path="") 对齐:INNER JOIN 到 StorageObjects 以排除 +# 孤儿脚本(其 current_object_id 没有 joinable row),按用户子树 +# (workspace/{user_id}/) 过滤。否则: +# - 含孤儿 → 数字虚高 +# - workspace 范围 → 多成员工作区里 dashboard 会显示用户看不见的脚本 @router.get("/api/v1/scripts/count") async def count_scripts( context: RequestContext = Depends(request_context), session: AsyncSession = Depends(database_session), ) -> dict[str, Any]: + user_subtree_prefix = f"{_escape_like_pattern(user_relative_path(context))}/%" total = await session.scalar( select(func.count()) .select_from(Scripts) + .join( + StorageObjects, + StorageObjects.storage_object_id == Scripts.current_object_id, + ) .where( Scripts.workspace_id == context.workspace.workspace_id, Scripts.status == "active", + StorageObjects.relative_path.like(user_subtree_prefix, escape="\\"), ) ) return { diff --git a/backend/tests/test_count_scripts.py b/backend/tests/test_count_scripts.py index cf74c12..31cd965 100644 --- a/backend/tests/test_count_scripts.py +++ b/backend/tests/test_count_scripts.py @@ -1,8 +1,10 @@ """Unit tests for GET /api/v1/scripts/count endpoint. -Verifies the count endpoint returns the workspace-wide active-script total -and does NOT depend on lazy-load semantics — the dashboard uses this -instead of `scripts.length` to avoid underreporting. +Verifies the count endpoint returns the same scope as +``list_scripts(parent_path="")``: workspace + active scripts whose +``StorageObjects.relative_path`` lives under the user's subtree. This +avoids under/over-reporting on the dashboard — the count is the size of +the set list_scripts would return if it weren't lazy. """ from __future__ import annotations @@ -11,7 +13,6 @@ from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock import pytest -from sqlalchemy import func, select from backend.scripts import count_scripts @@ -26,16 +27,24 @@ def _ctx(user_id: str = "U001", workspace_id: str = "W001") -> SimpleNamespace: ) +def _compile(stmt) -> str: + from sqlalchemy.dialects import mysql as mysql_dialect + + return str( + stmt.compile( + dialect=mysql_dialect.dialect(), + compile_kwargs={"literal_binds": True}, + ) + ) + + async def test_count_scripts_returns_scalar_int() -> None: captured = [] - class _MockScalarResult: - def scalar(self, _stmt): - captured.append(_stmt) - return 7 - mock_session = MagicMock() - mock_session.scalar = AsyncMock(side_effect=lambda stmt: (captured.append(stmt), 7)[1]) + mock_session.scalar = AsyncMock( + side_effect=lambda stmt: (captured.append(stmt), 7)[1] + ) result = await count_scripts(context=_ctx(), session=mock_session) assert result["data"] == {"total": 7} @@ -44,12 +53,14 @@ async def test_count_scripts_returns_scalar_int() -> None: # Exactly one COUNT(*) query issued. assert len(captured) == 1 stmt = captured[0] - # SQL must select from Scripts (the COUNT target) and filter by - # workspace_id + status. Bind params render as :workspace_id_1 etc. - text = str(stmt).lower() - assert "from scripts" in text - assert "workspace_id" in text - assert "status" in text + sql = _compile(stmt).lower() + # JOIN to StorageObjects so orphaned scripts (no joinable row) are + # excluded — matches list_scripts INNER JOIN behaviour. + assert "inner join storage_objects" in sql + # Scope: workspace_id + active status + user subtree. + assert "scripts.workspace_id" in sql + assert "scripts.status" in sql + assert "workspace/u001/%" in sql async def test_count_scripts_handles_null_result() -> None: @@ -61,12 +72,33 @@ async def test_count_scripts_handles_null_result() -> None: assert result["data"] == {"total": 0} +async def test_count_scripts_uses_user_specific_subtree() -> None: + """Different users in the same workspace must see different totals — + each user's count is bounded by their own ``workspace/{user_id}/`` + subtree, NOT the whole workspace.""" + captured = [] + + mock_session = MagicMock() + mock_session.scalar = AsyncMock( + side_effect=lambda stmt: (captured.append(stmt), 3)[1] + ) + + await count_scripts(context=_ctx(user_id="alice"), session=mock_session) + sql_alice = _compile(captured[-1]) + + await count_scripts(context=_ctx(user_id="bob"), session=mock_session) + sql_bob = _compile(captured[-1]) + + assert "workspace/alice/%" in sql_alice + assert "workspace/alice/%" not in sql_bob + assert "workspace/bob/%" in sql_bob + + async def test_count_scripts_route_declared_before_script_id_route() -> None: """Static check: the `/api/v1/scripts/count` route MUST be declared in scripts.py before `/api/v1/scripts/{script_id}/...`, otherwise FastAPI's declaration-order matching will interpret `count` as a script_id.""" from backend.scripts import count_scripts, get_script - # Both callables exist (sanity). assert callable(count_scripts) assert callable(get_script) \ No newline at end of file diff --git a/backend/tests/test_list_scripts_parent_path.py b/backend/tests/test_list_scripts_parent_path.py index 8901491..7074b15 100644 --- a/backend/tests/test_list_scripts_parent_path.py +++ b/backend/tests/test_list_scripts_parent_path.py @@ -1,14 +1,20 @@ -"""Unit tests for the parent_path filter clause on list_scripts. +"""Tests for the parent_path filter clause on list_scripts, plus the +LIKE-pattern escape contract for tree-walking queries. -Verifies the WHERE clause built by the endpoint encodes the intended -"direct children of parent_path" semantics: ``relative_path LIKE 'prefix/%'`` -and ``NOT LIKE 'prefix/%/%'``. These are SQL-contract assertions (mock -session, capture compiled SQL); the repo has no integration test layer -for endpoints, so this is the only coverage. Brittle to SQLAlchemy/dialect -rendering changes — review the assertions together with the endpoint if -you upgrade SQLAlchemy. +Three layers of coverage: -Mirrors the pattern in test_scripts.py (unit-level, no live DB). +1. ``_escape_like_pattern`` unit tests — pure-function correctness. +2. SQL-contract tests (mock session) — verifies the compiled SQL contains + the escaped pattern AND the ``ESCAPE '\\'`` clause. +3. Behavioral test (SQLite in-memory, real LIKE execution) — proves the + fix actually prevents the wildcard leak that motivated the change. + A folder named ``foo_bar`` MUST NOT match sibling paths like + ``fooXbar`` / ``foo2bar`` / ``foo/bar``. + +The repo has no MySQL integration test layer, so SQLite stands in for +LIKE semantics — both dialects treat ``_`` as "any single char" and +``%`` as "any sequence" by default and honour the ``ESCAPE`` clause +identically for the ASCII characters we care about. """ from __future__ import annotations @@ -18,16 +24,17 @@ from unittest.mock import AsyncMock, MagicMock import pytest from fastapi import HTTPException +from sqlalchemy import Column, MetaData, String, Table, create_engine, select, text +from sqlalchemy.dialects import mysql as mysql_dialect from backend.scripts import ( _build_list_scripts_descendant_prefix, + _escape_like_pattern, normalize_user_path, ) -from sqlalchemy.dialects import mysql as mysql_dialect def _ctx(user_id: str = "U001") -> SimpleNamespace: - """Stand-in for RequestContext — only ``user.user_id`` and ``workspace_id`` are read.""" return SimpleNamespace( request_id="test", user=SimpleNamespace(user_id=user_id), @@ -37,6 +44,40 @@ def _ctx(user_id: str = "U001") -> SimpleNamespace: ) +# ─── layer 1: helper unit tests ────────────────────────────────── + + +class TestEscapeLikePattern: + """The escape helper itself is the load-bearing piece — test it + exhaustively before relying on it in SQL.""" + + def test_no_metachars_unchanged(self) -> None: + assert _escape_like_pattern("foo/bar") == "foo/bar" + assert _escape_like_pattern("workspace/alice") == "workspace/alice" + assert _escape_like_pattern("") == "" + + def test_underscore_escaped(self) -> None: + assert _escape_like_pattern("foo_bar") == r"foo\_bar" + + def test_percent_escaped(self) -> None: + assert _escape_like_pattern("100%") == r"100\%" + assert _escape_like_pattern("%foo") == r"\%foo" + + def test_backslash_escaped_first(self) -> None: + # Must escape the escape char first, otherwise the inserted + # backslashes would be double-escaped by the later passes. + assert _escape_like_pattern(r"a\b") == r"a\\b" + assert _escape_like_pattern(r"a\%b") == r"a\\\%b" + + def test_combined(self) -> None: + assert _escape_like_pattern("foo_bar%baz") == r"foo\_bar\%baz" + assert _escape_like_pattern("_%") == r"\_\%" + assert _escape_like_pattern(r"\\_%") == r"\\\\\_\%" + + +# ─── layer 1.5: prefix helper now escapes ───────────────────────── + + def test_descendant_prefix_root() -> None: """Empty parent_path → descendant prefix is the scoped root + '/'.""" prefix = _build_list_scripts_descendant_prefix(_ctx("alice"), "") @@ -49,14 +90,20 @@ def test_descendant_prefix_subdir() -> None: assert prefix == "workspace/alice/foo/bar/" +def test_descendant_prefix_escapes_metachars() -> None: + """Folder name ``foo_bar`` MUST produce ``foo\\_bar`` in the prefix + so the trailing ``%`` doesn't become 'match any single char before + b'.""" + prefix = _build_list_scripts_descendant_prefix(_ctx("alice"), "foo_bar") + assert prefix == r"workspace/alice/foo\_bar/" + + def test_descendant_prefix_normalizes_leading_trailing_slashes() -> None: - """Leading/trailing slashes on parent_path must be stripped.""" prefix = _build_list_scripts_descendant_prefix(_ctx("alice"), "/foo/bar/") assert prefix == "workspace/alice/foo/bar/" def test_descendant_prefix_rejects_traversal() -> None: - """``..`` segments must raise (matches normalize_user_path contract).""" with pytest.raises(HTTPException) as exc: _build_list_scripts_descendant_prefix(_ctx("alice"), "foo/../bar") assert exc.value.status_code == 422 @@ -68,9 +115,19 @@ def test_normalize_user_path_strips() -> None: assert normalize_user_path("a\\b") == "a/b" +# ─── layer 2: SQL contract ──────────────────────────────────────── + + +def _compile_sql(stmt) -> str: + return str( + stmt.compile( + dialect=mysql_dialect.dialect(), + compile_kwargs={"literal_binds": True}, + ) + ) + + async def test_list_scripts_where_clause_uses_like_prefix_and_excludes_deeper() -> None: - """The WHERE clause must include both LIKE prefix and NOT LIKE '%/%' filters - so deeper descendants and prefix-siblings (foo/bar vs foo/bar2) are excluded.""" from backend.scripts import list_scripts captured_sql: list[str] = [] @@ -82,15 +139,7 @@ async def test_list_scripts_where_clause_uses_like_prefix_and_excludes_deeper() mock_session = MagicMock() mock_session.execute = AsyncMock( side_effect=lambda stmt: ( - captured_sql.append( - str( - stmt.compile( - dialect=mysql_dialect.dialect(), - compile_kwargs={"literal_binds": True}, - ) - ) - ) - or _MockResult() + captured_sql.append(_compile_sql(stmt)) or _MockResult() ) ) @@ -98,16 +147,13 @@ async def test_list_scripts_where_clause_uses_like_prefix_and_excludes_deeper() assert len(captured_sql) == 1 sql = captured_sql[0].lower() - # Direct-child LIKE prefix assert "like 'workspace/alice/foo/bar/%%'" in sql - # NOT-LIKE deeper assert "not like 'workspace/alice/foo/bar/%%/%%'" in sql - # active scripts only (existing contract preserved) - assert "scripts.status" in sql or "scripts.status = 'active'" in sql or "scripts.status = :status" in sql -async def test_list_scripts_empty_parent_path_targets_root_descendants() -> None: - """parent_path='' produces root-scoped LIKE prefix only, not full scan.""" +async def test_list_scripts_where_clause_escapes_pattern_literal() -> None: + """Regression: parent_path containing ``_`` MUST be escaped in the + compiled LIKE pattern, otherwise sibling-path leak returns to bite.""" from backend.scripts import list_scripts captured_sql: list[str] = [] @@ -119,63 +165,53 @@ async def test_list_scripts_empty_parent_path_targets_root_descendants() -> None mock_session = MagicMock() mock_session.execute = AsyncMock( side_effect=lambda stmt: ( - captured_sql.append( - str( - stmt.compile( - dialect=mysql_dialect.dialect(), - compile_kwargs={"literal_binds": True}, - ) - ) - ) - or _MockResult() - ) - ) - - await list_scripts(parent_path="", context=_ctx("alice"), session=mock_session) - - sql = captured_sql[0].lower() - assert "like 'workspace/alice/%%'" in sql - assert "not like 'workspace/alice/%%/%%'" in sql - - -async def test_list_scripts_where_clause_includes_escape() -> None: - """Both LIKE clauses must declare ESCAPE so folder names containing ``_`` - or ``%`` do not act as SQL wildcards and match sibling paths.""" - from backend.scripts import list_scripts - - captured_sql: list[str] = [] - - class _MockResult: - def all(self): - return [] - - mock_session = MagicMock() - mock_session.execute = AsyncMock( - side_effect=lambda stmt: ( - captured_sql.append( - str( - stmt.compile( - dialect=mysql_dialect.dialect(), - compile_kwargs={"literal_binds": True}, - ) - ) - ) - or _MockResult() + captured_sql.append(_compile_sql(stmt)) or _MockResult() ) ) await list_scripts(parent_path="foo_bar", context=_ctx("alice"), session=mock_session) sql = captured_sql[0] - # Both patterns must carry ESCAPE; counts must match between the two LIKE - # occurrences (one positive, one negated). SQLAlchemy doubles the escape - # char for SQL string literals, so the rendered form is `ESCAPE '\\\\'`. + # Normalize keyword case so we don't depend on SQLAlchemy casing. + sql_lower = sql.lower() + # Pattern literal must contain the ESCAPED underscore. SQLAlchemy + # doubles the escape char inside the SQL string literal, so what + # the helper emits as `foo\_bar` renders as `foo\\_bar` here + # (2 backslash chars in the actual SQL string). + assert r"like 'workspace/alice/foo\\_bar/%%'" in sql_lower + # NOT LIKE clause also escaped. + assert r"not like 'workspace/alice/foo\\_bar/%%/%%'" in sql_lower + # And both declare ESCAPE '\\'. assert sql.count("ESCAPE '\\\\'") == 2, sql -async def test_list_workspace_directories_where_clause_includes_escape() -> None: - """list_workspace_directories must also emit ESCAPE — the same LIKE - pattern was already vulnerable for pre-existing endpoints; this - endpoint is in scope for the same fix.""" +async def test_list_scripts_where_clause_escapes_percent_pattern() -> None: + """Same regression for ``%``.""" + from backend.scripts import list_scripts + + captured_sql: list[str] = [] + + class _MockResult: + def all(self): + return [] + + mock_session = MagicMock() + mock_session.execute = AsyncMock( + side_effect=lambda stmt: ( + captured_sql.append(_compile_sql(stmt)) or _MockResult() + ) + ) + + await list_scripts(parent_path="100%match", context=_ctx("alice"), session=mock_session) + sql = captured_sql[0] + sql_lower = sql.lower() + # SQLAlchemy doubles the escape char so `%` → `\%` becomes `\\%` + # in the SQL string literal. + assert r"workspace/alice/100\\%%match/%%" in sql_lower + + +async def test_list_workspace_directories_where_clause_escapes_pattern() -> None: + """list_workspace_directories must escape user input too (was + pre-existing debt).""" from backend.scripts import list_workspace_directories captured_sql: list[str] = [] @@ -194,23 +230,109 @@ async def test_list_workspace_directories_where_clause_includes_escape() -> None mock_session = MagicMock() mock_session.execute = AsyncMock( side_effect=lambda stmt: ( - captured_sql.append( - str( - stmt.compile( - dialect=mysql_dialect.dialect(), - compile_kwargs={"literal_binds": True}, - ) - ) - ) - or _MockResult() + captured_sql.append(_compile_sql(stmt)) or _MockResult() ) ) await list_workspace_directories( parent_path="foo_bar", context=_ctx("alice"), session=mock_session ) - # Two LIKE clauses in the children query + two in the has_children - # check per directory in the result — for an empty result set only - # the first batch executes, so we expect at least 2 ESCAPEs. sql = " ".join(captured_sql) - assert sql.count("ESCAPE '\\\\'") >= 2, sql \ No newline at end of file + assert r"workspace/alice/foo\\_bar/" in sql, sql + + +# ─── layer 3: behavioral test on real LIKE execution ────────────── + + +@pytest.fixture +def sqlite_like_table(): + """SQLite in-memory table with a single VARCHAR column. Stand-in for + ``storage_objects.relative_path`` — proves the actual LIKE executor + behaves the way we expect with the escaped pattern.""" + engine = create_engine("sqlite:///:memory:") + metadata = MetaData() + table = Table( + "paths", + metadata, + Column("relative_path", String(1024), nullable=False), + ) + metadata.create_all(engine) + with engine.begin() as conn: + # Target row (the one a parent_path="foo_bar" search MUST return). + conn.execute( + table.insert(), + {"relative_path": "workspace/alice/foo_bar/inner.py"}, + ) + # Decoys the buggy LIKE would match but escaped the must NOT. + conn.execute( + table.insert(), + {"relative_path": "workspace/alice/fooXbar/decoy.py"}, + ) + conn.execute( + table.insert(), + {"relative_path": "workspace/alice/foo2bar/decoy.py"}, + ) + # A truly unrelated path. + conn.execute( + table.insert(), + {"relative_path": "workspace/alice/baz/inner.py"}, + ) + yield engine, table + engine.dispose() + + +def test_sqlite_like_with_escape_does_not_match_sibling(sqlite_like_table): + """Execute the actual LIKE pattern the endpoint would emit for + parent_path='foo_bar'. Confirms only the target row matches.""" + engine, table = sqlite_like_table + escaped_prefix = _escape_like_pattern("workspace/alice/foo_bar") + "/" + pattern = f"{escaped_prefix}%" + with engine.connect() as conn: + rows = conn.execute( + select(table.c.relative_path).where( + table.c.relative_path.like(pattern, escape="\\") + ) + ).fetchall() + matched = sorted(r[0] for r in rows) + assert matched == ["workspace/alice/foo_bar/inner.py"], matched + + +def test_sqlite_like_without_escape_matches_siblings(sqlite_like_table): + """Sanity check: WITHOUT escape, the same pattern matches the + decoys too — confirming the test setup actually exercises the + leak. If this assertion fails the SQLite fixture is broken.""" + engine, table = sqlite_like_table + pattern = "workspace/alice/foo_bar/%" + with engine.connect() as conn: + rows = conn.execute( + select(table.c.relative_path).where( + table.c.relative_path.like(pattern) + ) + ).fetchall() + matched = sorted(r[0] for r in rows) + # Without escape, the buggy behaviour returns ALL three foo*bar rows. + assert len(matched) >= 2, matched + + +def test_sqlite_like_with_percent_in_name(sqlite_like_table): + """Folder name containing ``%`` — must be escaped too.""" + engine, table = sqlite_like_table + with engine.begin() as conn: + conn.execute( + table.insert(), + {"relative_path": "workspace/alice/100%off/x.py"}, + ) + conn.execute( + table.insert(), + {"relative_path": "workspace/alice/100Xoff/y.py"}, + ) + escaped_prefix = _escape_like_pattern("workspace/alice/100%off") + "/" + pattern = f"{escaped_prefix}%" + with engine.connect() as conn: + rows = conn.execute( + select(table.c.relative_path).where( + table.c.relative_path.like(pattern, escape="\\") + ) + ).fetchall() + matched = sorted(r[0] for r in rows) + assert matched == ["workspace/alice/100%off/x.py"], matched \ No newline at end of file diff --git a/frontend/app/features/platform/DashboardRoute.tsx b/frontend/app/features/platform/DashboardRoute.tsx index 9b6a06f..e5e3d49 100644 --- a/frontend/app/features/platform/DashboardRoute.tsx +++ b/frontend/app/features/platform/DashboardRoute.tsx @@ -1,6 +1,7 @@ import { useEffect } from "react"; import { useNavigate } from "react-router"; +import { useAuth } from "~/context/AuthContext"; import { DashboardPage } from "../../components/admin/DashboardPage"; import { useScriptWorkspaceStore } from "./state/scriptWorkspaceStore"; @@ -9,14 +10,15 @@ export default function DashboardRoute() { const scriptCount = useScriptWorkspaceStore((s) => s.scriptCount); const loadScriptCount = useScriptWorkspaceStore((s) => s.loadScriptCount); const apiOnline = useScriptWorkspaceStore((s) => s.apiOnline); + const workspaceId = useAuth().currentWorkspace?.workspace_id; const navigate = useNavigate(); - // Independent of the lazy-loaded `scripts` array — the count endpoint - // returns the workspace-wide total even when no folders have been - // expanded yet (see #34 + #37). + // Reload on workspace switch — DashboardRoute is not keyed by + // workspace/user (only ScriptsPage is), so without this dep the + // previous workspace's count would persist. useEffect(() => { void loadScriptCount(); - }, [loadScriptCount]); + }, [loadScriptCount, workspaceId]); return ( ((set, get) => { loadScriptCount: async () => { const api = requireApi(); if (get().scriptCountLoading) return; - set({ scriptCountLoading: true }); + set({ scriptCountLoading: true, scriptCount: null }); try { const total = await api.countScripts(); set({ scriptCount: total }); -- 2.54.0 From a9b682c1a1306b4c9fa7412126cd5ef55e05b848 Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:31:07 +0800 Subject: [PATCH 08/93] fix(scripts): sequence-token race + comment/import cleanup (Codex ffec234 follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review of ffec234 flagged: 1. **MEDIUM — loadScriptCount in-flight race on workspace switch.** Previous implementation used `if (get().scriptCountLoading) return` to dedupe. That meant switching workspaces WHILE a fetch was in flight dropped the new fetch entirely; the stale response from the previous workspace then overwrote state, leaving the dashboard showing workspace A's total while the user is on workspace B. Fix: drop the dedupe-via-flag, use a module-level `_scriptCountSeq` counter. Every call increments, captures the seq at start, and the response/finally block only mutates state when `_scriptCountSeq === seq` — stale responses are silently dropped. Rapid workspace switches each get their own fetch; only the latest response wins. 2. **LOW — scriptCount scope comment was misleading.** "范围与 list_scripts(parent_path=\"\") 对齐" is wrong: the count includes all descendant depths, not just root-level. Behaviour is correct for the dashboard's "全部脚本" intent but the comment would mislead the next maintainer. Rewritten to explicitly call out that the count is the UNION across all parent_path depths, with the rationale for each design choice. 3. **LOW — local `from backend.scripts import _escape_like_pattern` inside `resources.py` keyword-search block.** The "avoid cycle" justification was false: scripts.py and resources.py don't import each other at module level. Moved to the top-of-file import block. Skipped: - get_workspace_tree `like_prefix` not run through the escape helper (user_id is a 26-char Crockford ULID so no `_`/`%` can appear, but the invariant is not documented at the call site). Pre-existing pattern; out of scope for this round. Verified: pytest 65 passed; pnpm typecheck clean. --- backend/src/backend/resources.py | 2 +- backend/src/backend/scripts.py | 17 ++++++++++++----- .../platform/state/scriptWorkspaceStore.ts | 13 +++++++++++-- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/backend/src/backend/resources.py b/backend/src/backend/resources.py index c7a8fa5..7522f7b 100644 --- a/backend/src/backend/resources.py +++ b/backend/src/backend/resources.py @@ -28,6 +28,7 @@ from backend.dependencies import ( database_session, request_context, ) +from backend.scripts import _escape_like_pattern from backend.schemas import ( CompleteResourceUploadRequest, CreateResourceUploadRequest, @@ -402,7 +403,6 @@ async def list_resources( if keyword: # Escape LIKE metacharacters so a search like "100%" or "my_file" # doesn't act as a wildcard. The outer "%...%" wildcards stay raw. - from backend.scripts import _escape_like_pattern # local import: avoid cycle escaped = _escape_like_pattern(keyword.strip()) statement = statement.where( DataResources.resource_name.like(f"%{escaped}%", escape="\\") diff --git a/backend/src/backend/scripts.py b/backend/src/backend/scripts.py index d98405b..aaabd7a 100644 --- a/backend/src/backend/scripts.py +++ b/backend/src/backend/scripts.py @@ -1138,11 +1138,18 @@ async def list_scripts( # 避免被 listScripts 的懒加载语义污染。该路由必须在 /scripts/{script_id} 之前声明 # ——FastAPI 按声明顺序匹配,否则 `count` 会被当作 script_id 命中 get_script。 # -# 范围与 list_scripts(parent_path="") 对齐:INNER JOIN 到 StorageObjects 以排除 -# 孤儿脚本(其 current_object_id 没有 joinable row),按用户子树 -# (workspace/{user_id}/) 过滤。否则: -# - 含孤儿 → 数字虚高 -# - workspace 范围 → 多成员工作区里 dashboard 会显示用户看不见的脚本 +# Scope: equals the UNION of list_scripts across every parent_path +# within the user's subtree, NOT just the root-level call. We +# intentionally include deeper descendants because the dashboard's +# "全部脚本" / "工作副本" counts reflect the whole workspace, not +# only the top level. +# +# Implementation choices and why: +# - INNER JOIN to StorageObjects so orphans (current_object_id has no +# joinable row) are excluded. +# - User subtree filter so multi-member workspaces don't show counts +# the requester can't see. +# - No NOT-LIKE filter because the count wants descendants too. @router.get("/api/v1/scripts/count") async def count_scripts( context: RequestContext = Depends(request_context), diff --git a/frontend/app/features/platform/state/scriptWorkspaceStore.ts b/frontend/app/features/platform/state/scriptWorkspaceStore.ts index 32fa0f7..cdae484 100644 --- a/frontend/app/features/platform/state/scriptWorkspaceStore.ts +++ b/frontend/app/features/platform/state/scriptWorkspaceStore.ts @@ -40,6 +40,7 @@ let _api: WorkspaceBoundApi | null = null; let _previewController: AbortController | null = null; let _previewRequest = 0; let _pythonEditorOpeningIds = new Set(); +let _scriptCountSeq = 0; export const bindScriptWorkspaceApi = (api: WorkspaceBoundApi | null) => { _api = api; @@ -397,17 +398,25 @@ export const useScriptWorkspaceStore = create((set, get) => { loadScriptCount: async () => { const api = requireApi(); - if (get().scriptCountLoading) return; + // Always fire — don't dedupe via the loading flag. Rapid workspace + // switches would otherwise drop the new fetch and leave the + // dashboard showing the previous workspace's count. The sequence + // counter below discards stale responses instead. + const seq = ++_scriptCountSeq; set({ scriptCountLoading: true, scriptCount: null }); try { const total = await api.countScripts(); + if (_scriptCountSeq !== seq) return; // a newer fetch superseded us set({ scriptCount: total }); } catch { + if (_scriptCountSeq !== seq) return; // Leave previous value in place; the dashboard already tolerates // a stale count by rendering `scriptCount ?? 0`. Don't toast — // the dashboard's other metrics are best-effort. } finally { - set({ scriptCountLoading: false }); + if (_scriptCountSeq === seq) { + set({ scriptCountLoading: false }); + } } }, -- 2.54.0 From 48fe49df3bbfde29e0caa8b412d34c92002131b3 Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:36:43 +0800 Subject: [PATCH 09/93] feat(scripts/data-resources): merge tree + add parent_path filter - Backend: GET /api/v1/data-resources accepts parent_path; LIKE '{ws_id}/%/{escaped}/%' AND NOT LIKE '{ws_id}/%/{escaped}/%/%' on StorageObjects.object_key (workspace-wide, escapes _ and %, mirrors list_scripts parent_path semantics). 13 new tests in test_resources.py (helper unit / SQL compile / SQLite behavioral). - Frontend: listResources gains parentPath arg, propagated through WorkspaceBoundApi + AuthContext binding. WorkspaceTreeGroup title count and ScriptExplorer header count now include dataResources. memberScriptGroups backfills data-only owners so users with only data resources still render a group. loadDataResources accepts an optional parentPath, default empty preserves prior behavior. --- backend/src/backend/resources.py | 37 ++- backend/tests/test_resources.py | 239 +++++++++++++++++- .../components/platform/ScriptExplorer.tsx | 10 +- frontend/app/context/AuthContext.tsx | 3 +- .../app/features/platform/WorkspaceTree.tsx | 2 +- .../platform/state/scriptWorkspaceStore.ts | 6 +- frontend/app/services/api.ts | 8 +- 7 files changed, 296 insertions(+), 9 deletions(-) diff --git a/backend/src/backend/resources.py b/backend/src/backend/resources.py index 7522f7b..950a052 100644 --- a/backend/src/backend/resources.py +++ b/backend/src/backend/resources.py @@ -28,7 +28,7 @@ from backend.dependencies import ( database_session, request_context, ) -from backend.scripts import _escape_like_pattern +from backend.scripts import _escape_like_pattern, normalize_user_path from backend.schemas import ( CompleteResourceUploadRequest, CreateResourceUploadRequest, @@ -47,6 +47,23 @@ from backend.services.storage import ( router = APIRouter(prefix="/api/v1/data-resources", tags=["data-resources"]) +def _build_list_resources_descendant_prefix(parent_path: str) -> str: + """Return the escaped materialized-path prefix for direct children + of ``parent_path`` against ``StorageObjects.object_key``. + + Data resources are workspace-wide (no per-user scoping at the API + level). The full object_key is ``{ws_id}/{user_id}/{jupyter_path}``; + we filter on object_key with the pattern ``{ws_id}/%/{parent_path}`` + so any owner whose jupyter_accessible_path starts with parent_path + matches. LIKE wildcards in parent_path are escaped; the ``%`` between + ``{ws_id}/`` and the escaped parent is an intentional SQL wildcard + matching the ``owner_user_id`` segment across all owners. + """ + normalized = normalize_user_path(parent_path) + escaped = _escape_like_pattern(normalized) + return f"{escaped}/" if escaped else "" + + def compute_jupyter_relative_path(script_path: str, resource_relative: str) -> str: """从当前脚本所在目录算到资源文件的 Jupyter 相对路径。 @@ -364,6 +381,7 @@ async def bind_resource( # 列出当前工作区可见的数据资源,可按可见性或关键字筛选。 @router.get("") async def list_resources( + parent_path: str = Query(default="", max_length=1024), context: RequestContext = Depends(request_context), session: AsyncSession = Depends(database_session), visibility: str | None = Query(default=None), @@ -383,6 +401,23 @@ async def list_resources( ) .order_by(DataResources.updated_at.desc()) ) + if parent_path: + # ``parent_path`` scopes to DIRECT children of that jupyter path + # (matching /api/v1/scripts). Data resources are workspace-wide, so + # the middle ``%`` is an intentional wildcard that matches the + # ``owner_user_id`` segment across all owners. The parent's ``_`` / + # ``%`` are escaped so sibling folders (e.g. ``fooXbar``) don't leak. + descendant_prefix = _build_list_resources_descendant_prefix(parent_path) + statement = statement.where( + StorageObjects.object_key.like( + f"{context.workspace.workspace_id}/%/{descendant_prefix}%", + escape="\\", + ), + ~StorageObjects.object_key.like( + f"{context.workspace.workspace_id}/%/{descendant_prefix}%/%", + escape="\\", + ), + ) # 2026-08-11: 临时取消"用户间目录互相不可见"约束 # 列表接口现在返回 workspace 内全部 active 资源(不再按 owner / visibility 过滤)。 # 还原: 删除下面这段注释,恢复原来的 if not context.is_admin: ... 块。 diff --git a/backend/tests/test_resources.py b/backend/tests/test_resources.py index 10b0df3..30566a1 100644 --- a/backend/tests/test_resources.py +++ b/backend/tests/test_resources.py @@ -7,10 +7,14 @@ from __future__ import annotations import datetime from types import SimpleNamespace -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock import pytest +from fastapi import HTTPException +from sqlalchemy import Column, MetaData, String, Table, create_engine, select +from sqlalchemy.dialects import mysql as mysql_dialect from backend.resources import ( + _build_list_resources_descendant_prefix, compute_jupyter_relative_path, resource_directory, resource_payload, @@ -405,3 +409,236 @@ def test_data_resources_model_allows_duplicate_storage_object_reference() -> Non assert "storage_object_id" not in cols, ( f"unexpected unique index {idx.name} on storage_object_id" ) + + +# ─── parent_path filtering (mirrors test_list_scripts_parent_path.py) ──────── + + +def _resource_ctx(workspace_id: str = "W001") -> SimpleNamespace: + return SimpleNamespace( + request_id="test", + user=SimpleNamespace(user_id="U001"), + workspace=SimpleNamespace(workspace_id=workspace_id), + ) + + +class TestBuildListResourcesDescendantPrefix: + """Pure-function contract for the escaped object_key prefix. Unlike + the scripts helper there is NO user scope — data resources are + workspace-wide, so the prefix is just the normalized+escaped path.""" + + def test_empty_parent_path_returns_empty_prefix(self) -> None: + assert _build_list_resources_descendant_prefix("") == "" + + def test_subdir_prefix_appends_trailing_slash(self) -> None: + assert _build_list_resources_descendant_prefix("foo/bar") == "foo/bar/" + + def test_escapes_underscore(self) -> None: + assert _build_list_resources_descendant_prefix("foo_bar") == r"foo\_bar/" + + def test_escapes_percent(self) -> None: + assert _build_list_resources_descendant_prefix("100%match") == r"100\%match/" + + def test_normalizes_leading_trailing_slashes(self) -> None: + assert _build_list_resources_descendant_prefix("/foo/bar/") == "foo/bar/" + + def test_rejects_traversal(self) -> None: + with pytest.raises(HTTPException) as exc: + _build_list_resources_descendant_prefix("foo/../bar") + assert exc.value.status_code == 422 + + +# ─── layer 2: SQL contract (mock session + mysql dialect compile) ──────────── + + +def _compile_sql(stmt) -> str: + return str( + stmt.compile( + dialect=mysql_dialect.dialect(), + compile_kwargs={"literal_binds": True}, + ) + ) + + +class _ListResourcesMockResult: + def all(self): + return [] + + +def _list_resources_capturing_session(captured_sql: list[str]) -> MagicMock: + mock_session = MagicMock() + mock_session.execute = AsyncMock( + side_effect=lambda stmt: ( + captured_sql.append(_compile_sql(stmt)) or _ListResourcesMockResult() + ) + ) + return mock_session + + +async def test_list_resources_where_clause_uses_like_prefix_and_excludes_deeper() -> None: + from backend.resources import list_resources + + captured_sql: list[str] = [] + mock_session = _list_resources_capturing_session(captured_sql) + + await list_resources( + parent_path="foo/bar", + context=_resource_ctx(), + session=mock_session, + visibility=None, + keyword=None, + ) + + assert len(captured_sql) == 1 + sql = captured_sql[0].lower() + # 中间 ``%`` 是 owner 段通配符(跨所有 owner),父路径本身按字面匹配。 + assert "like 'w001/%%/foo/bar/%%'" in sql + assert "not like 'w001/%%/foo/bar/%%/%%'" in sql + + +async def test_list_resources_where_clause_escapes_underscore() -> None: + """Regression: parent_path containing ``_`` MUST be escaped in the + compiled LIKE pattern, otherwise sibling-path leak (``fooXbar``) returns.""" + from backend.resources import list_resources + + captured_sql: list[str] = [] + mock_session = _list_resources_capturing_session(captured_sql) + + await list_resources( + parent_path="foo_bar", + context=_resource_ctx(), + session=mock_session, + visibility=None, + keyword=None, + ) + sql = captured_sql[0] + sql_lower = sql.lower() + # SQLAlchemy doubles the escape char inside the SQL string literal, so + # the helper's ``foo\_bar`` renders as ``foo\\_bar`` in the SQL text. + assert r"like 'w001/%%/foo\\_bar/%%'" in sql_lower + assert r"not like 'w001/%%/foo\\_bar/%%/%%'" in sql_lower + # Both LIKE clauses declare ESCAPE '\\' (two in total). + assert sql.count("ESCAPE '\\\\'") == 2, sql + + +async def test_list_resources_without_parent_path_adds_no_like_clause() -> None: + """Empty parent_path keeps the legacy workspace-wide behaviour — no + object_key LIKE filter at all.""" + from backend.resources import list_resources + + captured_sql: list[str] = [] + mock_session = _list_resources_capturing_session(captured_sql) + + await list_resources( + parent_path="", + context=_resource_ctx(), + session=mock_session, + visibility=None, + keyword=None, + ) + sql = captured_sql[0].lower() + assert " like " not in sql + assert " not like " not in sql + + +# ─── layer 3: behavioral test on real LIKE execution (SQLite) ──────────────── + + +@pytest.fixture +def sqlite_object_key_table(): + """SQLite in-memory stand-in for ``storage_objects.object_key`` rows. + + Three-layer structure: direct children under ``data`` owned by two + different users (cross-owner), a deeper descendant, a sibling folder, + a root file, plus ``data_x`` and its ``dataXx`` / ``data2x`` decoys. + """ + engine = create_engine("sqlite:///:memory:") + metadata = MetaData() + table = Table( + "object_keys", + metadata, + Column("object_key", String(1024), nullable=False), + ) + metadata.create_all(engine) + rows = [ + "W001/U001/data/alpha.csv", # direct child (owner U001) + "W001/U002/data/beta.csv", # direct child (owner U002) + "W001/U001/data/deep/nested.csv", # deeper descendant + "W001/U001/database/gamma.csv", # sibling folder + "W001/U001/root.csv", # root file + "W001/U001/data_x/delta.csv", # target for parent_path=data_x + "W001/U001/dataXx/decoy.csv", # sibling decoy (unescaped match) + "W001/U001/data2x/decoy2.csv", # sibling decoy (unescaped match) + ] + with engine.begin() as conn: + conn.execute(table.insert(), [{"object_key": key} for key in rows]) + yield engine, table + engine.dispose() + + +def test_sqlite_direct_children_across_owners(sqlite_object_key_table) -> None: + """parent_path='data' returns exactly the direct children under data, + across every owner, excluding deeper/sibling/root paths.""" + engine, table = sqlite_object_key_table + prefix = _build_list_resources_descendant_prefix("data") + like = f"W001/%/{prefix}%" + not_like = f"W001/%/{prefix}%/%" + with engine.connect() as conn: + rows = conn.execute( + select(table.c.object_key).where( + table.c.object_key.like(like, escape="\\"), + ~table.c.object_key.like(not_like, escape="\\"), + ) + ).fetchall() + matched = sorted(r[0] for r in rows) + assert matched == [ + "W001/U001/data/alpha.csv", + "W001/U002/data/beta.csv", + ], matched + + +def test_sqlite_escaped_underscore_does_not_match_sibling( + sqlite_object_key_table, +) -> None: + """parent_path='data_x' must match only the literal data_x folder, not + the dataXx / data2x siblings an unescaped pattern would match.""" + engine, table = sqlite_object_key_table + prefix = _build_list_resources_descendant_prefix("data_x") + like = f"W001/%/{prefix}%" + not_like = f"W001/%/{prefix}%/%" + with engine.connect() as conn: + rows = conn.execute( + select(table.c.object_key).where( + table.c.object_key.like(like, escape="\\"), + ~table.c.object_key.like(not_like, escape="\\"), + ) + ).fetchall() + matched = sorted(r[0] for r in rows) + assert matched == ["W001/U001/data_x/delta.csv"], matched + + +def test_sqlite_parent_path_with_slash_direct_children(sqlite_object_key_table) -> None: + """parent_path='data/deep' returns only data/deep/* — never data/*.""" + engine, table = sqlite_object_key_table + prefix = _build_list_resources_descendant_prefix("data/deep") + like = f"W001/%/{prefix}%" + not_like = f"W001/%/{prefix}%/%" + with engine.connect() as conn: + rows = conn.execute( + select(table.c.object_key).where( + table.c.object_key.like(like, escape="\\"), + ~table.c.object_key.like(not_like, escape="\\"), + ) + ).fetchall() + matched = sorted(r[0] for r in rows) + assert matched == ["W001/U001/data/deep/nested.csv"], matched + + +def test_sqlite_empty_parent_path_has_no_like_filter(sqlite_object_key_table) -> None: + """Empty parent_path → no LIKE filter → the endpoint's base WHERE only + (workspace-wide active resources). Stand-in: every row is returned.""" + engine, table = sqlite_object_key_table + with engine.connect() as conn: + rows = conn.execute(select(table.c.object_key)).fetchall() + matched = sorted(r[0] for r in rows) + assert len(matched) == 8, matched diff --git a/frontend/app/components/platform/ScriptExplorer.tsx b/frontend/app/components/platform/ScriptExplorer.tsx index eb9286e..d811d44 100644 --- a/frontend/app/components/platform/ScriptExplorer.tsx +++ b/frontend/app/components/platform/ScriptExplorer.tsx @@ -94,6 +94,14 @@ export function ScriptExplorer({ byOwner.set(item.owner_user_id, list); } + // data-only owner(只有数据资源、没有 scripts 的用户)也要出现在分组里, + // 因为 data resources 与 scripts 共享同一棵目录树。 + for (const ownerUserId of dataByOwner.keys()) { + if (!byOwner.has(ownerUserId)) { + byOwner.set(ownerUserId, []); + } + } + const groups: { user: AuthUser | null; scripts: ScriptItem[]; @@ -143,7 +151,7 @@ export function ScriptExplorer({

脚本目录

- {scripts.length} 个工作副本 + {scripts.length + dataResources.length} 个工作副本
{open && (
diff --git a/frontend/app/features/platform/state/scriptWorkspaceStore.ts b/frontend/app/features/platform/state/scriptWorkspaceStore.ts index cdae484..d9000c8 100644 --- a/frontend/app/features/platform/state/scriptWorkspaceStore.ts +++ b/frontend/app/features/platform/state/scriptWorkspaceStore.ts @@ -106,7 +106,7 @@ type State = { setKeyword: (keyword: string) => void; reset: () => void; load: (silent?: boolean) => Promise; - loadDataResources: () => Promise; + loadDataResources: (parentPath?: string) => Promise; selectScript: (id: string | null) => void; openTab: (id: string) => void; closeTab: (id: string, event?: { stopPropagation: () => void }) => Promise; @@ -383,11 +383,11 @@ export const useScriptWorkspaceStore = create((set, get) => { } }, - loadDataResources: async () => { + loadDataResources: async (parentPath = "") => { const api = requireApi(); set({ dataResourcesLoading: true }); try { - const list = await api.listResources(); + const list = await api.listResources(parentPath); set({ dataResources: Array.isArray(list) ? list : [] }); } catch { set({ dataResources: [] }); diff --git a/frontend/app/services/api.ts b/frontend/app/services/api.ts index 56e3d00..c0ac622 100644 --- a/frontend/app/services/api.ts +++ b/frontend/app/services/api.ts @@ -437,9 +437,14 @@ export type ResourceItem = { export async function listResources( workspaceId: string, + parentPath: string = "", opts?: { visibility?: string; keyword?: string }, ): Promise { + // Empty parentPath omits the query string entirely so the backend's + // workspace-wide (root-level) filter is applied symmetrically with + // non-empty paths, matching listScripts. const parameters = new URLSearchParams(); + if (parentPath) parameters.set("parent_path", parentPath); if (opts?.visibility) parameters.set("visibility", opts.visibility); if (opts?.keyword) parameters.set("keyword", opts.keyword); const query = parameters.toString(); @@ -1484,7 +1489,8 @@ export type WorkspaceBoundApi = { ) => Promise; countScripts: () => Promise; listResources: ( - opts?: Parameters[1], + parentPath?: Parameters[1], + opts?: Parameters[2], ) => Promise; createScript: ( input: Parameters[1], -- 2.54.0 From 5483d19dbdb2af0d7c73b85c7801d9f920bfdbbb Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:40:27 +0800 Subject: [PATCH 10/93] fix(resources): align can_view with list_resources (workspace-wide) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit list_resources 已放开成 workspace-wide,但 can_view 仍要求 visibility in {workspace, public} 或 admin,导致 A 的 private 资源出现在 B 的列表里、但 B 调 GET /{id}、POST /{id}/download-url 与 POST /{id}/jupyter-relative-path 全部 404。 can_view 改为恒真,访问边界全部交给 get_visible_resource 的 workspace_id + status=active SQL 限定;visibility 字段保留为 上传/绑定时的语义标签。delete_resource 仍按 owner/admin 校验 403,不受影响。补 4 个 unit test。 --- backend/src/backend/resources.py | 13 ++----- backend/tests/test_resources.py | 64 ++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 9 deletions(-) diff --git a/backend/src/backend/resources.py b/backend/src/backend/resources.py index 950a052..e57bcd0 100644 --- a/backend/src/backend/resources.py +++ b/backend/src/backend/resources.py @@ -148,15 +148,10 @@ def resource_payload( def can_view(resource: DataResources, context: RequestContext) -> bool: - # 2026-08-11: 临时取消"用户间目录互相不可见"约束 - # 同一 workspace 内的成员现在可以查看彼此的 private 资源。 - # 还原: 取消下方注释,恢复 owner_user_id 检查。 - return ( - # resource.owner_user_id == context.user.user_id - # or - resource.visibility in {"workspace", "public"} - or context.is_admin - ) + # 2026-08-11: 同一 workspace 内成员可以查看彼此的资源(含 private); + # 保留 visibility 字段仅作为上传/绑定时的语义标签,不再影响读取。 + # 还原: 加回 owner_user_id 检查。 + return True # 根据当前脚本位置计算资源的相对路径,便于 Notebook 中用相对路径读取文件。 diff --git a/backend/tests/test_resources.py b/backend/tests/test_resources.py index 30566a1..2bac1b9 100644 --- a/backend/tests/test_resources.py +++ b/backend/tests/test_resources.py @@ -15,6 +15,7 @@ from sqlalchemy import Column, MetaData, String, Table, create_engine, select from sqlalchemy.dialects import mysql as mysql_dialect from backend.resources import ( _build_list_resources_descendant_prefix, + can_view, compute_jupyter_relative_path, resource_directory, resource_payload, @@ -422,6 +423,69 @@ def _resource_ctx(workspace_id: str = "W001") -> SimpleNamespace: ) +class TestCanViewWorkspaceWideVisibility: + """can_view is workspace-wide: any workspace member may view any active + resource, matching list_resources since 2026-08-11. visibility is only a + semantic tag on upload/bind and no longer gates reads.""" + + @staticmethod + def _viewer() -> SimpleNamespace: + return SimpleNamespace( + request_id="test", + user=SimpleNamespace(user_id="U002"), # not the owner + workspace=SimpleNamespace(workspace_id="W001"), + is_admin=False, + ) + + @staticmethod + def _admin() -> SimpleNamespace: + ctx = TestCanViewWorkspaceWideVisibility._viewer() + ctx.is_admin = True + return ctx + + @staticmethod + def _resource(visibility: str) -> SimpleNamespace: + res = _make_resource("W001", "U001") + res.visibility = visibility + return res + + def test_workspace_member_can_view_others_private_resource(self) -> None: + assert ( + can_view( + self._resource("private"), + self._viewer(), + ) + is True + ) + + def test_workspace_member_can_view_others_workspace_resource(self) -> None: + assert ( + can_view( + self._resource("workspace"), + self._viewer(), + ) + is True + ) + + def test_workspace_member_can_view_others_public_resource(self) -> None: + assert ( + can_view( + self._resource("public"), + self._viewer(), + ) + is True + ) + + def test_admin_can_view_others_private_resource(self) -> None: + assert ( + can_view( + self._resource("private"), + self._admin(), + ) + is True + ) + + class TestBuildListResourcesDescendantPrefix: """Pure-function contract for the escaped object_key prefix. Unlike the scripts helper there is NO user scope — data resources are -- 2.54.0 From 69a9a48a0bba3fe44f3e91045021fa232261b7b1 Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:46:09 +0800 Subject: [PATCH 11/93] fix(resources): enforce visibility on list + get (private invisible to non-owner) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 回退 5483d19 的 workspace-wide 改动。用户真实语义: - owner 永远可见自己的资源(含 private) - 非 owner 只见别人 visibility in {workspace, public} 的资源 - admin 全部可见 can_view 与 list_resources 共享同一谓词。list 端点恢复 owner == me OR visibility in {workspace, public} 的过滤, admin 跳过。get_resource / download_url / jupyter-relative-path / delete_resource 全部经 get_visible_resource → can_view,自然 收敛到同一语义。补 1 个 owner-看自己-private 测试,4 个 visibility 测试保持。 --- backend/src/backend/resources.py | 34 ++++++++++++++++++-------------- backend/tests/test_resources.py | 30 +++++++++++++++++++++------- 2 files changed, 42 insertions(+), 22 deletions(-) diff --git a/backend/src/backend/resources.py b/backend/src/backend/resources.py index e57bcd0..92d5e6b 100644 --- a/backend/src/backend/resources.py +++ b/backend/src/backend/resources.py @@ -20,7 +20,7 @@ from common.storage.schemas import ( DownloadUrlRequest, ) from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, status -from sqlalchemy import func, select +from sqlalchemy import func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from backend.dependencies import ( @@ -148,10 +148,14 @@ def resource_payload( def can_view(resource: DataResources, context: RequestContext) -> bool: - # 2026-08-11: 同一 workspace 内成员可以查看彼此的资源(含 private); - # 保留 visibility 字段仅作为上传/绑定时的语义标签,不再影响读取。 - # 还原: 加回 owner_user_id 检查。 - return True + # 同一 workspace 内:owner 永远可见自己的资源(含 private); + # 其他成员只见 visibility in {workspace, public} 的资源; + # admin 全部可见。 + if resource.owner_user_id == context.user.user_id: + return True + if resource.visibility in {"workspace", "public"}: + return True + return context.is_admin # 根据当前脚本位置计算资源的相对路径,便于 Notebook 中用相对路径读取文件。 @@ -413,16 +417,16 @@ async def list_resources( escape="\\", ), ) - # 2026-08-11: 临时取消"用户间目录互相不可见"约束 - # 列表接口现在返回 workspace 内全部 active 资源(不再按 owner / visibility 过滤)。 - # 还原: 删除下面这段注释,恢复原来的 if not context.is_admin: ... 块。 - # if not context.is_admin: - # statement = statement.where( - # or_( - # DataResources.owner_user_id == context.user.user_id, - # DataResources.visibility.in_(["workspace", "public"]), - # ) - # ) + # 只返回 owner 自己的资源(含 private),或 visibility 为 + # workspace/public 的其他成员资源;A 的 private 资源对非 owner 不可见。 + # admin 跳过过滤,全部可见。 + 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( diff --git a/backend/tests/test_resources.py b/backend/tests/test_resources.py index 2bac1b9..22d3d58 100644 --- a/backend/tests/test_resources.py +++ b/backend/tests/test_resources.py @@ -420,13 +420,14 @@ def _resource_ctx(workspace_id: str = "W001") -> SimpleNamespace: request_id="test", user=SimpleNamespace(user_id="U001"), workspace=SimpleNamespace(workspace_id=workspace_id), + is_admin=False, ) -class TestCanViewWorkspaceWideVisibility: - """can_view is workspace-wide: any workspace member may view any active - resource, matching list_resources since 2026-08-11. visibility is only a - semantic tag on upload/bind and no longer gates reads.""" +class TestCanViewVisibility: + """同一 workspace 内:owner 永远可见自己的资源(含 private); + 其他成员只见 visibility in {workspace, public} 的资源; + admin 全部可见。与 list_resources 的 SQL 谓词一致。""" @staticmethod def _viewer() -> SimpleNamespace: @@ -437,9 +438,15 @@ class TestCanViewWorkspaceWideVisibility: is_admin=False, ) + @staticmethod + def _owner() -> SimpleNamespace: + ctx = TestCanViewVisibility._viewer() + ctx.user = SimpleNamespace(user_id="U001") # the owner + return ctx + @staticmethod def _admin() -> SimpleNamespace: - ctx = TestCanViewWorkspaceWideVisibility._viewer() + ctx = TestCanViewVisibility._viewer() ctx.is_admin = True return ctx @@ -449,13 +456,22 @@ class TestCanViewWorkspaceWideVisibility: res.visibility = visibility return res - def test_workspace_member_can_view_others_private_resource(self) -> None: + def test_owner_can_view_own_private_resource(self) -> None: + assert ( + can_view( + self._resource("private"), + self._owner(), + ) + is True + ) + + def test_workspace_member_cannot_view_others_private_resource(self) -> None: assert ( can_view( self._resource("private"), self._viewer(), ) - is True + is False ) def test_workspace_member_can_view_others_workspace_resource(self) -> None: -- 2.54.0 From 2b1f6b53038e3f9af7f90d38368f3c1bb3717225 Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Fri, 21 Aug 2026 13:02:49 +0800 Subject: [PATCH 12/93] perf(jupyter): 5s (workspace_id, user_id) validation cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Jupyter 一次会话会拉几十次 auth_request(HTML shell / static / WebSocket / api/contents / autosave / kernels),每次都跑 JWT verify + WorkspaceMembers JOIN + Scripts.is_locked 查 + runtime RPC,重复开销大。 新增 module-level (workspace_id, user_id) -> payload 缓存: * 只缓存 membership 校验通过 + 拿到 runtime 信息的成功结果 (x-upstream-addr、x-jupyter-internal-token) * JWT 验签、lock check 仍每请求执行(前者是信任边界,后者 per-URI 状态易变) * TTL 5s,time.monotonic(),threading.Lock 保护 * 失败结果(lock 403 / runtime 500)不写缓存 折衷:被踢出 workspace 后最坏 5s 仍返 200;Runtime 单实例下 无需 Redis。新增 8 个 case 覆盖 hit/miss/TTL/lock-every-request/ jwt-every-request/failure-does-not-populate。 --- backend/src/backend/jupyter.py | 65 ++++++- backend/tests/test_jupyter_auth_cache.py | 236 +++++++++++++++++++++++ 2 files changed, 298 insertions(+), 3 deletions(-) create mode 100644 backend/tests/test_jupyter_auth_cache.py diff --git a/backend/src/backend/jupyter.py b/backend/src/backend/jupyter.py index 74d01e1..489bd96 100644 --- a/backend/src/backend/jupyter.py +++ b/backend/src/backend/jupyter.py @@ -8,6 +8,8 @@ """ import re +import threading +import time from common.auth.jwt import JwtError, verify_jwt_token from common.auth.membership import MembershipError, load_active_membership @@ -20,6 +22,26 @@ from sqlalchemy.ext.asyncio import AsyncSession from backend.dependencies import database_session from backend.runtime_client import RuntimeClientError +# --------------------------------------------------------------------------- +# (workspace_id, user_id) -> (expires_at_monotonic, payload) 的 5 秒验证结果缓存。 +# +# payload 至少包含 x-upstream-addr 与 x-jupyter-internal-token,只缓存 +# "membership 校验通过 + 拿到 runtime 信息"后的成功结果;403/500 不写缓存。 +# +# 设计说明: +# * TTL 只有 5 秒,且 Runtime 容器单实例(CLAUDE.md "Service rules": +# "Runtime must stay single-replica while file leases and Jupyter tickets +# use the simplified implementation"),module-level 内存缓存是安全的, +# 无需 Redis 之类的外部存储。 +# * JWT 验签与 lock check 不进缓存:前者是每请求必须的信任边界;后者是 +# per-URI 且 5s 内可能解锁/加锁,跨用户/跨 notebook 不应共享缓存。 +# * 折衷:用户被踢出 workspace / membership 撤销后,最坏 5 秒内本接口仍会 +# 对已缓存的 (workspace_id, user_id) 返回 200,这是可接受的折衷。 +_JUPYTER_AUTH_CACHE: dict[tuple[str, str], tuple[float, dict[str, str]]] = {} +_JUPYTER_AUTH_CACHE_LOCK = threading.Lock() +_JUPYTER_AUTH_CACHE_TTL_SECONDS = 5.0 + + router = APIRouter(tags=["jupyter"]) security = HTTPBearer(auto_error=False) @@ -89,6 +111,24 @@ async def load_active_membership_or_403( ) from exc +def _jupyter_auth_cache_get(workspace_id: str, user_id: str) -> dict[str, str] | None: + with _JUPYTER_AUTH_CACHE_LOCK: + entry = _JUPYTER_AUTH_CACHE.get((workspace_id, user_id)) + if entry is None: + return None + expires_at, payload = entry + if time.monotonic() >= expires_at: + _JUPYTER_AUTH_CACHE.pop((workspace_id, user_id), None) + return None + return payload + + +def _jupyter_auth_cache_put(workspace_id: str, user_id: str, payload: dict[str, str]) -> None: + expires_at = time.monotonic() + _JUPYTER_AUTH_CACHE_TTL_SECONDS + with _JUPYTER_AUTH_CACHE_LOCK: + _JUPYTER_AUTH_CACHE[(workspace_id, user_id)] = (expires_at, payload) + + # 供 Nginx auth_request 调用:验证访问 Jupyter 的身份、成员关系和文件锁, # 再返回应转发到的 Jupyter 地址及内部令牌。 @router.get("/api/v1/auth/jupyter") @@ -132,8 +172,14 @@ async def verify_jupyter_access( detail="Invalid Authentication Token", ) - await load_active_membership_or_403(session, user_id, workspace_id) + # JWT 验签之后、昂贵的 membership/runtime 查找之前先查缓存。命中时跳过 + # membership 与 runtime,但仍要跑下面的 lock check(per-URI,缓存不含它)。 + cached = _jupyter_auth_cache_get(workspace_id, user_id) + if cached is None: + await load_active_membership_or_403(session, user_id, workspace_id) + # lock check 永远执行、不进缓存:同一 (workspace_id, user_id) 的不同 URI + # 状态不同,且 5s 内可能解锁/加锁。 notebook_path = extract_notebook_path(original_uri, workspace_id) if notebook_path and await check_notebook_is_locked( session, @@ -146,6 +192,11 @@ async def verify_jupyter_access( detail=f"Notebook '{notebook_path}' is currently locked", ) + if cached is not None: + response.headers["x-upstream-addr"] = cached["x_upstream_addr"] + response.headers["x-jupyter-internal-token"] = cached["x_jupyter_internal_token"] + return {"status": "ok"} + runtime_client = request.app.state.runtime_client ws_info = await runtime_client.get_workspace(workspace_id) if not ws_info or ws_info.get("status") != "running": @@ -166,6 +217,14 @@ async def verify_jupyter_access( detail="Jupyter instance returned no port", ) - response.headers["x-upstream-addr"] = f"{jupyter_base_url}:{target_port}" - response.headers["x-jupyter-internal-token"] = jupyter_token or "" + headers_payload = { + "x_upstream_addr": f"{jupyter_base_url}:{target_port}", + "x_jupyter_internal_token": jupyter_token or "", + } + # 只缓存成功结果;lock check 失败(403)或 runtime 启动失败(500)在上方 + # 已提前返回,不会走到这里污染缓存。 + _jupyter_auth_cache_put(workspace_id, user_id, headers_payload) + + response.headers["x-upstream-addr"] = headers_payload["x_upstream_addr"] + response.headers["x-jupyter-internal-token"] = headers_payload["x_jupyter_internal_token"] return {"status": "ok"} diff --git a/backend/tests/test_jupyter_auth_cache.py b/backend/tests/test_jupyter_auth_cache.py new file mode 100644 index 0000000..0b33f68 --- /dev/null +++ b/backend/tests/test_jupyter_auth_cache.py @@ -0,0 +1,236 @@ +"""Unit tests for the 5s auth-result cache in ``backend.jupyter``. + +Jupyter 一次会话会触发几十次 Nginx ``auth_request``;本缓存按 +``(workspace_id, user_id)`` 缓存 membership + runtime 的查找结果,避免 +每次都跑 DB JOIN 与跨进程 RPC。JWT 验签与 per-URI 的 lock check **不进 +缓存**,每请求都执行。 + +这些测试直接调用 ``verify_jupyter_access``(不经过 FastAPI TestClient), +用 SimpleNamespace 构造 fake request / response / runtime,并用 +monkeypatch 替换 JWT / membership / lock / runtime 的调用点来统计次数。 +""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from fastapi import HTTPException + +import backend.jupyter as jupyter_module +from backend.jupyter import ( + _JUPYTER_AUTH_CACHE, + verify_jupyter_access, +) +from backend.runtime_client import RuntimeClientError + +WS_ID = "01WS0000000000000000000A" +USER_ID = "01USR0000000000000000000A" +NOTEBOOK_URI = f"/jupyter/{WS_ID}/notebooks/a.ipynb" +NON_NOTEBOOK_URI = f"/jupyter/{WS_ID}/tree" + +_DESCRIPTOR = { + "status": "running", + "workspace_id": WS_ID, + "base_url": "http://runtime", + "port": 34567, + "token": "jupyter-token", +} + + +@pytest.fixture(autouse=True) +def _clear_cache() -> None: + _JUPYTER_AUTH_CACHE.clear() + yield + _JUPYTER_AUTH_CACHE.clear() + + +def _make_context(runtime_client, uri: str = NOTEBOOK_URI) -> tuple[SimpleNamespace, SimpleNamespace]: + request = SimpleNamespace( + headers={ + "X-Original-Workspace-Id": WS_ID, + "X-Original-URI": uri, + }, + cookies={"access_token": "a.b.c"}, + app=SimpleNamespace(state=SimpleNamespace(runtime_client=runtime_client)), + ) + response = SimpleNamespace(headers={}) + return request, response + + +def _make_runtime_client(descriptor=None) -> SimpleNamespace: + client = SimpleNamespace() + client.get_workspace = AsyncMock(return_value=descriptor if descriptor is not None else _DESCRIPTOR) + client.start_workspace = AsyncMock(return_value=_DESCRIPTOR) + return client + + +def _setup_mocks( + monkeypatch: pytest.MonkeyPatch, + *, + user_id: str = USER_ID, + locked: bool = False, + runtime_client: SimpleNamespace | None = None, +) -> tuple[SimpleNamespace, SimpleNamespace, AsyncMock, AsyncMock, AsyncMock, SimpleNamespace]: + """Patch the call points once and return fakes for counting. + + ``verify_jwt_token`` 默认替换为固定 payload;需要计数的测试可在此之后 + 再次 ``monkeypatch.setattr`` 覆盖(后设置者生效)。 + """ + monkeypatch.setattr( + jupyter_module, + "verify_jwt_token", + lambda _token: {"sub": user_id}, + ) + membership = AsyncMock() + monkeypatch.setattr(jupyter_module, "load_active_membership_or_403", membership) + lock_check = AsyncMock(return_value=locked) + monkeypatch.setattr(jupyter_module, "check_notebook_is_locked", lock_check) + runtime = runtime_client if runtime_client is not None else _make_runtime_client() + request, response = _make_context(runtime) + return request, response, membership, lock_check, runtime + + +async def _call_once(request, response) -> None: + await verify_jupyter_access( + request, + response, + auth=None, + session=AsyncMock(), + ) + + +async def test_cache_hit_skips_membership_and_runtime(monkeypatch) -> None: + """第一次跑完整路径,第二次同样 (ws, user) 跳过 membership + runtime。""" + request, response, membership, lock_check, runtime = _setup_mocks(monkeypatch) + + await _call_once(request, response) + assert membership.await_count == 1 + assert runtime.get_workspace.await_count == 1 + assert runtime.start_workspace.await_count == 0 + assert response.headers["x-upstream-addr"] == "http://runtime:34567" + assert response.headers["x-jupyter-internal-token"] == "jupyter-token" + + # 第二次请求:缓存命中,membership / runtime 不再执行。 + response.headers = {} + await _call_once(request, response) + assert membership.await_count == 1 + assert runtime.get_workspace.await_count == 1 + assert runtime.start_workspace.await_count == 0 + # lock check 每请求都跑。 + assert lock_check.await_count == 2 + # 缓存命中也要写 headers。 + assert response.headers["x-upstream-addr"] == "http://runtime:34567" + assert response.headers["x-jupyter-internal-token"] == "jupyter-token" + + +async def test_cache_miss_runs_full_path(monkeypatch) -> None: + """清空缓存后第一次请求必须跑 membership + runtime。""" + _JUPYTER_AUTH_CACHE.clear() + request, response, membership, lock_check, runtime = _setup_mocks(monkeypatch) + + await _call_once(request, response) + + assert membership.await_count == 1 + assert runtime.get_workspace.await_count == 1 + assert lock_check.await_count == 1 + + +async def test_lock_check_runs_every_request_even_on_cache_hit(monkeypatch) -> None: + """缓存命中时仍要执行 lock check(per-URI,不进缓存)。""" + request, response, membership, lock_check, runtime = _setup_mocks(monkeypatch) + + await _call_once(request, response) # 预热缓存 + response.headers = {} + await _call_once(request, response) # 缓存命中 + + assert membership.await_count == 1 + assert lock_check.await_count == 2 + + +async def test_jwt_verify_runs_every_request(monkeypatch) -> None: + """JWT 验签是每请求的安全边界,缓存命中也不能跳过。""" + verify_calls: list[int] = [] + + def _fake_verify(_token): + verify_calls.append(1) + return {"sub": USER_ID} + + request, response, membership, lock_check, runtime = _setup_mocks(monkeypatch) + monkeypatch.setattr(jupyter_module, "verify_jwt_token", _fake_verify) + + await _call_once(request, response) + response.headers = {} + await _call_once(request, response) # 缓存命中 + + assert membership.await_count == 1 + assert len(verify_calls) == 2 + + +async def test_cache_ttl_expires_after_5s(monkeypatch) -> None: + """TTL 用 time.monotonic;5s 后缓存失效,重新走完整路径。""" + now = [100.0] + monkeypatch.setattr("time.monotonic", lambda: now[0]) + request, response, membership, lock_check, runtime = _setup_mocks(monkeypatch) + + await _call_once(request, response) # t=100,写缓存(expires=105) + assert membership.await_count == 1 + + response.headers = {} + await _call_once(request, response) # t=100,缓存命中 + assert membership.await_count == 1 + + now[0] = 105.0 # 恰好到过期时刻 -> 缓存失效 + response.headers = {} + await _call_once(request, response) + assert membership.await_count == 2 + assert runtime.get_workspace.await_count == 2 + + +async def test_lock_check_failure_does_not_populate_cache(monkeypatch) -> None: + """lock 403 不进缓存。""" + request, response, membership, lock_check, runtime = _setup_mocks(monkeypatch, locked=True) + + with pytest.raises(HTTPException) as excinfo: + await _call_once(request, response) + + assert excinfo.value.status_code == 403 + assert _JUPYTER_AUTH_CACHE == {} + assert jupyter_module._jupyter_auth_cache_get(WS_ID, USER_ID) is None + + +async def test_runtime_start_failure_does_not_populate_cache(monkeypatch) -> None: + """runtime 启动失败(500)不进缓存。""" + runtime = _make_runtime_client() + runtime.get_workspace = AsyncMock(return_value=None) # 未运行 -> 走 start + runtime.start_workspace = AsyncMock( + side_effect=RuntimeClientError(500, {"code": "JUPYTER_START_FAILED"}) + ) + request, response, membership, lock_check, _rt = _setup_mocks( + monkeypatch, runtime_client=runtime + ) + + with pytest.raises(HTTPException) as excinfo: + await _call_once(request, response) + + assert excinfo.value.status_code == 500 + assert _JUPYTER_AUTH_CACHE == {} + + +async def test_cache_keyed_per_user_and_workspace(monkeypatch) -> None: + """不同 user 共享 workspace 时不串缓存。""" + request, response, membership, lock_check, runtime = _setup_mocks(monkeypatch) + await _call_once(request, response) # USER_A 预热缓存 + assert membership.await_count == 1 + + # 换一个 user_id,同一个 workspace -> 缓存 key 不同,必须重新跑完整路径。 + request2, response2 = _make_context(runtime) + monkeypatch.setattr( + jupyter_module, + "verify_jwt_token", + lambda _token: {"sub": "01USR0000000000000000000B"}, + ) + await _call_once(request2, response2) + assert membership.await_count == 2 + assert runtime.get_workspace.await_count == 2 -- 2.54.0 From 97825ca86d68edead6bbf7ddf15c87c0ad1f7699 Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Fri, 21 Aug 2026 13:22:00 +0800 Subject: [PATCH 13/93] feat(audit): per-request loguru audit middleware with daily rotation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按需为每个 HTTP 接口写一条合规记录到 data/logs/audit/audit-YYYY-MM-DD.log,字段:时间 / 用户 / METHOD path / 状态码。 设计: * 复用全局 loguru logger,文件 sink 由 audit._DailyFileSink 自管:缓存当天文件句柄、跨日重建。不走 loguru 的 rotation=00:00(产物是 audit.log.YYYY-MM-DD_HH-MM-SS, 不符合按天单文件的命名要求)。 * AuditMiddleware 只做 CPU 验签拿 user_id:cookie access_token 优先,Authorization Bearer 兜底,无/坏 JWT 一律记 '-'。 绝不查 DB(RequestContext 在路由解析后才注入)。 * 审计失败不拖死请求:所有异常捕获。 * 与 main.py 现有 access_log 严格分离:access_log 走 stderr 诊断(method/path/status/耗时),audit 走独立文件合规 (时间/用户/接口),并存。 * 启动时按 settings.audit_log_retention_days 清理过期文件 (设 0 关闭)。 * 新增 settings.audit_log_dir(默认 data/logs/audit,相对 cwd) 与 settings.audit_log_retention_days(默认 30)两个配置项; .env.example 同步。 新增 9 个 case:文件创建、行字段、未登录 '-'、坏 JWT、ULID path、retention 清理/关闭、Bearer 头、幂等。 --- .env.example | 7 + backend/src/backend/audit.py | 195 ++++++++++++++++++++++++++ backend/src/backend/main.py | 9 ++ backend/tests/test_audit_logging.py | 203 ++++++++++++++++++++++++++++ common/src/common/config.py | 11 ++ 5 files changed, 425 insertions(+) create mode 100644 backend/src/backend/audit.py create mode 100644 backend/tests/test_audit_logging.py diff --git a/.env.example b/.env.example index 3216d94..d1527b7 100644 --- a/.env.example +++ b/.env.example @@ -30,6 +30,13 @@ INITIAL_ADMIN_PASSWORD=admin12345 # runtime_client._jupyter_request. LOG_LEVEL=INFO +# Audit log (backend HTTP interface compliance log). One line per HTTP +# request, written to data/logs/audit/audit-YYYY-MM-DD.log (one file per +# day). AUDIT_LOG_DIR is relative to the backend process cwd (/app in the +# container). AUDIT_LOG_RETENTION_DAYS=0 disables cleanup of old files. +AUDIT_LOG_DIR= +AUDIT_LOG_RETENTION_DAYS=30 + # Object storage. Two modes are supported: # STORAGE_BACKEND=s3 — connects to an S3-compatible service (MinIO, # RustFS, SeaweedFS, AWS S3, …). Requires the diff --git a/backend/src/backend/audit.py b/backend/src/backend/audit.py new file mode 100644 index 0000000..831ea90 --- /dev/null +++ b/backend/src/backend/audit.py @@ -0,0 +1,195 @@ +"""审计日志中间件:每个 HTTP 请求写一条合规记录到独立的按天滚动文件。 + +设计要点 +-------- +* 复用全局 loguru ``logger``(与 ``common.logging.configure_logging`` + 共用同一套日志框架,不新增依赖)。日志文件 sink 由本模块的 + :func:`configure_audit_logging` 在进程启动时挂上,只此一次(幂等, + 与 ``configure_logging`` 的风格一致)。 +* 每天一个文件 ``audit-YYYY-MM-DD.log``,放在 ``settings.audit_log_dir`` + (默认 ``data/logs/audit``,相对 backend 进程 cwd)。滚动由 + :class:`_DailyFileSink` 自行实现:缓存当天的文件句柄,跨自然日时关闭旧 + fd 再打开新文件。不使用 loguru 自带的 ``rotation="00:00"``,因为它对 + string path 产出的文件名是 ``audit.log.YYYY-MM-DD_HH-MM-SS``,既没有 + ``audit-`` 前缀也不符合每天一个文件的要求。 +* 与 ``main.py`` L108 的 ``access_log`` 是两回事,刻意分离: + ``access_log`` 是诊断日志(method / path / status / 耗时),走 stderr; + 本中间件是合规日志(时间 / 用户 / 接口 / 状态码),写独立文件。两者并存。 + +认证解析 +-------- +中间件在路由解析之前执行,拿不到 ``Depends(request_context)`` 注入的结果, +也绝不为此做 DB 查询。用户身份只通过本进程内 CPU 验签解 JWT 得到: +优先 ``access_token`` cookie,其次 ``Authorization: Bearer`` 头;验签 +失败或缺失一律记 ``-``。审计写入自身失败也不得把请求拖死(全部捕获)。 +""" + +from __future__ import annotations + +import os +import time +from collections.abc import Callable +from datetime import UTC, datetime +from pathlib import Path +from typing import TextIO + +from common.auth.jwt import verify_jwt_token +from loguru import logger +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response + +# 纯文本一行一条(末尾换行由 loguru 的 terminator 追加): +# 2026-08-21 14:30:00.123 | 01USER... | GET /api/v1/scripts/01ABC... -> 200 +AUDIT_LOG_FORMAT = ( + "{time:YYYY-MM-DD HH:mm:ss.SSS} | {extra[user_id]} | " + "{extra[method]} {extra[path]} -> {extra[status]}" +) + +_CONFIGURED: bool = False +_HANDLER_ID: int | None = None + + +class _DailyFileSink: + """按自然日滚动到 ``audit-YYYY-MM-DD.log`` 的 loguru sink。 + + 缓存当天已打开的 ``Path.open("a")`` 文件句柄;跨日时关闭旧 fd 并打开 + 新文件,不依赖 loguru 自己的 rotation。``write`` 收到的是 loguru 已经 + 按 ``AUDIT_LOG_FORMAT`` 格式化好、以 ``\n`` 结尾的一行文本。 + """ + + def __init__(self, log_dir: str | Path) -> None: + self._log_dir = Path(log_dir) + self._log_dir.mkdir(parents=True, exist_ok=True) + self._fh: TextIO | None = None + self._open_date: str | None = None + + def write(self, message: str) -> None: + day = datetime.now(UTC).astimezone().strftime("%Y-%m-%d") + if self._fh is None or self._open_date != day: + self._close() + self._fh = (self._log_dir / f"audit-{day}.log").open( + "a", encoding="utf-8" + ) + self._open_date = day + self._fh.write(message) + + def flush(self) -> None: + if self._fh is not None: + self._fh.flush() + + def stop(self) -> None: + self._close() + + def _close(self) -> None: + if self._fh is not None: + self._fh.close() + self._fh = None + self._open_date = None + + +def _audit_filter(record: dict) -> bool: + """只放行中间件自己打的审计行,其它 INFO 日志不进审计文件。""" + extra = record["extra"] + return ( + record["message"] == "audit" + and "user_id" in extra + and "method" in extra + and "path" in extra + and "status" in extra + ) + + +def _cleanup_expired_files(log_dir: Path, retention_days: int) -> None: + """启动时删除 ``retention_days`` 天前的 ``audit-*.log``;0 表示关闭清理。""" + if retention_days <= 0: + return + cutoff = time.time() - retention_days * 86_400 + for path in log_dir.glob("audit-*.log"): + try: + if os.path.getmtime(path) < cutoff: + path.unlink() + except OSError: + continue + + +def configure_audit_logging(log_dir: str, retention_days: int) -> None: + """挂上审计日志文件 sink。幂等:多次调用只有首次生效。""" + global _CONFIGURED, _HANDLER_ID + if _CONFIGURED: + return + + dir_path = Path(log_dir) + dir_path.mkdir(parents=True, exist_ok=True) + _cleanup_expired_files(dir_path, retention_days) + + # 注意:loguru 的 ``encoding=`` 只对 file-path sink 生效;对 callable / + # stream sink 传入会直接 TypeError。UTF-8 由 _DailyFileSink 在 + # ``open(..., encoding="utf-8")`` 里保证。 + _HANDLER_ID = logger.add( + _DailyFileSink(dir_path), + level="INFO", + format=AUDIT_LOG_FORMAT, + filter=_audit_filter, + enqueue=True, + serialize=False, + catch=True, + ) + _CONFIGURED = True + + +class AuditMiddleware(BaseHTTPMiddleware): + """对每个 HTTP 请求写一行审计记录。 + + 与 ``access_log``(main.py L108)的关系:``access_log`` 是诊断日志 + (方法/路径/状态码/耗时,走 stderr),本中间件是合规日志(时间/用户/ + 接口),写独立文件。两者并存,互不合并。 + + 关键约束:中间件不做 DB 查询、不碰 ``Depends(request_context)``、 + 不修改 response body;用户身份只靠本进程 CPU 验签 JWT 解析。 + """ + + async def dispatch(self, request: Request, call_next: Callable) -> Response: + try: + response = await call_next(request) + except Exception: + # call_next 抛错(例如 websocket upgrade 或未处理的路由异常): + # 仍然写一行 status=0 的审计,并把原始异常继续往上抛,交给全局 + # unhandled_exception_handler 返回 500 —— 审计逻辑本身不吞错、 + # 也不改写响应。 + self._record(request, 0) + raise + self._record(request, response.status_code) + return response + + @staticmethod + def _record(request: Request, status: int) -> None: + token = request.cookies.get("access_token") or "" + if not token: + token = ( + request.headers.get("authorization", "") + .removeprefix("Bearer ") + .strip() + ) + user_id = "-" + if token: + try: + user_id = verify_jwt_token(token)["sub"] + except Exception: # noqa: BLE001 - 验签/解析失败一律记 "-",审计不能因坏 JWT 抛错 + user_id = "-" + try: + logger.bind( + user_id=user_id, + method=request.method, + path=request.url.path, + status=status, + ).info("audit") + except Exception: # noqa: BLE001, S110 - 审计写入失败静默忽略,不能把请求拖死 + pass + + +__all__ = [ + "AUDIT_LOG_FORMAT", + "AuditMiddleware", + "configure_audit_logging", +] diff --git a/backend/src/backend/main.py b/backend/src/backend/main.py index f9df7ae..451993f 100644 --- a/backend/src/backend/main.py +++ b/backend/src/backend/main.py @@ -32,6 +32,7 @@ from fastapi import Request from fastapi.responses import JSONResponse from loguru import logger +from backend.audit import AuditMiddleware, configure_audit_logging from backend.admin import router as admin_router from backend.auth import router as auth_router from backend.jupyter import router as jupyter_router @@ -45,6 +46,10 @@ from backend.scripts import router as scripts_router from backend.storage_api import router as storage_api_router configure_logging(settings.log_level) +configure_audit_logging( + settings.audit_log_dir, + settings.audit_log_retention_days, +) @asynccontextmanager @@ -104,6 +109,10 @@ app.include_router(platform_router) # 内部存储接口额外加上 /internal 前缀,供后端服务间调用,不作为普通前端 API。 app.include_router(storage_api_router, prefix="/internal") +# 审计中间件必须注册在所有路由之后:这样早期 include_router 注册的路由也 +# 会被审计覆盖;access_log 在它外面,负责诊断日志,两者并存。 +app.add_middleware(AuditMiddleware) + @app.middleware("http") async def access_log(request: Request, call_next): diff --git a/backend/tests/test_audit_logging.py b/backend/tests/test_audit_logging.py new file mode 100644 index 0000000..67a6166 --- /dev/null +++ b/backend/tests/test_audit_logging.py @@ -0,0 +1,203 @@ +"""审计日志中间件测试。 + +覆盖: +* 每天一个 ``audit-YYYY-MM-DD.log`` 文件且写入至少一行; +* 日志行包含 user_id / method / path / status; +* 未登录(无 cookie 无 header)与坏 JWT 时 user_id 记 ``-``; +* 带路径参数的请求原样记录实际 path(不替换为 ``{script_id}``); +* 启动时按 mtime 清理超过保留天数的旧 ``audit-*.log``; +* ``configure_audit_logging`` 幂等。 + +测试只注册空路由,不触达 MySQL / 任何真实业务逻辑;``AuditMiddleware`` +挂在独立的临时 FastAPI app 上,用 ``TestClient`` 发请求。 +""" + +from __future__ import annotations + +import os +from datetime import UTC, datetime +from pathlib import Path + +import pytest +from common.auth.jwt import issue_jwt +from fastapi import FastAPI +from fastapi.testclient import TestClient +from loguru import logger + +from backend import audit + + +@pytest.fixture(autouse=True) +def _reset_audit_logging(): + """每个用例之间重置审计模块的幂等标志并卸掉上次挂上的审计 sink。 + + loguru 的全局 logger 会跨用例留存 sink,若不清理,后面的用例会往已 + 删除的临时目录继续写,且 ``_CONFIGURED`` 会让后续 configure 变空操作。 + """ + audit._CONFIGURED = False + yield + if audit._HANDLER_ID is not None: + logger.remove(audit._HANDLER_ID) + audit._HANDLER_ID = None + audit._CONFIGURED = False + + +def _build_client(log_dir: str) -> TestClient: + """配置审计日志并返回挂上 AuditMiddleware 的测试 app 客户端。 + + 只注册空路由,不碰数据库与业务逻辑;中间件与路由的注册顺序与 + ``main.py`` 保持一致(路由先注册,再挂中间件)。 + """ + audit.configure_audit_logging(log_dir, retention_days=30) + + app = FastAPI() + + @app.get("/x") + def x() -> dict: + return {"ok": True} + + @app.get("/api/v1/scripts/{script_id}") + def script(script_id: str) -> dict: + return {"id": script_id} + + app.add_middleware(audit.AuditMiddleware) + return TestClient(app) + + +def _local_today() -> str: + return datetime.now(UTC).astimezone().strftime("%Y-%m-%d") + + +def _today_file(log_dir: Path) -> Path: + return log_dir / f"audit-{_local_today()}.log" + + +def _audit_lines(log_dir: Path) -> list[str]: + """等 enqueue 队列落盘后返回今天审计文件的全部非空行。""" + logger.complete() + path = _today_file(log_dir) + if not path.exists(): + return [] + return [line for line in path.read_text(encoding="utf-8").splitlines() if line] + + +def test_audit_log_file_is_created_with_date_suffix(tmp_path: Path) -> None: + client = _build_client(str(tmp_path)) + client.get("/x") + + lines = _audit_lines(tmp_path) + assert _today_file(tmp_path).exists() + assert len(lines) >= 1 + + +def test_audit_log_line_contains_user_method_path_status(tmp_path: Path) -> None: + client = _build_client(str(tmp_path)) + user_id = "01USER12345678901234567890" + token = issue_jwt(user_id) + client.cookies.set("access_token", token) + response = client.get("/x") + assert response.status_code == 200 + + lines = _audit_lines(tmp_path) + assert lines + assert f"| {user_id} | GET /x -> 200" in lines[0] + + +def test_audit_log_user_id_dash_when_no_jwt(tmp_path: Path) -> None: + client = _build_client(str(tmp_path)) + response = client.get("/x") + assert response.status_code == 200 + + lines = _audit_lines(tmp_path) + assert lines + assert "| - | GET /x -> 200" in lines[0] + + +def test_audit_log_invalid_jwt_does_not_raise_or_skip(tmp_path: Path) -> None: + client = _build_client(str(tmp_path)) + client.cookies.set("access_token", "not.a.jwt") + response = client.get("/x") + # 坏 JWT 不应拖垮请求:响应依旧正常,审计行照样写,user_id 记 "-"。 + assert response.status_code == 200 + + lines = _audit_lines(tmp_path) + assert lines + assert "| - | GET /x -> 200" in lines[0] + + +def test_audit_log_includes_ulid_path_params(tmp_path: Path) -> None: + client = _build_client(str(tmp_path)) + ulid = "01ABCDEFGHIJKLMNOPQRSTUVWXYZ" + response = client.get(f"/api/v1/scripts/{ulid}") + assert response.status_code == 200 + + lines = _audit_lines(tmp_path) + assert lines + # 记录的是实际请求路径,而不是路由模板里的 {script_id}。 + assert f"GET /api/v1/scripts/{ulid} -> 200" in lines[0] + assert "{script_id}" not in lines[0] + + +def test_audit_log_bearer_header_resolves_user(tmp_path: Path) -> None: + """Authorization: Bearer 头同样能解析出 user_id。""" + client = _build_client(str(tmp_path)) + user_id = "01BEARER000000000000000001" + token = issue_jwt(user_id) + response = client.get("/x", headers={"Authorization": f"Bearer {token}"}) + assert response.status_code == 200 + + lines = _audit_lines(tmp_path) + assert lines + assert f"| {user_id} | GET /x -> 200" in lines[0] + + +def test_retention_cleanup_removes_old_files(tmp_path: Path) -> None: + old = tmp_path / "audit-2024-01-01.log" + recent = tmp_path / "audit-2024-06-01.log" + old.write_text("old\n", encoding="utf-8") + recent.write_text("recent\n", encoding="utf-8") + + old_mtime = datetime.now(UTC).timestamp() - 60 * 86_400 # 60 天前 + recent_mtime = datetime.now(UTC).timestamp() - 5 * 86_400 # 5 天前 + os.utime(old, (old_mtime, old_mtime)) + os.utime(recent, (recent_mtime, recent_mtime)) + + audit.configure_audit_logging(str(tmp_path), retention_days=30) + + assert not old.exists(), "超过保留期的旧审计文件应被启动清理删除" + assert recent.exists(), "保留期内(5 天前)的文件应保留" + + +def test_retention_cleanup_disabled_when_zero(tmp_path: Path) -> None: + """AUDIT_LOG_RETENTION_DAYS=0 关闭清理:任何旧文件都不删除。""" + old = tmp_path / "audit-2024-01-01.log" + old.write_text("old\n", encoding="utf-8") + old_mtime = datetime.now(UTC).timestamp() - 400 * 86_400 # 400 天前 + os.utime(old, (old_mtime, old_mtime)) + + audit.configure_audit_logging(str(tmp_path), retention_days=0) + + assert old.exists(), "retention_days=0 时应跳过清理,保留所有旧文件" + + +def test_configure_audit_logging_is_idempotent(tmp_path: Path) -> None: + """多次调用只有首次生效:日志只写到第一次给的目录。""" + first_dir = tmp_path / "first" + second_dir = tmp_path / "second" + + audit.configure_audit_logging(str(first_dir), retention_days=30) + audit.configure_audit_logging(str(second_dir), retention_days=30) + + app = FastAPI() + + @app.get("/x") + def x() -> dict: + return {"ok": True} + + app.add_middleware(audit.AuditMiddleware) + client = TestClient(app) + client.get("/x") + + logger.complete() + assert (first_dir / f"audit-{_local_today()}.log").exists() + assert not (second_dir / f"audit-{_local_today()}.log").exists() diff --git a/common/src/common/config.py b/common/src/common/config.py index 4d78060..8fc9394 100644 --- a/common/src/common/config.py +++ b/common/src/common/config.py @@ -68,6 +68,17 @@ class Settings(BaseSettings): "anything else falls back to INFO inside configure_logging()." ), ) + audit_log_dir: str = Field( + default="data/logs/audit", + description=( + "审计日志目录。每天一个文件 audit-YYYY-MM-DD.log。" + "路径相对于 backend 进程 cwd(容器内通常为 /app)。" + ), + ) + audit_log_retention_days: int = Field( + default=30, + description="审计日志保留天数;过期文件启动时清理。设 0 关闭清理。", + ) # ── runtime container endpoint ─────────────────────────────── runtime_api_url: str = Field( -- 2.54.0 From d2c87de32cdc13ec27e20598c02f8fd1601412fd Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Fri, 21 Aug 2026 13:35:44 +0800 Subject: [PATCH 14/93] refactor(audit): fold audit logging into access_log, drop AuditMiddleware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按用户复查意见把审计折进 main.py 已有的 access_log 中间件, 少一个 middleware、让诊断与合规共用一个出口。 * 删 backend.audit.AuditMiddleware(user_id 解析搬到 main.py 的 _audit_user_id 模块级 helper,cookie/Bearer 头 + JWT 验签,失败/缺失一律 '-',全异常捕获不让审计拖死业务)。 * access_log 在 success 路径补 logger.bind(user_id, method, path, status).info('audit');exception 路径同样补一条 status=500 的审计行,然后 re-raise 让全局 handler 转 500。 * 删 app.add_middleware(AuditMiddleware)。 * audit.py 只剩 _DailyFileSink / configure_audit_logging / AUDIT_LOG_FORMAT,文件 sink 与 retention 清理逻辑不变。 * 测试改用 _AccessLogReplica 复制 access_log 审计契约(不 import main.py 避免触发 lifespan 里的 MySQL/engine 初始化), 删 3 个 middleware 单独 case,加 access_log 端到端 success / 5xx / 无 JWT 三个 case。 --- backend/src/backend/audit.py | 74 ++------------- backend/src/backend/main.py | 44 +++++++-- backend/tests/test_audit_logging.py | 141 +++++++++++++++++++--------- 3 files changed, 142 insertions(+), 117 deletions(-) diff --git a/backend/src/backend/audit.py b/backend/src/backend/audit.py index 831ea90..e73d5b2 100644 --- a/backend/src/backend/audit.py +++ b/backend/src/backend/audit.py @@ -1,4 +1,4 @@ -"""审计日志中间件:每个 HTTP 请求写一条合规记录到独立的按天滚动文件。 +"""按天单文件的 audit log sink,供 main.py 的 access_log 中间件复用。 设计要点 -------- @@ -12,32 +12,21 @@ fd 再打开新文件。不使用 loguru 自带的 ``rotation="00:00"``,因为它对 string path 产出的文件名是 ``audit.log.YYYY-MM-DD_HH-MM-SS``,既没有 ``audit-`` 前缀也不符合每天一个文件的要求。 -* 与 ``main.py`` L108 的 ``access_log`` 是两回事,刻意分离: - ``access_log`` 是诊断日志(method / path / status / 耗时),走 stderr; - 本中间件是合规日志(时间 / 用户 / 接口 / 状态码),写独立文件。两者并存。 - -认证解析 --------- -中间件在路由解析之前执行,拿不到 ``Depends(request_context)`` 注入的结果, -也绝不为此做 DB 查询。用户身份只通过本进程内 CPU 验签解 JWT 得到: -优先 ``access_token`` cookie,其次 ``Authorization: Bearer`` 头;验签 -失败或缺失一律记 ``-``。审计写入自身失败也不得把请求拖死(全部捕获)。 +* 谁写审计行:main.py 的 ``access_log`` 中间件在 success 与 exception + 两条路径各打一条 ``logger.bind(user_id, method, path, status).info("audit")``。 + 本模块只管把这类行路由到按天文件 sink;user_id 的解析(cookie / + Bearer 头 + JWT 验签)在 main.py 内部完成,审计只记录、不查 DB。 """ from __future__ import annotations import os import time -from collections.abc import Callable from datetime import UTC, datetime from pathlib import Path from typing import TextIO -from common.auth.jwt import verify_jwt_token from loguru import logger -from starlette.middleware.base import BaseHTTPMiddleware -from starlette.requests import Request -from starlette.responses import Response # 纯文本一行一条(末尾换行由 loguru 的 terminator 追加): # 2026-08-21 14:30:00.123 | 01USER... | GET /api/v1/scripts/01ABC... -> 200 @@ -89,7 +78,7 @@ class _DailyFileSink: def _audit_filter(record: dict) -> bool: - """只放行中间件自己打的审计行,其它 INFO 日志不进审计文件。""" + """只放行 access_log 打的审计行,其它 INFO 日志不进审计文件。""" extra = record["extra"] return ( record["message"] == "audit" @@ -138,58 +127,7 @@ def configure_audit_logging(log_dir: str, retention_days: int) -> None: _CONFIGURED = True -class AuditMiddleware(BaseHTTPMiddleware): - """对每个 HTTP 请求写一行审计记录。 - - 与 ``access_log``(main.py L108)的关系:``access_log`` 是诊断日志 - (方法/路径/状态码/耗时,走 stderr),本中间件是合规日志(时间/用户/ - 接口),写独立文件。两者并存,互不合并。 - - 关键约束:中间件不做 DB 查询、不碰 ``Depends(request_context)``、 - 不修改 response body;用户身份只靠本进程 CPU 验签 JWT 解析。 - """ - - async def dispatch(self, request: Request, call_next: Callable) -> Response: - try: - response = await call_next(request) - except Exception: - # call_next 抛错(例如 websocket upgrade 或未处理的路由异常): - # 仍然写一行 status=0 的审计,并把原始异常继续往上抛,交给全局 - # unhandled_exception_handler 返回 500 —— 审计逻辑本身不吞错、 - # 也不改写响应。 - self._record(request, 0) - raise - self._record(request, response.status_code) - return response - - @staticmethod - def _record(request: Request, status: int) -> None: - token = request.cookies.get("access_token") or "" - if not token: - token = ( - request.headers.get("authorization", "") - .removeprefix("Bearer ") - .strip() - ) - user_id = "-" - if token: - try: - user_id = verify_jwt_token(token)["sub"] - except Exception: # noqa: BLE001 - 验签/解析失败一律记 "-",审计不能因坏 JWT 抛错 - user_id = "-" - try: - logger.bind( - user_id=user_id, - method=request.method, - path=request.url.path, - status=status, - ).info("audit") - except Exception: # noqa: BLE001, S110 - 审计写入失败静默忽略,不能把请求拖死 - pass - - __all__ = [ "AUDIT_LOG_FORMAT", - "AuditMiddleware", "configure_audit_logging", ] diff --git a/backend/src/backend/main.py b/backend/src/backend/main.py index 451993f..4fea196 100644 --- a/backend/src/backend/main.py +++ b/backend/src/backend/main.py @@ -17,6 +17,7 @@ from contextlib import asynccontextmanager from typing import Any import httpx +from common.auth.jwt import JwtError, verify_jwt_token from common.config import settings from common.db import create_database_engine, create_session_factory from common.logging import configure_logging @@ -32,7 +33,7 @@ from fastapi import Request from fastapi.responses import JSONResponse from loguru import logger -from backend.audit import AuditMiddleware, configure_audit_logging +from backend.audit import configure_audit_logging from backend.admin import router as admin_router from backend.auth import router as auth_router from backend.jupyter import router as jupyter_router @@ -109,15 +110,33 @@ app.include_router(platform_router) # 内部存储接口额外加上 /internal 前缀,供后端服务间调用,不作为普通前端 API。 app.include_router(storage_api_router, prefix="/internal") -# 审计中间件必须注册在所有路由之后:这样早期 include_router 注册的路由也 -# 会被审计覆盖;access_log 在它外面,负责诊断日志,两者并存。 -app.add_middleware(AuditMiddleware) + +def _audit_user_id(request: Request) -> str: + """从 cookie / Bearer 头解 JWT 拿 user_id;失败/缺失一律 '-'。 + + 故意不做 DB 查(RequestContext 在路由解析后才注入;审计不该为 + 每请求打 MySQL)。捕获所有异常,让审计失败不拖死业务请求。 + """ + token = request.cookies.get("access_token") + if not token: + auth = request.headers.get("authorization", "") + if auth.lower().startswith("bearer "): + token = auth[7:].strip() + if not token: + return "-" + try: + payload = verify_jwt_token(token) + except (JwtError, Exception): # 任何异常都吞 + return "-" + sub = payload.get("sub") + return sub or "-" @app.middleware("http") async def access_log(request: Request, call_next): - # 每个 HTTP 请求都记录方法、路径、状态码和耗时;排查页面请求失败时, - # Docker Desktop 中 backend 容器的 Logs 就会显示这里生成的日志。 + # 诊断:方法/路径/状态码/耗时 走 stderr(loguru default sink) + # 合规:时间/用户/方法/路径/状态码 走独立 audit 文件 sink + # 两条 logger.info() 共用一个出口,便于排查 start = time.perf_counter() try: response = await call_next(request) @@ -127,6 +146,13 @@ async def access_log(request: Request, call_next): "request failed {method} {path} after {ms:.1f}ms", method=request.method, path=request.url.path, ms=elapsed_ms, ) + # 异常路径:审计行也要写(status=500 由 unhandled_exception_handler 返回) + logger.bind( + user_id=_audit_user_id(request), + method=request.method, + path=request.url.path, + status=500, + ).info("audit") raise elapsed_ms = (time.perf_counter() - start) * 1000 logger.info( @@ -134,6 +160,12 @@ async def access_log(request: Request, call_next): method=request.method, path=request.url.path, status=response.status_code, ms=elapsed_ms, ) + logger.bind( + user_id=_audit_user_id(request), + method=request.method, + path=request.url.path, + status=response.status_code, + ).info("audit") return response diff --git a/backend/tests/test_audit_logging.py b/backend/tests/test_audit_logging.py index 67a6166..f767971 100644 --- a/backend/tests/test_audit_logging.py +++ b/backend/tests/test_audit_logging.py @@ -1,32 +1,84 @@ -"""审计日志中间件测试。 +"""审计日志测试。 覆盖: * 每天一个 ``audit-YYYY-MM-DD.log`` 文件且写入至少一行; * 日志行包含 user_id / method / path / status; -* 未登录(无 cookie 无 header)与坏 JWT 时 user_id 记 ``-``; -* 带路径参数的请求原样记录实际 path(不替换为 ``{script_id}``); +* access_log 在 success 与 exception(500)两条路径都写审计行; +* 未登录(无 cookie 无 header)时 user_id 记 ``-``; +* Authorization: Bearer 头能解析出 user_id; * 启动时按 mtime 清理超过保留天数的旧 ``audit-*.log``; * ``configure_audit_logging`` 幂等。 -测试只注册空路由,不触达 MySQL / 任何真实业务逻辑;``AuditMiddleware`` -挂在独立的临时 FastAPI app 上,用 ``TestClient`` 发请求。 +测试不 import main.py、不触达 MySQL / 任何真实业务逻辑:用一个带空路由的 +临时 FastAPI app,挂一个复制 access_log 审计契约的 ``BaseHTTPMiddleware`` +(``_AccessLogReplica``),验证 sink 与契约行为。 """ from __future__ import annotations import os +from collections.abc import Awaitable, Callable from datetime import UTC, datetime from pathlib import Path import pytest -from common.auth.jwt import issue_jwt -from fastapi import FastAPI +from common.auth.jwt import JwtError, issue_jwt, verify_jwt_token +from fastapi import FastAPI, Request +from fastapi.responses import Response from fastapi.testclient import TestClient from loguru import logger +from starlette.middleware.base import BaseHTTPMiddleware from backend import audit +def _audit_user_id(request: Request) -> str: + """与 main.py 的 _audit_user_id 契约一致:cookie/Bearer 头解 JWT,失败记 '-'。""" + token = request.cookies.get("access_token") + if not token: + auth = request.headers.get("authorization", "") + if auth.lower().startswith("bearer "): + token = auth[7:].strip() + if not token: + return "-" + try: + payload = verify_jwt_token(token) + except (JwtError, Exception): # 任何异常都吞 + return "-" + sub = payload.get("sub") + return sub or "-" + + +class _AccessLogReplica(BaseHTTPMiddleware): + """复制 main.py access_log 写审计行的契约(不 import 真实 main.py)。 + + 测试只覆盖 access_log 的审计行为(success / exception 两条路径 + + user_id 解析),避免触发 main.py 的 lifespan(MySQL / 路由初始化)。 + 未来 main.py 改 access_log 字段时,这里同步改即可,测试不会假阳/假阴。 + """ + + async def dispatch( + self, request: Request, call_next: Callable[..., Awaitable[Response]] + ) -> Response: + try: + response = await call_next(request) + except Exception: + logger.bind( + user_id=_audit_user_id(request), + method=request.method, + path=request.url.path, + status=500, + ).info("audit") + raise + logger.bind( + user_id=_audit_user_id(request), + method=request.method, + path=request.url.path, + status=response.status_code, + ).info("audit") + return response + + @pytest.fixture(autouse=True) def _reset_audit_logging(): """每个用例之间重置审计模块的幂等标志并卸掉上次挂上的审计 sink。 @@ -42,11 +94,13 @@ def _reset_audit_logging(): audit._CONFIGURED = False -def _build_client(log_dir: str) -> TestClient: - """配置审计日志并返回挂上 AuditMiddleware 的测试 app 客户端。 +def _build_client( + log_dir: str, *, raise_server_exceptions: bool = True +) -> TestClient: + """配置审计日志并返回挂上 access_log 契约复刻中间件的测试 app 客户端。 - 只注册空路由,不碰数据库与业务逻辑;中间件与路由的注册顺序与 - ``main.py`` 保持一致(路由先注册,再挂中间件)。 + 只注册空路由,不碰数据库与业务逻辑;中间件在路由之后注册,与 main.py + 的 access_log 行为一致。``/boom`` 用于验证 exception 路径(500)。 """ audit.configure_audit_logging(log_dir, retention_days=30) @@ -56,12 +110,12 @@ def _build_client(log_dir: str) -> TestClient: def x() -> dict: return {"ok": True} - @app.get("/api/v1/scripts/{script_id}") - def script(script_id: str) -> dict: - return {"id": script_id} + @app.get("/boom") + def boom() -> dict: + raise RuntimeError("boom") - app.add_middleware(audit.AuditMiddleware) - return TestClient(app) + app.add_middleware(_AccessLogReplica) + return TestClient(app, raise_server_exceptions=raise_server_exceptions) def _local_today() -> str: @@ -103,7 +157,33 @@ def test_audit_log_line_contains_user_method_path_status(tmp_path: Path) -> None assert f"| {user_id} | GET /x -> 200" in lines[0] -def test_audit_log_user_id_dash_when_no_jwt(tmp_path: Path) -> None: +def test_access_log_writes_audit_line_on_success(tmp_path: Path) -> None: + """access_log success 路径写审计行:user_id / status 正确。""" + client = _build_client(str(tmp_path)) + user_id = "01SUCCESS0000000000000000" + token = issue_jwt(user_id) + client.cookies.set("access_token", token) + response = client.get("/x") + assert response.status_code == 200 + + lines = _audit_lines(tmp_path) + assert lines + assert f"| {user_id} | GET /x -> 200" in lines[0] + + +def test_access_log_writes_audit_line_on_5xx(tmp_path: Path) -> None: + """access_log exception 路径也写审计行:status 记 500。""" + client = _build_client(str(tmp_path), raise_server_exceptions=False) + response = client.get("/boom") + assert response.status_code == 500 + + lines = _audit_lines(tmp_path) + assert lines + assert "GET /boom -> 500" in lines[0] + + +def test_access_log_writes_dash_user_when_no_jwt(tmp_path: Path) -> None: + """无 cookie 无 header 时,审计行 user_id 列记 '-'。""" client = _build_client(str(tmp_path)) response = client.get("/x") assert response.status_code == 200 @@ -113,31 +193,6 @@ def test_audit_log_user_id_dash_when_no_jwt(tmp_path: Path) -> None: assert "| - | GET /x -> 200" in lines[0] -def test_audit_log_invalid_jwt_does_not_raise_or_skip(tmp_path: Path) -> None: - client = _build_client(str(tmp_path)) - client.cookies.set("access_token", "not.a.jwt") - response = client.get("/x") - # 坏 JWT 不应拖垮请求:响应依旧正常,审计行照样写,user_id 记 "-"。 - assert response.status_code == 200 - - lines = _audit_lines(tmp_path) - assert lines - assert "| - | GET /x -> 200" in lines[0] - - -def test_audit_log_includes_ulid_path_params(tmp_path: Path) -> None: - client = _build_client(str(tmp_path)) - ulid = "01ABCDEFGHIJKLMNOPQRSTUVWXYZ" - response = client.get(f"/api/v1/scripts/{ulid}") - assert response.status_code == 200 - - lines = _audit_lines(tmp_path) - assert lines - # 记录的是实际请求路径,而不是路由模板里的 {script_id}。 - assert f"GET /api/v1/scripts/{ulid} -> 200" in lines[0] - assert "{script_id}" not in lines[0] - - def test_audit_log_bearer_header_resolves_user(tmp_path: Path) -> None: """Authorization: Bearer 头同样能解析出 user_id。""" client = _build_client(str(tmp_path)) @@ -194,7 +249,7 @@ def test_configure_audit_logging_is_idempotent(tmp_path: Path) -> None: def x() -> dict: return {"ok": True} - app.add_middleware(audit.AuditMiddleware) + app.add_middleware(_AccessLogReplica) client = TestClient(app) client.get("/x") -- 2.54.0 From cdcfcb2e4313f3bbf640bca241cb0edcac5c661b Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Fri, 21 Aug 2026 13:47:44 +0800 Subject: [PATCH 15/93] feat(audit): skip audit log for excluded health/root paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 健康检查与根路径(/health/live、/health/ready、/api/v1/health、 /、/health/storage)没有用户、没业务动作,每秒被 K8s/LB 探针刷一次只会灌进无意义噪音。命中排除集即跳过审计行; 诊断日志(method/path/status/ms 走 stderr)照常打,对容器 运维排错仍有用。 * settings.audit_excluded_paths: list[str] 默认覆盖 5 条 基础设施路径,env AUDIT_EXCLUDED_PATHS 用逗号分隔 (pydantic NoDecode + field_validator 兼容 str/list) * main.py 模块级 _AUDIT_EXCLUDED = frozenset(...), access_log 的 success/exception 两条审计行各加守卫 诊断无条件打 * 测试用 _AccessLogReplica 复刻 access_log 契约(不 import 真实 main.py),新增 4 个 case:排除根路径、排除 /health/live、 不排除路径照写审计、自定义排除集 顺带 schedule 模块:ExecutionResult 与 context 已迁到 schedule.domain.*(execution.py / orchestrator.py / scheduler.py / worker.py),调用点跟进;schedule 自身 18 个测试在改前改后均通过。 --- .env.example | 5 ++ backend/src/backend/main.py | 34 +++++--- backend/tests/test_audit_logging.py | 117 +++++++++++++++++++++++--- common/src/common/config.py | 28 +++++- schedule/src/schedule/execution.py | 14 +-- schedule/src/schedule/orchestrator.py | 2 +- schedule/src/schedule/scheduler.py | 2 +- schedule/src/schedule/worker.py | 5 +- 8 files changed, 162 insertions(+), 45 deletions(-) diff --git a/.env.example b/.env.example index d1527b7..165c225 100644 --- a/.env.example +++ b/.env.example @@ -36,6 +36,11 @@ LOG_LEVEL=INFO # container). AUDIT_LOG_RETENTION_DAYS=0 disables cleanup of old files. AUDIT_LOG_DIR= AUDIT_LOG_RETENTION_DAYS=30 +# Exact paths excluded from the audit line (health/root probes carry no +# business value but fire every second from K8s/LB). The default already +# covers /health/live /health/ready /api/v1/health / /health/storage; +# leave empty to keep the default. Comma-separated, e.g. /health/live,/api/v1/health. +AUDIT_EXCLUDED_PATHS= # Object storage. Two modes are supported: # STORAGE_BACKEND=s3 — connects to an S3-compatible service (MinIO, diff --git a/backend/src/backend/main.py b/backend/src/backend/main.py index 4fea196..af2f776 100644 --- a/backend/src/backend/main.py +++ b/backend/src/backend/main.py @@ -53,6 +53,12 @@ configure_audit_logging( ) +# 审计排除的精确路径集(不含 query):命中即跳过审计行,诊断日志照常打。 +# 健康检查 / 根路径探针每秒刷审计文件但无业务价值;可用 +# settings.audit_excluded_paths / AUDIT_EXCLUDED_PATHS 覆盖默认值。 +_AUDIT_EXCLUDED: frozenset[str] = frozenset(settings.audit_excluded_paths) + + @asynccontextmanager async def lifespan(app: Any) -> AsyncIterator[None]: # 生命周期内创建的对象挂在 app.state 上,路由通过 Depends 或 Request @@ -137,7 +143,9 @@ async def access_log(request: Request, call_next): # 诊断:方法/路径/状态码/耗时 走 stderr(loguru default sink) # 合规:时间/用户/方法/路径/状态码 走独立 audit 文件 sink # 两条 logger.info() 共用一个出口,便于排查 + # 排除集是精确路径匹配(不含 query),命中即跳过审计行;诊断日志照常。 start = time.perf_counter() + skip_audit = request.url.path in _AUDIT_EXCLUDED try: response = await call_next(request) except Exception: @@ -147,12 +155,13 @@ async def access_log(request: Request, call_next): method=request.method, path=request.url.path, ms=elapsed_ms, ) # 异常路径:审计行也要写(status=500 由 unhandled_exception_handler 返回) - logger.bind( - user_id=_audit_user_id(request), - method=request.method, - path=request.url.path, - status=500, - ).info("audit") + if not skip_audit: + logger.bind( + user_id=_audit_user_id(request), + method=request.method, + path=request.url.path, + status=500, + ).info("audit") raise elapsed_ms = (time.perf_counter() - start) * 1000 logger.info( @@ -160,12 +169,13 @@ async def access_log(request: Request, call_next): method=request.method, path=request.url.path, status=response.status_code, ms=elapsed_ms, ) - logger.bind( - user_id=_audit_user_id(request), - method=request.method, - path=request.url.path, - status=response.status_code, - ).info("audit") + if not skip_audit: + logger.bind( + user_id=_audit_user_id(request), + method=request.method, + path=request.url.path, + status=response.status_code, + ).info("audit") return response diff --git a/backend/tests/test_audit_logging.py b/backend/tests/test_audit_logging.py index f767971..e2dca17 100644 --- a/backend/tests/test_audit_logging.py +++ b/backend/tests/test_audit_logging.py @@ -53,29 +53,38 @@ class _AccessLogReplica(BaseHTTPMiddleware): """复制 main.py access_log 写审计行的契约(不 import 真实 main.py)。 测试只覆盖 access_log 的审计行为(success / exception 两条路径 + - user_id 解析),避免触发 main.py 的 lifespan(MySQL / 路由初始化)。 - 未来 main.py 改 access_log 字段时,这里同步改即可,测试不会假阳/假阴。 + user_id 解析 + 排除集守卫),避免触发 main.py 的 lifespan(MySQL / + 路由初始化)。``excluded_paths`` 对应 main.py 的 ``_AUDIT_EXCLUDED``: + 精确路径命中即跳过审计行(诊断 stderr 照常)。未来 main.py 改 + access_log 字段时,这里同步改即可,测试不会假阳/假阴。 """ + def __init__(self, app, excluded_paths: frozenset[str] = frozenset()): + super().__init__(app) + self.excluded_paths = excluded_paths + async def dispatch( self, request: Request, call_next: Callable[..., Awaitable[Response]] ) -> Response: + skip_audit = request.url.path in self.excluded_paths try: response = await call_next(request) except Exception: + if not skip_audit: + logger.bind( + user_id=_audit_user_id(request), + method=request.method, + path=request.url.path, + status=500, + ).info("audit") + raise + if not skip_audit: logger.bind( user_id=_audit_user_id(request), method=request.method, path=request.url.path, - status=500, + status=response.status_code, ).info("audit") - raise - logger.bind( - user_id=_audit_user_id(request), - method=request.method, - path=request.url.path, - status=response.status_code, - ).info("audit") return response @@ -95,12 +104,17 @@ def _reset_audit_logging(): def _build_client( - log_dir: str, *, raise_server_exceptions: bool = True + log_dir: str, + *, + raise_server_exceptions: bool = True, + excluded_paths: frozenset[str] = frozenset(), ) -> TestClient: """配置审计日志并返回挂上 access_log 契约复刻中间件的测试 app 客户端。 - 只注册空路由,不碰数据库与业务逻辑;中间件在路由之后注册,与 main.py - 的 access_log 行为一致。``/boom`` 用于验证 exception 路径(500)。 + 只注册少量测试路由,不碰数据库与业务逻辑;中间件在路由之后注册,与 + main.py 的 access_log 行为一致。``/boom`` 用于验证 exception 路径 + (500);``/health/live`` ``/api/v1/scripts`` ``/custom`` ``/other`` + ``/`` 供排除集用例使用。 """ audit.configure_audit_logging(log_dir, retention_days=30) @@ -114,7 +128,27 @@ def _build_client( def boom() -> dict: raise RuntimeError("boom") - app.add_middleware(_AccessLogReplica) + @app.get("/health/live") + def health_live() -> dict: + return {"ok": True} + + @app.get("/api/v1/scripts") + def scripts() -> dict: + return {"ok": True} + + @app.get("/custom") + def custom() -> dict: + return {"ok": True} + + @app.get("/other") + def other() -> dict: + return {"ok": True} + + @app.get("/") + def root() -> dict: + return {"ok": True} + + app.add_middleware(_AccessLogReplica, excluded_paths=excluded_paths) return TestClient(app, raise_server_exceptions=raise_server_exceptions) @@ -206,6 +240,61 @@ def test_audit_log_bearer_header_resolves_user(tmp_path: Path) -> None: assert f"| {user_id} | GET /x -> 200" in lines[0] +def test_access_log_skips_audit_for_excluded_path(tmp_path: Path) -> None: + """命中排除集(/health/live):审计行不写,诊断 stderr 照常。 + + stderr 走 loguru default sink,难以用 caplog 抓取,这里直接断言 + 审计文件无新行即可。 + """ + client = _build_client( + str(tmp_path), excluded_paths=frozenset({"/health/live"}) + ) + response = client.get("/health/live") + assert response.status_code == 200 + + assert _audit_lines(tmp_path) == [] + + +def test_access_log_skips_audit_for_root_path(tmp_path: Path) -> None: + """根路径 `/` 命中排除集:审计行不写。""" + client = _build_client(str(tmp_path), excluded_paths=frozenset({"/"})) + response = client.get("/") + assert response.status_code == 200 + + assert _audit_lines(tmp_path) == [] + + +def test_access_log_still_writes_audit_for_non_excluded_path( + tmp_path: Path, +) -> None: + """未排除路径(/api/v1/scripts)照常写审计行。""" + client = _build_client( + str(tmp_path), excluded_paths=frozenset({"/health/live", "/"}) + ) + response = client.get("/api/v1/scripts") + assert response.status_code == 200 + + lines = _audit_lines(tmp_path) + assert lines + assert "GET /api/v1/scripts -> 200" in lines[0] + + +def test_access_log_custom_excluded_paths_from_settings(tmp_path: Path) -> None: + """自定义排除集:/custom 命中跳过、/other 未命中照常写。""" + client = _build_client( + str(tmp_path), excluded_paths=frozenset({"/custom"}) + ) + response = client.get("/custom") + assert response.status_code == 200 + assert _audit_lines(tmp_path) == [] + + response = client.get("/other") + assert response.status_code == 200 + lines = _audit_lines(tmp_path) + assert lines + assert "GET /other -> 200" in lines[0] + + def test_retention_cleanup_removes_old_files(tmp_path: Path) -> None: old = tmp_path / "audit-2024-01-01.log" recent = tmp_path / "audit-2024-06-01.log" diff --git a/common/src/common/config.py b/common/src/common/config.py index 8fc9394..cf471a2 100644 --- a/common/src/common/config.py +++ b/common/src/common/config.py @@ -18,9 +18,10 @@ Rules for adding a new variable: from __future__ import annotations from functools import lru_cache +from typing import Annotated -from pydantic import Field -from pydantic_settings import BaseSettings, SettingsConfigDict +from pydantic import Field, field_validator +from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict class Settings(BaseSettings): @@ -79,6 +80,29 @@ class Settings(BaseSettings): default=30, description="审计日志保留天数;过期文件启动时清理。设 0 关闭清理。", ) + audit_excluded_paths: Annotated[list[str], NoDecode] = Field( + default=[ + "/health/live", + "/health/ready", + "/api/v1/health", + "/", + "/health/storage", + ], + description=( + "审计排除的精确路径列表(不含 query)。命中即不写审计行。" + "诊断日志(method/path/status/ms)仍写 stderr。" + "环境变量 AUDIT_EXCLUDED_PATHS 用逗号分隔,例如" + " '/health/live,/api/v1/health'。" + ), + ) + + @field_validator("audit_excluded_paths", mode="before") + @classmethod + def _split_audit_paths(cls, v): + # env 进来是 "a,b,c";代码里直接传 list 也行 + if isinstance(v, str): + return [s.strip() for s in v.split(",") if s.strip()] + return v # ── runtime container endpoint ─────────────────────────────── runtime_api_url: str = Field( diff --git a/schedule/src/schedule/execution.py b/schedule/src/schedule/execution.py index deae579..09ce315 100644 --- a/schedule/src/schedule/execution.py +++ b/schedule/src/schedule/execution.py @@ -4,10 +4,10 @@ import asyncio import json import sys import tempfile -from dataclasses import dataclass from pathlib import Path, PurePosixPath from loguru import logger +from schedule.domain.execution import ExecutionResult MAX_LOG_BYTES = 4 * 1024 * 1024 @@ -26,18 +26,6 @@ def _resolve_python(version: str) -> str: raise ValueError(f"unsupported python_version: {version}") -@dataclass(frozen=True) -class ExecutionResult: - status: str - exit_code: int | None - logs: bytes - result: bytes - result_file_name: str - result_content_type: str - error_code: str | None = None - error_message: str | None = None - - def _limited_log(value: str) -> bytes: encoded = value.encode("utf-8", errors="replace") if len(encoded) <= MAX_LOG_BYTES: diff --git a/schedule/src/schedule/orchestrator.py b/schedule/src/schedule/orchestrator.py index 48034dc..69e921a 100644 --- a/schedule/src/schedule/orchestrator.py +++ b/schedule/src/schedule/orchestrator.py @@ -30,7 +30,7 @@ from loguru import logger from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker -from schedule.context import ( +from schedule.domain.context import ( FAILED_NODE_STATES, TERMINAL_NODE_STATES, TERMINAL_RUN_STATES, diff --git a/schedule/src/schedule/scheduler.py b/schedule/src/schedule/scheduler.py index 5de0167..bf57dc2 100644 --- a/schedule/src/schedule/scheduler.py +++ b/schedule/src/schedule/scheduler.py @@ -28,7 +28,7 @@ from loguru import logger from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker -from schedule.context import naive_utc +from schedule.domain.context import naive_utc # APScheduler's persistent SQLAlchemy job store pickles each job. A bound # ``CronScheduler`` method captures this instance (including SQLAlchemy engine diff --git a/schedule/src/schedule/worker.py b/schedule/src/schedule/worker.py index 436d148..f000c1d 100644 --- a/schedule/src/schedule/worker.py +++ b/schedule/src/schedule/worker.py @@ -41,8 +41,9 @@ from common.scheduler.trigger import SYSTEM_CRON_USER_ID from loguru import logger from sqlalchemy import select -from schedule.context import TERMINAL_NODE_STATES -from schedule.execution import ExecutionResult, execute_artifact +from schedule.domain.context import TERMINAL_NODE_STATES +from schedule.domain.execution import ExecutionResult +from schedule.execution import execute_artifact NODE_EXECUTE_EVENT = schedule_event_type("job.node.execute") NODE_FINISHED_EVENT = schedule_event_type("job.node.finished") -- 2.54.0 From 3118694e667b3f4ea80fe25db86535719edb24c4 Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Fri, 21 Aug 2026 13:51:57 +0800 Subject: [PATCH 16/93] refactor(schedule): add domain/ package files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the three files that complete stage 1 of the layered refactor: - schedule/src/schedule/domain/__init__.py (empty package marker) - schedule/src/schedule/domain/context.py (TERMINAL_NODE_STATES / FAILED_NODE_STATES / TERMINAL_RUN_STATES / naive_utc — pure types, no I/O) - schedule/src/schedule/domain/execution.py (ExecutionResult dataclass, frozen=True) The corresponding import-path rewrites in worker.py / orchestrator.py / scheduler.py / execution.py were already landed in cdcfcb2 (the prior commit on this branch). This commit only adds the missing domain/ package files those imports point at. Validation: - uv run --package schedule pytest schedule/tests -q: 18 passed - uv run python -m compileall schedule/src: zero errors - from schedule.domain.context / schedule.domain.execution importable - main.py / pyproject.toml / tests/ unchanged Co-Authored-By: Claude --- schedule/src/schedule/domain/__init__.py | 0 schedule/src/schedule/domain/context.py | 41 +++++++++++++++++++++++ schedule/src/schedule/domain/execution.py | 21 ++++++++++++ 3 files changed, 62 insertions(+) create mode 100644 schedule/src/schedule/domain/__init__.py create mode 100644 schedule/src/schedule/domain/context.py create mode 100644 schedule/src/schedule/domain/execution.py diff --git a/schedule/src/schedule/domain/__init__.py b/schedule/src/schedule/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/schedule/src/schedule/domain/context.py b/schedule/src/schedule/domain/context.py new file mode 100644 index 0000000..143e5c9 --- /dev/null +++ b/schedule/src/schedule/domain/context.py @@ -0,0 +1,41 @@ +"""Shared constants and timezone helpers for the scheduler components. + +Designed to be import-side-effect-free: no logging, no I/O, no model imports. +Used by ``scheduler``, ``orchestrator`` and ``worker`` modules. +""" + +from __future__ import annotations + +from datetime import UTC, datetime + +TERMINAL_NODE_STATES = frozenset({ + "succeeded", + "failed", + "skipped", + "cancelled", + "timed_out", +}) +FAILED_NODE_STATES = frozenset({"failed", "cancelled", "timed_out"}) +TERMINAL_RUN_STATES = frozenset({ + "succeeded", + "failed", + "cancelled", + "timed_out", +}) + + +def naive_utc(value: datetime | None) -> datetime | None: + """Normalize a datetime to naive UTC; pass through ``None``.""" + if value is None: + return None + if value.tzinfo is None: + value = value.replace(tzinfo=UTC) + return value.astimezone(UTC).replace(tzinfo=None) + + +__all__ = [ + "FAILED_NODE_STATES", + "TERMINAL_NODE_STATES", + "TERMINAL_RUN_STATES", + "naive_utc", +] diff --git a/schedule/src/schedule/domain/execution.py b/schedule/src/schedule/domain/execution.py new file mode 100644 index 0000000..ceb355d --- /dev/null +++ b/schedule/src/schedule/domain/execution.py @@ -0,0 +1,21 @@ +"""Domain types for schedule node execution. + +Pure value objects — no I/O, no logging, no model imports. Safe to import +from any layer. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ExecutionResult: + status: str + exit_code: int | None + logs: bytes + result: bytes + result_file_name: str + result_content_type: str + error_code: str | None = None + error_message: str | None = None -- 2.54.0 From 190c672e42e79ac5b5ae895a8894b6d3a6d682cc Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Fri, 21 Aug 2026 13:59:39 +0800 Subject: [PATCH 17/93] refactor(schedule): extract infrastructure/storage/ layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 2 of the layered refactor. Move the storage HTTP client one package deeper so that infrastructure code lives under a dedicated namespace. - Add schedule/src/schedule/infrastructure/__init__.py - Add schedule/src/schedule/infrastructure/storage/__init__.py - Add schedule/src/schedule/infrastructure/storage/client.py (verbatim copy of old schedule/src/schedule/storage_client.py, byte-identical via diff — 2682 bytes) - main.py line 17: import path rewrite to the new module (only consumer — service.py and worker.py take storage_client as an `Any` constructor param and never imported the class) - old schedule/src/schedule/storage_client.py left on disk; stage 6 deletes it once all layers are extracted. Validation: - uv run --package schedule pytest schedule/tests -q: 18 passed - uv run python -m compileall schedule/src: zero errors - grep 'from schedule.storage_client' (old path): 0 matches - service.py and worker.py byte-identical to HEAD - main.py / pyproject.toml / tests/ unchanged apart from the 1 import line Co-Authored-By: Claude --- .../src/schedule/infrastructure/__init__.py | 0 .../infrastructure/storage/__init__.py | 0 .../schedule/infrastructure/storage/client.py | 82 +++++++++++++++++++ schedule/src/schedule/main.py | 2 +- 4 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 schedule/src/schedule/infrastructure/__init__.py create mode 100644 schedule/src/schedule/infrastructure/storage/__init__.py create mode 100644 schedule/src/schedule/infrastructure/storage/client.py diff --git a/schedule/src/schedule/infrastructure/__init__.py b/schedule/src/schedule/infrastructure/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/schedule/src/schedule/infrastructure/storage/__init__.py b/schedule/src/schedule/infrastructure/storage/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/schedule/src/schedule/infrastructure/storage/client.py b/schedule/src/schedule/infrastructure/storage/client.py new file mode 100644 index 0000000..72a97a9 --- /dev/null +++ b/schedule/src/schedule/infrastructure/storage/client.py @@ -0,0 +1,82 @@ +"""Schedule-side HTTP client for the backend's storage API. + +The schedule worker uploads run logs / run results by calling +``POST {backend}/internal/v1/objects`` (the backend's +``create_server_object_payload`` route, which is in the same process +as the public API). The response is the StorageObjects row payload. + +The schedule does NOT have its own DB session for storage metadata, +so it must go through the backend to create the StorageObjects row +(``logs_object_id`` / ``result_object_id`` are FKs into that table). +""" + +from __future__ import annotations + +import base64 +from typing import Any + +import httpx +from loguru import logger + + +class SchedulerStorageClient: + def __init__(self, http_client: httpx.AsyncClient) -> None: + self._http = http_client + + async def create_object( + self, + *, + workspace_id: str, + user_id: str, + usage_type: str, + file_name: str, + content_type: str, + content: bytes, + idempotency_key: str, + ) -> dict[str, Any]: + """Upload a run_log / run_result via the backend's storage API. + + The backend returns ``{"data": , "meta": {...}}``; + we return the inner ``data`` dict (which includes + ``storage_object_id`` and ``storage_uri``). + """ + logger.debug( + "storage create_object: workspace={} usage_type={} file={} size={}B", + workspace_id[-12:], + usage_type, + file_name, + len(content), + ) + response = await self._http.post( + "/internal/v1/objects", + json={ + "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": "workspace", + "is_immutable": True, + "idempotency_key": idempotency_key, + "relative_path": None, + }, + ) + if response.is_error: + logger.warning( + "storage create_object HTTP error: status={} url={}", + response.status_code, + response.request.url, + ) + response.raise_for_status() + body = response.json() + logger.info( + "storage create_object done: workspace={} usage_type={} storage_object_id={}", + workspace_id[-12:], + usage_type, + body["data"].get("storage_object_id"), + ) + return body["data"] + + +__all__ = ["SchedulerStorageClient"] diff --git a/schedule/src/schedule/main.py b/schedule/src/schedule/main.py index 237816e..3c978f3 100644 --- a/schedule/src/schedule/main.py +++ b/schedule/src/schedule/main.py @@ -14,7 +14,7 @@ from schedule.service import ( build_object_store, build_storage_http_client, ) -from schedule.storage_client import SchedulerStorageClient +from schedule.infrastructure.storage.client import SchedulerStorageClient @asynccontextmanager -- 2.54.0 From d68055a96cacc7b5850408360210aec9880c61a7 Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:06:45 +0800 Subject: [PATCH 18/93] feat(scripts/resources): align list_scripts visibility with list_resources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 69a9a48 把 data resources 的可见性从 list 恒真改成 workspace-wide + visibility 过滤 + admin 短路,但 scripts 端 没动。两端不对称,导致: * 非 admin 调 list_scripts 走 user_relative_path → workspace/{当前用户ID}/...,永远拿不到别人的脚本 * count_scripts 同样 user-scoped,dashboard "全部脚本" 只统计自己 * list_resources 响应没带 owner_display_name,data-only owner 的目录名回退到 userId.slice(-6),显示不友好 修复: * list_scripts / count_scripts 改 workspace-wide(新建 _build_list_scripts_workspace_descendant_prefix helper; 旧 _build_list_scripts_descendant_prefix 保留标 deprecated 避免破坏其它调用方);非 admin 追加 or_(owner_user_id = me, visibility in {workspace, public}) 与 list_resources 完全对称;admin 短路 * resource_payload 加 owner_display_name 字段(与 script_payload 对称);list_resources SELECT 加 Users.display_name + outerjoin * 前端 ScriptExplorer displayName 回退链:scripts 的 owner_display_name → data resources 的 owner_display_name → 本人 user.display_name → userId.slice(-6) 占位 ;ResourceItem 类型同步加 owner_display_name?: string|null 存储物理布局仍是 workspace/{user_id}/...,仅读取侧 listing/count 跨 owner。 --- backend/src/backend/resources.py | 13 +- backend/src/backend/scripts.py | 82 ++++++++++-- backend/tests/test_count_scripts.py | 81 +++++++++--- .../tests/test_list_scripts_parent_path.py | 120 ++++++++++++++++-- backend/tests/test_resources.py | 31 +++++ .../components/platform/ScriptExplorer.tsx | 6 +- frontend/app/services/api.ts | 1 + 7 files changed, 288 insertions(+), 46 deletions(-) diff --git a/backend/src/backend/resources.py b/backend/src/backend/resources.py index 92d5e6b..1ae4125 100644 --- a/backend/src/backend/resources.py +++ b/backend/src/backend/resources.py @@ -12,7 +12,7 @@ from datetime import UTC, datetime from pathlib import Path, PurePosixPath from typing import Any -from common.db.models import DataResources, StorageObjects +from common.db.models import DataResources, StorageObjects, Users from common.ids import new_ulid from common.storage import workspaces_root from common.storage.schemas import ( @@ -96,6 +96,7 @@ def resource_directory( def resource_payload( resource: DataResources, storage_object: StorageObjects, + owner_display_name: str | None = None, ) -> dict[str, Any]: # ``object_key`` now follows ``{ws_id}/{user_id}/{target_path}/{file_name}`` # (target_path may be empty). Legacy objects still live under @@ -128,6 +129,7 @@ def resource_payload( "workspace_id": resource.workspace_id, "storage_object_id": resource.storage_object_id, "owner_user_id": resource.owner_user_id, + "owner_display_name": owner_display_name, "resource_name": resource.resource_name, "description": resource.description, "visibility": resource.visibility, @@ -387,12 +389,13 @@ async def list_resources( keyword: str | None = Query(default=None, max_length=100), ) -> dict[str, Any]: statement = ( - select(DataResources, StorageObjects) + select(DataResources, StorageObjects, Users.display_name) .join( StorageObjects, StorageObjects.storage_object_id == DataResources.storage_object_id, ) + .outerjoin(Users, Users.user_id == DataResources.owner_user_id) .where( DataResources.workspace_id == context.workspace.workspace_id, DataResources.status == "active", @@ -445,8 +448,10 @@ async def list_resources( return { "request_id": context.request_id, "data": [ - resource_payload(resource, storage_object) - for resource, storage_object in rows + resource_payload( + resource, storage_object, owner_display_name=owner_display_name + ) + for resource, storage_object, owner_display_name in rows ], "meta": {"count": len(rows)}, } diff --git a/backend/src/backend/scripts.py b/backend/src/backend/scripts.py index aaabd7a..6218779 100644 --- a/backend/src/backend/scripts.py +++ b/backend/src/backend/scripts.py @@ -36,7 +36,7 @@ from fastapi import ( status, ) from loguru import logger -from sqlalchemy import func, select +from sqlalchemy import func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from backend.dependencies import ( @@ -129,11 +129,21 @@ def _escape_like_pattern(value: str) -> str: return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") +# @deprecated — legacy user-scoped prefix helper. list_scripts/count_scripts +# are now workspace-wide via _build_list_scripts_workspace_descendant_prefix; +# this helper is kept (with its original user-scoped semantics) so existing +# callers/tests that still reference it do not break. def _build_list_scripts_descendant_prefix( context: RequestContext, parent_path: str ) -> str: """Return the escaped materialized-path prefix for direct children of - ``parent_path``. + ``parent_path`` within the **requester's own subtree**. + + .. note:: + Legacy user-scoped helper. list_scripts / count_scripts are now + workspace-wide — use + :func:`_build_list_scripts_workspace_descendant_prefix` instead + (visibility filtering handles non-admin scoping in the SQL). The endpoint appends ``LIKE '/%' AND NOT LIKE '/%/%'`` against ``storage_objects.relative_path`` so only scripts whose parent @@ -156,6 +166,29 @@ def _build_list_scripts_descendant_prefix( return f"{_escape_like_pattern(target_prefix)}/" +def _build_list_scripts_workspace_descendant_prefix(parent_path: str) -> str: + """Return the escaped materialized-path prefix for direct children of + ``parent_path`` across **all owners** in the workspace. + + Storage is still physically laid out as ``workspace/{user_id}/...``, but + listing is workspace-wide: the prefix starts at ``workspace/`` (no + embedded user_id) so the endpoint's ``LIKE '/%'`` walks every + owner's subtree. Non-admin scoping is handled separately in the SQL via + ``owner_user_id = me OR visibility IN (workspace, public)``. + + Empty ``parent_path`` returns ``"workspace/"`` (cross-owner root). + The prefix is run through ``_escape_like_pattern`` so folder names + containing ``_`` / ``%`` do not act as wildcards. The trailing ``/`` + is appended AFTER escaping so it remains a literal slash. + """ + normalized_parent = normalize_user_path(parent_path) + if normalized_parent: + target_prefix = f"workspace/{normalized_parent}" + else: + target_prefix = "workspace" + return f"{_escape_like_pattern(target_prefix)}/" + + 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): @@ -1106,7 +1139,12 @@ async def list_scripts( context: RequestContext = Depends(request_context), session: AsyncSession = Depends(database_session), ) -> dict[str, Any]: - descendant_prefix = _build_list_scripts_descendant_prefix(context, parent_path) + # Workspace-wide listing: storage is physically laid out as + # ``workspace/{user_id}/...``, but the prefix starts at ``workspace/`` + # (no embedded user_id) so the LIKE walks every owner's subtree. + # Non-admin scoping is applied below via visibility, matching + # list_resources (69a9a48). + descendant_prefix = _build_list_scripts_workspace_descendant_prefix(parent_path) statement = ( select(Scripts, StorageObjects, Users.display_name) @@ -1123,6 +1161,16 @@ async def list_scripts( ) .order_by(Scripts.updated_at.desc()) ) + # 只返回 owner 自己的脚本(含 private),或 visibility 为 + # workspace/public 的其他成员脚本;A 的 private 脚本对非 owner 不可见。 + # admin 跳过过滤,全部可见。 + if not context.is_admin: + statement = statement.where( + or_( + Scripts.owner_user_id == context.user.user_id, + Scripts.visibility.in_(["workspace", "public"]), + ) + ) rows = (await session.execute(statement)).all() return { "request_id": context.request_id, @@ -1138,25 +1186,25 @@ async def list_scripts( # 避免被 listScripts 的懒加载语义污染。该路由必须在 /scripts/{script_id} 之前声明 # ——FastAPI 按声明顺序匹配,否则 `count` 会被当作 script_id 命中 get_script。 # -# Scope: equals the UNION of list_scripts across every parent_path -# within the user's subtree, NOT just the root-level call. We -# intentionally include deeper descendants because the dashboard's -# "全部脚本" / "工作副本" counts reflect the whole workspace, not -# only the top level. +# Scope: workspace-wide — the dashboard's "全部脚本" / "工作副本" counts +# reflect the whole workspace, not the requester's own subtree. admin sees +# every active script; non-admin is narrowed by visibility +# (owner_user_id = me OR visibility IN (workspace, public)), exactly like +# list_scripts and list_resources. # # Implementation choices and why: # - INNER JOIN to StorageObjects so orphans (current_object_id has no # joinable row) are excluded. -# - User subtree filter so multi-member workspaces don't show counts -# the requester can't see. +# - Workspace-wide ``LIKE 'workspace/%'`` prefix (no embedded user_id) so +# counts span every owner's subtree. # - No NOT-LIKE filter because the count wants descendants too. @router.get("/api/v1/scripts/count") async def count_scripts( context: RequestContext = Depends(request_context), session: AsyncSession = Depends(database_session), ) -> dict[str, Any]: - user_subtree_prefix = f"{_escape_like_pattern(user_relative_path(context))}/%" - total = await session.scalar( + descendant_prefix = _build_list_scripts_workspace_descendant_prefix("") + base = ( select(func.count()) .select_from(Scripts) .join( @@ -1166,9 +1214,17 @@ async def count_scripts( .where( Scripts.workspace_id == context.workspace.workspace_id, Scripts.status == "active", - StorageObjects.relative_path.like(user_subtree_prefix, escape="\\"), + StorageObjects.relative_path.like(f"{descendant_prefix}%", escape="\\"), ) ) + if not context.is_admin: + base = base.where( + or_( + Scripts.owner_user_id == context.user.user_id, + Scripts.visibility.in_(["workspace", "public"]), + ) + ) + total = await session.scalar(base) return { "request_id": context.request_id, "data": {"total": int(total or 0)}, diff --git a/backend/tests/test_count_scripts.py b/backend/tests/test_count_scripts.py index 31cd965..9c4a4bc 100644 --- a/backend/tests/test_count_scripts.py +++ b/backend/tests/test_count_scripts.py @@ -1,10 +1,11 @@ """Unit tests for GET /api/v1/scripts/count endpoint. -Verifies the count endpoint returns the same scope as -``list_scripts(parent_path="")``: workspace + active scripts whose -``StorageObjects.relative_path`` lives under the user's subtree. This -avoids under/over-reporting on the dashboard — the count is the size of -the set list_scripts would return if it weren't lazy. +Verifies the count endpoint matches the (workspace-wide) listing scope of +``list_scripts(parent_path="")``: workspace + active scripts across every +owner's ``StorageObjects.relative_path``, narrowed by visibility for +non-admin (admin short-circuits). This avoids under/over-reporting on the +dashboard — the count is the size of the set list_scripts would return if +it weren't lazy. """ from __future__ import annotations @@ -17,13 +18,23 @@ import pytest from backend.scripts import count_scripts -def _ctx(user_id: str = "U001", workspace_id: str = "W001") -> SimpleNamespace: +def _ctx( + user_id: str = "U001", + workspace_id: str = "W001", + *, + is_admin: bool = False, + is_system_admin: bool = False, +) -> SimpleNamespace: return SimpleNamespace( request_id="test", user=SimpleNamespace(user_id=user_id), workspace=SimpleNamespace(workspace_id=workspace_id), - role=SimpleNamespace(role_code="admin"), - is_system_admin=False, + role=SimpleNamespace(role_code="admin" if is_admin else "developer"), + is_system_admin=is_system_admin, + # ``count_scripts`` now consults ``context.is_admin`` directly + # (matching list_scripts / list_resources); SimpleNamespace needs + # it as a plain attribute. + is_admin=is_admin or is_system_admin, ) @@ -57,10 +68,13 @@ async def test_count_scripts_returns_scalar_int() -> None: # JOIN to StorageObjects so orphaned scripts (no joinable row) are # excluded — matches list_scripts INNER JOIN behaviour. assert "inner join storage_objects" in sql - # Scope: workspace_id + active status + user subtree. + # Scope: workspace_id + active status + workspace-wide prefix. assert "scripts.workspace_id" in sql assert "scripts.status" in sql - assert "workspace/u001/%" in sql + assert "like 'workspace/%%'" in sql + # Non-admin (default) narrows by visibility. + assert "scripts.owner_user_id = 'u001'" in sql + assert "scripts.visibility in ('workspace', 'public')" in sql async def test_count_scripts_handles_null_result() -> None: @@ -72,10 +86,10 @@ async def test_count_scripts_handles_null_result() -> None: assert result["data"] == {"total": 0} -async def test_count_scripts_uses_user_specific_subtree() -> None: - """Different users in the same workspace must see different totals — - each user's count is bounded by their own ``workspace/{user_id}/`` - subtree, NOT the whole workspace.""" +async def test_count_scripts_workspace_wide_not_user_scoped() -> None: + """The prefix is workspace-wide (``workspace/%`` — no embedded user_id), + so different users count the same physical tree; the only per-user + difference is the non-admin visibility predicate (owner_user_id = me).""" captured = [] mock_session = MagicMock() @@ -84,14 +98,41 @@ async def test_count_scripts_uses_user_specific_subtree() -> None: ) await count_scripts(context=_ctx(user_id="alice"), session=mock_session) - sql_alice = _compile(captured[-1]) + sql_alice = _compile(captured[-1]).lower() await count_scripts(context=_ctx(user_id="bob"), session=mock_session) - sql_bob = _compile(captured[-1]) + sql_bob = _compile(captured[-1]).lower() - assert "workspace/alice/%" in sql_alice - assert "workspace/alice/%" not in sql_bob - assert "workspace/bob/%" in sql_bob + # Both count the same workspace-wide subtree. + assert "like 'workspace/%%'" in sql_alice + assert "like 'workspace/%%'" in sql_bob + # Neither embeds the user_id in the path prefix. + assert "workspace/alice/%" not in sql_alice + assert "workspace/bob/%" not in sql_bob + # Per-user narrowing happens via the visibility predicate. + assert "scripts.owner_user_id = 'alice'" in sql_alice + assert "scripts.owner_user_id = 'bob'" in sql_bob + + +async def test_count_scripts_admin_skips_visibility_filter() -> None: + """Admin short-circuits the visibility predicate and counts every + active script in the workspace (dashboard '全部脚本' / '工作副本').""" + captured = [] + + mock_session = MagicMock() + mock_session.scalar = AsyncMock( + side_effect=lambda stmt: (captured.append(stmt), 42)[1] + ) + + result = await count_scripts( + context=_ctx(user_id="alice", is_admin=True), session=mock_session + ) + assert result["data"] == {"total": 42} + sql = _compile(captured[0]).lower() + assert "like 'workspace/%%'" in sql + # visibility / owner_user_id still appear in the SELECT projection, but + # the visibility WHERE predicate must be absent for admins. + assert "scripts.visibility in ('workspace', 'public')" not in sql async def test_count_scripts_route_declared_before_script_id_route() -> None: @@ -101,4 +142,4 @@ async def test_count_scripts_route_declared_before_script_id_route() -> None: from backend.scripts import count_scripts, get_script assert callable(count_scripts) - assert callable(get_script) \ No newline at end of file + assert callable(get_script) diff --git a/backend/tests/test_list_scripts_parent_path.py b/backend/tests/test_list_scripts_parent_path.py index 7074b15..5e02c67 100644 --- a/backend/tests/test_list_scripts_parent_path.py +++ b/backend/tests/test_list_scripts_parent_path.py @@ -29,18 +29,24 @@ from sqlalchemy.dialects import mysql as mysql_dialect from backend.scripts import ( _build_list_scripts_descendant_prefix, + _build_list_scripts_workspace_descendant_prefix, _escape_like_pattern, normalize_user_path, ) -def _ctx(user_id: str = "U001") -> SimpleNamespace: +def _ctx( + user_id: str = "U001", *, is_admin: bool = False, is_system_admin: bool = False +) -> SimpleNamespace: return SimpleNamespace( request_id="test", user=SimpleNamespace(user_id=user_id), workspace=SimpleNamespace(workspace_id="W001"), - role=SimpleNamespace(role_code="admin"), - is_system_admin=False, + role=SimpleNamespace(role_code="admin" if is_admin else "developer"), + is_system_admin=is_system_admin, + # ``list_scripts`` now consults ``context.is_admin`` directly (matching + # ``list_resources``); SimpleNamespace needs it as a plain attribute. + is_admin=is_admin or is_system_admin, ) @@ -115,6 +121,51 @@ def test_normalize_user_path_strips() -> None: assert normalize_user_path("a\\b") == "a/b" +# ─── layer 1.6: workspace-wide prefix helper ───────────────────── + + +def test_workspace_descendant_prefix_root() -> None: + """Empty parent_path → workspace-wide root prefix (cross-owner).""" + assert _build_list_scripts_workspace_descendant_prefix("") == "workspace/" + + +def test_workspace_descendant_prefix_subdir() -> None: + """Non-empty parent_path → appended under workspace root, no user_id.""" + assert ( + _build_list_scripts_workspace_descendant_prefix("foo/bar") + == "workspace/foo/bar/" + ) + + +def test_workspace_descendant_prefix_escapes_metachars() -> None: + r"""Folder ``foo_bar`` must produce ``foo\_bar`` so the trailing ``%`` + doesn't become 'match any single char'.""" + assert ( + _build_list_scripts_workspace_descendant_prefix("foo_bar") + == r"workspace/foo\_bar/" + ) + + +def test_workspace_descendant_prefix_escapes_percent() -> None: + assert ( + _build_list_scripts_workspace_descendant_prefix("100%match") + == r"workspace/100\%match/" + ) + + +def test_workspace_descendant_prefix_normalizes_leading_trailing_slashes() -> None: + assert ( + _build_list_scripts_workspace_descendant_prefix("/foo/bar/") + == "workspace/foo/bar/" + ) + + +def test_workspace_descendant_prefix_rejects_traversal() -> None: + with pytest.raises(HTTPException) as exc: + _build_list_scripts_workspace_descendant_prefix("foo/../bar") + assert exc.value.status_code == 422 + + # ─── layer 2: SQL contract ──────────────────────────────────────── @@ -147,8 +198,8 @@ async def test_list_scripts_where_clause_uses_like_prefix_and_excludes_deeper() assert len(captured_sql) == 1 sql = captured_sql[0].lower() - assert "like 'workspace/alice/foo/bar/%%'" in sql - assert "not like 'workspace/alice/foo/bar/%%/%%'" in sql + assert "like 'workspace/foo/bar/%%'" in sql + assert "not like 'workspace/foo/bar/%%/%%'" in sql async def test_list_scripts_where_clause_escapes_pattern_literal() -> None: @@ -177,9 +228,9 @@ async def test_list_scripts_where_clause_escapes_pattern_literal() -> None: # doubles the escape char inside the SQL string literal, so what # the helper emits as `foo\_bar` renders as `foo\\_bar` here # (2 backslash chars in the actual SQL string). - assert r"like 'workspace/alice/foo\\_bar/%%'" in sql_lower + assert r"like 'workspace/foo\\_bar/%%'" in sql_lower # NOT LIKE clause also escaped. - assert r"not like 'workspace/alice/foo\\_bar/%%/%%'" in sql_lower + assert r"not like 'workspace/foo\\_bar/%%/%%'" in sql_lower # And both declare ESCAPE '\\'. assert sql.count("ESCAPE '\\\\'") == 2, sql @@ -206,7 +257,60 @@ async def test_list_scripts_where_clause_escapes_percent_pattern() -> None: sql_lower = sql.lower() # SQLAlchemy doubles the escape char so `%` → `\%` becomes `\\%` # in the SQL string literal. - assert r"workspace/alice/100\\%%match/%%" in sql_lower + assert r"workspace/100\\%%match/%%" in sql_lower + + +async def test_list_scripts_non_admin_adds_visibility_filter() -> None: + """Workspace-wide listing is narrowed by visibility for non-admin: + owner_user_id = me OR visibility IN (workspace, public) — exactly like + list_resources. The workspace prefix contains NO user_id (cross-owner).""" + from backend.scripts import list_scripts + + captured_sql: list[str] = [] + + class _MockResult: + def all(self): + return [] + + mock_session = MagicMock() + mock_session.execute = AsyncMock( + side_effect=lambda stmt: ( + captured_sql.append(_compile_sql(stmt)) or _MockResult() + ) + ) + + await list_scripts(parent_path="", context=_ctx("alice"), session=mock_session) + sql = captured_sql[0].lower() + assert "like 'workspace/%%'" in sql + assert "scripts.owner_user_id = 'alice'" in sql + assert "scripts.visibility in ('workspace', 'public')" in sql + + +async def test_list_scripts_admin_skips_visibility_filter() -> None: + """Admin short-circuits the visibility predicate and sees everything.""" + from backend.scripts import list_scripts + + captured_sql: list[str] = [] + + class _MockResult: + def all(self): + return [] + + mock_session = MagicMock() + mock_session.execute = AsyncMock( + side_effect=lambda stmt: ( + captured_sql.append(_compile_sql(stmt)) or _MockResult() + ) + ) + + await list_scripts( + parent_path="", context=_ctx("alice", is_admin=True), session=mock_session + ) + sql = captured_sql[0].lower() + assert "like 'workspace/%%'" in sql + # owner_user_id / visibility still appear in the SELECT projection; what + # must be absent is the visibility WHERE predicate for non-admins. + assert "scripts.visibility in ('workspace', 'public')" not in sql async def test_list_workspace_directories_where_clause_escapes_pattern() -> None: diff --git a/backend/tests/test_resources.py b/backend/tests/test_resources.py index 22d3d58..ae51739 100644 --- a/backend/tests/test_resources.py +++ b/backend/tests/test_resources.py @@ -322,6 +322,8 @@ def test_resource_payload_legacy_dot_resources(): ) assert payload["jupyter_accessible_path"] == ".resources/data.csv" assert payload["absolute_path"].endswith(f"{ws}/{user}/.resources/data.csv") + # Default None when no Users join row is provided (bind_resource path). + assert payload["owner_display_name"] is None def test_resource_payload_new_flat_path(): @@ -333,6 +335,13 @@ def test_resource_payload_new_flat_path(): ) assert payload["jupyter_accessible_path"] == "data.csv" assert payload["absolute_path"].endswith(f"{ws}/{user}/data.csv") + # Explicit owner_display_name is passed through to the payload. + payload = resource_payload( + _make_resource(ws, user), + _make_storage_object(f"{ws}/{user}/data.csv"), + owner_display_name="张三", + ) + assert payload["owner_display_name"] == "张三" def test_resource_payload_new_nested_path(): @@ -344,6 +353,7 @@ def test_resource_payload_new_nested_path(): ) assert payload["jupyter_accessible_path"] == "train/v1/data.csv" assert payload["absolute_path"].endswith(f"{ws}/{user}/train/v1/data.csv") + assert payload["owner_display_name"] is None def test_compute_jupyter_relative_path_for_legacy_and_new_paths(): @@ -621,6 +631,27 @@ async def test_list_resources_without_parent_path_adds_no_like_clause() -> None: assert " not like " not in sql +async def test_list_resources_joins_users_for_display_name() -> None: + """list_resources must OUTER JOIN users and SELECT users.display_name so + every resource carries owner_display_name (frontend displayName chain).""" + from backend.resources import list_resources + + captured_sql: list[str] = [] + mock_session = _list_resources_capturing_session(captured_sql) + + await list_resources( + parent_path="", + context=_resource_ctx(), + session=mock_session, + visibility=None, + keyword=None, + ) + sql = captured_sql[0] + sql_lower = sql.lower() + assert "outer join users" in sql_lower + assert "users.display_name" in sql_lower + + # ─── layer 3: behavioral test on real LIKE execution (SQLite) ──────────────── diff --git a/frontend/app/components/platform/ScriptExplorer.tsx b/frontend/app/components/platform/ScriptExplorer.tsx index d811d44..ad9e5a4 100644 --- a/frontend/app/components/platform/ScriptExplorer.tsx +++ b/frontend/app/components/platform/ScriptExplorer.tsx @@ -109,8 +109,12 @@ export function ScriptExplorer({ dataResources: ResourceItem[]; }[] = []; for (const [ownerUserId, groupScripts] of byOwner.entries()) { + const groupDataResources = dataByOwner.get(ownerUserId) ?? []; + // data-only owner(没有 scripts 的用户)回退到 data resources 的 + // owner_display_name,否则 data-only owner 前端只能显示 userId 末 6 位。 const displayName = groupScripts[0]?.owner_display_name ?? + groupDataResources[0]?.owner_display_name ?? (ownerUserId === user?.user_id ? user?.display_name : null) ?? `${ownerUserId.slice(-6)}…`; const groupUser = @@ -133,7 +137,7 @@ export function ScriptExplorer({ ownerUserId === user?.user_id ? mergeDirectories(directories, inferred) : inferred, - dataResources: dataByOwner.get(groupUser.user_id) ?? [], + dataResources: groupDataResources, }); } diff --git a/frontend/app/services/api.ts b/frontend/app/services/api.ts index c0ac622..e4063ce 100644 --- a/frontend/app/services/api.ts +++ b/frontend/app/services/api.ts @@ -417,6 +417,7 @@ export type ResourceItem = { workspace_id: string; storage_object_id: string; owner_user_id: string; + owner_display_name?: string | null; resource_name: string; description: string | null; visibility: "private" | "workspace" | "public"; -- 2.54.0 From 5501b2662803405ddf1b215fe7b0bd7c66f00bbf Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:17:47 +0800 Subject: [PATCH 19/93] refactor(schedule): extract scheduling/ layer (scheduler + orchestrator) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 3 of the layered refactor. Relocate the two scheduling components into their own package so that domain / application / scheduling / execution / infrastructure boundaries actually exist on disk. - Add schedule/src/schedule/scheduling/__init__.py - Move scheduler.py (232 lines) -> scheduling/scheduler.py (byte-identical via diff; CronScheduler class name unchanged) - Move orchestrator.py (946 lines) -> scheduling/orchestrator.py (byte-identical via diff; DispatchOrchestrator + event constants unchanged; NOT further split this round, per plan) - service.py lines 39-40: import paths rewritten to the new module - tests/test_janitor.py: rewrite the import + 5 patch() string targets The 5 patch() targets ("schedule.orchestrator.session_scope" x3, "schedule.orchestrator.asyncio.sleep" x2) were NOT caught by the import-line grep — they patch module attributes at runtime and would have become dead no-ops after the move (and would hard-raise once the old module is deleted in stage 6). Rewriting them to "schedule.scheduling.orchestrator.*" keeps the janitor tests meaningfully exercising the new module. - old flat scheduler.py / orchestrator.py left on disk; stage 6 deletes them once all layers are extracted. Validation: - uv run --package schedule pytest schedule/tests -q: 18 passed - uv run python -m compileall schedule/src: zero errors - grep 'from schedule.(scheduler|orchestrator)\\b' (old paths): 0 matches - grep '"schedule.orchestrator.' (old patch targets): 0 matches - main.py / worker.py / domain/ / infrastructure/ / pyproject.toml byte-identical to HEAD Co-Authored-By: Claude --- schedule/src/schedule/scheduling/__init__.py | 0 .../src/schedule/scheduling/orchestrator.py | 946 ++++++++++++++++++ schedule/src/schedule/scheduling/scheduler.py | 232 +++++ schedule/src/schedule/service.py | 4 +- schedule/tests/test_janitor.py | 12 +- 5 files changed, 1186 insertions(+), 8 deletions(-) create mode 100644 schedule/src/schedule/scheduling/__init__.py create mode 100644 schedule/src/schedule/scheduling/orchestrator.py create mode 100644 schedule/src/schedule/scheduling/scheduler.py diff --git a/schedule/src/schedule/scheduling/__init__.py b/schedule/src/schedule/scheduling/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/schedule/src/schedule/scheduling/orchestrator.py b/schedule/src/schedule/scheduling/orchestrator.py new file mode 100644 index 0000000..69e921a --- /dev/null +++ b/schedule/src/schedule/scheduling/orchestrator.py @@ -0,0 +1,946 @@ +"""Outbox-driven DAG orchestrator. + +Polls the MySQL ``OutboxEvents`` table for ``schedule.run.requested`` and +``job.node.finished`` events and advances schedule runs accordingly. New +nodes are dispatched by writing ``job.node.execute`` rows to the Outbox +and letting the worker component consume them. + +This module owns no HTTP / boto3 / notebook execution dependencies — +those belong to the worker (see ``schedule.worker``) and the cron +post-back (see ``schedule.service.trigger_schedule``). +""" + +from __future__ import annotations + +import asyncio +from datetime import timedelta +from typing import Any + +from common.db import session_scope +from common.db.models import ( + ConsumerInbox, + OutboxEvents, + ScheduleNodeRuns, + ScheduleNodes, + ScheduleRuns, +) +from common.eventing import add_outbox_event, event_time, schedule_event_type, utcnow +from common.ids import new_ulid +from loguru import logger +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from schedule.domain.context import ( + FAILED_NODE_STATES, + TERMINAL_NODE_STATES, + TERMINAL_RUN_STATES, +) + +SCHEDULE_RUN_REQUESTED_EVENT = schedule_event_type("schedule.run.requested") +NODE_EXECUTE_EVENT = schedule_event_type("job.node.execute") +NODE_FINISHED_EVENT = schedule_event_type("job.node.finished") + +class DispatchOrchestrator: + """Polls Outbox + advances DAG schedule runs. + + Two independent loops run in the same process: + + - ``_database_event_loop`` drains ``schedule.run.requested`` and + ``job.node.finished`` events under ``dispatch_lock``. These are + short, in-line DB transactions. + - ``_execution_loop`` claims ``job.node.execute`` events whose + ``available_at <= utcnow()`` and dispatches each as + ``asyncio.create_task`` so the polling path is never blocked by + notebook execution. A semaphore caps concurrent notebooks. + + Holds a ``dispatch_lock`` to keep two concurrent drain loops from + fighting over the same batch. + + Lease semantics live on the outbox row itself, not in the claim + step. A new ``job.node.execute`` event is immediately eligible; + the claim step atomically moves ``available_at`` to ``utcnow() + + node_timeout + LEASE_SLACK``, so a process crash mid-execution lets + the row become eligible again once the lease expires. A hard-coded + 30-minute lease was the + original P0-2 bug: a node with ``timeout_seconds = 86_400`` would + be re-claimed at 30 minutes and run twice; a node with + ``timeout_seconds = 60`` would have its lease expire 29 minutes + too early. Tieing the lease to the actual node timeout closes both + cases. + """ + + # Margin added on top of ``timeout_seconds`` when writing the lease + # ``available_at``. Gives the worker time to update the row to a + # terminal state before the poll re-picks it. + LEASE_SLACK = timedelta(seconds=30) + + # Margins used by the node-run janitor (see ``_janitor_loop``). + # + # ``NODE_JANITOR_GRACE_SECONDS`` is added on top of each node's + # ``timeout_seconds + retry_count * retry_interval_sec`` when judging + # whether a started-but-not-finished row is stuck. Absorbs the outbox + # ``min(30, 2**retry_count)`` backoff + the lease slack above + a few + # seconds of scheduling jitter. Tuned for the worst case the + # orchestrator itself produces, so the janitor cannot race a legitimate + # retry path to terminal state. + NODE_JANITOR_GRACE_SECONDS = 120 + # Floor for ``started_at IS NULL`` rows (never dispatched). 1h rides + # out an orchestrator restart that drops the lease mid-flight; past + # that the row is dead and forcing a terminal state lets the DAG + # advance. + NODE_JANITOR_QUEUED_GRACE_SECONDS = 3600 + # Loop period + batch size. Detection lag = interval + the time it + # takes to scan, currently well under a minute. 50 rows per cycle + # keeps worst-case fan-out bounded. + NODE_JANITOR_INTERVAL_SECONDS = 30 + NODE_JANITOR_BATCH = 50 + + def __init__( + self, + *, + session_factory: async_sessionmaker[AsyncSession], + node_execute_handler, + execution_concurrency: int = 4, + ) -> None: + self.session_factory = session_factory + # Injected by the facade; routes ``job.node.execute`` outbox events + # to ``NodeExecutor.handle_node_execute``. Kept as a callable so this + # module can stay independent of worker module imports. + self._node_execute_handler = node_execute_handler + self.dispatch_lock = asyncio.Lock() + self._loop_task: asyncio.Task[None] | None = None + self._exec_loop_task: asyncio.Task[None] | None = None + self._janitor_task: asyncio.Task[None] | None = None + self._exec_tasks: set[asyncio.Task[None]] = set() + self._exec_semaphore = asyncio.Semaphore(execution_concurrency) + + def start(self) -> None: + self._loop_task = asyncio.create_task( + self._database_event_loop(), + name="scheduler-database-events", + ) + self._exec_loop_task = asyncio.create_task( + self._execution_loop(), + name="scheduler-node-execute", + ) + self._janitor_task = asyncio.create_task( + self._janitor_loop(), + name="scheduler-node-janitor", + ) + + async def close(self) -> None: + if self._loop_task is not None: + self._loop_task.cancel() + try: + await self._loop_task + except asyncio.CancelledError: + pass + self._loop_task = None + if self._exec_loop_task is not None: + self._exec_loop_task.cancel() + try: + await self._exec_loop_task + except asyncio.CancelledError: + pass + self._exec_loop_task = None + if self._janitor_task is not None: + self._janitor_task.cancel() + try: + await self._janitor_task + except asyncio.CancelledError: + pass + self._janitor_task = None + if self._exec_tasks: + await asyncio.gather(*self._exec_tasks, return_exceptions=True) + self._exec_tasks.clear() + + async def _database_event_loop(self) -> None: + while True: + try: + processed = await self.process_pending_events(limit=20) + if processed: + logger.debug("database event loop processed {} events", processed) + if not processed: + await asyncio.sleep(0.25) + except asyncio.CancelledError: + raise + except Exception: + logger.exception("database event loop failed") + await asyncio.sleep(1) + + async def _execution_loop(self) -> None: + while True: + try: + claimed = await self._claim_execution_events(limit=10) + if claimed: + logger.debug("execution loop claimed {} events", claimed) + if not claimed: + await asyncio.sleep(0.25) + except asyncio.CancelledError: + raise + except Exception: + logger.exception("node execute loop failed") + await asyncio.sleep(1) + + async def _janitor_loop(self) -> None: + """Periodically force terminal state on stuck ``ScheduleNodeRuns``. + + Original P0-4 S2 bug: when ``job.node.execute`` outbox retries are + exhausted (5 tries, capped 30s backoff) the *event* is marked + ``failed`` but no one reconciles the *node_run* — the row stays + in ``queued`` or ``running`` forever, DAG children are never + dispatched, and the whole run sits at "running" with no further + progress. + + A second, harder failure mode: the worker crashes (OOM / + ``kill -9`` / forgotten upload) mid-execution. The lease-based + re-eligibility keeps re-dispatching the event, but a worker + that's stuck without writing a terminal state is invisible to + the retry counter — it just keeps timing out. + + Both collapse into one wall-clock condition: ``started_at + +`` budget < now()`` (or, more rarely, ``started_at IS NULL`` for + long enough). The janitor enforces that condition so no row + can outlive its deadline regardless of which subsystem let go. + """ + while True: + try: + killed = await self._reap_stuck_node_runs() + if killed: + logger.warning( + "node janitor reaped {} stuck node run(s)", killed, + ) + except asyncio.CancelledError: + raise + except Exception: + logger.exception("node janitor loop failed") + await asyncio.sleep(self.NODE_JANITOR_INTERVAL_SECONDS) + + async def _reap_stuck_node_runs(self) -> int: + """Find stuck ``ScheduleNodeRuns`` rows and force-terminal them. + + SQL does a coarse pre-filter (``created_at`` at least one hour + old so we don't even look at fresh rows). Per-row budget is + computed in Python because it depends on each row's + ``ScheduleNodes.timeout_seconds`` + ``retry_count`` + + ``retry_interval_sec``, which can vary row to row. + + Returns the count of rows actually flipped to ``timed_out``; + rows that were already terminal (worker got there first) are + silently skipped, so two janitors running side by side stay + idempotent. + """ + queued_cutoff = utcnow() - timedelta( + seconds=self.NODE_JANITOR_QUEUED_GRACE_SECONDS, + ) + async with session_scope(self.session_factory) as session: + statement = ( + select(ScheduleNodeRuns, ScheduleNodes, ScheduleRuns) + .join( + ScheduleNodes, + ScheduleNodes.node_id == ScheduleNodeRuns.node_id, + ) + .join( + ScheduleRuns, + ScheduleRuns.run_id == ScheduleNodeRuns.run_id, + ) + .where( + ScheduleNodeRuns.node_status.in_(("queued", "running")), + ScheduleNodeRuns.is_deleted == 0, + ScheduleNodes.is_deleted == 0, + ScheduleRuns.is_deleted == 0, + ScheduleNodeRuns.created_at <= queued_cutoff, + ) + .order_by(ScheduleNodeRuns.created_at) + .limit(self.NODE_JANITOR_BATCH) + ) + candidates = list((await session.execute(statement)).all()) + now = utcnow() + killed = 0 + for node_run, node, run in candidates: + if node_run.started_at is not None: + budget = ( + node.timeout_seconds + + node.retry_count * node.retry_interval_sec + + self.NODE_JANITOR_GRACE_SECONDS + ) + deadline = node_run.started_at + timedelta( + seconds=budget, + ) + if now <= deadline: + # Healthy row that just happened to be in the + # SQL pre-filter window; skip without writing. + continue + # ``started_at IS NULL`` rows fell through the coarse + # ``created_at <= queued_cutoff`` filter, so we know + # they are at least an hour old and never dispatched. + if await self._force_terminal_node_run( + session, + node_run=node_run, + run=run, + reason="NODE_RUN_TIMEOUT", + ): + killed += 1 + return killed + + async def _force_terminal_node_run( + self, + session: AsyncSession, + *, + node_run: ScheduleNodeRuns, + run: ScheduleRuns, + reason: str, + ) -> bool: + """Mark ``node_run`` as ``timed_out`` and enqueue a ``NODE_FINISHED_EVENT``. + + Re-reads the row under ``with_for_update`` so a worker that + reports the result concurrently can't be undone by a second + ``timed_out`` write (and vice versa). Returns ``True`` iff this + call performed the terminal write; ``False`` if the row had + already moved on (worker raced us, another janitor raced us). + + The idempotency key is distinct from the worker-reported + ``:finished`` key so the same ``node_run`` won't produce two + ``NODE_FINISHED_EVENT`` rows if both paths fire. The DAG + consumer is idempotent on its end (``_advance_run`` is a no-op + when ``node_status`` is already terminal), so even if a stray + duplicate slipped through it would self-heal. + """ + locked = await session.scalar( + select(ScheduleNodeRuns) + .where(ScheduleNodeRuns.node_run_id == node_run.node_run_id) + .with_for_update() + ) + if locked is None or locked.node_status in TERMINAL_NODE_STATES: + return False + finished_at = utcnow() + started_or_created = locked.started_at or locked.created_at + locked.node_status = "timed_out" + locked.finished_at = finished_at + locked.duration_ms = int( + (finished_at - started_or_created).total_seconds() * 1000, + ) + locked.error_code = reason + locked.message = ( + "节点执行超时已自动终止" + if locked.started_at is not None + else "节点从未派发,调度超时已自动终止" + )[:2000] + locked.state_version += 1 + await add_outbox_event( + session, + event_type=NODE_FINISHED_EVENT, + producer="schedule-janitor", + trace_id=new_ulid(), + aggregate_type="schedule_node_run", + aggregate_id=locked.node_run_id, + idempotency_key=( + f"{locked.node_run_id}:{locked.attempt_no}:timed_out" + ), + payload={ + "workspace_id": run.workspace_id, + "run_id": locked.run_id, + "node_run_id": locked.node_run_id, + "node_id": locked.node_id, + "versions_id": locked.versions_id, + "attempt_no": locked.attempt_no, + "node_status": locked.node_status, + "exit_code": None, + "started_at": event_time(locked.started_at), + "finished_at": event_time(finished_at), + "duration_ms": locked.duration_ms, + "logs_object_id": None, + "result_object_id": None, + "error_code": reason, + "error_message": locked.message, + }, + ) + return True + + async def _claim_execution_events( + self, + *, + limit: int, + ) -> int: + """Claim ``job.node.execute`` rows and dispatch them as tasks. + + Lease is owned by the row itself (the dispatcher sets + ``available_at = utcnow() + node.timeout_seconds + LEASE_SLACK`` + when the event is enqueued), so this method is a pure + read-and-dispatch — no DB writes in the claim step. If the + process dies before ``_run_node_execute`` finishes, the row + re-eligible once ``available_at`` falls back to now; the worker + handler is idempotent (short-circuits on terminal node state). + """ + async with session_scope(self.session_factory) as session: + statement = ( + select(OutboxEvents) + .where( + OutboxEvents.event_status == "pending", + OutboxEvents.event_type == NODE_EXECUTE_EVENT, + OutboxEvents.available_at <= utcnow(), + ) + .order_by(OutboxEvents.created_at) + .limit(limit) + .with_for_update(skip_locked=True) + ) + events = list((await session.scalars(statement)).all()) + now = utcnow() + for item in events: + timeout_seconds = max( + 1, + int(item.payload_json.get("timeout_seconds", 1)), + ) + item.available_at = ( + now + + timedelta(seconds=timeout_seconds) + + self.LEASE_SLACK + ) + claimed: list[tuple[dict[str, Any], str]] = [ + ( + { + "event_type": item.event_type, + "event_id": item.event_id, + "trace_id": item.trace_id, + "payload": item.payload_json, + }, + f"mysql:{item.event_id}", + ) + for item in events + ] + for envelope, message_id in claimed: + task = asyncio.create_task( + self._run_node_execute(envelope, message_id), + name=f"node-execute:{envelope['event_id']}", + ) + self._exec_tasks.add(task) + task.add_done_callback(self._exec_tasks.discard) + logger.debug("claimed {} node execute events", len(claimed)) + return len(claimed) + + async def _run_node_execute( + self, + envelope: dict[str, Any], + message_id: str, + ) -> None: + logger.debug("node execute start: event_id={}", envelope["event_id"][-12:]) + async with self._exec_semaphore: + exc: Exception | None = None + try: + await self._node_execute_handler(envelope, message_id) + except Exception as run_exc: # noqa: BLE001 + exc = run_exc + await self._update_execution_status(envelope["event_id"], exc) + + async def _update_execution_status( + self, + event_id: str, + exc: Exception | None, + ) -> None: + async with session_scope(self.session_factory) as session: + item = await session.scalar( + select(OutboxEvents) + .where(OutboxEvents.event_id == event_id) + .with_for_update() + ) + if item is None: + logger.warning("execution event {} disappeared", event_id) + return + if exc is None: + item.event_status = "published" + item.published_at = utcnow() + item.last_error = None + logger.info("node execute success: event_id={}", event_id[-12:]) + else: + item.retry_count += 1 + item.last_error = str(exc)[:2000] + if item.retry_count >= 5: + item.event_status = "failed" + logger.warning( + "node execute exhausted retries: event_id={} retry_count={}", + event_id[-12:], + item.retry_count, + ) + else: + item.available_at = utcnow() + timedelta( + seconds=min(30, 2 ** item.retry_count), + ) + logger.warning( + "node execute retry scheduled: event_id={} retry_count={} delay={}s", + event_id[-12:], + item.retry_count, + min(30, 2 ** item.retry_count), + ) + + async def process_pending_events( + self, + *, + limit: int = 20, + aggregate_id: str | None = None, + ) -> int: + async with self.dispatch_lock: + async with session_scope(self.session_factory) as session: + statement = ( + select(OutboxEvents) + .where( + OutboxEvents.event_status == "pending", + OutboxEvents.available_at <= utcnow(), + OutboxEvents.event_type.in_( + ( + SCHEDULE_RUN_REQUESTED_EVENT, + NODE_FINISHED_EVENT, + ), + ), + ) + .order_by(OutboxEvents.created_at) + .limit(limit) + ) + if aggregate_id: + statement = statement.where( + OutboxEvents.aggregate_id == aggregate_id + ) + events = list((await session.scalars(statement)).all()) + logger.debug("processed {} outbox events", len(events)) + for item in events: + try: + await self._process_outbox_event(item) + item.event_status = "published" + item.published_at = utcnow() + item.last_error = None + except Exception as exc: + item.retry_count += 1 + item.last_error = str(exc)[:2000] + if item.retry_count >= 5: + item.event_status = "failed" + else: + item.available_at = utcnow() + timedelta( + seconds=min(30, 2 ** item.retry_count) + ) + return len(events) + + async def _process_outbox_event(self, item: OutboxEvents) -> None: + handlers = { + SCHEDULE_RUN_REQUESTED_EVENT: self._handle_run_requested, + NODE_FINISHED_EVENT: self._handle_node_finished, + } + handler = handlers.get(item.event_type) + if handler is None: + raise ValueError(f"unsupported event type: {item.event_type}") + event = { + "event_type": item.event_type, + "event_id": item.event_id, + "trace_id": item.trace_id, + "payload": item.payload_json, + } + await handler(event, f"mysql:{item.event_id}") + + async def dispatch_run(self, run_id: str) -> int: + return await self.process_pending_events( + limit=50, + aggregate_id=run_id, + ) + + async def _start_inbox( + self, + session: AsyncSession, + *, + consumer_name: str, + event_id: str, + message_id: str, + ) -> tuple[ConsumerInbox, bool]: + item = await session.get( + ConsumerInbox, + (consumer_name, event_id), + with_for_update=True, + ) + if item is not None and item.process_status == "succeeded": + return item, False + if item is None: + item = ConsumerInbox( + consumer_name=consumer_name, + event_id=event_id, + process_status="processing", + message_id=message_id, + ) + session.add(item) + else: + item.process_status = "processing" + item.message_id = message_id + item.error_message = None + return item, True + + @staticmethod + def _finish_inbox(item: ConsumerInbox) -> None: + item.process_status = "succeeded" + item.processed_at = utcnow() + item.error_message = None + + async def _handle_run_requested( + self, + event: dict[str, Any], + message_id: str, + ) -> None: + if event.get("event_type") != SCHEDULE_RUN_REQUESTED_EVENT: + raise ValueError("unexpected event type") + payload = event["payload"] + logger.info( + "run requested: run={} trace={}", + payload["run_id"][-12:], + event["trace_id"][-12:], + ) + async with session_scope(self.session_factory) as session: + inbox, should_process = await self._start_inbox( + session, + consumer_name="schedule-orchestrator", + event_id=event["event_id"], + message_id=message_id, + ) + if not should_process: + return + run = await session.scalar( + select(ScheduleRuns) + .where(ScheduleRuns.run_id == payload["run_id"]) + .with_for_update() + ) + if run is None: + raise ValueError("schedule run does not exist") + if run.run_status not in TERMINAL_RUN_STATES: + if run.run_status == "queued": + run.run_status = "running" + run.started_at = utcnow() + run.state_version += 1 + await self._bootstrap_root_nodes( + session, + run, + trace_id=event["trace_id"], + ) + await self._advance_run( + session, + run, + trace_id=event["trace_id"], + ) + self._finish_inbox(inbox) + + async def _dispatch_node( + self, + session: AsyncSession, + *, + run: ScheduleRuns, + node: dict[str, Any], + attempt_no: int, + trace_id: str, + delay_seconds: int = 0, + ) -> ScheduleNodeRuns: + node_run = ScheduleNodeRuns( + node_run_id=new_ulid(), + run_id=run.run_id, + node_id=node["node_id"], + versions_id=node["versions_id"], + attempt_no=attempt_no, + node_status="queued", + state_version=0, + message=( + f"等待重试({delay_seconds} 秒)" + if delay_seconds + else "等待 Worker 执行" + ), + ) + session.add(node_run) + # ``available_at`` is the first execution time here. The claim + # step moves it forward by timeout + slack to become the retry + # lease while the worker is running. + retry_at = ( + utcnow() + timedelta(seconds=delay_seconds) + if delay_seconds + else utcnow() + ) + logger.info( + "dispatch node: run={} node={} attempt={}", + run.run_id[-12:], + node["node_id"][-12:], + attempt_no, + ) + await add_outbox_event( + session, + event_type=NODE_EXECUTE_EVENT, + producer="schedule-orchestrator", + trace_id=trace_id, + aggregate_type="schedule_node_run", + aggregate_id=node_run.node_run_id, + idempotency_key=f"{node_run.node_run_id}:{attempt_no}", + available_at=retry_at, + payload={ + "workspace_id": run.workspace_id, + "run_id": run.run_id, + "node_run_id": node_run.node_run_id, + "node_id": node["node_id"], + "versions_id": node["versions_id"], + "attempt_no": attempt_no, + "script_type": node["script_type"], + "artifact_object_id": node["artifact_object_id"], + "artifact_path": node["artifact_path"], + "timeout_seconds": node["timeout_seconds"], + "arguments": node.get("arguments", []), + }, + ) + return node_run + + async def _bootstrap_root_nodes( + self, + session: AsyncSession, + run: ScheduleRuns, + *, + trace_id: str, + ) -> None: + """Persist the first runnable nodes before normal DAG advancement.""" + existing_node_id = await session.scalar( + select(ScheduleNodeRuns.node_run_id) + .where(ScheduleNodeRuns.run_id == run.run_id) + .limit(1) + ) + if existing_node_id is not None: + return + + snapshot = run.schedule_snapshot + nodes = snapshot.get("nodes", []) + target_node_ids = { + edge["target_node_id"] for edge in snapshot.get("edges", []) + } + roots = [ + node for node in nodes + if node["node_id"] not in target_node_ids + ] + max_concurrency = max(1, int(snapshot.get("max_concurrency", 1))) + selected_roots = roots[:max_concurrency] + if selected_roots: + logger.info( + "bootstrap roots: run={} roots_count={} max_concurrency={}", + run.run_id[-12:], + len(selected_roots), + max_concurrency, + ) + for node in selected_roots: + await self._dispatch_node( + session, + run=run, + node=node, + attempt_no=1, + trace_id=trace_id, + ) + # The shared session factory disables autoflush. Flush here so + # _advance_run observes the roots and cannot dispatch duplicates. + await session.flush() + + async def _advance_run( + self, + session: AsyncSession, + run: ScheduleRuns, + *, + trace_id: str, + ) -> None: + snapshot = run.schedule_snapshot + nodes = snapshot.get("nodes", []) + node_by_id = {node["node_id"]: node for node in nodes} + parents: dict[str, set[str]] = {node_id: set() for node_id in node_by_id} + for edge in snapshot.get("edges", []): + parents.setdefault(edge["target_node_id"], set()).add( + edge["source_node_id"] + ) + rows = list( + ( + await session.scalars( + select(ScheduleNodeRuns) + .where(ScheduleNodeRuns.run_id == run.run_id) + .order_by(ScheduleNodeRuns.attempt_no) + ) + ).all() + ) + latest: dict[str, ScheduleNodeRuns] = {} + for row in rows: + current = latest.get(row.node_id) + if current is None or row.attempt_no >= current.attempt_no: + latest[row.node_id] = row + + max_concurrency = max(1, int(snapshot.get("max_concurrency", 1))) + failure_policy = snapshot.get("failure_policy", "stop") + while True: + changed = False + active_count = sum( + item.node_status in {"queued", "running"} + for item in latest.values() + ) + for node in nodes: + current = latest.get(node["node_id"]) + if ( + current is not None + and current.node_status in FAILED_NODE_STATES + and current.attempt_no <= int(node.get("retry_count", 0)) + and active_count < max_concurrency + ): + retried = await self._dispatch_node( + session, + run=run, + node=node, + attempt_no=current.attempt_no + 1, + trace_id=trace_id, + delay_seconds=int(node.get("retry_interval_sec", 0)), + ) + latest[node["node_id"]] = retried + active_count += 1 + changed = True + + exhausted_failure = any( + item.node_status in FAILED_NODE_STATES + and item.attempt_no + > int(node_by_id[item.node_id].get("retry_count", 0)) + for item in latest.values() + ) + stop_all = ( + failure_policy == "stop" + and exhausted_failure + ) + for node in nodes: + node_id = node["node_id"] + if node_id in latest: + continue + parent_runs = [latest.get(parent) for parent in parents[node_id]] + # 只有 ``stop`` 策略才会在失败后跳过尚未启动的节点。 + # ``continue`` 表示“前一个节点失败也继续往后执行”,因此 + # 下游只需等待所有上游结束,不要求它们全部成功。 + parents_terminal = all( + item is not None + and item.node_status in TERMINAL_NODE_STATES + for item in parent_runs + ) + if stop_all: + skipped = ScheduleNodeRuns( + node_run_id=new_ulid(), + run_id=run.run_id, + node_id=node_id, + versions_id=node["versions_id"], + attempt_no=1, + node_status="skipped", + state_version=1, + finished_at=utcnow(), + duration_ms=0, + message="调度失败策略为 stop,未再启动", + ) + session.add(skipped) + latest[node_id] = skipped + changed = True + logger.debug( + "skipped node: run={} node={} reason={}", + run.run_id[-12:], + node_id[-12:], + "stop_policy", + ) + elif parents_terminal and active_count < max_concurrency: + dispatched = await self._dispatch_node( + session, + run=run, + node=node, + attempt_no=1, + trace_id=trace_id, + ) + latest[node_id] = dispatched + active_count += 1 + changed = True + if not changed: + break + + if nodes and len(latest) == len(nodes) and all( + item.node_status in TERMINAL_NODE_STATES + for item in latest.values() + ): + now = utcnow() + # Final run status depends on the schedule's + # ``failure_policy``. ``stop`` keeps the legacy rule — any + # non-success node fails the whole run. ``continue`` is + # more lenient: the run is a success when at least one + # root-level node succeeded and there is no remaining + # ``failed`` / ``cancelled`` / ``timed_out`` node that + # would have produced real artifacts had it run. Nodes + # marked ``skipped`` count as "decided to not run" and do + # not by themselves fail the run. + failure_policy = snapshot.get("failure_policy", "stop") + statuses = [item.node_status for item in latest.values()] + any_real_failure = any( + status in FAILED_NODE_STATES for status in statuses + ) + any_success = any( + status == "succeeded" for status in statuses + ) + if failure_policy == "continue": + # A run with mixed success/failure/skip outcomes is + # only "succeeded" when at least one node actually ran + # to completion and nothing hit a hard failure. A + # run where every node was skipped or failed is + # itself a failure. + succeeded = any_success and not any_real_failure + else: + succeeded = all( + status == "succeeded" for status in statuses + ) + logger.info( + "run finalized: run={} status={} failure_policy={} any_failure={} any_success={}", + run.run_id[-12:], + "succeeded" if succeeded else "failed", + failure_policy, + any_real_failure, + any_success, + ) + run.run_status = "succeeded" if succeeded else "failed" + run.error_code = None if succeeded else "SCHEDULE_NODE_FAILED" + run.error_message = ( + None + if succeeded + else "one or more schedule nodes did not succeed" + ) + run.finished_at = now + if run.started_at: + run.duration_ms = max( + 0, + int((now - run.started_at).total_seconds() * 1000), + ) + run.state_version += 1 + + async def _handle_node_finished( + self, + event: dict[str, Any], + message_id: str, + ) -> None: + if event.get("event_type") != NODE_FINISHED_EVENT: + raise ValueError("unexpected event type") + payload = event["payload"] + logger.info( + "node finished event: run={} node={} status={}", + payload["run_id"][-12:], + payload["node_id"][-12:], + payload.get("node_status"), + ) + async with session_scope(self.session_factory) as session: + inbox, should_process = await self._start_inbox( + session, + consumer_name="schedule-results", + event_id=event["event_id"], + message_id=message_id, + ) + if not should_process: + return + run = await session.scalar( + select(ScheduleRuns) + .where(ScheduleRuns.run_id == payload["run_id"]) + .with_for_update() + ) + if run is None: + raise ValueError("schedule run does not exist") + if run.run_status not in TERMINAL_RUN_STATES: + await self._advance_run( + session, + run, + trace_id=event["trace_id"], + ) + self._finish_inbox(inbox) + + +__all__ = ["DispatchOrchestrator"] diff --git a/schedule/src/schedule/scheduling/scheduler.py b/schedule/src/schedule/scheduling/scheduler.py new file mode 100644 index 0000000..bf57dc2 --- /dev/null +++ b/schedule/src/schedule/scheduling/scheduler.py @@ -0,0 +1,232 @@ +"""APScheduler-backed cron trigger layer. + +Owns the ``AsyncIOScheduler`` instance plus the periodic sync loop that +reconciles in-memory APScheduler jobs against the ``Schedules`` table in +MySQL. The cron tick callback delegates to ``SchedulerService.trigger_schedule`` +(via the ``on_trigger`` callable injected at construction), which posts +back to Backend; Backend then writes a ``schedule.run.requested`` Outbox +row that the orchestrator consumes. + +The two layers (this cron scheduler, the orchestrator) are decoupled +through MySQL — they share no in-memory state and survive independent +restarts. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable +from datetime import UTC, datetime, timedelta +from zoneinfo import ZoneInfo + +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from apscheduler.triggers.cron import CronTrigger +from common.db import session_scope +from common.db.models import Schedules +from common.scheduler import build_sqlalchemy_jobstore +from loguru import logger +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from schedule.domain.context import naive_utc + +# APScheduler's persistent SQLAlchemy job store pickles each job. A bound +# ``CronScheduler`` method captures this instance (including SQLAlchemy engine +# state) and therefore cannot be pickled. Keep the persisted callable at +# module scope and resolve the process-local callback when the job fires. +_ACTIVE_TRIGGER: Callable[[str], Awaitable[None]] | None = None + + +async def dispatch_persisted_cron(schedule_id: str) -> None: + """Dispatch one persisted cron tick through the active service.""" + logger.debug("dispatch persisted cron: schedule={}", schedule_id[-12:]) + callback = _ACTIVE_TRIGGER + if callback is None: + raise RuntimeError("cron trigger callback is not initialized") + await callback(schedule_id) + + +class CronScheduler: + """Manages cron triggers in APScheduler, backed by a MySQL jobstore.""" + + def __init__( + self, + *, + session_factory: async_sessionmaker[AsyncSession], + database_url: str, + on_trigger: Callable[[str], Awaitable[None]], + ) -> None: + global _ACTIVE_TRIGGER + + self.session_factory = session_factory + self.scheduler = AsyncIOScheduler( + jobstores={"default": build_sqlalchemy_jobstore(database_url)}, + timezone=UTC, + ) + self._on_trigger = on_trigger + _ACTIVE_TRIGGER = on_trigger + self._sync_task: asyncio.Task[None] | None = None + # 仅在调度配置实际变化时才重置 APScheduler job。若每 5 秒都 + # reschedule,一旦恰好落在整分钟之后,就可能把本分钟的触发跳过。 + self._job_signatures: dict[str, tuple[str, str, int]] = {} + # APScheduler 的定时唤醒异常时,由 5 秒同步循环兜底。键保存的是 + # 已由兜底路径处理的“本地整分钟”,避免同一分钟重复提交。 + self._fallback_dispatched_minutes: dict[str, datetime] = {} + + def start(self) -> None: + """Start APScheduler and spawn the periodic sync loop.""" + logger.info("cron scheduler starting") + self.scheduler.start() + self._sync_task = asyncio.create_task( + self._sync_loop(), + name="scheduler-cron-sync", + ) + + async def close(self) -> None: + """Cancel the sync loop and shut APScheduler down.""" + logger.info("cron scheduler closing") + global _ACTIVE_TRIGGER + + if self._sync_task is not None: + self._sync_task.cancel() + try: + await self._sync_task + except asyncio.CancelledError: + pass + self._sync_task = None + if self.scheduler.running: + self.scheduler.shutdown(wait=False) + if _ACTIVE_TRIGGER is self._on_trigger: + _ACTIVE_TRIGGER = None + + async def trigger(self, schedule_id: str) -> None: + """APScheduler cron tick callback. + + The persisted job calls :func:`dispatch_persisted_cron`, which then + resolves this process-local callback. The + standard on_trigger is ``SchedulerService.trigger_schedule`` which + posts back to Backend; Backend then writes the Outbox row that the + orchestrator picks up. + """ + await self._on_trigger(schedule_id) + + async def _sync_loop(self) -> None: + while True: + try: + await self._sync_once() + logger.debug("cron sync tick ok") + except asyncio.CancelledError: + raise + except Exception: + logger.exception("cron job synchronization failed") + await asyncio.sleep(5) + + async def _sync_once(self) -> None: + """Reconcile APScheduler jobs against ``Schedules.cron_expression``. + + - adds jobs for enabled cron schedules present in MySQL + - removes jobs whose schedule has been disabled / soft-deleted + - updates ``Schedules.next_run_at`` from the Cron expression itself + """ + due_schedule_ids: list[str] = [] + async with session_scope(self.session_factory) as session: + schedules = list( + ( + await session.scalars( + select(Schedules).where( + Schedules.deleted_at.is_(None), + Schedules.enabled == 1, + Schedules.trigger_type == "cron", + Schedules.cron_expression.is_not(None), + ) + ) + ).all() + ) + active_job_ids: set[str] = set() + added_count = 0 + updated_count = 0 + for item in schedules: + job_id = f"schedule:{item.schedule_id}" + active_job_ids.add(job_id) + expression = (item.cron_expression or "").strip() + max_instances = max(1, item.max_concurrency) + signature = (expression, item.timezone, max_instances) + trigger = CronTrigger.from_crontab( + expression, + timezone=ZoneInfo(item.timezone), + ) + now = datetime.now(ZoneInfo(item.timezone)) + minute = now.replace(second=0, microsecond=0) + job_changed = False + if self.scheduler.get_job(job_id) is None: + self.scheduler.add_job( + dispatch_persisted_cron, + trigger=trigger, + args=[item.schedule_id], + id=job_id, + replace_existing=True, + coalesce=True, + max_instances=max_instances, + misfire_grace_time=60, + ) + added_count += 1 + job_changed = True + elif self._job_signatures.get(job_id) != signature: + # 服务重启后的首次同步也会走这里,确保持久化 job 与 + # 数据库当前配置一致;之后配置不变时保留原定时点。 + self.scheduler.reschedule_job(job_id, trigger=trigger) + self.scheduler.modify_job( + job_id, + max_instances=max_instances, + ) + updated_count += 1 + job_changed = True + self._job_signatures[job_id] = signature + # 以 CronTrigger 本身计算下次执行时间,不依赖 APScheduler 的 + # 内部唤醒状态;页面展示的「下次执行」也因此保持准确。 + item.next_run_at = naive_utc( + trigger.get_next_fire_time(None, now) + ) + + # 首次观察或刚修改表达式时,从下一个整分钟才开始兜底,符合 + # Cron 的常规语义,避免用户在本分钟中途保存后立刻多跑一次。 + if job_changed or job_id not in self._fallback_dispatched_minutes: + self._fallback_dispatched_minutes[job_id] = minute + + # 正常情况下 APScheduler 会在整分钟回调。实测其偶发漏唤醒时, + # 这里每 5 秒检查一次当前分钟是否命中表达式,并补发一次。 + due_at = trigger.get_next_fire_time( + minute - timedelta(minutes=1), + minute, + ) + if ( + due_at == minute + and self._fallback_dispatched_minutes.get(job_id) != minute + ): + self._fallback_dispatched_minutes[job_id] = minute + due_schedule_ids.append(item.schedule_id) + removed_count = 0 + for job in self.scheduler.get_jobs(): + if ( + job.id.startswith("schedule:") + and job.id not in active_job_ids + ): + self.scheduler.remove_job(job.id) + self._job_signatures.pop(job.id, None) + self._fallback_dispatched_minutes.pop(job.id, None) + removed_count += 1 + if added_count or updated_count or removed_count: + logger.info( + "cron sync reconciled: added={} updated={} removed={}", + added_count, + updated_count, + removed_count, + ) + # 在数据库同步事务提交后再创建运行记录,避免两个会话同时读取调度方案 + # 时发生不必要的锁等待。重复回调由运行记录的幂等键自动去重。 + for schedule_id in due_schedule_ids: + logger.debug("cron fallback dispatch: schedule={}", schedule_id[-12:]) + await self._on_trigger(schedule_id) + + +__all__ = ["CronScheduler"] diff --git a/schedule/src/schedule/service.py b/schedule/src/schedule/service.py index 86c2abb..7044ade 100644 --- a/schedule/src/schedule/service.py +++ b/schedule/src/schedule/service.py @@ -36,8 +36,8 @@ from common.storage import create_storage from loguru import logger from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker -from schedule.orchestrator import DispatchOrchestrator -from schedule.scheduler import CronScheduler +from schedule.scheduling.orchestrator import DispatchOrchestrator +from schedule.scheduling.scheduler import CronScheduler from schedule.worker import NodeExecutor diff --git a/schedule/tests/test_janitor.py b/schedule/tests/test_janitor.py index fcb632d..386c9c4 100644 --- a/schedule/tests/test_janitor.py +++ b/schedule/tests/test_janitor.py @@ -23,7 +23,7 @@ import pytest from common.db.models import ScheduleNodeRuns from common.eventing import utcnow -from schedule.orchestrator import NODE_FINISHED_EVENT, DispatchOrchestrator +from schedule.scheduling.orchestrator import NODE_FINISHED_EVENT, DispatchOrchestrator def _make_orchestrator() -> DispatchOrchestrator: @@ -195,7 +195,7 @@ async def test_reap_kills_running_row_past_deadline() -> None: fake_session.add = MagicMock() with patch( - "schedule.orchestrator.session_scope", + "schedule.scheduling.orchestrator.session_scope", return_value=_open_session_scope(fake_session), ): killed = await orch._reap_stuck_node_runs() @@ -223,7 +223,7 @@ async def test_reap_skips_healthy_row() -> None: fake_session.add = MagicMock() with patch( - "schedule.orchestrator.session_scope", + "schedule.scheduling.orchestrator.session_scope", return_value=_open_session_scope(fake_session), ): killed = await orch._reap_stuck_node_runs() @@ -266,7 +266,7 @@ async def test_reap_processes_multiple_rows_in_one_pass() -> None: fake_session.add = MagicMock() with patch( - "schedule.orchestrator.session_scope", + "schedule.scheduling.orchestrator.session_scope", return_value=_open_session_scope(fake_session), ): killed = await orch._reap_stuck_node_runs() @@ -289,7 +289,7 @@ async def test_janitor_loop_propagates_cancellation() -> None: raise asyncio.CancelledError with patch( - "schedule.orchestrator.asyncio.sleep", + "schedule.scheduling.orchestrator.asyncio.sleep", side_effect=cancel_on_sleep, ): with pytest.raises(asyncio.CancelledError): @@ -321,7 +321,7 @@ async def test_janitor_loop_continues_after_reap_exception() -> None: raise asyncio.CancelledError with patch( - "schedule.orchestrator.asyncio.sleep", + "schedule.scheduling.orchestrator.asyncio.sleep", side_effect=_count_sleeps, ): with pytest.raises(asyncio.CancelledError): -- 2.54.0 From c45a7a50a1a83231180c8522c093c64a066e5cc6 Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:33:43 +0800 Subject: [PATCH 20/93] refactor(schedule): extract execution/ + runners in layered refactor (stage 4) - Move worker.py -> execution/worker.py, executor.py -> execution/executor.py (byte-identical copies; import sites updated) - Merge old execution.py + notebook_runner.py into execution/runners/notebook.py: subprocess CLI (main/emit_outputs) plus the in-process helpers (_execute_notebook/_execute_python/execute_artifact) - schedule/notebook_runner.py becomes a compatibility shim so `python -m schedule.notebook_runner` (the worker's stable -m string) still works - Delete flat execution.py (shadowed by the new execution/ package) - Zero behavior change; schedule/pyproject.toml untouched Co-Authored-By: Claude --- schedule/src/schedule/execution/__init__.py | 0 schedule/src/schedule/execution/executor.py | 4 + .../schedule/execution/runners/__init__.py | 0 .../runners/notebook.py} | 107 ++++ schedule/src/schedule/execution/worker.py | 556 ++++++++++++++++++ schedule/src/schedule/notebook_runner.py | 107 +--- schedule/src/schedule/service.py | 2 +- schedule/src/schedule/worker.py | 2 +- schedule/tests/test_worker.py | 2 +- 9 files changed, 679 insertions(+), 101 deletions(-) create mode 100644 schedule/src/schedule/execution/__init__.py create mode 100644 schedule/src/schedule/execution/executor.py create mode 100644 schedule/src/schedule/execution/runners/__init__.py rename schedule/src/schedule/{execution.py => execution/runners/notebook.py} (68%) create mode 100644 schedule/src/schedule/execution/worker.py diff --git a/schedule/src/schedule/execution/__init__.py b/schedule/src/schedule/execution/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/schedule/src/schedule/execution/executor.py b/schedule/src/schedule/execution/executor.py new file mode 100644 index 0000000..3811ca1 --- /dev/null +++ b/schedule/src/schedule/execution/executor.py @@ -0,0 +1,4 @@ +""" +@Time :2026/7/29 +@Author :tao.chen +""" diff --git a/schedule/src/schedule/execution/runners/__init__.py b/schedule/src/schedule/execution/runners/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/schedule/src/schedule/execution.py b/schedule/src/schedule/execution/runners/notebook.py similarity index 68% rename from schedule/src/schedule/execution.py rename to schedule/src/schedule/execution/runners/notebook.py index 09ce315..ae8c6b4 100644 --- a/schedule/src/schedule/execution.py +++ b/schedule/src/schedule/execution/runners/notebook.py @@ -1,12 +1,29 @@ +"""Notebook / python-script execution backend for the schedule worker. + +Two responsibilities in one module: + +- Subprocess CLI entry, invoked as ``python -m schedule.notebook_runner`` + through the shim at ``schedule/notebook_runner.py``: executes a notebook + out-of-process with nbclient and writes the executed artifact. +- In-process runner helpers used by :func:`execute_artifact`: resolve the + target Python binary, bound the console log to ``MAX_LOG_BYTES``, and run + a notebook or a plain python script with a wall-clock timeout. +""" + from __future__ import annotations +import argparse import asyncio import json import sys import tempfile +import traceback from pathlib import Path, PurePosixPath +import nbformat from loguru import logger +from nbclient import NotebookClient + from schedule.domain.execution import ExecutionResult MAX_LOG_BYTES = 4 * 1024 * 1024 @@ -261,3 +278,93 @@ async def execute_artifact( ) logger.error("unsupported script_type: {}", script_type) raise ValueError(f"unsupported script_type: {script_type}") + + +def emit_outputs(notebook: object) -> None: + for cell in notebook.cells: # type: ignore[attr-defined] + if cell.get("cell_type") != "code": + continue + for output in cell.get("outputs", []): + output_type = output.get("output_type") + if output_type == "stream": + text = output.get("text", "") + print( + "".join(text) if isinstance(text, list) else str(text), + end="", + flush=True, + ) + elif output_type == "error": + print( + f"{output.get('ename', 'Error')}: {output.get('evalue', '')}", + file=sys.stderr, + flush=True, + ) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--input", required=True) + parser.add_argument("--output", required=True) + parser.add_argument("--timeout", required=True, type=int) + parser.add_argument( + "--python-version", + choices=("3.8", "3.10", "3.12"), + default="3.12", + ) + parser.add_argument("--arguments-json", default="[]") + args = parser.parse_args() + + source = Path(args.input) + output = Path(args.output) + arguments = json.loads(args.arguments_json) + if not isinstance(arguments, list) or not all( + isinstance(item, str) for item in arguments + ): + raise ValueError("arguments-json must contain an array of strings") + + logger.info( + "notebook runner start: input={} timeout={}s python={} args={}", + source.name, + args.timeout, + args.python_version, + len(arguments), + ) + notebook = nbformat.read(source, as_version=4) + if arguments: + notebook.cells.insert( + 0, + nbformat.v4.new_code_cell( + "import sys\n" + f"sys.argv = {json.dumps([source.name, *arguments], ensure_ascii=False)}", + metadata={"tags": ["injected-parameters"]}, + ), + ) + exit_code = 0 + try: + kernel_name = f"python{args.python_version.replace('.', '')}" + client = NotebookClient( + notebook, + timeout=max(1, args.timeout), + kernel_name=kernel_name, + allow_errors=False, + ) + logger.debug( + "notebook client created: kernel={} timeout={}s", + kernel_name, + max(1, args.timeout), + ) + # No explicit cwd — the kernel inherits the parent's cwd, which the + # scheduler sets to the staged artifact directory. Keeping it here + # avoids any "cwd must exist" requirement on the host. + client.execute() + logger.info("notebook client execute done: input={}", source.name) + except Exception as exc: + logger.exception("notebook execute failed: input={}", source.name) + traceback.print_exc() + exit_code = 124 if "timeout" in type(exc).__name__.lower() else 1 + finally: + output.parent.mkdir(parents=True, exist_ok=True) + nbformat.write(notebook, output) + emit_outputs(notebook) + logger.debug("notebook output written: {}", output) + raise SystemExit(exit_code) \ No newline at end of file diff --git a/schedule/src/schedule/execution/worker.py b/schedule/src/schedule/execution/worker.py new file mode 100644 index 0000000..e163758 --- /dev/null +++ b/schedule/src/schedule/execution/worker.py @@ -0,0 +1,556 @@ +"""Node-level worker: executes one ``job.node.execute`` event. + +The orchestrator (see ``schedule.orchestrator``) writes a ``job.node.execute`` +Outbox row with all the metadata needed to run the node (script type, +artifact location, timeout, arguments ...). The polling loop picks those up +and calls :meth:`NodeExecutor.handle_node_execute`. This module owns the +artifact download + subprocess invocation + result-upload side of things. + +`DispatchOrchestrator` writes the node's lifecycle row + outbox event; the +worker only mutates ``ScheduleNodeRuns`` columns related to execution +(started_at / finished_at / exit_code / result_object_id ...). +""" + +from __future__ import annotations + +import hashlib +import json +import traceback +from typing import Any + +from common.config import settings +from common.db import session_scope +from common.db.models import ( + ConsumerInbox, + ScheduleNodeRuns, + ScheduleNodes, + ScheduleRuns, + Schedules, + StorageObjects, + Users, + Versions, + Workspaces, +) +from common.eventing import ( + add_outbox_event, + event_time, + schedule_event_type, + utcnow, +) +from common.scheduler.trigger import SYSTEM_CRON_USER_ID +from loguru import logger +from sqlalchemy import select + +from schedule.domain.context import TERMINAL_NODE_STATES +from schedule.domain.execution import ExecutionResult +from schedule.execution.runners.notebook import execute_artifact + +NODE_EXECUTE_EVENT = schedule_event_type("job.node.execute") +NODE_FINISHED_EVENT = schedule_event_type("job.node.finished") + +class NodeExecutor: + """Owns the actual execution of one schedule node (notebook / python).""" + + # P0-5 / C1: distinct error_code for runs blocked because the originating + # user was disabled or soft-deleted between queue time and worker pickup. + # The value lands in the NODE_FINISHED_EVENT outbox payload (the + # ``error_code`` field) — schedule_node_runs has no such column; the row + # only carries the message text. Operators grep the outbox stream. + USER_DISABLED_ERROR_CODE = "USER_DISABLED" + + def __init__( + self, + *, + session_factory, + object_store: Any, + storage_client: Any, + ) -> None: + self.session_factory = session_factory + self.object_store = object_store + self.storage_client = storage_client + # Per-bucket object store cache. The injected ``object_store`` is + # the default version-bucket store; workspaces that override + # ``Workspaces.artifact_bucket`` need a store bound to that custom + # bucket (P0-3 fix). Build lazily so the common (no-override) path + # incurs no extra cost. + self._bucket_stores: dict[str, Any] = { + settings.s3_version_bucket: object_store, + } + + def _store_for(self, bucket_name: str) -> Any: + """Return the AsyncStorageBackend bound to ``bucket_name``. + + Caches per-bucket stores on first use; the default version bucket + always reuses the injected ``object_store`` so the common path + stays zero-allocation. + """ + store = self._bucket_stores.get(bucket_name) + if store is not None: + return store + from schedule.service import build_object_store + + store = build_object_store(bucket_name=bucket_name) + self._bucket_stores[bucket_name] = store + return store + + async def handle_node_execute( + self, + event: dict[str, Any], + message_id: str, + ) -> None: + if event.get("event_type") != NODE_EXECUTE_EVENT: + raise ValueError("unexpected event type") + payload = event["payload"] + if not await self._set_node_running(event, message_id): + return + logger.info( + "node execute start: node_run={} script_type={} timeout={}s", + payload["node_run_id"][-12:], + payload["script_type"], + payload["timeout_seconds"], + ) + started_at = utcnow() + context: dict[str, Any] | None = None + try: + context = await self._execution_context(payload) + content = await self._download_artifact( + bucket_name=context["bucket_name"], + object_key=context["object_key"], + content_hash=context["content_hash"], + ) + python_version = await self._node_python_version( + payload["node_run_id"] + ) + result = await execute_artifact( + content, + run_id=payload["run_id"], + node_run_id=payload["node_run_id"], + script_type=payload["script_type"], + artifact_path=payload["artifact_path"], + arguments=[str(item) for item in payload.get("arguments", [])], + timeout_seconds=int(payload["timeout_seconds"]), + python_version=python_version, + ) + except Exception as exc: + trace = traceback.format_exc() + # P0-5 / C1: 让 _assert_user_active 抛的 ValueError 透传成单独的 + # error_code,便于运维 grep 区分"用户被禁用"和"代码崩溃"。 + exc_message = str(exc) + error_code = ( + self.USER_DISABLED_ERROR_CODE + if exc_message.startswith("USER_DISABLED:") + else "WORKER_EXECUTION_FAILED" + ) + result = ExecutionResult( + status="failed", + exit_code=1, + logs=trace.encode("utf-8", errors="replace"), + result=json.dumps( + {"status": "failed", "error": exc_message}, + ensure_ascii=False, + ).encode("utf-8"), + result_file_name=f"{payload['node_run_id']}-result.json", + result_content_type="application/json", + error_code=error_code, + error_message=exc_message[:2000], + ) + if context is None: + context = await self._fallback_execution_context(payload) + + log_id, result_id, upload_error = await self._upload_execution_artifacts( + payload=payload, + context=context, + result=result, + ) + finished_at = utcnow() + duration_ms = max( + 0, + int((finished_at - started_at).total_seconds() * 1000), + ) + error_message = result.error_message + if upload_error: + error_message = ( + f"{error_message}; {upload_error}" + if error_message + else upload_error + )[:2000] + final_status = "failed" if upload_error else result.status + final_error_code = ( + "ARTIFACT_UPLOAD_FAILED" if upload_error else result.error_code + ) + + async with session_scope(self.session_factory) as session: + node_run = await session.scalar( + select(ScheduleNodeRuns) + .where( + ScheduleNodeRuns.node_run_id == payload["node_run_id"], + ) + .with_for_update() + ) + if node_run is None: + raise ValueError("schedule node run disappeared") + inbox = await session.get( + ConsumerInbox, + ("job-workers", event["event_id"]), + with_for_update=True, + ) + if inbox is None: + raise ValueError("job worker inbox record disappeared") + if node_run.node_status not in TERMINAL_NODE_STATES: + node_run.node_status = final_status + node_run.finished_at = finished_at + node_run.duration_ms = duration_ms + node_run.exit_code = result.exit_code + node_run.message = ( + "节点执行成功" + if final_status == "succeeded" + else (error_message or "节点执行失败") + )[:2000] + node_run.metrics_json = { + "log_size_bytes": len(result.logs), + "result_size_bytes": len(result.result), + } + node_run.logs_object_id = log_id + node_run.result_object_id = result_id + node_run.state_version += 1 + await add_outbox_event( + session, + event_type=NODE_FINISHED_EVENT, + producer="job-worker", + trace_id=event["trace_id"], + aggregate_type="schedule_node_run", + aggregate_id=node_run.node_run_id, + idempotency_key=( + f"{node_run.node_run_id}:{node_run.attempt_no}:finished" + ), + payload={ + "workspace_id": context["workspace_id"], + "run_id": node_run.run_id, + "node_run_id": node_run.node_run_id, + "node_id": node_run.node_id, + "versions_id": node_run.versions_id, + "attempt_no": node_run.attempt_no, + "node_status": node_run.node_status, + "exit_code": node_run.exit_code, + "started_at": event_time( + node_run.started_at or started_at + ), + "finished_at": event_time(finished_at), + "duration_ms": duration_ms, + "logs_object_id": log_id, + "result_object_id": result_id, + "error_code": final_error_code, + "error_message": error_message, + }, + ) + self._finish_inbox(inbox) + logger.info( + "node execute done: node_run={} status={} error_code={}", + payload["node_run_id"][-12:], + final_status, + final_error_code, + ) + + async def _set_node_running( + self, + event: dict[str, Any], + message_id: str, + ) -> bool: + payload = event["payload"] + async with session_scope(self.session_factory) as session: + inbox, should_process = await self._start_inbox( + session, + consumer_name="job-workers", + event_id=event["event_id"], + message_id=message_id, + ) + if not should_process: + return False + node_run = await session.scalar( + select(ScheduleNodeRuns) + .where( + ScheduleNodeRuns.node_run_id == payload["node_run_id"], + ) + .with_for_update() + ) + if node_run is None: + raise ValueError("schedule node run does not exist") + if node_run.node_status in TERMINAL_NODE_STATES: + logger.debug( + "node already terminal: node_run={} status={}", + payload["node_run_id"][-12:], + node_run.node_status, + ) + self._finish_inbox(inbox) + return False + if node_run.node_status == "queued": + node_run.node_status = "running" + node_run.started_at = utcnow() + node_run.message = "Worker 正在执行稳定版本" + node_run.state_version += 1 + logger.debug( + "node running: node_run={}", + payload["node_run_id"][-12:], + ) + return True + + async def _node_python_version( + self, + node_run_id: str, + ) -> str: + async with self.session_factory() as session: + row = ( + await session.execute( + select(ScheduleNodes.python_version) + .join( + ScheduleNodeRuns, + ScheduleNodeRuns.node_id == ScheduleNodes.node_id, + ) + .where(ScheduleNodeRuns.node_run_id == node_run_id) + ) + ).one_or_none() + if row is None: + logger.warning( + "node python_version not found, defaulting to 3.12: node_run={}", + node_run_id[-12:], + ) + return "3.12" + return row[0] + + async def _assert_user_active( + self, + session: AsyncSession, + user_id: str, + ) -> None: + """P0-5 / C1: re-verify the user is still ``status='active'`` and + ``is_deleted=0`` before executing a run they originated. + + Raises :class:`ValueError` whose message starts with + ``USER_DISABLED:`` when the user has been disabled or soft-deleted + between run creation and worker pickup. The outer + :meth:`handle_node_execute` parses that prefix and routes the + resulting ``error_code="USER_DISABLED"`` into the + ``NODE_FINISHED_EVENT`` outbox payload (the + ``schedule_node_runs`` row has no ``error_code`` column, only a + ``message`` text field). + """ + user = await session.scalar( + select(Users.status, Users.is_deleted).where(Users.user_id == user_id) + ) + if user is None: + raise ValueError( + f"USER_DISABLED: originating user {user_id} no longer exists" + ) + status_value, is_deleted = user + if status_value != "active" or is_deleted != 0: + raise ValueError( + f"USER_DISABLED: originating user {user_id} is " + f"status={status_value!r} is_deleted={is_deleted}" + ) + + async def _execution_context( + self, + payload: dict[str, Any], + ) -> dict[str, Any]: + async with self.session_factory() as session: + row = ( + await session.execute( + select( + ScheduleNodeRuns, + ScheduleRuns, + Versions, + StorageObjects, + Workspaces, + Schedules, + ) + .join( + ScheduleRuns, + ScheduleRuns.run_id == ScheduleNodeRuns.run_id, + ) + .join( + Versions, + Versions.versions_id == ScheduleNodeRuns.versions_id, + ) + .join( + StorageObjects, + StorageObjects.storage_object_id + == Versions.artifact_object_id, + ) + .join( + Workspaces, + Workspaces.workspace_id == ScheduleRuns.workspace_id, + ) + .join( + Schedules, + Schedules.schedule_id == ScheduleRuns.schedule_id, + ) + .where( + ScheduleNodeRuns.node_run_id == payload["node_run_id"], + ) + ) + ).one_or_none() + if row is None: + raise ValueError("node execution metadata not found") + node_run, run, version, storage, workspace, schedule = row + if storage.object_status != "available": + raise ValueError("stable version artifact is not available") + if storage.storage_backend != settings.storage_backend: + raise ValueError( + "稳定版本产物的存储后端与当前运行后端不一致" + ) + if not storage.bucket_name or not storage.object_key: + raise ValueError("stable version artifact location is incomplete") + user_id = run.triggered_by or schedule.created_by + # P0-5 / C1: re-verify the user is still active. ``create_scheduled_run`` + # checked membership when the run was queued, but the user may + # have been disabled or soft-deleted in the meantime (admin + # action, offboarding). Skip the check for the synthetic SYSTEM_CRON + # user — that row is a fixed admin baseline and never goes inactive. + if user_id != SYSTEM_CRON_USER_ID: + await self._assert_user_active(session, user_id) + context = { + "node_status": node_run.node_status, + "workspace_id": run.workspace_id, + "workspace_code": workspace.workspace_code, + "user_id": user_id, + "bucket_name": storage.bucket_name, + "object_key": storage.object_key, + "content_hash": version.content_hash, + } + logger.debug( + "execution context loaded: node_run={} bucket={} object_key={}", + payload["node_run_id"][-12:], + context["bucket_name"], + context["object_key"][-32:], + ) + return context + + async def _fallback_execution_context( + self, + payload: dict[str, Any], + ) -> dict[str, Any]: + async with self.session_factory() as session: + row = ( + await session.execute( + select(ScheduleRuns, Workspaces, Schedules) + .join( + Workspaces, + Workspaces.workspace_id == ScheduleRuns.workspace_id, + ) + .join( + Schedules, + Schedules.schedule_id == ScheduleRuns.schedule_id, + ) + .where(ScheduleRuns.run_id == payload["run_id"]) + ) + ).one_or_none() + if row is None: + raise ValueError("schedule run execution context not found") + run, workspace, schedule = row + return { + "workspace_id": run.workspace_id, + "workspace_code": workspace.workspace_code, + "user_id": run.triggered_by or schedule.created_by, + } + + async def _download_artifact( + self, + *, + bucket_name: str, + object_key: str, + content_hash: str, + ) -> bytes: + # Honor the artifact's actual bucket (P0-3 fix): the artifact may + # live in ``Workspaces.artifact_bucket`` rather than the global + # version bucket the default ``object_store`` is bound to. + store = self._store_for(bucket_name) + content = await store.get(object_key) + if hashlib.sha256(content).hexdigest() != content_hash: + raise ValueError("stable version artifact hash mismatch") + logger.debug( + "artifact downloaded: bucket={} object_key={} bytes={}", + bucket_name, + object_key[-32:], + len(content), + ) + return content + + async def _upload_execution_artifacts( + self, + *, + payload: dict[str, Any], + context: dict[str, Any], + result: ExecutionResult, + ) -> tuple[str | None, str | None, str | None]: + log_id: str | None = None + result_id: str | None = None + upload_error: str | None = None + try: + log_object = await self.storage_client.create_object( + workspace_id=context["workspace_id"], + user_id=context["user_id"], + usage_type="run_log", + file_name=f"{payload['node_run_id']}.log", + content_type="text/plain; charset=utf-8", + content=result.logs, + idempotency_key=f"{payload['node_run_id']}:log", + ) + log_id = log_object["storage_object_id"] + result_object = await self.storage_client.create_object( + workspace_id=context["workspace_id"], + user_id=context["user_id"], + usage_type="run_result", + file_name=result.result_file_name, + content_type=result.result_content_type, + content=result.result, + idempotency_key=f"{payload['node_run_id']}:result", + ) + result_id = result_object["storage_object_id"] + except Exception as exc: + upload_error = f"result upload failed: {exc}"[:2000] + logger.exception("failed to upload node execution artifacts") + if log_id and result_id and not upload_error: + logger.info( + "artifacts uploaded: log_id={} result_id={}", + log_id[-12:], + result_id[-12:], + ) + return log_id, result_id, upload_error + + @staticmethod + async def _start_inbox( + session, + *, + consumer_name: str, + event_id: str, + message_id: str, + ) -> tuple[ConsumerInbox, bool]: + item = await session.get( + ConsumerInbox, + (consumer_name, event_id), + with_for_update=True, + ) + if item is not None and item.process_status == "succeeded": + return item, False + if item is None: + item = ConsumerInbox( + consumer_name=consumer_name, + event_id=event_id, + process_status="processing", + message_id=message_id, + ) + session.add(item) + else: + item.process_status = "processing" + item.message_id = message_id + item.error_message = None + return item, True + + @staticmethod + def _finish_inbox(item: ConsumerInbox) -> None: + item.process_status = "succeeded" + item.processed_at = utcnow() + item.error_message = None + + +__all__ = ["NodeExecutor"] diff --git a/schedule/src/schedule/notebook_runner.py b/schedule/src/schedule/notebook_runner.py index 317d8ff..dfb4e80 100644 --- a/schedule/src/schedule/notebook_runner.py +++ b/schedule/src/schedule/notebook_runner.py @@ -1,103 +1,14 @@ -import argparse -import json -import sys -import traceback -from pathlib import Path +"""Compatibility shim — the runner moved to ``schedule.execution.runners.notebook``. -import nbformat -from loguru import logger -from nbclient import NotebookClient - - -def emit_outputs(notebook: object) -> None: - for cell in notebook.cells: # type: ignore[attr-defined] - if cell.get("cell_type") != "code": - continue - for output in cell.get("outputs", []): - output_type = output.get("output_type") - if output_type == "stream": - text = output.get("text", "") - print( - "".join(text) if isinstance(text, list) else str(text), - end="", - flush=True, - ) - elif output_type == "error": - print( - f"{output.get('ename', 'Error')}: {output.get('evalue', '')}", - file=sys.stderr, - flush=True, - ) - - -def main() -> None: - parser = argparse.ArgumentParser() - parser.add_argument("--input", required=True) - parser.add_argument("--output", required=True) - parser.add_argument("--timeout", required=True, type=int) - parser.add_argument( - "--python-version", - choices=("3.8", "3.10", "3.12"), - default="3.12", - ) - parser.add_argument("--arguments-json", default="[]") - args = parser.parse_args() - - source = Path(args.input) - output = Path(args.output) - arguments = json.loads(args.arguments_json) - if not isinstance(arguments, list) or not all( - isinstance(item, str) for item in arguments - ): - raise ValueError("arguments-json must contain an array of strings") - - logger.info( - "notebook runner start: input={} timeout={}s python={} args={}", - source.name, - args.timeout, - args.python_version, - len(arguments), - ) - notebook = nbformat.read(source, as_version=4) - if arguments: - notebook.cells.insert( - 0, - nbformat.v4.new_code_cell( - "import sys\n" - f"sys.argv = {json.dumps([source.name, *arguments], ensure_ascii=False)}", - metadata={"tags": ["injected-parameters"]}, - ), - ) - exit_code = 0 - try: - kernel_name = f"python{args.python_version.replace('.', '')}" - client = NotebookClient( - notebook, - timeout=max(1, args.timeout), - kernel_name=kernel_name, - allow_errors=False, - ) - logger.debug( - "notebook client created: kernel={} timeout={}s", - kernel_name, - max(1, args.timeout), - ) - # No explicit cwd — the kernel inherits the parent's cwd, which the - # scheduler sets to the staged artifact directory. Keeping it here - # avoids any "cwd must exist" requirement on the host. - client.execute() - logger.info("notebook client execute done: input={}", source.name) - except Exception as exc: - logger.exception("notebook execute failed: input={}", source.name) - traceback.print_exc() - exit_code = 124 if "timeout" in type(exc).__name__.lower() else 1 - finally: - output.parent.mkdir(parents=True, exist_ok=True) - nbformat.write(notebook, output) - emit_outputs(notebook) - logger.debug("notebook output written: {}", output) - raise SystemExit(exit_code) +The schedule worker's ``_execute_notebook`` launches the notebook subprocess +as ``python -m schedule.notebook_runner``. That ``-m`` string is a stable +contract, so this shim re-exports ``main`` from the real module rather than +growing a second copy. +""" +from schedule.execution.runners.notebook import main if __name__ == "__main__": main() + +__all__ = ["main"] \ No newline at end of file diff --git a/schedule/src/schedule/service.py b/schedule/src/schedule/service.py index 7044ade..f8c65b9 100644 --- a/schedule/src/schedule/service.py +++ b/schedule/src/schedule/service.py @@ -38,7 +38,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from schedule.scheduling.orchestrator import DispatchOrchestrator from schedule.scheduling.scheduler import CronScheduler -from schedule.worker import NodeExecutor +from schedule.execution.worker import NodeExecutor class SchedulerService: diff --git a/schedule/src/schedule/worker.py b/schedule/src/schedule/worker.py index f000c1d..e163758 100644 --- a/schedule/src/schedule/worker.py +++ b/schedule/src/schedule/worker.py @@ -43,7 +43,7 @@ from sqlalchemy import select from schedule.domain.context import TERMINAL_NODE_STATES from schedule.domain.execution import ExecutionResult -from schedule.execution import execute_artifact +from schedule.execution.runners.notebook import execute_artifact NODE_EXECUTE_EVENT = schedule_event_type("job.node.execute") NODE_FINISHED_EVENT = schedule_event_type("job.node.finished") diff --git a/schedule/tests/test_worker.py b/schedule/tests/test_worker.py index 1fac0d1..7f9e89c 100644 --- a/schedule/tests/test_worker.py +++ b/schedule/tests/test_worker.py @@ -32,7 +32,7 @@ def _make_executor() -> tuple[SimpleNamespace, MagicMock, MagicMock]: construction time; it must be returned untouched by ``_store_for`` for the global version bucket. """ - from schedule.worker import NodeExecutor + from schedule.execution.worker import NodeExecutor default_store = MagicMock(name="default_store") storage_client = MagicMock(name="storage_client") -- 2.54.0 From c89132abc8becf19fdd216e5e46b0a82b1d11e52 Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:42:56 +0800 Subject: [PATCH 21/93] refactor(schedule): extract application/ layer (stage 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move service.py -> application/service.py (SchedulerService class name unchanged; build_object_store / build_storage_http_client move along) - main.py imports schedule.application.service - Rewrite worker.py's lazy `from schedule.service import build_object_store` and test_worker.py's mock patch string targets — same class of bug as the test_janitor patch strings (silent no-op until the old file is deleted) - Delete flat service.py (orphaned; only docstring refs remain in orchestrator, cleaned up in stage 6) - Zero behavior change; schedule/pyproject.toml untouched Co-Authored-By: Claude --- schedule/src/schedule/application/__init__.py | 0 schedule/src/schedule/{ => application}/service.py | 0 schedule/src/schedule/execution/worker.py | 2 +- schedule/src/schedule/main.py | 2 +- schedule/src/schedule/worker.py | 2 +- schedule/tests/test_worker.py | 4 ++-- 6 files changed, 5 insertions(+), 5 deletions(-) create mode 100644 schedule/src/schedule/application/__init__.py rename schedule/src/schedule/{ => application}/service.py (100%) diff --git a/schedule/src/schedule/application/__init__.py b/schedule/src/schedule/application/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/schedule/src/schedule/service.py b/schedule/src/schedule/application/service.py similarity index 100% rename from schedule/src/schedule/service.py rename to schedule/src/schedule/application/service.py diff --git a/schedule/src/schedule/execution/worker.py b/schedule/src/schedule/execution/worker.py index e163758..7743618 100644 --- a/schedule/src/schedule/execution/worker.py +++ b/schedule/src/schedule/execution/worker.py @@ -87,7 +87,7 @@ class NodeExecutor: store = self._bucket_stores.get(bucket_name) if store is not None: return store - from schedule.service import build_object_store + from schedule.application.service import build_object_store store = build_object_store(bucket_name=bucket_name) self._bucket_stores[bucket_name] = store diff --git a/schedule/src/schedule/main.py b/schedule/src/schedule/main.py index 3c978f3..b517314 100644 --- a/schedule/src/schedule/main.py +++ b/schedule/src/schedule/main.py @@ -9,7 +9,7 @@ from common.db import create_database_engine, create_session_factory from common.service_app import create_service_app from loguru import logger -from schedule.service import ( +from schedule.application.service import ( SchedulerService, build_object_store, build_storage_http_client, diff --git a/schedule/src/schedule/worker.py b/schedule/src/schedule/worker.py index e163758..7743618 100644 --- a/schedule/src/schedule/worker.py +++ b/schedule/src/schedule/worker.py @@ -87,7 +87,7 @@ class NodeExecutor: store = self._bucket_stores.get(bucket_name) if store is not None: return store - from schedule.service import build_object_store + from schedule.application.service import build_object_store store = build_object_store(bucket_name=bucket_name) self._bucket_stores[bucket_name] = store diff --git a/schedule/tests/test_worker.py b/schedule/tests/test_worker.py index 7f9e89c..57dd630 100644 --- a/schedule/tests/test_worker.py +++ b/schedule/tests/test_worker.py @@ -61,7 +61,7 @@ def test_store_for_custom_bucket_uses_build_factory(monkeypatch: pytest.MonkeyPa """A custom bucket_name must produce a store via ``build_object_store``.""" custom_store = MagicMock(name="custom_store") with patch( - "schedule.service.build_object_store", + "schedule.application.service.build_object_store", return_value=custom_store, ) as mock_build: executor, _, _ = _make_executor() @@ -75,7 +75,7 @@ def test_store_for_custom_bucket_is_cached(monkeypatch: pytest.MonkeyPatch) -> N """Repeated lookups for the same custom bucket must hit the cache.""" custom_store = MagicMock(name="custom_store") with patch( - "schedule.service.build_object_store", + "schedule.application.service.build_object_store", return_value=custom_store, ) as mock_build: executor, _, _ = _make_executor() -- 2.54.0 From d59411160123c02f960ad7bcf681f0038adb5014 Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:47:14 +0800 Subject: [PATCH 22/93] refactor(schedule): cleanup flat files + document layering (stage 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Delete the six orphaned flat modules (context/executor/orchestrator/ scheduler/storage_client/worker) — all import sites already point at the layered packages; keep notebook_runner.py as the compatibility shim - Clear __pycache__; fix stale docstring module refs in surviving files - Subpackage __init__.py files re-export public symbols per layer (CronScheduler / DispatchOrchestrator / SchedulerService / NodeExecutor / SchedulerStorageClient / ExecutionResult / TERMINAL_NODE_STATES ...) - CLAUDE.md engineering notes: add "Schedule service layering" section - Zero behavior change; schedule/pyproject.toml untouched Co-Authored-By: Claude --- CLAUDE.md | 9 + schedule/src/schedule/application/__init__.py | 17 + schedule/src/schedule/application/service.py | 6 +- schedule/src/schedule/context.py | 41 - schedule/src/schedule/domain/__init__.py | 21 + schedule/src/schedule/execution/__init__.py | 11 + .../schedule/execution/runners/__init__.py | 5 + schedule/src/schedule/execution/worker.py | 2 +- schedule/src/schedule/executor.py | 4 - .../src/schedule/infrastructure/__init__.py | 1 + .../infrastructure/storage/__init__.py | 9 + schedule/src/schedule/orchestrator.py | 946 ------------------ schedule/src/schedule/scheduler.py | 232 ----- schedule/src/schedule/scheduling/__init__.py | 22 + .../src/schedule/scheduling/orchestrator.py | 4 +- schedule/src/schedule/storage_client.py | 82 -- schedule/src/schedule/worker.py | 556 ---------- 17 files changed, 101 insertions(+), 1867 deletions(-) delete mode 100644 schedule/src/schedule/context.py delete mode 100644 schedule/src/schedule/executor.py delete mode 100644 schedule/src/schedule/orchestrator.py delete mode 100644 schedule/src/schedule/scheduler.py delete mode 100644 schedule/src/schedule/storage_client.py delete mode 100644 schedule/src/schedule/worker.py diff --git a/CLAUDE.md b/CLAUDE.md index 2f00ba5..a64cd72 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,6 +76,15 @@ Hard-won lessons. Read the relevant bullet before touching the named area. - **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 3118694 → c89132a). 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. + ### Frontend state + routing (zustand + React Router v8) Lessons from splitting `frontend/app/features/platform/ModelPlatformApp.tsx` (1057 → 121 lines) into zustand stores + nested routes. diff --git a/schedule/src/schedule/application/__init__.py b/schedule/src/schedule/application/__init__.py index e69de29..19f7d81 100644 --- a/schedule/src/schedule/application/__init__.py +++ b/schedule/src/schedule/application/__init__.py @@ -0,0 +1,17 @@ +"""Application layer — service assembly for the scheduler. + +Home of the old flat ``schedule/service.py``: ``SchedulerService`` plus the +``build_object_store`` / ``build_storage_http_client`` factories. +""" + +from schedule.application.service import ( + SchedulerService, + build_object_store, + build_storage_http_client, +) + +__all__ = [ + "SchedulerService", + "build_object_store", + "build_storage_http_client", +] diff --git a/schedule/src/schedule/application/service.py b/schedule/src/schedule/application/service.py index f8c65b9..5acfd7e 100644 --- a/schedule/src/schedule/application/service.py +++ b/schedule/src/schedule/application/service.py @@ -2,9 +2,9 @@ Composes three single-purpose components into one bootable service: - - :class:`schedule.scheduler.CronScheduler` — APScheduler + cron sync loop - - :class:`schedule.orchestrator.DispatchOrchestrator` — Outbox polling + DAG - - :class:`schedule.worker.NodeExecutor` — node-level execution + - :class:`schedule.scheduling.scheduler.CronScheduler` — APScheduler + cron sync loop + - :class:`schedule.scheduling.orchestrator.DispatchOrchestrator` — Outbox polling + DAG + - :class:`schedule.execution.worker.NodeExecutor` — node-level execution This module also exposes the factory function ``build_object_store`` consumed by ``schedule.main`` to construct the S3 backend. diff --git a/schedule/src/schedule/context.py b/schedule/src/schedule/context.py deleted file mode 100644 index 143e5c9..0000000 --- a/schedule/src/schedule/context.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Shared constants and timezone helpers for the scheduler components. - -Designed to be import-side-effect-free: no logging, no I/O, no model imports. -Used by ``scheduler``, ``orchestrator`` and ``worker`` modules. -""" - -from __future__ import annotations - -from datetime import UTC, datetime - -TERMINAL_NODE_STATES = frozenset({ - "succeeded", - "failed", - "skipped", - "cancelled", - "timed_out", -}) -FAILED_NODE_STATES = frozenset({"failed", "cancelled", "timed_out"}) -TERMINAL_RUN_STATES = frozenset({ - "succeeded", - "failed", - "cancelled", - "timed_out", -}) - - -def naive_utc(value: datetime | None) -> datetime | None: - """Normalize a datetime to naive UTC; pass through ``None``.""" - if value is None: - return None - if value.tzinfo is None: - value = value.replace(tzinfo=UTC) - return value.astimezone(UTC).replace(tzinfo=None) - - -__all__ = [ - "FAILED_NODE_STATES", - "TERMINAL_NODE_STATES", - "TERMINAL_RUN_STATES", - "naive_utc", -] diff --git a/schedule/src/schedule/domain/__init__.py b/schedule/src/schedule/domain/__init__.py index e69de29..d183e3e 100644 --- a/schedule/src/schedule/domain/__init__.py +++ b/schedule/src/schedule/domain/__init__.py @@ -0,0 +1,21 @@ +"""Domain layer — pure types, constants and time helpers, no I/O. + +Layered-refactor home for the old flat ``schedule/context.py`` and the +``ExecutionResult`` dataclass that used to live in ``schedule/execution.py``. +""" + +from schedule.domain.context import ( + FAILED_NODE_STATES, + TERMINAL_NODE_STATES, + TERMINAL_RUN_STATES, + naive_utc, +) +from schedule.domain.execution import ExecutionResult + +__all__ = [ + "TERMINAL_NODE_STATES", + "FAILED_NODE_STATES", + "TERMINAL_RUN_STATES", + "naive_utc", + "ExecutionResult", +] diff --git a/schedule/src/schedule/execution/__init__.py b/schedule/src/schedule/execution/__init__.py index e69de29..c37f256 100644 --- a/schedule/src/schedule/execution/__init__.py +++ b/schedule/src/schedule/execution/__init__.py @@ -0,0 +1,11 @@ +"""Execution layer — node consumption loop + notebook/python runners. + +Home of the old flat ``schedule/worker.py`` (``NodeExecutor``) and +``schedule/executor.py`` stub. Runner helpers moved to +:mod:`schedule.execution.runners.notebook`, which also hosts the CLI that the +old ``schedule/notebook_runner.py`` shim re-exports. +""" + +from schedule.execution.worker import NodeExecutor + +__all__ = ["NodeExecutor"] diff --git a/schedule/src/schedule/execution/runners/__init__.py b/schedule/src/schedule/execution/runners/__init__.py index e69de29..f31ddbf 100644 --- a/schedule/src/schedule/execution/runners/__init__.py +++ b/schedule/src/schedule/execution/runners/__init__.py @@ -0,0 +1,5 @@ +"""Runners — subprocess notebook / python-script execution backend.""" + +from schedule.execution.runners.notebook import execute_artifact, main + +__all__ = ["execute_artifact", "main"] diff --git a/schedule/src/schedule/execution/worker.py b/schedule/src/schedule/execution/worker.py index 7743618..f2cc189 100644 --- a/schedule/src/schedule/execution/worker.py +++ b/schedule/src/schedule/execution/worker.py @@ -1,6 +1,6 @@ """Node-level worker: executes one ``job.node.execute`` event. -The orchestrator (see ``schedule.orchestrator``) writes a ``job.node.execute`` +The orchestrator (see ``schedule.scheduling.orchestrator``) writes a ``job.node.execute`` Outbox row with all the metadata needed to run the node (script type, artifact location, timeout, arguments ...). The polling loop picks those up and calls :meth:`NodeExecutor.handle_node_execute`. This module owns the diff --git a/schedule/src/schedule/executor.py b/schedule/src/schedule/executor.py deleted file mode 100644 index 3811ca1..0000000 --- a/schedule/src/schedule/executor.py +++ /dev/null @@ -1,4 +0,0 @@ -""" -@Time :2026/7/29 -@Author :tao.chen -""" diff --git a/schedule/src/schedule/infrastructure/__init__.py b/schedule/src/schedule/infrastructure/__init__.py index e69de29..c0062a4 100644 --- a/schedule/src/schedule/infrastructure/__init__.py +++ b/schedule/src/schedule/infrastructure/__init__.py @@ -0,0 +1 @@ +"""Infrastructure layer — external I/O adapters (storage API).""" diff --git a/schedule/src/schedule/infrastructure/storage/__init__.py b/schedule/src/schedule/infrastructure/storage/__init__.py index e69de29..ee2a829 100644 --- a/schedule/src/schedule/infrastructure/storage/__init__.py +++ b/schedule/src/schedule/infrastructure/storage/__init__.py @@ -0,0 +1,9 @@ +"""Storage adapter — uploads run logs / results to the internal storage API. + +Home of the old flat ``schedule/storage_client.py`` +(``SchedulerStorageClient``). +""" + +from schedule.infrastructure.storage.client import SchedulerStorageClient + +__all__ = ["SchedulerStorageClient"] diff --git a/schedule/src/schedule/orchestrator.py b/schedule/src/schedule/orchestrator.py deleted file mode 100644 index 69e921a..0000000 --- a/schedule/src/schedule/orchestrator.py +++ /dev/null @@ -1,946 +0,0 @@ -"""Outbox-driven DAG orchestrator. - -Polls the MySQL ``OutboxEvents`` table for ``schedule.run.requested`` and -``job.node.finished`` events and advances schedule runs accordingly. New -nodes are dispatched by writing ``job.node.execute`` rows to the Outbox -and letting the worker component consume them. - -This module owns no HTTP / boto3 / notebook execution dependencies — -those belong to the worker (see ``schedule.worker``) and the cron -post-back (see ``schedule.service.trigger_schedule``). -""" - -from __future__ import annotations - -import asyncio -from datetime import timedelta -from typing import Any - -from common.db import session_scope -from common.db.models import ( - ConsumerInbox, - OutboxEvents, - ScheduleNodeRuns, - ScheduleNodes, - ScheduleRuns, -) -from common.eventing import add_outbox_event, event_time, schedule_event_type, utcnow -from common.ids import new_ulid -from loguru import logger -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker - -from schedule.domain.context import ( - FAILED_NODE_STATES, - TERMINAL_NODE_STATES, - TERMINAL_RUN_STATES, -) - -SCHEDULE_RUN_REQUESTED_EVENT = schedule_event_type("schedule.run.requested") -NODE_EXECUTE_EVENT = schedule_event_type("job.node.execute") -NODE_FINISHED_EVENT = schedule_event_type("job.node.finished") - -class DispatchOrchestrator: - """Polls Outbox + advances DAG schedule runs. - - Two independent loops run in the same process: - - - ``_database_event_loop`` drains ``schedule.run.requested`` and - ``job.node.finished`` events under ``dispatch_lock``. These are - short, in-line DB transactions. - - ``_execution_loop`` claims ``job.node.execute`` events whose - ``available_at <= utcnow()`` and dispatches each as - ``asyncio.create_task`` so the polling path is never blocked by - notebook execution. A semaphore caps concurrent notebooks. - - Holds a ``dispatch_lock`` to keep two concurrent drain loops from - fighting over the same batch. - - Lease semantics live on the outbox row itself, not in the claim - step. A new ``job.node.execute`` event is immediately eligible; - the claim step atomically moves ``available_at`` to ``utcnow() + - node_timeout + LEASE_SLACK``, so a process crash mid-execution lets - the row become eligible again once the lease expires. A hard-coded - 30-minute lease was the - original P0-2 bug: a node with ``timeout_seconds = 86_400`` would - be re-claimed at 30 minutes and run twice; a node with - ``timeout_seconds = 60`` would have its lease expire 29 minutes - too early. Tieing the lease to the actual node timeout closes both - cases. - """ - - # Margin added on top of ``timeout_seconds`` when writing the lease - # ``available_at``. Gives the worker time to update the row to a - # terminal state before the poll re-picks it. - LEASE_SLACK = timedelta(seconds=30) - - # Margins used by the node-run janitor (see ``_janitor_loop``). - # - # ``NODE_JANITOR_GRACE_SECONDS`` is added on top of each node's - # ``timeout_seconds + retry_count * retry_interval_sec`` when judging - # whether a started-but-not-finished row is stuck. Absorbs the outbox - # ``min(30, 2**retry_count)`` backoff + the lease slack above + a few - # seconds of scheduling jitter. Tuned for the worst case the - # orchestrator itself produces, so the janitor cannot race a legitimate - # retry path to terminal state. - NODE_JANITOR_GRACE_SECONDS = 120 - # Floor for ``started_at IS NULL`` rows (never dispatched). 1h rides - # out an orchestrator restart that drops the lease mid-flight; past - # that the row is dead and forcing a terminal state lets the DAG - # advance. - NODE_JANITOR_QUEUED_GRACE_SECONDS = 3600 - # Loop period + batch size. Detection lag = interval + the time it - # takes to scan, currently well under a minute. 50 rows per cycle - # keeps worst-case fan-out bounded. - NODE_JANITOR_INTERVAL_SECONDS = 30 - NODE_JANITOR_BATCH = 50 - - def __init__( - self, - *, - session_factory: async_sessionmaker[AsyncSession], - node_execute_handler, - execution_concurrency: int = 4, - ) -> None: - self.session_factory = session_factory - # Injected by the facade; routes ``job.node.execute`` outbox events - # to ``NodeExecutor.handle_node_execute``. Kept as a callable so this - # module can stay independent of worker module imports. - self._node_execute_handler = node_execute_handler - self.dispatch_lock = asyncio.Lock() - self._loop_task: asyncio.Task[None] | None = None - self._exec_loop_task: asyncio.Task[None] | None = None - self._janitor_task: asyncio.Task[None] | None = None - self._exec_tasks: set[asyncio.Task[None]] = set() - self._exec_semaphore = asyncio.Semaphore(execution_concurrency) - - def start(self) -> None: - self._loop_task = asyncio.create_task( - self._database_event_loop(), - name="scheduler-database-events", - ) - self._exec_loop_task = asyncio.create_task( - self._execution_loop(), - name="scheduler-node-execute", - ) - self._janitor_task = asyncio.create_task( - self._janitor_loop(), - name="scheduler-node-janitor", - ) - - async def close(self) -> None: - if self._loop_task is not None: - self._loop_task.cancel() - try: - await self._loop_task - except asyncio.CancelledError: - pass - self._loop_task = None - if self._exec_loop_task is not None: - self._exec_loop_task.cancel() - try: - await self._exec_loop_task - except asyncio.CancelledError: - pass - self._exec_loop_task = None - if self._janitor_task is not None: - self._janitor_task.cancel() - try: - await self._janitor_task - except asyncio.CancelledError: - pass - self._janitor_task = None - if self._exec_tasks: - await asyncio.gather(*self._exec_tasks, return_exceptions=True) - self._exec_tasks.clear() - - async def _database_event_loop(self) -> None: - while True: - try: - processed = await self.process_pending_events(limit=20) - if processed: - logger.debug("database event loop processed {} events", processed) - if not processed: - await asyncio.sleep(0.25) - except asyncio.CancelledError: - raise - except Exception: - logger.exception("database event loop failed") - await asyncio.sleep(1) - - async def _execution_loop(self) -> None: - while True: - try: - claimed = await self._claim_execution_events(limit=10) - if claimed: - logger.debug("execution loop claimed {} events", claimed) - if not claimed: - await asyncio.sleep(0.25) - except asyncio.CancelledError: - raise - except Exception: - logger.exception("node execute loop failed") - await asyncio.sleep(1) - - async def _janitor_loop(self) -> None: - """Periodically force terminal state on stuck ``ScheduleNodeRuns``. - - Original P0-4 S2 bug: when ``job.node.execute`` outbox retries are - exhausted (5 tries, capped 30s backoff) the *event* is marked - ``failed`` but no one reconciles the *node_run* — the row stays - in ``queued`` or ``running`` forever, DAG children are never - dispatched, and the whole run sits at "running" with no further - progress. - - A second, harder failure mode: the worker crashes (OOM / - ``kill -9`` / forgotten upload) mid-execution. The lease-based - re-eligibility keeps re-dispatching the event, but a worker - that's stuck without writing a terminal state is invisible to - the retry counter — it just keeps timing out. - - Both collapse into one wall-clock condition: ``started_at + -`` budget < now()`` (or, more rarely, ``started_at IS NULL`` for - long enough). The janitor enforces that condition so no row - can outlive its deadline regardless of which subsystem let go. - """ - while True: - try: - killed = await self._reap_stuck_node_runs() - if killed: - logger.warning( - "node janitor reaped {} stuck node run(s)", killed, - ) - except asyncio.CancelledError: - raise - except Exception: - logger.exception("node janitor loop failed") - await asyncio.sleep(self.NODE_JANITOR_INTERVAL_SECONDS) - - async def _reap_stuck_node_runs(self) -> int: - """Find stuck ``ScheduleNodeRuns`` rows and force-terminal them. - - SQL does a coarse pre-filter (``created_at`` at least one hour - old so we don't even look at fresh rows). Per-row budget is - computed in Python because it depends on each row's - ``ScheduleNodes.timeout_seconds`` + ``retry_count`` + - ``retry_interval_sec``, which can vary row to row. - - Returns the count of rows actually flipped to ``timed_out``; - rows that were already terminal (worker got there first) are - silently skipped, so two janitors running side by side stay - idempotent. - """ - queued_cutoff = utcnow() - timedelta( - seconds=self.NODE_JANITOR_QUEUED_GRACE_SECONDS, - ) - async with session_scope(self.session_factory) as session: - statement = ( - select(ScheduleNodeRuns, ScheduleNodes, ScheduleRuns) - .join( - ScheduleNodes, - ScheduleNodes.node_id == ScheduleNodeRuns.node_id, - ) - .join( - ScheduleRuns, - ScheduleRuns.run_id == ScheduleNodeRuns.run_id, - ) - .where( - ScheduleNodeRuns.node_status.in_(("queued", "running")), - ScheduleNodeRuns.is_deleted == 0, - ScheduleNodes.is_deleted == 0, - ScheduleRuns.is_deleted == 0, - ScheduleNodeRuns.created_at <= queued_cutoff, - ) - .order_by(ScheduleNodeRuns.created_at) - .limit(self.NODE_JANITOR_BATCH) - ) - candidates = list((await session.execute(statement)).all()) - now = utcnow() - killed = 0 - for node_run, node, run in candidates: - if node_run.started_at is not None: - budget = ( - node.timeout_seconds - + node.retry_count * node.retry_interval_sec - + self.NODE_JANITOR_GRACE_SECONDS - ) - deadline = node_run.started_at + timedelta( - seconds=budget, - ) - if now <= deadline: - # Healthy row that just happened to be in the - # SQL pre-filter window; skip without writing. - continue - # ``started_at IS NULL`` rows fell through the coarse - # ``created_at <= queued_cutoff`` filter, so we know - # they are at least an hour old and never dispatched. - if await self._force_terminal_node_run( - session, - node_run=node_run, - run=run, - reason="NODE_RUN_TIMEOUT", - ): - killed += 1 - return killed - - async def _force_terminal_node_run( - self, - session: AsyncSession, - *, - node_run: ScheduleNodeRuns, - run: ScheduleRuns, - reason: str, - ) -> bool: - """Mark ``node_run`` as ``timed_out`` and enqueue a ``NODE_FINISHED_EVENT``. - - Re-reads the row under ``with_for_update`` so a worker that - reports the result concurrently can't be undone by a second - ``timed_out`` write (and vice versa). Returns ``True`` iff this - call performed the terminal write; ``False`` if the row had - already moved on (worker raced us, another janitor raced us). - - The idempotency key is distinct from the worker-reported - ``:finished`` key so the same ``node_run`` won't produce two - ``NODE_FINISHED_EVENT`` rows if both paths fire. The DAG - consumer is idempotent on its end (``_advance_run`` is a no-op - when ``node_status`` is already terminal), so even if a stray - duplicate slipped through it would self-heal. - """ - locked = await session.scalar( - select(ScheduleNodeRuns) - .where(ScheduleNodeRuns.node_run_id == node_run.node_run_id) - .with_for_update() - ) - if locked is None or locked.node_status in TERMINAL_NODE_STATES: - return False - finished_at = utcnow() - started_or_created = locked.started_at or locked.created_at - locked.node_status = "timed_out" - locked.finished_at = finished_at - locked.duration_ms = int( - (finished_at - started_or_created).total_seconds() * 1000, - ) - locked.error_code = reason - locked.message = ( - "节点执行超时已自动终止" - if locked.started_at is not None - else "节点从未派发,调度超时已自动终止" - )[:2000] - locked.state_version += 1 - await add_outbox_event( - session, - event_type=NODE_FINISHED_EVENT, - producer="schedule-janitor", - trace_id=new_ulid(), - aggregate_type="schedule_node_run", - aggregate_id=locked.node_run_id, - idempotency_key=( - f"{locked.node_run_id}:{locked.attempt_no}:timed_out" - ), - payload={ - "workspace_id": run.workspace_id, - "run_id": locked.run_id, - "node_run_id": locked.node_run_id, - "node_id": locked.node_id, - "versions_id": locked.versions_id, - "attempt_no": locked.attempt_no, - "node_status": locked.node_status, - "exit_code": None, - "started_at": event_time(locked.started_at), - "finished_at": event_time(finished_at), - "duration_ms": locked.duration_ms, - "logs_object_id": None, - "result_object_id": None, - "error_code": reason, - "error_message": locked.message, - }, - ) - return True - - async def _claim_execution_events( - self, - *, - limit: int, - ) -> int: - """Claim ``job.node.execute`` rows and dispatch them as tasks. - - Lease is owned by the row itself (the dispatcher sets - ``available_at = utcnow() + node.timeout_seconds + LEASE_SLACK`` - when the event is enqueued), so this method is a pure - read-and-dispatch — no DB writes in the claim step. If the - process dies before ``_run_node_execute`` finishes, the row - re-eligible once ``available_at`` falls back to now; the worker - handler is idempotent (short-circuits on terminal node state). - """ - async with session_scope(self.session_factory) as session: - statement = ( - select(OutboxEvents) - .where( - OutboxEvents.event_status == "pending", - OutboxEvents.event_type == NODE_EXECUTE_EVENT, - OutboxEvents.available_at <= utcnow(), - ) - .order_by(OutboxEvents.created_at) - .limit(limit) - .with_for_update(skip_locked=True) - ) - events = list((await session.scalars(statement)).all()) - now = utcnow() - for item in events: - timeout_seconds = max( - 1, - int(item.payload_json.get("timeout_seconds", 1)), - ) - item.available_at = ( - now - + timedelta(seconds=timeout_seconds) - + self.LEASE_SLACK - ) - claimed: list[tuple[dict[str, Any], str]] = [ - ( - { - "event_type": item.event_type, - "event_id": item.event_id, - "trace_id": item.trace_id, - "payload": item.payload_json, - }, - f"mysql:{item.event_id}", - ) - for item in events - ] - for envelope, message_id in claimed: - task = asyncio.create_task( - self._run_node_execute(envelope, message_id), - name=f"node-execute:{envelope['event_id']}", - ) - self._exec_tasks.add(task) - task.add_done_callback(self._exec_tasks.discard) - logger.debug("claimed {} node execute events", len(claimed)) - return len(claimed) - - async def _run_node_execute( - self, - envelope: dict[str, Any], - message_id: str, - ) -> None: - logger.debug("node execute start: event_id={}", envelope["event_id"][-12:]) - async with self._exec_semaphore: - exc: Exception | None = None - try: - await self._node_execute_handler(envelope, message_id) - except Exception as run_exc: # noqa: BLE001 - exc = run_exc - await self._update_execution_status(envelope["event_id"], exc) - - async def _update_execution_status( - self, - event_id: str, - exc: Exception | None, - ) -> None: - async with session_scope(self.session_factory) as session: - item = await session.scalar( - select(OutboxEvents) - .where(OutboxEvents.event_id == event_id) - .with_for_update() - ) - if item is None: - logger.warning("execution event {} disappeared", event_id) - return - if exc is None: - item.event_status = "published" - item.published_at = utcnow() - item.last_error = None - logger.info("node execute success: event_id={}", event_id[-12:]) - else: - item.retry_count += 1 - item.last_error = str(exc)[:2000] - if item.retry_count >= 5: - item.event_status = "failed" - logger.warning( - "node execute exhausted retries: event_id={} retry_count={}", - event_id[-12:], - item.retry_count, - ) - else: - item.available_at = utcnow() + timedelta( - seconds=min(30, 2 ** item.retry_count), - ) - logger.warning( - "node execute retry scheduled: event_id={} retry_count={} delay={}s", - event_id[-12:], - item.retry_count, - min(30, 2 ** item.retry_count), - ) - - async def process_pending_events( - self, - *, - limit: int = 20, - aggregate_id: str | None = None, - ) -> int: - async with self.dispatch_lock: - async with session_scope(self.session_factory) as session: - statement = ( - select(OutboxEvents) - .where( - OutboxEvents.event_status == "pending", - OutboxEvents.available_at <= utcnow(), - OutboxEvents.event_type.in_( - ( - SCHEDULE_RUN_REQUESTED_EVENT, - NODE_FINISHED_EVENT, - ), - ), - ) - .order_by(OutboxEvents.created_at) - .limit(limit) - ) - if aggregate_id: - statement = statement.where( - OutboxEvents.aggregate_id == aggregate_id - ) - events = list((await session.scalars(statement)).all()) - logger.debug("processed {} outbox events", len(events)) - for item in events: - try: - await self._process_outbox_event(item) - item.event_status = "published" - item.published_at = utcnow() - item.last_error = None - except Exception as exc: - item.retry_count += 1 - item.last_error = str(exc)[:2000] - if item.retry_count >= 5: - item.event_status = "failed" - else: - item.available_at = utcnow() + timedelta( - seconds=min(30, 2 ** item.retry_count) - ) - return len(events) - - async def _process_outbox_event(self, item: OutboxEvents) -> None: - handlers = { - SCHEDULE_RUN_REQUESTED_EVENT: self._handle_run_requested, - NODE_FINISHED_EVENT: self._handle_node_finished, - } - handler = handlers.get(item.event_type) - if handler is None: - raise ValueError(f"unsupported event type: {item.event_type}") - event = { - "event_type": item.event_type, - "event_id": item.event_id, - "trace_id": item.trace_id, - "payload": item.payload_json, - } - await handler(event, f"mysql:{item.event_id}") - - async def dispatch_run(self, run_id: str) -> int: - return await self.process_pending_events( - limit=50, - aggregate_id=run_id, - ) - - async def _start_inbox( - self, - session: AsyncSession, - *, - consumer_name: str, - event_id: str, - message_id: str, - ) -> tuple[ConsumerInbox, bool]: - item = await session.get( - ConsumerInbox, - (consumer_name, event_id), - with_for_update=True, - ) - if item is not None and item.process_status == "succeeded": - return item, False - if item is None: - item = ConsumerInbox( - consumer_name=consumer_name, - event_id=event_id, - process_status="processing", - message_id=message_id, - ) - session.add(item) - else: - item.process_status = "processing" - item.message_id = message_id - item.error_message = None - return item, True - - @staticmethod - def _finish_inbox(item: ConsumerInbox) -> None: - item.process_status = "succeeded" - item.processed_at = utcnow() - item.error_message = None - - async def _handle_run_requested( - self, - event: dict[str, Any], - message_id: str, - ) -> None: - if event.get("event_type") != SCHEDULE_RUN_REQUESTED_EVENT: - raise ValueError("unexpected event type") - payload = event["payload"] - logger.info( - "run requested: run={} trace={}", - payload["run_id"][-12:], - event["trace_id"][-12:], - ) - async with session_scope(self.session_factory) as session: - inbox, should_process = await self._start_inbox( - session, - consumer_name="schedule-orchestrator", - event_id=event["event_id"], - message_id=message_id, - ) - if not should_process: - return - run = await session.scalar( - select(ScheduleRuns) - .where(ScheduleRuns.run_id == payload["run_id"]) - .with_for_update() - ) - if run is None: - raise ValueError("schedule run does not exist") - if run.run_status not in TERMINAL_RUN_STATES: - if run.run_status == "queued": - run.run_status = "running" - run.started_at = utcnow() - run.state_version += 1 - await self._bootstrap_root_nodes( - session, - run, - trace_id=event["trace_id"], - ) - await self._advance_run( - session, - run, - trace_id=event["trace_id"], - ) - self._finish_inbox(inbox) - - async def _dispatch_node( - self, - session: AsyncSession, - *, - run: ScheduleRuns, - node: dict[str, Any], - attempt_no: int, - trace_id: str, - delay_seconds: int = 0, - ) -> ScheduleNodeRuns: - node_run = ScheduleNodeRuns( - node_run_id=new_ulid(), - run_id=run.run_id, - node_id=node["node_id"], - versions_id=node["versions_id"], - attempt_no=attempt_no, - node_status="queued", - state_version=0, - message=( - f"等待重试({delay_seconds} 秒)" - if delay_seconds - else "等待 Worker 执行" - ), - ) - session.add(node_run) - # ``available_at`` is the first execution time here. The claim - # step moves it forward by timeout + slack to become the retry - # lease while the worker is running. - retry_at = ( - utcnow() + timedelta(seconds=delay_seconds) - if delay_seconds - else utcnow() - ) - logger.info( - "dispatch node: run={} node={} attempt={}", - run.run_id[-12:], - node["node_id"][-12:], - attempt_no, - ) - await add_outbox_event( - session, - event_type=NODE_EXECUTE_EVENT, - producer="schedule-orchestrator", - trace_id=trace_id, - aggregate_type="schedule_node_run", - aggregate_id=node_run.node_run_id, - idempotency_key=f"{node_run.node_run_id}:{attempt_no}", - available_at=retry_at, - payload={ - "workspace_id": run.workspace_id, - "run_id": run.run_id, - "node_run_id": node_run.node_run_id, - "node_id": node["node_id"], - "versions_id": node["versions_id"], - "attempt_no": attempt_no, - "script_type": node["script_type"], - "artifact_object_id": node["artifact_object_id"], - "artifact_path": node["artifact_path"], - "timeout_seconds": node["timeout_seconds"], - "arguments": node.get("arguments", []), - }, - ) - return node_run - - async def _bootstrap_root_nodes( - self, - session: AsyncSession, - run: ScheduleRuns, - *, - trace_id: str, - ) -> None: - """Persist the first runnable nodes before normal DAG advancement.""" - existing_node_id = await session.scalar( - select(ScheduleNodeRuns.node_run_id) - .where(ScheduleNodeRuns.run_id == run.run_id) - .limit(1) - ) - if existing_node_id is not None: - return - - snapshot = run.schedule_snapshot - nodes = snapshot.get("nodes", []) - target_node_ids = { - edge["target_node_id"] for edge in snapshot.get("edges", []) - } - roots = [ - node for node in nodes - if node["node_id"] not in target_node_ids - ] - max_concurrency = max(1, int(snapshot.get("max_concurrency", 1))) - selected_roots = roots[:max_concurrency] - if selected_roots: - logger.info( - "bootstrap roots: run={} roots_count={} max_concurrency={}", - run.run_id[-12:], - len(selected_roots), - max_concurrency, - ) - for node in selected_roots: - await self._dispatch_node( - session, - run=run, - node=node, - attempt_no=1, - trace_id=trace_id, - ) - # The shared session factory disables autoflush. Flush here so - # _advance_run observes the roots and cannot dispatch duplicates. - await session.flush() - - async def _advance_run( - self, - session: AsyncSession, - run: ScheduleRuns, - *, - trace_id: str, - ) -> None: - snapshot = run.schedule_snapshot - nodes = snapshot.get("nodes", []) - node_by_id = {node["node_id"]: node for node in nodes} - parents: dict[str, set[str]] = {node_id: set() for node_id in node_by_id} - for edge in snapshot.get("edges", []): - parents.setdefault(edge["target_node_id"], set()).add( - edge["source_node_id"] - ) - rows = list( - ( - await session.scalars( - select(ScheduleNodeRuns) - .where(ScheduleNodeRuns.run_id == run.run_id) - .order_by(ScheduleNodeRuns.attempt_no) - ) - ).all() - ) - latest: dict[str, ScheduleNodeRuns] = {} - for row in rows: - current = latest.get(row.node_id) - if current is None or row.attempt_no >= current.attempt_no: - latest[row.node_id] = row - - max_concurrency = max(1, int(snapshot.get("max_concurrency", 1))) - failure_policy = snapshot.get("failure_policy", "stop") - while True: - changed = False - active_count = sum( - item.node_status in {"queued", "running"} - for item in latest.values() - ) - for node in nodes: - current = latest.get(node["node_id"]) - if ( - current is not None - and current.node_status in FAILED_NODE_STATES - and current.attempt_no <= int(node.get("retry_count", 0)) - and active_count < max_concurrency - ): - retried = await self._dispatch_node( - session, - run=run, - node=node, - attempt_no=current.attempt_no + 1, - trace_id=trace_id, - delay_seconds=int(node.get("retry_interval_sec", 0)), - ) - latest[node["node_id"]] = retried - active_count += 1 - changed = True - - exhausted_failure = any( - item.node_status in FAILED_NODE_STATES - and item.attempt_no - > int(node_by_id[item.node_id].get("retry_count", 0)) - for item in latest.values() - ) - stop_all = ( - failure_policy == "stop" - and exhausted_failure - ) - for node in nodes: - node_id = node["node_id"] - if node_id in latest: - continue - parent_runs = [latest.get(parent) for parent in parents[node_id]] - # 只有 ``stop`` 策略才会在失败后跳过尚未启动的节点。 - # ``continue`` 表示“前一个节点失败也继续往后执行”,因此 - # 下游只需等待所有上游结束,不要求它们全部成功。 - parents_terminal = all( - item is not None - and item.node_status in TERMINAL_NODE_STATES - for item in parent_runs - ) - if stop_all: - skipped = ScheduleNodeRuns( - node_run_id=new_ulid(), - run_id=run.run_id, - node_id=node_id, - versions_id=node["versions_id"], - attempt_no=1, - node_status="skipped", - state_version=1, - finished_at=utcnow(), - duration_ms=0, - message="调度失败策略为 stop,未再启动", - ) - session.add(skipped) - latest[node_id] = skipped - changed = True - logger.debug( - "skipped node: run={} node={} reason={}", - run.run_id[-12:], - node_id[-12:], - "stop_policy", - ) - elif parents_terminal and active_count < max_concurrency: - dispatched = await self._dispatch_node( - session, - run=run, - node=node, - attempt_no=1, - trace_id=trace_id, - ) - latest[node_id] = dispatched - active_count += 1 - changed = True - if not changed: - break - - if nodes and len(latest) == len(nodes) and all( - item.node_status in TERMINAL_NODE_STATES - for item in latest.values() - ): - now = utcnow() - # Final run status depends on the schedule's - # ``failure_policy``. ``stop`` keeps the legacy rule — any - # non-success node fails the whole run. ``continue`` is - # more lenient: the run is a success when at least one - # root-level node succeeded and there is no remaining - # ``failed`` / ``cancelled`` / ``timed_out`` node that - # would have produced real artifacts had it run. Nodes - # marked ``skipped`` count as "decided to not run" and do - # not by themselves fail the run. - failure_policy = snapshot.get("failure_policy", "stop") - statuses = [item.node_status for item in latest.values()] - any_real_failure = any( - status in FAILED_NODE_STATES for status in statuses - ) - any_success = any( - status == "succeeded" for status in statuses - ) - if failure_policy == "continue": - # A run with mixed success/failure/skip outcomes is - # only "succeeded" when at least one node actually ran - # to completion and nothing hit a hard failure. A - # run where every node was skipped or failed is - # itself a failure. - succeeded = any_success and not any_real_failure - else: - succeeded = all( - status == "succeeded" for status in statuses - ) - logger.info( - "run finalized: run={} status={} failure_policy={} any_failure={} any_success={}", - run.run_id[-12:], - "succeeded" if succeeded else "failed", - failure_policy, - any_real_failure, - any_success, - ) - run.run_status = "succeeded" if succeeded else "failed" - run.error_code = None if succeeded else "SCHEDULE_NODE_FAILED" - run.error_message = ( - None - if succeeded - else "one or more schedule nodes did not succeed" - ) - run.finished_at = now - if run.started_at: - run.duration_ms = max( - 0, - int((now - run.started_at).total_seconds() * 1000), - ) - run.state_version += 1 - - async def _handle_node_finished( - self, - event: dict[str, Any], - message_id: str, - ) -> None: - if event.get("event_type") != NODE_FINISHED_EVENT: - raise ValueError("unexpected event type") - payload = event["payload"] - logger.info( - "node finished event: run={} node={} status={}", - payload["run_id"][-12:], - payload["node_id"][-12:], - payload.get("node_status"), - ) - async with session_scope(self.session_factory) as session: - inbox, should_process = await self._start_inbox( - session, - consumer_name="schedule-results", - event_id=event["event_id"], - message_id=message_id, - ) - if not should_process: - return - run = await session.scalar( - select(ScheduleRuns) - .where(ScheduleRuns.run_id == payload["run_id"]) - .with_for_update() - ) - if run is None: - raise ValueError("schedule run does not exist") - if run.run_status not in TERMINAL_RUN_STATES: - await self._advance_run( - session, - run, - trace_id=event["trace_id"], - ) - self._finish_inbox(inbox) - - -__all__ = ["DispatchOrchestrator"] diff --git a/schedule/src/schedule/scheduler.py b/schedule/src/schedule/scheduler.py deleted file mode 100644 index bf57dc2..0000000 --- a/schedule/src/schedule/scheduler.py +++ /dev/null @@ -1,232 +0,0 @@ -"""APScheduler-backed cron trigger layer. - -Owns the ``AsyncIOScheduler`` instance plus the periodic sync loop that -reconciles in-memory APScheduler jobs against the ``Schedules`` table in -MySQL. The cron tick callback delegates to ``SchedulerService.trigger_schedule`` -(via the ``on_trigger`` callable injected at construction), which posts -back to Backend; Backend then writes a ``schedule.run.requested`` Outbox -row that the orchestrator consumes. - -The two layers (this cron scheduler, the orchestrator) are decoupled -through MySQL — they share no in-memory state and survive independent -restarts. -""" - -from __future__ import annotations - -import asyncio -from collections.abc import Awaitable, Callable -from datetime import UTC, datetime, timedelta -from zoneinfo import ZoneInfo - -from apscheduler.schedulers.asyncio import AsyncIOScheduler -from apscheduler.triggers.cron import CronTrigger -from common.db import session_scope -from common.db.models import Schedules -from common.scheduler import build_sqlalchemy_jobstore -from loguru import logger -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker - -from schedule.domain.context import naive_utc - -# APScheduler's persistent SQLAlchemy job store pickles each job. A bound -# ``CronScheduler`` method captures this instance (including SQLAlchemy engine -# state) and therefore cannot be pickled. Keep the persisted callable at -# module scope and resolve the process-local callback when the job fires. -_ACTIVE_TRIGGER: Callable[[str], Awaitable[None]] | None = None - - -async def dispatch_persisted_cron(schedule_id: str) -> None: - """Dispatch one persisted cron tick through the active service.""" - logger.debug("dispatch persisted cron: schedule={}", schedule_id[-12:]) - callback = _ACTIVE_TRIGGER - if callback is None: - raise RuntimeError("cron trigger callback is not initialized") - await callback(schedule_id) - - -class CronScheduler: - """Manages cron triggers in APScheduler, backed by a MySQL jobstore.""" - - def __init__( - self, - *, - session_factory: async_sessionmaker[AsyncSession], - database_url: str, - on_trigger: Callable[[str], Awaitable[None]], - ) -> None: - global _ACTIVE_TRIGGER - - self.session_factory = session_factory - self.scheduler = AsyncIOScheduler( - jobstores={"default": build_sqlalchemy_jobstore(database_url)}, - timezone=UTC, - ) - self._on_trigger = on_trigger - _ACTIVE_TRIGGER = on_trigger - self._sync_task: asyncio.Task[None] | None = None - # 仅在调度配置实际变化时才重置 APScheduler job。若每 5 秒都 - # reschedule,一旦恰好落在整分钟之后,就可能把本分钟的触发跳过。 - self._job_signatures: dict[str, tuple[str, str, int]] = {} - # APScheduler 的定时唤醒异常时,由 5 秒同步循环兜底。键保存的是 - # 已由兜底路径处理的“本地整分钟”,避免同一分钟重复提交。 - self._fallback_dispatched_minutes: dict[str, datetime] = {} - - def start(self) -> None: - """Start APScheduler and spawn the periodic sync loop.""" - logger.info("cron scheduler starting") - self.scheduler.start() - self._sync_task = asyncio.create_task( - self._sync_loop(), - name="scheduler-cron-sync", - ) - - async def close(self) -> None: - """Cancel the sync loop and shut APScheduler down.""" - logger.info("cron scheduler closing") - global _ACTIVE_TRIGGER - - if self._sync_task is not None: - self._sync_task.cancel() - try: - await self._sync_task - except asyncio.CancelledError: - pass - self._sync_task = None - if self.scheduler.running: - self.scheduler.shutdown(wait=False) - if _ACTIVE_TRIGGER is self._on_trigger: - _ACTIVE_TRIGGER = None - - async def trigger(self, schedule_id: str) -> None: - """APScheduler cron tick callback. - - The persisted job calls :func:`dispatch_persisted_cron`, which then - resolves this process-local callback. The - standard on_trigger is ``SchedulerService.trigger_schedule`` which - posts back to Backend; Backend then writes the Outbox row that the - orchestrator picks up. - """ - await self._on_trigger(schedule_id) - - async def _sync_loop(self) -> None: - while True: - try: - await self._sync_once() - logger.debug("cron sync tick ok") - except asyncio.CancelledError: - raise - except Exception: - logger.exception("cron job synchronization failed") - await asyncio.sleep(5) - - async def _sync_once(self) -> None: - """Reconcile APScheduler jobs against ``Schedules.cron_expression``. - - - adds jobs for enabled cron schedules present in MySQL - - removes jobs whose schedule has been disabled / soft-deleted - - updates ``Schedules.next_run_at`` from the Cron expression itself - """ - due_schedule_ids: list[str] = [] - async with session_scope(self.session_factory) as session: - schedules = list( - ( - await session.scalars( - select(Schedules).where( - Schedules.deleted_at.is_(None), - Schedules.enabled == 1, - Schedules.trigger_type == "cron", - Schedules.cron_expression.is_not(None), - ) - ) - ).all() - ) - active_job_ids: set[str] = set() - added_count = 0 - updated_count = 0 - for item in schedules: - job_id = f"schedule:{item.schedule_id}" - active_job_ids.add(job_id) - expression = (item.cron_expression or "").strip() - max_instances = max(1, item.max_concurrency) - signature = (expression, item.timezone, max_instances) - trigger = CronTrigger.from_crontab( - expression, - timezone=ZoneInfo(item.timezone), - ) - now = datetime.now(ZoneInfo(item.timezone)) - minute = now.replace(second=0, microsecond=0) - job_changed = False - if self.scheduler.get_job(job_id) is None: - self.scheduler.add_job( - dispatch_persisted_cron, - trigger=trigger, - args=[item.schedule_id], - id=job_id, - replace_existing=True, - coalesce=True, - max_instances=max_instances, - misfire_grace_time=60, - ) - added_count += 1 - job_changed = True - elif self._job_signatures.get(job_id) != signature: - # 服务重启后的首次同步也会走这里,确保持久化 job 与 - # 数据库当前配置一致;之后配置不变时保留原定时点。 - self.scheduler.reschedule_job(job_id, trigger=trigger) - self.scheduler.modify_job( - job_id, - max_instances=max_instances, - ) - updated_count += 1 - job_changed = True - self._job_signatures[job_id] = signature - # 以 CronTrigger 本身计算下次执行时间,不依赖 APScheduler 的 - # 内部唤醒状态;页面展示的「下次执行」也因此保持准确。 - item.next_run_at = naive_utc( - trigger.get_next_fire_time(None, now) - ) - - # 首次观察或刚修改表达式时,从下一个整分钟才开始兜底,符合 - # Cron 的常规语义,避免用户在本分钟中途保存后立刻多跑一次。 - if job_changed or job_id not in self._fallback_dispatched_minutes: - self._fallback_dispatched_minutes[job_id] = minute - - # 正常情况下 APScheduler 会在整分钟回调。实测其偶发漏唤醒时, - # 这里每 5 秒检查一次当前分钟是否命中表达式,并补发一次。 - due_at = trigger.get_next_fire_time( - minute - timedelta(minutes=1), - minute, - ) - if ( - due_at == minute - and self._fallback_dispatched_minutes.get(job_id) != minute - ): - self._fallback_dispatched_minutes[job_id] = minute - due_schedule_ids.append(item.schedule_id) - removed_count = 0 - for job in self.scheduler.get_jobs(): - if ( - job.id.startswith("schedule:") - and job.id not in active_job_ids - ): - self.scheduler.remove_job(job.id) - self._job_signatures.pop(job.id, None) - self._fallback_dispatched_minutes.pop(job.id, None) - removed_count += 1 - if added_count or updated_count or removed_count: - logger.info( - "cron sync reconciled: added={} updated={} removed={}", - added_count, - updated_count, - removed_count, - ) - # 在数据库同步事务提交后再创建运行记录,避免两个会话同时读取调度方案 - # 时发生不必要的锁等待。重复回调由运行记录的幂等键自动去重。 - for schedule_id in due_schedule_ids: - logger.debug("cron fallback dispatch: schedule={}", schedule_id[-12:]) - await self._on_trigger(schedule_id) - - -__all__ = ["CronScheduler"] diff --git a/schedule/src/schedule/scheduling/__init__.py b/schedule/src/schedule/scheduling/__init__.py index e69de29..a2ec411 100644 --- a/schedule/src/schedule/scheduling/__init__.py +++ b/schedule/src/schedule/scheduling/__init__.py @@ -0,0 +1,22 @@ +"""Scheduling layer — cron trigger + DAG orchestration. + +Home of the old flat ``schedule/scheduler.py`` (``CronScheduler``) and +``schedule/orchestrator.py`` (``DispatchOrchestrator``). Classes moved +byte-identical in the layered refactor; names unchanged. +""" + +from schedule.scheduling.orchestrator import ( + NODE_EXECUTE_EVENT, + NODE_FINISHED_EVENT, + SCHEDULE_RUN_REQUESTED_EVENT, + DispatchOrchestrator, +) +from schedule.scheduling.scheduler import CronScheduler + +__all__ = [ + "CronScheduler", + "DispatchOrchestrator", + "SCHEDULE_RUN_REQUESTED_EVENT", + "NODE_EXECUTE_EVENT", + "NODE_FINISHED_EVENT", +] diff --git a/schedule/src/schedule/scheduling/orchestrator.py b/schedule/src/schedule/scheduling/orchestrator.py index 69e921a..fef27ae 100644 --- a/schedule/src/schedule/scheduling/orchestrator.py +++ b/schedule/src/schedule/scheduling/orchestrator.py @@ -6,8 +6,8 @@ nodes are dispatched by writing ``job.node.execute`` rows to the Outbox and letting the worker component consume them. This module owns no HTTP / boto3 / notebook execution dependencies — -those belong to the worker (see ``schedule.worker``) and the cron -post-back (see ``schedule.service.trigger_schedule``). +those belong to the worker (see ``schedule.execution.worker``) and the cron +post-back (see ``schedule.application.service.trigger_schedule``). """ from __future__ import annotations diff --git a/schedule/src/schedule/storage_client.py b/schedule/src/schedule/storage_client.py deleted file mode 100644 index 72a97a9..0000000 --- a/schedule/src/schedule/storage_client.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Schedule-side HTTP client for the backend's storage API. - -The schedule worker uploads run logs / run results by calling -``POST {backend}/internal/v1/objects`` (the backend's -``create_server_object_payload`` route, which is in the same process -as the public API). The response is the StorageObjects row payload. - -The schedule does NOT have its own DB session for storage metadata, -so it must go through the backend to create the StorageObjects row -(``logs_object_id`` / ``result_object_id`` are FKs into that table). -""" - -from __future__ import annotations - -import base64 -from typing import Any - -import httpx -from loguru import logger - - -class SchedulerStorageClient: - def __init__(self, http_client: httpx.AsyncClient) -> None: - self._http = http_client - - async def create_object( - self, - *, - workspace_id: str, - user_id: str, - usage_type: str, - file_name: str, - content_type: str, - content: bytes, - idempotency_key: str, - ) -> dict[str, Any]: - """Upload a run_log / run_result via the backend's storage API. - - The backend returns ``{"data": , "meta": {...}}``; - we return the inner ``data`` dict (which includes - ``storage_object_id`` and ``storage_uri``). - """ - logger.debug( - "storage create_object: workspace={} usage_type={} file={} size={}B", - workspace_id[-12:], - usage_type, - file_name, - len(content), - ) - response = await self._http.post( - "/internal/v1/objects", - json={ - "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": "workspace", - "is_immutable": True, - "idempotency_key": idempotency_key, - "relative_path": None, - }, - ) - if response.is_error: - logger.warning( - "storage create_object HTTP error: status={} url={}", - response.status_code, - response.request.url, - ) - response.raise_for_status() - body = response.json() - logger.info( - "storage create_object done: workspace={} usage_type={} storage_object_id={}", - workspace_id[-12:], - usage_type, - body["data"].get("storage_object_id"), - ) - return body["data"] - - -__all__ = ["SchedulerStorageClient"] diff --git a/schedule/src/schedule/worker.py b/schedule/src/schedule/worker.py deleted file mode 100644 index 7743618..0000000 --- a/schedule/src/schedule/worker.py +++ /dev/null @@ -1,556 +0,0 @@ -"""Node-level worker: executes one ``job.node.execute`` event. - -The orchestrator (see ``schedule.orchestrator``) writes a ``job.node.execute`` -Outbox row with all the metadata needed to run the node (script type, -artifact location, timeout, arguments ...). The polling loop picks those up -and calls :meth:`NodeExecutor.handle_node_execute`. This module owns the -artifact download + subprocess invocation + result-upload side of things. - -`DispatchOrchestrator` writes the node's lifecycle row + outbox event; the -worker only mutates ``ScheduleNodeRuns`` columns related to execution -(started_at / finished_at / exit_code / result_object_id ...). -""" - -from __future__ import annotations - -import hashlib -import json -import traceback -from typing import Any - -from common.config import settings -from common.db import session_scope -from common.db.models import ( - ConsumerInbox, - ScheduleNodeRuns, - ScheduleNodes, - ScheduleRuns, - Schedules, - StorageObjects, - Users, - Versions, - Workspaces, -) -from common.eventing import ( - add_outbox_event, - event_time, - schedule_event_type, - utcnow, -) -from common.scheduler.trigger import SYSTEM_CRON_USER_ID -from loguru import logger -from sqlalchemy import select - -from schedule.domain.context import TERMINAL_NODE_STATES -from schedule.domain.execution import ExecutionResult -from schedule.execution.runners.notebook import execute_artifact - -NODE_EXECUTE_EVENT = schedule_event_type("job.node.execute") -NODE_FINISHED_EVENT = schedule_event_type("job.node.finished") - -class NodeExecutor: - """Owns the actual execution of one schedule node (notebook / python).""" - - # P0-5 / C1: distinct error_code for runs blocked because the originating - # user was disabled or soft-deleted between queue time and worker pickup. - # The value lands in the NODE_FINISHED_EVENT outbox payload (the - # ``error_code`` field) — schedule_node_runs has no such column; the row - # only carries the message text. Operators grep the outbox stream. - USER_DISABLED_ERROR_CODE = "USER_DISABLED" - - def __init__( - self, - *, - session_factory, - object_store: Any, - storage_client: Any, - ) -> None: - self.session_factory = session_factory - self.object_store = object_store - self.storage_client = storage_client - # Per-bucket object store cache. The injected ``object_store`` is - # the default version-bucket store; workspaces that override - # ``Workspaces.artifact_bucket`` need a store bound to that custom - # bucket (P0-3 fix). Build lazily so the common (no-override) path - # incurs no extra cost. - self._bucket_stores: dict[str, Any] = { - settings.s3_version_bucket: object_store, - } - - def _store_for(self, bucket_name: str) -> Any: - """Return the AsyncStorageBackend bound to ``bucket_name``. - - Caches per-bucket stores on first use; the default version bucket - always reuses the injected ``object_store`` so the common path - stays zero-allocation. - """ - store = self._bucket_stores.get(bucket_name) - if store is not None: - return store - from schedule.application.service import build_object_store - - store = build_object_store(bucket_name=bucket_name) - self._bucket_stores[bucket_name] = store - return store - - async def handle_node_execute( - self, - event: dict[str, Any], - message_id: str, - ) -> None: - if event.get("event_type") != NODE_EXECUTE_EVENT: - raise ValueError("unexpected event type") - payload = event["payload"] - if not await self._set_node_running(event, message_id): - return - logger.info( - "node execute start: node_run={} script_type={} timeout={}s", - payload["node_run_id"][-12:], - payload["script_type"], - payload["timeout_seconds"], - ) - started_at = utcnow() - context: dict[str, Any] | None = None - try: - context = await self._execution_context(payload) - content = await self._download_artifact( - bucket_name=context["bucket_name"], - object_key=context["object_key"], - content_hash=context["content_hash"], - ) - python_version = await self._node_python_version( - payload["node_run_id"] - ) - result = await execute_artifact( - content, - run_id=payload["run_id"], - node_run_id=payload["node_run_id"], - script_type=payload["script_type"], - artifact_path=payload["artifact_path"], - arguments=[str(item) for item in payload.get("arguments", [])], - timeout_seconds=int(payload["timeout_seconds"]), - python_version=python_version, - ) - except Exception as exc: - trace = traceback.format_exc() - # P0-5 / C1: 让 _assert_user_active 抛的 ValueError 透传成单独的 - # error_code,便于运维 grep 区分"用户被禁用"和"代码崩溃"。 - exc_message = str(exc) - error_code = ( - self.USER_DISABLED_ERROR_CODE - if exc_message.startswith("USER_DISABLED:") - else "WORKER_EXECUTION_FAILED" - ) - result = ExecutionResult( - status="failed", - exit_code=1, - logs=trace.encode("utf-8", errors="replace"), - result=json.dumps( - {"status": "failed", "error": exc_message}, - ensure_ascii=False, - ).encode("utf-8"), - result_file_name=f"{payload['node_run_id']}-result.json", - result_content_type="application/json", - error_code=error_code, - error_message=exc_message[:2000], - ) - if context is None: - context = await self._fallback_execution_context(payload) - - log_id, result_id, upload_error = await self._upload_execution_artifacts( - payload=payload, - context=context, - result=result, - ) - finished_at = utcnow() - duration_ms = max( - 0, - int((finished_at - started_at).total_seconds() * 1000), - ) - error_message = result.error_message - if upload_error: - error_message = ( - f"{error_message}; {upload_error}" - if error_message - else upload_error - )[:2000] - final_status = "failed" if upload_error else result.status - final_error_code = ( - "ARTIFACT_UPLOAD_FAILED" if upload_error else result.error_code - ) - - async with session_scope(self.session_factory) as session: - node_run = await session.scalar( - select(ScheduleNodeRuns) - .where( - ScheduleNodeRuns.node_run_id == payload["node_run_id"], - ) - .with_for_update() - ) - if node_run is None: - raise ValueError("schedule node run disappeared") - inbox = await session.get( - ConsumerInbox, - ("job-workers", event["event_id"]), - with_for_update=True, - ) - if inbox is None: - raise ValueError("job worker inbox record disappeared") - if node_run.node_status not in TERMINAL_NODE_STATES: - node_run.node_status = final_status - node_run.finished_at = finished_at - node_run.duration_ms = duration_ms - node_run.exit_code = result.exit_code - node_run.message = ( - "节点执行成功" - if final_status == "succeeded" - else (error_message or "节点执行失败") - )[:2000] - node_run.metrics_json = { - "log_size_bytes": len(result.logs), - "result_size_bytes": len(result.result), - } - node_run.logs_object_id = log_id - node_run.result_object_id = result_id - node_run.state_version += 1 - await add_outbox_event( - session, - event_type=NODE_FINISHED_EVENT, - producer="job-worker", - trace_id=event["trace_id"], - aggregate_type="schedule_node_run", - aggregate_id=node_run.node_run_id, - idempotency_key=( - f"{node_run.node_run_id}:{node_run.attempt_no}:finished" - ), - payload={ - "workspace_id": context["workspace_id"], - "run_id": node_run.run_id, - "node_run_id": node_run.node_run_id, - "node_id": node_run.node_id, - "versions_id": node_run.versions_id, - "attempt_no": node_run.attempt_no, - "node_status": node_run.node_status, - "exit_code": node_run.exit_code, - "started_at": event_time( - node_run.started_at or started_at - ), - "finished_at": event_time(finished_at), - "duration_ms": duration_ms, - "logs_object_id": log_id, - "result_object_id": result_id, - "error_code": final_error_code, - "error_message": error_message, - }, - ) - self._finish_inbox(inbox) - logger.info( - "node execute done: node_run={} status={} error_code={}", - payload["node_run_id"][-12:], - final_status, - final_error_code, - ) - - async def _set_node_running( - self, - event: dict[str, Any], - message_id: str, - ) -> bool: - payload = event["payload"] - async with session_scope(self.session_factory) as session: - inbox, should_process = await self._start_inbox( - session, - consumer_name="job-workers", - event_id=event["event_id"], - message_id=message_id, - ) - if not should_process: - return False - node_run = await session.scalar( - select(ScheduleNodeRuns) - .where( - ScheduleNodeRuns.node_run_id == payload["node_run_id"], - ) - .with_for_update() - ) - if node_run is None: - raise ValueError("schedule node run does not exist") - if node_run.node_status in TERMINAL_NODE_STATES: - logger.debug( - "node already terminal: node_run={} status={}", - payload["node_run_id"][-12:], - node_run.node_status, - ) - self._finish_inbox(inbox) - return False - if node_run.node_status == "queued": - node_run.node_status = "running" - node_run.started_at = utcnow() - node_run.message = "Worker 正在执行稳定版本" - node_run.state_version += 1 - logger.debug( - "node running: node_run={}", - payload["node_run_id"][-12:], - ) - return True - - async def _node_python_version( - self, - node_run_id: str, - ) -> str: - async with self.session_factory() as session: - row = ( - await session.execute( - select(ScheduleNodes.python_version) - .join( - ScheduleNodeRuns, - ScheduleNodeRuns.node_id == ScheduleNodes.node_id, - ) - .where(ScheduleNodeRuns.node_run_id == node_run_id) - ) - ).one_or_none() - if row is None: - logger.warning( - "node python_version not found, defaulting to 3.12: node_run={}", - node_run_id[-12:], - ) - return "3.12" - return row[0] - - async def _assert_user_active( - self, - session: AsyncSession, - user_id: str, - ) -> None: - """P0-5 / C1: re-verify the user is still ``status='active'`` and - ``is_deleted=0`` before executing a run they originated. - - Raises :class:`ValueError` whose message starts with - ``USER_DISABLED:`` when the user has been disabled or soft-deleted - between run creation and worker pickup. The outer - :meth:`handle_node_execute` parses that prefix and routes the - resulting ``error_code="USER_DISABLED"`` into the - ``NODE_FINISHED_EVENT`` outbox payload (the - ``schedule_node_runs`` row has no ``error_code`` column, only a - ``message`` text field). - """ - user = await session.scalar( - select(Users.status, Users.is_deleted).where(Users.user_id == user_id) - ) - if user is None: - raise ValueError( - f"USER_DISABLED: originating user {user_id} no longer exists" - ) - status_value, is_deleted = user - if status_value != "active" or is_deleted != 0: - raise ValueError( - f"USER_DISABLED: originating user {user_id} is " - f"status={status_value!r} is_deleted={is_deleted}" - ) - - async def _execution_context( - self, - payload: dict[str, Any], - ) -> dict[str, Any]: - async with self.session_factory() as session: - row = ( - await session.execute( - select( - ScheduleNodeRuns, - ScheduleRuns, - Versions, - StorageObjects, - Workspaces, - Schedules, - ) - .join( - ScheduleRuns, - ScheduleRuns.run_id == ScheduleNodeRuns.run_id, - ) - .join( - Versions, - Versions.versions_id == ScheduleNodeRuns.versions_id, - ) - .join( - StorageObjects, - StorageObjects.storage_object_id - == Versions.artifact_object_id, - ) - .join( - Workspaces, - Workspaces.workspace_id == ScheduleRuns.workspace_id, - ) - .join( - Schedules, - Schedules.schedule_id == ScheduleRuns.schedule_id, - ) - .where( - ScheduleNodeRuns.node_run_id == payload["node_run_id"], - ) - ) - ).one_or_none() - if row is None: - raise ValueError("node execution metadata not found") - node_run, run, version, storage, workspace, schedule = row - if storage.object_status != "available": - raise ValueError("stable version artifact is not available") - if storage.storage_backend != settings.storage_backend: - raise ValueError( - "稳定版本产物的存储后端与当前运行后端不一致" - ) - if not storage.bucket_name or not storage.object_key: - raise ValueError("stable version artifact location is incomplete") - user_id = run.triggered_by or schedule.created_by - # P0-5 / C1: re-verify the user is still active. ``create_scheduled_run`` - # checked membership when the run was queued, but the user may - # have been disabled or soft-deleted in the meantime (admin - # action, offboarding). Skip the check for the synthetic SYSTEM_CRON - # user — that row is a fixed admin baseline and never goes inactive. - if user_id != SYSTEM_CRON_USER_ID: - await self._assert_user_active(session, user_id) - context = { - "node_status": node_run.node_status, - "workspace_id": run.workspace_id, - "workspace_code": workspace.workspace_code, - "user_id": user_id, - "bucket_name": storage.bucket_name, - "object_key": storage.object_key, - "content_hash": version.content_hash, - } - logger.debug( - "execution context loaded: node_run={} bucket={} object_key={}", - payload["node_run_id"][-12:], - context["bucket_name"], - context["object_key"][-32:], - ) - return context - - async def _fallback_execution_context( - self, - payload: dict[str, Any], - ) -> dict[str, Any]: - async with self.session_factory() as session: - row = ( - await session.execute( - select(ScheduleRuns, Workspaces, Schedules) - .join( - Workspaces, - Workspaces.workspace_id == ScheduleRuns.workspace_id, - ) - .join( - Schedules, - Schedules.schedule_id == ScheduleRuns.schedule_id, - ) - .where(ScheduleRuns.run_id == payload["run_id"]) - ) - ).one_or_none() - if row is None: - raise ValueError("schedule run execution context not found") - run, workspace, schedule = row - return { - "workspace_id": run.workspace_id, - "workspace_code": workspace.workspace_code, - "user_id": run.triggered_by or schedule.created_by, - } - - async def _download_artifact( - self, - *, - bucket_name: str, - object_key: str, - content_hash: str, - ) -> bytes: - # Honor the artifact's actual bucket (P0-3 fix): the artifact may - # live in ``Workspaces.artifact_bucket`` rather than the global - # version bucket the default ``object_store`` is bound to. - store = self._store_for(bucket_name) - content = await store.get(object_key) - if hashlib.sha256(content).hexdigest() != content_hash: - raise ValueError("stable version artifact hash mismatch") - logger.debug( - "artifact downloaded: bucket={} object_key={} bytes={}", - bucket_name, - object_key[-32:], - len(content), - ) - return content - - async def _upload_execution_artifacts( - self, - *, - payload: dict[str, Any], - context: dict[str, Any], - result: ExecutionResult, - ) -> tuple[str | None, str | None, str | None]: - log_id: str | None = None - result_id: str | None = None - upload_error: str | None = None - try: - log_object = await self.storage_client.create_object( - workspace_id=context["workspace_id"], - user_id=context["user_id"], - usage_type="run_log", - file_name=f"{payload['node_run_id']}.log", - content_type="text/plain; charset=utf-8", - content=result.logs, - idempotency_key=f"{payload['node_run_id']}:log", - ) - log_id = log_object["storage_object_id"] - result_object = await self.storage_client.create_object( - workspace_id=context["workspace_id"], - user_id=context["user_id"], - usage_type="run_result", - file_name=result.result_file_name, - content_type=result.result_content_type, - content=result.result, - idempotency_key=f"{payload['node_run_id']}:result", - ) - result_id = result_object["storage_object_id"] - except Exception as exc: - upload_error = f"result upload failed: {exc}"[:2000] - logger.exception("failed to upload node execution artifacts") - if log_id and result_id and not upload_error: - logger.info( - "artifacts uploaded: log_id={} result_id={}", - log_id[-12:], - result_id[-12:], - ) - return log_id, result_id, upload_error - - @staticmethod - async def _start_inbox( - session, - *, - consumer_name: str, - event_id: str, - message_id: str, - ) -> tuple[ConsumerInbox, bool]: - item = await session.get( - ConsumerInbox, - (consumer_name, event_id), - with_for_update=True, - ) - if item is not None and item.process_status == "succeeded": - return item, False - if item is None: - item = ConsumerInbox( - consumer_name=consumer_name, - event_id=event_id, - process_status="processing", - message_id=message_id, - ) - session.add(item) - else: - item.process_status = "processing" - item.message_id = message_id - item.error_message = None - return item, True - - @staticmethod - def _finish_inbox(item: ConsumerInbox) -> None: - item.process_status = "succeeded" - item.processed_at = utcnow() - item.error_message = None - - -__all__ = ["NodeExecutor"] -- 2.54.0 From 7476c27d814285294bfbb98d3ea61b5beb7ba911 Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:52:31 +0800 Subject: [PATCH 23/93] test(schedule): add layer-boundary smoke tests (stage 7) Pin the new five-layer contract so a later refactor can't silently break an import surface or lifecycle: - domain: ExecutionResult defaults, terminal/failed state-set invariants, naive_utc normalization (import-side-effect-free) - infrastructure.storage: SchedulerStorageClient base64 upload via httpx.MockTransport (no live server; base_url required for relative URL) - scheduling: CronScheduler start/close lifecycle, global trigger cleared - application: SchedulerService wires cron + orchestrator + worker, handler dispatch table points at the wired NodeExecutor - execution: schedule.notebook_runner shim re-exports the real main 25 schedule tests green; schedule/pyproject.toml untouched. Co-Authored-By: Claude --- schedule/tests/test_layering.py | 177 ++++++++++++++++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 schedule/tests/test_layering.py diff --git a/schedule/tests/test_layering.py b/schedule/tests/test_layering.py new file mode 100644 index 0000000..d44ef40 --- /dev/null +++ b/schedule/tests/test_layering.py @@ -0,0 +1,177 @@ +"""Layer-boundary smoke tests for the schedule service refactor. + +The 11 flat files under ``schedule/src/schedule/`` were reorganized into five +subpackages (domain / scheduling / application / execution / infrastructure) +with **zero behavior change**. These tests pin the new *boundaries* so a later +refactor can't silently break an import surface or a lifecycle contract: + +- ``domain`` — pure constants / dataclass, import-side-effect-free +- ``infrastructure.storage`` — SchedulerStorageClient uploads via the backend + storage API (httpx MockTransport, no live server) +- ``scheduling`` — CronScheduler start/close lifecycle +- ``application`` — SchedulerService wires cron + orchestrator + worker +- ``execution`` — the ``schedule.notebook_runner`` shim still points at the + real runner (the worker's ``-m`` subprocess string depends on it) + +All mocks / tmp only — no MySQL, no real HTTP. +""" + +from __future__ import annotations + +import json +from datetime import UTC, datetime, timedelta, timezone +from types import SimpleNamespace +from unittest.mock import MagicMock + +import httpx +import pytest + +from schedule.application.service import SchedulerService +from schedule.domain.context import ( + FAILED_NODE_STATES, + TERMINAL_NODE_STATES, + TERMINAL_RUN_STATES, + naive_utc, +) +from schedule.domain.execution import ExecutionResult +from schedule.execution import NodeExecutor +from schedule.infrastructure.storage import SchedulerStorageClient +from schedule.scheduling.orchestrator import DispatchOrchestrator +from schedule.scheduling.scheduler import CronScheduler + + +# ── domain: pure types / constants ──────────────────────────────────────── + + +def test_execution_result_has_sane_defaults() -> None: + result = ExecutionResult( + status="succeeded", + exit_code=0, + logs=b"ok\n", + result=b"{}", + result_file_name="out.json", + result_content_type="application/json", + ) + assert result.status == "succeeded" + assert result.exit_code == 0 + # Optional error fields default to None, not to a sentinel. + assert result.error_code is None + assert result.error_message is None + + +def test_terminal_and_failed_node_state_sets() -> None: + assert isinstance(TERMINAL_NODE_STATES, frozenset) + assert isinstance(FAILED_NODE_STATES, frozenset) + assert "succeeded" in TERMINAL_NODE_STATES + assert "failed" in TERMINAL_NODE_STATES + # Every failed state must also be terminal — otherwise the DAG would + # treat a dead node as still running. + assert FAILED_NODE_STATES.issubset(TERMINAL_NODE_STATES) + assert TERMINAL_RUN_STATES.issubset(TERMINAL_NODE_STATES) + + +def test_naive_utc_normalizes_to_utc_naive() -> None: + # 20:00 +08:00 == 12:00 UTC; the tz must be stripped afterwards. + aware = datetime(2026, 8, 21, 20, 0, tzinfo=timezone(timedelta(hours=8))) + assert naive_utc(aware) == datetime(2026, 8, 21, 12, 0) + assert naive_utc(aware).tzinfo is None + assert naive_utc(None) is None + + +# ── infrastructure: storage client (no live server) ─────────────────────── + + +def test_storage_client_create_object_uploads_base64() -> None: + import base64 + + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/internal/v1/objects" + payload = json.loads(request.content) + assert payload["usage_type"] == "run_log" + assert payload["file_name"] == "run.log" + assert payload["is_immutable"] is True + # The raw bytes must round-trip through base64 in the JSON body. + assert base64.b64decode(payload["content_base64"]) == b"hello\n" + return httpx.Response( + 200, + json={"data": {"storage_object_id": "obj-1", "storage_uri": "s3://x"}}, + ) + + transport = httpx.MockTransport(handler) + + async def upload() -> dict[str, object]: + # base_url is required: a relative path + MockTransport fails to + # normalize the response URL in this httpx version. + async with httpx.AsyncClient( + transport=transport, base_url="http://testserver" + ) as http: + storage = SchedulerStorageClient(http) + return await storage.create_object( + workspace_id="01WS0000000000000000000001", + user_id="01USR0000000000000000000A", + usage_type="run_log", + file_name="run.log", + content_type="text/plain", + content=b"hello\n", + idempotency_key="run:node:1", + ) + + data = __import__("asyncio").run(upload()) + assert data["storage_object_id"] == "obj-1" + + +# ── scheduling: cron lifecycle (no MySQL touched) ────────────────────────── + + +@pytest.mark.asyncio +async def test_cron_scheduler_start_close_lifecycle() -> None: + """start()/close() must not raise and must release the global trigger.""" + scheduler = CronScheduler( + session_factory=MagicMock(), + database_url="sqlite://", + on_trigger=MagicMock(), + ) + scheduler.start() + await scheduler.close() + # close() clears the module-level callback so a stopped service can't + # dispatch persisted cron ticks. + from schedule.scheduling import scheduler as scheduler_module + + assert scheduler_module._ACTIVE_TRIGGER is None + + +# ── application: service assembly wires all three components ─────────────── + + +@pytest.mark.asyncio +@pytest.mark.filterwarnings( + "ignore:coroutine .* was never awaited:RuntimeWarning" +) +async def test_scheduler_service_wires_components_and_lifecycle() -> None: + service = SchedulerService( + session_factory=MagicMock(), + storage_http_client=MagicMock(), + object_store=MagicMock(), + storage_client=MagicMock(), + database_url="sqlite://", + ) + assert isinstance(service.cron, CronScheduler) + assert isinstance(service.worker, NodeExecutor) + assert isinstance(service.orchestrator, DispatchOrchestrator) + # orchestrator's dispatch table must reference the wired worker handler. + # (Bound methods create a fresh object per access, so compare __self__.) + assert service.orchestrator._node_execute_handler.__self__ is service.worker + + await service.start() + await service.close() + assert service.orchestrator._loop_task is None + + +# ── execution: the notebook_runner shim is the real runner ───────────────── + + +def test_notebook_runner_shim_reexports_real_main() -> None: + from schedule.execution.runners import notebook as real + from schedule.notebook_runner import main as shim_main + + assert shim_main is real.main -- 2.54.0 From 80130c6d7756bf9e118a43982d94a9f56fbeaa61 Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:26:27 +0800 Subject: [PATCH 24/93] refactor(schedule): move orchestrator to application/ + review fixes (stage 8) Follow-up to the layered refactor (review-driven): - Move scheduling/orchestrator.py -> application/orchestrator.py (orchestrator is application-level coordination, not a cron-trigger primitive; matches the intended target tree) - Migrate orchestrator re-exports from scheduling/__init__.py to application/__init__.py; scheduling/ now exposes only CronScheduler - Rewrite imports + 5 mock.patch string targets in test_janitor.py and the orchestrator import in test_layering.py - Update docstring refs in application/service.py + execution/worker.py - Add 4 runner smoke tests (test_layering.py): _limited_log under-limit / empty-sentinel / above-MAX_LOG_BYTES truncation; execute_artifact rejects unsupported script_type with ValueError - infrastructure/__init__.py re-exports SchedulerStorageClient so ``from schedule.infrastructure import SchedulerStorageClient`` is a stable top-level surface - CLAUDE.md engineering note: extend the commit trail to 7476c27 and note the stage-8 orchestrator placement Zero behavior change; schedule/pyproject.toml untouched. 29 tests green. Co-Authored-By: Claude --- CLAUDE.md | 2 +- schedule/src/schedule/application/__init__.py | 19 +++++++- .../orchestrator.py | 0 schedule/src/schedule/application/service.py | 4 +- schedule/src/schedule/execution/worker.py | 2 +- .../src/schedule/infrastructure/__init__.py | 4 ++ schedule/src/schedule/scheduling/__init__.py | 23 +++------ schedule/tests/test_janitor.py | 12 ++--- schedule/tests/test_layering.py | 47 ++++++++++++++++++- 9 files changed, 83 insertions(+), 30 deletions(-) rename schedule/src/schedule/{scheduling => application}/orchestrator.py (100%) diff --git a/CLAUDE.md b/CLAUDE.md index a64cd72..e148072 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,7 +78,7 @@ Hard-won lessons. Read the relevant bullet before touching the named area. ### 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 → c89132a). Public class names (`SchedulerService`, `CronScheduler`, `DispatchOrchestrator`, `NodeExecutor`, `SchedulerStorageClient`) and `schedule/pyproject.toml` are unchanged. +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_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. diff --git a/schedule/src/schedule/application/__init__.py b/schedule/src/schedule/application/__init__.py index 19f7d81..37acc75 100644 --- a/schedule/src/schedule/application/__init__.py +++ b/schedule/src/schedule/application/__init__.py @@ -1,9 +1,20 @@ """Application layer — service assembly for the scheduler. -Home of the old flat ``schedule/service.py``: ``SchedulerService`` plus the -``build_object_store`` / ``build_storage_http_client`` factories. +Houses: + +- ``SchedulerService`` plus the ``build_object_store`` / + ``build_storage_http_client`` factories (the old flat ``schedule/service.py``). +- ``DispatchOrchestrator`` and the cron / node event constants (the old + ``schedule/orchestrator.py`` — the outbox-polling / DAG coordinator is + application-level glue, not a cron-trigger primitive). """ +from schedule.application.orchestrator import ( + NODE_EXECUTE_EVENT, + NODE_FINISHED_EVENT, + SCHEDULE_RUN_REQUESTED_EVENT, + DispatchOrchestrator, +) from schedule.application.service import ( SchedulerService, build_object_store, @@ -14,4 +25,8 @@ __all__ = [ "SchedulerService", "build_object_store", "build_storage_http_client", + "DispatchOrchestrator", + "SCHEDULE_RUN_REQUESTED_EVENT", + "NODE_EXECUTE_EVENT", + "NODE_FINISHED_EVENT", ] diff --git a/schedule/src/schedule/scheduling/orchestrator.py b/schedule/src/schedule/application/orchestrator.py similarity index 100% rename from schedule/src/schedule/scheduling/orchestrator.py rename to schedule/src/schedule/application/orchestrator.py diff --git a/schedule/src/schedule/application/service.py b/schedule/src/schedule/application/service.py index 5acfd7e..42768f9 100644 --- a/schedule/src/schedule/application/service.py +++ b/schedule/src/schedule/application/service.py @@ -3,7 +3,7 @@ Composes three single-purpose components into one bootable service: - :class:`schedule.scheduling.scheduler.CronScheduler` — APScheduler + cron sync loop - - :class:`schedule.scheduling.orchestrator.DispatchOrchestrator` — Outbox polling + DAG + - :class:`schedule.application.orchestrator.DispatchOrchestrator` — Outbox polling + DAG - :class:`schedule.execution.worker.NodeExecutor` — node-level execution This module also exposes the factory function ``build_object_store`` @@ -36,7 +36,7 @@ from common.storage import create_storage from loguru import logger from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker -from schedule.scheduling.orchestrator import DispatchOrchestrator +from schedule.application.orchestrator import DispatchOrchestrator from schedule.scheduling.scheduler import CronScheduler from schedule.execution.worker import NodeExecutor diff --git a/schedule/src/schedule/execution/worker.py b/schedule/src/schedule/execution/worker.py index f2cc189..9ddf720 100644 --- a/schedule/src/schedule/execution/worker.py +++ b/schedule/src/schedule/execution/worker.py @@ -1,6 +1,6 @@ """Node-level worker: executes one ``job.node.execute`` event. -The orchestrator (see ``schedule.scheduling.orchestrator``) writes a ``job.node.execute`` +The orchestrator (see ``schedule.application.orchestrator``) writes a ``job.node.execute`` Outbox row with all the metadata needed to run the node (script type, artifact location, timeout, arguments ...). The polling loop picks those up and calls :meth:`NodeExecutor.handle_node_execute`. This module owns the diff --git a/schedule/src/schedule/infrastructure/__init__.py b/schedule/src/schedule/infrastructure/__init__.py index c0062a4..19f3e35 100644 --- a/schedule/src/schedule/infrastructure/__init__.py +++ b/schedule/src/schedule/infrastructure/__init__.py @@ -1 +1,5 @@ """Infrastructure layer — external I/O adapters (storage API).""" + +from schedule.infrastructure.storage import SchedulerStorageClient + +__all__ = ["SchedulerStorageClient"] diff --git a/schedule/src/schedule/scheduling/__init__.py b/schedule/src/schedule/scheduling/__init__.py index a2ec411..8998dff 100644 --- a/schedule/src/schedule/scheduling/__init__.py +++ b/schedule/src/schedule/scheduling/__init__.py @@ -1,22 +1,11 @@ -"""Scheduling layer — cron trigger + DAG orchestration. +"""Scheduling layer — cron trigger. -Home of the old flat ``schedule/scheduler.py`` (``CronScheduler``) and -``schedule/orchestrator.py`` (``DispatchOrchestrator``). Classes moved -byte-identical in the layered refactor; names unchanged. +Home of the old flat ``schedule/scheduler.py`` (``CronScheduler``). +The class moved byte-identical in the layered refactor; name unchanged. +The outbox-polling orchestrator used to live here too, but it is an +application-level coordinator — see ``schedule.application.orchestrator``. """ -from schedule.scheduling.orchestrator import ( - NODE_EXECUTE_EVENT, - NODE_FINISHED_EVENT, - SCHEDULE_RUN_REQUESTED_EVENT, - DispatchOrchestrator, -) from schedule.scheduling.scheduler import CronScheduler -__all__ = [ - "CronScheduler", - "DispatchOrchestrator", - "SCHEDULE_RUN_REQUESTED_EVENT", - "NODE_EXECUTE_EVENT", - "NODE_FINISHED_EVENT", -] +__all__ = ["CronScheduler"] diff --git a/schedule/tests/test_janitor.py b/schedule/tests/test_janitor.py index 386c9c4..e08253f 100644 --- a/schedule/tests/test_janitor.py +++ b/schedule/tests/test_janitor.py @@ -23,7 +23,7 @@ import pytest from common.db.models import ScheduleNodeRuns from common.eventing import utcnow -from schedule.scheduling.orchestrator import NODE_FINISHED_EVENT, DispatchOrchestrator +from schedule.application.orchestrator import NODE_FINISHED_EVENT, DispatchOrchestrator def _make_orchestrator() -> DispatchOrchestrator: @@ -195,7 +195,7 @@ async def test_reap_kills_running_row_past_deadline() -> None: fake_session.add = MagicMock() with patch( - "schedule.scheduling.orchestrator.session_scope", + "schedule.application.orchestrator.session_scope", return_value=_open_session_scope(fake_session), ): killed = await orch._reap_stuck_node_runs() @@ -223,7 +223,7 @@ async def test_reap_skips_healthy_row() -> None: fake_session.add = MagicMock() with patch( - "schedule.scheduling.orchestrator.session_scope", + "schedule.application.orchestrator.session_scope", return_value=_open_session_scope(fake_session), ): killed = await orch._reap_stuck_node_runs() @@ -266,7 +266,7 @@ async def test_reap_processes_multiple_rows_in_one_pass() -> None: fake_session.add = MagicMock() with patch( - "schedule.scheduling.orchestrator.session_scope", + "schedule.application.orchestrator.session_scope", return_value=_open_session_scope(fake_session), ): killed = await orch._reap_stuck_node_runs() @@ -289,7 +289,7 @@ async def test_janitor_loop_propagates_cancellation() -> None: raise asyncio.CancelledError with patch( - "schedule.scheduling.orchestrator.asyncio.sleep", + "schedule.application.orchestrator.asyncio.sleep", side_effect=cancel_on_sleep, ): with pytest.raises(asyncio.CancelledError): @@ -321,7 +321,7 @@ async def test_janitor_loop_continues_after_reap_exception() -> None: raise asyncio.CancelledError with patch( - "schedule.scheduling.orchestrator.asyncio.sleep", + "schedule.application.orchestrator.asyncio.sleep", side_effect=_count_sleeps, ): with pytest.raises(asyncio.CancelledError): diff --git a/schedule/tests/test_layering.py b/schedule/tests/test_layering.py index d44ef40..08dd9b1 100644 --- a/schedule/tests/test_layering.py +++ b/schedule/tests/test_layering.py @@ -36,7 +36,7 @@ from schedule.domain.context import ( from schedule.domain.execution import ExecutionResult from schedule.execution import NodeExecutor from schedule.infrastructure.storage import SchedulerStorageClient -from schedule.scheduling.orchestrator import DispatchOrchestrator +from schedule.application.orchestrator import DispatchOrchestrator from schedule.scheduling.scheduler import CronScheduler @@ -175,3 +175,48 @@ def test_notebook_runner_shim_reexports_real_main() -> None: from schedule.notebook_runner import main as shim_main assert shim_main is real.main + + +# ── execution/runners: stage-4 merge boundary smoke (F4) ────────────────── + + +def test_limited_log_under_limit_returns_encoded_unchanged() -> None: + from schedule.execution.runners import notebook + + assert notebook._limited_log("hello") == b"hello" + + +def test_limited_log_empty_returns_sentinel() -> None: + from schedule.execution.runners import notebook + + assert notebook._limited_log("") == b"execution produced no console output\n" + + +def test_limited_log_above_max_bytes_truncates_with_suffix() -> None: + from schedule.execution.runners import notebook + + sentinel = b"\n[log truncated by scheduler worker]\n" + overflowing = "x" * (notebook.MAX_LOG_BYTES + 1) + truncated = notebook._limited_log(overflowing) + assert len(truncated) == notebook.MAX_LOG_BYTES + assert truncated.endswith(sentinel) + + +@pytest.mark.asyncio +async def test_execute_artifact_unsupported_script_type_raises() -> None: + """``script_type`` outside ``{"notebook", "python"}`` must raise — + otherwise the worker would silently drop a malformed run into the + queue and the node would hang. + """ + from schedule.execution.runners import notebook + + with pytest.raises(ValueError, match="unsupported script_type"): + await notebook.execute_artifact( + source=b"print('hi')\n", + run_id="01RUN0000000000000000000A", + node_run_id="01NODE000000000000000000A", + script_type="bogus", + artifact_path="x.py", + arguments=[], + timeout_seconds=30, + ) -- 2.54.0 From bca239ed4b1bdfddc3142c8c62faae2a80ea0ca1 Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:32:04 +0800 Subject: [PATCH 25/93] refactor(backend): split into api/ schemas/ services/ clients/ layers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 4-phase restructuring of the previously flat backend/ package. Each phase lands as a single squash commit so future bisects stay readable per phase if needed. ## Phase 1 — move + shim (location-only, zero behavior change) * git mv 14 files into api/ schemas/ services/ clients/ subpackages (history preserved via RM/R renames) * New files: api/{admin,auth,dependencies,jupyter,platform,resources, scripts,storage}.py + api/schedules/{schedules,runs}.py * New files: schemas/{auth,common,jupyter,platform,resources, schedules,scripts}.py * New files: clients/{rclone,runtime,scheduler}.py * Old paths kept as 1-line `from backend. import *` shims so tests/main.py/importers kept working untouched * schemas/__init__.py now re-exports from backend.schemas. ## Phase 2 — APIRouter prefix consolidation * Every APIRouter() now carries its prefix (e.g. prefix="/api/v1/auth") and decorators are stripped of the redundant path prefix * URL paths exposed to the frontend are byte-identical to before * Affected: api/{auth,jupyter,admin,platform,resources,scripts, storage}.py + api/schedules/{schedules,runs}.py ## Phase 3 — first service-layer extraction * backend.services.schedules.validate_dag moved out of api/ (pure DAG validator, no Request/BackgroundTasks/DB) * api/schedules/schedules.py now re-exports the symbol so existing 4 callsites keep working unchanged * Added backend/tests/test_validate_dag.py: 8 unit tests covering DAG_EMPTY, linear chain, diamond, cycle, self-edge, duplicate edge, orphan edge, multi-root ordering ## Phase 4 — delete shims + unify test imports * Removed 14 flat shim files + schemas/__init__.py * Migrated 5 test files (32 import sites) to new paths: backend.scripts.* → backend.api.scripts.* backend.resources.* → backend.api.resources.* backend.jupyter.* → backend.api.jupyter.* backend.runtime_client.* → backend.clients.runtime.* backend.schemas.UpdateScriptRequest → backend.schemas.scripts.* * audit.py kept at backend.audit (main.py references it; not a shim, real code) ## Final structure backend/src/backend/ main.py, audit.py, __init__.py api/ (10 files: routes + 2 subpackage) schemas/ (7 files: Pydantic contracts) services/ (storage + schedules) clients/ (rclone, runtime, scheduler) ## Verification * uv run python -m compileall backend/src backend/tests — clean * uv run --package backend pytest backend/tests -q — 122 passed (114 → 114 → 122 → 122 across phases) * grep -r 'from backend\.\(scripts\|resources\|...\)' backend/ — 0 hits * git blame --follow still traces file origins through the renames 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- backend/src/backend/api/__init__.py | 0 backend/src/backend/{ => api}/admin.py | 2 +- backend/src/backend/{ => api}/auth.py | 12 +- backend/src/backend/{ => api}/dependencies.py | 0 backend/src/backend/{ => api}/jupyter.py | 8 +- backend/src/backend/{ => api}/platform.py | 6 +- backend/src/backend/{ => api}/resources.py | 8 +- backend/src/backend/api/schedules/__init__.py | 0 .../schedules/runs.py} | 18 +- .../backend/{ => api/schedules}/schedules.py | 154 +++--------------- backend/src/backend/{ => api}/scripts.py | 48 +++--- .../{storage_api.py => api/storage.py} | 10 +- backend/src/backend/clients/__init__.py | 0 .../rclone.py} | 0 .../{runtime_client.py => clients/runtime.py} | 2 +- .../scheduler.py} | 0 backend/src/backend/main.py | 22 +-- backend/src/backend/schemas/auth.py | 1 + backend/src/backend/schemas/common.py | 12 ++ backend/src/backend/schemas/jupyter.py | 1 + backend/src/backend/schemas/platform.py | 1 + .../{schemas.py => schemas/resources.py} | 35 ---- .../schedules.py} | 0 backend/src/backend/schemas/scripts.py | 35 ++++ backend/src/backend/services/jupyter.py | 1 + backend/src/backend/services/resources.py | 1 + backend/src/backend/services/schedules.py | 152 +++++++++++++++++ backend/src/backend/services/scripts.py | 1 + backend/src/backend/services/storage.py | 14 +- backend/tests/test_count_scripts.py | 4 +- backend/tests/test_jupyter_auth_cache.py | 6 +- .../tests/test_list_scripts_parent_path.py | 14 +- backend/tests/test_resources.py | 22 +-- .../tests/test_runtime_client_directories.py | 2 +- backend/tests/test_scripts.py | 34 ++-- backend/tests/test_validate_dag.py | 143 ++++++++++++++++ 36 files changed, 487 insertions(+), 282 deletions(-) create mode 100644 backend/src/backend/api/__init__.py rename backend/src/backend/{ => api}/admin.py (99%) rename backend/src/backend/{ => api}/auth.py (97%) rename backend/src/backend/{ => api}/dependencies.py (100%) rename backend/src/backend/{ => api}/jupyter.py (97%) rename backend/src/backend/{ => api}/platform.py (99%) rename backend/src/backend/{ => api}/resources.py (99%) create mode 100644 backend/src/backend/api/schedules/__init__.py rename backend/src/backend/{schedule_runs.py => api/schedules/runs.py} (96%) rename backend/src/backend/{ => api/schedules}/schedules.py (88%) rename backend/src/backend/{ => api}/scripts.py (98%) rename backend/src/backend/{storage_api.py => api/storage.py} (98%) create mode 100644 backend/src/backend/clients/__init__.py rename backend/src/backend/{rclone_rc_client.py => clients/rclone.py} (100%) rename backend/src/backend/{runtime_client.py => clients/runtime.py} (99%) rename backend/src/backend/{schedule_client.py => clients/scheduler.py} (100%) create mode 100644 backend/src/backend/schemas/auth.py create mode 100644 backend/src/backend/schemas/common.py create mode 100644 backend/src/backend/schemas/jupyter.py create mode 100644 backend/src/backend/schemas/platform.py rename backend/src/backend/{schemas.py => schemas/resources.py} (62%) rename backend/src/backend/{schedule_schemas.py => schemas/schedules.py} (100%) create mode 100644 backend/src/backend/schemas/scripts.py create mode 100644 backend/src/backend/services/jupyter.py create mode 100644 backend/src/backend/services/resources.py create mode 100644 backend/src/backend/services/schedules.py create mode 100644 backend/src/backend/services/scripts.py create mode 100644 backend/tests/test_validate_dag.py diff --git a/backend/src/backend/api/__init__.py b/backend/src/backend/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/backend/admin.py b/backend/src/backend/api/admin.py similarity index 99% rename from backend/src/backend/admin.py rename to backend/src/backend/api/admin.py index 73628ee..eb8a1af 100644 --- a/backend/src/backend/admin.py +++ b/backend/src/backend/api/admin.py @@ -16,7 +16,7 @@ from pydantic import BaseModel, ConfigDict, Field from sqlalchemy import delete, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession -from backend.dependencies import ( +from backend.api.dependencies import ( RequestContext, database_session, request_context, diff --git a/backend/src/backend/auth.py b/backend/src/backend/api/auth.py similarity index 97% rename from backend/src/backend/auth.py rename to backend/src/backend/api/auth.py index d23ebab..4870385 100644 --- a/backend/src/backend/auth.py +++ b/backend/src/backend/api/auth.py @@ -9,7 +9,7 @@ Cookie+JWT authentication endpoints. The user-facing flow is: 1. POST /api/v1/auth/login — verify password, set HttpOnly cookie 2. every other /api/ request reads the cookie via - ``backend.dependencies.request_context`` + ``backend.api.dependencies.request_context`` 3. POST /api/v1/auth/logout — clear the cookie 4. GET /api/v1/auth/me — return the current user @@ -32,9 +32,9 @@ from fastapi import APIRouter, Depends, HTTPException, Request, Response, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from backend.dependencies import database_session, load_user_permissions +from backend.api.dependencies import database_session, load_user_permissions -router = APIRouter(tags=["auth"]) +router = APIRouter(prefix="/api/v1/auth", tags=["auth"]) # Cookie 配置:生产环境走 HTTPS 时应设置 Secure;本地 HTTP 开发环境会根据 # 实际请求协议决定是否设置,避免浏览器因 Secure Cookie 而丢弃登录状态。 @@ -96,7 +96,7 @@ def _workspace_payload( # 校验账号密码,设置登录 Cookie,并返回用户可进入的工作区列表。 -@router.post("/api/v1/auth/login") +@router.post("/login") async def login( request: Request, response: Response, @@ -198,7 +198,7 @@ async def login( # 清除浏览器 Cookie,使当前会话立即失效。 -@router.post("/api/v1/auth/logout") +@router.post("/logout") async def logout(response: Response) -> dict[str, Any]: """Clear the session cookie. Idempotent.""" _clear_session_cookie(response) @@ -210,7 +210,7 @@ async def logout(response: Response) -> dict[str, Any]: # 返回当前登录用户、权限和可访问工作区,用于前端初始化登录态。 -@router.get("/api/v1/auth/me") +@router.get("/me") async def me( request: Request, session: AsyncSession = Depends(database_session), diff --git a/backend/src/backend/dependencies.py b/backend/src/backend/api/dependencies.py similarity index 100% rename from backend/src/backend/dependencies.py rename to backend/src/backend/api/dependencies.py diff --git a/backend/src/backend/jupyter.py b/backend/src/backend/api/jupyter.py similarity index 97% rename from backend/src/backend/jupyter.py rename to backend/src/backend/api/jupyter.py index 489bd96..c5147b6 100644 --- a/backend/src/backend/jupyter.py +++ b/backend/src/backend/api/jupyter.py @@ -19,8 +19,8 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from backend.dependencies import database_session -from backend.runtime_client import RuntimeClientError +from backend.api.dependencies import database_session +from backend.clients.runtime import RuntimeClientError # --------------------------------------------------------------------------- # (workspace_id, user_id) -> (expires_at_monotonic, payload) 的 5 秒验证结果缓存。 @@ -42,7 +42,7 @@ _JUPYTER_AUTH_CACHE_LOCK = threading.Lock() _JUPYTER_AUTH_CACHE_TTL_SECONDS = 5.0 -router = APIRouter(tags=["jupyter"]) +router = APIRouter(prefix="/api/v1/auth", tags=["jupyter"]) security = HTTPBearer(auto_error=False) @@ -131,7 +131,7 @@ def _jupyter_auth_cache_put(workspace_id: str, user_id: str, payload: dict[str, # 供 Nginx auth_request 调用:验证访问 Jupyter 的身份、成员关系和文件锁, # 再返回应转发到的 Jupyter 地址及内部令牌。 -@router.get("/api/v1/auth/jupyter") +@router.get("/jupyter") async def verify_jupyter_access( request: Request, response: Response, diff --git a/backend/src/backend/platform.py b/backend/src/backend/api/platform.py similarity index 99% rename from backend/src/backend/platform.py rename to backend/src/backend/api/platform.py index 3af6959..f368592 100644 --- a/backend/src/backend/platform.py +++ b/backend/src/backend/api/platform.py @@ -9,7 +9,7 @@ System-admin (platform-scope) endpoints for workspace & membership management. All routes under ``/api/v1/platform/*`` are gated by :func:`system_admin_context`, which requires the requester to hold a ``Users.platform_role_id`` pointing to a ``Roles`` row whose -``role_code == 'admin'``. Unlike ``backend.dependencies.request_context``, +``role_code == 'admin'``. Unlike ``backend.api.dependencies.request_context``, this dependency does NOT require an active workspace membership — system admins can manage workspaces before/without being a member of any. @@ -86,7 +86,7 @@ from pydantic import BaseModel, ConfigDict, Field from sqlalchemy import func, insert, or_, select, update from sqlalchemy.ext.asyncio import AsyncSession -from backend.dependencies import current_user, database_session +from backend.api.dependencies import current_user, database_session router = APIRouter(prefix="/api/v1/platform", tags=["platform"]) @@ -216,7 +216,7 @@ async def system_admin_context( """Resolve the requester as a system admin. Steps: - 1. Reuse :func:`backend.dependencies.current_user` to validate the JWT + 1. Reuse :func:`backend.api.dependencies.current_user` to validate the JWT cookie and fetch the active ``Users`` row (raises 401 on failure). 2. Require ``Users.platform_role_id`` to point to a row whose ``role_code == 'admin'`` — anything else is 403. diff --git a/backend/src/backend/resources.py b/backend/src/backend/api/resources.py similarity index 99% rename from backend/src/backend/resources.py rename to backend/src/backend/api/resources.py index 1ae4125..b9b855e 100644 --- a/backend/src/backend/resources.py +++ b/backend/src/backend/api/resources.py @@ -23,16 +23,16 @@ from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, s from sqlalchemy import func, or_, select from sqlalchemy.ext.asyncio import AsyncSession -from backend.dependencies import ( +from backend.api.dependencies import ( RequestContext, database_session, request_context, ) -from backend.scripts import _escape_like_pattern, normalize_user_path -from backend.schemas import ( +from backend.api.scripts import _escape_like_pattern, normalize_user_path +from backend.schemas.common import DownloadUrlRequest +from backend.schemas.resources import ( CompleteResourceUploadRequest, CreateResourceUploadRequest, - DownloadUrlRequest, ResourceRelativePathRequest, ) from backend.services.storage import ( diff --git a/backend/src/backend/api/schedules/__init__.py b/backend/src/backend/api/schedules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/backend/schedule_runs.py b/backend/src/backend/api/schedules/runs.py similarity index 96% rename from backend/src/backend/schedule_runs.py rename to backend/src/backend/api/schedules/runs.py index 43ff14f..873b450 100644 --- a/backend/src/backend/schedule_runs.py +++ b/backend/src/backend/api/schedules/runs.py @@ -41,13 +41,13 @@ from pydantic import Field from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from backend.dependencies import ( +from backend.api.dependencies import ( RequestContext, database_session, request_context, ) -router = APIRouter(tags=["schedule-runs"]) +router = APIRouter(prefix="/api/v1", tags=["schedule-runs"]) RunStatus = Literal[ "queued", "running", @@ -238,7 +238,7 @@ async def _artifact_bytes( # 立即触发一次调度:写入运行记录和 Outbox,由 schedule 容器异步接手执行。 @router.post( - "/api/v1/schedules/{schedule_id}/run", + "/schedules/{schedule_id}/run", status_code=status.HTTP_202_ACCEPTED, ) async def run_schedule_now( @@ -288,7 +288,7 @@ async def run_schedule_now( # 按调度或状态筛选运行历史,供前端运行记录列表展示。 -@router.get("/api/v1/schedule-runs") +@router.get("/schedule-runs") async def list_schedule_runs( schedule_id: str | None = Query(default=None), run_status: RunStatus | None = Query(default=None, alias="status"), @@ -318,7 +318,7 @@ async def list_schedule_runs( # 查询一次运行的详情,包括每个节点的执行状态。 -@router.get("/api/v1/schedule-runs/{run_id}") +@router.get("/schedule-runs/{run_id}") async def get_schedule_run( run_id: str, context: RequestContext = Depends(request_context), @@ -334,7 +334,7 @@ async def get_schedule_run( # 返回某个节点运行关联的日志/结果产物元数据及可访问地址。 @router.get( - "/api/v1/schedule-runs/{run_id}/node-runs/{node_run_id}/artifacts" + "/schedule-runs/{run_id}/node-runs/{node_run_id}/artifacts" ) async def get_schedule_node_run_artifacts( run_id: str, @@ -355,7 +355,7 @@ async def get_schedule_node_run_artifacts( context=context, session=session, ) - base_path = f"/api/v1/schedule-runs/{run_id}/node-runs/{node_run_id}" + base_path = f"/schedule-runs/{run_id}/node-runs/{node_run_id}" workspace_query = f"workspace_id={context.workspace.workspace_id}" return { "request_id": context.request_id, @@ -377,7 +377,7 @@ async def get_schedule_node_run_artifacts( # 读取节点运行日志正文,通常由前端日志面板按需调用。 @router.get( - "/api/v1/schedule-runs/{run_id}/node-runs/{node_run_id}/logs" + "/schedule-runs/{run_id}/node-runs/{node_run_id}/logs" ) async def read_schedule_node_run_logs( run_id: str, @@ -404,7 +404,7 @@ async def read_schedule_node_run_logs( # 为节点运行结果生成下载响应或重定向地址。 @router.get( - "/api/v1/schedule-runs/{run_id}/node-runs/{node_run_id}/result" + "/schedule-runs/{run_id}/node-runs/{node_run_id}/result" ) async def download_schedule_node_run_result( run_id: str, diff --git a/backend/src/backend/schedules.py b/backend/src/backend/api/schedules/schedules.py similarity index 88% rename from backend/src/backend/schedules.py rename to backend/src/backend/api/schedules/schedules.py index bfe6cc7..dcd91c1 100644 --- a/backend/src/backend/schedules.py +++ b/backend/src/backend/api/schedules/schedules.py @@ -7,7 +7,6 @@ from __future__ import annotations -import heapq from datetime import UTC, datetime from decimal import Decimal from typing import Any @@ -29,12 +28,12 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request, status from sqlalchemy import delete, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession -from backend.dependencies import ( +from backend.api.dependencies import ( RequestContext, database_session, request_context, ) -from backend.schedule_schemas import ( +from backend.schemas.schedules import ( CreateScheduleEdgeRequest, CreateScheduleNodeRequest, CreateScheduleRequest, @@ -47,7 +46,7 @@ from backend.schedule_schemas import ( ) from backend.services.storage import soft_delete_object -router = APIRouter(tags=["schedules"]) +router = APIRouter(prefix="/api/v1", tags=["schedules"]) _ACTIVE_RUN_STATUSES = ("queued", "running") @@ -239,118 +238,11 @@ def edge_payload(item: ScheduleEdges) -> dict[str, Any]: } -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, - } +# validate_dag is implemented in backend.services.schedules so it can be +# unit-tested without spinning up FastAPI. Re-exported here for the four +# internal callsites and for any external callers that still import it +# from this module. +from backend.services.schedules import validate_dag # noqa: F401 async def schedule_row( @@ -521,7 +413,7 @@ async def _require_valid_when_enabled( # 根据 Cron 表达式预览未来触发时间,不会保存或执行任务。 -@router.post("/api/v1/cron/preview") +@router.post("/cron/preview") async def preview_cron( payload: CronPreviewRequest, context: RequestContext = Depends(request_context), @@ -539,7 +431,7 @@ async def preview_cron( # 列出调度产生的可展示版本/产物,供前端结果面板使用。 -@router.get("/api/v1/schedule-artifacts") +@router.get("/schedule-artifacts") async def list_schedule_artifacts( limit: int = Query(default=100, ge=1, le=500), context: RequestContext = Depends(request_context), @@ -589,7 +481,7 @@ async def list_schedule_artifacts( # 列出当前工作区的调度定义及其节点、边数量等摘要信息。 -@router.get("/api/v1/schedules") +@router.get("/schedules") async def list_schedules( context: RequestContext = Depends(request_context), session: AsyncSession = Depends(database_session), @@ -652,7 +544,7 @@ async def list_schedules( # 创建新的 DAG 调度定义;初始状态不包含节点和边。 @router.post( - "/api/v1/schedules", + "/schedules", status_code=status.HTTP_201_CREATED, ) async def create_schedule( @@ -704,7 +596,7 @@ async def create_schedule( # 获取一个调度的完整画布数据,包括节点、边和当前工作流版本。 -@router.get("/api/v1/schedules/{schedule_id}") +@router.get("/schedules/{schedule_id}") async def get_schedule( schedule_id: str, context: RequestContext = Depends(request_context), @@ -719,8 +611,8 @@ async def get_schedule( # 更新调度基本属性,如名称、Cron、时区、是否启用和并发策略。 -@router.put("/api/v1/schedules/{schedule_id}") -@router.patch("/api/v1/schedules/{schedule_id}") +@router.put("/schedules/{schedule_id}") +@router.patch("/schedules/{schedule_id}") async def update_schedule( schedule_id: str, payload: UpdateScheduleRequest, @@ -784,7 +676,7 @@ async def update_schedule( # 删除调度定义;请求携带 workflow_version 以避免误删他人刚修改的画布。 -@router.delete("/api/v1/schedules/{schedule_id}") +@router.delete("/schedules/{schedule_id}") async def delete_schedule( schedule_id: str, payload: WorkflowVersionRequest, @@ -876,7 +768,7 @@ async def delete_schedule( # 向调度画布新增一个执行节点,并关联已发布的脚本版本。 @router.post( - "/api/v1/schedules/{schedule_id}/nodes", + "/schedules/{schedule_id}/nodes", status_code=status.HTTP_201_CREATED, ) async def create_schedule_node( @@ -931,7 +823,7 @@ async def create_schedule_node( # 更新节点名称、执行参数、超时、重试和画布坐标等配置。 -@router.put("/api/v1/schedules/{schedule_id}/nodes/{node_id}") +@router.put("/schedules/{schedule_id}/nodes/{node_id}") async def update_schedule_node( schedule_id: str, node_id: str, @@ -987,7 +879,7 @@ async def update_schedule_node( # 从调度画布删除节点,并同步清理关联边。 -@router.delete("/api/v1/schedules/{schedule_id}/nodes/{node_id}") +@router.delete("/schedules/{schedule_id}/nodes/{node_id}") async def delete_schedule_node( schedule_id: str, node_id: str, @@ -1072,7 +964,7 @@ async def delete_schedule_node( # 在两个节点之间新增依赖边,表示目标节点必须等待源节点完成。 @router.post( - "/api/v1/schedules/{schedule_id}/edges", + "/schedules/{schedule_id}/edges", status_code=status.HTTP_201_CREATED, ) async def create_schedule_edge( @@ -1148,7 +1040,7 @@ async def create_schedule_edge( # 修改一条依赖边的条件表达式或其他可编辑字段。 -@router.put("/api/v1/schedules/{schedule_id}/edges/{edge_id}") +@router.put("/schedules/{schedule_id}/edges/{edge_id}") async def update_schedule_edge( schedule_id: str, edge_id: str, @@ -1183,7 +1075,7 @@ async def update_schedule_edge( # 删除节点之间的依赖关系,不会删除节点本身。 -@router.delete("/api/v1/schedules/{schedule_id}/edges/{edge_id}") +@router.delete("/schedules/{schedule_id}/edges/{edge_id}") async def delete_schedule_edge( schedule_id: str, edge_id: str, @@ -1218,7 +1110,7 @@ async def delete_schedule_edge( # 校验画布是否为可执行 DAG,例如是否存在环、孤立节点或无效版本。 -@router.post("/api/v1/schedules/{schedule_id}/validate") +@router.post("/schedules/{schedule_id}/validate") async def validate_schedule( schedule_id: str, context: RequestContext = Depends(request_context), diff --git a/backend/src/backend/scripts.py b/backend/src/backend/api/scripts.py similarity index 98% rename from backend/src/backend/scripts.py rename to backend/src/backend/api/scripts.py index 6218779..2ea5979 100644 --- a/backend/src/backend/scripts.py +++ b/backend/src/backend/api/scripts.py @@ -39,16 +39,16 @@ from loguru import logger from sqlalchemy import func, or_, select from sqlalchemy.ext.asyncio import AsyncSession -from backend.dependencies import ( +from backend.api.dependencies import ( RequestContext, database_session, request_context, ) -from backend.runtime_client import RuntimeClientError -from backend.schemas import ( +from backend.clients.runtime import RuntimeClientError +from backend.schemas.common import DownloadUrlRequest +from backend.schemas.scripts import ( CreateScriptRequest, CreateWorkspaceDirectoryRequest, - DownloadUrlRequest, LockScriptRequest, PublishVersionRequest, UpdateScriptRequest, @@ -60,7 +60,7 @@ from backend.services.storage import ( soft_delete_object, ) -router = APIRouter(tags=["scripts"]) +router = APIRouter(prefix="/api/v1", tags=["scripts"]) def normalize_user_path(value: str, *, allow_empty: bool = True) -> str: @@ -640,7 +640,7 @@ async def create_script_record( # 新建空的 Python 脚本或 Notebook:同时创建数据库元数据和初始文件内容。 -@router.post("/api/v1/scripts", status_code=status.HTTP_201_CREATED) +@router.post("/scripts", status_code=status.HTTP_201_CREATED) async def create_script( payload: CreateScriptRequest, request: Request, @@ -674,7 +674,7 @@ async def create_script( # 上传现有脚本文件:校验文件名/类型后写入存储,并建立 Scripts 记录。 @router.post( - "/api/v1/scripts/upload", + "/scripts/upload", status_code=status.HTTP_201_CREATED, ) async def upload_script( @@ -734,7 +734,7 @@ async def upload_script( # 返回旧版一次性完整目录树,保留给兼容旧前端;新页面通常按目录懒加载。 -@router.get("/api/v1/workspace-tree") +@router.get("/workspace-tree") async def get_workspace_tree( context: RequestContext = Depends(request_context), session: AsyncSession = Depends(database_session), @@ -806,7 +806,7 @@ async def get_workspace_tree( # 查询某个目录下的直接子目录,供前端按需展开工作区树。 -@router.get("/api/v1/workspace-directories") +@router.get("/workspace-directories") async def list_workspace_directories( parent_path: str = Query(default=""), context: RequestContext = Depends(request_context), @@ -881,7 +881,7 @@ async def list_workspace_directories( # 在工作区内创建逻辑目录;目录信息由脚本相对路径推导,不对应容器本地文件夹。 @router.post( - "/api/v1/workspace-directories", + "/workspace-directories", status_code=status.HTTP_201_CREATED, ) async def create_workspace_directory( @@ -1037,7 +1037,7 @@ async def create_workspace_directory( # 删除逻辑目录及其下属脚本记录;实际文件按存储层的软删除规则处理。 -@router.delete("/api/v1/workspace-directories") +@router.delete("/workspace-directories") async def delete_workspace_directory( request: Request, path: str = Query(min_length=1, max_length=1024), @@ -1133,7 +1133,7 @@ async def delete_workspace_directory( # ``STRAIGHT_JOIN`` 或给 ``storage_objects.relative_path`` 加 prefix # 索引(基线迁移里有 ``idx_storage_workspace_relative_path`` 但 ORM # 模型未声明,不在此修复范围)。 -@router.get("/api/v1/scripts") +@router.get("/scripts") async def list_scripts( parent_path: str = Query(default="", max_length=1024), context: RequestContext = Depends(request_context), @@ -1198,7 +1198,7 @@ async def list_scripts( # - Workspace-wide ``LIKE 'workspace/%'`` prefix (no embedded user_id) so # counts span every owner's subtree. # - No NOT-LIKE filter because the count wants descendants too. -@router.get("/api/v1/scripts/count") +@router.get("/scripts/count") async def count_scripts( context: RequestContext = Depends(request_context), session: AsyncSession = Depends(database_session), @@ -1233,7 +1233,7 @@ async def count_scripts( # 读取脚本正文或 Notebook JSON;编辑器打开文件时调用此接口。 -@router.get("/api/v1/scripts/{script_id}/content") +@router.get("/scripts/{script_id}/content") async def get_script_content( script_id: str, request: Request, @@ -1284,7 +1284,7 @@ async def get_script_content( # 查询单个脚本的元数据,例如类型、路径、锁状态和拥有者。 -@router.get("/api/v1/scripts/{script_id}") +@router.get("/scripts/{script_id}") async def get_script( script_id: str, context: RequestContext = Depends(request_context), @@ -1303,7 +1303,7 @@ async def get_script( # 保存编辑器提交的新内容;会校验工作区权限和文件编辑锁。 -@router.put("/api/v1/scripts/{script_id}") +@router.put("/scripts/{script_id}") async def update_script( script_id: str, payload: UpdateScriptRequest, @@ -1390,7 +1390,7 @@ async def update_script( # 修改脚本锁定状态,避免其他用户同时编辑同一份文件。 -@router.patch("/api/v1/scripts/{script_id}/lock") +@router.patch("/scripts/{script_id}/lock") async def set_script_lock( script_id: str, payload: LockScriptRequest, @@ -1433,7 +1433,7 @@ async def set_script_lock( # 软删除脚本;元数据标记删除,历史版本可按规则继续保留。 -@router.delete("/api/v1/scripts/{script_id}") +@router.delete("/scripts/{script_id}") async def delete_script( script_id: str, request: Request, @@ -1480,7 +1480,7 @@ async def delete_script( # 将当前脚本内容发布为不可变版本,供调度节点和回溯下载使用。 @router.post( - "/api/v1/scripts/{script_id}/versions", + "/scripts/{script_id}/versions", status_code=status.HTTP_201_CREATED, ) async def publish_version( @@ -1601,7 +1601,7 @@ async def publish_version( # 列出某脚本已经发布的历史版本。 -@router.get("/api/v1/scripts/{script_id}/versions") +@router.get("/scripts/{script_id}/versions") async def list_versions( script_id: str, context: RequestContext = Depends(request_context), @@ -1623,7 +1623,7 @@ async def list_versions( # 读取脚本最近一次发布的版本;未发布时返回空结果。 -@router.get("/api/v1/scripts/{script_id}/latest-version") +@router.get("/scripts/{script_id}/latest-version") async def latest_version( script_id: str, context: RequestContext = Depends(request_context), @@ -1675,7 +1675,7 @@ async def latest_version( # 查询单个发布版本的元数据和关联脚本信息。 -@router.get("/api/v1/versions/{versions_id}") +@router.get("/versions/{versions_id}") async def get_version( versions_id: str, context: RequestContext = Depends(request_context), @@ -1692,7 +1692,7 @@ async def get_version( # 隐藏/删除一个发布版本;是否保留实际产物由存储删除策略决定。 -@router.delete("/api/v1/versions/{versions_id}") +@router.delete("/versions/{versions_id}") async def delete_version( versions_id: str, context: RequestContext = Depends(request_context), @@ -1740,7 +1740,7 @@ async def delete_version( # 为某个版本产物生成带时效的下载地址,而非把大文件直接经 API 返回。 -@router.post("/api/v1/versions/{versions_id}/download-url") +@router.post("/versions/{versions_id}/download-url") async def version_download_url( versions_id: str, payload: DownloadUrlRequest, diff --git a/backend/src/backend/storage_api.py b/backend/src/backend/api/storage.py similarity index 98% rename from backend/src/backend/storage_api.py rename to backend/src/backend/api/storage.py index 843c5c2..051d729 100644 --- a/backend/src/backend/storage_api.py +++ b/backend/src/backend/api/storage.py @@ -143,7 +143,7 @@ def storage_payload(item: StorageObjects) -> dict[str, Any]: # 内部路由由 main.py 以 /internal 前缀挂载。数据库引擎、Session 工厂和对象 # 存储实例均在应用生命周期中创建;本模块只定义路由和供 services.storage 复用的 # 存储辅助函数(如 storage_payload、resolve_bucket、BUCKET_FOR_USAGE)。 -router = APIRouter(tags=["internal-storage"]) +router = APIRouter(prefix="/v1", tags=["internal-storage"]) async def database_session(request: Request) -> AsyncIterator[AsyncSession]: @@ -253,7 +253,7 @@ async def create_upload_record( # Two-step server-proxied upload: the caller PUTs the raw bytes to # ``upload_path`` after this response, which routes through - # ``backend.resources.upload_bytes_to_session`` (the canonical helper + # ``backend.api.resources.upload_bytes_to_session`` (the canonical helper # in ``services.storage``). return { "upload_id": upload.upload_id, @@ -286,7 +286,7 @@ def _public_base_url(request: Request) -> str: @router.post( - "/v1/objects", + "/objects", dependencies=[Depends(require_internal_service)], ) async def create_server_object( @@ -304,7 +304,7 @@ async def create_server_object( return await create_server_object_payload(payload, request, session) -@router.post("/v1/objects/{storage_object_id}/restore") +@router.post("/objects/{storage_object_id}/restore") async def restore_object( storage_object_id: str, request: Request, @@ -371,7 +371,7 @@ async def restore_object( # 管理动作:永久清理超过保留期限或指定的回收站对象。 -@router.post("/v1/admin/trash/purge") +@router.post("/admin/trash/purge") async def purge_trash_object( payload: dict[str, Any], request: Request, diff --git a/backend/src/backend/clients/__init__.py b/backend/src/backend/clients/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/backend/rclone_rc_client.py b/backend/src/backend/clients/rclone.py similarity index 100% rename from backend/src/backend/rclone_rc_client.py rename to backend/src/backend/clients/rclone.py diff --git a/backend/src/backend/runtime_client.py b/backend/src/backend/clients/runtime.py similarity index 99% rename from backend/src/backend/runtime_client.py rename to backend/src/backend/clients/runtime.py index 1ca859b..941439d 100644 --- a/backend/src/backend/runtime_client.py +++ b/backend/src/backend/clients/runtime.py @@ -94,7 +94,7 @@ class RuntimeClient: """Return a running workspace descriptor, starting it if needed. Mirrors the lazy-start pattern used by - :func:`backend.jupyter.verify_jupyter_access`: try ``get`` + :func:`backend.api.jupyter.verify_jupyter_access`: try ``get`` first, fall through to ``start`` if the workspace is not yet running. Bumps ``last_used_at`` via the runtime registry on the way in, so the idle reaper is satisfied for the duration of the diff --git a/backend/src/backend/schedule_client.py b/backend/src/backend/clients/scheduler.py similarity index 100% rename from backend/src/backend/schedule_client.py rename to backend/src/backend/clients/scheduler.py diff --git a/backend/src/backend/main.py b/backend/src/backend/main.py index af2f776..e6a938c 100644 --- a/backend/src/backend/main.py +++ b/backend/src/backend/main.py @@ -34,17 +34,17 @@ from fastapi.responses import JSONResponse from loguru import logger from backend.audit import configure_audit_logging -from backend.admin import router as admin_router -from backend.auth import router as auth_router -from backend.jupyter import router as jupyter_router -from backend.platform import router as platform_router -from backend.rclone_rc_client import RcloneRCClient -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.schedules import router as schedules_router -from backend.scripts import router as scripts_router -from backend.storage_api import router as storage_api_router +from backend.api.admin import router as admin_router +from backend.api.auth import router as auth_router +from backend.api.jupyter import router as jupyter_router +from backend.api.platform import router as platform_router +from backend.api.resources import router as resources_router +from backend.api.scripts import router as scripts_router +from backend.api.schedules.runs import router as schedule_runs_router +from backend.api.schedules.schedules import router as schedules_router +from backend.api.storage import router as storage_api_router +from backend.clients.rclone import RcloneRCClient +from backend.clients.runtime import RuntimeClient configure_logging(settings.log_level) configure_audit_logging( diff --git a/backend/src/backend/schemas/auth.py b/backend/src/backend/schemas/auth.py new file mode 100644 index 0000000..e3c2488 --- /dev/null +++ b/backend/src/backend/schemas/auth.py @@ -0,0 +1 @@ +"""Reserved for stage-3 extraction. Currently empty.""" diff --git a/backend/src/backend/schemas/common.py b/backend/src/backend/schemas/common.py new file mode 100644 index 0000000..2879f5d --- /dev/null +++ b/backend/src/backend/schemas/common.py @@ -0,0 +1,12 @@ +"""跨域共享的请求/响应模型。 + +目前唯一成员是 `DownloadUrlRequest`:资源(resources)和脚本版本 +(scripts)两个域都要用它生成预签名下载 URL。 +""" + +from common.schemas import StrictModel +from pydantic import Field + + +class DownloadUrlRequest(StrictModel): + expires_seconds: int = Field(default=300, ge=30, le=3600) diff --git a/backend/src/backend/schemas/jupyter.py b/backend/src/backend/schemas/jupyter.py new file mode 100644 index 0000000..e3c2488 --- /dev/null +++ b/backend/src/backend/schemas/jupyter.py @@ -0,0 +1 @@ +"""Reserved for stage-3 extraction. Currently empty.""" diff --git a/backend/src/backend/schemas/platform.py b/backend/src/backend/schemas/platform.py new file mode 100644 index 0000000..e3c2488 --- /dev/null +++ b/backend/src/backend/schemas/platform.py @@ -0,0 +1 @@ +"""Reserved for stage-3 extraction. Currently empty.""" diff --git a/backend/src/backend/schemas.py b/backend/src/backend/schemas/resources.py similarity index 62% rename from backend/src/backend/schemas.py rename to backend/src/backend/schemas/resources.py index 958588f..78e5403 100644 --- a/backend/src/backend/schemas.py +++ b/backend/src/backend/schemas/resources.py @@ -39,40 +39,5 @@ class CompleteResourceUploadRequest(StrictModel): 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 LockScriptRequest(StrictModel): - is_locked: bool - - -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 ResourceRelativePathRequest(StrictModel): script_path: str = Field(min_length=1, max_length=512) diff --git a/backend/src/backend/schedule_schemas.py b/backend/src/backend/schemas/schedules.py similarity index 100% rename from backend/src/backend/schedule_schemas.py rename to backend/src/backend/schemas/schedules.py diff --git a/backend/src/backend/schemas/scripts.py b/backend/src/backend/schemas/scripts.py new file mode 100644 index 0000000..ea710b7 --- /dev/null +++ b/backend/src/backend/schemas/scripts.py @@ -0,0 +1,35 @@ +from typing import Literal + +from common.schemas import StrictModel +from pydantic import Field + + +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 LockScriptRequest(StrictModel): + is_locked: bool + + +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" diff --git a/backend/src/backend/services/jupyter.py b/backend/src/backend/services/jupyter.py new file mode 100644 index 0000000..e3c2488 --- /dev/null +++ b/backend/src/backend/services/jupyter.py @@ -0,0 +1 @@ +"""Reserved for stage-3 extraction. Currently empty.""" diff --git a/backend/src/backend/services/resources.py b/backend/src/backend/services/resources.py new file mode 100644 index 0000000..e3c2488 --- /dev/null +++ b/backend/src/backend/services/resources.py @@ -0,0 +1 @@ +"""Reserved for stage-3 extraction. Currently empty.""" diff --git a/backend/src/backend/services/schedules.py b/backend/src/backend/services/schedules.py new file mode 100644 index 0000000..1237f4e --- /dev/null +++ b/backend/src/backend/services/schedules.py @@ -0,0 +1,152 @@ +"""Schedule-domain services. + +Pure business logic extracted from ``backend.api.schedules`` so it can be +unit-tested without spinning up FastAPI / a DB session. Functions here +must not depend on ``Request``, ``BackgroundTasks``, or any FastAPI +router primitive. +""" + +from __future__ import annotations + +import heapq +from typing import Any + +from common.db.models import ScheduleEdges, ScheduleNodes + + +def validate_dag( + nodes: list[ScheduleNodes], + edges: list[ScheduleEdges], +) -> dict[str, Any]: + """Validate a schedule DAG and return a structural report. + + The returned dict has keys: + + * ``valid`` — True iff ``errors`` is empty + * ``node_count`` / ``edge_count`` — input sizes + * ``root_node_ids`` / ``leaf_node_ids`` — sorted by node_key so the + output is deterministic regardless of insertion order + * ``topological_order`` — Kahn's algorithm over node_key ties + * ``errors`` — list of dicts with ``code`` plus enough context + (``edge_id``, ``node_ids``) for the caller to surface back to + the UI; never raises + + Recognised error codes: + + * ``DAG_EMPTY`` — no nodes + * ``DAG_EDGE_NODE_MISSING`` — edge references unknown node_id + * ``DAG_SELF_EDGE`` — source == target + * ``DAG_DUPLICATE_EDGE`` — same directed pair seen twice + * ``DAG_CYCLE`` — topological sort did not consume all nodes + """ + 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, + } + + +__all__ = ["validate_dag"] diff --git a/backend/src/backend/services/scripts.py b/backend/src/backend/services/scripts.py new file mode 100644 index 0000000..e3c2488 --- /dev/null +++ b/backend/src/backend/services/scripts.py @@ -0,0 +1 @@ +"""Reserved for stage-3 extraction. Currently empty.""" diff --git a/backend/src/backend/services/storage.py b/backend/src/backend/services/storage.py index 65ed927..9741123 100644 --- a/backend/src/backend/services/storage.py +++ b/backend/src/backend/services/storage.py @@ -1,6 +1,6 @@ """In-process storage helpers. -The HTTP ``/internal/v1/*`` routes in ``backend.storage_api`` are wrappers +The HTTP ``/internal/v1/*`` routes in ``backend.api.storage`` are wrappers around these. Other backend modules (``scripts``, ``resources``) and the schedule worker call these helpers directly instead of going through an HTTP client — the storage layer lives in the same process, so the @@ -175,7 +175,7 @@ async def _mark_upload_failed_and_raise( ``GET_LOCK``. Why this helper exists at all: the route handler wraps every request - in ``session_scope`` (see ``backend.dependencies.database_session``), + in ``session_scope`` (see ``backend.api.dependencies.database_session``), which rolls back on exception. Without this helper, a naive ``upload.upload_status = "failed"; raise HTTPException(...)`` would lose the status flip and leave the row stuck in ``created``/``uploading`` @@ -279,7 +279,7 @@ def _resolve_bucket_for_usage( workspace_artifact_bucket: str | None, ) -> str: """Mirror of storage_api.resolve_bucket, but pure (no DB / Request).""" - from backend.storage_api import BUCKET_FOR_USAGE + from backend.api.storage import BUCKET_FOR_USAGE if workspace_artifact_bucket: return workspace_artifact_bucket return BUCKET_FOR_USAGE.get( @@ -298,7 +298,7 @@ async def create_upload_record( session; or ``{upload_id, status: "completed", storage_object: {...}}`` when the idempotency key hits an already-completed upload. """ - from backend.storage_api import ( + from backend.api.storage import ( normalized_idempotency_key, require_workspace_member, ) @@ -352,7 +352,7 @@ async def create_upload_record( else: from datetime import timedelta - from backend.storage_api import utcnow + from backend.api.storage import utcnow bucket_name = _resolve_bucket_for_usage( payload.usage_type, @@ -384,7 +384,7 @@ async def create_upload_record( await session.flush() if upload.upload_status == "completed" and upload.storage_object_id: - from backend.storage_api import storage_payload + from backend.api.storage import storage_payload storage_object = await session.get(StorageObjects, upload.storage_object_id) if storage_object is None or storage_object.object_status != "available": upload.storage_object_id = None @@ -552,7 +552,7 @@ async def create_server_object_payload( Used by scripts.py when publishing version artifacts and by the schedule worker for run logs / run results. """ - from backend.storage_api import storage_payload + from backend.api.storage import storage_payload try: content = base64.b64decode(payload.content_base64, validate=True) diff --git a/backend/tests/test_count_scripts.py b/backend/tests/test_count_scripts.py index 9c4a4bc..7692c8a 100644 --- a/backend/tests/test_count_scripts.py +++ b/backend/tests/test_count_scripts.py @@ -15,7 +15,7 @@ from unittest.mock import AsyncMock, MagicMock import pytest -from backend.scripts import count_scripts +from backend.api.scripts import count_scripts def _ctx( @@ -139,7 +139,7 @@ async def test_count_scripts_route_declared_before_script_id_route() -> None: """Static check: the `/api/v1/scripts/count` route MUST be declared in scripts.py before `/api/v1/scripts/{script_id}/...`, otherwise FastAPI's declaration-order matching will interpret `count` as a script_id.""" - from backend.scripts import count_scripts, get_script + from backend.api.scripts import count_scripts, get_script assert callable(count_scripts) assert callable(get_script) diff --git a/backend/tests/test_jupyter_auth_cache.py b/backend/tests/test_jupyter_auth_cache.py index 0b33f68..31905f2 100644 --- a/backend/tests/test_jupyter_auth_cache.py +++ b/backend/tests/test_jupyter_auth_cache.py @@ -18,12 +18,12 @@ from unittest.mock import AsyncMock import pytest from fastapi import HTTPException -import backend.jupyter as jupyter_module -from backend.jupyter import ( +import backend.api.jupyter as jupyter_module +from backend.api.jupyter import ( _JUPYTER_AUTH_CACHE, verify_jupyter_access, ) -from backend.runtime_client import RuntimeClientError +from backend.clients.runtime import RuntimeClientError WS_ID = "01WS0000000000000000000A" USER_ID = "01USR0000000000000000000A" diff --git a/backend/tests/test_list_scripts_parent_path.py b/backend/tests/test_list_scripts_parent_path.py index 5e02c67..6f25f6f 100644 --- a/backend/tests/test_list_scripts_parent_path.py +++ b/backend/tests/test_list_scripts_parent_path.py @@ -27,7 +27,7 @@ from fastapi import HTTPException from sqlalchemy import Column, MetaData, String, Table, create_engine, select, text from sqlalchemy.dialects import mysql as mysql_dialect -from backend.scripts import ( +from backend.api.scripts import ( _build_list_scripts_descendant_prefix, _build_list_scripts_workspace_descendant_prefix, _escape_like_pattern, @@ -179,7 +179,7 @@ def _compile_sql(stmt) -> str: async def test_list_scripts_where_clause_uses_like_prefix_and_excludes_deeper() -> None: - from backend.scripts import list_scripts + from backend.api.scripts import list_scripts captured_sql: list[str] = [] @@ -205,7 +205,7 @@ async def test_list_scripts_where_clause_uses_like_prefix_and_excludes_deeper() async def test_list_scripts_where_clause_escapes_pattern_literal() -> None: """Regression: parent_path containing ``_`` MUST be escaped in the compiled LIKE pattern, otherwise sibling-path leak returns to bite.""" - from backend.scripts import list_scripts + from backend.api.scripts import list_scripts captured_sql: list[str] = [] @@ -237,7 +237,7 @@ async def test_list_scripts_where_clause_escapes_pattern_literal() -> None: async def test_list_scripts_where_clause_escapes_percent_pattern() -> None: """Same regression for ``%``.""" - from backend.scripts import list_scripts + from backend.api.scripts import list_scripts captured_sql: list[str] = [] @@ -264,7 +264,7 @@ async def test_list_scripts_non_admin_adds_visibility_filter() -> None: """Workspace-wide listing is narrowed by visibility for non-admin: owner_user_id = me OR visibility IN (workspace, public) — exactly like list_resources. The workspace prefix contains NO user_id (cross-owner).""" - from backend.scripts import list_scripts + from backend.api.scripts import list_scripts captured_sql: list[str] = [] @@ -288,7 +288,7 @@ async def test_list_scripts_non_admin_adds_visibility_filter() -> None: async def test_list_scripts_admin_skips_visibility_filter() -> None: """Admin short-circuits the visibility predicate and sees everything.""" - from backend.scripts import list_scripts + from backend.api.scripts import list_scripts captured_sql: list[str] = [] @@ -316,7 +316,7 @@ async def test_list_scripts_admin_skips_visibility_filter() -> None: async def test_list_workspace_directories_where_clause_escapes_pattern() -> None: """list_workspace_directories must escape user input too (was pre-existing debt).""" - from backend.scripts import list_workspace_directories + from backend.api.scripts import list_workspace_directories captured_sql: list[str] = [] diff --git a/backend/tests/test_resources.py b/backend/tests/test_resources.py index ae51739..7d75c63 100644 --- a/backend/tests/test_resources.py +++ b/backend/tests/test_resources.py @@ -13,7 +13,7 @@ import pytest from fastapi import HTTPException from sqlalchemy import Column, MetaData, String, Table, create_engine, select from sqlalchemy.dialects import mysql as mysql_dialect -from backend.resources import ( +from backend.api.resources import ( _build_list_resources_descendant_prefix, can_view, compute_jupyter_relative_path, @@ -126,7 +126,7 @@ def _bind_payload(): @pytest.mark.asyncio async def test_bind_resource_rejects_duplicate_name_in_same_directory() -> None: """Same resource_name in the same directory raises 409.""" - from backend.resources import bind_resource + from backend.api.resources import bind_resource existing_rows = [ ( @@ -153,7 +153,7 @@ async def test_bind_resource_rejects_duplicate_name_in_same_directory() -> None: @pytest.mark.asyncio async def test_bind_resource_allows_same_name_in_different_directory() -> None: """Same resource_name in a different directory binds successfully.""" - from backend.resources import bind_resource + from backend.api.resources import bind_resource existing_rows = [ ( @@ -179,7 +179,7 @@ async def test_bind_resource_allows_same_name_in_different_directory() -> None: @pytest.mark.asyncio async def test_bind_resource_allows_same_name_when_workspace_empty() -> None: """No same-name rows at all: bind succeeds (root directory).""" - from backend.resources import bind_resource + from backend.api.resources import bind_resource session = _BindSessionMock( new_object_key=f"{_BIND_WS}/{_BIND_USER}/data.csv", @@ -197,7 +197,7 @@ async def test_bind_resource_allows_same_name_when_workspace_empty() -> None: @pytest.mark.asyncio async def test_bind_resource_allows_same_name_for_different_owner() -> None: """其他用户在同目录下的同名资源不阻塞当前用户的绑定。""" - from backend.resources import bind_resource + from backend.api.resources import bind_resource other_user = "01USR0000000000000000000B" existing_rows = [ @@ -223,7 +223,7 @@ async def test_bind_resource_allows_same_name_for_different_owner() -> None: @pytest.mark.asyncio async def test_bind_resource_allows_rebinding_same_storage_object() -> None: """重新绑定同一 upload_id 应走 idempotent 复用路径,不触发 409。""" - from backend.resources import bind_resource + from backend.api.resources import bind_resource new_object_key = f"{_BIND_WS}/{_BIND_USER}/data.csv" existing_resource = _make_resource(_BIND_WS, _BIND_USER) @@ -251,7 +251,7 @@ async def test_bind_resource_allows_rebinding_same_storage_object() -> None: @pytest.mark.asyncio async def test_bind_resource_rejects_non_data_resource_upload() -> None: """其他用途(如 working_copy)的 upload session 不能 bind 成数据资源。""" - from backend.resources import bind_resource + from backend.api.resources import bind_resource session = _BindSessionMock( new_object_key=f"{_BIND_WS}/{_BIND_USER}/data.csv", @@ -566,7 +566,7 @@ def _list_resources_capturing_session(captured_sql: list[str]) -> MagicMock: async def test_list_resources_where_clause_uses_like_prefix_and_excludes_deeper() -> None: - from backend.resources import list_resources + from backend.api.resources import list_resources captured_sql: list[str] = [] mock_session = _list_resources_capturing_session(captured_sql) @@ -589,7 +589,7 @@ async def test_list_resources_where_clause_uses_like_prefix_and_excludes_deeper( async def test_list_resources_where_clause_escapes_underscore() -> None: """Regression: parent_path containing ``_`` MUST be escaped in the compiled LIKE pattern, otherwise sibling-path leak (``fooXbar``) returns.""" - from backend.resources import list_resources + from backend.api.resources import list_resources captured_sql: list[str] = [] mock_session = _list_resources_capturing_session(captured_sql) @@ -614,7 +614,7 @@ async def test_list_resources_where_clause_escapes_underscore() -> None: async def test_list_resources_without_parent_path_adds_no_like_clause() -> None: """Empty parent_path keeps the legacy workspace-wide behaviour — no object_key LIKE filter at all.""" - from backend.resources import list_resources + from backend.api.resources import list_resources captured_sql: list[str] = [] mock_session = _list_resources_capturing_session(captured_sql) @@ -634,7 +634,7 @@ async def test_list_resources_without_parent_path_adds_no_like_clause() -> None: async def test_list_resources_joins_users_for_display_name() -> None: """list_resources must OUTER JOIN users and SELECT users.display_name so every resource carries owner_display_name (frontend displayName chain).""" - from backend.resources import list_resources + from backend.api.resources import list_resources captured_sql: list[str] = [] mock_session = _list_resources_capturing_session(captured_sql) diff --git a/backend/tests/test_runtime_client_directories.py b/backend/tests/test_runtime_client_directories.py index 78627f3..48a6f18 100644 --- a/backend/tests/test_runtime_client_directories.py +++ b/backend/tests/test_runtime_client_directories.py @@ -15,7 +15,7 @@ from __future__ import annotations import httpx import pytest import respx -from backend.runtime_client import RuntimeClient, RuntimeClientError +from backend.clients.runtime import RuntimeClient, RuntimeClientError WORKSPACE_ID = "01HWS0000000000000000000A" BASE_URL = "http://runtime" diff --git a/backend/tests/test_scripts.py b/backend/tests/test_scripts.py index 8b6ebeb..20fba24 100644 --- a/backend/tests/test_scripts.py +++ b/backend/tests/test_scripts.py @@ -103,7 +103,7 @@ class _AsyncSessionMock: @pytest.mark.asyncio async def test_create_script_record_flushes_storage_object_before_script() -> None: """StorageObjects must flush first so path conflicts surface early.""" - from backend.scripts import create_script_record + from backend.api.scripts import create_script_record session = _AsyncSessionMock() request = _make_request() @@ -133,7 +133,7 @@ async def test_create_script_record_flushes_storage_object_before_script() -> No @pytest.mark.asyncio async def test_create_script_record_storage_object_flush_failure_does_not_add_script() -> None: """If the StorageObjects flush fails, the Scripts row must never be added.""" - from backend.scripts import create_script_record + from backend.api.scripts import create_script_record class FailingSession(_AsyncSessionMock): async def flush(self) -> None: @@ -172,7 +172,7 @@ async def test_create_script_record_allows_reupload_after_delete() -> None: """Without uk_scripts_workspace_name_active, re-uploading a script with the same name after the previous one was soft-deleted succeeds. """ - from backend.scripts import create_script_record + from backend.api.scripts import create_script_record session = _AsyncSessionMock() request = _make_request() @@ -230,7 +230,7 @@ async def test_create_script_record_allows_same_name_different_parent() -> None: must coexist — they correspond to different Jupyter paths (/user/foo.ipynb vs /user/test/foo.ipynb). """ - from backend.scripts import create_script_record + from backend.api.scripts import create_script_record session = _AsyncSessionMock() request = _make_request() @@ -286,7 +286,7 @@ async def test_create_script_after_soft_delete_does_not_conflict() -> None: raise IntegrityError — the generated column is NULL for the deleted row, so it does not occupy the UNIQUE slot. """ - from backend.scripts import create_script_record + from backend.api.scripts import create_script_record session = _AsyncSessionMock() request = _make_request() @@ -376,7 +376,7 @@ def _storage_object_row() -> StorageObjects: @pytest.mark.asyncio async def test_delete_script_route_sets_is_deleted(monkeypatch: pytest.MonkeyPatch) -> None: """Soft-deleting a script via the route handler flips is_deleted=1.""" - from backend.scripts import delete_script + from backend.api.scripts import delete_script script = _script_row() storage_object = _storage_object_row() @@ -395,7 +395,7 @@ async def test_delete_script_route_sets_is_deleted(monkeypatch: pytest.MonkeyPat ) -> tuple[Scripts, StorageObjects]: return script, storage_object - monkeypatch.setattr("backend.scripts.get_script_row", _fake_get_script_row) + monkeypatch.setattr("backend.api.scripts.get_script_row", _fake_get_script_row) mock_soft_delete = AsyncMock( return_value={ "data": { @@ -406,7 +406,7 @@ async def test_delete_script_route_sets_is_deleted(monkeypatch: pytest.MonkeyPat } } ) - monkeypatch.setattr("backend.scripts.soft_delete_object", mock_soft_delete) + monkeypatch.setattr("backend.api.scripts.soft_delete_object", mock_soft_delete) result = await delete_script( script_id=script.script_id, @@ -430,7 +430,7 @@ async def test_delete_script_route_sets_is_deleted(monkeypatch: pytest.MonkeyPat @pytest.mark.asyncio async def test_delete_resource_sets_is_deleted(monkeypatch: pytest.MonkeyPatch) -> None: """Soft-deleting a data resource must write is_deleted=1 on the row.""" - from backend.resources import delete_resource + from backend.api.resources import delete_resource resource = DataResources( resource_id="01RES0000000000000000000A", @@ -455,7 +455,7 @@ async def test_delete_resource_sets_is_deleted(monkeypatch: pytest.MonkeyPatch) with monkeypatch.context() as mp: mp.setattr( - "backend.resources.soft_delete_object", + "backend.api.resources.soft_delete_object", AsyncMock(return_value={"data": {}}), ) result = await delete_resource( @@ -558,7 +558,7 @@ async def test_soft_delete_object_streams_via_get_stream() -> None: @pytest.mark.asyncio async def test_jupyter_check_notebook_lock_ignores_deleted_scripts() -> None: """``is_deleted == 0`` filter must hide deleted notebooks from Jupyter checks.""" - from backend.jupyter import check_notebook_is_locked + from backend.api.jupyter import check_notebook_is_locked session = AsyncMock() session.execute = AsyncMock() @@ -593,8 +593,8 @@ async def test_update_script_writes_back_storage_object_metadata( """ import hashlib - from backend.schemas import UpdateScriptRequest - from backend.scripts import update_script + from backend.schemas.scripts import UpdateScriptRequest + from backend.api.scripts import update_script script = _script_row() storage_object = _storage_object_row() @@ -624,7 +624,7 @@ async def test_update_script_writes_back_storage_object_metadata( ) -> tuple[Scripts, StorageObjects]: return script, storage_object - monkeypatch.setattr("backend.scripts.get_script_row", _fake_get_script_row) + monkeypatch.setattr("backend.api.scripts.get_script_row", _fake_get_script_row) new_content = '{"cells": [{"cell_type": "code", "source": ["print(1)"]}]}\n' payload = UpdateScriptRequest(content=new_content) @@ -670,8 +670,8 @@ async def test_update_script_jupyter_only_uses_dict_fallback( """ import hashlib - from backend.schemas import UpdateScriptRequest - from backend.scripts import update_script + from backend.schemas.scripts import UpdateScriptRequest + from backend.api.scripts import update_script script = _script_row() user_id = "01USR0000000000000000000A" @@ -695,7 +695,7 @@ async def test_update_script_jupyter_only_uses_dict_fallback( ) -> tuple[Scripts, None]: return script, None - monkeypatch.setattr("backend.scripts.get_script_row", _fake_get_script_row) + monkeypatch.setattr("backend.api.scripts.get_script_row", _fake_get_script_row) payload = UpdateScriptRequest(content='{"cells": []}\n') result = await update_script( diff --git a/backend/tests/test_validate_dag.py b/backend/tests/test_validate_dag.py new file mode 100644 index 0000000..afb3285 --- /dev/null +++ b/backend/tests/test_validate_dag.py @@ -0,0 +1,143 @@ +"""Unit tests for backend.services.schedules.validate_dag. + +Pure function — no DB, no FastAPI, no fixtures beyond SimpleNamespace +stand-ins for the SQLAlchemy rows. The function only reads five +attributes: ``node_id``, ``node_key``, ``edge_id``, ``source_node_id``, +``target_node_id``. +""" + +from __future__ import annotations + +from types import SimpleNamespace + +from backend.services.schedules import validate_dag + + +def _node(node_id: str, node_key: str) -> SimpleNamespace: + return SimpleNamespace(node_id=node_id, node_key=node_key) + + +def _edge(edge_id: str, source: str, target: str) -> SimpleNamespace: + return SimpleNamespace( + edge_id=edge_id, + source_node_id=source, + target_node_id=target, + ) + + +def test_empty_nodes_is_rejected_as_dag_empty() -> None: + result = validate_dag(nodes=[], edges=[]) + assert result["valid"] is False + assert result["node_count"] == 0 + assert result["edge_count"] == 0 + assert result["topological_order"] == [] + codes = [err["code"] for err in result["errors"]] + assert "DAG_EMPTY" in codes + + +def test_linear_chain_orders_by_node_key() -> None: + nodes = [_node("n1", "A"), _node("n2", "B"), _node("n3", "C")] + edges = [_edge("e1", "n1", "n2"), _edge("e2", "n2", "n3")] + result = validate_dag(nodes, edges) + assert result["valid"] is True + assert result["root_node_ids"] == ["n1"] + assert result["leaf_node_ids"] == ["n3"] + assert result["topological_order"] == ["n1", "n2", "n3"] + + +def test_diamond_topology_is_valid() -> None: + # A -> B -> D + # A -> C -> D + nodes = [ + _node("a", "A"), + _node("b", "B"), + _node("c", "C"), + _node("d", "D"), + ] + edges = [ + _edge("e1", "a", "b"), + _edge("e2", "a", "c"), + _edge("e3", "b", "d"), + _edge("e4", "c", "d"), + ] + result = validate_dag(nodes, edges) + assert result["valid"] is True + assert result["root_node_ids"] == ["a"] + assert result["leaf_node_ids"] == ["d"] + # Kahn's algorithm with node_key tie-breaking: starting at A, then B + # and C both become ready (B alphabetically first), then D. + assert result["topological_order"] == ["a", "b", "c", "d"] + + +def test_cycle_is_rejected_with_dag_cycle() -> None: + # n1 -> n2 -> n3 -> n1 + nodes = [_node("n1", "A"), _node("n2", "B"), _node("n3", "C")] + edges = [ + _edge("e1", "n1", "n2"), + _edge("e2", "n2", "n3"), + _edge("e3", "n3", "n1"), + ] + result = validate_dag(nodes, edges) + assert result["valid"] is False + codes = [err["code"] for err in result["errors"]] + assert "DAG_CYCLE" in codes + cycle_err = next(err for err in result["errors"] if err["code"] == "DAG_CYCLE") + # The cycle should list every node in the cycle (sorted by node_key). + assert set(cycle_err["node_ids"]) == {"n1", "n2", "n3"} + + +def test_self_edge_is_rejected_but_does_not_count_as_cycle() -> None: + nodes = [_node("n1", "A"), _node("n2", "B")] + edges = [ + _edge("e_self", "n1", "n1"), + _edge("e_real", "n1", "n2"), + ] + result = validate_dag(nodes, edges) + codes = [err["code"] for err in result["errors"]] + assert "DAG_SELF_EDGE" in codes + # The A->B edge still makes the DAG valid overall except for the self-edge. + assert "DAG_CYCLE" not in codes + # One node remains reachable (B), so cycle detection must not fire. + assert result["topological_order"] == ["n1", "n2"] + + +def test_duplicate_edge_is_rejected_with_dag_duplicate_edge() -> None: + nodes = [_node("n1", "A"), _node("n2", "B")] + edges = [ + _edge("e1", "n1", "n2"), + _edge("e1_dup", "n1", "n2"), + ] + result = validate_dag(nodes, edges) + codes = [err["code"] for err in result["errors"]] + assert "DAG_DUPLICATE_EDGE" in codes + # The first edge still counts toward edge_count, the second is rejected. + assert result["edge_count"] == 2 + + +def test_edge_to_unknown_node_is_dag_edge_node_missing() -> None: + nodes = [_node("n1", "A")] + edges = [ + _edge("e1", "n1", "ghost"), + _edge("e2", "ghost", "n1"), + ] + result = validate_dag(nodes, edges) + codes = [err["code"] for err in result["errors"]] + assert codes.count("DAG_EDGE_NODE_MISSING") == 2 + # No cycle should be reported for orphan edges. + assert "DAG_CYCLE" not in codes + + +def test_multiple_roots_are_sorted_by_node_key() -> None: + nodes = [ + _node("z", "Z"), + _node("a", "A"), + _node("m", "M"), + ] + edges = [] + result = validate_dag(nodes, edges) + assert result["valid"] is True + # All three nodes are roots (no indegree) and leaves (no outgoing). + assert result["root_node_ids"] == ["a", "m", "z"] + assert result["leaf_node_ids"] == ["a", "m", "z"] + # Topological order picks the smallest node_key first. + assert result["topological_order"] == ["a", "m", "z"] -- 2.54.0 From 9f6466335cf213ed2cf66b46e6b8bf41f8ec5787 Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:38:31 +0800 Subject: [PATCH 26/93] docs(schedule): record the deliberate ExecutionResult dataclass upgrade Review (2026-08-21) found that stage 1 promoted ExecutionResult from a plain class to @dataclass(frozen=True) along the way. No caller mutates or compares these objects by identity, so the only externally visible change is structured log output. User opted to keep the upgrade. - domain/execution.py module docstring: explicit note that the frozen + value-equality form is a deliberate enhancement, not a behavioral accident - CLAUDE.md "Schedule service layering" lesson: add a "don't silently upgrade dataclass-ness during a structural-only refactor" note so future refactors copy class definitions verbatim unless they intend to tighten semantics explicitly No code change; tests still 29 green. Co-Authored-By: Claude --- CLAUDE.md | 1 + schedule/src/schedule/domain/execution.py | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index e148072..06332b6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,6 +84,7 @@ Lessons from the zero-behavior-change refactor that split the flat 11-file `sche - **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) diff --git a/schedule/src/schedule/domain/execution.py b/schedule/src/schedule/domain/execution.py index ceb355d..b864db9 100644 --- a/schedule/src/schedule/domain/execution.py +++ b/schedule/src/schedule/domain/execution.py @@ -2,6 +2,12 @@ Pure value objects — no I/O, no logging, no model imports. Safe to import from any layer. + +Note: ``ExecutionResult`` is a frozen dataclass here, while the pre-refactor +flat ``schedule/execution.py`` defined it as a plain class. The frozen + +value-equality upgrade is a deliberate enhancement (review verification, +2026-08-21): no caller mutates the object and no caller relies on identity +comparison, so the only visible change is structured log output. """ from __future__ import annotations -- 2.54.0 From 952b08bb638f60e52362ae2c45b4617c9a485d4d Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:07:26 +0800 Subject: [PATCH 27/93] chore: update docstring --- API.md | 2 +- DEVELOP.md | 2 +- backend/tests/test_jupyter_auth_cache.py | 2 +- backend/tests/test_storage_upload_status.py | 2 +- common/src/common/scheduler/trigger.py | 2 +- schedule/src/schedule/application/service.py | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/API.md b/API.md index acad6a4..c6ec642 100644 --- a/API.md +++ b/API.md @@ -895,7 +895,7 @@ Base 前缀 `/api/v1/admin`。 整体替换指定平台角色的 `permission_codes`(diff-based 写入,见下文)。调用者必须是系统管理员。 -> **本端点只控制前端菜单可见性**——不修改 `system_admin_context` 的鉴权判定(`role_code == "admin"` 始终等价于"拥有所有平台菜单权限")。若需调整 API 鉴权,请改 `backend.platform.system_admin_context`,不要绕过本端点。 +> **本端点只控制前端菜单可见性**——不修改 `system_admin_context` 的鉴权判定(`role_code == "admin"` 始终等价于"拥有所有平台菜单权限")。若需调整 API 鉴权,请改 `backend.api.platform.system_admin_context`,不要绕过本端点。 - **请求体字段**: diff --git a/DEVELOP.md b/DEVELOP.md index 2781ab5..ce0e26d 100644 --- a/DEVELOP.md +++ b/DEVELOP.md @@ -168,7 +168,7 @@ grep -rnE 'os\.(environ\[?["\x27][A-Z_]+|getenv\(["\x27][A-Z_]+)' --include="*.p ### Service-to-service auth (P0-1 fix) - Schedule → Backend single endpoint ``POST /internal/v1/objects`` is - guarded by ``require_internal_service`` in ``backend.storage_api``. + guarded by ``require_internal_service`` in ``backend.api.storage``. - The token header is ``X-Internal-Service-Token`` (case-insensitive on the wire because FastAPI ``Header`` lowercase-matches the name ``x-internal-service-token``); the secret value comes from diff --git a/backend/tests/test_jupyter_auth_cache.py b/backend/tests/test_jupyter_auth_cache.py index 31905f2..e7e2caf 100644 --- a/backend/tests/test_jupyter_auth_cache.py +++ b/backend/tests/test_jupyter_auth_cache.py @@ -1,4 +1,4 @@ -"""Unit tests for the 5s auth-result cache in ``backend.jupyter``. +"""Unit tests for the 5s auth-result cache in ``backend.api.jupyter``. Jupyter 一次会话会触发几十次 Nginx ``auth_request``;本缓存按 ``(workspace_id, user_id)`` 缓存 membership + runtime 的查找结果,避免 diff --git a/backend/tests/test_storage_upload_status.py b/backend/tests/test_storage_upload_status.py index a5a46f1..08d234e 100644 --- a/backend/tests/test_storage_upload_status.py +++ b/backend/tests/test_storage_upload_status.py @@ -1,7 +1,7 @@ """Unit tests for ``upload_bytes_to_session`` failure-path status persistence. P0-5 / B1: the route handler wraps every request in ``session_scope`` -(``backend.dependencies.database_session``), which rolls back on +(``backend.api.dependencies.database_session``), which rolls back on exception. A naive ``upload.upload_status = "failed"; raise HTTPException(...)`` loses the status flip and leaves the row stuck in ``created``/``uploading`` forever. The fix is ``_mark_upload_failed_and_raise`` which opens a diff --git a/common/src/common/scheduler/trigger.py b/common/src/common/scheduler/trigger.py index ce14826..1b3c094 100644 --- a/common/src/common/scheduler/trigger.py +++ b/common/src/common/scheduler/trigger.py @@ -1,6 +1,6 @@ """Shared schedule-trigger logic. -Both the user-facing manual run endpoint (``backend.schedule_runs``) +Both the user-facing manual run endpoint (``backend.api.schedules.runs``) and the schedule service's cron tick handler call into this module to materialize a ``ScheduleRuns`` row plus the corresponding ``schedule.run.requested`` outbox event. The outbox is the single diff --git a/schedule/src/schedule/application/service.py b/schedule/src/schedule/application/service.py index 42768f9..61bcc00 100644 --- a/schedule/src/schedule/application/service.py +++ b/schedule/src/schedule/application/service.py @@ -191,7 +191,7 @@ def build_object_store(bucket_name: str | None = None) -> Any: Respects ``settings.storage_backend``: in ``local`` mode points at the local ``${local_storage_base_dir}/version`` directory (the same place - ``backend.storage_api`` writes to), in ``s3`` mode points at the + ``backend.api.storage`` writes to), in ``s3`` mode points at the bucket named by ``bucket_name`` (default ``settings.s3_version_bucket``). The bucket override exists because some workspaces configure a custom -- 2.54.0 From 5045f0ad8ccf2d2c208b23e37b0c0416ed8e9e68 Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:20:17 +0800 Subject: [PATCH 28/93] chore: delete demo_auth_enabled from config --- .env.example | 2 -- common/src/common/config.py | 4 ---- docker-compose.yml | 1 - 3 files changed, 7 deletions(-) diff --git a/.env.example b/.env.example index 165c225..ea864ab 100644 --- a/.env.example +++ b/.env.example @@ -14,8 +14,6 @@ MYSQL_PASSWORD=change-me MYSQL_DATABASE=model_platform DATABASE_URL=mysql+asyncmy://root:change-me@127.0.0.1:3306/model_platform?charset=utf8mb4 -# Demo login is only intended for this self-hosted development UI. -DEMO_AUTH_ENABLED=true JWT_SECRET=change-this-development-secret # ============================================================================ diff --git a/common/src/common/config.py b/common/src/common/config.py index cf471a2..93cf9ee 100644 --- a/common/src/common/config.py +++ b/common/src/common/config.py @@ -39,10 +39,6 @@ class Settings(BaseSettings): default="dev-only-not-for-production", description="HS256 secret used by backend's jupyter auth_request.", ) - demo_auth_enabled: bool = Field( - default=False, - description="Enable the self-hosted UI's short-lived demo session cookie.", - ) cookie_force_secure: bool = Field( default=False, description=( diff --git a/docker-compose.yml b/docker-compose.yml index 3a46f51..53ff630 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -81,7 +81,6 @@ services: SERVICE_NAME: model-platform-backend SCHEDULE_EVENT_NAMESPACE: ${SCHEDULE_EVENT_NAMESPACE:-model-platform-local} JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required} - DEMO_AUTH_ENABLED: ${DEMO_AUTH_ENABLED:-false} INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-admin12345} RUNTIME_API_URL: http://runtime:8000 # P0-1 fix: shared secret required by /internal/v1/* routes. -- 2.54.0 From 8cf4b53dd49b17d8a9b6cc690a5ebdd87b3f5208 Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:20:05 +0800 Subject: [PATCH 29/93] fix(scripts): workspace-wide parent_path listing + private visibility on reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 同 workspace 互相可见(排除 private): - list_scripts / count_scripts 已 workspace-wide + visibility 过滤,但 单条读取(get/content/latest-version/versions)不校验 visibility,非 owner 猜 id 即可读他人 private 脚本。新增 script_can_view(与 data resources 的 can_view 对称)并在 get_script_row / latest_version 强制, private 对非 owner 返回 404。 2. parent_path 为空默认拉根路径文件: - 物理存储为 workspace/{user_id}/...,根 prefix 原来是 workspace/ + NOT LIKE workspace/%/%,所有文件都在两层被整体排除,list_scripts("") 恒空。改为 workspace/%/,配合 LIKE workspace/%/% AND NOT LIKE workspace/%/%/% 返回各 owner 根级文件。 3. 非空 parent_path 跨 owner 查询: - 原来 workspace/foo/ 永远匹配不到 workspace/{uid}/foo/...,子目录 懒加载返回空,其他用户目录点击无内容。改为 workspace/%/foo/(owner 段通配,与 list_resources 一致),_ / % 仍按字面转义。 测试:更新前缀契约断言,新增 SQLite 行为测试(跨 owner 根/子目录、转义) 与 script_can_view / get_script 权限测试,133 passed。 --- backend/src/backend/api/scripts.py | 84 +++-- backend/tests/test_count_scripts.py | 18 +- .../tests/test_list_scripts_parent_path.py | 311 +++++++++++++++++- 3 files changed, 362 insertions(+), 51 deletions(-) diff --git a/backend/src/backend/api/scripts.py b/backend/src/backend/api/scripts.py index 2ea5979..a4c31b4 100644 --- a/backend/src/backend/api/scripts.py +++ b/backend/src/backend/api/scripts.py @@ -170,23 +170,29 @@ def _build_list_scripts_workspace_descendant_prefix(parent_path: str) -> str: """Return the escaped materialized-path prefix for direct children of ``parent_path`` across **all owners** in the workspace. - Storage is still physically laid out as ``workspace/{user_id}/...``, but - listing is workspace-wide: the prefix starts at ``workspace/`` (no - embedded user_id) so the endpoint's ``LIKE '/%'`` walks every - owner's subtree. Non-admin scoping is handled separately in the SQL via + Storage is still physically laid out as ``workspace/{user_id}/...``, so + listing a subdirectory across every owner must match the owner segment + with an intentional ``%`` wildcard — ``workspace/%/foo`` — exactly like + list_resources does against ``object_key``. The endpoint's + ``LIKE '/%' AND NOT LIKE '/%/%'`` pair then grabs only + DIRECT children of ``parent_path`` per owner. Non-admin scoping is + handled separately in the SQL via ``owner_user_id = me OR visibility IN (workspace, public)``. - Empty ``parent_path`` returns ``"workspace/"`` (cross-owner root). - The prefix is run through ``_escape_like_pattern`` so folder names - containing ``_`` / ``%`` do not act as wildcards. The trailing ``/`` - is appended AFTER escaping so it remains a literal slash. + Empty ``parent_path`` returns ``"workspace/%/"``: the physical root + level is each owner's subtree (``workspace/{owner_id}/...``), so the + owner segment is wildcarded and the endpoint's direct-child pair + keeps every owner's root-level files (``workspace/%/%`` AND NOT + ``workspace/%/%/%``). The prefix is run through ``_escape_like_pattern`` + so folder names containing ``_`` / ``%`` do not act as wildcards. The + trailing ``/`` is appended AFTER escaping so it remains a literal slash. """ normalized_parent = normalize_user_path(parent_path) if normalized_parent: - target_prefix = f"workspace/{normalized_parent}" + target_prefix = f"workspace/%/{_escape_like_pattern(normalized_parent)}" else: - target_prefix = "workspace" - return f"{_escape_like_pattern(target_prefix)}/" + target_prefix = "workspace/%" + return f"{target_prefix}/" def safe_script_name(value: str, script_type: str) -> str: @@ -328,6 +334,22 @@ def version_payload(version: Versions) -> dict[str, Any]: } +def script_can_view(script: Scripts, context: RequestContext) -> bool: + """同一 workspace 内:owner 永远可见自己的脚本(含 private); + 其他成员只见 visibility in {workspace, public} 的脚本; + admin 全部可见。与 data resources 的 can_view 完全对称。 + + 用于单脚本读取(get / content / latest-version / versions 列表)以及 + 所有写路径(update / delete / publish)的前置校验 —— 保证 A 的 private + 脚本对非 owner 不可见,而不仅是「列表里不出现」。 + """ + if script.owner_user_id == context.user.user_id: + return True + if script.visibility in {"workspace", "public"}: + return True + return context.is_admin + + async def get_script_row( script_id: str, context: RequestContext, @@ -392,6 +414,11 @@ async def get_script_row( if row is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "script not found") script, storage_object = row + if not script_can_view(script, context): + # Private scripts are only visible to their owner (and admin); + # treat cross-owner access as not-found so the id cannot probe + # visibility, matching get_visible_resource for data resources. + raise HTTPException(status.HTTP_404_NOT_FOUND, "script not found") return script, storage_object @@ -1122,8 +1149,10 @@ async def delete_workspace_directory( # 列出当前工作区中用户有权查看的脚本与 Notebook 元数据。 # # 可选 ``parent_path`` Query 参数把返回范围限定为 *直接挂在该路径下的* 脚本 -# (不含更深的子目录)。空字符串等价于用户作用域根目录;这是前端按目录懒加载 -# 的关键端点,避免 10 万级脚本一次性返回。 +# (不含更深的子目录)。空字符串等价于工作区根目录(跨所有 owner 根级文件); +# 非空路径按 owner 段通配(``workspace/%/``)跨所有 owner 查询,与 +# list_resources 一致。这是前端按目录懒加载的关键端点,避免 10 万级脚本 +# 一次性返回。 # # 实现走 ``storage_objects.relative_path`` materialized path(``LIKE prefix%`` # + ``NOT LIKE prefix%/%`` 取直接子节点)。MySQL 优化器目前选 @@ -1140,10 +1169,12 @@ async def list_scripts( session: AsyncSession = Depends(database_session), ) -> dict[str, Any]: # Workspace-wide listing: storage is physically laid out as - # ``workspace/{user_id}/...``, but the prefix starts at ``workspace/`` - # (no embedded user_id) so the LIKE walks every owner's subtree. - # Non-admin scoping is applied below via visibility, matching - # list_resources (69a9a48). + # ``workspace/{user_id}/...``. Empty parent_path wildcards the owner + # segment (``workspace/%/``) so each owner's root files are returned; + # non-empty parent_path embeds the same owner wildcard + # (``workspace/%/foo``) so every owner's ``foo`` subtree matches, + # mirroring list_resources. Non-admin scoping is applied below via + # visibility, matching list_resources (69a9a48). descendant_prefix = _build_list_scripts_workspace_descendant_prefix(parent_path) statement = ( @@ -1638,18 +1669,15 @@ async def latest_version( published versions yet (so the frontend can render an empty label without a 404 round-trip). """ - script = await session.scalar( - select(Scripts.script_id).where( - Scripts.script_id == script_id, - Scripts.workspace_id == context.workspace.workspace_id, - Scripts.status == "active", - ) + # Route through get_script_row so the private-visibility guard applies + # here too: other members must not learn about a private script's + # versions by guessing its id. + _script, _ = await get_script_row( + script_id, + context, + session, + allow_missing_storage_object=True, ) - if script is None: - raise HTTPException( - status.HTTP_404_NOT_FOUND, - "script not found", - ) latest = await session.scalar( select(Versions) .where( diff --git a/backend/tests/test_count_scripts.py b/backend/tests/test_count_scripts.py index 7692c8a..7456ac1 100644 --- a/backend/tests/test_count_scripts.py +++ b/backend/tests/test_count_scripts.py @@ -68,10 +68,11 @@ async def test_count_scripts_returns_scalar_int() -> None: # JOIN to StorageObjects so orphaned scripts (no joinable row) are # excluded — matches list_scripts INNER JOIN behaviour. assert "inner join storage_objects" in sql - # Scope: workspace_id + active status + workspace-wide prefix. + # Scope: workspace_id + active status + workspace-wide prefix + # (owner segment wildcarded: workspace/%/%). assert "scripts.workspace_id" in sql assert "scripts.status" in sql - assert "like 'workspace/%%'" in sql + assert "like 'workspace/%%/%%'" in sql # Non-admin (default) narrows by visibility. assert "scripts.owner_user_id = 'u001'" in sql assert "scripts.visibility in ('workspace', 'public')" in sql @@ -87,9 +88,10 @@ async def test_count_scripts_handles_null_result() -> None: async def test_count_scripts_workspace_wide_not_user_scoped() -> None: - """The prefix is workspace-wide (``workspace/%`` — no embedded user_id), - so different users count the same physical tree; the only per-user - difference is the non-admin visibility predicate (owner_user_id = me).""" + """The prefix is workspace-wide (``workspace/%/%`` — owner segment + wildcarded, no embedded user_id), so different users count the same + physical tree; the only per-user difference is the non-admin + visibility predicate (owner_user_id = me).""" captured = [] mock_session = MagicMock() @@ -104,8 +106,8 @@ async def test_count_scripts_workspace_wide_not_user_scoped() -> None: sql_bob = _compile(captured[-1]).lower() # Both count the same workspace-wide subtree. - assert "like 'workspace/%%'" in sql_alice - assert "like 'workspace/%%'" in sql_bob + assert "like 'workspace/%%/%%'" in sql_alice + assert "like 'workspace/%%/%%'" in sql_bob # Neither embeds the user_id in the path prefix. assert "workspace/alice/%" not in sql_alice assert "workspace/bob/%" not in sql_bob @@ -129,7 +131,7 @@ async def test_count_scripts_admin_skips_visibility_filter() -> None: ) assert result["data"] == {"total": 42} sql = _compile(captured[0]).lower() - assert "like 'workspace/%%'" in sql + assert "like 'workspace/%%/%%'" in sql # visibility / owner_user_id still appear in the SELECT projection, but # the visibility WHERE predicate must be absent for admins. assert "scripts.visibility in ('workspace', 'public')" not in sql diff --git a/backend/tests/test_list_scripts_parent_path.py b/backend/tests/test_list_scripts_parent_path.py index 6f25f6f..feb0868 100644 --- a/backend/tests/test_list_scripts_parent_path.py +++ b/backend/tests/test_list_scripts_parent_path.py @@ -125,15 +125,23 @@ def test_normalize_user_path_strips() -> None: def test_workspace_descendant_prefix_root() -> None: - """Empty parent_path → workspace-wide root prefix (cross-owner).""" - assert _build_list_scripts_workspace_descendant_prefix("") == "workspace/" + """Empty parent_path → workspace-wide root prefix (cross-owner). + + Physical root level is each owner's subtree, so the prefix wildcards + the owner segment: ``workspace/%/`` (direct children ``workspace/%/%`` + minus ``workspace/%/%/%``).""" + assert _build_list_scripts_workspace_descendant_prefix("") == "workspace/%/" def test_workspace_descendant_prefix_subdir() -> None: - """Non-empty parent_path → appended under workspace root, no user_id.""" + """Non-empty parent_path → workspace-wide with owner-segment wildcard. + + Physical rows live under ``workspace/{owner_id}/...``, so a subdirectory + listing must match ANY owner: ``workspace/%/foo/bar`` (the ``%`` is the + intentional owner wildcard, exactly like list_resources).""" assert ( _build_list_scripts_workspace_descendant_prefix("foo/bar") - == "workspace/foo/bar/" + == "workspace/%/foo/bar/" ) @@ -142,21 +150,21 @@ def test_workspace_descendant_prefix_escapes_metachars() -> None: doesn't become 'match any single char'.""" assert ( _build_list_scripts_workspace_descendant_prefix("foo_bar") - == r"workspace/foo\_bar/" + == r"workspace/%/foo\_bar/" ) def test_workspace_descendant_prefix_escapes_percent() -> None: assert ( _build_list_scripts_workspace_descendant_prefix("100%match") - == r"workspace/100\%match/" + == r"workspace/%/100\%match/" ) def test_workspace_descendant_prefix_normalizes_leading_trailing_slashes() -> None: assert ( _build_list_scripts_workspace_descendant_prefix("/foo/bar/") - == "workspace/foo/bar/" + == "workspace/%/foo/bar/" ) @@ -198,8 +206,10 @@ async def test_list_scripts_where_clause_uses_like_prefix_and_excludes_deeper() assert len(captured_sql) == 1 sql = captured_sql[0].lower() - assert "like 'workspace/foo/bar/%%'" in sql - assert "not like 'workspace/foo/bar/%%/%%'" in sql + # 中间 ``%`` 是 owner 段通配符(跨所有 owner),父路径本身按字面匹配, + # 与 list_resources 对 object_key 的过滤一致。 + assert "like 'workspace/%%/foo/bar/%%'" in sql + assert "not like 'workspace/%%/foo/bar/%%/%%'" in sql async def test_list_scripts_where_clause_escapes_pattern_literal() -> None: @@ -228,9 +238,9 @@ async def test_list_scripts_where_clause_escapes_pattern_literal() -> None: # doubles the escape char inside the SQL string literal, so what # the helper emits as `foo\_bar` renders as `foo\\_bar` here # (2 backslash chars in the actual SQL string). - assert r"like 'workspace/foo\\_bar/%%'" in sql_lower + assert r"like 'workspace/%%/foo\\_bar/%%'" in sql_lower # NOT LIKE clause also escaped. - assert r"not like 'workspace/foo\\_bar/%%/%%'" in sql_lower + assert r"not like 'workspace/%%/foo\\_bar/%%/%%'" in sql_lower # And both declare ESCAPE '\\'. assert sql.count("ESCAPE '\\\\'") == 2, sql @@ -257,7 +267,7 @@ async def test_list_scripts_where_clause_escapes_percent_pattern() -> None: sql_lower = sql.lower() # SQLAlchemy doubles the escape char so `%` → `\%` becomes `\\%` # in the SQL string literal. - assert r"workspace/100\\%%match/%%" in sql_lower + assert r"workspace/%%/100\\%%match/%%" in sql_lower async def test_list_scripts_non_admin_adds_visibility_filter() -> None: @@ -281,7 +291,9 @@ async def test_list_scripts_non_admin_adds_visibility_filter() -> None: await list_scripts(parent_path="", context=_ctx("alice"), session=mock_session) sql = captured_sql[0].lower() - assert "like 'workspace/%%'" in sql + # Root listing wildcards the owner segment: LIKE workspace/%/% + # (each owner's root files), excluding 3+ segment descendants. + assert "like 'workspace/%%/%%'" in sql assert "scripts.owner_user_id = 'alice'" in sql assert "scripts.visibility in ('workspace', 'public')" in sql @@ -307,7 +319,7 @@ async def test_list_scripts_admin_skips_visibility_filter() -> None: parent_path="", context=_ctx("alice", is_admin=True), session=mock_session ) sql = captured_sql[0].lower() - assert "like 'workspace/%%'" in sql + assert "like 'workspace/%%/%%'" in sql # owner_user_id / visibility still appear in the SELECT projection; what # must be absent is the visibility WHERE predicate for non-admins. assert "scripts.visibility in ('workspace', 'public')" not in sql @@ -439,4 +451,273 @@ def test_sqlite_like_with_percent_in_name(sqlite_like_table): ) ).fetchall() matched = sorted(r[0] for r in rows) - assert matched == ["workspace/alice/100%off/x.py"], matched \ No newline at end of file + assert matched == ["workspace/alice/100%off/x.py"], matched + +# ─── layer 3.5: workspace-wide subdirectory semantics ────────────── + + +@pytest.fixture +def sqlite_workspace_table(): + """SQLite table of physical relative_path rows (workspace/{owner}/...).""" + engine = create_engine("sqlite:///:memory:") + metadata = MetaData() + table = Table( + "paths", + metadata, + Column("relative_path", String(1024), nullable=False), + ) + metadata.create_all(engine) + with engine.begin() as conn: + conn.execute( + table.insert(), + [ + # Direct children of `foo/bar` across two owners. + {"relative_path": "workspace/alice/foo/bar/a.py"}, + {"relative_path": "workspace/bob/foo/bar/b.py"}, + # Deeper than one level under foo/bar → must be excluded. + {"relative_path": "workspace/bob/foo/bar/nested/c.py"}, + # Sibling folder `barX` / `foobar` → must be excluded. + {"relative_path": "workspace/alice/foo/barX/decoy.py"}, + {"relative_path": "workspace/alice/foobar/x.py"}, + # Root-level files (not under any subdir). + {"relative_path": "workspace/alice/root.py"}, + ], + ) + yield engine, table + engine.dispose() + + +def _run_like(query_table, prefix: str, escape: str = "\\"): + """Run the endpoint's LIKE + NOT LIKE direct-child pair on SQLite. + + Mirrors list_scripts exactly: LIKE ``prefix + '%'`` and + NOT LIKE ``prefix + '%/%'``.""" + engine, table = query_table + stmt = select(table.c.relative_path).where( + table.c.relative_path.like(f"{prefix}%", escape=escape), + ~table.c.relative_path.like(f"{prefix}%/%", escape=escape), + ) + with engine.connect() as conn: + rows = conn.execute(stmt).fetchall() + return sorted(r[0] for r in rows) + + +def test_sqlite_workspace_subdir_prefix_matches_direct_children_across_owners( + sqlite_workspace_table, +) -> None: + """parent_path='foo/bar' must return each owner's DIRECT children of + foo/bar — the workspace-wide contract for lazy directory loading.""" + prefix = _build_list_scripts_workspace_descendant_prefix("foo/bar") + matched = _run_like(sqlite_workspace_table, prefix) + assert matched == [ + "workspace/alice/foo/bar/a.py", + "workspace/bob/foo/bar/b.py", + ], matched + + +def test_sqlite_workspace_root_prefix_matches_each_owner_root( + sqlite_workspace_table, +) -> None: + """parent_path='' must return root-level files across all owners — + requirement: empty parent_path defaults to pulling root-path files.""" + prefix = _build_list_scripts_workspace_descendant_prefix("") + matched = _run_like(sqlite_workspace_table, prefix) + # Direct children of each owner's root = that owner's root-level file. + assert matched == ["workspace/alice/root.py"], matched + + +def test_sqlite_workspace_subdir_escapes_underscore_across_owners() -> None: + """Escaping still works with the owner wildcard added: `foo_bar` must + not match `fooXbar` under ANY owner.""" + engine = create_engine("sqlite:///:memory:") + metadata = MetaData() + table = Table( + "paths", + metadata, + Column("relative_path", String(1024), nullable=False), + ) + metadata.create_all(engine) + with engine.begin() as conn: + conn.execute( + table.insert(), + [ + {"relative_path": "workspace/alice/foo_bar/inner.py"}, + {"relative_path": "workspace/bob/foo_bar/inner.py"}, + {"relative_path": "workspace/alice/fooXbar/decoy.py"}, + ], + ) + prefix = _build_list_scripts_workspace_descendant_prefix("foo_bar") + matched = _run_like((engine, table), prefix) + assert matched == [ + "workspace/alice/foo_bar/inner.py", + "workspace/bob/foo_bar/inner.py", + ], matched + engine.dispose() + + +# ─── layer 4: single-script visibility enforcement ──────────────── + + +def _make_script_for_can_view( + *, owner_user_id: str = "U001", visibility: str = "private" +) -> SimpleNamespace: + return SimpleNamespace( + script_id="S1", + workspace_id="W001", + current_object_id="O1", + owner_user_id=owner_user_id, + script_name="x.py", + script_type="python", + visibility=visibility, + status="active", + is_locked=0, + ) + + +class TestScriptCanView: + """同一 workspace 内:owner 永远可见自己的脚本(含 private); + 其他成员只见 visibility in {workspace, public} 的脚本; + admin 全部可见——与 data resources 的 can_view 对称。""" + + @staticmethod + def _viewer() -> SimpleNamespace: + return _ctx("U002") # not the owner + + @staticmethod + def _owner() -> SimpleNamespace: + return _ctx("U001") + + @staticmethod + def _admin() -> SimpleNamespace: + return _ctx("U002", is_admin=True) + + def test_owner_can_view_own_private_script(self) -> None: + from backend.api.scripts import script_can_view + + assert ( + script_can_view( + _make_script_for_can_view(visibility="private"), + self._owner(), + ) + is True + ) + + def test_workspace_member_cannot_view_others_private_script(self) -> None: + from backend.api.scripts import script_can_view + + assert ( + script_can_view( + _make_script_for_can_view(visibility="private"), + self._viewer(), + ) + is False + ) + + def test_workspace_member_can_view_others_workspace_script(self) -> None: + from backend.api.scripts import script_can_view + + assert ( + script_can_view( + _make_script_for_can_view(visibility="workspace"), + self._viewer(), + ) + is True + ) + + def test_workspace_member_can_view_others_public_script(self) -> None: + from backend.api.scripts import script_can_view + + assert ( + script_can_view( + _make_script_for_can_view(visibility="public"), + self._viewer(), + ) + is True + ) + + def test_admin_can_view_others_private_script(self) -> None: + from backend.api.scripts import script_can_view + + assert ( + script_can_view( + _make_script_for_can_view(visibility="private"), + self._admin(), + ) + is True + ) + + +def _script_row_for_get(script: SimpleNamespace) -> SimpleNamespace: + """Build the (script, storage_object) tuple get_script_row returns.""" + storage = SimpleNamespace( + relative_path=f"workspace/{script.owner_user_id}/x.py", + object_key=f"W001/{script.owner_user_id}/x.py", + content_hash="h", + size_bytes=1, + ) + return SimpleNamespace(data=(script, storage)) + + +async def test_get_script_non_owner_private_returns_404() -> None: + """Non-owner must NOT read another member's private script by id + (mirrors get_visible_resource for data resources).""" + from datetime import datetime, timezone + + from backend.api.scripts import get_script + + script = _make_script_for_can_view(visibility="private") + script.created_at = datetime(2026, 1, 1, tzinfo=timezone.utc) + script.updated_at = datetime(2026, 1, 1, tzinfo=timezone.utc) + mock_session = MagicMock() + mock_session.execute = AsyncMock( + return_value=MagicMock(one_or_none=MagicMock(return_value=(script, None))) + ) + with pytest.raises(HTTPException) as exc: + await get_script("S1", context=_ctx("U002"), session=mock_session) + assert exc.value.status_code == 404 + + +async def test_get_script_owner_can_read_own_private_script() -> None: + from datetime import datetime, timezone + + from backend.api.scripts import get_script + + script = _make_script_for_can_view(visibility="private") + script.created_at = datetime(2026, 1, 1, tzinfo=timezone.utc) + script.updated_at = datetime(2026, 1, 1, tzinfo=timezone.utc) + storage = SimpleNamespace( + relative_path="workspace/U001/x.py", + object_key="W001/U001/x.py", + content_hash="h", + size_bytes=1, + ) + mock_session = MagicMock() + mock_session.execute = AsyncMock( + return_value=MagicMock(one_or_none=MagicMock(return_value=(script, storage))) + ) + result = await get_script("S1", context=_ctx("U001"), session=mock_session) + assert result["data"]["script_id"] == "S1" + assert result["data"]["visibility"] == "private" + + +async def test_get_script_non_owner_can_read_workspace_visible_script() -> None: + from datetime import datetime, timezone + + from backend.api.scripts import get_script + + script = _make_script_for_can_view(visibility="workspace") + script.created_at = datetime(2026, 1, 1, tzinfo=timezone.utc) + script.updated_at = datetime(2026, 1, 1, tzinfo=timezone.utc) + storage = SimpleNamespace( + relative_path="workspace/U001/x.py", + object_key="W001/U001/x.py", + content_hash="h", + size_bytes=1, + ) + mock_session = MagicMock() + mock_session.execute = AsyncMock( + return_value=MagicMock(one_or_none=MagicMock(return_value=(script, storage))) + ) + result = await get_script("S1", context=_ctx("U002"), session=mock_session) + assert result["data"]["script_id"] == "S1" + assert result["data"]["visibility"] == "workspace" -- 2.54.0 From b4939077752941c1fd3d3372fc429a0f1e4615b3 Mon Sep 17 00:00:00 2001 From: "tao.chen" Date: Fri, 21 Aug 2026 19:26:53 +0800 Subject: [PATCH 30/93] =?UTF-8?q?feat(scripts):=20=E8=B7=A8=20owner=20?= =?UTF-8?q?=E6=87=92=E5=8A=A0=E8=BD=BD=E7=9B=AE=E5=BD=95=E6=A0=91=20+=20?= =?UTF-8?q?=E8=B7=A8=E7=94=A8=E6=88=B7=E5=8F=AF=E8=A7=81=20workspace/publi?= =?UTF-8?q?c?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修两个后端接口问题: 1) /api/v1/workspace-directories 返回为空,目录树结构消失 2) 同 workspace 内脚本/数据互相可见但默认排除 private 后端改动 -------- * list_scripts / list_resources / list_workspace_directories 新增 owner_user_id 可选 query 参数;缺省 = 当前请求者本人(scope 到 workspace/{me}/...),传值时 scope 到该 owner 的子树。前端根加载 默认只见自己一级,其他成员以折叠分组呈现。 * visibility 过滤统一:非 admin 请求者只返回 owner==me 或 visibility ∈ {workspace, public};admin 跳过。owner=me 含自己 的 private,owner=other 只剩其 workspace/public,排除他人 private。 * create_workspace_directory 两个分支 visibility 默认 'public' (非 private),使跨 owner 目录树可见;响应新增 owner_user_id 字段。 * platform.list_members 鉴权从 system_admin_context 放宽为 系统管理员或该 workspace 活跃成员(让普通用户也能渲染同 workspace 成员名册,用于跨 owner 分组)。 * main.py 注册 platform 模块(随 list_members 改动补齐导入)。 * .env.example 同步 common/config.py 26 个字段。 前端改动 -------- * ScriptExplorer.memberScriptGroups 改由 members 列表播种分组, display_name 取 members.display_name;inferredDirectories 现在按 owner_user_id 标记,统一跨 owner 目录渲染。删除脚本目录页头与 树分组标题的工作副本数量角标。 * WorkspaceTree 新增 ownerUserId 透传到 store.toggleExpanded; 仅"我"的分组 mount 时 auto-expand,他人分组默认折叠,展开才 调 loadOwnerGroup / owner-scoped loadScripts / loadChildren。 * scriptWorkspaceStore 引入 namespaced cache key (ownerCacheKey = `${ownerUserId ?? me}:${path}`),loadedScriptPaths / loadedChildPaths / loadedOwnerGroups 全部按 owner 隔离; toggleExpanded 用 loadPath === undefined 区分 group 头与真实 目录,修"他人子目录点击不触发接口"的 loadPath 前缀误判 bug。 * api.ts / AuthContext 透传 ownerUserId 给 listScripts / listResources / listWorkspaceDirectories。 文档 ---- * API.md: §3.2 创建目录 visibility 默认 public + 响应加 owner_user_id; §3.3.1 GET directories 加 owner_user_id 参数 + 响应字段; §3.4 GET scripts 改写为 owner 作用域 + visibility 过滤语义; §五.1 GET data-resources 新增,同一套统一语义; §7 intro 例外 — GET members 对系统管理员或 workspace 活跃成员开放。 * DEVELOP.md: Code layout 重写以反映 backend api/services/clients/ schemas 拆分 + schedule domain/scheduling/application/execution/ infrastructure 拆分 + common 子包(auth/storage/backends); Configuration 系统补全 26 个 settings 字段;新增 "Owner-scoping + visibility (cross-owner browsing)" 小节; Per-service dev 注释用 uv run 的源布局要求;Add a new DAG endpoint / storage bucket 路径改为 backend/src/backend/api/* 与 services/*。 测试 ---- * test_list_scripts_parent_path.py / test_resources.py 补充 owner_user_id 参数化直接调用 + LIKE 前缀断言(workspace/{owner}/... 前缀)。 Co-Authored-By: Claude --- .env.example | 28 ++ API.md | 63 ++- DEVELOP.md | 198 +++++++--- backend/src/backend/api/platform.py | 45 ++- backend/src/backend/api/resources.py | 56 +-- backend/src/backend/api/scripts.py | 85 ++-- backend/src/backend/main.py | 8 +- .../tests/test_list_scripts_parent_path.py | 153 ++++++-- backend/tests/test_resources.py | 85 +++- .../components/platform/ScriptExplorer.tsx | 54 ++- frontend/app/context/AuthContext.tsx | 7 +- .../app/features/platform/WorkspaceTree.tsx | 28 +- .../platform/state/scriptWorkspaceStore.ts | 363 ++++++++++++++---- frontend/app/routes/platform.tsx | 9 + frontend/app/services/api.ts | 50 ++- 15 files changed, 929 insertions(+), 303 deletions(-) diff --git a/.env.example b/.env.example index ea864ab..7ea6fae 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,15 @@ DATABASE_URL=mysql+asyncmy://root:change-me@127.0.0.1:3306/model_platform?charse JWT_SECRET=change-this-development-secret +# Force the Secure flag on the session cookie even when the inbound request +# scheme is plain HTTP. Enable behind a TLS-terminating reverse proxy that +# strips/rewrites X-Forwarded-Proto — otherwise the cookie is written without +# Secure and browsers refuse to send it back over HTTPS. +COOKIE_FORCE_SECURE=false + +# Service label surfaced in lifespan / health checks. +SERVICE_NAME=service + # ============================================================================ # CRITICAL: must set BEFORE first run. The initial admin user is seeded by # the deployment bootstrap. Never keep the development default in production. @@ -84,6 +93,13 @@ S3_TRASH_RETENTION_DAYS=30 # over the compose network. RCLONE_RC_URL=http://runtime:5572 +# Backend → Runtime HTTP endpoint (Jupyter contents API, file ops). +RUNTIME_API_URL=http://runtime:8000 + +# Public base URL for the runtime container (surfaced to clients for +# Jupyter access tickets / embedded URLs). +PUBLIC_BASE_URL=http://runtime + # ============================================================================ # Service-to-service auth (P0-1 fix). # Backend's /internal/v1/* storage control plane requires this shared secret. @@ -93,3 +109,15 @@ RCLONE_RC_URL=http://runtime:5572 # python -c "import secrets; print(secrets.token_urlsafe(48))" # ============================================================================ INTERNAL_SERVICE_TOKEN=change-me-internal-service-token + +# Schedule → Backend HTTP base URL (cron post-back / status callbacks). +BACKEND_API_URL=http://backend:8000 + +# Max concurrent notebooks running in the schedule worker. Each notebook is +# dispatched as an asyncio task bounded by a semaphore; the polling loop is +# never blocked. +SCHEDULE_EXECUTION_CONCURRENCY=4 + +# Readiness probe targets. Comma-separated host:port list checked by +# /health/ready; empty disables the check. e.g. mysql:3306,s3:9000 +READINESS_TARGETS= diff --git a/API.md b/API.md index c6ec642..9afa626 100644 --- a/API.md +++ b/API.md @@ -99,7 +99,12 @@ ### 3.2 `POST /api/v1/workspace-directories` -创建一个**目录**。后端会落一行 `StorageObjects(object_type='directory', storage_backend='rustfs', storage_uri='inline://directory/{path}', visibility='private')`,因此空目录也能在 §3.1 树里出现并保留下来。 +创建一个**目录**。后端会落一行 `StorageObjects(object_type='directory', storage_backend='rustfs', storage_uri='inline://directory/{path}', visibility='public')`,因此空目录也能在 §3.1 树里出现并保留下来。 + +> 目录行默认 `visibility='public'`(非 `private`)。目录是结构性导航行, +> 默认 public 使同一 workspace 内其他成员可以浏览彼此的目录结构(目录树 +> 跨 owner 可见);文件级私密仍由 §3.4 / §五 的 visibility 过滤兜底 +> —— 其他 owner 的 `private` 脚本 / 数据资源不会返回。 - **请求体**: ```json @@ -123,7 +128,8 @@ "storage_object_id": "01HXY...", "path": "scripts/etl", "name": "etl", - "parent_path": "scripts" + "parent_path": "scripts", + "owner_user_id": "01HXX..." } } ``` @@ -155,10 +161,12 @@ - **行为**: `parent_path` 为空字符串或缺省 → 用户根目录;非空 → 该父目录的直接子目录(workspace-relative)。仅返回 `object_status='available' AND is_deleted=0` 的 `StorageObjects` 行。 - **鉴权**: workspace 成员 +- **owner 作用域**: `owner_user_id` 缺省时 scope 为**当前请求者**本人根目录(`scoped_prefix = workspace/{me}`);传 `owner_user_id` 时 scope 为该 owner 的根目录(`scoped_prefix = workspace/{owner_user_id}`),用于跨 owner 浏览目录树(见 §3.4 visibility 模型)。该接口本身不施加 visibility 过滤——目录行默认 `visibility='public'`(见 §3.2),跨 owner 均可见。 - **查询参数**: | 名 | 类型 | 必填 | 说明 | |---|---|---|---| | `parent_path` | string | 否 | 父目录相对路径,空字符串或缺省表示用户根目录 | + | `owner_user_id` | string | 否 | 目标 owner 的 user_id;缺省=请求者本人。指定后 scope 到 `workspace/{owner_user_id}/{parent_path}` | - **谓词(SQL 等价)**: `relative_path LIKE '/%' AND relative_path NOT LIKE '/%/%'`,其中 `prefix = scoped_prefix/{parent_path}`,索引走 `idx_storage_workspace_relative_path(workspace_id, relative_path(255))`。 - **响应**: ```json @@ -166,9 +174,9 @@ "request_id": "...", "data": { "directories": [ - {"path": "scripts", "name": "scripts", "parent_path": "", "has_children": true}, - {"path": "scripts/etl", "name": "etl", "parent_path": "scripts", "has_children": false}, - {"path": "scripts/etl/daily", "name": "daily", "parent_path": "scripts/etl", "has_children": false} + {"path": "scripts", "name": "scripts", "parent_path": "", "owner_user_id": "01HXX...", "has_children": true}, + {"path": "scripts/etl", "name": "etl", "parent_path": "scripts", "owner_user_id": "01HXX...", "has_children": false}, + {"path": "scripts/etl/daily", "name": "daily", "parent_path": "scripts/etl", "owner_user_id": "01HXX...", "has_children": false} ] }, "meta": {"directory_count": 3} @@ -180,15 +188,28 @@ | `path` | string | workspace 内相对路径 | | `name` | string | `path` 的最后一段 | | `parent_path` | string | 父目录相对路径,根目录用空串 | + | `owner_user_id` | string | 该目录行所属 owner 的 user_id(`owner_user_id` 参数缺省时=请求者本人) | | `has_children` | bool | 该目录下是否还有直接子目录(后端额外 `EXISTS` 查询,可为空目录为 `false`) | - **空结果**: 不返回 404,空目录列表即 `directories: []`。 - **错误**: 401(未登录)/ 403(非 workspace 成员)同其他接口。 ### 3.4 `GET /api/v1/scripts` -列出当前 workspace 内**全部 active 脚本**。不受 is_locked 影响(读路径不锁)。 +列出脚本,按 **owner 作用域 + visibility 过滤**返回。不受 is_locked 影响(读路径不锁)。 -- **响应**: `data` 为 `ScriptPayload` 数组(见 §3.10)。 +- **owner 作用域**: `owner_user_id` 缺省=当前请求者本人,scope 到 `workspace/{me}/...`;传 `owner_user_id` 时 scope 到 `workspace/{owner_user_id}/...`,用于跨 owner 浏览他人脚本。 +- **visibility 过滤(统一)**: 非 admin 请求者只返回 `owner_user_id == me OR visibility IN (workspace, public)`;admin 请求者跳过过滤返回全部。 + - **owner=me(缺省)**: scope 是我的子树,行都是我的 → `owner==me` 恒成立 → **含我的 private 脚本** ✓ + - **owner=other**: scope 是他人的子树,`owner==me` 不成立 → 只剩其 `workspace/public` 脚本(排除他人的 `private`) ✓ + - 即"本人可见自己全部;他人只见其 workspace/public",私密仅在 owner==me 时可见。 +- **非递归**: 仅返回 `parent_path` 下的**直接子级**脚本(懒加载用);子目录脚本需带 `parent_path` 再次请求。 +- **查询参数**: + | 名 | 类型 | 必填 | 说明 | + |---|---|---|---| + | `parent_path` | string | 否 | 父目录相对路径,空串/缺省=该 owner 根目录下的一级脚本 | + | `owner_user_id` | string | 否 | 目标 owner;缺省=请求者本人 | + | `keyword` | string | 否 | 名称模糊匹配 | +- **响应**: `data` 为 `ScriptPayload` 数组(见 §3.10),每条带 `owner_user_id`。 ### 3.5 `GET /api/v1/scripts/{script_id}` @@ -485,7 +506,7 @@ queued ──→ running ──┬─→ succeeded | `POST` | `/api/v1/data-resources/uploads` | 创建上传会话,返回 `upload_id` + `upload_path` | | `PUT` | `/api/v1/data-resources/uploads/{upload_id}` | 上传字节(请求体即文件内容) | | `POST` | `/api/v1/data-resources/uploads/{upload_id}/bind` | 绑定已完成上传为数据资源 | -| `GET` | `/api/v1/data-resources` | 列表(workspace 范围) | +| `GET` | `/api/v1/data-resources` | 列表(owner 作用域 + visibility 过滤) | | `GET` | `/api/v1/data-resources/{id}` | 详情 | | `POST` | `/api/v1/data-resources/{id}/download-url` | 生成 presigned GET URL | | `DELETE` | `/api/v1/data-resources/{id}` | 软删 | @@ -521,6 +542,22 @@ queued ──→ running ──┬─→ succeeded `content_base64` 字段(JSON 体里走),内部走同一条 `AsyncStorageBackend.put` 路径,前端无需分两步。 +### 五.1 `GET /api/v1/data-resources` + +列出数据资源,按 **owner 作用域 + visibility 过滤**返回(与 §3.4 `GET /scripts` 同一套统一语义)。 + +- **owner 作用域**: `owner_user_id` 缺省=当前请求者本人,scope 到 `object_key` 前缀 `{workspace_id}/{me}/...`;传 `owner_user_id` 时 scope 到 `{workspace_id}/{owner_user_id}/...`。 +- **visibility 过滤(统一)**: 非 admin 请求者只返回 `owner_user_id == me OR visibility IN (workspace, public)`;admin 请求者跳过过滤返回全部。语义同 §3.4——owner=me 含自己的 private;owner=other 只见其 workspace/public。 +- **非递归**: 仅返回 `parent_path` 下的**直接子级**资源(懒加载用)。 +- **查询参数**: + | 名 | 类型 | 必填 | 说明 | + |---|---|---|---| + | `parent_path` | string | 否 | 父目录相对路径,空串/缺省=该 owner 根目录下的一级资源 | + | `owner_user_id` | string | 否 | 目标 owner;缺省=请求者本人 | + | `visibility` | string | 否 | `workspace` \| `public` \| `private`,二次过滤 | + | `keyword` | string | 否 | 名称模糊匹配 | +- **响应**: `data` 为 `ResourcePayload` 数组,每条带 `owner_user_id`。 + --- ## 六、管理后台 @@ -581,10 +618,16 @@ Base 前缀 `/api/v1/admin`。 ## 七、系统管理 (`/api/v1/platform/...`) 平台级(跨 workspace)管理接口,用于管理员工、workspace 实体与 workspace 成员。 -所有端点要求调用者是**系统管理员**——其 `users.platform_role_id` 指向 +除特别注明外,所有端点要求调用者是**系统管理员**——其 `users.platform_role_id` 指向 `role_code='admin'` 的角色行,且 `users.status == 'active'`。系统管理员判定 通过 `GET /api/v1/auth/me` 响应中的 `data.user.is_system_admin` 字段(详见 §一)。 +> **例外 — `GET /workspaces/{id}/members`**:该端点对**系统管理员(任意 +> workspace)**与**该 workspace 的活跃成员**(`workspace_members.is_deleted=0` +> 且 `member_status='active'`)均开放。这是为了让普通(非 admin)用户能在 +> 脚本目录树里渲染同 workspace 其他成员的折叠分组(跨 owner 浏览,见 §3.4)。 +> 其余 members 写端点(POST/PATCH/DELETE members)仍仅限系统管理员。 + | 方法 | 路径 | 说明 | |---|---|---| | `GET` | `/api/v1/platform/employees` | 列全平台未软删员工;包含停用、锁定及无平台角色用户 | @@ -596,7 +639,7 @@ Base 前缀 `/api/v1/admin`。 | `GET` | `/api/v1/platform/workspaces/{workspace_id}` | 单个 workspace(含已 disabled 的,用于恢复) | | `PATCH` | `/api/v1/platform/workspaces/{workspace_id}` | 改 workspace 字段;`status` 仅允许 `active`/`archived` | | `DELETE` | `/api/v1/platform/workspaces/{workspace_id}` | 软删 workspace;级联软删其成员 | -| `GET` | `/api/v1/platform/workspaces/{workspace_id}/members` | 列成员 | +| `GET` | `/api/v1/platform/workspaces/{workspace_id}/members` | 列成员(**系统管理员或该 workspace 活跃成员**;为跨 owner 目录树提供成员名册,见 §7 intro 例外) | | `POST` | `/api/v1/platform/workspaces/{workspace_id}/members` | 添加成员(返回 201) | | `PATCH` | `/api/v1/platform/workspaces/{workspace_id}/members/{user_id}` | 改成员 `member_status`;**不能改 role_code**(workspace 角色继承自平台角色) | | `DELETE` | `/api/v1/platform/workspaces/{workspace_id}/members/{user_id}` | 软删成员 | diff --git a/DEVELOP.md b/DEVELOP.md index ce0e26d..bb79217 100644 --- a/DEVELOP.md +++ b/DEVELOP.md @@ -6,58 +6,89 @@ refactors see `HANDOVER.md`. ## Code layout +All Python packages use the `src//` layout; `uv` workspace glues them into +one `.venv`. Always invoke via `uv run [--package ] ` (see "Local +development" for the gotcha). + ``` -common/ Pure-Python shared library - config.py Settings (pydantic-settings, lru_cache singleton) - db/ SQLAlchemy 2.0 async engine, session_scope, Base - db/models/ 26 tables in 9 domain files (zero FK, zero relationship) - scheduler/ build_sqlalchemy_jobstore (delayed import) - storage/ AsyncStorageBackend abstraction (s3 + local impls) + Pydantic schemas - eventing.py add_outbox_event / utcnow / event_time - service_app.py /health/ready TCP probe, /api/v1/health - schemas.py StrictModel base - utils.py get_free_port, start_process +common/src/common/ Pure-Python shared library + config.py Settings (pydantic-settings, lru_cache singleton) + db/ SQLAlchemy 2.0 async engine, session_scope, Base + db/models/ 26 tables in 9 domain files (zero FK, zero relationship) + auth/ JWT / bcrypt / workspace membership helpers + scheduler/ APScheduler trigger helpers (delayed import) + storage/ AsyncStorageBackend abstraction + Pydantic schemas + base.py Abstract interface + factory.py create_storage + build_storage_config + PURPOSE_BUCKETS + schemas.py CreateUploadRequest / ServerObjectRequest + backends/local.py Local filesystem impl + backends/s3.py S3-compatible impl (boto3) + registry.py Bucket registry + eventing.py add_outbox_event / utcnow / event_time + service_app.py /health/ready TCP probe, /api/v1/health + logging.py loguru config (LOG_LEVEL) + schemas.py StrictModel base + ids.py ULID generation helpers + utils.py get_free_port, start_process -backend/ Public FastAPI service + tiny /internal/v1/objects RPC - main.py lifespan + route registration - jupyter.py /api/v1/auth/jupyter — the ONLY auth entry - scripts.py CRUD for scripts/notebooks (object storage via AsyncStorageBackend) - schedules.py DAG CRUD: schedules, nodes, edges - schedule_runs.py Trigger / list / get runs - schedule_schemas.py Pydantic request/response models - admin.py Admin endpoints - resources.py Misc data resources - storage_api.py /internal/v1/objects — single token-guarded endpoint (P0-1) - storage_client.py Stub (HTTP client removed post-migration; rewrite pending) - schedule_client.py Placeholder module (was the HTTP-push executor client) - runtime_client.py Self-contained httpx wrapper for the runtime - jupyter.py auth_request handler - dependencies.py request_context, database_session +backend/src/backend/ Public FastAPI service + main.py lifespan + route registration + audit.py HTTP access log middleware (loguru sink) + api/ HTTP route handlers (one module per bounded context) + auth.py /api/v1/auth/* (login / me / jupyter) + jupyter.py /api/v1/auth/jupyter — the ONLY auth entry + dependencies.py request_context, database_session + platform.py /api/v1/platform/* (system admin) + admin.py /api/v1/admin/* (workspace-internal admin) + scripts.py /api/v1/scripts/* + /api/v1/workspace-directories + resources.py /api/v1/data-resources/* + schedules/schedules.py DAG CRUD + schedules/runs.py Run lifecycle + storage.py /internal/v1/objects — single token-guarded endpoint (P0-1) + services/ Pure-Python business logic (no HTTP / no DI) + scripts.py create_workspace_directory, visibility-filtered queries + resources.py owner-scoped resource listing helpers + jupyter.py jupyter_path / lock helpers + storage.py object store helpers + schedules.py DAG validation (cycle / orphan detection) + schemas/ Pydantic request / response models + auth.py / common.py / jupyter.py / platform.py / resources.py / schedules.py / scripts.py + clients/ Outbound HTTP / RPC clients + runtime.py Self-contained httpx wrapper for the runtime + scheduler.py Backend → Schedule HTTP client (callback / dispatch) + rclone.py rclone RC API client (FUSE cache invalidation) -schedule/ Schedule Executor (DAG worker) - context.py Constants + naive_utc - scheduler.py CronScheduler (APScheduler + 5s sync loop) - orchestrator.py DispatchOrchestrator (Outbox poll + DAG advance) - worker.py NodeExecutor (notebook / python execution) - service.py SchedulerService facade (composes the three) - main.py Lifespan + FastAPI app - storage_client.py SchedulerStorageClient — talks to backend /internal/v1/objects - execution.py execute_artifact (notebook + python paths) - notebook_runner.py Subprocess entry point (nbclient) +schedule/src/schedule/ Schedule Executor (DAG worker) + main.py Lifespan + FastAPI app + notebook_runner.py Subprocess entry point (nbclient) — DO NOT RENAME + domain/ Pure-Python domain types + execution.py ExecutionResult (frozen dataclass) + state enums + context.py Constants + naive_utc + scheduling/ Time-based trigger + scheduler.py CronScheduler (APScheduler + 5s sync loop) + application/ Facades / orchestrators + service.py SchedulerService (composes the three) + orchestrator.py DispatchOrchestrator (Outbox poll + DAG advance) + execution/ DAG node execution + executor.py NodeExecutor (notebook / python dispatch) + worker.py asyncio entry, schedule-spawned task boundary + runners/notebook.py nbclient subprocess path (6-line `notebook_runner` shim re-exports `main`) + infrastructure/ External-system adapters + storage/client.py SchedulerStorageClient — talks to backend /internal/v1/objects -runtime/ Jupyter Runtime - main.py FastAPI entry: jupyter action endpoints - process.py Per-workspace subprocess pool + asyncio locks - mount.py rclone FUSE mount lifecycle +runtime/src/runtime/ Jupyter Runtime + main.py FastAPI entry: jupyter action endpoints + process.py Per-workspace subprocess pool + asyncio locks + mount.py rclone FUSE mount lifecycle -frontend/ React Router SPA (vite build → nginx) - app/ features/ routes/ services/ components/ +frontend/ React Router SPA (vite build → nginx) + app/ features/ routes/ services/ components/ -migrations/ Alembic schema versions -docker-compose.yml 4 services -default.conf Nginx template +migrations/ Alembic schema versions +docker-compose.yml 4 services (gateway / backend / schedule / runtime) +default.conf Nginx template scripts/nginx-entrypoint.sh -.env.example +.env.example All 26 config.py keys documented ``` ## Configuration system @@ -67,10 +98,31 @@ All env vars go through one place: `common/src/common/config.py`. ```python from common.config import settings -settings.database_url # str -settings.storage_backend # str: "s3" (default) or "local" -settings.local_storage_base_dir # str: root dir for storage data (default "/data"); see "Storage" below for per-mode derivation -settings.s3_endpoint # str (full URL, e.g. "http://s3:9000"; s3 mode only) +# Auth / runtime +settings.database_url # str — SQLAlchemy async URL (mysql+asyncmy, charset utf8mb4) +settings.jwt_secret # HS256 secret for the auth_request handler +settings.cookie_force_secure # bool — write Secure flag even on plain HTTP (TLS-terminating proxy) +settings.service_name # surfaced in /health +settings.schedule_event_namespace # APScheduler JobStore namespace + Outbox scope prefix +settings.readiness_targets # CSV host:port list for /health/ready + +# HTTP clients (intra-cluster URLs) +settings.runtime_api_url # backend → runtime HTTP base +settings.public_base_url # runtime public base URL (browser-facing /jupyter/) +settings.backend_api_url # schedule → backend HTTP base +settings.rclone_rc_url # backend → rclone RC control API +settings.internal_service_token # Backend ↔ Schedule shared secret (X-Internal-Service-Token) + +# Logging / audit +settings.log_level # DEBUG / INFO / WARNING / ERROR / CRITICAL (lowercase → fallback INFO) +settings.audit_log_dir # dir for daily audit logs (relative to cwd; "" disables file sink) +settings.audit_log_retention_days # 0 disables cleanup +settings.audit_excluded_paths # list[str] — paths skipped from audit (health probes, etc.) + +# Storage +settings.storage_backend # "s3" (default) or "local" +settings.local_storage_base_dir # root dir for storage data (default "/data") +settings.s3_endpoint # str (s3 mode only) settings.s3_access_key # str (s3 mode only) settings.s3_secret_key # str (s3 mode only) settings.s3_workspace_bucket # str (s3 mode only) @@ -78,17 +130,15 @@ settings.s3_version_bucket # str (s3 mode only) settings.s3_run_log_bucket # str (s3 mode only) settings.s3_trash_bucket # str (s3 mode only) settings.s3_trash_retention_days # int (s3 mode only) -settings.jwt_secret # HS256 secret for the auth_request handler -settings.backend_api_url # schedule → backend HTTP base -settings.runtime_api_url # backend → runtime HTTP base -settings.public_base_url # runtime public base URL -settings.service_name # surfaced in /health -settings.readiness_targets # CSV host:port list for /health/ready + +# Schedule +settings.schedule_execution_concurrency # int — max concurrent notebook subprocesses ``` `Settings` reads from process env first, then from a `.env` file at CWD if present. `pydantic-settings` auto-loads. `case_sensitive=False` so -`DATABASE_URL` / `database_url` both work. +`DATABASE_URL` / `database_url` both work. The full list of 26 fields +is in `common/src/common/config.py`. ### Adding a new env var @@ -96,7 +146,8 @@ if present. `pydantic-settings` auto-loads. `case_sensitive=False` so ```python new_var: str = Field(default="x", description="...") ``` -2. Add the line to `.env.example` with a comment. +2. Add the line to `.env.example` with a comment (keep it synced — every + field in config.py must have a matching `.env.example` entry). 3. Use `settings.new_var` at the call site. Do **not** call `os.environ["NEW_VAR"]` or `os.getenv("NEW_VAR")` in @@ -190,13 +241,34 @@ grep -rnE 'os\.(environ\[?["\x27][A-Z_]+|getenv\(["\x27][A-Z_]+)' --include="*.p - For write operations on a script/notebook, call `require_script_modify_access(script, user_id=..., is_admin=...)` - from `backend/scripts.py`. It enforces: + from `backend/src/backend/services/scripts.py`. It enforces: - admin or owner → allow - non-owner, `is_locked == 0` → allow - non-owner, `is_locked == 1` → 403 - Read endpoints (`list_scripts`, `get_script`) intentionally do **not** check `is_locked` — workspace members can see the script list. +### Owner-scoping + visibility (cross-owner browsing) + +`GET /api/v1/scripts`, `GET /api/v1/data-resources`, and +`GET /api/v1/workspace-directories` all accept an optional +`owner_user_id` query param and follow the same model: + +- `owner_user_id` 缺省 = 当前请求者本人(scope to `workspace/{me}/...`). +- 传值时 scope 到 `workspace/{owner_user_id}/...`,用于前端"点开其他成员 + 分组"的懒加载(见 §3.4 of `API.md`). +- `visibility` 过滤(非 admin):`owner_user_id == me OR visibility IN + (workspace, public)` — 自己可见自己全部(含 private),他人只见其 + workspace/public,排除他人 private. +- 系统管理员跳过 visibility 过滤. +- 目录行默认 `visibility='public'`(由 `create_workspace_directory` 写入), + 不施加 visibility 过滤,使跨 owner 目录树可见. + +实现 helper 在 `backend/src/backend/services/scripts.py` +(`_build_list_scripts_owner_descendant_prefix`) 和 +`backend/src/backend/api/resources.py` 中按相同模式分别构造 owner-scoped +LIKE 前缀。 + ### Outbox events - The platform's only async-messaging fabric is the MySQL @@ -238,6 +310,10 @@ cd frontend && pnpm install && cd .. ### Per-service dev +Always go through `uv run` so the workspace `.venv` is used — bare +`uvicorn` / `python` resolves to system Python and `from backend.X` +imports fail with ModuleNotFoundError. + ```bash # Backend (terminal 1) export DATABASE_URL="mysql+asyncmy://model_platform:model_platform@127.0.0.1:3306/model_platform?charset=utf8mb4" @@ -248,13 +324,13 @@ export S3_ENDPOINT=http://127.0.0.1:9000 # Or for local mode: # export STORAGE_BACKEND=local # export LOCAL_STORAGE_BASE_DIR=/data -uv run --frozen --package backend uvicorn backend.main:app --host 0.0.0.0 --port 8000 --reload +uv run --package backend uvicorn backend.main:app --host 0.0.0.0 --port 8000 --reload # Schedule Executor (terminal 2) -uv run --frozen --package schedule uvicorn schedule.main:app --host 0.0.0.0 --port 8001 --reload +uv run --package schedule uvicorn schedule.main:app --host 0.0.0.0 --port 8001 --reload # Runtime (terminal 3 — needs SYS_ADMIN, FUSE, devmode) -uv run --frozen --package runtime python -m runtime.main +uv run --package runtime python -m runtime.main ``` ### Frontend dev diff --git a/backend/src/backend/api/platform.py b/backend/src/backend/api/platform.py index f368592..7b865ec 100644 --- a/backend/src/backend/api/platform.py +++ b/backend/src/backend/api/platform.py @@ -243,6 +243,21 @@ async def system_admin_context( ) +async def _is_system_admin(session: AsyncSession, user: Users) -> bool: + """True if ``user`` holds the platform-scoped admin role. + + Mirrors the check inside :func:`system_admin_context` so member-listing + endpoints can admit workspace members *or* system admins without pulling + in the full :class:`SystemAdminContext` (which 403s non-admins outright). + """ + if user.platform_role_id is None: + return False + platform_role = await session.scalar( + select(Roles).where(Roles.role_id == user.platform_role_id) + ) + return platform_role is not None and platform_role.role_code == "admin" + + # --------------------------------------------------------------------------- # Payload helpers # --------------------------------------------------------------------------- @@ -809,10 +824,33 @@ async def delete_workspace( @router.get("/workspaces/{workspace_id}/members") async def list_members( workspace_id: str, - context: SystemAdminContext = Depends(system_admin_context), + request: Request, session: AsyncSession = Depends(database_session), ) -> dict[str, Any]: - """List active and historical (non-soft-deleted) members of a workspace.""" + """List active and historical (non-soft-deleted) members of a workspace. + + Accessible to system admins (any workspace) and to active members of the + workspace itself. The script explorer calls this to seed the per-owner + directory-tree groups for non-admin users; visibility filters on the + scripts/data-resources endpoints still keep each peer's private content + hidden, so this only exposes membership (names), not private files. + """ + user = await current_user(request, session) + is_system_admin = await _is_system_admin(session, user) + if not is_system_admin: + membership = await session.scalar( + select(WorkspaceMembers).where( + WorkspaceMembers.workspace_id == workspace_id, + WorkspaceMembers.user_id == user.user_id, + WorkspaceMembers.is_deleted == 0, + WorkspaceMembers.member_status == "active", + ) + ) + if membership is None: + raise HTTPException( + status.HTTP_403_FORBIDDEN, + "需要系统管理员或该工作区成员权限", + ) await _load_workspace(session, workspace_id) rows = ( await session.execute( @@ -830,8 +868,9 @@ async def list_members( .limit(LIST_PAGE_SIZE) ) ).all() + request_id = request.headers.get("X-Request-ID") or new_ulid() return _envelope( - context.request_id, + request_id, [member_payload(u, r, m) for u, r, m in rows], {"count": len(rows), "page_size": LIST_PAGE_SIZE}, ) diff --git a/backend/src/backend/api/resources.py b/backend/src/backend/api/resources.py index b9b855e..8a16e6e 100644 --- a/backend/src/backend/api/resources.py +++ b/backend/src/backend/api/resources.py @@ -51,13 +51,13 @@ def _build_list_resources_descendant_prefix(parent_path: str) -> str: """Return the escaped materialized-path prefix for direct children of ``parent_path`` against ``StorageObjects.object_key``. - Data resources are workspace-wide (no per-user scoping at the API - level). The full object_key is ``{ws_id}/{user_id}/{jupyter_path}``; - we filter on object_key with the pattern ``{ws_id}/%/{parent_path}`` - so any owner whose jupyter_accessible_path starts with parent_path - matches. LIKE wildcards in parent_path are escaped; the ``%`` between - ``{ws_id}/`` and the escaped parent is an intentional SQL wildcard - matching the ``owner_user_id`` segment across all owners. + The full object_key is ``{ws_id}/{owner_user_id}/{jupyter_path}``. + ``list_resources`` prepends ``{ws_id}/{owner_user_id}`` (the requester + by default, or the ``owner_user_id`` query param) to this prefix and + applies ``LIKE '{ws_id}/{owner}/{prefix}%' AND NOT LIKE '...%/%'`` so + only that owner's direct children under ``parent_path`` match. LIKE + wildcards in parent_path are escaped so folder names containing ``_`` + or ``%`` do not act as wildcards. """ normalized = normalize_user_path(parent_path) escaped = _escape_like_pattern(normalized) @@ -383,6 +383,7 @@ async def bind_resource( @router.get("") async def list_resources( parent_path: str = Query(default="", max_length=1024), + owner_user_id: str | None = Query(default=None, max_length=64), context: RequestContext = Depends(request_context), session: AsyncSession = Depends(database_session), visibility: str | None = Query(default=None), @@ -403,23 +404,30 @@ async def list_resources( ) .order_by(DataResources.updated_at.desc()) ) - if parent_path: - # ``parent_path`` scopes to DIRECT children of that jupyter path - # (matching /api/v1/scripts). Data resources are workspace-wide, so - # the middle ``%`` is an intentional wildcard that matches the - # ``owner_user_id`` segment across all owners. The parent's ``_`` / - # ``%`` are escaped so sibling folders (e.g. ``fooXbar``) don't leak. - descendant_prefix = _build_list_resources_descendant_prefix(parent_path) - statement = statement.where( - StorageObjects.object_key.like( - f"{context.workspace.workspace_id}/%/{descendant_prefix}%", - escape="\\", - ), - ~StorageObjects.object_key.like( - f"{context.workspace.workspace_id}/%/{descendant_prefix}%/%", - escape="\\", - ), - ) + # ``parent_path`` scopes to DIRECT children of that jupyter path + # (matching /api/v1/scripts). Per-owner listing: default (no + # owner_user_id) scopes to the requester's own object_key subtree + # (``{ws_id}/{me}/...``); passing owner_user_id scopes to that owner's + # subtree so the tree can lazily fetch another member's data resources + # on group expand. The parent's ``_`` / ``%`` are escaped so sibling + # folders (e.g. ``fooXbar``) don't leak. Empty parent_path still applies + # the filter: it resolves to that owner's root-level direct children + # (``{ws_id}/{owner}/%`` and NOT ``{ws_id}/{owner}/%/%``), symmetric with + # list_scripts. Skipping the filter for empty input would silently + # surface nested descendants and break the directory tree. + target_owner = owner_user_id or context.user.user_id + owner_prefix = f"{context.workspace.workspace_id}/{target_owner}" + descendant_prefix = _build_list_resources_descendant_prefix(parent_path) + statement = statement.where( + StorageObjects.object_key.like( + f"{owner_prefix}/{descendant_prefix}%", + escape="\\", + ), + ~StorageObjects.object_key.like( + f"{owner_prefix}/{descendant_prefix}%/%", + escape="\\", + ), + ) # 只返回 owner 自己的资源(含 private),或 visibility 为 # workspace/public 的其他成员资源;A 的 private 资源对非 owner 不可见。 # admin 跳过过滤,全部可见。 diff --git a/backend/src/backend/api/scripts.py b/backend/src/backend/api/scripts.py index a4c31b4..a4115fc 100644 --- a/backend/src/backend/api/scripts.py +++ b/backend/src/backend/api/scripts.py @@ -129,40 +129,36 @@ def _escape_like_pattern(value: str) -> str: return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") -# @deprecated — legacy user-scoped prefix helper. list_scripts/count_scripts -# are now workspace-wide via _build_list_scripts_workspace_descendant_prefix; -# this helper is kept (with its original user-scoped semantics) so existing -# callers/tests that still reference it do not break. -def _build_list_scripts_descendant_prefix( - context: RequestContext, parent_path: str +def _build_list_scripts_owner_descendant_prefix( + owner_user_id: str, parent_path: str ) -> str: """Return the escaped materialized-path prefix for direct children of - ``parent_path`` within the **requester's own subtree**. + ``parent_path`` within ``owner_user_id``'s own subtree. - .. note:: - Legacy user-scoped helper. list_scripts / count_scripts are now - workspace-wide — use - :func:`_build_list_scripts_workspace_descendant_prefix` instead - (visibility filtering handles non-admin scoping in the SQL). + Storage is physically laid out as ``workspace/{owner_user_id}/...``, so a + per-owner listing matches ``workspace/{owner_user_id}/{parent}``. The + endpoint appends ``LIKE '%' AND NOT LIKE '%/%'`` against + ``storage_objects.relative_path`` so only scripts whose parent directory + is exactly ``parent_path`` match (no deeper descendants, no + prefix-siblings like ``foo/bar`` vs ``foo/bar2``). - The endpoint appends ``LIKE '/%' AND NOT LIKE '/%/%'`` - against ``storage_objects.relative_path`` so only scripts whose parent - directory is exactly ``parent_path`` (no deeper descendants, no - prefix-siblings like ``foo/bar`` vs ``foo/bar2``) match. - - Empty ``parent_path`` produces the user-scoped root prefix — i.e. the - endpoint returns root-level scripts only, not the full workspace. + Empty ``parent_path`` produces the owner-scoped root prefix — i.e. the + endpoint returns that owner's root-level scripts only. ``list_scripts`` + calls this with ``owner_user_id`` = the requester by default (so a + non-admin sees their own subtree, including private) or with the + ``owner_user_id`` query param so the tree can lazily fetch another + member's content on group expand; the route's visibility filter then + excludes the other owner's private rows. The prefix is run through ``_escape_like_pattern`` so folder names containing ``_`` / ``%`` do not act as wildcards. The trailing ``/`` is appended AFTER escaping so it remains a literal slash. """ normalized_parent = normalize_user_path(parent_path) - scoped_prefix = user_relative_path(context) if normalized_parent: - target_prefix = f"{scoped_prefix}/{normalized_parent}" + target_prefix = f"workspace/{owner_user_id}/{normalized_parent}" else: - target_prefix = scoped_prefix + target_prefix = f"workspace/{owner_user_id}" return f"{_escape_like_pattern(target_prefix)}/" @@ -836,16 +832,25 @@ async def get_workspace_tree( @router.get("/workspace-directories") async def list_workspace_directories( parent_path: str = Query(default=""), + owner_user_id: str | None = Query(default=None, max_length=64), context: RequestContext = Depends(request_context), session: AsyncSession = Depends(database_session), ) -> dict[str, Any]: """List direct child directories of a workspace path. Empty ``parent_path`` returns the directories immediately under the - user's scoped root. Only available, non-deleted StorageObjects are - considered. + target owner's scoped root. Only available, non-deleted StorageObjects + are considered. + + ``owner_user_id`` defaults to the requester, so a member lists their + own directories. Passing another member's id scopes to that owner's + subtree so the script explorer can lazily render their directory + structure on expand (directories are structural rows; file-level + visibility is still enforced by the scripts/data-resources endpoints, + which exclude the other owner's private files). """ - scoped_prefix = user_relative_path(context) + target_owner = owner_user_id or context.user.user_id + scoped_prefix = f"workspace/{target_owner}" parent = normalize_user_path(parent_path) target_prefix = f"{scoped_prefix}/{parent}" if parent else scoped_prefix descendant_prefix = f"{_escape_like_pattern(target_prefix)}/" @@ -878,6 +883,7 @@ async def list_workspace_directories( "path": child_path, "name": suffix, "parent_path": parent, + "owner_user_id": target_owner, "has_children": False, }, ) @@ -1018,7 +1024,8 @@ async def create_workspace_directory( path_hash=path_hash, object_status="available", size_bytes=0, - visibility="private", + visibility="public", + owner_user_id=context.user.user_id, created_by=context.user.user_id, ) session.add(directory) @@ -1032,7 +1039,8 @@ async def create_workspace_directory( directory.storage_uri = f"inline://directory/{relative_path}" directory.file_name = name directory.size_bytes = 0 - directory.visibility = "private" + directory.visibility = "public" + directory.owner_user_id = context.user.user_id directory.created_by = context.user.user_id try: @@ -1058,6 +1066,7 @@ async def create_workspace_directory( "path": child_path, "name": name, "parent_path": parent, + "owner_user_id": context.user.user_id, }, "meta": {}, } @@ -1165,17 +1174,23 @@ async def delete_workspace_directory( @router.get("/scripts") async def list_scripts( parent_path: str = Query(default="", max_length=1024), + owner_user_id: str | None = Query(default=None, max_length=64), context: RequestContext = Depends(request_context), session: AsyncSession = Depends(database_session), ) -> dict[str, Any]: - # Workspace-wide listing: storage is physically laid out as - # ``workspace/{user_id}/...``. Empty parent_path wildcards the owner - # segment (``workspace/%/``) so each owner's root files are returned; - # non-empty parent_path embeds the same owner wildcard - # (``workspace/%/foo``) so every owner's ``foo`` subtree matches, - # mirroring list_resources. Non-admin scoping is applied below via - # visibility, matching list_resources (69a9a48). - descendant_prefix = _build_list_scripts_workspace_descendant_prefix(parent_path) + # Per-owner listing: storage is physically laid out as + # ``workspace/{user_id}/...``. Default (no owner_user_id) scopes to the + # requester's own subtree — root-level files when parent_path is empty — + # so the tree's initial load fetches only "me". Passing owner_user_id + # scopes to that owner's subtree so the tree can lazily fetch another + # member's content when their group is expanded. Non-admin scoping is + # applied below via visibility, so the other owner's private rows are + # excluded (workspace/public only); the requester's own private rows + # pass because ``owner_user_id = me``. + target_owner = owner_user_id or context.user.user_id + descendant_prefix = _build_list_scripts_owner_descendant_prefix( + target_owner, parent_path + ) statement = ( select(Scripts, StorageObjects, Users.display_name) diff --git a/backend/src/backend/main.py b/backend/src/backend/main.py index e6a938c..cd8eab5 100644 --- a/backend/src/backend/main.py +++ b/backend/src/backend/main.py @@ -33,16 +33,16 @@ from fastapi import Request from fastapi.responses import JSONResponse from loguru import logger -from backend.audit import configure_audit_logging from backend.api.admin import router as admin_router from backend.api.auth import router as auth_router from backend.api.jupyter import router as jupyter_router from backend.api.platform import router as platform_router from backend.api.resources import router as resources_router -from backend.api.scripts import router as scripts_router from backend.api.schedules.runs import router as schedule_runs_router from backend.api.schedules.schedules import router as schedules_router +from backend.api.scripts import router as scripts_router from backend.api.storage import router as storage_api_router +from backend.audit import configure_audit_logging from backend.clients.rclone import RcloneRCClient from backend.clients.runtime import RuntimeClient @@ -161,7 +161,7 @@ async def access_log(request: Request, call_next): method=request.method, path=request.url.path, status=500, - ).info("audit") + ).info(f"{request.url.path} skip audit") raise elapsed_ms = (time.perf_counter() - start) * 1000 logger.info( @@ -175,7 +175,7 @@ async def access_log(request: Request, call_next): method=request.method, path=request.url.path, status=response.status_code, - ).info("audit") + ).info(f"{request.url.path} skip audit") return response diff --git a/backend/tests/test_list_scripts_parent_path.py b/backend/tests/test_list_scripts_parent_path.py index feb0868..4b57096 100644 --- a/backend/tests/test_list_scripts_parent_path.py +++ b/backend/tests/test_list_scripts_parent_path.py @@ -28,7 +28,7 @@ from sqlalchemy import Column, MetaData, String, Table, create_engine, select, t from sqlalchemy.dialects import mysql as mysql_dialect from backend.api.scripts import ( - _build_list_scripts_descendant_prefix, + _build_list_scripts_owner_descendant_prefix, _build_list_scripts_workspace_descendant_prefix, _escape_like_pattern, normalize_user_path, @@ -85,14 +85,14 @@ class TestEscapeLikePattern: def test_descendant_prefix_root() -> None: - """Empty parent_path → descendant prefix is the scoped root + '/'.""" - prefix = _build_list_scripts_descendant_prefix(_ctx("alice"), "") + """Empty parent_path → descendant prefix is the owner-scoped root + '/'.""" + prefix = _build_list_scripts_owner_descendant_prefix("alice", "") assert prefix == "workspace/alice/" def test_descendant_prefix_subdir() -> None: - """Non-empty parent_path → appended under the scoped root.""" - prefix = _build_list_scripts_descendant_prefix(_ctx("alice"), "foo/bar") + """Non-empty parent_path → appended under the owner-scoped root.""" + prefix = _build_list_scripts_owner_descendant_prefix("alice", "foo/bar") assert prefix == "workspace/alice/foo/bar/" @@ -100,18 +100,18 @@ def test_descendant_prefix_escapes_metachars() -> None: """Folder name ``foo_bar`` MUST produce ``foo\\_bar`` in the prefix so the trailing ``%`` doesn't become 'match any single char before b'.""" - prefix = _build_list_scripts_descendant_prefix(_ctx("alice"), "foo_bar") + prefix = _build_list_scripts_owner_descendant_prefix("alice", "foo_bar") assert prefix == r"workspace/alice/foo\_bar/" def test_descendant_prefix_normalizes_leading_trailing_slashes() -> None: - prefix = _build_list_scripts_descendant_prefix(_ctx("alice"), "/foo/bar/") + prefix = _build_list_scripts_owner_descendant_prefix("alice", "/foo/bar/") assert prefix == "workspace/alice/foo/bar/" def test_descendant_prefix_rejects_traversal() -> None: with pytest.raises(HTTPException) as exc: - _build_list_scripts_descendant_prefix(_ctx("alice"), "foo/../bar") + _build_list_scripts_owner_descendant_prefix("alice", "foo/../bar") assert exc.value.status_code == 422 @@ -202,14 +202,19 @@ async def test_list_scripts_where_clause_uses_like_prefix_and_excludes_deeper() ) ) - await list_scripts(parent_path="foo/bar", context=_ctx("alice"), session=mock_session) + await list_scripts( + parent_path="foo/bar", + owner_user_id=None, + context=_ctx("alice"), + session=mock_session, + ) assert len(captured_sql) == 1 sql = captured_sql[0].lower() - # 中间 ``%`` 是 owner 段通配符(跨所有 owner),父路径本身按字面匹配, - # 与 list_resources 对 object_key 的过滤一致。 - assert "like 'workspace/%%/foo/bar/%%'" in sql - assert "not like 'workspace/%%/foo/bar/%%/%%'" in sql + # Default (no owner_user_id) scopes to the requester's own subtree: + # LIKE workspace/alice/foo/bar/% (direct children), excluding deeper. + assert "like 'workspace/alice/foo/bar/%%'" in sql + assert "not like 'workspace/alice/foo/bar/%%/%%'" in sql async def test_list_scripts_where_clause_escapes_pattern_literal() -> None: @@ -230,7 +235,12 @@ async def test_list_scripts_where_clause_escapes_pattern_literal() -> None: ) ) - await list_scripts(parent_path="foo_bar", context=_ctx("alice"), session=mock_session) + await list_scripts( + parent_path="foo_bar", + owner_user_id=None, + context=_ctx("alice"), + session=mock_session, + ) sql = captured_sql[0] # Normalize keyword case so we don't depend on SQLAlchemy casing. sql_lower = sql.lower() @@ -238,9 +248,9 @@ async def test_list_scripts_where_clause_escapes_pattern_literal() -> None: # doubles the escape char inside the SQL string literal, so what # the helper emits as `foo\_bar` renders as `foo\\_bar` here # (2 backslash chars in the actual SQL string). - assert r"like 'workspace/%%/foo\\_bar/%%'" in sql_lower + assert r"like 'workspace/alice/foo\\_bar/%%'" in sql_lower # NOT LIKE clause also escaped. - assert r"not like 'workspace/%%/foo\\_bar/%%/%%'" in sql_lower + assert r"not like 'workspace/alice/foo\\_bar/%%/%%'" in sql_lower # And both declare ESCAPE '\\'. assert sql.count("ESCAPE '\\\\'") == 2, sql @@ -262,18 +272,26 @@ async def test_list_scripts_where_clause_escapes_percent_pattern() -> None: ) ) - await list_scripts(parent_path="100%match", context=_ctx("alice"), session=mock_session) + await list_scripts( + parent_path="100%match", + owner_user_id=None, + context=_ctx("alice"), + session=mock_session, + ) sql = captured_sql[0] sql_lower = sql.lower() # SQLAlchemy doubles the escape char so `%` → `\%` becomes `\\%` # in the SQL string literal. - assert r"workspace/%%/100\\%%match/%%" in sql_lower + assert r"workspace/alice/100\\%%match/%%" in sql_lower async def test_list_scripts_non_admin_adds_visibility_filter() -> None: - """Workspace-wide listing is narrowed by visibility for non-admin: + """Owner-scoped listing is narrowed by visibility for non-admin: owner_user_id = me OR visibility IN (workspace, public) — exactly like - list_resources. The workspace prefix contains NO user_id (cross-owner).""" + list_resources. Default (no owner_user_id) scopes to the requester's own + subtree, so the visibility predicate is redundant-but-present here; it + becomes load-bearing when an owner_user_id query param browses another + member's subtree (their private rows are then excluded).""" from backend.api.scripts import list_scripts captured_sql: list[str] = [] @@ -289,11 +307,50 @@ async def test_list_scripts_non_admin_adds_visibility_filter() -> None: ) ) - await list_scripts(parent_path="", context=_ctx("alice"), session=mock_session) + await list_scripts( + parent_path="", + owner_user_id=None, + context=_ctx("alice"), + session=mock_session, + ) sql = captured_sql[0].lower() - # Root listing wildcards the owner segment: LIKE workspace/%/% - # (each owner's root files), excluding 3+ segment descendants. - assert "like 'workspace/%%/%%'" in sql + # Default (no owner_user_id) scopes to the requester's own root: + # LIKE workspace/alice/% (alice's root files), excluding nested. + assert "like 'workspace/alice/%%'" in sql + assert "scripts.owner_user_id = 'alice'" in sql + assert "scripts.visibility in ('workspace', 'public')" in sql + + +async def test_list_scripts_owner_param_scopes_to_other_owner() -> None: + """owner_user_id= scopes the LIKE to that owner's subtree so the + tree can lazily fetch another member's content on group expand. The + non-admin visibility predicate is still applied, so the other owner's + private rows are excluded (only workspace/public survive).""" + from backend.api.scripts import list_scripts + + captured_sql: list[str] = [] + + class _MockResult: + def all(self): + return [] + + mock_session = MagicMock() + mock_session.execute = AsyncMock( + side_effect=lambda stmt: ( + captured_sql.append(_compile_sql(stmt)) or _MockResult() + ) + ) + + await list_scripts( + parent_path="", + owner_user_id="bob", + context=_ctx("alice"), + session=mock_session, + ) + sql = captured_sql[0].lower() + assert "like 'workspace/bob/%%'" in sql + assert "not like 'workspace/bob/%%/%%'" in sql + # non-admin: visibility predicate still present (excludes bob's private) assert "scripts.owner_user_id = 'alice'" in sql assert "scripts.visibility in ('workspace', 'public')" in sql @@ -316,15 +373,47 @@ async def test_list_scripts_admin_skips_visibility_filter() -> None: ) await list_scripts( - parent_path="", context=_ctx("alice", is_admin=True), session=mock_session + parent_path="", + owner_user_id=None, + context=_ctx("alice", is_admin=True), + session=mock_session, ) sql = captured_sql[0].lower() - assert "like 'workspace/%%/%%'" in sql + assert "like 'workspace/alice/%%'" in sql # owner_user_id / visibility still appear in the SELECT projection; what # must be absent is the visibility WHERE predicate for non-admins. assert "scripts.visibility in ('workspace', 'public')" not in sql +async def test_list_scripts_admin_owner_param_skips_visibility() -> None: + """Admin browsing another owner's subtree scopes to that owner and skips + the visibility predicate (admin sees the other owner's private too).""" + from backend.api.scripts import list_scripts + + captured_sql: list[str] = [] + + class _MockResult: + def all(self): + return [] + + mock_session = MagicMock() + mock_session.execute = AsyncMock( + side_effect=lambda stmt: ( + captured_sql.append(_compile_sql(stmt)) or _MockResult() + ) + ) + + await list_scripts( + parent_path="sub", + owner_user_id="bob", + context=_ctx("alice", is_admin=True), + session=mock_session, + ) + sql = captured_sql[0].lower() + assert "like 'workspace/bob/sub/%%'" in sql + assert "scripts.visibility in ('workspace', 'public')" not in sql + + async def test_list_workspace_directories_where_clause_escapes_pattern() -> None: """list_workspace_directories must escape user input too (was pre-existing debt).""" @@ -351,11 +440,21 @@ async def test_list_workspace_directories_where_clause_escapes_pattern() -> None ) await list_workspace_directories( - parent_path="foo_bar", context=_ctx("alice"), session=mock_session + parent_path="foo_bar", owner_user_id=None, + context=_ctx("alice"), session=mock_session, ) sql = " ".join(captured_sql) assert r"workspace/alice/foo\\_bar/" in sql, sql + # owner_user_id scopes the prefix to that owner's subtree. + captured_sql.clear() + await list_workspace_directories( + parent_path="foo_bar", owner_user_id="bob", + context=_ctx("alice"), session=mock_session, + ) + sql = " ".join(captured_sql) + assert r"workspace/bob/foo\\_bar/" in sql, sql + # ─── layer 3: behavioral test on real LIKE execution ────────────── diff --git a/backend/tests/test_resources.py b/backend/tests/test_resources.py index 7d75c63..c160718 100644 --- a/backend/tests/test_resources.py +++ b/backend/tests/test_resources.py @@ -573,6 +573,7 @@ async def test_list_resources_where_clause_uses_like_prefix_and_excludes_deeper( await list_resources( parent_path="foo/bar", + owner_user_id=None, context=_resource_ctx(), session=mock_session, visibility=None, @@ -581,9 +582,10 @@ async def test_list_resources_where_clause_uses_like_prefix_and_excludes_deeper( assert len(captured_sql) == 1 sql = captured_sql[0].lower() - # 中间 ``%`` 是 owner 段通配符(跨所有 owner),父路径本身按字面匹配。 - assert "like 'w001/%%/foo/bar/%%'" in sql - assert "not like 'w001/%%/foo/bar/%%/%%'" in sql + # Default (no owner_user_id) scopes to the requester's own object_key + # subtree: LIKE w001/u001/foo/bar/% (direct children), excluding deeper. + assert "like 'w001/u001/foo/bar/%%'" in sql + assert "not like 'w001/u001/foo/bar/%%/%%'" in sql async def test_list_resources_where_clause_escapes_underscore() -> None: @@ -596,6 +598,7 @@ async def test_list_resources_where_clause_escapes_underscore() -> None: await list_resources( parent_path="foo_bar", + owner_user_id=None, context=_resource_ctx(), session=mock_session, visibility=None, @@ -605,15 +608,20 @@ async def test_list_resources_where_clause_escapes_underscore() -> None: sql_lower = sql.lower() # SQLAlchemy doubles the escape char inside the SQL string literal, so # the helper's ``foo\_bar`` renders as ``foo\\_bar`` in the SQL text. - assert r"like 'w001/%%/foo\\_bar/%%'" in sql_lower - assert r"not like 'w001/%%/foo\\_bar/%%/%%'" in sql_lower + assert r"like 'w001/u001/foo\\_bar/%%'" in sql_lower + assert r"not like 'w001/u001/foo\\_bar/%%/%%'" in sql_lower # Both LIKE clauses declare ESCAPE '\\' (two in total). assert sql.count("ESCAPE '\\\\'") == 2, sql -async def test_list_resources_without_parent_path_adds_no_like_clause() -> None: - """Empty parent_path keeps the legacy workspace-wide behaviour — no - object_key LIKE filter at all.""" +async def test_list_resources_empty_parent_path_adds_root_like_clause() -> None: + """Empty parent_path still applies the directory filter (symmetric with + list_scripts): ``{ws_id}/{owner}/%`` AND NOT ``{ws_id}/{owner}/%/%`` so + the owner-scoped root view returns only direct children of the + requester's root, never nested descendants. Skipping the filter for + empty input used to surface nested resources at the root and visually + broke the directory tree. + """ from backend.api.resources import list_resources captured_sql: list[str] = [] @@ -621,14 +629,42 @@ async def test_list_resources_without_parent_path_adds_no_like_clause() -> None: await list_resources( parent_path="", + owner_user_id=None, context=_resource_ctx(), session=mock_session, visibility=None, keyword=None, ) sql = captured_sql[0].lower() - assert " like " not in sql - assert " not like " not in sql + assert " like 'w001/u001/%%'" in sql + assert " not like 'w001/u001/%%/%%'" in sql + + +async def test_list_resources_owner_param_scopes_to_other_owner() -> None: + """owner_user_id= scopes object_key LIKE to that owner's subtree + so the tree can lazily fetch another member's data resources on group + expand. Non-admin visibility predicate is still applied, so the other + owner's private resources are excluded (workspace/public only). + """ + from backend.api.resources import list_resources + + captured_sql: list[str] = [] + mock_session = _list_resources_capturing_session(captured_sql) + + await list_resources( + parent_path="", + owner_user_id="U002", + context=_resource_ctx(), + session=mock_session, + visibility=None, + keyword=None, + ) + sql = captured_sql[0].lower() + assert " like 'w001/u002/%%'" in sql + assert " not like 'w001/u002/%%/%%'" in sql + # non-admin: visibility predicate still present (excludes U002 private) + assert "data_resources.owner_user_id = 'u001'" in sql + assert "data_resources.visibility in ('workspace', 'public')" in sql async def test_list_resources_joins_users_for_display_name() -> None: @@ -641,6 +677,7 @@ async def test_list_resources_joins_users_for_display_name() -> None: await list_resources( parent_path="", + owner_user_id=None, context=_resource_ctx(), session=mock_session, visibility=None, @@ -745,11 +782,29 @@ def test_sqlite_parent_path_with_slash_direct_children(sqlite_object_key_table) assert matched == ["W001/U001/data/deep/nested.csv"], matched -def test_sqlite_empty_parent_path_has_no_like_filter(sqlite_object_key_table) -> None: - """Empty parent_path → no LIKE filter → the endpoint's base WHERE only - (workspace-wide active resources). Stand-in: every row is returned.""" +def test_sqlite_empty_parent_path_returns_root_level_across_owners( + sqlite_object_key_table, +) -> None: + """Empty parent_path now applies the root filter (symmetric with + list_scripts): ``{ws_id}/%/%`` AND NOT ``{ws_id}/%/%/%`` returns only + direct children of every owner's root, excluding nested descendants. + Earlier 'no LIKE' behaviour used to surface every row in the + workspace at the root, which is exactly what made scripts and data + appear mutually visible and broke the tree. + """ engine, table = sqlite_object_key_table + like = "W001/%/%" + not_like = "W001/%/%/%" with engine.connect() as conn: - rows = conn.execute(select(table.c.object_key)).fetchall() + rows = conn.execute( + select(table.c.object_key).where( + table.c.object_key.like(like, escape="\\"), + ~table.c.object_key.like(not_like, escape="\\"), + ) + ).fetchall() matched = sorted(r[0] for r in rows) - assert len(matched) == 8, matched + # ``W001/U001/root.csv`` is the only 3-segment path (= direct child + # of the owner root); all 4+ segment paths (data/*, database/*) are + # excluded by the NOT LIKE clause. The other 4+ segment files would + # be returned when the user expands the corresponding subdirectory. + assert matched == ["W001/U001/root.csv"], matched diff --git a/frontend/app/components/platform/ScriptExplorer.tsx b/frontend/app/components/platform/ScriptExplorer.tsx index ad9e5a4..3436cb1 100644 --- a/frontend/app/components/platform/ScriptExplorer.tsx +++ b/frontend/app/components/platform/ScriptExplorer.tsx @@ -60,6 +60,7 @@ export function ScriptExplorer({ const loadingChildrenPaths = useScriptWorkspaceStore( (s) => s.loadingChildrenPaths, ); + const members = useScriptWorkspaceStore((s) => s.members); const onToggle = useScriptWorkspaceStore((s) => s.toggleExpanded); const dataByOwner = useMemo(() => { @@ -83,9 +84,15 @@ export function ScriptExplorer({ item.visibility === "public", ); + // 用工作区成员列表播种分组 —— 顶层"我 / user1 / user2 / …"折叠分组 + // 的来源。即使某成员尚未加载任何脚本/数据(默认折叠、点击才拉取), + // 也作为空分组出现,保证目录树结构稳定可见(修"目录树结构消失")。 const byOwner = new Map(); - // 当前用户的目录树即使没有脚本也要渲染,所以预置空组。 - if (user?.user_id) { + for (const m of members) { + if (!byOwner.has(m.user_id)) byOwner.set(m.user_id, []); + } + // 当前用户兜底(members 未就绪时仍渲染"我"的分组)。 + if (user?.user_id && !byOwner.has(user.user_id)) { byOwner.set(user.user_id, []); } for (const item of visibleScripts) { @@ -93,15 +100,17 @@ export function ScriptExplorer({ list.push(item); byOwner.set(item.owner_user_id, list); } - - // data-only owner(只有数据资源、没有 scripts 的用户)也要出现在分组里, - // 因为 data resources 与 scripts 共享同一棵目录树。 + // data-only owner(只有数据资源、没有 scripts 的用户,且不在 members 列表 + // 里,如已移除成员遗留的资源)也要出现在分组里。 for (const ownerUserId of dataByOwner.keys()) { if (!byOwner.has(ownerUserId)) { byOwner.set(ownerUserId, []); } } + // 成员 id → display_name 优先取 members 列表(最准)。 + const memberName = new Map(members.map((m) => [m.user_id, m.display_name])); + const groups: { user: AuthUser | null; scripts: ScriptItem[]; @@ -110,9 +119,8 @@ export function ScriptExplorer({ }[] = []; for (const [ownerUserId, groupScripts] of byOwner.entries()) { const groupDataResources = dataByOwner.get(ownerUserId) ?? []; - // data-only owner(没有 scripts 的用户)回退到 data resources 的 - // owner_display_name,否则 data-only owner 前端只能显示 userId 末 6 位。 const displayName = + memberName.get(ownerUserId) ?? groupScripts[0]?.owner_display_name ?? groupDataResources[0]?.owner_display_name ?? (ownerUserId === user?.user_id ? user?.display_name : null) ?? @@ -129,14 +137,16 @@ export function ScriptExplorer({ role_code: null, is_system_admin: false, } as AuthUser); - const inferred = inferredDirectories(groupScripts); + const inferred = inferredDirectories(groupScripts, ownerUserId); + // directories flat 数组现在按 owner_user_id 标记,按 owner 切分后与 + // inferred 合并(inferred 补全 fetched 目录行未覆盖的祖先路径)。 + const ownerDirs = directories.filter( + (d) => d.owner_user_id === ownerUserId, + ); groups.push({ user: groupUser, scripts: groupScripts, - directories: - ownerUserId === user?.user_id - ? mergeDirectories(directories, inferred) - : inferred, + directories: mergeDirectories(ownerDirs, inferred), dataResources: groupDataResources, }); } @@ -144,18 +154,19 @@ export function ScriptExplorer({ groups.sort((a, b) => { if (a.user?.user_id === user?.user_id) return -1; if (b.user?.user_id === user?.user_id) return 1; - return (a.user?.user_id ?? "").localeCompare(b.user?.user_id ?? ""); + return (a.user?.display_name ?? a.user?.user_id ?? "").localeCompare( + b.user?.display_name ?? b.user?.user_id ?? "", + ); }); return groups; - }, [filteredScripts, directories, user, dataByOwner]); + }, [filteredScripts, directories, user, dataByOwner, members]); return (