diff --git a/PLAN.md b/PLAN.md index 5dbc488..7634444 100644 --- a/PLAN.md +++ b/PLAN.md @@ -2899,7 +2899,7 @@ The exact direct prerequisites below, not the ranges above, determine readiness. **Owns:** `internal/application/apply/execute_files.go`, `internal/application/apply/execute_files_test.go`. -**Deliverable:** Execute regular-file create/update/mode/baseline actions sequentially with per-target rechecks, durable writes, short state commits, and partial results. +**Deliverable:** Execute regular-file create/update/mode/baseline actions sequentially with per-target rechecks, durable writes, short state commits, and partial results, including the alias-to-file representation switch when the retained row is an active alias. **Tests:** `TestApplyFileExecution` injects every filesystem/state boundary and covers equality recovery and action-local secret clearing. diff --git a/internal/application/apply/execute_files.go b/internal/application/apply/execute_files.go index d947840..47b58bc 100644 --- a/internal/application/apply/execute_files.go +++ b/internal/application/apply/execute_files.go @@ -71,17 +71,35 @@ func (service *Service) executeFile(ctx context.Context, job fileJob) (ItemResul if err != nil { return partialRecord(job), err } - baseline, err := service.baselines.UpsertFileBaseline(job.root, job.home, baselineRow(job.candidate, contentHash)) - if err != nil { - return partialRecord(job), failure.New(failure.Operational, "apply: baseline "+job.action.TargetPath, err) + if err := service.commitBaseline(ctx, job, contentHash, durable); err != nil { + return partialRecord(job), err } - _ = baseline if !durable { return partialRecord(job), nil } return completedRecord(job), nil } +// commitBaseline switches an active alias row to the file representation or +// upserts the file baseline, only after a durable write. +func (service *Service) commitBaseline(ctx context.Context, job fileJob, contentHash deployment.Digest, durable bool) error { + if job.candidate.record.AliasState != nil && job.candidate.record.AliasState.Active() { + if !durable { + return failure.New(failure.Operational, "apply: alias-to-file transition is not durable: "+job.action.TargetPath, nil) + } + _, err := service.transitions.TransitionToFile(job.root, job.home, baselineRow(job.candidate, contentHash)) + if err != nil { + return failure.New(failure.Operational, "apply: transition to file "+job.action.TargetPath, err) + } + return nil + } + _, err := service.baselines.UpsertFileBaseline(job.root, job.home, baselineRow(job.candidate, contentHash)) + if err != nil { + return failure.New(failure.Operational, "apply: baseline "+job.action.TargetPath, err) + } + return nil +} + // writeSpec bundles the frozen precondition, exact bytes, and candidate of // one target write. type writeSpec struct { diff --git a/internal/application/apply/execute_files_test.go b/internal/application/apply/execute_files_test.go index 118ca67..7e7a025 100644 --- a/internal/application/apply/execute_files_test.go +++ b/internal/application/apply/execute_files_test.go @@ -47,6 +47,14 @@ func (b *baselineFake) UpsertFileBaseline(root, home string, baseline state.File return baseline, nil } +func (b *baselineFake) UpsertAliasBaseline(root, home string, baseline state.AliasBaseline) (state.AliasBaseline, error) { + b.calls++ + if b.failCall > 0 && b.calls >= b.failCall { + return state.AliasBaseline{}, os.ErrNotExist + } + return baseline, nil +} + // replacerFake delegates to the real replacer and can fail or count. type replacerFake struct { calls int diff --git a/internal/application/apply/prepare.go b/internal/application/apply/prepare.go index 7e8aad5..86b0773 100644 --- a/internal/application/apply/prepare.go +++ b/internal/application/apply/prepare.go @@ -92,13 +92,13 @@ func prepareActions(candidates Candidates, choices map[string]DecisionChoice, dr records := make([]ItemResult, 0) scope := prepareScope{choices: choices, dryRun: dryRun} for _, candidate := range candidates.All() { - execute, kind, source, kept, err := prepareOne(candidate, records, scope) + execute, kind, source, overwrite, kept, err := prepareOne(candidate, records, scope) if err != nil { return nil, nil, err } records = kept if execute { - actions = append(actions, PlanAction{TargetPath: candidate.record.TargetPath, Kind: kind, SourcePath: source}) + actions = append(actions, PlanAction{TargetPath: candidate.record.TargetPath, Kind: kind, SourcePath: source, Overwrite: overwrite}) } } return actions, records, nil @@ -112,26 +112,26 @@ type prepareScope struct { // prepareOne resolves one candidate into a skipped or dry-run record, or // reports that its pending action executes. -func prepareOne(candidate Candidate, records []ItemResult, scope prepareScope) (bool, ActionKind, string, []ItemResult, error) { +func prepareOne(candidate Candidate, records []ItemResult, scope prepareScope) (bool, ActionKind, string, bool, []ItemResult, error) { action, source, err := underlyingAction(candidate) if err != nil { - return false, "", "", records, err + return false, "", "", false, records, err } kind, has := mapKind(action) if !has { - return false, "", "", records, nil + return false, "", "", false, records, nil } choice, decided := scope.choices[candidate.record.TargetPath] if decided && choice == ChoiceSkip { - return false, kind, "", append(records, plannedRecord(candidate, kind)), nil + return false, kind, "", false, append(records, plannedRecord(candidate, kind)), nil } if needsDecision(candidate) && !decided && !scope.dryRun { - return false, kind, "", records, failure.New(failure.InvalidInput, "apply: unresolved decision for "+candidate.record.TargetPath, nil) + return false, kind, "", false, records, failure.New(failure.InvalidInput, "apply: unresolved decision for "+candidate.record.TargetPath, nil) } if scope.dryRun { - return false, kind, "", append(records, plannedRecord(candidate, kind)), nil + return false, kind, "", false, append(records, plannedRecord(candidate, kind)), nil } - return true, kind, source, records, nil + return true, kind, source, decided && choice == ChoiceOverwrite, records, nil } // needsDecision reports whether one candidate required an explicit choice. diff --git a/internal/application/apply/types.go b/internal/application/apply/types.go index 89f6f63..4933829 100644 --- a/internal/application/apply/types.go +++ b/internal/application/apply/types.go @@ -67,9 +67,10 @@ type StateReader interface { } // BaselineStore establishes or replaces the equal source/target baseline -// after a durable write. +// after a durable write, for both file and alias rows. type BaselineStore interface { UpsertFileBaseline(root, home string, baseline state.FileBaseline) (state.FileBaseline, error) + UpsertAliasBaseline(root, home string, baseline state.AliasBaseline) (state.AliasBaseline, error) } // TransitionStore atomically switches the active representation of one path @@ -263,11 +264,13 @@ const ( ) // PlanAction is one immutable apply action: the HOME-relative target, the -// action class, and the repository-relative source for content actions. +// action class, the repository-relative source for content actions, and +// whether an occupied alias path was confirmed for replacement. type PlanAction struct { TargetPath string Kind ActionKind SourcePath string + Overwrite bool } // ActionPlan freezes the ordered execution actions of one apply defensively. diff --git a/internal/application/apply/types_test.go b/internal/application/apply/types_test.go index b967303..2c942f0 100644 --- a/internal/application/apply/types_test.go +++ b/internal/application/apply/types_test.go @@ -184,6 +184,10 @@ func (f fakeBaselineStore) UpsertFileBaseline(root, home string, baseline state. return f.baseline, nil } +func (f fakeBaselineStore) UpsertAliasBaseline(root, home string, baseline state.AliasBaseline) (state.AliasBaseline, error) { + return state.AliasBaseline{}, nil +} + type fakeTransitionStore struct { alias state.AliasBaseline baseline state.FileBaseline