Files
browser/platform/service/server_files.go
T

668 lines
30 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"context"
"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-root"
serverFileBrowseWait = 4 * time.Second
)
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)
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: false,
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)
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
}
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: "failed", Entries: entries, Job: latest, RefreshedAt: latest.TerminalAt, Reason: "Run 返回的文件列表无法解析。"}), nil
}
state := "declared"
reason := "展示服务器文件缓存;打开文件标签时会自动读取 Run 实时目录。"
if hasLatest {
if !isTerminalJobState(latest.State) {
state = "pending"
reason = "Run 正在刷新目录。"
} else if latest.State == domain.JobStateFailed || latest.State == domain.JobStateCancelled {
state = "failed"
reason = serverFileListJobFailureReason(latest)
}
}
entries := filterServerFileEntries(serverFileEntriesFromDeclaredWorkspace(ctx.Workspace, request.DirectoryKey), request.Query)
if state == "declared" {
reason = "展示服务器文件缓存;打开文件标签时会自动读取 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 serverFileListJobFailureReason(job domain.Job) string {
prefix := "Run 文件目录刷新失败"
if job.State == domain.JobStateCancelled {
prefix = "Run 文件目录刷新已取消"
}
detail := strings.TrimSpace(job.CancelReason)
if detail == "" {
detail = strings.TrimSpace(job.Progress.Message)
}
if detail == "" {
detail = strings.TrimSpace(job.ExecutionResult.Summary)
}
if detail == "" {
return prefix + "。"
}
return prefix + "" + detail
}
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)
if err != nil {
return domain.ServerFileListResult{}, err
}
endpoint, err := svc.GetRunEndpoint(ctx.Instance.RunEndpointID)
if err != nil {
return domain.ServerFileListResult{}, err
}
entries := filterServerFileEntries(serverFileEntriesFromDeclaredWorkspace(ctx.Workspace, request.DirectoryKey), request.Query)
if !containsString(endpoint.Capabilities, domain.JobCapabilityFilesList) {
return domain.CopyServerFileListResult(domain.ServerFileListResult{
ServerInstanceID: ctx.Instance.ID,
PluginID: ctx.Plugin.ID,
DirectoryKey: request.DirectoryKey,
Path: request.Path,
State: "ready",
Entries: entries,
Reason: "当前 Run 未声明 files.list,已展示插件声明的逻辑文件。",
}), nil
}
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), Deployment: deploymentPlanForDispatch(ctx.Instance.Deployment), 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
}
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) BrowseServerFilesForSession(ctx context.Context, sessionID string, request domain.ServerFileListRequest) (domain.ServerFileListResult, error) {
request = normalizeServerFileListRequest(request)
if request.IdempotencyKey == "" {
idempotencyKey, err := svc.serverFileBrowseIdempotencyKey(request)
if err != nil {
return domain.ServerFileListResult{}, err
}
request.IdempotencyKey = idempotencyKey
}
result, err := svc.RefreshServerFileListForSession(sessionID, request)
if err != nil || result.State != "pending" || result.Job.ID == "" {
return result, err
}
timer := time.NewTimer(serverFileBrowseWait)
defer timer.Stop()
for {
current, ready, err := svc.serverFileListResultFromJob(request, result, result.Job.ID)
if err != nil || ready {
return current, err
}
waiter := svc.registerRunJobWaiter(result.Job.RunEndpointID)
current, ready, err = svc.serverFileListResultFromJob(request, result, result.Job.ID)
if err != nil || ready {
svc.unregisterRunJobWaiter(result.Job.RunEndpointID, waiter)
return current, err
}
select {
case <-ctx.Done():
svc.unregisterRunJobWaiter(result.Job.RunEndpointID, waiter)
return domain.ServerFileListResult{}, ctx.Err()
case <-waiter:
case <-timer.C:
svc.unregisterRunJobWaiter(result.Job.RunEndpointID, waiter)
current.Reason = "正在读取目录。"
return current, nil
}
}
}
func (svc *CoreService) serverFileListResultFromJob(request domain.ServerFileListRequest, fallback domain.ServerFileListResult, jobID string) (domain.ServerFileListResult, bool, error) {
job, err := svc.store.Jobs().Get(jobID)
if err != nil {
return domain.ServerFileListResult{}, true, err
}
result := domain.CopyServerFileListResult(fallback)
result.Job = job
if !isTerminalJobState(job.State) {
return result, false, nil
}
result.RefreshedAt = job.TerminalAt
if job.State != domain.JobStateSucceeded || job.ExecutionResult.Kind != "file.list" {
result.State = "failed"
result.Reason = serverFileListJobFailureReason(job)
return result, true, nil
}
entries, err := serverFileEntriesFromRunList(job.ExecutionResult.Content, request.DirectoryKey, request.Path)
if err != nil {
result.State = "failed"
result.Reason = "Run 返回的文件列表无法解析。"
return result, true, nil
}
result.State = "ready"
result.Entries = filterServerFileEntries(entries, request.Query)
result.Reason = "目录读取完成。"
return result, true, nil
}
func (svc *CoreService) serverFileBrowseIdempotencyKey(request domain.ServerFileListRequest) (string, error) {
token, err := randomToken()
if err != nil {
return "", err
}
raw := strings.Join([]string{request.ServerInstanceID, request.DirectoryKey, request.Path, request.Query, strconv.FormatBool(request.Recursive), token}, ":")
return fmt.Sprintf("file-browse:%d", stableStringNumber(raw)), 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)
if err != nil {
return domain.ServerFileUploadDispatch{}, err
}
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), Deployment: deploymentPlanForDispatch(ctx.Instance.Deployment), 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)
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 := 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) (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
}
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 serverFileWorkspaceIsPluginDeclared(workspace) {
if workspace.DefaultDirectoryKey == "" && len(workspace.Directories) > 0 {
workspace.DefaultDirectoryKey = workspace.Directories[0].Key
}
return workspace
}
return domain.PluginFileWorkspace{DefaultDirectoryKey: serverFileDefaultDirectoryKey, Directories: []domain.PluginLogicalDirectory{{Key: serverFileDefaultDirectoryKey, Label: "服务器根目录", Scope: "config"}}, Files: []domain.PluginLogicalFile{}, ConfigFields: []domain.PluginConfigField{}}
}
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: true, Downloadable: true, 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: true, 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 == "" && entry.Name != "" {
relativePath = cleanServerFileRelativePath(path.Join(fallbackPath, entry.Name))
} else if relativePath == "" {
relativePath = fallbackPath
}
editable := kind == domain.ServerFileEntryFile
downloadable := kind == domain.ServerFileEntryFile
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: editable, Downloadable: 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) {
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 != domain.JobCapabilityFilesList || job.TargetKey != directoryKey {
continue
}
if job.ExecutionInput.Inputs["path"] != relativePath {
continue
}
if !found || job.UpdatedAt.After(latest.UpdatedAt) || job.CreatedAt.After(latest.CreatedAt) {
latest = job
found = true
}
}
return domain.CopyJob(latest), found, nil
}
func isServerFileCapability(capability string) bool {
switch capability {
case domain.JobCapabilityFilesList, domain.JobCapabilityFilesRead, domain.JobCapabilityFilesWrite:
return true
default:
return false
}
}
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.cacheArtifactPayload(artifact.ID, 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
}