// List slice: schedule list, artifact list, current schedule, loading/busy flags, // and most mutation/loader actions. Filter inputs (scheduleKeyword/artifactKeyword) // live in canvasSlice since they bind to the left-panel UI. Cross-slice actions // live here when their primary write is to LIST fields; they reach into // CANVAS / DIALOG / RUNS via `get()`. import { ApiRequestError, type Schedule, type ScheduleArtifact, type ScheduleEdge, type ScheduleNode } from "../../../services/api"; import type { StateCreator } from "zustand"; import { applyPositionDrafts, applyServerUpdatedSchedule, artifactNodeKey, notify, parseObject, requireApi, setApiOnline, scheduleToForm, withMutation, } from "./helpers"; import { PYTHON_VERSION_OPTIONS, type NodeForm, type ScheduleForm, } from "./types"; import type { SchedulesStore } from "./useSchedulesStore"; // ---- State ---- export type ListSliceState = { schedules: Schedule[]; artifacts: ScheduleArtifact[]; schedule: Schedule | null; loading: boolean; busy: string | null; }; // ---- Actions ---- export type ListSliceActions = { // pure setters setSchedule: ( value: Schedule | null | ((current: Schedule | null) => Schedule | null), ) => void; setSchedules: ( value: Schedule[] | ((current: Schedule[]) => Schedule[]), ) => void; setArtifacts: ( value: ScheduleArtifact[] | ((current: ScheduleArtifact[]) => ScheduleArtifact[]), ) => void; setLoading: (loading: boolean) => void; setBusy: (busy: string | null) => void; // data loaders loadInitial: () => Promise; refreshLists: (preferredScheduleId?: string | null) => Promise; chooseSchedule: (scheduleId: string) => Promise; // mutations addSchedule: (name: string) => Promise; removeSchedule: (target?: Schedule) => Promise; renameSchedule: (target: Schedule, scheduleName: string) => Promise; removeArtifact: (artifact: ScheduleArtifact) => Promise; saveSchedule: () => Promise; runNow: () => Promise; addArtifactAt: ( artifact: ScheduleArtifact, positionX: number, positionY: number, ) => Promise; saveNode: (selectedNode: ScheduleNode | null) => Promise; removeNode: ( target?: ScheduleNode, options?: { deleteExecutionHistory?: boolean }, ) => Promise<"ok" | "needs_history_confirm" | "noop">; removeEdge: (target?: ScheduleEdge) => Promise; checkDag: () => Promise; connectTo: (targetNodeId: string) => Promise; }; // `SchedulesStore` is the combined state+actions of all slices, so `get()` // returns a fully typed snapshot — no `any` propagation. // eslint-disable-next-line @typescript-eslint/no-explicit-any export const createListSlice: StateCreator = (set, get) => { // ---- initial state ---- const state: ListSliceState = { schedules: [], artifacts: [], schedule: null, loading: true, busy: null, }; return { ...state, // ---- pure setters ---- setSchedule: (value) => set((s: any) => ({ schedule: typeof value === "function" ? (value as (current: Schedule | null) => Schedule | null)(s.schedule) : value, })), setSchedules: (value) => set((s: any) => ({ schedules: typeof value === "function" ? (value as (current: Schedule[]) => Schedule[])(s.schedules) : value, })), setArtifacts: (value) => set((s: any) => ({ artifacts: typeof value === "function" ? (value as (current: ScheduleArtifact[]) => ScheduleArtifact[])( s.artifacts, ) : value, })), setLoading: (loading) => set({ loading }), setBusy: (busy) => set({ busy }), // ---- data loaders ---- loadInitial: async () => { const api = requireApi(); let cancelled = false; set({ loading: true }); try { const [scheduleItems, artifactItems] = await Promise.all([ api.listSchedules(), api.listScheduleArtifacts(), ]); if (cancelled) return; set({ schedules: scheduleItems, artifacts: artifactItems }); if (scheduleItems[0]) { const detail = await api.getSchedule(scheduleItems[0].schedule_id); if (!cancelled) { set({ schedule: applyPositionDrafts(detail) }); } } setApiOnline(true); } catch (error) { if (cancelled) return; setApiOnline(false); notify({ tone: "error", message: error instanceof Error ? error.message : "调度数据加载失败", }); } finally { if (!cancelled) set({ loading: false }); } }, refreshLists: async (preferredScheduleId) => { const api = requireApi(); const [scheduleItems, artifactItems] = await Promise.all([ api.listSchedules(), api.listScheduleArtifacts(), ]); set({ schedules: scheduleItems, artifacts: artifactItems }); const targetId = preferredScheduleId ?? get().schedule?.schedule_id ?? scheduleItems[0]?.schedule_id ?? null; if (!targetId) { set({ schedule: null }); return; } const detail = await api.getSchedule(targetId); set({ schedule: applyPositionDrafts(detail) }); }, chooseSchedule: async (scheduleId) => { const api = requireApi(); const current = get().schedule; if (scheduleId === current?.schedule_id || get().busy) return; set({ busy: "load-schedule" }); get().clearAllPositionDrafts(); set({ selectedNodeId: null, selectedEdgeId: null, linkSourceId: null, cronResult: null, runs: [], runsLoading: false, }); try { set({ schedule: await api.getSchedule(scheduleId) }); setApiOnline(true); } catch (error) { const { handleError } = await import("./helpers"); await handleError(get, error, "调度详情加载失败"); } finally { set({ busy: null }); } }, // ---- mutations ---- addSchedule: async (name) => { const api = requireApi(); const scheduleName = name.trim(); if (get().busy || !scheduleName) return; set({ busy: "create-schedule" }); try { const created = await api.createSchedule({ schedule_name: scheduleName, description: "在画布中拖入稳定版本并配置执行顺序", trigger_type: "manual", timezone: "Asia/Shanghai", enabled: false, }); set((s: any) => ({ schedules: [created, ...s.schedules], schedule: created, createDialogOpen: false, newScheduleName: "", })); get().clearAllPositionDrafts(); set({ selectedNodeId: null, selectedEdgeId: null }); notify({ tone: "success", message: "调度方案已创建" }); } catch (error) { const { handleError } = await import("./helpers"); await handleError(get, error, "创建调度失败"); } finally { set({ busy: null }); } }, removeSchedule: async (target) => { const api = requireApi(); const target_ = target ?? get().schedule; if (!target_ || get().busy) return; set({ contextMenu: null }); set({ busy: "delete-schedule" }); try { await api.deleteSchedule(target_.schedule_id, target_.workflow_version); const remaining = get().schedules.filter( (item) => item.schedule_id !== target_.schedule_id, ); set({ schedules: remaining }); if (get().schedule?.schedule_id === target_.schedule_id) { get().clearAllPositionDrafts(); set({ schedule: null, selectedNodeId: null, selectedEdgeId: null, }); if (remaining[0]) { const detail = await api.getSchedule(remaining[0].schedule_id); set({ schedule: detail }); } } notify({ tone: "success", message: "调度方案已删除" }); } catch (error) { const { handleError } = await import("./helpers"); await handleError(get, error, "删除调度失败"); } finally { set({ busy: null }); } }, renameSchedule: async (target, scheduleName) => { const api = requireApi(); const trimmed = scheduleName.trim(); if (get().busy || !trimmed || trimmed === target.schedule_name) return; set({ busy: "rename-schedule" }); try { const updated = await api.updateSchedule(target.schedule_id, { workflow_version: target.workflow_version, schedule_name: trimmed, }); set((s: any) => ({ schedules: s.schedules.map((item: Schedule) => item.schedule_id === updated.schedule_id ? { ...updated, nodes: [], edges: [] } : item ), schedule: s.schedule?.schedule_id === updated.schedule_id ? updated : s.schedule, renameTarget: null, renameName: "", })); notify({ tone: "success", message: "调度方案已改名" }); } catch (error) { const { handleError } = await import("./helpers"); await handleError(get, error, "调度方案改名失败"); } finally { set({ busy: null }); } }, removeArtifact: async (artifact) => { const api = requireApi(); if (get().busy) return; set({ contextMenu: null }); set({ busy: "delete-artifact" }); try { await api.hideScheduleArtifact(artifact.versions_id); set((s: any) => ({ artifacts: s.artifacts.filter( (item: ScheduleArtifact) => item.versions_id !== artifact.versions_id, ), })); notify({ tone: "success", message: "已移出调度列表,稳定版本和历史记录保持不变", }); } catch (error) { const { handleError } = await import("./helpers"); await handleError(get, error, "移出调度列表失败"); } finally { set({ busy: null }); } }, saveSchedule: async () => { const api = requireApi(); const state_ = get(); const { schedule, scheduleForm } = state_; if (!schedule || state_.busy) return; const scheduleFormTyped = scheduleForm as ScheduleForm; const maxConcurrency = Number(scheduleFormTyped.maxConcurrency); if (!scheduleFormTyped.scheduleName.trim()) { notify({ tone: "error", message: "调度名称不能为空" }); return; } if (!Number.isInteger(maxConcurrency) || maxConcurrency < 1) { notify({ tone: "error", message: "最大并发数必须是正整数" }); return; } set({ busy: "save-schedule" }); let updated: Schedule = schedule; try { const { positionDrafts } = await import("./helpers"); for (const [nodeId, position] of positionDrafts.entries()) { updated = await api.updateScheduleNode(updated.schedule_id, nodeId, { workflow_version: updated.workflow_version, position_x: position.position_x, position_y: position.position_y, }); } updated = await api.updateSchedule(updated.schedule_id, { workflow_version: updated.workflow_version, schedule_name: scheduleFormTyped.scheduleName.trim(), description: scheduleFormTyped.description.trim() || null, trigger_type: scheduleFormTyped.triggerType, cron_expression: scheduleFormTyped.triggerType === "cron" ? scheduleFormTyped.cronExpression.trim() : null, timezone: scheduleFormTyped.timezone.trim(), enabled: scheduleFormTyped.enabled, max_concurrency: maxConcurrency, failure_policy: scheduleFormTyped.failurePolicy, }); get().clearAllPositionDrafts(); set((s: any) => ({ schedule: updated, schedules: s.schedules.map((item: Schedule) => item.schedule_id === updated.schedule_id ? { ...updated, nodes: [], edges: [] } : item ), })); notify({ tone: "success", message: "调度配置已保存" }); } catch (error) { const localDraft = applyPositionDrafts(updated); set({ schedule: localDraft }); const { handleError } = await import("./helpers"); await handleError(get, error, "调度配置保存失败"); } finally { set({ busy: null }); } }, runNow: async () => { const api = requireApi(); const state_ = get(); const { schedule, positionDraftCount } = state_; if (!schedule || state_.busy) return; if (positionDraftCount > 0) { notify({ tone: "info", message: "还有节点位置未保存,请先点击'保存配置'再运行", }); return; } if (!schedule.dag_validation.valid || schedule.nodes.length === 0) { notify({ tone: "error", message: "当前调度必须包含有效的非空 DAG 才能运行", }); return; } set({ busy: "run-now" }); try { const created = await api.runScheduleNow(schedule.schedule_id); set((s: any) => ({ runs: [ created, ...s.runs.filter((item: { run_id: string }) => item.run_id !== created.run_id), ].slice(0, 20), schedule: s.schedule ? { ...s.schedule, last_run_at: created.queued_at } : s.schedule, schedules: s.schedules.map((item: Schedule) => item.schedule_id === schedule.schedule_id ? { ...item, last_run_at: created.queued_at } : item ), })); notify({ tone: "success", message: `运行 ${created.run_id.slice(-8)} 已进入队列`, }); } catch (error) { const { handleError } = await import("./helpers"); await handleError(get, error, "立即运行失败"); } finally { set({ busy: null }); } }, addArtifactAt: async (artifact, positionX, positionY) => { const state_ = get(); const { schedule } = state_; if (!schedule) { notify({ tone: "info", message: "请先新建或选择一个调度方案" }); return; } const nodeKey = artifactNodeKey(artifact, schedule); const updated = await withMutation( set, get, "add-node", () => requireApi().createScheduleNode(schedule.schedule_id, { workflow_version: schedule.workflow_version, node_key: nodeKey, node_name: artifact.script_name, versions_id: artifact.versions_id, timeout_seconds: 600, retry_count: 0, retry_interval_sec: 5, position_x: Math.max(20, Math.round(positionX)), position_y: Math.max(20, Math.round(positionY)), arguments_json: {}, env_refs_json: {}, python_version: "3.12", }), `${artifact.script_name} 已加入画布`, ); if (updated) { const created = updated.nodes.find((item) => item.node_key === nodeKey); set({ selectedNodeId: created?.node_id ?? null }); } }, saveNode: async (selectedNode) => { const api = requireApi(); const state_ = get(); const { schedule, nodeForm } = state_; if (!schedule || !selectedNode) return; try { const nodeFormTyped = nodeForm as NodeForm; const timeoutSeconds = Number(nodeFormTyped.timeoutSeconds); const retryCount = Number(nodeFormTyped.retryCount); const retryIntervalSec = Number(nodeFormTyped.retryIntervalSec); if (!nodeFormTyped.nodeName.trim()) throw new Error("节点名称不能为空"); if (!Number.isInteger(timeoutSeconds) || timeoutSeconds < 1) { throw new Error("超时时间必须是正整数"); } if (!Number.isInteger(retryCount) || retryCount < 0) { throw new Error("重试次数必须是非负整数"); } if (!Number.isInteger(retryIntervalSec) || retryIntervalSec < 0) { throw new Error("重试间隔必须是非负整数"); } if (!PYTHON_VERSION_OPTIONS.includes(nodeFormTyped.pythonVersion)) { throw new Error("Python 版本必须是 3.8 / 3.10 / 3.12"); } const argumentsJson = parseObject(nodeFormTyped.argumentsJson, "运行参数"); const rawEnv = parseObject(nodeFormTyped.envRefsJson, "环境引用"); const envRefsJson = Object.fromEntries( Object.entries(rawEnv).map(([key, value]) => { if (typeof value !== "string") { throw new Error("环境引用的值必须是字符串"); } return [key, value]; }), ); await withMutation( set, get, "save-node", () => api.updateScheduleNode( schedule.schedule_id, selectedNode.node_id, { workflow_version: schedule.workflow_version, node_name: nodeFormTyped.nodeName.trim(), timeout_seconds: timeoutSeconds, retry_count: retryCount, retry_interval_sec: retryIntervalSec, arguments_json: argumentsJson, env_refs_json: envRefsJson, python_version: nodeFormTyped.pythonVersion, }, ), "节点配置已保存", ); } catch (error) { const { handleError } = await import("./helpers"); await handleError(get, error, "节点配置保存失败"); } }, removeNode: async (target, options) => { const api = requireApi(); const state_ = get(); const { schedule, selectedNodeId } = state_; const node = target ?? schedule?.nodes.find((item) => item.node_id === selectedNodeId) ?? null; if (!schedule || !node || state_.busy) return "noop"; set({ contextMenu: null }); set({ busy: "delete-node" }); try { let updated: Schedule; try { // 先走普通删除:没有历史记录时不额外打扰用户。 updated = await api.deleteScheduleNode( schedule.schedule_id, node.node_id, schedule.workflow_version, options?.deleteExecutionHistory ? { delete_execution_history: true } : undefined, ); } catch (error) { const requiresHistoryConfirmation = error instanceof ApiRequestError && error.status === 409 && error.code === "node_execution_history_exists"; if (!requiresHistoryConfirmation) throw error; // 交给 UI 弹二次确认;已确认则带 delete_execution_history 重试。 if (!options?.deleteExecutionHistory) { return "needs_history_confirm"; } updated = await api.deleteScheduleNode( schedule.schedule_id, node.node_id, schedule.workflow_version, { delete_execution_history: true }, ); } applyServerUpdatedSchedule(set, updated); if (selectedNodeId === node.node_id) set({ selectedNodeId: null }); notify({ tone: "success", message: "节点及其运行日志已删除" }); return "ok"; } catch (error) { const { handleError } = await import("./helpers"); await handleError(get, error, "删除节点失败"); return "noop"; } finally { set({ busy: null }); } }, removeEdge: async (target) => { const api = requireApi(); const state_ = get(); const { schedule, selectedEdgeId } = state_; const edge = target ?? schedule?.edges.find((item) => item.edge_id === selectedEdgeId) ?? null; if (!schedule || !edge || state_.busy) return; set({ contextMenu: null }); const updated = await withMutation( set, get, "delete-edge", () => api.deleteScheduleEdge( schedule.schedule_id, edge.edge_id, schedule.workflow_version, ), "连线已删除", ); if (updated && selectedEdgeId === edge.edge_id) { set({ selectedEdgeId: null }); } }, checkDag: async () => { const api = requireApi(); const { schedule } = get(); if (!schedule || get().busy) return; set({ busy: "validate" }); try { const result = await api.validateSchedule(schedule.schedule_id); set((s: any) => ({ schedule: s.schedule ? { ...s.schedule, dag_validation: result } : s.schedule, })); notify({ tone: result.valid ? "success" : "error", message: result.valid ? "当前 DAG 通过校验" : "当前 DAG 存在校验问题", }); } catch (error) { const { handleError } = await import("./helpers"); await handleError(get, error, "DAG 校验失败"); } finally { set({ busy: null }); } }, connectTo: async (targetNodeId) => { const state_ = get(); const { schedule, linkSourceId } = state_; if (!schedule || !linkSourceId || state_.busy) return; if (linkSourceId === targetNodeId) { set({ linkSourceId: null }); notify({ tone: "info", message: "已取消连线" }); return; } const sourceId = linkSourceId; set({ linkSourceId: null }); await withMutation( set, get, "create-edge", () => requireApi().createScheduleEdge(schedule.schedule_id, { workflow_version: schedule.workflow_version, source_node_id: sourceId, target_node_id: targetNodeId, }), "节点连线已创建", ); }, }; }; // (avoid unused-import warning for scheduleToForm — referenced for parity with original store) void scheduleToForm;