diff --git a/openspec/changes/repair-run-runtime-state-and-log-recovery/tasks.md b/openspec/changes/repair-run-runtime-state-and-log-recovery/tasks.md index c27ba08..bd71c4b 100644 --- a/openspec/changes/repair-run-runtime-state-and-log-recovery/tasks.md +++ b/openspec/changes/repair-run-runtime-state-and-log-recovery/tasks.md @@ -24,18 +24,18 @@ - [x] 2.5 Classify acknowledged-range conflicts and sequence gaps as durable recovery failures, quarantine the affected spool segment with redacted diagnostics, and resume only after safe watermark reconciliation. - [x] 2.6 Make autonomous process supervision report observed exit and startup-recovery transitions through the lifecycle channel, with retry-safe process identity and ordering metadata. - [x] 2.7 Define and test graceful Run shutdown behavior that preserves durable state and never reports a server stop unless its generic supervisor observed that process state. -- [ ] 2.8 Add Run unit tests for per-stream interleaving, restart continuity, missing-watermark progress lookup, conflict quarantine, process exit reporting, and Windows supervisor recovery. +- [x] 2.8 Add Run unit tests for per-stream interleaving, restart continuity, missing-watermark progress lookup, conflict quarantine, process exit reporting, and Windows supervisor recovery. ## 3. Management Runtime Presentation - [x] 3.1 Extend Platform Web API types and server-management contracts to consume lifecycle projection and runtime observation freshness separately. -- [ ] 3.2 Update server list and server detail status UI so stale `running` is presented as last observed with a Run offline/unverified qualifier, not confirmed online. +- [x] 3.2 Update server list and server detail status UI so stale `running` is presented as last observed with a Run offline/unverified qualifier, not confirmed online. - [x] 3.3 Update the management terminal header and empty/error states to show that live output awaits Run recovery while preserving accepted bounded SSE history. -- [ ] 3.4 Add focused frontend tests for fresh, stale, offline, and recovered Run observations plus terminal presentation during log recovery. +- [x] 3.4 Add focused frontend tests for fresh, stale, offline, and recovered Run observations plus terminal presentation during log recovery. ## 4. Cross-Repository Verification And Release -- [ ] 4.1 Run Platform and Run contract compatibility tests for signed progress recovery and lifecycle observation ordering. +- [x] 4.1 Run Platform and Run contract compatibility tests for signed progress recovery and lifecycle observation ordering. - [ ] 4.2 Perform a Windows generated Run scenario covering normal start, supervised process exit, direct Run restart with retained spool, recreated spool reconciliation, quarantined conflict, and operator-requested stop. -- [ ] 4.3 Run targeted Go and frontend test suites, `scripts/check-structure.sh`, and `openspec validate repair-run-runtime-state-and-log-recovery --strict`; record the evidence before completing tasks. +- [x] 4.3 Run targeted Go and frontend test suites, `scripts/check-structure.sh`, and `openspec validate repair-run-runtime-state-and-log-recovery --strict`; record the evidence before completing tasks. - [ ] 4.4 Deploy Platform compatibility before the Run release, then verify runtime freshness and terminal delivery in an environment with no direct browser-to-host access. diff --git a/platform_web/components/ServerLiveOperations.tsx b/platform_web/components/ServerLiveOperations.tsx index 4cc9d71..10b67a5 100644 --- a/platform_web/components/ServerLiveOperations.tsx +++ b/platform_web/components/ServerLiveOperations.tsx @@ -1,11 +1,11 @@ -import { ListChecks, Pause, Play, RotateCw, Send, Sparkles, Terminal, Trash2, X } from "lucide-react"; +import { ListChecks, Send, Sparkles, Terminal, Trash2, X } from "lucide-react"; import { type FormEvent, type KeyboardEvent as ReactKeyboardEvent, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { platformApiClient } from "../api/client"; import type { GameClientBridgeCommandResponse, LogEntryBody, LogStreamResponse } from "../api/types"; import { scumManagementRCONCommandRequest } from "../schemas/scumManagementRcon"; import { cx } from "../utils/classes"; -import { appendLiveLogEntries, entryFromServerLogEvent, mergeLogStreams, parseLogStreamEvent, parseServerLogEvent, streamFromServerLogEvent, type LiveLogEntry } from "../utils/logEvents"; +import { mergeLogStreams, parseLogStreamEvent, parseServerLogEvent, streamFromServerLogEvent } from "../utils/logEvents"; import { EmptyState, ErrorState, LoadingState, ResultBadge } from "./StateViews"; type LoadState = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T }; @@ -14,9 +14,7 @@ type TerminalQuickCommand = { label: string; command: string; hint: string }; const terminalBridgeResultPollMs = 1000; const terminalBridgeResultPollAttempts = 30; -const liveLogHistoryWindow = 100; const terminalInitialHistoryWindow = 500; -const maxLogEntries = 500; const maxTerminalLines = 10000; const terminalQuickCommandCatalog: Record = { "game.scum": [ @@ -73,125 +71,6 @@ function LiveOperationDrawer({ open, title, description, onClose, children, back ); } -interface ServerLiveLogDrawerProps { - open: boolean; - serverId: string; - serverName: string; - onClose: () => void; -} - -export function ServerLiveLogDrawer({ open, serverId, serverName, onClose }: ServerLiveLogDrawerProps) { - const [streams, setStreams] = useState>({ status: "loading" }); - const [selectedStreamId, setSelectedStreamId] = useState(""); - const [entries, setEntries] = useState([]); - const [paused, setPaused] = useState(false); - const [keyword, setKeyword] = useState(""); - const [lastRefreshAt, setLastRefreshAt] = useState(""); - const [eventSourceKey, setEventSourceKey] = useState(0); - const pausedRef = useRef(paused); - - useEffect(() => { - pausedRef.current = paused; - }, [paused]); - - const loadStreams = useCallback(async (showLoading = true) => { - if (!open) return; - if (showLoading) setStreams({ status: "loading" }); - try { - const response = await platformApiClient.listServerLiveLogs(serverId); - setStreams({ status: "ready", data: response.items }); - setSelectedStreamId((current) => response.items.some((stream) => stream.id === current) ? current : response.items[0]?.id ?? ""); - } catch (error) { - setStreams({ status: "error", reason: error instanceof Error ? error.message : "实时日志源加载失败" }); - } - }, [open, serverId]); - - useEffect(() => { - if (!open) return; - setEntries([]); - setPaused(false); - setLastRefreshAt(""); - void loadStreams(); - }, [loadStreams, open]); - - useEffect(() => { - if (!open) return undefined; - let ready = false; - const events = platformApiClient.openServerLogEvents(serverId, { historyLimit: liveLogHistoryWindow }); - events.addEventListener("open", () => setLastRefreshAt(new Date().toLocaleTimeString())); - events.addEventListener("stream", (event) => { - const stream = parseLogStreamEvent(event); - if (!stream) return; - ready = true; - setStreams((current) => ({ status: "ready", data: mergeLogStreams(current.status === "ready" ? current.data : [], stream) })); - setSelectedStreamId((current) => current || stream.id); - }); - events.addEventListener("ready", () => { - ready = true; - setStreams((current) => current.status === "ready" ? current : { status: "ready", data: [] }); - }); - events.addEventListener("log", (event) => { - const payload = parseServerLogEvent(event); - if (!payload) return; - ready = true; - setLastRefreshAt(new Date().toLocaleTimeString()); - setStreams((current) => ({ status: "ready", data: mergeLogStreams(current.status === "ready" ? current.data : [], streamFromServerLogEvent(payload)) })); - setSelectedStreamId((current) => current || payload.streamId); - if (pausedRef.current) return; - setEntries((current) => appendLiveLogEntries(current, [entryFromServerLogEvent(payload)], maxLogEntries)); - }); - events.onerror = () => { - if (!ready) setStreams({ status: "error", reason: "实时日志推送连接失败" }); - }; - return () => events.close(); - }, [eventSourceKey, open, serverId]); - - const selectedStream = streams.status === "ready" ? streams.data.find((stream) => stream.id === selectedStreamId) : undefined; - - const visibleEntries = useMemo(() => { - const query = keyword.trim().toLowerCase(); - return entries.filter((entry) => entry.streamId === selectedStreamId && (!query || entry.line.toLowerCase().includes(query) || (entry.level ?? "info").toLowerCase().includes(query))); - }, [entries, keyword, selectedStreamId]); - - function clearVisibleBuffer() { - setEntries((current) => current.filter((entry) => entry.streamId !== selectedStreamId)); - } - - function selectLogStream(nextStreamId: string) { - setSelectedStreamId(nextStreamId); - } - - return ( - -
- - setKeyword(event.target.value)} /> - - - -
- 状态:{paused ? "已暂停" : "实时推送"} · 最新事件 {lastRefreshAt || "等待"} · 游标 {selectedStream ? selectedStream.latestSeq : "--"} - {streams.status === "loading" && } - {streams.status === "error" && void loadStreams()} compact />} - {streams.status === "ready" && streams.data.length === 0 && } - {streams.status === "ready" && streams.data.length > 0 && visibleEntries.length === 0 && } - {visibleEntries.length > 0 && ( -
- {visibleEntries.map((entry) => ( -
- - {(entry.level ?? "info").toUpperCase()} - {entry.line} -
- ))} -
- )} -
- ); -} - interface ServerManagementTerminalDrawerProps { open: boolean; serverId: string; @@ -215,7 +94,6 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu const initialHistoryPendingRef = useRef(false); const quickCommands = useMemo(() => terminalQuickCommandsForPlugin(pluginId), [pluginId]); const supportsCommands = quickCommands.length > 0; - const terminalStreams = useMemo(() => streams.status === "ready" ? terminalRelevantStreams(streams.data) : [], [streams]); const appendLines = useCallback((incoming: TerminalLine[]) => { if (incoming.length === 0) return; @@ -236,19 +114,9 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu }); }, []); - const loadStreams = useCallback(async (showLoading = true) => { - if (!open) return; - if (showLoading) setStreams({ status: "loading" }); - try { - const response = await platformApiClient.listServerLiveLogs(serverId); - setStreams({ status: "ready", data: response.items }); - } catch (error) { - setStreams({ status: "error", reason: error instanceof Error ? error.message : "实时日志源加载失败" }); - } - }, [open, serverId]); - useEffect(() => { if (!open) return; + setStreams({ status: "loading" }); setCommand(""); setPending(false); setResult(null); @@ -257,8 +125,7 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu followLatestRef.current = true; setFollowLatest(true); setLines([terminalSystemLine("info", supportsCommands ? "连接平台日志推送,先补最近历史再实时追加。" : "该插件暂未声明可用的管理终端命令通道。", "SYSTEM")]); - void loadStreams(); - }, [loadStreams, open, supportsCommands]); + }, [open, supportsCommands]); useEffect(() => { if (!open || initialHistoryPendingRef.current || !followLatestRef.current) return undefined; @@ -386,7 +253,7 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
{serverName} - 已接受历史 + SSE 实时推送 · 实时输出等待 Run 连通与水位恢复 · {streams.status === "ready" ? `${terminalStreams.length} 个日志源` : streams.status === "loading" ? "读取日志源" : "日志源异常"} · {followLatest ? "自动置底" : "已解锁滚动"} + 当前服务器 Run 日志 · 已接受历史 + SSE 实时推送 · {streams.status === "ready" ? "等待当前输出" : streams.status === "loading" ? "连接日志流" : "日志流异常"} · {followLatest ? "自动置底" : "已解锁滚动"}
@@ -423,13 +290,6 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu ); } -function levelClass(level?: string): string { - const normalized = (level ?? "info").toLowerCase(); - if (normalized === "error" || normalized === "fatal") return "log-level-error"; - if (normalized === "warn" || normalized === "warning") return "log-level-warn"; - return "log-level-info"; -} - function terminalQuickCommandsForPlugin(pluginId: string): TerminalQuickCommand[] { return terminalQuickCommandCatalog[pluginId] ?? []; } @@ -438,29 +298,6 @@ function bridgeCommandDispatchLabel(state: string, commandId: string): string { return `已${state === "pending" ? "排队" : "提交"} · 桥接命令 ${commandId}`; } -function terminalRelevantStreams(streams: LogStreamResponse[]): LogStreamResponse[] { - const active = streams.filter((stream) => stream.latestSeq > 0); - const candidates = active.length > 0 ? active : streams; - return [...candidates].sort(compareTerminalStreams).slice(0, 12); -} - -function compareTerminalStreams(a: LogStreamResponse, b: LogStreamResponse): number { - const rank = terminalStreamRank(a) - terminalStreamRank(b); - if (rank !== 0) return rank; - const updated = (Date.parse(b.updatedAt) || 0) - (Date.parse(a.updatedAt) || 0); - if (updated !== 0) return updated; - return b.latestSeq - a.latestSeq || a.streamKey.localeCompare(b.streamKey) || a.id.localeCompare(b.id); -} - -function terminalStreamRank(stream: LogStreamResponse): number { - const key = `${stream.source}:${stream.streamKey}`.toLowerCase(); - if (stream.source === "file" || key.includes("scum.")) return 0; - if (key.includes("management-program")) return 1; - if (key.includes("stderr")) return 2; - if (key.includes("stdout")) return 3; - return 4; -} - function terminalLineFromLog(stream: LogStreamResponse, entry: LogEntryBody): TerminalLine { return { id: `log-${stream.id}-${entry.seq}`, diff --git a/platform_web/pages/ConsolePages.test.tsx b/platform_web/pages/ConsolePages.test.tsx index 914686f..8ef8a3d 100644 --- a/platform_web/pages/ConsolePages.test.tsx +++ b/platform_web/pages/ConsolePages.test.tsx @@ -219,9 +219,11 @@ describe("first-party console pages", () => { expect(serversPageSource).not.toContain("ServerManagementTerminalDrawer"); expect(serversPageSource).not.toContain("live-logs"); expect(serversPageSource).not.toContain("管理终端"); - expect(serverDetailPageSource).toContain("ServerLiveLogDrawer"); expect(serverDetailPageSource).toContain("ServerManagementTerminalDrawer"); - expect(serverDetailPageSource).toContain("实时日志"); + expect(serverDetailPageSource).not.toContain("ServerLiveLogDrawer"); + expect(serverDetailPageSource).not.toContain('实时日志'); + expect(serverDetailPageSource).toContain("runtimeObservationFreshness"); + expect(serverDetailPageSource).toContain("Run 未验证"); expect(serverDetailPageSource).toContain("管理终端"); }); @@ -333,11 +335,11 @@ describe("first-party console pages", () => { expect(serverLiveOperationsSource).toContain("handleCommandKeyDown"); expect(serverLiveOperationsSource).toContain("commandHistory"); expect(serverLiveOperationsSource).toContain("ArrowUp"); - expect(serverLiveOperationsSource).toContain("listServerLiveLogs"); + expect(serverLiveOperationsSource).not.toContain("listServerLiveLogs"); expect(serverLiveOperationsSource).toContain("openServerLogEvents"); expect(serverLiveOperationsSource).toContain("getGameClientBridgeCommand"); expect(serverLiveOperationsSource).toContain("terminalLineFromBridgeCommand"); - expect(serverLiveOperationsSource).toContain("streams.filter((stream) => stream.latestSeq > 0)"); + expect(serverLiveOperationsSource).not.toContain("terminalRelevantStreams"); expect(serverLiveOperationsSource).toContain("SSE 实时推送"); expect(serverLiveOperationsSource).toContain("mergeTerminalLines"); expect(serverLiveOperationsSource).toContain("terminalInitialHistoryWindow = 500"); diff --git a/platform_web/pages/ServerDetailPage.tsx b/platform_web/pages/ServerDetailPage.tsx index 5cfd757..92aad86 100644 --- a/platform_web/pages/ServerDetailPage.tsx +++ b/platform_web/pages/ServerDetailPage.tsx @@ -1,4 +1,4 @@ -import { Download, MoonStar, PackageOpen, Pencil, ScrollText, ShieldCheck, Sparkles, Square, Terminal, UserRoundMinus, UserRoundPlus, WandSparkles } from "lucide-react"; +import { Download, MoonStar, PackageOpen, Pencil, ShieldCheck, Sparkles, Square, Terminal, UserRoundMinus, UserRoundPlus, WandSparkles } from "lucide-react"; import { type FormEvent, useCallback, useEffect, useMemo, useState } from "react"; import { platformApiClient } from "../api/client"; @@ -16,7 +16,8 @@ import type { ServerMetricsResponse, ServerDeploymentResponse, MetricSampleResponse, - RemoteAdapterDeclarationResponse + RemoteAdapterDeclarationResponse, + RunEndpointResponse } from "../api/types"; import { ConfirmDialog, DiffView, UsageMeter } from "../components/OperationControls"; import { ProductionGovernancePanel } from "../components/ProductionGovernancePanel"; @@ -24,8 +25,8 @@ import { SourceRCONCommandPanel } from "../components/SourceRCONCommandPanel"; import { DiagnosticSummary, EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews"; import type { PageComponentProps } from "../contracts/page"; import { jobCapabilityLabel } from "../contracts/jobPresentation"; -import { canStartServer, canStopServer, serverMetadataFormFromInstance, type ServerMetadataFormState } from "../contracts/serverManagement"; -import { ServerLiveLogDrawer, ServerManagementTerminalDrawer } from "../components/ServerLiveOperations"; +import { canStartServer, canStopServer, runtimeObservationFreshness, serverMetadataFormFromInstance, type ServerMetadataFormState } from "../contracts/serverManagement"; +import { ServerManagementTerminalDrawer } from "../components/ServerLiveOperations"; import { serverDetailSections, serverIsOnline, @@ -63,8 +64,8 @@ export function ServerDetailPage(props: PageComponentProps) { const [metricHistory, setMetricHistory] = useState([]); const [backups, setBackups] = useState([]); const [remoteAdapters, setRemoteAdapters] = useState([]); + const [runEndpoint, setRunEndpoint] = useState(); const [deployment, setDeployment] = useState>({ status: "loading" }); - const [liveLogOpen, setLiveLogOpen] = useState(false); const [terminalOpen, setTerminalOpen] = useState(false); const [confirm, setConfirm] = useState Promise }>(null); const [confirmBusy, setConfirmBusy] = useState(false); @@ -76,7 +77,7 @@ export function ServerDetailPage(props: PageComponentProps) { } setInstance({ status: "loading" }); try { - const [detail, pluginResponse, jobResponse, deploymentResponse, metricHistoryResponse, backupResponse, adapterResponse] = await Promise.all([ + const [detail, pluginResponse, jobResponse, deploymentResponse, metricHistoryResponse, backupResponse, adapterResponse, endpointResponse] = await Promise.all([ platformApiClient.getServerInstance(serverId), platformApiClient.listGamePlugins(), platformApiClient.listJobs(serverId), @@ -86,7 +87,8 @@ export function ServerDetailPage(props: PageComponentProps) { .catch((error): LoadState => ({ status: "error", reason: error instanceof Error ? error.message : "部署定义加载失败" })), platformApiClient.listMetricHistory(serverId).catch(() => ({ items: [], count: 0 })), platformApiClient.listBackups(serverId).catch(() => ({ items: [], count: 0 })), - platformApiClient.listRemoteAdapters(serverId).catch(() => ({ items: [], count: 0 })) + platformApiClient.listRemoteAdapters(serverId).catch(() => ({ items: [], count: 0 })), + platformApiClient.listRunEndpoints().catch(() => ({ items: [], count: 0 })) ]); setInstance({ status: "ready", data: detail }); setPlugins(pluginResponse.items); @@ -95,6 +97,7 @@ export function ServerDetailPage(props: PageComponentProps) { setMetricHistory(metricHistoryResponse.items); setBackups(backupResponse.items); setRemoteAdapters(adapterResponse.items); + setRunEndpoint(endpointResponse.items.find((endpoint) => endpoint.id === detail.runEndpointId)); const artifactLists = await Promise.all( jobResponse.items.slice(0, 20).map((job) => platformApiClient @@ -107,6 +110,7 @@ export function ServerDetailPage(props: PageComponentProps) { } catch (error) { setInstance({ status: "error", reason: error instanceof Error ? error.message : "加载失败" }); setArtifacts([]); + setRunEndpoint(undefined); setDeployment({ status: "error", reason: "部署定义加载失败" }); setMetricHistory([]); setBackups([]); @@ -127,14 +131,16 @@ export function ServerDetailPage(props: PageComponentProps) { const refreshOperationalState = useCallback(async () => { if (!serverId) return; try { - const [detail, jobResponse, metricsResponse] = await Promise.all([ + const [detail, jobResponse, metricsResponse, endpointResponse] = await Promise.all([ platformApiClient.getServerInstance(serverId), platformApiClient.listJobs(serverId), - platformApiClient.listServerMetrics() + platformApiClient.listServerMetrics(), + platformApiClient.listRunEndpoints() ]); setInstance({ status: "ready", data: detail }); setJobs(jobResponse.items); setMetrics(metricsResponse.items.find((item) => item.serverInstanceId === serverId) ?? null); + setRunEndpoint(endpointResponse.items.find((endpoint) => endpoint.id === detail.runEndpointId)); } catch { setMetrics(null); } @@ -151,6 +157,8 @@ export function ServerDetailPage(props: PageComponentProps) { ); const canManageServers = session.capabilities.includes("servers.manage"); const readyPlugin = instance.status === "ready" ? plugins.find((plugin) => plugin.id === instance.data.pluginId) : undefined; + const detailFreshness = instance.status === "ready" ? runtimeObservationFreshness(instance.data, runEndpoint) : "unverified"; + const detailStateText = instance.status === "ready" && detailFreshness === "fresh" ? stateLabel(instance.data.state) : instance.status === "ready" ? `最后观测:${stateLabel(instance.data.state)}(Run 未验证)` : "未验证"; const detailSections = useMemo(() => serverDetailSectionEntries(readyPlugin), [readyPlugin]); useEffect(() => { @@ -243,7 +251,7 @@ export function ServerDetailPage(props: PageComponentProps) {
- {stateLabel(instance.data.state)} + {detailStateText} -
- + @@ -310,7 +317,6 @@ export function ServerDetailPage(props: PageComponentProps) { {section === "config" && } {section === "llm" && } {section === "history" && } - setLiveLogOpen(false)} /> setTerminalOpen(false)} /> )}