diff --git a/docs/changes/unreleased/1837-edit-finished-reminder.md b/docs/changes/unreleased/1837-edit-finished-reminder.md new file mode 100644 index 0000000000..173ecc5280 --- /dev/null +++ b/docs/changes/unreleased/1837-edit-finished-reminder.md @@ -0,0 +1,13 @@ +--- +kind: fixed +title: a finished reminder moved later with e wakes again, and a save after its new moment is refused +pr: 1837 +surface: [chat, engine, docs] +invalidates: + - "The edit line `e` puts in the box wrote a one-time moment to the minute, so a reminder made with `in 2m` came back a few seconds earlier and the next minute in the box could be seconds away. It keeps the seconds (`at 2026-10-14T20:33:13`), a moment in the hour a fall-back repeats is written with its offset, and the line saved unedited is the same moment." + - "`Store.Update` saved a new one-time moment that had already passed while the card was up, so the clock said the reminder at once and the row was finished again. It refuses it with `… has already passed` (to the second when it passed in the current minute), and nothing changes. `Store.Create` is unchanged: an add answered late still runs once, late." + - "A typed edit of a paused automation said `saved … · next …` for a slot it does not run at. It says `saved … · still paused — resume starts it again`." + - "The manual did not say a change can be refused at save time, or that a finished reminder can be renamed. `automations.md` has a section for both." + - "A finished reminder's own edit line was refused as `… has already passed` even when only its title changed. Only a line that moves the moment into the past is refused." + - "The typed edit's card worked its next run out from the schedule alone, so a paused one or an unchanged rhythm promised a run the save did not give. Both doors use `automation.NextAfterChange`, the rule the save keeps." +--- diff --git a/docs/design/automations/DESIGN.md b/docs/design/automations/DESIGN.md index 11e8e70a80..45d2b8b90a 100644 --- a/docs/design/automations/DESIGN.md +++ b/docs/design/automations/DESIGN.md @@ -65,9 +65,13 @@ The clauses are `title`, `at` (`18:00`, `tomorrow 18:00`, `2026-10-12T09:00`), `in` (`20m`), `every` (a cron line or an interval), `zone`, `say`, `do`, `look` (a command), `files` (a glob), `until` (the condition), `time` and `usd` (the limits), `folder`, and the flags `once`, `worktree` and `checkout`. An id may be -any unique prefix. A moment that has already passed is refused. Every -automation writes back out as the `edit` line that would make it what it is, so -the line `e` puts in the box changes nothing when it is saved unedited. +any unique prefix. A moment that has already passed is refused when it is +typed, and a change is refused again when its card is saved after its new moment +(`Store.Update`; an add answered late still runs once, late). An edit that leaves +a finished reminder's moment where it was is not refused. Every automation writes back out as the +`edit` line that would make it what it is — a moment with seconds keeps them +(`2026-10-12T09:00:27`) — so the line `e` puts in the box changes nothing when +it is saved unedited. **`/automations`** opens the list: every automation with its mark, title, kind, schedule, next run and last result. `enter` opens its history (every run: when, diff --git a/internal/automation/command.go b/internal/automation/command.go index ab9ec54c60..02a2a4ca2a 100644 --- a/internal/automation/command.go +++ b/internal/automation/command.go @@ -228,8 +228,8 @@ func (c Command) Apply(base Automation, now time.Time) (Automation, error) { a.Workspace = folder } } - if !a.Schedule.Repeats() && !a.Schedule.At.IsZero() && !a.Schedule.At.After(now) && c.touchesWhen() { - return Automation{}, fmt.Errorf("%s has already passed", a.Schedule.At.In(loc).Format("Mon 2 Jan 15:04")) + if !a.Schedule.Repeats() && !a.Schedule.At.IsZero() && !a.Schedule.At.After(now) && c.setsWhen(base, a) { + return Automation{}, errPassed(a.Schedule, now) } if err := a.Validate(); err != nil { return Automation{}, err @@ -237,17 +237,15 @@ func (c Command) Apply(base Automation, now time.Time) (Automation, error) { return a, nil } -// touchesWhen reports whether the line set the schedule, which is the only time -// a moment in the past is the person's mistake rather than the automation's -// history. -func (c Command) touchesWhen() bool { - for _, cl := range c.clauses { - switch cl.word { - case "at", "in", "every", "zone": - return true - } - } - return c.Verb == VerbAdd +// setsWhen reports whether the line put a's moment where it is, which is the +// only time a moment in the past is the person's mistake rather than the +// automation's history. +// +// AN EDIT LINE CARRIES THE MOMENT IT WAS WRITTEN WITH ([CommandLine]), so `at` +// being in it says nothing: a finished reminder renamed keeps a moment that has +// passed, and only a line that moves the moment is refused for where it put it. +func (c Command) setsWhen(base, a Automation) bool { + return c.Verb == VerbAdd || ScheduleChanged(base.Schedule, a.Schedule) } // CommandLine is the `edit` line that makes an automation exactly what it is — @@ -268,7 +266,7 @@ func CommandLine(a Automation) string { if err != nil { loc = time.Local } - parts = append(parts, "at", a.Schedule.At.In(loc).Format(momentLayout)) + parts = append(parts, "at", momentWord(a.Schedule.At.In(loc))) } if look := a.Look; look != nil { switch { @@ -307,6 +305,35 @@ func CommandLine(a Automation) string { // clock in the schedule's zone, to the minute. const momentLayout = "2006-01-02T15:04" +// momentSecondsLayout is a moment written to the second and below. Reading it +// takes any fraction of a second after the seconds, as Go's parsing does. +const momentSecondsLayout = "2006-01-02T15:04:05" + +// momentWord writes a moment the way [parseMoment] reads it back as the same +// instant: to the minute when that is all it has, and otherwise with its +// seconds and their fraction. +// +// A WALL CLOCK THAT NAMES TWO INSTANTS CARRIES ITS OFFSET. In the hour a +// fall-back repeats, `01:30` reads as the first one, so a moment in the second +// is written as RFC 3339 with its offset, which [parseMoment] reads exactly. +// +// A MOMENT WITH SECONDS KEEPS THEM. `in 2m` and the conversation's `when.in` +// make moments with seconds in them, and an edit line written to the minute +// moved the moment it was meant to keep — earlier, so the next minute in the +// box could be seconds away, and a save answered a beat late stored a moment +// that had already passed. +func momentWord(at time.Time) string { + layout := momentSecondsLayout + ".999999999" + if at.Second() == 0 && at.Nanosecond() == 0 { + layout = momentLayout + } + word := at.Format(layout) + if back, err := time.ParseInLocation(layout, word, at.Location()); err != nil || !back.Equal(at) { + return at.Format(time.RFC3339Nano) + } + return word +} + // parseMoment reads a moment the way a person types one: a time of day — // `18:00`, `6pm`, `6:30pm`, `18` — today, or after `today`, `tomorrow` or a // day of the week (`monday 9am`, `fri 17:30`, the next one to come); a date and @@ -317,7 +344,7 @@ func parseMoment(text string, now time.Time, loc *time.Location) (time.Time, err if at, err := time.Parse(time.RFC3339, text); err == nil { return at, nil } - for _, layout := range []string{momentLayout, "2006-01-02 15:04"} { + for _, layout := range []string{momentLayout, momentSecondsLayout, "2006-01-02 15:04", "2006-01-02 15:04:05"} { if at, err := time.ParseInLocation(layout, text, loc); err == nil { return at, nil } diff --git a/internal/automation/command_test.go b/internal/automation/command_test.go index 771ddb9f7f..903f5404fb 100644 --- a/internal/automation/command_test.go +++ b/internal/automation/command_test.go @@ -257,3 +257,119 @@ func TestAFolderUnderHomeIsKeptForTheStoreToRead(t *testing.T) { t.Fatalf("the store kept %q, want this machine's %q", made.Workspace, filepath.Join(house, "app")) } } + +// A MOMENT KEEPS ITS SECONDS ON THE WAY ROUND. `in 2m` and the conversation's +// `when.in` make moments with seconds in them; an edit line written to the +// minute moved the reminder by them, so the box put a different moment in front +// of the person than the one it would run at. +func TestCommandLineKeepsTheSecondsOfAMoment(t *testing.T) { + now, loc := commandNow(t) + for _, at := range []time.Time{ + time.Date(2026, 10, 10, 14, 32, 27, 0, loc), + time.Date(2026, 10, 10, 14, 32, 27, 481_000_000, loc), + time.Date(2026, 10, 10, 14, 33, 0, 0, loc), + } { + original := Automation{ID: "r", Title: "stretch", Workspace: "/work/project", Schedule: Schedule{At: at, Zone: "America/Toronto"}, Action: Action{Say: "stand up"}} + line := CommandLine(original) + cmd, err := ParseCommand(line) + if err != nil { + t.Fatalf("%s: %v", line, err) + } + back, err := cmd.Apply(original, now) + if err != nil { + t.Fatalf("%s: %v", line, err) + } + if !back.Schedule.At.Equal(at) || ScheduleChanged(original.Schedule, back.Schedule) { + t.Fatalf("%s moved the moment %v to %v", line, at, back.Schedule.At) + } + } + if line := CommandLine(Automation{ID: "r", Schedule: Schedule{At: time.Date(2026, 10, 10, 14, 33, 0, 0, loc), Zone: "America/Toronto"}}); !strings.HasSuffix(line, " at 2026-10-10T14:33") { + t.Fatalf("a whole minute is written %q, want it to the minute", line) + } +} + +// A FINISHED REMINDER'S OWN LINE IS NOT A MOMENT IN THE PAST TYPED BY MISTAKE. +// Its moment is its history, so a line that leaves it where it was — a new +// title, a new line to say — reads, and only a moment moved into the past is +// refused. +func TestAnEditThatKeepsAPassedMomentReads(t *testing.T) { + now, loc := commandNow(t) + finished := Automation{ID: "r", Title: "stretch", Workspace: "/work/project", Status: StatusFinished, + Schedule: Schedule{At: now.Add(-3*time.Minute - 27*time.Second), Zone: "America/Toronto"}, Action: Action{Say: "stand up"}} + cmd, err := ParseCommand(CommandLine(finished) + ` title "stretch again"`) + if err != nil { + t.Fatal(err) + } + edited, err := cmd.Apply(finished, now) + if err != nil { + t.Fatalf("retitling a finished reminder: %v", err) + } + if edited.Title != "stretch again" || !edited.Schedule.At.Equal(finished.Schedule.At) { + t.Fatalf("the edit read as %+v", edited) + } + earlier := finished.Schedule.At.In(loc).Add(-time.Hour).Format("2006-01-02T15:04") + cmd, err = ParseCommand("edit r at " + earlier) + if err != nil { + t.Fatal(err) + } + if _, err := cmd.Apply(finished, now); err == nil || !strings.Contains(err.Error(), "already passed") { + t.Fatalf("a moment moved into the past = %v, want it refused", err) + } +} + +// A MOMENT IN THE HOUR A FALL-BACK REPEATS KEEPS WHICH ONE IT IS. Toronto's +// clocks go from 01:59 EDT back to 01:00 EST on Sunday 1 November 2026, so +// `01:30` names two instants; the line carries the offset for the second. +func TestAMomentInARepeatedHourRoundTrips(t *testing.T) { + _, loc := commandNow(t) + second := time.Date(2026, 11, 1, 6, 30, 0, 0, time.UTC).In(loc) // 01:30 EST + first := time.Date(2026, 11, 1, 5, 30, 0, 0, time.UTC).In(loc) // 01:30 EDT + for _, c := range []struct { + at, now time.Time + status Status + more string + }{ + // Saved unedited while it is still to come. + {second, second.Add(-3 * time.Hour), StatusActive, ""}, + {first, first.Add(-3 * time.Hour), StatusActive, ""}, + // Renamed after it was said. + {second, second.Add(time.Hour), StatusFinished, ` title "stretch again"`}, + } { + original := Automation{ID: "r", Title: "stretch", Workspace: "/work/project", Status: c.status, + Schedule: Schedule{At: c.at, Zone: "America/Toronto"}, Action: Action{Say: "stand up"}} + line := CommandLine(original) + c.more + cmd, err := ParseCommand(line) + if err != nil { + t.Fatalf("%s: %v", line, err) + } + back, err := cmd.Apply(original, c.now) + if err != nil { + t.Fatalf("%s: %v", line, err) + } + if !back.Schedule.At.Equal(c.at) { + t.Fatalf("%s moved the moment %v to %v", line, c.at, back.Schedule.At) + } + } + if line := CommandLine(Automation{ID: "r", Schedule: Schedule{At: first, Zone: "America/Toronto"}}); !strings.HasSuffix(line, " at 2026-11-01T01:30") { + t.Fatalf("the first 01:30 is written %q, want the plain wall clock", line) + } +} + +// A MOMENT THAT PASSED IN THIS VERY MINUTE IS SAID TO THE SECOND, so the +// refusal does not read as 20:33 passing at 20:33. +func TestAMomentPassedThisMinuteIsSaidToTheSecond(t *testing.T) { + now, loc := commandNow(t) + now = now.Add(5 * time.Second) + base := Automation{ID: "r", Title: "stretch", Workspace: "/work/project", Schedule: Schedule{At: now.Add(time.Hour), Zone: "America/Toronto"}, Action: Action{Say: "stand up"}} + cmd, err := ParseCommand("edit r at " + now.In(loc).Format("15:04")) + if err != nil { + t.Fatal(err) + } + if _, err := cmd.Apply(base, now); err == nil || !strings.Contains(err.Error(), "14:30:00 has already passed") { + t.Fatalf("a moment passed this minute = %v", err) + } + cmd, _ = ParseCommand("edit r at 14:29") + if _, err := cmd.Apply(base, now); err == nil || !strings.HasSuffix(err.Error(), "14:29 has already passed") { + t.Fatalf("a moment passed before this minute = %v", err) + } +} diff --git a/internal/automation/store.go b/internal/automation/store.go index ceedc9ca7e..6224fd85ee 100644 --- a/internal/automation/store.go +++ b/internal/automation/store.go @@ -313,7 +313,9 @@ func (s *Store) List() ([]Automation, error) { // Update saves the person's change to an automation's own half — its title, // schedule, look, action, place and limits. Its status is left alone (that is // [Store.SetStatus]'s door). A changed schedule is worked out again from now, -// and a finished one-time automation given a new moment wakes again. A watch +// a finished one-time automation given a new moment wakes again, and a new +// moment that has already passed is refused ([errPassed]) — a zone changed +// alone too, which moves a one-time moment's instant as much as a new one. A watch // whose look or condition changed forgets what it had seen, so its first new // "yes" speaks. func (s *Store) Update(a Automation) (Automation, error) { @@ -339,9 +341,14 @@ func (s *Store) Update(a Automation) (Automation, error) { a.Seen, a.Memo = "", "" } if ScheduleChanged(current.Schedule, a.Schedule) { - if a.Schedule.Repeats() && a.Schedule.Interval() > 0 { - a.Schedule.Anchor = now + // A MOMENT THAT PASSED WHILE THE CARD WAS UP IS REFUSED, not saved as + // the next run: the clock would take it at once and the reminder the + // person had just moved later would be finished again a beat after + // they saved it. + if !a.Schedule.Repeats() && !a.Schedule.At.After(now) { + return errPassed(a.Schedule, now) } + a.Schedule = rescheduled(a.Schedule, now) first, err := a.Schedule.First(now) if err != nil { return err @@ -1006,6 +1013,49 @@ func ScheduleChanged(a, b Schedule) bool { return !a.At.Equal(b.At) || strings.TrimSpace(a.Every) != strings.TrimSpace(b.Every) || a.Zone != b.Zone } +// NextAfterChange is the next run [Store.Update] gives stored saved as changed +// at now. It is the ONE reading a change's card says its next run by, through +// either door — the conversation's change (internal/session's +// automation_tool.go) and the typed edit (internal/tui3's automationscmd.go) — +// so a card cannot promise a next run the save does not give: a new schedule +// worked out again from now, the next run it already has for an unchanged one +// (none for a finished one), and none while it is paused, because a paused +// automation does not run whatever its schedule says. +func NextAfterChange(stored, changed Automation, now time.Time) (time.Time, error) { + switch { + case stored.Status == StatusPaused: + return time.Time{}, nil + case !ScheduleChanged(stored.Schedule, changed.Schedule): + return stored.Next, nil + } + return rescheduled(changed.Schedule, now).First(now) +} + +// rescheduled is a new schedule as [Store.Update] keeps it: an interval counts +// from the moment it was changed. +func rescheduled(s Schedule, now time.Time) Schedule { + if s.Repeats() && s.Interval() > 0 { + s.Anchor = now + } + return s +} + +// errPassed refuses a one-time moment that is not after now, said in the +// schedule's own zone. A moment in now's own minute is said to the second: +// `20:33 has already passed` read at 20:33:05 looks like a clock disagreeing +// with itself. +func errPassed(s Schedule, now time.Time) error { + loc, err := s.location() + if err != nil { + loc = time.Local + } + layout := "Mon 2 Jan 15:04" + if s.At.Truncate(time.Minute).Equal(now.Truncate(time.Minute)) { + layout += ":05" + } + return fmt.Errorf("%s has already passed", s.At.In(loc).Format(layout)) +} + func newID() string { var raw [8]byte if _, err := rand.Read(raw[:]); err != nil { diff --git a/internal/automation/store_test.go b/internal/automation/store_test.go index 07ee4c3805..66dd081b4b 100644 --- a/internal/automation/store_test.go +++ b/internal/automation/store_test.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "strconv" + "strings" "testing" "time" ) @@ -539,3 +540,54 @@ func TestAHistoryKeepsItsNewestRunsAndTheirFolders(t *testing.T) { t.Fatalf("deleting the automation removed a worktree: %v", err) } } + +// A FINISHED REMINDER MOVED LATER WAKES AGAIN, and a save that comes after the +// new moment has passed is refused. A card answered late used to store the +// passed moment as the next run, so the clock said the reminder at once and the +// row was finished again a beat after the person saved it. +func TestAFinishedReminderMovedLaterWakesAndALateSaveIsRefused(t *testing.T) { + s, now := openStore(t) + start := *now + finished := func() Automation { + t.Helper() + *now = start + r, err := s.Create(reminder(start.Add(2*time.Minute + 27*time.Second))) + if err != nil { + t.Fatal(err) + } + *now = r.Schedule.At.Add(3 * time.Second) + if _, err := s.Take(r, *now); err != nil { + t.Fatal(err) + } + got, _ := s.Get(r.ID) + if got.Status != StatusFinished { + t.Fatalf("a taken reminder is %q", got.Status) + } + return got + } + + moved := finished() + moved.Schedule.At = now.Add(10 * time.Minute).Truncate(time.Minute) + saved, err := s.Update(moved) + if err != nil { + t.Fatal(err) + } + if saved.Status != StatusActive || !saved.Next.Equal(moved.Schedule.At) { + t.Fatalf("a finished reminder moved later is %q, next %v, want active at %v", saved.Status, saved.Next, moved.Schedule.At) + } + + // The card went up with the next minute — 30 seconds away — and was + // answered 5 seconds after it. + late := finished() + late.Schedule.At = late.Schedule.At.Truncate(time.Minute).Add(time.Minute) + *now = late.Schedule.At.Add(5 * time.Second) + if _, err := s.Update(late); err == nil || !strings.Contains(err.Error(), "already passed") { + t.Fatalf("a moment that passed while the card was up was saved: %v", err) + } + if due, _ := s.Due(*now); len(due) != 0 { + t.Fatalf("a refused save left the reminder due: %+v", due) + } + if got, _ := s.Get(late.ID); got.Status != StatusFinished || got.Revision != late.Revision { + t.Fatalf("a refused save changed the reminder: %+v", got) + } +} diff --git a/internal/manual/chat/automations.md b/internal/manual/chat/automations.md index 6d09a6cde7..552af249be 100644 --- a/internal/manual/chat/automations.md +++ b/internal/manual/chat/automations.md @@ -285,6 +285,24 @@ A watch that looks through a tool is written without its look in the typed line tool's arguments are not something anybody types on one line — and the edit leaves that look as it was. +## A change that was not saved, or a reminder still finished — has already passed, a card answered too late, and renaming a finished reminder + +A one-time moment has to be still to come **when the card is saved**, not only when it is +asked for. Move a reminder to 20:34 and answer the card at 20:34:05 and nothing is changed: +a typed edit says `Wed 14 Oct 20:34:00 has already passed` and its card ends `not saved`; +asked in the conversation, I am told `nothing was changed: Wed 14 Oct 20:34:00 has already +passed`. The reminder stays as it was — finished, if it had already been said — so give it +a moment still to come and it wakes again. A moment that passed in the current minute is +said to the second; an older one, as `Wed 14 Oct 20:30 has already passed`. + +A finished reminder can be renamed, or given a new line to say, without moving it: the line +`e` puts in the box keeps the moment it has, with its seconds when it has them +(`at 2026-10-14T20:33:13`), and only a moment moved into the past is refused. It stays +finished, with no next run, until a change gives it a moment still to come. + +A paused automation saved with a change stays paused: its card shows no next run, and a +typed save says `saved weekly update · still paused — resume starts it again`. + ## History — every run, read a run's whole result, why did I get this reminder, and open where it was asked `enter` on a row of `/automations` opens its history. The top says what it is — its title @@ -572,7 +590,7 @@ A line that cannot be read says why and saves nothing: - `add needs a title first: add "weekly update" every "0 9 * * 1" do "…"` - `unknown word "daily" — the words are title, at, in, every, zone, say, do, look, files, until, time, usd, folder, once, worktree, checkout` - `cannot read the moment "someday" — say 18:00, 6pm, tomorrow 9am, monday 9am or 2026-10-12T09:00` -- `Sat 10 Oct 09:00 has already passed` +- `Sat 10 Oct 09:00 has already passed` — when the line is read, and again for an edit whose card is saved after its new moment - `a watch needs a rhythm to look on` · `a watch needs the condition it is looking for` - `an automation needs something to say or something to do` diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index 3bb0556a01..c0524e43d1 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -2411,6 +2411,11 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { {"can you run something every monday morning", "automations"}, {"does it keep working when I close the terminal", "automations"}, {"how do I stop a reminder", "automations"}, + // A change refused at save time, and a finished reminder renamed: asked + // by somebody who moved a reminder and found it still finished. + {"I moved my reminder later but it still says finished", "automations"}, + {"why was my change to the reminder not saved", "automations"}, + {"can I rename a finished reminder", "automations"}, // The two clocks, asked apart: how often a watch looks, which is its own // rhythm now rather than one figure for everything, and the rhythm // somebody names when they want the thing to stop. diff --git a/internal/session/automation_tool.go b/internal/session/automation_tool.go index c838e05091..769ed1c843 100644 --- a/internal/session/automation_tool.go +++ b/internal/session/automation_tool.go @@ -414,19 +414,11 @@ func (a *Agent) automationChange(ctx context.Context, store *automation.Store, p if err := item.Validate(); err != nil { return "Invalid arguments: " + err.Error(), true, nil } - // THE CARD'S NEXT RUN IS THE ONE THE SAVE WILL GIVE IT: worked out again from - // now for a new schedule, the one it already has otherwise — none for a - // finished one — and none while it is paused, because a paused automation - // does not run whatever its schedule says. - var next time.Time - switch { - case item.Status == automation.StatusPaused: - case rescheduled: - if next, err = item.Schedule.First(now); err != nil { - return "Invalid arguments: " + err.Error(), true, nil - } - default: - next = stored.Next + // THE CARD'S NEXT RUN IS THE ONE THE SAVE WILL GIVE IT, by the one rule + // both doors keep ([automation.NextAfterChange]). + next, err := automation.NextAfterChange(stored, item, now) + if err != nil { + return "Invalid arguments: " + err.Error(), true, nil } whenWords := automation.Describe(item.Schedule, now) if rescheduled { diff --git a/internal/tui3/automationscmd.go b/internal/tui3/automationscmd.go index d73ad56595..ea0f1d8fc7 100644 --- a/internal/tui3/automationscmd.go +++ b/internal/tui3/automationscmd.go @@ -17,6 +17,7 @@ package tui3 import ( "strings" + "time" tea "charm.land/bubbletea/v2" @@ -48,6 +49,8 @@ const ( typedReason = "you typed it, so nothing is saved until you say so" typedNoAutoWord = "no automation has the id " typedSavedWord = "saved " + // typedStillPaused is a paused automation's receipt in place of a next run. + typedStillPaused = "still paused — resume starts it again" ) // automationsCommand is `/automations` and everything after it. @@ -70,12 +73,14 @@ func (a *app) automationsCommand(rest string) tea.Cmd { Schedule: automation.Schedule{Zone: a.autos.Zone}, Origin: automation.Origin{Transcript: a.file}, } - item, err := cmd.Apply(base, a.now()) + now := a.now() + item, err := cmd.Apply(base, now) if err != nil { a.note(err.Error()) return nil } - a.confirmTypedAutomation(item, false) + next, _ := item.Schedule.First(now) + a.confirmTypedAutomation(item, next, false) return nil } item, ok := a.automationByID(cmd.ID) @@ -85,12 +90,21 @@ func (a *app) automationsCommand(rest string) tea.Cmd { } switch cmd.Verb { case automation.VerbEdit: - edited, err := cmd.Apply(item, a.now()) + now := a.now() + edited, err := cmd.Apply(item, now) if err != nil { a.note(err.Error()) return nil } - a.confirmTypedAutomation(edited, true) + // THE CARD'S NEXT RUN IS THE ONE THE SAVE WILL GIVE IT, by the rule the + // conversation's change keeps: none for a finished one whose moment + // stays, none while paused, the one it has for a schedule left alone. + next, err := automation.NextAfterChange(item, edited, now) + if err != nil { + a.note(err.Error()) + return nil + } + a.confirmTypedAutomation(edited, next, true) return nil case automation.VerbRun: return a.runAutomationNow(item) @@ -151,10 +165,10 @@ func (a *app) automationByID(id string) (automation.Automation, bool) { // an automation by. const automationIDPrefixMin = 4 -// confirmTypedAutomation draws the card a typed add or edit ends on, and asks. -func (a *app) confirmTypedAutomation(item automation.Automation, edit bool) { +// confirmTypedAutomation draws the card a typed add or edit ends on, with the +// next run the save will give it, and asks. +func (a *app) confirmTypedAutomation(item automation.Automation, next time.Time, edit bool) { now := a.now() - next, _ := item.Schedule.First(now) a.typedSeq++ notice := session.AutomationNotice{ ID: typedAutomationID(a.typedSeq), @@ -239,8 +253,13 @@ func (a *app) typedAutomationAnswered(card *automationCard, item automation.Auto a.touch() return nil } + // A PAUSED ONE KEEPS A NEXT SLOT IT DOES NOT RUN AT, so the receipt + // says it is still paused, as its card said no next run. receipt := typedSavedWord + strings.TrimSpace(saved.Title) - if !saved.Next.IsZero() { + switch { + case saved.Status == automation.StatusPaused: + receipt += " · " + typedStillPaused + case !saved.Next.IsZero(): receipt += " · " + autoNextWord + automation.Moment(saved.Next, now) } a.note(receipt) diff --git a/internal/tui3/automationsplace_test.go b/internal/tui3/automationsplace_test.go index 2df5dcf28c..87c937c760 100644 --- a/internal/tui3/automationsplace_test.go +++ b/internal/tui3/automationsplace_test.go @@ -3,6 +3,7 @@ package tui3 import ( "os" "path/filepath" + "regexp" "strings" "sync" "testing" @@ -440,3 +441,149 @@ func TestAHostedWindowsTypedAutomationRunsInTheFarHome(t *testing.T) { t.Fatalf("a window on this machine sends %q", got) } } + +// THE OWNER'S WALK: a reminder made with `in 2m` says its line and is finished; +// `e` on its row puts its line in the box, the moment is moved to the next +// minute, and the card is answered a beat after that minute came. That save +// used to store the passed moment as the next run, so the clock said the +// reminder at once and the row was finished again. It is refused now, and a +// moment moved properly later wakes the reminder with that moment next. +func TestAFinishedReminderEditedLaterWakesAgainAndALateSaveSaysSo(t *testing.T) { + a, store := automationLab(t) + now := time.Date(2026, 10, 14, 20, 31, 13, 0, time.UTC) + a.clock = func() time.Time { return now } + store.SetClock(func() time.Time { return now }) + finished := func() automation.Automation { + t.Helper() + drive(t, a, runCmd(a.automationsCommand(`add stretch in 2m say "stand up"`))...) + harnessSettled(t, a) + drive(t, a, key(session.AutomationSaveKey)) + all, _ := store.List() + var made automation.Automation + for _, item := range all { + if item.Status == automation.StatusActive { + made = item + } + } + now = made.Schedule.At.Add(3 * time.Second) + if _, err := store.Take(made, now); err != nil { + t.Fatal(err) + } + readAutomationsNow(t, a) + got, _ := store.Get(made.ID) + if got.Status != automation.StatusFinished { + t.Fatalf("the said reminder is %q", got.Status) + } + return got + } + // editTo presses `e` on the reminder's row and sends its line with the + // moment moved to at. + editTo := func(item automation.Automation, at time.Time) { + t.Helper() + runCmd(a.openAutomationsAt(item.ID)) + drive(t, a, key("right"), key("e")) + box := a.input.String() + line := regexp.MustCompile(` at [0-9T:.\-]+ `).ReplaceAllString(box, " at "+at.Format("2006-01-02T15:04")+" ") + if line == box { + t.Fatalf("the box holds no moment to move: %q", box) + } + a.input.reset() + drive(t, a, runCmd(a.automationsCommand(strings.TrimPrefix(line, "/"+placeAutomationsWord+" ")))...) + if a.auto == nil || a.auto.settled() || a.auto.head != typedEditHead { + t.Fatalf("the edit raised no card: %+v (%s)", a.auto, homeNotes(a)) + } + if !a.auto.notice.Next.Equal(at) { + t.Fatalf("the card says the next run is %v, want %v", a.auto.notice.Next, at) + } + harnessSettled(t, a) + } + + late := finished() + moment := late.Schedule.At.Truncate(time.Minute).Add(time.Minute) + editTo(late, moment) + now = moment.Add(5 * time.Second) + drive(t, a, key(session.AutomationSaveKey)) + if due, _ := store.Due(now); len(due) != 0 { + t.Fatalf("a save answered after its moment left the reminder due at once: %+v", due) + } + if got, _ := store.Get(late.ID); got.Status != automation.StatusFinished { + t.Fatalf("a refused save left the reminder %q", got.Status) + } + if notes := homeNotes(a); !strings.Contains(notes, "already passed") { + t.Fatalf("the refusal does not say why: %q", notes) + } + if a.auto.verdict != autoNotSaved { + t.Fatalf("the late card settled as %q", a.auto.verdict) + } + + moved := finished() + moment = now.Add(10 * time.Minute).Truncate(time.Minute) + editTo(moved, moment) + drive(t, a, key(session.AutomationSaveKey)) + got, _ := store.Get(moved.ID) + if got.Status != automation.StatusActive || !got.Next.Equal(moment) { + t.Fatalf("a finished reminder moved later is %q, next %v, want active at %v", got.Status, got.Next, moment) + } +} + +// AN EDIT'S CARD PROMISES THE NEXT RUN THE SAVE GIVES — the rule the +// conversation's change keeps ([automation.NextAfterChange]): none for a +// finished reminder whose moment stays, none while paused, and the one it has +// for a rhythm the line did not change. +func TestAnEditsCardSaysTheNextRunTheSaveGives(t *testing.T) { + a, store := automationLab(t, labWork("weekly update", "0 9 * * 1"), labWatch("ci on main"), labReminder("leave")) + byTitle := map[string]automation.Automation{} + for _, item := range a.watch.list { + byTitle[item.Title] = item + } + if _, err := store.SetStatus(byTitle["weekly update"].ID, automation.StatusPaused); err != nil { + t.Fatal(err) + } + reminder := byTitle["leave"] + if _, err := store.Take(reminder, reminder.Schedule.At.Add(time.Second)); err != nil { + t.Fatal(err) + } + readAutomationsNow(t, a) + for _, item := range a.watch.list { + byTitle[item.Title] = item + } + cases := []struct { + title, more string + want time.Time + }{ + {"weekly update", "", time.Time{}}, + // A paused rhythm given a new one still runs at none, and its receipt + // says it is paused rather than the slot the store keeps for it. + {"weekly update renamed", ` every "0 10 * * 1"`, time.Time{}}, + {"leave", "", time.Time{}}, + {"ci on main", "", byTitle["ci on main"].Next}, + } + for _, c := range cases { + item := byTitle[c.title] + drive(t, a, runCmd(a.automationsCommand(automation.CommandLine(item)+` title "`+c.title+` renamed"`+c.more))...) + if a.auto == nil || a.auto.settled() { + t.Fatalf("%s: the edit raised no card (%s)", c.title, homeNotes(a)) + } + if !a.auto.notice.Next.Equal(c.want) { + t.Fatalf("%s: the card says the next run is %v, want %v", c.title, a.auto.notice.Next, c.want) + } + harnessSettled(t, a) + drive(t, a, key(session.AutomationSaveKey)) + saved, _ := store.Get(item.ID) + if saved.Title != c.title+" renamed" { + t.Fatalf("%s: saving the card stored %+v", c.title, saved) + } + if saved.Status == automation.StatusActive && !saved.Next.Equal(c.want) { + t.Fatalf("%s: the save gave next %v, the card said %v", c.title, saved.Next, c.want) + } + byTitle[saved.Title] = saved + if saved.Status != automation.StatusPaused { + continue + } + notes := homeNotes(a) + receipt := notes[strings.LastIndex(notes, typedSavedWord):] + if !strings.Contains(receipt, typedStillPaused) || strings.Contains(receipt, autoNextWord) { + t.Fatalf("%s: a paused save's receipt is %q, want it still paused and no next run", c.title, receipt) + } + } +}