import { type UniDeskConfig } from "./config"; import { coreInternalFetch } from "./microservices"; function stringOption(args: string[], name: string): string | undefined { const index = args.indexOf(name); if (index === -1) return undefined; const raw = args[index + 1]; if (raw === undefined || raw.length === 0) throw new Error(`${name} requires a non-empty value`); return raw; } function numberOption(args: string[], name: string, defaultValue: number): number { const raw = stringOption(args, name); if (raw === undefined) return defaultValue; const value = Number(raw); if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} must be a positive integer`); return value; } function booleanOption(args: string[], name: string, defaultValue: boolean): boolean { const raw = stringOption(args, name); if (raw === undefined) return defaultValue; if (["1", "true", "yes", "on"].includes(raw.toLowerCase())) return true; if (["0", "false", "no", "off"].includes(raw.toLowerCase())) return false; throw new Error(`${name} must be true or false`); } function responseBody(response: unknown): Record { if (typeof response !== "object" || response === null || Array.isArray(response)) return {}; const body = (response as { body?: unknown }).body; return typeof body === "object" && body !== null && !Array.isArray(body) ? body as Record : {}; } function terminalRunStatus(status: unknown): boolean { return ["succeeded", "failed", "skipped"].includes(String(status || "")); } function isPlainNumeric(value: string): boolean { return /^[0-9]+$/.test(value); } export function scheduleRunsScope(args: string[]): { scheduleId: string | null; limit: number } { const limit = numberOption(args, "--limit", 50); let scheduleId: string | null = null; for (let index = 1; index < args.length; index += 1) { const arg = args[index]; if (arg === "--limit") { index += 1; continue; } if (arg.startsWith("--")) { throw new Error(`unsupported schedule runs option: ${arg}`); } if (scheduleId !== null) { throw new Error("schedule runs accepts at most one schedule id"); } if (isPlainNumeric(arg)) { throw new Error("schedule runs requires a non-numeric schedule id; use schedule runs --limit N for the global history view"); } scheduleId = arg; } return { scheduleId, limit, }; } async function waitForScheduleRun(scheduleId: string, runId: string, timeoutMs: number): Promise { const started = Date.now(); let latest: unknown = null; const observeCommand = `bun scripts/cli.ts schedule runs ${scheduleId} --limit 20`; while (Date.now() - started < timeoutMs) { latest = coreInternalFetch(`/api/schedules/${encodeURIComponent(scheduleId)}/runs?limit=20`); const runs = responseBody(latest).runs; if (Array.isArray(runs)) { const run = runs.find((item) => typeof item === "object" && item !== null && (item as { id?: unknown }).id === runId); if (run !== undefined && terminalRunStatus((run as { status?: unknown }).status)) return { ok: true, run }; } await Bun.sleep(2000); } return { ok: false, timedOut: true, timeoutMs, runId, scheduleId, observeCommand, latest }; } export function scheduleRunObservation(scheduleId: string, response: unknown, wait: unknown): Record { const run = responseBody(response).run as { id?: unknown } | undefined; const runId = typeof run?.id === "string" ? run.id : null; return { trigger: response, originalRunId: run?.id ?? null, scheduleId, newRunId: runId, observeCommand: `bun scripts/cli.ts schedule runs ${scheduleId} --limit 20`, wait, }; } export function scheduleRetryRunObservation(originalRunId: string, response: unknown): Record { const body = responseBody(response); const scheduleId = typeof body.scheduleId === "string" ? body.scheduleId : null; const newRunId = typeof body.newRunId === "string" ? body.newRunId : null; return { originalRunId: String(body.originalRunId ?? originalRunId), scheduleId, newRunId, observeCommand: scheduleId !== null && newRunId !== null ? `bun scripts/cli.ts schedule runs ${scheduleId} --limit 20` : null, response, }; } function pgdataBackupScheduleBody(config: UniDeskConfig, args: string[]): Record { const id = stringOption(args, "--id") ?? "unidesk-pgdata-baidu-daily"; const timeOfDay = stringOption(args, "--time") ?? "03:30"; const remoteBaseDir = stringOption(args, "--remote-base") ?? "/SERVER_DATA/UNIDESK_PG_DATA"; const stagingSubdir = stringOption(args, "--staging-subdir") ?? "server-data/unidesk-pg-data"; const enabled = args.includes("--disabled") ? false : booleanOption(args, "--enabled", true); const timeoutMs = numberOption(args, "--timeout-ms", 60 * 60_000); return { id, name: "PGDATA daily Baidu Netdisk backup", description: "Daily PostgreSQL physical base backup uploaded to Baidu Netdisk /SERVER_DATA with monthly rotation.", enabled, concurrencyPolicy: "skip", schedule: { type: "daily", timeOfDay, timezone: config.project.timezone || "Etc/UTC" }, action: { type: "pgdata_backup", volumeName: config.database.volume, remoteBaseDir, stagingSubdir, baiduBaseUrl: "http://baidu-netdisk:4244", timeoutMs, cleanupLocal: true, }, }; } export async function runScheduleCommand(config: UniDeskConfig, args: string[]): Promise { const [action = "list", idArg] = args; if (action === "list") return coreInternalFetch("/api/schedules?limit=200"); if (action === "get") { if (!idArg) throw new Error("schedule get requires schedule id"); return coreInternalFetch(`/api/schedules/${encodeURIComponent(idArg)}`); } if (action === "runs") { const { scheduleId, limit } = scheduleRunsScope(args); return scheduleId ? coreInternalFetch(`/api/schedules/${encodeURIComponent(scheduleId)}/runs?limit=${limit}`) : coreInternalFetch(`/api/schedules/runs?limit=${limit}`); } if (action === "delete") { if (!idArg) throw new Error("schedule delete requires schedule id"); return coreInternalFetch(`/api/schedules/${encodeURIComponent(idArg)}`, { method: "DELETE" }); } if (action === "run") { if (!idArg) throw new Error("schedule run requires schedule id"); const response = coreInternalFetch(`/api/schedules/${encodeURIComponent(idArg)}/run`, { method: "POST", body: {} }); const run = responseBody(response).run as { id?: unknown } | undefined; const waitMs = numberOption(args, "--wait-ms", 0); const wait = waitMs > 0 && typeof run?.id === "string" ? await waitForScheduleRun(idArg, run.id, waitMs) : null; return scheduleRunObservation(idArg, response, wait); } if (action === "retry-run") { if (!idArg) throw new Error("schedule retry-run requires failed run id"); const response = coreInternalFetch(`/api/schedules/runs/${encodeURIComponent(idArg)}/retry`, { method: "POST", body: {} }); return scheduleRetryRunObservation(idArg, response); } if (action === "upsert-pgdata-backup" || action === "pgdata-backup") { const body = pgdataBackupScheduleBody(config, args); const id = String(body.id || "unidesk-pgdata-baidu-daily"); return coreInternalFetch(`/api/schedules/${encodeURIComponent(id)}`, { method: "PUT", body }); } throw new Error("schedule command must be one of: list, get, runs, run, retry-run, delete, upsert-pgdata-backup"); }