feat: 完整游戏运维功能

This commit is contained in:
npc0-hue
2026-07-18 09:04:01 +08:00
parent f3b14b7945
commit 48b8ad8d6c
187 changed files with 16607 additions and 1140 deletions
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { projectRuntimeTrackedJob, runtimeBuildStages } from "./RuntimeTaskProgress";
describe("distribution build job progress", () => {
it("projects worker progress messages onto the real build stage", () => {
const projection = projectRuntimeTrackedJob(runtimeBuildStages, {
id: "job-build-1",
state: "running",
progress: { percent: 65, message: "build_compile: compiling target executable" }
});
expect(projection).toMatchObject({ status: "running", percent: 65, currentStageKey: "build_compile" });
expect(projection.stageStatus).toMatchObject({ git_sync: "completed", env_check: "completed", deps_download: "completed", build_compile: "running", package_finalize: "pending" });
});
it("keeps a failed worker stage failed instead of timer-completing later stages", () => {
const projection = projectRuntimeTrackedJob(runtimeBuildStages, {
id: "job-build-2",
state: "failed",
progress: { percent: 65, message: "build_compile: Go compilation failed" }
});
expect(projection).toMatchObject({ status: "failed", percent: 65, currentStageKey: "build_compile" });
expect(projection.stageStatus.build_compile).toBe("failed");
expect(projection.stageStatus.package_finalize).toBe("pending");
});
it("marks every stage complete only when the backend job succeeds", () => {
const projection = projectRuntimeTrackedJob(runtimeBuildStages, {
id: "job-build-3",
state: "succeeded",
progress: { percent: 100, message: "package_finalize: build artifact available" }
});
expect(projection.status).toBe("succeeded");
expect(projection.percent).toBe(100);
expect(Object.values(projection.stageStatus)).toEqual(runtimeBuildStages.map(() => "completed"));
});
it("keeps durable retry-wait jobs active and exposes the next attempt", () => {
const projection = projectRuntimeTrackedJob(runtimeBuildStages, {
id: "job-build-retry",
state: "retrying",
progress: { percent: 10 },
attempt: 1,
retryPolicy: { maxAttempts: 3 },
nextAttemptAt: "2026-07-18T12:00:02Z"
});
expect(projection.status).toBe("running");
expect(projection.message).toContain("第 2 次尝试");
});
});
+43 -13
View File
@@ -96,8 +96,21 @@ interface RuntimeTaskRunOptions<T> {
export interface RuntimeTrackedJob {
id: string;
state: "queued" | "accepted" | "running" | "succeeded" | "failed" | "cancelled";
state: "queued" | "accepted" | "running" | "retrying" | "succeeded" | "failed" | "cancelled";
progress: { percent: number; message?: string };
attempt?: number;
retryPolicy?: { maxAttempts: number };
nextAttemptAt?: string;
cancelReason?: string;
reconcileOutcome?: string;
}
export interface RuntimeTrackedJobProjection {
status: RuntimeTaskStatus;
percent: number;
currentStageKey: string;
stageStatus: Record<string, RuntimeTaskStageStatus>;
message: string;
}
interface RuntimeTrackedTaskOptions<T> {
@@ -242,20 +255,15 @@ export function useRuntimeTaskController() {
while (true) {
const job = await poll(started.jobId);
const message = job.progress.message?.trim() || job.state;
const stageIndex = trackedStageIndex(stages, message, job.progress.percent);
const stage = stages[stageIndex] ?? stages[0];
const stageStatus = Object.fromEntries(
stages.map((item, index) => [item.key, index < stageIndex || job.state === "succeeded" ? "completed" : index === stageIndex ? "running" : "pending"])
) as Record<string, RuntimeTaskStageStatus>;
const projection = projectRuntimeTrackedJob(stages, job);
setTask((current) =>
current
? {
...current,
percent: Math.max(current.percent, Math.min(99, job.progress.percent)),
currentStageKey: stage?.key ?? current.currentStageKey,
stageStatus,
logs: current.logs[current.logs.length - 1] === message ? current.logs : appendRuntimeLog(current.logs, message)
percent: job.state === "succeeded" ? 100 : Math.max(current.percent, projection.percent),
currentStageKey: projection.currentStageKey || current.currentStageKey,
stageStatus: projection.stageStatus,
logs: current.logs[current.logs.length - 1] === projection.message ? current.logs : appendRuntimeLog(current.logs, projection.message)
}
: current
);
@@ -275,14 +283,15 @@ export function useRuntimeTaskController() {
return started.value;
}
if (job.state === "failed" || job.state === "cancelled") {
const error = message || (job.state === "cancelled" ? "构建已取消" : "构建失败");
const error = projection.message || (job.state === "cancelled" ? "构建已取消" : "构建失败");
setTask((current) =>
current
? {
...current,
status: "failed",
error,
stageStatus: { ...stageStatus, [stage?.key ?? firstStage]: "failed" },
currentStageKey: projection.currentStageKey || firstStage,
stageStatus: projection.stageStatus,
logs: appendRuntimeLog(current.logs, error)
}
: current
@@ -484,6 +493,27 @@ function trackedStageIndex(stages: RuntimeTaskStage[], message: string, percent:
return Math.min(index, Math.max(0, stages.length - 1));
}
export function projectRuntimeTrackedJob(stages: RuntimeTaskStage[], job: RuntimeTrackedJob): RuntimeTrackedJobProjection {
const retryLabel = job.state === "retrying" ? `等待第 ${Math.min((job.attempt ?? 0) + 1, job.retryPolicy?.maxAttempts ?? (job.attempt ?? 0) + 1)} 次尝试${job.nextAttemptAt ? `${new Date(job.nextAttemptAt).toLocaleString()}` : ""}` : "";
const message = job.cancelReason?.trim() || job.progress.message?.trim() || retryLabel || job.reconcileOutcome?.trim() || job.state;
const stageIndex = trackedStageIndex(stages, message, job.progress.percent);
const currentStageKey = stages[stageIndex]?.key ?? stages[0]?.key ?? "start";
const terminalFailure = job.state === "failed" || job.state === "cancelled";
const stageStatus = Object.fromEntries(
stages.map((item, index) => [
item.key,
job.state === "succeeded" || index < stageIndex ? "completed" : index === stageIndex ? (terminalFailure ? "failed" : "running") : "pending"
])
) as Record<string, RuntimeTaskStageStatus>;
return {
status: job.state === "succeeded" ? "succeeded" : terminalFailure ? "failed" : "running",
percent: job.state === "succeeded" ? 100 : Math.min(99, Math.max(0, job.progress.percent)),
currentStageKey,
stageStatus,
message
};
}
function appendRuntimeLog(logs: string[], line: string): string[] {
return [...logs, line].slice(-8);
}