merge: integrate feat/auth into develop

This commit is contained in:
Winnie
2026-08-03 17:44:00 +08:00
38 changed files with 2590 additions and 796 deletions
+8 -1
View File
@@ -17,6 +17,12 @@ DATABASE_URL=mysql+asyncmy://root:change-me@127.0.0.1:3306/model_platform?charse
DEMO_AUTH_ENABLED=true
JWT_SECRET=change-this-development-secret
# ============================================================================
# CRITICAL: must set BEFORE first run. The initial admin user is seeded by
# the deployment bootstrap. Never keep the development default in production.
# ============================================================================
INITIAL_ADMIN_PASSWORD=admin12345
# Object storage (S3-compatible, RustFS).
# RUSTFS_ENDPOINT is the single upstream URL consumed by all 4 services:
# - nginx (via scripts/nginx-entrypoint.sh, which parses host + port)
@@ -36,4 +42,5 @@ RUSTFS_SECRET_KEY=change-me
RUSTFS_WORKSPACE_BUCKET=workspaces
RUSTFS_VERSION_BUCKET=versions
RUSTFS_RUN_LOG_BUCKET=run-logs
RUSTFS_TRASH_BUCKET=trash
RUSTFS_TRASH_RETENTION_DAYS=30
+4 -4
View File
@@ -8,10 +8,10 @@ WORKDIR /app
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
RUN ( \
sed -i 's/deb.debian.org/mirrors.cloud.aliyuncs.com/g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
sed -i 's/deb.debian.org/mirrors.cloud.aliyuncs.com/g' /etc/apt/sources.list 2>/dev/null || \
sed -i 's/archive.ubuntu.com/mirrors.cloud.aliyuncs.com/g' /etc/apt/sources.list 2>/dev/null || \
sed -i 's/security.ubuntu.com/mirrors.cloud.aliyuncs.com/g' /etc/apt/sources.list 2>/dev/null \
sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list 2>/dev/null || \
sed -i 's/archive.ubuntu.com/mirrors.aliyun.com/g' /etc/apt/sources.list 2>/dev/null || \
sed -i 's/security.ubuntu.com/mirrors.aliyun.com/g' /etc/apt/sources.list 2>/dev/null \
) || ( \
sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list 2>/dev/null || \
+2
View File
@@ -11,6 +11,8 @@ dependencies = [
"alembic==1.18.5",
"cryptography==49.0.0",
"gunicorn>=26.0.0",
"passlib==1.7.4",
"bcrypt>=4.0,<4.1",
]
[tool.uv.sources]
+4 -4
View File
@@ -35,18 +35,18 @@ router = APIRouter(tags=["auth"])
# (future extension).
COOKIE_NAME = "access_token"
COOKIE_TTL_SECONDS = 24 * 60 * 60
COOKIE_SECURE = True
COOKIE_SAMESITE = "lax"
def _set_session_cookie(response: Response, token: str) -> None:
def _set_session_cookie(request: Request, response: Response, token: str) -> None:
forwarded_scheme = request.headers.get("x-forwarded-proto", request.url.scheme)
response.set_cookie(
key=COOKIE_NAME,
value=token,
max_age=COOKIE_TTL_SECONDS,
path="/",
httponly=True,
secure=COOKIE_SECURE,
secure=forwarded_scheme == "https",
samesite=COOKIE_SAMESITE,
)
@@ -159,7 +159,7 @@ async def login(
user_role_code = rows[0][1].role_code
token = issue_jwt(user.user_id, ttl_seconds=COOKIE_TTL_SECONDS)
_set_session_cookie(response, token)
_set_session_cookie(request, response, token)
return {
"request_id": new_ulid(),
+85 -35
View File
@@ -1,22 +1,48 @@
"""FastAPI dependencies for the public API.
Two distinct concerns live here:
* :func:`database_session` — yields a transactional ``AsyncSession``
bound to the request's app-state factory. The session commits on
success and rolls back on exception via ``common.db.session_scope``.
* :func:`request_context` — verifies the ``access_token`` cookie
(HS256-signed JWT), then loads the user's active membership for the
``workspace_id`` query parameter. Returns a :class:`RequestContext`
dataclass that downstream handlers read for identity, role, and the
request id.
The 56 ``Depends(request_context)`` call sites elsewhere in the
backend expect this exact dataclass shape; the workspace and role are
always present, so handlers do not need to handle the "no workspace
selected" case.
Routes that need a workspace context must declare the query parameter
explicitly, even when the path also contains a resource id (e.g.
``/schedules/{schedule_id}``). This keeps authorization decisions
local to the request and prevents the "implicit workspace" footgun
where a path-resource lookup quietly overrides what the user asked
for.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import AsyncIterator
from fastapi import Depends, Header, HTTPException, Request, status
from sqlalchemy import select
from fastapi import Depends, HTTPException, Query, Request, status
from sqlalchemy.ext.asyncio import AsyncSession
from common.auth.jwt import JwtError, verify_jwt_token
from common.auth.membership import MembershipError, load_active_membership
from common.db import session_scope
from common.db.models import (
Roles,
Users,
WorkspaceMembers,
Workspaces,
)
from common.db.models import Roles, Users, Workspaces
from common.ids import new_ulid
ACCESS_TOKEN_COOKIE = "access_token"
@dataclass(frozen=True)
class RequestContext:
request_id: str
@@ -34,41 +60,65 @@ async def database_session(request: Request) -> AsyncIterator[AsyncSession]:
yield session
async def current_user(
request: Request,
session: AsyncSession = Depends(database_session),
) -> Users:
"""Resolve the authenticated user from the ``access_token`` cookie.
Returns the active ``Users`` row or raises 401. Does NOT load a
workspace — use :func:`request_context` for handlers that need
a workspace-scoped context, or use ``Depends(current_user)`` for
workspace-agnostic endpoints (e.g. ``/api/v1/auth/me``).
"""
token = request.cookies.get(ACCESS_TOKEN_COOKIE)
if not token:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "not authenticated")
try:
payload = verify_jwt_token(token)
except JwtError as exc:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, str(exc)) from exc
user_id = payload.get("sub")
if not user_id:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid token")
user = await session.get(Users, user_id)
if user is None or user.status != "active":
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "user not found")
return user
async def request_context(
request: Request,
x_user_id: str = Header(alias="X-User-ID"),
x_workspace_id: str = Header(alias="X-Workspace-ID"),
x_request_id: str | None = Header(default=None, alias="X-Request-ID"),
workspace_id: str = Query(
...,
min_length=26,
max_length=26,
description="Workspace context for this request (CHAR(26) ULID).",
),
session: AsyncSession = Depends(database_session),
) -> RequestContext:
async with request.app.state.session_factory() as session:
statement = (
select(Users, Workspaces, Roles)
.join(
WorkspaceMembers,
WorkspaceMembers.user_id == Users.user_id,
"""Verify JWT and load the user's active membership for ``workspace_id``.
The ``request_id`` comes from the ``X-Request-ID`` header if
present, else from a freshly minted ULID. Handlers receive the
same :class:`RequestContext` shape they had under the old
header-based implementation, so the 56 ``Depends(request_context)``
call sites in this repo stay working without changes — they just
now pass ``?workspace_id=`` instead of the old headers.
"""
user = await current_user(request, session)
try:
_user, workspace, role = await load_active_membership(
session, user.user_id, workspace_id,
)
.join(
Workspaces,
Workspaces.workspace_id == WorkspaceMembers.workspace_id,
)
.join(Roles, Roles.role_id == WorkspaceMembers.role_id)
.where(
Users.user_id == x_user_id,
Users.status == "active",
WorkspaceMembers.workspace_id == x_workspace_id,
WorkspaceMembers.member_status == "active",
Workspaces.status == "active",
)
)
row = (await session.execute(statement)).one_or_none()
if row is None:
except MembershipError as exc:
raise HTTPException(
status.HTTP_403_FORBIDDEN,
"active workspace membership is required",
)
user, workspace, role = row
) from exc
request_id = request.headers.get("X-Request-ID") or new_ulid()
return RequestContext(
request_id=x_request_id or new_ulid(),
request_id=request_id,
user=user,
workspace=workspace,
role=role,
+24 -175
View File
@@ -1,129 +1,24 @@
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import re
import time
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy import select
from backend.dependencies import (
RequestContext,
database_session,
request_context,
)
from backend.runtime_client import RuntimeClientError
from common.config import settings
from common.db.models import Scripts, Users, WorkspaceMembers, Workspaces
from sqlalchemy.ext.asyncio import AsyncSession
from backend.dependencies import database_session
from backend.runtime_client import RuntimeClientError
from common.auth.jwt import JwtError, verify_jwt_token
from common.auth.membership import MembershipError, load_active_membership
from common.db.models import Scripts
router = APIRouter(tags=["jupyter"])
security = HTTPBearer(auto_error=False)
JWT_SECRET = settings.jwt_secret
JWT_ALGORITHM = "HS256"
def _b64decode(value: str) -> bytes:
padding = "=" * (-len(value) % 4)
return base64.urlsafe_b64decode(value + padding)
def _b64encode(value: bytes) -> str:
return base64.urlsafe_b64encode(value).rstrip(b"=").decode()
def create_jwt_token(user_id: str, *, expires_seconds: int = 3600) -> str:
header = _b64encode(
json.dumps(
{"alg": JWT_ALGORITHM, "typ": "JWT"},
separators=(",", ":"),
).encode()
)
expires_at = int(time.time()) + expires_seconds
payload = _b64encode(
json.dumps(
{"sub": user_id, "exp": expires_at},
separators=(",", ":"),
).encode()
)
signing_input = f"{header}.{payload}".encode()
signature = _b64encode(
hmac.new(
JWT_SECRET.encode(),
signing_input,
hashlib.sha256,
).digest()
)
return f"{header}.{payload}.{signature}"
def verify_jwt_token(token: str) -> dict:
"""Parse a signed JWT from the access_token cookie.
Returns a payload dict containing at least ``sub`` (user_id) and
``exp``; raises 401 on missing, malformed, or expired tokens. The
current implementation is intentionally minimal — replace with a
real verification once a public-key/HSM source is wired in.
"""
if not token:
raise HTTPException(
status_code=401,
detail="Missing Authentication Token",
)
try:
header_b64, payload_b64, signature_b64 = token.split(".", 2)
except ValueError as exc:
raise HTTPException(
status_code=401,
detail="Invalid Authentication Token",
) from exc
signing_input = f"{header_b64}.{payload_b64}".encode()
expected = hmac.new(
JWT_SECRET.encode(),
signing_input,
hashlib.sha256,
).digest()
try:
signature = _b64decode(signature_b64)
except Exception as exc: # pragma: no cover - malformed b64
raise HTTPException(
status_code=401,
detail="Invalid Authentication Token",
) from exc
if not hmac.compare_digest(expected, signature):
raise HTTPException(
status_code=401,
detail="Invalid Authentication Token",
)
try:
payload = json.loads(_b64decode(payload_b64))
except Exception as exc:
raise HTTPException(
status_code=401,
detail="Invalid Authentication Token",
) from exc
exp = payload.get("exp")
if not isinstance(exp, (int, float)) or exp < time.time():
raise HTTPException(
status_code=401,
detail="Token expired",
)
return payload
def extract_notebook_path(
uri: str,
workspace_id: str,
@@ -168,75 +63,23 @@ async def check_notebook_is_locked(
return bool(is_locked)
async def load_active_membership(
async def load_active_membership_or_403(
session: AsyncSession,
user_id: str,
workspace_id: str,
) -> tuple[Users, Workspaces]:
"""Resolve the user's active membership in the workspace."""
statement = (
select(Users, Workspaces)
.join(
WorkspaceMembers,
WorkspaceMembers.user_id == Users.user_id,
)
.join(
Workspaces,
Workspaces.workspace_id == WorkspaceMembers.workspace_id,
)
.where(
Users.user_id == user_id,
Users.status == "active",
WorkspaceMembers.workspace_id == workspace_id,
WorkspaceMembers.member_status == "active",
Workspaces.status == "active",
)
)
row = (await session.execute(statement)).one_or_none()
if row is None:
):
"""Resolve the user's active membership, raising 403 if missing.
Thin wrapper around :func:`common.auth.membership.load_active_membership`
that maps the library's ``MembershipError`` to a FastAPI 403.
"""
try:
return await load_active_membership(session, user_id, workspace_id)
except MembershipError as exc:
raise HTTPException(
status_code=403,
detail="active workspace membership is required",
)
return row
@router.post("/api/v1/auth/demo-session")
async def create_demo_session(
request: Request,
response: Response,
context: RequestContext = Depends(request_context),
) -> dict:
"""Issue the short-lived HttpOnly cookie used by the self-hosted UI.
The normal request-context check still requires an active user/workspace
membership. This endpoint is disabled by default and must be explicitly
enabled by the deployment configuration.
"""
if not settings.demo_auth_enabled:
raise HTTPException(status_code=404, detail="Not Found")
expires_seconds = 3600
response.set_cookie(
"access_token",
create_jwt_token(
context.user.user_id,
expires_seconds=expires_seconds,
),
max_age=expires_seconds,
httponly=True,
secure=request.url.scheme == "https",
samesite="lax",
path="/",
)
return {
"request_id": context.request_id,
"data": {
"user_id": context.user.user_id,
"workspace_id": context.workspace.workspace_id,
"expires_at": int(time.time()) + expires_seconds,
},
"meta": {},
}
) from exc
@router.get("/api/v1/auth/jupyter")
@@ -266,7 +109,13 @@ async def verify_jupyter_access(
cookie_token = request.cookies.get("access_token")
bearer_token = auth.credentials if auth else None
token = cookie_token or bearer_token
try:
payload = verify_jwt_token(token)
except JwtError as exc:
raise HTTPException(
status_code=401,
detail=str(exc),
) from exc
user_id = payload.get("sub")
if not user_id:
raise HTTPException(
@@ -274,7 +123,7 @@ async def verify_jupyter_access(
detail="Invalid Authentication Token",
)
await load_active_membership(session, user_id, workspace_id)
await load_active_membership_or_403(session, user_id, workspace_id)
notebook_path = extract_notebook_path(original_uri, workspace_id)
if notebook_path and await check_notebook_is_locked(
+4 -1
View File
@@ -12,6 +12,7 @@ from common.db import create_database_engine, create_session_factory
from common.service_app import create_service_app
from common.storage import RustFSObjectStore
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.resources import router as resources_router
from backend.runtime_client import RuntimeClient
@@ -34,12 +35,13 @@ async def lifespan(app: Any) -> AsyncIterator[None]:
access_key=settings.rustfs_access_key,
secret_key=settings.rustfs_secret_key,
)
# Ensure all three purpose-named buckets exist; the storage edge picks
# Ensure all four purpose-named buckets exist; the storage edge picks
# the right one per upload (see resolve_bucket in storage_api.py).
for bucket in (
settings.rustfs_workspace_bucket,
settings.rustfs_version_bucket,
settings.rustfs_run_log_bucket,
settings.rustfs_trash_bucket,
):
await asyncio.to_thread(
app.state.object_store.ensure_bucket,
@@ -69,6 +71,7 @@ app = create_service_app(
settings.service_name,
lifespan=lifespan,
)
app.include_router(auth_router)
app.include_router(jupyter_router)
app.include_router(resources_router)
app.include_router(schedule_runs_router)
+44 -150
View File
@@ -1,6 +1,5 @@
from __future__ import annotations
import hashlib
from datetime import UTC, datetime
from typing import Any, Literal
@@ -13,19 +12,22 @@ from common.db.models import (
ScheduleNodeRuns,
ScheduleRuns,
)
from common.eventing import add_outbox_event, utcnow
from common.ids import new_ulid
from backend.dependencies import (
RequestContext,
database_session,
request_context,
)
from common.schemas import StrictModel
from backend.schedules import (
graph_rows,
schedule_row,
validate_dag,
from common.scheduler import (
DagTooLarge,
InvalidDag,
InvalidNodeArguments,
ScheduleNotFound,
TriggerError,
create_scheduled_run,
normalize_idempotency_key,
)
from common.ids import new_ulid
router = APIRouter(tags=["schedule-runs"])
@@ -51,46 +53,23 @@ def _iso(value: datetime | None) -> str | None:
return value.astimezone(UTC).isoformat()
def _normalized_idempotency_key(
workspace_id: str,
schedule_id: str,
value: str,
) -> str:
normalized = value.strip()
if len(normalized) < 8:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
"Idempotency-Key must contain at least 8 characters",
def _http_error_from_trigger(exc: TriggerError) -> HTTPException:
if isinstance(exc, ScheduleNotFound):
return HTTPException(status.HTTP_404_NOT_FOUND, str(exc))
if isinstance(exc, InvalidDag):
return HTTPException(
status.HTTP_409_CONFLICT,
detail={
"code": "SCHEDULE_DAG_INVALID",
"message": str(exc),
"errors": exc.errors,
},
)
digest = hashlib.sha256(
f"{workspace_id}:{schedule_id}:{normalized}".encode("utf-8")
).hexdigest()
return f"run:v1:{digest}"
def _arguments(value: dict[str, Any] | None) -> list[str]:
payload = value or {}
raw = payload.get("_args")
result = [str(item) for item in raw] if isinstance(raw, list) else []
for key, item in payload.items():
if key == "_args":
continue
option = f"--{key.replace('_', '-')}"
if item is True:
result.append(option)
elif item is False or item is None:
continue
elif isinstance(item, list):
for list_item in item:
result.extend((option, str(list_item)))
elif isinstance(item, (str, int, float)):
result.extend((option, str(item)))
else:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
f"node argument {key!r} must be a scalar or list",
)
return result
if isinstance(exc, DagTooLarge):
return HTTPException(status.HTTP_409_CONFLICT, str(exc))
if isinstance(exc, InvalidNodeArguments):
return HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, exc.message)
return HTTPException(status.HTTP_409_CONFLICT, str(exc))
def run_summary(item: ScheduleRuns) -> dict[str, Any]:
@@ -183,125 +162,40 @@ async def run_schedule_now(
session: AsyncSession = Depends(database_session),
) -> dict[str, Any]:
reason = payload.reason if payload is not None else "manual_run"
key = _normalized_idempotency_key(
trigger_type: Literal["manual", "cron"] = "cron" if reason == "cron" else "manual"
try:
key = normalize_idempotency_key(
context.workspace.workspace_id,
schedule_id,
idempotency_key,
)
existing = await session.scalar(
select(ScheduleRuns).where(ScheduleRuns.idempotency_key == key)
)
if existing is not None:
if (
existing.workspace_id != context.workspace.workspace_id
or existing.schedule_id != schedule_id
):
except TriggerError as exc:
raise HTTPException(
status.HTTP_409_CONFLICT,
"Idempotency-Key belongs to another schedule run",
)
return {
"request_id": context.request_id,
"data": await run_detail(existing, session),
"meta": {"reused": True},
}
status.HTTP_422_UNPROCESSABLE_ENTITY, str(exc),
) from exc
schedule = await schedule_row(
schedule_id,
context,
try:
run, is_new = await create_scheduled_run(
session,
for_update=True,
)
node_rows, edges = await graph_rows(schedule_id, session)
nodes = [row[0] for row in node_rows]
validation = validate_dag(nodes, edges)
if not validation["valid"] or not nodes:
raise HTTPException(
status.HTTP_409_CONFLICT,
detail={
"code": "SCHEDULE_DAG_INVALID",
"message": "schedule must contain a valid non-empty DAG",
"errors": validation["errors"],
},
)
if len(nodes) > 100 or len(edges) > 500:
raise HTTPException(
status.HTTP_409_CONFLICT,
"schedule exceeds the v1 execution size limit",
)
snapshot = {
"schedule_name": schedule.schedule_name,
"workflow_version": schedule.workflow_version,
"max_concurrency": schedule.max_concurrency,
"failure_policy": schedule.failure_policy,
"nodes": [
{
"node_id": node.node_id,
"node_key": node.node_key,
"versions_id": version.versions_id,
"script_type": script.script_type,
"artifact_object_id": version.artifact_object_id,
"artifact_path": version.artifact_path,
"timeout_seconds": node.timeout_seconds,
"retry_count": node.retry_count,
"retry_interval_sec": node.retry_interval_sec,
"arguments": _arguments(node.arguments_json),
}
for node, version, script in node_rows
],
"edges": [
{
"source_node_id": edge.source_node_id,
"target_node_id": edge.target_node_id,
}
for edge in edges
],
}
now = utcnow()
run = ScheduleRuns(
run_id=new_ulid(),
schedule_id=schedule.schedule_id,
workspace_id=schedule.workspace_id,
workflow_version=schedule.workflow_version,
trigger_type="cron" if reason == "cron" else "manual",
schedule_id=schedule_id,
workspace_id=context.workspace.workspace_id,
triggered_by_user_id=context.user.user_id,
trigger_type=trigger_type,
idempotency_key=key,
run_status="queued",
state_version=0,
schedule_snapshot=snapshot,
queued_at=now,
triggered_by=context.user.user_id,
)
session.add(run)
schedule.last_run_at = now
await add_outbox_event(
session,
event_type="schedule.run.requested",
producer="platform-api",
trace_id=context.request_id,
aggregate_type="schedule_run",
aggregate_id=run.run_id,
idempotency_key=key,
payload={
"workspace_id": run.workspace_id,
"schedule_id": run.schedule_id,
"run_id": run.run_id,
"workflow_version": run.workflow_version,
"trigger_type": run.trigger_type,
"triggered_by": run.triggered_by,
"schedule_snapshot": snapshot,
},
)
await session.flush()
# Commit before yielding so the Outbox row is visible to the executor's
# next MySQL poll — the executor's _database_event_loop picks it up.
# We intentionally do NOT HTTP-push; see backend/schedule_client.py.
except TriggerError as exc:
raise _http_error_from_trigger(exc) from exc
# Commit before responding so the Outbox row is visible to the
# executor's next MySQL poll — the executor's _database_event_loop
# picks it up. We intentionally do NOT HTTP-push.
await session.commit()
await session.refresh(run)
return {
"request_id": context.request_id,
"data": await run_detail(run, session),
"meta": {"reused": False},
"meta": {"reused": not is_new},
}
+169 -12
View File
@@ -124,9 +124,20 @@ async def lifespan(app: Any) -> AsyncIterator[None]:
secret_key=settings.rustfs_secret_key,
)
app.state.default_bucket = settings.rustfs_workspace_bucket
# Ensure every purpose-named bucket exists up front, including the
# trash bucket. The trash bucket is shared across all workspaces
# and usage_types; the source key is preserved as a prefix so a
# restore is a same-key move back to the source bucket.
for bucket in (
settings.rustfs_workspace_bucket,
settings.rustfs_version_bucket,
settings.rustfs_run_log_bucket,
settings.rustfs_trash_bucket,
):
await asyncio.to_thread(
app.state.object_store.ensure_bucket,
app.state.default_bucket)
bucket,
)
try:
yield
finally:
@@ -227,12 +238,18 @@ async def create_upload_record(
storage_object = await session.get(
StorageObjects,
upload.storage_object_id)
if storage_object is None or storage_object.object_status != "available":
# The previously-completed object was deleted (or never
# materialized). Treat the idempotency hit as a tombstone
# and fall through to a fresh upload: clear the pointer so
# complete_upload_record re-validates the bucket + key.
upload.storage_object_id = None
upload.upload_status = "created"
else:
return {
"upload_id": upload.upload_id,
"status": upload.upload_status,
"storage_object": (
storage_payload(storage_object) if storage_object else None
),
"storage_object": storage_payload(storage_object),
}
if upload.upload_status not in {"created", "uploading"}:
raise HTTPException(
@@ -295,10 +312,13 @@ async def complete_upload_record(
raise HTTPException(status.HTTP_404_NOT_FOUND, "upload not found")
if upload.upload_status == "completed" and upload.storage_object_id:
item = await session.get(StorageObjects, upload.storage_object_id)
if item is None:
raise HTTPException(
status.HTTP_409_CONFLICT,
"completed upload has no storage object")
if item is None or item.object_status != "available":
# The linked storage object was deleted. Reset the upload so
# the caller can re-upload the same bytes and create a
# fresh, available object.
upload.storage_object_id = None
upload.upload_status = "created"
else:
return item
if upload.upload_status not in {"created", "uploading"}:
raise HTTPException(
@@ -552,6 +572,20 @@ async def delete_object(
storage_object_id: str,
request: Request,
session: AsyncSession = Depends(database_session)) -> dict[str, Any]:
"""Soft-delete a storage object.
The bytes are copied to ``rustfs_trash_bucket`` under the same key
(preserved as ``{source_bucket}/{object_key}`` so a restore is a
same-name move), the source key is then deleted from its origin
bucket, and the row's ``object_status`` flips to ``"deleted"`` with
``deleted_at`` stamped for the reaper.
Immutable artifacts (version snapshots, run logs) are not
trashed — the policy is enforced by ``is_immutable`` and an
explicit 409. The reaper will physically delete trashed objects
older than ``rustfs_trash_retention_days`` (out of scope for this
endpoint; the field is the contract).
"""
item = await session.scalar(
select(StorageObjects)
.where(StorageObjects.storage_object_id == storage_object_id)
@@ -563,22 +597,145 @@ async def delete_object(
raise HTTPException(
status.HTTP_409_CONFLICT,
"immutable object cannot be deleted")
if item.object_status != "deleted":
if item.object_status == "deleted":
return {
"data": {
"storage_object_id": storage_object_id,
"object_status": item.object_status,
"trash_key": item.trash_key,
}
}
if item.storage_backend == "rustfs" and item.bucket_name and item.object_key:
trash_key = f"{item.bucket_name}/{item.object_key}"
try:
await asyncio.to_thread(
request.app.state.object_store.delete,
bucket_name=item.bucket_name,
object_key=item.object_key)
request.app.state.object_store.move_to_trash,
source_bucket=item.bucket_name,
source_key=item.object_key,
trash_bucket=settings.rustfs_trash_bucket,
trash_key=trash_key,
)
except Exception as exc:
# If the move fails, leave the source intact and surface the
# error. We do NOT mark the row as deleted in that case —
# otherwise we'd have a row pointing to non-existent bytes.
raise HTTPException(
status.HTTP_502_BAD_GATEWAY,
f"failed to move object to trash: {exc}",
) from exc
item.trash_key = trash_key
item.object_status = "deleted"
item.deleted_at = utcnow()
return {
"data": {
"storage_object_id": storage_object_id,
"object_status": item.object_status,
"trash_key": item.trash_key,
"trash_bucket": settings.rustfs_trash_bucket,
}
}
@app.post(
"/internal/v1/objects/{storage_object_id}/restore")
async def restore_object(
storage_object_id: str,
request: Request,
session: AsyncSession = Depends(database_session)) -> dict[str, Any]:
"""Restore a soft-deleted object from the trash bucket.
Copies the bytes back to the source bucket + key and flips the
row back to ``object_status='available'``. If the source bucket
is missing the object (e.g. the trash copy was the only one), the
restore still works because we copy *from* trash rather than
renaming in place. The trash copy is left in place — the reaper
will collect it on the next sweep; this is intentional so a
failed restore does not destroy the only copy.
"""
item = await session.scalar(
select(StorageObjects)
.where(StorageObjects.storage_object_id == storage_object_id)
.with_for_update()
)
if item is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "object not found")
if item.object_status != "deleted":
raise HTTPException(
status.HTTP_409_CONFLICT,
"object is not in trash")
if not item.trash_key or not item.bucket_name or not item.object_key:
raise HTTPException(
status.HTTP_409_CONFLICT,
"object has no trash pointer; cannot restore")
try:
await asyncio.to_thread(
request.app.state.object_store.copy,
source_bucket=settings.rustfs_trash_bucket,
source_key=item.trash_key,
dest_bucket=item.bucket_name,
dest_key=item.object_key,
)
except Exception as exc:
raise HTTPException(
status.HTTP_502_BAD_GATEWAY,
f"failed to restore from trash: {exc}",
) from exc
item.object_status = "available"
item.deleted_at = None
# Keep trash_key so the reaper can clean up the duplicate on its
# next pass; we don't try to delete it here because a partial
# failure would leave the user with no data.
return {
"data": storage_payload(item),
}
@app.post(
"/internal/v1/admin/trash/purge")
async def purge_trash_object(
payload: dict[str, Any],
request: Request,
session: AsyncSession = Depends(database_session)) -> dict[str, Any]:
"""Physically delete a trashed object.
Admin / reaper endpoint — given a ``storage_object_id``, deletes
the bytes from the trash bucket and hard-deletes the DB row.
The route is split from ``delete_object`` because the regular
delete path is the one users hit, and reaper runs need a way to
finalize the lifecycle without re-entering the soft-delete branch.
"""
storage_object_id = (payload or {}).get("storage_object_id", "").strip()
if not storage_object_id:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"storage_object_id is required")
item = await session.scalar(
select(StorageObjects)
.where(StorageObjects.storage_object_id == storage_object_id)
.with_for_update()
)
if item is None:
return {"data": {"storage_object_id": storage_object_id, "purged": False}}
if item.object_status != "deleted":
raise HTTPException(
status.HTTP_409_CONFLICT,
"object is not in trash; refuse to hard-delete live data")
if item.trash_key:
try:
await asyncio.to_thread(
request.app.state.object_store.delete,
bucket_name=settings.rustfs_trash_bucket,
object_key=item.trash_key,
)
except Exception as exc:
raise HTTPException(
status.HTTP_502_BAD_GATEWAY,
f"failed to delete from trash: {exc}",
) from exc
await session.delete(item)
return {"data": {"storage_object_id": storage_object_id, "purged": True}}
@app.get("/internal/health/storage")
async def internal_health() -> dict[str, str]:
return {"status": "ready", "service": "storage-api"}
+2
View File
@@ -10,6 +10,8 @@ dependencies = [
"boto3>=1.34,<2",
"fastapi==0.116.1",
"pydantic-settings>=2.14.2",
"passlib==1.7.4",
"bcrypt>=4.0,<4.1",
]
[build-system]
+16
View File
@@ -83,6 +83,22 @@ class Settings(BaseSettings):
default="run-logs",
description="Bucket for schedule run logs.",
)
rustfs_trash_bucket: str = Field(
default="trash",
description=(
"Bucket for soft-deleted objects. The source bucket key is "
"preserved as a prefix so a restore is a same-key move. "
"Trash is reaped on a schedule out of band."
),
)
rustfs_trash_retention_days: int = Field(
default=30,
description=(
"How long a trashed object is retained before reaping. "
"Tracked in the database (StorageObjects.deleted_at) so the "
"reaper can run as a single SQL sweep."
),
)
# ── local FS roots ────────────────────────────────────────────
workspace_root: str = Field(
+9
View File
@@ -103,6 +103,15 @@ class StorageObjects(Base):
TINYINT(1), nullable=False, server_default=text("0")
)
deleted_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
trash_key: Mapped[Optional[str]] = mapped_column(
String(1100),
comment=(
"Path inside the trash bucket where the soft-deleted bytes "
"live. Format: '{source_bucket}/{object_key}' so a restore "
"is a same-key copy back to the source bucket. NULL while "
"the row is still available."
),
)
class DataResources(Base):
+21
View File
@@ -16,6 +16,18 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from common.scheduler.trigger import (
SYSTEM_CRON_USER_ID,
DagTooLarge,
InvalidDag,
InvalidNodeArguments,
ScheduleNotFound,
TriggerError,
create_scheduled_run,
normalize_idempotency_key,
parse_node_arguments,
)
if TYPE_CHECKING: # pragma: no cover - typing only
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
@@ -53,7 +65,16 @@ def build_sqlalchemy_jobstore(
__all__ = [
"DagTooLarge",
"InvalidDag",
"InvalidNodeArguments",
"JOBSTORE_TABLE",
"SYSTEM_CRON_USER_ID",
"ScheduleNotFound",
"TriggerError",
"build_sqlalchemy_jobstore",
"create_scheduled_run",
"normalize_idempotency_key",
"parse_node_arguments",
"to_sync_database_url",
]
+47
View File
@@ -156,3 +156,50 @@ class RustFSObjectStore:
def delete(self, *, bucket_name: str, object_key: str) -> None:
self.internal.delete_object(Bucket=bucket_name, Key=object_key)
def copy(
self,
*,
source_bucket: str,
source_key: str,
dest_bucket: str,
dest_key: str,
) -> None:
"""Server-side copy ``source_bucket/source_key`` → ``dest_bucket/dest_key``.
``CopySource`` is a single header string of the form
``/{bucket}/{key}`` — must NOT be URL-encoded or quoted.
"""
self.internal.copy_object(
Bucket=dest_bucket,
Key=dest_key,
CopySource={"Bucket": source_bucket, "Key": source_key},
)
def move_to_trash(
self,
*,
source_bucket: str,
source_key: str,
trash_bucket: str,
trash_key: str,
) -> None:
"""Copy an object into the trash bucket and delete the source.
The copy is a server-side operation in RustFS (no data flows
through the client). The source delete is best-effort: if it
fails after the copy succeeds the trash holds the only copy of
the bytes, which is exactly the point — the caller can retry.
"""
self.copy(
source_bucket=source_bucket,
source_key=source_key,
dest_bucket=trash_bucket,
dest_key=trash_key,
)
try:
self.delete(bucket_name=source_bucket, object_key=source_key)
except ClientError:
# Source was already gone, or transient delete failure —
# the trash copy is what matters; caller logs and moves on.
pass
+28
View File
@@ -43,6 +43,18 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# Explicitly forward the session cookie set by
# POST /api/v1/auth/login. nginx forwards it by default, but
# spelling it out keeps the auth contract visible.
proxy_set_header Cookie $http_cookie;
# Defense in depth: blank out the legacy identity headers so a
# malicious client cannot bypass the cookie-based auth flow
# by stuffing X-User-ID / X-Workspace-ID into the request.
# The backend's RequestContext no longer reads them (it
# derives identity from the access_token cookie), so this is
# belt-and-suspenders against a future regression.
proxy_set_header X-User-ID "";
proxy_set_header X-Workspace-ID "";
}
# =========================================================================
@@ -104,6 +116,15 @@ server {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# Defense in depth: do not let the browser-supplied identity
# headers leak past the auth subrequest. The auth subrequest
# only forwards the Cookie + Authorization it cares about;
# the actual Jupyter upstream is fully trusted (the address
# comes from the backend's runtime registry), so a leaked
# X-User-ID here would not matter for the proxy target but
# could pollute audit logs.
proxy_set_header X-User-ID "";
proxy_set_header X-Workspace-ID "";
}
# 2. 内部 Auth 子请求 location
@@ -123,6 +144,13 @@ server {
proxy_set_header Cookie $http_cookie;
proxy_set_header Authorization $http_authorization;
# Defense in depth: the auth subrequest reads the session
# cookie / Authorization header, not the legacy identity
# headers. Blank them out so a poisoned client header cannot
# be confused for an authenticated identity if the backend
# code is ever refactored to read them again.
proxy_set_header X-User-ID "";
proxy_set_header X-Workspace-ID "";
}
# 拒绝其余非法路径
+4
View File
@@ -15,6 +15,7 @@ services:
- head
environment:
DATABASE_URL: ${DATABASE_URL:?DATABASE_URL is required}
INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-admin12345}
web:
build:
@@ -55,6 +56,7 @@ services:
SERVICE_NAME: model-platform-backend
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
RUSTFS_ENDPOINT: ${RUSTFS_ENDPOINT:?RUSTFS_ENDPOINT is required}
RUSTFS_ACCESS_KEY: ${RUSTFS_ACCESS_KEY:?RUSTFS_ACCESS_KEY is required}
@@ -62,6 +64,8 @@ services:
RUSTFS_WORKSPACE_BUCKET: ${RUSTFS_WORKSPACE_BUCKET:-workspaces}
RUSTFS_VERSION_BUCKET: ${RUSTFS_VERSION_BUCKET:-versions}
RUSTFS_RUN_LOG_BUCKET: ${RUSTFS_RUN_LOG_BUCKET:-run-logs}
RUSTFS_TRASH_BUCKET: ${RUSTFS_TRASH_BUCKET:-trash}
RUSTFS_TRASH_RETENTION_DAYS: ${RUSTFS_TRASH_RETENTION_DAYS:-30}
READINESS_TARGETS: ${MYSQL_HOST:?MYSQL_HOST is required}:${MYSQL_PORT:-3306},${RUSTFS_HOST:?RUSTFS_HOST is required}:${RUSTFS_PORT:-9000},runtime:8000
depends_on:
migrate:
+83
View File
@@ -25,3 +25,86 @@
* { box-sizing: border-box; }
html, body { margin: 0; min-width: 1024px; min-height: 100%; }
button, input, select, textarea { font: inherit; }
.auth-loading,
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.auth-loading {
gap: 12px;
color: #61758a;
}
.auth-loading > span {
width: 20px;
height: 20px;
border: 2px solid #bfd5e9;
border-top-color: #1677ff;
border-radius: 50%;
animation: auth-spin 0.8s linear infinite;
}
@keyframes auth-spin { to { transform: rotate(360deg); } }
.login-page {
padding: 48px;
background:
radial-gradient(circle at 20% 15%, rgba(22, 119, 255, 0.12), transparent 34%),
linear-gradient(145deg, #eef5fb, #f8fafc 55%, #edf3f8);
}
.login-card {
width: 420px;
padding: 42px;
border: 1px solid #dbe6ef;
border-radius: 18px;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 22px 60px rgba(37, 63, 88, 0.14);
}
.login-brand {
width: 46px;
height: 46px;
display: grid;
place-items: center;
border-radius: 13px;
color: white;
background: linear-gradient(145deg, #1177e8, #25a1f2);
}
.login-kicker {
margin: 26px 0 8px;
color: #1677ff;
font-size: 11px;
font-weight: 750;
letter-spacing: 0.14em;
}
.login-card h1 { margin: 0; font-size: 27px; }
.login-description { margin: 10px 0 28px; color: #728398; }
.login-card form { display: grid; gap: 18px; }
.login-card label { display: grid; gap: 8px; color: #465b70; font-size: 13px; }
.login-card input {
width: 100%;
padding: 12px 14px;
border: 1px solid #cad8e5;
border-radius: 9px;
outline: none;
color: #18334f;
background: white;
}
.login-card input:focus { border-color: #1677ff; box-shadow: 0 0 0 3px rgba(22, 119, 255, 0.12); }
.login-card button {
min-height: 44px;
border: 0;
border-radius: 9px;
color: white;
background: #1677ff;
cursor: pointer;
}
.login-card button:disabled { opacity: 0.65; cursor: wait; }
.login-error { margin: -4px 0 0; color: #d4380d; font-size: 13px; }
+256
View File
@@ -0,0 +1,256 @@
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import { useLocation, useNavigate } from "react-router";
import * as rawApi from "../services/api";
import type { WorkspaceBoundApi } from "../services/api";
export type AuthUser = {
user_id: string;
username: string;
display_name: string;
email: string | null;
status: string;
role_code: string | null;
};
export type AuthWorkspace = {
workspace_id: string;
workspace_code: string;
workspace_name: string;
role_code: string;
role_name: string;
};
type AuthSession = {
user: AuthUser;
workspaces: AuthWorkspace[];
default_workspace_id: string | null;
};
type ApiEnvelope<T> = {
data: T;
};
type AuthContextValue = {
user: AuthUser | null;
workspaces: AuthWorkspace[];
currentWorkspace: AuthWorkspace | null;
loading: boolean;
login: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
setCurrentWorkspace: (workspaceId: string) => void;
};
const AuthContext = createContext<AuthContextValue | null>(null);
const workspaceStorageKey = "model-platform-current-workspace";
async function authRequest<T>(path: string, init: RequestInit = {}): Promise<T> {
const response = await fetch(path, {
...init,
credentials: "same-origin",
headers: {
...(init.body ? { "Content-Type": "application/json" } : {}),
...init.headers,
},
});
const payload = await response.json().catch(() => ({})) as
| ApiEnvelope<T>
| { detail?: string };
if (!response.ok) {
throw new Error(
"detail" in payload && typeof payload.detail === "string"
? payload.detail
: `请求失败(HTTP ${response.status}`,
);
}
return (payload as ApiEnvelope<T>).data;
}
function selectWorkspace(
session: AuthSession,
preferredId?: string | null,
): AuthWorkspace | null {
return session.workspaces.find((item) => item.workspace_id === preferredId)
?? session.workspaces.find(
(item) => item.workspace_id === session.default_workspace_id,
)
?? session.workspaces[0]
?? null;
}
export function AuthProvider({ children }: { children: ReactNode }) {
const location = useLocation();
const navigate = useNavigate();
const [user, setUser] = useState<AuthUser | null>(null);
const [workspaces, setWorkspaces] = useState<AuthWorkspace[]>([]);
const [currentWorkspace, setWorkspace] = useState<AuthWorkspace | null>(null);
const [loading, setLoading] = useState(true);
const applySession = useCallback((session: AuthSession) => {
const storedId = typeof window === "undefined"
? null
: window.localStorage.getItem(workspaceStorageKey);
const workspace = selectWorkspace(session, storedId);
setUser(session.user);
setWorkspaces(session.workspaces);
setWorkspace(workspace);
if (workspace && typeof window !== "undefined") {
window.localStorage.setItem(workspaceStorageKey, workspace.workspace_id);
}
}, []);
useEffect(() => {
let cancelled = false;
void authRequest<AuthSession>("/api/v1/auth/me")
.then((session) => {
if (!cancelled) applySession(session);
})
.catch(() => {
if (!cancelled) {
setUser(null);
setWorkspaces([]);
setWorkspace(null);
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [applySession]);
useEffect(() => {
if (loading) return;
if (!user && location.pathname !== "/login") {
navigate("/login", { replace: true });
} else if (user && location.pathname === "/login") {
navigate("/workbench", { replace: true });
}
}, [loading, location.pathname, navigate, user]);
const login = useCallback(async (username: string, password: string) => {
const session = await authRequest<AuthSession>("/api/v1/auth/login", {
method: "POST",
body: JSON.stringify({ username, password }),
});
applySession(session);
navigate("/workbench", { replace: true });
}, [applySession, navigate]);
const logout = useCallback(async () => {
try {
await authRequest<{ logged_out: boolean }>("/api/v1/auth/logout", {
method: "POST",
});
} finally {
setUser(null);
setWorkspaces([]);
setWorkspace(null);
navigate("/login", { replace: true });
}
}, [navigate]);
const setCurrentWorkspace = useCallback((workspaceId: string) => {
const workspace = workspaces.find((item) => item.workspace_id === workspaceId);
if (!workspace) return;
setWorkspace(workspace);
window.localStorage.setItem(workspaceStorageKey, workspace.workspace_id);
}, [workspaces]);
const value = useMemo<AuthContextValue>(() => ({
user,
workspaces,
currentWorkspace,
loading,
login,
logout,
setCurrentWorkspace,
}), [currentWorkspace, loading, login, logout, setCurrentWorkspace, user, workspaces]);
if (loading) {
return (
<div className="auth-loading" role="status">
<span />
</div>
);
}
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextValue {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth 必须在 AuthProvider 内使用");
}
return context;
}
export function useApi(): WorkspaceBoundApi {
const { currentWorkspace } = useAuth();
const workspaceId = currentWorkspace?.workspace_id ?? "";
return useMemo<WorkspaceBoundApi>(() => ({
listScripts: () => rawApi.listScripts(workspaceId),
createScript: (input) => rawApi.createScript(workspaceId, input),
uploadScript: (file, parentPath, visibility) =>
rawApi.uploadScript(workspaceId, file, parentPath, visibility),
updateScript: (scriptId, input) =>
rawApi.updateScript(workspaceId, scriptId, input),
deleteScript: (scriptId) => rawApi.deleteScript(workspaceId, scriptId),
listWorkspaceDirectories: () => rawApi.listWorkspaceDirectories(workspaceId),
createWorkspaceDirectory: (directoryName, parentPath) =>
rawApi.createWorkspaceDirectory(workspaceId, directoryName, parentPath),
deleteWorkspaceDirectory: (path) =>
rawApi.deleteWorkspaceDirectory(workspaceId, path),
acquireFileLock: (script) => rawApi.acquireFileLock(workspaceId, script),
heartbeatFileLock: (session) => rawApi.heartbeatFileLock(workspaceId, session),
releaseFileLock: (session) => rawApi.releaseFileLock(workspaceId, session),
releaseFileLockOnUnload: (session) =>
rawApi.releaseFileLockOnUnload(workspaceId, session),
createJupyterAccessTicket: (session) =>
rawApi.createJupyterAccessTicket(workspaceId, session),
listScriptVersions: (scriptId) => rawApi.listScriptVersions(workspaceId, scriptId),
publishScriptVersion: (input) => rawApi.publishScriptVersion(workspaceId, input),
listSchedules: () => rawApi.listSchedules(workspaceId),
getSchedule: (scheduleId) => rawApi.getSchedule(workspaceId, scheduleId),
createSchedule: (input) => rawApi.createSchedule(workspaceId, input),
updateSchedule: (scheduleId, input) =>
rawApi.updateSchedule(workspaceId, scheduleId, input),
deleteSchedule: (scheduleId, workflowVersion) =>
rawApi.deleteSchedule(workspaceId, scheduleId, workflowVersion),
listScheduleArtifacts: () => rawApi.listScheduleArtifacts(workspaceId),
hideScheduleArtifact: (versionsId) =>
rawApi.hideScheduleArtifact(workspaceId, versionsId),
listEmployees: () => rawApi.listEmployees(workspaceId),
createEmployee: (input) => rawApi.createEmployee(workspaceId, input),
updateEmployee: (userId, input) =>
rawApi.updateEmployee(workspaceId, userId, input),
deleteEmployee: (userId) => rawApi.deleteEmployee(workspaceId, userId),
createScheduleNode: (scheduleId, input) =>
rawApi.createScheduleNode(workspaceId, scheduleId, input),
updateScheduleNode: (scheduleId, nodeId, input) =>
rawApi.updateScheduleNode(workspaceId, scheduleId, nodeId, input),
deleteScheduleNode: (scheduleId, nodeId, workflowVersion) =>
rawApi.deleteScheduleNode(workspaceId, scheduleId, nodeId, workflowVersion),
createScheduleEdge: (scheduleId, input) =>
rawApi.createScheduleEdge(workspaceId, scheduleId, input),
deleteScheduleEdge: (scheduleId, edgeId, workflowVersion) =>
rawApi.deleteScheduleEdge(workspaceId, scheduleId, edgeId, workflowVersion),
validateSchedule: (scheduleId) => rawApi.validateSchedule(workspaceId, scheduleId),
previewCron: (input) => rawApi.previewCron(workspaceId, input),
runScheduleNow: (scheduleId) => rawApi.runScheduleNow(workspaceId, scheduleId),
listScheduleRuns: (input) => rawApi.listScheduleRuns(workspaceId, input),
getScheduleRun: (runId) => rawApi.getScheduleRun(workspaceId, runId),
}), [workspaceId]);
}
+19 -13
View File
@@ -2,13 +2,9 @@ import { type FormEvent, useEffect, useState } from "react";
import {
ApiRequestError,
createEmployee,
deleteEmployee,
demoContext,
listEmployees,
updateEmployee,
type Employee,
} from "../../services/api";
import { useApi, useAuth } from "../../context/AuthContext";
import Icon from "../../components/Icon";
import "../../styles/admin.css";
import "../../styles/dashboard.css";
@@ -28,13 +24,17 @@ export function DashboardPage({
online: boolean;
onNavigate: (page: "scripts" | "schedules" | "system") => void;
}) {
const { user, currentWorkspace } = useAuth();
return (
<section className="dashboard-page">
<div className="dashboard-hero">
<div>
<span>MODEL DEVELOPMENT PLATFORM</span>
<h2>{demoContext.userName}</h2>
<p> {demoContext.workspaceName}</p>
<h2>{user?.display_name ?? "用户"}</h2>
<p>
{currentWorkspace?.workspace_name ?? "(未选择 Workspace"}
</p>
</div>
<span className="dashboard-hero__badge">{online ? "服务正常" : "服务连接中"}</span>
</div>
@@ -112,18 +112,20 @@ export function SystemAdminPage({
onNotify: (notice: Notice) => void;
onConnectionChange: (online: boolean) => void;
}) {
const api = useApi();
const { user, currentWorkspace } = useAuth();
const [employees, setEmployees] = useState<Employee[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [editing, setEditing] = useState<Employee | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [form, setForm] = useState(EMPTY_FORM);
const canManage = demoContext.roleCode === "admin";
const canManage = user?.role_code === "admin";
const load = async (): Promise<void> => {
setLoading(true);
try {
setEmployees(await listEmployees());
setEmployees(await api.listEmployees());
onConnectionChange(true);
} catch (error) {
onConnectionChange(false);
@@ -164,7 +166,7 @@ export function SystemAdminPage({
setSaving(true);
try {
if (editing) {
const updated = await updateEmployee(editing.user_id, {
const updated = await api.updateEmployee(editing.user_id, {
display_name: form.display_name.trim(),
email: form.email.trim() || null,
role_code: form.role_code,
@@ -174,7 +176,7 @@ export function SystemAdminPage({
(item) => item.user_id === updated.user_id ? updated : item,
));
} else {
const created = await createEmployee({
const created = await api.createEmployee({
username: form.username.trim(),
display_name: form.display_name.trim(),
email: form.email.trim() || null,
@@ -197,7 +199,7 @@ export function SystemAdminPage({
const remove = async (employee: Employee): Promise<void> => {
if (!window.confirm(`确定从当前 Workspace 删除员工“${employee.display_name}”吗?`)) return;
try {
await deleteEmployee(employee.user_id);
await api.deleteEmployee(employee.user_id);
setEmployees((current) => current.filter((item) => item.user_id !== employee.user_id));
onNotify({ tone: "success", message: "员工已删除" });
} catch (error) {
@@ -211,7 +213,11 @@ export function SystemAdminPage({
return (
<section className="admin-page">
<header className="admin-page__header">
<div><span></span><h2></h2><p>{demoContext.workspaceName} · {employees.length} </p></div>
<div>
<span></span>
<h2></h2>
<p>{currentWorkspace?.workspace_name ?? "(未选择 Workspace"} · {employees.length} </p>
</div>
<button className="primary-button" type="button" disabled={!canManage} onClick={openCreate}>
<Icon name="plus" size={15} />
</button>
@@ -10,24 +10,6 @@ import {
import { useLocation, useNavigate } from "react-router";
import {
acquireFileLock,
createWorkspaceDirectory,
createScript,
createJupyterAccessTicket,
deleteScript,
deleteWorkspaceDirectory,
demoContext,
demoUsers,
demoWorkspaces,
heartbeatFileLock,
listScripts,
listScriptVersions,
listWorkspaceDirectories,
publishScriptVersion,
releaseFileLock,
releaseFileLockOnUnload,
setDemoContext,
uploadScript,
type ActiveEditSession,
type ScriptItem,
type ScriptType,
@@ -35,6 +17,7 @@ import {
type Visibility,
type WorkspaceDirectory,
} from "../../services/api";
import { useApi, useAuth } from "~/context/AuthContext";
import Icon from "../../components/Icon";
import SchedulePage from "../schedules/SchedulePage";
import { DashboardPage, SystemAdminPage } from "../admin/AdminPages";
@@ -123,9 +106,27 @@ function mergeDirectories(
).values()];
}
export default function ModelPlatformApp() {
const { currentWorkspace } = useAuth();
if (!currentWorkspace) {
return (
<div className="app-shell" style={{ display: "flex", alignItems: "center", justifyContent: "center" }}>
<div style={{ textAlign: "center" }}>
<span style={{ fontSize: 18 }}></span>
</div>
</div>
);
}
return <AuthenticatedModelPlatformApp />;
}
function AuthenticatedModelPlatformApp() {
const location = useLocation();
const navigate = useNavigate();
const activePage = pageFromPath(location.pathname);
const auth = useAuth();
const { user, workspaces, setCurrentWorkspace, logout } = auth;
const currentWorkspace = auth.currentWorkspace!;
const api = useApi();
const [scripts, setScripts] = useState<ScriptItem[]>([]);
const [directories, setDirectories] = useState<WorkspaceDirectory[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
@@ -144,7 +145,6 @@ export default function ModelPlatformApp() {
}>({ open: false, parentPath: "", name: "", busy: false });
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const [workspaceMenuOpen, setWorkspaceMenuOpen] = useState(false);
const [userMenuOpen, setUserMenuOpen] = useState(false);
const [uploadParentPath, setUploadParentPath] = useState("");
const [uploading, setUploading] = useState(false);
const uploadInputRef = useRef<HTMLInputElement | null>(null);
@@ -176,8 +176,8 @@ export default function ModelPlatformApp() {
setRefreshing(silent);
try {
const [items, folderItems] = await Promise.all([
listScripts(),
listWorkspaceDirectories(),
api.listScripts(),
api.listWorkspaceDirectories(),
]);
setScripts(items);
setDirectories(folderItems);
@@ -248,7 +248,7 @@ export default function ModelPlatformApp() {
}
let ignore = false;
setVersionsLoading(true);
void listScriptVersions(selectedId)
void api.listScriptVersions(selectedId)
.then((items) => {
if (!ignore) setVersions(items);
})
@@ -282,7 +282,7 @@ export default function ModelPlatformApp() {
return;
}
heartbeatRunning = true;
void heartbeatFileLock(current)
void api.heartbeatFileLock(current)
.then((updated) => {
setEditSession((active) => active
&& active.edit_session_id === updated.edit_session_id
@@ -319,7 +319,7 @@ export default function ModelPlatformApp() {
if (!current || current.edit_session_id !== editSession.edit_session_id) {
return;
}
void createJupyterAccessTicket(current)
void api.createJupyterAccessTicket(current)
.then((ticket) => {
setEditSession((active) => active
&& active.edit_session_id === ticket.edit_session_id
@@ -342,7 +342,7 @@ export default function ModelPlatformApp() {
if (!editSession) return;
const handleUnload = () => {
const current = editSessionRef.current;
if (current) releaseFileLockOnUnload(current);
if (current) api.releaseFileLockOnUnload(current);
};
window.addEventListener("beforeunload", handleUnload);
return () => window.removeEventListener("beforeunload", handleUnload);
@@ -356,24 +356,17 @@ export default function ModelPlatformApp() {
);
}, [keyword, scripts]);
const memberScriptGroups = [...demoUsers]
.sort((left, right) => (
Number(right.userId === demoContext.userId)
- Number(left.userId === demoContext.userId)
))
.map((user) => {
const memberScripts = filteredScripts.filter(
(item) => item.owner_user_id === user.userId,
const memberScriptGroups = (() => {
const currentUserScripts = filteredScripts.filter(
(item) => item.owner_user_id === user?.user_id,
);
const inferred = inferredDirectories(memberScripts);
return {
user,
scripts: memberScripts,
directories: user.userId === demoContext.userId
? mergeDirectories(directories, inferred)
: inferred,
};
});
const inferred = inferredDirectories(currentUserScripts);
return [{
user: user,
scripts: currentUserScripts,
directories: mergeDirectories(directories, inferred),
}];
})();
const selected = scripts.find((item) => item.script_id === selectedId) ?? null;
const selectScript = (scriptId: string | null) => {
@@ -413,7 +406,7 @@ export default function ModelPlatformApp() {
let newlyAcquired = false;
try {
if (active && active.script_id !== script.script_id) {
await releaseFileLock(active);
await api.releaseFileLock(active);
setEmbeddedJupyterUrl(null);
setEditSession(null);
editSessionRef.current = null;
@@ -422,20 +415,20 @@ export default function ModelPlatformApp() {
if (!requestIsCurrent()) return;
if (!active) {
active = await acquireFileLock(script);
active = await api.acquireFileLock(script);
newlyAcquired = true;
}
if (!requestIsCurrent()) {
if (active) {
await releaseFileLock(active);
await api.releaseFileLock(active);
clearSessionIfActive(active);
}
return;
}
const ticket = await createJupyterAccessTicket(active);
const ticket = await api.createJupyterAccessTicket(active);
if (!requestIsCurrent()) {
await releaseFileLock(active);
await api.releaseFileLock(active);
clearSessionIfActive(active);
return;
}
@@ -456,7 +449,7 @@ export default function ModelPlatformApp() {
} catch (error) {
if (newlyAcquired && active) {
try {
await releaseFileLock(active);
await api.releaseFileLock(active);
} catch {
// The database lease is the final safety net if compensation cannot reach Runtime.
}
@@ -513,7 +506,7 @@ export default function ModelPlatformApp() {
}
setEditBusy(true);
try {
await releaseFileLock(active);
await api.releaseFileLock(active);
setEmbeddedJupyterUrl(null);
setEditSession(null);
editSessionRef.current = null;
@@ -572,7 +565,7 @@ export default function ModelPlatformApp() {
if (!publishTarget) return;
setPublishing(true);
try {
const version = await publishScriptVersion({
const version = await api.publishScriptVersion({
script: publishTarget,
releaseNote,
visibility: publishVisibility,
@@ -602,7 +595,7 @@ export default function ModelPlatformApp() {
if (!form.name.trim()) return;
setCreating(true);
try {
const created = await createScript(form);
const created = await api.createScript(form);
setScripts((items) => [created, ...items]);
selectScript(created.script_id);
setCreateOpen(false);
@@ -658,7 +651,7 @@ export default function ModelPlatformApp() {
let lastCreated: ScriptItem | null = null;
try {
for (const file of files) {
lastCreated = await uploadScript(file, uploadParentPath);
lastCreated = await api.uploadScript(file, uploadParentPath);
}
await load(true);
if (lastCreated) selectScript(lastCreated.script_id);
@@ -684,7 +677,7 @@ export default function ModelPlatformApp() {
if (!folderDialog.name.trim()) return;
setFolderDialog((current) => ({ ...current, busy: true }));
try {
await createWorkspaceDirectory(
await api.createWorkspaceDirectory(
folderDialog.name.trim(),
folderDialog.parentPath,
);
@@ -718,7 +711,7 @@ export default function ModelPlatformApp() {
if (editSessionRef.current?.script_id === script.script_id) return;
}
try {
await deleteScript(script.script_id);
await api.deleteScript(script.script_id);
if (selectedIdRef.current === script.script_id) selectScript(null);
await load(true);
setToast({
@@ -752,7 +745,7 @@ export default function ModelPlatformApp() {
if (editSessionRef.current?.script_id === activeScript.script_id) return;
}
try {
const result = await deleteWorkspaceDirectory(path);
const result = await api.deleteWorkspaceDirectory(path);
const selectedScript = scripts.find(
(item) => item.script_id === selectedIdRef.current,
);
@@ -851,36 +844,29 @@ export default function ModelPlatformApp() {
{apiOnline ? "服务已连接" : "服务未连接"}
</div>
<div className="topbar-menu-wrap">
<button className="workspace-switcher" type="button" onClick={() => { setWorkspaceMenuOpen((value) => !value); setUserMenuOpen(false); }}>
<button className="workspace-switcher" type="button" onClick={() => { setWorkspaceMenuOpen((value) => !value); }}>
<span className="workspace-switcher__icon"><Icon name="workspace" size={18} /></span>
<span><small> Workspace</small><strong>{demoContext.workspaceName}</strong></span>
<span><small> Workspace</small><strong>{currentWorkspace.workspace_name}</strong></span>
<Icon name="chevron" size={15} />
</button>
{workspaceMenuOpen && (
<div className="topbar-dropdown">
{demoWorkspaces.map((workspace) => (
<button className={workspace.workspaceId === demoContext.workspaceId ? "is-selected" : ""} type="button" key={workspace.workspaceId} onClick={() => { setDemoContext({ workspace }); window.location.reload(); }}>
<Icon name="workspace" size={15} /><span><strong>{workspace.workspaceName}</strong><small>{workspace.workspaceId === demoContext.workspaceId ? "当前使用" : "点击切换"}</small></span>
{workspaces.map((workspace) => (
<button className={workspace.workspace_id === currentWorkspace.workspace_id ? "is-selected" : ""} type="button" key={workspace.workspace_id} onClick={() => { setCurrentWorkspace(workspace.workspace_id); setWorkspaceMenuOpen(false); }}>
<Icon name="workspace" size={15} /><span><strong>{workspace.workspace_name}</strong><small>{workspace.workspace_id === currentWorkspace.workspace_id ? "当前使用" : "点击切换"}</small></span>
</button>
))}
</div>
)}
</div>
<div className="topbar-menu-wrap">
<button className="user-menu" type="button" onClick={() => { setUserMenuOpen((value) => !value); setWorkspaceMenuOpen(false); }}>
<span className="avatar">{demoContext.userName.slice(0, 1)}</span>
<span className="user-menu__copy"><strong>{demoContext.userName}</strong><small>{demoContext.roleName}</small></span>
<Icon name="chevron" size={15} />
<button className="user-menu" type="button">
<span className="avatar">{user?.display_name?.slice(0, 1) ?? "?"}</span>
<span className="user-menu__copy"><strong>{user?.display_name ?? "未知用户"}</strong><small>{user?.role_code === "admin" ? "管理员" : "开发人员"}</small></span>
</button>
{userMenuOpen && (
<div className="topbar-dropdown topbar-dropdown--users">
{demoUsers.map((user) => (
<button className={user.userId === demoContext.userId ? "is-selected" : ""} type="button" key={user.userId} onClick={() => { setDemoContext({ user }); window.location.reload(); }}>
<span className="avatar">{user.userName.slice(0, 1)}</span><span><strong>{user.userName}</strong><small>{user.roleName} · {user.username}</small></span>
<button className="text-button" type="button" onClick={() => { logout(); window.location.assign("/login"); }}>
</button>
))}
</div>
)}
</div>
</div>
</header>
@@ -949,18 +935,18 @@ export default function ModelPlatformApp() {
<>
{memberScriptGroups.map((group) => (
<WorkspaceTreeGroup
key={group.user.userId}
title={`${group.user.userName}的文件`}
key={group.user?.user_id ?? "anon"}
title={`${group.user?.display_name}的文件`}
scripts={group.scripts}
directories={group.directories}
selectedId={selectedId}
onSelect={selectScript}
onContextMenu={
group.user.userId === demoContext.userId
group.user?.user_id === user?.user_id
? showContextMenu
: undefined
}
readOnly={group.user.userId !== demoContext.userId}
readOnly={group.user?.user_id !== user?.user_id}
/>
))}
{filteredScripts.length === 0 && (
@@ -1045,13 +1031,13 @@ export default function ModelPlatformApp() {
</section>
) : activePage === "schedules" ? (
<SchedulePage
key={`${demoContext.userId}-${demoContext.workspaceId}`}
key={`${user?.user_id ?? "anon"}-${currentWorkspace?.workspace_id ?? "none"}`}
onNotify={setToast}
onConnectionChange={setApiOnline}
/>
) : activePage === "system" ? (
<SystemAdminPage
key={`${demoContext.userId}-${demoContext.workspaceId}`}
key={`${user?.user_id ?? "anon"}-${currentWorkspace?.workspace_id ?? "none"}`}
onNotify={setToast}
onConnectionChange={setApiOnline}
/>
@@ -0,0 +1,327 @@
import Icon from "../../components/Icon";
import type {
ActiveEditSession,
ScriptItem,
StableVersion,
} from "../../services/api";
import { scriptIcon } from "./WorkspaceTree";
type ToastState = {
tone: "success" | "error" | "info";
message: string;
};
type ScriptWorkspaceProps = {
script: ScriptItem;
editSession: ActiveEditSession | null;
jupyterUrl: string | null;
editBusy: boolean;
openError: string | null;
latestVersion: StableVersion | null;
versionsLoading: boolean;
onOpenEditor: () => void;
onEndEditing: () => void;
onClose: () => void;
onPublish: () => void;
onInfo: (toast: ToastState) => void;
};
function formatTime(value: string) {
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(new Date(value));
}
function formatBytes(value: number) {
if (value < 1024) return `${value} B`;
return `${(value / 1024).toFixed(1)} KB`;
}
function shortHash(value: string) {
return value ? `${value.slice(0, 8)}${value.slice(-6)}` : "—";
}
function confineJupyterFrame(frame: HTMLIFrameElement): void {
try {
const document = frame.contentDocument;
if (!document?.documentElement) return;
const keepInside = (): void => {
document.querySelectorAll<HTMLElement>("button,[role='button']").forEach(
(element) => {
const label = `${element.getAttribute("aria-label") ?? ""} ${
element.getAttribute("title") ?? ""
} ${element.textContent ?? ""}`.trim();
if (/^open in\b/i.test(label) || /\bopen in\.\.\./i.test(label)) {
element.style.setProperty("display", "none", "important");
}
},
);
document.querySelectorAll<HTMLAnchorElement>("a[target]").forEach((link) => {
if (["_blank", "_top", "_parent"].includes(link.target)) {
link.target = "_self";
}
});
};
keepInside();
new MutationObserver(keepInside).observe(document.documentElement, {
childList: true,
subtree: true,
});
document.addEventListener("click", (event) => {
const target = event.target as HTMLElement | null;
const link = target?.closest?.("a") as HTMLAnchorElement | null;
if (link && ["_blank", "_top", "_parent"].includes(link.target)) {
link.target = "_self";
}
}, true);
} catch {
// The iframe remains sandboxed even if its document is not yet accessible.
}
}
export function ScriptWorkspace({
script,
editSession,
jupyterUrl,
editBusy,
openError,
latestVersion,
versionsLoading,
onOpenEditor,
onEndEditing,
onClose,
onPublish,
onInfo,
}: ScriptWorkspaceProps) {
const isNotebook = script.script_type === "notebook";
const isEditing = editSession?.session_status === "active";
return (
<>
<div className="tabbar">
<div className="editor-tab editor-tab--active">
<span className={`file-icon file-icon--${script.script_type}`}>
<Icon name={scriptIcon(script)} size={16} />
</span>
<span>{script.script_name}</span>
<button type="button" aria-label="关闭标签" onClick={onClose}>
<Icon name="close" size={14} />
</button>
</div>
<button
className="new-tab"
type="button"
onClick={() => onInfo({ tone: "info", message: "请从左侧选择或新建脚本" })}
>
<Icon name="plus" size={17} />
</button>
</div>
<div className="editor-toolbar">
<div className="editor-toolbar__path">
<span className={`file-icon file-icon--${script.script_type}`}>
<Icon name={scriptIcon(script)} size={17} />
</span>
<span></span>
<Icon name="chevron" size={13} />
<strong>{script.script_name}</strong>
</div>
<div className="editor-toolbar__actions">
{isEditing ? (
<button
className="end-edit-button"
type="button"
disabled={editBusy}
onClick={onEndEditing}
>
{editBusy ? "正在释放…" : "结束编辑"}
</button>
) : (
<button
type="button"
onClick={() => onInfo({
tone: "info",
message: "Jupyter 中保存后会直接写入 Workspace 工作副本",
})}
>
</button>
)}
<button
className="release-button"
type="button"
onClick={onPublish}
>
</button>
<span className={`stage-badge${isEditing ? " is-editing" : ""}`}>
<span />
{isEditing
? isNotebook
? "Demo 无锁模式 · Kernel 已连接"
: "Demo 无锁模式 · 编辑中"
: latestVersion
? `最新 ${latestVersion.version_label}`
: "工作副本已就绪"}
</span>
</div>
</div>
<div className={`editor-canvas${isEditing && jupyterUrl ? " is-embedded" : ""}`}>
{isEditing && jupyterUrl ? (
<section className="embedded-jupyter">
<div className="embedded-jupyter__status">
<span>
<i />
Workspace Jupyter Server
</span>
<span>
{isNotebook ? "Notebook Session · Python 3 Kernel" : "Jupyter 文本编辑器"}
</span>
<code title={editSession.runtime_id}>
Runtime {editSession.runtime_id.slice(-8)}
</code>
</div>
<iframe
key={`${editSession.edit_session_id}:${jupyterUrl}`}
src={jupyterUrl}
title={`${script.script_name} Jupyter 编辑器`}
allow="clipboard-read; clipboard-write"
sandbox="allow-same-origin allow-scripts allow-forms allow-downloads allow-modals"
onLoad={(event) => confineJupyterFrame(event.currentTarget)}
/>
</section>
) : isNotebook ? (
<section
className={`editor-opening-state${openError ? " has-error" : ""}`}
aria-live="polite"
>
<div className="editor-opening-state__icon">
{openError
? <Icon name="info" size={28} />
: <span className="button-spinner button-spinner--blue" />}
</div>
<strong>
{openError ? "Notebook 打开失败" : "正在打开 Notebook"}
</strong>
<p>
{openError
? openError
: "正在获取编辑锁并连接 Workspace Jupyter Server…"}
</p>
{openError && (
<button
className="open-editor-button"
type="button"
disabled={editBusy}
onClick={onOpenEditor}
>
{editBusy
? <span className="button-spinner button-spinner--blue" />
: <Icon name="refresh" size={16} />}
{editBusy ? "正在重试…" : "重试打开"}
</button>
)}
</section>
) : (
<section className="script-overview">
<div className="script-overview__header">
<div>
<span className="section-kicker">PYTHON SCRIPT</span>
<h2>{script.script_name}</h2>
<p>{script.relative_path}</p>
</div>
<button
className={`open-editor-button${isEditing ? " is-editing" : ""}`}
type="button"
disabled={editBusy}
onClick={onOpenEditor}
>
{editBusy
? <span className="button-spinner button-spinner--blue" />
: <Icon name="external" size={17} />}
{editBusy
? "正在准备编辑器…"
: isEditing
? "继续编辑"
: "打开编辑器"}
</button>
</div>
<div className="metadata-grid">
<div>
<span></span>
<strong>Python</strong>
</div>
<div>
<span></span>
<strong>
{script.visibility === "workspace"
? "Workspace"
: script.visibility === "public" ? "公开" : "私有"}
</strong>
</div>
<div>
<span></span>
<strong>{formatBytes(script.size_bytes)}</strong>
</div>
<div>
<span></span>
<strong>{formatTime(script.updated_at)}</strong>
</div>
</div>
<div className="preview-card">
<div className="preview-card__bar">
<div>
<span className="window-dot window-dot--red" />
<span className="window-dot window-dot--yellow" />
<span className="window-dot window-dot--green" />
</div>
<span>Python </span>
<em>{isEditing ? "Jupyter 中可编辑" : "平台只读预览"}</em>
</div>
<PythonPreview />
</div>
<div className="integrity-row">
<span>
<Icon name="check" size={15} />
{isEditing ? "Demo 无锁编辑会话已连接" : "工作副本已同步"}
</span>
<span>SHA-256&nbsp; {shortHash(script.content_hash)}</span>
<span>
&nbsp;
{versionsLoading
? "加载中"
: latestVersion
? `${latestVersion.version_label} · ${latestVersion.versions_id}`
: "尚未发布"}
</span>
</div>
</section>
)}
</div>
</>
);
}
function PythonPreview() {
return (
<div className="python-preview">
<div className="line-numbers">
1<br />2<br />3<br />4<br />5<br />6<br />7<br />8<br />9
</div>
<pre>
<span className="code-comment">&quot;&quot;&quot;&quot;&quot;&quot;</span>
{"\n\n"}<b>def</b> <span className="code-function">main</span>() -&gt; <b>None</b>:
{"\n"} print(<i>&quot;Hello, Model Platform!&quot;</i>)
{"\n\n\n"}<b>if</b> __name__ == <i>&quot;__main__&quot;</i>:
{"\n"} main()
</pre>
</div>
);
}
@@ -0,0 +1,212 @@
import { type MouseEvent as ReactMouseEvent, useState } from "react";
import Icon from "../../components/Icon";
import type {
ScriptItem,
WorkspaceDirectory,
} from "../../services/api";
export type WorkspaceTreeTarget = {
kind: "root" | "directory" | "file";
path: string;
script?: ScriptItem;
};
type WorkspaceTreeProps = {
title: string;
scripts: ScriptItem[];
directories: WorkspaceDirectory[];
selectedId: string | null;
onSelect: (id: string) => void;
onContextMenu?: (
event: ReactMouseEvent,
target: WorkspaceTreeTarget,
) => void;
readOnly?: boolean;
};
type WorkspaceTreeItemsProps = Omit<WorkspaceTreeProps, "title" | "readOnly"> & {
path: string;
depth: number;
};
function formatTime(value: string) {
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(new Date(value));
}
export function scriptIcon(item: ScriptItem) {
return item.script_type === "notebook" ? "notebook" : "python";
}
function ownedScriptPath(item: ScriptItem) {
return item.relative_path.replaceAll("\\", "/").split("/").slice(2).join("/");
}
function parentOf(path: string) {
const parts = path.split("/");
parts.pop();
return parts.join("/");
}
export function WorkspaceTreeGroup({
title,
scripts,
directories,
selectedId,
onSelect,
onContextMenu,
readOnly = false,
}: WorkspaceTreeProps) {
const [open, setOpen] = useState(true);
return (
<div className="tree-group">
<button
className={`tree-group__title${open ? " is-open" : ""}`}
type="button"
aria-expanded={open}
onClick={() => setOpen((current) => !current)}
onContextMenu={onContextMenu
? (event) => onContextMenu(event, { kind: "root", path: "" })
: undefined}
>
<Icon name="chevron" size={14} />
<Icon name="folder" size={17} />
<span>{title}</span>
<em>{scripts.length}</em>
</button>
{open && (
<div className="tree-group__items">
<WorkspaceTreeItems
path=""
depth={0}
scripts={scripts}
directories={directories}
selectedId={selectedId}
onSelect={onSelect}
onContextMenu={onContextMenu}
/>
{scripts.length === 0 && directories.length === 0 && (
<p className="tree-group__empty">
{readOnly ? "暂无文件" : "右键此目录即可新建或上传"}
</p>
)}
</div>
)}
</div>
);
}
function WorkspaceTreeItems({
path,
depth,
scripts,
directories,
selectedId,
onSelect,
onContextMenu,
}: WorkspaceTreeItemsProps) {
const childDirectories = directories.filter(
(item) => item.parent_path === path,
);
const childScripts = scripts.filter(
(item) => parentOf(ownedScriptPath(item)) === path,
);
return (
<>
{childDirectories.map((directory) => (
<DirectoryBranch
key={directory.path}
directory={directory}
depth={depth}
scripts={scripts}
directories={directories}
selectedId={selectedId}
onSelect={onSelect}
onContextMenu={onContextMenu}
/>
))}
{childScripts.map((item) => (
<button
className={`script-row${
selectedId === item.script_id ? " script-row--active" : ""
}`}
style={{ paddingLeft: 20 + depth * 16 }}
key={item.script_id}
type="button"
onClick={() => onSelect(item.script_id)}
onContextMenu={onContextMenu
? (event) => onContextMenu(event, {
kind: "file",
path: ownedScriptPath(item),
script: item,
})
: undefined}
>
<span className={`file-icon file-icon--${item.script_type}`}>
<Icon name={scriptIcon(item)} size={17} />
</span>
<span className="script-row__copy">
<strong title={item.script_name}>{item.script_name}</strong>
<small>{formatTime(item.updated_at)}</small>
</span>
{item.visibility !== "private" && (
<span className="visibility-dot" title="Workspace 可见" />
)}
</button>
))}
</>
);
}
function DirectoryBranch({
directory,
depth,
scripts,
directories,
selectedId,
onSelect,
onContextMenu,
}: Omit<WorkspaceTreeItemsProps, "path"> & {
directory: WorkspaceDirectory;
}) {
const [open, setOpen] = useState(true);
return (
<div className="directory-branch">
<button
className="directory-row"
style={{ paddingLeft: 10 + depth * 16 }}
type="button"
onClick={() => setOpen((current) => !current)}
onContextMenu={onContextMenu
? (event) => onContextMenu(event, {
kind: "directory",
path: directory.path,
})
: undefined}
>
<span className={`directory-row__chevron${open ? " is-open" : ""}`}>
<Icon name="chevron" size={13} />
</span>
<Icon name="folder" size={17} />
<strong title={directory.path}>{directory.name}</strong>
</button>
{open && (
<WorkspaceTreeItems
path={directory.path}
depth={depth + 1}
scripts={scripts}
directories={directories}
selectedId={selectedId}
onSelect={onSelect}
onContextMenu={onContextMenu}
/>
)}
</div>
);
}
@@ -11,22 +11,6 @@ import {
import {
ApiRequestError,
createSchedule,
createScheduleEdge,
createScheduleNode,
deleteSchedule,
deleteScheduleEdge,
deleteScheduleNode,
hideScheduleArtifact,
getSchedule,
listScheduleArtifacts,
listScheduleRuns,
listSchedules,
previewCron,
runScheduleNow,
updateSchedule,
updateScheduleNode,
validateSchedule,
type CronPreview,
type Schedule,
type ScheduleArtifact,
@@ -34,6 +18,8 @@ import {
type ScheduleNode,
type ScheduleRunSummary,
} from "../../services/api";
import { useApi } from "~/context/AuthContext";
import Icon from "../../components/Icon";
import "../../styles/schedule.css";
@@ -250,6 +236,7 @@ export default function SchedulePage({
const dragRef = useRef<DragState | null>(null);
const positionDraftsRef = useRef<Record<string, NodePositionDraft>>({});
const [positionDraftCount, setPositionDraftCount] = useState(0);
const api = useApi();
const selectedNode = schedule?.nodes.find(
(item) => item.node_id === selectedNodeId,
@@ -328,7 +315,7 @@ export default function SchedulePage({
): Promise<void> => {
if (showLoading) setRunsLoading(true);
try {
const items = await listScheduleRuns({
const items = await api.listScheduleRuns({
scheduleId,
limit: 20,
});
@@ -349,8 +336,8 @@ export default function SchedulePage({
preferredScheduleId?: string | null,
): Promise<void> => {
const [scheduleItems, artifactItems] = await Promise.all([
listSchedules(),
listScheduleArtifacts(),
api.listSchedules(),
api.listScheduleArtifacts(),
]);
setSchedules(scheduleItems);
setArtifacts(artifactItems);
@@ -362,20 +349,20 @@ export default function SchedulePage({
setSchedule(null);
return;
}
const detail = await getSchedule(targetId);
const detail = await api.getSchedule(targetId);
setSchedule(applyPositionDrafts(detail));
};
useEffect(() => {
let cancelled = false;
setLoading(true);
Promise.all([listSchedules(), listScheduleArtifacts()])
Promise.all([api.listSchedules(), api.listScheduleArtifacts()])
.then(async ([scheduleItems, artifactItems]) => {
if (cancelled) return;
setSchedules(scheduleItems);
setArtifacts(artifactItems);
if (scheduleItems[0]) {
const detail = await getSchedule(scheduleItems[0].schedule_id);
const detail = await api.getSchedule(scheduleItems[0].schedule_id);
if (!cancelled) setSchedule(applyPositionDrafts(detail));
}
onConnectionChange(true);
@@ -404,7 +391,7 @@ export default function SchedulePage({
}
let cancelled = false;
setRunsLoading(true);
listScheduleRuns({ scheduleId, limit: 20 })
api.listScheduleRuns({ scheduleId, limit: 20 })
.then((items) => {
if (!cancelled) setRuns(items);
})
@@ -507,7 +494,7 @@ export default function SchedulePage({
setLinkSourceId(null);
setCronResult(null);
try {
setSchedule(await getSchedule(scheduleId));
setSchedule(await api.getSchedule(scheduleId));
onConnectionChange(true);
} catch (error) {
await handleError(error, "调度详情加载失败");
@@ -529,7 +516,7 @@ export default function SchedulePage({
if (busy || !scheduleName) return;
setBusy("create-schedule");
try {
const created = await createSchedule({
const created = await api.createSchedule({
schedule_name: scheduleName,
description: "在画布中拖入稳定版本并配置执行顺序",
trigger_type: "manual",
@@ -557,7 +544,7 @@ export default function SchedulePage({
if (!window.confirm(`确定删除调度“${selectedSchedule.schedule_name}”吗?`)) return;
setBusy("delete-schedule");
try {
await deleteSchedule(
await api.deleteSchedule(
selectedSchedule.schedule_id,
selectedSchedule.workflow_version,
);
@@ -571,7 +558,7 @@ export default function SchedulePage({
setSelectedNodeId(null);
setSelectedEdgeId(null);
if (remaining[0]) {
setSchedule(await getSchedule(remaining[0].schedule_id));
setSchedule(await api.getSchedule(remaining[0].schedule_id));
}
}
onNotify({ tone: "success", message: "调度方案已删除" });
@@ -589,7 +576,7 @@ export default function SchedulePage({
if (!scheduleName || scheduleName === target.schedule_name) return;
setBusy("rename-schedule");
try {
const updated = await updateSchedule(target.schedule_id, {
const updated = await api.updateSchedule(target.schedule_id, {
workflow_version: target.workflow_version,
schedule_name: scheduleName,
});
@@ -620,7 +607,7 @@ export default function SchedulePage({
) return;
setBusy("delete-artifact");
try {
await hideScheduleArtifact(artifact.versions_id);
await api.hideScheduleArtifact(artifact.versions_id);
setArtifacts((current) => current.filter(
(item) => item.versions_id !== artifact.versions_id,
));
@@ -652,13 +639,13 @@ export default function SchedulePage({
for (const [nodeId, position] of Object.entries(
positionDraftsRef.current,
)) {
updated = await updateScheduleNode(updated.schedule_id, nodeId, {
updated = await api.updateScheduleNode(updated.schedule_id, nodeId, {
workflow_version: updated.workflow_version,
position_x: position.position_x,
position_y: position.position_y,
});
}
updated = await updateSchedule(updated.schedule_id, {
updated = await api.updateSchedule(updated.schedule_id, {
workflow_version: updated.workflow_version,
schedule_name: scheduleForm.scheduleName.trim(),
description: scheduleForm.description.trim() || null,
@@ -694,7 +681,7 @@ export default function SchedulePage({
if (busy) return;
setBusy("cron-preview");
try {
const result = await previewCron({
const result = await api.previewCron({
cron_expression: scheduleForm.cronExpression.trim(),
timezone: scheduleForm.timezone.trim(),
count: 5,
@@ -727,7 +714,7 @@ export default function SchedulePage({
}
setBusy("run-now");
try {
const created = await runScheduleNow(schedule.schedule_id);
const created = await api.runScheduleNow(schedule.schedule_id);
setRuns((current) => [
created,
...current.filter((item) => item.run_id !== created.run_id),
@@ -763,7 +750,7 @@ export default function SchedulePage({
const nodeKey = artifactNodeKey(artifact, schedule);
const updated = await withMutation(
"add-node",
() => createScheduleNode(schedule.schedule_id, {
() => api.createScheduleNode(schedule.schedule_id, {
workflow_version: schedule.workflow_version,
node_key: nodeKey,
node_name: artifact.script_name,
@@ -898,7 +885,7 @@ export default function SchedulePage({
setLinkSourceId(null);
await withMutation(
"create-edge",
() => createScheduleEdge(schedule.schedule_id, {
() => api.createScheduleEdge(schedule.schedule_id, {
workflow_version: schedule.workflow_version,
source_node_id: sourceId,
target_node_id: targetNodeId,
@@ -935,7 +922,7 @@ export default function SchedulePage({
);
await withMutation(
"save-node",
() => updateScheduleNode(schedule.schedule_id, selectedNode.node_id, {
() => api.updateScheduleNode(schedule.schedule_id, selectedNode.node_id, {
workflow_version: schedule.workflow_version,
node_name: nodeForm.nodeName.trim(),
timeout_seconds: timeoutSeconds,
@@ -958,7 +945,7 @@ export default function SchedulePage({
if (!window.confirm(`确定删除节点“${node.node_name}”吗?`)) return;
const updated = await withMutation(
"delete-node",
() => deleteScheduleNode(
() => api.deleteScheduleNode(
schedule.schedule_id,
node.node_id,
schedule.workflow_version,
@@ -974,7 +961,7 @@ export default function SchedulePage({
setContextMenu(null);
const updated = await withMutation(
"delete-edge",
() => deleteScheduleEdge(
() => api.deleteScheduleEdge(
schedule.schedule_id,
edge.edge_id,
schedule.workflow_version,
@@ -988,7 +975,7 @@ export default function SchedulePage({
if (!schedule || busy) return;
setBusy("validate");
try {
const result = await validateSchedule(schedule.schedule_id);
const result = await api.validateSchedule(schedule.schedule_id);
setSchedule((current) => current
? { ...current, dag_validation: result }
: current);
+6 -1
View File
@@ -8,6 +8,7 @@ import {
} from "react-router";
import type { Route } from "./+types/root";
import { AuthProvider } from "~/context/AuthContext";
import "./app.css";
export const links: Route.LinksFunction = () => [];
@@ -31,7 +32,11 @@ export function Layout({ children }: { children: React.ReactNode }) {
}
export default function App() {
return <Outlet />;
return (
<AuthProvider>
<Outlet />
</AuthProvider>
);
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
+1 -6
View File
@@ -1,11 +1,6 @@
import { index, type RouteConfig, route } from "@react-router/dev/routes";
import { type RouteConfig, route } from "@react-router/dev/routes";
export default [
route("login", "routes/login.tsx"),
index("routes/home.tsx"),
route("workbench", "routes/workbench.tsx"),
route("scripts", "routes/scripts.tsx"),
route("schedules", "routes/schedules.tsx"),
route("system", "routes/system.tsx"),
route("*", "routes/platform.tsx"),
] satisfies RouteConfig;
+1 -2
View File
@@ -1,7 +1,6 @@
import type { Route } from "./+types/home";
import { Welcome } from "../welcome/welcome";
export function meta({}: Route.MetaArgs) {
export function meta() {
return [
{ title: "New React Router App" },
{ name: "description", content: "Welcome to React Router!" },
+75
View File
@@ -0,0 +1,75 @@
import { type FormEvent, useState } from "react";
import type { Route } from "./+types/login";
import { useAuth } from "../context/AuthContext";
export function meta({}: Route.MetaArgs) {
return [
{ title: "登录 · 模型实验开发平台" },
{ name: "description", content: "登录模型实验开发平台" },
];
}
export default function LoginRoute() {
const { login } = useAuth();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!username.trim() || !password) {
setError("请输入用户名和密码");
return;
}
setBusy(true);
setError(null);
try {
await login(username.trim(), password);
} catch (cause) {
setError(cause instanceof Error ? cause.message : "登录失败,请稍后重试");
} finally {
setBusy(false);
}
};
return (
<main className="login-page">
<section className="login-card" aria-labelledby="login-title">
<div className="login-brand" aria-hidden="true"></div>
<p className="login-kicker">MODEL EXPERIMENT PLATFORM</p>
<h1 id="login-title"></h1>
<p className="login-description">使 Workspace</p>
<form onSubmit={(event) => void submit(event)}>
<label>
<span></span>
<input
autoComplete="username"
autoFocus
value={username}
onChange={(event) => setUsername(event.target.value)}
placeholder="请输入用户名"
disabled={busy}
/>
</label>
<label>
<span></span>
<input
type="password"
autoComplete="current-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="请输入密码"
disabled={busy}
/>
</label>
{error && <p className="login-error" role="alert">{error}</p>}
<button type="submit" disabled={busy}>
{busy ? "正在登录…" : "登录"}
</button>
</form>
</section>
</main>
);
}
+405 -128
View File
@@ -92,6 +92,19 @@ export function setDemoContext(input: {
}
}
// API client for the platform backend.
//
// All endpoints that take a workspace context require the caller to
// pass `workspaceId` explicitly. Components read the active workspace
// from `useAuth().currentWorkspace` and thread it through; the cookie
// set by `/api/v1/auth/login` is sent automatically thanks to
// `credentials: "same-origin"`, and the backend reads it via the
// shared `request_context` dependency.
//
// 401 from any endpoint means the session has expired or was never
// established; the global `apiRequest` helper bounces the user to
// `/login` so the platform never tries to render with a stale identity.
export type ScriptType = "python" | "notebook";
export type Visibility = "private" | "workspace" | "public";
@@ -162,22 +175,40 @@ export class ApiRequestError extends Error {
}
}
function appendWorkspaceId(path: string, workspaceId: string): string {
// `path` may already contain a query string. Use URLSearchParams to
// merge cleanly either way.
const separator = path.includes("?") ? "&" : "?";
return `${path}${separator}workspace_id=${encodeURIComponent(workspaceId)}`;
}
async function apiRequest<T>(
path: string,
init: RequestInit = {},
workspaceId?: string,
): Promise<T> {
const response = await fetch(path, {
const finalPath = workspaceId ? appendWorkspaceId(path, workspaceId) : path;
const response = await fetch(finalPath, {
...init,
credentials: "same-origin",
headers: {
"X-User-ID": demoContext.userId,
"X-Workspace-ID": demoContext.workspaceId,
"X-Request-ID": createUuid().replaceAll("-", ""),
...(init.body ? { "Content-Type": "application/json" } : {}),
...init.headers,
},
});
// Session expired / never authenticated — bounce to login. The
// /login route itself is the only path that must remain reachable
// while anonymous, so the redirect there is safe.
if (response.status === 401 && typeof window !== "undefined") {
const here = window.location.pathname;
if (here !== "/login") {
window.location.assign("/login");
}
throw new ApiRequestError("未登录或登录已过期", 401);
}
const payload = (await response.json().catch(() => ({}))) as
| ApiEnvelope<T>
| ApiErrorEnvelope;
@@ -201,8 +232,8 @@ async function apiRequest<T>(
return (payload as ApiEnvelope<T>).data;
}
export async function listScripts(): Promise<ScriptItem[]> {
return apiRequest<ScriptItem[]>("/api/v1/scripts");
export async function listScripts(workspaceId: string): Promise<ScriptItem[]> {
return apiRequest<ScriptItem[]>("/api/v1/scripts", {}, workspaceId);
}
function initialContent(scriptType: ScriptType): string {
@@ -258,13 +289,18 @@ function initialContent(scriptType: ScriptType): string {
);
}
export async function createScript(input: {
export async function createScript(
workspaceId: string,
input: {
name: string;
scriptType: ScriptType;
visibility: Visibility;
parentPath?: string | null;
}): Promise<ScriptItem> {
return apiRequest<ScriptItem>("/api/v1/scripts", {
},
): Promise<ScriptItem> {
return apiRequest<ScriptItem>(
"/api/v1/scripts",
{
method: "POST",
body: JSON.stringify({
script_name: input.name.trim(),
@@ -273,10 +309,13 @@ export async function createScript(input: {
content: initialContent(input.scriptType),
parent_path: input.parentPath,
}),
});
},
workspaceId,
);
}
export async function uploadScript(
workspaceId: string,
file: File,
parentPath = "",
visibility: Visibility = "workspace",
@@ -293,38 +332,64 @@ export async function uploadScript(
headers: { "Content-Type": "application/octet-stream" },
body: file,
},
workspaceId,
);
}
export async function updateScript(
workspaceId: string,
scriptId: string,
input: { content: string },
): Promise<ScriptItem> {
return apiRequest<ScriptItem>(
`/api/v1/scripts/${scriptId}`,
{ method: "PUT", body: JSON.stringify(input) },
workspaceId,
);
}
export async function deleteScript(
workspaceId: string,
scriptId: string,
): Promise<{ script_id: string; status: string; versions_preserved: boolean }> {
return apiRequest(`/api/v1/scripts/${scriptId}`, { method: "DELETE" });
return apiRequest(
`/api/v1/scripts/${scriptId}`,
{ method: "DELETE" },
workspaceId,
);
}
export async function listWorkspaceDirectories(): Promise<
WorkspaceDirectory[]
> {
export async function listWorkspaceDirectories(
workspaceId: string,
): Promise<WorkspaceDirectory[]> {
const data = await apiRequest<{ directories: WorkspaceDirectory[] }>(
"/api/v1/workspace-tree",
{},
workspaceId,
);
return data.directories;
}
export async function createWorkspaceDirectory(
workspaceId: string,
directoryName: string,
parentPath = "",
): Promise<WorkspaceDirectory> {
return apiRequest<WorkspaceDirectory>("/api/v1/workspace-directories", {
return apiRequest<WorkspaceDirectory>(
"/api/v1/workspace-directories",
{
method: "POST",
body: JSON.stringify({
directory_name: directoryName,
parent_path: parentPath,
}),
});
},
workspaceId,
);
}
export async function deleteWorkspaceDirectory(
workspaceId: string,
path: string,
): Promise<{
path: string;
@@ -333,9 +398,11 @@ export async function deleteWorkspaceDirectory(
versions_preserved: boolean;
}> {
const parameters = new URLSearchParams({ path });
return apiRequest(`/api/v1/workspace-directories?${parameters.toString()}`, {
method: "DELETE",
});
return apiRequest(
`/api/v1/workspace-directories?${parameters.toString()}`,
{ method: "DELETE" },
workspaceId,
);
}
export type FileLockSession = {
@@ -386,83 +453,115 @@ export type StableVersion = {
created_at: string;
};
// Note: the file-lock and jupyter-ticket endpoints are not yet
// implemented in the backend (see the cookie+JWT auth refactor plan).
// They are retained here so the editor UI keeps its existing call
// sites, but they will return 404 until the backend ships the
// corresponding routes.
export async function acquireFileLock(
workspaceId: string,
script: ScriptItem,
): Promise<ActiveEditSession> {
const now = Date.now();
const session = await apiRequest<FileLockSession>(
`/api/v1/files/${script.current_object_id}/lock`,
{ method: "POST" },
workspaceId,
);
if (!session.lock_token) {
throw new Error("加锁成功响应缺少 lock_token");
}
return {
edit_session_id: createUuid().replaceAll("-", ""),
workspace_id: script.workspace_id,
storage_object_id: script.current_object_id,
user_id: demoContext.userId,
session_status: "active",
lease_seconds: 3600,
heartbeat_interval_seconds: 300,
expires_at: new Date(now + 3600_000).toISOString(),
runtime_id: script.workspace_id,
jupyter_session_id: "demo-session",
relative_path: script.relative_path,
lock_token: "demo-unlocked-session",
...session,
script_id: script.script_id,
script_name: script.script_name,
jupyter_path: script.jupyter_path,
jupyter_path: session.relative_path ?? script.jupyter_path,
lock_token: session.lock_token,
};
}
export async function heartbeatFileLock(
workspaceId: string,
session: ActiveEditSession,
): Promise<FileLockSession> {
return {
...session,
expires_at: new Date(Date.now() + 3600_000).toISOString(),
};
return apiRequest<FileLockSession>(
`/api/v1/file-locks/${session.edit_session_id}/heartbeat`,
{
method: "POST",
body: JSON.stringify({ lock_token: session.lock_token }),
},
workspaceId,
);
}
export async function releaseFileLock(
workspaceId: string,
session: ActiveEditSession,
): Promise<FileLockSession> {
return { ...session, session_status: "closed" };
return apiRequest<FileLockSession>(
`/api/v1/file-locks/${session.edit_session_id}`,
{
method: "DELETE",
body: JSON.stringify({ lock_token: session.lock_token }),
},
workspaceId,
);
}
export function releaseFileLockOnUnload(_session: ActiveEditSession): void {
// The current Backend deliberately has no persisted file-lock API.
export function releaseFileLockOnUnload(
workspaceId: string,
session: ActiveEditSession,
): void {
void fetch(
`/api/v1/file-locks/${session.edit_session_id}?workspace_id=${
encodeURIComponent(workspaceId)
}`,
{
method: "DELETE",
credentials: "same-origin",
keepalive: true,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ lock_token: session.lock_token }),
},
);
}
export async function createJupyterAccessTicket(
workspaceId: string,
session: ActiveEditSession,
): Promise<JupyterAccessTicket> {
const result = await apiRequest<{ expires_at: number }>(
"/api/v1/auth/demo-session",
return apiRequest<JupyterAccessTicket>(
"/api/v1/jupyter/access-tickets",
{
method: "POST",
},
);
const editorRoute = session.script_name.toLowerCase().endsWith(".ipynb")
? "notebooks"
: "edit";
const encodedPath = session.jupyter_path
.split("/")
.filter(Boolean)
.map(encodeURIComponent)
.join("/");
return {
body: JSON.stringify({
edit_session_id: session.edit_session_id,
jupyter_url: `/jupyter/${encodeURIComponent(session.workspace_id)}/${editorRoute}/${encodedPath}`,
expires_at: new Date(result.expires_at * 1000).toISOString(),
};
lock_token: session.lock_token,
}),
},
workspaceId,
);
}
export async function listScriptVersions(
workspaceId: string,
scriptId: string,
): Promise<StableVersion[]> {
return apiRequest<StableVersion[]>(`/api/v1/scripts/${scriptId}/versions`);
return apiRequest<StableVersion[]>(
`/api/v1/scripts/${scriptId}/versions`,
{},
workspaceId,
);
}
export async function publishScriptVersion(input: {
export async function publishScriptVersion(
workspaceId: string,
input: {
script: ScriptItem;
releaseNote: string;
visibility: Visibility;
}): Promise<StableVersion> {
},
): Promise<StableVersion> {
return apiRequest<StableVersion>(
`/api/v1/scripts/${input.script.script_id}/versions`,
{
@@ -473,6 +572,7 @@ export async function publishScriptVersion(input: {
visibility: input.visibility,
}),
},
workspaceId,
);
}
@@ -625,15 +725,24 @@ export type ScheduleRunDetail = ScheduleRunSummary & {
node_runs: ScheduleNodeRun[];
};
export async function listSchedules(): Promise<Schedule[]> {
return apiRequest<Schedule[]>("/api/v1/schedules");
export async function listSchedules(workspaceId: string): Promise<Schedule[]> {
return apiRequest<Schedule[]>("/api/v1/schedules", {}, workspaceId);
}
export async function getSchedule(scheduleId: string): Promise<Schedule> {
return apiRequest<Schedule>(`/api/v1/schedules/${scheduleId}`);
export async function getSchedule(
workspaceId: string,
scheduleId: string,
): Promise<Schedule> {
return apiRequest<Schedule>(
`/api/v1/schedules/${scheduleId}`,
{},
workspaceId,
);
}
export async function createSchedule(input: {
export async function createSchedule(
workspaceId: string,
input: {
schedule_name: string;
description?: string | null;
trigger_type?: "manual" | "cron" | "api";
@@ -642,14 +751,17 @@ export async function createSchedule(input: {
enabled?: boolean;
max_concurrency?: number;
failure_policy?: "stop" | "continue";
}): Promise<Schedule> {
return apiRequest<Schedule>("/api/v1/schedules", {
method: "POST",
body: JSON.stringify(input),
});
},
): Promise<Schedule> {
return apiRequest<Schedule>(
"/api/v1/schedules",
{ method: "POST", body: JSON.stringify(input) },
workspaceId,
);
}
export async function updateSchedule(
workspaceId: string,
scheduleId: string,
input: {
workflow_version: number;
@@ -663,55 +775,72 @@ export async function updateSchedule(
failure_policy?: "stop" | "continue";
},
): Promise<Schedule> {
return apiRequest<Schedule>(`/api/v1/schedules/${scheduleId}`, {
method: "PATCH",
body: JSON.stringify(input),
});
return apiRequest<Schedule>(
`/api/v1/schedules/${scheduleId}`,
{ method: "PATCH", body: JSON.stringify(input) },
workspaceId,
);
}
export async function deleteSchedule(
workspaceId: string,
scheduleId: string,
workflowVersion: number,
): Promise<{ schedule_id: string; deleted: boolean; workflow_version: number }> {
return apiRequest(`/api/v1/schedules/${scheduleId}`, {
method: "DELETE",
body: JSON.stringify({ workflow_version: workflowVersion }),
});
return apiRequest(
`/api/v1/schedules/${scheduleId}`,
{ method: "DELETE", body: JSON.stringify({ workflow_version: workflowVersion }) },
workspaceId,
);
}
export async function listScheduleArtifacts(): Promise<ScheduleArtifact[]> {
return apiRequest<ScheduleArtifact[]>("/api/v1/schedule-artifacts");
export async function listScheduleArtifacts(
workspaceId: string,
): Promise<ScheduleArtifact[]> {
return apiRequest<ScheduleArtifact[]>(
"/api/v1/schedule-artifacts",
{},
workspaceId,
);
}
export async function hideScheduleArtifact(
workspaceId: string,
versionsId: string,
): Promise<{
versions_id: string;
deleted: boolean;
artifact_preserved: boolean;
}> {
return apiRequest(`/api/v1/versions/${versionsId}`, {
method: "DELETE",
});
return apiRequest(
`/api/v1/versions/${versionsId}`,
{ method: "DELETE" },
workspaceId,
);
}
export async function listEmployees(): Promise<Employee[]> {
return apiRequest<Employee[]>("/api/v1/admin/employees");
export async function listEmployees(workspaceId: string): Promise<Employee[]> {
return apiRequest<Employee[]>("/api/v1/admin/employees", {}, workspaceId);
}
export async function createEmployee(input: {
export async function createEmployee(
workspaceId: string,
input: {
username: string;
display_name: string;
email?: string | null;
role_code: "admin" | "developer";
}): Promise<Employee> {
return apiRequest<Employee>("/api/v1/admin/employees", {
method: "POST",
body: JSON.stringify(input),
});
},
): Promise<Employee> {
return apiRequest<Employee>(
"/api/v1/admin/employees",
{ method: "POST", body: JSON.stringify(input) },
workspaceId,
);
}
export async function updateEmployee(
workspaceId: string,
userId: string,
input: {
display_name?: string;
@@ -720,21 +849,26 @@ export async function updateEmployee(
status?: "active" | "disabled" | "locked";
},
): Promise<Employee> {
return apiRequest<Employee>(`/api/v1/admin/employees/${userId}`, {
method: "PATCH",
body: JSON.stringify(input),
});
return apiRequest<Employee>(
`/api/v1/admin/employees/${userId}`,
{ method: "PATCH", body: JSON.stringify(input) },
workspaceId,
);
}
export async function deleteEmployee(
workspaceId: string,
userId: string,
): Promise<{ user_id: string; deleted: boolean }> {
return apiRequest(`/api/v1/admin/employees/${userId}`, {
method: "DELETE",
});
return apiRequest(
`/api/v1/admin/employees/${userId}`,
{ method: "DELETE" },
workspaceId,
);
}
export async function createScheduleNode(
workspaceId: string,
scheduleId: string,
input: {
workflow_version: number;
@@ -750,13 +884,15 @@ export async function createScheduleNode(
env_refs_json?: Record<string, string>;
},
): Promise<Schedule> {
return apiRequest<Schedule>(`/api/v1/schedules/${scheduleId}/nodes`, {
method: "POST",
body: JSON.stringify(input),
});
return apiRequest<Schedule>(
`/api/v1/schedules/${scheduleId}/nodes`,
{ method: "POST", body: JSON.stringify(input) },
workspaceId,
);
}
export async function updateScheduleNode(
workspaceId: string,
scheduleId: string,
nodeId: string,
input: {
@@ -774,28 +910,26 @@ export async function updateScheduleNode(
): Promise<Schedule> {
return apiRequest<Schedule>(
`/api/v1/schedules/${scheduleId}/nodes/${nodeId}`,
{
method: "PUT",
body: JSON.stringify(input),
},
{ method: "PUT", body: JSON.stringify(input) },
workspaceId,
);
}
export async function deleteScheduleNode(
workspaceId: string,
scheduleId: string,
nodeId: string,
workflowVersion: number,
): Promise<Schedule> {
return apiRequest<Schedule>(
`/api/v1/schedules/${scheduleId}/nodes/${nodeId}`,
{
method: "DELETE",
body: JSON.stringify({ workflow_version: workflowVersion }),
},
{ method: "DELETE", body: JSON.stringify({ workflow_version: workflowVersion }) },
workspaceId,
);
}
export async function createScheduleEdge(
workspaceId: string,
scheduleId: string,
input: {
workflow_version: number;
@@ -804,50 +938,58 @@ export async function createScheduleEdge(
condition_expr?: string | null;
},
): Promise<Schedule> {
return apiRequest<Schedule>(`/api/v1/schedules/${scheduleId}/edges`, {
method: "POST",
body: JSON.stringify(input),
});
return apiRequest<Schedule>(
`/api/v1/schedules/${scheduleId}/edges`,
{ method: "POST", body: JSON.stringify(input) },
workspaceId,
);
}
export async function deleteScheduleEdge(
workspaceId: string,
scheduleId: string,
edgeId: string,
workflowVersion: number,
): Promise<Schedule> {
return apiRequest<Schedule>(
`/api/v1/schedules/${scheduleId}/edges/${edgeId}`,
{
method: "DELETE",
body: JSON.stringify({ workflow_version: workflowVersion }),
},
{ method: "DELETE", body: JSON.stringify({ workflow_version: workflowVersion }) },
workspaceId,
);
}
export async function validateSchedule(
workspaceId: string,
scheduleId: string,
): Promise<DagValidation & {
schedule_id: string;
workflow_version: number;
}> {
return apiRequest(`/api/v1/schedules/${scheduleId}/validate`, {
method: "POST",
});
return apiRequest(
`/api/v1/schedules/${scheduleId}/validate`,
{ method: "POST" },
workspaceId,
);
}
export async function previewCron(input: {
export async function previewCron(
workspaceId: string,
input: {
cron_expression: string;
timezone: string;
count?: number;
base_time?: string;
}): Promise<CronPreview> {
return apiRequest<CronPreview>("/api/v1/cron/preview", {
method: "POST",
body: JSON.stringify(input),
});
},
): Promise<CronPreview> {
return apiRequest<CronPreview>(
"/api/v1/cron/preview",
{ method: "POST", body: JSON.stringify(input) },
workspaceId,
);
}
export async function runScheduleNow(
workspaceId: string,
scheduleId: string,
): Promise<ScheduleRunDetail> {
return apiRequest<ScheduleRunDetail>(
@@ -859,25 +1001,160 @@ export async function runScheduleNow(
},
body: JSON.stringify({ reason: "manual_run" }),
},
workspaceId,
);
}
export async function listScheduleRuns(input: {
export async function listScheduleRuns(
workspaceId: string,
input: {
scheduleId?: string;
status?: ScheduleRunStatus;
limit?: number;
} = {}): Promise<ScheduleRunSummary[]> {
} = {},
): Promise<ScheduleRunSummary[]> {
const query = new URLSearchParams();
if (input.scheduleId) query.set("schedule_id", input.scheduleId);
if (input.status) query.set("status", input.status);
query.set("limit", String(input.limit ?? 20));
return apiRequest<ScheduleRunSummary[]>(
`/api/v1/schedule-runs?${query.toString()}`,
{},
workspaceId,
);
}
export async function getScheduleRun(
workspaceId: string,
runId: string,
): Promise<ScheduleRunDetail> {
return apiRequest<ScheduleRunDetail>(`/api/v1/schedule-runs/${runId}`);
return apiRequest<ScheduleRunDetail>(
`/api/v1/schedule-runs/${runId}`,
{},
workspaceId,
);
}
// ----------------------------------------------------------------------------
// Workspace-bound API surface.
//
// `useApi()` in ~/context/AuthContext returns an object where every
// function has had its first `workspaceId` argument pre-filled. The
// type below lets consumers import the bound type without depending
// on the raw functions. Keep this last in the file so the type
// references all the exports above.
// ----------------------------------------------------------------------------
export type WorkspaceBoundApi = {
listScripts: () => Promise<ScriptItem[]>;
createScript: (
input: Parameters<typeof createScript>[1],
) => Promise<ScriptItem>;
uploadScript: (
file: File,
parentPath?: string,
visibility?: Visibility,
) => Promise<ScriptItem>;
updateScript: (
scriptId: string,
input: Parameters<typeof updateScript>[2],
) => Promise<ScriptItem>;
deleteScript: (
scriptId: string,
) => Promise<{ script_id: string; status: string; versions_preserved: boolean }>;
listWorkspaceDirectories: () => Promise<WorkspaceDirectory[]>;
createWorkspaceDirectory: (
directoryName: string,
parentPath?: string,
) => Promise<WorkspaceDirectory>;
deleteWorkspaceDirectory: (
path: string,
) => Promise<{
path: string;
status: string;
deleted_scripts: number;
versions_preserved: boolean;
}>;
acquireFileLock: (
script: ScriptItem,
) => Promise<ActiveEditSession>;
heartbeatFileLock: (
session: ActiveEditSession,
) => Promise<FileLockSession>;
releaseFileLock: (
session: ActiveEditSession,
) => Promise<FileLockSession>;
releaseFileLockOnUnload: (session: ActiveEditSession) => void;
createJupyterAccessTicket: (
session: ActiveEditSession,
) => Promise<JupyterAccessTicket>;
listScriptVersions: (scriptId: string) => Promise<StableVersion[]>;
publishScriptVersion: (
input: Parameters<typeof publishScriptVersion>[1],
) => Promise<StableVersion>;
listSchedules: () => Promise<Schedule[]>;
getSchedule: (scheduleId: string) => Promise<Schedule>;
createSchedule: (
input: Parameters<typeof createSchedule>[1],
) => Promise<Schedule>;
updateSchedule: (
scheduleId: string,
input: Parameters<typeof updateSchedule>[2],
) => Promise<Schedule>;
deleteSchedule: (
scheduleId: string,
workflowVersion: number,
) => Promise<{ schedule_id: string; deleted: boolean; workflow_version: number }>;
listScheduleArtifacts: () => Promise<ScheduleArtifact[]>;
hideScheduleArtifact: (
versionsId: string,
) => Promise<{
versions_id: string;
deleted: boolean;
artifact_preserved: boolean;
}>;
listEmployees: () => Promise<Employee[]>;
createEmployee: (
input: Parameters<typeof createEmployee>[1],
) => Promise<Employee>;
updateEmployee: (
userId: string,
input: Parameters<typeof updateEmployee>[2],
) => Promise<Employee>;
deleteEmployee: (
userId: string,
) => Promise<{ user_id: string; deleted: boolean }>;
createScheduleNode: (
scheduleId: string,
input: Parameters<typeof createScheduleNode>[2],
) => Promise<Schedule>;
updateScheduleNode: (
scheduleId: string,
nodeId: string,
input: Parameters<typeof updateScheduleNode>[3],
) => Promise<Schedule>;
deleteScheduleNode: (
scheduleId: string,
nodeId: string,
workflowVersion: number,
) => Promise<Schedule>;
createScheduleEdge: (
scheduleId: string,
input: Parameters<typeof createScheduleEdge>[2],
) => Promise<Schedule>;
deleteScheduleEdge: (
scheduleId: string,
edgeId: string,
workflowVersion: number,
) => Promise<Schedule>;
validateSchedule: (
scheduleId: string,
) => Promise<DagValidation & { schedule_id: string; workflow_version: number }>;
previewCron: (
input: Parameters<typeof previewCron>[1],
) => Promise<CronPreview>;
runScheduleNow: (scheduleId: string) => Promise<ScheduleRunDetail>;
listScheduleRuns: (
input?: Parameters<typeof listScheduleRuns>[1],
) => Promise<ScheduleRunSummary[]>;
getScheduleRun: (runId: string) => Promise<ScheduleRunDetail>;
};
@@ -0,0 +1,57 @@
"""enable password login for the seeded development users
Revision ID: 9a1b2c3d4e5f
Revises: b71c4f2a9d10
Create Date: 2026-08-03 16:00:00
"""
from collections.abc import Sequence
import os
from alembic import op
import sqlalchemy as sa
from common.auth.passwords import hash_password
revision: str = "9a1b2c3d4e5f"
down_revision: str | Sequence[str] | None = "b71c4f2a9d10"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
SEEDED_USER_IDS = (
"0000000000RF6FG1SDBXG59S13",
"0000000000H2QYCGPCWQM1JSGS",
"0000000000RWG40ESZPGJT629J",
"00000000004CQV7WASJA6N6FW4",
)
DISABLED_PASSWORD = "demo-login-disabled"
def upgrade() -> None:
password = os.environ.get("INITIAL_ADMIN_PASSWORD", "admin12345")
users = sa.table(
"users",
sa.column("user_id", sa.String),
sa.column("password_hash", sa.String),
)
op.execute(
users.update()
.where(users.c.user_id.in_(SEEDED_USER_IDS))
.where(users.c.password_hash == DISABLED_PASSWORD)
.values(password_hash=hash_password(password))
)
def downgrade() -> None:
users = sa.table(
"users",
sa.column("user_id", sa.String),
sa.column("password_hash", sa.String),
)
op.execute(
users.update()
.where(users.c.user_id.in_(SEEDED_USER_IDS))
.values(password_hash=DISABLED_PASSWORD)
)
@@ -0,0 +1,33 @@
"""add the RustFS trash object key
Revision ID: a2b3c4d5e6f7
Revises: 9a1b2c3d4e5f
Create Date: 2026-08-03 16:30:00
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
revision: str = "a2b3c4d5e6f7"
down_revision: str | Sequence[str] | None = "9a1b2c3d4e5f"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"storage_objects",
sa.Column(
"trash_key",
sa.String(length=1100),
nullable=True,
comment="Path inside the trash bucket where soft-deleted bytes are stored",
),
)
def downgrade() -> None:
op.drop_column("storage_objects", "trash_key")
+4 -4
View File
@@ -10,10 +10,10 @@ COPY --from=rclone/rclone:latest /usr/local/bin/rclone /usr/local/bin/rclone
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
RUN ( \
sed -i 's/deb.debian.org/mirrors.cloud.aliyuncs.com/g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
sed -i 's/deb.debian.org/mirrors.cloud.aliyuncs.com/g' /etc/apt/sources.list 2>/dev/null || \
sed -i 's/archive.ubuntu.com/mirrors.cloud.aliyuncs.com/g' /etc/apt/sources.list 2>/dev/null || \
sed -i 's/security.ubuntu.com/mirrors.cloud.aliyuncs.com/g' /etc/apt/sources.list 2>/dev/null \
sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list 2>/dev/null || \
sed -i 's/archive.ubuntu.com/mirrors.aliyun.com/g' /etc/apt/sources.list 2>/dev/null || \
sed -i 's/security.ubuntu.com/mirrors.aliyun.com/g' /etc/apt/sources.list 2>/dev/null \
) || ( \
sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list 2>/dev/null || \
+17
View File
@@ -19,8 +19,11 @@ from runtime.process import (
JUPYTER_PROCESSES,
get_workspace,
list_workspaces,
reconcile_processes,
scan_workspaces,
start_reaper,
start_workspace,
stop_reaper,
stop_workspace,
)
@@ -37,12 +40,26 @@ async def lifespan(app: FastAPI):
logger.info("Starting up Runtime Service...")
start_rclone_mount()
logger.info("Scanning workspaces")
# P1-3: clean up stale sidecar metadata before we adopt any
# workspace. Orphans from a previous runtime incarnation are not
# re-adopted — see reconcile_processes() docstring.
counters = reconcile_processes()
logger.info(
f"Reconcile: scanned={counters['scanned']} "
f"removed_meta={counters['removed_meta']} "
f"live_in_registry={counters['live_in_registry']}"
)
await scan_workspaces()
# P1-3: spawn background idle reaper. Stopped in the lifespan
# finally block; awaits the cancellation to avoid a leaked task.
start_reaper()
logger.info("Runtime Service started")
try:
yield
finally:
logger.info("Service is shutting down. Stopping reaper...")
await stop_reaper()
logger.info(
"Service is shutting down. Terminating all active Jupyter sub-processes..."
)
+259 -4
View File
@@ -4,11 +4,37 @@ Owns the in-memory ``JUPYTER_PROCESSES`` dict, the ``STATE_LOCK`` that
serializes mutations to it, and the per-workspace start/stop/list/get
operations. Workspace discovery (startup scan) lives here because it is
a thin wrapper over ``start_workspace``.
Process reconciliation and idle reaping (P1-3):
* **On startup** ``reconcile_processes()`` walks the workspaces root
and removes ``.runtime-meta.json`` files whose owning Jupyter
process is no longer tracked. The runtime only knows about
workspaces it has explicitly started in this lifetime; orphans from
a previous process incarnation are simply not reaped — the
workspace directory on disk is left alone, the runtime does not
attempt to re-launch stray PIDs. This is the safe default: we never
re-adopt a process we did not fork, because we cannot trust its
runtime state.
* **Idle reaping** runs in a background task started by
``runtime.main.lifespan``. Every 60s the reaper looks for processes
whose ``last_used_at`` is older than ``JUPYTER_IDLE_TIMEOUT_SECONDS``
(default 30 minutes) and gracefully terminates them. ``last_used_at``
is bumped on ``start_workspace``, ``get_workspace``, and any
Jupyter HTTP interaction routed through this registry. Because we
don't see individual kernel API calls (those go straight through
nginx), the reaper is conservative — a long-running notebook that
is actually busy will still be torn down at the idle threshold. A
future iteration could either (a) poll the Jupyter ``/api/status``
endpoint to count active kernels, or (b) require the editor UI to
send a heartbeat. Both are out of scope here.
"""
from __future__ import annotations
import asyncio
import json
import os
import secrets
import subprocess
@@ -25,6 +51,27 @@ from runtime.mount import WORKSPACES_ROOT
PUBLIC_BASE_URL = settings.public_base_url
# P1-3: idle reaping tuning knobs. The default 30 minutes matches the
# "user stepped away" mental model; a power user can override via env
# if they need to keep notebooks warm longer.
JUPYTER_IDLE_TIMEOUT_SECONDS = int(
os.environ.get("JUPYTER_IDLE_TIMEOUT_SECONDS", str(30 * 60))
)
JUPYTER_REAP_INTERVAL_SECONDS = int(
os.environ.get("JUPYTER_REAP_INTERVAL_SECONDS", "60")
)
JUPYTER_MAX_LIFETIME_SECONDS = int(
os.environ.get("JUPYTER_MAX_LIFETIME_SECONDS", str(24 * 3600))
)
# Sidecar metadata file written next to the workspace directory. Holds
# the runtime state we need to reconstruct the in-memory map after a
# crash. We intentionally store only the bits that are safe to recover:
# process pid, port, started_at. ``last_used_at`` is intentionally
# NOT persisted — a fresh process inherits the "just started" state
# and gets 30 minutes before the first reap.
RUNTIME_META_FILENAME = ".runtime-meta.json"
class JupyterProcessRecord(TypedDict):
process: subprocess.Popen
@@ -32,11 +79,14 @@ class JupyterProcessRecord(TypedDict):
token: str
base_url: str
started_at: float
last_used_at: float
meta_path: str
JUPYTER_PROCESSES: dict[str, JupyterProcessRecord] = {}
WORKSPACE_LOCKS: dict[str, asyncio.Lock] = {}
_LOCKS_REGISTRY = asyncio.Lock()
_REAPER_TASK: asyncio.Task[None] | None = None
def get_workspace_lock(ws_id: str) -> asyncio.Lock:
@@ -69,18 +119,60 @@ def _drop_workspace_lock(ws_id: str) -> None:
WORKSPACE_LOCKS.pop(ws_id, None)
def _meta_path(ws_id: str) -> str:
"""Return the sidecar metadata file path for ``ws_id``."""
return str(WORKSPACES_ROOT / ws_id / RUNTIME_META_FILENAME)
def _write_meta(ws_id: str, record: JupyterProcessRecord) -> None:
"""Persist the bits of the record we can safely recover.
Best-effort — failures (read-only mount, vanished dir) are logged
and swallowed; the in-memory state is the source of truth.
"""
payload = {
"workspace_id": ws_id,
"pid": record["process"].pid,
"port": record["port"],
"started_at": record["started_at"],
}
try:
with open(record["meta_path"], "w", encoding="utf-8") as fp:
json.dump(payload, fp)
except Exception as exc: # pragma: no cover - defensive
logger.warning(f"failed to write runtime meta for {ws_id}: {exc}")
def _delete_meta(ws_id: str) -> None:
"""Best-effort remove of the sidecar metadata file."""
try:
os.unlink(_meta_path(ws_id))
except FileNotFoundError:
pass
except Exception as exc: # pragma: no cover - defensive
logger.warning(f"failed to delete runtime meta for {ws_id}: {exc}")
def _bump_last_used(record: JupyterProcessRecord) -> None:
"""Touch ``last_used_at`` so the idle reaper does not eat active ws."""
record["last_used_at"] = time.time()
async def start_workspace(ws_id: str) -> dict:
async with get_workspace_lock(ws_id):
workspace_path = WORKSPACES_ROOT / ws_id
workspace_path.mkdir(parents=True, exist_ok=True)
if ws_id in JUPYTER_PROCESSES:
p_info = JUPYTER_PROCESSES[ws_id]
if p_info["process"].poll() is None:
if time.time() - p_info["started_at"] > 24 * 3600:
if time.time() - p_info["started_at"] > JUPYTER_MAX_LIFETIME_SECONDS:
logger.warning(
f"Reusing Jupyter for {ws_id} older than 24h "
f"Reusing Jupyter for {ws_id} older than "
f"{JUPYTER_MAX_LIFETIME_SECONDS}s "
f"(started_at={p_info['started_at']})"
)
_bump_last_used(p_info)
return {
"status": "running",
"workspace_id": ws_id,
@@ -93,6 +185,9 @@ async def start_workspace(ws_id: str) -> dict:
f"?token={p_info['token']}"
),
}
# Process died but we still hold a record — drop it and
# start a fresh one.
_delete_meta(ws_id)
del JUPYTER_PROCESSES[ws_id]
port = get_free_port()
@@ -125,14 +220,18 @@ async def start_workspace(ws_id: str) -> dict:
)
full_url = f"{PUBLIC_BASE_URL}:{port}{base_path}?token={token}"
meta_path = _meta_path(ws_id)
now = time.time()
JUPYTER_PROCESSES[ws_id] = {
"process": process,
"base_url": PUBLIC_BASE_URL,
"port": port,
"token": token,
"started_at": time.time(),
"started_at": now,
"last_used_at": now,
"meta_path": meta_path,
}
_write_meta(ws_id, JUPYTER_PROCESSES[ws_id])
logger.info(
f"Started Jupyter for workspace {ws_id} "
@@ -176,6 +275,7 @@ async def stop_workspace(ws_id: str) -> dict:
logger.error(f"Failed to kill Jupyter process for {ws_id}: {err}")
del JUPYTER_PROCESSES[ws_id]
_delete_meta(ws_id)
_drop_workspace_lock(ws_id)
return {
@@ -198,6 +298,8 @@ async def list_workspaces() -> dict:
f"?token={info['token']}"
),
"is_alive": info["process"].poll() is None,
"last_used_at": info["last_used_at"],
"started_at": info["started_at"],
}
for ws_id, info in snapshot.items()
},
@@ -217,7 +319,9 @@ async def get_workspace(ws_id: str) -> dict:
if not is_alive:
del JUPYTER_PROCESSES[ws_id]
_delete_meta(ws_id)
else:
_bump_last_used(p_info)
return {
"status": "running",
"pid": p_info["process"].pid,
@@ -230,6 +334,7 @@ async def get_workspace(ws_id: str) -> dict:
f"?token={p_info['token']}"
),
"started_at": p_info["started_at"],
"last_used_at": p_info["last_used_at"],
}
_drop_workspace_lock(ws_id)
@@ -242,6 +347,152 @@ async def get_workspace(ws_id: str) -> dict:
)
def reconcile_processes() -> dict[str, int]:
"""P1-3 startup sweep.
Walks the workspaces root and inspects ``.runtime-meta.json``
sidecars. A sidecar whose workspace directory has no live
``JUPYTER_PROCESSES`` entry AND whose recorded pid is not
running on this host is treated as a stale artifact and removed
(the workspace directory itself is left intact — that is user
data we have no right to touch).
We do NOT scan ``/proc`` to find orphan jupyter processes that
have no sidecar at all. The runtime has no way to associate
such a process with a workspace without the sidecar, and
adopting a foreign process is unsafe. Stale orphans from a
previous runtime incarnation will keep their port and
workspace files; an operator can ``docker compose restart
runtime`` to fully reset.
Returns a small counter dict so the caller can log it.
"""
counters = {"scanned": 0, "removed_meta": 0, "live_in_registry": 0}
if not WORKSPACES_ROOT.exists():
return counters
try:
entries = os.listdir(WORKSPACES_ROOT)
except Exception as exc:
logger.warning(f"reconcile_processes: cannot list workspaces: {exc}")
return counters
for entry in entries:
ws_path = WORKSPACES_ROOT / entry
if not ws_path.is_dir():
continue
meta_file = ws_path / RUNTIME_META_FILENAME
if not meta_file.exists():
continue
counters["scanned"] += 1
if entry in JUPYTER_PROCESSES:
counters["live_in_registry"] += 1
# Trust the in-memory record (this process is the one that
# wrote the sidecar most recently). Refresh it.
_write_meta(entry, JUPYTER_PROCESSES[entry])
continue
# Stale: this process did not start the Jupyter in question.
# Verify the pid is dead; if it is, drop the sidecar so the
# next scan is clean. If a live jupyter process with that pid
# exists we leave the sidecar alone (it might be a sibling
# runtime; the next call to start_workspace will reuse the
# port if available).
try:
with open(meta_file, encoding="utf-8") as fp:
meta = json.load(fp)
pid = int(meta.get("pid", 0))
except Exception as exc:
logger.info(f"reconcile: removing malformed meta for {entry}: {exc}")
_delete_meta(entry)
counters["removed_meta"] += 1
continue
if pid <= 0:
_delete_meta(entry)
counters["removed_meta"] += 1
continue
try:
os.kill(pid, 0)
alive = True
except ProcessLookupError:
alive = False
except PermissionError:
alive = True # someone else's process, leave alone
if not alive:
logger.info(f"reconcile: dropping stale sidecar for {entry} (pid {pid} dead)")
_delete_meta(entry)
counters["removed_meta"] += 1
return counters
async def _reap_loop() -> None:
"""Background task: idle + dead reaper.
Runs every ``JUPYTER_REAP_INTERVAL_SECONDS``; stops on
:class:`asyncio.CancelledError`. Decisions:
* process is dead (poll() != None) → drop the record + meta
* process is alive but ``last_used_at`` is older than the
idle timeout → graceful stop
* process is alive but older than the max lifetime → graceful
stop (defence in depth: prevents a workspace from holding a
port forever)
"""
while True:
try:
await asyncio.sleep(JUPYTER_REAP_INTERVAL_SECONDS)
now = time.time()
victims: list[str] = []
for ws_id, info in list(JUPYTER_PROCESSES.items()):
if info["process"].poll() is not None:
logger.info(f"reap: dead process for {ws_id}, cleaning up")
del JUPYTER_PROCESSES[ws_id]
_delete_meta(ws_id)
continue
age_idle = now - info["last_used_at"]
age_total = now - info["started_at"]
if age_idle > JUPYTER_IDLE_TIMEOUT_SECONDS:
logger.info(
f"reap: idle Jupyter for {ws_id} "
f"(idle={age_idle:.0f}s > {JUPYTER_IDLE_TIMEOUT_SECONDS}s)"
)
victims.append(ws_id)
elif age_total > JUPYTER_MAX_LIFETIME_SECONDS:
logger.info(
f"reap: max-lifetime Jupyter for {ws_id} "
f"(age={age_total:.0f}s > {JUPYTER_MAX_LIFETIME_SECONDS}s)"
)
victims.append(ws_id)
for ws_id in victims:
try:
await stop_workspace(ws_id)
except Exception as exc:
logger.error(f"reap: failed to stop {ws_id}: {exc}")
except asyncio.CancelledError:
raise
except Exception as exc:
logger.exception(f"reap loop failed: {exc}")
def start_reaper() -> asyncio.Task[None]:
"""Spawn the background idle reaper. Idempotent."""
global _REAPER_TASK
if _REAPER_TASK is not None and not _REAPER_TASK.done():
return _REAPER_TASK
_REAPER_TASK = asyncio.create_task(_reap_loop(), name="jupyter-idle-reaper")
return _REAPER_TASK
async def stop_reaper() -> None:
"""Cancel the reaper and wait for it to finish (called from lifespan)."""
global _REAPER_TASK
task = _REAPER_TASK
_REAPER_TASK = None
if task is None or task.done():
return
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
async def scan_workspaces() -> None:
if not WORKSPACES_ROOT.exists():
return
@@ -257,6 +508,10 @@ async def scan_workspaces() -> None:
path = WORKSPACES_ROOT / entry
if not path.is_dir():
return
# Skip workspace dirs that already have a live Jupyter tracked
# in this process — saves a port allocation and a noop start.
if entry in JUPYTER_PROCESSES:
return
logger.info(f"Found workspace: {entry}")
logger.info(f"Auto-starting Jupyter for workspace ID: '{entry}'")
async with sem:
+4 -4
View File
@@ -7,10 +7,10 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
WORKDIR /app
RUN ( \
sed -i 's/deb.debian.org/mirrors.cloud.aliyuncs.com/g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
sed -i 's/deb.debian.org/mirrors.cloud.aliyuncs.com/g' /etc/apt/sources.list 2>/dev/null || \
sed -i 's/archive.ubuntu.com/mirrors.cloud.aliyuncs.com/g' /etc/apt/sources.list 2>/dev/null || \
sed -i 's/security.ubuntu.com/mirrors.cloud.aliyuncs.com/g' /etc/apt/sources.list 2>/dev/null \
sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list 2>/dev/null || \
sed -i 's/archive.ubuntu.com/mirrors.aliyun.com/g' /etc/apt/sources.list 2>/dev/null || \
sed -i 's/security.ubuntu.com/mirrors.aliyun.com/g' /etc/apt/sources.list 2>/dev/null \
) || ( \
sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list 2>/dev/null || \
+4 -4
View File
@@ -18,12 +18,12 @@ from schedule.storage_client import SchedulerStorageClient
async def lifespan(app: Any) -> AsyncIterator[None]:
engine = create_database_engine(settings.database_url)
session_factory = create_session_factory(engine)
backend_http_client = build_storage_http_client()
storage_http_client = build_storage_http_client()
service = SchedulerService(
session_factory=session_factory,
backend_http_client=backend_http_client,
storage_http_client=storage_http_client,
object_store=build_object_store(),
storage_client=SchedulerStorageClient(backend_http_client),
storage_client=SchedulerStorageClient(storage_http_client),
database_url=settings.database_url,
)
app.state.scheduler_service = service
@@ -32,7 +32,7 @@ async def lifespan(app: Any) -> AsyncIterator[None]:
yield
finally:
await service.close()
await backend_http_client.aclose()
await storage_http_client.aclose()
await engine.dispose()
+107 -32
View File
@@ -47,20 +47,30 @@ class DispatchOrchestrator:
- ``_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, sets a
far-future ``available_at`` as a lease, then dispatches each as
- ``_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. The dispatcher sets ``available_at = utcnow() + node_timeout
+ LEASE_SLACK`` when it writes the ``job.node.execute`` event, so a
process crash mid-execution lets the row re-eligible automatically
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.
"""
# Lease window for a claimed ``job.node.execute`` event. If the
# process dies mid-execution, the row re-eligible after this many
# minutes. The handler is idempotent (it short-circuits on terminal
# node states), so safe re-execution.
EXECUTION_LEASE = timedelta(minutes=30)
# 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)
def __init__(
self,
@@ -140,11 +150,13 @@ class DispatchOrchestrator:
) -> int:
"""Claim ``job.node.execute`` rows and dispatch them as tasks.
The claim step bumps ``available_at`` to a far-future lease so
the polling loop does not re-pick the same row while the
dispatched task is still running. Status stays ``pending``;
the dispatched task flips it to ``published`` or ``failed``
when execution completes.
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 = (
@@ -158,17 +170,18 @@ class DispatchOrchestrator:
.limit(limit)
)
events = list((await session.scalars(statement)).all())
claimed: list[tuple[dict[str, Any], str]] = []
lease_until = utcnow() + self.EXECUTION_LEASE
for item in events:
item.available_at = lease_until
envelope = {
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,
}
claimed.append((envelope, f"mysql:{item.event_id}"))
},
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),
@@ -379,6 +392,20 @@ class DispatchOrchestrator:
),
)
session.add(node_run)
# Lease is owned by the outbox row, not the claim step. Pick
# the later of (now, scheduled retry) and the timeout + slack,
# so a slow node isn't re-dispatched while it's still running
# but a crashed node does become eligible again after its
# timeout expires. See P0-2 in the auth refactor plan.
retry_at = (
utcnow() + timedelta(seconds=delay_seconds)
if delay_seconds
else utcnow()
)
lease_at = utcnow() + timedelta(
seconds=int(node["timeout_seconds"])
) + self.LEASE_SLACK
available_at = max(retry_at, lease_at)
await add_outbox_event(
session,
event_type="job.node.execute",
@@ -387,11 +414,7 @@ class DispatchOrchestrator:
aggregate_type="schedule_node_run",
aggregate_id=node_run.node_run_id,
idempotency_key=f"{node_run.node_run_id}:{attempt_no}",
available_at=(
utcnow() + timedelta(seconds=delay_seconds)
if delay_seconds
else None
),
available_at=available_at,
payload={
"workspace_id": run.workspace_id,
"run_id": run.run_id,
@@ -480,13 +503,29 @@ class DispatchOrchestrator:
if node_id in latest:
continue
parent_runs = [latest.get(parent) for parent in parents[node_id]]
parent_failed = any(
# Decide whether this node should be skipped. A node
# is only skipped when we know it can never run:
# * ``stop_all`` — the whole run was aborted on the
# first failure, so any not-yet-dispatched node is
# dropped;
# * all parents are terminal AND at least one
# failed — there is no remaining success path.
# If even one parent is still ``queued`` or
# ``running`` we keep waiting: under ``failure_policy
# == 'continue'`` a sibling might still succeed and
# the failed parent does not block that.
parents_terminal = all(
item is not None
and item.node_status in TERMINAL_NODE_STATES
and item.node_status != "succeeded"
for item in parent_runs
)
if stop_all or parent_failed:
any_parent_failed = any(
item is not None
and item.node_status in FAILED_NODE_STATES
for item in parent_runs
)
parents_blocked = parents_terminal and any_parent_failed
if stop_all or parents_blocked:
skipped = ScheduleNodeRuns(
node_run_id=new_ulid(),
run_id=run.run_id,
@@ -500,15 +539,24 @@ class DispatchOrchestrator:
message=(
"调度失败策略为 stop,未再启动"
if stop_all
else "上游节点未成功,已跳过"
else "上游节点全部终止且至少一个失败,已跳过"
),
)
session.add(skipped)
latest[node_id] = skipped
changed = True
elif (
all(
item is not None and item.node_status == "succeeded"
# Dispatch only when every parent has actually
# run to completion successfully. A None parent
# means the parent has not even been dispatched
# yet (e.g. upstream is still queued); the existing
# parents_blocked branch above handles the case
# where every parent is terminal but at least one
# failed.
len(parent_runs) > 0
and all(
item is not None
and item.node_status == "succeeded"
for item in parent_runs
)
and active_count < max_concurrency
@@ -531,13 +579,40 @@ class DispatchOrchestrator:
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(
item.node_status == "succeeded" for item in latest.values()
status == "succeeded" for status in statuses
)
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"
None
if succeeded
else "one or more schedule nodes did not succeed"
)
run.finished_at = now
if run.started_at:
+51 -27
View File
@@ -6,13 +6,16 @@ Composes three single-purpose components into one bootable service:
- :class:`schedule.orchestrator.DispatchOrchestrator` — Outbox polling + DAG
- :class:`schedule.worker.NodeExecutor` — node-level execution
This module also exposes the two factory functions (``build_object_store`` /
``build_storage_http_client``) consumed by ``schedule.main`` to construct
the backing resources that flow into the facade.
This module also exposes the factory function ``build_object_store``
consumed by ``schedule.main`` to construct the RustFS S3 client.
The ``SchedulerService`` itself stays small: it wires the three components
together and implements ``trigger_schedule``, the cron post-back to
Backend that ``CronScheduler`` calls at every cron tick.
The :class:`SchedulerService` itself stays small: it wires the three
components together and implements :meth:`SchedulerService.trigger_schedule`,
the cron tick handler. The trigger writes the new ``ScheduleRuns`` row
and ``schedule.run.requested`` outbox event in a single transaction
via :func:`common.scheduler.create_scheduled_run` — no HTTP call to
the backend, so no service-to-service auth is needed (the schedule
service shares the same MySQL via the Docker network).
"""
from __future__ import annotations
@@ -25,6 +28,11 @@ import httpx
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from common.config import settings
from common.scheduler import (
SYSTEM_CRON_USER_ID,
TriggerError,
create_scheduled_run,
)
from common.ids import new_ulid
from schedule.orchestrator import DispatchOrchestrator
@@ -49,13 +57,13 @@ class SchedulerService:
self,
*,
session_factory: async_sessionmaker[AsyncSession],
backend_http_client: httpx.AsyncClient,
storage_http_client: httpx.AsyncClient,
object_store: Any,
storage_client: Any,
database_url: str,
) -> None:
self.session_factory = session_factory
self.backend_http_client = backend_http_client
self.storage_http_client = storage_http_client
self.object_store = object_store
self.storage_client = storage_client
self.database_url = database_url
@@ -99,11 +107,17 @@ class SchedulerService:
await self.cron.close()
async def trigger_schedule(self, schedule_id: str) -> None:
"""Cron tick callback: post back to Backend to register a new run.
"""Cron tick callback: write a new ``ScheduleRuns`` row + outbox event.
Backend writes the ``schedule.run.requested`` Outbox row in the same
transaction as the ``ScheduleRuns`` insert; the orchestrator's polling
loop will pick it up and start advancing the DAG.
Runs in its own session. The cron ``triggered_by`` is the
stable :data:`common.scheduler.SYSTEM_CRON_USER_ID` (the
bootstrap migration seeds the matching ``Users`` row) — we no
longer impersonate the schedule's human creator as the previous
header-based implementation did.
Idempotency key is the cron minute bucket, so re-entering the
same tick (e.g. after a brief outage) reuses the existing run
via the unique constraint on ``schedule_runs.idempotency_key``.
"""
async with self.session_factory() as session:
from sqlalchemy import select
@@ -118,26 +132,29 @@ class SchedulerService:
or item.trigger_type != "cron"
):
return
user_id = item.created_by
workspace_id = item.workspace_id
now = datetime.now(UTC)
idempotency_key = (
f"cron:{schedule_id}:{now.strftime('%Y%m%d%H%M')}"
)
response = await self.backend_http_client.post(
f"/api/v1/schedules/{schedule_id}/run",
headers={
"X-User-ID": user_id,
"X-Workspace-ID": workspace_id,
"X-Request-ID": new_ulid(),
"Idempotency-Key": idempotency_key,
},
json={"reason": "cron"},
try:
async with self.session_factory() as session:
await create_scheduled_run(
session,
schedule_id=schedule_id,
workspace_id=workspace_id,
triggered_by_user_id=SYSTEM_CRON_USER_ID,
trigger_type="cron",
idempotency_key=idempotency_key,
trace_id=new_ulid(),
)
if response.is_error:
raise RuntimeError(
f"backend rejected cron run: {response.status_code} "
f"{response.text[:500]}"
await session.commit()
except TriggerError as exc:
# Most likely: idempotency_key collision from a previous
# tick in the same minute — silently no-op.
LOGGER.info(
"cron trigger no-op for schedule %s: %s", schedule_id, exc,
)
async def process_pending_events(
@@ -175,7 +192,14 @@ def build_object_store() -> Any:
def build_storage_http_client() -> httpx.AsyncClient:
"""Construct the httpx client that talks to Backend's HTTP API."""
"""Construct the httpx client that talks to Backend's storage API.
The schedule service no longer needs to call any user-facing
endpoint (cron trigger writes directly to the DB now), but the
storage endpoints at ``/internal/v1/...`` still live on the
backend process and are reached via this client. Auth is not
required — the client is bound to the shared Docker network.
"""
return httpx.AsyncClient(
base_url=settings.backend_api_url,
timeout=httpx.Timeout(60.0),
Generated
+36
View File
@@ -194,27 +194,50 @@ version = "0.2.0"
source = { editable = "backend" }
dependencies = [
{ name = "alembic" },
{ name = "bcrypt" },
{ name = "common" },
{ name = "croniter" },
{ name = "cryptography" },
{ name = "fastapi" },
{ name = "gunicorn" },
{ name = "httpx" },
{ name = "passlib" },
{ name = "uvicorn", extra = ["standard"] },
]
[package.metadata]
requires-dist = [
{ name = "alembic", specifier = "==1.18.5" },
{ name = "bcrypt", specifier = ">=4.0,<4.1" },
{ name = "common", editable = "common" },
{ name = "croniter", specifier = "==6.2.4" },
{ name = "cryptography", specifier = "==49.0.0" },
{ name = "fastapi", specifier = "==0.116.1" },
{ name = "gunicorn", specifier = ">=26.0.0" },
{ name = "httpx", specifier = "==0.28.1" },
{ name = "passlib", specifier = "==1.7.4" },
{ name = "uvicorn", extras = ["standard"], specifier = "==0.35.0" },
]
[[package]]
name = "bcrypt"
version = "4.0.1"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8c/ae/3af7d006aacf513975fd1948a6b4d6f8b4a307f8a244e1a3d3774b297aad/bcrypt-4.0.1.tar.gz", hash = "sha256:27d375903ac8261cfe4047f6709d16f7d18d39b1ec92aaf72af989552a650ebd", size = 25498, upload-time = "2022-10-09T15:36:49.775Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/78/d4/3b2657bd58ef02b23a07729b0df26f21af97169dbd0b5797afa9e97ebb49/bcrypt-4.0.1-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:b1023030aec778185a6c16cf70f359cbb6e0c289fd564a7cfa29e727a1c38f8f", size = 473446, upload-time = "2022-10-09T15:36:25.481Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/ec/0a/1582790232fef6c2aa201f345577306b8bfe465c2c665dec04c86a016879/bcrypt-4.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:08d2947c490093a11416df18043c27abe3921558d2c03e2076ccb28a116cb6d0", size = 583044, upload-time = "2022-10-09T15:37:09.447Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/41/16/49ff5146fb815742ad58cafb5034907aa7f166b1344d0ddd7fd1c818bd17/bcrypt-4.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0eaa47d4661c326bfc9d08d16debbc4edf78778e6aaba29c1bc7ce67214d4410", size = 583189, upload-time = "2022-10-09T15:37:10.69Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/aa/48/fd2b197a9741fa790ba0b88a9b10b5e88e62ff5cf3e1bc96d8354d7ce613/bcrypt-4.0.1-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ae88eca3024bb34bb3430f964beab71226e761f51b912de5133470b649d82344", size = 593473, upload-time = "2022-10-09T15:36:27.195Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/7d/50/e683d8418974a602ba40899c8a5c38b3decaf5a4d36c32fc65dce454d8a8/bcrypt-4.0.1-cp36-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:a522427293d77e1c29e303fc282e2d71864579527a04ddcfda6d4f8396c6c36a", size = 593249, upload-time = "2022-10-09T15:36:28.481Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/fb/a7/ee4561fd9b78ca23c8e5591c150cc58626a5dfb169345ab18e1c2c664ee0/bcrypt-4.0.1-cp36-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:fbdaec13c5105f0c4e5c52614d04f0bca5f5af007910daa8b6b12095edaa67b3", size = 583586, upload-time = "2022-10-09T15:37:11.962Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/64/fe/da28a5916128d541da0993328dc5cf4b43dfbf6655f2c7a2abe26ca2dc88/bcrypt-4.0.1-cp36-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ca3204d00d3cb2dfed07f2d74a25f12fc12f73e606fcaa6975d1f7ae69cacbb2", size = 593659, upload-time = "2022-10-09T15:36:30.049Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/dd/4f/3632a69ce344c1551f7c9803196b191a8181c6a1ad2362c225581ef0d383/bcrypt-4.0.1-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:089098effa1bc35dc055366740a067a2fc76987e8ec75349eb9484061c54f535", size = 613116, upload-time = "2022-10-09T15:37:14.107Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/87/69/edacb37481d360d06fc947dab5734aaf511acb7d1a1f9e2849454376c0f8/bcrypt-4.0.1-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:e9a51bbfe7e9802b5f3508687758b564069ba937748ad7b9e890086290d2f79e", size = 624290, upload-time = "2022-10-09T15:36:31.251Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/aa/ca/6a534669890725cbb8c1fb4622019be31813c8edaa7b6d5b62fc9360a17e/bcrypt-4.0.1-cp36-abi3-win32.whl", hash = "sha256:2caffdae059e06ac23fce178d31b4a702f2a3264c20bfb5ff541b338194d8fab", size = 159428, upload-time = "2022-10-09T15:36:32.893Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/46/81/d8c22cd7e5e1c6a7d48e41a1d1d46c92f17dae70a54d9814f746e6027dec/bcrypt-4.0.1-cp36-abi3-win_amd64.whl", hash = "sha256:8a68f4341daf7522fe8d73874de8906f3a339048ba406be6ddc1b3ccb16fc0d9", size = 152930, upload-time = "2022-10-09T15:36:34.635Z" },
]
[[package]]
name = "beautifulsoup4"
version = "4.15.0"
@@ -465,9 +488,11 @@ source = { editable = "common" }
dependencies = [
{ name = "apscheduler" },
{ name = "asyncmy" },
{ name = "bcrypt" },
{ name = "boto3" },
{ name = "fastapi" },
{ name = "greenlet" },
{ name = "passlib" },
{ name = "pydantic-settings" },
{ name = "sqlalchemy" },
]
@@ -476,9 +501,11 @@ dependencies = [
requires-dist = [
{ name = "apscheduler", specifier = ">=3.11.3" },
{ name = "asyncmy", specifier = "==0.2.11" },
{ name = "bcrypt", specifier = ">=4.0,<4.1" },
{ name = "boto3", specifier = ">=1.34,<2" },
{ name = "fastapi", specifier = "==0.116.1" },
{ name = "greenlet", specifier = ">=3.0.0" },
{ name = "passlib", specifier = "==1.7.4" },
{ name = "pydantic-settings", specifier = ">=2.14.2" },
{ name = "sqlalchemy", specifier = "==2.0.51" },
]
@@ -1357,6 +1384,15 @@ wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl", hash = "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c", size = 107025, upload-time = "2026-05-01T23:12:58.867Z" },
]
[[package]]
name = "passlib"
version = "1.7.4"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b6/06/9da9ee59a67fae7761aab3ccc84fa4f3f33f125b370f1ccdb915bf967c11/passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04", size = 689844, upload-time = "2020-10-08T19:00:52.121Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/3b/a4/ab6b7589382ca3df236e03faa71deac88cae040af60c071a78d254a62172/passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1", size = 525554, upload-time = "2020-10-08T19:00:49.856Z" },
]
[[package]]
name = "pexpect"
version = "4.9.0"