package service import ( "context" "encoding/json" "errors" "fmt" "log/slog" "sync" "gitea.stevedudenhoeffer.com/steve/pansy/internal/domain" ) // This file is pansy's undo substrate (#48). The agent acts freely — "empty the // garlic bed and plant cucumbers" runs without a confirmation prompt — which is // only defensible because the result is easy to roll back. // // The unit of undo is the OPERATION, not the row: a change set groups the // row-level revisions it produced, and revert replays their inverses. Two // properties shape everything here: // // - Revert is itself a change set (reverts_id names its target). History is // append-only, so an undo can be undone. git revert, not git reset. // - Revert is version-guarded per entity. If a row changed after the change set // being reverted, restoring the old snapshot would silently discard that // edit, so it is reported as a conflict and left alone — the rest of the // change set still reverts. // maxHistoryPageSize caps a history page so a season of edits can't be pulled in // one request. const maxHistoryPageSize = 100 // defaultHistoryPageSize is the page size when a caller doesn't ask for one. const defaultHistoryPageSize = 50 // changeSetKey is the context key carrying the ambient change scope. type changeSetKey struct{} // changeScope accumulates the revisions of one logical operation. Revisions are // buffered rather than written as they happen, so an operation that fails partway // leaves no half-recorded change set behind — the whole thing lands, or none // of it does. type changeScope struct { gardenID int64 actorID int64 source string summary string agentRunID *string mu sync.Mutex revs []domain.Revision } func (sc *changeScope) append(revs []domain.Revision) { sc.mu.Lock() defer sc.mu.Unlock() sc.revs = append(sc.revs, revs...) } func (sc *changeScope) taken() []domain.Revision { sc.mu.Lock() defer sc.mu.Unlock() return sc.revs } // scopeFrom returns the change scope on ctx, or nil when the caller opened none. func scopeFrom(ctx context.Context) *changeScope { sc, _ := ctx.Value(changeSetKey{}).(*changeScope) return sc } // change is one row-level mutation waiting to be recorded. before/after are the // row structs themselves; they are marshalled to JSON snapshots at record time. type change struct { entityType string entityID int64 op string before any after any } func changeCreate(entityType string, id int64, after any) change { return change{entityType: entityType, entityID: id, op: domain.OpCreate, after: after} } func changeUpdate(entityType string, id int64, before, after any) change { return change{entityType: entityType, entityID: id, op: domain.OpUpdate, before: before, after: after} } func changeDelete(entityType string, id int64, before any) change { return change{entityType: entityType, entityID: id, op: domain.OpDelete, before: before} } // ChangeSetOptions describes the change set WithChangeSet opens. type ChangeSetOptions struct { // Source is one of domain.SourceUI / SourceAgent / SourceAPI. The history UI // badges agent changes differently, so an autonomous edit is never mistaken // for a hand edit. Source string // Summary is the one-line description shown in the history list. Summary string // AgentRunID joins this change set back to the executus run that produced it. AgentRunID *string } // WithChangeSet runs fn with a change scope on the context, so every mutation fn // performs lands in ONE change set — the agent's "a whole turn is one undo" // guarantee. The change set is written only if fn succeeds; if fn changed // nothing, none is written and (nil, nil) is returned. // // Requires editor role on the garden, checked up front so a scope is never opened // for an actor who couldn't have mutated anything anyway. func (s *Service) WithChangeSet(ctx context.Context, actorID, gardenID int64, opts ChangeSetOptions, fn func(context.Context) error) (*domain.ChangeSet, error) { if _, err := s.requireGardenRole(ctx, actorID, gardenID, roleEditor); err != nil { return nil, err } if !validChangeSource(opts.Source) { return nil, domain.ErrInvalidInput } sc := &changeScope{ gardenID: gardenID, actorID: actorID, source: opts.Source, summary: opts.Summary, agentRunID: opts.AgentRunID, } if err := fn(context.WithValue(ctx, changeSetKey{}, sc)); err != nil { return nil, err } return s.commitScope(ctx, sc, nil) } // commitScope writes a scope's buffered revisions as one change set. revertsID is // set only by RevertChangeSet. A scope with no revisions writes nothing — an // operation that changed nothing doesn't belong in history. func (s *Service) commitScope(ctx context.Context, sc *changeScope, revertsID *int64) (*domain.ChangeSet, error) { revs := sc.taken() if len(revs) == 0 { return nil, nil } return s.store.WriteChangeSet(ctx, &domain.ChangeSet{ GardenID: sc.gardenID, ActorID: sc.actorID, Source: sc.source, Summary: sc.summary, AgentRunID: sc.agentRunID, RevertsID: revertsID, }, revs) } func validChangeSource(s string) bool { return s == domain.SourceUI || s == domain.SourceAgent || s == domain.SourceAPI } // record files the changes a mutation just made. When the caller opened a scope // (WithChangeSet) they join it; otherwise this operation becomes its own // single-op change set on the spot — the AUTO-SCOPE path, which is what keeps // this feature invasive but shallow: REST handlers need no edits at all and every // UI mutation lands in history for free. Only the agent wraps a whole turn. // // Note a multi-row operation (FillRegion, ClearObject) passes all of its changes // in ONE call, so it auto-scopes into one change set with N revisions rather than // N change sets. // // Recording is best-effort by design: the row is already written, so failing the // caller here would report a failure that didn't happen and invite a duplicate // retry. A history gap is logged loudly instead. func (s *Service) record(ctx context.Context, gardenID, actorID int64, summary string, changes ...change) { if len(changes) == 0 { return } revs, err := toRevisions(changes) if err != nil { slog.Error("service: snapshot revisions", "error", err, "garden", gardenID, "summary", summary) return } if sc := scopeFrom(ctx); sc != nil { sc.append(revs) return } if _, err := s.store.WriteChangeSet(ctx, &domain.ChangeSet{ GardenID: gardenID, ActorID: actorID, Source: domain.SourceUI, Summary: summary, }, revs); err != nil { slog.Error("service: record change set", "error", err, "garden", gardenID, "summary", summary) } } // toRevisions marshals each change's before/after rows to JSON snapshots. func toRevisions(changes []change) ([]domain.Revision, error) { revs := make([]domain.Revision, 0, len(changes)) for _, c := range changes { before, err := snapshot(c.before) if err != nil { return nil, err } after, err := snapshot(c.after) if err != nil { return nil, err } revs = append(revs, domain.Revision{ EntityType: c.entityType, EntityID: c.entityID, Op: c.op, Before: before, After: after, }) } return revs, nil } // snapshot marshals a row to a JSON string, preserving nil as a NULL column. func snapshot(v any) (*string, error) { if v == nil { return nil, nil } b, err := json.Marshal(v) if err != nil { return nil, fmt.Errorf("service: marshal snapshot: %w", err) } s := string(b) return &s, nil } // GardenHistory returns a page of a garden's change sets, newest first, for an // actor who can at least view it, plus whether more pages follow. limit is // clamped to [1, maxHistoryPageSize]. // // hasMore is answered by reading one row beyond the page and discarding it, // rather than by a second COUNT over a table that only grows. Keeping that here // (not in the handler) means the clamp and the probe can't disagree. func (s *Service) GardenHistory(ctx context.Context, actorID, gardenID int64, limit, offset int) ([]domain.ChangeSet, bool, error) { if _, err := s.requireGardenRole(ctx, actorID, gardenID, roleViewer); err != nil { return nil, false, err } if limit <= 0 { limit = defaultHistoryPageSize } if limit > maxHistoryPageSize { limit = maxHistoryPageSize } if offset < 0 { offset = 0 } sets, err := s.store.ListChangeSets(ctx, gardenID, limit+1, offset) if err != nil { return nil, false, err } if len(sets) > limit { return sets[:limit], true, nil } return sets, false, nil } // RevertChangeSet undoes a change set by applying the inverse of each of its // revisions, inside one NEW change set that points back at the target — so the // undo is itself undoable. Requires editor role on the garden. // // Entities that changed after the target change set are reported as conflicts and // left untouched; everything else still reverts. Returns the new change set (nil // when nothing could be reverted) alongside those conflicts. func (s *Service) RevertChangeSet(ctx context.Context, actorID, changeSetID int64) (*domain.ChangeSet, []domain.RevertConflict, error) { target, err := s.store.GetChangeSet(ctx, changeSetID) if err != nil { return nil, nil, err } if _, err := s.requireGardenRole(ctx, actorID, target.GardenID, roleEditor); err != nil { return nil, nil, err } conflicts := []domain.RevertConflict{} sc := &changeScope{ gardenID: target.GardenID, actorID: actorID, source: domain.SourceUI, summary: revertSummary(target), } for _, r := range planRevert(target.Revisions) { changes, conflict, err := s.applyInverse(ctx, r) if err != nil { return nil, nil, err } if conflict != nil { conflicts = append(conflicts, *conflict) continue } revs, err := toRevisions(changes) if err != nil { return nil, nil, err } sc.append(revs) } cs, err := s.commitScope(ctx, sc, &target.ID) if err != nil { return nil, nil, err } return cs, conflicts, nil } // planRevert orders a change set's revisions for inverse application. Three // passes, because the inverses carry foreign-key dependencies the original // operations did not: // // 1. restore deleted rows — parents (objects) BEFORE children (plantings), or a // restored plop has no object to hang off and its FK rejects it; // 2. undo updates; // 3. delete created rows — children before parents, so a delete never leans on // the cascade to clean up rows this revert is responsible for. // // Reverse seq order within each pass: the last change made is the first undone. // The passes are explicit rather than relying on reverse seq happening to order // parents correctly, which it only does by luck. func planRevert(revs []domain.Revision) []domain.Revision { rank := func(r domain.Revision) int { switch { case r.Op == domain.OpDelete && r.EntityType == domain.EntityObject: return 0 case r.Op == domain.OpDelete: return 1 case r.Op == domain.OpUpdate: return 2 case r.Op == domain.OpCreate && r.EntityType == domain.EntityObject: return 4 default: // create, non-object return 3 } } planned := make([]domain.Revision, 0, len(revs)) for pass := 0; pass <= 4; pass++ { for i := len(revs) - 1; i >= 0; i-- { if rank(revs[i]) == pass { planned = append(planned, revs[i]) } } } return planned } // applyInverse undoes one revision, returning the changes it made (to be recorded // in the revert's own change set), or a conflict describing why it declined. A // returned error is an infrastructure failure and aborts the revert; a conflict // is a normal outcome that skips just this entity. func (s *Service) applyInverse(ctx context.Context, r domain.Revision) ([]change, *domain.RevertConflict, error) { switch r.EntityType { case domain.EntityObject: return s.revertObject(ctx, r) case domain.EntityPlanting: return s.revertPlanting(ctx, r) case domain.EntityGarden: return s.revertGarden(ctx, r) default: return nil, conflict(r, domain.ConflictUnsupported, ""), nil } } func (s *Service) revertObject(ctx context.Context, r domain.Revision) ([]change, *domain.RevertConflict, error) { switch r.Op { case domain.OpCreate: // Inverse of a create is a delete. cur, err := s.store.GetObject(ctx, r.EntityID) if errors.Is(err, domain.ErrNotFound) { return nil, nil, nil // already gone: the inverse is a no-op, not a conflict } if err != nil { return nil, nil, err } if c := versionGuard(r, cur.Version, cur.Name); c != nil { return nil, c, nil } changes, err := s.deleteObjectRecording(ctx, cur) return changes, nil, err case domain.OpUpdate: cur, err := s.store.GetObject(ctx, r.EntityID) if errors.Is(err, domain.ErrNotFound) { return nil, conflict(r, domain.ConflictMissing, ""), nil } if err != nil { return nil, nil, err } if c := versionGuard(r, cur.Version, cur.Name); c != nil { return nil, c, nil } var target domain.GardenObject if err := unsnapshot(r.Before, &target); err != nil { return nil, nil, err } target.Version = cur.Version updated, err := s.store.UpdateObject(ctx, &target) if errors.Is(err, domain.ErrVersionConflict) { return nil, conflict(r, domain.ConflictChanged, cur.Name), nil } if err != nil { return nil, nil, err } return []change{changeUpdate(domain.EntityObject, updated.ID, cur, updated)}, nil, nil case domain.OpDelete: // Inverse of a delete is a restore, under the original id. if existing, err := s.store.GetObject(ctx, r.EntityID); err == nil { return nil, conflict(r, domain.ConflictExists, existing.Name), nil } else if !errors.Is(err, domain.ErrNotFound) { return nil, nil, err } var target domain.GardenObject if err := unsnapshot(r.Before, &target); err != nil { return nil, nil, err } restored, err := s.store.RestoreObject(ctx, &target) if err != nil { return nil, nil, err } return []change{changeCreate(domain.EntityObject, restored.ID, restored)}, nil, nil } return nil, conflict(r, domain.ConflictUnsupported, ""), nil } func (s *Service) revertPlanting(ctx context.Context, r domain.Revision) ([]change, *domain.RevertConflict, error) { switch r.Op { case domain.OpCreate: cur, err := s.store.GetPlanting(ctx, r.EntityID) if errors.Is(err, domain.ErrNotFound) { return nil, nil, nil // already gone } if err != nil { return nil, nil, err } if c := versionGuard(r, cur.Version, ""); c != nil { return nil, c, nil } if err := s.store.DeletePlanting(ctx, cur.ID); err != nil { return nil, nil, err } return []change{changeDelete(domain.EntityPlanting, cur.ID, cur)}, nil, nil case domain.OpUpdate: cur, err := s.store.GetPlanting(ctx, r.EntityID) if errors.Is(err, domain.ErrNotFound) { return nil, conflict(r, domain.ConflictMissing, ""), nil } if err != nil { return nil, nil, err } if c := versionGuard(r, cur.Version, ""); c != nil { return nil, c, nil } var target domain.Planting if err := unsnapshot(r.Before, &target); err != nil { return nil, nil, err } target.Version = cur.Version updated, err := s.store.UpdatePlanting(ctx, &target) if errors.Is(err, domain.ErrVersionConflict) { return nil, conflict(r, domain.ConflictChanged, ""), nil } if err != nil { return nil, nil, err } return []change{changeUpdate(domain.EntityPlanting, updated.ID, cur, updated)}, nil, nil case domain.OpDelete: if _, err := s.store.GetPlanting(ctx, r.EntityID); err == nil { return nil, conflict(r, domain.ConflictExists, ""), nil } else if !errors.Is(err, domain.ErrNotFound) { return nil, nil, err } var target domain.Planting if err := unsnapshot(r.Before, &target); err != nil { return nil, nil, err } restored, err := s.store.RestorePlanting(ctx, &target) if err != nil { return nil, nil, err } return []change{changeCreate(domain.EntityPlanting, restored.ID, restored)}, nil, nil } return nil, conflict(r, domain.ConflictUnsupported, ""), nil } // revertGarden undoes a garden metadata edit. Garden creation and deletion are // never recorded (see the migration), so update is the only op reachable here. func (s *Service) revertGarden(ctx context.Context, r domain.Revision) ([]change, *domain.RevertConflict, error) { if r.Op != domain.OpUpdate { return nil, conflict(r, domain.ConflictUnsupported, ""), nil } cur, err := s.store.GetGarden(ctx, r.EntityID) if errors.Is(err, domain.ErrNotFound) { return nil, conflict(r, domain.ConflictMissing, ""), nil } if err != nil { return nil, nil, err } if c := versionGuard(r, cur.Version, cur.Name); c != nil { return nil, c, nil } var target domain.Garden if err := unsnapshot(r.Before, &target); err != nil { return nil, nil, err } target.Version = cur.Version updated, err := s.store.UpdateGarden(ctx, &target) if errors.Is(err, domain.ErrVersionConflict) { return nil, conflict(r, domain.ConflictChanged, cur.Name), nil } if err != nil { return nil, nil, err } return []change{changeUpdate(domain.EntityGarden, updated.ID, cur, updated)}, nil, nil } // deleteObjectRecording deletes an object and returns every change to record. // Deleting an object cascades its plantings away in SQLite without the service // ever seeing them, so they are snapshotted first — otherwise the delete could be // listed in history but never reverted. Shared by DeleteObject and the revert of // an object creation, so both stay honest about the cascade. func (s *Service) deleteObjectRecording(ctx context.Context, o *domain.GardenObject) ([]change, error) { plops, err := s.store.ListPlantingsForObject(ctx, o.ID) if err != nil { return nil, err } if err := s.store.DeleteObject(ctx, o.ID); err != nil { return nil, err } changes := make([]change, 0, len(plops)+1) changes = append(changes, changeDelete(domain.EntityObject, o.ID, o)) for i := range plops { p := plops[i] changes = append(changes, changeDelete(domain.EntityPlanting, p.ID, &p)) } return changes, nil } // versionGuard reports a conflict when the row's current version differs from the // one the change set left behind — i.e. something edited it since, and reverting // would silently discard that edit. A revision with no after-snapshot (a delete) // has nothing to compare and passes. func versionGuard(r domain.Revision, currentVersion int64, name string) *domain.RevertConflict { var after struct { Version int64 `json:"version"` } if r.After == nil { return nil } if err := json.Unmarshal([]byte(*r.After), &after); err != nil { return conflict(r, domain.ConflictUnsupported, name) } if after.Version != currentVersion { return conflict(r, domain.ConflictChanged, name) } return nil } func conflict(r domain.Revision, reason, name string) *domain.RevertConflict { return &domain.RevertConflict{EntityType: r.EntityType, EntityID: r.EntityID, Reason: reason, Name: name} } // unsnapshot parses a JSON row snapshot back into a row struct. A missing // snapshot where one is required is a corrupt revision, not a normal outcome. func unsnapshot(s *string, into any) error { if s == nil { return fmt.Errorf("service: revision is missing the snapshot it needs") } if err := json.Unmarshal([]byte(*s), into); err != nil { return fmt.Errorf("service: parse snapshot: %w", err) } return nil } // revertSummary describes the revert in the history list. Reverting a revert // reads as "Redid …" rather than a stack of nested "Undid" prefixes. func revertSummary(target *domain.ChangeSet) string { verb := "Undid" if target.RevertsID != nil { verb = "Redid" } if target.Summary == "" { return verb + " an earlier change" } return verb + ": " + target.Summary }