feat: 完整游戏运维功能
This commit is contained in:
@@ -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 次尝试");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user