536 lines
25 KiB
Go
536 lines
25 KiB
Go
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
|
|
serverFileDefaultDirectoryKey = "server-files"
|
|
serverFileDefaultLogDirectoryKey = "server-logs"
|
|
)
|
|
|
|
type serverFileContext struct {
|
|
User domain.User
|
|
Instance domain.ServerInstance
|
|
Plugin domain.GamePlugin
|
|
Directory domain.PluginLogicalDirectory
|
|
Workspace domain.PluginFileWorkspace
|
|
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.Workspace)
|
|
view := domain.ServerFileWorkspaceView{
|
|
ServerInstanceID: ctx.Instance.ID,
|
|
PluginID: ctx.Plugin.ID,
|
|
DefaultDirectoryKey: workspace.DefaultDirectoryKey,
|
|
Directories: workspace.Directories,
|
|
Files: workspace.Files,
|
|
ConfigFields: workspace.ConfigFields,
|
|
DeclaredOnly: serverFileWorkspaceIsPluginDeclared(ctx.Plugin.FileWorkspace),
|
|
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.Workspace, request.DirectoryKey), request.Query)
|
|
if state == "declared" {
|
|
reasonPrefix := "展示插件声明的逻辑文件"
|
|
if !serverFileWorkspaceIsPluginDeclared(ctx.Plugin.FileWorkspace) {
|
|
reasonPrefix = "展示通用服务器文件目录"
|
|
}
|
|
reason = reasonPrefix + ";点击刷新可请求 Run 返回实时目录。"
|
|
}
|
|
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.Workspace, 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.Workspace, 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
|
|
}
|
|
workspace := effectiveServerFileWorkspace(plugin)
|
|
directory := domain.PluginLogicalDirectory{}
|
|
if directoryKey != "" {
|
|
var found bool
|
|
for _, candidate := range workspace.Directories {
|
|
if candidate.Key == directoryKey {
|
|
directory = candidate
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found && len(workspace.Directories) > 0 {
|
|
return serverFileContext{}, validationError("directoryKey must reference an available logical directory")
|
|
}
|
|
}
|
|
return serverFileContext{User: user, Instance: instance, Plugin: plugin, Directory: directory, Workspace: workspace, Scope: svc.runtimeProfileScope(instance.ID)}, nil
|
|
}
|
|
|
|
func effectiveServerFileWorkspace(plugin domain.GamePlugin) domain.PluginFileWorkspace {
|
|
workspace := domain.CopyPluginFileWorkspace(plugin.FileWorkspace)
|
|
if len(workspace.Directories) == 0 && workspace.DefaultDirectoryKey == "" {
|
|
workspace.DefaultDirectoryKey = serverFileDefaultDirectoryKey
|
|
workspace.Directories = []domain.PluginLogicalDirectory{{Key: serverFileDefaultDirectoryKey, Label: "服务器文件", Scope: "config"}}
|
|
if plugin.Permissions.Logs || containsString(plugin.DeclaredPermissions, "server.logs.read") || len(plugin.RuntimeProfiles.LogSources) > 0 {
|
|
workspace.Directories = append(workspace.Directories, domain.PluginLogicalDirectory{Key: serverFileDefaultLogDirectoryKey, Label: "日志文件", Scope: "logs"})
|
|
}
|
|
}
|
|
if workspace.DefaultDirectoryKey == "" && len(workspace.Directories) > 0 {
|
|
workspace.DefaultDirectoryKey = workspace.Directories[0].Key
|
|
}
|
|
return workspace
|
|
}
|
|
|
|
func serverFileWorkspaceIsPluginDeclared(workspace domain.PluginFileWorkspace) bool {
|
|
return workspace.DefaultDirectoryKey != "" || len(workspace.Directories) > 0 || len(workspace.Files) > 0 || len(workspace.ConfigFields) > 0
|
|
}
|
|
|
|
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
|
|
}
|