Files
yatm/internal/executor/fileops/service.go
T
Samuel Cui 4fef653b71 Prepare YATM v1.0.0-alpha.2
Unify file queries and Scan execution, retain indexed Identical results,
improve Job and Preview workflows, and verify release packaging.
2026-10-05 19:08:49 +08:00

289 lines
10 KiB
Go

package fileops
import (
"context"
"errors"
"fmt"
"strconv"
"github.com/google/uuid"
"github.com/samuelncui/yatm/entity"
"github.com/samuelncui/yatm/internal/executor"
"github.com/samuelncui/yatm/internal/treeops"
"google.golang.org/protobuf/proto"
)
type service struct {
exe *executor.Executor
// declaredLocations restricts a scoped service to the Locations it announced.
declaredLocations map[int64]bool
}
// ResultStream exposes request-bound results without coupling the shared engine to an RPC method.
type ResultStream interface {
Context() context.Context
Send(*entity.FileOperationResult) error
}
// Mkdir creates directories through the shared organization engine.
func Mkdir(exe *executor.Executor, req *entity.MkdirFilesRequest, stream ResultStream) error {
return (&service{exe: exe}).execute(&entity.FileOperationSpec{Kind: entity.FileOperationKind_FILE_OPERATION_KIND_MKDIR,
Destination: req.GetDestination(), Name: req.GetName()}, req.GetDryrun(), stream)
}
// Move moves or renames selected entries through the shared organization engine.
func Move(exe *executor.Executor, req *entity.MoveFilesRequest, stream ResultStream) error {
return (&service{exe: exe}).execute(&entity.FileOperationSpec{Kind: entity.FileOperationKind_FILE_OPERATION_KIND_MOVE,
Sources: req.GetSources(), Destination: req.GetDestination(), Name: req.GetName()}, req.GetDryrun(), stream)
}
// Remove moves selected entries to their source's recycle bin.
func Remove(exe *executor.Executor, req *entity.RemoveFilesRequest, stream ResultStream) error {
return (&service{exe: exe}).execute(&entity.FileOperationSpec{Kind: entity.FileOperationKind_FILE_OPERATION_KIND_REMOVE,
Sources: req.GetSources()}, req.GetDryrun(), stream)
}
func (s *service) execute(input *entity.FileOperationSpec, dryRun bool, stream ResultStream) (returnErr error) {
// Both scopes share intent validation and request-bound results, not separate public workflows.
spec, logical, err := validateSpec(input)
if err != nil {
return err
}
return s.executeTree(spec, logical, dryRun, stream)
}
func (s *service) executeTree(spec *entity.FileOperationSpec, logical, dryRun bool, stream ResultStream) (returnErr error) {
// Only construction differs; both namespaces run the same planner and executor.
ctx := stream.Context()
var store treeops.Store
var transaction treeops.Transaction
var config *config
var err error
options := treeops.Options{NativeMove: true}
if logical {
store, transaction = s.exe.Lib().FileTree(), s.exe.Lib().FileTreeTransaction
} else {
config, err = s.validateLocation(ctx, spec)
}
if err != nil {
return err
}
op, err := newOperation(ctx, s.exe)
if err != nil {
return err
}
defer func() { returnErr = errors.Join(returnErr, op.Close()) }()
if !logical {
location, err := op.currentLocation(ctx, config)
if err != nil {
return err
}
store = &locationTree{op: op, location: location, config: config}
}
engine, err := treeops.New(ctx, op.DB, store, options, transaction)
if err != nil {
return err
}
ref := func(value *entity.FileOperationRef) string {
if logical {
return strconv.FormatInt(value.GetFileId(), 10)
}
return "path:" + value.GetLocation().GetPath()
}
request := treeops.Request{Name: spec.Name}
switch spec.Kind {
case entity.FileOperationKind_FILE_OPERATION_KIND_MOVE:
request.Kind = treeops.Move
case entity.FileOperationKind_FILE_OPERATION_KIND_MKDIR:
request.Kind = treeops.Mkdir
case entity.FileOperationKind_FILE_OPERATION_KIND_REMOVE:
request.Kind = treeops.Delete
}
for _, source := range spec.Sources {
request.Sources = append(request.Sources, ref(source))
}
if spec.Destination != nil {
request.Destination = ref(spec.Destination)
}
if err := engine.Prepare(ctx, request); err != nil {
return err
}
count, size, err := engine.Totals(ctx)
if err != nil {
return err
}
summary := &entity.FileOperationSummary{TotalItemCount: count, UnprocessedCount: count, TotalBytes: size, Dryrun: dryRun}
if err := stream.Send(&entity.FileOperationResult{Summary: summary}); err != nil {
return err
}
// A dry run reports the resolved plan per planned entry and changes nothing.
if dryRun {
return reportPlan(ctx, engine, summary, stream)
}
// Every primitive exposes its actual paths, including untouched entries after a partial failure.
if err := engine.Run(ctx, func(root treeops.Result) error {
entry := &entity.FileOperationEntry{Id: root.ID, SourcePath: root.Source.Path, TargetPath: root.Target.Path,
Directory: root.Source.Directory || root.Target.Directory, SizeBytes: root.Source.Size, Error: root.Error,
Outcome: entity.FileOperationOutcome_FILE_OPERATION_OUTCOME_SUCCEEDED}
if root.FileID != 0 {
entry.FileId = &root.FileID
}
switch root.Outcome {
case treeops.PublicationPending:
entry.Outcome = entity.FileOperationOutcome_FILE_OPERATION_OUTCOME_PUBLICATION_PENDING
summary.PublicationPendingCount++
case treeops.Failed:
entry.Outcome = entity.FileOperationOutcome_FILE_OPERATION_OUTCOME_FAILED
summary.FailedCount++
case treeops.Succeeded:
summary.SucceededCount++
default:
entry.Outcome = entity.FileOperationOutcome_FILE_OPERATION_OUTCOME_UNPROCESSED
}
if entry.Outcome != entity.FileOperationOutcome_FILE_OPERATION_OUTCOME_UNPROCESSED {
summary.UnprocessedCount--
}
return stream.Send(&entity.FileOperationResult{Entry: entry, Summary: summary})
}); err != nil {
return err
}
summary.Completed = true
return stream.Send(&entity.FileOperationResult{Summary: summary})
}
// reportPlan streams the planned roots as a report, never as completed work.
func reportPlan(ctx context.Context, engine *treeops.Engine, summary *entity.FileOperationSummary, stream ResultStream) error {
roots, err := engine.Roots(ctx)
if err != nil {
return err
}
for _, root := range roots {
if err := ctx.Err(); err != nil {
return err
}
entry := &entity.FileOperationEntry{Id: root.ID, SourcePath: root.Source.Path, TargetPath: root.Target.Path,
Directory: root.Source.Directory || root.Target.Directory, SizeBytes: root.Source.Size, Error: root.Error,
Outcome: entity.FileOperationOutcome_FILE_OPERATION_OUTCOME_UNPROCESSED}
if err := stream.Send(&entity.FileOperationResult{Entry: entry, Summary: summary}); err != nil {
return err
}
}
summary.Completed = true
return stream.Send(&entity.FileOperationResult{Summary: summary})
}
func validateSpec(input *entity.FileOperationSpec) (*entity.FileOperationSpec, bool, error) {
// Validate explicit intent before acquiring resources or performing filesystem mutations.
if input == nil {
return nil, false, fmt.Errorf("file operation specification is missing")
}
spec := proto.Clone(input).(*entity.FileOperationSpec)
if len(spec.Sources) > 1000 {
return nil, false, fmt.Errorf("select at most 1000 operation roots")
}
switch spec.Kind {
case entity.FileOperationKind_FILE_OPERATION_KIND_MOVE:
if len(spec.Sources) == 0 || spec.Destination == nil {
return nil, false, fmt.Errorf("sources and destination are required")
}
if spec.Name != "" && len(spec.Sources) != 1 {
return nil, false, fmt.Errorf("a replacement name requires one source")
}
case entity.FileOperationKind_FILE_OPERATION_KIND_MKDIR:
if len(spec.Sources) != 0 || spec.Name == "" || spec.Destination == nil {
return nil, false, fmt.Errorf("mkdir requires a destination and name, without sources")
}
case entity.FileOperationKind_FILE_OPERATION_KIND_REMOVE:
if len(spec.Sources) == 0 || spec.Destination != nil || spec.Name != "" {
return nil, false, fmt.Errorf("delete requires sources only")
}
default:
return nil, false, fmt.Errorf("unsupported file operation")
}
// Validate every reference before dispatch; mixed selections must never mutate either store.
refs := append([]*entity.FileOperationRef{}, spec.Sources...)
if spec.Destination != nil {
refs = append(refs, spec.Destination)
}
logical, err := libraryReference(refs[0])
if err != nil {
return nil, false, err
}
for _, ref := range refs {
isLibrary, err := libraryReference(ref)
if err != nil {
return nil, false, err
}
if isLibrary != logical {
return nil, false, fmt.Errorf("file operations cannot mix Library and Location references")
}
}
return spec, logical, nil
}
func libraryReference(ref *entity.FileOperationRef) (bool, error) {
// Missing oneof values are not Library root references.
switch target := ref.GetTarget().(type) {
case *entity.FileOperationRef_FileId:
if target != nil {
return true, nil
}
case *entity.FileOperationRef_Location:
if target != nil && target.Location != nil {
return false, nil
}
}
return false, fmt.Errorf("file operation reference is missing")
}
func (s *service) validateLocation(ctx context.Context, request *entity.FileOperationSpec) (*config, error) {
// Convert the already type-checked public references once at the physical boundary.
spec := &locationSpec{Kind: request.Kind, Name: request.Name, Destination: request.Destination.GetLocation()}
for _, ref := range request.Sources {
spec.Sources = append(spec.Sources, ref.GetLocation())
}
// One confirmed Location owns all sources and the destination for this entire request.
ref := spec.Destination
if ref == nil {
ref = spec.Sources[0]
}
if err := s.admitLocation(ref.GetLocationId()); err != nil {
return nil, err
}
location, _, info, err := s.exe.ResolveLocationEntry(ctx, ref)
if err != nil {
return nil, err
}
if spec.Destination != nil && !info.IsDir() {
return nil, fmt.Errorf("operation destination is not a directory")
}
if spec.Destination != nil && executor.IsLocationTrashPath(spec.Destination.Path) {
return nil, fmt.Errorf("Trash cannot be an operation destination")
}
for _, source := range spec.Sources {
if source.GetLocationId() != location.ID {
return nil, fmt.Errorf("operations must stay within one Location")
}
if source.Path == "" {
return nil, fmt.Errorf("Location roots cannot be moved or removed")
}
if executor.IsLocationTrashPath(source.Path) {
if spec.Kind != entity.FileOperationKind_FILE_OPERATION_KIND_MOVE || !executor.IsLocationTrashContent(source.Path) {
return nil, fmt.Errorf("Trash entries can only be moved out")
}
if err := validateTrash(location.RootPath); err != nil {
return nil, err
}
}
if _, _, _, err := s.exe.ResolveLocationEntry(ctx, source); err != nil {
return nil, err
}
}
return &config{Spec: spec, OperationID: uuid.NewString(), LocationID: location.ID,
RootPath: location.RootPath}, nil
}