refactor(backend): split into api/ schemas/ services/ clients/ layers
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.<new> import *` shims so
tests/main.py/importers kept working untouched
* schemas/__init__.py now re-exports from backend.schemas.<domain>
## 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
@@ -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),
|
||||
@@ -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,
|
||||
@@ -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.
|
||||
@@ -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 (
|
||||
@@ -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,
|
||||
+23
-131
@@ -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),
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
@@ -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
|
||||
+11
-11
@@ -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(
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Reserved for stage-3 extraction. Currently empty."""
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
"""Reserved for stage-3 extraction. Currently empty."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Reserved for stage-3 extraction. Currently empty."""
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -0,0 +1 @@
|
||||
"""Reserved for stage-3 extraction. Currently empty."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Reserved for stage-3 extraction. Currently empty."""
|
||||
@@ -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"]
|
||||
@@ -0,0 +1 @@
|
||||
"""Reserved for stage-3 extraction. Currently empty."""
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user