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
+219
View File
@@ -1,6 +1,7 @@
package api package api
import ( import (
"io"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
@@ -92,6 +93,14 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/server-instances/{id}/dependencies/install", h.serverDependenciesInstall) mux.HandleFunc("/api/v1/server-instances/{id}/dependencies/install", h.serverDependenciesInstall)
mux.HandleFunc("/api/v1/server-instances/{id}/dependencies", h.serverDependencies) mux.HandleFunc("/api/v1/server-instances/{id}/dependencies", h.serverDependencies)
mux.HandleFunc("/api/v1/server-instances/{id}/logs/events", h.serverLogEvents) mux.HandleFunc("/api/v1/server-instances/{id}/logs/events", h.serverLogEvents)
mux.HandleFunc("/api/v1/server-instances/{id}/files/workspace", h.serverFilesWorkspace)
mux.HandleFunc("/api/v1/server-instances/{id}/files/list", h.serverFilesList)
mux.HandleFunc("/api/v1/server-instances/{id}/files/refresh", h.serverFilesRefresh)
mux.HandleFunc("/api/v1/server-instances/{id}/files/read-snapshot", h.serverFilesReadSnapshot)
mux.HandleFunc("/api/v1/server-instances/{id}/files/read", h.serverFilesRead)
mux.HandleFunc("/api/v1/server-instances/{id}/files/write", h.serverFilesWrite)
mux.HandleFunc("/api/v1/server-instances/{id}/files/upload", h.serverFilesUpload)
mux.HandleFunc("/api/v1/server-instances/{id}/files/download", h.serverFilesDownload)
mux.HandleFunc("/api/v1/server-instances/{id}/rcon/commands", h.sourceRCONCommands) mux.HandleFunc("/api/v1/server-instances/{id}/rcon/commands", h.sourceRCONCommands)
mux.HandleFunc("/api/v1/server-instances/{id}/administrators/candidates", h.serverAdministratorCandidates) mux.HandleFunc("/api/v1/server-instances/{id}/administrators/candidates", h.serverAdministratorCandidates)
mux.HandleFunc("/api/v1/server-instances/{id}/administrators", h.serverAdministrators) mux.HandleFunc("/api/v1/server-instances/{id}/administrators", h.serverAdministrators)
@@ -109,6 +118,7 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/run/jobs/source-rcon-input", h.requireRunSignature(h.runSourceRCONInput)) mux.HandleFunc("/api/v1/run/jobs/source-rcon-input", h.requireRunSignature(h.runSourceRCONInput))
mux.HandleFunc("/api/v1/run/jobs/update-input", h.requireRunSignature(h.runJobUpdateInput)) mux.HandleFunc("/api/v1/run/jobs/update-input", h.requireRunSignature(h.runJobUpdateInput))
mux.HandleFunc("/api/v1/run/jobs/update-chunk", h.requireRunSignature(h.runJobUpdateChunk)) mux.HandleFunc("/api/v1/run/jobs/update-chunk", h.requireRunSignature(h.runJobUpdateChunk))
mux.HandleFunc("/api/v1/run/files/input-chunk", h.requireRunSignature(h.runFileInputChunk))
mux.HandleFunc("/api/v1/run/jobs/update-health", h.requireRunSignature(h.runJobUpdateHealth)) mux.HandleFunc("/api/v1/run/jobs/update-health", h.requireRunSignature(h.runJobUpdateHealth))
mux.HandleFunc("/api/v1/run/jobs/client-manager-input", h.requireRunSignature(h.runClientManagerLifecycleInput)) mux.HandleFunc("/api/v1/run/jobs/client-manager-input", h.requireRunSignature(h.runClientManagerLifecycleInput))
mux.HandleFunc("/api/v1/run/jobs/client-manager-chunk", h.requireRunSignature(h.runClientManagerLifecycleChunk)) mux.HandleFunc("/api/v1/run/jobs/client-manager-chunk", h.requireRunSignature(h.runClientManagerLifecycleChunk))
@@ -1278,6 +1288,196 @@ func (h *coreHandlers) serverInstanceConfigApprove(w http.ResponseWriter, r *htt
writeJSON(w, http.StatusAccepted, dto.ServerConfigWriteDispatchFromDomain(dispatch)) writeJSON(w, http.StatusAccepted, dto.ServerConfigWriteDispatchFromDomain(dispatch))
} }
// serverFilesWorkspace godoc
// @Summary Read server file workspace
// @Description Returns plugin-declared logical directories and transfer policy without exposing host paths.
// @Tags server-files
// @Produce json
// @Param id path string true "Server instance ID"
// @Success 200 {object} dto.ServerFileWorkspaceResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 403 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Failure 405 {object} dto.ErrorResponse
// @Router /api/v1/server-instances/{id}/files/workspace [get]
func (h *coreHandlers) serverFilesWorkspace(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeMethodNotAllowed(w, http.MethodGet)
return
}
workspace, err := h.core.GetServerFileWorkspaceForSession(bearerToken(r), r.PathValue("id"))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.ServerFileWorkspaceFromDomain(workspace))
}
func (h *coreHandlers) serverFilesList(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeMethodNotAllowed(w, http.MethodGet)
return
}
directoryKey, err := h.serverFileDirectoryKey(r, r.URL.Query().Get("directoryKey"))
if err != nil {
writeServiceError(w, err)
return
}
result, err := h.core.ListServerFilesForSession(bearerToken(r), domain.ServerFileListRequest{ServerInstanceID: r.PathValue("id"), DirectoryKey: directoryKey, Path: r.URL.Query().Get("path"), Query: r.URL.Query().Get("query"), Recursive: r.URL.Query().Get("recursive") == "true"})
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.ServerFileListFromDomain(result))
}
func (h *coreHandlers) serverFilesRefresh(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.ServerFileListRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
request.DirectoryKey, err = h.serverFileDirectoryKey(r, request.DirectoryKey)
if err != nil {
writeServiceError(w, err)
return
}
result, err := h.core.RefreshServerFileListForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusAccepted, dto.ServerFileListFromDomain(result))
}
func (h *coreHandlers) serverFilesReadSnapshot(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeMethodNotAllowed(w, http.MethodGet)
return
}
snapshot, err := h.core.GetDeclaredFileReadSnapshotForSession(bearerToken(r), r.PathValue("id"), r.URL.Query().Get("key"))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.DeclaredFileReadSnapshotFromDomain(snapshot))
}
func (h *coreHandlers) serverFilesRead(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.ServerFileReadRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
result, err := h.core.ReadServerFileForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusAccepted, dto.FileOperationDispatchFromDomain(result))
}
func (h *coreHandlers) serverFilesWrite(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.ServerFileWriteRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
result, err := h.core.WriteServerFileForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusAccepted, dto.FileOperationDispatchFromDomain(result))
}
func (h *coreHandlers) serverFilesUpload(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
upload, err := h.decodeServerFileUpload(r)
if err != nil {
writeServiceError(w, err)
return
}
result, err := h.core.UploadServerFileForSession(bearerToken(r), upload)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusAccepted, dto.ServerFileUploadFromDomain(result))
}
func (h *coreHandlers) serverFilesDownload(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.ServerFileDownloadRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
result, err := h.core.PrepareServerFileDownloadForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusAccepted, dto.ServerFileDownloadFromDomain(result))
}
func (h *coreHandlers) serverFileDirectoryKey(r *http.Request, requested string) (string, error) {
requested = strings.TrimSpace(requested)
if requested != "" {
return requested, nil
}
workspace, err := h.core.GetServerFileWorkspaceForSession(bearerToken(r), r.PathValue("id"))
if err != nil {
return "", err
}
return workspace.DefaultDirectoryKey, nil
}
func (h *coreHandlers) decodeServerFileUpload(r *http.Request) (domain.ServerFileUploadRequest, error) {
if err := r.ParseMultipartForm(8 * 1024 * 1024); err != nil {
return domain.ServerFileUploadRequest{}, validator.ValidationError{Violations: []string{"multipart upload is invalid"}}
}
file, header, err := r.FormFile("file")
if err != nil {
return domain.ServerFileUploadRequest{}, validator.ValidationError{Violations: []string{"file is required"}}
}
defer file.Close()
payload, err := io.ReadAll(io.LimitReader(file, validator.MaxArtifactBytes+1))
if err != nil {
return domain.ServerFileUploadRequest{}, err
}
if int64(len(payload)) > validator.MaxArtifactBytes {
return domain.ServerFileUploadRequest{}, validator.ValidationError{Violations: []string{"payload is too large"}}
}
checksum := strings.TrimSpace(r.FormValue("checksum"))
if checksum == "" {
checksum = validator.BytesChecksum(payload)
}
filename := strings.TrimSpace(r.FormValue("filename"))
if filename == "" && header != nil {
filename = header.Filename
}
return domain.ServerFileUploadRequest{ServerInstanceID: r.PathValue("id"), DirectoryKey: r.FormValue("directoryKey"), RelativePath: r.FormValue("relativePath"), Filename: filename, Payload: payload, Checksum: checksum, IdempotencyKey: r.FormValue("idempotencyKey")}, nil
}
// fileOperationDispatch godoc // fileOperationDispatch godoc
// @Summary Dispatch scoped file operation // @Summary Dispatch scoped file operation
// @Description Queues a scoped files.read or files.write job using logical file keys or refs, never raw host paths. // @Description Queues a scoped files.read or files.write job using logical file keys or refs, never raw host paths.
@@ -1673,6 +1873,25 @@ func (h *coreHandlers) runJobUpdateChunk(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusOK, dto.RunUpdateChunkFromDomain(result)) writeJSON(w, http.StatusOK, dto.RunUpdateChunkFromDomain(result))
} }
// runFileInputChunk serves one bounded browser-uploaded file input range only to the active fenced Run attempt.
func (h *coreHandlers) runFileInputChunk(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.RunFileInputChunkRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
result, err := h.core.ReadRunFileInputChunk(request.ToDomain())
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.RunFileInputChunkFromDomain(result))
}
// runJobUpdateHealth godoc // runJobUpdateHealth godoc
// @Summary Confirm a reconciled Run self-update outcome // @Summary Confirm a reconciled Run self-update outcome
// @Description Accepts a signed current-session health or rollback report fenced to the terminal update job attempt. // @Description Accepts a signed current-session health or rollback report fenced to the terminal update job attempt.
+41 -5
View File
@@ -150,7 +150,9 @@ func TestMetricsAndConfigReadAPIAreSafeAndRoleScoped(t *testing.T) {
otherSession := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "other-metrics@example.test", Password: "secret-password"}).SessionID otherSession := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "other-metrics@example.test", Password: "secret-password"}).SessionID
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest()) postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest())
postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", validRunEndpointRequest()) 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{ instance := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{
ID: "server-metrics-api", ID: "server-metrics-api",
PluginID: "server.scum", PluginID: "server.scum",
@@ -234,11 +236,20 @@ func TestConfigWriteAndFileDispatchAPIAreScopedAndSafe(t *testing.T) {
} }
} }
func TestCoreAPIDeclaredFileReadSnapshotRouteIsScopedAndRedacted(t *testing.T) { func TestCoreAPIServerFileWorkspaceRoutesAreScoped(t *testing.T) {
router := newTestRouter() router := newTestRouter()
adminSession := createAdminSession(t, router) adminSession := createAdminSession(t, router)
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest()) postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{ID: "file-workspace-other", DisplayName: "File Workspace Other", Email: "file-workspace-other@example.test", Roles: []string{"server-admin"}, Password: "secret-password"}, adminSession)
postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", validRunEndpointRequest()) otherSession := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "file-workspace-other@example.test", Password: "secret-password"}).SessionID
pluginRequest := validGamePluginRequest()
pluginRequest.RequiredRunCapabilities = append(pluginRequest.RequiredRunCapabilities, domain.JobCapabilityFilesList, domain.JobCapabilityFilesRead, domain.JobCapabilityFilesWrite)
pluginRequest.DeclaredPermissions = []string{"server.files.read", "server.files.write"}
pluginRequest.Permissions.Files = true
pluginRequest.FileWorkspace = dto.PluginFileWorkspaceBody{DefaultDirectoryKey: "scum-config", Directories: []dto.PluginLogicalDirectoryBody{{Key: "scum-config", Label: "服务器配置", Scope: "config"}, {Key: "scum-logs", Label: "日志文件", Scope: "logs"}}, Files: []dto.PluginLogicalFileBody{{Key: "scum-server-settings", DirectoryKey: "scum-config", Label: "ServerSettings.ini", Kind: "config", Editable: true}, {Key: "scum-chat-log", DirectoryKey: "scum-logs", Label: "Chat.log", Kind: "log", StreamKey: "scum.chat"}}}
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", pluginRequest)
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{ instance := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{
ID: "server-file-snapshot-api", ID: "server-file-snapshot-api",
PluginID: "server.scum", PluginID: "server.scum",
@@ -246,8 +257,33 @@ func TestCoreAPIDeclaredFileReadSnapshotRouteIsScopedAndRedacted(t *testing.T) {
Name: "File Snapshot API Server", Name: "File Snapshot API Server",
State: domain.ServerInstanceStateRunning, State: domain.ServerInstanceStateRunning,
}, adminSession) }, adminSession)
putJSONWithAuth[dto.RuntimeBindingResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/runtime-binding", dto.RuntimeBindingUpdateRequest{ProfileKey: "local", Bindings: map[string]string{}}, adminSession)
assertStatus(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/"+instance.ID+"/files/read-snapshot?key=scum-server-settings", "", adminSession), http.StatusNotFound) workspace := getJSONWithAuth[dto.ServerFileWorkspaceResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/files/workspace", adminSession)
if workspace.DefaultDirectoryKey != "scum-config" || workspace.Transfer.Channel != "run-file-transfer" || len(workspace.Files) != 2 {
t.Fatalf("unexpected workspace: %+v", workspace)
}
list := getJSONWithAuth[dto.ServerFileListResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/files/list?directoryKey=scum-config", adminSession)
foundSettings := false
for _, entry := range list.Entries {
if entry.LogicalKey == "scum-server-settings" && entry.Editable && entry.Downloadable {
foundSettings = true
}
}
if list.State != "declared" || !foundSettings {
t.Fatalf("expected declared file list, got %+v", list)
}
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)
if read.Job.Capability != domain.JobCapabilityFilesRead || read.Job.TargetKey != "scum-server-settings" {
t.Fatalf("unexpected read dispatch: %+v", read)
}
snapshot := getJSONWithAuth[dto.DeclaredFileReadSnapshotResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/files/read-snapshot?key=scum-server-settings", adminSession)
if snapshot.State != "pending" || snapshot.JobID != read.Job.ID {
t.Fatalf("expected pending read snapshot, got %+v", snapshot)
}
assertErrorResponse(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/"+instance.ID+"/files/workspace", "", otherSession), http.StatusForbidden, errorCodeForbidden)
} }
func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) { func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) {
+8 -1
View File
@@ -4,7 +4,7 @@ Route declarations and handler comments live in `platform/api`. Request, respons
## Implemented Core Resource Routes ## Implemented Core Resource Routes
All routes use JSON request and response bodies. Collection routes support `GET` for lists and `POST` for create. Detail routes support `GET` by ID. Unsupported methods return `dto.ErrorResponse` with `405`. Routes use JSON request and response bodies unless a route explicitly accepts file upload multipart form data. Collection routes support `GET` for lists and `POST` for create. Detail routes support `GET` by ID. Unsupported methods return `dto.ErrorResponse` with `405`.
| Resource | Collection | Detail | DTO contracts | | Resource | Collection | Detail | DTO contracts |
| --- | --- | --- | --- | | --- | --- | --- | --- |
@@ -17,6 +17,7 @@ All routes use JSON request and response bodies. Collection routes support `GET`
| Server runtime distribution | n/a | `GET /api/v1/server-instances/{id}/runtime/actions`, `POST /api/v1/server-instances/{id}/run/generate`, `POST /api/v1/server-instances/{id}/run/download`, `POST /api/v1/server-instances/{id}/run/key/reset`, `POST /api/v1/server-instances/{id}/run/update`, `GET /api/v1/server-instances/{id}/run/update`, `POST /api/v1/server-instances/{id}/client-managers/generate`, `POST /api/v1/server-instances/{id}/client-managers/download`, `POST /api/v1/server-instances/{id}/client-managers/key/reset`, `GET /api/v1/server-instances/{id}/dependencies`, `POST /api/v1/server-instances/{id}/dependencies/check`, `POST /api/v1/server-instances/{id}/dependencies/install` | `ServerRuntimeActionsResponse`, `RunDistributionGenerateRequest`, `RunDistributionResponse`, `RunUpdateRequest`, `RunUpdateJobResponse`/`RunUpdateJobListResponse`, `ClientManagerBuildRequest`, `ClientManagerDistributionResponse`, `ClientManagerDownloadRequest`, `ComponentKeyResetRequest`, `ComponentKeyResponse`, `DependencyCatalogResponse`, `DependencyJobRequest` | | Server runtime distribution | n/a | `GET /api/v1/server-instances/{id}/runtime/actions`, `POST /api/v1/server-instances/{id}/run/generate`, `POST /api/v1/server-instances/{id}/run/download`, `POST /api/v1/server-instances/{id}/run/key/reset`, `POST /api/v1/server-instances/{id}/run/update`, `GET /api/v1/server-instances/{id}/run/update`, `POST /api/v1/server-instances/{id}/client-managers/generate`, `POST /api/v1/server-instances/{id}/client-managers/download`, `POST /api/v1/server-instances/{id}/client-managers/key/reset`, `GET /api/v1/server-instances/{id}/dependencies`, `POST /api/v1/server-instances/{id}/dependencies/check`, `POST /api/v1/server-instances/{id}/dependencies/install` | `ServerRuntimeActionsResponse`, `RunDistributionGenerateRequest`, `RunDistributionResponse`, `RunUpdateRequest`, `RunUpdateJobResponse`/`RunUpdateJobListResponse`, `ClientManagerBuildRequest`, `ClientManagerDistributionResponse`, `ClientManagerDownloadRequest`, `ComponentKeyResetRequest`, `ComponentKeyResponse`, `DependencyCatalogResponse`, `DependencyJobRequest` |
| Metrics | `GET /api/v1/metrics/platform`, `GET /api/v1/metrics/server-instances` | n/a | `PlatformResourceUsageResponse`, `ServerMetricsResponse`, `ServerMetricsListResponse` | | Metrics | `GET /api/v1/metrics/platform`, `GET /api/v1/metrics/server-instances` | n/a | `PlatformResourceUsageResponse`, `ServerMetricsResponse`, `ServerMetricsListResponse` |
| File operations | `POST /api/v1/file-operations/dispatch` | n/a | `FileOperationDispatchRequest`, `FileOperationDispatchResponse` | | File operations | `POST /api/v1/file-operations/dispatch` | n/a | `FileOperationDispatchRequest`, `FileOperationDispatchResponse` |
| Server file manager | `GET /api/v1/server-instances/{id}/files/workspace`, `GET /api/v1/server-instances/{id}/files/list`, `POST /api/v1/server-instances/{id}/files/refresh`, `POST /api/v1/server-instances/{id}/files/read`, `POST /api/v1/server-instances/{id}/files/write`, `POST /api/v1/server-instances/{id}/files/upload`, `POST /api/v1/server-instances/{id}/files/download` | `GET /api/v1/server-instances/{id}/files/read-snapshot` | `ServerFileWorkspaceResponse`, `ServerFileListResponse`, `DeclaredFileReadSnapshotResponse`, `ServerFileReadRequest`, `ServerFileWriteRequest`, `ServerFileUploadResponse`, `ServerFileDownloadRequest`, `ServerFileDownloadResponse` |
| Plugin-owned data | n/a | `GET/PUT/DELETE /api/v1/server-instances/{id}/plugin-data/{collection}`, `POST .../plugin-data/{collection}/transaction` | `PluginDataPutRequest`, `PluginDataTransactionRequest`, `PluginDataRecordResponse`, `PluginDataListResponse` | | Plugin-owned data | n/a | `GET/PUT/DELETE /api/v1/server-instances/{id}/plugin-data/{collection}`, `POST .../plugin-data/{collection}/transaction` | `PluginDataPutRequest`, `PluginDataTransactionRequest`, `PluginDataRecordResponse`, `PluginDataListResponse` |
| Server administrators | `GET /api/v1/server-instances/{id}/administrators/candidates`, `POST /api/v1/server-instances/{id}/administrators` | `DELETE /api/v1/server-instances/{id}/administrators/{userId}` | `ServerMemberRequest`, `ServerMemberResponse`, `ServerMemberListResponse`, `ServerInstanceResponse` | | Server administrators | `GET /api/v1/server-instances/{id}/administrators/candidates`, `POST /api/v1/server-instances/{id}/administrators` | `DELETE /api/v1/server-instances/{id}/administrators/{userId}` | `ServerMemberRequest`, `ServerMemberResponse`, `ServerMemberListResponse`, `ServerInstanceResponse` |
| Run endpoints | `GET /api/v1/run/endpoints`, `POST /api/v1/run/endpoints` | `GET /api/v1/run/endpoints/{id}` | `RunEndpointCreateRequest`, `RunEndpointResponse`, `RunEndpointListResponse` | | Run endpoints | `GET /api/v1/run/endpoints`, `POST /api/v1/run/endpoints` | `GET /api/v1/run/endpoints/{id}` | `RunEndpointCreateRequest`, `RunEndpointResponse`, `RunEndpointListResponse` |
@@ -186,6 +187,12 @@ Control is the highest-priority run-facing channel; artifact/file transfer press
Run job actions carry bounded job metadata only: job ID, run endpoint ID, server instance ID, capability, idempotency key, lease token, attempt/retry limits, deadlines, progress, terminal state, message, error code, result reference, and timing hints. Raw lease tokens exist only on the signed Run job channel; platform persistence stores their hashes. User-facing Job responses expose safe attempt, retry, cancel, terminal, and reconcile projections but never raw/hashed leases, Run sessions, secret refs, host paths, sockets, or credentials. Run job actions carry bounded job metadata only: job ID, run endpoint ID, server instance ID, capability, idempotency key, lease token, attempt/retry limits, deadlines, progress, terminal state, message, error code, result reference, and timing hints. Raw lease tokens exist only on the signed Run job channel; platform persistence stores their hashes. User-facing Job responses expose safe attempt, retry, cancel, terminal, and reconcile projections but never raw/hashed leases, Run sessions, secret refs, host paths, sockets, or credentials.
Job ack/progress/result/cancel/reconcile calls remain lightweight and independently valid while log batches or artifact/file chunks are queued, slow, or retrying. Equivalent duplicate terminal results remain idempotent under channel pressure. Job ack/progress/result/cancel/reconcile calls remain lightweight and independently valid while log batches or artifact/file chunks are queued, slow, or retrying. Equivalent duplicate terminal results remain idempotent under channel pressure.
## Implemented Run File Transfer Actions
- `POST /api/v1/run/files/input-chunk`: accept `RunFileInputChunkRequest`, validate the active Run endpoint session plus the fenced file-write job lease/attempt, and return `RunFileInputChunkResponse` with a bounded byte range from a server-instance scoped upload artifact.
Run file input chunks are used only for browser-staged file uploads that produce `artifact://` job inputs. The route never returns storage backend paths, browser bearer credentials, machine paths, direct sockets, or unrestricted artifact bodies. This channel is lower priority than control, job lifecycle calls, and log ingest.
## Implemented Log Ingest Actions ## Implemented Log Ingest Actions
- `POST /api/v1/run/logs/batches`: accept `LogBatchIngestRequest`, validate run session and stream metadata, store contiguous entries, update `LogStream.LatestSeq`, and return `LogBatchIngestResponse` with the acknowledged range. - `POST /api/v1/run/logs/batches`: accept `LogBatchIngestRequest`, validate run session and stream metadata, store contiguous entries, update `LogStream.LatestSeq`, and return `LogBatchIngestResponse` with the acknowledged range.
+207
View File
@@ -1000,6 +1000,7 @@ type ServerConfigWriteDispatch struct {
type FileOperationKind string type FileOperationKind string
const ( const (
FileOperationList FileOperationKind = "list"
FileOperationRead FileOperationKind = "read" FileOperationRead FileOperationKind = "read"
FileOperationWrite FileOperationKind = "write" FileOperationWrite FileOperationKind = "write"
) )
@@ -1042,6 +1043,151 @@ type DeclaredFileReadSnapshot struct {
Reason string Reason string
} }
type ServerFileEntryKind string
const (
ServerFileEntryDirectory ServerFileEntryKind = "directory"
ServerFileEntryFile ServerFileEntryKind = "file"
)
type ServerFileTransferPolicy struct {
Channel string
UploadChunkSizeBytes int
DownloadChunkSizeBytes int
MaxInlineEditBytes int
MaxBrowserUploadBytes int64
Notes []string
}
type ServerFileWorkspaceView struct {
ServerInstanceID string
PluginID string
DefaultDirectoryKey string
Directories []PluginLogicalDirectory
Files []PluginLogicalFile
ConfigFields []PluginConfigField
Transfer ServerFileTransferPolicy
DeclaredOnly bool
RuntimeWorkspaceScope string
}
type ServerFileEntry struct {
Name string
Kind ServerFileEntryKind
DirectoryKey string
RelativePath string
LogicalKey string
Scope string
SizeBytes int64
ModifiedAt time.Time
Checksum string
Editable bool
Downloadable bool
Remark string
}
type ServerFileListRequest struct {
ServerInstanceID string
DirectoryKey string
Path string
Query string
Recursive bool
IdempotencyKey string
}
type ServerFileListResult struct {
ServerInstanceID string
PluginID string
DirectoryKey string
Path string
State string
Entries []ServerFileEntry
Job Job
RefreshedAt time.Time
Reason string
}
type ServerFileReadRequest struct {
ServerInstanceID string
PluginID string
Key string
IdempotencyKey string
}
type ServerFileWriteRequest struct {
ServerInstanceID string
PluginID string
Key string
Content string
InputRef string
ExpectedVersion int
ExpectedChecksum string
IdempotencyKey string
}
type ServerFileUploadRequest struct {
ServerInstanceID string
DirectoryKey string
RelativePath string
Filename string
Payload []byte
Checksum string
IdempotencyKey string
}
type ServerFileUploadDispatch struct {
Status string
ServerInstanceID string
DirectoryKey string
RelativePath string
ArtifactID string
InputRef string
SizeBytes int64
Checksum string
Job Job
}
type ServerFileDownloadRequest struct {
ServerInstanceID string
Key string
IdempotencyKey string
}
type ServerFileDownloadResult struct {
Status string
ServerInstanceID string
Key string
Filename string
ContentType string
Content string
Checksum string
SizeBytes int64
Artifact *ArtifactDownloadReference
Job Job
ReadAt time.Time
Reason string
}
type RunFileInputChunkRequest struct {
RunEndpointID string
SessionToken string
JobID string
LeaseToken string
Attempt int
Offset int64
Length int
}
type RunFileInputChunk struct {
JobID string
ArtifactID string
Offset int64
TotalBytes int64
Checksum string
Payload []byte
Complete bool
}
type RunCapacity struct { type RunCapacity struct {
MaxJobs int MaxJobs int
RunningJobs int RunningJobs int
@@ -1054,6 +1200,7 @@ type RunCapacity struct {
const ( const (
JobCapabilityConfigWrite = "config.write" JobCapabilityConfigWrite = "config.write"
JobCapabilityFilesList = "files.list"
JobCapabilityFilesRead = "files.read" JobCapabilityFilesRead = "files.read"
JobCapabilityFilesWrite = "files.write" JobCapabilityFilesWrite = "files.write"
JobCapabilityRemoteFTPRead = "remote.ftp.read" JobCapabilityRemoteFTPRead = "remote.ftp.read"
@@ -1963,6 +2110,66 @@ func CopyFileOperationDispatchResult(result FileOperationDispatchResult) FileOpe
return result return result
} }
func CopyServerFileTransferPolicy(policy ServerFileTransferPolicy) ServerFileTransferPolicy {
policy.Notes = CopyStringSlice(policy.Notes)
return policy
}
func CopyServerFileWorkspaceView(view ServerFileWorkspaceView) ServerFileWorkspaceView {
view.Directories = append([]PluginLogicalDirectory(nil), view.Directories...)
view.Files = append([]PluginLogicalFile(nil), view.Files...)
view.ConfigFields = append([]PluginConfigField(nil), view.ConfigFields...)
view.Transfer = CopyServerFileTransferPolicy(view.Transfer)
return view
}
func CopyServerFileEntry(entry ServerFileEntry) ServerFileEntry {
return entry
}
func CopyServerFileEntries(entries []ServerFileEntry) []ServerFileEntry {
if entries == nil {
return nil
}
out := make([]ServerFileEntry, len(entries))
copy(out, entries)
return out
}
func CopyServerFileListResult(result ServerFileListResult) ServerFileListResult {
result.Entries = CopyServerFileEntries(result.Entries)
result.Job = CopyJob(result.Job)
return result
}
func CopyServerFileUploadRequest(request ServerFileUploadRequest) ServerFileUploadRequest {
request.Payload = CopyBytes(request.Payload)
return request
}
func CopyServerFileUploadDispatch(dispatch ServerFileUploadDispatch) ServerFileUploadDispatch {
dispatch.Job = CopyJob(dispatch.Job)
return dispatch
}
func CopyServerFileDownloadResult(result ServerFileDownloadResult) ServerFileDownloadResult {
if result.Artifact != nil {
artifact := CopyArtifactDownloadReference(*result.Artifact)
result.Artifact = &artifact
}
result.Job = CopyJob(result.Job)
return result
}
func CopyRunFileInputChunkRequest(request RunFileInputChunkRequest) RunFileInputChunkRequest {
return request
}
func CopyRunFileInputChunk(chunk RunFileInputChunk) RunFileInputChunk {
chunk.Payload = CopyBytes(chunk.Payload)
return chunk
}
func CopyRunEndpoint(endpoint RunEndpoint) RunEndpoint { func CopyRunEndpoint(endpoint RunEndpoint) RunEndpoint {
endpoint.Capabilities = CopyStringSlice(endpoint.Capabilities) endpoint.Capabilities = CopyStringSlice(endpoint.Capabilities)
endpoint.Capacity.PressureCodes = CopyStringSlice(endpoint.Capacity.PressureCodes) endpoint.Capacity.PressureCodes = CopyStringSlice(endpoint.Capacity.PressureCodes)
+29
View File
@@ -310,6 +310,26 @@ type RunUpdateChunkResponse struct {
Complete bool `json:"complete"` Complete bool `json:"complete"`
} }
type RunFileInputChunkRequest struct {
RunEndpointID string `json:"runEndpointId"`
SessionToken string `json:"sessionToken"`
JobID string `json:"jobId"`
LeaseToken string `json:"leaseToken"`
Attempt int `json:"attempt"`
Offset int64 `json:"offset"`
Length int `json:"length"`
}
type RunFileInputChunkResponse struct {
JobID string `json:"jobId"`
ArtifactID string `json:"artifactId"`
Offset int64 `json:"offset"`
TotalBytes int64 `json:"totalBytes"`
Checksum string `json:"checksum"`
Payload []byte `json:"payload"`
Complete bool `json:"complete"`
}
type RunUpdateHealthRequest struct { type RunUpdateHealthRequest struct {
RunEndpointID string `json:"runEndpointId"` RunEndpointID string `json:"runEndpointId"`
SessionToken string `json:"sessionToken"` SessionToken string `json:"sessionToken"`
@@ -428,6 +448,10 @@ func (request RunJobResultRequest) ToDomain() domain.RunJobResult {
} }
} }
func (request RunFileInputChunkRequest) ToDomain() domain.RunFileInputChunkRequest {
return domain.RunFileInputChunkRequest{RunEndpointID: request.RunEndpointID, SessionToken: request.SessionToken, JobID: request.JobID, LeaseToken: request.LeaseToken, Attempt: request.Attempt, Offset: request.Offset, Length: request.Length}
}
func (request DistributionBuildInputRequest) ToDomain() domain.DistributionBuildInputRequest { func (request DistributionBuildInputRequest) ToDomain() domain.DistributionBuildInputRequest {
return domain.DistributionBuildInputRequest{ return domain.DistributionBuildInputRequest{
RunEndpointID: request.RunEndpointID, RunEndpointID: request.RunEndpointID,
@@ -569,6 +593,11 @@ func RunUpdateChunkFromDomain(chunk domain.RunUpdateChunk) RunUpdateChunkRespons
return RunUpdateChunkResponse{JobID: chunk.JobID, ArtifactID: chunk.ArtifactID, Offset: chunk.Offset, TotalBytes: chunk.TotalBytes, Checksum: chunk.Checksum, Payload: chunk.Payload, Complete: chunk.Complete} return RunUpdateChunkResponse{JobID: chunk.JobID, ArtifactID: chunk.ArtifactID, Offset: chunk.Offset, TotalBytes: chunk.TotalBytes, Checksum: chunk.Checksum, Payload: chunk.Payload, Complete: chunk.Complete}
} }
func RunFileInputChunkFromDomain(chunk domain.RunFileInputChunk) RunFileInputChunkResponse {
chunk = domain.CopyRunFileInputChunk(chunk)
return RunFileInputChunkResponse{JobID: chunk.JobID, ArtifactID: chunk.ArtifactID, Offset: chunk.Offset, TotalBytes: chunk.TotalBytes, Checksum: chunk.Checksum, Payload: chunk.Payload, Complete: chunk.Complete}
}
func RunUpdateHealthFromDomain(result domain.RunUpdateHealthResult) RunUpdateHealthResponse { func RunUpdateHealthFromDomain(result domain.RunUpdateHealthResult) RunUpdateHealthResponse {
return RunUpdateHealthResponse{Accepted: result.Accepted, JobID: result.JobID, Phase: result.Phase, ServerTime: result.ServerTime} return RunUpdateHealthResponse{Accepted: result.Accepted, JobID: result.JobID, Phase: result.Phase, ServerTime: result.ServerTime}
} }
+171
View File
@@ -730,6 +730,110 @@ type DeclaredFileReadSnapshotResponse struct {
Reason string `json:"reason,omitempty"` Reason string `json:"reason,omitempty"`
} }
type ServerFileTransferPolicyResponse struct {
Channel string `json:"channel"`
UploadChunkSizeBytes int `json:"uploadChunkSizeBytes"`
DownloadChunkSizeBytes int `json:"downloadChunkSizeBytes"`
MaxInlineEditBytes int `json:"maxInlineEditBytes"`
MaxBrowserUploadBytes int64 `json:"maxBrowserUploadBytes"`
Notes []string `json:"notes,omitempty"`
}
type ServerFileWorkspaceResponse struct {
ServerInstanceID string `json:"serverInstanceId"`
PluginID string `json:"pluginId"`
DefaultDirectoryKey string `json:"defaultDirectoryKey"`
Directories []PluginLogicalDirectoryBody `json:"directories"`
Files []PluginLogicalFileBody `json:"files"`
ConfigFields []PluginConfigFieldBody `json:"configFields"`
Transfer ServerFileTransferPolicyResponse `json:"transfer"`
DeclaredOnly bool `json:"declaredOnly"`
RuntimeWorkspaceScope string `json:"runtimeWorkspaceScope,omitempty"`
}
type ServerFileEntryResponse struct {
Name string `json:"name"`
Kind domain.ServerFileEntryKind `json:"kind"`
DirectoryKey string `json:"directoryKey"`
RelativePath string `json:"relativePath,omitempty"`
LogicalKey string `json:"logicalKey,omitempty"`
Scope string `json:"scope,omitempty"`
SizeBytes int64 `json:"sizeBytes,omitempty"`
ModifiedAt time.Time `json:"modifiedAt,omitempty"`
Checksum string `json:"checksum,omitempty"`
Editable bool `json:"editable"`
Downloadable bool `json:"downloadable"`
Remark string `json:"remark,omitempty"`
}
type ServerFileListRequest struct {
DirectoryKey string `json:"directoryKey"`
Path string `json:"path,omitempty"`
Query string `json:"query,omitempty"`
Recursive bool `json:"recursive,omitempty"`
IdempotencyKey string `json:"idempotencyKey,omitempty"`
}
type ServerFileListResponse struct {
ServerInstanceID string `json:"serverInstanceId"`
PluginID string `json:"pluginId"`
DirectoryKey string `json:"directoryKey"`
Path string `json:"path,omitempty"`
State string `json:"state"`
Entries []ServerFileEntryResponse `json:"entries"`
Job *JobResponse `json:"job,omitempty"`
RefreshedAt time.Time `json:"refreshedAt,omitempty"`
Reason string `json:"reason,omitempty"`
}
type ServerFileReadRequest struct {
PluginID string `json:"pluginId,omitempty"`
Key string `json:"key"`
IdempotencyKey string `json:"idempotencyKey"`
}
type ServerFileWriteRequest struct {
PluginID string `json:"pluginId,omitempty"`
Key string `json:"key"`
Content string `json:"content,omitempty"`
InputRef string `json:"inputRef,omitempty"`
ExpectedVersion int `json:"expectedVersion,omitempty"`
ExpectedChecksum string `json:"expectedChecksum,omitempty"`
IdempotencyKey string `json:"idempotencyKey"`
}
type ServerFileUploadResponse struct {
Status string `json:"status"`
ServerInstanceID string `json:"serverInstanceId"`
DirectoryKey string `json:"directoryKey"`
RelativePath string `json:"relativePath"`
ArtifactID string `json:"artifactId"`
InputRef string `json:"inputRef"`
SizeBytes int64 `json:"sizeBytes"`
Checksum string `json:"checksum"`
Job JobResponse `json:"job"`
}
type ServerFileDownloadRequest struct {
Key string `json:"key"`
IdempotencyKey string `json:"idempotencyKey"`
}
type ServerFileDownloadResponse struct {
Status string `json:"status"`
ServerInstanceID string `json:"serverInstanceId"`
Key string `json:"key"`
Filename string `json:"filename"`
ContentType string `json:"contentType,omitempty"`
Content string `json:"content,omitempty"`
Checksum string `json:"checksum,omitempty"`
SizeBytes int64 `json:"sizeBytes,omitempty"`
Artifact *ArtifactDownloadReferenceResponse `json:"artifact,omitempty"`
Job *JobResponse `json:"job,omitempty"`
ReadAt time.Time `json:"readAt,omitempty"`
Reason string `json:"reason,omitempty"`
}
type RunCapacityResponse struct { type RunCapacityResponse struct {
MaxJobs int `json:"maxJobs"` MaxJobs int `json:"maxJobs"`
RunningJobs int `json:"runningJobs"` RunningJobs int `json:"runningJobs"`
@@ -1295,6 +1399,22 @@ func (request FileOperationDispatchRequest) ToDomain() domain.FileOperationDispa
} }
} }
func (request ServerFileListRequest) ToDomain(serverInstanceID string) domain.ServerFileListRequest {
return domain.ServerFileListRequest{ServerInstanceID: serverInstanceID, DirectoryKey: request.DirectoryKey, Path: request.Path, Query: request.Query, Recursive: request.Recursive, IdempotencyKey: request.IdempotencyKey}
}
func (request ServerFileReadRequest) ToDomain(serverInstanceID string) domain.ServerFileReadRequest {
return domain.ServerFileReadRequest{ServerInstanceID: serverInstanceID, PluginID: request.PluginID, Key: request.Key, IdempotencyKey: request.IdempotencyKey}
}
func (request ServerFileWriteRequest) ToDomain(serverInstanceID string) domain.ServerFileWriteRequest {
return domain.ServerFileWriteRequest{ServerInstanceID: serverInstanceID, PluginID: request.PluginID, Key: request.Key, Content: request.Content, InputRef: request.InputRef, ExpectedVersion: request.ExpectedVersion, ExpectedChecksum: request.ExpectedChecksum, IdempotencyKey: request.IdempotencyKey}
}
func (request ServerFileDownloadRequest) ToDomain(serverInstanceID string) domain.ServerFileDownloadRequest {
return domain.ServerFileDownloadRequest{ServerInstanceID: serverInstanceID, Key: request.Key, IdempotencyKey: request.IdempotencyKey}
}
func (request RunEndpointCreateRequest) ToDomain() domain.RunEndpoint { func (request RunEndpointCreateRequest) ToDomain() domain.RunEndpoint {
return domain.RunEndpoint{ return domain.RunEndpoint{
ID: request.ID, ID: request.ID,
@@ -1827,6 +1947,57 @@ func DeclaredFileReadSnapshotFromDomain(snapshot domain.DeclaredFileReadSnapshot
} }
} }
func ServerFileWorkspaceFromDomain(view domain.ServerFileWorkspaceView) ServerFileWorkspaceResponse {
view = domain.CopyServerFileWorkspaceView(view)
workspace := domain.PluginFileWorkspace{DefaultDirectoryKey: view.DefaultDirectoryKey, Directories: view.Directories, Files: view.Files, ConfigFields: view.ConfigFields}
body := fileWorkspaceFromDomain(workspace)
return ServerFileWorkspaceResponse{ServerInstanceID: view.ServerInstanceID, PluginID: view.PluginID, DefaultDirectoryKey: view.DefaultDirectoryKey, Directories: body.Directories, Files: body.Files, ConfigFields: body.ConfigFields, Transfer: ServerFileTransferPolicyFromDomain(view.Transfer), DeclaredOnly: view.DeclaredOnly, RuntimeWorkspaceScope: view.RuntimeWorkspaceScope}
}
func ServerFileTransferPolicyFromDomain(policy domain.ServerFileTransferPolicy) ServerFileTransferPolicyResponse {
policy = domain.CopyServerFileTransferPolicy(policy)
return ServerFileTransferPolicyResponse{Channel: policy.Channel, UploadChunkSizeBytes: policy.UploadChunkSizeBytes, DownloadChunkSizeBytes: policy.DownloadChunkSizeBytes, MaxInlineEditBytes: policy.MaxInlineEditBytes, MaxBrowserUploadBytes: policy.MaxBrowserUploadBytes, Notes: policy.Notes}
}
func ServerFileListFromDomain(result domain.ServerFileListResult) ServerFileListResponse {
result = domain.CopyServerFileListResult(result)
items := make([]ServerFileEntryResponse, len(result.Entries))
for index, entry := range result.Entries {
items[index] = ServerFileEntryFromDomain(entry)
}
var job *JobResponse
if result.Job.ID != "" {
body := JobFromDomain(result.Job)
job = &body
}
return ServerFileListResponse{ServerInstanceID: result.ServerInstanceID, PluginID: result.PluginID, DirectoryKey: result.DirectoryKey, Path: result.Path, State: result.State, Entries: items, Job: job, RefreshedAt: result.RefreshedAt, Reason: result.Reason}
}
func ServerFileEntryFromDomain(entry domain.ServerFileEntry) ServerFileEntryResponse {
entry = domain.CopyServerFileEntry(entry)
return ServerFileEntryResponse{Name: entry.Name, Kind: entry.Kind, DirectoryKey: entry.DirectoryKey, RelativePath: entry.RelativePath, LogicalKey: entry.LogicalKey, Scope: entry.Scope, SizeBytes: entry.SizeBytes, ModifiedAt: entry.ModifiedAt, Checksum: entry.Checksum, Editable: entry.Editable, Downloadable: entry.Downloadable, Remark: entry.Remark}
}
func ServerFileUploadFromDomain(dispatch domain.ServerFileUploadDispatch) ServerFileUploadResponse {
dispatch = domain.CopyServerFileUploadDispatch(dispatch)
return ServerFileUploadResponse{Status: dispatch.Status, ServerInstanceID: dispatch.ServerInstanceID, DirectoryKey: dispatch.DirectoryKey, RelativePath: dispatch.RelativePath, ArtifactID: dispatch.ArtifactID, InputRef: dispatch.InputRef, SizeBytes: dispatch.SizeBytes, Checksum: dispatch.Checksum, Job: JobFromDomain(dispatch.Job)}
}
func ServerFileDownloadFromDomain(result domain.ServerFileDownloadResult) ServerFileDownloadResponse {
result = domain.CopyServerFileDownloadResult(result)
var artifact *ArtifactDownloadReferenceResponse
if result.Artifact != nil {
body := ArtifactDownloadReferenceFromDomain(*result.Artifact)
artifact = &body
}
var job *JobResponse
if result.Job.ID != "" {
body := JobFromDomain(result.Job)
job = &body
}
return ServerFileDownloadResponse{Status: result.Status, ServerInstanceID: result.ServerInstanceID, Key: result.Key, Filename: result.Filename, ContentType: result.ContentType, Content: result.Content, Checksum: result.Checksum, SizeBytes: result.SizeBytes, Artifact: artifact, Job: job, ReadAt: result.ReadAt, Reason: result.Reason}
}
func RunEndpointFromDomain(endpoint domain.RunEndpoint) RunEndpointResponse { func RunEndpointFromDomain(endpoint domain.RunEndpoint) RunEndpointResponse {
endpoint = domain.CopyRunEndpoint(endpoint) endpoint = domain.CopyRunEndpoint(endpoint)
return RunEndpointResponse{ return RunEndpointResponse{
+15 -1
View File
@@ -50,7 +50,7 @@ Jobs must carry bounded metadata such as `jobId`, `runEndpointId`, `serverInstan
Plugin lifecycle assignments may add only a validated plugin identifier, enumerated lifecycle operation, target version, and logical workspace scope. Explicit operator-requested install, enable, disable, upgrade, rollback, retire, dependency-check, and bounded lifecycle commands remain Platform-authorized jobs. Generated Run package startup is not dependent on registration-time job assignment; it is driven by the autonomous lifecycle plan embedded by the platform builder. Assignments cannot carry arbitrary shell, provider configuration, raw credentials, host paths, PIDs, sockets, DSNs, or RCON secrets. Plugin lifecycle assignments may add only a validated plugin identifier, enumerated lifecycle operation, target version, and logical workspace scope. Explicit operator-requested install, enable, disable, upgrade, rollback, retire, dependency-check, and bounded lifecycle commands remain Platform-authorized jobs. Generated Run package startup is not dependent on registration-time job assignment; it is driven by the autonomous lifecycle plan embedded by the platform builder. Assignments cannot carry arbitrary shell, provider configuration, raw credentials, host paths, PIDs, sockets, DSNs, or RCON secrets.
Approved `config.write` and bounded `files.read`/`files.write` assignments carry logical keys, scoped refs, and compare-and-swap revision/checksum inputs. Run executes them inside its scoped workspace with atomic writes and returns bounded logical result metadata; resolved machine paths remain Run-local. Approved `config.write` and bounded `files.list`/`files.read`/`files.write` assignments carry logical directory keys, file keys, scoped refs, and compare-and-swap revision/checksum inputs. Run executes them inside its scoped workspace with atomic writes and returns bounded logical result metadata; resolved machine paths remain Run-local.
Job ack, progress, cancellation polling, reconciliation, and terminal result calls are lightweight lifecycle metadata. They must remain valid while artifact chunks or log retries are pending, and duplicate equivalent terminal results remain idempotent under channel pressure. Job ack, progress, cancellation polling, reconciliation, and terminal result calls are lightweight lifecycle metadata. They must remain valid while artifact chunks or log retries are pending, and duplicate equivalent terminal results remain idempotent under channel pressure.
@@ -113,6 +113,20 @@ Artifact upload supports active run session validation, job/server-instance owne
Artifact/file transfer is the lower-priority heavy channel. Chunk upload and completion must not block control heartbeat, job ack/result delivery, cancellation/reconcile calls, or log ingest acknowledgement. Lightweight routes must reject heavy transfer payloads instead of accepting or storing them. Artifact/file transfer is the lower-priority heavy channel. Chunk upload and completion must not block control heartbeat, job ack/result delivery, cancellation/reconcile calls, or log ingest acknowledgement. Lightweight routes must reject heavy transfer payloads instead of accepting or storing them.
## Server File Manager Transfer
Implemented HTTP JSON routes:
- `POST /api/v1/run/files/input-chunk`
Browser-facing file management uses server-instance scoped routes on Platform for workspace, list, read, write, upload, and download preparation. The browser only sends plugin-declared logical directory keys, logical file keys, relative names, inline text for small edits, or platform-owned `artifact://` input refs. It never receives or sends host paths, direct Run sockets, Run sessions, job lease tokens, storage credentials, or raw machine endpoints.
`files.list` jobs return a bounded `file.list` execution result containing logical entries. `files.read` jobs may return a bounded inline `file.read` result for editable text or a `resultRef` pointing to a platform artifact for larger content. Browser downloads are prepared through Platform and then read in bounded chunks using the platform artifact download contract.
Browser uploads are first staged as server-instance artifacts. Platform then queues a `files.write` job whose `inputRef` is `artifact://<id>` and whose execution input names the dedicated `run-file-transfer` channel. Run pulls those bytes through `POST /api/v1/run/files/input-chunk` while proving the active endpoint session plus job attempt and lease. The chunk route is fenced to the active file-write job, validates artifact ownership/checksum, and returns bounded byte ranges only.
File-manager transfer is a separate, low-priority heavy path. Slow uploads, downloads, retries, or file input chunk pulls must not block control heartbeat, job claim/ack/progress/result/cancel/reconcile, durable log batch ingest, or artifact upload acknowledgements. Control, jobs, logs, artifacts, file transfer, and optional game-client bridge remain independently backpressured channels.
## Client Manager lifecycle channel ## Client Manager lifecycle channel
Client Manager lifecycle jobs use the independent capabilities `client-manager.deploy`, `client-manager.control`, `client-manager.update`, `client-manager.rollback`, and `client-manager.uninstall`. Run obtains a fenced logical contract from `POST /api/v1/run/jobs/client-manager-input` and reads resumable artifact chunks from `POST /api/v1/run/jobs/client-manager-chunk`; these routes are separate from artifact upload, Run control, logs, and optional game-client traffic. The contract carries installation/profile, target, version/revision, checksum, deployment/key generations, fixed executable reference, bounded arguments/timeouts, and idempotency. For a plugin-declared companion profile it also carries a generic `companionConfig` materialization contract: safe relative template/schema/output references, the fenced component identity, declared component capabilities, Platform URL source, proof environment-variable name, component-session/TLS policy, and bounded timing values. Client Manager lifecycle jobs use the independent capabilities `client-manager.deploy`, `client-manager.control`, `client-manager.update`, `client-manager.rollback`, and `client-manager.uninstall`. Run obtains a fenced logical contract from `POST /api/v1/run/jobs/client-manager-input` and reads resumable artifact chunks from `POST /api/v1/run/jobs/client-manager-chunk`; these routes are separate from artifact upload, Run control, logs, and optional game-client traffic. The contract carries installation/profile, target, version/revision, checksum, deployment/key generations, fixed executable reference, bounded arguments/timeouts, and idempotency. For a plugin-declared companion profile it also carries a generic `companionConfig` materialization contract: safe relative template/schema/output references, the fenced component identity, declared component capabilities, Platform URL source, proof environment-variable name, component-session/TLS policy, and bounded timing values.
+4
View File
@@ -311,6 +311,10 @@ func validateExecutionResultForJob(job domain.Job, result domain.RunJobResult) e
if result.ExecutionResult.Checksum == "" || result.ExecutionResult.Checksum != validator.BytesChecksum([]byte(job.ExecutionInput.Content)) { if result.ExecutionResult.Checksum == "" || result.ExecutionResult.Checksum != validator.BytesChecksum([]byte(job.ExecutionInput.Content)) {
return validationError("config write result checksum is invalid") return validationError("config write result checksum is invalid")
} }
case domain.JobCapabilityFilesList:
if result.ExecutionResult.Kind != "file.list" {
return validationError("file list result type is invalid")
}
case domain.JobCapabilityFilesRead: case domain.JobCapabilityFilesRead:
if result.ExecutionResult.Kind != "file.read" { if result.ExecutionResult.Kind != "file.read" {
return validationError("file read result type is invalid") return validationError("file read result type is invalid")
+24 -6
View File
@@ -123,6 +123,13 @@ type Core interface {
RequestRemoteAdapterForSession(string, domain.RemoteAdapterRequest) (domain.RemoteAdapterResult, error) RequestRemoteAdapterForSession(string, domain.RemoteAdapterRequest) (domain.RemoteAdapterResult, error)
GetServerConfigForSession(string, string) (domain.ServerConfig, error) GetServerConfigForSession(string, string) (domain.ServerConfig, error)
GetDeclaredFileReadSnapshotForSession(string, string, string) (domain.DeclaredFileReadSnapshot, error) GetDeclaredFileReadSnapshotForSession(string, string, string) (domain.DeclaredFileReadSnapshot, error)
GetServerFileWorkspaceForSession(string, string) (domain.ServerFileWorkspaceView, error)
ListServerFilesForSession(string, domain.ServerFileListRequest) (domain.ServerFileListResult, error)
RefreshServerFileListForSession(string, domain.ServerFileListRequest) (domain.ServerFileListResult, error)
ReadServerFileForSession(string, domain.ServerFileReadRequest) (domain.FileOperationDispatchResult, error)
WriteServerFileForSession(string, domain.ServerFileWriteRequest) (domain.FileOperationDispatchResult, error)
UploadServerFileForSession(string, domain.ServerFileUploadRequest) (domain.ServerFileUploadDispatch, error)
PrepareServerFileDownloadForSession(string, domain.ServerFileDownloadRequest) (domain.ServerFileDownloadResult, error)
PreviewServerConfigWriteForSession(string, domain.ServerConfigDiffRequest) (domain.ServerConfigDiffPreview, error) PreviewServerConfigWriteForSession(string, domain.ServerConfigDiffRequest) (domain.ServerConfigDiffPreview, error)
ApproveServerConfigWriteForSession(string, domain.ServerConfigWriteApproval) (domain.ServerConfigWriteDispatch, error) ApproveServerConfigWriteForSession(string, domain.ServerConfigWriteApproval) (domain.ServerConfigWriteDispatch, error)
DispatchFileOperationForSession(string, domain.FileOperationDispatchRequest) (domain.FileOperationDispatchResult, error) DispatchFileOperationForSession(string, domain.FileOperationDispatchRequest) (domain.FileOperationDispatchResult, error)
@@ -143,6 +150,7 @@ type Core interface {
GetSourceRCONExecutionInput(domain.SourceRCONExecutionInputRequest) (domain.SourceRCONExecutionInput, error) GetSourceRCONExecutionInput(domain.SourceRCONExecutionInputRequest) (domain.SourceRCONExecutionInput, error)
GetRunUpdateInput(domain.RunUpdateInputRequest) (domain.RunUpdateInput, error) GetRunUpdateInput(domain.RunUpdateInputRequest) (domain.RunUpdateInput, error)
ReadRunUpdateChunk(domain.RunUpdateChunkRequest) (domain.RunUpdateChunk, error) ReadRunUpdateChunk(domain.RunUpdateChunkRequest) (domain.RunUpdateChunk, error)
ReadRunFileInputChunk(domain.RunFileInputChunkRequest) (domain.RunFileInputChunk, error)
ReportRunUpdateHealth(domain.RunUpdateHealthReport) (domain.RunUpdateHealthResult, error) ReportRunUpdateHealth(domain.RunUpdateHealthReport) (domain.RunUpdateHealthResult, error)
RequestRunJobCancel(domain.RunJobCancelRequest) (domain.RunJobCancelRequestResult, error) RequestRunJobCancel(domain.RunJobCancelRequest) (domain.RunJobCancelRequestResult, error)
PollRunJobCancel(domain.RunJobCancelPoll) (domain.RunJobCancelPollResult, error) PollRunJobCancel(domain.RunJobCancelPoll) (domain.RunJobCancelPollResult, error)
@@ -2156,11 +2164,6 @@ func (svc *CoreService) DispatchFileOperationForSession(sessionID string, reques
if artifact.OwnerKind != domain.ArtifactOwnerKindServerInstance || artifact.OwnerID != instance.ID || artifact.State != domain.ArtifactStateAvailable { if artifact.OwnerKind != domain.ArtifactOwnerKindServerInstance || artifact.OwnerID != instance.ID || artifact.State != domain.ArtifactStateAvailable {
return domain.FileOperationDispatchResult{}, ErrForbidden return domain.FileOperationDispatchResult{}, ErrForbidden
} }
payload, payloadErr := svc.artifactPayload(artifactID)
if payloadErr != nil {
return domain.FileOperationDispatchResult{}, payloadErr
}
content = string(payload)
} }
if request.PluginID != "" { if request.PluginID != "" {
plugin, err := svc.store.GamePlugins().Get(request.PluginID) plugin, err := svc.store.GamePlugins().Get(request.PluginID)
@@ -2173,7 +2176,7 @@ func (svc *CoreService) DispatchFileOperationForSession(sessionID string, reques
if plugin.Status != domain.GamePluginStatusInstalled { if plugin.Status != domain.GamePluginStatusInstalled {
return domain.FileOperationDispatchResult{}, validationError("plugin must be installed") return domain.FileOperationDispatchResult{}, validationError("plugin must be installed")
} }
if request.Operation == domain.FileOperationRead && !plugin.Permissions.Files && !containsString(plugin.DeclaredPermissions, "server.files.read") { if (request.Operation == domain.FileOperationList || request.Operation == domain.FileOperationRead) && !plugin.Permissions.Files && !containsString(plugin.DeclaredPermissions, "server.files.read") {
return domain.FileOperationDispatchResult{}, ErrForbidden return domain.FileOperationDispatchResult{}, ErrForbidden
} }
if request.Operation == domain.FileOperationWrite && !containsString(plugin.DeclaredPermissions, "server.files.write") { if request.Operation == domain.FileOperationWrite && !containsString(plugin.DeclaredPermissions, "server.files.write") {
@@ -2188,6 +2191,10 @@ func (svc *CoreService) DispatchFileOperationForSession(sessionID string, reques
} }
capability := domain.JobCapabilityFilesRead capability := domain.JobCapabilityFilesRead
message := "file read queued" message := "file read queued"
if request.Operation == domain.FileOperationList {
capability = domain.JobCapabilityFilesList
message = "file list queued"
}
if request.Operation == domain.FileOperationWrite { if request.Operation == domain.FileOperationWrite {
capability = domain.JobCapabilityFilesWrite capability = domain.JobCapabilityFilesWrite
message = "file write queued" message = "file write queued"
@@ -2218,6 +2225,17 @@ func (svc *CoreService) DispatchFileOperationForSession(sessionID string, reques
} }
func declaredPluginFileRequest(workspace domain.PluginFileWorkspace, request domain.FileOperationDispatchRequest) (domain.PluginLogicalFile, bool, bool) { func declaredPluginFileRequest(workspace domain.PluginFileWorkspace, request domain.FileOperationDispatchRequest) (domain.PluginLogicalFile, bool, bool) {
if request.Operation == domain.FileOperationList {
if len(workspace.Directories) == 0 {
return domain.PluginLogicalFile{}, false, true
}
for _, directory := range workspace.Directories {
if directory.Key == request.Key {
return domain.PluginLogicalFile{}, true, true
}
}
return domain.PluginLogicalFile{}, true, false
}
if len(workspace.Files) == 0 { if len(workspace.Files) == 0 {
return domain.PluginLogicalFile{}, false, true return domain.PluginLogicalFile{}, false, true
} }
+508
View File
@@ -0,0 +1,508 @@
package service
import (
"encoding/json"
"errors"
"fmt"
"path"
"sort"
"strconv"
"strings"
"time"
"browser.local/platform/domain"
"browser.local/platform/repo"
"browser.local/platform/validator"
)
const (
serverFileTransferChannel = "run-file-transfer"
serverFileMaxInlineEditBytes = 64 * 1024
)
type serverFileContext struct {
User domain.User
Instance domain.ServerInstance
Plugin domain.GamePlugin
Directory domain.PluginLogicalDirectory
Scope string
}
type runFileListEnvelope struct {
DirectoryKey string `json:"directoryKey"`
Path string `json:"path"`
Entries []runFileListEntry `json:"entries"`
}
type runFileListEntry struct {
Name string `json:"name"`
Kind string `json:"kind"`
DirectoryKey string `json:"directoryKey"`
RelativePath string `json:"relativePath"`
LogicalKey string `json:"logicalKey"`
Scope string `json:"scope"`
SizeBytes int64 `json:"sizeBytes"`
ModifiedAt string `json:"modifiedAt"`
Checksum string `json:"checksum"`
Editable bool `json:"editable"`
Downloadable bool `json:"downloadable"`
Remark string `json:"remark"`
}
func (svc *CoreService) GetServerFileWorkspaceForSession(sessionID string, serverInstanceID string) (domain.ServerFileWorkspaceView, error) {
ctx, err := svc.serverFileContextForSession(sessionID, serverInstanceID, "", false, false)
if err != nil {
return domain.ServerFileWorkspaceView{}, err
}
workspace := domain.CopyPluginFileWorkspace(ctx.Plugin.FileWorkspace)
if workspace.DefaultDirectoryKey == "" && len(workspace.Directories) > 0 {
workspace.DefaultDirectoryKey = workspace.Directories[0].Key
}
view := domain.ServerFileWorkspaceView{
ServerInstanceID: ctx.Instance.ID,
PluginID: ctx.Plugin.ID,
DefaultDirectoryKey: workspace.DefaultDirectoryKey,
Directories: workspace.Directories,
Files: workspace.Files,
ConfigFields: workspace.ConfigFields,
DeclaredOnly: true,
RuntimeWorkspaceScope: svc.runtimeProfileScope(ctx.Instance.ID),
Transfer: domain.ServerFileTransferPolicy{
Channel: serverFileTransferChannel,
UploadChunkSizeBytes: validator.MaxArtifactChunkBytes,
DownloadChunkSizeBytes: validator.MaxArtifactDownloadBytes,
MaxInlineEditBytes: serverFileMaxInlineEditBytes,
MaxBrowserUploadBytes: validator.MaxArtifactBytes,
Notes: []string{
"文件字节通过独立文件传输端点传递,不占用 control、jobs 或 logs 通道。",
"Web 只发送逻辑目录和相对路径;Run 在本机工作区内解析真实路径。",
},
},
}
return domain.CopyServerFileWorkspaceView(view), nil
}
func (svc *CoreService) ListServerFilesForSession(sessionID string, request domain.ServerFileListRequest) (domain.ServerFileListResult, error) {
request = normalizeServerFileListRequest(request)
if err := validator.ValidateServerFileListRequest(request); err != nil {
return domain.ServerFileListResult{}, err
}
ctx, err := svc.serverFileContextForSession(sessionID, request.ServerInstanceID, request.DirectoryKey, false, false)
if err != nil {
return domain.ServerFileListResult{}, err
}
latest, hasLatest, err := svc.latestFileListJob(ctx.Instance.ID, request.DirectoryKey, request.Path)
if err != nil {
return domain.ServerFileListResult{}, err
}
if hasLatest && latest.State == domain.JobStateSucceeded && latest.ExecutionResult.Kind == "file.list" {
entries, parseErr := serverFileEntriesFromRunList(latest.ExecutionResult.Content, request.DirectoryKey, request.Path)
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
}
}
state := "declared"
reason := "展示插件声明的逻辑文件;点击刷新可请求 Run 返回实时目录。"
if hasLatest && !isTerminalJobState(latest.State) {
state = "pending"
reason = "Run 正在刷新目录。"
}
entries := filterServerFileEntries(serverFileEntriesFromDeclaredWorkspace(ctx.Plugin.FileWorkspace, request.DirectoryKey), request.Query)
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 (svc *CoreService) RefreshServerFileListForSession(sessionID string, request domain.ServerFileListRequest) (domain.ServerFileListResult, error) {
request = normalizeServerFileListRequest(request)
if request.IdempotencyKey == "" {
request.IdempotencyKey = fmt.Sprintf("file-list:%s:%s:%s", request.ServerInstanceID, request.DirectoryKey, request.Path)
}
if err := validator.ValidateServerFileListRequest(request); err != nil {
return domain.ServerFileListResult{}, err
}
ctx, err := svc.serverFileContextForSession(sessionID, request.ServerInstanceID, request.DirectoryKey, false, true)
if err != nil {
return domain.ServerFileListResult{}, err
}
job, err := svc.CreateJob(domain.Job{
ID: jobIDFromParts("job-file-list", request.ServerInstanceID, request.IdempotencyKey),
ServerInstanceID: ctx.Instance.ID,
RunEndpointID: ctx.Instance.RunEndpointID,
Capability: domain.JobCapabilityFilesList,
TargetKey: request.DirectoryKey,
InputRef: "",
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: svc.runtimeProfileScope(ctx.Instance.ID), Inputs: map[string]string{
"directoryKey": request.DirectoryKey,
"path": request.Path,
"recursive": strconv.FormatBool(request.Recursive),
"query": request.Query,
}, MaxReadBytes: serverFileMaxInlineEditBytes},
IdempotencyKey: request.IdempotencyKey,
Progress: domain.JobProgress{Percent: 0, Phase: "queued", Message: "file list queued"},
})
if err != nil {
return domain.ServerFileListResult{}, err
}
entries := filterServerFileEntries(serverFileEntriesFromDeclaredWorkspace(ctx.Plugin.FileWorkspace, request.DirectoryKey), request.Query)
return domain.CopyServerFileListResult(domain.ServerFileListResult{ServerInstanceID: ctx.Instance.ID, PluginID: ctx.Plugin.ID, DirectoryKey: request.DirectoryKey, Path: request.Path, State: "pending", Entries: entries, Job: job, Reason: "目录刷新任务已派发到 Run。"}), nil
}
func (svc *CoreService) ReadServerFileForSession(sessionID string, request domain.ServerFileReadRequest) (domain.FileOperationDispatchResult, error) {
if err := validator.ValidateServerFileReadRequest(request); err != nil {
return domain.FileOperationDispatchResult{}, err
}
return svc.DispatchFileOperationForSession(sessionID, domain.FileOperationDispatchRequest{ServerInstanceID: request.ServerInstanceID, PluginID: request.PluginID, Operation: domain.FileOperationRead, Key: request.Key, IdempotencyKey: request.IdempotencyKey})
}
func (svc *CoreService) WriteServerFileForSession(sessionID string, request domain.ServerFileWriteRequest) (domain.FileOperationDispatchResult, error) {
if request.InputRef == "" {
request.InputRef = fileManagerInlineInputRef(request.ServerInstanceID, request.Key, request.IdempotencyKey)
}
if err := validator.ValidateServerFileWriteRequest(request); err != nil {
return domain.FileOperationDispatchResult{}, err
}
return svc.DispatchFileOperationForSession(sessionID, domain.FileOperationDispatchRequest{ServerInstanceID: request.ServerInstanceID, PluginID: request.PluginID, Operation: domain.FileOperationWrite, Key: request.Key, InputRef: request.InputRef, Content: request.Content, ExpectedConfigVersion: request.ExpectedVersion, ExpectedChecksum: request.ExpectedChecksum, IdempotencyKey: request.IdempotencyKey})
}
func (svc *CoreService) UploadServerFileForSession(sessionID string, request domain.ServerFileUploadRequest) (domain.ServerFileUploadDispatch, error) {
request = domain.CopyServerFileUploadRequest(request)
if err := validator.ValidateServerFileUploadRequest(request); err != nil {
return domain.ServerFileUploadDispatch{}, err
}
ctx, err := svc.serverFileContextForSession(sessionID, request.ServerInstanceID, request.DirectoryKey, true, true)
if err != nil {
return domain.ServerFileUploadDispatch{}, err
}
if strings.EqualFold(ctx.Directory.Scope, "logs") {
return domain.ServerFileUploadDispatch{}, validationError("log directories are read-only")
}
relativePath := cleanServerFileRelativePath(path.Join(request.RelativePath, request.Filename))
artifactID := serverFileUploadArtifactID(request.ServerInstanceID, request.IdempotencyKey, relativePath)
artifact := domain.Artifact{ID: artifactID, OwnerKind: domain.ArtifactOwnerKindServerInstance, OwnerID: ctx.Instance.ID, SizeBytes: int64(len(request.Payload)), Checksum: request.Checksum, State: domain.ArtifactStateAvailable, CreatedAt: svc.now(), UpdatedAt: svc.now()}
if err := svc.putBrowserFileArtifact(artifact, request.Payload); err != nil {
return domain.ServerFileUploadDispatch{}, err
}
inputRef := "artifact://" + artifact.ID
targetKey := cleanServerFileRelativePath(path.Join(request.DirectoryKey, relativePath))
job, err := svc.CreateJob(domain.Job{
ID: jobIDFromParts("job-file-upload", request.ServerInstanceID, request.IdempotencyKey),
ServerInstanceID: ctx.Instance.ID,
RunEndpointID: ctx.Instance.RunEndpointID,
Capability: domain.JobCapabilityFilesWrite,
TargetKey: targetKey,
InputRef: inputRef,
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: svc.runtimeProfileScope(ctx.Instance.ID), ExpectedChecksum: request.Checksum, Inputs: map[string]string{
"directoryKey": request.DirectoryKey,
"relativePath": relativePath,
"filename": request.Filename,
"transfer": serverFileTransferChannel,
}},
IdempotencyKey: request.IdempotencyKey,
Progress: domain.JobProgress{Percent: 0, Phase: "queued", Message: "file upload staged; Run will pull input chunks"},
})
if err != nil {
return domain.ServerFileUploadDispatch{}, err
}
return domain.CopyServerFileUploadDispatch(domain.ServerFileUploadDispatch{Status: "queued", ServerInstanceID: ctx.Instance.ID, DirectoryKey: request.DirectoryKey, RelativePath: relativePath, ArtifactID: artifact.ID, InputRef: inputRef, SizeBytes: artifact.SizeBytes, Checksum: artifact.Checksum, Job: job}), nil
}
func (svc *CoreService) PrepareServerFileDownloadForSession(sessionID string, request domain.ServerFileDownloadRequest) (domain.ServerFileDownloadResult, error) {
if err := validator.ValidateServerFileDownloadRequest(request); err != nil {
return domain.ServerFileDownloadResult{}, err
}
ctx, err := svc.serverFileContextForSession(sessionID, request.ServerInstanceID, "", false, true)
if err != nil {
return domain.ServerFileDownloadResult{}, err
}
filename := serverFileDisplayName(ctx.Plugin.FileWorkspace, request.Key)
job, hasJob, err := svc.latestFileReadJob(ctx.Instance.ID, request.Key)
if err != nil {
return domain.ServerFileDownloadResult{}, err
}
if hasJob && job.State == domain.JobStateSucceeded && job.ExecutionResult.Kind == "file.read" {
if strings.HasPrefix(job.ResultRef, "artifact://") {
reference, openErr := svc.OpenArtifactDownloadForSession(sessionID, domain.ArtifactDownloadReferenceRequest{ArtifactID: strings.TrimPrefix(job.ResultRef, "artifact://")})
if openErr != nil {
return domain.ServerFileDownloadResult{}, openErr
}
return domain.CopyServerFileDownloadResult(domain.ServerFileDownloadResult{Status: "ready", ServerInstanceID: ctx.Instance.ID, Key: request.Key, Filename: filename, ContentType: reference.ContentType, Checksum: reference.Checksum, SizeBytes: reference.SizeBytes, Artifact: &reference, Job: job, ReadAt: job.TerminalAt}), nil
}
if job.ExecutionResult.Content != "" {
content := redactDeclaredFileReadContent(job.ExecutionResult.Content)
return domain.CopyServerFileDownloadResult(domain.ServerFileDownloadResult{Status: "ready", ServerInstanceID: ctx.Instance.ID, Key: request.Key, Filename: filename, ContentType: "text/plain; charset=utf-8", Content: content, Checksum: job.ExecutionResult.Checksum, SizeBytes: int64(len([]byte(content))), Job: job, ReadAt: job.TerminalAt}), nil
}
}
if hasJob && !isTerminalJobState(job.State) {
return domain.CopyServerFileDownloadResult(domain.ServerFileDownloadResult{Status: "pending", ServerInstanceID: ctx.Instance.ID, Key: request.Key, Filename: filename, Job: job, Reason: "文件读取任务仍在执行。"}), nil
}
dispatch, err := svc.ReadServerFileForSession(sessionID, domain.ServerFileReadRequest{ServerInstanceID: request.ServerInstanceID, PluginID: ctx.Plugin.ID, Key: request.Key, IdempotencyKey: request.IdempotencyKey})
if err != nil {
return domain.ServerFileDownloadResult{}, err
}
return domain.CopyServerFileDownloadResult(domain.ServerFileDownloadResult{Status: "pending", ServerInstanceID: ctx.Instance.ID, Key: request.Key, Filename: filename, Job: dispatch.Job, Reason: "文件读取任务已派发;完成后可再次下载。"}), nil
}
func (svc *CoreService) ReadRunFileInputChunk(request domain.RunFileInputChunkRequest) (domain.RunFileInputChunk, error) {
request = domain.CopyRunFileInputChunkRequest(request)
if err := validator.ValidateRunFileInputChunkRequest(request); err != nil {
return domain.RunFileInputChunk{}, err
}
session, err := svc.validatedRunSession(request.RunEndpointID, request.SessionToken)
if err != nil {
return domain.RunFileInputChunk{}, err
}
svc.jobMu.Lock()
job, err := svc.fencedJob(session, request.JobID, request.LeaseToken, request.Attempt)
if err != nil {
svc.jobMu.Unlock()
return domain.RunFileInputChunk{}, err
}
if job.Capability != domain.JobCapabilityFilesWrite || !strings.HasPrefix(job.InputRef, "artifact://") {
svc.jobMu.Unlock()
return domain.RunFileInputChunk{}, validationError("job does not reference a file input artifact")
}
if job.State != domain.JobStateAccepted && job.State != domain.JobStateRunning {
svc.jobMu.Unlock()
return domain.RunFileInputChunk{}, validationError("job is not active")
}
artifactID := strings.TrimPrefix(job.InputRef, "artifact://")
serverInstanceID := job.ServerInstanceID
svc.jobMu.Unlock()
artifact, err := svc.store.Artifacts().Get(artifactID)
if err != nil {
return domain.RunFileInputChunk{}, err
}
if artifact.OwnerKind != domain.ArtifactOwnerKindServerInstance || artifact.OwnerID != serverInstanceID || artifact.State != domain.ArtifactStateAvailable {
return domain.RunFileInputChunk{}, ErrForbidden
}
payload, err := svc.artifactPayload(artifact.ID)
if err != nil {
return domain.RunFileInputChunk{}, err
}
if int64(len(payload)) != artifact.SizeBytes || validator.BytesChecksum(payload) != artifact.Checksum {
return domain.RunFileInputChunk{}, validationError("file input artifact checksum mismatch")
}
if request.Offset >= artifact.SizeBytes {
return domain.RunFileInputChunk{}, validationError("offset must be inside artifact content")
}
length := request.Length
remaining := artifact.SizeBytes - request.Offset
if int64(length) > remaining {
length = int(remaining)
}
end := int(request.Offset) + length
chunk := domain.RunFileInputChunk{JobID: job.ID, ArtifactID: artifact.ID, Offset: request.Offset, TotalBytes: artifact.SizeBytes, Checksum: artifact.Checksum, Payload: payload[int(request.Offset):end], Complete: int64(end) == artifact.SizeBytes}
return domain.CopyRunFileInputChunk(chunk), nil
}
func (svc *CoreService) serverFileContextForSession(sessionID string, serverInstanceID string, directoryKey string, requireWrite bool, requireRuntime bool) (serverFileContext, error) {
instance, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID)
if err != nil {
return serverFileContext{}, err
}
user, err := svc.GetCurrentUser(sessionID)
if err != nil {
return serverFileContext{}, err
}
if requireRuntime {
if err := svc.requireCompleteRuntimeBindings(user.ID, instance.ID, "file.manager.denied"); err != nil {
return serverFileContext{}, err
}
}
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
return serverFileContext{}, err
}
if plugin.Status != domain.GamePluginStatusInstalled {
return serverFileContext{}, validationError("plugin must be installed")
}
if requireWrite {
if !containsString(plugin.DeclaredPermissions, "server.files.write") {
return serverFileContext{}, ErrForbidden
}
} else if !plugin.Permissions.Files && !containsString(plugin.DeclaredPermissions, "server.files.read") {
return serverFileContext{}, ErrForbidden
}
directory := domain.PluginLogicalDirectory{}
if directoryKey != "" {
var found bool
for _, candidate := range plugin.FileWorkspace.Directories {
if candidate.Key == directoryKey {
directory = candidate
found = true
break
}
}
if !found && len(plugin.FileWorkspace.Directories) > 0 {
return serverFileContext{}, validationError("directoryKey must reference a plugin-declared directory")
}
}
return serverFileContext{User: user, Instance: instance, Plugin: plugin, Directory: directory, Scope: svc.runtimeProfileScope(instance.ID)}, nil
}
func normalizeServerFileListRequest(request domain.ServerFileListRequest) domain.ServerFileListRequest {
request.DirectoryKey = strings.TrimSpace(request.DirectoryKey)
request.Path = cleanServerFileRelativePath(request.Path)
request.Query = strings.TrimSpace(request.Query)
return request
}
func serverFileEntriesFromDeclaredWorkspace(workspace domain.PluginFileWorkspace, directoryKey string) []domain.ServerFileEntry {
entries := make([]domain.ServerFileEntry, 0)
for _, directory := range workspace.Directories {
if directoryKey == "" || directory.Key == directoryKey {
continue
}
entries = append(entries, domain.ServerFileEntry{Name: directory.Label, Kind: domain.ServerFileEntryDirectory, DirectoryKey: directory.Key, RelativePath: "", Scope: directory.Scope, Editable: false, Downloadable: false, Remark: "插件声明目录"})
}
for _, file := range workspace.Files {
if directoryKey != "" && file.DirectoryKey != directoryKey {
continue
}
entries = append(entries, domain.ServerFileEntry{Name: file.Label, Kind: domain.ServerFileEntryFile, DirectoryKey: file.DirectoryKey, RelativePath: file.Key, LogicalKey: file.Key, Scope: file.Kind, Editable: file.Editable, Downloadable: true, Remark: fileRemark(file)})
}
sort.SliceStable(entries, func(i int, j int) bool {
if entries[i].Kind == entries[j].Kind {
return strings.ToLower(entries[i].Name) < strings.ToLower(entries[j].Name)
}
return entries[i].Kind == domain.ServerFileEntryDirectory
})
return entries
}
func serverFileEntriesFromRunList(content string, fallbackDirectoryKey string, fallbackPath string) ([]domain.ServerFileEntry, error) {
var envelope runFileListEnvelope
if err := json.Unmarshal([]byte(content), &envelope); err != nil {
return nil, err
}
entries := make([]domain.ServerFileEntry, 0, len(envelope.Entries))
for _, entry := range envelope.Entries {
kind := domain.ServerFileEntryFile
if entry.Kind == string(domain.ServerFileEntryDirectory) {
kind = domain.ServerFileEntryDirectory
}
modifiedAt := time.Time{}
if entry.ModifiedAt != "" {
modifiedAt, _ = time.Parse(time.RFC3339, entry.ModifiedAt)
}
directoryKey := entry.DirectoryKey
if directoryKey == "" {
directoryKey = fallbackDirectoryKey
}
relativePath := cleanServerFileRelativePath(entry.RelativePath)
if relativePath == "" {
relativePath = fallbackPath
}
entries = append(entries, domain.ServerFileEntry{Name: entry.Name, Kind: kind, DirectoryKey: directoryKey, RelativePath: relativePath, LogicalKey: entry.LogicalKey, Scope: entry.Scope, SizeBytes: entry.SizeBytes, ModifiedAt: modifiedAt, Checksum: entry.Checksum, Editable: entry.Editable, Downloadable: entry.Downloadable, Remark: entry.Remark})
}
return entries, nil
}
func filterServerFileEntries(entries []domain.ServerFileEntry, query string) []domain.ServerFileEntry {
query = strings.ToLower(strings.TrimSpace(query))
if query == "" {
return domain.CopyServerFileEntries(entries)
}
filtered := make([]domain.ServerFileEntry, 0, len(entries))
for _, entry := range entries {
if strings.Contains(strings.ToLower(entry.Name+" "+entry.RelativePath+" "+entry.LogicalKey+" "+entry.Remark), query) {
filtered = append(filtered, entry)
}
}
return filtered
}
func (svc *CoreService) latestFileListJob(serverInstanceID string, directoryKey string, relativePath string) (domain.Job, bool, error) {
return svc.latestServerFileJob(serverInstanceID, domain.JobCapabilityFilesList, directoryKey)
}
func (svc *CoreService) latestFileReadJob(serverInstanceID string, key string) (domain.Job, bool, error) {
return svc.latestServerFileJob(serverInstanceID, domain.JobCapabilityFilesRead, key)
}
func (svc *CoreService) latestServerFileJob(serverInstanceID string, capability string, targetKey string) (domain.Job, bool, error) {
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 != capability || job.TargetKey != targetKey {
continue
}
if !found || job.UpdatedAt.After(latest.UpdatedAt) || job.CreatedAt.After(latest.CreatedAt) {
latest = job
found = true
}
}
return domain.CopyJob(latest), found, nil
}
func (svc *CoreService) putBrowserFileArtifact(artifact domain.Artifact, payload []byte) error {
svc.artifactMu.Lock()
defer svc.artifactMu.Unlock()
if existing, err := svc.store.Artifacts().Get(artifact.ID); err == nil {
if existing.OwnerKind != artifact.OwnerKind || existing.OwnerID != artifact.OwnerID || existing.SizeBytes != artifact.SizeBytes || existing.Checksum != artifact.Checksum || existing.State != domain.ArtifactStateAvailable {
return validationError("file upload idempotency key conflicts with existing artifact")
}
} else if !errors.Is(err, repo.ErrNotFound) {
return err
} else {
if err := validator.ValidateArtifact(artifact); err != nil {
return err
}
if err := svc.store.Artifacts().Create(artifact); err != nil {
return err
}
}
if err := svc.artifactStore.PutPayload(artifact.ID, payload); err != nil {
return err
}
svc.artifactPayloads[artifact.ID] = domain.CopyBytes(payload)
return nil
}
func cleanServerFileRelativePath(value string) string {
value = strings.TrimSpace(strings.ReplaceAll(value, "\\", "/"))
if value == "" || value == "." {
return ""
}
cleaned := path.Clean(value)
if cleaned == "." {
return ""
}
return strings.TrimPrefix(cleaned, "/")
}
func serverFileUploadArtifactID(serverInstanceID string, idempotencyKey string, relativePath string) string {
raw := serverInstanceID + ":" + idempotencyKey + ":" + relativePath
return "artifact-file-upload-" + sanitizeIDPart(serverInstanceID) + "-" + fmt.Sprint(stableStringNumber(raw))
}
func fileManagerInlineInputRef(serverInstanceID string, key string, idempotencyKey string) string {
return "input://server-files/" + sanitizeIDPart(serverInstanceID) + "/" + sanitizeIDPart(key) + "/" + fmt.Sprint(stableStringNumber(idempotencyKey))
}
func fileRemark(file domain.PluginLogicalFile) string {
if file.StreamKey != "" {
return "日志流 " + file.StreamKey
}
if file.Editable {
return "可编辑配置"
}
return "插件声明文件"
}
func serverFileDisplayName(workspace domain.PluginFileWorkspace, key string) string {
for _, file := range workspace.Files {
if file.Key == key && strings.TrimSpace(file.Label) != "" {
return file.Label
}
}
name := path.Base(key)
if name == "." || name == "/" || name == "" {
return "server-file.txt"
}
return name
}
+114 -2
View File
@@ -1421,6 +1421,104 @@ func ValidateFileOperationDispatchRequest(request domain.FileOperationDispatchRe
return finish(violations) return finish(violations)
} }
func ValidateServerFileListRequest(request domain.ServerFileListRequest) error {
var violations []string
violations = appendRequired(violations, "serverInstanceId", request.ServerInstanceID)
violations = appendRequired(violations, "directoryKey", request.DirectoryKey)
if !validLogicalFileKey(request.DirectoryKey) {
violations = append(violations, "directoryKey is not allowed")
}
if request.Path != "" && !validLogicalFileKey(request.Path) {
violations = append(violations, "path is not allowed")
}
if len([]rune(request.Query)) > 80 || containsUnsafeRuntimeSecret(request.Query) || looksLikeRawHostPath(request.Query) {
violations = append(violations, "query is not allowed")
}
if request.IdempotencyKey != "" && (containsUnsafeRuntimeSecret(request.IdempotencyKey) || looksLikeRawHostPath(request.IdempotencyKey)) {
violations = append(violations, "idempotencyKey is not allowed")
}
return finish(violations)
}
func ValidateServerFileReadRequest(request domain.ServerFileReadRequest) error {
return ValidateFileOperationDispatchRequest(domain.FileOperationDispatchRequest{ServerInstanceID: request.ServerInstanceID, PluginID: request.PluginID, Operation: domain.FileOperationRead, Key: request.Key, IdempotencyKey: request.IdempotencyKey})
}
func ValidateServerFileWriteRequest(request domain.ServerFileWriteRequest) error {
return ValidateFileOperationDispatchRequest(domain.FileOperationDispatchRequest{ServerInstanceID: request.ServerInstanceID, PluginID: request.PluginID, Operation: domain.FileOperationWrite, Key: request.Key, InputRef: request.InputRef, Content: request.Content, ExpectedConfigVersion: request.ExpectedVersion, ExpectedChecksum: request.ExpectedChecksum, IdempotencyKey: request.IdempotencyKey})
}
func ValidateServerFileUploadRequest(request domain.ServerFileUploadRequest) error {
request = domain.CopyServerFileUploadRequest(request)
var violations []string
violations = appendRequired(violations, "serverInstanceId", request.ServerInstanceID)
violations = appendRequired(violations, "directoryKey", request.DirectoryKey)
violations = appendRequired(violations, "filename", request.Filename)
violations = appendRequired(violations, "checksum", request.Checksum)
violations = appendRequired(violations, "idempotencyKey", request.IdempotencyKey)
if !validLogicalFileKey(request.DirectoryKey) {
violations = append(violations, "directoryKey is not allowed")
}
if request.RelativePath != "" && !validLogicalFileKey(request.RelativePath) {
violations = append(violations, "relativePath is not allowed")
}
if !validUploadFilename(request.Filename) {
violations = append(violations, "filename is not allowed")
}
if len(request.Payload) == 0 {
violations = append(violations, "payload is required")
}
if int64(len(request.Payload)) > MaxArtifactBytes {
violations = append(violations, fmt.Sprintf("payload must not exceed %d", MaxArtifactBytes))
}
if request.Checksum != "" {
if !validSHA256Checksum(request.Checksum) {
violations = append(violations, "checksum must be sha256:<hex>")
} else if request.Checksum != BytesChecksum(request.Payload) {
violations = append(violations, "checksum does not match payload")
}
}
for _, value := range []string{request.ServerInstanceID, request.DirectoryKey, request.RelativePath, request.Filename, request.IdempotencyKey} {
if containsUnsafeRuntimeSecret(value) || looksLikeRawHostPath(value) || strings.Contains(strings.ToLower(value), "unix://") {
violations = append(violations, "request contains unsafe content")
break
}
}
return finish(violations)
}
func ValidateServerFileDownloadRequest(request domain.ServerFileDownloadRequest) error {
var violations []string
violations = appendRequired(violations, "serverInstanceId", request.ServerInstanceID)
violations = appendRequired(violations, "key", request.Key)
violations = appendRequired(violations, "idempotencyKey", request.IdempotencyKey)
if !validLogicalFileKey(request.Key) {
violations = append(violations, "key is not allowed")
}
if containsUnsafeRuntimeSecret(request.IdempotencyKey) || looksLikeRawHostPath(request.IdempotencyKey) {
violations = append(violations, "idempotencyKey is not allowed")
}
return finish(violations)
}
func ValidateRunFileInputChunkRequest(request domain.RunFileInputChunkRequest) error {
var violations []string
violations = appendRequired(violations, "runEndpointId", request.RunEndpointID)
violations = appendRequired(violations, "sessionToken", request.SessionToken)
violations = appendRequired(violations, "jobId", request.JobID)
violations = appendRequired(violations, "leaseToken", request.LeaseToken)
if request.Attempt <= 0 {
violations = append(violations, "attempt must be positive")
}
if request.Offset < 0 {
violations = append(violations, "offset must not be negative")
}
if request.Length <= 0 || request.Length > MaxArtifactDownloadBytes {
violations = append(violations, fmt.Sprintf("length must be between 1 and %d", MaxArtifactDownloadBytes))
}
return finish(violations)
}
func appendPercentViolation(violations []string, field string, value float64) []string { func appendPercentViolation(violations []string, field string, value float64) []string {
if value < 0 || value > 100 { if value < 0 || value > 100 {
return append(violations, field+" must be between 0 and 100") return append(violations, field+" must be between 0 and 100")
@@ -1588,7 +1686,7 @@ func ValidateJob(job domain.Job) error {
if len(job.ExecutionResult.Summary) > maxSummaryLength { if len(job.ExecutionResult.Summary) > maxSummaryLength {
violations = append(violations, "executionResult.summary is too long") violations = append(violations, "executionResult.summary is too long")
} }
if job.Capability == domain.JobCapabilityConfigWrite || job.Capability == domain.JobCapabilityFilesRead || job.Capability == domain.JobCapabilityFilesWrite { if job.Capability == domain.JobCapabilityConfigWrite || job.Capability == domain.JobCapabilityFilesList || job.Capability == domain.JobCapabilityFilesRead || job.Capability == domain.JobCapabilityFilesWrite {
if job.ServerInstanceID == "" { if job.ServerInstanceID == "" {
violations = append(violations, "serverInstanceId is required for scoped file jobs") violations = append(violations, "serverInstanceId is required for scoped file jobs")
} }
@@ -2305,13 +2403,27 @@ func validRemoteDatabaseEngine(engine string) bool {
func validFileOperationKind(operation domain.FileOperationKind) bool { func validFileOperationKind(operation domain.FileOperationKind) bool {
switch operation { switch operation {
case domain.FileOperationRead, domain.FileOperationWrite: case domain.FileOperationList, domain.FileOperationRead, domain.FileOperationWrite:
return true return true
default: default:
return false return false
} }
} }
func validUploadFilename(name string) bool {
trimmed := strings.TrimSpace(name)
if trimmed == "" || trimmed != name || len([]rune(name)) > 120 || strings.Contains(name, "/") || strings.Contains(name, `\`) || strings.Contains(name, "..") || strings.Contains(name, "://") || looksLikeRawHostPath(name) || containsUnsafeRuntimeSecret(name) {
return false
}
for _, char := range name {
if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || char == '_' || char == '-' || char == '.' || char == ' ' || char == '(' || char == ')' {
continue
}
return false
}
return true
}
func validConfigFileKey(key string) bool { func validConfigFileKey(key string) bool {
switch key { switch key {
case "server.properties", "config/server.properties": case "server.properties", "config/server.properties":
+55 -1
View File
@@ -144,6 +144,18 @@ const runtimeDownload: ArtifactDownloadReferenceResponse = {
storageBehavior: "platform-memory-transfer-session" 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", () => { describe("PlatformApiClient AI providers", () => {
afterEach(() => { afterEach(() => {
setPlatformApiSessionToken(null); setPlatformApiSessionToken(null);
@@ -259,6 +271,40 @@ describe("PlatformApiClient AI providers", () => {
job: { ...job, id: "job-file-read", capability: "files.read", targetKey: "logs/latest.log" } 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")) { if (url.endsWith("/api/v1/run/endpoints")) {
return jsonResponse({ items: [endpoint], count: 1 }); return jsonResponse({ items: [endpoint], count: 1 });
} }
@@ -546,6 +592,14 @@ describe("PlatformApiClient AI providers", () => {
status: "queued", status: "queued",
job: { capability: "files.read", targetKey: "logs/latest.log" } 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.listRunEndpoints()).resolves.toMatchObject({ count: 1 });
await expect(client.listJobs()).resolves.toMatchObject({ count: 1 }); await expect(client.listJobs()).resolves.toMatchObject({ count: 1 });
await expect(client.listJobs(server.id)).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" }) 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" } }); ).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 () => { it("calls plugin marketplace endpoints with filter and state contracts", async () => {
+64
View File
@@ -32,6 +32,7 @@ import type {
CurrentUserResponse, CurrentUserResponse,
DependencyCatalogResponse, DependencyCatalogResponse,
DependencyJobRequest, DependencyJobRequest,
DeclaredFileReadSnapshotResponse,
FileOperationDispatchRequest, FileOperationDispatchRequest,
FileOperationDispatchResponse, FileOperationDispatchResponse,
GameClientBridgeCancelRequest, GameClientBridgeCancelRequest,
@@ -84,6 +85,14 @@ import type {
ServerDeploymentResponse, ServerDeploymentResponse,
ServerInstanceListResponse, ServerInstanceListResponse,
ServerDeletionRequest, ServerDeletionRequest,
ServerFileDownloadRequest,
ServerFileDownloadResponse,
ServerFileListRequest,
ServerFileListResponse,
ServerFileReadRequest,
ServerFileUploadResponse,
ServerFileWorkspaceResponse,
ServerFileWriteRequest,
ServerInstanceUpdateRequest, ServerInstanceUpdateRequest,
ServerInstanceResponse, ServerInstanceResponse,
ServerMemberListResponse, 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> { async listLogStreams(serverInstanceId?: string): Promise<LogStreamListResponse> {
const query = serverInstanceId ? `?serverInstanceId=${encodeURIComponent(serverInstanceId)}` : ""; const query = serverInstanceId ? `?serverInstanceId=${encodeURIComponent(serverInstanceId)}` : "";
return this.request<LogStreamListResponse>(`/log-streams${query}`); return this.request<LogStreamListResponse>(`/log-streams${query}`);
@@ -900,4 +954,14 @@ function artifactQuery(filter: ArtifactFilterRequest): string {
return query ? `?${query}` : ""; 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); 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. - `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. - `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. - 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. - `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. - `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. - `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; job: JobResponse;
} }
export type FileOperationKind = "read" | "write"; export type FileOperationKind = "list" | "read" | "write";
export interface FileOperationDispatchRequest { export interface FileOperationDispatchRequest {
serverInstanceId: string; serverInstanceId: string;
@@ -1459,6 +1459,112 @@ export interface DeclaredFileReadSnapshotResponse {
reason?: string; 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 { export interface LogStreamResponse {
id: string; id: string;
serverInstanceId: string; serverInstanceId: string;
+2 -1
View File
@@ -144,10 +144,11 @@ export interface PlatformOverviewSignal {
at: string; at: string;
} }
export type ServerDetailSection = "manage" | "llm" | `plugin:${string}`; export type ServerDetailSection = "manage" | "files" | "llm" | `plugin:${string}`;
export const serverDetailSections: Array<{ id: ServerDetailSection; label: string }> = [ export const serverDetailSections: Array<{ id: ServerDetailSection; label: string }> = [
{ id: "manage", label: "管理" }, { id: "manage", label: "管理" },
{ id: "files", label: "文件" },
{ id: "llm", label: "AI 助手" } { id: "llm", label: "AI 助手" }
]; ];
@@ -64,6 +64,9 @@ describe("ServerDetailPage config write approval", () => {
}); });
it("keeps artifact transfer and backend internals out of server detail", () => { it("keeps artifact transfer and backend internals out of server detail", () => {
expect(serverDetailPageSource).toContain("ServerFilesSection");
expect(serverDetailPageSource).toContain("getServerFileWorkspace");
expect(serverDetailPageSource).toContain("downloadServerFileResult");
expect(serverDetailPageSource).not.toContain("openArtifactDownload"); expect(serverDetailPageSource).not.toContain("openArtifactDownload");
expect(serverDetailPageSource).not.toContain("readArtifactContent"); expect(serverDetailPageSource).not.toContain("readArtifactContent");
expect(serverDetailPageSource).not.toContain("浏览器制品传输"); expect(serverDetailPageSource).not.toContain("浏览器制品传输");
@@ -130,6 +133,7 @@ describe("ServerDetailPage config write approval", () => {
it("routes plugin-declared pages into server detail tabs", () => { it("routes plugin-declared pages into server detail tabs", () => {
expect(serverDetailPageSource).toContain("serverDetailSectionEntries(readyPlugin)"); expect(serverDetailPageSource).toContain("serverDetailSectionEntries(readyPlugin)");
expect(serverDetailPageSource).toContain("plugin:${page.key}"); expect(serverDetailPageSource).toContain("plugin:${page.key}");
expect(serverDetailPageSource).toContain('section === "files"');
expect(serverDetailPageSource).toContain("PluginPageSection"); expect(serverDetailPageSource).toContain("PluginPageSection");
expect(serverDetailPageSource).toContain("PluginPageHostPage"); expect(serverDetailPageSource).toContain("PluginPageHostPage");
expect(serverDetailPageSource).not.toContain("ScumFileManagementSection"); expect(serverDetailPageSource).not.toContain("ScumFileManagementSection");
+324 -3
View File
@@ -1,9 +1,10 @@
import { MoonStar, PackageOpen, Pencil, ShieldCheck, Sparkles, Square, Terminal, UserRoundMinus, UserRoundPlus, WandSparkles } from "lucide-react"; import { ChevronRight, Download, Eye, FileText, Folder, MoonStar, PackageOpen, Pencil, RefreshCw, Save, Search, ShieldCheck, Sparkles, Square, Terminal, Upload, UserRoundMinus, UserRoundPlus, WandSparkles } from "lucide-react";
import { type FormEvent, useCallback, useEffect, useMemo, useState } from "react"; import { type ChangeEvent, type FormEvent, useCallback, useEffect, useMemo, useState } from "react";
import { platformApiClient } from "../api/client"; import { platformApiClient } from "../api/client";
import type { import type {
ConfigDiffLineResponse, ConfigDiffLineResponse,
DeclaredFileReadSnapshotResponse,
GamePluginResponse, GamePluginResponse,
JobResponse, JobResponse,
ServerInstanceResponse, ServerInstanceResponse,
@@ -11,7 +12,10 @@ import type {
ServerMetricsResponse, ServerMetricsResponse,
ServerDeploymentResponse, ServerDeploymentResponse,
ServerConfigDiffPreviewResponse, ServerConfigDiffPreviewResponse,
RunEndpointResponse RunEndpointResponse,
ServerFileEntryResponse,
ServerFileListResponse,
ServerFileWorkspaceResponse
} from "../api/types"; } from "../api/types";
import { ConfirmDialog, UsageMeter } from "../components/OperationControls"; import { ConfirmDialog, UsageMeter } from "../components/OperationControls";
import { ServerManagementTerminalDrawer } from "../components/ServerManagementTerminalDrawer"; import { ServerManagementTerminalDrawer } from "../components/ServerManagementTerminalDrawer";
@@ -30,6 +34,7 @@ import {
serverMetadataUpdateRequestFromForm serverMetadataUpdateRequestFromForm
} from "../schemas/serverManagement"; } from "../schemas/serverManagement";
import { cx } from "../utils/classes"; import { cx } from "../utils/classes";
import { downloadServerFileResult } from "../utils/serverFileTransfer";
import { stateLabel, statusClass } from "./ServersPage"; import { stateLabel, statusClass } from "./ServersPage";
import { PluginPageHostPage } from "./PluginPageHostPage"; import { PluginPageHostPage } from "./PluginPageHostPage";
@@ -281,6 +286,7 @@ export function ServerDetailPage(props: PageComponentProps) {
/> />
)} )}
{section === "manage" && <ServerAdministratorsSection instance={instance.data} session={session} onChanged={(next) => setInstance({ status: "ready", data: next })} />} {section === "manage" && <ServerAdministratorsSection instance={instance.data} session={session} onChanged={(next) => setInstance({ status: "ready", data: next })} />}
{section === "files" && <ServerFilesSection instance={instance.data} session={session} operations={operations} />}
{section === "llm" && <LlmSection serverId={serverId} instance={instance.data} session={session} operations={operations} />} {section === "llm" && <LlmSection serverId={serverId} instance={instance.data} session={session} operations={operations} />}
<ServerManagementTerminalDrawer open={terminalOpen} serverId={instance.data.id} serverName={instance.data.name} pluginId={instance.data.pluginId} canManage={canManageServers} onClose={() => setTerminalOpen(false)} /> <ServerManagementTerminalDrawer open={terminalOpen} serverId={instance.data.id} serverName={instance.data.name} pluginId={instance.data.pluginId} canManage={canManageServers} onClose={() => setTerminalOpen(false)} />
</> </>
@@ -560,6 +566,321 @@ function ServerAdministratorsSection({ instance, session, onChanged }: ServerAdm
); );
} }
interface ServerFilesSectionProps {
instance: ServerInstanceResponse;
session: PageComponentProps["session"];
operations: PageComponentProps["operations"];
}
interface ServerFileEditorState {
entry: ServerFileEntryResponse | null;
key: string;
draft: string;
snapshot?: DeclaredFileReadSnapshotResponse;
loading: boolean;
saving: boolean;
message?: string;
error?: string;
}
function ServerFilesSection({ instance, session, operations }: ServerFilesSectionProps) {
const [workspace, setWorkspace] = useState<LoadState<ServerFileWorkspaceResponse>>({ status: "loading" });
const [list, setList] = useState<LoadState<ServerFileListResponse>>({ status: "loading" });
const [directoryKey, setDirectoryKey] = useState("");
const [relativePath, setRelativePath] = useState("");
const [searchDraft, setSearchDraft] = useState("");
const [searchQuery, setSearchQuery] = useState("");
const [recursive, setRecursive] = useState(false);
const [panelResult, setPanelResult] = useState<{ status: "pending" | "succeeded" | "failed"; label: string } | null>(null);
const [uploadBusy, setUploadBusy] = useState(false);
const [editor, setEditor] = useState<ServerFileEditorState>({ entry: null, key: "", draft: "", loading: false, saving: false });
const activeDirectory = workspace.status === "ready" ? workspace.data.directories.find((item) => item.key === directoryKey) : undefined;
const canUpload = workspace.status === "ready" && activeDirectory?.scope !== "logs" && !uploadBusy;
const entries = list.status === "ready" ? list.data.entries : [];
const loadWorkspace = useCallback(async () => {
setWorkspace({ status: "loading" });
try {
const response = await platformApiClient.getServerFileWorkspace(instance.id);
setWorkspace({ status: "ready", data: response });
setDirectoryKey((current) => current || response.defaultDirectoryKey || response.directories[0]?.key || "");
} catch (error) {
setWorkspace({ status: "error", reason: error instanceof Error ? error.message : "文件工作区加载失败" });
setList({ status: "error", reason: "文件工作区不可用" });
}
}, [instance.id]);
const loadList = useCallback(async () => {
if (!directoryKey) return;
setList({ status: "loading" });
try {
const response = await platformApiClient.listServerFiles(instance.id, { directoryKey, path: relativePath || undefined, query: searchQuery || undefined, recursive });
setList({ status: "ready", data: response });
} catch (error) {
setList({ status: "error", reason: error instanceof Error ? error.message : "文件列表加载失败" });
}
}, [directoryKey, instance.id, recursive, relativePath, searchQuery]);
useEffect(() => {
void loadWorkspace();
}, [loadWorkspace]);
useEffect(() => {
if (workspace.status !== "ready" || !directoryKey) return;
void loadList();
}, [directoryKey, loadList, workspace.status]);
async function refreshRuntimeList() {
if (!directoryKey) return;
const operationId = operations.begin({ intent: "刷新文件目录", targetKind: "server", targetId: instance.id, requester: session.displayName });
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);
setPanelResult({ status: "pending", label: response.reason ?? "目录刷新任务已派发,稍后可再次刷新查看实时结果。" });
} catch (error) {
const reason = error instanceof Error ? error.message : "目录刷新失败";
operations.fail(operationId, reason, operationId);
setPanelResult({ status: "failed", label: reason });
}
}
async function openEntry(entry: ServerFileEntryResponse) {
if (entry.kind === "directory") {
setDirectoryKey(entry.directoryKey || directoryKey);
setRelativePath(entry.relativePath ?? "");
setEditor({ entry: null, key: "", draft: "", loading: false, saving: false });
return;
}
await openEditor(entry);
}
async function openEditor(entry: ServerFileEntryResponse) {
const key = serverFileEntryKey(entry);
if (!key) {
setPanelResult({ status: "failed", label: "该文件缺少插件声明的逻辑 key,不能读取。" });
return;
}
setEditor({ entry, key, draft: "", loading: true, saving: false, message: "正在读取最近快照…" });
try {
const snapshot = await platformApiClient.getServerFileReadSnapshot(instance.id, key);
if (snapshot.state === "ready") {
setEditor({ entry, key, draft: snapshot.content ?? "", snapshot, loading: false, saving: false, message: snapshot.content === undefined ? snapshot.reason ?? "文件快照已就绪,但内容不适合内联编辑。" : "已加载最近读取快照。" });
return;
}
const operationId = operations.begin({ intent: "读取文件", targetKind: "server", targetId: instance.id, requester: session.displayName });
const dispatch = await platformApiClient.readServerFile(instance.id, { key, idempotencyKey: serverFileIdempotency("read", instance.id, key) });
operations.succeed(operationId, `读取任务 ${dispatch.job.id} 已派发`, dispatch.job);
setEditor({ entry, key, draft: "", snapshot, loading: false, saving: false, message: snapshot.reason ?? "读取任务已派发;Run 返回后再次打开即可编辑。" });
setPanelResult({ status: "pending", label: `读取任务已派发:${dispatch.job.id}` });
} catch (error) {
const reason = error instanceof Error ? error.message : "文件读取失败";
setEditor({ entry, key, draft: "", loading: false, saving: false, error: reason });
setPanelResult({ status: "failed", label: reason });
}
}
async function saveEditor() {
if (!editor.entry || !editor.key || editor.saving || !editor.entry.editable) return;
const operationId = operations.begin({ intent: "保存文件", targetKind: "server", targetId: instance.id, requester: session.displayName });
setEditor((current) => ({ ...current, saving: true, error: undefined, message: "正在派发写入任务…" }));
try {
const dispatch = await platformApiClient.writeServerFile(instance.id, { key: editor.key, content: editor.draft, expectedVersion: editor.snapshot?.version, expectedChecksum: editor.snapshot?.checksum, idempotencyKey: serverFileIdempotency("write", instance.id, editor.key) });
operations.succeed(operationId, `写入任务 ${dispatch.job.id} 已派发`, dispatch.job);
setEditor((current) => ({ ...current, saving: false, message: "保存任务已派发;Run 会在工作区内原子写入。" }));
setPanelResult({ status: "pending", label: `写入任务已派发:${dispatch.job.id}` });
await loadList();
} catch (error) {
const reason = error instanceof Error ? error.message : "文件保存失败";
operations.fail(operationId, reason, operationId);
setEditor((current) => ({ ...current, saving: false, error: reason }));
setPanelResult({ status: "failed", label: reason });
}
}
async function downloadEntry(entry: ServerFileEntryResponse) {
const key = serverFileEntryKey(entry);
if (!key || !entry.downloadable) return;
const operationId = operations.begin({ intent: "下载文件", targetKind: "server", targetId: instance.id, requester: session.displayName });
setPanelResult({ status: "pending", label: "正在准备文件下载…" });
try {
const result = await platformApiClient.prepareServerFileDownload(instance.id, { key, idempotencyKey: serverFileIdempotency("download", instance.id, key) });
const message = await downloadServerFileResult(platformApiClient, result);
operations.succeed(operationId, message, result.job);
setPanelResult({ status: result.status === "ready" ? "succeeded" : "pending", label: message });
} catch (error) {
const reason = error instanceof Error ? error.message : "文件下载失败";
operations.fail(operationId, reason, operationId);
setPanelResult({ status: "failed", label: reason });
}
}
async function uploadFile(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
event.target.value = "";
if (!file || !directoryKey || workspace.status !== "ready") return;
if (file.size > workspace.data.transfer.maxBrowserUploadBytes) {
setPanelResult({ status: "failed", label: `文件超过浏览器上传上限:${formatBytes(workspace.data.transfer.maxBrowserUploadBytes)}` });
return;
}
const operationId = operations.begin({ intent: "上传文件", targetKind: "server", targetId: instance.id, requester: session.displayName });
setUploadBusy(true);
setPanelResult({ status: "pending", label: `正在暂存上传:${file.name}` });
try {
const response = await platformApiClient.uploadServerFile(instance.id, { directoryKey, relativePath: relativePath || undefined, file, idempotencyKey: serverFileIdempotency("upload", instance.id, file.name) });
operations.succeed(operationId, `上传已暂存,写入任务 ${response.job.id} 已派发`, response.job);
setPanelResult({ status: "pending", label: `上传已走独立文件通道排队:${response.relativePath}` });
await loadList();
} catch (error) {
const reason = error instanceof Error ? error.message : "文件上传失败";
operations.fail(operationId, reason, operationId);
setPanelResult({ status: "failed", label: reason });
} finally {
setUploadBusy(false);
}
}
function submitSearch(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSearchQuery(searchDraft.trim());
}
function goUp() {
if (relativePath) {
setRelativePath(relativePath.split("/").filter(Boolean).slice(0, -1).join("/"));
return;
}
if (workspace.status === "ready" && directoryKey !== workspace.data.defaultDirectoryKey) {
setDirectoryKey(workspace.data.defaultDirectoryKey || workspace.data.directories[0]?.key || directoryKey);
}
}
if (workspace.status === "loading") return <LoadingState label="正在加载文件工作区…" compact />;
if (workspace.status === "error") return <ErrorState title="文件工作区不可用" reason={workspace.reason} diagnosticId={`server-files:${instance.id}`} onRetry={() => void loadWorkspace()} compact />;
return (
<article className="console-panel server-file-manager" aria-label="server file manager">
<div className="panel-header">
<h2><Folder size={16} style={{ verticalAlign: "-2px" }} /> </h2>
<span className="page-status"> {workspace.data.transfer.channel} · {formatBytes(workspace.data.transfer.maxInlineEditBytes)}</span>
</div>
<div className="server-file-pathbar" aria-label="当前文件路径">
<button type="button" className="icon-command" onClick={goUp} disabled={!relativePath && directoryKey === workspace.data.defaultDirectoryKey}><ChevronRight size={14} className="server-file-back-icon" /><span></span></button>
<span className="server-file-path-chip">{activeDirectory?.label ?? (directoryKey || "未声明目录")}</span>
{relativePath.split("/").filter(Boolean).map((part) => <span key={part} className="server-file-path-chip server-file-path-child"><ChevronRight size={12} />{part}</span>)}
</div>
<div className="server-file-toolbar">
<div className="server-file-directory-tabs" role="tablist" aria-label="文件目录">
{workspace.data.directories.map((directory) => (
<button key={directory.key} type="button" className={cx("segmented-button", directory.key === directoryKey && "segmented-button-active")} onClick={() => { setDirectoryKey(directory.key); setRelativePath(""); }}>
{directory.label}
</button>
))}
</div>
<form className="server-file-search" onSubmit={submitSearch}>
<Search size={14} />
<input type="search" value={searchDraft} placeholder="搜索文件/目录" onChange={(event) => setSearchDraft(event.target.value)} />
<label><input type="checkbox" checked={recursive} onChange={(event) => setRecursive(event.target.checked)} /> </label>
<button type="submit" className="icon-command"></button>
</form>
<div className="action-strip server-file-actions">
<button type="button" className="icon-command" onClick={() => void refreshRuntimeList()} disabled={!directoryKey}><RefreshCw size={14} /><span></span></button>
<label className={cx("server-file-upload-control", !canUpload && "server-file-upload-disabled")} title={canUpload ? "上传到当前逻辑目录" : "当前目录不可上传或正在上传"}>
<Upload size={14} /><span>{uploadBusy ? "上传中…" : "上传"}</span><input type="file" disabled={!canUpload} onChange={(event) => void uploadFile(event)} />
</label>
</div>
</div>
{panelResult && <ResultBadge status={panelResult.status} label={panelResult.label} />}
{list.status === "loading" && <LoadingState label="正在加载文件列表…" compact />}
{list.status === "error" && <ErrorState title="文件列表不可用" reason={list.reason} diagnosticId={`server-file-list:${instance.id}:${directoryKey}`} onRetry={() => void loadList()} compact />}
{list.status === "ready" && (
<div className="server-file-layout">
<div className="resource-table-wrap server-file-table-wrap">
<table className="resource-table server-file-table">
<thead><tr><th aria-label="选择"><input type="checkbox" disabled /></th><th></th><th></th><th></th><th></th><th></th></tr></thead>
<tbody>
{entries.length === 0 && <tr><td colSpan={6}><span className="provider-id"></span></td></tr>}
{entries.map((entry) => (
<tr key={serverFileEntryRowKey(entry)} className={entry.kind === "directory" ? "server-file-directory-row" : undefined}>
<td><input type="checkbox" disabled /></td>
<td>
<button type="button" className="table-link-button server-file-name-button" onClick={() => void openEntry(entry)}>
{entry.kind === "directory" ? <Folder size={16} /> : <FileText size={16} />}<span>{entry.name}</span>
</button>
<span className="provider-id">{entry.logicalKey || entry.relativePath || entry.directoryKey}</span>
</td>
<td>{entry.kind === "directory" ? "计算" : formatBytes(entry.sizeBytes)}</td>
<td>{formatDateTime(entry.modifiedAt)}</td>
<td>{entry.remark || entry.scope || "--"}</td>
<td>
<div className="row-actions human-row-actions">
{entry.kind === "directory" ? <button type="button" title="打开目录" onClick={() => void openEntry(entry)}><Eye size={14} /><span></span></button> : <button type="button" title="读取/编辑" disabled={!entry.editable} onClick={() => void openEditor(entry)}><Pencil size={14} /><span></span></button>}
{entry.kind === "file" && <button type="button" title="下载" disabled={!entry.downloadable} onClick={() => void downloadEntry(entry)}><Download size={14} /><span></span></button>}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<aside className="server-file-editor" aria-label="file editor">
<div className="panel-header"><h3><FileText size={15} style={{ verticalAlign: "-2px" }} /> </h3>{editor.entry && <span className="page-status">{editor.entry.name}</span>}</div>
{!editor.entry && <p className="section-copy"> Run </p>}
{editor.entry && editor.loading && <LoadingState label="正在读取文件快照…" compact />}
{editor.entry && editor.error && <ErrorState title="文件编辑不可用" reason={editor.error} diagnosticId={`server-file-edit:${instance.id}:${editor.key}`} compact />}
{editor.entry && editor.message && !editor.error && <span className="provider-id">{editor.message}</span>}
{editor.entry && editor.snapshot?.state === "ready" && editor.snapshot.content !== undefined && (
<label className="server-file-editor-field">
<textarea value={editor.draft} spellCheck={false} onChange={(event) => setEditor((current) => ({ ...current, draft: event.target.value }))} />
</label>
)}
{editor.entry && (
<div className="action-strip server-file-editor-actions">
<button type="button" className="primary-command" disabled={!editor.entry.editable || editor.loading || editor.saving || editor.snapshot?.content === undefined} onClick={() => void saveEditor()}><Save size={14} /><span>{editor.saving ? "保存中…" : "保存"}</span></button>
<button type="button" className="icon-command" disabled={!editor.entry.downloadable} onClick={() => void downloadEntry(editor.entry!)}><Download size={14} /><span></span></button>
</div>
)}
</aside>
</div>
)}
</article>
);
}
function serverFileEntryKey(entry: ServerFileEntryResponse): string {
return entry.logicalKey || entry.relativePath || entry.name;
}
function serverFileEntryRowKey(entry: ServerFileEntryResponse): string {
return `${entry.kind}:${entry.directoryKey}:${entry.relativePath ?? ""}:${entry.logicalKey ?? ""}:${entry.name}`;
}
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()}`;
}
function formatBytes(value?: number): string {
if (value === undefined || !Number.isFinite(value)) return "--";
if (value < 1024) return `${value} B`;
const units = ["KB", "MB", "GB", "TB"];
let scaled = value / 1024;
let unitIndex = 0;
while (scaled >= 1024 && unitIndex < units.length - 1) {
scaled /= 1024;
unitIndex += 1;
}
return `${scaled >= 10 ? scaled.toFixed(1) : scaled.toFixed(2)} ${units[unitIndex]}`;
}
function formatDateTime(value?: string): string {
if (!value) return "--";
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString();
}
function HeaderStat({ label, value }: { label: string; value: string }) { function HeaderStat({ label, value }: { label: string; value: string }) {
return ( return (
<span className="server-card-stat"> <span className="server-card-stat">
+21
View File
@@ -450,6 +450,26 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))}
.server-toolbar select{min-height:38px;border:1px solid var(--line-strong);border-radius:8px;padding:0 10px;background:var(--surface-solid);color:var(--ink);font:inherit;min-width:180px} .server-toolbar select{min-height:38px;border:1px solid var(--line-strong);border-radius:8px;padding:0 10px;background:var(--surface-solid);color:var(--ink);font:inherit;min-width:180px}
.server-toolbar select:focus{border-color:var(--accent);outline:2px solid var(--accent-soft)} .server-toolbar select:focus{border-color:var(--accent);outline:2px solid var(--accent-soft)}
.server-toolbar input[type=search]:focus{border-color:var(--accent);outline:2px solid var(--accent-soft)} .server-toolbar input[type=search]:focus{border-color:var(--accent);outline:2px solid var(--accent-soft)}
.server-file-manager{display:grid;gap:12px}
.server-file-pathbar{display:flex;align-items:center;gap:8px;min-width:0;padding:8px;border:1px solid var(--line);border-radius:8px;background:var(--frosted-surface),color-mix(in srgb,var(--surface) 74%,transparent);box-shadow:inset 0 1px 0 var(--crystal-rim)}
.server-file-back-icon{transform:rotate(180deg)}
.server-file-path-chip{display:inline-flex;align-items:center;gap:4px;min-height:30px;padding:0 10px;border:1px solid var(--line);border-radius:999px;background:var(--control-surface);color:var(--ink-soft);font-size:12px;font-weight:800;white-space:nowrap}
.server-file-path-child{color:var(--ink-faint)}
.server-file-toolbar{display:grid;grid-template-columns:minmax(180px,1fr) minmax(260px,1.2fr) auto;gap:8px;align-items:center;min-width:0}
.server-file-directory-tabs{display:flex;gap:6px;overflow:auto;min-width:0;padding-bottom:2px}
.server-file-search{display:flex;align-items:center;gap:7px;min-width:0;min-height:38px;padding:0 8px;border:1px solid var(--line);border-radius:8px;background:var(--control-surface);box-shadow:inset 0 1px 0 var(--crystal-rim)}
.server-file-search input[type=search]{min-width:120px;flex:1 1 auto;border:0;background:transparent;color:var(--ink);font:inherit;outline:0}
.server-file-search label{display:inline-flex;align-items:center;gap:5px;color:var(--ink-faint);font-size:12px;font-weight:800;white-space:nowrap}
.server-file-actions{justify-content:flex-end;flex-wrap:nowrap}
.server-file-upload-control{min-height:36px;display:inline-flex;align-items:center;justify-content:center;gap:8px;border:1px solid var(--line-strong);border-radius:8px;padding:0 12px;background:var(--control-surface);color:var(--ink-soft);cursor:pointer;box-shadow:inset 0 1px 0 var(--crystal-rim),0 8px 18px var(--glass-shadow);font-weight:700;white-space:nowrap}
.server-file-upload-control input{display:none}.server-file-upload-control:hover,.server-file-upload-control:focus-within{border-color:var(--accent);color:var(--ink);box-shadow:inset 0 1px 0 var(--crystal-rim),0 0 0 2px var(--accent-soft),0 12px 26px var(--glass-shadow)}
.server-file-upload-disabled{opacity:.55;cursor:not-allowed}
.server-file-layout{display:grid;grid-template-columns:minmax(0,1fr) minmax(280px,360px);gap:12px;align-items:start;min-width:0}
.server-file-table-wrap{max-height:560px}.server-file-table{min-width:860px}.server-file-table td:first-child,.server-file-table th:first-child{width:44px}.server-file-directory-row{background:color-mix(in srgb,var(--accent-soft) 54%,transparent)}
.server-file-name-button{display:inline-flex;align-items:center;gap:8px;color:var(--ink);font-weight:850}.server-file-name-button svg{color:var(--accent-deep)}
.server-file-editor{display:grid;gap:10px;padding:12px;border:1px solid var(--line);border-radius:8px;background:var(--glass-wash),var(--glass-tint),var(--surface);box-shadow:inset 0 1px 0 var(--crystal-rim),0 14px 30px var(--glass-shadow);min-width:0}
.server-file-editor-field{display:grid;gap:6px;color:var(--ink-soft);font-size:13px;font-weight:800}.server-file-editor-field textarea{min-height:320px;width:100%;border:1px solid var(--line-strong);border-radius:8px;padding:10px;background:var(--surface-solid);color:var(--ink);font:13px/1.45 var(--font-mono);resize:vertical}.server-file-editor-field textarea:focus{border-color:var(--accent);outline:2px solid var(--accent-soft)}
.server-file-editor-actions .primary-command{width:auto}
.server-card-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,420px),1fr));gap:16px} .server-card-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,420px),1fr));gap:16px}
.server-card{display:grid;gap:12px;padding:16px;border:1px solid var(--line);border-radius:8px;background:var(--frosted-surface),var(--glass-tint),var(--surface);backdrop-filter:blur(22px) saturate(1.28);text-align:left;transition:transform 120ms ease,border-color 120ms ease;box-shadow:var(--jelly-inset),inset 0 0 0 1px var(--diamond-line),0 18px 42px var(--glass-shadow),0 0 28px rgba(255,255,255,.2);position:relative;overflow:hidden;min-width:0} .server-card{display:grid;gap:12px;padding:16px;border:1px solid var(--line);border-radius:8px;background:var(--frosted-surface),var(--glass-tint),var(--surface);backdrop-filter:blur(22px) saturate(1.28);text-align:left;transition:transform 120ms ease,border-color 120ms ease;box-shadow:var(--jelly-inset),inset 0 0 0 1px var(--diamond-line),0 18px 42px var(--glass-shadow),0 0 28px rgba(255,255,255,.2);position:relative;overflow:hidden;min-width:0}
.server-card:focus-visible,.server-card:hover{border-color:var(--accent);outline:0;transform:translateY(-2px)} .server-card:focus-visible,.server-card:hover{border-color:var(--accent);outline:0;transform:translateY(-2px)}
@@ -709,6 +729,7 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))}
.section-tabs{overflow-x:auto;flex-wrap:nowrap;padding-bottom:4px} .section-tabs{overflow-x:auto;flex-wrap:nowrap;padding-bottom:4px}
.server-toolbar{align-items:stretch} .server-toolbar{align-items:stretch}
.server-toolbar input[type=search],.server-toolbar select{flex:1 1 100%;min-width:0} .server-toolbar input[type=search],.server-toolbar select{flex:1 1 100%;min-width:0}
.server-file-toolbar,.server-file-layout{grid-template-columns:1fr}.server-file-search,.server-file-actions,.server-file-pathbar{align-items:stretch;flex-wrap:wrap}.server-file-search input[type=search]{min-width:0}.server-file-actions{justify-content:stretch}.server-file-actions>*{flex:1 1 auto}
.plugin-control-row,.server-card-head,.server-detail-title-row{grid-template-columns:1fr;align-items:stretch} .plugin-control-row,.server-card-head,.server-detail-title-row{grid-template-columns:1fr;align-items:stretch}
.server-card-head,.server-detail-title-row{display:grid} .server-card-head,.server-detail-title-row{display:grid}
.server-detail-title-row .action-strip{align-items:stretch} .server-detail-title-row .action-strip{align-items:stretch}
+42
View File
@@ -0,0 +1,42 @@
import type { PlatformApiClient } from "../api/client";
import type { ServerFileDownloadResponse } from "../api/types";
export async function downloadServerFileResult(client: PlatformApiClient, result: ServerFileDownloadResponse): Promise<string> {
if (result.status !== "ready") {
return result.reason ?? "文件读取任务已派发,完成后可再次下载。";
}
if (result.content !== undefined) {
saveBlob(new Blob([result.content], { type: result.contentType ?? "text/plain;charset=utf-8" }), result.filename || "server-file.txt");
return `下载已开始:${result.filename || result.key}`;
}
if (!result.artifact) {
return "文件内容尚未可用。";
}
const chunks: ArrayBuffer[] = [];
let offset = 0;
const chunkSize = Math.max(1, result.artifact.chunkSizeBytes || 1024 * 1024);
while (offset < result.artifact.sizeBytes) {
const chunk = await client.readArtifactContent(result.artifact.artifactId, offset, Math.min(chunkSize, result.artifact.sizeBytes - offset));
chunks.push(chunk.payload);
offset += chunk.contentLength;
if (chunk.contentLength <= 0) break;
}
saveBlob(new Blob(chunks, { type: result.artifact.contentType || "application/octet-stream" }), result.filename || result.artifact.filename);
return `下载已开始:${result.filename || result.artifact.filename}`;
}
function saveBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = safeFilename(filename);
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function safeFilename(filename: string): string {
const cleaned = filename.replace(/[\\/:*?"<>|]+/g, "-").trim();
return cleaned || "server-file.txt";
}