package stage import ( "context" "errors" "fmt" "os" "path/filepath" "strings" "gitea.maximumdirect.net/eric/narratio/internal/fileops" "gitea.maximumdirect.net/eric/narratio/internal/manifest" ) const ( cleanupSkipReason = "cleanup policies are disabled" cleanupSemanticConfigVersion = 1 ) type cleanupStage struct{} type cleanupSemanticConfig struct { DeleteSpoolAudio bool `json:"delete_spool_audio"` SpoolRoot string `json:"spool_root"` DeleteWorkspace bool `json:"delete_workspace"` WorkspaceRoot string `json:"workspace_root"` } func (cleanupStage) Name() string { return "cleanup" } func (cleanupStage) RequiresRunWorkspace() bool { return false } func (cleanupStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*StageResult, error) { plan, err := buildCleanupPlan(env, m) if err != nil { return nil, err } if !plan.enabled { return &StageResult{ Disposition: StageDispositionSkipped, SkipReason: cleanupSkipReason, }, nil } completedPolicies := make([]string, 0, len(plan.targets)) for _, target := range plan.targets { if err := ctx.Err(); err != nil { return nil, fmt.Errorf("cleanup canceled before %s: %w", target.policy, err) } if err := fileops.RemoveAllUnderRoot(target.root, target.target); err != nil { return nil, fmt.Errorf("apply %s: %w", target.policy, err) } completedPolicies = append(completedPolicies, target.policy) } return &StageResult{Metadata: map[string]any{ "stage": "cleanup", "published_run_id": plan.publishedRunID, "completed_policies": completedPolicies, }}, nil } func (cleanupStage) SemanticConfigFingerprint(env *Env) (manifest.SemanticConfigFingerprint, error) { if env == nil || env.Config == nil || env.Config.Pipeline == nil { return manifest.SemanticConfigFingerprint{}, fmt.Errorf("resolved pipeline configuration is required") } pipeline := env.Config.Pipeline return FingerprintSemanticConfig(cleanupSemanticConfigVersion, cleanupSemanticConfig{ DeleteSpoolAudio: pipeline.Spool.DeleteAudioAfterPublish, SpoolRoot: normalizedCleanupRoot(pipeline.Spool.Root), DeleteWorkspace: pipeline.Workspace.CleanupAfterPublish, WorkspaceRoot: normalizedCleanupRoot(pipeline.Workspace.Root), }) } func (cleanupStage) ValidateResume(ctx context.Context, env *Env, m *manifest.Manifest) (ResumeValidation, error) { plan, err := buildCleanupPlan(env, m) if err != nil { return ResumeValidation{}, err } if !plan.enabled { return NonResumable(cleanupSkipReason), nil } for _, target := range plan.targets { if err := ctx.Err(); err != nil { return ResumeValidation{}, fmt.Errorf("validate cleanup resume for %s: %w", target.policy, err) } if _, err := os.Lstat(target.target); err == nil { return NonResumable(fmt.Sprintf("cleanup target for %s exists", target.policy)), nil } else if !errors.Is(err, os.ErrNotExist) { return ResumeValidation{}, fmt.Errorf("inspect cleanup target for %s: %w", target.policy, err) } } return Resumable(), nil } func normalizedCleanupRoot(root string) string { root = strings.TrimSpace(root) if root == "" { return "" } return filepath.Clean(root) }