aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/specs/2026-08-29-sync-status-file-design.md
diff options
context:
space:
mode:
Diffstat (limited to 'docs/superpowers/specs/2026-08-29-sync-status-file-design.md')
-rw-r--r--docs/superpowers/specs/2026-08-29-sync-status-file-design.md177
1 files changed, 177 insertions, 0 deletions
diff --git a/docs/superpowers/specs/2026-08-29-sync-status-file-design.md b/docs/superpowers/specs/2026-08-29-sync-status-file-design.md
new file mode 100644
index 0000000..b5c3a40
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-29-sync-status-file-design.md
@@ -0,0 +1,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.