Add server file manager workflow
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user