From 7d8695cf9108325527c5513c573c79ef5a2aee0f Mon Sep 17 00:00:00 2001 From: scgopi Date: Sun, 6 Sep 2026 12:16:02 -0700 Subject: [PATCH] Advance the mail cursor to the highest post handed over (#288) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mail inbox read the room in one step and moved the cursor in another, and the daemon moved it to the room's latest post — so a post landing between the two was marked read without ever being printed, a race the CLI called accepted. MailboxQuery.advanceCursor now moves the reader's cursor to Mailbox.highestDeliveredID inside GraphStore.mailbox, in the same actor turn the answer is drawn; persisted, never broadcast, refused to the asker alone. highestDeliveredID is the highest id below which every unread post was handed over — nil for an empty page or a filtered first post — and the daemon refuses advanceCursor with search. An unread answer is a page that is never smaller than the room (inboxPageSize = maxNotices + maxLetters, pinned by test) so pruning cannot eat a page between requests, and what pruning does eat is counted on the answer (prunedUnread) and said out loud. The legacy GraphCommand.mailroomInbox moves no cursor and answers with an error naming the version skew, so an older CLI that received nothing advances nothing. --mark walks the unread mail through the mailbox. The Swift CLI and the remote shim send one request, and the accepted-race comment is gone from both. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DeGL2CxuGmq16RSZpJYm2N --- .../Sources/CLI/GraphcodeCommand.swift | 53 ++++- GraphcodeKit/Sources/GraphStore.swift | 97 +++++--- GraphcodeKit/Sources/IPC/DaemonProtocol.swift | 13 +- GraphcodeKit/Sources/ProjectRegistry.swift | 14 +- .../Sources/Sessions/RemoteGraphAccess.swift | 84 ++++--- MailroomKit/Sources/Mailroom.swift | 90 +++++++- graphcode-cli/Sources/main.swift | 71 +++--- graphcode/Tests/MailboxTests.swift | 213 +++++++++++++++++- graphcode/Tests/MailroomCommandTests.swift | 101 +++++++++ graphcode/Tests/MailroomTests.swift | 13 +- graphcode/Tests/RemoteCLIShimTests.swift | 69 +++++- 11 files changed, 693 insertions(+), 125 deletions(-) diff --git a/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift b/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift index e5ee6cbb..527bc811 100644 --- a/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift +++ b/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift @@ -104,14 +104,14 @@ public enum GraphcodeCommand: Equatable, Sendable { graphcode mail post [--topic ] post a notice to the whole graph, for whoever comes next graphcode mail inbox [--headlines] [--full] [--mark] [--json] - read your unread mail and mark the room read. A large - backlog prints as headlines on its own and says so — - --full insists on every body, --headlines insists on - triage lines (deep-read either with `read`). --mark - advances the cursor without printing the backlog, --json is - the machine-readable shape. Combined, the output wins in the - order --json > --mark > --headlines > --full; the cursor - advances whichever flags you pass + read your unread mail; what prints is what is marked read. + A large backlog prints as headlines on its own and says + so — --full insists on every body, --headlines insists on + triage lines (deep-read either with `read`) — and comes a + page at a time: run it again for the next page. --mark + marks everything read without printing it, --json is the + machine-readable shape. Combined, the output wins in the + order --json > --mark > --headlines > --full graphcode mail read one post in full — the deep-read half of --headlines graphcode mail list [--search ] [--json] @@ -866,9 +866,14 @@ extension GraphcodeCommand { if let search, !search.isEmpty { return unread ? "no unread posts match '\(search)'" : "no posts match '\(search)'" } - return unread - ? "no unread posts" - : "the room is empty — post one: graphcode mail post " + guard unread else { + return "the room is empty — post one: graphcode mail post " + } + return mailbox.prunedUnread > 0 + ? "no unread posts — but \(mailbox.prunedUnread) landed since your last inbox and " + + "were pruned before you read them; the room keeps \(Mailroom.maxNotices) notices " + + "and \(Mailroom.maxLetters) letters, read it more often" + : "no unread posts" } let triaged = mailbox.bodiesTrimmed && !headlines let label = unread ? "mailroom, unread" : "mailroom" @@ -883,6 +888,23 @@ extension GraphcodeCommand { lines.append( headlines || mailbox.bodiesTrimmed ? " \(renderHeadline(post))" : " \(render(post))") } + if unread, mailbox.prunedUnread > 0 { + // Said before the posts, in words, or the loop believes it is caught up: mail + // that landed after its cursor and was pruned before it asked is gone for good. + lines.append( + " \(mailbox.prunedUnread) post\(mailbox.prunedUnread == 1 ? "" : "s") landed since your " + + "last inbox and \(mailbox.prunedUnread == 1 ? "was" : "were") pruned before you read " + + "\(mailbox.prunedUnread == 1 ? "it" : "them") — the room keeps " + + "\(Mailroom.maxNotices) notices and \(Mailroom.maxLetters) letters; read it more often") + } + if mailbox.remaining > 0 { + // A page, not the whole backlog: the cursor stopped at the last post above, so + // the same command again is the next page — said in words, or a loop would + // take one page for the lot. + lines.append( + " \(mailbox.remaining) more unread past #\(mailbox.highestDeliveredID ?? 0) — " + + "run the same command again for the next page") + } return lines.joined(separator: "\n") } @@ -894,8 +916,15 @@ extension GraphcodeCommand { struct Board: Encodable { var posts: [MailroomPost] var lastRead: Int? + /// Present only when the answer is a page, so the shape scripts already parse + /// is untouched until there is something to say. + var remaining: Int? + var prunedUnread: Int? } - let board = Board(posts: mailbox.posts, lastRead: mailbox.lastRead) + let board = Board( + posts: mailbox.posts, lastRead: mailbox.lastRead, + remaining: mailbox.remaining > 0 ? mailbox.remaining : nil, + prunedUnread: mailbox.prunedUnread > 0 ? mailbox.prunedUnread : nil) let encoder = JSONEncoder() encoder.outputFormatting = [.sortedKeys] encoder.dateEncodingStrategy = .iso8601 diff --git a/GraphcodeKit/Sources/GraphStore.swift b/GraphcodeKit/Sources/GraphStore.swift index fbd75e33..7eade8a0 100644 --- a/GraphcodeKit/Sources/GraphStore.swift +++ b/GraphcodeKit/Sources/GraphStore.swift @@ -517,7 +517,10 @@ public actor GraphStore { // MARK: - Commands - public func handle(_ command: GraphCommand) async { + /// `from` is the connection the command arrived on, when the registry knows it — what + /// lets a refusal meant for one client go to that client alone. Tests drive the + /// store without one and hear refusals through `onAnnounceError`. + public func handle(_ command: GraphCommand, from connectionID: UUID? = nil) async { // A loop inside a composite addresses itself by its own id — its briefing tells it // to `node memo `, and ids are unique across the whole tree, // so a caller has no reason to know how deep its target sits (the same rule @@ -648,8 +651,8 @@ public actor GraphStore { case .mailroomPost(let text, let topic, let from): await mailroomPost(text: text, topic: topic, from: from) - case .mailroomInbox(let from): - mailroomInbox(from: from) + case .mailroomInbox: + refuseLegacyInbox(to: connectionID) case .mailroomWatch(let on, let topic, let from): mailroomWatch(on: on, topic: topic, from: from) @@ -1690,39 +1693,77 @@ public actor GraphStore { graph.mailroom = Mailroom.pruned(graph.mailroom + [post]) } - /// The room as a client asked for it — the read half of every mail verb, and the - /// only way posts leave the daemon now that `.graphChanged` carries their digest - /// instead (issue #288). Pure: nothing moves, nothing is persisted, nobody else - /// hears about it. Not gated on the room being on — reading was never gated, and a - /// room switched off still shows what was said while it was on. - public func mailbox(_ query: MailboxQuery) -> Mailbox { - Mailroom.serve(query, from: graph.mailroom) { graph.nodes[id: $0]?.lastMailroomRead } + /// Why a mailbox request was refused — the daemon's wording, for the asking + /// connection alone rather than every client (`announceError` broadcasts). + public struct MailboxRefusal: Error, Equatable { + public let message: String } - /// Advances the reading loop's cursor to the newest post — the write half of - /// `graphcode mail inbox`. Deliberately no memory record: sync is reading, - /// not learning, and a log line per read would turn the log into a metronome. - private func mailroomInbox(from readerID: UUID?) { + /// The room as a client asked for it — the read half of every mail verb, and the + /// only way posts leave the daemon now that `.graphChanged` carries their digest + /// instead (issue #288). Reading is not gated on the room being on — it never was, + /// and a room switched off still shows what was said while it was on. + /// + /// With `advanceCursor`, also the acknowledgement: the reader's cursor moves to the + /// highest post in the answer, in the same actor turn the answer was drawn — so no + /// post can land between the read and the mark and be marked read unseen, and a + /// page that stops short (`Mailroom.inboxPageSize`) leaves the rest unread for the + /// next request. Persisted, never broadcast: a cursor is the reader's alone, and the + /// next snapshot anything else causes carries it anyway. + public func mailbox(_ query: MailboxQuery) throws -> Mailbox { + let mailbox = Mailroom.serve(query, from: graph.mailroom) { + graph.nodes[id: $0]?.lastMailroomRead + } + guard query.advanceCursor == true, case .unread(let readerID) = query.selection else { + return mailbox + } + // A searched answer skips unread posts, and a cursor only moves through mail that + // was handed over — `highestDeliveredID` already stops at the first miss, but the + // pair is refused outright so no caller can lean on remembering that. + guard query.search?.isEmpty ?? true else { + throw MailboxRefusal( + message: "a searched inbox cannot move the cursor — search with `mail list`, or " + + "read the inbox unsearched") + } guard mailroomIsOn() else { - announceError( - "the Mailroom is off — enable Mailroom in Settings " + throw MailboxRefusal( + message: "the Mailroom is off — enable Mailroom in Settings " + "(mailroomEnabled in ~/.graphcode/settings.json)") - return } - guard let readerID, graph.nodes[id: readerID] != nil else { - announceError( - "mail inbox needs a loop identity — run it from a loop's session " + guard graph.nodes[id: readerID] != nil else { + throw MailboxRefusal( + message: "mail inbox needs a loop identity — run it from a loop's session " + "($ZMX_SESSION); a human reading the board needs no cursor") - return } - // Never moves backward: ids only grow (`Mailroom.nextID` is max-plus-one), so - // the max below only guards a board emptied by something other than pruning. - let latest = graph.mailroom.last?.id ?? 0 - // Read into a local first: reading and writing the cursor through the same - // `IdentifiedArray` subscript in one expression is an overlapping access the - // runtime treats as fatal exclusivity. + // Never moves backward, and never past what was handed over. let current = graph.nodes[id: readerID]?.lastMailroomRead ?? 0 - graph.nodes[id: readerID]?.lastMailroomRead = max(latest, current) + let delivered = mailbox.highestDeliveredID ?? current + guard delivered > current else { return mailbox } + graph.nodes[id: readerID]?.lastMailroomRead = delivered + onGraphChanged?(graph) + return mailbox + } + + /// What `GraphCommand.mailroomInbox` does now: nothing to the cursor, and says why. + /// + /// This was "advance to the newest post" — the acknowledgement half of a `mail inbox` + /// that read the posts off its snapshot. Snapshots no longer carry posts, so the one + /// client still sending this is a CLI older than the daemon, which has just printed + /// "no unread posts" off an empty snapshot and would now have its cursor moved past + /// mail it never saw — permanently, upgrade or not. A cursor moves only through mail + /// that was handed over, so the old command is refused loudly and the new one + /// (`MailboxQuery.advanceCursor`) is the only thing that moves it. + private func refuseLegacyInbox(to connectionID: UUID?) { + let message = + "this graphcode CLI predates the daemon's mailbox — nothing was marked read. " + + "Upgrade graphcode (the app installs it beside graphcoded) and run the " + + "inbox again" + // To the asker alone, never `announceError`: that writes to every connected + // client, and one stale CLI's problem is nobody else's error to read. + if let connectionID, connections[connectionID] != nil { + send(.errorOccurred(message), to: connectionID) + } + onAnnounceError?(message) } /// Subscribes or unsubscribes the calling loop. Recorded to the loop's memory so a diff --git a/GraphcodeKit/Sources/IPC/DaemonProtocol.swift b/GraphcodeKit/Sources/IPC/DaemonProtocol.swift index aeed896a..99a004e8 100644 --- a/GraphcodeKit/Sources/IPC/DaemonProtocol.swift +++ b/GraphcodeKit/Sources/IPC/DaemonProtocol.swift @@ -146,12 +146,13 @@ public indirect enum GraphCommand: Codable, Sendable, Equatable { /// (`mailroomEnabled` in `~/.graphcode/settings.json`) — a silent no-op would read, /// to the loop that sent it, as a post nobody answered. case mailroomPost(text: String, topic: String?, from: UUID?) - /// Mark every post on the Mailroom as read for the calling loop — `graphcode mail - /// inbox`, the cursor half of reading. The posts themselves come back on a - /// `DaemonCommand.mailbox` sent first; this is the write that makes "unread" mean - /// something the *next* inbox can subtract from. Requires a loop identity: a human - /// reading the room needs no cursor, since nothing downstream tracks what they have - /// seen. + /// **Refused by a daemon from this version on.** This was the acknowledgement half of + /// `mail inbox` — "advance my cursor to the newest post" — sent after a CLI had read + /// the posts off its snapshot. Snapshots carry no posts now, and the cursor moves only + /// through mail actually handed over (`MailboxQuery.advanceCursor`); a client still + /// sending this is older than the daemon and would otherwise have its cursor moved + /// past mail it never saw. Kept on the wire so that client gets an answer that says + /// so instead of a hang-up. case mailroomInbox(from: UUID?) /// Subscribe (`on: true`) or unsubscribe (`on: false`) the calling loop to Mailroom /// posts — `graphcode mail watch`. A watched post is delivered the way a diff --git a/GraphcodeKit/Sources/ProjectRegistry.swift b/GraphcodeKit/Sources/ProjectRegistry.swift index 964bd361..fd0c2ae8 100644 --- a/GraphcodeKit/Sources/ProjectRegistry.swift +++ b/GraphcodeKit/Sources/ProjectRegistry.swift @@ -352,7 +352,7 @@ public actor ProjectRegistry { send(.errorOccurred("\(path) isn't open — open it first."), to: fileDescriptor) return } - await store.handle(inner) + await store.handle(inner, from: connectionID) case .refused(let reason): send(.errorOccurred(reason), to: fileDescriptor) } @@ -367,9 +367,15 @@ public actor ProjectRegistry { send(.errorOccurred("\(path) isn't open — open it first."), to: fileDescriptor) return } - send( - .mailbox(projectPath: canonicalPath, mailbox: await store.mailbox(query)), - to: fileDescriptor) + do { + send( + .mailbox(projectPath: canonicalPath, mailbox: try await store.mailbox(query)), + to: fileDescriptor) + } catch let refusal as GraphStore.MailboxRefusal { + send(.errorOccurred(refusal.message), to: fileDescriptor) + } catch { + send(.errorOccurred("\(error)"), to: fileDescriptor) + } case .refused(let reason): send(.errorOccurred(reason), to: fileDescriptor) } diff --git a/GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift b/GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift index f5261ae7..7e7d6d73 100644 --- a/GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift +++ b/GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift @@ -453,17 +453,27 @@ public enum RemoteGraphAccess { return "".join(clusters[:HEADLINE_BUDGET]) + ELLIPSIS + MAX_NOTICES = 200 + MAX_LETTERS = 200 + + def render_board(mailbox, project, unread, headlines=False, search=None): posts = mailbox.get("posts") or [] + pruned = mailbox.get("prunedUnread") or 0 if not posts: if search: if unread: return "no unread posts match '%s'" % search return "no posts match '%s'" % search - if unread: - return "no unread posts" - return ("the room is empty %s post one: graphcode mail post " - " " % (EM_DASH, ELLIPSIS)) + if not unread: + return ("the room is empty %s post one: graphcode mail post " + " " % (EM_DASH, ELLIPSIS)) + if pruned > 0: + return ("no unread posts %s but %d landed since your last inbox and were " + "pruned before you read them; the room keeps %d notices and %d " + "letters, read it more often" % (EM_DASH, pruned, MAX_NOTICES, + MAX_LETTERS)) + return "no unread posts" trimmed = bool(mailbox.get("bodiesTrimmed")) triaged = trimmed and not headlines label = "mailroom, unread" if unread else "mailroom" @@ -477,6 +487,18 @@ public enum RemoteGraphAccess { for post in posts: lines.append(" " + (render_headline(post) if headlines or trimmed else render_post(post))) + if unread and pruned > 0: + lines.append(" %d post%s landed since your last inbox and %s pruned before you " + "read %s %s the room keeps %d notices and %d letters; read it more " + "often" % (pruned, "" if pruned == 1 else "s", + "was" if pruned == 1 else "were", + "it" if pruned == 1 else "them", EM_DASH, + MAX_NOTICES, MAX_LETTERS)) + remaining = mailbox.get("remaining") or 0 + if remaining > 0: + lines.append(" %d more unread past #%s %s run the same command again for the " + "next page" % (remaining, mailbox.get("highestDeliveredID") or 0, + EM_DASH)) return "\n".join(lines) @@ -505,6 +527,10 @@ public enum RemoteGraphAccess { board = {"posts": [encoded_post(post) for post in (mailbox.get("posts") or [])]} if mailbox.get("lastRead") is not None: board["lastRead"] = mailbox["lastRead"] + if (mailbox.get("remaining") or 0) > 0: + board["remaining"] = mailbox["remaining"] + if (mailbox.get("prunedUnread") or 0) > 0: + board["prunedUnread"] = mailbox["prunedUnread"] # Swift's JSONEncoder escapes forward slashes and emits non-ASCII raw; json.dumps # does neither by default. `/` cannot occur outside a string in JSON, so escaping # the dumped text wholesale is exact. A body carrying a path or a URL is what makes @@ -814,30 +840,34 @@ public enum RemoteGraphAccess { graph = daemon.open_project(project) headlines = "headlines" in flags full = "full" in flags - mark = "mark" in flags - # Posts first, cursor second, so a refusal to move it stops before anything - # is printed as read; `--mark` prints no posts, so it asks for none. Bodies - # are the room's call unless a flag insists (MailboxQuery.fullBodies). - mailbox = None - if not mark: - query = {"selection": {"unread": {"reader": reader}}} - if "json" in flags or full: - query["fullBodies"] = True - elif headlines: - query["fullBodies"] = False - mailbox = daemon.mailbox(project, query) - daemon.send(graph_command(project, {"mailroomInbox": {"from": reader}})) - key, value = daemon.wait_for(["graphChanged", "errorOccurred"]) - if key == "errorOccurred": - fail(value["_0"]) - # Same one-round-trip race the Swift CLI documents and accepts. - if mailbox is None: - latest = board_digest(value["_0"]).get("latestID") or 0 - if latest > 0: - print("marked read up to #%d" % latest) - else: + if "mark" in flags: + # Everything marked read, nothing printed -- still page by page through + # the mailbox, since a cursor only moves through mail handed over. + mailbox = None + while True: + mailbox = daemon.mailbox(project, { + "selection": {"unread": {"reader": reader}}, + "fullBodies": False, "advanceCursor": True}) + if not (mailbox.get("remaining") or 0) > 0: + break + highest = mailbox.get("highestDeliveredID") + if highest is not None: + print("marked read up to #%d" % highest) + elif not ((mailbox.get("digest") or {}).get("latestID") or 0): print("marked read %s the room is empty" % EM_DASH) - elif "json" in flags: + else: + print("marked read %s nothing was unread" % EM_DASH) + return + # One request does both halves: a page of unread posts, and the cursor moved + # to the highest one in that page in the same daemon turn. Bodies are the + # room's call unless a flag insists (MailboxQuery.fullBodies). + query = {"selection": {"unread": {"reader": reader}}, "advanceCursor": True} + if "json" in flags or full: + query["fullBodies"] = True + elif headlines: + query["fullBodies"] = False + mailbox = daemon.mailbox(project, query) + if "json" in flags: print(render_board_json(mailbox)) else: print(render_board(mailbox, graph.get("project") or {}, True, diff --git a/MailroomKit/Sources/Mailroom.swift b/MailroomKit/Sources/Mailroom.swift index 5808a585..c41d41dc 100644 --- a/MailroomKit/Sources/Mailroom.swift +++ b/MailroomKit/Sources/Mailroom.swift @@ -271,11 +271,23 @@ public struct MailboxQuery: Codable, Equatable, Sendable { /// `Mailroom.needsTriage` — what `mail inbox` does unless told `--full` or /// `--headlines`, since a loop cannot know how much mail it has before reading it. public var fullBodies: Bool? + /// For an `.unread` selection: move the reader's cursor to the highest post this + /// answer carries (`Mailbox.highestDeliveredID`) — the read and the acknowledgement + /// in one step, so a post landing between the two can never be marked read + /// without having been handed over, and a page that stops short leaves the rest + /// unread for the next request. Refused for a reader the graph does not know, or + /// while the room is off. Optional so a query from an older client decodes as the + /// plain read it always was. + public var advanceCursor: Bool? - public init(selection: Selection, search: String? = nil, fullBodies: Bool? = nil) { + public init( + selection: Selection, search: String? = nil, fullBodies: Bool? = nil, + advanceCursor: Bool? = nil + ) { self.selection = selection self.search = search self.fullBodies = fullBodies + self.advanceCursor = advanceCursor } } @@ -292,19 +304,51 @@ public struct Mailbox: Codable, Equatable, Sendable { public var digest: MailroomDigest /// The reader's cursor, for an `.unread` selection whose reader the room knows. public var lastRead: Int? - /// The id of the last post in `posts` — what a cursor may honestly advance to, - /// since it is the highest post the reader was actually handed. + /// What a cursor may honestly advance to: the highest id below which *every* unread + /// post was handed over in this answer. Not simply the last post's id — a page stops + /// short, and a search skips posts that stay unread — so this is `nil` for an empty + /// answer, `nil` when the first unread post was filtered out, and the page's last + /// post only when nothing below it was skipped. A cursor moves through mail that + /// was delivered, never past mail that was not. public var highestDeliveredID: Int? + /// How many posts the selection matched beyond this page (`Mailroom.inboxPageSize`). + /// They stay unread: the cursor stops at `highestDeliveredID`, and the same request + /// again brings the next page. + public var remaining: Int + /// Posts that landed after the reader's cursor and were pruned before the reader + /// asked — mail this loop will never see, counted so it is told rather than left to + /// believe it is caught up. Ids are contiguous (`Mailroom.nextID`), so it is the ids + /// above the cursor that no surviving post carries. Zero for a reader the room does + /// not know, and for any selection but `.unread`. + public var prunedUnread: Int public init( posts: [MailroomPost], bodiesTrimmed: Bool, digest: MailroomDigest, lastRead: Int? = nil, - highestDeliveredID: Int? = nil + highestDeliveredID: Int? = nil, remaining: Int = 0, prunedUnread: Int = 0 ) { self.posts = posts self.bodiesTrimmed = bodiesTrimmed self.digest = digest self.lastRead = lastRead self.highestDeliveredID = highestDeliveredID + self.remaining = remaining + self.prunedUnread = prunedUnread + } + + private enum CodingKeys: String, CodingKey { + case posts, bodiesTrimmed, digest, lastRead, highestDeliveredID, remaining, prunedUnread + } + + /// `remaining` decodes as zero from an answer that predates it. + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + posts = try container.decode([MailroomPost].self, forKey: .posts) + bodiesTrimmed = try container.decode(Bool.self, forKey: .bodiesTrimmed) + digest = try container.decode(MailroomDigest.self, forKey: .digest) + lastRead = try container.decodeIfPresent(Int.self, forKey: .lastRead) + highestDeliveredID = try container.decodeIfPresent(Int.self, forKey: .highestDeliveredID) + remaining = try container.decodeIfPresent(Int.self, forKey: .remaining) ?? 0 + prunedUnread = try container.decodeIfPresent(Int.self, forKey: .prunedUnread) ?? 0 } } @@ -327,6 +371,15 @@ extension Mailroom { /// trim on the wire without the reader being able to tell. public static let headlineBodyBudget = 80 + /// The most posts one `.unread` answer carries — and never fewer than the room can + /// hold. A page smaller than retention opened a window the unbounded answer never + /// had: page two of a backlog could be pruned before the reader asked for it, and the + /// reader would skip it in silence. So the page is the room's own cap: every live + /// unread post fits one answer, the mechanism stays (a page still says what it left, + /// and the cursor still stops at what it handed over), and pruning between requests + /// can only eat what `prunedUnread` then reports. Pinned by test. + public static let inboxPageSize = maxNotices + maxLetters + /// Answers a query against a room. `cursor` is the reader's `lastMailroomRead`, or /// `nil` for a reader the graph does not know — who, as before, is shown everything /// and refused the cursor advance afterwards. @@ -339,12 +392,21 @@ extension Mailroom { var selected: [MailroomPost] var lastRead: Int? var deepRead = false + var paged = false + var pruned = 0 switch query.selection { case .board: selected = posts case .unread(let reader): lastRead = cursor(reader) selected = unread(in: posts, since: lastRead) + paged = true + // Every id above the cursor was a post once; the ones no survivor carries were + // pruned before this reader got to them. + if let lastRead { + let latest = posts.map(\.id).max() ?? lastRead + pruned = max(0, latest - lastRead - selected.count) + } case .post(let id): selected = posts.filter { $0.id == id } deepRead = true @@ -356,6 +418,23 @@ extension Mailroom { || $0.topic?.lowercased().contains(needle) == true } } + var remaining = 0 + if paged, selected.count > inboxPageSize { + remaining = selected.count - inboxPageSize + selected = Array(selected.prefix(inboxPageSize)) + } + // The cursor's honest ceiling: walk the unread posts in order and stop at the first + // one this answer does not carry — a page's edge or a search's miss. + var deliverable: Int? + if paged { + let handed = Set(selected.map(\.id)) + for post in unread(in: posts, since: lastRead) { + guard handed.contains(post.id) else { break } + deliverable = post.id + } + } else { + deliverable = selected.last?.id + } let trimmed: Bool switch query.fullBodies { case .some(let full): trimmed = !full && !deepRead @@ -364,6 +443,7 @@ extension Mailroom { if trimmed { selected = selected.map { $0.headlined() } } return Mailbox( posts: selected, bodiesTrimmed: trimmed, digest: MailroomDigest(of: posts), - lastRead: lastRead, highestDeliveredID: selected.last?.id) + lastRead: lastRead, highestDeliveredID: deliverable, remaining: remaining, + prunedUnread: pruned) } } diff --git a/graphcode-cli/Sources/main.swift b/graphcode-cli/Sources/main.swift index d65ca701..91db2785 100644 --- a/graphcode-cli/Sources/main.swift +++ b/graphcode-cli/Sources/main.swift @@ -415,34 +415,39 @@ do { + "($ZMX_SESSION); a human reading the board wants `graphcode mail list`") } let opened = try openProject(projectPath) - // The posts come first and the cursor moves second, so a refusal to move it — the - // room switched off, a reader the graph does not know — stops before anything is - // printed as read. `--mark` prints no posts, so it asks for none. - var mailbox: Mailbox? - if !mark { - // `fullBodies` nil leaves the triage to the room: a loop cannot know how much - // mail it has before reading it, and the first inbox of a loop born after a - // busy week is the whole room. `--json` and `--full` insist on every body, - // `--headlines` on triage lines. - let fullBodies: Bool? = json || full ? true : headlines ? false : nil - mailbox = try fetchMailbox( - projectPath, MailboxQuery(selection: .unread(reader: reader), fullBodies: fullBodies)) - } - try client.send( - .graphCommand(projectPath: projectPath, command: .mailroomInbox(from: reader))) - let syncVerdict = try client.waitForEvent { event in - switch event { - case .graphChanged, .errorOccurred: return true - default: return false + if mark { + // The quiet sync: everything marked read, nothing printed — the backlog is not + // the loop's problem any more, and the one line says the cursor actually moved. + // Still page by page through the mailbox, headlines only: a cursor moves only + // through mail that was handed over, so "everything" is walked, not jumped to. + var mailbox: Mailbox? + repeat { + mailbox = try fetchMailbox( + projectPath, + MailboxQuery( + selection: .unread(reader: reader), fullBodies: false, advanceCursor: true)) + } while (mailbox?.remaining ?? 0) > 0 + if let highest = mailbox?.highestDeliveredID { + print("marked read up to #\(highest)") + } else if (mailbox?.digest.latestID ?? 0) == 0 { + print("marked read — the room is empty") + } else { + print("marked read — nothing was unread") } - } - if case .errorOccurred(let message) = syncVerdict { fail(message) } - // Known race, accepted: a post landing between the mailbox answer and the daemon - // advancing the cursor is marked read without ever having been printed. The - // window is one round-trip wide and a watcher would have heard the post live - // anyway; fixing it properly means syncing to the highest *printed* id - // (`Mailbox.highestDeliveredID`) rather than to latest. - if let mailbox { + } else { + // One request does both halves: the daemon hands over a page of unread posts + // and moves the cursor to the highest one *in that page*, in the same actor + // turn — so nothing can land between the read and the mark and be marked read + // unseen, and a backlog longer than a page stays unread past it. `fullBodies` + // nil leaves the triage to the room: a loop cannot know how much mail it has + // before reading it, and the first inbox of a loop born after a busy week is + // the whole room. `--json` and `--full` insist on every body, `--headlines` on + // triage lines. + let fullBodies: Bool? = json || full ? true : headlines ? false : nil + let mailbox = try fetchMailbox( + projectPath, + MailboxQuery( + selection: .unread(reader: reader), fullBodies: fullBodies, advanceCursor: true)) if json { print(GraphcodeCommand.renderMailroomJSON(mailbox)) } else { @@ -451,18 +456,6 @@ do { mailbox, project: projectRef(opened, projectPath), unread: true, headlines: headlines)) } - } else { - // The quiet sync: the backlog is not the loop's problem any more, and the one - // line says the cursor actually moved — a silent success would read, to the - // loop that sent it, like a command nobody applied. Read off the graph the - // advance came back on, which is the room as it stood when the cursor moved. - var latest = 0 - if case .graphChanged(let graph) = syncVerdict { latest = graph.boardDigest.latestID } - if latest > 0 { - print("marked read up to #\(latest)") - } else { - print("marked read — the room is empty") - } } case .mailroomRead(let projectPath, let postID): diff --git a/graphcode/Tests/MailboxTests.swift b/graphcode/Tests/MailboxTests.swift index 83659cbe..4911be61 100644 --- a/graphcode/Tests/MailboxTests.swift +++ b/graphcode/Tests/MailboxTests.swift @@ -1,3 +1,4 @@ +import ComposableArchitecture import Foundation import GraphcodeKit import MailroomKit @@ -171,6 +172,108 @@ struct MailboxTests { #expect(serve(graph, .post(id: 1)).posts[0].body.count == 200) } + /// The bound with teeth: an unread answer is a page, oldest first, and says how many + /// it left; the whole room and a deep read are never paged. + @Test + func anUnreadAnswerIsAPageThatSaysWhatItLeft() { + var graph = LoopGraph(project: ProjectRef(path: "/tmp/x", name: "x")) + graph.mailroom = (1...(Mailroom.inboxPageSize + 50)).map { Self.post($0, "note \($0)") } + let reader = UUID() + + let page = serve(graph, .unread(reader: reader), fullBodies: true) + #expect(page.posts.count == Mailroom.inboxPageSize) + #expect(page.posts.first?.id == 1) + #expect(page.highestDeliveredID == Mailroom.inboxPageSize) + #expect(page.remaining == 50) + + #expect(serve(graph, .board).posts.count == graph.mailroom.count) + #expect(serve(graph, .board).remaining == 0) + #expect(serve(graph, .post(id: 7)).remaining == 0) + } + + /// `highestDeliveredID` names only mail that was handed over, in every shape: an + /// empty page has none; a page's edge is its last post only when nothing below it + /// was skipped; a search that skipped the first unread post promises nothing, and + /// one that matched a prefix promises exactly that prefix. The reviewer's case from + /// #293 is the third. + @Test + func theCursorCeilingNeverPassesMailThatWasNotHandedOver() { + var graph = LoopGraph(project: ProjectRef(path: "/tmp/x", name: "x")) + graph.mailroom = [ + Self.post(1, "nothing to do with it"), + Self.post(2, "also unrelated"), + Self.post(3, "the release is cut"), + ] + let reader = LoopNode(title: "Reader", loopType: .turnBased) + graph.nodes.append(reader) + + let searched = serve(graph, .unread(reader: reader.id), search: "release") + #expect(searched.posts.map(\.id) == [3]) + #expect(searched.highestDeliveredID == nil) + + var caughtUp = graph + caughtUp.nodes[id: reader.id]?.lastMailroomRead = 3 + #expect(serve(caughtUp, .unread(reader: reader.id)).highestDeliveredID == nil) + + // A search that matches the first two and not the third may promise #2. + var prefix = graph + prefix.mailroom = [Self.post(1, "release a"), Self.post(2, "release b"), Self.post(3, "other")] + #expect(serve(prefix, .unread(reader: reader.id), search: "release").highestDeliveredID == 2) + + // A page promises its own edge, and nothing on the next page. + var long = graph + long.mailroom = (1...(Mailroom.inboxPageSize + 1)).map { Self.post($0, "note \($0)") } + let page = serve(long, .unread(reader: reader.id)) + #expect(page.highestDeliveredID == Mailroom.inboxPageSize) + #expect(page.remaining == 1) + + // The whole room and a deep read: what was handed over is what was asked for. + #expect(serve(graph, .board, search: "release").highestDeliveredID == 3) + #expect(serve(graph, .post(id: 2)).highestDeliveredID == 2) + } + + /// A page is never smaller than the room, or pruning could eat page two between two + /// requests and the reader would skip it in silence — the window the unbounded + /// answer never had. And what pruning does eat is counted, not hidden. + @Test + func aPageIsNeverSmallerThanTheRoomAndPrunedMailIsCounted() { + #expect(Mailroom.inboxPageSize >= Mailroom.maxNotices + Mailroom.maxLetters) + + var graph = LoopGraph(project: ProjectRef(path: "/tmp/x", name: "x")) + var reader = LoopNode(title: "Reader", loopType: .turnBased) + reader.lastMailroomRead = 2 + graph.nodes.append(reader) + // Ids 3 and 4 landed after the cursor and were pruned; 5 survives. + graph.mailroom = [Self.post(1, "old"), Self.post(2, "read"), Self.post(5, "new")] + + let answer = serve(graph, .unread(reader: reader.id)) + #expect(answer.posts.map(\.id) == [5]) + #expect(answer.prunedUnread == 2) + #expect(answer.highestDeliveredID == 5) + + // Caught up, and everything since was pruned: told so, with nothing to hand over. + var emptied = graph + emptied.mailroom = [Self.post(1, "old"), Self.post(2, "read")] + emptied.nodes[id: reader.id]?.lastMailroomRead = 2 + var latestGone = emptied + latestGone.mailroom.append(Self.post(9, "much later")) + latestGone.nodes[id: reader.id]?.lastMailroomRead = 9 + #expect(serve(latestGone, .unread(reader: reader.id)).prunedUnread == 0) + #expect(serve(graph, .board).prunedUnread == 0) + #expect(serve(graph, .unread(reader: UUID())).prunedUnread == 0) + } + + /// `remaining` decodes as zero from an answer that predates it. + @Test + func anOlderAnswerDecodesWithNothingRemaining() throws { + let literal = """ + {"posts":[],"bodiesTrimmed":false,"digest":{"count":0,"latestID":0,"fingerprint":1}} + """ + let decoded = try JSONDecoder().decode(Mailbox.self, from: Data(literal.utf8)) + #expect(decoded.remaining == 0) + #expect(decoded.posts.isEmpty) + } + /// The wire shape the remote shim types by hand: pinned here so a rename on the Swift /// side fails a test before it fails on someone's build box. @Test @@ -204,7 +307,11 @@ struct MailboxTests { #expect(mailbox["bodiesTrimmed"] as? Bool == false) #expect((mailbox["digest"] as? [String: Any])?["count"] as? Int == 0) } +} +/// The daemon half, in an extension: the suite sits past swiftlint's 350-line +/// `type_body_length` error with it inside. +extension MailboxTests { // MARK: The daemon /// Reads one frame off `descriptor` on another thread: the store writes from its @@ -253,13 +360,105 @@ struct MailboxTests { #expect(posted.boardDigest.count == 1) #expect(posted.boardDigest.latestID == 1) - let mailbox = await store.mailbox(MailboxQuery(selection: .board, fullBodies: true)) + let mailbox = try await store.mailbox(MailboxQuery(selection: .board, fullBodies: true)) #expect(mailbox.posts.map(\.body) == ["claiming #12"]) #expect(mailbox.posts.map(\.topic) == ["claims"]) #expect(mailbox.highestDeliveredID == 1) #expect(mailbox.digest == posted.boardDigest) } + /// The cursor moves to the highest post handed over, inside the same actor turn as + /// the answer — and since a page is never smaller than the room, one answer hands + /// over every live unread post. Persisted once per advance, never broadcast: the + /// connection that asked gets its mailbox and no snapshot. + @Test + func advancingTheCursorStopsAtWhatWasHandedOver() async throws { + let persisted = LockIsolated(0) + let store = GraphStore( + onGraphChanged: { _ in persisted.withValue { $0 += 1 } }, + onEnsureSession: { _, _ in }, onDeliverMessage: { _, _, _ in true }, + onMailroomEnabled: { true }) + await store.handle( + .createNode(NodeDraft(title: "Reader", loopType: .turnBased, firstInstruction: "Work"))) + let reader = await store.graph.nodes[0].id + // More than the room keeps: the oldest five are pruned before anyone reads. + for index in 1...(Mailroom.maxNotices + 5) { + await store.handle(.mailroomPost(text: "note \(index)", topic: nil, from: nil)) + } + var pair: [Int32] = [0, 0] + #expect(socketpair(AF_UNIX, SOCK_STREAM, 0, &pair) == 0) + defer { + OutboundChannels.close(pair[0]) + close(pair[1]) + } + await store.addConnection(id: UUID(), fileDescriptor: pair[0]) + guard case .graphChanged = try await nextEvent(from: pair[1]) else { + Issue.record("expected the joining snapshot") + return + } + let writesBefore = persisted.value + + let first = try await store.mailbox( + MailboxQuery(selection: .unread(reader: reader), fullBodies: true, advanceCursor: true)) + #expect(first.posts.count == Mailroom.maxNotices) + #expect(first.posts.first?.id == 6) + #expect(first.remaining == 0) + #expect(first.highestDeliveredID == Mailroom.maxNotices + 5) + #expect(await store.graph.nodes[id: reader]?.lastMailroomRead == Mailroom.maxNotices + 5) + #expect(persisted.value == writesBefore + 1) + + // Caught up: nothing handed over, nothing moved, nothing written. + let second = try await store.mailbox( + MailboxQuery(selection: .unread(reader: reader), advanceCursor: true)) + #expect(second.posts.isEmpty) + #expect(second.highestDeliveredID == nil) + #expect(persisted.value == writesBefore + 1) + + // One more lands and is handed over on the next ask, moving the cursor by one. + await store.handle(.mailroomPost(text: "late", topic: nil, from: nil)) + let third = try await store.mailbox( + MailboxQuery(selection: .unread(reader: reader), advanceCursor: true)) + #expect(third.posts.map(\.id) == [Mailroom.maxNotices + 6]) + #expect(await store.graph.nodes[id: reader]?.lastMailroomRead == Mailroom.maxNotices + 6) + + // No snapshot went out for any advance — only the post's own broadcast. + guard case .graphChanged = try await nextEvent(from: pair[1]) else { + Issue.record("expected the late post's broadcast") + return + } + var probe = [UInt8](repeating: 0, count: 1) + let peeked = recv(pair[1], &probe, 1, MSG_PEEK | MSG_DONTWAIT) + #expect(peeked < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) + } + + /// A reader the graph does not know, or a room that is off, is refused the advance + /// — to the asker alone, with the cursor untouched — and a read that asked for no + /// advance is never refused. + @Test + func theAdvanceIsRefusedWhereTheOldCursorCommandWas() async throws { + let store = GraphStore( + onEnsureSession: { _, _ in }, onDeliverMessage: { _, _, _ in true }, + onMailroomEnabled: { false }) + await store.handle( + .createNode(NodeDraft(title: "Reader", loopType: .turnBased, firstInstruction: "Work"))) + let reader = await store.graph.nodes[0].id + + await #expect(throws: GraphStore.MailboxRefusal.self) { + try await store.mailbox( + MailboxQuery(selection: .unread(reader: reader), advanceCursor: true)) + } + _ = try await store.mailbox(MailboxQuery(selection: .unread(reader: reader))) + _ = try await store.mailbox(MailboxQuery(selection: .board, advanceCursor: true)) + + let on = GraphStore( + onEnsureSession: { _, _ in }, onDeliverMessage: { _, _, _ in true }, + onMailroomEnabled: { true }) + await #expect(throws: GraphStore.MailboxRefusal.self) { + try await on.mailbox( + MailboxQuery(selection: .unread(reader: UUID()), advanceCursor: true)) + } + } + /// The registry answers a mailbox request on the asking connection alone, names the /// project canonically, and refuses one against a project the connection never opened /// the way it refuses a command against it. @@ -306,5 +505,17 @@ struct MailboxTests { return } #expect(refusal.contains("isn't open")) + + // A refused cursor advance comes back the same way, to this connection. + await registry.handle( + .mailbox( + projectPath: project, + query: MailboxQuery(selection: .unread(reader: UUID()), advanceCursor: true)), + connectionID: connection) + guard case .errorOccurred(let refusedAdvance) = try await nextEvent(from: pair[1]) else { + Issue.record("expected the advance to be refused") + return + } + #expect(refusedAdvance.contains("loop identity") || refusedAdvance.contains("Mailroom is off")) } } diff --git a/graphcode/Tests/MailroomCommandTests.swift b/graphcode/Tests/MailroomCommandTests.swift index 0fffcc10..915a6d16 100644 --- a/graphcode/Tests/MailroomCommandTests.swift +++ b/graphcode/Tests/MailroomCommandTests.swift @@ -327,6 +327,107 @@ func aRoomThatTriagedItselfSaysSoAndWhereTheFullTextIs() { #expect(triaged.split(separator: "\n").dropFirst() == whole.split(separator: "\n").dropFirst()) } +@Test +func aPageSaysWhatItLeftAndJSONSaysSoOnlyThen() throws { + var graph = LoopGraph(project: ProjectRef(path: "/tmp/x", name: "x")) + graph.mailroom = (1...(Mailroom.inboxPageSize + 2)).map { + MailroomPost( + id: $0, at: Date(timeIntervalSince1970: 0), authorID: nil, author: "a human", + topic: nil, body: "note \($0)") + } + let reader = UUID() + + let page = served(graph, .unread(reader: reader), fullBodies: true) + let rendered = GraphcodeCommand.renderMailroom(page, project: graph.project, unread: true) + #expect( + rendered.hasSuffix( + "2 more unread past #\(Mailroom.inboxPageSize) — run the same command again for the next page" + )) + #expect(rendered.split(separator: "\n").count == Mailroom.inboxPageSize + 2) + + struct Board: Decodable { + let posts: [MailroomPost] + let remaining: Int? + } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let paged = try decoder.decode( + Board.self, from: try #require(GraphcodeCommand.renderMailroomJSON(page).data(using: .utf8))) + #expect(paged.remaining == 2) + #expect(paged.posts.count == Mailroom.inboxPageSize) + + let (small, smallReader) = boardWithPosts() + let whole = try decoder.decode( + Board.self, + from: try #require( + GraphcodeCommand.renderMailroomJSON( + served(small, .unread(reader: smallReader.id), fullBodies: true) + ).data(using: .utf8))) + #expect(whole.remaining == nil) + #expect( + !GraphcodeCommand.renderMailroom( + served(small, .unread(reader: smallReader.id)), project: small.project, unread: true + ).contains("more unread")) +} + +@Test +func prunedMailIsSaidOutLoudWithAndWithoutPosts() throws { + var graph = LoopGraph(project: ProjectRef(path: "/tmp/x", name: "x")) + var reader = LoopNode(title: "Reader", loopType: .turnBased) + reader.lastMailroomRead = 2 + graph.nodes.append(reader) + graph.mailroom = [ + MailroomPost( + id: 2, at: Date(timeIntervalSince1970: 0), authorID: nil, author: "a human", + topic: nil, body: "read"), + MailroomPost( + id: 6, at: Date(timeIntervalSince1970: 1), authorID: nil, author: "a human", + topic: nil, body: "survived"), + ] + + let withPosts = GraphcodeCommand.renderMailroom( + served(graph, .unread(reader: reader.id)), project: graph.project, unread: true) + #expect(withPosts.contains("#6")) + #expect(withPosts.contains("3 posts landed since your last inbox and were pruned")) + + graph.mailroom.removeLast() + graph.mailroom.append( + MailroomPost( + id: 6, at: Date(timeIntervalSince1970: 1), authorID: nil, author: "a human", + topic: nil, body: "survived")) + graph.nodes[id: reader.id]?.lastMailroomRead = 6 + var gone = graph + gone.mailroom = [graph.mailroom[0]] + gone.nodes[id: reader.id]?.lastMailroomRead = 2 + // Only #2 survives; the cursor is at 2, nothing above it exists: nothing was pruned + // *unread* — the count needs a surviving post above the cursor to be knowable. + #expect( + GraphcodeCommand.renderMailroom( + served(gone, .unread(reader: reader.id)), project: graph.project, unread: true) + == "no unread posts") + + // JSON says it only when there is something to say — read off the room as it stood + // when three posts had been pruned unread. + graph.nodes[id: reader.id]?.lastMailroomRead = 2 + struct Board: Decodable { + let prunedUnread: Int? + } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let pruned = try decoder.decode( + Board.self, + from: try #require( + GraphcodeCommand.renderMailroomJSON(served(graph, .unread(reader: reader.id))) + .data(using: .utf8))) + #expect(pruned.prunedUnread == 3) + let whole = try decoder.decode( + Board.self, + from: try #require( + GraphcodeCommand.renderMailroomJSON(served(graph, .board, fullBodies: true)) + .data(using: .utf8))) + #expect(whole.prunedUnread == nil) +} + @Test func searchFiltersWhatIsShownButNeverWhatIsRemembered() { let (graph, reader) = boardWithPosts() diff --git a/graphcode/Tests/MailroomTests.swift b/graphcode/Tests/MailroomTests.swift index 0b39f853..7514c797 100644 --- a/graphcode/Tests/MailroomTests.swift +++ b/graphcode/Tests/MailroomTests.swift @@ -101,16 +101,25 @@ struct MailroomTests { #expect(graph.mailroom.last?.id == Mailroom.maxNotices + 5) } + /// The cursor moves through the mailbox request that hands the mail over + /// (`MailboxQuery.advanceCursor`), never backward, and never on the legacy + /// `mailroomInbox`, which a daemon now refuses. @Test - func syncAdvancesCursorAndNeverMovesItBackward() async { + func syncAdvancesCursorAndNeverMovesItBackward() async throws { let store = await makeStore() let ids = nodeIDs(await store.graph) await store.handle(.mailroomPost(text: "one", topic: nil, from: ids[0])) - await store.handle(.mailroomInbox(from: ids[1])) + _ = try await store.mailbox( + MailboxQuery(selection: .unread(reader: ids[1]), advanceCursor: true)) var graph = await store.graph #expect(graph.nodes[id: ids[1]]?.lastMailroomRead == 1) + _ = try await store.mailbox( + MailboxQuery(selection: .unread(reader: ids[1]), advanceCursor: true)) + graph = await store.graph + #expect(graph.nodes[id: ids[1]]?.lastMailroomRead == 1) + await store.handle(.mailroomInbox(from: ids[1])) graph = await store.graph #expect(graph.nodes[id: ids[1]]?.lastMailroomRead == 1) diff --git a/graphcode/Tests/RemoteCLIShimTests.swift b/graphcode/Tests/RemoteCLIShimTests.swift index d0795f2e..7b627fe8 100644 --- a/graphcode/Tests/RemoteCLIShimTests.swift +++ b/graphcode/Tests/RemoteCLIShimTests.swift @@ -509,12 +509,20 @@ extension RemoteCLIShimTests { command: .mailroomPost( text: "the board is for everyone", topic: nil, from: nil))) + // `--mark` walks the unread mail through the mailbox, headlines only, page by + // page — never the legacy `mailroomInbox`, which a daemon now refuses. let synced = try runShim( ["mailroom", "sync", Self.project, "--mark"], environment: session, graph: graph) #expect(synced.stdout == "marked read up to #3\n") #expect( synced.commands.dropFirst().first - == .graphCommand(projectPath: Self.project, command: .mailroomInbox(from: reader.id))) + == .mailbox( + projectPath: Self.project, + query: MailboxQuery( + selection: .unread(reader: reader.id), fullBodies: false, advanceCursor: true))) + #expect( + !synced.commands.contains { if case .graphCommand = $0 { return true } else { return false } } + ) let watching = try runShim( ["mailroom", "watch", Self.project, "--topic", "build"], environment: session, @@ -639,6 +647,65 @@ extension RemoteCLIShimTests { #expect(spelled.stdout == "posted #5 (claims)\n") } + /// A plain `inbox` is one request: the page of unread posts and the cursor advance + /// ride the same `mailbox`, with no `mailroomInbox` behind it — the way the Swift CLI + /// sends it. `--mark` is the one spelling that still marks everything read, unseen. + @Test + func inboxAsksForOnePageAndMovesTheCursorInTheSameRequest() throws { + let (graph, reader) = Self.board() + let session = ["ZMX_SESSION": "graphcode-\(reader.id.uuidString)"] + + let inbox = try runShim( + ["mailroom", "inbox", Self.project], environment: session, graph: graph) + #expect(inbox.status == 0) + #expect( + inbox.commands == [ + .openProject(path: Self.project), + .mailbox( + projectPath: Self.project, + query: MailboxQuery(selection: .unread(reader: reader.id), advanceCursor: true)), + ]) + + let full = try runShim( + ["mailroom", "inbox", Self.project, "--json"], environment: session, graph: graph) + #expect( + full.commands.last + == .mailbox( + projectPath: Self.project, + query: MailboxQuery( + selection: .unread(reader: reader.id), fullBodies: true, advanceCursor: true))) + } + + /// A backlog longer than a page prints the page and says what it left, byte-equal + /// to the Swift renderer — in text and in `--json`. + @Test + func aPagedBacklogRendersByteEqualToo() throws { + var graph = LoopGraph(project: ProjectRef(path: Self.project, name: "widget")) + let reader = LoopNode(title: "Reader", loopType: .goalBased) + graph.nodes.append(reader) + graph.mailroom = (1...(Mailroom.inboxPageSize + 3)).map { index in + MailroomPost( + id: index, at: Date(timeIntervalSince1970: 1_756_000_000 + Double(index)), + authorID: nil, author: "a human", topic: nil, body: "note \(index)") + } + let session = ["ZMX_SESSION": "graphcode-\(reader.id.uuidString)"] + + let text = try runShim( + ["mailroom", "inbox", Self.project], environment: session, graph: graph) + let expected = GraphcodeCommand.renderMailroom( + Self.served(graph, .unread(reader: reader.id)), project: graph.project, unread: true) + #expect(text.stdout == expected + "\n") + #expect(text.stdout.contains("3 more unread past #\(Mailroom.inboxPageSize)")) + + let json = try runShim( + ["mailroom", "inbox", Self.project, "--json"], environment: session, graph: graph) + #expect( + json.stdout + == GraphcodeCommand.renderMailroomJSON( + Self.served(graph, .unread(reader: reader.id), fullBodies: true)) + "\n") + #expect(json.stdout.contains("\"remaining\":3")) + } + /// `read` and `list` ask the mailbox for exactly what they print and send no command — /// so neither can move a cursor by accident — and they ask the way the Swift CLI asks: /// one post whole, or the whole room with every body.