Add server file manager workflow
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"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", h.serverDependencies)
|
||||
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}/administrators/candidates", h.serverAdministratorCandidates)
|
||||
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/update-input", h.requireRunSignature(h.runJobUpdateInput))
|
||||
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/client-manager-input", h.requireRunSignature(h.runClientManagerLifecycleInput))
|
||||
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))
|
||||
}
|
||||
|
||||
// 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
|
||||
// @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.
|
||||
@@ -1673,6 +1873,25 @@ func (h *coreHandlers) runJobUpdateChunk(w http.ResponseWriter, r *http.Request)
|
||||
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
|
||||
// @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.
|
||||
|
||||
@@ -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
|
||||
|
||||
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{
|
||||
ID: "server-metrics-api",
|
||||
PluginID: "server.scum",
|
||||
@@ -234,11 +236,20 @@ func TestConfigWriteAndFileDispatchAPIAreScopedAndSafe(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreAPIDeclaredFileReadSnapshotRouteIsScopedAndRedacted(t *testing.T) {
|
||||
func TestCoreAPIServerFileWorkspaceRoutesAreScoped(t *testing.T) {
|
||||
router := newTestRouter()
|
||||
adminSession := createAdminSession(t, router)
|
||||
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest())
|
||||
postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", validRunEndpointRequest())
|
||||
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)
|
||||
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{
|
||||
ID: "server-file-snapshot-api",
|
||||
PluginID: "server.scum",
|
||||
@@ -246,8 +257,33 @@ func TestCoreAPIDeclaredFileReadSnapshotRouteIsScopedAndRedacted(t *testing.T) {
|
||||
Name: "File Snapshot API Server",
|
||||
State: domain.ServerInstanceStateRunning,
|
||||
}, 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) {
|
||||
|
||||
@@ -4,7 +4,7 @@ Route declarations and handler comments live in `platform/api`. Request, respons
|
||||
|
||||
## 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 |
|
||||
| --- | --- | --- | --- |
|
||||
@@ -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` |
|
||||
| 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` |
|
||||
| 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` |
|
||||
| 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` |
|
||||
@@ -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.
|
||||
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
|
||||
|
||||
- `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.
|
||||
|
||||
@@ -1000,6 +1000,7 @@ type ServerConfigWriteDispatch struct {
|
||||
type FileOperationKind string
|
||||
|
||||
const (
|
||||
FileOperationList FileOperationKind = "list"
|
||||
FileOperationRead FileOperationKind = "read"
|
||||
FileOperationWrite FileOperationKind = "write"
|
||||
)
|
||||
@@ -1042,6 +1043,151 @@ type DeclaredFileReadSnapshot struct {
|
||||
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 {
|
||||
MaxJobs int
|
||||
RunningJobs int
|
||||
@@ -1054,6 +1200,7 @@ type RunCapacity struct {
|
||||
|
||||
const (
|
||||
JobCapabilityConfigWrite = "config.write"
|
||||
JobCapabilityFilesList = "files.list"
|
||||
JobCapabilityFilesRead = "files.read"
|
||||
JobCapabilityFilesWrite = "files.write"
|
||||
JobCapabilityRemoteFTPRead = "remote.ftp.read"
|
||||
@@ -1963,6 +2110,66 @@ func CopyFileOperationDispatchResult(result FileOperationDispatchResult) FileOpe
|
||||
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 {
|
||||
endpoint.Capabilities = CopyStringSlice(endpoint.Capabilities)
|
||||
endpoint.Capacity.PressureCodes = CopyStringSlice(endpoint.Capacity.PressureCodes)
|
||||
|
||||
@@ -310,6 +310,26 @@ type RunUpdateChunkResponse struct {
|
||||
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 {
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
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 {
|
||||
return domain.DistributionBuildInputRequest{
|
||||
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}
|
||||
}
|
||||
|
||||
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 {
|
||||
return RunUpdateHealthResponse{Accepted: result.Accepted, JobID: result.JobID, Phase: result.Phase, ServerTime: result.ServerTime}
|
||||
}
|
||||
|
||||
@@ -730,6 +730,110 @@ type DeclaredFileReadSnapshotResponse struct {
|
||||
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 {
|
||||
MaxJobs int `json:"maxJobs"`
|
||||
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 {
|
||||
return domain.RunEndpoint{
|
||||
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 {
|
||||
endpoint = domain.CopyRunEndpoint(endpoint)
|
||||
return RunEndpointResponse{
|
||||
|
||||
@@ -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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
## 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 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.
|
||||
|
||||
@@ -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)) {
|
||||
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:
|
||||
if result.ExecutionResult.Kind != "file.read" {
|
||||
return validationError("file read result type is invalid")
|
||||
|
||||
@@ -123,6 +123,13 @@ type Core interface {
|
||||
RequestRemoteAdapterForSession(string, domain.RemoteAdapterRequest) (domain.RemoteAdapterResult, error)
|
||||
GetServerConfigForSession(string, string) (domain.ServerConfig, 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)
|
||||
ApproveServerConfigWriteForSession(string, domain.ServerConfigWriteApproval) (domain.ServerConfigWriteDispatch, error)
|
||||
DispatchFileOperationForSession(string, domain.FileOperationDispatchRequest) (domain.FileOperationDispatchResult, error)
|
||||
@@ -143,6 +150,7 @@ type Core interface {
|
||||
GetSourceRCONExecutionInput(domain.SourceRCONExecutionInputRequest) (domain.SourceRCONExecutionInput, error)
|
||||
GetRunUpdateInput(domain.RunUpdateInputRequest) (domain.RunUpdateInput, error)
|
||||
ReadRunUpdateChunk(domain.RunUpdateChunkRequest) (domain.RunUpdateChunk, error)
|
||||
ReadRunFileInputChunk(domain.RunFileInputChunkRequest) (domain.RunFileInputChunk, error)
|
||||
ReportRunUpdateHealth(domain.RunUpdateHealthReport) (domain.RunUpdateHealthResult, error)
|
||||
RequestRunJobCancel(domain.RunJobCancelRequest) (domain.RunJobCancelRequestResult, 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 {
|
||||
return domain.FileOperationDispatchResult{}, ErrForbidden
|
||||
}
|
||||
payload, payloadErr := svc.artifactPayload(artifactID)
|
||||
if payloadErr != nil {
|
||||
return domain.FileOperationDispatchResult{}, payloadErr
|
||||
}
|
||||
content = string(payload)
|
||||
}
|
||||
if 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 {
|
||||
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
|
||||
}
|
||||
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
|
||||
message := "file read queued"
|
||||
if request.Operation == domain.FileOperationList {
|
||||
capability = domain.JobCapabilityFilesList
|
||||
message = "file list queued"
|
||||
}
|
||||
if request.Operation == domain.FileOperationWrite {
|
||||
capability = domain.JobCapabilityFilesWrite
|
||||
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) {
|
||||
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 {
|
||||
return domain.PluginLogicalFile{}, false, true
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1421,6 +1421,104 @@ func ValidateFileOperationDispatchRequest(request domain.FileOperationDispatchRe
|
||||
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 {
|
||||
if value < 0 || value > 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 {
|
||||
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 == "" {
|
||||
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 {
|
||||
switch operation {
|
||||
case domain.FileOperationRead, domain.FileOperationWrite:
|
||||
case domain.FileOperationList, domain.FileOperationRead, domain.FileOperationWrite:
|
||||
return true
|
||||
default:
|
||||
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 {
|
||||
switch key {
|
||||
case "server.properties", "config/server.properties":
|
||||
|
||||
@@ -144,6 +144,18 @@ const runtimeDownload: ArtifactDownloadReferenceResponse = {
|
||||
storageBehavior: "platform-memory-transfer-session"
|
||||
};
|
||||
|
||||
const serverFileWorkspace = {
|
||||
serverInstanceId: server.id,
|
||||
pluginId: plugin.id,
|
||||
defaultDirectoryKey: "configs",
|
||||
directories: [{ key: "configs", label: "配置", scope: "config" }],
|
||||
files: [{ key: "config/server.properties", directoryKey: "configs", label: "server.properties", kind: "config", editable: true }],
|
||||
configFields: [],
|
||||
transfer: { channel: "run-file-transfer", uploadChunkSizeBytes: 1048576, downloadChunkSizeBytes: 1048576, maxInlineEditBytes: 65536, maxBrowserUploadBytes: 52428800 },
|
||||
declaredOnly: true,
|
||||
runtimeWorkspaceScope: "server-runtime"
|
||||
};
|
||||
|
||||
describe("PlatformApiClient AI providers", () => {
|
||||
afterEach(() => {
|
||||
setPlatformApiSessionToken(null);
|
||||
@@ -259,6 +271,40 @@ describe("PlatformApiClient AI providers", () => {
|
||||
job: { ...job, id: "job-file-read", capability: "files.read", targetKey: "logs/latest.log" }
|
||||
});
|
||||
}
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/files/workspace") && (!init?.method || init.method === "GET")) {
|
||||
return jsonResponse(serverFileWorkspace);
|
||||
}
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/files/list?directoryKey=configs&query=server&recursive=true")) {
|
||||
return jsonResponse({ serverInstanceId: server.id, pluginId: plugin.id, directoryKey: "configs", state: "declared", entries: [{ name: "server.properties", kind: "file", directoryKey: "configs", relativePath: "config/server.properties", logicalKey: "config/server.properties", scope: "config", sizeBytes: 42, editable: true, downloadable: true, remark: "配置文件" }], reason: "declared" });
|
||||
}
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/files/refresh") && init?.method === "POST") {
|
||||
expect(JSON.parse(String(init.body))).toEqual({ directoryKey: "configs", query: "server", recursive: true, idempotencyKey: "idem-file-list" });
|
||||
return jsonResponse({ serverInstanceId: server.id, pluginId: plugin.id, directoryKey: "configs", state: "pending", entries: [], job: { ...job, id: "job-file-list", capability: "files.list", targetKey: "configs" }, reason: "queued" });
|
||||
}
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/files/read-snapshot?key=config%2Fserver.properties")) {
|
||||
return jsonResponse({ serverInstanceId: server.id, pluginId: plugin.id, key: "config/server.properties", state: "ready", content: "server.name=Example\n", version: 3, checksum: "sha256:filechecksum", sizeBytes: 20, readAt: "2026-07-03T00:00:00Z" });
|
||||
}
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/files/read") && init?.method === "POST") {
|
||||
expect(JSON.parse(String(init.body))).toEqual({ key: "config/server.properties", idempotencyKey: "idem-file-read" });
|
||||
return jsonResponse({ status: "queued", serverInstanceId: server.id, pluginId: plugin.id, operation: "read", key: "config/server.properties", job: { ...job, id: "job-server-file-read", capability: "files.read", targetKey: "config/server.properties" } });
|
||||
}
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/files/write") && init?.method === "POST") {
|
||||
expect(JSON.parse(String(init.body))).toEqual({ key: "config/server.properties", content: "server.name=Example\n", expectedVersion: 3, expectedChecksum: "sha256:filechecksum", idempotencyKey: "idem-file-write" });
|
||||
return jsonResponse({ status: "queued", serverInstanceId: server.id, pluginId: plugin.id, operation: "write", key: "config/server.properties", job: { ...job, id: "job-server-file-write", capability: "files.write", targetKey: "config/server.properties" } });
|
||||
}
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/files/upload") && init?.method === "POST") {
|
||||
const body = init.body as FormData;
|
||||
expect(body.get("directoryKey")).toBe("configs");
|
||||
expect(body.get("relativePath")).toBe("");
|
||||
expect(body.get("filename")).toBe("server.properties");
|
||||
expect(body.get("idempotencyKey")).toBe("idem-file-upload");
|
||||
expect(body.get("file")).toBeInstanceOf(File);
|
||||
return jsonResponse({ status: "queued", serverInstanceId: server.id, directoryKey: "configs", relativePath: "server.properties", artifactId: "artifact-upload-1", inputRef: "artifact://artifact-upload-1", sizeBytes: 20, checksum: "sha256:uploadchecksum", job: { ...job, id: "job-server-file-upload", capability: "files.write", targetKey: "configs/server.properties" } });
|
||||
}
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/files/download") && init?.method === "POST") {
|
||||
expect(JSON.parse(String(init.body))).toEqual({ key: "config/server.properties", idempotencyKey: "idem-file-download" });
|
||||
return jsonResponse({ status: "ready", serverInstanceId: server.id, key: "config/server.properties", filename: "server.properties", contentType: "text/plain; charset=utf-8", content: "server.name=Example\n", checksum: "sha256:filechecksum", sizeBytes: 20, readAt: "2026-07-03T00:00:00Z" });
|
||||
}
|
||||
if (url.endsWith("/api/v1/run/endpoints")) {
|
||||
return jsonResponse({ items: [endpoint], count: 1 });
|
||||
}
|
||||
@@ -546,6 +592,14 @@ describe("PlatformApiClient AI providers", () => {
|
||||
status: "queued",
|
||||
job: { capability: "files.read", targetKey: "logs/latest.log" }
|
||||
});
|
||||
await expect(client.getServerFileWorkspace(server.id)).resolves.toMatchObject({ defaultDirectoryKey: "configs", transfer: { channel: "run-file-transfer" } });
|
||||
await expect(client.listServerFiles(server.id, { directoryKey: "configs", query: "server", recursive: true })).resolves.toMatchObject({ state: "declared", entries: [{ logicalKey: "config/server.properties" }] });
|
||||
await expect(client.refreshServerFiles(server.id, { directoryKey: "configs", query: "server", recursive: true, idempotencyKey: "idem-file-list" })).resolves.toMatchObject({ state: "pending", job: { capability: "files.list" } });
|
||||
await expect(client.getServerFileReadSnapshot(server.id, "config/server.properties")).resolves.toMatchObject({ state: "ready", content: "server.name=Example\n" });
|
||||
await expect(client.readServerFile(server.id, { key: "config/server.properties", idempotencyKey: "idem-file-read" })).resolves.toMatchObject({ operation: "read", job: { capability: "files.read" } });
|
||||
await expect(client.writeServerFile(server.id, { key: "config/server.properties", content: "server.name=Example\n", expectedVersion: 3, expectedChecksum: "sha256:filechecksum", idempotencyKey: "idem-file-write" })).resolves.toMatchObject({ operation: "write", job: { capability: "files.write" } });
|
||||
await expect(client.uploadServerFile(server.id, { directoryKey: "configs", file: new File(["server.name=Example\n"], "server.properties", { type: "text/plain" }), idempotencyKey: "idem-file-upload" })).resolves.toMatchObject({ inputRef: "artifact://artifact-upload-1", job: { capability: "files.write" } });
|
||||
await expect(client.prepareServerFileDownload(server.id, { key: "config/server.properties", idempotencyKey: "idem-file-download" })).resolves.toMatchObject({ status: "ready", filename: "server.properties", content: "server.name=Example\n" });
|
||||
await expect(client.listRunEndpoints()).resolves.toMatchObject({ count: 1 });
|
||||
await expect(client.listJobs()).resolves.toMatchObject({ count: 1 });
|
||||
await expect(client.listJobs(server.id)).resolves.toMatchObject({ count: 1 });
|
||||
@@ -600,7 +654,7 @@ describe("PlatformApiClient AI providers", () => {
|
||||
client.invokeAI({ requestId: "ai-1", serverInstanceId: server.id, purpose: "config.suggest", prompt: "Tune PVP safely", currentConfig: "server.name=Example Survival #1\n" })
|
||||
).resolves.toMatchObject({ status: "ok", usage: { mocked: true }, configRecommendation: { diffSummary: "review required" } });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(36);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(44);
|
||||
});
|
||||
|
||||
it("calls plugin marketplace endpoints with filter and state contracts", async () => {
|
||||
|
||||
@@ -32,6 +32,7 @@ import type {
|
||||
CurrentUserResponse,
|
||||
DependencyCatalogResponse,
|
||||
DependencyJobRequest,
|
||||
DeclaredFileReadSnapshotResponse,
|
||||
FileOperationDispatchRequest,
|
||||
FileOperationDispatchResponse,
|
||||
GameClientBridgeCancelRequest,
|
||||
@@ -84,6 +85,14 @@ import type {
|
||||
ServerDeploymentResponse,
|
||||
ServerInstanceListResponse,
|
||||
ServerDeletionRequest,
|
||||
ServerFileDownloadRequest,
|
||||
ServerFileDownloadResponse,
|
||||
ServerFileListRequest,
|
||||
ServerFileListResponse,
|
||||
ServerFileReadRequest,
|
||||
ServerFileUploadResponse,
|
||||
ServerFileWorkspaceResponse,
|
||||
ServerFileWriteRequest,
|
||||
ServerInstanceUpdateRequest,
|
||||
ServerInstanceResponse,
|
||||
ServerMemberListResponse,
|
||||
@@ -575,6 +584,51 @@ export class PlatformApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async getServerFileWorkspace(serverInstanceId: string): Promise<ServerFileWorkspaceResponse> {
|
||||
return this.request<ServerFileWorkspaceResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/files/workspace`);
|
||||
}
|
||||
|
||||
async listServerFiles(serverInstanceId: string, request: Partial<ServerFileListRequest> = {}): Promise<ServerFileListResponse> {
|
||||
const params = serverFileListQuery(request);
|
||||
return this.request<ServerFileListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/files/list${params}`);
|
||||
}
|
||||
|
||||
async refreshServerFiles(serverInstanceId: string, request: ServerFileListRequest): Promise<ServerFileListResponse> {
|
||||
return this.request<ServerFileListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/files/refresh`, { method: "POST", body: request });
|
||||
}
|
||||
|
||||
async readServerFile(serverInstanceId: string, request: ServerFileReadRequest): Promise<FileOperationDispatchResponse> {
|
||||
return this.request<FileOperationDispatchResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/files/read`, { method: "POST", body: request });
|
||||
}
|
||||
|
||||
async getServerFileReadSnapshot(serverInstanceId: string, key: string): Promise<DeclaredFileReadSnapshotResponse> {
|
||||
const params = new URLSearchParams({ key });
|
||||
return this.request<DeclaredFileReadSnapshotResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/files/read-snapshot?${params.toString()}`);
|
||||
}
|
||||
|
||||
async writeServerFile(serverInstanceId: string, request: ServerFileWriteRequest): Promise<FileOperationDispatchResponse> {
|
||||
return this.request<FileOperationDispatchResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/files/write`, { method: "POST", body: request });
|
||||
}
|
||||
|
||||
async uploadServerFile(serverInstanceId: string, request: { directoryKey: string; relativePath?: string; file: File; idempotencyKey: string }): Promise<ServerFileUploadResponse> {
|
||||
const body = new FormData();
|
||||
body.set("directoryKey", request.directoryKey);
|
||||
body.set("relativePath", request.relativePath ?? "");
|
||||
body.set("filename", request.file.name);
|
||||
body.set("idempotencyKey", request.idempotencyKey);
|
||||
body.set("file", request.file);
|
||||
const headers = new Headers();
|
||||
const sessionToken = this.sessionTokenProvider();
|
||||
if (sessionToken) headers.set("Authorization", `Bearer ${sessionToken}`);
|
||||
const response = await fetch(`${this.baseUrl}/server-instances/${encodeURIComponent(serverInstanceId)}/files/upload`, { method: "POST", credentials: "same-origin", headers, body });
|
||||
if (!response.ok) throw await responseError(response);
|
||||
return response.json() as Promise<ServerFileUploadResponse>;
|
||||
}
|
||||
|
||||
async prepareServerFileDownload(serverInstanceId: string, request: ServerFileDownloadRequest): Promise<ServerFileDownloadResponse> {
|
||||
return this.request<ServerFileDownloadResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/files/download`, { method: "POST", body: request });
|
||||
}
|
||||
|
||||
async listLogStreams(serverInstanceId?: string): Promise<LogStreamListResponse> {
|
||||
const query = serverInstanceId ? `?serverInstanceId=${encodeURIComponent(serverInstanceId)}` : "";
|
||||
return this.request<LogStreamListResponse>(`/log-streams${query}`);
|
||||
@@ -900,4 +954,14 @@ function artifactQuery(filter: ArtifactFilterRequest): string {
|
||||
return query ? `?${query}` : "";
|
||||
}
|
||||
|
||||
function serverFileListQuery(request: Partial<ServerFileListRequest>): string {
|
||||
const params = new URLSearchParams();
|
||||
if (request.directoryKey) params.set("directoryKey", request.directoryKey);
|
||||
if (request.path) params.set("path", request.path);
|
||||
if (request.query) params.set("query", request.query);
|
||||
if (request.recursive) params.set("recursive", "true");
|
||||
const query = params.toString();
|
||||
return query ? `?${query}` : "";
|
||||
}
|
||||
|
||||
export const platformApiClient = new PlatformApiClient(readWebRuntimeEnv().platformApiBaseUrl);
|
||||
|
||||
@@ -27,7 +27,8 @@ Normal browser login uses the platform's HttpOnly SameSite cookie and `credentia
|
||||
- `startServerInstance` and `stopServerInstance` post `ServerLifecycleCommandRequest` with the current config version and receive the lifecycle job response.
|
||||
- `listServerAdministratorCandidates`, `addServerAdministrator`, and `removeServerAdministrator` call server membership endpoints so server owners can invite or remove active non-platform-admin server administrators.
|
||||
- Game-specific pages use the scoped `plugin-data` collection API and declared plugin bridge machine actions; Platform does not expose game-specific projection or workflow clients.
|
||||
- `dispatchFileOperation` posts `FileOperationDispatchRequest` to `/file-operations/dispatch` using logical file keys and scoped refs rather than raw host paths; it is not wired into SCUM server-detail/plugin pages as a raw file workbench.
|
||||
- `dispatchFileOperation` posts `FileOperationDispatchRequest` to `/file-operations/dispatch` using logical file keys and scoped refs rather than raw host paths; it remains the low-level compatibility dispatch for plugin-declared file work.
|
||||
- `getServerFileWorkspace`, `listServerFiles`, `refreshServerFiles`, `readServerFile`, `getServerFileReadSnapshot`, `writeServerFile`, `uploadServerFile`, and `prepareServerFileDownload` power the first-party server-detail file manager. The page renders plugin-declared logical directories, requests live listings through `files.list`, reads editable snapshots through `files.read`, saves through `files.write`, and stages browser uploads as server-instance artifacts before Run pulls input chunks on the dedicated file-transfer channel. Server detail may call only these server-file APIs plus the encapsulated download helper; it must not call raw artifact-transfer methods directly.
|
||||
- `listArtifacts`, `openArtifactDownload`, and `readArtifactContent` use platform artifact routes for available job/server artifacts. Browser reads are chunked through `/artifacts/{id}/content` and must render only safe filenames, checksums, progress, and platform storage behavior.
|
||||
- `authorizePluginBridge` posts `PluginBridgeAuthorizeRequest` to `/plugin-bridge/authorize` for preflight decisions.
|
||||
- `executePluginBridge` posts `PluginBridgeExecuteRequest` to `/plugin-bridge/execute` from host-owned bridge dispatch utilities only. Plugin pages receive typed `PluginBridgeExecuteResponse` envelopes and never receive the platform API client, bearer token, raw provider key, run socket, host path, or storage credential.
|
||||
|
||||
+107
-1
@@ -1421,7 +1421,7 @@ export interface ServerConfigWriteDispatchResponse {
|
||||
job: JobResponse;
|
||||
}
|
||||
|
||||
export type FileOperationKind = "read" | "write";
|
||||
export type FileOperationKind = "list" | "read" | "write";
|
||||
|
||||
export interface FileOperationDispatchRequest {
|
||||
serverInstanceId: string;
|
||||
@@ -1459,6 +1459,112 @@ export interface DeclaredFileReadSnapshotResponse {
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface ServerFileTransferPolicyResponse {
|
||||
channel: string;
|
||||
uploadChunkSizeBytes: number;
|
||||
downloadChunkSizeBytes: number;
|
||||
maxInlineEditBytes: number;
|
||||
maxBrowserUploadBytes: number;
|
||||
notes?: string[];
|
||||
}
|
||||
|
||||
export interface ServerFileWorkspaceResponse {
|
||||
serverInstanceId: string;
|
||||
pluginId: string;
|
||||
defaultDirectoryKey: string;
|
||||
directories: PluginLogicalDirectoryResponse[];
|
||||
files: PluginLogicalFileResponse[];
|
||||
configFields: PluginConfigFieldResponse[];
|
||||
transfer: ServerFileTransferPolicyResponse;
|
||||
declaredOnly: boolean;
|
||||
runtimeWorkspaceScope?: string;
|
||||
}
|
||||
|
||||
export type ServerFileEntryKind = "directory" | "file";
|
||||
|
||||
export interface ServerFileEntryResponse {
|
||||
name: string;
|
||||
kind: ServerFileEntryKind;
|
||||
directoryKey: string;
|
||||
relativePath?: string;
|
||||
logicalKey?: string;
|
||||
scope?: string;
|
||||
sizeBytes?: number;
|
||||
modifiedAt?: string;
|
||||
checksum?: string;
|
||||
editable: boolean;
|
||||
downloadable: boolean;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export interface ServerFileListRequest {
|
||||
directoryKey: string;
|
||||
path?: string;
|
||||
query?: string;
|
||||
recursive?: boolean;
|
||||
idempotencyKey?: string;
|
||||
}
|
||||
|
||||
export interface ServerFileListResponse {
|
||||
serverInstanceId: string;
|
||||
pluginId: string;
|
||||
directoryKey: string;
|
||||
path?: string;
|
||||
state: "ready" | "pending" | "declared" | string;
|
||||
entries: ServerFileEntryResponse[];
|
||||
job?: JobResponse;
|
||||
refreshedAt?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface ServerFileReadRequest {
|
||||
pluginId?: string;
|
||||
key: string;
|
||||
idempotencyKey: string;
|
||||
}
|
||||
|
||||
export interface ServerFileWriteRequest {
|
||||
pluginId?: string;
|
||||
key: string;
|
||||
content?: string;
|
||||
inputRef?: string;
|
||||
expectedVersion?: number;
|
||||
expectedChecksum?: string;
|
||||
idempotencyKey: string;
|
||||
}
|
||||
|
||||
export interface ServerFileUploadResponse {
|
||||
status: string;
|
||||
serverInstanceId: string;
|
||||
directoryKey: string;
|
||||
relativePath: string;
|
||||
artifactId: string;
|
||||
inputRef: string;
|
||||
sizeBytes: number;
|
||||
checksum: string;
|
||||
job: JobResponse;
|
||||
}
|
||||
|
||||
export interface ServerFileDownloadRequest {
|
||||
key: string;
|
||||
idempotencyKey: string;
|
||||
}
|
||||
|
||||
export interface ServerFileDownloadResponse {
|
||||
status: "ready" | "pending" | string;
|
||||
serverInstanceId: string;
|
||||
key: string;
|
||||
filename: string;
|
||||
contentType?: string;
|
||||
content?: string;
|
||||
checksum?: string;
|
||||
sizeBytes?: number;
|
||||
artifact?: ArtifactDownloadReferenceResponse;
|
||||
job?: JobResponse;
|
||||
readAt?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface LogStreamResponse {
|
||||
id: string;
|
||||
serverInstanceId: string;
|
||||
|
||||
@@ -144,10 +144,11 @@ export interface PlatformOverviewSignal {
|
||||
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 }> = [
|
||||
{ id: "manage", label: "管理" },
|
||||
{ id: "files", label: "文件" },
|
||||
{ id: "llm", label: "AI 助手" }
|
||||
];
|
||||
|
||||
|
||||
@@ -64,6 +64,9 @@ describe("ServerDetailPage config write approval", () => {
|
||||
});
|
||||
|
||||
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("readArtifactContent");
|
||||
expect(serverDetailPageSource).not.toContain("浏览器制品传输");
|
||||
@@ -130,6 +133,7 @@ describe("ServerDetailPage config write approval", () => {
|
||||
it("routes plugin-declared pages into server detail tabs", () => {
|
||||
expect(serverDetailPageSource).toContain("serverDetailSectionEntries(readyPlugin)");
|
||||
expect(serverDetailPageSource).toContain("plugin:${page.key}");
|
||||
expect(serverDetailPageSource).toContain('section === "files"');
|
||||
expect(serverDetailPageSource).toContain("PluginPageSection");
|
||||
expect(serverDetailPageSource).toContain("PluginPageHostPage");
|
||||
expect(serverDetailPageSource).not.toContain("ScumFileManagementSection");
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { MoonStar, PackageOpen, Pencil, ShieldCheck, Sparkles, Square, Terminal, UserRoundMinus, UserRoundPlus, WandSparkles } from "lucide-react";
|
||||
import { type FormEvent, useCallback, useEffect, useMemo, useState } from "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 ChangeEvent, type FormEvent, useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { platformApiClient } from "../api/client";
|
||||
import type {
|
||||
ConfigDiffLineResponse,
|
||||
DeclaredFileReadSnapshotResponse,
|
||||
GamePluginResponse,
|
||||
JobResponse,
|
||||
ServerInstanceResponse,
|
||||
@@ -11,7 +12,10 @@ import type {
|
||||
ServerMetricsResponse,
|
||||
ServerDeploymentResponse,
|
||||
ServerConfigDiffPreviewResponse,
|
||||
RunEndpointResponse
|
||||
RunEndpointResponse,
|
||||
ServerFileEntryResponse,
|
||||
ServerFileListResponse,
|
||||
ServerFileWorkspaceResponse
|
||||
} from "../api/types";
|
||||
import { ConfirmDialog, UsageMeter } from "../components/OperationControls";
|
||||
import { ServerManagementTerminalDrawer } from "../components/ServerManagementTerminalDrawer";
|
||||
@@ -30,6 +34,7 @@ import {
|
||||
serverMetadataUpdateRequestFromForm
|
||||
} from "../schemas/serverManagement";
|
||||
import { cx } from "../utils/classes";
|
||||
import { downloadServerFileResult } from "../utils/serverFileTransfer";
|
||||
import { stateLabel, statusClass } from "./ServersPage";
|
||||
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 === "files" && <ServerFilesSection 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)} />
|
||||
</>
|
||||
@@ -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 }) {
|
||||
return (
|
||||
<span className="server-card-stat">
|
||||
|
||||
@@ -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: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{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)}
|
||||
@@ -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}
|
||||
.server-toolbar{align-items:stretch}
|
||||
.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}
|
||||
.server-card-head,.server-detail-title-row{display:grid}
|
||||
.server-detail-title-row .action-strip{align-items:stretch}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
Reference in New Issue
Block a user