140 lines
4.3 KiB
TypeScript
140 lines
4.3 KiB
TypeScript
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<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(),
|
||
};
|
||
}
|