import { ApiRequestError, createUuid } from "./_shared"; import type { ScriptItem } from "./scripts"; 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; }; // 本地浏览器级“编辑锁”——后端没有 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 { 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 { // 本地锁不存在过期概念;只是把 expires_at 推后让 UI 看着还活着。 // 该字段当前没有任何消费者,保留只是为了不破坏契约。 return session; } export async function releaseFileLock( _workspaceId: string, session: ActiveEditSession, ): Promise { return { ...session, session_status: "closed" }; } export function releaseFileLockOnUnload( _workspaceId: string, _session: ActiveEditSession, ): void { // 本地锁随 tab 生命周期结束。beforeunload 调到这里只是让 store 端 // 清理模块级引用,避免下一个 tab 复用时看到陈旧 _editSession。 } async function waitForJupyterReady(jupyterUrl: string): Promise { 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 { 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(), }; }