aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/specs/2026-08-29-sync-status-file-design.md
blob: b5c3a40dcc1c47cb81b6d7ea91f271a882326918 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
# A status file, so qtmaildir is told about a sync instead of inferring it

Item 174, and item 125 with it. 2026-08-29.

## The premise, which is the user's and which reverses the usual direction

`assets/mailsync.sh` was written for another system and adapted for qtmaildir.
It is now the only consumer, so the script serves the application rather than
the application accommodating the script. Where a gap is found in qtmaildir,
the script is reshaped to bridge it. mbsync and IMAP stay out of the
application; the script stays a commodity.

Two facts settle the scope, both verified rather than assumed:

- The user's crontab runs `mailsync.sh` every ten minutes and nothing else
  touches mail. There is no third-party mbsync and no bare `notmuch new`.
- `~/bin/mailsync.sh` is a SYMLINK to `assets/mailsync.sh` in this repo, so an
  edit here is live on the next cron tick with no deploy step.

## What is actually wrong

qtmaildir learns about a sync it did not start through two indirect channels,
both of which are inferences about a process that has already exited:

- **An inode in `/proc/locks`** (`syncmonitor.cpp:64`), watching the file the
  script flocks. This answers "is a sync running", and it is the reason item
  125 exists: a skipped run (exit 75) releases no lock the monitor ever saw
  held, so the spinner runs for ever.
- **A grep of the log** for `RUN END ... status=OK`
  (`MailSync::lastRunOutcome`, one production caller at `mainwindow.cpp:5182`).
  This makes a human-readable log line into load-bearing wire format: anyone
  reformatting that banner breaks the application silently.

Neither channel carries what the application needs, which is WHICH CHANNELS a
run synced, WHEN, and WITH WHAT RESULT. The consequence is visible in the code:
the local sync path narrows its clear to the accounts the run carried
(`mainwindow.cpp:4623`, `subtract(accountsThisRunCarried)`), while the external
path cannot and does a blanket `m_pendingTagEdits.clear()` at `:5183`. The
external path is coarser than the local one for want of information the script
has and does not report.

## What is NOT wrong, and must not be "fixed"

**A bare `notmuch new` cannot clear the pending count, and should not.** The
count means "confirmed tag mutations not yet known to have reached the MAIL
STORE" (`mainwindow.h:1763`), which is the server. A tag edit is in notmuch the
moment it is made; what is outstanding is mbsync pushing the renamed Maildir
files. `notmuch new` re-indexes local files and pushes nothing.

Item 174's own "Approach" section proposes watching
`notmuch_database_get_revision()`. That is rejected here: a revision moves when
mail ARRIVES as well as when our edits land, and in neither case does it say
anything about the server. Clearing on a revision bump would make the indicator
claim work is safe to quit on when it is still local, which is item 28's defect
returning in a new costume. The item's own constraint gestures at this and then
resolves it with a per-message re-check, but that check reads notmuch, which
also cannot see the server.

The status file is the correct instrument precisely because the script knows
what notmuch cannot: whether MBSYNC ran and what it returned.

## The design

The script writes one JSON file at the end of every run, including a skipped
one. qtmaildir watches that file.

**Path:** `~/.local/state/qtmaildir/syncstatus.json`, configurable beside
`syncLog` for the same reasons that key exists. Note it goes under the
application's own state directory, not beside `mailsync.log` in
`~/.local/state/`: the log is the script's, the status file is the interface.

**Shape:**

```json
{
  "version": 1,
  "run_id": "2026-08-29T10:33:07+02:00",
  "started": "2026-08-29T10:33:07+02:00",
  "ended": "2026-08-29T10:33:41+02:00",
  "state": "ok",
  "channels": ["work", "personal"],
  "mbsync_status": 0,
  "notmuch_status": 0
}
```

- `state` is `ok`, `failed` or `skipped`. Three states, not a boolean, because
  a skip is neither: item 125 exists because a skip currently reads as neither
  success nor failure and so resolves nothing.
- `channels` is the channel list the run actually synced, or `["-a"]` for a
  full run. This is what lets the external path narrow its clear the way the
  local path already does.
- `version` is refused rather than guessed at by a reader that does not know
  it, following the rule the rules file already uses for `kFormatVersion`.

**Written atomically**, to a temporary file in the same directory and then
`mv`, which is atomic within a filesystem. A reader watching the file must
never see a half-written one, and qtmaildir will be watching it while it is
written.

**The log keeps its banner.** `RUN END` stays exactly as it is, and
`MailSync::lastRunOutcome()` stays with its eight tests. It becomes the
FALLBACK for a status file that is missing or unreadable, which is what a first
run after upgrading looks like, rather than being deleted in the same change
that adds its replacement.

## What each side does

**`assets/mailsync.sh`.** Collect the channel list, write the file on every
exit path. The skip branch at the top exits before the status directory exists,
so it needs its own write. Two properties recorded as load-bearing survive
untouched: printing to stdout as well as the log, and exiting with the real
status.

**`src/mailsync.{h,cpp}`.** A `SyncStatus` value struct and a static reader,
beside `lastRunOutcome()` and in the same shape: a pure function of a path,
returning a value with an Unknown-equivalent for every failure. No widget, no
process.

**`src/syncmonitor.{h,cpp}`.** Watch the status file with `QFileSystemWatcher`,
beside the existing `/proc/locks` polling rather than replacing it. The lock
answers "a sync is running now" and the file answers "a run finished and here
is what it did"; these are different questions and a completion record cannot
answer the first.

**`src/mainwindow.cpp`.** The external path at `:5182` reads the status file
instead of the log, and narrows its clear by channel, resolving channels back
to accounts through `Account::channel`. A `skipped` state clears the spinner
without clearing the count, which is item 125.

## Constraints

- **The script is live.** Every edit reaches real mail within ten minutes.
  Writing the status file is purely additive and nothing reads it until the
  application does, so a partial state is harmless, but the ORDER matters: the
  script lands first and is left to run for a few cycles before the reader is
  written.
- **`Account::channel` may differ from the account key** and may be empty,
  in which case the key is the channel. A reverse lookup must handle both, and
  a channel naming no account must be ignored rather than dropped on the floor
  silently.
- **`-a` means every account**, and is not the same as a list naming them all.
  A reader that treats `["-a"]` as an unknown channel clears nothing on exactly
  the run that carried everything.
- **Only a successful run may clear the count**, per the rule the local path
  states at `:4614`. `failed` and `skipped` clear no edits.
- **Unknown changes no state**, exactly as `SyncOutcome::Unknown` and
  `SyncMonitor::State::Unknown` are treated today.
- **Two readers, one format.** This is the `rules.json` situation again, a bash
  writer and a C++ reader agreeing by test rather than by shared code. The
  discipline in AGENTS.md under "Changing the rule format" applies: change both
  sides together, bump the version only for a breaking change, and run both
  suites.
- **No new dependency for JSON.** The script writes it with `printf`, which is
  why the shape above has no nesting; qtmaildir reads it with `QJsonDocument`,
  which it already uses for `queries.json`.

## Tests

- `assets/hooks/`-style Python test for the script, in the shape the two hook
  suites already take: run it with a stub `mbsync` and `notmuch` on PATH,
  assert the file's contents for ok, failed and skipped.
- `test_mailsync.cpp`: the reader, against files written by hand. Every failure
  mode returns the Unknown equivalent, including a truncated file, an unknown
  version, and a file that is not JSON at all.
- `test_mainwindow.cpp`: the external path clears the count for a channel it
  carried and leaves an account it did not, which is the behaviour the blanket
  clear cannot express. And a `skipped` run clears the spinner and no edits.

## What this deliberately does not do

- No daemon, no socket, no D-Bus. The file is the interface.
- No change to how a LOCAL sync reports: it has the process and its exit
  status, which is better evidence than a file.
- No backfill of item 125's other half. The spinner is cleared by a `skipped`
  record here; whether `SyncMonitor` should also time out an observation it
  never saw end is a separate question and stays in item 125.