Files
pansy/internal/service/revisions.go
T
steve 78a04672b5
Build image / build-and-push (push) Successful in 8s
Seed provenance + inventory: source links and seed lots (#50) (#64)
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-21 05:39:24 +00:00

679 lines
25 KiB
Go

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
}