Add server file manager workflow

This commit is contained in:
npc0-hue
2026-08-24 12:56:55 +08:00
parent dc9b0eaf2a
commit 33d3a13e74
20 changed files with 1961 additions and 22 deletions
+4
View File
@@ -311,6 +311,10 @@ func validateExecutionResultForJob(job domain.Job, result domain.RunJobResult) e
if result.ExecutionResult.Checksum == "" || result.ExecutionResult.Checksum != validator.BytesChecksum([]byte(job.ExecutionInput.Content)) {
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")
+24 -6
View File
@@ -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
}
+508
View File
@@ -0,0 +1,508 @@
package service
import (
"encoding/json"
"errors"
"fmt"
"path"
"sort"
"strconv"
"strings"
"time"
"browser.local/platform/domain"
"browser.local/platform/repo"
"browser.local/platform/validator"
)
const (
serverFileTransferChannel = "run-file-transfer"
serverFileMaxInlineEditBytes = 64 * 1024
)
type serverFileContext struct {
User domain.User
Instance domain.ServerInstance
Plugin domain.GamePlugin
Directory domain.PluginLogicalDirectory
Scope string
}
type runFileListEnvelope struct {
DirectoryKey string `json:"directoryKey"`
Path string `json:"path"`
Entries []runFileListEntry `json:"entries"`
}
type runFileListEntry struct {
Name string `json:"name"`
Kind string `json:"kind"`
DirectoryKey string `json:"directoryKey"`
RelativePath string `json:"relativePath"`
LogicalKey string `json:"logicalKey"`
Scope string `json:"scope"`
SizeBytes int64 `json:"sizeBytes"`
ModifiedAt string `json:"modifiedAt"`
Checksum string `json:"checksum"`
Editable bool `json:"editable"`
Downloadable bool `json:"downloadable"`
Remark string `json:"remark"`
}
func (svc *CoreService) GetServerFileWorkspaceForSession(sessionID string, serverInstanceID string) (domain.ServerFileWorkspaceView, error) {
ctx, err := svc.serverFileContextForSession(sessionID, serverInstanceID, "", false, false)
if err != nil {
return domain.ServerFileWorkspaceView{}, err
}
workspace := domain.CopyPluginFileWorkspace(ctx.Plugin.FileWorkspace)
if workspace.DefaultDirectoryKey == "" && len(workspace.Directories) > 0 {
workspace.DefaultDirectoryKey = workspace.Directories[0].Key
}
view := domain.ServerFileWorkspaceView{
ServerInstanceID: ctx.Instance.ID,
PluginID: ctx.Plugin.ID,
DefaultDirectoryKey: workspace.DefaultDirectoryKey,
Directories: workspace.Directories,
Files: workspace.Files,
ConfigFields: workspace.ConfigFields,
DeclaredOnly: true,
RuntimeWorkspaceScope: svc.runtimeProfileScope(ctx.Instance.ID),
Transfer: domain.ServerFileTransferPolicy{
Channel: serverFileTransferChannel,
UploadChunkSizeBytes: validator.MaxArtifactChunkBytes,
DownloadChunkSizeBytes: validator.MaxArtifactDownloadBytes,
MaxInlineEditBytes: serverFileMaxInlineEditBytes,
MaxBrowserUploadBytes: validator.MaxArtifactBytes,
Notes: []string{
"文件字节通过独立文件传输端点传递,不占用 control、jobs 或 logs 通道。",
"Web 只发送逻辑目录和相对路径;Run 在本机工作区内解析真实路径。",
},
},
}
return domain.CopyServerFileWorkspaceView(view), nil
}
func (svc *CoreService) ListServerFilesForSession(sessionID string, request domain.ServerFileListRequest) (domain.ServerFileListResult, error) {
request = normalizeServerFileListRequest(request)
if err := validator.ValidateServerFileListRequest(request); err != nil {
return domain.ServerFileListResult{}, err
}
ctx, err := svc.serverFileContextForSession(sessionID, request.ServerInstanceID, request.DirectoryKey, false, false)
if err != nil {
return domain.ServerFileListResult{}, err
}
latest, hasLatest, err := svc.latestFileListJob(ctx.Instance.ID, request.DirectoryKey, request.Path)
if err != nil {
return domain.ServerFileListResult{}, err
}
if hasLatest && latest.State == domain.JobStateSucceeded && latest.ExecutionResult.Kind == "file.list" {
entries, parseErr := serverFileEntriesFromRunList(latest.ExecutionResult.Content, request.DirectoryKey, request.Path)
if parseErr == nil {
return domain.CopyServerFileListResult(domain.ServerFileListResult{ServerInstanceID: ctx.Instance.ID, PluginID: ctx.Plugin.ID, DirectoryKey: request.DirectoryKey, Path: request.Path, State: "ready", Entries: filterServerFileEntries(entries, request.Query), Job: latest, RefreshedAt: latest.TerminalAt}), nil
}
}
state := "declared"
reason := "展示插件声明的逻辑文件;点击刷新可请求 Run 返回实时目录。"
if hasLatest && !isTerminalJobState(latest.State) {
state = "pending"
reason = "Run 正在刷新目录。"
}
entries := filterServerFileEntries(serverFileEntriesFromDeclaredWorkspace(ctx.Plugin.FileWorkspace, request.DirectoryKey), request.Query)
return domain.CopyServerFileListResult(domain.ServerFileListResult{ServerInstanceID: ctx.Instance.ID, PluginID: ctx.Plugin.ID, DirectoryKey: request.DirectoryKey, Path: request.Path, State: state, Entries: entries, Job: latest, RefreshedAt: latest.TerminalAt, Reason: reason}), nil
}
func (svc *CoreService) RefreshServerFileListForSession(sessionID string, request domain.ServerFileListRequest) (domain.ServerFileListResult, error) {
request = normalizeServerFileListRequest(request)
if request.IdempotencyKey == "" {
request.IdempotencyKey = fmt.Sprintf("file-list:%s:%s:%s", request.ServerInstanceID, request.DirectoryKey, request.Path)
}
if err := validator.ValidateServerFileListRequest(request); err != nil {
return domain.ServerFileListResult{}, err
}
ctx, err := svc.serverFileContextForSession(sessionID, request.ServerInstanceID, request.DirectoryKey, false, true)
if err != nil {
return domain.ServerFileListResult{}, err
}
job, err := svc.CreateJob(domain.Job{
ID: jobIDFromParts("job-file-list", request.ServerInstanceID, request.IdempotencyKey),
ServerInstanceID: ctx.Instance.ID,
RunEndpointID: ctx.Instance.RunEndpointID,
Capability: domain.JobCapabilityFilesList,
TargetKey: request.DirectoryKey,
InputRef: "",
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: svc.runtimeProfileScope(ctx.Instance.ID), Inputs: map[string]string{
"directoryKey": request.DirectoryKey,
"path": request.Path,
"recursive": strconv.FormatBool(request.Recursive),
"query": request.Query,
}, MaxReadBytes: serverFileMaxInlineEditBytes},
IdempotencyKey: request.IdempotencyKey,
Progress: domain.JobProgress{Percent: 0, Phase: "queued", Message: "file list queued"},
})
if err != nil {
return domain.ServerFileListResult{}, err
}
entries := filterServerFileEntries(serverFileEntriesFromDeclaredWorkspace(ctx.Plugin.FileWorkspace, request.DirectoryKey), request.Query)
return domain.CopyServerFileListResult(domain.ServerFileListResult{ServerInstanceID: ctx.Instance.ID, PluginID: ctx.Plugin.ID, DirectoryKey: request.DirectoryKey, Path: request.Path, State: "pending", Entries: entries, Job: job, Reason: "目录刷新任务已派发到 Run。"}), nil
}
func (svc *CoreService) ReadServerFileForSession(sessionID string, request domain.ServerFileReadRequest) (domain.FileOperationDispatchResult, error) {
if err := validator.ValidateServerFileReadRequest(request); err != nil {
return domain.FileOperationDispatchResult{}, err
}
return svc.DispatchFileOperationForSession(sessionID, domain.FileOperationDispatchRequest{ServerInstanceID: request.ServerInstanceID, PluginID: request.PluginID, Operation: domain.FileOperationRead, Key: request.Key, IdempotencyKey: request.IdempotencyKey})
}
func (svc *CoreService) WriteServerFileForSession(sessionID string, request domain.ServerFileWriteRequest) (domain.FileOperationDispatchResult, error) {
if request.InputRef == "" {
request.InputRef = fileManagerInlineInputRef(request.ServerInstanceID, request.Key, request.IdempotencyKey)
}
if err := validator.ValidateServerFileWriteRequest(request); err != nil {
return domain.FileOperationDispatchResult{}, err
}
return svc.DispatchFileOperationForSession(sessionID, domain.FileOperationDispatchRequest{ServerInstanceID: request.ServerInstanceID, PluginID: request.PluginID, Operation: domain.FileOperationWrite, Key: request.Key, InputRef: request.InputRef, Content: request.Content, ExpectedConfigVersion: request.ExpectedVersion, ExpectedChecksum: request.ExpectedChecksum, IdempotencyKey: request.IdempotencyKey})
}
func (svc *CoreService) UploadServerFileForSession(sessionID string, request domain.ServerFileUploadRequest) (domain.ServerFileUploadDispatch, error) {
request = domain.CopyServerFileUploadRequest(request)
if err := validator.ValidateServerFileUploadRequest(request); err != nil {
return domain.ServerFileUploadDispatch{}, err
}
ctx, err := svc.serverFileContextForSession(sessionID, request.ServerInstanceID, request.DirectoryKey, true, true)
if err != nil {
return domain.ServerFileUploadDispatch{}, err
}
if strings.EqualFold(ctx.Directory.Scope, "logs") {
return domain.ServerFileUploadDispatch{}, validationError("log directories are read-only")
}
relativePath := cleanServerFileRelativePath(path.Join(request.RelativePath, request.Filename))
artifactID := serverFileUploadArtifactID(request.ServerInstanceID, request.IdempotencyKey, relativePath)
artifact := domain.Artifact{ID: artifactID, OwnerKind: domain.ArtifactOwnerKindServerInstance, OwnerID: ctx.Instance.ID, SizeBytes: int64(len(request.Payload)), Checksum: request.Checksum, State: domain.ArtifactStateAvailable, CreatedAt: svc.now(), UpdatedAt: svc.now()}
if err := svc.putBrowserFileArtifact(artifact, request.Payload); err != nil {
return domain.ServerFileUploadDispatch{}, err
}
inputRef := "artifact://" + artifact.ID
targetKey := cleanServerFileRelativePath(path.Join(request.DirectoryKey, relativePath))
job, err := svc.CreateJob(domain.Job{
ID: jobIDFromParts("job-file-upload", request.ServerInstanceID, request.IdempotencyKey),
ServerInstanceID: ctx.Instance.ID,
RunEndpointID: ctx.Instance.RunEndpointID,
Capability: domain.JobCapabilityFilesWrite,
TargetKey: targetKey,
InputRef: inputRef,
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: svc.runtimeProfileScope(ctx.Instance.ID), ExpectedChecksum: request.Checksum, Inputs: map[string]string{
"directoryKey": request.DirectoryKey,
"relativePath": relativePath,
"filename": request.Filename,
"transfer": serverFileTransferChannel,
}},
IdempotencyKey: request.IdempotencyKey,
Progress: domain.JobProgress{Percent: 0, Phase: "queued", Message: "file upload staged; Run will pull input chunks"},
})
if err != nil {
return domain.ServerFileUploadDispatch{}, err
}
return domain.CopyServerFileUploadDispatch(domain.ServerFileUploadDispatch{Status: "queued", ServerInstanceID: ctx.Instance.ID, DirectoryKey: request.DirectoryKey, RelativePath: relativePath, ArtifactID: artifact.ID, InputRef: inputRef, SizeBytes: artifact.SizeBytes, Checksum: artifact.Checksum, Job: job}), nil
}
func (svc *CoreService) PrepareServerFileDownloadForSession(sessionID string, request domain.ServerFileDownloadRequest) (domain.ServerFileDownloadResult, error) {
if err := validator.ValidateServerFileDownloadRequest(request); err != nil {
return domain.ServerFileDownloadResult{}, err
}
ctx, err := svc.serverFileContextForSession(sessionID, request.ServerInstanceID, "", false, true)
if err != nil {
return domain.ServerFileDownloadResult{}, err
}
filename := serverFileDisplayName(ctx.Plugin.FileWorkspace, request.Key)
job, hasJob, err := svc.latestFileReadJob(ctx.Instance.ID, request.Key)
if err != nil {
return domain.ServerFileDownloadResult{}, err
}
if hasJob && job.State == domain.JobStateSucceeded && job.ExecutionResult.Kind == "file.read" {
if strings.HasPrefix(job.ResultRef, "artifact://") {
reference, openErr := svc.OpenArtifactDownloadForSession(sessionID, domain.ArtifactDownloadReferenceRequest{ArtifactID: strings.TrimPrefix(job.ResultRef, "artifact://")})
if openErr != nil {
return domain.ServerFileDownloadResult{}, openErr
}
return domain.CopyServerFileDownloadResult(domain.ServerFileDownloadResult{Status: "ready", ServerInstanceID: ctx.Instance.ID, Key: request.Key, Filename: filename, ContentType: reference.ContentType, Checksum: reference.Checksum, SizeBytes: reference.SizeBytes, Artifact: &reference, Job: job, ReadAt: job.TerminalAt}), nil
}
if job.ExecutionResult.Content != "" {
content := redactDeclaredFileReadContent(job.ExecutionResult.Content)
return domain.CopyServerFileDownloadResult(domain.ServerFileDownloadResult{Status: "ready", ServerInstanceID: ctx.Instance.ID, Key: request.Key, Filename: filename, ContentType: "text/plain; charset=utf-8", Content: content, Checksum: job.ExecutionResult.Checksum, SizeBytes: int64(len([]byte(content))), Job: job, ReadAt: job.TerminalAt}), nil
}
}
if hasJob && !isTerminalJobState(job.State) {
return domain.CopyServerFileDownloadResult(domain.ServerFileDownloadResult{Status: "pending", ServerInstanceID: ctx.Instance.ID, Key: request.Key, Filename: filename, Job: job, Reason: "文件读取任务仍在执行。"}), nil
}
dispatch, err := svc.ReadServerFileForSession(sessionID, domain.ServerFileReadRequest{ServerInstanceID: request.ServerInstanceID, PluginID: ctx.Plugin.ID, Key: request.Key, IdempotencyKey: request.IdempotencyKey})
if err != nil {
return domain.ServerFileDownloadResult{}, err
}
return domain.CopyServerFileDownloadResult(domain.ServerFileDownloadResult{Status: "pending", ServerInstanceID: ctx.Instance.ID, Key: request.Key, Filename: filename, Job: dispatch.Job, Reason: "文件读取任务已派发;完成后可再次下载。"}), nil
}
func (svc *CoreService) ReadRunFileInputChunk(request domain.RunFileInputChunkRequest) (domain.RunFileInputChunk, error) {
request = domain.CopyRunFileInputChunkRequest(request)
if err := validator.ValidateRunFileInputChunkRequest(request); err != nil {
return domain.RunFileInputChunk{}, err
}
session, err := svc.validatedRunSession(request.RunEndpointID, request.SessionToken)
if err != nil {
return domain.RunFileInputChunk{}, err
}
svc.jobMu.Lock()
job, err := svc.fencedJob(session, request.JobID, request.LeaseToken, request.Attempt)
if err != nil {
svc.jobMu.Unlock()
return domain.RunFileInputChunk{}, err
}
if job.Capability != domain.JobCapabilityFilesWrite || !strings.HasPrefix(job.InputRef, "artifact://") {
svc.jobMu.Unlock()
return domain.RunFileInputChunk{}, validationError("job does not reference a file input artifact")
}
if job.State != domain.JobStateAccepted && job.State != domain.JobStateRunning {
svc.jobMu.Unlock()
return domain.RunFileInputChunk{}, validationError("job is not active")
}
artifactID := strings.TrimPrefix(job.InputRef, "artifact://")
serverInstanceID := job.ServerInstanceID
svc.jobMu.Unlock()
artifact, err := svc.store.Artifacts().Get(artifactID)
if err != nil {
return domain.RunFileInputChunk{}, err
}
if artifact.OwnerKind != domain.ArtifactOwnerKindServerInstance || artifact.OwnerID != serverInstanceID || artifact.State != domain.ArtifactStateAvailable {
return domain.RunFileInputChunk{}, ErrForbidden
}
payload, err := svc.artifactPayload(artifact.ID)
if err != nil {
return domain.RunFileInputChunk{}, err
}
if int64(len(payload)) != artifact.SizeBytes || validator.BytesChecksum(payload) != artifact.Checksum {
return domain.RunFileInputChunk{}, validationError("file input artifact checksum mismatch")
}
if request.Offset >= artifact.SizeBytes {
return domain.RunFileInputChunk{}, validationError("offset must be inside artifact content")
}
length := request.Length
remaining := artifact.SizeBytes - request.Offset
if int64(length) > remaining {
length = int(remaining)
}
end := int(request.Offset) + length
chunk := domain.RunFileInputChunk{JobID: job.ID, ArtifactID: artifact.ID, Offset: request.Offset, TotalBytes: artifact.SizeBytes, Checksum: artifact.Checksum, Payload: payload[int(request.Offset):end], Complete: int64(end) == artifact.SizeBytes}
return domain.CopyRunFileInputChunk(chunk), nil
}
func (svc *CoreService) serverFileContextForSession(sessionID string, serverInstanceID string, directoryKey string, requireWrite bool, requireRuntime bool) (serverFileContext, error) {
instance, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID)
if err != nil {
return serverFileContext{}, err
}
user, err := svc.GetCurrentUser(sessionID)
if err != nil {
return serverFileContext{}, err
}
if requireRuntime {
if err := svc.requireCompleteRuntimeBindings(user.ID, instance.ID, "file.manager.denied"); err != nil {
return serverFileContext{}, err
}
}
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
return serverFileContext{}, err
}
if plugin.Status != domain.GamePluginStatusInstalled {
return serverFileContext{}, validationError("plugin must be installed")
}
if requireWrite {
if !containsString(plugin.DeclaredPermissions, "server.files.write") {
return serverFileContext{}, ErrForbidden
}
} else if !plugin.Permissions.Files && !containsString(plugin.DeclaredPermissions, "server.files.read") {
return serverFileContext{}, ErrForbidden
}
directory := domain.PluginLogicalDirectory{}
if directoryKey != "" {
var found bool
for _, candidate := range plugin.FileWorkspace.Directories {
if candidate.Key == directoryKey {
directory = candidate
found = true
break
}
}
if !found && len(plugin.FileWorkspace.Directories) > 0 {
return serverFileContext{}, validationError("directoryKey must reference a plugin-declared directory")
}
}
return serverFileContext{User: user, Instance: instance, Plugin: plugin, Directory: directory, Scope: svc.runtimeProfileScope(instance.ID)}, nil
}
func normalizeServerFileListRequest(request domain.ServerFileListRequest) domain.ServerFileListRequest {
request.DirectoryKey = strings.TrimSpace(request.DirectoryKey)
request.Path = cleanServerFileRelativePath(request.Path)
request.Query = strings.TrimSpace(request.Query)
return request
}
func serverFileEntriesFromDeclaredWorkspace(workspace domain.PluginFileWorkspace, directoryKey string) []domain.ServerFileEntry {
entries := make([]domain.ServerFileEntry, 0)
for _, directory := range workspace.Directories {
if directoryKey == "" || directory.Key == directoryKey {
continue
}
entries = append(entries, domain.ServerFileEntry{Name: directory.Label, Kind: domain.ServerFileEntryDirectory, DirectoryKey: directory.Key, RelativePath: "", Scope: directory.Scope, Editable: false, Downloadable: false, Remark: "插件声明目录"})
}
for _, file := range workspace.Files {
if directoryKey != "" && file.DirectoryKey != directoryKey {
continue
}
entries = append(entries, domain.ServerFileEntry{Name: file.Label, Kind: domain.ServerFileEntryFile, DirectoryKey: file.DirectoryKey, RelativePath: file.Key, LogicalKey: file.Key, Scope: file.Kind, Editable: file.Editable, Downloadable: true, Remark: fileRemark(file)})
}
sort.SliceStable(entries, func(i int, j int) bool {
if entries[i].Kind == entries[j].Kind {
return strings.ToLower(entries[i].Name) < strings.ToLower(entries[j].Name)
}
return entries[i].Kind == domain.ServerFileEntryDirectory
})
return entries
}
func serverFileEntriesFromRunList(content string, fallbackDirectoryKey string, fallbackPath string) ([]domain.ServerFileEntry, error) {
var envelope runFileListEnvelope
if err := json.Unmarshal([]byte(content), &envelope); err != nil {
return nil, err
}
entries := make([]domain.ServerFileEntry, 0, len(envelope.Entries))
for _, entry := range envelope.Entries {
kind := domain.ServerFileEntryFile
if entry.Kind == string(domain.ServerFileEntryDirectory) {
kind = domain.ServerFileEntryDirectory
}
modifiedAt := time.Time{}
if entry.ModifiedAt != "" {
modifiedAt, _ = time.Parse(time.RFC3339, entry.ModifiedAt)
}
directoryKey := entry.DirectoryKey
if directoryKey == "" {
directoryKey = fallbackDirectoryKey
}
relativePath := cleanServerFileRelativePath(entry.RelativePath)
if relativePath == "" {
relativePath = fallbackPath
}
entries = append(entries, domain.ServerFileEntry{Name: entry.Name, Kind: kind, DirectoryKey: directoryKey, RelativePath: relativePath, LogicalKey: entry.LogicalKey, Scope: entry.Scope, SizeBytes: entry.SizeBytes, ModifiedAt: modifiedAt, Checksum: entry.Checksum, Editable: entry.Editable, Downloadable: entry.Downloadable, Remark: entry.Remark})
}
return entries, nil
}
func filterServerFileEntries(entries []domain.ServerFileEntry, query string) []domain.ServerFileEntry {
query = strings.ToLower(strings.TrimSpace(query))
if query == "" {
return domain.CopyServerFileEntries(entries)
}
filtered := make([]domain.ServerFileEntry, 0, len(entries))
for _, entry := range entries {
if strings.Contains(strings.ToLower(entry.Name+" "+entry.RelativePath+" "+entry.LogicalKey+" "+entry.Remark), query) {
filtered = append(filtered, entry)
}
}
return filtered
}
func (svc *CoreService) latestFileListJob(serverInstanceID string, directoryKey string, relativePath string) (domain.Job, bool, error) {
return svc.latestServerFileJob(serverInstanceID, domain.JobCapabilityFilesList, directoryKey)
}
func (svc *CoreService) latestFileReadJob(serverInstanceID string, key string) (domain.Job, bool, error) {
return svc.latestServerFileJob(serverInstanceID, domain.JobCapabilityFilesRead, key)
}
func (svc *CoreService) latestServerFileJob(serverInstanceID string, capability string, targetKey string) (domain.Job, bool, error) {
jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: serverInstanceID})
if err != nil {
return domain.Job{}, false, err
}
var latest domain.Job
found := false
for _, job := range jobs {
if job.Capability != capability || job.TargetKey != targetKey {
continue
}
if !found || job.UpdatedAt.After(latest.UpdatedAt) || job.CreatedAt.After(latest.CreatedAt) {
latest = job
found = true
}
}
return domain.CopyJob(latest), found, nil
}
func (svc *CoreService) putBrowserFileArtifact(artifact domain.Artifact, payload []byte) error {
svc.artifactMu.Lock()
defer svc.artifactMu.Unlock()
if existing, err := svc.store.Artifacts().Get(artifact.ID); err == nil {
if existing.OwnerKind != artifact.OwnerKind || existing.OwnerID != artifact.OwnerID || existing.SizeBytes != artifact.SizeBytes || existing.Checksum != artifact.Checksum || existing.State != domain.ArtifactStateAvailable {
return validationError("file upload idempotency key conflicts with existing artifact")
}
} else if !errors.Is(err, repo.ErrNotFound) {
return err
} else {
if err := validator.ValidateArtifact(artifact); err != nil {
return err
}
if err := svc.store.Artifacts().Create(artifact); err != nil {
return err
}
}
if err := svc.artifactStore.PutPayload(artifact.ID, payload); err != nil {
return err
}
svc.artifactPayloads[artifact.ID] = domain.CopyBytes(payload)
return nil
}
func cleanServerFileRelativePath(value string) string {
value = strings.TrimSpace(strings.ReplaceAll(value, "\\", "/"))
if value == "" || value == "." {
return ""
}
cleaned := path.Clean(value)
if cleaned == "." {
return ""
}
return strings.TrimPrefix(cleaned, "/")
}
func serverFileUploadArtifactID(serverInstanceID string, idempotencyKey string, relativePath string) string {
raw := serverInstanceID + ":" + idempotencyKey + ":" + relativePath
return "artifact-file-upload-" + sanitizeIDPart(serverInstanceID) + "-" + fmt.Sprint(stableStringNumber(raw))
}
func fileManagerInlineInputRef(serverInstanceID string, key string, idempotencyKey string) string {
return "input://server-files/" + sanitizeIDPart(serverInstanceID) + "/" + sanitizeIDPart(key) + "/" + fmt.Sprint(stableStringNumber(idempotencyKey))
}
func fileRemark(file domain.PluginLogicalFile) string {
if file.StreamKey != "" {
return "日志流 " + file.StreamKey
}
if file.Editable {
return "可编辑配置"
}
return "插件声明文件"
}
func serverFileDisplayName(workspace domain.PluginFileWorkspace, key string) string {
for _, file := range workspace.Files {
if file.Key == key && strings.TrimSpace(file.Label) != "" {
return file.Label
}
}
name := path.Base(key)
if name == "." || name == "/" || name == "" {
return "server-file.txt"
}
return name
}