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.