Fix server file refresh pending state

This commit is contained in:
npc0-hue
2026-08-24 18:06:42 +08:00
parent 7e15cb09b4
commit 7e10c5685a
5 changed files with 160 additions and 9 deletions
+46
View File
@@ -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) { func TestCoreServiceRunJobRejectsInvalidSessionAndLease(t *testing.T) {
svc, sessionToken := newRegisteredRunJobService(t) svc, sessionToken := newRegisteredRunJobService(t)
createQueuedRunJob(t, svc, "job-1", "idem-1") createQueuedRunJob(t, svc, "job-1", "idem-1")
+29
View File
@@ -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) { func TestDeclaredFileReadSnapshotProjectionStatesAndRedaction(t *testing.T) {
svc := newTestCoreService() svc := newTestCoreService()
plugin, endpoint := createPluginAndRunEndpoint(t, svc) plugin, endpoint := createPluginAndRunEndpoint(t, svc)
+28 -3
View File
@@ -99,12 +99,19 @@ func (svc *CoreService) ListServerFilesForSession(sessionID string, request doma
if parseErr == nil { 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 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" state := "declared"
reason := "展示服务器文件缓存;打开文件标签时会自动读取 Run 实时目录。" reason := "展示服务器文件缓存;打开文件标签时会自动读取 Run 实时目录。"
if hasLatest && !isTerminalJobState(latest.State) { if hasLatest {
state = "pending" if !isTerminalJobState(latest.State) {
reason = "Run 正在刷新目录。" 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) entries := filterServerFileEntries(serverFileEntriesFromDeclaredWorkspace(ctx.Workspace, request.DirectoryKey), request.Query)
if state == "declared" { 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 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) { func (svc *CoreService) RefreshServerFileListForSession(sessionID string, request domain.ServerFileListRequest) (domain.ServerFileListResult, error) {
request = normalizeServerFileListRequest(request) request = normalizeServerFileListRequest(request)
if request.IdempotencyKey == "" { if request.IdempotencyKey == "" {
+3 -1
View File
@@ -88,7 +88,9 @@ describe("ServerDetailPage config write approval", () => {
it("keeps the server file manager list-first without plugin declaration gates", () => { it("keeps the server file manager list-first without plugin declaration gates", () => {
expect(serverDetailPageSource).toContain("server-file-editor-overlay"); expect(serverDetailPageSource).toContain("server-file-editor-overlay");
expect(serverDetailPageSource).toContain("refreshRuntimeList({ silent: true })"); 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("server-file-layout");
expect(serverDetailPageSource).not.toContain("未声明目录"); expect(serverDetailPageSource).not.toContain("未声明目录");
expect(serverDetailPageSource).not.toContain("插件尚未声明"); expect(serverDetailPageSource).not.toContain("插件尚未声明");
+54 -5
View File
@@ -590,12 +590,15 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio
} }
}, [instance.id]); }, [instance.id]);
const loadList = useCallback(async () => { const loadList = useCallback(async (options: { silent?: boolean } = {}) => {
if (!directoryKey) return; if (!directoryKey) return;
setList({ status: "loading" }); if (!options.silent) setList({ status: "loading" });
try { try {
const response = await platformApiClient.listServerFiles(instance.id, { directoryKey, path: relativePath || undefined, query: searchQuery || undefined, recursive }); const response = await platformApiClient.listServerFiles(instance.id, { directoryKey, path: relativePath || undefined, query: searchQuery || undefined, recursive });
setList({ status: "ready", data: response }); 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) { } catch (error) {
setList({ status: "error", reason: error instanceof Error ? error.message : "文件列表加载失败" }); 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) }); 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 }); setList({ status: "ready", data: response });
if (operationId) operations.succeed(operationId, `目录刷新任务 ${response.job?.id ?? "已派发"}`, response.job); 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) { } catch (error) {
const reason = error instanceof Error ? error.message : "目录刷新失败"; const reason = error instanceof Error ? error.message : "目录刷新失败";
if (operationId) operations.fail(operationId, reason, operationId); if (operationId) operations.fail(operationId, reason, operationId);
@@ -633,7 +636,7 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio
useEffect(() => { useEffect(() => {
if (workspace.status !== "ready" || !directoryKey || list.status !== "ready" || list.data.state !== "pending") return; 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); return () => window.clearInterval(timer);
}, [directoryKey, list, loadList, workspace.status]); }, [directoryKey, list, loadList, workspace.status]);
@@ -794,7 +797,7 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio
<table className="resource-table server-file-table"> <table className="resource-table server-file-table">
<thead><tr><th></th><th></th><th></th><th></th><th></th></tr></thead> <thead><tr><th></th><th></th><th></th><th></th><th></th></tr></thead>
<tbody> <tbody>
{entries.length === 0 && <tr><td colSpan={5}><span className="provider-id">{list.data.state === "pending" ? "正在读取当前目录,Run 返回后会自动更新。" : "当前目录暂无文件。"}</span></td></tr>} {entries.length === 0 && <tr><td colSpan={5}><span className="provider-id">{serverFileListEmptyLabel(list.data)}</span></td></tr>}
{entries.map((entry) => ( {entries.map((entry) => (
<tr key={serverFileEntryRowKey(entry)} className={entry.kind === "directory" ? "server-file-directory-row" : undefined}> <tr key={serverFileEntryRowKey(entry)} className={entry.kind === "directory" ? "server-file-directory-row" : undefined}>
<td> <td>
@@ -851,6 +854,52 @@ function serverFileEntryRowKey(entry: ServerFileEntryResponse): string {
return `${entry.kind}:${entry.directoryKey}:${entry.relativePath ?? ""}:${entry.logicalKey ?? ""}:${entry.name}`; 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 { 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()}`; return `web-file-${prefix}-${serverId}-${String(key).replace(/[^a-zA-Z0-9_.-]+/g, "-").slice(0, 40)}-${Date.now()}`;
} }