From 3bca866cd54c291195135ded603ccd29961d5e26 Mon Sep 17 00:00:00 2001 From: scgopi Date: Sun, 6 Sep 2026 10:13:52 -0700 Subject: [PATCH 1/2] Serve the Mailroom from a bounded mailbox request, not every broadcast (#288) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A .graphChanged carried the whole room — three quarters of a 176 KB frame on the live graph, to every client on every change. Snapshots now carry LoopGraph.mailroomDigest in the posts' place (wireSnapshot()), and posts leave the daemon only through DaemonCommand.mailbox, answered on the asking connection with a Mailbox: the whole room, one loop's unread slice, or one post, searched and triaged to headlines by Mailroom.serve. The Swift CLI, the remote Python shim and the app's rail all read through it; the app carries its copy across broadcasts and asks again only when the digest says it is stale. Measured on an isolated daemon seeded with this project's live graph (31 loops, 166 posts): graphChanged 193,188 B -> 50,730 B (-74%); a default 'mail inbox' answer is a 36 KB headline mailbox. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DeGL2CxuGmq16RSZpJYm2N --- .../Sources/CLI/GraphcodeCommand.swift | 136 +++----- GraphcodeKit/Sources/Domain/LoopGraph.swift | 42 ++- GraphcodeKit/Sources/GraphStore.swift | 14 +- GraphcodeKit/Sources/IPC/DaemonProtocol.swift | 30 +- GraphcodeKit/Sources/ProjectRegistry.swift | 17 + .../Sources/Sessions/RemoteGraphAccess.swift | 176 +++++----- MailroomKit/Sources/Mailroom.swift | 164 +++++++++ Package.swift | 2 +- Project.swift | 3 +- graphcode-cli/Sources/main.swift | 131 +++++--- .../Sources/Features/App/AppFeature.swift | 33 +- .../LoopWorkspace/LoopWorkspaceFeature.swift | 5 +- .../Project/ProjectFeature+Mailroom.swift | 30 ++ .../Features/Project/ProjectFeature.swift | 12 +- graphcode/Tests/MailboxTests.swift | 310 ++++++++++++++++++ graphcode/Tests/MailroomBudgetTests.swift | 10 +- graphcode/Tests/MailroomCommandTests.swift | 123 +++++-- graphcode/Tests/MailroomFetchTests.swift | 162 +++++++++ graphcode/Tests/RemoteCLIShimTests.swift | 133 ++++++-- 19 files changed, 1231 insertions(+), 302 deletions(-) create mode 100644 graphcode/Sources/Features/Project/ProjectFeature+Mailroom.swift create mode 100644 graphcode/Tests/MailboxTests.swift create mode 100644 graphcode/Tests/MailroomFetchTests.swift 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..df84a42b 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 open on this 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..354407e9 100644 --- a/MailroomKit/Sources/Mailroom.swift +++ b/MailroomKit/Sources/Mailroom.swift @@ -201,3 +201,167 @@ 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) + } + self.init(count: posts.count, latestID: posts.last?.id ?? 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..2421823e 100644 --- a/graphcode/Sources/Features/App/AppFeature.swift +++ b/graphcode/Sources/Features/App/AppFeature.swift @@ -349,17 +349,23 @@ struct AppFeature { } else { state.projects.append(ProjectFeature.State(graph: graph)) } + // 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 +374,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 +394,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 +842,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..10216ef8 --- /dev/null +++ b/graphcode/Sources/Features/Project/ProjectFeature+Mailroom.swift @@ -0,0 +1,30 @@ +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. + 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 + return (carried, broadcast.boardDigest != current.boardDigest) + } + + /// 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..4f8ed79e 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,16 @@ 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 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..67a38d23 --- /dev/null +++ b/graphcode/Tests/MailroomFetchTests.swift @@ -0,0 +1,162 @@ +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) + #expect(store.state.graph.mailroomDigest == room.wireSnapshot().mailroomDigest) + + // 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) + + // 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) + } + + /// 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, From f47b4f3ecfd731558f8936d8834ec1fcb07c3d87 Mon Sep 17 00:00:00 2001 From: scgopi Date: Sun, 6 Sep 2026 11:09:27 -0700 Subject: [PATCH 2/2] Hold the room's digest only once its posts arrived; two nits from review A project that adopted a broadcast's digest before the mailbox answer landed judged every later broadcast fresh, so an answer that never came was never asked for again. The digest a project holds is now the one its posts came from (set by the .mailbox answer), and a first snapshot is held without one. MailroomDigest reads latestID the way Mailroom.nextID does, and DaemonCommand.mailbox's doc says what the routing actually requires. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DeGL2CxuGmq16RSZpJYm2N --- GraphcodeKit/Sources/IPC/DaemonProtocol.swift | 4 +- MailroomKit/Sources/Mailroom.swift | 4 +- .../Sources/Features/App/AppFeature.swift | 5 +- .../Project/ProjectFeature+Mailroom.swift | 14 ++++ .../Features/Project/ProjectFeature.swift | 1 + graphcode/Tests/MailroomFetchTests.swift | 69 ++++++++++++++++++- 6 files changed, 91 insertions(+), 6 deletions(-) diff --git a/GraphcodeKit/Sources/IPC/DaemonProtocol.swift b/GraphcodeKit/Sources/IPC/DaemonProtocol.swift index df84a42b..aeed896a 100644 --- a/GraphcodeKit/Sources/IPC/DaemonProtocol.swift +++ b/GraphcodeKit/Sources/IPC/DaemonProtocol.swift @@ -43,8 +43,8 @@ public enum DaemonCommand: Codable, Sendable, Equatable { /// 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 open on this connection, the same - /// rule as `.graphCommand`. + /// 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) } diff --git a/MailroomKit/Sources/Mailroom.swift b/MailroomKit/Sources/Mailroom.swift index 354407e9..5808a585 100644 --- a/MailroomKit/Sources/Mailroom.swift +++ b/MailroomKit/Sources/Mailroom.swift @@ -243,7 +243,9 @@ public struct MailroomDigest: Codable, Equatable, Sendable { mix(post.authorID?.uuidString ?? "") mix(post.author) } - self.init(count: posts.count, latestID: posts.last?.id ?? 0, fingerprint: hash) + // 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 } diff --git a/graphcode/Sources/Features/App/AppFeature.swift b/graphcode/Sources/Features/App/AppFeature.swift index 2421823e..69f52d90 100644 --- a/graphcode/Sources/Features/App/AppFeature.swift +++ b/graphcode/Sources/Features/App/AppFeature.swift @@ -344,10 +344,11 @@ 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 diff --git a/graphcode/Sources/Features/Project/ProjectFeature+Mailroom.swift b/graphcode/Sources/Features/Project/ProjectFeature+Mailroom.swift index 10216ef8..b8279c57 100644 --- a/graphcode/Sources/Features/Project/ProjectFeature+Mailroom.swift +++ b/graphcode/Sources/Features/Project/ProjectFeature+Mailroom.swift @@ -8,15 +8,29 @@ extension ProjectFeature { /// — 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. diff --git a/graphcode/Sources/Features/Project/ProjectFeature.swift b/graphcode/Sources/Features/Project/ProjectFeature.swift index 4f8ed79e..401da885 100644 --- a/graphcode/Sources/Features/Project/ProjectFeature.swift +++ b/graphcode/Sources/Features/Project/ProjectFeature.swift @@ -373,6 +373,7 @@ struct ProjectFeature { 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/MailroomFetchTests.swift b/graphcode/Tests/MailroomFetchTests.swift index 67a38d23..bc012b35 100644 --- a/graphcode/Tests/MailroomFetchTests.swift +++ b/graphcode/Tests/MailroomFetchTests.swift @@ -48,7 +48,8 @@ struct MailroomFetchTests { await store.finish() #expect(await sent.commands == [Self.fetch]) #expect(store.state.graph.mailroom.isEmpty) - #expect(store.state.graph.mailroomDigest == room.wireSnapshot().mailroomDigest) + // 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( @@ -56,6 +57,7 @@ struct MailroomFetchTests { ) { _ 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. @@ -76,6 +78,71 @@ struct MailroomFetchTests { #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