From 7e10c5685a7c75b0b9b6164fd3445a0d011713b0 Mon Sep 17 00:00:00 2001 From: npc0-hue Date: Mon, 24 Aug 2026 18:06:42 +0800 Subject: [PATCH] Fix server file refresh pending state --- platform/service/job_channel_test.go | 46 +++++++++++++++ platform/service/resources_test.go | 29 ++++++++++ platform/service/server_files.go | 31 +++++++++- platform_web/pages/ServerDetailPage.test.tsx | 4 +- platform_web/pages/ServerDetailPage.tsx | 59 ++++++++++++++++++-- 5 files changed, 160 insertions(+), 9 deletions(-) diff --git a/platform/service/job_channel_test.go b/platform/service/job_channel_test.go index 78390ec..a443b69 100644 --- a/platform/service/job_channel_test.go +++ b/platform/service/job_channel_test.go @@ -98,6 +98,52 @@ func TestCoreServiceRunJobClaimNoJob(t *testing.T) { } } +func TestCoreServiceRunJobClaimAllowsServerFileCapabilityWithoutDeclaration(t *testing.T) { + svc := newTestCoreService() + plugin, endpoint := createPluginAndRunEndpoint(t, svc) + instance, err := svc.CreateServerInstance(domain.ServerInstance{ + ID: "server-file-claim", + PluginID: plugin.ID, + RunEndpointID: endpoint.ID, + Name: "File Claim Server", + State: domain.ServerInstanceStateRunning, + }) + if err != nil { + t.Fatalf("create server instance: %v", err) + } + helloRequest := validRunControlHello() + helloRequest.CapabilityReport.Capabilities = []string{"control.hello", "control.heartbeat", "process.start"} + helloRequest.CapabilityReport.Fingerprint = "cap-file-claim-no-list" + hello, err := svc.RegisterRunHello(helloRequest) + if err != nil { + t.Fatalf("register run hello: %v", err) + } + _, err = svc.CreateJob(domain.Job{ + ID: "job-file-list", + ServerInstanceID: instance.ID, + RunEndpointID: endpoint.ID, + Capability: domain.JobCapabilityFilesList, + TargetKey: "server-root", + IdempotencyKey: "idem-file-list", + }) + if err != nil { + t.Fatalf("create file list job: %v", err) + } + + claim, err := svc.ClaimRunJob(domain.RunJobClaim{ + RunEndpointID: endpoint.ID, + SessionToken: hello.SessionToken, + Capabilities: []string{"process.start"}, + Capacity: domain.RunCapacity{MaxJobs: 4}, + }) + if err != nil { + t.Fatalf("claim file list job: %v", err) + } + if !claim.Accepted || !claim.HasJob || claim.Job.JobID != "job-file-list" || claim.Job.Capability != domain.JobCapabilityFilesList { + t.Fatalf("expected file job claim without files.list declaration, got %+v", claim) + } +} + func TestCoreServiceRunJobRejectsInvalidSessionAndLease(t *testing.T) { svc, sessionToken := newRegisteredRunJobService(t) createQueuedRunJob(t, svc, "job-1", "idem-1") diff --git a/platform/service/resources_test.go b/platform/service/resources_test.go index 0425ae1..aa1ed82 100644 --- a/platform/service/resources_test.go +++ b/platform/service/resources_test.go @@ -970,6 +970,35 @@ func TestPluginFileWorkspaceDoesNotConstrainServerFileDispatch(t *testing.T) { } } +func TestServerFileListReportsFailedRuntimeRefresh(t *testing.T) { + svc := newTestCoreService() + plugin, endpoint := createPluginAndRunEndpoint(t, svc) + ownerSession := createServiceUserAndLogin(t, svc, domain.User{ID: "user-file-list-failure", DisplayName: "File List Failure", Email: "file-list-failure@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"}) + instance, err := svc.CreateServerInstanceForSession(ownerSession, domain.ServerInstance{ID: "server-file-list-failure", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "File List Failure Server", State: domain.ServerInstanceStateRunning}) + if err != nil { + t.Fatalf("create server: %v", err) + } + createCompleteRuntimeBinding(t, svc, instance, "local") + + refresh, err := svc.RefreshServerFileListForSession(ownerSession, domain.ServerFileListRequest{ServerInstanceID: instance.ID, DirectoryKey: "server-root", IdempotencyKey: "idem-file-list-failure"}) + if err != nil || refresh.State != "pending" || refresh.Job.ID == "" { + t.Fatalf("refresh file list: result=%+v err=%v", refresh, err) + } + failedJob := refresh.Job + failedJob.State = domain.JobStateFailed + failedJob.Progress = domain.JobProgress{Percent: 100, Message: "executor does not support files.list"} + failedJob.TerminalAt = fixedTime.Add(2 * time.Minute) + failedJob.UpdatedAt = failedJob.TerminalAt + if err := svc.store.Jobs().Update(failedJob); err != nil { + t.Fatalf("update failed file list job: %v", err) + } + + list, err := svc.ListServerFilesForSession(ownerSession, domain.ServerFileListRequest{ServerInstanceID: instance.ID, DirectoryKey: "server-root"}) + if err != nil || list.State != "failed" || list.Job.ID != failedJob.ID || !strings.Contains(list.Reason, "executor does not support files.list") { + t.Fatalf("expected failed file list state, list=%+v err=%v", list, err) + } +} + func TestDeclaredFileReadSnapshotProjectionStatesAndRedaction(t *testing.T) { svc := newTestCoreService() plugin, endpoint := createPluginAndRunEndpoint(t, svc) diff --git a/platform/service/server_files.go b/platform/service/server_files.go index 93d267a..8d6534a 100644 --- a/platform/service/server_files.go +++ b/platform/service/server_files.go @@ -99,12 +99,19 @@ func (svc *CoreService) ListServerFilesForSession(sessionID string, request doma if parseErr == nil { return domain.CopyServerFileListResult(domain.ServerFileListResult{ServerInstanceID: ctx.Instance.ID, PluginID: ctx.Plugin.ID, DirectoryKey: request.DirectoryKey, Path: request.Path, State: "ready", Entries: filterServerFileEntries(entries, request.Query), Job: latest, RefreshedAt: latest.TerminalAt}), nil } + entries = filterServerFileEntries(serverFileEntriesFromDeclaredWorkspace(ctx.Workspace, request.DirectoryKey), request.Query) + return domain.CopyServerFileListResult(domain.ServerFileListResult{ServerInstanceID: ctx.Instance.ID, PluginID: ctx.Plugin.ID, DirectoryKey: request.DirectoryKey, Path: request.Path, State: "failed", Entries: entries, Job: latest, RefreshedAt: latest.TerminalAt, Reason: "Run 返回的文件列表无法解析。"}), nil } state := "declared" reason := "展示服务器文件缓存;打开文件标签时会自动读取 Run 实时目录。" - if hasLatest && !isTerminalJobState(latest.State) { - state = "pending" - reason = "Run 正在刷新目录。" + if hasLatest { + if !isTerminalJobState(latest.State) { + state = "pending" + reason = "Run 正在刷新目录。" + } else if latest.State == domain.JobStateFailed || latest.State == domain.JobStateCancelled { + state = "failed" + reason = serverFileListJobFailureReason(latest) + } } entries := filterServerFileEntries(serverFileEntriesFromDeclaredWorkspace(ctx.Workspace, request.DirectoryKey), request.Query) if state == "declared" { @@ -113,6 +120,24 @@ func (svc *CoreService) ListServerFilesForSession(sessionID string, request doma 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 } +func serverFileListJobFailureReason(job domain.Job) string { + prefix := "Run 文件目录刷新失败" + if job.State == domain.JobStateCancelled { + prefix = "Run 文件目录刷新已取消" + } + detail := strings.TrimSpace(job.CancelReason) + if detail == "" { + detail = strings.TrimSpace(job.Progress.Message) + } + if detail == "" { + detail = strings.TrimSpace(job.ExecutionResult.Summary) + } + if detail == "" { + return prefix + "。" + } + return prefix + ":" + detail +} + func (svc *CoreService) RefreshServerFileListForSession(sessionID string, request domain.ServerFileListRequest) (domain.ServerFileListResult, error) { request = normalizeServerFileListRequest(request) if request.IdempotencyKey == "" { diff --git a/platform_web/pages/ServerDetailPage.test.tsx b/platform_web/pages/ServerDetailPage.test.tsx index ace15e9..e2c1b74 100644 --- a/platform_web/pages/ServerDetailPage.test.tsx +++ b/platform_web/pages/ServerDetailPage.test.tsx @@ -88,7 +88,9 @@ 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).toContain("window.setInterval(() => void loadList({ silent: true }), 2000)"); + expect(serverDetailPageSource).toContain("if (!options.silent) setList({ status: \"loading\" })"); + expect(serverDetailPageSource).toContain("serverFileListPendingLabel"); expect(serverDetailPageSource).not.toContain("server-file-layout"); expect(serverDetailPageSource).not.toContain("未声明目录"); expect(serverDetailPageSource).not.toContain("插件尚未声明"); diff --git a/platform_web/pages/ServerDetailPage.tsx b/platform_web/pages/ServerDetailPage.tsx index d43d93c..cf6df0f 100644 --- a/platform_web/pages/ServerDetailPage.tsx +++ b/platform_web/pages/ServerDetailPage.tsx @@ -590,12 +590,15 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio } }, [instance.id]); - const loadList = useCallback(async () => { + const loadList = useCallback(async (options: { silent?: boolean } = {}) => { if (!directoryKey) return; - setList({ status: "loading" }); + if (!options.silent) setList({ status: "loading" }); try { const response = await platformApiClient.listServerFiles(instance.id, { directoryKey, path: relativePath || undefined, query: searchQuery || undefined, recursive }); setList({ status: "ready", data: response }); + if (options.silent && response.state === "ready") setPanelResult({ status: "succeeded", label: `目录已更新:${response.entries.length} 项` }); + if (options.silent && response.state === "pending") setPanelResult({ status: "pending", label: serverFileListPendingLabel(response) }); + if (options.silent && response.state === "failed") setPanelResult({ status: "failed", label: response.reason ?? "目录刷新失败" }); } catch (error) { setList({ status: "error", reason: error instanceof Error ? error.message : "文件列表加载失败" }); } @@ -613,7 +616,7 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio 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 }); if (operationId) operations.succeed(operationId, `目录刷新任务 ${response.job?.id ?? "已派发"}`, response.job); - setPanelResult({ status: "pending", label: response.reason ?? "目录刷新任务已派发,稍后可再次刷新查看实时结果。" }); + setPanelResult({ status: serverFileListResultStatus(response), label: serverFileListResultLabel(response) }); } catch (error) { const reason = error instanceof Error ? error.message : "目录刷新失败"; if (operationId) operations.fail(operationId, reason, operationId); @@ -633,7 +636,7 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio useEffect(() => { if (workspace.status !== "ready" || !directoryKey || list.status !== "ready" || list.data.state !== "pending") return; - const timer = window.setInterval(() => void loadList(), 2000); + const timer = window.setInterval(() => void loadList({ silent: true }), 2000); return () => window.clearInterval(timer); }, [directoryKey, list, loadList, workspace.status]); @@ -794,7 +797,7 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio - {entries.length === 0 && } + {entries.length === 0 && } {entries.map((entry) => (
文件名称大小修改时间备注操作
{list.data.state === "pending" ? "正在读取当前目录,Run 返回后会自动更新。" : "当前目录暂无文件。"}
{serverFileListEmptyLabel(list.data)}
@@ -851,6 +854,52 @@ function serverFileEntryRowKey(entry: ServerFileEntryResponse): string { return `${entry.kind}:${entry.directoryKey}:${entry.relativePath ?? ""}:${entry.logicalKey ?? ""}:${entry.name}`; } +function serverFileListResultStatus(response: ServerFileListResponse): "pending" | "succeeded" | "failed" { + if (response.state === "ready") return "succeeded"; + if (response.state === "failed") return "failed"; + return "pending"; +} + +function serverFileListResultLabel(response: ServerFileListResponse): string { + if (response.state === "ready") return `目录已更新:${response.entries.length} 项`; + if (response.state === "failed") return response.reason ?? "目录刷新失败"; + return serverFileListPendingLabel(response); +} + +function serverFileListEmptyLabel(response: ServerFileListResponse): string { + if (response.state === "pending") return serverFileListPendingLabel(response); + if (response.state === "failed") return response.reason ?? "目录刷新失败"; + return "当前目录暂无文件。"; +} + +function serverFileListPendingLabel(response: ServerFileListResponse): string { + const job = response.job; + if (!job) return response.reason ?? "正在读取当前目录,Run 返回后会自动更新。"; + const progress = job.progress?.message?.trim(); + const attempt = job.attempt > 0 ? ` · 第 ${job.attempt} 次尝试` : ""; + const nextAttempt = job.state === "retrying" && job.nextAttemptAt ? ` · 下次 ${formatDateTime(job.nextAttemptAt)}` : ""; + return `文件刷新任务 ${serverFileJobStateLabel(job.state)}${attempt}${nextAttempt}${progress ? ` · ${progress}` : ""}`; +} + +function serverFileJobStateLabel(state: JobResponse["state"]): string { + switch (state) { + case "queued": + return "已排队,等待 Run 领取"; + case "accepted": + return "Run 已领取,等待确认"; + case "running": + return "运行中"; + case "retrying": + return "等待重试"; + case "succeeded": + return "已完成"; + case "cancelled": + return "已取消"; + default: + return "已失败"; + } +} + function serverFileIdempotency(prefix: string, serverId: string, key: string): string { return `web-file-${prefix}-${serverId}-${String(key).replace(/[^a-zA-Z0-9_.-]+/g, "-").slice(0, 40)}-${Date.now()}`; }