Add server file manager workflow

This commit is contained in:
npc0-hue
2026-08-24 12:56:55 +08:00
parent dc9b0eaf2a
commit 33d3a13e74
20 changed files with 1961 additions and 22 deletions
+55 -1
View File
@@ -144,6 +144,18 @@ const runtimeDownload: ArtifactDownloadReferenceResponse = {
storageBehavior: "platform-memory-transfer-session"
};
const serverFileWorkspace = {
serverInstanceId: server.id,
pluginId: plugin.id,
defaultDirectoryKey: "configs",
directories: [{ key: "configs", label: "配置", scope: "config" }],
files: [{ key: "config/server.properties", directoryKey: "configs", label: "server.properties", kind: "config", editable: true }],
configFields: [],
transfer: { channel: "run-file-transfer", uploadChunkSizeBytes: 1048576, downloadChunkSizeBytes: 1048576, maxInlineEditBytes: 65536, maxBrowserUploadBytes: 52428800 },
declaredOnly: true,
runtimeWorkspaceScope: "server-runtime"
};
describe("PlatformApiClient AI providers", () => {
afterEach(() => {
setPlatformApiSessionToken(null);
@@ -259,6 +271,40 @@ describe("PlatformApiClient AI providers", () => {
job: { ...job, id: "job-file-read", capability: "files.read", targetKey: "logs/latest.log" }
});
}
if (url.endsWith("/api/v1/server-instances/server-1/files/workspace") && (!init?.method || init.method === "GET")) {
return jsonResponse(serverFileWorkspace);
}
if (url.endsWith("/api/v1/server-instances/server-1/files/list?directoryKey=configs&query=server&recursive=true")) {
return jsonResponse({ serverInstanceId: server.id, pluginId: plugin.id, directoryKey: "configs", state: "declared", entries: [{ name: "server.properties", kind: "file", directoryKey: "configs", relativePath: "config/server.properties", logicalKey: "config/server.properties", scope: "config", sizeBytes: 42, editable: true, downloadable: true, remark: "配置文件" }], reason: "declared" });
}
if (url.endsWith("/api/v1/server-instances/server-1/files/refresh") && init?.method === "POST") {
expect(JSON.parse(String(init.body))).toEqual({ directoryKey: "configs", query: "server", recursive: true, idempotencyKey: "idem-file-list" });
return jsonResponse({ serverInstanceId: server.id, pluginId: plugin.id, directoryKey: "configs", state: "pending", entries: [], job: { ...job, id: "job-file-list", capability: "files.list", targetKey: "configs" }, reason: "queued" });
}
if (url.endsWith("/api/v1/server-instances/server-1/files/read-snapshot?key=config%2Fserver.properties")) {
return jsonResponse({ serverInstanceId: server.id, pluginId: plugin.id, key: "config/server.properties", state: "ready", content: "server.name=Example\n", version: 3, checksum: "sha256:filechecksum", sizeBytes: 20, readAt: "2026-07-03T00:00:00Z" });
}
if (url.endsWith("/api/v1/server-instances/server-1/files/read") && init?.method === "POST") {
expect(JSON.parse(String(init.body))).toEqual({ key: "config/server.properties", idempotencyKey: "idem-file-read" });
return jsonResponse({ status: "queued", serverInstanceId: server.id, pluginId: plugin.id, operation: "read", key: "config/server.properties", job: { ...job, id: "job-server-file-read", capability: "files.read", targetKey: "config/server.properties" } });
}
if (url.endsWith("/api/v1/server-instances/server-1/files/write") && init?.method === "POST") {
expect(JSON.parse(String(init.body))).toEqual({ key: "config/server.properties", content: "server.name=Example\n", expectedVersion: 3, expectedChecksum: "sha256:filechecksum", idempotencyKey: "idem-file-write" });
return jsonResponse({ status: "queued", serverInstanceId: server.id, pluginId: plugin.id, operation: "write", key: "config/server.properties", job: { ...job, id: "job-server-file-write", capability: "files.write", targetKey: "config/server.properties" } });
}
if (url.endsWith("/api/v1/server-instances/server-1/files/upload") && init?.method === "POST") {
const body = init.body as FormData;
expect(body.get("directoryKey")).toBe("configs");
expect(body.get("relativePath")).toBe("");
expect(body.get("filename")).toBe("server.properties");
expect(body.get("idempotencyKey")).toBe("idem-file-upload");
expect(body.get("file")).toBeInstanceOf(File);
return jsonResponse({ status: "queued", serverInstanceId: server.id, directoryKey: "configs", relativePath: "server.properties", artifactId: "artifact-upload-1", inputRef: "artifact://artifact-upload-1", sizeBytes: 20, checksum: "sha256:uploadchecksum", job: { ...job, id: "job-server-file-upload", capability: "files.write", targetKey: "configs/server.properties" } });
}
if (url.endsWith("/api/v1/server-instances/server-1/files/download") && init?.method === "POST") {
expect(JSON.parse(String(init.body))).toEqual({ key: "config/server.properties", idempotencyKey: "idem-file-download" });
return jsonResponse({ status: "ready", serverInstanceId: server.id, key: "config/server.properties", filename: "server.properties", contentType: "text/plain; charset=utf-8", content: "server.name=Example\n", checksum: "sha256:filechecksum", sizeBytes: 20, readAt: "2026-07-03T00:00:00Z" });
}
if (url.endsWith("/api/v1/run/endpoints")) {
return jsonResponse({ items: [endpoint], count: 1 });
}
@@ -546,6 +592,14 @@ describe("PlatformApiClient AI providers", () => {
status: "queued",
job: { capability: "files.read", targetKey: "logs/latest.log" }
});
await expect(client.getServerFileWorkspace(server.id)).resolves.toMatchObject({ defaultDirectoryKey: "configs", transfer: { channel: "run-file-transfer" } });
await expect(client.listServerFiles(server.id, { directoryKey: "configs", query: "server", recursive: true })).resolves.toMatchObject({ state: "declared", entries: [{ logicalKey: "config/server.properties" }] });
await expect(client.refreshServerFiles(server.id, { directoryKey: "configs", query: "server", recursive: true, idempotencyKey: "idem-file-list" })).resolves.toMatchObject({ state: "pending", job: { capability: "files.list" } });
await expect(client.getServerFileReadSnapshot(server.id, "config/server.properties")).resolves.toMatchObject({ state: "ready", content: "server.name=Example\n" });
await expect(client.readServerFile(server.id, { key: "config/server.properties", idempotencyKey: "idem-file-read" })).resolves.toMatchObject({ operation: "read", job: { capability: "files.read" } });
await expect(client.writeServerFile(server.id, { key: "config/server.properties", content: "server.name=Example\n", expectedVersion: 3, expectedChecksum: "sha256:filechecksum", idempotencyKey: "idem-file-write" })).resolves.toMatchObject({ operation: "write", job: { capability: "files.write" } });
await expect(client.uploadServerFile(server.id, { directoryKey: "configs", file: new File(["server.name=Example\n"], "server.properties", { type: "text/plain" }), idempotencyKey: "idem-file-upload" })).resolves.toMatchObject({ inputRef: "artifact://artifact-upload-1", job: { capability: "files.write" } });
await expect(client.prepareServerFileDownload(server.id, { key: "config/server.properties", idempotencyKey: "idem-file-download" })).resolves.toMatchObject({ status: "ready", filename: "server.properties", content: "server.name=Example\n" });
await expect(client.listRunEndpoints()).resolves.toMatchObject({ count: 1 });
await expect(client.listJobs()).resolves.toMatchObject({ count: 1 });
await expect(client.listJobs(server.id)).resolves.toMatchObject({ count: 1 });
@@ -600,7 +654,7 @@ describe("PlatformApiClient AI providers", () => {
client.invokeAI({ requestId: "ai-1", serverInstanceId: server.id, purpose: "config.suggest", prompt: "Tune PVP safely", currentConfig: "server.name=Example Survival #1\n" })
).resolves.toMatchObject({ status: "ok", usage: { mocked: true }, configRecommendation: { diffSummary: "review required" } });
expect(fetchMock).toHaveBeenCalledTimes(36);
expect(fetchMock).toHaveBeenCalledTimes(44);
});
it("calls plugin marketplace endpoints with filter and state contracts", async () => {
+64
View File
@@ -32,6 +32,7 @@ import type {
CurrentUserResponse,
DependencyCatalogResponse,
DependencyJobRequest,
DeclaredFileReadSnapshotResponse,
FileOperationDispatchRequest,
FileOperationDispatchResponse,
GameClientBridgeCancelRequest,
@@ -84,6 +85,14 @@ import type {
ServerDeploymentResponse,
ServerInstanceListResponse,
ServerDeletionRequest,
ServerFileDownloadRequest,
ServerFileDownloadResponse,
ServerFileListRequest,
ServerFileListResponse,
ServerFileReadRequest,
ServerFileUploadResponse,
ServerFileWorkspaceResponse,
ServerFileWriteRequest,
ServerInstanceUpdateRequest,
ServerInstanceResponse,
ServerMemberListResponse,
@@ -575,6 +584,51 @@ export class PlatformApiClient {
});
}
async getServerFileWorkspace(serverInstanceId: string): Promise<ServerFileWorkspaceResponse> {
return this.request<ServerFileWorkspaceResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/files/workspace`);
}
async listServerFiles(serverInstanceId: string, request: Partial<ServerFileListRequest> = {}): Promise<ServerFileListResponse> {
const params = serverFileListQuery(request);
return this.request<ServerFileListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/files/list${params}`);
}
async refreshServerFiles(serverInstanceId: string, request: ServerFileListRequest): Promise<ServerFileListResponse> {
return this.request<ServerFileListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/files/refresh`, { method: "POST", body: request });
}
async readServerFile(serverInstanceId: string, request: ServerFileReadRequest): Promise<FileOperationDispatchResponse> {
return this.request<FileOperationDispatchResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/files/read`, { method: "POST", body: request });
}
async getServerFileReadSnapshot(serverInstanceId: string, key: string): Promise<DeclaredFileReadSnapshotResponse> {
const params = new URLSearchParams({ key });
return this.request<DeclaredFileReadSnapshotResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/files/read-snapshot?${params.toString()}`);
}
async writeServerFile(serverInstanceId: string, request: ServerFileWriteRequest): Promise<FileOperationDispatchResponse> {
return this.request<FileOperationDispatchResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/files/write`, { method: "POST", body: request });
}
async uploadServerFile(serverInstanceId: string, request: { directoryKey: string; relativePath?: string; file: File; idempotencyKey: string }): Promise<ServerFileUploadResponse> {
const body = new FormData();
body.set("directoryKey", request.directoryKey);
body.set("relativePath", request.relativePath ?? "");
body.set("filename", request.file.name);
body.set("idempotencyKey", request.idempotencyKey);
body.set("file", request.file);
const headers = new Headers();
const sessionToken = this.sessionTokenProvider();
if (sessionToken) headers.set("Authorization", `Bearer ${sessionToken}`);
const response = await fetch(`${this.baseUrl}/server-instances/${encodeURIComponent(serverInstanceId)}/files/upload`, { method: "POST", credentials: "same-origin", headers, body });
if (!response.ok) throw await responseError(response);
return response.json() as Promise<ServerFileUploadResponse>;
}
async prepareServerFileDownload(serverInstanceId: string, request: ServerFileDownloadRequest): Promise<ServerFileDownloadResponse> {
return this.request<ServerFileDownloadResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/files/download`, { method: "POST", body: request });
}
async listLogStreams(serverInstanceId?: string): Promise<LogStreamListResponse> {
const query = serverInstanceId ? `?serverInstanceId=${encodeURIComponent(serverInstanceId)}` : "";
return this.request<LogStreamListResponse>(`/log-streams${query}`);
@@ -900,4 +954,14 @@ function artifactQuery(filter: ArtifactFilterRequest): string {
return query ? `?${query}` : "";
}
function serverFileListQuery(request: Partial<ServerFileListRequest>): string {
const params = new URLSearchParams();
if (request.directoryKey) params.set("directoryKey", request.directoryKey);
if (request.path) params.set("path", request.path);
if (request.query) params.set("query", request.query);
if (request.recursive) params.set("recursive", "true");
const query = params.toString();
return query ? `?${query}` : "";
}
export const platformApiClient = new PlatformApiClient(readWebRuntimeEnv().platformApiBaseUrl);
+2 -1
View File
@@ -27,7 +27,8 @@ Normal browser login uses the platform's HttpOnly SameSite cookie and `credentia
- `startServerInstance` and `stopServerInstance` post `ServerLifecycleCommandRequest` with the current config version and receive the lifecycle job response.
- `listServerAdministratorCandidates`, `addServerAdministrator`, and `removeServerAdministrator` call server membership endpoints so server owners can invite or remove active non-platform-admin server administrators.
- Game-specific pages use the scoped `plugin-data` collection API and declared plugin bridge machine actions; Platform does not expose game-specific projection or workflow clients.
- `dispatchFileOperation` posts `FileOperationDispatchRequest` to `/file-operations/dispatch` using logical file keys and scoped refs rather than raw host paths; it is not wired into SCUM server-detail/plugin pages as a raw file workbench.
- `dispatchFileOperation` posts `FileOperationDispatchRequest` to `/file-operations/dispatch` using logical file keys and scoped refs rather than raw host paths; it remains the low-level compatibility dispatch for plugin-declared file work.
- `getServerFileWorkspace`, `listServerFiles`, `refreshServerFiles`, `readServerFile`, `getServerFileReadSnapshot`, `writeServerFile`, `uploadServerFile`, and `prepareServerFileDownload` power the first-party server-detail file manager. The page renders plugin-declared logical directories, requests live listings through `files.list`, reads editable snapshots through `files.read`, saves through `files.write`, and stages browser uploads as server-instance artifacts before Run pulls input chunks on the dedicated file-transfer channel. Server detail may call only these server-file APIs plus the encapsulated download helper; it must not call raw artifact-transfer methods directly.
- `listArtifacts`, `openArtifactDownload`, and `readArtifactContent` use platform artifact routes for available job/server artifacts. Browser reads are chunked through `/artifacts/{id}/content` and must render only safe filenames, checksums, progress, and platform storage behavior.
- `authorizePluginBridge` posts `PluginBridgeAuthorizeRequest` to `/plugin-bridge/authorize` for preflight decisions.
- `executePluginBridge` posts `PluginBridgeExecuteRequest` to `/plugin-bridge/execute` from host-owned bridge dispatch utilities only. Plugin pages receive typed `PluginBridgeExecuteResponse` envelopes and never receive the platform API client, bearer token, raw provider key, run socket, host path, or storage credential.
+107 -1
View File
@@ -1421,7 +1421,7 @@ export interface ServerConfigWriteDispatchResponse {
job: JobResponse;
}
export type FileOperationKind = "read" | "write";
export type FileOperationKind = "list" | "read" | "write";
export interface FileOperationDispatchRequest {
serverInstanceId: string;
@@ -1459,6 +1459,112 @@ export interface DeclaredFileReadSnapshotResponse {
reason?: string;
}
export interface ServerFileTransferPolicyResponse {
channel: string;
uploadChunkSizeBytes: number;
downloadChunkSizeBytes: number;
maxInlineEditBytes: number;
maxBrowserUploadBytes: number;
notes?: string[];
}
export interface ServerFileWorkspaceResponse {
serverInstanceId: string;
pluginId: string;
defaultDirectoryKey: string;
directories: PluginLogicalDirectoryResponse[];
files: PluginLogicalFileResponse[];
configFields: PluginConfigFieldResponse[];
transfer: ServerFileTransferPolicyResponse;
declaredOnly: boolean;
runtimeWorkspaceScope?: string;
}
export type ServerFileEntryKind = "directory" | "file";
export interface ServerFileEntryResponse {
name: string;
kind: ServerFileEntryKind;
directoryKey: string;
relativePath?: string;
logicalKey?: string;
scope?: string;
sizeBytes?: number;
modifiedAt?: string;
checksum?: string;
editable: boolean;
downloadable: boolean;
remark?: string;
}
export interface ServerFileListRequest {
directoryKey: string;
path?: string;
query?: string;
recursive?: boolean;
idempotencyKey?: string;
}
export interface ServerFileListResponse {
serverInstanceId: string;
pluginId: string;
directoryKey: string;
path?: string;
state: "ready" | "pending" | "declared" | string;
entries: ServerFileEntryResponse[];
job?: JobResponse;
refreshedAt?: string;
reason?: string;
}
export interface ServerFileReadRequest {
pluginId?: string;
key: string;
idempotencyKey: string;
}
export interface ServerFileWriteRequest {
pluginId?: string;
key: string;
content?: string;
inputRef?: string;
expectedVersion?: number;
expectedChecksum?: string;
idempotencyKey: string;
}
export interface ServerFileUploadResponse {
status: string;
serverInstanceId: string;
directoryKey: string;
relativePath: string;
artifactId: string;
inputRef: string;
sizeBytes: number;
checksum: string;
job: JobResponse;
}
export interface ServerFileDownloadRequest {
key: string;
idempotencyKey: string;
}
export interface ServerFileDownloadResponse {
status: "ready" | "pending" | string;
serverInstanceId: string;
key: string;
filename: string;
contentType?: string;
content?: string;
checksum?: string;
sizeBytes?: number;
artifact?: ArtifactDownloadReferenceResponse;
job?: JobResponse;
readAt?: string;
reason?: string;
}
export interface LogStreamResponse {
id: string;
serverInstanceId: string;