issue 1858: handle, unhandle and remind an item, each with its own line (1858.2)

ADR 0517 §1, §2, §4, §5: an item is a task, a promise or the mail itself; each act and its action line are
kept in one transaction, a refused or failed act writes none, an undo lasts until the day ends and a
reminder shows the item again the next day (Europe/Brussels). The seat's serves list in mesh-mail's
manifest names the new verbs.
This commit is contained in:
jochen
2026-10-22 19:51:54 +02:00
parent fdd56d2259
commit a5ffe4eea7
9 changed files with 415 additions and 5 deletions
@@ -0,0 +1,32 @@
package main
// The operator's days (design 63 §2, novox/hq ADR 0517 §1, §2): today, this week and the day an undo ends are
// days in the operator's own time zone, Europe/Brussels, whatever the machine's own zone is. The zone's rules
// are built into the binary (time/tzdata), so a machine without a zone database answers the same.
import (
"time"
_ "time/tzdata"
)
// Brussels is the operator's time zone.
var Brussels = mustZone("Europe/Brussels")
func mustZone(name string) *time.Location {
loc, err := time.LoadLocation(name)
if err != nil {
panic(err) // time/tzdata carries it: never reached
}
return loc
}
// dayOf is the operator's day of t, as midnight UTC of that date: the same form a date column answers.
func dayOf(t time.Time) time.Time {
y, m, d := t.In(Brussels).Date()
return time.Date(y, m, d, 0, 0, 0, 0, time.UTC)
}
// endOfWeek is the last day of the week of day (a dayOf): its Sunday.
func endOfWeek(day time.Time) time.Time {
return day.AddDate(0, 0, (7-int(day.Weekday()))%7)
}
@@ -0,0 +1,145 @@
package main
// Issue 1858 (novox/hq ADR 0517 §1, §2, §4, §5): handle, unhandle and remind, item by item, each writing its
// one action line in the same transaction as its act; an act that fails writes none.
import (
"context"
"strings"
"testing"
"time"
)
// callTool calls a tool by its name through Tools(), so a test names a verb before it exists and fails on
// that, not on a compile error.
func callTool(t *testing.T, b *Bundle, name string, args map[string]any) (any, error) {
t.Helper()
for _, tool := range b.Tools() {
if tool.Name == name {
return tool.Run(args)
}
}
t.Fatalf("the bundle serves no %s", name)
return nil, nil
}
// brusselsAt is a time on the operator's own clock (design 63 §2).
func brusselsAt(year int, month time.Month, day, hour, minute int) time.Time {
return time.Date(year, month, day, hour, minute, 0, 0, Brussels)
}
// listBundle is a bundle over store whose clock reads *now.
func listBundle(store Store, now *time.Time) *Bundle {
b := testBundle(store, "mesh-mail-web")
b.Now = func() time.Time { return *now }
return b
}
func lines(t *testing.T, store Store) []ActionLine {
t.Helper()
out, err := store.Actions(context.Background(), 1000)
if err != nil {
t.Fatal(err)
}
return out
}
// 1858/AC4: handle, unhandle and remind each write exactly one line — what, mail id, item, mode, when — and
// change the item's handling.
func TestHandleUnhandleAndRemindEachWriteOneLine(t *testing.T) {
ctx := context.Background()
store := newMemStore()
id := entryWithItems(t, store)
now := brusselsAt(2026, 10, 22, 9, 15)
b := listBundle(store, &now)
if _, err := callTool(t, b, HandleTool, map[string]any{"id": float64(id), "item": "task 2", "mode": "work"}); err != nil {
t.Fatal(err)
}
hs, _ := store.Handlings(ctx)
if len(hs) != 1 || hs[0].Entry != id || hs[0].Item != "task 2" || !hs[0].Handled.Equal(now) {
t.Fatalf("handlings = %+v, want task 2 handled at %s", hs, now)
}
got := lines(t, store)
if len(got) != 1 || got[0].What != ActHandled || got[0].Entry != id || got[0].Item != "task 2" || got[0].Mode != "work" || !got[0].At.Equal(now) {
t.Fatalf("lines = %+v, want one: handled, %d, task 2, work, %s", got, id, now)
}
now = now.Add(time.Minute)
if _, err := callTool(t, b, UnhandleTool, map[string]any{"id": float64(id), "item": "task 2", "mode": "work"}); err != nil {
t.Fatal(err)
}
if hs, _ := store.Handlings(ctx); len(hs) != 0 {
t.Fatalf("handlings after unhandle = %+v, want none", hs)
}
if got := lines(t, store); len(got) != 2 || got[0].What != ActUndone || got[0].Item != "task 2" {
t.Fatalf("lines = %+v, want undone newest", got)
}
if _, err := callTool(t, b, RemindTool, map[string]any{"id": float64(id), "mode": "personal"}); err != nil {
t.Fatal(err)
}
hs, _ = store.Handlings(ctx)
tomorrow := time.Date(2026, 10, 23, 0, 0, 0, 0, time.UTC)
if len(hs) != 1 || hs[0].Item != "mail" || !hs[0].ShowsAgain.Equal(tomorrow) || !hs[0].Handled.IsZero() {
t.Fatalf("handlings after remind = %+v, want the mail shown again on 2026-10-23", hs)
}
if got := lines(t, store); len(got) != 3 || got[0].What != ActReminded || got[0].Item != "mail" || got[0].Mode != "personal" {
t.Fatalf("lines = %+v, want reminded newest", got)
}
}
// 1858/AC4: an act that fails — refused, or failing in the store — writes no line.
func TestAFailedActWritesNoLine(t *testing.T) {
store := newMemStore()
id := entryWithItems(t, store)
now := brusselsAt(2026, 10, 22, 9, 15)
b := listBundle(store, &now)
for _, tc := range []struct {
name, verb string
args map[string]any
why string
}{
{"no such item", HandleTool, map[string]any{"id": float64(id), "item": "task 3"}, "task 3"},
{"no such entry", HandleTool, map[string]any{"id": float64(id + 99)}, "no mail entry"},
{"not an item", RemindTool, map[string]any{"id": float64(id), "item": "the subject"}, "item"},
{"not a mode", HandleTool, map[string]any{"id": float64(id), "mode": "holiday"}, "mode"},
{"undo what is not handled", UnhandleTool, map[string]any{"id": float64(id), "item": "promise 1"}, "not handled"},
} {
t.Run(tc.name, func(t *testing.T) {
_, err := callTool(t, b, tc.verb, tc.args)
if err == nil || !strings.Contains(err.Error(), tc.why) {
t.Fatalf("%s %v: %v, want a refusal naming %q", tc.verb, tc.args, err, tc.why)
}
if got := lines(t, store); len(got) != 0 {
t.Fatalf("lines = %+v, want none", got)
}
})
}
store.failAct = errStoreFake
if _, err := callTool(t, b, HandleTool, map[string]any{"id": float64(id)}); err == nil {
t.Fatal("handle with a failing store: no error")
}
if hs, _ := store.Handlings(context.Background()); len(hs) != 0 || len(lines(t, store)) != 0 {
t.Fatalf("a failed act kept %+v and wrote %+v", hs, lines(t, store))
}
}
// 1858/AC2 (undo lasts the day): an item handled yesterday is not undone today.
func TestUndoLastsUntilTheDayEnds(t *testing.T) {
store := newMemStore()
id := entryWithItems(t, store)
now := brusselsAt(2026, 10, 22, 23, 50)
b := listBundle(store, &now)
if _, err := callTool(t, b, HandleTool, map[string]any{"id": float64(id), "item": "task 1"}); err != nil {
t.Fatal(err)
}
now = brusselsAt(2026, 10, 23, 0, 5)
if _, err := callTool(t, b, UnhandleTool, map[string]any{"id": float64(id), "item": "task 1"}); err == nil ||
!strings.Contains(err.Error(), "until the day ends") {
t.Fatalf("undo the next day: %v, want a refusal saying undo lasts until the day ends", err)
}
if got := lines(t, store); len(got) != 1 {
t.Fatalf("lines = %+v, want the handled line alone", got)
}
}
@@ -0,0 +1,185 @@
package main
// The operator's list and what the operator does with it (novox/hq ADR 0517 §1 to §5, issue 1858): handle,
// unhandle and remind an item — a task, a promise, or the mail itself — each writing its one action line in
// the same transaction as its act (Store.Act); list, the operator's list for a mode (list.go); record, a line
// for an act that changes no entry; and actions, the action log. Each answers only a caller the setting
// callers names, like every mail-reading verb. A refused act writes nothing.
import (
"context"
"fmt"
"time"
)
// ItemMail is the item that is the mail itself; "task n" and "promise n" are its tasks and promises.
const ItemMail = "mail"
// Modes are the page's modes (ADR 0515 §4): which accounts each shows is mesh-mail-web's setting `modes`.
var Modes = []string{"work", "personal", "everything"}
// itemArg reads the item an act concerns: the mail itself when left out.
func itemArg(args map[string]any) (string, error) {
v, has := args["item"]
if !has || v == nil {
return ItemMail, nil
}
s, _ := v.(string)
for _, known := range []string{ItemMail, "task 1", "task 2", "task 3", "promise 1", "promise 2", "promise 3"} {
if s == known {
return s, nil
}
}
return "", fmt.Errorf("the item %q is none of mail, task 1 to 3, promise 1 to 3", s)
}
// modeArg reads the mode the page is in: everything when left out.
func modeArg(args map[string]any) (string, error) {
v, has := args["mode"]
if !has || v == nil {
return "everything", nil
}
s, _ := v.(string)
for _, known := range Modes {
if s == known {
return s, nil
}
}
return "", fmt.Errorf("the mode %q is none of work, personal, everything", s)
}
// holdsItem says whether the entry holds the item: the mail always, a task or a promise by its number.
func holdsItem(e Entry, item string) bool {
if item == ItemMail {
return true
}
var n int
if _, err := fmt.Sscanf(item, "task %d", &n); err == nil {
return hasTask(e, n)
}
if _, err := fmt.Sscanf(item, "promise %d", &n); err == nil {
return hasPromise(e, n)
}
return false
}
// itemAct is what handle, unhandle and remind share: the caller checked, the arguments read, the entry and
// the item found, and the item's handling as it stands.
type itemAct struct {
store Store
entry Entry
item string
mode string
current Handling
}
func (b *Bundle) startItemAct(verb string, args map[string]any) (itemAct, error) {
if _, err := b.checkCaller(args); err != nil {
return itemAct{}, err
}
if err := only(args, "id", "item", "mode"); err != nil {
return itemAct{}, err
}
id, err := number(args, "id", 0)
if err != nil {
return itemAct{}, err
}
if id <= 0 {
return itemAct{}, fmt.Errorf("%s needs id", verb)
}
item, err := itemArg(args)
if err != nil {
return itemAct{}, err
}
mode, err := modeArg(args)
if err != nil {
return itemAct{}, err
}
ctx := context.Background()
store, err := b.Store(ctx)
if err != nil {
return itemAct{}, err
}
entry, ok, err := store.Entry(ctx, int64(id))
if err != nil {
return itemAct{}, err
}
if !ok {
return itemAct{}, fmt.Errorf("%s: no mail entry %d is held; list answers the ids held", verb, id)
}
if !holdsItem(entry, item) {
return itemAct{}, fmt.Errorf("%s: the mail entry %d holds no %s", verb, id, item)
}
a := itemAct{store: store, entry: entry, item: item, mode: mode, current: Handling{Entry: entry.ID, Item: item}}
handlings, err := store.Handlings(ctx)
if err != nil {
return itemAct{}, err
}
for _, h := range handlings {
if h.Entry == entry.ID && h.Item == item {
a.current = h
}
}
return a, nil
}
// act keeps the item's new handling and the act's line, in one transaction (Store.Act).
func (a itemAct) act(verb, what string, h Handling, at time.Time) (any, error) {
err := a.store.Act(context.Background(), h, ActionLine{What: what, Entry: a.entry.ID, Item: a.item, Mode: a.mode, At: at})
if err == ErrNotFound {
return nil, fmt.Errorf("%s: no mail entry %d is held; list answers the ids held", verb, a.entry.ID)
}
if err != nil {
return nil, err
}
return map[string]any{what: map[string]any{"id": a.entry.ID, "item": a.item}}, nil
}
// handleTool marks an item handled (ADR 0517 §1): the mail itself handles all its items with it.
func (b *Bundle) handleTool(args map[string]any) (any, error) {
a, err := b.startItemAct(HandleTool, args)
if err != nil {
return nil, err
}
if !a.current.Handled.IsZero() {
return nil, fmt.Errorf("%s: %s of mail entry %d is handled already", HandleTool, a.item, a.entry.ID)
}
now := b.now()
h := a.current
h.Handled = now
return a.act(HandleTool, ActHandled, h, now)
}
// unhandleTool undoes a handling of today (ADR 0517 §1: the undo lasts until the day ends).
func (b *Bundle) unhandleTool(args map[string]any) (any, error) {
a, err := b.startItemAct(UnhandleTool, args)
if err != nil {
return nil, err
}
now := b.now()
if a.current.Handled.IsZero() {
return nil, fmt.Errorf("%s: %s of mail entry %d is not handled", UnhandleTool, a.item, a.entry.ID)
}
if !dayOf(a.current.Handled).Equal(dayOf(now)) {
return nil, fmt.Errorf("%s: %s of mail entry %d was handled before today, and an undo lasts until the day ends",
UnhandleTool, a.item, a.entry.ID)
}
h := a.current
h.Handled = time.Time{}
return a.act(UnhandleTool, ActUndone, h, now)
}
// remindTool hides an item until the next day, when the list shows it under today (ADR 0517 §2).
func (b *Bundle) remindTool(args map[string]any) (any, error) {
a, err := b.startItemAct(RemindTool, args)
if err != nil {
return nil, err
}
if !a.current.Handled.IsZero() {
return nil, fmt.Errorf("%s: %s of mail entry %d is handled; undo it first", RemindTool, a.item, a.entry.ID)
}
now := b.now()
h := a.current
h.ShowsAgain = dayOf(now).AddDate(0, 0, 1)
return a.act(RemindTool, ActReminded, h, now)
}
@@ -59,7 +59,7 @@ func TestTheManifestClaimsMailReadingAndInvokesReadOnly(t *testing.T) {
t.Fatalf("runs-as %q, want %q", m.RunsAs, Module)
}
wantClaims := []seat{
{Name: "mail-reading", Scope: "mesh", Serves: []string{"entries", "groups", "correct", "lessons", "undo", "status"}},
{Name: "mail-reading", Scope: "mesh", Serves: []string{"entries", "groups", "correct", "lessons", "undo", "status", "handle", "unhandle", "remind"}},
}
if !reflect.DeepEqual(m.Claims, wantClaims) {
t.Fatalf("claims %+v, want %+v", m.Claims, wantClaims)
@@ -29,6 +29,14 @@ const (
LessonsTool = "mail-reading.lessons"
UndoTool = "mail-reading.undo"
StatusTool = "mail-reading.status"
// The verbs of the operator's list (novox/hq ADR 0517 §4, issue 1858).
ListTool = "mail-reading.list"
HandleTool = "mail-reading.handle"
UnhandleTool = "mail-reading.unhandle"
RemindTool = "mail-reading.remind"
RecordTool = "mail-reading.record"
ActionsTool = "mail-reading.actions"
)
// Bundle is what the tools reach: the trusted setting `callers`, the runtime's own caller, and the index
@@ -38,6 +46,7 @@ type Bundle struct {
Env Env
Caller func(args map[string]any) string
Store func(ctx context.Context) (Store, error)
Now func() time.Time // nil outside a test: time.Now
// callersOverride, when set, is used instead of reading Env.Callers — the tests' own seam.
callersOverride func() (Callers, error)
@@ -48,6 +57,13 @@ type Env struct {
Callers string // the path MESH_MAIL_READER_CALLERS names.
}
func (b *Bundle) now() time.Time {
if b.Now != nil {
return b.Now()
}
return time.Now()
}
func (b *Bundle) caller(args map[string]any) string {
if b.Caller != nil {
return b.Caller(args)
@@ -180,6 +196,25 @@ func (b *Bundle) Tools() []stdio.Tool {
"no mail read says so). Answered only to a caller the setting callers names. Read-only.",
Input: schema(nil, map[string]any{}),
Run: b.statusTool},
{Name: HandleTool,
Description: "Mark one item of a mail entry handled for the operator (novox/hq ADR 0517 §1): a task or a " +
"promise by its number, or the mail itself, which handles all its items with it. It changes nothing " +
"in the mailbox, and writes one handled line in the action log. Answered only to a caller the " +
"setting callers names.",
Input: itemActSchema(),
Run: b.handleTool},
{Name: UnhandleTool,
Description: "Undo a handling of today (novox/hq ADR 0517 §1): the item is back in its section; the undo " +
"lasts until the day ends (Europe/Brussels). Writes one undone line. Answered only to a caller the " +
"setting callers names.",
Input: itemActSchema(),
Run: b.unhandleTool},
{Name: RemindTool,
Description: "Remind me tomorrow (novox/hq ADR 0517 §2): the item is hidden until the next day, when the " +
"list shows it under today. Writes one reminded line. Answered only to a caller the setting callers " +
"names.",
Input: itemActSchema(),
Run: b.remindTool},
{Name: HealthTool,
Description: "How mesh-mail-reader is (novox/hq ADR 0459 §12): unhealthy once a batch judgement is " +
"refused, times out or answers out of shape, naming which and since when; healthy once one " +
@@ -189,6 +224,15 @@ func (b *Bundle) Tools() []stdio.Tool {
}
}
// itemActSchema is handle's, unhandle's and remind's own arguments.
func itemActSchema() map[string]any {
return schema([]string{"id"}, map[string]any{
"id": integer("the mail entry's id, as list answered it"),
"item": text("task 1 to 3 or promise 1 to 3 by its number, or mail (the mail itself, also when left out)"),
"mode": text("the mode the page is in: work, personal or everything (when left out)"),
})
}
// entryOut is one mail entry as entriesTool answers it (1521/AC1): account, message id, date, sender name,
// subject, group (by its label), summary, mark, why and due date — never a mail body.
func entryOut(e Entry, labels map[int64]string) map[string]any {
@@ -22,7 +22,8 @@ func TestToolsListsTheSixVerbsAndHealth(t *testing.T) {
}
want := []string{
"mail-reading.entries", "mail-reading.groups", "mail-reading.correct", "mail-reading.lessons",
"mail-reading.undo", "mail-reading.status", "mail_reader_health",
"mail-reading.undo", "mail-reading.status", "mail-reading.handle", "mail-reading.unhandle", "mail-reading.remind",
"mail_reader_health",
}
if len(names) != len(want) {
t.Fatalf("Tools() = %v, want %v", names, want)
+4 -1
View File
@@ -6,7 +6,7 @@
{
"name": "mail-reading",
"scope": "mesh",
"serves": ["entries", "groups", "correct", "lessons", "undo", "status"]
"serves": ["entries", "groups", "correct", "lessons", "undo", "status", "handle", "unhandle", "remind"]
}
],
"consumes": [
@@ -48,6 +48,9 @@
"mail-reading.lessons",
"mail-reading.undo",
"mail-reading.status",
"mail-reading.handle",
"mail-reading.unhandle",
"mail-reading.remind",
"mail_reader_health"
],
"runtime-health": {
@@ -148,7 +148,7 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) {
{Name: "mail-store", Scope: "mesh", Serves: []string{"accounts", "search", "read", "send", "send-status"}},
{Name: "mail-webclient", Scope: "mesh", Serves: []string{"open", "status"}},
{Name: "mail-notifier", Scope: "node", Serves: []string{"status"}, Needs: []string{"node-notifier"}},
{Name: "mail-reading", Scope: "mesh", Serves: []string{"entries", "groups", "correct", "lessons", "undo", "status"}},
{Name: "mail-reading", Scope: "mesh", Serves: []string{"entries", "groups", "correct", "lessons", "undo", "status", "handle", "unhandle", "remind"}},
}
if !reflect.DeepEqual(m.Seats, wantSeats) {
t.Fatalf("seats %+v, want %+v", m.Seats, wantSeats)
+1 -1
View File
@@ -22,7 +22,7 @@
{
"name": "mail-reading",
"scope": "mesh",
"serves": ["entries", "groups", "correct", "lessons", "undo", "status"]
"serves": ["entries", "groups", "correct", "lessons", "undo", "status", "handle", "unhandle", "remind"]
}
],
"claims": [