Files
model-platform/frontend/app/services/api.ts
T
2026-09-02 10:10:41 +08:00

1690 lines
46 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
export type DemoUser = {
userId: string;
userName: string;
username: string;
roleCode: "admin" | "developer";
roleName: string;
};
export type DemoWorkspace = {
workspaceId: string;
workspaceName: string;
};
export function createUuid(): string {
const cryptoApi = globalThis.crypto;
if (typeof cryptoApi?.randomUUID === "function") {
return cryptoApi.randomUUID();
}
const bytes = new Uint8Array(16);
if (typeof cryptoApi?.getRandomValues === "function") {
cryptoApi.getRandomValues(bytes);
} else {
for (let index = 0; index < bytes.length; index += 1) {
bytes[index] = Math.floor(Math.random() * 256);
}
}
bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x40;
bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80;
const hex = Array.from(bytes, (value) =>
value.toString(16).padStart(2, "0"),
).join("");
return [
hex.slice(0, 8),
hex.slice(8, 12),
hex.slice(12, 16),
hex.slice(16, 20),
hex.slice(20),
].join("-");
}
export const demoUsers: DemoUser[] = [
{ userId: "0000000000RF6FG1SDBXG59S13", userName: "张三", username: "admin-zhang", roleCode: "admin", roleName: "管理员" },
{ userId: "0000000000H2QYCGPCWQM1JSGS", userName: "李四", username: "admin-li", roleCode: "admin", roleName: "管理员" },
{ userId: "0000000000RWG40ESZPGJT629J", userName: "王五", username: "dev-wang", roleCode: "developer", roleName: "开发人员" },
{ userId: "00000000004CQV7WASJA6N6FW4", userName: "赵六", username: "dev-zhao", roleCode: "developer", roleName: "开发人员" },
];
export const demoWorkspaces: DemoWorkspace[] = [
{ workspaceId: "00000000000BM630VT9ARVFZPC", workspaceName: "模型开发 Workspace" },
{ workspaceId: "0000000000AE0NC0V5T424KK86", workspaceName: "风险验证 Workspace" },
];
function readStoredContext(): Partial<{
userId: string;
workspaceId: string;
}> {
if (typeof window === "undefined") return {};
try {
return JSON.parse(
window.localStorage.getItem("model-platform-demo-context") ?? "{}",
) as Partial<{ userId: string; workspaceId: string }>;
} catch {
return {};
}
}
const storedContext = readStoredContext();
const initialUser = demoUsers.find((item) => item.userId === storedContext.userId)
?? demoUsers[0];
const initialWorkspace = demoWorkspaces.find(
(item) => item.workspaceId === storedContext.workspaceId,
) ?? demoWorkspaces[0];
export const demoContext = {
...initialUser,
...initialWorkspace,
};
export function setDemoContext(input: {
user?: DemoUser;
workspace?: DemoWorkspace;
}): void {
if (input.user) Object.assign(demoContext, input.user);
if (input.workspace) Object.assign(demoContext, input.workspace);
if (typeof window !== "undefined") {
window.localStorage.setItem("model-platform-demo-context", JSON.stringify({
userId: demoContext.userId,
workspaceId: demoContext.workspaceId,
}));
}
}
// API client for the platform backend.
//
// All endpoints that take a workspace context require the caller to
// pass `workspaceId` explicitly. Components read the active workspace
// from `useAuth().currentWorkspace` and thread it through; the cookie
// set by `/api/v1/auth/login` is sent automatically thanks to
// `credentials: "same-origin"`, and the backend reads it via the
// shared `request_context` dependency.
//
// 401 from any endpoint means the session has expired or was never
// established; the global `apiRequest` helper bounces the user to
// `/login` so the platform never tries to render with a stale identity.
export type ScriptType = "python" | "notebook";
export type Visibility = "private" | "workspace" | "public";
export type Employee = {
user_id: string;
username: string;
display_name: string;
email: string | null;
status: "active" | "disabled" | "locked";
role_code: "admin" | "developer";
role_name: string;
created_at: string;
};
// Workspace (Project) types - 对应 API.md 第七部分系统管理接口
export type Workspace = {
workspace_id: string;
workspace_code: string;
workspace_name: string;
active_root_uri: string;
quota_bytes: number;
status: "active" | "archived" | "disabled";
description: string | null;
created_by: string;
created_at: string;
updated_at: string | null;
};
export type WorkspaceMember = {
user_id: string;
username: string;
display_name: string;
email: string | null;
role_code: "admin" | "developer";
role_name: string;
member_status: "active" | "disabled" | "locked";
joined_at: string;
};
export type WorkspaceCreatePayload = {
workspace_code: string;
workspace_name: string;
quota_bytes?: number;
description?: string;
};
export type WorkspaceUpdatePayload = {
workspace_name?: string;
quota_bytes?: number;
description?: string;
status?: "active" | "archived";
};
/** 加入工作区;角色继承自用户的 platform_role,请求体不能带 role_code。 */
export type WorkspaceMemberAddPayload = {
user_id: string;
};
/** 仅可改成员状态;改角色请 PATCH /platform/employees/{user_id}。 */
export type WorkspaceMemberUpdatePayload = {
member_status?: "active" | "disabled" | "locked";
};
export type ScriptItem = {
script_id: string;
workspace_id: string;
current_object_id: string;
owner_user_id: string;
owner_display_name: string | null;
script_name: string;
script_type: ScriptType;
visibility: Visibility;
status: string;
is_locked: boolean;
relative_path: string;
jupyter_path: string;
content_hash: string;
size_bytes: number;
created_at: string;
updated_at: string;
};
export type WorkspaceDirectory = {
path: string;
name: string;
parent_path: string;
owner_user_id: string;
has_children?: boolean;
};
type ApiEnvelope<T> = {
request_id: string;
data: T;
meta: Record<string, unknown>;
};
type ApiErrorEnvelope = {
detail?: string | {
code?: string;
message?: string;
};
error?: {
code?: string;
message?: string;
details?: {
editor_name?: string;
lease_expires_at?: string;
};
};
};
export class ApiRequestError extends Error {
readonly status: number;
readonly code?: string;
constructor(message: string, status: number, code?: string) {
super(message);
this.name = "ApiRequestError";
this.status = status;
this.code = code;
}
}
function appendWorkspaceId(path: string, workspaceId: string): string {
// `path` may already contain a query string. Use URLSearchParams to
// merge cleanly either way.
const separator = path.includes("?") ? "&" : "?";
return `${path}${separator}workspace_id=${encodeURIComponent(workspaceId)}`;
}
async function apiRequest<T>(
path: string,
init: RequestInit = {},
workspaceId?: string,
): Promise<T> {
const finalPath = workspaceId ? appendWorkspaceId(path, workspaceId) : path;
const response = await fetch(finalPath, {
...init,
credentials: "same-origin",
headers: {
"X-Request-ID": createUuid().replaceAll("-", ""),
...(init.body ? { "Content-Type": "application/json" } : {}),
...init.headers,
},
});
// Session expired / never authenticated — bounce to login. The
// /login route itself is the only path that must remain reachable
// while anonymous, so the redirect there is safe.
if (response.status === 401 && typeof window !== "undefined") {
const here = window.location.pathname;
if (here !== "/login") {
window.location.assign("/login");
}
throw new ApiRequestError("未登录或登录已过期", 401);
}
const payload = (await response.json().catch(() => ({}))) as
| ApiEnvelope<T>
| ApiErrorEnvelope;
if (!response.ok) {
const error = payload as ApiErrorEnvelope;
const detailMessage = typeof error.detail === "string"
? error.detail
: error.detail?.message;
const editor = error.error?.details?.editor_name;
throw new ApiRequestError(
(editor ? `${error.error?.message ?? "文件正在编辑"}${editor}` : undefined)
?? error.error?.message
?? detailMessage
?? `请求失败(HTTP ${response.status}`,
response.status,
typeof error.detail === "object"
? error.detail?.code
: error.error?.code,
);
}
return (payload as ApiEnvelope<T>).data;
}
export async function listScripts(
workspaceId: string,
parentPath: string = "",
ownerUserId?: string,
): Promise<ScriptItem[]> {
// 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(
workspaceId: string,
): Promise<number> {
// Backend route /api/v1/scripts/count must be declared BEFORE the
// /scripts/{script_id} route on the server side. Returns
// { data: { total: number } } — the dashboard's single source of
// truth for "total active scripts in workspace", independent of the
// lazy-loaded scripts[] in the workspace store.
const envelope = await apiRequest<{ total: number }>(
"/api/v1/scripts/count",
{},
workspaceId,
);
return envelope.total;
}
function initialContent(scriptType: ScriptType): string {
if (scriptType === "python") {
return [
'"""模型实验开发平台构建脚本。"""',
"",
"",
"def main() -> None:",
' print("Hello, Model Platform!")',
"",
"",
'if __name__ == "__main__":',
" main()",
"",
].join("\n");
}
return JSON.stringify(
{
cells: [
{
id: "intro",
cell_type: "code",
execution_count: null,
metadata: {},
outputs: [],
source: ["print('Hello, Model Platform!')\n"],
},
],
metadata: {
kernelspec: {
display_name: "Python 3",
language: "python",
name: "python3",
},
language_info: {
name: "python",
version: "3.12",
},
},
nbformat: 4,
nbformat_minor: 5,
},
null,
2,
);
}
export async function createScript(
workspaceId: string,
input: {
name: string;
scriptType: ScriptType;
visibility: Visibility;
parentPath?: string | null;
},
): Promise<ScriptItem> {
return apiRequest<ScriptItem>(
"/api/v1/scripts",
{
method: "POST",
body: JSON.stringify({
script_name: input.name.trim(),
script_type: input.scriptType,
visibility: input.visibility,
content: initialContent(input.scriptType),
parent_path: input.parentPath,
}),
},
workspaceId,
);
}
export async function uploadScript(
workspaceId: string,
file: File,
parentPath = "",
visibility: Visibility = "workspace",
): Promise<ScriptItem> {
const parameters = new URLSearchParams({
file_name: file.name,
parent_path: parentPath,
visibility,
});
return apiRequest<ScriptItem>(
`/api/v1/scripts/upload?${parameters.toString()}`,
{
method: "POST",
headers: { "Content-Type": "application/octet-stream" },
body: file,
},
workspaceId,
);
}
export type ResourceItem = {
resource_id: string;
workspace_id: string;
storage_object_id: string;
owner_user_id: string;
owner_display_name?: string | null;
resource_name: string;
description: string | null;
visibility: "private" | "workspace" | "public";
status: string;
created_at: string;
updated_at: string;
file: {
file_name: string;
file_extension: string | null;
mime_type: string | null;
size_bytes: number;
content_hash: string | null;
object_status: string;
};
jupyter_accessible_path: string;
absolute_path: string;
};
export async function listResources(
workspaceId: string,
parentPath: string = "",
opts?: { visibility?: string; keyword?: string; ownerUserId?: string },
): Promise<ResourceItem[]> {
// 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).
return apiRequest<ResourceItem[]>(
`/api/v1/data-resources${query ? `?${query}` : ""}`,
{},
workspaceId,
);
}
export async function deleteResource(
workspaceId: string,
resourceId: string,
): Promise<{ resource_id: string; status: string }> {
return apiRequest<{ resource_id: string; status: string }>(
`/api/v1/data-resources/${encodeURIComponent(resourceId)}`,
{ method: "DELETE" },
workspaceId,
);
}
export async function createResourceUpload(
workspaceId: string,
body: {
file_name: string;
content_type: string;
expected_size_bytes: number;
expected_hash: string | null;
target_path?: string;
},
): Promise<{ upload_id: string; upload_path: string }> {
return apiRequest<{ upload_id: string; upload_path: string }>(
"/api/v1/data-resources/uploads",
{
method: "POST",
body: JSON.stringify(body),
headers: { "Idempotency-Key": createUuid().replaceAll("-", "") },
},
workspaceId,
);
}
export async function uploadResourceBytes(
workspaceId: string,
uploadId: string,
fileBytes: ArrayBuffer | Blob,
contentType: string,
): Promise<{ storage_object_id: string }> {
return apiRequest<{ storage_object_id: string }>(
`/api/v1/data-resources/uploads/${encodeURIComponent(uploadId)}`,
{
method: "PUT",
headers: { "Content-Type": contentType },
body: fileBytes,
},
workspaceId,
);
}
export async function bindResourceUpload(
workspaceId: string,
uploadId: string,
body: {
resource_name: string;
description: string;
visibility: "private" | "workspace" | "public";
},
): Promise<ResourceItem> {
return apiRequest<ResourceItem>(
`/api/v1/data-resources/uploads/${encodeURIComponent(uploadId)}/bind`,
{
method: "POST",
body: JSON.stringify(body),
},
workspaceId,
);
}
export async function setScriptLock(
workspaceId: string,
scriptId: string,
isLocked: boolean,
): Promise<ScriptItem> {
return apiRequest<ScriptItem>(
`/api/v1/scripts/${encodeURIComponent(scriptId)}/lock`,
{
method: "PATCH",
body: JSON.stringify({ is_locked: isLocked }),
},
workspaceId,
);
}
export async function updateScript(
workspaceId: string,
scriptId: string,
input: { content: string },
): Promise<ScriptItem> {
return apiRequest<ScriptItem>(
`/api/v1/scripts/${scriptId}`,
{ method: "PUT", body: JSON.stringify(input) },
workspaceId,
);
}
export async function getScriptContent(
workspaceId: string,
scriptId: string,
): Promise<{
script_id: string;
script_type: ScriptType;
content: string | object;
format: string;
}> {
const response = await fetch(
`/api/v1/scripts/${scriptId}/content?workspace_id=${encodeURIComponent(workspaceId)}`,
{
credentials: "same-origin",
headers: {
"X-Request-ID": createUuid().replaceAll("-", ""),
},
},
);
if (response.status === 401 && typeof window !== "undefined") {
const here = window.location.pathname;
if (here !== "/login") {
window.location.assign("/login");
}
throw new ApiRequestError("未登录或登录已过期", 401);
}
const payload = await response.json();
if (!response.ok) {
const error = payload as ApiErrorEnvelope;
const detailMessage = typeof error.detail === "string"
? error.detail
: error.detail?.message;
throw new ApiRequestError(
detailMessage ?? `请求失败(HTTP ${response.status}`,
response.status,
);
}
const data = (payload as { data: { script_id: string; script_type: ScriptType; content: string | object; format: string } }).data;
if (!data || !data.script_type) {
throw new ApiRequestError("响应数据格式错误", response.status);
}
return data;
}
export async function deleteScript(
workspaceId: string,
scriptId: string,
): Promise<{ script_id: string; status: string; versions_preserved: boolean }> {
return apiRequest(
`/api/v1/scripts/${scriptId}`,
{ method: "DELETE" },
workspaceId,
);
}
export async function listWorkspaceDirectories(
workspaceId: string,
parentPath: string = "",
ownerUserId?: string,
): Promise<WorkspaceDirectory[]> {
// 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 ? `?${query}` : ""}`,
{},
workspaceId,
);
return data.directories;
}
export async function createWorkspaceDirectory(
workspaceId: string,
directoryName: string,
parentPath = "",
): Promise<WorkspaceDirectory> {
return apiRequest<WorkspaceDirectory>(
"/api/v1/workspace-directories",
{
method: "POST",
body: JSON.stringify({
directory_name: directoryName,
parent_path: parentPath,
}),
},
workspaceId,
);
}
export async function deleteWorkspaceDirectory(
workspaceId: string,
path: string,
): Promise<{
path: string;
status: string;
deleted_scripts: number;
versions_preserved: boolean;
}> {
const parameters = new URLSearchParams({ path });
return apiRequest(
`/api/v1/workspace-directories?${parameters.toString()}`,
{ method: "DELETE" },
workspaceId,
);
}
export type FileLockSession = {
edit_session_id: string;
workspace_id: string;
storage_object_id: string;
user_id: string;
session_status: "active" | "closed" | "expired";
lease_seconds: number;
heartbeat_interval_seconds: number;
expires_at: string;
runtime_id: string;
jupyter_session_id: string;
jupyter_url?: string;
relative_path?: string;
lock_token?: string;
};
export type ActiveEditSession = FileLockSession & {
script_id: string;
script_name: string;
jupyter_path: string;
lock_token: string;
ticket_expires_at?: string;
};
export type JupyterAccessTicket = {
edit_session_id: string;
jupyter_url: string;
expires_at: string;
};
export type StableVersion = {
versions_id: string;
workspace_id: string;
script_id: string;
source_object_id: string;
artifact_object_id: string;
version_no: number;
version_label: string;
source_path: string;
artifact_path: string;
content_hash: string;
file_size_bytes: number;
visibility: Visibility;
release_note: string | null;
created_by: string;
created_at: string;
};
// 本地浏览器级“编辑锁”——后端没有 acquire/heartbeat/release/edit-session 表。
// 这里的四个函数全部是占位:返回结构是为了让上层 store 的
// _editSession / sessionCache 继续按“session”接口工作,但锁的实际作用域
// 仅限当前 tab。关闭 tab、刷新页面、用隐身模式打开、或换浏览器,锁即失效。
// 不要把这些函数当作鉴权或并发控制用——它们什么都不查、什么都不写。
// 真实并发控制需要后端 edit_sessions 表 + Nginx auth_request 联动,是后续工单。
export async function acquireFileLock(
workspaceId: string,
script: ScriptItem,
): Promise<ActiveEditSession> {
const now = Date.now();
return {
edit_session_id: createUuid().replaceAll("-", ""),
workspace_id: workspaceId,
storage_object_id: script.current_object_id,
user_id: script.owner_user_id,
session_status: "active",
lease_seconds: 3600,
heartbeat_interval_seconds: 300,
expires_at: new Date(now + 3600_000).toISOString(),
runtime_id: workspaceId,
jupyter_session_id: "local",
relative_path: script.relative_path,
lock_token: "local",
script_id: script.script_id,
script_name: script.script_name,
jupyter_path: script.jupyter_path,
};
}
export async function heartbeatFileLock(
_workspaceId: string,
session: ActiveEditSession,
): Promise<FileLockSession> {
// 本地锁不存在过期概念;只是把 expires_at 推后让 UI 看着还活着。
// 该字段当前没有任何消费者,保留只是为了不破坏契约。
return session;
}
export async function releaseFileLock(
_workspaceId: string,
session: ActiveEditSession,
): Promise<FileLockSession> {
return { ...session, session_status: "closed" };
}
export function releaseFileLockOnUnload(
_workspaceId: string,
_session: ActiveEditSession,
): void {
// 本地锁随 tab 生命周期结束。beforeunload 调到这里只是让 store 端
// 清理模块级引用,避免下一个 tab 复用时看到陈旧 _editSession。
}
async function waitForJupyterReady(jupyterUrl: string): Promise<void> {
const retryableStatuses = new Set([502, 503, 504]);
let lastStatus = 0;
for (let attempt = 0; attempt < 10; attempt += 1) {
const response = await fetch(jupyterUrl, {
credentials: "same-origin",
cache: "no-store",
});
if (response.ok) return;
lastStatus = response.status;
if (!retryableStatuses.has(response.status)) {
throw new ApiRequestError(
`Jupyter 打开失败(HTTP ${response.status}`,
response.status,
);
}
await new Promise((resolve) => window.setTimeout(resolve, 400));
}
throw new ApiRequestError(
`Jupyter 服务启动超时${lastStatus ? `HTTP ${lastStatus}` : ""}`,
lastStatus || 504,
);
}
export async function createJupyterAccessTicket(
workspaceId: string,
session: ActiveEditSession,
): Promise<JupyterAccessTicket> {
const editorRoute = session.script_name.toLowerCase().endsWith(".ipynb")
? "notebooks"
: "edit";
const encodedPath = session.jupyter_path
.split("/")
.filter(Boolean)
.map(encodeURIComponent)
.join("/");
if (!encodedPath) {
throw new Error("脚本缺少 Jupyter 存储路径");
}
const jupyterUrl = `/jupyter/${encodeURIComponent(workspaceId)}/${editorRoute}/${encodedPath}`;
await waitForJupyterReady(jupyterUrl);
return {
edit_session_id: session.edit_session_id,
jupyter_url: jupyterUrl,
expires_at: new Date(Date.now() + 3600_000).toISOString(),
};
}
export type LatestVersion = {
versions_id: string;
version_label: string;
};
export async function getLatestScriptVersion(
workspaceId: string,
scriptId: string,
): Promise<LatestVersion | null> {
return apiRequest<LatestVersion | null>(
`/api/v1/scripts/${scriptId}/latest-version`,
{},
workspaceId,
);
}
export async function publishScriptVersion(
workspaceId: string,
input: {
script: ScriptItem;
releaseNote: string;
visibility: Visibility;
},
): Promise<StableVersion> {
return apiRequest<StableVersion>(
`/api/v1/scripts/${input.script.script_id}/versions`,
{
method: "POST",
body: JSON.stringify({
source_object_id: input.script.current_object_id,
release_note: input.releaseNote.trim() || null,
visibility: input.visibility,
}),
},
workspaceId,
);
}
export type ScheduleArtifact = {
versions_id: string;
version_label: string;
script_id: string;
script_name: string;
script_type: ScriptType;
content_hash: string;
file_size_bytes: number;
visibility: Visibility;
created_by: string;
created_at: string;
};
export type PythonVersion = "3.8" | "3.10" | "3.12";
export type ScheduleNode = {
node_id: string;
schedule_id: string;
node_key: string;
node_name: string;
versions_id: string;
timeout_seconds: number;
retry_count: number;
retry_interval_sec: number;
position_x: number;
position_y: number;
arguments_json: Record<string, unknown>;
env_refs_json: Record<string, string>;
python_version: PythonVersion;
created_at: string;
updated_at: string;
version: {
versions_id: string;
version_label: string;
script_id: string;
script_name: string;
script_type: ScriptType;
content_hash: string;
created_at: string;
};
};
export type ScheduleEdge = {
edge_id: string;
schedule_id: string;
source_node_id: string;
target_node_id: string;
condition_expr: string | null;
created_at: string;
};
export type DagValidation = {
valid: boolean;
node_count: number;
edge_count: number;
root_node_ids: string[];
leaf_node_ids: string[];
topological_order: string[];
errors: Array<{
code: string;
message: string;
edge_id?: string;
node_ids?: string[];
}>;
};
export type Schedule = {
schedule_id: string;
workspace_id: string;
schedule_name: string;
description: string | null;
trigger_type: "manual" | "cron" | "api";
cron_expression: string | null;
timezone: string;
enabled: boolean;
workflow_version: number;
max_concurrency: number;
failure_policy: "stop" | "continue";
last_run_at: string | null;
next_run_at: string | null;
created_by: string;
updated_by: string;
created_at: string;
updated_at: string;
node_count: number;
edge_count: number;
nodes: ScheduleNode[];
edges: ScheduleEdge[];
dag_validation: DagValidation;
};
export type CronPreview = {
cron_expression: string;
timezone: string;
base_time: string;
occurrences: Array<{
local_time: string;
utc_time: string;
}>;
};
export type ScheduleRunStatus =
| "queued"
| "running"
| "succeeded"
| "failed"
| "cancelled"
| "timed_out";
export type ScheduleNodeRunStatus =
| ScheduleRunStatus
| "skipped";
export type ScheduleRunSummary = {
run_id: string;
schedule_id: string;
workspace_id: string;
workflow_version: number;
trigger_type: "manual" | "cron" | "api" | "retry";
run_status: ScheduleRunStatus;
state_version: number;
queued_at: string;
started_at: string | null;
finished_at: string | null;
duration_ms: number | null;
error_code: string | null;
error_message: string | null;
logs_object_id: string | null;
result_object_id: string | null;
};
export type ScheduleNodeRun = {
node_run_id: string;
run_id: string;
node_id: string;
versions_id: string;
attempt_no: number;
node_status: ScheduleNodeRunStatus;
state_version: number;
started_at: string | null;
finished_at: string | null;
duration_ms: number | null;
exit_code: number | null;
message: string | null;
logs_object_id: string | null;
result_object_id: string | null;
};
export type ScheduleRunDetail = ScheduleRunSummary & {
node_runs: ScheduleNodeRun[];
};
export type ScheduleRunArtifact = {
url: string;
file_name: string;
mime_type: string | null;
size_bytes: number;
};
export type ScheduleNodeRunArtifacts = {
run_id: string;
node_run_id: string;
log: ScheduleRunArtifact | null;
result: ScheduleRunArtifact | null;
};
export async function listSchedules(workspaceId: string): Promise<Schedule[]> {
return apiRequest<Schedule[]>("/api/v1/schedules", {}, workspaceId);
}
export async function getSchedule(
workspaceId: string,
scheduleId: string,
): Promise<Schedule> {
return apiRequest<Schedule>(
`/api/v1/schedules/${scheduleId}`,
{},
workspaceId,
);
}
export async function createSchedule(
workspaceId: string,
input: {
schedule_name: string;
description?: string | null;
trigger_type?: "manual" | "cron" | "api";
cron_expression?: string | null;
timezone?: string;
enabled?: boolean;
max_concurrency?: number;
failure_policy?: "stop" | "continue";
},
): Promise<Schedule> {
return apiRequest<Schedule>(
"/api/v1/schedules",
{ method: "POST", body: JSON.stringify(input) },
workspaceId,
);
}
export async function updateSchedule(
workspaceId: string,
scheduleId: string,
input: {
workflow_version: number;
schedule_name?: string;
description?: string | null;
trigger_type?: "manual" | "cron" | "api";
cron_expression?: string | null;
timezone?: string;
enabled?: boolean;
max_concurrency?: number;
failure_policy?: "stop" | "continue";
},
): Promise<Schedule> {
return apiRequest<Schedule>(
`/api/v1/schedules/${scheduleId}`,
{ method: "PATCH", body: JSON.stringify(input) },
workspaceId,
);
}
export async function deleteSchedule(
workspaceId: string,
scheduleId: string,
workflowVersion: number,
): Promise<{ schedule_id: string; deleted: boolean; workflow_version: number }> {
return apiRequest(
`/api/v1/schedules/${scheduleId}`,
{ method: "DELETE", body: JSON.stringify({ workflow_version: workflowVersion }) },
workspaceId,
);
}
export async function listScheduleArtifacts(
workspaceId: string,
): Promise<ScheduleArtifact[]> {
return apiRequest<ScheduleArtifact[]>(
"/api/v1/schedule-artifacts",
{},
workspaceId,
);
}
export async function hideScheduleArtifact(
workspaceId: string,
versionsId: string,
): Promise<{
versions_id: string;
deleted: boolean;
artifact_preserved: boolean;
}> {
return apiRequest(
`/api/v1/versions/${versionsId}`,
{ method: "DELETE" },
workspaceId,
);
}
// 系统管理级别接口 - 不区分 workspace
export async function listPlatformEmployees(): Promise<Employee[]> {
return apiRequest<Employee[]>("/api/v1/platform/employees");
}
export async function createPlatformEmployee(
input: {
username: string;
display_name: string;
email?: string | undefined;
password: string;
role_code?: "admin" | "developer";
},
): Promise<Employee> {
return apiRequest<Employee>(
"/api/v1/platform/employees",
{ method: "POST", body: JSON.stringify({ ...input, role_code: input.role_code ?? "developer" }) },
);
}
export async function updatePlatformEmployee(
userId: string,
input: {
display_name?: string;
email?: string | null;
role_code?: "admin" | "developer";
status?: "active" | "disabled" | "locked";
},
): Promise<Employee> {
return apiRequest<Employee>(
`/api/v1/platform/employees/${userId}`,
{ method: "PATCH", body: JSON.stringify(input) },
);
}
export async function deletePlatformEmployee(
userId: string,
): Promise<{ user_id: string; deleted: boolean }> {
return apiRequest(
`/api/v1/platform/employees/${userId}`,
{ method: "DELETE" },
);
}
// 兼容旧的 workspace 级别接口(已废弃,建议使用 system-level 接口)
/** @deprecated 使用 listPlatformEmployees 代替 */
export async function listEmployees(workspaceId: string): Promise<Employee[]> {
return apiRequest<Employee[]>("/api/v1/admin/employees", {}, workspaceId);
}
/** @deprecated 使用 createPlatformEmployee 代替 */
export async function createEmployee(
workspaceId: string,
input: {
username: string;
display_name: string;
email?: string | null;
role_code: "admin" | "developer";
password: string;
},
): Promise<Employee> {
return apiRequest<Employee>(
"/api/v1/admin/employees",
{ method: "POST", body: JSON.stringify(input) },
workspaceId,
);
}
/** @deprecated 使用 updatePlatformEmployee 代替 */
export async function updateEmployee(
workspaceId: string,
userId: string,
input: {
display_name?: string;
email?: string | null;
role_code?: "admin" | "developer";
status?: "active" | "disabled" | "locked";
},
): Promise<Employee> {
return apiRequest<Employee>(
`/api/v1/admin/employees/${userId}`,
{ method: "PATCH", body: JSON.stringify(input) },
workspaceId,
);
}
/** @deprecated 使用 deletePlatformEmployee 代替 */
export async function deleteEmployee(
workspaceId: string,
userId: string,
): Promise<{ user_id: string; deleted: boolean }> {
return apiRequest(
`/api/v1/admin/employees/${userId}`,
{ method: "DELETE" },
workspaceId,
);
}
// ----------------------------------------------------------------------------
// Workspace (Project) Management APIs - 对应 API.md 第七部分系统管理接口
// ----------------------------------------------------------------------------
export async function listWorkspaces(): Promise<Workspace[]> {
return apiRequest<Workspace[]>("/api/v1/platform/workspaces");
}
export async function createWorkspace(
input: WorkspaceCreatePayload,
): Promise<Workspace> {
return apiRequest<Workspace>(
"/api/v1/platform/workspaces",
{ method: "POST", body: JSON.stringify(input) },
);
}
export async function getWorkspace(workspaceId: string): Promise<Workspace> {
return apiRequest<Workspace>(`/api/v1/platform/workspaces/${workspaceId}`);
}
export async function updateWorkspace(
workspaceId: string,
input: WorkspaceUpdatePayload,
): Promise<Workspace> {
return apiRequest<Workspace>(
`/api/v1/platform/workspaces/${workspaceId}`,
{ method: "PATCH", body: JSON.stringify(input) },
);
}
export async function deleteWorkspace(workspaceId: string): Promise<{
workspace_id: string;
deleted: boolean;
}> {
return apiRequest(
`/api/v1/platform/workspaces/${workspaceId}`,
{ method: "DELETE" },
);
}
export async function listWorkspaceMembers(workspaceId: string): Promise<WorkspaceMember[]> {
return apiRequest<WorkspaceMember[]>(
`/api/v1/platform/workspaces/${workspaceId}/members`,
);
}
export async function addWorkspaceMember(
workspaceId: string,
input: WorkspaceMemberAddPayload,
): Promise<WorkspaceMember> {
return apiRequest<WorkspaceMember>(
`/api/v1/platform/workspaces/${workspaceId}/members`,
{ method: "POST", body: JSON.stringify(input) },
);
}
export async function updateWorkspaceMember(
workspaceId: string,
userId: string,
input: WorkspaceMemberUpdatePayload,
): Promise<WorkspaceMember> {
return apiRequest<WorkspaceMember>(
`/api/v1/platform/workspaces/${workspaceId}/members/${userId}`,
{ method: "PATCH", body: JSON.stringify(input) },
);
}
export async function deleteWorkspaceMember(
workspaceId: string,
userId: string,
): Promise<{ user_id: string; deleted: boolean }> {
return apiRequest(
`/api/v1/platform/workspaces/${workspaceId}/members/${userId}`,
{ method: "DELETE" },
);
}
export async function createScheduleNode(
workspaceId: string,
scheduleId: string,
input: {
workflow_version: number;
node_key: string;
node_name: string;
versions_id: string;
timeout_seconds?: number;
retry_count?: number;
retry_interval_sec?: number;
position_x?: number;
position_y?: number;
arguments_json?: Record<string, unknown>;
env_refs_json?: Record<string, string>;
python_version?: PythonVersion;
},
): Promise<Schedule> {
return apiRequest<Schedule>(
`/api/v1/schedules/${scheduleId}/nodes`,
{ method: "POST", body: JSON.stringify(input) },
workspaceId,
);
}
export async function updateScheduleNode(
workspaceId: string,
scheduleId: string,
nodeId: string,
input: {
workflow_version: number;
node_name?: string;
versions_id?: string;
timeout_seconds?: number;
retry_count?: number;
retry_interval_sec?: number;
position_x?: number;
position_y?: number;
arguments_json?: Record<string, unknown>;
env_refs_json?: Record<string, string>;
python_version?: PythonVersion;
},
): Promise<Schedule> {
return apiRequest<Schedule>(
`/api/v1/schedules/${scheduleId}/nodes/${nodeId}`,
{ method: "PUT", body: JSON.stringify(input) },
workspaceId,
);
}
export async function deleteScheduleNode(
workspaceId: string,
scheduleId: string,
nodeId: string,
workflowVersion: number,
options: { delete_execution_history?: boolean } = {},
): Promise<Schedule> {
return apiRequest<Schedule>(
`/api/v1/schedules/${scheduleId}/nodes/${nodeId}`,
{
method: "DELETE",
body: JSON.stringify({ workflow_version: workflowVersion, ...options }),
},
workspaceId,
);
}
export async function createScheduleEdge(
workspaceId: string,
scheduleId: string,
input: {
workflow_version: number;
source_node_id: string;
target_node_id: string;
condition_expr?: string | null;
},
): Promise<Schedule> {
return apiRequest<Schedule>(
`/api/v1/schedules/${scheduleId}/edges`,
{ method: "POST", body: JSON.stringify(input) },
workspaceId,
);
}
export async function deleteScheduleEdge(
workspaceId: string,
scheduleId: string,
edgeId: string,
workflowVersion: number,
): Promise<Schedule> {
return apiRequest<Schedule>(
`/api/v1/schedules/${scheduleId}/edges/${edgeId}`,
{ method: "DELETE", body: JSON.stringify({ workflow_version: workflowVersion }) },
workspaceId,
);
}
export async function validateSchedule(
workspaceId: string,
scheduleId: string,
): Promise<DagValidation & {
schedule_id: string;
workflow_version: number;
}> {
return apiRequest(
`/api/v1/schedules/${scheduleId}/validate`,
{ method: "POST" },
workspaceId,
);
}
export async function previewCron(
workspaceId: string,
input: {
cron_expression: string;
timezone: string;
count?: number;
base_time?: string;
},
): Promise<CronPreview> {
return apiRequest<CronPreview>(
"/api/v1/cron/preview",
{ method: "POST", body: JSON.stringify(input) },
workspaceId,
);
}
export async function runScheduleNow(
workspaceId: string,
scheduleId: string,
): Promise<ScheduleRunDetail> {
return apiRequest<ScheduleRunDetail>(
`/api/v1/schedules/${scheduleId}/run`,
{
method: "POST",
headers: {
"Idempotency-Key": createUuid(),
},
body: JSON.stringify({ reason: "manual_run" }),
},
workspaceId,
);
}
export async function listScheduleRuns(
workspaceId: string,
input: {
scheduleId?: string;
status?: ScheduleRunStatus;
limit?: number;
} = {},
): Promise<ScheduleRunSummary[]> {
const query = new URLSearchParams();
if (input.scheduleId) query.set("schedule_id", input.scheduleId);
if (input.status) query.set("status", input.status);
query.set("limit", String(input.limit ?? 20));
return apiRequest<ScheduleRunSummary[]>(
`/api/v1/schedule-runs?${query.toString()}`,
{},
workspaceId,
);
}
export async function getScheduleRun(
workspaceId: string,
runId: string,
): Promise<ScheduleRunDetail> {
return apiRequest<ScheduleRunDetail>(
`/api/v1/schedule-runs/${runId}`,
{},
workspaceId,
);
}
export async function getScheduleNodeRunArtifacts(
workspaceId: string,
runId: string,
nodeRunId: string,
): Promise<ScheduleNodeRunArtifacts> {
return apiRequest<ScheduleNodeRunArtifacts>(
`/api/v1/schedule-runs/${runId}/node-runs/${nodeRunId}/artifacts`,
{},
workspaceId,
);
}
// ----------------------------------------------------------------------------
// Workspace-bound API surface.
//
// `useApi()` in ~/context/AuthContext returns an object where every
// function has had its first `workspaceId` argument pre-filled. The
// type below lets consumers import the bound type without depending
// on the raw functions. Keep this last in the file so the type
// references all the exports above.
// ----------------------------------------------------------------------------
export type WorkspaceBoundApi = {
listScripts: (
parentPath?: Parameters<typeof listScripts>[1],
ownerUserId?: Parameters<typeof listScripts>[2],
) => Promise<ScriptItem[]>;
countScripts: () => Promise<number>;
listResources: (
parentPath?: Parameters<typeof listResources>[1],
opts?: Parameters<typeof listResources>[2],
) => Promise<ResourceItem[]>;
createScript: (
input: Parameters<typeof createScript>[1],
) => Promise<ScriptItem>;
uploadScript: (
file: File,
parentPath?: string,
visibility?: Visibility,
) => Promise<ScriptItem>;
createResourceUpload: (
body: Parameters<typeof createResourceUpload>[1],
) => Promise<{ upload_id: string; upload_path: string }>;
uploadResourceBytes: (
uploadId: string,
fileBytes: ArrayBuffer | Blob,
contentType: string,
) => Promise<{ storage_object_id: string }>;
bindResourceUpload: (
uploadId: string,
body: Parameters<typeof bindResourceUpload>[2],
) => Promise<ResourceItem>;
deleteResource: (
resourceId: string,
) => Promise<{ resource_id: string; status: string }>;
updateScript: (
scriptId: string,
input: Parameters<typeof updateScript>[2],
) => Promise<ScriptItem>;
deleteScript: (
scriptId: string,
) => Promise<{ script_id: string; status: string; versions_preserved: boolean }>;
setScriptLock: (
scriptId: string,
isLocked: boolean,
) => Promise<ScriptItem>;
listWorkspaceDirectories: (
parentPath?: Parameters<typeof listWorkspaceDirectories>[1],
ownerUserId?: Parameters<typeof listWorkspaceDirectories>[2],
) => Promise<WorkspaceDirectory[]>;
createWorkspaceDirectory: (
directoryName: string,
parentPath?: string,
) => Promise<WorkspaceDirectory>;
deleteWorkspaceDirectory: (
path: string,
) => Promise<{
path: string;
status: string;
deleted_scripts: number;
versions_preserved: boolean;
}>;
acquireFileLock: (
script: ScriptItem,
) => Promise<ActiveEditSession>;
heartbeatFileLock: (
session: ActiveEditSession,
) => Promise<FileLockSession>;
releaseFileLock: (
session: ActiveEditSession,
) => Promise<FileLockSession>;
releaseFileLockOnUnload: (session: ActiveEditSession) => void;
createJupyterAccessTicket: (
session: ActiveEditSession,
) => Promise<JupyterAccessTicket>;
getLatestScriptVersion: (scriptId: string) => Promise<LatestVersion | null>;
publishScriptVersion: (
input: Parameters<typeof publishScriptVersion>[1],
) => Promise<StableVersion>;
listSchedules: () => Promise<Schedule[]>;
getSchedule: (scheduleId: string) => Promise<Schedule>;
createSchedule: (
input: Parameters<typeof createSchedule>[1],
) => Promise<Schedule>;
updateSchedule: (
scheduleId: string,
input: Parameters<typeof updateSchedule>[2],
) => Promise<Schedule>;
deleteSchedule: (
scheduleId: string,
workflowVersion: number,
) => Promise<{ schedule_id: string; deleted: boolean; workflow_version: number }>;
listScheduleArtifacts: () => Promise<ScheduleArtifact[]>;
hideScheduleArtifact: (
versionsId: string,
) => Promise<{
versions_id: string;
deleted: boolean;
artifact_preserved: boolean;
}>;
listEmployees: () => Promise<Employee[]>;
listPlatformEmployees: () => Promise<Employee[]>;
createEmployee: (
input: Parameters<typeof createEmployee>[1],
) => Promise<Employee>;
createPlatformEmployee: (
input: {
username: string;
display_name: string;
email?: string | undefined;
password: string;
role_code?: "admin" | "developer";
},
) => Promise<Employee>;
updateEmployee: (
userId: string,
input: Parameters<typeof updateEmployee>[2],
) => Promise<Employee>;
updatePlatformEmployee: (
userId: string,
input: {
display_name?: string;
email?: string | null;
role_code?: "admin" | "developer";
status?: "active" | "disabled" | "locked";
},
) => Promise<Employee>;
deleteEmployee: (
userId: string,
) => Promise<{ user_id: string; deleted: boolean }>;
deletePlatformEmployee: (
userId: string,
) => Promise<{ user_id: string; deleted: boolean }>;
createScheduleNode: (
scheduleId: string,
input: Parameters<typeof createScheduleNode>[2],
) => Promise<Schedule>;
updateScheduleNode: (
scheduleId: string,
nodeId: string,
input: Parameters<typeof updateScheduleNode>[3],
) => Promise<Schedule>;
deleteScheduleNode: (
scheduleId: string,
nodeId: string,
workflowVersion: number,
options?: { delete_execution_history?: boolean },
) => Promise<Schedule>;
createScheduleEdge: (
scheduleId: string,
input: Parameters<typeof createScheduleEdge>[2],
) => Promise<Schedule>;
deleteScheduleEdge: (
scheduleId: string,
edgeId: string,
workflowVersion: number,
) => Promise<Schedule>;
validateSchedule: (
scheduleId: string,
) => Promise<DagValidation & { schedule_id: string; workflow_version: number }>;
previewCron: (
input: Parameters<typeof previewCron>[1],
) => Promise<CronPreview>;
runScheduleNow: (scheduleId: string) => Promise<ScheduleRunDetail>;
listScheduleRuns: (
input?: Parameters<typeof listScheduleRuns>[1],
) => Promise<ScheduleRunSummary[]>;
getScheduleRun: (runId: string) => Promise<ScheduleRunDetail>;
getScheduleNodeRunArtifacts: (
runId: string,
nodeRunId: string,
) => Promise<ScheduleNodeRunArtifacts>;
// Workspace (Project) Management - 系统管理接口
listWorkspaces: () => Promise<Workspace[]>;
createWorkspace: (input: WorkspaceCreatePayload) => Promise<Workspace>;
updateWorkspace: (
workspaceId: string,
input: WorkspaceUpdatePayload,
) => Promise<Workspace>;
deleteWorkspace: (workspaceId: string) => Promise<{ workspace_id: string; deleted: boolean }>;
listWorkspaceMembers: (workspaceId: string) => Promise<WorkspaceMember[]>;
addWorkspaceMember: (
workspaceId: string,
input: WorkspaceMemberAddPayload,
) => Promise<WorkspaceMember>;
updateWorkspaceMember: (
workspaceId: string,
userId: string,
input: WorkspaceMemberUpdatePayload,
) => Promise<WorkspaceMember>;
deleteWorkspaceMember: (
workspaceId: string,
userId: string,
) => Promise<{ user_id: string; deleted: boolean }>;
};