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 { // fn failed, but the mutations it completed before failing are already // committed — this scope buffers history, not data. Dropping the buffer // would leave those changes with no history and no way to undo them, // which is exactly the situation undo exists for. Record what happened, // mark the summary, and still report the failure. sc.summary = partialSummary(sc.summary) if _, cerr := s.commitScope(ctx, sc, nil); cerr != nil { slog.Error("service: partial turn could not be recorded", "error", cerr, "garden", gardenID) } return nil, err } return s.commitScope(ctx, sc, nil) } // partialSummary marks a change set whose operation failed partway, so the // history list can say so rather than presenting it as a completed action. func partialSummary(summary string) string { if summary == "" { return "Partly applied (the operation failed partway)" } return summary + " (failed partway)" } // 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. source says who // asked (the agent can undo its own work, and the history badge should show that). // // 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 needed reverting) alongside those conflicts. // // The inverses are applied one row at a time rather than in a single // transaction, because the store's version-guarded update path is per-row. That // means a failure partway leaves the garden partly reverted — so whatever DID // apply is recorded before the error is returned, and the partial revert is // itself a change set you can undo. Losing the history for changes that really // happened would be strictly worse than the partial state. func (s *Service) RevertChangeSet(ctx context.Context, actorID, changeSetID int64, source string) (*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 } if !validChangeSource(source) { return nil, nil, domain.ErrInvalidInput } conflicts := []domain.RevertConflict{} sc := &changeScope{ gardenID: target.GardenID, actorID: actorID, source: source, summary: revertSummary(target), } // A change set may hold several revisions for the SAME entity (an agent turn // that moves a bed and then renames it). Each inverse bumps that row's // version, so from the second one on, the version in the snapshot no longer // matches the live row — and a naive guard would call our own work a conflict. // This tracks what we just wrote so the guard compares against reality. applied := map[entityKey]int64{} for _, r := range planRevert(target.Revisions) { changes, conflict, err := s.applyInverse(ctx, r, applied) if err != nil { // Record what actually landed before surfacing the failure, so the // partial revert is visible and undoable rather than orphaned. if _, cerr := s.commitScope(ctx, sc, &target.ID); cerr != nil { slog.Error("service: partial revert could not be recorded", "error", cerr, "changeSet", changeSetID) } 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 } // entityKey identifies a row across the entity types a revision can name. type entityKey struct { entityType string id int64 } func keyOf(r domain.Revision) entityKey { return entityKey{entityType: r.EntityType, id: r.EntityID} } // planRevert orders a change set's revisions for inverse application, because the // inverses carry foreign-key dependencies the original operations did not. Five // passes, in this order: // // 0. restore deleted OBJECTS — parents first, or a restored plop has no object // to hang off and its FK rejects it; // 1. restore everything else deleted; // 2. undo updates; // 3. delete created non-objects (plantings) — children before parents; // 4. delete created OBJECTS last, so a delete never leans on the cascade to // clean up rows this revert is itself 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. // // applied carries the versions this revert has already written, so a change set // holding several revisions for one entity doesn't mistake its own earlier work // for someone else's edit. func (s *Service) applyInverse(ctx context.Context, r domain.Revision, applied map[entityKey]int64) ([]change, *domain.RevertConflict, error) { switch r.EntityType { case domain.EntityObject: return revertEntity(ctx, s, r, applied, objectRevertOps(s)) case domain.EntityPlanting: return revertEntity(ctx, s, r, applied, plantingRevertOps(s)) case domain.EntityGarden: return revertEntity(ctx, s, r, applied, gardenRevertOps(s)) default: return nil, conflict(r, domain.ConflictUnsupported, ""), nil } } // revertOps is everything reverting one entity type needs. The three // implementations differ only in which store methods they call and how a row // names itself; the ordering, guards and conflict reporting are shared below so // they cannot drift apart. type revertOps[T any] struct { entityType string get func(ctx context.Context, id int64) (*T, error) update func(ctx context.Context, row *T) (*T, error) // restore re-inserts a deleted row under its original id. nil means this // entity type is never deleted (gardens), and a delete revision for it is // reported as unsupported rather than silently skipped. restore func(ctx context.Context, row *T) (*T, error) // remove undoes a creation. It returns the changes it made, because deleting // an object also deletes the plantings hanging off it. remove func(ctx context.Context, row *T) ([]change, error) // beforeRestore fixes up a snapshot whose references may have gone stale // while it sat in history. beforeRestore func(ctx context.Context, row *T) error // blockRemoval optionally refuses a removal, e.g. an object that has gained // plantings since. Returns a conflict reason, or "" to allow it. blockRemoval func(ctx context.Context, row *T) (string, error) version func(row *T) int64 setVersion func(row *T, v int64) label func(row *T) string } // revertEntity applies the inverse of one revision for one entity type. func revertEntity[T any]( ctx context.Context, s *Service, r domain.Revision, applied map[entityKey]int64, ops revertOps[T], ) ([]change, *domain.RevertConflict, error) { key := keyOf(r) // Inverse of a delete is a restore, under the original id. if r.Op == domain.OpDelete { if ops.restore == nil { return nil, conflict(r, domain.ConflictUnsupported, ""), nil } if existing, err := ops.get(ctx, r.EntityID); err == nil { return nil, conflict(r, domain.ConflictExists, ops.label(existing)), nil } else if !errors.Is(err, domain.ErrNotFound) { return nil, nil, err } var target T if err := unsnapshot(r.Before, &target); err != nil { return nil, nil, err } if ops.beforeRestore != nil { if err := ops.beforeRestore(ctx, &target); err != nil { return nil, nil, err } } restored, err := ops.restore(ctx, &target) if err != nil { // The id may have been taken between the check above and this insert. // Re-check rather than surfacing a driver-specific constraint error: // "someone else has that id now" is a conflict, not a failure. if existing, gerr := ops.get(ctx, r.EntityID); gerr == nil { return nil, conflict(r, domain.ConflictExists, ops.label(existing)), nil } return nil, nil, err } applied[key] = ops.version(restored) return []change{changeCreate(ops.entityType, r.EntityID, restored)}, nil, nil } cur, err := ops.get(ctx, r.EntityID) if errors.Is(err, domain.ErrNotFound) { if r.Op == domain.OpCreate { return nil, nil, nil // already gone: the inverse is a no-op, not a conflict } return nil, conflict(r, domain.ConflictMissing, ""), nil } if err != nil { return nil, nil, err } if c := versionGuard(r, ops.version(cur), ops.label(cur), applied[key]); c != nil { return nil, c, nil } // Inverse of a create is a removal. if r.Op == domain.OpCreate { if ops.remove == nil { return nil, conflict(r, domain.ConflictUnsupported, ""), nil } if ops.blockRemoval != nil { reason, err := ops.blockRemoval(ctx, cur) if err != nil { return nil, nil, err } if reason != "" { return nil, conflict(r, reason, ops.label(cur)), nil } } changes, err := ops.remove(ctx, cur) if err != nil { return nil, nil, err } delete(applied, key) return changes, nil, nil } if r.Op != domain.OpUpdate { return nil, conflict(r, domain.ConflictUnsupported, ""), nil } var target T if err := unsnapshot(r.Before, &target); err != nil { return nil, nil, err } ops.setVersion(&target, ops.version(cur)) updated, err := ops.update(ctx, &target) if errors.Is(err, domain.ErrVersionConflict) { return nil, conflict(r, domain.ConflictChanged, ops.label(cur)), nil } if err != nil { return nil, nil, err } applied[key] = ops.version(updated) return []change{changeUpdate(ops.entityType, r.EntityID, cur, updated)}, nil, nil } func objectRevertOps(s *Service) revertOps[domain.GardenObject] { return revertOps[domain.GardenObject]{ entityType: domain.EntityObject, get: s.store.GetObject, update: s.store.UpdateObject, restore: s.store.RestoreObject, remove: s.deleteObjectRecording, // Undoing "added a bed" would cascade away anything planted in it since. // Those plops are snapshotted, so it would be recoverable — but deleting // someone's plants as a side effect of an unrelated undo should be // reported, not performed quietly. blockRemoval: func(ctx context.Context, o *domain.GardenObject) (string, error) { plops, err := s.store.ListPlantingsForObject(ctx, o.ID) if err != nil { return "", err } if len(plops) > 0 { return domain.ConflictChanged, nil } return "", nil }, version: func(o *domain.GardenObject) int64 { return o.Version }, setVersion: func(o *domain.GardenObject, v int64) { o.Version = v }, label: func(o *domain.GardenObject) string { return o.Name }, } } func plantingRevertOps(s *Service) revertOps[domain.Planting] { return revertOps[domain.Planting]{ entityType: domain.EntityPlanting, get: s.store.GetPlanting, update: s.store.UpdatePlanting, restore: s.store.RestorePlanting, // A snapshot can name a seed lot that has since been deleted. The live // rows got their link nulled by ON DELETE SET NULL, but the snapshot // still holds the old id, and restoring it would violate the foreign key // and abort the whole revert. Drop the dangling link instead: the plant // really was in the ground, which is the part worth restoring. beforeRestore: func(ctx context.Context, p *domain.Planting) error { if p.SeedLotID == nil { return nil } if _, err := s.store.GetSeedLot(ctx, *p.SeedLotID); errors.Is(err, domain.ErrNotFound) { p.SeedLotID = nil } else if err != nil { return err } return nil }, remove: func(ctx context.Context, p *domain.Planting) ([]change, error) { if err := s.store.DeletePlanting(ctx, p.ID); err != nil { return nil, err } return []change{changeDelete(domain.EntityPlanting, p.ID, p)}, nil }, version: func(p *domain.Planting) int64 { return p.Version }, setVersion: func(p *domain.Planting, v int64) { p.Version = v }, label: func(p *domain.Planting) string { return "" }, } } // gardenRevertOps has no restore or remove: garden creation and deletion are // never recorded (see the migration), so metadata updates are all that can reach // here, and anything else is honestly reported as unsupported. func gardenRevertOps(s *Service) revertOps[domain.Garden] { return revertOps[domain.Garden]{ entityType: domain.EntityGarden, get: s.store.GetGarden, update: s.store.UpdateGarden, version: func(g *domain.Garden) int64 { return g.Version }, setVersion: func(g *domain.Garden, v int64) { g.Version = v }, label: func(g *domain.Garden) string { return g.Name }, } } // 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. // // appliedVersion is non-zero when THIS revert already wrote to this row, which // happens whenever a change set holds more than one revision for the same entity. // In that case the live version is our own doing and is the correct thing to // expect — comparing against the snapshot would flag our own work as a conflict. func versionGuard(r domain.Revision, currentVersion int64, name string, appliedVersion int64) *domain.RevertConflict { if r.After == nil { return nil } expected := appliedVersion if expected == 0 { var after struct { Version int64 `json:"version"` } if err := json.Unmarshal([]byte(*r.After), &after); err != nil { return conflict(r, domain.ConflictUnsupported, name) } expected = after.Version } if expected != 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 }