diff --git a/internal/harnessrl/contracts.ts b/internal/harnessrl/contracts.ts index 02fb059f..9668ad99 100644 --- a/internal/harnessrl/contracts.ts +++ b/internal/harnessrl/contracts.ts @@ -36,6 +36,27 @@ export type StartCaseRunInput = { runtimeApiUrl: string; }; +export type ListCaseRunsInput = { + limit: number; + status?: CaseRunStatus; + before?: { createdAt: string; runId: string }; +}; + +export type CaseRunListItem = { + runId: string; + caseId: string; + workflowId: string; + workflowRunId: string | null; + status: CaseRunStatus; + stage: string; + terminal: boolean; + createdAt: string; + updatedAt: string; + artifactCount: number; + evidenceStatus: string | null; + artifactManifestSha256: string | null; +}; + export type ActivityResult = { runId: string; activityName: string; @@ -47,6 +68,7 @@ export interface HarnessRLRegistry { ensureSchema(): Promise; createRun(input: StartCaseRunInput): Promise; getRun(runId: string): Promise; + listRuns(input: ListCaseRunsInput): Promise; updateRun(runId: string, patch: Partial>): Promise; appendEvent(runId: string, status: CaseRunStatus, stage: string, payload?: Record): Promise; listEvents(runId: string): Promise; diff --git a/internal/harnessrl/harnessrl.test.ts b/internal/harnessrl/harnessrl.test.ts index 8ba07cf7..356e16e0 100644 --- a/internal/harnessrl/harnessrl.test.ts +++ b/internal/harnessrl/harnessrl.test.ts @@ -43,6 +43,49 @@ test("API restart preserves PostgreSQL-shaped registry read model", async () => assert.equal(record.workflowRunId, "temporal-run-1"); }); +test("run history lists shared registry results newest first with an opaque cursor", async () => { + const fixture = await softwareSmokeFixture(); + const registry = new MemoryRegistry(); + const service = createHarnessRLService({ registry, temporal: new FakeTemporal(), cwd: fixture.root, caseRepo: fixture.root, stateRoot: fixture.stateRoot }); + const app = createHarnessRLHttpApp({ service }); + await service.submit({ caseId: fixture.caseId, runId: "cli-smoke-older", runtimeApiUrl: "https://public.example.test" }); + await service.submit({ caseId: fixture.caseId, runId: "cli-smoke-newer", runtimeApiUrl: "https://public.example.test" }); + const older = registry.runs.get("cli-smoke-older")!; + const newer = registry.runs.get("cli-smoke-newer")!; + older.createdAt = "2026-07-17T10:00:00.000Z"; + newer.createdAt = "2026-07-18T10:00:00.000Z"; + await registry.updateRun("cli-smoke-older", { status: "completed", stage: "completed" }); + await registry.updateRun("cli-smoke-newer", { + status: "completed", + stage: "completed", + result: { + artifactManifestSha256: "d".repeat(64), + summary: { artifactCount: 1, artifacts: [{ path: "software-smoke.json" }] }, + evidence: { status: "recorded" } + } + }); + + const firstResponse = await app.fetch(new Request("http://harnessrl.test/v1/caserun/runs?status=completed&limit=1")); + assert.equal(firstResponse.status, 200); + const first = await firstResponse.json() as any; + assert.equal(first.count, 1); + assert.equal(first.runs[0].runId, "cli-smoke-newer"); + assert.equal(first.runs[0].artifactCount, 1); + assert.equal(first.runs[0].evidenceStatus, "recorded"); + assert.equal(first.runs[0].artifactManifestSha256, "d".repeat(64)); + assert.equal("runDir" in first.runs[0], false); + assert.equal(typeof first.nextCursor, "string"); + + const secondResponse = await app.fetch(new Request(`http://harnessrl.test/v1/caserun/runs?limit=1&cursor=${encodeURIComponent(first.nextCursor)}`)); + const second = await secondResponse.json() as any; + assert.equal(second.runs[0].runId, "cli-smoke-older"); + assert.equal(second.nextCursor, null); + + const invalidResponse = await app.fetch(new Request("http://harnessrl.test/v1/caserun/runs?status=done")); + assert.equal(invalidResponse.status, 400); + assert.equal((await invalidResponse.json() as any).error.code, "invalid_status"); +}); + test("worker restart reuses idempotent prepare/build/collect activity results", async () => { const fixture = await softwareSmokeFixture(); const registry = new MemoryRegistry(); @@ -253,6 +296,14 @@ class MemoryRegistry implements HarnessRLRegistry { return structuredClone(record); } async getRun(runId: string) { const value = this.runs.get(runId); return value ? structuredClone(value) : null; } + async listRuns(input: { limit: number; status?: CaseRunStatus; before?: { createdAt: string; runId: string } }) { + return [...this.runs.values()] + .filter((record) => !input.status || record.status === input.status) + .filter((record) => !input.before || record.createdAt < input.before.createdAt || (record.createdAt === input.before.createdAt && record.runId < input.before.runId)) + .sort((left, right) => right.createdAt.localeCompare(left.createdAt) || right.runId.localeCompare(left.runId)) + .slice(0, input.limit) + .map((record) => structuredClone(record)); + } async updateRun(runId: string, patch: any) { const current = this.runs.get(runId); if (!current) throw new Error("missing run"); diff --git a/internal/harnessrl/http.ts b/internal/harnessrl/http.ts index 5460b31c..67c7fc58 100644 --- a/internal/harnessrl/http.ts +++ b/internal/harnessrl/http.ts @@ -17,6 +17,13 @@ export function createHarnessRLHttpApp(options: { service: ReturnType>) { await this.ensureSchema(); const current = await this.getRun(runId); diff --git a/internal/harnessrl/service.ts b/internal/harnessrl/service.ts index 2bdc3af1..e44c021c 100644 --- a/internal/harnessrl/service.ts +++ b/internal/harnessrl/service.ts @@ -1,10 +1,14 @@ import { randomUUID } from "node:crypto"; +import { Buffer } from "node:buffer"; import { mkdir, readFile, readdir, stat } from "node:fs/promises"; import path from "node:path"; -import type { HarnessRLRegistry, HarnessRLTemporalGateway } from "./contracts.ts"; +import type { CaseRunListItem, CaseRunRecord, CaseRunStatus, HarnessRLRegistry, HarnessRLTemporalGateway } from "./contracts.ts"; const RUN_ID_PATTERN = /^[A-Za-z0-9_.:-]{3,180}$/u; +const CASE_RUN_STATUSES = new Set(["queued", "running", "completed", "failed", "blocked", "cancel_requested", "canceled"]); +const DEFAULT_RUN_LIST_LIMIT = 20; +const MAX_RUN_LIST_LIMIT = 100; export function createHarnessRLService(options: { registry: HarnessRLRegistry; temporal: HarnessRLTemporalGateway; cwd: string; caseRepo: string; stateRoot: string }) { const { registry, temporal } = options; @@ -20,6 +24,21 @@ export function createHarnessRLService(options: { registry: HarnessRLRegistry; t } return cases.sort((left, right) => left.caseId.localeCompare(right.caseId)); }, + async listRuns(input: { status?: string; limit?: string; cursor?: string } = {}) { + const status = parseStatus(input.status); + const limit = parseLimit(input.limit); + const records = await registry.listRuns({ status, limit: limit + 1, before: decodeCursor(input.cursor) }); + const hasMore = records.length > limit; + const page = hasMore ? records.slice(0, limit) : records; + const last = page.at(-1); + return { + ok: true, + contractVersion: "hwlab-caserun-runs-v1", + count: page.length, + runs: page.map(runListItem), + nextCursor: hasMore && last ? encodeCursor(last) : null + }; + }, async submit(input: { caseId: string; runId?: string; runtimeApiUrl: string }) { const caseId = opaqueId(input.caseId, "invalid_case_id"); const definition = await loadCase(options.caseRepo, caseId).catch(() => null); @@ -64,6 +83,71 @@ export function createHarnessRLService(options: { registry: HarnessRLRegistry; t }; } +function runListItem(record: CaseRunRecord): CaseRunListItem { + const summary = objectValue(record.result?.summary); + const evidence = objectValue(record.result?.evidence); + const artifacts = Array.isArray(summary?.artifacts) ? summary.artifacts : Array.isArray(evidence?.artifacts) ? evidence.artifacts : []; + return { + runId: record.runId, + caseId: record.caseId, + workflowId: record.workflowId, + workflowRunId: record.workflowRunId, + status: record.status, + stage: record.stage, + terminal: record.terminal, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + artifactCount: finiteNumber(summary?.artifactCount) ?? artifacts.length, + evidenceStatus: stringValue(evidence?.status), + artifactManifestSha256: stringValue(record.result?.artifactManifestSha256) + }; +} + +function parseStatus(value?: string): CaseRunStatus | undefined { + if (value === undefined || value === "") return undefined; + if (!CASE_RUN_STATUSES.has(value as CaseRunStatus)) throw codedError("invalid_status", "status is invalid", { status: value }); + return value as CaseRunStatus; +} + +function parseLimit(value?: string): number { + if (value === undefined || value === "") return DEFAULT_RUN_LIST_LIMIT; + const limit = Number(value); + if (!Number.isInteger(limit) || limit < 1 || limit > MAX_RUN_LIST_LIMIT) { + throw codedError("invalid_limit", `limit must be an integer between 1 and ${MAX_RUN_LIST_LIMIT}`); + } + return limit; +} + +function encodeCursor(record: Pick): string { + return Buffer.from(JSON.stringify({ createdAt: record.createdAt, runId: record.runId }), "utf8").toString("base64url"); +} + +function decodeCursor(value?: string): { createdAt: string; runId: string } | undefined { + if (value === undefined || value === "") return undefined; + try { + const parsed = JSON.parse(Buffer.from(value, "base64url").toString("utf8")); + const createdAt = String(parsed?.createdAt ?? ""); + const runId = opaqueId(parsed?.runId, "invalid_cursor"); + if (!createdAt || Number.isNaN(Date.parse(createdAt))) throw new Error("createdAt is invalid"); + return { createdAt: new Date(createdAt).toISOString(), runId }; + } catch { + throw codedError("invalid_cursor", "cursor is invalid"); + } +} + +function objectValue(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : null; +} + +function stringValue(value: unknown): string | null { + return typeof value === "string" && value ? value : null; +} + +function finiteNumber(value: unknown): number | null { + const number = Number(value); + return Number.isFinite(number) && number >= 0 ? number : null; +} + async function loadCase(caseRepo: string, caseId: string) { const caseDir = path.join(caseRepo, "cases", caseId); const definition = JSON.parse(await readFile(path.join(caseDir, "case.json"), "utf8")); diff --git a/web/hwlab-cloud-web/scripts/caserun-native-fixtures.test.ts b/web/hwlab-cloud-web/scripts/caserun-native-fixtures.test.ts index 39c79f27..dc477555 100644 --- a/web/hwlab-cloud-web/scripts/caserun-native-fixtures.test.ts +++ b/web/hwlab-cloud-web/scripts/caserun-native-fixtures.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { listNativeCases, readNativeAggregate, readNativeEvents, readNativeRun, resetNativeFixtures, startNativeRun } from "./caserun-native-fixtures"; +import { listNativeCases, listNativeRuns, readNativeAggregate, readNativeEvents, readNativeRun, resetNativeFixtures, startNativeRun } from "./caserun-native-fixtures"; test("native CaseRun fixtures expose six deterministic API-owned states", () => { resetNativeFixtures(); @@ -36,6 +36,17 @@ test("native CaseRun fixtures expose six deterministic API-owned states", () => assert.equal(events?.mode, "native-test"); }); +test("native run history exposes a completed CLI-shaped result", () => { + resetNativeFixtures(); + const history = listNativeRuns(); + assert.equal(history.mode, "native-test"); + assert.equal(history.runs[0]?.runId, "cli-software-smoke-passed"); + assert.equal(history.runs[0]?.caseId, "software-smoke"); + assert.equal(history.runs[0]?.status, "completed"); + assert.equal(history.runs[0]?.artifactCount, 1); + assert.equal(readNativeAggregate("cli-software-smoke-passed")?.terminal, true); +}); + test("native blockers and cancellation come from the API fixture", () => { resetNativeFixtures(); const blocked = startNativeRun("native-blocked")!; diff --git a/web/hwlab-cloud-web/scripts/caserun-native-fixtures.ts b/web/hwlab-cloud-web/scripts/caserun-native-fixtures.ts index 605c53ed..915cd800 100644 --- a/web/hwlab-cloud-web/scripts/caserun-native-fixtures.ts +++ b/web/hwlab-cloud-web/scripts/caserun-native-fixtures.ts @@ -14,6 +14,7 @@ interface NativeRunState { caseId: string; cursor: number; steps: NativeStep[]; + createdAt: string; } const MODE = "native-test"; @@ -105,8 +106,8 @@ function runResponse(run: NativeRunState) { stage: step.stage, terminal: step.terminal, blocker: step.blocker ?? null, - createdAt: timestamp(0), - updatedAt: timestamp(run.cursor), + createdAt: run.createdAt, + updatedAt: new Date(Date.parse(run.createdAt) + run.cursor * 1000).toISOString(), facts: { jobId: `job-${run.runId}`, artifactCount: step.status === "completed" ? 1 : 0, @@ -145,11 +146,44 @@ export function startNativeRun(caseId: string) { if (!scenario) return null; runSequence += 1; const runId = `${caseId}-${runSequence}`; - const run: NativeRunState = { runId, caseId, cursor: 0, steps: scenario.steps }; + const run: NativeRunState = { runId, caseId, cursor: 0, steps: scenario.steps, createdAt: timestamp(30 + runSequence) }; runs.set(runId, run); return runResponse(run); } +export function listNativeRuns(query: { status?: string; limit?: number; cursor?: string } = {}) { + const limit = query.limit ?? 30; + const before = query.cursor ? decodeCursor(query.cursor) : null; + const records = [...runs.values()] + .map((run) => runResponse(run)) + .filter((run) => !query.status || run.status === query.status) + .filter((run) => !before || run.createdAt < before.createdAt || (run.createdAt === before.createdAt && run.runId < before.runId)) + .sort((left, right) => right.createdAt.localeCompare(left.createdAt) || right.runId.localeCompare(left.runId)); + const page = records.slice(0, limit); + const hasMore = records.length > limit; + return { + ok: true, + mode: MODE, + contractVersion: "hwlab-caserun-runs-v1", + count: page.length, + runs: page.map((run) => ({ + runId: run.runId, + caseId: run.caseId, + workflowId: run.workflowId, + workflowRunId: run.workflowRunId, + status: run.status, + stage: run.stage, + terminal: run.terminal, + createdAt: run.createdAt, + updatedAt: run.updatedAt, + artifactCount: run.facts.artifactCount, + evidenceStatus: run.facts.evidenceStatus, + artifactManifestSha256: run.references.aggregate.sha256 + })), + nextCursor: hasMore && page.length ? encodeCursor(page.at(-1)!) : null + }; +} + export function readNativeRun(runId: string, advance = true) { const run = runs.get(runId); if (!run) return null; @@ -180,4 +214,23 @@ export function readNativeAggregate(runId: string) { export function resetNativeFixtures(): void { runSequence = 0; runs.clear(); + const steps = scenarios["native-completed"]!.steps; + runs.set("cli-software-smoke-passed", { + runId: "cli-software-smoke-passed", + caseId: "software-smoke", + cursor: steps.length - 1, + steps, + createdAt: timestamp(20) + }); } + +function encodeCursor(run: { createdAt: string; runId: string }): string { + return Buffer.from(JSON.stringify({ createdAt: run.createdAt, runId: run.runId }), "utf8").toString("base64url"); +} + +function decodeCursor(cursor: string): { createdAt: string; runId: string } { + const parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")); + return { createdAt: String(parsed.createdAt), runId: String(parsed.runId) }; +} + +resetNativeFixtures(); diff --git a/web/hwlab-cloud-web/scripts/caserun-native-server.ts b/web/hwlab-cloud-web/scripts/caserun-native-server.ts index da6afc83..81ecbe2d 100644 --- a/web/hwlab-cloud-web/scripts/caserun-native-server.ts +++ b/web/hwlab-cloud-web/scripts/caserun-native-server.ts @@ -1,4 +1,4 @@ -import { listNativeCases, readNativeAggregate, readNativeEvents, readNativeRun, startNativeRun } from "./caserun-native-fixtures"; +import { listNativeCases, listNativeRuns, readNativeAggregate, readNativeEvents, readNativeRun, startNativeRun } from "./caserun-native-fixtures"; const port = Number(requiredEnv("HWLAB_CASERUN_NATIVE_PORT")); const hostname = requiredEnv("HWLAB_CASERUN_NATIVE_HOST"); @@ -36,6 +36,13 @@ const server = Bun.serve({ } if (request.method === "GET" && url.pathname === "/auth/session") return json(authSession()); if (request.method === "GET" && url.pathname === "/v1/caserun/cases") return json(listNativeCases()); + if (request.method === "GET" && url.pathname === "/v1/caserun/runs") { + return json(listNativeRuns({ + status: url.searchParams.get("status") ?? undefined, + limit: url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : undefined, + cursor: url.searchParams.get("cursor") ?? undefined + })); + } if (request.method === "POST" && url.pathname === "/v1/caserun/runs") { const body = await request.json().catch(() => null) as { caseId?: string } | null; if (!body?.caseId) return json({ ok: false, mode: "native-test", error: { code: "case-id-required", message: "caseId is required" } }, 400); diff --git a/web/hwlab-cloud-web/src/api/caserun.ts b/web/hwlab-cloud-web/src/api/caserun.ts index 7fd589d6..0e2f3a9c 100644 --- a/web/hwlab-cloud-web/src/api/caserun.ts +++ b/web/hwlab-cloud-web/src/api/caserun.ts @@ -1,8 +1,16 @@ import { fetchJson } from "./client"; -import type { ApiResult, CaseRunAggregateResponse, CaseRunCasesResponse, CaseRunEventsResponse, CaseRunRunResponse } from "@/types"; +import type { ApiResult, CaseRunAggregateResponse, CaseRunCasesResponse, CaseRunEventsResponse, CaseRunRunsResponse, CaseRunRunResponse } from "@/types"; export const caserunAPI = { cases: (): Promise> => fetchJson("/v1/caserun/cases", { timeoutMs: 12000, timeoutName: "CaseRun cases" }), + runs: (query: { status?: string; limit?: number; cursor?: string } = {}): Promise> => { + const params = new URLSearchParams(); + if (query.status) params.set("status", query.status); + if (query.limit) params.set("limit", String(query.limit)); + if (query.cursor) params.set("cursor", query.cursor); + const suffix = params.size ? `?${params.toString()}` : ""; + return fetchJson(`/v1/caserun/runs${suffix}`, { timeoutMs: 12000, timeoutName: "CaseRun runs" }); + }, startRun: (caseId: string): Promise> => fetchJson("/v1/caserun/runs", { method: "POST", body: JSON.stringify({ caseId }), diff --git a/web/hwlab-cloud-web/src/stores/caserun.test.ts b/web/hwlab-cloud-web/src/stores/caserun.test.ts index 6dd6d3cb..52ae52b5 100644 --- a/web/hwlab-cloud-web/src/stores/caserun.test.ts +++ b/web/hwlab-cloud-web/src/stores/caserun.test.ts @@ -1,8 +1,9 @@ import { createPinia, setActivePinia } from "pinia"; import { beforeEach, expect, test, vi } from "vitest"; -const { casesRequest, startRequest, runRequest, eventsRequest, aggregateRequest } = vi.hoisted(() => ({ +const { casesRequest, runsRequest, startRequest, runRequest, eventsRequest, aggregateRequest } = vi.hoisted(() => ({ casesRequest: vi.fn(), + runsRequest: vi.fn(), startRequest: vi.fn(), runRequest: vi.fn(), eventsRequest: vi.fn(), @@ -10,7 +11,7 @@ const { casesRequest, startRequest, runRequest, eventsRequest, aggregateRequest })); vi.mock("@/api", () => ({ - caserunAPI: { cases: casesRequest, startRun: startRequest, run: runRequest, events: eventsRequest, aggregate: aggregateRequest } + caserunAPI: { cases: casesRequest, runs: runsRequest, startRun: startRequest, run: runRequest, events: eventsRequest, aggregate: aggregateRequest } })); import { useCaseRunStore } from "./caserun"; @@ -18,10 +19,27 @@ import { useCaseRunStore } from "./caserun"; beforeEach(() => { setActivePinia(createPinia()); casesRequest.mockReset(); + runsRequest.mockReset(); startRequest.mockReset(); runRequest.mockReset(); eventsRequest.mockReset(); aggregateRequest.mockReset(); + runsRequest.mockResolvedValue({ ok: true, data: { runs: [], nextCursor: null } }); +}); + +test("run history keeps shared registry summaries and pagination separate from current run", async () => { + runsRequest + .mockResolvedValueOnce({ ok: true, data: { runs: [{ runId: "cli-run-2", caseId: "software-smoke", status: "completed", stage: "completed", terminal: true }], nextCursor: "next-page" } }) + .mockResolvedValueOnce({ ok: true, data: { runs: [{ runId: "cli-run-1", caseId: "software-smoke", status: "completed", stage: "completed", terminal: true }], nextCursor: null } }); + const store = useCaseRunStore(); + + await store.refreshRuns(); + await store.refreshRuns({ append: true }); + + expect(store.runs.map((run) => run.runId)).toEqual(["cli-run-2", "cli-run-1"]); + expect(store.nextRunsCursor).toBeNull(); + expect(store.currentRun).toBeNull(); + expect(runsRequest).toHaveBeenLastCalledWith({ limit: 30, cursor: "next-page" }); }); test("polling follows API terminal instead of status names", async () => { diff --git a/web/hwlab-cloud-web/src/stores/caserun.ts b/web/hwlab-cloud-web/src/stores/caserun.ts index 71f25f16..78c4a701 100644 --- a/web/hwlab-cloud-web/src/stores/caserun.ts +++ b/web/hwlab-cloud-web/src/stores/caserun.ts @@ -1,16 +1,20 @@ import { computed, ref } from "vue"; import { defineStore } from "pinia"; import { caserunAPI } from "@/api"; -import type { CaseRunAggregateResponse, CaseRunCaseSummary, CaseRunEvent, CaseRunRunResponse } from "@/types"; +import type { CaseRunAggregateResponse, CaseRunCaseSummary, CaseRunEvent, CaseRunListItem, CaseRunRunResponse } from "@/types"; export const useCaseRunStore = defineStore("caserun", () => { const cases = ref([]); + const runs = ref([]); + const nextRunsCursor = ref(null); const currentRun = ref(null); const events = ref([]); const aggregate = ref(null); const loading = ref(false); const starting = ref(false); + const runsLoading = ref(false); const error = ref(null); + const runsError = ref(null); const selectedCaseId = computed(() => currentRun.value?.caseId ?? cases.value[0]?.caseId ?? ""); const shouldPoll = computed(() => Boolean(currentRun.value?.runId && currentRun.value.terminal === false)); @@ -23,6 +27,22 @@ export const useCaseRunStore = defineStore("caserun", () => { loading.value = false; } + async function refreshRuns(options: { append?: boolean } = {}): Promise { + if (runsLoading.value) return; + runsLoading.value = true; + runsError.value = null; + const cursor = options.append ? nextRunsCursor.value ?? undefined : undefined; + const result = await caserunAPI.runs({ limit: 30, cursor }); + if (result.ok && result.data) { + const incoming = result.data.runs ?? []; + runs.value = options.append ? [...runs.value, ...incoming.filter((item) => !runs.value.some((existing) => existing.runId === item.runId))] : incoming; + nextRunsCursor.value = result.data.nextCursor ?? null; + } else { + runsError.value = result.error ?? "无法读取最近运行"; + } + runsLoading.value = false; + } + async function startRun(caseId: string): Promise { starting.value = true; error.value = null; @@ -36,18 +56,26 @@ export const useCaseRunStore = defineStore("caserun", () => { } currentRun.value = result.data; await refreshRun(result.data.runId ?? ""); + await refreshRuns(); return result.data; } - async function refreshRun(runId = currentRun.value?.runId ?? ""): Promise { - if (!runId) return; + async function refreshRun(runId = currentRun.value?.runId ?? ""): Promise { + if (!runId) return false; const result = await caserunAPI.run(runId); if (!result.ok || !result.data) { error.value = result.error ?? "无法读取 CaseRun 运行"; - return; + if (currentRun.value?.runId !== runId) { + currentRun.value = null; + events.value = []; + aggregate.value = null; + } + return false; } + error.value = null; currentRun.value = result.data; await Promise.all([refreshEvents(runId), refreshAggregate(runId)]); + return true; } async function refreshEvents(runId = currentRun.value?.runId ?? ""): Promise { @@ -62,5 +90,10 @@ export const useCaseRunStore = defineStore("caserun", () => { if (result.ok && result.data?.runId === runId) aggregate.value = result.data; } - return { cases, currentRun, events, aggregate, loading, starting, error, selectedCaseId, shouldPoll, refreshCases, startRun, refreshRun, refreshEvents, refreshAggregate }; + return { + cases, runs, nextRunsCursor, currentRun, events, aggregate, + loading, runsLoading, starting, error, runsError, + selectedCaseId, shouldPoll, + refreshCases, refreshRuns, startRun, refreshRun, refreshEvents, refreshAggregate + }; }); diff --git a/web/hwlab-cloud-web/src/types/index.ts b/web/hwlab-cloud-web/src/types/index.ts index 4bfd5a1d..a96f3835 100644 --- a/web/hwlab-cloud-web/src/types/index.ts +++ b/web/hwlab-cloud-web/src/types/index.ts @@ -517,6 +517,8 @@ export interface HwlabNodeUpdateMetadata { } export interface CaseRunCaseSummary { caseId: string; title?: string; mode?: string; available?: boolean; hwpodSpec?: string; subject?: Record | null; expected?: Record | null; runtime?: Record | null; [key: string]: unknown } export interface CaseRunCasesResponse { ok?: boolean; mode?: string; contractVersion?: string; count?: number; cases?: CaseRunCaseSummary[]; caseAuthority?: Record; [key: string]: unknown } +export interface CaseRunListItem { runId: string; caseId: string; workflowId?: string; workflowRunId?: string | null; status: string; stage: string; terminal: boolean; createdAt: string; updatedAt: string; artifactCount?: number; evidenceStatus?: string | null; artifactManifestSha256?: string | null; [key: string]: unknown } +export interface CaseRunRunsResponse { ok?: boolean; mode?: string; contractVersion?: string; count?: number; runs?: CaseRunListItem[]; nextCursor?: string | null; [key: string]: unknown } export interface CaseRunEvent { id?: number; at?: string; createdAt?: string; status?: string; stage?: string; payload?: Record; [key: string]: unknown } export interface CaseRunBlocker { code: string; summary: string; layer?: string | null; details?: Record | null } export interface CaseRunReference { href?: string; ref?: string | null; sha256?: string | null; count?: number; relationship?: Record | null; [key: string]: unknown } diff --git a/web/hwlab-cloud-web/src/views/caserun/CaseRunView.vue b/web/hwlab-cloud-web/src/views/caserun/CaseRunView.vue index 5d31270c..b02524f6 100644 --- a/web/hwlab-cloud-web/src/views/caserun/CaseRunView.vue +++ b/web/hwlab-cloud-web/src/views/caserun/CaseRunView.vue @@ -14,7 +14,10 @@ import { FileCheck2, FileCode2, GitBranch, + History, + ListChecks, RefreshCw, + Search, ShieldAlert, TerminalSquare, XCircle, @@ -23,12 +26,15 @@ import PageCommandBar from "@/components/layout/PageCommandBar.vue"; import StatusStrip, { type ConsoleStatusItem } from "@/components/layout/StatusStrip.vue"; import StatusBadge from "@/components/common/StatusBadge.vue"; import { useCaseRunStore } from "@/stores/caserun"; -import type { CaseRunArtifact, CaseRunCaseSummary, CaseRunEvent } from "@/types"; +import type { CaseRunArtifact, CaseRunCaseSummary, CaseRunEvent, CaseRunListItem } from "@/types"; const route = useRoute(); const router = useRouter(); const caserun = useCaseRunStore(); const selectedCaseId = ref(""); +type SidebarTab = "runs" | "cases"; +const sidebarTab = ref("runs"); +const runLookup = ref(""); type EvidenceTab = "aggregate" | "artifacts" | "workflow" | "hwpod"; const evidenceTabs: Array<{ id: EvidenceTab; label: string }> = [ { id: "aggregate", label: "结果" }, @@ -76,12 +82,12 @@ const statusItems = computed(() => [ onMounted(async () => { selectedCaseId.value = typeof route.query.case === "string" ? route.query.case : ""; + if (selectedCaseId.value && !runId.value) sidebarTab.value = "cases"; selectedTab.value = parseTab(route.query.tab); - await caserun.refreshCases(); + await Promise.all([caserun.refreshCases(), caserun.refreshRuns()]); if (!selectedCaseId.value) selectedCaseId.value = caserun.cases[0]?.caseId ?? ""; if (runId.value) { - await caserun.refreshRun(runId.value); - if (caserun.currentRun?.caseId) selectedCaseId.value = caserun.currentRun.caseId; + await loadRun(runId.value); } pollTimer = setInterval(() => { if (caserun.shouldPoll) void caserun.refreshRun(); @@ -93,7 +99,7 @@ onBeforeUnmount(() => { }); watch(() => route.params.runId, async (value) => { - if (typeof value === "string" && value) await caserun.refreshRun(value); + if (typeof value === "string" && value) await loadRun(value); }); watch(() => route.query.tab, (value) => { selectedTab.value = parseTab(value); }); @@ -109,15 +115,34 @@ async function startRun(): Promise { } async function refresh(): Promise { - await caserun.refreshCases(); + await Promise.all([caserun.refreshCases(), caserun.refreshRuns()]); if (runId.value) await caserun.refreshRun(runId.value); } async function selectCase(caseId: string): Promise { + sidebarTab.value = "cases"; selectedCaseId.value = caseId; await router.replace({ name: runId.value ? "CaseRunDetail" : "CaseRun", params: runId.value ? { runId: runId.value } : {}, query: { case: caseId, tab: selectedTab.value } }); } +async function loadRun(value: string): Promise { + runLookup.value = value; + const loaded = await caserun.refreshRun(value); + if (loaded && caserun.currentRun?.caseId) selectedCaseId.value = caserun.currentRun.caseId; +} + +async function openRun(value = runLookup.value): Promise { + const target = value.trim(); + if (!target) return; + sidebarTab.value = "runs"; + runLookup.value = target; + if (target === runId.value) { + await loadRun(target); + return; + } + await router.push({ name: "CaseRunDetail", params: { runId: target } }); +} + async function selectTab(tab: EvidenceTab): Promise { selectedTab.value = tab; await router.replace({ query: { ...route.query, tab } }); @@ -164,6 +189,20 @@ function eventTime(value?: string): string { return Number.isNaN(date.getTime()) ? "--:--:--" : date.toLocaleTimeString("zh-CN", { hour12: false }); } +function historyTime(value?: string): string { + if (!value) return "时间未知"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "时间未知"; + return date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false }); +} + +function historyStatusClass(run: CaseRunListItem): string { + if (run.status === "completed") return "complete"; + if (run.status === "failed" || run.status === "blocked") return "failed"; + if (run.status === "canceled") return "canceled"; + return "running"; +} + function formatDuration(start?: string, end?: string): string { if (!start || !end) return "-"; const elapsed = new Date(end).getTime() - new Date(start).getTime(); @@ -200,27 +239,49 @@ function artifactSize(bytes?: number): string {
@@ -329,6 +390,27 @@ function artifactSize(bytes?: number): string { .caserun-sidebar-heading h2, .inspector-heading h2, .run-heading h2, .section-heading h3 { margin: 3px 0 0; font-size: 15px; font-weight: 780; } .caserun-kicker { color: var(--console-cyan-700); font-family: var(--console-font-mono); font-size: 10px; font-weight: 780; letter-spacing: .08em; } .caserun-count { min-width: 24px; padding: 3px 7px; border: 1px solid var(--console-border); border-radius: 99px; color: var(--console-graphite-600); font-family: var(--console-font-mono); font-size: 11px; text-align: center; } +.caserun-sidebar-tabs { display: grid; grid-template-columns: repeat(2, 1fr); gap: 3px; margin: 8px; border: 1px solid var(--console-border); border-radius: var(--console-radius-sm); background: var(--console-surface-raised); padding: 3px; } +.caserun-sidebar-tabs button { display: inline-flex; min-width: 0; align-items: center; justify-content: center; gap: 6px; border: 0; border-radius: 4px; background: transparent; padding: 7px 5px; color: var(--console-graphite-600); font-size: 11px; cursor: pointer; } +.caserun-sidebar-tabs button[aria-selected="true"] { background: var(--console-cyan-100); color: var(--console-cyan-700); font-weight: 760; } +.caserun-run-lookup { display: grid; grid-template-columns: 16px minmax(0, 1fr) auto; gap: 7px; align-items: center; margin: 0 8px 7px; border: 1px solid var(--console-border); border-radius: var(--console-radius-sm); background: var(--console-surface-raised); padding: 4px 4px 4px 9px; color: var(--console-graphite-500); } +.caserun-run-lookup:focus-within { border-color: var(--console-cyan-500); box-shadow: 0 0 0 2px var(--console-cyan-100); } +.caserun-run-lookup input { width: 100%; min-width: 0; border: 0; outline: 0; background: transparent; color: var(--console-graphite-950); font-family: var(--console-font-mono); font-size: 10px; } +.caserun-run-lookup button { border: 0; border-radius: 4px; background: var(--console-cyan-700); padding: 6px 8px; color: white; font-size: 10px; cursor: pointer; } +.caserun-run-lookup button:disabled { cursor: not-allowed; opacity: .45; } +.caserun-run-list { min-height: 0; flex: 1 1 auto; overflow: auto; overscroll-behavior: contain; padding: 0 8px 8px; } +.caserun-run-row { display: grid; width: 100%; gap: 5px; border: 1px solid transparent; border-bottom-color: var(--console-border); background: transparent; padding: 10px 7px; color: inherit; text-align: left; cursor: pointer; } +.caserun-run-row:hover, .caserun-run-row.selected { border-color: var(--console-cyan-200); border-radius: var(--console-radius-sm); background: var(--console-cyan-100); } +.run-row-top, .run-row-meta { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 7px; } +.run-row-top strong { overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } +.run-row-status { flex: 0 0 auto; border-radius: 99px; padding: 2px 6px; background: var(--console-graphite-100); color: var(--console-graphite-600); font-size: 9px; } +.run-row-status.complete { background: var(--console-green-100); color: var(--console-green-700); } +.run-row-status.failed { background: var(--console-red-100); color: var(--console-red-700); } +.run-row-status.running { background: var(--console-cyan-100); color: var(--console-cyan-700); } +.caserun-run-row code { overflow: hidden; color: var(--console-graphite-600); font-family: var(--console-font-mono); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; } +.run-row-meta { justify-content: flex-start; color: var(--console-graphite-500); font-family: var(--console-font-mono); font-size: 9px; } +.run-row-meta time { margin-left: auto; } +.caserun-load-more { width: 100%; border: 0; background: transparent; padding: 10px; color: var(--console-cyan-700); font-size: 10px; cursor: pointer; } .caserun-case-list { min-height: 0; flex: 1 1 auto; overflow: auto; overscroll-behavior: contain; padding: 8px; } .caserun-case-row { display: grid; width: 100%; grid-template-columns: 28px minmax(0, 1fr) 16px; gap: 8px; align-items: start; border: 1px solid transparent; border-radius: var(--console-radius-sm); background: transparent; padding: 10px 8px; color: inherit; text-align: left; cursor: pointer; } .caserun-case-row:hover, .caserun-case-row.selected { border-color: var(--console-cyan-200); background: var(--console-cyan-100); }