diff --git a/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift b/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift index 07c9fc86..e5ee6cbb 100644 --- a/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift +++ b/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift @@ -849,91 +849,53 @@ extension GraphcodeCommand { return projects.map { "\($0.name) \($0.path)" }.joined(separator: "\n") } - /// The board for a terminal. `mail list` prints the whole thing (`reader` - /// nil); `mail inbox` passes the reading loop's id and prints only what its - /// cursor has not covered — the subtraction is `Mailroom.unread`, the arithmetic - /// the daemon's cursor contract rests on, so the CLI's "unread" and the store's can - /// never disagree. `headlines` truncates each body to a triage line's worth (the - /// deep read is `mail read `); `search` keeps only posts whose author, - /// topic or body contains the text, case-insensitively — a list-side filter, never - /// a sync-side one, because marking unread mail read without showing it is the one - /// way this verb could lose mail. + /// The room for a terminal, from the mailbox the daemon answered + /// (`DaemonCommand.mailbox`). `mail list` asks for the whole room (`unread` false); + /// `mail inbox` asks for what the reading loop's cursor has not covered. The + /// subtraction, the search and the triage all happened in the daemon, so what + /// arrives is exactly what prints — this only decides the words around it. A + /// mailbox whose bodies the room cut says so (`bodiesTrimmed`), and unless the + /// caller asked for headlines itself, the header tells the reader it is reading a + /// triaged room and where the full text is. public static func renderMailroom( - _ graph: LoopGraph, unreadFor readerID: UUID? = nil, headlines: Bool = false, - search: String? = nil, autoTriage: Bool = false + _ mailbox: Mailbox, project: ProjectRef, unread: Bool, headlines: Bool = false, + search: String? = nil ) -> String { - var posts: [MailroomPost] - if let readerID { - posts = Mailroom.unread( - in: graph.mailroom, since: graph.nodes[id: readerID]?.lastMailroomRead) - } else { - posts = graph.mailroom - } - if let search, !search.isEmpty { - let needle = search.lowercased() - posts = posts.filter { - $0.body.lowercased().contains(needle) || $0.author.lowercased().contains(needle) - || $0.topic?.lowercased().contains(needle) == true - } - } + let posts = mailbox.posts guard !posts.isEmpty else { if let search, !search.isEmpty { - return readerID == nil - ? "no posts match '\(search)'" - : "no unread posts match '\(search)'" + return unread ? "no unread posts match '\(search)'" : "no posts match '\(search)'" } - return readerID == nil - ? "the room is empty — post one: graphcode mail post " - : "no unread posts" + return unread + ? "no unread posts" + : "the room is empty — post one: graphcode mail post " } - // `sync` asks to be triaged; `--headlines` and `--full` are the two ways to say - // so explicitly. Announced on the line above the posts rather than silently, so a - // loop reading a truncated board knows it is reading one. - let triaged = autoTriage && Mailroom.needsTriage(posts) - let label = readerID == nil ? "mailroom" : "mailroom, unread" - var header = - "\(graph.project.name) \(label): \(posts.count) post\(posts.count == 1 ? "" : "s")" + let triaged = mailbox.bodiesTrimmed && !headlines + let label = unread ? "mailroom, unread" : "mailroom" + var header = "\(project.name) \(label): \(posts.count) post\(posts.count == 1 ? "" : "s")" if triaged { header += " — headlines only, that is a lot to read at once. " - + "Full text: graphcode mail read \(graph.project.path) " + + "Full text: graphcode mail read \(project.path) " } var lines = [header] for post in posts { - lines.append(headlines || triaged ? " \(renderHeadline(post))" : " \(render(post))") + lines.append( + headlines || mailbox.bodiesTrimmed ? " \(renderHeadline(post))" : " \(render(post))") } return lines.joined(separator: "\n") } - /// The board as one machine-readable object — the same posts `renderMailroom` - /// would print (the same `search` filter included, so `--search --json` shows a - /// filtered board, never quietly an unfiltered one), plus the reader's cursor so a - /// client can compute unread itself. Dates are ISO-8601, pinned by test — the - /// encoder's default (seconds since 2001) is a wire format only this process - /// should ever have to know about. - public static func renderMailroomJSON( - _ graph: LoopGraph, unreadFor readerID: UUID? = nil, search: String? = nil - ) -> String { + /// The mailbox as one machine-readable object — the same posts `renderMailroom` + /// would print, plus the reader's cursor so a client can compute unread itself. + /// Dates are ISO-8601, pinned by test — the encoder's default (seconds since 2001) + /// is a wire format only this process should ever have to know about. + public static func renderMailroomJSON(_ mailbox: Mailbox) -> String { struct Board: Encodable { var posts: [MailroomPost] var lastRead: Int? } - var posts: [MailroomPost] - if let readerID { - posts = Mailroom.unread( - in: graph.mailroom, since: graph.nodes[id: readerID]?.lastMailroomRead) - } else { - posts = graph.mailroom - } - if let search, !search.isEmpty { - let needle = search.lowercased() - posts = posts.filter { - $0.body.lowercased().contains(needle) || $0.author.lowercased().contains(needle) - || $0.topic?.lowercased().contains(needle) == true - } - } - let lastRead = readerID.flatMap { graph.nodes[id: $0]?.lastMailroomRead } - let board = Board(posts: posts, lastRead: lastRead) + let board = Board(posts: mailbox.posts, lastRead: mailbox.lastRead) let encoder = JSONEncoder() encoder.outputFormatting = [.sortedKeys] encoder.dateEncodingStrategy = .iso8601 @@ -941,28 +903,28 @@ extension GraphcodeCommand { return String(decoding: data, as: UTF8.self) } - /// `status`'s one-line window onto the board: how many posts exist and — when the - /// caller is a loop with a cursor here — how many are unread for it. `nil` when the - /// board is empty, so a project that never touched the Mailroom renders exactly + /// `status`'s one-line window onto the room: how many posts exist and — when the + /// caller is a loop with a cursor here — whether any are unread for it. `nil` when + /// the room is empty, so a project that never touched the Mailroom renders exactly /// as it did before this line existed. The point is cost: the briefing already sends /// loops to `status` before claiming or creating work, and this makes the "is there - /// mail I should know about" check ride along for free. + /// mail I should know about" check ride along for free — off the snapshot's digest, + /// without the posts themselves ever crossing the socket. public static func renderMailroomStatusLine( _ graph: LoopGraph, readerID: UUID? = nil ) -> String? { - guard !graph.mailroom.isEmpty else { return nil } - let total = graph.mailroom.count - let plural = total == 1 ? "" : "s" - // "Unread for you" needs a *you* this board knows: the daemon refuses sync for a - // reader absent from the graph, so the status line claims no unread for one + let digest = graph.boardDigest + guard !digest.isEmpty else { return nil } + let plural = digest.count == 1 ? "" : "s" + // "For you" needs a *you* this room knows: the daemon refuses the cursor advance + // for a reader absent from the graph, so the status line claims nothing for one // either — a foreign or stale id gets the plain count, same as a human. - guard let readerID, graph.nodes[id: readerID] != nil else { - return "mailroom: \(total) post\(plural)" + guard let readerID, let reader = graph.nodes[id: readerID] else { + return "mailroom: \(digest.count) post\(plural)" } - let unread = Mailroom.unread( - in: graph.mailroom, since: graph.nodes[id: readerID]?.lastMailroomRead - ).count - return "mailroom: \(total) post\(plural), \(unread) unread for you" + let unread = digest.latestID > (reader.lastMailroomRead ?? 0) + return "mailroom: \(digest.count) post\(plural), " + + (unread ? "unread mail for you" : "nothing unread for you") } /// One post, one line — the same identification the daemon's wake nudge quotes, so @@ -990,11 +952,15 @@ extension GraphcodeCommand { } /// `mail post`'s answer — the sequence number is what the author's own log and - /// any replier's `node send` can refer to the note by. - public static func renderPosted(_ graph: LoopGraph) -> String { - guard let post = graph.mailroom.last else { return "posted" } - let topic = post.topic.map { " (\($0))" } ?? "" - return "posted #\(post.id)\(topic)" + /// any replier's `node send` can refer to the note by. Read off the digest of the + /// graph the post came back on; `topic` is the one the caller typed, spelled the way + /// the daemon keeps it (trimmed, lower-cased, absent when blank). + public static func renderPosted(_ graph: LoopGraph, topic: String? = nil) -> String { + let latest = graph.boardDigest.latestID + guard latest > 0 else { return "posted" } + let spelled = topic?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let suffix = spelled.flatMap { $0.isEmpty ? nil : " (\($0))" } ?? "" + return "posted #\(latest)\(suffix)" } public static func describe(_ error: ParseError) -> String { diff --git a/GraphcodeKit/Sources/Domain/LoopGraph.swift b/GraphcodeKit/Sources/Domain/LoopGraph.swift index adaba123..bb6e55e5 100644 --- a/GraphcodeKit/Sources/Domain/LoopGraph.swift +++ b/GraphcodeKit/Sources/Domain/LoopGraph.swift @@ -20,16 +20,25 @@ public struct LoopGraph: Identifiable, Codable, Equatable, Sendable { public var scope: LoopGraphScope public var nodes: IdentifiedArrayOf public var edges: IdentifiedArrayOf - /// The project's Mailroom — every post any loop has dropped onto the shared board, - /// oldest first, notes and mirrored records each capped on their own budget + /// The project's Mailroom — every post any loop has dropped onto the shared room, + /// oldest first, notices and mirrored letters each capped on their own budget /// (`Mailroom.maxNotices`, `Mailroom.maxLetters`). Kept on the graph rather than in a /// side store so it inherits for free everything graph state already has: one - /// writer (the daemon), atomic persistence beside the graph file, a snapshot in - /// every `.graphChanged` (which is how the CLI reads it — no second read path), and - /// the global graph at `graphcode://global` becoming a cross-project board without - /// a line of extra code. Empty for anyone who never touches the board; graphs saved - /// before the field existed decode with it empty. + /// writer (the daemon), atomic persistence beside the graph file, and the global + /// graph at `graphcode://global` becoming a cross-project room without a line of + /// extra code. Empty for anyone who never touches the room; graphs saved before the + /// field existed decode with it empty. + /// + /// **Not on the wire.** A `.graphChanged` snapshot carries `mailroomDigest` in this + /// field's place (`wireSnapshot()`): the posts were three quarters of every + /// broadcast frame on a busy graph (issue #288). A client reads them through + /// `DaemonCommand.mailbox`, bounded and on request, and one holding a copy fills this + /// field back in itself. public var mailroom: [MailroomPost] = [] + /// What a snapshot says about the room instead of shipping it — see `mailroom`. + /// Set only on the copy a daemon sends (`wireSnapshot()`); `nil` on the graph the + /// daemon owns and persists. + public var mailroomDigest: MailroomDigest? public var project: ProjectRef { get { scope.projectRef } @@ -38,6 +47,21 @@ public struct LoopGraph: Identifiable, Codable, Equatable, Sendable { public var isGlobal: Bool { scope.isGlobal } + /// The room as a snapshot describes it, from whichever side of the socket this + /// graph is on: the digest a daemon stamped, or — for a graph that still carries its + /// posts, as the daemon's own does and a pre-digest daemon's snapshots did — one + /// computed from them. + public var boardDigest: MailroomDigest { mailroomDigest ?? MailroomDigest(of: mailroom) } + + /// This graph as a `.graphChanged` frame carries it: the posts stripped and their + /// digest stamped in their place. Everything else is the graph exactly as it is. + public func wireSnapshot() -> LoopGraph { + var copy = self + copy.mailroomDigest = MailroomDigest(of: mailroom) + copy.mailroom = [] + return copy + } + public init( id: UUID = UUID(), scope: LoopGraphScope, @@ -254,7 +278,7 @@ public struct LoopGraph: Identifiable, Codable, Equatable, Sendable { // MARK: - Coding private enum CodingKeys: String, CodingKey { - case id, nodes, edges, mailroom + case id, nodes, edges, mailroom, mailroomDigest /// Persisted as a `ProjectRef` rather than as the scope enum. Every graph on disk /// predates `LoopGraphScope`, and the ref round-trips both cases losslessly (the /// global graph's reserved path decodes straight back to `.global`), so there was @@ -272,6 +296,7 @@ public struct LoopGraph: Identifiable, Codable, Equatable, Sendable { mailroom = try container.decodeIfPresent([MailroomPost].self, forKey: .mailroom) ?? decoder.legacyMailroomValue([MailroomPost].self, "artifactory") ?? [] + mailroomDigest = try container.decodeIfPresent(MailroomDigest.self, forKey: .mailroomDigest) } public func encode(to encoder: Encoder) throws { @@ -283,5 +308,6 @@ public struct LoopGraph: Identifiable, Codable, Equatable, Sendable { // Absent while empty, so a graph file nobody has posted to stays byte-for-byte // what it was — the same reason `hasActiveDependents` never reaches disk. if !mailroom.isEmpty { try container.encode(mailroom, forKey: .mailroom) } + if let mailroomDigest { try container.encode(mailroomDigest, forKey: .mailroomDigest) } } } diff --git a/GraphcodeKit/Sources/GraphStore.swift b/GraphcodeKit/Sources/GraphStore.swift index cd4703bf..fbd75e33 100644 --- a/GraphcodeKit/Sources/GraphStore.swift +++ b/GraphcodeKit/Sources/GraphStore.swift @@ -508,7 +508,7 @@ public actor GraphStore { // snapshot it was joining for. OutboundChannels.open(fileDescriptor) connections[id] = fileDescriptor - send(.graphChanged(graph), to: id) + send(.graphChanged(graph.wireSnapshot()), to: id) } public func removeConnection(_ id: UUID) { @@ -1690,6 +1690,15 @@ 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 } + } + /// 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. @@ -3204,8 +3213,9 @@ public actor GraphStore { /// other caller wants `broadcast` — a graph change that isn't saved is a graph change /// lost at the next daemon restart. private func notifyClients() { + let snapshot = graph.wireSnapshot() for id in connections.keys { - send(.graphChanged(graph), to: id) + send(.graphChanged(snapshot), to: id) } } diff --git a/GraphcodeKit/Sources/IPC/DaemonProtocol.swift b/GraphcodeKit/Sources/IPC/DaemonProtocol.swift index 7a260f4d..aeed896a 100644 --- a/GraphcodeKit/Sources/IPC/DaemonProtocol.swift +++ b/GraphcodeKit/Sources/IPC/DaemonProtocol.swift @@ -1,4 +1,5 @@ import Foundation +import MailroomKit /// What the app (or, eventually, the `graphcode` CLI) can ask `graphcoded` to do. See /// docs/03-architecture.md#background-daemons and @@ -38,6 +39,13 @@ public enum DaemonCommand: Codable, Sendable, Equatable { /// `forgetProject` precisely because it is. case deleteProjectGraph(path: String) case graphCommand(projectPath: String, command: GraphCommand) + /// Read the project's Mailroom — the whole room, one loop's unread slice of it, or + /// one post — answered on this connection alone with a `.mailbox`. This is the read + /// path the room has instead of riding every `.graphChanged`: a snapshot carries only + /// `LoopGraph.mailroomDigest`, and whoever wants posts asks for exactly the posts it + /// wants (issue #288). Requires the project to be resident in the daemon — opened by + /// some connection — the same rule as `.graphCommand`. + case mailbox(projectPath: String, query: MailboxQuery) } /// Mutations against exactly one project's graph — this is what `GraphStore.handle` @@ -138,12 +146,12 @@ 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 CLI reads the board out of the - /// graph snapshot it already gets from `openProject`; this is the write that makes - /// "unread" mean something the *next* sync can subtract from. Requires a loop - /// identity: a human reading the board needs no cursor, since nothing downstream - /// tracks what they have seen. + /// 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. 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 @@ -192,7 +200,11 @@ public indirect enum GraphCommand: Codable, Sendable, Equatable { /// other connected client subscribed to the same project, so two open windows never /// disagree about that project's graph state. `graphChanged` always carries the *full* /// graph rather than a diff: simplest possible thing that keeps every client in sync, -/// and small enough at this scale that a diff protocol isn't worth the complexity yet. +/// and small enough at this scale that a diff protocol isn't worth the complexity yet — +/// with one exception. The Mailroom's posts are left out (`LoopGraph.wireSnapshot()`) +/// and their digest sent instead: they were three quarters of every frame on a busy +/// graph, re-sent to every client on every change, and a client reads them through +/// `DaemonCommand.mailbox` when it actually wants them. /// The graph's own `project` field is what tells a client which project a /// `graphChanged` event belongs to — there's no separate "project opened" event, /// because joining a project already gets one of these as an immediate snapshot (see @@ -201,4 +213,8 @@ public enum DaemonEvent: Codable, Sendable, Equatable { case recentProjectsListed([ProjectRef]) case graphChanged(LoopGraph) case errorOccurred(String) + /// The answer to a `DaemonCommand.mailbox`, sent only to the connection that asked. + /// `projectPath` is the canonical spelling the daemon routed the query to, which is + /// the id an app keys its projects by. + case mailbox(projectPath: String, mailbox: Mailbox) } diff --git a/GraphcodeKit/Sources/ProjectRegistry.swift b/GraphcodeKit/Sources/ProjectRegistry.swift index 1ef10de6..964bd361 100644 --- a/GraphcodeKit/Sources/ProjectRegistry.swift +++ b/GraphcodeKit/Sources/ProjectRegistry.swift @@ -356,6 +356,23 @@ public actor ProjectRegistry { case .refused(let reason): send(.errorOccurred(reason), to: fileDescriptor) } + + case .mailbox(let path, let query): + // Routed and gated exactly as a `.graphCommand`: the room belongs to the project + // the open landed on, and a query against a project this connection never + // opened is answered the way a command against one is. + switch routing(for: path, isSidebar: sidebarConnections.contains(connectionID)) { + case .project(let canonicalPath): + guard let store = stores[canonicalPath] else { + send(.errorOccurred("\(path) isn't open — open it first."), to: fileDescriptor) + return + } + send( + .mailbox(projectPath: canonicalPath, mailbox: await store.mailbox(query)), + 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 7d6419d8..f5261ae7 100644 --- a/GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift +++ b/GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift @@ -314,6 +314,15 @@ public enum RemoteGraphAccess { fail(value["_0"]) return value["_0"] + def mailbox(self, path, query): + # The read half of every mail verb: posts no longer ride `graphChanged` + # (only their digest does), so a verb asks for exactly the posts it prints. + self.send({"mailbox": {"projectPath": path, "query": query}}) + key, value = self.wait_for(["mailbox", "errorOccurred"]) + if key == "errorOccurred": + fail(value["_0"]) + return value["mailbox"] + def known_projects(self): self.send({"listRecentProjects": {}}) _, value = self.wait_for(["recentProjectsListed"]) @@ -355,13 +364,11 @@ public enum RemoteGraphAccess { return flags - # The board's own arithmetic, ported from MailroomKit rather than asked for over the - # wire: `openProject`'s snapshot already carries every post and every reader's cursor, so - # `read` and `list` send no command at all and `sync` prints from the snapshot it took - # before advancing the cursor. RemoteCLIShimTests asserts this renderer byte-equal against - # GraphcodeCommand's, which is the only thing that makes a second copy of it safe to have. - TRIAGE_AFTER_POSTS = 12 - TRIAGE_AFTER_BYTES = 4096 + # The renderers, ported from GraphcodeCommand: the daemon does the subtraction, the + # search and the triage (`Mailroom.serve`), and what comes back in a mailbox is + # exactly what prints -- this only decides the words around it. RemoteCLIShimTests + # asserts each renderer byte-equal against GraphcodeCommand's, which is the only thing + # that makes a second copy of it safe to have. # Foundation encodes Date as seconds since 2001-01-01, not since the epoch. REFERENCE_DATE_OFFSET = 978307200 # Spelled out rather than left to strftime("%b"), which follows the remote host's @@ -373,17 +380,14 @@ public enum RemoteGraphAccess { HEADLINE_BUDGET = 80 - def mailroom_unread(posts, last_read): - if last_read is None: - return list(posts) - return [post for post in posts if post.get("id", 0) > last_read] - - - def mailroom_needs_triage(posts): - if len(posts) > TRIAGE_AFTER_POSTS: - return True - weight = sum(len((post.get("body") or "").encode("utf-8")) for post in posts) - return weight > TRIAGE_AFTER_BYTES + def board_digest(graph): + # What the snapshot says about the room in place of the room -- or, from a daemon + # that still ships posts, the same two numbers read off them (LoopGraph.boardDigest). + digest = graph.get("mailroomDigest") + if digest is not None: + return digest + posts = graph.get("mailroom") or [] + return {"count": len(posts), "latestID": posts[-1].get("id", 0) if posts else 0} def mailroom_cursor(graph, reader): @@ -449,43 +453,20 @@ public enum RemoteGraphAccess { return "".join(clusters[:HEADLINE_BUDGET]) + ELLIPSIS - def folded(text): - # Swift's String.contains compares canonically, so "e" + U+0301 and U+00E9 match - # there and would not here: Python's `in` is a code-point test. A body carrying - # decomposed text -- which anything sourced from a macOS path routinely does -- - # would otherwise be findable from the Mac and invisible from the remote host, - # which is the board reporting that mail does not exist. - return unicodedata.normalize("NFC", (text or "").lower()) - - - def filtered_posts(posts, search): - if not search: - return posts - needle = folded(search) - return [post for post in posts - if needle in folded(post.get("body")) - or needle in folded(post.get("author")) - or needle in folded(post.get("topic"))] - - - def render_board(graph, reader=None, headlines=False, search=None, auto_triage=False): - project = graph.get("project") or {} - posts = graph.get("mailroom") or [] - if reader is not None: - cursor, _ = mailroom_cursor(graph, reader) - posts = mailroom_unread(posts, cursor) - posts = filtered_posts(posts, search) + def render_board(mailbox, project, unread, headlines=False, search=None): + posts = mailbox.get("posts") or [] if not posts: if search: - if reader is None: - return "no posts match '%s'" % search - return "no unread posts match '%s'" % search - if reader is not None: + 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)) - triaged = auto_triage and mailroom_needs_triage(posts) - label = "mailroom" if reader is None else "mailroom, unread" + trimmed = bool(mailbox.get("bodiesTrimmed")) + triaged = trimmed and not headlines + label = "mailroom, unread" if unread else "mailroom" header = "%s %s: %d post%s" % (project.get("name", "?"), label, len(posts), "" if len(posts) == 1 else "s") if triaged: @@ -494,7 +475,7 @@ public enum RemoteGraphAccess { % (EM_DASH, project.get("path", ""))) lines = [header] for post in posts: - lines.append(" " + (render_headline(post) if headlines or triaged + lines.append(" " + (render_headline(post) if headlines or trimmed else render_post(post))) return "\n".join(lines) @@ -520,15 +501,10 @@ public enum RemoteGraphAccess { return encoded - def render_board_json(graph, reader=None, search=None): - posts = graph.get("mailroom") or [] - last_read = None - if reader is not None: - last_read, _ = mailroom_cursor(graph, reader) - posts = mailroom_unread(posts, last_read) - board = {"posts": [encoded_post(post) for post in filtered_posts(posts, search)]} - if last_read is not None: - board["lastRead"] = last_read + def render_board_json(mailbox): + board = {"posts": [encoded_post(post) for post in (mailbox.get("posts") or [])]} + if mailbox.get("lastRead") is not None: + board["lastRead"] = mailbox["lastRead"] # 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 @@ -538,24 +514,25 @@ public enum RemoteGraphAccess { def mailroom_status_line(graph, reader): - posts = graph.get("mailroom") or [] - if not posts: + digest = board_digest(graph) + count = digest.get("count") or 0 + if not count: return None - plural = "" if len(posts) == 1 else "s" + plural = "" if count == 1 else "s" cursor, known = mailroom_cursor(graph, reader) if reader else (None, False) if not known: - return "mailroom: %d post%s" % (len(posts), plural) - return "mailroom: %d post%s, %d unread for you" % ( - len(posts), plural, len(mailroom_unread(posts, cursor))) + return "mailroom: %d post%s" % (count, plural) + unread = (digest.get("latestID") or 0) > (cursor or 0) + return "mailroom: %d post%s, %s" % ( + count, plural, "unread mail for you" if unread else "nothing unread for you") - def render_posted(graph): - posts = graph.get("mailroom") or [] - if not posts: + def render_posted(graph, topic): + latest = board_digest(graph).get("latestID") or 0 + if not latest: return "posted" - post = posts[-1] - topic = (" (%s)" % post["topic"]) if post.get("topic") is not None else "" - return "posted #%s%s" % (post.get("id"), topic) + spelled = (topic or "").strip().lower() + return "posted #%s%s" % (latest, (" (%s)" % spelled) if spelled else "") def render(graph): @@ -822,7 +799,8 @@ public enum RemoteGraphAccess { if not text: fail("missing note") payload = {"text": text, "topic": flags.get("topic"), "from": self_node_id()} - run_and_report(project, {"mailroomPost": payload}, render_posted) + run_and_report(project, {"mailroomPost": payload}, + lambda graph: render_posted(graph, flags.get("topic"))) return if subverb in ("inbox", "sync"): @@ -833,28 +811,37 @@ public enum RemoteGraphAccess { "mail list`" % EM_DASH) daemon = Daemon() project = resolve_project(daemon, project) - # Unread is computed from the snapshot taken *before* the cursor moves; reading - # it afterwards would report every post as read. Same one-round-trip race the - # Swift CLI documents and accepts. 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"]) - headlines = "headlines" in flags - full = "full" in flags - if "json" in flags: - print(render_board_json(graph, reader=reader)) - elif "mark" in flags: - posts = graph.get("mailroom") or [] - latest = posts[-1].get("id", 0) if posts else 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: print("marked read %s the room is empty" % EM_DASH) + elif "json" in flags: + print(render_board_json(mailbox)) else: - print(render_board(graph, reader=reader, headlines=headlines, - auto_triage=not headlines and not full)) + print(render_board(mailbox, graph.get("project") or {}, True, + headlines=headlines)) return if subverb == "read": @@ -863,11 +850,11 @@ public enum RemoteGraphAccess { post_id = mailroom_post_id(arguments[0]) daemon = Daemon() project = resolve_project(daemon, project) - graph = daemon.open_project(project) - for post in graph.get("mailroom") or []: - if post.get("id") == post_id: - print(render_post(post)) - return + daemon.open_project(project) + mailbox = daemon.mailbox(project, {"selection": {"post": {"id": post_id}}}) + for post in mailbox.get("posts") or []: + print(render_post(post)) + return fail("no post #%d on this board %s `graphcode mail list %s` shows the " "ids that exist" % (post_id, EM_DASH, project)) @@ -875,10 +862,15 @@ public enum RemoteGraphAccess { daemon = Daemon() project = resolve_project(daemon, project) graph = daemon.open_project(project) + query = {"selection": {"board": {}}, "fullBodies": True} + if flags.get("search"): + query["search"] = flags["search"] + mailbox = daemon.mailbox(project, query) if "json" in flags: - print(render_board_json(graph, search=flags.get("search"))) + print(render_board_json(mailbox)) else: - print(render_board(graph, search=flags.get("search"))) + print(render_board(mailbox, graph.get("project") or {}, False, + search=flags.get("search"))) return watcher = self_node_id() diff --git a/MailroomKit/Sources/Mailroom.swift b/MailroomKit/Sources/Mailroom.swift index e848fa5b..5808a585 100644 --- a/MailroomKit/Sources/Mailroom.swift +++ b/MailroomKit/Sources/Mailroom.swift @@ -201,3 +201,169 @@ public enum Mailroom { return posts.filter { kept.contains($0.id) } } } + +/// What a graph snapshot says about the room in place of the room itself. +/// +/// The posts used to ride every `.graphChanged`, and on a busy graph they were three +/// quarters of every frame — 133 KB of a 176 KB broadcast, re-sent to every client on +/// every presence tick, to clients that already had them and clients that never read +/// mail at all (issue #288). A snapshot now carries only this: enough for `status` to +/// say there is mail, for a poster to learn its sequence number, and for a client +/// holding a copy of the room to know whether that copy is stale. The posts themselves +/// are served on request, bounded, by `Mailroom.serve`. +public struct MailroomDigest: Codable, Equatable, Sendable { + public var count: Int + /// The highest id on the room, `0` while empty. + public var latestID: Int + /// Changes whenever a post is added, pruned, or edited in place — the one in-place + /// edit being an author's deletion, which `count` and `latestID` cannot see. Stable + /// across processes (FNV-1a over the fields that can change), so two daemons + /// describing the same room agree and a client can compare digests from before and + /// after a restart. + public var fingerprint: UInt64 + + public init(count: Int, latestID: Int, fingerprint: UInt64) { + self.count = count + self.latestID = latestID + self.fingerprint = fingerprint + } + + public init(of posts: [MailroomPost]) { + var hash: UInt64 = 0xcbf2_9ce4_8422_2325 + func mix(_ text: String) { + for byte in text.utf8 { + hash ^= UInt64(byte) + hash = hash &* 0x0000_0100_0000_01b3 + } + hash ^= 0xff + hash = hash &* 0x0000_0100_0000_01b3 + } + for post in posts { + mix(String(post.id)) + mix(post.authorID?.uuidString ?? "") + mix(post.author) + } + // The maximum, the way `Mailroom.nextID` reads it, rather than the last post's — + // one answer to "the newest id" rather than two that happen to agree. + self.init(count: posts.count, latestID: posts.map(\.id).max() ?? 0, fingerprint: hash) + } + + public var isEmpty: Bool { count == 0 } +} + +/// What a client asks the room for — the read half of every mail verb. +public struct MailboxQuery: Codable, Equatable, Sendable { + public enum Selection: Codable, Equatable, Sendable { + /// The whole room — `mail list`, a human's window. + case board + /// Only what `reader`'s cursor has not covered — `mail inbox`. + case unread(reader: UUID) + /// One post in full — `mail read `. + case post(id: Int) + } + + public var selection: Selection + /// Keeps only posts whose author, topic or body contains the text, + /// case-insensitively — applied before bodies are cut, so a match deep in a body + /// still counts. + public var search: String? + /// `true` for whole bodies, `false` for headlines, `nil` to let the room decide by + /// `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? + + public init(selection: Selection, search: String? = nil, fullBodies: Bool? = nil) { + self.selection = selection + self.search = search + self.fullBodies = fullBodies + } +} + +/// The room's answer to a `MailboxQuery`: the posts asked for, and the numbers a +/// reader needs to act on them without holding the whole room. +public struct Mailbox: Codable, Equatable, Sendable { + /// Oldest first, the room's own order. + public var posts: [MailroomPost] + /// Whether `posts` carry headlines rather than whole bodies + /// (`Mailroom.headlineBodyBudget`). Said explicitly so a caller that left the choice + /// to the room can tell its reader it is reading a triaged room. + public var bodiesTrimmed: Bool + /// The room the answer was drawn from, for the same purposes a snapshot's is. + 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. + public var highestDeliveredID: Int? + + public init( + posts: [MailroomPost], bodiesTrimmed: Bool, digest: MailroomDigest, lastRead: Int? = nil, + highestDeliveredID: Int? = nil + ) { + self.posts = posts + self.bodiesTrimmed = bodiesTrimmed + self.digest = digest + self.lastRead = lastRead + self.highestDeliveredID = highestDeliveredID + } +} + +extension MailroomPost { + /// The same post with its body cut to the first `Mailroom.headlineBodyBudget` + /// characters — what a triaged mailbox carries instead of the whole note. Cut in + /// grapheme clusters, never mid-glyph. + public func headlined() -> MailroomPost { + guard body.count > Mailroom.headlineBodyBudget else { return self } + return MailroomPost( + id: id, at: at, authorID: authorID, author: author, topic: topic, + body: String(body.prefix(Mailroom.headlineBodyBudget)), kind: kind) + } +} + +extension Mailroom { + /// How much of a body a headline keeps. The CLI's triage line is cut at 80 + /// characters *including* the post's byline, so 80 characters of body is always + /// enough for it to render exactly what the whole body would have — the room can + /// trim on the wire without the reader being able to tell. + public static let headlineBodyBudget = 80 + + /// 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. + /// + /// `search` filters before bodies are cut. A `.post` selection is never trimmed: it + /// is the deep read a headline points at. + public static func serve( + _ query: MailboxQuery, from posts: [MailroomPost], cursor: (UUID) -> Int? + ) -> Mailbox { + var selected: [MailroomPost] + var lastRead: Int? + var deepRead = false + switch query.selection { + case .board: + selected = posts + case .unread(let reader): + lastRead = cursor(reader) + selected = unread(in: posts, since: lastRead) + case .post(let id): + selected = posts.filter { $0.id == id } + deepRead = true + } + if let search = query.search, !search.isEmpty { + let needle = search.lowercased() + selected = selected.filter { + $0.body.lowercased().contains(needle) || $0.author.lowercased().contains(needle) + || $0.topic?.lowercased().contains(needle) == true + } + } + let trimmed: Bool + switch query.fullBodies { + case .some(let full): trimmed = !full && !deepRead + case .none: trimmed = !deepRead && needsTriage(selected) + } + if trimmed { selected = selected.map { $0.headlined() } } + return Mailbox( + posts: selected, bodiesTrimmed: trimmed, digest: MailroomDigest(of: posts), + lastRead: lastRead, highestDeliveredID: selected.last?.id) + } +} diff --git a/Package.swift b/Package.swift index 58439a08..5854233f 100644 --- a/Package.swift +++ b/Package.swift @@ -45,7 +45,7 @@ let package = Package( ), .executableTarget( name: "graphcode-cli", - dependencies: ["GraphcodeKit"], + dependencies: ["GraphcodeKit", "MailroomKit"], path: "graphcode-cli/Sources", swiftSettings: [.swiftLanguageMode(.v5)] ), diff --git a/Project.swift b/Project.swift index 3e48c10e..3ff81e07 100644 --- a/Project.swift +++ b/Project.swift @@ -139,7 +139,8 @@ let project = Project( "graphcode-cli/Sources" ], dependencies: [ - .target(name: "GraphcodeKit") + .target(name: "GraphcodeKit"), + .target(name: "MailroomKit"), ] ), .target( diff --git a/graphcode-cli/Sources/main.swift b/graphcode-cli/Sources/main.swift index 9342b54f..d65ca701 100644 --- a/graphcode-cli/Sources/main.swift +++ b/graphcode-cli/Sources/main.swift @@ -1,5 +1,6 @@ import Foundation import GraphcodeKit +import MailroomKit // graphcode — the CLI half of docs/03-architecture.md#cli-graphcode. // @@ -100,7 +101,7 @@ func openProject(_ projectPath: String) throws -> LoopGraph? { let opened = try client.waitForEvent { switch $0 { case .graphChanged, .errorOccurred: return true - case .recentProjectsListed: return false + case .recentProjectsListed, .mailbox: return false } } if case .errorOccurred(let message) = opened { fail(message) } @@ -108,6 +109,34 @@ func openProject(_ projectPath: String) throws -> LoopGraph? { return nil } +/// Asks the daemon for posts — the read half of every mail verb, answered on this +/// connection alone. A `.graphChanged` no longer carries the room, only its digest +/// (`LoopGraph.mailroomDigest`), so a verb that prints posts asks for exactly the posts +/// it prints: one loop's unread slice, one post, or the whole room. A refusal (the +/// project not open, a path the daemon does not know) arrives as an error and stops +/// here, the way `openProject`'s does. +func fetchMailbox(_ projectPath: String, _ query: MailboxQuery) throws -> Mailbox { + try client.send(.mailbox(projectPath: projectPath, query: query)) + let answer = try client.waitForEvent { + switch $0 { + case .mailbox, .errorOccurred: return true + default: return false + } + } + if case .errorOccurred(let message) = answer { fail(message) } + guard case .mailbox(_, let mailbox) = answer else { + fail("graphcoded never answered the mailbox request", code: ExitCode.ambiguous) + } + return mailbox +} + +/// The project as the daemon names it, for the mail renderers' header line — or, on +/// the one path where the open answered with nothing to name it by, as the caller +/// spelled it. +func projectRef(_ opened: LoopGraph?, _ projectPath: String) -> ProjectRef { + opened?.project ?? ProjectRef(path: projectPath, name: projectPath) +} + /// Every mutating verb waits for the `.graphChanged` broadcast its own command caused, /// then prints the resulting graph. That's the daemon's only acknowledgement — it has no /// request/response correlation — and it doubles as useful output. @@ -370,7 +399,7 @@ do { } if case .errorOccurred(let message) = postVerdict { fail(message) } if case .graphChanged(let graph) = postVerdict { - print(GraphcodeCommand.renderPosted(graph)) + print(GraphcodeCommand.renderPosted(graph, topic: topic)) } case .mailroomInbox(let projectPath, let headlines, let mark, let json, let full): @@ -386,6 +415,19 @@ 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 @@ -395,60 +437,59 @@ do { } } if case .errorOccurred(let message) = syncVerdict { fail(message) } - // Unread is computed from the snapshot `openProject` already delivered: sync only - // moves the cursor, so the posts it covers are exactly those above the cursor - // there. Known race, accepted: a post landing between that snapshot 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 rather - // than to latest, which nothing so far has needed. - if let graph = opened { + // 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 { if json { - print(GraphcodeCommand.renderMailroomJSON(graph, unreadFor: reader)) - } else if mark { - // 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. - if let latest = graph.mailroom.last?.id, latest > 0 { - print("marked read up to #\(latest)") - } else { - print("marked read — the board is empty") - } + print(GraphcodeCommand.renderMailroomJSON(mailbox)) } else { - // `autoTriage` unless the caller said which way they want it: a loop cannot - // know how much mail it has before reading it, and the first sync of a loop - // born after a busy week is the whole board. print( GraphcodeCommand.renderMailroom( - graph, unreadFor: reader, headlines: headlines, - autoTriage: !headlines && !full)) + 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): - // Read-only: the post rides the snapshot, no command is sent, no cursor moves — - // the deep-read half of `sync --headlines` triage, priced at one line of context - // per post a loop actually decides to care about. - if let graph = try openProject(projectPath) { - guard let post = graph.mailroom.first(where: { $0.id == postID }) else { - fail( - "no post #\(postID) on this board — `graphcode mail list \(projectPath)` " - + "shows the ids that exist") - } - print(GraphcodeCommand.render(post)) + // Read-only: no cursor moves — the deep-read half of `inbox --headlines` triage, + // priced at one line of context per post a loop actually decides to care about. + try openProject(projectPath) + let mailbox = try fetchMailbox(projectPath, MailboxQuery(selection: .post(id: postID))) + guard let post = mailbox.posts.first else { + fail( + "no post #\(postID) on this board — `graphcode mail list \(projectPath)` " + + "shows the ids that exist") } + print(GraphcodeCommand.render(post)) case .mailroomList(let projectPath, let search, let json): - // Read-only: no command is sent, so — the `status` rule — nothing past the - // snapshot is waited for, and no cursor moves. This is the human's window onto - // the board; `sync` is the loop's. `--search` filters what is shown, never what - // is remembered. - if let graph = try openProject(projectPath) { - if json { - print(GraphcodeCommand.renderMailroomJSON(graph, search: search)) - } else { - print(GraphcodeCommand.renderMailroom(graph, search: search)) - } + // Read-only: no cursor moves. This is the human's window onto the room; `inbox` + // is the loop's. `--search` filters what is shown, never what is remembered, and + // every body comes whole — a human asked to see the room, not to have it triaged. + let opened = try openProject(projectPath) + let mailbox = try fetchMailbox( + projectPath, MailboxQuery(selection: .board, search: search, fullBodies: true)) + if json { + print(GraphcodeCommand.renderMailroomJSON(mailbox)) + } else { + print( + GraphcodeCommand.renderMailroom( + mailbox, project: projectRef(opened, projectPath), unread: false, search: search)) } case .mailroomWatch(let projectPath, let on, let topic): diff --git a/graphcode/Sources/Features/App/AppFeature.swift b/graphcode/Sources/Features/App/AppFeature.swift index 9a3437a7..69f52d90 100644 --- a/graphcode/Sources/Features/App/AppFeature.swift +++ b/graphcode/Sources/Features/App/AppFeature.swift @@ -344,22 +344,29 @@ struct AppFeature { // anyone opened, it's the one row that's always there, and it arrives last // (the app asks for it after `.restoreOpenProjects`) so appending would // leave it below folders that came back from a previous session. + let held = ProjectFeature.holding(graph) if graph.isGlobal { - state.projects.insert(ProjectFeature.State(graph: graph), at: 0) + state.projects.insert(ProjectFeature.State(graph: held), at: 0) } else { - state.projects.append(ProjectFeature.State(graph: graph)) + state.projects.append(ProjectFeature.State(graph: held)) } + // The snapshot carries the room's digest, not its posts; the first + // sight of a project with anything on its board asks for them. Later + // changes are `ProjectFeature`'s to notice, off its own `.graphChanged`. + let fetchesBoard = !graph.boardDigest.isEmpty // Selection follows only a project *this* app asked for. A folder can now // also arrive because someone else opened it — `graphcode status ` // from a loop or an editor plugin joins every running sidebar to it — and // that is a row appearing, not a reason to close the terminal a human is // working in. + let fetch: Effect = + fetchesBoard ? ProjectFeature.fetchBoard(path, via: orchestratorClient) : .none guard state.pendingOpenPaths.remove(path) != nil || graph.isGlobal else { - return .none + return fetch } state.selectedProjectPath = path state.openLoop = nil - return .none + return fetch } // Keep an open workspace's node in sync (title, presence dot, check bar) — // the workspace doesn't own a daemon subscription itself. @@ -368,7 +375,10 @@ struct AppFeature { state.openLoop?.node = updated // The rail's downstream list comes off this — a handoff drawn while the // terminal is up should appear there without reopening the workspace. - state.openLoop?.graph = graph + // The room's posts are not on the wire; the copy this app already holds + // stays until the mailbox reply that a changed digest asks for. + let hydrated = hydratedWithBoard(graph, in: state) + state.openLoop?.graph = hydrated } else { // The loop was deleted out from under its own terminal — easy to do now // that the sidebar can delete a loop while its workspace is the visible @@ -385,6 +395,14 @@ struct AppFeature { case .errorOccurred(let message): state.welcome.errorMessage = message return .none + + case .mailbox(let path, let mailbox): + // The posts a snapshot's digest stood in for. The project keeps the copy; + // an open workspace in that project reads the same room off its own graph. + if state.openLoop?.projectPath == path { + state.openLoop?.graph.mailroom = mailbox.posts + } + return .send(.projects(.element(id: path, action: .daemonEvent(event)))) } case .projectHeaderTapped(let path): @@ -825,6 +843,16 @@ extension AppFeature { /// surface is deliberately not on the kill list: the loop's session belongs to the /// node, and whichever side deletes the node ends it (`GraphStore` on the daemon's /// path, `QuickChats` for a chat). + /// A broadcast graph with the room's posts filled back in from the copy this app + /// holds for the project — see `LoopGraph.mailroom`. A snapshot from a daemon that + /// still ships posts keeps its own. + func hydratedWithBoard(_ graph: LoopGraph, in state: State) -> LoopGraph { + guard graph.mailroom.isEmpty else { return graph } + var hydrated = graph + hydrated.mailroom = state.projects[id: graph.project.path]?.graph.mailroom ?? [] + return hydrated + } + func closeOpenWorkspace(_ state: inout State) { guard let openLoop = state.openLoop else { return } let surfaces = openLoop.layout.tabs.flatMap { $0.surfaces } diff --git a/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceFeature.swift b/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceFeature.swift index 4d35a707..b33d8e9b 100644 --- a/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceFeature.swift +++ b/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceFeature.swift @@ -298,8 +298,9 @@ struct LoopWorkspaceFeature { LoopWorkspaceRail.saveMailroomFolded(state.isMailroomFolded) return .none - // Nothing local to change: the post is the daemon's to apply, and the board it - // lands on arrives back in the next `.graphChanged`. + // Nothing local to change: the post is the daemon's to apply, and the room it + // lands on is asked for when the next `.graphChanged` says the room changed + // (`ProjectFeature`), then reaches this graph through `AppFeature`. case .mailroomPostSubmitted: return .none diff --git a/graphcode/Sources/Features/Project/ProjectFeature+Mailroom.swift b/graphcode/Sources/Features/Project/ProjectFeature+Mailroom.swift new file mode 100644 index 00000000..b8279c57 --- /dev/null +++ b/graphcode/Sources/Features/Project/ProjectFeature+Mailroom.swift @@ -0,0 +1,44 @@ +import ComposableArchitecture +import Foundation +import GraphcodeKit +import MailroomKit + +extension ProjectFeature { + /// A broadcast with the room's posts carried over from the copy this project holds + /// — they are not on the wire (`LoopGraph.mailroom`) — and whether its digest says + /// that copy is stale, which is what asks for a fresh one. A snapshot from a daemon + /// that still ships posts keeps its own and is never stale. + /// + /// The digest carried over is the one the held posts *came from* (set by the + /// `.mailbox` answer), never the broadcast's: a project that adopted the new digest + /// before its posts arrived would judge every later broadcast fresh, and an answer + /// that never landed — a swallowed send, a refusal — would never be asked for again. + static func carryingRoom( + _ broadcast: LoopGraph, over current: LoopGraph + ) -> (graph: LoopGraph, stale: Bool) { + guard broadcast.mailroom.isEmpty else { return (broadcast, false) } + var carried = broadcast + carried.mailroom = current.mailroom + carried.mailroomDigest = current.mailroomDigest + return (carried, broadcast.boardDigest != current.boardDigest) + } + + /// A project's first snapshot as it is held: the room's digest left unset until the + /// posts it describes arrive, so the first broadcast after a lost answer asks again. + static func holding(_ snapshot: LoopGraph) -> LoopGraph { + var held = snapshot + held.mailroomDigest = nil + return held + } + + /// Asks the daemon for the project's whole room — the posts a `.graphChanged` + /// snapshot describes but no longer carries. The reply lands as `.mailbox`, on this + /// connection alone. Whole bodies, since the rail shows them. + static func fetchBoard(_ projectPath: String, via client: OrchestratorClient) -> Effect { + .run { _ in + try? await client.send( + .mailbox( + projectPath: projectPath, query: MailboxQuery(selection: .board, fullBodies: true))) + } + } +} diff --git a/graphcode/Sources/Features/Project/ProjectFeature.swift b/graphcode/Sources/Features/Project/ProjectFeature.swift index a2812610..401da885 100644 --- a/graphcode/Sources/Features/Project/ProjectFeature.swift +++ b/graphcode/Sources/Features/Project/ProjectFeature.swift @@ -2,6 +2,7 @@ import AppKit import ComposableArchitecture import Foundation import GraphcodeKit +import MailroomKit import UniformTypeIdentifiers /// One open project's graph canvas — one of possibly several the sidebar shows at once @@ -336,8 +337,9 @@ struct ProjectFeature { case .daemonEvent(let event): switch event { - case .graphChanged(let newGraph): + case .graphChanged(let broadcast): state.connectionError = nil + let (newGraph, boardChanged) = Self.carryingRoom(broadcast, over: state.graph) loopTitleDirectory.register(newGraph.project.path, newGraph) // Every card placed again from the graph that just arrived, rather than only the // ones that are new. Slots handed out at arrival time made the canvas a record of @@ -361,10 +363,17 @@ struct ProjectFeature { // The broadcast that delivers a form-created loop is what makes it openable — // switch to it now, the way tapping it would. Matched by id so an unrelated // broadcast (another loop finishing, a CLI edit) leaves the pending id waiting. + let fetch: Effect = + boardChanged + ? Self.fetchBoard(newGraph.project.path, via: orchestratorClient) : .none if let pending = state.pendingCreatedNodeID, newGraph.nodes[id: pending] != nil { state.pendingCreatedNodeID = nil - return .send(.nodeTapped(pending)) + return .merge(fetch, .send(.nodeTapped(pending))) } + return fetch + case .mailbox(_, let mailbox): + state.graph.mailroom = mailbox.posts + state.graph.mailroomDigest = mailbox.digest case .errorOccurred(let message): state.connectionError = message case .recentProjectsListed: diff --git a/graphcode/Tests/MailboxTests.swift b/graphcode/Tests/MailboxTests.swift new file mode 100644 index 00000000..83659cbe --- /dev/null +++ b/graphcode/Tests/MailboxTests.swift @@ -0,0 +1,310 @@ +import Foundation +import GraphcodeKit +import MailroomKit +import Testing + +#if canImport(Darwin) + import Darwin +#endif + +/// The room's read path since issue #288: a `.graphChanged` carries the room's digest, +/// never its posts, and a client that wants posts asks for exactly the posts it wants +/// with a `DaemonCommand.mailbox`. These pin the three halves — what the snapshot says +/// instead, what the room answers, and that the answer is what leaves the daemon. +@Suite +struct MailboxTests { + private static func post( + _ id: Int, _ body: String, author: String = "a human", authorID: UUID? = nil, + topic: String? = nil, kind: MailroomPost.Kind = .notice + ) -> MailroomPost { + MailroomPost( + id: id, at: Date(timeIntervalSince1970: TimeInterval(id)), authorID: authorID, + author: author, topic: topic, body: body, kind: kind) + } + + /// A room of three and a reader who has seen the first. + private static func room() -> (LoopGraph, LoopNode) { + var graph = LoopGraph(project: ProjectRef(path: "/tmp/x", name: "x")) + var reader = LoopNode(title: "Reader", loopType: .turnBased) + reader.lastMailroomRead = 1 + graph.nodes.append(reader) + graph.mailroom = [ + post(1, "already read"), + post( + 2, String(repeating: "red ", count: 40), author: "Author", authorID: UUID(), + topic: "build"), + post(3, "auth deadlock traced to token refresh"), + ] + return (graph, reader) + } + + private func serve( + _ graph: LoopGraph, _ selection: MailboxQuery.Selection, search: String? = nil, + fullBodies: Bool? = nil + ) -> Mailbox { + Mailroom.serve( + MailboxQuery(selection: selection, search: search, fullBodies: fullBodies), + from: graph.mailroom + ) { graph.nodes[id: $0]?.lastMailroomRead } + } + + // MARK: The snapshot + + @Test + func aWireSnapshotCarriesTheDigestInsteadOfThePosts() throws { + let (graph, _) = Self.room() + + let wire = graph.wireSnapshot() + #expect(wire.mailroom.isEmpty) + #expect(wire.mailroomDigest == MailroomDigest(of: graph.mailroom)) + #expect(wire.boardDigest.count == 3) + #expect(wire.boardDigest.latestID == 3) + // Everything that is not the room is the graph exactly as it was. + #expect(wire.nodes == graph.nodes) + #expect(wire.project == graph.project) + + // The digest survives the socket; the posts were never on it. + let decoded = try JSONDecoder().decode( + LoopGraph.self, from: try JSONEncoder().encode(wire)) + #expect(decoded.mailroom.isEmpty) + #expect(decoded.mailroomDigest == wire.mailroomDigest) + // And a graph the daemon owns never carries one to disk. + #expect(graph.mailroomDigest == nil) + let persisted = String(decoding: try JSONEncoder().encode(graph), as: UTF8.self) + #expect(!persisted.contains("mailroomDigest")) + } + + /// A snapshot from a daemon that still ships posts — or the daemon's own graph — + /// describes its room off those posts, so every reader of `boardDigest` works on + /// both sides of the upgrade. + @Test + func aGraphStillCarryingPostsDescribesItselfOffThem() { + let (graph, _) = Self.room() + #expect(graph.boardDigest == graph.wireSnapshot().boardDigest) + #expect(LoopGraph(project: ProjectRef(path: "/tmp/x", name: "x")).boardDigest.isEmpty) + } + + /// The one edit that changes a room without changing what it holds — an author's + /// deletion — has to show in the digest, or a client's copy would keep naming a loop + /// that is gone. Pruning and posting already move `count` or `latestID`. + @Test + func theFingerprintSeesAnAuthorsDeletion() { + let (graph, _) = Self.room() + var edited = graph + edited.mailroom[1] = graph.mailroom[1].withAuthorDeleted() + + let before = MailroomDigest(of: graph.mailroom) + let after = MailroomDigest(of: edited.mailroom) + #expect(before.count == after.count && before.latestID == after.latestID) + #expect(before.fingerprint != after.fingerprint) + // Deterministic: the same room describes itself the same way twice, and across + // processes — it is compared between a daemon's snapshots either side of a restart. + #expect(MailroomDigest(of: graph.mailroom) == before) + } + + // MARK: The answer + + @Test + func unreadIsWhatTheCursorHasNotCoveredAndAStrangerSeesEverything() { + let (graph, reader) = Self.room() + + let mine = serve(graph, .unread(reader: reader.id)) + #expect(mine.posts.map(\.id) == [2, 3]) + #expect(mine.lastRead == 1) + #expect(mine.highestDeliveredID == 3) + #expect(mine.digest.count == 3) + + // No cursor to subtract from: shown everything, as before — the cursor advance + // that follows is what refuses a reader the graph does not know. + let stranger = serve(graph, .unread(reader: UUID())) + #expect(stranger.posts.map(\.id) == [1, 2, 3]) + #expect(stranger.lastRead == nil) + } + + @Test + func theBoardAndOnePostAreWholeAndSearchFiltersBeforeAnyCut() { + let (graph, reader) = Self.room() + + #expect(serve(graph, .board, fullBodies: true).posts == graph.mailroom) + #expect(serve(graph, .post(id: 2)).posts == [graph.mailroom[1]]) + #expect(serve(graph, .post(id: 9)).posts.isEmpty) + #expect(serve(graph, .post(id: 9)).highestDeliveredID == nil) + + // "deadlock" lives only in #3's body; "auth" would also match #2's author. + #expect(serve(graph, .unread(reader: reader.id), search: "deadlock").posts.map(\.id) == [3]) + #expect(serve(graph, .board, search: "AUTH").posts.map(\.id) == [2, 3]) + #expect(serve(graph, .board, search: "nonesuch").posts.isEmpty) + } + + /// The bound. Left to the room, a backlog past `Mailroom.needsTriage` comes back as + /// headlines and says so; a caller can insist either way; a deep read never cuts. + @Test + func aBacklogIsTriagedToHeadlinesUnlessTheCallerInsists() { + var graph = LoopGraph(project: ProjectRef(path: "/tmp/x", name: "x")) + graph.mailroom = (1...(Mailroom.triageAfterPosts + 1)).map { + Self.post($0, String(repeating: "x", count: 200)) + } + let reader = UUID() + + let triaged = serve(graph, .unread(reader: reader)) + #expect(triaged.bodiesTrimmed) + #expect(triaged.posts.allSatisfy { $0.body.count == Mailroom.headlineBodyBudget }) + #expect(triaged.posts.count == graph.mailroom.count) + #expect(triaged.highestDeliveredID == graph.mailroom.count) + + let insisted = serve(graph, .unread(reader: reader), fullBodies: true) + #expect(!insisted.bodiesTrimmed) + #expect(insisted.posts == graph.mailroom) + + var small = graph + small.mailroom = [Self.post(1, "short")] + let headlines = serve(small, .board, fullBodies: false) + #expect(headlines.bodiesTrimmed) + #expect(headlines.posts[0].body == "short") + + // Bodies cut in characters, never mid-glyph, and short ones untouched. + let accented = Self.post(1, String(repeating: "é", count: 100)) + #expect(accented.headlined().body == String(repeating: "é", count: 80)) + #expect(Self.post(2, "brief").headlined() == Self.post(2, "brief")) + + #expect(!serve(graph, .post(id: 1)).bodiesTrimmed) + #expect(serve(graph, .post(id: 1)).posts[0].body.count == 200) + } + + /// 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 + func theQueryAndItsAnswerHaveTheShapeTheShimTypes() throws { + let reader = UUID() + let literal = """ + {"mailbox":{"projectPath":"/tmp/x","query":{"selection":{"unread":{"reader":\ + "\(reader.uuidString)"}},"fullBodies":true}}} + """ + let decoded = try JSONDecoder().decode(DaemonCommand.self, from: Data(literal.utf8)) + #expect( + decoded + == .mailbox( + projectPath: "/tmp/x", + query: MailboxQuery(selection: .unread(reader: reader), fullBodies: true))) + let board = try JSONDecoder().decode( + DaemonCommand.self, + from: Data(#"{"mailbox":{"projectPath":"/tmp/x","query":{"selection":{"board":{}}}}}"#.utf8)) + #expect(board == .mailbox(projectPath: "/tmp/x", query: MailboxQuery(selection: .board))) + + let answer = DaemonEvent.mailbox( + projectPath: "/tmp/x", + mailbox: Mailbox( + posts: [], bodiesTrimmed: false, digest: MailroomDigest(of: []), lastRead: 4, + highestDeliveredID: nil)) + let encoded = try JSONSerialization.jsonObject(with: try JSONEncoder().encode(answer)) + let envelope = try #require((encoded as? [String: Any])?["mailbox"] as? [String: Any]) + #expect(envelope["projectPath"] as? String == "/tmp/x") + let mailbox = try #require(envelope["mailbox"] as? [String: Any]) + #expect(mailbox["lastRead"] as? Int == 4) + #expect(mailbox["bodiesTrimmed"] as? Bool == false) + #expect((mailbox["digest"] as? [String: Any])?["count"] as? Int == 0) + } + + // MARK: The daemon + + /// Reads one frame off `descriptor` on another thread: the store writes from its + /// actor, and a full socket buffer would otherwise park the actor the test awaits. + private func nextEvent(from descriptor: Int32) async throws -> DaemonEvent { + try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global().async { + do { + let data = try FramedMessageIO.readFrame(from: descriptor) + continuation.resume(returning: try JSONDecoder().decode(DaemonEvent.self, from: data)) + } catch { + continuation.resume(throwing: error) + } + } + } + } + + /// Over a real socket: the snapshot a client joins with and the broadcast a post + /// causes carry the digest and no posts, and the store's mailbox is where they are. + @Test + func broadcastsCarryTheDigestAndTheMailboxCarriesThePosts() async throws { + let store = GraphStore( + onEnsureSession: { _, _ in }, onDeliverMessage: { _, _, _ in true }, + onMailroomEnabled: { true }) + var pair: [Int32] = [0, 0] + #expect(socketpair(AF_UNIX, SOCK_STREAM, 0, &pair) == 0) + defer { + close(pair[0]) + close(pair[1]) + } + + await store.addConnection(id: UUID(), fileDescriptor: pair[0]) + guard case .graphChanged(let joined) = try await nextEvent(from: pair[1]) else { + Issue.record("expected the joining snapshot") + return + } + #expect(joined.boardDigest.isEmpty) + #expect(joined.mailroomDigest != nil) + + await store.handle(.mailroomPost(text: "claiming #12", topic: "Claims", from: nil)) + guard case .graphChanged(let posted) = try await nextEvent(from: pair[1]) else { + Issue.record("expected the broadcast the post caused") + return + } + #expect(posted.mailroom.isEmpty) + #expect(posted.boardDigest.count == 1) + #expect(posted.boardDigest.latestID == 1) + + let mailbox = 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 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. + @Test + func theRegistryRoutesAMailboxRequestLikeACommand() async throws { + let project = "/tmp/mailbox-tests-\(UUID().uuidString.prefix(8))" + try FileManager.default.createDirectory(atPath: project, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(atPath: project) } + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("graphcode-tests-\(UUID().uuidString)", isDirectory: true) + let registry = ProjectRegistry( + persistenceDirectory: directory, ensureSession: { _, _ in }, readPresence: nil) + var pair: [Int32] = [0, 0] + #expect(socketpair(AF_UNIX, SOCK_STREAM, 0, &pair) == 0) + defer { + close(pair[0]) + close(pair[1]) + } + + let connection = UUID() + await registry.addConnection(id: connection, fileDescriptor: pair[0]) + await registry.handle(.openProject(path: project), connectionID: connection) + guard case .graphChanged = try await nextEvent(from: pair[1]) else { + Issue.record("expected the joining snapshot") + return + } + + await registry.handle( + .mailbox(projectPath: project, query: MailboxQuery(selection: .board)), + connectionID: connection) + guard case .mailbox(let path, let mailbox) = try await nextEvent(from: pair[1]) else { + Issue.record("expected the mailbox answer") + return + } + #expect(path == ProjectRegistry.canonicalize(project)) + #expect(mailbox.posts.isEmpty) + #expect(mailbox.digest.isEmpty) + + await registry.handle( + .mailbox(projectPath: "/tmp", query: MailboxQuery(selection: .board)), + connectionID: connection) + guard case .errorOccurred(let refusal) = try await nextEvent(from: pair[1]) else { + Issue.record("expected a refusal") + return + } + #expect(refusal.contains("isn't open")) + } +} diff --git a/graphcode/Tests/MailroomBudgetTests.swift b/graphcode/Tests/MailroomBudgetTests.swift index 4405ce2b..8695dcec 100644 --- a/graphcode/Tests/MailroomBudgetTests.swift +++ b/graphcode/Tests/MailroomBudgetTests.swift @@ -179,7 +179,10 @@ struct MailroomBudgetTests { } let rendered = GraphcodeCommand.renderMailroom( - graph, unreadFor: reader.id, autoTriage: true) + Mailroom.serve( + MailboxQuery(selection: .unread(reader: reader.id)), from: graph.mailroom + ) { graph.nodes[id: $0]?.lastMailroomRead }, + project: graph.project, unread: true) #expect(rendered.contains("headlines only")) #expect(rendered.contains("mail read /tmp/p ")) @@ -196,7 +199,10 @@ struct MailroomBudgetTests { body: "short enough to read in full")) let rendered = GraphcodeCommand.renderMailroom( - graph, unreadFor: reader.id, autoTriage: true) + Mailroom.serve( + MailboxQuery(selection: .unread(reader: reader.id)), from: graph.mailroom + ) { graph.nodes[id: $0]?.lastMailroomRead }, + project: graph.project, unread: true) #expect(rendered.contains("short enough to read in full")) #expect(!rendered.contains("headlines only")) diff --git a/graphcode/Tests/MailroomCommandTests.swift b/graphcode/Tests/MailroomCommandTests.swift index c12a1650..0fffcc10 100644 --- a/graphcode/Tests/MailroomCommandTests.swift +++ b/graphcode/Tests/MailroomCommandTests.swift @@ -113,7 +113,11 @@ struct MailroomCommandTests { topic: "claims", body: "issue #12 is mine"), ] - let rendered = GraphcodeCommand.renderMailroom(graph) + let rendered = GraphcodeCommand.renderMailroom( + Mailroom.serve( + MailboxQuery(selection: .board, fullBodies: true), from: graph.mailroom + ) { _ in nil }, + project: graph.project, unread: false) #expect(rendered.contains("#1 from a human")) #expect(rendered.contains("#4 (claims) from Author")) @@ -135,19 +139,25 @@ struct MailroomCommandTests { topic: nil, body: "still unread"), ] - let forReader = GraphcodeCommand.renderMailroom(graph, unreadFor: reader.id) + let forReader = GraphcodeCommand.renderMailroom( + served(graph, .unread(reader: reader.id)), project: graph.project, unread: true) #expect(forReader.contains("#2")) #expect(!forReader.contains("#1 ")) // A loop that never synced sees everything; so does one whose id is not on this // graph (no cursor to subtract from). - #expect(GraphcodeCommand.renderMailroom(graph, unreadFor: UUID()).contains("#1")) + #expect( + GraphcodeCommand.renderMailroom( + served(graph, .unread(reader: UUID())), project: graph.project, unread: true + ).contains("#1")) } @Test func emptyBoardAndNothingUnreadSaySo() { var graph = LoopGraph(project: ProjectRef(path: "/tmp/x", name: "x")) - #expect(GraphcodeCommand.renderMailroom(graph).contains("the room is empty")) + #expect( + GraphcodeCommand.renderMailroom(served(graph, .board), project: graph.project, unread: false) + .contains("the room is empty")) var reader = LoopNode(title: "Reader", loopType: .turnBased) reader.lastMailroomRead = 3 @@ -158,7 +168,10 @@ struct MailroomCommandTests { topic: nil, body: "caught up") ] - #expect(GraphcodeCommand.renderMailroom(graph, unreadFor: reader.id) == "no unread posts") + #expect( + GraphcodeCommand.renderMailroom( + served(graph, .unread(reader: reader.id)), project: graph.project, unread: true) + == "no unread posts") } @Test @@ -171,7 +184,13 @@ struct MailroomCommandTests { id: 7, at: Date(timeIntervalSince1970: 0), authorID: nil, author: "a human", topic: "build", body: "build is red") ] - #expect(GraphcodeCommand.renderPosted(graph) == "posted #7 (build)") + // The topic is the caller's, spelled the way the daemon keeps it — the post itself + // is not on the graph that comes back, only the room's digest is. + #expect(GraphcodeCommand.renderPosted(graph, topic: " Build ") == "posted #7 (build)") + #expect( + GraphcodeCommand.renderPosted(graph.wireSnapshot(), topic: "build") == "posted #7 (build)") + #expect(GraphcodeCommand.renderPosted(graph.wireSnapshot(), topic: " ") == "posted #7") + #expect(GraphcodeCommand.renderPosted(graph) == "posted #7") } @Test @@ -184,6 +203,18 @@ struct MailroomCommandTests { // MARK: Read-side verbs (status line, headlines, read, --json, --search, --mark) +/// The room's answer, served off a graph the way `GraphStore.mailbox` serves it — +/// what every renderer below now takes instead of the graph. +private func served( + _ graph: LoopGraph, _ selection: MailboxQuery.Selection, search: String? = nil, + fullBodies: Bool? = nil +) -> Mailbox { + Mailroom.serve( + MailboxQuery(selection: selection, search: search, fullBodies: fullBodies), + from: graph.mailroom + ) { graph.nodes[id: $0]?.lastMailroomRead } +} + private func boardWithPosts() -> (LoopGraph, LoopNode) { var graph = LoopGraph(project: ProjectRef(path: "/tmp/x", name: "x")) var reader = LoopNode(title: "Reader", loopType: .turnBased) @@ -260,29 +291,64 @@ func listParsesSearchAndJSON() throws { func headlinesCutBodiesToATriageLine() { let (graph, reader) = boardWithPosts() - let headlines = GraphcodeCommand.renderMailroom(graph, unreadFor: reader.id, headlines: true) + let headlines = GraphcodeCommand.renderMailroom( + served(graph, .unread(reader: reader.id), fullBodies: false), project: graph.project, + unread: true, headlines: true) #expect(headlines.contains("#2 (build)")) #expect(!headlines.contains("red red red red red red red red red red red red")) - let full = GraphcodeCommand.renderMailroom(graph, unreadFor: reader.id) + // Asked for, so no "headlines only" apology on the header line. + #expect(!headlines.contains("headlines only")) + let full = GraphcodeCommand.renderMailroom( + served(graph, .unread(reader: reader.id), fullBodies: true), project: graph.project, + unread: true) #expect(full.contains("red red")) } +@Test +func aRoomThatTriagedItselfSaysSoAndWhereTheFullTextIs() { + var graph = LoopGraph(project: ProjectRef(path: "/tmp/x", name: "x")) + graph.mailroom = (1...(Mailroom.triageAfterPosts + 1)).map { + MailroomPost( + id: $0, at: Date(timeIntervalSince1970: 0), authorID: nil, author: "a human", + topic: nil, body: String(repeating: "word ", count: 30)) + } + let reader = UUID() + + let triaged = GraphcodeCommand.renderMailroom( + served(graph, .unread(reader: reader)), project: graph.project, unread: true) + #expect(triaged.contains("headlines only, that is a lot to read at once")) + #expect(triaged.contains("graphcode mail read /tmp/x ")) + #expect(triaged.split(separator: "\n").count == graph.mailroom.count + 1) + #expect(triaged.split(separator: "\n").dropFirst().allSatisfy { $0.hasSuffix("…") }) + // A room that cut the bodies renders the very lines the whole bodies would have. + let whole = GraphcodeCommand.renderMailroom( + served(graph, .unread(reader: reader), fullBodies: true), project: graph.project, + unread: true, headlines: true) + #expect(triaged.split(separator: "\n").dropFirst() == whole.split(separator: "\n").dropFirst()) +} + @Test func searchFiltersWhatIsShownButNeverWhatIsRemembered() { let (graph, reader) = boardWithPosts() // "deadlock" lives only in #3's body — note "auth" would have matched #2's // author ("Author"), which is the filter doing its job, not a bug. - let filtered = GraphcodeCommand.renderMailroom(graph, unreadFor: reader.id, search: "deadlock") + let filtered = GraphcodeCommand.renderMailroom( + served(graph, .unread(reader: reader.id), search: "deadlock"), project: graph.project, + unread: true, search: "deadlock") #expect(filtered.contains("#3")) #expect(!filtered.contains("#2")) #expect( - GraphcodeCommand.renderMailroom(graph, search: "nonesuch") - .contains("no posts match 'nonesuch'")) + GraphcodeCommand.renderMailroom( + served(graph, .board, search: "nonesuch"), project: graph.project, unread: false, + search: "nonesuch" + ).contains("no posts match 'nonesuch'")) #expect( - GraphcodeCommand.renderMailroom(graph, unreadFor: reader.id, search: "nonesuch") - .contains("no unread posts match 'nonesuch'")) + GraphcodeCommand.renderMailroom( + served(graph, .unread(reader: reader.id), search: "nonesuch"), project: graph.project, + unread: true, search: "nonesuch" + ).contains("no unread posts match 'nonesuch'")) } @Test @@ -294,7 +360,9 @@ func jsonRendersTheSameTruthInOtherSyntax() throws { let (graph, reader) = boardWithPosts() let forReader = try #require( - GraphcodeCommand.renderMailroomJSON(graph, unreadFor: reader.id).data(using: .utf8)) + GraphcodeCommand.renderMailroomJSON( + served(graph, .unread(reader: reader.id), fullBodies: true) + ).data(using: .utf8)) let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 let decoded = try decoder.decode(Board.self, from: forReader) @@ -302,7 +370,8 @@ func jsonRendersTheSameTruthInOtherSyntax() throws { #expect(decoded.posts.map(\.id) == [2, 3]) let whole = try #require( - GraphcodeCommand.renderMailroomJSON(graph).data(using: .utf8)) + GraphcodeCommand.renderMailroomJSON(served(graph, .board, fullBodies: true)) + .data(using: .utf8)) let everything = try decoder.decode(Board.self, from: whole) #expect(everything.posts.count == 3) #expect(everything.lastRead == nil) @@ -314,15 +383,26 @@ func statusLineCountsPostsAndUnreadOnlyWhenThereAreAny() { #expect( GraphcodeCommand.renderMailroomStatusLine(graph, readerID: reader.id) - == "mailroom: 3 posts, 2 unread for you") + == "mailroom: 3 posts, unread mail for you") #expect( GraphcodeCommand.renderMailroomStatusLine(graph) == "mailroom: 3 posts") #expect( GraphcodeCommand.renderMailroomStatusLine( LoopGraph(project: ProjectRef(path: "/tmp/x", name: "x"))) == nil) - let rendered = GraphcodeCommand.render(graph, mailroomReader: reader.id) - #expect(rendered.contains("mailroom: 3 posts, 2 unread for you")) + // Off the digest, so the snapshot a client actually receives says the same. + let wire = graph.wireSnapshot() + #expect( + GraphcodeCommand.renderMailroomStatusLine(wire, readerID: reader.id) + == "mailroom: 3 posts, unread mail for you") + var caughtUp = wire + caughtUp.nodes[id: reader.id]?.lastMailroomRead = 3 + #expect( + GraphcodeCommand.renderMailroomStatusLine(caughtUp, readerID: reader.id) + == "mailroom: 3 posts, nothing unread for you") + + let rendered = GraphcodeCommand.render(wire, mailroomReader: reader.id) + #expect(rendered.contains("mailroom: 3 posts, unread mail for you")) } // MARK: Read-side review round (status-line blast radius, json+search, boundaries) @@ -369,7 +449,9 @@ func listJSONHonorsTheSearchFilter() throws { let (graph, _) = boardWithPosts() let filtered = try #require( - GraphcodeCommand.renderMailroomJSON(graph, search: "deadlock").data(using: .utf8)) + GraphcodeCommand.renderMailroomJSON( + served(graph, .board, search: "deadlock", fullBodies: true) + ).data(using: .utf8)) let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 #expect(try decoder.decode(Board.self, from: filtered).posts.map(\.id) == [3]) @@ -382,7 +464,8 @@ func jsonDatesAreISOTwo8601NotTheEncoderDefault() throws { } let (graph, _) = boardWithPosts() let data = try #require( - GraphcodeCommand.renderMailroomJSON(graph).data(using: .utf8)) + GraphcodeCommand.renderMailroomJSON(served(graph, .board, fullBodies: true)) + .data(using: .utf8)) // The pin: decode with the ISO-8601 strategy explicitly. The default (seconds // since 2001-01-01) fails here, so nobody can silently change the wire format. diff --git a/graphcode/Tests/MailroomFetchTests.swift b/graphcode/Tests/MailroomFetchTests.swift new file mode 100644 index 00000000..bc012b35 --- /dev/null +++ b/graphcode/Tests/MailroomFetchTests.swift @@ -0,0 +1,229 @@ +import ComposableArchitecture +import Foundation +import GraphcodeKit +import MailroomKit +import Testing + +@testable import graphcode + +/// The app's half of the room's read path (issue #288): a `.graphChanged` snapshot +/// carries the room's digest and no posts, so the copy a project holds carries over +/// from broadcast to broadcast, and a digest that says the copy is stale is what asks +/// the daemon for a fresh one — never every broadcast, never the presence tick. +@Suite +struct MailroomFetchTests { + private static let project = ProjectRef(path: "/tmp/project-a", name: "project-a") + + private actor Sent { + private(set) var commands: [DaemonCommand] = [] + func append(_ command: DaemonCommand) { commands.append(command) } + } + + private static func post(_ id: Int, _ body: String) -> MailroomPost { + MailroomPost( + id: id, at: Date(timeIntervalSince1970: TimeInterval(id)), authorID: nil, + author: "a human", topic: nil, body: body) + } + + private static let fetch = DaemonCommand.mailbox( + projectPath: project.path, query: MailboxQuery(selection: .board, fullBodies: true)) + + @Test + @MainActor + func aProjectAsksForTheRoomOnlyWhenTheDigestSaysItsCopyIsStale() async { + let sent = Sent() + var room = LoopGraph(project: Self.project) + room.mailroom = [Self.post(1, "claiming #12")] + let store = TestStore( + initialState: ProjectFeature.State(graph: LoopGraph(project: Self.project)) + ) { + ProjectFeature() + } withDependencies: { + $0.orchestratorClient.send = { command in await sent.append(command) } + } + store.exhaustivity = .off + + // A snapshot whose digest names a post this project has never seen: ask for it. + await store.send(.daemonEvent(.graphChanged(room.wireSnapshot()))) + await store.finish() + #expect(await sent.commands == [Self.fetch]) + #expect(store.state.graph.mailroom.isEmpty) + // Not yet: the digest is held only once the posts it describes have arrived. + #expect(store.state.graph.mailroomDigest == nil) + + // The answer is the copy from here on. + let mailbox = Mailroom.serve( + MailboxQuery(selection: .board, fullBodies: true), from: room.mailroom + ) { _ in nil } + await store.send(.daemonEvent(.mailbox(projectPath: Self.project.path, mailbox: mailbox))) + #expect(store.state.graph.mailroom == room.mailroom) + #expect(store.state.graph.mailroomDigest == mailbox.digest) + + // An unrelated broadcast — a presence tick, a rename — carries the copy over and + // asks for nothing. + var renamed = room + renamed.nodes.append(LoopNode(title: "Newcomer", loopType: .turnBased)) + await store.send(.daemonEvent(.graphChanged(renamed.wireSnapshot()))) + await store.finish() + #expect(await sent.commands == [Self.fetch]) + #expect(store.state.graph.mailroom == room.mailroom) + #expect(store.state.graph.nodes.count == 1) + + // A post landing changes the digest, and the room is asked for again. + var grown = renamed + grown.mailroom.append(Self.post(2, "done with #12")) + await store.send(.daemonEvent(.graphChanged(grown.wireSnapshot()))) + await store.finish() + #expect(await sent.commands == [Self.fetch, Self.fetch]) + #expect(store.state.graph.mailroom == room.mailroom) + } + + /// An answer that never lands — a swallowed send, a refusal — must not leave the + /// project holding the new digest with the old posts: the next broadcast carrying + /// that digest asks again. + @Test + @MainActor + func aProjectWhoseFetchNeverLandsAsksAgain() async { + let sent = Sent() + var room = LoopGraph(project: Self.project) + room.mailroom = [Self.post(1, "claiming #12")] + let store = TestStore( + initialState: ProjectFeature.State(graph: LoopGraph(project: Self.project)) + ) { + ProjectFeature() + } withDependencies: { + $0.orchestratorClient.send = { command in await sent.append(command) } + } + store.exhaustivity = .off + + await store.send(.daemonEvent(.graphChanged(room.wireSnapshot()))) + await store.finish() + #expect(await sent.commands.count == 1) + + // The answer never comes. The next broadcast carries the same digest. + await store.send(.daemonEvent(.graphChanged(room.wireSnapshot()))) + await store.finish() + #expect(await sent.commands.count == 2) + #expect(store.state.graph.mailroom.isEmpty) + + // Once it lands, the same digest is current and nothing asks again. + let mailbox = Mailroom.serve( + MailboxQuery(selection: .board, fullBodies: true), from: room.mailroom + ) { _ in nil } + await store.send(.daemonEvent(.mailbox(projectPath: Self.project.path, mailbox: mailbox))) + await store.send(.daemonEvent(.graphChanged(room.wireSnapshot()))) + await store.finish() + #expect(await sent.commands.count == 2) + #expect(store.state.graph.mailroom == room.mailroom) + } + + /// The app's first snapshot of a project is held the same way: an answer that never + /// lands is asked for again on the next broadcast. + @Test + @MainActor + func theAppHoldsAFirstSnapshotWithoutItsDigest() async { + let sent = Sent() + var room = LoopGraph(project: Self.project) + room.mailroom = [Self.post(1, "claiming #12")] + let store = TestStore(initialState: AppFeature.State()) { + AppFeature() + } withDependencies: { + $0.orchestratorClient.send = { command in await sent.append(command) } + } + store.exhaustivity = .off + + await store.send(.daemonEvent(.graphChanged(room.wireSnapshot()))) + await store.finish() + #expect(await sent.commands == [Self.fetch]) + #expect(store.state.projects[id: Self.project.path]?.graph.mailroomDigest == nil) + + await store.send(.daemonEvent(.graphChanged(room.wireSnapshot()))) + await store.receive(\.projects) + await store.finish() + #expect(await sent.commands == [Self.fetch, Self.fetch]) + } + + /// A snapshot from a daemon that still ships posts keeps its own — nothing is + /// carried over it, and nothing is asked for. + @Test + @MainActor + func aSnapshotStillCarryingPostsIsTakenAsItIs() async { + let sent = Sent() + var room = LoopGraph(project: Self.project) + room.mailroom = [Self.post(1, "claiming #12")] + let store = TestStore( + initialState: ProjectFeature.State(graph: LoopGraph(project: Self.project)) + ) { + ProjectFeature() + } withDependencies: { + $0.orchestratorClient.send = { command in await sent.append(command) } + } + store.exhaustivity = .off + + await store.send(.daemonEvent(.graphChanged(room))) + await store.finish() + #expect(await sent.commands.isEmpty) + #expect(store.state.graph.mailroom == room.mailroom) + } + + /// The first sight of a project is the app's, not the project reducer's — the + /// snapshot that *creates* the project row is what asks for its room; a project + /// with an empty room is not asked about. + @Test + @MainActor + func theAppAsksOnAProjectsFirstSnapshot() async { + let sent = Sent() + var room = LoopGraph(project: Self.project) + room.mailroom = [Self.post(1, "claiming #12")] + let store = TestStore(initialState: AppFeature.State()) { + AppFeature() + } withDependencies: { + $0.orchestratorClient.send = { command in await sent.append(command) } + } + store.exhaustivity = .off + + await store.send(.daemonEvent(.graphChanged(room.wireSnapshot()))) + await store.finish() + #expect(await sent.commands == [Self.fetch]) + + let quiet = LoopGraph(project: ProjectRef(path: "/tmp/project-b", name: "project-b")) + await store.send(.daemonEvent(.graphChanged(quiet.wireSnapshot()))) + await store.finish() + #expect(await sent.commands == [Self.fetch]) + } + + /// An open workspace reads the room off its own graph: the answer reaches it, and + /// the next broadcast reaches it with the copy still on. + @Test + @MainActor + func anOpenWorkspaceReadsTheSameRoom() async { + let node = LoopNode(title: "Research", checkDescription: "Sound?") + var room = LoopGraph(project: Self.project, nodes: [node]) + room.mailroom = [Self.post(1, "claiming #12")] + var state = AppFeature.State() + state.projects.append(ProjectFeature.State(graph: room.wireSnapshot())) + state.openLoop = LoopWorkspaceFeature.State( + node: node, layout: .defaultLayout(forNode: node.id), projectPath: Self.project.path, + projectName: Self.project.name) + let store = TestStore(initialState: state) { + AppFeature() + } withDependencies: { + $0.orchestratorClient.send = { _ in } + } + store.exhaustivity = .off + + let mailbox = Mailroom.serve( + MailboxQuery(selection: .board, fullBodies: true), from: room.mailroom + ) { _ in nil } + await store.send(.daemonEvent(.mailbox(projectPath: Self.project.path, mailbox: mailbox))) + // The project's copy is the project reducer's to set, one hop down. + await store.receive(\.projects) + #expect(store.state.projects[id: Self.project.path]?.graph.mailroom == room.mailroom) + #expect(store.state.openLoop?.graph.mailroom == room.mailroom) + + await store.send(.daemonEvent(.graphChanged(room.wireSnapshot()))) + await store.finish() + #expect(store.state.openLoop?.graph.mailroom == room.mailroom) + #expect(store.state.openLoop?.graph.nodes.count == 1) + } +} diff --git a/graphcode/Tests/RemoteCLIShimTests.swift b/graphcode/Tests/RemoteCLIShimTests.swift index 4dc44045..d0795f2e 100644 --- a/graphcode/Tests/RemoteCLIShimTests.swift +++ b/graphcode/Tests/RemoteCLIShimTests.swift @@ -180,10 +180,21 @@ struct RemoteCLIShimTests { guard let command = try? JSONDecoder().decode(DaemonCommand.self, from: data) else { return } received.append(command) - let event: DaemonEvent = - command == .listRecentProjects - ? .recentProjectsListed([ProjectRef(path: Self.project, name: "widget")]) - : .graphChanged(graph) + // The shapes the real daemon answers with: a snapshot carries the room's + // digest and no posts, and a mailbox request is answered off the room itself. + let event: DaemonEvent + switch command { + case .listRecentProjects: + event = .recentProjectsListed([ProjectRef(path: Self.project, name: "widget")]) + case .mailbox(_, let query): + event = .mailbox( + projectPath: Self.project, + mailbox: Mailroom.serve(query, from: graph.mailroom) { + graph.nodes[id: $0]?.lastMailroomRead + }) + default: + event = .graphChanged(graph.wireSnapshot()) + } guard let reply = try? JSONEncoder().encode(event), (try? FramedMessageIO.writeFrame(reply, to: client)) != nil else { return } @@ -223,6 +234,18 @@ struct RemoteCLIShimTests { /// The board verbs, split into their own extension: `RemoteCLIShimTests` sits at /// swiftlint's 350-line `type_body_length` error without them. extension RemoteCLIShimTests { + /// Served the way `GraphStore.mailbox` serves it; the shim asks the daemon for exactly + /// this, so the renderers on both sides are handed the same posts. + fileprivate static func served( + _ graph: LoopGraph, _ selection: MailboxQuery.Selection, search: String? = nil, + fullBodies: Bool? = nil + ) -> Mailbox { + Mailroom.serve( + MailboxQuery(selection: selection, search: search, fullBodies: fullBodies), + from: graph.mailroom + ) { graph.nodes[id: $0]?.lastMailroomRead } + } + /// The board the parity tests drive: a cursor that leaves one post read, a topic and /// a bare byline, a body long enough that a headline has to cut it, and one carrying /// the slashes and the em dash that separate Swift's JSON escaping from Python's. @@ -262,27 +285,45 @@ extension RemoteCLIShimTests { let (graph, reader) = Self.board() let session = ["ZMX_SESSION": "graphcode-\(reader.id.uuidString)"] + func served( + _ selection: MailboxQuery.Selection, search: String? = nil, fullBodies: Bool? = nil + ) -> Mailbox { + Self.served(graph, selection, search: search, fullBodies: fullBodies) + } + let project = graph.project let cases: [(arguments: [String], environment: [String: String], expected: String)] = [ - (["mailroom", "list", Self.project], [:], GraphcodeCommand.renderMailroom(graph)), + ( + ["mailroom", "list", Self.project], [:], + GraphcodeCommand.renderMailroom( + served(.board, fullBodies: true), project: project, unread: false) + ), ( ["mailroom", "list", Self.project, "--search", "RED"], [:], - GraphcodeCommand.renderMailroom(graph, search: "RED") + GraphcodeCommand.renderMailroom( + served(.board, search: "RED", fullBodies: true), project: project, unread: false, + search: "RED") ), ( ["mailroom", "list", Self.project, "--search", "nothing-matches"], [:], - GraphcodeCommand.renderMailroom(graph, search: "nothing-matches") + GraphcodeCommand.renderMailroom( + served(.board, search: "nothing-matches", fullBodies: true), project: project, + unread: false, search: "nothing-matches") ), ( ["mailroom", "sync", Self.project], session, - GraphcodeCommand.renderMailroom(graph, unreadFor: reader.id, autoTriage: true) + GraphcodeCommand.renderMailroom( + served(.unread(reader: reader.id)), project: project, unread: true) ), ( ["mailroom", "sync", Self.project, "--headlines"], session, - GraphcodeCommand.renderMailroom(graph, unreadFor: reader.id, headlines: true) + GraphcodeCommand.renderMailroom( + served(.unread(reader: reader.id), fullBodies: false), project: project, unread: true, + headlines: true) ), ( ["mailroom", "sync", Self.project, "--full"], session, - GraphcodeCommand.renderMailroom(graph, unreadFor: reader.id) + GraphcodeCommand.renderMailroom( + served(.unread(reader: reader.id), fullBodies: true), project: project, unread: true) ), ( ["mailroom", "read", Self.project, "2"], [:], @@ -290,11 +331,15 @@ extension RemoteCLIShimTests { ), ( ["mailroom", "list", Self.project, "--json"], [:], - GraphcodeCommand.renderMailroomJSON(graph) + GraphcodeCommand.renderMailroomJSON(served(.board, fullBodies: true)) ), ( ["mailroom", "sync", Self.project, "--json"], session, - GraphcodeCommand.renderMailroomJSON(graph, unreadFor: reader.id) + GraphcodeCommand.renderMailroomJSON(served(.unread(reader: reader.id), fullBodies: true)) + ), + ( + ["mailroom", "sync", Self.project, "--mark"], session, + "marked read up to #3" ), ] @@ -317,7 +362,11 @@ extension RemoteCLIShimTests { graph.nodes.append(reader) let empty = try runShim(["mailroom", "list", Self.project], graph: graph) - #expect(empty.stdout == GraphcodeCommand.renderMailroom(graph) + "\n") + #expect( + empty.stdout + == GraphcodeCommand.renderMailroom( + Mailroom.serve(MailboxQuery(selection: .board, fullBodies: true), from: []) { _ in nil }, + project: graph.project, unread: false) + "\n") graph.mailroom = [ MailroomPost( @@ -327,9 +376,6 @@ extension RemoteCLIShimTests { let synced = try runShim( ["mailroom", "sync", Self.project], environment: ["ZMX_SESSION": "graphcode-\(reader.id.uuidString)"], graph: graph) - #expect( - synced.stdout - == GraphcodeCommand.renderMailroom(graph, unreadFor: reader.id, autoTriage: true) + "\n") #expect(synced.stdout == "no unread posts\n") } @@ -377,7 +423,9 @@ extension RemoteCLIShimTests { environment: ["ZMX_SESSION": "graphcode-\(reader.id.uuidString)"], graph: graph) return ( run.stdout, - GraphcodeCommand.renderMailroom(graph, unreadFor: reader.id, autoTriage: true) + "\n" + GraphcodeCommand.renderMailroom( + Self.served(graph, .unread(reader: reader.id)), project: graph.project, unread: true) + + "\n" ) } @@ -412,7 +460,7 @@ extension RemoteCLIShimTests { environment: ["ZMX_SESSION": "graphcode-\(reader.id.uuidString)"], graph: graph) let readerLine = try #require( GraphcodeCommand.renderMailroomStatusLine(graph, readerID: reader.id)) - #expect(readerLine == "mailroom: 3 posts, 2 unread for you") + #expect(readerLine == "mailroom: 3 posts, unread mail for you") #expect(asReader.stdout.hasSuffix(" " + readerLine + "\n")) // A human shell, and a loop this graph has never heard of, both get the plain @@ -443,7 +491,8 @@ extension RemoteCLIShimTests { ["mailroom", "post", Self.project, "--topic", "claims", "issue", "#12", "is", "mine"], environment: session, graph: graph) #expect(posted.status == 0) - #expect(posted.stdout == GraphcodeCommand.renderPosted(graph) + "\n") + #expect(posted.stdout == GraphcodeCommand.renderPosted(graph, topic: "claims") + "\n") + #expect(posted.stdout == "posted #3 (claims)\n") #expect( posted.commands.dropFirst().first == .graphCommand( @@ -521,7 +570,8 @@ extension RemoteCLIShimTests { ["mailroom", "sync", Self.project, "--headlines"], environment: ["ZMX_SESSION": "graphcode-\(reader.id.uuidString)"], graph: graph) let expected = GraphcodeCommand.renderMailroom( - graph, unreadFor: reader.id, headlines: true) + Self.served(graph, .unread(reader: reader.id), fullBodies: false), project: graph.project, + unread: true, headlines: true) #expect(run.stdout == expected + "\n", "body \(index) cut differently") } } @@ -546,7 +596,9 @@ extension RemoteCLIShimTests { for needle in ["éclair", "e\u{0301}clair", "Amélie", "café", "ÉCLAIR"] { let run = try runShim( ["mailroom", "list", Self.project, "--search", needle], graph: graph) - let expected = GraphcodeCommand.renderMailroom(graph, search: needle) + let expected = GraphcodeCommand.renderMailroom( + Self.served(graph, .board, search: needle, fullBodies: true), project: graph.project, + unread: false, search: needle) #expect(run.stdout == expected + "\n", "search '\(needle)' diverged") #expect(!expected.hasPrefix("no posts match"), "fixture no longer exercises a match") } @@ -575,24 +627,41 @@ extension RemoteCLIShimTests { #expect(run.stdout == GraphcodeCommand.render(graph.mailroom[0]) + "\n") #expect(run.stdout.contains("#5 () from a human")) + // The sequence number comes off the digest and the topic is the caller's — spelled + // the way the daemon keeps it, and absent when there was none. let posted = try runShim( ["mailroom", "post", Self.project, "anything"], graph: graph) #expect(posted.stdout == GraphcodeCommand.renderPosted(graph) + "\n") - #expect(posted.stdout == "posted #5 ()\n") + #expect(posted.stdout == "posted #5\n") + let spelled = try runShim( + ["mailroom", "post", Self.project, "--topic", " Claims ", "anything"], graph: graph) + #expect(spelled.stdout == GraphcodeCommand.renderPosted(graph, topic: " Claims ") + "\n") + #expect(spelled.stdout == "posted #5 (claims)\n") } - /// `read` and `list` send nothing past the open — the snapshot already carries the - /// board — so neither can move a cursor by accident. + /// `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. @Test - func readAndListSendNoCommandPastTheOpen() throws { + func readAndListAskTheMailboxAndMoveNoCursor() throws { let (graph, _) = Self.board() - for arguments in [ - ["mailroom", "read", Self.project, "3"], ["mailroom", "list", Self.project], - ] { - let run = try runShim(arguments, graph: graph) - #expect(run.status == 0) - #expect(run.commands == [.openProject(path: Self.project)]) - } + let read = try runShim(["mailroom", "read", Self.project, "3"], graph: graph) + #expect(read.status == 0) + #expect( + read.commands == [ + .openProject(path: Self.project), + .mailbox(projectPath: Self.project, query: MailboxQuery(selection: .post(id: 3))), + ]) + + let list = try runShim(["mailroom", "list", Self.project, "--search", "gate"], graph: graph) + #expect(list.status == 0) + #expect( + list.commands == [ + .openProject(path: Self.project), + .mailbox( + projectPath: Self.project, + query: MailboxQuery(selection: .board, search: "gate", fullBodies: true)), + ]) } /// The cursor verbs refuse a human shell up front, in the Swift CLI's own wording,