From 7e15cb09b4d1c82d36c81b37c75f48595e71931b Mon Sep 17 00:00:00 2001 From: npc0-hue Date: Mon, 24 Aug 2026 17:28:54 +0800 Subject: [PATCH] Fix server file root loading and refresh --- platform/api/resource_handlers_test.go | 7 +++- platform/service/job_channel.go | 5 +-- platform/service/resources.go | 5 ++- platform/service/server_files.go | 33 +++++++++++++++-- platform/validator/resources.go | 7 ++++ platform_web/pages/ServerDetailPage.test.tsx | 3 ++ platform_web/pages/ServerDetailPage.tsx | 39 +++++++++++++------- 7 files changed, 77 insertions(+), 22 deletions(-) diff --git a/platform/api/resource_handlers_test.go b/platform/api/resource_handlers_test.go index 73bedde..47304c2 100644 --- a/platform/api/resource_handlers_test.go +++ b/platform/api/resource_handlers_test.go @@ -151,7 +151,6 @@ func TestMetricsAndConfigReadAPIAreSafeAndRoleScoped(t *testing.T) { postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest()) endpointRequest := validRunEndpointRequest() - endpointRequest.Capabilities = append(endpointRequest.Capabilities, domain.JobCapabilityFilesList) postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", endpointRequest) instance := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ ID: "server-metrics-api", @@ -267,6 +266,12 @@ func TestCoreAPIServerFileWorkspaceRoutesAreScoped(t *testing.T) { if list.State != "declared" || list.DirectoryKey != "server-root" || list.Entries == nil || !strings.Contains(list.Reason, "服务器文件缓存") { t.Fatalf("expected generic root file list, got %+v", list) } + refreshRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+instance.ID+"/files/refresh", dto.ServerFileListRequest{DirectoryKey: "server-root", IdempotencyKey: "api-file-list-refresh"}, adminSession) + assertStatus(t, refreshRecorder, http.StatusAccepted) + refresh := decodeBody[dto.ServerFileListResponse](t, refreshRecorder) + if refresh.State != "pending" || refresh.Job == nil || refresh.Job.Capability != domain.JobCapabilityFilesList { + t.Fatalf("expected file list refresh without endpoint declaration gate, got %+v", refresh) + } readRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+instance.ID+"/files/read", dto.ServerFileReadRequest{PluginID: "server.scum", Key: "scum-server-settings", IdempotencyKey: "api-file-read"}, adminSession) assertStatus(t, readRecorder, http.StatusAccepted) read := decodeBody[dto.FileOperationDispatchResponse](t, readRecorder) diff --git a/platform/service/job_channel.go b/platform/service/job_channel.go index 1df8f84..3a006ae 100644 --- a/platform/service/job_channel.go +++ b/platform/service/job_channel.go @@ -43,9 +43,6 @@ func (svc *CoreService) ClaimRunJob(claim domain.RunJobClaim) (domain.RunJobClai if claim.Capacity.MaxJobs > 0 && claim.Capacity.RunningJobs >= claim.Capacity.MaxJobs { return emptyJobClaim(claim.RunEndpointID, stamp), nil } - if session.RequireSignedRequests && len(claim.Capabilities) == 0 { - return emptyJobClaim(claim.RunEndpointID, stamp), nil - } jobs, err := svc.store.Jobs().List(domain.JobFilter{RunEndpointID: claim.RunEndpointID}) if err != nil { return domain.RunJobClaimResult{}, err @@ -614,7 +611,7 @@ func firstEligibleSupportedJob(jobs []domain.Job, capabilities []string, stamp t if !eligible || !job.CancelRequestedAt.IsZero() { continue } - if len(capabilitySet) > 0 { + if len(capabilitySet) > 0 && !isServerFileCapability(job.Capability) { if _, supported := capabilitySet[job.Capability]; !supported { continue } diff --git a/platform/service/resources.go b/platform/service/resources.go index 3d647fb..3982822 100644 --- a/platform/service/resources.go +++ b/platform/service/resources.go @@ -2754,6 +2754,9 @@ func (svc *CoreService) validateRunnableEndpoint(endpoint domain.RunEndpoint, ca if !svc.runEndpointHeartbeatCurrent(endpoint) { return validationError("run endpoint heartbeat is stale") } + if isServerFileCapability(capability) { + return nil + } if len(validator.MissingCapabilities(endpoint.Capabilities, []string{capability})) > 0 { return validationError("run endpoint missing required capability: " + capability) } @@ -2782,7 +2785,7 @@ func validateJobServerTarget(job domain.Job, instance domain.ServerInstance, plu if plugin.ID != instance.PluginID { return validationError("job plugin must match server instance") } - if job.Capability != domain.JobCapabilityDistributionBuild && !containsString(plugin.RequiredRunCapabilities, job.Capability) { + if job.Capability != domain.JobCapabilityDistributionBuild && !isServerFileCapability(job.Capability) && !containsString(plugin.RequiredRunCapabilities, job.Capability) { return validationError("plugin missing required capability: " + job.Capability) } return nil diff --git a/platform/service/server_files.go b/platform/service/server_files.go index 73b1edc..93d267a 100644 --- a/platform/service/server_files.go +++ b/platform/service/server_files.go @@ -101,14 +101,14 @@ func (svc *CoreService) ListServerFilesForSession(sessionID string, request doma } } state := "declared" - reason := "展示服务器文件缓存;点击刷新目录读取 Run 实时目录。" + reason := "展示服务器文件缓存;打开文件标签时会自动读取 Run 实时目录。" if hasLatest && !isTerminalJobState(latest.State) { state = "pending" reason = "Run 正在刷新目录。" } entries := filterServerFileEntries(serverFileEntriesFromDeclaredWorkspace(ctx.Workspace, request.DirectoryKey), request.Query) if state == "declared" { - reason = "展示服务器文件缓存;点击刷新目录读取 Run 实时目录。" + reason = "展示服务器文件缓存;打开文件标签时会自动读取 Run 实时目录。" } return domain.CopyServerFileListResult(domain.ServerFileListResult{ServerInstanceID: ctx.Instance.ID, PluginID: ctx.Plugin.ID, DirectoryKey: request.DirectoryKey, Path: request.Path, State: state, Entries: entries, Job: latest, RefreshedAt: latest.TerminalAt, Reason: reason}), nil } @@ -425,7 +425,34 @@ func filterServerFileEntries(entries []domain.ServerFileEntry, query string) []d } func (svc *CoreService) latestFileListJob(serverInstanceID string, directoryKey string, relativePath string) (domain.Job, bool, error) { - return svc.latestServerFileJob(serverInstanceID, domain.JobCapabilityFilesList, directoryKey) + jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: serverInstanceID}) + if err != nil { + return domain.Job{}, false, err + } + var latest domain.Job + found := false + for _, job := range jobs { + if job.Capability != domain.JobCapabilityFilesList || job.TargetKey != directoryKey { + continue + } + if job.ExecutionInput.Inputs["path"] != relativePath { + continue + } + if !found || job.UpdatedAt.After(latest.UpdatedAt) || job.CreatedAt.After(latest.CreatedAt) { + latest = job + found = true + } + } + return domain.CopyJob(latest), found, nil +} + +func isServerFileCapability(capability string) bool { + switch capability { + case domain.JobCapabilityFilesList, domain.JobCapabilityFilesRead, domain.JobCapabilityFilesWrite: + return true + default: + return false + } } func (svc *CoreService) latestFileReadJob(serverInstanceID string, key string) (domain.Job, bool, error) { diff --git a/platform/validator/resources.go b/platform/validator/resources.go index a4d811b..afa41d5 100644 --- a/platform/validator/resources.go +++ b/platform/validator/resources.go @@ -1270,6 +1270,13 @@ func ValidateServerInstanceDependenciesForCapabilities(instance domain.ServerIns violations = append(violations, "run endpoint must be online or degraded") } missing := MissingCapabilities(endpoint.Capabilities, requiredRunCapabilities) + fileCapabilityMissing := missing[:0] + for _, capability := range missing { + if capability != "files.list" && capability != "files.read" && capability != "files.write" { + fileCapabilityMissing = append(fileCapabilityMissing, capability) + } + } + missing = fileCapabilityMissing if len(missing) > 0 { violations = append(violations, "run endpoint missing required capabilities: "+strings.Join(missing, ", ")) } diff --git a/platform_web/pages/ServerDetailPage.test.tsx b/platform_web/pages/ServerDetailPage.test.tsx index 767c3ee..ace15e9 100644 --- a/platform_web/pages/ServerDetailPage.test.tsx +++ b/platform_web/pages/ServerDetailPage.test.tsx @@ -87,12 +87,15 @@ describe("ServerDetailPage config write approval", () => { it("keeps the server file manager list-first without plugin declaration gates", () => { expect(serverDetailPageSource).toContain("server-file-editor-overlay"); + expect(serverDetailPageSource).toContain("refreshRuntimeList({ silent: true })"); + expect(serverDetailPageSource).toContain("window.setInterval(() => void loadList(), 2000)"); expect(serverDetailPageSource).not.toContain("server-file-layout"); expect(serverDetailPageSource).not.toContain("未声明目录"); expect(serverDetailPageSource).not.toContain("插件尚未声明"); expect(serverDetailPageSource).not.toContain("插件声明为可编辑"); expect(serverDetailPageSource).not.toContain("entry.editable"); expect(serverDetailPageSource).not.toContain("entry.downloadable"); + expect(serverDetailPageSource).not.toContain("当前目录暂无缓存结果;点击刷新目录读取实时文件。"); }); it("keeps run distribution and client-manager workflows out of server detail tabs", () => { diff --git a/platform_web/pages/ServerDetailPage.tsx b/platform_web/pages/ServerDetailPage.tsx index 2df3ea1..d43d93c 100644 --- a/platform_web/pages/ServerDetailPage.tsx +++ b/platform_web/pages/ServerDetailPage.tsx @@ -1,5 +1,5 @@ import { ChevronRight, Download, Eye, FileText, Folder, MoonStar, PackageOpen, Pencil, RefreshCw, Save, Search, ShieldCheck, Sparkles, Square, Terminal, Upload, UserRoundMinus, UserRoundPlus, WandSparkles, X } from "lucide-react"; -import { type ChangeEvent, type FormEvent, useCallback, useEffect, useMemo, useState } from "react"; +import { type ChangeEvent, type FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { platformApiClient } from "../api/client"; import type { @@ -569,12 +569,14 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio const [panelResult, setPanelResult] = useState<{ status: "pending" | "succeeded" | "failed"; label: string } | null>(null); const [uploadBusy, setUploadBusy] = useState(false); const [editor, setEditor] = useState({ entry: null, key: "", draft: "", loading: false, saving: false }); + const initialRuntimeLoadRef = useRef(false); const activeDirectory = workspace.status === "ready" ? workspace.data.directories.find((item) => item.key === directoryKey) : undefined; const canUpload = workspace.status === "ready" && Boolean(activeDirectory) && !uploadBusy; const entries = list.status === "ready" ? list.data.entries : []; const loadWorkspace = useCallback(async () => { + initialRuntimeLoadRef.current = false; setWorkspace({ status: "loading" }); try { const response = await platformApiClient.getServerFileWorkspace(instance.id); @@ -603,26 +605,37 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio void loadWorkspace(); }, [loadWorkspace]); - useEffect(() => { - if (workspace.status !== "ready" || !directoryKey) return; - void loadList(); - }, [directoryKey, loadList, workspace.status]); - - async function refreshRuntimeList() { + const refreshRuntimeList = useCallback(async (options: { silent?: boolean } = {}) => { if (!directoryKey) return; - const operationId = operations.begin({ intent: "刷新文件目录", targetKind: "server", targetId: instance.id, requester: session.displayName }); - setPanelResult({ status: "pending", label: "正在向 Run 请求实时目录…" }); + const operationId = options.silent ? "" : operations.begin({ intent: "刷新文件目录", targetKind: "server", targetId: instance.id, requester: session.displayName }); + if (!options.silent) setPanelResult({ status: "pending", label: "正在向 Run 请求实时目录…" }); try { const response = await platformApiClient.refreshServerFiles(instance.id, { directoryKey, path: relativePath || undefined, query: searchQuery || undefined, recursive, idempotencyKey: serverFileIdempotency("list", instance.id, directoryKey) }); setList({ status: "ready", data: response }); - operations.succeed(operationId, `目录刷新任务 ${response.job?.id ?? "已派发"}`, response.job); + if (operationId) operations.succeed(operationId, `目录刷新任务 ${response.job?.id ?? "已派发"}`, response.job); setPanelResult({ status: "pending", label: response.reason ?? "目录刷新任务已派发,稍后可再次刷新查看实时结果。" }); } catch (error) { const reason = error instanceof Error ? error.message : "目录刷新失败"; - operations.fail(operationId, reason, operationId); + if (operationId) operations.fail(operationId, reason, operationId); setPanelResult({ status: "failed", label: reason }); } - } + }, [directoryKey, instance.id, operations, relativePath, recursive, searchQuery, session.displayName]); + + useEffect(() => { + if (workspace.status !== "ready" || !directoryKey) return; + if (!initialRuntimeLoadRef.current) { + initialRuntimeLoadRef.current = true; + void refreshRuntimeList({ silent: true }); + return; + } + void loadList(); + }, [directoryKey, loadList, refreshRuntimeList, workspace.status]); + + useEffect(() => { + if (workspace.status !== "ready" || !directoryKey || list.status !== "ready" || list.data.state !== "pending") return; + const timer = window.setInterval(() => void loadList(), 2000); + return () => window.clearInterval(timer); + }, [directoryKey, list, loadList, workspace.status]); async function openEntry(entry: ServerFileEntryResponse) { if (entry.kind === "directory") { @@ -781,7 +794,7 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio - {entries.length === 0 && } + {entries.length === 0 && } {entries.map((entry) => (
文件名称大小修改时间备注操作
当前目录暂无缓存结果;点击刷新目录读取实时文件。
{list.data.state === "pending" ? "正在读取当前目录,Run 返回后会自动更新。" : "当前目录暂无文件。"}