feat(scripts): 跨 owner 懒加载目录树 + 跨用户可见 workspace/public
修两个后端接口问题:
1) /api/v1/workspace-directories 返回为空,目录树结构消失
2) 同 workspace 内脚本/数据互相可见但默认排除 private
后端改动
--------
* list_scripts / list_resources / list_workspace_directories 新增
owner_user_id 可选 query 参数;缺省 = 当前请求者本人(scope 到
workspace/{me}/...),传值时 scope 到该 owner 的子树。前端根加载
默认只见自己一级,其他成员以折叠分组呈现。
* visibility 过滤统一:非 admin 请求者只返回 owner==me 或
visibility ∈ {workspace, public};admin 跳过。owner=me 含自己
的 private,owner=other 只剩其 workspace/public,排除他人 private。
* create_workspace_directory 两个分支 visibility 默认 'public'
(非 private),使跨 owner 目录树可见;响应新增 owner_user_id 字段。
* platform.list_members 鉴权从 system_admin_context 放宽为
系统管理员或该 workspace 活跃成员(让普通用户也能渲染同
workspace 成员名册,用于跨 owner 分组)。
* main.py 注册 platform 模块(随 list_members 改动补齐导入)。
* .env.example 同步 common/config.py 26 个字段。
前端改动
--------
* ScriptExplorer.memberScriptGroups 改由 members 列表播种分组,
display_name 取 members.display_name;inferredDirectories 现在按
owner_user_id 标记,统一跨 owner 目录渲染。删除脚本目录页头与
树分组标题的工作副本数量角标。
* WorkspaceTree 新增 ownerUserId 透传到 store.toggleExpanded;
仅"我"的分组 mount 时 auto-expand,他人分组默认折叠,展开才
调 loadOwnerGroup / owner-scoped loadScripts / loadChildren。
* scriptWorkspaceStore 引入 namespaced cache key
(ownerCacheKey = `${ownerUserId ?? me}:${path}`),loadedScriptPaths
/ loadedChildPaths / loadedOwnerGroups 全部按 owner 隔离;
toggleExpanded 用 loadPath === undefined 区分 group 头与真实
目录,修"他人子目录点击不触发接口"的 loadPath 前缀误判 bug。
* api.ts / AuthContext 透传 ownerUserId 给 listScripts /
listResources / listWorkspaceDirectories。
文档
----
* API.md: §3.2 创建目录 visibility 默认 public + 响应加 owner_user_id;
§3.3.1 GET directories 加 owner_user_id 参数 + 响应字段;
§3.4 GET scripts 改写为 owner 作用域 + visibility 过滤语义;
§五.1 GET data-resources 新增,同一套统一语义;
§7 intro 例外 — GET members 对系统管理员或 workspace 活跃成员开放。
* DEVELOP.md: Code layout 重写以反映 backend api/services/clients/
schemas 拆分 + schedule domain/scheduling/application/execution/
infrastructure 拆分 + common 子包(auth/storage/backends);
Configuration 系统补全 26 个 settings 字段;新增
"Owner-scoping + visibility (cross-owner browsing)" 小节;
Per-service dev 注释用 uv run 的源布局要求;Add a new DAG endpoint /
storage bucket 路径改为 backend/src/backend/api/* 与 services/*。
测试
----
* test_list_scripts_parent_path.py /
test_resources.py 补充 owner_user_id 参数化直接调用 + LIKE
前缀断言(workspace/{owner}/... 前缀)。
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,15 @@ DATABASE_URL=mysql+asyncmy://root:change-me@127.0.0.1:3306/model_platform?charse
|
||||
|
||||
JWT_SECRET=change-this-development-secret
|
||||
|
||||
# Force the Secure flag on the session cookie even when the inbound request
|
||||
# scheme is plain HTTP. Enable behind a TLS-terminating reverse proxy that
|
||||
# strips/rewrites X-Forwarded-Proto — otherwise the cookie is written without
|
||||
# Secure and browsers refuse to send it back over HTTPS.
|
||||
COOKIE_FORCE_SECURE=false
|
||||
|
||||
# Service label surfaced in lifespan / health checks.
|
||||
SERVICE_NAME=service
|
||||
|
||||
# ============================================================================
|
||||
# CRITICAL: must set BEFORE first run. The initial admin user is seeded by
|
||||
# the deployment bootstrap. Never keep the development default in production.
|
||||
@@ -84,6 +93,13 @@ S3_TRASH_RETENTION_DAYS=30
|
||||
# over the compose network.
|
||||
RCLONE_RC_URL=http://runtime:5572
|
||||
|
||||
# Backend → Runtime HTTP endpoint (Jupyter contents API, file ops).
|
||||
RUNTIME_API_URL=http://runtime:8000
|
||||
|
||||
# Public base URL for the runtime container (surfaced to clients for
|
||||
# Jupyter access tickets / embedded URLs).
|
||||
PUBLIC_BASE_URL=http://runtime
|
||||
|
||||
# ============================================================================
|
||||
# Service-to-service auth (P0-1 fix).
|
||||
# Backend's /internal/v1/* storage control plane requires this shared secret.
|
||||
@@ -93,3 +109,15 @@ RCLONE_RC_URL=http://runtime:5572
|
||||
# python -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||
# ============================================================================
|
||||
INTERNAL_SERVICE_TOKEN=change-me-internal-service-token
|
||||
|
||||
# Schedule → Backend HTTP base URL (cron post-back / status callbacks).
|
||||
BACKEND_API_URL=http://backend:8000
|
||||
|
||||
# Max concurrent notebooks running in the schedule worker. Each notebook is
|
||||
# dispatched as an asyncio task bounded by a semaphore; the polling loop is
|
||||
# never blocked.
|
||||
SCHEDULE_EXECUTION_CONCURRENCY=4
|
||||
|
||||
# Readiness probe targets. Comma-separated host:port list checked by
|
||||
# /health/ready; empty disables the check. e.g. mysql:3306,s3:9000
|
||||
READINESS_TARGETS=
|
||||
|
||||
@@ -99,7 +99,12 @@
|
||||
|
||||
### 3.2 `POST /api/v1/workspace-directories`
|
||||
|
||||
创建一个**目录**。后端会落一行 `StorageObjects(object_type='directory', storage_backend='rustfs', storage_uri='inline://directory/{path}', visibility='private')`,因此空目录也能在 §3.1 树里出现并保留下来。
|
||||
创建一个**目录**。后端会落一行 `StorageObjects(object_type='directory', storage_backend='rustfs', storage_uri='inline://directory/{path}', visibility='public')`,因此空目录也能在 §3.1 树里出现并保留下来。
|
||||
|
||||
> 目录行默认 `visibility='public'`(非 `private`)。目录是结构性导航行,
|
||||
> 默认 public 使同一 workspace 内其他成员可以浏览彼此的目录结构(目录树
|
||||
> 跨 owner 可见);文件级私密仍由 §3.4 / §五 的 visibility 过滤兜底
|
||||
> —— 其他 owner 的 `private` 脚本 / 数据资源不会返回。
|
||||
|
||||
- **请求体**:
|
||||
```json
|
||||
@@ -123,7 +128,8 @@
|
||||
"storage_object_id": "01HXY...",
|
||||
"path": "scripts/etl",
|
||||
"name": "etl",
|
||||
"parent_path": "scripts"
|
||||
"parent_path": "scripts",
|
||||
"owner_user_id": "01HXX..."
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -155,10 +161,12 @@
|
||||
|
||||
- **行为**: `parent_path` 为空字符串或缺省 → 用户根目录;非空 → 该父目录的直接子目录(workspace-relative)。仅返回 `object_status='available' AND is_deleted=0` 的 `StorageObjects` 行。
|
||||
- **鉴权**: workspace 成员
|
||||
- **owner 作用域**: `owner_user_id` 缺省时 scope 为**当前请求者**本人根目录(`scoped_prefix = workspace/{me}`);传 `owner_user_id` 时 scope 为该 owner 的根目录(`scoped_prefix = workspace/{owner_user_id}`),用于跨 owner 浏览目录树(见 §3.4 visibility 模型)。该接口本身不施加 visibility 过滤——目录行默认 `visibility='public'`(见 §3.2),跨 owner 均可见。
|
||||
- **查询参数**:
|
||||
| 名 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `parent_path` | string | 否 | 父目录相对路径,空字符串或缺省表示用户根目录 |
|
||||
| `owner_user_id` | string | 否 | 目标 owner 的 user_id;缺省=请求者本人。指定后 scope 到 `workspace/{owner_user_id}/{parent_path}` |
|
||||
- **谓词(SQL 等价)**: `relative_path LIKE '<prefix>/%' AND relative_path NOT LIKE '<prefix>/%/%'`,其中 `prefix = scoped_prefix/{parent_path}`,索引走 `idx_storage_workspace_relative_path(workspace_id, relative_path(255))`。
|
||||
- **响应**:
|
||||
```json
|
||||
@@ -166,9 +174,9 @@
|
||||
"request_id": "...",
|
||||
"data": {
|
||||
"directories": [
|
||||
{"path": "scripts", "name": "scripts", "parent_path": "", "has_children": true},
|
||||
{"path": "scripts/etl", "name": "etl", "parent_path": "scripts", "has_children": false},
|
||||
{"path": "scripts/etl/daily", "name": "daily", "parent_path": "scripts/etl", "has_children": false}
|
||||
{"path": "scripts", "name": "scripts", "parent_path": "", "owner_user_id": "01HXX...", "has_children": true},
|
||||
{"path": "scripts/etl", "name": "etl", "parent_path": "scripts", "owner_user_id": "01HXX...", "has_children": false},
|
||||
{"path": "scripts/etl/daily", "name": "daily", "parent_path": "scripts/etl", "owner_user_id": "01HXX...", "has_children": false}
|
||||
]
|
||||
},
|
||||
"meta": {"directory_count": 3}
|
||||
@@ -180,15 +188,28 @@
|
||||
| `path` | string | workspace 内相对路径 |
|
||||
| `name` | string | `path` 的最后一段 |
|
||||
| `parent_path` | string | 父目录相对路径,根目录用空串 |
|
||||
| `owner_user_id` | string | 该目录行所属 owner 的 user_id(`owner_user_id` 参数缺省时=请求者本人) |
|
||||
| `has_children` | bool | 该目录下是否还有直接子目录(后端额外 `EXISTS` 查询,可为空目录为 `false`) |
|
||||
- **空结果**: 不返回 404,空目录列表即 `directories: []`。
|
||||
- **错误**: 401(未登录)/ 403(非 workspace 成员)同其他接口。
|
||||
|
||||
### 3.4 `GET /api/v1/scripts`
|
||||
|
||||
列出当前 workspace 内**全部 active 脚本**。不受 is_locked 影响(读路径不锁)。
|
||||
列出脚本,按 **owner 作用域 + visibility 过滤**返回。不受 is_locked 影响(读路径不锁)。
|
||||
|
||||
- **响应**: `data` 为 `ScriptPayload` 数组(见 §3.10)。
|
||||
- **owner 作用域**: `owner_user_id` 缺省=当前请求者本人,scope 到 `workspace/{me}/...`;传 `owner_user_id` 时 scope 到 `workspace/{owner_user_id}/...`,用于跨 owner 浏览他人脚本。
|
||||
- **visibility 过滤(统一)**: 非 admin 请求者只返回 `owner_user_id == me OR visibility IN (workspace, public)`;admin 请求者跳过过滤返回全部。
|
||||
- **owner=me(缺省)**: scope 是我的子树,行都是我的 → `owner==me` 恒成立 → **含我的 private 脚本** ✓
|
||||
- **owner=other**: scope 是他人的子树,`owner==me` 不成立 → 只剩其 `workspace/public` 脚本(排除他人的 `private`) ✓
|
||||
- 即"本人可见自己全部;他人只见其 workspace/public",私密仅在 owner==me 时可见。
|
||||
- **非递归**: 仅返回 `parent_path` 下的**直接子级**脚本(懒加载用);子目录脚本需带 `parent_path` 再次请求。
|
||||
- **查询参数**:
|
||||
| 名 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `parent_path` | string | 否 | 父目录相对路径,空串/缺省=该 owner 根目录下的一级脚本 |
|
||||
| `owner_user_id` | string | 否 | 目标 owner;缺省=请求者本人 |
|
||||
| `keyword` | string | 否 | 名称模糊匹配 |
|
||||
- **响应**: `data` 为 `ScriptPayload` 数组(见 §3.10),每条带 `owner_user_id`。
|
||||
|
||||
### 3.5 `GET /api/v1/scripts/{script_id}`
|
||||
|
||||
@@ -485,7 +506,7 @@ queued ──→ running ──┬─→ succeeded
|
||||
| `POST` | `/api/v1/data-resources/uploads` | 创建上传会话,返回 `upload_id` + `upload_path` |
|
||||
| `PUT` | `/api/v1/data-resources/uploads/{upload_id}` | 上传字节(请求体即文件内容) |
|
||||
| `POST` | `/api/v1/data-resources/uploads/{upload_id}/bind` | 绑定已完成上传为数据资源 |
|
||||
| `GET` | `/api/v1/data-resources` | 列表(workspace 范围) |
|
||||
| `GET` | `/api/v1/data-resources` | 列表(owner 作用域 + visibility 过滤) |
|
||||
| `GET` | `/api/v1/data-resources/{id}` | 详情 |
|
||||
| `POST` | `/api/v1/data-resources/{id}/download-url` | 生成 presigned GET URL |
|
||||
| `DELETE` | `/api/v1/data-resources/{id}` | 软删 |
|
||||
@@ -521,6 +542,22 @@ queued ──→ running ──┬─→ succeeded
|
||||
`content_base64` 字段(JSON 体里走),内部走同一条 `AsyncStorageBackend.put`
|
||||
路径,前端无需分两步。
|
||||
|
||||
### 五.1 `GET /api/v1/data-resources`
|
||||
|
||||
列出数据资源,按 **owner 作用域 + visibility 过滤**返回(与 §3.4 `GET /scripts` 同一套统一语义)。
|
||||
|
||||
- **owner 作用域**: `owner_user_id` 缺省=当前请求者本人,scope 到 `object_key` 前缀 `{workspace_id}/{me}/...`;传 `owner_user_id` 时 scope 到 `{workspace_id}/{owner_user_id}/...`。
|
||||
- **visibility 过滤(统一)**: 非 admin 请求者只返回 `owner_user_id == me OR visibility IN (workspace, public)`;admin 请求者跳过过滤返回全部。语义同 §3.4——owner=me 含自己的 private;owner=other 只见其 workspace/public。
|
||||
- **非递归**: 仅返回 `parent_path` 下的**直接子级**资源(懒加载用)。
|
||||
- **查询参数**:
|
||||
| 名 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `parent_path` | string | 否 | 父目录相对路径,空串/缺省=该 owner 根目录下的一级资源 |
|
||||
| `owner_user_id` | string | 否 | 目标 owner;缺省=请求者本人 |
|
||||
| `visibility` | string | 否 | `workspace` \| `public` \| `private`,二次过滤 |
|
||||
| `keyword` | string | 否 | 名称模糊匹配 |
|
||||
- **响应**: `data` 为 `ResourcePayload` 数组,每条带 `owner_user_id`。
|
||||
|
||||
---
|
||||
|
||||
## 六、管理后台
|
||||
@@ -581,10 +618,16 @@ Base 前缀 `/api/v1/admin`。
|
||||
## 七、系统管理 (`/api/v1/platform/...`)
|
||||
|
||||
平台级(跨 workspace)管理接口,用于管理员工、workspace 实体与 workspace 成员。
|
||||
所有端点要求调用者是**系统管理员**——其 `users.platform_role_id` 指向
|
||||
除特别注明外,所有端点要求调用者是**系统管理员**——其 `users.platform_role_id` 指向
|
||||
`role_code='admin'` 的角色行,且 `users.status == 'active'`。系统管理员判定
|
||||
通过 `GET /api/v1/auth/me` 响应中的 `data.user.is_system_admin` 字段(详见 §一)。
|
||||
|
||||
> **例外 — `GET /workspaces/{id}/members`**:该端点对**系统管理员(任意
|
||||
> workspace)**与**该 workspace 的活跃成员**(`workspace_members.is_deleted=0`
|
||||
> 且 `member_status='active'`)均开放。这是为了让普通(非 admin)用户能在
|
||||
> 脚本目录树里渲染同 workspace 其他成员的折叠分组(跨 owner 浏览,见 §3.4)。
|
||||
> 其余 members 写端点(POST/PATCH/DELETE members)仍仅限系统管理员。
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| `GET` | `/api/v1/platform/employees` | 列全平台未软删员工;包含停用、锁定及无平台角色用户 |
|
||||
@@ -596,7 +639,7 @@ Base 前缀 `/api/v1/admin`。
|
||||
| `GET` | `/api/v1/platform/workspaces/{workspace_id}` | 单个 workspace(含已 disabled 的,用于恢复) |
|
||||
| `PATCH` | `/api/v1/platform/workspaces/{workspace_id}` | 改 workspace 字段;`status` 仅允许 `active`/`archived` |
|
||||
| `DELETE` | `/api/v1/platform/workspaces/{workspace_id}` | 软删 workspace;级联软删其成员 |
|
||||
| `GET` | `/api/v1/platform/workspaces/{workspace_id}/members` | 列成员 |
|
||||
| `GET` | `/api/v1/platform/workspaces/{workspace_id}/members` | 列成员(**系统管理员或该 workspace 活跃成员**;为跨 owner 目录树提供成员名册,见 §7 intro 例外) |
|
||||
| `POST` | `/api/v1/platform/workspaces/{workspace_id}/members` | 添加成员(返回 201) |
|
||||
| `PATCH` | `/api/v1/platform/workspaces/{workspace_id}/members/{user_id}` | 改成员 `member_status`;**不能改 role_code**(workspace 角色继承自平台角色) |
|
||||
| `DELETE` | `/api/v1/platform/workspaces/{workspace_id}/members/{user_id}` | 软删成员 |
|
||||
|
||||
+137
-61
@@ -6,58 +6,89 @@ refactors see `HANDOVER.md`.
|
||||
|
||||
## Code layout
|
||||
|
||||
All Python packages use the `src/<pkg>/` layout; `uv` workspace glues them into
|
||||
one `.venv`. Always invoke via `uv run [--package <pkg>] <cmd>` (see "Local
|
||||
development" for the gotcha).
|
||||
|
||||
```
|
||||
common/ Pure-Python shared library
|
||||
config.py Settings (pydantic-settings, lru_cache singleton)
|
||||
db/ SQLAlchemy 2.0 async engine, session_scope, Base
|
||||
db/models/ 26 tables in 9 domain files (zero FK, zero relationship)
|
||||
scheduler/ build_sqlalchemy_jobstore (delayed import)
|
||||
storage/ AsyncStorageBackend abstraction (s3 + local impls) + Pydantic schemas
|
||||
eventing.py add_outbox_event / utcnow / event_time
|
||||
service_app.py /health/ready TCP probe, /api/v1/health
|
||||
schemas.py StrictModel base
|
||||
utils.py get_free_port, start_process
|
||||
common/src/common/ Pure-Python shared library
|
||||
config.py Settings (pydantic-settings, lru_cache singleton)
|
||||
db/ SQLAlchemy 2.0 async engine, session_scope, Base
|
||||
db/models/ 26 tables in 9 domain files (zero FK, zero relationship)
|
||||
auth/ JWT / bcrypt / workspace membership helpers
|
||||
scheduler/ APScheduler trigger helpers (delayed import)
|
||||
storage/ AsyncStorageBackend abstraction + Pydantic schemas
|
||||
base.py Abstract interface
|
||||
factory.py create_storage + build_storage_config + PURPOSE_BUCKETS
|
||||
schemas.py CreateUploadRequest / ServerObjectRequest
|
||||
backends/local.py Local filesystem impl
|
||||
backends/s3.py S3-compatible impl (boto3)
|
||||
registry.py Bucket registry
|
||||
eventing.py add_outbox_event / utcnow / event_time
|
||||
service_app.py /health/ready TCP probe, /api/v1/health
|
||||
logging.py loguru config (LOG_LEVEL)
|
||||
schemas.py StrictModel base
|
||||
ids.py ULID generation helpers
|
||||
utils.py get_free_port, start_process
|
||||
|
||||
backend/ Public FastAPI service + tiny /internal/v1/objects RPC
|
||||
main.py lifespan + route registration
|
||||
jupyter.py /api/v1/auth/jupyter — the ONLY auth entry
|
||||
scripts.py CRUD for scripts/notebooks (object storage via AsyncStorageBackend)
|
||||
schedules.py DAG CRUD: schedules, nodes, edges
|
||||
schedule_runs.py Trigger / list / get runs
|
||||
schedule_schemas.py Pydantic request/response models
|
||||
admin.py Admin endpoints
|
||||
resources.py Misc data resources
|
||||
storage_api.py /internal/v1/objects — single token-guarded endpoint (P0-1)
|
||||
storage_client.py Stub (HTTP client removed post-migration; rewrite pending)
|
||||
schedule_client.py Placeholder module (was the HTTP-push executor client)
|
||||
runtime_client.py Self-contained httpx wrapper for the runtime
|
||||
jupyter.py auth_request handler
|
||||
dependencies.py request_context, database_session
|
||||
backend/src/backend/ Public FastAPI service
|
||||
main.py lifespan + route registration
|
||||
audit.py HTTP access log middleware (loguru sink)
|
||||
api/ HTTP route handlers (one module per bounded context)
|
||||
auth.py /api/v1/auth/* (login / me / jupyter)
|
||||
jupyter.py /api/v1/auth/jupyter — the ONLY auth entry
|
||||
dependencies.py request_context, database_session
|
||||
platform.py /api/v1/platform/* (system admin)
|
||||
admin.py /api/v1/admin/* (workspace-internal admin)
|
||||
scripts.py /api/v1/scripts/* + /api/v1/workspace-directories
|
||||
resources.py /api/v1/data-resources/*
|
||||
schedules/schedules.py DAG CRUD
|
||||
schedules/runs.py Run lifecycle
|
||||
storage.py /internal/v1/objects — single token-guarded endpoint (P0-1)
|
||||
services/ Pure-Python business logic (no HTTP / no DI)
|
||||
scripts.py create_workspace_directory, visibility-filtered queries
|
||||
resources.py owner-scoped resource listing helpers
|
||||
jupyter.py jupyter_path / lock helpers
|
||||
storage.py object store helpers
|
||||
schedules.py DAG validation (cycle / orphan detection)
|
||||
schemas/ Pydantic request / response models
|
||||
auth.py / common.py / jupyter.py / platform.py / resources.py / schedules.py / scripts.py
|
||||
clients/ Outbound HTTP / RPC clients
|
||||
runtime.py Self-contained httpx wrapper for the runtime
|
||||
scheduler.py Backend → Schedule HTTP client (callback / dispatch)
|
||||
rclone.py rclone RC API client (FUSE cache invalidation)
|
||||
|
||||
schedule/ Schedule Executor (DAG worker)
|
||||
context.py Constants + naive_utc
|
||||
scheduler.py CronScheduler (APScheduler + 5s sync loop)
|
||||
orchestrator.py DispatchOrchestrator (Outbox poll + DAG advance)
|
||||
worker.py NodeExecutor (notebook / python execution)
|
||||
service.py SchedulerService facade (composes the three)
|
||||
main.py Lifespan + FastAPI app
|
||||
storage_client.py SchedulerStorageClient — talks to backend /internal/v1/objects
|
||||
execution.py execute_artifact (notebook + python paths)
|
||||
notebook_runner.py Subprocess entry point (nbclient)
|
||||
schedule/src/schedule/ Schedule Executor (DAG worker)
|
||||
main.py Lifespan + FastAPI app
|
||||
notebook_runner.py Subprocess entry point (nbclient) — DO NOT RENAME
|
||||
domain/ Pure-Python domain types
|
||||
execution.py ExecutionResult (frozen dataclass) + state enums
|
||||
context.py Constants + naive_utc
|
||||
scheduling/ Time-based trigger
|
||||
scheduler.py CronScheduler (APScheduler + 5s sync loop)
|
||||
application/ Facades / orchestrators
|
||||
service.py SchedulerService (composes the three)
|
||||
orchestrator.py DispatchOrchestrator (Outbox poll + DAG advance)
|
||||
execution/ DAG node execution
|
||||
executor.py NodeExecutor (notebook / python dispatch)
|
||||
worker.py asyncio entry, schedule-spawned task boundary
|
||||
runners/notebook.py nbclient subprocess path (6-line `notebook_runner` shim re-exports `main`)
|
||||
infrastructure/ External-system adapters
|
||||
storage/client.py SchedulerStorageClient — talks to backend /internal/v1/objects
|
||||
|
||||
runtime/ Jupyter Runtime
|
||||
main.py FastAPI entry: jupyter action endpoints
|
||||
process.py Per-workspace subprocess pool + asyncio locks
|
||||
mount.py rclone FUSE mount lifecycle
|
||||
runtime/src/runtime/ Jupyter Runtime
|
||||
main.py FastAPI entry: jupyter action endpoints
|
||||
process.py Per-workspace subprocess pool + asyncio locks
|
||||
mount.py rclone FUSE mount lifecycle
|
||||
|
||||
frontend/ React Router SPA (vite build → nginx)
|
||||
app/ features/ routes/ services/ components/
|
||||
frontend/ React Router SPA (vite build → nginx)
|
||||
app/ features/ routes/ services/ components/
|
||||
|
||||
migrations/ Alembic schema versions
|
||||
docker-compose.yml 4 services
|
||||
default.conf Nginx template
|
||||
migrations/ Alembic schema versions
|
||||
docker-compose.yml 4 services (gateway / backend / schedule / runtime)
|
||||
default.conf Nginx template
|
||||
scripts/nginx-entrypoint.sh
|
||||
.env.example
|
||||
.env.example All 26 config.py keys documented
|
||||
```
|
||||
|
||||
## Configuration system
|
||||
@@ -67,10 +98,31 @@ All env vars go through one place: `common/src/common/config.py`.
|
||||
```python
|
||||
from common.config import settings
|
||||
|
||||
settings.database_url # str
|
||||
settings.storage_backend # str: "s3" (default) or "local"
|
||||
settings.local_storage_base_dir # str: root dir for storage data (default "/data"); see "Storage" below for per-mode derivation
|
||||
settings.s3_endpoint # str (full URL, e.g. "http://s3:9000"; s3 mode only)
|
||||
# Auth / runtime
|
||||
settings.database_url # str — SQLAlchemy async URL (mysql+asyncmy, charset utf8mb4)
|
||||
settings.jwt_secret # HS256 secret for the auth_request handler
|
||||
settings.cookie_force_secure # bool — write Secure flag even on plain HTTP (TLS-terminating proxy)
|
||||
settings.service_name # surfaced in /health
|
||||
settings.schedule_event_namespace # APScheduler JobStore namespace + Outbox scope prefix
|
||||
settings.readiness_targets # CSV host:port list for /health/ready
|
||||
|
||||
# HTTP clients (intra-cluster URLs)
|
||||
settings.runtime_api_url # backend → runtime HTTP base
|
||||
settings.public_base_url # runtime public base URL (browser-facing /jupyter/)
|
||||
settings.backend_api_url # schedule → backend HTTP base
|
||||
settings.rclone_rc_url # backend → rclone RC control API
|
||||
settings.internal_service_token # Backend ↔ Schedule shared secret (X-Internal-Service-Token)
|
||||
|
||||
# Logging / audit
|
||||
settings.log_level # DEBUG / INFO / WARNING / ERROR / CRITICAL (lowercase → fallback INFO)
|
||||
settings.audit_log_dir # dir for daily audit logs (relative to cwd; "" disables file sink)
|
||||
settings.audit_log_retention_days # 0 disables cleanup
|
||||
settings.audit_excluded_paths # list[str] — paths skipped from audit (health probes, etc.)
|
||||
|
||||
# Storage
|
||||
settings.storage_backend # "s3" (default) or "local"
|
||||
settings.local_storage_base_dir # root dir for storage data (default "/data")
|
||||
settings.s3_endpoint # str (s3 mode only)
|
||||
settings.s3_access_key # str (s3 mode only)
|
||||
settings.s3_secret_key # str (s3 mode only)
|
||||
settings.s3_workspace_bucket # str (s3 mode only)
|
||||
@@ -78,17 +130,15 @@ settings.s3_version_bucket # str (s3 mode only)
|
||||
settings.s3_run_log_bucket # str (s3 mode only)
|
||||
settings.s3_trash_bucket # str (s3 mode only)
|
||||
settings.s3_trash_retention_days # int (s3 mode only)
|
||||
settings.jwt_secret # HS256 secret for the auth_request handler
|
||||
settings.backend_api_url # schedule → backend HTTP base
|
||||
settings.runtime_api_url # backend → runtime HTTP base
|
||||
settings.public_base_url # runtime public base URL
|
||||
settings.service_name # surfaced in /health
|
||||
settings.readiness_targets # CSV host:port list for /health/ready
|
||||
|
||||
# Schedule
|
||||
settings.schedule_execution_concurrency # int — max concurrent notebook subprocesses
|
||||
```
|
||||
|
||||
`Settings` reads from process env first, then from a `.env` file at CWD
|
||||
if present. `pydantic-settings` auto-loads. `case_sensitive=False` so
|
||||
`DATABASE_URL` / `database_url` both work.
|
||||
`DATABASE_URL` / `database_url` both work. The full list of 26 fields
|
||||
is in `common/src/common/config.py`.
|
||||
|
||||
### Adding a new env var
|
||||
|
||||
@@ -96,7 +146,8 @@ if present. `pydantic-settings` auto-loads. `case_sensitive=False` so
|
||||
```python
|
||||
new_var: str = Field(default="x", description="...")
|
||||
```
|
||||
2. Add the line to `.env.example` with a comment.
|
||||
2. Add the line to `.env.example` with a comment (keep it synced — every
|
||||
field in config.py must have a matching `.env.example` entry).
|
||||
3. Use `settings.new_var` at the call site.
|
||||
|
||||
Do **not** call `os.environ["NEW_VAR"]` or `os.getenv("NEW_VAR")` in
|
||||
@@ -190,13 +241,34 @@ grep -rnE 'os\.(environ\[?["\x27][A-Z_]+|getenv\(["\x27][A-Z_]+)' --include="*.p
|
||||
|
||||
- For write operations on a script/notebook, call
|
||||
`require_script_modify_access(script, user_id=..., is_admin=...)`
|
||||
from `backend/scripts.py`. It enforces:
|
||||
from `backend/src/backend/services/scripts.py`. It enforces:
|
||||
- admin or owner → allow
|
||||
- non-owner, `is_locked == 0` → allow
|
||||
- non-owner, `is_locked == 1` → 403
|
||||
- Read endpoints (`list_scripts`, `get_script`) intentionally do **not**
|
||||
check `is_locked` — workspace members can see the script list.
|
||||
|
||||
### Owner-scoping + visibility (cross-owner browsing)
|
||||
|
||||
`GET /api/v1/scripts`, `GET /api/v1/data-resources`, and
|
||||
`GET /api/v1/workspace-directories` all accept an optional
|
||||
`owner_user_id` query param and follow the same model:
|
||||
|
||||
- `owner_user_id` 缺省 = 当前请求者本人(scope to `workspace/{me}/...`).
|
||||
- 传值时 scope 到 `workspace/{owner_user_id}/...`,用于前端"点开其他成员
|
||||
分组"的懒加载(见 §3.4 of `API.md`).
|
||||
- `visibility` 过滤(非 admin):`owner_user_id == me OR visibility IN
|
||||
(workspace, public)` — 自己可见自己全部(含 private),他人只见其
|
||||
workspace/public,排除他人 private.
|
||||
- 系统管理员跳过 visibility 过滤.
|
||||
- 目录行默认 `visibility='public'`(由 `create_workspace_directory` 写入),
|
||||
不施加 visibility 过滤,使跨 owner 目录树可见.
|
||||
|
||||
实现 helper 在 `backend/src/backend/services/scripts.py`
|
||||
(`_build_list_scripts_owner_descendant_prefix`) 和
|
||||
`backend/src/backend/api/resources.py` 中按相同模式分别构造 owner-scoped
|
||||
LIKE 前缀。
|
||||
|
||||
### Outbox events
|
||||
|
||||
- The platform's only async-messaging fabric is the MySQL
|
||||
@@ -238,6 +310,10 @@ cd frontend && pnpm install && cd ..
|
||||
|
||||
### Per-service dev
|
||||
|
||||
Always go through `uv run` so the workspace `.venv` is used — bare
|
||||
`uvicorn` / `python` resolves to system Python and `from backend.X`
|
||||
imports fail with ModuleNotFoundError.
|
||||
|
||||
```bash
|
||||
# Backend (terminal 1)
|
||||
export DATABASE_URL="mysql+asyncmy://model_platform:model_platform@127.0.0.1:3306/model_platform?charset=utf8mb4"
|
||||
@@ -248,13 +324,13 @@ export S3_ENDPOINT=http://127.0.0.1:9000
|
||||
# Or for local mode:
|
||||
# export STORAGE_BACKEND=local
|
||||
# export LOCAL_STORAGE_BASE_DIR=/data
|
||||
uv run --frozen --package backend uvicorn backend.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
uv run --package backend uvicorn backend.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
|
||||
# Schedule Executor (terminal 2)
|
||||
uv run --frozen --package schedule uvicorn schedule.main:app --host 0.0.0.0 --port 8001 --reload
|
||||
uv run --package schedule uvicorn schedule.main:app --host 0.0.0.0 --port 8001 --reload
|
||||
|
||||
# Runtime (terminal 3 — needs SYS_ADMIN, FUSE, devmode)
|
||||
uv run --frozen --package runtime python -m runtime.main
|
||||
uv run --package runtime python -m runtime.main
|
||||
```
|
||||
|
||||
### Frontend dev
|
||||
|
||||
@@ -243,6 +243,21 @@ async def system_admin_context(
|
||||
)
|
||||
|
||||
|
||||
async def _is_system_admin(session: AsyncSession, user: Users) -> bool:
|
||||
"""True if ``user`` holds the platform-scoped admin role.
|
||||
|
||||
Mirrors the check inside :func:`system_admin_context` so member-listing
|
||||
endpoints can admit workspace members *or* system admins without pulling
|
||||
in the full :class:`SystemAdminContext` (which 403s non-admins outright).
|
||||
"""
|
||||
if user.platform_role_id is None:
|
||||
return False
|
||||
platform_role = await session.scalar(
|
||||
select(Roles).where(Roles.role_id == user.platform_role_id)
|
||||
)
|
||||
return platform_role is not None and platform_role.role_code == "admin"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Payload helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -809,10 +824,33 @@ async def delete_workspace(
|
||||
@router.get("/workspaces/{workspace_id}/members")
|
||||
async def list_members(
|
||||
workspace_id: str,
|
||||
context: SystemAdminContext = Depends(system_admin_context),
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(database_session),
|
||||
) -> dict[str, Any]:
|
||||
"""List active and historical (non-soft-deleted) members of a workspace."""
|
||||
"""List active and historical (non-soft-deleted) members of a workspace.
|
||||
|
||||
Accessible to system admins (any workspace) and to active members of the
|
||||
workspace itself. The script explorer calls this to seed the per-owner
|
||||
directory-tree groups for non-admin users; visibility filters on the
|
||||
scripts/data-resources endpoints still keep each peer's private content
|
||||
hidden, so this only exposes membership (names), not private files.
|
||||
"""
|
||||
user = await current_user(request, session)
|
||||
is_system_admin = await _is_system_admin(session, user)
|
||||
if not is_system_admin:
|
||||
membership = await session.scalar(
|
||||
select(WorkspaceMembers).where(
|
||||
WorkspaceMembers.workspace_id == workspace_id,
|
||||
WorkspaceMembers.user_id == user.user_id,
|
||||
WorkspaceMembers.is_deleted == 0,
|
||||
WorkspaceMembers.member_status == "active",
|
||||
)
|
||||
)
|
||||
if membership is None:
|
||||
raise HTTPException(
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
"需要系统管理员或该工作区成员权限",
|
||||
)
|
||||
await _load_workspace(session, workspace_id)
|
||||
rows = (
|
||||
await session.execute(
|
||||
@@ -830,8 +868,9 @@ async def list_members(
|
||||
.limit(LIST_PAGE_SIZE)
|
||||
)
|
||||
).all()
|
||||
request_id = request.headers.get("X-Request-ID") or new_ulid()
|
||||
return _envelope(
|
||||
context.request_id,
|
||||
request_id,
|
||||
[member_payload(u, r, m) for u, r, m in rows],
|
||||
{"count": len(rows), "page_size": LIST_PAGE_SIZE},
|
||||
)
|
||||
|
||||
@@ -51,13 +51,13 @@ def _build_list_resources_descendant_prefix(parent_path: str) -> str:
|
||||
"""Return the escaped materialized-path prefix for direct children
|
||||
of ``parent_path`` against ``StorageObjects.object_key``.
|
||||
|
||||
Data resources are workspace-wide (no per-user scoping at the API
|
||||
level). The full object_key is ``{ws_id}/{user_id}/{jupyter_path}``;
|
||||
we filter on object_key with the pattern ``{ws_id}/%/{parent_path}``
|
||||
so any owner whose jupyter_accessible_path starts with parent_path
|
||||
matches. LIKE wildcards in parent_path are escaped; the ``%`` between
|
||||
``{ws_id}/`` and the escaped parent is an intentional SQL wildcard
|
||||
matching the ``owner_user_id`` segment across all owners.
|
||||
The full object_key is ``{ws_id}/{owner_user_id}/{jupyter_path}``.
|
||||
``list_resources`` prepends ``{ws_id}/{owner_user_id}`` (the requester
|
||||
by default, or the ``owner_user_id`` query param) to this prefix and
|
||||
applies ``LIKE '{ws_id}/{owner}/{prefix}%' AND NOT LIKE '...%/%'`` so
|
||||
only that owner's direct children under ``parent_path`` match. LIKE
|
||||
wildcards in parent_path are escaped so folder names containing ``_``
|
||||
or ``%`` do not act as wildcards.
|
||||
"""
|
||||
normalized = normalize_user_path(parent_path)
|
||||
escaped = _escape_like_pattern(normalized)
|
||||
@@ -383,6 +383,7 @@ async def bind_resource(
|
||||
@router.get("")
|
||||
async def list_resources(
|
||||
parent_path: str = Query(default="", max_length=1024),
|
||||
owner_user_id: str | None = Query(default=None, max_length=64),
|
||||
context: RequestContext = Depends(request_context),
|
||||
session: AsyncSession = Depends(database_session),
|
||||
visibility: str | None = Query(default=None),
|
||||
@@ -403,23 +404,30 @@ async def list_resources(
|
||||
)
|
||||
.order_by(DataResources.updated_at.desc())
|
||||
)
|
||||
if parent_path:
|
||||
# ``parent_path`` scopes to DIRECT children of that jupyter path
|
||||
# (matching /api/v1/scripts). Data resources are workspace-wide, so
|
||||
# the middle ``%`` is an intentional wildcard that matches the
|
||||
# ``owner_user_id`` segment across all owners. The parent's ``_`` /
|
||||
# ``%`` are escaped so sibling folders (e.g. ``fooXbar``) don't leak.
|
||||
descendant_prefix = _build_list_resources_descendant_prefix(parent_path)
|
||||
statement = statement.where(
|
||||
StorageObjects.object_key.like(
|
||||
f"{context.workspace.workspace_id}/%/{descendant_prefix}%",
|
||||
escape="\\",
|
||||
),
|
||||
~StorageObjects.object_key.like(
|
||||
f"{context.workspace.workspace_id}/%/{descendant_prefix}%/%",
|
||||
escape="\\",
|
||||
),
|
||||
)
|
||||
# ``parent_path`` scopes to DIRECT children of that jupyter path
|
||||
# (matching /api/v1/scripts). Per-owner listing: default (no
|
||||
# owner_user_id) scopes to the requester's own object_key subtree
|
||||
# (``{ws_id}/{me}/...``); passing owner_user_id scopes to that owner's
|
||||
# subtree so the tree can lazily fetch another member's data resources
|
||||
# on group expand. The parent's ``_`` / ``%`` are escaped so sibling
|
||||
# folders (e.g. ``fooXbar``) don't leak. Empty parent_path still applies
|
||||
# the filter: it resolves to that owner's root-level direct children
|
||||
# (``{ws_id}/{owner}/%`` and NOT ``{ws_id}/{owner}/%/%``), symmetric with
|
||||
# list_scripts. Skipping the filter for empty input would silently
|
||||
# surface nested descendants and break the directory tree.
|
||||
target_owner = owner_user_id or context.user.user_id
|
||||
owner_prefix = f"{context.workspace.workspace_id}/{target_owner}"
|
||||
descendant_prefix = _build_list_resources_descendant_prefix(parent_path)
|
||||
statement = statement.where(
|
||||
StorageObjects.object_key.like(
|
||||
f"{owner_prefix}/{descendant_prefix}%",
|
||||
escape="\\",
|
||||
),
|
||||
~StorageObjects.object_key.like(
|
||||
f"{owner_prefix}/{descendant_prefix}%/%",
|
||||
escape="\\",
|
||||
),
|
||||
)
|
||||
# 只返回 owner 自己的资源(含 private),或 visibility 为
|
||||
# workspace/public 的其他成员资源;A 的 private 资源对非 owner 不可见。
|
||||
# admin 跳过过滤,全部可见。
|
||||
|
||||
@@ -129,40 +129,36 @@ def _escape_like_pattern(value: str) -> str:
|
||||
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
|
||||
|
||||
# @deprecated — legacy user-scoped prefix helper. list_scripts/count_scripts
|
||||
# are now workspace-wide via _build_list_scripts_workspace_descendant_prefix;
|
||||
# this helper is kept (with its original user-scoped semantics) so existing
|
||||
# callers/tests that still reference it do not break.
|
||||
def _build_list_scripts_descendant_prefix(
|
||||
context: RequestContext, parent_path: str
|
||||
def _build_list_scripts_owner_descendant_prefix(
|
||||
owner_user_id: str, parent_path: str
|
||||
) -> str:
|
||||
"""Return the escaped materialized-path prefix for direct children of
|
||||
``parent_path`` within the **requester's own subtree**.
|
||||
``parent_path`` within ``owner_user_id``'s own subtree.
|
||||
|
||||
.. note::
|
||||
Legacy user-scoped helper. list_scripts / count_scripts are now
|
||||
workspace-wide — use
|
||||
:func:`_build_list_scripts_workspace_descendant_prefix` instead
|
||||
(visibility filtering handles non-admin scoping in the SQL).
|
||||
Storage is physically laid out as ``workspace/{owner_user_id}/...``, so a
|
||||
per-owner listing matches ``workspace/{owner_user_id}/{parent}``. The
|
||||
endpoint appends ``LIKE '<prefix>%' AND NOT LIKE '<prefix>%/%'`` against
|
||||
``storage_objects.relative_path`` so only scripts whose parent directory
|
||||
is exactly ``parent_path`` match (no deeper descendants, no
|
||||
prefix-siblings like ``foo/bar`` vs ``foo/bar2``).
|
||||
|
||||
The endpoint appends ``LIKE '<prefix>/%' AND NOT LIKE '<prefix>/%/%'``
|
||||
against ``storage_objects.relative_path`` so only scripts whose parent
|
||||
directory is exactly ``parent_path`` (no deeper descendants, no
|
||||
prefix-siblings like ``foo/bar`` vs ``foo/bar2``) match.
|
||||
|
||||
Empty ``parent_path`` produces the user-scoped root prefix — i.e. the
|
||||
endpoint returns root-level scripts only, not the full workspace.
|
||||
Empty ``parent_path`` produces the owner-scoped root prefix — i.e. the
|
||||
endpoint returns that owner's root-level scripts only. ``list_scripts``
|
||||
calls this with ``owner_user_id`` = the requester by default (so a
|
||||
non-admin sees their own subtree, including private) or with the
|
||||
``owner_user_id`` query param so the tree can lazily fetch another
|
||||
member's content on group expand; the route's visibility filter then
|
||||
excludes the other owner's private rows.
|
||||
|
||||
The prefix is run through ``_escape_like_pattern`` so folder names
|
||||
containing ``_`` / ``%`` do not act as wildcards. The trailing ``/``
|
||||
is appended AFTER escaping so it remains a literal slash.
|
||||
"""
|
||||
normalized_parent = normalize_user_path(parent_path)
|
||||
scoped_prefix = user_relative_path(context)
|
||||
if normalized_parent:
|
||||
target_prefix = f"{scoped_prefix}/{normalized_parent}"
|
||||
target_prefix = f"workspace/{owner_user_id}/{normalized_parent}"
|
||||
else:
|
||||
target_prefix = scoped_prefix
|
||||
target_prefix = f"workspace/{owner_user_id}"
|
||||
return f"{_escape_like_pattern(target_prefix)}/"
|
||||
|
||||
|
||||
@@ -836,16 +832,25 @@ async def get_workspace_tree(
|
||||
@router.get("/workspace-directories")
|
||||
async def list_workspace_directories(
|
||||
parent_path: str = Query(default=""),
|
||||
owner_user_id: str | None = Query(default=None, max_length=64),
|
||||
context: RequestContext = Depends(request_context),
|
||||
session: AsyncSession = Depends(database_session),
|
||||
) -> dict[str, Any]:
|
||||
"""List direct child directories of a workspace path.
|
||||
|
||||
Empty ``parent_path`` returns the directories immediately under the
|
||||
user's scoped root. Only available, non-deleted StorageObjects are
|
||||
considered.
|
||||
target owner's scoped root. Only available, non-deleted StorageObjects
|
||||
are considered.
|
||||
|
||||
``owner_user_id`` defaults to the requester, so a member lists their
|
||||
own directories. Passing another member's id scopes to that owner's
|
||||
subtree so the script explorer can lazily render their directory
|
||||
structure on expand (directories are structural rows; file-level
|
||||
visibility is still enforced by the scripts/data-resources endpoints,
|
||||
which exclude the other owner's private files).
|
||||
"""
|
||||
scoped_prefix = user_relative_path(context)
|
||||
target_owner = owner_user_id or context.user.user_id
|
||||
scoped_prefix = f"workspace/{target_owner}"
|
||||
parent = normalize_user_path(parent_path)
|
||||
target_prefix = f"{scoped_prefix}/{parent}" if parent else scoped_prefix
|
||||
descendant_prefix = f"{_escape_like_pattern(target_prefix)}/"
|
||||
@@ -878,6 +883,7 @@ async def list_workspace_directories(
|
||||
"path": child_path,
|
||||
"name": suffix,
|
||||
"parent_path": parent,
|
||||
"owner_user_id": target_owner,
|
||||
"has_children": False,
|
||||
},
|
||||
)
|
||||
@@ -1018,7 +1024,8 @@ async def create_workspace_directory(
|
||||
path_hash=path_hash,
|
||||
object_status="available",
|
||||
size_bytes=0,
|
||||
visibility="private",
|
||||
visibility="public",
|
||||
owner_user_id=context.user.user_id,
|
||||
created_by=context.user.user_id,
|
||||
)
|
||||
session.add(directory)
|
||||
@@ -1032,7 +1039,8 @@ async def create_workspace_directory(
|
||||
directory.storage_uri = f"inline://directory/{relative_path}"
|
||||
directory.file_name = name
|
||||
directory.size_bytes = 0
|
||||
directory.visibility = "private"
|
||||
directory.visibility = "public"
|
||||
directory.owner_user_id = context.user.user_id
|
||||
directory.created_by = context.user.user_id
|
||||
|
||||
try:
|
||||
@@ -1058,6 +1066,7 @@ async def create_workspace_directory(
|
||||
"path": child_path,
|
||||
"name": name,
|
||||
"parent_path": parent,
|
||||
"owner_user_id": context.user.user_id,
|
||||
},
|
||||
"meta": {},
|
||||
}
|
||||
@@ -1165,17 +1174,23 @@ async def delete_workspace_directory(
|
||||
@router.get("/scripts")
|
||||
async def list_scripts(
|
||||
parent_path: str = Query(default="", max_length=1024),
|
||||
owner_user_id: str | None = Query(default=None, max_length=64),
|
||||
context: RequestContext = Depends(request_context),
|
||||
session: AsyncSession = Depends(database_session),
|
||||
) -> dict[str, Any]:
|
||||
# Workspace-wide listing: storage is physically laid out as
|
||||
# ``workspace/{user_id}/...``. Empty parent_path wildcards the owner
|
||||
# segment (``workspace/%/``) so each owner's root files are returned;
|
||||
# non-empty parent_path embeds the same owner wildcard
|
||||
# (``workspace/%/foo``) so every owner's ``foo`` subtree matches,
|
||||
# mirroring list_resources. Non-admin scoping is applied below via
|
||||
# visibility, matching list_resources (69a9a48).
|
||||
descendant_prefix = _build_list_scripts_workspace_descendant_prefix(parent_path)
|
||||
# Per-owner listing: storage is physically laid out as
|
||||
# ``workspace/{user_id}/...``. Default (no owner_user_id) scopes to the
|
||||
# requester's own subtree — root-level files when parent_path is empty —
|
||||
# so the tree's initial load fetches only "me". Passing owner_user_id
|
||||
# scopes to that owner's subtree so the tree can lazily fetch another
|
||||
# member's content when their group is expanded. Non-admin scoping is
|
||||
# applied below via visibility, so the other owner's private rows are
|
||||
# excluded (workspace/public only); the requester's own private rows
|
||||
# pass because ``owner_user_id = me``.
|
||||
target_owner = owner_user_id or context.user.user_id
|
||||
descendant_prefix = _build_list_scripts_owner_descendant_prefix(
|
||||
target_owner, parent_path
|
||||
)
|
||||
|
||||
statement = (
|
||||
select(Scripts, StorageObjects, Users.display_name)
|
||||
|
||||
@@ -33,16 +33,16 @@ from fastapi import Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from loguru import logger
|
||||
|
||||
from backend.audit import configure_audit_logging
|
||||
from backend.api.admin import router as admin_router
|
||||
from backend.api.auth import router as auth_router
|
||||
from backend.api.jupyter import router as jupyter_router
|
||||
from backend.api.platform import router as platform_router
|
||||
from backend.api.resources import router as resources_router
|
||||
from backend.api.scripts import router as scripts_router
|
||||
from backend.api.schedules.runs import router as schedule_runs_router
|
||||
from backend.api.schedules.schedules import router as schedules_router
|
||||
from backend.api.scripts import router as scripts_router
|
||||
from backend.api.storage import router as storage_api_router
|
||||
from backend.audit import configure_audit_logging
|
||||
from backend.clients.rclone import RcloneRCClient
|
||||
from backend.clients.runtime import RuntimeClient
|
||||
|
||||
@@ -161,7 +161,7 @@ async def access_log(request: Request, call_next):
|
||||
method=request.method,
|
||||
path=request.url.path,
|
||||
status=500,
|
||||
).info("audit")
|
||||
).info(f"{request.url.path} skip audit")
|
||||
raise
|
||||
elapsed_ms = (time.perf_counter() - start) * 1000
|
||||
logger.info(
|
||||
@@ -175,7 +175,7 @@ async def access_log(request: Request, call_next):
|
||||
method=request.method,
|
||||
path=request.url.path,
|
||||
status=response.status_code,
|
||||
).info("audit")
|
||||
).info(f"{request.url.path} skip audit")
|
||||
return response
|
||||
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ from sqlalchemy import Column, MetaData, String, Table, create_engine, select, t
|
||||
from sqlalchemy.dialects import mysql as mysql_dialect
|
||||
|
||||
from backend.api.scripts import (
|
||||
_build_list_scripts_descendant_prefix,
|
||||
_build_list_scripts_owner_descendant_prefix,
|
||||
_build_list_scripts_workspace_descendant_prefix,
|
||||
_escape_like_pattern,
|
||||
normalize_user_path,
|
||||
@@ -85,14 +85,14 @@ class TestEscapeLikePattern:
|
||||
|
||||
|
||||
def test_descendant_prefix_root() -> None:
|
||||
"""Empty parent_path → descendant prefix is the scoped root + '/'."""
|
||||
prefix = _build_list_scripts_descendant_prefix(_ctx("alice"), "")
|
||||
"""Empty parent_path → descendant prefix is the owner-scoped root + '/'."""
|
||||
prefix = _build_list_scripts_owner_descendant_prefix("alice", "")
|
||||
assert prefix == "workspace/alice/"
|
||||
|
||||
|
||||
def test_descendant_prefix_subdir() -> None:
|
||||
"""Non-empty parent_path → appended under the scoped root."""
|
||||
prefix = _build_list_scripts_descendant_prefix(_ctx("alice"), "foo/bar")
|
||||
"""Non-empty parent_path → appended under the owner-scoped root."""
|
||||
prefix = _build_list_scripts_owner_descendant_prefix("alice", "foo/bar")
|
||||
assert prefix == "workspace/alice/foo/bar/"
|
||||
|
||||
|
||||
@@ -100,18 +100,18 @@ def test_descendant_prefix_escapes_metachars() -> None:
|
||||
"""Folder name ``foo_bar`` MUST produce ``foo\\_bar`` in the prefix
|
||||
so the trailing ``%`` doesn't become 'match any single char before
|
||||
b'."""
|
||||
prefix = _build_list_scripts_descendant_prefix(_ctx("alice"), "foo_bar")
|
||||
prefix = _build_list_scripts_owner_descendant_prefix("alice", "foo_bar")
|
||||
assert prefix == r"workspace/alice/foo\_bar/"
|
||||
|
||||
|
||||
def test_descendant_prefix_normalizes_leading_trailing_slashes() -> None:
|
||||
prefix = _build_list_scripts_descendant_prefix(_ctx("alice"), "/foo/bar/")
|
||||
prefix = _build_list_scripts_owner_descendant_prefix("alice", "/foo/bar/")
|
||||
assert prefix == "workspace/alice/foo/bar/"
|
||||
|
||||
|
||||
def test_descendant_prefix_rejects_traversal() -> None:
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
_build_list_scripts_descendant_prefix(_ctx("alice"), "foo/../bar")
|
||||
_build_list_scripts_owner_descendant_prefix("alice", "foo/../bar")
|
||||
assert exc.value.status_code == 422
|
||||
|
||||
|
||||
@@ -202,14 +202,19 @@ async def test_list_scripts_where_clause_uses_like_prefix_and_excludes_deeper()
|
||||
)
|
||||
)
|
||||
|
||||
await list_scripts(parent_path="foo/bar", context=_ctx("alice"), session=mock_session)
|
||||
await list_scripts(
|
||||
parent_path="foo/bar",
|
||||
owner_user_id=None,
|
||||
context=_ctx("alice"),
|
||||
session=mock_session,
|
||||
)
|
||||
|
||||
assert len(captured_sql) == 1
|
||||
sql = captured_sql[0].lower()
|
||||
# 中间 ``%`` 是 owner 段通配符(跨所有 owner),父路径本身按字面匹配,
|
||||
# 与 list_resources 对 object_key 的过滤一致。
|
||||
assert "like 'workspace/%%/foo/bar/%%'" in sql
|
||||
assert "not like 'workspace/%%/foo/bar/%%/%%'" in sql
|
||||
# Default (no owner_user_id) scopes to the requester's own subtree:
|
||||
# LIKE workspace/alice/foo/bar/% (direct children), excluding deeper.
|
||||
assert "like 'workspace/alice/foo/bar/%%'" in sql
|
||||
assert "not like 'workspace/alice/foo/bar/%%/%%'" in sql
|
||||
|
||||
|
||||
async def test_list_scripts_where_clause_escapes_pattern_literal() -> None:
|
||||
@@ -230,7 +235,12 @@ async def test_list_scripts_where_clause_escapes_pattern_literal() -> None:
|
||||
)
|
||||
)
|
||||
|
||||
await list_scripts(parent_path="foo_bar", context=_ctx("alice"), session=mock_session)
|
||||
await list_scripts(
|
||||
parent_path="foo_bar",
|
||||
owner_user_id=None,
|
||||
context=_ctx("alice"),
|
||||
session=mock_session,
|
||||
)
|
||||
sql = captured_sql[0]
|
||||
# Normalize keyword case so we don't depend on SQLAlchemy casing.
|
||||
sql_lower = sql.lower()
|
||||
@@ -238,9 +248,9 @@ async def test_list_scripts_where_clause_escapes_pattern_literal() -> None:
|
||||
# doubles the escape char inside the SQL string literal, so what
|
||||
# the helper emits as `foo\_bar` renders as `foo\\_bar` here
|
||||
# (2 backslash chars in the actual SQL string).
|
||||
assert r"like 'workspace/%%/foo\\_bar/%%'" in sql_lower
|
||||
assert r"like 'workspace/alice/foo\\_bar/%%'" in sql_lower
|
||||
# NOT LIKE clause also escaped.
|
||||
assert r"not like 'workspace/%%/foo\\_bar/%%/%%'" in sql_lower
|
||||
assert r"not like 'workspace/alice/foo\\_bar/%%/%%'" in sql_lower
|
||||
# And both declare ESCAPE '\\'.
|
||||
assert sql.count("ESCAPE '\\\\'") == 2, sql
|
||||
|
||||
@@ -262,18 +272,26 @@ async def test_list_scripts_where_clause_escapes_percent_pattern() -> None:
|
||||
)
|
||||
)
|
||||
|
||||
await list_scripts(parent_path="100%match", context=_ctx("alice"), session=mock_session)
|
||||
await list_scripts(
|
||||
parent_path="100%match",
|
||||
owner_user_id=None,
|
||||
context=_ctx("alice"),
|
||||
session=mock_session,
|
||||
)
|
||||
sql = captured_sql[0]
|
||||
sql_lower = sql.lower()
|
||||
# SQLAlchemy doubles the escape char so `%` → `\%` becomes `\\%`
|
||||
# in the SQL string literal.
|
||||
assert r"workspace/%%/100\\%%match/%%" in sql_lower
|
||||
assert r"workspace/alice/100\\%%match/%%" in sql_lower
|
||||
|
||||
|
||||
async def test_list_scripts_non_admin_adds_visibility_filter() -> None:
|
||||
"""Workspace-wide listing is narrowed by visibility for non-admin:
|
||||
"""Owner-scoped listing is narrowed by visibility for non-admin:
|
||||
owner_user_id = me OR visibility IN (workspace, public) — exactly like
|
||||
list_resources. The workspace prefix contains NO user_id (cross-owner)."""
|
||||
list_resources. Default (no owner_user_id) scopes to the requester's own
|
||||
subtree, so the visibility predicate is redundant-but-present here; it
|
||||
becomes load-bearing when an owner_user_id query param browses another
|
||||
member's subtree (their private rows are then excluded)."""
|
||||
from backend.api.scripts import list_scripts
|
||||
|
||||
captured_sql: list[str] = []
|
||||
@@ -289,11 +307,50 @@ async def test_list_scripts_non_admin_adds_visibility_filter() -> None:
|
||||
)
|
||||
)
|
||||
|
||||
await list_scripts(parent_path="", context=_ctx("alice"), session=mock_session)
|
||||
await list_scripts(
|
||||
parent_path="",
|
||||
owner_user_id=None,
|
||||
context=_ctx("alice"),
|
||||
session=mock_session,
|
||||
)
|
||||
sql = captured_sql[0].lower()
|
||||
# Root listing wildcards the owner segment: LIKE workspace/%/%
|
||||
# (each owner's root files), excluding 3+ segment descendants.
|
||||
assert "like 'workspace/%%/%%'" in sql
|
||||
# Default (no owner_user_id) scopes to the requester's own root:
|
||||
# LIKE workspace/alice/% (alice's root files), excluding nested.
|
||||
assert "like 'workspace/alice/%%'" in sql
|
||||
assert "scripts.owner_user_id = 'alice'" in sql
|
||||
assert "scripts.visibility in ('workspace', 'public')" in sql
|
||||
|
||||
|
||||
async def test_list_scripts_owner_param_scopes_to_other_owner() -> None:
|
||||
"""owner_user_id=<other> scopes the LIKE to that owner's subtree so the
|
||||
tree can lazily fetch another member's content on group expand. The
|
||||
non-admin visibility predicate is still applied, so the other owner's
|
||||
private rows are excluded (only workspace/public survive)."""
|
||||
from backend.api.scripts import list_scripts
|
||||
|
||||
captured_sql: list[str] = []
|
||||
|
||||
class _MockResult:
|
||||
def all(self):
|
||||
return []
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.execute = AsyncMock(
|
||||
side_effect=lambda stmt: (
|
||||
captured_sql.append(_compile_sql(stmt)) or _MockResult()
|
||||
)
|
||||
)
|
||||
|
||||
await list_scripts(
|
||||
parent_path="",
|
||||
owner_user_id="bob",
|
||||
context=_ctx("alice"),
|
||||
session=mock_session,
|
||||
)
|
||||
sql = captured_sql[0].lower()
|
||||
assert "like 'workspace/bob/%%'" in sql
|
||||
assert "not like 'workspace/bob/%%/%%'" in sql
|
||||
# non-admin: visibility predicate still present (excludes bob's private)
|
||||
assert "scripts.owner_user_id = 'alice'" in sql
|
||||
assert "scripts.visibility in ('workspace', 'public')" in sql
|
||||
|
||||
@@ -316,15 +373,47 @@ async def test_list_scripts_admin_skips_visibility_filter() -> None:
|
||||
)
|
||||
|
||||
await list_scripts(
|
||||
parent_path="", context=_ctx("alice", is_admin=True), session=mock_session
|
||||
parent_path="",
|
||||
owner_user_id=None,
|
||||
context=_ctx("alice", is_admin=True),
|
||||
session=mock_session,
|
||||
)
|
||||
sql = captured_sql[0].lower()
|
||||
assert "like 'workspace/%%/%%'" in sql
|
||||
assert "like 'workspace/alice/%%'" in sql
|
||||
# owner_user_id / visibility still appear in the SELECT projection; what
|
||||
# must be absent is the visibility WHERE predicate for non-admins.
|
||||
assert "scripts.visibility in ('workspace', 'public')" not in sql
|
||||
|
||||
|
||||
async def test_list_scripts_admin_owner_param_skips_visibility() -> None:
|
||||
"""Admin browsing another owner's subtree scopes to that owner and skips
|
||||
the visibility predicate (admin sees the other owner's private too)."""
|
||||
from backend.api.scripts import list_scripts
|
||||
|
||||
captured_sql: list[str] = []
|
||||
|
||||
class _MockResult:
|
||||
def all(self):
|
||||
return []
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.execute = AsyncMock(
|
||||
side_effect=lambda stmt: (
|
||||
captured_sql.append(_compile_sql(stmt)) or _MockResult()
|
||||
)
|
||||
)
|
||||
|
||||
await list_scripts(
|
||||
parent_path="sub",
|
||||
owner_user_id="bob",
|
||||
context=_ctx("alice", is_admin=True),
|
||||
session=mock_session,
|
||||
)
|
||||
sql = captured_sql[0].lower()
|
||||
assert "like 'workspace/bob/sub/%%'" in sql
|
||||
assert "scripts.visibility in ('workspace', 'public')" not in sql
|
||||
|
||||
|
||||
async def test_list_workspace_directories_where_clause_escapes_pattern() -> None:
|
||||
"""list_workspace_directories must escape user input too (was
|
||||
pre-existing debt)."""
|
||||
@@ -351,11 +440,21 @@ async def test_list_workspace_directories_where_clause_escapes_pattern() -> None
|
||||
)
|
||||
|
||||
await list_workspace_directories(
|
||||
parent_path="foo_bar", context=_ctx("alice"), session=mock_session
|
||||
parent_path="foo_bar", owner_user_id=None,
|
||||
context=_ctx("alice"), session=mock_session,
|
||||
)
|
||||
sql = " ".join(captured_sql)
|
||||
assert r"workspace/alice/foo\\_bar/" in sql, sql
|
||||
|
||||
# owner_user_id scopes the prefix to that owner's subtree.
|
||||
captured_sql.clear()
|
||||
await list_workspace_directories(
|
||||
parent_path="foo_bar", owner_user_id="bob",
|
||||
context=_ctx("alice"), session=mock_session,
|
||||
)
|
||||
sql = " ".join(captured_sql)
|
||||
assert r"workspace/bob/foo\\_bar/" in sql, sql
|
||||
|
||||
|
||||
# ─── layer 3: behavioral test on real LIKE execution ──────────────
|
||||
|
||||
|
||||
@@ -573,6 +573,7 @@ async def test_list_resources_where_clause_uses_like_prefix_and_excludes_deeper(
|
||||
|
||||
await list_resources(
|
||||
parent_path="foo/bar",
|
||||
owner_user_id=None,
|
||||
context=_resource_ctx(),
|
||||
session=mock_session,
|
||||
visibility=None,
|
||||
@@ -581,9 +582,10 @@ async def test_list_resources_where_clause_uses_like_prefix_and_excludes_deeper(
|
||||
|
||||
assert len(captured_sql) == 1
|
||||
sql = captured_sql[0].lower()
|
||||
# 中间 ``%`` 是 owner 段通配符(跨所有 owner),父路径本身按字面匹配。
|
||||
assert "like 'w001/%%/foo/bar/%%'" in sql
|
||||
assert "not like 'w001/%%/foo/bar/%%/%%'" in sql
|
||||
# Default (no owner_user_id) scopes to the requester's own object_key
|
||||
# subtree: LIKE w001/u001/foo/bar/% (direct children), excluding deeper.
|
||||
assert "like 'w001/u001/foo/bar/%%'" in sql
|
||||
assert "not like 'w001/u001/foo/bar/%%/%%'" in sql
|
||||
|
||||
|
||||
async def test_list_resources_where_clause_escapes_underscore() -> None:
|
||||
@@ -596,6 +598,7 @@ async def test_list_resources_where_clause_escapes_underscore() -> None:
|
||||
|
||||
await list_resources(
|
||||
parent_path="foo_bar",
|
||||
owner_user_id=None,
|
||||
context=_resource_ctx(),
|
||||
session=mock_session,
|
||||
visibility=None,
|
||||
@@ -605,15 +608,20 @@ async def test_list_resources_where_clause_escapes_underscore() -> None:
|
||||
sql_lower = sql.lower()
|
||||
# SQLAlchemy doubles the escape char inside the SQL string literal, so
|
||||
# the helper's ``foo\_bar`` renders as ``foo\\_bar`` in the SQL text.
|
||||
assert r"like 'w001/%%/foo\\_bar/%%'" in sql_lower
|
||||
assert r"not like 'w001/%%/foo\\_bar/%%/%%'" in sql_lower
|
||||
assert r"like 'w001/u001/foo\\_bar/%%'" in sql_lower
|
||||
assert r"not like 'w001/u001/foo\\_bar/%%/%%'" in sql_lower
|
||||
# Both LIKE clauses declare ESCAPE '\\' (two in total).
|
||||
assert sql.count("ESCAPE '\\\\'") == 2, sql
|
||||
|
||||
|
||||
async def test_list_resources_without_parent_path_adds_no_like_clause() -> None:
|
||||
"""Empty parent_path keeps the legacy workspace-wide behaviour — no
|
||||
object_key LIKE filter at all."""
|
||||
async def test_list_resources_empty_parent_path_adds_root_like_clause() -> None:
|
||||
"""Empty parent_path still applies the directory filter (symmetric with
|
||||
list_scripts): ``{ws_id}/{owner}/%`` AND NOT ``{ws_id}/{owner}/%/%`` so
|
||||
the owner-scoped root view returns only direct children of the
|
||||
requester's root, never nested descendants. Skipping the filter for
|
||||
empty input used to surface nested resources at the root and visually
|
||||
broke the directory tree.
|
||||
"""
|
||||
from backend.api.resources import list_resources
|
||||
|
||||
captured_sql: list[str] = []
|
||||
@@ -621,14 +629,42 @@ async def test_list_resources_without_parent_path_adds_no_like_clause() -> None:
|
||||
|
||||
await list_resources(
|
||||
parent_path="",
|
||||
owner_user_id=None,
|
||||
context=_resource_ctx(),
|
||||
session=mock_session,
|
||||
visibility=None,
|
||||
keyword=None,
|
||||
)
|
||||
sql = captured_sql[0].lower()
|
||||
assert " like " not in sql
|
||||
assert " not like " not in sql
|
||||
assert " like 'w001/u001/%%'" in sql
|
||||
assert " not like 'w001/u001/%%/%%'" in sql
|
||||
|
||||
|
||||
async def test_list_resources_owner_param_scopes_to_other_owner() -> None:
|
||||
"""owner_user_id=<other> scopes object_key LIKE to that owner's subtree
|
||||
so the tree can lazily fetch another member's data resources on group
|
||||
expand. Non-admin visibility predicate is still applied, so the other
|
||||
owner's private resources are excluded (workspace/public only).
|
||||
"""
|
||||
from backend.api.resources import list_resources
|
||||
|
||||
captured_sql: list[str] = []
|
||||
mock_session = _list_resources_capturing_session(captured_sql)
|
||||
|
||||
await list_resources(
|
||||
parent_path="",
|
||||
owner_user_id="U002",
|
||||
context=_resource_ctx(),
|
||||
session=mock_session,
|
||||
visibility=None,
|
||||
keyword=None,
|
||||
)
|
||||
sql = captured_sql[0].lower()
|
||||
assert " like 'w001/u002/%%'" in sql
|
||||
assert " not like 'w001/u002/%%/%%'" in sql
|
||||
# non-admin: visibility predicate still present (excludes U002 private)
|
||||
assert "data_resources.owner_user_id = 'u001'" in sql
|
||||
assert "data_resources.visibility in ('workspace', 'public')" in sql
|
||||
|
||||
|
||||
async def test_list_resources_joins_users_for_display_name() -> None:
|
||||
@@ -641,6 +677,7 @@ async def test_list_resources_joins_users_for_display_name() -> None:
|
||||
|
||||
await list_resources(
|
||||
parent_path="",
|
||||
owner_user_id=None,
|
||||
context=_resource_ctx(),
|
||||
session=mock_session,
|
||||
visibility=None,
|
||||
@@ -745,11 +782,29 @@ def test_sqlite_parent_path_with_slash_direct_children(sqlite_object_key_table)
|
||||
assert matched == ["W001/U001/data/deep/nested.csv"], matched
|
||||
|
||||
|
||||
def test_sqlite_empty_parent_path_has_no_like_filter(sqlite_object_key_table) -> None:
|
||||
"""Empty parent_path → no LIKE filter → the endpoint's base WHERE only
|
||||
(workspace-wide active resources). Stand-in: every row is returned."""
|
||||
def test_sqlite_empty_parent_path_returns_root_level_across_owners(
|
||||
sqlite_object_key_table,
|
||||
) -> None:
|
||||
"""Empty parent_path now applies the root filter (symmetric with
|
||||
list_scripts): ``{ws_id}/%/%`` AND NOT ``{ws_id}/%/%/%`` returns only
|
||||
direct children of every owner's root, excluding nested descendants.
|
||||
Earlier 'no LIKE' behaviour used to surface every row in the
|
||||
workspace at the root, which is exactly what made scripts and data
|
||||
appear mutually visible and broke the tree.
|
||||
"""
|
||||
engine, table = sqlite_object_key_table
|
||||
like = "W001/%/%"
|
||||
not_like = "W001/%/%/%"
|
||||
with engine.connect() as conn:
|
||||
rows = conn.execute(select(table.c.object_key)).fetchall()
|
||||
rows = conn.execute(
|
||||
select(table.c.object_key).where(
|
||||
table.c.object_key.like(like, escape="\\"),
|
||||
~table.c.object_key.like(not_like, escape="\\"),
|
||||
)
|
||||
).fetchall()
|
||||
matched = sorted(r[0] for r in rows)
|
||||
assert len(matched) == 8, matched
|
||||
# ``W001/U001/root.csv`` is the only 3-segment path (= direct child
|
||||
# of the owner root); all 4+ segment paths (data/*, database/*) are
|
||||
# excluded by the NOT LIKE clause. The other 4+ segment files would
|
||||
# be returned when the user expands the corresponding subdirectory.
|
||||
assert matched == ["W001/U001/root.csv"], matched
|
||||
|
||||
@@ -60,6 +60,7 @@ export function ScriptExplorer({
|
||||
const loadingChildrenPaths = useScriptWorkspaceStore(
|
||||
(s) => s.loadingChildrenPaths,
|
||||
);
|
||||
const members = useScriptWorkspaceStore((s) => s.members);
|
||||
const onToggle = useScriptWorkspaceStore((s) => s.toggleExpanded);
|
||||
|
||||
const dataByOwner = useMemo(() => {
|
||||
@@ -83,9 +84,15 @@ export function ScriptExplorer({
|
||||
item.visibility === "public",
|
||||
);
|
||||
|
||||
// 用工作区成员列表播种分组 —— 顶层"我 / user1 / user2 / …"折叠分组
|
||||
// 的来源。即使某成员尚未加载任何脚本/数据(默认折叠、点击才拉取),
|
||||
// 也作为空分组出现,保证目录树结构稳定可见(修"目录树结构消失")。
|
||||
const byOwner = new Map<string, ScriptItem[]>();
|
||||
// 当前用户的目录树即使没有脚本也要渲染,所以预置空组。
|
||||
if (user?.user_id) {
|
||||
for (const m of members) {
|
||||
if (!byOwner.has(m.user_id)) byOwner.set(m.user_id, []);
|
||||
}
|
||||
// 当前用户兜底(members 未就绪时仍渲染"我"的分组)。
|
||||
if (user?.user_id && !byOwner.has(user.user_id)) {
|
||||
byOwner.set(user.user_id, []);
|
||||
}
|
||||
for (const item of visibleScripts) {
|
||||
@@ -93,15 +100,17 @@ export function ScriptExplorer({
|
||||
list.push(item);
|
||||
byOwner.set(item.owner_user_id, list);
|
||||
}
|
||||
|
||||
// data-only owner(只有数据资源、没有 scripts 的用户)也要出现在分组里,
|
||||
// 因为 data resources 与 scripts 共享同一棵目录树。
|
||||
// data-only owner(只有数据资源、没有 scripts 的用户,且不在 members 列表
|
||||
// 里,如已移除成员遗留的资源)也要出现在分组里。
|
||||
for (const ownerUserId of dataByOwner.keys()) {
|
||||
if (!byOwner.has(ownerUserId)) {
|
||||
byOwner.set(ownerUserId, []);
|
||||
}
|
||||
}
|
||||
|
||||
// 成员 id → display_name 优先取 members 列表(最准)。
|
||||
const memberName = new Map(members.map((m) => [m.user_id, m.display_name]));
|
||||
|
||||
const groups: {
|
||||
user: AuthUser | null;
|
||||
scripts: ScriptItem[];
|
||||
@@ -110,9 +119,8 @@ export function ScriptExplorer({
|
||||
}[] = [];
|
||||
for (const [ownerUserId, groupScripts] of byOwner.entries()) {
|
||||
const groupDataResources = dataByOwner.get(ownerUserId) ?? [];
|
||||
// data-only owner(没有 scripts 的用户)回退到 data resources 的
|
||||
// owner_display_name,否则 data-only owner 前端只能显示 userId 末 6 位。
|
||||
const displayName =
|
||||
memberName.get(ownerUserId) ??
|
||||
groupScripts[0]?.owner_display_name ??
|
||||
groupDataResources[0]?.owner_display_name ??
|
||||
(ownerUserId === user?.user_id ? user?.display_name : null) ??
|
||||
@@ -129,14 +137,16 @@ export function ScriptExplorer({
|
||||
role_code: null,
|
||||
is_system_admin: false,
|
||||
} as AuthUser);
|
||||
const inferred = inferredDirectories(groupScripts);
|
||||
const inferred = inferredDirectories(groupScripts, ownerUserId);
|
||||
// directories flat 数组现在按 owner_user_id 标记,按 owner 切分后与
|
||||
// inferred 合并(inferred 补全 fetched 目录行未覆盖的祖先路径)。
|
||||
const ownerDirs = directories.filter(
|
||||
(d) => d.owner_user_id === ownerUserId,
|
||||
);
|
||||
groups.push({
|
||||
user: groupUser,
|
||||
scripts: groupScripts,
|
||||
directories:
|
||||
ownerUserId === user?.user_id
|
||||
? mergeDirectories(directories, inferred)
|
||||
: inferred,
|
||||
directories: mergeDirectories(ownerDirs, inferred),
|
||||
dataResources: groupDataResources,
|
||||
});
|
||||
}
|
||||
@@ -144,18 +154,19 @@ export function ScriptExplorer({
|
||||
groups.sort((a, b) => {
|
||||
if (a.user?.user_id === user?.user_id) return -1;
|
||||
if (b.user?.user_id === user?.user_id) return 1;
|
||||
return (a.user?.user_id ?? "").localeCompare(b.user?.user_id ?? "");
|
||||
return (a.user?.display_name ?? a.user?.user_id ?? "").localeCompare(
|
||||
b.user?.display_name ?? b.user?.user_id ?? "",
|
||||
);
|
||||
});
|
||||
|
||||
return groups;
|
||||
}, [filteredScripts, directories, user, dataByOwner]);
|
||||
}, [filteredScripts, directories, user, dataByOwner, members]);
|
||||
|
||||
return (
|
||||
<aside className="explorer">
|
||||
<div className="explorer__header">
|
||||
<div>
|
||||
<h2>脚本目录</h2>
|
||||
<span>{scripts.length + dataResources.length} 个工作副本</span>
|
||||
</div>
|
||||
<div className="explorer__actions">
|
||||
<button
|
||||
@@ -217,6 +228,7 @@ export function ScriptExplorer({
|
||||
<WorkspaceTreeGroup
|
||||
key={ownerKey}
|
||||
groupKey={`__group__${ownerKey}`}
|
||||
ownerUserId={ownerKey}
|
||||
title={`${group.user?.display_name}`}
|
||||
scripts={group.scripts}
|
||||
directories={group.directories}
|
||||
@@ -272,14 +284,22 @@ function ownedScriptPath(item: ScriptItem) {
|
||||
return item.relative_path.replaceAll("\\", "/").split("/").slice(2).join("/");
|
||||
}
|
||||
|
||||
function inferredDirectories(items: ScriptItem[]): WorkspaceDirectory[] {
|
||||
function inferredDirectories(
|
||||
items: ScriptItem[],
|
||||
ownerUserId: string,
|
||||
): WorkspaceDirectory[] {
|
||||
const result = new Map<string, WorkspaceDirectory>();
|
||||
for (const item of items) {
|
||||
const parts = parentOf(ownedScriptPath(item)).split("/").filter(Boolean);
|
||||
let parentPath = "";
|
||||
for (const name of parts) {
|
||||
const path = parentPath ? `${parentPath}/${name}` : name;
|
||||
result.set(path, { path, name, parent_path: parentPath });
|
||||
result.set(path, {
|
||||
path,
|
||||
name,
|
||||
parent_path: parentPath,
|
||||
owner_user_id: ownerUserId,
|
||||
});
|
||||
parentPath = path;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,7 +221,8 @@ export function useApi(): WorkspaceBoundApi {
|
||||
const workspaceId = currentWorkspace?.workspace_id ?? "";
|
||||
|
||||
return useMemo<WorkspaceBoundApi>(() => ({
|
||||
listScripts: (parentPath) => rawApi.listScripts(workspaceId, parentPath),
|
||||
listScripts: (parentPath, ownerUserId) =>
|
||||
rawApi.listScripts(workspaceId, parentPath, ownerUserId),
|
||||
countScripts: () => rawApi.countScripts(workspaceId),
|
||||
listResources: (parentPath, opts) =>
|
||||
rawApi.listResources(workspaceId, parentPath, opts),
|
||||
@@ -241,8 +242,8 @@ export function useApi(): WorkspaceBoundApi {
|
||||
deleteScript: (scriptId) => rawApi.deleteScript(workspaceId, scriptId),
|
||||
setScriptLock: (scriptId, isLocked) =>
|
||||
rawApi.setScriptLock(workspaceId, scriptId, isLocked),
|
||||
listWorkspaceDirectories: (parentPath?: string) =>
|
||||
rawApi.listWorkspaceDirectories(workspaceId, parentPath ?? ""),
|
||||
listWorkspaceDirectories: (parentPath, ownerUserId) =>
|
||||
rawApi.listWorkspaceDirectories(workspaceId, parentPath ?? "", ownerUserId),
|
||||
createWorkspaceDirectory: (directoryName, parentPath) =>
|
||||
rawApi.createWorkspaceDirectory(workspaceId, directoryName, parentPath),
|
||||
deleteWorkspaceDirectory: (path) =>
|
||||
|
||||
@@ -28,8 +28,11 @@ type WorkspaceTreeProps = {
|
||||
onCopyResourcePath?: (jupyterPath: string) => void;
|
||||
// 唯一标识此 group(通常 `__group__<owner_user_id>`),让多个 owner 的 group 各自独立展开。
|
||||
groupKey: string;
|
||||
// 该 group 所属 owner 的 user_id —— 透传给 store.toggleExpanded,使他人
|
||||
// 分组展开时走 loadOwnerGroup / 带 owner 的 loadScripts(懒加载)。
|
||||
ownerUserId: string;
|
||||
expandedPaths: Set<string>;
|
||||
onToggle: (path: string, loadPath?: string) => void;
|
||||
onToggle: (path: string, loadPath?: string, ownerUserId?: string) => void;
|
||||
loadingChildrenPaths: Set<string>;
|
||||
};
|
||||
|
||||
@@ -79,6 +82,7 @@ export function WorkspaceTreeGroup({
|
||||
dataResources,
|
||||
onCopyResourcePath,
|
||||
groupKey,
|
||||
ownerUserId,
|
||||
expandedPaths,
|
||||
onToggle,
|
||||
loadingChildrenPaths,
|
||||
@@ -125,14 +129,16 @@ export function WorkspaceTreeGroup({
|
||||
parent_path: path.includes("/")
|
||||
? path.split("/").slice(0, -1).join("/")
|
||||
: "",
|
||||
owner_user_id: ownerUserId,
|
||||
}));
|
||||
}, [dataResources]);
|
||||
}, [dataResources, ownerUserId]);
|
||||
|
||||
// 首次挂载自动展开(保留原本 useState(true) 的默认展开行为)。
|
||||
// toggleExpanded 内识别 `__group__` 前缀,不会触发 loadChildren。
|
||||
// 默认只展开"我"的分组(!readOnly);其他成员分组默认折叠,点击才
|
||||
// 按需拉取其可见内容(懒加载设计)。原本对所有 group 无条件 onToggle
|
||||
// 会让所有 owner 的内容在根加载时就被全量拉取,违背"默认只拉取自己的一级"。
|
||||
useEffect(() => {
|
||||
if (!expandedPaths.has(groupKey)) {
|
||||
void onToggle(groupKey);
|
||||
if (!readOnly && !expandedPaths.has(groupKey)) {
|
||||
void onToggle(groupKey, undefined, ownerUserId);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [groupKey]);
|
||||
@@ -142,7 +148,7 @@ export function WorkspaceTreeGroup({
|
||||
className={`tree-group__title${open ? " is-open" : ""}`}
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={() => onToggle(groupKey)}
|
||||
onClick={() => onToggle(groupKey, undefined, ownerUserId)}
|
||||
onContextMenu={onContextMenu
|
||||
? (event) => onContextMenu(event, { kind: "root", path: "" })
|
||||
: undefined}
|
||||
@@ -150,12 +156,12 @@ export function WorkspaceTreeGroup({
|
||||
<Icon name="chevron" size={14} />
|
||||
<Icon name="folder" size={17} />
|
||||
<span>{title}</span>
|
||||
<em>{scripts.length + (dataResources ?? []).length}</em>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="tree-group__items">
|
||||
<WorkspaceTreeItems
|
||||
groupKey={groupKey}
|
||||
ownerUserId={ownerUserId}
|
||||
path=""
|
||||
depth={0}
|
||||
scripts={[...scripts, ...dataResourceScripts]}
|
||||
@@ -186,6 +192,7 @@ export function WorkspaceTreeGroup({
|
||||
|
||||
function WorkspaceTreeItems({
|
||||
groupKey,
|
||||
ownerUserId,
|
||||
path,
|
||||
depth,
|
||||
scripts,
|
||||
@@ -210,6 +217,7 @@ function WorkspaceTreeItems({
|
||||
<DirectoryBranch
|
||||
key={directory.path}
|
||||
groupKey={groupKey}
|
||||
ownerUserId={ownerUserId}
|
||||
directory={directory}
|
||||
depth={depth}
|
||||
scripts={scripts}
|
||||
@@ -271,6 +279,7 @@ function WorkspaceTreeItems({
|
||||
|
||||
function DirectoryBranch({
|
||||
groupKey,
|
||||
ownerUserId,
|
||||
directory,
|
||||
depth,
|
||||
scripts,
|
||||
@@ -294,7 +303,7 @@ function DirectoryBranch({
|
||||
className="directory-row"
|
||||
style={{ paddingLeft: 10 + depth * 16 }}
|
||||
type="button"
|
||||
onClick={() => onToggle(expandKey, directory.path)}
|
||||
onClick={() => onToggle(expandKey, directory.path, ownerUserId)}
|
||||
onContextMenu={onContextMenu
|
||||
? (event) => onContextMenu(event, {
|
||||
kind: "directory",
|
||||
@@ -312,6 +321,7 @@ function DirectoryBranch({
|
||||
{open && (
|
||||
<WorkspaceTreeItems
|
||||
groupKey={groupKey}
|
||||
ownerUserId={ownerUserId}
|
||||
path={directory.path}
|
||||
depth={depth + 1}
|
||||
scripts={scripts}
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
Visibility,
|
||||
WorkspaceBoundApi,
|
||||
WorkspaceDirectory,
|
||||
WorkspaceMember,
|
||||
} from "~/services/api";
|
||||
|
||||
import type { NewScriptForm } from "./uiStore";
|
||||
@@ -41,11 +42,35 @@ let _previewController: AbortController | null = null;
|
||||
let _previewRequest = 0;
|
||||
let _pythonEditorOpeningIds = new Set<string>();
|
||||
let _scriptCountSeq = 0;
|
||||
// 当前登录用户 id —— 与 `_api` 一样由 layout 在 render body 绑定。
|
||||
// 用于:(1) `loadScripts`/`loadOwnerGroup` 区分"我"与他人;
|
||||
// (2) `toggleExpanded` 判定展开真实目录时是否需要 loadChildren(他人的
|
||||
// 目录全靠脚本路径推断,不调 listWorkspaceDirectories)。
|
||||
let _currentUserId: string | null = null;
|
||||
// 当前工作区 id —— listWorkspaceMembers 不像 listScripts 那样把 workspaceId
|
||||
// 烤进 bound api,需要显式传入,故由 layout 绑定。
|
||||
let _workspaceId: string | null = null;
|
||||
|
||||
export const bindScriptWorkspaceApi = (api: WorkspaceBoundApi | null) => {
|
||||
_api = api;
|
||||
};
|
||||
|
||||
export const bindScriptWorkspaceUser = (userId: string | null) => {
|
||||
_currentUserId = userId;
|
||||
};
|
||||
|
||||
export const bindScriptWorkspaceId = (workspaceId: string | null) => {
|
||||
_workspaceId = workspaceId;
|
||||
};
|
||||
|
||||
// `loadedScriptPaths` / `loadedChildPaths` 的 key 命名空间:
|
||||
// `${owner_user_id}:${parent_path}`,让"我"与他人的同名子目录缓存互不串扰。
|
||||
// owner 缺省时回退当前用户,再回退字面量 "me"(仅作占位 key,不会发到后端)。
|
||||
function ownerCacheKey(ownerUserId: string | undefined, path: string): string {
|
||||
const owner = ownerUserId ?? _currentUserId ?? "me";
|
||||
return `${owner}:${path}`;
|
||||
}
|
||||
|
||||
export const getSessionCache = () => sessionCache;
|
||||
export const clearSessionCache = () => {
|
||||
sessionCache.clear();
|
||||
@@ -86,10 +111,9 @@ type State = {
|
||||
expandedPaths: Set<string>;
|
||||
loadingChildrenPaths: Set<string>;
|
||||
loadedChildPaths: Set<string>;
|
||||
// Per-parent-path script cache. Keys are user-relative parent paths;
|
||||
// values are scripts whose storage path lives directly under that parent.
|
||||
// The flat `scripts` array above is the union (deduped by script_id) of
|
||||
// every cache entry that has been loaded in this session.
|
||||
// Per-owner × parent-path script cache. Keys are namespaced
|
||||
// `${owner_user_id}:${parent_path}` (see ownerCacheKey) so "我"与他人
|
||||
// 的同名子目录互不串扰。flat `scripts` 数组是其并集(按 script_id 去重)。
|
||||
loadedScriptPaths: Set<string>;
|
||||
loadingScriptPaths: Set<string>;
|
||||
// Workspace-wide active-script total — separate from the lazy-loaded
|
||||
@@ -98,6 +122,14 @@ type State = {
|
||||
scriptCount: number | null;
|
||||
scriptCountLoading: boolean;
|
||||
|
||||
// 工作区成员列表 —— 目录树顶层"我 / user1 / user2 / …"折叠分组的来源。
|
||||
// 默认只加载"我"的一级目录;其他成员分组折叠,点击才按需拉取
|
||||
// 其可见(workspace/public)内容、排除其 private。
|
||||
members: WorkspaceMember[];
|
||||
// 已拉取根级内容的 owner(loadOwnerGroup 标记),避免重复拉取。
|
||||
loadedOwnerGroups: Set<string>;
|
||||
loadingOwnerGroups: Set<string>;
|
||||
|
||||
// 只读内容刷新版本号(用于触发已打开标签页的内容刷新)
|
||||
readOnlyRefreshVersion: number;
|
||||
|
||||
@@ -106,7 +138,7 @@ type State = {
|
||||
setKeyword: (keyword: string) => void;
|
||||
reset: () => void;
|
||||
load: (silent?: boolean) => Promise<void>;
|
||||
loadDataResources: (parentPath?: string) => Promise<void>;
|
||||
loadDataResources: (parentPath?: string, ownerUserId?: string) => Promise<void>;
|
||||
selectScript: (id: string | null) => void;
|
||||
openTab: (id: string) => void;
|
||||
closeTab: (id: string, event?: { stopPropagation: () => void }) => Promise<void>;
|
||||
@@ -132,11 +164,16 @@ type State = {
|
||||
deleteScript: (script: ScriptItem) => Promise<void>;
|
||||
deleteDataResource: (resourceId: string) => Promise<void>;
|
||||
deleteDirectory: (path: string) => Promise<void>;
|
||||
toggleExpanded: (path: string, loadPath?: string) => Promise<void>;
|
||||
loadChildren: (parentPath: string) => Promise<void>;
|
||||
// Lazy-load scripts directly under `parentPath`. Idempotent — repeated
|
||||
// calls for an already-loaded path are no-ops; in-flight calls dedupe.
|
||||
loadScripts: (parentPath: string) => Promise<void>;
|
||||
toggleExpanded: (path: string, loadPath?: string, ownerUserId?: string) => Promise<void>;
|
||||
loadChildren: (parentPath: string, ownerUserId?: string) => Promise<void>;
|
||||
// Lazy-load scripts directly under `parentPath` for `ownerUserId`(缺省
|
||||
// = 当前用户)。Idempotent — repeated calls for an already-loaded
|
||||
// owner×path are no-ops; in-flight calls dedupe.
|
||||
loadScripts: (parentPath: string, ownerUserId?: string) => Promise<void>;
|
||||
// 按需拉取某成员的根级可见脚本 + 数据资源(点击其折叠分组时触发)。
|
||||
// Always fetches (refresh-safe); toggleExpanded 的分组头分支负责守门
|
||||
// 避免重复拉取,loadedOwnerGroups 标记已加载状态。
|
||||
loadOwnerGroup: (ownerUserId: string) => Promise<void>;
|
||||
// Fetch the workspace-wide active-script total. Cheap; the dashboard
|
||||
// uses this for its hero count so it doesn't depend on lazy-loaded state.
|
||||
loadScriptCount: () => Promise<void>;
|
||||
@@ -227,6 +264,10 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
|
||||
scriptCount: null,
|
||||
scriptCountLoading: false,
|
||||
|
||||
members: [],
|
||||
loadedOwnerGroups: new Set<string>(),
|
||||
loadingOwnerGroups: new Set<string>(),
|
||||
|
||||
readOnlyRefreshVersion: 0,
|
||||
|
||||
setApiOnline: (online) => set({ apiOnline: online }),
|
||||
@@ -269,6 +310,9 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
|
||||
loadingScriptPaths: new Set<string>(),
|
||||
scriptCount: null,
|
||||
scriptCountLoading: false,
|
||||
members: [],
|
||||
loadedOwnerGroups: new Set<string>(),
|
||||
loadingOwnerGroups: new Set<string>(),
|
||||
});
|
||||
},
|
||||
|
||||
@@ -277,54 +321,86 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
|
||||
if (!silent) set({ loading: true });
|
||||
set({ refreshing: silent });
|
||||
try {
|
||||
// Re-fetch every path currently in the cache. On initial mount the
|
||||
// cache is empty so this degrades to a single root fetch; on
|
||||
// toolbar refresh / after createFolder / after deleteScript the
|
||||
// previously-expanded folders are also re-fetched so the UI stays
|
||||
// consistent (otherwise the cache would keep stale "loaded"
|
||||
// markers while the corresponding scripts had been replaced by
|
||||
// the root-only payload, leaving subfolders empty on re-expand).
|
||||
const cachedScriptPaths = Array.from(get().loadedScriptPaths);
|
||||
const cachedChildPaths = Array.from(get().loadedChildPaths).filter(
|
||||
(p) => p !== "",
|
||||
const me = _currentUserId;
|
||||
// 拉取工作区成员列表 —— 顶层"我 / user1 / user2 / …"折叠分组的来源。
|
||||
const memberList =
|
||||
_workspaceId != null
|
||||
? await api
|
||||
.listWorkspaceMembers(_workspaceId)
|
||||
.catch(() => [] as WorkspaceMember[])
|
||||
: [];
|
||||
// 只重新拉取"我"的已缓存脚本路径(含根)。首次挂载缓存为空 → 退化为
|
||||
// 单次根级拉取。他人脚本不动(保留在 flat scripts 里,见下方合并)。
|
||||
const myCachedScriptKeys = Array.from(get().loadedScriptPaths).filter(
|
||||
(k) => k.startsWith(`${me}:`) || k.startsWith("me:"),
|
||||
);
|
||||
const rootScriptKey = ownerCacheKey(undefined, "");
|
||||
const scriptFetches =
|
||||
cachedScriptPaths.length > 0
|
||||
? cachedScriptPaths.map((p) =>
|
||||
api.listScripts(p).catch(() => [] as ScriptItem[]),
|
||||
)
|
||||
: [api.listScripts("")];
|
||||
const dirFetches = [
|
||||
api.listWorkspaceDirectories(""),
|
||||
...cachedChildPaths.map((p) =>
|
||||
api.listWorkspaceDirectories(p).catch(() => [] as WorkspaceDirectory[]),
|
||||
),
|
||||
];
|
||||
myCachedScriptKeys.length > 0
|
||||
? myCachedScriptKeys.map((k) => {
|
||||
const p = k.slice(k.indexOf(":") + 1);
|
||||
return api.listScripts(p).catch(() => [] as ScriptItem[]);
|
||||
})
|
||||
: [api.listScripts("").catch(() => [] as ScriptItem[])];
|
||||
// 只重新拉取"我"的已缓存目录路径(含根)。他人目录不动(保留在
|
||||
// flat directories 里,按 (owner,path) 去重合并)。
|
||||
const myCachedDirKeys = Array.from(get().loadedChildPaths).filter(
|
||||
(k) => (me != null && k.startsWith(`${me}:`)) || k.startsWith("me:"),
|
||||
);
|
||||
const rootDirKey = ownerCacheKey(undefined, "");
|
||||
const dirFetches =
|
||||
myCachedDirKeys.length > 0
|
||||
? myCachedDirKeys.map((k) => {
|
||||
const p = k.slice(k.indexOf(":") + 1);
|
||||
return api
|
||||
.listWorkspaceDirectories(p)
|
||||
.catch(() => [] as WorkspaceDirectory[]);
|
||||
})
|
||||
: [api
|
||||
.listWorkspaceDirectories("")
|
||||
.catch(() => [] as WorkspaceDirectory[])];
|
||||
const [scriptLists, dirLists] = await Promise.all([
|
||||
Promise.all(scriptFetches),
|
||||
Promise.all(dirFetches),
|
||||
]);
|
||||
const freshScripts = scriptLists.flat();
|
||||
const freshDirs = dirLists.flat();
|
||||
// Dedup: later occurrences win so fresh per-path payloads override
|
||||
// any duplicates coming through different fetch slots.
|
||||
const myFreshScripts = scriptLists.flat();
|
||||
// "我"的脚本用 fresh 集合替换;他人脚本原样保留(按 script_id 去重合并)。
|
||||
const otherScripts = get().scripts.filter(
|
||||
(s) => s.owner_user_id !== me,
|
||||
);
|
||||
const dedupedScripts = Array.from(
|
||||
new Map(freshScripts.map((s) => [s.script_id, s])).values(),
|
||||
new Map(
|
||||
[...otherScripts, ...myFreshScripts].map((s) => [s.script_id, s]),
|
||||
).values(),
|
||||
);
|
||||
// "我"的目录用 fresh 集合替换;他人目录原样保留(按 (owner,path) 去重)。
|
||||
const otherDirs = get().directories.filter(
|
||||
(d) => d.owner_user_id !== me,
|
||||
);
|
||||
const dedupedDirs = Array.from(
|
||||
new Map(freshDirs.map((d) => [d.path, d])).values(),
|
||||
new Map(
|
||||
[...otherDirs, ...dirLists.flat()].map((d) => [
|
||||
`${d.owner_user_id}:${d.path}`,
|
||||
d,
|
||||
]),
|
||||
).values(),
|
||||
);
|
||||
const nextLoadedScripts = new Set(cachedScriptPaths);
|
||||
nextLoadedScripts.add("");
|
||||
const nextLoadedScripts = new Set(myCachedScriptKeys);
|
||||
nextLoadedScripts.add(rootScriptKey);
|
||||
const nextLoadedChildren = new Set(get().loadedChildPaths);
|
||||
nextLoadedChildren.add("");
|
||||
nextLoadedChildren.add(rootDirKey);
|
||||
set({
|
||||
scripts: dedupedScripts,
|
||||
directories: dedupedDirs,
|
||||
members: memberList,
|
||||
apiOnline: true,
|
||||
loadedScriptPaths: nextLoadedScripts,
|
||||
loadedChildPaths: nextLoadedChildren,
|
||||
});
|
||||
// 刷新已展开的其他成员分组(loadOwnerGroup 总是发起请求,刷新安全)。
|
||||
for (const owner of get().loadedOwnerGroups) {
|
||||
if (owner !== me) void get().loadOwnerGroup(owner);
|
||||
}
|
||||
const validIds = new Set(dedupedScripts.map((item) => item.script_id));
|
||||
const currentSelected = get().selectedId;
|
||||
if (!currentSelected || !validIds.has(currentSelected)) {
|
||||
@@ -347,33 +423,37 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
|
||||
}
|
||||
},
|
||||
|
||||
loadScripts: async (parentPath) => {
|
||||
loadScripts: async (parentPath, ownerUserId) => {
|
||||
const api = requireApi();
|
||||
if (get().loadedScriptPaths.has(parentPath)) return;
|
||||
// Dedupe in-flight requests for the same path.
|
||||
if (get().loadingScriptPaths.has(parentPath)) return;
|
||||
const cacheKey = ownerCacheKey(ownerUserId, parentPath);
|
||||
if (get().loadedScriptPaths.has(cacheKey)) return;
|
||||
// Dedupe in-flight requests for the same owner×path.
|
||||
if (get().loadingScriptPaths.has(cacheKey)) return;
|
||||
const next = new Set(get().loadingScriptPaths);
|
||||
next.add(parentPath);
|
||||
next.add(cacheKey);
|
||||
set({ loadingScriptPaths: next });
|
||||
try {
|
||||
const items = await api.listScripts(parentPath);
|
||||
const items = await api.listScripts(parentPath, ownerUserId);
|
||||
set((state) => {
|
||||
const existingIds = new Set(state.scripts.map((s) => s.script_id));
|
||||
const fresh = items.filter((s) => !existingIds.has(s.script_id));
|
||||
// append-only 合并(按 script_id 去重,fresh 覆盖 stale 同 id 值)。
|
||||
// 该 owner×path 的全量刷新由 load()(我)/ loadOwnerGroup(他人)
|
||||
// 负责 drop-by-owner 后重并入;这里是子目录展开,append 即可。
|
||||
const byId = new Map(state.scripts.map((s) => [s.script_id, s]));
|
||||
for (const item of items) byId.set(item.script_id, item);
|
||||
const nextLoaded = new Set(state.loadedScriptPaths);
|
||||
nextLoaded.add(parentPath);
|
||||
nextLoaded.add(cacheKey);
|
||||
return {
|
||||
scripts: [...state.scripts, ...fresh],
|
||||
scripts: Array.from(byId.values()),
|
||||
loadedScriptPaths: nextLoaded,
|
||||
loadingScriptPaths: new Set(
|
||||
[...state.loadingScriptPaths].filter((p) => p !== parentPath),
|
||||
[...state.loadingScriptPaths].filter((p) => p !== cacheKey),
|
||||
),
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
set((state) => ({
|
||||
loadingScriptPaths: new Set(
|
||||
[...state.loadingScriptPaths].filter((p) => p !== parentPath),
|
||||
[...state.loadingScriptPaths].filter((p) => p !== cacheKey),
|
||||
),
|
||||
}));
|
||||
pushToast(
|
||||
@@ -383,14 +463,96 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
|
||||
}
|
||||
},
|
||||
|
||||
loadDataResources: async (parentPath = "") => {
|
||||
loadOwnerGroup: async (ownerUserId) => {
|
||||
const api = requireApi();
|
||||
if (get().loadingOwnerGroups.has(ownerUserId)) return;
|
||||
const nextLoading = new Set(get().loadingOwnerGroups);
|
||||
nextLoading.add(ownerUserId);
|
||||
set({ loadingOwnerGroups: nextLoading });
|
||||
try {
|
||||
const [scripts, resources, directories] = await Promise.all([
|
||||
api.listScripts("", ownerUserId).catch(() => [] as ScriptItem[]),
|
||||
api.listResources("", { ownerUserId }).catch(() => [] as ResourceItem[]),
|
||||
api.listWorkspaceDirectories("", ownerUserId).catch(
|
||||
() => [] as WorkspaceDirectory[],
|
||||
),
|
||||
]);
|
||||
set((state) => {
|
||||
// 丢弃该 owner 的旧脚本/资源/目录(按 owner 过滤后保留他人),再并入 fresh。
|
||||
const keptScripts = state.scripts.filter(
|
||||
(s) => s.owner_user_id !== ownerUserId,
|
||||
);
|
||||
const keptResources = state.dataResources.filter(
|
||||
(r) => r.owner_user_id !== ownerUserId,
|
||||
);
|
||||
const keptDirs = state.directories.filter(
|
||||
(d) => d.owner_user_id !== ownerUserId,
|
||||
);
|
||||
const scriptIds = new Set(keptScripts.map((s) => s.script_id));
|
||||
const freshScripts = scripts.filter((s) => !scriptIds.has(s.script_id));
|
||||
const resourceIds = new Set(keptResources.map((r) => r.resource_id));
|
||||
const freshResources = resources.filter(
|
||||
(r) => !resourceIds.has(r.resource_id),
|
||||
);
|
||||
const dirIds = new Set(
|
||||
keptDirs.map((d) => `${d.owner_user_id}:${d.path}`),
|
||||
);
|
||||
const freshDirs = directories.filter(
|
||||
(d) => !dirIds.has(`${d.owner_user_id}:${d.path}`),
|
||||
);
|
||||
const nextLoaded = new Set(state.loadedOwnerGroups);
|
||||
nextLoaded.add(ownerUserId);
|
||||
const nextScriptPaths = new Set(state.loadedScriptPaths);
|
||||
nextScriptPaths.add(ownerCacheKey(ownerUserId, ""));
|
||||
const nextChildPaths = new Set(state.loadedChildPaths);
|
||||
nextChildPaths.add(ownerCacheKey(ownerUserId, ""));
|
||||
return {
|
||||
scripts: [...keptScripts, ...freshScripts],
|
||||
dataResources: [...keptResources, ...freshResources],
|
||||
directories: [...keptDirs, ...freshDirs],
|
||||
loadedOwnerGroups: nextLoaded,
|
||||
loadedScriptPaths: nextScriptPaths,
|
||||
loadedChildPaths: nextChildPaths,
|
||||
loadingOwnerGroups: new Set(
|
||||
[...state.loadingOwnerGroups].filter((o) => o !== ownerUserId),
|
||||
),
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
set((state) => ({
|
||||
loadingOwnerGroups: new Set(
|
||||
[...state.loadingOwnerGroups].filter((o) => o !== ownerUserId),
|
||||
),
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
loadDataResources: async (parentPath = "", ownerUserId) => {
|
||||
const api = requireApi();
|
||||
set({ dataResourcesLoading: true });
|
||||
try {
|
||||
const list = await api.listResources(parentPath);
|
||||
set({ dataResources: Array.isArray(list) ? list : [] });
|
||||
const list = await api.listResources(parentPath, { ownerUserId });
|
||||
const fresh = Array.isArray(list) ? list : [];
|
||||
set((state) => {
|
||||
// 按 owner 范围合并:丢弃该 owner 的旧资源再并入 fresh(fresh 覆盖
|
||||
// 同 id)。owner 缺省=我,故根加载/刷新我的数据时替换我的一级资源。
|
||||
const targetOwner = ownerUserId ?? _currentUserId ?? null;
|
||||
const kept = state.dataResources.filter(
|
||||
(r) => r.owner_user_id !== targetOwner,
|
||||
);
|
||||
const byId = new Map(kept.map((r) => [r.resource_id, r]));
|
||||
for (const item of fresh) byId.set(item.resource_id, item);
|
||||
return { dataResources: Array.from(byId.values()) };
|
||||
});
|
||||
} catch {
|
||||
set({ dataResources: [] });
|
||||
set((state) => {
|
||||
const targetOwner = ownerUserId ?? _currentUserId ?? null;
|
||||
return {
|
||||
dataResources: state.dataResources.filter(
|
||||
(r) => r.owner_user_id !== targetOwner,
|
||||
),
|
||||
};
|
||||
});
|
||||
} finally {
|
||||
set({ dataResourcesLoading: false });
|
||||
}
|
||||
@@ -420,32 +582,40 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
|
||||
}
|
||||
},
|
||||
|
||||
loadChildren: async (parentPath) => {
|
||||
loadChildren: async (parentPath, ownerUserId) => {
|
||||
const api = requireApi();
|
||||
if (get().loadedChildPaths.has(parentPath)) return;
|
||||
const cacheKey = ownerCacheKey(ownerUserId, parentPath);
|
||||
if (get().loadedChildPaths.has(cacheKey)) return;
|
||||
const next = new Set(get().loadingChildrenPaths);
|
||||
next.add(parentPath);
|
||||
next.add(cacheKey);
|
||||
set({ loadingChildrenPaths: next });
|
||||
try {
|
||||
const children = await api.listWorkspaceDirectories(parentPath);
|
||||
const children = await api.listWorkspaceDirectories(parentPath, ownerUserId);
|
||||
set((state) => {
|
||||
const nextLoaded = new Set(state.loadedChildPaths);
|
||||
nextLoaded.add(parentPath);
|
||||
nextLoaded.add(cacheKey);
|
||||
// 丢弃该 owner 该 parent 下的旧目录行,再并入 fresh(按 (owner,path) 去重)。
|
||||
const targetOwner = ownerUserId ?? _currentUserId ?? null;
|
||||
const trimmed = state.directories.filter(
|
||||
(d) => d.parent_path !== parentPath,
|
||||
(d) =>
|
||||
!(d.owner_user_id === targetOwner && d.parent_path === parentPath),
|
||||
);
|
||||
const byId = new Map(
|
||||
trimmed.map((d) => [`${d.owner_user_id}:${d.path}`, d]),
|
||||
);
|
||||
for (const c of children) byId.set(`${c.owner_user_id}:${c.path}`, c);
|
||||
return {
|
||||
directories: [...trimmed, ...children],
|
||||
directories: Array.from(byId.values()),
|
||||
loadedChildPaths: nextLoaded,
|
||||
loadingChildrenPaths: new Set(
|
||||
[...state.loadingChildrenPaths].filter((p) => p !== parentPath),
|
||||
[...state.loadingChildrenPaths].filter((p) => p !== cacheKey),
|
||||
),
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
set((state) => ({
|
||||
loadingChildrenPaths: new Set(
|
||||
[...state.loadingChildrenPaths].filter((p) => p !== parentPath),
|
||||
[...state.loadingChildrenPaths].filter((p) => p !== cacheKey),
|
||||
),
|
||||
}));
|
||||
pushToast(
|
||||
@@ -455,7 +625,7 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
|
||||
}
|
||||
},
|
||||
|
||||
toggleExpanded: async (path, loadPath) => {
|
||||
toggleExpanded: async (path, loadPath, ownerUserId) => {
|
||||
const state = get();
|
||||
const isOpen = state.expandedPaths.has(path);
|
||||
const next = new Set(state.expandedPaths);
|
||||
@@ -463,16 +633,30 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
|
||||
next.delete(path);
|
||||
} else {
|
||||
next.add(path);
|
||||
// group key (`__group__<owner_id>`) 不是 workspace 路径,不调 loadChildren / loadScripts
|
||||
const actualLoadPath = loadPath ?? path;
|
||||
if (!actualLoadPath.startsWith("__group__")) {
|
||||
// Load both sub-directories and scripts directly under this folder
|
||||
// in parallel. Both are idempotent + cached; cheap when already loaded.
|
||||
if (!state.loadedChildPaths.has(actualLoadPath)) {
|
||||
void get().loadChildren(actualLoadPath);
|
||||
const me = _currentUserId;
|
||||
// 用 `loadPath === undefined` 区分分组头与真实目录,而不是用
|
||||
// `path.startsWith("__group__")`:目录的 expandKey 是
|
||||
// `${groupKey}/${dir.path}` 即 `__group__<owner>/dir`,同样以
|
||||
// `__group__` 开头,前缀判断会把子目录点击误当成分组头,导致
|
||||
// 既不调 loadChildren 也不调 loadScripts("子目录点击不触发接口")。
|
||||
// 分组头 always 传 loadPath=undefined;目录 always 传 loadPath=dir.path。
|
||||
if (loadPath === undefined) {
|
||||
// 分组头:他人分组首次展开 → loadOwnerGroup 按需拉取其根级可见
|
||||
// 脚本+数据+目录(守门去重)。仅翻转 expand;真实子目录的懒加载
|
||||
// 由目录分支(loadPath !== undefined)负责。
|
||||
if (ownerUserId && ownerUserId !== me && !state.loadedOwnerGroups.has(ownerUserId)) {
|
||||
void get().loadOwnerGroup(ownerUserId);
|
||||
}
|
||||
if (!state.loadedScriptPaths.has(actualLoadPath)) {
|
||||
void get().loadScripts(actualLoadPath);
|
||||
} else {
|
||||
// 真实目录展开:loadChildren(owner 限定的显式目录行)+ loadScripts
|
||||
// 并行。两者都 idempotent + 缓存;ownerUserId 缺省=我。他人目录同样
|
||||
// 调 loadChildren(owner) 拉取其目录结构,否则嵌套子目录无法被发现
|
||||
// (list_scripts 非递归,只能看到直接子脚本)。
|
||||
if (!state.loadedChildPaths.has(ownerCacheKey(ownerUserId, loadPath))) {
|
||||
void get().loadChildren(loadPath, ownerUserId);
|
||||
}
|
||||
if (!state.loadedScriptPaths.has(ownerCacheKey(ownerUserId, loadPath))) {
|
||||
void get().loadScripts(loadPath, ownerUserId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1028,16 +1212,25 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
|
||||
if (parentPath === "") {
|
||||
await get().load(true);
|
||||
} else {
|
||||
const me = _currentUserId ?? "me";
|
||||
const parentKey = ownerCacheKey(me, parentPath);
|
||||
set((state) => {
|
||||
const nextLoaded = new Set(state.loadedChildPaths);
|
||||
for (const p of state.loadedChildPaths) {
|
||||
if (p.startsWith(`${parentPath}/`)) nextLoaded.delete(p);
|
||||
// 仅失效"我"在该 parent 之下的缓存(namespaced key)。
|
||||
if (p.startsWith(`${me}:`) && p.slice(me.length + 1).startsWith(`${parentPath}/`)) {
|
||||
nextLoaded.delete(p);
|
||||
}
|
||||
}
|
||||
nextLoaded.delete(parentPath);
|
||||
nextLoaded.delete(parentKey);
|
||||
return {
|
||||
loadedChildPaths: nextLoaded,
|
||||
directories: state.directories.filter(
|
||||
(d) => !d.parent_path.startsWith(`${parentPath}/`),
|
||||
(d) =>
|
||||
!(
|
||||
d.owner_user_id === me
|
||||
&& d.parent_path.startsWith(`${parentPath}/`)
|
||||
),
|
||||
),
|
||||
expandedPaths: new Set(state.expandedPaths),
|
||||
};
|
||||
@@ -1124,20 +1317,30 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
|
||||
) {
|
||||
get().selectScript(null);
|
||||
}
|
||||
const me = _currentUserId ?? "me";
|
||||
const pathKey = ownerCacheKey(me, path);
|
||||
set((state) => {
|
||||
const nextLoaded = new Set(state.loadedChildPaths);
|
||||
const nextExpanded = new Set(state.expandedPaths);
|
||||
for (const p of state.loadedChildPaths) {
|
||||
if (p === path || p.startsWith(`${path}/`)) nextLoaded.delete(p);
|
||||
// 仅失效"我"该 path 及其子目录的缓存(namespaced key)。
|
||||
if (!p.startsWith(`${me}:`)) continue;
|
||||
const bare = p.slice(me.length + 1);
|
||||
if (bare === path || bare.startsWith(`${path}/`)) nextLoaded.delete(p);
|
||||
}
|
||||
for (const p of state.expandedPaths) {
|
||||
if (p === path || p.startsWith(`${path}/`)) nextExpanded.delete(p);
|
||||
}
|
||||
void pathKey;
|
||||
return {
|
||||
loadedChildPaths: nextLoaded,
|
||||
expandedPaths: nextExpanded,
|
||||
directories: state.directories.filter(
|
||||
(d) => d.parent_path !== path && !d.parent_path.startsWith(`${path}/`),
|
||||
(d) =>
|
||||
!(
|
||||
d.owner_user_id === me
|
||||
&& (d.parent_path === path || d.parent_path.startsWith(`${path}/`))
|
||||
),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -9,6 +9,8 @@ import { useApi, useAuth } from "../context/AuthContext";
|
||||
import { useEditSessionLifecycle } from "../features/platform/hooks/useEditSessionLifecycle";
|
||||
import {
|
||||
bindScriptWorkspaceApi,
|
||||
bindScriptWorkspaceId,
|
||||
bindScriptWorkspaceUser,
|
||||
editSessionHandle,
|
||||
useScriptWorkspaceStore,
|
||||
} from "../features/platform/state/scriptWorkspaceStore";
|
||||
@@ -88,11 +90,18 @@ function AuthenticatedLayout() {
|
||||
bindScriptWorkspaceApi(api);
|
||||
bindSchedulesApi(api);
|
||||
bindAdminApi(api);
|
||||
// 当前用户 id + 工作区 id 同步绑定到 script workspace store(render body,
|
||||
// 与 bindScriptWorkspaceApi 同理)。store 的 lazy 跨 owner 逻辑据此区分
|
||||
// "我"与他人、并调用需要显式 workspaceId 的 listWorkspaceMembers。
|
||||
bindScriptWorkspaceUser(user?.user_id ?? null);
|
||||
bindScriptWorkspaceId(currentWorkspace?.workspace_id ?? null);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
bindScriptWorkspaceApi(null);
|
||||
bindSchedulesApi(null);
|
||||
bindAdminApi(null);
|
||||
bindScriptWorkspaceUser(null);
|
||||
bindScriptWorkspaceId(null);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -191,6 +191,7 @@ export type WorkspaceDirectory = {
|
||||
path: string;
|
||||
name: string;
|
||||
parent_path: string;
|
||||
owner_user_id: string;
|
||||
has_children?: boolean;
|
||||
};
|
||||
|
||||
@@ -287,13 +288,21 @@ async function apiRequest<T>(
|
||||
export async function listScripts(
|
||||
workspaceId: string,
|
||||
parentPath: string = "",
|
||||
ownerUserId?: string,
|
||||
): Promise<ScriptItem[]> {
|
||||
// Empty parentPath omits the query string entirely so the backend's
|
||||
// root-level filter is applied symmetrically with non-empty paths.
|
||||
const query = parentPath
|
||||
? `?parent_path=${encodeURIComponent(parentPath)}`
|
||||
: "";
|
||||
return apiRequest<ScriptItem[]>(`/api/v1/scripts${query}`, {}, workspaceId);
|
||||
// Default (no ownerUserId) scopes to the requester's own subtree; passing
|
||||
// ownerUserId scopes to that owner's subtree (workspace/public only — the
|
||||
// backend excludes their private) so the tree can lazily fetch another
|
||||
// member's content when their group is expanded.
|
||||
const parameters = new URLSearchParams();
|
||||
if (parentPath) parameters.set("parent_path", parentPath);
|
||||
if (ownerUserId) parameters.set("owner_user_id", ownerUserId);
|
||||
const query = parameters.toString();
|
||||
return apiRequest<ScriptItem[]>(
|
||||
`/api/v1/scripts${query ? `?${query}` : ""}`,
|
||||
{},
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
export async function countScripts(
|
||||
@@ -439,15 +448,16 @@ export type ResourceItem = {
|
||||
export async function listResources(
|
||||
workspaceId: string,
|
||||
parentPath: string = "",
|
||||
opts?: { visibility?: string; keyword?: string },
|
||||
opts?: { visibility?: string; keyword?: string; ownerUserId?: string },
|
||||
): Promise<ResourceItem[]> {
|
||||
// Empty parentPath omits the query string entirely so the backend's
|
||||
// workspace-wide (root-level) filter is applied symmetrically with
|
||||
// non-empty paths, matching listScripts.
|
||||
// Default (no ownerUserId) scopes to the requester's own object_key
|
||||
// subtree; passing ownerUserId scopes to that owner's subtree so the tree
|
||||
// can lazily fetch another member's data resources on group expand.
|
||||
const parameters = new URLSearchParams();
|
||||
if (parentPath) parameters.set("parent_path", parentPath);
|
||||
if (opts?.visibility) parameters.set("visibility", opts.visibility);
|
||||
if (opts?.keyword) parameters.set("keyword", opts.keyword);
|
||||
if (opts?.ownerUserId) parameters.set("owner_user_id", opts.ownerUserId);
|
||||
const query = parameters.toString();
|
||||
// apiRequest<T> already unwraps the envelope's `data` field, so we
|
||||
// request `ResourceItem[]` directly here (matching listScripts).
|
||||
@@ -613,12 +623,18 @@ export async function deleteScript(
|
||||
export async function listWorkspaceDirectories(
|
||||
workspaceId: string,
|
||||
parentPath: string = "",
|
||||
ownerUserId?: string,
|
||||
): Promise<WorkspaceDirectory[]> {
|
||||
const query = parentPath
|
||||
? `?parent_path=${encodeURIComponent(parentPath)}`
|
||||
: "";
|
||||
// Default (no ownerUserId) scopes to the requester's own subtree; passing
|
||||
// ownerUserId scopes to that owner so the tree can lazily render their
|
||||
// directory structure on expand. Directories are structural rows; file
|
||||
// visibility is still enforced by the scripts/data-resources endpoints.
|
||||
const parameters = new URLSearchParams();
|
||||
if (parentPath) parameters.set("parent_path", parentPath);
|
||||
if (ownerUserId) parameters.set("owner_user_id", ownerUserId);
|
||||
const query = parameters.toString();
|
||||
const data = await apiRequest<{ directories: WorkspaceDirectory[] }>(
|
||||
`/api/v1/workspace-directories${query}`,
|
||||
`/api/v1/workspace-directories${query ? `?${query}` : ""}`,
|
||||
{},
|
||||
workspaceId,
|
||||
);
|
||||
@@ -1487,6 +1503,7 @@ export async function getScheduleNodeRunArtifacts(
|
||||
export type WorkspaceBoundApi = {
|
||||
listScripts: (
|
||||
parentPath?: Parameters<typeof listScripts>[1],
|
||||
ownerUserId?: Parameters<typeof listScripts>[2],
|
||||
) => Promise<ScriptItem[]>;
|
||||
countScripts: () => Promise<number>;
|
||||
listResources: (
|
||||
@@ -1527,7 +1544,10 @@ export type WorkspaceBoundApi = {
|
||||
scriptId: string,
|
||||
isLocked: boolean,
|
||||
) => Promise<ScriptItem>;
|
||||
listWorkspaceDirectories: (parentPath?: string) => Promise<WorkspaceDirectory[]>;
|
||||
listWorkspaceDirectories: (
|
||||
parentPath?: Parameters<typeof listWorkspaceDirectories>[1],
|
||||
ownerUserId?: Parameters<typeof listWorkspaceDirectories>[2],
|
||||
) => Promise<WorkspaceDirectory[]>;
|
||||
createWorkspaceDirectory: (
|
||||
directoryName: string,
|
||||
parentPath?: string,
|
||||
|
||||
Reference in New Issue
Block a user